322 lines
14 KiB
JavaScript
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|