diff --git a/cmd/bd/doctor/git.go b/cmd/bd/doctor/git.go index b3d38725..63367c25 100644 --- a/cmd/bd/doctor/git.go +++ b/cmd/bd/doctor/git.go @@ -675,6 +675,129 @@ func FixSyncBranchHealth(path string) error { return fix.DBJSONLSync(path) } +// FindOrphanedIssues identifies issues referenced in git commits but still open in the database. +// This is the shared core logic used by both 'bd orphans' and 'bd doctor' commands. +// Returns empty slice if not a git repo, no database, or no orphans found (no error). +func FindOrphanedIssues(path string) ([]OrphanIssue, error) { + // Skip if not in a git repo + cmd := exec.Command("git", "rev-parse", "--git-dir") + cmd.Dir = path + if err := cmd.Run(); err != nil { + return []OrphanIssue{}, nil // Not a git repo, return empty list + } + + beadsDir := filepath.Join(path, ".beads") + + // Skip if no .beads directory + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + return []OrphanIssue{}, nil + } + + // Get database path + dbPath := filepath.Join(beadsDir, "beads.db") + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return []OrphanIssue{}, nil + } + + // Open database read-only + db, err := openDBReadOnly(dbPath) + if err != nil { + return []OrphanIssue{}, nil + } + defer db.Close() + + // Get issue prefix from config + var issuePrefix string + err = db.QueryRow("SELECT value FROM config WHERE key = 'issue_prefix'").Scan(&issuePrefix) + if err != nil || issuePrefix == "" { + issuePrefix = "bd" // default + } + + // Get all open/in_progress issues with their titles (title is optional for compatibility) + var rows *sql.Rows + rows, err = db.Query("SELECT id, title, status FROM issues WHERE status IN ('open', 'in_progress')") + // If the query fails (e.g., no title column), fall back to simpler query + if err != nil { + rows, err = db.Query("SELECT id, '', status FROM issues WHERE status IN ('open', 'in_progress')") + if err != nil { + return []OrphanIssue{}, nil + } + } + defer rows.Close() + + openIssues := make(map[string]*OrphanIssue) + for rows.Next() { + var id, title, status string + if err := rows.Scan(&id, &title, &status); err == nil { + openIssues[id] = &OrphanIssue{ + IssueID: id, + Title: title, + Status: status, + } + } + } + + if len(openIssues) == 0 { + return []OrphanIssue{}, nil + } + + // Get git log + cmd = exec.Command("git", "log", "--oneline", "--all") + cmd.Dir = path + output, err := cmd.Output() + if err != nil { + return []OrphanIssue{}, nil + } + + // Parse commits for issue references + // Match pattern like (bd-xxx) or (bd-xxx.1) including hierarchical IDs + pattern := fmt.Sprintf(`\(%s-[a-z0-9.]+\)`, regexp.QuoteMeta(issuePrefix)) + re := regexp.MustCompile(pattern) + + var orphanedIssues []OrphanIssue + lines := strings.Split(string(output), "\n") + + for _, line := range lines { + if line == "" { + continue + } + + // Extract commit hash and message + parts := strings.SplitN(line, " ", 2) + if len(parts) < 1 { + continue + } + + commitHash := parts[0] + commitMsg := "" + if len(parts) > 1 { + commitMsg = parts[1] + } + + // Find issue IDs in this commit + matches := re.FindAllString(line, -1) + for _, match := range matches { + issueID := strings.Trim(match, "()") + if orphan, exists := openIssues[issueID]; exists { + // Only record first (most recent) commit per issue + if orphan.LatestCommit == "" { + orphan.LatestCommit = commitHash + orphan.LatestCommitMessage = commitMsg + } + } + } + } + + // Collect issues with commit references + for _, orphan := range openIssues { + if orphan.LatestCommit != "" { + orphanedIssues = append(orphanedIssues, *orphan) + } + } + + return orphanedIssues, nil +} + // CheckOrphanedIssues detects issues referenced in git commits but still open. // This catches cases where someone implemented a fix with "(bd-xxx)" in the commit // message but forgot to run "bd close". @@ -714,97 +837,40 @@ func CheckOrphanedIssues(path string) DoctorCheck { } } - // Open database read-only + // Use the shared FindOrphanedIssues function + orphans, err := FindOrphanedIssues(path) + if err != nil { + return DoctorCheck{ + Name: "Orphaned Issues", + Status: StatusOK, + Message: "N/A (unable to check orphaned issues)", + Category: CategoryGit, + } + } + + // Check for "no open issues" case - this requires checking the database + // since FindOrphanedIssues silently returns empty slice db, err := openDBReadOnly(dbPath) - if err != nil { - return DoctorCheck{ - Name: "Orphaned Issues", - Status: StatusOK, - Message: "N/A (unable to open database)", - Category: CategoryGit, - } - } - defer db.Close() - - // Get issue prefix from config - var issuePrefix string - err = db.QueryRow("SELECT value FROM config WHERE key = 'issue_prefix'").Scan(&issuePrefix) - if err != nil || issuePrefix == "" { - issuePrefix = "bd" // default - } - - // Get all open issue IDs - rows, err := db.Query("SELECT id FROM issues WHERE status IN ('open', 'in_progress')") - if err != nil { - return DoctorCheck{ - Name: "Orphaned Issues", - Status: StatusOK, - Message: "N/A (unable to query issues)", - Category: CategoryGit, - } - } - defer rows.Close() - - openSet := make(map[string]bool) - for rows.Next() { - var id string - if err := rows.Scan(&id); err == nil { - openSet[id] = true - } - } - - if len(openSet) == 0 { - return DoctorCheck{ - Name: "Orphaned Issues", - Status: StatusOK, - Message: "No open issues to check", - Category: CategoryGit, - } - } - - // Get issue IDs referenced in git commits - cmd = exec.Command("git", "log", "--oneline", "--all") - cmd.Dir = path - output, err := cmd.Output() - if err != nil { - return DoctorCheck{ - Name: "Orphaned Issues", - Status: StatusOK, - Message: "N/A (unable to read git log)", - Category: CategoryGit, - } - } - - // Parse commit messages for issue references - // Match pattern like (bd-xxx) or (bd-xxx.1) including hierarchical IDs - pattern := fmt.Sprintf(`\(%s-[a-z0-9.]+\)`, regexp.QuoteMeta(issuePrefix)) - re := regexp.MustCompile(pattern) - - // Track which open issues appear in commits (with first commit hash) - orphanedIssues := make(map[string]string) // issue ID -> commit hash - lines := strings.Split(string(output), "\n") - for _, line := range lines { - if line == "" { - continue - } - matches := re.FindAllString(line, -1) - for _, match := range matches { - // Extract issue ID (remove parentheses) - issueID := strings.Trim(match, "()") - if openSet[issueID] { - // Only record the first (most recent) commit - if _, exists := orphanedIssues[issueID]; !exists { - // Extract commit hash (first word of line) - parts := strings.SplitN(line, " ", 2) - if len(parts) > 0 { - orphanedIssues[issueID] = parts[0] + if err == nil { + defer db.Close() + rows, err := db.Query("SELECT COUNT(*) FROM issues WHERE status IN ('open', 'in_progress')") + if err == nil { + defer rows.Close() + if rows.Next() { + var count int + if err := rows.Scan(&count); err == nil && count == 0 { + return DoctorCheck{ + Name: "Orphaned Issues", + Status: StatusOK, + Message: "No open issues to check", + Category: CategoryGit, } } } } } - if len(orphanedIssues) == 0 { + if len(orphans) == 0 { return DoctorCheck{ Name: "Orphaned Issues", Status: StatusOK, @@ -815,14 +881,14 @@ func CheckOrphanedIssues(path string) DoctorCheck { // Build detail message var details []string - for id, commit := range orphanedIssues { - details = append(details, fmt.Sprintf("%s (commit %s)", id, commit)) + for _, orphan := range orphans { + details = append(details, fmt.Sprintf("%s (commit %s)", orphan.IssueID, orphan.LatestCommit)) } return DoctorCheck{ Name: "Orphaned Issues", Status: StatusWarning, - Message: fmt.Sprintf("%d issue(s) referenced in commits but still open", len(orphanedIssues)), + Message: fmt.Sprintf("%d issue(s) referenced in commits but still open", len(orphans)), Detail: strings.Join(details, ", "), Fix: "Run 'bd show ' to check if implemented, then 'bd close ' if done", Category: CategoryGit, diff --git a/cmd/bd/doctor/types.go b/cmd/bd/doctor/types.go index 4e5dcbf9..7182838f 100644 --- a/cmd/bd/doctor/types.go +++ b/cmd/bd/doctor/types.go @@ -41,3 +41,13 @@ type DoctorCheck struct { Fix string `json:"fix,omitempty"` Category string `json:"category,omitempty"` // category for grouping in output } + +// OrphanIssue represents an issue referenced in commits but still open. +// This is shared between 'bd orphans' and 'bd doctor' commands. +type OrphanIssue struct { + IssueID string + Title string + Status string + LatestCommit string + LatestCommitMessage string +} diff --git a/cmd/bd/orphans.go b/cmd/bd/orphans.go new file mode 100644 index 00000000..7007e9e6 --- /dev/null +++ b/cmd/bd/orphans.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/cmd/bd/doctor" + "github.com/steveyegge/beads/internal/ui" +) + +var orphansCmd = &cobra.Command{ + Use: "orphans", + Short: "Identify orphaned issues (referenced in commits but still open)", + Long: `Identify orphaned issues - issues that are referenced in commit messages but remain open or in_progress in the database. + +This helps identify work that has been implemented but not formally closed. + +Examples: + bd orphans # Show orphaned issues + bd orphans --json # Machine-readable output + bd orphans --details # Show full commit information + bd orphans --fix # Close orphaned issues with confirmation`, + Run: func(cmd *cobra.Command, args []string) { + path := "." + orphans, err := findOrphanedIssues(path) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + fix, _ := cmd.Flags().GetBool("fix") + details, _ := cmd.Flags().GetBool("details") + + if jsonOutput { + outputJSON(orphans) + return + } + + if len(orphans) == 0 { + fmt.Printf("%s No orphaned issues found\n", ui.RenderPass("✓")) + return + } + + fmt.Printf("\n%s Found %d orphaned issue(s):\n\n", ui.RenderWarn("⚠"), len(orphans)) + + // Sort by issue ID for consistent output + sort.Slice(orphans, func(i, j int) bool { + return orphans[i].IssueID < orphans[j].IssueID + }) + + for i, orphan := range orphans { + fmt.Printf("%d. %s: %s\n", i+1, ui.RenderID(orphan.IssueID), orphan.Title) + fmt.Printf(" Status: %s\n", orphan.Status) + if details && orphan.LatestCommit != "" { + fmt.Printf(" Latest commit: %s - %s\n", orphan.LatestCommit, orphan.LatestCommitMessage) + } + } + + if fix { + fmt.Println() + fmt.Printf("This will close %d orphaned issue(s). Continue? (Y/n): ", len(orphans)) + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + if response != "" && response != "y" && response != "yes" { + fmt.Println("Canceled.") + return + } + + // Close orphaned issues + closedCount := 0 + for _, orphan := range orphans { + err := closeIssue(orphan.IssueID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", orphan.IssueID, err) + } else { + fmt.Printf("✓ Closed %s\n", orphan.IssueID) + closedCount++ + } + } + fmt.Printf("\nClosed %d issue(s)\n", closedCount) + } + }, +} + +// orphanIssueOutput is the JSON output format for orphaned issues +type orphanIssueOutput struct { + IssueID string `json:"issue_id"` + Title string `json:"title"` + Status string `json:"status"` + LatestCommit string `json:"latest_commit,omitempty"` + LatestCommitMessage string `json:"latest_commit_message,omitempty"` +} + +// findOrphanedIssues wraps the shared doctor package function and converts to output format +func findOrphanedIssues(path string) ([]orphanIssueOutput, error) { + orphans, err := doctor.FindOrphanedIssues(path) + if err != nil { + return nil, fmt.Errorf("unable to find orphaned issues: %w", err) + } + + var output []orphanIssueOutput + for _, orphan := range orphans { + output = append(output, orphanIssueOutput{ + IssueID: orphan.IssueID, + Title: orphan.Title, + Status: orphan.Status, + LatestCommit: orphan.LatestCommit, + LatestCommitMessage: orphan.LatestCommitMessage, + }) + } + return output, nil +} + +// closeIssue closes an issue using bd close +func closeIssue(issueID string) error { + cmd := exec.Command("bd", "close", issueID, "--reason", "Implemented") + return cmd.Run() +} + +func init() { + orphansCmd.Flags().BoolP("fix", "f", false, "Close orphaned issues with confirmation") + orphansCmd.Flags().Bool("details", false, "Show full commit information") + rootCmd.AddCommand(orphansCmd) +} diff --git a/cmd/bd/orphans_test.go b/cmd/bd/orphans_test.go new file mode 100644 index 00000000..c4bc1c5e --- /dev/null +++ b/cmd/bd/orphans_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// TestOrphansBasic tests basic orphan detection +func TestOrphansBasic(t *testing.T) { + // Create a temporary directory with a git repo and beads database + tmpDir := t.TempDir() + + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to init git repo: %v", err) + } + + // Configure git user (needed for commits) + ctx := context.Background() + for _, cmd := range []*exec.Cmd{ + exec.CommandContext(ctx, "git", "-C", tmpDir, "config", "user.email", "test@example.com"), + exec.CommandContext(ctx, "git", "-C", tmpDir, "config", "user.name", "Test User"), + } { + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to configure git: %v", err) + } + } + + // Create .beads directory + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("Failed to create .beads dir: %v", err) + } + + // Create a minimal database with beads.db + // For this test, we'll skip creating an actual database + // since the test is primarily about integration + + // Test: findOrphanedIssues should handle missing database gracefully + orphans, err := findOrphanedIssues(tmpDir) + if err != nil { + t.Fatalf("findOrphanedIssues failed: %v", err) + } + + // Should be empty list since no database + if len(orphans) != 0 { + t.Errorf("Expected empty orphans list, got %d", len(orphans)) + } +} + +// TestOrphansNotGitRepo tests behavior in non-git directories +func TestOrphansNotGitRepo(t *testing.T) { + tmpDir := t.TempDir() + + // Should not error, just return empty list + orphans, err := findOrphanedIssues(tmpDir) + if err != nil { + t.Fatalf("findOrphanedIssues failed: %v", err) + } + + if len(orphans) != 0 { + t.Errorf("Expected empty orphans list for non-git repo, got %d", len(orphans)) + } +} + +// TestCloseIssueCommand tests that close issue command is properly formed +func TestCloseIssueCommand(t *testing.T) { + // This is a basic test to ensure the closeIssue function + // attempts to run the correct command. + // In a real environment, this would fail since bd close requires + // a valid beads database. + + // Just test that the function doesn't panic + // (actual close will fail, which is expected) + _ = closeIssue("bd-test-invalid") + // Error is expected since the issue doesn't exist +}