diff --git a/internal/cmd/purge.go b/internal/cmd/purge.go new file mode 100644 index 0000000..2cbe483 --- /dev/null +++ b/internal/cmd/purge.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kostyay/kticket/internal/ticket" + "github.com/spf13/cobra" +) + +var purgeCmd = &cobra.Command{ + Use: "purge", + Short: "Delete all closed tickets", + Long: "Permanently delete all closed ticket files. Validates that no open tickets reference them.", + RunE: runPurge, +} + +func init() { + rootCmd.AddCommand(purgeCmd) +} + +type purgeResult struct { + Deleted int `json:"deleted"` + Errors []string `json:"errors,omitempty"` +} + +func runPurge(cmd *cobra.Command, args []string) error { + allTickets, err := Store.List() + if err != nil { + return fmt.Errorf("list tickets: %w", err) + } + + var closedTickets []*ticket.Ticket + for _, t := range allTickets { + if t.Status == ticket.StatusClosed { + closedTickets = append(closedTickets, t) + } + } + + if len(closedTickets) == 0 { + if IsJSON() { + return PrintJSON(purgeResult{Deleted: 0}) + } + fmt.Println("No closed tickets to purge") + return nil + } + + if err := validatePurge(allTickets, closedTickets); err != nil { + return err + } + + if IsJSON() { + return fmt.Errorf("refusing to purge in JSON mode (interactive confirmation required)") + } + + confirmed, err := promptConfirmation(closedTickets) + if err != nil { + return fmt.Errorf("prompt: %w", err) + } + + if !confirmed { + fmt.Println("Purge cancelled") + return nil + } + + for _, t := range closedTickets { + path := filepath.Join(Store.Dir, t.ID+".md") + if err := os.Remove(path); err != nil { + return fmt.Errorf("delete %s: %w", t.ID, err) + } + } + + fmt.Printf("Purged %d tickets\n", len(closedTickets)) + return nil +} + +func validatePurge(allTickets, closedTickets []*ticket.Ticket) error { + closedSet := make(map[string]bool) + for _, t := range closedTickets { + closedSet[t.ID] = true + } + + for _, t := range allTickets { + if t.Status == ticket.StatusClosed { + continue + } + + if t.Parent != "" && closedSet[t.Parent] { + return fmt.Errorf("cannot purge %s: ticket %s has it as parent", t.Parent, t.ID) + } + + for _, dep := range t.Deps { + if closedSet[dep] { + return fmt.Errorf("cannot purge %s: ticket %s depends on it", dep, t.ID) + } + } + + for _, link := range t.Links { + if closedSet[link] { + return fmt.Errorf("cannot purge %s: ticket %s links to it", link, t.ID) + } + } + } + + return nil +} + +func promptConfirmation(tickets []*ticket.Ticket) (bool, error) { + fmt.Printf("Found %d closed tickets:\n", len(tickets)) + for _, t := range tickets { + fmt.Printf(" %s: %s\n", t.ID, t.Title) + } + fmt.Printf("\nPurge %d tickets? [y/N] ", len(tickets)) + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + return false, err + } + + response = strings.ToLower(strings.TrimSpace(response)) + return response == "y" || response == "yes", nil +} diff --git a/internal/cmd/purge_test.go b/internal/cmd/purge_test.go new file mode 100644 index 0000000..a3c528e --- /dev/null +++ b/internal/cmd/purge_test.go @@ -0,0 +1,273 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/kostyay/kticket/internal/ticket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPurgeBasic(t *testing.T) { + defer setupTestEnv(t)() + + open := mkTicket(t, "kt-001", "Open Task", ticket.StatusOpen) + closed1 := mkTicket(t, "kt-002", "Closed Task 1", ticket.StatusClosed) + closed2 := mkTicket(t, "kt-003", "Closed Task 2", ticket.StatusClosed) + + mockStdin(t, "y\n") + + err := runPurge(nil, nil) + require.NoError(t, err) + + files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) + assert.Len(t, files, 1) + + _, err = Store.Get(open.ID) + assert.NoError(t, err) + + _, err = Store.Get(closed1.ID) + assert.Error(t, err) + _, err = Store.Get(closed2.ID) + assert.Error(t, err) +} + +func TestPurgeNoClosedTickets(t *testing.T) { + defer setupTestEnv(t)() + + mkTicket(t, "kt-001", "Open Task 1", ticket.StatusOpen) + mkTicket(t, "kt-002", "Open Task 2", ticket.StatusOpen) + + err := runPurge(nil, nil) + require.NoError(t, err) + + // Verify all tickets still exist + files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) + assert.Len(t, files, 2) +} + +func TestPurgeBlockedByParent(t *testing.T) { + defer setupTestEnv(t)() + + parent := mkTicket(t, "kt-parent", "Parent Epic", ticket.StatusClosed) + child := mkTicket(t, "kt-child", "Child Task", ticket.StatusOpen) + + child.Parent = parent.ID + require.NoError(t, Store.Save(child)) + + err := runPurge(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot purge") + assert.Contains(t, err.Error(), parent.ID) + assert.Contains(t, err.Error(), "parent") + + _, err = Store.Get(parent.ID) + assert.NoError(t, err) +} + +func TestPurgeBlockedByDep(t *testing.T) { + defer setupTestEnv(t)() + + dep := mkTicket(t, "kt-dep", "Dependency", ticket.StatusClosed) + task := mkTicket(t, "kt-task", "Task", ticket.StatusOpen) + + task.Deps = []string{dep.ID} + require.NoError(t, Store.Save(task)) + + err := runPurge(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot purge") + assert.Contains(t, err.Error(), dep.ID) + assert.Contains(t, err.Error(), "depends") + + _, err = Store.Get(dep.ID) + assert.NoError(t, err) +} + +func TestPurgeBlockedByLink(t *testing.T) { + defer setupTestEnv(t)() + + linked := mkTicket(t, "kt-linked", "Linked", ticket.StatusClosed) + task := mkTicket(t, "kt-task", "Task", ticket.StatusOpen) + + task.Links = []string{linked.ID} + require.NoError(t, Store.Save(task)) + + err := runPurge(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot purge") + assert.Contains(t, err.Error(), linked.ID) + assert.Contains(t, err.Error(), "links") + + _, err = Store.Get(linked.ID) + assert.NoError(t, err) +} + +func TestPurgeUserCancels(t *testing.T) { + defer setupTestEnv(t)() + + closed := mkTicket(t, "kt-001", "Closed Task", ticket.StatusClosed) + + mockStdin(t, "n\n") + + err := runPurge(nil, nil) + require.NoError(t, err) + + _, err = Store.Get(closed.ID) + assert.NoError(t, err) + + files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) + assert.Len(t, files, 1) +} + +func TestPurgeJSONMode(t *testing.T) { + defer setupTestEnv(t)() + jsonFlag = true + defer func() { jsonFlag = false }() + + mkTicket(t, "kt-001", "Closed Task", ticket.StatusClosed) + + err := runPurge(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "refusing to purge in JSON mode") + + files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) + assert.Len(t, files, 1) +} + +func TestPurgeJSONModeNoClosed(t *testing.T) { + defer setupTestEnv(t)() + jsonFlag = true + defer func() { jsonFlag = false }() + + mkTicket(t, "kt-001", "Open Task", ticket.StatusOpen) + + err := runPurge(nil, nil) + require.NoError(t, err) +} + +func TestValidatePurge(t *testing.T) { + defer setupTestEnv(t)() + + closed1 := mkTicket(t, "kt-closed1", "Closed 1", ticket.StatusClosed) + closed2 := mkTicket(t, "kt-closed2", "Closed 2", ticket.StatusClosed) + open1 := mkTicket(t, "kt-open1", "Open 1", ticket.StatusOpen) + open2 := mkTicket(t, "kt-open2", "Open 2", ticket.StatusOpen) + + allTickets := []*ticket.Ticket{closed1, closed2, open1, open2} + closedTickets := []*ticket.Ticket{closed1, closed2} + + err := validatePurge(allTickets, closedTickets) + assert.NoError(t, err) + + open1.Parent = closed1.ID + err = validatePurge(allTickets, closedTickets) + assert.Error(t, err) + assert.Contains(t, err.Error(), closed1.ID) + assert.Contains(t, err.Error(), "parent") + + open1.Parent = "" + open1.Deps = []string{closed2.ID} + err = validatePurge(allTickets, closedTickets) + assert.Error(t, err) + assert.Contains(t, err.Error(), closed2.ID) + assert.Contains(t, err.Error(), "depends") + + open1.Deps = nil + open2.Links = []string{closed1.ID} + err = validatePurge(allTickets, closedTickets) + assert.Error(t, err) + assert.Contains(t, err.Error(), closed1.ID) + assert.Contains(t, err.Error(), "links") +} + +func TestPromptConfirmation(t *testing.T) { + tickets := []*ticket.Ticket{ + {ID: "kt-001", Status: ticket.StatusClosed, Title: "First Closed"}, + {ID: "kt-002", Status: ticket.StatusClosed, Title: "Second Closed"}, + } + + tests := []struct { + input string + want bool + }{ + {"y\n", true}, + {"yes\n", true}, + {"n\n", false}, + {"no\n", false}, + {"\n", false}, + } + + for _, tc := range tests { + mockStdin(t, tc.input) + confirmed, err := promptConfirmation(tickets) + require.NoError(t, err) + assert.Equal(t, tc.want, confirmed) + } +} + +func TestPurgeMultipleReferences(t *testing.T) { + defer setupTestEnv(t)() + + closed := mkTicket(t, "kt-closed", "Closed", ticket.StatusClosed) + open1 := mkTicket(t, "kt-open1", "Open 1", ticket.StatusOpen) + open2 := mkTicket(t, "kt-open2", "Open 2", ticket.StatusOpen) + + open1.Deps = []string{closed.ID} + open2.Links = []string{closed.ID} + require.NoError(t, Store.Save(open1)) + require.NoError(t, Store.Save(open2)) + + err := runPurge(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot purge") +} + +func TestPurgeOnlyClosedReferences(t *testing.T) { + defer setupTestEnv(t)() + + closed1 := mkTicket(t, "kt-closed1", "Closed 1", ticket.StatusClosed) + closed2 := mkTicket(t, "kt-closed2", "Closed 2", ticket.StatusClosed) + + closed1.Deps = []string{closed2.ID} + closed2.Links = []string{closed1.ID} + require.NoError(t, Store.Save(closed1)) + require.NoError(t, Store.Save(closed2)) + + mockStdin(t, "y\n") + + err := runPurge(nil, nil) + require.NoError(t, err) + + files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) + assert.Len(t, files, 0) +} + +func TestPurgeInProgressReferences(t *testing.T) { + defer setupTestEnv(t)() + + closed := mkTicket(t, "kt-closed", "Closed", ticket.StatusClosed) + inProgress := mkTicket(t, "kt-progress", "In Progress", ticket.StatusInProgress) + + inProgress.Deps = []string{closed.ID} + require.NoError(t, Store.Save(inProgress)) + + err := runPurge(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot purge") +} + +func mockStdin(t *testing.T, input string) { + t.Helper() + oldStdin := os.Stdin + t.Cleanup(func() { os.Stdin = oldStdin }) + + r, w, _ := os.Pipe() + os.Stdin = r + go func() { + defer w.Close() + w.Write([]byte(input)) + }() +}