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:
154
internal/storage/sqlite/migrations/025_remove_depends_on_fk.go
Normal file
154
internal/storage/sqlite/migrations/025_remove_depends_on_fk.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// MigrateRemoveDependsOnFK removes the FOREIGN KEY constraint on depends_on_id
|
||||
// to allow external dependencies (external:<project>:<capability>).
|
||||
// See bd-zmmy for design context.
|
||||
func MigrateRemoveDependsOnFK(db *sql.DB) error {
|
||||
// Disable foreign keys for table recreation
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _, _ = db.Exec(`PRAGMA foreign_keys = ON`) }()
|
||||
|
||||
// Begin transaction for atomic table recreation
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Drop views that depend on the dependencies table
|
||||
// They will be recreated after the table is rebuilt
|
||||
if _, err = tx.Exec(`DROP VIEW IF EXISTS ready_issues`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(`DROP VIEW IF EXISTS blocked_issues`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create new table without FK on depends_on_id
|
||||
// Keep FK on issue_id (source must exist)
|
||||
// Remove FK on depends_on_id (target can be external ref)
|
||||
if _, err = tx.Exec(`
|
||||
CREATE TABLE dependencies_new (
|
||||
issue_id TEXT NOT NULL,
|
||||
depends_on_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'blocks',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
thread_id TEXT,
|
||||
PRIMARY KEY (issue_id, depends_on_id, type),
|
||||
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
|
||||
)
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy data from old table
|
||||
if _, err = tx.Exec(`
|
||||
INSERT INTO dependencies_new
|
||||
SELECT issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id
|
||||
FROM dependencies
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop old table
|
||||
if _, err = tx.Exec(`DROP TABLE dependencies`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Rename new table
|
||||
if _, err = tx.Exec(`ALTER TABLE dependencies_new RENAME TO dependencies`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Recreate indexes
|
||||
if _, err = tx.Exec(`
|
||||
CREATE INDEX idx_dependencies_issue_id ON dependencies(issue_id)
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(`
|
||||
CREATE INDEX idx_dependencies_depends_on ON dependencies(depends_on_id)
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(`
|
||||
CREATE INDEX idx_dependencies_type ON dependencies(type)
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(`
|
||||
CREATE INDEX idx_dependencies_depends_on_type ON dependencies(depends_on_id, type)
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(`
|
||||
CREATE INDEX idx_dependencies_depends_on_type_issue ON dependencies(depends_on_id, type, issue_id)
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Recreate views
|
||||
if _, err = tx.Exec(`
|
||||
CREATE VIEW IF NOT EXISTS ready_issues AS
|
||||
WITH RECURSIVE
|
||||
blocked_directly AS (
|
||||
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')
|
||||
),
|
||||
blocked_transitively AS (
|
||||
SELECT issue_id, 0 as depth
|
||||
FROM blocked_directly
|
||||
UNION ALL
|
||||
SELECT d.issue_id, bt.depth + 1
|
||||
FROM blocked_transitively bt
|
||||
JOIN dependencies d ON d.depends_on_id = bt.issue_id
|
||||
WHERE d.type = 'parent-child'
|
||||
AND bt.depth < 50
|
||||
)
|
||||
SELECT i.*
|
||||
FROM issues i
|
||||
WHERE i.status = 'open'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM blocked_transitively WHERE issue_id = i.id
|
||||
)
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(`
|
||||
CREATE VIEW IF NOT EXISTS blocked_issues AS
|
||||
SELECT
|
||||
i.*,
|
||||
COUNT(d.depends_on_id) as blocked_by_count
|
||||
FROM issues i
|
||||
JOIN dependencies d ON i.id = d.issue_id
|
||||
JOIN issues blocker ON d.depends_on_id = blocker.id
|
||||
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
AND d.type = 'blocks'
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
GROUP BY i.id
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
Reference in New Issue
Block a user