feat(close): Add --suggest-next flag to show newly unblocked issues (GH#679)

When closing an issue, the new --suggest-next flag returns a list of
issues that became unblocked (ready to work on) as a result of the close.

This helps agents and users quickly identify what work is now available
after completing a blocker.

Example:
  $ bd close bd-5 --suggest-next
  ✓ Closed bd-5: Completed

  Newly unblocked:
    • bd-7 "Implement feature X" (P1)
    • bd-8 "Write tests for X" (P2)

Implementation:
- Added GetNewlyUnblockedByClose to storage interface
- Implemented efficient single-query for SQLite using blocked_issues_cache
- Added SuggestNext field to CloseArgs in RPC protocol
- Added CloseResult type for structured response
- CLI handles both daemon and direct modes

Thanks to @kraitsura for the detailed feature request and design.

🤖 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-25 20:05:04 -08:00
parent 35ab0d7a7f
commit f3a5e02a35
16 changed files with 306 additions and 140 deletions
+9 -13
View File
@@ -249,20 +249,16 @@ type LoopSpec struct {
// OnCompleteSpec defines actions triggered when a step completes (gt-8tmz.8).
// Used for runtime expansion over step output (the for-each construct).
//
// Example JSON:
// Example YAML:
//
// {
// "id": "survey-workers",
// "on_complete": {
// "for_each": "output.polecats",
// "bond": "mol-polecat-arm",
// "vars": {
// "polecat_name": "{item.name}",
// "rig": "{item.rig}"
// },
// "parallel": true
// }
// }
// step: survey-workers
// on_complete:
// for_each: output.polecats
// bond: mol-polecat-arm
// vars:
// polecat_name: "{item.name}"
// rig: "{item.rig}"
// parallel: true
type OnCompleteSpec struct {
// ForEach is the path to the iterable collection in step output.
// Format: "output.<field>" or "output.<field>.<nested>"
+12 -2
View File
@@ -3,6 +3,8 @@ package rpc
import (
"encoding/json"
"time"
"github.com/steveyegge/beads/internal/types"
)
// Operation constants for all bd commands
@@ -124,8 +126,16 @@ type UpdateArgs struct {
// CloseArgs represents arguments for the close operation
type CloseArgs struct {
ID string `json:"id"`
Reason string `json:"reason,omitempty"`
ID string `json:"id"`
Reason string `json:"reason,omitempty"`
SuggestNext bool `json:"suggest_next,omitempty"` // Return newly unblocked issues (GH#679)
}
// CloseResult is returned when SuggestNext is true (GH#679)
// When SuggestNext is false, just the closed issue is returned for backward compatibility
type CloseResult struct {
Closed *types.Issue `json:"closed"` // The issue that was closed
Unblocked []*types.Issue `json:"unblocked,omitempty"` // Issues newly unblocked by closing
}
// DeleteArgs represents arguments for the delete operation
+20
View File
@@ -555,6 +555,26 @@ func (s *Server) handleClose(req *Request) Response {
})
closedIssue, _ := store.GetIssue(ctx, closeArgs.ID)
// If SuggestNext is requested, find newly unblocked issues (GH#679)
if closeArgs.SuggestNext {
unblocked, err := store.GetNewlyUnblockedByClose(ctx, closeArgs.ID)
if err != nil {
// Non-fatal: still return the closed issue
unblocked = nil
}
result := CloseResult{
Closed: closedIssue,
Unblocked: unblocked,
}
data, _ := json.Marshal(result)
return Response{
Success: true,
Data: data,
}
}
// Backward compatible: just return the closed issue
data, _ := json.Marshal(closedIssue)
return Response{
Success: true,
+52
View File
@@ -1184,6 +1184,58 @@ func (m *MemoryStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
return stale, nil
}
// GetNewlyUnblockedByClose returns issues that became unblocked when the given issue was closed.
// This is used by the --suggest-next flag on bd close (GH#679).
func (m *MemoryStorage) GetNewlyUnblockedByClose(ctx context.Context, closedIssueID string) ([]*types.Issue, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var unblocked []*types.Issue
// Find issues that depend on the closed issue
for issueID, deps := range m.dependencies {
issue, exists := m.issues[issueID]
if !exists {
continue
}
// Only consider open/in_progress, non-pinned issues
if issue.Status != types.StatusOpen && issue.Status != types.StatusInProgress {
continue
}
if issue.Pinned {
continue
}
// Check if this issue depended on the closed issue
dependedOnClosed := false
for _, dep := range deps {
if dep.DependsOnID == closedIssueID && dep.Type == types.DepBlocks {
dependedOnClosed = true
break
}
}
if !dependedOnClosed {
continue
}
// Check if now unblocked (no remaining open blockers)
blockers := m.getOpenBlockers(issueID)
if len(blockers) == 0 {
issueCopy := *issue
unblocked = append(unblocked, &issueCopy)
}
}
// Sort by priority ascending
sort.Slice(unblocked, func(i, j int) bool {
return unblocked[i].Priority < unblocked[j].Priority
})
return unblocked, nil
}
func (m *MemoryStorage) AddComment(ctx context.Context, issueID, actor, comment string) error {
return nil
}
+43
View File
@@ -596,6 +596,49 @@ func filterBlockedByExternalDeps(ctx context.Context, blocked []*types.BlockedIs
return result
}
// GetNewlyUnblockedByClose returns issues that became unblocked when the given issue was closed.
// This is used by the --suggest-next flag on bd close to show what work is now available.
// An issue is "newly unblocked" if:
// - It had a 'blocks' dependency on the closed issue
// - It is now unblocked (not in blocked_issues_cache)
// - It has status open or in_progress (ready to work on)
//
// The cache is already rebuilt by CloseIssue before this is called, so we just need to
// find dependents that are no longer blocked.
func (s *SQLiteStorage) GetNewlyUnblockedByClose(ctx context.Context, closedIssueID string) ([]*types.Issue, error) {
// Find issues that:
// 1. Had a 'blocks' dependency on the closed issue
// 2. Are now NOT in blocked_issues_cache (unblocked)
// 3. Have status open or in_progress
// 4. Are not pinned
query := `
SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.pinned, i.is_template,
i.await_type, i.await_id, i.timeout_ns, i.waiters
FROM issues i
JOIN dependencies d ON i.id = d.issue_id
WHERE d.depends_on_id = ?
AND d.type = 'blocks'
AND i.status IN ('open', 'in_progress')
AND i.pinned = 0
AND NOT EXISTS (
SELECT 1 FROM blocked_issues_cache WHERE issue_id = i.id
)
ORDER BY i.priority ASC
`
rows, err := s.db.QueryContext(ctx, query, closedIssueID)
if err != nil {
return nil, fmt.Errorf("failed to get newly unblocked issues: %w", err)
}
defer func() { _ = rows.Close() }()
return s.scanIssues(ctx, rows)
}
// buildOrderByClause generates the ORDER BY clause based on sort policy
func buildOrderByClause(policy types.SortPolicy) string {
switch policy {
+53
View File
@@ -1512,3 +1512,56 @@ func TestCheckExternalDepInvalidFormats(t *testing.T) {
})
}
}
// TestGetNewlyUnblockedByClose tests the --suggest-next functionality (GH#679)
func TestGetNewlyUnblockedByClose(t *testing.T) {
env := newTestEnv(t)
// Create a blocker issue
blocker := env.CreateIssueWith("Blocker", types.StatusOpen, 1, types.TypeTask)
// Create two issues blocked by the blocker
blocked1 := env.CreateIssueWith("Blocked 1", types.StatusOpen, 2, types.TypeTask)
blocked2 := env.CreateIssueWith("Blocked 2", types.StatusOpen, 3, types.TypeTask)
// Create one issue blocked by multiple issues (blocker + another)
otherBlocker := env.CreateIssueWith("Other Blocker", types.StatusOpen, 1, types.TypeTask)
multiBlocked := env.CreateIssueWith("Multi Blocked", types.StatusOpen, 2, types.TypeTask)
// Add dependencies (issue depends on blocker)
env.AddDep(blocked1, blocker)
env.AddDep(blocked2, blocker)
env.AddDep(multiBlocked, blocker)
env.AddDep(multiBlocked, otherBlocker)
// Close the blocker
env.Close(blocker, "Done")
// Get newly unblocked issues
ctx := context.Background()
unblocked, err := env.Store.GetNewlyUnblockedByClose(ctx, blocker.ID)
if err != nil {
t.Fatalf("GetNewlyUnblockedByClose failed: %v", err)
}
// Should return blocked1 and blocked2 (but not multiBlocked, which is still blocked by otherBlocker)
if len(unblocked) != 2 {
t.Errorf("Expected 2 unblocked issues, got %d", len(unblocked))
}
// Check that the right issues are unblocked
unblockedIDs := make(map[string]bool)
for _, issue := range unblocked {
unblockedIDs[issue.ID] = true
}
if !unblockedIDs[blocked1.ID] {
t.Errorf("Expected %s to be unblocked", blocked1.ID)
}
if !unblockedIDs[blocked2.ID] {
t.Errorf("Expected %s to be unblocked", blocked2.ID)
}
if unblockedIDs[multiBlocked.ID] {
t.Errorf("Expected %s to still be blocked (has another blocker)", multiBlocked.ID)
}
}
+1
View File
@@ -110,6 +110,7 @@ type Storage interface {
GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error)
GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error)
GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error)
GetNewlyUnblockedByClose(ctx context.Context, closedIssueID string) ([]*types.Issue, error) // GH#679
// Events
AddComment(ctx context.Context, issueID, actor, comment string) error
+3
View File
@@ -98,6 +98,9 @@ func (m *mockStorage) GetEpicsEligibleForClosure(ctx context.Context) ([]*types.
func (m *mockStorage) GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error) {
return nil, nil
}
func (m *mockStorage) GetNewlyUnblockedByClose(ctx context.Context, closedIssueID string) ([]*types.Issue, error) {
return nil, nil
}
func (m *mockStorage) AddComment(ctx context.Context, issueID, actor, comment string) error {
return nil
}