Skip to content
Merged
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
32 changes: 30 additions & 2 deletions api/combined.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,38 @@ import {
retrieveSecondaryMessage,
} from "../src/common/error.js";
import { parseArray, parseBoolean } from "../src/common/ops.js";
import axios from "axios";
import { renderError } from "../src/common/render.js";
import { fetchOverview } from "../src/fetchers/overview.js";
import { fetchStreak } from "../src/fetchers/streak.js";
import { fetchTopLanguages } from "../src/fetchers/top-languages.js";

/**
* Fetch cached overview stats from the public gist.
*
* @param {string} username GitHub username (gist owner).
* @returns {Promise<object>} Cached overview stats.
*/
const fetchCachedOverview = async (username) => {
const gistId = process.env.GIST_ID;
Comment on lines +21 to +28

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gist-fetching/parsing logic is duplicated with fetchGistStats in src/fetchers/overview.js (same URL pattern + JSON parsing). To reduce drift and keep behavior consistent (timeouts, error handling, defaults), consider extracting a shared helper (e.g., src/fetchers/gist-stats.js) and using it from both overview and combined.

Copilot uses AI. Check for mistakes.
if (!gistId) {
throw new Error("GIST_ID not configured");
}
const res = await axios({
method: "get",
url: `https://gist.githubusercontent.com/${username}/${gistId}/raw/github-stats.json`,
});
const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
Comment on lines +32 to +36

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchCachedOverview no longer validates username (previously fetchOverview enforced github-username-regex). With the current code, malformed usernames can produce a bad gist URL / confusing errors. Consider validating username here (or reusing an existing username validation helper) and throwing the same “Invalid username provided.” error for consistency across endpoints.

Copilot uses AI. Check for mistakes.
return {
name: data.name || username,
totalStars: data.totalStars || 0,
totalForks: data.totalForks || 0,
totalCommits: data.totalCommits || 0,
linesChanged: data.linesChanged || 0,
repoViews: data.repoViews || 0,
contributedTo: data.contributedTo || 0,
};
};

// @ts-ignore
export default async (req, res) => {
const {
Expand Down Expand Up @@ -58,8 +85,9 @@ export default async (req, res) => {

try {
// Fetch all three data sources in parallel.
// Overview comes from the cached gist (fast), streak and langs from GraphQL.
const [overview, streak, langs] = await Promise.all([
fetchOverview(username),
fetchCachedOverview(username),
fetchStreak(username),
fetchTopLanguages(username, parseArray(exclude_repo)),
]);
Comment on lines 89 to 93

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are Jest tests for other API routes, but none covering /api/combined, and this PR changes its data source (now depends on the gist fetch/parse). Adding a unit test that mocks the gist HTTP GET and verifies the rendered SVG uses the cached overview fields would help prevent regressions.

Copilot uses AI. Check for mistakes.
Expand Down
188 changes: 147 additions & 41 deletions scripts/update-stats.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,58 +27,140 @@ const restHeaders = {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/**
* Fetch all non-fork repos the user owns or is an org member of via GraphQL,
* paginating through all results.
* @returns {Promise<string[]>} Array of "owner/name" strings.
* Run a GraphQL query against the GitHub API.
* @param {string} query GraphQL query string.
* @param {object} variables Query variables.
* @returns {Promise<object>} Parsed JSON response.
*/
async function graphql(query, variables) {
const res = await fetch(GRAPHQL_URL, {
method: "POST",
headers: {
Authorization: `bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables }),
});

if (!res.ok) {
throw new Error(`GraphQL request failed: ${res.status} ${res.statusText}`);
}

const json = await res.json();

if (json.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(json.errors)}`);
}

return json;
}

/**
* Fetch all repos (owned + contributed to) with stars/forks, plus contribution years.
* @returns {Promise<{ repos: Map<string, object>, name: string, contributionYears: number[] }>} Repos and user info.
*/
async function fetchAllRepos() {
const query = `
query($login: String!, $after: String) {
query($login: String!, $ownedAfter: String, $contribAfter: String) {
user(login: $login) {
repositories(first: 100, ownerAffiliations: [OWNER, ORGANIZATION_MEMBER], isFork: false, after: $after) {
nodes { nameWithOwner }
name
login
repositories(first: 100, ownerAffiliations: [OWNER, ORGANIZATION_MEMBER], isFork: false, after: $ownedAfter) {
nodes {
nameWithOwner
stargazers { totalCount }
forkCount
}
pageInfo { hasNextPage endCursor }
}
repositoriesContributedTo(first: 100, includeUserRepositories: false, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY], after: $contribAfter) {
nodes {
nameWithOwner
stargazers { totalCount }
forkCount
}
pageInfo { hasNextPage endCursor }
}
contributionsCollection {
contributionYears
}
}
}
`;

const repos = [];
let after = null;
let hasNextPage = true;

while (hasNextPage) {
const res = await fetch(GRAPHQL_URL, {
method: "POST",
headers: {
Authorization: `bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, variables: { login: USERNAME, after } }),
const repos = new Map();
let name = "";
let contributionYears = [];
let ownedAfter = null;
let contribAfter = null;
let hasOwnedNext = true;
let hasContribNext = true;

while (hasOwnedNext || hasContribNext) {
const json = await graphql(query, {
login: USERNAME,
ownedAfter: hasOwnedNext ? ownedAfter : null,
contribAfter: hasContribNext ? contribAfter : null,
});
Comment on lines +99 to 104

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchAllRepos can get stuck in an endless pagination loop. When hasOwnedNext (or hasContribNext) becomes false, the code starts sending ownedAfter: null again, which restarts that connection at page 1; pageInfo.hasNextPage can then flip back to true and the loop never terminates for users with >100 repos on the other connection. Consider paginating owned and contributed repos in separate loops, or use GraphQL @include directives + boolean vars to omit a connection entirely once it’s complete (so you don’t re-read its pageInfo).

Copilot uses AI. Check for mistakes.

if (!res.ok) {
throw new Error(
`GraphQL request failed: ${res.status} ${res.statusText}`,
);
const user = json.data.user;
if (!name) {
name = user.name || user.login;
contributionYears = user.contributionsCollection.contributionYears;
}

const json = await res.json();
for (const repo of user.repositories.nodes) {
if (!repos.has(repo.nameWithOwner)) {
repos.set(repo.nameWithOwner, repo);
}
}

if (json.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(json.errors)}`);
for (const repo of user.repositoriesContributedTo.nodes) {
if (!repos.has(repo.nameWithOwner)) {
repos.set(repo.nameWithOwner, repo);
}
}

const { nodes, pageInfo } = json.data.user.repositories;
for (const node of nodes) {
repos.push(node.nameWithOwner);
hasOwnedNext = user.repositories.pageInfo.hasNextPage;
if (hasOwnedNext) {
ownedAfter = user.repositories.pageInfo.endCursor;
}

hasNextPage = pageInfo.hasNextPage;
after = pageInfo.endCursor;
hasContribNext = user.repositoriesContributedTo.pageInfo.hasNextPage;
if (hasContribNext) {
contribAfter = user.repositoriesContributedTo.pageInfo.endCursor;
}
}

return repos;
return { repos, name, contributionYears };
}

/**
* Fetch all-time contributions by querying each contribution year.
* @param {number[]} years Contribution years.
* @returns {Promise<number>} Total contributions.
*/
async function fetchTotalContributions(years) {
const yearFragments = years
.map(
(year) => `
year${year}: contributionsCollection(
from: "${year}-01-01T00:00:00Z",
to: "${parseInt(year, 10) + 1}-01-01T00:00:00Z"
) {
contributionCalendar { totalContributions }
}`,
)
.join("\n");

const query = `query($login: String!) { user(login: $login) { ${yearFragments} } }`;
const json = await graphql(query, { login: USERNAME });
Comment on lines +156 to +157

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If contributionYears is empty, yearFragments becomes an empty string and this builds a GraphQL query where user { } has an empty selection set, which is invalid GraphQL and will fail the action. Add an early return (e.g., 0) when years.length === 0 (or guard the query construction accordingly).

Copilot uses AI. Check for mistakes.

let total = 0;
for (const key of Object.keys(json.data.user)) {
total += json.data.user[key]?.contributionCalendar?.totalContributions || 0;
}
return total;
}

/**
Expand Down Expand Up @@ -178,13 +260,12 @@ async function fetchRepoViews(repo) {

/**
* Update the target gist with computed stats.
* @param {number} linesChanged Total lines changed.
* @param {number} repoViews Total repository views.
* @param {object} stats All computed stats.
*/
async function updateGist(linesChanged, repoViews) {
async function updateGist(stats) {
const url = `${REST_BASE}/gists/${GIST_ID}`;
const content = JSON.stringify(
{ linesChanged, repoViews, updatedAt: new Date().toISOString() },
{ ...stats, updatedAt: new Date().toISOString() },
null,
2,
);
Expand Down Expand Up @@ -213,15 +294,32 @@ async function updateGist(linesChanged, repoViews) {
*/
async function main() {
console.log(`Fetching repos for ${USERNAME}...`);
const repos = await fetchAllRepos();
console.log(`Fetched ${repos.length} repos`);
const { repos, name, contributionYears } = await fetchAllRepos();
const repoList = [...repos.keys()];
console.log(`Fetched ${repoList.length} repos`);

// Compute overview stats from the repo data.
let totalStars = 0;
let totalForks = 0;
for (const repo of repos.values()) {
totalStars += repo.stargazers.totalCount;
totalForks += repo.forkCount;
}

console.log(`Stars: ${totalStars}, Forks: ${totalForks}`);

// Fetch all-time contributions.
console.log("Fetching all-time contributions...");
const totalCommits = await fetchTotalContributions(contributionYears);
console.log(`All-time contributions: ${totalCommits}`);

// Compute per-repo stats (lines changed + views).
let totalLinesChanged = 0;
let totalViews = 0;

for (let i = 0; i < repos.length; i++) {
const repo = repos[i];
console.log(`Processing repo ${i + 1}/${repos.length}: ${repo}`);
for (let i = 0; i < repoList.length; i++) {
const repo = repoList[i];
console.log(`Processing repo ${i + 1}/${repoList.length}: ${repo}`);

const lines = await fetchLinesChanged(repo);
totalLinesChanged += lines;
Expand All @@ -235,7 +333,15 @@ async function main() {
console.log(`Lines changed: ${totalLinesChanged}`);
console.log(`Views: ${totalViews}`);

await updateGist(totalLinesChanged, totalViews);
await updateGist({
name,
totalStars,
totalForks,
totalCommits,
contributedTo: repoList.length,
linesChanged: totalLinesChanged,
repoViews: totalViews,
});
console.log("Gist updated");
}

Expand Down
Loading