feat(status): add deferred status for icebox issues (bd-4jr)

Add 'deferred' as a valid issue status for issues that are deliberately
put on ice - not blocked by dependencies, just postponed for later.

Changes:
- Add StatusDeferred constant and update IsValid() validation
- Add DeferredIssues to Statistics struct with counting in both SQLite
  and memory storage
- Add 'bd defer' command to set status to deferred
- Add 'bd undefer' command to restore status to open
- Update help text across list, search, count, dep, stale, and config
- Update MCP server models and tools to accept deferred status
- Add deferred to blocker status checks (schema, cache, ready, compact)
- Add StatusDeferred to public API exports (beads.go, internal/beads)
- Add snowflake styling for deferred in dep tree and graph views

Semantics:
- deferred vs blocked: deferred is a choice, blocked is forced
- deferred vs closed: deferred will be revisited, closed is done
- Deferred issues excluded from 'bd ready' (already works since
  default filter only includes open/in_progress)
- Deferred issues still block dependents (they are not done!)
- Deferred issues visible in 'bd list' and 'bd stale'

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-20 14:13:57 -08:00
parent 844e9ffc02
commit e778b3f648
22 changed files with 335 additions and 33 deletions
+1
View File
@@ -77,6 +77,7 @@ const (
StatusOpen = types.StatusOpen StatusOpen = types.StatusOpen
StatusInProgress = types.StatusInProgress StatusInProgress = types.StatusInProgress
StatusBlocked = types.StatusBlocked StatusBlocked = types.StatusBlocked
StatusDeferred = types.StatusDeferred
StatusClosed = types.StatusClosed StatusClosed = types.StatusClosed
) )
+1 -1
View File
@@ -32,7 +32,7 @@ Custom Status States:
bd config set status.custom "awaiting_review,awaiting_testing,awaiting_docs" bd config set status.custom "awaiting_review,awaiting_testing,awaiting_docs"
This enables issues to use statuses like 'awaiting_review' in addition to 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: Examples:
bd config set jira.url "https://company.atlassian.net" bd config set jira.url "https://company.atlassian.net"
+1 -1
View File
@@ -420,7 +420,7 @@ Examples:
func init() { func init() {
// Filter flags (same as list command) // 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().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("assignee", "a", "", "Filter by assignee")
countCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)") countCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
+142
View File
@@ -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)
}
+3 -1
View File
@@ -497,6 +497,8 @@ func getStatusEmoji(status types.Status) string {
return "◧" // U+25E7 Square Left Half Black return "◧" // U+25E7 Square Left Half Black
case types.StatusBlocked: case types.StatusBlocked:
return "⚠" // U+26A0 Warning Sign return "⚠" // U+26A0 Warning Sign
case types.StatusDeferred:
return "❄" // U+2744 Snowflake (on ice)
case types.StatusClosed: case types.StatusClosed:
return "☑" // U+2611 Ballot Box with Check return "☑" // U+2611 Ballot Box with Check
default: default:
@@ -746,7 +748,7 @@ func init() {
depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)") 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().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("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") 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 // Note: --json flag is defined as a persistent flag in main.go, not here
+6
View File
@@ -382,6 +382,9 @@ func renderNodeBox(node *GraphNode, width int) string {
case types.StatusBlocked: case types.StatusBlocked:
statusIcon = "●" statusIcon = "●"
colorFn = color.New(color.FgRed).SprintFunc() colorFn = color.New(color.FgRed).SprintFunc()
case types.StatusDeferred:
statusIcon = "❄"
colorFn = color.New(color.FgCyan).SprintFunc()
case types.StatusClosed: case types.StatusClosed:
statusIcon = "✓" statusIcon = "✓"
colorFn = color.New(color.FgGreen).SprintFunc() colorFn = color.New(color.FgGreen).SprintFunc()
@@ -458,6 +461,9 @@ func renderNodeBoxWithDeps(node *GraphNode, width int, blocksCount int, blockedB
case types.StatusBlocked: case types.StatusBlocked:
statusIcon = "●" statusIcon = "●"
colorFn = color.New(color.FgRed).SprintFunc() colorFn = color.New(color.FgRed).SprintFunc()
case types.StatusDeferred:
statusIcon = "❄"
colorFn = color.New(color.FgCyan).SprintFunc()
case types.StatusClosed: case types.StatusClosed:
statusIcon = "✓" statusIcon = "✓"
colorFn = color.New(color.FgGreen).SprintFunc() colorFn = color.New(color.FgGreen).SprintFunc()
+1 -1
View File
@@ -565,7 +565,7 @@ var listCmd = &cobra.Command{
} }
func init() { 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, "") registerPriorityFlag(listCmd, "")
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule)") listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule)")
+1 -1
View File
@@ -370,7 +370,7 @@ func outputSearchResults(issues []*types.Issue, query string, longFormat bool) {
func init() { func init() {
searchCmd.Flags().String("query", "", "Search query (alternative to positional argument)") 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("assignee", "a", "", "Filter by assignee")
searchCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)") 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)") searchCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)")
+3 -3
View File
@@ -23,8 +23,8 @@ This helps identify:
limit, _ := cmd.Flags().GetInt("limit") limit, _ := cmd.Flags().GetInt("limit")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
// Validate status if provided // Validate status if provided
if status != "" && status != "open" && status != "in_progress" && status != "blocked" { 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\n", status) fmt.Fprintf(os.Stderr, "Error: invalid status '%s'. Valid values: open, in_progress, blocked, deferred\n", status)
os.Exit(1) os.Exit(1)
} }
filter := types.StaleFilter{ filter := types.StaleFilter{
@@ -107,7 +107,7 @@ func displayStaleIssues(issues []*types.Issue, days int) {
} }
func init() { func init() {
staleCmd.Flags().IntP("days", "d", 30, "Issues not updated in this many days") 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().IntP("limit", "n", 50, "Maximum issues to show")
staleCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON format") staleCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON format")
rootCmd.AddCommand(staleCmd) rootCmd.AddCommand(staleCmd)
+2
View File
@@ -312,6 +312,8 @@ func getAssignedStatistics(assignee string) *types.Statistics {
stats.InProgressIssues++ stats.InProgressIssues++
case types.StatusBlocked: case types.StatusBlocked:
stats.BlockedIssues++ stats.BlockedIssues++
case types.StatusDeferred:
stats.DeferredIssues++
case types.StatusClosed: case types.StatusClosed:
stats.ClosedIssues++ stats.ClosedIssues++
} }
+138
View File
@@ -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)
}
@@ -6,7 +6,7 @@ from typing import Literal, Any
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
# Type aliases for issue statuses, types, and dependencies # 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"] IssueType = Literal["bug", "feature", "task", "epic", "chore"]
DependencyType = Literal["blocks", "related", "parent-child", "discovered-from"] DependencyType = Literal["blocks", "related", "parent-child", "discovered-from"]
@@ -372,7 +372,7 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]:
"name": "list", "name": "list",
"description": "List all issues with optional filters", "description": "List all issues with optional filters",
"parameters": { "parameters": {
"status": "open|in_progress|blocked|closed (optional)", "status": "open|in_progress|blocked|deferred|closed (optional)",
"priority": "int 0-4 (optional)", "priority": "int 0-4 (optional)",
"issue_type": "bug|feature|task|epic|chore (optional)", "issue_type": "bug|feature|task|epic|chore (optional)",
"assignee": "str (optional)", "assignee": "str (optional)",
@@ -413,7 +413,7 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]:
"description": "Update an existing issue", "description": "Update an existing issue",
"parameters": { "parameters": {
"issue_id": "str (required)", "issue_id": "str (required)",
"status": "open|in_progress|blocked|closed (optional)", "status": "open|in_progress|blocked|deferred|closed (optional)",
"priority": "int 0-4 (optional)", "priority": "int 0-4 (optional)",
"assignee": "str (optional)", "assignee": "str (optional)",
"title": "str (optional)", "title": "str (optional)",
@@ -317,7 +317,7 @@ async def beads_ready_work(
async def beads_list_issues( 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, 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, issue_type: Annotated[IssueType | None, "Filter by type (bug, feature, task, epic, chore)"] = None,
assignee: Annotated[str | None, "Filter by assignee"] = None, assignee: Annotated[str | None, "Filter by assignee"] = None,
@@ -392,7 +392,7 @@ async def beads_create_issue(
async def beads_update_issue( async def beads_update_issue(
issue_id: Annotated[str, "Issue ID (e.g., bd-1)"], 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, priority: Annotated[int | None, "New priority (0-4)"] = None,
assignee: Annotated[str | None, "New assignee"] = None, assignee: Annotated[str | None, "New assignee"] = None,
title: Annotated[str | None, "New title"] = None, title: Annotated[str | None, "New title"] = None,
+2 -1
View File
@@ -205,8 +205,9 @@ type (
const ( const (
StatusOpen = types.StatusOpen StatusOpen = types.StatusOpen
StatusInProgress = types.StatusInProgress StatusInProgress = types.StatusInProgress
StatusClosed = types.StatusClosed
StatusBlocked = types.StatusBlocked StatusBlocked = types.StatusBlocked
StatusDeferred = types.StatusDeferred
StatusClosed = types.StatusClosed
) )
// IssueType constants // IssueType constants
+9 -4
View File
@@ -1033,7 +1033,7 @@ func (m *MemoryStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
return results, nil 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. // The caller must hold at least a read lock.
func (m *MemoryStorage) getOpenBlockers(issueID string) []string { func (m *MemoryStorage) getOpenBlockers(issueID string) []string {
deps := m.dependencies[issueID] deps := m.dependencies[issueID]
@@ -1053,7 +1053,7 @@ func (m *MemoryStorage) getOpenBlockers(issueID string) []string {
continue continue
} }
switch blocker.Status { switch blocker.Status {
case types.StatusOpen, types.StatusInProgress, types.StatusBlocked: case types.StatusOpen, types.StatusInProgress, types.StatusBlocked, types.StatusDeferred:
blockers = append(blockers, blocker.ID) blockers = append(blockers, blocker.ID)
} }
} }
@@ -1082,7 +1082,8 @@ func (m *MemoryStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
} }
blockers := m.getOpenBlockers(issue.ID) 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 continue
} }
@@ -1219,13 +1220,17 @@ func (m *MemoryStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
stats.InProgressIssues++ stats.InProgressIssues++
case types.StatusClosed: case types.StatusClosed:
stats.ClosedIssues++ stats.ClosedIssues++
case types.StatusDeferred:
stats.DeferredIssues++
case types.StatusTombstone: case types.StatusTombstone:
stats.TombstoneIssues++ stats.TombstoneIssues++
case types.StatusPinned:
stats.PinnedIssues++
} }
} }
// TotalIssues excludes tombstones (matches SQLite behavior) // 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 // Second pass: calculate blocked and ready issues based on dependencies
// An issue is blocked if it has open blockers (uses same logic as GetBlockedIssues) // An issue is blocked if it has open blockers (uses same logic as GetBlockedIssues)
+1 -1
View File
@@ -121,7 +121,7 @@ func (s *SQLiteStorage) rebuildBlockedCache(ctx context.Context, exec execer) er
FROM dependencies d FROM dependencies d
JOIN issues blocker ON d.depends_on_id = blocker.id JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE d.type = 'blocks' 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 -- Step 2: Propagate blockage to all descendants via parent-child
+2 -2
View File
@@ -78,7 +78,7 @@ func (s *SQLiteStorage) GetTier1Candidates(ctx context.Context) ([]*CompactionCa
COUNT(DISTINCT dt.dependent_id) as dependent_count COUNT(DISTINCT dt.dependent_id) as dependent_count
FROM issues i FROM issues i
LEFT JOIN dependent_tree dt ON i.id = dt.issue_id 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 <= ? AND dt.depth <= ?
WHERE i.status = 'closed' WHERE i.status = 'closed'
AND i.closed_at IS NOT NULL 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 JOIN issues dep ON d.issue_id = dep.id
WHERE d.depends_on_id = i.id WHERE d.depends_on_id = i.id
AND d.type = 'blocks' 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 ORDER BY i.closed_at ASC
` `
+7 -5
View File
@@ -112,16 +112,18 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
// Get counts (bd-nyt: exclude tombstones from TotalIssues, report separately) // Get counts (bd-nyt: exclude tombstones from TotalIssues, report separately)
// (bd-6v2: also count pinned issues) // (bd-6v2: also count pinned issues)
// (bd-4jr: also count deferred issues)
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
SELECT SELECT
COALESCE(SUM(CASE WHEN status != 'tombstone' THEN 1 ELSE 0 END), 0) as total, 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 = '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 = '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 = '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 = 'tombstone' THEN 1 ELSE 0 END), 0) as tombstone,
COALESCE(SUM(CASE WHEN status = 'pinned' THEN 1 ELSE 0 END), 0) as pinned COALESCE(SUM(CASE WHEN status = 'pinned' THEN 1 ELSE 0 END), 0) as pinned
FROM issues 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 { if err != nil {
return nil, fmt.Errorf("failed to get issue counts: %w", err) 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 FROM issues i
JOIN dependencies d ON i.id = d.issue_id JOIN dependencies d ON i.id = d.issue_id
JOIN issues blocker ON d.depends_on_id = blocker.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 d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked') AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
`).Scan(&stats.BlockedIssues) `).Scan(&stats.BlockedIssues)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get blocked count: %w", err) 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' WHERE i.status = 'open'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM dependencies d 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 WHERE d.issue_id = i.id
AND d.type = 'blocks' AND d.type = 'blocks'
AND blocked.status IN ('open', 'in_progress', 'blocked') AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
) )
`).Scan(&stats.ReadyIssues) `).Scan(&stats.ReadyIssues)
if err != nil { if err != nil {
+4 -3
View File
@@ -293,18 +293,19 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
AND EXISTS ( AND EXISTS (
SELECT 1 FROM issues blocker SELECT 1 FROM issues blocker
WHERE blocker.id = d.depends_on_id 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.pinned = 0
AND ( AND (
i.status = 'blocked' i.status = 'blocked'
OR i.status = 'deferred'
OR EXISTS ( OR EXISTS (
SELECT 1 FROM dependencies d2 SELECT 1 FROM dependencies d2
JOIN issues blocker ON d2.depends_on_id = blocker.id JOIN issues blocker ON d2.depends_on_id = blocker.id
WHERE d2.issue_id = i.id WHERE d2.issue_id = i.id
AND d2.type = 'blocks' 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 GROUP BY i.id
+3 -3
View File
@@ -206,7 +206,7 @@ WITH RECURSIVE
FROM dependencies d FROM dependencies d
JOIN issues blocker ON d.depends_on_id = blocker.id JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE d.type = 'blocks' 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 -- Propagate blockage to all descendants via parent-child
blocked_transitively AS ( blocked_transitively AS (
@@ -236,8 +236,8 @@ SELECT
FROM issues i FROM issues i
JOIN dependencies d ON i.id = d.issue_id JOIN dependencies d ON i.id = d.issue_id
JOIN issues blocker ON d.depends_on_id = blocker.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 d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked') AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
GROUP BY i.id; GROUP BY i.id;
` `
+3 -1
View File
@@ -219,6 +219,7 @@ const (
StatusOpen Status = "open" StatusOpen Status = "open"
StatusInProgress Status = "in_progress" StatusInProgress Status = "in_progress"
StatusBlocked Status = "blocked" StatusBlocked Status = "blocked"
StatusDeferred Status = "deferred" // Deliberately put on ice for later (bd-4jr)
StatusClosed Status = "closed" StatusClosed Status = "closed"
StatusTombstone Status = "tombstone" // Soft-deleted issue (bd-vw8) StatusTombstone Status = "tombstone" // Soft-deleted issue (bd-vw8)
StatusPinned Status = "pinned" // Persistent bead that stays open indefinitely (bd-6v2) 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) // IsValid checks if the status value is valid (built-in statuses only)
func (s Status) IsValid() bool { func (s Status) IsValid() bool {
switch s { switch s {
case StatusOpen, StatusInProgress, StatusBlocked, StatusClosed, StatusTombstone, StatusPinned: case StatusOpen, StatusInProgress, StatusBlocked, StatusDeferred, StatusClosed, StatusTombstone, StatusPinned:
return true return true
} }
return false return false
@@ -425,6 +426,7 @@ type Statistics struct {
InProgressIssues int `json:"in_progress_issues"` InProgressIssues int `json:"in_progress_issues"`
ClosedIssues int `json:"closed_issues"` ClosedIssues int `json:"closed_issues"`
BlockedIssues int `json:"blocked_issues"` BlockedIssues int `json:"blocked_issues"`
DeferredIssues int `json:"deferred_issues"` // Issues on ice (bd-4jr)
ReadyIssues int `json:"ready_issues"` ReadyIssues int `json:"ready_issues"`
TombstoneIssues int `json:"tombstone_issues"` // Soft-deleted issues (bd-nyt) TombstoneIssues int `json:"tombstone_issues"` // Soft-deleted issues (bd-nyt)
PinnedIssues int `json:"pinned_issues"` // Persistent issues (bd-6v2) PinnedIssues int `json:"pinned_issues"` // Persistent issues (bd-6v2)