When no git remote is configured, DetectUserRole() now defaults to Maintainer instead of Contributor. This fixes issue routing for: 1. New personal projects (no remote configured yet) 2. Intentionally local-only repositories Previously, issues would silently route to ~/.beads-planning instead of the local .beads/ directory. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
417 lines
12 KiB
Go
417 lines
12 KiB
Go
package routing
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
func TestDetermineTargetRepo(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config *RoutingConfig
|
|
userRole UserRole
|
|
repoPath string
|
|
want string
|
|
}{
|
|
{
|
|
name: "explicit override takes precedence",
|
|
config: &RoutingConfig{
|
|
Mode: "auto",
|
|
DefaultRepo: "~/planning",
|
|
MaintainerRepo: ".",
|
|
ContributorRepo: "~/contributor-planning",
|
|
ExplicitOverride: "/tmp/custom",
|
|
},
|
|
userRole: Maintainer,
|
|
repoPath: ".",
|
|
want: "/tmp/custom",
|
|
},
|
|
{
|
|
name: "auto mode - maintainer uses maintainer repo",
|
|
config: &RoutingConfig{
|
|
Mode: "auto",
|
|
MaintainerRepo: ".",
|
|
ContributorRepo: "~/contributor-planning",
|
|
},
|
|
userRole: Maintainer,
|
|
repoPath: ".",
|
|
want: ".",
|
|
},
|
|
{
|
|
name: "auto mode - contributor uses contributor repo",
|
|
config: &RoutingConfig{
|
|
Mode: "auto",
|
|
MaintainerRepo: ".",
|
|
ContributorRepo: "~/contributor-planning",
|
|
},
|
|
userRole: Contributor,
|
|
repoPath: ".",
|
|
want: "~/contributor-planning",
|
|
},
|
|
{
|
|
name: "explicit mode uses default",
|
|
config: &RoutingConfig{
|
|
Mode: "explicit",
|
|
DefaultRepo: "~/planning",
|
|
},
|
|
userRole: Maintainer,
|
|
repoPath: ".",
|
|
want: "~/planning",
|
|
},
|
|
{
|
|
name: "no config defaults to current directory",
|
|
config: &RoutingConfig{
|
|
Mode: "auto",
|
|
},
|
|
userRole: Maintainer,
|
|
repoPath: ".",
|
|
want: ".",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := DetermineTargetRepo(tt.config, tt.userRole, tt.repoPath)
|
|
if got != tt.want {
|
|
t.Errorf("DetermineTargetRepo() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDetectUserRole_Fallback(t *testing.T) {
|
|
// Test fallback behavior when git is not available - local projects default to maintainer
|
|
role, err := DetectUserRole("/nonexistent/path/that/does/not/exist")
|
|
if err != nil {
|
|
t.Fatalf("DetectUserRole() error = %v, want nil", err)
|
|
}
|
|
if role != Maintainer {
|
|
t.Errorf("DetectUserRole() = %v, want %v (local project fallback)", role, Maintainer)
|
|
}
|
|
}
|
|
|
|
func TestExtractPrefix(t *testing.T) {
|
|
tests := []struct {
|
|
id string
|
|
want string
|
|
}{
|
|
{"gt-abc123", "gt-"},
|
|
{"bd-xyz", "bd-"},
|
|
{"hq-1234", "hq-"},
|
|
{"abc123", ""}, // No hyphen
|
|
{"", ""}, // Empty string
|
|
{"-abc", "-"}, // Starts with hyphen
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.id, func(t *testing.T) {
|
|
got := ExtractPrefix(tt.id)
|
|
if got != tt.want {
|
|
t.Errorf("ExtractPrefix(%q) = %q, want %q", tt.id, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractProjectFromPath(t *testing.T) {
|
|
tests := []struct {
|
|
path string
|
|
want string
|
|
}{
|
|
{"beads/mayor/rig", "beads"},
|
|
{"gastown/crew/max", "gastown"},
|
|
{"simple", "simple"},
|
|
{"", ""},
|
|
{"/absolute/path", ""}, // Starts with /, first component is empty
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.path, func(t *testing.T) {
|
|
got := ExtractProjectFromPath(tt.path)
|
|
if got != tt.want {
|
|
t.Errorf("ExtractProjectFromPath(%q) = %q, want %q", tt.path, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveToExternalRef(t *testing.T) {
|
|
// This test is limited since it requires a routes.jsonl file
|
|
// Just test that it returns empty string for nonexistent directory
|
|
got := ResolveToExternalRef("bd-abc", "/nonexistent/path")
|
|
if got != "" {
|
|
t.Errorf("ResolveToExternalRef() = %q, want empty string for nonexistent path", got)
|
|
}
|
|
}
|
|
|
|
type gitCall struct {
|
|
repo string
|
|
args []string
|
|
}
|
|
|
|
type gitResponse struct {
|
|
expect gitCall
|
|
output string
|
|
err error
|
|
}
|
|
|
|
type gitStub struct {
|
|
t *testing.T
|
|
responses []gitResponse
|
|
idx int
|
|
}
|
|
|
|
func (s *gitStub) run(repo string, args ...string) ([]byte, error) {
|
|
if s.idx >= len(s.responses) {
|
|
s.t.Fatalf("unexpected git call %v in repo %s", args, repo)
|
|
}
|
|
resp := s.responses[s.idx]
|
|
s.idx++
|
|
if resp.expect.repo != repo {
|
|
s.t.Fatalf("repo mismatch: got %q want %q", repo, resp.expect.repo)
|
|
}
|
|
if !reflect.DeepEqual(resp.expect.args, args) {
|
|
s.t.Fatalf("args mismatch: got %v want %v", args, resp.expect.args)
|
|
}
|
|
return []byte(resp.output), resp.err
|
|
}
|
|
|
|
func (s *gitStub) verify() {
|
|
if s.idx != len(s.responses) {
|
|
s.t.Fatalf("expected %d git calls, got %d", len(s.responses), s.idx)
|
|
}
|
|
}
|
|
|
|
func TestDetectUserRole_ConfigOverrideMaintainer(t *testing.T) {
|
|
orig := gitCommandRunner
|
|
stub := &gitStub{t: t, responses: []gitResponse{
|
|
{expect: gitCall{"", []string{"config", "--get", "beads.role"}}, output: "maintainer\n"},
|
|
}}
|
|
gitCommandRunner = stub.run
|
|
t.Cleanup(func() {
|
|
gitCommandRunner = orig
|
|
stub.verify()
|
|
})
|
|
|
|
role, err := DetectUserRole("")
|
|
if err != nil {
|
|
t.Fatalf("DetectUserRole error = %v", err)
|
|
}
|
|
if role != Maintainer {
|
|
t.Fatalf("expected %s, got %s", Maintainer, role)
|
|
}
|
|
}
|
|
|
|
func TestDetectUserRole_ConfigOverrideContributor(t *testing.T) {
|
|
orig := gitCommandRunner
|
|
stub := &gitStub{t: t, responses: []gitResponse{
|
|
{expect: gitCall{"/repo", []string{"config", "--get", "beads.role"}}, output: "contributor\n"},
|
|
}}
|
|
gitCommandRunner = stub.run
|
|
t.Cleanup(func() {
|
|
gitCommandRunner = orig
|
|
stub.verify()
|
|
})
|
|
|
|
role, err := DetectUserRole("/repo")
|
|
if err != nil {
|
|
t.Fatalf("DetectUserRole error = %v", err)
|
|
}
|
|
if role != Contributor {
|
|
t.Fatalf("expected %s, got %s", Contributor, role)
|
|
}
|
|
}
|
|
|
|
func TestDetectUserRole_PushURLMaintainer(t *testing.T) {
|
|
orig := gitCommandRunner
|
|
stub := &gitStub{t: t, responses: []gitResponse{
|
|
{expect: gitCall{"/repo", []string{"config", "--get", "beads.role"}}, output: "unknown"},
|
|
{expect: gitCall{"/repo", []string{"remote", "get-url", "--push", "origin"}}, output: "git@github.com:owner/repo.git"},
|
|
}}
|
|
gitCommandRunner = stub.run
|
|
t.Cleanup(func() {
|
|
gitCommandRunner = orig
|
|
stub.verify()
|
|
})
|
|
|
|
role, err := DetectUserRole("/repo")
|
|
if err != nil {
|
|
t.Fatalf("DetectUserRole error = %v", err)
|
|
}
|
|
if role != Maintainer {
|
|
t.Fatalf("expected %s, got %s", Maintainer, role)
|
|
}
|
|
}
|
|
|
|
func TestDetectUserRole_HTTPSCredentialsMaintainer(t *testing.T) {
|
|
orig := gitCommandRunner
|
|
stub := &gitStub{t: t, responses: []gitResponse{
|
|
{expect: gitCall{"/repo", []string{"config", "--get", "beads.role"}}, output: ""},
|
|
{expect: gitCall{"/repo", []string{"remote", "get-url", "--push", "origin"}}, output: "https://token@github.com/owner/repo.git"},
|
|
}}
|
|
gitCommandRunner = stub.run
|
|
t.Cleanup(func() {
|
|
gitCommandRunner = orig
|
|
stub.verify()
|
|
})
|
|
|
|
role, err := DetectUserRole("/repo")
|
|
if err != nil {
|
|
t.Fatalf("DetectUserRole error = %v", err)
|
|
}
|
|
if role != Maintainer {
|
|
t.Fatalf("expected %s, got %s", Maintainer, role)
|
|
}
|
|
}
|
|
|
|
func TestDetectUserRole_HTTPSNoCredentialsContributor(t *testing.T) {
|
|
orig := gitCommandRunner
|
|
stub := &gitStub{t: t, responses: []gitResponse{
|
|
{expect: gitCall{"", []string{"config", "--get", "beads.role"}}, err: errors.New("missing")},
|
|
{expect: gitCall{"", []string{"remote", "get-url", "--push", "origin"}}, err: errors.New("no push")},
|
|
{expect: gitCall{"", []string{"remote", "get-url", "origin"}}, output: "https://github.com/owner/repo.git"},
|
|
}}
|
|
gitCommandRunner = stub.run
|
|
t.Cleanup(func() {
|
|
gitCommandRunner = orig
|
|
stub.verify()
|
|
})
|
|
|
|
role, err := DetectUserRole("")
|
|
if err != nil {
|
|
t.Fatalf("DetectUserRole error = %v", err)
|
|
}
|
|
if role != Contributor {
|
|
t.Fatalf("expected %s, got %s", Contributor, role)
|
|
}
|
|
}
|
|
|
|
func TestDetectUserRole_NoRemoteMaintainer(t *testing.T) {
|
|
// When no git remote is configured, default to maintainer (local project)
|
|
orig := gitCommandRunner
|
|
stub := &gitStub{t: t, responses: []gitResponse{
|
|
{expect: gitCall{"/local", []string{"config", "--get", "beads.role"}}, err: errors.New("missing")},
|
|
{expect: gitCall{"/local", []string{"remote", "get-url", "--push", "origin"}}, err: errors.New("no remote")},
|
|
{expect: gitCall{"/local", []string{"remote", "get-url", "origin"}}, err: errors.New("no remote")},
|
|
}}
|
|
gitCommandRunner = stub.run
|
|
t.Cleanup(func() {
|
|
gitCommandRunner = orig
|
|
stub.verify()
|
|
})
|
|
|
|
role, err := DetectUserRole("/local")
|
|
if err != nil {
|
|
t.Fatalf("DetectUserRole error = %v", err)
|
|
}
|
|
if role != Maintainer {
|
|
t.Fatalf("expected %s for local project with no remote, got %s", Maintainer, role)
|
|
}
|
|
}
|
|
|
|
// TestFindTownRoutes_SymlinkedBeadsDir verifies that findTownRoutes correctly
|
|
// handles symlinked .beads directories by using findTownRootFromCWD() instead of
|
|
// walking up from the beadsDir path.
|
|
//
|
|
// Scenario: ~/gt/.beads is a symlink to ~/gt/olympus/.beads
|
|
// Before fix: walking up from ~/gt/olympus/.beads finds ~/gt/olympus (WRONG)
|
|
// After fix: findTownRootFromCWD() walks up from CWD to find mayor/town.json at ~/gt
|
|
func TestFindTownRoutes_SymlinkedBeadsDir(t *testing.T) {
|
|
// Create temporary directory structure simulating Gas Town:
|
|
// tmpDir/
|
|
// mayor/
|
|
// town.json <- town root marker
|
|
// olympus/ <- actual beads storage
|
|
// .beads/
|
|
// routes.jsonl
|
|
// .beads -> olympus/.beads <- symlink
|
|
// daedalus/
|
|
// mayor/
|
|
// rig/
|
|
// .beads/ <- target rig
|
|
tmpDir, err := os.MkdirTemp("", "routing-symlink-test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Resolve symlinks in tmpDir (macOS /var -> /private/var)
|
|
tmpDir, err = filepath.EvalSymlinks(tmpDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create mayor/town.json to mark town root
|
|
mayorDir := filepath.Join(tmpDir, "mayor")
|
|
if err := os.MkdirAll(mayorDir, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
townJSON := filepath.Join(mayorDir, "town.json")
|
|
if err := os.WriteFile(townJSON, []byte(`{"name": "test-town"}`), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create olympus/.beads with routes.jsonl
|
|
olympusBeadsDir := filepath.Join(tmpDir, "olympus", ".beads")
|
|
if err := os.MkdirAll(olympusBeadsDir, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
routesContent := `{"prefix": "gt-", "path": "daedalus/mayor/rig"}
|
|
`
|
|
routesPath := filepath.Join(olympusBeadsDir, "routes.jsonl")
|
|
if err := os.WriteFile(routesPath, []byte(routesContent), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create daedalus/mayor/rig/.beads as target rig
|
|
daedalusBeadsDir := filepath.Join(tmpDir, "daedalus", "mayor", "rig", ".beads")
|
|
if err := os.MkdirAll(daedalusBeadsDir, 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Create metadata.json so the rig is recognized as valid
|
|
if err := os.WriteFile(filepath.Join(daedalusBeadsDir, "metadata.json"), []byte(`{}`), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create symlink: tmpDir/.beads -> olympus/.beads
|
|
symlinkPath := filepath.Join(tmpDir, ".beads")
|
|
if err := os.Symlink(olympusBeadsDir, symlinkPath); err != nil {
|
|
t.Skip("Cannot create symlinks on this system (may require admin on Windows)")
|
|
}
|
|
|
|
// Change to the town root directory - this simulates the user running bd from ~/gt
|
|
// The fix uses findTownRootFromCWD() which needs CWD to be inside the town
|
|
t.Chdir(tmpDir)
|
|
|
|
// Simulate what happens when FindBeadsDir() returns the resolved symlink path
|
|
// (this is what CanonicalizePath does)
|
|
resolvedBeadsDir := olympusBeadsDir // This is what would be passed to findTownRoutes
|
|
|
|
// Call findTownRoutes with the resolved symlink path
|
|
routes, townRoot := findTownRoutes(resolvedBeadsDir)
|
|
|
|
// Verify we got the routes
|
|
if len(routes) == 0 {
|
|
t.Fatal("findTownRoutes returned no routes")
|
|
}
|
|
|
|
// Verify the town root is correct (should be tmpDir, NOT tmpDir/olympus)
|
|
if townRoot != tmpDir {
|
|
t.Errorf("findTownRoutes returned wrong townRoot:\n got: %s\n want: %s", townRoot, tmpDir)
|
|
}
|
|
|
|
// Verify route resolution works - the route should resolve to the correct path
|
|
expectedRigPath := filepath.Join(tmpDir, "daedalus", "mayor", "rig", ".beads")
|
|
for _, route := range routes {
|
|
if route.Prefix == "gt-" {
|
|
actualPath := filepath.Join(townRoot, route.Path, ".beads")
|
|
if actualPath != expectedRigPath {
|
|
t.Errorf("Route resolution failed:\n got: %s\n want: %s", actualPath, expectedRigPath)
|
|
}
|
|
}
|
|
}
|
|
}
|