From 2c8026525861ff68d8ca3cfda61bef2679bb9f9f Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 19:46:02 -0800 Subject: [PATCH] Add bd exec bridge and mutation API routes with tests --- .beads/issues.jsonl | 10 +- package.json | 2 +- src/app/api/beads/_shared.ts | 51 +++++ src/app/api/beads/close/route.ts | 5 + src/app/api/beads/comment/route.ts | 5 + src/app/api/beads/create/route.ts | 5 + src/app/api/beads/reopen/route.ts | 5 + src/app/api/beads/update/route.ts | 5 + src/lib/bd-path.ts | 78 ++++++++ src/lib/bridge.ts | 163 ++++++++++++++++ src/lib/mutations.ts | 295 +++++++++++++++++++++++++++++ tests/api/mutations-routes.test.ts | 55 ++++++ tests/lib/bd-path.test.ts | 43 +++++ tests/lib/bridge.test.ts | 86 +++++++++ tests/lib/mutations.test.ts | 101 ++++++++++ 15 files changed, 904 insertions(+), 5 deletions(-) create mode 100644 src/app/api/beads/_shared.ts create mode 100644 src/app/api/beads/close/route.ts create mode 100644 src/app/api/beads/comment/route.ts create mode 100644 src/app/api/beads/create/route.ts create mode 100644 src/app/api/beads/reopen/route.ts create mode 100644 src/app/api/beads/update/route.ts create mode 100644 src/lib/bd-path.ts create mode 100644 src/lib/bridge.ts create mode 100644 src/lib/mutations.ts create mode 100644 tests/api/mutations-routes.test.ts create mode 100644 tests/lib/bd-path.test.ts create mode 100644 tests/lib/bridge.test.ts create mode 100644 tests/lib/mutations.test.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a1b22ff..46a9cb9 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -3,12 +3,13 @@ {"id":"bb-29x.2","title":"Implement API integration tests for read, mutate, and SSE routes","description":"Validate route contracts and interaction boundaries across read/write/realtime layers.","acceptance_criteria":"Integration suite verifies route behavior and error semantics.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:17.4912736-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:17.4912736-08:00","labels":["integration","tests"],"dependencies":[{"issue_id":"bb-29x.2","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:17.4923012-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.2","depends_on_id":"bb-29x.1","type":"blocks","created_at":"2026-02-11T17:12:38.9423299-08:00","created_by":"zenchantlive"}]} {"id":"bb-29x.3","title":"Record parser and realtime performance baseline against PRD targets","description":"Measure parse latency and update propagation using realistic sample sizes and document outcomes.","acceptance_criteria":"Performance report exists with methodology and observed timings.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:18.3210495-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:18.3210495-08:00","labels":["benchmark","perf"],"dependencies":[{"issue_id":"bb-29x.3","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:18.3220949-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.3","depends_on_id":"bb-29x.2","type":"blocks","created_at":"2026-02-11T17:12:39.4534943-08:00","created_by":"zenchantlive"}]} {"id":"bb-29x.4","title":"Document operational runbook and boundary rationale","description":"Write architecture docs covering scanner policy, bd bridge behavior, and consistency guardrails for future maintainers.","acceptance_criteria":"Runbook documents startup, troubleshooting, and boundary rules.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:19.1385778-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:19.1385778-08:00","labels":["docs","runbook"],"dependencies":[{"issue_id":"bb-29x.4","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:19.1402086-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.4","depends_on_id":"bb-29x.2","type":"blocks","created_at":"2026-02-11T17:12:39.9591458-08:00","created_by":"zenchantlive"}]} +{"id":"bb-3pr","title":"Smoke test mutation lifecycle 2","description":"Temporary issue for API mutation smoke test","status":"closed","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T19:44:10.9737485-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:44:16.4912473-08:00","closed_at":"2026-02-11T19:44:16.4912473-08:00","close_reason":"Cleanup after API smoke test","labels":["api","smoke"],"comments":[{"id":1,"issue_id":"bb-3pr","author":"zenchantlive","text":"Smoke test comment via API route","created_at":"2026-02-12T03:44:13Z"},{"id":2,"issue_id":"bb-3pr","author":"zenchantlive","text":"Smoke test reopen","created_at":"2026-02-12T03:44:15Z"}]} {"id":"bb-6aj","title":"Project Registry and Multi-Project Scanner","description":"Support multiple Windows project roots using profile-scoped registry storage and safe discovery scanning tuned for developer machines.","acceptance_criteria":"Projects can be added/removed/listed and discovered via scanner with deterministic normalization.","status":"open","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:47.7205517-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:47.7205517-08:00","labels":["multi-project","scanner"],"dependencies":[{"issue_id":"bb-6aj","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T17:12:19.6374139-08:00","created_by":"zenchantlive"}]} {"id":"bb-6aj.1","title":"Persist project registry in %USERPROFILE%\\\\.beadboard\\\\projects.json","description":"Implement read/write management for registry file in user profile path, isolated from repository files and safe for local machine usage.","acceptance_criteria":"Registry file is created lazily and survives app restarts.","status":"closed","priority":0,"issue_type":"task","assignee":"agent-a","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:48.5403111-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:53:17.2085722-08:00","closed_at":"2026-02-11T17:53:17.2085722-08:00","close_reason":"Implemented %USERPROFILE%/.beadboard/projects.json registry persistence with Windows-safe normalization and dedupe.","labels":["config","registry"],"dependencies":[{"issue_id":"bb-6aj.1","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:48.5419102-08:00","created_by":"zenchantlive"}]} {"id":"bb-6aj.2","title":"Implement registry API for add/remove/list operations","description":"Expose robust API endpoints with path validation and normalized identity checks to prevent duplicates.","acceptance_criteria":"API supports add, remove, list and returns clear validation errors.","status":"closed","priority":0,"issue_type":"task","assignee":"agent-a","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:49.3542564-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:53:23.9298353-08:00","closed_at":"2026-02-11T17:53:23.9298353-08:00","close_reason":"Implemented /api/projects GET/POST/DELETE with validation, normalization, and registry integration.","labels":["api","registry"],"dependencies":[{"issue_id":"bb-6aj.2","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:49.3558158-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.2","depends_on_id":"bb-6aj.1","type":"blocks","created_at":"2026-02-11T17:12:26.7117348-08:00","created_by":"zenchantlive"}]} {"id":"bb-6aj.3","title":"Build scanner with profile-root default and depth/ignore controls","description":"Scan %USERPROFILE% and user-defined roots for .beads directories with bounded recursion and ignore patterns to protect performance.","acceptance_criteria":"Scanner discovers projects without traversing entire drives by default.","status":"open","priority":0,"issue_type":"task","assignee":"agent-c","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:50.1925005-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:43:32.4095636-08:00","labels":["performance","scanner"],"dependencies":[{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:50.1940841-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj.1","type":"blocks","created_at":"2026-02-11T17:12:27.2225981-08:00","created_by":"zenchantlive"}]} {"id":"bb-6aj.3.1","title":"Add explicit full-drive scan mode for C:/D: by user action","description":"Provide an opt-in scan mode for full drive enumeration while retaining safe defaults and progress reporting expectations.","acceptance_criteria":"Full-drive scan is only activated explicitly, never by default startup logic.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.0244174-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:51.0244174-08:00","labels":["optional","scanner"],"dependencies":[{"issue_id":"bb-6aj.3.1","depends_on_id":"bb-6aj.3","type":"parent-child","created_at":"2026-02-11T17:11:51.0259617-08:00","created_by":"zenchantlive"}]} -{"id":"bb-6aj.4","title":"Implement aggregate project issue context model","description":"Define normalized project identity payload attached to every issue for cross-project Kanban, timeline, and session views.","acceptance_criteria":"Aggregated read output always includes stable project metadata.","status":"in_progress","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.8518922-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:31:12.4614879-08:00","labels":["aggregation","data-model"],"dependencies":[{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:51.8534893-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:27.7270195-08:00","created_by":"zenchantlive"}]} +{"id":"bb-6aj.4","title":"Implement aggregate project issue context model","description":"Define normalized project identity payload attached to every issue for cross-project Kanban, timeline, and session views.","acceptance_criteria":"Aggregated read output always includes stable project metadata.","status":"closed","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.8518922-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:21.5826669-08:00","closed_at":"2026-02-11T19:45:21.5826669-08:00","close_reason":"Added project context model and attached to read issues.","labels":["aggregation","data-model"],"dependencies":[{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:51.8534893-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:27.7270195-08:00","created_by":"zenchantlive"}]} {"id":"bb-92d","title":"Foundation and Read/Write Boundary","description":"Establish the Windows-native Next.js foundation, canonical Beads schema handling, and strict data boundaries: read from JSONL, write only via bd.exe. This epic defines the non-negotiable invariants that all later work must preserve.","acceptance_criteria":"App boots on Windows, schema/parser contracts exist, and no direct issues.jsonl write path exists in code.","status":"closed","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:41.0756295-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:28:27.8108066-08:00","closed_at":"2026-02-11T17:28:27.8108066-08:00","close_reason":"Completed foundation milestone: bootstrap, licensing/docs, schema contracts, parser, windows path normalization, and write-boundary guardrails.","labels":["beadboard","foundation","windows"]} {"id":"bb-92d.1","title":"Bootstrap Next.js 15 + React 19 + TypeScript strict","description":"Initialize project scaffold with strict TypeScript, App Router baseline, and repeatable scripts for lint/typecheck/test in PowerShell.","acceptance_criteria":"npm install and dev startup work on Windows; strict type checking enabled.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:41.9363647-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:23:14.0089901-08:00","closed_at":"2026-02-11T17:23:14.0089901-08:00","close_reason":"Bootstrapped Next.js 15 + React 19 + strict TypeScript; install/typecheck/dev startup verified on Windows.","labels":["foundation","nextjs"],"dependencies":[{"issue_id":"bb-92d.1","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:41.9379355-08:00","created_by":"zenchantlive"}]} {"id":"bb-92d.2","title":"Add MIT license and baseline repository docs","description":"Add LICENSE and baseline docs that state Windows-native support, read/write boundaries, and required runtime dependencies.","acceptance_criteria":"MIT license present and docs describe core architecture constraints.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:42.7699961-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:23:50.7519159-08:00","closed_at":"2026-02-11T17:23:50.7519159-08:00","close_reason":"Added MIT license and baseline repository documentation with architecture boundary rules.","labels":["docs","license"],"dependencies":[{"issue_id":"bb-92d.2","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:42.7715653-08:00","created_by":"zenchantlive"}]} @@ -24,6 +25,7 @@ {"id":"bb-bc4.3","title":"Redesign tokenized theme and visual hierarchy","description":"Upgrade visual system quality using semantic tokens for surface/text/status/priority states, stronger typography hierarchy, and improved contrast. Move away from flat/basic palette while preserving clarity and performance.","acceptance_criteria":"UI theme shows clear hierarchy and contrast, aligns with premium demo quality expectations, and remains consistent across components.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:44.8548956-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:19.0348391-08:00","closed_at":"2026-02-11T18:59:19.0348391-08:00","close_reason":"Redesigned semantic tokens/theme contrast and hierarchy to improve production visual quality.","labels":["design-system","theme","tokens"],"dependencies":[{"issue_id":"bb-bc4.3","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:44.8564376-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.3","depends_on_id":"bb-bc4.1","type":"blocks","created_at":"2026-02-11T18:50:44.8606805-08:00","created_by":"zenchantlive"}]} {"id":"bb-bc4.4","title":"Implement mobile/tablet detail panel interaction model","description":"Adapt detail panel behavior for small screens (overlay or drawer model) with safe viewport sizing, accessible dismissal, and non-destructive navigation. Desktop retains efficient side-panel behavior.","acceptance_criteria":"Detail view is usable on mobile/tablet and does not trap or obscure board interaction irrecoverably.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:45.8342573-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:19.8911935-08:00","closed_at":"2026-02-11T18:59:19.8911935-08:00","close_reason":"Implemented mobile detail overlay flow while preserving desktop sticky side-detail behavior.","labels":["detail-panel","mobile","ux"],"dependencies":[{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:45.8360334-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4.2","type":"blocks","created_at":"2026-02-11T18:51:10.0929812-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4.3","type":"blocks","created_at":"2026-02-11T18:51:10.9352149-08:00","created_by":"zenchantlive"}]} {"id":"bb-bc4.5","title":"Playwright multi-breakpoint visual verification","description":"Capture and review before/after screenshots at 390x844, 768x1024, and 1440x900 to validate reachability, clipping, control usability, and detail-panel behavior. Store artifacts under artifacts/ with explicit naming conventions.","acceptance_criteria":"Required six screenshots exist (before/after x 3 breakpoints) and observations confirm responsive/visual acceptance criteria.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:47.0018379-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:20.7427588-08:00","closed_at":"2026-02-11T18:59:20.7427588-08:00","close_reason":"Captured required Playwright before/after screenshots at mobile/tablet/desktop and validated layout usability.","labels":["playwright","verification","visual"],"dependencies":[{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:47.0034039-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4.4","type":"blocks","created_at":"2026-02-11T18:51:11.7817934-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4.3","type":"blocks","created_at":"2026-02-11T18:51:12.6236762-08:00","created_by":"zenchantlive"}]} +{"id":"bb-bq6","title":"Smoke test mutation lifecycle","description":"Temporary issue for API mutation smoke test","status":"open","priority":3,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T19:43:52.1686473-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:43:52.1686473-08:00","labels":["api","smoke"]} {"id":"bb-bvn","title":"Dependency Graph (React Flow)","description":"Visualize issue relationships and blocked chains through an interactive graph backed by parsed dependency edges.","acceptance_criteria":"Graph renders dependencies correctly and supports navigation to issue details.","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:09.2057278-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:09.2057278-08:00","labels":["graph","react-flow"],"dependencies":[{"issue_id":"bb-bvn","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:22.6642419-08:00","created_by":"zenchantlive"}]} {"id":"bb-bvn.1","title":"Parse dependency edges and build adjacency structures","description":"Extract edges for blocks, parent, relates_to, duplicates, and supersedes to support graph rendering and analysis.","acceptance_criteria":"Adjacency output is complete and consistent for all supported edge types.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.0434044-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:10.0434044-08:00","labels":["graph","parser"],"dependencies":[{"issue_id":"bb-bvn.1","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.0449367-08:00","created_by":"zenchantlive"}]} {"id":"bb-bvn.2","title":"Implement React Flow graph view with pan/zoom/select interactions","description":"Render nodes and edges with interactive navigation and issue selection integration.","acceptance_criteria":"Users can pan, zoom, and select nodes to inspect linked issue context.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.8683725-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:10.8683725-08:00","labels":["graph","ui"],"dependencies":[{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.8694189-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn.1","type":"blocks","created_at":"2026-02-11T17:12:36.8736785-08:00","created_by":"zenchantlive"}]} @@ -47,8 +49,8 @@ {"id":"bb-xhm.2","title":"Implement snapshot diffing for derived timeline events","description":"Compare periodic snapshots and watcher updates to infer meaningful change events without requiring write interception.","acceptance_criteria":"Diff engine emits deterministic event records for relevant field changes.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:07.5007059-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:07.5007059-08:00","labels":["diff","timeline"],"dependencies":[{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:07.501756-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm.1","type":"blocks","created_at":"2026-02-11T17:12:35.3430513-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-tpc.2","type":"blocks","created_at":"2026-02-11T17:12:35.8495336-08:00","created_by":"zenchantlive"}]} {"id":"bb-xhm.3","title":"Build timeline UI with date grouping and project/assignee/event filters","description":"Render reverse-chronological feed suitable for morning review workflows with practical filter controls.","acceptance_criteria":"Timeline view supports grouping and filter combinations with acceptable performance.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:08.3834905-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:08.3834905-08:00","labels":["timeline","ui"],"dependencies":[{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:08.3851144-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm.2","type":"blocks","created_at":"2026-02-11T17:12:36.3627477-08:00","created_by":"zenchantlive"}]} {"id":"bb-ymg","title":"CLI Write-Back via bd.exe","description":"Enable safe issue mutations from UI by routing all write operations through bd.exe and reflecting results through realtime reconciliation.","acceptance_criteria":"No direct JSONL writes exist; all mutations use bd commands and recover cleanly from failures.","status":"open","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:00.9164956-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:00.9164956-08:00","labels":["bd-cli","mutation"],"dependencies":[{"issue_id":"bb-ymg","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:21.1512868-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:21.6536312-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.1","title":"Implement bd bridge using child_process.execFile with project-scoped cwd","description":"Wrap bd execution with command argument safety, Windows path compatibility, stdout/stderr parsing, and project-specific current working directory.","acceptance_criteria":"Bridge executes supported bd commands and returns structured result/error payloads.","status":"in_progress","priority":0,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:01.7327732-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:35:15.8003769-08:00","labels":["bridge","execfile"],"dependencies":[{"issue_id":"bb-ymg.1","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:01.7343468-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.1","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:32.3039711-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.1.1","title":"Resolve bd.exe location from PATH and configuration fallback","description":"Add detection logic for bd executable and actionable errors when not found, including setup guidance.","acceptance_criteria":"Missing bd path returns clear setup instructions and diagnostics.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:02.5593205-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:02.5593205-08:00","labels":["bridge","setup"],"dependencies":[{"issue_id":"bb-ymg.1.1","depends_on_id":"bb-ymg.1","type":"parent-child","created_at":"2026-02-11T17:12:02.5603636-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.2","title":"Implement mutation API for create/update/close/reopen/comment operations","description":"Expose strict server-side mutation endpoints translating UI actions to corresponding bd commands with validated arguments.","acceptance_criteria":"All required mutation operations execute via bd and return normalized responses.","status":"open","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:03.3757503-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:03.3757503-08:00","labels":["api","mutation"],"dependencies":[{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:03.377343-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1","type":"blocks","created_at":"2026-02-11T17:12:32.810993-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1.1","type":"blocks","created_at":"2026-02-11T17:12:33.313807-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.1","title":"Implement bd bridge using child_process.execFile with project-scoped cwd","description":"Wrap bd execution with command argument safety, Windows path compatibility, stdout/stderr parsing, and project-specific current working directory.","acceptance_criteria":"Bridge executes supported bd commands and returns structured result/error payloads.","notes":"Implemented src/lib/bridge.ts with execFile-based bd runner, project-scoped cwd, timeout support, structured command result payload, and failure classification (not_found, timeout, bad_args, non_zero_exit, unknown). Added RED-\u003eGREEN tests in tests/lib/bridge.test.ts.","status":"closed","priority":0,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:01.7327732-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:16.7478549-08:00","closed_at":"2026-02-11T19:45:16.7478549-08:00","close_reason":"Bridge implemented with structured result/error classification and project-scoped execFile command execution; tests added.","labels":["bridge","execfile"],"dependencies":[{"issue_id":"bb-ymg.1","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:01.7343468-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.1","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:32.3039711-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.1.1","title":"Resolve bd.exe location from PATH and configuration fallback","description":"Add detection logic for bd executable and actionable errors when not found, including setup guidance.","acceptance_criteria":"Missing bd path returns clear setup instructions and diagnostics.","notes":"Implemented src/lib/bd-path.ts executable resolution with config-first then PATH lookup (bd.exe/bd.cmd/bd.bat/bd), plus actionable setup guidance when missing. Added tests/lib/bd-path.test.ts for success/failure cases.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:02.5593205-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:44:57.3720854-08:00","closed_at":"2026-02-11T19:44:57.3720854-08:00","close_reason":"Executable resolution implemented with config/PATH fallback and actionable missing-bd guidance; tests added.","labels":["bridge","setup"],"dependencies":[{"issue_id":"bb-ymg.1.1","depends_on_id":"bb-ymg.1","type":"parent-child","created_at":"2026-02-11T17:12:02.5603636-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.2","title":"Implement mutation API for create/update/close/reopen/comment operations","description":"Expose strict server-side mutation endpoints translating UI actions to corresponding bd commands with validated arguments.","acceptance_criteria":"All required mutation operations execute via bd and return normalized responses.","notes":"Implemented mutation validation/mapping/execution layer in src/lib/mutations.ts and App Router endpoints: /api/beads/create|update|close|reopen|comment. Added payload validation tests, route validation tests, and smoke-tested create/update/comment/close/reopen lifecycle via API.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:03.3757503-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:26.3234246-08:00","closed_at":"2026-02-11T19:45:26.3234246-08:00","close_reason":"Mutation API implemented for create/update/close/reopen/comment with payload validation, command mapping, normalized error shape, and verified smoke lifecycle via API.","labels":["api","mutation"],"dependencies":[{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:03.377343-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1","type":"blocks","created_at":"2026-02-11T17:12:32.810993-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1.1","type":"blocks","created_at":"2026-02-11T17:12:33.313807-08:00","created_by":"zenchantlive"}]} {"id":"bb-ymg.3","title":"Add optimistic updates with rollback and SSE reconciliation","description":"Apply immediate UI updates for responsiveness, rollback on command failure, and reconcile with watcher-triggered authoritative state updates.","acceptance_criteria":"Failed mutations restore previous UI state and emit meaningful error feedback.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:04.1956393-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:04.1956393-08:00","labels":["optimistic","state"],"dependencies":[{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:04.1966728-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:33.8246167-08:00","created_by":"zenchantlive"}]} {"id":"bb-ymg.4","title":"Implement drag-and-drop status transitions mapped to bd commands","description":"Map card moves to valid status transitions and use close/reopen semantics where applicable instead of direct file manipulation.","acceptance_criteria":"DnD transitions call proper bd commands and reject invalid transitions safely.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:05.0129676-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:05.0129676-08:00","labels":["dnd","kanban"],"dependencies":[{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:05.014527-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:34.329788-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-trz.1","type":"blocks","created_at":"2026-02-11T17:12:34.8422542-08:00","created_by":"zenchantlive"}]} diff --git a/package.json b/package.json index aa81b5e..adefd10 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" }, "dependencies": { "framer-motion": "^11.18.2", diff --git a/src/app/api/beads/_shared.ts b/src/app/api/beads/_shared.ts new file mode 100644 index 0000000..e5f5c50 --- /dev/null +++ b/src/app/api/beads/_shared.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; + +import { executeMutation, MutationValidationError, validateMutationPayload, type MutationOperation } from '../../../lib/mutations'; + +function badRequest(message: string, operation: MutationOperation) { + return NextResponse.json( + { + ok: false, + operation, + error: { + classification: 'bad_args', + message, + }, + }, + { status: 400 }, + ); +} + +export async function handleMutationRequest(request: Request, operation: MutationOperation): Promise { + let body: unknown; + + try { + body = await request.json(); + } catch { + return badRequest('Invalid JSON body.', operation); + } + + try { + const payload = validateMutationPayload(operation, body); + const result = await executeMutation(operation, payload); + + const status = result.ok ? 200 : result.error?.classification === 'not_found' ? 404 : 400; + return NextResponse.json(result, { status }); + } catch (error) { + if (error instanceof MutationValidationError) { + return badRequest(error.message, operation); + } + + return NextResponse.json( + { + ok: false, + operation, + error: { + classification: 'unknown', + message: error instanceof Error ? error.message : 'Unknown mutation error.', + }, + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/beads/close/route.ts b/src/app/api/beads/close/route.ts new file mode 100644 index 0000000..e2cfbaf --- /dev/null +++ b/src/app/api/beads/close/route.ts @@ -0,0 +1,5 @@ +import { handleMutationRequest } from '../_shared'; + +export async function POST(request: Request): Promise { + return handleMutationRequest(request, 'close'); +} diff --git a/src/app/api/beads/comment/route.ts b/src/app/api/beads/comment/route.ts new file mode 100644 index 0000000..9e84164 --- /dev/null +++ b/src/app/api/beads/comment/route.ts @@ -0,0 +1,5 @@ +import { handleMutationRequest } from '../_shared'; + +export async function POST(request: Request): Promise { + return handleMutationRequest(request, 'comment'); +} diff --git a/src/app/api/beads/create/route.ts b/src/app/api/beads/create/route.ts new file mode 100644 index 0000000..5b2feba --- /dev/null +++ b/src/app/api/beads/create/route.ts @@ -0,0 +1,5 @@ +import { handleMutationRequest } from '../_shared'; + +export async function POST(request: Request): Promise { + return handleMutationRequest(request, 'create'); +} diff --git a/src/app/api/beads/reopen/route.ts b/src/app/api/beads/reopen/route.ts new file mode 100644 index 0000000..efae9dc --- /dev/null +++ b/src/app/api/beads/reopen/route.ts @@ -0,0 +1,5 @@ +import { handleMutationRequest } from '../_shared'; + +export async function POST(request: Request): Promise { + return handleMutationRequest(request, 'reopen'); +} diff --git a/src/app/api/beads/update/route.ts b/src/app/api/beads/update/route.ts new file mode 100644 index 0000000..a4ada91 --- /dev/null +++ b/src/app/api/beads/update/route.ts @@ -0,0 +1,5 @@ +import { handleMutationRequest } from '../_shared'; + +export async function POST(request: Request): Promise { + return handleMutationRequest(request, 'update'); +} diff --git a/src/lib/bd-path.ts b/src/lib/bd-path.ts new file mode 100644 index 0000000..6ab3be3 --- /dev/null +++ b/src/lib/bd-path.ts @@ -0,0 +1,78 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export interface ResolveBdExecutableOptions { + explicitPath?: string | null; + env?: NodeJS.ProcessEnv; +} + +export interface BdExecutableResolution { + executable: string; + source: 'config' | 'path'; +} + +export class BdExecutableNotFoundError extends Error { + readonly code = 'BD_NOT_FOUND'; + + constructor(message: string) { + super(message); + this.name = 'BdExecutableNotFoundError'; + } +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function splitEnvPath(env: NodeJS.ProcessEnv = process.env): string[] { + const value = env.Path ?? env.PATH ?? ''; + if (!value.trim()) { + return []; + } + + return value.split(';').map((segment) => segment.trim()).filter(Boolean); +} + +function executableCandidates(directory: string): string[] { + return ['bd.exe', 'bd.cmd', 'bd.bat', 'bd'].map((name) => path.join(directory, name)); +} + +function buildNotFoundMessage(explicitPath?: string | null): string { + const lines = [ + 'bd.exe was not found.', + 'Install it with: npm install -g @beads/bd', + 'Or configure an explicit executable path in request payload/config.', + ]; + + if (explicitPath) { + lines.push(`Configured path was not found: ${explicitPath}`); + } + + return lines.join(' '); +} + +export async function resolveBdExecutable(options: ResolveBdExecutableOptions = {}): Promise { + if (options.explicitPath && options.explicitPath.trim()) { + const explicit = path.resolve(options.explicitPath); + if (await fileExists(explicit)) { + return { executable: explicit, source: 'config' }; + } + + throw new BdExecutableNotFoundError(buildNotFoundMessage(options.explicitPath)); + } + + for (const dir of splitEnvPath(options.env)) { + for (const candidate of executableCandidates(dir)) { + if (await fileExists(candidate)) { + return { executable: candidate, source: 'path' }; + } + } + } + + throw new BdExecutableNotFoundError(buildNotFoundMessage()); +} diff --git a/src/lib/bridge.ts b/src/lib/bridge.ts new file mode 100644 index 0000000..2779e39 --- /dev/null +++ b/src/lib/bridge.ts @@ -0,0 +1,163 @@ +import { execFile as nodeExecFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { BdExecutableNotFoundError, resolveBdExecutable } from './bd-path'; + +const execFileAsync = promisify(nodeExecFile); + +export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown'; + +export interface RunBdCommandOptions { + projectRoot: string; + args: string[]; + timeoutMs?: number; + explicitBdPath?: string | null; +} + +export interface RunBdCommandResult { + success: boolean; + classification: BdFailureClassification | null; + command: string; + args: string[]; + cwd: string; + stdout: string; + stderr: string; + code: number | null; + durationMs: number; + error: string | null; +} + +type ExecFileOptions = { + cwd: string; + timeout: number; + windowsHide: boolean; + env: NodeJS.ProcessEnv; +}; + +type ExecFileLike = ( + command: string, + args: string[], + options: ExecFileOptions, +) => Promise<{ stdout: string; stderr: string }>; + +interface RunBdCommandDeps { + resolveBdExecutable: typeof resolveBdExecutable; + execFile: ExecFileLike; + env: NodeJS.ProcessEnv; +} + +function normalizeOutput(text: unknown): string { + if (typeof text !== 'string') { + return ''; + } + + return text.replaceAll('\r\n', '\n').trim(); +} + +function toErrorMessage(value: unknown): string { + if (value instanceof Error) { + return value.message; + } + return String(value ?? 'Unknown error'); +} + +function classifyFailure(error: NodeJS.ErrnoException & { stderr?: string; killed?: boolean; signal?: string }): BdFailureClassification { + if (error.code === 'ENOENT') { + return 'not_found'; + } + + if (error.code === 'ETIMEDOUT' || error.killed || error.signal === 'SIGTERM') { + return 'timeout'; + } + + const stderr = normalizeOutput(error.stderr); + if (typeof error.code === 'number') { + if (/(unknown|invalid|required|usage)/i.test(stderr)) { + return 'bad_args'; + } + return 'non_zero_exit'; + } + + return 'unknown'; +} + +export async function runBdCommand( + options: RunBdCommandOptions, + injectedDeps?: Partial, +): Promise { + const startedAt = Date.now(); + const timeoutMs = options.timeoutMs ?? 30_000; + const cwd = options.projectRoot; + const args = [...options.args]; + + const deps: RunBdCommandDeps = { + resolveBdExecutable: injectedDeps?.resolveBdExecutable ?? resolveBdExecutable, + execFile: injectedDeps?.execFile ?? execFileAsync, + env: injectedDeps?.env ?? process.env, + }; + + let command = options.explicitBdPath ?? 'bd.exe'; + + try { + const resolved = await deps.resolveBdExecutable({ + explicitPath: options.explicitBdPath, + env: deps.env, + }); + command = resolved.executable; + + const { stdout, stderr } = await deps.execFile(command, args, { + cwd, + timeout: timeoutMs, + windowsHide: true, + env: deps.env, + }); + + return { + success: true, + classification: null, + command, + args, + cwd, + stdout: normalizeOutput(stdout), + stderr: normalizeOutput(stderr), + code: 0, + durationMs: Date.now() - startedAt, + error: null, + }; + } catch (rawError) { + if (rawError instanceof BdExecutableNotFoundError) { + return { + success: false, + classification: 'not_found', + command, + args, + cwd, + stdout: '', + stderr: '', + code: null, + durationMs: Date.now() - startedAt, + error: rawError.message, + }; + } + + const error = rawError as NodeJS.ErrnoException & { + stderr?: string; + stdout?: string; + killed?: boolean; + signal?: string; + }; + + return { + success: false, + classification: classifyFailure(error), + command, + args, + cwd, + stdout: normalizeOutput(error.stdout), + stderr: normalizeOutput(error.stderr), + code: typeof error.code === 'number' ? error.code : null, + durationMs: Date.now() - startedAt, + error: toErrorMessage(error), + }; + } +} diff --git a/src/lib/mutations.ts b/src/lib/mutations.ts new file mode 100644 index 0000000..d92f7d7 --- /dev/null +++ b/src/lib/mutations.ts @@ -0,0 +1,295 @@ +import { runBdCommand, type RunBdCommandResult } from './bridge'; + +export type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment'; +export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed'; + +interface MutationBasePayload { + projectRoot: string; + bdPath?: string; +} + +export interface CreateMutationPayload extends MutationBasePayload { + title: string; + description?: string; + priority?: number; + issueType?: string; + assignee?: string; + labels?: string[]; +} + +export interface UpdateMutationPayload extends MutationBasePayload { + id: string; + title?: string; + description?: string; + status?: MutationStatus; + priority?: number; + assignee?: string; + labels?: string[]; +} + +export interface CloseMutationPayload extends MutationBasePayload { + id: string; + reason?: string; +} + +export interface ReopenMutationPayload extends MutationBasePayload { + id: string; + reason?: string; +} + +export interface CommentMutationPayload extends MutationBasePayload { + id: string; + text: string; +} + +export type MutationPayload = + | CreateMutationPayload + | UpdateMutationPayload + | CloseMutationPayload + | ReopenMutationPayload + | CommentMutationPayload; + +export interface MutationErrorShape { + classification: 'bad_args' | 'not_found' | 'timeout' | 'non_zero_exit' | 'unknown'; + message: string; +} + +export interface MutationResponse { + ok: boolean; + operation: MutationOperation; + command: RunBdCommandResult; + error?: MutationErrorShape; +} + +export class MutationValidationError extends Error { + readonly code = 'MUTATION_VALIDATION_ERROR'; + + constructor(message: string) { + super(message); + this.name = 'MutationValidationError'; + } +} + +function asNonEmptyString(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new MutationValidationError(`"${field}" is required.`); + } + return value.trim(); +} + +function asOptionalString(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== 'string') { + throw new MutationValidationError('Expected a string value.'); + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function asOptionalPriority(value: unknown): number | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > 4) { + throw new MutationValidationError('"priority" must be a number between 0 and 4.'); + } + return value; +} + +function asOptionalLabels(value: unknown): string[] | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (!Array.isArray(value)) { + throw new MutationValidationError('"labels" must be an array of strings.'); + } + const labels = value.map((label) => { + if (typeof label !== 'string' || !label.trim()) { + throw new MutationValidationError('"labels" must be an array of non-empty strings.'); + } + return label.trim(); + }); + + return labels.length ? labels : undefined; +} + +function asOptionalStatus(value: unknown): MutationStatus | undefined { + if (value === undefined || value === null) { + return undefined; + } + const status = asNonEmptyString(value, 'status'); + if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status)) { + throw new MutationValidationError('"status" is invalid.'); + } + return status as MutationStatus; +} + +function parseBasePayload(raw: unknown): MutationBasePayload { + if (!raw || typeof raw !== 'object') { + throw new MutationValidationError('Payload must be a JSON object.'); + } + + const data = raw as Record; + return { + projectRoot: asNonEmptyString(data.projectRoot, 'projectRoot'), + bdPath: asOptionalString(data.bdPath), + }; +} + +export function validateMutationPayload(operation: MutationOperation, payload: unknown): MutationPayload { + const base = parseBasePayload(payload); + const data = payload as Record; + + if (operation === 'create') { + return { + ...base, + title: asNonEmptyString(data.title, 'title'), + description: asOptionalString(data.description), + priority: asOptionalPriority(data.priority), + issueType: asOptionalString(data.issueType), + assignee: asOptionalString(data.assignee), + labels: asOptionalLabels(data.labels), + }; + } + + if (operation === 'update') { + const mapped: UpdateMutationPayload = { + ...base, + id: asNonEmptyString(data.id, 'id'), + title: asOptionalString(data.title), + description: asOptionalString(data.description), + status: asOptionalStatus(data.status), + priority: asOptionalPriority(data.priority), + assignee: asOptionalString(data.assignee), + labels: asOptionalLabels(data.labels), + }; + + if (!mapped.title && !mapped.description && !mapped.status && mapped.priority === undefined && !mapped.assignee && !mapped.labels) { + throw new MutationValidationError('At least one update field is required.'); + } + + return mapped; + } + + if (operation === 'close') { + return { + ...base, + id: asNonEmptyString(data.id, 'id'), + reason: asOptionalString(data.reason), + }; + } + + if (operation === 'reopen') { + return { + ...base, + id: asNonEmptyString(data.id, 'id'), + reason: asOptionalString(data.reason), + }; + } + + return { + ...base, + id: asNonEmptyString(data.id, 'id'), + text: asNonEmptyString(data.text, 'text'), + }; +} + +function pushOptionalArg(args: string[], flag: string, value: string | undefined): void { + if (value) { + args.push(flag, value); + } +} + +function pushOptionalLabels(args: string[], labels: string[] | undefined): void { + if (labels && labels.length > 0) { + args.push('-l', labels.join(',')); + } +} + +export function buildBdMutationArgs(operation: MutationOperation, payload: MutationPayload): string[] { + if (operation === 'create') { + const data = payload as CreateMutationPayload; + const args = ['create', data.title]; + pushOptionalArg(args, '-d', data.description); + if (data.priority !== undefined) { + args.push('-p', String(data.priority)); + } + pushOptionalArg(args, '-t', data.issueType); + pushOptionalArg(args, '-a', data.assignee); + pushOptionalLabels(args, data.labels); + args.push('--json'); + return args; + } + + if (operation === 'update') { + const data = payload as UpdateMutationPayload; + const args = ['update', data.id]; + pushOptionalArg(args, '--title', data.title); + pushOptionalArg(args, '-d', data.description); + pushOptionalArg(args, '-s', data.status); + if (data.priority !== undefined) { + args.push('-p', String(data.priority)); + } + pushOptionalArg(args, '-a', data.assignee); + pushOptionalLabels(args, data.labels); + args.push('--json'); + return args; + } + + if (operation === 'close') { + const data = payload as CloseMutationPayload; + const args = ['close', data.id]; + pushOptionalArg(args, '-r', data.reason); + args.push('--json'); + return args; + } + + if (operation === 'reopen') { + const data = payload as ReopenMutationPayload; + const args = ['reopen', data.id]; + pushOptionalArg(args, '-r', data.reason); + args.push('--json'); + return args; + } + + const data = payload as CommentMutationPayload; + return ['comments', 'add', data.id, data.text, '--json']; +} + +interface ExecuteMutationDeps { + runBdCommand: typeof runBdCommand; +} + +export async function executeMutation( + operation: MutationOperation, + payload: MutationPayload, + deps: Partial = {}, +): Promise { + const runner = deps.runBdCommand ?? runBdCommand; + const args = buildBdMutationArgs(operation, payload); + const command = await runner({ + projectRoot: payload.projectRoot, + args, + explicitBdPath: payload.bdPath, + }); + + if (!command.success) { + return { + ok: false, + operation, + command, + error: { + classification: command.classification ?? 'unknown', + message: command.error ?? (command.stderr || 'Mutation command failed.'), + }, + }; + } + + return { + ok: true, + operation, + command, + }; +} diff --git a/tests/api/mutations-routes.test.ts b/tests/api/mutations-routes.test.ts new file mode 100644 index 0000000..6d1fe3b --- /dev/null +++ b/tests/api/mutations-routes.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { POST as createPost } from '../../src/app/api/beads/create/route'; +import { POST as reopenPost } from '../../src/app/api/beads/reopen/route'; +import { POST as commentPost } from '../../src/app/api/beads/comment/route'; + +async function readJson(response: Response): Promise { + const text = await response.text(); + return text ? JSON.parse(text) : {}; +} + +test('create route returns 400 for invalid payload', async () => { + const response = await createPost( + new Request('http://localhost/api/beads/create', { + method: 'POST', + body: JSON.stringify({ projectRoot: '', title: '' }), + headers: { 'content-type': 'application/json' }, + }), + ); + + assert.equal(response.status, 400); + const data = await readJson(response); + assert.equal(data.ok, false); + assert.equal(data.error.classification, 'bad_args'); +}); + +test('reopen route returns 400 for missing id', async () => { + const response = await reopenPost( + new Request('http://localhost/api/beads/reopen', { + method: 'POST', + body: JSON.stringify({ projectRoot: 'C:/repo' }), + headers: { 'content-type': 'application/json' }, + }), + ); + + assert.equal(response.status, 400); + const data = await readJson(response); + assert.equal(data.ok, false); +}); + +test('comment route returns 400 for missing comment text', async () => { + const response = await commentPost( + new Request('http://localhost/api/beads/comment', { + method: 'POST', + body: JSON.stringify({ projectRoot: 'C:/repo', id: 'bb-1' }), + headers: { 'content-type': 'application/json' }, + }), + ); + + assert.equal(response.status, 400); + const data = await readJson(response); + assert.equal(data.ok, false); + assert.equal(typeof data.error.message, 'string'); +}); diff --git a/tests/lib/bd-path.test.ts b/tests/lib/bd-path.test.ts new file mode 100644 index 0000000..bc7d5ed --- /dev/null +++ b/tests/lib/bd-path.test.ts @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { BdExecutableNotFoundError, resolveBdExecutable } from '../../src/lib/bd-path'; + +test('resolveBdExecutable prefers explicit configured path when provided', async () => { + const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-bd-path-')); + const explicit = path.join(temp, 'tools', 'bd.exe'); + await fs.mkdir(path.dirname(explicit), { recursive: true }); + await fs.writeFile(explicit, ''); + + const resolved = await resolveBdExecutable({ explicitPath: explicit, env: { Path: '', NODE_ENV: 'test' } }); + + assert.equal(resolved.executable, explicit); + assert.equal(resolved.source, 'config'); +}); + +test('resolveBdExecutable finds bd.exe on PATH when explicit path is not set', async () => { + const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-bd-path-env-')); + const candidate = path.join(temp, 'bd.exe'); + await fs.writeFile(candidate, ''); + + const resolved = await resolveBdExecutable({ env: { Path: temp, NODE_ENV: 'test' } }); + + assert.equal(resolved.executable, candidate); + assert.equal(resolved.source, 'path'); +}); + +test('resolveBdExecutable throws actionable setup guidance when executable is missing', async () => { + await assert.rejects( + () => resolveBdExecutable({ env: { Path: '', NODE_ENV: 'test' } }), + (error: unknown) => { + assert.equal(error instanceof BdExecutableNotFoundError, true); + const message = String((error as Error).message).toLowerCase(); + assert.equal(message.includes('npm install -g @beads/bd'), true); + assert.equal(message.includes('bd.exe'), true); + return true; + }, + ); +}); diff --git a/tests/lib/bridge.test.ts b/tests/lib/bridge.test.ts new file mode 100644 index 0000000..98f6d75 --- /dev/null +++ b/tests/lib/bridge.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { runBdCommand } from '../../src/lib/bridge'; + +test('runBdCommand returns structured success payload from execFile output', async () => { + const result = await runBdCommand( + { + projectRoot: 'C:/repo/project', + args: ['list', '--json'], + timeoutMs: 2000, + explicitBdPath: 'C:/tools/bd.exe', + }, + { + resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }), + execFile: async (command, args, options) => { + assert.equal(command, 'C:/tools/bd.exe'); + assert.deepEqual(args, ['list', '--json']); + assert.equal(options.cwd, 'C:/repo/project'); + return { stdout: '[{"id":"bb-1"}]\r\n', stderr: '' }; + }, + }, + ); + + assert.equal(result.success, true); + assert.equal(result.classification, null); + assert.equal(result.stdout, '[{"id":"bb-1"}]'); +}); + +test('runBdCommand classifies missing executable as not_found', async () => { + const result = await runBdCommand( + { projectRoot: 'C:/repo/project', args: ['list'] }, + { + resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }), + execFile: async () => { + const error = new Error('spawn ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }, + }, + ); + + assert.equal(result.success, false); + assert.equal(result.classification, 'not_found'); +}); + +test('runBdCommand classifies timeout failures', async () => { + const result = await runBdCommand( + { projectRoot: 'C:/repo/project', args: ['list'], timeoutMs: 5 }, + { + resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }), + execFile: async () => { + const error = new Error('timed out') as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + error.code = 'ETIMEDOUT'; + error.killed = true; + error.signal = 'SIGTERM'; + throw error; + }, + }, + ); + + assert.equal(result.success, false); + assert.equal(result.classification, 'timeout'); +}); + +test('runBdCommand classifies non-zero bad-argument exits', async () => { + const result = await runBdCommand( + { projectRoot: 'C:/repo/project', args: ['update', '--bad-flag'] }, + { + resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }), + execFile: async () => { + const error = new Error('exit code 1') as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + }; + (error as any).code = 1; + error.stderr = 'unknown flag: --bad-flag'; + error.stdout = ''; + throw error; + }, + }, + ); + + assert.equal(result.success, false); + assert.equal(result.classification, 'bad_args'); +}); diff --git a/tests/lib/mutations.test.ts b/tests/lib/mutations.test.ts new file mode 100644 index 0000000..ecb37f6 --- /dev/null +++ b/tests/lib/mutations.test.ts @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + MutationValidationError, + buildBdMutationArgs, + validateMutationPayload, + executeMutation, + type MutationOperation, +} from '../../src/lib/mutations'; + +const root = 'C:/Users/Zenchant/codex/beadboard'; + +test('validateMutationPayload rejects invalid payloads', () => { + assert.throws( + () => validateMutationPayload('create', { projectRoot: '', title: '' }), + (error: unknown) => error instanceof MutationValidationError, + ); +}); + +test('buildBdMutationArgs maps reopen correctly', () => { + const payload = validateMutationPayload('reopen', { + projectRoot: root, + id: 'bb-123', + reason: 'retry work', + }); + + const args = buildBdMutationArgs('reopen', payload); + assert.deepEqual(args, ['reopen', 'bb-123', '-r', 'retry work', '--json']); +}); + +test('buildBdMutationArgs maps comment correctly', () => { + const payload = validateMutationPayload('comment', { + projectRoot: root, + id: 'bb-123', + text: 'Added notes', + }); + + const args = buildBdMutationArgs('comment', payload); + assert.deepEqual(args, ['comments', 'add', 'bb-123', 'Added notes', '--json']); +}); + +test('executeMutation surfaces bridge failures in normalized response', async () => { + const payload = validateMutationPayload('close', { + projectRoot: root, + id: 'bb-123', + reason: 'completed', + }); + + const result = await executeMutation('close', payload, { + runBdCommand: async ({ args }) => { + assert.deepEqual(args, ['close', 'bb-123', '-r', 'completed', '--json']); + return { + success: false, + classification: 'non_zero_exit', + command: 'bd.exe', + args, + cwd: root, + stdout: '', + stderr: 'cannot close', + code: 1, + durationMs: 3, + error: 'cannot close', + }; + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.error?.classification, 'non_zero_exit'); +}); + +test('executeMutation returns successful normalized response', async () => { + const payload = validateMutationPayload('update', { + projectRoot: root, + id: 'bb-123', + status: 'in_progress', + priority: 1, + }); + + const result = await executeMutation('update', payload, { + runBdCommand: async ({ args }) => { + assert.deepEqual(args, ['update', 'bb-123', '-s', 'in_progress', '-p', '1', '--json']); + return { + success: true, + classification: null, + command: 'bd.exe', + args, + cwd: root, + stdout: '{"id":"bb-123"}', + stderr: '', + code: 0, + durationMs: 2, + error: null, + }; + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.operation, 'update'); + assert.equal(result.command.success, true); +});