Refactor GitHub Stats Action to use Bun, update dependencies, and enhance README with detailed usage instructions. Bump version to 0.0.6.

This commit is contained in:
Luke Hagar
2025-12-04 20:54:52 +00:00
parent eba2296e9d
commit f70e248a3b
20 changed files with 1297 additions and 46852 deletions

View File

@@ -1,40 +1,8 @@
export const NOT_LANGUAGES = [
"html",
"markdown",
"dockerfile",
"roff",
"rich text format",
"powershell",
"css",
"php",
];
export type UserStats = {
name: string;
username: string;
repoViews: number;
linesOfCodeChanged: number;
totalCommits: number;
totalPullRequests: number;
openIssues: number;
closedIssues: number;
fetchedAt: number;
forkCount: number;
starCount: number;
totalContributions: number;
codeByteTotal: number;
topLanguages: Array<{
languageName: string;
color: string | null;
value: number;
}>;
contributionData: Array<{ contributionCount: number; date: string }>;
};
export type Language = {
languageName: string;
color: string | null;
value: number;
percentage: number;
};
export type ContributionData = {
@@ -60,9 +28,96 @@ export type ContributionsCollection = {
};
};
export type MonthlyContribution = {
month: string; // YYYY-MM format
contributions: number;
};
export type ContributionStats = {
longestStreak: number;
currentStreak: number;
mostActiveDay: string; // Day of week
averagePerDay: number;
averagePerWeek: number;
averagePerMonth: number;
monthlyBreakdown: MonthlyContribution[];
};
export type RateLimitInfo = {
limit: number;
remaining: number;
used: number;
resetAt: string;
};
export type UserProfile = {
name: string;
login: string;
bio: string | null;
company: string | null;
location: string | null;
email: string | null;
twitterUsername: string | null;
websiteUrl: string | null;
avatarUrl: string;
createdAt: string;
followers: number;
following: number;
};
export type RepoDetails = {
name: string;
description: string | null;
stars: number;
forks: number;
isArchived: boolean;
primaryLanguage: string | null;
updatedAt: string;
createdAt: string;
};
export type UserStats = {
name: string;
username: string;
avatarUrl: string;
bio: string | null;
company: string | null;
location: string | null;
email: string | null;
twitterUsername: string | null;
websiteUrl: string | null;
createdAt: string;
repoViews: number;
linesOfCodeChanged: number;
linesAdded: number;
linesDeleted: number;
commitCount: number;
totalCommits: number;
totalPullRequests: number;
totalPullRequestReviews: number;
openIssues: number;
closedIssues: number;
fetchedAt: number;
forkCount: number;
starCount: number;
starsGiven: number;
followers: number;
following: number;
repositoriesContributedTo: number;
discussionsStarted: number;
discussionsAnswered: number;
totalContributions: number;
codeByteTotal: number;
topLanguages: Language[];
contributionStats: ContributionStats;
contributionsCollection: ContributionsCollection;
topRepos: RepoDetails[];
};
export interface GraphQLResponse {
user: User;
viewer: Viewer;
rateLimit?: RateLimitInfo;
}
export interface User {

251
src/index.test.ts Normal file
View File

@@ -0,0 +1,251 @@
import { describe, expect, test } from "bun:test";
import {
formatBytes,
formatNumber,
aggregateLanguages,
calculateContributionStats,
processBatched,
} from "./index";
import type { ContributionsCollection } from "./Types";
describe("formatBytes", () => {
test("formats bytes correctly", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(1023)).toBe("1023 B");
});
test("formats kilobytes correctly", () => {
expect(formatBytes(1024)).toBe("1.0 KB");
expect(formatBytes(1536)).toBe("1.5 KB");
expect(formatBytes(10240)).toBe("10.0 KB");
});
test("formats megabytes correctly", () => {
expect(formatBytes(1024 * 1024)).toBe("1.0 MB");
expect(formatBytes(1.5 * 1024 * 1024)).toBe("1.5 MB");
expect(formatBytes(100 * 1024 * 1024)).toBe("100.0 MB");
});
test("formats gigabytes correctly", () => {
expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB");
expect(formatBytes(2.5 * 1024 * 1024 * 1024)).toBe("2.5 GB");
});
});
describe("formatNumber", () => {
test("formats small numbers as-is", () => {
expect(formatNumber(0)).toBe("0");
expect(formatNumber(1)).toBe("1");
expect(formatNumber(999)).toBe("999");
});
test("formats thousands with K suffix", () => {
expect(formatNumber(1000)).toBe("1.0K");
expect(formatNumber(1500)).toBe("1.5K");
expect(formatNumber(10000)).toBe("10.0K");
expect(formatNumber(999999)).toBe("1000.0K");
});
test("formats millions with M suffix", () => {
expect(formatNumber(1000000)).toBe("1.0M");
expect(formatNumber(1500000)).toBe("1.5M");
expect(formatNumber(10000000)).toBe("10.0M");
});
});
describe("aggregateLanguages", () => {
test("aggregates languages from multiple repos", () => {
const repos = [
{
languages: {
edges: [
{ size: 1000, node: { name: "TypeScript", color: "#3178c6" } },
{ size: 500, node: { name: "JavaScript", color: "#f1e05a" } },
],
},
},
{
languages: {
edges: [
{ size: 2000, node: { name: "TypeScript", color: "#3178c6" } },
{ size: 300, node: { name: "Python", color: "#3572A5" } },
],
},
},
];
const result = aggregateLanguages(repos);
expect(result.codeByteTotal).toBe(3800);
expect(result.languages).toHaveLength(3);
// Should be sorted by value descending
expect(result.languages[0].languageName).toBe("TypeScript");
expect(result.languages[0].value).toBe(3000);
expect(result.languages[0].percentage).toBeCloseTo(78.95, 1);
expect(result.languages[1].languageName).toBe("JavaScript");
expect(result.languages[1].value).toBe(500);
expect(result.languages[2].languageName).toBe("Python");
expect(result.languages[2].value).toBe(300);
});
test("handles empty repos", () => {
const repos: Array<{ languages: { edges: Array<{ size: number; node: { name: string; color: string } }> } }> = [];
const result = aggregateLanguages(repos);
expect(result.codeByteTotal).toBe(0);
expect(result.languages).toHaveLength(0);
});
test("handles repos with no languages", () => {
const repos = [{ languages: { edges: [] } }];
const result = aggregateLanguages(repos);
expect(result.codeByteTotal).toBe(0);
expect(result.languages).toHaveLength(0);
});
test("preserves language colors", () => {
const repos = [
{
languages: {
edges: [{ size: 1000, node: { name: "Rust", color: "#dea584" } }],
},
},
];
const result = aggregateLanguages(repos);
expect(result.languages[0].color).toBe("#dea584");
});
});
describe("calculateContributionStats", () => {
const createContributionsCollection = (
days: Array<{ date: string; contributionCount: number }>
): ContributionsCollection => ({
totalCommitContributions: 0,
restrictedContributionsCount: 0,
totalIssueContributions: 0,
totalRepositoryContributions: 0,
totalPullRequestContributions: 0,
totalPullRequestReviewContributions: 0,
contributionCalendar: {
totalContributions: days.reduce((sum, d) => sum + d.contributionCount, 0),
weeks: [{ contributionDays: days }],
},
});
test("calculates longest streak correctly", () => {
const collection = createContributionsCollection([
{ date: "2024-01-01", contributionCount: 1 },
{ date: "2024-01-02", contributionCount: 2 },
{ date: "2024-01-03", contributionCount: 0 },
{ date: "2024-01-04", contributionCount: 1 },
{ date: "2024-01-05", contributionCount: 1 },
{ date: "2024-01-06", contributionCount: 1 },
]);
const stats = calculateContributionStats(collection);
expect(stats.longestStreak).toBe(3);
});
test("calculates averages correctly", () => {
const collection = createContributionsCollection([
{ date: "2024-01-01", contributionCount: 10 },
{ date: "2024-01-02", contributionCount: 20 },
{ date: "2024-01-03", contributionCount: 30 },
{ date: "2024-01-04", contributionCount: 40 },
]);
const stats = calculateContributionStats(collection);
expect(stats.averagePerDay).toBe(25);
expect(stats.averagePerWeek).toBe(175);
});
test("calculates monthly breakdown correctly", () => {
const collection = createContributionsCollection([
{ date: "2024-01-15", contributionCount: 5 },
{ date: "2024-01-20", contributionCount: 10 },
{ date: "2024-02-10", contributionCount: 15 },
]);
const stats = calculateContributionStats(collection);
expect(stats.monthlyBreakdown).toHaveLength(2);
expect(stats.monthlyBreakdown[0]).toEqual({ month: "2024-01", contributions: 15 });
expect(stats.monthlyBreakdown[1]).toEqual({ month: "2024-02", contributions: 15 });
});
test("identifies most active day of week", () => {
// Create contributions heavily weighted toward Monday
const collection = createContributionsCollection([
{ date: "2024-01-01", contributionCount: 100 }, // Monday
{ date: "2024-01-02", contributionCount: 1 }, // Tuesday
{ date: "2024-01-08", contributionCount: 100 }, // Monday
{ date: "2024-01-09", contributionCount: 1 }, // Tuesday
]);
const stats = calculateContributionStats(collection);
expect(stats.mostActiveDay).toBe("Monday");
});
test("handles empty contribution data", () => {
const collection = createContributionsCollection([]);
const stats = calculateContributionStats(collection);
expect(stats.longestStreak).toBe(0);
expect(stats.currentStreak).toBe(0);
expect(stats.averagePerDay).toBe(0);
expect(stats.monthlyBreakdown).toHaveLength(0);
});
});
describe("processBatched", () => {
test("processes items in batches", async () => {
const items = [1, 2, 3, 4, 5];
const processedOrder: number[] = [];
const results = await processBatched(items, 2, async (item) => {
processedOrder.push(item);
return item * 2;
});
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];
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");
});
test("processes single batch correctly", async () => {
const items = [1, 2, 3];
const results = await processBatched(items, 10, async (item) => item);
expect(results).toHaveLength(3);
});
test("handles empty array", async () => {
const results = await processBatched([], 5, async (item: number) => item);
expect(results).toHaveLength(0);
});
});

View File

@@ -1,23 +1,80 @@
import core from "@actions/core";
import { config } from "dotenv";
import { writeFileSync } from "fs";
import { Octokit } from "octokit";
import { throttling } from "@octokit/plugin-throttling";
import { ContributionsCollection, Language } from "./Types";
import {
ContributionsCollection,
Language,
RateLimitInfo,
ContributionStats,
MonthlyContribution,
RepoDetails,
} from "./Types";
import type { GraphQlQueryResponseData } from "@octokit/graphql";
config();
const ThrottledOctokit = Octokit.plugin(throttling);
// Constants
const MAX_RETRY_COUNT = 5;
const BATCH_SIZE = 10;
const RETRY_DELAY_MS = 2000;
/**
* Log rate limit information from GraphQL response
*/
function logRateLimit(rateLimit: RateLimitInfo | undefined, context: string) {
if (rateLimit) {
console.log(
`[Rate Limit] ${context}: ${rateLimit.remaining}/${rateLimit.limit} remaining (resets at ${rateLimit.resetAt})`
);
}
}
/**
* 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
*/
export async function getUserData(
octokit: Octokit,
username: string
): Promise<GraphQlQueryResponseData> {
return octokit.graphql(
const response = await octokit.graphql<GraphQlQueryResponseData & { rateLimit: RateLimitInfo }>(
`query userInfo($login: String!) {
user(login: $login) {
name
login
bio
company
location
email
twitterUsername
websiteUrl
avatarUrl
createdAt
followers {
totalCount
}
following {
totalCount
}
pullRequests(first: 1) {
totalCount
}
@@ -30,9 +87,6 @@ export async function getUserData(
closedIssues: issues(states: CLOSED) {
totalCount
}
followers {
totalCount
}
repositoryDiscussions {
totalCount
}
@@ -51,13 +105,19 @@ export async function getUserData(
login: username,
}
);
logRateLimit(response.rateLimit, "getUserData");
return response;
}
/**
* Get repository data with extended metadata
*/
export async function getRepoData(
octokit: Octokit,
username: string
): Promise<GraphQlQueryResponseData> {
return octokit.graphql.paginate(
const response = await octokit.graphql.paginate(
`query repoInfo($login: String!, $cursor: String) {
user(login: $login) {
repositories(
@@ -68,11 +128,19 @@ export async function getRepoData(
) {
totalCount
nodes {
name
description
isArchived
createdAt
updatedAt
stargazers {
totalCount
}
forkCount
name
primaryLanguage {
name
color
}
languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {
edges {
size
@@ -89,34 +157,45 @@ export async function getRepoData(
}
}
}
rateLimit {
limit
remaining
used
resetAt
}
}`,
{
login: username,
}
);
logRateLimit((response as { rateLimit?: RateLimitInfo }).rateLimit, "getRepoData");
return response as GraphQlQueryResponseData;
}
/**
* Get contribution collection for a date range using proper GraphQL variables
*/
export async function getContributionCollection(
octokit: Octokit,
year: string
createdAt: string
) {
const yearCreated = new Date(year);
const yearCreated = new Date(createdAt);
const currentYear = new Date();
const promises = [];
for (let i = yearCreated.getFullYear(); i <= currentYear.getFullYear(); i++) {
let startYear = `${i}-01-01T00:00:00.000Z`;
if (i === yearCreated.getFullYear()) startYear = year;
if (i === yearCreated.getFullYear()) startYear = createdAt;
let endYear = `${i + 1}-01-01T00:00:00.000Z`;
if (i === currentYear.getFullYear()) endYear = currentYear.toISOString();
promises.push(
octokit
.graphql<
Promise<{
viewer: { contributionsCollection: ContributionsCollection };
}>
>(
`{
.graphql<{
viewer: { contributionsCollection: ContributionsCollection };
rateLimit: RateLimitInfo;
}>(
`query getContributions($from: DateTime!, $to: DateTime!) {
rateLimit {
limit
remaining
@@ -124,14 +203,10 @@ export async function getContributionCollection(
resetAt
}
viewer {
contributionsCollection(
from: "${startYear}"
to: "${endYear}"
) {
contributionsCollection(from: $from, to: $to) {
totalCommitContributions
restrictedContributionsCount
totalIssueContributions
totalCommitContributions
totalRepositoryContributions
totalPullRequestContributions
totalPullRequestReviewContributions
@@ -146,18 +221,31 @@ export async function getContributionCollection(
}
}
}
}`,
{
from: startYear,
to: endYear,
}
`
)
.then((response) => {
logRateLimit(response.rateLimit, `getContributionCollection year ${i}`);
return response;
})
.catch((error) => {
console.error(`Failed to fetch data for year ${i}: ${error.message}`);
return null;
})
);
}
const years = (await Promise.all(promises)).filter(Boolean) as {
viewer: { contributionsCollection: ContributionsCollection };
}[];
const years = (await Promise.allSettled(promises))
.filter(
(result): result is PromiseFulfilledResult<{
viewer: { contributionsCollection: ContributionsCollection };
rateLimit: RateLimitInfo;
} | null> => result.status === "fulfilled" && result.value !== null
)
.map((result) => result.value!);
if (years.length === 0) {
throw new Error("Failed to fetch data for all years");
@@ -195,75 +283,255 @@ export async function getContributionCollection(
return contributionsCollection;
}
/**
* Get total commits for user
*/
export async function getTotalCommits(octokit: Octokit, username: string) {
return octokit.rest.search.commits({
q: `author:${username}`,
});
}
/**
* Get stars given by user
*/
export async function getUsersStars(octokit: Octokit, username: string) {
return octokit.rest.activity.listReposStarredByUser({
const response = await octokit.rest.activity.listReposStarredByUser({
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"/);
if (lastPageMatch) {
return parseInt(lastPageMatch[1], 10);
}
}
return response.data.length;
}
/**
* Get contributor stats for a repo with retry limit
*/
export async function getReposContributorsStats(
octokit: Octokit,
username: string,
repo: string
) {
owner: string,
repo: string,
retryCount = 0
): Promise<Awaited<ReturnType<typeof octokit.rest.repos.getContributorsStats>> | undefined> {
try {
const response = await octokit.rest.repos.getContributorsStats({
owner: username,
owner,
repo,
});
if (response.status === 202) {
// Retry after the specified delay
await new Promise((resolve) => setTimeout(resolve, 2 * 1000));
if (retryCount >= MAX_RETRY_COUNT) {
console.warn(
`Max retries (${MAX_RETRY_COUNT}) reached for ${owner}/${repo}, skipping`
);
return undefined;
}
// Retry the request
return getReposContributorsStats(octokit, username, repo);
} else {
return response;
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
return getReposContributorsStats(octokit, owner, repo, retryCount + 1);
}
return response;
} catch (error) {
console.error(error);
console.error(`Error fetching contributor stats for ${owner}/${repo}:`, error);
return undefined;
}
}
/**
* Get view count for a repo
*/
export async function getReposViewCount(
octokit: Octokit,
username: string,
owner: string,
repo: string
) {
return octokit.rest.repos.getViews({
per: "week",
owner: username,
owner,
repo,
});
}
export const NOT_LANGUAGES = [
"html",
"markdown",
"dockerfile",
"roff",
"rich text format",
"powershell",
"css",
"php",
];
/**
* Calculate contribution statistics from calendar data
*/
export function calculateContributionStats(
contributionsCollection: ContributionsCollection
): ContributionStats {
const allDays: { date: string; count: number }[] = [];
const monthlyMap = new Map<string, number>();
const dayOfWeekCounts = new Map<string, number>();
const NOT_LANGUAGES_OBJ = Object.fromEntries(
NOT_LANGUAGES.map((l) => [l, true])
);
// Flatten all contribution days
for (const week of contributionsCollection.contributionCalendar.weeks) {
for (const day of week.contributionDays) {
allDays.push({ date: day.date, count: day.contributionCount });
try {
// Monthly aggregation
const month = day.date.substring(0, 7); // YYYY-MM
monthlyMap.set(month, (monthlyMap.get(month) || 0) + day.contributionCount);
// Day of week aggregation
const dayOfWeek = new Date(day.date).toLocaleDateString("en-US", {
weekday: "long",
});
dayOfWeekCounts.set(
dayOfWeek,
(dayOfWeekCounts.get(dayOfWeek) || 0) + day.contributionCount
);
}
}
// Sort days by date
allDays.sort((a, b) => a.date.localeCompare(b.date));
// Calculate streaks
let currentStreak = 0;
let longestStreak = 0;
let tempStreak = 0;
const today = new Date().toISOString().split("T")[0];
const yesterday = new Date(Date.now() - 86400000).toISOString().split("T")[0];
for (let i = allDays.length - 1; i >= 0; i--) {
const day = allDays[i];
if (day.count > 0) {
tempStreak++;
if (i === allDays.length - 1 && (day.date === today || day.date === yesterday)) {
currentStreak = tempStreak;
}
} else {
longestStreak = Math.max(longestStreak, tempStreak);
tempStreak = 0;
}
}
longestStreak = Math.max(longestStreak, tempStreak);
// Recalculate current streak from the end
currentStreak = 0;
for (let i = allDays.length - 1; i >= 0; i--) {
if (allDays[i].count > 0) {
currentStreak++;
} else if (allDays[i].date !== today) {
// Allow today to have 0 if we're still on today
break;
}
}
// Find most active day
let mostActiveDay = "Sunday";
let maxDayCount = 0;
for (const [day, count] of dayOfWeekCounts) {
if (count > maxDayCount) {
maxDayCount = count;
mostActiveDay = day;
}
}
// Calculate averages
const totalDays = allDays.length || 1;
const totalContributions =
contributionsCollection.contributionCalendar.totalContributions;
const averagePerDay = totalContributions / totalDays;
const averagePerWeek = averagePerDay * 7;
const averagePerMonth = averagePerDay * 30;
// Monthly breakdown sorted by date
const monthlyBreakdown: MonthlyContribution[] = Array.from(monthlyMap.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([month, contributions]) => ({ month, contributions }));
return {
longestStreak,
currentStreak,
mostActiveDay,
averagePerDay: Math.round(averagePerDay * 100) / 100,
averagePerWeek: Math.round(averagePerWeek * 100) / 100,
averagePerMonth: Math.round(averagePerMonth * 100) / 100,
monthlyBreakdown,
};
}
/**
* Aggregate languages from repos using Map for O(n) performance
*/
export function aggregateLanguages(
repos: Array<{
languages: { edges: Array<{ size: number; node: { name: string; color: string } }> };
}>
): { languages: Language[]; codeByteTotal: number } {
const languageMap = new Map<string, { color: string | null; value: number }>();
let codeByteTotal = 0;
for (const repo of repos) {
for (const edge of repo.languages.edges) {
const langName = edge.node.name;
const existing = languageMap.get(langName);
if (existing) {
existing.value += edge.size;
} else {
languageMap.set(langName, {
color: edge.node.color,
value: edge.size,
});
}
codeByteTotal += edge.size;
}
}
// Convert to array, sort by value descending, add percentages
const languages: Language[] = Array.from(languageMap.entries())
.map(([languageName, data]) => ({
languageName,
color: data.color,
value: data.value,
percentage: codeByteTotal > 0
? Math.round((data.value / codeByteTotal) * 10000) / 100
: 0,
}))
.sort((a, b) => b.value - a.value);
return { languages, codeByteTotal };
}
/**
* Format bytes to human-readable string
*/
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
/**
* Format number to human-readable string
*/
export function formatNumber(num: number): string {
if (num < 1000) return num.toString();
if (num < 1000000) return `${(num / 1000).toFixed(1)}K`;
return `${(num / 1000000).toFixed(1)}M`;
}
/**
* Main function
*/
async function main() {
const setup1 = performance.now();
const token = process.env["GITHUB_TOKEN"];
if (!token) core.error("GITHUB_TOKEN is not present");
if (!token) {
core.setFailed("GITHUB_TOKEN is not present");
return;
}
const octokit = new ThrottledOctokit({
auth: token,
@@ -274,14 +542,12 @@ try {
);
if (retryCount < 1) {
// only retries once
octokit.log.info(`Retrying after ${retryAfter} seconds!`);
return true;
}
return false;
},
onSecondaryRateLimit: (retryAfter, options, octokit) => {
// does not retry, only logs a warning
octokit.log.warn(
`SecondaryRateLimit detected for request ${options.method} ${options.url}.`
);
@@ -292,41 +558,57 @@ try {
});
const fetchedAt = Date.now();
const setup2 = performance.now();
console.log(`Setup time: ${(setup2 - setup1).toFixed(2)}ms`);
console.log(`Setup time: ${setup2 - setup1}ms`);
// Fetch main data in parallel
const main1 = performance.now();
const userDetails = await octokit.rest.users.getAuthenticated();
const username = userDetails.data.login;
const [userData, repoData, totalCommits, contributionsCollection] =
const [userData, repoData, totalCommits, contributionsCollection, starsGiven] =
await Promise.all([
getUserData(octokit, username),
getRepoData(octokit, username),
getTotalCommits(octokit, username),
getContributionCollection(octokit, userDetails.data.created_at),
getUsersStars(octokit, username),
]);
const main2 = performance.now();
console.log(`Main data fetch time: ${(main2 - main1).toFixed(2)}ms`);
console.log(`Main time: ${main2 - main1}ms`);
const viewCountPromises = [];
// Process repos
const repos = repoData.user.repositories.nodes;
let starCount = 0;
let forkCount = 0;
let contribStatsPromises = [];
let contributorStats = [];
const repos = repoData.user.repositories.nodes;
interface RepoInfo {
owner: string;
name: string;
isOwner: boolean;
stars: number;
forks: number;
description: string | null;
isArchived: boolean;
primaryLanguage: string | null;
updatedAt: string;
createdAt: string;
}
const promisesCreate1 = performance.now();
const promisesResolve1 = performance.now();
for (const repo of repos) {
let repoOwner, repoName;
const repoInfoList: RepoInfo[] = repos.map((repo: {
name: string;
nameWithOwner?: string;
stargazers: { totalCount: number };
forkCount: number;
description: string | null;
isArchived: boolean;
primaryLanguage: { name: string } | null;
updatedAt: string;
createdAt: string;
}) => {
let repoOwner: string;
let repoName: string;
if (repo.nameWithOwner) {
[repoOwner, repoName] = repo.nameWithOwner.split("/");
@@ -335,201 +617,212 @@ try {
repoName = repo.name;
}
contribStatsPromises.push(
getReposContributorsStats(octokit, repoOwner, repoName)
);
if (repoOwner === username) {
viewCountPromises.push(getReposViewCount(octokit, username, repoName));
const isOwner = repoOwner === username;
if (isOwner) {
starCount += repo.stargazers.totalCount;
forkCount += repo.forkCount;
}
}
const promisesCreate2 = performance.now();
return {
owner: repoOwner,
name: repoName,
isOwner,
stars: repo.stargazers.totalCount,
forks: repo.forkCount,
description: repo.description,
isArchived: repo.isArchived,
primaryLanguage: repo.primaryLanguage?.name || null,
updatedAt: repo.updatedAt,
createdAt: repo.createdAt,
};
});
console.log(`Promises create time: ${promisesCreate2 - promisesCreate1}ms`);
const repoContribStatsResps = await Promise.all(contribStatsPromises);
const promisesResolve2 = performance.now();
console.log(
`Promises resolve time: ${promisesResolve2 - promisesResolve1}ms`
// Fetch contributor stats in batches
const contribStats1 = performance.now();
const contribStatsResults = await processBatched(
repoInfoList,
BATCH_SIZE,
(repo) => getReposContributorsStats(octokit, repo.owner, repo.name)
);
const contribStats2 = performance.now();
console.log(`Contributor stats fetch time: ${(contribStats2 - contribStats1).toFixed(2)}ms`);
const parseRepoPromises1 = performance.now();
// 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 viewCount2 = performance.now();
console.log(`View count fetch time: ${(viewCount2 - viewCount1).toFixed(2)}ms`);
for (const resp of repoContribStatsResps) {
if (!resp) {
continue;
}
// Process contributor stats
const parseStats1 = performance.now();
let linesAdded = 0;
let linesDeleted = 0;
let commitCount = 0;
let stats;
for (const result of contribStatsResults) {
if (result.status !== "fulfilled" || !result.value) continue;
if (!Array.isArray(resp.data)) {
console.log(resp);
stats = [resp.data];
} else {
stats = resp.data;
}
const repoContribStats = stats.find(
const resp = result.value;
const stats = Array.isArray(resp.data) ? resp.data : [resp.data];
const userStats = stats.find(
(contributor) => contributor?.author?.login === username
);
if (repoContribStats?.weeks)
contributorStats.push(...repoContribStats.weeks);
}
const parseRepoPromises2 = performance.now();
console.log(
`Parse repo promises time: ${parseRepoPromises2 - parseRepoPromises1}ms`
);
const parseLines1 = performance.now();
let linesOfCodeChanged = 0;
let addedLines = 0;
let deletedLines = 0;
let changedLines = 0;
for (const week of contributorStats) {
if (week.a) {
linesOfCodeChanged += week.a;
addedLines += week.a;
}
if (week.d) {
linesOfCodeChanged += week.d;
deletedLines += week.d;
}
if (week.c) {
linesOfCodeChanged += week.c;
changedLines += week.c;
if (userStats?.weeks) {
for (const week of userStats.weeks) {
if (week.a) linesAdded += week.a;
if (week.d) linesDeleted += week.d;
if (week.c) commitCount += week.c;
}
}
}
const parseLines2 = performance.now();
console.log(`Parse lines time: ${parseLines2 - parseLines1}ms`);
const linesOfCodeChanged = linesAdded + linesDeleted;
const parseStats2 = performance.now();
console.log(`Parse contributor stats time: ${(parseStats2 - parseStats1).toFixed(2)}ms`);
// Process view counts
const parseViews1 = performance.now();
const viewCounts = await Promise.all(viewCountPromises);
let repoViews = 0;
for (const viewCount of viewCounts) {
repoViews += viewCount.data.count;
}
const parseViews2 = performance.now();
console.log(`Parse views time: ${parseViews2 - parseViews1}ms`);
const parseLang1 = performance.now();
const topLanguages: Language[] = [];
let codeByteTotal = 0;
for (const node of repoData.user.repositories.nodes) {
for (const edge of node.languages.edges) {
if (NOT_LANGUAGES_OBJ[edge.node.name.toLowerCase()]) {
continue;
}
const existingLanguage = topLanguages.find(
(l) => l.languageName === edge.node.name
);
if (existingLanguage) {
existingLanguage.value += edge.size;
codeByteTotal += edge.size;
} else {
topLanguages.push({
languageName: edge.node.name,
color: edge.node.color,
value: edge.size,
});
codeByteTotal += edge.size;
}
for (const result of viewCountResults) {
if (result.status === "fulfilled" && result.value) {
repoViews += result.value.data.count;
}
}
const parseViews2 = performance.now();
console.log(`Parse views time: ${(parseViews2 - parseViews1).toFixed(2)}ms`);
// Aggregate languages with O(n) performance
const parseLang1 = performance.now();
const { languages: topLanguages, codeByteTotal } = aggregateLanguages(repos);
const parseLang2 = performance.now();
console.log(`Parse languages time: ${(parseLang2 - parseLang1).toFixed(2)}ms`);
console.log(`Parse languages time: ${parseLang2 - parseLang1}ms`);
// Calculate contribution statistics
const calcStats1 = performance.now();
const contributionStats = calculateContributionStats(contributionsCollection);
const calcStats2 = performance.now();
console.log(`Calculate contribution stats time: ${(calcStats2 - calcStats1).toFixed(2)}ms`);
// Build top repos list (top 10 by stars)
const topRepos: RepoDetails[] = repoInfoList
.filter((r) => r.isOwner && !r.isArchived)
.sort((a, b) => b.stars - a.stars)
.slice(0, 10)
.map((r) => ({
name: r.name,
description: r.description,
stars: r.stars,
forks: r.forks,
isArchived: r.isArchived,
primaryLanguage: r.primaryLanguage,
updatedAt: r.updatedAt,
createdAt: r.createdAt,
}));
// Build output
const tableData = [
["Name", userDetails.data.name || ""],
["Username", username],
["Repository Views", repoViews],
["Lines of Code Changed", linesOfCodeChanged],
["Lines Added", addedLines],
["Lines Deleted", deletedLines],
["Lines Changed", changedLines],
["Total Commits", totalCommits.data.total_count],
["Repository Views", formatNumber(repoViews)],
["Lines of Code Changed", formatNumber(linesOfCodeChanged)],
["Lines Added", formatNumber(linesAdded)],
["Lines Deleted", formatNumber(linesDeleted)],
["Commit Count (from stats)", formatNumber(commitCount)],
["Total Commits (search)", formatNumber(totalCommits.data.total_count)],
["Total Pull Requests", userData.user.pullRequests.totalCount],
["Code Byte Total", codeByteTotal],
["Top Languages", topLanguages.map((lang) => lang.languageName).join(", ")],
["Total PR Reviews", contributionsCollection.totalPullRequestReviewContributions],
["Code Bytes Total", formatBytes(codeByteTotal)],
["Top Languages", topLanguages.slice(0, 5).map((lang) => lang.languageName).join(", ")],
["Fork Count", forkCount],
["Star Count", starCount],
[
"Total Contributions",
contributionsCollection.contributionCalendar.totalContributions,
],
["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],
["Total Contributions", contributionsCollection.contributionCalendar.totalContributions],
["Closed Issues", userData.user.closedIssues.totalCount],
["Open Issues", userData.user.openIssues.totalCount],
["Fetched At", fetchedAt],
["Fetched At", new Date(fetchedAt).toISOString()],
];
const formattedTableData = tableData.map((row) => {
return { Name: row[0], Value: row[1] };
});
const formattedTableData = tableData.map((row) => ({
Name: row[0],
Value: row[1],
}));
console.table(formattedTableData);
writeFileSync(
"github-user-stats.json",
JSON.stringify(
{
name: userDetails.data.name || "",
avatarUrl: userDetails.data.avatar_url,
username,
repoViews,
linesOfCodeChanged,
linesAdded: addedLines,
linesDeleted: deletedLines,
linesChanged: changedLines,
totalCommits: totalCommits.data.total_count,
totalPullRequests: userData.user.pullRequests.totalCount,
codeByteTotal,
topLanguages,
forkCount,
starCount,
totalContributions:
contributionsCollection.contributionCalendar.totalContributions,
contributionsCollection,
closedIssues: userData.user.closedIssues.totalCount,
openIssues: userData.user.openIssues.totalCount,
fetchedAt,
},
null,
4
)
);
// Write output file
const output = {
name: userDetails.data.name || "",
avatarUrl: userDetails.data.avatar_url,
username,
bio: userData.user.bio || null,
company: userData.user.company || null,
location: userData.user.location || null,
email: userData.user.email || null,
twitterUsername: userData.user.twitterUsername || null,
websiteUrl: userData.user.websiteUrl || null,
createdAt: userDetails.data.created_at,
repoViews,
linesOfCodeChanged,
linesAdded,
linesDeleted,
commitCount,
totalCommits: totalCommits.data.total_count,
totalPullRequests: userData.user.pullRequests.totalCount,
totalPullRequestReviews: contributionsCollection.totalPullRequestReviewContributions,
codeByteTotal,
topLanguages,
forkCount,
starCount,
starsGiven,
followers: userData.user.followers.totalCount,
following: userData.user.following.totalCount,
repositoriesContributedTo: userData.user.repositoriesContributedTo.totalCount,
discussionsStarted: userData.user.repositoryDiscussions.totalCount,
discussionsAnswered: userData.user.repositoryDiscussionComments.totalCount,
totalContributions: contributionsCollection.contributionCalendar.totalContributions,
contributionStats,
contributionsCollection,
topRepos,
closedIssues: userData.user.closedIssues.totalCount,
openIssues: userData.user.openIssues.totalCount,
fetchedAt,
};
if (process.env["GITHUB_WORKFLOW"])
writeFileSync("github-user-stats.json", JSON.stringify(output, null, 2));
// Write GitHub Actions summary
if (process.env["GITHUB_WORKFLOW"]) {
await core.summary
.addHeading("Test Results")
.addHeading("GitHub Stats")
.addTable([
[
{ data: "Name", header: true },
{ data: "Metric", header: true },
{ data: "Value", header: true },
],
...tableData.map((row) => [String(row[0]), String(row[1])]),
])
.write();
} catch (error) {
core.setFailed(error as string);
}
console.log(`\nTotal execution time: ${(performance.now() - setup1).toFixed(2)}ms`);
}
// Run main function only when this file is the entry point
const isMainModule = import.meta.main ?? process.argv[1]?.endsWith("index.ts");
if (isMainModule) {
main().catch((error) => {
console.error("Fatal error:", error);
core.setFailed(error instanceof Error ? error.message : String(error));
});
}