|
1 | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
2 | 2 | import { ciCompletionHeadSha, processJob, resolveCiCompletionPrNumbers } from "../../src/queue/processors"; |
3 | | -import { upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; |
| 3 | +import { getPullRequestDetailSyncState, upsertPullRequestDetailSyncState, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; |
4 | 4 | import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; |
5 | 5 | import { createTestEnv } from "../helpers/d1"; |
6 | 6 | import type { GitHubWebhookPayload, JobMessage } from "../../src/types"; |
@@ -207,6 +207,78 @@ describe("CI-completion fork PR resume (head-SHA fallback)", () => { |
207 | 207 | expect(webhook?.status).toBe("processed"); |
208 | 208 | }); |
209 | 209 |
|
| 210 | + it("invalidation: a COALESCED fork completion still invalidates the durable CI-state cache (on BOTH events)", async () => { |
| 211 | + const cache = new MemoryTransientCache(); |
| 212 | + const env = createTestEnv({ SELFHOST_TRANSIENT_CACHE: cache }); |
| 213 | + await seedForkResumeRepo(env, "JSONbored/gittensory", 99, FORK_SHA); |
| 214 | + // A second open PR on a DIFFERENT head SHA must be skipped by the invalidation loop's head-SHA filter. |
| 215 | + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 98, title: "Other fork PR", state: "open", user: { login: "someone-else" }, head: { sha: "feedface0000feedface0000feedface0000feed" }, labels: [], body: "unrelated" }); |
| 216 | + // A throwing fetch proves both events resolve/invalidate off the stored DB row, never the live commits/pulls call. |
| 217 | + vi.stubGlobal("fetch", async () => { |
| 218 | + throw new Error("fetch must not be called: the stored fork PR row matches the head SHA"); |
| 219 | + }); |
| 220 | + |
| 221 | + // Seed a STALE, pre-completion CI aggregate a reader could observe, keyed to the completing head SHA. |
| 222 | + const seedStaleCiState = async (pullNumber: number, ciState: "failed" | "passed") => |
| 223 | + upsertPullRequestDetailSyncState(env, { repoFullName: "JSONbored/gittensory", pullNumber, status: "complete", ciHeadSha: FORK_SHA, ciState, ciStateFetchedAt: new Date().toISOString() }); |
| 224 | + |
| 225 | + // Event 1 (NOT coalesced): claims the head-SHA window, invalidates via the re-review loop. |
| 226 | + await seedStaleCiState(99, "failed"); |
| 227 | + await seedStaleCiState(98, "failed"); |
| 228 | + await processJob(env, { |
| 229 | + type: "github-webhook", |
| 230 | + deliveryId: "fork-invalidate-1", |
| 231 | + eventName: "check_suite", |
| 232 | + payload: checkSuitePayload({ repo: "JSONbored/gittensory", installationId: 5001, headSha: FORK_SHA, prNumbers: [] }), |
| 233 | + }); |
| 234 | + expect((await getPullRequestDetailSyncState(env, "JSONbored/gittensory", 99))?.ciState).toBeNull(); |
| 235 | + |
| 236 | + // A reader repopulates the durable cache with the burst's NEXT, DIFFERING settled CI state before the second event. |
| 237 | + await seedStaleCiState(99, "passed"); |
| 238 | + |
| 239 | + // Event 2 (COALESCED within the same window): the re-review dispatch is suppressed, but the fix guarantees the |
| 240 | + // durable-cache invalidation still runs -- otherwise a reader would observe the stale "passed" snapshot until TTL. |
| 241 | + await processJob(env, { |
| 242 | + type: "github-webhook", |
| 243 | + deliveryId: "fork-invalidate-2", |
| 244 | + eventName: "check_suite", |
| 245 | + payload: checkSuitePayload({ repo: "JSONbored/gittensory", installationId: 5001, headSha: FORK_SHA, prNumbers: [] }), |
| 246 | + }); |
| 247 | + expect((await getPullRequestDetailSyncState(env, "JSONbored/gittensory", 99))?.ciState).toBeNull(); |
| 248 | + // PR 98 (mismatched head SHA) is never touched by the invalidation loop -- its stale state stays put. |
| 249 | + expect((await getPullRequestDetailSyncState(env, "JSONbored/gittensory", 98))?.ciState).toBe("failed"); |
| 250 | + }); |
| 251 | + |
| 252 | + it("regression: the COALESCED second fork completion suppresses the duplicate re-review dispatch", async () => { |
| 253 | + const cache = new MemoryTransientCache(); |
| 254 | + const env = createTestEnv({ SELFHOST_TRANSIENT_CACHE: cache }); |
| 255 | + await seedForkResumeRepo(env, "JSONbored/gittensory", 99, FORK_SHA); |
| 256 | + vi.stubGlobal("fetch", async () => { |
| 257 | + throw new Error("fetch must not be called: the stored fork PR row matches the head SHA"); |
| 258 | + }); |
| 259 | + |
| 260 | + for (const deliveryId of ["fork-dispatch-1", "fork-dispatch-2"]) { |
| 261 | + await processJob(env, { |
| 262 | + type: "github-webhook", |
| 263 | + deliveryId, |
| 264 | + eventName: "check_suite", |
| 265 | + payload: checkSuitePayload({ repo: "JSONbored/gittensory", installationId: 5001, headSha: FORK_SHA, prNumbers: [] }), |
| 266 | + }); |
| 267 | + } |
| 268 | + |
| 269 | + // The fork-resume audit is written on the dispatch path only (AFTER the coalescing early-return), so exactly one |
| 270 | + // audit across two same-window events proves the second event's re-review dispatch was coalesced away, not re-run. |
| 271 | + const audits = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?") |
| 272 | + .bind("github_app.ci_completion_fork_resume") |
| 273 | + .first<{ n: number }>(); |
| 274 | + expect(audits?.n).toBe(1); |
| 275 | + // Both events are still recorded as processed regardless of coalescing. |
| 276 | + const processed = await env.DB.prepare("select count(*) as n from webhook_events where status = 'processed' and delivery_id in (?, ?)") |
| 277 | + .bind("fork-dispatch-1", "fork-dispatch-2") |
| 278 | + .first<{ n: number }>(); |
| 279 | + expect(processed?.n).toBe(2); |
| 280 | + }); |
| 281 | + |
210 | 282 | it("dispatch: a head SHA that matches nothing is a no-op (no fork audit, no throw, webhook recorded)", async () => { |
211 | 283 | const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); |
212 | 284 | await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 5001); |
|
0 commit comments