From 371e760d94dcfc5812da03aae204136ceebc9579 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Mon, 16 Feb 2026 13:27:14 +0800 Subject: [PATCH] feat: enhance workflow and testing infrastructure - Introduced ExecutionMode type for project execution modes (parallel, sequential). - Updated SetupOpts to use ExecutionMode instead of string literals. - Enhanced workflow states to include a new "In Review" state with appropriate transitions. - Implemented TestHarness for end-to-end testing, including command interception and workspace setup. - Created TestProvider for in-memory issue tracking during tests. - Refactored project registration and setup tools to utilize ExecutionMode. - Updated various tools to ensure compatibility with new workflow and execution modes. - Added new dependencies: cockatiel for resilience and zod for schema validation. --- lib/bootstrap-hook.ts | 68 ++- lib/config/index.ts | 3 + lib/config/loader.ts | 35 +- lib/config/merge.ts | 5 + lib/config/schema.ts | 114 +++++ lib/config/types.ts | 25 + lib/dispatch.ts | 40 +- lib/projects.ts | 87 +++- lib/providers/github.ts | 31 +- lib/providers/gitlab.ts | 31 +- lib/providers/provider.ts | 15 + lib/providers/resilience.ts | 49 ++ lib/roles/registry.test.ts | 3 +- lib/roles/registry.ts | 2 +- lib/services/bootstrap.e2e.test.ts | 253 ++++++++++ lib/services/health.ts | 9 +- lib/services/heartbeat.ts | 103 ++-- lib/services/pipeline.e2e.test.ts | 747 +++++++++++++++++++++++++++++ lib/services/pipeline.ts | 50 +- lib/services/queue-scan.ts | 88 ++++ lib/services/queue.ts | 3 +- lib/services/review.ts | 98 ++++ lib/services/tick.ts | 79 +-- lib/setup/config.ts | 3 +- lib/setup/index.ts | 3 +- lib/templates.ts | 37 +- lib/testing/harness.ts | 292 +++++++++++ lib/testing/index.ts | 16 + lib/testing/test-provider.ts | 224 +++++++++ lib/tools/project-register.ts | 5 +- lib/tools/setup.ts | 6 +- lib/tools/status.ts | 6 +- lib/tools/task-update.test.ts | 5 +- lib/tools/work-start.ts | 6 +- lib/workflow.ts | 148 ++++-- package-lock.json | 14 +- package.json | 4 +- 37 files changed, 2444 insertions(+), 263 deletions(-) create mode 100644 lib/config/schema.ts create mode 100644 lib/providers/resilience.ts create mode 100644 lib/services/bootstrap.e2e.test.ts create mode 100644 lib/services/pipeline.e2e.test.ts create mode 100644 lib/services/queue-scan.ts create mode 100644 lib/services/review.ts create mode 100644 lib/testing/harness.ts create mode 100644 lib/testing/index.ts create mode 100644 lib/testing/test-provider.ts diff --git a/lib/bootstrap-hook.ts b/lib/bootstrap-hook.ts index d9f2806..57943dc 100644 --- a/lib/bootstrap-hook.ts +++ b/lib/bootstrap-hook.ts @@ -33,36 +33,61 @@ export function parseDevClawSessionKey( return { projectName: match[1], role: match[2] }; } +/** + * Result of loading role instructions — includes the source for traceability. + */ +export type RoleInstructionsResult = { + content: string; + /** Which file the instructions were loaded from, or null if none found. */ + source: string | null; +}; + /** * Load role-specific instructions from workspace. * Tries project-specific file first, then falls back to default. + * Returns both the content and the source path for logging/traceability. * - * This is the same logic previously in dispatch.ts loadRoleInstructions(), - * now called from the bootstrap hook instead of during dispatch. + * Resolution order: + * 1. devclaw/projects//prompts/.md (project-specific) + * 2. projects/roles//.md (old project-specific) + * 3. devclaw/prompts/.md (workspace default) + * 4. projects/roles/default/.md (old default) */ export async function loadRoleInstructions( workspaceDir: string, projectName: string, role: string, -): Promise { +): Promise; +export async function loadRoleInstructions( + workspaceDir: string, + projectName: string, + role: string, + opts: { withSource: true }, +): Promise; +export async function loadRoleInstructions( + workspaceDir: string, + projectName: string, + role: string, + opts?: { withSource: true }, +): Promise { const dataDir = path.join(workspaceDir, DATA_DIR); - // Project-specific: devclaw/projects//prompts/.md - const projectFile = path.join(dataDir, "projects", projectName, "prompts", `${role}.md`); - try { return await fs.readFile(projectFile, "utf-8"); } catch { /* not found */ } + const candidates = [ + path.join(dataDir, "projects", projectName, "prompts", `${role}.md`), + path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`), + path.join(dataDir, "prompts", `${role}.md`), + path.join(workspaceDir, "projects", "roles", "default", `${role}.md`), + ]; - // Fallback old path: projects/roles//.md - const oldProjectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`); - try { return await fs.readFile(oldProjectFile, "utf-8"); } catch { /* not found */ } - - // Default: devclaw/prompts/.md - const defaultFile = path.join(dataDir, "prompts", `${role}.md`); - try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* not found */ } - - // Fallback old default: projects/roles/default/.md - const oldDefaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`); - try { return await fs.readFile(oldDefaultFile, "utf-8"); } catch { /* not found */ } + for (const filePath of candidates) { + try { + const content = await fs.readFile(filePath, "utf-8"); + if (opts?.withSource) return { content, source: filePath }; + return content; + } catch { /* not found, try next */ } + } + if (opts?.withSource) return { content: "", source: null }; return ""; } @@ -102,25 +127,26 @@ export function registerBootstrapHook(api: OpenClawPluginApi): void { const bootstrapFiles = context.bootstrapFiles; if (!Array.isArray(bootstrapFiles)) return; - const instructions = await loadRoleInstructions( + const { content, source } = await loadRoleInstructions( workspaceDir, parsed.projectName, parsed.role, + { withSource: true }, ); - if (!instructions) return; + if (!content) return; // Inject as a virtual bootstrap file. OpenClaw includes these in the // agent's system prompt automatically (via buildBootstrapContextFiles). bootstrapFiles.push({ name: "WORKER_INSTRUCTIONS.md" as any, path: ``, - content: instructions.trim(), + content: content.trim(), missing: false, }); api.logger.info( - `Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}"`, + `Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}" from ${source}`, ); }); } diff --git a/lib/config/index.ts b/lib/config/index.ts index 94345be..f4f4257 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -8,7 +8,10 @@ export type { RoleOverride, ResolvedConfig, ResolvedRoleConfig, + ResolvedTimeouts, + TimeoutConfig, } from "./types.js"; export { loadConfig } from "./loader.js"; export { mergeConfig } from "./merge.js"; +export { validateConfig, validateWorkflowIntegrity } from "./schema.js"; diff --git a/lib/config/loader.ts b/lib/config/loader.ts index 60eabc2..f2d356f 100644 --- a/lib/config/loader.ts +++ b/lib/config/loader.ts @@ -14,7 +14,8 @@ import YAML from "yaml"; import { ROLE_REGISTRY } from "../roles/registry.js"; import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js"; import { mergeConfig } from "./merge.js"; -import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, RoleOverride } from "./types.js"; +import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, ResolvedTimeouts, RoleOverride } from "./types.js"; +import { validateConfig, validateWorkflowIntegrity } from "./schema.js"; import { DATA_DIR } from "../setup/migrate-layout.js"; /** @@ -140,20 +141,42 @@ function resolve(config: DevClawConfig): ResolvedConfig { states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states }, }; - return { roles, workflow }; + // Validate structural integrity (cross-references between states) + const integrityErrors = validateWorkflowIntegrity(workflow); + if (integrityErrors.length > 0) { + throw new Error(`Workflow config integrity errors:\n - ${integrityErrors.join("\n - ")}`); + } + + const timeouts: ResolvedTimeouts = { + gitPullMs: config.timeouts?.gitPullMs ?? 30_000, + gatewayMs: config.timeouts?.gatewayMs ?? 15_000, + sessionPatchMs: config.timeouts?.sessionPatchMs ?? 30_000, + dispatchMs: config.timeouts?.dispatchMs ?? 600_000, + staleWorkerHours: config.timeouts?.staleWorkerHours ?? 2, + }; + + return { roles, workflow, timeouts }; } // --------------------------------------------------------------------------- // File reading helpers // --------------------------------------------------------------------------- -/** Read workflow.yaml (new primary config file). */ +/** Read workflow.yaml (new primary config file). Validates structure via Zod. */ async function readWorkflowFile(dir: string): Promise { try { const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8"); - return YAML.parse(content) as DevClawConfig; - } catch { /* not found */ } - return null; + const parsed = YAML.parse(content); + if (parsed) validateConfig(parsed); + return parsed as DevClawConfig; + } catch (err: any) { + if (err?.code === "ENOENT") return null; + // Re-throw validation errors with file context + if (err?.name === "ZodError") { + throw new Error(`Invalid workflow.yaml in ${dir}: ${err.message}`); + } + return null; + } } /** Read config.yaml (old name, fallback for unmigrated workspaces). */ diff --git a/lib/config/merge.ts b/lib/config/merge.ts index 5fad7bb..8a2a090 100644 --- a/lib/config/merge.ts +++ b/lib/config/merge.ts @@ -57,6 +57,11 @@ export function mergeConfig( } } + // Merge timeouts + if (base.timeouts || overlay.timeouts) { + merged.timeouts = { ...base.timeouts, ...overlay.timeouts }; + } + return merged; } diff --git a/lib/config/schema.ts b/lib/config/schema.ts new file mode 100644 index 0000000..aa6449d --- /dev/null +++ b/lib/config/schema.ts @@ -0,0 +1,114 @@ +/** + * config/schema.ts — Zod validation for DevClaw workflow config. + * + * Validates workflow YAML at load time with clear error messages. + * Enforces: transition targets exist, queue states have roles, + * terminal states have no outgoing transitions. + */ +import { z } from "zod"; +import { StateType } from "../workflow.js"; + +const STATE_TYPES = Object.values(StateType) as [string, ...string[]]; + +const TransitionTargetSchema = z.union([ + z.string(), + z.object({ + target: z.string(), + actions: z.array(z.string()).optional(), + description: z.string().optional(), + }), +]); + +const StateConfigSchema = z.object({ + type: z.enum(STATE_TYPES), + role: z.string().optional(), + label: z.string(), + color: z.string(), + priority: z.number().optional(), + description: z.string().optional(), + check: z.string().optional(), + on: z.record(z.string(), TransitionTargetSchema).optional(), +}); + +const WorkflowConfigSchema = z.object({ + initial: z.string(), + states: z.record(z.string(), StateConfigSchema), +}); + +const RoleOverrideSchema = z.union([ + z.literal(false), + z.object({ + levels: z.array(z.string()).optional(), + defaultLevel: z.string().optional(), + models: z.record(z.string(), z.string()).optional(), + emoji: z.record(z.string(), z.string()).optional(), + completionResults: z.array(z.string()).optional(), + }), +]); + +const TimeoutConfigSchema = z.object({ + gitPullMs: z.number().positive().optional(), + gatewayMs: z.number().positive().optional(), + sessionPatchMs: z.number().positive().optional(), + dispatchMs: z.number().positive().optional(), + staleWorkerHours: z.number().positive().optional(), +}).optional(); + +export const DevClawConfigSchema = z.object({ + roles: z.record(z.string(), RoleOverrideSchema).optional(), + workflow: WorkflowConfigSchema.partial().optional(), + timeouts: TimeoutConfigSchema, +}); + +/** + * Validate a raw parsed config object. + * Returns the validated config or throws with a descriptive error. + */ +export function validateConfig(raw: unknown): void { + DevClawConfigSchema.parse(raw); +} + +/** + * Validate structural integrity of a fully-resolved workflow config. + * Checks cross-references that Zod schema alone can't enforce: + * - All transition targets point to existing states + * - Queue states have a role assigned + * - Terminal states have no outgoing transitions + */ +export function validateWorkflowIntegrity( + workflow: { initial: string; states: Record }> }, +): string[] { + const errors: string[] = []; + const stateKeys = new Set(Object.keys(workflow.states)); + + if (!stateKeys.has(workflow.initial)) { + errors.push(`Initial state "${workflow.initial}" does not exist in states`); + } + + for (const [key, state] of Object.entries(workflow.states)) { + if (state.type === StateType.QUEUE && !state.role) { + errors.push(`Queue state "${key}" must have a role assigned`); + } + + if (state.type === StateType.ACTIVE && !state.role) { + errors.push(`Active state "${key}" must have a role assigned`); + } + + if (state.type === StateType.TERMINAL && state.on && Object.keys(state.on).length > 0) { + errors.push(`Terminal state "${key}" should not have outgoing transitions`); + } + + if (state.on) { + for (const [event, transition] of Object.entries(state.on)) { + const target = typeof transition === "string" + ? transition + : (transition as { target: string }).target; + if (!stateKeys.has(target)) { + errors.push(`State "${key}" transition "${event}" targets non-existent state "${target}"`); + } + } + } + } + + return errors; +} diff --git a/lib/config/types.ts b/lib/config/types.ts index 35ab24f..4b48cd3 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -18,6 +18,18 @@ export type RoleOverride = { completionResults?: string[]; }; +/** + * Configurable timeout values (in milliseconds). + * All fields optional — defaults applied at resolution time. + */ +export type TimeoutConfig = { + gitPullMs?: number; + gatewayMs?: number; + sessionPatchMs?: number; + dispatchMs?: number; + staleWorkerHours?: number; +}; + /** * The full workflow.yaml shape. * All fields optional — missing fields inherit from the layer below. @@ -25,6 +37,18 @@ export type RoleOverride = { export type DevClawConfig = { roles?: Record; workflow?: Partial; + timeouts?: TimeoutConfig; +}; + +/** + * Fully resolved timeout config — all fields present with defaults. + */ +export type ResolvedTimeouts = { + gitPullMs: number; + gatewayMs: number; + sessionPatchMs: number; + dispatchMs: number; + staleWorkerHours: number; }; /** @@ -34,6 +58,7 @@ export type DevClawConfig = { export type ResolvedConfig = { roles: Record; workflow: WorkflowConfig; + timeouts: ResolvedTimeouts; }; /** diff --git a/lib/dispatch.ts b/lib/dispatch.ts index c5644b6..6aa94c3 100644 --- a/lib/dispatch.ts +++ b/lib/dispatch.ts @@ -151,6 +151,7 @@ export async function dispatchTask( const resolvedConfig = await loadConfig(workspaceDir, project.name); const resolvedRole = resolvedConfig.roles[role]; + const { timeouts } = resolvedConfig; const model = resolveModel(role, level, resolvedRole); const worker = getWorker(project, role); const existingSessionKey = getSessionForLevel(worker, level); @@ -194,16 +195,22 @@ export async function dispatchTask( channel: opts.channel ?? "telegram", runtime, }, - ).catch(() => { /* non-fatal */ }); + ).catch((err) => { + auditLog(workspaceDir, "dispatch_warning", { + step: "notify", issue: issueId, role, + error: (err as Error).message ?? String(err), + }).catch(() => {}); + }); // Step 3: Ensure session exists (fire-and-forget — don't wait for gateway) // Session key is deterministic, so we can proceed immediately - ensureSessionFireAndForget(sessionKey, model); + ensureSessionFireAndForget(sessionKey, model, workspaceDir, timeouts.sessionPatchMs); // Step 4: Send task to agent (fire-and-forget) sendToAgent(sessionKey, taskMessage, { - agentId, projectName: project.name, issueId, role, - orchestratorSessionKey: opts.sessionKey, + agentId, projectName: project.name, issueId, role, level, + orchestratorSessionKey: opts.sessionKey, workspaceDir, + dispatchTimeoutMs: timeouts.dispatchMs, }); // Step 5: Update worker state @@ -241,19 +248,24 @@ export async function dispatchTask( * Session key is deterministic, so we don't need to wait for confirmation. * If this fails, health check will catch orphaned state later. */ -function ensureSessionFireAndForget(sessionKey: string, model: string): void { +function ensureSessionFireAndForget(sessionKey: string, model: string, workspaceDir: string, timeoutMs = 30_000): void { runCommand( ["openclaw", "gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model })], - { timeoutMs: 30_000 }, - ).catch(() => { /* fire-and-forget */ }); + { timeoutMs }, + ).catch((err) => { + auditLog(workspaceDir, "dispatch_warning", { + step: "ensureSession", sessionKey, + error: (err as Error).message ?? String(err), + }).catch(() => {}); + }); } function sendToAgent( sessionKey: string, taskMessage: string, - opts: { agentId?: string; projectName: string; issueId: number; role: string; orchestratorSessionKey?: string }, + opts: { agentId?: string; projectName: string; issueId: number; role: string; level?: string; orchestratorSessionKey?: string; workspaceDir: string; dispatchTimeoutMs?: number }, ): void { const gatewayParams = JSON.stringify({ - idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${Date.now()}`, + idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${opts.level ?? "unknown"}-${sessionKey}`, agentId: opts.agentId ?? "devclaw", sessionKey, message: taskMessage, @@ -264,8 +276,14 @@ function sendToAgent( // Fire-and-forget: long-running agent turn, don't await runCommand( ["openclaw", "gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"], - { timeoutMs: 600_000 }, - ).catch(() => { /* fire-and-forget */ }); + { timeoutMs: opts.dispatchTimeoutMs ?? 600_000 }, + ).catch((err) => { + auditLog(opts.workspaceDir, "dispatch_warning", { + step: "sendToAgent", sessionKey, + issue: opts.issueId, role: opts.role, + error: (err as Error).message ?? String(err), + }).catch(() => {}); + }); } async function recordWorkerState( diff --git a/lib/projects.ts b/lib/projects.ts index bc3d7d8..02aeefd 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -1,12 +1,61 @@ /** * Atomic projects.json read/write operations. * All state mutations go through this module to prevent corruption. + * + * Uses file-level locking to prevent concurrent read-modify-write races. */ import fs from "node:fs/promises"; import path from "node:path"; import { homedir } from "node:os"; import { migrateProject } from "./migrations.js"; import { ensureWorkspaceMigrated, DATA_DIR } from "./setup/migrate-layout.js"; +import type { ExecutionMode } from "./workflow.js"; + +// --------------------------------------------------------------------------- +// File locking — prevents concurrent read-modify-write races +// --------------------------------------------------------------------------- + +const LOCK_STALE_MS = 30_000; +const LOCK_RETRY_MS = 50; +const LOCK_TIMEOUT_MS = 10_000; + +function lockPath(workspaceDir: string): string { + return projectsPath(workspaceDir) + ".lock"; +} + +async function acquireLock(workspaceDir: string): Promise { + const lock = lockPath(workspaceDir); + const deadline = Date.now() + LOCK_TIMEOUT_MS; + + while (Date.now() < deadline) { + try { + await fs.writeFile(lock, String(Date.now()), { flag: "wx" }); + return; + } catch (err: any) { + if (err.code !== "EEXIST") throw err; + + // Check for stale lock + try { + const content = await fs.readFile(lock, "utf-8"); + const lockTime = Number(content); + if (Date.now() - lockTime > LOCK_STALE_MS) { + try { await fs.unlink(lock); } catch { /* race */ } + continue; + } + } catch { /* lock disappeared — retry */ } + + await new Promise((r) => setTimeout(r, LOCK_RETRY_MS)); + } + } + + // Last resort: force remove potentially stale lock + try { await fs.unlink(lockPath(workspaceDir)); } catch { /* ignore */ } + await fs.writeFile(lock, String(Date.now()), { flag: "wx" }); +} + +async function releaseLock(workspaceDir: string): Promise { + try { await fs.unlink(lockPath(workspaceDir)); } catch { /* already removed */ } +} export type WorkerState = { active: boolean; @@ -28,7 +77,7 @@ export type Project = { /** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */ provider?: "github" | "gitlab"; /** Project-level role execution: parallel (DEVELOPER+TESTER can run simultaneously) or sequential (only one role at a time). Default: parallel */ - roleExecution?: "parallel" | "sequential"; + roleExecution?: ExecutionMode; maxDevWorkers?: number; maxQaWorkers?: number; /** Worker state per role (developer, tester, architect, or custom roles). */ @@ -109,6 +158,7 @@ export function getWorker( /** * Update worker state for a project. Only provided fields are updated. * Sessions are merged (not replaced) when both existing and new sessions are present. + * Uses file locking to prevent concurrent read-modify-write races. */ export async function updateWorker( workspaceDir: string, @@ -116,22 +166,27 @@ export async function updateWorker( role: string, updates: Partial, ): Promise { - const data = await readProjects(workspaceDir); - const project = data.projects[groupId]; - if (!project) { - throw new Error(`Project not found for groupId: ${groupId}`); + await acquireLock(workspaceDir); + try { + const data = await readProjects(workspaceDir); + const project = data.projects[groupId]; + if (!project) { + throw new Error(`Project not found for groupId: ${groupId}`); + } + + const worker = project.workers[role] ?? emptyWorkerState([]); + + if (updates.sessions && worker.sessions) { + updates.sessions = { ...worker.sessions, ...updates.sessions }; + } + + project.workers[role] = { ...worker, ...updates }; + + await writeProjects(workspaceDir, data); + return data; + } finally { + await releaseLock(workspaceDir); } - - const worker = project.workers[role] ?? emptyWorkerState([]); - - if (updates.sessions && worker.sessions) { - updates.sessions = { ...worker.sessions, ...updates.sessions }; - } - - project.workers[role] = { ...worker, ...updates }; - - await writeProjects(workspaceDir, data); - return data; } /** diff --git a/lib/providers/github.ts b/lib/providers/github.ts index 6166b76..7677476 100644 --- a/lib/providers/github.ts +++ b/lib/providers/github.ts @@ -6,8 +6,11 @@ import { type Issue, type StateLabel, type IssueComment, + type PrStatus, + PrState, } from "./provider.js"; import { runCommand } from "../run-command.js"; +import { withResilience } from "./resilience.js"; import { DEFAULT_WORKFLOW, getStateLabels, @@ -41,8 +44,10 @@ export class GitHubProvider implements IssueProvider { } private async gh(args: string[]): Promise { - const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath }); - return result.stdout.trim(); + return withResilience(async () => { + const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath }); + return result.stdout.trim(); + }); } async ensureLabel(name: string, color: string): Promise { @@ -125,6 +130,28 @@ export class GitHubProvider implements IssueProvider { } catch { return null; } } + async getPrStatus(issueId: number): Promise { + const pat = `#${issueId}`; + // Check open PRs first + try { + const raw = await this.gh(["pr", "list", "--state", "open", "--json", "title,body,url,reviewDecision", "--limit", "20"]); + const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string; reviewDecision: string }>; + const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat)); + if (pr) { + const state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.OPEN; + return { state, url: pr.url }; + } + } catch { /* continue to merged check */ } + // Check merged PRs + try { + const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "title,body,url", "--limit", "20"]); + const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string }>; + const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat)); + if (pr) return { state: PrState.MERGED, url: pr.url }; + } catch { /* ignore */ } + return { state: PrState.CLOSED, url: null }; + } + async addComment(issueId: number, body: string): Promise { await this.gh(["issue", "comment", String(issueId), "--body", body]); } diff --git a/lib/providers/gitlab.ts b/lib/providers/gitlab.ts index fc59466..603a7dc 100644 --- a/lib/providers/gitlab.ts +++ b/lib/providers/gitlab.ts @@ -6,8 +6,11 @@ import { type Issue, type StateLabel, type IssueComment, + type PrStatus, + PrState, } from "./provider.js"; import { runCommand } from "../run-command.js"; +import { withResilience } from "./resilience.js"; import { DEFAULT_WORKFLOW, getStateLabels, @@ -25,8 +28,10 @@ export class GitLabProvider implements IssueProvider { } private async glab(args: string[]): Promise { - const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath }); - return result.stdout.trim(); + return withResilience(async () => { + const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath }); + return result.stdout.trim(); + }); } async ensureLabel(name: string, color: string): Promise { @@ -122,6 +127,28 @@ export class GitLabProvider implements IssueProvider { } catch { return null; } } + async getPrStatus(issueId: number): Promise { + const pat = `#${issueId}`; + // Check open MRs first + try { + const raw = await this.glab(["mr", "list", "--output", "json", "--state", "opened"]); + const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string; approved_by?: Array }>; + const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat)); + if (mr) { + const state = mr.approved_by && mr.approved_by.length > 0 ? PrState.APPROVED : PrState.OPEN; + return { state, url: mr.web_url }; + } + } catch { /* continue to merged check */ } + // Check merged MRs + try { + const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]); + const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string }>; + const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat)); + if (mr) return { state: PrState.MERGED, url: mr.web_url }; + } catch { /* ignore */ } + return { state: PrState.CLOSED, url: null }; + } + async addComment(issueId: number, body: string): Promise { // Pass message directly as argv — no shell escaping needed with spawn await this.glab(["issue", "note", String(issueId), "--message", body]); diff --git a/lib/providers/provider.ts b/lib/providers/provider.ts index 9eedb74..d404371 100644 --- a/lib/providers/provider.ts +++ b/lib/providers/provider.ts @@ -28,6 +28,20 @@ export type IssueComment = { created_at: string; }; +/** Built-in PR states. */ +export const PrState = { + OPEN: "open", + APPROVED: "approved", + MERGED: "merged", + CLOSED: "closed", +} as const; +export type PrState = (typeof PrState)[keyof typeof PrState]; + +export type PrStatus = { + state: PrState; + url: string | null; +}; + // --------------------------------------------------------------------------- // Provider interface // --------------------------------------------------------------------------- @@ -46,6 +60,7 @@ export interface IssueProvider { getCurrentStateLabel(issue: Issue): StateLabel | null; hasMergedMR(issueId: number): Promise; getMergedMRUrl(issueId: number): Promise; + getPrStatus(issueId: number): Promise; addComment(issueId: number, body: string): Promise; healthCheck(): Promise; } diff --git a/lib/providers/resilience.ts b/lib/providers/resilience.ts new file mode 100644 index 0000000..52e75f5 --- /dev/null +++ b/lib/providers/resilience.ts @@ -0,0 +1,49 @@ +/** + * providers/resilience.ts — Retry and circuit breaker policies for provider calls. + * + * Uses cockatiel for lightweight resilience without heavyweight orchestration. + * Applied to GitHub/GitLab CLI calls that can fail due to network, rate limits, or timeouts. + */ +import { + ExponentialBackoff, + retry, + circuitBreaker, + ConsecutiveBreaker, + handleAll, + wrap, + type IPolicy, +} from "cockatiel"; + +/** + * Default retry policy: 3 attempts with exponential backoff. + * Handles all errors (network, timeout, CLI failure). + */ +const retryPolicy = retry(handleAll, { + maxAttempts: 3, + backoff: new ExponentialBackoff({ + initialDelay: 500, + maxDelay: 5_000, + }), +}); + +/** + * Circuit breaker: opens after 5 consecutive failures, half-opens after 30s. + * Prevents hammering a provider that's down. + */ +const breakerPolicy = circuitBreaker(handleAll, { + halfOpenAfter: 30_000, + breaker: new ConsecutiveBreaker(5), +}); + +/** + * Combined policy: circuit breaker wrapping retry. + * If circuit is open, calls fail fast without retrying. + */ +export const providerPolicy: IPolicy = wrap(breakerPolicy, retryPolicy); + +/** + * Execute a provider call with retry + circuit breaker. + */ +export function withResilience(fn: () => Promise): Promise { + return providerPolicy.execute(() => fn()); +} diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index b8a6536..f1eb542 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -185,13 +185,14 @@ describe("emoji", () => { describe("completion results", () => { it("should return valid results per role", () => { - assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "blocked"]); + assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "review", "blocked"]); assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]); assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]); }); it("should validate results", () => { assert.strictEqual(isValidResult("developer", "done"), true); + assert.strictEqual(isValidResult("developer", "review"), true); assert.strictEqual(isValidResult("developer", "pass"), false); assert.strictEqual(isValidResult("tester", "pass"), true); assert.strictEqual(isValidResult("tester", "done"), false); diff --git a/lib/roles/registry.ts b/lib/roles/registry.ts index cd1fbee..7e016d4 100644 --- a/lib/roles/registry.ts +++ b/lib/roles/registry.ts @@ -30,7 +30,7 @@ export const ROLE_REGISTRY: Record = { senior: "🧠", }, fallbackEmoji: "🔧", - completionResults: ["done", "blocked"], + completionResults: ["done", "review", "blocked"], sessionKeyPattern: "developer", notifications: { onStart: true, onComplete: true }, }, diff --git a/lib/services/bootstrap.e2e.test.ts b/lib/services/bootstrap.e2e.test.ts new file mode 100644 index 0000000..0015109 --- /dev/null +++ b/lib/services/bootstrap.e2e.test.ts @@ -0,0 +1,253 @@ +/** + * E2E bootstrap tests — verifies the full bootstrap hook chain: + * dispatchTask() → session key → registerBootstrapHook fires → bootstrapFiles injected + * + * Uses simulateBootstrap() which registers the real hook with a mock API, + * fires it with the session key from dispatch, and returns the resulting + * bootstrapFiles array — proving instructions actually reach the worker. + * + * Run: npx tsx --test lib/services/bootstrap.e2e.test.ts + */ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert"; +import { createTestHarness, type TestHarness } from "../testing/index.js"; +import { dispatchTask } from "../dispatch.js"; + +describe("E2E bootstrap — hook injection", () => { + let h: TestHarness; + + afterEach(async () => { + if (h) await h.cleanup(); + }); + + it("should inject project-specific instructions into bootstrapFiles", async () => { + h = await createTestHarness({ projectName: "my-app" }); + h.provider.seedIssue({ iid: 1, title: "Add feature", labels: ["To Do"] }); + + // Write both default and project-specific prompts + await h.writePrompt("developer", "# Default Developer\nGeneric instructions."); + await h.writePrompt("developer", "# My App Developer\nUse React. Follow our design system.", "my-app"); + + // Dispatch to get the session key + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 1, + issueTitle: "Add feature", + issueDescription: "", + issueUrl: "https://example.com/issues/1", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + // Fire the actual bootstrap hook with the dispatch session key + const files = await h.simulateBootstrap(result.sessionKey); + + // Should have exactly one injected file + assert.strictEqual(files.length, 1, `Expected 1 bootstrap file, got ${files.length}`); + assert.strictEqual(files[0].name, "WORKER_INSTRUCTIONS.md"); + assert.strictEqual(files[0].missing, false); + assert.ok(files[0].path.includes("my-app"), `Path should reference project: ${files[0].path}`); + assert.ok(files[0].path.includes("developer"), `Path should reference role: ${files[0].path}`); + + // Content should be project-specific, NOT default + const content = files[0].content!; + assert.ok(content.includes("My App Developer"), `Got: ${content}`); + assert.ok(content.includes("Use React")); + assert.ok(!content.includes("Generic instructions")); + }); + + it("should fall back to default instructions when no project override exists", async () => { + h = await createTestHarness({ projectName: "other-app" }); + h.provider.seedIssue({ iid: 2, title: "Fix bug", labels: ["To Do"] }); + + // Only write default prompt — no project-specific + await h.writePrompt("developer", "# Default Developer\nFollow coding standards."); + + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 2, + issueTitle: "Fix bug", + issueDescription: "", + issueUrl: "https://example.com/issues/2", + role: "developer", + level: "junior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const files = await h.simulateBootstrap(result.sessionKey); + + assert.strictEqual(files.length, 1); + assert.ok(files[0].content!.includes("Default Developer")); + assert.ok(files[0].content!.includes("Follow coding standards")); + }); + + it("should inject scaffolded default instructions when no overrides exist", async () => { + h = await createTestHarness({ projectName: "bare-app" }); + h.provider.seedIssue({ iid: 3, title: "Chore", labels: ["To Do"] }); + + // Don't write any custom prompts — ensureWorkspaceMigrated scaffolds defaults + + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 3, + issueTitle: "Chore", + issueDescription: "", + issueUrl: "https://example.com/issues/3", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const files = await h.simulateBootstrap(result.sessionKey); + + // Default developer instructions are scaffolded by ensureDefaultFiles + assert.strictEqual(files.length, 1); + assert.ok(files[0].content!.includes("DEVELOPER"), "Should contain DEVELOPER heading"); + assert.ok(files[0].content!.includes("work_finish"), "Should reference work_finish"); + }); + + it("should NOT inject anything for unknown custom roles", async () => { + h = await createTestHarness({ projectName: "custom-app" }); + + // Simulate a session key for a custom role that has no prompt file + // This key won't parse because "reviewer" isn't in the role registry + const files = await h.simulateBootstrap( + "agent:main:subagent:custom-app-reviewer-medior", + ); + + assert.strictEqual(files.length, 0, "Should not inject files for unknown roles"); + }); + + it("should resolve tester instructions independently from developer", async () => { + h = await createTestHarness({ projectName: "multi-role" }); + h.provider.seedIssue({ iid: 4, title: "Test thing", labels: ["To Test"] }); + + // Write project-specific for developer, default for tester + await h.writePrompt("developer", "# Dev for multi-role\nSpecific dev rules.", "multi-role"); + await h.writePrompt("tester", "# Default Tester\nRun integration tests."); + + // Dispatch as tester + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 4, + issueTitle: "Test thing", + issueDescription: "", + issueUrl: "https://example.com/issues/4", + role: "tester", + level: "medior", + fromLabel: "To Test", + toLabel: "Testing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + // Simulate bootstrap for the tester session + const testerFiles = await h.simulateBootstrap(result.sessionKey); + assert.strictEqual(testerFiles.length, 1); + assert.ok(testerFiles[0].content!.includes("Default Tester")); + assert.ok(!testerFiles[0].content!.includes("Dev for multi-role")); + + // Simulate bootstrap for a developer session on the same project + const devKey = result.sessionKey.replace("-tester-", "-developer-"); + const devFiles = await h.simulateBootstrap(devKey); + assert.strictEqual(devFiles.length, 1); + assert.ok(devFiles[0].content!.includes("Dev for multi-role")); + assert.ok(devFiles[0].content!.includes("Specific dev rules")); + }); + + it("should handle project names with hyphens correctly", async () => { + h = await createTestHarness({ projectName: "my-cool-project" }); + h.provider.seedIssue({ iid: 5, title: "Hyphen test", labels: ["To Do"] }); + + await h.writePrompt( + "developer", + "# Hyphenated Project\nThis project has hyphens in the name.", + "my-cool-project", + ); + + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 5, + issueTitle: "Hyphen test", + issueDescription: "", + issueUrl: "https://example.com/issues/5", + role: "developer", + level: "senior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const files = await h.simulateBootstrap(result.sessionKey); + + assert.strictEqual(files.length, 1); + assert.ok(files[0].content!.includes("Hyphenated Project")); + assert.ok(files[0].path.includes("my-cool-project")); + }); + + it("should resolve architect instructions with project override", async () => { + h = await createTestHarness({ projectName: "arch-proj" }); + h.provider.seedIssue({ iid: 6, title: "Design API", labels: ["To Design"] }); + + await h.writePrompt("architect", "# Default Architect\nGeneral design guidelines."); + await h.writePrompt("architect", "# Arch Proj Architect\nUse event-driven architecture.", "arch-proj"); + + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 6, + issueTitle: "Design API", + issueDescription: "", + issueUrl: "https://example.com/issues/6", + role: "architect", + level: "senior", + fromLabel: "To Design", + toLabel: "Designing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const files = await h.simulateBootstrap(result.sessionKey); + + assert.strictEqual(files.length, 1); + assert.ok(files[0].content!.includes("Arch Proj Architect")); + assert.ok(files[0].content!.includes("event-driven")); + assert.ok(!files[0].content!.includes("General design guidelines")); + }); + + it("should not inject when session key is not a DevClaw subagent", async () => { + h = await createTestHarness(); + + // Non-DevClaw session key — hook should no-op + const files = await h.simulateBootstrap("agent:main:orchestrator"); + assert.strictEqual(files.length, 0); + }); +}); diff --git a/lib/services/health.ts b/lib/services/health.ts index eb41649..68167ba 100644 --- a/lib/services/health.ts +++ b/lib/services/health.ts @@ -83,13 +83,13 @@ export type SessionLookup = Map; * Returns null if gateway is unavailable (timeout, error, etc). * Callers should skip session liveness checks if null — unknown ≠ dead. */ -export async function fetchGatewaySessions(): Promise { +export async function fetchGatewaySessions(gatewayTimeoutMs = 15_000): Promise { const lookup: SessionLookup = new Map(); try { const result = await runCommand( ["openclaw", "gateway", "call", "status", "--json"], - { timeoutMs: 15_000 }, + { timeoutMs: gatewayTimeoutMs }, ); const jsonStart = result.stdout.indexOf("{"); @@ -151,10 +151,13 @@ export async function checkWorkerHealth(opts: { sessions: SessionLookup | null; /** Workflow config (defaults to DEFAULT_WORKFLOW) */ workflow?: WorkflowConfig; + /** Hours after which an active worker is considered stale (default: 2) */ + staleWorkerHours?: number; }): Promise { const { workspaceDir, groupId, project, role, autoFix, provider, sessions, workflow = DEFAULT_WORKFLOW, + staleWorkerHours = 2, } = opts; const fixes: HealthFix[] = []; @@ -316,7 +319,7 @@ export async function checkWorkerHealth(opts: { // --------------------------------------------------------------------------- if (worker.active && worker.startTime && sessionKey && sessions && isSessionAlive(sessionKey, sessions)) { const hours = (Date.now() - new Date(worker.startTime).getTime()) / 3_600_000; - if (hours > 2) { + if (hours > staleWorkerHours) { const fix: HealthFix = { issue: { type: "stale_worker", diff --git a/lib/services/heartbeat.ts b/lib/services/heartbeat.ts index 7083d70..307df3f 100644 --- a/lib/services/heartbeat.ts +++ b/lib/services/heartbeat.ts @@ -18,7 +18,10 @@ import { log as auditLog } from "../audit.js"; import { DATA_DIR } from "../setup/migrate-layout.js"; import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js"; import { projectTick } from "./tick.js"; +import { reviewPass } from "./review.js"; import { createProvider } from "../providers/index.js"; +import { loadConfig } from "../config/index.js"; +import { ExecutionMode } from "../workflow.js"; // --------------------------------------------------------------------------- // Types @@ -39,6 +42,7 @@ type TickResult = { totalPickups: number; totalHealthFixes: number; totalSkipped: number; + totalReviewTransitions: number; }; type ServiceContext = { @@ -191,6 +195,7 @@ async function processAllAgents( totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0, + totalReviewTransitions: 0, }; // Fetch gateway sessions once for all agents/projects @@ -209,6 +214,7 @@ async function processAllAgents( result.totalPickups += agentResult.totalPickups; result.totalHealthFixes += agentResult.totalHealthFixes; result.totalSkipped += agentResult.totalSkipped; + result.totalReviewTransitions += agentResult.totalReviewTransitions; } return result; @@ -218,9 +224,9 @@ async function processAllAgents( * Log tick results if anything happened. */ function logTickResult(result: TickResult, logger: ServiceContext["logger"]): void { - if (result.totalPickups > 0 || result.totalHealthFixes > 0) { + if (result.totalPickups > 0 || result.totalHealthFixes > 0 || result.totalReviewTransitions > 0) { logger.info( - `work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalSkipped} skipped`, + `work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalReviewTransitions} review transitions, ${result.totalSkipped} skipped`, ); } } @@ -243,60 +249,83 @@ export async function tick(opts: { const projectIds = Object.keys(data.projects); if (projectIds.length === 0) { - return { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0 }; + return { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0, totalReviewTransitions: 0 }; } const result: TickResult = { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0, + totalReviewTransitions: 0, }; - const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel"; + const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL; let activeProjects = 0; for (const groupId of projectIds) { - const project = data.projects[groupId]; - if (!project) continue; + try { + const project = data.projects[groupId]; + if (!project) continue; - // Health pass: auto-fix zombies and stale workers - result.totalHealthFixes += await performHealthPass( - workspaceDir, - groupId, - project, - sessions, - ); + const { provider } = await createProvider({ repo: project.repo, provider: project.provider }); + const resolvedConfig = await loadConfig(workspaceDir, project.name); - // Budget check: stop if we've hit the limit - const remaining = config.maxPickupsPerTick - result.totalPickups; - if (remaining <= 0) break; + // Health pass: auto-fix zombies and stale workers + result.totalHealthFixes += await performHealthPass( + workspaceDir, + groupId, + project, + sessions, + provider, + resolvedConfig.timeouts.staleWorkerHours, + ); - // Sequential project guard: don't start new projects if one is active - const isProjectActive = await checkProjectActive(workspaceDir, groupId); - if (projectExecution === "sequential" && !isProjectActive && activeProjects >= 1) { + // Review pass: transition issues whose PR check condition is met + result.totalReviewTransitions += await reviewPass({ + workspaceDir, + groupId, + workflow: resolvedConfig.workflow, + provider, + repoPath: project.repo, + gitPullTimeoutMs: resolvedConfig.timeouts.gitPullMs, + }); + + // Budget check: stop if we've hit the limit + const remaining = config.maxPickupsPerTick - result.totalPickups; + if (remaining <= 0) break; + + // Sequential project guard: don't start new projects if one is active + const isProjectActive = await checkProjectActive(workspaceDir, groupId); + if (projectExecution === ExecutionMode.SEQUENTIAL && !isProjectActive && activeProjects >= 1) { + result.totalSkipped++; + continue; + } + + // Tick pass: fill free worker slots + const tickResult = await projectTick({ + workspaceDir, + groupId, + agentId, + pluginConfig, + maxPickups: remaining, + }); + + result.totalPickups += tickResult.pickups.length; + result.totalSkipped += tickResult.skipped.length; + + // Notifications now handled by dispatchTask + if (isProjectActive || tickResult.pickups.length > 0) activeProjects++; + } catch (err) { + // Per-project isolation: one failing project doesn't crash the entire tick + opts.logger.warn(`Heartbeat tick failed for project ${groupId}: ${(err as Error).message}`); result.totalSkipped++; - continue; } - - // Tick pass: fill free worker slots - const tickResult = await projectTick({ - workspaceDir, - groupId, - agentId, - pluginConfig, - maxPickups: remaining, - }); - - result.totalPickups += tickResult.pickups.length; - result.totalSkipped += tickResult.skipped.length; - - // Notifications now handled by dispatchTask - if (isProjectActive || tickResult.pickups.length > 0) activeProjects++; } await auditLog(workspaceDir, "heartbeat_tick", { projectsScanned: projectIds.length, healthFixes: result.totalHealthFixes, + reviewTransitions: result.totalReviewTransitions, pickups: result.totalPickups, skipped: result.totalSkipped, }); @@ -312,8 +341,9 @@ async function performHealthPass( groupId: string, project: any, sessions: SessionLookup | null, + provider: import("../providers/provider.js").IssueProvider, + staleWorkerHours?: number, ): Promise { - const { provider } = await createProvider({ repo: project.repo, provider: project.provider }); let fixedCount = 0; for (const role of Object.keys(project.workers)) { @@ -326,6 +356,7 @@ async function performHealthPass( sessions, autoFix: true, provider, + staleWorkerHours, }); fixedCount += healthFixes.filter((f) => f.fixed).length; diff --git a/lib/services/pipeline.e2e.test.ts b/lib/services/pipeline.e2e.test.ts new file mode 100644 index 0000000..0689291 --- /dev/null +++ b/lib/services/pipeline.e2e.test.ts @@ -0,0 +1,747 @@ +/** + * E2E pipeline tests — exercises the full workflow lifecycle. + * + * Tests dispatch → completion → review pass using: + * - TestProvider (in-memory issues, call tracking) + * - Mock runCommand (captures gateway calls, task messages) + * - Real projects.json on disk (temp workspace) + * + * Run: npx tsx --test lib/services/pipeline.e2e.test.ts + */ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import { createTestHarness, type TestHarness } from "../testing/index.js"; +import { dispatchTask } from "../dispatch.js"; +import { executeCompletion } from "./pipeline.js"; +import { reviewPass } from "./review.js"; +import { DEFAULT_WORKFLOW } from "../workflow.js"; +import { readProjects, getWorker } from "../projects.js"; + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("E2E pipeline", () => { + let h: TestHarness; + + afterEach(async () => { + if (h) await h.cleanup(); + }); + + // ========================================================================= + // Dispatch + // ========================================================================= + + describe("dispatchTask", () => { + beforeEach(async () => { + h = await createTestHarness(); + // Seed a "To Do" issue + h.provider.seedIssue({ iid: 42, title: "Add login page", labels: ["To Do"] }); + }); + + it("should transition label, update worker state, and fire gateway calls", async () => { + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "test-agent", + groupId: h.groupId, + project: h.project, + issueId: 42, + issueTitle: "Add login page", + issueDescription: "Build the login page", + issueUrl: "https://example.com/issues/42", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + // Verify dispatch result + assert.strictEqual(result.sessionAction, "spawn"); + assert.ok(result.sessionKey.includes("test-project-developer-medior")); + assert.ok(result.announcement.includes("#42")); + assert.ok(result.announcement.includes("Add login page")); + + // Verify label transitioned on the issue + const issue = await h.provider.getIssue(42); + assert.ok(issue.labels.includes("Doing"), `Expected "Doing" label, got: ${issue.labels}`); + assert.ok(!issue.labels.includes("To Do"), "Should not have 'To Do' label"); + + // Verify worker state updated in projects.json + const data = await readProjects(h.workspaceDir); + const worker = getWorker(data.projects[h.groupId], "developer"); + assert.strictEqual(worker.active, true); + assert.strictEqual(worker.issueId, "42"); + assert.strictEqual(worker.level, "medior"); + + // Verify gateway commands were fired + assert.ok(h.commands.sessionPatches().length > 0, "Should have patched session"); + assert.ok(h.commands.taskMessages().length > 0, "Should have sent task message"); + + // Verify task message contains issue context + const taskMsg = h.commands.taskMessages()[0]; + assert.ok(taskMsg.includes("Add login page"), "Task message should include title"); + assert.ok(taskMsg.includes(h.groupId), "Task message should include groupId"); + assert.ok(taskMsg.includes("work_finish"), "Task message should reference work_finish"); + }); + + it("should include comments in task message", async () => { + h.provider.comments.set(42, [ + { author: "alice", body: "Please use OAuth", created_at: "2026-01-01T00:00:00Z" }, + { author: "bob", body: "Agreed, OAuth2 flow", created_at: "2026-01-02T00:00:00Z" }, + ]); + + await dispatchTask({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + project: h.project, + issueId: 42, + issueTitle: "Add login page", + issueDescription: "", + issueUrl: "https://example.com/issues/42", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + const taskMsg = h.commands.taskMessages()[0]; + assert.ok(taskMsg.includes("alice"), "Should include comment author"); + assert.ok(taskMsg.includes("Please use OAuth"), "Should include comment body"); + assert.ok(taskMsg.includes("bob"), "Should include second comment author"); + }); + + it("should reuse existing session when available", async () => { + // Set up worker with existing session + h = await createTestHarness({ + workers: { + developer: { + sessions: { medior: "agent:test-agent:subagent:test-project-developer-medior" }, + }, + }, + }); + h.provider.seedIssue({ iid: 42, title: "Quick fix", labels: ["To Do"] }); + + const result = await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "test-agent", + groupId: h.groupId, + project: h.project, + issueId: 42, + issueTitle: "Quick fix", + issueDescription: "", + issueUrl: "https://example.com/issues/42", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + assert.strictEqual(result.sessionAction, "send"); + }); + }); + + // ========================================================================= + // Completion — developer:done + // ========================================================================= + + describe("executeCompletion — developer:done", () => { + beforeEach(async () => { + h = await createTestHarness({ + workers: { + developer: { active: true, issueId: "10", level: "medior" }, + }, + }); + h.provider.seedIssue({ iid: 10, title: "Build feature X", labels: ["Doing"] }); + }); + + it("should transition Doing → To Test, deactivate worker, run gitPull+detectPr actions", async () => { + h.provider.mergedMrUrls.set(10, "https://example.com/mr/5"); + + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "done", + issueId: 10, + summary: "Built feature X", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + // Label transition + assert.strictEqual(output.labelTransition, "Doing → To Test"); + assert.ok(output.announcement.includes("#10")); + + // Issue state + const issue = await h.provider.getIssue(10); + assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`); + assert.ok(!issue.labels.includes("Doing")); + + // Worker deactivated + const data = await readProjects(h.workspaceDir); + const worker = getWorker(data.projects[h.groupId], "developer"); + assert.strictEqual(worker.active, false); + + // PR URL detected + assert.strictEqual(output.prUrl, "https://example.com/mr/5"); + + // gitPull action was executed + const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git"); + assert.ok(gitCmds.length > 0, "Should have run git pull"); + assert.deepStrictEqual(gitCmds[0].argv, ["git", "pull"]); + + // Issue NOT closed (done goes to To Test, not Done) + assert.strictEqual(output.issueClosed, false); + }); + }); + + // ========================================================================= + // Completion — developer:review + // ========================================================================= + + describe("executeCompletion — developer:review", () => { + beforeEach(async () => { + h = await createTestHarness({ + workers: { + developer: { active: true, issueId: "20", level: "senior" }, + }, + }); + h.provider.seedIssue({ iid: 20, title: "Refactor auth", labels: ["Doing"] }); + }); + + it("should transition Doing → In Review, deactivate worker", async () => { + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "review", + issueId: 20, + summary: "PR open for review", + prUrl: "https://example.com/pr/3", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + assert.strictEqual(output.labelTransition, "Doing → In Review"); + assert.ok(output.nextState.includes("review"), `nextState: ${output.nextState}`); + + const issue = await h.provider.getIssue(20); + assert.ok(issue.labels.includes("In Review"), `Labels: ${issue.labels}`); + + // Worker should be deactivated + const data = await readProjects(h.workspaceDir); + assert.strictEqual(getWorker(data.projects[h.groupId], "developer").active, false); + + // Issue should NOT be closed + assert.strictEqual(output.issueClosed, false); + }); + }); + + // ========================================================================= + // Completion — tester:pass + // ========================================================================= + + describe("executeCompletion — tester:pass", () => { + beforeEach(async () => { + h = await createTestHarness({ + workers: { + tester: { active: true, issueId: "30", level: "medior" }, + }, + }); + h.provider.seedIssue({ iid: 30, title: "Verify login", labels: ["Testing"] }); + }); + + it("should transition Testing → Done, close issue", async () => { + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "pass", + issueId: 30, + summary: "All tests pass", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + assert.strictEqual(output.labelTransition, "Testing → Done"); + assert.strictEqual(output.issueClosed, true); + + const issue = await h.provider.getIssue(30); + assert.ok(issue.labels.includes("Done")); + assert.strictEqual(issue.state, "closed"); + + // Verify closeIssue was called + const closeCalls = h.provider.callsTo("closeIssue"); + assert.strictEqual(closeCalls.length, 1); + assert.strictEqual(closeCalls[0].args.issueId, 30); + }); + }); + + // ========================================================================= + // Completion — tester:fail + // ========================================================================= + + describe("executeCompletion — tester:fail", () => { + beforeEach(async () => { + h = await createTestHarness({ + workers: { + tester: { active: true, issueId: "40", level: "medior" }, + }, + }); + h.provider.seedIssue({ iid: 40, title: "Check signup", labels: ["Testing"] }); + }); + + it("should transition Testing → To Improve, reopen issue", async () => { + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "fail", + issueId: 40, + summary: "Signup form validation broken", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + assert.strictEqual(output.labelTransition, "Testing → To Improve"); + assert.strictEqual(output.issueReopened, true); + + const issue = await h.provider.getIssue(40); + assert.ok(issue.labels.includes("To Improve")); + assert.strictEqual(issue.state, "opened"); + + const reopenCalls = h.provider.callsTo("reopenIssue"); + assert.strictEqual(reopenCalls.length, 1); + }); + }); + + // ========================================================================= + // Completion — developer:blocked + // ========================================================================= + + describe("executeCompletion — developer:blocked", () => { + beforeEach(async () => { + h = await createTestHarness({ + workers: { + developer: { active: true, issueId: "50", level: "junior" }, + }, + }); + h.provider.seedIssue({ iid: 50, title: "Fix CSS", labels: ["Doing"] }); + }); + + it("should transition Doing → Refining, no close/reopen", async () => { + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "blocked", + issueId: 50, + summary: "Need design decision", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + assert.strictEqual(output.labelTransition, "Doing → Refining"); + assert.strictEqual(output.issueClosed, false); + assert.strictEqual(output.issueReopened, false); + + const issue = await h.provider.getIssue(50); + assert.ok(issue.labels.includes("Refining")); + }); + }); + + // ========================================================================= + // Review pass + // ========================================================================= + + describe("reviewPass", () => { + beforeEach(async () => { + h = await createTestHarness(); + }); + + it("should transition In Review → To Test when PR is merged", async () => { + // Seed issue in "In Review" state + h.provider.seedIssue({ iid: 60, title: "Feature Y", labels: ["In Review"] }); + h.provider.setPrStatus(60, { state: "merged", url: "https://example.com/pr/10" }); + + const transitions = await reviewPass({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + workflow: DEFAULT_WORKFLOW, + provider: h.provider, + repoPath: "/tmp/test-repo", + }); + + assert.strictEqual(transitions, 1); + + // Issue should now have "To Test" label + const issue = await h.provider.getIssue(60); + assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`); + assert.ok(!issue.labels.includes("In Review"), "Should not have In Review"); + + // gitPull action should have been attempted + const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git"); + assert.ok(gitCmds.length > 0, "Should have run git pull"); + }); + + it("should NOT transition when PR is still open", async () => { + h.provider.seedIssue({ iid: 61, title: "Feature Z", labels: ["In Review"] }); + h.provider.setPrStatus(61, { state: "open", url: "https://example.com/pr/11" }); + + const transitions = await reviewPass({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + workflow: DEFAULT_WORKFLOW, + provider: h.provider, + repoPath: "/tmp/test-repo", + }); + + assert.strictEqual(transitions, 0); + + // Issue should still have "In Review" + const issue = await h.provider.getIssue(61); + assert.ok(issue.labels.includes("In Review")); + }); + + it("should handle multiple review issues in one pass", async () => { + h.provider.seedIssue({ iid: 70, title: "PR A", labels: ["In Review"] }); + h.provider.seedIssue({ iid: 71, title: "PR B", labels: ["In Review"] }); + h.provider.setPrStatus(70, { state: "merged", url: "https://example.com/pr/20" }); + h.provider.setPrStatus(71, { state: "merged", url: "https://example.com/pr/21" }); + + const transitions = await reviewPass({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + workflow: DEFAULT_WORKFLOW, + provider: h.provider, + repoPath: "/tmp/test-repo", + }); + + assert.strictEqual(transitions, 2); + + const issue70 = await h.provider.getIssue(70); + const issue71 = await h.provider.getIssue(71); + assert.ok(issue70.labels.includes("To Test")); + assert.ok(issue71.labels.includes("To Test")); + }); + }); + + // ========================================================================= + // Full lifecycle: dispatch → complete → review → test → done + // ========================================================================= + + describe("full lifecycle", () => { + it("developer:done → tester:pass (direct path)", async () => { + h = await createTestHarness(); + + // 1. Seed issue in To Do + h.provider.seedIssue({ iid: 100, title: "Build dashboard", labels: ["To Do"] }); + + // 2. Dispatch developer + await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 100, + issueTitle: "Build dashboard", + issueDescription: "Create the main dashboard view", + issueUrl: "https://example.com/issues/100", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + let issue = await h.provider.getIssue(100); + assert.ok(issue.labels.includes("Doing")); + + // 3. Developer completes → To Test + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "done", + issueId: 100, + summary: "Dashboard built", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + issue = await h.provider.getIssue(100); + assert.ok(issue.labels.includes("To Test"), `After dev done: ${issue.labels}`); + + // 4. Simulate tester dispatch (activate worker manually for completion) + const { activateWorker } = await import("../projects.js"); + await activateWorker(h.workspaceDir, h.groupId, "tester", { + issueId: "100", level: "medior", + }); + await h.provider.transitionLabel(100, "To Test", "Testing"); + + // 5. Tester passes → Done + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "pass", + issueId: 100, + summary: "All checks passed", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + issue = await h.provider.getIssue(100); + assert.ok(issue.labels.includes("Done"), `Final state: ${issue.labels}`); + assert.strictEqual(issue.state, "closed"); + }); + + it("developer:review → review pass → tester:pass (review path)", async () => { + h = await createTestHarness(); + + // 1. Seed issue in To Do + h.provider.seedIssue({ iid: 200, title: "Auth refactor", labels: ["To Do"] }); + + // 2. Dispatch developer + await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 200, + issueTitle: "Auth refactor", + issueDescription: "Refactor authentication system", + issueUrl: "https://example.com/issues/200", + role: "developer", + level: "senior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + // 3. Developer finishes with "review" → In Review + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "review", + issueId: 200, + summary: "PR ready for review", + prUrl: "https://example.com/pr/50", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + let issue = await h.provider.getIssue(200); + assert.ok(issue.labels.includes("In Review"), `After review: ${issue.labels}`); + + // 4. PR gets merged — review pass picks it up + h.provider.setPrStatus(200, { state: "merged", url: "https://example.com/pr/50" }); + + const transitions = await reviewPass({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + workflow: DEFAULT_WORKFLOW, + provider: h.provider, + repoPath: "/tmp/test-repo", + }); + + assert.strictEqual(transitions, 1); + issue = await h.provider.getIssue(200); + assert.ok(issue.labels.includes("To Test"), `After review pass: ${issue.labels}`); + + // 5. Tester passes → Done + const { activateWorker } = await import("../projects.js"); + await activateWorker(h.workspaceDir, h.groupId, "tester", { + issueId: "200", level: "medior", + }); + await h.provider.transitionLabel(200, "To Test", "Testing"); + + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "pass", + issueId: 200, + summary: "Auth refactor verified", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + issue = await h.provider.getIssue(200); + assert.ok(issue.labels.includes("Done"), `Final state: ${issue.labels}`); + assert.strictEqual(issue.state, "closed"); + }); + + it("developer:done → tester:fail → developer:done → tester:pass (fail cycle)", async () => { + h = await createTestHarness(); + + h.provider.seedIssue({ iid: 300, title: "Payment flow", labels: ["To Do"] }); + + // 1. Dispatch developer + await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: h.project, + issueId: 300, + issueTitle: "Payment flow", + issueDescription: "Implement payment", + issueUrl: "https://example.com/issues/300", + role: "developer", + level: "medior", + fromLabel: "To Do", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + // 2. Developer done → To Test + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "done", + issueId: 300, + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + // 3. Activate tester + transition + const { activateWorker } = await import("../projects.js"); + await activateWorker(h.workspaceDir, h.groupId, "tester", { + issueId: "300", level: "medior", + }); + await h.provider.transitionLabel(300, "To Test", "Testing"); + + // 4. Tester FAILS → To Improve + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "fail", + issueId: 300, + summary: "Validation broken", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + let issue = await h.provider.getIssue(300); + assert.ok(issue.labels.includes("To Improve"), `After fail: ${issue.labels}`); + assert.strictEqual(issue.state, "opened"); // reopened + + // 5. Developer picks up again (To Improve → Doing) + await dispatchTask({ + workspaceDir: h.workspaceDir, + agentId: "main", + groupId: h.groupId, + project: (await readProjects(h.workspaceDir)).projects[h.groupId], + issueId: 300, + issueTitle: "Payment flow", + issueDescription: "Implement payment", + issueUrl: "https://example.com/issues/300", + role: "developer", + level: "medior", + fromLabel: "To Improve", + toLabel: "Doing", + transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to), + provider: h.provider, + }); + + // 6. Developer fixes it → To Test + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "developer", + result: "done", + issueId: 300, + summary: "Fixed validation", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + issue = await h.provider.getIssue(300); + assert.ok(issue.labels.includes("To Test"), `After fix: ${issue.labels}`); + + // 7. Tester passes → Done + await activateWorker(h.workspaceDir, h.groupId, "tester", { + issueId: "300", level: "medior", + }); + await h.provider.transitionLabel(300, "To Test", "Testing"); + + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "pass", + issueId: 300, + summary: "All good now", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + issue = await h.provider.getIssue(300); + assert.ok(issue.labels.includes("Done"), `Final state: ${issue.labels}`); + assert.strictEqual(issue.state, "closed"); + }); + }); + + // ========================================================================= + // Provider call tracking + // ========================================================================= + + describe("provider call tracking", () => { + it("should track all provider interactions during completion", async () => { + h = await createTestHarness({ + workers: { + tester: { active: true, issueId: "90", level: "medior" }, + }, + }); + h.provider.seedIssue({ iid: 90, title: "Test tracking", labels: ["Testing"] }); + h.provider.resetCalls(); + + await executeCompletion({ + workspaceDir: h.workspaceDir, + groupId: h.groupId, + role: "tester", + result: "pass", + issueId: 90, + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + }); + + // Should have: getIssue (for URL), transitionLabel, closeIssue + assert.ok(h.provider.callsTo("getIssue").length >= 1, "Should call getIssue"); + assert.strictEqual(h.provider.callsTo("transitionLabel").length, 1); + assert.strictEqual(h.provider.callsTo("closeIssue").length, 1); + + // Verify transition args + const transition = h.provider.callsTo("transitionLabel")[0]; + assert.strictEqual(transition.args.issueId, 90); + assert.strictEqual(transition.args.from, "Testing"); + assert.strictEqual(transition.args.to, "Done"); + }); + }); +}); diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index 3c9ae53..a15bc0a 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -8,8 +8,11 @@ import type { StateLabel, IssueProvider } from "../providers/provider.js"; import { deactivateWorker } from "../projects.js"; import { runCommand } from "../run-command.js"; import { notify, getNotificationConfig } from "../notify.js"; +import { log as auditLog } from "../audit.js"; +import { loadConfig } from "../config/index.js"; import { DEFAULT_WORKFLOW, + Action, getCompletionRule, getNextStateDescription, getCompletionEmoji, @@ -72,18 +75,23 @@ export async function executeCompletion(opts: { const rule = getCompletionRule(workflow, role, result); if (!rule) throw new Error(`No completion rule for ${key}`); + const { timeouts } = await loadConfig(workspaceDir, projectName); let prUrl = opts.prUrl; - // Git pull (dev:done) - if (rule.gitPull) { - try { - await runCommand(["git", "pull"], { timeoutMs: 30_000, cwd: repoPath }); - } catch { /* best-effort */ } - } - - // Auto-detect PR URL (dev:done) - if (rule.detectPr && !prUrl) { - try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch { /* ignore */ } + // Execute pre-notification actions + for (const action of rule.actions) { + switch (action) { + case Action.GIT_PULL: + try { await runCommand(["git", "pull"], { timeoutMs: timeouts.gitPullMs, cwd: repoPath }); } catch (err) { + auditLog(workspaceDir, "pipeline_warning", { step: "gitPull", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); + } + break; + case Action.DETECT_PR: + if (!prUrl) { try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch (err) { + auditLog(workspaceDir, "pipeline_warning", { step: "detectPr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); + } } + break; + } } // Get issue early (for URL in notification) @@ -113,15 +121,25 @@ export async function executeCompletion(opts: { channel: channel ?? "telegram", runtime, }, - ).catch(() => { /* non-fatal */ }); + ).catch((err) => { + auditLog(workspaceDir, "pipeline_warning", { step: "notify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); + }); // Deactivate worker + transition label await deactivateWorker(workspaceDir, groupId, role); await provider.transitionLabel(issueId, rule.from as StateLabel, rule.to as StateLabel); - // Close/reopen - if (rule.closeIssue) await provider.closeIssue(issueId); - if (rule.reopenIssue) await provider.reopenIssue(issueId); + // Execute post-transition actions + for (const action of rule.actions) { + switch (action) { + case Action.CLOSE_ISSUE: + await provider.closeIssue(issueId); + break; + case Action.REOPEN_ISSUE: + await provider.reopenIssue(issueId); + break; + } + } // Build announcement using workflow-derived emoji const emoji = getCompletionEmoji(role, result); @@ -138,7 +156,7 @@ export async function executeCompletion(opts: { nextState, prUrl, issueUrl: issue.web_url, - issueClosed: rule.closeIssue, - issueReopened: rule.reopenIssue, + issueClosed: rule.actions.includes(Action.CLOSE_ISSUE), + issueReopened: rule.actions.includes(Action.REOPEN_ISSUE), }; } diff --git a/lib/services/queue-scan.ts b/lib/services/queue-scan.ts new file mode 100644 index 0000000..aef88af --- /dev/null +++ b/lib/services/queue-scan.ts @@ -0,0 +1,88 @@ +/** + * queue-scan.ts — Issue queue scanning helpers. + * + * Shared by: tick (projectTick), work-start (auto-pickup), and other consumers + * that need to find queued issues or detect roles/levels from labels. + */ +import type { Issue, StateLabel } from "../providers/provider.js"; +import type { IssueProvider } from "../providers/provider.js"; +import { getLevelsForRole, getAllLevels } from "../roles/index.js"; +import { + getQueueLabels, + getAllQueueLabels, + detectRoleFromLabel as workflowDetectRole, + type WorkflowConfig, + type Role, +} from "../workflow.js"; + +// --------------------------------------------------------------------------- +// Label detection +// --------------------------------------------------------------------------- + +export function detectLevelFromLabels(labels: string[]): string | null { + const lower = labels.map((l) => l.toLowerCase()); + + // Match role.level labels (e.g., "dev.senior", "qa.mid", "architect.junior") + for (const l of lower) { + const dot = l.indexOf("."); + if (dot === -1) continue; + const role = l.slice(0, dot); + const level = l.slice(dot + 1); + const roleLevels = getLevelsForRole(role); + if (roleLevels.includes(level)) return level; + } + + // Fallback: plain level name + const all = getAllLevels(); + return all.find((l) => lower.includes(l)) ?? null; +} + +/** + * Detect role from a label using workflow config. + */ +export function detectRoleFromLabel( + label: StateLabel, + workflow: WorkflowConfig, +): Role | null { + return workflowDetectRole(workflow, label); +} + +// --------------------------------------------------------------------------- +// Issue queue queries +// --------------------------------------------------------------------------- + +export async function findNextIssueForRole( + provider: Pick, + role: Role, + workflow: WorkflowConfig, +): Promise<{ issue: Issue; label: StateLabel } | null> { + const labels = getQueueLabels(workflow, role); + for (const label of labels) { + try { + const issues = await provider.listIssuesByLabel(label); + if (issues.length > 0) return { issue: issues[issues.length - 1], label }; + } catch { /* continue */ } + } + return null; +} + +/** + * Find next issue for any role (optional filter). Used by work_start for auto-detection. + */ +export async function findNextIssue( + provider: Pick, + role: Role | undefined, + workflow: WorkflowConfig, +): Promise<{ issue: Issue; label: StateLabel } | null> { + const labels = role + ? getQueueLabels(workflow, role) + : getAllQueueLabels(workflow); + + for (const label of labels) { + try { + const issues = await provider.listIssuesByLabel(label); + if (issues.length > 0) return { issue: issues[issues.length - 1], label }; + } catch { /* continue */ } + } + return null; +} diff --git a/lib/services/queue.ts b/lib/services/queue.ts index cceaac6..d2b2764 100644 --- a/lib/services/queue.ts +++ b/lib/services/queue.ts @@ -9,6 +9,7 @@ import { createProvider } from "../providers/index.js"; import type { Project } from "../projects.js"; import { DEFAULT_WORKFLOW, + StateType, type WorkflowConfig, type Role, } from "../workflow.js"; @@ -27,7 +28,7 @@ export function getQueueLabelsWithPriority( const labels: Array<{ label: string; priority: number; role?: Role }> = []; for (const state of Object.values(workflow.states)) { - if (state.type === "queue") { + if (state.type === StateType.QUEUE) { labels.push({ label: state.label, priority: state.priority ?? 0, diff --git a/lib/services/review.ts b/lib/services/review.ts new file mode 100644 index 0000000..d5a0e98 --- /dev/null +++ b/lib/services/review.ts @@ -0,0 +1,98 @@ +/** + * review.ts — Poll review-type states for PR status changes. + * + * Scans review states in the workflow and transitions issues + * whose PR check condition (merged/approved) is met. + * Called by the heartbeat service during its periodic sweep. + */ +import type { IssueProvider } from "../providers/provider.js"; +import { PrState } from "../providers/provider.js"; +import { + Action, + ReviewCheck, + WorkflowEvent, + StateType, + type WorkflowConfig, + type StateConfig, +} from "../workflow.js"; +import { runCommand } from "../run-command.js"; +import { log as auditLog } from "../audit.js"; + +/** + * Scan review-type states and transition issues whose PR check condition is met. + * Returns the number of transitions made. + */ +export async function reviewPass(opts: { + workspaceDir: string; + groupId: string; + workflow: WorkflowConfig; + provider: IssueProvider; + repoPath: string; + gitPullTimeoutMs?: number; +}): Promise { + const { workspaceDir, groupId, workflow, provider, repoPath, gitPullTimeoutMs = 30_000 } = opts; + let transitions = 0; + + // Find all review-type states + const reviewStates = Object.entries(workflow.states) + .filter(([, s]) => s.type === StateType.REVIEW) as [string, StateConfig][]; + + for (const [stateKey, state] of reviewStates) { + if (!state.on || !state.check) continue; + + const issues = await provider.listIssuesByLabel(state.label); + for (const issue of issues) { + const status = await provider.getPrStatus(issue.iid); + + const conditionMet = + (state.check === ReviewCheck.PR_MERGED && status.state === PrState.MERGED) || + (state.check === ReviewCheck.PR_APPROVED && (status.state === PrState.APPROVED || status.state === PrState.MERGED)); + + if (!conditionMet) continue; + + // Find the success transition (first non-BLOCKED event) + const successEvent = Object.keys(state.on).find((e) => e !== WorkflowEvent.BLOCKED); + if (!successEvent) continue; + + const transition = state.on[successEvent]; + const targetKey = typeof transition === "string" ? transition : transition.target; + const actions = typeof transition === "object" ? transition.actions : undefined; + const targetState = workflow.states[targetKey]; + if (!targetState) continue; + + // Execute transition actions + if (actions) { + for (const action of actions) { + switch (action) { + case Action.GIT_PULL: + try { await runCommand(["git", "pull"], { timeoutMs: gitPullTimeoutMs, cwd: repoPath }); } catch { /* best-effort */ } + break; + case Action.CLOSE_ISSUE: + await provider.closeIssue(issue.iid); + break; + case Action.REOPEN_ISSUE: + await provider.reopenIssue(issue.iid); + break; + } + } + } + + // Transition label + await provider.transitionLabel(issue.iid, state.label, targetState.label); + + await auditLog(workspaceDir, "review_transition", { + groupId, + issueId: issue.iid, + from: state.label, + to: targetState.label, + check: state.check, + prState: status.state, + prUrl: status.url, + }); + + transitions++; + } + } + + return transitions; +} diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 55d7b7f..d334ac2 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -11,84 +11,15 @@ import { createProvider } from "../providers/index.js"; import { selectLevel } from "../model-selector.js"; import { getWorker, getSessionForLevel, readProjects } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; -import { getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js"; +import { roleForLevel } from "../roles/index.js"; import { loadConfig } from "../config/index.js"; import { - getQueueLabels, - getAllQueueLabels, + ExecutionMode, getActiveLabel, - detectRoleFromLabel as workflowDetectRole, type WorkflowConfig, type Role, } from "../workflow.js"; - -// --------------------------------------------------------------------------- -// Shared helpers (used by tick, work-start, auto-pickup) -// --------------------------------------------------------------------------- - -export function detectLevelFromLabels(labels: string[]): string | null { - const lower = labels.map((l) => l.toLowerCase()); - - // Match role.level labels (e.g., "dev.senior", "qa.mid", "architect.junior") - for (const l of lower) { - const dot = l.indexOf("."); - if (dot === -1) continue; - const role = l.slice(0, dot); - const level = l.slice(dot + 1); - const roleLevels = getLevelsForRole(role); - if (roleLevels.includes(level)) return level; - } - - // Fallback: plain level name - const all = getAllLevels(); - return all.find((l) => lower.includes(l)) ?? null; -} - -/** - * Detect role from a label using workflow config. - */ -export function detectRoleFromLabel( - label: StateLabel, - workflow: WorkflowConfig, -): Role | null { - return workflowDetectRole(workflow, label); -} - -export async function findNextIssueForRole( - provider: Pick, - role: Role, - workflow: WorkflowConfig, -): Promise<{ issue: Issue; label: StateLabel } | null> { - const labels = getQueueLabels(workflow, role); - for (const label of labels) { - try { - const issues = await provider.listIssuesByLabel(label); - if (issues.length > 0) return { issue: issues[issues.length - 1], label }; - } catch { /* continue */ } - } - return null; -} - -/** - * Find next issue for any role (optional filter). Used by work_start for auto-detection. - */ -export async function findNextIssue( - provider: Pick, - role: Role | undefined, - workflow: WorkflowConfig, -): Promise<{ issue: Issue; label: StateLabel } | null> { - const labels = role - ? getQueueLabels(workflow, role) - : getAllQueueLabels(workflow); - - for (const label of labels) { - try { - const issues = await provider.listIssuesByLabel(label); - if (issues.length > 0) return { issue: issues[issues.length - 1], label }; - } catch { /* continue */ } - } - return null; -} +import { detectLevelFromLabels, findNextIssueForRole } from "./queue-scan.js"; // --------------------------------------------------------------------------- // projectTick @@ -146,7 +77,7 @@ export async function projectTick(opts: { const workflow = opts.workflow ?? resolvedConfig.workflow; const provider = opts.provider ?? (await createProvider({ repo: project.repo, provider: project.provider })).provider; - const roleExecution = project.roleExecution ?? "parallel"; + const roleExecution = project.roleExecution ?? ExecutionMode.PARALLEL; const enabledRoles = Object.entries(resolvedConfig.roles) .filter(([, r]) => r.enabled) .map(([id]) => id); @@ -173,7 +104,7 @@ export async function projectTick(opts: { } // Check sequential role execution: any other role must be inactive const otherRoles = enabledRoles.filter((r: string) => r !== role); - if (roleExecution === "sequential" && otherRoles.some((r: string) => getWorker(fresh, r).active)) { + if (roleExecution === ExecutionMode.SEQUENTIAL && otherRoles.some((r: string) => getWorker(fresh, r).active)) { skipped.push({ role, reason: "Sequential: other role active" }); continue; } diff --git a/lib/setup/config.ts b/lib/setup/config.ts index a112f87..a18f020 100644 --- a/lib/setup/config.ts +++ b/lib/setup/config.ts @@ -6,6 +6,7 @@ */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; +import type { ExecutionMode } from "../workflow.js"; /** * Write DevClaw plugin config to openclaw.json plugins section. @@ -21,7 +22,7 @@ import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; export async function writePluginConfig( api: OpenClawPluginApi, agentId?: string, - projectExecution?: "parallel" | "sequential", + projectExecution?: ExecutionMode, ): Promise { const config = api.runtime.config.loadConfig() as Record; diff --git a/lib/setup/index.ts b/lib/setup/index.ts index 4766585..7817b9e 100644 --- a/lib/setup/index.ts +++ b/lib/setup/index.ts @@ -14,6 +14,7 @@ import { createAgent, resolveWorkspacePath } from "./agent.js"; import { writePluginConfig } from "./config.js"; import { scaffoldWorkspace } from "./workspace.js"; import { DATA_DIR } from "./migrate-layout.js"; +import type { ExecutionMode } from "../workflow.js"; export type ModelConfig = Record>; @@ -33,7 +34,7 @@ export type SetupOpts = { /** Model overrides per role.level. Missing levels use defaults. */ models?: Record>>; /** Plugin-level project execution mode: parallel or sequential. Default: parallel. */ - projectExecution?: "parallel" | "sequential"; + projectExecution?: ExecutionMode; }; export type SetupResult = { diff --git a/lib/templates.ts b/lib/templates.ts index 8bf5d80..acebd23 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -21,10 +21,12 @@ Read the comments carefully — they often contain clarifications, decisions, or - Work in a git worktree (never switch branches in the main repo) - Run tests before completing -- Create an MR/PR to the base branch and merge it +- Create an MR/PR to the base branch - **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead. DevClaw manages issue state — auto-closing bypasses QA. -- Clean up the worktree after merging -- When done, call work_finish with role "developer", result "done", and a brief summary +- **Merge or request review:** + - Merge the PR yourself → call work_finish with result "done" + - Leave the PR open for human review → call work_finish with result "review" (the heartbeat will auto-advance when the PR is merged) +- Clean up the worktree after merging (if you merged) - If you discover unrelated bugs, call task_create to file them - Do NOT call work_start, status, health, or project_register `; @@ -144,7 +146,8 @@ Skip the orchestrator section. Follow your task message and role instructions (a When you are done, **call \`work_finish\` yourself** — do not just announce in text. -- **DEVELOPER done:** \`work_finish({ role: "developer", result: "done", projectGroupId: "", summary: "" })\` +- **DEVELOPER done (merged):** \`work_finish({ role: "developer", result: "done", projectGroupId: "", summary: "" })\` +- **DEVELOPER review (PR open):** \`work_finish({ role: "developer", result: "review", projectGroupId: "", summary: "" })\` - **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "", summary: "" })\` - **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "", summary: "" })\` - **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "", summary: "" })\` @@ -224,10 +227,12 @@ All orchestration goes through these tools. You do NOT manually manage sessions, \`\`\` Planning → To Do → Doing → To Test → Testing → Done - ↓ - To Improve → Doing (fix cycle) - ↓ - Refining (human decision) + ↓ ↑ + In Review ─────┘ (auto-advances when PR merged) + ↓ + To Improve → Doing (fix cycle) + ↓ + Refining (human decision) To Design → Designing → Planning (design complete) \`\`\` @@ -257,6 +262,7 @@ All roles (Developer, Tester, Architect) use the same level scheme. Levels descr Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically. The heartbeat service will pick up the next task on its next cycle: - Developer "done" → issue moves to "To Test" → scheduler dispatches Tester +- Developer "review" → issue moves to "In Review" → heartbeat polls PR status → auto-advances to "To Test" when merged - Tester "fail" → issue moves to "To Improve" → scheduler dispatches Developer - Tester "pass" → Done, no further dispatch - Tester "refine" / blocked → needs human input @@ -270,7 +276,7 @@ Workers receive role-specific instructions appended to their task message. These ### Heartbeats -**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers) and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json. +**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers), review polling (auto-advancing "In Review" issues when PRs are merged), and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json. ### Safety @@ -333,6 +339,9 @@ workflow: COMPLETE: target: toTest actions: [gitPull, detectPr] + REVIEW: + target: reviewing + actions: [detectPr] BLOCKED: refining toTest: type: queue @@ -370,6 +379,16 @@ workflow: color: "#f39c12" on: APPROVE: todo + reviewing: + type: review + label: In Review + color: "#c5def5" + check: prMerged + on: + APPROVED: + target: toTest + actions: [gitPull] + BLOCKED: refining done: type: terminal label: Done diff --git a/lib/testing/harness.ts b/lib/testing/harness.ts new file mode 100644 index 0000000..4807cf2 --- /dev/null +++ b/lib/testing/harness.ts @@ -0,0 +1,292 @@ +/** + * Test harness — scaffolds a temporary workspace with projects.json, + * installs a mock runCommand, and provides helpers for E2E pipeline tests. + * + * Usage: + * const h = await createTestHarness({ ... }); + * try { ... } finally { await h.cleanup(); } + */ +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { initRunCommand } from "../run-command.js"; +import { writeProjects, type ProjectsData, type Project, emptyWorkerState } from "../projects.js"; +import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js"; +import { registerBootstrapHook } from "../bootstrap-hook.js"; +import { TestProvider } from "./test-provider.js"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; + +// --------------------------------------------------------------------------- +// Bootstrap file type (mirrors OpenClaw's internal type) +// --------------------------------------------------------------------------- + +export type BootstrapFile = { + name: string; + path: string; + content?: string; + missing: boolean; +}; + +// --------------------------------------------------------------------------- +// Command interceptor +// --------------------------------------------------------------------------- + +export type CapturedCommand = { + argv: string[]; + opts: { timeoutMs: number; cwd?: string }; + /** Extracted from gateway `agent` call params, if applicable. */ + taskMessage?: string; + /** Extracted from gateway `sessions.patch` params, if applicable. */ + sessionPatch?: { key: string; model: string }; +}; + +export type CommandInterceptor = { + /** All captured commands, in order. */ + commands: CapturedCommand[]; + /** Filter commands by first argv element. */ + commandsFor(cmd: string): CapturedCommand[]; + /** Get all task messages sent via `openclaw gateway call agent`. */ + taskMessages(): string[]; + /** Get all session patches. */ + sessionPatches(): Array<{ key: string; model: string }>; + /** Reset captured commands. */ + reset(): void; +}; + +function createCommandInterceptor(): { + interceptor: CommandInterceptor; + handler: (argv: string[], opts: number | { timeoutMs: number; cwd?: string }) => Promise<{ stdout: string; stderr: string; code: number | null; signal: null; killed: false }>; +} { + const commands: CapturedCommand[] = []; + + const handler = async ( + argv: string[], + optsOrTimeout: number | { timeoutMs: number; cwd?: string }, + ) => { + const opts = typeof optsOrTimeout === "number" + ? { timeoutMs: optsOrTimeout } + : optsOrTimeout; + + const captured: CapturedCommand = { argv, opts }; + + // Parse gateway agent calls to extract task message + if (argv[0] === "openclaw" && argv[1] === "gateway" && argv[2] === "call") { + const rpcMethod = argv[3]; + const paramsIdx = argv.indexOf("--params"); + if (paramsIdx !== -1 && argv[paramsIdx + 1]) { + try { + const params = JSON.parse(argv[paramsIdx + 1]); + if (rpcMethod === "agent" && params.message) { + captured.taskMessage = params.message; + } + if (rpcMethod === "sessions.patch") { + captured.sessionPatch = { key: params.key, model: params.model }; + } + } catch { /* ignore parse errors */ } + } + } + + commands.push(captured); + + return { stdout: "{}", stderr: "", code: 0, signal: null as null, killed: false as const }; + }; + + const interceptor: CommandInterceptor = { + commands, + commandsFor(cmd: string) { + return commands.filter((c) => c.argv[0] === cmd); + }, + taskMessages() { + return commands + .filter((c) => c.taskMessage !== undefined) + .map((c) => c.taskMessage!); + }, + sessionPatches() { + return commands + .filter((c) => c.sessionPatch !== undefined) + .map((c) => c.sessionPatch!); + }, + reset() { + commands.length = 0; + }, + }; + + return { interceptor, handler }; +} + +// --------------------------------------------------------------------------- +// Test harness +// --------------------------------------------------------------------------- + +export type TestHarness = { + /** Temporary workspace directory. */ + workspaceDir: string; + /** In-memory issue provider. */ + provider: TestProvider; + /** Command interceptor — captures all runCommand calls. */ + commands: CommandInterceptor; + /** The project group ID used for test data. */ + groupId: string; + /** The project data. */ + project: Project; + /** Workflow config. */ + workflow: WorkflowConfig; + /** Write updated projects data to disk. */ + writeProjects(data: ProjectsData): Promise; + /** Read current projects data from disk. */ + readProjects(): Promise; + /** + * Write a role prompt file to the workspace. + * @param role - Role name (e.g. "developer", "tester") + * @param content - Prompt file content + * @param projectName - If provided, writes project-specific prompt; otherwise writes default. + */ + writePrompt(role: string, content: string, projectName?: string): Promise; + /** + * Simulate the agent:bootstrap hook firing for a session key. + * Registers the real hook with a mock API, fires it, returns the injected bootstrap files. + * This tests the full hook chain: session key → parse → load instructions → inject. + */ + simulateBootstrap(sessionKey: string): Promise; + /** Clean up temp directory. */ + cleanup(): Promise; +}; + +export type HarnessOptions = { + /** Project name (default: "test-project"). */ + projectName?: string; + /** Group ID (default: "-1234567890"). */ + groupId?: string; + /** Repo path (default: "/tmp/test-repo"). */ + repo?: string; + /** Base branch (default: "main"). */ + baseBranch?: string; + /** Workflow config (default: DEFAULT_WORKFLOW). */ + workflow?: WorkflowConfig; + /** Initial worker state overrides. */ + workers?: Record>; + /** Additional projects to seed. */ + extraProjects?: Record; +}; + +export async function createTestHarness(opts?: HarnessOptions): Promise { + const { + projectName = "test-project", + groupId = "-1234567890", + repo = "/tmp/test-repo", + baseBranch = "main", + workflow = DEFAULT_WORKFLOW, + workers: workerOverrides, + extraProjects, + } = opts ?? {}; + + // Create temp workspace + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-e2e-")); + const dataDir = path.join(workspaceDir, "devclaw"); + const logDir = path.join(dataDir, "log"); + await fs.mkdir(logDir, { recursive: true }); + + // Build project + const defaultWorkers: Record = { + developer: emptyWorkerState(["junior", "medior", "senior"]), + tester: emptyWorkerState(["junior", "medior", "senior"]), + architect: emptyWorkerState(["junior", "senior"]), + }; + + // Apply worker overrides + if (workerOverrides) { + for (const [role, overrides] of Object.entries(workerOverrides)) { + if (defaultWorkers[role]) { + defaultWorkers[role] = { ...defaultWorkers[role], ...overrides }; + } else { + defaultWorkers[role] = { ...emptyWorkerState([]), ...overrides }; + } + } + } + + const project: Project = { + name: projectName, + repo, + groupName: "Test Group", + deployUrl: "", + baseBranch, + deployBranch: baseBranch, + provider: "github", + workers: defaultWorkers, + }; + + const projectsData: ProjectsData = { + projects: { + [groupId]: project, + ...extraProjects, + }, + }; + + await writeProjects(workspaceDir, projectsData); + + // Install mock runCommand + const { interceptor, handler } = createCommandInterceptor(); + initRunCommand({ + runtime: { + system: { runCommandWithTimeout: handler }, + }, + } as unknown as OpenClawPluginApi); + + // Create test provider + const provider = new TestProvider({ workflow }); + + return { + workspaceDir, + provider, + commands: interceptor, + groupId, + project, + workflow, + async writeProjects(data: ProjectsData) { + await writeProjects(workspaceDir, data); + }, + async readProjects() { + const { readProjects } = await import("../projects.js"); + return readProjects(workspaceDir); + }, + async writePrompt(role: string, content: string, forProject?: string) { + const dir = forProject + ? path.join(dataDir, "projects", forProject, "prompts") + : path.join(dataDir, "prompts"); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, `${role}.md`), content, "utf-8"); + }, + async simulateBootstrap(sessionKey: string) { + // Capture the hook callback by mocking api.registerHook + let hookCallback: ((event: any) => Promise) | null = null; + const mockApi = { + registerHook(_name: string, cb: (event: any) => Promise) { + hookCallback = cb; + }, + logger: { + info() {}, + warn() {}, + error() {}, + }, + } as unknown as OpenClawPluginApi; + + registerBootstrapHook(mockApi); + if (!hookCallback) throw new Error("registerBootstrapHook did not register a callback"); + + // Build a bootstrap event matching what OpenClaw sends + const bootstrapFiles: BootstrapFile[] = []; + await hookCallback({ + sessionKey, + context: { + workspaceDir, + bootstrapFiles, + }, + }); + + return bootstrapFiles; + }, + async cleanup() { + await fs.rm(workspaceDir, { recursive: true, force: true }); + }, + }; +} diff --git a/lib/testing/index.ts b/lib/testing/index.ts new file mode 100644 index 0000000..b5c4f94 --- /dev/null +++ b/lib/testing/index.ts @@ -0,0 +1,16 @@ +/** + * testing/ — Test infrastructure for DevClaw integration tests. + * + * Exports: + * - TestProvider: In-memory IssueProvider with call tracking + * - createTestHarness: Scaffolds temp workspace + mock runCommand + */ +export { TestProvider, type ProviderCall } from "./test-provider.js"; +export { + createTestHarness, + type TestHarness, + type HarnessOptions, + type CommandInterceptor, + type CapturedCommand, + type BootstrapFile, +} from "./harness.js"; diff --git a/lib/testing/test-provider.ts b/lib/testing/test-provider.ts new file mode 100644 index 0000000..17a5331 --- /dev/null +++ b/lib/testing/test-provider.ts @@ -0,0 +1,224 @@ +/** + * TestProvider — In-memory IssueProvider for integration tests. + * + * Tracks all method calls for assertion. Issues are stored in a simple map. + * No external dependencies — pure TypeScript. + */ +import type { + IssueProvider, + Issue, + StateLabel, + IssueComment, + PrStatus, +} from "../providers/provider.js"; +import { getStateLabels } from "../workflow.js"; +import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js"; + +// --------------------------------------------------------------------------- +// Call tracking +// --------------------------------------------------------------------------- + +export type ProviderCall = + | { method: "ensureLabel"; args: { name: string; color: string } } + | { method: "ensureAllStateLabels"; args: {} } + | { method: "createIssue"; args: { title: string; description: string; label: StateLabel; assignees?: string[] } } + | { method: "listIssuesByLabel"; args: { label: StateLabel } } + | { method: "getIssue"; args: { issueId: number } } + | { method: "listComments"; args: { issueId: number } } + | { method: "transitionLabel"; args: { issueId: number; from: StateLabel; to: StateLabel } } + | { method: "closeIssue"; args: { issueId: number } } + | { method: "reopenIssue"; args: { issueId: number } } + | { method: "hasMergedMR"; args: { issueId: number } } + | { method: "getMergedMRUrl"; args: { issueId: number } } + | { method: "getPrStatus"; args: { issueId: number } } + | { method: "addComment"; args: { issueId: number; body: string } } + | { method: "healthCheck"; args: {} }; + +// --------------------------------------------------------------------------- +// TestProvider +// --------------------------------------------------------------------------- + +export class TestProvider implements IssueProvider { + /** All issues keyed by iid. */ + issues = new Map(); + /** Comments per issue. */ + comments = new Map(); + /** Labels that have been ensured. */ + labels = new Map(); + /** PR status overrides per issue. Default: { state: "closed", url: null }. */ + prStatuses = new Map(); + /** Merged MR URLs per issue. */ + mergedMrUrls = new Map(); + /** All calls, in order. */ + calls: ProviderCall[] = []; + + private nextIssueId = 1; + private workflow: WorkflowConfig; + + constructor(opts?: { workflow?: WorkflowConfig }) { + this.workflow = opts?.workflow ?? DEFAULT_WORKFLOW; + } + + // ------------------------------------------------------------------------- + // Test helpers + // ------------------------------------------------------------------------- + + /** Create an issue directly in the store (bypasses createIssue tracking). */ + seedIssue(overrides: Partial & { iid: number }): Issue { + const issue: Issue = { + iid: overrides.iid, + title: overrides.title ?? `Issue #${overrides.iid}`, + description: overrides.description ?? "", + labels: overrides.labels ?? [], + state: overrides.state ?? "opened", + web_url: overrides.web_url ?? `https://example.com/issues/${overrides.iid}`, + }; + this.issues.set(issue.iid, issue); + if (issue.iid >= this.nextIssueId) this.nextIssueId = issue.iid + 1; + return issue; + } + + /** Set PR status for an issue (used by review pass tests). */ + setPrStatus(issueId: number, status: PrStatus): void { + this.prStatuses.set(issueId, status); + } + + /** Get calls filtered by method name. */ + callsTo( + method: M, + ): Extract[] { + return this.calls.filter((c) => c.method === method) as any; + } + + /** Reset call tracking (keeps issue state). */ + resetCalls(): void { + this.calls = []; + } + + /** Full reset — clear everything. */ + reset(): void { + this.issues.clear(); + this.comments.clear(); + this.labels.clear(); + this.prStatuses.clear(); + this.mergedMrUrls.clear(); + this.calls = []; + this.nextIssueId = 1; + } + + // ------------------------------------------------------------------------- + // IssueProvider implementation + // ------------------------------------------------------------------------- + + async ensureLabel(name: string, color: string): Promise { + this.calls.push({ method: "ensureLabel", args: { name, color } }); + this.labels.set(name, color); + } + + async ensureAllStateLabels(): Promise { + this.calls.push({ method: "ensureAllStateLabels", args: {} }); + const stateLabels = getStateLabels(this.workflow); + for (const label of stateLabels) { + this.labels.set(label, "#000000"); + } + } + + async createIssue( + title: string, + description: string, + label: StateLabel, + assignees?: string[], + ): Promise { + this.calls.push({ method: "createIssue", args: { title, description, label, assignees } }); + const iid = this.nextIssueId++; + const issue: Issue = { + iid, + title, + description, + labels: [label], + state: "opened", + web_url: `https://example.com/issues/${iid}`, + }; + this.issues.set(iid, issue); + return issue; + } + + async listIssuesByLabel(label: StateLabel): Promise { + this.calls.push({ method: "listIssuesByLabel", args: { label } }); + return [...this.issues.values()].filter((i) => i.labels.includes(label)); + } + + async getIssue(issueId: number): Promise { + this.calls.push({ method: "getIssue", args: { issueId } }); + const issue = this.issues.get(issueId); + if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`); + return issue; + } + + async listComments(issueId: number): Promise { + this.calls.push({ method: "listComments", args: { issueId } }); + return this.comments.get(issueId) ?? []; + } + + async transitionLabel( + issueId: number, + from: StateLabel, + to: StateLabel, + ): Promise { + this.calls.push({ method: "transitionLabel", args: { issueId, from, to } }); + const issue = this.issues.get(issueId); + if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`); + // Remove all state labels, add the new one + const stateLabels = getStateLabels(this.workflow); + issue.labels = issue.labels.filter((l) => !stateLabels.includes(l)); + issue.labels.push(to); + } + + async closeIssue(issueId: number): Promise { + this.calls.push({ method: "closeIssue", args: { issueId } }); + const issue = this.issues.get(issueId); + if (issue) issue.state = "closed"; + } + + async reopenIssue(issueId: number): Promise { + this.calls.push({ method: "reopenIssue", args: { issueId } }); + const issue = this.issues.get(issueId); + if (issue) issue.state = "opened"; + } + + hasStateLabel(issue: Issue, expected: StateLabel): boolean { + return issue.labels.includes(expected); + } + + getCurrentStateLabel(issue: Issue): StateLabel | null { + const stateLabels = getStateLabels(this.workflow); + return stateLabels.find((l) => issue.labels.includes(l)) ?? null; + } + + async hasMergedMR(issueId: number): Promise { + this.calls.push({ method: "hasMergedMR", args: { issueId } }); + return this.mergedMrUrls.has(issueId); + } + + async getMergedMRUrl(issueId: number): Promise { + this.calls.push({ method: "getMergedMRUrl", args: { issueId } }); + return this.mergedMrUrls.get(issueId) ?? null; + } + + async getPrStatus(issueId: number): Promise { + this.calls.push({ method: "getPrStatus", args: { issueId } }); + return this.prStatuses.get(issueId) ?? { state: "closed", url: null }; + } + + async addComment(issueId: number, body: string): Promise { + this.calls.push({ method: "addComment", args: { issueId, body } }); + const existing = this.comments.get(issueId) ?? []; + existing.push({ author: "test", body, created_at: new Date().toISOString() }); + this.comments.set(issueId, existing); + } + + async healthCheck(): Promise { + this.calls.push({ method: "healthCheck", args: {} }); + return true; + } +} diff --git a/lib/tools/project-register.ts b/lib/tools/project-register.ts index 1d83d75..650627c 100644 --- a/lib/tools/project-register.ts +++ b/lib/tools/project-register.ts @@ -15,6 +15,7 @@ import { resolveRepoPath } from "../projects.js"; import { createProvider } from "../providers/index.js"; import { log as auditLog } from "../audit.js"; import { getAllRoleIds, getLevelsForRole } from "../roles/index.js"; +import { ExecutionMode } from "../workflow.js"; import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js"; import { DATA_DIR } from "../setup/migrate-layout.js"; @@ -84,7 +85,7 @@ export function createProjectRegisterTool() { }, roleExecution: { type: "string", - enum: ["parallel", "sequential"], + enum: Object.values(ExecutionMode), description: "Project-level role execution mode: parallel (DEV and QA can work simultaneously) or sequential (only one role active at a time). Defaults to parallel.", }, }, @@ -99,7 +100,7 @@ export function createProjectRegisterTool() { const baseBranch = params.baseBranch as string; const deployBranch = (params.deployBranch as string) ?? baseBranch; const deployUrl = (params.deployUrl as string) ?? ""; - const roleExecution = (params.roleExecution as "parallel" | "sequential") ?? "parallel"; + const roleExecution = (params.roleExecution as ExecutionMode) ?? ExecutionMode.PARALLEL; const workspaceDir = ctx.workspaceDir; if (!workspaceDir) { diff --git a/lib/tools/setup.ts b/lib/tools/setup.ts index 63ca82e..18b908e 100644 --- a/lib/tools/setup.ts +++ b/lib/tools/setup.ts @@ -9,6 +9,7 @@ import { jsonResult } from "openclaw/plugin-sdk"; import type { ToolContext } from "../types.js"; import { runSetup, type SetupOpts } from "../setup/index.js"; import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js"; +import { ExecutionMode } from "../workflow.js"; export function createSetupTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -51,7 +52,7 @@ export function createSetupTool(api: OpenClawPluginApi) { }, projectExecution: { type: "string", - enum: ["parallel", "sequential"], + enum: Object.values(ExecutionMode), description: "Project execution mode. Default: parallel.", }, }, @@ -68,8 +69,7 @@ export function createSetupTool(api: OpenClawPluginApi) { workspacePath: params.newAgentName ? undefined : ctx.workspaceDir, models: params.models as SetupOpts["models"], projectExecution: params.projectExecution as - | "parallel" - | "sequential" + | ExecutionMode | undefined, }); diff --git a/lib/tools/status.ts b/lib/tools/status.ts index 88aa31f..e76c10b 100644 --- a/lib/tools/status.ts +++ b/lib/tools/status.ts @@ -11,7 +11,7 @@ import { readProjects, getProject } from "../projects.js"; import { log as auditLog } from "../audit.js"; import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js"; import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js"; -import { loadWorkflow } from "../workflow.js"; +import { loadWorkflow, ExecutionMode } from "../workflow.js"; export function createStatusTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -30,7 +30,7 @@ export function createStatusTool(api: OpenClawPluginApi) { const groupId = params.projectGroupId as string | undefined; const pluginConfig = getPluginConfig(api); - const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel"; + const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL; // Load workspace-level workflow (per-project loaded inside map) const workflow = await loadWorkflow(workspaceDir); @@ -66,7 +66,7 @@ export function createStatusTool(api: OpenClawPluginApi) { return { name: project.name, groupId: pid, - roleExecution: project.roleExecution ?? "parallel", + roleExecution: project.roleExecution ?? ExecutionMode.PARALLEL, workers, queue: queueCounts, }; diff --git a/lib/tools/task-update.test.ts b/lib/tools/task-update.test.ts index 5eeb50a..7137b8f 100644 --- a/lib/tools/task-update.test.ts +++ b/lib/tools/task-update.test.ts @@ -27,10 +27,11 @@ describe("task_update tool", () => { "Done", "To Improve", "Refining", + "In Review", ]; - + // In a real test, we'd verify these against the tool's enum - assert.strictEqual(validStates.length, 8); + assert.strictEqual(validStates.length, 9); }); it("validates required parameters", () => { diff --git a/lib/tools/work-start.ts b/lib/tools/work-start.ts index df44cab..2fa708b 100644 --- a/lib/tools/work-start.ts +++ b/lib/tools/work-start.ts @@ -12,10 +12,10 @@ import type { StateLabel } from "../providers/provider.js"; import { selectLevel } from "../model-selector.js"; import { getWorker } from "../projects.js"; import { dispatchTask } from "../dispatch.js"; -import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js"; +import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/queue-scan.js"; import { getAllRoleIds, isLevelForRole } from "../roles/index.js"; import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js"; -import { loadWorkflow, getActiveLabel } from "../workflow.js"; +import { loadWorkflow, getActiveLabel, ExecutionMode } from "../workflow.js"; export function createWorkStartTool(api: OpenClawPluginApi) { return (ctx: ToolContext) => ({ @@ -70,7 +70,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) { // Check worker availability const worker = getWorker(project, role); if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`); - if ((project.roleExecution ?? "parallel") === "sequential") { + if ((project.roleExecution ?? ExecutionMode.PARALLEL) === ExecutionMode.SEQUENTIAL) { for (const [otherRole, otherWorker] of Object.entries(project.workers)) { if (otherRole !== role && otherWorker.active) { throw new Error(`Sequential roleExecution: ${otherRole.toUpperCase()} is active`); diff --git a/lib/workflow.ts b/lib/workflow.ts index 46882f7..c8ef085 100644 --- a/lib/workflow.ts +++ b/lib/workflow.ts @@ -13,14 +13,60 @@ // Types // --------------------------------------------------------------------------- -export type StateType = "queue" | "active" | "hold" | "terminal"; +/** Built-in state types. */ +export const StateType = { + QUEUE: "queue", + ACTIVE: "active", + HOLD: "hold", + TERMINAL: "terminal", + REVIEW: "review", +} as const; +export type StateType = (typeof StateType)[keyof typeof StateType]; + +/** Built-in execution modes for role and project parallelism. */ +export const ExecutionMode = { + PARALLEL: "parallel", + SEQUENTIAL: "sequential", +} as const; +export type ExecutionMode = (typeof ExecutionMode)[keyof typeof ExecutionMode]; + /** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */ export type Role = string; -export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue"; +/** Action identifier. Built-in actions listed in `Action`; custom actions are also valid strings. */ +export type TransitionAction = string; + +/** Built-in transition actions. Custom actions are also valid — these are just the ones with built-in handlers. */ +export const Action = { + GIT_PULL: "gitPull", + DETECT_PR: "detectPr", + CLOSE_ISSUE: "closeIssue", + REOPEN_ISSUE: "reopenIssue", +} as const; + +/** Built-in review check types for review states. */ +export const ReviewCheck = { + PR_APPROVED: "prApproved", + PR_MERGED: "prMerged", +} as const; +export type ReviewCheckType = (typeof ReviewCheck)[keyof typeof ReviewCheck]; + +/** Built-in workflow events. */ +export const WorkflowEvent = { + PICKUP: "PICKUP", + COMPLETE: "COMPLETE", + REVIEW: "REVIEW", + APPROVED: "APPROVED", + PASS: "PASS", + FAIL: "FAIL", + REFINE: "REFINE", + BLOCKED: "BLOCKED", + APPROVE: "APPROVE", +} as const; export type TransitionTarget = string | { target: string; actions?: TransitionAction[]; + description?: string; }; export type StateConfig = { @@ -29,6 +75,8 @@ export type StateConfig = { label: string; color: string; priority?: number; + description?: string; + check?: ReviewCheckType; on?: Record; }; @@ -40,10 +88,7 @@ export type WorkflowConfig = { export type CompletionRule = { from: string; to: string; - gitPull?: boolean; - detectPr?: boolean; - closeIssue?: boolean; - reopenIssue?: boolean; + actions: string[]; }; // --------------------------------------------------------------------------- @@ -54,84 +99,95 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { initial: "planning", states: { planning: { - type: "hold", + type: StateType.HOLD, label: "Planning", color: "#95a5a6", - on: { APPROVE: "todo" }, + on: { [WorkflowEvent.APPROVE]: "todo" }, }, todo: { - type: "queue", + type: StateType.QUEUE, role: "developer", label: "To Do", color: "#428bca", priority: 1, - on: { PICKUP: "doing" }, + on: { [WorkflowEvent.PICKUP]: "doing" }, }, doing: { - type: "active", + type: StateType.ACTIVE, role: "developer", label: "Doing", color: "#f0ad4e", on: { - COMPLETE: { target: "toTest", actions: ["gitPull", "detectPr"] }, - BLOCKED: "refining", + [WorkflowEvent.COMPLETE]: { target: "toTest", actions: [Action.GIT_PULL, Action.DETECT_PR] }, + [WorkflowEvent.REVIEW]: { target: "reviewing", actions: [Action.DETECT_PR] }, + [WorkflowEvent.BLOCKED]: "refining", }, }, toTest: { - type: "queue", + type: StateType.QUEUE, role: "tester", label: "To Test", color: "#5bc0de", priority: 2, - on: { PICKUP: "testing" }, + on: { [WorkflowEvent.PICKUP]: "testing" }, }, testing: { - type: "active", + type: StateType.ACTIVE, role: "tester", label: "Testing", color: "#9b59b6", on: { - PASS: { target: "done", actions: ["closeIssue"] }, - FAIL: { target: "toImprove", actions: ["reopenIssue"] }, - REFINE: "refining", - BLOCKED: "refining", + [WorkflowEvent.PASS]: { target: "done", actions: [Action.CLOSE_ISSUE] }, + [WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] }, + [WorkflowEvent.REFINE]: "refining", + [WorkflowEvent.BLOCKED]: "refining", }, }, toImprove: { - type: "queue", + type: StateType.QUEUE, role: "developer", label: "To Improve", color: "#d9534f", priority: 3, - on: { PICKUP: "doing" }, + on: { [WorkflowEvent.PICKUP]: "doing" }, }, refining: { - type: "hold", + type: StateType.HOLD, label: "Refining", color: "#f39c12", - on: { APPROVE: "todo" }, + on: { [WorkflowEvent.APPROVE]: "todo" }, + }, + reviewing: { + type: StateType.REVIEW, + label: "In Review", + color: "#c5def5", + check: ReviewCheck.PR_MERGED, + on: { + [WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.GIT_PULL] }, + [WorkflowEvent.BLOCKED]: "refining", + }, }, done: { - type: "terminal", + type: StateType.TERMINAL, label: "Done", color: "#5cb85c", }, toDesign: { - type: "queue", + type: StateType.QUEUE, role: "architect", label: "To Design", color: "#0075ca", priority: 1, - on: { PICKUP: "designing" }, + on: { [WorkflowEvent.PICKUP]: "designing" }, }, designing: { - type: "active", + type: StateType.ACTIVE, role: "architect", label: "Designing", color: "#d4c5f9", on: { - COMPLETE: "planning", - BLOCKED: "refining", + [WorkflowEvent.COMPLETE]: "planning", + [WorkflowEvent.BLOCKED]: "refining", }, }, }, @@ -181,7 +237,7 @@ export function getLabelColors(workflow: WorkflowConfig): Record */ export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] { return Object.values(workflow.states) - .filter((s) => s.type === "queue" && s.role === role) + .filter((s) => s.type === StateType.QUEUE && s.role === role) .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) .map((s) => s.label); } @@ -191,7 +247,7 @@ export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] { */ export function getAllQueueLabels(workflow: WorkflowConfig): string[] { return Object.values(workflow.states) - .filter((s) => s.type === "queue") + .filter((s) => s.type === StateType.QUEUE) .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) .map((s) => s.label); } @@ -201,7 +257,7 @@ export function getAllQueueLabels(workflow: WorkflowConfig): string[] { */ export function getActiveLabel(workflow: WorkflowConfig, role: Role): string { const state = Object.values(workflow.states).find( - (s) => s.type === "active" && s.role === role, + (s) => s.type === StateType.ACTIVE && s.role === role, ); if (!state) throw new Error(`No active state for role "${role}"`); return state.label; @@ -219,8 +275,8 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string { // Find queue states that transition to this active state for (const [, state] of Object.entries(workflow.states)) { - if (state.type !== "queue" || state.role !== role) continue; - const pickup = state.on?.PICKUP; + if (state.type !== StateType.QUEUE || state.role !== role) continue; + const pickup = state.on?.[WorkflowEvent.PICKUP]; if (pickup === activeStateKey) { return state.label; } @@ -235,7 +291,7 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string { */ export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Role | null { for (const state of Object.values(workflow.states)) { - if (state.label === label && state.type === "queue" && state.role) { + if (state.label === label && state.type === StateType.QUEUE && state.role) { return state.role; } } @@ -247,7 +303,7 @@ export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Ro */ export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean { return Object.values(workflow.states).some( - (s) => s.label === label && s.type === "queue", + (s) => s.label === label && s.type === StateType.QUEUE, ); } @@ -256,7 +312,7 @@ export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean { */ export function isActiveLabel(workflow: WorkflowConfig, label: string): boolean { return Object.values(workflow.states).some( - (s) => s.label === label && s.type === "active", + (s) => s.label === label && s.type === StateType.ACTIVE, ); } @@ -283,7 +339,8 @@ export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): st * Convention: "done" → COMPLETE, others → uppercase. */ function resultToEvent(result: string): string { - if (result === "done") return "COMPLETE"; + if (result === "done") return WorkflowEvent.COMPLETE; + if (result === "review") return WorkflowEvent.REVIEW; return result.toUpperCase(); } @@ -320,10 +377,7 @@ export function getCompletionRule( return { from: activeLabel, to: targetState.label, - gitPull: actions?.includes("gitPull"), - detectPr: actions?.includes("detectPr"), - closeIssue: actions?.includes("closeIssue"), - reopenIssue: actions?.includes("reopenIssue"), + actions: actions ?? [], }; } @@ -342,9 +396,10 @@ export function getNextStateDescription( const targetState = findStateByLabel(workflow, rule.to); if (!targetState) return ""; - if (targetState.type === "terminal") return "Done!"; - if (targetState.type === "hold") return "awaiting human decision"; - if (targetState.type === "queue" && targetState.role) { + if (targetState.type === StateType.TERMINAL) return "Done!"; + if (targetState.type === StateType.REVIEW) return "awaiting PR review"; + if (targetState.type === StateType.HOLD) return "awaiting human decision"; + if (targetState.type === StateType.QUEUE && targetState.role) { return `${targetState.role.toUpperCase()} queue`; } @@ -357,6 +412,7 @@ export function getNextStateDescription( */ const RESULT_EMOJI: Record = { done: "✅", + review: "👀", pass: "🎉", fail: "❌", refine: "🤔", diff --git a/package-lock.json b/package-lock.json index 54fe18b..7d31ea6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "1.2.2", "license": "MIT", "dependencies": { - "yaml": "^2.8.2" + "cockatiel": "^3.2.1", + "yaml": "^2.8.2", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^25.2.3", @@ -3769,6 +3771,15 @@ "node": ">= 8" } }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8789,7 +8800,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 01a15d3..f063e05 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "typescript": "^5.9.3" }, "dependencies": { - "yaml": "^2.8.2" + "cockatiel": "^3.2.1", + "yaml": "^2.8.2", + "zod": "^4.3.6" } }