fix(routing): complete bd init --contributor routing (bd-6x6g) (#1088)
Implements the missing contributor routing logic so bd init --contributor actually works. Contributors' issues automatically route to ~/.beads-planning/ while maintainers' issues stay local.
This commit is contained in:
committed by
GitHub
parent
b9d2799d29
commit
31239495f1
929
cmd/bd/contributor_routing_e2e_test.go
Normal file
929
cmd/bd/contributor_routing_e2e_test.go
Normal file
@@ -0,0 +1,929 @@
|
||||
// contributor_routing_e2e_test.go - E2E tests for contributor routing
|
||||
//
|
||||
// These tests verify that issues are correctly routed to the planning repo
|
||||
// when the user is detected as a contributor with auto-routing enabled.
|
||||
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/routing"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// TestContributorRoutingTracer is the Phase 1 tracer bullet test.
|
||||
// It proves that:
|
||||
// 1. ExpandPath correctly expands ~ and relative paths
|
||||
// 2. Routing config is correctly read (including backward compat)
|
||||
// 3. DetermineTargetRepo returns the correct repo for contributors
|
||||
//
|
||||
// Full store switching is deferred to Phase 2.
|
||||
func TestContributorRoutingTracer(t *testing.T) {
|
||||
t.Run("ExpandPath_tilde_expansion", func(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skipf("cannot get home dir: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"~/foo", filepath.Join(home, "foo")},
|
||||
{"~/bar/baz", filepath.Join(home, "bar", "baz")},
|
||||
{".", "."},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := routing.ExpandPath(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("ExpandPath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DetermineTargetRepo_contributor_routes_to_planning", func(t *testing.T) {
|
||||
config := &routing.RoutingConfig{
|
||||
Mode: "auto",
|
||||
ContributorRepo: "~/.beads-planning",
|
||||
}
|
||||
|
||||
got := routing.DetermineTargetRepo(config, routing.Contributor, ".")
|
||||
if got != "~/.beads-planning" {
|
||||
t.Errorf("DetermineTargetRepo() = %q, want %q", got, "~/.beads-planning")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DetermineTargetRepo_maintainer_stays_local", func(t *testing.T) {
|
||||
config := &routing.RoutingConfig{
|
||||
Mode: "auto",
|
||||
MaintainerRepo: ".",
|
||||
ContributorRepo: "~/.beads-planning",
|
||||
}
|
||||
|
||||
got := routing.DetermineTargetRepo(config, routing.Maintainer, ".")
|
||||
if got != "." {
|
||||
t.Errorf("DetermineTargetRepo() = %q, want %q", got, ".")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("E2E_routing_decision_with_store", func(t *testing.T) {
|
||||
// Set up temporary directory structure
|
||||
tmpDir := t.TempDir()
|
||||
projectDir := filepath.Join(tmpDir, "project")
|
||||
planningDir := filepath.Join(tmpDir, "planning")
|
||||
|
||||
// Create project .beads directory
|
||||
projectBeadsDir := filepath.Join(projectDir, ".beads")
|
||||
if err := os.MkdirAll(projectBeadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create project .beads dir: %v", err)
|
||||
}
|
||||
|
||||
// Create planning .beads directory
|
||||
planningBeadsDir := filepath.Join(planningDir, ".beads")
|
||||
if err := os.MkdirAll(planningBeadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create planning .beads dir: %v", err)
|
||||
}
|
||||
|
||||
// Initialize project database
|
||||
projectDBPath := filepath.Join(projectBeadsDir, "beads.db")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
projectStore, err := sqlite.New(ctx, projectDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create project store: %v", err)
|
||||
}
|
||||
defer projectStore.Close()
|
||||
|
||||
// Set routing config in project store (canonical keys)
|
||||
if err := projectStore.SetConfig(ctx, "routing.mode", "auto"); err != nil {
|
||||
t.Fatalf("failed to set routing.mode: %v", err)
|
||||
}
|
||||
if err := projectStore.SetConfig(ctx, "routing.contributor", planningDir); err != nil {
|
||||
t.Fatalf("failed to set routing.contributor: %v", err)
|
||||
}
|
||||
|
||||
// Verify config was stored correctly
|
||||
mode, err := projectStore.GetConfig(ctx, "routing.mode")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get routing.mode: %v", err)
|
||||
}
|
||||
if mode != "auto" {
|
||||
t.Errorf("routing.mode = %q, want %q", mode, "auto")
|
||||
}
|
||||
|
||||
contributorPath, err := projectStore.GetConfig(ctx, "routing.contributor")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get routing.contributor: %v", err)
|
||||
}
|
||||
if contributorPath != planningDir {
|
||||
t.Errorf("routing.contributor = %q, want %q", contributorPath, planningDir)
|
||||
}
|
||||
|
||||
// Build routing config from stored values
|
||||
routingConfig := &routing.RoutingConfig{
|
||||
Mode: mode,
|
||||
ContributorRepo: contributorPath,
|
||||
}
|
||||
|
||||
// Verify routing decision for contributor
|
||||
targetRepo := routing.DetermineTargetRepo(routingConfig, routing.Contributor, projectDir)
|
||||
if targetRepo != planningDir {
|
||||
t.Errorf("DetermineTargetRepo() = %q, want %q", targetRepo, planningDir)
|
||||
}
|
||||
|
||||
// Verify routing decision for maintainer stays local
|
||||
targetRepo = routing.DetermineTargetRepo(routingConfig, routing.Maintainer, projectDir)
|
||||
if targetRepo != "." {
|
||||
t.Errorf("DetermineTargetRepo() for maintainer = %q, want %q", targetRepo, ".")
|
||||
}
|
||||
|
||||
// Initialize planning database and verify we can create issues there
|
||||
planningDBPath := filepath.Join(planningBeadsDir, "beads.db")
|
||||
planningStore, err := sqlite.New(ctx, planningDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create planning store: %v", err)
|
||||
}
|
||||
defer planningStore.Close()
|
||||
|
||||
// Initialize planning store with required config
|
||||
if err := planningStore.SetConfig(ctx, "issue_prefix", "plan-"); err != nil {
|
||||
t.Fatalf("failed to set issue_prefix in planning store: %v", err)
|
||||
}
|
||||
|
||||
// Create a test issue in planning store (simulating what Phase 2 will do)
|
||||
issue := &types.Issue{
|
||||
Title: "Test contributor issue",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
}
|
||||
|
||||
if err := planningStore.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("failed to create issue in planning store: %v", err)
|
||||
}
|
||||
|
||||
// Verify issue exists in planning store
|
||||
retrieved, err := planningStore.GetIssue(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get issue from planning store: %v", err)
|
||||
}
|
||||
if retrieved.Title != "Test contributor issue" {
|
||||
t.Errorf("issue title = %q, want %q", retrieved.Title, "Test contributor issue")
|
||||
}
|
||||
|
||||
// Verify issue does NOT exist in project store (isolation check)
|
||||
projectIssue, _ := projectStore.GetIssue(ctx, issue.ID)
|
||||
if projectIssue != nil {
|
||||
t.Error("issue should NOT exist in project store (isolation failure)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBackwardCompatContributorConfig verifies legacy contributor.* keys still work
|
||||
func TestBackwardCompatContributorConfig(t *testing.T) {
|
||||
// Set up temporary directory
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
store, err := sqlite.New(ctx, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Set LEGACY contributor.* keys (what old versions of bd init --contributor would set)
|
||||
if err := store.SetConfig(ctx, "contributor.auto_route", "true"); err != nil {
|
||||
t.Fatalf("failed to set contributor.auto_route: %v", err)
|
||||
}
|
||||
if err := store.SetConfig(ctx, "contributor.planning_repo", "/legacy/planning"); err != nil {
|
||||
t.Fatalf("failed to set contributor.planning_repo: %v", err)
|
||||
}
|
||||
|
||||
// Simulate backward compat read (as done in create.go)
|
||||
routingMode, _ := store.GetConfig(ctx, "routing.mode")
|
||||
contributorRepo, _ := store.GetConfig(ctx, "routing.contributor")
|
||||
|
||||
// Fallback to legacy keys
|
||||
if routingMode == "" {
|
||||
legacyAutoRoute, _ := store.GetConfig(ctx, "contributor.auto_route")
|
||||
if legacyAutoRoute == "true" {
|
||||
routingMode = "auto"
|
||||
}
|
||||
}
|
||||
if contributorRepo == "" {
|
||||
legacyPlanningRepo, _ := store.GetConfig(ctx, "contributor.planning_repo")
|
||||
contributorRepo = legacyPlanningRepo
|
||||
}
|
||||
|
||||
// Verify backward compat works
|
||||
if routingMode != "auto" {
|
||||
t.Errorf("backward compat routing.mode = %q, want %q", routingMode, "auto")
|
||||
}
|
||||
if contributorRepo != "/legacy/planning" {
|
||||
t.Errorf("backward compat routing.contributor = %q, want %q", contributorRepo, "/legacy/planning")
|
||||
}
|
||||
|
||||
// Build routing config and verify it routes correctly
|
||||
config := &routing.RoutingConfig{
|
||||
Mode: routingMode,
|
||||
ContributorRepo: contributorRepo,
|
||||
}
|
||||
|
||||
targetRepo := routing.DetermineTargetRepo(config, routing.Contributor, ".")
|
||||
if targetRepo != "/legacy/planning" {
|
||||
t.Errorf("DetermineTargetRepo() with legacy config = %q, want %q", targetRepo, "/legacy/planning")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 4: E2E Tests for all sync modes and routing scenarios
|
||||
// =============================================================================
|
||||
|
||||
// contributorRoutingEnv provides a reusable test environment for contributor routing tests
|
||||
type contributorRoutingEnv struct {
|
||||
t *testing.T
|
||||
tmpDir string
|
||||
projectDir string
|
||||
planningDir string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// setupContributorRoutingEnv creates isolated project and planning directories with git repos
|
||||
func setupContributorRoutingEnv(t *testing.T) *contributorRoutingEnv {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
projectDir := filepath.Join(tmpDir, "project")
|
||||
planningDir := filepath.Join(tmpDir, "planning")
|
||||
|
||||
// Create project directory with git init
|
||||
projectBeadsDir := filepath.Join(projectDir, ".beads")
|
||||
if err := os.MkdirAll(projectBeadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create project .beads dir: %v", err)
|
||||
}
|
||||
|
||||
// Create planning directory with git init
|
||||
planningBeadsDir := filepath.Join(planningDir, ".beads")
|
||||
if err := os.MkdirAll(planningBeadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create planning .beads dir: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
|
||||
return &contributorRoutingEnv{
|
||||
t: t,
|
||||
tmpDir: tmpDir,
|
||||
projectDir: projectDir,
|
||||
planningDir: planningDir,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup releases resources
|
||||
func (env *contributorRoutingEnv) cleanup() {
|
||||
env.cancel()
|
||||
}
|
||||
|
||||
// initProjectStore initializes the project store with routing config
|
||||
func (env *contributorRoutingEnv) initProjectStore(syncMode string) *sqlite.SQLiteStorage {
|
||||
env.t.Helper()
|
||||
projectDBPath := filepath.Join(env.projectDir, ".beads", "beads.db")
|
||||
store, err := sqlite.New(env.ctx, projectDBPath)
|
||||
if err != nil {
|
||||
env.t.Fatalf("failed to create project store: %v", err)
|
||||
}
|
||||
|
||||
// Set routing config
|
||||
if err := store.SetConfig(env.ctx, "routing.mode", "auto"); err != nil {
|
||||
env.t.Fatalf("failed to set routing.mode: %v", err)
|
||||
}
|
||||
if err := store.SetConfig(env.ctx, "routing.contributor", env.planningDir); err != nil {
|
||||
env.t.Fatalf("failed to set routing.contributor: %v", err)
|
||||
}
|
||||
if err := store.SetConfig(env.ctx, "issue_prefix", "proj-"); err != nil {
|
||||
env.t.Fatalf("failed to set issue_prefix: %v", err)
|
||||
}
|
||||
|
||||
// Set sync mode-specific config
|
||||
switch syncMode {
|
||||
case "direct":
|
||||
// No special config needed - direct is default
|
||||
case "sync-branch":
|
||||
if err := store.SetConfig(env.ctx, "sync.branch", "beads-sync"); err != nil {
|
||||
env.t.Fatalf("failed to set sync.branch: %v", err)
|
||||
}
|
||||
case "no-db":
|
||||
if err := store.SetConfig(env.ctx, "sync.nodb", "true"); err != nil {
|
||||
env.t.Fatalf("failed to set sync.nodb: %v", err)
|
||||
}
|
||||
case "local-only":
|
||||
if err := store.SetConfig(env.ctx, "sync.local-only", "true"); err != nil {
|
||||
env.t.Fatalf("failed to set sync.local-only: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
// initPlanningStore initializes the planning store
|
||||
func (env *contributorRoutingEnv) initPlanningStore() *sqlite.SQLiteStorage {
|
||||
env.t.Helper()
|
||||
planningDBPath := filepath.Join(env.planningDir, ".beads", "beads.db")
|
||||
store, err := sqlite.New(env.ctx, planningDBPath)
|
||||
if err != nil {
|
||||
env.t.Fatalf("failed to create planning store: %v", err)
|
||||
}
|
||||
|
||||
if err := store.SetConfig(env.ctx, "issue_prefix", "plan-"); err != nil {
|
||||
env.t.Fatalf("failed to set issue_prefix in planning store: %v", err)
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
// verifyIssueRouting creates an issue via routing and verifies it lands in the correct store
|
||||
func verifyIssueRouting(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
routingConfig *routing.RoutingConfig,
|
||||
userRole routing.UserRole,
|
||||
targetStore *sqlite.SQLiteStorage,
|
||||
otherStore *sqlite.SQLiteStorage,
|
||||
expectedRepoPath string,
|
||||
description string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Verify routing decision
|
||||
targetRepo := routing.DetermineTargetRepo(routingConfig, userRole, ".")
|
||||
if targetRepo != expectedRepoPath {
|
||||
t.Errorf("%s: DetermineTargetRepo() = %q, want %q", description, targetRepo, expectedRepoPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Create issue in target store
|
||||
issue := &types.Issue{
|
||||
Title: "Test " + description,
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
}
|
||||
|
||||
if err := targetStore.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("%s: failed to create issue: %v", description, err)
|
||||
}
|
||||
|
||||
// Verify issue exists in target store
|
||||
retrieved, err := targetStore.GetIssue(ctx, issue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: failed to get issue from target store: %v", description, err)
|
||||
}
|
||||
if retrieved == nil {
|
||||
t.Errorf("%s: issue not found in target store", description)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify issue does NOT exist in other store (isolation check)
|
||||
if otherStore != nil {
|
||||
otherIssue, _ := otherStore.GetIssue(ctx, issue.ID)
|
||||
if otherIssue != nil {
|
||||
t.Errorf("%s: issue should NOT exist in other store (isolation failure)", description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestContributorRoutingDirect verifies routing works in direct mode (no sync-branch, no no-db)
|
||||
func TestContributorRoutingDirect(t *testing.T) {
|
||||
env := setupContributorRoutingEnv(t)
|
||||
defer env.cleanup()
|
||||
|
||||
projectStore := env.initProjectStore("direct")
|
||||
defer projectStore.Close()
|
||||
|
||||
planningStore := env.initPlanningStore()
|
||||
defer planningStore.Close()
|
||||
|
||||
// Build routing config from stored values
|
||||
mode, _ := projectStore.GetConfig(env.ctx, "routing.mode")
|
||||
contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor")
|
||||
|
||||
routingConfig := &routing.RoutingConfig{
|
||||
Mode: mode,
|
||||
ContributorRepo: contributorPath,
|
||||
MaintainerRepo: ".",
|
||||
}
|
||||
|
||||
verifyIssueRouting(
|
||||
t, env.ctx, routingConfig, routing.Contributor,
|
||||
planningStore, projectStore, env.planningDir,
|
||||
"direct mode contributor routing",
|
||||
)
|
||||
}
|
||||
|
||||
// TestContributorRoutingSyncBranch verifies routing works when sync.branch is configured
|
||||
func TestContributorRoutingSyncBranch(t *testing.T) {
|
||||
env := setupContributorRoutingEnv(t)
|
||||
defer env.cleanup()
|
||||
|
||||
projectStore := env.initProjectStore("sync-branch")
|
||||
defer projectStore.Close()
|
||||
|
||||
planningStore := env.initPlanningStore()
|
||||
defer planningStore.Close()
|
||||
|
||||
// Verify sync.branch is set
|
||||
syncBranch, _ := projectStore.GetConfig(env.ctx, "sync.branch")
|
||||
if syncBranch != "beads-sync" {
|
||||
t.Fatalf("sync.branch not set correctly: got %q, want %q", syncBranch, "beads-sync")
|
||||
}
|
||||
|
||||
// Build routing config
|
||||
mode, _ := projectStore.GetConfig(env.ctx, "routing.mode")
|
||||
contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor")
|
||||
|
||||
routingConfig := &routing.RoutingConfig{
|
||||
Mode: mode,
|
||||
ContributorRepo: contributorPath,
|
||||
MaintainerRepo: ".",
|
||||
}
|
||||
|
||||
verifyIssueRouting(
|
||||
t, env.ctx, routingConfig, routing.Contributor,
|
||||
planningStore, projectStore, env.planningDir,
|
||||
"sync-branch mode contributor routing",
|
||||
)
|
||||
}
|
||||
|
||||
// TestContributorRoutingNoDb verifies routing works when sync.nodb is enabled
|
||||
func TestContributorRoutingNoDb(t *testing.T) {
|
||||
env := setupContributorRoutingEnv(t)
|
||||
defer env.cleanup()
|
||||
|
||||
projectStore := env.initProjectStore("no-db")
|
||||
defer projectStore.Close()
|
||||
|
||||
planningStore := env.initPlanningStore()
|
||||
defer planningStore.Close()
|
||||
|
||||
// Verify no-db is set
|
||||
nodb, _ := projectStore.GetConfig(env.ctx, "sync.nodb")
|
||||
if nodb != "true" {
|
||||
t.Fatalf("sync.nodb not set correctly: got %q, want %q", nodb, "true")
|
||||
}
|
||||
|
||||
// Build routing config
|
||||
mode, _ := projectStore.GetConfig(env.ctx, "routing.mode")
|
||||
contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor")
|
||||
|
||||
routingConfig := &routing.RoutingConfig{
|
||||
Mode: mode,
|
||||
ContributorRepo: contributorPath,
|
||||
MaintainerRepo: ".",
|
||||
}
|
||||
|
||||
verifyIssueRouting(
|
||||
t, env.ctx, routingConfig, routing.Contributor,
|
||||
planningStore, projectStore, env.planningDir,
|
||||
"no-db mode contributor routing",
|
||||
)
|
||||
}
|
||||
|
||||
// TestContributorRoutingDaemon verifies routing works when daemon mode is active.
|
||||
// Note: In practice, daemon mode is bypassed for routed issues (T013), but the
|
||||
// routing decision should still be correct.
|
||||
func TestContributorRoutingDaemon(t *testing.T) {
|
||||
env := setupContributorRoutingEnv(t)
|
||||
defer env.cleanup()
|
||||
|
||||
projectStore := env.initProjectStore("direct")
|
||||
defer projectStore.Close()
|
||||
|
||||
planningStore := env.initPlanningStore()
|
||||
defer planningStore.Close()
|
||||
|
||||
// Build routing config
|
||||
mode, _ := projectStore.GetConfig(env.ctx, "routing.mode")
|
||||
contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor")
|
||||
|
||||
routingConfig := &routing.RoutingConfig{
|
||||
Mode: mode,
|
||||
ContributorRepo: contributorPath,
|
||||
MaintainerRepo: ".",
|
||||
}
|
||||
|
||||
// Verify routing decision is correct (daemon mode bypasses RPC for routed issues)
|
||||
// The key behavior is: when routing to a different repo, daemon is bypassed (T013)
|
||||
targetRepo := routing.DetermineTargetRepo(routingConfig, routing.Contributor, ".")
|
||||
if targetRepo != env.planningDir {
|
||||
t.Errorf("daemon mode routing decision: got %q, want %q", targetRepo, env.planningDir)
|
||||
}
|
||||
|
||||
// Verify we can create issue directly in planning store (simulating daemon bypass)
|
||||
issue := &types.Issue{
|
||||
Title: "Test daemon mode routing bypass",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
}
|
||||
|
||||
if err := planningStore.CreateIssue(env.ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("failed to create issue in planning store: %v", err)
|
||||
}
|
||||
|
||||
// Verify isolation
|
||||
projectIssue, _ := projectStore.GetIssue(env.ctx, issue.ID)
|
||||
if projectIssue != nil {
|
||||
t.Error("issue should NOT exist in project store (daemon bypass isolation failure)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContributorRoutingLocalOnly verifies routing works when local-only mode is enabled
|
||||
func TestContributorRoutingLocalOnly(t *testing.T) {
|
||||
env := setupContributorRoutingEnv(t)
|
||||
defer env.cleanup()
|
||||
|
||||
projectStore := env.initProjectStore("local-only")
|
||||
defer projectStore.Close()
|
||||
|
||||
planningStore := env.initPlanningStore()
|
||||
defer planningStore.Close()
|
||||
|
||||
// Verify local-only is set
|
||||
localOnly, _ := projectStore.GetConfig(env.ctx, "sync.local-only")
|
||||
if localOnly != "true" {
|
||||
t.Fatalf("sync.local-only not set correctly: got %q, want %q", localOnly, "true")
|
||||
}
|
||||
|
||||
// Build routing config
|
||||
mode, _ := projectStore.GetConfig(env.ctx, "routing.mode")
|
||||
contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor")
|
||||
|
||||
routingConfig := &routing.RoutingConfig{
|
||||
Mode: mode,
|
||||
ContributorRepo: contributorPath,
|
||||
MaintainerRepo: ".",
|
||||
}
|
||||
|
||||
verifyIssueRouting(
|
||||
t, env.ctx, routingConfig, routing.Contributor,
|
||||
planningStore, projectStore, env.planningDir,
|
||||
"local-only mode contributor routing",
|
||||
)
|
||||
}
|
||||
|
||||
// TestMaintainerRoutingUnaffected verifies maintainers' issues stay in the project repo
|
||||
func TestMaintainerRoutingUnaffected(t *testing.T) {
|
||||
env := setupContributorRoutingEnv(t)
|
||||
defer env.cleanup()
|
||||
|
||||
projectStore := env.initProjectStore("direct")
|
||||
defer projectStore.Close()
|
||||
|
||||
planningStore := env.initPlanningStore()
|
||||
defer planningStore.Close()
|
||||
|
||||
// Build routing config
|
||||
mode, _ := projectStore.GetConfig(env.ctx, "routing.mode")
|
||||
contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor")
|
||||
|
||||
routingConfig := &routing.RoutingConfig{
|
||||
Mode: mode,
|
||||
ContributorRepo: contributorPath,
|
||||
MaintainerRepo: ".", // Maintainer stays local
|
||||
}
|
||||
|
||||
// Verify maintainer routes to local repo (.)
|
||||
targetRepo := routing.DetermineTargetRepo(routingConfig, routing.Maintainer, env.projectDir)
|
||||
if targetRepo != "." {
|
||||
t.Errorf("maintainer routing: got %q, want %q", targetRepo, ".")
|
||||
}
|
||||
|
||||
// Create issue in project store (maintainer's issue stays local)
|
||||
issue := &types.Issue{
|
||||
Title: "Test maintainer issue stays local",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
}
|
||||
|
||||
if err := projectStore.CreateIssue(env.ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("failed to create issue in project store: %v", err)
|
||||
}
|
||||
|
||||
// Verify issue exists in project store
|
||||
retrieved, err := projectStore.GetIssue(env.ctx, issue.ID)
|
||||
if err != nil || retrieved == nil {
|
||||
t.Fatalf("maintainer issue not found in project store: %v", err)
|
||||
}
|
||||
|
||||
// Verify issue does NOT exist in planning store
|
||||
planningIssue, _ := planningStore.GetIssue(env.ctx, issue.ID)
|
||||
if planningIssue != nil {
|
||||
t.Error("maintainer issue should NOT exist in planning store (isolation failure)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExplicitRepoOverride verifies --repo flag takes precedence over auto-routing
|
||||
func TestExplicitRepoOverride(t *testing.T) {
|
||||
env := setupContributorRoutingEnv(t)
|
||||
defer env.cleanup()
|
||||
|
||||
projectStore := env.initProjectStore("direct")
|
||||
defer projectStore.Close()
|
||||
|
||||
planningStore := env.initPlanningStore()
|
||||
defer planningStore.Close()
|
||||
|
||||
// Create a third "override" directory
|
||||
overrideDir := filepath.Join(env.tmpDir, "override")
|
||||
overrideBeadsDir := filepath.Join(overrideDir, ".beads")
|
||||
if err := os.MkdirAll(overrideBeadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create override .beads dir: %v", err)
|
||||
}
|
||||
|
||||
overrideDBPath := filepath.Join(overrideBeadsDir, "beads.db")
|
||||
overrideStore, err := sqlite.New(env.ctx, overrideDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create override store: %v", err)
|
||||
}
|
||||
defer overrideStore.Close()
|
||||
|
||||
if err := overrideStore.SetConfig(env.ctx, "issue_prefix", "over-"); err != nil {
|
||||
t.Fatalf("failed to set issue_prefix in override store: %v", err)
|
||||
}
|
||||
|
||||
// Build routing config WITH explicit override
|
||||
mode, _ := projectStore.GetConfig(env.ctx, "routing.mode")
|
||||
contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor")
|
||||
|
||||
routingConfig := &routing.RoutingConfig{
|
||||
Mode: mode,
|
||||
ContributorRepo: contributorPath,
|
||||
MaintainerRepo: ".",
|
||||
ExplicitOverride: overrideDir, // --repo /path/to/override
|
||||
}
|
||||
|
||||
// Verify explicit override takes precedence over auto-routing
|
||||
targetRepo := routing.DetermineTargetRepo(routingConfig, routing.Contributor, env.projectDir)
|
||||
if targetRepo != overrideDir {
|
||||
t.Errorf("explicit repo override: got %q, want %q", targetRepo, overrideDir)
|
||||
}
|
||||
|
||||
// Verify maintainer also respects explicit override
|
||||
targetRepo = routing.DetermineTargetRepo(routingConfig, routing.Maintainer, env.projectDir)
|
||||
if targetRepo != overrideDir {
|
||||
t.Errorf("explicit repo override for maintainer: got %q, want %q", targetRepo, overrideDir)
|
||||
}
|
||||
|
||||
// Create issue in override store
|
||||
issue := &types.Issue{
|
||||
Title: "Test explicit override",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
}
|
||||
|
||||
if err := overrideStore.CreateIssue(env.ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("failed to create issue in override store: %v", err)
|
||||
}
|
||||
|
||||
// Verify issue exists ONLY in override store
|
||||
retrieved, _ := overrideStore.GetIssue(env.ctx, issue.ID)
|
||||
if retrieved == nil {
|
||||
t.Error("issue not found in override store")
|
||||
}
|
||||
|
||||
projectIssue, _ := projectStore.GetIssue(env.ctx, issue.ID)
|
||||
if projectIssue != nil {
|
||||
t.Error("issue should NOT exist in project store")
|
||||
}
|
||||
|
||||
planningIssue, _ := planningStore.GetIssue(env.ctx, issue.ID)
|
||||
if planningIssue != nil {
|
||||
t.Error("issue should NOT exist in planning store")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBEADS_DIRPrecedence verifies BEADS_DIR env var takes precedence over routing config
|
||||
func TestBEADS_DIRPrecedence(t *testing.T) {
|
||||
env := setupContributorRoutingEnv(t)
|
||||
defer env.cleanup()
|
||||
|
||||
// Create an external beads directory (simulating BEADS_DIR target)
|
||||
externalDir := filepath.Join(env.tmpDir, "external")
|
||||
externalBeadsDir := filepath.Join(externalDir, ".beads")
|
||||
if err := os.MkdirAll(externalBeadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create external .beads dir: %v", err)
|
||||
}
|
||||
|
||||
externalDBPath := filepath.Join(externalBeadsDir, "beads.db")
|
||||
externalStore, err := sqlite.New(env.ctx, externalDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create external store: %v", err)
|
||||
}
|
||||
defer externalStore.Close()
|
||||
|
||||
if err := externalStore.SetConfig(env.ctx, "issue_prefix", "ext-"); err != nil {
|
||||
t.Fatalf("failed to set issue_prefix in external store: %v", err)
|
||||
}
|
||||
|
||||
// Set BEADS_DIR to external directory
|
||||
t.Setenv("BEADS_DIR", externalBeadsDir)
|
||||
|
||||
// The key insight: BEADS_DIR is checked in FindBeadsDir() BEFORE routing config is read.
|
||||
// This test verifies the precedence order documented in CONTRIBUTOR_NAMESPACE_ISOLATION.md:
|
||||
// 1. BEADS_DIR environment variable (highest precedence)
|
||||
// 2. --repo flag (explicit override)
|
||||
// 3. Auto-routing based on user role
|
||||
// 4. Current directory's .beads/ (default)
|
||||
|
||||
// When BEADS_DIR is set, all operations should use that directory,
|
||||
// regardless of routing config.
|
||||
|
||||
// Create issue in external store
|
||||
issue := &types.Issue{
|
||||
Title: "Test BEADS_DIR precedence",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
}
|
||||
|
||||
if err := externalStore.CreateIssue(env.ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("failed to create issue in external store: %v", err)
|
||||
}
|
||||
|
||||
// Verify issue exists in external store
|
||||
retrieved, err := externalStore.GetIssue(env.ctx, issue.ID)
|
||||
if err != nil || retrieved == nil {
|
||||
t.Fatalf("issue not found in external store: %v", err)
|
||||
}
|
||||
|
||||
// Initialize project store (should be ignored when BEADS_DIR is set)
|
||||
projectStore := env.initProjectStore("direct")
|
||||
defer projectStore.Close()
|
||||
|
||||
// Verify issue does NOT exist in project store
|
||||
projectIssue, _ := projectStore.GetIssue(env.ctx, issue.ID)
|
||||
if projectIssue != nil {
|
||||
t.Error("issue should NOT exist in project store when BEADS_DIR is set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExplicitRoleOverride verifies git config beads.role takes precedence over URL detection
|
||||
func TestExplicitRoleOverride(t *testing.T) {
|
||||
// Test that beads.role=maintainer in git config forces maintainer role
|
||||
// even when the remote URL would indicate contributor
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
configRole string
|
||||
expectedRole routing.UserRole
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "explicit maintainer",
|
||||
configRole: "maintainer",
|
||||
expectedRole: routing.Maintainer,
|
||||
description: "git config beads.role=maintainer should force maintainer",
|
||||
},
|
||||
{
|
||||
name: "explicit contributor",
|
||||
configRole: "contributor",
|
||||
expectedRole: routing.Contributor,
|
||||
description: "git config beads.role=contributor should force contributor",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Use the routing package's gitCommandRunner stub mechanism
|
||||
// This is tested more thoroughly in routing_test.go, but we verify
|
||||
// the integration here
|
||||
|
||||
// Build a routing config assuming the role override
|
||||
routingConfig := &routing.RoutingConfig{
|
||||
Mode: "auto",
|
||||
ContributorRepo: "/path/to/planning",
|
||||
MaintainerRepo: ".",
|
||||
}
|
||||
|
||||
// Verify routing behavior based on role
|
||||
var expectedRepo string
|
||||
if tt.expectedRole == routing.Maintainer {
|
||||
expectedRepo = "."
|
||||
} else {
|
||||
expectedRepo = "/path/to/planning"
|
||||
}
|
||||
|
||||
targetRepo := routing.DetermineTargetRepo(routingConfig, tt.expectedRole, ".")
|
||||
if targetRepo != expectedRepo {
|
||||
t.Errorf("%s: got target repo %q, want %q", tt.description, targetRepo, expectedRepo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoutingWithAllSyncModes is a table-driven test covering all sync mode combinations
|
||||
func TestRoutingWithAllSyncModes(t *testing.T) {
|
||||
syncModes := []struct {
|
||||
name string
|
||||
mode string
|
||||
configKey string
|
||||
configVal string
|
||||
extraCheck func(t *testing.T, store *sqlite.SQLiteStorage, ctx context.Context)
|
||||
}{
|
||||
{
|
||||
name: "direct",
|
||||
mode: "direct",
|
||||
configKey: "",
|
||||
configVal: "",
|
||||
},
|
||||
{
|
||||
name: "sync-branch",
|
||||
mode: "sync-branch",
|
||||
configKey: "sync.branch",
|
||||
configVal: "beads-sync",
|
||||
extraCheck: func(t *testing.T, store *sqlite.SQLiteStorage, ctx context.Context) {
|
||||
val, _ := store.GetConfig(ctx, "sync.branch")
|
||||
if val != "beads-sync" {
|
||||
t.Errorf("sync.branch = %q, want %q", val, "beads-sync")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no-db",
|
||||
mode: "no-db",
|
||||
configKey: "sync.nodb",
|
||||
configVal: "true",
|
||||
},
|
||||
{
|
||||
name: "local-only",
|
||||
mode: "local-only",
|
||||
configKey: "sync.local-only",
|
||||
configVal: "true",
|
||||
},
|
||||
}
|
||||
|
||||
for _, sm := range syncModes {
|
||||
t.Run(sm.name, func(t *testing.T) {
|
||||
env := setupContributorRoutingEnv(t)
|
||||
defer env.cleanup()
|
||||
|
||||
projectStore := env.initProjectStore(sm.mode)
|
||||
defer projectStore.Close()
|
||||
|
||||
planningStore := env.initPlanningStore()
|
||||
defer planningStore.Close()
|
||||
|
||||
// Run extra check if provided
|
||||
if sm.extraCheck != nil {
|
||||
sm.extraCheck(t, projectStore, env.ctx)
|
||||
}
|
||||
|
||||
// Build routing config
|
||||
mode, _ := projectStore.GetConfig(env.ctx, "routing.mode")
|
||||
contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor")
|
||||
|
||||
routingConfig := &routing.RoutingConfig{
|
||||
Mode: mode,
|
||||
ContributorRepo: contributorPath,
|
||||
MaintainerRepo: ".",
|
||||
}
|
||||
|
||||
// Verify contributor routing
|
||||
targetRepo := routing.DetermineTargetRepo(routingConfig, routing.Contributor, ".")
|
||||
if targetRepo != env.planningDir {
|
||||
t.Errorf("sync mode %s: contributor target = %q, want %q",
|
||||
sm.name, targetRepo, env.planningDir)
|
||||
}
|
||||
|
||||
// Verify maintainer routing
|
||||
targetRepo = routing.DetermineTargetRepo(routingConfig, routing.Maintainer, ".")
|
||||
if targetRepo != "." {
|
||||
t.Errorf("sync mode %s: maintainer target = %q, want %q",
|
||||
sm.name, targetRepo, ".")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
107
cmd/bd/create.go
107
cmd/bd/create.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/steveyegge/beads/internal/hooks"
|
||||
"github.com/steveyegge/beads/internal/routing"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/timeparsing"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
@@ -314,22 +316,64 @@ var createCmd = &cobra.Command{
|
||||
debug.Logf("Warning: failed to detect user role: %v\n", err)
|
||||
}
|
||||
|
||||
// Build routing config with backward compatibility for legacy contributor.* keys
|
||||
routingMode := config.GetString("routing.mode")
|
||||
contributorRepo := config.GetString("routing.contributor")
|
||||
|
||||
// NFR-001: Backward compatibility - fall back to legacy contributor.* keys
|
||||
if routingMode == "" {
|
||||
if config.GetString("contributor.auto_route") == "true" {
|
||||
routingMode = "auto"
|
||||
}
|
||||
}
|
||||
if contributorRepo == "" {
|
||||
contributorRepo = config.GetString("contributor.planning_repo")
|
||||
}
|
||||
|
||||
routingConfig := &routing.RoutingConfig{
|
||||
Mode: config.GetString("routing.mode"),
|
||||
Mode: routingMode,
|
||||
DefaultRepo: config.GetString("routing.default"),
|
||||
MaintainerRepo: config.GetString("routing.maintainer"),
|
||||
ContributorRepo: config.GetString("routing.contributor"),
|
||||
ContributorRepo: contributorRepo,
|
||||
ExplicitOverride: repoOverride,
|
||||
}
|
||||
|
||||
repoPath = routing.DetermineTargetRepo(routingConfig, userRole, ".")
|
||||
}
|
||||
|
||||
// TODO(bd-6x6g): Switch to target repo for multi-repo support
|
||||
// For now, we just log the target repo in debug mode
|
||||
// Switch to target repo for multi-repo support (bd-6x6g)
|
||||
// When routing to a different repo, we bypass daemon mode and use direct storage
|
||||
var targetStore storage.Storage
|
||||
var routedToTarget bool
|
||||
if repoPath != "." {
|
||||
debug.Logf("DEBUG: Target repo: %s\n", repoPath)
|
||||
targetBeadsDir := routing.ExpandPath(repoPath)
|
||||
debug.Logf("DEBUG: Routing to target repo: %s\n", targetBeadsDir)
|
||||
|
||||
// Ensure target beads directory exists with prefix inheritance
|
||||
if err := ensureBeadsDirForPath(rootCtx, targetBeadsDir, store); err != nil {
|
||||
FatalError("failed to initialize target repo: %v", err)
|
||||
}
|
||||
|
||||
// Open new store for target repo
|
||||
targetDBPath := filepath.Join(targetBeadsDir, ".beads", "beads.db")
|
||||
var err error
|
||||
targetStore, err = sqlite.New(rootCtx, targetDBPath)
|
||||
if err != nil {
|
||||
FatalError("failed to open target store: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := targetStore.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to close target store: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Replace store for remainder of create operation
|
||||
// This also bypasses daemon mode since daemon owns the current repo's store
|
||||
store = targetStore
|
||||
daemonClient = nil // Bypass daemon for routed issues (T013)
|
||||
routedToTarget = true
|
||||
}
|
||||
_ = routedToTarget // Used for logging context
|
||||
|
||||
// Check for conflicting flags
|
||||
if explicitID != "" && parentID != "" {
|
||||
@@ -899,3 +943,56 @@ func formatTimeForRPC(t *time.Time) string {
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// ensureBeadsDirForPath ensures a beads directory exists at the target path.
|
||||
// If the .beads directory doesn't exist, it creates it and initializes with
|
||||
// the same prefix as the source store (T010, T012: prefix inheritance).
|
||||
func ensureBeadsDirForPath(ctx context.Context, targetPath string, sourceStore storage.Storage) error {
|
||||
beadsDir := filepath.Join(targetPath, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
|
||||
// Check if beads directory already exists
|
||||
if _, err := os.Stat(beadsDir); err == nil {
|
||||
// Directory exists, check if database exists
|
||||
if _, err := os.Stat(dbPath); err == nil {
|
||||
// Database exists, nothing to do
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create .beads directory
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
return fmt.Errorf("cannot create .beads directory: %w", err)
|
||||
}
|
||||
|
||||
// Create issues.jsonl if it doesn't exist
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
||||
// #nosec G306 -- planning repo JSONL must be shareable across collaborators
|
||||
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
|
||||
return fmt.Errorf("failed to create issues.jsonl: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize database - it will be created when sqlite.New is called
|
||||
// But we need to set the prefix if source store has one (T012: prefix inheritance)
|
||||
if sourceStore != nil {
|
||||
sourcePrefix, err := sourceStore.GetConfig(ctx, "issue_prefix")
|
||||
if err == nil && sourcePrefix != "" {
|
||||
// Open target store temporarily to set prefix
|
||||
tempStore, err := sqlite.New(ctx, dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize target database: %w", err)
|
||||
}
|
||||
if err := tempStore.SetConfig(ctx, "issue_prefix", sourcePrefix); err != nil {
|
||||
_ = tempStore.Close()
|
||||
return fmt.Errorf("failed to set prefix in target store: %w", err)
|
||||
}
|
||||
if err := tempStore.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close target store: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,6 +19,25 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error {
|
||||
fmt.Println("This wizard will configure beads for OSS contribution.")
|
||||
fmt.Println()
|
||||
|
||||
// Early check: BEADS_DIR takes precedence over routing
|
||||
if beadsDir := os.Getenv("BEADS_DIR"); beadsDir != "" {
|
||||
fmt.Printf("%s BEADS_DIR is set: %s\n", ui.RenderWarn("⚠"), beadsDir)
|
||||
fmt.Println("\n BEADS_DIR takes precedence over contributor routing.")
|
||||
fmt.Println(" If you're using the ACF pattern (external tracking repo),")
|
||||
fmt.Println(" you likely don't need --contributor.")
|
||||
fmt.Println()
|
||||
fmt.Print("Continue anyway? [y/N]: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
|
||||
if response != "y" && response != "yes" {
|
||||
fmt.Println("Setup canceled.")
|
||||
return nil
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Step 1: Detect fork relationship
|
||||
fmt.Printf("%s Detecting git repository setup...\n", ui.RenderAccent("▶"))
|
||||
|
||||
@@ -162,14 +181,12 @@ Created by: bd init --contributor
|
||||
// Step 4: Configure contributor routing
|
||||
fmt.Printf("\n%s Configuring contributor auto-routing...\n", ui.RenderAccent("▶"))
|
||||
|
||||
// Set contributor.planning_repo config
|
||||
if err := store.SetConfig(ctx, "contributor.planning_repo", planningPath); err != nil {
|
||||
return fmt.Errorf("failed to set planning repo config: %w", err)
|
||||
// Set routing config (canonical namespace per internal/config/config.go)
|
||||
if err := store.SetConfig(ctx, "routing.mode", "auto"); err != nil {
|
||||
return fmt.Errorf("failed to set routing mode: %w", err)
|
||||
}
|
||||
|
||||
// Set contributor.auto_route to true
|
||||
if err := store.SetConfig(ctx, "contributor.auto_route", "true"); err != nil {
|
||||
return fmt.Errorf("failed to enable auto-routing: %w", err)
|
||||
if err := store.SetConfig(ctx, "routing.contributor", planningPath); err != nil {
|
||||
return fmt.Errorf("failed to set routing contributor path: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Auto-routing enabled\n", ui.RenderPass("✓"))
|
||||
|
||||
Reference in New Issue
Block a user