Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ exports[`Test /api contract > should match the public many-params response snaps

</g>
</svg>",
"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"]}}",
"graphqlRequest": "{"query":"\\nquery userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation], $includeUserRepositories: Boolean!) {\\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 includeUserRepositories: $includeUserRepositories\\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"],"includeUserRepositories":false}}",
"headers": [
[
"Cache-Control",
Expand Down
54 changes: 28 additions & 26 deletions apps/frontend/src/content/docs/docs/cards/stats.md

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion apps/frontend/src/content/docs/docs/fork.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,20 @@ It works everywhere, including GitHub sponsorship pages, where the other light/d

GitHub-Stats-Extended adds `light_github` and `dark_github` [themes](/frontend/docs/customization/themes/) that exactly match GitHub's own UI colors. For repo and gist cards use `light_github_repocard` and `dark_github_repocard`, which differ only in icon color.

### New Contributions stat
### New contributions stat

GitHub-Stats-Extended adds an optional stat showing the number of [contributions](https://docs.github.com/en/account-and-profile/reference/profile-contributions-reference#what-counts-as-a-contribution) (commits, pull requests, issues, etc.) across all years of a user's history. Enable it with `&show=contributions`. Whether private contributions are counted depends on [your profile visibility 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).

:::note
The pre-existing "Contributed to" stat counts repositories a user has contributed to, not contributions.
:::

### New options for "contributed-to" stats

GitHub-Stats-Extended adds an `all_time_contribs` stat that shows the number of repositories a user has contributed to across all years — not just the past year like the default `contribs` stat. Enable it with [`&show=all_time_contribs`](/frontend/docs/cards/stats/#showing-additional-individual-stats).

GitHub-Stats-Extended also adds a parameter [`contribs_include_own_repos`](/frontend/docs/cards/stats/#options) to include the user's own repositories in the `contribs` and `all_time_contribs` stats. By default, both stats exclude them and only count repositories owned by other users or organizations.

### Customization of top languages card

GitHub-Stats-Extended can show your top languages without any numbers via the `hide_values` parameter. And the new `prog_bar_bg_color` parameter sets the background color of progress bars, e.g. to transparent:
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default async (
number_precision,
rank_icon,
show,
contribs_include_own_repos,
...remainingParams
},
pat = null,
Expand Down Expand Up @@ -106,6 +107,8 @@ export default async (
showStats.includes("issues_commented"),
parseArray(role),
showStats.includes("contributions"),
showStats.includes("all_time_contribs"),
parseBoolean(contribs_include_own_repos),
pat,
);

Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/cards/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ const renderStatsCard = (
totalDiscussionsStarted,
totalDiscussionsAnswered,
contributedTo,
allTimeContributedTo,
totalPRsAuthored,
totalPRsCommented,
totalPRsReviewed,
Expand Down Expand Up @@ -432,6 +433,15 @@ const renderStatsCard = (
id: "contribs",
};

if (show.includes("all_time_contribs")) {
STATS["all_time_contribs"] = {
icon: icons.contribs,
label: i18n.t("statcard.all-time-contribs"),
value: allTimeContributedTo,
id: "all_time_contribs",
};
}

const isLongLocale = locale ? LONG_LOCALES.includes(locale) : false;

// check if all used labels are short
Expand Down
205 changes: 205 additions & 0 deletions packages/core/src/fetchers/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import type {
UserInfoQuery,
UserInfoQueryVariables,
} from "../graphql/generated/stats.js";
import type { ContributionRange } from "../graphql/reposContributedToDocument.js";
import {
MAX_REPOSITORIES_LIMIT,
buildReposContributedToDocument,
} from "../graphql/reposContributedToDocument.js";

import type { RepoUserStats, StatsData } from "./types.js";

Expand All @@ -44,6 +49,7 @@ const reposFetcher = createGraphQLFetcher(UserReposDocument, "bearer");
* @param variables.includeDiscussionsAnswers Include discussions answers.
* @param variables.startTime Time to start the count of total commits.
* @param variables.ownerAffiliations The owner affiliations to filter by. Default: OWNER.
* @param variables.includeUserRepositories Whether to include the user's own repositories in the repos contributed to.
* @param variables.pat PAT override or null.
* @returns The stats response, with every fetched page's repos merged in.
*
Expand All @@ -57,6 +63,7 @@ const statsFetcher = async ({
includeDiscussionsAnswers,
startTime,
ownerAffiliations,
includeUserRepositories,
pat,
}: {
username: string;
Expand All @@ -65,6 +72,7 @@ const statsFetcher = async ({
includeDiscussionsAnswers: boolean;
startTime: string | undefined;
ownerAffiliations: UserInfoQueryVariables["ownerAffiliations"];
includeUserRepositories: boolean;
pat: string | null;
}): Promise<StatsFetcherResponse> => {
// only the first request carries the stats themselves
Expand All @@ -78,6 +86,7 @@ const statsFetcher = async ({
includeDiscussionsAnswers,
startTime,
ownerAffiliations,
includeUserRepositories,
},
pat,
);
Expand Down Expand Up @@ -332,6 +341,187 @@ const fetchTotalContributions = async (
return total;
};

const MS_PER_DAY = 24 * 60 * 60 * 1000;

/**
* Round a timestamp (e.g. `Date.getTime()`) to the nearest UTC midnight.
*
* @param timestamp Milliseconds since epoch.
* @returns Milliseconds since epoch of the nearest UTC midnight.
*/
const roundToNearestMidnight = (timestamp: number): number =>
Math.round(timestamp / MS_PER_DAY) * MS_PER_DAY;

/**
* Fetch the repositories a user contributed to across every given range.
*
* All ranges still pending are queried together in a single request. Whenever a
* range's sub-collection returns `MAX_REPOSITORIES_LIMIT` results,
* that range is split and requeried in the next round, since the true count
* could be higher and some repos may be missing from the response.
*
* @param username GitHub username.
* @param ranges Ranges to fetch.
* @param includeOwnRepos Whether to include the user's own repos in the result.
* @param pat Optional PAT override.
* @returns The set of `nameWithOwner` repo identifiers.
*/
const fetchReposContributedTo = async (
username: string,
ranges: Array<ContributionRange>,
includeOwnRepos: boolean,
pat: string | null,
): Promise<Set<string>> => {
const repos = new Set<string>();
let pending = ranges;

while (pending.length > 0) {
const document = buildReposContributedToDocument(pending);
const fetcher = createGraphQLFetcher(document, "bearer");
const res = await retryer(
fetcher,
{ login: username, maxRepositories: MAX_REPOSITORIES_LIMIT },
pat,
);

if (res.data.errors) {
logger.error(res.data.errors);
const firstError = res.data.errors[0];
if (firstError?.message) {
throw new CustomError(
wrapTextMultiline(firstError.message, 525, 12)[0] ?? "",
res.statusText,
);
}
throw new CustomError(
"Something went wrong while trying to retrieve the repository contributions data using the GraphQL API.",
CustomError.GRAPHQL_ERROR,
);
}

const user = res.data.data.user;
if (!user) {
throw new CustomError(
"Something went wrong while trying to retrieve the repository contributions data using the GraphQL API.",
CustomError.GRAPHQL_ERROR,
);
}

const nextPending: Array<ContributionRange> = [];
pending.forEach((range, index) => {
const rangeResponse = user[`range_${index}`];
if (!rangeResponse) {
throw new CustomError(
"Something went wrong while trying to retrieve the repository contributions data using the GraphQL API.",
CustomError.GRAPHQL_ERROR,
);
}

const commitRepos = rangeResponse.commitContributionsByRepository;
const issueRepos = rangeResponse.issueContributionsByRepository;
const prRepos = rangeResponse.pullRequestContributionsByRepository;
const createdRepoNodes =
rangeResponse.repositoryContributions.nodes ?? [];

const isSaturated =
commitRepos.length >= MAX_REPOSITORIES_LIMIT ||
issueRepos.length >= MAX_REPOSITORIES_LIMIT ||
prRepos.length >= MAX_REPOSITORIES_LIMIT ||
createdRepoNodes.length >= MAX_REPOSITORIES_LIMIT;

const rangeDays = Math.round(
(range.to.getTime() - range.from.getTime()) / MS_PER_DAY,
);
// a range of 1 day or less can't be split any further
if (isSaturated && rangeDays >= 2) {
const mid = new Date(
roundToNearestMidnight(
range.from.getTime() + Math.floor(rangeDays / 2) * MS_PER_DAY,
),
);
// GitHub seems to use only the date portion and ignore the time. So we
// subtract 1 second from the `to` of the first half to wrap it to the
// previous day and avoid a 1-day overlap of the two halves.
nextPending.push({
from: range.from,
to: new Date(mid.getTime() - 1000),
});
nextPending.push({ from: mid, to: range.to });
return;
}
if (isSaturated) {
logger.log(
`Range ${range.from.toISOString()} - ${range.to.toISOString()} is saturated but cannot be split further.`,
);
}

for (const { repository } of [
...commitRepos,
...issueRepos,
...prRepos,
]) {
repos.add(repository.nameWithOwner);
}
for (const node of createdRepoNodes) {
if (node) {
repos.add(node.repository.nameWithOwner);
}
}
});

if (nextPending.length > 0) {
logger.log(
`found ${pending.length} saturated ranges, splitting and retrying...`,
);
}
pending = nextPending;
}

if (!includeOwnRepos) {
for (const repo of repos) {
if (repo.startsWith(`${username}/`)) {
repos.delete(repo);
}
}
}
return repos;
};

/**
* Calculates the count of repositories the user contributed to, across every
* contribution year.
*
* GitHub's `repositoriesContributedTo` field can only span one year. So we walk
* every year individually via `contributionsCollection(from, to)` and
* de-duplicate the repo results.
*
* Whether private contributions are included depends on the used PAT.
*
* @param username GitHub username.
* @param years Contribution years to walk.
* @param includeOwnRepos Whether to include the user's own repositories in the count.
* @param pat Optional PAT override.
* @returns Count of repositories.
*/
const fetchAllTimeReposContributedTo = async (
username: string,
years: Array<number>,
includeOwnRepos: boolean,
pat: string | null = null,
): Promise<number> => {
const ranges: Array<ContributionRange> = years.map((year) => ({
from: new Date(Date.UTC(year, 0, 1)),
to: new Date(Date.UTC(year, 11, 31, 23, 59, 59)),
}));
const repos = await fetchReposContributedTo(
username,
ranges,
includeOwnRepos,
pat,
);
return repos.size;
};

/**
* Fetch stats for a given username.
*
Expand All @@ -351,6 +541,8 @@ const fetchTotalContributions = async (
* @param include_issues_commented Include count of issues commented.
* @param ownerAffiliations Owner affiliations. Default: OWNER.
* @param include_contributions Include all-time contributions.
* @param include_all_time_contribs Include all-time count of repos contributed to.
* @param contribs_include_own_repos Include user-owned repos in contributed-to counts.
* @param pat Optional PAT override.
* @returns Stats data.
*/
Expand All @@ -371,6 +563,8 @@ const fetchStats = async (
include_issues_commented = false,
ownerAffiliations: Array<string> = [],
include_contributions = false,
include_all_time_contribs = false,
contribs_include_own_repos = false,
pat: string | null = null,
): Promise<StatsData> => {
if (!username) {
Expand All @@ -389,6 +583,7 @@ const fetchStats = async (
totalDiscussionsStarted: 0,
totalDiscussionsAnswered: 0,
contributedTo: 0,
allTimeContributedTo: 0,
totalPRsAuthored: 0,
totalPRsCommented: 0,
totalPRsReviewed: 0,
Expand All @@ -406,6 +601,7 @@ const fetchStats = async (
includeDiscussionsAnswers: include_discussions_answers,
startTime: commits_year ? `${commits_year}-01-01T00:00:00Z` : undefined,
ownerAffiliations: affiliations,
includeUserRepositories: contribs_include_own_repos,
pat,
});

Expand Down Expand Up @@ -490,6 +686,15 @@ const fetchStats = async (
);
}

if (include_all_time_contribs) {
stats.allTimeContributedTo = await fetchAllTimeReposContributedTo(
username,
user.contributionsCollection.contributionYears,
contribs_include_own_repos,
pat,
);
}

// Retrieve stars while filtering out repositories to be hidden.
const allExcludedRepos = [
...exclude_repo,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/fetchers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface StatsData {
totalDiscussionsStarted: number;
totalDiscussionsAnswered: number;
contributedTo: number;
allTimeContributedTo: number;
totalPRsAuthored: number;
totalPRsCommented: number;
totalPRsReviewed: number;
Expand Down
Loading