2 Commits

3 changed files with 382 additions and 92 deletions

View File

@@ -71,11 +71,63 @@ export type RepoDetails = {
stars: number;
forks: number;
isArchived: boolean;
isFork: boolean;
isPrivate: boolean;
primaryLanguage: string | null;
topics: string[];
updatedAt: string;
createdAt: string;
};
export type RepoStats = {
totalRepos: number;
publicRepos: number;
privateRepos: number;
archivedRepos: number;
forkedRepos: number;
originalRepos: number;
activeRepos: number;
reposWithStars: number;
reposCreatedThisYear: number;
averageStarsPerRepo: number;
};
export type TopicCount = {
name: string;
count: number;
};
export type ComputedStats = {
// Repo statistics
totalRepos: number;
publicRepos: number;
privateRepos: number;
archivedRepos: number;
forkedRepos: number;
originalRepos: number;
activeRepos: number;
reposWithStars: number;
reposCreatedThisYear: number;
averageStarsPerRepo: number;
// Language statistics
languageCount: number;
primaryLanguage: string | null;
primaryLanguageThisYear: string | null;
topLanguagesThisYear: Language[];
// Topic statistics
totalTopics: number;
topTopics: TopicCount[];
allTopics: string[];
// Contribution statistics
contributionsThisYear: number;
contributionsLastYear: number;
yearOverYearGrowth: number | null;
mostProductiveMonth: { month: string; contributions: number } | null;
};
export type UserStats = {
name: string;
username: string;
@@ -110,6 +162,8 @@ export type UserStats = {
codeByteTotal: number;
topLanguages: Language[];
contributionStats: ContributionStats;
repoStats: RepoStats;
computedStats: ComputedStats;
contributionsCollection: ContributionsCollection;
topRepos: RepoDetails[];
};

View File

@@ -4,9 +4,9 @@ import {
formatNumber,
aggregateLanguages,
calculateContributionStats,
processBatched,
calculateComputedStats,
} from "./index";
import type { ContributionsCollection } from "./Types";
import type { ContributionsCollection, Language, ContributionStats } from "./Types";
describe("formatBytes", () => {
test("formats bytes correctly", () => {
@@ -202,50 +202,135 @@ describe("calculateContributionStats", () => {
});
});
describe("processBatched", () => {
test("processes items in batches", async () => {
const items = [1, 2, 3, 4, 5];
const processedOrder: number[] = [];
describe("calculateComputedStats", () => {
const currentYear = new Date().getFullYear();
const lastYear = currentYear - 1;
const results = await processBatched(items, 2, async (item) => {
processedOrder.push(item);
return item * 2;
const createRepoInfo = (overrides: Partial<{
stars: number;
forks: number;
isArchived: boolean;
isFork: boolean;
isPrivate: boolean;
topics: string[];
updatedAt: string;
createdAt: string;
languages: { edges: Array<{ size: number; node: { name: string; color: string } }> };
}> = {}) => ({
stars: 0,
forks: 0,
isArchived: false,
isFork: false,
isPrivate: false,
topics: [],
updatedAt: `${currentYear}-06-01T00:00:00Z`,
createdAt: `${lastYear}-01-01T00:00:00Z`,
languages: { edges: [] },
...overrides,
});
const createContributionStats = (monthlyBreakdown: Array<{ month: string; contributions: number }>): ContributionStats => ({
longestStreak: 0,
currentStreak: 0,
mostActiveDay: "Monday",
averagePerDay: 0,
averagePerWeek: 0,
averagePerMonth: 0,
monthlyBreakdown,
});
test("calculates repo statistics correctly", () => {
const repos = [
createRepoInfo({ stars: 10, isPrivate: false }),
createRepoInfo({ stars: 5, isPrivate: true }),
createRepoInfo({ isArchived: true }),
createRepoInfo({ isFork: true }),
createRepoInfo({ updatedAt: `${currentYear}-01-15T00:00:00Z` }),
createRepoInfo({ createdAt: `${currentYear}-03-01T00:00:00Z`, updatedAt: `${currentYear}-03-01T00:00:00Z` }),
];
const stats = calculateComputedStats(repos, [], createContributionStats([]));
expect(stats.totalRepos).toBe(6);
expect(stats.publicRepos).toBe(5);
expect(stats.privateRepos).toBe(1);
expect(stats.archivedRepos).toBe(1);
expect(stats.forkedRepos).toBe(1);
expect(stats.originalRepos).toBe(5);
expect(stats.reposWithStars).toBe(2);
expect(stats.reposCreatedThisYear).toBe(1);
expect(stats.averageStarsPerRepo).toBe(2.5); // 15 stars / 6 repos
});
test("calculates language statistics correctly", () => {
const topLanguages: Language[] = [
{ languageName: "TypeScript", color: "#3178c6", value: 1000, percentage: 50 },
{ languageName: "JavaScript", color: "#f1e05a", value: 500, percentage: 25 },
{ languageName: "Python", color: "#3572A5", value: 500, percentage: 25 },
];
const stats = calculateComputedStats([], topLanguages, createContributionStats([]));
expect(stats.languageCount).toBe(3);
expect(stats.primaryLanguage).toBe("TypeScript");
});
test("calculates year over year growth correctly", () => {
const contributionStats = createContributionStats([
{ month: `${lastYear}-01`, contributions: 50 },
{ month: `${lastYear}-02`, contributions: 50 },
{ month: `${currentYear}-01`, contributions: 75 },
{ month: `${currentYear}-02`, contributions: 75 },
]);
const stats = calculateComputedStats([], [], contributionStats);
expect(stats.contributionsLastYear).toBe(100);
expect(stats.contributionsThisYear).toBe(150);
expect(stats.yearOverYearGrowth).toBe(50); // 50% growth
});
test("identifies most productive month", () => {
const contributionStats = createContributionStats([
{ month: `${currentYear}-01`, contributions: 50 },
{ month: `${currentYear}-02`, contributions: 150 },
{ month: `${currentYear}-03`, contributions: 75 },
]);
const stats = calculateComputedStats([], [], contributionStats);
expect(stats.mostProductiveMonth).toEqual({
month: `${currentYear}-02`,
contributions: 150,
});
expect(results).toHaveLength(5);
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(5);
const values = results
.filter((r): r is PromiseFulfilledResult<number> => r.status === "fulfilled")
.map((r) => r.value);
expect(values).toEqual([2, 4, 6, 8, 10]);
});
test("handles errors gracefully with Promise.allSettled", async () => {
const items = [1, 2, 3];
test("handles empty data gracefully", () => {
const stats = calculateComputedStats([], [], createContributionStats([]));
const results = await processBatched(items, 2, async (item) => {
if (item === 2) throw new Error("Test error");
return item;
});
expect(results).toHaveLength(3);
expect(results[0].status).toBe("fulfilled");
expect(results[1].status).toBe("rejected");
expect(results[2].status).toBe("fulfilled");
expect(stats.totalRepos).toBe(0);
expect(stats.languageCount).toBe(0);
expect(stats.primaryLanguage).toBe(null);
expect(stats.yearOverYearGrowth).toBe(null);
expect(stats.mostProductiveMonth).toBe(null);
expect(stats.totalTopics).toBe(0);
expect(stats.topTopics).toHaveLength(0);
});
test("processes single batch correctly", async () => {
const items = [1, 2, 3];
test("aggregates topics correctly", () => {
const repos = [
createRepoInfo({ topics: ["typescript", "github-action", "automation"] }),
createRepoInfo({ topics: ["typescript", "cli"] }),
createRepoInfo({ topics: ["python", "automation"] }),
];
const results = await processBatched(items, 10, async (item) => item);
const stats = calculateComputedStats(repos, [], createContributionStats([]));
expect(results).toHaveLength(3);
});
test("handles empty array", async () => {
const results = await processBatched([], 5, async (item: number) => item);
expect(results).toHaveLength(0);
expect(stats.totalTopics).toBe(5);
expect(stats.allTopics).toEqual(["automation", "cli", "github-action", "python", "typescript"]);
// Top topics should be sorted by count
expect(stats.topTopics[0]).toEqual({ name: "typescript", count: 2 });
expect(stats.topTopics[1]).toEqual({ name: "automation", count: 2 });
});
});

View File

@@ -9,15 +9,17 @@ import {
ContributionStats,
MonthlyContribution,
RepoDetails,
RepoStats,
ComputedStats,
TopicCount,
} from "./Types";
import type { GraphQlQueryResponseData } from "@octokit/graphql";
const ThrottledOctokit = Octokit.plugin(throttling);
// Constants
const MAX_RETRY_COUNT = 5;
const BATCH_SIZE = 10;
const RETRY_DELAY_MS = 2000;
const MAX_RETRY_COUNT = 10; // With exponential backoff: 1+2+4+8+16+32+64+128+256+512 = ~17 minutes max
const INITIAL_RETRY_DELAY_MS = 1000;
/**
* Log rate limit information from GraphQL response
@@ -30,25 +32,6 @@ function logRateLimit(rateLimit: RateLimitInfo | undefined, context: string) {
}
}
/**
* Process promises in batches to avoid overwhelming the API
*/
export async function processBatched<T, R>(
items: T[],
batchSize: number,
processor: (item: T) => Promise<R>
): Promise<PromiseSettledResult<R>[]> {
const results: PromiseSettledResult<R>[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.allSettled(batch.map(processor));
results.push(...batchResults);
}
return results;
}
/**
* Get user profile data with extended fields
*/
@@ -131,6 +114,8 @@ export async function getRepoData(
name
description
isArchived
isFork
isPrivate
createdAt
updatedAt
stargazers {
@@ -141,6 +126,13 @@ export async function getRepoData(
name
color
}
repositoryTopics(first: 20) {
nodes {
topic {
name
}
}
}
languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {
edges {
size
@@ -300,8 +292,6 @@ export async function getUsersStars(octokit: Octokit, username: string) {
username,
per_page: 1,
});
// The total count is in the Link header or we can make a request with per_page=1
// and check the last page. For simplicity, we'll count from headers if available.
const linkHeader = response.headers.link;
if (linkHeader) {
const lastPageMatch = linkHeader.match(/page=(\d+)>; rel="last"/);
@@ -313,7 +303,7 @@ export async function getUsersStars(octokit: Octokit, username: string) {
}
/**
* Get contributor stats for a repo with retry limit
* Get contributor stats for a repo with exponential backoff for 202 responses
*/
export async function getReposContributorsStats(
octokit: Octokit,
@@ -329,13 +319,22 @@ export async function getReposContributorsStats(
if (response.status === 202) {
if (retryCount >= MAX_RETRY_COUNT) {
console.warn(
`Max retries (${MAX_RETRY_COUNT}) reached for ${owner}/${repo}, skipping`
);
console.warn(`Max retries (${MAX_RETRY_COUNT}) reached for ${owner}/${repo} after exponential backoff, skipping`);
return undefined;
}
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 64s, 128s, 256s, 512s
const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount);
const maxDelay = 60000; // Cap at 60 seconds per retry
const actualDelay = Math.min(delay, maxDelay);
if (retryCount === 0) {
console.log(`[202] ${owner}/${repo}: GitHub computing stats, waiting ${actualDelay}ms...`);
} else {
console.log(`[202] ${owner}/${repo}: Retry ${retryCount + 1}/${MAX_RETRY_COUNT}, waiting ${actualDelay}ms...`);
}
await new Promise((resolve) => setTimeout(resolve, actualDelay));
return getReposContributorsStats(octokit, owner, repo, retryCount + 1);
}
@@ -422,7 +421,6 @@ export function calculateContributionStats(
if (allDays[i].count > 0) {
currentStreak++;
} else if (allDays[i].date !== today) {
// Allow today to have 0 if we're still on today
break;
}
}
@@ -503,6 +501,123 @@ export function aggregateLanguages(
return { languages, codeByteTotal };
}
/**
* Calculate computed stats from existing data (no API calls)
*/
export function calculateComputedStats(
repoInfoList: Array<{
stars: number;
forks: number;
isArchived: boolean;
isFork: boolean;
isPrivate: boolean;
topics: string[];
updatedAt: string;
createdAt: string;
languages: { edges: Array<{ size: number; node: { name: string; color: string } }> };
}>,
topLanguages: Language[],
contributionStats: ContributionStats
): ComputedStats {
const currentYear = new Date().getFullYear();
const currentYearStr = `${currentYear}`;
const lastYearStr = `${currentYear - 1}`;
// Repo statistics
const totalRepos = repoInfoList.length;
const publicRepos = repoInfoList.filter((r) => !r.isPrivate).length;
const privateRepos = repoInfoList.filter((r) => r.isPrivate).length;
const archivedRepos = repoInfoList.filter((r) => r.isArchived).length;
const forkedRepos = repoInfoList.filter((r) => r.isFork).length;
const originalRepos = totalRepos - forkedRepos;
const activeRepos = repoInfoList.filter((r) => r.updatedAt.startsWith(currentYearStr)).length;
const reposWithStars = repoInfoList.filter((r) => r.stars > 0).length;
const reposCreatedThisYear = repoInfoList.filter((r) => r.createdAt.startsWith(currentYearStr)).length;
// Star statistics
const totalStars = repoInfoList.reduce((sum, r) => sum + r.stars, 0);
const averageStarsPerRepo = totalRepos > 0 ? Math.round((totalStars / totalRepos) * 100) / 100 : 0;
// Language statistics
const languageCount = topLanguages.length;
const primaryLanguage = topLanguages[0]?.languageName || null;
// Filter repos active this year for language calculation
const reposThisYear = repoInfoList.filter((r) => r.updatedAt.startsWith(currentYearStr));
const { languages: languagesThisYear } = aggregateLanguages(reposThisYear);
const topLanguagesThisYear = languagesThisYear.slice(0, 10);
const primaryLanguageThisYear = topLanguagesThisYear[0]?.languageName || null;
// Contribution statistics from monthly breakdown
const contributionsThisYear = contributionStats.monthlyBreakdown
.filter((m) => m.month.startsWith(currentYearStr))
.reduce((sum, m) => sum + m.contributions, 0);
const contributionsLastYear = contributionStats.monthlyBreakdown
.filter((m) => m.month.startsWith(lastYearStr))
.reduce((sum, m) => sum + m.contributions, 0);
// Year over year growth
const yearOverYearGrowth = contributionsLastYear > 0
? Math.round(((contributionsThisYear - contributionsLastYear) / contributionsLastYear) * 10000) / 100
: null;
// Most productive month
let mostProductiveMonth: { month: string; contributions: number } | null = null;
for (const m of contributionStats.monthlyBreakdown) {
if (!mostProductiveMonth || m.contributions > mostProductiveMonth.contributions) {
mostProductiveMonth = m;
}
}
// Topic statistics
const topicCountMap = new Map<string, number>();
const allTopicsSet = new Set<string>();
for (const repo of repoInfoList) {
for (const topic of repo.topics) {
topicCountMap.set(topic, (topicCountMap.get(topic) || 0) + 1);
allTopicsSet.add(topic);
}
}
const topTopics: TopicCount[] = Array.from(topicCountMap.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20);
const allTopics = Array.from(allTopicsSet).sort();
const totalTopics = allTopics.length;
return {
// Repo stats
totalRepos,
publicRepos,
privateRepos,
archivedRepos,
forkedRepos,
originalRepos,
activeRepos,
reposWithStars,
reposCreatedThisYear,
averageStarsPerRepo,
// Language stats
languageCount,
primaryLanguage,
primaryLanguageThisYear,
topLanguagesThisYear,
// Topic stats
totalTopics,
topTopics,
allTopics,
// Contribution stats
contributionsThisYear,
contributionsLastYear,
yearOverYearGrowth,
mostProductiveMonth,
};
}
/**
* Format bytes to human-readable string
*/
@@ -536,16 +651,12 @@ async function main() {
const octokit = new ThrottledOctokit({
auth: token,
throttle: {
onRateLimit: (retryAfter, options, octokit, retryCount) => {
onRateLimit: (retryAfter, options, octokit) => {
octokit.log.warn(
`Request quota exhausted for request ${options.method} ${options.url}`
);
if (retryCount < 1) {
octokit.log.info(`Retrying after ${retryAfter} seconds!`);
return true;
}
return false;
octokit.log.info(`Retrying after ${retryAfter} seconds!`);
return true; // Always retry on rate limit
},
onSecondaryRateLimit: (retryAfter, options, octokit) => {
octokit.log.warn(
@@ -591,9 +702,13 @@ async function main() {
forks: number;
description: string | null;
isArchived: boolean;
isFork: boolean;
isPrivate: boolean;
primaryLanguage: string | null;
topics: string[];
updatedAt: string;
createdAt: string;
languages: { edges: Array<{ size: number; node: { name: string; color: string } }> };
}
const repoInfoList: RepoInfo[] = repos.map((repo: {
@@ -603,9 +718,13 @@ async function main() {
forkCount: number;
description: string | null;
isArchived: boolean;
isFork: boolean;
isPrivate: boolean;
primaryLanguage: { name: string } | null;
repositoryTopics: { nodes: Array<{ topic: { name: string } }> };
updatedAt: string;
createdAt: string;
languages: { edges: Array<{ size: number; node: { name: string; color: string } }> };
}) => {
let repoOwner: string;
let repoName: string;
@@ -631,32 +750,34 @@ async function main() {
forks: repo.forkCount,
description: repo.description,
isArchived: repo.isArchived,
isFork: repo.isFork,
isPrivate: repo.isPrivate,
primaryLanguage: repo.primaryLanguage?.name || null,
topics: repo.repositoryTopics.nodes.map((n) => n.topic.name),
updatedAt: repo.updatedAt,
createdAt: repo.createdAt,
languages: repo.languages,
};
});
// Fetch contributor stats in batches
// Fire ALL requests in parallel - let throttle plugin handle rate limiting
const contribStats1 = performance.now();
const contribStatsResults = await processBatched(
repoInfoList,
BATCH_SIZE,
(repo) => getReposContributorsStats(octokit, repo.owner, repo.name)
const contribStatsPromises = repoInfoList.map((repo) =>
getReposContributorsStats(octokit, repo.owner, repo.name)
);
const contribStats2 = performance.now();
console.log(`Contributor stats fetch time: ${(contribStats2 - contribStats1).toFixed(2)}ms`);
// Fetch view counts in batches (only for owned repos)
const viewCount1 = performance.now();
const ownedRepos = repoInfoList.filter((r) => r.isOwner);
const viewCountResults = await processBatched(
ownedRepos,
BATCH_SIZE,
(repo) => getReposViewCount(octokit, repo.owner, repo.name)
const viewCountPromises = ownedRepos.map((repo) =>
getReposViewCount(octokit, repo.owner, repo.name)
);
const viewCount2 = performance.now();
console.log(`View count fetch time: ${(viewCount2 - viewCount1).toFixed(2)}ms`);
// Wait for all in parallel
const [contribStatsResults, viewCountResults] = await Promise.all([
Promise.allSettled(contribStatsPromises),
Promise.allSettled(viewCountPromises),
]);
const contribStats2 = performance.now();
console.log(`All repo stats fetch time: ${(contribStats2 - contribStats1).toFixed(2)}ms`);
// Process contributor stats
const parseStats1 = performance.now();
@@ -709,6 +830,9 @@ async function main() {
const calcStats2 = performance.now();
console.log(`Calculate contribution stats time: ${(calcStats2 - calcStats1).toFixed(2)}ms`);
// Calculate computed stats (no API calls)
const computedStats = calculateComputedStats(repoInfoList, topLanguages, contributionStats);
// Build top repos list (top 10 by stars)
const topRepos: RepoDetails[] = repoInfoList
.filter((r) => r.isOwner && !r.isArchived)
@@ -720,15 +844,34 @@ async function main() {
stars: r.stars,
forks: r.forks,
isArchived: r.isArchived,
isFork: r.isFork,
isPrivate: r.isPrivate,
primaryLanguage: r.primaryLanguage,
topics: r.topics,
updatedAt: r.updatedAt,
createdAt: r.createdAt,
}));
// Build repo stats
const repoStats: RepoStats = {
totalRepos: computedStats.totalRepos,
publicRepos: computedStats.publicRepos,
privateRepos: computedStats.privateRepos,
archivedRepos: computedStats.archivedRepos,
forkedRepos: computedStats.forkedRepos,
originalRepos: computedStats.originalRepos,
activeRepos: computedStats.activeRepos,
reposWithStars: computedStats.reposWithStars,
reposCreatedThisYear: computedStats.reposCreatedThisYear,
averageStarsPerRepo: computedStats.averageStarsPerRepo,
};
// Build output
const tableData = [
["Name", userDetails.data.name || ""],
["Username", username],
["Total Repos", computedStats.totalRepos],
["Active Repos (this year)", computedStats.activeRepos],
["Repository Views", formatNumber(repoViews)],
["Lines of Code Changed", formatNumber(linesOfCodeChanged)],
["Lines Added", formatNumber(linesAdded)],
@@ -738,15 +881,21 @@ async function main() {
["Total Pull Requests", userData.user.pullRequests.totalCount],
["Total PR Reviews", contributionsCollection.totalPullRequestReviewContributions],
["Code Bytes Total", formatBytes(codeByteTotal)],
["Top Languages", topLanguages.slice(0, 5).map((lang) => lang.languageName).join(", ")],
["Languages Used", computedStats.languageCount],
["Primary Language", computedStats.primaryLanguage || "N/A"],
["Primary Language (this year)", computedStats.primaryLanguageThisYear || "N/A"],
["Fork Count", forkCount],
["Star Count", starCount],
["Avg Stars/Repo", computedStats.averageStarsPerRepo],
["Stars Given", starsGiven],
["Followers", userData.user.followers.totalCount],
["Following", userData.user.following.totalCount],
["Current Streak", `${contributionStats.currentStreak} days`],
["Longest Streak", `${contributionStats.longestStreak} days`],
["Most Active Day", contributionStats.mostActiveDay],
["Contributions (this year)", computedStats.contributionsThisYear],
["Contributions (last year)", computedStats.contributionsLastYear],
["YoY Growth", computedStats.yearOverYearGrowth !== null ? `${computedStats.yearOverYearGrowth}%` : "N/A"],
["Total Contributions", contributionsCollection.contributionCalendar.totalContributions],
["Closed Issues", userData.user.closedIssues.totalCount],
["Open Issues", userData.user.openIssues.totalCount],
@@ -792,6 +941,8 @@ async function main() {
discussionsAnswered: userData.user.repositoryDiscussionComments.totalCount,
totalContributions: contributionsCollection.contributionCalendar.totalContributions,
contributionStats,
repoStats,
computedStats,
contributionsCollection,
topRepos,
closedIssues: userData.user.closedIssues.totalCount,