feat: add cross-project dependency support - config and external: prefix (bd-66w1, bd-om4a)

Config (bd-66w1):
- Add external_projects config for mapping project names to paths
- Add GetExternalProjects() and ResolveExternalProjectPath() functions
- Add config documentation and tests

External deps (bd-om4a):
- bd dep add accepts external:project:capability syntax
- External refs stored as-is in dependencies table
- GetBlockedIssues includes external deps in blocked_by list
- blocked_issues_cache includes external dependencies
- Add validation and parsing helpers for external refs

🤖 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-21 23:08:00 -08:00
parent fe6fec437a
commit e7f09660c0
7 changed files with 430 additions and 21 deletions

View File

@@ -11,6 +11,7 @@
// The blocked_issues_cache table stores issue_id values for all issues that are currently
// blocked. An issue is blocked if:
// - It has a 'blocks' dependency on an open/in_progress/blocked issue (direct blocking)
// - It has a 'blocks' dependency on an external:* reference (cross-project blocking, bd-om4a)
// - Its parent is blocked and it's connected via 'parent-child' dependency (transitive blocking)
//
// The cache is maintained automatically by invalidating and rebuilding whenever:
@@ -112,16 +113,27 @@ func (s *SQLiteStorage) rebuildBlockedCache(ctx context.Context, exec execer) er
}
// Rebuild using the recursive CTE logic
// Includes both local blockers (open issues) and external refs (bd-om4a)
query := `
INSERT INTO blocked_issues_cache (issue_id)
WITH RECURSIVE
-- Step 1: Find issues blocked directly by dependencies
-- Includes both local blockers (open issues) and external references
blocked_directly AS (
-- Local blockers: issues with open status
SELECT DISTINCT d.issue_id
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', 'deferred')
UNION
-- External blockers: always blocking until resolved (bd-om4a)
SELECT DISTINCT d.issue_id
FROM dependencies d
WHERE d.type = 'blocks'
AND d.depends_on_id LIKE 'external:%'
),
-- Step 2: Propagate blockage to all descendants via parent-child

View File

@@ -274,12 +274,17 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
// GetBlockedIssues returns issues that are blocked by dependencies or have status=blocked
// Note: Pinned issues are excluded from the output (beads-ei4)
// Note: Includes external: references in blocked_by list (bd-om4a)
func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error) {
// Use UNION to combine:
// 1. Issues with open/in_progress/blocked status that have dependency blockers
// 2. Issues with status=blocked (even if they have no dependency blockers)
// Use GROUP_CONCAT to get all blocker IDs in a single query (no N+1)
// Exclude pinned issues (beads-ei4)
//
// For blocked_by_count and blocker_ids:
// - Count local blockers (open issues) + external refs (external:*)
// - External refs are always considered "open" until resolved (bd-om4a)
rows, err := s.db.QueryContext(ctx, `
SELECT
i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
@@ -290,16 +295,22 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
FROM issues i
LEFT JOIN dependencies d ON i.id = d.issue_id
AND d.type = 'blocks'
AND EXISTS (
SELECT 1 FROM issues blocker
WHERE blocker.id = d.depends_on_id
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
AND (
-- Local blockers: must be open/in_progress/blocked/deferred
EXISTS (
SELECT 1 FROM issues blocker
WHERE blocker.id = d.depends_on_id
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
)
-- External refs: always included (resolution happens at query time)
OR d.depends_on_id LIKE 'external:%'
)
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred')
AND i.pinned = 0
AND (
i.status = 'blocked'
OR i.status = 'deferred'
-- Has local open blockers
OR EXISTS (
SELECT 1 FROM dependencies d2
JOIN issues blocker ON d2.depends_on_id = blocker.id
@@ -307,6 +318,13 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
AND d2.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
)
-- Has external blockers (always considered blocking until resolved)
OR EXISTS (
SELECT 1 FROM dependencies d3
WHERE d3.issue_id = i.id
AND d3.type = 'blocks'
AND d3.depends_on_id LIKE 'external:%'
)
)
GROUP BY i.id
ORDER BY i.priority ASC