Implement auto-routing for bd create (bd-ubu2)
- Add internal/routing package with DetectUserRole and DetermineTargetRepo - Add routing config schema (mode, default, maintainer, contributor) - Add --repo flag to bd create for explicit override - Integrate routing logic into create command - Test with contributor/maintainer roles and explicit override Part of bd-8hf (Auto-routing and maintainer detection)
This commit is contained in:
100
internal/routing/routing.go
Normal file
100
internal/routing/routing.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UserRole represents whether the user is a maintainer or contributor
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
Maintainer UserRole = "maintainer"
|
||||
Contributor UserRole = "contributor"
|
||||
)
|
||||
|
||||
// DetectUserRole determines if the user is a maintainer or contributor
|
||||
// based on git configuration and repository permissions.
|
||||
//
|
||||
// Detection strategy:
|
||||
// 1. Check if user has push access to origin (git remote -v shows write URL)
|
||||
// 2. Check git config for beads.role setting (explicit override)
|
||||
// 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()
|
||||
if err == nil {
|
||||
role := strings.TrimSpace(string(output))
|
||||
if role == string(Maintainer) {
|
||||
return Maintainer, nil
|
||||
}
|
||||
if role == string(Contributor) {
|
||||
return Contributor, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
if err != nil {
|
||||
// No remote or error - default to contributor
|
||||
return Contributor, nil
|
||||
}
|
||||
|
||||
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, "@") {
|
||||
return Maintainer, nil
|
||||
}
|
||||
|
||||
// HTTPS without credentials likely means read-only contributor
|
||||
return Contributor, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// DetermineTargetRepo determines which repo should receive a new issue
|
||||
// based on routing configuration and user role
|
||||
func DetermineTargetRepo(config *RoutingConfig, userRole UserRole, repoPath string) string {
|
||||
// Explicit override takes precedence
|
||||
if config.ExplicitOverride != "" {
|
||||
return config.ExplicitOverride
|
||||
}
|
||||
|
||||
// Auto mode: route based on user role
|
||||
if config.Mode == "auto" {
|
||||
if userRole == Maintainer && config.MaintainerRepo != "" {
|
||||
return config.MaintainerRepo
|
||||
}
|
||||
if userRole == Contributor && config.ContributorRepo != "" {
|
||||
return config.ContributorRepo
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default repo
|
||||
if config.DefaultRepo != "" {
|
||||
return config.DefaultRepo
|
||||
}
|
||||
|
||||
// No routing configured - use current repo
|
||||
return "."
|
||||
}
|
||||
90
internal/routing/routing_test.go
Normal file
90
internal/routing/routing_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"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
|
||||
role, err := DetectUserRole("/nonexistent/path/that/does/not/exist")
|
||||
if err != nil {
|
||||
t.Fatalf("DetectUserRole() error = %v, want nil", err)
|
||||
}
|
||||
if role != Contributor {
|
||||
t.Errorf("DetectUserRole() = %v, want %v (fallback)", role, Contributor)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user