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 784234ff773d8..dee4aef1f0943 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 ...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]) {\\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"]}}", "headers": [ [ "Cache-Control", diff --git a/apps/backend/tests/utils.js b/apps/backend/tests/utils.js index 739845ca5b807..069297f7cd2b5 100644 --- a/apps/backend/tests/utils.js +++ b/apps/backend/tests/utils.js @@ -8,6 +8,9 @@ export const data_stats = { name: "Anurag Hazra", login: "anuraghazra", repositoriesContributedTo: { totalCount: 51 }, + contributionsCollection: { + contributionYears: [2022, 2024], + }, commits: { totalCommitContributions: 200, }, diff --git a/apps/frontend/src/pages/Home/buildCardUrl.test.ts b/apps/frontend/src/pages/Home/buildCardUrl.test.ts index 350eecd12c848..9e3b3820868b6 100644 --- a/apps/frontend/src/pages/Home/buildCardUrl.test.ts +++ b/apps/frontend/src/pages/Home/buildCardUrl.test.ts @@ -53,7 +53,7 @@ describe("buildCardUrl", () => { "?username=john" + "&hide_title=true" + // commas are percent-encoded (%2C) now that params go through URLSearchParams - "&show=reviews%2Cdiscussions_started%2Cdiscussions_answered%2Cprs_merged%2Cprs_merged_percentage%2Cprs_commented%2Cprs_reviewed%2Cissues_commented" + + "&show=reviews%2Cdiscussions_started%2Cdiscussions_answered%2Cprs_merged%2Cprs_merged_percentage%2Cprs_commented%2Cprs_reviewed%2Cissues_commented%2Ccontributions" + "&show_icons=true" + "&include_all_commits=true", ); diff --git a/apps/frontend/src/pages/Home/buildCardUrl.ts b/apps/frontend/src/pages/Home/buildCardUrl.ts index 70743b823612c..28081a0c2564a 100644 --- a/apps/frontend/src/pages/Home/buildCardUrl.ts +++ b/apps/frontend/src/pages/Home/buildCardUrl.ts @@ -54,7 +54,7 @@ export function buildCardUrl( } if (showAllStats) { url = url.show( - "reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented", + "reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented,contributions", ); } if (showIcons) { diff --git a/packages/core/src/api/index.js b/packages/core/src/api/index.js index 52c381b122f54..c51328a3f5b75 100644 --- a/packages/core/src/api/index.js +++ b/packages/core/src/api/index.js @@ -105,6 +105,7 @@ export default async ( showStats.includes("issues_authored"), showStats.includes("issues_commented"), parseArray(role), + showStats.includes("contributions"), pat, ); diff --git a/packages/core/src/cards/stats.ts b/packages/core/src/cards/stats.ts index a12e8fd0080b7..b5dc277b05a6c 100644 --- a/packages/core/src/cards/stats.ts +++ b/packages/core/src/cards/stats.ts @@ -247,6 +247,7 @@ const renderStatsCard = ( totalPRsReviewed, totalIssuesAuthored, totalIssuesCommented, + totalContributions, rank, } = stats; const { @@ -293,6 +294,16 @@ const renderStatsCard = ( value: totalStars, id: "stars", }; + + if (show.includes("contributions")) { + STATS["contributions"] = { + icon: icons.contributions, + label: i18n.t("statcard.contributions"), + value: totalContributions, + id: "contributions", + }; + } + STATS["commits"] = { icon: icons.commits, label: `${i18n.t("statcard.commits")}${getTotalCommitsYearLabel( diff --git a/packages/core/src/common/icons.ts b/packages/core/src/common/icons.ts index 64bc3b523f310..9e8b2f636a94c 100644 --- a/packages/core/src/common/icons.ts +++ b/packages/core/src/common/icons.ts @@ -38,6 +38,7 @@ const icons = { discussions_started: ``, discussions_answered: ``, comments: ``, + contributions: ``, gist: ``, }; diff --git a/packages/core/src/fetchers/stats.ts b/packages/core/src/fetchers/stats.ts index c0f082fa7f718..1ee51fc694597 100644 --- a/packages/core/src/fetchers/stats.ts +++ b/packages/core/src/fetchers/stats.ts @@ -11,6 +11,7 @@ import type { GraphQLResponse } from "../common/http.js"; import { logger } from "../common/log.js"; import { buildSearchFilter, parseOwnerAffiliations } from "../common/ops.js"; import { retryer } from "../common/retryer.js"; +import { buildContributionsDocument } from "../graphql/contributionsDocument.js"; import { UserInfoDocument, UserReposDocument, @@ -274,6 +275,63 @@ const fetchRepoUserStats = async ( return stats; }; +/** + * Fetch all-time contributions by building a single GraphQL query + * for all the given years. + * + * Whether private contributions are included depends on the user's profile 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 + */ +const fetchTotalContributions = async ( + username: string, + years: Array, + pat: string | null = null, +): Promise => { + if (years.length === 0) { + return 0; + } + + const contributionsFetcher = createGraphQLFetcher( + buildContributionsDocument(years), + "bearer", + ); + + const contribRes = await retryer( + contributionsFetcher, + { login: username }, + pat, + ); + + if (contribRes.data.errors) { + logger.error(contribRes.data.errors); + const firstError = contribRes.data.errors[0]; + if (firstError?.message) { + throw new CustomError( + wrapTextMultiline(firstError.message, 525, 12)[0] ?? "", + contribRes.statusText, + ); + } + throw new CustomError( + "Something went wrong while trying to retrieve the contributions data using the GraphQL API.", + CustomError.GRAPHQL_ERROR, + ); + } + + const user = contribRes.data.data.user; + if (!user) { + return 0; + } + + let total = 0; + for (const year of years) { + const yearBlock = user[`year_${year}`]; + if (yearBlock?.contributionCalendar.totalContributions) { + total += yearBlock.contributionCalendar.totalContributions; + } + } + return total; +}; + /** * Fetch stats for a given username. * @@ -292,6 +350,7 @@ const fetchRepoUserStats = async ( * @param include_issues_authored Include count of issues authored. * @param include_issues_commented Include count of issues commented. * @param ownerAffiliations Owner affiliations. Default: OWNER. + * @param include_contributions Include all-time contributions. * @param pat Optional PAT override. * @returns Stats data. */ @@ -311,6 +370,7 @@ const fetchStats = async ( include_issues_authored = false, include_issues_commented = false, ownerAffiliations: Array = [], + include_contributions = false, pat: string | null = null, ): Promise => { if (!username) { @@ -334,6 +394,7 @@ const fetchStats = async ( totalPRsReviewed: 0, totalIssuesAuthored: 0, totalIssuesCommented: 0, + totalContributions: 0, rank: { level: "C", percentile: 100 }, }; const affiliations = parseOwnerAffiliations(ownerAffiliations); @@ -421,6 +482,14 @@ const fetchStats = async ( } stats.contributedTo = user.repositoriesContributedTo.totalCount; + if (include_contributions) { + stats.totalContributions = await fetchTotalContributions( + username, + user.contributionsCollection.contributionYears, + 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 da070f3daa61d..6fa65c44f44ce 100644 --- a/packages/core/src/fetchers/types.ts +++ b/packages/core/src/fetchers/types.ts @@ -49,6 +49,7 @@ export interface StatsData { totalPRsReviewed: number; totalIssuesAuthored: number; totalIssuesCommented: number; + totalContributions: number; rank: { level: string; percentile: number }; } diff --git a/packages/core/src/graphql/contributionsDocument.ts b/packages/core/src/graphql/contributionsDocument.ts new file mode 100644 index 0000000000000..e9cf51ebfb006 --- /dev/null +++ b/packages/core/src/graphql/contributionsDocument.ts @@ -0,0 +1,43 @@ +import type { YearContributionsFragment } from "./generated/stats.js"; +import { graphqlDocument } from "./graphqlDocument.js"; + +interface ContributionsQueryVariables { + login: string; +} + +interface ContributionsQuery { + user: Record<`year_${number}`, YearContributionsFragment> | null; +} + +/** + * Build the all-time contributions query, one aliased `contributionsCollection` field per year. + * The shape is only known at runtime. + * + * @param years Contribution years, one `year_` alias each. + * @returns Document for `createGraphQLFetcher`. + */ +const buildContributionsDocument = (years: Array) => { + const yearFields = years + .map((year) => { + // without "to", 2024-01-01 would count toward year=2023 + const from = `${year}-01-01T00:00:00Z`; + const to = `${year}-12-31T23:59:59Z`; + return `year_${year}: contributionsCollection(from: "${from}", to: "${to}") { ...YearContributions }`; + }) + .join("\n"); + + // fragment must match queries/stats.graphql, which generates its type + return graphqlDocument(` +query userContributions($login: String!) { + user(login: $login) { + ${yearFields} + } +} +fragment YearContributions on ContributionsCollection { + contributionCalendar { + totalContributions + } +}`); +}; + +export { buildContributionsDocument }; diff --git a/packages/core/src/graphql/generated/stats.ts b/packages/core/src/graphql/generated/stats.ts index e8d39af0df2ed..c2b1d718d721a 100644 --- a/packages/core/src/graphql/generated/stats.ts +++ b/packages/core/src/graphql/generated/stats.ts @@ -63,6 +63,7 @@ export type UserInfoQuery = { followers: { totalCount: number }; repositoryDiscussions?: { totalCount: number }; repositoryDiscussionComments?: { totalCount: number }; + contributionsCollection: { contributionYears: Array }; repositories: { totalCount: number; nodes: Array<{ name: string; stargazerCount: number } | null> | null; @@ -71,6 +72,10 @@ export type UserInfoQuery = { } | null; }; +export type YearContributionsFragment = { + contributionCalendar: { totalContributions: number }; +}; + export const UserReposDocument = graphqlDocument< UserReposQuery, UserReposQueryVariables @@ -143,6 +148,9 @@ query userInfo($login: String!, $after: String, $includeMergedPullRequests: Bool repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) { totalCount } + contributionsCollection { + contributionYears + } ...RepoStars } } diff --git a/packages/core/src/graphql/graphqlDocument.ts b/packages/core/src/graphql/graphqlDocument.ts index 355f121db9789..be385eda23851 100644 --- a/packages/core/src/graphql/graphqlDocument.ts +++ b/packages/core/src/graphql/graphqlDocument.ts @@ -1,8 +1,7 @@ /** * A GraphQL query paired with the types of its result and variables. * - * `text` is what goes over the wire; the other two members only carry types and are - * never assigned. + * `text` is what goes over the wire; the other two members only carry types and are never assigned. * Documents are generated from `queries/*.graphql` * See `scripts/generate-graphql-types.js`. */ diff --git a/packages/core/src/graphql/queries/stats.graphql b/packages/core/src/graphql/queries/stats.graphql index 8a4114fd0cb00..d99a27b086d55 100644 --- a/packages/core/src/graphql/queries/stats.graphql +++ b/packages/core/src/graphql/queries/stats.graphql @@ -78,6 +78,15 @@ query userInfo( @include(if: $includeDiscussionsAnswers) { totalCount } + contributionsCollection { + contributionYears + } ...RepoStars } } + +fragment YearContributions on ContributionsCollection { + contributionCalendar { + totalContributions + } +} diff --git a/packages/core/src/translations.ts b/packages/core/src/translations.ts index 385d4759f0d27..0786e119d9876 100644 --- a/packages/core/src/translations.ts +++ b/packages/core/src/translations.ts @@ -524,6 +524,9 @@ const statCardLocales = ({ "statcard.issues-commented": { en: "Issues Commented", }, + "statcard.contributions": { + en: "Total Contributions", + }, "statcard.prs-merged": { en: "Total PRs Merged", ar: "مجموع طلبات السحب المُدمجة", diff --git a/packages/core/tests/fetchStats.test.ts b/packages/core/tests/fetchStats.test.ts index 47c277d681b38..8dddbf37d4f6b 100644 --- a/packages/core/tests/fetchStats.test.ts +++ b/packages/core/tests/fetchStats.test.ts @@ -17,6 +17,9 @@ const data_stats = { user: { name: "Anurag Hazra", repositoriesContributedTo: { totalCount: 61 }, + contributionsCollection: { + contributionYears: [2022, 2024], + }, commits: { totalCommitContributions: 100, }, @@ -104,6 +107,15 @@ const data_repo_zero_stars = { }, }; +const data_contributions = { + data: { + user: { + year_2022: { contributionCalendar: { totalContributions: 150 } }, + year_2024: { contributionCalendar: { totalContributions: 200 } }, + }, + }, +}; + const error = { errors: [ { @@ -129,6 +141,9 @@ beforeEach(() => { if (req.variables?.startTime?.startsWith("2003")) { return [200, data_year2003]; } + if (req.query.includes("contributionCalendar")) { + return [200, data_contributions]; + } return [ 200, req.query.includes("totalCommitContributions") ? data_stats : data_repo, @@ -172,6 +187,7 @@ describe("Test fetchStats", () => { totalPRsReviewed: 0, totalIssuesAuthored: 0, totalIssuesCommented: 0, + totalContributions: 0, rank, }); }); @@ -213,6 +229,7 @@ describe("Test fetchStats", () => { totalPRsReviewed: 0, totalIssuesAuthored: 0, totalIssuesCommented: 0, + totalContributions: 0, rank, }); }); @@ -262,6 +279,7 @@ describe("Test fetchStats", () => { totalPRsReviewed: 0, totalIssuesAuthored: 0, totalIssuesCommented: 0, + totalContributions: 0, rank, }); }); @@ -320,6 +338,7 @@ describe("Test fetchStats", () => { totalPRsReviewed: 0, totalIssuesAuthored: 0, totalIssuesCommented: 0, + totalContributions: 0, rank, }); }); @@ -357,6 +376,7 @@ describe("Test fetchStats", () => { totalPRsReviewed: 0, totalIssuesAuthored: 0, totalIssuesCommented: 0, + totalContributions: 0, rank, }); }); @@ -394,6 +414,7 @@ describe("Test fetchStats", () => { totalPRsReviewed: 0, totalIssuesAuthored: 0, totalIssuesCommented: 0, + totalContributions: 0, rank, }); }); @@ -431,6 +452,7 @@ describe("Test fetchStats", () => { totalPRsReviewed: 0, totalIssuesAuthored: 0, totalIssuesCommented: 0, + totalContributions: 0, rank, }); }); @@ -510,6 +532,7 @@ describe("Test fetchStats", () => { totalPRsReviewed: 0, totalIssuesAuthored: 0, totalIssuesCommented: 0, + totalContributions: 0, rank, }); }); @@ -544,6 +567,7 @@ describe("Test fetchStats", () => { totalPRsReviewed: 0, totalIssuesAuthored: 0, totalIssuesCommented: 0, + totalContributions: 0, rank, }); }); @@ -587,10 +611,110 @@ describe("Test fetchStats", () => { totalPRsAuthored: 0, totalPRsCommented: 0, totalPRsReviewed: 0, + totalContributions: 0, rank, }); }); + it("should fetch total contributions when include_contributions is true", async () => { + const stats = await fetchStats( + "anuraghazra", + false, + [], + false, + false, + false, + undefined, + [], + [], + false, + false, + false, + false, + false, + [], + true, // include_contributions + ); + + expect(stats.totalContributions).toBe(350); + }); + + it("should throw when the contributions query returns an error", async () => { + mock.onPost("https://api.github.com/graphql").reply((cfg) => { + const req = JSON.parse(cfg.data as string) as { query: string }; + if (req.query.includes("contributionCalendar")) { + return [ + 200, + { + data: null, + errors: [{ message: "Some test GraphQL error" }], + }, + ]; + } + return [ + 200, + req.query.includes("totalCommitContributions") ? data_stats : data_repo, + ]; + }); + + await expect( + fetchStats( + "anuraghazra", + false, + [], + false, + false, + false, + undefined, + [], + [], + false, + false, + false, + false, + false, + [], + true, // include_contributions + ), + ).rejects.toThrow("Some test GraphQL error"); + }); + + it("should throw a generic error when the contributions query returns an error without a message", async () => { + mock.onPost("https://api.github.com/graphql").reply((cfg) => { + const req = JSON.parse(cfg.data as string) as { query: string }; + if (req.query.includes("contributionCalendar")) { + return [200, { data: null, errors: [{ type: "SOME_ERROR" }] }]; + } + return [ + 200, + req.query.includes("totalCommitContributions") ? data_stats : data_repo, + ]; + }); + + await expect( + fetchStats( + "anuraghazra", + false, + [], + false, + false, + false, + undefined, + [], + [], + false, + false, + false, + false, + false, + [], + true, // include_contributions + ), + ).rejects.toThrow( + "Something went wrong while trying to retrieve the contributions data using the GraphQL API.", + ); + }); + it("should return correct data when user don't have any pull requests", async () => { mock .onPost("https://api.github.com/graphql") @@ -624,6 +748,7 @@ describe("Test fetchStats", () => { totalPRsAuthored: 0, totalPRsCommented: 0, totalPRsReviewed: 0, + totalContributions: 0, rank, }); }); diff --git a/packages/core/tests/renderStatsCard.test.ts b/packages/core/tests/renderStatsCard.test.ts index 5e3b6831bdee8..8201e5ad8639b 100644 --- a/packages/core/tests/renderStatsCard.test.ts +++ b/packages/core/tests/renderStatsCard.test.ts @@ -25,6 +25,7 @@ const stats: StatsData = { totalPRsReviewed: 100, totalIssuesAuthored: 100, totalIssuesCommented: 100, + totalContributions: 5000, rank: { level: "A+", percentile: 40 }, }; @@ -57,6 +58,7 @@ describe("Test renderStatsCard", () => { expect( screen.queryByTestId("prs_merged_percentage"), ).not.toBeInTheDocument(); + expect(screen.queryByTestId("contributions")).not.toBeInTheDocument(); }); it("should have proper name apostrophe", () => { @@ -121,6 +123,14 @@ describe("Test renderStatsCard", () => { expect(screen.queryByTestId("prs_merged_percentage")).toBeDefined(); }); + it("should show contributions stat when included in show list", () => { + document.body.innerHTML = renderStatsCard(stats, { + show: ["contributions"], + }); + + expect(screen.getByTestId("contributions").textContent).toBe("5k"); + }); + it("should hide_rank", () => { document.body.innerHTML = renderStatsCard(stats, { hide_rank: true });