diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a1b22ff..932462e 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"}]} @@ -18,12 +19,14 @@ {"id":"bb-92d.5","title":"Implement Windows path normalization utilities","description":"Create centralized helpers for canonical path keys, display formatting, and cross-drive normalization to avoid duplicate project identities.","acceptance_criteria":"Canonicalization is consistent for C:\\ and D:\\ style paths.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:46.0751161-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:27:27.7164974-08:00","closed_at":"2026-02-11T17:27:27.7164974-08:00","close_reason":"Implemented Windows path normalization utilities with canonicalization, keying, and display transformations.","labels":["paths","windows"],"dependencies":[{"issue_id":"bb-92d.5","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:46.0767429-08:00","created_by":"zenchantlive"}]} {"id":"bb-92d.6","title":"Add guardrail test preventing direct writes to .beads/issues.jsonl","description":"Enforce read/write boundary by scanning source for forbidden direct file write patterns targeting Beads issue files.","acceptance_criteria":"Guardrail test fails on boundary violations and passes when write path uses bd bridge only.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:46.9013352-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:28:27.4699395-08:00","closed_at":"2026-02-11T17:28:27.4699395-08:00","close_reason":"Added guardrail scanner and automated test to block direct writes to .beads/issues.jsonl.","labels":["guardrail","safety"],"dependencies":[{"issue_id":"bb-92d.6","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:46.9029535-08:00","created_by":"zenchantlive"}]} {"id":"bb-ag8","title":"TEMP_DELETE_ME","status":"closed","priority":4,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:10:04.5765506-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:10:10.3812634-08:00","closed_at":"2026-02-11T17:10:10.3812634-08:00","close_reason":"cleanup temp test issue"} +{"id":"bb-atl","title":"Writeback phase smoke","description":"Temp for optimistic and transition smoke","status":"closed","priority":3,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T19:58:24.0374092-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:58:29.147102-08:00","closed_at":"2026-02-11T19:58:29.147102-08:00","close_reason":"cleanup writeback smoke","labels":["smoke","writeback"],"comments":[{"id":3,"issue_id":"bb-atl","author":"zenchantlive","text":"transition smoke reopen","created_at":"2026-02-12T03:58:27Z"}]} {"id":"bb-bc4","title":"Kanban Responsive Design Hardening","description":"Refine tracer-bullet Kanban into a production-grade, responsive experience across mobile/tablet/desktop using tokenized Tailwind styling and strict architecture boundaries. Scope includes layout reachability, card/column sizing integrity, improved visual language, and small-screen detail-panel behavior.","acceptance_criteria":"At 390x844, 768x1024, and 1440x900 all status columns are reachable, cards are not clipped, controls remain usable, and detail interactions work without direct JSONL write-path regressions.","status":"closed","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:41.814041-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:21.5796629-08:00","closed_at":"2026-02-11T18:59:21.5796629-08:00","close_reason":"Responsive design hardening scope completed with tests and Playwright evidence.","labels":["design-system","kanban","responsive","ui"],"dependencies":[{"issue_id":"bb-bc4","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T18:50:41.817863-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T18:51:20.344-08:00","created_by":"zenchantlive"}]} {"id":"bb-bc4.1","title":"Rework board responsiveness and horizontal reachability","description":"Implement intentional responsive board behavior: fluid column sizing, explicit horizontal board scrolling strategy, and viewport-safe wrappers so every status column is reachable without layout breakage. Use relative sizing constraints and avoid rigid fixed-width assumptions.","acceptance_criteria":"Board supports reliable horizontal reachability at all target breakpoints; no hidden/unreachable status columns.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:42.8356269-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:17.3199003-08:00","closed_at":"2026-02-11T18:59:17.3199003-08:00","close_reason":"Implemented fluid horizontal board reachability with snap and overflow containment across breakpoints.","labels":["kanban","layout","responsive"],"dependencies":[{"issue_id":"bb-bc4.1","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:42.837217-08:00","created_by":"zenchantlive"}]} {"id":"bb-bc4.2","title":"Fix column/card sizing and overflow behavior","description":"Correct card and column sizing to prevent clipping, overflow artifacts, and unreadable metadata blocks. Ensure card internals wrap/truncate intentionally and columns maintain consistent density and scroll behavior.","acceptance_criteria":"Cards remain fully readable within columns, no clipped card content, and column internals scroll predictably.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:43.8439541-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:18.1823946-08:00","closed_at":"2026-02-11T18:59:18.1823946-08:00","close_reason":"Fixed card/column overflow and sizing with clamp-based widths, scroll-safe columns, and improved text wrapping.","labels":["cards","kanban","overflow"],"dependencies":[{"issue_id":"bb-bc4.2","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:43.8457677-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.2","depends_on_id":"bb-bc4.1","type":"blocks","created_at":"2026-02-11T18:50:43.8490043-08:00","created_by":"zenchantlive"}]} {"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 +50,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.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"}]} +{"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.","notes":"Implemented optimistic status updates with rollback in Kanban page, per-issue pending state, and authoritative reconciliation via new GET /api/beads/read endpoint after successful mutations.","status":"closed","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-11T19:59:02.289739-08:00","closed_at":"2026-02-11T19:59:02.289739-08:00","close_reason":"Optimistic board updates with rollback and authoritative post-mutation reconciliation via read route implemented and validated.","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.","notes":"Implemented lane drag-and-drop interactions in Kanban board, status transition planning (including closed -\u003e reopen+update), and mapped transitions to bd mutation API routes with pending-state safeguards.","status":"closed","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-11T19:59:21.7655834-08:00","closed_at":"2026-02-11T19:59:21.7655834-08:00","close_reason":"Kanban lane drag-and-drop transitions now map to bd-backed close/reopen/update mutations with transition planner tests and runtime smoke validation.","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 f32d70e..f1a7eb6 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/project-context.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/project-context.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/lib/writeback.test.ts && node --import tsx --test tests/lib/registry.test.ts && node --import tsx --test tests/lib/scanner.test.ts && node --import tsx --test tests/api/projects-route.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/read/route.ts b/src/app/api/beads/read/route.ts new file mode 100644 index 0000000..a3510bc --- /dev/null +++ b/src/app/api/beads/read/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; + +import { readIssuesFromDisk } from '../../../../lib/read-issues'; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd(); + + try { + const issues = await readIssuesFromDisk({ projectRoot }); + return NextResponse.json({ ok: true, issues }); + } catch (error) { + return NextResponse.json( + { + ok: false, + error: { + classification: 'unknown', + message: error instanceof Error ? error.message : 'Failed to read issues.', + }, + }, + { status: 500 }, + ); + } +} 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/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..240c7c4 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; + +import { addProject, listProjects, RegistryValidationError, removeProject } from '../../../lib/registry'; + +export const runtime = 'nodejs'; + +function projectsPayload(projects: Array<{ path: string }>): { projects: Array<{ path: string }> } { + return { + projects: projects.map((project) => ({ path: project.path })), + }; +} + +async function readPathFromBody(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + throw new RegistryValidationError('Request body must be valid JSON.'); + } + + const path = (body as { path?: unknown }).path; + if (typeof path !== 'string' || path.trim().length === 0) { + throw new RegistryValidationError('`path` is required and must be a non-empty string.'); + } + + return path; +} + +export async function GET(): Promise { + const projects = await listProjects(); + return NextResponse.json(projectsPayload(projects), { status: 200 }); +} + +export async function POST(request: Request): Promise { + try { + const projectPath = await readPathFromBody(request); + const result = await addProject(projectPath); + return NextResponse.json(projectsPayload(result.projects), { status: result.added ? 201 : 200 }); + } catch (error) { + if (error instanceof RegistryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to add project.' }, { status: 500 }); + } +} + +export async function DELETE(request: Request): Promise { + try { + const projectPath = await readPathFromBody(request); + const result = await removeProject(projectPath); + return NextResponse.json({ removed: result.removed, ...projectsPayload(result.projects) }, { status: 200 }); + } catch (error) { + if (error instanceof RegistryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to remove project.' }, { status: 500 }); + } +} diff --git a/src/app/api/scan/route.ts b/src/app/api/scan/route.ts new file mode 100644 index 0000000..390f122 --- /dev/null +++ b/src/app/api/scan/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; + +import { scanForProjects } from '../../../lib/scanner'; +import type { ScanMode } from '../../../lib/scanner'; + +export const runtime = 'nodejs'; + +function parseMode(value: string | null): ScanMode { + if (!value || value === 'default') { + return 'default'; + } + + if (value === 'full-drive') { + return 'full-drive'; + } + + throw new Error('Invalid scan mode. Use mode=default or mode=full-drive.'); +} + +function parseDepth(value: string | null): number | undefined { + if (!value) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error('Depth must be a non-negative integer.'); + } + + return parsed; +} + +export async function GET(request: Request): Promise { + try { + const url = new URL(request.url); + const mode = parseMode(url.searchParams.get('mode')); + const maxDepth = parseDepth(url.searchParams.get('depth')); + const result = await scanForProjects({ mode, maxDepth }); + return NextResponse.json(result, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to scan projects.'; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index a3d12e7..694d171 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,5 +3,5 @@ import { readIssuesFromDisk } from '../lib/read-issues'; export default async function Page() { const issues = await readIssuesFromDisk(); - return ; + return ; } diff --git a/src/components/kanban/kanban-board.tsx b/src/components/kanban/kanban-board.tsx index ef98b85..39f0151 100644 --- a/src/components/kanban/kanban-board.tsx +++ b/src/components/kanban/kanban-board.tsx @@ -1,8 +1,9 @@ 'use client'; import { AnimatePresence } from 'framer-motion'; +import type { DragEvent } from 'react'; -import { KANBAN_STATUSES } from '../../lib/kanban'; +import { KANBAN_STATUSES, type KanbanStatus } from '../../lib/kanban'; import type { BeadIssue } from '../../lib/types'; import { KanbanCard } from './kanban-card'; @@ -10,47 +11,153 @@ import { KanbanCard } from './kanban-card'; interface KanbanBoardProps { columns: Record<(typeof KANBAN_STATUSES)[number], BeadIssue[]>; selectedIssueId: string | null; + pendingIssueIds: Set; + activeStatus: KanbanStatus | null; + onActivateStatus: (status: KanbanStatus | null) => void; + onMoveIssue: (issue: BeadIssue, targetStatus: KanbanStatus) => void; onSelect: (issue: BeadIssue) => void; } const STATUS_META: Record<(typeof KANBAN_STATUSES)[number], { label: string; dot: string }> = { - open: { label: 'Open', dot: 'bg-sky-300' }, + open: { label: 'Open', dot: 'bg-zinc-300' }, in_progress: { label: 'In Progress', dot: 'bg-amber-300' }, blocked: { label: 'Blocked', dot: 'bg-rose-300' }, - deferred: { label: 'Deferred', dot: 'bg-slate-300' }, + deferred: { label: 'Deferred', dot: 'bg-stone-400' }, closed: { label: 'Done', dot: 'bg-emerald-300' }, }; const STATUS_COLUMN_CLASS: Record<(typeof KANBAN_STATUSES)[number], string> = { - open: 'bg-sky-500/10', + open: 'bg-zinc-500/10', in_progress: 'bg-amber-500/10', blocked: 'bg-rose-500/10', - deferred: 'bg-slate-500/10', + deferred: 'bg-stone-500/10', closed: 'bg-emerald-500/10', }; -export function KanbanBoard({ columns, selectedIssueId, onSelect }: KanbanBoardProps) { +export function KanbanBoard({ columns, selectedIssueId, pendingIssueIds, activeStatus, onActivateStatus, onMoveIssue, onSelect }: KanbanBoardProps) { + const allIssues = KANBAN_STATUSES.flatMap((status) => columns[status]); + + const issueLookup = new Map(allIssues.map((issue) => [issue.id, issue])); + + const handleExpandAndSelect = (status: KanbanStatus, issue: BeadIssue) => { + onActivateStatus(status); + onSelect(issue); + }; + + const onDragStart = (issue: BeadIssue, event: DragEvent) => { + event.dataTransfer.setData('application/x-bead-id', issue.id); + event.dataTransfer.setData('application/x-bead-status', issue.status); + event.dataTransfer.effectAllowed = 'move'; + }; + + const onDropLane = (targetStatus: KanbanStatus, event: DragEvent) => { + event.preventDefault(); + const issueId = event.dataTransfer.getData('application/x-bead-id'); + const sourceStatus = event.dataTransfer.getData('application/x-bead-status') as KanbanStatus; + if (!issueId || !sourceStatus || sourceStatus === targetStatus) { + return; + } + + const issue = issueLookup.get(issueId); + if (!issue) { + return; + } + + onMoveIssue(issue, targetStatus); + }; + return ( -
+
{KANBAN_STATUSES.map((status) => (
event.preventDefault()} + onDrop={(event) => onDropLane(status, event)} + className={`rounded-2xl border border-border-soft ${STATUS_COLUMN_CLASS[status]} p-2.5 transition ${ + activeStatus === status ? 'shadow-card' : 'opacity-90' + }`} > -
- - - {STATUS_META[status].label} - - {columns[status].length} +
+ + {activeStatus === status ? ( + + ) : null}
-
- - {columns[status].map((issue) => ( - + {activeStatus === status ? ( +
+ + {columns[status].map((issue) => ( + + ))} + + {columns[status].length === 0 ? ( +
+ No beads +
+ ) : null} +
+ ) : ( +
+ {columns[status].slice(0, 6).map((issue) => ( + ))} - -
+ {columns[status].length > 6 ? ( + + ) : null} + {columns[status].length === 0 ? ( + + No beads + + ) : null} +
+ )}
))}
diff --git a/src/components/kanban/kanban-card.tsx b/src/components/kanban/kanban-card.tsx index f2642a1..c105666 100644 --- a/src/components/kanban/kanban-card.tsx +++ b/src/components/kanban/kanban-card.tsx @@ -1,6 +1,7 @@ 'use client'; import { motion } from 'framer-motion'; +import type { DragEvent } from 'react'; import type { BeadIssue } from '../../lib/types'; @@ -9,6 +10,9 @@ import { Chip } from '../shared/chip'; interface KanbanCardProps { issue: BeadIssue; selected: boolean; + pending?: boolean; + draggable?: boolean; + onNativeDragStart?: (issue: BeadIssue, event: DragEvent) => void; onSelect: (issue: BeadIssue) => void; } @@ -19,7 +23,7 @@ function priorityClass(priority: number): string { case 1: return 'border-amber-300/40 bg-amber-500/20 text-amber-50'; case 2: - return 'border-sky-300/40 bg-sky-500/20 text-sky-50'; + return 'border-teal-300/40 bg-teal-500/20 text-teal-50'; case 3: return 'border-slate-300/35 bg-slate-500/22 text-slate-50'; default: @@ -27,9 +31,9 @@ function priorityClass(priority: number): string { } } -export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) { +export function KanbanCard({ issue, selected, pending = false, draggable = false, onNativeDragStart, onSelect }: KanbanCardProps) { const selectedClass = selected - ? 'border-cyan-300/80 bg-surface-raised shadow-card ring-1 ring-cyan-300/35' + ? 'border-amber-200/60 bg-surface-raised shadow-card ring-1 ring-amber-200/20' : 'border-border-soft bg-surface/95 shadow-[0_6px_18px_rgba(4,8,17,0.5)] hover:border-border-strong hover:bg-surface-raised/95'; return ( @@ -37,8 +41,12 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) { layout transition={{ duration: 0.18, ease: 'easeOut' }} type="button" + draggable={draggable} + onDragStartCapture={(event) => onNativeDragStart?.(issue, event)} onClick={() => onSelect(issue)} - className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass}`} + className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass} ${ + pending ? 'opacity-70' : '' + }`} >
{issue.id}
{issue.title}
@@ -51,7 +59,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) { {issue.issue_type} deps {issue.dependencies.length} -
+
{issue.assignee ? `@${issue.assignee}` : 'unassigned'}
{issue.labels.length > 0 ? ( @@ -61,6 +69,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) { ))}
) : null} + {pending ?
Saving…
: null} ); } diff --git a/src/components/kanban/kanban-page.tsx b/src/components/kanban/kanban-page.tsx index 5bcb194..1eb04af 100644 --- a/src/components/kanban/kanban-page.tsx +++ b/src/components/kanban/kanban-page.tsx @@ -1,11 +1,12 @@ 'use client'; import { motion } from 'framer-motion'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; -import type { KanbanFilterOptions } from '../../lib/kanban'; +import type { KanbanFilterOptions, KanbanStatus } from '../../lib/kanban'; import { buildKanbanColumns, buildKanbanStats, filterKanbanIssues } from '../../lib/kanban'; import type { BeadIssue } from '../../lib/types'; +import { applyOptimisticStatus, planStatusTransition } from '../../lib/writeback'; import { KanbanBoard } from './kanban-board'; import { KanbanControls } from './kanban-controls'; @@ -13,49 +14,154 @@ import { KanbanDetail } from './kanban-detail'; interface KanbanPageProps { issues: BeadIssue[]; + projectRoot: string; } -export function KanbanPage({ issues }: KanbanPageProps) { +type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment'; + +interface MutationErrorResponse { + error?: { message?: string }; +} + +async function postMutation(operation: MutationOperation, body: Record) { + const response = await fetch(`/api/beads/${operation}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const payload = (await response.json()) as { ok: boolean; error?: { message?: string } }; + + if (!response.ok || !payload.ok) { + throw new Error(payload.error?.message ?? `${operation} failed`); + } +} + +async function fetchIssues(projectRoot: string): Promise { + const response = await fetch(`/api/beads/read?projectRoot=${encodeURIComponent(projectRoot)}`, { + cache: 'no-store', + }); + const payload = (await response.json()) as { ok: boolean; issues?: BeadIssue[] } & MutationErrorResponse; + if (!response.ok || !payload.ok || !payload.issues) { + throw new Error(payload.error?.message ?? 'Failed to refresh issues'); + } + return payload.issues; +} + +export function KanbanPage({ issues, projectRoot }: KanbanPageProps) { + const [localIssues, setLocalIssues] = useState(issues); const [filters, setFilters] = useState({ query: '', type: '', priority: '', - showClosed: false, + showClosed: true, }); - const [selectedIssueId, setSelectedIssueId] = useState(issues[0]?.id ?? null); + const [selectedIssueId, setSelectedIssueId] = useState(null); const [mobileDetailOpen, setMobileDetailOpen] = useState(false); + const [activeStatus, setActiveStatus] = useState('open'); + const [desktopDetailMinimized, setDesktopDetailMinimized] = useState(false); + const [pendingIssueIds, setPendingIssueIds] = useState>(new Set()); + const [mutationError, setMutationError] = useState(null); - const filteredIssues = useMemo(() => filterKanbanIssues(issues, filters), [issues, filters]); + useEffect(() => { + setLocalIssues(issues); + }, [issues]); + + const filteredIssues = useMemo(() => filterKanbanIssues(localIssues, filters), [localIssues, filters]); const columns = useMemo(() => buildKanbanColumns(filteredIssues), [filteredIssues]); const stats = useMemo(() => buildKanbanStats(filteredIssues), [filteredIssues]); - const selectedIssue = useMemo( - () => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? filteredIssues[0] ?? null, - [filteredIssues, selectedIssueId], - ); + const selectedIssue = useMemo(() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? null, [filteredIssues, selectedIssueId]); + const showDesktopDetail = Boolean(selectedIssue) && !desktopDetailMinimized; + + const mutateStatus = async (issue: BeadIssue, targetStatus: KanbanStatus) => { + const steps = planStatusTransition(issue, targetStatus); + if (steps.length === 0) { + return; + } + + setMutationError(null); + const previous = localIssues; + setPendingIssueIds((value) => new Set(value).add(issue.id)); + setLocalIssues((current) => applyOptimisticStatus(current, issue.id, targetStatus)); + + try { + for (const step of steps) { + await postMutation(step.operation, { + projectRoot, + ...step.payload, + }); + } + + const reconciled = await fetchIssues(projectRoot); + setLocalIssues(reconciled); + } catch (error) { + setLocalIssues(previous); + setMutationError(error instanceof Error ? error.message : 'Mutation failed'); + } finally { + setPendingIssueIds((value) => { + const next = new Set(value); + next.delete(issue.id); + return next; + }); + } + }; return (
-

BeadBoard

+

BeadBoard

Kanban Dashboard

Tracer Bullet 1 from live `.beads/issues.jsonl` on Windows-native paths.

-
- + {mutationError ? ( +
{mutationError}
+ ) : null} +
+ { setSelectedIssueId(issue.id); + setDesktopDetailMinimized(false); setMobileDetailOpen(true); }} /> -
- -
+ {showDesktopDetail ? ( +
+ +
+ ) : null}
{mobileDetailOpen && selectedIssue ? ( 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/src/lib/registry.ts b/src/lib/registry.ts new file mode 100644 index 0000000..eacd45b --- /dev/null +++ b/src/lib/registry.ts @@ -0,0 +1,140 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing'; + +export interface RegistryProject { + path: string; + key: string; +} + +interface RegistryDocument { + version: 1; + projects: RegistryProject[]; +} + +export class RegistryValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'RegistryValidationError'; + } +} + +function userProfileRoot(): string { + return process.env.USERPROFILE?.trim() || os.homedir(); +} + +export function registryFilePath(): string { + return path.join(userProfileRoot(), '.beadboard', 'projects.json'); +} + +function ensureWindowsAbsolutePath(input: string): string { + const normalized = canonicalizeWindowsPath(input.trim()); + if (!/^[A-Za-z]:\\/.test(normalized)) { + throw new RegistryValidationError('Project path must be a Windows absolute path (e.g. C:\\Repos\\Project).'); + } + + return normalized; +} + +function normalizeProject(input: string): RegistryProject { + const normalized = ensureWindowsAbsolutePath(input); + return { + path: toDisplayPath(normalized), + key: windowsPathKey(normalized), + }; +} + +function normalizeProjects(input: unknown): RegistryProject[] { + if (!Array.isArray(input)) { + return []; + } + + const seen = new Set(); + const normalized: RegistryProject[] = []; + + for (const item of input) { + if (!item || typeof item !== 'object') { + continue; + } + + const candidate = item as { path?: unknown }; + if (typeof candidate.path !== 'string') { + continue; + } + + try { + const project = normalizeProject(candidate.path); + if (!seen.has(project.key)) { + seen.add(project.key); + normalized.push(project); + } + } catch { + continue; + } + } + + return normalized; +} + +async function readRegistryDocument(): Promise { + const filePath = registryFilePath(); + + try { + const raw = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw) as { projects?: unknown }; + return { + version: 1, + projects: normalizeProjects(parsed.projects), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { version: 1, projects: [] }; + } + + throw error; + } +} + +async function writeRegistryDocument(document: RegistryDocument): Promise { + const filePath = registryFilePath(); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(document, null, 2)}\n`, 'utf8'); +} + +export async function listProjects(): Promise { + const document = await readRegistryDocument(); + return document.projects; +} + +export async function addProject(projectPath: string): Promise<{ added: boolean; projects: RegistryProject[] }> { + const document = await readRegistryDocument(); + const project = normalizeProject(projectPath); + + if (document.projects.some((entry) => entry.key === project.key)) { + return { added: false, projects: document.projects }; + } + + document.projects.push(project); + await writeRegistryDocument(document); + return { added: true, projects: document.projects }; +} + +export async function removeProject(projectPath: string): Promise<{ removed: boolean; projects: RegistryProject[] }> { + const document = await readRegistryDocument(); + const project = normalizeProject(projectPath); + const nextProjects = document.projects.filter((entry) => entry.key !== project.key); + + if (nextProjects.length === document.projects.length) { + return { removed: false, projects: document.projects }; + } + + const nextDocument: RegistryDocument = { + version: 1, + projects: nextProjects, + }; + + await writeRegistryDocument(nextDocument); + return { removed: true, projects: nextDocument.projects }; +} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts new file mode 100644 index 0000000..aa24225 --- /dev/null +++ b/src/lib/scanner.ts @@ -0,0 +1,223 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing'; +import { listProjects } from './registry'; + +export type ScanMode = 'default' | 'full-drive'; + +export interface ScannerProject { + root: string; + key: string; + displayPath: string; +} + +export interface ScanStats { + scannedDirectories: number; + ignoredDirectories: number; + skippedDirectories: number; + elapsedMs: number; +} + +export interface ScanOptions { + mode?: ScanMode; + maxDepth?: number; + roots?: string[]; + ignoreDirectories?: string[]; +} + +export interface ScanResult { + mode: ScanMode; + roots: string[]; + projects: ScannerProject[]; + stats: ScanStats; +} + +const DEFAULT_MAX_DEPTH = 6; +const DEFAULT_IGNORE_DIRECTORIES = [ + 'node_modules', + '.git', + '.next', + 'dist', + 'build', + 'out', + 'coverage', + 'artifacts', + 'logs', + '.worktrees', // TODO: confirm whether worktrees should be scan targets. +]; + +function userProfileRoot(): string { + return process.env.USERPROFILE?.trim() || os.homedir(); +} + +function toCanonicalRoot(input: string): string { + return canonicalizeWindowsPath(input); +} + +function shouldSkipFsError(error: NodeJS.ErrnoException): boolean { + return error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EACCES' || error.code === 'EPERM'; +} + +async function ensureDirectoryExists(input: string): Promise { + try { + const stat = await fs.stat(input); + return stat.isDirectory() ? input : null; + } catch (error) { + if (shouldSkipFsError(error as NodeJS.ErrnoException)) { + return null; + } + throw error; + } +} + +async function resolveFullDriveRoots(): Promise { + const candidates = ['C:\\', 'D:\\']; + const roots: string[] = []; + + for (const candidate of candidates) { + const existing = await ensureDirectoryExists(candidate); + if (existing) { + roots.push(existing); + } + } + + return roots; +} + +export async function resolveScanRoots(options: ScanOptions = {}): Promise { + const mode: ScanMode = options.mode ?? 'default'; + const registryProjects = await listProjects(); + const roots = [ + userProfileRoot(), + ...registryProjects.map((project) => project.path), + ...(options.roots ?? []), + ]; + + if (mode === 'full-drive') { + roots.push(...(await resolveFullDriveRoots())); + } + + const seen = new Set(); + const normalizedRoots: string[] = []; + + for (const root of roots) { + const normalized = toCanonicalRoot(root); + const key = windowsPathKey(normalized); + if (seen.has(key)) { + continue; + } + + const existing = await ensureDirectoryExists(normalized); + if (!existing) { + continue; + } + + seen.add(key); + normalizedRoots.push(existing); + } + + return normalizedRoots; +} + +function buildIgnoreSet(additional: string[] = []): Set { + return new Set( + [...DEFAULT_IGNORE_DIRECTORIES, ...additional].map((entry) => entry.trim().toLowerCase()).filter(Boolean), + ); +} + +function recordProject(projects: Map, root: string): void { + const normalized = toCanonicalRoot(root); + const key = windowsPathKey(normalized); + if (!projects.has(key)) { + projects.set(key, { + root: normalized, + key, + displayPath: toDisplayPath(normalized), + }); + } +} + +async function scanRoot( + root: string, + maxDepth: number, + ignoreSet: Set, + projects: Map, + stats: ScanStats, +): Promise { + const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) { + continue; + } + + stats.scannedDirectories += 1; + let entries: fs.Dirent[]; + try { + entries = await fs.readdir(current.dir, { withFileTypes: true }); + } catch (error) { + if (shouldSkipFsError(error as NodeJS.ErrnoException)) { + stats.skippedDirectories += 1; + continue; + } + throw error; + } + + let hasBeads = false; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + if (entry.name === '.beads') { + hasBeads = true; + continue; + } + + const entryName = entry.name.toLowerCase(); + if (ignoreSet.has(entryName)) { + stats.ignoredDirectories += 1; + continue; + } + + if (current.depth < maxDepth) { + queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 }); + } + } + + if (hasBeads) { + recordProject(projects, current.dir); + } + } +} + +export async function scanForProjects(options: ScanOptions = {}): Promise { + const mode: ScanMode = options.mode ?? 'default'; + const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; + const ignoreSet = buildIgnoreSet(options.ignoreDirectories); + const roots = await resolveScanRoots(options); + const projects = new Map(); + const stats: ScanStats = { + scannedDirectories: 0, + ignoredDirectories: 0, + skippedDirectories: 0, + elapsedMs: 0, + }; + const start = Date.now(); + + for (const root of roots) { + await scanRoot(root, maxDepth, ignoreSet, projects, stats); + } + + stats.elapsedMs = Date.now() - start; + + return { + mode, + roots, + projects: Array.from(projects.values()), + stats, + }; +} diff --git a/src/lib/writeback.ts b/src/lib/writeback.ts new file mode 100644 index 0000000..ca61e85 --- /dev/null +++ b/src/lib/writeback.ts @@ -0,0 +1,56 @@ +import type { BeadIssue, BeadStatus } from './types'; + +export type MutationStep = + | { operation: 'close'; payload: { id: string; reason?: string } } + | { operation: 'reopen'; payload: { id: string; reason?: string } } + | { operation: 'update'; payload: { id: string; status: 'open' | 'in_progress' | 'blocked' | 'deferred' } }; + +function isBoardStatus(status: BeadStatus): status is 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed' { + return ['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status); +} + +export function planStatusTransition( + issue: Pick, + targetStatus: 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed', +): MutationStep[] { + if (!isBoardStatus(issue.status) || issue.status === targetStatus) { + return []; + } + + if (targetStatus === 'closed') { + return [{ operation: 'close', payload: { id: issue.id, reason: 'Moved to closed via board drag-and-drop' } }]; + } + + if (issue.status === 'closed') { + if (targetStatus === 'open') { + return [{ operation: 'reopen', payload: { id: issue.id, reason: 'Moved from closed via board drag-and-drop' } }]; + } + + return [ + { operation: 'reopen', payload: { id: issue.id, reason: 'Moved from closed via board drag-and-drop' } }, + { operation: 'update', payload: { id: issue.id, status: targetStatus } }, + ]; + } + + return [{ operation: 'update', payload: { id: issue.id, status: targetStatus } }]; +} + +export function applyOptimisticStatus( + issues: BeadIssue[], + issueId: string, + targetStatus: 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed', + atIso: string = new Date().toISOString(), +): BeadIssue[] { + return issues.map((issue) => { + if (issue.id !== issueId) { + return issue; + } + + return { + ...issue, + status: targetStatus, + updated_at: atIso, + closed_at: targetStatus === 'closed' ? atIso : null, + }; + }); +} 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/api/projects-route.test.ts b/tests/api/projects-route.test.ts new file mode 100644 index 0000000..0f4d77e --- /dev/null +++ b/tests/api/projects-route.test.ts @@ -0,0 +1,109 @@ +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 { DELETE, GET, POST } from '../../src/app/api/projects/route'; + +async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-api-')); + process.env.USERPROFILE = tempDir; + + try { + await run(tempDir); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +async function readJson(response: Response): Promise { + return response.json(); +} + +test('GET /api/projects returns empty list initially', async () => { + await withTempUserProfile(async () => { + const response = await GET(); + assert.equal(response.status, 200); + + const body = (await readJson(response)) as { projects: unknown[] }; + assert.deepEqual(body.projects, []); + }); +}); + +test('POST /api/projects validates payload and path', async () => { + await withTempUserProfile(async () => { + const missing = await POST(new Request('http://localhost/api/projects', { method: 'POST', body: '{}' })); + assert.equal(missing.status, 400); + + const missingBody = (await readJson(missing)) as { error: string }; + assert.match(missingBody.error, /path/i); + + const invalidPath = await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: '/tmp/project' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(invalidPath.status, 400); + }); +}); + +test('POST deduplicates and GET returns normalized path', async () => { + await withTempUserProfile(async () => { + const first = await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: 'c:/Users/Zenchant/codex/beadboard/' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(first.status, 201); + + const dup = await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: 'C:\\users\\zenchant\\codex\\beadboard' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(dup.status, 200); + + const list = await GET(); + const body = (await readJson(list)) as { projects: Array<{ path: string }> }; + assert.deepEqual(body.projects, [{ path: 'C:/Users/Zenchant/codex/beadboard' }]); + }); +}); + +test('DELETE /api/projects removes by normalized path', async () => { + await withTempUserProfile(async () => { + await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: 'D:/Repos/One' }), + headers: { 'content-type': 'application/json' }, + }), + ); + + const removed = await DELETE( + new Request('http://localhost/api/projects', { + method: 'DELETE', + body: JSON.stringify({ path: 'd:\\repos\\one\\' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(removed.status, 200); + + const list = await GET(); + const body = (await readJson(list)) as { projects: unknown[] }; + assert.deepEqual(body.projects, []); + }); +}); 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); +}); diff --git a/tests/lib/registry.test.ts b/tests/lib/registry.test.ts new file mode 100644 index 0000000..15537df --- /dev/null +++ b/tests/lib/registry.test.ts @@ -0,0 +1,86 @@ +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 { + addProject, + listProjects, + removeProject, + registryFilePath, + type RegistryProject, +} from '../../src/lib/registry'; + +async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-')); + process.env.USERPROFILE = tempDir; + + try { + await run(tempDir); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +test('registryFilePath resolves under %USERPROFILE%/.beadboard/projects.json', async () => { + await withTempUserProfile(async (userProfile) => { + const result = registryFilePath(); + assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json')); + }); +}); + +test('listProjects returns empty when registry does not exist', async () => { + await withTempUserProfile(async () => { + const result = await listProjects(); + assert.deepEqual(result, []); + }); +}); + +test('addProject persists normalized path and deduplicates case/separators', async () => { + await withTempUserProfile(async () => { + const first = await addProject('c:/Work/Alpha/'); + assert.equal(first.added, true); + + const second = await addProject('C:\\work\\alpha'); + assert.equal(second.added, false); + + const listed = await listProjects(); + assert.equal(listed.length, 1); + assert.equal(listed[0].path, 'C:/Work/Alpha'); + + const file = await fs.readFile(registryFilePath(), 'utf8'); + const parsed = JSON.parse(file) as { projects: RegistryProject[] }; + assert.equal(parsed.projects.length, 1); + }); +}); + +test('removeProject removes matching normalized path', async () => { + await withTempUserProfile(async () => { + await addProject('D:/Repos/One'); + await addProject('D:/Repos/Two'); + + const removed = await removeProject('d:\\repos\\one\\'); + assert.equal(removed.removed, true); + + const listed = await listProjects(); + assert.deepEqual( + listed.map((project) => project.path), + ['D:/Repos/Two'], + ); + }); +}); + +test('addProject rejects non-Windows absolute paths', async () => { + await withTempUserProfile(async () => { + await assert.rejects(() => addProject('/tmp/project'), /Windows absolute path/i); + await assert.rejects(() => addProject('relative/path'), /Windows absolute path/i); + }); +}); diff --git a/tests/lib/scanner.test.ts b/tests/lib/scanner.test.ts new file mode 100644 index 0000000..3b3a1c6 --- /dev/null +++ b/tests/lib/scanner.test.ts @@ -0,0 +1,68 @@ +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 { addProject } from '../../src/lib/registry'; +import { scanForProjects, resolveScanRoots } from '../../src/lib/scanner'; +import { canonicalizeWindowsPath, sameWindowsPath, windowsPathKey } from '../../src/lib/pathing'; + +async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scan-')); + process.env.USERPROFILE = tempDir; + + try { + await run(tempDir); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +test('resolveScanRoots includes profile and registry roots by default', async () => { + await withTempUserProfile(async (userProfile) => { + const registryRoot = path.join(userProfile, 'Registered'); + await fs.mkdir(registryRoot, { recursive: true }); + await addProject(registryRoot); + + const roots = await resolveScanRoots(); + + assert.equal(roots.some((root) => sameWindowsPath(root, userProfile)), true); + assert.equal(roots.some((root) => sameWindowsPath(root, registryRoot)), true); + assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), false); + }); +}); + +test('resolveScanRoots includes full-drive roots only when requested', async () => { + await withTempUserProfile(async () => { + const roots = await resolveScanRoots({ mode: 'full-drive' }); + assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), true); + }); +}); + +test('scanForProjects respects depth limits and ignore list', async () => { + await withTempUserProfile(async (userProfile) => { + const projectRoot = path.join(userProfile, 'ProjectA'); + await fs.mkdir(path.join(projectRoot, '.beads'), { recursive: true }); + + const ignoredRoot = path.join(userProfile, 'node_modules', 'Ignored'); + await fs.mkdir(path.join(ignoredRoot, '.beads'), { recursive: true }); + + const deepRoot = path.join(userProfile, 'Deep', 'Level1', 'Level2', 'ProjectDeep'); + await fs.mkdir(path.join(deepRoot, '.beads'), { recursive: true }); + + const result = await scanForProjects({ maxDepth: 1 }); + const keys = result.projects.map((project) => project.key); + + assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(projectRoot))), true); + assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(ignoredRoot))), false); + assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(deepRoot))), false); + }); +}); diff --git a/tests/lib/writeback.test.ts b/tests/lib/writeback.test.ts new file mode 100644 index 0000000..d0ab061 --- /dev/null +++ b/tests/lib/writeback.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { applyOptimisticStatus, planStatusTransition } from '../../src/lib/writeback'; +import type { BeadIssue } from '../../src/lib/types'; + +test('planStatusTransition maps open -> closed to close command', () => { + const steps = planStatusTransition({ id: 'bb-1', status: 'open' }, 'closed'); + assert.deepEqual(steps, [{ operation: 'close', payload: { id: 'bb-1', reason: 'Moved to closed via board drag-and-drop' } }]); +}); + +test('planStatusTransition maps closed -> in_progress to reopen + update', () => { + const steps = planStatusTransition({ id: 'bb-2', status: 'closed' }, 'in_progress'); + assert.deepEqual(steps, [ + { operation: 'reopen', payload: { id: 'bb-2', reason: 'Moved from closed via board drag-and-drop' } }, + { operation: 'update', payload: { id: 'bb-2', status: 'in_progress' } }, + ]); +}); + +test('planStatusTransition maps non-closed transitions to update', () => { + const steps = planStatusTransition({ id: 'bb-3', status: 'blocked' }, 'open'); + assert.deepEqual(steps, [{ operation: 'update', payload: { id: 'bb-3', status: 'open' } }]); +}); + +test('applyOptimisticStatus updates selected issue status and timestamps', () => { + const issues: BeadIssue[] = [ + { + id: 'bb-1', + title: 'One', + description: null, + status: 'open', + priority: 2, + issue_type: 'task', + assignee: null, + owner: null, + labels: [], + dependencies: [], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + closed_at: null, + close_reason: null, + closed_by_session: null, + created_by: null, + due_at: null, + estimated_minutes: null, + external_ref: null, + metadata: {}, + }, + ]; + + const updated = applyOptimisticStatus(issues, 'bb-1', 'closed', '2026-02-12T00:00:00Z'); + assert.equal(updated[0].status, 'closed'); + assert.equal(updated[0].closed_at, '2026-02-12T00:00:00Z'); + assert.equal(updated[0].updated_at, '2026-02-12T00:00:00Z'); +});