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 33b4290d06fa5..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=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=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 @@ -65,28 +66,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`1 | 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 the stats card (i.e. `contributions`, `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`1 | 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`, `contributions`, `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)` | 1: Supports light and dark mode via `ring_color_light` and `ring_color_dark`. diff --git a/apps/frontend/src/content/docs/docs/fork.md b/apps/frontend/src/content/docs/docs/fork.md index 2468d91b5676a..c8ef887b79733 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). @@ -54,6 +54,12 @@ GitHub-Stats-Extended adds an optional stat showing the number of [contributions The pre-existing "Contributed to" stat counts repositories a user has contributed to, not contributions. ::: +### New options for "contributed-to" stats + +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 them and only count repositories owned by other users or organizations. + ### Customization of top languages card GitHub-Stats-Extended can show your top languages without any numbers via the `hide_values` parameter. And the new `prog_bar_bg_color` parameter sets the background color of progress bars, e.g. to transparent: 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 1ee51fc694597..81b88a8e5eceb 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"; @@ -44,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 to. * @param variables.pat PAT override or null. * @returns The stats response, with every fetched page's repos merged in. * @@ -57,6 +63,7 @@ const statsFetcher = async ({ includeDiscussionsAnswers, startTime, ownerAffiliations, + includeUserRepositories, pat, }: { username: string; @@ -65,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 @@ -78,6 +86,7 @@ const statsFetcher = async ({ includeDiscussionsAnswers, startTime, ownerAffiliations, + includeUserRepositories, }, pat, ); @@ -332,6 +341,187 @@ 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; + +/** + * 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 `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. + * + * @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(); + let pending = ranges; + + while (pending.length > 0) { + const document = buildReposContributedToDocument(pending); + 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, + ), + ); + // 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), + }); + 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, + ...issueRepos, + ...prRepos, + ]) { + repos.add(repository.nameWithOwner); + } + for (const node of createdRepoNodes) { + if (node) { + repos.add(node.repository.nameWithOwner); + } + } + }); + + if (nextPending.length > 0) { + logger.log( + `found ${pending.length} saturated ranges, splitting and retrying...`, + ); + } + pending = nextPending; + } + + if (!includeOwnRepos) { + for (const repo of repos) { + if (repo.startsWith(`${username}/`)) { + repos.delete(repo); + } + } + } + return repos; +}; + +/** + * Calculates the count of repositories the user contributed to, across every + * contribution year. + * + * GitHub's `repositoriesContributedTo` field can only span one year. So we walk + * every year individually via `contributionsCollection(from, to)` and + * de-duplicate the repo results. + * + * Whether private contributions are included depends on the used PAT. + * + * @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, + includeOwnRepos, + pat, + ); + return repos.size; +}; + /** * Fetch stats for a given username. * @@ -351,6 +541,8 @@ const fetchTotalContributions = 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. */ @@ -371,6 +563,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) { @@ -389,6 +583,7 @@ const fetchStats = async ( totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, contributedTo: 0, + allTimeContributedTo: 0, totalPRsAuthored: 0, totalPRsCommented: 0, totalPRsReviewed: 0, @@ -406,6 +601,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, }); @@ -490,6 +686,15 @@ const fetchStats = async ( ); } + 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 = [ ...exclude_repo, 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 c2b1d718d721a..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 = { @@ -76,6 +77,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 @@ -111,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 @@ -124,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 d99a27b086d55..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 } @@ -90,3 +92,28 @@ fragment YearContributions on ContributionsCollection { totalContributions } } + +fragment RangeContributionsByRepo on ContributionsCollection { + commitContributionsByRepository(maxRepositories: $maxRepositories) { + repository { + nameWithOwner + } + } + issueContributionsByRepository(maxRepositories: $maxRepositories) { + repository { + nameWithOwner + } + } + pullRequestContributionsByRepository(maxRepositories: $maxRepositories) { + repository { + nameWithOwner + } + } + repositoryContributions(first: $maxRepositories) { + 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..0d3c9e8a6a8a5 --- /dev/null +++ b/packages/core/src/graphql/reposContributedToDocument.ts @@ -0,0 +1,78 @@ +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 date range to query for contributions. */ +interface ContributionRange { + from: Date; + to: Date; +} + +/** + * 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`. + * + * @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.toISOString()}", to: "${to.toISOString()}") { ...RangeContributionsByRepo }`, + ) + .join("\n"); + + // fragment must match queries/stats.graphql, which generates its type + return graphqlDocument< + ReposContributedToQuery, + ReposContributedToQueryVariables + >(` +query userReposContributedTo($login: String!, $maxRepositories: Int!) { + user(login: $login) { + ${rangeFields} + } +} +fragment RangeContributionsByRepo on ContributionsCollection { + commitContributionsByRepository(maxRepositories: $maxRepositories) { + repository { + nameWithOwner + } + } + issueContributionsByRepository(maxRepositories: $maxRepositories) { + repository { + nameWithOwner + } + } + pullRequestContributionsByRepository(maxRepositories: $maxRepositories) { + repository { + nameWithOwner + } + } + repositoryContributions(first: $maxRepositories) { + nodes { + repository { + nameWithOwner + } + } + } +}`); +}; + +export { buildReposContributedToDocument, MAX_REPOSITORIES_LIMIT }; +export type { ContributionRange }; 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: "طلبات السحب التي تم مراجعتها", diff --git a/packages/core/tests/fetchStats.test.ts b/packages/core/tests/fetchStats.test.ts index 8dddbf37d4f6b..7257dce64492e 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/repo3" } }], + }, + }, + range_1: { + commitContributionsByRepository: [ + { repository: { nameWithOwner: "anuraghazra/own-repo" } }, + ], + issueContributionsByRepository: [], + pullRequestContributionsByRepository: [ + { repository: { nameWithOwner: "org/repo4" } }, + ], + 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,134 @@ 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); + }); + + 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); + }); }); 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 });