hugo-unified-git-activity/get_history/index.ts

380 lines
11 KiB
TypeScript

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<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: 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<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) {
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<ActivityMap> {
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`,
);