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:
1
beads.go
1
beads.go
@@ -96,6 +96,7 @@ const (
|
|||||||
DepRelated = types.DepRelated
|
DepRelated = types.DepRelated
|
||||||
DepParentChild = types.DepParentChild
|
DepParentChild = types.DepParentChild
|
||||||
DepDiscoveredFrom = types.DepDiscoveredFrom
|
DepDiscoveredFrom = types.DepDiscoveredFrom
|
||||||
|
DepConditionalBlocks = types.DepConditionalBlocks // B runs only if A fails (bd-kzda)
|
||||||
)
|
)
|
||||||
|
|
||||||
// SortPolicy constants
|
// SortPolicy constants
|
||||||
|
|||||||
@@ -301,12 +301,18 @@ func bondProtoProto(ctx context.Context, s storage.Storage, protoA, protoB *type
|
|||||||
return fmt.Errorf("linking proto B: %w", err)
|
return fmt.Errorf("linking proto B: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For sequential bonding, add blocking dependency: B blocks on A
|
// For sequential/conditional bonding, add blocking dependency: B blocks on A
|
||||||
if bondType == types.BondTypeSequential {
|
// Sequential: B runs after A completes (any outcome)
|
||||||
|
// Conditional: B runs only if A fails (bd-kzda)
|
||||||
|
if bondType == types.BondTypeSequential || bondType == types.BondTypeConditional {
|
||||||
|
depType := types.DepBlocks
|
||||||
|
if bondType == types.BondTypeConditional {
|
||||||
|
depType = types.DepConditionalBlocks
|
||||||
|
}
|
||||||
seqDep := &types.Dependency{
|
seqDep := &types.Dependency{
|
||||||
IssueID: protoB.ID,
|
IssueID: protoB.ID,
|
||||||
DependsOnID: protoA.ID,
|
DependsOnID: protoA.ID,
|
||||||
Type: types.DepBlocks,
|
Type: depType,
|
||||||
}
|
}
|
||||||
if err := tx.AddDependency(ctx, seqDep, actorName); err != nil {
|
if err := tx.AddDependency(ctx, seqDep, actorName); err != nil {
|
||||||
return fmt.Errorf("adding sequence dep: %w", err)
|
return fmt.Errorf("adding sequence dep: %w", err)
|
||||||
@@ -357,12 +363,18 @@ func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issu
|
|||||||
// Attach spawned molecule to existing molecule
|
// Attach spawned molecule to existing molecule
|
||||||
err = s.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
err = s.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
||||||
// Add dependency from spawned root to molecule
|
// Add dependency from spawned root to molecule
|
||||||
// For sequential: use blocks (captures workflow semantics)
|
// Sequential: use blocks (B runs after A completes)
|
||||||
// For parallel/conditional: use parent-child (organizational)
|
// Conditional: use conditional-blocks (B runs only if A fails) (bd-kzda)
|
||||||
|
// Parallel: use parent-child (organizational, no blocking)
|
||||||
// Note: Schema only allows one dependency per (issue_id, depends_on_id) pair
|
// Note: Schema only allows one dependency per (issue_id, depends_on_id) pair
|
||||||
depType := types.DepParentChild
|
var depType types.DependencyType
|
||||||
if bondType == types.BondTypeSequential {
|
switch bondType {
|
||||||
|
case types.BondTypeSequential:
|
||||||
depType = types.DepBlocks
|
depType = types.DepBlocks
|
||||||
|
case types.BondTypeConditional:
|
||||||
|
depType = types.DepConditionalBlocks
|
||||||
|
default:
|
||||||
|
depType = types.DepParentChild
|
||||||
}
|
}
|
||||||
dep := &types.Dependency{
|
dep := &types.Dependency{
|
||||||
IssueID: spawnResult.NewEpicID,
|
IssueID: spawnResult.NewEpicID,
|
||||||
@@ -397,12 +409,18 @@ func bondMolProto(ctx context.Context, s storage.Storage, mol, proto *types.Issu
|
|||||||
func bondMolMol(ctx context.Context, s storage.Storage, molA, molB *types.Issue, bondType, actorName string) (*BondResult, error) {
|
func bondMolMol(ctx context.Context, s storage.Storage, molA, molB *types.Issue, bondType, actorName string) (*BondResult, error) {
|
||||||
err := s.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
err := s.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
||||||
// Add dependency: B links to A
|
// Add dependency: B links to A
|
||||||
// For sequential: use blocks (captures workflow semantics)
|
// Sequential: use blocks (B runs after A completes)
|
||||||
// For parallel/conditional: use parent-child (organizational)
|
// Conditional: use conditional-blocks (B runs only if A fails) (bd-kzda)
|
||||||
|
// Parallel: use parent-child (organizational, no blocking)
|
||||||
// Note: Schema only allows one dependency per (issue_id, depends_on_id) pair
|
// Note: Schema only allows one dependency per (issue_id, depends_on_id) pair
|
||||||
depType := types.DepParentChild
|
var depType types.DependencyType
|
||||||
if bondType == types.BondTypeSequential {
|
switch bondType {
|
||||||
|
case types.BondTypeSequential:
|
||||||
depType = types.DepBlocks
|
depType = types.DepBlocks
|
||||||
|
case types.BondTypeConditional:
|
||||||
|
depType = types.DepConditionalBlocks
|
||||||
|
default:
|
||||||
|
depType = types.DepParentChild
|
||||||
}
|
}
|
||||||
dep := &types.Dependency{
|
dep := &types.Dependency{
|
||||||
IssueID: molB.ID,
|
IssueID: molB.ID,
|
||||||
|
|||||||
@@ -1041,6 +1041,7 @@ func TestSpawnAttachTypes(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"sequential uses blocks", types.BondTypeSequential, types.DepBlocks},
|
{"sequential uses blocks", types.BondTypeSequential, types.DepBlocks},
|
||||||
{"parallel uses parent-child", types.BondTypeParallel, types.DepParentChild},
|
{"parallel uses parent-child", types.BondTypeParallel, types.DepParentChild},
|
||||||
|
{"conditional uses conditional-blocks", types.BondTypeConditional, types.DepConditionalBlocks},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ const (
|
|||||||
DepRelated = types.DepRelated
|
DepRelated = types.DepRelated
|
||||||
DepParentChild = types.DepParentChild
|
DepParentChild = types.DepParentChild
|
||||||
DepDiscoveredFrom = types.DepDiscoveredFrom
|
DepDiscoveredFrom = types.DepDiscoveredFrom
|
||||||
|
DepConditionalBlocks = types.DepConditionalBlocks // B runs only if A fails (bd-kzda)
|
||||||
)
|
)
|
||||||
|
|
||||||
// SortPolicy constants
|
// SortPolicy constants
|
||||||
|
|||||||
@@ -12,12 +12,17 @@
|
|||||||
// blocked. An issue is blocked if:
|
// 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 open/in_progress/blocked issue (direct blocking)
|
||||||
// - It has a 'blocks' dependency on an external:* reference (cross-project blocking, bd-om4a)
|
// - 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)
|
// - 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:
|
// 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)
|
// - 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
|
// Related and discovered-from dependencies do NOT trigger cache invalidation since they
|
||||||
// don't affect blocking semantics.
|
// 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
|
// Rebuild using the recursive CTE logic
|
||||||
// Only includes local blockers (open issues) - external refs are resolved
|
// Only includes local blockers (open issues) - external refs are resolved
|
||||||
// lazily at query time by GetReadyWork (bd-zmmy supersedes bd-om4a)
|
// 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 := `
|
query := `
|
||||||
INSERT INTO blocked_issues_cache (issue_id)
|
INSERT INTO blocked_issues_cache (issue_id)
|
||||||
WITH RECURSIVE
|
WITH RECURSIVE
|
||||||
-- Step 1: Find issues blocked directly by LOCAL dependencies
|
-- Step 1: Find issues blocked directly by LOCAL dependencies
|
||||||
-- External refs (external:*) are excluded - they're resolved lazily by GetReadyWork
|
-- External refs (external:*) are excluded - they're resolved lazily by GetReadyWork
|
||||||
blocked_directly AS (
|
blocked_directly AS (
|
||||||
|
-- Regular 'blocks' dependencies: B blocked if A not closed
|
||||||
SELECT DISTINCT d.issue_id
|
SELECT DISTINCT d.issue_id
|
||||||
FROM dependencies d
|
FROM dependencies d
|
||||||
JOIN issues blocker ON d.depends_on_id = blocker.id
|
JOIN issues blocker ON d.depends_on_id = blocker.id
|
||||||
WHERE d.type = 'blocks'
|
WHERE d.type = 'blocks'
|
||||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
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
|
-- 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)
|
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)
|
// Invalidate blocked cache for blocking dependencies (bd-1c4h, bd-kzda)
|
||||||
if dep.Type == types.DepBlocks || dep.Type == types.DepParentChild {
|
if dep.Type.AffectsReadyWork() {
|
||||||
if err := t.parent.invalidateBlockedCache(ctx, t.conn); err != nil {
|
if err := t.parent.invalidateBlockedCache(ctx, t.conn); err != nil {
|
||||||
return fmt.Errorf("failed to invalidate blocked cache: %w", err)
|
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 = ?
|
SELECT type FROM dependencies WHERE issue_id = ? AND depends_on_id = ?
|
||||||
`, issueID, dependsOnID).Scan(&depType)
|
`, issueID, dependsOnID).Scan(&depType)
|
||||||
|
|
||||||
// Store whether cache needs invalidation before deletion
|
// Store whether cache needs invalidation before deletion (bd-1c4h, bd-kzda)
|
||||||
needsCacheInvalidation := false
|
needsCacheInvalidation := false
|
||||||
if err == nil {
|
if err == nil {
|
||||||
needsCacheInvalidation = (depType == types.DepBlocks || depType == types.DepParentChild)
|
needsCacheInvalidation = depType.AffectsReadyWork()
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := t.conn.ExecContext(ctx, `
|
result, err := t.conn.ExecContext(ctx, `
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ const (
|
|||||||
// Workflow types (affect ready work calculation)
|
// Workflow types (affect ready work calculation)
|
||||||
DepBlocks DependencyType = "blocks"
|
DepBlocks DependencyType = "blocks"
|
||||||
DepParentChild DependencyType = "parent-child"
|
DepParentChild DependencyType = "parent-child"
|
||||||
|
DepConditionalBlocks DependencyType = "conditional-blocks" // B runs only if A fails (bd-kzda)
|
||||||
|
|
||||||
// Association types
|
// Association types
|
||||||
DepRelated DependencyType = "related"
|
DepRelated DependencyType = "related"
|
||||||
@@ -395,7 +396,7 @@ func (d DependencyType) IsValid() bool {
|
|||||||
// Returns false for custom/user-defined types (which are still valid).
|
// Returns false for custom/user-defined types (which are still valid).
|
||||||
func (d DependencyType) IsWellKnown() bool {
|
func (d DependencyType) IsWellKnown() bool {
|
||||||
switch d {
|
switch d {
|
||||||
case DepBlocks, DepParentChild, DepRelated, DepDiscoveredFrom,
|
case DepBlocks, DepParentChild, DepConditionalBlocks, DepRelated, DepDiscoveredFrom,
|
||||||
DepRepliesTo, DepRelatesTo, DepDuplicates, DepSupersedes,
|
DepRepliesTo, DepRelatesTo, DepDuplicates, DepSupersedes,
|
||||||
DepAuthoredBy, DepAssignedTo, DepApprovedBy:
|
DepAuthoredBy, DepAssignedTo, DepApprovedBy:
|
||||||
return true
|
return true
|
||||||
@@ -404,9 +405,41 @@ func (d DependencyType) IsWellKnown() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AffectsReadyWork returns true if this dependency type blocks work.
|
// AffectsReadyWork returns true if this dependency type blocks work.
|
||||||
// Only "blocks" and "parent-child" relationships affect the ready work calculation.
|
// Only "blocks", "parent-child", and "conditional-blocks" affect the ready work calculation.
|
||||||
func (d DependencyType) AffectsReadyWork() bool {
|
func (d DependencyType) AffectsReadyWork() bool {
|
||||||
return d == DepBlocks || d == DepParentChild
|
return d == DepBlocks || d == DepParentChild || d == DepConditionalBlocks
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailureCloseKeywords are keywords that indicate an issue was closed due to failure.
|
||||||
|
// Used by conditional-blocks dependencies to determine if the condition is met.
|
||||||
|
var FailureCloseKeywords = []string{
|
||||||
|
"failed",
|
||||||
|
"rejected",
|
||||||
|
"wontfix",
|
||||||
|
"won't fix",
|
||||||
|
"cancelled",
|
||||||
|
"canceled",
|
||||||
|
"abandoned",
|
||||||
|
"blocked",
|
||||||
|
"error",
|
||||||
|
"timeout",
|
||||||
|
"aborted",
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFailureClose returns true if the close reason indicates the issue failed.
|
||||||
|
// This is used by conditional-blocks dependencies: B runs only if A fails.
|
||||||
|
// A "failure" close reason contains one of the FailureCloseKeywords (case-insensitive).
|
||||||
|
func IsFailureClose(closeReason string) bool {
|
||||||
|
if closeReason == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(closeReason)
|
||||||
|
for _, keyword := range FailureCloseKeywords {
|
||||||
|
if strings.Contains(lower, keyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Label represents a tag on an issue
|
// Label represents a tag on an issue
|
||||||
|
|||||||
@@ -482,6 +482,7 @@ func TestDependencyTypeAffectsReadyWork(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{DepBlocks, true},
|
{DepBlocks, true},
|
||||||
{DepParentChild, true},
|
{DepParentChild, true},
|
||||||
|
{DepConditionalBlocks, true},
|
||||||
{DepRelated, false},
|
{DepRelated, false},
|
||||||
{DepDiscoveredFrom, false},
|
{DepDiscoveredFrom, false},
|
||||||
{DepRepliesTo, false},
|
{DepRepliesTo, false},
|
||||||
@@ -503,6 +504,51 @@ func TestDependencyTypeAffectsReadyWork(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsFailureClose(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
closeReason string
|
||||||
|
isFailure bool
|
||||||
|
}{
|
||||||
|
// Failure keywords
|
||||||
|
{"failed", "Task failed due to timeout", true},
|
||||||
|
{"rejected", "PR was rejected by reviewer", true},
|
||||||
|
{"wontfix", "Closed as wontfix", true},
|
||||||
|
{"won't fix", "Won't fix - by design", true},
|
||||||
|
{"cancelled", "Work cancelled", true},
|
||||||
|
{"canceled", "Work canceled", true},
|
||||||
|
{"abandoned", "Abandoned feature", true},
|
||||||
|
{"blocked", "Blocked by external dependency", true},
|
||||||
|
{"error", "Encountered error during execution", true},
|
||||||
|
{"timeout", "Test timeout exceeded", true},
|
||||||
|
{"aborted", "Build aborted", true},
|
||||||
|
|
||||||
|
// Case insensitive
|
||||||
|
{"FAILED upper", "FAILED", true},
|
||||||
|
{"Failed mixed", "Failed to build", true},
|
||||||
|
|
||||||
|
// Success cases (no failure keywords)
|
||||||
|
{"completed", "Completed successfully", false},
|
||||||
|
{"done", "Done", false},
|
||||||
|
{"merged", "Merged to main", false},
|
||||||
|
{"fixed", "Bug fixed", false},
|
||||||
|
{"implemented", "Feature implemented", false},
|
||||||
|
{"empty", "", false},
|
||||||
|
|
||||||
|
// Partial matches should work
|
||||||
|
{"prefixed", "prefailed", true}, // contains "failed"
|
||||||
|
{"suffixed", "failedtest", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := IsFailureClose(tt.closeReason); got != tt.isFailure {
|
||||||
|
t.Errorf("IsFailureClose(%q) = %v, want %v", tt.closeReason, got, tt.isFailure)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIssueStructFields(t *testing.T) {
|
func TestIssueStructFields(t *testing.T) {
|
||||||
// Test that all time fields work correctly
|
// Test that all time fields work correctly
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
Reference in New Issue
Block a user