* fix(routing): auto-enable hydration and flush JSONL after routed create Fixes split-brain bug where issues routed to different repos (via routing.mode=auto) weren't visible in bd list because JSONL wasn't updated and hydration wasn't configured. **Problem**: When routing.mode=auto routes issues to a separate repo (e.g., ~/.beads-planning), those issues don't appear in 'bd list' because: 1. Target repo's JSONL isn't flushed after create 2. Multi-repo hydration (repos.additional) not configured automatically 3. No doctor warnings about the misconfiguration **Changes**: 1. **Auto-flush JSONL after routed create** (cmd/bd/create.go) - After routing issue to target repo, immediately flush to JSONL - Tries target daemon's export RPC first (if daemon running) - Falls back to direct JSONL export if no daemon - Ensures hydration can read the new issue immediately 2. **Enable hydration in bd init --contributor** (cmd/bd/init_contributor.go) - Wizard now automatically adds planning repo to repos.additional - Users no longer need to manually run 'bd repo add' - Routed issues appear in bd list immediately after setup 3. **Add doctor check for hydrated repo daemons** (cmd/bd/doctor/daemon.go) - New CheckHydratedRepoDaemons() warns if daemons not running - Without daemons, JSONL becomes stale and hydration breaks - Suggests: cd <repo> && bd daemon start --local 4. **Add doctor check for routing+hydration mismatch** (cmd/bd/doctor/config_values.go) - Validates routing targets are in repos.additional - Catches split-brain configuration before users encounter it - Suggests: bd repo add <routing-target> **Testing**: Builds successfully. Unit/integration tests pending. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * test(routing): add comprehensive tests for routing fixes Add unit tests for all 4 routing/hydration fixes: 1. **create_routing_flush_test.go** - Test JSONL flush after routing - TestFlushRoutedRepo_DirectExport: Verify direct JSONL export - TestPerformAtomicExport: Test atomic file operations - TestFlushRoutedRepo_PathExpansion: Test path handling - TestRoutingWithHydrationIntegration: E2E routing+hydration test 2. **daemon_test.go** - Test hydrated repo daemon check - TestCheckHydratedRepoDaemons: Test with/without daemons running - Covers no repos, daemons running, daemons missing scenarios 3. **config_values_test.go** - Test routing+hydration validation - Test routing without hydration (should warn) - Test routing with correct hydration (should pass) - Test routing target not in hydration list (should warn) - Test maintainer="." edge case (should pass) All tests follow existing patterns and use t.TempDir() for isolation. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(tests): fix test failures and refine routing validation logic Fixes test failures and improves validation accuracy: 1. **Fix routing+hydration validation** (config_values.go) - Exclude "." from hasRoutingTargets check (current repo doesn't need hydration) - Prevents false warnings when maintainer="." or contributor="." 2. **Fix test ID generation** (create_routing_flush_test.go) - Use auto-generated IDs instead of hard-coded "beads-test1" - Respects test store prefix configuration (test-) - Fixed json.NewDecoder usage (file handle, not os.Open result) 3. **Fix config validation tests** (config_values_test.go) - Create actual directories for routing paths to pass path validation - Tests now verify both routing+hydration AND path existence checks 4. **Fix daemon test expectations** (daemon_test.go) - When database unavailable, check returns "No additional repos" not error - This is correct behavior (graceful degradation) All tests now pass: - TestFlushRoutedRepo* (3 tests) - TestPerformAtomicExport - TestCheckHydratedRepoDaemons (3 subtests) - TestCheckConfigValues routing tests (5 subtests) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * docs: clarify when git config beads.role maintainer is needed Clarify that maintainer role config is only needed in edge case: - Using GitHub HTTPS URL without credentials - But you have write access (are a maintainer) In most cases, beads auto-detects correctly via: - SSH URLs (git@github.com:owner/repo.git) - HTTPS with credentials This prevents confusion - users with SSH or credential-based HTTPS don't need to manually configure their role. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(lint): address linter warnings in routing flush code - Add missing sqlite import in daemon.go - Fix unchecked client.Close() error return - Fix unchecked tempFile.Close() error returns - Mark unused parameters with _ prefix - Add nolint:gosec for safe tempPath construction Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Roland Tritsch <roland@ailtir.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
280 lines
9.4 KiB
Go
280 lines
9.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/beads/internal/config"
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
)
|
|
|
|
// runContributorWizard guides the user through OSS contributor setup
|
|
func runContributorWizard(ctx context.Context, store storage.Storage) error {
|
|
fmt.Printf("\n%s %s\n\n", ui.RenderBold("bd"), ui.RenderBold("Contributor Workflow Setup Wizard"))
|
|
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("▶"))
|
|
|
|
isFork, upstreamURL := detectForkSetup()
|
|
|
|
if isFork {
|
|
fmt.Printf("%s Detected fork workflow (upstream: %s)\n", ui.RenderPass("✓"), upstreamURL)
|
|
} else {
|
|
fmt.Printf("%s No upstream remote detected\n", ui.RenderWarn("⚠"))
|
|
fmt.Println("\n For fork workflows, add an 'upstream' remote:")
|
|
fmt.Println(" git remote add upstream <original-repo-url>")
|
|
fmt.Println()
|
|
|
|
// Ask if they want to continue anyway
|
|
fmt.Print("Continue with contributor setup? [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
|
|
}
|
|
}
|
|
|
|
// Step 2: Check push access to origin
|
|
fmt.Printf("\n%s Checking repository access...\n", ui.RenderAccent("▶"))
|
|
|
|
hasPushAccess, originURL := checkPushAccess()
|
|
|
|
if hasPushAccess {
|
|
fmt.Printf("%s You have push access to origin (%s)\n", ui.RenderPass("✓"), originURL)
|
|
fmt.Printf(" %s You can commit directly to this repository.\n", ui.RenderWarn("⚠"))
|
|
fmt.Println()
|
|
fmt.Print("Do you want to use a separate planning repo anyway? [Y/n]: ")
|
|
reader := bufio.NewReader(os.Stdin)
|
|
response, _ := reader.ReadString('\n')
|
|
response = strings.TrimSpace(strings.ToLower(response))
|
|
|
|
if response == "n" || response == "no" {
|
|
fmt.Println("\nSetup canceled. Your issues will be stored in the current repository.")
|
|
return nil
|
|
}
|
|
} else {
|
|
fmt.Printf("%s Read-only access to origin (%s)\n", ui.RenderPass("✓"), originURL)
|
|
fmt.Println(" Planning repo recommended to keep experimental work separate.")
|
|
}
|
|
|
|
// Step 3: Configure planning repository
|
|
fmt.Printf("\n%s Setting up planning repository...\n", ui.RenderAccent("▶"))
|
|
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get home directory: %w", err)
|
|
}
|
|
|
|
defaultPlanningRepo := filepath.Join(homeDir, ".beads-planning")
|
|
|
|
fmt.Printf("\nWhere should contributor planning issues be stored?\n")
|
|
fmt.Printf("Default: %s\n", ui.RenderAccent(defaultPlanningRepo))
|
|
fmt.Print("Planning repo path [press Enter for default]: ")
|
|
|
|
reader := bufio.NewReader(os.Stdin)
|
|
planningPath, _ := reader.ReadString('\n')
|
|
planningPath = strings.TrimSpace(planningPath)
|
|
|
|
if planningPath == "" {
|
|
planningPath = defaultPlanningRepo
|
|
}
|
|
|
|
// Expand ~ if present
|
|
if strings.HasPrefix(planningPath, "~/") {
|
|
planningPath = filepath.Join(homeDir, planningPath[2:])
|
|
}
|
|
|
|
// Create planning repository if it doesn't exist
|
|
if _, err := os.Stat(planningPath); os.IsNotExist(err) {
|
|
fmt.Printf("\nCreating planning repository at %s\n", ui.RenderAccent(planningPath))
|
|
|
|
if err := os.MkdirAll(planningPath, 0750); err != nil {
|
|
return fmt.Errorf("failed to create planning repo directory: %w", err)
|
|
}
|
|
|
|
// Initialize git repo in planning directory
|
|
cmd := exec.Command("git", "init")
|
|
cmd.Dir = planningPath
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to initialize git in planning repo: %w", err)
|
|
}
|
|
|
|
// Initialize beads in planning repo
|
|
beadsDir := filepath.Join(planningPath, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
|
return fmt.Errorf("failed to create .beads in planning repo: %w", err)
|
|
}
|
|
|
|
// Create issues.jsonl (canonical name, bd-6xd)
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
// #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)
|
|
}
|
|
|
|
// Create README in planning repo
|
|
readmePath := filepath.Join(planningPath, "README.md")
|
|
readmeContent := fmt.Sprintf(`# Beads Planning Repository
|
|
|
|
This repository stores contributor planning issues for OSS projects.
|
|
|
|
## Purpose
|
|
|
|
- Keep experimental planning separate from upstream PRs
|
|
- Track discovered work and implementation notes
|
|
- Maintain private todos and design exploration
|
|
|
|
## Usage
|
|
|
|
Issues here are automatically created when working on forked repositories.
|
|
|
|
Created by: bd init --contributor
|
|
`)
|
|
// #nosec G306 -- README should be world-readable
|
|
if err := os.WriteFile(readmePath, []byte(readmeContent), 0644); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to create README: %v\n", err)
|
|
}
|
|
|
|
// Initial commit in planning repo
|
|
cmd = exec.Command("git", "add", ".")
|
|
cmd.Dir = planningPath
|
|
_ = cmd.Run()
|
|
|
|
cmd = exec.Command("git", "commit", "-m", "Initial commit: beads planning repository")
|
|
cmd.Dir = planningPath
|
|
_ = cmd.Run()
|
|
|
|
fmt.Printf("%s Planning repository created\n", ui.RenderPass("✓"))
|
|
} else {
|
|
fmt.Printf("%s Using existing planning repository\n", ui.RenderPass("✓"))
|
|
}
|
|
|
|
// Step 4: Configure contributor routing
|
|
fmt.Printf("\n%s Configuring contributor auto-routing...\n", ui.RenderAccent("▶"))
|
|
|
|
// 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)
|
|
}
|
|
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("✓"))
|
|
|
|
// Step 4b: Enable multi-repo hydration so routed issues are visible (bd-fix-routing)
|
|
fmt.Printf("\n%s Configuring multi-repo hydration...\n", ui.RenderAccent("▶"))
|
|
|
|
// Find config.yaml path
|
|
configPath, err := config.FindConfigYAMLPath()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find config.yaml: %w", err)
|
|
}
|
|
|
|
// Add planning repo to repos.additional for hydration
|
|
if err := config.AddRepo(configPath, planningPath); err != nil {
|
|
// Check if already added (non-fatal)
|
|
if !strings.Contains(err.Error(), "already exists") {
|
|
return fmt.Errorf("failed to configure hydration: %w", err)
|
|
}
|
|
}
|
|
|
|
fmt.Printf("%s Hydration enabled for planning repo\n", ui.RenderPass("✓"))
|
|
fmt.Println(" Issues from planning repo will appear in 'bd list'")
|
|
|
|
// If this is a fork, configure sync to pull beads from upstream (bd-bx9)
|
|
// This ensures `bd sync` gets the latest issues from the source repo,
|
|
// not from the fork's potentially outdated origin/main
|
|
if isFork {
|
|
if err := store.SetConfig(ctx, "sync.remote", "upstream"); err != nil {
|
|
return fmt.Errorf("failed to set sync remote: %w", err)
|
|
}
|
|
fmt.Printf("%s Sync configured to pull from upstream (source repo)\n", ui.RenderPass("✓"))
|
|
}
|
|
|
|
// Step 5: Summary
|
|
fmt.Printf("\n%s %s\n\n", ui.RenderPass("✓"), ui.RenderBold("Contributor setup complete!"))
|
|
|
|
fmt.Println("Configuration:")
|
|
fmt.Printf(" Current repo issues: %s\n", ui.RenderAccent(".beads/issues.jsonl"))
|
|
fmt.Printf(" Planning repo issues: %s\n", ui.RenderAccent(filepath.Join(planningPath, ".beads/issues.jsonl")))
|
|
fmt.Println()
|
|
fmt.Println("How it works:")
|
|
fmt.Println(" • Issues you create will route to the planning repo")
|
|
fmt.Println(" • Planning stays out of your PRs to upstream")
|
|
fmt.Println(" • Use 'bd list' to see issues from both repos")
|
|
fmt.Println()
|
|
fmt.Printf("Try it: %s\n", ui.RenderAccent("bd create \"Plan feature X\" -p 2"))
|
|
fmt.Println()
|
|
|
|
return nil
|
|
}
|
|
|
|
// detectForkSetup checks if we're in a fork by looking for upstream remote
|
|
func detectForkSetup() (isFork bool, upstreamURL string) {
|
|
cmd := exec.Command("git", "remote", "get-url", "upstream")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
// No upstream remote found
|
|
return false, ""
|
|
}
|
|
|
|
upstreamURL = strings.TrimSpace(string(output))
|
|
return true, upstreamURL
|
|
}
|
|
|
|
// checkPushAccess determines if we have push access to origin
|
|
func checkPushAccess() (hasPush bool, originURL string) {
|
|
// Get origin URL
|
|
cmd := exec.Command("git", "remote", "get-url", "origin")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return false, ""
|
|
}
|
|
|
|
originURL = strings.TrimSpace(string(output))
|
|
|
|
// SSH URLs indicate likely push access (git@github.com:...)
|
|
if strings.HasPrefix(originURL, "git@") {
|
|
return true, originURL
|
|
}
|
|
|
|
// HTTPS URLs typically indicate read-only clone
|
|
if strings.HasPrefix(originURL, "https://") {
|
|
return false, originURL
|
|
}
|
|
|
|
// Other protocols (file://, etc.) assume push access
|
|
return true, originURL
|
|
}
|