When routing-based verification (verifyBeadExists) fails due to routes.jsonl configuration issues, gt sling now falls back to pattern matching via looksLikeBeadID to accept valid bead ID formats. The fix ensures: 1. verifyBeadExists is tried first (routing-based lookup) 2. verifyFormulaExists is tried second (formula check) 3. looksLikeBeadID pattern match is used as final fallback Also improved looksLikeBeadID to accept any 1-5 letter lowercase prefix followed by hyphen and alphanumeric chars. Fixes: gt sling bd-xxx failing with "not a valid bead or formula" when the bead exists but routing cannot find it. Closes: gt-9e8s5 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
558 lines
14 KiB
Go
558 lines
14 KiB
Go
package cmd
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseWispIDFromJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
wantID string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "new_epic_id",
|
|
json: `{"new_epic_id":"gt-wisp-abc","created":7,"phase":"vapor"}`,
|
|
wantID: "gt-wisp-abc",
|
|
},
|
|
{
|
|
name: "root_id legacy",
|
|
json: `{"root_id":"gt-wisp-legacy"}`,
|
|
wantID: "gt-wisp-legacy",
|
|
},
|
|
{
|
|
name: "result_id forward compat",
|
|
json: `{"result_id":"gt-wisp-result"}`,
|
|
wantID: "gt-wisp-result",
|
|
},
|
|
{
|
|
name: "precedence prefers new_epic_id",
|
|
json: `{"root_id":"gt-wisp-legacy","new_epic_id":"gt-wisp-new"}`,
|
|
wantID: "gt-wisp-new",
|
|
},
|
|
{
|
|
name: "missing id keys",
|
|
json: `{"created":7,"phase":"vapor"}`,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid JSON",
|
|
json: `{"new_epic_id":`,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotID, err := parseWispIDFromJSON([]byte(tt.json))
|
|
if (err != nil) != tt.wantErr {
|
|
t.Fatalf("parseWispIDFromJSON() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
if gotID != tt.wantID {
|
|
t.Fatalf("parseWispIDFromJSON() id = %q, want %q", gotID, tt.wantID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSlingFormulaOnBeadRoutesBDCommandsToTargetRig(t *testing.T) {
|
|
townRoot := t.TempDir()
|
|
|
|
// Minimal workspace marker so workspace.FindFromCwd() succeeds.
|
|
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
|
t.Fatalf("mkdir mayor/rig: %v", err)
|
|
}
|
|
|
|
// Create a rig path that owns gt-* beads, and a routes.jsonl pointing to it.
|
|
rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig")
|
|
if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil {
|
|
t.Fatalf("mkdir .beads: %v", err)
|
|
}
|
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
|
t.Fatalf("mkdir rigDir: %v", err)
|
|
}
|
|
routes := strings.Join([]string{
|
|
`{"prefix":"gt-","path":"gastown/mayor/rig"}`,
|
|
`{"prefix":"hq-","path":"."}`,
|
|
"",
|
|
}, "\n")
|
|
if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil {
|
|
t.Fatalf("write routes.jsonl: %v", err)
|
|
}
|
|
|
|
// Stub bd so we can observe the working directory for cook/wisp/bond.
|
|
binDir := filepath.Join(townRoot, "bin")
|
|
if err := os.MkdirAll(binDir, 0755); err != nil {
|
|
t.Fatalf("mkdir binDir: %v", err)
|
|
}
|
|
logPath := filepath.Join(townRoot, "bd.log")
|
|
bdPath := filepath.Join(binDir, "bd")
|
|
bdScript := `#!/bin/sh
|
|
set -e
|
|
echo "$(pwd)|$*" >> "${BD_LOG}"
|
|
if [ "$1" = "--no-daemon" ]; then
|
|
shift
|
|
fi
|
|
cmd="$1"
|
|
shift || true
|
|
case "$cmd" in
|
|
show)
|
|
echo '[{"title":"Test issue","status":"open","assignee":"","description":""}]'
|
|
;;
|
|
formula)
|
|
# formula show <name>
|
|
exit 0
|
|
;;
|
|
cook)
|
|
exit 0
|
|
;;
|
|
mol)
|
|
sub="$1"
|
|
shift || true
|
|
case "$sub" in
|
|
wisp)
|
|
echo '{"new_epic_id":"gt-wisp-xyz"}'
|
|
;;
|
|
bond)
|
|
echo '{"root_id":"gt-wisp-xyz"}'
|
|
;;
|
|
esac
|
|
;;
|
|
esac
|
|
exit 0
|
|
`
|
|
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
|
|
t.Fatalf("write bd stub: %v", err)
|
|
}
|
|
|
|
t.Setenv("BD_LOG", logPath)
|
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
t.Setenv(EnvGTRole, "mayor")
|
|
t.Setenv("GT_POLECAT", "")
|
|
t.Setenv("GT_CREW", "")
|
|
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("getwd: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
|
if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil {
|
|
t.Fatalf("chdir: %v", err)
|
|
}
|
|
|
|
// Ensure we don't leak global flag state across tests.
|
|
prevOn := slingOnTarget
|
|
prevVars := slingVars
|
|
prevDryRun := slingDryRun
|
|
prevNoConvoy := slingNoConvoy
|
|
t.Cleanup(func() {
|
|
slingOnTarget = prevOn
|
|
slingVars = prevVars
|
|
slingDryRun = prevDryRun
|
|
slingNoConvoy = prevNoConvoy
|
|
})
|
|
|
|
slingDryRun = false
|
|
slingNoConvoy = true
|
|
slingVars = nil
|
|
slingOnTarget = "gt-abc123"
|
|
|
|
if err := runSling(nil, []string{"mol-review"}); err != nil {
|
|
t.Fatalf("runSling: %v", err)
|
|
}
|
|
|
|
logBytes, err := os.ReadFile(logPath)
|
|
if err != nil {
|
|
t.Fatalf("read bd log: %v", err)
|
|
}
|
|
logLines := strings.Split(strings.TrimSpace(string(logBytes)), "\n")
|
|
|
|
wantDir := rigDir
|
|
if resolved, err := filepath.EvalSymlinks(wantDir); err == nil {
|
|
wantDir = resolved
|
|
}
|
|
gotCook := false
|
|
gotWisp := false
|
|
gotBond := false
|
|
|
|
for _, line := range logLines {
|
|
parts := strings.SplitN(line, "|", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
dir := parts[0]
|
|
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
|
|
dir = resolved
|
|
}
|
|
args := parts[1]
|
|
|
|
switch {
|
|
case strings.Contains(args, " cook "):
|
|
gotCook = true
|
|
if dir != wantDir {
|
|
t.Fatalf("bd cook ran in %q, want %q (args: %q)", dir, wantDir, args)
|
|
}
|
|
case strings.Contains(args, " mol wisp "):
|
|
gotWisp = true
|
|
if dir != wantDir {
|
|
t.Fatalf("bd mol wisp ran in %q, want %q (args: %q)", dir, wantDir, args)
|
|
}
|
|
case strings.Contains(args, " mol bond "):
|
|
gotBond = true
|
|
if dir != wantDir {
|
|
t.Fatalf("bd mol bond ran in %q, want %q (args: %q)", dir, wantDir, args)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !gotCook || !gotWisp || !gotBond {
|
|
t.Fatalf("missing expected bd commands: cook=%v wisp=%v bond=%v (log: %q)", gotCook, gotWisp, gotBond, string(logBytes))
|
|
}
|
|
}
|
|
|
|
// TestVerifyBeadExistsAllowStale reproduces the bug in gtl-ncq where beads
|
|
// visible via regular bd show fail with --no-daemon due to database sync issues.
|
|
// The fix uses --allow-stale to skip the sync check for existence verification.
|
|
func TestVerifyBeadExistsAllowStale(t *testing.T) {
|
|
townRoot := t.TempDir()
|
|
|
|
// Create minimal workspace structure
|
|
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
|
t.Fatalf("mkdir mayor/rig: %v", err)
|
|
}
|
|
|
|
// Create a stub bd that simulates the sync issue:
|
|
// - --no-daemon without --allow-stale fails (database out of sync)
|
|
// - --no-daemon with --allow-stale succeeds (skips sync check)
|
|
binDir := filepath.Join(townRoot, "bin")
|
|
if err := os.MkdirAll(binDir, 0755); err != nil {
|
|
t.Fatalf("mkdir binDir: %v", err)
|
|
}
|
|
bdPath := filepath.Join(binDir, "bd")
|
|
bdScript := `#!/bin/sh
|
|
# Check for --allow-stale flag
|
|
allow_stale=false
|
|
for arg in "$@"; do
|
|
if [ "$arg" = "--allow-stale" ]; then
|
|
allow_stale=true
|
|
fi
|
|
done
|
|
|
|
if [ "$1" = "--no-daemon" ]; then
|
|
if [ "$allow_stale" = "true" ]; then
|
|
# --allow-stale skips sync check, succeeds
|
|
echo '[{"title":"Test bead","status":"open","assignee":""}]'
|
|
exit 0
|
|
else
|
|
# Without --allow-stale, fails with sync error
|
|
echo '{"error":"Database out of sync with JSONL."}'
|
|
exit 1
|
|
fi
|
|
fi
|
|
# Daemon mode works
|
|
echo '[{"title":"Test bead","status":"open","assignee":""}]'
|
|
exit 0
|
|
`
|
|
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
|
|
t.Fatalf("write bd stub: %v", err)
|
|
}
|
|
|
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("getwd: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
|
if err := os.Chdir(townRoot); err != nil {
|
|
t.Fatalf("chdir: %v", err)
|
|
}
|
|
|
|
// EXPECTED: verifyBeadExists should use --no-daemon --allow-stale and succeed
|
|
beadID := "jv-v599"
|
|
err = verifyBeadExists(beadID)
|
|
if err != nil {
|
|
t.Errorf("verifyBeadExists(%q) failed: %v\nExpected --allow-stale to skip sync check", beadID, err)
|
|
}
|
|
}
|
|
|
|
// TestSlingWithAllowStale tests the full gt sling flow with --allow-stale fix.
|
|
// This is an integration test for the gtl-ncq bug.
|
|
func TestSlingWithAllowStale(t *testing.T) {
|
|
townRoot := t.TempDir()
|
|
|
|
// Create minimal workspace structure
|
|
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
|
t.Fatalf("mkdir mayor/rig: %v", err)
|
|
}
|
|
|
|
// Create stub bd that respects --allow-stale
|
|
binDir := filepath.Join(townRoot, "bin")
|
|
if err := os.MkdirAll(binDir, 0755); err != nil {
|
|
t.Fatalf("mkdir binDir: %v", err)
|
|
}
|
|
bdPath := filepath.Join(binDir, "bd")
|
|
bdScript := `#!/bin/sh
|
|
# Check for --allow-stale flag
|
|
allow_stale=false
|
|
for arg in "$@"; do
|
|
if [ "$arg" = "--allow-stale" ]; then
|
|
allow_stale=true
|
|
fi
|
|
done
|
|
|
|
if [ "$1" = "--no-daemon" ]; then
|
|
shift
|
|
cmd="$1"
|
|
if [ "$cmd" = "show" ]; then
|
|
if [ "$allow_stale" = "true" ]; then
|
|
echo '[{"title":"Synced bead","status":"open","assignee":""}]'
|
|
exit 0
|
|
fi
|
|
echo '{"error":"Database out of sync"}'
|
|
exit 1
|
|
fi
|
|
exit 0
|
|
fi
|
|
cmd="$1"
|
|
shift || true
|
|
case "$cmd" in
|
|
show)
|
|
echo '[{"title":"Synced bead","status":"open","assignee":""}]'
|
|
;;
|
|
update)
|
|
exit 0
|
|
;;
|
|
esac
|
|
exit 0
|
|
`
|
|
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
|
|
t.Fatalf("write bd stub: %v", err)
|
|
}
|
|
|
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
t.Setenv(EnvGTRole, "crew")
|
|
t.Setenv("GT_CREW", "jv")
|
|
t.Setenv("GT_POLECAT", "")
|
|
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("getwd: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
|
if err := os.Chdir(townRoot); err != nil {
|
|
t.Fatalf("chdir: %v", err)
|
|
}
|
|
|
|
// Save and restore global flags
|
|
prevDryRun := slingDryRun
|
|
prevNoConvoy := slingNoConvoy
|
|
t.Cleanup(func() {
|
|
slingDryRun = prevDryRun
|
|
slingNoConvoy = prevNoConvoy
|
|
})
|
|
|
|
slingDryRun = true
|
|
slingNoConvoy = true
|
|
|
|
// EXPECTED: gt sling should use daemon mode and succeed
|
|
// ACTUAL: verifyBeadExists uses --no-daemon and fails with sync error
|
|
beadID := "jv-v599"
|
|
err = runSling(nil, []string{beadID})
|
|
if err != nil {
|
|
// Check if it's the specific error we're testing for
|
|
if strings.Contains(err.Error(), "is not a valid bead or formula") {
|
|
t.Errorf("gt sling failed to recognize bead %q: %v\nExpected to use daemon mode, but used --no-daemon which fails when DB out of sync", beadID, err)
|
|
} else {
|
|
// Some other error - might be expected in dry-run mode
|
|
t.Logf("gt sling returned error (may be expected in test): %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLooksLikeBeadID tests the bead ID pattern recognition function.
|
|
// This ensures gt sling accepts bead IDs even when routing-based verification fails.
|
|
// Fixes: gt sling bd-ka761 failing with 'not a valid bead or formula'
|
|
//
|
|
// Note: looksLikeBeadID is a fallback check in sling. The actual sling flow is:
|
|
// 1. Try verifyBeadExists (routing-based lookup)
|
|
// 2. Try verifyFormulaExists (formula check)
|
|
// 3. Fall back to looksLikeBeadID pattern match
|
|
// So "mol-release" matches the pattern but won't be treated as bead in practice
|
|
// because it would be caught by formula verification first.
|
|
func TestLooksLikeBeadID(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want bool
|
|
}{
|
|
// Valid bead IDs - should return true
|
|
{"gt-abc123", true},
|
|
{"bd-ka761", true},
|
|
{"hq-cv-abc", true},
|
|
{"ap-qtsup.16", true},
|
|
{"beads-xyz", true},
|
|
{"jv-v599", true},
|
|
{"gt-9e8s5", true},
|
|
{"hq-00gyg", true},
|
|
|
|
// Short prefixes that match pattern (but may be formulas in practice)
|
|
{"mol-release", true}, // 3-char prefix matches pattern (formula check runs first in sling)
|
|
{"mol-abc123", true}, // 3-char prefix matches pattern
|
|
|
|
// Non-bead strings - should return false
|
|
{"formula-name", false}, // "formula" is 7 chars (> 5)
|
|
{"mayor", false}, // no hyphen
|
|
{"gastown", false}, // no hyphen
|
|
{"deacon/dogs", false}, // contains slash
|
|
{"", false}, // empty
|
|
{"-abc", false}, // starts with hyphen
|
|
{"GT-abc", false}, // uppercase prefix
|
|
{"123-abc", false}, // numeric prefix
|
|
{"a-", false}, // nothing after hyphen
|
|
{"aaaaaa-b", false}, // prefix too long (6 chars)
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := looksLikeBeadID(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("looksLikeBeadID(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|