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().Bool("silent", false, "Output only the issue ID (for scripting)")
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)
createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)")
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)")
registerPriorityFlag(listCmd, "")
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().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)")

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)
}
// 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()
}

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) {
store, cleanup := setupTestDB(t)
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)
}
// 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
}

View File

@@ -394,12 +394,13 @@ const (
TypeGate IssueType = "gate" // Async coordination gate
TypeAgent IssueType = "agent" // Agent identity bead
TypeRole IssueType = "role" // Agent role definition
TypeConvoy IssueType = "convoy" // Cross-project tracking with reactive completion
)
// IsValid checks if the issue type value is valid
func (t IssueType) IsValid() bool {
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 false