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:
1
beads.go
1
beads.go
@@ -77,6 +77,7 @@ const (
|
||||
StatusOpen = types.StatusOpen
|
||||
StatusInProgress = types.StatusInProgress
|
||||
StatusBlocked = types.StatusBlocked
|
||||
StatusDeferred = types.StatusDeferred
|
||||
StatusClosed = types.StatusClosed
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)")
|
||||
|
||||
142
cmd/bd/defer.go
Normal file
142
cmd/bd/defer.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
138
cmd/bd/undefer.go
Normal file
138
cmd/bd/undefer.go
Normal 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
|
||||
|
||||
# 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"]
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user