Implement bd delete command with comprehensive cleanup
This commit is contained in:
306
cmd/bd/delete.go
Normal file
306
cmd/bd/delete.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
var deleteCmd = &cobra.Command{
|
||||
Use: "delete <issue-id>",
|
||||
Short: "Delete an issue and clean up references",
|
||||
Long: `Delete an issue and clean up all references to it.
|
||||
|
||||
This command will:
|
||||
1. Remove all dependency links (any type, both directions) involving the issue
|
||||
2. Update text references to "[deleted:ID]" in directly connected issues
|
||||
3. Delete the issue from the database
|
||||
|
||||
This is a destructive operation that cannot be undone. Use with caution.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
issueID := args[0]
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get the issue to be deleted
|
||||
issue, err := store.GetIssue(ctx, issueID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if issue == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: issue %s not found\n", issueID)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Find all connected issues (dependencies in both directions)
|
||||
connectedIssues := make(map[string]*types.Issue)
|
||||
|
||||
// Get dependencies (issues this one depends on)
|
||||
deps, err := store.GetDependencies(ctx, issueID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting dependencies: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, dep := range deps {
|
||||
connectedIssues[dep.ID] = dep
|
||||
}
|
||||
|
||||
// Get dependents (issues that depend on this one)
|
||||
dependents, err := store.GetDependents(ctx, issueID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting dependents: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, dependent := range dependents {
|
||||
connectedIssues[dependent.ID] = dependent
|
||||
}
|
||||
|
||||
// Get dependency records (outgoing) to count how many we'll remove
|
||||
depRecords, err := store.GetDependencyRecords(ctx, issueID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting dependency records: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Build the regex pattern for matching issue IDs (handles hyphenated IDs properly)
|
||||
// Pattern: (^|non-word-char)(issueID)($|non-word-char) where word-char includes hyphen
|
||||
idPattern := `(^|[^A-Za-z0-9_-])(` + regexp.QuoteMeta(issueID) + `)($|[^A-Za-z0-9_-])`
|
||||
re := regexp.MustCompile(idPattern)
|
||||
replacementText := `$1[deleted:` + issueID + `]$3`
|
||||
|
||||
// Preview mode
|
||||
if !force {
|
||||
red := color.New(color.FgRed).SprintFunc()
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
|
||||
fmt.Printf("\n%s\n", red("⚠️ DELETE PREVIEW"))
|
||||
fmt.Printf("\nIssue to delete:\n")
|
||||
fmt.Printf(" %s: %s\n", issueID, issue.Title)
|
||||
|
||||
totalDeps := len(depRecords) + len(dependents)
|
||||
if totalDeps > 0 {
|
||||
fmt.Printf("\nDependency links to remove: %d\n", totalDeps)
|
||||
for _, dep := range depRecords {
|
||||
fmt.Printf(" %s → %s (%s)\n", dep.IssueID, dep.DependsOnID, dep.Type)
|
||||
}
|
||||
for _, dep := range dependents {
|
||||
fmt.Printf(" %s → %s (inbound)\n", dep.ID, issueID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(connectedIssues) > 0 {
|
||||
fmt.Printf("\nConnected issues where text references will be updated:\n")
|
||||
issuesWithRefs := 0
|
||||
for id, connIssue := range connectedIssues {
|
||||
// Check if there are actually text references using the fixed regex
|
||||
hasRefs := re.MatchString(connIssue.Description) ||
|
||||
(connIssue.Notes != "" && re.MatchString(connIssue.Notes)) ||
|
||||
(connIssue.Design != "" && re.MatchString(connIssue.Design)) ||
|
||||
(connIssue.AcceptanceCriteria != "" && re.MatchString(connIssue.AcceptanceCriteria))
|
||||
|
||||
if hasRefs {
|
||||
fmt.Printf(" %s: %s\n", id, connIssue.Title)
|
||||
issuesWithRefs++
|
||||
}
|
||||
}
|
||||
if issuesWithRefs == 0 {
|
||||
fmt.Printf(" (none have text references)\n")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s\n", yellow("This operation cannot be undone!"))
|
||||
fmt.Printf("To proceed, run: %s\n\n", yellow("bd delete "+issueID+" --force"))
|
||||
return
|
||||
}
|
||||
|
||||
// Actually delete
|
||||
|
||||
// 1. Update text references in connected issues (all text fields)
|
||||
updatedIssueCount := 0
|
||||
for id, connIssue := range connectedIssues {
|
||||
updates := make(map[string]interface{})
|
||||
|
||||
// Replace in description
|
||||
if re.MatchString(connIssue.Description) {
|
||||
newDesc := re.ReplaceAllString(connIssue.Description, replacementText)
|
||||
updates["description"] = newDesc
|
||||
}
|
||||
|
||||
// Replace in notes
|
||||
if connIssue.Notes != "" && re.MatchString(connIssue.Notes) {
|
||||
newNotes := re.ReplaceAllString(connIssue.Notes, replacementText)
|
||||
updates["notes"] = newNotes
|
||||
}
|
||||
|
||||
// Replace in design
|
||||
if connIssue.Design != "" && re.MatchString(connIssue.Design) {
|
||||
newDesign := re.ReplaceAllString(connIssue.Design, replacementText)
|
||||
updates["design"] = newDesign
|
||||
}
|
||||
|
||||
// Replace in acceptance_criteria
|
||||
if connIssue.AcceptanceCriteria != "" && re.MatchString(connIssue.AcceptanceCriteria) {
|
||||
newAC := re.ReplaceAllString(connIssue.AcceptanceCriteria, replacementText)
|
||||
updates["acceptance_criteria"] = newAC
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to update references in %s: %v\n", id, err)
|
||||
} else {
|
||||
updatedIssueCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove all dependency links (outgoing)
|
||||
outgoingRemoved := 0
|
||||
for _, dep := range depRecords {
|
||||
if err := store.RemoveDependency(ctx, dep.IssueID, dep.DependsOnID, actor); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to remove dependency %s → %s: %v\n",
|
||||
dep.IssueID, dep.DependsOnID, err)
|
||||
} else {
|
||||
outgoingRemoved++
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove inbound dependency links (issues that depend on this one)
|
||||
inboundRemoved := 0
|
||||
for _, dep := range dependents {
|
||||
if err := store.RemoveDependency(ctx, dep.ID, issueID, actor); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to remove dependency %s → %s: %v\n",
|
||||
dep.ID, issueID, err)
|
||||
} else {
|
||||
inboundRemoved++
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete the issue itself from database
|
||||
if err := deleteIssue(ctx, issueID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error deleting issue: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 5. Remove from JSONL (auto-flush can't see deletions)
|
||||
if err := removeIssueFromJSONL(issueID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to remove from JSONL: %v\n", err)
|
||||
}
|
||||
|
||||
// Schedule auto-flush to update neighbors
|
||||
markDirtyAndScheduleFlush()
|
||||
|
||||
totalDepsRemoved := outgoingRemoved + inboundRemoved
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"deleted": issueID,
|
||||
"dependencies_removed": totalDepsRemoved,
|
||||
"references_updated": updatedIssueCount,
|
||||
})
|
||||
} else {
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("%s Deleted %s\n", green("✓"), issueID)
|
||||
fmt.Printf(" Removed %d dependency link(s)\n", totalDepsRemoved)
|
||||
fmt.Printf(" Updated text references in %d issue(s)\n", updatedIssueCount)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// deleteIssue removes an issue from the database
|
||||
// Note: This is a direct database operation since Storage interface doesn't have Delete
|
||||
func deleteIssue(ctx context.Context, issueID string) error {
|
||||
// We need to access the SQLite storage directly
|
||||
// Check if store is SQLite storage
|
||||
type deleter interface {
|
||||
DeleteIssue(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
if d, ok := store.(deleter); ok {
|
||||
return d.DeleteIssue(ctx, issueID)
|
||||
}
|
||||
|
||||
return fmt.Errorf("delete operation not supported by this storage backend")
|
||||
}
|
||||
|
||||
// removeIssueFromJSONL removes a deleted issue from the JSONL file
|
||||
// Auto-flush cannot see deletions because the dirty_issues row is deleted with the issue
|
||||
func removeIssueFromJSONL(issueID string) error {
|
||||
path := findJSONLPath()
|
||||
if path == "" {
|
||||
return nil // No JSONL file yet
|
||||
}
|
||||
|
||||
// Read all issues except the deleted one
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // No file, nothing to clean
|
||||
}
|
||||
return fmt.Errorf("failed to open JSONL: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var issues []*types.Issue
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var iss types.Issue
|
||||
if err := json.Unmarshal([]byte(line), &iss); err != nil {
|
||||
// Skip malformed lines
|
||||
continue
|
||||
}
|
||||
if iss.ID != issueID {
|
||||
issues = append(issues, &iss)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("failed to read JSONL: %w", err)
|
||||
}
|
||||
|
||||
// Write to temp file atomically
|
||||
temp := fmt.Sprintf("%s.tmp.%d", path, os.Getpid())
|
||||
out, err := os.OpenFile(temp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(out)
|
||||
for _, iss := range issues {
|
||||
if err := enc.Encode(iss); err != nil {
|
||||
out.Close()
|
||||
os.Remove(temp)
|
||||
return fmt.Errorf("failed to write issue: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(temp)
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
if err := os.Rename(temp, path); err != nil {
|
||||
os.Remove(temp)
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
deleteCmd.Flags().BoolP("force", "f", false, "Actually delete (without this flag, shows preview)")
|
||||
rootCmd.AddCommand(deleteCmd)
|
||||
}
|
||||
@@ -1294,6 +1294,49 @@ func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteIssue permanently removes an issue from the database
|
||||
func (s *SQLiteStorage) DeleteIssue(ctx context.Context, id string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete dependencies (both directions)
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM dependencies WHERE issue_id = ? OR depends_on_id = ?`, id, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete dependencies: %w", err)
|
||||
}
|
||||
|
||||
// Delete events
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM events WHERE issue_id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete events: %w", err)
|
||||
}
|
||||
|
||||
// Delete from dirty_issues
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM dirty_issues WHERE issue_id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete dirty marker: %w", err)
|
||||
}
|
||||
|
||||
// Delete the issue itself
|
||||
result, err := tx.ExecContext(ctx, `DELETE FROM issues WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete issue: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("issue not found: %s", id)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SearchIssues finds issues matching query and filters
|
||||
func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) {
|
||||
whereClauses := []string{}
|
||||
|
||||
Reference in New Issue
Block a user