From dd5ae4c9effe6a654d2bd492f50741a37003f3f0 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Mon, 11 May 2026 09:38:10 -0400 Subject: [PATCH] Add activity pull --- get_history/.gitignore | 34 ++++++ get_history/bun.lock | 26 ++++ get_history/index.ts | 248 ++++++++++++++++++++++++++++++++++++++ get_history/package.json | 12 ++ get_history/tsconfig.json | 30 +++++ hugo.toml | 3 + 6 files changed, 353 insertions(+) create mode 100644 get_history/.gitignore create mode 100644 get_history/bun.lock create mode 100644 get_history/index.ts create mode 100644 get_history/package.json create mode 100644 get_history/tsconfig.json create mode 100644 hugo.toml diff --git a/get_history/.gitignore b/get_history/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/get_history/.gitignore @@ -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 diff --git a/get_history/bun.lock b/get_history/bun.lock new file mode 100644 index 0000000..978a13a --- /dev/null +++ b/get_history/bun.lock @@ -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=="], + } +} diff --git a/get_history/index.ts b/get_history/index.ts new file mode 100644 index 0000000..92e868d --- /dev/null +++ b/get_history/index.ts @@ -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(); + 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 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 { + 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 { + 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`, +); diff --git a/get_history/package.json b/get_history/package.json new file mode 100644 index 0000000..3d6e7ac --- /dev/null +++ b/get_history/package.json @@ -0,0 +1,12 @@ +{ + "name": "get_history", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/get_history/tsconfig.json b/get_history/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/get_history/tsconfig.json @@ -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 + } +} diff --git a/hugo.toml b/hugo.toml new file mode 100644 index 0000000..19c8846 --- /dev/null +++ b/hugo.toml @@ -0,0 +1,3 @@ +baseURL = 'https://example.org/' +locale = 'en-us' +title = 'My New Hugo Project'