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:
@@ -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")
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user