Skip to content

Commit 0d9c29f

Browse files
committed
fix(review): stop redundant republishing for a draft fork PR's repeat CI-completion triggers (#6685)
A draft fork PR's own CI produces one check_run.completed webhook per job (each arriving with an empty pull_requests[] payload, forcing the fork-resume fallback in webhook-coalesce.ts to re-run the whole evaluation pass), so a multi-job CI run republished the SAME comment/label surface once per completing job even though nothing about the PR changed between them. Confirmed live: 12 identical republishes of #6592 in 29 minutes -- the actual root cause of the ops_anomaly "review burst" false alarms that have been firing across the fleet for at least a day. The one-shot cadence guard already avoids wasting AI spend on these repeat passes (ai_slop_one_shot_skip, linked_issue_satisfaction_one_shot_skip, ai_review_auto_review_skipped all correctly fire), but nothing stopped the pipeline from proceeding to republish anyway. The only existing "skip republish if nothing changed" guard (canSkipCurrentSurface) is deliberately scoped to publicSurface: "off" repos, and requires gateEnabled -- which is false fleet-wide here since reviewCheckMode is disabled, so that guard never even engages for loopover/awesome-claude/metagraphed regardless of surface type. Add a narrower, independent skip specifically for the draft case: when autoReviewSkipReason is deterministically "review skipped (draft)" (not just reused from cache) and the head SHA matches what was last fully published, republishing is a provable no-op. Hoisted the reason into a try-block-independent variable (autoReviewSkipReasonForPublish) since the original is scoped inside the try block that ends before the publish site, mirroring the existing aiReviewExpected/aiReviewWasReused hoisting pattern. Closes #6685
1 parent 11a6dac commit 0d9c29f

2 files changed

Lines changed: 229 additions & 0 deletions

File tree

src/queue/processors.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8297,6 +8297,9 @@ async function maybePublishPrPublicSurface(
82978297
let aiReviewExpected = false;
82988298
let aiReviewWasReused = false;
82998299
let gateFinalized = false;
8300+
// #6685: hoisted the same way aiReviewExpected/aiReviewWasReused are above -- assigned inside the try block
8301+
// below, read at the draft-republish skip check past it (autoReviewSkipReason itself is try-block-scoped).
8302+
let autoReviewSkipReasonForPublish: string | null = null;
83008303
const publishedOutputs: PublicSurfaceOutput[] = [];
83018304
const failedOutputs: PublicSurfaceOutputFailure[] = [];
83028305
const reviewedHeadSha = reviewedPullRequestHeadSha(pr.headSha, advisory.headSha);
@@ -8906,6 +8909,7 @@ async function maybePublishPrPublicSurface(
89068909
addedLineCount: autoReviewAddedLineCount,
89078910
changedFileCount: autoReviewChangedFileCount,
89088911
}));
8912+
autoReviewSkipReasonForPublish = autoReviewSkipReason;
89098913
// review.changed_files_summary (#1957) + review.effort_score (#1955): both deterministic, no-AI — resolve
89108914
// them here, UNCONDITIONALLY, rather than inside the aiReviewWillRun-gated closure below. These sections
89118915
// must still render whenever the manifest opts in even when the AI review itself is skipped this pass
@@ -10001,6 +10005,35 @@ async function maybePublishPrPublicSurface(
1000110005
if (!prelimHasPublicOutput) return finishPublicSurfacePublication();
1000210006
if (publicSurfaceSkipped || !official || !author)
1000310007
return finishPublicSurfacePublication();
10008+
// #6685 (review-burst): a draft fork PR's own CI produces one check_run.completed webhook per job (each
10009+
// arriving with an empty pull_requests[] payload, forcing the fork-resume fallback in webhook-coalesce.ts
10010+
// to re-run this whole pass), so a multi-job CI run on a draft PR republished the SAME surface once per
10011+
// completing job even though nothing about the PR changed between them -- confirmed live, 12 identical
10012+
// republishes of JSONbored/loopover#6592 in 29 minutes. autoReviewSkipReason === "review skipped (draft)"
10013+
// is a deterministic signal (from pr.isDraft + config, resolved before any AI/cache call) that this pass
10014+
// has nothing new to report from the review dimension; a matching lastPublishedSurfaceSha proves the head
10015+
// hasn't moved since the last full publish either. Together they're a provable no-op for the draft case
10016+
// specifically -- narrower than, and independent of, the check-run-only skip above (canSkipCurrentSurface
10017+
// requires gateEnabled, which is false fleet-wide here since reviewCheckMode is disabled), so this applies
10018+
// regardless of publicSurface/gateEnabled. !forceAiReview preserves an explicit maintainer re-trigger the
10019+
// same way that guard does.
10020+
if (
10021+
autoReviewSkipReasonForPublish === "review skipped (draft)" &&
10022+
!webhook.forceAiReview &&
10023+
advisory.headSha &&
10024+
advisory.headSha === pr.lastPublishedSurfaceSha
10025+
) {
10026+
incr("loopover_public_surface_publish_skipped_current_total");
10027+
await recordAuditEvent(env, {
10028+
eventType: "github_app.public_surface_publish_skipped_current",
10029+
actor: author,
10030+
targetKey: `${repoFullName}#${pr.number}`,
10031+
outcome: "completed",
10032+
detail: "draft PR already current for this head; skipped republish",
10033+
metadata: { deliveryId: webhook.deliveryId, repoFullName, headSha: advisory.headSha },
10034+
}).catch(() => undefined);
10035+
return finishPublicSurfacePublication();
10036+
}
1000410037

1000510038
const [github] = await Promise.all([
1000610039
fetchPublicContributorProfile(author, env),

test/unit/queue.test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6011,6 +6011,202 @@ describe("queue processors", () => {
60116011
expect(reuseAudit?.n).toBe(2); // both later passes explicitly reused the durable cache
60126012
});
60136013

6014+
it("REGRESSION (#6685): a draft PR's repeat fork-CI-completion triggers stop republishing once the head is already current", async () => {
6015+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
6016+
// reviewCheckMode: "disabled" reproduces the live incident exactly -- gateEnabled (shouldPublishReviewCheck
6017+
// && headSha) is false here, so the OLDER check-run-only skip guard (canSkipCurrentSurface, gated on
6018+
// gateEnabled) never engages; this fix must not depend on it.
6019+
await seedRegateChurnRepo(env, { publicSurface: "comment_and_label" });
6020+
await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { commentMode: "all_prs", publicSurface: "comment_and_label", checkRunMode: "off", reviewCheckMode: "disabled", aiReviewMode: "block" }, review: { auto_review: { skip_drafts: true, cadence: "continuous" } } });
6021+
await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 90, title: "Draft feature", state: "open", draft: true, user: { login: "contributor" }, head: { sha: "a90" }, labels: [], body: "Closes #1" } as never);
6022+
await upsertPullRequestDetailSyncState(env, { repoFullName: "JSONbored/gittensory", pullNumber: 90, status: "complete", reviewsSyncedAt: new Date().toISOString() });
6023+
6024+
const stickyComment: { current: { id: number; body: string } | null } = { current: null };
6025+
let commentPosts = 0;
6026+
let commentPatches = 0;
6027+
let labelPosts = 0;
6028+
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
6029+
const url = input.toString();
6030+
const method = init?.method ?? "GET";
6031+
if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" });
6032+
if (url.includes("/pulls/90/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]);
6033+
if (url.endsWith("/pulls/90")) return Response.json({ number: 90, title: "Draft feature", state: "open", draft: true, user: { login: "contributor" }, head: { sha: "a90" }, labels: [], body: "Closes #1", mergeable_state: "clean" });
6034+
if (url.includes("/commits/a90/check-runs")) return Response.json({ total_count: 0, check_runs: [] });
6035+
if (url.includes("/commits/a90/status")) return Response.json({ state: "success", statuses: [] });
6036+
if (url.includes("/issues/1")) return Response.json({ number: 1, title: "Issue", state: "open", labels: [], user: { login: "reporter" } });
6037+
if (url.includes("/issues/90/comments") && method === "GET") {
6038+
return Response.json(stickyComment.current ? [{ ...stickyComment.current, user: { login: "gittensory[bot]", type: "Bot" } }] : []);
6039+
}
6040+
if (url.includes("/issues/90/comments") && method === "POST") {
6041+
commentPosts += 1;
6042+
const body = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? "");
6043+
stickyComment.current = { id: 1, body };
6044+
return Response.json({ id: 1 }, { status: 201 });
6045+
}
6046+
if (url.includes("/issues/comments/1") && method === "PATCH") {
6047+
commentPatches += 1;
6048+
const body = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? "");
6049+
stickyComment.current = { id: 1, body };
6050+
return Response.json({ id: 1 }, { status: 200 });
6051+
}
6052+
if (url.includes("/issues/90/labels") && method === "GET") return Response.json([]);
6053+
if (url.includes("/issues/90/labels") && method === "POST") {
6054+
labelPosts += 1;
6055+
return Response.json([]);
6056+
}
6057+
if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } });
6058+
return Response.json({});
6059+
});
6060+
6061+
// First pass: the initial publish for this head -- always goes through in full (a CREATE placeholder,
6062+
// then a PATCH to the final settled content, mirroring #3379's own first-pass shape).
6063+
await processJob(env, { type: "agent-regate-pr", deliveryId: "fork-resume-1", repoFullName: "JSONbored/gittensory", prNumber: 90, installationId: 123 });
6064+
expect(commentPosts).toBe(1);
6065+
const patchesAfterFirst = commentPatches;
6066+
const labelPostsAfterFirst = labelPosts;
6067+
expect(labelPostsAfterFirst).toBeGreaterThan(0);
6068+
6069+
// Three more fork-resume passes over the SAME unchanged head SHA (mirroring one webhook per completing
6070+
// CI job on a fork PR, confirmed live: 12 of these in 29 minutes for JSONbored/loopover#6592).
6071+
await processJob(env, { type: "agent-regate-pr", deliveryId: "fork-resume-2", repoFullName: "JSONbored/gittensory", prNumber: 90, installationId: 123 });
6072+
await processJob(env, { type: "agent-regate-pr", deliveryId: "fork-resume-3", repoFullName: "JSONbored/gittensory", prNumber: 90, installationId: 123 });
6073+
await processJob(env, { type: "agent-regate-pr", deliveryId: "fork-resume-4", repoFullName: "JSONbored/gittensory", prNumber: 90, installationId: 123 });
6074+
6075+
expect(commentPosts).toBe(1); // never re-created
6076+
expect(commentPatches).toBe(patchesAfterFirst); // never rewritten either — the whole publish pass is skipped, not just diffed away
6077+
6078+
const publishedAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?")
6079+
.bind("github_app.pr_public_surface_published", "JSONbored/gittensory#90")
6080+
.first<{ n: number }>();
6081+
expect(publishedAudit?.n).toBe(1); // only the first pass ever counts as a publish
6082+
6083+
const skippedAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?")
6084+
.bind("github_app.public_surface_publish_skipped_current", "JSONbored/gittensory#90")
6085+
.first<{ n: number }>();
6086+
expect(skippedAudit?.n).toBe(3); // all three repeat fork-resume passes were proven redundant up-front
6087+
});
6088+
6089+
it("REGRESSION (#6685): a draft PR's republish-skip does NOT apply once a new commit changes the head, or when a maintainer forces a re-trigger", async () => {
6090+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
6091+
await seedRegateChurnRepo(env, { publicSurface: "comment_and_label" });
6092+
await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { commentMode: "all_prs", publicSurface: "comment_and_label", checkRunMode: "off", reviewCheckMode: "disabled", aiReviewMode: "block" }, review: { auto_review: { skip_drafts: true, cadence: "continuous" } } });
6093+
await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 91, title: "Draft feature", state: "open", draft: true, user: { login: "contributor" }, head: { sha: "b91" }, labels: [], body: "Closes #1" } as never);
6094+
await upsertPullRequestDetailSyncState(env, { repoFullName: "JSONbored/gittensory", pullNumber: 91, status: "complete", reviewsSyncedAt: new Date().toISOString() });
6095+
6096+
let headSha = "b91";
6097+
const stickyComment: { current: { id: number; body: string } | null } = { current: null };
6098+
let commentPosts = 0;
6099+
let commentPatches = 0;
6100+
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
6101+
const url = input.toString();
6102+
const method = init?.method ?? "GET";
6103+
if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" });
6104+
if (url.includes(`/pulls/91/files`)) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]);
6105+
if (url.endsWith("/pulls/91")) return Response.json({ number: 91, title: "Draft feature", state: "open", draft: true, user: { login: "contributor" }, head: { sha: headSha }, labels: [], body: "Closes #1", mergeable_state: "clean" });
6106+
if (url.includes(`/commits/${headSha}/check-runs`)) return Response.json({ total_count: 0, check_runs: [] });
6107+
if (url.includes(`/commits/${headSha}/status`)) return Response.json({ state: "success", statuses: [] });
6108+
if (url.includes("/issues/1")) return Response.json({ number: 1, title: "Issue", state: "open", labels: [], user: { login: "reporter" } });
6109+
if (url.includes("/issues/91/comments") && method === "GET") {
6110+
return Response.json(stickyComment.current ? [{ ...stickyComment.current, user: { login: "gittensory[bot]", type: "Bot" } }] : []);
6111+
}
6112+
if (url.includes("/issues/91/comments") && method === "POST") {
6113+
commentPosts += 1;
6114+
const body = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? "");
6115+
stickyComment.current = { id: 1, body };
6116+
return Response.json({ id: 1 }, { status: 201 });
6117+
}
6118+
if (url.includes("/issues/comments/1") && method === "PATCH") {
6119+
commentPatches += 1;
6120+
const body = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? "");
6121+
stickyComment.current = { id: 1, body };
6122+
return Response.json({ id: 1 }, { status: 200 });
6123+
}
6124+
if (url.includes("/issues/91/labels") && method === "GET") return Response.json([]);
6125+
if (url.includes("/issues/91/labels") && method === "POST") return Response.json([]);
6126+
if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } });
6127+
return Response.json({});
6128+
});
6129+
6130+
await processJob(env, { type: "agent-regate-pr", deliveryId: "fork-resume-1", repoFullName: "JSONbored/gittensory", prNumber: 91, installationId: 123 });
6131+
expect(commentPosts).toBe(1);
6132+
const patchesAfterFirst = commentPatches;
6133+
6134+
// A new commit lands (new head SHA) -- the next fork-resume pass must NOT be treated as redundant. A
6135+
// repeat publish to an EXISTING sticky comment is a PATCH (update in place), not a second POST, so a
6136+
// real republish shows up as an additional PATCH here, not another create.
6137+
headSha = "c91";
6138+
await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 91, title: "Draft feature", state: "open", draft: true, user: { login: "contributor" }, head: { sha: headSha }, labels: [], body: "Closes #1" } as never);
6139+
await processJob(env, { type: "agent-regate-pr", deliveryId: "fork-resume-2", repoFullName: "JSONbored/gittensory", prNumber: 91, installationId: 123 });
6140+
const patchesAfterNewHead = commentPatches;
6141+
expect(patchesAfterNewHead).toBeGreaterThan(patchesAfterFirst); // republished (as an update) for the new head, not skipped
6142+
6143+
const publishedAfterNewHead = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?")
6144+
.bind("github_app.pr_public_surface_published", "JSONbored/gittensory#91")
6145+
.first<{ n: number }>();
6146+
expect(publishedAfterNewHead?.n).toBe(2); // the first publish, plus this one for the new head
6147+
6148+
// A maintainer explicitly forces a fresh pass over the SAME (now-current) head -- must not be skipped either.
6149+
await processJob(env, { type: "agent-regate-pr", deliveryId: "fork-resume-3", repoFullName: "JSONbored/gittensory", prNumber: 91, installationId: 123, force: true });
6150+
expect(commentPatches).toBeGreaterThan(patchesAfterNewHead);
6151+
const publishedAfterForce = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?")
6152+
.bind("github_app.pr_public_surface_published", "JSONbored/gittensory#91")
6153+
.first<{ n: number }>();
6154+
expect(publishedAfterForce?.n).toBe(3);
6155+
});
6156+
6157+
it("swallows a failing draft-republish-skip audit write without throwing (#6685)", async () => {
6158+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
6159+
await seedRegateChurnRepo(env, { publicSurface: "comment_and_label" });
6160+
await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { commentMode: "all_prs", publicSurface: "comment_and_label", checkRunMode: "off", reviewCheckMode: "disabled", aiReviewMode: "block" }, review: { auto_review: { skip_drafts: true, cadence: "continuous" } } });
6161+
await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 92, title: "Draft feature", state: "open", draft: true, user: { login: "contributor" }, head: { sha: "d92" }, labels: [], body: "Closes #1" } as never);
6162+
await upsertPullRequestDetailSyncState(env, { repoFullName: "JSONbored/gittensory", pullNumber: 92, status: "complete", reviewsSyncedAt: new Date().toISOString() });
6163+
6164+
const stickyComment: { current: { id: number; body: string } | null } = { current: null };
6165+
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
6166+
const url = input.toString();
6167+
const method = init?.method ?? "GET";
6168+
if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" });
6169+
if (url.includes("/pulls/92/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]);
6170+
if (url.endsWith("/pulls/92")) return Response.json({ number: 92, title: "Draft feature", state: "open", draft: true, user: { login: "contributor" }, head: { sha: "d92" }, labels: [], body: "Closes #1", mergeable_state: "clean" });
6171+
if (url.includes("/commits/d92/check-runs")) return Response.json({ total_count: 0, check_runs: [] });
6172+
if (url.includes("/commits/d92/status")) return Response.json({ state: "success", statuses: [] });
6173+
if (url.includes("/issues/1")) return Response.json({ number: 1, title: "Issue", state: "open", labels: [], user: { login: "reporter" } });
6174+
if (url.includes("/issues/92/comments") && method === "GET") {
6175+
return Response.json(stickyComment.current ? [{ ...stickyComment.current, user: { login: "gittensory[bot]", type: "Bot" } }] : []);
6176+
}
6177+
if (url.includes("/issues/92/comments") && method === "POST") {
6178+
const body = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? "");
6179+
stickyComment.current = { id: 1, body };
6180+
return Response.json({ id: 1 }, { status: 201 });
6181+
}
6182+
if (url.includes("/issues/comments/1") && method === "PATCH") {
6183+
const body = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? "");
6184+
stickyComment.current = { id: 1, body };
6185+
return Response.json({ id: 1 }, { status: 200 });
6186+
}
6187+
if (url.includes("/issues/92/labels") && method === "GET") return Response.json([]);
6188+
if (url.includes("/issues/92/labels") && method === "POST") return Response.json([]);
6189+
if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } });
6190+
return Response.json({});
6191+
});
6192+
6193+
// First pass publishes for real (populating lastPublishedSurfaceSha).
6194+
await processJob(env, { type: "agent-regate-pr", deliveryId: "fork-resume-1", repoFullName: "JSONbored/gittensory", prNumber: 92, installationId: 123 });
6195+
6196+
const originalRecordAuditEvent = repositoriesModule.recordAuditEvent;
6197+
const auditSpy = vi.spyOn(repositoriesModule, "recordAuditEvent").mockImplementation(async (auditEnv, event) => {
6198+
if (event.eventType === "github_app.public_surface_publish_skipped_current") throw new Error("audit DB down");
6199+
await originalRecordAuditEvent(auditEnv, event);
6200+
});
6201+
6202+
// The second (repeat, same-head) pass takes the draft-skip path -- its audit write fails, but the job
6203+
// must still resolve cleanly rather than throwing.
6204+
await expect(
6205+
processJob(env, { type: "agent-regate-pr", deliveryId: "fork-resume-2", repoFullName: "JSONbored/gittensory", prNumber: 92, installationId: 123 }),
6206+
).resolves.toBeUndefined();
6207+
auditSpy.mockRestore();
6208+
});
6209+
60146210
it("a PURE base-branch movement (no reviewed content change) triggers neither a fresh AI review nor a comment rewrite", async () => {
60156211
let aiCalls = 0;
60166212
const env = createTestEnv({

0 commit comments

Comments
 (0)