diff --git a/.claude/agents/testing-reviewer.md b/.claude/agents/testing-reviewer.md index 2e6ed90..f36c6bf 100644 --- a/.claude/agents/testing-reviewer.md +++ b/.claude/agents/testing-reviewer.md @@ -9,6 +9,8 @@ You receive a handoff artifact or diff from the previous agent. Use it as your p ## Review checklist +First read `~/github/qa-testing-rules/AGENT.md` (or the canonical `screenleon/qa-testing-rules` copy) and apply its 12-category matrix. Mark categories as covered or intentionally N/A; do not accept happy-path-only tests for changed behavior. [agent:documentation-architect] + For every changed file, systematically check: 1. **Handler tests** — every new HTTP handler has at least one happy-path and one error-path test in `*_test.go`. Check `backend/internal/handlers/`. diff --git a/.githooks/pre-push b/.githooks/pre-push index 4802419..13c5a14 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -51,7 +51,7 @@ export TEST_DATABASE_URL="sqlite" unset DATABASE_URL 2>/dev/null || true cd "$BACKEND" -if ! go test ./... -count=1 -timeout 180s; then +if ! go test ./... -count=1 -timeout 300s; then echo "" echo "[pre-push] BLOCKED: backend tests failed. Fix them before pushing." echo "[pre-push] to skip (emergency only): git push --no-verify" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0b2ea41..e2c01be 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -25,6 +25,7 @@ - Agent-generated content must include a source marker. - Dashboard state must be computed from system data, not manual input. - Consult `docs/mvp-scope.md` before adding features. +- For test work, read `~/github/qa-testing-rules/AGENT.md` or the canonical `screenleon/qa-testing-rules` copy first; apply the 12-category matrix and avoid happy-path-only coverage. [agent:documentation-architect] ## Mandatory workflow diff --git a/AGENTS.md b/AGENTS.md index 294f693..9f3f8e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,10 @@ Precedence: Project Context > Domain Rules > Global Rules. - Dashboard state must be computed from system data, not from free-form human input. - PostgreSQL is the active runtime data store. Treat older SQLite references as historical unless a task explicitly targets legacy assumptions. +## Testing rules + +When writing or reviewing tests, read `~/github/qa-testing-rules/AGENT.md` (or the canonical upstream `screenleon/qa-testing-rules` copy) before adding cases. Apply the 12-category matrix, prefer integration tests for owned DB/HTTP boundaries, avoid happy-path-only coverage, and perform a mutation self-check for core tests before handoff. [agent:documentation-architect] + ## Source of truth - `docs/operating-rules.md` — safety, scope, validation, review rules diff --git a/DECISIONS.md b/DECISIONS.md index cc3a727..12360c7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -4,6 +4,13 @@ Active architectural and behavioral decisions for Agent Native PM. When this file exceeds 50 entries or 30 KB, archive older entries to `DECISIONS_ARCHIVE.md`. The most recent archival pass was on 2026-04-27. +## 2026-05-01: Backlog-first project management core before connector auto-implementation [agent:feature-planner] + +- **Context**: The owner wants the frontend to become useful first as a local project management system with an API-backed backlog. Connector auto-generation and implementation should come later. Current Workspace behavior forces users through requirement -> planning run -> candidate before they can manage work, and generated candidates do not yet behave like durable project backlog. +- **Decision**: (1) Add a first-class `backlog_items` table as the user-facing backlog source of truth. (2) The ProjectDetail `Backlog` tab becomes the default working surface once the backlog-first implementation lands. (3) Backlog items support labels in the first slice. (4) `urgent` is a first-class priority distinct from `high` for both backlog items and tasks; committing an urgent backlog item creates an urgent task. (5) Manual backlog creation does not create or require a requirement; requirements are used when the user has a need but wants system decomposition. (6) Generated backlog is backlog: planning runs whose output is backlog must materialize `backlog_items` directly, without requiring an extra "accept candidate to backlog" step. Candidate rows may remain as compatibility/evidence artifacts but must not be the primary user-facing backlog layer. (7) Connector auto-implementation, `role_dispatch_auto`, and automatic task claiming from backlog are deferred until after B1-B5 in `docs/backlog-first-plan.md`. +- **Constraints introduced**: (a) User-facing backlog APIs should be project-level (`/api/projects/:id/backlog-items`) rather than planning-run-scoped. (b) The default flow is generated/manual/API backlog item -> review/priority/readiness -> committed task -> optional connector execution later. (c) Direct task generation is allowed only when the user explicitly asks to generate tasks, not when they ask to generate backlog. (d) New connector execution behavior must not be added while implementing B1-B5 except to preserve existing behavior. +- **Source**: Owner approval and clarifications on 2026-05-01; implementation plan in `docs/backlog-first-plan.md`. + ## 2026-04-27: Phase 3B PR-3 — candidate feedback fields + quality summary [agent:backend-architect] - **Context**: Phase 3B PR-3 adds optional operator feedback on evaluated backlog candidates (`feedback_kind`, `feedback_note`) and a derived `QualitySummary` on planning runs so the UI can show acceptance-rate and pending-review counts. @@ -122,9 +129,9 @@ When this file exceeds 50 entries or 30 KB, archive older entries to `DECISIONS_ ## 2026-04-24: ProjectDetail primary/secondary tab split + Settings gear [agent:feature-planner] - **Context**: `frontend/src/pages/ProjectDetail.tsx` exposed seven co-equal tabs (Overview, Workspace, Tasks, Documents, Drift, Activity, Settings). First-time operators could not tell which surface was the main entry point; Settings in particular competed for attention equally with the default Workspace surface despite being rarely opened. -- **Decision**: ProjectDetail's rail is now a two-tier structure. Primary rail (always visible, in order): Workspace · Overview · Tasks · Documents. Secondary group under a "More ▾" popover: Drift · Activity. Settings moves to a gear icon in the project header (right of "Sync Now"). The "More ▾" button shows an aggregated count/dot when demoted tabs carry actionable state (open drift signals, recent agent runs) so demotion never blinds the operator. All `?tab=` deep links continue to resolve; routing and default-tab (Workspace, per 2026-04-22 Phase 2 S5 ADR) are unchanged. +- **Decision**: ProjectDetail's rail is now a two-tier structure. Primary rail (always visible, in order): Backlog · Planning · Tasks · Documents. Secondary group under a "More ▾" popover: Overview · Drift · Activity. Settings moves to a gear icon in the project header (right of "Sync Now"). The "More ▾" button shows an aggregated count/dot when demoted tabs carry actionable state (open drift signals, recent agent runs) so demotion never blinds the operator. All `?tab=` deep links continue to resolve. Superseded in part on 2026-05-01: `Backlog` is now the first primary rail item and default tab, `Workspace` is relabelled `Planning`, and `Overview` is demoted from the primary rail. - **Alternatives considered**: (1) Keep Drift in the primary rail and demote only Activity — rejected because Drift already has a count badge in its rail button; demoting it does not lose signal. (2) Also demote Documents — rejected because document-centric projects use it as often as Tasks. (3) Remove Settings entirely and move per-project configuration into a right-hand slide-out panel — rejected; the gear-icon affordance is cheaper, reuses the existing tab machinery, and keeps Settings URL-addressable for docs/screenshots. (4) Keep seven tabs but add an onboarding tooltip — rejected as a band-aid over a real IA problem. -- **Constraints introduced**: (a) The primary rail's four tabs are a stable set. Any future new ProjectDetail feature that looks like a "primary" surface should demote one of the existing four, not grow the rail to five. (b) `More ▾` popover content must render a count/dot indicator for any contained tab whose demotion would otherwise hide actionable state (the test case T-P4-1-8 encodes this invariant). (c) Settings gear icon uses `aria-label="Project settings"` and active-tab styling — any refactor of the page-header actions block must preserve both. +- **Constraints introduced**: (a) The primary rail's current four workflow tabs are a stable set: Backlog, Planning, Tasks, Documents. Future primary additions still require a DECISIONS update and should demote an existing primary tab when possible. (b) `More ▾` popover content must render a count/dot indicator for any contained tab whose demotion would otherwise hide actionable state. (c) Settings gear icon uses `aria-label="Project settings"` and active-tab styling — any refactor of the page-header actions block must preserve both. - **Source**: Phase 4 P4-1. See `docs/phase4-plan.md` §5 P4-1 for the slice's DoD and test matrix. ## 2026-04-24: Model Settings hub page at `/settings/models-hub` [agent:feature-planner] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 92bca27..7cf8423 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -106,6 +106,7 @@ func main() { planningSettingsStore := store.NewPlanningSettingsStore(db, settingsBox) accountBindingStore := store.NewAccountBindingStore(db, settingsBox) localConnectorStore := store.NewLocalConnectorStore(db, dialect) + backlogItemStore := store.NewBacklogItemStore(db, dialect) // Phase 3B stores contextSnapshotStore := store.NewContextSnapshotStore(db) @@ -122,6 +123,7 @@ func main() { // Phase 1 handlers projectHandler := handlers.NewProjectHandler(projectStore, repoMappingStore) requirementHandler := handlers.NewRequirementHandler(requirementStore, projectStore) + backlogItemHandler := handlers.NewBacklogItemHandler(backlogItemStore, projectStore) taskHandler := handlers.NewTaskHandler(taskStore, projectStore) documentHandler := handlers.NewDocumentHandler(documentStore, projectStore, repoMappingStore) summaryHandler := handlers.NewSummaryHandler(summaryStore, projectStore) @@ -215,6 +217,7 @@ func main() { ProjectHandler: projectHandler, RequirementHandler: requirementHandler, PlanningRunHandler: planningRunHandler, + BacklogItemHandler: backlogItemHandler, TaskHandler: taskHandler, DocumentHandler: documentHandler, SummaryHandler: summaryHandler, diff --git a/backend/db/migrations/036_backlog_items.down.sql b/backend/db/migrations/036_backlog_items.down.sql new file mode 100644 index 0000000..c13aaa8 --- /dev/null +++ b/backend/db/migrations/036_backlog_items.down.sql @@ -0,0 +1,10 @@ +DROP INDEX IF EXISTS idx_task_lineage_backlog_item_id; +ALTER TABLE task_lineage DROP COLUMN backlog_item_id; + +DROP INDEX IF EXISTS idx_backlog_items_task_unique; +DROP INDEX IF EXISTS idx_backlog_items_candidate_unique; +DROP INDEX IF EXISTS idx_backlog_items_project_updated; +DROP INDEX IF EXISTS idx_backlog_items_project_priority; +DROP INDEX IF EXISTS idx_backlog_items_project_status_rank; + +DROP TABLE IF EXISTS backlog_items; diff --git a/backend/db/migrations/036_backlog_items.sql b/backend/db/migrations/036_backlog_items.sql new file mode 100644 index 0000000..8837560 --- /dev/null +++ b/backend/db/migrations/036_backlog_items.sql @@ -0,0 +1,53 @@ +-- Migration 036: first-class backlog items +-- +-- backlog_items becomes the user-facing backlog source of truth. It can be +-- created manually without a requirement, or linked to planning/candidate +-- artifacts when generated by the system. + +CREATE TABLE IF NOT EXISTS backlog_items ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + requirement_id TEXT, + planning_run_id TEXT, + backlog_candidate_id TEXT, + task_id TEXT, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'triage', + priority TEXT NOT NULL DEFAULT 'medium', + source TEXT NOT NULL DEFAULT 'human', + rank INTEGER NOT NULL DEFAULT 0, + labels JSONB NOT NULL DEFAULT '[]', + acceptance_criteria TEXT NOT NULL DEFAULT '', + blocked_reason TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (requirement_id) REFERENCES requirements(id) ON DELETE SET NULL, + FOREIGN KEY (planning_run_id) REFERENCES planning_runs(id) ON DELETE SET NULL, + FOREIGN KEY (backlog_candidate_id) REFERENCES backlog_candidates(id) ON DELETE SET NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_backlog_items_project_status_rank + ON backlog_items(project_id, status, rank ASC, updated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_backlog_items_project_priority + ON backlog_items(project_id, priority); + +CREATE INDEX IF NOT EXISTS idx_backlog_items_project_updated + ON backlog_items(project_id, updated_at DESC); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_backlog_items_candidate_unique + ON backlog_items(backlog_candidate_id) + WHERE backlog_candidate_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_backlog_items_task_unique + ON backlog_items(task_id) + WHERE task_id IS NOT NULL; + +ALTER TABLE task_lineage + ADD COLUMN backlog_item_id TEXT REFERENCES backlog_items(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_task_lineage_backlog_item_id + ON task_lineage(backlog_item_id); diff --git a/backend/internal/handlers/backlog_items.go b/backend/internal/handlers/backlog_items.go new file mode 100644 index 0000000..92cc9f2 --- /dev/null +++ b/backend/internal/handlers/backlog_items.go @@ -0,0 +1,239 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/screenleon/agent-native-pm/internal/models" + "github.com/screenleon/agent-native-pm/internal/store" +) + +type BacklogItemHandler struct { + store *store.BacklogItemStore + projectStore *store.ProjectStore +} + +func NewBacklogItemHandler(s *store.BacklogItemStore, ps *store.ProjectStore) *BacklogItemHandler { + return &BacklogItemHandler{store: s, projectStore: ps} +} + +func (h *BacklogItemHandler) ListByProject(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + if !h.projectReadable(w, r, projectID) { + return + } + + page, perPage := parsePagination(r) + sort := r.URL.Query().Get("sort") + order := r.URL.Query().Get("order") + filters := models.BacklogItemListFilters{ + Status: r.URL.Query().Get("status"), + Priority: r.URL.Query().Get("priority"), + Source: r.URL.Query().Get("source"), + Label: strings.TrimSpace(r.URL.Query().Get("label")), + Query: strings.TrimSpace(r.URL.Query().Get("q")), + } + if !validateBacklogItemFilters(w, filters) { + return + } + + items, total, err := h.store.ListByProject(projectID, page, perPage, sort, order, filters) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list backlog items") + return + } + if items == nil { + items = []models.BacklogItem{} + } + writeSuccess(w, http.StatusOK, items, models.PaginationMeta{Page: page, PerPage: perPage, Total: total}) +} + +func (h *BacklogItemHandler) Create(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + if !h.projectReadable(w, r, projectID) { + return + } + + var req models.CreateBacklogItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + req.Title = strings.TrimSpace(req.Title) + if req.Title == "" { + writeError(w, http.StatusBadRequest, "title is required") + return + } + if req.Status != "" && !models.ValidBacklogItemStatuses[req.Status] { + writeError(w, http.StatusBadRequest, "invalid status value") + return + } + if req.Status == models.BacklogItemStatusCommitted { + writeError(w, http.StatusBadRequest, "committed status is set by commit-to-task") + return + } + if req.Priority != "" && !models.ValidTaskPriorities[req.Priority] { + writeError(w, http.StatusBadRequest, "invalid priority value") + return + } + if req.Source != "" && !models.ValidBacklogItemSources[req.Source] { + writeError(w, http.StatusBadRequest, "invalid source value") + return + } + + item, err := h.store.Create(projectID, req) + if err != nil { + if errors.Is(err, store.ErrBacklogItemInvalidOrigin) { + writeError(w, http.StatusBadRequest, "origin fields must belong to the project") + return + } + writeError(w, http.StatusInternalServerError, "failed to create backlog item") + return + } + writeSuccess(w, http.StatusCreated, item, nil) +} + +func (h *BacklogItemHandler) Get(w http.ResponseWriter, r *http.Request) { + item, ok := h.itemReadable(w, r) + if !ok { + return + } + writeSuccess(w, http.StatusOK, item, nil) +} + +func (h *BacklogItemHandler) Update(w http.ResponseWriter, r *http.Request) { + item, ok := h.itemReadable(w, r) + if !ok { + return + } + + var req models.UpdateBacklogItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Title != nil && strings.TrimSpace(*req.Title) == "" { + writeError(w, http.StatusBadRequest, "title cannot be blank") + return + } + if req.Status != nil && !models.ValidBacklogItemStatuses[*req.Status] { + writeError(w, http.StatusBadRequest, "invalid status value") + return + } + if req.Status != nil && *req.Status == models.BacklogItemStatusCommitted && (item.Status != models.BacklogItemStatusCommitted || item.TaskID == "") { + writeError(w, http.StatusBadRequest, "committed status is set by commit-to-task") + return + } + if req.Status != nil && item.TaskID != "" && *req.Status != models.BacklogItemStatusCommitted { + writeError(w, http.StatusConflict, "committed backlog item status cannot be changed") + return + } + if req.Priority != nil && !models.ValidTaskPriorities[*req.Priority] { + writeError(w, http.StatusBadRequest, "invalid priority value") + return + } + if item.TaskID != "" && changesCommittedTaskSnapshot(item, req) { + writeError(w, http.StatusConflict, "committed backlog item task fields cannot be changed") + return + } + + updated, err := h.store.Update(item.ID, req) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update backlog item") + return + } + if updated == nil { + writeError(w, http.StatusNotFound, "backlog item not found") + return + } + writeSuccess(w, http.StatusOK, updated, nil) +} + +func (h *BacklogItemHandler) CommitToTask(w http.ResponseWriter, r *http.Request) { + item, ok := h.itemReadable(w, r) + if !ok { + return + } + + response, err := h.store.CommitToTask(item.ID) + if err != nil { + if errors.Is(err, store.ErrBacklogItemAlreadyArchived) { + writeError(w, http.StatusConflict, "archived backlog item cannot be committed") + return + } + if errors.Is(err, store.ErrBacklogItemNotCommittable) { + writeError(w, http.StatusConflict, "backlog item must be triage or ready before commit") + return + } + writeError(w, http.StatusInternalServerError, "failed to commit backlog item") + return + } + if response == nil { + writeError(w, http.StatusNotFound, "backlog item not found") + return + } + writeSuccess(w, http.StatusOK, response, nil) +} + +func (h *BacklogItemHandler) projectReadable(w http.ResponseWriter, r *http.Request, projectID string) bool { + if !requestAllowsProject(r, projectID) { + writeError(w, http.StatusNotFound, "project not found") + return false + } + project, err := h.projectStore.GetByID(projectID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to verify project") + return false + } + if project == nil || !projectAllowedForUser(r, h.projectStore, projectID) { + writeError(w, http.StatusNotFound, "project not found") + return false + } + return true +} + +func changesCommittedTaskSnapshot(item *models.BacklogItem, req models.UpdateBacklogItemRequest) bool { + if req.Title != nil && strings.TrimSpace(*req.Title) != item.Title { + return true + } + if req.Description != nil && *req.Description != item.Description { + return true + } + if req.Priority != nil && *req.Priority != item.Priority { + return true + } + return false +} + +func (h *BacklogItemHandler) itemReadable(w http.ResponseWriter, r *http.Request) (*models.BacklogItem, bool) { + id := chi.URLParam(r, "id") + item, err := h.store.GetByID(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to get backlog item") + return nil, false + } + if item == nil || !requestAllowsProject(r, item.ProjectID) || !projectAllowedForUser(r, h.projectStore, item.ProjectID) { + writeError(w, http.StatusNotFound, "backlog item not found") + return nil, false + } + return item, true +} + +func validateBacklogItemFilters(w http.ResponseWriter, filters models.BacklogItemListFilters) bool { + if filters.Status != "" && !models.ValidBacklogItemStatuses[filters.Status] { + writeError(w, http.StatusBadRequest, "invalid status value") + return false + } + if filters.Priority != "" && !models.ValidTaskPriorities[filters.Priority] { + writeError(w, http.StatusBadRequest, "invalid priority value") + return false + } + if filters.Source != "" && !models.ValidBacklogItemSources[filters.Source] { + writeError(w, http.StatusBadRequest, "invalid source value") + return false + } + return true +} diff --git a/backend/internal/handlers/handlers_test.go b/backend/internal/handlers/handlers_test.go index 1ab401e..c674567 100644 --- a/backend/internal/handlers/handlers_test.go +++ b/backend/internal/handlers/handlers_test.go @@ -34,6 +34,7 @@ func setupTestServer(t *testing.T) http.Handler { rs := store.NewRequirementStore(db) prs := store.NewPlanningRunStore(db, testutil.TestDialect()) bcs := store.NewBacklogCandidateStore(db, testutil.TestDialect()) + bis := store.NewBacklogItemStore(db, testutil.TestDialect()) ts := store.NewTaskStore(db) ds := store.NewDocumentStore(db) srs := store.NewSyncRunStore(db) @@ -50,6 +51,7 @@ func setupTestServer(t *testing.T) http.Handler { ProjectHandler: handlers.NewProjectHandler(ps, rms), RequirementHandler: handlers.NewRequirementHandler(rs, ps), PlanningRunHandler: handlers.NewPlanningRunHandler(prs, bcs, ps, rs, ars, planner), + BacklogItemHandler: handlers.NewBacklogItemHandler(bis, ps), TaskHandler: handlers.NewTaskHandler(ts, ps), DocumentHandler: handlers.NewDocumentHandler(ds, ps, rms), SummaryHandler: handlers.NewSummaryHandler(ss, ps), @@ -306,7 +308,7 @@ func TestTaskListFilters(t *testing.T) { t.Fatalf("invalid status filter: expected 400, got %d: %s", w.Code, w.Body.String()) } - req = httptest.NewRequest("GET", "/api/projects/"+projectID+"/tasks?priority=urgent", nil) + req = httptest.NewRequest("GET", "/api/projects/"+projectID+"/tasks?priority=now", nil) w = httptest.NewRecorder() srv.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { @@ -405,6 +407,382 @@ func TestTaskBatchUpdate(t *testing.T) { } } +func createProjectForBacklogTest(t *testing.T, srv http.Handler, name string) string { + t.Helper() + if name == "" { + name = "Backlog Project" + } + req := httptest.NewRequest("POST", "/api/projects", strings.NewReader(fmt.Sprintf(`{"name":%q}`, name))) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create project: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var projResp models.Envelope + if err := json.NewDecoder(w.Body).Decode(&projResp); err != nil { + t.Fatalf("decode project: %v", err) + } + return projResp.Data.(map[string]interface{})["id"].(string) +} + +func createBacklogItemForTest(t *testing.T, srv http.Handler, projectID, body string) map[string]interface{} { + t.Helper() + req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/backlog-items", strings.NewReader(body)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create backlog item: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var createResp models.Envelope + if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil { + t.Fatalf("decode backlog item: %v", err) + } + return createResp.Data.(map[string]interface{}) +} + +// Commits an urgent backlog item to a task while preserving priority and lineage. +// Steps: +// 1. Create a project and one urgent backlog item with labels. +// 2. List it through priority+label filters and commit it to a task twice. +// 3. Assert urgent priority, backlog-item lineage, and idempotent already_applied response. +func TestBacklogItemCreateListAndCommitUrgent(t *testing.T) { + srv := setupTestServer(t) + projectID := createProjectForBacklogTest(t, srv, "Backlog Project") + + createBody := `{ + "title":"Patch production incident path", + "description":"Keep urgent distinct from high", + "priority":"urgent", + "labels":["api","incident"], + "acceptance_criteria":"Task keeps urgent priority" + }` + item := createBacklogItemForTest(t, srv, projectID, createBody) + itemID := item["id"].(string) + if got := item["priority"]; got != "urgent" { + t.Fatalf("expected urgent backlog priority, got %v", got) + } + + req := httptest.NewRequest("GET", "/api/projects/"+projectID+"/backlog-items?priority=urgent&label=incident", nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("list backlog items: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var listResp models.Envelope + if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil { + t.Fatalf("decode backlog list: %v", err) + } + items := listResp.Data.([]interface{}) + if len(items) != 1 { + t.Fatalf("expected one filtered backlog item, got %d", len(items)) + } + + req = httptest.NewRequest("POST", "/api/backlog-items/"+itemID+"/commit-to-task", nil) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("commit backlog item: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var commitResp models.Envelope + if err := json.NewDecoder(w.Body).Decode(&commitResp); err != nil { + t.Fatalf("decode commit response: %v", err) + } + commitData := commitResp.Data.(map[string]interface{}) + task := commitData["task"].(map[string]interface{}) + if got := task["priority"]; got != "urgent" { + t.Fatalf("expected committed task priority urgent, got %v", got) + } + lineage := commitData["lineage"].(map[string]interface{}) + if got := lineage["lineage_kind"]; got != models.TaskLineageKindBacklogItem { + t.Fatalf("expected backlog_item lineage, got %v", got) + } + if got := lineage["backlog_item_id"]; got != itemID { + t.Fatalf("expected backlog item lineage id %s, got %v", itemID, got) + } + + req = httptest.NewRequest("POST", "/api/backlog-items/"+itemID+"/commit-to-task", nil) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("commit already committed backlog item: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var secondCommitResp models.Envelope + if err := json.NewDecoder(w.Body).Decode(&secondCommitResp); err != nil { + t.Fatalf("decode second commit response: %v", err) + } + secondCommitData := secondCommitResp.Data.(map[string]interface{}) + if got := secondCommitData["already_applied"]; got != true { + t.Fatalf("expected already_applied true, got %v", got) + } +} + +// Allows metadata edits on committed backlog items while locking task-owned fields. +// Steps: +// 1. Create and commit a backlog item. +// 2. Patch labels with status still committed, then attempt to change the title. +// 3. Assert labels save but task-owned title changes are rejected with 409. +func TestBacklogItemUpdateCommittedItemLocksTaskOwnedFields(t *testing.T) { + srv := setupTestServer(t) + projectID := createProjectForBacklogTest(t, srv, "Committed Backlog Project") + item := createBacklogItemForTest(t, srv, projectID, `{"title":"Patch production incident path","priority":"urgent","labels":["api","incident"]}`) + itemID := item["id"].(string) + + req := httptest.NewRequest("POST", "/api/backlog-items/"+itemID+"/commit-to-task", nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("commit backlog item: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + req = httptest.NewRequest("PATCH", "/api/backlog-items/"+itemID, strings.NewReader(`{"labels":["api","incident","committed"],"status":"committed"}`)) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("patch committed backlog item labels: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var patchResp models.Envelope + if err := json.NewDecoder(w.Body).Decode(&patchResp); err != nil { + t.Fatalf("decode committed patch response: %v", err) + } + labels := patchResp.Data.(map[string]interface{})["labels"].([]interface{}) + if len(labels) != 3 { + t.Fatalf("expected 3 committed labels, got %d", len(labels)) + } + + req = httptest.NewRequest("PATCH", "/api/backlog-items/"+itemID, strings.NewReader(`{"title":"Patch production incident path v2","status":"committed"}`)) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("patch committed backlog item task fields: expected 409, got %d: %s", w.Code, w.Body.String()) + } +} + +// Rejects committed status on create because only commit-to-task can set it. +// Steps: +// 1. Create a project. +// 2. POST a backlog item with status=committed. +// 3. Assert the API rejects the request before any item is created. +func TestBacklogItemCreateRejectsCommittedStatus(t *testing.T) { + srv := setupTestServer(t) + projectID := createProjectForBacklogTest(t, srv, "Reject Committed Create") + + req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/backlog-items", strings.NewReader(`{"title":"Invalid committed create","status":"committed"}`)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("create committed backlog item: expected 400, got %d: %s", w.Code, w.Body.String()) + } + + req = httptest.NewRequest("GET", "/api/projects/"+projectID+"/backlog-items", nil) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("list after rejected create: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var listResp models.Envelope + if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil { + t.Fatalf("decode backlog list: %v", err) + } + if got := len(listResp.Data.([]interface{})); got != 0 { + t.Fatalf("expected rejected committed create to leave 0 backlog items, got %d", got) + } +} + +func assertBacklogListFilterRejected(t *testing.T, srv http.Handler, projectID, query string) { + t.Helper() + req := httptest.NewRequest("GET", "/api/projects/"+projectID+"/backlog-items"+query, nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for query %s, got %d: %s", query, w.Code, w.Body.String()) + } +} + +// Rejects invalid backlog status filters with a concrete 400 contract. +// Steps: +// 1. Create a project. +// 2. Request the list endpoint with an invalid status value. +// 3. Assert the API rejects the filter instead of silently widening results. +func TestBacklogItemListRejectsInvalidStatusFilter(t *testing.T) { + srv := setupTestServer(t) + projectID := createProjectForBacklogTest(t, srv, "Invalid Filter Backlog Project") + assertBacklogListFilterRejected(t, srv, projectID, "?status=done") +} + +// Rejects invalid backlog priority filters with a concrete 400 contract. +// Steps: +// 1. Create a project. +// 2. Request the list endpoint with an invalid priority value. +// 3. Assert the API rejects the filter instead of silently widening results. +func TestBacklogItemListRejectsInvalidPriorityFilter(t *testing.T) { + srv := setupTestServer(t) + projectID := createProjectForBacklogTest(t, srv, "Invalid Filter Backlog Project") + assertBacklogListFilterRejected(t, srv, projectID, "?priority=critical") +} + +// Rejects invalid backlog source filters with a concrete 400 contract. +// Steps: +// 1. Create a project. +// 2. Request the list endpoint with an invalid source value. +// 3. Assert the API rejects the filter instead of silently widening results. +func TestBacklogItemListRejectsInvalidSourceFilter(t *testing.T) { + srv := setupTestServer(t) + projectID := createProjectForBacklogTest(t, srv, "Invalid Filter Backlog Project") + assertBacklogListFilterRejected(t, srv, projectID, "?source=automation") +} + +// Paginates backlog items by rank while returning the unpaginated total. +// Steps: +// 1. Create three backlog items with out-of-order ranks. +// 2. List page 1 and page 2 with per_page=2 sorted by rank. +// 3. Assert rank ordering and meta.total remain stable across pages. +func TestBacklogItemListPaginatesByRankWithTotal(t *testing.T) { + srv := setupTestServer(t) + projectID := createProjectForBacklogTest(t, srv, "Paged Backlog Project") + createBacklogItemForTest(t, srv, projectID, `{"title":"Third ranked","rank":3}`) + createBacklogItemForTest(t, srv, projectID, `{"title":"First ranked","rank":1}`) + createBacklogItemForTest(t, srv, projectID, `{"title":"Second ranked","rank":2}`) + + req := httptest.NewRequest("GET", "/api/projects/"+projectID+"/backlog-items?sort=rank&order=asc&page=1&per_page=2", nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("list page 1: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var pageOne models.Envelope + if err := json.NewDecoder(w.Body).Decode(&pageOne); err != nil { + t.Fatalf("decode page 1: %v", err) + } + pageOneItems := pageOne.Data.([]interface{}) + if got := len(pageOneItems); got != 2 { + t.Fatalf("expected page 1 to contain 2 items, got %d", got) + } + if got := pageOneItems[0].(map[string]interface{})["title"]; got != "First ranked" { + t.Fatalf("expected first page item to be First ranked, got %v", got) + } + if got := pageOneItems[1].(map[string]interface{})["title"]; got != "Second ranked" { + t.Fatalf("expected second page item to be Second ranked, got %v", got) + } + if got := pageOne.Meta.(map[string]interface{})["total"]; got != float64(3) { + t.Fatalf("expected total 3 on page 1, got %v", got) + } + + req = httptest.NewRequest("GET", "/api/projects/"+projectID+"/backlog-items?sort=rank&order=asc&page=2&per_page=2", nil) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("list page 2: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var pageTwo models.Envelope + if err := json.NewDecoder(w.Body).Decode(&pageTwo); err != nil { + t.Fatalf("decode page 2: %v", err) + } + pageTwoItems := pageTwo.Data.([]interface{}) + if got := len(pageTwoItems); got != 1 { + t.Fatalf("expected page 2 to contain 1 item, got %d", got) + } + if got := pageTwoItems[0].(map[string]interface{})["title"]; got != "Third ranked" { + t.Fatalf("expected page 2 item to be Third ranked, got %v", got) + } + if got := pageTwo.Meta.(map[string]interface{})["total"]; got != float64(3) { + t.Fatalf("expected total 3 on page 2, got %v", got) + } +} + +// Rejects backlog origins that belong to a different project. +// Steps: +// 1. Create two projects and a requirement under project A. +// 2. Attempt to create a project B backlog item pointing at project A's requirement. +// 3. Assert the API returns 400 instead of creating cross-project lineage. +func TestBacklogItemCreateRejectsCrossProjectOrigin(t *testing.T) { + srv := setupTestServer(t) + + createProject := func(name string) string { + req := httptest.NewRequest("POST", "/api/projects", strings.NewReader(fmt.Sprintf(`{"name":%q}`, name))) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create project %s: expected 201, got %d: %s", name, w.Code, w.Body.String()) + } + var resp models.Envelope + json.NewDecoder(w.Body).Decode(&resp) + return resp.Data.(map[string]interface{})["id"].(string) + } + projectAID := createProject("Backlog Origin A") + projectBID := createProject("Backlog Origin B") + + req := httptest.NewRequest("POST", "/api/projects/"+projectAID+"/requirements", strings.NewReader(`{"title":"Origin requirement"}`)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create requirement: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var reqResp models.Envelope + if err := json.NewDecoder(w.Body).Decode(&reqResp); err != nil { + t.Fatalf("decode requirement: %v", err) + } + requirementID := reqResp.Data.(map[string]interface{})["id"].(string) + + req = httptest.NewRequest("POST", "/api/projects/"+projectBID+"/backlog-items", strings.NewReader(fmt.Sprintf(`{"title":"Cross-project origin","requirement_id":%q}`, requirementID))) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("cross-project origin: expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +// Rejects archived backlog items at commit time and leaves the task list untouched. +// Steps: +// 1. Create an archived backlog item. +// 2. Attempt to commit it to a task. +// 3. Assert the API returns 409 and no task side effect is created. +func TestBacklogItemCommitRejectsArchivedStatusWithoutCreatingTask(t *testing.T) { + srv := setupTestServer(t) + projectID := createProjectForBacklogTest(t, srv, "Archived Backlog Project") + item := createBacklogItemForTest(t, srv, projectID, `{"title":"Archived item","status":"archived","priority":"urgent"}`) + itemID := item["id"].(string) + + req := httptest.NewRequest("POST", "/api/backlog-items/"+itemID+"/commit-to-task", nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("commit archived item: expected 409, got %d: %s", w.Code, w.Body.String()) + } + + req = httptest.NewRequest("GET", "/api/projects/"+projectID+"/tasks", nil) + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("list tasks after archived commit rejection: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var taskList models.Envelope + if err := json.NewDecoder(w.Body).Decode(&taskList); err != nil { + t.Fatalf("decode task list: %v", err) + } + if got := len(taskList.Data.([]interface{})); got != 0 { + t.Fatalf("expected archived commit rejection to create 0 tasks, got %d", got) + } +} + +// Rejects blocked backlog items at commit time before any task side effect exists. +// Steps: +// 1. Create a blocked urgent backlog item with a blocked reason. +// 2. Attempt to commit it to a task. +// 3. Assert the API returns 409 instead of creating a task. +func TestBacklogItemCommitRejectsBlockedStatus(t *testing.T) { + srv := setupTestServer(t) + projectID := createProjectForBacklogTest(t, srv, "Blocked Backlog Project") + item := createBacklogItemForTest(t, srv, projectID, `{"title":"Blocked item","status":"blocked","priority":"urgent","blocked_reason":"Needs API contract"}`) + itemID := item["id"].(string) + + req := httptest.NewRequest("POST", "/api/backlog-items/"+itemID+"/commit-to-task", nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("commit blocked item: expected 409, got %d: %s", w.Code, w.Body.String()) + } +} + func TestTaskBatchUpdateRollsBackWhenTaskMissingFromProject(t *testing.T) { srv := setupTestServer(t) diff --git a/backend/internal/handlers/remote_models.go b/backend/internal/handlers/remote_models.go index 837fb77..9978ff2 100644 --- a/backend/internal/handlers/remote_models.go +++ b/backend/internal/handlers/remote_models.go @@ -23,8 +23,8 @@ const maxProviderResponseBytes = 1 << 20 // 1 MiB // RemoteModelsHandler proxies model-discovery and connection-probe requests // to any OpenAI-compatible endpoint on behalf of the frontend. type RemoteModelsHandler struct { - fetchClient *http.Client // short timeout — models list should be fast - probeClient *http.Client // longer timeout — chat completion may be slow + fetchClient *http.Client // short timeout — models list should be fast + probeClient *http.Client // longer timeout — chat completion may be slow bindingStore *store.AccountBindingStore } @@ -36,6 +36,21 @@ func NewRemoteModelsHandler(bindingStore *store.AccountBindingStore) *RemoteMode } } +// WithHTTPClients replaces the outbound provider clients. It is used by tests +// to exercise provider calls without opening a listening socket. +func (h *RemoteModelsHandler) WithHTTPClients(fetchClient, probeClient *http.Client) *RemoteModelsHandler { + if h == nil { + return nil + } + if fetchClient != nil { + h.fetchClient = fetchClient + } + if probeClient != nil { + h.probeClient = probeClient + } + return h +} + // ── Fetch models ────────────────────────────────────────────────────────────── type remoteModelsRequest struct { diff --git a/backend/internal/handlers/remote_models_probe_test.go b/backend/internal/handlers/remote_models_probe_test.go index 9990b2d..2abfa08 100644 --- a/backend/internal/handlers/remote_models_probe_test.go +++ b/backend/internal/handlers/remote_models_probe_test.go @@ -6,6 +6,7 @@ package handlers_test import ( "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" "testing" @@ -18,18 +19,35 @@ import ( "github.com/screenleon/agent-native-pm/internal/testutil" ) -// fakeLLMServer returns a minimal OpenAI-compatible chat completion response -// for any POST /chat/completions request. -func fakeLLMServer(t *testing.T) *httptest.Server { +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +// fakeLLMClient returns a minimal OpenAI-compatible response without opening +// a TCP listener. The sandbox used in CI/test agents may forbid socket binds, +// so this exercises the same handler path through an in-memory RoundTripper. +func fakeLLMClient(t *testing.T) *http.Client { t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ + return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/chat/completions" { + return fakeJSONResponse(http.StatusNotFound, `{"error":{"message":"not found"}}`), nil + } + return fakeJSONResponse(http.StatusOK, `{ "model": "test-model", "choices": [{"message": {"content": "ok"}}], "usage": {"prompt_tokens": 5, "completion_tokens": 1} - }`)) - })) + }`), nil + })} +} + +func fakeJSONResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader([]byte(body))), + } } // probeFixture wires AccountBindingHandler + RemoteModelsHandler into one @@ -51,7 +69,7 @@ func newProbeFixture(t *testing.T) probeFixture { } bs := store.NewAccountBindingStore(db, nil) abh := handlers.NewAccountBindingHandler(bs).WithLocalMode(true) - rmh := handlers.NewRemoteModelsHandler(bs) + rmh := handlers.NewRemoteModelsHandler(bs).WithHTTPClients(nil, fakeLLMClient(t)) srv := router.New(router.Deps{ AccountBindingHandler: abh, @@ -170,11 +188,8 @@ func Test3A1_6ListUserNullProbeColumns(t *testing.T) { // T-3A1-7: POST /api/me/probe-model with binding_id persists probe result. // Uses a fake LLM server so the probe HTTP call succeeds without a real provider. func Test3A1_7ProbeWithBindingIDPersists(t *testing.T) { - llm := fakeLLMServer(t) - defer llm.Close() - f := newProbeFixture(t) - bindingID := createAPIBinding(t, f.srv, llm.URL+"/v1") + bindingID := createAPIBinding(t, f.srv, "http://localhost:11434/v1") // POST probe-model referencing the binding. probeBody, _ := json.Marshal(map[string]interface{}{ @@ -230,16 +245,13 @@ func Test3A1_7ProbeWithBindingIDPersists(t *testing.T) { // T-3A1-8: POST /api/me/probe-model without binding_id does not touch any binding row. func Test3A1_8ProbeWithoutBindingIDNoPersist(t *testing.T) { - llm := fakeLLMServer(t) - defer llm.Close() - f := newProbeFixture(t) // Create a binding so we can verify it was untouched afterwards. createAPIBinding(t, f.srv, "http://localhost:11434/v1") // POST probe-model with inline credentials (no binding_id). probeBody, _ := json.Marshal(map[string]interface{}{ - "base_url": llm.URL + "/v1", + "base_url": "http://localhost:11434/v1", "model_id": "test-model", }) req := httptest.NewRequest(http.MethodPost, "/api/me/probe-model", bytes.NewReader(probeBody)) diff --git a/backend/internal/models/backlog_item.go b/backend/internal/models/backlog_item.go new file mode 100644 index 0000000..f2661f9 --- /dev/null +++ b/backend/internal/models/backlog_item.go @@ -0,0 +1,92 @@ +package models + +import "time" + +const ( + BacklogItemStatusTriage = "triage" + BacklogItemStatusReady = "ready" + BacklogItemStatusCommitted = "committed" + BacklogItemStatusBlocked = "blocked" + BacklogItemStatusArchived = "archived" + + BacklogItemSourceHuman = "human" + BacklogItemSourcePlanningRun = "planning_run" + BacklogItemSourceBacklogCandidate = "backlog_candidate" + BacklogItemSourceConnector = "connector" +) + +var ValidBacklogItemStatuses = map[string]bool{ + BacklogItemStatusTriage: true, + BacklogItemStatusReady: true, + BacklogItemStatusCommitted: true, + BacklogItemStatusBlocked: true, + BacklogItemStatusArchived: true, +} + +var ValidBacklogItemSources = map[string]bool{ + BacklogItemSourceHuman: true, + BacklogItemSourcePlanningRun: true, + BacklogItemSourceBacklogCandidate: true, + BacklogItemSourceConnector: true, +} + +type BacklogItem struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + RequirementID string `json:"requirement_id,omitempty"` + PlanningRunID string `json:"planning_run_id,omitempty"` + BacklogCandidateID string `json:"backlog_candidate_id,omitempty"` + TaskID string `json:"task_id,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + Source string `json:"source"` + Rank int `json:"rank"` + Labels []string `json:"labels"` + AcceptanceCriteria string `json:"acceptance_criteria"` + BlockedReason string `json:"blocked_reason"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreateBacklogItemRequest struct { + RequirementID string `json:"requirement_id,omitempty"` + PlanningRunID string `json:"planning_run_id,omitempty"` + BacklogCandidateID string `json:"backlog_candidate_id,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + Source string `json:"source"` + Rank int `json:"rank"` + Labels []string `json:"labels"` + AcceptanceCriteria string `json:"acceptance_criteria"` + BlockedReason string `json:"blocked_reason"` +} + +type UpdateBacklogItemRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Status *string `json:"status,omitempty"` + Priority *string `json:"priority,omitempty"` + Rank *int `json:"rank,omitempty"` + Labels *[]string `json:"labels,omitempty"` + AcceptanceCriteria *string `json:"acceptance_criteria,omitempty"` + BlockedReason *string `json:"blocked_reason,omitempty"` +} + +type BacklogItemListFilters struct { + Status string + Priority string + Source string + Label string + Query string +} + +type CommitBacklogItemResponse struct { + BacklogItem BacklogItem `json:"backlog_item"` + Task Task `json:"task"` + Lineage TaskLineage `json:"lineage"` + AlreadyApplied bool `json:"already_applied"` +} diff --git a/backend/internal/models/requirement.go b/backend/internal/models/requirement.go index c64621b..d52f310 100644 --- a/backend/internal/models/requirement.go +++ b/backend/internal/models/requirement.go @@ -80,6 +80,7 @@ const ( TaskLineageKindAppliedCandidate = "applied_candidate" TaskLineageKindManualRequirement = "manual_requirement" TaskLineageKindMergedRequirement = "merged_requirement" + TaskLineageKindBacklogItem = "backlog_item" ) var ValidRequirementStatuses = map[string]bool{ @@ -238,8 +239,8 @@ type PlanningRun struct { LeaseExpiresAt *time.Time `json:"lease_expires_at"` DispatchError string `json:"dispatch_error"` ErrorMessage string `json:"error_message"` - AdapterType string `json:"adapter_type,omitempty"` - ModelOverride string `json:"model_override,omitempty"` + AdapterType string `json:"adapter_type,omitempty"` + ModelOverride string `json:"model_override,omitempty"` // AccountBindingID is the account_bindings.id chosen at run-create time // (Path B S2). Nullable to distinguish "no binding selected" from // "explicitly empty" — exposed as a pointer so JSON omits the key when @@ -276,23 +277,23 @@ type PlanningRun struct { // is preserved as the embedded `Invocation` field; new sub-blocks are // optional so old rows decode cleanly. type PlanningRunCliInfo struct { - BindingSnapshot *PlanningRunBindingSnapshot `json:"binding_snapshot,omitempty"` - Invocation *CliUsageInfo `json:"cli_invocation,omitempty"` - ErrorKind string `json:"error_kind,omitempty"` - RemediationHint string `json:"remediation_hint,omitempty"` - DispatchWarning string `json:"dispatch_warning,omitempty"` + BindingSnapshot *PlanningRunBindingSnapshot `json:"binding_snapshot,omitempty"` + Invocation *CliUsageInfo `json:"cli_invocation,omitempty"` + ErrorKind string `json:"error_kind,omitempty"` + RemediationHint string `json:"remediation_hint,omitempty"` + DispatchWarning string `json:"dispatch_warning,omitempty"` } const ( - ErrorKindUnknown = "unknown" - ErrorKindSessionExpired = "session_expired" - ErrorKindRateLimited = "rate_limited" - ErrorKindContextOverflow = "context_overflow" - ErrorKindAdapterTimeout = "adapter_timeout" - ErrorKindCliNotFound = "cli_not_found" - ErrorKindCliTimeout = "cli_timeout" - ErrorKindModelNotAvailable = "model_not_available" - ErrorKindAdapterProtocol = "adapter_protocol_error" + ErrorKindUnknown = "unknown" + ErrorKindSessionExpired = "session_expired" + ErrorKindRateLimited = "rate_limited" + ErrorKindContextOverflow = "context_overflow" + ErrorKindAdapterTimeout = "adapter_timeout" + ErrorKindCliNotFound = "cli_not_found" + ErrorKindCliTimeout = "cli_timeout" + ErrorKindModelNotAvailable = "model_not_available" + ErrorKindAdapterProtocol = "adapter_protocol_error" // Phase 6c: dispatch safety boundary error kinds. These are produced // by the role_dispatch loop in connector/service.go (NOT by planning // runs) — see docs/phase6c-plan.md §3 C2. @@ -302,7 +303,7 @@ const ( // Phase 6c PR-2 will populate role_not_found from the server-side // claim-next-task enforcement; the constant ships in PR-1 so the // allowlist + remediation catalog is finalised in one place. - ErrorKindRoleNotFound = "role_not_found" + ErrorKindRoleNotFound = "role_not_found" // Phase 6c PR-2 (Copilot review #4): distinct kind for tasks whose // source string does NOT carry a "" suffix after // "role_dispatch:". This is structurally different from @@ -324,17 +325,17 @@ const ( // submitted by the adapter. Anything outside this set is normalised to // ErrorKindUnknown (S5a/S5b, design §5 D7). var AllowedErrorKinds = map[string]bool{ - ErrorKindUnknown: true, - ErrorKindSessionExpired: true, - ErrorKindRateLimited: true, - ErrorKindContextOverflow: true, - ErrorKindAdapterTimeout: true, - ErrorKindCliNotFound: true, - ErrorKindCliTimeout: true, - ErrorKindModelNotAvailable: true, - ErrorKindAdapterProtocol: true, - ErrorKindDispatchTimeout: true, - ErrorKindOutputTooLarge: true, + ErrorKindUnknown: true, + ErrorKindSessionExpired: true, + ErrorKindRateLimited: true, + ErrorKindContextOverflow: true, + ErrorKindAdapterTimeout: true, + ErrorKindCliNotFound: true, + ErrorKindCliTimeout: true, + ErrorKindModelNotAvailable: true, + ErrorKindAdapterProtocol: true, + ErrorKindDispatchTimeout: true, + ErrorKindOutputTooLarge: true, ErrorKindInvalidResultSchema: true, ErrorKindRoleNotFound: true, ErrorKindRoleDispatchMalformed: true, @@ -346,20 +347,20 @@ var AllowedErrorKinds = map[string]bool{ // computes the hint from this map and persists it alongside error_kind in // connector_cli_info — adapters never supply free-text hints. var ErrorKindRemediations = map[string]string{ - ErrorKindSessionExpired: "Re-authenticate your CLI (run `claude` or `codex` once interactively) then retry the planning run.", - ErrorKindRateLimited: "Your CLI subscription has hit a rate limit. Wait a few minutes before retrying.", - ErrorKindContextOverflow: "The planning context was too large for the model. Try reducing the number of open requirements or documents in scope.", - ErrorKindAdapterTimeout: "The adapter timed out waiting for the CLI. Check that your CLI is healthy (`anpm-connector doctor`) and retry.", - ErrorKindCliNotFound: "The CLI command was not found on the connector's PATH. Check the cli_command field on your CLI binding and ensure the binary is installed.", - ErrorKindCliTimeout: "The CLI process timed out. Check that your CLI is healthy and retry.", - ErrorKindModelNotAvailable: "The requested model is not available for this CLI. Check the model_id on your CLI binding.", - ErrorKindAdapterProtocol: "The adapter produced unexpected output. Check your adapter script and retry.", - ErrorKindDispatchTimeout: "The role-dispatch CLI ran past its wall-clock budget and was killed. The role's typical budget is shown in the Apply panel; set ANPM_DISPATCH_TIMEOUT (seconds) to override globally for unusually long tasks, or 0 to disable.", - ErrorKindOutputTooLarge: "The CLI produced more output than the dispatch boundary allows (default 5 MB). Re-run with a tighter task scope, or set ANPM_DISPATCH_OUTPUT_MAX (bytes) to raise the limit (0 disables).", - ErrorKindInvalidResultSchema: "The CLI returned output that does not match the role result schema (must include a `files` array). Check the role prompt and retry.", + ErrorKindSessionExpired: "Re-authenticate your CLI (run `claude` or `codex` once interactively) then retry the planning run.", + ErrorKindRateLimited: "Your CLI subscription has hit a rate limit. Wait a few minutes before retrying.", + ErrorKindContextOverflow: "The planning context was too large for the model. Try reducing the number of open requirements or documents in scope.", + ErrorKindAdapterTimeout: "The adapter timed out waiting for the CLI. Check that your CLI is healthy (`anpm-connector doctor`) and retry.", + ErrorKindCliNotFound: "The CLI command was not found on the connector's PATH. Check the cli_command field on your CLI binding and ensure the binary is installed.", + ErrorKindCliTimeout: "The CLI process timed out. Check that your CLI is healthy and retry.", + ErrorKindModelNotAvailable: "The requested model is not available for this CLI. Check the model_id on your CLI binding.", + ErrorKindAdapterProtocol: "The adapter produced unexpected output. Check your adapter script and retry.", + ErrorKindDispatchTimeout: "The role-dispatch CLI ran past its wall-clock budget and was killed. The role's typical budget is shown in the Apply panel; set ANPM_DISPATCH_TIMEOUT (seconds) to override globally for unusually long tasks, or 0 to disable.", + ErrorKindOutputTooLarge: "The CLI produced more output than the dispatch boundary allows (default 5 MB). Re-run with a tighter task scope, or set ANPM_DISPATCH_OUTPUT_MAX (bytes) to raise the limit (0 disables).", + ErrorKindInvalidResultSchema: "The CLI returned output that does not match the role result schema (must include a `files` array). Check the role prompt and retry.", ErrorKindRoleNotFound: "The task references an execution role that is not in the current catalog. The role may have been renamed or removed; create a new candidate with a current role.", ErrorKindRoleDispatchMalformed: "The task source is missing a role suffix (expected `role_dispatch:`). This typically means the task was created before role suffixes were required; create a new candidate with a current role.", - ErrorKindRouterNoMatch: "The role dispatcher could not match the task to a known execution role. Try selecting a role manually from the dropdown.", + ErrorKindRouterNoMatch: "The role dispatcher could not match the task to a known execution role. Try selecting a role manually from the dropdown.", } // PlanningRunBindingSnapshot freezes the fields of an account_bindings row @@ -445,24 +446,24 @@ type PlanningEvidenceDetail struct { } type BacklogCandidate struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - RequirementID string `json:"requirement_id"` - PlanningRunID string `json:"planning_run_id"` - ParentCandidateID string `json:"parent_candidate_id,omitempty"` - SuggestionType string `json:"suggestion_type"` - Title string `json:"title"` - Description string `json:"description"` - Status string `json:"status"` - Rationale string `json:"rationale"` - ValidationCriteria string `json:"validation_criteria,omitempty"` - PODecision string `json:"po_decision,omitempty"` - PriorityScore float64 `json:"priority_score"` - Confidence float64 `json:"confidence"` - Rank int `json:"rank"` - Evidence []string `json:"evidence"` - EvidenceDetail PlanningEvidenceDetail `json:"evidence_detail"` - DuplicateTitles []string `json:"duplicate_titles"` + ID string `json:"id"` + ProjectID string `json:"project_id"` + RequirementID string `json:"requirement_id"` + PlanningRunID string `json:"planning_run_id"` + ParentCandidateID string `json:"parent_candidate_id,omitempty"` + SuggestionType string `json:"suggestion_type"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Rationale string `json:"rationale"` + ValidationCriteria string `json:"validation_criteria,omitempty"` + PODecision string `json:"po_decision,omitempty"` + PriorityScore float64 `json:"priority_score"` + Confidence float64 `json:"confidence"` + Rank int `json:"rank"` + Evidence []string `json:"evidence"` + EvidenceDetail PlanningEvidenceDetail `json:"evidence_detail"` + DuplicateTitles []string `json:"duplicate_titles"` // ExecutionRole names the specialist that should execute this // candidate if apply is dispatched via role_dispatch (Phase 5 B2). // Phase 6c PR-2: catalog enforcement applies — non-empty values @@ -485,7 +486,7 @@ type BacklogCandidate struct { FeedbackKind string `json:"feedback_kind,omitempty"` // FeedbackNote is a free-text annotation paired with FeedbackKind. // Phase 3B PR-3. - FeedbackNote string `json:"feedback_note,omitempty"` + FeedbackNote string `json:"feedback_note,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -494,10 +495,10 @@ type BacklogCandidate struct { // actor_audit row for a candidate's execution_role field. // Confidence is only populated when ActorKind == "router". type ExecutionRoleAuthoring struct { - ActorKind string `json:"actor_kind"` // "user" | "api_key" | "router" | "system" | "connector" - ActorID string `json:"actor_id,omitempty"` // user id, api-key id, etc + ActorKind string `json:"actor_kind"` // "user" | "api_key" | "router" | "system" | "connector" + ActorID string `json:"actor_id,omitempty"` // user id, api-key id, etc Rationale string `json:"rationale,omitempty"` - Confidence *float64 `json:"confidence,omitempty"` // router-only + Confidence *float64 `json:"confidence,omitempty"` // router-only SetAt time.Time `json:"set_at"` } @@ -535,6 +536,7 @@ type TaskLineage struct { RequirementID string `json:"requirement_id,omitempty"` PlanningRunID string `json:"planning_run_id,omitempty"` BacklogCandidateID string `json:"backlog_candidate_id,omitempty"` + BacklogItemID string `json:"backlog_item_id,omitempty"` LineageKind string `json:"lineage_kind"` CreatedAt time.Time `json:"created_at"` } @@ -594,17 +596,17 @@ type CandidateEvidenceSummary struct { // BacklogCandidateStore.ListAppliedLineageByProject and exposed via // GET /api/projects/:id/task-lineage. type AppliedLineageEntry struct { - LineageID string `json:"lineage_id"` - ProjectID string `json:"project_id"` - TaskID string `json:"task_id"` - TaskTitle string `json:"task_title"` - TaskStatus string `json:"task_status"` - RequirementID string `json:"requirement_id,omitempty"` - RequirementTitle string `json:"requirement_title,omitempty"` - PlanningRunID string `json:"planning_run_id,omitempty"` - PlanningRunStatus string `json:"planning_run_status,omitempty"` - BacklogCandidateID string `json:"backlog_candidate_id,omitempty"` - BacklogCandidateTitle string `json:"backlog_candidate_title,omitempty"` - LineageKind string `json:"lineage_kind"` - CreatedAt time.Time `json:"created_at"` + LineageID string `json:"lineage_id"` + ProjectID string `json:"project_id"` + TaskID string `json:"task_id"` + TaskTitle string `json:"task_title"` + TaskStatus string `json:"task_status"` + RequirementID string `json:"requirement_id,omitempty"` + RequirementTitle string `json:"requirement_title,omitempty"` + PlanningRunID string `json:"planning_run_id,omitempty"` + PlanningRunStatus string `json:"planning_run_status,omitempty"` + BacklogCandidateID string `json:"backlog_candidate_id,omitempty"` + BacklogCandidateTitle string `json:"backlog_candidate_title,omitempty"` + LineageKind string `json:"lineage_kind"` + CreatedAt time.Time `json:"created_at"` } diff --git a/backend/internal/models/task.go b/backend/internal/models/task.go index 027f64c..3b6af6f 100644 --- a/backend/internal/models/task.go +++ b/backend/internal/models/task.go @@ -83,4 +83,5 @@ var ValidTaskPriorities = map[string]bool{ "low": true, "medium": true, "high": true, + "urgent": true, } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 6f3779c..64247e3 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -23,6 +23,7 @@ type Deps struct { AccountBindingHandler *handlers.AccountBindingHandler LocalConnectorHandler *handlers.LocalConnectorHandler ConnectorActivityHandler *handlers.ConnectorActivityHandler + BacklogItemHandler *handlers.BacklogItemHandler TaskHandler *handlers.TaskHandler DocumentHandler *handlers.DocumentHandler SummaryHandler *handlers.SummaryHandler @@ -154,6 +155,13 @@ func New(deps Deps) http.Handler { r.Post("/backlog-candidates/{id}/apply", deps.PlanningRunHandler.ApplyBacklogCandidate) r.Post("/backlog-candidates/{id}/suggest-role", deps.PlanningRunHandler.SuggestRole) } + if deps.BacklogItemHandler != nil { + r.Get("/projects/{id}/backlog-items", deps.BacklogItemHandler.ListByProject) + r.Post("/projects/{id}/backlog-items", deps.BacklogItemHandler.Create) + r.Get("/backlog-items/{id}", deps.BacklogItemHandler.Get) + r.Patch("/backlog-items/{id}", deps.BacklogItemHandler.Update) + r.Post("/backlog-items/{id}/commit-to-task", deps.BacklogItemHandler.CommitToTask) + } // Tasks r.Get("/projects/{id}/tasks", deps.TaskHandler.ListByProject) diff --git a/backend/internal/store/backlog_candidate_store.go b/backend/internal/store/backlog_candidate_store.go index 12cbeb0..63019f6 100644 --- a/backend/internal/store/backlog_candidate_store.go +++ b/backend/internal/store/backlog_candidate_store.go @@ -19,11 +19,11 @@ import ( ) var ( - ErrBacklogCandidateNotMutable = errors.New("backlog candidate is not mutable") - ErrBacklogCandidateNoChanges = errors.New("no backlog candidate changes requested") - ErrBacklogCandidateBlankTitle = errors.New("backlog candidate title cannot be blank") - ErrBacklogCandidateBadStatus = errors.New("invalid backlog candidate status") - ErrBacklogCandidateNotApproved = errors.New("backlog candidate must be approved before applying") + ErrBacklogCandidateNotMutable = errors.New("backlog candidate is not mutable") + ErrBacklogCandidateNoChanges = errors.New("no backlog candidate changes requested") + ErrBacklogCandidateBlankTitle = errors.New("backlog candidate title cannot be blank") + ErrBacklogCandidateBadStatus = errors.New("invalid backlog candidate status") + ErrBacklogCandidateNotApproved = errors.New("backlog candidate must be approved before applying") ErrBacklogCandidateInvalidExecutionMode = errors.New("invalid execution_mode (expected 'manual' or 'role_dispatch')") // Phase 6c PR-2: apply payload now carries execution_role; these // errors fire when the role is missing or unknown for role_dispatch. @@ -790,6 +790,7 @@ func scanTaskLineage(row rowScanner) (*models.TaskLineage, error) { var requirementID sql.NullString var planningRunID sql.NullString var backlogCandidateID sql.NullString + var backlogItemID sql.NullString err := row.Scan( &lineage.ID, &lineage.ProjectID, @@ -797,6 +798,7 @@ func scanTaskLineage(row rowScanner) (*models.TaskLineage, error) { &requirementID, &planningRunID, &backlogCandidateID, + &backlogItemID, &lineage.LineageKind, &lineage.CreatedAt, ) @@ -815,6 +817,9 @@ func scanTaskLineage(row rowScanner) (*models.TaskLineage, error) { if backlogCandidateID.Valid { lineage.BacklogCandidateID = backlogCandidateID.String } + if backlogItemID.Valid { + lineage.BacklogItemID = backlogItemID.String + } return &lineage, nil } @@ -827,7 +832,7 @@ func scanTaskLineage(row rowScanner) (*models.TaskLineage, error) { // FK semantics from migration 009 determine the join types: // - task_lineage.task_id: ON DELETE CASCADE (lineage row dies with the // task, so INNER JOIN would normally be safe) — we still use LEFT JOIN -// + COALESCE defensively so a future FK change or a stray NULL never +// - COALESCE defensively so a future FK change or a stray NULL never // makes a lineage row disappear silently from the lane. // - requirement_id / planning_run_id / backlog_candidate_id: // ON DELETE SET NULL — the lineage row survives with NULL refs, so @@ -899,7 +904,7 @@ func (s *BacklogCandidateStore) ListAppliedLineageByProject(projectID string) ([ func getTaskLineageByCandidateID(tx *sql.Tx, candidateID string) (*models.TaskLineage, error) { return scanTaskLineage( tx.QueryRow(` - SELECT id, project_id, task_id, requirement_id, planning_run_id, backlog_candidate_id, lineage_kind, created_at + SELECT id, project_id, task_id, requirement_id, planning_run_id, backlog_candidate_id, backlog_item_id, lineage_kind, created_at FROM task_lineage WHERE backlog_candidate_id = $1 ORDER BY created_at ASC, id ASC diff --git a/backend/internal/store/backlog_item_store.go b/backend/internal/store/backlog_item_store.go new file mode 100644 index 0000000..c3db6b4 --- /dev/null +++ b/backend/internal/store/backlog_item_store.go @@ -0,0 +1,494 @@ +package store + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/screenleon/agent-native-pm/internal/database" + "github.com/screenleon/agent-native-pm/internal/models" +) + +var ( + ErrBacklogItemAlreadyArchived = errors.New("backlog item is archived") + ErrBacklogItemInvalidOrigin = errors.New("backlog item origin does not belong to project") + ErrBacklogItemNotCommittable = errors.New("backlog item is not ready to commit") +) + +type BacklogItemStore struct { + db *sql.DB + dialect database.Dialect +} + +func NewBacklogItemStore(db *sql.DB, dialect database.Dialect) *BacklogItemStore { + return &BacklogItemStore{db: db, dialect: dialect} +} + +const backlogItemColumns = `id, project_id, requirement_id, planning_run_id, backlog_candidate_id, task_id, + title, description, status, priority, source, rank, labels, acceptance_criteria, blocked_reason, + created_at, updated_at` + +func (s *BacklogItemStore) ListByProject(projectID string, page, perPage int, sort, order string, filters models.BacklogItemListFilters) ([]models.BacklogItem, int, error) { + whereClause, args, nextPos := buildBacklogItemWhereClause(s.dialect, projectID, filters) + + var total int + if err := s.db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM backlog_items %s", whereClause), args...).Scan(&total); err != nil { + return nil, 0, err + } + + validSorts := map[string]bool{ + "rank": true, + "created_at": true, + "updated_at": true, + "priority": true, + "status": true, + "title": true, + } + if sort == "" || !validSorts[sort] { + sort = "rank" + } + if order == "" { + order = "ASC" + } + order = strings.ToUpper(order) + if order != "ASC" && order != "DESC" { + order = "ASC" + } + + offset := (page - 1) * perPage + queryArgs := append(append([]interface{}{}, args...), perPage, offset) + query := fmt.Sprintf(` + SELECT %s + FROM backlog_items + %s + ORDER BY %s %s, updated_at DESC, id ASC + LIMIT $%d OFFSET $%d + `, backlogItemColumns, whereClause, sort, order, nextPos, nextPos+1) + + rows, err := s.db.Query(query, queryArgs...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items := make([]models.BacklogItem, 0) + for rows.Next() { + item, err := scanBacklogItem(rows) + if err != nil { + return nil, 0, err + } + if item != nil { + items = append(items, *item) + } + } + return items, total, rows.Err() +} + +func buildBacklogItemWhereClause(dialect database.Dialect, projectID string, filters models.BacklogItemListFilters) (string, []interface{}, int) { + whereClauses := []string{"project_id = $1"} + args := []interface{}{projectID} + pos := 2 + + if filters.Status != "" { + whereClauses = append(whereClauses, fmt.Sprintf("status = $%d", pos)) + args = append(args, filters.Status) + pos++ + } + if filters.Priority != "" { + whereClauses = append(whereClauses, fmt.Sprintf("priority = $%d", pos)) + args = append(args, filters.Priority) + pos++ + } + if filters.Source != "" { + whereClauses = append(whereClauses, fmt.Sprintf("source = $%d", pos)) + args = append(args, filters.Source) + pos++ + } + if filters.Label != "" { + if dialect.IsSQLite() { + whereClauses = append(whereClauses, fmt.Sprintf("EXISTS (SELECT 1 FROM json_each(labels) WHERE value = $%d)", pos)) + } else { + whereClauses = append(whereClauses, fmt.Sprintf("labels ? $%d", pos)) + } + args = append(args, filters.Label) + pos++ + } + if filters.Query != "" { + whereClauses = append(whereClauses, fmt.Sprintf("(LOWER(title) LIKE LOWER($%d) OR LOWER(description) LIKE LOWER($%d))", pos, pos)) + args = append(args, "%"+filters.Query+"%") + pos++ + } + + return "WHERE " + strings.Join(whereClauses, " AND "), args, pos +} + +func (s *BacklogItemStore) GetByID(id string) (*models.BacklogItem, error) { + return scanBacklogItem(s.db.QueryRow( + `SELECT `+backlogItemColumns+` FROM backlog_items WHERE id = $1`, id, + )) +} + +func (s *BacklogItemStore) Create(projectID string, req models.CreateBacklogItemRequest) (*models.BacklogItem, error) { + id := uuid.New().String() + now := time.Now().UTC() + + if err := s.validateOriginProject(projectID, req); err != nil { + return nil, err + } + + status := strings.TrimSpace(req.Status) + if status == "" { + status = models.BacklogItemStatusTriage + } + priority := strings.TrimSpace(req.Priority) + if priority == "" { + priority = "medium" + } + source := strings.TrimSpace(req.Source) + if source == "" { + source = models.BacklogItemSourceHuman + } + labelsJSON, err := marshalBacklogLabels(req.Labels) + if err != nil { + return nil, err + } + + _, err = s.db.Exec(` + INSERT INTO backlog_items ( + id, project_id, requirement_id, planning_run_id, backlog_candidate_id, title, description, + status, priority, source, rank, labels, acceptance_criteria, blocked_reason, created_at, updated_at + ) + VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''), $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + `, id, projectID, req.RequirementID, req.PlanningRunID, req.BacklogCandidateID, strings.TrimSpace(req.Title), + req.Description, status, priority, source, req.Rank, string(labelsJSON), req.AcceptanceCriteria, req.BlockedReason, now, now) + if err != nil { + return nil, err + } + return s.GetByID(id) +} + +func (s *BacklogItemStore) validateOriginProject(projectID string, req models.CreateBacklogItemRequest) error { + checks := []struct { + table string + id string + }{ + {table: "requirements", id: strings.TrimSpace(req.RequirementID)}, + {table: "planning_runs", id: strings.TrimSpace(req.PlanningRunID)}, + {table: "backlog_candidates", id: strings.TrimSpace(req.BacklogCandidateID)}, + } + for _, check := range checks { + if check.id == "" { + continue + } + var count int + query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE id = $1 AND project_id = $2", check.table) + if err := s.db.QueryRow(query, check.id, projectID).Scan(&count); err != nil { + return err + } + if count == 0 { + return ErrBacklogItemInvalidOrigin + } + } + return nil +} + +func (s *BacklogItemStore) Update(id string, req models.UpdateBacklogItemRequest) (*models.BacklogItem, error) { + setClauses := make([]string, 0) + args := make([]interface{}, 0) + pos := 1 + + if req.Title != nil { + setClauses = append(setClauses, fmt.Sprintf("title = $%d", pos)) + args = append(args, strings.TrimSpace(*req.Title)) + pos++ + } + if req.Description != nil { + setClauses = append(setClauses, fmt.Sprintf("description = $%d", pos)) + args = append(args, *req.Description) + pos++ + } + if req.Status != nil { + setClauses = append(setClauses, fmt.Sprintf("status = $%d", pos)) + args = append(args, *req.Status) + pos++ + } + if req.Priority != nil { + setClauses = append(setClauses, fmt.Sprintf("priority = $%d", pos)) + args = append(args, *req.Priority) + pos++ + } + if req.Rank != nil { + setClauses = append(setClauses, fmt.Sprintf("rank = $%d", pos)) + args = append(args, *req.Rank) + pos++ + } + if req.Labels != nil { + labelsJSON, err := marshalBacklogLabels(*req.Labels) + if err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("labels = $%d", pos)) + args = append(args, string(labelsJSON)) + pos++ + } + if req.AcceptanceCriteria != nil { + setClauses = append(setClauses, fmt.Sprintf("acceptance_criteria = $%d", pos)) + args = append(args, *req.AcceptanceCriteria) + pos++ + } + if req.BlockedReason != nil { + setClauses = append(setClauses, fmt.Sprintf("blocked_reason = $%d", pos)) + args = append(args, *req.BlockedReason) + pos++ + } + if len(setClauses) == 0 { + return s.GetByID(id) + } + + setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", pos)) + args = append(args, time.Now().UTC()) + pos++ + args = append(args, id) + + query := fmt.Sprintf("UPDATE backlog_items SET %s WHERE id = $%d", strings.Join(setClauses, ", "), pos) + result, err := s.db.Exec(query, args...) + if err != nil { + return nil, err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return nil, nil + } + return s.GetByID(id) +} + +func (s *BacklogItemStore) CommitToTask(id string) (*models.CommitBacklogItemResponse, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback() + }() + + if err := s.lockBacklogItemForCommit(tx, id); err != nil { + return nil, err + } + + item, err := s.getByIDForUpdate(tx, id) + if err != nil { + return nil, err + } + if item == nil { + return nil, nil + } + if item.Status == models.BacklogItemStatusArchived { + return nil, ErrBacklogItemAlreadyArchived + } + if item.TaskID != "" { + task, err := getTaskByID(tx, item.TaskID) + if err != nil { + return nil, err + } + if task != nil { + lineage, err := getTaskLineageByBacklogItemID(tx, item.ID) + if err != nil { + return nil, err + } + if lineage == nil { + lineage, err = createBacklogItemTaskLineage(tx, item, task.ID) + if err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return &models.CommitBacklogItemResponse{BacklogItem: *item, Task: *task, Lineage: *lineage, AlreadyApplied: true}, nil + } + } + if item.Status != models.BacklogItemStatusTriage && item.Status != models.BacklogItemStatusReady { + return nil, ErrBacklogItemNotCommittable + } + + task, err := createBacklogItemTask(tx, item) + if err != nil { + return nil, err + } + lineage, err := createBacklogItemTaskLineage(tx, item, task.ID) + if err != nil { + return nil, err + } + now := time.Now().UTC() + if _, err := tx.Exec(` + UPDATE backlog_items + SET status = $1, task_id = $2, updated_at = $3 + WHERE id = $4 + `, models.BacklogItemStatusCommitted, task.ID, now, item.ID); err != nil { + return nil, err + } + item.Status = models.BacklogItemStatusCommitted + item.TaskID = task.ID + item.UpdatedAt = now + + if err := tx.Commit(); err != nil { + return nil, err + } + return &models.CommitBacklogItemResponse{BacklogItem: *item, Task: *task, Lineage: *lineage}, nil +} + +func (s *BacklogItemStore) lockBacklogItemForCommit(tx *sql.Tx, id string) error { + if !s.dialect.IsSQLite() { + return nil + } + _, err := tx.Exec(`UPDATE backlog_items SET updated_at = updated_at WHERE id = $1`, id) + return err +} + +func (s *BacklogItemStore) getByIDForUpdate(tx *sql.Tx, id string) (*models.BacklogItem, error) { + query := `SELECT ` + backlogItemColumns + ` FROM backlog_items WHERE id = $1 ` + s.dialect.ForUpdate() + return scanBacklogItem(tx.QueryRow(query, id)) +} + +func scanBacklogItem(row rowScanner) (*models.BacklogItem, error) { + var item models.BacklogItem + var requirementID, planningRunID, backlogCandidateID, taskID sql.NullString + var labelsRaw []byte + err := row.Scan( + &item.ID, + &item.ProjectID, + &requirementID, + &planningRunID, + &backlogCandidateID, + &taskID, + &item.Title, + &item.Description, + &item.Status, + &item.Priority, + &item.Source, + &item.Rank, + &labelsRaw, + &item.AcceptanceCriteria, + &item.BlockedReason, + &item.CreatedAt, + &item.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if requirementID.Valid { + item.RequirementID = requirementID.String + } + if planningRunID.Valid { + item.PlanningRunID = planningRunID.String + } + if backlogCandidateID.Valid { + item.BacklogCandidateID = backlogCandidateID.String + } + if taskID.Valid { + item.TaskID = taskID.String + } + item.Labels = unmarshalBacklogLabels(labelsRaw) + return &item, nil +} + +func createBacklogItemTask(tx *sql.Tx, item *models.BacklogItem) (*models.Task, error) { + id := uuid.New().String() + now := time.Now().UTC() + source := "backlog:" + item.ID + if len(source) > 80 { + source = source[:80] + } + _, err := tx.Exec(` + INSERT INTO tasks (id, project_id, title, description, status, priority, assignee, source, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, '', $7, $8, $9) + `, id, item.ProjectID, item.Title, item.Description, "todo", item.Priority, source, now, now) + if err != nil { + return nil, err + } + return getTaskByID(tx, id) +} + +func createBacklogItemTaskLineage(tx *sql.Tx, item *models.BacklogItem, taskID string) (*models.TaskLineage, error) { + lineage := buildBacklogItemLineage(item, taskID, time.Now().UTC()) + _, err := tx.Exec(` + INSERT INTO task_lineage ( + id, project_id, task_id, requirement_id, planning_run_id, backlog_candidate_id, backlog_item_id, lineage_kind, created_at + ) + VALUES ($1, $2, $3, NULLIF($4, ''), NULLIF($5, ''), NULLIF($6, ''), $7, $8, $9) + `, lineage.ID, lineage.ProjectID, lineage.TaskID, lineage.RequirementID, lineage.PlanningRunID, + lineage.BacklogCandidateID, lineage.BacklogItemID, lineage.LineageKind, lineage.CreatedAt) + if err != nil { + return nil, err + } + return lineage, nil +} + +func buildBacklogItemLineage(item *models.BacklogItem, taskID string, createdAt time.Time) *models.TaskLineage { + return &models.TaskLineage{ + ID: uuid.New().String(), + ProjectID: item.ProjectID, + TaskID: taskID, + RequirementID: item.RequirementID, + PlanningRunID: item.PlanningRunID, + BacklogCandidateID: item.BacklogCandidateID, + BacklogItemID: item.ID, + LineageKind: models.TaskLineageKindBacklogItem, + CreatedAt: createdAt, + } +} + +func getTaskLineageByBacklogItemID(tx *sql.Tx, backlogItemID string) (*models.TaskLineage, error) { + return scanTaskLineage( + tx.QueryRow(` + SELECT id, project_id, task_id, requirement_id, planning_run_id, backlog_candidate_id, backlog_item_id, lineage_kind, created_at + FROM task_lineage + WHERE backlog_item_id = $1 + ORDER BY created_at ASC, id ASC + LIMIT 1 + `, backlogItemID), + ) +} + +func marshalBacklogLabels(values []string) ([]byte, error) { + return json.Marshal(normalizeBacklogLabels(values)) +} + +func unmarshalBacklogLabels(raw []byte) []string { + if len(bytes.TrimSpace(raw)) == 0 { + return []string{} + } + values := []string{} + if err := json.Unmarshal(raw, &values); err != nil { + return []string{} + } + return normalizeBacklogLabels(values) +} + +func normalizeBacklogLabels(values []string) []string { + if len(values) == 0 { + return []string{} + } + seen := make(map[string]bool, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" || seen[trimmed] { + continue + } + seen[trimmed] = true + out = append(out, trimmed) + } + if len(out) == 0 { + return []string{} + } + return out +} diff --git a/docs/api-surface.md b/docs/api-surface.md index ad04700..ccbe2a2 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -704,7 +704,7 @@ Source: `[agent:backend-architect]` - `page`, `per_page`: pagination - `sort`, `order`: list ordering - `status`: exact match. Allowed values: `todo`, `in_progress`, `done`, `cancelled` -- `priority`: exact match. Allowed values: `low`, `medium`, `high` +- `priority`: exact match. Allowed values: `low`, `medium`, `high`, `urgent` - `assignee`: exact match assignee filter Invalid `status` or `priority` values return `400`. @@ -722,6 +722,56 @@ Invalid `status` or `priority` values return `400`. } ``` +### Backlog Items + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/projects/:id/backlog-items` | List backlog items for a project | +| POST | `/api/projects/:id/backlog-items` | Create a backlog item | +| GET | `/api/backlog-items/:id` | Get a backlog item by ID | +| PATCH | `/api/backlog-items/:id` | Update a backlog item | +| POST | `/api/backlog-items/:id/commit-to-task` | Create or replay the committed task for a backlog item | + +#### List backlog items query parameters + +- `page`, `per_page`: pagination +- `sort`, `order`: list ordering. Supported sort fields: `rank`, `created_at`, `updated_at`, `priority`, `status`, `title` +- `status`: exact match. Allowed values: `triage`, `ready`, `committed`, `blocked`, `archived` +- `priority`: exact match. Allowed values: `low`, `medium`, `high`, `urgent` +- `source`: exact match. Allowed values: `human`, `planning_run`, `backlog_candidate`, `connector` +- `label`: exact label match +- `q`: case-insensitive title/description search + +`committed` is a system-owned backlog status. Clients cannot set it through create or patch; use `POST /api/backlog-items/:id/commit-to-task`. + +#### Create backlog item request + +```json +{ + "title": "Define API-backed backlog lifecycle", + "description": "Backlog items should exist before connector execution.", + "priority": "urgent", + "status": "triage", + "labels": ["api", "backlog"], + "acceptance_criteria": "Manual items can be committed into urgent tasks." +} +``` + +#### Commit backlog item response + +```json +{ + "data": { + "backlog_item": { "id": "backlog-id", "status": "committed", "task_id": "task-id" }, + "task": { "id": "task-id", "priority": "urgent" }, + "lineage": { "lineage_kind": "backlog_item", "backlog_item_id": "backlog-id", "task_id": "task-id" }, + "already_applied": false + }, + "error": null, + "meta": null +} +``` + Notes: - `task_ids` must contain at least 1 task and at most 100. diff --git a/docs/backlog-first-plan.md b/docs/backlog-first-plan.md new file mode 100644 index 0000000..2536712 --- /dev/null +++ b/docs/backlog-first-plan.md @@ -0,0 +1,651 @@ +# Backlog-first plan — Project management core before connector automation + +**Status**: approved for B1-B5 · 2026-05-01 · `[agent:feature-planner]` +**Gates**: Owner approved the `backlog_items` table, labels, default Backlog tab, generated-backlog-as-backlog behavior, and deferring connector auto-implementation until after B1-B5. Implementation still follows the normal pre-PR verification gate. +**Precondition**: Phase 6c is available on `main`. Connector auto-generation and auto-implementation remain deferred until the backlog management layer is usable on its own. +**Current PR scope**: B1 + B2 + the UX clarification slice in section 14. B3 planning-output materialization remains a follow-up; generated planning runs still use the legacy `backlog_candidates` path until B3 lands. + +--- + +## 1. Problem statement + +The current Planning Workspace can generate and review backlog candidates, but it does not yet feel like a project management system. + +The main usability gap is not the connector. It is that backlog is still a by-product of a planning run: + +- A user must navigate `requirement -> planning run -> candidate` before seeing work. +- Candidate review is optimized for inspecting one run, not for managing a project backlog over time. +- The only full candidate list API is run-scoped: `GET /api/planning-runs/:id/backlog-candidates`. +- Project-level counts in the Workspace can only see the currently loaded candidate set, not all open backlog. +- Connector/role-dispatch controls are already visible in the candidate detail, which makes the core backlog workflow feel heavier than necessary. + +The product should first become a reliable local project management system for backlog capture, review, prioritization, and API-based synchronization. Connector auto-generation and implementation should build on that stable backlog contract later. + +--- + +## 2. Product direction + +Agent Native PM should treat backlog as the primary project management layer. + +### Entity semantics + +| Concept | Meaning | +|---|---| +| Requirement | A raw goal, feature idea, bug theme, or planning input. | +| Backlog item | A durable project-management work item that can be prioritized, edited, queried, and later committed to execution. | +| Backlog candidate | A legacy/generated suggestion artifact from a planning run. It may remain as an internal compatibility/evidence record, but user-facing generated backlog should materialize as backlog items directly. | +| Task | A committed execution item. It can be performed manually or dispatched to a connector in later phases. | + +### Core flow + +```text +manual input / API input / planning output + -> backlog item + -> review + priority + readiness + -> committed task + -> optional connector execution later +``` + +### UX principle + +The default project surface should answer: + +1. What should I work on? +2. What is blocked or needs review? +3. What changed recently? +4. What is ready to become a task? + +It should not require the user to understand planning runs, adapters, execution modes, or connector dispatch before they can manage work. + +--- + +## 3. Current state inventory + +### Frontend + +| File | Current state | Backlog-first implication | +|---|---|---| +| `frontend/src/pages/ProjectDetail/PlanningTab.tsx` | Workspace composes requirement queue, planning launcher, run list, candidate review, and applied lineage. | Keep it, but stop making it the only way to manage backlog. | +| `frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.tsx` | Dense one-run review surface with evidence, score, role dispatch, feedback, and apply controls. | Reuse detail pieces, but move project-level backlog scanning into a new surface. | +| `frontend/src/pages/ProjectDetail/planning/hooks/usePlanningWorkspaceData.ts` | Holds requirement/run/candidate state, role loading, and apply handlers. | Split project-level backlog fetching/mutation into a separate hook. | +| `frontend/src/pages/ProjectDetail/TasksTab.tsx` | Existing task execution board. | Keep as committed execution view, not as the draft backlog source of truth. | + +### Backend/API + +| Current endpoint | Limitation | +|---|---| +| `GET /api/planning-runs/:id/backlog-candidates` | Requires selecting one run before viewing candidates. | +| `PATCH /api/backlog-candidates/:id` | Edits generated candidate review fields only. | +| `POST /api/backlog-candidates/:id/apply` | Applies candidate directly to task, skipping a durable backlog item. | +| `GET /api/projects/:id/task-lineage` | Shows candidate-to-task traceability after apply, not pre-task backlog state. | +| `GET /api/projects/:id/backlog-candidates/by-evidence` | Evidence reverse lookup only; not a backlog management API. | + +### Data model + +`requirements`, `planning_runs`, `backlog_candidates`, and `task_lineage` are already in place. The missing layer is a first-class project backlog table that can represent human-created, API-imported, and planning-derived work before task commitment. + +Owner clarification on 2026-05-01: manual backlog items do not need a requirement. Requirements are for situations where the user has a need but wants the system to decompose it. If the user clearly knows the work item, it can be created directly as backlog. + +--- + +## 4. Proposed data model + +Add a new `backlog_items` table. + +| Column | Type | Notes | +|---|---|---| +| `id` | TEXT PRIMARY KEY | UUID v4 | +| `project_id` | TEXT NOT NULL | FK -> `projects.id` | +| `requirement_id` | TEXT NULL | Optional origin requirement | +| `planning_run_id` | TEXT NULL | Optional origin planning run | +| `backlog_candidate_id` | TEXT NULL | Optional generated candidate origin | +| `task_id` | TEXT NULL | Set when committed to a task | +| `title` | TEXT NOT NULL | User-editable | +| `description` | TEXT NOT NULL DEFAULT '' | User-editable | +| `status` | TEXT NOT NULL DEFAULT 'triage' | `triage`, `ready`, `committed`, `blocked`, `archived` | +| `priority` | TEXT NOT NULL DEFAULT 'medium' | `low`, `medium`, `high`, `urgent`; `urgent` means interrupt/current-plan attention, while `high` means next-priority planned work | +| `source` | TEXT NOT NULL DEFAULT 'human' | `human`, `api:`, `candidate:`, `agent:`, `analysis` | +| `rank` | INTEGER NOT NULL DEFAULT 0 | Manual ordering inside a project | +| `labels` | JSON/TEXT NOT NULL DEFAULT '[]' | Keep SQLite/Postgres parity | +| `acceptance_criteria` | TEXT NOT NULL DEFAULT '' | Optional DoD/validation | +| `blocked_reason` | TEXT NOT NULL DEFAULT '' | Visible only when blocked | +| `created_at` | TIMESTAMPTZ | Existing dialect pattern | +| `updated_at` | TIMESTAMPTZ | Existing dialect pattern | + +Indexes: + +- `(project_id, status, rank, updated_at DESC)` +- `(project_id, priority, updated_at DESC)` +- `(project_id, backlog_candidate_id)` unique where `backlog_candidate_id IS NOT NULL` +- `(project_id, task_id)` where `task_id IS NOT NULL` + +### Status lifecycle + +```text +triage -> ready -> committed +triage -> blocked -> ready +triage/ready/blocked -> archived +``` + +Rules: + +- `committed` means a task exists and `task_id` is set. +- Archived backlog items stay queryable by API but hidden by default in UI. +- Manual backlog item creation does not create or require a requirement. +- Planning runs that generate backlog should create backlog items directly. +- A legacy candidate can create at most one backlog item when using compatibility flows. +- A backlog item can create at most one task. +- When an `urgent` backlog item is committed to a task, the task priority remains `urgent`. + +--- + +## 5. Proposed API surface + +### Project backlog list + +`GET /api/projects/:id/backlog-items` + +Query params: + +- `status` +- `priority` +- `source` +- `q` +- `include_archived` +- `page` +- `per_page` +- `sort=rank|priority|updated_at|created_at` +- `order=asc|desc` + +Response uses the standard envelope. + +### Create backlog item + +`POST /api/projects/:id/backlog-items` + +Request: + +```json +{ + "title": "Add project-level backlog API", + "description": "Expose backlog items independent of planning runs.", + "priority": "high", + "labels": ["api", "planning"], + "acceptance_criteria": "API supports list, create, update, and commit-to-task." +} +``` + +Manual create defaults: + +- `requirement_id` is omitted unless the user explicitly starts from a requirement. +- `source` defaults to `human`. +- `labels` are supported in B1 because external API sync is expected to need structured grouping. + +### Update backlog item + +`PATCH /api/backlog-items/:id` + +Mutable fields: + +- `title` +- `description` +- `status` +- `priority` +- `rank` +- `labels` +- `acceptance_criteria` +- `blocked_reason` + +### Planning run output to backlog + +Planning runs whose requested output is backlog should persist generated work as `backlog_items` directly. + +Behavior: + +- The user-facing result of "generate backlog" is backlog items. +- `planning_run_id` and `requirement_id` are stored on each generated backlog item when available. +- `backlog_candidate_id` is optional and only used if the implementation keeps candidate rows as a compatibility/evidence artifact during migration. +- The system must not require an extra "accept to backlog" click after the user asked to generate backlog. +- If the user explicitly asks to generate tasks instead, the run may create committed tasks through the task flow rather than backlog items. + +### Commit backlog item to task + +`POST /api/backlog-items/:id/commit-to-task` + +Behavior: + +- Requires backlog item status `ready` or `triage`. +- Creates a task with copied title/description/priority. +- Sets backlog item `status='committed'` and `task_id`. +- Writes lineage with `lineage_kind='backlog_item'` or extends lineage model to carry `backlog_item_id`. +- Idempotent if already committed. + +### External API use + +Human session and API-key callers should use the same endpoints. Project-scoped API keys may create/update backlog only inside their allowed project. + +Example external sync: + +```text +GET /api/projects/:id/backlog-items?status=triage,ready +POST /api/projects/:id/backlog-items +PATCH /api/backlog-items/:id +POST /api/backlog-items/:id/commit-to-task +``` + +--- + +## 6. Frontend information architecture + +### Project primary rail + +Recommended primary tabs: + +1. **Backlog** — new default working surface +2. **Workspace** — planning/generation/review +3. **Tasks** — committed execution +4. **Documents** +5. **Overview** + +Owner decision: `Backlog` should become the default ProjectDetail tab in the backlog-first implementation. + +### Backlog page layout + +Use a dense operational layout, not a marketing/card-heavy surface. + +```text +Backlog +Filters: status | priority | source | search | archived + +Triage Ready Blocked Committed +item item item item -> task +item item +``` + +Modes: + +- **List mode**: best for scanning many items. +- **Board mode**: best for status movement. +- **Detail drawer**: edit title, description, labels, criteria, origin links, and commit action. + +### Candidate review changes + +Candidate review becomes an input pipeline: + +- Generated backlog runs should land in the Backlog tab directly. +- Keep legacy candidate review for compatibility, evidence inspection, and older planning runs. +- Keep `Apply/Commit to Task` available only as a secondary shortcut inside backlog detail or explicit "generate task" flow. +- Keep role dispatch and suggest-role controls hidden under `Advanced execution`. + +### Empty state + +The first screen should offer three clear entries: + +1. Add backlog item manually. +2. Generate backlog from a requirement. +3. Run What's Next analysis. + +The user should not need to pick execution mode on the first screen. + +--- + +## 7. Connector and auto-implementation boundary + +Connector auto-generation and auto-implementation are explicitly deferred until backlog is stable. + +Allowed in this phase: + +- Keep existing connector planning runs working. +- Preserve role authoring fields on candidates. +- Keep current role-dispatch APIs from regressing. +- Show connector status only where it explains an active run. + +Not allowed in this phase: + +- New `role_dispatch_auto` behavior. +- Automatic task claiming from backlog. +- Connector-generated code execution from a backlog item. +- New sandboxing or adapter framework work, unless needed to prevent a regression. + +Future connector path: + +```text +backlog_item ready + -> choose execution role + -> dispatch task / implementation plan + -> connector claims + -> structured result + -> task status + artifact links update +``` + +--- + +## 8. Slice plan + +### Slice B1 — Backlog model + API + +Scope: + +1. Add migration for `backlog_items`. +2. Add Go model, store, handler, router entries. +3. Implement list/create/update/commit-to-task. +4. Add project-scoped authorization checks. +5. Update `docs/data-model.md` and `docs/api-surface.md`. +6. Support labels in the B1 contract. +7. Support `urgent` as a real backlog and task priority. + +Definition of Done: + +| ID | Scenario | Expected | +|---|---|---| +| B1-1 | Create backlog item with valid title | 201 and item is returned | +| B1-2 | Create with blank title | 400 | +| B1-3 | List by project | Only project-visible items returned | +| B1-4 | API key scoped to another project | 404 to avoid leaking project existence | +| B1-5 | Patch status/priority/rank | Updated item returned | +| B1-6 | Manual backlog item create without requirement | Item is created with `requirement_id=null` | +| B1-7 | Commit urgent item to task | Task priority is `urgent`; backlog item still reads `urgent` | +| B1-8 | Commit to task | Task created, item marked committed, idempotent replay returns same task | +| B1-9 | SQLite and Postgres suites | Both pass | + +### Slice B2 — Backlog tab frontend + +Scope: + +1. Add `BacklogTab` under `frontend/src/pages/ProjectDetail/`. +2. Add `useProjectBacklogData` hook. +3. Add API client methods and TypeScript types. +4. Add list view with filters and quick edit. +5. Add detail drawer for full editing and commit-to-task. + +Definition of Done: + +| ID | Scenario | Expected | +|---|---|---| +| B2-1 | Open project detail | Backlog tab is the default selected tab | +| B2-2 | Empty project | Shows manual add, generate, and What's Next entries | +| B2-3 | Project with items | List is scannable without selecting a requirement/run | +| B2-4 | Filter by status/priority/source/label | Results update without layout jump | +| B2-5 | Edit item title/priority/status/labels | PATCH persists and UI updates | +| B2-6 | Commit item | Task appears and backlog item becomes committed | +| B2-7 | Mobile width | Text and actions do not overlap | + +### Slice B3 — Planning output writes backlog + +Scope: + +1. Update planning completion so backlog-generation runs create `backlog_items` directly. +2. Add planning-origin fields to backlog item response. +3. Keep candidate rows only as compatibility/evidence artifacts if needed by existing code. +4. Update Planning Workspace to route completed backlog generation to the Backlog tab. +5. Keep direct task generation as an explicit separate mode, not the default backlog-generation path. +6. Add lineage/deep-link from backlog item back to requirement/run and candidate when present. + +Definition of Done: + +| ID | Scenario | Expected | +|---|---|---| +| B3-1 | Generate backlog from requirement | Backlog items are created without an extra accept step | +| B3-2 | Generate backlog twice for same run | No duplicate backlog items for the same generated artifact | +| B3-3 | Open generated backlog item | Detail drawer shows origin requirement/run links | +| B3-4 | Explicit generate-task flow | Creates tasks only when user selected task output | +| B3-5 | Existing candidate/apply flow | Still works for older workflows | + +### Slice B4 — Backlog as API integration point + +Scope: + +1. Document API-key usage for external backlog sync. +2. Add request examples for create/update/list/commit. +3. Add source conventions: `api:`, `agent:`, `candidate:`. +4. Add server validation for source prefix length and allowed characters. +5. Add a minimal `scripts/backlog-api-smoke.sh` optional local smoke script. + +Definition of Done: + +| ID | Scenario | Expected | +|---|---|---| +| B4-1 | API key creates item | Source is stored and response is scoped | +| B4-2 | API key lists project backlog | Only allowed project returned | +| B4-3 | Invalid source prefix | 400 | +| B4-4 | Docs show full curl flow | User can reproduce manually | + +### Slice B5 — Project-level backlog summary + +Scope: + +1. Add backlog counts to project dashboard summary. +2. Show counts in ProjectList and ProjectOverviewTab. +3. Replace currently-selected-run candidate counts where project-level counts are more useful. + +Definition of Done: + +| ID | Scenario | Expected | +|---|---|---| +| B5-1 | Project has triage/ready/blocked items | Summary returns correct counts | +| B5-2 | Project list | Shows backlog attention without opening project | +| B5-3 | Workspace sidebar | Counts reflect project backlog, not only selected run | + +### Slice B6 — Connector execution planning follow-up + +Scope: + +Planning only. No execution implementation in this slice. + +1. Define how `backlog_items` become connector-dispatchable tasks. +2. Decide whether dispatch attaches to `backlog_items`, `tasks`, or both. +3. Define execution result storage and artifact links. +4. Run risk/security review before implementation because this touches subprocess execution. + +Exit criteria: + +- Backlog APIs are stable. +- Backlog UI is usable for manual/API project management. +- Users can dogfood backlog for at least one project without connector auto-implementation. + +--- + +## 9. Backend implementation notes + +### Store boundaries + +Add a dedicated `BacklogItemStore`. + +Do not overload `BacklogCandidateStore`; candidates are generated suggestions, while backlog items are user-managed project state. + +### Lineage + +Preferred option: + +- Add `backlog_item_id` to `task_lineage`. +- Keep existing candidate fields. +- For task created from backlog item accepted from candidate, lineage can carry all four IDs: + `requirement_id`, `planning_run_id`, `backlog_candidate_id`, `backlog_item_id`. + +Fallback option: + +- Keep `task_lineage` unchanged in B1. +- Store `task_id` on `backlog_items`. +- Add lineage expansion in B3/B5. + +Recommendation: use the preferred option if B1 already touches migrations. The traceability model is central to the product. + +### Candidate status compatibility + +Do not add a new candidate status unless necessary. Generated backlog should be represented by `backlog_items`; candidate rows, if retained, are compatibility/evidence artifacts. This avoids widening every existing candidate status switch. + +--- + +## 10. Frontend implementation notes + +### File placement + +New ProjectDetail siblings: + +```text +frontend/src/pages/ProjectDetail/BacklogTab.tsx +frontend/src/pages/ProjectDetail/BacklogTab.test.tsx +frontend/src/pages/ProjectDetail/backlog/ + BacklogList.tsx + BacklogBoard.tsx + BacklogDetailDrawer.tsx + BacklogFilters.tsx + hooks/useProjectBacklogData.ts +``` + +This respects the existing ProjectDetail structural rule: new product additions live under `frontend/src/pages/ProjectDetail/`. + +### Interaction design + +Keep the first implementation utilitarian: + +- Dense rows. +- Inline status/priority controls. +- Detail drawer for longer fields. +- Stable dimensions for badges, controls, and row actions. +- No nested cards. +- No decorative hero sections. + +### Candidate review simplification + +Candidate detail should show: + +1. Title and summary. +2. Why suggested. +3. Origin/evidence links. +4. Link to generated backlog item when one exists. +5. Advanced execution collapsed. + +Move long score breakdowns behind a disclosure. + +--- + +## 11. Risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---:|---:|---| +| Entity overlap confuses users: candidate vs backlog item vs task | High | High | Use one default flow: generated backlog -> backlog item -> task. Keep candidate language out of the primary UI. | +| API grows without stable workflow | Medium | High | Ship B1 + B2 together before connector work. | +| Direct candidate apply and backlog commit diverge | Medium | Medium | Keep direct apply compatibility but make backlog commit the primary path. | +| New table duplicates task features | Medium | Medium | Keep backlog items pre-commit only; tasks remain execution state. | +| Connector assumptions leak into backlog model | Medium | High | No connector-specific fields on backlog item in B1 except optional future role hint if already needed. | +| SQLite/Postgres migration parity | Medium | High | Use dialect-compatible schema and run both test suites. | + +--- + +## 12. Open questions + +1. Should API-created backlog items be allowed to commit directly to tasks, or require human session confirmation first? + +Recommended defaults: + +1. Allow API-key commit only for project-scoped keys; audit source clearly. + +--- + +## 13. Approval checkpoint + +Owner decisions recorded 2026-05-01: + +1. Approved the new `backlog_items` table. +2. Approved `Backlog` as the default ProjectDetail tab. +3. Approved labels in B1. +4. Approved `urgent` as a first-class backlog and task priority, distinct from `high`. +5. Approved manual backlog creation without a requirement. +6. Clarified that generated backlog is backlog; it should not require a separate accept step unless the user explicitly requested candidate review. +7. Approved deferring connector auto-implementation until after B1-B5. + +Implementation should start with Slice B1 and B2 as one coherent capability. Connector automation should not resume until the backlog layer has passed local dogfood. + +--- + +## 14. Project detail UX clarification plan + +**Status**: proposed · 2026-05-01 · `[agent:feature-planner]` + +This section records the first dogfood UX issue after adding the backlog-first project surface: the current project detail page exposes too many first-level concepts at once (`Workspace`, `Backlog`, `Overview`, `Tasks`, `Documents`, and `More`), while the dark theme makes muted text and secondary badges hard to read. For a new user, the page does not clearly explain what each area is for or what action should happen next. + +### Problem diagnosis + +| Area | Current issue | Product impact | +|---|---|---| +| Primary rail | Six first-level choices compete for attention. `Workspace` and `Overview` are especially ambiguous beside `Backlog`. | New users must understand the product taxonomy before they can manage work. | +| Default mental model | Backlog, task, workspace, and overview appear as peers. | The backlog-first workflow is less obvious than the underlying implementation structure. | +| Dark theme contrast | Essential metadata often uses muted text on a dark background. | Labels, row metadata, hints, and secondary actions are readable only when the user already knows where to look. | +| Backlog presentation | Items are visible, but the page does not yet clearly teach priority, status, labels, and next action as one work-management unit. | The backlog behaves like a data list instead of a project command surface. | + +### Information architecture direction + +Reduce first-level navigation to workflow surfaces, not implementation entities. + +Recommended primary rail: + +1. **Backlog** — default project command surface: triage, ready work, blocked work, urgent items, and commit-to-task. +2. **Planning** — renamed from `Workspace`; requirement capture and generated backlog planning live here. +3. **Tasks** — committed execution work only. +4. **Docs** — project documents and knowledge base. +5. **More** — settings, connectors, advanced activity, and historical/debug views. + +Recommended demotions: + +- `Overview` should become a summary band at the top of `Backlog`, not a primary tab. It should answer "what needs attention now" with counts and recent change signals. +- `Workspace` should be renamed to `Planning` or `Generate` because "workspace" is too broad and does not explain the job-to-be-done. +- Connector controls should stay out of first-level navigation until connector auto-implementation resumes after B1-B5. + +### Backlog page clarity direction + +The first screen inside a project should show a scannable operational layout: + +```text +Project name / repo signal +Current focus: urgent, blocked, ready, recently committed + +Backlog filters: status | priority | label | search | archived + +Backlog list: + priority | status | title | labels | source | next action + +Selected item detail: + description | acceptance criteria | blocked reason | origin | commit-to-task +``` + +Rules: + +- Every backlog row must show `priority`, `status`, `labels`, and one obvious next action. +- `urgent` must be visually distinct from `high`; urgent means "interrupt/current-plan attention", while high means "next planned priority". +- Empty state should offer exactly three entries: add backlog item, generate backlog from a requirement, and run What's Next analysis. +- Advanced planning-run, candidate, role-dispatch, and connector language should stay behind details or advanced controls. + +### Visual contrast direction + +Treat text roles as usability contracts: + +- Primary text: item titles, section labels, selected tab labels. +- Secondary text: useful metadata such as source, timestamps, counts, and labels. +- Tertiary text: long explanatory hints and disabled information only. + +Implementation target: + +- Raise secondary text contrast on dark backgrounds; do not use muted gray for required metadata. +- Increase badge foreground contrast and use clearer borders for priority/status labels. +- Keep dark UI if desired, but use fewer near-black layers and stronger separation between the page background, panels, rows, and active controls. +- Validate mobile and desktop layouts so labels and row actions do not overlap or disappear. + +### Proposed follow-up slices + +| Slice | Scope | Definition of Done | +|---|---|---| +| UX-1 | Rename and simplify project rail. | First-level nav has no more than five entries, with Backlog default and Overview demoted into a summary band. | +| UX-2 | Improve dark-theme contrast tokens and backlog badge colors. | Secondary metadata and priority/status labels are readable without relying on white-only text. | +| UX-3 | Rework BacklogTab into list plus selected detail. | New users can identify item priority, status, labels, and next action in one scan. | +| UX-4 | Add dogfood sample backlog data path. | Local data includes at least one urgent UX backlog item for checking row presentation. | + +### Dogfood sample created + +A local backlog item was created in `.anpm/data.db` on 2026-05-01 for visual verification: + +- Title: `Dogfood: verify urgent backlog row clarity` +- Priority: `urgent` +- Status: `triage` +- Labels: `ux`, `contrast`, `dogfood` +- Purpose: verify that urgent priority, labels, status, long description, and acceptance criteria are readable in the current Backlog tab. diff --git a/docs/data-model.md b/docs/data-model.md index 26a8873..9037ebb 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -36,6 +36,7 @@ planning_runs 1---* backlog_candidates planning_runs 1---* task_lineage planning_runs 1---* planning_context_snapshots backlog_candidates 1---* task_lineage +backlog_items 1---* task_lineage tasks 1---* task_lineage documents 1---* document_links @@ -86,7 +87,7 @@ Notes: | `title` | TEXT | NOT NULL | Task title | | `description` | TEXT | DEFAULT '' | Task details | | `status` | TEXT | NOT NULL DEFAULT 'todo' | `todo`, `in_progress`, `done`, `cancelled` | -| `priority` | TEXT | DEFAULT 'medium' | `low`, `medium`, `high` | +| `priority` | TEXT | DEFAULT 'medium' | `low`, `medium`, `high`, `urgent` | | `assignee` | TEXT | DEFAULT '' | Human name or agent identifier | | `source` | TEXT | DEFAULT '' | `human` or `agent:` or `role_dispatch:` | | `dispatch_status` | TEXT | NOT NULL DEFAULT 'none' | (Phase 6b, migration 029) `none`, `queued`, `running`, `completed`, `failed` | @@ -94,6 +95,28 @@ Notes: | `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | | `updated_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | +### Table: `backlog_items` + +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | TEXT | PRIMARY KEY | UUID v4 | +| `project_id` | TEXT | NOT NULL, FK -> projects.id | Parent project | +| `requirement_id` | TEXT | FK -> requirements.id, nullable | Optional originating requirement | +| `planning_run_id` | TEXT | FK -> planning_runs.id, nullable | Optional originating planning run | +| `backlog_candidate_id` | TEXT | FK -> backlog_candidates.id, nullable, unique when present | Optional compatibility/evidence candidate | +| `task_id` | TEXT | FK -> tasks.id, nullable, unique when present | Task created when committed | +| `title` | TEXT | NOT NULL | Backlog item title | +| `description` | TEXT | NOT NULL DEFAULT '' | Backlog item details | +| `status` | TEXT | NOT NULL DEFAULT 'triage' | `triage`, `ready`, `committed`, `blocked`, `archived` | +| `priority` | TEXT | NOT NULL DEFAULT 'medium' | `low`, `medium`, `high`, `urgent` | +| `source` | TEXT | NOT NULL DEFAULT 'human' | `human`, `planning_run`, `backlog_candidate`, `connector` | +| `rank` | INTEGER | NOT NULL DEFAULT 0 | Manual or generated ordering | +| `labels` | JSONB | NOT NULL DEFAULT '[]' | User-facing labels | +| `acceptance_criteria` | TEXT | NOT NULL DEFAULT '' | Optional completion criteria | +| `blocked_reason` | TEXT | NOT NULL DEFAULT '' | Optional blocked-state context | +| `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | +| `updated_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | + Notes (tasks): - `dispatch_status = 'queued'` is set automatically at task creation when `source` starts with `role_dispatch:`. - `dispatch_status = 'running'` is set when a connector claims the task via `POST /api/connector/claim-next-task`. @@ -342,7 +365,8 @@ JSON response. `audit.QueryLatest` is the underlying primitive. | `requirement_id` | TEXT | FK -> requirements.id | Optional requirement ancestor | | `planning_run_id` | TEXT | FK -> planning_runs.id | Optional planning run ancestor | | `backlog_candidate_id` | TEXT | FK -> backlog_candidates.id | Optional candidate ancestor | -| `lineage_kind` | TEXT | NOT NULL DEFAULT 'applied_candidate' | `applied_candidate`, `manual_requirement`, `merged_requirement` | +| `backlog_item_id` | TEXT | FK -> backlog_items.id | Optional backlog item ancestor | +| `lineage_kind` | TEXT | NOT NULL DEFAULT 'applied_candidate' | `applied_candidate`, `manual_requirement`, `merged_requirement`, `backlog_item` | | `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | | Notes: diff --git a/docs/operating-rules.md b/docs/operating-rules.md index 99274e4..e0a84ae 100644 --- a/docs/operating-rules.md +++ b/docs/operating-rules.md @@ -163,6 +163,7 @@ Every behavior change that affects: ### Testing expectations +- Test design follows `~/github/qa-testing-rules/AGENT.md` / `screenleon/qa-testing-rules`: enumerate the 12 categories before writing cases, document intentional N/A categories, avoid happy-path-only coverage, and mutation-check core tests before handoff. [agent:documentation-architect] - Unit tests for all business logic in Go - API integration tests for endpoint contracts - Frontend: component tests for critical UI paths diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 51ba682..a5c1606 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -8,6 +8,7 @@ import type { AccountBinding, CreateAccountBindingPayload, UpdateAccountBindingPayload, LocalConnector, CreateLocalConnectorPairingSessionPayload, CreateLocalConnectorPairingSessionResponse, ConnectorActivityResponse, ActiveConnectorEntry, + BacklogItem, CreateBacklogItemPayload, UpdateBacklogItemPayload, CommitBacklogItemResponse, } from '../types'; const BASE_URL = '/api'; @@ -178,6 +179,56 @@ export async function requeueDispatchTask(id: string) { }); } +export type BacklogItemListFilters = { + status?: string; + priority?: string; + source?: string; + label?: string; + q?: string; +}; + +export async function listBacklogItems( + projectId: string, + page = 1, + perPage = 100, + sort = 'rank', + order = 'asc', + filters?: BacklogItemListFilters, +) { + const params = new URLSearchParams({ + page: page.toString(), + per_page: perPage.toString(), + sort, + order, + ...(filters?.status && { status: filters.status }), + ...(filters?.priority && { priority: filters.priority }), + ...(filters?.source && { source: filters.source }), + ...(filters?.label && { label: filters.label }), + ...(filters?.q && { q: filters.q }), + }); + return request(`/projects/${encodeURIComponent(projectId)}/backlog-items?${params}`); +} + +export async function createBacklogItem(projectId: string, data: CreateBacklogItemPayload) { + return request(`/projects/${encodeURIComponent(projectId)}/backlog-items`, { + method: 'POST', + body: JSON.stringify(data), + }); +} + +export async function updateBacklogItem(id: string, data: UpdateBacklogItemPayload) { + return request(`/backlog-items/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(data), + }); +} + +export async function commitBacklogItemToTask(id: string) { + return request(`/backlog-items/${encodeURIComponent(id)}/commit-to-task`, { + method: 'POST', + }); +} + // Documents export async function listDocuments(projectId: string, page = 1, perPage = 20) { return request(`/projects/${encodeURIComponent(projectId)}/documents?page=${page}&per_page=${perPage}`); @@ -747,4 +798,3 @@ export async function listActiveConnectors(projectId: string) { export function connectorActivityStreamURL(connectorId: string): string { return `${BASE_URL}/me/local-connectors/${encodeURIComponent(connectorId)}/activity-stream`; } - diff --git a/frontend/src/index.css b/frontend/src/index.css index 705a172..7cdd378 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,10 +1,11 @@ :root { - --bg: #0f1117; - --bg-card: #1a1d27; - --bg-hover: #242736; - --border: #2d3044; - --text: #e4e4e7; - --text-muted: #9ca3af; + --bg: #0b0f17; + --bg-card: #141a24; + --bg-hover: #202938; + --border: #334155; + --text: #f8fafc; + --text-muted: #cbd5e1; + --text-subtle: #94a3b8; --primary: #6366f1; --primary-hover: #818cf8; --danger: #ef4444; @@ -213,56 +214,76 @@ a:hover { /* Status badges */ .badge { - display: inline-block; + display: inline-flex; + align-items: center; + justify-content: center; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; - font-weight: 500; + font-weight: 600; + border: 1px solid transparent; + line-height: 1.35; + white-space: nowrap; } .badge-todo { - background: #374151; - color: #9ca3af; + background: #253044; + color: #e2e8f0; + border-color: #475569; } .badge-in_progress { background: #1e3a5f; - color: var(--info); + color: #bfdbfe; + border-color: #3b82f6; } .badge-done { background: #14532d; - color: var(--success); + color: #bbf7d0; + border-color: #22c55e; } .badge-cancelled { background: #451a1a; - color: #f87171; + color: #fecaca; + border-color: #ef4444; } .badge-low { - background: #374151; - color: #9ca3af; + background: #253044; + color: #e2e8f0; + border-color: #475569; } .badge-medium { - background: #422006; - color: var(--warning); + background: #51350b; + color: #fde68a; + border-color: #d97706; } .badge-high { - background: #451a1a; - color: #f87171; + background: #7f1d1d; + color: #fecaca; + border-color: #f87171; +} + +.badge-urgent { + background: #881337; + color: #ffe4e6; + border-color: #fb7185; } .badge-stale { - background: #451a1a; - color: #f87171; + background: #7f1d1d; + color: #fecaca; + border-color: #ef4444; } .badge-fresh { background: #14532d; - color: var(--success); + color: #bbf7d0; + border-color: #22c55e; } /* Grid */ @@ -577,6 +598,115 @@ a:hover { flex: 1 1 12rem; } +.backlog-focus-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; +} + +.backlog-focus-item { + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--bg-card); + padding: 0.8rem 0.9rem; +} + +.backlog-focus-item span { + display: block; + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; +} + +.backlog-focus-item strong { + display: block; + margin-top: 0.2rem; + font-size: 1.35rem; + line-height: 1.2; +} + +.backlog-focus-item small { + display: block; + margin-top: 0.3rem; + color: var(--text-subtle); + font-size: 0.76rem; + line-height: 1.35; +} + +.backlog-list { + display: grid; + gap: 0.7rem; +} + +.backlog-row { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: center; + text-align: left; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--bg-card); + color: var(--text); + padding: 0.9rem 1rem; + transition: border-color 0.2s, background-color 0.2s, transform 0.2s; +} + +.backlog-row:hover, +.backlog-row:focus-visible { + border-color: var(--primary); + background: var(--bg-hover); + outline: none; +} + +.backlog-row:focus-visible { + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.35); +} + +.backlog-row-main { + min-width: 0; +} + +.backlog-row-title { + display: flex; + align-items: center; + gap: 0.6rem; + min-width: 0; +} + +.backlog-row-title strong { + overflow-wrap: anywhere; + line-height: 1.35; +} + +.backlog-row p { + margin: 0.45rem 0 0; + color: var(--text-muted); + font-size: 0.86rem; + line-height: 1.45; +} + +.backlog-row-meta { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + align-items: center; + margin-top: 0.65rem; +} + +.backlog-muted { + color: var(--text-muted); + font-size: 0.78rem; +} + +.backlog-row-action { + display: flex; + justify-content: flex-end; +} + .planning-shell { display: grid; gap: 1rem; @@ -1042,6 +1172,15 @@ a:hover { .planning-candidate-review-layout { grid-template-columns: 1fr; } + .backlog-focus-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .backlog-row { + grid-template-columns: 1fr; + } + .backlog-row-action { + justify-content: flex-start; + } .table { min-width: 620px; } @@ -1502,8 +1641,9 @@ a:hover { background: var(--bg-hover, rgba(255, 255, 255, 0.03)); } .project-rail button.is-active { - background: var(--primary); - color: white; + background: #4f46e5; + color: #ffffff; + box-shadow: inset 3px 0 0 #c7d2fe; } .project-rail .rail-count { background: rgba(255, 255, 255, 0.18); @@ -1514,8 +1654,9 @@ a:hover { text-align: center; } .project-rail button:not(.is-active) .rail-count { - background: var(--border); - color: var(--text-muted); + background: #1f2937; + border: 1px solid var(--border); + color: var(--text); } /* P4-2: Model Settings hub card grid. */ diff --git a/frontend/src/pages/ProjectDetail.test.tsx b/frontend/src/pages/ProjectDetail.test.tsx index daf47ad..d323a99 100644 --- a/frontend/src/pages/ProjectDetail.test.tsx +++ b/frontend/src/pages/ProjectDetail.test.tsx @@ -57,6 +57,10 @@ vi.mock('../api/client', () => ({ listDocuments: vi.fn(() => Promise.resolve({ data: [] })), refreshDocumentSummary: vi.fn(() => Promise.resolve({ data: null })), discoverMirrorRepos: vi.fn(() => Promise.resolve({ data: { repos: [] } })), + listBacklogItems: vi.fn(() => Promise.resolve({ data: [] })), + createBacklogItem: vi.fn(() => Promise.resolve({ data: null })), + updateBacklogItem: vi.fn(() => Promise.resolve({ data: null })), + commitBacklogItemToTask: vi.fn(() => Promise.resolve({ data: null })), // PlanningTab + planning/ siblings call these; stub so they don't error. listPlanningRuns: vi.fn(() => Promise.resolve({ data: [] })), listBacklogCandidates: vi.fn(() => Promise.resolve({ data: [] })), @@ -88,40 +92,37 @@ beforeEach(() => { }) describe(' P4-1 IA', () => { - // T-P4-1-1: primary rail contains exactly Workspace, Overview, Tasks, - // Documents — plus exactly one More ▾ button. This test codifies the - // 2026-04-24 DECISIONS.md entry "the primary rail's four tabs are a stable - // set" (N-2): a future PR that adds a fifth primary tab without amending - // DECISIONS will fail this count assertion in CI. - it('T-P4-1-1: primary rail exposes exactly 4 primary tabs + More ▾', async () => { + // Backlog is now the default command surface; planning/tasks/docs remain + // primary, while overview/drift/activity live behind More. + it('T-P4-1-1: primary rail exposes Backlog, Planning, Tasks, Documents + More ▾', async () => { renderAt('/projects/p1') await waitLoaded() const rail = screen.getByRole('navigation', { name: /Project sections/i }) - // Exactly 5 buttons: 4 primary tabs + 1 More ▾ trigger. A new rail entry - // forces either updating this number (and DECISIONS.md) or routing it - // through the More popover instead. + // Exactly 5 buttons: 4 primary workflow tabs + 1 More ▾ trigger. const railButtons = within(rail).getAllByRole('button') expect(railButtons).toHaveLength(5) - expect(within(rail).getByRole('button', { name: /Workspace/ })).toBeInTheDocument() - expect(within(rail).getByRole('button', { name: /^Overview$/i })).toBeInTheDocument() + expect(within(rail).getByRole('button', { name: /Backlog/ })).toBeInTheDocument() + expect(within(rail).getByRole('button', { name: /Planning/ })).toBeInTheDocument() expect(within(rail).getByRole('button', { name: /^Tasks/ })).toBeInTheDocument() expect(within(rail).getByRole('button', { name: /^Documents/ })).toBeInTheDocument() expect(within(rail).getByRole('button', { name: /More/i })).toBeInTheDocument() - // Drift + Activity must NOT be directly visible — they live in More ▾. + // Overview + Drift + Activity must NOT be directly visible — they live in More ▾. + expect(within(rail).queryByRole('button', { name: /^Overview$/i })).not.toBeInTheDocument() expect(within(rail).queryByRole('button', { name: /^Drift$/i })).not.toBeInTheDocument() expect(within(rail).queryByRole('button', { name: /^Activity$/i })).not.toBeInTheDocument() // Settings has moved to the gear icon in the page header. expect(within(rail).queryByRole('button', { name: /^Settings/i })).not.toBeInTheDocument() }) - // T-P4-1-3: click "More ▾" → popover reveals Drift + Activity. - it('T-P4-1-3: More popover reveals Drift + Activity', async () => { + // T-P4-1-3: click "More ▾" → popover reveals Overview, Drift + Activity. + it('T-P4-1-3: More popover reveals Overview, Drift + Activity', async () => { renderAt('/projects/p1') await waitLoaded() await userEvent.click(screen.getByRole('button', { name: /More/i })) const menu = screen.getByRole('menu') + expect(within(menu).getByRole('menuitem', { name: /Overview/i })).toBeInTheDocument() expect(within(menu).getByRole('menuitem', { name: /Drift/i })).toBeInTheDocument() expect(within(menu).getByRole('menuitem', { name: /Activity/i })).toBeInTheDocument() }) diff --git a/frontend/src/pages/ProjectDetail.tsx b/frontend/src/pages/ProjectDetail.tsx index 41143bc..504448d 100644 --- a/frontend/src/pages/ProjectDetail.tsx +++ b/frontend/src/pages/ProjectDetail.tsx @@ -34,8 +34,9 @@ import { DriftTab } from './ProjectDetail/DriftTab' import { AgentsTab } from './ProjectDetail/AgentsTab' import { SettingsTab } from './ProjectDetail/SettingsTab' import { PlanningTab } from './ProjectDetail/PlanningTab' +import { BacklogTab } from './ProjectDetail/BacklogTab' -type Tab = 'overview' | 'planning' | 'tasks' | 'documents' | 'drift' | 'agents' | 'settings' +type Tab = 'backlog' | 'overview' | 'planning' | 'tasks' | 'documents' | 'drift' | 'agents' | 'settings' type TaskFilterState = { status: '' | Task['status']; priority: '' | Task['priority']; assignee: string } function ProjectDetail() { @@ -48,22 +49,13 @@ function ProjectDetail() { const [syncRuns, setSyncRuns] = useState([]) const [agentRuns, setAgentRuns] = useState([]) const [driftSignals, setDriftSignals] = useState([]) - // Per Phase 2 S5 / design-decision D1: the Planning Workspace is the - // per-project default landing surface (the operator's "what needs my - // review?" surface). Overview remains at its tab index for read-only - // status browsing. - // - // The initial tab is seeded from `?tab=` (if the value matches a - // known Tab slug) so deep links like `/projects/:id?tab=overview` - // continue to resolve to the expected surface; otherwise the default - // is the Workspace. Clicks on the project rail keep the URL in sync - // via setTabAndSync so the browser back/forward buttons land on the - // correct tab. + // Backlog is the per-project default working surface. Deep links still + // resolve when `?tab=` matches a known tab. const [searchParams, setSearchParams] = useSearchParams() const initialTab: Tab = (() => { const raw = searchParams.get('tab') as Tab | null - const known: Tab[] = ['overview', 'planning', 'tasks', 'documents', 'drift', 'agents', 'settings'] - return raw && known.includes(raw) ? raw : 'planning' + const known: Tab[] = ['backlog', 'overview', 'planning', 'tasks', 'documents', 'drift', 'agents', 'settings'] + return raw && known.includes(raw) ? raw : 'backlog' })() const [tab, setTabState] = useState(initialTab) const setTab = (next: Tab) => { @@ -578,13 +570,13 @@ function ProjectDetail() {
+ {tab === 'backlog' && ( + setTab('tasks')} + /> + )} {tab === 'overview' && ( ({ + listBacklogItems: (...args: unknown[]) => listBacklogItems(...args), + createBacklogItem: (...args: unknown[]) => createBacklogItem(...args), + updateBacklogItem: (...args: unknown[]) => updateBacklogItem(...args), + commitBacklogItemToTask: (...args: unknown[]) => commitBacklogItemToTask(...args), +})) + +function makeItem(overrides: Partial): BacklogItem { + return { + id: overrides.id ?? 'backlog-1', + project_id: 'project-1', + title: overrides.title ?? 'Backlog item', + description: overrides.description ?? '', + status: overrides.status ?? 'triage', + priority: overrides.priority ?? 'medium', + source: overrides.source ?? 'human', + rank: overrides.rank ?? 0, + labels: overrides.labels ?? [], + acceptance_criteria: overrides.acceptance_criteria ?? '', + blocked_reason: overrides.blocked_reason ?? '', + created_at: overrides.created_at ?? '2026-05-01T00:00:00Z', + updated_at: overrides.updated_at ?? '2026-05-01T00:00:00Z', + ...overrides, + } +} + +function renderBacklog() { + return render( + , + ) +} + +beforeEach(() => { + vi.clearAllMocks() + createBacklogItem.mockResolvedValue({ data: null }) + updateBacklogItem.mockResolvedValue({ data: null }) + commitBacklogItemToTask.mockResolvedValue({ data: { already_applied: false } }) +}) + +describe(' focus summary', () => { + /** + * Counts urgent focus as project-level open urgent with committed context. + * Steps: + * 1. Return urgent open, urgent committed, ready, and blocked backlog items. + * 2. Render the backlog tab. + * 3. Assert the focus strip separates open urgent from committed urgent. + */ + it('counts urgent focus as open urgent while showing total and committed context', async () => { + const backlogItems = [ + makeItem({ id: 'urgent-open', title: 'Open urgent', priority: 'urgent', status: 'triage' }), + makeItem({ id: 'urgent-committed', title: 'Committed urgent', priority: 'urgent', status: 'committed', task_id: 'task-1' }), + makeItem({ id: 'ready', title: 'Ready work', priority: 'high', status: 'ready' }), + makeItem({ id: 'blocked', title: 'Blocked work', priority: 'medium', status: 'blocked' }), + ] + listBacklogItems.mockResolvedValue({ + data: backlogItems, + meta: { page: 1, per_page: 100, total: backlogItems.length }, + }) + + renderBacklog() + + const summary = await screen.findByLabelText('Backlog current focus') + await waitFor(() => expect(within(summary).getByText('Urgent open')).toBeInTheDocument()) + + expect(within(summary).getByText('2 total · 1 committed')).toBeInTheDocument() + expect(within(summary).getByText('Blocked open')).toBeInTheDocument() + expect(within(summary).getByText('Ready open')).toBeInTheDocument() + expect(within(summary).getByText('Already converted to tasks')).toBeInTheDocument() + }) + + /** + * Keeps focus metrics project-wide while the visible list is filtered. + * Steps: + * 1. Return one filtered row for the visible list and three rows for summary. + * 2. Render the backlog tab. + * 3. Assert visible totals and project-level urgent focus do not collapse to the filter. + */ + it('keeps focus summary project-level when the visible list is filtered', async () => { + const filteredItems = [ + makeItem({ id: 'ready', title: 'Ready work', priority: 'high', status: 'ready', labels: ['ui'] }), + ] + const projectItems = [ + ...filteredItems, + makeItem({ id: 'urgent-open', title: 'Open urgent', priority: 'urgent', status: 'triage', labels: ['api'] }), + makeItem({ id: 'blocked', title: 'Blocked work', priority: 'medium', status: 'blocked', labels: ['api'] }), + ] + listBacklogItems + .mockResolvedValueOnce({ data: filteredItems, meta: { page: 1, per_page: 100, total: filteredItems.length } }) + .mockResolvedValueOnce({ data: projectItems, meta: { page: 1, per_page: 500, total: projectItems.length } }) + + renderBacklog() + + const summary = await screen.findByLabelText('Backlog current focus') + expect(within(summary).getByText('Urgent open')).toBeInTheDocument() + expect(within(summary).getByText('1 total · 0 committed')).toBeInTheDocument() + expect(screen.getByText('Showing 1 of 1 matching · 3 project total')).toBeInTheDocument() + }) +}) + +describe(' workflow controls', () => { + /** + * Disables commit on blocked backlog rows. + * Steps: + * 1. Render one blocked backlog item. + * 2. Inspect the row action exposed to the user. + * 3. Assert the commit action is disabled and no commit API call can be triggered. + */ + it('disables commit action for blocked backlog items', async () => { + const user = userEvent.setup() + const blockedItem = makeItem({ + id: 'blocked', + title: 'Blocked work', + priority: 'urgent', + status: 'blocked', + blocked_reason: 'Needs API contract', + }) + listBacklogItems.mockResolvedValue({ + data: [blockedItem], + meta: { page: 1, per_page: 100, total: 1 }, + }) + + renderBacklog() + + const row = (await screen.findByText('Blocked work')).closest('.backlog-row') + if (!(row instanceof HTMLElement)) throw new Error('blocked backlog row not found') + const commitButton = within(row).getByRole('button', { name: 'Blocked' }) + expect(commitButton).toBeDisabled() + + await user.click(commitButton) + expect(commitBacklogItemToTask).not.toHaveBeenCalled() + }) + + /** + * Omits committed status from the create form. + * Steps: + * 1. Open the create backlog modal. + * 2. Inspect the status select options. + * 3. Assert users cannot create already-committed backlog items from the UI. + */ + it('does not offer committed status while creating a backlog item', async () => { + const user = userEvent.setup() + listBacklogItems.mockResolvedValue({ + data: [], + meta: { page: 1, per_page: 100, total: 0 }, + }) + + renderBacklog() + + await screen.findByLabelText('Backlog current focus') + await user.click(screen.getByRole('button', { name: '+ New Backlog' })) + + const modal = screen.getByRole('heading', { name: 'Create Backlog Item' }).closest('.modal') + if (!(modal instanceof HTMLElement)) throw new Error('create backlog modal not found') + const statusSelect = within(modal).getByDisplayValue('Triage') as HTMLSelectElement + const optionLabels = Array.from(statusSelect.options).map(option => option.textContent) + expect(optionLabels).toEqual(['Triage', 'Ready', 'Blocked', 'Archived']) + }) + + /** + * Keeps task-owned fields read-only after a backlog item is committed. + * Steps: + * 1. Render one committed backlog item and open the edit modal. + * 2. Change a non-task metadata field and save. + * 3. Assert title/priority remain the original values in the update payload. + */ + it('locks task-owned fields when editing committed backlog items', async () => { + const user = userEvent.setup() + const committedItem = makeItem({ + id: 'committed', + title: 'Committed urgent', + description: 'Original description', + priority: 'urgent', + status: 'committed', + task_id: 'task-1', + labels: ['api'], + }) + listBacklogItems.mockResolvedValue({ + data: [committedItem], + meta: { page: 1, per_page: 100, total: 1 }, + }) + + renderBacklog() + + const row = (await screen.findByText('Committed urgent')).closest('.backlog-row') + if (!(row instanceof HTMLElement)) throw new Error('committed backlog row not found') + await user.click(within(row).getByRole('button', { name: 'Edit' })) + + const modal = screen.getByRole('heading', { name: 'Edit Backlog Item' }).closest('.modal') + if (!(modal instanceof HTMLElement)) throw new Error('edit backlog modal not found') + expect(within(modal).getByDisplayValue('Committed urgent')).toBeDisabled() + expect(within(modal).getByDisplayValue('Original description')).toBeDisabled() + expect(within(modal).getByDisplayValue('Urgent')).toBeDisabled() + + const labelsInput = within(modal).getByPlaceholderText('api, ui, urgent-path') + await user.clear(labelsInput) + await user.type(labelsInput, 'api, committed') + await user.click(within(modal).getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(updateBacklogItem).toHaveBeenCalledTimes(1)) + expect(updateBacklogItem).toHaveBeenCalledWith('committed', expect.objectContaining({ + title: 'Committed urgent', + description: 'Original description', + priority: 'urgent', + status: 'committed', + labels: ['api', 'committed'], + })) + }) +}) diff --git a/frontend/src/pages/ProjectDetail/BacklogTab.tsx b/frontend/src/pages/ProjectDetail/BacklogTab.tsx new file mode 100644 index 0000000..ddb88d1 --- /dev/null +++ b/frontend/src/pages/ProjectDetail/BacklogTab.tsx @@ -0,0 +1,455 @@ +import { useEffect, useMemo, useState, type FormEvent } from 'react' +import type { BacklogItem, CreateBacklogItemPayload, Task, UpdateBacklogItemPayload } from '../../types' +import { commitBacklogItemToTask, createBacklogItem, listBacklogItems, updateBacklogItem } from '../../api/client' + +type BacklogFilterState = { + status: '' | BacklogItem['status'] + priority: '' | BacklogItem['priority'] + label: string + q: string +} + +type BacklogFormState = { + title: string + description: string + status: BacklogItem['status'] + priority: BacklogItem['priority'] + rank: string + labels: string + acceptance_criteria: string + blocked_reason: string +} + +const priorityOptions: Array<{ value: Task['priority']; label: string }> = [ + { value: 'urgent', label: 'Urgent' }, + { value: 'high', label: 'High' }, + { value: 'medium', label: 'Medium' }, + { value: 'low', label: 'Low' }, +] + +const statusOptions: Array<{ value: BacklogItem['status']; label: string }> = [ + { value: 'triage', label: 'Triage' }, + { value: 'ready', label: 'Ready' }, + { value: 'blocked', label: 'Blocked' }, + { value: 'committed', label: 'Committed' }, + { value: 'archived', label: 'Archived' }, +] + +const emptyForm: BacklogFormState = { + title: '', + description: '', + status: 'triage', + priority: 'medium', + rank: '0', + labels: '', + acceptance_criteria: '', + blocked_reason: '', +} + +interface BacklogTabProps { + projectId: string + onReload: () => void + onError: (msg: string) => void + onSuccess: (msg: string) => void + onNavigateToTasks: () => void +} + +export function BacklogTab({ projectId, onReload, onError, onSuccess, onNavigateToTasks }: BacklogTabProps) { + const [items, setItems] = useState([]) + const [summaryItems, setSummaryItems] = useState([]) + const [filteredTotal, setFilteredTotal] = useState(0) + const [projectBacklogTotal, setProjectBacklogTotal] = useState(0) + const [loading, setLoading] = useState(false) + const [filters, setFilters] = useState({ status: '', priority: '', label: '', q: '' }) + const [showCreate, setShowCreate] = useState(false) + const [form, setForm] = useState(emptyForm) + const [editingItem, setEditingItem] = useState(null) + const [editForm, setEditForm] = useState(emptyForm) + const [committingId, setCommittingId] = useState(null) + + const loadBacklog = async () => { + try { + setLoading(true) + const [response, summaryResponse] = await Promise.all([ + listBacklogItems(projectId, 1, 100, 'rank', 'asc', { + status: filters.status || undefined, + priority: filters.priority || undefined, + label: filters.label.trim() || undefined, + q: filters.q.trim() || undefined, + }), + listBacklogItems(projectId, 1, 500, 'rank', 'asc'), + ]) + setItems(response.data) + setSummaryItems(summaryResponse.data) + setFilteredTotal(response.meta?.total ?? response.data.length) + setProjectBacklogTotal(summaryResponse.meta?.total ?? summaryResponse.data.length) + } catch (err) { + setItems([]) + setSummaryItems([]) + setFilteredTotal(0) + setProjectBacklogTotal(0) + onError(err instanceof Error ? err.message : 'Failed to load backlog') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void loadBacklog() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId, filters.status, filters.priority, filters.label, filters.q]) + + const openCount = useMemo(() => items.filter(item => item.status !== 'committed' && item.status !== 'archived').length, [items]) + const visibleItems = useMemo( + () => filters.status ? items : items.filter(item => item.status !== 'archived'), + [filters.status, items], + ) + const allLabels = useMemo(() => { + const labels = new Set() + for (const item of items) { + for (const label of item.labels ?? []) labels.add(label) + } + return [...labels].sort() + }, [items]) + const focusCounts = useMemo(() => ({ + urgentOpen: summaryItems.filter(item => item.priority === 'urgent' && item.status !== 'committed' && item.status !== 'archived').length, + urgentTotal: summaryItems.filter(item => item.priority === 'urgent').length, + urgentCommitted: summaryItems.filter(item => item.priority === 'urgent' && item.status === 'committed').length, + blockedOpen: summaryItems.filter(item => item.status === 'blocked').length, + readyOpen: summaryItems.filter(item => item.status === 'ready').length, + committedTotal: summaryItems.filter(item => item.status === 'committed').length, + }), [summaryItems]) + + function resetFilters() { + setFilters({ status: '', priority: '', label: '', q: '' }) + } + + function itemToForm(item: BacklogItem): BacklogFormState { + return { + title: item.title, + description: item.description, + status: item.status, + priority: item.priority, + rank: String(item.rank), + labels: (item.labels ?? []).join(', '), + acceptance_criteria: item.acceptance_criteria, + blocked_reason: item.blocked_reason, + } + } + + function parseLabels(raw: string): string[] { + const seen = new Set() + return raw + .split(',') + .map(label => label.trim()) + .filter(label => { + if (!label || seen.has(label)) return false + seen.add(label) + return true + }) + } + +function formToCreatePayload(state: BacklogFormState): CreateBacklogItemPayload { + return { + title: state.title.trim(), + description: state.description, + status: state.status, + priority: state.priority, + source: 'human', + rank: Number.parseInt(state.rank, 10) || 0, + labels: parseLabels(state.labels), + acceptance_criteria: state.acceptance_criteria, + blocked_reason: state.blocked_reason, + } + } + + function formToUpdatePayload(state: BacklogFormState, original: BacklogItem): UpdateBacklogItemPayload { + return { + title: original.status === 'committed' ? original.title : state.title.trim(), + description: original.status === 'committed' ? original.description : state.description, + status: state.status, + priority: original.status === 'committed' ? original.priority : state.priority, + rank: Number.parseInt(state.rank, 10) || 0, + labels: parseLabels(state.labels), + acceptance_criteria: state.acceptance_criteria, + blocked_reason: state.blocked_reason, + } + } + + async function handleCreate(e: FormEvent) { + e.preventDefault() + if (!form.title.trim()) return + try { + await createBacklogItem(projectId, formToCreatePayload(form)) + setForm(emptyForm) + setShowCreate(false) + onSuccess('Backlog item created.') + await loadBacklog() + } catch (err) { + onError(err instanceof Error ? err.message : 'Failed to create backlog item') + } + } + + async function handleSave(e: FormEvent) { + e.preventDefault() + if (!editingItem || !editForm.title.trim()) return + try { + await updateBacklogItem(editingItem.id, formToUpdatePayload(editForm, editingItem)) + setEditingItem(null) + onSuccess('Backlog item updated.') + await loadBacklog() + } catch (err) { + onError(err instanceof Error ? err.message : 'Failed to update backlog item') + } + } + + async function handleCommit(item: BacklogItem) { + try { + setCommittingId(item.id) + const response = await commitBacklogItemToTask(item.id) + onSuccess(response.data.already_applied ? 'Backlog item already has a task.' : 'Backlog item committed to task.') + await loadBacklog() + onReload() + } catch (err) { + onError(err instanceof Error ? err.message : 'Failed to commit backlog item') + } finally { + setCommittingId(null) + } + } + + function openEdit(item: BacklogItem) { + setEditingItem(item) + setEditForm(itemToForm(item)) + } + + const hasActiveFilters = Boolean(filters.status || filters.priority || filters.label.trim() || filters.q.trim()) + const statusBadgeClass = (status: BacklogItem['status']) => { + if (status === 'committed') return 'badge-fresh' + if (status === 'blocked') return 'badge-stale' + if (status === 'ready') return 'badge-low' + if (status === 'archived') return 'badge-cancelled' + return 'badge-todo' + } + + return ( +
+
+
+ Urgent open + {focusCounts.urgentOpen} + {focusCounts.urgentTotal} total · {focusCounts.urgentCommitted} committed +
+
+ Blocked open + {focusCounts.blockedOpen} + Needs unblock before task commit +
+
+ Ready open + {focusCounts.readyOpen} + Can become tasks next +
+
+ Committed + {focusCounts.committedTotal} + Already converted to tasks +
+
+ +
+
+ {openCount} open + + Showing {visibleItems.length} of {filteredTotal} + {projectBacklogTotal !== filteredTotal ? ` matching · ${projectBacklogTotal} project total` : ''} + + + + + + setFilters({ ...filters, label: e.target.value })} + placeholder="Label" + list="backlog-labels" + /> + + {allLabels.map(label => + setFilters({ ...filters, q: e.target.value })} + placeholder="Search backlog" + /> + +
+
+ + +
+
+ + {loading ? ( +
+

Loading backlog...

+
+ ) : visibleItems.length === 0 ? ( +
+

{hasActiveFilters ? 'No backlog items match the current filters' : 'No backlog items yet'}

+

{hasActiveFilters ? 'Adjust or reset filters to see more items.' : 'Create backlog items first, then commit only the ones that should become tasks.'}

+
+ ) : ( +
+ {visibleItems.map(item => ( +
+
+
+ {item.priority} + {item.title} +
+ {item.description &&

{item.description}

} +
+ {item.status} + {(item.labels ?? []).length === 0 + ? No labels + : item.labels.map(label => {label})} + Source {item.source} + Updated {new Date(item.updated_at).toLocaleDateString()} +
+
+
+ + +
+
+ ))} +
+ )} + + {showCreate && ( + setShowCreate(false)} + onSubmit={handleCreate} + includeCommittedStatus={false} + /> + )} + + {editingItem && ( + setEditingItem(null)} + onSubmit={handleSave} + includeCommittedStatus={editingItem.status === 'committed'} + taskFieldsReadOnly={editingItem.status === 'committed'} + /> + )} +
+ ) +} + +function BacklogFormModal({ + title, + form, + onChange, + onClose, + onSubmit, + includeCommittedStatus, + taskFieldsReadOnly, +}: { + title: string + form: BacklogFormState + onChange: (next: BacklogFormState) => void + onClose: () => void + onSubmit: (e: FormEvent) => void + includeCommittedStatus?: boolean + taskFieldsReadOnly?: boolean +}) { + const modalStatusOptions = includeCommittedStatus + ? statusOptions + : statusOptions.filter(option => option.value !== 'committed') + + return ( +
+
e.stopPropagation()}> +

{title}

+
+
+ + onChange({ ...form, title: e.target.value })} disabled={taskFieldsReadOnly} autoFocus={!taskFieldsReadOnly} /> +
+
+ +