package main import ( "encoding/json" "fmt" "os" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/ui" ) var epicCmd = &cobra.Command{ Use: "epic", GroupID: "deps", Short: "Epic management commands", } var epicStatusCmd = &cobra.Command{ Use: "status", Short: "Show epic completion status", Run: func(cmd *cobra.Command, args []string) { eligibleOnly, _ := cmd.Flags().GetBool("eligible-only") // Use global jsonOutput set by PersistentPreRun var epics []*types.EpicStatus var err error if daemonClient != nil { resp, err := daemonClient.EpicStatus(&rpc.EpicStatusArgs{ EligibleOnly: eligibleOnly, }) if err != nil { FatalErrorRespectJSON("communicating with daemon: %v", err) } if !resp.Success { FatalErrorRespectJSON("getting epic status: %s", resp.Error) } if err := json.Unmarshal(resp.Data, &epics); err != nil { FatalErrorRespectJSON("parsing response: %v", err) } } else { ctx := rootCtx epics, err = store.GetEpicsEligibleForClosure(ctx) if err != nil { FatalErrorRespectJSON("getting epic status: %v", err) } if eligibleOnly { filtered := []*types.EpicStatus{} for _, epic := range epics { if epic.EligibleForClose { filtered = append(filtered, epic) } } epics = filtered } } if jsonOutput { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") if err := enc.Encode(epics); err != nil { FatalErrorRespectJSON("encoding JSON: %v", err) } return } // Human-readable output if len(epics) == 0 { fmt.Println("No open epics found") return } for _, epicStatus := range epics { epic := epicStatus.Epic percentage := 0 if epicStatus.TotalChildren > 0 { percentage = (epicStatus.ClosedChildren * 100) / epicStatus.TotalChildren } statusIcon := "" if epicStatus.EligibleForClose { statusIcon = ui.RenderPass("✓") } else if percentage > 0 { statusIcon = ui.RenderWarn("○") } else { statusIcon = "○" } fmt.Printf("%s %s %s\n", statusIcon, ui.RenderAccent(epic.ID), ui.RenderBold(epic.Title)) fmt.Printf(" Progress: %d/%d children closed (%d%%)\n", epicStatus.ClosedChildren, epicStatus.TotalChildren, percentage) if epicStatus.EligibleForClose { fmt.Printf(" %s\n", ui.RenderPass("Eligible for closure")) } fmt.Println() } }, } var closeEligibleEpicsCmd = &cobra.Command{ Use: "close-eligible", Short: "Close epics where all children are complete", Run: func(cmd *cobra.Command, args []string) { dryRun, _ := cmd.Flags().GetBool("dry-run") // Block writes in readonly mode (closing modifies data) if !dryRun { CheckReadonly("epic close-eligible") } // Use global jsonOutput set by PersistentPreRun var eligibleEpics []*types.EpicStatus if daemonClient != nil { resp, err := daemonClient.EpicStatus(&rpc.EpicStatusArgs{ EligibleOnly: true, }) if err != nil { FatalErrorRespectJSON("communicating with daemon: %v", err) } if !resp.Success { FatalErrorRespectJSON("getting eligible epics: %s", resp.Error) } if err := json.Unmarshal(resp.Data, &eligibleEpics); err != nil { FatalErrorRespectJSON("parsing response: %v", err) } } else { ctx := rootCtx epics, err := store.GetEpicsEligibleForClosure(ctx) if err != nil { FatalErrorRespectJSON("getting eligible epics: %v", err) } for _, epic := range epics { if epic.EligibleForClose { eligibleEpics = append(eligibleEpics, epic) } } } if len(eligibleEpics) == 0 { if !jsonOutput { fmt.Println("No epics eligible for closure") } else { fmt.Println("[]") } return } if dryRun { if jsonOutput { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") if err := enc.Encode(eligibleEpics); err != nil { FatalErrorRespectJSON("encoding JSON: %v", err) } } else { fmt.Printf("Would close %d epic(s):\n", len(eligibleEpics)) for _, epicStatus := range eligibleEpics { fmt.Printf(" - %s: %s\n", epicStatus.Epic.ID, epicStatus.Epic.Title) } } return } // Actually close the epics closedIDs := []string{} for _, epicStatus := range eligibleEpics { if daemonClient != nil { resp, err := daemonClient.CloseIssue(&rpc.CloseArgs{ ID: epicStatus.Epic.ID, Reason: "All children completed", }) if err != nil || !resp.Success { errMsg := "" if err != nil { errMsg = err.Error() } else if !resp.Success { errMsg = resp.Error } fmt.Fprintf(os.Stderr, "Error closing %s: %s\n", epicStatus.Epic.ID, errMsg) continue } } else { ctx := rootCtx err := store.CloseIssue(ctx, epicStatus.Epic.ID, "All children completed", "system", "") if err != nil { fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", epicStatus.Epic.ID, err) continue } } closedIDs = append(closedIDs, epicStatus.Epic.ID) } if jsonOutput { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") if err := enc.Encode(map[string]interface{}{ "closed": closedIDs, "count": len(closedIDs), }); err != nil { FatalErrorRespectJSON("encoding JSON: %v", err) } } else { fmt.Printf("✓ Closed %d epic(s)\n", len(closedIDs)) for _, id := range closedIDs { fmt.Printf(" - %s\n", id) } } }, } func init() { epicCmd.AddCommand(epicStatusCmd) epicCmd.AddCommand(closeEligibleEpicsCmd) epicStatusCmd.Flags().Bool("eligible-only", false, "Show only epics eligible for closure") closeEligibleEpicsCmd.Flags().Bool("dry-run", false, "Preview what would be closed without making changes") rootCmd.AddCommand(epicCmd) }