Files
ci/src/core/git-context.ts
T
Jon Chery f478088797 refactor(P06): rename milestone type schema-breaking → major, reinforce release flow
---ci---
phase: 6
milestone: v0.10
status: execute
decisions:
  - id: D-001
    decision: Rename MilestoneType schema-breaking to major for clarity
    rationale: Major better describes the semver impact (major version bump) and aligns with standard semver terminology
    confidence: 0.95
    alternatives: [schema-breaking, breaking, major-change]
  - id: D-002
    decision: Add autopilot rules, PR+QA gates, and merge validation to ship workflow
    rationale: Release flow was documented but not enforced in the workflow. Zero-HITL rules, branch hierarchy validation, and coreci packaging steps ensure consistent releases
    confidence: 0.90
    alternatives: [keep-as-documentation-only, add-to-AGENTS.md-only]
---/ci---
2026-06-01 15:29:43 +00:00

345 lines
9.3 KiB
TypeScript

import { execSync } from "node:child_process";
import {
ParsedCIAgentCommit,
CIAgentMetadata,
CommitDecision,
} from "../types/commit-meta.js";
import { parseCommitMessage } from "./commit-parser.js";
import { PipelineStage } from "../types/pipeline.js";
import { MilestoneType } from "../types/config.js";
export interface ProjectState {
currentPhase: number;
currentMilestone: string;
currentStage: PipelineStage;
phasesCompleted: number[];
phaseBranches: BranchInfo[];
milestoneBranches: string[];
lastCommit: ParsedCIAgentCommit | null;
}
export interface BranchInfo {
name: string;
type: "phase" | "milestone" | "hotfix" | "other";
phaseNumber?: number;
milestone?: string;
merged: boolean;
}
export class GitContext {
private projectPath: string;
private projectSlug?: string;
constructor(projectPath: string, projectSlug?: string) {
this.projectPath = projectPath;
this.projectSlug = projectSlug;
}
setProjectSlug(slug: string | undefined): void {
this.projectSlug = slug;
}
getProjectSlug(): string | undefined {
return this.projectSlug;
}
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): ParsedCIAgentCommit[] {
const format = "%H%x00%s%x00%B%x01";
const raw = this.git(`log --max-count=${count} --format="${format}"`);
if (!raw) return [];
const commits: ParsedCIAgentCommit[] = [];
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(): ParsedCIAgentCommit | 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),
};
let branchName = cleanName;
const projectPrefix = this.projectSlug ? `${this.projectSlug}/` : "";
if (projectPrefix && cleanName.startsWith(projectPrefix)) {
branchName = cleanName.slice(projectPrefix.length);
}
const phaseMatch = branchName.match(/^phase\/(\d+)-(.+)/);
if (phaseMatch) {
info.type = "phase";
info.phaseNumber = parseInt(phaseMatch[1], 10);
return info;
}
const milestoneMatch = branchName.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 commits = this.getRecentCommits(50);
return this.getDecisionsFromCommits(commits, phase);
}
getDecisionsFromCommits(commits: ParsedCIAgentCommit[], 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): ParsedCIAgentCommit[] {
const commits = this.getRecentCommits(200);
return commits.filter(
(c) => c.scope === `P${String(phase).padStart(2, "0")}` || c.ci?.phase === phase
);
}
getCommitsForBranch(branch: string): ParsedCIAgentCommit[] {
const format = "%H%x00%s%x00%B%x01";
const raw = this.git(`log ${branch} --max-count=100 --format="${format}"`);
if (!raw) return [];
const commits: ParsedCIAgentCommit[] = [];
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;
}
detectProjectFromCommit(): string | null {
const commit = this.getLatestCiCommit();
if (commit?.ci?.project) return commit.ci.project;
const branches = this.getBranches();
for (const branch of branches) {
const projectMatch = branch.name.match(/^([a-z0-9-]+)\/(?:phase|milestone)\//);
if (projectMatch) return projectMatch[1];
}
return null;
}
getMilestoneType(): MilestoneType {
const commits = this.getRecentCommits(100);
let hasAnyCiCommit = false;
for (const commit of commits) {
if (!commit.ci) continue;
hasAnyCiCommit = true;
if (commit.type === "feat") return "feature";
if (commit.type === "refactor" || commit.scope === "init") return "major";
}
if (!hasAnyCiCommit) return "nfr";
return "nfr";
}
isNfrMilestone(): boolean {
return this.getMilestoneType() === "nfr";
}
}