From 12b797781db403398d7d6a7521092c509b4022c4 Mon Sep 17 00:00:00 2001 From: Jordan Hubbard Date: Mon, 29 Dec 2025 00:55:44 -0400 Subject: [PATCH] test: expand routing and compact coverage --- internal/compact/git.go | 15 ++-- internal/compact/git_test.go | 30 ++++++++ internal/routing/routing.go | 44 ++++++------ internal/routing/routing_test.go | 119 ++++++++++++++++++++++++++++--- 4 files changed, 168 insertions(+), 40 deletions(-) create mode 100644 internal/compact/git_test.go diff --git a/internal/compact/git.go b/internal/compact/git.go index c8795378..aa87b24e 100644 --- a/internal/compact/git.go +++ b/internal/compact/git.go @@ -1,21 +1,20 @@ package compact import ( - "bytes" "os/exec" "strings" ) +var gitExec = func(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).Output() +} + // GetCurrentCommitHash returns the current git HEAD commit hash. // Returns empty string if not in a git repository or if git command fails. func GetCurrentCommitHash() string { - cmd := exec.Command("git", "rev-parse", "HEAD") - var out bytes.Buffer - cmd.Stdout = &out - - if err := cmd.Run(); err != nil { + output, err := gitExec("git", "rev-parse", "HEAD") + if err != nil { return "" } - - return strings.TrimSpace(out.String()) + return strings.TrimSpace(string(output)) } diff --git a/internal/compact/git_test.go b/internal/compact/git_test.go new file mode 100644 index 00000000..27c847e0 --- /dev/null +++ b/internal/compact/git_test.go @@ -0,0 +1,30 @@ +package compact + +import ( + "errors" + "testing" +) + +func TestGetCurrentCommitHashSuccess(t *testing.T) { + orig := gitExec + gitExec = func(string, ...string) ([]byte, error) { + return []byte("abc123\n"), nil + } + t.Cleanup(func() { gitExec = orig }) + + if got := GetCurrentCommitHash(); got != "abc123" { + t.Fatalf("expected trimmed hash, got %q", got) + } +} + +func TestGetCurrentCommitHashError(t *testing.T) { + orig := gitExec + gitExec = func(string, ...string) ([]byte, error) { + return nil, errors.New("boom") + } + t.Cleanup(func() { gitExec = orig }) + + if got := GetCurrentCommitHash(); got != "" { + t.Fatalf("expected empty string on error, got %q", got) + } +} diff --git a/internal/routing/routing.go b/internal/routing/routing.go index bb1e70be..342ddd38 100644 --- a/internal/routing/routing.go +++ b/internal/routing/routing.go @@ -5,6 +5,14 @@ import ( "strings" ) +var gitCommandRunner = func(repoPath string, args ...string) ([]byte, error) { + cmd := exec.Command("git", args...) + if repoPath != "" { + cmd.Dir = repoPath + } + return cmd.Output() +} + // UserRole represents whether the user is a maintainer or contributor type UserRole string @@ -22,11 +30,7 @@ const ( // 3. Fall back to contributor if uncertain func DetectUserRole(repoPath string) (UserRole, error) { // First check for explicit role in git config - cmd := exec.Command("git", "config", "--get", "beads.role") - if repoPath != "" { - cmd.Dir = repoPath - } - output, err := cmd.Output() + output, err := gitCommandRunner(repoPath, "config", "--get", "beads.role") if err == nil { role := strings.TrimSpace(string(output)) if role == string(Maintainer) { @@ -38,18 +42,10 @@ func DetectUserRole(repoPath string) (UserRole, error) { } // Check push access by examining remote URL - cmd = exec.Command("git", "remote", "get-url", "--push", "origin") - if repoPath != "" { - cmd.Dir = repoPath - } - output, err = cmd.Output() + output, err = gitCommandRunner(repoPath, "remote", "get-url", "--push", "origin") if err != nil { // Fallback to standard fetch URL if push URL fails (some git versions/configs) - cmd = exec.Command("git", "remote", "get-url", "origin") - if repoPath != "" { - cmd.Dir = repoPath - } - output, err = cmd.Output() + output, err = gitCommandRunner(repoPath, "remote", "get-url", "origin") if err != nil { // No remote or error - default to contributor return Contributor, nil @@ -57,13 +53,13 @@ func DetectUserRole(repoPath string) (UserRole, error) { } pushURL := strings.TrimSpace(string(output)) - + // Check if URL indicates write access // SSH URLs (git@github.com:user/repo.git) typically indicate write access // HTTPS with token/password also indicates write access - if strings.HasPrefix(pushURL, "git@") || - strings.HasPrefix(pushURL, "ssh://") || - strings.Contains(pushURL, "@") { + if strings.HasPrefix(pushURL, "git@") || + strings.HasPrefix(pushURL, "ssh://") || + strings.Contains(pushURL, "@") { return Maintainer, nil } @@ -73,11 +69,11 @@ func DetectUserRole(repoPath string) (UserRole, error) { // RoutingConfig defines routing rules for issues type RoutingConfig struct { - Mode string // "auto" or "explicit" - DefaultRepo string // Default repo for new issues - MaintainerRepo string // Repo for maintainers (in auto mode) - ContributorRepo string // Repo for contributors (in auto mode) - ExplicitOverride string // Explicit --repo flag override + Mode string // "auto" or "explicit" + DefaultRepo string // Default repo for new issues + MaintainerRepo string // Repo for maintainers (in auto mode) + ContributorRepo string // Repo for contributors (in auto mode) + ExplicitOverride string // Explicit --repo flag override } // DetermineTargetRepo determines which repo should receive a new issue diff --git a/internal/routing/routing_test.go b/internal/routing/routing_test.go index 13e19906..e000988c 100644 --- a/internal/routing/routing_test.go +++ b/internal/routing/routing_test.go @@ -1,6 +1,8 @@ package routing import ( + "errors" + "reflect" "testing" ) @@ -15,11 +17,11 @@ func TestDetermineTargetRepo(t *testing.T) { { name: "explicit override takes precedence", config: &RoutingConfig{ - Mode: "auto", - DefaultRepo: "~/planning", - MaintainerRepo: ".", - ContributorRepo: "~/contributor-planning", - ExplicitOverride: "/tmp/custom", + Mode: "auto", + DefaultRepo: "~/planning", + MaintainerRepo: ".", + ContributorRepo: "~/contributor-planning", + ExplicitOverride: "/tmp/custom", }, userRole: Maintainer, repoPath: ".", @@ -97,9 +99,9 @@ func TestExtractPrefix(t *testing.T) { {"gt-abc123", "gt-"}, {"bd-xyz", "bd-"}, {"hq-1234", "hq-"}, - {"abc123", ""}, // No hyphen - {"", ""}, // Empty string - {"-abc", "-"}, // Starts with hyphen + {"abc123", ""}, // No hyphen + {"", ""}, // Empty string + {"-abc", "-"}, // Starts with hyphen } for _, tt := range tests { @@ -142,3 +144,104 @@ func TestResolveToExternalRef(t *testing.T) { 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_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_DefaultContributor(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) + } +}