feat: add 'convoy' issue type with reactive completion (bd-hj0s)

- Add TypeConvoy to issue types for cross-project tracking
- Implement reactive completion: when all tracked issues close,
  convoy auto-closes with reason "All tracked issues completed"
- Uses 'tracks' dependency type (non-blocking, cross-prefix capable)
- Update help text for --type flag in list/create commands
- Add test for convoy reactive completion behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
This commit is contained in:
Steve Yegge
2025-12-30 00:04:43 -08:00
parent 160feb95ea
commit b63df91230
6 changed files with 247 additions and 3 deletions

View File

@@ -499,7 +499,7 @@ func init() {
createCmd.Flags().String("title", "", "Issue title (alternative to positional argument)") createCmd.Flags().String("title", "", "Issue title (alternative to positional argument)")
createCmd.Flags().Bool("silent", false, "Output only the issue ID (for scripting)") createCmd.Flags().Bool("silent", false, "Output only the issue ID (for scripting)")
registerPriorityFlag(createCmd, "2") registerPriorityFlag(createCmd, "2")
createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule|gate|agent|role)") createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule|gate|agent|role|convoy)")
registerCommonIssueFlags(createCmd) registerCommonIssueFlags(createCmd)
createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)") createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)")
createCmd.Flags().StringSlice("label", []string{}, "Alias for --labels") createCmd.Flags().StringSlice("label", []string{}, "Alias for --labels")

View File

@@ -912,7 +912,7 @@ func init() {
listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)") listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
registerPriorityFlag(listCmd, "") registerPriorityFlag(listCmd, "")
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate)") listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate, convoy)")
listCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL). Can combine with --label-any") listCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL). Can combine with --label-any")
listCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label") listCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label")
listCmd.Flags().String("title", "", "Filter by title text (case-insensitive substring match)") listCmd.Flags().String("title", "", "Filter by title text (case-insensitive substring match)")

View File

@@ -1100,6 +1100,83 @@ func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string
return fmt.Errorf("failed to invalidate blocked cache: %w", err) return fmt.Errorf("failed to invalidate blocked cache: %w", err)
} }
// Reactive convoy completion: check if any convoys tracking this issue should auto-close
// Find convoys that track this issue (convoy.issue_id tracks closed_issue.depends_on_id)
convoyRows, err := tx.QueryContext(ctx, `
SELECT DISTINCT d.issue_id
FROM dependencies d
JOIN issues i ON d.issue_id = i.id
WHERE d.depends_on_id = ?
AND d.type = ?
AND i.issue_type = ?
AND i.status != ?
`, id, types.DepTracks, types.TypeConvoy, types.StatusClosed)
if err != nil {
return fmt.Errorf("failed to find tracking convoys: %w", err)
}
defer func() { _ = convoyRows.Close() }()
var convoyIDs []string
for convoyRows.Next() {
var convoyID string
if err := convoyRows.Scan(&convoyID); err != nil {
return fmt.Errorf("failed to scan convoy ID: %w", err)
}
convoyIDs = append(convoyIDs, convoyID)
}
if err := convoyRows.Err(); err != nil {
return fmt.Errorf("convoy rows iteration error: %w", err)
}
// For each convoy, check if all tracked issues are now closed
for _, convoyID := range convoyIDs {
// Count non-closed tracked issues for this convoy
var openCount int
err := tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM dependencies d
JOIN issues i ON d.depends_on_id = i.id
WHERE d.issue_id = ?
AND d.type = ?
AND i.status != ?
AND i.status != ?
`, convoyID, types.DepTracks, types.StatusClosed, types.StatusTombstone).Scan(&openCount)
if err != nil {
return fmt.Errorf("failed to count open tracked issues for convoy %s: %w", convoyID, err)
}
// If all tracked issues are closed, auto-close the convoy
if openCount == 0 {
closeReason := "All tracked issues completed"
_, err := tx.ExecContext(ctx, `
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?
WHERE id = ?
`, types.StatusClosed, now, now, closeReason, convoyID)
if err != nil {
return fmt.Errorf("failed to auto-close convoy %s: %w", convoyID, err)
}
// Record the close event
_, err = tx.ExecContext(ctx, `
INSERT INTO events (issue_id, event_type, actor, comment)
VALUES (?, ?, ?, ?)
`, convoyID, types.EventClosed, "system:convoy-completion", closeReason)
if err != nil {
return fmt.Errorf("failed to record convoy close event: %w", err)
}
// Mark convoy as dirty
_, err = tx.ExecContext(ctx, `
INSERT INTO dirty_issues (issue_id, marked_at)
VALUES (?, ?)
ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at
`, convoyID, now)
if err != nil {
return fmt.Errorf("failed to mark convoy dirty: %w", err)
}
}
}
return tx.Commit() return tx.Commit()
} }

View File

@@ -1469,6 +1469,100 @@ func TestDeleteConfig(t *testing.T) {
} }
} }
func TestConvoyReactiveCompletion(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a convoy
convoy := &types.Issue{
Title: "Test Convoy",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeConvoy,
}
err := store.CreateIssue(ctx, convoy, "test-user")
if err != nil {
t.Fatalf("CreateIssue convoy failed: %v", err)
}
// Create two issues to track
issue1 := &types.Issue{
Title: "Tracked Issue 1",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
err = store.CreateIssue(ctx, issue1, "test-user")
if err != nil {
t.Fatalf("CreateIssue issue1 failed: %v", err)
}
issue2 := &types.Issue{
Title: "Tracked Issue 2",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
err = store.CreateIssue(ctx, issue2, "test-user")
if err != nil {
t.Fatalf("CreateIssue issue2 failed: %v", err)
}
// Add tracking dependencies: convoy tracks issue1 and issue2
dep1 := &types.Dependency{
IssueID: convoy.ID,
DependsOnID: issue1.ID,
Type: types.DepTracks,
}
err = store.AddDependency(ctx, dep1, "test-user")
if err != nil {
t.Fatalf("AddDependency for issue1 failed: %v", err)
}
dep2 := &types.Dependency{
IssueID: convoy.ID,
DependsOnID: issue2.ID,
Type: types.DepTracks,
}
err = store.AddDependency(ctx, dep2, "test-user")
if err != nil {
t.Fatalf("AddDependency for issue2 failed: %v", err)
}
// Close first issue - convoy should still be open
err = store.CloseIssue(ctx, issue1.ID, "Done", "test-user")
if err != nil {
t.Fatalf("CloseIssue issue1 failed: %v", err)
}
convoyAfter1, err := store.GetIssue(ctx, convoy.ID)
if err != nil {
t.Fatalf("GetIssue convoy after issue1 closed failed: %v", err)
}
if convoyAfter1.Status == types.StatusClosed {
t.Error("Convoy should NOT be closed after only first tracked issue is closed")
}
// Close second issue - convoy should auto-close now
err = store.CloseIssue(ctx, issue2.ID, "Done", "test-user")
if err != nil {
t.Fatalf("CloseIssue issue2 failed: %v", err)
}
convoyAfter2, err := store.GetIssue(ctx, convoy.ID)
if err != nil {
t.Fatalf("GetIssue convoy after issue2 closed failed: %v", err)
}
if convoyAfter2.Status != types.StatusClosed {
t.Errorf("Convoy should be auto-closed when all tracked issues are closed, got status: %v", convoyAfter2.Status)
}
if convoyAfter2.CloseReason != "All tracked issues completed" {
t.Errorf("Convoy close reason should be 'All tracked issues completed', got: %q", convoyAfter2.CloseReason)
}
}
func TestIsClosed(t *testing.T) { func TestIsClosed(t *testing.T) {
store, cleanup := setupTestDB(t) store, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()

View File

@@ -561,6 +561,78 @@ func (t *sqliteTxStorage) CloseIssue(ctx context.Context, id string, reason stri
return fmt.Errorf("failed to invalidate blocked cache: %w", err) return fmt.Errorf("failed to invalidate blocked cache: %w", err)
} }
// Reactive convoy completion: check if any convoys tracking this issue should auto-close
// Find convoys that track this issue (convoy.issue_id tracks closed_issue.depends_on_id)
convoyRows, err := t.conn.QueryContext(ctx, `
SELECT DISTINCT d.issue_id
FROM dependencies d
JOIN issues i ON d.issue_id = i.id
WHERE d.depends_on_id = ?
AND d.type = ?
AND i.issue_type = ?
AND i.status != ?
`, id, types.DepTracks, types.TypeConvoy, types.StatusClosed)
if err != nil {
return fmt.Errorf("failed to find tracking convoys: %w", err)
}
defer func() { _ = convoyRows.Close() }()
var convoyIDs []string
for convoyRows.Next() {
var convoyID string
if err := convoyRows.Scan(&convoyID); err != nil {
return fmt.Errorf("failed to scan convoy ID: %w", err)
}
convoyIDs = append(convoyIDs, convoyID)
}
if err := convoyRows.Err(); err != nil {
return fmt.Errorf("convoy rows iteration error: %w", err)
}
// For each convoy, check if all tracked issues are now closed
for _, convoyID := range convoyIDs {
// Count non-closed tracked issues for this convoy
var openCount int
err := t.conn.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM dependencies d
JOIN issues i ON d.depends_on_id = i.id
WHERE d.issue_id = ?
AND d.type = ?
AND i.status != ?
AND i.status != ?
`, convoyID, types.DepTracks, types.StatusClosed, types.StatusTombstone).Scan(&openCount)
if err != nil {
return fmt.Errorf("failed to count open tracked issues for convoy %s: %w", convoyID, err)
}
// If all tracked issues are closed, auto-close the convoy
if openCount == 0 {
closeReason := "All tracked issues completed"
_, err := t.conn.ExecContext(ctx, `
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?
WHERE id = ?
`, types.StatusClosed, now, now, closeReason, convoyID)
if err != nil {
return fmt.Errorf("failed to auto-close convoy %s: %w", convoyID, err)
}
// Record the close event
_, err = t.conn.ExecContext(ctx, `
INSERT INTO events (issue_id, event_type, actor, comment)
VALUES (?, ?, ?, ?)
`, convoyID, types.EventClosed, "system:convoy-completion", closeReason)
if err != nil {
return fmt.Errorf("failed to record convoy close event: %w", err)
}
// Mark convoy as dirty
if err := markDirty(ctx, t.conn, convoyID); err != nil {
return fmt.Errorf("failed to mark convoy dirty: %w", err)
}
}
}
return nil return nil
} }

View File

@@ -394,12 +394,13 @@ const (
TypeGate IssueType = "gate" // Async coordination gate TypeGate IssueType = "gate" // Async coordination gate
TypeAgent IssueType = "agent" // Agent identity bead TypeAgent IssueType = "agent" // Agent identity bead
TypeRole IssueType = "role" // Agent role definition TypeRole IssueType = "role" // Agent role definition
TypeConvoy IssueType = "convoy" // Cross-project tracking with reactive completion
) )
// IsValid checks if the issue type value is valid // IsValid checks if the issue type value is valid
func (t IssueType) IsValid() bool { func (t IssueType) IsValid() bool {
switch t { switch t {
case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule, TypeGate, TypeAgent, TypeRole: case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule, TypeGate, TypeAgent, TypeRole, TypeConvoy:
return true return true
} }
return false return false