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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user