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();
+ }
+ });
+});