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---
222 lines
8.0 KiB
TypeScript
222 lines
8.0 KiB
TypeScript
import { execSync } from "node:child_process";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import * as fs from "node:fs";
|
|
import { GitBranch } from "../core/git-branch.js";
|
|
|
|
function createTempRepo(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-branch-test-"));
|
|
execSync("git init", { cwd: dir, stdio: "pipe" });
|
|
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "pipe" });
|
|
execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
|
|
fs.writeFileSync(path.join(dir, "file.txt"), "initial", "utf-8");
|
|
execSync("git add . && git commit -m 'initial'", { cwd: dir, stdio: "pipe" });
|
|
return dir;
|
|
}
|
|
|
|
function cleanup(dir: string): void {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
describe("GitBranch", () => {
|
|
let repoDir: string;
|
|
|
|
beforeEach(() => {
|
|
repoDir = createTempRepo();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(repoDir);
|
|
});
|
|
|
|
describe("createPhaseBranch", () => {
|
|
it("creates a phase branch with correct naming", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
const result = gitBranch.createPhaseBranch(1, "authentication");
|
|
|
|
expect(result.created).toBe(true);
|
|
expect(result.name).toBe("phase/01-authentication");
|
|
});
|
|
|
|
it("detects already existing phase branch", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
gitBranch.createPhaseBranch(1, "authentication");
|
|
|
|
const result = gitBranch.createPhaseBranch(1, "authentication");
|
|
expect(result.alreadyExisted).toBe(true);
|
|
expect(result.created).toBe(false);
|
|
});
|
|
|
|
it("zero-pads phase numbers", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
const result = gitBranch.createPhaseBranch(3, "real-time-notifications");
|
|
|
|
expect(result.name).toBe("phase/03-real-time-notifications");
|
|
});
|
|
|
|
it("creates project-prefixed phase branch when projectSlug is set", () => {
|
|
const gitBranch = new GitBranch(repoDir, "task-api");
|
|
const result = gitBranch.createPhaseBranch(1, "authentication");
|
|
|
|
expect(result.created).toBe(true);
|
|
expect(result.name).toBe("task-api/phase/01-authentication");
|
|
});
|
|
|
|
it("updates project prefix after setProjectSlug", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
gitBranch.setProjectSlug("auth-svc");
|
|
const result = gitBranch.createPhaseBranch(2, "token-rotation");
|
|
|
|
expect(result.name).toBe("auth-svc/phase/02-token-rotation");
|
|
});
|
|
});
|
|
|
|
describe("createMilestoneBranch", () => {
|
|
it("creates a milestone branch with correct naming", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
const result = gitBranch.createMilestoneBranch("v1.0", "mvp");
|
|
|
|
expect(result.created).toBe(true);
|
|
expect(result.name).toBe("milestone/v1.0-mvp");
|
|
});
|
|
|
|
it("detects already existing milestone branch", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
gitBranch.createMilestoneBranch("v1.0", "mvp");
|
|
|
|
const result = gitBranch.createMilestoneBranch("v1.0", "mvp");
|
|
expect(result.alreadyExisted).toBe(true);
|
|
});
|
|
|
|
it("creates project-prefixed milestone branch when projectSlug is set", () => {
|
|
const gitBranch = new GitBranch(repoDir, "task-api");
|
|
const result = gitBranch.createMilestoneBranch("v1.0", "mvp");
|
|
|
|
expect(result.created).toBe(true);
|
|
expect(result.name).toBe("task-api/milestone/v1.0-mvp");
|
|
});
|
|
});
|
|
|
|
describe("listPhases", () => {
|
|
it("lists phase branches with status", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
gitBranch.createPhaseBranch(1, "auth");
|
|
gitBranch.createPhaseBranch(2, "tasks");
|
|
|
|
const phases = gitBranch.listPhases();
|
|
expect(phases.length).toBeGreaterThanOrEqual(2);
|
|
|
|
const phase1 = phases.find((p) => p.phaseNumber === 1);
|
|
expect(phase1).toBeDefined();
|
|
expect(phase1!.status).toBe("active");
|
|
});
|
|
});
|
|
|
|
describe("getPhaseStatus", () => {
|
|
it("returns status for existing phase branch", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
gitBranch.createPhaseBranch(1, "auth");
|
|
|
|
const status = gitBranch.getPhaseStatus(1);
|
|
expect(status).not.toBeNull();
|
|
expect(status!.phaseNumber).toBe(1);
|
|
expect(status!.status).toBe("active");
|
|
});
|
|
|
|
it("returns null for non-existent phase", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
const status = gitBranch.getPhaseStatus(99);
|
|
expect(status).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("slugify", () => {
|
|
it("creates correct branch slugs", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
const result = gitBranch.createPhaseBranch(1, "Real-Time Notifications & Stuff!");
|
|
expect(result.name).toMatch(/^phase\/01-/);
|
|
});
|
|
});
|
|
|
|
describe("validateMergeOrder", () => {
|
|
it("rejects phase → main when milestone branch exists", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
gitBranch.createMilestoneBranch("v0.5", "baseline");
|
|
gitBranch.createPhaseBranch(1, "auth");
|
|
execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" });
|
|
|
|
const result = gitBranch.validateMergeOrder("phase/01-auth", "main");
|
|
expect(result.valid).toBe(false);
|
|
expect(result.reason).toContain("milestone");
|
|
});
|
|
|
|
it("allows phase → milestone branch", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
gitBranch.createMilestoneBranch("v0.5", "baseline");
|
|
gitBranch.createPhaseBranch(1, "auth");
|
|
execSync(`git checkout milestone/v0.5-baseline`, { cwd: repoDir, stdio: "pipe" });
|
|
|
|
const result = gitBranch.validateMergeOrder("phase/01-auth", "milestone/v0.5-baseline");
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it("allows phase → main when no milestone branch exists", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
gitBranch.createPhaseBranch(1, "auth");
|
|
execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" });
|
|
|
|
const result = gitBranch.validateMergeOrder("phase/01-auth", "main");
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it("allows hotfix → main", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
gitBranch.createMilestoneBranch("v0.5", "baseline");
|
|
execSync(`git checkout -b hotfix/critical-fix main`, { cwd: repoDir, stdio: "pipe" });
|
|
fs.writeFileSync(path.join(repoDir, "fix.txt"), "fix");
|
|
execSync(`git add . && git commit -m "hotfix: critical fix"`, { cwd: repoDir, stdio: "pipe" });
|
|
execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" });
|
|
|
|
const result = gitBranch.validateMergeOrder("hotfix/critical-fix", "main");
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("computeMilestoneTag", () => {
|
|
it("computes next minor for feature milestone", () => {
|
|
execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" });
|
|
execSync(`git tag -a v0.5.2 -m "v0.5.2"`, { cwd: repoDir, stdio: "pipe" });
|
|
|
|
const gitBranch = new GitBranch(repoDir);
|
|
const tag = gitBranch.computeMilestoneTag("feature");
|
|
expect(tag).toBe("v0.6.0");
|
|
});
|
|
|
|
it("computes next major for major milestone", () => {
|
|
execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" });
|
|
|
|
const gitBranch = new GitBranch(repoDir);
|
|
const tag = gitBranch.computeMilestoneTag("major");
|
|
expect(tag).toBe("v1.0.0");
|
|
});
|
|
|
|
it("starts from v0.1.0 when no tags exist", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
const tag = gitBranch.computeMilestoneTag("feature");
|
|
expect(tag).toBe("v0.1.0");
|
|
});
|
|
});
|
|
|
|
describe("mergeMilestoneBranch", () => {
|
|
it("rejects merge when unmerged phase branches remain", () => {
|
|
const gitBranch = new GitBranch(repoDir);
|
|
gitBranch.createMilestoneBranch("v0.5", "baseline");
|
|
gitBranch.createPhaseBranch(1, "auth");
|
|
execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" });
|
|
|
|
const result = gitBranch.mergeMilestoneBranch("milestone/v0.5-baseline", "main", true);
|
|
expect(result.success).toBe(false);
|
|
expect(result.message).toContain("unmerged");
|
|
});
|
|
});
|
|
}); |