f478088797
---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---
345 lines
9.3 KiB
TypeScript
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";
|
|
}
|
|
} |