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 ]\n\nOptions:\n -o, --output 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(); 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(); 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 { 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 { 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 { 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 { 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 { const getActivity = async ( varsPresentCheck: () => boolean, getActivityMap: () => Promise, 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}`, );