Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/maintainers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"repo": "TanStack/ai",
"maintainers": [
{
"github": "tombeckenham",
"discord": null,
"maxOpenAssignments": 10
},
{
"github": "AlemTuzlak",
"discord": null,
"maxOpenAssignments": 15
},
{
"github": "jherr",
"discord": null,
"maxOpenAssignments": 8
}
],
"sla": {
"firstResponseHours": 24,
"followUpResponseHours": 48,
"staleAuthorDays": 14
},
"spam": {
"maxAccountAgeDays": 30,
"maxChangedLines": 30
},
"botAllowlist": ["renovate", "dependabot", "github-actions"],
"maxCommentsPerRun": 25
}
44 changes: 44 additions & 0 deletions .github/workflows/maintainer-scorecard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Maintainer Scorecard

# Daily maintainer to-do digest posted to Discord: SLA breaches, ready-to-merge
# PRs, per-maintainer queues, new/stale items, and response-time stats.
# Requires the DISCORD_MAINTAINER_WEBHOOK repo secret; without it the digest
# only lands in the workflow step summary.

on:
schedule:
# 21:00 UTC β‰ˆ start of the Australian workday
- cron: '0 21 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Print the digest instead of posting to Discord'
type: boolean
default: true

concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false

permissions: {}

jobs:
scorecard:
name: Scorecard
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
Comment thread
coderabbitai[bot] marked this conversation as resolved.
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Tools
uses: TanStack/config/.github/setup@190f659075ff0845850e330883eb26d7ffd0671f # main
- name: Build and post scorecard
run: pnpm maintainer:scorecard
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_MAINTAINER_WEBHOOK }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '1' || '' }}
43 changes: 43 additions & 0 deletions .github/workflows/maintainer-sweep.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Maintainer Sweep

# Scheduled triage sweep: assigns open PRs/issues to maintainers, posts a
# one-time ack + pre-review checklist, and reconciles waiting-on labels.
# API-only β€” PR code is never checked out or executed (no pull_request_target).

on:
schedule:
- cron: '17 */3 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Print planned actions without executing them'
type: boolean
default: true

concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false

permissions: {}

jobs:
sweep:
name: Sweep
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Tools
uses: TanStack/config/.github/setup@190f659075ff0845850e330883eb26d7ffd0671f # main
- name: Run sweep
run: pnpm maintainer:sweep
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '1' || '' }}
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"clean": "pnpm --filter \"./packages/**\" run clean",
"clean:all": "git clean -fdx --exclude=\"!.env\"",
"test": "pnpm run test:ci",
"test:pr": "pnpm run test:react-native && nx affected --targets=test:sherif,test:knip,test:docs,test:kiira,test:eslint,test:lib,test:types,test:build,build && pnpm test:dts",
"test:ci": "pnpm run test:react-native && nx run-many --targets=test:sherif,test:knip,test:docs,test:kiira,test:eslint,test:lib,test:types,test:build,build && pnpm test:dts",
"test:pr": "pnpm run test:react-native && nx affected --targets=test:sherif,test:knip,test:docs,test:kiira,test:maintainer,test:eslint,test:lib,test:types,test:build,build && pnpm test:dts",
"test:ci": "pnpm run test:react-native && nx run-many --targets=test:sherif,test:knip,test:docs,test:kiira,test:maintainer,test:eslint,test:lib,test:types,test:build,build && pnpm test:dts",
"test:eslint": "nx affected --target=test:eslint --exclude=examples/**,testing/**",
"test:sherif": "sherif",
"test:lib": "nx affected --targets=test:lib --exclude=examples/**,testing/**",
Expand All @@ -26,6 +26,9 @@
"test:knip": "knip",
"test:docs": "tsx scripts/verify-links.ts",
"test:dts": "node scripts/scan-dangling-dts.mjs",
"test:maintainer": "vitest run --root scripts/maintainer",
"maintainer:sweep": "tsx scripts/maintainer/sweep.ts",
"maintainer:scorecard": "tsx scripts/maintainer/scorecard.ts",
"test:kiira": "kiira check",
"test:react-native": "pnpm --filter @tanstack/ai-react-native-smoke smoke",
"test:e2e": "pnpm --filter @tanstack/ai-e2e test:e2e",
Expand Down Expand Up @@ -55,6 +58,7 @@
"test:dts",
"test:kiira",
"test:knip",
"test:maintainer",
"test:sherif"
]
},
Expand Down
61 changes: 61 additions & 0 deletions scripts/maintainer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Maintainer toolset

Automation that keeps on top of open PRs, issues, and discussions:

- **Sweep** (`.github/workflows/maintainer-sweep.yml`, every 3h): assigns each
open PR/issue to a maintainer, posts a one-time ack comment with a
deterministic pre-review checklist, asks for a reproduction on bug reports
that lack one, and reconciles `waiting-on: *` / `ready-to-merge` /
`needs-repro` / `has-pr` labels.
- **Scorecard** (`.github/workflows/maintainer-scorecard.yml`, daily): posts a
to-do digest to Discord β€” SLA breaches, ready-to-merge PRs, per-maintainer
queues (including which items got fresh contributor replies), new/flagged/
stale items, unanswered discussions, and response-time stats.

Both are **stateless and API-only**: every metric (first-response time, 7-day
deltas, SLA clocks) is recomputed from GitHub timelines each run, idempotency
comes from HTML markers in bot comments plus visible assignment/label state,
and PR code is never checked out or executed (no `pull_request_target`).

## Configuration β€” `.github/maintainers.json`

| Field | Meaning |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `maintainers[].github` | GitHub login; the roster defines who counts as "a maintainer responded". |
| `maintainers[].discord` | Discord user id (snowflake) for digest @-mentions; `null` β†’ plain bold name. |
| `maintainers[].areas` | Optional file globs (`**`, `*`, `?`) giving this maintainer routing priority for matching PRs (issues match package names in title/body). Omitted/empty (the default) β†’ assignment is pure least-loaded rotation. |
| `maintainers[].maxOpenAssignments` | Routing skips a maintainer at this many open assignments. |
| `sla.firstResponseHours` | Deadline for the first human response on a new item (default 24h). |
| `sla.followUpResponseHours` | Deadline for answering a follow-up message (default 48h). |
| `sla.staleAuthorDays` | Author silence before an item is a nudge/close candidate (default 14d). |
| `spam.*` | Drive-by/bounty heuristic: account younger than `maxAccountAgeDays` **and** diff ≀ `maxChangedLines` **and** no linked issue β†’ flagged for human judgment, never auto-acked. |
| `botAllowlist` | Logins always treated as bots (excluded from human metrics and acks). |
| `maxCommentsPerRun` | Safety cap on comments per sweep; overflow defers to the next run. |

## Secrets

- `DISCORD_MAINTAINER_WEBHOOK` β€” Discord webhook URL for the daily digest
(Server Settings β†’ Integrations β†’ Webhooks). Without it, the digest lands in
the workflow step summary only.
- The sweep uses the default `GITHUB_TOKEN` β€” no extra setup.

## Running locally

```bash
# Dry-run: prints the mutation plan / digest, changes nothing
pnpm maintainer:sweep --dry-run
pnpm maintainer:scorecard --dry-run

# Tests
pnpm test:maintainer
```

Both entries use `GITHUB_TOKEN` if set, else fall back to `gh auth token`.

## Layout

Pure logic (`classify.ts` waiting-on state machine + SLA clocks, `route.ts`
assignment, `metrics.ts` scorecard, `glob.ts`) is fixture-tested and does no
I/O. `collect.ts` (GraphQL, read-only), `actions.ts` (REST mutations),
`discord.ts` (webhook) are the I/O seams; `sweep.ts` / `scorecard.ts` are the
entry points.
142 changes: 142 additions & 0 deletions scripts/maintainer/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Mutation planning + execution for the sweep. The sweep builds a plan of
* `Mutation` objects first (printable in dry-run mode), then executes them
* through the REST API.
*/

import type { GitHubClient } from './github'

export const MANAGED_LABELS: Array<{
name: string
color: string
description: string
}> = [
{
name: 'waiting-on: maintainer',
color: 'd93f0b',
description: 'The ball is in the maintainers’ court',
},
{
name: 'waiting-on: author',
color: 'fbca04',
description: 'Waiting for the author to respond or update',
},
{
name: 'ready-to-merge',
color: '0e8a16',
description: 'Approved, CI green, no conflicts',
},
{
name: 'needs-repro',
color: 'e99695',
description: 'Needs a minimal reproduction before it can be worked on',
},
{
name: 'has-pr',
color: 'bfd4f2',
description: 'An open PR references this issue',
},
]

const MANAGED_LABEL_NAMES = new Set(MANAGED_LABELS.map((l) => l.name))

export type Mutation =
| { kind: 'assign'; number: number; assignee: string }
| { kind: 'comment'; number: number; body: string; note: string }
| { kind: 'add-labels'; number: number; labels: Array<string> }
| { kind: 'remove-label'; number: number; label: string }

export function describeMutation(m: Mutation): string {
switch (m.kind) {
case 'assign':
return `assign #${m.number} β†’ @${m.assignee}`
case 'comment':
return `comment on #${m.number} (${m.note})`
case 'add-labels':
return `label #${m.number} + [${m.labels.join(', ')}]`
case 'remove-label':
return `label #${m.number} βˆ’ [${m.label}]`
}
}

/**
* Diff an item's current labels against the desired managed labels; labels
* outside MANAGED_LABELS are never touched.
*/
export function planLabelChanges(
number: number,
currentLabels: Array<string>,
desiredManaged: Array<string>,
): Array<Mutation> {
const current = new Set(
currentLabels.filter((l) => MANAGED_LABEL_NAMES.has(l)),
)
const desired = new Set(desiredManaged)
const mutations: Array<Mutation> = []
const toAdd = [...desired].filter((l) => !current.has(l))
if (toAdd.length > 0) {
mutations.push({ kind: 'add-labels', number, labels: toAdd })
}
for (const label of current) {
if (!desired.has(label)) {
mutations.push({ kind: 'remove-label', number, label })
}
}
return mutations
}

export async function ensureManagedLabels(
client: GitHubClient,
repo: string,
): Promise<void> {
for (const label of MANAGED_LABELS) {
try {
await client.rest('POST', `/repos/${repo}/labels`, label)
} catch (error) {
// 422 = already exists; anything else should surface.
if (!(error instanceof Error) || !error.message.includes('422')) {
throw error
}
}
}
}

export async function executeMutations(
client: GitHubClient,
repo: string,
mutations: Array<Mutation>,
): Promise<void> {
for (const m of mutations) {
switch (m.kind) {
case 'assign':
await client.rest(
'POST',
`/repos/${repo}/issues/${m.number}/assignees`,
{
assignees: [m.assignee],
},
)
break
case 'comment':
await client.rest(
'POST',
`/repos/${repo}/issues/${m.number}/comments`,
{
body: m.body,
},
)
break
case 'add-labels':
await client.rest('POST', `/repos/${repo}/issues/${m.number}/labels`, {
labels: m.labels,
})
break
case 'remove-label':
await client.rest(
'DELETE',
`/repos/${repo}/issues/${m.number}/labels/${encodeURIComponent(m.label)}`,
)
break
}
}
}
Loading
Loading