From c704850700bc4ecfa48ed0d07bf7eab4dc762ea9 Mon Sep 17 00:00:00 2001 From: sfitzgerald-x1 <116673636+sfitzgerald-x1@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:06:34 -0700 Subject: [PATCH 1/3] Handle resource-limited GitHub profiles --- lib/github/client.ts | 93 +++++++++++++++++++++++++++++++++++++++++++- tests/client.test.ts | 53 +++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/lib/github/client.ts b/lib/github/client.ts index ee6100a..07e1d59 100644 --- a/lib/github/client.ts +++ b/lib/github/client.ts @@ -17,7 +17,7 @@ import { tokenPool, pickToken, pickFailover, recordTokenHealth, benchToken, type // the years in small parallel batches with a retry — and tolerate a dropped // batch (the figure is only used log-scaled, so a missing year barely moves it). -export type GithubErrorType = "invalid" | "notfound" | "ratelimit" | "network" | "config"; +export type GithubErrorType = "invalid" | "notfound" | "ratelimit" | "resource" | "network" | "config"; export interface GithubError { type: GithubErrorType; @@ -124,6 +124,16 @@ interface UserNode { }; } +type ProfileBasicsNode = Omit; + +interface FallbackContributionTotalNode { + recent: { + contributionCalendar: { + totalContributions: number; + }; + }; +} + interface YearContrib { totalCommitContributions: number; totalIssueContributions: number; @@ -193,6 +203,12 @@ async function gql(query: string, login: string, tok: PoolToken, retries = 1) benchToken(tok.idx, res.headers); return fail("ratelimit", "GitHub rate limit hit. Try again shortly."); } + if (body.errors?.some((e) => e.type === "RESOURCE_LIMITS_EXCEEDED")) { + return fail("resource", "GitHub query was too expensive for this profile."); + } + if (body.errors?.length) { + return fail("network", body.errors[0]?.message ?? "GitHub returned a GraphQL error."); + } return { user: body.data?.user ?? null }; } return fail("network", "GitHub request failed."); // unreachable; satisfies the type checker @@ -228,6 +244,35 @@ function profileQuery(): string { }`; } +function profileBasicsQuery(): string { + return ` + query ProfileBasics($login: String!) { + user(login: $login) { + login + name + avatarUrl(size: 480) + location + createdAt + followers { totalCount } + repositories(ownerAffiliations: OWNER, isFork: false, first: 100, orderBy: { field: STARGAZERS, direction: DESC }) { + totalCount + nodes { nameWithOwner stargazerCount primaryLanguage { name } createdAt pushedAt } + } + } + }`; +} + +function fallbackContributionTotalQuery(): string { + return ` + query FallbackContributionTotal($login: String!) { + user(login: $login) { + recent: contributionsCollection { + contributionCalendar { totalContributions } + } + } + }`; +} + function lifetimeQuery(years: number[], currentYear: number, nowIso: string): string { const aliases = years .map((y) => { @@ -290,6 +335,44 @@ async function fetchLifetime( return sums.reduce((a, b) => a + b, 0); } +function fallbackRecent(totalContributions: number): UserNode["recent"] { + return { + // When GitHub refuses the detailed contribution query, keep the card scouted + // with the cheap public contribution total rather than misreporting 404. + totalCommitContributions: totalContributions, + totalPullRequestContributions: 0, + totalPullRequestReviewContributions: 0, + totalIssueContributions: 0, + restrictedContributionsCount: 0, + commitContributionsByRepository: [], + contributionCalendar: { weeks: [] }, + }; +} + +async function fetchFallbackContributionTotal(login: string, tok: PoolToken): Promise { + try { + const { user } = await gql(fallbackContributionTotalQuery(), login, tok, 0); + return user?.recent.contributionCalendar.totalContributions ?? 0; + } catch (e) { + const err = e as GithubError; + if (err.type === "resource" || err.type === "network") return 0; + throw e; + } +} + +async function fetchResourceLimitedProfile(login: string, tok: PoolToken): Promise { + const { user } = await gql(profileBasicsQuery(), login, tok); + if (!user) return fail("notfound", "No GitHub user by that name."); + + const contributionTotal = await fetchFallbackContributionTotal(login, tok); + const fallbackUser: UserNode = { ...user, recent: fallbackRecent(contributionTotal) }; + + // Lifetime needs many contribution windows, the same family of resolvers that + // already exceeded GitHub's resource budget. Use the last-year total as a + // conservative lower bound so the profile renders inside the request budget. + return normalize(fallbackUser, contributionTotal); +} + export async function fetchProfile(username: string, now = new Date()): Promise { const login = username.trim().replace(/^@/, ""); if (!VALID.test(login)) return fail("invalid", "That doesn't look like a GitHub username."); @@ -305,13 +388,19 @@ export async function fetchProfile(username: string, now = new Date()): Promise< try { ({ user } = await gql(profileQuery(), login, tok)); } catch (e) { + if ((e as GithubError).type === "resource") return fetchResourceLimitedProfile(login, tok); // Only a rate limit is cured by another token (a timeout or 5xx would just // fail again) — retry once on the healthiest token, if the pool has one. if ((e as GithubError).type !== "ratelimit" || pool.length < 2) throw e; const fallback = await pickFailover(tok.idx, pool); if (!fallback) throw e; // every other token is benched too tok = fallback; - ({ user } = await gql(profileQuery(), login, tok)); + try { + ({ user } = await gql(profileQuery(), login, tok)); + } catch (retryErr) { + if ((retryErr as GithubError).type === "resource") return fetchResourceLimitedProfile(login, tok); + throw retryErr; + } } if (!user) return fail("notfound", "No GitHub user by that name."); diff --git a/tests/client.test.ts b/tests/client.test.ts index 98d43ef..64788c4 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -41,6 +41,7 @@ const USER = { totalPullRequestReviewContributions: 0, totalIssueContributions: 0, restrictedContributionsCount: 0, + commitContributionsByRepository: [], contributionCalendar: { weeks: [] }, }, }; @@ -128,6 +129,58 @@ describe("fetchProfile token pool", () => { expect(calls[1].token).not.toBe(primary); }); + it("falls back to a lean card when GitHub rejects the full contribution query as too expensive", async () => { + const basicUser = { + login: USER.login, + name: USER.name, + avatarUrl: USER.avatarUrl, + location: USER.location, + createdAt: USER.createdAt, + followers: USER.followers, + repositories: USER.repositories, + }; + scriptFetch((_token, body) => { + if (body.includes("query ProfileBasics(")) return ok({ data: { user: basicUser } }); + if (body.includes("query FallbackContributionTotal(")) { + return ok({ + data: { + user: { + recent: { + contributionCalendar: { totalContributions: 6065 }, + }, + }, + }, + }); + } + if (body.includes("query Profile(")) { + return ok({ + data: { user: null }, + errors: [{ type: "RESOURCE_LIMITS_EXCEEDED", message: "Resource limits for this query exceeded." }], + }); + } + return ok({ data: { user: {} } }); + }); + + const payload = await fetchProfile(LOGIN, NOW); + + expect(payload.login).toBe(LOGIN); + expect(payload.recentCommits).toBe(6065); + expect(payload.lifetimeContributions).toBe(6065); + expect(calls.some((c) => c.body.includes("query ProfileBasics("))).toBe(true); + expect(calls.some((c) => c.body.includes("query FallbackContributionTotal("))).toBe(true); + expect(calls.some((c) => c.body.includes("query Lifetime("))).toBe(false); + }); + + it("surfaces GraphQL execution errors instead of treating null partial data as not found", async () => { + scriptFetch((_token, body) => + body.includes("query Profile(") + ? ok({ data: { user: null }, errors: [{ type: "SOMETHING_ELSE", message: "field failed" }] }) + : okFor(body), + ); + + await expect(fetchProfile(LOGIN, NOW)).rejects.toMatchObject({ type: "network", message: "field failed" }); + }); + it("propagates the rate limit when every other token is benched", async () => { const reset = nowSec() + 1200; for (let i = 0; i < POOL.length; i++) { From a40886fd002186ea86b833afee5e6d7505d22864 Mon Sep 17 00:00:00 2001 From: sfitzgerald-x1 <116673636+sfitzgerald-x1@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:41:07 -0700 Subject: [PATCH 2/3] Recover detailed data for resource-limited profiles --- lib/github/client.ts | 212 +++++++++++++++++++++++++++++++++++++------ tests/client.test.ts | 42 +++++++-- 2 files changed, 217 insertions(+), 37 deletions(-) diff --git a/lib/github/client.ts b/lib/github/client.ts index 07e1d59..a901570 100644 --- a/lib/github/client.ts +++ b/lib/github/client.ts @@ -69,6 +69,9 @@ const ENDPOINT = "https://api.github.com/graphql"; const VALID = /^(?=.*[a-z\d])[a-z\d-]{1,39}$/i; const GITHUB_EPOCH_YEAR = 2008; // GitHub launched Feb 2008; no account predates it. const LIFETIME_BATCH = 4; // contribution windows per request — stays well under GitHub's timeout. +const DAY_MS = 86_400_000; +const RESOURCE_RECENT_WINDOW_DAYS = 21; +const RESOURCE_WINDOW_CONCURRENCY = 4; // A repo only feeds the language signal once the user has really worked in it — // this many commits in the last year — so a single drive-by typo fix to a large // polyglot repo can't inflate their language diversity. @@ -126,14 +129,26 @@ interface UserNode { type ProfileBasicsNode = Omit; -interface FallbackContributionTotalNode { - recent: { +type RecentNode = UserNode["recent"]; +type RepoContribution = RecentNode["commitContributionsByRepository"][number]; + +interface RecentWindowNode { + c: RecentNode; +} + +interface ContributionTotalNode { + c: { contributionCalendar: { totalContributions: number; }; }; } +interface ContributionWindow { + from: string; + to: string; +} + interface YearContrib { totalCommitContributions: number; totalIssueContributions: number; @@ -262,11 +277,31 @@ function profileBasicsQuery(): string { }`; } -function fallbackContributionTotalQuery(): string { +function recentWindowQuery(from: string, to: string): string { return ` - query FallbackContributionTotal($login: String!) { + query ResourceRecentWindow($login: String!) { user(login: $login) { - recent: contributionsCollection { + c: contributionsCollection(from: "${from}", to: "${to}") { + totalCommitContributions + totalPullRequestContributions + totalPullRequestReviewContributions + totalIssueContributions + restrictedContributionsCount + commitContributionsByRepository(maxRepositories: 100) { + contributions { totalCount } + repository { nameWithOwner isFork isPrivate primaryLanguage { name } } + } + contributionCalendar { weeks { contributionDays { contributionCount } } } + } + } + }`; +} + +function contributionTotalQuery(from: string, to: string): string { + return ` + query LifetimeContributionTotal($login: String!) { + user(login: $login) { + c: contributionsCollection(from: "${from}", to: "${to}") { contributionCalendar { totalContributions } } } @@ -294,6 +329,98 @@ function chunk(arr: T[], size: number): T[][] { return out; } +async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const results = new Array(items.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const i = next++; + results[i] = await fn(items[i]); + } + }), + ); + return results; +} + +function startOfUtcDay(date: Date): Date { + const d = new Date(date); + d.setUTCHours(0, 0, 0, 0); + return d; +} + +function endOfUtcDay(date: Date): Date { + const d = new Date(date); + d.setUTCHours(23, 59, 59, 999); + return d; +} + +function addUtcDays(date: Date, days: number): Date { + return new Date(date.getTime() + days * DAY_MS); +} + +function contributionWindows(from: Date, to: Date, days: number): ContributionWindow[] { + const windows: ContributionWindow[] = []; + for (let start = startOfUtcDay(from); start <= to; start = addUtcDays(start, days)) { + const end = new Date(Math.min(endOfUtcDay(addUtcDays(start, days - 1)).getTime(), to.getTime())); + windows.push({ from: start.toISOString(), to: end.toISOString() }); + } + return windows; +} + +function splitWindow(w: ContributionWindow): [ContributionWindow, ContributionWindow] | null { + const from = new Date(w.from); + const to = new Date(w.to); + if (to.getTime() - from.getTime() < DAY_MS) return null; + const mid = endOfUtcDay(new Date(from.getTime() + Math.floor((to.getTime() - from.getTime()) / 2))); + return [ + { from: from.toISOString(), to: mid.toISOString() }, + { from: addUtcDays(startOfUtcDay(mid), 1).toISOString(), to: to.toISOString() }, + ]; +} + +function emptyRecent(): RecentNode { + return { + totalCommitContributions: 0, + totalPullRequestContributions: 0, + totalPullRequestReviewContributions: 0, + totalIssueContributions: 0, + restrictedContributionsCount: 0, + commitContributionsByRepository: [], + contributionCalendar: { weeks: [] }, + }; +} + +function combineRecent(parts: RecentNode[]): RecentNode { + const out = emptyRecent(); + const repos = new Map(); + + for (const p of parts) { + out.totalCommitContributions += p.totalCommitContributions; + out.totalPullRequestContributions += p.totalPullRequestContributions; + out.totalPullRequestReviewContributions += p.totalPullRequestReviewContributions; + out.totalIssueContributions += p.totalIssueContributions; + out.restrictedContributionsCount += p.restrictedContributionsCount; + out.contributionCalendar.weeks.push(...p.contributionCalendar.weeks); + + for (const c of p.commitContributionsByRepository ?? []) { + const key = c.repository.nameWithOwner; + const existing = repos.get(key); + if (existing) { + existing.contributions.totalCount += c.contributions.totalCount; + } else { + repos.set(key, { + contributions: { totalCount: c.contributions.totalCount }, + repository: c.repository, + }); + } + } + } + + out.commitContributionsByRepository = [...repos.values()]; + return out; +} + // Sum of every year's contributions (commits + issues + PRs + reviews + private). // Each batch is best-effort: a batch that fails after its retry contributes 0 // rather than failing the scout. @@ -335,42 +462,67 @@ async function fetchLifetime( return sums.reduce((a, b) => a + b, 0); } -function fallbackRecent(totalContributions: number): UserNode["recent"] { - return { - // When GitHub refuses the detailed contribution query, keep the card scouted - // with the cheap public contribution total rather than misreporting 404. - totalCommitContributions: totalContributions, - totalPullRequestContributions: 0, - totalPullRequestReviewContributions: 0, - totalIssueContributions: 0, - restrictedContributionsCount: 0, - commitContributionsByRepository: [], - contributionCalendar: { weeks: [] }, - }; +async function fetchRecentWindow(login: string, tok: PoolToken, w: ContributionWindow): Promise { + try { + const { user } = await gql(recentWindowQuery(w.from, w.to), login, tok, 0); + return user?.c ?? emptyRecent(); + } catch (e) { + const err = e as GithubError; + if (err.type === "resource") { + const split = splitWindow(w); + if (split) return combineRecent(await Promise.all(split.map((part) => fetchRecentWindow(login, tok, part)))); + return emptyRecent(); + } + if (err.type === "network") return emptyRecent(); + throw e; + } } -async function fetchFallbackContributionTotal(login: string, tok: PoolToken): Promise { +async function fetchWindowedRecent(login: string, tok: PoolToken, now: Date): Promise { + const from = new Date(now.getTime() - 365 * DAY_MS); + const windows = contributionWindows(from, now, RESOURCE_RECENT_WINDOW_DAYS); + return combineRecent(await mapLimit(windows, RESOURCE_WINDOW_CONCURRENCY, (w) => fetchRecentWindow(login, tok, w))); +} + +async function fetchContributionTotal(login: string, tok: PoolToken, w: ContributionWindow): Promise { try { - const { user } = await gql(fallbackContributionTotalQuery(), login, tok, 0); - return user?.recent.contributionCalendar.totalContributions ?? 0; + const { user } = await gql(contributionTotalQuery(w.from, w.to), login, tok, 0); + return user?.c.contributionCalendar.totalContributions ?? 0; } catch (e) { const err = e as GithubError; - if (err.type === "resource" || err.type === "network") return 0; + if (err.type === "resource") { + const split = splitWindow(w); + if (split) return (await Promise.all(split.map((part) => fetchContributionTotal(login, tok, part)))).reduce((a, b) => a + b, 0); + return 0; + } + if (err.type === "network") return 0; throw e; } } -async function fetchResourceLimitedProfile(login: string, tok: PoolToken): Promise { +async function fetchLifetimeTotals(login: string, tok: PoolToken, createdYear: number, currentYear: number, now: Date): Promise { + const years: ContributionWindow[] = []; + for (let y = Math.max(createdYear, GITHUB_EPOCH_YEAR); y <= currentYear; y++) { + years.push({ + from: `${y}-01-01T00:00:00.000Z`, + to: y === currentYear ? now.toISOString() : `${y}-12-31T23:59:59.999Z`, + }); + } + const totals = await mapLimit(years, RESOURCE_WINDOW_CONCURRENCY, (w) => fetchContributionTotal(login, tok, w)); + return totals.reduce((a, b) => a + b, 0); +} + +async function fetchResourceLimitedProfile(login: string, tok: PoolToken, now: Date): Promise { const { user } = await gql(profileBasicsQuery(), login, tok); if (!user) return fail("notfound", "No GitHub user by that name."); - const contributionTotal = await fetchFallbackContributionTotal(login, tok); - const fallbackUser: UserNode = { ...user, recent: fallbackRecent(contributionTotal) }; + const createdYear = new Date(user.createdAt).getUTCFullYear(); + const [recent, lifetimeContributions] = await Promise.all([ + fetchWindowedRecent(login, tok, now), + fetchLifetimeTotals(login, tok, createdYear, now.getUTCFullYear(), now), + ]); - // Lifetime needs many contribution windows, the same family of resolvers that - // already exceeded GitHub's resource budget. Use the last-year total as a - // conservative lower bound so the profile renders inside the request budget. - return normalize(fallbackUser, contributionTotal); + return normalize({ ...user, recent }, lifetimeContributions); } export async function fetchProfile(username: string, now = new Date()): Promise { @@ -388,7 +540,7 @@ export async function fetchProfile(username: string, now = new Date()): Promise< try { ({ user } = await gql(profileQuery(), login, tok)); } catch (e) { - if ((e as GithubError).type === "resource") return fetchResourceLimitedProfile(login, tok); + if ((e as GithubError).type === "resource") return fetchResourceLimitedProfile(login, tok, now); // Only a rate limit is cured by another token (a timeout or 5xx would just // fail again) — retry once on the healthiest token, if the pool has one. if ((e as GithubError).type !== "ratelimit" || pool.length < 2) throw e; @@ -398,7 +550,7 @@ export async function fetchProfile(username: string, now = new Date()): Promise< try { ({ user } = await gql(profileQuery(), login, tok)); } catch (retryErr) { - if ((retryErr as GithubError).type === "resource") return fetchResourceLimitedProfile(login, tok); + if ((retryErr as GithubError).type === "resource") return fetchResourceLimitedProfile(login, tok, now); throw retryErr; } } diff --git a/tests/client.test.ts b/tests/client.test.ts index 64788c4..3f34f12 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -129,7 +129,7 @@ describe("fetchProfile token pool", () => { expect(calls[1].token).not.toBe(primary); }); - it("falls back to a lean card when GitHub rejects the full contribution query as too expensive", async () => { + it("falls back to windowed contribution data when GitHub rejects the full query as too expensive", async () => { const basicUser = { login: USER.login, name: USER.name, @@ -141,17 +141,36 @@ describe("fetchProfile token pool", () => { }; scriptFetch((_token, body) => { if (body.includes("query ProfileBasics(")) return ok({ data: { user: basicUser } }); - if (body.includes("query FallbackContributionTotal(")) { + if (body.includes("query ResourceRecentWindow(")) { return ok({ data: { user: { - recent: { - contributionCalendar: { totalContributions: 6065 }, + c: { + totalCommitContributions: 1, + totalPullRequestContributions: 2, + totalPullRequestReviewContributions: 3, + totalIssueContributions: 4, + restrictedContributionsCount: 5, + commitContributionsByRepository: [ + { + contributions: { totalCount: 1 }, + repository: { + nameWithOwner: "acme/api", + isFork: false, + isPrivate: false, + primaryLanguage: { name: "Go" }, + }, + }, + ], + contributionCalendar: { weeks: [{ contributionDays: [{ contributionCount: 1 }, { contributionCount: 0 }] }] }, }, }, }, }); } + if (body.includes("query LifetimeContributionTotal(")) { + return ok({ data: { user: { c: { contributionCalendar: { totalContributions: 100 } } } } }); + } if (body.includes("query Profile(")) { return ok({ data: { user: null }, @@ -162,12 +181,21 @@ describe("fetchProfile token pool", () => { }); const payload = await fetchProfile(LOGIN, NOW); + const recentWindowCalls = calls.filter((c) => c.body.includes("query ResourceRecentWindow(")).length; + const lifetimeCalls = calls.filter((c) => c.body.includes("query LifetimeContributionTotal(")).length; expect(payload.login).toBe(LOGIN); - expect(payload.recentCommits).toBe(6065); - expect(payload.lifetimeContributions).toBe(6065); + expect(payload.recentCommits).toBe(recentWindowCalls); + expect(payload.recentPRs).toBe(recentWindowCalls * 2); + expect(payload.recentReviews).toBe(recentWindowCalls * 3); + expect(payload.recentIssues).toBe(recentWindowCalls * 4); + expect(payload.recentRestricted).toBe(recentWindowCalls * 5); + expect(payload.recentActiveDays).toBe(recentWindowCalls); + expect(payload.lifetimeContributions).toBe(lifetimeCalls * 100); + expect(payload.languageRepos).toContainEqual({ language: "Go" }); expect(calls.some((c) => c.body.includes("query ProfileBasics("))).toBe(true); - expect(calls.some((c) => c.body.includes("query FallbackContributionTotal("))).toBe(true); + expect(recentWindowCalls).toBeGreaterThan(0); + expect(lifetimeCalls).toBeGreaterThan(0); expect(calls.some((c) => c.body.includes("query Lifetime("))).toBe(false); }); From fbdc3d72c651be5e79ec01337515ea6dc177d5a5 Mon Sep 17 00:00:00 2001 From: sfitzgerald-x1 <116673636+sfitzgerald-x1@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:10:23 -0700 Subject: [PATCH 3/3] Preserve not-found and partial GraphQL responses --- lib/github/client.ts | 43 +++++++++++++++++++++++-------------- tests/client.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/lib/github/client.ts b/lib/github/client.ts index a901570..eb3b379 100644 --- a/lib/github/client.ts +++ b/lib/github/client.ts @@ -136,12 +136,8 @@ interface RecentWindowNode { c: RecentNode; } -interface ContributionTotalNode { - c: { - contributionCalendar: { - totalContributions: number; - }; - }; +interface ContributionTotalsNode { + c: YearContrib; } interface ContributionWindow { @@ -218,10 +214,13 @@ async function gql(query: string, login: string, tok: PoolToken, retries = 1) benchToken(tok.idx, res.headers); return fail("ratelimit", "GitHub rate limit hit. Try again shortly."); } - if (body.errors?.some((e) => e.type === "RESOURCE_LIMITS_EXCEEDED")) { + if (body.errors?.length && body.errors.every((e) => e.type === "NOT_FOUND")) { + return { user: null }; + } + if (body.errors?.some((e) => e.type === "RESOURCE_LIMITS_EXCEEDED") && !body.data?.user) { return fail("resource", "GitHub query was too expensive for this profile."); } - if (body.errors?.length) { + if (body.errors?.length && !body.data?.user) { return fail("network", body.errors[0]?.message ?? "GitHub returned a GraphQL error."); } return { user: body.data?.user ?? null }; @@ -299,10 +298,14 @@ function recentWindowQuery(from: string, to: string): string { function contributionTotalQuery(from: string, to: string): string { return ` - query LifetimeContributionTotal($login: String!) { + query LifetimeContributionTotals($login: String!) { user(login: $login) { c: contributionsCollection(from: "${from}", to: "${to}") { - contributionCalendar { totalContributions } + totalCommitContributions + totalIssueContributions + totalPullRequestContributions + totalPullRequestReviewContributions + restrictedContributionsCount } } }`; @@ -361,9 +364,11 @@ function addUtcDays(date: Date, days: number): Date { function contributionWindows(from: Date, to: Date, days: number): ContributionWindow[] { const windows: ContributionWindow[] = []; - for (let start = startOfUtcDay(from); start <= to; start = addUtcDays(start, days)) { + let start = new Date(from); + while (start <= to) { const end = new Date(Math.min(endOfUtcDay(addUtcDays(start, days - 1)).getTime(), to.getTime())); windows.push({ from: start.toISOString(), to: end.toISOString() }); + start = new Date(end.getTime() + 1); } return windows; } @@ -464,13 +469,13 @@ async function fetchLifetime( async function fetchRecentWindow(login: string, tok: PoolToken, w: ContributionWindow): Promise { try { - const { user } = await gql(recentWindowQuery(w.from, w.to), login, tok, 0); + const { user } = await gql(recentWindowQuery(w.from, w.to), login, tok); return user?.c ?? emptyRecent(); } catch (e) { const err = e as GithubError; if (err.type === "resource") { const split = splitWindow(w); - if (split) return combineRecent(await Promise.all(split.map((part) => fetchRecentWindow(login, tok, part)))); + if (split) return combineRecent([await fetchRecentWindow(login, tok, split[0]), await fetchRecentWindow(login, tok, split[1])]); return emptyRecent(); } if (err.type === "network") return emptyRecent(); @@ -486,13 +491,19 @@ async function fetchWindowedRecent(login: string, tok: PoolToken, now: Date): Pr async function fetchContributionTotal(login: string, tok: PoolToken, w: ContributionWindow): Promise { try { - const { user } = await gql(contributionTotalQuery(w.from, w.to), login, tok, 0); - return user?.c.contributionCalendar.totalContributions ?? 0; + const { user } = await gql(contributionTotalQuery(w.from, w.to), login, tok); + return user?.c + ? user.c.totalCommitContributions + + user.c.totalIssueContributions + + user.c.totalPullRequestContributions + + user.c.totalPullRequestReviewContributions + + user.c.restrictedContributionsCount + : 0; } catch (e) { const err = e as GithubError; if (err.type === "resource") { const split = splitWindow(w); - if (split) return (await Promise.all(split.map((part) => fetchContributionTotal(login, tok, part)))).reduce((a, b) => a + b, 0); + if (split) return (await fetchContributionTotal(login, tok, split[0])) + (await fetchContributionTotal(login, tok, split[1])); return 0; } if (err.type === "network") return 0; diff --git a/tests/client.test.ts b/tests/client.test.ts index 3f34f12..068baae 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -168,8 +168,20 @@ describe("fetchProfile token pool", () => { }, }); } - if (body.includes("query LifetimeContributionTotal(")) { - return ok({ data: { user: { c: { contributionCalendar: { totalContributions: 100 } } } } }); + if (body.includes("query LifetimeContributionTotals(")) { + return ok({ + data: { + user: { + c: { + totalCommitContributions: 70, + totalIssueContributions: 10, + totalPullRequestContributions: 15, + totalPullRequestReviewContributions: 2, + restrictedContributionsCount: 3, + }, + }, + }, + }); } if (body.includes("query Profile(")) { return ok({ @@ -182,7 +194,7 @@ describe("fetchProfile token pool", () => { const payload = await fetchProfile(LOGIN, NOW); const recentWindowCalls = calls.filter((c) => c.body.includes("query ResourceRecentWindow(")).length; - const lifetimeCalls = calls.filter((c) => c.body.includes("query LifetimeContributionTotal(")).length; + const lifetimeCalls = calls.filter((c) => c.body.includes("query LifetimeContributionTotals(")).length; expect(payload.login).toBe(LOGIN); expect(payload.recentCommits).toBe(recentWindowCalls); @@ -209,6 +221,38 @@ describe("fetchProfile token pool", () => { await expect(fetchProfile(LOGIN, NOW)).rejects.toMatchObject({ type: "network", message: "field failed" }); }); + it("keeps real GitHub NOT_FOUND responses on the notfound path", async () => { + scriptFetch((_token, body) => + body.includes("query Profile(") + ? ok({ + data: { user: null }, + errors: [ + { + type: "NOT_FOUND", + path: ["user"], + message: "Could not resolve to a User with the login of 'missing-user'.", + }, + ], + }) + : okFor(body), + ); + + await expect(fetchProfile("missing-user", NOW)).rejects.toMatchObject({ type: "notfound" }); + }); + + it("uses partial GraphQL data when field-level errors still include a profile", async () => { + scriptFetch((_token, body) => + body.includes("query Profile(") + ? ok({ + data: { user: USER }, + errors: [{ type: "FORBIDDEN", message: "Resource protected by organization SAML enforcement." }], + }) + : okFor(body), + ); + + await expect(fetchProfile(LOGIN, NOW)).resolves.toMatchObject({ login: LOGIN }); + }); + it("propagates the rate limit when every other token is benched", async () => { const reset = nowSec() + 1200; for (let i = 0; i < POOL.length; i++) {