From e11bcb931eb11646a12f7e79362020039d56d5c6 Mon Sep 17 00:00:00 2001 From: gastown/witness Date: Sun, 4 Jan 2026 20:28:28 -0500 Subject: [PATCH 1/2] fix(sling): Format cross-rig beads as external refs in convoy tracking When creating auto-convoys for cross-rig beads (e.g., gt-xxx or gu-xxx), the tracking relation was failing because bd couldn't resolve the bead ID from HQ context. Now formats non-HQ beads as external:prefix:id for proper resolution. Fixes convoy tracking for cross-rig sling operations. --- internal/cmd/sling.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 39da5e46..011fb5c9 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -1240,7 +1240,16 @@ func createAutoConvoy(beadID, beadTitle string) (string, error) { } // Add tracking relation: convoy tracks the issue - depArgs := []string{"dep", "add", convoyID, beadID, "--type=tracks"} + // Format cross-prefix beads as external refs so bd can resolve them + trackBeadID := beadID + if !strings.HasPrefix(beadID, "hq-") { + parts := strings.SplitN(beadID, "-", 3) + if len(parts) >= 2 { + rigPrefix := parts[0] + "-" + parts[1] + trackBeadID = fmt.Sprintf("external:%s:%s", rigPrefix, beadID) + } + } + depArgs := []string{"dep", "add", convoyID, trackBeadID, "--type=tracks"} depCmd := exec.Command("bd", depArgs...) depCmd.Dir = townBeads depCmd.Stderr = os.Stderr @@ -1278,10 +1287,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)) From 8f03b447710edad6a37276a82e6342991cdd8da0 Mon Sep 17 00:00:00 2001 From: alice Date: Sun, 4 Jan 2026 20:51:08 -0500 Subject: [PATCH 2/2] test: Add unit tests for formatTrackBeadID helper Extract the cross-rig bead formatting logic into a testable helper function and add comprehensive unit tests: - TestFormatTrackBeadID: 8 test cases covering HQ beads, cross-rig beads, and edge cases (single segment, empty string, many segments) - TestFormatTrackBeadIDConsumerCompatibility: 3 test cases verifying the external ref format can be correctly parsed by consumers in convoy.go, model.go, feed/convoy.go, and web/fetcher.go The helper function includes godoc with examples showing expected behavior for different bead ID formats. --- internal/cmd/sling.go | 31 ++++++--- internal/cmd/sling_test.go | 133 +++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 internal/cmd/sling_test.go diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 011fb5c9..c92792ca 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -1240,15 +1240,7 @@ func createAutoConvoy(beadID, beadTitle string) (string, error) { } // Add tracking relation: convoy tracks the issue - // Format cross-prefix beads as external refs so bd can resolve them - trackBeadID := beadID - if !strings.HasPrefix(beadID, "hq-") { - parts := strings.SplitN(beadID, "-", 3) - if len(parts) >= 2 { - rigPrefix := parts[0] + "-" + parts[1] - trackBeadID = fmt.Sprintf("external:%s:%s", rigPrefix, beadID) - } - } + trackBeadID := formatTrackBeadID(beadID) depArgs := []string{"dep", "add", convoyID, trackBeadID, "--type=tracks"} depCmd := exec.Command("bd", depArgs...) depCmd.Dir = townBeads @@ -1408,3 +1400,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) + } + }) + } +}