diff --git a/cmd/task/bulk.go b/cmd/task/bulk.go new file mode 100644 index 00000000..b079e855 --- /dev/null +++ b/cmd/task/bulk.go @@ -0,0 +1,365 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/bborn/workflow/internal/db" +) + +// parseTaskIDs parses a slice of string arguments into task IDs. +func parseTaskIDs(args []string) ([]int64, error) { + var ids []int64 + for _, arg := range args { + var id int64 + if _, err := fmt.Sscanf(arg, "%d", &id); err != nil { + return nil, fmt.Errorf("invalid task ID: %s", arg) + } + ids = append(ids, id) + } + return ids, nil +} + +// completeMultipleTaskIDs provides completion for commands that accept multiple task IDs. +func completeMultipleTaskIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return fetchTaskCompletions(toComplete) +} + +// completeStatusThenMultipleTaskIDs completes status for first arg, then task IDs. +func completeStatusThenMultipleTaskIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return validStatuses(), cobra.ShellCompDirectiveNoFileComp + } + return fetchTaskCompletions(toComplete) +} + +// newBulkCmd creates the bulk parent command and its subcommands. +func newBulkCmd() *cobra.Command { + bulkCmd := &cobra.Command{ + Use: "bulk", + Short: "Perform operations on multiple tasks at once", + Long: `Bulk operations let you act on multiple tasks in a single command. + +Examples: + ty bulk status done 10 11 12 + ty bulk delete 5 6 7 + ty bulk close 10 11 12 + ty bulk execute 10 11 12 + ty bulk archive 10 11 12`, + } + + // bulk status [task-id...] + bulkStatusCmd := &cobra.Command{ + Use: "status [task-id...]", + Short: "Set status on multiple tasks", + ValidArgsFunction: completeStatusThenMultipleTaskIDs, + Long: `Change the status of multiple tasks at once. + +Valid statuses: backlog, queued, processing, blocked, done, archived. + +Examples: + ty bulk status done 10 11 12 + ty bulk status backlog 5 6 + ty bulk status archived 1 2 3`, + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + status := strings.ToLower(strings.TrimSpace(args[0])) + if !isValidStatus(status) { + fmt.Fprintln(os.Stderr, errorStyle.Render("Invalid status. Must be one of: "+strings.Join(validStatuses(), ", "))) + os.Exit(1) + } + + ids, err := parseTaskIDs(args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(err.Error())) + os.Exit(1) + } + + database, err := db.Open(db.DefaultPath()) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + defer database.Close() + + var succeeded, failed int + for _, id := range ids { + task, err := database.GetTask(id) + if err != nil || task == nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Task #%d not found, skipping", id))) + failed++ + continue + } + if err := database.UpdateTaskStatus(id, status); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Error updating task #%d: %v", id, err))) + failed++ + continue + } + fmt.Println(successStyle.Render(fmt.Sprintf("Task #%d moved to %s", id, status))) + succeeded++ + } + + printBulkSummary("status change", succeeded, failed) + }, + } + bulkCmd.AddCommand(bulkStatusCmd) + + // bulk delete [task-id...] + bulkDeleteCmd := &cobra.Command{ + Use: "delete [task-id...]", + Short: "Delete multiple tasks", + ValidArgsFunction: completeMultipleTaskIDs, + Long: `Delete multiple tasks, killing their agent sessions and removing worktrees. + +Examples: + ty bulk delete 5 6 7 + ty bulk delete --force 1 2 3`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ids, err := parseTaskIDs(args) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(err.Error())) + os.Exit(1) + } + + force, _ := cmd.Flags().GetBool("force") + + // Confirm unless --force + if !force { + // Show what will be deleted + database, err := db.Open(db.DefaultPath()) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + fmt.Printf("Tasks to delete:\n") + for _, id := range ids { + task, err := database.GetTask(id) + if err != nil || task == nil { + fmt.Printf(" #%d (not found)\n", id) + } else { + fmt.Printf(" #%d: %s\n", id, task.Title) + } + } + database.Close() + + fmt.Printf("\nDelete %d task(s)? [y/N] ", len(ids)) + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + fmt.Println("Cancelled") + return + } + } + + var succeeded, failed int + for _, id := range ids { + if err := deleteTask(id); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Error deleting task #%d: %v", id, err))) + failed++ + continue + } + fmt.Println(successStyle.Render(fmt.Sprintf("Deleted task #%d", id))) + succeeded++ + } + + printBulkSummary("deletion", succeeded, failed) + }, + } + bulkDeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + bulkCmd.AddCommand(bulkDeleteCmd) + + // bulk close [task-id...] + bulkCloseCmd := &cobra.Command{ + Use: "close [task-id...]", + Aliases: []string{"done", "complete"}, + Short: "Mark multiple tasks as done", + ValidArgsFunction: completeMultipleTaskIDs, + Long: `Mark multiple tasks as completed. + +Examples: + ty bulk close 10 11 12 + ty bulk done 5 6`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ids, err := parseTaskIDs(args) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(err.Error())) + os.Exit(1) + } + + database, err := db.Open(db.DefaultPath()) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + defer database.Close() + + var succeeded, failed int + for _, id := range ids { + task, err := database.GetTask(id) + if err != nil || task == nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Task #%d not found, skipping", id))) + failed++ + continue + } + if task.Status == db.StatusDone { + fmt.Println(dimStyle.Render(fmt.Sprintf("Task #%d is already done, skipping", id))) + continue + } + if err := database.UpdateTaskStatus(id, db.StatusDone); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Error closing task #%d: %v", id, err))) + failed++ + continue + } + fmt.Println(successStyle.Render(fmt.Sprintf("Closed task #%d: %s", id, task.Title))) + succeeded++ + } + + printBulkSummary("close", succeeded, failed) + }, + } + bulkCmd.AddCommand(bulkCloseCmd) + + // bulk execute [task-id...] + bulkExecuteCmd := &cobra.Command{ + Use: "execute [task-id...]", + Aliases: []string{"queue", "run"}, + Short: "Queue multiple tasks for execution", + ValidArgsFunction: completeMultipleTaskIDs, + Long: `Queue multiple tasks for execution by the daemon. + +Examples: + ty bulk execute 10 11 12 + ty bulk queue 5 6 + ty bulk execute --dangerous 10 11 12`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ids, err := parseTaskIDs(args) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(err.Error())) + os.Exit(1) + } + + executeDangerous, _ := cmd.Flags().GetBool("dangerous") + + database, err := db.Open(db.DefaultPath()) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + defer database.Close() + + var succeeded, failed int + for _, id := range ids { + task, err := database.GetTask(id) + if err != nil || task == nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Task #%d not found, skipping", id))) + failed++ + continue + } + if task.Status == db.StatusQueued { + fmt.Println(dimStyle.Render(fmt.Sprintf("Task #%d is already queued, skipping", id))) + continue + } + if task.Status == db.StatusProcessing { + fmt.Println(dimStyle.Render(fmt.Sprintf("Task #%d is already processing, skipping", id))) + continue + } + + if executeDangerous { + if err := database.UpdateTaskDangerousMode(id, true); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Error setting dangerous mode for task #%d: %v", id, err))) + failed++ + continue + } + } + + if err := database.UpdateTaskStatus(id, db.StatusQueued); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Error queueing task #%d: %v", id, err))) + failed++ + continue + } + msg := fmt.Sprintf("Queued task #%d: %s", id, task.Title) + if executeDangerous { + msg += " (dangerous mode)" + } + fmt.Println(successStyle.Render(msg)) + succeeded++ + } + + printBulkSummary("queue", succeeded, failed) + }, + } + bulkExecuteCmd.Flags().Bool("dangerous", false, "Execute in dangerous mode (skip permission prompts)") + bulkCmd.AddCommand(bulkExecuteCmd) + + // bulk archive [task-id...] + bulkArchiveCmd := &cobra.Command{ + Use: "archive [task-id...]", + Short: "Archive multiple tasks", + ValidArgsFunction: completeMultipleTaskIDs, + Long: `Archive multiple tasks at once. + +Examples: + ty bulk archive 1 2 3`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ids, err := parseTaskIDs(args) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(err.Error())) + os.Exit(1) + } + + database, err := db.Open(db.DefaultPath()) + if err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) + os.Exit(1) + } + defer database.Close() + + var succeeded, failed int + for _, id := range ids { + task, err := database.GetTask(id) + if err != nil || task == nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Task #%d not found, skipping", id))) + failed++ + continue + } + if task.Status == db.StatusArchived { + fmt.Println(dimStyle.Render(fmt.Sprintf("Task #%d is already archived, skipping", id))) + continue + } + if err := database.UpdateTaskStatus(id, db.StatusArchived); err != nil { + fmt.Fprintln(os.Stderr, errorStyle.Render(fmt.Sprintf("Error archiving task #%d: %v", id, err))) + failed++ + continue + } + fmt.Println(successStyle.Render(fmt.Sprintf("Archived task #%d: %s", id, task.Title))) + succeeded++ + } + + printBulkSummary("archive", succeeded, failed) + }, + } + bulkCmd.AddCommand(bulkArchiveCmd) + + return bulkCmd +} + +// printBulkSummary prints a summary line for bulk operations. +func printBulkSummary(operation string, succeeded, failed int) { + if succeeded+failed == 0 { + return + } + if failed == 0 { + fmt.Println(successStyle.Render(fmt.Sprintf("\nBulk %s complete: %d succeeded", operation, succeeded))) + } else { + fmt.Println(dimStyle.Render(fmt.Sprintf("\nBulk %s complete: %d succeeded, %d failed", operation, succeeded, failed))) + } +} diff --git a/cmd/task/bulk_test.go b/cmd/task/bulk_test.go new file mode 100644 index 00000000..6ad079ef --- /dev/null +++ b/cmd/task/bulk_test.go @@ -0,0 +1,259 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/bborn/workflow/internal/db" +) + +func setupBulkTestDB(t *testing.T) (*db.DB, func()) { + t.Helper() + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + + cleanup := func() { + database.Close() + os.Remove(dbPath) + } + + return database, cleanup +} + +func createTestTasks(t *testing.T, database *db.DB, count int) []int64 { + t.Helper() + var ids []int64 + for i := 0; i < count; i++ { + task := &db.Task{ + Title: "Test task " + string(rune('A'+i)), + Status: db.StatusBacklog, + Type: db.TypeCode, + } + if err := database.CreateTask(task); err != nil { + t.Fatalf("failed to create task %d: %v", i, err) + } + ids = append(ids, task.ID) + } + return ids +} + +func TestParseTaskIDs(t *testing.T) { + tests := []struct { + name string + args []string + want []int64 + wantErr bool + }{ + { + name: "single ID", + args: []string{"42"}, + want: []int64{42}, + }, + { + name: "multiple IDs", + args: []string{"1", "2", "3"}, + want: []int64{1, 2, 3}, + }, + { + name: "invalid ID", + args: []string{"1", "abc", "3"}, + wantErr: true, + }, + { + name: "empty args", + args: []string{}, + want: nil, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseTaskIDs(tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("parseTaskIDs() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if len(got) != len(tt.want) { + t.Errorf("parseTaskIDs() = %v, want %v", got, tt.want) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("parseTaskIDs()[%d] = %d, want %d", i, got[i], tt.want[i]) + } + } + } + }) + } +} + +func TestBulkStatusChange(t *testing.T) { + database, cleanup := setupBulkTestDB(t) + defer cleanup() + + ids := createTestTasks(t, database, 3) + + // Change all to done + for _, id := range ids { + if err := database.UpdateTaskStatus(id, db.StatusDone); err != nil { + t.Fatalf("failed to update task %d: %v", id, err) + } + } + + // Verify all are done + for _, id := range ids { + task, err := database.GetTask(id) + if err != nil { + t.Fatalf("failed to get task %d: %v", id, err) + } + if task.Status != db.StatusDone { + t.Errorf("task #%d status = %s, want %s", id, task.Status, db.StatusDone) + } + } +} + +func TestBulkDeleteTasks(t *testing.T) { + database, cleanup := setupBulkTestDB(t) + defer cleanup() + + ids := createTestTasks(t, database, 3) + + // Delete all + for _, id := range ids { + if err := database.DeleteTask(id); err != nil { + t.Fatalf("failed to delete task %d: %v", id, err) + } + } + + // Verify all deleted + for _, id := range ids { + task, err := database.GetTask(id) + if err != nil { + t.Fatalf("unexpected error getting deleted task %d: %v", id, err) + } + if task != nil { + t.Errorf("task #%d should be deleted but still exists", id) + } + } +} + +func TestBulkArchiveTasks(t *testing.T) { + database, cleanup := setupBulkTestDB(t) + defer cleanup() + + ids := createTestTasks(t, database, 3) + + // Archive all + for _, id := range ids { + if err := database.UpdateTaskStatus(id, db.StatusArchived); err != nil { + t.Fatalf("failed to archive task %d: %v", id, err) + } + } + + // Verify all archived + for _, id := range ids { + task, err := database.GetTask(id) + if err != nil { + t.Fatalf("failed to get task %d: %v", id, err) + } + if task.Status != db.StatusArchived { + t.Errorf("task #%d status = %s, want %s", id, task.Status, db.StatusArchived) + } + } +} + +func TestBulkCloseTasks(t *testing.T) { + database, cleanup := setupBulkTestDB(t) + defer cleanup() + + ids := createTestTasks(t, database, 3) + + // Close all + for _, id := range ids { + if err := database.UpdateTaskStatus(id, db.StatusDone); err != nil { + t.Fatalf("failed to close task %d: %v", id, err) + } + } + + // Verify all done + for _, id := range ids { + task, err := database.GetTask(id) + if err != nil { + t.Fatalf("failed to get task %d: %v", id, err) + } + if task.Status != db.StatusDone { + t.Errorf("task #%d status = %s, want %s", id, task.Status, db.StatusDone) + } + } +} + +func TestBulkQueueTasks(t *testing.T) { + database, cleanup := setupBulkTestDB(t) + defer cleanup() + + ids := createTestTasks(t, database, 3) + + // Queue all + for _, id := range ids { + if err := database.UpdateTaskStatus(id, db.StatusQueued); err != nil { + t.Fatalf("failed to queue task %d: %v", id, err) + } + } + + // Verify all queued + for _, id := range ids { + task, err := database.GetTask(id) + if err != nil { + t.Fatalf("failed to get task %d: %v", id, err) + } + if task.Status != db.StatusQueued { + t.Errorf("task #%d status = %s, want %s", id, task.Status, db.StatusQueued) + } + } +} + +func TestBulkSkipsAlreadyDone(t *testing.T) { + database, cleanup := setupBulkTestDB(t) + defer cleanup() + + ids := createTestTasks(t, database, 2) + + // Set first task to done already + if err := database.UpdateTaskStatus(ids[0], db.StatusDone); err != nil { + t.Fatalf("failed to set task done: %v", err) + } + + // Verify first is already done + task, _ := database.GetTask(ids[0]) + if task.Status != db.StatusDone { + t.Fatalf("expected task to be done") + } + + // Second task should still be backlog + task, _ = database.GetTask(ids[1]) + if task.Status != db.StatusBacklog { + t.Fatalf("expected task to be backlog") + } +} + +func TestBulkHandlesMissingTasks(t *testing.T) { + database, cleanup := setupBulkTestDB(t) + defer cleanup() + + // Try to get a non-existent task + task, err := database.GetTask(9999) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if task != nil { + t.Error("expected nil for non-existent task") + } +} diff --git a/cmd/task/main.go b/cmd/task/main.go index ea5bd898..f90659a3 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -3125,6 +3125,9 @@ The server shares the same SQLite database the daemon writes to (WAL mode).`, serveCmd.Flags().Int("port", 8080, "Port to listen on") rootCmd.AddCommand(serveCmd) + // Bulk operations + rootCmd.AddCommand(newBulkCmd()) + // Completion command for shell tab completion rootCmd.AddCommand(newCompletionCmd(rootCmd))