Skip to content
Merged
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
49 changes: 49 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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<boolean> {
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<void> {
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 <finding>` (#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
Expand Down
140 changes: 136 additions & 4 deletions test/unit/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://github.com/ghapi/repos/JSONbored/gittensory/pulls/77" } };
async function seedPausePr(env: Env): Promise<void> {
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<typeof processJob>[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, {
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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 }>();
Expand Down