From 3285ef9c2577db36d100a37d950292d385585c98 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 17 Jun 2026 02:08:30 -0700 Subject: [PATCH] fix(reliability): authenticate public GitHub profile fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes #790 — the timeout half shipped in #817. fetchPublicContributorProfile's two api.github.com calls were unauthenticated (60/hr ceiling), so the 500-login contributor-evidence loop could exhaust it and silently return source: unavailable. Thread an optional env through the 8 callers and send Authorization: Bearer when GITHUB_PUBLIC_TOKEN is set, lifting the ceiling to 5000/hr (mirrors the existing fetchPublicRepoStats). Signature stays backward-compatible (env is optional). Closes #790 --- src/api/routes.ts | 4 ++-- src/github/public.ts | 5 ++++- src/mcp/server.ts | 4 ++-- src/queue/processors.ts | 4 ++-- src/services/agent-orchestrator.ts | 2 +- src/services/decision-pack.ts | 2 +- test/unit/adapters.test.ts | 16 ++++++++++++++++ 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/api/routes.ts b/src/api/routes.ts index b7d19abe75..c3ced94615 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -2148,7 +2148,7 @@ export function createApp() { const unauthorized = await requireContributorAccess(c, login); if (unauthorized) return unauthorized; const [github, pullRequests, issues, cachedRepoStats, gittensorSnapshot] = await Promise.all([ - fetchPublicContributorProfile(login), + fetchPublicContributorProfile(login, c.env), listContributorPullRequests(c.env, login), listContributorIssues(c.env, login), listContributorRepoStats(c.env, login), @@ -3784,7 +3784,7 @@ async function loadOpenQueueCounts(env: Env, fullName: string): Promise<{ openIs async function loadContributorFastContext(env: Env, login: string) { const [github, contributorPullRequests, contributorIssues, repositories, syncStates, syncSegments, cachedRepoStats, gittensorSnapshot] = await Promise.all([ - fetchPublicContributorProfile(login), + fetchPublicContributorProfile(login, env), listContributorPullRequests(env, login), listContributorIssues(env, login), listRepositories(env), diff --git a/src/github/public.ts b/src/github/public.ts index c66fc5d19e..82b9d50e18 100644 --- a/src/github/public.ts +++ b/src/github/public.ts @@ -59,12 +59,15 @@ const repoStatsCache = new Map(); // indefinitely (mirrors GITHUB_FETCH_TIMEOUT_MS in src/github/app.ts) (#790). const GITHUB_PUBLIC_FETCH_TIMEOUT_MS = 12_000; -export async function fetchPublicContributorProfile(login: string): Promise { +export async function fetchPublicContributorProfile(login: string, env?: Pick): Promise { const safeLogin = encodeURIComponent(login); const headers = { accept: "application/vnd.github+json", "user-agent": "gittensory/0.1", "x-github-api-version": "2022-11-28", + // Authenticated requests lift the 60/hr unauthenticated ceiling to 5000/hr so the 500-login evidence + // loop doesn't exhaust it and silently degrade (mirrors fetchPublicRepoStats) (#790). + ...(env?.GITHUB_PUBLIC_TOKEN ? { authorization: `Bearer ${env.GITHUB_PUBLIC_TOKEN}` } : {}), }; try { const signal = AbortSignal.timeout(GITHUB_PUBLIC_FETCH_TIMEOUT_MS); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a3b97d51d0..0fc7235fed 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1485,7 +1485,7 @@ export class GittensoryMcp { private async getContributorProfile(login: string): Promise { this.requireContributorAccess(login); const [github, pullRequests, issues, cachedRepoStats, gittensorSnapshot] = await Promise.all([ - fetchPublicContributorProfile(login), + fetchPublicContributorProfile(login, this.env), listContributorPullRequests(this.env, login), listContributorIssues(this.env, login), listContributorRepoStats(this.env, login), @@ -2026,7 +2026,7 @@ export class GittensoryMcp { private async loadContributorFastContext(login: string) { const [github, contributorPullRequests, contributorIssues, repositories, syncStates, cachedRepoStats, gittensorSnapshot] = await Promise.all([ - fetchPublicContributorProfile(login), + fetchPublicContributorProfile(login, this.env), listContributorPullRequests(this.env, login), listContributorIssues(this.env, login), listRepositories(this.env), diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 9a3fa48514..ac63b70928 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -407,7 +407,7 @@ async function buildContributorEvidence(env: Env, login?: string): Promise // 500-login batch and poison-pill the queue on retry (#787). try { const [github, contributorPullRequests, contributorIssues, cachedRepoStats, gittensorSnapshot] = await Promise.all([ - fetchPublicContributorProfile(contributorLogin), + fetchPublicContributorProfile(contributorLogin, env), listContributorPullRequests(env, contributorLogin), listContributorIssues(env, contributorLogin), listContributorRepoStats(env, contributorLogin), @@ -1270,7 +1270,7 @@ async function maybePublishPrPublicSurface( if (!prelimHasPublicOutput) return; if (publicSurfaceSkipped || !official || !author) return; - const [github] = await Promise.all([fetchPublicContributorProfile(author)]); + const [github] = await Promise.all([fetchPublicContributorProfile(author, env)]); const contributorPullRequests: Awaited> = []; const contributorIssues: Awaited> = []; const repoStats: Awaited> = official.status === "confirmed" ? contributorRepoStatsFromGittensor(official.snapshot) : []; diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index 7886490053..6640cd8259 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -326,7 +326,7 @@ async function executeLocalBranchRun(env: Env, run: AgentRunRecord, kind: string async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Promise { const [github, contributorPullRequests, contributorIssues, repositories, syncStates, cachedRepoStats, gittensorSnapshot, repo, issues, pullRequests, recentMergedPullRequests, bounties, scoringSnapshot, issueQuality, repoManifest] = await Promise.all([ - fetchPublicContributorProfile(input.login), + fetchPublicContributorProfile(input.login, env), listContributorPullRequests(env, input.login), listContributorIssues(env, input.login), listRepositories(env), diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts index 08e7a51f55..dedca3f071 100644 --- a/src/services/decision-pack.ts +++ b/src/services/decision-pack.ts @@ -423,7 +423,7 @@ export async function buildAndPersistContributorDecisionPack(env: Env, login: st // The heavy full-table reads are login-independent; reuse caller-provided context (batch job) or load once here (single-login run). const { repositories, syncStates, syncSegments, totals, allIssues, allPullRequests, bounties, scoringSnapshot } = shared ?? (await loadDecisionPackSharedInputs(env)); const [github, contributorPullRequests, contributorIssues, cachedRepoStats, gittensorSnapshot] = await Promise.all([ - fetchPublicContributorProfile(login), + fetchPublicContributorProfile(login, env), listContributorPullRequests(env, login), listContributorIssues(env, login), listContributorRepoStats(env, login), diff --git a/test/unit/adapters.test.ts b/test/unit/adapters.test.ts index 4cef6a5c5e..86255b66d6 100644 --- a/test/unit/adapters.test.ts +++ b/test/unit/adapters.test.ts @@ -123,4 +123,20 @@ describe("small adapters and normalizers", () => { vi.stubGlobal("fetch", async () => new Response("nope", { status: 500 })); await expect(fetchPublicContributorProfile("missing")).resolves.toMatchObject({ login: "missing", source: "unavailable", topLanguages: [] }); }); + + it("authenticates public profile requests with GITHUB_PUBLIC_TOKEN to lift the rate ceiling (#790)", async () => { + const authHeaders: Array = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + authHeaders.push(new Headers(init?.headers).get("authorization")); + const url = input.toString(); + if (url.endsWith("/users/dev")) return Response.json({ login: "dev", public_repos: 0, followers: 0 }); + return Response.json([]); + }); + await fetchPublicContributorProfile("dev", { GITHUB_PUBLIC_TOKEN: "public-token" }); + expect(authHeaders).toEqual(["Bearer public-token", "Bearer public-token"]); + + authHeaders.length = 0; + await fetchPublicContributorProfile("dev"); + expect(authHeaders).toEqual([null, null]); + }); });