Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Binaries
drover
/drover
*.exe
*.exe~
*.dll
Expand Down
92 changes: 75 additions & 17 deletions cmd/drover/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
)

Expand All @@ -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 {
Expand Down Expand Up @@ -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 <epic-id>",
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
}

Expand Down Expand Up @@ -694,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{
Expand Down Expand Up @@ -941,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
Expand Down Expand Up @@ -1912,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{
Expand Down
39 changes: 30 additions & 9 deletions internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
Expand Down Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions internal/db/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}