feat(P06): docs & hardening — AGENTS.md/README fixes, agent tests, Gitea tests, multi-project tests, version 0.7.0
---ci--- phase: 6 milestone: v0.7.0 plan: 06 task: P06-all status: execute ---/ci---
This commit is contained in:
@@ -2,7 +2,7 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
|
||||
export class DocWriterAgent extends BaseAgent {
|
||||
readonly name = "doc-writer";
|
||||
readonly description = "Autonomous documentation writer. No behavioral changes from Learnship.";
|
||||
readonly description = "Autonomous documentation writer.";
|
||||
readonly workflow = "execute";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { ExecutorAgent } from "../agents/executor.js";
|
||||
import { AgentContext } from "../agents/base.js";
|
||||
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
|
||||
import { emptyTokenUsage } from "../backends/types.js";
|
||||
|
||||
class MockBackend implements IntelligenceBackend {
|
||||
readonly name = "mock";
|
||||
readonly type = "llm" as const;
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
async execute(request: BackendRequest): Promise<BackendResult> {
|
||||
return {
|
||||
success: true,
|
||||
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
|
||||
artifacts: [],
|
||||
decisions: [],
|
||||
escalations: [],
|
||||
usage: emptyTokenUsage(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-executor-test-"));
|
||||
}
|
||||
|
||||
function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
|
||||
return {
|
||||
project_path: dir,
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "Build a REST API for task management",
|
||||
config_path: path.join(dir, ".ciagent", "config.json"),
|
||||
backend,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ExecutorAgent", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
it("returns honest failure without backend", async () => {
|
||||
const executor = new ExecutorAgent();
|
||||
const result = await executor.execute(makeContext(dir));
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("intelligence backend");
|
||||
});
|
||||
|
||||
it("delegates to backend when available", async () => {
|
||||
const mockBackend = new MockBackend();
|
||||
const executor = new ExecutorAgent();
|
||||
const result = await executor.execute(makeContext(dir, mockBackend));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain("Mock backend executed");
|
||||
});
|
||||
|
||||
it("has correct agent name", () => {
|
||||
const executor = new ExecutorAgent();
|
||||
expect(executor.name).toBe("executor");
|
||||
});
|
||||
|
||||
it("has correct workflow", () => {
|
||||
const executor = new ExecutorAgent();
|
||||
expect(executor.workflow).toBe("execute");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { PlannerAgent } from "../agents/planner.js";
|
||||
import { AgentContext } from "../agents/base.js";
|
||||
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
|
||||
import { Decision } from "../types/decisions.js";
|
||||
import { Escalation } from "../types/escalation.js";
|
||||
import { emptyTokenUsage } from "../backends/types.js";
|
||||
|
||||
class MockBackend implements IntelligenceBackend {
|
||||
readonly name = "mock";
|
||||
readonly type = "llm" as const;
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
async execute(request: BackendRequest): Promise<BackendResult> {
|
||||
return {
|
||||
success: true,
|
||||
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
|
||||
artifacts: [],
|
||||
decisions: [],
|
||||
escalations: [],
|
||||
usage: emptyTokenUsage(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-planner-test-"));
|
||||
}
|
||||
|
||||
function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
|
||||
return {
|
||||
project_path: dir,
|
||||
phase: 1,
|
||||
stage: "plan",
|
||||
specification: "Build a REST API for task management",
|
||||
config_path: path.join(dir, ".ciagent", "config.json"),
|
||||
backend,
|
||||
};
|
||||
}
|
||||
|
||||
function setupCIAgentDir(dir: string): void {
|
||||
const ciDir = path.join(dir, ".ciagent");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ciDir, "config.json"), "{}");
|
||||
}
|
||||
|
||||
function writeRequirementsMd(dir: string): void {
|
||||
const ciDir = path.join(dir, ".ciagent");
|
||||
const content = [
|
||||
"# Requirements",
|
||||
"",
|
||||
"## v1 Requirements",
|
||||
"",
|
||||
"### Core",
|
||||
"",
|
||||
"- [ ] **REQ-01**: User authentication",
|
||||
"- [ ] **REQ-02**: Task CRUD operations",
|
||||
"- [ ] **REQ-03**: Real-time notifications",
|
||||
"",
|
||||
"## Traceability",
|
||||
"",
|
||||
"| Requirement | Phase | Status |",
|
||||
"|-------------|-------|--------|",
|
||||
"| REQ-01 | Phase 1 | in_progress |",
|
||||
"| REQ-02 | Phase 1 | pending |",
|
||||
"| REQ-03 | Phase 1 | blocked |",
|
||||
].join("\n");
|
||||
fs.writeFileSync(path.join(ciDir, "REQUIREMENTS.md"), content);
|
||||
}
|
||||
|
||||
function writeRoadmapMd(dir: string): void {
|
||||
const ciDir = path.join(dir, ".ciagent");
|
||||
const content = [
|
||||
"# Roadmap",
|
||||
"",
|
||||
"## Overview",
|
||||
"",
|
||||
"Task management API roadmap",
|
||||
"",
|
||||
"## Phases",
|
||||
"",
|
||||
"- [ ] **Phase 1: Authentication** - Implement auth",
|
||||
"",
|
||||
"## Phase Details",
|
||||
"",
|
||||
"### Phase 1: Authentication",
|
||||
"**Goal**: Implement user authentication",
|
||||
"**Depends on**: Nothing",
|
||||
"**Requirements**: REQ-01, REQ-02",
|
||||
"**Success Criteria**:",
|
||||
"1. .ciagent/REQUIREMENTS.md exists",
|
||||
"**Status**: in_progress",
|
||||
].join("\n");
|
||||
fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), content);
|
||||
}
|
||||
|
||||
describe("PlannerAgent", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
it("returns honest failure without backend when no requirements or roadmap", async () => {
|
||||
setupCIAgentDir(dir);
|
||||
const planner = new PlannerAgent();
|
||||
const result = await planner.execute(makeContext(dir));
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("No requirements or roadmap");
|
||||
});
|
||||
|
||||
it("creates PLAN.md from REQUIREMENTS.md without backend", async () => {
|
||||
setupCIAgentDir(dir);
|
||||
writeRequirementsMd(dir);
|
||||
writeRoadmapMd(dir);
|
||||
|
||||
const planner = new PlannerAgent();
|
||||
const result = await planner.execute(makeContext(dir));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain("plan");
|
||||
expect(fs.existsSync(path.join(dir, ".ciagent", "PLAN.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("PLAN.md contains phase goal and tasks", async () => {
|
||||
setupCIAgentDir(dir);
|
||||
writeRequirementsMd(dir);
|
||||
writeRoadmapMd(dir);
|
||||
|
||||
const planner = new PlannerAgent();
|
||||
await planner.execute(makeContext(dir));
|
||||
|
||||
const planContent = fs.readFileSync(path.join(dir, ".ciagent", "PLAN.md"), "utf-8");
|
||||
expect(planContent).toContain("Phase 1 Plan");
|
||||
expect(planContent).toContain("Phase Goal");
|
||||
expect(planContent).toContain("Tasks");
|
||||
});
|
||||
|
||||
it("delegates to backend when available", async () => {
|
||||
setupCIAgentDir(dir);
|
||||
const mockBackend = new MockBackend();
|
||||
const planner = new PlannerAgent();
|
||||
const result = await planner.execute(makeContext(dir, mockBackend));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain("Mock backend executed");
|
||||
});
|
||||
|
||||
it("has correct agent name", () => {
|
||||
const planner = new PlannerAgent();
|
||||
expect(planner.name).toBe("planner");
|
||||
});
|
||||
|
||||
it("has correct workflow", () => {
|
||||
const planner = new PlannerAgent();
|
||||
expect(planner.workflow).toBe("plan");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { ResearcherAgent } from "../agents/researcher.js";
|
||||
import { AgentContext } from "../agents/base.js";
|
||||
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
|
||||
import { emptyTokenUsage } from "../backends/types.js";
|
||||
|
||||
class MockBackend implements IntelligenceBackend {
|
||||
readonly name = "mock";
|
||||
readonly type = "llm" as const;
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
async execute(request: BackendRequest): Promise<BackendResult> {
|
||||
return {
|
||||
success: true,
|
||||
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
|
||||
artifacts: [],
|
||||
decisions: [],
|
||||
escalations: [],
|
||||
usage: emptyTokenUsage(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-researcher-test-"));
|
||||
}
|
||||
|
||||
function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
|
||||
return {
|
||||
project_path: dir,
|
||||
phase: 1,
|
||||
stage: "research",
|
||||
specification: "Build a REST API for task management",
|
||||
config_path: path.join(dir, ".ciagent", "config.json"),
|
||||
backend,
|
||||
};
|
||||
}
|
||||
|
||||
function setupCIAgentDir(dir: string): void {
|
||||
const ciDir = path.join(dir, ".ciagent");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ciDir, "config.json"), '{"projects":[],"active_project":""}');
|
||||
}
|
||||
|
||||
function writeProjectMd(dir: string): void {
|
||||
const ciDir = path.join(dir, ".ciagent");
|
||||
const content = [
|
||||
"# Task API",
|
||||
"",
|
||||
"## What This Is",
|
||||
"",
|
||||
"A REST API for managing tasks",
|
||||
"",
|
||||
"## Requirements",
|
||||
"",
|
||||
"### Validated",
|
||||
"",
|
||||
"- ✓ User authentication",
|
||||
"",
|
||||
"### Active",
|
||||
"",
|
||||
"- [ ] Task CRUD",
|
||||
"",
|
||||
"### Out of Scope",
|
||||
"",
|
||||
"- Admin dashboard",
|
||||
"",
|
||||
"## Context",
|
||||
"",
|
||||
"Node.js project",
|
||||
"",
|
||||
"## Constraints",
|
||||
"",
|
||||
"- Must use Node.js",
|
||||
"",
|
||||
"## Key Decisions",
|
||||
"",
|
||||
"| Decision | Rationale | Outcome |",
|
||||
"|----------|-----------|---------|",
|
||||
].join("\n");
|
||||
fs.writeFileSync(path.join(ciDir, "PROJECT.md"), content);
|
||||
}
|
||||
|
||||
function writeArchitectureMd(dir: string): void {
|
||||
const ciDir = path.join(dir, ".ciagent");
|
||||
const content = [
|
||||
"# Architecture",
|
||||
"",
|
||||
"## Overview",
|
||||
"",
|
||||
"Task management system architecture",
|
||||
"",
|
||||
"## Components",
|
||||
"",
|
||||
"### Core",
|
||||
"- **Description**: Core module",
|
||||
"- **Boundaries**: src/core/ — internal module",
|
||||
"- **Depends on**: None",
|
||||
"",
|
||||
"## Data Flow",
|
||||
"",
|
||||
"Request → Handler → Service → Database",
|
||||
"",
|
||||
"## Build Order",
|
||||
"",
|
||||
"1. Build core module",
|
||||
].join("\n");
|
||||
fs.writeFileSync(path.join(ciDir, "ARCHITECTURE.md"), content);
|
||||
}
|
||||
|
||||
function setupSourceDir(dir: string): void {
|
||||
const srcDir = path.join(dir, "src");
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(srcDir, "core"), { recursive: true });
|
||||
fs.mkdirSync(path.join(srcDir, "agents"), { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, "core", "index.ts"), "export {};\n");
|
||||
fs.writeFileSync(path.join(srcDir, "agents", "base.ts"), "export {};\n");
|
||||
}
|
||||
|
||||
describe("ResearcherAgent", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
it("reads .ciagent/ files without backend", async () => {
|
||||
setupCIAgentDir(dir);
|
||||
writeProjectMd(dir);
|
||||
writeArchitectureMd(dir);
|
||||
|
||||
const researcher = new ResearcherAgent();
|
||||
const result = await researcher.execute(makeContext(dir));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain("findingsCount");
|
||||
});
|
||||
|
||||
it("only modifies .ciagent/ files", async () => {
|
||||
setupCIAgentDir(dir);
|
||||
writeProjectMd(dir);
|
||||
writeArchitectureMd(dir);
|
||||
setupSourceDir(dir);
|
||||
|
||||
const srcDir = path.join(dir, "src");
|
||||
const filesBefore = new Set<string>();
|
||||
function collectFiles(d: string): void {
|
||||
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const full = path.join(d, entry.name);
|
||||
if (entry.isDirectory() && entry.name !== "node_modules") {
|
||||
collectFiles(full);
|
||||
} else {
|
||||
filesBefore.add(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
collectFiles(srcDir);
|
||||
|
||||
const researcher = new ResearcherAgent();
|
||||
await researcher.execute(makeContext(dir));
|
||||
|
||||
collectFiles(srcDir);
|
||||
for (const f of filesBefore) {
|
||||
expect(fs.existsSync(f)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("updates ARCHITECTURE.md from source scan", async () => {
|
||||
setupCIAgentDir(dir);
|
||||
writeProjectMd(dir);
|
||||
setupSourceDir(dir);
|
||||
|
||||
const researcher = new ResearcherAgent();
|
||||
const result = await researcher.execute(makeContext(dir));
|
||||
|
||||
if (result.success) {
|
||||
const parsed = JSON.parse(result.output);
|
||||
expect(parsed.filesUpdated).toContain(".ciagent/ARCHITECTURE.md");
|
||||
}
|
||||
});
|
||||
|
||||
it("delegates to backend when available", async () => {
|
||||
setupCIAgentDir(dir);
|
||||
const mockBackend = new MockBackend();
|
||||
const researcher = new ResearcherAgent();
|
||||
const result = await researcher.execute(makeContext(dir, mockBackend));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain("Mock backend executed");
|
||||
});
|
||||
|
||||
it("has correct agent name", () => {
|
||||
const researcher = new ResearcherAgent();
|
||||
expect(researcher.name).toBe("researcher");
|
||||
});
|
||||
|
||||
it("has correct workflow", () => {
|
||||
const researcher = new ResearcherAgent();
|
||||
expect(researcher.workflow).toBe("research");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { TesterAgent } from "../agents/tester.js";
|
||||
import { AgentContext } from "../agents/base.js";
|
||||
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
|
||||
import { emptyTokenUsage } from "../backends/types.js";
|
||||
|
||||
class MockBackend implements IntelligenceBackend {
|
||||
readonly name = "mock";
|
||||
readonly type = "llm" as const;
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
async execute(request: BackendRequest): Promise<BackendResult> {
|
||||
return {
|
||||
success: true,
|
||||
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
|
||||
artifacts: [],
|
||||
decisions: [],
|
||||
escalations: [],
|
||||
usage: emptyTokenUsage(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-tester-test-"));
|
||||
}
|
||||
|
||||
function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
|
||||
return {
|
||||
project_path: dir,
|
||||
phase: 1,
|
||||
stage: "test",
|
||||
specification: "Build a REST API for task management",
|
||||
config_path: path.join(dir, ".ciagent", "config.json"),
|
||||
backend,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TesterAgent", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
it("detects test files when src directory exists", async () => {
|
||||
const srcDir = path.join(dir, "src");
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, "app.integration.test.ts"), "test('integration', () => {});\n");
|
||||
|
||||
const tester = new TesterAgent();
|
||||
const result = await tester.execute(makeContext(dir));
|
||||
expect(result.success).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not write test files", async () => {
|
||||
const srcDir = path.join(dir, "src");
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, "app.test.ts"), "test('unit', () => {});\n");
|
||||
|
||||
const testFilesBefore = fs.readdirSync(srcDir).filter(f => f.endsWith(".test.ts"));
|
||||
const tester = new TesterAgent();
|
||||
await tester.execute(makeContext(dir));
|
||||
const testFilesAfter = fs.readdirSync(srcDir).filter(f => f.endsWith(".test.ts"));
|
||||
expect(testFilesAfter.length).toBe(testFilesBefore.length);
|
||||
});
|
||||
|
||||
it("delegates to backend when available", async () => {
|
||||
const mockBackend = new MockBackend();
|
||||
const tester = new TesterAgent();
|
||||
const result = await tester.execute(makeContext(dir, mockBackend));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain("Mock backend executed");
|
||||
});
|
||||
|
||||
it("has correct agent name", () => {
|
||||
const tester = new TesterAgent();
|
||||
expect(tester.name).toBe("tester");
|
||||
});
|
||||
|
||||
it("has correct workflow", () => {
|
||||
const tester = new TesterAgent();
|
||||
expect(tester.workflow).toBe("test");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { VerifierAgent } from "../agents/verifier.js";
|
||||
import { AgentContext } from "../agents/base.js";
|
||||
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
|
||||
import { emptyTokenUsage } from "../backends/types.js";
|
||||
|
||||
class MockBackend implements IntelligenceBackend {
|
||||
readonly name = "mock";
|
||||
readonly type = "llm" as const;
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
async execute(request: BackendRequest): Promise<BackendResult> {
|
||||
return {
|
||||
success: true,
|
||||
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
|
||||
artifacts: [],
|
||||
decisions: [],
|
||||
escalations: [],
|
||||
usage: emptyTokenUsage(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-verifier-test-"));
|
||||
}
|
||||
|
||||
function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
|
||||
return {
|
||||
project_path: dir,
|
||||
phase: 1,
|
||||
stage: "verify",
|
||||
specification: "Build a REST API for task management",
|
||||
config_path: path.join(dir, ".ciagent", "config.json"),
|
||||
backend,
|
||||
};
|
||||
}
|
||||
|
||||
function setupBasicProject(dir: string): void {
|
||||
const ciDir = path.join(dir, ".ciagent");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ciDir, "config.json"), "{}");
|
||||
|
||||
const srcDir = path.join(dir, "src");
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, "index.ts"), 'export const VERSION = "0.7.0";\n');
|
||||
}
|
||||
|
||||
describe("VerifierAgent", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
it("runs mechanical verification without backend", async () => {
|
||||
setupBasicProject(dir);
|
||||
const verifier = new VerifierAgent();
|
||||
const result = await verifier.execute(makeContext(dir));
|
||||
expect(result.output).toBeDefined();
|
||||
});
|
||||
|
||||
it("is read-only — does not create new source files", async () => {
|
||||
setupBasicProject(dir);
|
||||
const srcDir = path.join(dir, "src");
|
||||
const filesBefore = fs.readdirSync(srcDir);
|
||||
const verifier = new VerifierAgent();
|
||||
await verifier.execute(makeContext(dir));
|
||||
const filesAfter = fs.readdirSync(srcDir);
|
||||
expect(filesAfter.length).toBe(filesBefore.length);
|
||||
});
|
||||
|
||||
it("delegates to backend when available", async () => {
|
||||
setupBasicProject(dir);
|
||||
const mockBackend = new MockBackend();
|
||||
const verifier = new VerifierAgent();
|
||||
const result = await verifier.execute(makeContext(dir, mockBackend));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain("Mock backend executed");
|
||||
});
|
||||
|
||||
it("has correct agent name", () => {
|
||||
const verifier = new VerifierAgent();
|
||||
expect(verifier.name).toBe("verifier");
|
||||
});
|
||||
|
||||
it("has correct workflow", () => {
|
||||
const verifier = new VerifierAgent();
|
||||
expect(verifier.workflow).toBe("verify");
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -121,7 +121,7 @@ export function createInitCommand(): Command {
|
||||
console.log("\nNext steps:");
|
||||
console.log(" ciagent run --all # Run full pipeline");
|
||||
console.log(" ciagent run research # Run specific phase");
|
||||
console.log(" ci status # Check project status");
|
||||
console.log(" ciagent status # Check project status");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { GiteaClient, generateReleaseNotes, GiteaReleaseConfig } from "../core/gitea.js";
|
||||
|
||||
const defaultConfig: GiteaReleaseConfig = {
|
||||
baseUrl: "https://git.example.com",
|
||||
token: "test-token-123",
|
||||
owner: "testorg",
|
||||
repo: "testrepo",
|
||||
};
|
||||
|
||||
function makeReleaseResponse(overrides: Partial<{
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
url: string;
|
||||
html_url: string;
|
||||
draft: boolean;
|
||||
prerelease: boolean;
|
||||
}> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: overrides.id ?? 1,
|
||||
tag_name: overrides.tag_name ?? "v1.0.0",
|
||||
name: overrides.name ?? "v1.0.0",
|
||||
body: overrides.body ?? "Release notes",
|
||||
url: overrides.url ?? "https://git.example.com/api/v1/repos/testorg/testrepo/releases/1",
|
||||
html_url: overrides.html_url ?? "https://git.example.com/testorg/testrepo/releases/tag/v1.0.0",
|
||||
draft: overrides.draft ?? false,
|
||||
prerelease: overrides.prerelease ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GiteaClient", () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("createRelease", () => {
|
||||
it("creates a release via POST", async () => {
|
||||
const client = new GiteaClient(defaultConfig);
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => makeReleaseResponse({ tag_name: "v1.0.0", name: "v1.0.0" }),
|
||||
});
|
||||
|
||||
const release = await client.createRelease({
|
||||
tag_name: "v1.0.0",
|
||||
name: "v1.0.0",
|
||||
body: "Initial release",
|
||||
});
|
||||
|
||||
expect(release.tag_name).toBe("v1.0.0");
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
const call = (globalThis.fetch as jest.Mock).mock.calls[0];
|
||||
expect(call[0]).toContain("/releases");
|
||||
expect(call[1].method).toBe("POST");
|
||||
expect(call[1].headers.Authorization).toBe("token test-token-123");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
const client = new GiteaClient(defaultConfig);
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
text: async () => "Conflict: tag already exists",
|
||||
});
|
||||
|
||||
await expect(client.createRelease({
|
||||
tag_name: "v1.0.0",
|
||||
name: "v1.0.0",
|
||||
body: "",
|
||||
})).rejects.toThrow("Gitea API error: 409");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listReleases", () => {
|
||||
it("lists releases via GET", async () => {
|
||||
const client = new GiteaClient(defaultConfig);
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
makeReleaseResponse({ id: 1, tag_name: "v1.0.0" }),
|
||||
makeReleaseResponse({ id: 2, tag_name: "v1.1.0" }),
|
||||
],
|
||||
});
|
||||
|
||||
const releases = await client.listReleases();
|
||||
expect(releases).toHaveLength(2);
|
||||
expect(releases[0].tag_name).toBe("v1.0.0");
|
||||
expect(releases[1].tag_name).toBe("v1.1.0");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
const client = new GiteaClient(defaultConfig);
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await expect(client.listReleases()).rejects.toThrow("Gitea API error: 500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReleaseByTag", () => {
|
||||
it("returns release when found", async () => {
|
||||
const client = new GiteaClient(defaultConfig);
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => makeReleaseResponse({ tag_name: "v1.0.0" }),
|
||||
});
|
||||
|
||||
const release = await client.getReleaseByTag("v1.0.0");
|
||||
expect(release).not.toBeNull();
|
||||
expect(release!.tag_name).toBe("v1.0.0");
|
||||
});
|
||||
|
||||
it("returns null on 404", async () => {
|
||||
const client = new GiteaClient(defaultConfig);
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const release = await client.getReleaseByTag("v0.0.0");
|
||||
expect(release).toBeNull();
|
||||
});
|
||||
|
||||
it("throws on other non-ok status", async () => {
|
||||
const client = new GiteaClient(defaultConfig);
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await expect(client.getReleaseByTag("v1.0.0")).rejects.toThrow("Gitea API error: 500");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateReleaseNotes", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-gitea-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("parses git log into categorized sections", () => {
|
||||
const gitDir = path.join(dir, "repo");
|
||||
fs.mkdirSync(gitDir, { recursive: true });
|
||||
|
||||
const { execSync } = require("node:child_process");
|
||||
execSync("git init", { cwd: gitDir, stdio: "pipe" });
|
||||
execSync('git config user.email "test@test.com"', { cwd: gitDir, stdio: "pipe" });
|
||||
execSync('git config user.name "Test"', { cwd: gitDir, stdio: "pipe" });
|
||||
|
||||
fs.writeFileSync(path.join(gitDir, "file1.txt"), "hello");
|
||||
execSync("git add -A", { cwd: gitDir, stdio: "pipe" });
|
||||
execSync('git commit -m "feat: add authentication"', { cwd: gitDir, stdio: "pipe" });
|
||||
|
||||
fs.writeFileSync(path.join(gitDir, "file2.txt"), "world");
|
||||
execSync("git add -A", { cwd: gitDir, stdio: "pipe" });
|
||||
execSync('git commit -m "fix: resolve login bug"', { cwd: gitDir, stdio: "pipe" });
|
||||
|
||||
execSync("git tag v1.0.0", { cwd: gitDir, stdio: "pipe" });
|
||||
|
||||
const notes = generateReleaseNotes(gitDir, null, "v1.0.0");
|
||||
expect(notes).toContain("New Features");
|
||||
expect(notes).toContain("add authentication");
|
||||
expect(notes).toContain("Bug Fixes");
|
||||
expect(notes).toContain("resolve login bug");
|
||||
});
|
||||
|
||||
it("returns no-commits message when no commits found", () => {
|
||||
const nonExistent = path.join(dir, "nonexistent");
|
||||
const notes = generateReleaseNotes(nonExistent, null, "v0.0.0");
|
||||
expect(notes).toContain("No commits found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { CIAgentFiles, ProjectEntry } from "../core/ciagent-files.js";
|
||||
import { initCIAgent, loadConfig, saveConfig } from "../core/config.js";
|
||||
import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-multiproject-test-"));
|
||||
}
|
||||
|
||||
function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
describe("Multi-project CIAgentFiles operations", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
describe("--project flag behavior via CIAgentFiles", () => {
|
||||
it("sets active project via setActiveProject", () => {
|
||||
const ciFiles = new CIAgentFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
ciFiles.addProject("task-api", "Task API");
|
||||
ciFiles.addProject("auth-svc", "Auth Service");
|
||||
|
||||
ciFiles.setActiveProject("auth-svc");
|
||||
expect(ciFiles.getActiveProject()).toBe("auth-svc");
|
||||
});
|
||||
|
||||
it("lists all added projects", () => {
|
||||
const ciFiles = new CIAgentFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
ciFiles.addProject("task-api", "Task API");
|
||||
ciFiles.addProject("auth-svc", "Auth Service");
|
||||
|
||||
const projects = ciFiles.listProjects();
|
||||
expect(projects.length).toBeGreaterThanOrEqual(2);
|
||||
const slugs = projects.map(p => p.slug);
|
||||
expect(slugs).toContain("task-api");
|
||||
expect(slugs).toContain("auth-svc");
|
||||
});
|
||||
|
||||
it("addProject does not duplicate existing slug", () => {
|
||||
const ciFiles = new CIAgentFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
ciFiles.addProject("task-api", "Task API");
|
||||
ciFiles.addProject("task-api", "Task API V2");
|
||||
|
||||
const projects = ciFiles.listProjects();
|
||||
const taskApiProjects = projects.filter(p => p.slug === "task-api");
|
||||
expect(taskApiProjects.length).toBe(1);
|
||||
});
|
||||
|
||||
it("defaults to empty string when no active project set", () => {
|
||||
const ciFiles = new CIAgentFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
expect(ciFiles.getActiveProject()).toBe("");
|
||||
});
|
||||
|
||||
it("isMultiProject returns false for single or no projects", () => {
|
||||
const ciFiles = new CIAgentFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
expect(ciFiles.isMultiProject()).toBe(false);
|
||||
});
|
||||
|
||||
it("isMultiProject returns true when projects exist in config", () => {
|
||||
const ciFiles = new CIAgentFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
ciFiles.addProject("task-api", "Task API");
|
||||
ciFiles.addProject("auth-svc", "Auth Service");
|
||||
expect(ciFiles.isMultiProject()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config-level project operations", () => {
|
||||
it("initCIAgent with slug adds project to config", () => {
|
||||
const config = initCIAgent(dir, undefined, "task-api", "Task API");
|
||||
expect(config.projects).toHaveLength(1);
|
||||
expect(config.active_project).toBe("task-api");
|
||||
});
|
||||
|
||||
it("--project override sets active_project in config", () => {
|
||||
initCIAgent(dir, undefined, "task-api", "Task API");
|
||||
const config = loadConfig(dir);
|
||||
config.active_project = "task-api";
|
||||
config.projects = [
|
||||
{ slug: "task-api", name: "Task API", default: true },
|
||||
{ slug: "auth-svc", name: "Auth Service" },
|
||||
];
|
||||
saveConfig(dir, config);
|
||||
|
||||
const loaded = loadConfig(dir);
|
||||
expect(loaded.active_project).toBe("task-api");
|
||||
expect(loaded.projects).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("setActiveProject persists to config", () => {
|
||||
initCIAgent(dir, undefined, "task-api", "Task API");
|
||||
const ciFiles = new CIAgentFiles(dir);
|
||||
ciFiles.addProject("auth-svc", "Auth Service");
|
||||
ciFiles.setActiveProject("auth-svc");
|
||||
|
||||
const config = loadConfig(dir);
|
||||
expect(config.active_project).toBe("auth-svc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("project slug and directory structure", () => {
|
||||
it("multi-project mode uses .ciagent/<slug>/ subdirectory", () => {
|
||||
const ciFiles = new CIAgentFiles(dir, "task-api");
|
||||
ciFiles.ensureCIDir();
|
||||
ciFiles.ensureProjectDir();
|
||||
|
||||
const projectDir = path.join(dir, ".ciagent", "task-api");
|
||||
expect(fs.existsSync(projectDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("single-project mode uses .ciagent/ directly", () => {
|
||||
const ciFiles = new CIAgentFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
ciFiles.ensureProjectDir();
|
||||
|
||||
expect(fs.existsSync(path.join(dir, ".ciagent"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, ".ciagent", "task-api"))).toBe(false);
|
||||
});
|
||||
|
||||
it("writeProjectMd writes to project subdirectory in multi-project", () => {
|
||||
const ciFiles = new CIAgentFiles(dir, "task-api");
|
||||
ciFiles.ensureCIDir();
|
||||
ciFiles.ensureProjectDir();
|
||||
|
||||
ciFiles.writeProjectMd({
|
||||
name: "Task API",
|
||||
coreValue: "Manage tasks",
|
||||
requirements: { validated: [], active: ["Task CRUD"], outOfScope: [] },
|
||||
constraints: ["Node.js"],
|
||||
context: "REST API",
|
||||
keyDecisions: [],
|
||||
}, "test write");
|
||||
|
||||
expect(fs.existsSync(path.join(dir, ".ciagent", "task-api", "PROJECT.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("readProjectMd reads from project subdirectory in multi-project", () => {
|
||||
const ciFiles = new CIAgentFiles(dir, "task-api");
|
||||
ciFiles.ensureCIDir();
|
||||
ciFiles.ensureProjectDir();
|
||||
|
||||
ciFiles.writeProjectMd({
|
||||
name: "Task API",
|
||||
coreValue: "Manage tasks",
|
||||
requirements: { validated: [], active: [], outOfScope: [] },
|
||||
constraints: [],
|
||||
context: "",
|
||||
keyDecisions: [],
|
||||
}, "test write");
|
||||
|
||||
const projectMd = ciFiles.readProjectMd();
|
||||
expect(projectMd).not.toBeNull();
|
||||
expect(projectMd!.name).toBe("Task API");
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const VERSION = "0.5.0";
|
||||
export const VERSION = "0.7.0";
|
||||
Reference in New Issue
Block a user