feat: Add 'bd orphans' command with DRY refactoring of orphan detection
Implements the 'bd orphans' command to identify issues referenced in commits but still open in the database, and refactors to eliminate code duplication. - Creates cmd/bd/orphans.go with Cobra command structure - Identifies orphaned issues (referenced in git commits but still open/in_progress) - Supports multiple output formats (human, JSON, detailed) - Auto-close with --fix flag DRY refactoring: - Extracted FindOrphanedIssues() to cmd/bd/doctor/git.go as shared core logic - Moved OrphanIssue type to cmd/bd/doctor/types.go - Refactored CheckOrphanedIssues() to use FindOrphanedIssues() Cherry-picked from PR #767 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -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 <id>' to check if implemented, then 'bd close <id>' if done",
|
||||
Category: CategoryGit,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
129
cmd/bd/orphans.go
Normal file
129
cmd/bd/orphans.go
Normal file
@@ -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)
|
||||
}
|
||||
82
cmd/bd/orphans_test.go
Normal file
82
cmd/bd/orphans_test.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user