diff --git a/beads.go b/beads.go index 27833e92..d942296a 100644 --- a/beads.go +++ b/beads.go @@ -77,6 +77,7 @@ const ( StatusOpen = types.StatusOpen StatusInProgress = types.StatusInProgress StatusBlocked = types.StatusBlocked + StatusDeferred = types.StatusDeferred StatusClosed = types.StatusClosed ) diff --git a/cmd/bd/config.go b/cmd/bd/config.go index c5ac9156..ca0a1da7 100644 --- a/cmd/bd/config.go +++ b/cmd/bd/config.go @@ -32,7 +32,7 @@ Custom Status States: bd config set status.custom "awaiting_review,awaiting_testing,awaiting_docs" This enables issues to use statuses like 'awaiting_review' in addition to - the built-in statuses (open, in_progress, blocked, closed). + the built-in statuses (open, in_progress, blocked, deferred, closed). Examples: bd config set jira.url "https://company.atlassian.net" diff --git a/cmd/bd/count.go b/cmd/bd/count.go index 07780030..2fb722f8 100644 --- a/cmd/bd/count.go +++ b/cmd/bd/count.go @@ -420,7 +420,7 @@ Examples: func init() { // Filter flags (same as list command) - countCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)") + countCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)") countCmd.Flags().IntP("priority", "p", 0, "Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)") countCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") countCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)") diff --git a/cmd/bd/defer.go b/cmd/bd/defer.go new file mode 100644 index 00000000..c7fde17b --- /dev/null +++ b/cmd/bd/defer.go @@ -0,0 +1,142 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" +) + +var deferCmd = &cobra.Command{ + Use: "defer [id...]", + Short: "Defer one or more issues for later", + Long: `Defer issues to put them on ice for later. + +Deferred issues are deliberately set aside - not blocked by anything specific, +just postponed for future consideration. Unlike blocked issues, there's no +dependency keeping them from being worked. Unlike closed issues, they will +be revisited. + +Deferred issues don't show in 'bd ready' but remain visible in 'bd list'. + +Examples: + bd defer bd-abc # Defer a single issue + bd defer bd-abc bd-def # Defer multiple issues`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + CheckReadonly("defer") + + ctx := rootCtx + + // Resolve partial IDs first + var resolvedIDs []string + if daemonClient != nil { + for _, id := range args { + resolveArgs := &rpc.ResolveIDArgs{ID: id} + resp, err := daemonClient.ResolveID(resolveArgs) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err) + os.Exit(1) + } + var resolvedID string + if err := json.Unmarshal(resp.Data, &resolvedID); err != nil { + fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) + os.Exit(1) + } + resolvedIDs = append(resolvedIDs, resolvedID) + } + } else { + var err error + resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } + + deferredIssues := []*types.Issue{} + + // If daemon is running, use RPC + if daemonClient != nil { + for _, id := range resolvedIDs { + status := string(types.StatusDeferred) + updateArgs := &rpc.UpdateArgs{ + ID: id, + Status: &status, + } + + resp, err := daemonClient.Update(updateArgs) + if err != nil { + fmt.Fprintf(os.Stderr, "Error deferring %s: %v\n", id, err) + continue + } + + if jsonOutput { + var issue types.Issue + if err := json.Unmarshal(resp.Data, &issue); err == nil { + deferredIssues = append(deferredIssues, &issue) + } + } else { + cyan := color.New(color.FgCyan).SprintFunc() + fmt.Printf("%s Deferred %s\n", cyan("*"), id) + } + } + + if jsonOutput && len(deferredIssues) > 0 { + outputJSON(deferredIssues) + } + return + } + + // Fall back to direct storage access + if store == nil { + fmt.Fprintln(os.Stderr, "Error: database not initialized") + os.Exit(1) + } + + for _, id := range args { + fullID, err := utils.ResolvePartialID(ctx, store, id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err) + continue + } + + updates := map[string]interface{}{ + "status": string(types.StatusDeferred), + } + + if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil { + fmt.Fprintf(os.Stderr, "Error deferring %s: %v\n", fullID, err) + continue + } + + if jsonOutput { + issue, _ := store.GetIssue(ctx, fullID) + if issue != nil { + deferredIssues = append(deferredIssues, issue) + } + } else { + cyan := color.New(color.FgCyan).SprintFunc() + fmt.Printf("%s Deferred %s\n", cyan("*"), fullID) + } + } + + // Schedule auto-flush if any issues were deferred + if len(args) > 0 { + markDirtyAndScheduleFlush() + } + + if jsonOutput && len(deferredIssues) > 0 { + outputJSON(deferredIssues) + } + }, +} + +func init() { + rootCmd.AddCommand(deferCmd) +} diff --git a/cmd/bd/dep.go b/cmd/bd/dep.go index 00f14015..d7ec05c3 100644 --- a/cmd/bd/dep.go +++ b/cmd/bd/dep.go @@ -497,6 +497,8 @@ func getStatusEmoji(status types.Status) string { return "◧" // U+25E7 Square Left Half Black case types.StatusBlocked: return "⚠" // U+26A0 Warning Sign + case types.StatusDeferred: + return "❄" // U+2744 Snowflake (on ice) case types.StatusClosed: return "☑" // U+2611 Ballot Box with Check default: @@ -746,7 +748,7 @@ func init() { depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)") depTreeCmd.Flags().Bool("reverse", false, "Show dependent tree (deprecated: use --direction=up)") depTreeCmd.Flags().String("direction", "", "Tree direction: 'down' (dependencies), 'up' (dependents), or 'both'") - depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, closed)") + depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, deferred, closed)") depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart") // Note: --json flag is defined as a persistent flag in main.go, not here diff --git a/cmd/bd/graph.go b/cmd/bd/graph.go index 01cbdc4c..56bbc40d 100644 --- a/cmd/bd/graph.go +++ b/cmd/bd/graph.go @@ -382,6 +382,9 @@ func renderNodeBox(node *GraphNode, width int) string { case types.StatusBlocked: statusIcon = "●" colorFn = color.New(color.FgRed).SprintFunc() + case types.StatusDeferred: + statusIcon = "❄" + colorFn = color.New(color.FgCyan).SprintFunc() case types.StatusClosed: statusIcon = "✓" colorFn = color.New(color.FgGreen).SprintFunc() @@ -458,6 +461,9 @@ func renderNodeBoxWithDeps(node *GraphNode, width int, blocksCount int, blockedB case types.StatusBlocked: statusIcon = "●" colorFn = color.New(color.FgRed).SprintFunc() + case types.StatusDeferred: + statusIcon = "❄" + colorFn = color.New(color.FgCyan).SprintFunc() case types.StatusClosed: statusIcon = "✓" colorFn = color.New(color.FgGreen).SprintFunc() diff --git a/cmd/bd/list.go b/cmd/bd/list.go index 91a96a13..7b6b1a9c 100644 --- a/cmd/bd/list.go +++ b/cmd/bd/list.go @@ -565,7 +565,7 @@ var listCmd = &cobra.Command{ } func init() { - listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)") + listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)") registerPriorityFlag(listCmd, "") listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule)") diff --git a/cmd/bd/search.go b/cmd/bd/search.go index 55423a15..0d43e384 100644 --- a/cmd/bd/search.go +++ b/cmd/bd/search.go @@ -370,7 +370,7 @@ func outputSearchResults(issues []*types.Issue, query string, longFormat bool) { func init() { searchCmd.Flags().String("query", "", "Search query (alternative to positional argument)") - searchCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)") + searchCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)") searchCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") searchCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)") searchCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)") diff --git a/cmd/bd/stale.go b/cmd/bd/stale.go index c68c90e6..afba1efa 100644 --- a/cmd/bd/stale.go +++ b/cmd/bd/stale.go @@ -23,8 +23,8 @@ This helps identify: limit, _ := cmd.Flags().GetInt("limit") // Use global jsonOutput set by PersistentPreRun // Validate status if provided - if status != "" && status != "open" && status != "in_progress" && status != "blocked" { - fmt.Fprintf(os.Stderr, "Error: invalid status '%s'. Valid values: open, in_progress, blocked\n", status) + if status != "" && status != "open" && status != "in_progress" && status != "blocked" && status != "deferred" { + fmt.Fprintf(os.Stderr, "Error: invalid status '%s'. Valid values: open, in_progress, blocked, deferred\n", status) os.Exit(1) } filter := types.StaleFilter{ @@ -107,7 +107,7 @@ func displayStaleIssues(issues []*types.Issue, days int) { } func init() { staleCmd.Flags().IntP("days", "d", 30, "Issues not updated in this many days") - staleCmd.Flags().StringP("status", "s", "", "Filter by status (open|in_progress|blocked)") + staleCmd.Flags().StringP("status", "s", "", "Filter by status (open|in_progress|blocked|deferred)") staleCmd.Flags().IntP("limit", "n", 50, "Maximum issues to show") staleCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON format") rootCmd.AddCommand(staleCmd) diff --git a/cmd/bd/status.go b/cmd/bd/status.go index 00cbde10..327ae9bf 100644 --- a/cmd/bd/status.go +++ b/cmd/bd/status.go @@ -312,6 +312,8 @@ func getAssignedStatistics(assignee string) *types.Statistics { stats.InProgressIssues++ case types.StatusBlocked: stats.BlockedIssues++ + case types.StatusDeferred: + stats.DeferredIssues++ case types.StatusClosed: stats.ClosedIssues++ } diff --git a/cmd/bd/undefer.go b/cmd/bd/undefer.go new file mode 100644 index 00000000..ca08f426 --- /dev/null +++ b/cmd/bd/undefer.go @@ -0,0 +1,138 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" +) + +var undeferCmd = &cobra.Command{ + Use: "undefer [id...]", + Short: "Undefer one or more issues (restore to open)", + Long: `Undefer issues to restore them to open status. + +This brings issues back from the icebox so they can be worked on again. +Issues will appear in 'bd ready' if they have no blockers. + +Examples: + bd undefer bd-abc # Undefer a single issue + bd undefer bd-abc bd-def # Undefer multiple issues`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + CheckReadonly("undefer") + + ctx := rootCtx + + // Resolve partial IDs first + var resolvedIDs []string + if daemonClient != nil { + for _, id := range args { + resolveArgs := &rpc.ResolveIDArgs{ID: id} + resp, err := daemonClient.ResolveID(resolveArgs) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err) + os.Exit(1) + } + var resolvedID string + if err := json.Unmarshal(resp.Data, &resolvedID); err != nil { + fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) + os.Exit(1) + } + resolvedIDs = append(resolvedIDs, resolvedID) + } + } else { + var err error + resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } + + undeferredIssues := []*types.Issue{} + + // If daemon is running, use RPC + if daemonClient != nil { + for _, id := range resolvedIDs { + status := string(types.StatusOpen) + updateArgs := &rpc.UpdateArgs{ + ID: id, + Status: &status, + } + + resp, err := daemonClient.Update(updateArgs) + if err != nil { + fmt.Fprintf(os.Stderr, "Error undeferring %s: %v\n", id, err) + continue + } + + if jsonOutput { + var issue types.Issue + if err := json.Unmarshal(resp.Data, &issue); err == nil { + undeferredIssues = append(undeferredIssues, &issue) + } + } else { + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("%s Undeferred %s (now open)\n", green("*"), id) + } + } + + if jsonOutput && len(undeferredIssues) > 0 { + outputJSON(undeferredIssues) + } + return + } + + // Fall back to direct storage access + if store == nil { + fmt.Fprintln(os.Stderr, "Error: database not initialized") + os.Exit(1) + } + + for _, id := range args { + fullID, err := utils.ResolvePartialID(ctx, store, id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err) + continue + } + + updates := map[string]interface{}{ + "status": string(types.StatusOpen), + } + + if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil { + fmt.Fprintf(os.Stderr, "Error undeferring %s: %v\n", fullID, err) + continue + } + + if jsonOutput { + issue, _ := store.GetIssue(ctx, fullID) + if issue != nil { + undeferredIssues = append(undeferredIssues, issue) + } + } else { + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("%s Undeferred %s (now open)\n", green("*"), fullID) + } + } + + // Schedule auto-flush if any issues were undeferred + if len(args) > 0 { + markDirtyAndScheduleFlush() + } + + if jsonOutput && len(undeferredIssues) > 0 { + outputJSON(undeferredIssues) + } + }, +} + +func init() { + rootCmd.AddCommand(undeferCmd) +} diff --git a/integrations/beads-mcp/src/beads_mcp/models.py b/integrations/beads-mcp/src/beads_mcp/models.py index de9108c2..118c456b 100644 --- a/integrations/beads-mcp/src/beads_mcp/models.py +++ b/integrations/beads-mcp/src/beads_mcp/models.py @@ -6,7 +6,7 @@ from typing import Literal, Any from pydantic import BaseModel, Field, field_validator # Type aliases for issue statuses, types, and dependencies -IssueStatus = Literal["open", "in_progress", "blocked", "closed"] +IssueStatus = Literal["open", "in_progress", "blocked", "deferred", "closed"] IssueType = Literal["bug", "feature", "task", "epic", "chore"] DependencyType = Literal["blocks", "related", "parent-child", "discovered-from"] diff --git a/integrations/beads-mcp/src/beads_mcp/server.py b/integrations/beads-mcp/src/beads_mcp/server.py index 256e81a0..1ed3d00e 100644 --- a/integrations/beads-mcp/src/beads_mcp/server.py +++ b/integrations/beads-mcp/src/beads_mcp/server.py @@ -372,7 +372,7 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]: "name": "list", "description": "List all issues with optional filters", "parameters": { - "status": "open|in_progress|blocked|closed (optional)", + "status": "open|in_progress|blocked|deferred|closed (optional)", "priority": "int 0-4 (optional)", "issue_type": "bug|feature|task|epic|chore (optional)", "assignee": "str (optional)", @@ -413,7 +413,7 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]: "description": "Update an existing issue", "parameters": { "issue_id": "str (required)", - "status": "open|in_progress|blocked|closed (optional)", + "status": "open|in_progress|blocked|deferred|closed (optional)", "priority": "int 0-4 (optional)", "assignee": "str (optional)", "title": "str (optional)", diff --git a/integrations/beads-mcp/src/beads_mcp/tools.py b/integrations/beads-mcp/src/beads_mcp/tools.py index 65cb5611..d340eea5 100644 --- a/integrations/beads-mcp/src/beads_mcp/tools.py +++ b/integrations/beads-mcp/src/beads_mcp/tools.py @@ -317,7 +317,7 @@ async def beads_ready_work( async def beads_list_issues( - status: Annotated[IssueStatus | None, "Filter by status (open, in_progress, blocked, closed)"] = None, + status: Annotated[IssueStatus | None, "Filter by status (open, in_progress, blocked, deferred, closed)"] = None, priority: Annotated[int | None, "Filter by priority (0-4, 0=highest)"] = None, issue_type: Annotated[IssueType | None, "Filter by type (bug, feature, task, epic, chore)"] = None, assignee: Annotated[str | None, "Filter by assignee"] = None, @@ -392,7 +392,7 @@ async def beads_create_issue( async def beads_update_issue( issue_id: Annotated[str, "Issue ID (e.g., bd-1)"], - status: Annotated[IssueStatus | None, "New status (open, in_progress, blocked, closed)"] = None, + status: Annotated[IssueStatus | None, "New status (open, in_progress, blocked, deferred, closed)"] = None, priority: Annotated[int | None, "New priority (0-4)"] = None, assignee: Annotated[str | None, "New assignee"] = None, title: Annotated[str | None, "New title"] = None, diff --git a/internal/beads/beads.go b/internal/beads/beads.go index b35efcad..f4e411fc 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -205,8 +205,9 @@ type ( const ( StatusOpen = types.StatusOpen StatusInProgress = types.StatusInProgress - StatusClosed = types.StatusClosed StatusBlocked = types.StatusBlocked + StatusDeferred = types.StatusDeferred + StatusClosed = types.StatusClosed ) // IssueType constants diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index c1400718..0d051937 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -1033,7 +1033,7 @@ func (m *MemoryStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte return results, nil } -// getOpenBlockers returns the IDs of blockers that are currently open/in_progress/blocked. +// getOpenBlockers returns the IDs of blockers that are currently open/in_progress/blocked/deferred. // The caller must hold at least a read lock. func (m *MemoryStorage) getOpenBlockers(issueID string) []string { deps := m.dependencies[issueID] @@ -1053,7 +1053,7 @@ func (m *MemoryStorage) getOpenBlockers(issueID string) []string { continue } switch blocker.Status { - case types.StatusOpen, types.StatusInProgress, types.StatusBlocked: + case types.StatusOpen, types.StatusInProgress, types.StatusBlocked, types.StatusDeferred: blockers = append(blockers, blocker.ID) } } @@ -1082,7 +1082,8 @@ func (m *MemoryStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI } blockers := m.getOpenBlockers(issue.ID) - if issue.Status != types.StatusBlocked && len(blockers) == 0 { + // Issue is "blocked" if: status is blocked, status is deferred, or has open blockers + if issue.Status != types.StatusBlocked && issue.Status != types.StatusDeferred && len(blockers) == 0 { continue } @@ -1219,13 +1220,17 @@ func (m *MemoryStorage) GetStatistics(ctx context.Context) (*types.Statistics, e stats.InProgressIssues++ case types.StatusClosed: stats.ClosedIssues++ + case types.StatusDeferred: + stats.DeferredIssues++ case types.StatusTombstone: stats.TombstoneIssues++ + case types.StatusPinned: + stats.PinnedIssues++ } } // TotalIssues excludes tombstones (matches SQLite behavior) - stats.TotalIssues = stats.OpenIssues + stats.InProgressIssues + stats.ClosedIssues + stats.TotalIssues = stats.OpenIssues + stats.InProgressIssues + stats.ClosedIssues + stats.DeferredIssues + stats.PinnedIssues // Second pass: calculate blocked and ready issues based on dependencies // An issue is blocked if it has open blockers (uses same logic as GetBlockedIssues) diff --git a/internal/storage/sqlite/blocked_cache.go b/internal/storage/sqlite/blocked_cache.go index 49a8aa70..65902077 100644 --- a/internal/storage/sqlite/blocked_cache.go +++ b/internal/storage/sqlite/blocked_cache.go @@ -121,7 +121,7 @@ func (s *SQLiteStorage) rebuildBlockedCache(ctx context.Context, exec execer) er FROM dependencies d JOIN issues blocker ON d.depends_on_id = blocker.id WHERE d.type = 'blocks' - AND blocker.status IN ('open', 'in_progress', 'blocked') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') ), -- Step 2: Propagate blockage to all descendants via parent-child diff --git a/internal/storage/sqlite/compact.go b/internal/storage/sqlite/compact.go index acac2f04..9c1c2383 100644 --- a/internal/storage/sqlite/compact.go +++ b/internal/storage/sqlite/compact.go @@ -78,7 +78,7 @@ func (s *SQLiteStorage) GetTier1Candidates(ctx context.Context) ([]*CompactionCa COUNT(DISTINCT dt.dependent_id) as dependent_count FROM issues i LEFT JOIN dependent_tree dt ON i.id = dt.issue_id - AND dt.dependent_status IN ('open', 'in_progress', 'blocked') + AND dt.dependent_status IN ('open', 'in_progress', 'blocked', 'deferred') AND dt.depth <= ? WHERE i.status = 'closed' AND i.closed_at IS NOT NULL @@ -163,7 +163,7 @@ func (s *SQLiteStorage) GetTier2Candidates(ctx context.Context) ([]*CompactionCa JOIN issues dep ON d.issue_id = dep.id WHERE d.depends_on_id = i.id AND d.type = 'blocks' - AND dep.status IN ('open', 'in_progress', 'blocked') + AND dep.status IN ('open', 'in_progress', 'blocked', 'deferred') ) ORDER BY i.closed_at ASC ` diff --git a/internal/storage/sqlite/events.go b/internal/storage/sqlite/events.go index 7b7aaf24..1bdd11e8 100644 --- a/internal/storage/sqlite/events.go +++ b/internal/storage/sqlite/events.go @@ -112,16 +112,18 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e // Get counts (bd-nyt: exclude tombstones from TotalIssues, report separately) // (bd-6v2: also count pinned issues) + // (bd-4jr: also count deferred issues) err := s.db.QueryRowContext(ctx, ` SELECT COALESCE(SUM(CASE WHEN status != 'tombstone' THEN 1 ELSE 0 END), 0) as total, COALESCE(SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END), 0) as open, COALESCE(SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END), 0) as in_progress, COALESCE(SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END), 0) as closed, + COALESCE(SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END), 0) as deferred, COALESCE(SUM(CASE WHEN status = 'tombstone' THEN 1 ELSE 0 END), 0) as tombstone, COALESCE(SUM(CASE WHEN status = 'pinned' THEN 1 ELSE 0 END), 0) as pinned FROM issues - `).Scan(&stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues, &stats.TombstoneIssues, &stats.PinnedIssues) + `).Scan(&stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues, &stats.DeferredIssues, &stats.TombstoneIssues, &stats.PinnedIssues) if err != nil { return nil, fmt.Errorf("failed to get issue counts: %w", err) } @@ -132,9 +134,9 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e FROM issues i JOIN dependencies d ON i.id = d.issue_id JOIN issues blocker ON d.depends_on_id = blocker.id - WHERE i.status IN ('open', 'in_progress', 'blocked') + WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred') AND d.type = 'blocks' - AND blocker.status IN ('open', 'in_progress', 'blocked') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') `).Scan(&stats.BlockedIssues) if err != nil { return nil, fmt.Errorf("failed to get blocked count: %w", err) @@ -147,10 +149,10 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e WHERE i.status = 'open' AND NOT EXISTS ( SELECT 1 FROM dependencies d - JOIN issues blocked ON d.depends_on_id = blocked.id + JOIN issues blocker ON d.depends_on_id = blocker.id WHERE d.issue_id = i.id AND d.type = 'blocks' - AND blocked.status IN ('open', 'in_progress', 'blocked') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') ) `).Scan(&stats.ReadyIssues) if err != nil { diff --git a/internal/storage/sqlite/ready.go b/internal/storage/sqlite/ready.go index b0398c5c..914f6c3c 100644 --- a/internal/storage/sqlite/ready.go +++ b/internal/storage/sqlite/ready.go @@ -293,18 +293,19 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI AND EXISTS ( SELECT 1 FROM issues blocker WHERE blocker.id = d.depends_on_id - AND blocker.status IN ('open', 'in_progress', 'blocked') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') ) - WHERE i.status IN ('open', 'in_progress', 'blocked') + WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred') AND i.pinned = 0 AND ( i.status = 'blocked' + OR i.status = 'deferred' OR EXISTS ( SELECT 1 FROM dependencies d2 JOIN issues blocker ON d2.depends_on_id = blocker.id WHERE d2.issue_id = i.id AND d2.type = 'blocks' - AND blocker.status IN ('open', 'in_progress', 'blocked') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') ) ) GROUP BY i.id diff --git a/internal/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go index 7b533af6..7fc791d8 100644 --- a/internal/storage/sqlite/schema.go +++ b/internal/storage/sqlite/schema.go @@ -206,7 +206,7 @@ WITH RECURSIVE FROM dependencies d JOIN issues blocker ON d.depends_on_id = blocker.id WHERE d.type = 'blocks' - AND blocker.status IN ('open', 'in_progress', 'blocked') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') ), -- Propagate blockage to all descendants via parent-child blocked_transitively AS ( @@ -236,8 +236,8 @@ SELECT FROM issues i JOIN dependencies d ON i.id = d.issue_id JOIN issues blocker ON d.depends_on_id = blocker.id -WHERE i.status IN ('open', 'in_progress', 'blocked') +WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred') AND d.type = 'blocks' - AND blocker.status IN ('open', 'in_progress', 'blocked') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') GROUP BY i.id; ` diff --git a/internal/types/types.go b/internal/types/types.go index f2d4559b..3b8397bd 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -219,6 +219,7 @@ const ( StatusOpen Status = "open" StatusInProgress Status = "in_progress" StatusBlocked Status = "blocked" + StatusDeferred Status = "deferred" // Deliberately put on ice for later (bd-4jr) StatusClosed Status = "closed" StatusTombstone Status = "tombstone" // Soft-deleted issue (bd-vw8) StatusPinned Status = "pinned" // Persistent bead that stays open indefinitely (bd-6v2) @@ -227,7 +228,7 @@ const ( // IsValid checks if the status value is valid (built-in statuses only) func (s Status) IsValid() bool { switch s { - case StatusOpen, StatusInProgress, StatusBlocked, StatusClosed, StatusTombstone, StatusPinned: + case StatusOpen, StatusInProgress, StatusBlocked, StatusDeferred, StatusClosed, StatusTombstone, StatusPinned: return true } return false @@ -425,6 +426,7 @@ type Statistics struct { InProgressIssues int `json:"in_progress_issues"` ClosedIssues int `json:"closed_issues"` BlockedIssues int `json:"blocked_issues"` + DeferredIssues int `json:"deferred_issues"` // Issues on ice (bd-4jr) ReadyIssues int `json:"ready_issues"` TombstoneIssues int `json:"tombstone_issues"` // Soft-deleted issues (bd-nyt) PinnedIssues int `json:"pinned_issues"` // Persistent issues (bd-6v2)