Skip to content
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ vercel_token
*.code-workspace

.vercel

.DS_Store
96 changes: 59 additions & 37 deletions api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand All @@ -63,6 +65,7 @@ export default async (req, res) => {
theme,
},
});

if (!access.isPassed) {
return access.result;
}
Expand All @@ -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({
Expand All @@ -151,6 +172,7 @@ export default async (req, res) => {
}),
);
}

return res.send(
renderError({
message: "An unknown error occurred",
Expand Down
5 changes: 4 additions & 1 deletion src/cards/stats.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
};
Expand Down
1 change: 1 addition & 0 deletions src/cards/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/common/envs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
166 changes: 166 additions & 0 deletions src/fetchers/all-time-contributions.js
Original file line number Diff line number Diff line change
@@ -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<number[]>} 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<Object>} 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<Object>} 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,
};
};
Loading