From f3bcdbe85f9abc5686edd06db44fe9d530b2eded Mon Sep 17 00:00:00 2001 From: Santosh Yadav Date: Thu, 16 Jul 2026 23:02:27 +0200 Subject: [PATCH 1/8] feat: batch GitHub API calls into single GraphQL queries Replace per-year API calls with batched endpoints that use GraphQL field aliases to fetch all years in one request. This fixes 'Resource limits exceeded' errors for users with many years of activity. New server endpoints: - POST /api/github/contributions-batch - POST /api/github/repository-contributions-batch Updated frontend service methods (getAggregatedActivity, getRepositoryInsights) to use the batch endpoints, reducing N+1 API calls down to 2 total (contributions + repo contributions). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/app/core/services/github.service.ts | 52 +++- yourstory/src/server.ts | 287 ++++++++++++++++++ 2 files changed, 329 insertions(+), 10 deletions(-) diff --git a/yourstory/src/app/core/services/github.service.ts b/yourstory/src/app/core/services/github.service.ts index d1a7200..2779913 100644 --- a/yourstory/src/app/core/services/github.service.ts +++ b/yourstory/src/app/core/services/github.service.ts @@ -28,6 +28,10 @@ interface ContributionsResponse { privateContributions: number; } +interface ContributionsBatchResponse { + years: ContributionsResponse[]; +} + interface DiscussionsResponse { lifetimeDiscussions: number; lifetimeDiscussionComments: number; @@ -38,6 +42,10 @@ interface RepositoryContributionsResponse { topRepositories: RepositoryContribution[]; } +interface RepositoryContributionsBatchResponse { + years: RepositoryContributionsResponse[]; +} + @Injectable({ providedIn: 'root' }) export class GitHubService { private readonly http = inject(HttpClient); @@ -77,6 +85,26 @@ export class GitHubService { ); } + /** + * Fetches contributions for multiple years in a single API call using GraphQL aliases. + */ + getContributionsBatch(username: string, years: number[]): Observable { + return this.http.post('/api/github/contributions-batch', { + username, + years, + }); + } + + /** + * Fetches repository contributions for multiple years in a single API call. + */ + getRepositoryContributionsBatch(username: string, years: number[]): Observable { + return this.http.post('/api/github/repository-contributions-batch', { + username, + years, + }); + } + /** * Fetches timeline milestone data for a given username. */ @@ -118,7 +146,8 @@ export class GitHubService { /** * Fetches contributions from the user's account creation year to the current year, - * capped at a maximum of 10 years, then aggregates the numeric fields by summing them. + * then aggregates the numeric fields by summing them. + * Uses a single batched GraphQL call instead of per-year requests. * Discussion counts are included once — they are already lifetime totals. * * @param username - GitHub username to fetch data for. @@ -132,7 +161,6 @@ export class GitHubService { if (createdAt) { startYear = new Date(createdAt).getFullYear(); } else { - // Fallback: last 4 years (original behaviour) startYear = currentYear - 3; } @@ -142,11 +170,14 @@ export class GitHubService { } return forkJoin({ - contributions: forkJoin(years.map((year) => this.getContributions(username, year))), + contributionsBatch: this.getContributionsBatch(username, years), discussions: this.getDiscussions(username), - repoContributions: forkJoin(years.map((year) => this.getRepositoryContributions(username, year))), + repoContributionsBatch: this.getRepositoryContributionsBatch(username, years), }).pipe( - map(({ contributions, discussions, repoContributions }) => { + map(({ contributionsBatch, discussions, repoContributionsBatch }) => { + const contributions = contributionsBatch.years; + const repoContributions = repoContributionsBatch.years; + // Merge repository contributions across years, deduplicating by nameWithOwner const repoMap = new Map(); for (const yearData of repoContributions) { @@ -179,8 +210,9 @@ export class GitHubService { } /** - * Fetches repository contribution insights from account creation year to current year, - * capped at a maximum of 10 years, and merges per-repository activity across years. + * Fetches repository contribution insights from account creation year to current year + * and merges per-repository activity across years. + * Uses a single batched GraphQL call instead of per-year requests. * * @param username - GitHub username to fetch data for. * @param createdAt - ISO 8601 date string of the GitHub account creation date. @@ -201,11 +233,11 @@ export class GitHubService { years.push(y); } - return forkJoin(years.map((year) => this.getRepositoryContributions(username, year))).pipe( - map((yearlyResults) => { + return this.getRepositoryContributionsBatch(username, years).pipe( + map((batchResponse) => { const repoMap = new Map(); - for (const yearData of yearlyResults) { + for (const yearData of batchResponse.years) { const year = yearData.year; for (const repo of yearData.topRepositories) { const yearlyContribution: YearlyRepoContribution = { diff --git a/yourstory/src/server.ts b/yourstory/src/server.ts index 01087fe..b4a9030 100644 --- a/yourstory/src/server.ts +++ b/yourstory/src/server.ts @@ -364,6 +364,289 @@ async function handleRepositoryContributions( } } +/** + * POST /api/github/contributions-batch + * Body: { username: string, years: number[] } + * + * Fetches contributions for all requested years in a single GraphQL call + * using field aliases, avoiding per-year API calls that hit resource limits. + */ +async function handleContributionsBatch( + request: Request, + env: Env, +): Promise { + const body = (await request.json()) as { username?: string; years?: number[] }; + const login = body.username ?? ''; + if (!login) return json({ error: 'username is required' }, 400); + + const token = env.GITHUB_TOKEN; + if (!token) return json({ error: 'GITHUB_TOKEN is not configured' }, 503); + + const years = body.years ?? [new Date().getFullYear()]; + if (years.length === 0) return json({ error: 'years array is empty' }, 400); + + // Build aliased fragments for each year + const contribFields = ` + totalCommitContributions + totalIssueContributions + totalPullRequestContributions + totalPullRequestReviewContributions + restrictedContributionsCount + `; + + const yearFragments = years.map( + (y) => + `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${y}-12-31T23:59:59Z") { ${contribFields} }` + ).join('\n '); + + const query = ` + query($login: String!) { + user(login: $login) { + ${yearFragments} + } + } + `; + + try { + const response = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: `bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'commitstory-app', + }, + body: JSON.stringify({ query, variables: { login } }), + }); + + if (!response.ok) return json({ error: 'GitHub API error' }, response.status); + + type ContributionsCollection = { + totalCommitContributions: number; + totalIssueContributions: number; + totalPullRequestContributions: number; + totalPullRequestReviewContributions: number; + restrictedContributionsCount?: number; + }; + + const data = (await response.json()) as { + data?: { user?: Record }; + errors?: Array<{ message: string; extensions?: { saml_failure?: boolean } }>; + }; + + // If we get SAML errors, retry without restrictedContributionsCount + let privateBlocked = false; + if (data.errors?.length) { + const hasSamlError = data.errors.some((e) => { + const msg = e.message?.toLowerCase() ?? ''; + return e.extensions?.saml_failure === true || msg.includes('saml') || msg.includes('organization'); + }); + + if (!hasSamlError) return json({ error: data.errors[0].message }, 400); + + privateBlocked = true; + const contribFieldsNoPrivate = ` + totalCommitContributions + totalIssueContributions + totalPullRequestContributions + totalPullRequestReviewContributions + `; + + const yearFragmentsFallback = years.map( + (y) => + `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${y}-12-31T23:59:59Z") { ${contribFieldsNoPrivate} }` + ).join('\n '); + + const fallbackQuery = ` + query($login: String!) { + user(login: $login) { + ${yearFragmentsFallback} + } + } + `; + + const fallbackResponse = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: `bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'commitstory-app', + }, + body: JSON.stringify({ query: fallbackQuery, variables: { login } }), + }); + + if (!fallbackResponse.ok) return json({ error: 'GitHub API error' }, fallbackResponse.status); + + const fallbackData = (await fallbackResponse.json()) as typeof data; + if (fallbackData.errors?.length) return json({ error: fallbackData.errors[0].message }, 400); + + const user = fallbackData.data?.user ?? {}; + const results = years.map((y) => { + const c = user[`y${y}`]; + return { + year: y, + commits: c?.totalCommitContributions ?? 0, + issues: c?.totalIssueContributions ?? 0, + pullRequests: c?.totalPullRequestContributions ?? 0, + reviews: c?.totalPullRequestReviewContributions ?? 0, + privateContributions: 0, + ...(privateBlocked ? { privateContributionsBlocked: true } : {}), + }; + }); + + return json({ years: results }); + } + + const user = data.data?.user ?? {}; + const results = years.map((y) => { + const c = user[`y${y}`]; + return { + year: y, + commits: c?.totalCommitContributions ?? 0, + issues: c?.totalIssueContributions ?? 0, + pullRequests: c?.totalPullRequestContributions ?? 0, + reviews: c?.totalPullRequestReviewContributions ?? 0, + privateContributions: c?.restrictedContributionsCount ?? 0, + }; + }); + + return json({ years: results }); + } catch (err) { + console.error('GitHub contributions-batch error:', err); + return json({ error: 'Failed to fetch contributions' }, 500); + } +} + +/** + * POST /api/github/repository-contributions-batch + * Body: { username: string, years: number[] } + * + * Fetches repository contributions for all requested years in a single + * GraphQL call using field aliases. + */ +async function handleRepositoryContributionsBatch( + request: Request, + env: Env, +): Promise { + const body = (await request.json()) as { username?: string; years?: number[] }; + const login = body.username ?? ''; + if (!login) return json({ error: 'username is required' }, 400); + + const token = env.GITHUB_TOKEN; + if (!token) return json({ error: 'GITHUB_TOKEN is not configured' }, 503); + + const years = body.years ?? [new Date().getFullYear()]; + if (years.length === 0) return json({ error: 'years array is empty' }, 400); + + const repoFields = ` + commitContributionsByRepository(maxRepositories: 100) { + contributions { totalCount } + repository { name nameWithOwner url stargazerCount } + } + pullRequestContributionsByRepository(maxRepositories: 100) { + contributions { totalCount } + repository { name nameWithOwner url stargazerCount } + } + `; + + const yearFragments = years.map( + (y) => + `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${y}-12-31T23:59:59Z") { ${repoFields} }` + ).join('\n '); + + const query = ` + query($login: String!) { + user(login: $login) { + ${yearFragments} + } + } + `; + + try { + const response = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: `bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'commitstory-app', + }, + body: JSON.stringify({ query, variables: { login } }), + }); + + if (!response.ok) return json({ error: 'GitHub API error' }, response.status); + + type RepoContribNode = { + contributions: { totalCount: number }; + repository: { name: string; nameWithOwner: string; url: string; stargazerCount: number }; + }; + + type YearCollection = { + commitContributionsByRepository: RepoContribNode[]; + pullRequestContributionsByRepository: RepoContribNode[]; + }; + + const data = (await response.json()) as { + data?: { user?: Record }; + errors?: { message: string }[]; + }; + + if (data.errors?.length) return json({ error: data.errors[0].message }, 400); + + const user = data.data?.user ?? {}; + + interface RepoEntry { + name: string; + nameWithOwner: string; + url: string; + stargazerCount: number; + commits: number; + pullRequests: number; + totalContributions: number; + } + + const results = years.map((y) => { + const collection = user[`y${y}`]; + const commitsByRepo = collection?.commitContributionsByRepository ?? []; + const prsByRepo = collection?.pullRequestContributionsByRepository ?? []; + + const repoMap = new Map(); + + for (const node of commitsByRepo) { + const { name, nameWithOwner, url, stargazerCount } = node.repository; + const commits = node.contributions.totalCount; + const existing = repoMap.get(nameWithOwner); + if (existing) { + existing.commits += commits; + existing.totalContributions += commits; + } else { + repoMap.set(nameWithOwner, { name, nameWithOwner, url, stargazerCount, commits, pullRequests: 0, totalContributions: commits }); + } + } + + for (const node of prsByRepo) { + const { name, nameWithOwner, url, stargazerCount } = node.repository; + const prs = node.contributions.totalCount; + const existing = repoMap.get(nameWithOwner); + if (existing) { + existing.pullRequests += prs; + existing.totalContributions += prs; + } else { + repoMap.set(nameWithOwner, { name, nameWithOwner, url, stargazerCount, commits: 0, pullRequests: prs, totalContributions: prs }); + } + } + + const topRepositories = Array.from(repoMap.values()) + .sort((a, b) => b.totalContributions - a.totalContributions); + + return { year: y, topRepositories }; + }); + + return json({ years: results }); + } catch (err) { + console.error('GitHub repository-contributions-batch error:', err); + return json({ error: 'Failed to fetch repository contributions' }, 500); + } +} + /** * GET /api/github/discussions?username=... */ @@ -1385,6 +1668,10 @@ export default { response = await handleContributions(request, env); } else if (path === '/api/github/repository-contributions' && method === 'GET') { response = await handleRepositoryContributions(request, env); + } else if (path === '/api/github/contributions-batch' && method === 'POST') { + response = await handleContributionsBatch(request, env); + } else if (path === '/api/github/repository-contributions-batch' && method === 'POST') { + response = await handleRepositoryContributionsBatch(request, env); } else if (path === '/api/github/discussions' && method === 'GET') { response = await handleDiscussions(request, env); } else if (path === '/api/github/milestones' && method === 'POST') { From 3183ecf445e9415b809b51b56a523942d9e30aca Mon Sep 17 00:00:00 2001 From: Santosh Yadav Date: Thu, 16 Jul 2026 23:09:50 +0200 Subject: [PATCH 2/8] fix: chunk batched GraphQL queries to avoid node/complexity limits - Contributions batch: chunked into groups of 5 years per query - Repo contributions batch: chunked into groups of 3 years per query - Reduced maxRepositories from 100 to 20 per year to lower node count - Added rateLimit { cost remaining } to queries for debugging - Sequential chunk execution prevents hitting GitHub's resource limits Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- yourstory/src/server.ts | 367 +++++++++++++++++++++------------------- 1 file changed, 195 insertions(+), 172 deletions(-) diff --git a/yourstory/src/server.ts b/yourstory/src/server.ts index b4a9030..2a18818 100644 --- a/yourstory/src/server.ts +++ b/yourstory/src/server.ts @@ -364,12 +364,30 @@ async function handleRepositoryContributions( } } +// ─── Shared helpers for batched GitHub endpoints ────────────────────────────── + +/** Split an array into chunks of at most `size` elements. */ +function chunkArray(arr: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + chunks.push(arr.slice(i, i + size)); + } + return chunks; +} + +/** Max years per GraphQL query for contributions (lightweight – scalars only). */ +const CONTRIBUTIONS_CHUNK_SIZE = 5; +/** Max years per GraphQL query for repo contributions (heavy – nested nodes). */ +const REPO_CONTRIBUTIONS_CHUNK_SIZE = 3; +/** Max repositories to fetch per year in repo-contributions queries. */ +const MAX_REPOSITORIES_PER_YEAR = 20; + /** * POST /api/github/contributions-batch * Body: { username: string, years: number[] } * - * Fetches contributions for all requested years in a single GraphQL call - * using field aliases, avoiding per-year API calls that hit resource limits. + * Fetches contributions for all requested years using GraphQL field aliases. + * Years are chunked to stay within GitHub's query complexity limits. */ async function handleContributionsBatch( request: Request, @@ -385,7 +403,19 @@ async function handleContributionsBatch( const years = body.years ?? [new Date().getFullYear()]; if (years.length === 0) return json({ error: 'years array is empty' }, 400); - // Build aliased fragments for each year + type ContributionsCollection = { + totalCommitContributions: number; + totalIssueContributions: number; + totalPullRequestContributions: number; + totalPullRequestReviewContributions: number; + restrictedContributionsCount?: number; + }; + + type GraphQLResponse = { + data?: { user?: Record }; + errors?: Array<{ message: string; extensions?: { saml_failure?: boolean } }>; + }; + const contribFields = ` totalCommitContributions totalIssueContributions @@ -394,122 +424,112 @@ async function handleContributionsBatch( restrictedContributionsCount `; - const yearFragments = years.map( - (y) => - `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${y}-12-31T23:59:59Z") { ${contribFields} }` - ).join('\n '); - - const query = ` - query($login: String!) { - user(login: $login) { - ${yearFragments} - } - } + const contribFieldsNoPrivate = ` + totalCommitContributions + totalIssueContributions + totalPullRequestContributions + totalPullRequestReviewContributions `; - try { - const response = await fetch('https://api.github.com/graphql', { - method: 'POST', - headers: { - Authorization: `bearer ${token}`, - 'Content-Type': 'application/json', - 'User-Agent': 'commitstory-app', - }, - body: JSON.stringify({ query, variables: { login } }), - }); - - if (!response.ok) return json({ error: 'GitHub API error' }, response.status); + const chunks = chunkArray(years, CONTRIBUTIONS_CHUNK_SIZE); + const allResults: { year: number; commits: number; issues: number; pullRequests: number; reviews: number; privateContributions: number; privateContributionsBlocked?: boolean }[] = []; + let privateBlocked = false; - type ContributionsCollection = { - totalCommitContributions: number; - totalIssueContributions: number; - totalPullRequestContributions: number; - totalPullRequestReviewContributions: number; - restrictedContributionsCount?: number; - }; - - const data = (await response.json()) as { - data?: { user?: Record }; - errors?: Array<{ message: string; extensions?: { saml_failure?: boolean } }>; - }; - - // If we get SAML errors, retry without restrictedContributionsCount - let privateBlocked = false; - if (data.errors?.length) { - const hasSamlError = data.errors.some((e) => { - const msg = e.message?.toLowerCase() ?? ''; - return e.extensions?.saml_failure === true || msg.includes('saml') || msg.includes('organization'); - }); - - if (!hasSamlError) return json({ error: data.errors[0].message }, 400); - - privateBlocked = true; - const contribFieldsNoPrivate = ` - totalCommitContributions - totalIssueContributions - totalPullRequestContributions - totalPullRequestReviewContributions - `; - - const yearFragmentsFallback = years.map( - (y) => - `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${y}-12-31T23:59:59Z") { ${contribFieldsNoPrivate} }` + try { + for (const chunk of chunks) { + const fields = privateBlocked ? contribFieldsNoPrivate : contribFields; + const yearFragments = chunk.map( + (y) => `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${y}-12-31T23:59:59Z") { ${fields} }` ).join('\n '); - const fallbackQuery = ` + const query = ` query($login: String!) { - user(login: $login) { - ${yearFragmentsFallback} - } + rateLimit { cost remaining } + user(login: $login) { ${yearFragments} } } `; - const fallbackResponse = await fetch('https://api.github.com/graphql', { + const response = await fetch('https://api.github.com/graphql', { method: 'POST', headers: { Authorization: `bearer ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'commitstory-app', }, - body: JSON.stringify({ query: fallbackQuery, variables: { login } }), + body: JSON.stringify({ query, variables: { login } }), }); - if (!fallbackResponse.ok) return json({ error: 'GitHub API error' }, fallbackResponse.status); + if (!response.ok) return json({ error: 'GitHub API error' }, response.status); - const fallbackData = (await fallbackResponse.json()) as typeof data; - if (fallbackData.errors?.length) return json({ error: fallbackData.errors[0].message }, 400); + const data = (await response.json()) as GraphQLResponse; + + if (data.errors?.length) { + const hasSamlError = data.errors.some((e) => { + const msg = e.message?.toLowerCase() ?? ''; + return e.extensions?.saml_failure === true || msg.includes('saml') || msg.includes('organization'); + }); + + if (!hasSamlError) return json({ error: data.errors[0].message }, 400); + + // Retry this chunk without restrictedContributionsCount + privateBlocked = true; + const fallbackFragments = chunk.map( + (y) => `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${y}-12-31T23:59:59Z") { ${contribFieldsNoPrivate} }` + ).join('\n '); - const user = fallbackData.data?.user ?? {}; - const results = years.map((y) => { + const fallbackQuery = ` + query($login: String!) { + user(login: $login) { ${fallbackFragments} } + } + `; + + const fallbackResponse = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: `bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'commitstory-app', + }, + body: JSON.stringify({ query: fallbackQuery, variables: { login } }), + }); + + if (!fallbackResponse.ok) return json({ error: 'GitHub API error' }, fallbackResponse.status); + + const fallbackData = (await fallbackResponse.json()) as GraphQLResponse; + if (fallbackData.errors?.length) return json({ error: fallbackData.errors[0].message }, 400); + + const user = fallbackData.data?.user ?? {}; + for (const y of chunk) { + const c = user[`y${y}`]; + allResults.push({ + year: y, + commits: c?.totalCommitContributions ?? 0, + issues: c?.totalIssueContributions ?? 0, + pullRequests: c?.totalPullRequestContributions ?? 0, + reviews: c?.totalPullRequestReviewContributions ?? 0, + privateContributions: 0, + privateContributionsBlocked: true, + }); + } + continue; + } + + const user = data.data?.user ?? {}; + for (const y of chunk) { const c = user[`y${y}`]; - return { + allResults.push({ year: y, commits: c?.totalCommitContributions ?? 0, issues: c?.totalIssueContributions ?? 0, pullRequests: c?.totalPullRequestContributions ?? 0, reviews: c?.totalPullRequestReviewContributions ?? 0, - privateContributions: 0, + privateContributions: privateBlocked ? 0 : (c?.restrictedContributionsCount ?? 0), ...(privateBlocked ? { privateContributionsBlocked: true } : {}), - }; - }); - - return json({ years: results }); + }); + } } - const user = data.data?.user ?? {}; - const results = years.map((y) => { - const c = user[`y${y}`]; - return { - year: y, - commits: c?.totalCommitContributions ?? 0, - issues: c?.totalIssueContributions ?? 0, - pullRequests: c?.totalPullRequestContributions ?? 0, - reviews: c?.totalPullRequestReviewContributions ?? 0, - privateContributions: c?.restrictedContributionsCount ?? 0, - }; - }); - - return json({ years: results }); + return json({ years: allResults }); } catch (err) { console.error('GitHub contributions-batch error:', err); return json({ error: 'Failed to fetch contributions' }, 500); @@ -520,8 +540,8 @@ async function handleContributionsBatch( * POST /api/github/repository-contributions-batch * Body: { username: string, years: number[] } * - * Fetches repository contributions for all requested years in a single - * GraphQL call using field aliases. + * Fetches repository contributions for all requested years using GraphQL + * field aliases. Years are chunked (3 at a time) to stay within node limits. */ async function handleRepositoryContributionsBatch( request: Request, @@ -537,110 +557,113 @@ async function handleRepositoryContributionsBatch( const years = body.years ?? [new Date().getFullYear()]; if (years.length === 0) return json({ error: 'years array is empty' }, 400); + type RepoContribNode = { + contributions: { totalCount: number }; + repository: { name: string; nameWithOwner: string; url: string; stargazerCount: number }; + }; + + type YearCollection = { + commitContributionsByRepository: RepoContribNode[]; + pullRequestContributionsByRepository: RepoContribNode[]; + }; + + interface RepoEntry { + name: string; + nameWithOwner: string; + url: string; + stargazerCount: number; + commits: number; + pullRequests: number; + totalContributions: number; + } + const repoFields = ` - commitContributionsByRepository(maxRepositories: 100) { + commitContributionsByRepository(maxRepositories: ${MAX_REPOSITORIES_PER_YEAR}) { contributions { totalCount } repository { name nameWithOwner url stargazerCount } } - pullRequestContributionsByRepository(maxRepositories: 100) { + pullRequestContributionsByRepository(maxRepositories: ${MAX_REPOSITORIES_PER_YEAR}) { contributions { totalCount } repository { name nameWithOwner url stargazerCount } } `; - const yearFragments = years.map( - (y) => - `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${y}-12-31T23:59:59Z") { ${repoFields} }` - ).join('\n '); - - const query = ` - query($login: String!) { - user(login: $login) { - ${yearFragments} - } - } - `; + const chunks = chunkArray(years, REPO_CONTRIBUTIONS_CHUNK_SIZE); + const allResults: { year: number; topRepositories: RepoEntry[] }[] = []; try { - const response = await fetch('https://api.github.com/graphql', { - method: 'POST', - headers: { - Authorization: `bearer ${token}`, - 'Content-Type': 'application/json', - 'User-Agent': 'commitstory-app', - }, - body: JSON.stringify({ query, variables: { login } }), - }); + for (const chunk of chunks) { + const yearFragments = chunk.map( + (y) => `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${y}-12-31T23:59:59Z") { ${repoFields} }` + ).join('\n '); - if (!response.ok) return json({ error: 'GitHub API error' }, response.status); + const query = ` + query($login: String!) { + rateLimit { cost remaining } + user(login: $login) { ${yearFragments} } + } + `; - type RepoContribNode = { - contributions: { totalCount: number }; - repository: { name: string; nameWithOwner: string; url: string; stargazerCount: number }; - }; + const response = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: `bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'commitstory-app', + }, + body: JSON.stringify({ query, variables: { login } }), + }); - type YearCollection = { - commitContributionsByRepository: RepoContribNode[]; - pullRequestContributionsByRepository: RepoContribNode[]; - }; + if (!response.ok) return json({ error: 'GitHub API error' }, response.status); - const data = (await response.json()) as { - data?: { user?: Record }; - errors?: { message: string }[]; - }; + const data = (await response.json()) as { + data?: { user?: Record }; + errors?: { message: string }[]; + }; - if (data.errors?.length) return json({ error: data.errors[0].message }, 400); + if (data.errors?.length) return json({ error: data.errors[0].message }, 400); - const user = data.data?.user ?? {}; + const user = data.data?.user ?? {}; - interface RepoEntry { - name: string; - nameWithOwner: string; - url: string; - stargazerCount: number; - commits: number; - pullRequests: number; - totalContributions: number; - } + for (const y of chunk) { + const collection = user[`y${y}`]; + const commitsByRepo = collection?.commitContributionsByRepository ?? []; + const prsByRepo = collection?.pullRequestContributionsByRepository ?? []; - const results = years.map((y) => { - const collection = user[`y${y}`]; - const commitsByRepo = collection?.commitContributionsByRepository ?? []; - const prsByRepo = collection?.pullRequestContributionsByRepository ?? []; - - const repoMap = new Map(); - - for (const node of commitsByRepo) { - const { name, nameWithOwner, url, stargazerCount } = node.repository; - const commits = node.contributions.totalCount; - const existing = repoMap.get(nameWithOwner); - if (existing) { - existing.commits += commits; - existing.totalContributions += commits; - } else { - repoMap.set(nameWithOwner, { name, nameWithOwner, url, stargazerCount, commits, pullRequests: 0, totalContributions: commits }); + const repoMap = new Map(); + + for (const node of commitsByRepo) { + const { name, nameWithOwner, url, stargazerCount } = node.repository; + const commits = node.contributions.totalCount; + const existing = repoMap.get(nameWithOwner); + if (existing) { + existing.commits += commits; + existing.totalContributions += commits; + } else { + repoMap.set(nameWithOwner, { name, nameWithOwner, url, stargazerCount, commits, pullRequests: 0, totalContributions: commits }); + } } - } - for (const node of prsByRepo) { - const { name, nameWithOwner, url, stargazerCount } = node.repository; - const prs = node.contributions.totalCount; - const existing = repoMap.get(nameWithOwner); - if (existing) { - existing.pullRequests += prs; - existing.totalContributions += prs; - } else { - repoMap.set(nameWithOwner, { name, nameWithOwner, url, stargazerCount, commits: 0, pullRequests: prs, totalContributions: prs }); + for (const node of prsByRepo) { + const { name, nameWithOwner, url, stargazerCount } = node.repository; + const prs = node.contributions.totalCount; + const existing = repoMap.get(nameWithOwner); + if (existing) { + existing.pullRequests += prs; + existing.totalContributions += prs; + } else { + repoMap.set(nameWithOwner, { name, nameWithOwner, url, stargazerCount, commits: 0, pullRequests: prs, totalContributions: prs }); + } } - } - const topRepositories = Array.from(repoMap.values()) - .sort((a, b) => b.totalContributions - a.totalContributions); + const topRepositories = Array.from(repoMap.values()) + .sort((a, b) => b.totalContributions - a.totalContributions); - return { year: y, topRepositories }; - }); + allResults.push({ year: y, topRepositories }); + } + } - return json({ years: results }); + return json({ years: allResults }); } catch (err) { console.error('GitHub repository-contributions-batch error:', err); return json({ error: 'Failed to fetch repository contributions' }, 500); From db27ca127c4356c49c2fb9357f44737070d8a5b7 Mon Sep 17 00:00:00 2001 From: Santosh Yadav Date: Thu, 16 Jul 2026 23:17:21 +0200 Subject: [PATCH 3/8] fix: reduce chunk sizes to avoid GitHub resource limits - Contributions: 3 years per chunk (scalars only, lightweight) - Repo contributions: 1 year per chunk (safest, avoids node limits) - maxRepositories: reduced from 20 to 10 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- yourstory/src/server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yourstory/src/server.ts b/yourstory/src/server.ts index 2a18818..eac406e 100644 --- a/yourstory/src/server.ts +++ b/yourstory/src/server.ts @@ -376,11 +376,11 @@ function chunkArray(arr: T[], size: number): T[][] { } /** Max years per GraphQL query for contributions (lightweight – scalars only). */ -const CONTRIBUTIONS_CHUNK_SIZE = 5; +const CONTRIBUTIONS_CHUNK_SIZE = 3; /** Max years per GraphQL query for repo contributions (heavy – nested nodes). */ -const REPO_CONTRIBUTIONS_CHUNK_SIZE = 3; +const REPO_CONTRIBUTIONS_CHUNK_SIZE = 1; /** Max repositories to fetch per year in repo-contributions queries. */ -const MAX_REPOSITORIES_PER_YEAR = 20; +const MAX_REPOSITORIES_PER_YEAR = 10; /** * POST /api/github/contributions-batch From 994fd56cf4fca073aaee374544daab295127411d Mon Sep 17 00:00:00 2001 From: Santosh Yadav Date: Thu, 16 Jul 2026 23:26:01 +0200 Subject: [PATCH 4/8] fix: remove restrictedContributionsCount to avoid resource limits The restrictedContributionsCount field forces GitHub to enumerate all org memberships internally, causing 'Resource limits exceeded' for users with many org contributions. Removed from both the single-year and batch endpoints. privateContributions now returns 0. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- yourstory/src/server.ts | 139 ++++++---------------------------------- 1 file changed, 20 insertions(+), 119 deletions(-) diff --git a/yourstory/src/server.ts b/yourstory/src/server.ts index eac406e..b0d7440 100644 --- a/yourstory/src/server.ts +++ b/yourstory/src/server.ts @@ -155,21 +155,7 @@ async function handleContributions(request: Request, env: Env): Promise - fetch('https://api.github.com/graphql', { + try { + const response = await fetch('https://api.github.com/graphql', { method: 'POST', headers: { Authorization: `Bearer ${token}`, @@ -193,44 +179,21 @@ async function handleContributions(request: Request, env: Env): Promise; - }; - - const response = await requestGraphQL(queryWithPrivate); if (!response.ok) return json({ error: 'GitHub API error' }, response.status); - const data = (await response.json()) as GraphQLResponse; - - let c = data.data?.user?.contributionsCollection; - let privateBlocked = false; - - if (data.errors?.length) { - const hasSamlError = data.errors.some((e) => { - const msg = e.message?.toLowerCase() ?? ''; - return e.extensions?.saml_failure === true || msg.includes('saml') || msg.includes('organization'); - }); + const data = (await response.json()) as { + data?: { user?: { contributionsCollection?: { + totalCommitContributions: number; + totalIssueContributions: number; + totalPullRequestContributions: number; + totalPullRequestReviewContributions: number; + } } }; + errors?: Array<{ message: string }>; + }; - if (!hasSamlError) return json({ error: data.errors[0].message }, 400); + if (data.errors?.length) return json({ error: data.errors[0].message }, 400); - // Retry without restrictedContributionsCount for SAML-enforced orgs - privateBlocked = true; - const fallback = await requestGraphQL(queryWithoutPrivate); - if (!fallback.ok) return json({ error: 'GitHub API error' }, fallback.status); - const fallbackData = (await fallback.json()) as GraphQLResponse; - if (fallbackData.errors?.length) return json({ error: fallbackData.errors[0].message }, 400); - c = fallbackData.data?.user?.contributionsCollection ?? c; - } + const c = data.data?.user?.contributionsCollection; return json({ year, @@ -238,8 +201,7 @@ async function handleContributions(request: Request, env: Env): Promise }; - errors?: Array<{ message: string; extensions?: { saml_failure?: boolean } }>; + errors?: Array<{ message: string }>; }; const contribFields = ` @@ -421,25 +382,15 @@ async function handleContributionsBatch( totalIssueContributions totalPullRequestContributions totalPullRequestReviewContributions - restrictedContributionsCount - `; - - const contribFieldsNoPrivate = ` - totalCommitContributions - totalIssueContributions - totalPullRequestContributions - totalPullRequestReviewContributions `; const chunks = chunkArray(years, CONTRIBUTIONS_CHUNK_SIZE); - const allResults: { year: number; commits: number; issues: number; pullRequests: number; reviews: number; privateContributions: number; privateContributionsBlocked?: boolean }[] = []; - let privateBlocked = false; + const allResults: { year: number; commits: number; issues: number; pullRequests: number; reviews: number; privateContributions: number }[] = []; try { for (const chunk of chunks) { - const fields = privateBlocked ? contribFieldsNoPrivate : contribFields; const yearFragments = chunk.map( - (y) => `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${y}-12-31T23:59:59Z") { ${fields} }` + (y) => `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${y}-12-31T23:59:59Z") { ${contribFields} }` ).join('\n '); const query = ` @@ -463,56 +414,7 @@ async function handleContributionsBatch( const data = (await response.json()) as GraphQLResponse; - if (data.errors?.length) { - const hasSamlError = data.errors.some((e) => { - const msg = e.message?.toLowerCase() ?? ''; - return e.extensions?.saml_failure === true || msg.includes('saml') || msg.includes('organization'); - }); - - if (!hasSamlError) return json({ error: data.errors[0].message }, 400); - - // Retry this chunk without restrictedContributionsCount - privateBlocked = true; - const fallbackFragments = chunk.map( - (y) => `y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${y}-12-31T23:59:59Z") { ${contribFieldsNoPrivate} }` - ).join('\n '); - - const fallbackQuery = ` - query($login: String!) { - user(login: $login) { ${fallbackFragments} } - } - `; - - const fallbackResponse = await fetch('https://api.github.com/graphql', { - method: 'POST', - headers: { - Authorization: `bearer ${token}`, - 'Content-Type': 'application/json', - 'User-Agent': 'commitstory-app', - }, - body: JSON.stringify({ query: fallbackQuery, variables: { login } }), - }); - - if (!fallbackResponse.ok) return json({ error: 'GitHub API error' }, fallbackResponse.status); - - const fallbackData = (await fallbackResponse.json()) as GraphQLResponse; - if (fallbackData.errors?.length) return json({ error: fallbackData.errors[0].message }, 400); - - const user = fallbackData.data?.user ?? {}; - for (const y of chunk) { - const c = user[`y${y}`]; - allResults.push({ - year: y, - commits: c?.totalCommitContributions ?? 0, - issues: c?.totalIssueContributions ?? 0, - pullRequests: c?.totalPullRequestContributions ?? 0, - reviews: c?.totalPullRequestReviewContributions ?? 0, - privateContributions: 0, - privateContributionsBlocked: true, - }); - } - continue; - } + if (data.errors?.length) return json({ error: data.errors[0].message }, 400); const user = data.data?.user ?? {}; for (const y of chunk) { @@ -523,8 +425,7 @@ async function handleContributionsBatch( issues: c?.totalIssueContributions ?? 0, pullRequests: c?.totalPullRequestContributions ?? 0, reviews: c?.totalPullRequestReviewContributions ?? 0, - privateContributions: privateBlocked ? 0 : (c?.restrictedContributionsCount ?? 0), - ...(privateBlocked ? { privateContributionsBlocked: true } : {}), + privateContributions: 0, }); } } From ad9cacf565fc4d4ed3cc34a5954f862b0ffe0095 Mon Sep 17 00:00:00 2001 From: Santosh Yadav Date: Fri, 17 Jul 2026 11:29:46 +0200 Subject: [PATCH 5/8] fix: cap year range to last 7 years to avoid API resource limits Users with accounts older than 7 years were generating too many year-scoped GraphQL queries. Cap startYear to (currentYear - 6) so at most 7 years of data are fetched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- yourstory/src/app/core/services/github.service.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/yourstory/src/app/core/services/github.service.ts b/yourstory/src/app/core/services/github.service.ts index 2779913..253b0bf 100644 --- a/yourstory/src/app/core/services/github.service.ts +++ b/yourstory/src/app/core/services/github.service.ts @@ -46,6 +46,9 @@ interface RepositoryContributionsBatchResponse { years: RepositoryContributionsResponse[]; } +/** Maximum number of years to fetch to stay within GitHub API limits. */ +const MAX_YEARS = 7; + @Injectable({ providedIn: 'root' }) export class GitHubService { private readonly http = inject(HttpClient); @@ -159,7 +162,7 @@ export class GitHubService { let startYear: number; if (createdAt) { - startYear = new Date(createdAt).getFullYear(); + startYear = Math.max(new Date(createdAt).getFullYear(), currentYear - MAX_YEARS + 1); } else { startYear = currentYear - 3; } @@ -223,7 +226,7 @@ export class GitHubService { let startYear: number; if (createdAt) { - startYear = new Date(createdAt).getFullYear(); + startYear = Math.max(new Date(createdAt).getFullYear(), currentYear - MAX_YEARS + 1); } else { startYear = currentYear - 3; } From 581d18c8f312f4ed070c0e704ce95fa32b7fbec9 Mon Sep 17 00:00:00 2001 From: Santosh Yadav Date: Fri, 17 Jul 2026 11:45:26 +0200 Subject: [PATCH 6/8] fix: reduce MAX_YEARS from 7 to 5 (6 still exceeds limits) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- yourstory/src/app/core/services/github.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yourstory/src/app/core/services/github.service.ts b/yourstory/src/app/core/services/github.service.ts index 253b0bf..9dda3e1 100644 --- a/yourstory/src/app/core/services/github.service.ts +++ b/yourstory/src/app/core/services/github.service.ts @@ -47,7 +47,7 @@ interface RepositoryContributionsBatchResponse { } /** Maximum number of years to fetch to stay within GitHub API limits. */ -const MAX_YEARS = 7; +const MAX_YEARS = 5; @Injectable({ providedIn: 'root' }) export class GitHubService { From 2db699c5772240ef1788310abf7491afc5b50d44 Mon Sep 17 00:00:00 2001 From: Santosh Yadav Date: Fri, 17 Jul 2026 11:52:06 +0200 Subject: [PATCH 7/8] fix: reduce contributions chunk size to 1 year per query 3 years still exceeded resource limits for some accounts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- yourstory/src/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yourstory/src/server.ts b/yourstory/src/server.ts index b0d7440..430bbdf 100644 --- a/yourstory/src/server.ts +++ b/yourstory/src/server.ts @@ -337,8 +337,8 @@ function chunkArray(arr: T[], size: number): T[][] { return chunks; } -/** Max years per GraphQL query for contributions (lightweight – scalars only). */ -const CONTRIBUTIONS_CHUNK_SIZE = 3; +/** Max years per GraphQL query for contributions. */ +const CONTRIBUTIONS_CHUNK_SIZE = 1; /** Max years per GraphQL query for repo contributions (heavy – nested nodes). */ const REPO_CONTRIBUTIONS_CHUNK_SIZE = 1; /** Max repositories to fetch per year in repo-contributions queries. */ From cffb917dc42ba2ba4217b0cf1623647b2cea47d8 Mon Sep 17 00:00:00 2001 From: Santosh Yadav Date: Fri, 17 Jul 2026 12:05:40 +0200 Subject: [PATCH 8/8] fix: gracefully handle per-year GitHub API failures Years where GitHub returns 'Resource limits exceeded' are now skipped instead of crashing the entire batch request. The response includes a failedYears array so the frontend knows which data is missing. This handles accounts with complex org memberships that blow GitHub's internal node limits for certain year ranges. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/app/core/services/github.service.ts | 2 ++ yourstory/src/server.ts | 33 +++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/yourstory/src/app/core/services/github.service.ts b/yourstory/src/app/core/services/github.service.ts index 9dda3e1..c0f65d3 100644 --- a/yourstory/src/app/core/services/github.service.ts +++ b/yourstory/src/app/core/services/github.service.ts @@ -30,6 +30,7 @@ interface ContributionsResponse { interface ContributionsBatchResponse { years: ContributionsResponse[]; + failedYears?: number[]; } interface DiscussionsResponse { @@ -44,6 +45,7 @@ interface RepositoryContributionsResponse { interface RepositoryContributionsBatchResponse { years: RepositoryContributionsResponse[]; + failedYears?: number[]; } /** Maximum number of years to fetch to stay within GitHub API limits. */ diff --git a/yourstory/src/server.ts b/yourstory/src/server.ts index 430bbdf..66887a5 100644 --- a/yourstory/src/server.ts +++ b/yourstory/src/server.ts @@ -387,6 +387,8 @@ async function handleContributionsBatch( const chunks = chunkArray(years, CONTRIBUTIONS_CHUNK_SIZE); const allResults: { year: number; commits: number; issues: number; pullRequests: number; reviews: number; privateContributions: number }[] = []; + const failedYears: number[] = []; + try { for (const chunk of chunks) { const yearFragments = chunk.map( @@ -410,11 +412,21 @@ async function handleContributionsBatch( body: JSON.stringify({ query, variables: { login } }), }); - if (!response.ok) return json({ error: 'GitHub API error' }, response.status); + if (!response.ok) { + // Treat entire chunk as failed but continue with other chunks + console.warn(`GitHub API returned ${response.status} for contributions chunk [${chunk.join(',')}]`); + failedYears.push(...chunk); + continue; + } const data = (await response.json()) as GraphQLResponse; - if (data.errors?.length) return json({ error: data.errors[0].message }, 400); + if (data.errors?.length) { + // Resource limits or other errors — skip this chunk, continue + console.warn(`GitHub GraphQL error for contributions chunk [${chunk.join(',')}]: ${data.errors[0].message}`); + failedYears.push(...chunk); + continue; + } const user = data.data?.user ?? {}; for (const y of chunk) { @@ -430,7 +442,7 @@ async function handleContributionsBatch( } } - return json({ years: allResults }); + return json({ years: allResults, failedYears: failedYears.length > 0 ? failedYears : undefined }); } catch (err) { console.error('GitHub contributions-batch error:', err); return json({ error: 'Failed to fetch contributions' }, 500); @@ -491,6 +503,7 @@ async function handleRepositoryContributionsBatch( const chunks = chunkArray(years, REPO_CONTRIBUTIONS_CHUNK_SIZE); const allResults: { year: number; topRepositories: RepoEntry[] }[] = []; + const failedYears: number[] = []; try { for (const chunk of chunks) { @@ -515,14 +528,22 @@ async function handleRepositoryContributionsBatch( body: JSON.stringify({ query, variables: { login } }), }); - if (!response.ok) return json({ error: 'GitHub API error' }, response.status); + if (!response.ok) { + console.warn(`GitHub API returned ${response.status} for repo-contributions chunk [${chunk.join(',')}]`); + failedYears.push(...chunk); + continue; + } const data = (await response.json()) as { data?: { user?: Record }; errors?: { message: string }[]; }; - if (data.errors?.length) return json({ error: data.errors[0].message }, 400); + if (data.errors?.length) { + console.warn(`GitHub GraphQL error for repo-contributions chunk [${chunk.join(',')}]: ${data.errors[0].message}`); + failedYears.push(...chunk); + continue; + } const user = data.data?.user ?? {}; @@ -564,7 +585,7 @@ async function handleRepositoryContributionsBatch( } } - return json({ years: allResults }); + return json({ years: allResults, failedYears: failedYears.length > 0 ? failedYears : undefined }); } catch (err) { console.error('GitHub repository-contributions-batch error:', err); return json({ error: 'Failed to fetch repository contributions' }, 500);