Compare commits
2 commits
fb98ff881d
...
e250019862
| Author | SHA1 | Date | |
|---|---|---|---|
| e250019862 | |||
| dd5ae4c9ef |
9 changed files with 434 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
test.html
|
||||||
|
activity.json
|
||||||
6
assets/css/heatmap.css
Normal file
6
assets/css/heatmap.css
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
:root{
|
||||||
|
--color-empty: #F5F5F5;
|
||||||
|
--color-low: #BDD7EE;
|
||||||
|
--color-mid: #2E86C1;
|
||||||
|
--color-high: #1A5276
|
||||||
|
}
|
||||||
73
assets/js/build_heatmap.js
Normal file
73
assets/js/build_heatmap.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
export function flattenData(raw) {
|
||||||
|
const result = {};
|
||||||
|
for (const [date, repos] of Object.entries(raw)) {
|
||||||
|
result[date] = Object.values(repos).reduce((sum, n) => sum + n, 0);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function constructWeeks() {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const start = new Date(today);
|
||||||
|
start.setDate(today.getDate() - 52 * 7);
|
||||||
|
start.setDate(start.getDate() - start.getDay());
|
||||||
|
|
||||||
|
const weeks = [];
|
||||||
|
const cur = new Date(start);
|
||||||
|
while (cur <= today) {
|
||||||
|
const week = [];
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
const day = new Date(cur);
|
||||||
|
week.push(day <= today ? day : null);
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
weeks.push(week);
|
||||||
|
}
|
||||||
|
return weeks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColor(count) {
|
||||||
|
if (count === 0) return "var(--color-empty)";
|
||||||
|
if (count <= 3) return "var(--color-low)";
|
||||||
|
if (count <= 10) return "var(--color-mid)";
|
||||||
|
return "var(--color-high)";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render(weeks, counts) {
|
||||||
|
const CELL = 13;
|
||||||
|
const GAP = 2;
|
||||||
|
const SHIFT = CELL + GAP;
|
||||||
|
|
||||||
|
const svgNS = "http://www.w3.org/2000/svg";
|
||||||
|
const svg = document.createElementNS(svgNS, "svg");
|
||||||
|
|
||||||
|
svg.setAttribute("width", weeks.length * SHIFT + 30);
|
||||||
|
svg.setAttribute("height", SHIFT * 7 + 20);
|
||||||
|
|
||||||
|
weeks.forEach((week, col) => {
|
||||||
|
week.forEach((day, row) => {
|
||||||
|
if (day === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = day.toISOString().slice(0, 10); // Get the key in yyyy-mm-dd format
|
||||||
|
|
||||||
|
const count = counts[key] || 0;
|
||||||
|
|
||||||
|
const rect = document.createElementNS(svgNS, "rect");
|
||||||
|
rect.setAttribute("x", col * SHIFT + 30);
|
||||||
|
rect.setAttribute("y", row * SHIFT);
|
||||||
|
rect.setAttribute("height", CELL);
|
||||||
|
rect.setAttribute("width", CELL);
|
||||||
|
rect.setAttribute("fill", getColor(count));
|
||||||
|
rect.setAttribute("rx", 2);
|
||||||
|
rect.setAttribute("data-date", key);
|
||||||
|
rect.setAttribute("data-count", count);
|
||||||
|
|
||||||
|
svg.appendChild(rect);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("heatmap").appendChild(svg);
|
||||||
|
});
|
||||||
|
}
|
||||||
34
get_history/.gitignore
vendored
Normal file
34
get_history/.gitignore
vendored
Normal 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
26
get_history/bun.lock
Normal 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
248
get_history/index.ts
Normal 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
12
get_history/package.json
Normal 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
30
get_history/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
3
hugo.toml
Normal file
3
hugo.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
baseURL = 'https://example.org/'
|
||||||
|
locale = 'en-us'
|
||||||
|
title = 'My New Hugo Project'
|
||||||
Loading…
Add table
Add a link
Reference in a new issue