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:
Steve Yegge
2025-12-23 00:26:29 -08:00
parent aa1ce63156
commit 7fb92ff78c
9 changed files with 261 additions and 30 deletions

View File

@@ -365,8 +365,9 @@ type DependencyType string
// Dependency type constants
const (
// Workflow types (affect ready work calculation)
DepBlocks DependencyType = "blocks"
DepParentChild DependencyType = "parent-child"
DepBlocks DependencyType = "blocks"
DepParentChild DependencyType = "parent-child"
DepConditionalBlocks DependencyType = "conditional-blocks" // B runs only if A fails (bd-kzda)
// Association types
DepRelated DependencyType = "related"
@@ -395,7 +396,7 @@ func (d DependencyType) IsValid() bool {
// Returns false for custom/user-defined types (which are still valid).
func (d DependencyType) IsWellKnown() bool {
switch d {
case DepBlocks, DepParentChild, DepRelated, DepDiscoveredFrom,
case DepBlocks, DepParentChild, DepConditionalBlocks, DepRelated, DepDiscoveredFrom,
DepRepliesTo, DepRelatesTo, DepDuplicates, DepSupersedes,
DepAuthoredBy, DepAssignedTo, DepApprovedBy:
return true
@@ -404,9 +405,41 @@ func (d DependencyType) IsWellKnown() bool {
}
// 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 {
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

View File

@@ -482,6 +482,7 @@ func TestDependencyTypeAffectsReadyWork(t *testing.T) {
}{
{DepBlocks, true},
{DepParentChild, true},
{DepConditionalBlocks, true},
{DepRelated, false},
{DepDiscoveredFrom, 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) {
// Test that all time fields work correctly
now := time.Now()