function requireEnv(key: string): string { const value = process.env[key]; if (!value) throw new Error(`Missing required env var: ${key}`); return value; } const config = { githubApiKey: requireEnv("GH_API_KEY"), forgejoApiKey: requireEnv("FJ_API_KEY"), forgejoUsername: requireEnv("FJ_USER"), forgejoURL: requireEnv("FJ_URL"), gitlabApiKey: process.env["GL_API_KEY"], gitlabUsername: process.env["GL_USER"], gitlabURL: process.env["GL_URL"] ?? "https://gitlab.com", }; 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: any[] = await response.json(); 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) { console.log("[GitLab] Skipping: GL_API_KEY or GL_USER not set"); return {}; } const pageLimit = 100; let page = 1; const map: ActivityMap = {}; const afterDate = oneYearAgo.toISOString().split("T")[0]!; console.log(`[GitLab] Fetching events for user "${config.gitlabUsername}" from ${config.gitlabURL} after ${afterDate}`); while (true) { const url = `${config.gitlabURL}/api/v4/users/${config.gitlabUsername}/events?action=pushed&after=${afterDate}&per_page=${pageLimit}&page=${page}`; console.log(`[GitLab] GET ${url}`); const response = await fetch(url, { headers: { "PRIVATE-TOKEN": config.gitlabApiKey }, }); console.log(`[GitLab] Response: ${response.status} ${response.statusText}`); if (!response.ok) { const body = await response.text(); console.error(`[GitLab] Error body: ${body}`); throw new Error(`GitLab API error: ${response.status} ${response.statusText}`); } const events: any[] = await response.json(); console.log(`[GitLab] Page ${page}: received ${events.length} events`); if (events.length > 0) { console.log(`[GitLab] First event sample:`, JSON.stringify(events[0], null, 2)); } if (events.length === 0) break; const parsed = parseGitLabEvents(events); console.log(`[GitLab] Page ${page}: parsed ${Object.keys(parsed).length} days of activity`); mergeInto(map, parsed); if (events.length < pageLimit) break; page++; } return map; } async function makeActivityMap(): Promise { const [ghMap, fjMap, glMap] = await Promise.all([ getGitHubActivityMap(), getForgejoActivityMap(), getGitLabActivityMap(), ]); const merged = deduplicateAcrossPlatforms(ghMap, fjMap); return deduplicateAcrossPlatforms(merged, glMap); } const activityMap = await makeActivityMap(); await Bun.write( "../static/activity.json", JSON.stringify(activityMap, null, 2), ); console.log( `Wrote ${Object.keys(activityMap).length} days of activity to out/activity.json`, );