From a8742daaf9bd1c86631ffe70b989471fac8138c3 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:38:52 -0700 Subject: [PATCH 1/2] refactor(github): consolidate the second githubHeaders duplication cluster (#4610) app.ts, scoring/model.ts, contributor-issue-draft.ts, and upstream/ruleset.ts each carried their own drifted githubHeaders copy (four different signatures, no shared GitHub HTTP header helper anywhere in src/). Extract one shared, options-object githubHeaders into github/client.ts and route all four call sites through it. Mechanical, no behavior change. --- src/github/app.ts | 21 +++++---------- src/github/client.ts | 26 +++++++++++++++++++ src/scoring/model.ts | 14 +++------- src/services/contributor-issue-draft.ts | 13 ++-------- src/upstream/ruleset.ts | 23 +++++------------ test/unit/github-client.test.ts | 34 +++++++++++++++++++++++++ 6 files changed, 78 insertions(+), 53 deletions(-) diff --git a/src/github/app.ts b/src/github/app.ts index 9c405a1aa1..799b3ffaac 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -7,6 +7,7 @@ import { recordGitHubRateLimitObservation, updateInstallationPermissions } from import { recordClockSkewFromResponse } from "../selfhost/clock-skew"; import { clearGitHubResponseCacheForTest, + githubHeaders, githubRateLimitAdmissionKeyForInstallation, makeInstallationOctokit, timeoutFetch, @@ -215,7 +216,7 @@ async function requestInstallationTokenWithJwt( `https://api.github.com/app/installations/${installationId}/access_tokens`, { method: "POST", - headers: githubHeaders(`Bearer ${jwt}`), + headers: githubHeaders({ token: jwt, json: true }), }, ); recordClockSkewFromResponse(response); @@ -381,7 +382,7 @@ export async function getAppInstallation( // cache must stay keyed off the Authorization header (the no-admission-key fallback) to preserve per-App-identity // isolation (#1940). recordGitHubRateLimitObservation below records the SAME admissionKey directly instead. const response = await timeoutFetch(`https://api.github.com${path}`, { - headers: githubHeaders(`Bearer ${jwt}`), + headers: githubHeaders({ token: jwt, json: true }), }); // #4506: refreshInstallationHealthRecords's per-installation loop (backfill.ts) makes one of these calls per // installation, but this call lives outside backfill.ts's module boundary -- and backfill.ts already imports @@ -441,7 +442,7 @@ export async function getRepositoryCollaboratorPermission( const response = await timeoutFetch( `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/collaborators/${encodeURIComponent(login)}/permission`, { - headers: githubHeaders(`Bearer ${token}`), + headers: githubHeaders({ token, json: true }), githubRateLimitAdmission: true, githubRateLimitAdmissionKey: githubRateLimitAdmissionKeyForInstallation(installationId), }, @@ -475,7 +476,7 @@ export async function getGithubUserCreatedAt( const response = await timeoutFetch( `https://api.github.com/users/${encodeURIComponent(login)}`, { - headers: githubHeaders(`Bearer ${token}`), + headers: githubHeaders({ token, json: true }), githubRateLimitAdmission: true, githubRateLimitAdmissionKey: githubRateLimitAdmissionKeyForInstallation(installationId), }, @@ -615,7 +616,7 @@ export async function cancelInFlightWorkflowRunsForHeadSha( try { const token = await createInstallationToken(env, installationId); const fetchOptions: ActionsRunFetchOptions = { - headers: githubHeaders(`Bearer ${token}`), + headers: githubHeaders({ token, json: true }), githubRateLimitAdmission: true, githubRateLimitAdmissionKey: githubRateLimitAdmissionKeyForInstallation(installationId), }; @@ -1170,13 +1171,3 @@ export function getInstallationId( ): number | null { return payload.installation?.id ?? null; } - -function githubHeaders(authorization: string): HeadersInit { - return { - accept: "application/vnd.github+json", - authorization, - "content-type": "application/json", - "user-agent": "gittensory/0.1", - "x-github-api-version": "2022-11-28", - }; -} diff --git a/src/github/client.ts b/src/github/client.ts index 3b24c4d25a..77f4ef4d6b 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -29,6 +29,32 @@ const DEFAULT_METADATA_TTL_SECONDS = 10 * 60; const DEFAULT_COMMIT_TTL_SECONDS = 15 * 60; export const GITHUB_RESPONSE_CACHE_REPLAY_HEADER = "x-gittensory-cache"; +/** The single shared GitHub REST header-builder for every raw-`fetch`/`timeoutFetch` call in `src/` (Octokit + * calls set their own headers internally and don't need this). Consolidates four independent, drifted + * `githubHeaders` copies that had each grown a different signature (#4610). `token` is optional — a public, + * unauthenticated read omits it rather than sending an empty `authorization`. */ +export interface GitHubHeadersOptions { + /** Bearer token. Omitted (or empty) → no `authorization` header. */ + token?: string | undefined; + /** `accept` header value. Defaults to the standard REST JSON media type. */ + accept?: string | undefined; + /** Include `content-type: application/json`, for a request with a JSON body. Defaults to false. */ + json?: boolean | undefined; + /** Include the pinned `x-github-api-version` header. Defaults to true. */ + apiVersion?: boolean | undefined; +} + +export function githubHeaders(options: GitHubHeadersOptions = {}): Record { + const { token, accept = "application/vnd.github+json", json = false, apiVersion = true } = options; + return { + accept, + "user-agent": "gittensory/0.1", + ...(apiVersion ? { "x-github-api-version": "2022-11-28" } : {}), + ...(json ? { "content-type": "application/json" } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; +} + /** A shared cache for safe GitHub GET responses (e.g. Redis on the self-host). Stores only status/body/ * content-type plus pagination/validator headers — never rate-limit or encoding headers. Set on the self-host; * the Worker leaves it null. */ diff --git a/src/scoring/model.ts b/src/scoring/model.ts index 0e135b948f..64903dfe63 100644 --- a/src/scoring/model.ts +++ b/src/scoring/model.ts @@ -2,7 +2,7 @@ import { getLatestScoringModelSnapshot, persistScoringModelSnapshot, } from "../db/repositories"; -import { timeoutFetch } from "../github/client"; +import { githubHeaders, timeoutFetch } from "../github/client"; import { getLatestRegistrySnapshot } from "../registry/sync"; import { resolveUpstreamCommitSha } from "../upstream/commit"; import { syncUnmodeledScoringConstantDrift } from "../upstream/unmodeled-scoring-drift"; @@ -162,7 +162,7 @@ function activeModelWarnings(constants: Record): string[] { async function fetchText(url: string, token?: string): Promise<{ ok: true; value: string } | { ok: false; error: string }> { try { - const response = await timeoutFetch(url, { headers: githubHeaders(token, "text/plain") }); + const response = await timeoutFetch(url, { headers: githubHeaders({ token, accept: "text/plain", apiVersion: false }) }); if (!response.ok) return { ok: false, error: `${response.status} ${response.statusText}` }; return { ok: true, value: await response.text() }; } catch (error) { @@ -172,18 +172,10 @@ async function fetchText(url: string, token?: string): Promise<{ ok: true; value async function fetchJson(url: string, token?: string): Promise<{ ok: true; value: Record } | { ok: false; error: string }> { try { - const response = await timeoutFetch(url, { headers: githubHeaders(token, "application/json") }); + const response = await timeoutFetch(url, { headers: githubHeaders({ token, accept: "application/json", apiVersion: false }) }); if (!response.ok) return { ok: false, error: `${response.status} ${response.statusText}` }; return { ok: true, value: (await response.json()) as Record }; } catch (error) { return { ok: false, error: errorMessage(error) }; } } - -function githubHeaders(token: string | undefined, accept: string): Record { - return { - accept, - "user-agent": "gittensory/0.1", - ...(token ? { authorization: `Bearer ${token}` } : {}), - }; -} diff --git a/src/services/contributor-issue-draft.ts b/src/services/contributor-issue-draft.ts index 5de3904a32..403e47b29d 100644 --- a/src/services/contributor-issue-draft.ts +++ b/src/services/contributor-issue-draft.ts @@ -17,7 +17,7 @@ import { import type { IssueRecord, RepositoryRecord, RepositorySettings } from "../types"; import { isGlobalAgentPause } from "../settings/agent-execution"; import { isMaintainerAssociation } from "../github/commands"; -import { timeoutFetch } from "../github/client"; +import { githubHeaders, timeoutFetch } from "../github/client"; import { sha256Hex } from "../utils/crypto"; import { jsonString, nowIso, repoParts } from "../utils/json"; import { @@ -559,7 +559,7 @@ async function createGitHubContributorIssue(env: Env, repoFullName: string, draf if (!owner || !name) return null; const response = await timeoutFetch(`https://api.github.com/repos/${owner}/${name}/issues`, { method: "POST", - headers: githubHeaders(token), + headers: githubHeaders({ token }), body: jsonString({ title: draft.title, body: draft.body, @@ -570,12 +570,3 @@ async function createGitHubContributorIssue(env: Env, repoFullName: string, draf const payload = (await response.json()) as { number?: number; html_url?: string }; return payload.number && payload.html_url ? { number: payload.number, url: payload.html_url } : null; } - -function githubHeaders(token: string): Record { - return { - accept: "application/vnd.github+json", - "user-agent": "gittensory/0.1", - "x-github-api-version": "2022-11-28", - authorization: `Bearer ${token}`, - }; -} diff --git a/src/upstream/ruleset.ts b/src/upstream/ruleset.ts index 3a1b2fc5fd..98ca5bffde 100644 --- a/src/upstream/ruleset.ts +++ b/src/upstream/ruleset.ts @@ -11,7 +11,7 @@ import { upsertUpstreamDriftReport, } from "../db/repositories"; import { resolveGittensorySelfRepoFullName } from "../config/gittensory-repo-focus-manifest"; -import { timeoutFetch } from "../github/client"; +import { githubHeaders, timeoutFetch } from "../github/client"; import { resolveUpstreamCommitSha } from "./commit"; import { isGlobalAgentPause } from "../settings/agent-execution"; import { normalizeRegistryPayload } from "../registry/normalize"; @@ -439,7 +439,7 @@ async function fetchTrackedSource( try { const response = await timeoutFetch(apiUrl, { headers: { - ...githubHeaders(env.GITHUB_PUBLIC_TOKEN, "application/vnd.github+json"), + ...githubHeaders({ token: env.GITHUB_PUBLIC_TOKEN, accept: "application/vnd.github+json" }), ...(previous?.etag ? { "if-none-match": previous.etag } : {}), }, }); @@ -467,7 +467,7 @@ async function fetchTrackedSource( } try { - const response = await fetch(rawUrl(config, source.path), { headers: githubHeaders(env.GITHUB_PUBLIC_TOKEN, "text/plain") }); + const response = await fetch(rawUrl(config, source.path), { headers: githubHeaders({ token: env.GITHUB_PUBLIC_TOKEN, accept: "text/plain" }) }); if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); return sourceSnapshotFromContent({ config, @@ -1040,7 +1040,7 @@ async function findGitHubIssueForFingerprint(repo: string, token: string, finger try { for (let page = 1; ; page += 1) { const url = `https://api.github.com/repos/${owner}/${name}/issues?state=open&labels=signals&per_page=100&page=${page}`; - const response = await timeoutFetch(url, { headers: githubHeaders(token, "application/vnd.github+json") }); + const response = await timeoutFetch(url, { headers: githubHeaders({ token, accept: "application/vnd.github+json" }) }); if (!response.ok) return null; const issues = (await response.json()) as Array<{ number?: number; @@ -1072,7 +1072,7 @@ async function createGitHubDriftIssue(repo: string, token: string, report: Upstr if (!owner || !name) return null; const response = await timeoutFetch(`https://api.github.com/repos/${owner}/${name}/issues`, { method: "POST", - headers: githubHeaders(token, "application/vnd.github+json"), + headers: githubHeaders({ token, accept: "application/vnd.github+json" }), body: jsonString(githubDriftIssuePayload(report, assignees)), }); if (!response.ok) return null; @@ -1085,7 +1085,7 @@ async function updateGitHubDriftIssue(repo: string, token: string, issueNumber: if (!owner || !name || !Number.isInteger(issueNumber) || issueNumber <= 0) return null; const response = await timeoutFetch(`https://api.github.com/repos/${owner}/${name}/issues/${issueNumber}`, { method: "PATCH", - headers: githubHeaders(token, "application/vnd.github+json"), + headers: githubHeaders({ token, accept: "application/vnd.github+json" }), body: jsonString(githubDriftIssuePayload(report, assignees)), }); if (!response.ok) return null; @@ -1100,7 +1100,7 @@ async function validateRecordedGitHubIssue(repo: string, token: string, report: if (!owner || !name || !parsedUrl || parsedUrl.number !== report.issueNumber) return null; if (parsedUrl.owner.toLowerCase() !== owner.toLowerCase() || parsedUrl.name.toLowerCase() !== name.toLowerCase()) return null; try { - const response = await timeoutFetch(`https://api.github.com/repos/${owner}/${name}/issues/${report.issueNumber}`, { headers: githubHeaders(token, "application/vnd.github+json") }); + const response = await timeoutFetch(`https://api.github.com/repos/${owner}/${name}/issues/${report.issueNumber}`, { headers: githubHeaders({ token, accept: "application/vnd.github+json" }) }); if (!response.ok) return null; const issue = (await response.json()) as { number?: number; @@ -1274,15 +1274,6 @@ function upstreamModulesForArea(area: UpstreamDriftArea): string[] { } } -function githubHeaders(token: string | undefined, accept: string): Record { - return { - accept, - "user-agent": "gittensory/0.1", - "x-github-api-version": "2022-11-28", - ...(token ? { authorization: `Bearer ${token}` } : {}), - }; -} - function rawUrl(config: { repo: string; ref: string }, path: string): string { return `https://raw.githubusercontent.com/${config.repo}/${config.ref}/${path}`; } diff --git a/test/unit/github-client.test.ts b/test/unit/github-client.test.ts index bf01061a8c..38dfa771ca 100644 --- a/test/unit/github-client.test.ts +++ b/test/unit/github-client.test.ts @@ -3,6 +3,7 @@ import { clearGitHubResponseCacheForTest, forcedSelfhostMode, githubAdmissionKeyScope, + githubHeaders, githubRateLimitAdmissionKeyForInstallation, githubRateLimitAdmissionKeyForPublicToken, githubRateLimitAdmissionKeyForToken, @@ -185,6 +186,39 @@ describe("githubAdmissionKeyScope — classify an admission key the SAME way as }); }); +describe("githubHeaders — the single shared GitHub REST header-builder (#4610, consolidating 4 drifted copies)", () => { + it("with no options, defaults to the standard accept + pinned api-version and omits content-type/authorization", () => { + expect(githubHeaders()).toEqual({ + accept: "application/vnd.github+json", + "user-agent": "gittensory/0.1", + "x-github-api-version": "2022-11-28", + }); + }); + + it("adds a Bearer authorization header only when a non-empty token is given", () => { + expect(githubHeaders({ token: "abc123" }).authorization).toBe("Bearer abc123"); + expect(githubHeaders({}).authorization).toBeUndefined(); // omitted token → no header + expect(githubHeaders({ token: "" }).authorization).toBeUndefined(); // empty token → treated as absent + }); + + it("uses a caller-given accept header instead of the default (app.ts/model.ts/ruleset.ts all vary this)", () => { + expect(githubHeaders({ accept: "text/plain" }).accept).toBe("text/plain"); + expect(githubHeaders({ accept: "application/json" }).accept).toBe("application/json"); + }); + + it("includes content-type: application/json only when json is explicitly requested (app.ts's JSON POSTs)", () => { + expect(githubHeaders({ json: true })["content-type"]).toBe("application/json"); + expect(githubHeaders({ json: false })["content-type"]).toBeUndefined(); + expect(githubHeaders({})["content-type"]).toBeUndefined(); // defaults to false + }); + + it("includes x-github-api-version unless apiVersion is explicitly disabled (model.ts's fetchText/fetchJson omit it)", () => { + expect(githubHeaders({})["x-github-api-version"]).toBe("2022-11-28"); // defaults to true + expect(githubHeaders({ apiVersion: true })["x-github-api-version"]).toBe("2022-11-28"); + expect(githubHeaders({ apiVersion: false })["x-github-api-version"]).toBeUndefined(); + }); +}); + describe("timeoutFetch", () => { it("passes an explicit caller signal straight through", async () => { const seen: Array = []; From c8c069cddac297c358589a6c87e998a76c2623ce Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:38:59 -0700 Subject: [PATCH 2/2] refactor(queue): convert CI-aggregate cache params to an options object (#4610) cachedFetchLiveCiAggregate, fetchLiveCiAggregateWithRequiredContexts, cachedLiveCiAggregate, and refreshLiveCiAggregate each took 9-10 positional params, including three consecutive same-shaped optional strings (headSha, baseRef, token) with no compiler protection against transposition. Convert all four to the single labeled options-object convention already used by neighboring large functions in this file. The 3-layer cache architecture (cachedLiveCiAggregate -> refreshLiveCiAggregate -> reuseOrRefreshLiveCiAggregate) is unchanged; only the parameter-passing convention changes. Mechanical, no behavior change -- the full existing queue.test.ts suite (807 tests) passes unmodified. --- src/queue/processors.ts | 213 +++++++++++++++++++++++----------------- 1 file changed, 123 insertions(+), 90 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index c83e6abc66..7bbec0f6a7 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -792,124 +792,140 @@ function evictLiveFactOnReject( */ async function cachedFetchLiveCiAggregate( env: Env, - repoFullName: string, - prNumber: number, - headSha: string | null | undefined, - token: string | undefined, - requiredContexts: ReadonlySet | null | undefined, - requiredContextsKey: string, - forceRefresh: boolean, - // False when the caller's own required-context lookup FAILED (not merely resolved to "none configured") -- - // that fail-open aggregate must never be persisted under the normal key, or a transient lookup error would - // mask the repo's real required-context state for every other reader until the entry's TTL expires (#selfhost- - // ci-verification gate review finding). The live-fetched aggregate is still returned to THIS caller either way. - requiredContextsResolved: boolean, - admissionKey?: GitHubRateLimitAdmissionKey, + args: { + repoFullName: string; + prNumber: number; + headSha: string | null | undefined; + token: string | undefined; + requiredContexts: ReadonlySet | null | undefined; + requiredContextsKey: string; + forceRefresh: boolean; + // False when the caller's own required-context lookup FAILED (not merely resolved to "none configured") -- + // that fail-open aggregate must never be persisted under the normal key, or a transient lookup error would + // mask the repo's real required-context state for every other reader until the entry's TTL expires (#selfhost- + // ci-verification gate review finding). The live-fetched aggregate is still returned to THIS caller either way. + requiredContextsResolved: boolean; + admissionKey?: GitHubRateLimitAdmissionKey | undefined; + }, ): Promise { - const cached = await getPullRequestDetailSyncState(env, repoFullName, prNumber).catch(() => null); - if (!forceRefresh && cached && isCiStateCacheFresh(cached, headSha, requiredContextsKey)) { + const cached = await getPullRequestDetailSyncState(env, args.repoFullName, args.prNumber).catch(() => null); + if (!args.forceRefresh && cached && isCiStateCacheFresh(cached, args.headSha, args.requiredContextsKey)) { const deserialized = deserializeCachedCiAggregate(cached); if (deserialized) { incr(CI_STATE_CACHE_METRIC, { field: "aggregate", result: "hit" }); return deserialized; } } - incr(CI_STATE_CACHE_METRIC, { field: "aggregate", result: forceRefresh ? "forced" : "miss" }); - const live = await fetchLiveCiAggregatePreferGraphQl(env, repoFullName, headSha, token, requiredContexts, admissionKey); - if (requiredContextsResolved) { - await writeThroughCiStateCache(env, repoFullName, prNumber, cached, headSha, requiredContextsKey, live); + incr(CI_STATE_CACHE_METRIC, { field: "aggregate", result: args.forceRefresh ? "forced" : "miss" }); + const live = await fetchLiveCiAggregatePreferGraphQl(env, args.repoFullName, args.headSha, args.token, args.requiredContexts, args.admissionKey); + if (args.requiredContextsResolved) { + await writeThroughCiStateCache(env, args.repoFullName, args.prNumber, cached, args.headSha, args.requiredContextsKey, live); } return live; } function fetchLiveCiAggregateWithRequiredContexts( env: Env, - repoFullName: string, - facts: LiveGithubFacts, - prNumber: number, - headSha: string | null | undefined, - baseRef: string | null | undefined, - token: string | undefined, - expectedCiContexts: ReadonlyArray | null | undefined, - forceRefresh: boolean, - admissionKey?: GitHubRateLimitAdmissionKey, + args: { + repoFullName: string; + facts: LiveGithubFacts; + prNumber: number; + headSha: string | null | undefined; + baseRef: string | null | undefined; + token: string | undefined; + expectedCiContexts: ReadonlyArray | null | undefined; + forceRefresh: boolean; + admissionKey?: GitHubRateLimitAdmissionKey | undefined; + }, ): Promise { // CI refresh callers need fresh check/status state; branch protection contexts move slowly enough to stay // request-cached. When the #1941 flag is on, fetchLiveCiAggregatePreferGraphQl collapses the check/status reads // into one GraphQL rollup (reusing these requiredContexts), else it uses the proven REST aggregate. // cachedFetchLiveCiAggregate (#selfhost-ci-verification) is the durable, cross-job snapshot cache sibling to // this request-scoped LiveGithubFacts memo -- it is only ever consulted here, on a LiveGithubFacts miss. - return cachedRequiredStatusContexts(env, repoFullName, facts, baseRef, token, expectedCiContexts, admissionKey) + return cachedRequiredStatusContexts(env, args.repoFullName, args.facts, args.baseRef, args.token, args.expectedCiContexts, args.admissionKey) .catch(() => ({ requiredContexts: null, resolved: false })) .then(({ requiredContexts, resolved }) => - cachedFetchLiveCiAggregate(env, repoFullName, prNumber, headSha, token, requiredContexts, resolvedRequiredContextsKeyPart(requiredContexts), forceRefresh, resolved, admissionKey), + cachedFetchLiveCiAggregate(env, { + repoFullName: args.repoFullName, + prNumber: args.prNumber, + headSha: args.headSha, + token: args.token, + requiredContexts, + requiredContextsKey: resolvedRequiredContextsKeyPart(requiredContexts), + forceRefresh: args.forceRefresh, + requiredContextsResolved: resolved, + admissionKey: args.admissionKey, + }), ); } function cachedLiveCiAggregate( env: Env, - repoFullName: string, - facts: LiveGithubFacts, - prNumber: number, - headSha: string | null | undefined, - baseRef: string | null | undefined, - token: string | undefined, - expectedCiContexts: ReadonlyArray | null | undefined, - admissionKey?: GitHubRateLimitAdmissionKey, + args: { + repoFullName: string; + facts: LiveGithubFacts; + prNumber: number; + headSha: string | null | undefined; + baseRef: string | null | undefined; + token: string | undefined; + expectedCiContexts: ReadonlyArray | null | undefined; + admissionKey?: GitHubRateLimitAdmissionKey | undefined; + }, ): Promise { - const key = liveFactKey(repoFullName, headSha, baseRef, liveFactTokenPart(token), expectedCiContextsKeyPart(expectedCiContexts)); - const cached = facts.ciAggregates.get(key); + const key = liveFactKey(args.repoFullName, args.headSha, args.baseRef, liveFactTokenPart(args.token), expectedCiContextsKeyPart(args.expectedCiContexts)); + const cached = args.facts.ciAggregates.get(key); if (cached) return cached; const next = evictLiveFactOnReject( - facts.ciAggregates, + args.facts.ciAggregates, key, - fetchLiveCiAggregateWithRequiredContexts( - env, - repoFullName, - facts, - prNumber, - headSha, - baseRef, - token, - expectedCiContexts, - false, - admissionKey, - ), + fetchLiveCiAggregateWithRequiredContexts(env, { + repoFullName: args.repoFullName, + facts: args.facts, + prNumber: args.prNumber, + headSha: args.headSha, + baseRef: args.baseRef, + token: args.token, + expectedCiContexts: args.expectedCiContexts, + forceRefresh: false, + admissionKey: args.admissionKey, + }), ); - facts.ciAggregates.set(key, next); + args.facts.ciAggregates.set(key, next); return next; } function refreshLiveCiAggregate( env: Env, - repoFullName: string, - facts: LiveGithubFacts, - prNumber: number, - headSha: string | null | undefined, - baseRef: string | null | undefined, - token: string | undefined, - expectedCiContexts: ReadonlyArray | null | undefined, - admissionKey?: GitHubRateLimitAdmissionKey, + args: { + repoFullName: string; + facts: LiveGithubFacts; + prNumber: number; + headSha: string | null | undefined; + baseRef: string | null | undefined; + token: string | undefined; + expectedCiContexts: ReadonlyArray | null | undefined; + admissionKey?: GitHubRateLimitAdmissionKey | undefined; + }, ): Promise { - const key = liveFactKey(repoFullName, headSha, baseRef, liveFactTokenPart(token), expectedCiContextsKeyPart(expectedCiContexts)); + const key = liveFactKey(args.repoFullName, args.headSha, args.baseRef, liveFactTokenPart(args.token), expectedCiContextsKeyPart(args.expectedCiContexts)); const next = evictLiveFactOnReject( - facts.ciAggregates, + args.facts.ciAggregates, key, - fetchLiveCiAggregateWithRequiredContexts( - env, - repoFullName, - facts, - prNumber, - headSha, - baseRef, - token, - expectedCiContexts, - true, - admissionKey, - ), + fetchLiveCiAggregateWithRequiredContexts(env, { + repoFullName: args.repoFullName, + facts: args.facts, + prNumber: args.prNumber, + headSha: args.headSha, + baseRef: args.baseRef, + token: args.token, + expectedCiContexts: args.expectedCiContexts, + forceRefresh: true, + admissionKey: args.admissionKey, + }), ); - facts.ciAggregates.set(key, next); - facts.forcedCiAggregateKeys.add(key); + args.facts.ciAggregates.set(key, next); + args.facts.forcedCiAggregateKeys.add(key); return next; } @@ -1001,7 +1017,7 @@ function reuseOrRefreshLiveCiAggregate( const key = liveFactKey(repoFullName, headSha, baseRef, liveFactTokenPart(token), expectedCiContextsKeyPart(expectedCiContexts)); const cached = facts.forcedCiAggregateKeys.has(key) ? facts.ciAggregates.get(key) : undefined; if (cached) return cached; - return refreshLiveCiAggregate(env, repoFullName, facts, prNumber, headSha, baseRef, token, expectedCiContexts, admissionKey); + return refreshLiveCiAggregate(env, { repoFullName, facts, prNumber, headSha, baseRef, token, expectedCiContexts, admissionKey }); } /** @@ -3819,7 +3835,16 @@ async function prReadyForReview( } // 2) wait for CI to finish before running the Gittensory review. Required contexts still define which failures // block/close, but hasPending tracks any visible non-bot CI that is not settled yet. - const ci = await cachedLiveCiAggregate(env, repoFullName, liveFacts, pr.number, pr.headSha, pr.baseRef, token, settings.expectedCiContexts, admissionKey).catch(() => undefined); + const ci = await cachedLiveCiAggregate(env, { + repoFullName, + facts: liveFacts, + prNumber: pr.number, + headSha: pr.headSha, + baseRef: pr.baseRef, + token, + expectedCiContexts: settings.expectedCiContexts, + admissionKey, + }).catch(() => undefined); if (ci?.hasPending) { // Staleness cap: inferred or unreadable pending CI can otherwise defer FOREVER (orphaned required context, // transiently unreadable pages, fork check that never reports). Past the cap we stop deferring and let the @@ -8761,17 +8786,16 @@ async function resolveManifestPassedValidationCount( // fetchLiveCiAggregatePreferGraphQl, and the durable-cache read/write) is already fail-open at every // internal step (see their own doc comments), so it never rejects -- an extra catch here would just be // dead, uncoverable code. - const liveCi = await cachedLiveCiAggregate( - env, - args.repoFullName, - args.liveFacts, - args.prNumber, - args.headSha, - args.baseRef, + const liveCi = await cachedLiveCiAggregate(env, { + repoFullName: args.repoFullName, + facts: args.liveFacts, + prNumber: args.prNumber, + headSha: args.headSha, + baseRef: args.baseRef, token, - args.expectedCiContexts, + expectedCiContexts: args.expectedCiContexts, admissionKey, - ); + }); return liveCi.ciState === "passed" ? 1 : 0; } @@ -11100,7 +11124,16 @@ async function maybePublishPrPublicSurface( const baseRef = pr.baseRef ?? repo?.defaultBranch; // Required contexts still detect missing/pending required CI, but every visible completed red check/status is // adverse and blocks the PR. - const liveCi = await refreshLiveCiAggregate(env, repoFullName, webhook.liveFacts, pr.number, pr.headSha, baseRef, token, settings.expectedCiContexts, admissionKey); + const liveCi = await refreshLiveCiAggregate(env, { + repoFullName, + facts: webhook.liveFacts, + prNumber: pr.number, + headSha: pr.headSha, + baseRef, + token, + expectedCiContexts: settings.expectedCiContexts, + admissionKey, + }); // Live merge-state too — the SAME source the disposition uses (planAgentMaintenanceActions reads liveMergeState). // The stored pr.mergeableState lags GitHub's async recompute, and the gate's own check/review publication can // also advance mergeability after readiness ran, so refresh at this post-publish boundary.