From d61a76f88d3f19d599b87079326e0d1eca3aa190 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:21:20 +0200 Subject: [PATCH 1/7] WIP: all-time contributed to --- packages/core/src/fetchers/stats.ts | 195 +++++++++++++++++- packages/core/src/graphql/generated/stats.ts | 15 ++ .../core/src/graphql/queries/stats.graphql | 25 +++ .../src/graphql/reposContributedToDocument.ts | 82 ++++++++ 4 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/graphql/reposContributedToDocument.ts diff --git a/packages/core/src/fetchers/stats.ts b/packages/core/src/fetchers/stats.ts index 1ee51fc694597..747a9401c8912 100644 --- a/packages/core/src/fetchers/stats.ts +++ b/packages/core/src/fetchers/stats.ts @@ -21,6 +21,11 @@ import type { UserInfoQuery, UserInfoQueryVariables, } from "../graphql/generated/stats.js"; +import type { ContributionRange } from "../graphql/reposContributedToDocument.js"; +import { + MAX_REPOSITORIES_LIMIT, + buildReposContributedToDocument, +} from "../graphql/reposContributedToDocument.js"; import type { RepoUserStats, StatsData } from "./types.js"; @@ -332,6 +337,177 @@ const fetchTotalContributions = async ( return total; }; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Round a timestamp (e.g. `Date.getTime()`) to the nearest UTC midnight. + * + * @param timestamp Milliseconds since epoch. + * @returns Milliseconds since epoch of the nearest UTC midnight. + */ +const roundToNearestMidnight = (timestamp: number): number => + Math.round(timestamp / MS_PER_DAY) * MS_PER_DAY; + +// TODO: consider merging this with `ContributionRange` +/** A range still being resolved, alongside its actual `Date` bounds (for bisecting). */ +interface PendingRange { + from: Date; + to: Date; +} + +/** + * Fetch the distinct set of repositories a user contributed to (commits, + * issues, PRs, or repo creation) across every given range. + * + * All ranges still pending are queried together in a single request (one + * aliased `contributionsCollection` field each). Whenever a range's + * sub-collection returns `CONTRIBUTIONS_COLLECTION_REPO_CAP` results, that + * range is bisected and requeried in the next round, since the true count + * could be higher and some repos may be missing from the response. + * + * @param username GitHub username. + * @param ranges Ranges to fetch. + * @param pat Optional PAT override. + * @returns The distinct set of `nameWithOwner` repo identifiers. + */ +const fetchReposContributedToForRanges = async ( + username: string, + ranges: Array, + pat: string | null, +): Promise> => { + const repos = new Set(); + let pending = ranges; + + while (pending.length > 0) { + const document = buildReposContributedToDocument( + pending.map((range): ContributionRange => ({ + from: range.from.toISOString(), + to: range.to.toISOString(), + })), + ); + const fetcher = createGraphQLFetcher(document, "bearer"); + const res = await retryer( + fetcher, + { login: username, maxRepositories: MAX_REPOSITORIES_LIMIT }, + pat, + ); + + if (res.data.errors) { + logger.error(res.data.errors); + const firstError = res.data.errors[0]; + if (firstError?.message) { + throw new CustomError( + wrapTextMultiline(firstError.message, 525, 12)[0] ?? "", + res.statusText, + ); + } + throw new CustomError( + "Something went wrong while trying to retrieve the repository contributions data using the GraphQL API.", + CustomError.GRAPHQL_ERROR, + ); + } + + const user = res.data.data.user; + if (!user) { + throw new CustomError( + "Something went wrong while trying to retrieve the repository contributions data using the GraphQL API.", + CustomError.GRAPHQL_ERROR, + ); + } + + const nextPending: Array = []; + pending.forEach((range, index) => { + const rangeResponse = user[`range_${index}`]; + if (!rangeResponse) { + throw new CustomError( + "Something went wrong while trying to retrieve the repository contributions data using the GraphQL API.", + CustomError.GRAPHQL_ERROR, + ); + } + + const commitRepos = rangeResponse.commitContributionsByRepository; + const issueRepos = rangeResponse.issueContributionsByRepository; + const prRepos = rangeResponse.pullRequestContributionsByRepository; + const createdRepoNodes = + rangeResponse.repositoryContributions.nodes ?? []; + + const isSaturated = + commitRepos.length >= MAX_REPOSITORIES_LIMIT || + issueRepos.length >= MAX_REPOSITORIES_LIMIT || + prRepos.length >= MAX_REPOSITORIES_LIMIT || + createdRepoNodes.length >= MAX_REPOSITORIES_LIMIT; + + const rangeDays = Math.round( + (range.to.getTime() - range.from.getTime()) / MS_PER_DAY, + ); + // a range of 1 day or less can't be split any further + if (isSaturated && rangeDays >= 2) { + const mid = new Date( + roundToNearestMidnight( + range.from.getTime() + Math.floor(rangeDays / 2) * MS_PER_DAY, + ), + ); + nextPending.push({ + from: range.from, + to: new Date(mid.getTime() - 1000), + }); + nextPending.push({ from: mid, to: range.to }); + return; + } + + for (const { repository } of [ + ...commitRepos, + ...issueRepos, + ...prRepos, + ]) { + repos.add(repository.nameWithOwner); + } + for (const node of createdRepoNodes) { + if (node) { + repos.add(node.repository.nameWithOwner); + } + } + }); + + pending = nextPending; + } + + return repos; +}; + +/** + * Fetch the all-time count of distinct repositories the user contributed to + * (commits, issues, PRs, or repo creation), across every contribution year. + * + * Unlike `repositoriesContributedTo` in `stats.graphql` (which is scoped to + * the past year by default), this walks every year individually via + * `contributionsCollection(from, to)` and de-duplicates the results, since + * that's the only way to see contributions further back than a year. + * + * Whether private contributions are included depends on the same rule as + * `fetchTotalContributions`: it's implied by whether the PAT used has access + * to the user's private contributions (i.e. it belongs to the user, or an + * org member if the org enabled that visibility). There's no separate way to + * request/deny private contributions independent of the PAT's own access. + * + * @param username GitHub username. + * @param years Contribution years to walk. + * @param pat Optional PAT override. + * @returns Count of distinct repositories. + */ +const fetchAllTimeRepositoriesContributedTo = async ( + username: string, + years: Array, + pat: string | null = null, +): Promise => { + const ranges: Array = years.map((year) => ({ + from: new Date(Date.UTC(year, 0, 1)), + to: new Date(Date.UTC(year, 11, 31, 23, 59, 59, 999)), + })); + const repos = await fetchReposContributedToForRanges(username, ranges, pat); + return repos.size; +}; + /** * Fetch stats for a given username. * @@ -490,6 +666,19 @@ const fetchStats = async ( ); } + // TODO: + // temporary: compute the all-time repositoriesContributedTo and just log it, + // until it's wired up as a real stat with query param + docs support. + const allTimeRepositoriesContributedTo = + await fetchAllTimeRepositoriesContributedTo( + username, + user.contributionsCollection.contributionYears, + pat, + ); + logger.log( + `All-time repositoriesContributedTo for ${username}: ${allTimeRepositoriesContributedTo}`, + ); + // Retrieve stars while filtering out repositories to be hidden. const allExcludedRepos = [ ...exclude_repo, @@ -515,4 +704,8 @@ const fetchStats = async ( return stats; }; -export { fetchStats, fetchRepoUserStats }; +export { + fetchStats, + fetchRepoUserStats, + fetchAllTimeRepositoriesContributedTo, +}; diff --git a/packages/core/src/graphql/generated/stats.ts b/packages/core/src/graphql/generated/stats.ts index c2b1d718d721a..9270bc679e5b2 100644 --- a/packages/core/src/graphql/generated/stats.ts +++ b/packages/core/src/graphql/generated/stats.ts @@ -76,6 +76,21 @@ export type YearContributionsFragment = { contributionCalendar: { totalContributions: number }; }; +export type RangeContributionsByRepoFragment = { + commitContributionsByRepository: Array<{ + repository: { nameWithOwner: string }; + }>; + issueContributionsByRepository: Array<{ + repository: { nameWithOwner: string }; + }>; + pullRequestContributionsByRepository: Array<{ + repository: { nameWithOwner: string }; + }>; + repositoryContributions: { + nodes: Array<{ repository: { nameWithOwner: string } } | null> | null; + }; +}; + export const UserReposDocument = graphqlDocument< UserReposQuery, UserReposQueryVariables diff --git a/packages/core/src/graphql/queries/stats.graphql b/packages/core/src/graphql/queries/stats.graphql index d99a27b086d55..e647be5c73f79 100644 --- a/packages/core/src/graphql/queries/stats.graphql +++ b/packages/core/src/graphql/queries/stats.graphql @@ -90,3 +90,28 @@ fragment YearContributions on ContributionsCollection { totalContributions } } + +fragment RangeContributionsByRepo on d { + commitContributionsByRepository(maxRepositories: $repoCap) { + repository { + nameWithOwner + } + } + issueContributionsByRepository(maxRepositories: $repoCap) { + repository { + nameWithOwner + } + } + pullRequestContributionsByRepository(maxRepositories: $repoCap) { + repository { + nameWithOwner + } + } + repositoryContributions(first: $repoCap) { + nodes { + repository { + nameWithOwner + } + } + } +} diff --git a/packages/core/src/graphql/reposContributedToDocument.ts b/packages/core/src/graphql/reposContributedToDocument.ts new file mode 100644 index 0000000000000..08ecc8131890e --- /dev/null +++ b/packages/core/src/graphql/reposContributedToDocument.ts @@ -0,0 +1,82 @@ +import type { RangeContributionsByRepoFragment } from "./generated/stats.js"; +import { graphqlDocument } from "./graphqlDocument.js"; + +/** max value GitHub allows for `first/maxRepositories` */ +const MAX_REPOSITORIES_LIMIT = 100; + +interface ReposContributedToQueryVariables { + login: string; + maxRepositories: number; +} + +interface ReposContributedToQuery { + user: Record<`range_${number}`, RangeContributionsByRepoFragment> | null; +} + +/** A `[from, to]` date range to query, both bounds as ISO 8601 timestamps. */ +interface ContributionRange { + from: string; + to: string; +} + +/** + * Build a query for the repositories a user contributed to within multiple + * time ranges, grouped by contribution type. One aliased + * `contributionsCollection` field per range, so all ranges are fetched in a + * single request. The shape is only known at runtime. + * + * Mirrors the `contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]` + * filter used by `repositoriesContributedTo` in `stats.graphql` (review + * contributions are intentionally left out), but `contributionsCollection`'s + * by-repository fields cap out at 100 results each, so callers need to split + * a saturated range in two and re-query. + * + * @param ranges Ranges to fetch, one `range_` alias each. + * @returns Document for `createGraphQLFetcher`. + */ +const buildReposContributedToDocument = (ranges: Array) => { + const rangeFields = ranges + .map( + ({ from, to }, index) => + `range_${index}: contributionsCollection(from: "${from}", to: "${to}") { ...RangeContributionsByRepo }`, + ) + .join("\n"); + + // fragment must match queries/stats.graphql, which generates its type + return graphqlDocument< + ReposContributedToQuery, + ReposContributedToQueryVariables + >(` +query userReposContributedTo($login: String!, $repoCap: Int!) { + user(login: $login) { + ${rangeFields} + } +} +fragment RangeContributionsByRepo on ContributionsCollection { + commitContributionsByRepository(maxRepositories: $repoCap) { + repository { + nameWithOwner + } + } + issueContributionsByRepository(maxRepositories: $repoCap) { + repository { + nameWithOwner + } + } + pullRequestContributionsByRepository(maxRepositories: $repoCap) { + repository { + nameWithOwner + } + } + repositoryContributions(first: $repoCap) { + nodes { + repository { + nameWithOwner + } + } + } +}`); +}; + +export { buildReposContributedToDocument, MAX_REPOSITORIES_LIMIT }; +export type { ContributionRange }; From 682f9ef566a4593cd6862aad6dc3b069e32e3da2 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:08:37 +0200 Subject: [PATCH 2/7] WIP: improve new code --- packages/core/src/fetchers/stats.ts | 81 +++++++------------ .../core/src/graphql/queries/stats.graphql | 2 +- .../src/graphql/reposContributedToDocument.ts | 20 ++--- 3 files changed, 39 insertions(+), 64 deletions(-) diff --git a/packages/core/src/fetchers/stats.ts b/packages/core/src/fetchers/stats.ts index 747a9401c8912..bb8579d53772b 100644 --- a/packages/core/src/fetchers/stats.ts +++ b/packages/core/src/fetchers/stats.ts @@ -348,43 +348,29 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000; const roundToNearestMidnight = (timestamp: number): number => Math.round(timestamp / MS_PER_DAY) * MS_PER_DAY; -// TODO: consider merging this with `ContributionRange` -/** A range still being resolved, alongside its actual `Date` bounds (for bisecting). */ -interface PendingRange { - from: Date; - to: Date; -} - /** - * Fetch the distinct set of repositories a user contributed to (commits, - * issues, PRs, or repo creation) across every given range. + * Fetch the repositories a user contributed to across every given range. * - * All ranges still pending are queried together in a single request (one - * aliased `contributionsCollection` field each). Whenever a range's - * sub-collection returns `CONTRIBUTIONS_COLLECTION_REPO_CAP` results, that - * range is bisected and requeried in the next round, since the true count + * All ranges still pending are queried together in a single request. Whenever a + * range's sub-collection returns `CONTRIBUTIONS_COLLECTION_REPO_CAP` results, + * that range is split and requeried in the next round, since the true count * could be higher and some repos may be missing from the response. * * @param username GitHub username. * @param ranges Ranges to fetch. * @param pat Optional PAT override. - * @returns The distinct set of `nameWithOwner` repo identifiers. + * @returns The set of `nameWithOwner` repo identifiers. */ -const fetchReposContributedToForRanges = async ( +const fetchReposContributedTo = async ( username: string, - ranges: Array, + ranges: Array, pat: string | null, ): Promise> => { const repos = new Set(); let pending = ranges; while (pending.length > 0) { - const document = buildReposContributedToDocument( - pending.map((range): ContributionRange => ({ - from: range.from.toISOString(), - to: range.to.toISOString(), - })), - ); + const document = buildReposContributedToDocument(pending); const fetcher = createGraphQLFetcher(document, "bearer"); const res = await retryer( fetcher, @@ -415,7 +401,7 @@ const fetchReposContributedToForRanges = async ( ); } - const nextPending: Array = []; + const nextPending: Array = []; pending.forEach((range, index) => { const rangeResponse = user[`range_${index}`]; if (!rangeResponse) { @@ -447,6 +433,9 @@ const fetchReposContributedToForRanges = async ( range.from.getTime() + Math.floor(rangeDays / 2) * MS_PER_DAY, ), ); + // GitHub seems to use only the date portion and ignore the time. So we + // subtract 1 second from the `to` of the first half to wrap it to the + // previous day and avoid a 1-day overlap of the two halves. nextPending.push({ from: range.from, to: new Date(mid.getTime() - 1000), @@ -476,35 +465,30 @@ const fetchReposContributedToForRanges = async ( }; /** - * Fetch the all-time count of distinct repositories the user contributed to - * (commits, issues, PRs, or repo creation), across every contribution year. + * Calculates the count of repositories the user contributed to, across every + * contribution year. * - * Unlike `repositoriesContributedTo` in `stats.graphql` (which is scoped to - * the past year by default), this walks every year individually via - * `contributionsCollection(from, to)` and de-duplicates the results, since - * that's the only way to see contributions further back than a year. + * GitHub's `repositoriesContributedTo` field can only span one year. So we walk + * every year individually via `contributionsCollection(from, to)` and + * de-duplicates the repo results. * - * Whether private contributions are included depends on the same rule as - * `fetchTotalContributions`: it's implied by whether the PAT used has access - * to the user's private contributions (i.e. it belongs to the user, or an - * org member if the org enabled that visibility). There's no separate way to - * request/deny private contributions independent of the PAT's own access. + * Whether private contributions are included depends on the used PAT. * * @param username GitHub username. * @param years Contribution years to walk. * @param pat Optional PAT override. - * @returns Count of distinct repositories. + * @returns Count of repositories. */ -const fetchAllTimeRepositoriesContributedTo = async ( +const fetchAllTimeReposContributedTo = async ( username: string, years: Array, pat: string | null = null, ): Promise => { - const ranges: Array = years.map((year) => ({ + const ranges: Array = years.map((year) => ({ from: new Date(Date.UTC(year, 0, 1)), - to: new Date(Date.UTC(year, 11, 31, 23, 59, 59, 999)), + to: new Date(Date.UTC(year, 11, 31, 23, 59, 59)), })); - const repos = await fetchReposContributedToForRanges(username, ranges, pat); + const repos = await fetchReposContributedTo(username, ranges, pat); return repos.size; }; @@ -669,14 +653,13 @@ const fetchStats = async ( // TODO: // temporary: compute the all-time repositoriesContributedTo and just log it, // until it's wired up as a real stat with query param + docs support. - const allTimeRepositoriesContributedTo = - await fetchAllTimeRepositoriesContributedTo( - username, - user.contributionsCollection.contributionYears, - pat, - ); + const allTimeReposContributedTo = await fetchAllTimeReposContributedTo( + username, + user.contributionsCollection.contributionYears, + pat, + ); logger.log( - `All-time repositoriesContributedTo for ${username}: ${allTimeRepositoriesContributedTo}`, + `All-time repositoriesContributedTo for ${username}: ${allTimeReposContributedTo}`, ); // Retrieve stars while filtering out repositories to be hidden. @@ -704,8 +687,4 @@ const fetchStats = async ( return stats; }; -export { - fetchStats, - fetchRepoUserStats, - fetchAllTimeRepositoriesContributedTo, -}; +export { fetchStats, fetchRepoUserStats }; diff --git a/packages/core/src/graphql/queries/stats.graphql b/packages/core/src/graphql/queries/stats.graphql index e647be5c73f79..ec0a6bfb1c866 100644 --- a/packages/core/src/graphql/queries/stats.graphql +++ b/packages/core/src/graphql/queries/stats.graphql @@ -91,7 +91,7 @@ fragment YearContributions on ContributionsCollection { } } -fragment RangeContributionsByRepo on d { +fragment RangeContributionsByRepo on ContributionsCollection { commitContributionsByRepository(maxRepositories: $repoCap) { repository { nameWithOwner diff --git a/packages/core/src/graphql/reposContributedToDocument.ts b/packages/core/src/graphql/reposContributedToDocument.ts index 08ecc8131890e..eb45431525a42 100644 --- a/packages/core/src/graphql/reposContributedToDocument.ts +++ b/packages/core/src/graphql/reposContributedToDocument.ts @@ -13,23 +13,19 @@ interface ReposContributedToQuery { user: Record<`range_${number}`, RangeContributionsByRepoFragment> | null; } -/** A `[from, to]` date range to query, both bounds as ISO 8601 timestamps. */ +/** A date range to query for contributions. */ interface ContributionRange { - from: string; - to: string; + from: Date; + to: Date; } /** - * Build a query for the repositories a user contributed to within multiple - * time ranges, grouped by contribution type. One aliased - * `contributionsCollection` field per range, so all ranges are fetched in a - * single request. The shape is only known at runtime. + * Build a query for the repositories a user contributed to within multiple time + * ranges. One aliased `contributionsCollection` field per range, so all ranges + * are fetched in a single request. The shape is only known at runtime. * * Mirrors the `contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]` - * filter used by `repositoriesContributedTo` in `stats.graphql` (review - * contributions are intentionally left out), but `contributionsCollection`'s - * by-repository fields cap out at 100 results each, so callers need to split - * a saturated range in two and re-query. + * filter used by `repositoriesContributedTo` in `stats.graphql`. * * @param ranges Ranges to fetch, one `range_` alias each. * @returns Document for `createGraphQLFetcher`. @@ -38,7 +34,7 @@ const buildReposContributedToDocument = (ranges: Array) => { const rangeFields = ranges .map( ({ from, to }, index) => - `range_${index}: contributionsCollection(from: "${from}", to: "${to}") { ...RangeContributionsByRepo }`, + `range_${index}: contributionsCollection(from: "${from.toISOString()}", to: "${to.toISOString()}") { ...RangeContributionsByRepo }`, ) .join("\n"); From bd58cfd47cb5ace20d71ca618103e6becb831fc9 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:41:39 +0200 Subject: [PATCH 3/7] add includeOwnRepos param, wire up params --- packages/core/src/api/index.js | 3 ++ packages/core/src/cards/stats.ts | 10 +++++ packages/core/src/fetchers/stats.ts | 42 +++++++++++++------ packages/core/src/fetchers/types.ts | 1 + packages/core/src/graphql/generated/stats.ts | 4 +- .../core/src/graphql/queries/stats.graphql | 10 +++-- .../src/graphql/reposContributedToDocument.ts | 10 ++--- packages/core/src/translations.ts | 3 ++ 8 files changed, 61 insertions(+), 22 deletions(-) diff --git a/packages/core/src/api/index.js b/packages/core/src/api/index.js index c51328a3f5b75..b8f446c89fc00 100644 --- a/packages/core/src/api/index.js +++ b/packages/core/src/api/index.js @@ -35,6 +35,7 @@ export default async ( number_precision, rank_icon, show, + contribs_include_own_repos, ...remainingParams }, pat = null, @@ -106,6 +107,8 @@ export default async ( showStats.includes("issues_commented"), parseArray(role), showStats.includes("contributions"), + showStats.includes("all_time_contribs"), + parseBoolean(contribs_include_own_repos), pat, ); diff --git a/packages/core/src/cards/stats.ts b/packages/core/src/cards/stats.ts index a8d54c0097a4d..aeb3ce6c64a19 100644 --- a/packages/core/src/cards/stats.ts +++ b/packages/core/src/cards/stats.ts @@ -242,6 +242,7 @@ const renderStatsCard = ( totalDiscussionsStarted, totalDiscussionsAnswered, contributedTo, + allTimeContributedTo, totalPRsAuthored, totalPRsCommented, totalPRsReviewed, @@ -432,6 +433,15 @@ const renderStatsCard = ( id: "contribs", }; + if (show.includes("all_time_contribs")) { + STATS["all_time_contribs"] = { + icon: icons.contribs, + label: i18n.t("statcard.all-time-contribs"), + value: allTimeContributedTo, + id: "all_time_contribs", + }; + } + const isLongLocale = locale ? LONG_LOCALES.includes(locale) : false; // check if all used labels are short diff --git a/packages/core/src/fetchers/stats.ts b/packages/core/src/fetchers/stats.ts index bb8579d53772b..cae8d023ac7f1 100644 --- a/packages/core/src/fetchers/stats.ts +++ b/packages/core/src/fetchers/stats.ts @@ -49,6 +49,7 @@ const reposFetcher = createGraphQLFetcher(UserReposDocument, "bearer"); * @param variables.includeDiscussionsAnswers Include discussions answers. * @param variables.startTime Time to start the count of total commits. * @param variables.ownerAffiliations The owner affiliations to filter by. Default: OWNER. + * @param variables.includeUserRepositories Whether to include the user's own repositories in the repos contributed. * @param variables.pat PAT override or null. * @returns The stats response, with every fetched page's repos merged in. * @@ -62,6 +63,7 @@ const statsFetcher = async ({ includeDiscussionsAnswers, startTime, ownerAffiliations, + includeUserRepositories, pat, }: { username: string; @@ -70,6 +72,7 @@ const statsFetcher = async ({ includeDiscussionsAnswers: boolean; startTime: string | undefined; ownerAffiliations: UserInfoQueryVariables["ownerAffiliations"]; + includeUserRepositories: boolean; pat: string | null; }): Promise => { // only the first request carries the stats themselves @@ -83,6 +86,7 @@ const statsFetcher = async ({ includeDiscussionsAnswers, startTime, ownerAffiliations, + includeUserRepositories, }, pat, ); @@ -358,12 +362,14 @@ const roundToNearestMidnight = (timestamp: number): number => * * @param username GitHub username. * @param ranges Ranges to fetch. + * @param includeOwnRepos Whether to include the user's own repos in the result. * @param pat Optional PAT override. * @returns The set of `nameWithOwner` repo identifiers. */ const fetchReposContributedTo = async ( username: string, ranges: Array, + includeOwnRepos: boolean, pat: string | null, ): Promise> => { const repos = new Set(); @@ -461,6 +467,13 @@ const fetchReposContributedTo = async ( pending = nextPending; } + if (!includeOwnRepos) { + for (const repo of repos) { + if (repo.startsWith(`${username}/`)) { + repos.delete(repo); + } + } + } return repos; }; @@ -476,19 +489,21 @@ const fetchReposContributedTo = async ( * * @param username GitHub username. * @param years Contribution years to walk. + * @param includeOwnRepos Whether to include the user's own repositories in the count. * @param pat Optional PAT override. * @returns Count of repositories. */ const fetchAllTimeReposContributedTo = async ( username: string, years: Array, + includeOwnRepos: boolean, pat: string | null = null, ): Promise => { const ranges: Array = years.map((year) => ({ from: new Date(Date.UTC(year, 0, 1)), to: new Date(Date.UTC(year, 11, 31, 23, 59, 59)), })); - const repos = await fetchReposContributedTo(username, ranges, pat); + const repos = await fetchReposContributedTo(username, ranges, includeOwnRepos, pat); return repos.size; }; @@ -511,6 +526,8 @@ const fetchAllTimeReposContributedTo = async ( * @param include_issues_commented Include count of issues commented. * @param ownerAffiliations Owner affiliations. Default: OWNER. * @param include_contributions Include all-time contributions. + * @param include_all_time_contribs Include all-time count of repos contributed to. + * @param contribs_include_own_repos Include user-owned repos in contributed-to counts. * @param pat Optional PAT override. * @returns Stats data. */ @@ -531,6 +548,8 @@ const fetchStats = async ( include_issues_commented = false, ownerAffiliations: Array = [], include_contributions = false, + include_all_time_contribs = false, + contribs_include_own_repos = false, pat: string | null = null, ): Promise => { if (!username) { @@ -549,6 +568,7 @@ const fetchStats = async ( totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, contributedTo: 0, + allTimeContributedTo: 0, totalPRsAuthored: 0, totalPRsCommented: 0, totalPRsReviewed: 0, @@ -566,6 +586,7 @@ const fetchStats = async ( includeDiscussionsAnswers: include_discussions_answers, startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined, ownerAffiliations: affiliations, + includeUserRepositories: contribs_include_own_repos, pat, }); @@ -650,17 +671,14 @@ const fetchStats = async ( ); } - // TODO: - // temporary: compute the all-time repositoriesContributedTo and just log it, - // until it's wired up as a real stat with query param + docs support. - const allTimeReposContributedTo = await fetchAllTimeReposContributedTo( - username, - user.contributionsCollection.contributionYears, - pat, - ); - logger.log( - `All-time repositoriesContributedTo for ${username}: ${allTimeReposContributedTo}`, - ); + if (include_all_time_contribs) { + stats.allTimeContributedTo = await fetchAllTimeReposContributedTo( + username, + user.contributionsCollection.contributionYears, + contribs_include_own_repos, + pat, + ); + } // Retrieve stars while filtering out repositories to be hidden. const allExcludedRepos = [ diff --git a/packages/core/src/fetchers/types.ts b/packages/core/src/fetchers/types.ts index 6fa65c44f44ce..116bb6c28d5e5 100644 --- a/packages/core/src/fetchers/types.ts +++ b/packages/core/src/fetchers/types.ts @@ -44,6 +44,7 @@ export interface StatsData { totalDiscussionsStarted: number; totalDiscussionsAnswered: number; contributedTo: number; + allTimeContributedTo: number; totalPRsAuthored: number; totalPRsCommented: number; totalPRsReviewed: number; diff --git a/packages/core/src/graphql/generated/stats.ts b/packages/core/src/graphql/generated/stats.ts index 9270bc679e5b2..316898af8eb09 100644 --- a/packages/core/src/graphql/generated/stats.ts +++ b/packages/core/src/graphql/generated/stats.ts @@ -47,6 +47,7 @@ export type UserInfoQueryVariables = Exact<{ | Types.RepositoryAffiliation | null | undefined; + includeUserRepositories: boolean; }>; export type UserInfoQuery = { @@ -126,7 +127,7 @@ export const UserInfoDocument = graphqlDocument< UserInfoQuery, UserInfoQueryVariables >(` -query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) { +query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation], $includeUserRepositories: Boolean!) { user(login: $login) { name login @@ -139,6 +140,7 @@ query userInfo($login: String!, $after: String, $includeMergedPullRequests: Bool repositoriesContributedTo( first: 1 contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY] + includeUserRepositories: $includeUserRepositories ) { totalCount } diff --git a/packages/core/src/graphql/queries/stats.graphql b/packages/core/src/graphql/queries/stats.graphql index ec0a6bfb1c866..ef94929e298cb 100644 --- a/packages/core/src/graphql/queries/stats.graphql +++ b/packages/core/src/graphql/queries/stats.graphql @@ -39,6 +39,7 @@ query userInfo( $includeDiscussionsAnswers: Boolean! $startTime: DateTime = null $ownerAffiliations: [RepositoryAffiliation] + $includeUserRepositories: Boolean! ) { user(login: $login) { name @@ -52,6 +53,7 @@ query userInfo( repositoriesContributedTo( first: 1 contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY] + includeUserRepositories: $includeUserRepositories ) { totalCount } @@ -92,22 +94,22 @@ fragment YearContributions on ContributionsCollection { } fragment RangeContributionsByRepo on ContributionsCollection { - commitContributionsByRepository(maxRepositories: $repoCap) { + commitContributionsByRepository(maxRepositories: $maxRepositories) { repository { nameWithOwner } } - issueContributionsByRepository(maxRepositories: $repoCap) { + issueContributionsByRepository(maxRepositories: $maxRepositories) { repository { nameWithOwner } } - pullRequestContributionsByRepository(maxRepositories: $repoCap) { + pullRequestContributionsByRepository(maxRepositories: $maxRepositories) { repository { nameWithOwner } } - repositoryContributions(first: $repoCap) { + repositoryContributions(first: $maxRepositories) { nodes { repository { nameWithOwner diff --git a/packages/core/src/graphql/reposContributedToDocument.ts b/packages/core/src/graphql/reposContributedToDocument.ts index eb45431525a42..0d3c9e8a6a8a5 100644 --- a/packages/core/src/graphql/reposContributedToDocument.ts +++ b/packages/core/src/graphql/reposContributedToDocument.ts @@ -43,28 +43,28 @@ const buildReposContributedToDocument = (ranges: Array) => { ReposContributedToQuery, ReposContributedToQueryVariables >(` -query userReposContributedTo($login: String!, $repoCap: Int!) { +query userReposContributedTo($login: String!, $maxRepositories: Int!) { user(login: $login) { ${rangeFields} } } fragment RangeContributionsByRepo on ContributionsCollection { - commitContributionsByRepository(maxRepositories: $repoCap) { + commitContributionsByRepository(maxRepositories: $maxRepositories) { repository { nameWithOwner } } - issueContributionsByRepository(maxRepositories: $repoCap) { + issueContributionsByRepository(maxRepositories: $maxRepositories) { repository { nameWithOwner } } - pullRequestContributionsByRepository(maxRepositories: $repoCap) { + pullRequestContributionsByRepository(maxRepositories: $maxRepositories) { repository { nameWithOwner } } - repositoryContributions(first: $repoCap) { + repositoryContributions(first: $maxRepositories) { nodes { repository { nameWithOwner diff --git a/packages/core/src/translations.ts b/packages/core/src/translations.ts index 0786e119d9876..95e0b795bbf81 100644 --- a/packages/core/src/translations.ts +++ b/packages/core/src/translations.ts @@ -362,6 +362,9 @@ const statCardLocales = ({ no: "Bidro til (i fjor)", be: "Уклад (за мінулы год)", }, + "statcard.all-time-contribs": { + en: "Contributed to (all time)", + }, "statcard.reviews": { en: "Total PRs Reviewed", ar: "طلبات السحب التي تم مراجعتها", From 9ef1554064691cb93b14cd3f02eb7edbe763fb92 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:12:16 +0200 Subject: [PATCH 4/7] tests and docs --- .../__snapshots__/api.test.js.snap | 2 +- .../src/content/docs/docs/cards/stats.md | 49 +++--- apps/frontend/src/content/docs/docs/fork.md | 6 + packages/core/src/fetchers/stats.ts | 7 +- packages/core/tests/fetchStats.test.ts | 140 +++++++++++++++++- packages/core/tests/renderStatsCard.test.ts | 11 ++ 6 files changed, 184 insertions(+), 31 deletions(-) diff --git a/apps/backend/tests/public-instance/__snapshots__/api.test.js.snap b/apps/backend/tests/public-instance/__snapshots__/api.test.js.snap index dee4aef1f0943..fed519e724c7e 100644 --- a/apps/backend/tests/public-instance/__snapshots__/api.test.js.snap +++ b/apps/backend/tests/public-instance/__snapshots__/api.test.js.snap @@ -699,7 +699,7 @@ exports[`Test /api contract > should match the public many-params response snaps ", - "graphqlRequest": "{"query":"\\nquery userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n name\\n login\\n commits: contributionsCollection(from: $startTime) {\\n totalCommitContributions\\n }\\n reviews: contributionsCollection {\\n totalPullRequestReviewContributions\\n }\\n repositoriesContributedTo(\\n first: 1\\n contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]\\n ) {\\n totalCount\\n }\\n pullRequests(first: 1) {\\n totalCount\\n }\\n mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {\\n totalCount\\n }\\n openIssues: issues(states: OPEN) {\\n totalCount\\n }\\n closedIssues: issues(states: CLOSED) {\\n totalCount\\n }\\n followers {\\n totalCount\\n }\\n repositoryDiscussions @include(if: $includeDiscussions) {\\n totalCount\\n }\\n repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {\\n totalCount\\n }\\n contributionsCollection {\\n contributionYears\\n }\\n ...RepoStars\\n }\\n}\\nfragment RepoStars on User {\\n repositories(\\n first: 100\\n after: $after\\n ownerAffiliations: $ownerAffiliations\\n orderBy: {direction: DESC, field: STARGAZERS}\\n ) {\\n totalCount\\n nodes {\\n ...RepoNode\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\\nfragment RepoNode on Repository {\\n name\\n stargazerCount\\n}","variables":{"login":"anuraghazra","after":null,"includeMergedPullRequests":true,"includeDiscussions":true,"includeDiscussionsAnswers":true,"startTime":"2024-01-01T00:00:00Z","ownerAffiliations":["OWNER","COLLABORATOR"]}}", + "graphqlRequest": "{"query":"\\nquery userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation], $includeUserRepositories: Boolean!) {\\n user(login: $login) {\\n name\\n login\\n commits: contributionsCollection(from: $startTime) {\\n totalCommitContributions\\n }\\n reviews: contributionsCollection {\\n totalPullRequestReviewContributions\\n }\\n repositoriesContributedTo(\\n first: 1\\n contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]\\n includeUserRepositories: $includeUserRepositories\\n ) {\\n totalCount\\n }\\n pullRequests(first: 1) {\\n totalCount\\n }\\n mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {\\n totalCount\\n }\\n openIssues: issues(states: OPEN) {\\n totalCount\\n }\\n closedIssues: issues(states: CLOSED) {\\n totalCount\\n }\\n followers {\\n totalCount\\n }\\n repositoryDiscussions @include(if: $includeDiscussions) {\\n totalCount\\n }\\n repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {\\n totalCount\\n }\\n contributionsCollection {\\n contributionYears\\n }\\n ...RepoStars\\n }\\n}\\nfragment RepoStars on User {\\n repositories(\\n first: 100\\n after: $after\\n ownerAffiliations: $ownerAffiliations\\n orderBy: {direction: DESC, field: STARGAZERS}\\n ) {\\n totalCount\\n nodes {\\n ...RepoNode\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n}\\nfragment RepoNode on Repository {\\n name\\n stargazerCount\\n}","variables":{"login":"anuraghazra","after":null,"includeMergedPullRequests":true,"includeDiscussions":true,"includeDiscussionsAnswers":true,"startTime":"2024-01-01T00:00:00Z","ownerAffiliations":["OWNER","COLLABORATOR"],"includeUserRepositories":false}}", "headers": [ [ "Cache-Control", diff --git a/apps/frontend/src/content/docs/docs/cards/stats.md b/apps/frontend/src/content/docs/docs/cards/stats.md index ee171084f0107..d8f9aa3bb6a86 100644 --- a/apps/frontend/src/content/docs/docs/cards/stats.md +++ b/apps/frontend/src/content/docs/docs/cards/stats.md @@ -26,10 +26,10 @@ You can pass a query parameter `&hide=` to hide any specific stats with comma-se You can pass a query parameter `&show=` to show any specific additional stats with comma-separated values. -> Options: `&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented` +> Options: `&show=all_time_contribs,reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented` ```md -![Anurag's GitHub stats](https://github-stats-extended.vercel.app/api?username=anuraghazra&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented) +![Anurag's GitHub stats](https://github-stats-extended.vercel.app/api?username=anuraghazra&show=all_time_contribs,reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented) ``` ## Showing icons @@ -60,28 +60,29 @@ You can specify a year and fetch only the commits that were made in that year by You can customize the appearance and behavior of the stats card using the [common options](/frontend/docs/customization/common-options/) and the exclusive options listed in the table below. -| Name | Description | Type | Default value | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------- | -| `hide` | Hides the [specified items](#hiding-individual-stats) from stats. | string (comma-separated values) | `null` | -| `hide_title` | Hides the title of your stats card. | boolean | `false` | -| `card_width` | Sets the card's width manually. | number | `500px (approx.)` | -| `hide_rank` | Hides the rank and automatically resizes the card width. | boolean | `false` | -| `rank_icon` | Shows alternative rank icon (i.e. `github`, `percentile` or `default`). | enum | `default` | -| `show_icons` | Shows icons near all stats. | boolean | `false` | -| `include_all_commits` | Count total commits instead of just the current year commits. | boolean | `false` | -| `line_height` | Sets the line height between text. | integer | `25` | -| `exclude_repo` | Excludes specified repositories. Affects only the count for "Total Stars Earned". | string (comma-separated values) | `null` | -| `repo` | Count only stats from the specified repositories. Affects only [certain items](#filtering-by-repository-and-owner). | string (comma-separated values) | `null` | -| `owner` | Count only stats from the specified organizations or users. Affects only [certain items](#filtering-by-repository-and-owner). | string (comma-separated values) | `null` | -| `role` | Include repositories where the user has one of the specified [roles](https://docs.github.com/en/graphql/reference/repos#enum-repositoryaffiliation) (OWNER, ORGANIZATION_MEMBER, COLLABORATOR). | string (comma-separated values) | `OWNER` | -| `custom_title` | Sets a custom title for the card. | string | ` GitHub Stats` | -| `text_bold` | Uses bold text. | boolean | `true` | -| `disable_animations` | Disables all animations in the card. | boolean | `false` | -| `ring_color` | Color of the rank circle. | string (hex color) | `2f80ed` | -| `number_format` | Switches between two available formats for displaying the card values: `short` (i.e. `6.6k`) and `long` (i.e. `6626`). | enum | `short` | -| `number_precision` | Enforce the number of digits after the decimal point for `short` number format. Must be an integer between 0 and 2. Will be ignored for `long` number format. | integer (0, 1 or 2) | `null` | -| `show` | Shows [additional items](#showing-additional-individual-stats) on stats card (i.e. `reviews`, `discussions_started`, `discussions_answered`, `prs_merged` or `prs_merged_percentage`. And the following, which support the `repo` and `owner` filters: `prs_authored`, `prs_commented`, `prs_reviewed`, `issues_authored` or `issues_commented`). | string (comma-separated values) | `null` | -| `commits_year` | Filters and counts only commits made in the specified year. | integer _(YYYY)_ | ` (one year to date)` | +| Name | Description | Type | Default value | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------- | +| `hide` | Hides the [specified items](#hiding-individual-stats) from stats. | string (comma-separated values) | `null` | +| `hide_title` | Hides the title of your stats card. | boolean | `false` | +| `card_width` | Sets the card's width manually. | number | `500px (approx.)` | +| `hide_rank` | Hides the rank and automatically resizes the card width. | boolean | `false` | +| `rank_icon` | Shows alternative rank icon (i.e. `github`, `percentile` or `default`). | enum | `default` | +| `show_icons` | Shows icons near all stats. | boolean | `false` | +| `include_all_commits` | Count total commits instead of just the current year commits. | boolean | `false` | +| `line_height` | Sets the line height between text. | integer | `25` | +| `exclude_repo` | Excludes specified repositories. Affects only the count for "Total Stars Earned". | string (comma-separated values) | `null` | +| `repo` | Count only stats from the specified repositories. Affects only [certain items](#filtering-by-repository-and-owner). | string (comma-separated values) | `null` | +| `owner` | Count only stats from the specified organizations or users. Affects only [certain items](#filtering-by-repository-and-owner). | string (comma-separated values) | `null` | +| `role` | Include repositories where the user has one of the specified [roles](https://docs.github.com/en/graphql/reference/repos#enum-repositoryaffiliation) (OWNER, ORGANIZATION_MEMBER, COLLABORATOR). | string (comma-separated values) | `OWNER` | +| `custom_title` | Sets a custom title for the card. | string | ` GitHub Stats` | +| `text_bold` | Uses bold text. | boolean | `true` | +| `disable_animations` | Disables all animations in the card. | boolean | `false` | +| `ring_color` | Color of the rank circle. | string (hex color) | `2f80ed` | +| `number_format` | Switches between two available formats for displaying the card values: `short` (i.e. `6.6k`) and `long` (i.e. `6626`). | enum | `short` | +| `number_precision` | Enforce the number of digits after the decimal point for `short` number format. Must be an integer between 0 and 2. Will be ignored for `long` number format. | integer (0, 1 or 2) | `null` | +| `show` | Shows [additional items](#showing-additional-individual-stats) on stats card (i.e. `all_time_contribs`, `reviews`, `discussions_started`, `discussions_answered`, `prs_merged` or `prs_merged_percentage`. And the following, which support the `repo` and `owner` filters: `prs_authored`, `prs_commented`, `prs_reviewed`, `issues_authored` or `issues_commented`). | string (comma-separated values) | `null` | +| `contribs_include_own_repos` | Includes the user's own repositories when calculating the `contribs` and `all_time_contribs` stats. By default, only repositories owned by other users or organizations are counted. | boolean | `false` | +| `commits_year` | Filters and counts only commits made in the specified year. | integer _(YYYY)_ | ` (one year to date)` | :::caution[Warning] Custom title should be URI-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding) (i.e: `Anurag's GitHub Stats` should become `Anurag%27s%20GitHub%20Stats`). You can use [urlencoder.org](https://www.urlencoder.org/) to help you do this automatically. diff --git a/apps/frontend/src/content/docs/docs/fork.md b/apps/frontend/src/content/docs/docs/fork.md index d1e57e1cd0f95..f17745e82bed3 100644 --- a/apps/frontend/src/content/docs/docs/fork.md +++ b/apps/frontend/src/content/docs/docs/fork.md @@ -32,6 +32,12 @@ GitHub-Stats-Extended proactively precomputes and caches cards. This solves the GitHub-Stats-Extended fetches up to 1000 of your starred repositories to accurately compute your stars count. In github-readme-stats, this is limited to 100 repos because github-readme-stats doesn't have the above-mentioned performance improvements. +### New options for "contributed-to" stats + +GitHub-Stats-Extended adds an `all_time_contribs` stat that shows the total number of repositories a user has contributed to across all years — not just the past year like the default `contribs` stat. Add [`&show=all_time_contribs`](/frontend/docs/cards/stats/#showing-additional-individual-stats) to your stats card URL to display it. + +GitHub-Stats-Extended also adds a parameter [`contribs_include_own_repos`](/frontend/docs/cards/stats/#options) to include the user's own repositories in the `contribs` and `all_time_contribs` stats. By default, both stats exclude the user's own repositories and only count repositories owned by other users or organizations. + ### Customization of top languages card GitHub-Stats-Extended allows you to display your top languages without any numbers via the `hide_values` parameter. And the `prog_bar_bg_color` parameter allows you to customize the background color of the progress bars, e.g. by setting it to white: diff --git a/packages/core/src/fetchers/stats.ts b/packages/core/src/fetchers/stats.ts index cae8d023ac7f1..387ad566253bb 100644 --- a/packages/core/src/fetchers/stats.ts +++ b/packages/core/src/fetchers/stats.ts @@ -503,7 +503,12 @@ const fetchAllTimeReposContributedTo = async ( from: new Date(Date.UTC(year, 0, 1)), to: new Date(Date.UTC(year, 11, 31, 23, 59, 59)), })); - const repos = await fetchReposContributedTo(username, ranges, includeOwnRepos, pat); + const repos = await fetchReposContributedTo( + username, + ranges, + includeOwnRepos, + pat, + ); return repos.size; }; diff --git a/packages/core/tests/fetchStats.test.ts b/packages/core/tests/fetchStats.test.ts index 8dddbf37d4f6b..102d145b165e1 100644 --- a/packages/core/tests/fetchStats.test.ts +++ b/packages/core/tests/fetchStats.test.ts @@ -52,6 +52,9 @@ const data_stats = { const data_year2003 = structuredClone(data_stats); data_year2003.data.user.commits.totalCommitContributions = 428; +const data_stats_with_own_repos = structuredClone(data_stats); +data_stats_with_own_repos.data.user.repositoriesContributedTo.totalCount = 75; + const data_without_pull_requests = { data: { user: { @@ -116,6 +119,37 @@ const data_contributions = { }, }; +const data_repos_contributed_to = { + data: { + user: { + range_0: { + commitContributionsByRepository: [ + { repository: { nameWithOwner: "org/repo1" } }, + ], + issueContributionsByRepository: [ + { repository: { nameWithOwner: "org/repo2" } }, + ], + pullRequestContributionsByRepository: [], + repositoryContributions: { + nodes: [{ repository: { nameWithOwner: "org/repo4" } }], + }, + }, + range_1: { + commitContributionsByRepository: [ + { repository: { nameWithOwner: "anuraghazra/own-repo" } }, + ], + issueContributionsByRepository: [], + pullRequestContributionsByRepository: [ + { repository: { nameWithOwner: "org/repo3" } }, + ], + repositoryContributions: { + nodes: [{ repository: { nameWithOwner: "org/repo2" } }], + }, + }, + }, + }, +}; + const error = { errors: [ { @@ -134,20 +168,28 @@ beforeEach(() => { loadConfigFromEnv(); mock.onPost("https://api.github.com/graphql").reply((cfg) => { const req = JSON.parse(cfg.data as string) as { - variables?: { startTime?: string }; + variables?: { startTime?: string; includeUserRepositories?: boolean }; query: string; }; if (req.variables?.startTime?.startsWith("2003")) { return [200, data_year2003]; } + if (req.query.includes("userReposContributedTo")) { + return [200, data_repos_contributed_to]; + } if (req.query.includes("contributionCalendar")) { return [200, data_contributions]; } - return [ - 200, - req.query.includes("totalCommitContributions") ? data_stats : data_repo, - ]; + if (req.query.includes("totalCommitContributions")) { + return [ + 200, + req.variables?.includeUserRepositories + ? data_stats_with_own_repos + : data_stats, + ]; + } + return [200, data_repo]; }); }); @@ -172,6 +214,7 @@ describe("Test fetchStats", () => { expect(stats).toStrictEqual({ contributedTo: 61, + allTimeContributedTo: 0, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, @@ -214,6 +257,7 @@ describe("Test fetchStats", () => { expect(stats).toStrictEqual({ contributedTo: 61, + allTimeContributedTo: 0, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, @@ -264,6 +308,7 @@ describe("Test fetchStats", () => { expect(stats).toStrictEqual({ contributedTo: 61, + allTimeContributedTo: 0, name: "Anurag Hazra", totalCommits: 1000, totalIssues: 200, @@ -323,6 +368,7 @@ describe("Test fetchStats", () => { expect(stats).toStrictEqual({ contributedTo: 61, + allTimeContributedTo: 0, name: "Anurag Hazra", totalCommits: 1000, totalIssues: 200, @@ -361,6 +407,7 @@ describe("Test fetchStats", () => { expect(stats).toStrictEqual({ contributedTo: 61, + allTimeContributedTo: 0, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, @@ -399,6 +446,7 @@ describe("Test fetchStats", () => { expect(stats).toStrictEqual({ contributedTo: 61, + allTimeContributedTo: 0, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, @@ -437,6 +485,7 @@ describe("Test fetchStats", () => { expect(stats).toStrictEqual({ contributedTo: 61, + allTimeContributedTo: 0, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, @@ -517,6 +566,7 @@ describe("Test fetchStats", () => { expect(stats).toStrictEqual({ contributedTo: 61, + allTimeContributedTo: 0, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, @@ -552,6 +602,7 @@ describe("Test fetchStats", () => { expect(stats).toStrictEqual({ contributedTo: 61, + allTimeContributedTo: 0, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, @@ -596,6 +647,7 @@ describe("Test fetchStats", () => { expect(stats).toStrictEqual({ contributedTo: 61, + allTimeContributedTo: 0, name: "Anurag Hazra", totalCommits: 428, totalIssues: 200, @@ -733,6 +785,7 @@ describe("Test fetchStats", () => { expect(stats).toStrictEqual({ contributedTo: 61, + allTimeContributedTo: 0, name: "Anurag Hazra", totalCommits: 100, totalIssues: 200, @@ -752,4 +805,81 @@ describe("Test fetchStats", () => { rank, }); }); + + it("should include own repos in contributed-to count when contribs_include_own_repos is true", async () => { + const statsWithout = await fetchStats("anuraghazra"); + expect(statsWithout.contributedTo).toBe(61); + + const statsWith = await fetchStats( + "anuraghazra", + false, + [], + false, + false, + false, + undefined, + [], + [], + false, + false, + false, + false, + false, + [], + false, + false, + true, // contribs_include_own_repos + ); + expect(statsWith.contributedTo).toBe(75); + }); + + it("should fetch all-time repos contributed to when include_all_time_contribs is true", async () => { + const stats = await fetchStats( + "anuraghazra", + false, + [], + false, + false, + false, + undefined, + [], + [], + false, + false, + false, + false, + false, + [], + false, + true, // include_all_time_contribs + false, // contribs_include_own_repos + ); + + expect(stats.allTimeContributedTo).toBe(4); + }); + + it("should include own repos in all-time contributed-to count when contribs_include_own_repos is true", async () => { + const stats = await fetchStats( + "anuraghazra", + false, + [], + false, + false, + false, + undefined, + [], + [], + false, + false, + false, + false, + false, + [], + false, + true, // include_all_time_contribs + true, // contribs_include_own_repos + ); + + expect(stats.allTimeContributedTo).toBe(5); + }); }); diff --git a/packages/core/tests/renderStatsCard.test.ts b/packages/core/tests/renderStatsCard.test.ts index 7c2c8c14aad29..27ac444482559 100644 --- a/packages/core/tests/renderStatsCard.test.ts +++ b/packages/core/tests/renderStatsCard.test.ts @@ -20,6 +20,7 @@ const stats: StatsData = { totalDiscussionsStarted: 10, totalDiscussionsAnswered: 50, contributedTo: 500, + allTimeContributedTo: 500, totalPRsAuthored: 100, totalPRsCommented: 100, totalPRsReviewed: 100, @@ -57,6 +58,7 @@ describe("Test renderStatsCard", () => { screen.queryByTestId("prs_merged_percentage"), ).not.toBeInTheDocument(); expect(screen.queryByTestId("contributions")).not.toBeInTheDocument(); + expect(screen.queryByTestId("all_time_contribs")).not.toBeInTheDocument(); }); it("should have proper name apostrophe", () => { @@ -130,6 +132,15 @@ describe("Test renderStatsCard", () => { expect(screen.getByTestId("contributions").textContent).toBe("5k"); }); + it("should show all_time_contribs stat when included in show list", () => { + document.body.innerHTML = renderStatsCard(stats, { + show: ["all_time_contribs"], + }); + + expect(screen.getByTestId("all_time_contribs")).toHaveTextContent("500"); + expect(screen.queryByTestId("all_time_contribs")).toBeInTheDocument(); + }); + it("should hide_rank", () => { document.body.innerHTML = renderStatsCard(stats, { hide_rank: true }); From b6ef01b6c6815309bee4d8e0b5bed3a4a2b2e054 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:39:33 +0200 Subject: [PATCH 5/7] adapt to merged docs --- apps/frontend/src/content/docs/docs/cards/stats.md | 9 +++++---- apps/frontend/src/content/docs/docs/fork.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/content/docs/docs/cards/stats.md b/apps/frontend/src/content/docs/docs/cards/stats.md index bfdfed9df031d..d64499b44dd03 100644 --- a/apps/frontend/src/content/docs/docs/cards/stats.md +++ b/apps/frontend/src/content/docs/docs/cards/stats.md @@ -26,15 +26,16 @@ You can pass a query parameter `&hide=` to hide any specific stats with comma-se You can pass a query parameter `&show=` to show any specific additional stats with comma-separated values. -> Options: `&show=all_time_contribs,contributions,reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented` +> Options: `&show=contributions,all_time_contribs,reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented` ```md -![Anurag's GitHub stats](https://github-stats-extended.vercel.app/api?username=anuraghazra&show=all_time_contribs,contributions,reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented) +![Anurag's GitHub stats](https://github-stats-extended.vercel.app/api?username=anuraghazra&show=contributions,all_time_contribs,reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented) ``` :::note -`contributions` counts contributions across all years; -the `contribs` item under `&hide=` counts repositories contributed to. +[`contributions`](/frontend/docs/fork/#new-contributions-stat) counts contributions across all years. +[`all_time_contribs`](/frontend/docs/fork/#new-options-for-contributed-to-stats) counts repositories the user has contributed to across all years. +The `contribs` stat under `&hide=` counts repositories the user has contributed to in the past year. ::: ## Showing icons diff --git a/apps/frontend/src/content/docs/docs/fork.md b/apps/frontend/src/content/docs/docs/fork.md index d67ba956d0c0c..14306f684c553 100644 --- a/apps/frontend/src/content/docs/docs/fork.md +++ b/apps/frontend/src/content/docs/docs/fork.md @@ -46,7 +46,7 @@ It works everywhere, including GitHub sponsorship pages, where the other light/d GitHub-Stats-Extended adds `light_github` and `dark_github` [themes](/frontend/docs/customization/themes/) that exactly match GitHub's own UI colors. For repo and gist cards use `light_github_repocard` and `dark_github_repocard`, which differ only in icon color. -### New Contributions stat +### New contributions stat GitHub-Stats-Extended adds an optional stat showing the number of [contributions](https://docs.github.com/en/account-and-profile/reference/profile-contributions-reference#what-counts-as-a-contribution) (commits, pull requests, issues, etc.) across all years of a user's history. Enable it with `&show=contributions`. Whether private contributions are counted depends on [your profile visibility settings](https://docs.github.com/en/account-and-profile/how-tos/contribution-settings/manage-visibility-settings-for-private-contributions-and-achievements#changing-the-visibility-of-your-private-contributions). From 92238f4dfdab7c28a2f732be7c238af6e9d438f4 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:08:25 +0200 Subject: [PATCH 6/7] test splitting, add logs, small docs changes --- apps/frontend/src/content/docs/docs/fork.md | 4 +- packages/core/src/fetchers/stats.ts | 12 ++++- packages/core/tests/fetchStats.test.ts | 57 ++++++++++++++++++++- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/content/docs/docs/fork.md b/apps/frontend/src/content/docs/docs/fork.md index 14306f684c553..c8ef887b79733 100644 --- a/apps/frontend/src/content/docs/docs/fork.md +++ b/apps/frontend/src/content/docs/docs/fork.md @@ -56,9 +56,9 @@ The pre-existing "Contributed to" stat counts repositories a user has contribute ### New options for "contributed-to" stats -GitHub-Stats-Extended adds an `all_time_contribs` stat that shows the total number of repositories a user has contributed to across all years — not just the past year like the default `contribs` stat. Add [`&show=all_time_contribs`](/frontend/docs/cards/stats/#showing-additional-individual-stats) to your stats card URL to display it. +GitHub-Stats-Extended adds an `all_time_contribs` stat that shows the number of repositories a user has contributed to across all years — not just the past year like the default `contribs` stat. Enable it with [`&show=all_time_contribs`](/frontend/docs/cards/stats/#showing-additional-individual-stats). -GitHub-Stats-Extended also adds a parameter [`contribs_include_own_repos`](/frontend/docs/cards/stats/#options) to include the user's own repositories in the `contribs` and `all_time_contribs` stats. By default, both stats exclude the user's own repositories and only count repositories owned by other users or organizations. +GitHub-Stats-Extended also adds a parameter [`contribs_include_own_repos`](/frontend/docs/cards/stats/#options) to include the user's own repositories in the `contribs` and `all_time_contribs` stats. By default, both stats exclude them and only count repositories owned by other users or organizations. ### Customization of top languages card diff --git a/packages/core/src/fetchers/stats.ts b/packages/core/src/fetchers/stats.ts index 387ad566253bb..14e0c23e6f87f 100644 --- a/packages/core/src/fetchers/stats.ts +++ b/packages/core/src/fetchers/stats.ts @@ -449,6 +449,11 @@ const fetchReposContributedTo = async ( nextPending.push({ from: mid, to: range.to }); return; } + if (isSaturated) { + logger.log( + `Range ${range.from.toISOString()} - ${range.to.toISOString()} is saturated but cannot be split further.`, + ); + } for (const { repository } of [ ...commitRepos, @@ -464,6 +469,11 @@ const fetchReposContributedTo = async ( } }); + if (nextPending.length > 0) { + logger.log( + `found ${pending.length} saturated ranges, splitting and retrying...`, + ); + } pending = nextPending; } @@ -483,7 +493,7 @@ const fetchReposContributedTo = async ( * * GitHub's `repositoriesContributedTo` field can only span one year. So we walk * every year individually via `contributionsCollection(from, to)` and - * de-duplicates the repo results. + * de-duplicate the repo results. * * Whether private contributions are included depends on the used PAT. * diff --git a/packages/core/tests/fetchStats.test.ts b/packages/core/tests/fetchStats.test.ts index 102d145b165e1..7257dce64492e 100644 --- a/packages/core/tests/fetchStats.test.ts +++ b/packages/core/tests/fetchStats.test.ts @@ -131,7 +131,7 @@ const data_repos_contributed_to = { ], pullRequestContributionsByRepository: [], repositoryContributions: { - nodes: [{ repository: { nameWithOwner: "org/repo4" } }], + nodes: [{ repository: { nameWithOwner: "org/repo3" } }], }, }, range_1: { @@ -140,7 +140,7 @@ const data_repos_contributed_to = { ], issueContributionsByRepository: [], pullRequestContributionsByRepository: [ - { repository: { nameWithOwner: "org/repo3" } }, + { repository: { nameWithOwner: "org/repo4" } }, ], repositoryContributions: { nodes: [{ repository: { nameWithOwner: "org/repo2" } }], @@ -882,4 +882,57 @@ describe("Test fetchStats", () => { expect(stats.allTimeContributedTo).toBe(5); }); + + it("should split saturated ranges until 1-day", async () => { + const saturatedRange = { + commitContributionsByRepository: Array.from({ length: 100 }, (_, i) => ({ + repository: { nameWithOwner: `org/repo${i}` }, + })), + issueContributionsByRepository: [], + pullRequestContributionsByRepository: [], + repositoryContributions: { nodes: [] }, + }; + + let requestCount = 0; + + mock.reset(); + mock.onPost("https://api.github.com/graphql").reply((cfg) => { + requestCount++; + const req = JSON.parse(cfg.data as string) as { query: string }; + + if (req.query.includes("userReposContributedTo")) { + const rangeCount = (req.query.match(/range_\d+:/g) ?? []).length; + const ranges: Record = {}; + for (let i = 0; i < rangeCount; i++) { + ranges[`range_${i}`] = saturatedRange; + } + return [200, { data: { user: ranges } }]; + } + return [200, data_stats]; + }); + + const stats = await fetchStats( + "anuraghazra", + false, + [], + false, + false, + false, + undefined, + [], + [], + false, + false, + false, + false, + false, + [], + false, + true, // include_all_time_contribs + false, + ); + + expect(stats.allTimeContributedTo).toBe(100); + expect(requestCount).toEqual(11); + }); }); From 4340024a9aab6be300bcefb13f9c3f8fd813963a Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:37:15 +0200 Subject: [PATCH 7/7] minor comment improvements --- packages/core/src/fetchers/stats.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/fetchers/stats.ts b/packages/core/src/fetchers/stats.ts index 14e0c23e6f87f..81b88a8e5eceb 100644 --- a/packages/core/src/fetchers/stats.ts +++ b/packages/core/src/fetchers/stats.ts @@ -49,7 +49,7 @@ const reposFetcher = createGraphQLFetcher(UserReposDocument, "bearer"); * @param variables.includeDiscussionsAnswers Include discussions answers. * @param variables.startTime Time to start the count of total commits. * @param variables.ownerAffiliations The owner affiliations to filter by. Default: OWNER. - * @param variables.includeUserRepositories Whether to include the user's own repositories in the repos contributed. + * @param variables.includeUserRepositories Whether to include the user's own repositories in the repos contributed to. * @param variables.pat PAT override or null. * @returns The stats response, with every fetched page's repos merged in. * @@ -356,7 +356,7 @@ const roundToNearestMidnight = (timestamp: number): number => * Fetch the repositories a user contributed to across every given range. * * All ranges still pending are queried together in a single request. Whenever a - * range's sub-collection returns `CONTRIBUTIONS_COLLECTION_REPO_CAP` results, + * range's sub-collection returns `MAX_REPOSITORIES_LIMIT` results, * that range is split and requeried in the next round, since the true count * could be higher and some repos may be missing from the response. *