diff --git a/api/index.js b/api/index.js index 6ea4ffe0c20e7..2a3a30923ad66 100644 --- a/api/index.js +++ b/api/index.js @@ -29,6 +29,8 @@ export default async (req, res) => { show_icons, include_all_commits, commits_year, + commits_end_year, + commits_api, line_height, title_color, ring_color, @@ -94,6 +96,8 @@ export default async (req, res) => { showStats.includes("discussions_started"), showStats.includes("discussions_answered"), parseInt(commits_year, 10), + parseInt(commits_end_year, 10), + commits_api, ); const cacheSeconds = resolveCacheSeconds({ requested: parseInt(cache_seconds, 10), diff --git a/readme.md b/readme.md index 6abfa97e75127..41c0a97c92229 100644 --- a/readme.md +++ b/readme.md @@ -374,7 +374,8 @@ If we don't support your language, please consider contributing! You can find mo | `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` | +| `commits_api` | API execution mode for the statistics card.

Values:
• `default` - default behavior (fetches data for the past year or for the specified year).
• `advanced` - uses GraphQL API to fetch all-time public commits by default. Can be extended to include private repositories via `include_all_commits`, or filtered by specific years via `commits_year` and `commits_end_year`.

_Note: The value is case-sensitive. If an invalid or unknown value is provided, it will safely fallback to the `default` behavior._ | enum | `default` | +| `include_all_commits` | Count total commits instead of just the current year commits.

If `commits_api` is set to `advanced`, setting `include_all_commits` to `true` will include private commits in the total count as well. | boolean | `false` | | `line_height` | Sets the line height between text. | integer | `25` | | `exclude_repo` | Excludes specified repositories. | string (comma-separated values) | `null` | | `custom_title` | Sets a custom title for the card. | string | ` GitHub Stats` | @@ -384,7 +385,8 @@ If we don't support your language, please consider contributing! You can find mo | `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`). | string (comma-separated values) | `null` | -| `commits_year` | Filters and counts only commits made in the specified year. | integer _(YYYY)_ | ` (one year to date)` | +| `commits_year` | Filters and counts only commits made in the specified year.

If `commits_api` is set to `advanced`, this parameter filters commits for the specified year, or serves as the start year when paired with `commits_end_year`. | integer _(YYYY)_ | ` (one year to date)` | +| `commits_end_year` | End year of the commits range.

Works only in conjunction with `commits_year` and when `commits_api` is set to `advanced`. | integer _(YYYY)_ | `undefined` | > [!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. @@ -392,6 +394,9 @@ If we don't support your language, please consider contributing! You can find mo > [!NOTE] > When hide\_rank=`true`, the minimum card width is 270 px + the title length and padding. +> [!NOTE] +> Set the `DISABLE_ADVANCED_COMMITS` environment variable to `true` to disable the `advanced` commits API mode globally. Although the advanced mode is highly optimized and fetches all data in a **single GraphQL request**, this toggle is provided as a safeguard to disable the feature if high-traffic instances hit strict GitHub API rate limits. When disabled, it safely falls back to the default behavior. + *** # GitHub Extra Pins diff --git a/src/fetchers/stats.js b/src/fetchers/stats.js index 376a15816144e..c998c434f7f9e 100644 --- a/src/fetchers/stats.js +++ b/src/fetchers/stats.js @@ -38,55 +38,113 @@ const GRAPHQL_REPOS_QUERY = ` } `; -const GRAPHQL_STATS_QUERY = ` - query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null) { - user(login: $login) { - name - login - commits: contributionsCollection (from: $startTime) { - totalCommitContributions, - } - reviews: contributionsCollection { - totalPullRequestReviewContributions - } - repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) { - totalCount - } - pullRequests(first: 1) { - totalCount - } - mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) { - totalCount - } - openIssues: issues(states: OPEN) { - totalCount - } - closedIssues: issues(states: CLOSED) { - totalCount - } - followers { - totalCount - } - repositoryDiscussions @include(if: $includeDiscussions) { - totalCount - } - repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) { - totalCount +const GRAPHQL_SHARED_STATS_FIELDS = ` + name + login + reviews: contributionsCollection { + totalPullRequestReviewContributions + } + repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) { + totalCount + } + pullRequests(first: 1) { + totalCount + } + mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) { + totalCount + } + openIssues: issues(states: OPEN) { + totalCount + } + closedIssues: issues(states: CLOSED) { + totalCount + } + followers { + totalCount + } + repositoryDiscussions @include(if: $includeDiscussions) { + totalCount + } + repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) { + totalCount + } + ${GRAPHQL_REPOS_FIELD} +`; + +/** + * Helper function for wrapping fields in the basic GraphQL userInfo query. + * Prevents duplication of the root structure and query parameters. + * + * @param {string} innerFields Internal GraphQL fields to query on the user object. + * @param {boolean} [hasStartTime=false] Flag to dynamically add the $startTime argument to the header. + * @returns {string} Full text of the GraphQL query. + */ +const wrapInUserInfoQuery = (innerFields, hasStartTime = false) => { + const startTimeArg = hasStartTime ? ", $startTime: DateTime = null" : ""; + return ` + query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!${startTimeArg}) { + user(login: $login) { + ${innerFields} } - ${GRAPHQL_REPOS_FIELD} + } + `; +}; + +const GRAPHQL_STATS_QUERY = wrapInUserInfoQuery( + ` + commits: contributionsCollection (from: $startTime) { + totalCommitContributions, + } + ${GRAPHQL_SHARED_STATS_FIELDS} +`, + true, +); + +const GRAPHQL_CREATION_QUERY = ` + query userCreation($login: String!) { + user(login: $login) { + createdAt } } `; +/** + * Dynamic generation of ONE packed GraphQL query for a range of years. + * + * @param {number} startYear Starting year of the range. + * @param {number} endYear End year of the range. + * @param {boolean} includeAllCommits Flag for including private commits in the query. + * @returns {string} GraphQL query text. + */ +const generateAdvancedStatsQuery = (startYear, endYear, includeAllCommits) => { + let yearlyCommitsFields = ""; + const privateField = includeAllCommits ? "restrictedContributionsCount" : ""; + for (let year = startYear; year <= endYear; year++) { + yearlyCommitsFields += ` + year_${year}: contributionsCollection(from: "${year}-01-01T00:00:00Z", to: "${year}-12-31T23:59:59Z") { + totalCommitContributions + ${privateField} + } + `; + } + + return wrapInUserInfoQuery(` + ${yearlyCommitsFields} + ${GRAPHQL_SHARED_STATS_FIELDS} + `); +}; + /** * Stats fetcher object. * - * @param {object & { after: string | null }} variables Fetcher variables. + * @param {object & { after: string | null, query?: string }} variables Fetcher variables. * @param {string} token GitHub token. * @returns {Promise} Axios response. */ const fetcher = (variables, token) => { - const query = variables.after ? GRAPHQL_REPOS_QUERY : GRAPHQL_STATS_QUERY; + const query = + variables.query || + (variables.after ? GRAPHQL_REPOS_QUERY : GRAPHQL_STATS_QUERY); return request( { query, @@ -107,6 +165,7 @@ const fetcher = (variables, token) => { * @param {boolean} variables.includeDiscussions Include discussions. * @param {boolean} variables.includeDiscussionsAnswers Include discussions answers. * @param {string|undefined} variables.startTime Time to start the count of total commits. + * @param {string} [variables.query] An external GraphQL query that is forced into the fetcher (e.g. for an advanced API). * @returns {Promise} Axios response. * * @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true. @@ -117,6 +176,7 @@ const statsFetcher = async ({ includeDiscussions, includeDiscussionsAnswers, startTime, + query, }) => { let stats; let hasNextPage = true; @@ -130,6 +190,7 @@ const statsFetcher = async ({ includeDiscussions, includeDiscussionsAnswers, startTime, + query, }; let res = await retryer(fetcher, variables); if (res.data.errors) { @@ -212,6 +273,127 @@ const totalCommitsFetcher = async (username) => { return totalCount; }; +/** + * Check GraphQL response for errors, log them, and throw corresponding CustomError. + * + * @param {import('axios').AxiosResponse} res The Axios/Fetch response object. + * @throws {CustomError} If the response contains GraphQL errors. + */ +const handleGraphQLErrors = (res) => { + if (!res.data.errors) { + return; + } + + logger.error(res.data.errors); + const firstError = res.data.errors[0]; + + if (firstError.type === "NOT_FOUND") { + throw new CustomError( + firstError.message || "Could not fetch user.", + CustomError.USER_NOT_FOUND, + ); + } + if (firstError.message) { + throw new CustomError( + wrapTextMultiline(firstError.message, 90, 1)[0], + res.statusText, + ); + } + throw new CustomError( + "Something went wrong while trying to retrieve the stats data using the GraphQL API.", + CustomError.GRAPHQL_ERROR, + ); +}; + +/** + * Determines whether the advanced commit count API should be used + * and dynamically generates the corresponding GraphQL query. + * + * @param {string} username GitHub username. + * @param {boolean} include_all_commits Flag for counting commits over time (including private ones). + * @param {number|undefined} commits_year Start year of the range or a specific year. + * @param {number|undefined} commits_end_year The end year of the range of years. + * @returns {Promise<{ query?: string, startYear?: number, endYear?: number }>} An object containing the generated query and resolved year boundaries if conditions are met; an empty object otherwise. + */ +const resolveAdvancedStatsQuery = async ( + username, + include_all_commits, + commits_year, + commits_end_year, +) => { + const useYearRange = + commits_year !== undefined && + commits_end_year !== undefined && + !isNaN(commits_year) && + !isNaN(commits_end_year) && + commits_end_year >= commits_year; + + if (useYearRange) { + return { + query: generateAdvancedStatsQuery( + commits_year, + commits_end_year, + include_all_commits, + ), + startYear: commits_year, + endYear: commits_end_year, + }; + } + + if (!include_all_commits) { + return {}; + } + + if (commits_year) { + return { + query: generateAdvancedStatsQuery( + commits_year, + commits_year, + include_all_commits, + ), + startYear: commits_year, + endYear: commits_year, + }; + } + + const res = await retryer(fetcher, { + login: username, + query: GRAPHQL_CREATION_QUERY, + }); + + // Catch GraphQL errors. + handleGraphQLErrors(res); + + const startYear = new Date(res.data.data.user.createdAt).getFullYear(); + const endYear = new Date().getFullYear(); + + return { + query: generateAdvancedStatsQuery(startYear, endYear, include_all_commits), + startYear, + endYear, + }; +}; + +const commitsApiValue = { + advanced: "advanced", + default: "default", +}; + +/** + * Returns the validated value for the Commits API. + * + * @param {string|undefined} value commits api value. + * @returns {string} validated commits api value. + */ +const getValidatedCommitsApiValue = (value) => { + switch (value) { + case commitsApiValue.advanced: + return value; + default: + return commitsApiValue.default; + } +}; + /** * Fetch stats for a given username. * @@ -222,6 +404,8 @@ const totalCommitsFetcher = async (username) => { * @param {boolean} include_discussions Include discussions. * @param {boolean} include_discussions_answers Include discussions answers. * @param {number|undefined} commits_year Year to count total commits + * @param {number|undefined} [commits_end_year] End year of the range + * @param {string} [commits_api] API execution mode for the statistics card (e.g., "advanced"). * @returns {Promise} Stats data. */ const fetchStats = async ( @@ -232,6 +416,8 @@ const fetchStats = async ( include_discussions = false, include_discussions_answers = false, commits_year, + commits_end_year, + commits_api, ) => { if (!username) { throw new MissingParamError(["username"]); @@ -252,41 +438,53 @@ const fetchStats = async ( rank: { level: "C", percentile: 100 }, }; - let res = await statsFetcher({ + const commitsApi = getValidatedCommitsApiValue(commits_api); + const isAdvancedDisabled = process.env.DISABLE_ADVANCED_COMMITS === "true"; + + const advancedStatsQueryInfo = + commitsApi === commitsApiValue.advanced && !isAdvancedDisabled + ? await resolveAdvancedStatsQuery( + username, + include_all_commits, + commits_year, + commits_end_year, + ) + : {}; + + const res = await statsFetcher({ username, includeMergedPullRequests: include_merged_pull_requests, includeDiscussions: include_discussions, includeDiscussionsAnswers: include_discussions_answers, startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined, + query: advancedStatsQueryInfo.query, }); // Catch GraphQL errors. - if (res.data.errors) { - logger.error(res.data.errors); - if (res.data.errors[0].type === "NOT_FOUND") { - throw new CustomError( - res.data.errors[0].message || "Could not fetch user.", - CustomError.USER_NOT_FOUND, - ); - } - if (res.data.errors[0].message) { - throw new CustomError( - wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], - res.statusText, - ); - } - throw new CustomError( - "Something went wrong while trying to retrieve the stats data using the GraphQL API.", - CustomError.GRAPHQL_ERROR, - ); - } + handleGraphQLErrors(res); const user = res.data.data.user; stats.name = user.name || user.login; - // if include_all_commits, fetch all commits using the REST API. - if (include_all_commits) { + if (advancedStatsQueryInfo.query) { + const startYear = advancedStatsQueryInfo.startYear || 0; + const endYear = advancedStatsQueryInfo.endYear || -1; + + let totalCommits = 0; + + for (let year = startYear; year <= endYear; year++) { + const yearBlock = user[`year_${year}`]; + if (yearBlock) { + totalCommits += + (yearBlock.totalCommitContributions || 0) + + (yearBlock.restrictedContributionsCount || 0); + } + } + + stats.totalCommits = totalCommits; + } else if (include_all_commits) { + // if include_all_commits, fetch all commits using the REST API. stats.totalCommits = await totalCommitsFetcher(username); } else { stats.totalCommits = user.commits.totalCommitContributions; diff --git a/tests/fetchStats.test.js b/tests/fetchStats.test.js index 6c7edb46a34b4..60fc17640ebab 100644 --- a/tests/fetchStats.test.js +++ b/tests/fetchStats.test.js @@ -115,6 +115,30 @@ beforeEach(() => { ) { return [200, data_year2003]; } + + if ( + req.variables.commits_api === "advanced" || + cfg.data.includes("year_") + ) { + const advancedData = JSON.parse(JSON.stringify(data_stats)); + delete advancedData.data.user.commits; + + advancedData.data.user.year_2025 = { + totalCommitContributions: 100, + restrictedContributionsCount: 50, + }; + advancedData.data.user.year_2026 = { + totalCommitContributions: 200, + restrictedContributionsCount: 25, + }; + + return [200, advancedData]; + } + + if (cfg.data.includes("userCreation")) { + return [200, { data: { user: { createdAt: "2025-01-01T00:00:00Z" } } }]; + } + return [ 200, req.query.includes("totalCommitContributions") ? data_stats : data_repo, @@ -505,3 +529,178 @@ describe("Test fetchStats", () => { }); }); }); + +describe("FetchStats Advanced API Core Logic", () => { + it("should correctly fetch and sum public and private commits for a valid year range", async () => { + const stats = await fetchStats( + "anuraghazra", + true, + [], + false, + false, + false, + 2025, + 2026, + "advanced", + ); + + expect(stats.totalCommits).toBe(375); + }); + + it("should correctly fetch and sum commits for complete all-time using userCreation fallback", async () => { + const stats = await fetchStats( + "anuraghazra", + true, + [], + false, + false, + false, + undefined, + undefined, + "advanced", + ); + + expect(stats.totalCommits).toBe(375); + }); + + it("should return empty object from resolver if advanced api is on but range and include_all_commits are false", async () => { + const stats = await fetchStats( + "anuraghazra", + false, + [], + false, + false, + false, + undefined, + undefined, + "advanced", + ); + + expect(stats.totalCommits).toBe(100); + }); + + it("should resolve query for a single year when include_all_commits is true", async () => { + const localMock = new MockAdapter(axios); + + const advancedData = JSON.parse(JSON.stringify(data_stats)); + delete advancedData.data.user.commits; + + advancedData.data.user.year_2025 = { + totalCommitContributions: 150, + restrictedContributionsCount: 50, + }; + + localMock.onPost(new RegExp("/graphql")).reply(200, advancedData); + + try { + const stats = await fetchStats( + "anuraghazra", + true, + [], + false, + false, + false, + 2025, + undefined, + "advanced", + ); + + expect(stats.totalCommits).toBe(200); + } finally { + localMock.restore(); + } + }); +}); + +describe("FetchStats Advanced API Error Handling Logic", () => { + it("should correctly handle and throw CustomError for NOT_FOUND GraphQL errors", async () => { + const localMock = new MockAdapter(axios); + + localMock.onPost(new RegExp("/graphql")).reply(200, { + errors: [ + { + type: "NOT_FOUND", + message: "Could not resolve to a User with the login of 'noname'.", + }, + ], + }); + + try { + await expect( + fetchStats( + "noname", + true, + [], + false, + false, + false, + undefined, + undefined, + "advanced", + ), + ).rejects.toThrow( + "Could not resolve to a User with the login of 'noname'.", + ); + } finally { + localMock.restore(); + } + }); + + it("should correctly handle and throw generic GraphQL errors with a message", async () => { + const localMock = new MockAdapter(axios); + + localMock.onPost(new RegExp("/graphql")).reply(200, { + errors: [ + { + message: "Some internal GitHub schema error occurred.", + }, + ], + }); + + try { + await expect( + fetchStats( + "anuraghazra", + true, + [], + false, + false, + false, + undefined, + undefined, + "advanced", + ), + ).rejects.toThrow("Some internal GitHub schema error occurred."); + } finally { + localMock.restore(); + } + }); + + it("should throw generic GraphQL error if error object has no type or message", async () => { + const localMock = new MockAdapter(axios); + + localMock.onPost(new RegExp("/graphql")).reply(200, { + errors: [{}], + }); + + try { + await expect( + fetchStats( + "anuraghazra", + true, + [], + false, + false, + false, + undefined, + undefined, + "advanced", + ), + ).rejects.toThrow( + "Something went wrong while trying to retrieve the stats data using the GraphQL API.", + ); + } finally { + localMock.restore(); + } + }); +});