Add activity pull

This commit is contained in:
Alex Selimov 2026-05-11 09:38:10 -04:00
parent fb98ff881d
commit dd5ae4c9ef
6 changed files with 353 additions and 0 deletions

34
get_history/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

26
get_history/bun.lock Normal file
View file

@ -0,0 +1,26 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "get_history",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
}
}

248
get_history/index.ts Normal file
View file

@ -0,0 +1,248 @@
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"),
};
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const formattedOneYearAgo = oneYearAgo.toISOString().replace("Z", "+00:00");
const ghQuery = `
{
user(login: "aselimov") {
contributionsCollection {
commitContributionsByRepository(maxRepositories: 100) {
repository {
nameWithOwner
isMirror
isPrivate
}
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 repos =
data?.data?.user?.contributionsCollection
?.commitContributionsByRepository ?? [];
for (const repoEntry of repos) {
const repoName: string = repoEntry.repository.nameWithOwner;
const isMirror: boolean = repoEntry.repository.isMirror;
const isPrivate: boolean = repoEntry.repository.isPrivate;
if (isMirror) continue;
const displayName = isPrivate ? "private" : repoName;
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]![displayName] = (result[day]![displayName] ?? 0) + count;
}
}
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 makeActivityMap(): Promise<ActivityMap> {
const ghResponse = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.githubApiKey}`,
},
body: JSON.stringify({ query: ghQuery }),
});
if (!ghResponse.ok) {
throw new Error(
`GitHub API error: ${ghResponse.status} ${ghResponse.statusText}`,
);
}
const ghData = await ghResponse.json();
const ghMap = parseGitHubContributions(ghData);
const fjMap = await getForgejoActivityMap();
return deduplicateAcrossPlatforms(ghMap, fjMap);
}
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`,
);

12
get_history/package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "get_history",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

30
get_history/tsconfig.json Normal file
View file

@ -0,0 +1,30 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}