mirror of
https://github.com/LukeHagar/stats-action.git
synced 2025-12-06 04:21:26 +00:00
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:
121
src/Types.ts
121
src/Types.ts
@@ -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
251
src/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
743
src/index.ts
743
src/index.ts
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user