From e9b21c745df395d6bb23698a1c24cc6ee3f509f4 Mon Sep 17 00:00:00 2001 From: davion-knight <298846663+davion-knight@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:48:17 -0500 Subject: [PATCH] feat(commands): wire @gittensory pause dispatch to record the autoreview-paused marker (#2164) Adds maybeProcessPauseCommand, the dispatch handler for the @gittensory pause PR-comment verb (already a registered parse target + authorization policy from #1960). It pauses AUTO-REVIEW for a single PR by recording a per-PR github_app.autoreview_paused audit marker the sweep/webhook re-review path can honor, and posts a public-safe confirmation. Auto-review scope only: it records nothing on the Gate check-run, flips no AgentActionMode, and builds no advisory, so the one-shot gate disposition and its enforcement are left intact. Mirrors maybeProcessResolveCommand's classify -> authorize -> record shape (classifyPrCommandRequest + authorizePrActionActor + the gate-override skip/denied/completed recording convention). Unlike gate-override it does not consult resolveAgentActionMode: the pause is itself the stop-auto-reviewing instruction, so the marker + confirmation are recorded unconditionally on an authorized pause. Closes #2164 --- src/queue/processors.ts | 49 ++++++++++++++ test/unit/queue.test.ts | 140 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 185 insertions(+), 4 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 0f0f65b03a..33920f513f 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -5594,6 +5594,7 @@ async function processGitHubWebhook( if (eventName === "issue_comment" && (await maybeProcessResolveCommand(env, deliveryId, payload))) { await recordWebhookEvent(env, { deliveryId, eventName, action: payload.action, installationId: payload.installation?.id, repositoryFullName: payload.repository?.full_name, payloadHash: "processed", status: "processed" }); return; } if (eventName === "issue_comment" && (await maybeProcessExplainCommand(env, deliveryId, payload))) { await recordWebhookEvent(env, { deliveryId, eventName, action: payload.action, installationId: payload.installation?.id, repositoryFullName: payload.repository?.full_name, payloadHash: "processed", status: "processed" }); return; } + if (eventName === "issue_comment" && (await maybeProcessPauseCommand(env, deliveryId, payload))) { await recordWebhookEvent(env, { deliveryId, eventName, action: payload.action, installationId: payload.installation?.id, repositoryFullName: payload.repository?.full_name, payloadHash: "processed", status: "processed" }); return; } if ( eventName === "issue_comment" && (await maybeProcessConfigurationCommand(env, deliveryId, payload)) @@ -10693,6 +10694,54 @@ async function maybeProcessResolveCommand(env: Env, deliveryId: string, payload: await recordAuditEvent(env, { eventType: "github_app.finding_resolved", actor: req.actor, targetKey, outcome: "completed", detail: `Marked ${resolvedLabel} as resolved.`, metadata: { deliveryId, repoFullName: req.repoFullName, scope: findingRef.scope, resolvedWarningCount: selection.findings.length, recordedSuppressionCount, ...(findingRef.scope === "single" ? { findingCode: findingRef.findingCode } : {}) } }); await recordGithubProductUsage(env, "finding_resolved", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "completed", metadata: { scope: findingRef.scope, resolvedWarningCount: selection.findings.length, recordedSuppressionCount, ...(findingRef.scope === "single" ? { findingCode: findingRef.findingCode } : {}) } }); return true; } +/** + * `@gittensory pause` (#2164, part of #1960): a maintainer pauses AUTO-REVIEW for THIS PR only by recording a + * per-PR `github_app.autoreview_paused` marker (an audit event keyed to repo#pr) that the sweep/webhook re-review + * path can honor. AUTO-REVIEW SCOPE ONLY — it deliberately touches neither the Gate check-run, the AgentActionMode, + * nor any advisory, so the one-shot gate disposition and its enforcement are left intact (#1960's hard constraint: + * pause must never flip the gate to advisory or bypass the disposition; the gate-enforcement side and any + * repository_settings kill-switch stay maintainer-owned). Mirrors maybeProcessResolveCommand's classify → authorize + * → record shape (classifyPrCommandRequest + authorizePrActionActor + the gate-override skip/denied/completed + * recording convention). Unlike gate-override it does NOT consult resolveAgentActionMode: the pause IS the + * "stop auto-reviewing" instruction, so gating the marker behind the execution mode would make an already + * paused/dry-run agent impossible to pause — the marker + public-safe confirmation are therefore recorded + * unconditionally on an authorized pause. Returns true once it owns the event; a non-pause comment returns false + * and falls through to the other command handlers. + */ +async function maybeProcessPauseCommand(env: Env, deliveryId: string, payload: GitHubWebhookPayload): Promise { + const command = parseGittensoryMentionCommand(payload.comment?.body); + if (!command || command.name !== "pause") return false; + const { classifyPrCommandRequest } = await import("../github/pr-command-request"); + const req = classifyPrCommandRequest(payload, getInstallationId(payload)); + if (!req.ok) { + await recordAutoreviewPausedSkip(env, deliveryId, req.repoFullName, req.targetKey, req.actor, req.reason); + return true; + } + const targetKey = `${req.repoFullName}#${req.pr.number}`; + const [pr, settings] = await Promise.all([getPullRequest(env, req.repoFullName, req.pr.number), resolveRepositorySettings(env, req.repoFullName)]); + if (!pr) { + await recordAutoreviewPausedSkip(env, deliveryId, req.repoFullName, targetKey, req.actor, "cached_pr_missing"); + return true; + } + const { authorization } = await authorizePrActionActor({ env, deliveryId, installationId: req.installationId, repoFullName: req.repoFullName, issue: payload.issue!, actor: req.actor, commandName: "pause" as GittensoryMentionCommandName, settings, pr }); + if (!authorization.authorized) { + await recordAuditEvent(env, { eventType: "github_app.autoreview_paused_denied", actor: req.actor, targetKey, outcome: "denied", detail: authorization.reason, metadata: { deliveryId, repoFullName: req.repoFullName, allowedRoles: commandAuthorizationAllowedRoles(settings.commandAuthorization, "pause") } }); + await recordGithubProductUsage(env, "autoreview_paused_denied", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "denied", metadata: { reason: authorization.reason, actorKind: authorization.actorKind, allowedRoles: commandAuthorizationAllowedRoles(settings.commandAuthorization, "pause") } }); + return true; + } + const safeReason = sanitizePublicComment((command.reason ?? "").trim() || "No reason provided."); + const confirmation = sanitizePublicComment([AGENT_COMMAND_COMMENT_MARKER, "", "> [!NOTE]", `> **Auto-review paused by @${req.actor}**`, "> Auto-review is paused for this PR only. Gate enforcement and the one-shot disposition are unchanged; use `@gittensory resume` to re-enable auto-review.", "", `- Reason: ${safeReason}`, "", "---", gittensoryFooter()].join("\n")); + await createIssueComment(env, req.installationId, req.repoFullName, req.pr.number, confirmation); + await recordAuditEvent(env, { eventType: "github_app.autoreview_paused", actor: req.actor, targetKey, outcome: "completed", detail: safeReason, metadata: { deliveryId, repoFullName: req.repoFullName } }); + await recordGithubProductUsage(env, "autoreview_paused", { actor: req.actor, repoFullName: req.repoFullName, targetKey, outcome: "completed", metadata: { actorKind: authorization.actorKind } }); + return true; +} + +async function recordAutoreviewPausedSkip(env: Env, deliveryId: string, repoFullName: string | null, targetKey: string | null, actor: string | null, reason: string): Promise { + await recordAuditEvent(env, { eventType: "github_app.autoreview_paused_skipped", actor, targetKey, outcome: "completed", detail: reason, metadata: { deliveryId, repoFullName, reason } }); + await recordGithubProductUsage(env, "autoreview_paused_skipped", { actor, repoFullName, targetKey, outcome: "skipped", metadata: { reason } }); +} + /** * `@gittensory explain ` (#2169, part of #1960): a contributor/maintainer asks for more detail on a * specific posted review finding. Read-only — it looks the finding up in THIS PR's current advisory (the same diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 3813ad90ce..ab10b0c33b 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -10765,6 +10765,138 @@ describe("queue processors", () => { expect(skip?.detail).toBe("unsupported_comment_action_or_bot"); }); + const pauseIssue = { number: 77, title: "Add a retry to the fetch helper", state: "open", user: { login: "reporter" }, body: "b", pull_request: { url: "https://api.github.com/repos/JSONbored/gittensory/pulls/77" } }; + async function seedPausePr(env: Env): Promise { + await setupPlannerRepo(env); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 77, title: "Add a retry to the fetch helper", state: "open", user: { login: "reporter" }, head: { sha: "h1" }, labels: [], body: "b" }); + } + + it("pause (#2164): a maintainer @gittensory pause records the autoreview-paused marker and posts a public-safe confirmation", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await seedPausePr(env); + let postedBody: string | undefined; + const urls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + urls.push(url); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/") && url.includes("/permission")) return Response.json({ permission: "admin" }); // maintainer + if (url.includes("/issues/77/comments")) { + postedBody = init?.body ? JSON.parse(init.body.toString()).body : undefined; + return Response.json({ id: 5 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + await processJob(env, plannerWebhook("@gittensory pause flaky CI, will re-enable after the fix", "maintainer1", pauseIssue)); + expect(postedBody).toContain("Auto-review paused by @maintainer1"); + expect(postedBody).toContain("Gate enforcement and the one-shot disposition are unchanged"); + expect(postedBody).toContain("flaky CI, will re-enable after the fix"); + // AUTO-REVIEW SCOPE ONLY (#2164): no Gate check-run is written and no gate-disposition audit is recorded, so the + // one-shot gate/advisory is provably untouched. + expect(urls.some((u) => u.includes("/check-runs"))).toBe(false); + const gateAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type like 'github_app.gate_%'").first<{ n: number }>(); + expect(gateAudit?.n).toBe(0); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.autoreview_paused").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("completed"); + expect(audit?.detail).toBe("flaky CI, will re-enable after the fix"); + const usage = await env.DB.prepare("select outcome from product_usage_events where event_name = ?").bind("autoreview_paused").first<{ outcome: string }>(); + expect(usage?.outcome).toBe("completed"); + }); + + it("pause: an authorized pause with no trailing reason records the marker with a 'No reason provided.' detail", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await seedPausePr(env); + let postedBody: string | undefined; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/") && url.includes("/permission")) return Response.json({ permission: "admin" }); + if (url.includes("/issues/77/comments")) { + postedBody = init?.body ? JSON.parse(init.body.toString()).body : undefined; + return Response.json({ id: 5 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + await processJob(env, plannerWebhook("@gittensory pause", "maintainer1", pauseIssue)); + expect(postedBody).toContain("No reason provided."); + const audit = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.autoreview_paused").first<{ detail: string }>(); + expect(audit?.detail).toBe("No reason provided."); + }); + + it("pause: a non-maintainer is denied — nothing is posted and a denied marker is recorded (never a pause)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await seedPausePr(env); + let posted = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/") && url.includes("/permission")) return Response.json({ permission: "read" }); // not a maintainer + if (url.includes("/issues/77/comments")) { + posted = true; + return Response.json({ id: 5 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + await processJob(env, plannerWebhook("@gittensory pause let me in", "outsider", pauseIssue)); + expect(posted).toBe(false); + const denied = await env.DB.prepare("select outcome from audit_events where event_type = ?").bind("github_app.autoreview_paused_denied").first<{ outcome: string }>(); + expect(denied?.outcome).toBe("denied"); + const paused = await env.DB.prepare("select 1 from audit_events where event_type = ?").bind("github_app.autoreview_paused").first(); + expect(paused).toBeFalsy(); + }); + + it("pause: a pause on a PR with no cached record is recorded as a cached_pr_missing skip, never posted", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await setupPlannerRepo(env); // repo + installation, but deliberately NO cached PR record + let posted = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/") && url.includes("/permission")) return Response.json({ permission: "admin" }); + if (url.includes("/comments")) posted = true; + return new Response("not found", { status: 404 }); + }); + await processJob(env, plannerWebhook("@gittensory pause", "maintainer1", pauseIssue)); + expect(posted).toBe(false); + const skip = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.autoreview_paused_skipped").first<{ detail: string }>(); + expect(skip?.detail).toBe("cached_pr_missing"); + }); + + it("pause: a bot-authored @gittensory pause is recorded as a classifier skip, never posted", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await seedPausePr(env); + let posted = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("/comments")) posted = true; + return new Response("not found", { status: 404 }); + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "pause-bot", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: pauseIssue, + comment: { body: "@gittensory pause", user: { login: "some-bot[bot]", type: "Bot" } }, + sender: { login: "some-bot[bot]", type: "Bot" }, + }, + } as unknown as Parameters[1]); + expect(posted).toBe(false); + const skip = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.autoreview_paused_skipped").first<{ detail: string }>(); + expect(skip?.detail).toBe("bot_author"); + }); + + it("pause: a non-pause comment is not intercepted (the handler declines, no autoreview audit)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await seedPausePr(env); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); + await processJob(env, plannerWebhook("just a normal comment, no mention", "maintainer1", pauseIssue)); + const paused = await env.DB.prepare("select 1 from audit_events where event_type like 'github_app.autoreview_paused%'").first(); + expect(paused).toBeFalsy(); + }); + it("REGRESSION (#audit-draft-maintenance): a clean DRAFT PR is never auto-merged/approved/closed (drafts are WIP)", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertInstallation(env, { @@ -23627,7 +23759,7 @@ describe("queue processors", () => { }); }); - it("a #1960 action-command verb with no dispatch handler wired yet (e.g. pause) is bailed out of the Q&A answer-card path, not misrendered as help (#2160)", async () => { + it("a #1960 action-command verb with no dispatch handler wired yet (e.g. resume) is bailed out of the Q&A answer-card path, not misrendered as help (#2160)", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); await upsertRepositorySettings(env, { @@ -23676,13 +23808,13 @@ describe("queue processors", () => { installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, issue: { number: 93, title: "Not yet wired", state: "open", user: { login: "contributor" }, pull_request: {} }, - comment: { id: 900, body: "@gittensory pause", author_association: "OWNER", user: { login: "maintainer", type: "User" } }, + comment: { id: 900, body: "@gittensory resume", author_association: "OWNER", user: { login: "maintainer", type: "User" } }, sender: { login: "maintainer", type: "User" }, }, }); - // No handler claims a bare "pause" comment yet (its dispatch lands in a follow-up bounty -- unlike - // "resolve"/"configuration", which now have their own handlers), so the Q&A answer-card path must bail + // No handler claims a bare "resume" comment yet (its dispatch lands in a follow-up bounty -- unlike + // "pause"/"resolve"/"configuration", which now have their own handlers), so the Q&A answer-card path must bail // rather than post a stray "help" card or any other Q&A comment. expect(calls.comments).toBe(0); const feedback = await env.DB.prepare("select id from audit_events where event_type = ?").bind("github_app.agent_command_feedback_prompted").first<{ id: string }>();