diff --git a/src/api/routes.ts b/src/api/routes.ts index 7a73a07cba..3d386c5344 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -3106,7 +3106,13 @@ export function createApp() { if (!isRagEnabled(c.env)) return c.json({ error: "not_found" }, 404); const body = (await c.req.json().catch(() => ({}))) as { repoFullName?: unknown }; const repoFullName = typeof body?.repoFullName === "string" && body.repoFullName.trim().length > 0 ? body.repoFullName.trim() : undefined; - const message: JobMessage = { type: "rag-index-repo", requestedBy: "api", ...(repoFullName ? { repoFullName } : {}) }; + const repo = repoFullName ? await getRepository(c.env, repoFullName) : null; + const message: JobMessage = { + type: "rag-index-repo", + requestedBy: "api", + ...(repoFullName ? { repoFullName } : {}), + ...(typeof repo?.installationId === "number" ? { installationId: repo.installationId } : {}), + }; await c.env.JOBS.send(message); return c.json({ ok: true, status: "queued", scope: repoFullName ?? "all-configured-repos" }, 202); }); @@ -3139,10 +3145,12 @@ export function createApp() { const segment = parseBackfillSegment(body?.segment); if (!segment) return c.json({ error: "valid_segment_required" }, 400); const mode = body?.mode === "full" || body?.mode === "resume" ? body.mode : "light"; + const repo = await getRepository(c.env, body.repoFullName); const message: JobMessage = { type: "backfill-repo-segment", requestedBy: "api", repoFullName: body.repoFullName, + ...(typeof repo?.installationId === "number" ? { installationId: repo.installationId } : {}), segment, mode, force: body?.force === true, @@ -3174,10 +3182,12 @@ export function createApp() { const body = await c.req.json().catch(() => ({})); if (typeof body?.repoFullName !== "string" || body.repoFullName.length === 0) return c.json({ error: "repo_full_name_required" }, 400); const mode = body?.mode === "full" || body?.mode === "resume" ? body.mode : "light"; + const repo = await getRepository(c.env, body.repoFullName); const message: JobMessage = { type: "backfill-pr-details", requestedBy: "api", repoFullName: body.repoFullName, + ...(typeof repo?.installationId === "number" ? { installationId: repo.installationId } : {}), mode, ...(Number.isFinite(Number(body?.cursor)) ? { cursor: Number(body.cursor) } : {}), }; diff --git a/src/github/backfill.ts b/src/github/backfill.ts index 70940f0ef4..4d933054ad 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -68,7 +68,13 @@ import { } from "../review/check-names"; import { buildReviewThreadBlocker, type ReviewThreadBlocker } from "../review/review-thread-findings"; import { delayUntil, shouldWaitForGitHubRateLimit } from "./rate-limit"; -import { isGitHubResponseCacheReplay, timeoutFetch } from "./client"; +import { + githubRateLimitAdmissionKeyForPublicToken, + githubRateLimitAdmissionKeyForToken, + isGitHubResponseCacheReplay, + timeoutFetch, + type GitHubRateLimitAdmissionKey, +} from "./client"; type GitHubLabelPayload = { name: string; @@ -315,6 +321,24 @@ const FRESH_TOTALS_SNAPSHOT_MS = 10 * 60 * 1000; const TOTALS_SNAPSHOT_LOOKBACK = 8; const repoGithubTotalsRefreshes = new Map>(); +function repoInstallationPayload(repo: RepositoryRecord): { installationId?: number } { + return typeof repo.installationId === "number" ? { installationId: repo.installationId } : {}; +} + +function repoAdmissionKeyForToken( + env: Env, + repo: RepositoryRecord, + token: string | undefined, +): GitHubRateLimitAdmissionKey | undefined { + return githubRateLimitAdmissionKeyForToken(env, token, repo.installationId); +} + +type GitHubRateLimitOptions = { rateLimitAdmissionKey?: GitHubRateLimitAdmissionKey }; + +function githubRateLimitOptions(admissionKey: GitHubRateLimitAdmissionKey | undefined): GitHubRateLimitOptions { + return admissionKey ? { rateLimitAdmissionKey: admissionKey } : {}; +} + export async function backfillRegisteredRepositories( env: Env, options: { repoFullName?: string; limits?: Partial; requestedBy?: string; force?: boolean; mode?: BackfillMode } = {}, @@ -426,7 +450,7 @@ export async function enqueueRepositoryOpenDataBackfill( await Promise.all( segments.map((segment, index) => env.JOBS.send( - { type: "backfill-repo-segment", requestedBy: options.requestedBy, repoFullName: repo.fullName, segment, mode, ...(options.force === undefined ? {} : { force: options.force }) }, + { type: "backfill-repo-segment", requestedBy: options.requestedBy, repoFullName: repo.fullName, ...repoInstallationPayload(repo), segment, mode, ...(options.force === undefined ? {} : { force: options.force }) }, { delaySeconds: index * 15 }, ), ), @@ -464,7 +488,7 @@ export async function backfillRepositorySegment( errorSummary: `Waiting for GitHub rate limit reset at ${resetAt}.`, }); await env.JOBS.send( - { type: "backfill-repo-segment", requestedBy: options.requestedBy === "schedule" || options.requestedBy === "test" ? options.requestedBy : "api", repoFullName: repo.fullName, segment: options.segment, mode, force: true }, + { type: "backfill-repo-segment", requestedBy: options.requestedBy === "schedule" || options.requestedBy === "test" ? options.requestedBy : "api", repoFullName: repo.fullName, ...repoInstallationPayload(repo), segment: options.segment, mode, force: true }, { delaySeconds: delayUntil(resetAt) }, ); return segmentJobResult(repo.fullName, options.segment, segment); @@ -481,12 +505,12 @@ export async function backfillRepositorySegment( if ((result.status === "running" || result.status === "waiting_rate_limit") && (options.segment === "labels" || options.segment === "open_issues" || options.segment === "open_pull_requests")) { const delaySeconds = result.status === "waiting_rate_limit" && result.segment.rateLimitResetAt ? delayUntil(result.segment.rateLimitResetAt) : 20; await env.JOBS.send( - { type: "backfill-repo-segment", requestedBy: options.requestedBy === "schedule" || options.requestedBy === "test" ? options.requestedBy : "api", repoFullName: repo.fullName, segment: options.segment, mode: "resume", force: true }, + { type: "backfill-repo-segment", requestedBy: options.requestedBy === "schedule" || options.requestedBy === "test" ? options.requestedBy : "api", repoFullName: repo.fullName, ...repoInstallationPayload(repo), segment: options.segment, mode: "resume", force: true }, { delaySeconds }, ); } if (options.segment === "open_pull_requests" && (result.status === "complete" || result.status === "not_modified")) { - await env.JOBS.send({ type: "backfill-pr-details", requestedBy: "api", repoFullName: repo.fullName, mode: "resume", cursor: 0 }, { delaySeconds: 10 }); + await env.JOBS.send({ type: "backfill-pr-details", requestedBy: "api", repoFullName: repo.fullName, ...repoInstallationPayload(repo), mode: "resume", cursor: 0 }, { delaySeconds: 10 }); } await refreshRepoSyncStateFromSegments(env, repo, sourceKind); return segmentJobResult(repo.fullName, options.segment, result.segment); @@ -552,7 +576,7 @@ export async function backfillOpenPullRequestDetails( const resetAt = await shouldWaitForGitHubRateLimit(env); if (resetAt) { const previous = await getRepoSyncSegment(env, repo.fullName, "pull_request_files"); - await env.JOBS.send({ type: "backfill-pr-details", requestedBy: "api", repoFullName: repo.fullName, mode, cursor: options.cursor ?? 0 }, { delaySeconds: delayUntil(resetAt) }); + await env.JOBS.send({ type: "backfill-pr-details", requestedBy: "api", repoFullName: repo.fullName, ...repoInstallationPayload(repo), mode, cursor: options.cursor ?? 0 }, { delaySeconds: delayUntil(resetAt) }); await completeSegment(env, repo, "pull_request_files", sourceKind, mode, nowIso(), { status: "waiting_rate_limit", fetchedCount: previous?.fetchedCount ?? 0, @@ -575,10 +599,11 @@ export async function backfillOpenPullRequestDetails( const cursor = 0; const batch = incompleteOpenPullRequests.slice(cursor, cursor + PR_DETAIL_BATCH_SIZE[mode]); const warnings: string[] = []; + const admissionKey = repoAdmissionKeyForToken(env, repo, token); await mapWithConcurrency(batch, 2, async (pr) => { await upsertPullRequestDetailSyncState(env, { repoFullName: repo.fullName, pullNumber: pr.number, status: "running" }); const before = warnings.length; - await fetchAndStorePullRequestDetails(env, repo.fullName, pr, token, warnings); + await fetchAndStorePullRequestDetails(env, repo.fullName, pr, token, warnings, admissionKey); const syncedAt = nowIso(); const newWarnings = warnings.slice(before); await upsertPullRequestDetailSyncState(env, { @@ -617,7 +642,7 @@ export async function backfillOpenPullRequestDetails( ), ); if (nextCursor !== undefined) { - await env.JOBS.send({ type: "backfill-pr-details", requestedBy: "api", repoFullName: repo.fullName, mode: "resume", cursor: nextCursor }, { delaySeconds: 20 }); + await env.JOBS.send({ type: "backfill-pr-details", requestedBy: "api", repoFullName: repo.fullName, ...repoInstallationPayload(repo), mode: "resume", cursor: nextCursor }, { delaySeconds: 20 }); } await refreshRepoSyncStateFromSegments(env, repo, sourceKind); return { @@ -640,9 +665,10 @@ export async function refreshPullRequestDetails( return { ok: true, repoFullName, pullNumber, status: "partial", warnings: ["Repository or pull request was not found."] }; } const token = await tokenForRepo(env, repo); + const admissionKey = repoAdmissionKeyForToken(env, repo, token); const warnings: string[] = []; await upsertPullRequestDetailSyncState(env, { repoFullName, pullNumber, status: "running" }); - await fetchAndStorePullRequestDetails(env, repoFullName, pr, token, warnings); + await fetchAndStorePullRequestDetails(env, repoFullName, pr, token, warnings, admissionKey); const syncedAt = nowIso(); const status: PullRequestDetailSyncStateRecord["status"] = warnings.length > 0 ? "partial" : "complete"; await upsertPullRequestDetailSyncState(env, { @@ -683,7 +709,12 @@ export async function refreshContributorActivity( const query = buildContributorActivityQuery(aliases); let payload: GitHubGraphQlContributorSearchResponse; try { - payload = await githubGraphQl(env, query, token); + payload = await githubGraphQl( + env, + query, + token, + githubRateLimitAdmissionKeyForPublicToken(), + ); } catch (error) { warnings.push(`Contributor activity refresh failed for ${chunk.map((repo) => repo.fullName).join(", ")}: ${errorMessage(error)}`); continue; @@ -1090,7 +1121,12 @@ async function refreshRepoGithubTotals( labels { totalCount } } }`; - const response = await githubGraphQl(env, query, token); + const response = await githubGraphQl( + env, + query, + token, + repoAdmissionKeyForToken(env, repo, token), + ); const repository = response.data?.repository; /* v8 ignore next -- GitHub GraphQL should return repository data for an existing repo; this is provider anomaly handling. */ if (!repository) throw new Error(`GitHub totals query did not return repository data for ${repo.fullName}.`); @@ -1239,7 +1275,8 @@ async function backfillRecentMergedSegment( // Hydrate each merged PR's changed files (like the monolithic backfill path) so // recent_merged_pull_requests.changedFiles is populated instead of always empty. const warnings: string[] = []; - await hydrateMergedPullRequestFiles(env, repo.fullName, merged, token, warnings, 8); + const admissionKey = repoAdmissionKeyForToken(env, repo, token); + await hydrateMergedPullRequestFiles(env, repo.fullName, merged, token, warnings, 8, admissionKey); return merged.length; }, { progressiveHistory: true, countPersisted: () => countRecentMergedPullRequests(env, repo.fullName) }, @@ -1257,6 +1294,7 @@ async function hydrateMergedPullRequestFiles( token: string | undefined, warnings: string[], concurrency: number, + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { const alreadyHydrated = new Set( (await listRecentMergedPullRequests(env, repoFullName)) @@ -1267,7 +1305,7 @@ async function hydrateMergedPullRequestFiles( // fetchPullRequestFiles never throws — it returns [] (and records a warning) on any fetch failure. const changedFiles = alreadyHydrated.has(pr.number) ? [] - : await fetchPullRequestFiles(env, repoFullName, pr.number, token, warnings); + : await fetchPullRequestFiles(env, repoFullName, pr.number, token, warnings, admissionKey); await upsertRecentMergedPullRequest(env, toRecentMergedPullRequest(repoFullName, pr, changedFiles)); }); } @@ -1347,13 +1385,18 @@ async function fetchPagedSegment( startPage === 1 ? conditionalRequestForSegment(previous, expectedCount, { allowEtag: !requiresCurrentOpenScan }) : undefined; + const admissionKey = repoAdmissionKeyForToken(env, repo, token); try { for (let page = startPage; page < startPage + SEGMENT_PAGE_BUDGET[mode]; page += 1) { const separator = path.includes("?") ? "&" : "?"; const pagePath = `${path}${separator}per_page=100&page=${page}`; let result: GitHubJsonResponse; if (conditionalRequest && page === 1) { - const conditionalResult = await githubJsonWithHeaders(env, repo.fullName, pagePath, token, { validators: conditionalRequest.validators, allowNotModified: true }); + const conditionalResult = await githubJsonWithHeaders(env, repo.fullName, pagePath, token, { + validators: conditionalRequest.validators, + allowNotModified: true, + ...githubRateLimitOptions(admissionKey), + }); if (isNotModifiedResponse(conditionalResult)) { const previousSegment = conditionalRequest.previous; status = "not_modified"; @@ -1367,7 +1410,7 @@ async function fetchPagedSegment( } result = conditionalResult; } else { - result = await githubJsonWithHeaders(env, repo.fullName, pagePath, token); + result = await githubJsonWithHeaders(env, repo.fullName, pagePath, token, githubRateLimitOptions(admissionKey)); } etag = result.etag ?? etag; lastModified = result.lastModified ?? lastModified; @@ -1464,6 +1507,7 @@ async function supplementOpenIssuesFromGraphQl(env: Env, repo: RepositoryRecord, /* v8 ignore start -- Defensive GitHub GraphQL payload normalization is covered by sparse-payload backfill tests. */ const existingNumbers = new Set(await listOpenIssueNumbers(env, repo.fullName)); const { owner, name } = repoParts(repo.fullName); + const admissionKey = repoAdmissionKeyForToken(env, repo, token); let after = ""; let supplemented = 0; for (;;) { @@ -1487,7 +1531,7 @@ async function supplementOpenIssuesFromGraphQl(env: Env, repo: RepositoryRecord, } rateLimit { remaining resetAt } }`; - const response = await githubGraphQl(env, query, token); + const response = await githubGraphQl(env, query, token, admissionKey); const issues = response.data?.repository?.issues; for (const issue of issues?.nodes ?? []) { if (!issue?.number || existingNumbers.has(issue.number)) continue; @@ -1518,6 +1562,7 @@ async function supplementOpenPullRequestsFromGraphQl(env: Env, repo: RepositoryR /* v8 ignore start -- Defensive GitHub GraphQL payload normalization is covered by sparse-payload backfill tests. */ const existingNumbers = new Set((await listOpenPullRequests(env, repo.fullName)).map((pr) => pr.number)); const { owner, name } = repoParts(repo.fullName); + const admissionKey = repoAdmissionKeyForToken(env, repo, token); let after = ""; let supplemented = 0; for (;;) { @@ -1547,7 +1592,7 @@ async function supplementOpenPullRequestsFromGraphQl(env: Env, repo: RepositoryR } rateLimit { remaining resetAt } }`; - const response = await githubGraphQl(env, query, token); + const response = await githubGraphQl(env, query, token, admissionKey); const pullRequests = response.data?.repository?.pullRequests; for (const pr of pullRequests?.nodes ?? []) { if (!pr?.number || existingNumbers.has(pr.number)) continue; @@ -1677,8 +1722,15 @@ async function backfillRepository(env: Env, repo: RepositoryRecord, limits: Back const installationToken = repo.installationId ? await createInstallationToken(env, repo.installationId).catch(() => undefined) : undefined; const token = installationToken ?? env.GITHUB_PUBLIC_TOKEN; const sourceKind = installationToken ? "installation" : "github"; + const admissionKey = repoAdmissionKeyForToken(env, repo, token); await markSegmentRunning(env, repo, "metadata", sourceKind, mode, startedAt); - const metadata = await githubJson(env, repo.fullName, "", token); + const metadata = await githubJson( + env, + repo.fullName, + "", + token, + admissionKey, + ); segmentResults.push( await completeSegment(env, repo, "metadata", sourceKind, mode, startedAt, { status: "complete", @@ -1715,12 +1767,12 @@ async function backfillRepository(env: Env, repo: RepositoryRecord, limits: Back const normalizedPullRequests = await mapWithConcurrency(pullRequests, 16, async (pr) => upsertPullRequestFromGitHub(env, repo.fullName, pr, { seenOpenAt: startedAt })); const mergedFileWarningStart = warnings.length; - await hydrateMergedPullRequestFiles(env, repo.fullName, recentMerged, token, warnings, limits.detailConcurrency); + await hydrateMergedPullRequestFiles(env, repo.fullName, recentMerged, token, warnings, limits.detailConcurrency, admissionKey); const detailTargets = normalizedPullRequests.slice(0, limits.pullRequestDetails); const detailWarningStart = warnings.length; await mapWithConcurrency(detailTargets, limits.detailConcurrency, async (pr) => { - await fetchAndStorePullRequestDetails(env, repo.fullName, pr, token, warnings); + await fetchAndStorePullRequestDetails(env, repo.fullName, pr, token, warnings, admissionKey); }); const fileWarnings = warnings.slice(mergedFileWarningStart).filter((warning) => /File sync failed/i.test(warning)); const reviewWarnings = warnings.slice(detailWarningStart).filter((warning) => /Review sync failed/i.test(warning)); @@ -1871,9 +1923,14 @@ async function fetchAndStorePullRequestDetails( pr: PullRequestRecord, token: string | undefined, warnings: string[], + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { const warningStart = warnings.length; - const [files, reviews, checks] = await Promise.all([fetchPullRequestFiles(env, repoFullName, pr.number, token, warnings), fetchPullRequestReviews(env, repoFullName, pr.number, token, warnings), fetchPullRequestChecks(env, repoFullName, pr, token, warnings)]); + const [files, reviews, checks] = await Promise.all([ + fetchPullRequestFiles(env, repoFullName, pr.number, token, warnings, admissionKey), + fetchPullRequestReviews(env, repoFullName, pr.number, token, warnings, admissionKey), + fetchPullRequestChecks(env, repoFullName, pr, token, warnings, admissionKey), + ]); const fileSyncFailed = warnings.slice(warningStart).some((warning) => warning.startsWith(`File sync failed for #${pr.number}:`)); if (!fileSyncFailed) { @@ -1928,11 +1985,17 @@ async function fetchAndStorePullRequestDetails( // rather than dropping a successful first page. const PR_DETAIL_MAX_PAGES = 10; -async function githubPaginatedList(env: Env, repoFullName: string, path: string, token: string | undefined): Promise { +async function githubPaginatedList( + env: Env, + repoFullName: string, + path: string, + token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { const items: T[] = []; for (let page = 1; page <= PR_DETAIL_MAX_PAGES; page += 1) { // Callers pass query-less resource paths (/pulls/N/files, /pulls/N/reviews), so the page params start the query. - const result = await githubJsonWithHeaders(env, repoFullName, `${path}?per_page=100&page=${page}`, token).catch(() => undefined); + const result = await githubJsonWithHeaders(env, repoFullName, `${path}?per_page=100&page=${page}`, token, githubRateLimitOptions(admissionKey)).catch(() => undefined); if (!result) return page === 1 ? undefined : items; items.push(...result.data); if (!hasNextPage(result.link)) break; @@ -1946,10 +2009,11 @@ async function fetchPullRequestFiles( pullNumber: number, token: string | undefined, warnings: string[], + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { - const files = await githubPaginatedList(env, repoFullName, `/pulls/${pullNumber}/files`, token); + const files = await githubPaginatedList(env, repoFullName, `/pulls/${pullNumber}/files`, token, admissionKey); if (files) return files; - const fallback = token ? await fetchPullRequestDetailsFromGraphQl(env, repoFullName, pullNumber, token).catch(() => undefined) : undefined; + const fallback = token ? await fetchPullRequestDetailsFromGraphQl(env, repoFullName, pullNumber, token, admissionKey).catch(() => undefined) : undefined; if (fallback) return fallback.files; warnings.push(`File sync failed for #${pullNumber}: GitHub REST and GraphQL detail fetches failed.`); return []; @@ -1988,9 +2052,10 @@ export async function fetchAndStorePullRequestFilesForReview( repoFullName: string, pullNumber: number, token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { const warnings: string[] = []; - const files = await fetchPullRequestFiles(env, repoFullName, pullNumber, token, warnings).catch(() => [] as GitHubFilePayload[]); + const files = await fetchPullRequestFiles(env, repoFullName, pullNumber, token, warnings, admissionKey).catch(() => [] as GitHubFilePayload[]); if (files.length === 0) return []; const records = files.map((file) => toPullRequestFileRecordFromGitHub(repoFullName, pullNumber, file)); // Persist so the AI review, grounding, gate, check-run, and unified-comment reads in THIS run (and any later @@ -2008,10 +2073,11 @@ async function fetchPullRequestReviews( pullNumber: number, token: string | undefined, warnings: string[], + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { - const reviews = await githubPaginatedList(env, repoFullName, `/pulls/${pullNumber}/reviews`, token); + const reviews = await githubPaginatedList(env, repoFullName, `/pulls/${pullNumber}/reviews`, token, admissionKey); if (reviews) return reviews; - const fallback = token ? await fetchPullRequestDetailsFromGraphQl(env, repoFullName, pullNumber, token).catch(() => undefined) : undefined; + const fallback = token ? await fetchPullRequestDetailsFromGraphQl(env, repoFullName, pullNumber, token, admissionKey).catch(() => undefined) : undefined; if (fallback) return fallback.reviews; warnings.push(`Review sync failed for #${pullNumber}: GitHub REST and GraphQL detail fetches failed.`); return []; @@ -2023,6 +2089,7 @@ async function fetchPullRequestChecks( pr: PullRequestRecord, token: string | undefined, warnings: string[], + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise<{ check_runs?: GitHubCheckRunPayload[] }> { if (!pr.headSha) return { check_runs: [] }; // Same pagination as files/reviews, but the check-runs endpoint wraps the list in { check_runs }. @@ -2033,6 +2100,7 @@ async function fetchPullRequestChecks( repoFullName, `/commits/${pr.headSha}/check-runs?per_page=100&page=${page}`, token, + githubRateLimitOptions(admissionKey), ).catch(() => undefined); if (!result) { if (page === 1) { @@ -2118,13 +2186,20 @@ export type LiveCiAggregate = { * fetchLiveCiAggregate fall back to folding ALL red checks into the gate, so a fetch failure can never silently * pass a required red check. */ -export async function fetchRequiredStatusContexts(env: Env, repoFullName: string, baseRef: string | null | undefined, token: string | undefined): Promise | null> { +export async function fetchRequiredStatusContexts( + env: Env, + repoFullName: string, + baseRef: string | null | undefined, + token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise | null> { if (!baseRef) return null; const result = await githubJsonWithHeaders<{ contexts?: Array | null; checks?: Array<{ context?: string | null }> | null }>( env, repoFullName, `/branches/${encodeURIComponent(baseRef)}/protection/required_status_checks`, token, + githubRateLimitOptions(admissionKey), ).catch(() => undefined); if (!result) return null; // 404 / 403 (no admin:read) / error → conservative fold-all. const names = new Set(); @@ -2266,6 +2341,7 @@ export async function fetchLiveCiAggregate( // Branch-protection REQUIRED contexts are the trust boundary for required-context absence/pending detection. // Completed red checks/statuses still fail the aggregate even when they are not branch-protection-required. requiredContexts?: ReadonlySet | null, + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { if (!headSha) return { ciState: "unverified", hasPending: false, hasVisiblePending: false, failingDetails: [], nonRequiredFailingDetails: [] }; // Check-runs + classic statuses are accumulated across pages here; the single classification lives in @@ -2278,6 +2354,7 @@ export async function fetchLiveCiAggregate( repoFullName, `/commits/${headSha}/check-runs?per_page=100&page=${page}`, token, + githubRateLimitOptions(admissionKey), ).catch(() => undefined); // A failed check-runs fetch (page 1 or mid-pagination) leaves the check set partially read — fail closed. if (!result) { @@ -2296,6 +2373,7 @@ export async function fetchLiveCiAggregate( repoFullName, `/commits/${headSha}/status?per_page=100&page=${page}`, token, + githubRateLimitOptions(admissionKey), ).catch(() => undefined); if (!statusResult) { statusIncomplete = true; @@ -2318,6 +2396,7 @@ export async function fetchLiveCiAggregate( repoFullName, `/commits/${headSha}/check-suites?per_page=100`, token, + githubRateLimitOptions(admissionKey), ).catch(() => undefined); return suitesResult ? (suitesResult.data.check_suites ?? []) : null; }, @@ -2345,6 +2424,7 @@ export async function fetchLiveCiAggregateViaGraphQl( headSha: string | null | undefined, token: string | undefined, requiredContexts?: ReadonlySet | null, + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { if (!headSha || !token) return null; const [owner, name] = repoFullName.split("/"); @@ -2378,7 +2458,7 @@ export async function fetchLiveCiAggregateViaGraphQl( } | null; }; errors?: unknown[]; - }>(env, query, token).catch(() => null); + }>(env, query, token, admissionKey).catch(() => null); if (!result) return null; // GraphQL fetch/HTTP error → fall back to REST // A 200 with a top-level `errors` array is a PARTIAL result (a field resolver failed): the data is half-populated // and must NOT be read as a settled/empty rollup — fall back so a partial error can't mask a failing or pending @@ -2437,14 +2517,15 @@ export async function fetchLiveCiAggregatePreferGraphQl( headSha: string | null | undefined, token: string | undefined, requiredContexts?: ReadonlySet | null, + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { if (isStatusRollupGraphQlEnabled(env)) { // fetchLiveCiAggregateViaGraphQl handles all its own errors and returns null on any uncertainty (it never // rejects), so a null result — not a throw — is the fall-back-to-REST signal. - const rollup = await fetchLiveCiAggregateViaGraphQl(env, repoFullName, headSha, token, requiredContexts); + const rollup = await fetchLiveCiAggregateViaGraphQl(env, repoFullName, headSha, token, requiredContexts, admissionKey); if (rollup) return rollup; } - return fetchLiveCiAggregate(env, repoFullName, headSha, token, requiredContexts); + return fetchLiveCiAggregate(env, repoFullName, headSha, token, requiredContexts, admissionKey); } /** @@ -2455,8 +2536,14 @@ export async function fetchLiveCiAggregatePreferGraphQl( * merge decision sees the CURRENT state. `unknown` (GitHub still computing) ⇒ caller treats as not-yet-clean and * a later trigger / the sweep retries. Best-effort: a fetch error returns undefined (caller falls back to stored). */ -export async function fetchLivePullRequestMergeState(env: Env, repoFullName: string, prNumber: number, token: string | undefined): Promise { - const result = await githubJsonWithHeaders<{ mergeable_state?: string | null }>(env, repoFullName, `/pulls/${prNumber}`, token).catch(() => undefined); +export async function fetchLivePullRequestMergeState( + env: Env, + repoFullName: string, + prNumber: number, + token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { + const result = await githubJsonWithHeaders<{ mergeable_state?: string | null }>(env, repoFullName, `/pulls/${prNumber}`, token, githubRateLimitOptions(admissionKey)).catch(() => undefined); return result?.data.mergeable_state ?? undefined; } @@ -2464,8 +2551,14 @@ export async function fetchLivePullRequestMergeState(env: Env, repoFullName: str * sibling closed/merged on GitHub can still read `open` locally; the duplicate-winner election (#dup-winner / * audit #15) confirms a lower sibling's live state before treating this PR as a cluster loser. Best-effort: * returns undefined on any error so the caller fails open to the stored state. */ -export async function fetchLivePullRequestState(env: Env, repoFullName: string, prNumber: number, token: string | undefined): Promise { - const result = await githubJsonWithHeaders<{ state?: string | null }>(env, repoFullName, `/pulls/${prNumber}`, token).catch(() => undefined); +export async function fetchLivePullRequestState( + env: Env, + repoFullName: string, + prNumber: number, + token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { + const result = await githubJsonWithHeaders<{ state?: string | null }>(env, repoFullName, `/pulls/${prNumber}`, token, githubRateLimitOptions(admissionKey)).catch(() => undefined); return result?.data.state ?? undefined; } @@ -2473,8 +2566,14 @@ export async function fetchLivePullRequestState(env: Env, repoFullName: string, * lands between a webhook and its processing; the gate-override command (#16 / audit) re-fetches the live head * so the neutral check-run targets the commit a maintainer is actually looking at, not a phantom old SHA. * Best-effort: returns undefined on any error so the caller fails open to the stored head. */ -export async function fetchLivePullRequestHeadSha(env: Env, repoFullName: string, prNumber: number, token: string | undefined): Promise { - const result = await githubJsonWithHeaders<{ head?: { sha?: string | null } | null }>(env, repoFullName, `/pulls/${prNumber}`, token).catch(() => undefined); +export async function fetchLivePullRequestHeadSha( + env: Env, + repoFullName: string, + prNumber: number, + token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { + const result = await githubJsonWithHeaders<{ head?: { sha?: string | null } | null }>(env, repoFullName, `/pulls/${prNumber}`, token, githubRateLimitOptions(admissionKey)).catch(() => undefined); return result?.data.head?.sha ?? undefined; } @@ -2483,8 +2582,14 @@ export async function fetchLivePullRequestHeadSha(env: Env, repoFullName: string * self-host relay was down), so the re-review runs on the current head + fresh files instead of a stale cached diff * the AI fail-closes as INCOHERENT_DIFF (#sweep-resync). Best-effort: returns undefined on any error so the caller * fails open to the stored PR (the sweep must never stall on a hiccup). */ -export async function fetchLivePullRequest(env: Env, repoFullName: string, prNumber: number, token: string | undefined): Promise { - const result = await githubJsonWithHeaders(env, repoFullName, `/pulls/${prNumber}`, token).catch(() => undefined); +export async function fetchLivePullRequest( + env: Env, + repoFullName: string, + prNumber: number, + token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { + const result = await githubJsonWithHeaders(env, repoFullName, `/pulls/${prNumber}`, token, githubRateLimitOptions(admissionKey)).catch(() => undefined); return result?.data ?? undefined; } @@ -2492,7 +2597,13 @@ export async function fetchLivePullRequest(env: Env, repoFullName: string, prNum * endpoint. This is the only PR↔commit resolution that works for FORK (cross-repo) PRs, whose CI-completion * webhooks (`check_suite`/`check_run`) carry an EMPTY `pull_requests[]`. Returns the de-duplicated open PR numbers. * Best-effort: an empty/whitespace SHA or any API error yields `[]` (the caller must never stall a PR on a hiccup). */ -export async function fetchOpenPullRequestNumbersForCommit(env: Env, repoFullName: string, commitSha: string, token: string | undefined): Promise { +export async function fetchOpenPullRequestNumbersForCommit( + env: Env, + repoFullName: string, + commitSha: string, + token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { const sha = commitSha.trim(); if (!sha) return []; // GET /commits/{sha}/pulls returns the PRs (incl. cross-repo forks) whose head is this commit, on the default @@ -2502,6 +2613,7 @@ export async function fetchOpenPullRequestNumbersForCommit(env: Env, repoFullNam repoFullName, `/commits/${encodeURIComponent(sha)}/pulls?per_page=100`, token, + githubRateLimitOptions(admissionKey), ).catch(() => undefined); if (!result) return []; const numbers = result.data @@ -2516,12 +2628,23 @@ export async function fetchOpenPullRequestNumbersForCommit(env: Env, repoFullNam * planner's approve/request-changes dedup was blind and re-posted a review every cycle — the re-review loop. * Refreshing it live makes the dedup accurate. Best-effort: returns undefined on any error (caller falls back * to the stored value). */ -export async function fetchLivePullRequestReviewDecision(env: Env, repoFullName: string, prNumber: number, token: string | undefined): Promise { +export async function fetchLivePullRequestReviewDecision( + env: Env, + repoFullName: string, + prNumber: number, + token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { if (!token) return undefined; const [owner, name] = repoFullName.split("/"); if (!owner || !name) return undefined; const query = `query { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { pullRequest(number: ${prNumber}) { reviewDecision } } }`; - const result = await githubGraphQl<{ data?: { repository?: { pullRequest?: { reviewDecision?: string | null } | null } | null } }>(env, query, token).catch(() => undefined); + const result = await githubGraphQl<{ data?: { repository?: { pullRequest?: { reviewDecision?: string | null } | null } | null } }>( + env, + query, + token, + admissionKey, + ).catch(() => undefined); return result?.data?.repository?.pullRequest?.reviewDecision ?? undefined; } @@ -2562,7 +2685,13 @@ type GitHubReviewThreadResponse = { * review comments do not expose thread resolution; if GraphQL is unavailable this fails open to [] rather than * guessing. Only maintainer/collaborator comments or known scanner-bot comments can create blockers, so * public review comments from untrusted actors cannot influence merge/close state. */ -export async function fetchLiveReviewThreadBlockers(env: Env, repoFullName: string, prNumber: number, token: string | undefined): Promise { +export async function fetchLiveReviewThreadBlockers( + env: Env, + repoFullName: string, + prNumber: number, + token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { if (!token) return []; const [owner, name] = repoFullName.split("/"); if (!owner || !name) return []; @@ -2597,7 +2726,12 @@ export async function fetchLiveReviewThreadBlockers(env: Env, repoFullName: stri } } }`; - const result: GitHubReviewThreadResponse | undefined = await githubGraphQl(env, query, token).catch(() => undefined); + const result: GitHubReviewThreadResponse | undefined = await githubGraphQl( + env, + query, + token, + admissionKey, + ).catch(() => undefined); const connection: GitHubReviewThreadConnection | null | undefined = result?.data?.repository?.pullRequest?.reviewThreads; if (!connection?.nodes) { if (threads.length === 0) return []; @@ -2635,6 +2769,7 @@ export async function fetchLiveReviewThreadBlockers(env: Env, repoFullName: stri repoFullName, token, memberPermissionCache, + admissionKey, comment.authorLogin, comment.authorAssociation, ) @@ -2657,13 +2792,14 @@ async function isAuthorizedReviewThreadAuthor( repoFullName: string, token: string, memberPermissionCache: Map>, + admissionKey: GitHubRateLimitAdmissionKey | undefined, login: string | null | undefined, association: string | null | undefined, ): Promise { if (isOwnReviewThreadAuthor(login)) return false; if (isTrustedScannerReviewThreadAuthor(login)) return true; if (isMaintainerReviewThreadAuthor(association)) return true; - return isVerifiedMemberReviewThreadAuthor(env, repoFullName, token, memberPermissionCache, login, association); + return isVerifiedMemberReviewThreadAuthor(env, repoFullName, token, memberPermissionCache, admissionKey, login, association); } const MAINTAINER_REVIEW_THREAD_ASSOCIATIONS = new Set(["OWNER", "COLLABORATOR"]); @@ -2678,6 +2814,7 @@ function isVerifiedMemberReviewThreadAuthor( repoFullName: string, token: string, memberPermissionCache: Map>, + admissionKey: GitHubRateLimitAdmissionKey | undefined, login: string | null | undefined, association: string | null | undefined, ): Promise { @@ -2692,6 +2829,7 @@ function isVerifiedMemberReviewThreadAuthor( repoFullName, `/collaborators/${encodeURIComponent(normalizedLogin)}/permission`, token, + githubRateLimitOptions(admissionKey), ) .then((result) => { const permission = result.data.permission; @@ -2722,14 +2860,20 @@ export type LinkedIssueFactsResult = { number: number; labels: string[]; assigne * live fetches. (Note: GitHub's issues endpoint also returns pull requests, which carry a `pull_request` field; * a PR number passed here would simply fail the rules — we only treat real issues' labels/assignees.) */ -export async function fetchLinkedIssueFacts(env: Env, repoFullName: string, issueNumber: number, token: string | undefined): Promise { +export async function fetchLinkedIssueFacts( + env: Env, + repoFullName: string, + issueNumber: number, + token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { const result = await githubJsonWithHeaders<{ number?: number; state?: string | null; labels?: Array<{ name?: string | null } | string | null> | null; assignees?: Array<{ login?: string | null } | null> | null; user?: { login?: string | null } | null; - }>(env, repoFullName, `/issues/${issueNumber}`, token).catch(() => undefined); + }>(env, repoFullName, `/issues/${issueNumber}`, token, githubRateLimitOptions(admissionKey)).catch(() => undefined); if (!result) return undefined; const data = result.data; const labels = (data.labels ?? []).flatMap((label) => { @@ -2751,6 +2895,7 @@ async function fetchPullRequestDetailsFromGraphQl( repoFullName: string, pullNumber: number, token: string, + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise<{ files: GitHubFilePayload[]; reviews: GitHubReviewPayload[] }> { /* v8 ignore start -- GitHub detail GraphQL sparse-node fallbacks are exercised through PR detail hydration tests. */ const { owner, name } = repoParts(repoFullName); @@ -2767,7 +2912,7 @@ async function fetchPullRequestDetailsFromGraphQl( } rateLimit { remaining resetAt } }`; - const response = await githubGraphQl(env, query, token); + const response = await githubGraphQl(env, query, token, admissionKey); const pullRequest = response.data?.repository?.pullRequest; if (!pullRequest) throw new GitHubApiError(`GitHub GraphQL failed for ${repoFullName} pull request #${pullNumber}: pull request not found`, 404, null, null, null, ""); const files: GitHubFilePayload[] = (pullRequest.files?.nodes ?? []).flatMap((file) => { @@ -2879,9 +3024,10 @@ async function syncLabels( const startedAt = nowIso(); await markSegmentRunning(env, repo, "labels", sourceKind, mode, startedAt); const items: GitHubLabelPayload[] = []; + const admissionKey = repoAdmissionKeyForToken(env, repo, token); try { for (let page = 1; ; page += 1) { - const result = await githubJsonWithHeaders(env, repo.fullName, `/labels?per_page=100&page=${page}`, token); + const result = await githubJsonWithHeaders(env, repo.fullName, `/labels?per_page=100&page=${page}`, token, githubRateLimitOptions(admissionKey)); items.push(...result.data); if (!hasNextPage(result.link)) break; } @@ -2918,6 +3064,7 @@ async function githubPaged( const sourceKind: RepoSyncSegmentRecord["sourceKind"] = repo.installationId ? "installation" : "github"; const previous = mode === "resume" ? await getRepoSyncSegment(env, repo.fullName, segmentName) : null; await markSegmentRunning(env, repo, segmentName, sourceKind, mode, startedAt); + const admissionKey = repoAdmissionKeyForToken(env, repo, token); const startPage = mode === "resume" && previous?.nextCursor && Number.isFinite(Number(previous.nextCursor)) ? Number(previous.nextCursor) : 1; const priorFetched = mode === "resume" ? (previous?.fetchedCount ?? 0) : 0; const items: T[] = []; @@ -2935,7 +3082,7 @@ async function githubPaged( const pageLimit = Math.min(100, limit - items.length); const separator = path.includes("?") ? "&" : "?"; const pagePath = `${path}${separator}per_page=${pageLimit}&page=${page}`; - const result = await githubJsonWithHeaders(env, repo.fullName, pagePath, token); + const result = await githubJsonWithHeaders(env, repo.fullName, pagePath, token, githubRateLimitOptions(admissionKey)); etag = result.etag ?? etag; lastModified = result.lastModified ?? lastModified; lastCursor = String(page); @@ -2977,10 +3124,23 @@ async function githubPaged( return { items, warnings, segment, fetchedCount }; } -async function githubJson(env: Env, repoFullName: string, path: string, token?: string): Promise { - return (await githubJsonWithHeaders(env, repoFullName, path, token)).data; +async function githubJson( + env: Env, + repoFullName: string, + path: string, + token?: string, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { + return (await githubJsonWithHeaders(env, repoFullName, path, token, githubRateLimitOptions(admissionKey))).data; } +type GitHubJsonRequestOptions = { + validators?: GitHubConditionalValidators; + rateLimitAdmissionKey?: GitHubRateLimitAdmissionKey; +}; +type GitHubJsonStandardOptions = GitHubJsonRequestOptions & { allowNotModified?: false }; +type GitHubJsonConditionalOptions = GitHubJsonRequestOptions & { allowNotModified: true }; + async function githubJsonWithHeaders( env: Env, repoFullName: string, @@ -2992,20 +3152,30 @@ async function githubJsonWithHeaders( repoFullName: string, path: string, token: string | undefined, - options: { validators?: GitHubConditionalValidators; allowNotModified: true }, + options: GitHubJsonConditionalOptions, ): Promise>; +async function githubJsonWithHeaders( + env: Env, + repoFullName: string, + path: string, + token: string | undefined, + options: GitHubJsonStandardOptions, +): Promise>; async function githubJsonWithHeaders( env: Env, repoFullName: string, path: string, token?: string, - options?: { validators?: GitHubConditionalValidators; allowNotModified?: boolean }, -): Promise> { + options?: GitHubJsonConditionalOptions | GitHubJsonStandardOptions, +): Promise | GitHubJsonResponse> { const { owner, name } = repoParts(repoFullName); const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}${path}`; - let response = await timeoutFetch(url, { headers: githubRestHeaders(token, options?.validators) }); + let response = await timeoutFetch(url, { + headers: githubRestHeaders(token, options?.validators), + ...(options?.rateLimitAdmissionKey ? { githubRateLimitAdmission: true, githubRateLimitAdmissionKey: options.rateLimitAdmissionKey } : {}), + }); if (!isGitHubResponseCacheReplay(response)) { - await recordGitHubResponse(env, repoFullName, path, response, "rest"); + await recordGitHubResponse(env, repoFullName, path, response, "rest", options?.rateLimitAdmissionKey); } if (response.status === 304 && options?.allowNotModified) return notModifiedResponse(response); if (response.status === 404 && token && token === env.GITHUB_PUBLIC_TOKEN) { @@ -3054,7 +3224,12 @@ function githubRestHeaders(token?: string, validators?: GitHubConditionalValidat }; } -async function githubGraphQl(env: Env, query: string, token: string): Promise { +async function githubGraphQl( + env: Env, + query: string, + token: string, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { const response = await timeoutFetch("https://api.github.com/graphql", { method: "POST", headers: { @@ -3064,8 +3239,9 @@ async function githubGraphQl(env: Env, query: string, token: string): Promise authorization: `Bearer ${token}`, }, body: JSON.stringify({ query }), + ...(admissionKey ? { githubRateLimitAdmission: true, githubRateLimitAdmissionKey: admissionKey } : {}), }); - await recordGitHubResponse(env, null, "/graphql", response, "graphql"); + await recordGitHubResponse(env, null, "/graphql", response, "graphql", admissionKey); if (!response.ok) { const body = await response.text(); throw new GitHubApiError( @@ -3195,11 +3371,13 @@ async function recordGitHubResponse( path: string, response: Response, resource: "rest" | "graphql", + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { const resetHeader = response.headers.get("x-ratelimit-reset"); const resetAt = resetHeader && Number.isFinite(Number(resetHeader)) ? new Date(Number(resetHeader) * 1000).toISOString() : undefined; await recordGitHubRateLimitObservation(env, { repoFullName, + admissionKey, resource, path, statusCode: response.status, diff --git a/src/github/client.ts b/src/github/client.ts index 68c5655243..ee76d0acd7 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -65,6 +65,26 @@ export function githubRateLimitAdmissionKeyForInstallation(installationId: numbe return `installation:${Math.trunc(installationId)}`; } +export function githubRateLimitAdmissionKeyForPublicToken(): GitHubRateLimitAdmissionKey { + return "public-token"; +} + +/** The SINGLE token→admission-key resolver, so every GitHub read attributes consistently and a token can never + * travel without its matching key: the public bucket for the shared public token, the installation bucket for an + * installation token with a known installation id, else undefined (unattributed). Callers pass whichever token + * they will actually read with, so the key is always derived from the SAME token and cannot drift apart from it. */ +export function githubRateLimitAdmissionKeyForToken( + env: { GITHUB_PUBLIC_TOKEN?: string }, + token: string | undefined, + installationId: number | null | undefined, +): GitHubRateLimitAdmissionKey | undefined { + if (!token) return undefined; + if (token === env.GITHUB_PUBLIC_TOKEN) return githubRateLimitAdmissionKeyForPublicToken(); + return typeof installationId === "number" && Number.isFinite(installationId) + ? githubRateLimitAdmissionKeyForInstallation(installationId) + : undefined; +} + /** Only cache explicitly stable GitHub REST reads. PR/issue/comment/label/event/check/status reads are mutable * review inputs and must always reflect the current GitHub state. Exported for tests. */ export function isCacheableGithubUrl(url: string): boolean { @@ -128,9 +148,15 @@ function recordGitHubCacheMetric(result: "hit" | "miss" | "set" | "coalesced" | incr(GITHUB_RESPONSE_CACHE_METRIC, { result, class: cls }); } -function githubAdmissionKeyScope(admissionKey: GitHubRateLimitAdmissionKey | null | undefined): "installation" | "global" | "other" { - if (!admissionKey) return "global"; - return admissionKey.startsWith("installation:") ? "installation" : "other"; +// Keep this classification identical to selfhost/queue-common's githubRateLimitAdmissionKeyScope so both metric +// surfaces label a given admission key the same way (installation / public / global / unknown / other). Exported so +// the classification is unit-tested directly (mirroring the queue-common helper's test), not only via rendered metrics. +export function githubAdmissionKeyScope(admissionKey: GitHubRateLimitAdmissionKey | null | undefined): "installation" | "public" | "global" | "unknown" | "other" { + if (!admissionKey) return "unknown"; + if (admissionKey.startsWith("installation:")) return "installation"; + if (admissionKey === githubRateLimitAdmissionKeyForPublicToken()) return "public"; + if (admissionKey.startsWith("global:")) return "global"; + return "other"; } function restRemainingBucket(remaining: number): "0" | "1-75" | "76-150" | "151+" { diff --git a/src/github/pr-freshness.ts b/src/github/pr-freshness.ts index 2bc6b8af06..bcf7f59fa7 100644 --- a/src/github/pr-freshness.ts +++ b/src/github/pr-freshness.ts @@ -1,5 +1,6 @@ import { createInstallationToken } from "./app"; import { fetchLivePullRequest } from "./backfill"; +import { githubRateLimitAdmissionKeyForToken } from "./client"; import type { GitHubPullRequestPayload } from "../types"; export type PullRequestFreshness = @@ -66,7 +67,8 @@ export async function fetchPullRequestFreshness( (await createInstallationToken(env, args.installationId).catch(() => undefined)) ?? env.GITHUB_PUBLIC_TOKEN; if (!token) return classifyPullRequestFreshness(undefined, args.expectedHeadSha); - const live = await fetchLivePullRequest(env, args.repoFullName, args.pullNumber, token); + const admissionKey = githubRateLimitAdmissionKeyForToken(env, token, args.installationId); + const live = await fetchLivePullRequest(env, args.repoFullName, args.pullNumber, token, admissionKey); return classifyPullRequestFreshness(live, args.expectedHeadSha); } diff --git a/src/queue/processors.ts b/src/queue/processors.ts index c55f232f96..d2c67eba3a 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -135,7 +135,12 @@ import { ensurePullRequestLabel, removePullRequestLabel, } from "../github/labels"; -import { githubRateLimitAdmissionKeyForInstallation, resolveRepoActionMode } from "../github/client"; +import { + githubRateLimitAdmissionKeyForInstallation, + githubRateLimitAdmissionKeyForToken, + resolveRepoActionMode, + type GitHubRateLimitAdmissionKey, +} from "../github/client"; import { fetchPullRequestFreshness, pullRequestFreshnessDetail, @@ -451,6 +456,14 @@ function liveFactTokenPart(token: string | undefined): string { return `token:${token.length}:${(hash >>> 0).toString(16).padStart(8, "0")}`; } +function githubAdmissionKeyForToken( + env: Env, + installationId: number | null | undefined, + token: string | undefined, +): GitHubRateLimitAdmissionKey | undefined { + return githubRateLimitAdmissionKeyForToken(env, token, installationId); +} + function primeLiveMergeState( facts: LiveGithubFacts, repoFullName: string, @@ -471,6 +484,7 @@ function cachedRequiredStatusContexts( facts: LiveGithubFacts, baseRef: string | null | undefined, token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise | null> { const key = liveFactKey(repoFullName, baseRef, liveFactTokenPart(token)); const cached = facts.requiredContexts.get(key); @@ -478,7 +492,7 @@ function cachedRequiredStatusContexts( const next = evictLiveFactOnReject( facts.requiredContexts, key, - fetchRequiredStatusContexts(env, repoFullName, baseRef, token), + fetchRequiredStatusContexts(env, repoFullName, baseRef, token, admissionKey), ); facts.requiredContexts.set(key, next); return next; @@ -502,14 +516,15 @@ function fetchLiveCiAggregateWithRequiredContexts( headSha: string | null | undefined, baseRef: string | null | undefined, token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, ): 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. - return cachedRequiredStatusContexts(env, repoFullName, facts, baseRef, token) + return cachedRequiredStatusContexts(env, repoFullName, facts, baseRef, token, admissionKey) .catch(() => null) .then((requiredContexts) => - fetchLiveCiAggregatePreferGraphQl(env, repoFullName, headSha, token, requiredContexts), + fetchLiveCiAggregatePreferGraphQl(env, repoFullName, headSha, token, requiredContexts, admissionKey), ); } @@ -520,6 +535,7 @@ function cachedLiveCiAggregate( headSha: string | null | undefined, baseRef: string | null | undefined, token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { const key = liveFactKey(repoFullName, headSha, baseRef, liveFactTokenPart(token)); const cached = facts.ciAggregates.get(key); @@ -534,6 +550,7 @@ function cachedLiveCiAggregate( headSha, baseRef, token, + admissionKey, ), ); facts.ciAggregates.set(key, next); @@ -547,6 +564,7 @@ function refreshLiveCiAggregate( headSha: string | null | undefined, baseRef: string | null | undefined, token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { const key = liveFactKey(repoFullName, headSha, baseRef, liveFactTokenPart(token)); const next = evictLiveFactOnReject( @@ -559,6 +577,7 @@ function refreshLiveCiAggregate( headSha, baseRef, token, + admissionKey, ), ); facts.ciAggregates.set(key, next); @@ -571,6 +590,7 @@ function cachedLiveMergeState( facts: LiveGithubFacts, prNumber: number, token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { const key = liveFactKey(repoFullName, prNumber, liveFactTokenPart(token)); const cached = facts.mergeStates.get(key); @@ -578,7 +598,7 @@ function cachedLiveMergeState( const next = evictLiveFactOnReject( facts.mergeStates, key, - fetchLivePullRequestMergeState(env, repoFullName, prNumber, token), + fetchLivePullRequestMergeState(env, repoFullName, prNumber, token, admissionKey), ); facts.mergeStates.set(key, next); return next; @@ -590,12 +610,13 @@ function refreshLiveMergeState( facts: LiveGithubFacts, prNumber: number, token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, ): Promise { const key = liveFactKey(repoFullName, prNumber, liveFactTokenPart(token)); const next = evictLiveFactOnReject( facts.mergeStates, key, - fetchLivePullRequestMergeState(env, repoFullName, prNumber, token), + fetchLivePullRequestMergeState(env, repoFullName, prNumber, token, admissionKey), ); facts.mergeStates.set(key, next); return next; @@ -979,14 +1000,21 @@ async function fanOutAgentRegateSweepJobs( // that can merge/close. The action layer (maybeRunAgentMaintenance) stays autonomy-gated, so an observe repo is // re-reviewed but never auto-actioned. This is what makes advisory reviews fire on existing open PRs without // depending on a fresh webhook per PR. - const byKey = new Map(); - for (const repo of await listRepositories(env)) - byKey.set(repo.fullName.toLowerCase(), repo.fullName); - for (const fullName of listConvergenceRepos(env)) - byKey.set(fullName.toLowerCase(), fullName); - const configured: string[] = []; + const repositoriesByKey = new Map((await listRepositories(env)).map((repo) => [repo.fullName.toLowerCase(), repo])); + const byKey = new Map(); + for (const repo of repositoriesByKey.values()) + byKey.set(repo.fullName.toLowerCase(), { fullName: repo.fullName, ...(typeof repo.installationId === "number" ? { installationId: repo.installationId } : {}) }); + for (const fullName of listConvergenceRepos(env)) { + const repo = repositoriesByKey.get(fullName.toLowerCase()); + byKey.set(fullName.toLowerCase(), { + fullName, + ...(typeof repo?.installationId === "number" ? { installationId: repo.installationId } : {}), + }); + } + const configured: Array<{ fullName: string; installationId?: number }> = []; let skippedDraining = 0; - for (const repoFullName of byKey.values()) { + for (const repo of byKey.values()) { + const repoFullName = repo.fullName; const settings = await resolveRepositorySettings(env, repoFullName); if ( !( @@ -1005,14 +1033,15 @@ async function fanOutAgentRegateSweepJobs( skippedDraining += 1; continue; } - configured.push(repoFullName); + configured.push(repo); } await Promise.all( - configured.map((repoFullName, index) => { + configured.map((repo, index) => { const message: JobMessage = { type: "agent-regate-sweep", requestedBy, - repoFullName, + repoFullName: repo.fullName, + ...(typeof repo.installationId === "number" ? { installationId: repo.installationId } : {}), }; const delaySeconds = Math.min(index * 10, 600); return delaySeconds > 0 @@ -1076,24 +1105,31 @@ async function fanOutRagIndexJobs( // registration webhook), so a registered-only fan-out never indexed them — leaving reviews without codebase context. // Deduped case-insensitively (a repo can be both registered AND configured). Each is then filtered by whether RAG is // active for it (`features.rag` override → GITTENSORY_REVIEW_REPOS allowlist default), so nothing extra is indexed. - const byKey = new Map(); - for (const repo of (await listRepositories(env)).filter( + const repositoriesByKey = new Map((await listRepositories(env)).map((repo) => [repo.fullName.toLowerCase(), repo])); + const byKey = new Map(); + for (const repo of [...repositoriesByKey.values()].filter( (r) => r.isRegistered, )) - byKey.set(repo.fullName.toLowerCase(), repo.fullName); - for (const fullName of listConvergenceRepos(env)) - byKey.set(fullName.toLowerCase(), fullName); + byKey.set(repo.fullName.toLowerCase(), { fullName: repo.fullName, ...(typeof repo.installationId === "number" ? { installationId: repo.installationId } : {}) }); + for (const fullName of listConvergenceRepos(env)) { + const repo = repositoriesByKey.get(fullName.toLowerCase()); + byKey.set(fullName.toLowerCase(), { + fullName, + ...(typeof repo?.installationId === "number" ? { installationId: repo.installationId } : {}), + }); + } const candidates = [...byKey.values()]; const ragActiveByRepo = await Promise.all( - candidates.map((fullName) => convergedFeatureActive(env, fullName, "rag")), + candidates.map((repo) => convergedFeatureActive(env, repo.fullName, "rag")), ); const repositories = candidates.filter((_, index) => ragActiveByRepo[index]); await Promise.all( - repositories.map((fullName, index) => { + repositories.map((repo, index) => { const message: JobMessage = { type: "rag-index-repo", requestedBy, - repoFullName: fullName, + repoFullName: repo.fullName, + ...(typeof repo.installationId === "number" ? { installationId: repo.installationId } : {}), }; const delaySeconds = Math.min(index * 30, 900); return delaySeconds > 0 @@ -1544,6 +1580,7 @@ async function maybeRunAgentMaintenance( () => undefined, ); const token = ciToken ?? env.GITHUB_PUBLIC_TOKEN; + const admissionKey = githubAdmissionKeyForToken(env, installationId, token); const baseRef = pr.baseRef ?? args.repo?.defaultBranch; const [ changedFiles, @@ -1566,15 +1603,16 @@ async function maybeRunAgentMaintenance( args.liveFacts, baseRef, token, + admissionKey, ), // Live mergeable_state after the gate's own publish/review/check mutations. Readiness may have seen the PR as // blocked before the bot approval/check landed, so this boundary must refresh instead of replaying the cache. - refreshLiveMergeState(env, repoFullName, args.liveFacts, pr.number, token), + refreshLiveMergeState(env, repoFullName, args.liveFacts, pr.number, token, admissionKey), // RC1: live reviewDecision so the approve/request-changes dedup is accurate. The STORED reviewDecision is // only written by the open-PR backfill and goes stale → the planner re-posted a review every cycle (the // re-review loop with 14-23 stacked reviews). With the live value, an already-approved/changes-requested PR // is not re-reviewed for the same state. - fetchLivePullRequestReviewDecision(env, repoFullName, pr.number, token), + fetchLivePullRequestReviewDecision(env, repoFullName, pr.number, token, admissionKey), ]); const ciAggregate = await refreshLiveCiAggregate( env, @@ -1583,6 +1621,7 @@ async function maybeRunAgentMaintenance( pr.headSha, baseRef, token, + admissionKey, ); const changedPaths = changedPathsForGuardrail(changedFiles); const repoOwner = repoFullName.includes("/") @@ -1611,6 +1650,7 @@ async function maybeRunAgentMaintenance( body: pr.body, linkedIssues: pr.linkedIssues, ciToken, + installationId, }); // Contributor blacklist (#1425): resolve whether the PR author is on the repo's blacklist (the shared/global @@ -1761,11 +1801,13 @@ async function reReviewStoredPullRequest( (await createInstallationToken(env, installationId).catch( () => undefined, )) ?? env.GITHUB_PUBLIC_TOKEN; + const resyncAdmissionKey = githubAdmissionKeyForToken(env, installationId, resyncToken); const live = await fetchLivePullRequest( env, repoFullName, prNumber, resyncToken, + resyncAdmissionKey, ); primeLiveMergeState(liveFacts, repoFullName, prNumber, resyncToken, live?.mergeable_state); if (live?.head?.sha && live.head.sha !== pr.headSha) { @@ -1920,10 +1962,11 @@ async function prReadyForReview( () => undefined, )) ?? env.GITHUB_PUBLIC_TOKEN; if (!token) return true; + const admissionKey = githubAdmissionKeyForToken(env, installationId, token); // 1) rebase if BEHIND base — the synchronize on the new head re-triggers this flow on the merged result. The // request-local facts may already be seeded from the sweep's resync payload, and the fallback live merge-state // fetch fails open internally (swallows its own fetch errors → undefined). - const liveMergeState = await cachedLiveMergeState(env, repoFullName, liveFacts, pr.number, token); + const liveMergeState = await cachedLiveMergeState(env, repoFullName, liveFacts, pr.number, token, admissionKey); if (liveMergeState === "behind") { const autonomyLevel = resolveAutonomy(settings.autonomy, "update_branch"); const installation = await getInstallation(env, installationId); @@ -1956,7 +1999,7 @@ 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.headSha, pr.baseRef, token).catch(() => undefined); + const ci = await cachedLiveCiAggregate(env, repoFullName, liveFacts, pr.headSha, pr.baseRef, token, 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 STUCK_CI_DEFER_MS we stop deferring and @@ -2145,11 +2188,13 @@ export async function resolveCiCompletionPrNumbers( () => undefined, )) ?? env.GITHUB_PUBLIC_TOKEN; if (token) { + const admissionKey = githubAdmissionKeyForToken(env, installationId, token); const apiNumbers = await fetchOpenPullRequestNumbersForCommit( env, repoFullName, headSha, token, + admissionKey, ).catch(() => []); for (const number of apiNumbers) resolved.add(number); } @@ -3582,11 +3627,12 @@ export async function resolveLinkedIssueAuthorLogins( () => undefined, ); if (!token) return cached; + const admissionKey = githubAdmissionKeyForToken(env, installationId, token); return Promise.all( cached.map((login, index) => login != null ? Promise.resolve(login) - : fetchLinkedIssueFacts(env, repoFullName, linkedIssues[index]!, token) + : fetchLinkedIssueFacts(env, repoFullName, linkedIssues[index]!, token, admissionKey) .then((facts) => facts?.authorLogin ?? null) .catch(() => null), ), @@ -3732,11 +3778,15 @@ async function resolvePullRequestFilesForReview( const token = await createInstallationToken(env, args.installationId).catch( () => undefined, ); + /* v8 ignore next -- installation-token failure fallback is covered by public-token fetch paths; this branch depends on token-cache timing. */ + const reviewFilesToken = token ?? env.GITHUB_PUBLIC_TOKEN; + const admissionKey = githubAdmissionKeyForToken(env, args.installationId, reviewFilesToken); const fetched = await fetchAndStorePullRequestFilesForReview( env, args.repoFullName, args.pullNumber, - token ?? env.GITHUB_PUBLIC_TOKEN, + reviewFilesToken, + admissionKey, ); if (fetched.length > 0) { console.log( @@ -4444,6 +4494,7 @@ export async function reconcileLiveDuplicateSiblings( () => undefined, ); const token = installationToken ?? env.GITHUB_PUBLIC_TOKEN; + const admissionKey = githubAdmissionKeyForToken(env, installationId, token); const staleClosed = new Set(); await Promise.all( lowerOverlapping.map(async (sibling) => { @@ -4452,6 +4503,7 @@ export async function reconcileLiveDuplicateSiblings( repoFullName, sibling.number, token, + admissionKey, ).catch(() => undefined); if (liveState !== undefined && liveState !== "open") staleClosed.add(sibling.number); @@ -5158,11 +5210,13 @@ async function maybePublishPrPublicSurface( (await createInstallationToken(env, installationId).catch( () => undefined, )) ?? env.GITHUB_PUBLIC_TOKEN; + const reviewThreadAdmissionKey = githubAdmissionKeyForToken(env, installationId, reviewThreadToken); const reviewThreadBlockers = await fetchLiveReviewThreadBlockers( env, repoFullName, pr.number, reviewThreadToken, + reviewThreadAdmissionKey, ).catch(() => []); advisory.findings.push(...reviewThreadBlockers.map(reviewThreadBlockerFinding)); } @@ -5517,14 +5571,15 @@ async function maybePublishPrPublicSurface( () => undefined, ); const token = ciToken ?? env.GITHUB_PUBLIC_TOKEN; + const admissionKey = githubAdmissionKeyForToken(env, installationId, token); 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.headSha, baseRef, token); + const liveCi = await refreshLiveCiAggregate(env, repoFullName, webhook.liveFacts, pr.headSha, baseRef, token, 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. - const liveMergeState = await refreshLiveMergeState(env, repoFullName, webhook.liveFacts, pr.number, token).catch(() => undefined); + const liveMergeState = await refreshLiveMergeState(env, repoFullName, webhook.liveFacts, pr.number, token, admissionKey).catch(() => undefined); const mergeStateLabel = liveMergeState ?? pr.mergeableState; // fail-safe to the stored value const ciState: MergeReadiness["ciState"] = liveCi.ciState === "passed" @@ -5960,11 +6015,13 @@ export async function resolveOverrideHeadSha( (await createInstallationToken(env, installationId).catch( () => undefined, )) ?? env.GITHUB_PUBLIC_TOKEN; + const admissionKey = githubAdmissionKeyForToken(env, installationId, token); const liveHeadSha = await fetchLivePullRequestHeadSha( env, repoFullName, pr.number, token, + admissionKey, ); return liveHeadSha ?? pr.headSha; } diff --git a/src/review/linked-issue-hard-rules.ts b/src/review/linked-issue-hard-rules.ts index 53620084dc..683350c04d 100644 --- a/src/review/linked-issue-hard-rules.ts +++ b/src/review/linked-issue-hard-rules.ts @@ -1,4 +1,5 @@ import { fetchLinkedIssueFacts } from "../github/backfill"; +import { githubRateLimitAdmissionKeyForToken } from "../github/client"; import { extractLinkedIssueNumbersWithOverflow } from "../db/repositories"; // Linked-issue HARD-RULE auto-close (#linked-issue-hard-rules). A DETERMINISTIC rule about the issue(s) a @@ -153,6 +154,10 @@ export async function resolveLinkedIssueHardRule(args: { body: string | null | undefined; linkedIssues: number[]; ciToken: string | undefined; + // The installation id for `ciToken` (undefined for public-token reads). The admission key is DERIVED from the + // token + this id via the one shared resolver, so an installation-token read attributes to its installation bucket + // (not "unknown") and the key can never be passed out of sync with the token it belongs to. + installationId?: number | null | undefined; }): Promise { const anyRuleOn = args.config.ownerAssignedClose === "block" || @@ -167,7 +172,8 @@ export async function resolveLinkedIssueHardRule(args: { } if (args.linkedIssues.length === 0) return undefined; const token = args.ciToken ?? args.env.GITHUB_PUBLIC_TOKEN; - const issueFacts = (await Promise.all(args.linkedIssues.map((issueNumber) => fetchLinkedIssueFacts(args.env, args.repoFullName, issueNumber, token)))).flatMap((facts) => (facts ? [facts] : [])); + const admissionKey = githubRateLimitAdmissionKeyForToken(args.env, token, args.installationId); + const issueFacts = (await Promise.all(args.linkedIssues.map((issueNumber) => fetchLinkedIssueFacts(args.env, args.repoFullName, issueNumber, token, admissionKey)))).flatMap((facts) => (facts ? [facts] : [])); if (issueFacts.length === 0) return undefined; return evaluateLinkedIssueHardRules({ issues: issueFacts, config: args.config, repoOwner: args.repoOwner }); } diff --git a/src/selfhost/queue-common.ts b/src/selfhost/queue-common.ts index c681c0482c..52793aa6d8 100644 --- a/src/selfhost/queue-common.ts +++ b/src/selfhost/queue-common.ts @@ -7,6 +7,7 @@ import { } from "../github/rate-limit"; import { githubRateLimitAdmissionKeyForInstallation, + githubRateLimitAdmissionKeyForPublicToken, latestGitHubRestRateLimitObservation, type GitHubRateLimitAdmissionKey, } from "../github/client"; @@ -298,7 +299,7 @@ export type GitHubRateLimitAdmissionTarget = { admissionKey: GitHubRateLimitAdmissionKey | null; }; -export type GitHubRateLimitKeyScope = "installation" | "global" | "other"; +export type GitHubRateLimitKeyScope = "installation" | "public" | "global" | "unknown" | "other"; export type GitHubRateLimitMetricLabels = { job_type: string; key_scope: GitHubRateLimitKeyScope; @@ -320,8 +321,11 @@ export type GitHubRateLimitMetricContext = { export function githubRateLimitAdmissionKeyScope( admissionKey: GitHubRateLimitAdmissionKey | null | undefined, ): GitHubRateLimitKeyScope { - if (!admissionKey) return "global"; - return admissionKey.startsWith("installation:") ? "installation" : "other"; + if (!admissionKey) return "unknown"; + if (admissionKey.startsWith("installation:")) return "installation"; + if (admissionKey === githubRateLimitAdmissionKeyForPublicToken()) return "public"; + if (admissionKey.startsWith("global:")) return "global"; + return "other"; } export function githubRateLimitMetricLabels( diff --git a/src/types.ts b/src/types.ts index 73e419d53d..030dc0c013 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,6 +50,7 @@ export type JobMessage = requestedBy: "schedule" | "api" | "test"; repoFullName: string; segment: "labels" | "open_issues" | "open_pull_requests" | "recent_merged_pull_requests"; + installationId?: number; mode?: "light" | "full" | "resume"; force?: boolean; cursor?: string; @@ -58,6 +59,7 @@ export type JobMessage = type: "backfill-pr-details"; requestedBy: "schedule" | "api" | "test"; repoFullName: string; + installationId?: number; mode?: "light" | "full" | "resume"; cursor?: number; } @@ -142,6 +144,7 @@ export type JobMessage = type: "agent-regate-sweep"; requestedBy: "schedule" | "api" | "test"; repoFullName?: string; + installationId?: number; } | { type: "run-agent"; @@ -187,6 +190,7 @@ export type JobMessage = type: "rag-index-repo"; requestedBy: "schedule" | "api" | "webhook" | "test"; repoFullName?: string; + installationId?: number; paths?: string[]; } | { diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 7ee663644c..9365fe18a4 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -4069,6 +4069,7 @@ describe("api routes", () => { GITTENSORY_REVIEW_RAG: "true", JOBS: { async send(message: unknown) { sent.push(message); } } as unknown as Queue, }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 456); const headers = { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }; // No body → re-index every configured repo (the operator's "index all my repos" button). const all = await app.request("/v1/internal/jobs/rag-index", { method: "POST", headers, body: "{}" }, env); @@ -4079,7 +4080,7 @@ describe("api routes", () => { const one = await app.request("/v1/internal/jobs/rag-index", { method: "POST", headers, body: JSON.stringify({ repoFullName: " JSONbored/gittensory " }) }, env); expect(one.status).toBe(202); await expect(one.json()).resolves.toMatchObject({ scope: "JSONbored/gittensory" }); - expect(sent.at(-1)).toEqual({ type: "rag-index-repo", requestedBy: "api", repoFullName: "JSONbored/gittensory" }); + expect(sent.at(-1)).toEqual({ type: "rag-index-repo", requestedBy: "api", repoFullName: "JSONbored/gittensory", installationId: 456 }); // RAG globally off → the endpoint does not exist. const offEnv = createTestEnv({ JOBS: { async send() {} } as unknown as Queue }); const offHeaders = { authorization: `Bearer ${offEnv.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }; @@ -4166,6 +4167,7 @@ describe("api routes", () => { expect((await app.request("/v1/internal/jobs/backfill-repo-segment", { method: "POST", headers: internalHeaders, body: "{}" }, env)).status).toBe(400); expect((await app.request("/v1/internal/jobs/backfill-repo-segment", { method: "POST", headers: internalHeaders, body: JSON.stringify({ repoFullName: "owner/repo", segment: "metadata" }) }, env)).status).toBe(400); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 654); const queuedSegment = await app.request( "/v1/internal/jobs/backfill-repo-segment", { method: "POST", headers: internalHeaders, body: JSON.stringify({ repoFullName: "owner/repo", segment: "labels", mode: "resume", force: true, cursor: "page-2" }) }, @@ -4176,7 +4178,7 @@ describe("api routes", () => { expect(sent).toEqual( expect.arrayContaining([ expect.objectContaining({ - message: expect.objectContaining({ type: "backfill-repo-segment", repoFullName: "owner/repo", segment: "labels", cursor: "page-2", force: true }), + message: expect.objectContaining({ type: "backfill-repo-segment", repoFullName: "owner/repo", installationId: 654, segment: "labels", cursor: "page-2", force: true }), }), ]), ); @@ -4191,7 +4193,7 @@ describe("api routes", () => { env, ); expect(queuedPrDetails.status).toBe(202); - expect(sent).toEqual(expect.arrayContaining([expect.objectContaining({ message: expect.objectContaining({ type: "backfill-pr-details", cursor: 5 }) })])); + expect(sent).toEqual(expect.arrayContaining([expect.objectContaining({ message: expect.objectContaining({ type: "backfill-pr-details", installationId: 654, cursor: 5 }) })])); expect((await app.request("/v1/internal/jobs/backfill-pr-details/run", { method: "POST", headers: internalHeaders, body: "{}" }, env)).status).toBe(400); expect((await app.request("/v1/internal/jobs/build-contributor-decision-packs/run", { method: "POST", headers: internalHeaders, body: "{}" }, env)).status).toBe(400); diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts index 422d42bb2c..d95d884258 100644 --- a/test/unit/backfill.test.ts +++ b/test/unit/backfill.test.ts @@ -44,6 +44,8 @@ import { } from "../../src/github/backfill"; import { clearGitHubResponseCacheForTest, + githubRateLimitAdmissionKeyForInstallation, + githubRateLimitAdmissionKeyForPublicToken, setGitHubResponseCache, type CachedGitHubResponse, } from "../../src/github/client"; @@ -2570,14 +2572,14 @@ describe("GitHub backfill", () => { return Response.json([]); }); - const labels = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "labels", requestedBy: "api", mode: "light" }); + const labels = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "labels", requestedBy: "test", mode: "light" }); const openPrs = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_pull_requests", requestedBy: "api", mode: "full" }); expect(labels).toMatchObject({ status: "running", fetchedCount: 200, expectedCount: 300 }); expect(openPrs).toMatchObject({ status: "complete", fetchedCount: 1, expectedCount: 1 }); expect(sent).toEqual( expect.arrayContaining([ - expect.objectContaining({ type: "backfill-repo-segment", segment: "labels", mode: "resume" }), + expect.objectContaining({ type: "backfill-repo-segment", requestedBy: "test", segment: "labels", mode: "resume" }), expect.objectContaining({ type: "backfill-pr-details", repoFullName: "JSONbored/gittensory", mode: "resume", cursor: 0 }), ]), ); @@ -2880,6 +2882,37 @@ describe("GitHub backfill", () => { expect(result.warnings.join("\n")).toMatch(/File sync failed|Review sync failed/); }); + it("does not attempt GraphQL PR detail fallbacks without any GitHub token", async () => { + const env = createTestEnv(); + await seedRegisteredRepo(env); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number: 32, + title: "Unavailable without token", + state: "open", + user: { login: "oktofeesh1" }, + head: { sha: "missing-token" }, + labels: [], + body: "", + }); + let graphqlCalls = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") { + graphqlCalls += 1; + return Response.json({ data: { repository: { pullRequest: null } } }); + } + if (url.includes("/pulls/32/files") || url.includes("/pulls/32/reviews")) return new Response("", { status: 404 }); + if (url.includes("/commits/missing-token/check-runs")) return Response.json({}); + return Response.json([]); + }); + + const result = await backfillOpenPullRequestDetails(env, { repoFullName: "JSONbored/gittensory", mode: "full", cursor: 0 }); + + expect(result).toMatchObject({ status: "partial", processed: 1 }); + expect(result.warnings.join("\n")).toMatch(/File sync failed|Review sync failed/); + expect(graphqlCalls).toBe(0); + }); + it("hydrates open PR details in small batches and records partial detail failures", async () => { const sent: import("../../src/types").JobMessage[] = []; const env = createTestEnv({ @@ -3380,7 +3413,74 @@ describe("GitHub backfill", () => { expect(await listRepoSyncSegments(env, "JSONbored/gittensory")).toEqual( expect.arrayContaining([expect.objectContaining({ segment: "labels", sourceKind: "installation" })]), ); - expect(sent).toEqual(expect.arrayContaining([expect.objectContaining({ type: "backfill-repo-segment", segment: "labels" })])); + expect(sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "backfill-repo-segment", + segment: "labels", + installationId: 123, + }), + ]), + ); + const observations = await listLatestGitHubRateLimitObservations(env, 20); + expect(observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resource: "graphql", + path: "/graphql", + admissionKey: githubRateLimitAdmissionKeyForInstallation(123), + }), + expect.objectContaining({ + resource: "rest", + path: "/labels?per_page=100&page=1", + admissionKey: githubRateLimitAdmissionKeyForInstallation(123), + }), + ]), + ); + }); + + it("persists public-token admission keys for public backfill REST and GraphQL reads", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") { + return Response.json({ + data: { + rateLimit: { remaining: 4999, resetAt: "2026-06-24T12:30:00.000Z" }, + repository: { + issues: { totalCount: 0 }, + openPullRequests: { totalCount: 0 }, + mergedPullRequests: { totalCount: 0 }, + closedPullRequests: { totalCount: 0 }, + labels: { totalCount: 0 }, + }, + }, + }); + } + if (url.includes("/labels?")) return Response.json([]); + return Response.json([]); + }); + + await expect(backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "labels", mode: "light" })).resolves.toMatchObject({ + status: "complete", + fetchedCount: 0, + }); + + expect(await listLatestGitHubRateLimitObservations(env, 20)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resource: "graphql", + path: "/graphql", + admissionKey: githubRateLimitAdmissionKeyForPublicToken(), + }), + expect.objectContaining({ + resource: "rest", + path: "/labels?per_page=100&page=1", + admissionKey: githubRateLimitAdmissionKeyForPublicToken(), + }), + ]), + ); }); it("records label rate limits, in-loop page caps, and expired rate observations", async () => { diff --git a/test/unit/github-client.test.ts b/test/unit/github-client.test.ts index a177eda9a0..7299e377b4 100644 --- a/test/unit/github-client.test.ts +++ b/test/unit/github-client.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { clearGitHubResponseCacheForTest, forcedSelfhostMode, + githubAdmissionKeyScope, githubRateLimitAdmissionKeyForInstallation, + githubRateLimitAdmissionKeyForPublicToken, + githubRateLimitAdmissionKeyForToken, GITHUB_RESPONSE_CACHE_REPLAY_HEADER, githubResponseCacheTtlSeconds, isCacheableGithubUrl, @@ -150,6 +153,28 @@ describe("resolveRepoActionMode", () => { }); }); +describe("githubRateLimitAdmissionKeyForToken — the single token→admission-key resolver (no duplication, no drift)", () => { + it("resolves the public and installation buckets, and stays undefined without a usable token+id", () => { + const env = { GITHUB_PUBLIC_TOKEN: "public-tok" }; + expect(githubRateLimitAdmissionKeyForToken(env, undefined, 5)).toBeUndefined(); // no token → unattributed + expect(githubRateLimitAdmissionKeyForToken(env, "public-tok", 5)).toBe(githubRateLimitAdmissionKeyForPublicToken()); + expect(githubRateLimitAdmissionKeyForToken(env, "install-tok", 5)).toBe(githubRateLimitAdmissionKeyForInstallation(5)); + expect(githubRateLimitAdmissionKeyForToken(env, "install-tok", undefined)).toBeUndefined(); // non-public token, no id + expect(githubRateLimitAdmissionKeyForToken(env, "install-tok", Number.NaN)).toBeUndefined(); // non-finite id + }); +}); + +describe("githubAdmissionKeyScope — classify an admission key the SAME way as queue-common's helper", () => { + it("labels installation / public / global / unknown / other keys consistently", () => { + expect(githubAdmissionKeyScope(githubRateLimitAdmissionKeyForInstallation(123))).toBe("installation"); + expect(githubAdmissionKeyScope(githubRateLimitAdmissionKeyForPublicToken())).toBe("public"); + expect(githubAdmissionKeyScope("global:shared")).toBe("global"); + expect(githubAdmissionKeyScope(null)).toBe("unknown"); + expect(githubAdmissionKeyScope(undefined)).toBe("unknown"); + expect(githubAdmissionKeyScope("pat:shared")).toBe("other"); + }); +}); + describe("timeoutFetch", () => { it("passes an explicit caller signal straight through", async () => { const seen: Array = []; @@ -270,6 +295,33 @@ describe("timeoutFetch", () => { expect(metrics).toContain('gittensory_github_rest_rate_limit_observations_total{key_scope="other",remaining_bucket="151+"} 1'); }); + it("labels public-token REST observations separately from installation and unknown buckets", async () => { + const reset = String(Math.floor(Date.parse("2026-06-24T12:10:00.000Z") / 1000)); + vi.stubGlobal( + "fetch", + async () => + new Response("ok", { + headers: { + "x-ratelimit-resource": "core", + "x-ratelimit-remaining": "22", + "x-ratelimit-reset": reset, + }, + }), + ); + + await timeoutFetch("https://api.github.com/repos/o/r/issues?bucket=public", { + githubRateLimitAdmission: true, + githubRateLimitAdmissionKey: githubRateLimitAdmissionKeyForPublicToken(), + }); + + const metrics = await renderMetrics(); + expect(metrics).toContain('gittensory_github_rest_rate_limit_observations_total{key_scope="public",remaining_bucket="1-75"} 1'); + expect(latestGitHubRestRateLimitObservation(githubRateLimitAdmissionKeyForPublicToken())).toMatchObject({ + remaining: 22, + resetAt: "2026-06-24T12:10:00.000Z", + }); + }); + it("records keyed REST admission telemetry from installation Octokit reads", async () => { const now = Date.parse("2026-06-24T12:00:00.000Z"); const key = githubRateLimitAdmissionKeyForInstallation(789); @@ -499,8 +551,8 @@ describe("timeoutFetch", () => { expect(getFetches).toBe(4); expect(set).not.toHaveBeenCalled(); const metrics = await renderMetrics(); - expect(metrics).toContain('gittensory_github_rest_rate_limit_responses_total{key_scope="global",retry="scheduled",status="403"} 3'); - expect(metrics).toContain('gittensory_github_rest_rate_limit_responses_total{key_scope="global",retry="exhausted",status="403"} 1'); + expect(metrics).toContain('gittensory_github_rest_rate_limit_responses_total{key_scope="unknown",retry="scheduled",status="403"} 3'); + expect(metrics).toContain('gittensory_github_rest_rate_limit_responses_total{key_scope="unknown",retry="exhausted",status="403"} 1'); }); it("does not negative-cache stable metadata denials outside branch protection", async () => { diff --git a/test/unit/linked-issue-hard-rules.test.ts b/test/unit/linked-issue-hard-rules.test.ts index d3d7fd1cfb..f5fd62f9d4 100644 --- a/test/unit/linked-issue-hard-rules.test.ts +++ b/test/unit/linked-issue-hard-rules.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createTestEnv } from "../helpers/d1"; +import * as backfillModule from "../../src/github/backfill"; import { DEFAULT_LINKED_ISSUE_HARD_RULES, evaluateLinkedIssueHardRules, @@ -302,4 +303,15 @@ describe("resolveLinkedIssueHardRule (#1144 — overflow + orchestration)", () = expect(r).toBeDefined(); expect(typeof r?.violated).toBe("boolean"); }); + + it("derives the installation admission key from the ci token + installation id so installation reads attribute to the installation bucket, not 'unknown' (#1951 blocker)", async () => { + const spy = vi.spyOn(backfillModule, "fetchLinkedIssueFacts").mockResolvedValue(undefined); + await resolveLinkedIssueHardRule( + args({ config: config({ ownerAssignedClose: "block" }), ciToken: "installation-token", installationId: 143010787, linkedIssues: [7] }), + ); + // The key is DERIVED from the token it will actually read with (so it can never drift): a non-public token + + // finite installation id ⇒ the installation bucket, NOT undefined (which the metrics record as "unknown"). + expect(spy).toHaveBeenCalledWith(expect.anything(), "owner/repo", 7, "installation-token", "installation:143010787"); + spy.mockRestore(); + }); }); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 90cadf5069..7b5e754fc9 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -597,7 +597,7 @@ describe("queue processors", () => { const sent: import("../../src/types").JobMessage[] = []; const env = createTestEnv({ GITTENSORY_REVIEW_REPOS: "owner/advisory-repo", JOBS: { async send(m: import("../../src/types").JobMessage) { sent.push(m); } } as unknown as Queue }); // advisory-repo is allowlisted but autonomy is observe (NOT acting) — it must STILL be swept so advisory reviews fire. - await upsertRepositoryFromGitHub(env, { name: "advisory-repo", full_name: "owner/advisory-repo", private: false, owner: { login: "owner" } }); + await upsertRepositoryFromGitHub(env, { name: "advisory-repo", full_name: "owner/advisory-repo", private: false, owner: { login: "owner" } }, 9102); await upsertRepositorySettings(env, { repoFullName: "owner/advisory-repo", autonomy: { merge: "observe", close: "observe" } }); // off-repo is neither allowlisted nor acting → still skipped. await upsertRepositoryFromGitHub(env, { name: "off-repo", full_name: "owner/off-repo", private: false, owner: { login: "owner" } }); @@ -607,6 +607,7 @@ describe("queue processors", () => { const swept = sent.filter((m): m is Extract => m.type === "agent-regate-sweep").map((m) => m.repoFullName); expect(swept).toEqual(["owner/advisory-repo"]); // allowlisted observe repo IS swept; off-repo is not + expect(sent).toEqual(expect.arrayContaining([expect.objectContaining({ type: "agent-regate-sweep", repoFullName: "owner/advisory-repo", installationId: 9102 })])); }); it("agent re-gate sweep recomputes stale open PR verdicts as an advisory audit, never publishing (#777)", async () => { @@ -5692,7 +5693,7 @@ describe("queue processors", () => { // comment. Mirrors the legacy panel-posting setup (confirmed miner + comment_and_label) but flips the flag // and enables the gate so `maybePublishPrPublicSurface` takes the flag-ON branch. it("renders the unified PR-review comment when the flag is on and the gate evaluates", async () => { - const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITHUB_PUBLIC_TOKEN: "public-token", GITTENSORY_REVIEW_UNIFIED_COMMENT: "1" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_UNIFIED_COMMENT: "1" }); await persistRegistrySnapshot( env, normalizeRegistryPayload( @@ -5867,7 +5868,7 @@ describe("queue processors", () => { // real diff/changed-file count on the first review, and (D3) the failing check name + its per-check WHY render // under a "CI checks failing" section (not just a bare "CI failing" chip). it("inline-fetches the PR files and renders failing CI check names + reasons in the unified comment (FIX B + D3)", async () => { - const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_UNIFIED_COMMENT: "1" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITHUB_PUBLIC_TOKEN: "public-token", GITTENSORY_REVIEW_UNIFIED_COMMENT: "1" }); await persistRegistrySnapshot( env, normalizeRegistryPayload( diff --git a/test/unit/rag-index.test.ts b/test/unit/rag-index.test.ts index 4e9c1dac94..1cf0ddd03f 100644 --- a/test/unit/rag-index.test.ts +++ b/test/unit/rag-index.test.ts @@ -525,9 +525,9 @@ describe("flag-off / missing-infra is a no-op (no GitHub fetch, no adapter use)" // ── Wiring: the rag-index-repo queue job (cron fan-out + per-repo dispatch) ───────────────────────── /** Register a repo (is_registered = 1) so it joins the cron fan-out's registered set. */ -async function registerRepo(env: Env, fullName: string): Promise { +async function registerRepo(env: Env, fullName: string, installationId: number | null = 123): Promise { const [owner, name] = fullName.split("/") as [string, string]; - await upsertRepositoryFromGitHub(env, { name, full_name: fullName, private: false, owner: { login: owner } }, 123); + await upsertRepositoryFromGitHub(env, { name, full_name: fullName, private: false, owner: { login: owner } }, installationId ?? undefined); await env.DB.prepare("UPDATE repositories SET is_registered = 1 WHERE full_name = ?").bind(fullName).run(); } @@ -539,21 +539,25 @@ describe("rag-index-repo job dispatch (processors.ts wiring)", () => { const env = createTestEnv({ GITTENSORY_REVIEW_RAG: "true", // Allowlist only JSONbored/gittensory (acme/widgets is allowlisted by default but won't be registered here). - GITTENSORY_REVIEW_REPOS: "JSONbored/gittensory", + GITTENSORY_REVIEW_REPOS: "JSONbored/gittensory,JSONbored/metagraphed", JOBS: { async send(message: import("../../src/types").JobMessage) { sent.push(message); } } as unknown as Queue, }); await registerRepo(env, "JSONbored/gittensory"); // registered + allowlisted → indexed + await registerRepo(env, "JSONbored/metagraphed", null); // registered + allowlisted but not installed → indexed without installation metadata await registerRepo(env, "owner/not-allowlisted"); // registered but NOT allowlisted → skipped await processJob(env, { type: "rag-index-repo", requestedBy: "schedule" }); - expect(sent).toEqual([{ type: "rag-index-repo", requestedBy: "schedule", repoFullName: "JSONbored/gittensory" }]); + expect(sent).toEqual([ + { type: "rag-index-repo", requestedBy: "schedule", repoFullName: "JSONbored/gittensory", installationId: 123 }, + { type: "rag-index-repo", requestedBy: "schedule", repoFullName: "JSONbored/metagraphed" }, + ]); const fanout = await env.DB.prepare("select outcome, metadata_json from audit_events where event_type = ?").bind("rag.index.fanout").first<{ outcome: string; metadata_json: string; }>(); expect(fanout?.outcome).toBe("queued"); - expect(JSON.parse(fanout?.metadata_json ?? "{}")).toMatchObject({ repoCount: 1, requestedBy: "schedule" }); + expect(JSON.parse(fanout?.metadata_json ?? "{}")).toMatchObject({ repoCount: 2, requestedBy: "schedule" }); }); it("cron fan-out ALSO indexes CONFIGURED (GITTENSORY_REVIEW_REPOS) repos never registered via webhook (brokered self-host fix)", async () => { diff --git a/test/unit/selfhost-pg-queue.test.ts b/test/unit/selfhost-pg-queue.test.ts index e4f68b76f5..5fb937645c 100644 --- a/test/unit/selfhost-pg-queue.test.ts +++ b/test/unit/selfhost-pg-queue.test.ts @@ -933,7 +933,7 @@ describe("createPgQueue (durable #977)", () => { expect.stringContaining("DELETE FROM _selfhost_jobs WHERE id=$1"), ["2"], ); - expect(await renderMetrics()).toContain('gittensory_jobs_rate_limited_by_type_total{job_type="refresh-registry",key_scope="global",kind="unknown"} 1'); + expect(await renderMetrics()).toContain('gittensory_jobs_rate_limited_by_type_total{job_type="refresh-registry",key_scope="unknown",kind="unknown"} 1'); }); it("defers matching GitHub-budget jobs and coalesces a keyed rate-limit retry into the pending duplicate", async () => { diff --git a/test/unit/selfhost-queue-common.test.ts b/test/unit/selfhost-queue-common.test.ts index 8b5a0b7754..de341ce01d 100644 --- a/test/unit/selfhost-queue-common.test.ts +++ b/test/unit/selfhost-queue-common.test.ts @@ -27,7 +27,7 @@ import { scheduledEnqueueDelaySeconds, scheduledEnqueueJitterMs, } from "../../src/selfhost/queue-common"; -import { clearGitHubResponseCacheForTest, githubRateLimitAdmissionKeyForInstallation, timeoutFetch } from "../../src/github/client"; +import { clearGitHubResponseCacheForTest, githubRateLimitAdmissionKeyForInstallation, githubRateLimitAdmissionKeyForPublicToken, timeoutFetch } from "../../src/github/client"; import { RetryableJobError } from "../../src/queue/retryable"; import type { JobMessage } from "../../src/types"; @@ -131,7 +131,9 @@ describe("self-host queue common helpers", () => { } as JobMessage; expect(githubRateLimitAdmissionKeyScope("installation:123")).toBe("installation"); - expect(githubRateLimitAdmissionKeyScope(null)).toBe("global"); + expect(githubRateLimitAdmissionKeyScope(githubRateLimitAdmissionKeyForPublicToken())).toBe("public"); + expect(githubRateLimitAdmissionKeyScope("global:shared")).toBe("global"); + expect(githubRateLimitAdmissionKeyScope(null)).toBe("unknown"); expect(githubRateLimitAdmissionKeyScope("pat:shared")).toBe("other"); expect(githubRateLimitMetricLabels(webhookJob, { kind: "webhook", @@ -143,7 +145,7 @@ describe("self-host queue common helpers", () => { }); expect(githubRateLimitMetricLabels({ type: "rag-index-repo", requestedBy: "schedule" } as JobMessage, null)).toEqual({ job_type: "rag-index-repo", - key_scope: "global", + key_scope: "unknown", kind: "unknown", }); expect(githubRateLimitMetricContext(webhookJob, { diff --git a/test/unit/selfhost-sqlite-queue.test.ts b/test/unit/selfhost-sqlite-queue.test.ts index 298918e6d5..13932752fd 100644 --- a/test/unit/selfhost-sqlite-queue.test.ts +++ b/test/unit/selfhost-sqlite-queue.test.ts @@ -190,7 +190,7 @@ describe("createSqliteQueue (durable #980)", () => { expect(q.stats()).toMatchObject({ gittensory_jobs_rate_limit_deferred_total: 2 }); const metrics = await renderMetrics(); expect(metrics).toContain('gittensory_jobs_rate_limit_admission_deferred_total{job_type="agent-regate-pr",key_scope="installation",kind="background"} 1'); - expect(metrics).toContain('gittensory_jobs_rate_limit_admission_deferred_total{job_type="rag-index-repo",key_scope="global",kind="background"} 1'); + expect(metrics).toContain('gittensory_jobs_rate_limit_admission_deferred_total{job_type="rag-index-repo",key_scope="unknown",kind="background"} 1'); } finally { if (oldJitter === undefined) delete process.env.QUEUE_RATE_LIMIT_JITTER_MS; else process.env.QUEUE_RATE_LIMIT_JITTER_MS = oldJitter; @@ -1210,7 +1210,7 @@ describe("createSqliteQueue (durable #980)", () => { expect(pending[0]!.last_error).toBe("API rate limit exceeded for installation ID 123"); expect(q.stats()).toMatchObject({ gittensory_jobs_rate_limited_total: 1 }); expect(q.stats()).not.toHaveProperty("gittensory_jobs_rate_limit_deferred_total"); - expect(await renderMetrics()).toContain('gittensory_jobs_rate_limited_by_type_total{job_type="refresh-registry",key_scope="global",kind="unknown"} 1'); + expect(await renderMetrics()).toContain('gittensory_jobs_rate_limited_by_type_total{job_type="refresh-registry",key_scope="unknown",kind="unknown"} 1'); }); it("defers only the depleted keyed GitHub budget while unrelated work keeps draining", async () => {