diff --git a/.gitignore b/.gitignore index b1d9a017c5b80..db5dcb53588b0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ vercel_token *.code-workspace .vercel + +.DS_Store \ No newline at end of file diff --git a/api/index.js b/api/index.js index 6ea4ffe0c20e7..72afed1091e5a 100644 --- a/api/index.js +++ b/api/index.js @@ -12,7 +12,7 @@ import { MissingParamError, retrieveSecondaryMessage, } from "../src/common/error.js"; -import { parseArray, parseBoolean } from "../src/common/ops.js"; +import { clampValue, parseArray, parseBoolean } from "../src/common/ops.js"; import { renderError } from "../src/common/render.js"; import { fetchStats } from "../src/fetchers/stats.js"; import { isLocaleAvailable } from "../src/translations.js"; @@ -48,7 +48,9 @@ export default async (req, res) => { border_color, rank_icon, show, + all_time_contribs, } = req.query; + res.setHeader("Content-Type", "image/svg+xml"); const access = guardAccess({ @@ -63,6 +65,7 @@ export default async (req, res) => { theme, }, }); + if (!access.isPassed) { return access.result; } @@ -88,53 +91,71 @@ export default async (req, res) => { const stats = await fetchStats( username, parseBoolean(include_all_commits), + parseBoolean(all_time_contribs), parseArray(exclude_repo), showStats.includes("prs_merged") || showStats.includes("prs_merged_percentage"), showStats.includes("discussions_started"), showStats.includes("discussions_answered"), - parseInt(commits_year, 10), + commits_year ? parseInt(commits_year, 10) : undefined, ); - const cacheSeconds = resolveCacheSeconds({ - requested: parseInt(cache_seconds, 10), - def: CACHE_TTL.STATS_CARD.DEFAULT, - min: CACHE_TTL.STATS_CARD.MIN, - max: CACHE_TTL.STATS_CARD.MAX, - }); + // Use longer cache for all-time contributions since they change slowly + const FOUR_HOURS = 60 * 60 * 4; + const SIX_HOURS = 60 * 60 * 6; + const ONE_DAY = 60 * 60 * 24; + + const cacheSeconds = parseBoolean(all_time_contribs) + ? clampValue( + parseInt(cache_seconds || SIX_HOURS, 10), + SIX_HOURS, + ONE_DAY, + ) + : clampValue( + parseInt(cache_seconds || FOUR_HOURS, 10), + FOUR_HOURS, + ONE_DAY, + ); + + // Set cache headers BEFORE sending response setCacheHeaders(res, cacheSeconds); - return res.send( - renderStatsCard(stats, { - hide: parseArray(hide), - show_icons: parseBoolean(show_icons), - hide_title: parseBoolean(hide_title), - hide_border: parseBoolean(hide_border), - card_width: parseInt(card_width, 10), - hide_rank: parseBoolean(hide_rank), - include_all_commits: parseBoolean(include_all_commits), - commits_year: parseInt(commits_year, 10), - line_height, - title_color, - ring_color, - icon_color, - text_color, - text_bold: parseBoolean(text_bold), - bg_color, - theme, - custom_title, - border_radius, - border_color, - number_format, - number_precision: parseInt(number_precision, 10), - locale: locale ? locale.toLowerCase() : null, - disable_animations: parseBoolean(disable_animations), - rank_icon, - show: showStats, - }), - ); + // Render and send the card + const renderedCard = renderStatsCard(stats, { + hide: parseArray(hide), + show_icons: parseBoolean(show_icons), + hide_title: parseBoolean(hide_title), + hide_border: parseBoolean(hide_border), + card_width: parseInt(card_width, 10), + hide_rank: parseBoolean(hide_rank), + include_all_commits: parseBoolean(include_all_commits), + all_time_contribs: parseBoolean(all_time_contribs), + commits_year: parseInt(commits_year, 10), + line_height, + title_color, + ring_color, + icon_color, + text_color, + text_bold: parseBoolean(text_bold), + bg_color, + theme, + custom_title, + border_radius, + border_color, + number_format, + number_precision: parseInt(number_precision, 10), + locale: locale ? locale.toLowerCase() : null, + disable_animations: parseBoolean(disable_animations), + rank_icon, + show: showStats, + }); + + return res.send(renderedCard); + } catch (err) { + // Set error cache headers BEFORE sending error response setErrorCacheHeaders(res); + if (err instanceof Error) { return res.send( renderError({ @@ -151,6 +172,7 @@ export default async (req, res) => { }), ); } + return res.send( renderError({ message: "An unknown error occurred", diff --git a/src/cards/stats.js b/src/cards/stats.js index 6b428d48c34ae..a842f059caf7f 100644 --- a/src/cards/stats.js +++ b/src/cards/stats.js @@ -277,6 +277,7 @@ const renderStatsCard = (stats, options = {}) => { card_width, hide_rank = false, include_all_commits = false, + all_time_contribs = false, commits_year, line_height = 25, title_color, @@ -404,7 +405,9 @@ const renderStatsCard = (stats, options = {}) => { STATS.contribs = { icon: icons.contribs, - label: i18n.t("statcard.contribs"), + label: all_time_contribs + ? i18n.t("statcard.contribs-alltime") + : i18n.t("statcard.contribs"), value: contributedTo, id: "contribs", }; diff --git a/src/cards/types.d.ts b/src/cards/types.d.ts index 7535df35bbe6f..48ef23fa6b3f4 100644 --- a/src/cards/types.d.ts +++ b/src/cards/types.d.ts @@ -20,6 +20,7 @@ export type StatCardOptions = CommonOptions & { card_width: number; hide_rank: boolean; include_all_commits: boolean; + all_time_contribs: boolean; commits_year: number; line_height: number | string; custom_title: string; diff --git a/src/common/envs.js b/src/common/envs.js index 5f1319662b94d..4857357707880 100644 --- a/src/common/envs.js +++ b/src/common/envs.js @@ -8,8 +8,10 @@ const gistWhitelist = process.env.GIST_WHITELIST ? process.env.GIST_WHITELIST.split(",") : undefined; +const ALL_TIME_CONTRIBS=process.env.ALL_TIME_CONTRIBS == "true"; + const excludeRepositories = process.env.EXCLUDE_REPO ? process.env.EXCLUDE_REPO.split(",") : []; -export { whitelist, gistWhitelist, excludeRepositories }; +export { whitelist, gistWhitelist, excludeRepositories, ALL_TIME_CONTRIBS }; diff --git a/src/fetchers/all-time-contributions.js b/src/fetchers/all-time-contributions.js new file mode 100644 index 0000000000000..1f2cf82349ba9 --- /dev/null +++ b/src/fetchers/all-time-contributions.js @@ -0,0 +1,166 @@ +// @ts-check + +import { retryer } from "../common/retryer.js"; +import { MissingParamError, CustomError } from "../common/error.js"; +import { request } from "../common/http.js"; +import { logger } from "../common/log.js"; + +/** + * GraphQL query to fetch contribution years for a user + */ +const CONTRIBUTION_YEARS_QUERY = ` + query contributionYears($login: String!) { + user(login: $login) { + contributionsCollection { + contributionYears + } + } + } +`; + +/** + * GraphQL query to fetch contributions for a specific year + */ +const YEAR_CONTRIBUTIONS_QUERY = ` + query yearContributions($login: String!, $from: DateTime!, $to: DateTime!) { + user(login: $login) { + contributionsCollection(from: $from, to: $to) { + commitContributionsByRepository(maxRepositories: 100) { + repository { + nameWithOwner + } + } + issueContributionsByRepository(maxRepositories: 100) { + repository { + nameWithOwner + } + } + pullRequestContributionsByRepository(maxRepositories: 100) { + repository { + nameWithOwner + } + } + pullRequestReviewContributionsByRepository(maxRepositories: 100) { + repository { + nameWithOwner + } + } + } + } + } +`; + +/** + * Fetches all contribution years for a user + * @param {string} login - GitHub username + * @param {string} token - GitHub PAT + * @returns {Promise} Array of years + */ +const fetchContributionYears = async (login, token) => { + const fetcher = (variables) => { + return request( + { + query: CONTRIBUTION_YEARS_QUERY, + variables, + }, + { + Authorization: `bearer ${token}`, + }, + ); + }; + + const res = await retryer(fetcher, { login }); + + if (res.data.errors) { + throw new Error("Failed to fetch contribution years"); + } + + if (!res.data.data?.user?.contributionsCollection) { + throw new Error("Invalid response structure"); + } + + const years = res.data.data.user.contributionsCollection.contributionYears || []; + return years; +}; + +/** + * Fetches contributions for a specific year + * @param {string} login - GitHub username + * @param {number} year - Year to fetch + * @param {string} token - GitHub PAT + * @returns {Promise} Contribution data for the year + */ +const fetchYearContributions = async (login, year, token) => { + const from = `${year}-01-01T00:00:00Z`; + const to = `${year}-12-31T23:59:59Z`; + + const fetcher = (variables) => { + return request( + { + query: YEAR_CONTRIBUTIONS_QUERY, + variables, + }, + { + Authorization: `bearer ${token}`, + }, + ); + }; + + const res = await retryer(fetcher, { login, from, to }); + + if (res.data.errors) { + throw new Error(`Failed to fetch year ${year}`); + } + + if (!res.data.data?.user?.contributionsCollection) { + throw new Error(`Invalid response for year ${year}`); + } + + return res.data.data.user.contributionsCollection; +}; + +/** + * Fetches all-time contribution statistics (deduplicated by default) + * @param {string} login - GitHub username + * @param {string} token - GitHub PAT + * @returns {Promise} All-time contribution stats with unique repository count + */ +export const fetchAllTimeContributions = async (login, token) => { + if (!login) { + throw new MissingParamError(["login"]); + } + + if (!token) { + throw new Error("GitHub token not set"); + } + + // Fetch all contribution years + const years = await fetchContributionYears(login, token); + + // Count unique repositories across ALL years + const allRepos = new Set(); + + // Fetch all years in PARALLEL for speed + const yearDataPromises = years.map(year => fetchYearContributions(login, year, token)); + const yearDataResults = await Promise.all(yearDataPromises); + + yearDataResults.forEach((yearData) => { + const addRepos = (contributions) => { + contributions?.forEach((contrib) => { + if (contrib.repository?.nameWithOwner) { + allRepos.add(contrib.repository.nameWithOwner); + } + }); + }; + + addRepos(yearData.commitContributionsByRepository); + addRepos(yearData.issueContributionsByRepository); + addRepos(yearData.pullRequestContributionsByRepository); + addRepos(yearData.pullRequestReviewContributionsByRepository); + }); + + return { + totalRepositoriesContributedTo: allRepos.size, + yearsAnalyzed: years.length, + }; +}; \ No newline at end of file diff --git a/src/fetchers/stats.js b/src/fetchers/stats.js index 376a15816144e..f85dd475d6647 100644 --- a/src/fetchers/stats.js +++ b/src/fetchers/stats.js @@ -6,10 +6,11 @@ import githubUsernameRegex from "github-username-regex"; import { calculateRank } from "../calculateRank.js"; import { retryer } from "../common/retryer.js"; import { logger } from "../common/log.js"; -import { excludeRepositories } from "../common/envs.js"; +import { excludeRepositories, ALL_TIME_CONTRIBS } from "../common/envs.js"; import { CustomError, MissingParamError } from "../common/error.js"; import { wrapTextMultiline } from "../common/fmt.js"; import { request } from "../common/http.js"; +import { fetchAllTimeContributions } from "./all-time-contributions.js"; dotenv.config(); @@ -190,7 +191,6 @@ const fetchTotalCommits = (variables, token) => { */ const totalCommitsFetcher = async (username) => { if (!githubUsernameRegex.test(username)) { - logger.log("Invalid username provided."); throw new Error("Invalid username provided."); } @@ -198,7 +198,6 @@ const totalCommitsFetcher = async (username) => { try { res = await retryer(fetchTotalCommits, { login: username }); } catch (err) { - logger.log(err); throw new Error(err); } @@ -217,6 +216,7 @@ const totalCommitsFetcher = async (username) => { * * @param {string} username GitHub username. * @param {boolean} include_all_commits Include all commits. + * @param {boolean} all_time_contribs Include all-time contributions (deduplicated). * @param {string[]} exclude_repo Repositories to exclude. * @param {boolean} include_merged_pull_requests Include merged pull requests. * @param {boolean} include_discussions Include discussions. @@ -227,6 +227,7 @@ const totalCommitsFetcher = async (username) => { const fetchStats = async ( username, include_all_commits = false, + all_time_contribs = false, exclude_repo = [], include_merged_pull_requests = false, include_discussions = false, @@ -262,7 +263,6 @@ const fetchStats = async ( // 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.", @@ -308,7 +308,30 @@ const fetchStats = async ( stats.totalDiscussionsAnswered = user.repositoryDiscussionComments.totalCount; } - stats.contributedTo = user.repositoriesContributedTo.totalCount; + + // Handle all-time contributions if enabled (always deduplicated) + if (all_time_contribs && ALL_TIME_CONTRIBS) { + try { + // Add timeout protection (9 seconds) + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 9000) + ); + + const allTimePromise = fetchAllTimeContributions( + username, + process.env.PAT_1, + ); + + const allTimeData = await Promise.race([allTimePromise, timeoutPromise]); + stats.contributedTo = allTimeData.totalRepositoriesContributedTo; + } catch (err) { + // Silent fallback to last year's count + stats.contributedTo = user.repositoriesContributedTo.totalCount; + } + } else { + // Default: last year's contributions + stats.contributedTo = user.repositoriesContributedTo.totalCount; + } // Retrieve stars while filtering out repositories to be hidden. const allExcludedRepos = [...exclude_repo, ...excludeRepositories]; diff --git a/src/translations.js b/src/translations.js index ad069cc407813..48d5b76332ff7 100644 --- a/src/translations.js +++ b/src/translations.js @@ -351,6 +351,54 @@ const statCardLocales = ({ name, apostrophe }) => { "sr-latn": "Doprinosi (prošla godina)", no: "Bidro til (i fjor)", }, + "statcard.contribs-alltime": { + en: "Contributed to (all time)", + ar: "ساهم في (كل الوقت)", + az: "Töhfə verdi (bütün vaxtlar)", + ca: "Contribucions (tots els temps)", + cn: "贡献的项目数(全部时间)", + "zh-tw": "參與項目數量(全部時間)", + cs: "Přispěl k (celá doba)", + de: "Beigetragen zu (gesamte Zeit)", + sw: "Idadi ya michango (wakati wote)", + ur: "تمام وقت میں تعاون کیا", + bg: "Приноси (за цялото време)", + bn: "অবদান (সব সময়)", + es: "Contribuciones en (todo el tiempo)", + fa: "مشارکت در (تمام زمان‌ها)", + fi: "Osallistunut (koko ajan)", + fr: "Contribué à (tout le temps)", + hi: "(सभी समय) में योगदान दिया", + sa: "(सर्वदा) योगदानम् कृतम्", + hu: "Hozzájárulások (minden idők)", + it: "Ha contribuito a (sempre)", + ja: "貢献したリポジトリ (全期間)", + kr: "(전체 기간) 기여", + nl: "Bijgedragen aan (alle tijd)", + "pt-pt": "Contribuiu em (todo o tempo)", + "pt-br": "Contribuiu para (todo o tempo)", + np: "कुल योगदानहरू (सबै समय)", + el: "Συνεισφέρθηκε σε (όλη την ώρα)", + ro: "Total Contribuiri (tot timpul)", + ru: "Внесено вклада (за все время)", + "uk-ua": "Зроблено внесок (за весь час)", + id: "Berkontribusi ke (sepanjang waktu)", + ml: "(എല്ലാ സമയത്തും)ആകെ സംഭാവനകൾ", + my: "အကူအညီပေးခဲ့သည် (အချိန်တိုင်း)", + ta: "(எல்லா காலமும்) பங்களித்தது", + sk: "Účasti (celý čas)", + tr: "Katkı Verildi (tüm zamanlar)", + pl: "Kontrybucje (cały czas)", + uz: "Hissa qoʻshgan (barcha vaqt)", + vi: "Đã Đóng Góp (mọi lúc)", + se: "Bidragit till (all tid)", + he: "תרם ל... (כל הזמן)", + fil: "Nag-ambag sa (buong panahon)", + th: "มีส่วนร่วมใน (ตลอดเวลา)", + sr: "Доприноси (све време)", + "sr-latn": "Doprinosi (sve vreme)", + no: "Bidro til (hele tiden)", + }, "statcard.reviews": { en: "Total PRs Reviewed", ar: "طلبات السحب التي تم مراجعتها",