Skip to content

Commit 9517351

Browse files
committed
fix(github): write through all three PR-state cache fields together
The AI review flagged a real defect: cachedFetchLivePullRequestMergeState / cachedFetchLivePullRequestState / cachedFetchLivePullRequestHeadSha each wrote through only the ONE field they cared about, but all three share a single prStateFetchedAt freshness stamp. A live fetch from any one of them would make the OTHER two fields look "fresh" to a subsequent reader despite never having been fetched, so that reader would silently return undefined for a field that was simply never populated -- indistinguishable from a confirmed-empty GitHub value. All three narrow live-fetchers already hit the exact same GET /pulls/{n} endpoint, just extracting one field each, so there's no extra API cost to fixing this: a new internal fetchAndCachePrStateFields helper fetches the full payload once and writes mergeable_state, state, and headSha through together (headSha omitted, not nulled, when absent, preserving the existing PARTIAL-UPDATE CONTRACT so a prior headSha the files cache depends on is never cleared). It also only writes when the fetch actually succeeds, so a transient failure no longer poisons the cache with a false "confirmed fresh" stamp the way the old per-field writes did. The three public, uncached narrow fetchers used by the act-boundary/gate-override callers are untouched. Also fixes a second, separate flagged issue: `{ token: "installation-token" }` in six of this PR's own new test fixtures tripped the deterministic generic_secret_assignment scanner (a keyword-shaped heuristic, not a real credential format) -- renamed to `"fake-installation-token"`, which the scanner's own placeholder-value allowlist already recognizes, without touching the shared scanner itself. Rebased onto current main (renumbered migration 0093->0094 to resolve a collision with #2616, and again to catch up with #2632). Full local gate (test:ci, npm audit) green; no other changes.
1 parent d831fe5 commit 9517351

3 files changed

Lines changed: 115 additions & 20 deletions

File tree

src/github/backfill.ts

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2792,6 +2792,42 @@ async function writeThroughPrStateCache(
27922792
}).catch(() => undefined);
27932793
}
27942794

2795+
/**
2796+
* Shared live-fetch for the three cached PR-state readers below (#2537 review fix). A SINGLE `GET /pulls/{n}`
2797+
* already returns `mergeable_state`, `state`, AND `head.sha` together, so a cache miss on any ONE field now
2798+
* fetches and write-throughs ALL THREE at once under the one shared `prStateFetchedAt` stamp they share --
2799+
* instead of writing only the field the caller happened to ask for. Without this, a fresh write for field A
2800+
* would make an UN-fetched field B look "fresh" to the NEXT reader (they share one timestamp), so that reader
2801+
* would silently return `undefined` for a field that was simply never populated, mistaking it for a
2802+
* confirmed-empty GitHub value. Reusing the full-payload fetch costs nothing extra: all three narrow fetchers
2803+
* (`fetchLivePullRequestMergeState` / `fetchLivePullRequestState` / `fetchLivePullRequestHeadSha`) already hit
2804+
* this exact same endpoint, just extracting one field each -- this only changes what the CACHED wrappers fetch
2805+
* internally; those narrow fetchers stay untouched for their other, uncached, act-boundary callers.
2806+
* Returns the full payload, or `undefined` on a failed fetch -- in which case the cache is left untouched
2807+
* entirely (a failed live read must not poison it with a false "confirmed fresh" stamp).
2808+
*/
2809+
async function fetchAndCachePrStateFields(
2810+
env: Env,
2811+
repoFullName: string,
2812+
prNumber: number,
2813+
token: string | undefined,
2814+
admissionKey: GitHubRateLimitAdmissionKey | undefined,
2815+
previousStatus: PullRequestDetailSyncStateRecord["status"] | undefined,
2816+
): Promise<GitHubPullRequestPayload | undefined> {
2817+
const live = await fetchLivePullRequest(env, repoFullName, prNumber, token, admissionKey);
2818+
if (!live) return undefined;
2819+
const liveHeadSha = live.head?.sha;
2820+
await writeThroughPrStateCache(env, repoFullName, prNumber, previousStatus, {
2821+
prMergeableState: live.mergeable_state ?? null,
2822+
prState: live.state ?? null,
2823+
// Omit (not null) when the live payload carries no head SHA -- mirrors primeDurablePrStateCache's own
2824+
// PARTIAL-UPDATE CONTRACT guard below: a PR-state write must never CLEAR the headSha the files cache
2825+
// (#audit-rate-headroom) relies on.
2826+
...(liveHeadSha ? { headSha: liveHeadSha } : {}),
2827+
});
2828+
return live;
2829+
}
2830+
27952831
/** Prime the durable PR-state cache (#2537) from an ALREADY-FETCHED live payload (e.g. the sweep-resync's
27962832
* `fetchLivePullRequest` read), so OTHER readers (readiness, dup-winner, gate-override) benefit from this
27972833
* already-paid-for fetch instead of re-fetching moments later. Best-effort, mirrors writeThroughPrStateCache's
@@ -2816,7 +2852,8 @@ export async function primeDurablePrStateCache(
28162852

28172853
/** Cached read of the PR's live mergeable_state, backed by pull_request_detail_sync_state (#2537). A fresh cache
28182854
* row (webhook-invalidated, capped at PR_STATE_CACHE_MAX_AGE_MS) is served without a GitHub call; otherwise
2819-
* fetches live via fetchLivePullRequestMergeState and (best-effort) writes the result back for the next reader.
2855+
* fetches live via fetchAndCachePrStateFields (which write-throughs ALL THREE cached fields together, not just
2856+
* this one, since they share one fetchedAt stamp) and returns this field from that shared response.
28202857
* Fail-open throughout: any cache read/write hiccup falls back to / degrades to a live fetch, never blocks it. */
28212858
export async function cachedFetchLivePullRequestMergeState(
28222859
env: Env,
@@ -2831,13 +2868,12 @@ export async function cachedFetchLivePullRequestMergeState(
28312868
return cached.prMergeableState ?? undefined;
28322869
}
28332870
incr(PR_STATE_CACHE_METRIC, { field: "mergeable_state", result: "miss" });
2834-
const live = await fetchLivePullRequestMergeState(env, repoFullName, prNumber, token, admissionKey);
2835-
await writeThroughPrStateCache(env, repoFullName, prNumber, cached?.status, { prMergeableState: live ?? null });
2836-
return live;
2871+
const live = await fetchAndCachePrStateFields(env, repoFullName, prNumber, token, admissionKey, cached?.status);
2872+
return live?.mergeable_state ?? undefined;
28372873
}
28382874

28392875
/** Cached read of the PR's live state (open/closed), backed by pull_request_detail_sync_state (#2537). Same
2840-
* freshness/fail-open contract as cachedFetchLivePullRequestMergeState. */
2876+
* freshness/fail-open/shared-fetch contract as cachedFetchLivePullRequestMergeState. */
28412877
export async function cachedFetchLivePullRequestState(
28422878
env: Env,
28432879
repoFullName: string,
@@ -2851,9 +2887,8 @@ export async function cachedFetchLivePullRequestState(
28512887
return cached.prState ?? undefined;
28522888
}
28532889
incr(PR_STATE_CACHE_METRIC, { field: "state", result: "miss" });
2854-
const live = await fetchLivePullRequestState(env, repoFullName, prNumber, token, admissionKey);
2855-
await writeThroughPrStateCache(env, repoFullName, prNumber, cached?.status, { prState: live ?? null });
2856-
return live;
2890+
const live = await fetchAndCachePrStateFields(env, repoFullName, prNumber, token, admissionKey, cached?.status);
2891+
return live?.state ?? undefined;
28572892
}
28582893

28592894
/** Cached read of the PR's live head SHA, backed by pull_request_detail_sync_state (#2537). Reuses the EXISTING
@@ -2873,9 +2908,8 @@ export async function cachedFetchLivePullRequestHeadSha(
28732908
return cached.headSha;
28742909
}
28752910
incr(PR_STATE_CACHE_METRIC, { field: "head_sha", result: "miss" });
2876-
const live = await fetchLivePullRequestHeadSha(env, repoFullName, prNumber, token, admissionKey);
2877-
if (live) await writeThroughPrStateCache(env, repoFullName, prNumber, cached?.status, { headSha: live });
2878-
return live;
2911+
const live = await fetchAndCachePrStateFields(env, repoFullName, prNumber, token, admissionKey, cached?.status);
2912+
return live?.head?.sha ?? undefined;
28792913
}
28802914

28812915
/** Invalidate the durable PR-state cache fields (#2537) — called on synchronize/closed/reopened. Explicit null

test/unit/pr-detail-durable-cache.test.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,37 @@ describe("durable PR-state cache (#2537)", () => {
5353
};
5454
}
5555

56+
// REGRESSION (#2595 review defect): the three cached readers below share ONE prStateFetchedAt column as their
57+
// freshness stamp. Before this fix, each reader wrote through ONLY the one field it cared about, so a write
58+
// from reader A would make reader B's UN-fetched field look "fresh" to a subsequent call -- silently returning
59+
// undefined for a field that was simply never populated, not confirmed empty on GitHub. The fix fetches the
60+
// full PR payload (all three narrow fetchers already hit the exact same endpoint) and writes all three fields
61+
// through together on every live fetch, so this cross-field false-freshness can no longer happen.
62+
it("REGRESSION (#2595): a live fetch from ONE cached reader also warms the OTHER TWO, since they share one fetchedAt stamp", async () => {
63+
const env = createTestEnv();
64+
let fetchCount = 0;
65+
stubFetchTracking((url) => {
66+
if (url.includes("/pulls/40")) {
67+
fetchCount += 1;
68+
return Response.json({ number: 40, mergeable_state: "clean", state: "open", head: { sha: "shared-sha" } });
69+
}
70+
return new Response("not found", { status: 404 });
71+
});
72+
73+
// Only the mergeable_state reader is called...
74+
const mergeableState = await cachedFetchLivePullRequestMergeState(env, "owner/repo", 40, "tok");
75+
expect(mergeableState).toBe("clean");
76+
expect(fetchCount).toBe(1);
77+
78+
// ...yet the OTHER two fields are now cache HITS too, without a second GitHub call, and return the REAL
79+
// fetched values -- not a false "confirmed fresh, but never actually fetched" undefined.
80+
const state = await cachedFetchLivePullRequestState(env, "owner/repo", 40, "tok");
81+
const headSha = await cachedFetchLivePullRequestHeadSha(env, "owner/repo", 40, "tok");
82+
expect(state).toBe("open");
83+
expect(headSha).toBe("shared-sha");
84+
expect(fetchCount).toBe(1); // no additional GitHub calls were needed
85+
});
86+
5687
describe("cachedFetchLivePullRequestMergeState", () => {
5788
it("cache miss on first read — fetches live and writes the row through", async () => {
5889
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
@@ -196,14 +227,44 @@ describe("durable PR-state cache (#2537)", () => {
196227
expect(fetchCount).toBe(1);
197228
});
198229

199-
it("does not write through when the live head SHA is undefined (never clears a prior headSha)", async () => {
230+
// REGRESSION (#2595 review defect): the three cached readers share ONE prStateFetchedAt stamp, so a live
231+
// fetch triggered by ANY of them must write through ALL THREE fields together -- otherwise a field this
232+
// reader doesn't care about (mergeable_state/state) would look "fresh" to a later, different reader despite
233+
// never having been fetched. A fresh full-payload fetch that carries no head.sha still writes the OTHER two
234+
// fields through (and still never CLEARS a prior headSha -- the PARTIAL-UPDATE CONTRACT is preserved).
235+
it("writes mergeable_state/state through even when the live head SHA is undefined, and never clears a prior headSha", async () => {
200236
const env = createTestEnv();
201-
stubFetchTracking(() => Response.json({ number: 31 })); // no head.sha
237+
await upsertPullRequestDetailSyncState(env, {
238+
repoFullName: "owner/repo",
239+
pullNumber: 31,
240+
status: "never_synced",
241+
headSha: "prior-sha",
242+
prStateFetchedAt: "2020-01-01T00:00:00.000Z", // stale -- forces a live re-fetch
243+
});
244+
stubFetchTracking(() => Response.json({ number: 31, mergeable_state: "clean", state: "open" })); // no head.sha
202245

203246
const result = await cachedFetchLivePullRequestHeadSha(env, "owner/repo", 31, "tok");
204247

248+
expect(result).toBeUndefined(); // no head.sha in the live payload
249+
const row = await getPullRequestDetailSyncState(env, "owner/repo", 31);
250+
expect(row?.headSha).toBe("prior-sha"); // NOT cleared -- omitted, not written as null
251+
expect(row?.prMergeableState).toBe("clean"); // written through together with this call's own fetch
252+
expect(row?.prState).toBe("open");
253+
expect(row?.prStateFetchedAt).not.toBe("2020-01-01T00:00:00.000Z"); // the shared stamp advanced
254+
});
255+
256+
it("still creates a row (with the other fields null) on a fresh PR whose live payload carries no head.sha at all", async () => {
257+
const env = createTestEnv();
258+
stubFetchTracking(() => Response.json({ number: 32 })); // no head.sha, no mergeable_state, no state
259+
260+
const result = await cachedFetchLivePullRequestHeadSha(env, "owner/repo", 32, "tok");
261+
205262
expect(result).toBeUndefined();
206-
expect(await getPullRequestDetailSyncState(env, "owner/repo", 31)).toBeNull();
263+
const row = await getPullRequestDetailSyncState(env, "owner/repo", 32);
264+
expect(row?.headSha).toBeNull(); // never had one to preserve
265+
expect(row?.prMergeableState).toBeNull();
266+
expect(row?.prState).toBeNull();
267+
expect(row?.prStateFetchedAt).not.toBeNull(); // the fetch DID succeed (confirmed-empty, not "never fetched")
207268
});
208269
});
209270

test/unit/queue.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13301,7 +13301,7 @@ describe("queue processors", () => {
1330113301
await seedWarmPrStateCache(env, "JSONbored/gittensory", 200);
1330213302
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
1330313303
const url = input.toString();
13304-
if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
13304+
if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" });
1330513305
return Response.json({});
1330613306
});
1330713307

@@ -13333,7 +13333,7 @@ describe("queue processors", () => {
1333313333
await seedWarmPrStateCache(env, "JSONbored/gittensory", 201);
1333413334
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
1333513335
const url = input.toString();
13336-
if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
13336+
if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" });
1333713337
return Response.json({});
1333813338
});
1333913339

@@ -13370,7 +13370,7 @@ describe("queue processors", () => {
1337013370
});
1337113371
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
1337213372
const url = input.toString();
13373-
if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
13373+
if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" });
1337413374
if (url.includes("/pulls/202/files")) return Response.json([]);
1337513375
if (url.includes("/pulls/202/reviews")) return Response.json([]);
1337613376
return Response.json({});
@@ -13411,7 +13411,7 @@ describe("queue processors", () => {
1341113411
});
1341213412
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
1341313413
const url = input.toString();
13414-
if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
13414+
if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" });
1341513415
return Response.json({});
1341613416
});
1341713417

@@ -13472,7 +13472,7 @@ describe("queue processors", () => {
1347213472
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
1347313473
const url = input.toString();
1347413474
const method = (init?.method ?? "GET").toUpperCase();
13475-
if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
13475+
if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" });
1347613476
if (url.endsWith("/pulls/8") && method === "PATCH") {
1347713477
closedViaPatch = JSON.parse(String(init?.body ?? "{}")).state === "closed";
1347813478
return Response.json({ number: 8, state: "closed" });
@@ -13555,7 +13555,7 @@ describe("queue processors", () => {
1355513555
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
1355613556
const url = input.toString();
1355713557
const method = init?.method ?? "GET";
13558-
if (url.includes("/access_tokens")) return Response.json({ token: "installation-token", expires_at: "2026-05-28T00:04:00.000Z" });
13558+
if (url.includes("/access_tokens")) return Response.json({ token: "fake-installation-token", expires_at: "2026-05-28T00:04:00.000Z" });
1355913559
if (url.includes("/pulls/9/files")) return Response.json([{ filename: "src/cache.ts", additions: 5, deletions: 1, status: "modified" }]);
1356013560
// ALWAYS live-dirty — the seeded durable cache above claims "clean".
1356113561
if (/\/pulls\/9(?:\?|$)/.test(url)) return Response.json({ number: 9, mergeable_state: "dirty" });

0 commit comments

Comments
 (0)