Skip to content

Commit cdcff93

Browse files
authored
Merge branch 'main' into feat/duplication-scan
2 parents 5304a68 + a675a73 commit cdcff93

38 files changed

Lines changed: 1936 additions & 137 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ALTER TABLE github_rate_limit_observations
2+
ADD COLUMN admission_key TEXT;
3+
4+
CREATE INDEX IF NOT EXISTS github_rate_limit_observations_admission_observed_idx
5+
ON github_rate_limit_observations (admission_key, observed_at);

src/auth/github-oauth.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
timingSafeEqual,
66
} from "./security";
77
import { recordAuditEvent } from "../db/repositories";
8+
import { timeoutFetch } from "../github/client";
89
import type { JsonValue } from "../types";
910

1011
type GitHubDeviceCodeResponse = {
@@ -178,7 +179,7 @@ export async function createSessionFromGitHubToken(
178179
});
179180
throw new Error("github_token_audience_invalid");
180181
}
181-
const response = await fetch("https://api.github.com/user", {
182+
const response = await timeoutFetch("https://api.github.com/user", {
182183
headers: {
183184
accept: "application/vnd.github+json",
184185
authorization: `Bearer ${githubToken}`,
@@ -206,7 +207,7 @@ export async function createSessionFromGitHubToken(
206207
// isn't configured, the token can't be vouched for, so it is rejected.
207208
async function verifyTokenBelongsToApp(env: Env, githubToken: string): Promise<boolean> {
208209
if (!env.GITHUB_OAUTH_CLIENT_ID || !env.GITHUB_OAUTH_CLIENT_SECRET) return false;
209-
const response = await fetch(`https://api.github.com/applications/${env.GITHUB_OAUTH_CLIENT_ID}/token`, {
210+
const response = await timeoutFetch(`https://api.github.com/applications/${env.GITHUB_OAUTH_CLIENT_ID}/token`, {
210211
method: "POST",
211212
headers: {
212213
accept: "application/vnd.github+json",

src/db/repositories.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,7 @@ export async function recordGitHubRateLimitObservation(env: Env, observation: Gi
992992
await db.insert(githubRateLimitObservations).values({
993993
id: observation.id ?? crypto.randomUUID(),
994994
repoFullName: observation.repoFullName,
995+
admissionKey: observation.admissionKey,
995996
resource: observation.resource,
996997
path: observation.path,
997998
statusCode: observation.statusCode,
@@ -4059,6 +4060,7 @@ function toGitHubRateLimitObservationRecord(row: typeof githubRateLimitObservati
40594060
return {
40604061
id: row.id,
40614062
repoFullName: row.repoFullName,
4063+
admissionKey: row.admissionKey,
40624064
resource: row.resource === "graphql" ? "graphql" : "rest",
40634065
path: row.path,
40644066
statusCode: row.statusCode,

src/db/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ export const githubRateLimitObservations = sqliteTable(
168168
{
169169
id: text("id").primaryKey(),
170170
repoFullName: text("repo_full_name"),
171+
admissionKey: text("admission_key"),
171172
resource: text("resource").notNull().default("rest"),
172173
path: text("path").notNull(),
173174
statusCode: integer("status_code").notNull(),
@@ -177,6 +178,7 @@ export const githubRateLimitObservations = sqliteTable(
177178
observedAt: text("observed_at").notNull().$defaultFn(() => nowIso()),
178179
},
179180
(table) => ({
181+
admissionObserved: index("github_rate_limit_observations_admission_observed_idx").on(table.admissionKey, table.observedAt),
180182
repoObserved: index("github_rate_limit_observations_repo_observed_idx").on(table.repoFullName, table.observedAt),
181183
reset: index("github_rate_limit_observations_reset_idx").on(table.resetAt),
182184
}),

src/github/app.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "../orb/broker-client";
66
import {
77
clearGitHubResponseCacheForTest,
8+
githubRateLimitAdmissionKeyForInstallation,
89
makeInstallationOctokit,
910
timeoutFetch,
1011
} from "./client";
@@ -337,7 +338,11 @@ export async function getRepositoryCollaboratorPermission(
337338
const token = await createInstallationToken(env, installationId);
338339
const response = await timeoutFetch(
339340
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/collaborators/${encodeURIComponent(login)}/permission`,
340-
{ headers: githubHeaders(`Bearer ${token}`) },
341+
{
342+
headers: githubHeaders(`Bearer ${token}`),
343+
githubRateLimitAdmission: true,
344+
githubRateLimitAdmissionKey: githubRateLimitAdmissionKeyForInstallation(installationId),
345+
},
341346
);
342347
if (response.status === 404) return null;
343348
if (!response.ok) {
@@ -579,7 +584,7 @@ async function createOrUpdateNamedCheckRun(
579584
return await withInstallationTokenRetry(env, installationId, async (token) => {
580585
// makeInstallationOctokit injects the shared per-request timeout (a stalled PATCH can never orphan the
581586
// in_progress check) AND suppresses the check-run writes under a non-live mode (dry-run / pause / freeze).
582-
const octokit = makeInstallationOctokit(env, token, check.mode);
587+
const octokit = makeInstallationOctokit(env, token, check.mode, githubRateLimitAdmissionKeyForInstallation(installationId));
583588
// Point the merge-box "Details" link at the repo's Gittensory maintainer panel instead of GitHub's generic
584589
// check page. Spread conditionally so a URL-construction failure (null) just omits it. (#audit-details-url)
585590
const detailsUrl = maintainerControlPanelUrl(env, repoFullName);

src/github/backfill.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2779,7 +2779,7 @@ function githubRestHeaders(token?: string): HeadersInit {
27792779
}
27802780

27812781
async function githubGraphQl<T>(env: Env, query: string, token: string): Promise<T> {
2782-
const response = await fetch("https://api.github.com/graphql", {
2782+
const response = await timeoutFetch("https://api.github.com/graphql", {
27832783
method: "POST",
27842784
headers: {
27852785
accept: "application/vnd.github+json",

src/github/client.ts

Lines changed: 180 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,23 @@ export function setGitHubResponseCache(cache: GitHubResponseCache | null): void
4545

4646
export type GitHubCacheClass = "branch_protection" | "metadata";
4747
type EnvLookup = Record<string, string | undefined>;
48+
export type GitHubTimeoutFetchInit = RequestInit & {
49+
/** Opt in to using this response's REST bucket headers for self-host queue admission control. */
50+
githubRateLimitAdmission?: boolean;
51+
/** Stable actor key for admission control. Installation-token reads should use the installation id. */
52+
githubRateLimitAdmissionKey?: string;
53+
};
54+
export type GitHubRateLimitAdmissionKey = string;
55+
export type LocalGitHubRestRateLimitObservation = {
56+
remaining: number;
57+
resetAt: string;
58+
observedAtMs: number;
59+
};
60+
const latestRestRateLimitObservations = new Map<GitHubRateLimitAdmissionKey, LocalGitHubRestRateLimitObservation>();
61+
62+
export function githubRateLimitAdmissionKeyForInstallation(installationId: number): GitHubRateLimitAdmissionKey {
63+
return `installation:${Math.trunc(installationId)}`;
64+
}
4865

4966
/** Only cache explicitly stable GitHub REST reads. PR/issue/comment/label/event/check/status reads are mutable
5067
* review inputs and must always reflect the current GitHub state. Exported for tests. */
@@ -86,6 +103,13 @@ export function githubResponseCacheTtlSeconds(cls: GitHubCacheClass, env: EnvLoo
86103
return positiveEnvSeconds(env, "GITHUB_METADATA_CACHE_TTL_SECONDS", DEFAULT_METADATA_TTL_SECONDS);
87104
}
88105

106+
function isCacheableGithubResponseStatus(cls: GitHubCacheClass, status: number): boolean {
107+
if (status === 200) return true;
108+
// Branch-protection permissions are repo/base-branch metadata. Cache stable negative answers too,
109+
// otherwise a missing permission can burn the REST bucket on every PR pass.
110+
return cls === "branch_protection" && (status === 403 || status === 404);
111+
}
112+
89113
function hasConditionalRequestHeader(headers: Headers): boolean {
90114
return headers.has("if-none-match") || headers.has("if-modified-since") || headers.has("if-match") || headers.has("if-unmodified-since");
91115
}
@@ -102,6 +126,30 @@ function recordGitHubCacheMetric(result: "hit" | "miss" | "set" | "coalesced" |
102126
incr(GITHUB_RESPONSE_CACHE_METRIC, { result, class: cls });
103127
}
104128

129+
function parseRateLimitInt(value: string | null): number | null {
130+
if (value === null) return null;
131+
const parsed = Number(value);
132+
return Number.isFinite(parsed) ? parsed : null;
133+
}
134+
135+
function observeGitHubRestRateLimit(url: string, response: Response, admissionKey: GitHubRateLimitAdmissionKey): void {
136+
if (!url.startsWith(`${GITHUB_API_PREFIX}/`)) return;
137+
const resource = response.headers.get("x-ratelimit-resource");
138+
if (resource !== null && resource !== "core") return;
139+
const remaining = parseRateLimitInt(response.headers.get("x-ratelimit-remaining"));
140+
const reset = parseRateLimitInt(response.headers.get("x-ratelimit-reset"));
141+
if (remaining === null || reset === null) return;
142+
latestRestRateLimitObservations.set(admissionKey, {
143+
remaining,
144+
resetAt: new Date(reset * 1000).toISOString(),
145+
observedAtMs: Date.now(),
146+
});
147+
}
148+
149+
export function latestGitHubRestRateLimitObservation(admissionKey: GitHubRateLimitAdmissionKey): LocalGitHubRestRateLimitObservation | null {
150+
return latestRestRateLimitObservations.get(admissionKey) ?? null;
151+
}
152+
105153
async function sha256Short(value: string): Promise<string> {
106154
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
107155
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 16);
@@ -114,6 +162,25 @@ async function responseCacheKey(url: string, headers: Headers): Promise<string>
114162
return `v2:${authHash}:${accept}:${apiVersion}:${url}`;
115163
}
116164

165+
type VolatileSingleFlightScope = { requestKey: string; authorization: string };
166+
167+
function volatileSingleFlightScope(url: string, headers: Headers): VolatileSingleFlightScope {
168+
const accept = encodeURIComponent(headers.get("accept") || "");
169+
const apiVersion = encodeURIComponent(headers.get("x-github-api-version") || "");
170+
return { requestKey: `volatile:${accept}:${apiVersion}:${url}`, authorization: headers.get("authorization") || "" };
171+
}
172+
173+
function isVolatileSingleFlightEligibleGithubUrl(url: string, headers: Headers): boolean {
174+
if (!url.startsWith(`${GITHUB_API_PREFIX}/`)) return false;
175+
const accept = (headers.get("accept") ?? "").toLowerCase();
176+
if (accept.includes("raw") || accept.includes("text/plain")) return false;
177+
const path = githubApiPath(url);
178+
return (
179+
!/^\/repos\/[^/]+\/[^/]+\/contents(?:\/|$|[?#])/.test(path) &&
180+
!/^\/repos\/[^/]+\/[^/]+\/git\/(?:trees|blobs)\//.test(path)
181+
);
182+
}
183+
117184
function requestHeaders(input: RequestInfo | URL, init: RequestInit | undefined): Headers {
118185
const headers = new Headers(typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined);
119186
new Headers(init?.headers).forEach((value, key) => headers.set(key, value));
@@ -128,6 +195,22 @@ function requestUrl(input: RequestInfo | URL): string {
128195
return typeof Request !== "undefined" && input instanceof Request ? input.url : String(input);
129196
}
130197

198+
function requestSignal(input: RequestInfo | URL, init: GitHubTimeoutFetchInit | undefined): AbortSignal | undefined {
199+
return init?.signal ?? (typeof Request !== "undefined" && input instanceof Request ? input.signal : undefined);
200+
}
201+
202+
function rateLimitAdmissionKey(init: GitHubTimeoutFetchInit | undefined): GitHubRateLimitAdmissionKey | null {
203+
if (init?.githubRateLimitAdmission !== true) return null;
204+
const key = init.githubRateLimitAdmissionKey?.trim();
205+
return key ? key : null;
206+
}
207+
208+
function requestInitForFetch(init: GitHubTimeoutFetchInit | undefined): RequestInit | undefined {
209+
if (!init || (!("githubRateLimitAdmission" in init) && !("githubRateLimitAdmissionKey" in init))) return init;
210+
const { githubRateLimitAdmission: _omitted, githubRateLimitAdmissionKey: _omittedKey, ...rest } = init;
211+
return rest;
212+
}
213+
131214
export function isGitHubResponseCacheReplay(response: Response): boolean {
132215
return response.headers.get(GITHUB_RESPONSE_CACHE_REPLAY_HEADER) !== null;
133216
}
@@ -179,15 +262,29 @@ function responseFromCached(hit: CachedGitHubResponse, replayKind: "hit" | "coal
179262
});
180263
}
181264

182-
async function fetchWithGitHubRetry(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
265+
async function replayableResponse(response: Response): Promise<CachedGitHubResponse> {
266+
return {
267+
status: response.status,
268+
body: await response.clone().text(),
269+
contentType: response.headers.get("content-type") ?? "application/json",
270+
...(response.headers.get("link") ? { link: response.headers.get("link")! } : {}),
271+
...(response.headers.get("etag") ? { etag: response.headers.get("etag")! } : {}),
272+
...(response.headers.get("last-modified") ? { lastModified: response.headers.get("last-modified")! } : {}),
273+
};
274+
}
275+
276+
async function fetchWithGitHubRetry(input: RequestInfo | URL, init?: GitHubTimeoutFetchInit): Promise<Response> {
183277
let response: Response;
278+
const fetchInit = requestInitForFetch(init);
279+
const admissionKey = rateLimitAdmissionKey(init);
184280
for (let attempt = 0; ; attempt += 1) {
185-
response = init?.signal
186-
? await fetch(input, init)
281+
response = fetchInit?.signal
282+
? await fetch(input, fetchInit)
187283
: await fetch(input, {
188-
...(init ?? {}),
284+
...(fetchInit ?? {}),
189285
signal: AbortSignal.timeout(GITHUB_FETCH_TIMEOUT_MS),
190286
});
287+
if (admissionKey) observeGitHubRestRateLimit(requestUrl(input), response, admissionKey);
191288
// Retry a transient rate-limit (with backoff) instead of surfacing it; stop once exhausted or it's not a limit.
192289
if (attempt >= GITHUB_RATE_LIMIT_MAX_RETRIES || !(await isRateLimitedResponse(response))) break;
193290
await sleep(rateLimitRetryMs(response, attempt));
@@ -197,22 +294,16 @@ async function fetchWithGitHubRetry(input: RequestInfo | URL, init?: RequestInit
197294

198295
async function fetchAndMaybeCacheGitHubGet(
199296
input: RequestInfo | URL,
200-
init: RequestInit | undefined,
297+
init: GitHubTimeoutFetchInit | undefined,
201298
url: string,
202299
cacheKey: string,
203300
cls: GitHubCacheClass,
204301
): Promise<{ response: Response; cached: CachedGitHubResponse | null }> {
205302
const response = await fetchWithGitHubRetry(input, init);
206-
if (response.status !== 200) return { response, cached: null };
303+
if (!isCacheableGithubResponseStatus(cls, response.status)) return { response, cached: null };
304+
if (await isRateLimitedResponse(response)) return { response, cached: null };
207305
try {
208-
const cached = {
209-
status: 200,
210-
body: await response.clone().text(),
211-
contentType: response.headers.get("content-type") ?? "application/json",
212-
...(response.headers.get("link") ? { link: response.headers.get("link")! } : {}),
213-
...(response.headers.get("etag") ? { etag: response.headers.get("etag")! } : {}),
214-
...(response.headers.get("last-modified") ? { lastModified: response.headers.get("last-modified")! } : {}),
215-
};
306+
const cached = await replayableResponse(response);
216307
await responseCache!.set(cacheKey, cached, githubResponseCacheTtlSeconds(cls));
217308
recordGitHubCacheMetric("set", cls);
218309
return { response, cached };
@@ -225,15 +316,76 @@ async function fetchAndMaybeCacheGitHubGet(
225316
// Single-flight cacheable GETs inside one isolate: a webhook burst often asks for the same metadata
226317
// before Redis has been populated. Join those cold misses so GitHub sees one request, then replay the cached body.
227318
const inFlightCacheableGets = new Map<string, Promise<CachedGitHubResponse | null>>();
319+
// Mutable GitHub GETs are not persisted in Redis, but simultaneous identical reads in one burst can still share the
320+
// leader's response. This dedupes review fan-out without replaying stale CI, PR, label, comment, or event data later.
321+
const inFlightVolatileGets = new Map<string, Map<string, Promise<CachedGitHubResponse | null>>>();
322+
323+
async function fetchWithVolatileSingleFlight(
324+
input: RequestInfo | URL,
325+
init: GitHubTimeoutFetchInit | undefined,
326+
scope: VolatileSingleFlightScope,
327+
): Promise<Response> {
328+
const existing = inFlightVolatileGets.get(scope.requestKey)?.get(scope.authorization);
329+
if (existing) {
330+
recordGitHubCacheMetric("coalesced", "sensitive");
331+
const replay = await waitForVolatileReplay(existing, requestSignal(input, init));
332+
if (replay) return responseFromCached(replay, "coalesced");
333+
}
334+
let resolveShared!: (value: CachedGitHubResponse | null) => void;
335+
const shared = new Promise<CachedGitHubResponse | null>((resolve) => {
336+
resolveShared = resolve;
337+
});
338+
let bucket = inFlightVolatileGets.get(scope.requestKey);
339+
if (!bucket) {
340+
bucket = new Map();
341+
inFlightVolatileGets.set(scope.requestKey, bucket);
342+
}
343+
const sharedWithCleanup = shared.finally(() => {
344+
const current = inFlightVolatileGets.get(scope.requestKey);
345+
current?.delete(scope.authorization);
346+
if (current?.size === 0) inFlightVolatileGets.delete(scope.requestKey);
347+
});
348+
bucket.set(scope.authorization, sharedWithCleanup);
349+
recordGitHubCacheMetric("bypassed", "sensitive");
350+
try {
351+
const response = await fetchWithGitHubRetry(input, init);
352+
try {
353+
resolveShared(await replayableResponse(response));
354+
} catch {
355+
resolveShared(null);
356+
}
357+
return response;
358+
} catch (error) {
359+
resolveShared(null);
360+
throw error;
361+
}
362+
}
363+
364+
function abortSignalError(signal: AbortSignal): Error {
365+
return signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted.");
366+
}
367+
368+
function waitForVolatileReplay(shared: Promise<CachedGitHubResponse | null>, signal: AbortSignal | undefined): Promise<CachedGitHubResponse | null> {
369+
if (!signal) return shared;
370+
if (signal.aborted) return Promise.reject(abortSignalError(signal));
371+
return new Promise((resolve, reject) => {
372+
const onAbort = () => reject(abortSignalError(signal));
373+
signal.addEventListener("abort", onAbort, { once: true });
374+
shared.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
375+
});
376+
}
228377

229378
// A 12s hard cap on every GitHub request. Centralised here so the app token/installation raw fetches plus comment /
230379
// label / check-run / pr-action Octokit helpers all inherit the cache boundary, retry, and timeout behavior.
231-
export async function timeoutFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
380+
export async function timeoutFetch(input: RequestInfo | URL, init?: GitHubTimeoutFetchInit): Promise<Response> {
232381
const method = requestMethod(input, init);
233382
const url = requestUrl(input);
234383
const headers = requestHeaders(input, init);
235384
const conditional = hasConditionalRequestHeader(headers);
236385
const cls = method === "GET" && !conditional ? githubCacheClassForUrl(url) : null;
386+
if (method === "GET" && !conditional && cls === null && isVolatileSingleFlightEligibleGithubUrl(url, headers)) {
387+
return fetchWithVolatileSingleFlight(input, init, volatileSingleFlightScope(url, headers));
388+
}
237389
const useCache = responseCache !== null && cls !== null;
238390
if (!useCache) {
239391
recordGitHubCacheMetric("bypassed", cacheBypassClass(method, url, headers));
@@ -276,6 +428,8 @@ export async function timeoutFetch(input: RequestInfo | URL, init?: RequestInit)
276428
export function clearGitHubResponseCacheForTest(): void {
277429
responseCache = null;
278430
inFlightCacheableGets.clear();
431+
inFlightVolatileGets.clear();
432+
latestRestRateLimitObservations.clear();
279433
}
280434

281435
const WRITE_METHODS = new Set(["POST", "PATCH", "PUT", "DELETE"]);
@@ -330,8 +484,17 @@ export function forcedSelfhostMode(env: { SELFHOST_DEPLOYMENT_MODE?: string | un
330484
* the executor are not double-denied; surface callers (check-run / comment / label) pass the resolved repo mode.
331485
* A SELFHOST_DEPLOYMENT_MODE override beats the per-call mode so the whole instance can be forced non-actuating.
332486
*/
333-
export function makeInstallationOctokit(env: Env, token: string, mode: AgentActionMode = "live"): Octokit {
334-
const octokit = new Octokit({ auth: token, request: { fetch: timeoutFetch } });
487+
export function makeInstallationOctokit(env: Env, token: string, mode: AgentActionMode = "live", admissionKey?: GitHubRateLimitAdmissionKey | undefined): Octokit {
488+
const octokit = new Octokit({
489+
auth: token,
490+
request: {
491+
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
492+
const fetchInit: GitHubTimeoutFetchInit = Object.assign({ githubRateLimitAdmission: admissionKey !== undefined }, init);
493+
if (admissionKey) fetchInit.githubRateLimitAdmissionKey = admissionKey;
494+
return timeoutFetch(input, fetchInit);
495+
},
496+
},
497+
});
335498
const effectiveMode = forcedSelfhostMode(env) ?? mode;
336499
if (effectiveMode !== "live") {
337500
octokit.hook.wrap("request", async (request, options) => {

0 commit comments

Comments
 (0)