feat(P03): multi-project support, NFR milestone versioning, phase context reset, install scripts (v0.3.0)
This commit is contained in:
@@ -44,6 +44,294 @@ describe("CiFiles", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectSlug", () => {
|
||||
it("defaults to empty string", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
expect(ciFiles.getProjectSlug()).toBe("");
|
||||
});
|
||||
|
||||
it("uses provided project slug", () => {
|
||||
const ciFiles = new CiFiles(dir, "task-api");
|
||||
expect(ciFiles.getProjectSlug()).toBe("task-api");
|
||||
});
|
||||
|
||||
it("setProjectSlug updates slug", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.setProjectSlug("auth-svc");
|
||||
expect(ciFiles.getProjectSlug()).toBe("auth-svc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-project support", () => {
|
||||
it("isMultiProject returns false when not initialized", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
expect(ciFiles.isMultiProject()).toBe(false);
|
||||
});
|
||||
|
||||
it("isMultiProject returns false for single-project config", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
|
||||
projects: [{ slug: "default", name: "Default" }],
|
||||
active_project: "default",
|
||||
}));
|
||||
expect(ciFiles.isMultiProject()).toBe(true);
|
||||
});
|
||||
|
||||
it("isMultiProject returns false for config without projects array", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({}));
|
||||
expect(ciFiles.isMultiProject()).toBe(false);
|
||||
});
|
||||
|
||||
it("addProject adds a project to config", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
|
||||
projects: [],
|
||||
active_project: "",
|
||||
}));
|
||||
|
||||
ciFiles.addProject("task-api", "Task API", true);
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8"));
|
||||
expect(config.projects).toHaveLength(1);
|
||||
expect(config.projects[0].slug).toBe("task-api");
|
||||
expect(config.active_project).toBe("task-api");
|
||||
});
|
||||
|
||||
it("addProject does not duplicate existing project", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
|
||||
projects: [{ slug: "task-api", name: "Task API" }],
|
||||
active_project: "task-api",
|
||||
}));
|
||||
|
||||
ciFiles.addProject("task-api", "Task API V2");
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8"));
|
||||
expect(config.projects).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("addProject creates project subdirectory", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
|
||||
projects: [],
|
||||
active_project: "",
|
||||
}));
|
||||
|
||||
ciFiles.addProject("task-api", "Task API", true);
|
||||
|
||||
expect(fs.existsSync(path.join(dir, ".ci", "task-api"))).toBe(true);
|
||||
});
|
||||
|
||||
it("getActiveProject returns from config", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
|
||||
projects: [{ slug: "task-api", name: "Task API", default: true }],
|
||||
active_project: "task-api",
|
||||
}));
|
||||
|
||||
expect(ciFiles.getActiveProject()).toBe("task-api");
|
||||
});
|
||||
|
||||
it("setActiveProject updates config", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
|
||||
projects: [
|
||||
{ slug: "task-api", name: "Task API" },
|
||||
{ slug: "auth-svc", name: "Auth Service" },
|
||||
],
|
||||
active_project: "task-api",
|
||||
}));
|
||||
|
||||
ciFiles.setActiveProject("auth-svc");
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8"));
|
||||
expect(config.active_project).toBe("auth-svc");
|
||||
});
|
||||
|
||||
it("listProjects returns projects from config", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
|
||||
projects: [
|
||||
{ slug: "task-api", name: "Task API", default: true },
|
||||
{ slug: "auth-svc", name: "Auth Service" },
|
||||
],
|
||||
active_project: "task-api",
|
||||
}));
|
||||
|
||||
const projects = ciFiles.listProjects();
|
||||
expect(projects).toHaveLength(2);
|
||||
expect(projects[0].slug).toBe("task-api");
|
||||
expect(projects[1].slug).toBe("auth-svc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsMigration", () => {
|
||||
it("returns false when not initialized", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
expect(ciFiles.needsMigration()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when already multi-project", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
|
||||
projects: [{ slug: "default", name: "Default" }],
|
||||
}));
|
||||
fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test");
|
||||
expect(ciFiles.needsMigration()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when flat files exist without subdirs or multi-project config", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({}));
|
||||
fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test");
|
||||
expect(ciFiles.needsMigration()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when flat files exist but subdirs also exist", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({}));
|
||||
fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test");
|
||||
fs.mkdirSync(path.join(dir, ".ci", "task-api"));
|
||||
fs.writeFileSync(path.join(dir, ".ci", "task-api", "PROJECT.md"), "# Task API");
|
||||
expect(ciFiles.needsMigration()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateFlatToProject", () => {
|
||||
it("moves flat files to project subdirectory", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({}));
|
||||
fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test Project");
|
||||
fs.writeFileSync(path.join(dir, ".ci", "ARCHITECTURE.md"), "# Architecture");
|
||||
fs.writeFileSync(path.join(dir, ".ci", "ROADMAP.md"), "# Roadmap");
|
||||
fs.writeFileSync(path.join(dir, ".ci", "REQUIREMENTS.md"), "# Requirements");
|
||||
|
||||
ciFiles.migrateFlatToProject("my-app");
|
||||
|
||||
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "PROJECT.md"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "ARCHITECTURE.md"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "ROADMAP.md"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "REQUIREMENTS.md"))).toBe(true);
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8"));
|
||||
expect(config.projects).toHaveLength(1);
|
||||
expect(config.active_project).toBe("my-app");
|
||||
});
|
||||
|
||||
it("does not migrate when not needed", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
|
||||
projects: [{ slug: "existing", name: "Existing" }],
|
||||
}));
|
||||
|
||||
ciFiles.migrateFlatToProject("new-proj");
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8"));
|
||||
expect(config.projects).toHaveLength(1);
|
||||
expect(config.projects[0].slug).toBe("existing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNfrMilestone", () => {
|
||||
it("returns true when no roadmap exists", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
expect(ciFiles.isNfrMilestone()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when phases are all NFR types", () => {
|
||||
const ciFiles = new CiFiles(dir, "nfr-proj");
|
||||
ciFiles.ensureProjectDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
|
||||
projects: [{ slug: "nfr-proj", name: "NFR Project", default: true }],
|
||||
active_project: "nfr-proj",
|
||||
}));
|
||||
const roadmap: RoadmapMd = {
|
||||
overview: "NFR-only",
|
||||
phases: [
|
||||
{ number: 1, name: "test-coverage", description: "Add tests", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
|
||||
{ number: 2, name: "refactor-api", description: "Refactor", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] },
|
||||
],
|
||||
};
|
||||
ciFiles.writeRoadmapMd(roadmap);
|
||||
expect(ciFiles.isNfrMilestone()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when phases include feature work", () => {
|
||||
const ciFiles = new CiFiles(dir, "feat-proj");
|
||||
ciFiles.ensureProjectDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
|
||||
projects: [{ slug: "feat-proj", name: "Feature Project", default: true }],
|
||||
active_project: "feat-proj",
|
||||
}));
|
||||
const roadmap: RoadmapMd = {
|
||||
overview: "mixed",
|
||||
phases: [
|
||||
{ number: 1, name: "authentication", description: "Auth feature", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
|
||||
{ number: 2, name: "test-coverage", description: "Add tests", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] },
|
||||
],
|
||||
};
|
||||
ciFiles.writeRoadmapMd(roadmap);
|
||||
expect(ciFiles.isNfrMilestone()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-project file paths", () => {
|
||||
it("writes PROJECT.md to project subdirectory when slug is set", () => {
|
||||
const ciFiles = new CiFiles(dir, "my-app");
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
|
||||
projects: [{ slug: "my-app", name: "My App", default: true }],
|
||||
active_project: "my-app",
|
||||
}));
|
||||
|
||||
const project: ProjectMd = {
|
||||
name: "My App",
|
||||
coreValue: "Build something cool",
|
||||
requirements: { validated: [], active: [], outOfScope: [] },
|
||||
constraints: [],
|
||||
context: "Test context",
|
||||
keyDecisions: [],
|
||||
};
|
||||
|
||||
ciFiles.writeProjectMd(project, "initial");
|
||||
|
||||
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "PROJECT.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("writes PROJECT.md to .ci root when no slug is set", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({}));
|
||||
|
||||
const project: ProjectMd = {
|
||||
name: "Default App",
|
||||
coreValue: "Build something",
|
||||
requirements: { validated: [], active: [], outOfScope: [] },
|
||||
constraints: [],
|
||||
context: "Test context",
|
||||
keyDecisions: [],
|
||||
};
|
||||
|
||||
ciFiles.writeProjectMd(project, "initial");
|
||||
|
||||
expect(fs.existsSync(path.join(dir, ".ci", "PROJECT.md"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PROJECT.md", () => {
|
||||
const project: ProjectMd = {
|
||||
name: "Task API",
|
||||
|
||||
+244
-18
@@ -64,33 +64,204 @@ export interface ArchitectureMd {
|
||||
buildOrder: string[];
|
||||
}
|
||||
|
||||
export interface ProjectEntry {
|
||||
slug: string;
|
||||
name: string;
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
export class CiFiles {
|
||||
private projectPath: string;
|
||||
private projectSlug: string;
|
||||
|
||||
constructor(projectPath: string) {
|
||||
constructor(projectPath: string, projectSlug?: string) {
|
||||
this.projectPath = projectPath;
|
||||
this.projectSlug = projectSlug || "";
|
||||
}
|
||||
|
||||
private get ciDir(): string {
|
||||
return path.join(this.projectPath, CI_DIR);
|
||||
}
|
||||
|
||||
private get projectDir(): string {
|
||||
if (this.projectSlug) {
|
||||
return path.join(this.ciDir, this.projectSlug);
|
||||
}
|
||||
return this.ciDir;
|
||||
}
|
||||
|
||||
setProjectSlug(slug: string): void {
|
||||
this.projectSlug = slug;
|
||||
}
|
||||
|
||||
getProjectSlug(): string {
|
||||
return this.projectSlug;
|
||||
}
|
||||
|
||||
ensureCIDir(): void {
|
||||
ensureDir(this.ciDir);
|
||||
}
|
||||
|
||||
ensureProjectDir(): void {
|
||||
this.ensureCIDir();
|
||||
if (this.projectSlug) {
|
||||
ensureDir(this.projectDir);
|
||||
}
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return fileExists(path.join(this.ciDir, "config.json"));
|
||||
}
|
||||
|
||||
isMultiProject(): boolean {
|
||||
if (!this.isInitialized()) return false;
|
||||
const config = this.readConfigJson();
|
||||
const projects = config?.projects;
|
||||
return Array.isArray(projects) && (projects as unknown[]).length > 0;
|
||||
}
|
||||
|
||||
listProjects(): ProjectEntry[] {
|
||||
if (!this.isInitialized()) return [];
|
||||
|
||||
const config = this.readConfigJson();
|
||||
if (Array.isArray(config?.projects) && config.projects.length > 0) {
|
||||
return config.projects;
|
||||
}
|
||||
|
||||
const subdirs = this.getProjectSubdirectories();
|
||||
if (subdirs.length > 0) {
|
||||
return subdirs.map((slug) => {
|
||||
const projMd = this.readProjectMdForSlug(slug);
|
||||
return {
|
||||
slug,
|
||||
name: projMd?.name || slug,
|
||||
default: subdirs.length === 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [{ slug: "default", name: "Default Project", default: true }];
|
||||
}
|
||||
|
||||
getActiveProject(): string {
|
||||
if (!this.isInitialized()) return "";
|
||||
|
||||
const config = this.readConfigJson();
|
||||
if (config && typeof config.active_project === "string") return config.active_project;
|
||||
|
||||
const projects = this.listProjects();
|
||||
const defaultProject = projects.find((p) => p.default);
|
||||
if (defaultProject) return defaultProject.slug;
|
||||
|
||||
return projects.length > 0 ? projects[0].slug : "";
|
||||
}
|
||||
|
||||
setActiveProject(slug: string): void {
|
||||
this.ensureCIDir();
|
||||
const config = this.readConfigJson() || {};
|
||||
config.active_project = slug;
|
||||
this.writeConfigJson(config);
|
||||
}
|
||||
|
||||
addProject(slug: string, name: string, isDefault: boolean = false): void {
|
||||
this.ensureCIDir();
|
||||
const config = this.readConfigJson() || {};
|
||||
|
||||
if (!Array.isArray(config.projects)) {
|
||||
config.projects = [];
|
||||
}
|
||||
|
||||
if ((config.projects as unknown[]).some((p: unknown) => (p as ProjectEntry).slug === slug)) return;
|
||||
|
||||
(config.projects as ProjectEntry[]).push({ slug, name, default: isDefault });
|
||||
|
||||
if (isDefault || (config.projects as unknown[]).length === 1) {
|
||||
config.active_project = slug;
|
||||
}
|
||||
|
||||
this.writeConfigJson(config);
|
||||
ensureDir(path.join(this.ciDir, slug));
|
||||
}
|
||||
|
||||
needsMigration(): boolean {
|
||||
if (!this.isInitialized()) return false;
|
||||
if (this.isMultiProject()) return false;
|
||||
|
||||
const hasFlatFiles = fileExists(path.join(this.ciDir, "PROJECT.md"));
|
||||
const hasSubdirs = this.getProjectSubdirectories().length > 0;
|
||||
|
||||
return hasFlatFiles && !hasSubdirs;
|
||||
}
|
||||
|
||||
migrateFlatToProject(slug: string): void {
|
||||
if (!this.needsMigration()) return;
|
||||
|
||||
this.ensureCIDir();
|
||||
const projectDir = path.join(this.ciDir, slug);
|
||||
ensureDir(projectDir);
|
||||
|
||||
const filesToMove = ["PROJECT.md", "ARCHITECTURE.md", "ROADMAP.md", "REQUIREMENTS.md"];
|
||||
for (const file of filesToMove) {
|
||||
const src = path.join(this.ciDir, file);
|
||||
const dest = path.join(projectDir, file);
|
||||
if (fileExists(src) && !fileExists(dest)) {
|
||||
const content = readFile(src);
|
||||
if (content) {
|
||||
writeFile(dest, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = this.readConfigJson() || {};
|
||||
config.projects = [{ slug, name: slug, default: true }];
|
||||
config.active_project = slug;
|
||||
this.writeConfigJson(config);
|
||||
}
|
||||
|
||||
private getProjectSubdirectories(): string[] {
|
||||
if (!fs.existsSync(this.ciDir)) return [];
|
||||
|
||||
try {
|
||||
return fs.readdirSync(this.ciDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.filter((d) => {
|
||||
const projectFile = path.join(this.ciDir, d.name, "PROJECT.md");
|
||||
return fileExists(projectFile);
|
||||
})
|
||||
.map((d) => d.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private readConfigJson(): Record<string, unknown> | null {
|
||||
const content = readFile(path.join(this.ciDir, "config.json"));
|
||||
if (!content) return null;
|
||||
try {
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private writeConfigJson(config: Record<string, unknown>): void {
|
||||
writeFile(path.join(this.ciDir, "config.json"), JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
private readProjectMdForSlug(slug: string): ProjectMd | null {
|
||||
const content = readFile(path.join(this.ciDir, slug, "PROJECT.md"));
|
||||
if (!content) return null;
|
||||
return this.parseProjectMd(content);
|
||||
}
|
||||
|
||||
readProjectMd(): ProjectMd | null {
|
||||
const content = readFile(path.join(this.ciDir, "PROJECT.md"));
|
||||
const content = readFile(path.join(this.projectDir, "PROJECT.md"));
|
||||
if (!content) return null;
|
||||
return this.parseProjectMd(content);
|
||||
}
|
||||
|
||||
writeProjectMd(project: ProjectMd, reason: string): void {
|
||||
this.ensureCIDir();
|
||||
this.ensureProjectDir();
|
||||
const lines: string[] = [
|
||||
`# ${project.name}`,
|
||||
"",
|
||||
@@ -130,17 +301,17 @@ export class CiFiles {
|
||||
"",
|
||||
];
|
||||
|
||||
writeFile(path.join(this.ciDir, "PROJECT.md"), lines.join("\n"));
|
||||
writeFile(path.join(this.projectDir, "PROJECT.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
readRoadmapMd(): RoadmapMd | null {
|
||||
const content = readFile(path.join(this.ciDir, "ROADMAP.md"));
|
||||
const content = readFile(path.join(this.projectDir, "ROADMAP.md"));
|
||||
if (!content) return null;
|
||||
return this.parseRoadmapMd(content);
|
||||
}
|
||||
|
||||
writeRoadmapMd(roadmap: RoadmapMd): void {
|
||||
this.ensureCIDir();
|
||||
this.ensureProjectDir();
|
||||
const lines: string[] = [
|
||||
"# Roadmap",
|
||||
"",
|
||||
@@ -160,7 +331,7 @@ export class CiFiles {
|
||||
|
||||
for (const phase of roadmap.phases) {
|
||||
lines.push(`### Phase ${phase.number}: ${phase.name}`);
|
||||
lines.push(`**Goal**: ${phase.description}`);
|
||||
lines.push(`**Goal**.: ${phase.description}`);
|
||||
lines.push(`**Depends on**: ${phase.dependsOn.length > 0 ? phase.dependsOn.map((d) => `Phase ${d}`).join(", ") : "Nothing"}`);
|
||||
lines.push(`**Requirements**: ${phase.requirements.length > 0 ? phase.requirements.join(", ") : "None"}`);
|
||||
lines.push("**Success Criteria**:");
|
||||
@@ -171,17 +342,17 @@ export class CiFiles {
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
writeFile(path.join(this.ciDir, "ROADMAP.md"), lines.join("\n"));
|
||||
writeFile(path.join(this.projectDir, "ROADMAP.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
readRequirementsMd(): RequirementsMd | null {
|
||||
const content = readFile(path.join(this.ciDir, "REQUIREMENTS.md"));
|
||||
const content = readFile(path.join(this.projectDir, "REQUIREMENTS.md"));
|
||||
if (!content) return null;
|
||||
return this.parseRequirementsMd(content);
|
||||
}
|
||||
|
||||
writeRequirementsMd(requirements: RequirementsMd): void {
|
||||
this.ensureCIDir();
|
||||
this.ensureProjectDir();
|
||||
const lines: string[] = [
|
||||
"# Requirements",
|
||||
"",
|
||||
@@ -226,17 +397,17 @@ export class CiFiles {
|
||||
lines.push(`| ${t.requirement} | Phase ${t.phase} | ${t.status} |`);
|
||||
}
|
||||
|
||||
writeFile(path.join(this.ciDir, "REQUIREMENTS.md"), lines.join("\n"));
|
||||
writeFile(path.join(this.projectDir, "REQUIREMENTS.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
readArchitectureMd(): ArchitectureMd | null {
|
||||
const content = readFile(path.join(this.ciDir, "ARCHITECTURE.md"));
|
||||
const content = readFile(path.join(this.projectDir, "ARCHITECTURE.md"));
|
||||
if (!content) return null;
|
||||
return this.parseArchitectureMd(content);
|
||||
}
|
||||
|
||||
writeArchitectureMd(architecture: ArchitectureMd): void {
|
||||
this.ensureCIDir();
|
||||
this.ensureProjectDir();
|
||||
const lines: string[] = [
|
||||
"# Architecture",
|
||||
"",
|
||||
@@ -267,7 +438,7 @@ export class CiFiles {
|
||||
lines.push(`1. ${step}`);
|
||||
}
|
||||
|
||||
writeFile(path.join(this.ciDir, "ARCHITECTURE.md"), lines.join("\n"));
|
||||
writeFile(path.join(this.projectDir, "ARCHITECTURE.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
updateRequirementStatus(reqId: string, status: "pending" | "in_progress" | "complete" | "blocked"): void {
|
||||
@@ -296,6 +467,21 @@ export class CiFiles {
|
||||
this.writeRoadmapMd(roadmap);
|
||||
}
|
||||
|
||||
isNfrMilestone(): boolean {
|
||||
const roadmap = this.readRoadmapMd();
|
||||
if (!roadmap) return true;
|
||||
|
||||
const nfrTypes: string[] = ["fix", "chore", "docs", "perf", "refactor", "test"];
|
||||
for (const phase of roadmap.phases) {
|
||||
if (phase.status === "in_progress" || phase.status === "not_started") {
|
||||
const phaseName = phase.name.toLowerCase();
|
||||
const hasFeature = !nfrTypes.some((t) => phaseName.includes(t)) && !phaseName.includes("bug") && !phaseName.includes("tune") && !phaseName.includes("refresh");
|
||||
if (hasFeature) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private parseProjectMd(content: string): ProjectMd {
|
||||
return {
|
||||
name: this.extractSection(content, "# ") || "Unknown",
|
||||
@@ -312,10 +498,50 @@ export class CiFiles {
|
||||
}
|
||||
|
||||
private parseRoadmapMd(content: string): RoadmapMd {
|
||||
return {
|
||||
overview: this.extractSection(content, "## Overview") || "",
|
||||
phases: [],
|
||||
};
|
||||
const overview = this.extractSection(content, "## Overview") || "";
|
||||
|
||||
const phases: RoadmapMd["phases"] = [];
|
||||
const phaseRegex = /### Phase (\d+): (.+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = phaseRegex.exec(content)) !== null) {
|
||||
const number = parseInt(match[1], 10);
|
||||
const name = match[2].trim();
|
||||
const sectionStart = match.index + match[0].length;
|
||||
const nextPhase = content.indexOf("\n### Phase ", sectionStart);
|
||||
const nextH2 = content.indexOf("\n## ", sectionStart);
|
||||
const sectionEnd = Math.min(
|
||||
nextPhase >= 0 ? nextPhase : content.length,
|
||||
nextH2 >= 0 ? nextH2 : content.length
|
||||
);
|
||||
const section = content.slice(sectionStart, sectionEnd);
|
||||
|
||||
const goalMatch = section.match(/\*\*Goal\.?\*\*:\s*(.+)/);
|
||||
const statusMatch = section.match(/\*\*Status\*\*:\s*(.+)/);
|
||||
const reqMatch = section.match(/\*\*Requirements\*\*:\s*(.+)/);
|
||||
const depsMatch = section.match(/\*\*Depends on\*\*:\s*(.+)/);
|
||||
|
||||
const statusVal = statusMatch ? statusMatch[1].trim() : "not_started";
|
||||
const validStatuses = ["not_started", "in_progress", "complete", "deferred"] as const;
|
||||
|
||||
phases.push({
|
||||
number,
|
||||
name,
|
||||
description: goalMatch ? goalMatch[1].trim() : "",
|
||||
status: validStatuses.includes(statusVal as typeof validStatuses[number])
|
||||
? (statusVal as RoadmapMd["phases"][number]["status"])
|
||||
: "not_started",
|
||||
dependsOn: depsMatch && depsMatch[1].trim() !== "Nothing"
|
||||
? depsMatch[1].split(",").map((d: string) => parseInt(d.trim().replace(/Phase /g, ""), 10)).filter((n: number) => !isNaN(n))
|
||||
: [],
|
||||
requirements: reqMatch && reqMatch[1].trim() !== "None"
|
||||
? reqMatch[1].split(",").map((r: string) => r.trim()).filter(Boolean)
|
||||
: [],
|
||||
successCriteria: [],
|
||||
});
|
||||
}
|
||||
|
||||
return { overview, phases };
|
||||
}
|
||||
|
||||
private parseRequirementsMd(content: string): RequirementsMd {
|
||||
|
||||
@@ -13,6 +13,18 @@ describe("CommitBuilder", () => {
|
||||
expect(block).toContain("status: execute");
|
||||
});
|
||||
|
||||
it("builds ci block with project", () => {
|
||||
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute", project: "task-api" };
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).toContain("project: task-api");
|
||||
});
|
||||
|
||||
it("builds ci block without project when not set", () => {
|
||||
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute" };
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).not.toContain("project:");
|
||||
});
|
||||
|
||||
it("builds ci block with decisions", () => {
|
||||
const ci: CiMetadata = {
|
||||
phase: 1,
|
||||
@@ -172,6 +184,16 @@ describe("CommitBuilder", () => {
|
||||
expect(parsed.compound!.problem).toBe("Token replay attacks");
|
||||
expect(parsed.lessons).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("round-trips project field", () => {
|
||||
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute", project: "task-api" };
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
const fullMessage = `feat(task-api/P01): test\n\n---ci---\n${block}\n---/ci---`;
|
||||
const extracted = extractCiBlock(fullMessage)!;
|
||||
const parsed = parseCiBlock(extracted)!;
|
||||
|
||||
expect(parsed.project).toBe("task-api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInitCommit", () => {
|
||||
@@ -193,6 +215,19 @@ describe("CommitBuilder", () => {
|
||||
expect(msg).toContain("Build a REST API for task management");
|
||||
expect(msg).toContain("AUTH-01");
|
||||
});
|
||||
|
||||
it("builds an init commit message with project", () => {
|
||||
const msg = CommitBuilder.buildInitCommit({
|
||||
projectName: "task-api",
|
||||
phaseCount: 4,
|
||||
milestone: "v1.0",
|
||||
project: "task-api",
|
||||
specification: "Build a REST API",
|
||||
requirements: ["AUTH-01"],
|
||||
});
|
||||
|
||||
expect(msg).toContain("project: task-api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTaskCommit", () => {
|
||||
@@ -223,6 +258,22 @@ describe("CommitBuilder", () => {
|
||||
expect(msg).toContain("D-003");
|
||||
expect(msg).toContain("AUTH-01");
|
||||
});
|
||||
|
||||
it("builds a task commit with project prefix", () => {
|
||||
const msg = CommitBuilder.buildTaskCommit({
|
||||
type: "feat",
|
||||
phase: 1,
|
||||
milestone: "v1.0",
|
||||
project: "task-api",
|
||||
plan: "01-01",
|
||||
task: "01-01-02",
|
||||
subject: "registration endpoint",
|
||||
status: "execute",
|
||||
});
|
||||
|
||||
expect(msg).toContain("feat(task-api/P01-01-02):");
|
||||
expect(msg).toContain("project: task-api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPhaseCompletionCommit", () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface InitCommitInput {
|
||||
projectName: string;
|
||||
phaseCount: number;
|
||||
milestone: string;
|
||||
project?: string;
|
||||
specification: string;
|
||||
requirements?: string[];
|
||||
constraints?: string[];
|
||||
@@ -36,6 +37,7 @@ export interface TaskCommitInput {
|
||||
type: CommitType;
|
||||
phase: number;
|
||||
milestone: string;
|
||||
project?: string;
|
||||
plan: string;
|
||||
task: string;
|
||||
subject: string;
|
||||
@@ -95,6 +97,7 @@ export class CommitBuilder {
|
||||
lines.push(`phase: ${ci.phase}`);
|
||||
lines.push(`milestone: ${ci.milestone}`);
|
||||
|
||||
if (ci.project) lines.push(`project: ${ci.project}`);
|
||||
if (ci.plan) lines.push(`plan: ${ci.plan}`);
|
||||
if (ci.task) lines.push(`task: ${ci.task}`);
|
||||
|
||||
@@ -162,6 +165,7 @@ export class CommitBuilder {
|
||||
const ci: CiMetadata = {
|
||||
phase: 0,
|
||||
milestone: input.milestone,
|
||||
project: input.project,
|
||||
status: "specify",
|
||||
decisions: input.decisions,
|
||||
};
|
||||
@@ -193,6 +197,7 @@ export class CommitBuilder {
|
||||
const ci: CiMetadata = {
|
||||
phase: input.phase,
|
||||
milestone: input.milestone,
|
||||
project: input.project,
|
||||
plan: input.plan,
|
||||
task: input.task,
|
||||
status: input.status,
|
||||
@@ -204,6 +209,7 @@ export class CommitBuilder {
|
||||
phase: input.phase,
|
||||
plan: input.plan,
|
||||
task: input.task,
|
||||
project: input.project,
|
||||
isInit: false,
|
||||
isMilestone: false,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
CommitEscalation,
|
||||
CommitRequirements,
|
||||
CommitCompoundMeta,
|
||||
parseCommitScope,
|
||||
formatCommitScope,
|
||||
CommitScope,
|
||||
} from "../types/commit-meta.js";
|
||||
import {
|
||||
extractCiBlock,
|
||||
@@ -112,6 +115,19 @@ escalations:
|
||||
|
||||
All tests pass. Awaiting deploy approval.`;
|
||||
|
||||
const SAMPLE_PROJECT_COMMIT = `feat(task-api/P01-01-02): create registration endpoint
|
||||
|
||||
---ci---
|
||||
phase: 1
|
||||
milestone: v1.0
|
||||
project: task-api
|
||||
plan: 01-01
|
||||
task: 01-01-02
|
||||
status: execute
|
||||
---/ci---
|
||||
|
||||
Registration endpoint for task-api project.`;
|
||||
|
||||
describe("extractCiBlock", () => {
|
||||
it("extracts ---ci--- block from commit message", () => {
|
||||
const block = extractCiBlock(SAMPLE_INIT_COMMIT);
|
||||
@@ -192,6 +208,14 @@ describe("parseCiBlock", () => {
|
||||
expect(meta.escalations![0].resolution).toBe("pending");
|
||||
});
|
||||
|
||||
it("parses project field", () => {
|
||||
const block = extractCiBlock(SAMPLE_PROJECT_COMMIT)!;
|
||||
const meta = parseCiBlock(block)!;
|
||||
expect(meta.project).toBe("task-api");
|
||||
expect(meta.phase).toBe(1);
|
||||
expect(meta.plan).toBe("01-01");
|
||||
});
|
||||
|
||||
it("returns null for empty block", () => {
|
||||
const meta = parseCiBlock("");
|
||||
expect(meta).toBeNull();
|
||||
@@ -249,4 +273,85 @@ describe("parseCommitMessage", () => {
|
||||
const parsed = parseCommitMessage("pqr678", SAMPLE_TASK_COMMIT);
|
||||
expect(parsed.body).toContain("POST /auth/register validates email and password");
|
||||
});
|
||||
|
||||
it("parses commit with project-prefixed scope", () => {
|
||||
const parsed = parseCommitMessage("stu901", SAMPLE_PROJECT_COMMIT);
|
||||
expect(parsed.type).toBe("feat");
|
||||
expect(parsed.scope).toBe("task-api/P01-01-02");
|
||||
expect(parsed.ci!.project).toBe("task-api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCommitScope", () => {
|
||||
it("parses init scope", () => {
|
||||
const scope = parseCommitScope("init");
|
||||
expect(scope.isInit).toBe(true);
|
||||
expect(scope.phase).toBe(0);
|
||||
});
|
||||
|
||||
it("parses milestone scope", () => {
|
||||
const scope = parseCommitScope("milestone");
|
||||
expect(scope.isMilestone).toBe(true);
|
||||
expect(scope.phase).toBe(0);
|
||||
});
|
||||
|
||||
it("parses simple phase scope", () => {
|
||||
const scope = parseCommitScope("P01");
|
||||
expect(scope.phase).toBe(1);
|
||||
expect(scope.isInit).toBe(false);
|
||||
expect(scope.isMilestone).toBe(false);
|
||||
});
|
||||
|
||||
it("parses task scope with plan and task", () => {
|
||||
const scope = parseCommitScope("P01-01-02");
|
||||
expect(scope.phase).toBe(1);
|
||||
expect(scope.plan).toBe("01-01");
|
||||
expect(scope.task).toBe("01-01-02");
|
||||
});
|
||||
|
||||
it("parses project-prefixed scope", () => {
|
||||
const scope = parseCommitScope("task-api/P01-01-02");
|
||||
expect(scope.project).toBe("task-api");
|
||||
expect(scope.phase).toBe(1);
|
||||
expect(scope.plan).toBe("01-01");
|
||||
expect(scope.task).toBe("01-01-02");
|
||||
});
|
||||
|
||||
it("does not treat P-prefixed scope as project-prefixed", () => {
|
||||
const scope = parseCommitScope("P01-auth");
|
||||
expect(scope.project).toBeUndefined();
|
||||
expect(scope.phase).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCommitScope", () => {
|
||||
it("formats init scope", () => {
|
||||
const scope: CommitScope = { phase: 0, isInit: true, isMilestone: false };
|
||||
expect(formatCommitScope(scope)).toBe("init");
|
||||
});
|
||||
|
||||
it("formats milestone scope", () => {
|
||||
const scope: CommitScope = { phase: 0, isInit: false, isMilestone: true };
|
||||
expect(formatCommitScope(scope)).toBe("milestone");
|
||||
});
|
||||
|
||||
it("formats simple phase scope", () => {
|
||||
const scope: CommitScope = { phase: 1, isInit: false, isMilestone: false };
|
||||
expect(formatCommitScope(scope)).toBe("P01");
|
||||
});
|
||||
|
||||
it("formats task scope", () => {
|
||||
const scope: CommitScope = { phase: 1, plan: "01-01", task: "01-01-02", isInit: false, isMilestone: false };
|
||||
expect(formatCommitScope(scope)).toBe("P01-01-02");
|
||||
});
|
||||
|
||||
it("formats project-prefixed scope", () => {
|
||||
const scope: CommitScope = { phase: 1, project: "task-api", plan: "01-01", task: "01-01-02", isInit: false, isMilestone: false };
|
||||
expect(formatCommitScope(scope)).toBe("task-api/P01-01-02");
|
||||
});
|
||||
|
||||
it("formats project-prefixed phase scope without plan/task", () => {
|
||||
const scope: CommitScope = { phase: 2, project: "auth-svc", isInit: false, isMilestone: false };
|
||||
expect(formatCommitScope(scope)).toBe("auth-svc/P02");
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,9 @@ export function parseCiBlock(yaml: string): CiMetadata | null {
|
||||
const statusMatch = yaml.match(/^status:\s*(.+)$/m);
|
||||
if (statusMatch) result.status = statusMatch[1].trim() as CiMetadata["status"];
|
||||
|
||||
const projectMatch = yaml.match(/^project:\s*(.+)$/m);
|
||||
if (projectMatch) result.project = projectMatch[1].trim();
|
||||
|
||||
result.decisions = parseDecisionsFromYaml(yaml);
|
||||
result.escalations = parseEscalationsFromYaml(yaml);
|
||||
result.requirements = parseRequirementsFromYaml(yaml);
|
||||
|
||||
@@ -45,6 +45,37 @@ describe("CI Config", () => {
|
||||
expect(config.autonomy.max_revision_iterations).toBe(3);
|
||||
expect(config.autonomy.escalation_hooks).toEqual(["deploy", "delete_data", "merge_to_main"]);
|
||||
});
|
||||
|
||||
it("initializes with project slug", () => {
|
||||
const config = initCI(tempDir, undefined, "task-api", "Task API");
|
||||
expect(config.projects).toHaveLength(1);
|
||||
expect(config.projects[0].slug).toBe("task-api");
|
||||
expect(config.projects[0].name).toBe("Task API");
|
||||
expect(config.projects[0].default).toBe(true);
|
||||
expect(config.active_project).toBe("task-api");
|
||||
});
|
||||
|
||||
it("does not re-add existing project slug", () => {
|
||||
initCI(tempDir, undefined, "task-api", "Task API");
|
||||
const config = initCI(tempDir, undefined, "task-api", "Task API V2");
|
||||
expect(config.projects).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("defaults projects and active_project when no slug provided", () => {
|
||||
const config = initCI(tempDir);
|
||||
expect(config.projects).toEqual([]);
|
||||
expect(config.active_project).toBe("");
|
||||
});
|
||||
|
||||
it("preserves existing projects when adding new one", () => {
|
||||
const config1 = initCI(tempDir, undefined, "task-api", "Task API");
|
||||
const config2 = initCI(tempDir, {
|
||||
...config1,
|
||||
projects: [...config1.projects, { slug: "auth-svc", name: "Auth Service" }],
|
||||
}, "auth-svc", "Auth Service");
|
||||
expect(config2.projects).toHaveLength(2);
|
||||
expect(config2.active_project).toBe("auth-svc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfig", () => {
|
||||
@@ -68,6 +99,13 @@ describe("CI Config", () => {
|
||||
expect(config.git.auto_commit).toBe(true);
|
||||
expect(config.git.branching_strategy).toBe("phase");
|
||||
});
|
||||
|
||||
it("loads projects array from config", () => {
|
||||
initCI(tempDir, undefined, "task-api", "Task API");
|
||||
const config = loadConfig(tempDir);
|
||||
expect(config.projects).toHaveLength(1);
|
||||
expect(config.active_project).toBe("task-api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveConfig", () => {
|
||||
@@ -81,6 +119,19 @@ describe("CI Config", () => {
|
||||
const loaded = loadConfig(tempDir);
|
||||
expect(loaded.autonomy.level).toBe("guided");
|
||||
});
|
||||
|
||||
it("saves and reloads config with projects", () => {
|
||||
ensureCIDir(tempDir);
|
||||
const config = {
|
||||
...DEFAULT_CI_CONFIG,
|
||||
projects: [{ slug: "my-app", name: "My App", default: true }],
|
||||
active_project: "my-app",
|
||||
};
|
||||
saveConfig(tempDir, config);
|
||||
const loaded = loadConfig(tempDir);
|
||||
expect(loaded.projects).toHaveLength(1);
|
||||
expect(loaded.active_project).toBe("my-app");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCIInitialized", () => {
|
||||
|
||||
+14
-1
@@ -62,11 +62,24 @@ export function isCIInitialized(projectPath: string): boolean {
|
||||
return fs.existsSync(ciDir) && fs.existsSync(configPath);
|
||||
}
|
||||
|
||||
export function initCI(projectPath: string, config?: Partial<CIConfig>): CIConfig {
|
||||
export function initCI(projectPath: string, config?: Partial<CIConfig>, projectSlug?: string, projectName?: string): CIConfig {
|
||||
ensureCIDir(projectPath);
|
||||
|
||||
let projects = config?.projects || DEFAULT_CI_CONFIG.projects;
|
||||
let activeProject = config?.active_project || DEFAULT_CI_CONFIG.active_project;
|
||||
|
||||
if (projectSlug) {
|
||||
if (!projects.some((p) => p.slug === projectSlug)) {
|
||||
projects = [...projects, { slug: projectSlug, name: projectName || projectSlug, default: projects.length === 0 }];
|
||||
}
|
||||
activeProject = projectSlug;
|
||||
}
|
||||
|
||||
const fullConfig: CIConfig = {
|
||||
...DEFAULT_CI_CONFIG,
|
||||
...config,
|
||||
projects,
|
||||
active_project: activeProject,
|
||||
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, ...config?.autonomy },
|
||||
parallelization: {
|
||||
...DEFAULT_CI_CONFIG.parallelization,
|
||||
|
||||
@@ -53,6 +53,22 @@ describe("GitBranch", () => {
|
||||
|
||||
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", () => {
|
||||
@@ -71,6 +87,14 @@ describe("GitBranch", () => {
|
||||
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", () => {
|
||||
|
||||
+17
-4
@@ -30,10 +30,21 @@ export interface MilestoneBranchInfo {
|
||||
export class GitBranch {
|
||||
private projectPath: string;
|
||||
private gitContext: GitContext;
|
||||
private projectSlug?: string;
|
||||
|
||||
constructor(projectPath: string) {
|
||||
constructor(projectPath: string, projectSlug?: string) {
|
||||
this.projectPath = projectPath;
|
||||
this.gitContext = new GitContext(projectPath);
|
||||
this.projectSlug = projectSlug;
|
||||
this.gitContext = new GitContext(projectPath, projectSlug);
|
||||
}
|
||||
|
||||
setProjectSlug(slug: string | undefined): void {
|
||||
this.projectSlug = slug;
|
||||
this.gitContext.setProjectSlug(slug);
|
||||
}
|
||||
|
||||
private prefix(name: string): string {
|
||||
return this.projectSlug ? `${this.projectSlug}/${name}` : name;
|
||||
}
|
||||
|
||||
private git(args: string): string {
|
||||
@@ -58,7 +69,8 @@ export class GitBranch {
|
||||
createPhaseBranch(phaseNumber: number, phaseName: string): BranchCreateResult {
|
||||
const padded = String(phaseNumber).padStart(2, "0");
|
||||
const slug = this.slugify(phaseName);
|
||||
const branchName = `phase/${padded}-${slug}`;
|
||||
const baseName = `phase/${padded}-${slug}`;
|
||||
const branchName = this.prefix(baseName);
|
||||
|
||||
const existing = this.gitContext.getBranches().find((b) => b.name === branchName);
|
||||
if (existing) {
|
||||
@@ -75,7 +87,8 @@ export class GitBranch {
|
||||
|
||||
createMilestoneBranch(version: string, milestoneName: string): BranchCreateResult {
|
||||
const slug = this.slugify(milestoneName);
|
||||
const branchName = `milestone/${version}-${slug}`;
|
||||
const baseName = `milestone/${version}-${slug}`;
|
||||
const branchName = this.prefix(baseName);
|
||||
|
||||
const existing = this.gitContext.getBranches().find((b) => b.name === branchName);
|
||||
if (existing) {
|
||||
|
||||
@@ -56,6 +56,24 @@ describe("GitContext", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectSlug", () => {
|
||||
it("defaults to undefined", () => {
|
||||
const ctx = new GitContext(repoDir);
|
||||
expect(ctx.getProjectSlug()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts project slug in constructor", () => {
|
||||
const ctx = new GitContext(repoDir, "task-api");
|
||||
expect(ctx.getProjectSlug()).toBe("task-api");
|
||||
});
|
||||
|
||||
it("setProjectSlug updates slug", () => {
|
||||
const ctx = new GitContext(repoDir);
|
||||
ctx.setProjectSlug("auth-svc");
|
||||
expect(ctx.getProjectSlug()).toBe("auth-svc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecentCommits", () => {
|
||||
it("returns parsed commits with ci blocks", () => {
|
||||
commit(repoDir, `docs(init): initialize project
|
||||
@@ -187,5 +205,78 @@ lessons:
|
||||
expect(milestoneBranches.length).toBeGreaterThanOrEqual(1);
|
||||
expect(phaseBranches[0].phaseNumber).toBe(1);
|
||||
});
|
||||
|
||||
it("strips project prefix when projectSlug is set", () => {
|
||||
commit(repoDir, "initial");
|
||||
execSync("git checkout -b task-api/phase/01-auth", { cwd: repoDir, stdio: "pipe" });
|
||||
commit(repoDir, "feat: auth work");
|
||||
|
||||
const ctx = new GitContext(repoDir, "task-api");
|
||||
const branches = ctx.getBranches();
|
||||
|
||||
const phaseBranches = branches.filter((b) => b.type === "phase");
|
||||
expect(phaseBranches.length).toBeGreaterThanOrEqual(1);
|
||||
expect(phaseBranches[0].phaseNumber).toBe(1);
|
||||
expect(phaseBranches[0].name).toBe("task-api/phase/01-auth");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProjectFromCommit", () => {
|
||||
it("detects project from ci block project field", () => {
|
||||
commit(repoDir, `feat(P01): task work
|
||||
|
||||
---ci---
|
||||
phase: 1
|
||||
milestone: v1.0
|
||||
project: task-api
|
||||
status: execute
|
||||
---/ci---`);
|
||||
|
||||
const ctx = new GitContext(repoDir);
|
||||
expect(ctx.detectProjectFromCommit()).toBe("task-api");
|
||||
});
|
||||
|
||||
it("detects project from branch prefix", () => {
|
||||
commit(repoDir, "initial");
|
||||
execSync("git checkout -b auth-svc/phase/01-auth", { cwd: repoDir, stdio: "pipe" });
|
||||
commit(repoDir, "feat: auth work");
|
||||
|
||||
const ctx = new GitContext(repoDir);
|
||||
expect(ctx.detectProjectFromCommit()).toBe("auth-svc");
|
||||
});
|
||||
|
||||
it("returns null when no project detected", () => {
|
||||
commit(repoDir, "feat: some work");
|
||||
const ctx = new GitContext(repoDir);
|
||||
expect(ctx.detectProjectFromCommit()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNfrMilestone", () => {
|
||||
it("returns true when no feat commits exist", () => {
|
||||
commit(repoDir, `chore(P01): cleanup
|
||||
|
||||
---ci---
|
||||
phase: 1
|
||||
milestone: v0.1.1
|
||||
status: execute
|
||||
---/ci---`);
|
||||
|
||||
const ctx = new GitContext(repoDir);
|
||||
expect(ctx.isNfrMilestone()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when feat commits exist", () => {
|
||||
commit(repoDir, `feat(P01): add feature
|
||||
|
||||
---ci---
|
||||
phase: 1
|
||||
milestone: v1.0
|
||||
status: execute
|
||||
---/ci---`);
|
||||
|
||||
const ctx = new GitContext(repoDir);
|
||||
expect(ctx.isNfrMilestone()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+43
-3
@@ -27,9 +27,19 @@ export interface BranchInfo {
|
||||
|
||||
export class GitContext {
|
||||
private projectPath: string;
|
||||
private projectSlug?: string;
|
||||
|
||||
constructor(projectPath: 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 {
|
||||
@@ -98,14 +108,21 @@ export class GitContext {
|
||||
merged: mergedBranches.has(cleanName),
|
||||
};
|
||||
|
||||
const phaseMatch = cleanName.match(/^phase\/(\d+)-(.+)/);
|
||||
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 = cleanName.match(/^milestone\/(.+)/);
|
||||
const milestoneMatch = branchName.match(/^milestone\/(.+)/);
|
||||
if (milestoneMatch) {
|
||||
info.type = "milestone";
|
||||
info.milestone = milestoneMatch[1];
|
||||
@@ -311,4 +328,27 @@ export class GitContext {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
isNfrMilestone(): boolean {
|
||||
const commits = this.getRecentCommits(100);
|
||||
for (const commit of commits) {
|
||||
if (commit.type === "feat" && commit.ci) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user