diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1b9ad443..34991e88 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,4 @@ -{"id":"bd-93d","title":"Jira export script (jsonl2jira.py)","description":"Create a Python script to push beads issues to Jira.\n\n**Requires**: Jira import script to be complete first (need external_ref matching logic working)\n\n**Features needed**:\n- Create new Jira issues from beads issues without external_ref\n- Update existing Jira issues matched by external_ref\n- Map beads fields back to Jira fields\n- Handle Jira workflow transitions (status changes may need transitions)\n- Support custom field mapping for design/acceptance_criteria/notes\n\n**Challenges**:\n- Jira status changes often require workflow transitions, not direct updates\n- Need to discover valid transitions via API\n- Custom fields vary by Jira instance\n\n**Usage**:\n```bash\nbd export | python jsonl2jira.py --create-only # Only create, don't update\nbd export | python jsonl2jira.py # Create and update\n```\n\n**After creation**: Sets external_ref on beads issue to link back","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-30T12:56:14.266357-08:00","updated_at":"2025-11-30T15:19:40.264737-08:00","closed_at":"2025-11-30T15:19:40.264737-08:00","dependencies":[{"issue_id":"bd-93d","depends_on_id":"bd-qvj","type":"parent-child","created_at":"2025-11-30T12:56:44.652391-08:00","created_by":"stevey"},{"issue_id":"bd-93d","depends_on_id":"bd-tjn","type":"blocks","created_at":"2025-11-30T12:56:54.941116-08:00","created_by":"stevey"}]} {"id":"bd-clg","title":"bd jira sync command","description":"Add a built-in bd command for Jira synchronization.\n\n**Requires**: Both import and export scripts working\n\n**Features**:\n- `bd jira sync --pull` - Import from Jira to beads\n- `bd jira sync --push` - Export from beads to Jira\n- `bd jira sync` - Bidirectional (pull then push, with conflict resolution)\n- `bd jira status` - Show sync status and last sync time\n\n**Conflict resolution**:\n- Timestamp-based: newer update wins\n- Option for --prefer-local or --prefer-jira to override\n- Interactive mode for manual conflict resolution (optional)\n\n**Integration**:\n- Uses jira.* config settings from bd config\n- Stores last sync timestamp in config\n- Logs sync activity for audit\n\n**Stretch goals**:\n- Webhook integration for real-time sync\n- Selective sync by JQL filter","status":"closed","priority":3,"issue_type":"feature","created_at":"2025-11-30T12:56:27.716537-08:00","updated_at":"2025-11-30T15:25:37.896045-08:00","closed_at":"2025-11-30T15:25:37.896045-08:00","dependencies":[{"issue_id":"bd-clg","depends_on_id":"bd-qvj","type":"parent-child","created_at":"2025-11-30T12:56:49.796568-08:00","created_by":"stevey"},{"issue_id":"bd-clg","depends_on_id":"bd-tjn","type":"blocks","created_at":"2025-11-30T12:57:00.075288-08:00","created_by":"stevey"},{"issue_id":"bd-clg","depends_on_id":"bd-93d","type":"blocks","created_at":"2025-11-30T12:57:05.206431-08:00","created_by":"stevey"}]} {"id":"bd-qvj","title":"Jira import/export integration","description":"Add ability to import issues from Jira and export beads issues to Jira. See GitHub discussion #430 for user request.\n\n**Background**: User @kaihendry configured jira.* settings but found bd sync doesn't sync with Jira - only with git. The config namespace exists but the integration doesn't.\n\n**Existing infrastructure**:\n- external_ref field for linking to external systems\n- Import logic matches by external_ref first (priority over ID matching)\n- Config namespace jira.* documented in CONFIG.md\n- GitHub import example (examples/github-import/gh2jsonl.py) provides pattern\n\n**Schema mapping challenges**:\n- Jira workflows are highly customizable (status mapping)\n- Jira has more field types (components, versions, custom fields)\n- Two-way sync needs conflict resolution\n- Jira Cloud vs Server/Data Center have different APIs\n\n**Approach**: Phased implementation starting with import, then export, then bidirectional sync.","status":"open","priority":2,"issue_type":"epic","created_at":"2025-11-30T12:55:44.835009-08:00","updated_at":"2025-11-30T12:55:44.835009-08:00"} +{"id":"bd-93d","title":"Jira export script (jsonl2jira.py)","description":"Create a Python script to push beads issues to Jira.\n\n**Requires**: Jira import script to be complete first (need external_ref matching logic working)\n\n**Features needed**:\n- Create new Jira issues from beads issues without external_ref\n- Update existing Jira issues matched by external_ref\n- Map beads fields back to Jira fields\n- Handle Jira workflow transitions (status changes may need transitions)\n- Support custom field mapping for design/acceptance_criteria/notes\n\n**Challenges**:\n- Jira status changes often require workflow transitions, not direct updates\n- Need to discover valid transitions via API\n- Custom fields vary by Jira instance\n\n**Usage**:\n```bash\nbd export | python jsonl2jira.py --create-only # Only create, don't update\nbd export | python jsonl2jira.py # Create and update\n```\n\n**After creation**: Sets external_ref on beads issue to link back","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-30T12:56:14.266357-08:00","updated_at":"2025-11-30T15:19:40.264737-08:00","closed_at":"2025-11-30T15:19:40.264737-08:00","dependencies":[{"issue_id":"bd-93d","depends_on_id":"bd-qvj","type":"parent-child","created_at":"2025-11-30T12:56:44.652391-08:00","created_by":"stevey"},{"issue_id":"bd-93d","depends_on_id":"bd-tjn","type":"blocks","created_at":"2025-11-30T12:56:54.941116-08:00","created_by":"stevey"}]} {"id":"bd-tjn","title":"Jira import script (jira2jsonl.py)","description":"Create a Python script to import Jira issues into beads JSONL format.\n\n**Pattern**: Follow examples/github-import/gh2jsonl.py\n\n**Features needed**:\n- Fetch issues via Jira REST API with JQL queries\n- Pagination handling (100 issues per request)\n- Map Jira fields to beads fields (see schema mapping in epic)\n- Set external_ref to Jira issue URL for re-sync\n- Support both Jira Cloud and Server/Data Center APIs\n- Read config from bd config (jira.url, jira.project, jira.api_token)\n\n**Config-driven mapping**:\n- jira.status_map.* for status conversion\n- jira.type_map.* for issue type conversion\n- jira.priority_map.* for priority conversion\n\n**Usage**:\n```bash\npython jira2jsonl.py --project PROJ | bd import\n# or\npython jira2jsonl.py --jql 'project=PROJ AND status\\!=Done' | bd import\n```\n\n**Output**: JSONL with external_ref set to Jira issue URL","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-30T12:55:59.517985-08:00","updated_at":"2025-11-30T15:11:56.692594-08:00","closed_at":"2025-11-30T15:11:56.692594-08:00","dependencies":[{"issue_id":"bd-tjn","depends_on_id":"bd-qvj","type":"parent-child","created_at":"2025-11-30T12:56:39.507643-08:00","created_by":"stevey"}]} diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 9792a187..c90b0b3c 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -25,9 +25,74 @@ import ( // CanonicalDatabaseName is the required database filename for all beads repositories const CanonicalDatabaseName = "beads.db" +// RedirectFileName is the name of the file that redirects to another .beads directory +const RedirectFileName = "redirect" + // LegacyDatabaseNames are old names that should be migrated var LegacyDatabaseNames = []string{"bd.db", "issues.db", "bugs.db"} +// followRedirect checks if a .beads directory contains a redirect file and follows it. +// If a redirect file exists, it returns the target .beads directory path. +// If no redirect exists or there's an error, it returns the original path unchanged. +// +// The redirect file should contain a single path (relative or absolute) to the target +// .beads directory. Relative paths are resolved from the parent directory of the +// original .beads directory (i.e., the project root). +// +// Redirect chains are not followed - only one level of redirection is supported. +// This prevents infinite loops and keeps the behavior predictable. +func followRedirect(beadsDir string) string { + redirectFile := filepath.Join(beadsDir, RedirectFileName) + data, err := os.ReadFile(redirectFile) + if err != nil { + // No redirect file or can't read it - use original path + return beadsDir + } + + // Parse the redirect target (trim whitespace and handle comments) + target := strings.TrimSpace(string(data)) + + // Skip empty lines and comments to find the actual path + lines := strings.Split(target, "\n") + target = "" + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + target = line + break + } + } + + if target == "" { + return beadsDir + } + + // Resolve relative paths from the parent of the .beads directory (project root) + if !filepath.IsAbs(target) { + projectRoot := filepath.Dir(beadsDir) + target = filepath.Join(projectRoot, target) + } + + // Canonicalize the target path + target = utils.CanonicalizePath(target) + + // Verify the target exists and is a directory + info, err := os.Stat(target) + if err != nil || !info.IsDir() { + // Invalid redirect target - fall back to original + fmt.Fprintf(os.Stderr, "Warning: redirect target does not exist or is not a directory: %s\n", target) + return beadsDir + } + + // Prevent redirect chains - don't follow if target also has a redirect + targetRedirect := filepath.Join(target, RedirectFileName) + if _, err := os.Stat(targetRedirect); err == nil { + fmt.Fprintf(os.Stderr, "Warning: redirect chains not allowed, ignoring redirect in %s\n", target) + } + + return target +} + // findDatabaseInBeadsDir searches for a database file within a .beads directory. // It implements the standard search order: // 1. Check config.json first (single source of truth) @@ -200,6 +265,9 @@ func NewSQLiteStorage(ctx context.Context, dbPath string) (Storage, error) { // 2. $BEADS_DB environment variable (points directly to database file, deprecated) // 3. .beads/*.db in current directory or ancestors // +// Redirect files are supported: if a .beads/redirect file exists, its contents +// are used as the actual .beads directory path. +// // Returns empty string if no database is found. func FindDatabasePath() string { // 1. Check BEADS_DIR environment variable (preferred) @@ -207,6 +275,9 @@ func FindDatabasePath() string { // Canonicalize the path to prevent nested .beads directories absBeadsDir := utils.CanonicalizePath(beadsDir) + // Follow redirect if present + absBeadsDir = followRedirect(absBeadsDir) + // Use helper to find database (no warnings for BEADS_DIR - user explicitly set it) if dbPath := findDatabaseInBeadsDir(absBeadsDir, false); dbPath != "" { return dbPath @@ -269,11 +340,17 @@ func hasBeadsProjectFiles(beadsDir string) bool { // Returns empty string if not found. Supports both database and JSONL-only mode. // Stops at the git repository root to avoid finding unrelated directories (bd-c8x). // Validates that the directory contains actual project files (bd-420). +// Redirect files are supported: if a .beads/redirect file exists, its contents +// are used as the actual .beads directory path. // This is useful for commands that need to detect beads projects without requiring a database. func FindBeadsDir() string { // 1. Check BEADS_DIR environment variable (preferred) if beadsDir := os.Getenv("BEADS_DIR"); beadsDir != "" { absBeadsDir := utils.CanonicalizePath(beadsDir) + + // Follow redirect if present + absBeadsDir = followRedirect(absBeadsDir) + if info, err := os.Stat(absBeadsDir); err == nil && info.IsDir() { // Validate directory contains actual project files (bd-420) if hasBeadsProjectFiles(absBeadsDir) { @@ -294,6 +371,9 @@ func FindBeadsDir() string { for dir := cwd; dir != "/" && dir != "."; dir = filepath.Dir(dir) { beadsDir := filepath.Join(dir, ".beads") if info, err := os.Stat(beadsDir); err == nil && info.IsDir() { + // Follow redirect if present + beadsDir = followRedirect(beadsDir) + // Validate directory contains actual project files (bd-420) if hasBeadsProjectFiles(beadsDir) { return beadsDir @@ -345,7 +425,9 @@ func findGitRoot() string { // findDatabaseInTree walks up the directory tree looking for .beads/*.db // Stops at the git repository root to avoid finding unrelated databases (bd-c8x). -// Prefers config.json, falls back to beads.db, and warns if multiple .db files exist +// Prefers config.json, falls back to beads.db, and warns if multiple .db files exist. +// Redirect files are supported: if a .beads/redirect file exists, its contents +// are used as the actual .beads directory path. func findDatabaseInTree() string { dir, err := os.Getwd() if err != nil { @@ -365,6 +447,9 @@ func findDatabaseInTree() string { for { beadsDir := filepath.Join(dir, ".beads") if info, err := os.Stat(beadsDir); err == nil && info.IsDir() { + // Follow redirect if present + beadsDir = followRedirect(beadsDir) + // Use helper to find database (with warnings for auto-discovery) if dbPath := findDatabaseInBeadsDir(beadsDir, true); dbPath != "" { return dbPath @@ -393,6 +478,8 @@ func findDatabaseInTree() string { // Returns a slice of DatabaseInfo for each database found, starting from the // closest to CWD (most relevant) to the furthest (least relevant). // Stops at the git repository root to avoid finding unrelated databases (bd-c8x). +// Redirect files are supported: if a .beads/redirect file exists, its contents +// are used as the actual .beads directory path. func FindAllDatabases() []DatabaseInfo { databases := []DatabaseInfo{} // Initialize to empty slice, never return nil seen := make(map[string]bool) // Track canonical paths to avoid duplicates @@ -409,6 +496,9 @@ func FindAllDatabases() []DatabaseInfo { for { beadsDir := filepath.Join(dir, ".beads") if info, err := os.Stat(beadsDir); err == nil && info.IsDir() { + // Follow redirect if present + beadsDir = followRedirect(beadsDir) + // Found .beads/ directory, look for *.db files matches, err := filepath.Glob(filepath.Join(beadsDir, "*.db")) if err == nil && len(matches) > 0 { diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index d351b0f6..3cc187be 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -497,3 +497,315 @@ func TestFindDatabasePathHomeDefault(t *testing.T) { t.Errorf("Expected absolute path or empty string, got '%s'", result) } } + +// TestFollowRedirect tests the redirect file functionality +func TestFollowRedirect(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T, tmpDir string) (stubDir, targetDir string) + expectRedirect bool + }{ + { + name: "no redirect file - returns original", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + return beadsDir, "" + }, + expectRedirect: false, + }, + { + name: "relative path redirect", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + // Create stub .beads with redirect + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Create target .beads directory + targetDir := filepath.Join(tmpDir, "actual", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetDir, "beads.db"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + // Write redirect file with relative path + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("../actual/.beads\n"), 0644); err != nil { + t.Fatal(err) + } + + return stubDir, targetDir + }, + expectRedirect: true, + }, + { + name: "absolute path redirect", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + // Create stub .beads with redirect + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Create target .beads directory + targetDir := filepath.Join(tmpDir, "actual", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetDir, "beads.db"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + // Write redirect file with absolute path + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte(targetDir+"\n"), 0644); err != nil { + t.Fatal(err) + } + + return stubDir, targetDir + }, + expectRedirect: true, + }, + { + name: "redirect with comments", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + // Create stub .beads with redirect + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Create target .beads directory + targetDir := filepath.Join(tmpDir, "actual", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetDir, "beads.db"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + // Write redirect file with comments + redirectPath := filepath.Join(stubDir, "redirect") + content := "# Redirect to actual beads location\n# This is a workspace redirect\n" + targetDir + "\n" + if err := os.WriteFile(redirectPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + return stubDir, targetDir + }, + expectRedirect: true, + }, + { + name: "redirect to non-existent directory - returns original", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Write redirect to non-existent path + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("/nonexistent/path/.beads\n"), 0644); err != nil { + t.Fatal(err) + } + + return stubDir, "" + }, + expectRedirect: false, // Should fall back to original + }, + { + name: "empty redirect file - returns original", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Write empty redirect file + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte(""), 0644); err != nil { + t.Fatal(err) + } + + return stubDir, "" + }, + expectRedirect: false, + }, + { + name: "redirect file with only comments - returns original", + setupFunc: func(t *testing.T, tmpDir string) (string, string) { + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Write redirect file with only comments + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("# Just a comment\n# Another comment\n"), 0644); err != nil { + t.Fatal(err) + } + + return stubDir, "" + }, + expectRedirect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "beads-redirect-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + stubDir, targetDir := tt.setupFunc(t, tmpDir) + + result := followRedirect(stubDir) + + // Resolve symlinks for comparison (macOS uses /private/var) + resultResolved, _ := filepath.EvalSymlinks(result) + stubResolved, _ := filepath.EvalSymlinks(stubDir) + + if tt.expectRedirect { + targetResolved, _ := filepath.EvalSymlinks(targetDir) + if resultResolved != targetResolved { + t.Errorf("followRedirect() = %q, want %q", result, targetDir) + } + } else { + if resultResolved != stubResolved { + t.Errorf("followRedirect() = %q, want original %q", result, stubDir) + } + } + }) + } +} + +// TestFindDatabasePathWithRedirect tests that FindDatabasePath follows redirects +func TestFindDatabasePathWithRedirect(t *testing.T) { + // Save original state + originalEnv := os.Getenv("BEADS_DIR") + originalWd, _ := os.Getwd() + defer func() { + if originalEnv != "" { + os.Setenv("BEADS_DIR", originalEnv) + } else { + os.Unsetenv("BEADS_DIR") + } + os.Chdir(originalWd) + }() + os.Unsetenv("BEADS_DIR") + + // Create temp directory structure + tmpDir, err := os.MkdirTemp("", "beads-redirect-finddb-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Create stub .beads with redirect + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Create target .beads directory with actual database + targetDir := filepath.Join(tmpDir, "actual", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + targetDB := filepath.Join(targetDir, "beads.db") + if err := os.WriteFile(targetDB, []byte{}, 0644); err != nil { + t.Fatal(err) + } + + // Write redirect file + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("../actual/.beads\n"), 0644); err != nil { + t.Fatal(err) + } + + // Change to project directory + projectDir := filepath.Join(tmpDir, "project") + if err := os.Chdir(projectDir); err != nil { + t.Fatal(err) + } + + // FindDatabasePath should follow the redirect + result := FindDatabasePath() + + // Resolve symlinks for comparison + resultResolved, _ := filepath.EvalSymlinks(result) + targetDBResolved, _ := filepath.EvalSymlinks(targetDB) + + if resultResolved != targetDBResolved { + t.Errorf("FindDatabasePath() = %q, want %q (via redirect)", result, targetDB) + } +} + +// TestFindBeadsDirWithRedirect tests that FindBeadsDir follows redirects +func TestFindBeadsDirWithRedirect(t *testing.T) { + // Save original state + originalEnv := os.Getenv("BEADS_DIR") + originalWd, _ := os.Getwd() + defer func() { + if originalEnv != "" { + os.Setenv("BEADS_DIR", originalEnv) + } else { + os.Unsetenv("BEADS_DIR") + } + os.Chdir(originalWd) + }() + os.Unsetenv("BEADS_DIR") + + // Create temp directory structure + tmpDir, err := os.MkdirTemp("", "beads-redirect-finddir-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Create stub .beads with redirect + stubDir := filepath.Join(tmpDir, "project", ".beads") + if err := os.MkdirAll(stubDir, 0755); err != nil { + t.Fatal(err) + } + + // Create target .beads directory with project files + targetDir := filepath.Join(tmpDir, "actual", ".beads") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetDir, "issues.jsonl"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + // Write redirect file + redirectPath := filepath.Join(stubDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("../actual/.beads\n"), 0644); err != nil { + t.Fatal(err) + } + + // Change to project directory + projectDir := filepath.Join(tmpDir, "project") + if err := os.Chdir(projectDir); err != nil { + t.Fatal(err) + } + + // FindBeadsDir should follow the redirect + result := FindBeadsDir() + + // Resolve symlinks for comparison + resultResolved, _ := filepath.EvalSymlinks(result) + targetDirResolved, _ := filepath.EvalSymlinks(targetDir) + + if resultResolved != targetDirResolved { + t.Errorf("FindBeadsDir() = %q, want %q (via redirect)", result, targetDir) + } +}