From 04be3687b7044fefbd19530207802b5e42d40dc3 Mon Sep 17 00:00:00 2001 From: Martin Bhatta <95274465+martinbha@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:05:11 +0900 Subject: [PATCH 1/4] fix: derive active years from contribution history --- lib/github/client.ts | 67 ++++++++++++------ lib/github/signals.ts | 13 ++-- tests/client.test.ts | 153 +++++++++++++++++++++++++++++++++++++++++- tests/engine.test.ts | 11 +++ tests/signals.test.ts | 53 +++++++++++++++ 5 files changed, 265 insertions(+), 32 deletions(-) diff --git a/lib/github/client.ts b/lib/github/client.ts index 7fb6b84..9c5b63d 100644 --- a/lib/github/client.ts +++ b/lib/github/client.ts @@ -72,6 +72,7 @@ export interface RawPayload { recentRestricted: number; // last-year private contributions (count only) recentActiveDays: number; lifetimeContributions: number; // all years, all types, incl. private + activeYears: number; // calendar years where GitHub reports any contribution activity } const ENDPOINT = "https://api.github.com/graphql"; @@ -146,6 +147,17 @@ interface YearContrib { totalPullRequestContributions: number; totalPullRequestReviewContributions: number; restrictedContributionsCount: number; + hasAnyContributions: boolean; +} + +interface LifetimeSummary { + totalContributions: number; + activeYears: number; +} + +interface YearResult { + contributions: number; + active: boolean; } // POSTs a query, retrying transient failures. Terminal failures (bad token, @@ -437,7 +449,7 @@ function lifetimeQuery( const aliases = years .map((y) => { const to = y === currentYear ? nowIso : `${y}-12-31T23:59:59Z`; - return ` y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${to}") { totalCommitContributions totalIssueContributions totalPullRequestContributions totalPullRequestReviewContributions restrictedContributionsCount }`; + return ` y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${to}") { totalCommitContributions totalIssueContributions totalPullRequestContributions totalPullRequestReviewContributions restrictedContributionsCount hasAnyContributions }`; }) .join("\n"); return ` @@ -461,8 +473,9 @@ const sumContrib = (c: YearContrib) => c.totalPullRequestReviewContributions + c.restrictedContributionsCount; -// 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 +// Summarize every year's contributions (commits + issues + PRs + reviews + +// private) while the annual boundaries still exist. Each batch is best-effort: +// a year that remains unavailable after its retry contributes 0 and is inactive // rather than failing the scout. async function fetchLifetime( login: string, @@ -470,39 +483,52 @@ async function fetchLifetime( createdYear: number, currentYear: number, nowIso: string, -): Promise { +): Promise { const years: number[] = []; for (let y = Math.max(createdYear, GITHUB_EPOCH_YEAR); y <= currentYear; y++) years.push(y); - const yearsTotal = async (batch: number[]): Promise => { + const fetchYears = async (batch: number[]): Promise => { const { user } = await gql>( lifetimeQuery(batch, currentYear, nowIso), login, tok, ); - if (!user) return 0; - return batch.reduce((s, y) => { - const c = user[`y${y}`]; - return c ? s + sumContrib(c) : s; - }, 0); + return batch.map((year) => { + const collection = user?.[`y${year}`]; + return collection + ? { + contributions: sumContrib(collection), + active: collection.hasAnyContributions, + } + : { contributions: 0, active: false }; + }); }; - const sums = await Promise.all( + const batches = await Promise.all( chunk(years, LIFETIME_BATCH).map(async (batch) => { try { - return await yearsTotal(batch); + return await fetchYears(batch); } catch { // Aliased years pool their resource cost, so one hyperactive year sinks // its whole batch — retry each year alone (own request, own budget) and // let only the truly over-budget years degrade to 0. - const singles = await Promise.all( - batch.map((y) => yearsTotal([y]).catch(() => 0)), + return Promise.all( + batch.map(async (year) => { + try { + return (await fetchYears([year]))[0]; + } catch { + return { contributions: 0, active: false }; + } + }), ); - return singles.reduce((a, b) => a + b, 0); } }), ); - return sums.reduce((a, b) => a + b, 0); + const results = batches.flat(); + return { + totalContributions: results.reduce((total, year) => total + year.contributions, 0), + activeYears: results.filter((year) => year.active).length, + }; } export async function fetchProfile( @@ -547,7 +573,7 @@ export async function fetchProfile( if (!user) return fail("notfound", "No GitHub user by that name."); const createdYear = new Date(user.createdAt).getUTCFullYear(); - const lifetimeContributions = await fetchLifetime( + const lifetime = await fetchLifetime( login, tok, createdYear, @@ -555,10 +581,10 @@ export async function fetchProfile( now.toISOString(), ); - return normalize(user, lifetimeContributions); + return normalize(user, lifetime); } -function normalize(user: UserNode, lifetimeContributions: number): RawPayload { +function normalize(user: UserNode, lifetime: LifetimeSummary): RawPayload { const repos: RawRepo[] = user.repositories.nodes.map((n) => ({ stars: n.stargazerCount ?? 0, language: n.primaryLanguage?.name ?? null, @@ -608,6 +634,7 @@ function normalize(user: UserNode, lifetimeContributions: number): RawPayload { recentIssues: user.recent.totalIssueContributions, recentRestricted: user.recent.restrictedContributionsCount, recentActiveDays, - lifetimeContributions, + lifetimeContributions: lifetime.totalContributions, + activeYears: lifetime.activeYears, }; } diff --git a/lib/github/signals.ts b/lib/github/signals.ts index 9dcacf8..3754a01 100644 --- a/lib/github/signals.ts +++ b/lib/github/signals.ts @@ -3,7 +3,6 @@ import { rankLanguages } from "./languages"; import type { Signals } from "@/lib/scoring/types"; const YEAR_MS = 31557600000; -const yearOf = (iso: string) => (iso ? new Date(iso).getUTCFullYear() : null); // Maps the (already real, already flattened) GraphQL payload onto the scoring // signals. No estimation — every field traces back to a GitHub number. @@ -21,14 +20,10 @@ export function signalsFromPayload(p: RawPayload, now = Date.now()): Signals { // styling/markup (CSS/HTML) — the #1 drives the card's language + logo. const rankedLanguages = rankLanguages(p.languageRepos); - const years = new Set(); - for (const r of p.repos) { - const c = yearOf(r.createdAt); - const pushed = yearOf(r.pushedAt); - if (c) years.add(c); - if (pushed) years.add(pushed); - } - const active_years = Math.min(Math.max(years.size, 1), Math.ceil(account_age_years) || 1); + // GitHub reports this over the same annual contribution collections used for + // the lifetime total, so organization and private activity count even when no + // personally-owned repository changed that year. + const active_years = Math.min(Math.max(p.activeYears, 0), Math.ceil(account_age_years)); // Recent activity over the last year: every contribution type GitHub exposes, // including the private (restricted) count, so it matches the profile graph. diff --git a/tests/client.test.ts b/tests/client.test.ts index eff7898..52814a8 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -21,6 +21,17 @@ import { hashLogin } from "@/lib/github/tokens"; const POOL = ["tokA", "tokB", "tokC", "tokD"]; const NOW = new Date("2026-07-03T12:00:00Z"); const LOGIN = "someuser"; +const yearsFrom = (start: number, end: number) => + Array.from({ length: end - start + 1 }, (_, index) => start + index); +const queryYears = (body: string) => [...body.matchAll(/y(\d{4}):/g)].map((match) => Number(match[1])); +const yearCollection = (contributions: number, active: boolean) => ({ + totalCommitContributions: contributions, + totalIssueContributions: 0, + totalPullRequestContributions: 0, + totalPullRequestReviewContributions: 0, + restrictedContributionsCount: 0, + hasAnyContributions: active, +}); const flush = () => new Promise((r) => setTimeout(r, 0)); const healthKey = (idx: number) => `gitfut:ghtoken:v1:${idx}`; const nowSec = () => Math.floor(Date.now() / 1000); @@ -359,13 +370,19 @@ describe("fetchProfile GraphQL error triage + resource-limit fallback", () => { }); it("retries a resource-limited lifetime batch year by year", async () => { + const requestedYears = yearsFrom(new Date(USER.createdAt).getUTCFullYear(), NOW.getUTCFullYear()); const YEAR = { totalCommitContributions: 5, totalIssueContributions: 1, totalPullRequestContributions: 2, totalPullRequestReviewContributions: 1, restrictedContributionsCount: 1, + hasAnyContributions: true, }; + const contributionsPerYear = Object.values(YEAR).reduce( + (total, value) => total + (typeof value === "number" ? value : 0), + 0, + ); scriptFetch((_t, body) => { if (body.includes("query Profile(")) return ok({ data: { user: USER } }); if (body.includes("query Lifetime(")) { @@ -378,9 +395,139 @@ describe("fetchProfile GraphQL error triage + resource-limit fallback", () => { }); const payload = await fetchProfile(LOGIN, NOW); - // createdAt 2023 -> years 2023..2026: 1 failed batch + 4 single-year retries. - expect(calls.filter((c) => c.body.includes("query Lifetime(")).length).toBe(5); - expect(payload.lifetimeContributions).toBe(10 * 4); + expect(calls.filter((c) => c.body.includes("query Lifetime(")).length).toBe(1 + requestedYears.length); + expect(payload.lifetimeContributions).toBe(contributionsPerYear * requestedYears.length); + expect(payload.activeYears).toBe(requestedYears.length); + }); +}); + +describe("fetchProfile lifetime contribution years", () => { + const startYear = 2021; + const accountYears = yearsFrom(startYear, NOW.getUTCFullYear()); + const contributionCount = (year: number) => year - startYear + 1; + const ownedNode = (year: number) => ({ + nameWithOwner: `${LOGIN}/repo-${year}`, + stargazerCount: 0, + primaryLanguage: null, + createdAt: `${year}-01-01T00:00:00Z`, + pushedAt: `${year}-12-01T00:00:00Z`, + }); + const userWithOwnedRepoYears = (ownedRepoYears: Set) => ({ + ...USER, + createdAt: `${startYear}-01-01T00:00:00Z`, + repositories: { + totalCount: ownedRepoYears.size, + nodes: [...ownedRepoYears].map(ownedNode), + }, + }); + const collectionsFor = ( + body: string, + contributionYears: Set, + missingYears = new Set(), + ) => + Object.fromEntries( + queryYears(body) + .filter((year) => !missingYears.has(year)) + .map((year) => [ + `y${year}`, + yearCollection(contributionYears.has(year) ? contributionCount(year) : 0, contributionYears.has(year)), + ]), + ); + const lifetimeResourceError = () => + ok({ + data: { user: null }, + errors: [{ type: "RESOURCE_LIMITS_EXCEEDED", message: "Resource limits for this query exceeded." }], + }); + + it("derives active years from annual activity rather than owned-repository dates", async () => { + const contributionYears = new Set(accountYears); + const ownedRepoYears = new Set([accountYears[0], ...accountYears.slice(-3)]); + const user = userWithOwnedRepoYears(ownedRepoYears); + + scriptFetch((_token, body) => { + if (body.includes("query Profile(")) return ok({ data: { user } }); + return ok({ data: { user: collectionsFor(body, contributionYears) } }); + }); + + const result = await fetchProfile(LOGIN, NOW); + const lifetimeCalls = calls.filter((call) => call.body.includes("query Lifetime(")); + + expect(lifetimeCalls.every((call) => call.body.includes("hasAnyContributions"))).toBe(true); + expect(result.activeYears).toBe(contributionYears.size); + expect(result.activeYears).not.toBe(ownedRepoYears.size); + }); + + it("counts only non-contiguous years where GitHub reports activity", async () => { + const contributionYears = new Set(accountYears.filter((_year, index) => index % 2 === 0)); + const user = userWithOwnedRepoYears(new Set(accountYears)); + + scriptFetch((_token, body) => + body.includes("query Profile(") + ? ok({ data: { user } }) + : ok({ data: { user: collectionsFor(body, contributionYears) } }), + ); + + const result = await fetchProfile(LOGIN, NOW); + const expectedLifetime = [...contributionYears].reduce((total, year) => total + contributionCount(year), 0); + + expect(result.activeYears).toBe(contributionYears.size); + expect(result.lifetimeContributions).toBe(expectedLifetime); + }); + + it("retains recovered years when a batch falls back to individual requests", async () => { + const contributionYears = new Set(accountYears); + const user = userWithOwnedRepoYears(new Set()); + + scriptFetch((_token, body) => { + if (body.includes("query Profile(")) return ok({ data: { user } }); + if (queryYears(body).length > 1) return lifetimeResourceError(); + return ok({ data: { user: collectionsFor(body, contributionYears) } }); + }); + + const result = await fetchProfile(LOGIN, NOW); + const expectedLifetime = [...contributionYears].reduce((total, year) => total + contributionCount(year), 0); + + expect(result.activeYears).toBe(contributionYears.size); + expect(result.lifetimeContributions).toBe(expectedLifetime); + }); + + it("treats an individually unavailable year as zero and inactive", async () => { + const contributionYears = new Set(accountYears); + const unavailableYears = new Set([accountYears[Math.floor(accountYears.length / 2)]]); + const user = userWithOwnedRepoYears(new Set()); + + scriptFetch((_token, body) => { + if (body.includes("query Profile(")) return ok({ data: { user } }); + const requestedYears = queryYears(body); + if (requestedYears.length > 1 || unavailableYears.has(requestedYears[0])) return lifetimeResourceError(); + return ok({ data: { user: collectionsFor(body, contributionYears) } }); + }); + + const result = await fetchProfile(LOGIN, NOW); + const recoveredYears = accountYears.filter((year) => !unavailableYears.has(year)); + const expectedLifetime = recoveredYears.reduce((total, year) => total + contributionCount(year), 0); + + expect(result.activeYears).toBe(recoveredYears.length); + expect(result.lifetimeContributions).toBe(expectedLifetime); + }); + + it("treats an omitted annual collection as zero and inactive", async () => { + const contributionYears = new Set(accountYears); + const missingYears = new Set([accountYears[Math.floor(accountYears.length / 2)]]); + const user = userWithOwnedRepoYears(new Set()); + + scriptFetch((_token, body) => + body.includes("query Profile(") + ? ok({ data: { user } }) + : ok({ data: { user: collectionsFor(body, contributionYears, missingYears) } }), + ); + + const result = await fetchProfile(LOGIN, NOW); + const returnedYears = accountYears.filter((year) => !missingYears.has(year)); + const expectedLifetime = returnedYears.reduce((total, year) => total + contributionCount(year), 0); + + expect(result.activeYears).toBe(returnedYears.length); + expect(result.lifetimeContributions).toBe(expectedLifetime); }); }); diff --git a/tests/engine.test.ts b/tests/engine.test.ts index 8c54101..c050fbc 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -69,3 +69,14 @@ describe("buildCard — founder overrides", () => { expect(card.founder).toBeUndefined(); }); }); + +describe("buildCard — recent contribution spike", () => { + it("promotes an otherwise identical gold card to in-form", () => { + const steady = buildCard({ ...base, recent_spike: false }); + const spiking = buildCard({ ...base, recent_spike: true }); + + expect(spiking.overall).toBe(steady.overall); + expect(steady.finish).toBe("gold"); + expect(spiking.finish).toBe("totw"); + }); +}); diff --git a/tests/signals.test.ts b/tests/signals.test.ts index 646d934..2ed477f 100644 --- a/tests/signals.test.ts +++ b/tests/signals.test.ts @@ -32,6 +32,7 @@ const payload = (over: Partial = {}): RawPayload => ({ recentRestricted: 0, recentActiveDays: 0, lifetimeContributions: 0, + activeYears: 0, ...over, }); @@ -86,3 +87,55 @@ describe("signalsFromPayload — language diversity", () => { expect(s.topLanguage).toBeNull(); }); }); + +describe("signalsFromPayload — active contribution years", () => { + const yearsFrom = (start: number, end: number) => + Array.from({ length: end - start + 1 }, (_, index) => start + index); + + it("uses annual contribution activity instead of owned-repository dates", () => { + const contributionYears = yearsFrom(2021, 2026); + const ownedRepoYears = new Set([contributionYears[0], ...contributionYears.slice(-3)]); + const repos = [...ownedRepoYears].map((year) => + repo({ + createdAt: `${year}-01-01T00:00:00Z`, + pushedAt: `${year}-12-01T00:00:00Z`, + }), + ); + + const s = signalsFromPayload(payload({ repos, activeYears: contributionYears.length }), NOW); + + expect(s.active_years).toBe(contributionYears.length); + expect(s.active_years).not.toBe(ownedRepoYears.size); + }); + + it("preserves zero when GitHub reports no active contribution years", () => { + const s = signalsFromPayload(payload({ repos: [repo()], activeYears: 0 }), NOW); + + expect(s.active_years).toBe(0); + }); + + it("uses contribution years at the recent-spike decision boundary", () => { + const contributionYears = yearsFrom(2021, 2026); + const ownedRepoYears = new Set([contributionYears[0], ...contributionYears.slice(-3)]); + const lifetimeContributions = 3_600; + const recentContributions = 1_600; + const correctedThreshold = 2 * (lifetimeContributions / contributionYears.length); + const repositoryThreshold = 2 * (lifetimeContributions / ownedRepoYears.size); + + expect(recentContributions).toBeGreaterThan(correctedThreshold); + expect(recentContributions).toBeLessThanOrEqual(repositoryThreshold); + + const s = signalsFromPayload( + payload({ + createdAt: "2021-01-01T00:00:00Z", + activeYears: contributionYears.length, + lifetimeContributions, + recentCommits: recentContributions, + }), + NOW, + ); + + expect(s.active_years).toBe(contributionYears.length); + expect(s.recent_spike).toBe(true); + }); +}); From 44493390970b226e1cb8a78703de3e3ea9d4bca5 Mon Sep 17 00:00:00 2001 From: Martin Bhatta <95274465+martinbha@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:05:35 +0900 Subject: [PATCH 2/4] fix: align distribution sampling with contribution years --- scripts/distribution-runner.ts | 80 ++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/scripts/distribution-runner.ts b/scripts/distribution-runner.ts index ebfbbb4..e599946 100644 --- a/scripts/distribution-runner.ts +++ b/scripts/distribution-runner.ts @@ -46,6 +46,7 @@ const TOKEN = process.env.GH_TOKEN!; const MAX_ID = 300_000_000; const ENDPOINT = "https://api.github.com/graphql"; const CONCURRENCY = 8; +const LIFETIME_BATCH = 4; const DIST_MIN = 50; const FLUSH_EVERY = 100; @@ -156,50 +157,82 @@ type YearContrib = { totalPullRequestContributions: number; totalPullRequestReviewContributions: number; restrictedContributionsCount: number; + hasAnyContributions: boolean; +}; + +type YearResult = { + contributions: number; + active: boolean; +}; + +type LifetimeSummary = { + totalContributions: number; + activeYears: number; }; function lifetimeQuery(years: number[], currentYear: number, nowIso: string): string { const aliases = years .map((y) => { const to = y === currentYear ? nowIso : `${y}-12-31T23:59:59Z`; - return `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${to}") { totalCommitContributions totalIssueContributions totalPullRequestContributions totalPullRequestReviewContributions restrictedContributionsCount }`; + return `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${to}") { totalCommitContributions totalIssueContributions totalPullRequestContributions totalPullRequestReviewContributions restrictedContributionsCount hasAnyContributions }`; }) .join("\n"); return `query Lifetime($login: String!) { user(login: $login) { ${aliases} } }`; } -async function fetchLifetime(login: string, createdYear: number): Promise { +async function fetchLifetime(login: string, createdYear: number): Promise { const now = new Date(); const currentYear = now.getUTCFullYear(); const years: number[] = []; for (let y = Math.max(createdYear, 2008); y <= currentYear; y++) years.push(y); const batches: number[][] = []; - for (let i = 0; i < years.length; i += 5) batches.push(years.slice(i, i + 5)); - const sums = await Promise.all( - batches.map(async (batch) => { - const u = await gql>(lifetimeQuery(batch, currentYear, now.toISOString()), login); - if (!u) return 0; - return batch.reduce((s, y) => { - const c = u[`y${y}`]; - return c - ? s + - c.totalCommitContributions + - c.totalIssueContributions + - c.totalPullRequestContributions + - c.totalPullRequestReviewContributions + - c.restrictedContributionsCount - : s; - }, 0); - }), - ); - return sums.reduce((a, b) => a + b, 0); + for (let i = 0; i < years.length; i += LIFETIME_BATCH) batches.push(years.slice(i, i + LIFETIME_BATCH)); + + const fetchYears = async (batch: number[]): Promise => { + const user = await gql>( + lifetimeQuery(batch, currentYear, now.toISOString()), + login, + ); + if (!user) return null; + return batch.map((year) => { + const collection = user[`y${year}`]; + return collection + ? { + contributions: + collection.totalCommitContributions + + collection.totalIssueContributions + + collection.totalPullRequestContributions + + collection.totalPullRequestReviewContributions + + collection.restrictedContributionsCount, + active: collection.hasAnyContributions, + } + : { contributions: 0, active: false }; + }); + }; + + const results = ( + await Promise.all( + batches.map(async (batch) => { + const grouped = await fetchYears(batch); + if (grouped) return grouped; + return Promise.all( + batch.map(async (year) => (await fetchYears([year]))?.[0] ?? { contributions: 0, active: false }), + ); + }), + ) + ).flat(); + + return { + totalContributions: results.reduce((total, year) => total + year.contributions, 0), + activeYears: results.filter((year) => year.active).length, + }; } async function fetchPayload(login: string): Promise { const user = await gql(PROFILE_QUERY, login); if (!user) return null; const createdYear = new Date(user.createdAt).getUTCFullYear(); - const lifetimeContributions = await fetchLifetime(login, createdYear); + const lifetime = await fetchLifetime(login, createdYear); const repos: RawRepo[] = user.repositories.nodes.map((n) => ({ stars: n.stargazerCount ?? 0, language: n.primaryLanguage?.name ?? null, @@ -241,7 +274,8 @@ async function fetchPayload(login: string): Promise { recentIssues: user.recent.totalIssueContributions, recentRestricted: user.recent.restrictedContributionsCount, recentActiveDays, - lifetimeContributions, + lifetimeContributions: lifetime.totalContributions, + activeYears: lifetime.activeYears, }; } From 38e8a3e6c07df4bfe9238d25e9db8d91a668bbb4 Mon Sep 17 00:00:00 2001 From: Martin Bhatta <95274465+martinbha@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:07:04 +0900 Subject: [PATCH 3/4] fix: cap activity by inclusive calendar years --- lib/github/signals.ts | 5 ++++- tests/client.test.ts | 1 + tests/signals.test.ts | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/github/signals.ts b/lib/github/signals.ts index 3754a01..427b73c 100644 --- a/lib/github/signals.ts +++ b/lib/github/signals.ts @@ -23,7 +23,10 @@ export function signalsFromPayload(p: RawPayload, now = Date.now()): Signals { // GitHub reports this over the same annual contribution collections used for // the lifetime total, so organization and private activity count even when no // personally-owned repository changed that year. - const active_years = Math.min(Math.max(p.activeYears, 0), Math.ceil(account_age_years)); + const createdYear = new Date(p.createdAt).getUTCFullYear(); + const currentYear = new Date(now).getUTCFullYear(); + const accountCalendarYears = Math.max(0, currentYear - createdYear + 1); + const active_years = Math.min(Math.max(p.activeYears, 0), accountCalendarYears); // Recent activity over the last year: every contribution type GitHub exposes, // including the private (restricted) count, so it matches the profile graph. diff --git a/tests/client.test.ts b/tests/client.test.ts index 52814a8..c3546ef 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -452,6 +452,7 @@ describe("fetchProfile lifetime contribution years", () => { const result = await fetchProfile(LOGIN, NOW); const lifetimeCalls = calls.filter((call) => call.body.includes("query Lifetime(")); + expect(lifetimeCalls).toHaveLength(Math.ceil(accountYears.length / 4)); expect(lifetimeCalls.every((call) => call.body.includes("hasAnyContributions"))).toBe(true); expect(result.activeYears).toBe(contributionYears.size); expect(result.activeYears).not.toBe(ownedRepoYears.size); diff --git a/tests/signals.test.ts b/tests/signals.test.ts index 2ed477f..7674bf0 100644 --- a/tests/signals.test.ts +++ b/tests/signals.test.ts @@ -114,6 +114,20 @@ describe("signalsFromPayload — active contribution years", () => { expect(s.active_years).toBe(0); }); + it("allows activity in every inclusive calendar year for late-year accounts", () => { + const contributionYears = yearsFrom(2021, 2026); + const s = signalsFromPayload( + payload({ + createdAt: "2021-12-31T23:59:59Z", + activeYears: contributionYears.length, + }), + NOW, + ); + + expect(s.account_age_years).toBeLessThan(contributionYears.length - 1); + expect(s.active_years).toBe(contributionYears.length); + }); + it("uses contribution years at the recent-spike decision boundary", () => { const contributionYears = yearsFrom(2021, 2026); const ownedRepoYears = new Set([contributionYears[0], ...contributionYears.slice(-3)]); From dfdf9dc781efa27333cdeeb44eaf6710afb68190 Mon Sep 17 00:00:00 2001 From: Martin Bhatta <95274465+martinbha@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:08:37 +0900 Subject: [PATCH 4/4] test: decouple active-year coverage from batching --- lib/github/client.ts | 10 +++++----- tests/client.test.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/github/client.ts b/lib/github/client.ts index 9c5b63d..2e718bc 100644 --- a/lib/github/client.ts +++ b/lib/github/client.ts @@ -14,8 +14,8 @@ import { tokenPool, pickToken, pickFailover, recordTokenHealth, benchToken, type // Lifetime contributions need one contributionsCollection window per calendar // year (each window must span ≤1yr). GitHub's resolver times out (~10s) if too // many windows share a request, so we fetch the profile in one fast query, then -// 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). +// the years in small parallel batches. Failed batches retry year by year; an +// individually unavailable year becomes zero/inactive so the scout stays usable. // // GitHub also rejects contributionsCollection outright (RESOURCE_LIMITS_EXCEEDED) // when the window holds too many contribution events — measured mid-2026 at @@ -509,9 +509,9 @@ async function fetchLifetime( try { return await fetchYears(batch); } catch { - // Aliased years pool their resource cost, so one hyperactive year sinks - // its whole batch — retry each year alone (own request, own budget) and - // let only the truly over-budget years degrade to 0. + // Aliased years pool their resource cost, so one unavailable year can + // sink its whole batch. Retry each year with its own request and budget, + // then degrade only individually unavailable years to zero/inactive. return Promise.all( batch.map(async (year) => { try { diff --git a/tests/client.test.ts b/tests/client.test.ts index c3546ef..8fa90a4 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -452,7 +452,7 @@ describe("fetchProfile lifetime contribution years", () => { const result = await fetchProfile(LOGIN, NOW); const lifetimeCalls = calls.filter((call) => call.body.includes("query Lifetime(")); - expect(lifetimeCalls).toHaveLength(Math.ceil(accountYears.length / 4)); + expect(lifetimeCalls.length).toBeGreaterThan(0); expect(lifetimeCalls.every((call) => call.body.includes("hasAnyContributions"))).toBe(true); expect(result.activeYears).toBe(contributionYears.size); expect(result.activeYears).not.toBe(ownedRepoYears.size);