From 4ae2813d729e94da294064ef24e55bb172891a4b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:55:08 -0400 Subject: [PATCH 1/3] feat(pr-pipeline): add an hourly sweep that re-dispatches intake triage The lane sweep workflow gains an hourly triage-sweep job. Each pass lists open issues with no intake-triage comment and no triage or status label, by a listed human or the lanes App, over an hour old and not born triaged, and dispatches intake-triage.yml for at most 5 with the job's own token. One bot comment per issue records the runs; after 2 that left the issue untriaged, the sweep files one needs-triage issue naming the runs and labels the original needs-triage. No App token is spent. Co-Authored-By: Claude Opus 5.5 --- .../triage-sweep.mjs | 230 ++++++++++++++ .../triage-sweep.test.mjs | 282 ++++++++++++++++++ .../workflow.test.mjs | 26 +- .../workflows/maintenance-audit-lane-runs.yml | 60 +++- 4 files changed, 591 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/maintenance-audit-lane-runs/triage-sweep.mjs create mode 100644 .github/scripts/maintenance-audit-lane-runs/triage-sweep.test.mjs diff --git a/.github/scripts/maintenance-audit-lane-runs/triage-sweep.mjs b/.github/scripts/maintenance-audit-lane-runs/triage-sweep.mjs new file mode 100644 index 0000000000..88e886979b --- /dev/null +++ b/.github/scripts/maintenance-audit-lane-runs/triage-sweep.mjs @@ -0,0 +1,230 @@ +// The hourly triage sweep: finds open issues intake triage never reached and +// dispatches intake-triage.yml for them, with the job's own token only. An +// issue qualifies when it has no intake comment and no triage or status +// label, is no pull request, was not born triaged, is over an hour old, and +// its author is a listed human or the lanes App, as the intake lane admits on +// open. github-actions[bot] issues are left out: the lane sweep's can quote +// fork-controlled log lines. Per issue, one comment holds the runs +// dispatched; after 2 that left it untriaged the sweep files one needs-triage +// issue and labels the original needs-triage. No model. +// +// Accepted exposure: intake reads an issue's comments, and anyone can comment +// in the hour before the sweep dispatches. Intake treats them as untrusted +// data and applies only allowlisted labels, never agent-ready. +import { appendFileSync, readFileSync } from "node:fs"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +import { BORN_TRIAGED_MARKER, humanIds } from "../../actions/check-born-triaged/check-born-triaged.mjs"; +import { createBudget } from "./collect.mjs"; + +export const MAX_DISPATCHES = 5; +const MAX_FILED = 5; +const MAX_CHECKED = 20; +const MAX_ISSUE_PAGES = 15; +const COMMENT_PAGES = 3; +const ATTEMPTS = 2; +const MIN_AGE_MS = 3_600_000; +const BOT = "github-actions[bot]"; +const INTAKE_MARKER = ""; +const STATE = /^/; +// Labels intake triage or a person applies once an issue is triaged. +const TRIAGED = /^(needs-triage|needs-human|needs-spec|agent-ready)$|^(status|priority|work-class): /; + +export function selectCandidates({ issues, now, authorIds }) { + return issues + .filter( + (i) => + !i.pull_request && + authorIds.has(String(i.user?.id)) && + !(i.labels ?? []).some((l) => TRIAGED.test(l.name ?? "")) && + !String(i.body ?? "") + .trimEnd() + .endsWith(BORN_TRIAGED_MARKER) && + now - new Date(i.created_at) >= MIN_AGE_MS, + ) + .sort((a, b) => new Date(a.created_at) - new Date(b.created_at) || a.number - b.number); +} + +// The intake lane's own lookup for its earlier comment. +export const hasIntakeComment = (comments) => + comments.some((c) => c.user?.login === BOT && String(c.body ?? "").startsWith(INTAKE_MARKER)); + +export function readState(comments) { + for (const c of comments) { + const m = c.user?.login === BOT ? STATE.exec(String(c.body ?? "")) : null; + if (m) { + return { + commentId: c.id, + runs: m[1] ? m[1].split(",").map(Number) : [], + filed: m[2] ? Number(m[2]) : null, + }; + } + } + return { commentId: null, runs: [], filed: null }; +} + +export const renderState = ({ runs, filed, runUrl }) => + `\n` + + (filed + ? `Intake triage did not triage this issue after ${runs.length} dispatches; #${filed} hands it to a maintainer.` + : `The triage sweep dispatched [intake triage](${runUrl}) for this issue (attempt ${runs.length} of ${ATTEMPTS}).`); + +export function decide({ state, latest }) { + if (state.filed) return "stop"; + if (state.runs.length === 0) return "dispatch"; + if (latest?.status !== "completed") return "wait"; + return state.runs.length >= ATTEMPTS ? "file" : "dispatch"; +} + +export const renderFiledIssue = ({ number, runs, repoUrl }) => ({ + title: `Intake triage left #${number} untriaged after ${runs.length} dispatches`, + labels: ["needs-triage"], + body: [ + `The hourly triage sweep dispatched intake triage for #${number} ${runs.length} times, and the issue still has no intake comment and no triage or status label. The sweep no longer dispatches it.`, + "", + ...runs.map((r) => `- ${repoUrl}/actions/runs/${r.id} (${r.conclusion ?? "unknown"})`), + "", + "Triage the issue by hand, and read the runs for why intake did not.", + ].join("\n"), +}); + +// One pass. `call` returns null for a GET that 404s. +export async function runSweep({ call, remaining = () => Infinity, repo, serverUrl, now, authorIds }) { + const repoUrl = `${serverUrl}/${repo}`; + const issues = []; + for (let page = 1; page <= MAX_ISSUE_PAGES; page += 1) { + const batch = await call("GET", `/repos/${repo}/issues?state=open&per_page=100&page=${page}`); + issues.push(...batch); + if (batch.length < 100) break; + } + const result = { candidates: 0, dispatched: [], filed: [], handedOff: [], waiting: [], failed: [], truncated: null }; + const candidates = selectCandidates({ issues, now, authorIds }); + result.candidates = candidates.length; + const handOff = (number) => call("POST", `/repos/${repo}/issues/${number}/labels`, { labels: ["needs-triage"] }); + for (const { number } of candidates.slice(0, MAX_CHECKED)) { + if (result.dispatched.length >= MAX_DISPATCHES && result.filed.length >= MAX_FILED) break; + try { + const comments = []; + let full = false; + for (let page = 1; page <= COMMENT_PAGES; page += 1) { + const batch = (await call("GET", `/repos/${repo}/issues/${number}/comments?per_page=100&page=${page}`)) ?? []; + comments.push(...batch); + full = batch.length === 100; + if (!full) break; + } + if (hasIntakeComment(comments)) continue; + // Past the pages read, the state comment may be hidden: a person decides. + if (full) { + await handOff(number); + result.handedOff.push(number); + continue; + } + const state = readState(comments); + const latest = state.runs.length > 0 ? await call("GET", `/repos/${repo}/actions/runs/${state.runs.at(-1)}`) : null; + // A run the API no longer has counts as finished. + let action = decide({ state, latest: state.runs.length > 0 && latest === null ? { status: "completed" } : latest }); + if (action === "wait") result.waiting.push(number); + if (action === "dispatch" && result.dispatched.length >= MAX_DISPATCHES) action = "stop"; + if (action === "file" && result.filed.length >= MAX_FILED) action = "stop"; + // Never start a run the budget left cannot record, or a filing it cannot finish. + if ((action === "dispatch" && remaining() < 2) || (action === "file" && remaining() < state.runs.length + 2)) { + result.truncated = `stopped at #${number}: too little call budget left`; + break; + } + // intake-triage.yml admits this token's dispatch whoever wrote the + // issue, so the author check in selectCandidates is the only one. + if (action === "dispatch") { + const sent = await call("POST", `/repos/${repo}/actions/workflows/intake-triage.yml/dispatches`, { + ref: "main", + inputs: { "issue-number": String(number) }, + }); + const id = Number(sent?.workflow_run_id); + if (!Number.isInteger(id) || id <= 0) throw new Error("the dispatch returned no run id"); + result.dispatched.push(number); + await writeState(call, repo, number, state, { + runs: [...state.runs, id], + filed: null, + runUrl: `${repoUrl}/actions/runs/${id}`, + }); + } + if (action === "file") { + const runs = []; + for (const id of state.runs) { + const run = id === state.runs.at(-1) ? latest : await call("GET", `/repos/${repo}/actions/runs/${id}`); + runs.push({ id, conclusion: run?.conclusion ?? null }); + } + const filed = await call("POST", `/repos/${repo}/issues`, renderFiledIssue({ number, runs, repoUrl })); + result.filed.push(filed.number); + await writeState(call, repo, number, state, { runs: state.runs, filed: filed.number }); + await handOff(number); + } + } catch (error) { + if (/call budget/.test(error.message)) { + result.truncated = `stopped at #${number}: ${error.message}`; + break; + } + result.failed.push({ number, error: error.message }); + } + } + return result; +} + +async function writeState(call, repo, number, state, next) { + const body = { body: renderState(next) }; + if (state.commentId) await call("PATCH", `/repos/${repo}/issues/comments/${state.commentId}`, body); + else await call("POST", `/repos/${repo}/issues/${number}/comments`, body); +} + +async function main() { + const env = process.env; + const budget = createBudget(Number(env.CALL_BUDGET ?? 120)); + const call = async (method, path, body) => { + budget.spend(); + const response = await fetch(`${env.GITHUB_API_URL}${path}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${env.GITHUB_TOKEN}`, + "x-github-api-version": "2022-11-28", + ...(body ? { "content-type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (response.status === 404 && method === "GET") return null; + if (!response.ok) throw new Error(`${method} ${path}: HTTP ${response.status}`); + return response.status === 204 ? null : response.json(); + }; + const list = JSON.parse(readFileSync(env.TRUSTED_ACTORS_PATH, "utf8")); + const authorIds = new Set(humanIds(list).split(",").filter(Boolean)); + if (/^[1-9][0-9]*$/.test(env.LANES_APP_ID ?? "")) authorIds.add(env.LANES_APP_ID); + const result = await runSweep({ + call, + remaining: () => budget.limit - budget.used, + repo: env.REPOSITORY, + serverUrl: env.GITHUB_SERVER_URL, + now: new Date(), + authorIds, + }); + const numbers = (xs) => xs.map((n) => `#${n}`).join(", ") || "none"; + const lines = [ + "### Triage sweep", + "", + `Candidates: ${result.candidates}. Dispatched: ${numbers(result.dispatched)}. Filed: ${numbers(result.filed)}. Handed off for too many comments: ${numbers(result.handedOff)}. Waiting on a run: ${numbers(result.waiting)}. Calls: ${budget.used}.`, + "", + ...(result.truncated ? [`- ${result.truncated}`] : []), + ...result.failed.map((f) => `- failed #${f.number}: ${f.error}`), + ]; + appendFileSync(env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n`); + console.log( + `triage-sweep: candidates ${result.candidates}, dispatched ${result.dispatched.length}, filed ${result.filed.length}, waiting ${result.waiting.length}, failed ${result.failed.length}, calls ${budget.used}`, + ); + if (result.failed.length > 0) { + console.log(`::error::${result.failed.length} triage sweep issue(s) failed; see the step summary`); + process.exitCode = 1; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await main(); +} diff --git a/.github/scripts/maintenance-audit-lane-runs/triage-sweep.test.mjs b/.github/scripts/maintenance-audit-lane-runs/triage-sweep.test.mjs new file mode 100644 index 0000000000..689e5fd788 --- /dev/null +++ b/.github/scripts/maintenance-audit-lane-runs/triage-sweep.test.mjs @@ -0,0 +1,282 @@ +// The hourly triage sweep's rules, from the owner-approved design: an open +// issue qualifies when it has no intake-triage comment and no triage or +// status label, is not a pull request, was not born triaged, and is over an +// hour old; its author must be one the intake lane admits on open. Each pass +// dispatches at most 5, and after 2 dispatches that left the issue untriaged +// the sweep files one needs-triage issue and stops dispatching that issue. +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + decide, + hasIntakeComment, + MAX_DISPATCHES, + readState, + renderFiledIssue, + renderState, + runSweep, + selectCandidates, +} from "./triage-sweep.mjs"; + +const NOW = new Date("2026-10-18T12:00:00Z"); +const BOT = { login: "github-actions[bot]", id: 41898282 }; +const HUMAN = 153232337; +const OUTSIDER = 555; + +const issue = (number, over = {}) => ({ + number, + user: { id: HUMAN }, + labels: [], + body: "a report", + created_at: "2026-10-18T09:00:00Z", + ...over, +}); + +const APP = 337595936; +const authors = new Set([String(HUMAN), String(APP)]); + +test("a candidate has no triage or status label, is no PR, not born triaged, over an hour old, by an admitted author", () => { + const issues = [ + issue(1), + issue(2, { labels: [{ name: "needs-triage" }] }), + issue(3, { labels: [{ name: "status: needs-info" }] }), + issue(4, { labels: [{ name: "agent-ready" }] }), + issue(5, { labels: [{ name: "needs-human" }] }), + issue(6, { labels: [{ name: "priority: low" }] }), + issue(7, { labels: [{ name: "work-class: scoped" }] }), + issue(8, { labels: [{ name: "needs-spec" }] }), + issue(9, { pull_request: { url: "x" } }), + issue(10, { body: "text\n\n" }), + issue(11, { created_at: "2026-10-18T11:30:00Z" }), + issue(12, { user: { id: OUTSIDER } }), + issue(13, { labels: [{ name: "question" }, { name: "dependencies" }] }), + issue(14, { user: { id: APP }, created_at: "2026-10-01T00:00:00Z" }), + issue(15, { user: { id: BOT.id } }), + ]; + assert.deepEqual( + selectCandidates({ issues, now: NOW, authorIds: authors }).map((i) => i.number), + [14, 1, 13], + ); +}); + +test("a quoted born-triaged marker mid-body does not exempt the issue", () => { + const issues = [issue(1, { body: "\nmore text" })]; + assert.deepEqual( + selectCandidates({ issues, now: NOW, authorIds: authors }).map((i) => i.number), + [1], + ); +}); + +test("only the bot's comment that starts with the intake marker counts as triaged", () => { + const marker = "\nThanks for the report."; + assert.equal(hasIntakeComment([{ user: BOT, body: marker }]), true); + assert.equal(hasIntakeComment([{ user: { login: "someone", id: 9 }, body: marker }]), false); + assert.equal(hasIntakeComment([{ user: BOT, body: `quoted: ${marker}` }]), false); + assert.equal(hasIntakeComment([]), false); +}); + +test("the sweep state round-trips and is read only from the bot's comment", () => { + const body = renderState({ runs: [11, 22], filed: null, runUrl: "https://github.com/o/r/actions/runs/22" }); + const comments = [ + { id: 1, user: { login: "someone", id: 9 }, body: renderState({ runs: [], filed: 5, runUrl: "u" }) }, + { id: 2, user: BOT, body }, + ]; + assert.deepEqual(readState(comments), { commentId: 2, runs: [11, 22], filed: null }); + assert.deepEqual(readState([{ id: 3, user: BOT, body: renderState({ runs: [11, 22], filed: 77, runUrl: "u" }) }]), { + commentId: 3, + runs: [11, 22], + filed: 77, + }); + assert.deepEqual(readState([]), { commentId: null, runs: [], filed: null }); +}); + +test("decide: dispatch first, wait on an unfinished run, dispatch again after one, file after two, stop once filed", () => { + const done = { status: "completed", conclusion: "failure" }; + assert.equal(decide({ state: { runs: [], filed: null }, latest: null }), "dispatch"); + assert.equal(decide({ state: { runs: [1], filed: null }, latest: { status: "in_progress" } }), "wait"); + assert.equal(decide({ state: { runs: [1], filed: null }, latest: { status: "queued" } }), "wait"); + assert.equal(decide({ state: { runs: [1], filed: null }, latest: done }), "dispatch"); + assert.equal( + decide({ state: { runs: [1], filed: null }, latest: { status: "completed", conclusion: "success" } }), + "dispatch", + ); + assert.equal(decide({ state: { runs: [1, 2], filed: null }, latest: { status: "in_progress" } }), "wait"); + assert.equal(decide({ state: { runs: [1, 2], filed: null }, latest: done }), "file"); + assert.equal(decide({ state: { runs: [1, 2], filed: 9 }, latest: done }), "stop"); +}); + +test("the filed issue names the issue and every failed run, and carries needs-triage", () => { + const filed = renderFiledIssue({ + number: 42, + runs: [ + { id: 11, conclusion: "failure" }, + { id: 22, conclusion: "success" }, + ], + repoUrl: "https://github.com/o/r", + }); + assert.equal(filed.title, "Intake triage left #42 untriaged after 2 dispatches"); + assert.deepEqual(filed.labels, ["needs-triage"]); + assert.match(filed.body, /#42/); + assert.ok(filed.body.includes("https://github.com/o/r/actions/runs/11")); + assert.ok(filed.body.includes("https://github.com/o/r/actions/runs/22")); +}); + +// A fake GitHub holding issues, comments and runs, recording every write. +function fakeGitHub({ issues, comments = {}, runs = {} }) { + const writes = []; + let nextRun = 900; + let nextIssue = 5000; + let nextComment = 100; + const call = async (method, path, body) => { + let m; + if (method === "GET" && (m = /^\/repos\/o\/r\/issues\?state=open&per_page=100&page=(\d+)$/.exec(path))) { + return issues.slice((Number(m[1]) - 1) * 100, Number(m[1]) * 100); + } + if (method === "GET" && (m = /^\/repos\/o\/r\/issues\/(\d+)\/comments\?per_page=100&page=(\d+)$/.exec(path))) { + return (comments[m[1]] ?? []).slice((Number(m[2]) - 1) * 100, Number(m[2]) * 100); + } + if (method === "GET" && (m = /^\/repos\/o\/r\/actions\/runs\/(\d+)$/.exec(path))) { + return runs[m[1]] ?? null; + } + writes.push({ method, path, body }); + if (method === "POST" && path === "/repos/o/r/actions/workflows/intake-triage.yml/dispatches") { + nextRun += 1; + return { workflow_run_id: nextRun }; + } + if (method === "POST" && path === "/repos/o/r/issues") return { number: (nextIssue += 1) }; + if (method === "POST" && path.endsWith("/labels")) return []; + if (method === "POST" || method === "PATCH") return { id: (nextComment += 1) }; + throw new Error(`unexpected ${method} ${path}`); + }; + return { call, writes }; +} + +const sweep = (gh) => + runSweep({ call: gh.call, repo: "o/r", serverUrl: "https://github.com", now: NOW, authorIds: authors }); + +test("a pass dispatches at most 5 issues, oldest first, and records each run on the issue", async () => { + // Listed newest first, as the issues API returns them. + const issues = Array.from({ length: 7 }, (_, i) => issue(7 - i, { created_at: `2026-10-1${6 - i}T00:00:00Z` })); + const gh = fakeGitHub({ issues }); + const result = await sweep(gh); + assert.equal(MAX_DISPATCHES, 5); + const dispatches = gh.writes.filter((w) => w.path.endsWith("/dispatches")); + assert.deepEqual( + dispatches.map((w) => w.body), + [1, 2, 3, 4, 5].map((n) => ({ ref: "main", inputs: { "issue-number": String(n) } })), + ); + const states = gh.writes.filter((w) => /\/issues\/\d+\/comments$/.test(w.path)); + assert.deepEqual( + states.map((w) => [w.path, readState([{ id: 1, user: BOT, body: w.body.body }]).runs]), + [1, 2, 3, 4, 5].map((n, i) => [`/repos/o/r/issues/${n}/comments`, [901 + i]]), + ); + assert.deepEqual(result.dispatched, [1, 2, 3, 4, 5]); +}); + +test("an issue with an intake comment is left alone", async () => { + const gh = fakeGitHub({ + issues: [issue(1)], + comments: { 1: [{ id: 5, user: BOT, body: "\nok" }] }, + }); + await sweep(gh); + assert.deepEqual(gh.writes, []); +}); + +test("a second dispatch edits the same state comment", async () => { + const gh = fakeGitHub({ + issues: [issue(1)], + comments: { 1: [{ id: 5, user: BOT, body: renderState({ runs: [11], filed: null, runUrl: "u" }) }] }, + runs: { 11: { status: "completed", conclusion: "failure" } }, + }); + await sweep(gh); + assert.deepEqual( + gh.writes.map((w) => [w.method, w.path]), + [ + ["POST", "/repos/o/r/actions/workflows/intake-triage.yml/dispatches"], + ["PATCH", "/repos/o/r/issues/comments/5"], + ], + ); + assert.deepEqual(readState([{ id: 5, user: BOT, body: gh.writes[1].body.body }]).runs, [11, 901]); +}); + +test("after 2 untriaged dispatches the sweep files once, then never dispatches or files again", async () => { + const runs = { + 11: { status: "completed", conclusion: "failure" }, + 22: { status: "completed", conclusion: "success" }, + }; + const first = fakeGitHub({ + issues: [issue(1)], + comments: { 1: [{ id: 5, user: BOT, body: renderState({ runs: [11, 22], filed: null, runUrl: "u" }) }] }, + runs, + }); + const result = await sweep(first); + assert.deepEqual( + first.writes.map((w) => [w.method, w.path]), + [ + ["POST", "/repos/o/r/issues"], + ["PATCH", "/repos/o/r/issues/comments/5"], + ["POST", "/repos/o/r/issues/1/labels"], + ], + ); + assert.deepEqual(first.writes[0].body.labels, ["needs-triage"]); + // The label takes the original out of later passes' candidates. + assert.deepEqual(first.writes[2].body, { labels: ["needs-triage"] }); + assert.deepEqual(result.filed, [5001]); + const marked = first.writes[1].body.body; + assert.equal(readState([{ id: 5, user: BOT, body: marked }]).filed, 5001); + + const second = fakeGitHub({ + issues: [issue(1)], + comments: { 1: [{ id: 5, user: BOT, body: marked }] }, + runs, + }); + await sweep(second); + assert.deepEqual(second.writes, []); +}); + +test("a run still in progress holds the issue for a later pass", async () => { + const gh = fakeGitHub({ + issues: [issue(1)], + comments: { 1: [{ id: 5, user: BOT, body: renderState({ runs: [11], filed: null, runUrl: "u" }) }] }, + runs: { 11: { status: "in_progress", conclusion: null } }, + }); + const result = await sweep(gh); + assert.deepEqual(gh.writes, []); + assert.deepEqual(result.waiting, [1]); +}); + +// A comment flood past the pages read would hide the state comment and force +// a dispatch every pass. +test("an issue with more comments than the sweep reads goes to a maintainer, undispatched", async () => { + const flood = Array.from({ length: 300 }, (_, i) => ({ id: i, user: { login: "someone", id: 9 }, body: "spam" })); + const gh = fakeGitHub({ issues: [issue(1)], comments: { 1: flood } }); + const result = await sweep(gh); + assert.deepEqual( + gh.writes.map((w) => [w.method, w.path, w.body]), + [["POST", "/repos/o/r/issues/1/labels", { labels: ["needs-triage"] }]], + ); + assert.deepEqual(result.dispatched, []); +}); + +test("a pass stops dispatching when the budget left cannot record the run", async () => { + const gh = fakeGitHub({ issues: [issue(1), issue(2, { created_at: "2026-10-18T09:30:00Z" })] }); + let left = 6; + const call = async (...args) => { + if (left === 0) throw new Error("call budget of 6 reached"); + left -= 1; + return gh.call(...args); + }; + // issues page 1, comments of #1, dispatch, state write: 4 calls; #2's + // comments leave 1, too few for a dispatch and its state write. + const result = await runSweep({ + call, + remaining: () => left, + repo: "o/r", + serverUrl: "https://github.com", + now: NOW, + authorIds: authors, + }); + assert.deepEqual(result.dispatched, [1]); + assert.equal(gh.writes.filter((w) => w.path.endsWith("/dispatches")).length, 1); + assert.match(result.truncated, /budget/); +}); diff --git a/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs b/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs index e4c6b19197..cefb7ef890 100644 --- a/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs +++ b/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs @@ -1,8 +1,9 @@ // maintenance-audit-lane-runs.yml's hardening, from docs/conventions/pr-pipeline/README.md // ("Every lane" and "The lane sweep"): only a schedule or a manual run starts // it, the model job holds read permissions only and runs with -// --setting-sources user and dontAsk and no shell, only the scripted file job -// may write issues, and every job checks the kill switch first. +// --setting-sources user and dontAsk and no shell, only the scripted file and +// triage-sweep jobs write, and every job checks the kill switch first. The +// hourly schedule runs only the triage sweep. import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; @@ -28,13 +29,13 @@ test("only a schedule or a manual run starts the lane, with no default permissio assert.match(text, /\npermissions: \{\}\n/); }); -test("the three jobs are collect, judge and file, in that order", () => { - assert.deepEqual(Object.keys(jobs), ["collect", "judge", "file"]); +test("the jobs are collect, judge, file and triage-sweep, in that order", () => { + assert.deepEqual(Object.keys(jobs), ["collect", "judge", "file", "triage-sweep"]); assert.match(jobs.judge, /\n {4}needs: collect\n/); assert.match(jobs.file, /\n {4}needs: \[collect, judge\]\n/); }); -test("only the file job writes, and no job can mint a token", () => { +test("only the file and triage-sweep jobs write, and no job can mint a token", () => { for (const job of ["collect", "judge"]) { assert.ok( Object.values(permissions(job)).every((v) => v === "read"), @@ -46,6 +47,12 @@ test("only the file job writes, and no job can mint a token", () => { contents: "read", issues: "write", }); + assert.deepEqual(permissions("triage-sweep"), { + actions: "write", + contents: "read", + issues: "write", + }); + assert.match(jobs["triage-sweep"], /\n {10}GITHUB_TOKEN: \$\{\{ github\.token \}\}\n/); assert.doesNotMatch(text, /id-token|request-lane-token|secrets\.[A-Z_]*(APP|PRIVATE)_KEY/); }); @@ -82,3 +89,12 @@ test("every job checks the kill switch before its first API or model step", () = assert.ok(work > kill, `${name} works only after the kill switch`); } }); + +test("the daily schedule runs the audit, the hourly one only the triage sweep", () => { + const crons = [...text.matchAll(/\n {4}- cron: "([^"]+)"/g)].map((m) => m[1]); + assert.deepEqual(crons, ["33 6 * * *", "17 * * * *"]); + assert.match(jobs.collect, /\n {4}if: github\.ref == 'refs\/heads\/main' && github\.event\.schedule != '17 \* \* \* \*'\n/); + assert.match(jobs["triage-sweep"], /\n {4}if: github\.ref == 'refs\/heads\/main'\n/); + // Two passes at once could dispatch the same issue twice. + assert.match(jobs["triage-sweep"], /\n {4}concurrency:\n {6}group: [^\n]+\n {6}cancel-in-progress: false\n/); +}); diff --git a/.github/workflows/maintenance-audit-lane-runs.yml b/.github/workflows/maintenance-audit-lane-runs.yml index 1dacce2bcc..d862cadb60 100644 --- a/.github/workflows/maintenance-audit-lane-runs.yml +++ b/.github/workflows/maintenance-audit-lane-runs.yml @@ -17,6 +17,11 @@ name: maintenance-audit-lane-runs # at most 5 issues, within 50 calls. It writes with the job's own token, so # its issues come from github-actions[bot] and start no workflow. # +# A fourth job, triage-sweep, runs alone every hour (and with the daily run): +# it dispatches intake-triage.yml for open issues intake never reached, at +# most 5 a pass, and files one needs-triage issue for an issue 2 dispatches +# left untriaged. It uses the job's own token, never the lanes App. +# # How to stop it: set the organization or repository lanes kill-switch # variable to anything but "false"; every job checks it first. # @@ -25,12 +30,14 @@ name: maintenance-audit-lane-runs on: schedule: - cron: "33 6 * * *" + - cron: "17 * * * *" workflow_dispatch: permissions: {} +# The hourly pass has its own group, so it never waits behind the daily audit. concurrency: - group: maintenance-audit-lane-runs + group: maintenance-audit-lane-runs-${{ github.event.schedule == '17 * * * *' && 'triage' || 'audit' }} cancel-in-progress: false defaults: @@ -39,7 +46,7 @@ defaults: jobs: collect: - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' && github.event.schedule != '17 * * * *' runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -304,3 +311,52 @@ jobs: run: | mkdir -p "$FINDINGS_DIR" node .base/.github/scripts/maintenance-audit-lane-runs/file-findings.mjs + + # A dispatch sent with this token starts a run as github-actions[bot] + # (41898282), which intake-triage.yml admits; intake itself uses its own + # GITHUB_TOKEN and the Claude OAuth secret, no App token. At most 120 REST + # calls, of the token's 1,000 an hour per repository: up to 15 issue pages, + # then for at most 20 candidates up to 3 comment pages and the latest run, + # at most 5 dispatches (2 calls) and 5 filings (4 calls); the pass stops + # before an action the calls left cannot finish. + triage-sweep: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + concurrency: + group: maintenance-audit-lane-runs-triage-sweep + cancel-in-progress: false + permissions: + actions: write # dispatch intake-triage.yml and read its runs + contents: read # check out main + issues: write # write the state comment and file the needs-triage issue + steps: + - name: Check out the run's main commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + path: .base + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .base/.node-version + package-manager-cache: false + + - name: Check the kill switch + id: kill + uses: ./.base/.github/actions/check-kill-switch # zizmor: ignore[self-repository] + with: + value: ${{ vars.CLAUDE_LANES_DISABLED }} + repo-value: ${{ vars.CLAUDE_LANES_REPO_DISABLED }} + + - name: Sweep for untriaged issues + if: steps.kill.outputs.proceed == 'true' + env: + GITHUB_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + TRUSTED_ACTORS_PATH: .base/.github/standards/trusted-actors/trusted-actors.json + LANES_APP_ID: ${{ vars.AUTOMATION_LANES_APP_SENDER_ID }} + CALL_BUDGET: "120" + run: node .base/.github/scripts/maintenance-audit-lane-runs/triage-sweep.mjs From c50206df1bf9faf0df5b9fedc49ca1a09b6e958a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:05:37 -0400 Subject: [PATCH 2/3] docs(pr-pipeline): describe the hourly triage sweep in the lane sweep section Says what the triage-sweep job selects, what it does, and which token it holds. Co-authored-by: Claude Opus 5.5 --- docs/conventions/pr-pipeline/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 9e2fa5c313..b62a837e1e 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -848,6 +848,17 @@ The file job writes with the job's own `GITHUB_TOKEN`, not a lanes App token, so from `github-actions[bot]` and start no workflow, intake triage included. The kill switch stops every job. +A fourth job, **triage-sweep**, runs alone every hour and with the daily run, with no model +([`triage-sweep.mjs`](../../../.github/scripts/maintenance-audit-lane-runs/triage-sweep.mjs)). It +selects open issues, not pull requests, over an hour old, written by a human on the trusted-actors +list or by the lanes App, that have no intake comment, no born-triaged marker and no triage, status, +priority or work-class label. Issues from `github-actions[bot]`, the lane sweep's own included, are +left out. For each it re-dispatches `intake-triage.yml`, at most 5 a pass, and records the runs in +one comment on the issue; after 2 dispatches leave it untriaged it files one `needs-triage` issue +naming the runs and labels the original `needs-triage`. It holds the job's own `GITHUB_TOKEN` +(`actions: write`, `contents: read`, `issues: write`), never a lanes App token, and stops at 120 +REST calls. + ## Models `models` sets the model, effort and turn budget of every model run, so a model change is one edit. From a0f32001cccd86b8d85340c6bbd3c5464dfcf868 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:27:19 -0400 Subject: [PATCH 3/3] fix(pr-pipeline): request run details, page past triaged issues, label before filing in the triage sweep The intake dispatch now sends return_run_details: true; under API version 2022-11-28 the endpoint otherwise answers 204 with no run id, so no attempt was ever recorded. Issues intake already commented on no longer count toward the 20-issue check limit, so they cannot starve the issues behind them. Filing labels the original needs-triage before it creates the handoff issue, so a failed state write cannot file a second one on the next pass. Co-authored-by: Claude Opus 5.5 --- .../triage-sweep.mjs | 13 ++++- .../triage-sweep.test.mjs | 50 ++++++++++++++++--- docs/conventions/pr-pipeline/README.md | 4 +- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/.github/scripts/maintenance-audit-lane-runs/triage-sweep.mjs b/.github/scripts/maintenance-audit-lane-runs/triage-sweep.mjs index 88e886979b..46642da5a7 100644 --- a/.github/scripts/maintenance-audit-lane-runs/triage-sweep.mjs +++ b/.github/scripts/maintenance-audit-lane-runs/triage-sweep.mjs @@ -102,7 +102,11 @@ export async function runSweep({ call, remaining = () => Infinity, repo, serverU const candidates = selectCandidates({ issues, now, authorIds }); result.candidates = candidates.length; const handOff = (number) => call("POST", `/repos/${repo}/issues/${number}/labels`, { labels: ["needs-triage"] }); - for (const { number } of candidates.slice(0, MAX_CHECKED)) { + // Issues intake already commented on cost one read and do not count toward + // MAX_CHECKED, so they cannot starve the issues behind them. + let checked = 0; + for (const { number } of candidates) { + if (checked >= MAX_CHECKED) break; if (result.dispatched.length >= MAX_DISPATCHES && result.filed.length >= MAX_FILED) break; try { const comments = []; @@ -114,6 +118,7 @@ export async function runSweep({ call, remaining = () => Infinity, repo, serverU if (!full) break; } if (hasIntakeComment(comments)) continue; + checked += 1; // Past the pages read, the state comment may be hidden: a person decides. if (full) { await handOff(number); @@ -138,6 +143,8 @@ export async function runSweep({ call, remaining = () => Infinity, repo, serverU const sent = await call("POST", `/repos/${repo}/actions/workflows/intake-triage.yml/dispatches`, { ref: "main", inputs: { "issue-number": String(number) }, + // Without it, API version 2022-11-28 answers 204 with no run id. + return_run_details: true, }); const id = Number(sent?.workflow_run_id); if (!Number.isInteger(id) || id <= 0) throw new Error("the dispatch returned no run id"); @@ -154,10 +161,12 @@ export async function runSweep({ call, remaining = () => Infinity, repo, serverU const run = id === state.runs.at(-1) ? latest : await call("GET", `/repos/${repo}/actions/runs/${id}`); runs.push({ id, conclusion: run?.conclusion ?? null }); } + // The label goes first: it takes the original out of later passes, so a + // write that fails after it can never file a second issue. + await handOff(number); const filed = await call("POST", `/repos/${repo}/issues`, renderFiledIssue({ number, runs, repoUrl })); result.filed.push(filed.number); await writeState(call, repo, number, state, { runs: state.runs, filed: filed.number }); - await handOff(number); } } catch (error) { if (/call budget/.test(error.message)) { diff --git a/.github/scripts/maintenance-audit-lane-runs/triage-sweep.test.mjs b/.github/scripts/maintenance-audit-lane-runs/triage-sweep.test.mjs index 689e5fd788..6a80acc86b 100644 --- a/.github/scripts/maintenance-audit-lane-runs/triage-sweep.test.mjs +++ b/.github/scripts/maintenance-audit-lane-runs/triage-sweep.test.mjs @@ -122,7 +122,7 @@ test("the filed issue names the issue and every failed run, and carries needs-tr }); // A fake GitHub holding issues, comments and runs, recording every write. -function fakeGitHub({ issues, comments = {}, runs = {} }) { +function fakeGitHub({ issues, comments = {}, runs = {}, failPatch = false }) { const writes = []; let nextRun = 900; let nextIssue = 5000; @@ -139,12 +139,20 @@ function fakeGitHub({ issues, comments = {}, runs = {} }) { return runs[m[1]] ?? null; } writes.push({ method, path, body }); + // API version 2022-11-28 returns the run id only when return_run_details + // is true, else 204, which `call` turns into null. if (method === "POST" && path === "/repos/o/r/actions/workflows/intake-triage.yml/dispatches") { + if (body?.return_run_details !== true) return null; nextRun += 1; return { workflow_run_id: nextRun }; } if (method === "POST" && path === "/repos/o/r/issues") return { number: (nextIssue += 1) }; - if (method === "POST" && path.endsWith("/labels")) return []; + if (method === "POST" && (m = /^\/repos\/o\/r\/issues\/(\d+)\/labels$/.exec(path))) { + const target = issues.find((i) => i.number === Number(m[1])); + target.labels = [...(target.labels ?? []), ...body.labels.map((name) => ({ name }))]; + return []; + } + if (method === "PATCH" && failPatch) throw new Error(`PATCH ${path}: HTTP 502`); if (method === "POST" || method === "PATCH") return { id: (nextComment += 1) }; throw new Error(`unexpected ${method} ${path}`); }; @@ -163,7 +171,7 @@ test("a pass dispatches at most 5 issues, oldest first, and records each run on const dispatches = gh.writes.filter((w) => w.path.endsWith("/dispatches")); assert.deepEqual( dispatches.map((w) => w.body), - [1, 2, 3, 4, 5].map((n) => ({ ref: "main", inputs: { "issue-number": String(n) } })), + [1, 2, 3, 4, 5].map((n) => ({ ref: "main", inputs: { "issue-number": String(n) }, return_run_details: true })), ); const states = gh.writes.filter((w) => /\/issues\/\d+\/comments$/.test(w.path)); assert.deepEqual( @@ -213,16 +221,16 @@ test("after 2 untriaged dispatches the sweep files once, then never dispatches o assert.deepEqual( first.writes.map((w) => [w.method, w.path]), [ + ["POST", "/repos/o/r/issues/1/labels"], ["POST", "/repos/o/r/issues"], ["PATCH", "/repos/o/r/issues/comments/5"], - ["POST", "/repos/o/r/issues/1/labels"], ], ); - assert.deepEqual(first.writes[0].body.labels, ["needs-triage"]); // The label takes the original out of later passes' candidates. - assert.deepEqual(first.writes[2].body, { labels: ["needs-triage"] }); + assert.deepEqual(first.writes[0].body, { labels: ["needs-triage"] }); + assert.deepEqual(first.writes[1].body.labels, ["needs-triage"]); assert.deepEqual(result.filed, [5001]); - const marked = first.writes[1].body.body; + const marked = first.writes[2].body.body; assert.equal(readState([{ id: 5, user: BOT, body: marked }]).filed, 5001); const second = fakeGitHub({ @@ -280,3 +288,31 @@ test("a pass stops dispatching when the budget left cannot record the run", asyn assert.equal(gh.writes.filter((w) => w.path.endsWith("/dispatches")).length, 1); assert.match(result.truncated, /budget/); }); + +test("a filing whose state write fails is not filed again on the next pass", async () => { + const gh = fakeGitHub({ + issues: [issue(1)], + comments: { 1: [{ id: 5, user: BOT, body: renderState({ runs: [11, 22], filed: null, runUrl: "u" }) }] }, + runs: { + 11: { status: "completed", conclusion: "failure" }, + 22: { status: "completed", conclusion: "failure" }, + }, + failPatch: true, + }); + const first = await sweep(gh); + assert.equal(first.failed.length, 1); + await sweep(gh); + assert.equal(gh.writes.filter((w) => w.method === "POST" && w.path === "/repos/o/r/issues").length, 1); +}); + +test("issues intake already commented on do not use up the per-pass check limit", async () => { + const commented = Array.from({ length: 20 }, (_, i) => issue(i + 1, { created_at: "2026-10-01T00:00:00Z" })); + const gh = fakeGitHub({ + issues: [...commented, issue(21)], + comments: Object.fromEntries( + commented.map((i) => [i.number, [{ id: i.number, user: BOT, body: "\nok" }]]), + ), + }); + const result = await sweep(gh); + assert.deepEqual(result.dispatched, [21]); +}); diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index b62a837e1e..50e4db7e9d 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -854,8 +854,8 @@ selects open issues, not pull requests, over an hour old, written by a human on list or by the lanes App, that have no intake comment, no born-triaged marker and no triage, status, priority or work-class label. Issues from `github-actions[bot]`, the lane sweep's own included, are left out. For each it re-dispatches `intake-triage.yml`, at most 5 a pass, and records the runs in -one comment on the issue; after 2 dispatches leave it untriaged it files one `needs-triage` issue -naming the runs and labels the original `needs-triage`. It holds the job's own `GITHUB_TOKEN` +one comment on the issue; after 2 dispatches leave it untriaged it labels the original +`needs-triage`, then files one `needs-triage` issue naming the runs. It holds the job's own `GITHUB_TOKEN` (`actions: write`, `contents: read`, `issues: write`), never a lanes App token, and stops at 120 REST calls.