Two lint errors introduced in recent commits: - cmd/bd/doctor/git.go:896 - unchecked provider.Close() - cmd/bd/orphans.go:120 - unchecked provider.Close() Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
169 lines
5.2 KiB
Go
169 lines
5.2 KiB
Go
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/storage"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
)
|
|
|
|
// doctorFindOrphanedIssues is the function used to find orphaned issues.
|
|
// It accepts a git path and an IssueProvider for flexibility (cross-repo, mock testing).
|
|
var doctorFindOrphanedIssues = doctor.FindOrphanedIssues
|
|
|
|
var closeIssueRunner = func(issueID string) error {
|
|
cmd := exec.Command("bd", "close", issueID, "--reason", "Implemented")
|
|
return cmd.Run()
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// getIssueProvider returns an IssueProvider based on the current configuration.
|
|
// If --db flag is set, it creates a provider from that database path.
|
|
// Otherwise, it uses the global store (already opened in PersistentPreRun).
|
|
func getIssueProvider() (types.IssueProvider, func(), error) {
|
|
// If --db flag is set and we have a dbPath, create a provider from that path
|
|
if dbPath != "" {
|
|
provider, err := storage.NewLocalProvider(dbPath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to open database at %s: %w", dbPath, err)
|
|
}
|
|
return provider, func() { _ = provider.Close() }, nil
|
|
}
|
|
|
|
// Use the global store (already opened by PersistentPreRun)
|
|
if store != nil {
|
|
provider := storage.NewStorageProvider(store)
|
|
return provider, func() {}, nil // No cleanup needed for global store
|
|
}
|
|
|
|
return nil, nil, fmt.Errorf("no database available")
|
|
}
|
|
|
|
// findOrphanedIssues wraps the shared doctor package function and converts to output format.
|
|
// It respects the --db flag for cross-repo orphan detection.
|
|
func findOrphanedIssues(path string) ([]orphanIssueOutput, error) {
|
|
provider, cleanup, err := getIssueProvider()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to find orphaned issues: %w", err)
|
|
}
|
|
defer cleanup()
|
|
|
|
orphans, err := doctorFindOrphanedIssues(path, provider)
|
|
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 {
|
|
return closeIssueRunner(issueID)
|
|
}
|
|
|
|
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)
|
|
}
|