From 25ce06e14f173d6ef22b8da47df0fdb5558f9c16 Mon Sep 17 00:00:00 2001 From: Lauren ten Hoor Date: Mon, 16 Feb 2026 14:34:08 +0800 Subject: [PATCH] feat: implement auto-merge for approved PRs and update workflow documentation --- docs/ARCHITECTURE.md | 6 +- docs/CONFIGURATION.md | 8 +- docs/MANAGEMENT.md | 4 +- docs/ONBOARDING.md | 2 +- docs/ROADMAP.md | 8 +- docs/TOOLS.md | 2 +- lib/providers/github.ts | 9 ++ lib/providers/gitlab.ts | 9 ++ lib/providers/provider.ts | 1 + lib/services/pipeline.e2e.test.ts | 51 +++++++++-- lib/services/pipeline.ts | 5 ++ lib/services/review.ts | 42 ++++++++- lib/templates.ts | 141 +++++------------------------- lib/testing/test-provider.ts | 45 ++++++++-- lib/workflow.ts | 38 ++++---- 15 files changed, 208 insertions(+), 163 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5968d98..7cb3558 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -427,7 +427,7 @@ sequenceDiagram WF-->>DEV: { announcement: "πŸ‘€ DEVELOPER REVIEW #42" } ``` -The issue sits in "In Review" until the heartbeat's **review pass** detects the PR has been merged, then automatically transitions to "To Test". +The issue sits in "In Review" until the heartbeat's **review pass** detects the PR has been approved. DevClaw then auto-merges the PR and transitions to "To Test". If the merge fails (e.g. conflicts), the issue moves to "To Improve" where a developer is auto-dispatched to resolve conflicts. ### Phase 6: TESTER pickup @@ -586,7 +586,7 @@ Every piece of data and where it lives: β”‚ design_task β†’ architect dispatch β”‚ β”‚ β”‚ β”‚ Bootstrap hook β†’ injects role instructions into worker sessionsβ”‚ -β”‚ Review pass β†’ polls PR status, auto-advances In Review β”‚ +β”‚ Review pass β†’ polls PR status, auto-merges approved PRs β”‚ β”‚ Config loader β†’ three-layer merge + Zod validation β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ↕ atomic file I/O ↕ OpenClaw CLI (plugin shells out) @@ -662,7 +662,7 @@ graph LR SD[Session dispatch
create + send via CLI] AC[Scheduling
tick queue after work_finish] RI[Role instructions
injected via bootstrap hook] - RV[Review polling
PR status β†’ auto-advance] + RV[Review polling
PR approved β†’ auto-merge] A[Audit logging] Z[Zombie cleanup] CFG[Config validation
Zod + integrity checks] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index aacddf4..fd16ad4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -153,11 +153,12 @@ workflow: type: review label: In Review color: "#c5def5" - check: prMerged + check: prApproved on: APPROVED: target: toTest - actions: [gitPull] + actions: [mergePr, gitPull] + MERGE_FAILED: toImprove BLOCKED: refining done: type: terminal @@ -188,7 +189,7 @@ workflow: | `queue` | Waiting for pickup. Must have a `role`. Has `priority` for ordering. | | `active` | Worker is currently working on it. Must have a `role`. | | `hold` | Paused, awaiting human decision. | -| `review` | Awaiting external check (PR merged/approved). Has `check` field. | +| `review` | Awaiting external check (PR approved/merged). Has `check` field. Heartbeat polls and auto-transitions. | | `terminal` | Completed. No outgoing transitions. | **Built-in actions:** @@ -197,6 +198,7 @@ workflow: |---|---| | `gitPull` | Pull latest from the base branch | | `detectPr` | Auto-detect PR URL from the issue | +| `mergePr` | Merge the PR associated with the issue. Critical in review states (aborts on failure). | | `closeIssue` | Close the issue | | `reopenIssue` | Reopen the issue | diff --git a/docs/MANAGEMENT.md b/docs/MANAGEMENT.md index c0643b3..c4d541e 100644 --- a/docs/MANAGEMENT.md +++ b/docs/MANAGEMENT.md @@ -48,8 +48,8 @@ DevClaw enforces a configurable but consistent lifecycle for every task. The def ``` Planning β†’ To Do β†’ Doing β†’ To Test β†’ Testing β†’ Done - β†˜ In Review β†’ (PR merged) β†’ To Test - β†˜ To Improve β†’ Doing (fix cycle) + β†˜ In Review β†’ (PR approved β†’ auto-merge) β†’ To Test + β†˜ To Improve β†’ Doing (merge conflict / fix cycle) β†˜ Refining β†’ (human decision) ``` diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index bdb60c8..61093aa 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -266,7 +266,7 @@ Change which model powers each level in `workflow.yaml` β€” see [Configuration]( | Session management | Plugin | Creates, reuses, and dispatches to sessions via CLI. Agent never touches session tools. | | Task completion | Plugin (`work_finish`) | Workers self-report. Scheduler dispatches next role. | | Role instructions | Plugin (bootstrap hook) | Injected into worker sessions via `agent:bootstrap` hook at session startup | -| Review polling | Plugin (heartbeat) | Auto-advances "In Review" issues when PR is merged | +| Review polling | Plugin (heartbeat) | Auto-merges and advances "In Review" issues when PR is approved | | Config validation | Plugin | Zod schemas validate `workflow.yaml` at load time | | Audit logging | Plugin | Automatic NDJSON append per tool call | | Zombie detection | Plugin | `health` checks active vs alive | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 68bc809..c252e45 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,13 +14,13 @@ The issue lifecycle is now a configurable state machine defined in `workflow.yam ``` Planning β†’ To Do β†’ Doing β†’ To Test β†’ Testing β†’ Done - β†˜ In Review β†’ (PR merged) β†’ To Test - β†˜ To Improve β†’ Doing + β†˜ In Review β†’ (PR approved β†’ auto-merge) β†’ To Test + β†˜ To Improve β†’ Doing (merge conflict / fix cycle) β†˜ Refining β†’ (human decision) To Design β†’ Designing β†’ Planning ``` -States have types (`queue`, `active`, `hold`, `review`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`). +States have types (`queue`, `active`, `hold`, `review`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `mergePr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`). ### Three-Layer Configuration @@ -42,7 +42,7 @@ Worker sessions receive role-specific instructions via the `agent:bootstrap` hoo ### In Review State and PR Polling -DEVELOPER can submit a PR for human review (`result: "review"`), which transitions the issue to `In Review`. The heartbeat's review pass polls PR status via `getPrStatus()` on the provider. When the PR is merged, the issue auto-transitions to `To Test` for TESTER pickup. +DEVELOPER can submit a PR for human review (`result: "review"`), which transitions the issue to `In Review`. The heartbeat's review pass polls PR status via `getPrStatus()` on the provider. When the PR is approved, DevClaw auto-merges via `mergePr()` and transitions to `To Test` for TESTER pickup. If the merge fails (e.g. conflicts), the issue moves to `To Improve` where a developer is auto-dispatched to resolve conflicts. ### Architect Role diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 3f829c5..7e3a162 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -244,7 +244,7 @@ Manual trigger for heartbeat: health fix + review polling + queue dispatch. Same **Three-pass sweep:** 1. **Health pass** β€” Runs `checkWorkerHealth` per project per role. Auto-fixes zombies, stale workers, orphaned state. -2. **Review pass** β€” Polls PR status for issues in "In Review" state. Transitions to "To Test" when PR is merged. +2. **Review pass** β€” Polls PR status for issues in "In Review" state. Auto-merges and transitions to "To Test" when PR is approved. If merge fails (conflicts), transitions to "To Improve" for developer to fix. 3. **Tick pass** β€” Calls `projectTick` per project. Fills free worker slots by priority (To Improve > To Test > To Do). **Execution guards:** diff --git a/lib/providers/github.ts b/lib/providers/github.ts index 7677476..d3d1adc 100644 --- a/lib/providers/github.ts +++ b/lib/providers/github.ts @@ -152,6 +152,15 @@ export class GitHubProvider implements IssueProvider { return { state: PrState.CLOSED, url: null }; } + async mergePr(issueId: number): Promise { + const pat = `#${issueId}`; + const raw = await this.gh(["pr", "list", "--state", "open", "--json", "number,title,body,url", "--limit", "20"]); + const prs = JSON.parse(raw) as Array<{ number: number; title: string; body: string; url: string }>; + const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat)); + if (!pr) throw new Error(`No open PR found for issue #${issueId}`); + await this.gh(["pr", "merge", pr.url, "--merge"]); + } + 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 603a7dc..4288b79 100644 --- a/lib/providers/gitlab.ts +++ b/lib/providers/gitlab.ts @@ -149,6 +149,15 @@ export class GitLabProvider implements IssueProvider { return { state: PrState.CLOSED, url: null }; } + async mergePr(issueId: number): Promise { + const pat = `#${issueId}`; + const raw = await this.glab(["mr", "list", "--output", "json", "--state", "opened"]); + const mrs = JSON.parse(raw) as Array<{ iid: number; title: string; description: string }>; + const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat)); + if (!mr) throw new Error(`No open MR found for issue #${issueId}`); + await this.glab(["mr", "merge", String(mr.iid)]); + } + 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 d404371..aae7f1e 100644 --- a/lib/providers/provider.ts +++ b/lib/providers/provider.ts @@ -61,6 +61,7 @@ export interface IssueProvider { hasMergedMR(issueId: number): Promise; getMergedMRUrl(issueId: number): Promise; getPrStatus(issueId: number): Promise; + mergePr(issueId: number): Promise; addComment(issueId: number, body: string): Promise; healthCheck(): Promise; } diff --git a/lib/services/pipeline.e2e.test.ts b/lib/services/pipeline.e2e.test.ts index 0689291..cdc600c 100644 --- a/lib/services/pipeline.e2e.test.ts +++ b/lib/services/pipeline.e2e.test.ts @@ -370,10 +370,10 @@ describe("E2E pipeline", () => { h = await createTestHarness(); }); - it("should transition In Review β†’ To Test when PR is merged", async () => { + it("should auto-merge and transition In Review β†’ To Test when PR is approved", 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" }); + h.provider.setPrStatus(60, { state: "approved", url: "https://example.com/pr/10" }); const transitions = await reviewPass({ workspaceDir: h.workspaceDir, @@ -390,6 +390,11 @@ describe("E2E pipeline", () => { assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`); assert.ok(!issue.labels.includes("In Review"), "Should not have In Review"); + // mergePr action should have been called + const mergeCalls = h.provider.callsTo("mergePr"); + assert.strictEqual(mergeCalls.length, 1); + assert.strictEqual(mergeCalls[0].args.issueId, 60); + // 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"); @@ -417,8 +422,8 @@ describe("E2E pipeline", () => { 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" }); + h.provider.setPrStatus(70, { state: "approved", url: "https://example.com/pr/20" }); + h.provider.setPrStatus(71, { state: "approved", url: "https://example.com/pr/21" }); const transitions = await reviewPass({ workspaceDir: h.workspaceDir, @@ -434,6 +439,40 @@ describe("E2E pipeline", () => { const issue71 = await h.provider.getIssue(71); assert.ok(issue70.labels.includes("To Test")); assert.ok(issue71.labels.includes("To Test")); + + // Both should have had mergePr called + const mergeCalls = h.provider.callsTo("mergePr"); + assert.strictEqual(mergeCalls.length, 2); + }); + + it("should transition In Review β†’ To Improve when merge fails (conflicts)", async () => { + h.provider.seedIssue({ iid: 65, title: "Conflicting PR", labels: ["In Review"] }); + h.provider.setPrStatus(65, { state: "approved", url: "https://example.com/pr/15" }); + h.provider.mergePrFailures.add(65); + + 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 have moved to "To Improve" (not "To Test") + const issue = await h.provider.getIssue(65); + assert.ok(issue.labels.includes("To Improve"), `Labels: ${issue.labels}`); + assert.ok(!issue.labels.includes("In Review"), "Should not have In Review"); + assert.ok(!issue.labels.includes("To Test"), "Should NOT have To Test"); + + // mergePr should have been attempted + const mergeCalls = h.provider.callsTo("mergePr"); + assert.strictEqual(mergeCalls.length, 1); + + // gitPull should NOT have run (aborted before git pull) + const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git"); + assert.strictEqual(gitCmds.length, 0, "Should NOT have run git pull after merge failure"); }); }); @@ -551,8 +590,8 @@ describe("E2E pipeline", () => { 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" }); + // 4. PR gets approved β€” review pass picks it up and auto-merges + h.provider.setPrStatus(200, { state: "approved", url: "https://example.com/pr/50" }); const transitions = await reviewPass({ workspaceDir: h.workspaceDir, diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index a15bc0a..01e22de 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -91,6 +91,11 @@ export async function executeCompletion(opts: { auditLog(workspaceDir, "pipeline_warning", { step: "detectPr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); } } break; + case Action.MERGE_PR: + try { await provider.mergePr(issueId); } catch (err) { + auditLog(workspaceDir, "pipeline_warning", { step: "mergePr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); + } + break; } } diff --git a/lib/services/review.ts b/lib/services/review.ts index d5a0e98..c66baa1 100644 --- a/lib/services/review.ts +++ b/lib/services/review.ts @@ -50,8 +50,10 @@ export async function reviewPass(opts: { if (!conditionMet) continue; - // Find the success transition (first non-BLOCKED event) - const successEvent = Object.keys(state.on).find((e) => e !== WorkflowEvent.BLOCKED); + // Find the success transition (first event that isn't BLOCKED or MERGE_FAILED) + const successEvent = Object.keys(state.on).find( + (e) => e !== WorkflowEvent.BLOCKED && e !== WorkflowEvent.MERGE_FAILED, + ); if (!successEvent) continue; const transition = state.on[successEvent]; @@ -60,10 +62,41 @@ export async function reviewPass(opts: { const targetState = workflow.states[targetKey]; if (!targetState) continue; - // Execute transition actions + // Execute transition actions β€” mergePr is critical (aborts on failure) + let aborted = false; if (actions) { for (const action of actions) { switch (action) { + case Action.MERGE_PR: + try { + await provider.mergePr(issue.iid); + } catch (err) { + // Merge failed β†’ fire MERGE_FAILED transition (developer fixes conflicts) + await auditLog(workspaceDir, "review_merge_failed", { + groupId, + issueId: issue.iid, + from: state.label, + error: (err as Error).message ?? String(err), + }); + const failedTransition = state.on[WorkflowEvent.MERGE_FAILED]; + if (failedTransition) { + const failedKey = typeof failedTransition === "string" ? failedTransition : failedTransition.target; + const failedState = workflow.states[failedKey]; + if (failedState) { + await provider.transitionLabel(issue.iid, state.label, failedState.label); + await auditLog(workspaceDir, "review_transition", { + groupId, + issueId: issue.iid, + from: state.label, + to: failedState.label, + reason: "merge_failed", + }); + transitions++; + } + } + aborted = true; + } + break; case Action.GIT_PULL: try { await runCommand(["git", "pull"], { timeoutMs: gitPullTimeoutMs, cwd: repoPath }); } catch { /* best-effort */ } break; @@ -74,9 +107,12 @@ export async function reviewPass(opts: { await provider.reopenIssue(issue.iid); break; } + if (aborted) break; } } + if (aborted) continue; // skip normal transition, move to next issue + // Transition label await provider.transitionLabel(issue.iid, state.label, targetState.label); diff --git a/lib/templates.ts b/lib/templates.ts index acebd23..57f4d1a 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -2,6 +2,9 @@ * Shared templates for workspace files. * Used by setup and project_register. */ +import YAML from "yaml"; +import { DEFAULT_WORKFLOW } from "./workflow.js"; +import { ROLE_REGISTRY } from "./roles/registry.js"; export const DEFAULT_DEV_INSTRUCTIONS = `# DEVELOPER Worker Instructions @@ -25,7 +28,7 @@ Read the comments carefully β€” they often contain clarifications, decisions, or - **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. - **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) + - Leave the PR open for human review β†’ call work_finish with result "review" (the heartbeat will auto-merge when approved and advance to testing) - 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 @@ -228,9 +231,9 @@ All orchestration goes through these tools. You do NOT manually manage sessions, \`\`\` Planning β†’ To Do β†’ Doing β†’ To Test β†’ Testing β†’ Done ↓ ↑ - In Review β”€β”€β”€β”€β”€β”˜ (auto-advances when PR merged) + In Review β”€β”€β”€β”€β”€β”˜ (auto-merges when PR approved) ↓ - To Improve β†’ Doing (fix cycle) + To Improve β†’ Doing (merge conflict / fix cycle) ↓ Refining (human decision) @@ -262,7 +265,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 +- Developer "review" β†’ issue moves to "In Review" β†’ heartbeat polls PR status β†’ auto-merges and advances to "To Test" when approved (merge conflicts β†’ "To Improve" for developer to fix) - Tester "fail" β†’ issue moves to "To Improve" β†’ scheduler dispatches Developer - Tester "pass" β†’ Done, no further dispatch - Tester "refine" / blocked β†’ needs human input @@ -292,121 +295,19 @@ export const HEARTBEAT_MD_TEMPLATE = `# HEARTBEAT.md Do nothing. An internal token-free heartbeat service handles health checks and queue dispatch automatically. `; -export const WORKFLOW_YAML_TEMPLATE = `# DevClaw workflow configuration -# Modify values to customize. Copy to devclaw/projects//workflow.yaml for project-specific overrides. +/** + * Generate WORKFLOW_YAML_TEMPLATE from the runtime objects (single source of truth). + */ +function buildWorkflowYaml(): string { + const roles: Record }> = {}; + for (const [id, config] of Object.entries(ROLE_REGISTRY)) { + roles[id] = { models: { ...config.models } }; + } -roles: - developer: - models: - junior: anthropic/claude-haiku-4-5 - medior: anthropic/claude-sonnet-4-5 - senior: anthropic/claude-opus-4-6 - tester: - models: - junior: anthropic/claude-haiku-4-5 - medior: anthropic/claude-sonnet-4-5 - senior: anthropic/claude-opus-4-6 - architect: - models: - junior: anthropic/claude-sonnet-4-5 - senior: anthropic/claude-opus-4-6 - # Disable a role entirely: - # architect: false + const header = + "# DevClaw workflow configuration\n" + + "# Modify values to customize. Copy to devclaw/projects//workflow.yaml for project-specific overrides.\n\n"; + return header + YAML.stringify({ roles, workflow: DEFAULT_WORKFLOW }); +} -workflow: - initial: planning - states: - planning: - type: hold - label: Planning - color: "#95a5a6" - on: - APPROVE: todo - todo: - type: queue - role: developer - label: To Do - color: "#428bca" - priority: 1 - on: - PICKUP: doing - doing: - type: active - role: developer - label: Doing - color: "#f0ad4e" - on: - COMPLETE: - target: toTest - actions: [gitPull, detectPr] - REVIEW: - target: reviewing - actions: [detectPr] - BLOCKED: refining - toTest: - type: queue - role: tester - label: To Test - color: "#5bc0de" - priority: 2 - on: - PICKUP: testing - testing: - type: active - role: tester - label: Testing - color: "#9b59b6" - on: - PASS: - target: done - actions: [closeIssue] - FAIL: - target: toImprove - actions: [reopenIssue] - REFINE: refining - BLOCKED: refining - toImprove: - type: queue - role: developer - label: To Improve - color: "#d9534f" - priority: 3 - on: - PICKUP: doing - refining: - type: hold - label: Refining - 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 - color: "#5cb85c" - toDesign: - type: queue - role: architect - label: To Design - color: "#0075ca" - priority: 1 - on: - PICKUP: designing - designing: - type: active - role: architect - label: Designing - color: "#d4c5f9" - on: - COMPLETE: planning - BLOCKED: refining -`; +export const WORKFLOW_YAML_TEMPLATE = buildWorkflowYaml(); diff --git a/lib/testing/test-provider.ts b/lib/testing/test-provider.ts index 17a5331..c20f6f1 100644 --- a/lib/testing/test-provider.ts +++ b/lib/testing/test-provider.ts @@ -21,16 +21,28 @@ import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js"; 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: "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: "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: "mergePr"; args: { issueId: number } } | { method: "addComment"; args: { issueId: number; body: string } } | { method: "healthCheck"; args: {} }; @@ -49,6 +61,8 @@ export class TestProvider implements IssueProvider { prStatuses = new Map(); /** Merged MR URLs per issue. */ mergedMrUrls = new Map(); + /** Issue IDs where mergePr should fail (simulates merge conflicts). */ + mergePrFailures = new Set(); /** All calls, in order. */ calls: ProviderCall[] = []; @@ -71,7 +85,8 @@ export class TestProvider implements IssueProvider { description: overrides.description ?? "", labels: overrides.labels ?? [], state: overrides.state ?? "opened", - web_url: overrides.web_url ?? `https://example.com/issues/${overrides.iid}`, + 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; @@ -102,6 +117,7 @@ export class TestProvider implements IssueProvider { this.labels.clear(); this.prStatuses.clear(); this.mergedMrUrls.clear(); + this.mergePrFailures.clear(); this.calls = []; this.nextIssueId = 1; } @@ -129,7 +145,10 @@ export class TestProvider implements IssueProvider { label: StateLabel, assignees?: string[], ): Promise { - this.calls.push({ method: "createIssue", args: { title, description, label, assignees } }); + this.calls.push({ + method: "createIssue", + args: { title, description, label, assignees }, + }); const iid = this.nextIssueId++; const issue: Issue = { iid, @@ -210,10 +229,26 @@ export class TestProvider implements IssueProvider { return this.prStatuses.get(issueId) ?? { state: "closed", url: null }; } + async mergePr(issueId: number): Promise { + this.calls.push({ method: "mergePr", args: { issueId } }); + if (this.mergePrFailures.has(issueId)) { + throw new Error(`Merge conflict: cannot merge PR for issue #${issueId}`); + } + // Simulate successful merge β€” update PR status to merged + const existing = this.prStatuses.get(issueId); + if (existing) { + this.prStatuses.set(issueId, { state: "merged", url: existing.url }); + } + } + 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() }); + existing.push({ + author: "test", + body, + created_at: new Date().toISOString(), + }); this.comments.set(issueId, existing); } diff --git a/lib/workflow.ts b/lib/workflow.ts index c8ef085..e0d46a3 100644 --- a/lib/workflow.ts +++ b/lib/workflow.ts @@ -39,6 +39,7 @@ export type TransitionAction = string; export const Action = { GIT_PULL: "gitPull", DETECT_PR: "detectPr", + MERGE_PR: "mergePr", CLOSE_ISSUE: "closeIssue", REOPEN_ISSUE: "reopenIssue", } as const; @@ -56,6 +57,7 @@ export const WorkflowEvent = { COMPLETE: "COMPLETE", REVIEW: "REVIEW", APPROVED: "APPROVED", + MERGE_FAILED: "MERGE_FAILED", PASS: "PASS", FAIL: "FAIL", REFINE: "REFINE", @@ -98,6 +100,7 @@ export type CompletionRule = { export const DEFAULT_WORKFLOW: WorkflowConfig = { initial: "planning", states: { + // ── Main pipeline (happy path) ────────────────────────────── planning: { type: StateType.HOLD, label: "Planning", @@ -123,6 +126,17 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { [WorkflowEvent.BLOCKED]: "refining", }, }, + reviewing: { + type: StateType.REVIEW, + label: "In Review", + color: "#c5def5", + check: ReviewCheck.PR_APPROVED, + on: { + [WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.MERGE_PR, Action.GIT_PULL] }, + [WorkflowEvent.MERGE_FAILED]: "toImprove", + [WorkflowEvent.BLOCKED]: "refining", + }, + }, toTest: { type: StateType.QUEUE, role: "tester", @@ -143,6 +157,13 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { [WorkflowEvent.BLOCKED]: "refining", }, }, + done: { + type: StateType.TERMINAL, + label: "Done", + color: "#5cb85c", + }, + + // ── Side paths (loops back into main pipeline) ────────────── toImprove: { type: StateType.QUEUE, role: "developer", @@ -157,21 +178,8 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { color: "#f39c12", 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: StateType.TERMINAL, - label: "Done", - color: "#5cb85c", - }, + + // ── Architect track ───────────────────────────────────────── toDesign: { type: StateType.QUEUE, role: "architect",