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
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 on lines +30 to +31

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 | 🟠 Major | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the two workflows around the referenced lines.
for f in .github/workflows/maintainer-scorecard.yml .github/workflows/maintainer-sweep.yml; do
  echo "===== $f ====="
  nl -ba "$f" | sed -n '1,220p'
  echo
done

# Look for code that collects issues, pull requests, discussions, or scorecard/sweep snapshot data.
rg -n "discussion|discussions|pull-requests|issues|snapshot|scorecard|sweep" .github/workflows . --glob '!**/node_modules/**'

Repository: TanStack/ai

Length of output: 244


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== maintainer-scorecard.yml ==="
sed -n '1,220p' .github/workflows/maintainer-scorecard.yml | cat -n

echo
echo "=== maintainer-sweep.yml ==="
sed -n '1,260p' .github/workflows/maintainer-sweep.yml | cat -n

echo
echo "=== workflow references ==="
rg -n "discussion|discussions|pull-requests|issues|scorecard|sweep|github\.api|octokit|GraphQL|REST" .github/workflows . --glob '!**/node_modules/**'

Repository: TanStack/ai

Length of output: 50367


🌐 Web query:

GitHub Actions workflow permissions write includes read discussions issues pull-requests repository permissions docs

πŸ’‘ Result:

In GitHub Actions, the permissions key allows you to define the access level of the GITHUB_TOKEN for a specific job or an entire workflow [1][2]. This is a security best practice that enables the principle of least privilege, ensuring the token has only the minimum access required to perform its tasks [1][3]. When you explicitly define the permissions key in your workflow file, all unspecified permissions are automatically set to none, except for the metadata scope, which always retains read access [2]. The available permission scopes include, but are not limited to, the following [4]: contents: Manages repository code access. The write level allows you to push commits, create tags, and manage releases. issues: Manages issue tracking. The write level allows you to create, edit, close, or delete issues and add comments. pull-requests: Manages pull request operations. The write level permits actions such as creating or merging pull requests, as well as modifying PR labels, assignees, and descriptions. discussions: Manages GitHub Discussions. The write level allows you to create, edit, or delete discussions and comments. actions: Manages workflow execution and control. checks: Manages status checks and build results. deployments: Manages deployment status and infrastructure. packages: Manages GitHub Packages. pages: Manages GitHub Pages settings and publishing. When granting write access, the token is granted the ability to modify or delete existing data within that specific scope, whereas read access only permits viewing that data [2]. For example, contents: write enables the token to push changes to the repository, which is necessary for tasks like automated version bumping or generating documentation [5][2]. For security, it is recommended to audit and limit these permissions, as broad default settings (if configured at the repository or organization level) may grant more access than your workflow requires [3]. You can manage these settings at the job or workflow level in your YAML configuration [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== scripts/maintainer/collect.ts (relevant sections) ==="
sed -n '1,220p' scripts/maintainer/collect.ts | cat -n
echo
sed -n '220,420p' scripts/maintainer/collect.ts | cat -n

echo
echo "=== scripts/maintainer/README.md (permissions / API notes) ==="
sed -n '1,120p' scripts/maintainer/README.md | cat -n

echo
echo "=== scripts/maintainer/sweep.ts (high-level data usage) ==="
sed -n '1,240p' scripts/maintainer/sweep.ts | cat -n

echo
echo "=== scripts/maintainer/scorecard.ts (high-level data usage) ==="
sed -n '1,140p' scripts/maintainer/scorecard.ts | cat -n

Repository: TanStack/ai

Length of output: 30056


Grant the repo read scopes snapshot collection needs

collectSnapshot() reads open PRs, issues, and discussions. maintainer-scorecard.yml only grants contents: read, so add issues: read, pull-requests: read, and discussions: read there; maintainer-sweep.yml already has issues: write and pull-requests: write, so it only needs discussions: read.

πŸ“ Affects 2 files
  • .github/workflows/maintainer-scorecard.yml#L30-L31 (this comment)
  • .github/workflows/maintainer-sweep.yml#L28-L31
πŸ€– 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 @.github/workflows/maintainer-scorecard.yml around lines 30 - 31, Update
permissions in .github/workflows/maintainer-scorecard.yml at lines 30-31 to add
issues: read, pull-requests: read, and discussions: read alongside contents:
read. Update permissions in .github/workflows/maintainer-sweep.yml at lines
28-31 to add discussions: read while preserving its existing issues: write and
pull-requests: write permissions.

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