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("✓"))
|
||||
|
||||
@@ -256,6 +256,26 @@ bd migrate plan-42 --to .
|
||||
|
||||
This creates a new issue in the target repo with a reference to the original.
|
||||
|
||||
## Sync Mode Interactions
|
||||
|
||||
Contributor routing works independently of the project repo's sync configuration. The planning repo has its own sync behavior:
|
||||
|
||||
| Sync Mode | Project Repo | Planning Repo | Notes |
|
||||
|-----------|--------------|---------------|-------|
|
||||
| **Direct** | Uses `.beads/` directly | Uses `~/.beads-planning/.beads/` | Both use direct storage, no interaction |
|
||||
| **Sync-branch** | Uses separate branch for beads | Uses direct storage | Planning repo does NOT inherit `sync.branch` config |
|
||||
| **No-db mode** | JSONL-only operations | Routes JSONL operations to planning repo | Planning repo still uses database |
|
||||
| **Daemon mode** | Background auto-sync | Daemon bypassed for routed issues | Planning repo operations are synchronous |
|
||||
| **Local-only** | No git remote | Works normally | Planning repo can have its own git remote independently |
|
||||
| **External (BEADS_DIR)** | Uses separate repo via env var | BEADS_DIR takes precedence over routing | If `BEADS_DIR` is set, routing config is ignored |
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Separate databases**: Planning repo is completely independent - it has its own `.beads/` directory
|
||||
2. **No config inheritance**: Planning repo does not inherit project's `sync.branch`, `no-db`, or daemon settings
|
||||
3. **BEADS_DIR precedence**: If `BEADS_DIR` environment variable is set, it overrides routing configuration
|
||||
4. **Daemon bypass**: Issues routed to planning repo bypass daemon mode to avoid connection staleness
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Contributor Setup (Recommended)
|
||||
@@ -295,6 +315,89 @@ export BEADS_DIR=~/.beads-planning
|
||||
bd create "My task" -p 1
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Routing Not Working
|
||||
|
||||
**Symptom**: Issues appear in `./.beads/issues.jsonl` instead of planning repo
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# Check routing configuration
|
||||
bd config get routing.mode
|
||||
bd config get routing.contributor
|
||||
|
||||
# Check detected role
|
||||
git config beads.role # If set, this overrides auto-detection
|
||||
git remote get-url --push origin # Should show HTTPS for contributors
|
||||
```
|
||||
|
||||
**Solutions**:
|
||||
1. Verify `routing.mode` is set to `auto`
|
||||
2. Verify `routing.contributor` points to planning repo path
|
||||
3. Check that `BEADS_DIR` is NOT set (it overrides routing)
|
||||
4. If using SSH URL but want contributor behavior, set `git config beads.role contributor`
|
||||
|
||||
### BEADS_DIR Conflicts with Routing
|
||||
|
||||
**Symptom**: Warning message about BEADS_DIR overriding routing config
|
||||
|
||||
**Explanation**: `BEADS_DIR` environment variable takes precedence over all routing configuration. This is intentional for backward compatibility.
|
||||
|
||||
**Solutions**:
|
||||
1. **Unset BEADS_DIR** if you want routing to work: `unset BEADS_DIR`
|
||||
2. **Keep BEADS_DIR** and ignore routing config (BEADS_DIR will be used)
|
||||
3. **Use explicit --repo flag** to override both: `bd create "task" -p 1 --repo /path/to/repo`
|
||||
|
||||
### Planning Repo Not Initialized
|
||||
|
||||
**Symptom**: Error when creating issue: "failed to initialize target repo"
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
ls -la ~/.beads-planning/.beads/ # Should exist
|
||||
```
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Reinitialize planning repo
|
||||
bd init --contributor # Wizard will recreate if missing
|
||||
```
|
||||
|
||||
### Prefix Mismatch Between Repos
|
||||
|
||||
**Symptom**: Planning repo issues have different prefix than expected
|
||||
|
||||
**Explanation**: Planning repo inherits the project repo's prefix during initialization. If you want a different prefix:
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Configure planning repo prefix
|
||||
cd ~/.beads-planning
|
||||
bd config set db.prefix plan # Use "plan-" prefix for planning issues
|
||||
cd - # Return to project repo
|
||||
```
|
||||
|
||||
### Config Keys Not Found (Legacy)
|
||||
|
||||
**Symptom**: Old docs or scripts reference `contributor.auto_route` or `contributor.planning_repo`
|
||||
|
||||
**Explanation**: Config keys were renamed in v0.48.0:
|
||||
- `contributor.auto_route` → `routing.mode` (value: `auto` or `explicit`)
|
||||
- `contributor.planning_repo` → `routing.contributor`
|
||||
|
||||
**Solution**: Use new keys. Legacy keys still work for backward compatibility but are deprecated.
|
||||
|
||||
```bash
|
||||
# Old (deprecated but still works)
|
||||
bd config set contributor.auto_route true
|
||||
bd config set contributor.planning_repo ~/.beads-planning
|
||||
|
||||
# New (preferred)
|
||||
bd config set routing.mode auto
|
||||
bd config set routing.contributor ~/.beads-planning
|
||||
```
|
||||
|
||||
## Pollution Detection Heuristics
|
||||
|
||||
For `bd preflight`, we can detect pollution by checking:
|
||||
|
||||
@@ -135,9 +135,10 @@ bd close bd-abc --reason "PR merged"
|
||||
The wizard configures these settings in `.beads/beads.db`:
|
||||
|
||||
```yaml
|
||||
contributor:
|
||||
planning_repo: ~/.beads-planning
|
||||
auto_route: true
|
||||
routing:
|
||||
mode: auto
|
||||
contributor: ~/.beads-planning
|
||||
maintainer: .
|
||||
```
|
||||
|
||||
### Manual Configuration
|
||||
@@ -148,9 +149,24 @@ If you prefer manual setup:
|
||||
# Initialize beads normally
|
||||
bd init
|
||||
|
||||
# Configure planning repo
|
||||
# Configure routing
|
||||
bd config set routing.mode auto
|
||||
bd config set routing.contributor ~/.beads-planning
|
||||
bd config set routing.maintainer .
|
||||
```
|
||||
|
||||
### Legacy Configuration (Deprecated)
|
||||
|
||||
Older versions used `contributor.*` keys. These still work for backward compatibility:
|
||||
|
||||
```bash
|
||||
# Old keys (deprecated but functional)
|
||||
bd config set contributor.planning_repo ~/.beads-planning
|
||||
bd config set contributor.auto_route true
|
||||
|
||||
# New keys (preferred)
|
||||
bd config set routing.mode auto
|
||||
bd config set routing.contributor ~/.beads-planning
|
||||
```
|
||||
|
||||
## Multi-Repository View
|
||||
@@ -178,10 +194,10 @@ bd list --source-repo ~/.beads-planning # Planning repo only
|
||||
|
||||
### Q: What if I want some issues in the upstream repo?
|
||||
|
||||
A: Override auto-routing with `--source-repo` flag:
|
||||
A: Override auto-routing with `--repo` flag:
|
||||
|
||||
```bash
|
||||
bd create "Document new API" -p 2 --source-repo .
|
||||
bd create "Document new API" -p 2 --repo .
|
||||
```
|
||||
|
||||
### Q: Can I change the planning repo location?
|
||||
@@ -189,7 +205,7 @@ bd create "Document new API" -p 2 --source-repo .
|
||||
A: Yes, configure it:
|
||||
|
||||
```bash
|
||||
bd config set contributor.planning_repo /path/to/my-planning
|
||||
bd config set routing.contributor /path/to/my-planning
|
||||
```
|
||||
|
||||
### Q: What if I have push access to upstream?
|
||||
@@ -198,10 +214,11 @@ A: The wizard will ask if you want a planning repo anyway. You can say "no" to s
|
||||
|
||||
### Q: How do I disable auto-routing?
|
||||
|
||||
A: Turn it off:
|
||||
A: Change routing mode to explicit:
|
||||
|
||||
```bash
|
||||
bd config set contributor.auto_route false
|
||||
bd config set routing.mode explicit
|
||||
bd config set routing.default . # Default to current repo
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -102,3 +104,28 @@ func DetermineTargetRepo(config *RoutingConfig, userRole UserRole, repoPath stri
|
||||
// No routing configured - use current repo
|
||||
return "."
|
||||
}
|
||||
|
||||
// ExpandPath expands ~ to home directory and resolves relative paths to absolute.
|
||||
// Returns the original path if expansion fails.
|
||||
func ExpandPath(path string) string {
|
||||
if path == "" || path == "." {
|
||||
return path
|
||||
}
|
||||
|
||||
// Expand ~ to home directory
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
path = filepath.Join(home, path[2:])
|
||||
}
|
||||
}
|
||||
|
||||
// Convert relative paths to absolute
|
||||
if !filepath.IsAbs(path) {
|
||||
if abs, err := filepath.Abs(path); err == nil {
|
||||
path = abs
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user