feat: bd ready filters by external dep satisfaction (bd-zmmy)

GetReadyWork now lazily resolves external dependencies at query time:
- External refs (external:project:capability) checked against target DB
- Issues with unsatisfied external deps are filtered from ready list
- Satisfaction = closed issue with provides:<capability> label in target

Key changes:
- Remove FK constraint on depends_on_id to allow external refs
- Add migration 025 to drop FK and recreate views
- Filter external deps in GetReadyWork, not in blocked_issues_cache
- Add application-level validation for orphaned local deps
- Comprehensive tests for external dep resolution

🤖 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:41:44 -08:00
parent a9bfce7f6e
commit 1cfb23487b
9 changed files with 633 additions and 64 deletions

View File

@@ -600,7 +600,7 @@ func (t *sqliteTxStorage) AddDependency(ctx context.Context, dep *types.Dependen
return fmt.Errorf("invalid dependency type: %q (must be non-empty string, max 50 chars)", dep.Type)
}
// Validate that both issues exist
// Validate that source issue exists
issueExists, err := t.GetIssue(ctx, dep.IssueID)
if err != nil {
return fmt.Errorf("failed to check issue %s: %w", dep.IssueID, err)
@@ -609,24 +609,31 @@ func (t *sqliteTxStorage) AddDependency(ctx context.Context, dep *types.Dependen
return fmt.Errorf("issue %s not found", dep.IssueID)
}
dependsOnExists, err := t.GetIssue(ctx, dep.DependsOnID)
if err != nil {
return fmt.Errorf("failed to check dependency %s: %w", dep.DependsOnID, err)
}
if dependsOnExists == nil {
return fmt.Errorf("dependency target %s not found", dep.DependsOnID)
}
// External refs (external:<project>:<capability>) don't need target validation (bd-zmmy)
// They are resolved lazily at query time by CheckExternalDep
isExternalRef := strings.HasPrefix(dep.DependsOnID, "external:")
// Prevent self-dependency
if dep.IssueID == dep.DependsOnID {
return fmt.Errorf("issue cannot depend on itself")
}
var dependsOnExists *types.Issue
if !isExternalRef {
dependsOnExists, err = t.GetIssue(ctx, dep.DependsOnID)
if err != nil {
return fmt.Errorf("failed to check dependency %s: %w", dep.DependsOnID, err)
}
if dependsOnExists == nil {
return fmt.Errorf("dependency target %s not found", dep.DependsOnID)
}
// Validate parent-child dependency direction
if dep.Type == types.DepParentChild {
if issueExists.IssueType == types.TypeEpic && dependsOnExists.IssueType != types.TypeEpic {
return fmt.Errorf("invalid parent-child dependency: parent (%s) cannot depend on child (%s). Use: bd dep add %s %s --type parent-child",
dep.IssueID, dep.DependsOnID, dep.DependsOnID, dep.IssueID)
// Prevent self-dependency (only for local deps)
if dep.IssueID == dep.DependsOnID {
return fmt.Errorf("issue cannot depend on itself")
}
// Validate parent-child dependency direction (only for local deps)
if dep.Type == types.DepParentChild {
if issueExists.IssueType == types.TypeEpic && dependsOnExists.IssueType != types.TypeEpic {
return fmt.Errorf("invalid parent-child dependency: parent (%s) cannot depend on child (%s). Use: bd dep add %s %s --type parent-child",
dep.IssueID, dep.DependsOnID, dep.DependsOnID, dep.IssueID)
}
}
}
@@ -695,12 +702,14 @@ func (t *sqliteTxStorage) AddDependency(ctx context.Context, dep *types.Dependen
return fmt.Errorf("failed to record event: %w", err)
}
// Mark both issues as dirty
// Mark issues as dirty - for external refs, only mark the source issue
if err := markDirty(ctx, t.conn, dep.IssueID); err != nil {
return fmt.Errorf("failed to mark issue dirty: %w", err)
}
if err := markDirty(ctx, t.conn, dep.DependsOnID); err != nil {
return fmt.Errorf("failed to mark depends-on issue dirty: %w", err)
if !isExternalRef {
if err := markDirty(ctx, t.conn, dep.DependsOnID); err != nil {
return fmt.Errorf("failed to mark depends-on issue dirty: %w", err)
}
}
// Invalidate blocked cache for blocking dependencies (bd-1c4h)