export type Phase = "red" | "green" | "refactor" | "spike" | "init" | "untagged"; export interface ParsedCommit { phase: Phase; step: string | null; subject: string; } const PHASE_RE = /^(red|green|refactor|spike)(?:\(([a-z][a-z0-9-]*)\))?:\s*(.*)$/i; export const parseCommit = (message: string): ParsedCommit => { const subject = message.split("\n")[0] ?? ""; const m = subject.match(PHASE_RE); if (m) { return { phase: m[1]!.toLowerCase() as Phase, step: m[2] ?? null, subject: m[3] ?? "", }; } if (/^Initial commit$/i.test(subject)) { return { phase: "init", step: null, subject }; } return { phase: "untagged", step: null, subject }; }; export interface Progress { verifiedSteps: Set; redCount: number; greenCount: number; refactorCount: number; spikeCount: number; untaggedCount: number; } // A step counts as "verified" when its red commit is followed by a green // for the same step. Refactor and untagged commits are tallied separately // for the score breakdown but don't move verification. export const computeProgress = (commits: { commit: { message: string } }[]): Progress => { const pendingRed = new Set(); const verifiedSteps = new Set(); let redCount = 0; let greenCount = 0; let refactorCount = 0; let spikeCount = 0; let untaggedCount = 0; // Forgejo returns commits newest-first; walk oldest-first to get sequence. for (const c of [...commits].reverse()) { const p = parseCommit(c.commit.message); if (p.phase === "red") { redCount++; if (p.step) pendingRed.add(p.step); } else if (p.phase === "green") { greenCount++; if (p.step && pendingRed.has(p.step)) verifiedSteps.add(p.step); } else if (p.phase === "refactor") { refactorCount++; } else if (p.phase === "spike") { spikeCount++; } else if (p.phase === "untagged") { untaggedCount++; } } return { verifiedSteps, redCount, greenCount, refactorCount, spikeCount, untaggedCount }; };