diff --git a/package.json b/package.json index 3197d56c3a..cb31d86c42 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,8 @@ "test:maintainer": "vitest run --root scripts/maintainer && vitest run scripts/model-sync scripts/publish-staged-docs.test.ts && vitest run scripts/coverage-check.test.ts", "test:ai-review": "vitest run --root agent-scripts/ai-review", "ai-review": "node agent-scripts/ai-review/run.ts", - "maintainer:sweep": "tsx scripts/maintainer/sweep.ts", - "maintainer:scorecard": "tsx scripts/maintainer/scorecard.ts", + "maintainer:sweep": "node scripts/maintainer/sweep.ts", + "maintainer:scorecard": "node 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", diff --git a/scripts/maintainer/README.md b/scripts/maintainer/README.md index d7fe9555d9..f2dda90b93 100644 --- a/scripts/maintainer/README.md +++ b/scripts/maintainer/README.md @@ -36,15 +36,20 @@ and PR code is never checked out or executed (no `pull_request_target`). ## Assignment order -1. Route unassigned core-owned PRs first. Read `.github/CODEOWNERS` from the +1. If an unassigned PR closes an open issue that already has an assignee, and + that assignee is on the roster and did not author the PR, assign the PR to + them. This wins over the core-review rule. The first linked issue with a + qualifying assignee wins. A missing issue, a non-roster assignee, or an + assignee who authored the PR falls through to the steps below. +2. Route unassigned core-owned PRs first. Read `.github/CODEOWNERS` from the default branch through GitHub, including last-match and ownerless exceptions. Assign these PRs to `AlemTuzlak`, regardless of load. If Alem authored the PR, choose another maintainer with the fewest assigned PRs. -2. Route other unassigned PRs to the least-loaded maintainer who is not the +3. Route other unassigned PRs to the least-loaded maintainer who is not the author. Break load ties with configured areas and matching files or packages from PRs merged in the last 14 days (authored or reviewed by the maintainer). Remaining ties use a deterministic rotation based on the item number. -3. Route unassigned issues with a separate issue count. Package names in the +4. Route unassigned issues with a separate issue count. Package names in the title/body break load ties using configured areas. Every eligible item gets an assignee when the roster has someone other than diff --git a/scripts/maintainer/scorecard.ts b/scripts/maintainer/scorecard.ts index 1180d5459e..3c19c8efd8 100644 --- a/scripts/maintainer/scorecard.ts +++ b/scripts/maintainer/scorecard.ts @@ -3,7 +3,7 @@ * Discord webhook (DISCORD_WEBHOOK_URL). With --dry-run (or when the webhook * is not configured) it prints the payload instead of posting. * - * Usage: tsx scripts/maintainer/scorecard.ts [--dry-run] + * Usage: node scripts/maintainer/scorecard.ts [--dry-run] */ import { readdir } from 'node:fs/promises' diff --git a/scripts/maintainer/sweep.test.ts b/scripts/maintainer/sweep.test.ts index 013d4c3366..6f8334492a 100644 --- a/scripts/maintainer/sweep.test.ts +++ b/scripts/maintainer/sweep.test.ts @@ -170,6 +170,63 @@ describe('planSweep', () => { expect(plan.commentsSuppressedByCap).toBe(2) }) + it('assigns a PR to the roster assignee of the issue it closes, ahead of core review', () => { + const roster = { + ...config, + maintainers: config.maintainers.map((m) => + m.github === 'alem' ? { ...m, github: 'AlemTuzlak' } : m, + ), + } + const snapshot = makeSnapshot({ + codeowners: 'scripts/ @TanStack/tanstack-core', + prs: [ + makePR({ + number: 99, + files: ['scripts/a.ts'], + linkedIssues: [200], + }), + ], + issues: [makeIssue({ number: 200, assignees: ['jack'] })], + }) + const assign = planSweep(snapshot, roster).mutations.find( + (m) => m.kind === 'assign' && m.number === 99, + ) + expect(assign).toEqual({ + kind: 'assign', + number: 99, + assignee: 'jack', + }) + }) + + it('routes normally when the closed issue assignee authored the PR', () => { + const roster = { + ...config, + maintainers: config.maintainers.map((m) => + m.github === 'alem' ? { ...m, github: 'AlemTuzlak' } : m, + ), + } + const snapshot = makeSnapshot({ + codeowners: 'scripts/ @TanStack/tanstack-core', + prs: [ + makePR({ + number: 99, + author: 'jack', + files: ['scripts/a.ts'], + linkedIssues: [200], + }), + ], + issues: [makeIssue({ number: 200, assignees: ['jack'] })], + }) + const assign = planSweep(snapshot, roster).mutations.find( + (m) => m.kind === 'assign' && m.number === 99, + ) + expect(assign).toEqual({ + kind: 'assign', + number: 99, + assignee: 'AlemTuzlak', + }) + }) + it('spreads fallback assignments across maintainers', () => { const snapshot = makeSnapshot({ prs: [11, 12, 13].map((n) => makePR({ number: n, files: ['README.md'] })), diff --git a/scripts/maintainer/sweep.ts b/scripts/maintainer/sweep.ts index c18000f7ea..13ce99bad5 100644 --- a/scripts/maintainer/sweep.ts +++ b/scripts/maintainer/sweep.ts @@ -13,7 +13,7 @@ * Read/write via the GitHub API only; PR code is never checked out or * executed. Idempotent: safe to re-run at any time. * - * Usage: tsx scripts/maintainer/sweep.ts [--dry-run] + * Usage: node scripts/maintainer/sweep.ts [--dry-run] */ import process from 'node:process' @@ -27,12 +27,12 @@ import { import { classifyAll } from './classify' import { collectSnapshot } from './collect' import { buildAckComment, buildReproComment } from './comments' -import { loadConfig } from './config' +import { isRosterMaintainer, loadConfig } from './config' import { createGitHubClient } from './github' import { computeLoad, requiresCoreReview, routeIssue, routePR } from './route' import { isDryRun, resolveToken, writeStepSummary } from './env' import type { Mutation } from './actions' -import type { RepoSnapshot, ToolsetConfig } from './types' +import type { PRItem, RepoSnapshot, ToolsetConfig } from './types' export interface SweepPlan { mutations: Array @@ -40,6 +40,28 @@ export interface SweepPlan { commentsSuppressedByCap: number } +/** Roster login of a closed issue's assignee, when they did not author the PR. */ +function assigneeFromClosedIssue( + pr: PRItem, + issueByNumber: Map }>, + config: ToolsetConfig, +): string | null { + for (const number of pr.linkedIssues) { + const issue = issueByNumber.get(number) + if (!issue) continue + for (const login of issue.assignees) { + if (login.toLowerCase() === pr.author?.toLowerCase()) continue + if (!isRosterMaintainer(login, config)) continue + return ( + config.maintainers.find( + (m) => m.github.toLowerCase() === login.toLowerCase(), + )?.github ?? null + ) + } + } + return null +} + export function planSweep( snapshot: RepoSnapshot, config: ToolsetConfig, @@ -54,6 +76,9 @@ export function planSweep( snapshot.issues.map((issue) => issue.assignees), ) + const issueByNumber = new Map( + snapshot.issues.map((issue) => [issue.number, issue]), + ) const mutations: Array = [] const skippedAsSpam: Array = [] let comments = 0 @@ -83,15 +108,18 @@ export function planSweep( let assignee = pr.assignees[0] ?? null if (assignee === null) { - const routed = routePR( - pr.files, - pr.author, - config, - prLoad, - pr.number, - snapshot.codeowners, - snapshot.recentlyClosed, - ) + const fromIssue = assigneeFromClosedIssue(pr, issueByNumber, config) + const routed = + fromIssue ?? + routePR( + pr.files, + pr.author, + config, + prLoad, + pr.number, + snapshot.codeowners, + snapshot.recentlyClosed, + ) if (routed !== null) { mutations.push({ kind: 'assign', number: pr.number, assignee: routed }) prLoad.set(routed, (prLoad.get(routed) ?? 0) + 1)