fix: prevent closing issues with open blockers (GH#962)

Added IsBlocked method to Storage interface that checks if an issue is
in the blocked_issues_cache and returns the blocking issue IDs.

The close command now checks for blockers before allowing an issue to
be closed:
- If an issue has open blockers, closing is blocked with an error message
- The --force flag overrides this check
- Works in both daemon mode (RPC) and direct storage mode
- Also handles cross-rig routed IDs

This addresses the bug where agents could close a bead even when it
depends on an open bug/issue.

Closes #962

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
fang
2026-01-09 22:56:56 -08:00
committed by Steve Yegge
parent 0933bf5eda
commit a851104203
8 changed files with 175 additions and 0 deletions

View File

@@ -680,6 +680,52 @@ func filterBlockedByExternalDeps(ctx context.Context, blocked []*types.BlockedIs
return result
}
// IsBlocked checks if an issue is blocked by open dependencies (GH#962).
// Returns true if the issue is in the blocked_issues_cache, along with a list
// of issue IDs that are blocking it.
// This is used to prevent closing issues that still have open blockers.
func (s *SQLiteStorage) IsBlocked(ctx context.Context, issueID string) (bool, []string, error) {
// First check if the issue is in the blocked cache
var inCache bool
err := s.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM blocked_issues_cache WHERE issue_id = ?)
`, issueID).Scan(&inCache)
if err != nil {
return false, nil, fmt.Errorf("failed to check blocked status: %w", err)
}
if !inCache {
return false, nil, nil
}
// Get the blocking issue IDs
// We query dependencies for 'blocks' type where the blocker is still open
rows, err := s.db.QueryContext(ctx, `
SELECT d.depends_on_id
FROM dependencies d
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE d.issue_id = ?
AND d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
ORDER BY blocker.priority ASC
`, issueID)
if err != nil {
return true, nil, fmt.Errorf("failed to get blockers: %w", err)
}
defer func() { _ = rows.Close() }()
var blockers []string
for rows.Next() {
var blockerID string
if err := rows.Scan(&blockerID); err != nil {
return true, nil, fmt.Errorf("failed to scan blocker ID: %w", err)
}
blockers = append(blockers, blockerID)
}
return true, blockers, rows.Err()
}
// 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:

View File

@@ -1855,3 +1855,53 @@ func TestParentIDEmptyParent(t *testing.T) {
t.Fatalf("Expected 0 ready issues for empty parent, got %d", len(ready))
}
}
// TestIsBlocked tests the IsBlocked method (GH#962)
func TestIsBlocked(t *testing.T) {
env := newTestEnv(t)
ctx := context.Background()
// Create issues:
// issue1: open, no dependencies → NOT BLOCKED
// issue2: open, depends on issue1 (open) → BLOCKED by issue1
// issue3: open, depends on issue4 (closed) → NOT BLOCKED (blocker is closed)
issue1 := env.CreateIssue("Open No Deps")
issue2 := env.CreateIssue("Blocked by open")
issue3 := env.CreateIssue("Blocked by closed")
issue4 := env.CreateIssue("Will be closed")
env.AddDep(issue2, issue1) // issue2 depends on issue1 (open)
env.AddDep(issue3, issue4) // issue3 depends on issue4
env.Close(issue4, "Done") // Close issue4
// Test issue1: not blocked
blocked, blockers, err := env.Store.IsBlocked(ctx, issue1.ID)
if err != nil {
t.Fatalf("IsBlocked failed: %v", err)
}
if blocked {
t.Errorf("Expected issue1 to NOT be blocked, got blocked=true with blockers=%v", blockers)
}
// Test issue2: blocked by issue1
blocked, blockers, err = env.Store.IsBlocked(ctx, issue2.ID)
if err != nil {
t.Fatalf("IsBlocked failed: %v", err)
}
if !blocked {
t.Error("Expected issue2 to be blocked")
}
if len(blockers) != 1 || blockers[0] != issue1.ID {
t.Errorf("Expected blockers=[%s], got %v", issue1.ID, blockers)
}
// Test issue3: not blocked (issue4 is closed)
blocked, blockers, err = env.Store.IsBlocked(ctx, issue3.ID)
if err != nil {
t.Fatalf("IsBlocked failed: %v", err)
}
if blocked {
t.Errorf("Expected issue3 to NOT be blocked (blocker is closed), got blocked=true with blockers=%v", blockers)
}
}