|
| 1 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 2 | +import { listAuditEventsByType, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; |
| 3 | +import { reReviewStoredPullRequest } from "../../src/queue/processors"; |
| 4 | +import { verdictStabilityKey, writeVerdictStability } from "../../src/review/verdict-stability"; |
| 5 | +import { normalizeRegistryPayload } from "../../src/registry/normalize"; |
| 6 | +import { persistRegistrySnapshot } from "../../src/registry/sync"; |
| 7 | +import { asCloudEnv, createTestEnv } from "../helpers/d1"; |
| 8 | +import { generatePrivateKeyPem } from "../helpers/github-app-key"; |
| 9 | + |
| 10 | +// #10222: the wiring #10204 shipped without a test. The backoff logic itself is covered by |
| 11 | +// verdict-stability.test.ts; what was never covered is WHERE the guard sits, and #10204 put it after the |
| 12 | +// readiness gate -- which fires onReachedReadiness and consumes the one-shot panel-retrigger marker. These |
| 13 | +// tests pin the three properties that placement got wrong. |
| 14 | + |
| 15 | +const REPO = "JSONbored/gittensory"; |
| 16 | +const HEAD = "sha-settled"; |
| 17 | + |
| 18 | +async function seed(env: ReturnType<typeof createTestEnv>) { |
| 19 | + await persistRegistrySnapshot( |
| 20 | + asCloudEnv(env), |
| 21 | + normalizeRegistryPayload({ [REPO]: { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), |
| 22 | + ); |
| 23 | + await upsertInstallation(env, { |
| 24 | + action: "created", |
| 25 | + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "selected", permissions: {}, events: [] }, |
| 26 | + }); |
| 27 | + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: REPO, private: false, owner: { login: "JSONbored" } }, 123); |
| 28 | + await upsertRepositorySettings(env, { repoFullName: REPO, autoLabelEnabled: false, autonomy: { label: "auto" } }); |
| 29 | + await upsertPullRequestFromGitHub(env, REPO, { |
| 30 | + number: 77, title: "settled", state: "open", user: { login: "contributor" }, author_association: "CONTRIBUTOR", |
| 31 | + head: { sha: HEAD }, base: { ref: "main" }, labels: [], body: "Closes #1", created_at: "2026-07-31T09:00:00Z", |
| 32 | + } as never); |
| 33 | +} |
| 34 | + |
| 35 | +/** A verdict that has repeated enough to be settled, evaluated a moment ago -- so the backoff is engaged. */ |
| 36 | +async function seedSettledVerdict(env: ReturnType<typeof createTestEnv>) { |
| 37 | + await writeVerdictStability(env.SELFHOST_TRANSIENT_CACHE, verdictStabilityKey(REPO, 77, HEAD), { |
| 38 | + fingerprint: "hold|missing_linked_issue|", |
| 39 | + repeats: 8, |
| 40 | + lastEvaluatedMs: Date.now(), |
| 41 | + }); |
| 42 | +} |
| 43 | + |
| 44 | +function stubGitHub(onReadiness: () => void) { |
| 45 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 46 | + const url = input.toString(); |
| 47 | + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); |
| 48 | + // A files read means the pass got PAST the guard into the publish unit. Readiness itself deliberately |
| 49 | + // runs before the guard (#10061), so it is not the probe. |
| 50 | + if (url.includes("/files")) onReadiness(); |
| 51 | + if (url.endsWith("/pulls/77")) return Response.json({ number: 77, title: "settled", state: "open", user: { login: "contributor" }, head: { sha: HEAD }, labels: [], body: "Closes #1", mergeable_state: "clean" }); |
| 52 | + if (url.includes("/check-runs")) return Response.json({ total_count: 1, check_runs: [{ name: "t", status: "completed", conclusion: "success", app: { slug: "github-actions" } }] }); |
| 53 | + if (url.includes("/status")) return Response.json({ state: "success", statuses: [] }); |
| 54 | + if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } }); |
| 55 | + return Response.json({}); |
| 56 | + }); |
| 57 | +} |
| 58 | + |
| 59 | +async function skippedEvents(env: ReturnType<typeof createTestEnv>) { |
| 60 | + return listAuditEventsByType(env, "github_app.review_skipped_stable_verdict", "2000-01-01T00:00:00Z"); |
| 61 | +} |
| 62 | + |
| 63 | +/** The same key processors.ts's pendingPrPanelRetriggerKey builds -- written directly because the marker |
| 64 | + * writer is module-private to processors.ts. */ |
| 65 | +const RETRIGGER_KEY = `pr-panel-retrigger-pending:${REPO.toLowerCase()}#77:${HEAD}`; |
| 66 | + |
| 67 | +describe("verdict-stability backoff wiring (#10222)", () => { |
| 68 | + afterEach(() => { |
| 69 | + vi.unstubAllGlobals(); |
| 70 | + }); |
| 71 | + |
| 72 | + it("backs off a settled verdict, and records why", async () => { |
| 73 | + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); |
| 74 | + await seed(env); |
| 75 | + await seedSettledVerdict(env); |
| 76 | + let reviewRan = false; |
| 77 | + stubGitHub(() => { reviewRan = true; }); |
| 78 | + |
| 79 | + expect(await reReviewStoredPullRequest(env, "d1", 123, REPO, 77)).toBe(false); |
| 80 | + expect(reviewRan, "a backed-off pass must not reach the publish unit").toBe(false); |
| 81 | + expect(await skippedEvents(env)).toHaveLength(1); |
| 82 | + }); |
| 83 | + |
| 84 | + it("REGRESSION: an explicit force is NEVER backed off", async () => { |
| 85 | + // #10204's guard ignored options.force, so an operator's manual re-gate could be silently suppressed. |
| 86 | + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); |
| 87 | + await seed(env); |
| 88 | + await seedSettledVerdict(env); |
| 89 | + let reviewRan = false; |
| 90 | + stubGitHub(() => { reviewRan = true; }); |
| 91 | + |
| 92 | + await reReviewStoredPullRequest(env, "d2", 123, REPO, 77, undefined, { force: true }); |
| 93 | + expect(reviewRan, "a forced pass must proceed into the publish unit").toBe(true); |
| 94 | + expect(await skippedEvents(env)).toHaveLength(0); |
| 95 | + }); |
| 96 | + |
| 97 | + it("REGRESSION: a visual-preview poll tick is NEVER backed off", async () => { |
| 98 | + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); |
| 99 | + await seed(env); |
| 100 | + await seedSettledVerdict(env); |
| 101 | + let reviewRan = false; |
| 102 | + stubGitHub(() => { reviewRan = true; }); |
| 103 | + |
| 104 | + await reReviewStoredPullRequest(env, "d3", 123, REPO, 77, 2); |
| 105 | + expect(reviewRan).toBe(true); |
| 106 | + expect(await skippedEvents(env)).toHaveLength(0); |
| 107 | + }); |
| 108 | + |
| 109 | + it("REGRESSION: a backed-off pass does not eat the one-shot panel-retrigger marker (#7626)", async () => { |
| 110 | + // The failure #10204's placement caused: readiness consumed the marker, THEN the guard returned, so the |
| 111 | + // user's "Re-run LoopOver review" click vanished with nothing left to re-trigger it. |
| 112 | + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); |
| 113 | + await seed(env); |
| 114 | + await seedSettledVerdict(env); |
| 115 | + await env.SELFHOST_TRANSIENT_CACHE?.set(RETRIGGER_KEY, "1", 3600); |
| 116 | + stubGitHub(() => undefined); |
| 117 | + |
| 118 | + expect(await reReviewStoredPullRequest(env, "d4", 123, REPO, 77)).toBe(false); |
| 119 | + |
| 120 | + // The marker must still be there for a later pass to consume. |
| 121 | + const stillPending = await env.SELFHOST_TRANSIENT_CACHE?.get(RETRIGGER_KEY); |
| 122 | + expect(stillPending, "the retrigger marker must survive a backed-off pass").toBeTruthy(); |
| 123 | + }); |
| 124 | + |
| 125 | + it("never backs off a PR with no head SHA -- there is no key to have settled under", async () => { |
| 126 | + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); |
| 127 | + await seed(env); |
| 128 | + await seedSettledVerdict(env); |
| 129 | + await upsertPullRequestFromGitHub(env, REPO, { |
| 130 | + number: 78, title: "no head", state: "open", user: { login: "contributor" }, author_association: "CONTRIBUTOR", |
| 131 | + base: { ref: "main" }, labels: [], body: "Closes #1", created_at: "2026-07-31T09:00:00Z", |
| 132 | + } as never); |
| 133 | + stubGitHub(() => undefined); |
| 134 | + |
| 135 | + await reReviewStoredPullRequest(env, "d6", 123, REPO, 78); |
| 136 | + expect(await skippedEvents(env)).toHaveLength(0); |
| 137 | + }); |
| 138 | + |
| 139 | + it("does not back off a verdict that has not settled yet", async () => { |
| 140 | + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); |
| 141 | + await seed(env); |
| 142 | + await writeVerdictStability(env.SELFHOST_TRANSIENT_CACHE, verdictStabilityKey(REPO, 77, HEAD), { |
| 143 | + fingerprint: "hold|missing_linked_issue|", |
| 144 | + repeats: 1, |
| 145 | + lastEvaluatedMs: Date.now(), |
| 146 | + }); |
| 147 | + let reviewRan = false; |
| 148 | + stubGitHub(() => { reviewRan = true; }); |
| 149 | + |
| 150 | + await reReviewStoredPullRequest(env, "d5", 123, REPO, 77); |
| 151 | + expect(reviewRan).toBe(true); |
| 152 | + expect(await skippedEvents(env)).toHaveLength(0); |
| 153 | + }); |
| 154 | +}); |
0 commit comments