From 8dc7cc8b6304045afe315af970607d269525f2fb Mon Sep 17 00:00:00 2001 From: Paul Milbank Date: Fri, 16 Jan 2026 10:52:13 +1300 Subject: [PATCH 1/4] feat: add a new db UpdateEpicStatus method to set the epic status from the drover CLI --- internal/db/db.go | 39 +++++++++++++++----- internal/db/db_test.go | 83 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/internal/db/db.go b/internal/db/db.go index d384e44..4f6e0ad 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -373,6 +373,27 @@ func (s *Store) CreateEpic(title, description string) (*types.Epic, error) { return epic, nil } +// UpdateEpicStatus sets the status of an epic using the typed EpicStatus. +func (s *Store) UpdateEpicStatus(epicID string, status types.EpicStatus) error { + result, err := s.DB.Exec(` + UPDATE epics + SET status = ? + WHERE id = ? + `, string(status), epicID) + if err != nil { + return fmt.Errorf("updating epic status: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("getting rows affected: %w", err) + } + if rows == 0 { + return fmt.Errorf("epic not found: %s", epicID) + } + return nil +} + // CreateTask creates a new task with optional dependencies func (s *Store) CreateTask(title, description, epicID string, priority int, blockedBy []string) (*types.Task, error) { return s.CreateTaskWithOperator(title, description, epicID, priority, blockedBy, "") @@ -1143,15 +1164,15 @@ func (s *Store) ListAllDependencies() ([]types.TaskDependency, error) { // WorktreeInfo represents a worktree with its metadata type WorktreeInfo struct { - TaskID string - Path string - Branch string - CreatedAt int64 - LastUsedAt int64 - Status string - DiskSize int64 - TaskStatus string - TaskTitle string + TaskID string + Path string + Branch string + CreatedAt int64 + LastUsedAt int64 + Status string + DiskSize int64 + TaskStatus string + TaskTitle string } // CreateWorktree records a new worktree in the database diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 15f7666..3ea88f3 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -596,3 +596,86 @@ func TestStore_ResetTasksByIDs_Empty(t *testing.T) { t.Errorf("Expected task status to still be 'completed', got '%s'", status) } } + +func TestStore_UpdateEpicStatus(t *testing.T) { + store, _ := setupTestDB(t) + defer store.Close() + + // Create a test epic + epic, err := store.CreateEpic("Test Epic", "Test Description") + if err != nil { + t.Fatalf("Failed to create epic: %v", err) + } + + // Verify initial status + if epic.Status != types.EpicStatusOpen { + t.Errorf("Expected initial status to be 'open', got '%s'", epic.Status) + } + + // Update epic status to closed + err = store.UpdateEpicStatus(epic.ID, types.EpicStatusClosed) + if err != nil { + t.Fatalf("Failed to update epic status: %v", err) + } + + // Retrieve the epic and verify status was updated + epics, err := store.ListEpics() + if err != nil { + t.Fatalf("Failed to list epics: %v", err) + } + + if len(epics) != 1 { + t.Fatalf("Expected 1 epic, got %d", len(epics)) + } + + if epics[0].Status != types.EpicStatusClosed { + t.Errorf("Expected epic status to be 'closed', got '%s'", epics[0].Status) + } + + // Update back to open + err = store.UpdateEpicStatus(epic.ID, types.EpicStatusOpen) + if err != nil { + t.Fatalf("Failed to update epic status back to open: %v", err) + } + + // Verify it was updated + epics, err = store.ListEpics() + if err != nil { + t.Fatalf("Failed to list epics: %v", err) + } + + if epics[0].Status != types.EpicStatusOpen { + t.Errorf("Expected epic status to be 'open', got '%s'", epics[0].Status) + } +} + +func TestStore_UpdateEpicStatus_NotFound(t *testing.T) { + store, _ := setupTestDB(t) + defer store.Close() + + // Try to update a non-existent epic + err := store.UpdateEpicStatus("epic-nonexistent", types.EpicStatusClosed) + if err == nil { + t.Fatal("Expected error when updating non-existent epic, got nil") + } + + // Verify error message mentions epic not found + expectedMsg := "epic not found" + if !contains(err.Error(), expectedMsg) { + t.Errorf("Expected error to contain '%s', got '%s'", expectedMsg, err.Error()) + } +} + +// Helper function to check if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && stringContains(s, substr)) +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} From 7c08a04603c04c0412c4b29da465f43b5d2adfee Mon Sep 17 00:00:00 2001 From: Paul Milbank Date: Fri, 16 Jan 2026 10:53:37 +1300 Subject: [PATCH 2/4] feat: add list and close commands to the CLI for epics --- cmd/drover/commands.go | 59 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/cmd/drover/commands.go b/cmd/drover/commands.go index c664120..61d984e 100644 --- a/cmd/drover/commands.go +++ b/cmd/drover/commands.go @@ -574,7 +574,64 @@ func epicCmd() *cobra.Command { return cmd.Help() }, } - command.AddCommand(epicAdd) + // epic list - show all epics + epicList := &cobra.Command{ + Use: "list", + Short: "List all epics", + RunE: func(cmd *cobra.Command, args []string) error { + _, store, err := requireProject() + if err != nil { + return err + } + defer store.Close() + + epics, err := store.ListEpics() + if err != nil { + return fmt.Errorf("listing epics: %w", err) + } + + if len(epics) == 0 { + fmt.Println("No epics found. Create one with: drover epic add \"My Epic\"") + return nil + } + + for _, epic := range epics { + emoji := "📘" + switch string(epic.Status) { + case "open": + emoji = "📖" + case "closed": + emoji = "📕" + } + fmt.Printf("%s %s — %s (%s)\n", emoji, epic.ID, epic.Title, epic.Status) + } + + return nil + }, + } + + // epic close - close an epic + epicClose := &cobra.Command{ + Use: "close ", + Short: "Close an epic", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + _, store, err := requireProject() + if err != nil { + return err + } + defer store.Close() + + epicID := args[0] + if err := store.UpdateEpicStatus(epicID, types.EpicStatusClosed); err != nil { + return err + } + fmt.Printf("🔒 Closed epic %s\n", epicID) + return nil + }, + } + + command.AddCommand(epicAdd, epicList, epicClose) return command } From 2cf3e0c9e9d8d528fb87841e785a77542f788a8b Mon Sep 17 00:00:00 2001 From: Paul Milbank Date: Fri, 16 Jan 2026 10:53:54 +1300 Subject: [PATCH 3/4] fix: stop ignoring files in drover directory --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ce36ea3..a617634 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries -drover +/drover *.exe *.exe~ *.dll From 3aaf5da09bf66427870bb42d37d89f154c7d2787 Mon Sep 17 00:00:00 2001 From: Paul Milbank Date: Fri, 16 Jan 2026 10:54:15 +1300 Subject: [PATCH 4/4] fix: fmt and import ordering --- cmd/drover/commands.go | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/cmd/drover/commands.go b/cmd/drover/commands.go index 61d984e..340fe36 100644 --- a/cmd/drover/commands.go +++ b/cmd/drover/commands.go @@ -19,8 +19,8 @@ import ( "github.com/cloud-shuttle/drover/internal/db" "github.com/cloud-shuttle/drover/internal/git" "github.com/cloud-shuttle/drover/internal/template" - "github.com/cloud-shuttle/drover/pkg/types" "github.com/cloud-shuttle/drover/internal/workflow" + "github.com/cloud-shuttle/drover/pkg/types" "github.com/dbos-inc/dbos-transact-golang/dbos" "github.com/spf13/cobra" ) @@ -299,11 +299,11 @@ func runWithSQLite(cmd *cobra.Command, runCfg *config.Config, store *db.Store, p func addCmd() *cobra.Command { var ( - desc string - epicID string - parentID string - priority int - blockedBy []string + desc string + epicID string + parentID string + priority int + blockedBy []string skipValidation bool ) @@ -321,7 +321,7 @@ Hierarchical Tasks: drover add "Sub-task title" --parent task-123 Maximum depth is 2 levels (Epic → Parent → Child).`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { _, store, err := requireProject() if err != nil { @@ -751,10 +751,10 @@ and other metadata. Useful for inspecting individual task details.`, func resetCmd() *cobra.Command { var ( - resetCompleted bool + resetCompleted bool resetInProgress bool - resetClaimed bool - resetFailed bool + resetClaimed bool + resetFailed bool ) command := &cobra.Command{ @@ -998,13 +998,13 @@ func exportSession(projectDir string, store *db.Store, outputPath, format string // Build session export session := map[string]interface{}{ - "version": "1.0", - "exportedAt": time.Now().Format(time.RFC3339), - "repository": projectDir, - "tasks": tasks, - "epics": epics, + "version": "1.0", + "exportedAt": time.Now().Format(time.RFC3339), + "repository": projectDir, + "tasks": tasks, + "epics": epics, "dependencies": dependencies, - "worktrees": worktrees, + "worktrees": worktrees, } // Determine output path @@ -1969,6 +1969,7 @@ func formatTimestamp(timestamp int64) string { t := time.Unix(timestamp, 0) return t.Format("2006-01-02 15:04:05") } + // worktreeCmd returns the worktree management command func worktreeCmd() *cobra.Command { cmd := &cobra.Command{