Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
35 changes: 35 additions & 0 deletions .github/maintainers.json
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
}
47 changes: 47 additions & 0 deletions .github/workflows/maintainer-scorecard.yml
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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' || '' }}
44 changes: 44 additions & 0 deletions .github/workflows/maintainer-sweep.yml
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' || '' }}
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:oxlint,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:oxlint,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:oxlint,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:oxlint,test:lib,test:types,test:build,build && pnpm test:dts",
"test:oxlint": "nx affected --target=test:oxlint --exclude=examples/**,testing/**",
"test:sherif": "sherif --ignore-dependency typescript --ignore-dependency vite",
"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
63 changes: 63 additions & 0 deletions scripts/maintainer/README.md
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.
175 changes: 175 additions & 0 deletions scripts/maintainer/actions.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 || true

Repository: 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' || true

Repository: 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);
})();
JS

Repository: TanStack/ai

Length of output: 5411


🌐 Web query:

GitHub REST API remove a label from an issue DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name} returns 404

💡 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 HTTP 404 if the label was removed by another maintainer before this run. That error currently propagates through executeMutations and aborts subsequent mutations; catch the remove-label DELETE 404 by label only before rethrowing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/maintainer/actions.ts` around lines 130 - 135, Update the
remove-label branch in executeMutations to catch errors from the DELETE request
and ignore only HTTP 404 responses for that label, allowing execution to
continue; rethrow all other errors unchanged.

}
}

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)}`)
}
}
Loading
Loading