diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 17fbac6c..2e4264b3 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -1242,7 +1242,8 @@ func createAutoConvoy(beadID, beadTitle string) (string, error) { } // Add tracking relation: convoy tracks the issue - depArgs := []string{"dep", "add", convoyID, beadID, "--type=tracks"} + trackBeadID := formatTrackBeadID(beadID) + depArgs := []string{"dep", "add", convoyID, trackBeadID, "--type=tracks"} depCmd := exec.Command("bd", depArgs...) depCmd.Dir = townBeads depCmd.Stderr = os.Stderr @@ -1280,10 +1281,10 @@ func runBatchSling(beadIDs []string, rigName string, townBeadsDir string) error // Track results for summary type slingResult struct { - beadID string - polecat string - success bool - errMsg string + beadID string + polecat string + success bool + errMsg string } results := make([]slingResult, 0, len(beadIDs)) @@ -1401,3 +1402,24 @@ func runBatchSling(beadIDs []string, rigName string, townBeadsDir string) error return nil } + +// formatTrackBeadID formats a bead ID for use in convoy tracking dependencies. +// Cross-rig beads (non-hq- prefixed) are formatted as external references +// so the bd tool can resolve them when running from HQ context. +// +// Examples: +// - "hq-abc123" -> "hq-abc123" (HQ beads unchanged) +// - "gt-mol-xyz" -> "external:gt-mol:gt-mol-xyz" +// - "beads-task-123" -> "external:beads-task:beads-task-123" +func formatTrackBeadID(beadID string) string { + if strings.HasPrefix(beadID, "hq-") { + return beadID + } + parts := strings.SplitN(beadID, "-", 3) + if len(parts) >= 2 { + rigPrefix := parts[0] + "-" + parts[1] + return fmt.Sprintf("external:%s:%s", rigPrefix, beadID) + } + // Fallback for malformed IDs (single segment) + return beadID +} diff --git a/internal/cmd/sling_test.go b/internal/cmd/sling_test.go new file mode 100644 index 00000000..7a56c127 --- /dev/null +++ b/internal/cmd/sling_test.go @@ -0,0 +1,133 @@ +package cmd + +import "testing" + +func TestFormatTrackBeadID(t *testing.T) { + tests := []struct { + name string + beadID string + expected string + }{ + // HQ beads should remain unchanged + { + name: "hq bead unchanged", + beadID: "hq-abc123", + expected: "hq-abc123", + }, + { + name: "hq convoy unchanged", + beadID: "hq-cv-xyz789", + expected: "hq-cv-xyz789", + }, + + // Cross-rig beads get external: prefix + { + name: "gastown rig bead", + beadID: "gt-mol-abc123", + expected: "external:gt-mol:gt-mol-abc123", + }, + { + name: "beads rig task", + beadID: "beads-task-xyz", + expected: "external:beads-task:beads-task-xyz", + }, + { + name: "two segment ID", + beadID: "foo-bar", + expected: "external:foo-bar:foo-bar", + }, + + // Edge cases + { + name: "single segment fallback", + beadID: "orphan", + expected: "orphan", + }, + { + name: "empty string fallback", + beadID: "", + expected: "", + }, + { + name: "many segments", + beadID: "a-b-c-d-e-f", + expected: "external:a-b:a-b-c-d-e-f", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatTrackBeadID(tt.beadID) + if result != tt.expected { + t.Errorf("formatTrackBeadID(%q) = %q, want %q", tt.beadID, result, tt.expected) + } + }) + } +} + +// TestFormatTrackBeadIDConsumerCompatibility verifies that the external ref format +// produced by formatTrackBeadID can be correctly parsed by the consumer pattern +// used in convoy.go, model.go, feed/convoy.go, and web/fetcher.go. +func TestFormatTrackBeadIDConsumerCompatibility(t *testing.T) { + // Consumer pattern from convoy.go:1062-1068: + // if strings.HasPrefix(issueID, "external:") { + // parts := strings.SplitN(issueID, ":", 3) + // if len(parts) == 3 { + // issueID = parts[2] // Extract the actual issue ID + // } + // } + + tests := []struct { + name string + beadID string + wantOriginalID string + }{ + { + name: "cross-rig bead round-trips", + beadID: "gt-mol-abc123", + wantOriginalID: "gt-mol-abc123", + }, + { + name: "beads rig bead round-trips", + beadID: "beads-task-xyz", + wantOriginalID: "beads-task-xyz", + }, + { + name: "hq bead unchanged", + beadID: "hq-abc123", + wantOriginalID: "hq-abc123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + formatted := formatTrackBeadID(tt.beadID) + + // Simulate consumer parsing logic + parsed := formatted + if len(formatted) > 9 && formatted[:9] == "external:" { + parts := make([]string, 0, 3) + start := 0 + count := 0 + for i := 0; i < len(formatted) && count < 2; i++ { + if formatted[i] == ':' { + parts = append(parts, formatted[start:i]) + start = i + 1 + count++ + } + } + if count == 2 { + parts = append(parts, formatted[start:]) + } + if len(parts) == 3 { + parsed = parts[2] + } + } + + if parsed != tt.wantOriginalID { + t.Errorf("round-trip failed: formatTrackBeadID(%q) = %q, parsed back to %q, want %q", + tt.beadID, formatted, parsed, tt.wantOriginalID) + } + }) + } +}