diff --git a/src/fetchers/stats-fetcher.js b/src/fetchers/stats-fetcher.js index 115cd50a51564..05f41f541bfaf 100644 --- a/src/fetchers/stats-fetcher.js +++ b/src/fetchers/stats-fetcher.js @@ -167,34 +167,160 @@ const statsFetcher = async ({ * @description Done like this because the GitHub API does not provide a way to fetch all the commits. See * #92#issuecomment-661026467 and #211 for more information. */ +// Fetch all the commits for all the repositories of a given username. const totalCommitsFetcher = async (username) => { if (!githubUsernameRegex.test(username)) { logger.log("Invalid username provided."); throw new Error("Invalid username provided."); } - // https://developer.github.com/v3/search/#search-commits - const fetchTotalCommits = (variables, token) => { - return axios({ - method: "get", - url: `https://api.github.com/search/commits?q=author:${variables.login}`, - headers: { - "Content-Type": "application/json", - Accept: "application/vnd.github.cloak-preview", - Authorization: `token ${token}`, - }, - }); + const fetchTotalCommits = async (variables, token) => { + // REST request (old method) + let restRes; + try { + restRes = await axios({ + method: "get", + url: `https://api.github.com/search/commits?q=author:${variables.login}`, + headers: { + "Content-Type": "application/json", + Accept: "application/vnd.github.cloak-preview", + Authorization: `token ${token}`, + }, + }); + } catch (err) { + logger.error("REST /search/commits failed:", err.message || err); + throw new Error(err); + } + + if (restRes?.data && restRes.data.error) { + throw new Error("Could not fetch total commits."); + } + + let baselineTotal = restRes?.data?.total_count; + + try { + // First get account created year using GraphQL + // Include createdAt so we can set start year. + const userQuery = ` + query($login: String!) { + user(login: $login) { + createdAt + } + } + `; + + const userRes = await axios({ + method: "post", + url: "https://api.github.com/graphql", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + data: JSON.stringify({ + query: userQuery, + variables: { login: variables.login }, + }), + }); + + const createdAt = userRes?.data?.data?.user?.createdAt; + const startYear = createdAt ? new Date(createdAt).getFullYear() : null; + const currentYear = new Date().getFullYear(); + + // otherwise iterate years from startYear..currentYear. + const years = startYear + ? Array.from( + { length: currentYear - startYear + 1 }, + (_, i) => startYear + i, + ) + : [currentYear]; + + // GraphQL per-year query returns totalContributions for that year. + const gqlQuery = ` + query($login: String!, $from: DateTime!, $to: DateTime!) { + user(login: $login) { + contributionsCollection(from: $from, to: $to) { + contributionCalendar { + totalContributions + } + } + } + } + `; + + const yearPromises = years.map(async (y) => { + const from = `${y}-01-01T00:00:00Z`; + const to = `${y}-12-31T23:59:59Z`; + const res = await axios({ + method: "post", + url: "https://api.github.com/graphql", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + data: JSON.stringify({ + query: gqlQuery, + variables: { login: variables.login, from, to }, + }), + }); + + return ( + res?.data?.data?.user?.contributionsCollection?.contributionCalendar + ?.totalContributions || 0 + ); + }); + + const contributionsByYear = await Promise.all(yearPromises); + const totalContributions = contributionsByYear.reduce((a, b) => a + b, 0); + + // If GraphQL returned a sensible total, use it by overriding restRes.data.total_count + if ( + typeof totalContributions === "number" && + !isNaN(totalContributions) + ) { + restRes.data.total_count = totalContributions; + return restRes; + } + // otherwise, fall through to return restRes baseline + } catch (err) { + logger.error( + "GraphQL-based commit aggregation failed, falling back to REST:", + err.response?.data || err.message || err, + ); + } + + // If REST baseline exists and is numeric, return the REST response. + if (typeof baselineTotal === "number" && !isNaN(baselineTotal)) { + return restRes; + } + + // Nothing worked + throw new CustomError( + "Could not fetch total commits.", + CustomError.GITHUB_REST_API_ERROR, + ); }; let res; try { res = await retryer(fetchTotalCommits, { login: username }); } catch (err) { + console.log(err); logger.log(err); - throw new Error(err); + throw new CustomError( + "Could not fetch total commits.", + CustomError.GITHUB_REST_API_ERROR, + ); + } + + if (!res || !res.data) { + throw new CustomError( + "Could not fetch total commits.", + CustomError.GITHUB_REST_API_ERROR, + ); } - const totalCount = res.data.total_count; + const totalCount = res.data?.total_count; + if (!totalCount || isNaN(totalCount)) { throw new CustomError( "Could not fetch total commits.", diff --git a/tests/fetchStats.test.js b/tests/fetchStats.test.js index ca8d7bc37062e..e0f2dc843e2c1 100644 --- a/tests/fetchStats.test.js +++ b/tests/fetchStats.test.js @@ -182,7 +182,38 @@ describe("Test fetchStats", () => { .onGet("https://api.github.com/search/commits?q=author:anuraghazra") .reply(200, { total_count: 1000 }); - let stats = await fetchStats("anuraghazra", true); + mock.onPost("https://api.github.com/graphql").reply(() => { + const response = { + data: { + user: { + name: "Anurag Hazra", + repositories: { + nodes: [ + { name: "repo-1", stargazers: { totalCount: 150 } }, + { name: "repo-2", stargazers: { totalCount: 150 } }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + contributionsCollection: { + totalPullRequestReviewContributions: 50, + contributionCalendar: { totalContributions: 1000 }, + }, + pullRequests: { totalCount: 300 }, + mergedPullRequests: { totalCount: 0 }, + openIssues: { totalCount: 200 }, + closedIssues: { totalCount: 0 }, + repositoryDiscussions: { totalCount: 0 }, + repositoriesContributedTo: { totalCount: 61 }, + followers: { totalCount: 100 }, + }, + }, + }; + + return [200, response]; + }); + + const stats = await fetchStats("anuraghazra", true); + const rank = calculateRank({ all_commits: true, commits: 1000, @@ -231,7 +262,36 @@ describe("Test fetchStats", () => { .onGet("https://api.github.com/search/commits?q=author:anuraghazra") .reply(200, { total_count: 1000 }); - let stats = await fetchStats("anuraghazra", true, ["test-repo-1"]); + // Mock GitHub GraphQL API for repos and contributions + mock.onPost("https://api.github.com/graphql").reply(200, { + data: { + user: { + name: "Anurag Hazra", + repositories: { + nodes: [ + { name: "test-repo-1", stargazers: { totalCount: 100 } }, // to exclude + { name: "repo-2", stargazers: { totalCount: 50 } }, + { name: "repo-3", stargazers: { totalCount: 50 } }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + contributionsCollection: { + totalPullRequestReviewContributions: 50, + contributionCalendar: { totalContributions: 1000 }, + }, + pullRequests: { totalCount: 300 }, + mergedPullRequests: { totalCount: 0 }, + openIssues: { totalCount: 200 }, + closedIssues: { totalCount: 0 }, + repositoryDiscussions: { totalCount: 0 }, + repositoriesContributedTo: { totalCount: 61 }, + followers: { totalCount: 100 }, + }, + }, + }); + + const stats = await fetchStats("anuraghazra", true, ["test-repo-1"]); + const rank = calculateRank({ all_commits: true, commits: 1000, @@ -239,7 +299,7 @@ describe("Test fetchStats", () => { reviews: 50, issues: 200, repos: 5, - stars: 200, + stars: 100, // 50 + 50 excluding 100 from test-repo-1 followers: 100, }); @@ -252,7 +312,7 @@ describe("Test fetchStats", () => { totalPRsMerged: 0, mergedPRsPercentage: 0, totalReviews: 50, - totalStars: 200, + totalStars: 100, totalDiscussionsStarted: 0, totalDiscussionsAnswered: 0, rank,