v0.2.0: Git-native architecture (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import {
|
||||
ParsedCiCommit,
|
||||
CiMetadata,
|
||||
CommitDecision,
|
||||
} from "../types/commit-meta.js";
|
||||
import { parseCommitMessage } from "./commit-parser.js";
|
||||
import { PipelineStage } from "../types/pipeline.js";
|
||||
|
||||
export interface ProjectState {
|
||||
currentPhase: number;
|
||||
currentMilestone: string;
|
||||
currentStage: PipelineStage;
|
||||
phasesCompleted: number[];
|
||||
phaseBranches: BranchInfo[];
|
||||
milestoneBranches: string[];
|
||||
lastCommit: ParsedCiCommit | null;
|
||||
}
|
||||
|
||||
export interface BranchInfo {
|
||||
name: string;
|
||||
type: "phase" | "milestone" | "hotfix" | "other";
|
||||
phaseNumber?: number;
|
||||
milestone?: string;
|
||||
merged: boolean;
|
||||
}
|
||||
|
||||
export class GitContext {
|
||||
private projectPath: string;
|
||||
|
||||
constructor(projectPath: string) {
|
||||
this.projectPath = projectPath;
|
||||
}
|
||||
|
||||
private git(args: string): string {
|
||||
try {
|
||||
return execSync(`git ${args}`, {
|
||||
cwd: this.projectPath,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private gitLines(args: string): string[] {
|
||||
const result = this.git(args);
|
||||
return result ? result.split("\n").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
isGitRepo(): boolean {
|
||||
return this.git("rev-parse --is-inside-work-tree") === "true";
|
||||
}
|
||||
|
||||
getCurrentBranch(): string {
|
||||
return this.git("rev-parse --abbrev-ref HEAD");
|
||||
}
|
||||
|
||||
getRecentCommits(count: number = 20): ParsedCiCommit[] {
|
||||
const format = "%H%x00%s%x00%B%x01";
|
||||
const raw = this.git(`log --max-count=${count} --format="${format}"`);
|
||||
|
||||
if (!raw) return [];
|
||||
|
||||
const commits: ParsedCiCommit[] = [];
|
||||
const entries = raw.split("\x01").filter(Boolean);
|
||||
|
||||
for (const entry of entries) {
|
||||
const parts = entry.split("\x00");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const hash = parts[0].trim();
|
||||
const subject = parts[1].trim();
|
||||
const body = parts[2].trim();
|
||||
|
||||
const fullMessage = body || subject;
|
||||
commits.push(parseCommitMessage(hash, fullMessage));
|
||||
}
|
||||
|
||||
return commits;
|
||||
}
|
||||
|
||||
getLatestCiCommit(): ParsedCiCommit | null {
|
||||
const commits = this.getRecentCommits(1);
|
||||
return commits.length > 0 ? commits[0] : null;
|
||||
}
|
||||
|
||||
getBranches(): BranchInfo[] {
|
||||
const branches = this.gitLines("branch -a --format='%(refname:short)'");
|
||||
const mergedBranches = new Set(this.gitLines("branch --merged --format='%(refname:short)'"));
|
||||
|
||||
return branches.map((name) => {
|
||||
const cleanName = name.replace(/^remotes\/origin\//, "");
|
||||
const info: BranchInfo = {
|
||||
name: cleanName,
|
||||
type: "other",
|
||||
merged: mergedBranches.has(cleanName),
|
||||
};
|
||||
|
||||
const phaseMatch = cleanName.match(/^phase\/(\d+)-(.+)/);
|
||||
if (phaseMatch) {
|
||||
info.type = "phase";
|
||||
info.phaseNumber = parseInt(phaseMatch[1], 10);
|
||||
return info;
|
||||
}
|
||||
|
||||
const milestoneMatch = cleanName.match(/^milestone\/(.+)/);
|
||||
if (milestoneMatch) {
|
||||
info.type = "milestone";
|
||||
info.milestone = milestoneMatch[1];
|
||||
return info;
|
||||
}
|
||||
|
||||
if (cleanName.startsWith("hotfix/")) {
|
||||
info.type = "hotfix";
|
||||
}
|
||||
|
||||
return info;
|
||||
});
|
||||
}
|
||||
|
||||
getPhaseBranches(): BranchInfo[] {
|
||||
return this.getBranches().filter((b) => b.type === "phase");
|
||||
}
|
||||
|
||||
getMilestoneBranches(): BranchInfo[] {
|
||||
return this.getBranches().filter((b) => b.type === "milestone");
|
||||
}
|
||||
|
||||
reconstructState(): ProjectState {
|
||||
const latestCommit = this.getLatestCiCommit();
|
||||
const branches = this.getBranches();
|
||||
const phaseBranches = branches.filter((b) => b.type === "phase");
|
||||
const milestoneBranches = branches.filter((b) => b.type === "milestone");
|
||||
|
||||
const phasesCompleted = phaseBranches
|
||||
.filter((b) => b.merged)
|
||||
.map((b) => b.phaseNumber!)
|
||||
.filter(Boolean);
|
||||
|
||||
let currentPhase = 0;
|
||||
let currentMilestone = "";
|
||||
let currentStage: PipelineStage = "specify";
|
||||
|
||||
if (latestCommit?.ci) {
|
||||
currentPhase = latestCommit.ci.phase;
|
||||
currentMilestone = latestCommit.ci.milestone;
|
||||
currentStage = latestCommit.ci.status;
|
||||
}
|
||||
|
||||
if (!currentMilestone && milestoneBranches.length > 0) {
|
||||
const activeMilestone = milestoneBranches.find((b) => !b.merged);
|
||||
if (activeMilestone) currentMilestone = activeMilestone.milestone || "";
|
||||
}
|
||||
|
||||
return {
|
||||
currentPhase,
|
||||
currentMilestone,
|
||||
currentStage,
|
||||
phasesCompleted,
|
||||
phaseBranches,
|
||||
milestoneBranches: milestoneBranches.map((b) => b.name),
|
||||
lastCommit: latestCommit,
|
||||
};
|
||||
}
|
||||
|
||||
getDecisions(phase?: number): CommitDecision[] {
|
||||
const grepArg = phase !== undefined ? `--grep="phase: ${phase}"` : '--grep="decisions:"';
|
||||
const raw = this.git(`log --all ${grepArg} --format="%B%x01"`);
|
||||
|
||||
if (!raw) return [];
|
||||
|
||||
const decisions: CommitDecision[] = [];
|
||||
const entries = raw.split("\x01").filter(Boolean);
|
||||
|
||||
for (const entry of entries) {
|
||||
const commits = this.getRecentCommits(50);
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.decisions) {
|
||||
if (phase === undefined || commit.ci.phase === phase) {
|
||||
decisions.push(...commit.ci.decisions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return decisions;
|
||||
}
|
||||
|
||||
getDecisionsFromCommits(commits: ParsedCiCommit[], phase?: number): CommitDecision[] {
|
||||
const decisions: CommitDecision[] = [];
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.decisions) {
|
||||
if (phase === undefined || commit.ci.phase === phase) {
|
||||
decisions.push(...commit.ci.decisions);
|
||||
}
|
||||
}
|
||||
}
|
||||
return decisions;
|
||||
}
|
||||
|
||||
getLessons(phase?: number): string[] {
|
||||
const commits = this.getRecentCommits(100);
|
||||
const lessons: string[] = [];
|
||||
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.lessons) {
|
||||
if (phase === undefined || commit.ci.phase === phase) {
|
||||
lessons.push(...commit.ci.lessons);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lessons;
|
||||
}
|
||||
|
||||
getCompounds(category?: string): Array<{
|
||||
category: string;
|
||||
problem: string;
|
||||
solution: string;
|
||||
phase: number;
|
||||
}> {
|
||||
const commits = this.getRecentCommits(100);
|
||||
const compounds: Array<{ category: string; problem: string; solution: string; phase: number }> = [];
|
||||
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.compound) {
|
||||
if (!category || commit.ci.compound.category === category) {
|
||||
compounds.push({
|
||||
...commit.ci.compound,
|
||||
phase: commit.ci.phase,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return compounds;
|
||||
}
|
||||
|
||||
getEscalations(): Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
resolution: string;
|
||||
phase: number;
|
||||
}> {
|
||||
const commits = this.getRecentCommits(100);
|
||||
const escalations: Array<{ id: string; type: string; description: string; resolution: string; phase: number }> = [];
|
||||
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.escalations) {
|
||||
for (const esc of commit.ci.escalations) {
|
||||
escalations.push({ ...esc, phase: commit.ci.phase });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return escalations;
|
||||
}
|
||||
|
||||
getRequirementsCoverage(): { covered: string[]; partial: string[] } {
|
||||
const commits = this.getRecentCommits(100);
|
||||
const covered = new Set<string>();
|
||||
const partial = new Set<string>();
|
||||
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.requirements) {
|
||||
for (const req of commit.ci.requirements.covered) covered.add(req);
|
||||
for (const req of commit.ci.requirements.partial) partial.add(req);
|
||||
}
|
||||
}
|
||||
|
||||
for (const req of covered) {
|
||||
partial.delete(req);
|
||||
}
|
||||
|
||||
return {
|
||||
covered: [...covered].sort(),
|
||||
partial: [...partial].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
getCommitsForPhase(phase: number): ParsedCiCommit[] {
|
||||
const commits = this.getRecentCommits(200);
|
||||
return commits.filter(
|
||||
(c) => c.scope === `P${String(phase).padStart(2, "0")}` || c.ci?.phase === phase
|
||||
);
|
||||
}
|
||||
|
||||
getCommitsForBranch(branch: string): ParsedCiCommit[] {
|
||||
const format = "%H%x00%s%x00%B%x01";
|
||||
const raw = this.git(`log ${branch} --max-count=100 --format="${format}"`);
|
||||
|
||||
if (!raw) return [];
|
||||
|
||||
const commits: ParsedCiCommit[] = [];
|
||||
const entries = raw.split("\x01").filter(Boolean);
|
||||
|
||||
for (const entry of entries) {
|
||||
const parts = entry.split("\x00");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const hash = parts[0].trim();
|
||||
const subject = parts[1].trim();
|
||||
const body = parts[2].trim();
|
||||
const fullMessage = body || subject;
|
||||
|
||||
commits.push(parseCommitMessage(hash, fullMessage));
|
||||
}
|
||||
|
||||
return commits;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user