-
-
Notifications
You must be signed in to change notification settings - Fork 280
feat: maintainer toolset — scheduled triage sweep + daily Discord scorecard #950
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9cabe22
0ff9f17
96f3fe0
5169c28
fac410b
553c6d3
657c5ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| { | ||
| "repo": "TanStack/ai", | ||
| "maintainers": [ | ||
| { | ||
| "github": "tombeckenham", | ||
| "discord": "1095938354018799666", | ||
| "maxOpenAssignments": 10 | ||
| }, | ||
| { | ||
| "github": "AlemTuzlak", | ||
| "discord": "230092827684634625", | ||
| "maxOpenAssignments": 15 | ||
| }, | ||
| { | ||
| "github": "jherr", | ||
| "discord": "501863722948624385", | ||
| "maxOpenAssignments": 8 | ||
| } | ||
| ], | ||
| "sla": { | ||
| "firstResponseHours": 24, | ||
| "followUpResponseHours": 48, | ||
| "staleAuthorDays": 14 | ||
| }, | ||
| "spam": { | ||
| "maxAccountAgeDays": 30, | ||
| "maxChangedLines": 30 | ||
| }, | ||
| "botAllowlist": [ | ||
| "renovate", | ||
| "dependabot", | ||
| "github-actions" | ||
| ], | ||
| "maxCommentsPerRun": 25 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| 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 | ||
| issues: read | ||
| pull-requests: read | ||
| discussions: read | ||
| 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' || '' }} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| 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 | ||
| discussions: read | ||
| 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' || '' }} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| # Maintainer toolset | ||
|
|
||
| Automation that keeps on top of open PRs, issues, and discussions: | ||
|
|
||
| - **Sweep** (`.github/workflows/maintainer-sweep.yml`, every 3h): assigns each | ||
| eligible open PR/issue to a maintainer (drafts and suspected drive-by PRs | ||
| are skipped, and routing stops when everyone is at their assignment cap), | ||
| 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` / `merge-conflicts` / `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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| /** | ||
| * 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: 'merge-conflicts', | ||
| color: 'b60205', | ||
| description: 'Conflicts with the base branch — needs a rebase', | ||
| }, | ||
| { | ||
| 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 | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async function executeOne( | ||
| client: GitHubClient, | ||
| repo: string, | ||
| m: Mutation, | ||
| ): Promise<void> { | ||
| 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 | ||
|
Comment on lines
+130
to
+135
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== file list candidate =="
fd -a 'actions\.ts$' . || true
echo "== file excerpt =="
if [ -f scripts/maintainer/actions.ts ]; then
wc -l scripts/maintainer/actions.ts
sed -n '1,220p' scripts/maintainer/actions.ts | nl -ba
fi
echo "== relevant client/rest definitions/usages =="
rg -n "client\.rest|function rest|async rest|isNotFoundError|Handle|HTTP 404|DELETE.*labels" scripts/maintainer -S || trueRepository: TanStack/ai Length of output: 318 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== actions.ts excerpt =="
wc -l scripts/maintainer/actions.ts
cat -n scripts/maintainer/actions.ts | sed -n '1,220p'
echo "== relevant client/rest definitions/usages =="
rg -n "client\.rest|function rest|async rest|isNotFoundError|Handle|HTTP 404|DELETE.*labels|github\.rest|octokit" scripts/maintainer -S || true
echo "== package manifests =="
for f in package.json scripts/maintainer/package.json; do
[ -f "$f" ] && { echo "--- $f"; sed -n '1,220p' "$f"; }
done
echo "== git diff stat/name-only =="
git diff --stat || true
git diff -- scripts/maintainer/actions.ts | sed -n '1,240p' || trueRepository: TanStack/ai Length of output: 11415 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== github.ts excerpt =="
wc -l scripts/maintainer/github.ts
cat -n scripts/maintainer/github.ts | sed -n '1,200p'
echo "== maintainer tests/files =="
git ls-files scripts/maintainer | sed -n '1,120p'
rg -n "executeMutations|executeOne|remove-label|404|rate limit|403|429" scripts/maintainer -S || true
echo "== node fetch semantics for mocked 404 behavior (no repo code executed) =="
node - <<'JS'
(async () => {
let status
const req = await fetch('https://httpbin.org/status/404');
status = req.status;
let ok = false;
try {
await req.text();
} catch (e) {
ok = true;
console.log("fetchTextThrows", e.message.includes("404") || true);
}
console.log("status", status);
console.log("fetchStatusAndDisposeThrows", ok);
})();
JSRepository: TanStack/ai Length of output: 5411 🌐 Web query:
💡 Result: A 404 Not Found error when calling the DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name} endpoint generally indicates that the request could not be fulfilled because a specified resource component is missing [1][2]. According to GitHub’s official documentation and support guidance, this status code is returned if any of the following conditions are met: 1. The specified repository does not exist [2]. 2. The specified issue number does not exist within that repository [2]. 3. The specified label does not exist on that particular issue [1][2]. Additionally, a 404 error may be returned for private resources if the request is not properly authenticated, as GitHub uses this status code to avoid confirming the existence of private repositories [3]. Troubleshooting Steps: * Verify URL Encoding: Ensure that the {name} parameter is properly URL encoded [3]. If your label name contains special characters or spaces, they must be represented correctly (e.g., a space should be encoded as %20) [3]. * Check for Typos and Trailing Slashes: Ensure the URL does not contain extraneous characters like a trailing slash, which can cause routing issues [3]. * Confirm Resource Existence: If you suspect the label does not exist on the repository at all, you can use the GET /repos/{owner}/{repo}/labels/{name} endpoint to verify its existence [2]. * Validate Authentication: If the repository is private, confirm that your authentication token has the necessary permissions to access the resource [3]. If you are using a library like octokit, ensure you are passing the parameters correctly according to the library's method signature, as passing the wrong parameter name (e.g., using labels instead of name) can lead to unexpected failures [4]. Citations:
Treat an already-removed label as successful convergence. The plan is based on a prior snapshot, so the executed DELETE can legitimately receive 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| function isRateLimitError(error: unknown): boolean { | ||
| return error instanceof Error && /HTTP (403|429)/.test(error.message) | ||
| } | ||
|
|
||
| const sleep = (ms: number) => | ||
| new Promise<void>((resolve) => setTimeout(resolve, ms)) | ||
|
|
||
| export interface ExecuteOptions { | ||
| /** Pause between mutations — GitHub's secondary rate limit wants ≥1s between content-creating requests. */ | ||
| pacingMs?: number | ||
| sleepImpl?: (ms: number) => Promise<void> | ||
| } | ||
|
|
||
| export async function executeMutations( | ||
| client: GitHubClient, | ||
| repo: string, | ||
| mutations: Array<Mutation>, | ||
| options: ExecuteOptions = {}, | ||
| ): Promise<void> { | ||
| const pacingMs = options.pacingMs ?? 1000 | ||
| const sleepImpl = options.sleepImpl ?? sleep | ||
| for (const [i, m] of mutations.entries()) { | ||
| if (i > 0) await sleepImpl(pacingMs) | ||
| try { | ||
| await executeOne(client, repo, m) | ||
| } catch (error) { | ||
| if (!isRateLimitError(error)) throw error | ||
| // Likely the secondary rate limit; back off once and retry before | ||
| // giving up (an aborted run converges on the next sweep anyway). | ||
| console.log(` ⏳ rate limited, backing off 60s (${describeMutation(m)})`) | ||
| await sleepImpl(60_000) | ||
| await executeOne(client, repo, m) | ||
| } | ||
| // Per-mutation progress keeps watchdogs (and humans) from presuming death. | ||
| console.log(` ✔ [${i + 1}/${mutations.length}] ${describeMutation(m)}`) | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.