Merge bb-6aj-3-scanner
This commit is contained in:
commit
89a9941d88
29 changed files with 2036 additions and 49 deletions
|
|
@ -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"}]}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
51
src/app/api/beads/_shared.ts
Normal file
51
src/app/api/beads/_shared.ts
Normal file
|
|
@ -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<Response> {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
5
src/app/api/beads/close/route.ts
Normal file
5
src/app/api/beads/close/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'close');
|
||||
}
|
||||
5
src/app/api/beads/comment/route.ts
Normal file
5
src/app/api/beads/comment/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'comment');
|
||||
}
|
||||
5
src/app/api/beads/create/route.ts
Normal file
5
src/app/api/beads/create/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'create');
|
||||
}
|
||||
24
src/app/api/beads/read/route.ts
Normal file
24
src/app/api/beads/read/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { readIssuesFromDisk } from '../../../../lib/read-issues';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
5
src/app/api/beads/reopen/route.ts
Normal file
5
src/app/api/beads/reopen/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'reopen');
|
||||
}
|
||||
5
src/app/api/beads/update/route.ts
Normal file
5
src/app/api/beads/update/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'update');
|
||||
}
|
||||
60
src/app/api/projects/route.ts
Normal file
60
src/app/api/projects/route.ts
Normal file
|
|
@ -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<string> {
|
||||
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<Response> {
|
||||
const projects = await listProjects();
|
||||
return NextResponse.json(projectsPayload(projects), { status: 200 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
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<Response> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
44
src/app/api/scan/route.ts
Normal file
44
src/app/api/scan/route.ts
Normal file
|
|
@ -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<Response> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -3,5 +3,5 @@ import { readIssuesFromDisk } from '../lib/read-issues';
|
|||
|
||||
export default async function Page() {
|
||||
const issues = await readIssuesFromDisk();
|
||||
return <KanbanPage issues={issues} />;
|
||||
return <KanbanPage issues={issues} projectRoot={process.cwd()} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
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<HTMLButtonElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<section className="flex min-w-fit snap-x snap-mandatory gap-3 overflow-x-auto overscroll-x-contain pb-2">
|
||||
<section className="grid min-h-[58vh] gap-2.5">
|
||||
{KANBAN_STATUSES.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className={`w-[clamp(17rem,24vw,22rem)] shrink-0 snap-start rounded-2xl border border-border-soft ${STATUS_COLUMN_CLASS[status]} p-2.5`}
|
||||
onDragOver={(event) => 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'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<strong className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
|
||||
<span className={`h-2 w-2 rounded-full ${STATUS_META[status].dot}`} />
|
||||
{STATUS_META[status].label}
|
||||
</strong>
|
||||
<span className="font-mono text-xs text-text-muted">{columns[status].length}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={activeStatus === status}
|
||||
onClick={() => {
|
||||
onActivateStatus(status);
|
||||
const firstIssue = columns[status][0];
|
||||
if (firstIssue) {
|
||||
onSelect(firstIssue);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center justify-between rounded-lg px-1 py-0.5 text-left"
|
||||
>
|
||||
<strong className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
|
||||
<span className={`h-2 w-2 rounded-full ${STATUS_META[status].dot}`} />
|
||||
{STATUS_META[status].label}
|
||||
</strong>
|
||||
<span className="font-mono text-xs text-text-muted">{columns[status].length}</span>
|
||||
</button>
|
||||
{activeStatus === status ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Minimize ${STATUS_META[status].label} lane`}
|
||||
onClick={() => onActivateStatus(null)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border-soft bg-surface-muted/60 text-sm text-text-muted hover:border-border-strong hover:text-text-body"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid h-[clamp(24rem,60vh,48rem)] content-start gap-2 overflow-y-auto pr-1">
|
||||
<AnimatePresence initial={false}>
|
||||
{columns[status].map((issue) => (
|
||||
<KanbanCard key={issue.id} issue={issue} selected={selectedIssueId === issue.id} onSelect={onSelect} />
|
||||
{activeStatus === status ? (
|
||||
<div className="mt-2 grid max-h-[50vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2 2xl:grid-cols-3">
|
||||
<AnimatePresence initial={false}>
|
||||
{columns[status].map((issue) => (
|
||||
<KanbanCard
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
pending={pendingIssueIds.has(issue.id)}
|
||||
selected={selectedIssueId === issue.id}
|
||||
draggable={!pendingIssueIds.has(issue.id)}
|
||||
onNativeDragStart={onDragStart}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{columns[status].length === 0 ? (
|
||||
<div className="flex h-24 w-full items-center justify-center rounded-xl border border-dashed border-border-soft/80 bg-surface/35 text-xs text-text-muted">
|
||||
No beads
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{columns[status].slice(0, 6).map((issue) => (
|
||||
<button
|
||||
key={issue.id}
|
||||
type="button"
|
||||
onClick={() => handleExpandAndSelect(status, issue)}
|
||||
className="max-w-full rounded-lg border border-border-soft bg-surface-muted/60 px-2 py-1 text-left hover:border-border-strong hover:bg-surface-raised/70"
|
||||
title={issue.title}
|
||||
>
|
||||
<div className="font-mono text-[10px] text-text-muted">{issue.id}</div>
|
||||
<div className="line-clamp-1 text-xs font-medium text-text-body">{issue.title}</div>
|
||||
</button>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{columns[status].length > 6 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivateStatus(status)}
|
||||
className="rounded-lg border border-border-soft bg-surface/50 px-2 py-1 text-xs text-text-muted hover:bg-surface-muted/70"
|
||||
>
|
||||
+{columns[status].length - 6} more
|
||||
</button>
|
||||
) : null}
|
||||
{columns[status].length === 0 ? (
|
||||
<span className="rounded-lg border border-dashed border-border-soft/80 bg-surface/30 px-2 py-1 text-xs text-text-muted">
|
||||
No beads
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>) => 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' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-mono text-[11px] text-text-muted break-all">{issue.id}</div>
|
||||
<div className="mt-1 text-sm font-semibold leading-5 text-text-strong break-words">{issue.title}</div>
|
||||
|
|
@ -51,7 +59,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
|
|||
<Chip>{issue.issue_type}</Chip>
|
||||
<Chip tone="status">deps {issue.dependencies.length}</Chip>
|
||||
</div>
|
||||
<div className="mt-2 break-words font-mono text-xs text-cyan-100/90">
|
||||
<div className="mt-2 break-words font-mono text-xs text-amber-100/90">
|
||||
{issue.assignee ? `@${issue.assignee}` : 'unassigned'}
|
||||
</div>
|
||||
{issue.labels.length > 0 ? (
|
||||
|
|
@ -61,6 +69,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
|
|||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{pending ? <div className="mt-2 text-[11px] font-medium text-amber-200">Saving…</div> : null}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) {
|
||||
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<BeadIssue[]> {
|
||||
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<BeadIssue[]>(issues);
|
||||
const [filters, setFilters] = useState<KanbanFilterOptions>({
|
||||
query: '',
|
||||
type: '',
|
||||
priority: '',
|
||||
showClosed: false,
|
||||
showClosed: true,
|
||||
});
|
||||
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(issues[0]?.id ?? null);
|
||||
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(null);
|
||||
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
|
||||
const [activeStatus, setActiveStatus] = useState<KanbanStatus | null>('open');
|
||||
const [desktopDetailMinimized, setDesktopDetailMinimized] = useState(false);
|
||||
const [pendingIssueIds, setPendingIssueIds] = useState<Set<string>>(new Set());
|
||||
const [mutationError, setMutationError] = useState<string | null>(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 (
|
||||
<main className="mx-auto min-h-screen max-w-[1800px] px-4 py-4 sm:px-6 sm:py-6">
|
||||
<header className="mb-4 rounded-2xl border border-border-soft bg-surface/90 px-4 py-4 shadow-card backdrop-blur md:px-5">
|
||||
<p className="font-mono text-xs uppercase tracking-[0.14em] text-cyan-100/80">BeadBoard</p>
|
||||
<p className="font-mono text-xs uppercase tracking-[0.14em] text-text-muted">BeadBoard</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-text-strong sm:text-3xl">Kanban Dashboard</h1>
|
||||
<p className="mt-2 text-sm text-text-muted">Tracer Bullet 1 from live `.beads/issues.jsonl` on Windows-native paths.</p>
|
||||
</header>
|
||||
<KanbanControls filters={filters} stats={stats} onFiltersChange={setFilters} />
|
||||
<section className="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,24rem)] xl:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]">
|
||||
<motion.div layout className="overflow-x-auto rounded-2xl border border-border-soft bg-surface/80 p-2.5 shadow-card">
|
||||
{mutationError ? (
|
||||
<div className="mt-3 rounded-xl border border-rose-300/40 bg-rose-950/40 px-3 py-2 text-sm text-rose-100">{mutationError}</div>
|
||||
) : null}
|
||||
<section
|
||||
className={`mt-3 overflow-hidden rounded-2xl border border-border-soft bg-surface/82 shadow-card ${
|
||||
showDesktopDetail ? 'lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]' : ''
|
||||
}`}
|
||||
>
|
||||
<motion.div layout className="p-2.5 sm:p-3">
|
||||
<KanbanBoard
|
||||
columns={columns}
|
||||
selectedIssueId={selectedIssue?.id ?? null}
|
||||
pendingIssueIds={pendingIssueIds}
|
||||
activeStatus={activeStatus}
|
||||
onActivateStatus={setActiveStatus}
|
||||
onMoveIssue={mutateStatus}
|
||||
onSelect={(issue) => {
|
||||
setSelectedIssueId(issue.id);
|
||||
setDesktopDetailMinimized(false);
|
||||
setMobileDetailOpen(true);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
<div className="hidden lg:sticky lg:top-4 lg:block lg:self-start">
|
||||
<KanbanDetail issue={selectedIssue} />
|
||||
</div>
|
||||
{showDesktopDetail ? (
|
||||
<div className="hidden border-t border-border-soft bg-surface/72 p-3 lg:block lg:border-l lg:border-t-0">
|
||||
<aside className="rounded-xl border border-border-soft bg-surface/78 p-3">
|
||||
<div className="mb-2 flex items-center justify-end gap-2 border-b border-border-soft pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDesktopDetailMinimized(true)}
|
||||
className="rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-body"
|
||||
>
|
||||
Minimize
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIssueId(null)}
|
||||
className="rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-muted"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-16rem)] overflow-y-auto pr-1">
|
||||
<KanbanDetail issue={selectedIssue} framed={false} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{mobileDetailOpen && selectedIssue ? (
|
||||
|
|
|
|||
78
src/lib/bd-path.ts
Normal file
78
src/lib/bd-path.ts
Normal file
|
|
@ -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<boolean> {
|
||||
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<BdExecutableResolution> {
|
||||
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());
|
||||
}
|
||||
163
src/lib/bridge.ts
Normal file
163
src/lib/bridge.ts
Normal file
|
|
@ -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<RunBdCommandDeps>,
|
||||
): Promise<RunBdCommandResult> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
295
src/lib/mutations.ts
Normal file
295
src/lib/mutations.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
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<ExecuteMutationDeps> = {},
|
||||
): Promise<MutationResponse> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
140
src/lib/registry.ts
Normal file
140
src/lib/registry.ts
Normal file
|
|
@ -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<string>();
|
||||
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<RegistryDocument> {
|
||||
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<void> {
|
||||
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<RegistryProject[]> {
|
||||
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 };
|
||||
}
|
||||
223
src/lib/scanner.ts
Normal file
223
src/lib/scanner.ts
Normal file
|
|
@ -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<string | null> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<string>();
|
||||
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<string> {
|
||||
return new Set(
|
||||
[...DEFAULT_IGNORE_DIRECTORIES, ...additional].map((entry) => entry.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function recordProject(projects: Map<string, ScannerProject>, 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<string>,
|
||||
projects: Map<string, ScannerProject>,
|
||||
stats: ScanStats,
|
||||
): Promise<void> {
|
||||
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<ScanResult> {
|
||||
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<string, ScannerProject>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
56
src/lib/writeback.ts
Normal file
56
src/lib/writeback.ts
Normal file
|
|
@ -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<BeadIssue, 'id' | 'status'>,
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
55
tests/api/mutations-routes.test.ts
Normal file
55
tests/api/mutations-routes.test.ts
Normal file
|
|
@ -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<any> {
|
||||
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');
|
||||
});
|
||||
109
tests/api/projects-route.test.ts
Normal file
109
tests/api/projects-route.test.ts
Normal file
|
|
@ -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<void>): Promise<void> {
|
||||
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<unknown> {
|
||||
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, []);
|
||||
});
|
||||
});
|
||||
43
tests/lib/bd-path.test.ts
Normal file
43
tests/lib/bd-path.test.ts
Normal file
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
86
tests/lib/bridge.test.ts
Normal file
86
tests/lib/bridge.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
101
tests/lib/mutations.test.ts
Normal file
101
tests/lib/mutations.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
86
tests/lib/registry.test.ts
Normal file
86
tests/lib/registry.test.ts
Normal file
|
|
@ -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<void>): Promise<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
68
tests/lib/scanner.test.ts
Normal file
68
tests/lib/scanner.test.ts
Normal file
|
|
@ -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<void>): Promise<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
55
tests/lib/writeback.test.ts
Normal file
55
tests/lib/writeback.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue