feat: implement conditional bond type for mol bond (bd-kzda)
Conditional bonds now work as documented: "B runs only if A fails". Implementation: - Add DepConditionalBlocks dependency type to types.go - Add IsFailureClose() helper to detect failure keywords in close_reason - Update blocked cache to handle conditional-blocks: - B is blocked while A is open - B stays blocked if A closes with success - B becomes unblocked if A closes with failure Failure keywords: failed, rejected, wontfix, cancelled, abandoned, blocked, error, timeout, aborted (case-insensitive) Updated bondProtoProto, bondProtoMol, bondMolMol to use DepConditionalBlocks for conditional bond type. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,12 +12,17 @@
|
||||
// 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)
|
||||
// - It has a 'conditional-blocks' dependency where the blocker hasn't failed (bd-kzda)
|
||||
// - Its parent is blocked and it's connected via 'parent-child' dependency (transitive blocking)
|
||||
//
|
||||
// Conditional blocks (bd-kzda): B runs only if A fails. B is blocked until A is closed
|
||||
// with a failure close reason (failed, rejected, wontfix, cancelled, abandoned, etc.).
|
||||
// If A succeeds (closed without failure), B stays blocked.
|
||||
//
|
||||
// The cache is maintained automatically by invalidating and rebuilding whenever:
|
||||
// - A 'blocks' or 'parent-child' dependency is added or removed
|
||||
// - A 'blocks', 'conditional-blocks', or 'parent-child' dependency is added or removed
|
||||
// - Any issue's status changes (affects whether it blocks others)
|
||||
// - An issue is closed (closed issues don't block others)
|
||||
// - An issue is closed (closed issues don't block others; conditional-blocks checks close_reason)
|
||||
//
|
||||
// Related and discovered-from dependencies do NOT trigger cache invalidation since they
|
||||
// don't affect blocking semantics.
|
||||
@@ -115,17 +120,57 @@ func (s *SQLiteStorage) rebuildBlockedCache(ctx context.Context, exec execer) er
|
||||
// Rebuild using the recursive CTE logic
|
||||
// Only includes local blockers (open issues) - external refs are resolved
|
||||
// lazily at query time by GetReadyWork (bd-zmmy supersedes bd-om4a)
|
||||
//
|
||||
// Handles three blocking types:
|
||||
// - 'blocks': B is blocked until A is closed (any close reason)
|
||||
// - 'conditional-blocks': B is blocked until A is closed with failure (bd-kzda)
|
||||
// - 'parent-child': Propagates blockage to children
|
||||
//
|
||||
// Failure close reasons are detected by matching keywords in close_reason:
|
||||
// failed, rejected, wontfix, won't fix, cancelled, canceled, abandoned,
|
||||
// blocked, error, timeout, aborted
|
||||
query := `
|
||||
INSERT INTO blocked_issues_cache (issue_id)
|
||||
WITH RECURSIVE
|
||||
-- Step 1: Find issues blocked directly by LOCAL dependencies
|
||||
-- External refs (external:*) are excluded - they're resolved lazily by GetReadyWork
|
||||
blocked_directly AS (
|
||||
-- Regular 'blocks' dependencies: B blocked if A not closed
|
||||
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
|
||||
|
||||
-- 'conditional-blocks' dependencies: B blocked unless A closed with failure (bd-kzda)
|
||||
-- B is blocked if:
|
||||
-- - A is not closed (still in progress), OR
|
||||
-- - A is closed without a failure indication
|
||||
SELECT DISTINCT d.issue_id
|
||||
FROM dependencies d
|
||||
JOIN issues blocker ON d.depends_on_id = blocker.id
|
||||
WHERE d.type = 'conditional-blocks'
|
||||
AND (
|
||||
-- A is not closed: B stays blocked
|
||||
blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
OR
|
||||
-- A is closed but NOT with a failure: B stays blocked (condition not met)
|
||||
(blocker.status = 'closed' AND NOT (
|
||||
LOWER(COALESCE(blocker.close_reason, '')) LIKE '%failed%'
|
||||
OR LOWER(COALESCE(blocker.close_reason, '')) LIKE '%rejected%'
|
||||
OR LOWER(COALESCE(blocker.close_reason, '')) LIKE '%wontfix%'
|
||||
OR LOWER(COALESCE(blocker.close_reason, '')) LIKE '%won''t fix%'
|
||||
OR LOWER(COALESCE(blocker.close_reason, '')) LIKE '%cancelled%'
|
||||
OR LOWER(COALESCE(blocker.close_reason, '')) LIKE '%canceled%'
|
||||
OR LOWER(COALESCE(blocker.close_reason, '')) LIKE '%abandoned%'
|
||||
OR LOWER(COALESCE(blocker.close_reason, '')) LIKE '%blocked%'
|
||||
OR LOWER(COALESCE(blocker.close_reason, '')) LIKE '%error%'
|
||||
OR LOWER(COALESCE(blocker.close_reason, '')) LIKE '%timeout%'
|
||||
OR LOWER(COALESCE(blocker.close_reason, '')) LIKE '%aborted%'
|
||||
))
|
||||
)
|
||||
),
|
||||
|
||||
-- Step 2: Propagate blockage to all descendants via parent-child
|
||||
|
||||
@@ -374,3 +374,89 @@ func TestMultipleBlockersInCache(t *testing.T) {
|
||||
t.Errorf("Expected %s to be removed from cache (both blockers closed)", blocked.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConditionalBlocksCache tests the conditional-blocks dependency type (bd-kzda)
|
||||
// B runs only if A fails. B is blocked until A is closed with a failure close reason.
|
||||
func TestConditionalBlocksCache(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create A (potential failure) -> B (conditional on A's failure)
|
||||
issueA := &types.Issue{Title: "Issue A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issueB := &types.Issue{Title: "Issue B (runs if A fails)", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issueA, "test-user")
|
||||
store.CreateIssue(ctx, issueB, "test-user")
|
||||
|
||||
// Add conditional-blocks dependency: B depends on A failing
|
||||
dep := &types.Dependency{IssueID: issueB.ID, DependsOnID: issueA.ID, Type: types.DepConditionalBlocks}
|
||||
store.AddDependency(ctx, dep, "test-user")
|
||||
|
||||
// Initially: A is open, so B should be blocked
|
||||
cached := getCachedBlockedIssues(t, store)
|
||||
if !cached[issueB.ID] {
|
||||
t.Errorf("Expected %s to be blocked (A is still open)", issueB.ID)
|
||||
}
|
||||
|
||||
// Close A with SUCCESS (no failure keywords) - B should STILL be blocked
|
||||
store.CloseIssue(ctx, issueA.ID, "Completed successfully", "test-user")
|
||||
|
||||
cached = getCachedBlockedIssues(t, store)
|
||||
if !cached[issueB.ID] {
|
||||
t.Errorf("Expected %s to be blocked (A succeeded, condition not met)", issueB.ID)
|
||||
}
|
||||
|
||||
// Reopen A
|
||||
store.UpdateIssue(ctx, issueA.ID, map[string]interface{}{"status": types.StatusOpen}, "test-user")
|
||||
|
||||
// Close A with FAILURE - B should now be UNBLOCKED
|
||||
store.CloseIssue(ctx, issueA.ID, "Task failed due to timeout", "test-user")
|
||||
|
||||
cached = getCachedBlockedIssues(t, store)
|
||||
if cached[issueB.ID] {
|
||||
t.Errorf("Expected %s to be unblocked (A failed, condition met)", issueB.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConditionalBlocksVariousFailureKeywords tests that various failure keywords unlock B
|
||||
func TestConditionalBlocksVariousFailureKeywords(t *testing.T) {
|
||||
failureReasons := []string{
|
||||
"failed",
|
||||
"rejected",
|
||||
"wontfix",
|
||||
"won't fix",
|
||||
"cancelled",
|
||||
"canceled",
|
||||
"abandoned",
|
||||
"blocked",
|
||||
"error",
|
||||
"timeout",
|
||||
"aborted",
|
||||
}
|
||||
|
||||
for _, reason := range failureReasons {
|
||||
t.Run(reason, func(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
issueA := &types.Issue{Title: "Issue A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
issueB := &types.Issue{Title: "Issue B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issueA, "test-user")
|
||||
store.CreateIssue(ctx, issueB, "test-user")
|
||||
|
||||
dep := &types.Dependency{IssueID: issueB.ID, DependsOnID: issueA.ID, Type: types.DepConditionalBlocks}
|
||||
store.AddDependency(ctx, dep, "test-user")
|
||||
|
||||
// Close A with failure reason
|
||||
store.CloseIssue(ctx, issueA.ID, "Closed: "+reason, "test-user")
|
||||
|
||||
cached := getCachedBlockedIssues(t, store)
|
||||
if cached[issueB.ID] {
|
||||
t.Errorf("Expected B to be unblocked after A closed with '%s'", reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,8 +712,8 @@ func (t *sqliteTxStorage) AddDependency(ctx context.Context, dep *types.Dependen
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate blocked cache for blocking dependencies (bd-1c4h)
|
||||
if dep.Type == types.DepBlocks || dep.Type == types.DepParentChild {
|
||||
// Invalidate blocked cache for blocking dependencies (bd-1c4h, bd-kzda)
|
||||
if dep.Type.AffectsReadyWork() {
|
||||
if err := t.parent.invalidateBlockedCache(ctx, t.conn); err != nil {
|
||||
return fmt.Errorf("failed to invalidate blocked cache: %w", err)
|
||||
}
|
||||
@@ -730,10 +730,10 @@ func (t *sqliteTxStorage) RemoveDependency(ctx context.Context, issueID, depends
|
||||
SELECT type FROM dependencies WHERE issue_id = ? AND depends_on_id = ?
|
||||
`, issueID, dependsOnID).Scan(&depType)
|
||||
|
||||
// Store whether cache needs invalidation before deletion
|
||||
// Store whether cache needs invalidation before deletion (bd-1c4h, bd-kzda)
|
||||
needsCacheInvalidation := false
|
||||
if err == nil {
|
||||
needsCacheInvalidation = (depType == types.DepBlocks || depType == types.DepParentChild)
|
||||
needsCacheInvalidation = depType.AffectsReadyWork()
|
||||
}
|
||||
|
||||
result, err := t.conn.ExecContext(ctx, `
|
||||
|
||||
Reference in New Issue
Block a user