- Add -o option to dictate where to output file to. - Update so that missing environment variables just skips that specific hub
430 lines
12 KiB
TypeScript
430 lines
12 KiB
TypeScript
import { mkdir } from "node:fs/promises";
|
|
import { dirname } from "node:path";
|
|
|
|
function parseArgs(args: string[]): { outputPath: string } {
|
|
let outputPath = "../static/activity.json";
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
|
|
if (arg === "--help" || arg === "-h") {
|
|
console.log(
|
|
"Usage: bun run index.ts [--output <path>]\n\nOptions:\n -o, --output <path> Where to write activity.json (default: ../static/activity.json)",
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (arg === "--output" || arg === "-o") {
|
|
const value = args[i + 1];
|
|
if (!value) throw new Error(`${arg} requires a path`);
|
|
outputPath = value;
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (arg?.startsWith("--output=")) {
|
|
outputPath = arg.slice("--output=".length);
|
|
continue;
|
|
}
|
|
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
|
|
return { outputPath };
|
|
}
|
|
|
|
const cli = parseArgs(Bun.argv.slice(2));
|
|
|
|
const config = {
|
|
githubApiKey: process.env["GH_API_KEY"] ?? "",
|
|
forgejoApiKey: process.env["FJ_API_KEY"] ?? "",
|
|
forgejoUsername: process.env["FJ_USER"] ?? "",
|
|
forgejoURL: process.env["FJ_URL"] ?? "https://codeberg.org",
|
|
gitlabApiKey: process.env["GL_API_KEY"] ?? "",
|
|
gitlabUsername: process.env["GL_USER"] ?? "",
|
|
gitlabURL: process.env["GL_URL"] ?? "https://gitlab.com",
|
|
};
|
|
|
|
function areGhVarsPresent() {
|
|
return (
|
|
config.githubApiKey?.trim().length > 0 &&
|
|
config.githubApiKey?.trim().length > 0
|
|
);
|
|
}
|
|
|
|
function areFjVarsPresent() {
|
|
return (
|
|
config.forgejoApiKey?.trim().length > 0 &&
|
|
config.forgejoUsername?.trim().length > 0
|
|
);
|
|
}
|
|
|
|
function areGlVarsPresent() {
|
|
return (
|
|
config.gitlabApiKey?.trim().length > 0 &&
|
|
config.gitlabUsername?.trim().length > 0
|
|
);
|
|
}
|
|
const now = new Date();
|
|
const oneYearAgo = new Date();
|
|
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
|
const formattedOneYearAgo = oneYearAgo.toISOString().replace("Z", "+00:00");
|
|
|
|
function buildGhQuery(from: string, to: string): string {
|
|
return `{
|
|
viewer {
|
|
contributionsCollection(from: "${from}", to: "${to}") {
|
|
contributionCalendar {
|
|
weeks {
|
|
contributionDays {
|
|
date
|
|
contributionCount
|
|
}
|
|
}
|
|
}
|
|
commitContributionsByRepository(maxRepositories: 100) {
|
|
repository {
|
|
nameWithOwner
|
|
isMirror
|
|
}
|
|
contributions(first: 100) {
|
|
nodes {
|
|
occurredAt
|
|
commitCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`;
|
|
}
|
|
|
|
type DayContributions = {
|
|
[repo: string]: number;
|
|
};
|
|
|
|
type ActivityMap = {
|
|
[day: string]: DayContributions;
|
|
};
|
|
|
|
function mergeInto(target: ActivityMap, source: ActivityMap): void {
|
|
for (const [day, repos] of Object.entries(source)) {
|
|
if (!target[day]) target[day] = {};
|
|
for (const [repo, count] of Object.entries(repos)) {
|
|
target[day]![repo] = (target[day]![repo] ?? 0) + count;
|
|
}
|
|
}
|
|
}
|
|
|
|
function repoBaseName(fullName: string): string {
|
|
return fullName.split("/")[1]?.toLowerCase() ?? fullName.toLowerCase();
|
|
}
|
|
|
|
function deduplicateAcrossPlatforms(
|
|
ghMap: ActivityMap,
|
|
fjMap: ActivityMap,
|
|
): ActivityMap {
|
|
const ghBaseNames = new Set<string>();
|
|
for (const repos of Object.values(ghMap)) {
|
|
for (const repo of Object.keys(repos)) {
|
|
if (repo !== "private") ghBaseNames.add(repoBaseName(repo));
|
|
}
|
|
}
|
|
|
|
const sharedBaseNames = new Set<string>();
|
|
for (const repos of Object.values(fjMap)) {
|
|
for (const repo of Object.keys(repos)) {
|
|
if (repo !== "private" && ghBaseNames.has(repoBaseName(repo))) {
|
|
sharedBaseNames.add(repoBaseName(repo));
|
|
}
|
|
}
|
|
}
|
|
|
|
const result: ActivityMap = {};
|
|
const allDays = new Set([...Object.keys(ghMap), ...Object.keys(fjMap)]);
|
|
|
|
for (const day of allDays) {
|
|
result[day] = {};
|
|
const ghDay = ghMap[day] ?? {};
|
|
const fjDay = fjMap[day] ?? {};
|
|
|
|
for (const [repo, count] of Object.entries(ghDay)) {
|
|
const base = repoBaseName(repo);
|
|
if (repo !== "private" && sharedBaseNames.has(base)) {
|
|
const fjRepo = Object.keys(fjDay).find((r) => repoBaseName(r) === base);
|
|
const fjCount = fjRepo ? (fjDay[fjRepo] ?? 0) : 0;
|
|
if (count >= fjCount) {
|
|
result[day]![repo] = count;
|
|
}
|
|
// else: Forgejo wins, added below
|
|
} else {
|
|
result[day]![repo] = (result[day]![repo] ?? 0) + count;
|
|
}
|
|
}
|
|
|
|
for (const [repo, count] of Object.entries(fjDay)) {
|
|
const base = repoBaseName(repo);
|
|
if (repo !== "private" && sharedBaseNames.has(base)) {
|
|
const ghRepo = Object.keys(ghDay).find((r) => repoBaseName(r) === base);
|
|
const ghCount = ghRepo ? (ghDay[ghRepo] ?? 0) : 0;
|
|
if (count > ghCount) {
|
|
if (ghRepo) delete result[day]![ghRepo];
|
|
result[day]![repo] = count;
|
|
}
|
|
// else: GitHub already won
|
|
} else {
|
|
result[day]![repo] = (result[day]![repo] ?? 0) + count;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(result[day]!).length === 0) {
|
|
delete result[day];
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function parseGitHubContributions(data: any): ActivityMap {
|
|
const result: ActivityMap = {};
|
|
|
|
const collection = data?.data?.viewer?.contributionsCollection;
|
|
|
|
const calendarMap: { [day: string]: number } = {};
|
|
for (const week of collection?.contributionCalendar?.weeks ?? []) {
|
|
for (const day of week.contributionDays) {
|
|
if (day.contributionCount > 0)
|
|
calendarMap[day.date] = day.contributionCount;
|
|
}
|
|
}
|
|
|
|
for (const repoEntry of collection?.commitContributionsByRepository ?? []) {
|
|
const repoName: string = repoEntry.repository.nameWithOwner;
|
|
if (repoEntry.repository.isMirror) continue;
|
|
|
|
for (const contribution of repoEntry.contributions.nodes) {
|
|
const day: string = contribution.occurredAt.split("T")[0];
|
|
const count: number = contribution.commitCount;
|
|
if (!result[day]) result[day] = {};
|
|
result[day]![repoName] = (result[day]![repoName] ?? 0) + count;
|
|
}
|
|
}
|
|
|
|
for (const [day, total] of Object.entries(calendarMap)) {
|
|
const known = Object.values(result[day] ?? {}).reduce((a, b) => a + b, 0);
|
|
const hidden = total - known;
|
|
if (hidden > 0) {
|
|
if (!result[day]) result[day] = {};
|
|
result[day]!["private"] = (result[day]!["private"] ?? 0) + hidden;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function parseForgejoActivity(activities: any[]): ActivityMap {
|
|
const result: ActivityMap = {};
|
|
|
|
for (const activity of activities) {
|
|
if (activity.repo?.mirror) continue;
|
|
if (activity.op_type !== "commit_repo") continue;
|
|
|
|
const isPrivate: boolean = activity.repo?.private ?? false;
|
|
const repoFullName: string = activity.repo?.full_name ?? "unknown";
|
|
const displayName = isPrivate ? "private" : repoFullName;
|
|
|
|
let content: { Commits?: { Timestamp: string | number }[] };
|
|
try {
|
|
content = JSON.parse(activity.content);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const commit of content.Commits ?? []) {
|
|
let day: string;
|
|
if (typeof commit.Timestamp === "number") {
|
|
day = new Date(commit.Timestamp * 1000).toISOString().split("T")[0]!;
|
|
} else {
|
|
day = commit.Timestamp.split("T")[0]!;
|
|
}
|
|
|
|
if (!result[day]) result[day] = {};
|
|
result[day]![displayName] = (result[day]![displayName] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function getForgejoActivityMap(): Promise<ActivityMap> {
|
|
const pageLimit = 50;
|
|
let page = 1;
|
|
const map: ActivityMap = {};
|
|
|
|
while (true) {
|
|
const response = await fetch(
|
|
`${config.forgejoURL}/api/v1/users/${config.forgejoUsername}/activities/feeds?limit=${pageLimit}&page=${page}&after=${formattedOneYearAgo}`,
|
|
{ headers: { Authorization: `Bearer ${config.forgejoApiKey}` } },
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Forgejo API error: ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
|
|
const activities = (await response.json()) as any[];
|
|
if (activities.length === 0) break;
|
|
|
|
const pageMap = parseForgejoActivity(activities);
|
|
mergeInto(map, pageMap);
|
|
|
|
if (activities.length < pageLimit) break;
|
|
page++;
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
async function ghFetch(query: string): Promise<any> {
|
|
const response = await fetch("https://api.github.com/graphql", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${config.githubApiKey}`,
|
|
},
|
|
body: JSON.stringify({ query }),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`GitHub API error: ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function getGitHubActivityMap(): Promise<ActivityMap> {
|
|
const ranges = Array.from({ length: 12 }, (_, i) => {
|
|
const to = new Date(now);
|
|
to.setMonth(to.getMonth() - i);
|
|
const from = new Date(to);
|
|
from.setMonth(from.getMonth() - 1);
|
|
return { from: from.toISOString(), to: to.toISOString() };
|
|
});
|
|
|
|
const results = await Promise.all(
|
|
ranges.map(({ from, to }) => ghFetch(buildGhQuery(from, to))),
|
|
);
|
|
|
|
const map: ActivityMap = {};
|
|
for (const data of results) {
|
|
mergeInto(map, parseGitHubContributions(data));
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
function parseGitLabEvents(events: any[]): ActivityMap {
|
|
const result: ActivityMap = {};
|
|
|
|
for (const event of events) {
|
|
const pushData = event.push_data;
|
|
if (!pushData) continue;
|
|
// "deleted" is a branch deletion — no commits to count
|
|
if (pushData.action === "deleted") continue;
|
|
|
|
// New branch pushes report commit_count=0 even though commits exist;
|
|
// fall back to 1 when there's a known head commit.
|
|
const commitCount: number =
|
|
pushData.commit_count > 0
|
|
? pushData.commit_count
|
|
: pushData.commit_to
|
|
? 1
|
|
: 0;
|
|
if (commitCount === 0) continue;
|
|
|
|
// GitLab doesn't include a nested project object in the events API response;
|
|
// construct a name from the fields that are always present.
|
|
const repoFullName: string =
|
|
event.project?.path_with_namespace ??
|
|
(event.author_username && event.target_title
|
|
? `${event.author_username}/${event.target_title}`
|
|
: "unknown");
|
|
const isPrivate: boolean = event.project?.visibility === "private";
|
|
const displayName = isPrivate ? "private" : repoFullName;
|
|
|
|
const day: string = event.created_at.split("T")[0]!;
|
|
|
|
if (!result[day]) result[day] = {};
|
|
result[day]![displayName] = (result[day]![displayName] ?? 0) + commitCount;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function getGitLabActivityMap(): Promise<ActivityMap> {
|
|
if (!config.gitlabApiKey || !config.gitlabUsername) {
|
|
return {};
|
|
}
|
|
|
|
const pageLimit = 100;
|
|
let page = 1;
|
|
const map: ActivityMap = {};
|
|
const afterDate = oneYearAgo.toISOString().split("T")[0]!;
|
|
|
|
while (true) {
|
|
const url = `${config.gitlabURL}/api/v4/users/${config.gitlabUsername}/events?action=pushed&after=${afterDate}&per_page=${pageLimit}&page=${page}`;
|
|
|
|
const response = await fetch(url, {
|
|
headers: { "PRIVATE-TOKEN": config.gitlabApiKey },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`GitLab API error: ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
|
|
const events = (await response.json()) as any[];
|
|
if (events.length === 0) break;
|
|
|
|
const parsed = parseGitLabEvents(events);
|
|
mergeInto(map, parsed);
|
|
|
|
if (events.length < pageLimit) break;
|
|
page++;
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
async function makeActivityMap(): Promise<ActivityMap> {
|
|
const getActivity = async (
|
|
varsPresentCheck: () => boolean,
|
|
getActivityMap: () => Promise<ActivityMap>,
|
|
hubName: string,
|
|
) => {
|
|
if (varsPresentCheck()) {
|
|
console.log(`Fetching ${hubName} activity`);
|
|
return getActivityMap();
|
|
} else {
|
|
console.log(`${hubName} not configured, skipping...`);
|
|
return {};
|
|
}
|
|
};
|
|
const [ghMap, fjMap, glMap] = await Promise.all([
|
|
getActivity(areGhVarsPresent, getGitHubActivityMap, "Github"),
|
|
getActivity(areFjVarsPresent, getForgejoActivityMap, "Forgejo"),
|
|
getActivity(areGlVarsPresent, getGitLabActivityMap, "Gitlab"),
|
|
]);
|
|
|
|
const merged = deduplicateAcrossPlatforms(ghMap, fjMap);
|
|
return deduplicateAcrossPlatforms(merged, glMap);
|
|
}
|
|
|
|
const activityMap = await makeActivityMap();
|
|
await mkdir(dirname(cli.outputPath), { recursive: true });
|
|
await Bun.write(cli.outputPath, JSON.stringify(activityMap, null, 2));
|
|
console.log(
|
|
`Wrote ${Object.keys(activityMap).length} days of activity to ${cli.outputPath}`,
|
|
);
|