From 9fcdbff087e18b9bbf0e67aa9911bee0b1a645e9 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:48:23 -0700 Subject: [PATCH] feat(selfhost): Redis-backed installation-token store + GitHub GET response cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciles the batch's Redis caches onto main. Two injectable seams in github/app.ts (null on the Worker → unchanged): an InstallationTokenStore so a multi-replica self-host mints ~1 token/hour/ installation across the FLEET (not per-replica) and warm tokens survive restarts, and a short-TTL GitHubResponseCache that dedups the ~24 safe GETs per review (never the token-mint/rate-limit endpoints). Both wired in server.ts only when REDIS_URL is set (GITHUB_CACHE_TTL_SECONDS, default 20s, 0 disables). Preserves the stale-token-grace path + its tests; adds fleet/cache coverage. --- src/github/app.ts | 506 +++++--- src/selfhost/redis-response-cache.ts | 44 + src/selfhost/redis-token-cache.ts | 48 + src/server.ts | 23 +- test/unit/github-app.test.ts | 1045 +++++++++++++---- .../selfhost-redis-response-cache.test.ts | 71 ++ test/unit/selfhost-redis-token-cache.test.ts | 65 + 7 files changed, 1412 insertions(+), 390 deletions(-) create mode 100644 src/selfhost/redis-response-cache.ts create mode 100644 src/selfhost/redis-token-cache.ts create mode 100644 test/unit/selfhost-redis-response-cache.test.ts create mode 100644 test/unit/selfhost-redis-token-cache.test.ts diff --git a/src/github/app.ts b/src/github/app.ts index edba19ba55..881a8c9f90 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -1,11 +1,23 @@ import type { Advisory, GitHubWebhookPayload } from "../types"; -import { fetchBrokeredInstallationToken, isOrbBrokerMode } from "../orb/broker-client"; +import { + fetchBrokeredInstallationToken, + isOrbBrokerMode, +} from "../orb/broker-client"; import { makeInstallationOctokit } from "./client"; import { maintainerControlPanelUrl } from "./footer"; import type { AgentActionMode } from "../settings/agent-execution"; import { signRs256Jwt } from "../utils/crypto"; import { errorMessage } from "../utils/json"; -import { evaluateGateCheck, formatCheckRunOutput, formatGateCheckOutput, type CheckRunAnnotationContext, type CheckRunOutput, type GateCheckConclusion, type GateCheckEvaluation, type GateCheckPolicy } from "../rules/advisory"; +import { + evaluateGateCheck, + formatCheckRunOutput, + formatGateCheckOutput, + type CheckRunAnnotationContext, + type CheckRunOutput, + type GateCheckConclusion, + type GateCheckEvaluation, + type GateCheckPolicy, +} from "../rules/advisory"; type CheckRunResponse = { id: number; @@ -27,7 +39,10 @@ export type CheckRunOutcome = export const GITTENSORY_CONTEXT_CHECK_NAME = "Gittensory Context"; export const GITTENSORY_GATE_CHECK_NAME = "Gittensory Gate"; -type GitHubCheckConclusion = Advisory["conclusion"] | GateCheckConclusion | "skipped"; +type GitHubCheckConclusion = + | Advisory["conclusion"] + | GateCheckConclusion + | "skipped"; type GitHubCheckStatus = "queued" | "in_progress" | "completed"; /** Hard cap on a single GitHub API request. Without it a slow/half-open GitHub connection can hang the @@ -36,9 +51,66 @@ type GitHubCheckStatus = "queued" | "in_progress" | "completed"; * finalize. Applied to every raw fetch here and to the Octokit instances (via a timeout-injecting fetch). */ const GITHUB_FETCH_TIMEOUT_MS = 12_000; -function timeoutFetch(input: RequestInfo | URL, init?: RequestInit): Promise { - if (init?.signal) return fetch(input, init); - return fetch(input, { ...(init ?? {}), signal: AbortSignal.timeout(GITHUB_FETCH_TIMEOUT_MS) }); +/** A short-TTL cache for safe GitHub GET responses (e.g. Redis on the self-host). Stores only status/body/ + * content-type — never rate-limit or encoding headers. Set on the self-host; the Worker leaves it null. */ +export interface CachedGitHubResponse { + status: number; + body: string; + contentType: string; +} +export interface GitHubResponseCache { + get(url: string): Promise; + set(url: string, value: CachedGitHubResponse): Promise; +} +let responseCache: GitHubResponseCache | null = null; +export function setGitHubResponseCache( + cache: GitHubResponseCache | null, +): void { + responseCache = cache; +} + +/** Only cache GETs to the GitHub REST API, and never the token-minting or rate-limit endpoints (volatile / + * per-call). Everything else (PR/file/user/org reads) is safe to dedup for a short window. Exported for tests. */ +export function isCacheableGithubUrl(url: string): boolean { + if (!url.startsWith("https://api.github.com/")) return false; + return !url.includes("/access_tokens") && !url.includes("/rate_limit"); +} + +async function timeoutFetch( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + const method = (init?.method ?? "GET").toUpperCase(); + const url = String(input); // timeoutFetch is only ever called with string URLs (app template strings + octokit) + const useCache = + responseCache !== null && method === "GET" && isCacheableGithubUrl(url); + if (useCache) { + const hit = await responseCache!.get(url).catch(() => null); // a cache read must never break the fetch + if (hit) + return new Response(hit.body, { + status: hit.status, + headers: { "content-type": hit.contentType }, + }); + } + const response = init?.signal + ? await fetch(input, init) + : await fetch(input, { + ...(init ?? {}), + signal: AbortSignal.timeout(GITHUB_FETCH_TIMEOUT_MS), + }); + if (useCache && response.status === 200) { + try { + const body = await response.clone().text(); // clone leaves the returned response readable + await responseCache!.set(url, { + status: 200, + body, + contentType: response.headers.get("content-type") ?? "application/json", + }); + } catch { + /* caching is best-effort */ + } + } + return response; } // In-isolate installation-token cache. GitHub installation tokens are valid ~1h; minting a fresh one on EVERY @@ -47,12 +119,52 @@ function timeoutFetch(input: RequestInfo | URL, init?: RequestInit): Promise(); +const installationTokenCache = new Map< + number, + { token: string; expiresAtMs: number } +>(); const TOKEN_SAFETY_MARGIN_MS = 120_000; -export async function createInstallationToken(env: Env, installationId: number): Promise { - const cached = installationTokenCache.get(installationId); - if (cached && cached.expiresAtMs - TOKEN_SAFETY_MARGIN_MS > Date.now()) return cached.token; +/** A shared installation-token store (e.g. Redis on the self-host) so a multi-replica deployment mints ~1 + * token/hour/installation across the FLEET, not per-replica. Set on the self-host; the Worker leaves it null + * and falls back to the in-isolate Map (unchanged behavior). */ +export interface InstallationTokenStore { + get( + installationId: number, + ): Promise<{ token: string; expiresAtMs: number } | null>; + set( + installationId: number, + value: { token: string; expiresAtMs: number }, + ): Promise; +} +let externalTokenStore: InstallationTokenStore | null = null; +export function setInstallationTokenStore( + store: InstallationTokenStore | null, +): void { + externalTokenStore = store; +} +async function readCachedToken( + installationId: number, +): Promise<{ token: string; expiresAtMs: number } | null> { + return externalTokenStore + ? externalTokenStore.get(installationId) + : (installationTokenCache.get(installationId) ?? null); +} +async function writeCachedToken( + installationId: number, + value: { token: string; expiresAtMs: number }, +): Promise { + if (externalTokenStore) await externalTokenStore.set(installationId, value); + else installationTokenCache.set(installationId, value); +} + +export async function createInstallationToken( + env: Env, + installationId: number, +): Promise { + const cached = await readCachedToken(installationId); + if (cached && cached.expiresAtMs - TOKEN_SAFETY_MARGIN_MS > Date.now()) + return cached.token; // Self-host broker mode: a brokered self-host holds no App private key, so source the installation token from // the central Orb (enrollment secret → short-lived token) instead of minting locally. Cloud sets no enrollment // secret, so this branch is inert there → byte-identical. The token caches the same way (the install id is the @@ -60,7 +172,10 @@ export async function createInstallationToken(env: Env, installationId: number): if (isOrbBrokerMode(env)) { try { const brokered = await fetchBrokeredInstallationToken(env); - installationTokenCache.set(installationId, { token: brokered.token, expiresAtMs: brokered.expiresAtMs }); + await writeCachedToken(installationId, { + token: brokered.token, + expiresAtMs: brokered.expiresAtMs, + }); return brokered.token; } catch (error) { // Stale-token grace (#2): a brokered self-host holds no App key, so without this a single Orb mint failure @@ -69,26 +184,54 @@ export async function createInstallationToken(env: Env, installationId: number): // an actually-expired token is never served). Otherwise emit an alertable structured log and rethrow so the // queue's retry/DLQ handles a genuine outage. if (cached && cached.expiresAtMs > Date.now()) { - console.warn(JSON.stringify({ level: "warn", event: "orb_broker_degraded_serving_cached_token", installationId, expiresInMs: cached.expiresAtMs - Date.now(), error: errorMessage(error) })); + console.warn( + JSON.stringify({ + level: "warn", + event: "orb_broker_degraded_serving_cached_token", + installationId, + expiresInMs: cached.expiresAtMs - Date.now(), + error: errorMessage(error), + }), + ); return cached.token; } - console.error(JSON.stringify({ level: "error", event: "orb_broker_unavailable", installationId, error: errorMessage(error) })); + console.error( + JSON.stringify({ + level: "error", + event: "orb_broker_unavailable", + installationId, + error: errorMessage(error), + }), + ); throw error; } } const jwt = await createAppJwt(env); - const response = await timeoutFetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, { - method: "POST", - headers: githubHeaders(`Bearer ${jwt}`), - }); + const response = await timeoutFetch( + `https://api.github.com/app/installations/${installationId}/access_tokens`, + { + method: "POST", + headers: githubHeaders(`Bearer ${jwt}`), + }, + ); if (!response.ok) { const body = await response.text(); - throw new Error(`Failed to create GitHub installation token (${response.status}): ${body.slice(0, 200)}`); + throw new Error( + `Failed to create GitHub installation token (${response.status}): ${body.slice(0, 200)}`, + ); } - const payload = (await response.json()) as { token?: string; expires_at?: string }; - if (!payload.token) throw new Error("GitHub installation token response did not include a token."); - const expiresAtMs = payload.expires_at ? Date.parse(payload.expires_at) : Date.now() + 50 * 60_000; - installationTokenCache.set(installationId, { token: payload.token, expiresAtMs }); + const payload = (await response.json()) as { + token?: string; + expires_at?: string; + }; + if (!payload.token) + throw new Error( + "GitHub installation token response did not include a token.", + ); + const expiresAtMs = payload.expires_at + ? Date.parse(payload.expires_at) + : Date.now() + 50 * 60_000; + await writeCachedToken(installationId, { token: payload.token, expiresAtMs }); return payload.token; } @@ -101,8 +244,16 @@ export async function createInstallationToken(env: Env, installationId: number): * drop a legitimate delivery whose app_id is null/unknown. Signature verification (per-App webhook secret) is the * PRIMARY isolation; this is defense-in-depth for a shared-endpoint/secret misconfiguration. PURE. */ -export function isForeignAppInstallation(ownAppId: string | undefined, installationAppId: number | null | undefined): boolean { - if (!ownAppId || installationAppId === null || installationAppId === undefined) return false; +export function isForeignAppInstallation( + ownAppId: string | undefined, + installationAppId: number | null | undefined, +): boolean { + if ( + !ownAppId || + installationAppId === null || + installationAppId === undefined + ) + return false; const own = Number.parseInt(ownAppId, 10); if (!Number.isFinite(own)) return false; return own !== installationAppId; @@ -112,23 +263,43 @@ export function isForeignAppInstallation(ownAppId: string | undefined, installat * otherwise leaks a cached token across test cases that share an installation id). */ export function clearInstallationTokenCacheForTest(): void { installationTokenCache.clear(); + externalTokenStore = null; + responseCache = null; } -export async function getAppInstallation(env: Env, installationId: number): Promise> { +export async function getAppInstallation( + env: Env, + installationId: number, +): Promise> { const jwt = await createAppJwt(env); - const response = await timeoutFetch(`https://api.github.com/app/installations/${installationId}`, { - headers: githubHeaders(`Bearer ${jwt}`), - }); + const response = await timeoutFetch( + `https://api.github.com/app/installations/${installationId}`, + { + headers: githubHeaders(`Bearer ${jwt}`), + }, + ); if (!response.ok) { const body = await response.text(); - throw new Error(`Failed to fetch GitHub App installation (${response.status}): ${body.slice(0, 200)}`); + throw new Error( + `Failed to fetch GitHub App installation (${response.status}): ${body.slice(0, 200)}`, + ); } - const payload = (await response.json()) as NonNullable; - if (!payload.id) throw new Error("GitHub installation response did not include an id."); + const payload = (await response.json()) as NonNullable< + GitHubWebhookPayload["installation"] + >; + if (!payload.id) + throw new Error("GitHub installation response did not include an id."); return payload; } -export type GitHubRepositoryCollaboratorPermission = "admin" | "maintain" | "write" | "triage" | "read" | "none" | string; +export type GitHubRepositoryCollaboratorPermission = + | "admin" + | "maintain" + | "write" + | "triage" + | "read" + | "none" + | string; export async function getRepositoryCollaboratorPermission( env: Env, @@ -146,9 +317,13 @@ export async function getRepositoryCollaboratorPermission( if (response.status === 404) return null; if (!response.ok) { const body = await response.text(); - throw new Error(`Failed to fetch GitHub collaborator permission (${response.status}): ${body.slice(0, 200)}`); + throw new Error( + `Failed to fetch GitHub collaborator permission (${response.status}): ${body.slice(0, 200)}`, + ); } - const payload = (await response.json()) as { permission?: GitHubRepositoryCollaboratorPermission }; + const payload = (await response.json()) as { + permission?: GitHubRepositoryCollaboratorPermission; + }; return payload.permission ?? null; } @@ -176,12 +351,18 @@ export async function createOrUpdateCheckRun( annotationContext?: CheckRunAnnotationContext, mode: AgentActionMode = "live", ): Promise { - return createOrUpdateNamedCheckRun(env, installationId, repoFullName, advisory, { - name: GITTENSORY_CONTEXT_CHECK_NAME, - conclusion: advisory.conclusion, - output: formatCheckRunOutput(advisory, detailLevel, annotationContext), - mode, - }); + return createOrUpdateNamedCheckRun( + env, + installationId, + repoFullName, + advisory, + { + name: GITTENSORY_CONTEXT_CHECK_NAME, + conclusion: advisory.conclusion, + output: formatCheckRunOutput(advisory, detailLevel, annotationContext), + mode, + }, + ); } export async function createOrUpdateGateCheckRun( @@ -190,7 +371,10 @@ export async function createOrUpdateGateCheckRun( repoFullName: string, advisory: Advisory, policy: GateCheckPolicy = {}, - options: { checkRunId?: number | undefined; gate?: GateCheckEvaluation | undefined } = {}, + options: { + checkRunId?: number | undefined; + gate?: GateCheckEvaluation | undefined; + } = {}, mode: AgentActionMode = "live", ): Promise { // Prefer the AUTHORITATIVE pre-computed evaluation when the caller has one (#5 / audit): the surface/content @@ -198,14 +382,20 @@ export async function createOrUpdateGateCheckRun( // and re-deriving here via evaluateGateCheck would discard that override — publishing a GREEN check while the // PR is actually auto-closed/held. Callers without a surface lane omit `gate` and re-derive as before (identical). const gate = options.gate ?? evaluateGateCheck(advisory, policy); - return createOrUpdateNamedCheckRun(env, installationId, repoFullName, advisory, { - name: GITTENSORY_GATE_CHECK_NAME, - status: "completed", - conclusion: gate.conclusion, - output: formatGateCheckOutput(gate), - checkRunId: options.checkRunId, - mode, - }); + return createOrUpdateNamedCheckRun( + env, + installationId, + repoFullName, + advisory, + { + name: GITTENSORY_GATE_CHECK_NAME, + status: "completed", + conclusion: gate.conclusion, + output: formatGateCheckOutput(gate), + checkRunId: options.checkRunId, + mode, + }, + ); } export async function createOrUpdatePendingGateCheckRun( @@ -215,16 +405,23 @@ export async function createOrUpdatePendingGateCheckRun( advisory: Advisory, mode: AgentActionMode = "live", ): Promise { - return createOrUpdateNamedCheckRun(env, installationId, repoFullName, advisory, { - name: GITTENSORY_GATE_CHECK_NAME, - status: "in_progress", - output: { - title: "Gittensory Gate is evaluating", - summary: "Gittensory is running deterministic public PR hygiene checks.", - text: "The Gate blocks every author on the repo's configured hard blockers (duplicate PRs by default); on everything else, and while state is still syncing, it stays advisory.", + return createOrUpdateNamedCheckRun( + env, + installationId, + repoFullName, + advisory, + { + name: GITTENSORY_GATE_CHECK_NAME, + status: "in_progress", + output: { + title: "Gittensory Gate is evaluating", + summary: + "Gittensory is running deterministic public PR hygiene checks.", + text: "The Gate blocks every author on the repo's configured hard blockers (duplicate PRs by default); on everything else, and while state is still syncing, it stays advisory.", + }, + mode, }, - mode, - }); + ); } export async function createOrUpdateSkippedGateCheckRun( @@ -235,17 +432,23 @@ export async function createOrUpdateSkippedGateCheckRun( reason = "PR closed before full evaluation.", mode: AgentActionMode = "live", ): Promise { - return createOrUpdateNamedCheckRun(env, installationId, repoFullName, advisory, { - name: GITTENSORY_GATE_CHECK_NAME, - status: "completed", - conclusion: "skipped", - output: { - title: "Gittensory Gate skipped", - summary: reason, - text: "Gittensory does not post late first comments on closed or merged pull requests.", + return createOrUpdateNamedCheckRun( + env, + installationId, + repoFullName, + advisory, + { + name: GITTENSORY_GATE_CHECK_NAME, + status: "completed", + conclusion: "skipped", + output: { + title: "Gittensory Gate skipped", + summary: reason, + text: "Gittensory does not post late first comments on closed or merged pull requests.", + }, + mode, }, - mode, - }); + ); } /** @@ -263,18 +466,25 @@ export async function createOrUpdateErroredGateCheckRun( options: { checkRunId?: number | undefined } = {}, mode: AgentActionMode = "live", ): Promise { - return createOrUpdateNamedCheckRun(env, installationId, repoFullName, advisory, { - name: GITTENSORY_GATE_CHECK_NAME, - status: "completed", - conclusion: "neutral", - output: { - title: "Gittensory Gate — could not finish evaluating", - summary: "A transient error interrupted gate evaluation. This does NOT block the PR and re-runs automatically on the next push.", - text: "Gittensory finalizes the Gate to a neutral, non-blocking state when evaluation is interrupted, so the check never hangs in_progress. Push a new commit or use the 'Re-run Gittensory review' checkbox to re-evaluate.", + return createOrUpdateNamedCheckRun( + env, + installationId, + repoFullName, + advisory, + { + name: GITTENSORY_GATE_CHECK_NAME, + status: "completed", + conclusion: "neutral", + output: { + title: "Gittensory Gate — could not finish evaluating", + summary: + "A transient error interrupted gate evaluation. This does NOT block the PR and re-runs automatically on the next push.", + text: "Gittensory finalizes the Gate to a neutral, non-blocking state when evaluation is interrupted, so the check never hangs in_progress. Push a new commit or use the 'Re-run Gittensory review' checkbox to re-evaluate.", + }, + checkRunId: options.checkRunId, + mode, }, - checkRunId: options.checkRunId, - mode, - }); + ); } /** @@ -291,18 +501,25 @@ export async function createOrUpdateOverriddenGateCheckRun( options: { actor: string; reason: string; checkRunId?: number | undefined }, mode: AgentActionMode = "live", ): Promise { - return createOrUpdateNamedCheckRun(env, installationId, repoFullName, advisory, { - name: GITTENSORY_GATE_CHECK_NAME, - status: "completed", - conclusion: "neutral", - output: { - title: `Gittensory Gate — overridden by @${options.actor}`, - summary: "A maintainer set the Gate to neutral for THIS commit only. This does NOT permanently bypass the Gate; a new push re-evaluates it.", - text: `Overridden by @${options.actor}: ${options.reason}`, + return createOrUpdateNamedCheckRun( + env, + installationId, + repoFullName, + advisory, + { + name: GITTENSORY_GATE_CHECK_NAME, + status: "completed", + conclusion: "neutral", + output: { + title: `Gittensory Gate — overridden by @${options.actor}`, + summary: + "A maintainer set the Gate to neutral for THIS commit only. This does NOT permanently bypass the Gate; a new push re-evaluates it.", + text: `Overridden by @${options.actor}: ${options.reason}`, + }, + checkRunId: options.checkRunId, + mode, }, - checkRunId: options.checkRunId, - mode, - }); + ); } async function createOrUpdateNamedCheckRun( @@ -321,7 +538,8 @@ async function createOrUpdateNamedCheckRun( ): Promise { if (!advisory.headSha) return null; const [owner, repo] = repoFullName.split("/"); - if (!owner || !repo) throw new Error(`Invalid repository full name: ${repoFullName}`); + if (!owner || !repo) + throw new Error(`Invalid repository full name: ${repoFullName}`); const token = await createInstallationToken(env, installationId); // makeInstallationOctokit injects the shared per-request timeout (a stalled PATCH can never orphan the @@ -334,62 +552,76 @@ async function createOrUpdateNamedCheckRun( try { if (check.checkRunId) { - const response = await octokit.request("PATCH /repos/{owner}/{repo}/check-runs/{check_run_id}", { + const response = await octokit.request( + "PATCH /repos/{owner}/{repo}/check-runs/{check_run_id}", + { + owner, + repo, + check_run_id: check.checkRunId, + name: check.name, + /* v8 ignore next 2 -- Exported check helpers always provide status/conclusion for known-id finalization. */ + status: check.status ?? "completed", + ...(check.conclusion ? { conclusion: check.conclusion } : {}), + output: outputForCheckRunUpdate(check.output), + ...detailsUrlBody, + }, + ); + const data = response.data as CheckRunResponse; + return publishedOutcome(data); + } + + const existing = await octokit.request( + "GET /repos/{owner}/{repo}/commits/{ref}/check-runs", + { owner, repo, - check_run_id: check.checkRunId, - name: check.name, - /* v8 ignore next 2 -- Exported check helpers always provide status/conclusion for known-id finalization. */ - status: check.status ?? "completed", - ...(check.conclusion ? { conclusion: check.conclusion } : {}), - output: outputForCheckRunUpdate(check.output), - ...detailsUrlBody, - }); + ref: advisory.headSha, + check_name: check.name, + filter: "latest", + per_page: 1, + }, + ); + const existingCheckRun = (existing.data as CheckRunListResponse) + .check_runs?.[0]; + if (existingCheckRun) { + const response = await octokit.request( + "PATCH /repos/{owner}/{repo}/check-runs/{check_run_id}", + { + owner, + repo, + check_run_id: existingCheckRun.id, + name: check.name, + status: check.status ?? "completed", + ...(check.conclusion ? { conclusion: check.conclusion } : {}), + output: outputForCheckRunUpdate(check.output), + ...detailsUrlBody, + }, + ); const data = response.data as CheckRunResponse; return publishedOutcome(data); } - const existing = await octokit.request("GET /repos/{owner}/{repo}/commits/{ref}/check-runs", { - owner, - repo, - ref: advisory.headSha, - check_name: check.name, - filter: "latest", - per_page: 1, - }); - const existingCheckRun = (existing.data as CheckRunListResponse).check_runs?.[0]; - if (existingCheckRun) { - const response = await octokit.request("PATCH /repos/{owner}/{repo}/check-runs/{check_run_id}", { + const response = await octokit.request( + "POST /repos/{owner}/{repo}/check-runs", + { owner, repo, - check_run_id: existingCheckRun.id, name: check.name, + head_sha: advisory.headSha, status: check.status ?? "completed", ...(check.conclusion ? { conclusion: check.conclusion } : {}), - output: outputForCheckRunUpdate(check.output), + output: check.output, ...detailsUrlBody, - }); - const data = response.data as CheckRunResponse; - return publishedOutcome(data); - } - - const response = await octokit.request("POST /repos/{owner}/{repo}/check-runs", { - owner, - repo, - name: check.name, - head_sha: advisory.headSha, - status: check.status ?? "completed", - ...(check.conclusion ? { conclusion: check.conclusion } : {}), - output: check.output, - ...detailsUrlBody, - }); + }, + ); const data = response.data as CheckRunResponse; return publishedOutcome(data); } catch (error) { if (isCheckRunPermissionError(error)) { return { kind: "permission_missing", - warning: "GitHub App Checks: write permission is missing. Enable it in the GitHub App settings and re-approve the installation.", + warning: + "GitHub App Checks: write permission is missing. Enable it in the GitHub App settings and re-approve the installation.", }; } throw error; @@ -403,7 +635,10 @@ function outputForCheckRunUpdate(output: CheckRunOutput): CheckRunOutput { } function publishedOutcome(data: CheckRunResponse): CheckRunOutcome { - const outcome: { kind: "published"; id: number; html_url?: string } = { kind: "published", id: data.id }; + const outcome: { kind: "published"; id: number; html_url?: string } = { + kind: "published", + id: data.id, + }; if (data.html_url) outcome.html_url = data.html_url; return outcome; } @@ -413,10 +648,17 @@ function isCheckRunPermissionError(error: unknown): boolean { if (typeof error !== "object" || error === null) return false; const e = error as { status?: number; message?: string }; if (e.status === 403) return true; - return typeof e.message === "string" && /resource not accessible by integration|not have permission/i.test(e.message); + return ( + typeof e.message === "string" && + /resource not accessible by integration|not have permission/i.test( + e.message, + ) + ); } -export function getInstallationId(payload: GitHubWebhookPayload): number | null { +export function getInstallationId( + payload: GitHubWebhookPayload, +): number | null { return payload.installation?.id ?? null; } diff --git a/src/selfhost/redis-response-cache.ts b/src/selfhost/redis-response-cache.ts new file mode 100644 index 0000000000..e28e343136 --- /dev/null +++ b/src/selfhost/redis-response-cache.ts @@ -0,0 +1,44 @@ +// Redis-backed GitHub GET-response cache (#perf). Optional: when REDIS_URL + GITHUB_CACHE_TTL_SECONDS>0 are set, +// the self-host caches safe GitHub API GET responses for a short TTL. A single review pass makes ~24 GitHub +// fetches (PR data, files, user/org lookups) — many repeated — all network-bound and rate-limited. A short-TTL +// cache dedups those within and across rapid re-reviews, cutting latency and rate-limit pressure, and it +// persists across restarts. Keyed by URL; the TTL bounds staleness. Only the status + body + content-type are +// stored — NOT rate-limit headers (a cache hit consumed no quota) or content-encoding (the body is decoded). +import type { Redis } from "ioredis"; +import type { CachedGitHubResponse, GitHubResponseCache } from "../github/app"; + +const keyFor = (url: string): string => `gh:resp:${url}`; + +export function createRedisResponseCache( + redis: Redis, + ttlSeconds: number, +): GitHubResponseCache { + return { + async get(url: string) { + const raw = await redis.get(keyFor(url)); + if (!raw) return null; + try { + const value = JSON.parse(raw) as Partial; + return typeof value.status === "number" && + typeof value.body === "string" && + typeof value.contentType === "string" + ? { + status: value.status, + body: value.body, + contentType: value.contentType, + } + : null; + } catch { + return null; + } + }, + async set(url: string, value: CachedGitHubResponse) { + await redis.set( + keyFor(url), + JSON.stringify(value), + "EX", + Math.max(1, ttlSeconds), + ); + }, + }; +} diff --git a/src/selfhost/redis-token-cache.ts b/src/selfhost/redis-token-cache.ts new file mode 100644 index 0000000000..9609cf325a --- /dev/null +++ b/src/selfhost/redis-token-cache.ts @@ -0,0 +1,48 @@ +// Redis-backed installation-token store (#perf). Optional: when REDIS_URL is set, the self-host backs +// github/app.ts's installation-token cache with Redis so warm tokens SURVIVE restarts/deploys. The default +// in-isolate Map dies on every restart, so a brokered self-host re-mints a token (an Orb round-trip) on the +// next call after each cold start — wasteful when the container restarts often. Keyed by installation id, with +// the TTL set to the token's own remaining lifetime so the entry self-expires exactly when the token does. +// Also makes the cache shared across instances if the stack is ever scaled horizontally. +import type { Redis } from "ioredis"; +import type { InstallationTokenStore } from "../github/app"; + +const keyFor = (installationId: number): string => + `gh:insttoken:${installationId}`; + +export function createRedisTokenCache(redis: Redis): InstallationTokenStore { + return { + async get(installationId: number) { + const raw = await redis.get(keyFor(installationId)); + if (!raw) return null; + try { + const value = JSON.parse(raw) as { + token?: unknown; + expiresAtMs?: unknown; + }; + return typeof value.token === "string" && + typeof value.expiresAtMs === "number" + ? { token: value.token, expiresAtMs: value.expiresAtMs } + : null; + } catch { + return null; + } + }, + async set( + installationId: number, + value: { token: string; expiresAtMs: number }, + ) { + // Floor at 1s; a token already inside the safety margin still gets cached briefly rather than not at all. + const ttlSeconds = Math.max( + 1, + Math.floor((value.expiresAtMs - Date.now()) / 1000), + ); + await redis.set( + keyFor(installationId), + JSON.stringify(value), + "EX", + ttlSeconds, + ); + }, + }; +} diff --git a/src/server.ts b/src/server.ts index 73027ea13d..a11aeb97b1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -323,12 +323,33 @@ async function main(): Promise { const { createRedisCache } = await import("./selfhost/redis-cache"); rateLimiter = createRedisRateLimiter(redisClient); webhookCache = createRedisCache(redisClient); + // Persist the installation-token cache in Redis so warm GitHub App tokens survive restarts/deploys and are + // shared across replicas (the in-isolate Map otherwise re-mints — an Orb round-trip — per replica/cold start). + const { createRedisTokenCache } = + await import("./selfhost/redis-token-cache"); + const { setInstallationTokenStore, setGitHubResponseCache } = + await import("./github/app"); + setInstallationTokenStore(createRedisTokenCache(redisClient)); + // Short-TTL cache for safe GitHub GET responses (dedups the ~24 reads per review). Default 20s; 0 disables. + const ghCacheTtl = Math.max( + 0, + Number(process.env.GITHUB_CACHE_TTL_SECONDS ?? "20"), + ); + if (ghCacheTtl > 0) { + const { createRedisResponseCache } = + await import("./selfhost/redis-response-cache"); + setGitHubResponseCache(createRedisResponseCache(redisClient, ghCacheTtl)); + } readinessProbes.push({ name: "redis", check: () => withTimeout(redisClient.ping().then(() => true)), }); console.log( - JSON.stringify({ event: "selfhost_rate_limiter", backend: "redis" }), + JSON.stringify({ + event: "selfhost_rate_limiter", + backend: "redis", + githubResponseCacheTtl: ghCacheTtl, + }), ); } diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 0e90ada541..b1f28c133f 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -10,7 +10,10 @@ import { getAppInstallation, getInstallationId, getRepositoryCollaboratorPermission, + isCacheableGithubUrl, isForeignAppInstallation, + setGitHubResponseCache, + setInstallationTokenStore, } from "../../src/github/app"; import type { Advisory } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; @@ -25,25 +28,37 @@ describe("GitHub check runs", () => { it("creates a completed Gittensory check run with an installation token", async () => { const privateKey = await generatePrivateKeyPem(); const calls: string[] = []; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - calls.push(url); - if (url.includes("/access_tokens")) { - return Response.json({ token: "installation-token" }); - } - if (url.includes("/commits/abc123/check-runs")) { - return Response.json({ total_count: 0, check_runs: [] }); - } - if (url.includes("/check-runs")) { - const body = JSON.parse(String(init?.body)) as { name: string; conclusion: string; output: { title: string; text: string } }; - expect(body.name).toBe("Gittensory Context"); - expect(body.conclusion).toBe("neutral"); - expect(body.output.title).toBe("Gittensory context posted"); - expect(body.output.text).not.toMatch(/linked issue|reviewability|reward|farming|wallet|hotkey|trust score/i); - return Response.json({ id: 42, html_url: "https://github.com/checks/42" }, { status: 201 }); - } - return new Response("not found", { status: 404 }); - }); + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + calls.push(url); + if (url.includes("/access_tokens")) { + return Response.json({ token: "installation-token" }); + } + if (url.includes("/commits/abc123/check-runs")) { + return Response.json({ total_count: 0, check_runs: [] }); + } + if (url.includes("/check-runs")) { + const body = JSON.parse(String(init?.body)) as { + name: string; + conclusion: string; + output: { title: string; text: string }; + }; + expect(body.name).toBe("Gittensory Context"); + expect(body.conclusion).toBe("neutral"); + expect(body.output.title).toBe("Gittensory context posted"); + expect(body.output.text).not.toMatch( + /linked issue|reviewability|reward|farming|wallet|hotkey|trust score/i, + ); + return Response.json( + { id: 42, html_url: "https://github.com/checks/42" }, + { status: 201 }, + ); + } + return new Response("not found", { status: 404 }); + }, + ); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); const advisory: Advisory = { @@ -68,22 +83,39 @@ describe("GitHub check runs", () => { generatedAt: "2026-05-22T00:00:00.000Z", }; - const result = await createOrUpdateCheckRun(env, 123, "JSONbored/gittensory", advisory); + const result = await createOrUpdateCheckRun( + env, + 123, + "JSONbored/gittensory", + advisory, + ); expect(result).toMatchObject({ kind: "published", id: 42 }); - expect(calls.some((url) => url.includes("/app/installations/123/access_tokens"))).toBe(true); - expect(calls.some((url) => url.includes("/repos/JSONbored/gittensory/check-runs"))).toBe(true); + expect( + calls.some((url) => url.includes("/app/installations/123/access_tokens")), + ).toBe(true); + expect( + calls.some((url) => + url.includes("/repos/JSONbored/gittensory/check-runs"), + ), + ).toBe(true); }); it("accepts GitHub App RSA private key PEMs for installation tokens", async () => { const privateKey = generateRsaPrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); return new Response("not found", { status: 404 }); }); - await expect(createInstallationToken(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123)).resolves.toBe("installation-token"); + await expect( + createInstallationToken( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + ), + ).resolves.toBe("installation-token"); }); it("caches an installation token and reuses it within the validity window", async () => { @@ -93,7 +125,10 @@ describe("GitHub check runs", () => { const url = input.toString(); if (url.includes("/access_tokens")) { mints += 1; - return Response.json({ token: `installation-token-${mints}`, expires_at: new Date(Date.now() + 60 * 60_000).toISOString() }); + return Response.json({ + token: `installation-token-${mints}`, + expires_at: new Date(Date.now() + 60 * 60_000).toISOString(), + }); } return new Response("not found", { status: 404 }); }); @@ -116,7 +151,10 @@ describe("GitHub check runs", () => { mints += 1; // First mint expires almost immediately (inside the 2-minute safety margin) → must not be reused. const expiresInMs = mints === 1 ? 30_000 : 60 * 60_000; - return Response.json({ token: `installation-token-${mints}`, expires_at: new Date(Date.now() + expiresInMs).toISOString() }); + return Response.json({ + token: `installation-token-${mints}`, + expires_at: new Date(Date.now() + expiresInMs).toISOString(), + }); } return new Response("not found", { status: 404 }); }); @@ -136,7 +174,11 @@ describe("GitHub check runs", () => { const url = input.toString(); if (url.includes("/v1/orb/token")) { brokerCalls += 1; - return Response.json({ token: "brokered-token", installationId: 999, expiresAt: new Date(Date.now() + 60 * 60_000).toISOString() }); + return Response.json({ + token: "brokered-token", + installationId: 999, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + }); } return new Response("not found", { status: 404 }); }); @@ -154,7 +196,12 @@ describe("GitHub check runs", () => { if (url.includes("/v1/orb/token")) { calls += 1; // First mint returns a token expiring within the 2-min safety margin → the next call re-mints; that re-mint fails. - if (calls === 1) return Response.json({ token: "tok-1", installationId: 1001, expiresAt: new Date(Date.now() + 90_000).toISOString() }); + if (calls === 1) + return Response.json({ + token: "tok-1", + installationId: 1001, + expiresAt: new Date(Date.now() + 90_000).toISOString(), + }); return new Response("orb down", { status: 503 }); } return new Response("nf", { status: 404 }); @@ -168,10 +215,16 @@ describe("GitHub check runs", () => { it("#2: rethrows when the broker is down and there is no still-valid cached token", async () => { vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/v1/orb/token")) return new Response("orb down", { status: 503 }); + if (url.includes("/v1/orb/token")) + return new Response("orb down", { status: 503 }); return new Response("nf", { status: 404 }); }); - await expect(createInstallationToken(createTestEnv({ ORB_ENROLLMENT_SECRET: "orbsec_test" }), 1002)).rejects.toThrow(); + await expect( + createInstallationToken( + createTestEnv({ ORB_ENROLLMENT_SECRET: "orbsec_test" }), + 1002, + ), + ).rejects.toThrow(); }); it("#2: rethrows when the only cached token has actually expired (no dangerous reuse)", async () => { @@ -180,7 +233,12 @@ describe("GitHub check runs", () => { const url = input.toString(); if (url.includes("/v1/orb/token")) { calls += 1; - if (calls === 1) return Response.json({ token: "tok-old", installationId: 1003, expiresAt: new Date(Date.now() - 1_000).toISOString() }); + if (calls === 1) + return Response.json({ + token: "tok-old", + installationId: 1003, + expiresAt: new Date(Date.now() - 1_000).toISOString(), + }); return new Response("orb down", { status: 503 }); } return new Response("nf", { status: 404 }); @@ -196,57 +254,126 @@ describe("GitHub check runs", () => { vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); calls.push(url); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.endsWith("/repos/JSONbored/gittensory/collaborators/maintainer/permission")) return Response.json({ permission: "maintain" }); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if ( + url.endsWith( + "/repos/JSONbored/gittensory/collaborators/maintainer/permission", + ) + ) + return Response.json({ permission: "maintain" }); return new Response("not found", { status: 404 }); }); - await expect(getRepositoryCollaboratorPermission(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", "maintainer")).resolves.toBe("maintain"); - expect(calls.some((url) => url.includes("/app/installations/123/access_tokens"))).toBe(true); + await expect( + getRepositoryCollaboratorPermission( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + "JSONbored/gittensory", + "maintainer", + ), + ).resolves.toBe("maintain"); + expect( + calls.some((url) => url.includes("/app/installations/123/access_tokens")), + ).toBe(true); }); it("handles missing repository collaborator permission responses", async () => { const privateKey = await generatePrivateKeyPem(); - await expect(getRepositoryCollaboratorPermission(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "invalid", "maintainer")).resolves.toBeNull(); - await expect(getRepositoryCollaboratorPermission(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", "")).resolves.toBeNull(); + await expect( + getRepositoryCollaboratorPermission( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + "invalid", + "maintainer", + ), + ).resolves.toBeNull(); + await expect( + getRepositoryCollaboratorPermission( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + "JSONbored/gittensory", + "", + ), + ).resolves.toBeNull(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/collaborators/missing/permission")) return new Response("missing", { status: 404 }); - if (url.includes("/collaborators/no-permission/permission")) return Response.json({}); - if (url.includes("/collaborators/error/permission")) return new Response("permission unavailable", { status: 500 }); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/collaborators/missing/permission")) + return new Response("missing", { status: 404 }); + if (url.includes("/collaborators/no-permission/permission")) + return Response.json({}); + if (url.includes("/collaborators/error/permission")) + return new Response("permission unavailable", { status: 500 }); return new Response("not found", { status: 404 }); }); - await expect(getRepositoryCollaboratorPermission(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", "missing")).resolves.toBeNull(); - await expect(getRepositoryCollaboratorPermission(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", "no-permission")).resolves.toBeNull(); - await expect(getRepositoryCollaboratorPermission(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", "error")).rejects.toThrow(/Failed to fetch GitHub collaborator permission/); + await expect( + getRepositoryCollaboratorPermission( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + "JSONbored/gittensory", + "missing", + ), + ).resolves.toBeNull(); + await expect( + getRepositoryCollaboratorPermission( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + "JSONbored/gittensory", + "no-permission", + ), + ).resolves.toBeNull(); + await expect( + getRepositoryCollaboratorPermission( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + "JSONbored/gittensory", + "error", + ), + ).rejects.toThrow(/Failed to fetch GitHub collaborator permission/); }); it("updates an existing Gittensory check run for the same head SHA", async () => { const privateKey = await generatePrivateKeyPem(); const methods: string[] = []; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - methods.push(`${init?.method ?? "GET"} ${url}`); - if (url.includes("/access_tokens")) { - return Response.json({ token: "installation-token" }); - } - if (url.includes("/commits/abc123/check-runs")) { - return Response.json({ total_count: 1, check_runs: [{ id: 42, name: "Gittensory" }] }); - } - if (url.includes("/check-runs/42")) { - const body = JSON.parse(String(init?.body)) as { name: string; conclusion: string; output: { title: string; text: string } }; - expect(body.name).toBe("Gittensory Context"); - expect(body.conclusion).toBe("success"); - expect(body.output.title).toBe("Gittensory context checked"); - expect(body.output.text).not.toMatch(/reviewability|reward|farming|wallet|hotkey|trust score/i); - return Response.json({ id: 42, html_url: "https://github.com/checks/42" }); - } - return new Response("not found", { status: 404 }); - }); + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + methods.push(`${init?.method ?? "GET"} ${url}`); + if (url.includes("/access_tokens")) { + return Response.json({ token: "installation-token" }); + } + if (url.includes("/commits/abc123/check-runs")) { + return Response.json({ + total_count: 1, + check_runs: [{ id: 42, name: "Gittensory" }], + }); + } + if (url.includes("/check-runs/42")) { + const body = JSON.parse(String(init?.body)) as { + name: string; + conclusion: string; + output: { title: string; text: string }; + }; + expect(body.name).toBe("Gittensory Context"); + expect(body.conclusion).toBe("success"); + expect(body.output.title).toBe("Gittensory context checked"); + expect(body.output.text).not.toMatch( + /reviewability|reward|farming|wallet|hotkey|trust score/i, + ); + return Response.json({ + id: 42, + html_url: "https://github.com/checks/42", + }); + } + return new Response("not found", { status: 404 }); + }, + ); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); const advisory: Advisory = { @@ -264,19 +391,34 @@ describe("GitHub check runs", () => { generatedAt: "2026-05-22T00:00:00.000Z", }; - const result = await createOrUpdateCheckRun(env, 123, "JSONbored/gittensory", advisory); + const result = await createOrUpdateCheckRun( + env, + 123, + "JSONbored/gittensory", + advisory, + ); expect(result).toMatchObject({ kind: "published", id: 42 }); - expect(methods.some((call) => call.startsWith("PATCH ") && call.includes("/check-runs/42"))).toBe(true); + expect( + methods.some( + (call) => call.startsWith("PATCH ") && call.includes("/check-runs/42"), + ), + ).toBe(true); }); it("returns permission_missing outcome when GitHub returns 403", async () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/")) return Response.json({ total_count: 0, check_runs: [] }); - if (url.includes("/check-runs")) return new Response(JSON.stringify({ message: "Resource not accessible by integration" }), { status: 403 }); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) + return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) + return new Response( + JSON.stringify({ message: "Resource not accessible by integration" }), + { status: 403 }, + ); return new Response("not found", { status: 404 }); }); @@ -296,25 +438,44 @@ describe("GitHub check runs", () => { generatedAt: "2026-05-22T00:00:00.000Z", }; - const result = await createOrUpdateCheckRun(env, 123, "JSONbored/gittensory", advisory); + const result = await createOrUpdateCheckRun( + env, + 123, + "JSONbored/gittensory", + advisory, + ); expect(result).toMatchObject({ kind: "permission_missing" }); - expect((result as { kind: string; warning: string }).warning).toMatch(/Checks: write/i); + expect((result as { kind: string; warning: string }).warning).toMatch( + /Checks: write/i, + ); }); it("creates a failing opt-in Gittensory Gate check for merge blockers", async () => { const privateKey = await generatePrivateKeyPem(); - let capturedBody: { name?: string; conclusion?: string; output?: { title?: string; text?: string } } = {}; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/")) return Response.json({ total_count: 0, check_runs: [] }); - if (url.includes("/check-runs")) { - capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; - return Response.json({ id: 88, html_url: "https://github.com/checks/88" }, { status: 201 }); - } - return new Response("not found", { status: 404 }); - }); + let capturedBody: { + name?: string; + conclusion?: string; + output?: { title?: string; text?: string }; + } = {}; + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) + return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) { + capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; + return Response.json( + { id: 88, html_url: "https://github.com/checks/88" }, + { status: 201 }, + ); + } + return new Response("not found", { status: 404 }); + }, + ); const result = await createOrUpdateGateCheckRun( createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), @@ -331,7 +492,15 @@ describe("GitHub check runs", () => { severity: "warning", title: "Gittensory advisory available", summary: "1 advisory finding generated.", - findings: [{ code: "missing_linked_issue", title: "No linked issue detected", severity: "warning", detail: "No closing reference.", action: "Link the issue before merge." }], + findings: [ + { + code: "missing_linked_issue", + title: "No linked issue detected", + severity: "warning", + detail: "No closing reference.", + action: "Link the issue before merge.", + }, + ], generatedAt: "2026-05-22T00:00:00.000Z", }, { linkedIssueGateMode: "block" }, @@ -344,24 +513,42 @@ describe("GitHub check runs", () => { output: { title: "Gittensory Gate: No linked issue detected" }, }); expect(capturedBody.output?.text).toContain("Link the issue before merge."); - expect(capturedBody.output?.text).not.toMatch(/reward|wallet|hotkey|trust score|reviewability|farming/i); + expect(capturedBody.output?.text).not.toMatch( + /reward|wallet|hotkey|trust score|reviewability|farming/i, + ); }); it("creates an in-progress Gate check without a conclusion", async () => { const privateKey = await generatePrivateKeyPem(); - let capturedBody: { name?: string; status?: string; conclusion?: string; details_url?: string; output?: { title?: string; text?: string } } = {}; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/")) return Response.json({ total_count: 0, check_runs: [] }); - if (url.includes("/check-runs")) { - capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; - return Response.json({ id: 89 }, { status: 201 }); - } - return new Response("not found", { status: 404 }); - }); + let capturedBody: { + name?: string; + status?: string; + conclusion?: string; + details_url?: string; + output?: { title?: string; text?: string }; + } = {}; + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) + return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) { + capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; + return Response.json({ id: 89 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }, + ); - const result = await createOrUpdatePendingGateCheckRun(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", gateAdvisory("pending123")); + const result = await createOrUpdatePendingGateCheckRun( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + "JSONbored/gittensory", + gateAdvisory("pending123"), + ); expect(result).toMatchObject({ kind: "published", id: 89 }); expect(capturedBody).toMatchObject({ @@ -373,47 +560,80 @@ describe("GitHub check runs", () => { // The Gate blocks every author the same on a configured blocker (confirmed status no longer gates the verdict). expect(capturedBody.output?.text).toContain("blocks every author"); // The "Details" link points at the repo's Gittensory maintainer panel, not GitHub's generic check page. (#audit-details-url) - expect(capturedBody.details_url).toBe("https://gittensory.aethereal.dev/app?view=maintainer&repo=JSONbored%2Fgittensory"); + expect(capturedBody.details_url).toBe( + "https://gittensory.aethereal.dev/app?view=maintainer&repo=JSONbored%2Fgittensory", + ); }); it("omits details_url when the site origin cannot form a URL (#audit-details-url null arm)", async () => { const privateKey = await generatePrivateKeyPem(); let capturedBody: { details_url?: string } = {}; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/")) return Response.json({ total_count: 0, check_runs: [] }); - if (url.includes("/check-runs")) { - capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; - return Response.json({ id: 90 }, { status: 201 }); - } - return new Response("not found", { status: 404 }); - }); + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) + return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) { + capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; + return Response.json({ id: 90 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }, + ); - const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey, PUBLIC_SITE_ORIGIN: "not-a-valid-origin" }); - await createOrUpdatePendingGateCheckRun(env, 123, "JSONbored/gittensory", gateAdvisory("pending-no-url")); + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: privateKey, + PUBLIC_SITE_ORIGIN: "not-a-valid-origin", + }); + await createOrUpdatePendingGateCheckRun( + env, + 123, + "JSONbored/gittensory", + gateAdvisory("pending-no-url"), + ); expect(capturedBody).not.toHaveProperty("details_url"); }); it("finalizes a known pending Gate check by id without listing check runs first", async () => { const privateKey = await generatePrivateKeyPem(); const calls: string[] = []; - let capturedBody: { name?: string; status?: string; conclusion?: string; output?: { title?: string; text?: string } } = {}; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - calls.push(`${init?.method ?? "GET"} ${url}`); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/check-runs/456")) { - capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; - return Response.json({ id: 456 }); - } - return new Response("not found", { status: 404 }); - }); + let capturedBody: { + name?: string; + status?: string; + conclusion?: string; + output?: { title?: string; text?: string }; + } = {}; + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + calls.push(`${init?.method ?? "GET"} ${url}`); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/check-runs/456")) { + capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; + return Response.json({ id: 456 }); + } + return new Response("not found", { status: 404 }); + }, + ); - const result = await createOrUpdateGateCheckRun(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", gateAdvisory("final123"), {}, { checkRunId: 456 }); + const result = await createOrUpdateGateCheckRun( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + "JSONbored/gittensory", + gateAdvisory("final123"), + {}, + { checkRunId: 456 }, + ); expect(result).toEqual({ kind: "published", id: 456 }); - expect(calls.some((call) => call.includes("/commits/final123/check-runs"))).toBe(false); + expect( + calls.some((call) => call.includes("/commits/final123/check-runs")), + ).toBe(false); expect(capturedBody).toMatchObject({ name: "Gittensory Gate", status: "completed", @@ -424,17 +644,25 @@ describe("GitHub check runs", () => { it("publishes the precomputed authoritative gate (surface-lane override) instead of re-deriving (#5)", async () => { const privateKey = await generatePrivateKeyPem(); - let capturedBody: { conclusion?: string; output?: { title?: string; text?: string } } = {}; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/")) return Response.json({ total_count: 0, check_runs: [] }); - if (url.includes("/check-runs")) { - capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; - return Response.json({ id: 91 }, { status: 201 }); - } - return new Response("not found", { status: 404 }); - }); + let capturedBody: { + conclusion?: string; + output?: { title?: string; text?: string }; + } = {}; + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) + return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) { + capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; + return Response.json({ id: 91 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }, + ); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); // The advisory is CLEAN (re-deriving via evaluateGateCheck would publish "success"), but the surface lane @@ -444,10 +672,24 @@ describe("GitHub check runs", () => { conclusion: "failure" as const, title: "Metagraphed surface review", summary: "Surface payload rejected.", - blockers: [{ code: "surface_lane_reject", title: "Surface rejected", severity: "critical" as const, detail: "Registry payload failed validation." }], + blockers: [ + { + code: "surface_lane_reject", + title: "Surface rejected", + severity: "critical" as const, + detail: "Registry payload failed validation.", + }, + ], warnings: [], }; - const result = await createOrUpdateGateCheckRun(env, 123, "JSONbored/gittensory", gateAdvisory("surface-sha"), {}, { gate: surfaceGate }); + const result = await createOrUpdateGateCheckRun( + env, + 123, + "JSONbored/gittensory", + gateAdvisory("surface-sha"), + {}, + { gate: surfaceGate }, + ); expect(result).toEqual({ kind: "published", id: 91 }); expect(capturedBody.conclusion).toBe("failure"); // the surface override, NOT the clean re-derivation @@ -457,39 +699,70 @@ describe("GitHub check runs", () => { it("updates an existing pending Gate check without adding a conclusion", async () => { const privateKey = await generatePrivateKeyPem(); let capturedBody: { status?: string; conclusion?: string } = {}; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/pending-existing/check-runs")) { - return Response.json({ total_count: 1, check_runs: [{ id: 333, name: "Gittensory Gate" }] }); - } - if (url.includes("/check-runs/333")) { - capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; - return Response.json({ id: 333, html_url: "https://github.com/checks/333" }); - } - return new Response("not found", { status: 404 }); - }); + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/pending-existing/check-runs")) { + return Response.json({ + total_count: 1, + check_runs: [{ id: 333, name: "Gittensory Gate" }], + }); + } + if (url.includes("/check-runs/333")) { + capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; + return Response.json({ + id: 333, + html_url: "https://github.com/checks/333", + }); + } + return new Response("not found", { status: 404 }); + }, + ); - const result = await createOrUpdatePendingGateCheckRun(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "JSONbored/gittensory", gateAdvisory("pending-existing")); + const result = await createOrUpdatePendingGateCheckRun( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + "JSONbored/gittensory", + gateAdvisory("pending-existing"), + ); - expect(result).toMatchObject({ kind: "published", id: 333, html_url: "https://github.com/checks/333" }); + expect(result).toMatchObject({ + kind: "published", + id: 333, + html_url: "https://github.com/checks/333", + }); expect(capturedBody.status).toBe("in_progress"); expect(capturedBody).not.toHaveProperty("conclusion"); }); it("publishes a skipped Gate check for closed PR races", async () => { const privateKey = await generatePrivateKeyPem(); - let capturedBody: { status?: string; conclusion?: string; output?: { title?: string; summary?: string; text?: string } } = {}; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/closed123/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); - if (url.includes("/check-runs")) { - capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; - return Response.json({ id: 91, html_url: "https://github.com/checks/91" }, { status: 201 }); - } - return new Response("not found", { status: 404 }); - }); + let capturedBody: { + status?: string; + conclusion?: string; + output?: { title?: string; summary?: string; text?: string }; + } = {}; + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/closed123/check-runs")) + return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) { + capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; + return Response.json( + { id: 91, html_url: "https://github.com/checks/91" }, + { status: 201 }, + ); + } + return new Response("not found", { status: 404 }); + }, + ); const result = await createOrUpdateSkippedGateCheckRun( createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), @@ -508,28 +781,44 @@ describe("GitHub check runs", () => { summary: "Merged before Gittensory finished.", }, }); - expect(capturedBody.output?.text).toContain("does not post late first comments"); + expect(capturedBody.output?.text).toContain( + "does not post late first comments", + ); }); it("publishes Context check annotations on changed files while Gate stays text-only", async () => { const privateKey = await generatePrivateKeyPem(); - let contextBody: { name?: string; output?: { annotations?: Array<{ path: string; title: string }> } } = {}; - let gateBody: { name?: string; output?: { annotations?: Array<{ path: string; title: string }> } } = {}; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/")) return Response.json({ total_count: 0, check_runs: [] }); - if (url.includes("/check-runs")) { - const body = JSON.parse(String(init?.body)) as { - name?: string; - output?: { annotations?: Array<{ path: string; title: string }> }; - }; - if (body.name === "Gittensory Context") contextBody = body; - if (body.name === "Gittensory Gate") gateBody = body; - return Response.json({ id: body.name === "Gittensory Gate" ? 90 : 77 }, { status: 201 }); - } - return new Response("not found", { status: 404 }); - }); + let contextBody: { + name?: string; + output?: { annotations?: Array<{ path: string; title: string }> }; + } = {}; + let gateBody: { + name?: string; + output?: { annotations?: Array<{ path: string; title: string }> }; + } = {}; + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) + return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) { + const body = JSON.parse(String(init?.body)) as { + name?: string; + output?: { annotations?: Array<{ path: string; title: string }> }; + }; + if (body.name === "Gittensory Context") contextBody = body; + if (body.name === "Gittensory Gate") gateBody = body; + return Response.json( + { id: body.name === "Gittensory Gate" ? 90 : 77 }, + { status: 201 }, + ); + } + return new Response("not found", { status: 404 }); + }, + ); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); const advisory: Advisory = { @@ -547,35 +836,78 @@ describe("GitHub check runs", () => { generatedAt: "2026-05-22T00:00:00.000Z", }; - await createOrUpdateCheckRun(env, 123, "JSONbored/gittensory", advisory, "standard", { - pullNumber: 9, - files: [{ repoFullName: "JSONbored/gittensory", pullNumber: 9, path: "src/api/routes.ts", additions: 4, deletions: 0, changes: 4, payload: {} }], - collisions: { - repoFullName: "JSONbored/gittensory", - generatedAt: "2026-06-10T00:00:00.000Z", - summary: { clusterCount: 0, highRiskCount: 0, itemsReviewed: 0 }, - clusters: [], + await createOrUpdateCheckRun( + env, + 123, + "JSONbored/gittensory", + advisory, + "standard", + { + pullNumber: 9, + files: [ + { + repoFullName: "JSONbored/gittensory", + pullNumber: 9, + path: "src/api/routes.ts", + additions: 4, + deletions: 0, + changes: 4, + payload: {}, + }, + ], + collisions: { + repoFullName: "JSONbored/gittensory", + generatedAt: "2026-06-10T00:00:00.000Z", + summary: { clusterCount: 0, highRiskCount: 0, itemsReviewed: 0 }, + clusters: [], + }, }, - }); - await createOrUpdateGateCheckRun(env, 123, "JSONbored/gittensory", advisory); + ); + await createOrUpdateGateCheckRun( + env, + 123, + "JSONbored/gittensory", + advisory, + ); - expect(contextBody.output?.annotations?.[0]).toMatchObject({ path: "src/api/routes.ts", title: "Missing test evidence" }); + expect(contextBody.output?.annotations?.[0]).toMatchObject({ + path: "src/api/routes.ts", + title: "Missing test evidence", + }); expect(gateBody.output?.annotations).toBeUndefined(); }); it("omits annotations when updating an existing Context check run", async () => { const privateKey = await generatePrivateKeyPem(); - let patchedBody: { output?: { annotations?: Array<{ path: string; title: string }>; text?: string } } = {}; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/")) return Response.json({ total_count: 1, check_runs: [{ id: 77, name: "Gittensory Context" }] }); - if (url.includes("/check-runs/77")) { - patchedBody = JSON.parse(String(init?.body)) as { output?: { annotations?: Array<{ path: string; title: string }>; text?: string } }; - return Response.json({ id: 77 }, { status: 200 }); - } - return new Response("not found", { status: 404 }); - }); + let patchedBody: { + output?: { + annotations?: Array<{ path: string; title: string }>; + text?: string; + }; + } = {}; + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) + return Response.json({ + total_count: 1, + check_runs: [{ id: 77, name: "Gittensory Context" }], + }); + if (url.includes("/check-runs/77")) { + patchedBody = JSON.parse(String(init?.body)) as { + output?: { + annotations?: Array<{ path: string; title: string }>; + text?: string; + }; + }; + return Response.json({ id: 77 }, { status: 200 }); + } + return new Response("not found", { status: 404 }); + }, + ); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); const advisory: Advisory = { @@ -593,34 +925,63 @@ describe("GitHub check runs", () => { generatedAt: "2026-05-22T00:00:00.000Z", }; - await createOrUpdateCheckRun(env, 123, "JSONbored/gittensory", advisory, "standard", { - pullNumber: 9, - files: [{ repoFullName: "JSONbored/gittensory", pullNumber: 9, path: "src/api/routes.ts", additions: 4, deletions: 0, changes: 4, payload: {} }], - collisions: { - repoFullName: "JSONbored/gittensory", - generatedAt: "2026-06-10T00:00:00.000Z", - summary: { clusterCount: 0, highRiskCount: 0, itemsReviewed: 0 }, - clusters: [], + await createOrUpdateCheckRun( + env, + 123, + "JSONbored/gittensory", + advisory, + "standard", + { + pullNumber: 9, + files: [ + { + repoFullName: "JSONbored/gittensory", + pullNumber: 9, + path: "src/api/routes.ts", + additions: 4, + deletions: 0, + changes: 4, + payload: {}, + }, + ], + collisions: { + repoFullName: "JSONbored/gittensory", + generatedAt: "2026-06-10T00:00:00.000Z", + summary: { clusterCount: 0, highRiskCount: 0, itemsReviewed: 0 }, + clusters: [], + }, }, - }); + ); - expect(patchedBody.output?.text).toBe("No detailed findings are published in check runs."); + expect(patchedBody.output?.text).toBe( + "No detailed findings are published in check runs.", + ); expect(patchedBody.output?.annotations).toBeUndefined(); }); it("publishes check run with standard detail level and includes public-safe finding text", async () => { const privateKey = await generatePrivateKeyPem(); let capturedBody: { output?: { text?: string } } = {}; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/")) return Response.json({ total_count: 0, check_runs: [] }); - if (url.includes("/check-runs")) { - capturedBody = JSON.parse(String(init?.body)) as { output?: { text?: string } }; - return Response.json({ id: 77, html_url: "https://github.com/checks/77" }, { status: 201 }); - } - return new Response("not found", { status: 404 }); - }); + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) + return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) { + capturedBody = JSON.parse(String(init?.body)) as { + output?: { text?: string }; + }; + return Response.json( + { id: 77, html_url: "https://github.com/checks/77" }, + { status: 201 }, + ); + } + return new Response("not found", { status: 404 }); + }, + ); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); const advisory: Advisory = { @@ -646,21 +1007,36 @@ describe("GitHub check runs", () => { generatedAt: "2026-05-22T00:00:00.000Z", }; - const result = await createOrUpdateCheckRun(env, 123, "JSONbored/gittensory", advisory, "standard"); + const result = await createOrUpdateCheckRun( + env, + 123, + "JSONbored/gittensory", + advisory, + "standard", + ); expect(result).toMatchObject({ kind: "published", id: 77 }); - expect(capturedBody.output?.text).toMatch(/⚠️ Public PR context is available/); - expect(capturedBody.output?.text).not.toMatch(/No linked issue|reward|wallet|hotkey|trust score|reviewability|farming/i); + expect(capturedBody.output?.text).toMatch( + /⚠️ Public PR context is available/, + ); + expect(capturedBody.output?.text).not.toMatch( + /No linked issue|reward|wallet|hotkey|trust score|reviewability|farming/i, + ); }); it("returns permission_missing for message-based 422 permission errors", async () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) + return Response.json({ total_count: 0, check_runs: [] }); if (url.includes("/check-runs")) { - return new Response(JSON.stringify({ message: "Resource not accessible by integration" }), { status: 422 }); + return new Response( + JSON.stringify({ message: "Resource not accessible by integration" }), + { status: 422 }, + ); } return new Response("not found", { status: 404 }); }); @@ -681,7 +1057,12 @@ describe("GitHub check runs", () => { generatedAt: "2026-05-22T00:00:00.000Z", }; - const result = await createOrUpdateCheckRun(env, 123, "JSONbored/gittensory", advisory); + const result = await createOrUpdateCheckRun( + env, + 123, + "JSONbored/gittensory", + advisory, + ); expect(result).toMatchObject({ kind: "permission_missing" }); }); @@ -689,9 +1070,12 @@ describe("GitHub check runs", () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/")) return Response.json({ total_count: 0, check_runs: [] }); - if (url.includes("/check-runs")) return new Response("internal server error", { status: 500 }); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) + return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) + return new Response("internal server error", { status: 500 }); return new Response("not found", { status: 404 }); }); @@ -711,15 +1095,19 @@ describe("GitHub check runs", () => { generatedAt: "2026-05-22T00:00:00.000Z", }; - await expect(createOrUpdateCheckRun(env, 123, "JSONbored/gittensory", advisory)).rejects.toThrow(); + await expect( + createOrUpdateCheckRun(env, 123, "JSONbored/gittensory", advisory), + ).rejects.toThrow(); }); it("rethrows non-object check-run errors", async () => { const privateKey = await generatePrivateKeyPem(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); - if (url.includes("/commits/")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/access_tokens")) + return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) + return Response.json({ total_count: 0, check_runs: [] }); if (url.includes("/check-runs")) throw "network interrupted"; return new Response("not found", { status: 404 }); }); @@ -740,23 +1128,30 @@ describe("GitHub check runs", () => { generatedAt: "2026-05-22T00:00:00.000Z", }; - await expect(createOrUpdateCheckRun(env, 123, "JSONbored/gittensory", advisory)).rejects.toMatchObject({ cause: "network interrupted" }); + await expect( + createOrUpdateCheckRun(env, 123, "JSONbored/gittensory", advisory), + ).rejects.toMatchObject({ cause: "network interrupted" }); }); it("skips check creation when no head SHA is available", async () => { - const result = await createOrUpdateCheckRun(createTestEnv(), 123, "JSONbored/gittensory", { - id: "advisory-3", - targetType: "pull_request", - targetKey: "JSONbored/gittensory#1", - repoFullName: "JSONbored/gittensory", - pullNumber: 1, - conclusion: "success", - severity: "info", - title: "Gittensory advisory passed", - summary: "Pull request advisory generated.", - findings: [], - generatedAt: "2026-05-22T00:00:00.000Z", - }); + const result = await createOrUpdateCheckRun( + createTestEnv(), + 123, + "JSONbored/gittensory", + { + id: "advisory-3", + targetType: "pull_request", + targetKey: "JSONbored/gittensory#1", + repoFullName: "JSONbored/gittensory", + pullNumber: 1, + conclusion: "success", + severity: "info", + title: "Gittensory advisory passed", + summary: "Pull request advisory generated.", + findings: [], + generatedAt: "2026-05-22T00:00:00.000Z", + }, + ); expect(result).toBeNull(); }); @@ -779,18 +1174,38 @@ describe("GitHub check runs", () => { }), ).rejects.toThrow(/Invalid repository full name/); - await expect(createInstallationToken(createTestEnv({ GITHUB_APP_PRIVATE_KEY: "" }), 123)).rejects.toThrow(/not configured/); - expect(getInstallationId({ action: "created", installation: { id: 123 } })).toBe(123); + await expect( + createInstallationToken( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: "" }), + 123, + ), + ).rejects.toThrow(/not configured/); + expect( + getInstallationId({ action: "created", installation: { id: 123 } }), + ).toBe(123); expect(getInstallationId({ action: "created" })).toBeNull(); }); it("surfaces GitHub token response failures", async () => { const privateKey = await generatePrivateKeyPem(); - vi.stubGlobal("fetch", async () => new Response("bad credentials", { status: 401 })); - await expect(createInstallationToken(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123)).rejects.toThrow(/Failed to create GitHub installation token/); + vi.stubGlobal( + "fetch", + async () => new Response("bad credentials", { status: 401 }), + ); + await expect( + createInstallationToken( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + ), + ).rejects.toThrow(/Failed to create GitHub installation token/); vi.stubGlobal("fetch", async () => Response.json({})); - await expect(createInstallationToken(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123)).rejects.toThrow(/did not include a token/); + await expect( + createInstallationToken( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + ), + ).rejects.toThrow(/did not include a token/); }); it("fetches live GitHub App installation metadata", async () => { @@ -803,14 +1218,22 @@ describe("GitHub check runs", () => { account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "selected", - permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { + checks: "write", + metadata: "read", + pull_requests: "read", + issues: "write", + }, events: ["issues", "pull_request", "repository"], }); } return new Response("not found", { status: 404 }); }); - const installation = await getAppInstallation(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123); + const installation = await getAppInstallation( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + ); expect(installation).toMatchObject({ id: 123, @@ -822,11 +1245,24 @@ describe("GitHub check runs", () => { it("surfaces live GitHub App installation fetch failures", async () => { const privateKey = await generatePrivateKeyPem(); - vi.stubGlobal("fetch", async () => new Response("installation missing", { status: 404 })); - await expect(getAppInstallation(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123)).rejects.toThrow(/Failed to fetch GitHub App installation/); + vi.stubGlobal( + "fetch", + async () => new Response("installation missing", { status: 404 }), + ); + await expect( + getAppInstallation( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + ), + ).rejects.toThrow(/Failed to fetch GitHub App installation/); vi.stubGlobal("fetch", async () => Response.json({})); - await expect(getAppInstallation(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123)).rejects.toThrow(/did not include an id/); + await expect( + getAppInstallation( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + ), + ).rejects.toThrow(/did not include an id/); }); }); @@ -842,7 +1278,9 @@ async function generatePrivateKeyPem(): Promise { ["sign", "verify"], )) as CryptoKeyPair; const exported = await crypto.subtle.exportKey("pkcs8", key.privateKey); - const base64 = Buffer.from(exported as ArrayBuffer).toString("base64").replace(/(.{64})/g, "$1\n"); + const base64 = Buffer.from(exported as ArrayBuffer) + .toString("base64") + .replace(/(.{64})/g, "$1\n"); return `-----BEGIN PRIVATE KEY-----\n${base64}\n-----END PRIVATE KEY-----`; } @@ -888,3 +1326,96 @@ describe("isForeignAppInstallation (#selfhost-app-id)", () => { expect(isForeignAppInstallation("not-a-number", 99999)).toBe(false); }); }); + +describe("self-host Redis token store + GitHub GET response cache", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("uses an injected external token store (Redis on the self-host) instead of the in-isolate Map", async () => { + const privateKey = await generatePrivateKeyPem(); + const store = new Map(); + setInstallationTokenStore({ + get: async (id) => store.get(id) ?? null, + set: async (id, v) => void store.set(id, v), + }); + let mints = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("/access_tokens")) { + mints += 1; + return Response.json({ + token: `ext-token-${mints}`, + expires_at: new Date(Date.now() + 60 * 60_000).toISOString(), + }); + } + return new Response("not found", { status: 404 }); + }); + + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); + const first = await createInstallationToken(env, 321); + const second = await createInstallationToken(env, 321); + + expect(first).toBe("ext-token-1"); + expect(second).toBe("ext-token-1"); // second served from the external store, not re-minted + expect(mints).toBe(1); + expect(store.has(321)).toBe(true); // written to the external store, not the in-isolate Map + }); + + it("isCacheableGithubUrl: caches GitHub GETs but not token-mint / rate-limit / non-GitHub URLs", () => { + expect( + isCacheableGithubUrl("https://api.github.com/repos/o/r/pulls/1"), + ).toBe(true); + expect( + isCacheableGithubUrl( + "https://api.github.com/app/installations/1/access_tokens", + ), + ).toBe(false); + expect(isCacheableGithubUrl("https://api.github.com/rate_limit")).toBe( + false, + ); + expect(isCacheableGithubUrl("https://example.com/x")).toBe(false); + }); + + it("serves a cached GitHub GET on the second call and skips the network", async () => { + const privateKey = await generatePrivateKeyPem(); + const store = new Map< + string, + { status: number; body: string; contentType: string } + >(); + setGitHubResponseCache({ + get: async (u) => store.get(u) ?? null, + set: async (u, v) => void store.set(u, v), + }); + let getFetches = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().endsWith("/app/installations/42")) { + getFetches += 1; + return Response.json({ id: 42, account: { login: "JSONbored" } }); + } + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); + const a = await getAppInstallation(env, 42); + const b = await getAppInstallation(env, 42); + expect(a.id).toBe(42); + expect(b.id).toBe(42); + expect(getFetches).toBe(1); // second call served from the response cache + expect(store.has("https://api.github.com/app/installations/42")).toBe(true); + }); + + it("does not cache a non-200 GitHub GET", async () => { + const privateKey = await generatePrivateKeyPem(); + const store = new Map< + string, + { status: number; body: string; contentType: string } + >(); + setGitHubResponseCache({ + get: async (u) => store.get(u) ?? null, + set: async (u, v) => void store.set(u, v), + }); + vi.stubGlobal("fetch", async () => new Response("nope", { status: 500 })); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); + await expect(getAppInstallation(env, 99)).rejects.toThrow(); + expect(store.has("https://api.github.com/app/installations/99")).toBe( + false, + ); // non-200 not cached + }); +}); diff --git a/test/unit/selfhost-redis-response-cache.test.ts b/test/unit/selfhost-redis-response-cache.test.ts new file mode 100644 index 0000000000..fa127e9e35 --- /dev/null +++ b/test/unit/selfhost-redis-response-cache.test.ts @@ -0,0 +1,71 @@ +import type { Redis } from "ioredis"; +import { describe, expect, it } from "vitest"; +import { createRedisResponseCache } from "../../src/selfhost/redis-response-cache"; + +function fakeRedis(): { + redis: Redis; + store: Map; + ttl: () => number; +} { + const store = new Map(); + let lastTtl = -1; + const redis = { + async get(k: string) { + return store.get(k) ?? null; + }, + async set(k: string, v: string, _ex: "EX", ttl: number) { + store.set(k, v); + lastTtl = ttl; + return "OK"; + }, + } as unknown as Redis; + return { redis, store, ttl: () => lastTtl }; +} + +const URL_A = "https://api.github.com/repos/o/r/pulls/1"; + +describe("createRedisResponseCache (#perf GitHub GET cache)", () => { + it("get returns null for a missing url", async () => { + expect( + await createRedisResponseCache(fakeRedis().redis, 20).get(URL_A), + ).toBeNull(); + }); + + it("set then get round-trips status/body/content-type with the configured TTL", async () => { + const f = fakeRedis(); + const cache = createRedisResponseCache(f.redis, 30); + await cache.set(URL_A, { + status: 200, + body: '{"x":1}', + contentType: "application/json", + }); + expect(f.ttl()).toBe(30); + expect(await cache.get(URL_A)).toEqual({ + status: 200, + body: '{"x":1}', + contentType: "application/json", + }); + }); + + it("floors the TTL at 1s", async () => { + const f = fakeRedis(); + await createRedisResponseCache(f.redis, 0).set(URL_A, { + status: 200, + body: "{}", + contentType: "application/json", + }); + expect(f.ttl()).toBe(1); + }); + + it("get returns null on malformed JSON", async () => { + const f = fakeRedis(); + f.store.set("gh:resp:" + URL_A, "{nope"); + expect(await createRedisResponseCache(f.redis, 20).get(URL_A)).toBeNull(); + }); + + it("get returns null when the stored shape is wrong", async () => { + const f = fakeRedis(); + f.store.set("gh:resp:" + URL_A, JSON.stringify({ status: "200", body: 1 })); + expect(await createRedisResponseCache(f.redis, 20).get(URL_A)).toBeNull(); + }); +}); diff --git a/test/unit/selfhost-redis-token-cache.test.ts b/test/unit/selfhost-redis-token-cache.test.ts new file mode 100644 index 0000000000..c94b4658fb --- /dev/null +++ b/test/unit/selfhost-redis-token-cache.test.ts @@ -0,0 +1,65 @@ +import type { Redis } from "ioredis"; +import { describe, expect, it } from "vitest"; +import { createRedisTokenCache } from "../../src/selfhost/redis-token-cache"; + +/** Minimal ioredis stand-in that records the TTL passed to set(). */ +function fakeRedis(): { + redis: Redis; + store: Map; + ttl: () => number; +} { + const store = new Map(); + let lastTtl = -1; + const redis = { + async get(k: string) { + return store.get(k) ?? null; + }, + async set(k: string, v: string, _ex: "EX", ttl: number) { + store.set(k, v); + lastTtl = ttl; + return "OK"; + }, + } as unknown as Redis; + return { redis, store, ttl: () => lastTtl }; +} + +describe("createRedisTokenCache (#perf installation-token persistence)", () => { + it("get returns null for a missing installation", async () => { + const { redis } = fakeRedis(); + expect(await createRedisTokenCache(redis).get(42)).toBeNull(); + }); + + it("set then get round-trips the token + expiry, with TTL ~ the token lifetime", async () => { + const f = fakeRedis(); + const cache = createRedisTokenCache(f.redis); + const expiresAtMs = Date.now() + 3_600_000; + await cache.set(7, { token: "tok", expiresAtMs }); + expect(f.ttl()).toBeGreaterThan(3500); // ~3600s + expect(f.ttl()).toBeLessThanOrEqual(3600); + expect(await cache.get(7)).toEqual({ token: "tok", expiresAtMs }); + }); + + it("floors the TTL at 1s for an already-near-expiry token", async () => { + const f = fakeRedis(); + await createRedisTokenCache(f.redis).set(1, { + token: "t", + expiresAtMs: Date.now() - 5000, + }); + expect(f.ttl()).toBe(1); + }); + + it("get returns null on malformed JSON", async () => { + const f = fakeRedis(); + f.store.set("gh:insttoken:9", "{not json"); + expect(await createRedisTokenCache(f.redis).get(9)).toBeNull(); + }); + + it("get returns null when the stored shape is wrong", async () => { + const f = fakeRedis(); + f.store.set( + "gh:insttoken:9", + JSON.stringify({ token: 123, expiresAtMs: "soon" }), + ); + expect(await createRedisTokenCache(f.redis).get(9)).toBeNull(); + }); +});