Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/metrics/commit-history.mjs
T

322 lines
14 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(SCRIPT_DIR, "../..");
export const COMMIT_HISTORY_DAYS = 60;
export const COMMIT_HISTORY_TIME_ZONE = "Europe/Berlin";
const DEFAULTS = Object.freeze({
days: COMMIT_HISTORY_DAYS,
label: null,
outputDir: "docs/metrics",
ref: "origin/main",
});
function printHelp() {
console.log(`Usage: node scripts/metrics/commit-history.mjs [options]
Options:
--ref <git-ref> Revision to inspect (default: ${DEFAULTS.ref})
--label <text> Ref label rendered in outputs (default: value of --ref)
--days <number> Calendar days to render (default: ${DEFAULTS.days})
--output-dir <path> Output directory (default: ${DEFAULTS.outputDir})
--help Show this help
Outputs:
commit-history.csv Daily commit counts
commit-history.svg Standalone 60-day chart
All commits reachable from the selected revision are counted by committer date,
including merge commits and commits introduced by merged branches. Calendar days
are grouped in ${COMMIT_HISTORY_TIME_ZONE}; days without commits remain visible.`);
}
export function parseArgs(argv) {
const options = { ...DEFAULTS };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--help") {
printHelp();
process.exit(0);
}
if (argument === "--ref" || argument === "--label" || argument === "--days" || argument === "--output-dir") {
const value = argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${argument}`);
if (argument === "--ref") options.ref = value;
if (argument === "--label") options.label = value;
if (argument === "--output-dir") options.outputDir = value;
if (argument === "--days") {
const days = Number(value);
if (!Number.isInteger(days) || days < 1 || days > 365) {
throw new Error("--days must be an integer between 1 and 365");
}
options.days = days;
}
index += 1;
continue;
}
throw new Error(`Unknown argument: ${argument}`);
}
return { ...options, label: options.label ?? options.ref };
}
function git(args, options = {}) {
return execFileSync("git", args, {
cwd: options.repoRoot ?? REPO_ROOT,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
}
export function toBerlinDateKey(timestamp) {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) throw new Error(`Invalid Git timestamp: ${timestamp}`);
const parts = new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "2-digit",
timeZone: COMMIT_HISTORY_TIME_ZONE,
year: "numeric",
}).formatToParts(date);
const value = (type) => parts.find((part) => part.type === type)?.value;
return `${value("year")}-${value("month")}-${value("day")}`;
}
function addCalendarDays(dateKey, amount) {
const [year, month, day] = dateKey.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1, day + amount, 12));
return date.toISOString().slice(0, 10);
}
export function collectCommitHistory(ref, { days = COMMIT_HISTORY_DAYS, repoRoot = REPO_ROOT, runGit = git } = {}) {
const resolvedRef = runGit(["rev-parse", "--verify", `${ref}^{commit}`], { repoRoot }).trim();
const tipTimestamp = runGit(["show", "-s", "--format=%cI", resolvedRef], { repoRoot }).trim();
const endDate = toBerlinDateKey(tipTimestamp);
const startDate = addCalendarDays(endDate, -(days - 1));
const rows = [];
const counts = new Map();
for (let offset = 0; offset < days; offset += 1) {
const date = addCalendarDays(startDate, offset);
const row = { count: 0, date };
rows.push(row);
counts.set(date, row);
}
const log = runGit(["log", resolvedRef, "--format=%H%x09%cI"], { repoRoot }).trim();
if (log) {
for (const line of log.split("\n")) {
const [sha, timestamp] = line.split("\t");
if (!sha || !timestamp) throw new Error(`Malformed Git log entry: ${line}`);
const row = counts.get(toBerlinDateKey(timestamp));
if (row) row.count += 1;
}
}
return { days, endDate, resolvedRef, rows, startDate, tipTimestamp };
}
function escapeXml(value) {
return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
}
function formatNumber(value) {
return new Intl.NumberFormat("de-DE").format(Math.round(value));
}
function formatDecimal(value) {
return new Intl.NumberFormat("de-DE", {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
}).format(value);
}
function formatDisplayDate(dateKey) {
const [, month, day] = dateKey.split("-");
return `${day}.${month}.`;
}
function niceAxisMaximum(value) {
if (value <= 0) return 1;
const roughStep = value / 4;
const magnitude = 10 ** Math.floor(Math.log10(roughStep));
const normalized = roughStep / magnitude;
const niceNormalized = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 2.5 ? 2.5 : normalized <= 4 ? 4 : normalized <= 5 ? 5 : 10;
const step = niceNormalized * magnitude;
return Math.ceil(value / step) * step;
}
export function renderCsv(history, ref) {
const metadata = [
`# ref=${ref}`,
`# resolved_tip=${history.resolvedRef}`,
`# tip_timestamp=${history.tipTimestamp}`,
"# metric=commits reachable from ref grouped by committer date",
`# window_days=${history.days}`,
`# timezone=${COMMIT_HISTORY_TIME_ZONE}`,
"# included=merge commits and commits introduced by merged branches",
"# excluded=commits reachable only from other branches such as loc-metrics",
];
const body = history.rows.map((row) => `${row.date},${row.count}`);
return `${[...metadata, "date,commit_count", ...body].join("\n")}\n`;
}
export function renderSvg(history, ref) {
const width = 1600;
const height = 900;
const plot = { bottom: 730, left: 126, right: 1518, top: 320 };
const plotWidth = plot.right - plot.left;
const plotHeight = plot.bottom - plot.top;
const rows = history.rows;
if (rows.length === 0) throw new Error("Commit history requires at least one calendar day");
const latest = rows.at(-1);
const latestIndex = rows.length - 1;
const lastSeven = rows.slice(-7).reduce((sum, row) => sum + row.count, 0);
const total = rows.reduce((sum, row) => sum + row.count, 0);
const average = total / rows.length;
const peakCount = Math.max(...rows.map((row) => row.count));
const peakIndex = rows.findIndex((row) => row.count === peakCount);
const peak = rows[peakIndex];
const axisMaximum = niceAxisMaximum(peakCount * 1.02);
const step = plotWidth / rows.length;
const barWidth = Math.max(4, Math.min(28, step * 0.6));
const x = (index) => plot.left + (index + 0.5) * step;
const y = (value) => plot.bottom - (value / axisMaximum) * plotHeight;
const barHeight = (value) => (value / axisMaximum) * plotHeight;
const trendPoints = [];
for (let index = 6; index < rows.length; index += 1) {
const movingAverage = rows.slice(index - 6, index + 1).reduce((sum, row) => sum + row.count, 0) / 7;
trendPoints.push(`${x(index).toFixed(1)},${y(movingAverage).toFixed(1)}`);
}
const cards = [
{ color: "#0F172A", label: "HEUTE", value: `${formatNumber(latest.count)} Commits` },
{ color: "#0F766E", label: "LETZTE 7 TAGE", value: `${formatNumber(lastSeven)} Commits` },
{ color: "#F59E0B", label: `Ø PRO TAG · ${history.days} TAGE`, value: `${formatDecimal(average)} Commits` },
{ color: "#8B5CF6", label: `PEAK · ${formatDisplayDate(peak.date)}`, value: `${formatNumber(peak.count)} Commits` },
];
const svg = [];
svg.push(`<?xml version="1.0" encoding="UTF-8"?>`);
svg.push(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="title description">`
);
svg.push(`<title id="title">HiveGrid Commit-Aktivität auf ${escapeXml(ref)}</title>`);
svg.push(
`<desc id="description">Tägliche Anzahl aller von ${escapeXml(ref)} erreichbaren Commits für die letzten ${history.days} Kalendertage, gruppiert in der Zeitzone ${COMMIT_HISTORY_TIME_ZONE}, mit Sieben-Tage-Durchschnitt.</desc>`
);
svg.push(
`<style>.text{font-family:Inter,ui-sans-serif,system-ui,sans-serif}.grid{stroke:#E2E8F0;stroke-width:1}.axis-label{fill:#64748B;font-size:15px}.bar{fill:#0F766E;fill-opacity:.82}</style>`
);
svg.push(`<rect width="${width}" height="${height}" fill="#F8FAFC"/>`);
svg.push(`<text class="text" x="78" y="74" fill="#0F172A" font-size="36" font-weight="750">HiveGrid · Commit-Aktivität</text>`);
svg.push(
`<text class="text" x="78" y="110" fill="#64748B" font-size="18">${escapeXml(ref)} @ ${history.resolvedRef.slice(0, 9)} · letzte ${history.days} Kalendertage · ${COMMIT_HISTORY_TIME_ZONE} · heute laufend</text>`
);
cards.forEach((card, index) => {
const cardX = 78 + index * 366;
svg.push(`<rect x="${cardX}" y="142" width="340" height="92" rx="14" fill="#FFFFFF" stroke="#E2E8F0"/>`);
svg.push(`<rect x="${cardX}" y="142" width="6" height="92" rx="3" fill="${card.color}"/>`);
svg.push(
`<text class="text" x="${cardX + 26}" y="174" fill="#64748B" font-size="14" font-weight="650" letter-spacing="0.5">${escapeXml(card.label)}</text>`
);
svg.push(`<text class="text" x="${cardX + 26}" y="211" fill="#0F172A" font-size="29" font-weight="750">${escapeXml(card.value)}</text>`);
});
for (let tick = 0; tick <= 4; tick += 1) {
const value = (axisMaximum / 4) * tick;
const tickY = y(value);
svg.push(`<line class="grid" x1="${plot.left}" y1="${tickY}" x2="${plot.right}" y2="${tickY}"${tick === 0 ? ' stroke="#CBD5E1"' : ""}/>`);
svg.push(`<text class="text axis-label" x="${plot.left - 18}" y="${tickY + 6}" text-anchor="end">${escapeXml(formatNumber(value))}</text>`);
}
rows.forEach((row, index) => {
const isLatest = index === latestIndex;
const isPeak = index === peakIndex && !isLatest;
const fill = isLatest ? "#0F172A" : isPeak ? "#8B5CF6" : null;
const extra = fill ? ` fill="${fill}"${isPeak ? ' fill-opacity="0.9"' : ""}` : ' class="bar"';
svg.push(
`<rect${extra} x="${(x(index) - barWidth / 2).toFixed(1)}" y="${y(row.count).toFixed(1)}" width="${barWidth.toFixed(1)}" height="${barHeight(row.count).toFixed(1)}" rx="${Math.min(4, barWidth / 2).toFixed(1)}"/>`
);
});
if (trendPoints.length > 0) {
svg.push(`<polyline points="${trendPoints.join(" ")}" fill="none" stroke="#F59E0B" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`);
const lastTrendPoint = trendPoints.at(-1).split(",");
svg.push(`<circle cx="${lastTrendPoint[0]}" cy="${lastTrendPoint[1]}" r="6" fill="#F59E0B" stroke="#FFFFFF" stroke-width="3"/>`);
}
const renderValueLabel = (index, value, color, stroke) => {
const centerX = x(index);
const labelY = Math.max(plot.top - 22, y(value) - 45);
svg.push(`<rect x="${(centerX - 27).toFixed(1)}" y="${labelY.toFixed(1)}" width="54" height="30" rx="10" fill="#FFFFFF" stroke="${stroke}"/>`);
svg.push(
`<text class="text" x="${centerX.toFixed(1)}" y="${(labelY + 20).toFixed(1)}" fill="${color}" font-size="14" font-weight="750" text-anchor="middle">${escapeXml(formatNumber(value))}</text>`
);
};
if (peakIndex !== latestIndex && Math.abs(x(peakIndex) - x(latestIndex)) >= 60) {
renderValueLabel(peakIndex, peak.count, "#7C3AED", "#DDD6FE");
}
renderValueLabel(latestIndex, latest.count, "#0F172A", "#CBD5E1");
const tickCount = Math.min(7, rows.length);
const tickIndexes = new Set();
for (let tick = 0; tick < tickCount; tick += 1) {
const index = tick === tickCount - 1 ? rows.length - 1 : Math.round((tick * rows.length) / Math.max(1, tickCount - 1));
tickIndexes.add(Math.min(rows.length - 1, index));
}
for (const index of tickIndexes) {
const tickX = x(index);
svg.push(`<line x1="${tickX.toFixed(1)}" y1="${plot.bottom}" x2="${tickX.toFixed(1)}" y2="${plot.bottom + 8}" stroke="#94A3B8"/>`);
svg.push(
`<text class="text axis-label" x="${tickX.toFixed(1)}" y="${plot.bottom + 34}" text-anchor="middle">${formatDisplayDate(rows[index].date)}</text>`
);
}
svg.push(`<rect x="126" y="814" width="16" height="16" rx="4" fill="#0F766E" fill-opacity="0.82"/>`);
svg.push(`<text class="text" x="151" y="828" fill="#475569" font-size="15">Commits pro Tag</text>`);
svg.push(`<line x1="322" y1="822" x2="350" y2="822" stroke="#F59E0B" stroke-width="4" stroke-linecap="round"/>`);
svg.push(`<text class="text" x="362" y="828" fill="#475569" font-size="15">7-Tage-Durchschnitt</text>`);
svg.push(
`<text class="text" x="1518" y="828" text-anchor="end" fill="#94A3B8" font-size="13">Alle von main erreichbaren Commits · Merge-Commits und Branch-Commits · Tage ohne Commits = 0</text>`
);
svg.push(`</svg>`);
return `${svg.join("\n")}\n`;
}
function main() {
const options = parseArgs(process.argv.slice(2));
const outputDir = path.resolve(REPO_ROOT, options.outputDir);
const history = collectCommitHistory(options.ref, { days: options.days, repoRoot: REPO_ROOT });
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, "commit-history.csv"), renderCsv(history, options.label));
fs.writeFileSync(path.join(outputDir, "commit-history.svg"), renderSvg(history, options.label));
const latest = history.rows.at(-1);
const total = history.rows.reduce((sum, row) => sum + row.count, 0);
console.log(`Commit history: ${history.days} calendar days from ${history.startDate} to ${history.endDate}`);
console.log(`Latest day: ${formatNumber(latest.count)} commits; window total: ${formatNumber(total)}`);
console.log(`Wrote ${path.relative(REPO_ROOT, outputDir)}/commit-history.{csv,svg}`);
}
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
}