From fc950e47bf4cc65963f26e81e7ae676bdc7a0b13 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Thu, 12 Feb 2026 00:51:56 +0100 Subject: [PATCH 1/2] feat: add workflow CLI commands - `complete`, `scrap`, `start` for status transitions - `ready`, `next`, `blocked` as query shortcuts - `milestones`, `progress` for reporting - Extract shared `resolveBean()` helper, refactor `update.go` to use it - Update `prompt.tmpl` with workflow command docs for agents Refs: beans-mmyp --- .beans/beans-mmyp--workflow-cli-commands.md | 2 +- internal/commands/blocked.go | 90 +++ internal/commands/complete.go | 76 +++ internal/commands/content.go | 70 +++ internal/commands/milestones.go | 169 ++++++ internal/commands/next.go | 60 ++ internal/commands/progress.go | 144 +++++ internal/commands/prompt.tmpl | 30 +- internal/commands/ready.go | 116 ++++ internal/commands/register.go | 8 + internal/commands/scrap.go | 74 +++ internal/commands/start.go | 91 +++ internal/commands/update.go | 23 +- internal/commands/workflow_test.go | 591 ++++++++++++++++++++ 14 files changed, 1518 insertions(+), 26 deletions(-) create mode 100644 internal/commands/blocked.go create mode 100644 internal/commands/complete.go create mode 100644 internal/commands/milestones.go create mode 100644 internal/commands/next.go create mode 100644 internal/commands/progress.go create mode 100644 internal/commands/ready.go create mode 100644 internal/commands/scrap.go create mode 100644 internal/commands/start.go create mode 100644 internal/commands/workflow_test.go diff --git a/.beans/beans-mmyp--workflow-cli-commands.md b/.beans/beans-mmyp--workflow-cli-commands.md index 0d397c42..e628f098 100644 --- a/.beans/beans-mmyp--workflow-cli-commands.md +++ b/.beans/beans-mmyp--workflow-cli-commands.md @@ -1,7 +1,7 @@ --- # beans-mmyp title: Workflow CLI commands -status: todo +status: in-progress type: epic priority: normal created_at: 2025-12-27T21:43:38Z diff --git a/internal/commands/blocked.go b/internal/commands/blocked.go new file mode 100644 index 00000000..7fa0d2b1 --- /dev/null +++ b/internal/commands/blocked.go @@ -0,0 +1,90 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/hmans/beans/pkg/bean" + "github.com/hmans/beans/internal/graph" + "github.com/hmans/beans/internal/graph/model" + "github.com/hmans/beans/internal/output" + "github.com/hmans/beans/internal/ui" + "github.com/spf13/cobra" +) + +var ( + blockedJSON bool + blockedQuiet bool +) + +type blockedEntry struct { + Bean *bean.Bean `json:"bean"` + Blockers []*bean.Bean `json:"blockers"` +} + +var blockedCmd = &cobra.Command{ + Use: "blocked", + Short: "List beans that are blocked", + Long: `Lists beans that are blocked by other beans, showing what blocks each one.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + isBlocked := true + filter := &model.BeanFilter{ + IsBlocked: &isBlocked, + ExcludeStatus: []string{"completed", "scrapped"}, + } + + resolver := &graph.Resolver{Core: core} + beans, err := resolver.Query().Beans(context.Background(), filter) + if err != nil { + return cmdError(blockedJSON, output.ErrValidation, "querying beans: %v", err) + } + + sortBeans(beans, "", cfg) + + if blockedJSON { + entries := make([]blockedEntry, 0, len(beans)) + for _, b := range beans { + blockers := core.FindActiveBlockers(b.ID) + entries = append(entries, blockedEntry{Bean: b, Blockers: blockers}) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(entries) + } + + if blockedQuiet { + for _, b := range beans { + fmt.Println(b.ID) + } + return nil + } + + if len(beans) == 0 { + fmt.Println(ui.Muted.Render("No blocked beans.")) + return nil + } + + for _, b := range beans { + blockers := core.FindActiveBlockers(b.ID) + var blockerStrs []string + for _, bl := range blockers { + blockerStrs = append(blockerStrs, ui.ID.Render(bl.ID)+" "+ui.Muted.Render(bl.Title)) + } + + fmt.Println(ui.ID.Render(b.ID) + " " + b.Title) + fmt.Println(" " + ui.Warning.Render("Blocked by: ") + strings.Join(blockerStrs, ", ")) + } + + return nil + }, +} + +func RegisterBlockedCmd(root *cobra.Command) { + blockedCmd.Flags().BoolVar(&blockedJSON, "json", false, "Output as JSON") + blockedCmd.Flags().BoolVarP(&blockedQuiet, "quiet", "q", false, "Only output IDs") + root.AddCommand(blockedCmd) +} diff --git a/internal/commands/complete.go b/internal/commands/complete.go new file mode 100644 index 00000000..5c7c2602 --- /dev/null +++ b/internal/commands/complete.go @@ -0,0 +1,76 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/hmans/beans/internal/graph" + "github.com/hmans/beans/internal/graph/model" + "github.com/hmans/beans/internal/ui" + "github.com/spf13/cobra" +) + +var ( + completeSummary string + completeJSON bool +) + +var completeCmd = &cobra.Command{ + Use: "complete [id...]", + Short: "Mark one or more beans as completed", + Long: `Sets the status of one or more beans to "completed". Optionally appends a summary of changes.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resolver := &graph.Resolver{Core: core} + var results []*result + var errs []error + + for _, id := range args { + b, _, err := resolveBean(resolver, id, completeJSON) + if err != nil { + errs = append(errs, err) + continue + } + + if b.Status == "completed" { + if !completeJSON { + fmt.Println(ui.Warning.Render("Already completed: ") + ui.ID.Render(b.ID)) + } + results = append(results, &result{bean: b, warning: "already completed"}) + continue + } + + status := "completed" + input := model.UpdateBeanInput{ + Status: &status, + } + + if completeSummary != "" { + appendText := "## Summary of Changes\n\n" + completeSummary + input.BodyMod = &model.BodyModification{ + Append: &appendText, + } + } + + b, err = resolver.Mutation().UpdateBean(context.Background(), b.ID, input) + if err != nil { + errs = append(errs, mutationError(completeJSON, err)) + continue + } + + results = append(results, &result{bean: b}) + } + + if len(errs) > 0 && len(results) == 0 { + return errs[0] + } + + return outputResults(results, errs, completeJSON, "completed", "Completed") + }, +} + +func RegisterCompleteCmd(root *cobra.Command) { + completeCmd.Flags().StringVarP(&completeSummary, "summary", "m", "", "Summary of changes to append") + completeCmd.Flags().BoolVar(&completeJSON, "json", false, "Output as JSON") + root.AddCommand(completeCmd) +} diff --git a/internal/commands/content.go b/internal/commands/content.go index c93f5303..44e9ea58 100644 --- a/internal/commands/content.go +++ b/internal/commands/content.go @@ -1,13 +1,16 @@ package commands import ( + "context" "fmt" "io" "os" "strings" + "github.com/hmans/beans/internal/graph" "github.com/hmans/beans/pkg/bean" "github.com/hmans/beans/internal/output" + "github.com/hmans/beans/internal/ui" ) // resolveContent returns content from a direct value or file flag. @@ -94,6 +97,73 @@ func applyBodyAppend(body, text string) string { return bean.AppendWithSeparator(body, text) } +// resolveBean finds a bean by ID, checking the archive if needed. +// Returns the bean and whether it was unarchived. +func resolveBean(resolver *graph.Resolver, id string, jsonMode bool) (*bean.Bean, bool, error) { + b, err := resolver.Query().Bean(context.Background(), id) + if err != nil { + return nil, false, cmdError(jsonMode, output.ErrNotFound, "failed to find bean: %v", err) + } + + if b == nil { + unarchived, unarchiveErr := core.LoadAndUnarchive(id) + if unarchiveErr != nil { + return nil, false, cmdError(jsonMode, output.ErrNotFound, "bean not found: %s", id) + } + b, err = resolver.Query().Bean(context.Background(), unarchived.ID) + if err != nil || b == nil { + return nil, false, cmdError(jsonMode, output.ErrNotFound, "bean not found: %s", id) + } + return b, true, nil + } + + return b, false, nil +} + +// result holds the outcome of a workflow command for a single bean. +type result struct { + bean *bean.Bean + warning string +} + +// outputResults handles JSON and human output for multi-ID workflow commands. +func outputResults(results []*result, errs []error, jsonMode bool, pastTense, pastTenseCapitalized string) error { + beans := make([]*bean.Bean, 0, len(results)) + var warnings []string + for _, r := range results { + beans = append(beans, r.bean) + if r.warning != "" { + warnings = append(warnings, fmt.Sprintf("%s: %s", r.bean.ID, r.warning)) + } + } + for _, e := range errs { + warnings = append(warnings, e.Error()) + } + + if jsonMode { + if len(beans) == 1 && len(warnings) == 0 { + return output.Success(beans[0], "Bean "+pastTense) + } + resp := output.Response{ + Success: true, + Beans: beans, + Count: len(beans), + Message: fmt.Sprintf("%d bean(s) %s", len(beans), pastTense), + } + if len(warnings) > 0 { + resp.Warnings = warnings + } + return output.JSON(resp) + } + + for _, r := range results { + if r.warning == "" { + fmt.Println(ui.Success.Render(pastTenseCapitalized+" ") + ui.ID.Render(r.bean.ID)) + } + } + return nil +} + // resolveAppendContent handles --append value, supporting stdin with "-". func resolveAppendContent(value string) (string, error) { if value == "-" { diff --git a/internal/commands/milestones.go b/internal/commands/milestones.go new file mode 100644 index 00000000..ee79fcad --- /dev/null +++ b/internal/commands/milestones.go @@ -0,0 +1,169 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/hmans/beans/pkg/bean" + "github.com/hmans/beans/internal/graph" + "github.com/hmans/beans/internal/output" + "github.com/hmans/beans/internal/ui" + "github.com/spf13/cobra" +) + +var ( + milestonesJSON bool + milestonesIncludeDone bool +) + +type milestoneInfo struct { + Milestone *bean.Bean `json:"milestone"` + Total int `json:"total"` + ByStatus map[string]int `json:"by_status"` + Completion float64 `json:"completion_pct"` +} + +var milestonesCmd = &cobra.Command{ + Use: "milestones", + Short: "Show milestone progress", + Long: `Shows all milestones with child counts and completion percentages.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + resolver := &graph.Resolver{Core: core} + allBeans, err := resolver.Query().Beans(context.Background(), nil) + if err != nil { + return cmdError(milestonesJSON, output.ErrValidation, "querying beans: %v", err) + } + + // Build children index + children := make(map[string][]*bean.Bean) + for _, b := range allBeans { + if b.Parent != "" { + children[b.Parent] = append(children[b.Parent], b) + } + } + + // Collect all descendants (not just direct children) + var collectDescendants func(id string) []*bean.Bean + collectDescendants = func(id string) []*bean.Bean { + var all []*bean.Bean + for _, child := range children[id] { + all = append(all, child) + all = append(all, collectDescendants(child.ID)...) + } + return all + } + + // Find milestones + var milestones []*bean.Bean + for _, b := range allBeans { + if b.Type != "milestone" { + continue + } + if !milestonesIncludeDone && cfg.IsArchiveStatus(b.Status) { + continue + } + milestones = append(milestones, b) + } + + sortByStatusThenCreated(milestones, cfg) + + var infos []milestoneInfo + for _, m := range milestones { + descendants := collectDescendants(m.ID) + byStatus := make(map[string]int) + for _, d := range descendants { + byStatus[d.Status]++ + } + total := len(descendants) + completed := byStatus["completed"] + pct := 0.0 + if total > 0 { + pct = float64(completed) / float64(total) * 100 + } + infos = append(infos, milestoneInfo{ + Milestone: m, + Total: total, + ByStatus: byStatus, + Completion: pct, + }) + } + + if milestonesJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(infos) + } + + if len(infos) == 0 { + fmt.Println(ui.Muted.Render("No milestones found.")) + return nil + } + + for i, info := range infos { + if i > 0 { + fmt.Println() + } + + statusCfg := cfg.GetStatus(info.Milestone.Status) + statusColor := "gray" + if statusCfg != nil { + statusColor = statusCfg.Color + } + isArchive := cfg.IsArchiveStatus(info.Milestone.Status) + + fmt.Println( + ui.ID.Render(info.Milestone.ID) + " " + + ui.RenderStatusWithColor(info.Milestone.Status, statusColor, isArchive) + " " + + ui.Title.Render(info.Milestone.Title), + ) + + if info.Total == 0 { + fmt.Println(" " + ui.Muted.Render("No children")) + continue + } + + // Progress bar + barWidth := 30 + filled := int(info.Completion / 100 * float64(barWidth)) + if filled > barWidth { + filled = barWidth + } + bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) + fmt.Printf(" %s %.0f%% (%d/%d)\n", + ui.Success.Render(bar), + info.Completion, + info.ByStatus["completed"], + info.Total, + ) + + // Status breakdown + var parts []string + for _, s := range cfg.StatusNames() { + if count, ok := info.ByStatus[s]; ok && count > 0 { + sCfg := cfg.GetStatus(s) + sColor := "gray" + if sCfg != nil { + sColor = sCfg.Color + } + sArchive := cfg.IsArchiveStatus(s) + parts = append(parts, ui.RenderStatusTextWithColor(s, sColor, sArchive)+fmt.Sprintf(": %d", count)) + } + } + if len(parts) > 0 { + fmt.Println(" " + strings.Join(parts, " ")) + } + } + + return nil + }, +} + +func RegisterMilestonesCmd(root *cobra.Command) { + milestonesCmd.Flags().BoolVar(&milestonesJSON, "json", false, "Output as JSON") + milestonesCmd.Flags().BoolVar(&milestonesIncludeDone, "include-done", false, "Include completed/scrapped milestones") + root.AddCommand(milestonesCmd) +} diff --git a/internal/commands/next.go b/internal/commands/next.go new file mode 100644 index 00000000..8696b2b1 --- /dev/null +++ b/internal/commands/next.go @@ -0,0 +1,60 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/hmans/beans/internal/graph" + "github.com/hmans/beans/internal/graph/model" + "github.com/hmans/beans/internal/output" + "github.com/hmans/beans/internal/ui" + "github.com/spf13/cobra" +) + +var ( + nextJSON bool +) + +var nextCmd = &cobra.Command{ + Use: "next", + Short: "Show the highest-priority bean ready to start", + Long: `Shows the single highest-priority bean that is not blocked and ready to start.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + isBlocked := false + filter := &model.BeanFilter{ + IsBlocked: &isBlocked, + ExcludeStatus: []string{"in-progress", "completed", "scrapped", "draft"}, + } + + resolver := &graph.Resolver{Core: core} + beans, err := resolver.Query().Beans(context.Background(), filter) + if err != nil { + return cmdError(nextJSON, output.ErrValidation, "querying beans: %v", err) + } + + sortBeans(beans, "priority", cfg) + + if len(beans) == 0 { + if nextJSON { + return output.SuccessMessage("No beans ready to start") + } + fmt.Println(ui.Muted.Render("No beans ready to start.")) + return nil + } + + b := beans[0] + + if nextJSON { + return output.SuccessSingle(b) + } + + showStyledBean(b) + return nil + }, +} + +func RegisterNextCmd(root *cobra.Command) { + nextCmd.Flags().BoolVar(&nextJSON, "json", false, "Output as JSON") + root.AddCommand(nextCmd) +} diff --git a/internal/commands/progress.go b/internal/commands/progress.go new file mode 100644 index 00000000..a5b49c28 --- /dev/null +++ b/internal/commands/progress.go @@ -0,0 +1,144 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/hmans/beans/internal/graph" + "github.com/hmans/beans/internal/graph/model" + "github.com/hmans/beans/internal/output" + "github.com/hmans/beans/internal/ui" + "github.com/spf13/cobra" +) + +var ( + progressJSON bool +) + +type progressData struct { + Total int `json:"total"` + ByStatus map[string]int `json:"by_status"` + ByType map[string]int `json:"by_type"` + BlockedCount int `json:"blocked_count"` +} + +var progressCmd = &cobra.Command{ + Use: "progress", + Short: "Show overall project progress", + Long: `Shows a summary of all beans by status and type, including blocked count.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + resolver := &graph.Resolver{Core: core} + allBeans, err := resolver.Query().Beans(context.Background(), nil) + if err != nil { + return cmdError(progressJSON, output.ErrValidation, "querying beans: %v", err) + } + + byStatus := make(map[string]int) + byType := make(map[string]int) + for _, b := range allBeans { + byStatus[b.Status]++ + byType[b.Type]++ + } + + // Count blocked beans + isBlocked := true + blockedFilter := &model.BeanFilter{ + IsBlocked: &isBlocked, + ExcludeStatus: []string{"completed", "scrapped"}, + } + blockedBeans, err := resolver.Query().Beans(context.Background(), blockedFilter) + if err != nil { + return cmdError(progressJSON, output.ErrValidation, "querying blocked beans: %v", err) + } + + data := progressData{ + Total: len(allBeans), + ByStatus: byStatus, + ByType: byType, + BlockedCount: len(blockedBeans), + } + + if progressJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(data) + } + + fmt.Println(ui.Title.Render(fmt.Sprintf("Project Progress (%d beans)", data.Total))) + fmt.Println() + + // Status table + fmt.Println(ui.Bold.Render("By Status")) + maxCount := 0 + for _, count := range byStatus { + if count > maxCount { + maxCount = count + } + } + barMaxWidth := 20 + for _, s := range cfg.StatusNames() { + count := byStatus[s] + if count == 0 { + continue + } + sCfg := cfg.GetStatus(s) + sColor := "gray" + if sCfg != nil { + sColor = sCfg.Color + } + isArchive := cfg.IsArchiveStatus(s) + + barWidth := barMaxWidth + if maxCount > 0 { + barWidth = count * barMaxWidth / maxCount + } + if barWidth == 0 { + barWidth = 1 + } + bar := strings.Repeat("█", barWidth) + + label := ui.RenderStatusTextWithColor( + fmt.Sprintf("%-12s", s), sColor, isArchive, + ) + fmt.Printf(" %s %s %d\n", label, ui.Success.Render(bar), count) + } + + fmt.Println() + + // Type breakdown + fmt.Println(ui.Bold.Render("By Type")) + for _, t := range cfg.TypeNames() { + count := byType[t] + if count == 0 { + continue + } + tCfg := cfg.GetType(t) + tColor := "gray" + if tCfg != nil { + tColor = tCfg.Color + } + label := ui.RenderTypeText(fmt.Sprintf("%-12s", t), tColor) + fmt.Printf(" %s %d\n", label, count) + } + + fmt.Println() + + // Blocked count + if data.BlockedCount > 0 { + fmt.Println(ui.Warning.Render(fmt.Sprintf("Blocked: %d bean(s)", data.BlockedCount))) + } else { + fmt.Println(ui.Success.Render("No blocked beans")) + } + + return nil + }, +} + +func RegisterProgressCmd(root *cobra.Command) { + progressCmd.Flags().BoolVar(&progressJSON, "json", false, "Output as JSON") + root.AddCommand(progressCmd) +} diff --git a/internal/commands/prompt.tmpl b/internal/commands/prompt.tmpl index 56e6ee67..44f8e3e6 100644 --- a/internal/commands/prompt.tmpl +++ b/internal/commands/prompt.tmpl @@ -16,13 +16,14 @@ BEFORE starting any task: - FIRST: Check if there already is an existing bean about this work. If there isn't, create a bean with `beans create "Title" -t -d "Description..." -s in-progress` - THEN: Do the work, and keep the bean's todo items current (check off what has been done, as it happens; `- [ ]` → `- [x]`) -- WHEN COMMITTING: Always update the bean BEFORE committing so that the bean file changes are included in the same commit as the code changes. Include both code changes AND bean file(s) in every commit. +- FINALLY: ONLY if the bean has no unchecked todo items left, mark it completed with `beans complete ` (optionally with `-m "summary"`) +- WHEN COMMITTING: Include both code changes AND bean file(s) in the commit AFTER finishing any task: -- **Mark the bean completed/scrapped BEFORE making the final commit**, so the status change is included in the commit. -- When COMPLETING a bean (ONLY if it has no unchecked todo items left), mark it completed with `beans update -s completed` and add a `## Summary of Changes` section describing what was done. -- When SCRAPPING a bean, update it with a `## Reasons for Scrapping` section explaining why. +- When COMPLETING a bean: `beans complete -m "Summary of what was done"` +- When SCRAPPING a bean: `beans scrap -m "Reason for scrapping"` +- When STARTING a bean: `beans start ` (warns if blocked, use `-f` to force) - Offer to create follow-up beans for any non-urgent work that was deferred. ## Finding Work @@ -31,7 +32,10 @@ When the user asks what to work on next: ```bash # Find beans ready to start (not blocked, excludes in-progress/completed/scrapped/draft) -beans list --json --ready +beans ready --json + +# Show the highest-priority bean ready to start +beans next --json # View full details of specific beans (supports multiple IDs) beans show --json [id...] @@ -63,6 +67,22 @@ beans update --json --body-replace-old "old" --body-replace-new "new" # Re beans update --json --body-append "## Notes" # Append to body beans update --json -s completed --body-replace-old "- [ ] Task" --body-replace-new "- [x] Task" # Combined +# Workflow commands (preferred over `beans update -s ...`) +beans start # Set status to in-progress (warns if blocked) +beans start -f # Start even if blocked +beans complete # Set status to completed +beans complete -m "Summary..." # Complete with a summary of changes +beans scrap -m "Reason..." # Set status to scrapped (reason required) + +# Finding work +beans ready --json # Beans ready to start (same as list --ready) +beans next --json # Highest-priority bean ready to start +beans blocked --json # Beans blocked by others + +# Reporting +beans milestones # Milestone progress with completion percentages +beans progress # Overall project status breakdown + # Archive completed/scrapped beans (only when user requests) beans archive ``` diff --git a/internal/commands/ready.go b/internal/commands/ready.go new file mode 100644 index 00000000..7d1a2bb4 --- /dev/null +++ b/internal/commands/ready.go @@ -0,0 +1,116 @@ +package commands + +import ( + "context" + "fmt" + "os" + + "github.com/hmans/beans/pkg/bean" + "github.com/hmans/beans/internal/graph" + "github.com/hmans/beans/internal/graph/model" + "github.com/hmans/beans/internal/output" + "github.com/hmans/beans/internal/ui" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var ( + readyJSON bool + readyQuiet bool + readySort string + readyFull bool +) + +var readyCmd = &cobra.Command{ + Use: "ready", + Short: "List beans ready to start", + Long: `Lists beans that are not blocked and not in-progress, completed, scrapped, or draft status.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + isBlocked := false + filter := &model.BeanFilter{ + IsBlocked: &isBlocked, + ExcludeStatus: []string{"in-progress", "completed", "scrapped", "draft"}, + } + + resolver := &graph.Resolver{Core: core} + beans, err := resolver.Query().Beans(context.Background(), filter) + if err != nil { + return cmdError(readyJSON, output.ErrValidation, "querying beans: %v", err) + } + + sortBeans(beans, readySort, cfg) + + if readyJSON { + if !readyFull { + for _, b := range beans { + b.Body = "" + } + } + return output.SuccessMultiple(beans) + } + + if readyQuiet { + for _, b := range beans { + fmt.Println(b.ID) + } + return nil + } + + // Tree view (same as list command) + allBeans, err := resolver.Query().Beans(context.Background(), nil) + if err != nil { + return fmt.Errorf("querying all beans for tree: %w", err) + } + + implicitStatuses := make(map[string]string, len(allBeans)) + for _, b := range allBeans { + if status, _ := core.ImplicitStatus(b.ID); status != "" { + implicitStatuses[b.ID] = status + } + } + + sortFn := func(b []*bean.Bean) { + sortBeans(b, readySort, cfg) + } + + tree := ui.BuildTree(beans, allBeans, sortFn, implicitStatuses) + + if len(tree) == 0 { + fmt.Println(ui.Muted.Render("No beans ready to start.")) + return nil + } + + maxIDWidth := 2 + for _, b := range allBeans { + if len(b.ID) > maxIDWidth { + maxIDWidth = len(b.ID) + } + } + maxIDWidth += 2 + + hasTags := false + for _, b := range beans { + if len(b.Tags) > 0 { + hasTags = true + break + } + } + + termWidth := 80 + if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { + termWidth = w + } + + fmt.Print(ui.RenderTree(tree, cfg, maxIDWidth, hasTags, termWidth)) + return nil + }, +} + +func RegisterReadyCmd(root *cobra.Command) { + readyCmd.Flags().BoolVar(&readyJSON, "json", false, "Output as JSON") + readyCmd.Flags().BoolVarP(&readyQuiet, "quiet", "q", false, "Only output IDs") + readyCmd.Flags().StringVar(&readySort, "sort", "", "Sort by: created, updated, status, priority, id") + readyCmd.Flags().BoolVar(&readyFull, "full", false, "Include bean body in JSON output") + root.AddCommand(readyCmd) +} diff --git a/internal/commands/register.go b/internal/commands/register.go index ab473d17..04d8dca6 100644 --- a/internal/commands/register.go +++ b/internal/commands/register.go @@ -10,15 +10,23 @@ import ( // RegisterCoreCommands adds all core CLI commands to the root command. func RegisterCoreCommands(root *cobra.Command) { RegisterArchiveCmd(root) + RegisterBlockedCmd(root) RegisterCheckCmd(root) + RegisterCompleteCmd(root) RegisterCreateCmd(root) RegisterDeleteCmd(root) RegisterGraphqlCmd(root) RegisterInitCmd(root) RegisterListCmd(root) + RegisterMilestonesCmd(root) + RegisterNextCmd(root) RegisterPrimeCmd(root) + RegisterProgressCmd(root) + RegisterReadyCmd(root) RegisterRoadmapCmd(root) + RegisterScrapCmd(root) RegisterShowCmd(root) + RegisterStartCmd(root) RegisterUpdateCmd(root) RegisterVersionCmd(root) diff --git a/internal/commands/scrap.go b/internal/commands/scrap.go new file mode 100644 index 00000000..152e7f96 --- /dev/null +++ b/internal/commands/scrap.go @@ -0,0 +1,74 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/hmans/beans/internal/graph" + "github.com/hmans/beans/internal/graph/model" + "github.com/hmans/beans/internal/ui" + "github.com/spf13/cobra" +) + +var ( + scrapReason string + scrapJSON bool +) + +var scrapCmd = &cobra.Command{ + Use: "scrap [id...]", + Short: "Mark one or more beans as scrapped", + Long: `Sets the status of one or more beans to "scrapped". Requires a reason explaining why.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resolver := &graph.Resolver{Core: core} + var results []*result + var errs []error + + for _, id := range args { + b, _, err := resolveBean(resolver, id, scrapJSON) + if err != nil { + errs = append(errs, err) + continue + } + + if b.Status == "scrapped" { + if !scrapJSON { + fmt.Println(ui.Warning.Render("Already scrapped: ") + ui.ID.Render(b.ID)) + } + results = append(results, &result{bean: b, warning: "already scrapped"}) + continue + } + + status := "scrapped" + appendText := "## Reasons for Scrapping\n\n" + scrapReason + input := model.UpdateBeanInput{ + Status: &status, + BodyMod: &model.BodyModification{ + Append: &appendText, + }, + } + + b, err = resolver.Mutation().UpdateBean(context.Background(), b.ID, input) + if err != nil { + errs = append(errs, mutationError(scrapJSON, err)) + continue + } + + results = append(results, &result{bean: b}) + } + + if len(errs) > 0 && len(results) == 0 { + return errs[0] + } + + return outputResults(results, errs, scrapJSON, "scrapped", "Scrapped") + }, +} + +func RegisterScrapCmd(root *cobra.Command) { + scrapCmd.Flags().StringVarP(&scrapReason, "reason", "m", "", "Reason for scrapping (required)") + _ = scrapCmd.MarkFlagRequired("reason") + scrapCmd.Flags().BoolVar(&scrapJSON, "json", false, "Output as JSON") + root.AddCommand(scrapCmd) +} diff --git a/internal/commands/start.go b/internal/commands/start.go new file mode 100644 index 00000000..6d4c9b02 --- /dev/null +++ b/internal/commands/start.go @@ -0,0 +1,91 @@ +package commands + +import ( + "context" + "fmt" + "strings" + + "github.com/hmans/beans/pkg/bean" + "github.com/hmans/beans/internal/graph" + "github.com/hmans/beans/internal/graph/model" + "github.com/hmans/beans/internal/output" + "github.com/hmans/beans/internal/ui" + "github.com/spf13/cobra" +) + +var ( + startForce bool + startJSON bool +) + +var startCmd = &cobra.Command{ + Use: "start [id...]", + Short: "Start working on one or more beans", + Long: `Sets the status of one or more beans to "in-progress". Warns if a bean is blocked unless --force is used.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resolver := &graph.Resolver{Core: core} + var results []*result + var errs []error + + for _, id := range args { + b, _, err := resolveBean(resolver, id, startJSON) + if err != nil { + errs = append(errs, err) + continue + } + + if b.Status == "in-progress" { + if !startJSON { + fmt.Println(ui.Warning.Render("Already in-progress: ") + ui.ID.Render(b.ID)) + } + results = append(results, &result{bean: b, warning: "already in-progress"}) + continue + } + + blockers := core.FindActiveBlockers(b.ID) + if len(blockers) > 0 && !startForce { + msg := formatBlockerMessage(b.ID, blockers) + errs = append(errs, cmdError(startJSON, output.ErrValidation, "%s", msg)) + continue + } + + if len(blockers) > 0 && startForce && !startJSON { + fmt.Println(ui.Warning.Render("Warning: ") + ui.ID.Render(b.ID) + " is blocked, starting anyway") + } + + status := "in-progress" + input := model.UpdateBeanInput{ + Status: &status, + } + + b, err = resolver.Mutation().UpdateBean(context.Background(), b.ID, input) + if err != nil { + errs = append(errs, mutationError(startJSON, err)) + continue + } + + results = append(results, &result{bean: b}) + } + + if len(errs) > 0 && len(results) == 0 { + return errs[0] + } + + return outputResults(results, errs, startJSON, "started", "Started") + }, +} + +func formatBlockerMessage(beanID string, blockers []*bean.Bean) string { + var parts []string + for _, bl := range blockers { + parts = append(parts, fmt.Sprintf("%s (%s)", bl.ID, bl.Title)) + } + return fmt.Sprintf("%s is blocked by: %s", beanID, strings.Join(parts, ", ")) +} + +func RegisterStartCmd(root *cobra.Command) { + startCmd.Flags().BoolVarP(&startForce, "force", "f", false, "Start even if blocked") + startCmd.Flags().BoolVar(&startJSON, "json", false, "Output as JSON") + root.AddCommand(startCmd) +} diff --git a/internal/commands/update.go b/internal/commands/update.go index b32b7c75..c2cf9c3c 100644 --- a/internal/commands/update.go +++ b/internal/commands/update.go @@ -45,28 +45,11 @@ var updateCmd = &cobra.Command{ Long: `Updates one or more properties of an existing bean.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - ctx := context.Background() resolver := &graph.Resolver{Core: core} - // Find the bean - b, err := resolver.Query().Bean(ctx, args[0]) + b, wasArchived, err := resolveBean(resolver, args[0], updateJSON) if err != nil { - return cmdError(updateJSON, output.ErrNotFound, "failed to find bean: %v", err) - } - - // If not found, check the archive and unarchive if present - wasArchived := false - if b == nil { - unarchived, unarchiveErr := core.LoadAndUnarchive(args[0]) - if unarchiveErr != nil { - return cmdError(updateJSON, output.ErrNotFound, "bean not found: %s", args[0]) - } - // Re-query to get the model.Bean - b, err = resolver.Query().Bean(ctx, unarchived.ID) - if err != nil || b == nil { - return cmdError(updateJSON, output.ErrNotFound, "bean not found: %s", args[0]) - } - wasArchived = true + return err } // Track changes for output @@ -93,7 +76,7 @@ var updateCmd = &cobra.Command{ // Apply all updates atomically via single UpdateBean mutation // This includes field updates, body modifications, and relationship changes if hasFieldUpdates(input) { - b, err = resolver.Mutation().UpdateBean(ctx, b.ID, input) + b, err = resolver.Mutation().UpdateBean(context.Background(), b.ID, input) if err != nil { return mutationError(updateJSON, err) } diff --git a/internal/commands/workflow_test.go b/internal/commands/workflow_test.go new file mode 100644 index 00000000..89e03afc --- /dev/null +++ b/internal/commands/workflow_test.go @@ -0,0 +1,591 @@ +package commands + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hmans/beans/pkg/bean" + "github.com/hmans/beans/pkg/beancore" + "github.com/hmans/beans/pkg/config" + "github.com/hmans/beans/internal/graph" + "github.com/hmans/beans/internal/graph/model" +) + +// setupTestCore creates a temporary beans directory and initializes core/cfg. +func setupTestCore(t *testing.T) (*beancore.Core, func()) { + t.Helper() + tmpDir := t.TempDir() + beansDir := filepath.Join(tmpDir, ".beans") + if err := os.MkdirAll(beansDir, 0755); err != nil { + t.Fatalf("failed to create test .beans dir: %v", err) + } + + testCfg := config.Default() + testCore := beancore.New(beansDir, testCfg) + if err := testCore.Load(); err != nil { + t.Fatalf("failed to load core: %v", err) + } + + oldCore := core + oldCfg := cfg + core = testCore + cfg = testCfg + + cleanup := func() { + core = oldCore + cfg = oldCfg + } + + return testCore, cleanup +} + +func createBean(t *testing.T, c *beancore.Core, id, title, status, beanType string) *bean.Bean { + t.Helper() + b := &bean.Bean{ + ID: id, + Slug: bean.Slugify(title), + Title: title, + Status: status, + Type: beanType, + } + if err := c.Create(b); err != nil { + t.Fatalf("failed to create test bean: %v", err) + } + return b +} + +func createBeanWithPriority(t *testing.T, c *beancore.Core, id, title, status, beanType, priority string) *bean.Bean { + t.Helper() + b := &bean.Bean{ + ID: id, + Slug: bean.Slugify(title), + Title: title, + Status: status, + Type: beanType, + Priority: priority, + } + if err := c.Create(b); err != nil { + t.Fatalf("failed to create test bean: %v", err) + } + return b +} + +func createBeanWithParent(t *testing.T, c *beancore.Core, id, title, status, beanType, parent string) *bean.Bean { + t.Helper() + b := &bean.Bean{ + ID: id, + Slug: bean.Slugify(title), + Title: title, + Status: status, + Type: beanType, + Parent: parent, + } + if err := c.Create(b); err != nil { + t.Fatalf("failed to create test bean: %v", err) + } + return b +} + +// --- resolveBean tests --- + +func TestResolveBean(t *testing.T) { + testCore, cleanup := setupTestCore(t) + defer cleanup() + + createBean(t, testCore, "test-1", "Test Bean", "todo", "task") + + t.Run("found", func(t *testing.T) { + resolver := &graph.Resolver{Core: testCore} + b, wasArchived, err := resolveBean(resolver, "test-1", false) + if err != nil { + t.Fatalf("resolveBean() error = %v", err) + } + if b == nil { + t.Fatal("resolveBean() returned nil") + } + if b.ID != "test-1" { + t.Errorf("resolveBean().ID = %q, want %q", b.ID, "test-1") + } + if wasArchived { + t.Error("resolveBean() wasArchived = true, want false") + } + }) + + t.Run("not found", func(t *testing.T) { + resolver := &graph.Resolver{Core: testCore} + _, _, err := resolveBean(resolver, "nonexistent", false) + if err == nil { + t.Fatal("resolveBean() expected error for nonexistent bean") + } + }) +} + +// --- outputResults tests --- + +func TestOutputResults(t *testing.T) { + t.Run("single success JSON", func(t *testing.T) { + b := &bean.Bean{ID: "test-1", Title: "Test"} + results := []*result{{bean: b}} + err := outputResults(results, nil, true, "completed", "Completed") + if err != nil { + t.Errorf("outputResults() error = %v", err) + } + }) + + t.Run("with warnings JSON", func(t *testing.T) { + b := &bean.Bean{ID: "test-1", Title: "Test"} + results := []*result{{bean: b, warning: "already completed"}} + err := outputResults(results, nil, true, "completed", "Completed") + if err != nil { + t.Errorf("outputResults() error = %v", err) + } + }) + + t.Run("multiple beans JSON", func(t *testing.T) { + results := []*result{ + {bean: &bean.Bean{ID: "test-1", Title: "First"}}, + {bean: &bean.Bean{ID: "test-2", Title: "Second"}}, + } + err := outputResults(results, nil, true, "completed", "Completed") + if err != nil { + t.Errorf("outputResults() error = %v", err) + } + }) + + t.Run("human output", func(t *testing.T) { + b := &bean.Bean{ID: "test-1", Title: "Test"} + results := []*result{{bean: b}} + err := outputResults(results, nil, false, "completed", "Completed") + if err != nil { + t.Errorf("outputResults() error = %v", err) + } + }) +} + +// --- formatBlockerMessage tests --- + +func TestFormatBlockerMessage(t *testing.T) { + t.Run("single blocker", func(t *testing.T) { + blockers := []*bean.Bean{{ID: "b-1", Title: "Blocker"}} + msg := formatBlockerMessage("target", blockers) + if !strings.Contains(msg, "b-1") || !strings.Contains(msg, "Blocker") { + t.Errorf("formatBlockerMessage() = %q, expected to contain blocker info", msg) + } + }) + + t.Run("multiple blockers", func(t *testing.T) { + blockers := []*bean.Bean{ + {ID: "b-1", Title: "First"}, + {ID: "b-2", Title: "Second"}, + } + msg := formatBlockerMessage("target", blockers) + if !strings.Contains(msg, "b-1") || !strings.Contains(msg, "b-2") { + t.Errorf("formatBlockerMessage() = %q, expected to contain both blockers", msg) + } + }) +} + +// --- Complete command logic tests --- + +func TestCompleteCommand(t *testing.T) { + testCore, cleanup := setupTestCore(t) + defer cleanup() + + resolver := &graph.Resolver{Core: testCore} + ctx := context.Background() + + t.Run("complete a todo bean", func(t *testing.T) { + createBean(t, testCore, "comp-1", "Complete Me", "todo", "task") + + status := "completed" + b, err := resolver.Mutation().UpdateBean(ctx, "comp-1", model.UpdateBeanInput{Status: &status}) + if err != nil { + t.Fatalf("UpdateBean() error = %v", err) + } + if b.Status != "completed" { + t.Errorf("status = %q, want %q", b.Status, "completed") + } + }) + + t.Run("complete with summary appended", func(t *testing.T) { + createBean(t, testCore, "comp-2", "With Summary", "todo", "task") + + status := "completed" + appendText := "## Summary of Changes\n\nDid the thing" + b, err := resolver.Mutation().UpdateBean(ctx, "comp-2", model.UpdateBeanInput{ + Status: &status, + BodyMod: &model.BodyModification{Append: &appendText}, + }) + if err != nil { + t.Fatalf("UpdateBean() error = %v", err) + } + if !strings.Contains(b.Body, "## Summary of Changes") { + t.Errorf("body = %q, expected to contain summary section", b.Body) + } + if !strings.Contains(b.Body, "Did the thing") { + t.Errorf("body = %q, expected to contain summary text", b.Body) + } + }) + + t.Run("already completed bean", func(t *testing.T) { + createBean(t, testCore, "comp-3", "Already Done", "completed", "task") + b, err := resolver.Query().Bean(ctx, "comp-3") + if err != nil { + t.Fatalf("Bean() error = %v", err) + } + if b.Status != "completed" { + t.Errorf("expected bean to already be completed") + } + }) +} + +// --- Scrap command logic tests --- + +func TestScrapCommand(t *testing.T) { + testCore, cleanup := setupTestCore(t) + defer cleanup() + + resolver := &graph.Resolver{Core: testCore} + ctx := context.Background() + + t.Run("scrap a todo bean with reason", func(t *testing.T) { + createBean(t, testCore, "scrap-1", "Scrap Me", "todo", "task") + + status := "scrapped" + appendText := "## Reasons for Scrapping\n\nNo longer needed" + b, err := resolver.Mutation().UpdateBean(ctx, "scrap-1", model.UpdateBeanInput{ + Status: &status, + BodyMod: &model.BodyModification{Append: &appendText}, + }) + if err != nil { + t.Fatalf("UpdateBean() error = %v", err) + } + if b.Status != "scrapped" { + t.Errorf("status = %q, want %q", b.Status, "scrapped") + } + if !strings.Contains(b.Body, "Reasons for Scrapping") { + t.Errorf("body = %q, expected to contain reason section", b.Body) + } + }) +} + +// --- Start command logic tests --- + +func TestStartCommand(t *testing.T) { + testCore, cleanup := setupTestCore(t) + defer cleanup() + + resolver := &graph.Resolver{Core: testCore} + ctx := context.Background() + + t.Run("start a todo bean", func(t *testing.T) { + createBean(t, testCore, "start-1", "Start Me", "todo", "task") + + status := "in-progress" + b, err := resolver.Mutation().UpdateBean(ctx, "start-1", model.UpdateBeanInput{Status: &status}) + if err != nil { + t.Fatalf("UpdateBean() error = %v", err) + } + if b.Status != "in-progress" { + t.Errorf("status = %q, want %q", b.Status, "in-progress") + } + }) + + t.Run("blocked bean detected", func(t *testing.T) { + blocker := createBean(t, testCore, "blocker-1", "Blocker", "todo", "task") + blocked := createBean(t, testCore, "blocked-1", "Blocked", "todo", "task") + blocked.BlockedBy = []string{blocker.ID} + if err := testCore.Update(blocked, nil); err != nil { + t.Fatalf("Save() error = %v", err) + } + + blockers := testCore.FindActiveBlockers("blocked-1") + if len(blockers) == 0 { + t.Fatal("expected bean to be blocked") + } + if blockers[0].ID != "blocker-1" { + t.Errorf("blocker ID = %q, want %q", blockers[0].ID, "blocker-1") + } + }) + + t.Run("blocked with completed blocker is not blocked", func(t *testing.T) { + createBean(t, testCore, "done-blocker", "Done Blocker", "completed", "task") + target := createBean(t, testCore, "target-1", "Target", "todo", "task") + target.BlockedBy = []string{"done-blocker"} + if err := testCore.Update(target, nil); err != nil { + t.Fatalf("Save() error = %v", err) + } + + blockers := testCore.FindActiveBlockers("target-1") + if len(blockers) != 0 { + t.Errorf("expected no active blockers, got %d", len(blockers)) + } + }) +} + +// --- Ready/Next filter tests --- + +func TestReadyFilter(t *testing.T) { + testCore, cleanup := setupTestCore(t) + defer cleanup() + + resolver := &graph.Resolver{Core: testCore} + ctx := context.Background() + + createBean(t, testCore, "ready-1", "Ready Todo", "todo", "task") + createBean(t, testCore, "ready-2", "In Progress", "in-progress", "task") + createBean(t, testCore, "ready-3", "Completed", "completed", "task") + createBean(t, testCore, "ready-4", "Draft", "draft", "task") + createBean(t, testCore, "ready-5", "Another Todo", "todo", "feature") + + isBlocked := false + filter := &model.BeanFilter{ + IsBlocked: &isBlocked, + ExcludeStatus: []string{"in-progress", "completed", "scrapped", "draft"}, + } + + beans, err := resolver.Query().Beans(ctx, filter) + if err != nil { + t.Fatalf("Beans() error = %v", err) + } + + // Should only get ready-1 and ready-5 + if len(beans) != 2 { + t.Fatalf("expected 2 ready beans, got %d", len(beans)) + } + + ids := make(map[string]bool) + for _, b := range beans { + ids[b.ID] = true + } + if !ids["ready-1"] || !ids["ready-5"] { + t.Errorf("expected ready-1 and ready-5, got %v", ids) + } +} + +func TestNextPrioritySorting(t *testing.T) { + testCore, cleanup := setupTestCore(t) + defer cleanup() + + resolver := &graph.Resolver{Core: testCore} + ctx := context.Background() + + createBeanWithPriority(t, testCore, "next-1", "Low Priority", "todo", "task", "low") + createBeanWithPriority(t, testCore, "next-2", "Critical", "todo", "task", "critical") + createBeanWithPriority(t, testCore, "next-3", "Normal", "todo", "task", "") + + isBlocked := false + filter := &model.BeanFilter{ + IsBlocked: &isBlocked, + ExcludeStatus: []string{"in-progress", "completed", "scrapped", "draft"}, + } + + beans, err := resolver.Query().Beans(ctx, filter) + if err != nil { + t.Fatalf("Beans() error = %v", err) + } + + sortBeans(beans, "priority", cfg) + + if len(beans) == 0 { + t.Fatal("expected some beans") + } + if beans[0].ID != "next-2" { + t.Errorf("first bean should be critical priority (next-2), got %q", beans[0].ID) + } +} + +func TestNextEmptyResult(t *testing.T) { + _, cleanup := setupTestCore(t) + defer cleanup() + + resolver := &graph.Resolver{Core: core} + ctx := context.Background() + + isBlocked := false + filter := &model.BeanFilter{ + IsBlocked: &isBlocked, + ExcludeStatus: []string{"in-progress", "completed", "scrapped", "draft"}, + } + + beans, err := resolver.Query().Beans(ctx, filter) + if err != nil { + t.Fatalf("Beans() error = %v", err) + } + if len(beans) != 0 { + t.Errorf("expected 0 beans, got %d", len(beans)) + } +} + +// --- Blocked filter tests --- + +func TestBlockedFilter(t *testing.T) { + testCore, cleanup := setupTestCore(t) + defer cleanup() + + resolver := &graph.Resolver{Core: testCore} + ctx := context.Background() + + createBean(t, testCore, "bl-blocker", "The Blocker", "todo", "task") + blocked := createBean(t, testCore, "bl-target", "Blocked Bean", "todo", "task") + blocked.BlockedBy = []string{"bl-blocker"} + if err := testCore.Update(blocked, nil); err != nil { + t.Fatalf("Save() error = %v", err) + } + createBean(t, testCore, "bl-free", "Free Bean", "todo", "task") + + isBlocked := true + filter := &model.BeanFilter{ + IsBlocked: &isBlocked, + ExcludeStatus: []string{"completed", "scrapped"}, + } + + beans, err := resolver.Query().Beans(ctx, filter) + if err != nil { + t.Fatalf("Beans() error = %v", err) + } + + if len(beans) != 1 { + t.Fatalf("expected 1 blocked bean, got %d", len(beans)) + } + if beans[0].ID != "bl-target" { + t.Errorf("blocked bean ID = %q, want %q", beans[0].ID, "bl-target") + } + + blockers := testCore.FindActiveBlockers(beans[0].ID) + if len(blockers) != 1 { + t.Fatalf("expected 1 blocker, got %d", len(blockers)) + } + if blockers[0].ID != "bl-blocker" { + t.Errorf("blocker ID = %q, want %q", blockers[0].ID, "bl-blocker") + } +} + +// --- Milestones tests --- + +func TestMilestonesAggregation(t *testing.T) { + testCore, cleanup := setupTestCore(t) + defer cleanup() + + createBean(t, testCore, "ms-1", "Milestone 1", "in-progress", "milestone") + createBeanWithParent(t, testCore, "ms-child-1", "Task 1", "completed", "task", "ms-1") + createBeanWithParent(t, testCore, "ms-child-2", "Task 2", "todo", "task", "ms-1") + createBeanWithParent(t, testCore, "ms-child-3", "Task 3", "completed", "task", "ms-1") + + resolver := &graph.Resolver{Core: testCore} + ctx := context.Background() + allBeans, err := resolver.Query().Beans(ctx, nil) + if err != nil { + t.Fatalf("Beans() error = %v", err) + } + + // Build children index + children := make(map[string][]*bean.Bean) + for _, b := range allBeans { + if b.Parent != "" { + children[b.Parent] = append(children[b.Parent], b) + } + } + + kids := children["ms-1"] + if len(kids) != 3 { + t.Fatalf("expected 3 children, got %d", len(kids)) + } + + byStatus := make(map[string]int) + for _, k := range kids { + byStatus[k.Status]++ + } + + if byStatus["completed"] != 2 { + t.Errorf("completed = %d, want 2", byStatus["completed"]) + } + if byStatus["todo"] != 1 { + t.Errorf("todo = %d, want 1", byStatus["todo"]) + } +} + +func TestMilestonesExcludeArchived(t *testing.T) { + testCore, cleanup := setupTestCore(t) + defer cleanup() + + createBean(t, testCore, "ms-active", "Active MS", "in-progress", "milestone") + createBean(t, testCore, "ms-done", "Done MS", "completed", "milestone") + + resolver := &graph.Resolver{Core: testCore} + ctx := context.Background() + allBeans, err := resolver.Query().Beans(ctx, nil) + if err != nil { + t.Fatalf("Beans() error = %v", err) + } + + var active, done int + for _, b := range allBeans { + if b.Type != "milestone" { + continue + } + if cfg.IsArchiveStatus(b.Status) { + done++ + } else { + active++ + } + } + + if active != 1 { + t.Errorf("active milestones = %d, want 1", active) + } + if done != 1 { + t.Errorf("done milestones = %d, want 1", done) + } +} + +// --- Progress tests --- + +func TestProgressAggregation(t *testing.T) { + testCore, cleanup := setupTestCore(t) + defer cleanup() + + createBean(t, testCore, "pg-1", "Task 1", "todo", "task") + createBean(t, testCore, "pg-2", "Task 2", "in-progress", "task") + createBean(t, testCore, "pg-3", "Bug 1", "todo", "bug") + createBean(t, testCore, "pg-4", "Feature 1", "completed", "feature") + + resolver := &graph.Resolver{Core: testCore} + ctx := context.Background() + allBeans, err := resolver.Query().Beans(ctx, nil) + if err != nil { + t.Fatalf("Beans() error = %v", err) + } + + byStatus := make(map[string]int) + byType := make(map[string]int) + for _, b := range allBeans { + byStatus[b.Status]++ + byType[b.Type]++ + } + + if len(allBeans) != 4 { + t.Errorf("total = %d, want 4", len(allBeans)) + } + if byStatus["todo"] != 2 { + t.Errorf("todo = %d, want 2", byStatus["todo"]) + } + if byStatus["in-progress"] != 1 { + t.Errorf("in-progress = %d, want 1", byStatus["in-progress"]) + } + if byStatus["completed"] != 1 { + t.Errorf("completed = %d, want 1", byStatus["completed"]) + } + if byType["task"] != 2 { + t.Errorf("task = %d, want 2", byType["task"]) + } + if byType["bug"] != 1 { + t.Errorf("bug = %d, want 1", byType["bug"]) + } + if byType["feature"] != 1 { + t.Errorf("feature = %d, want 1", byType["feature"]) + } +} From f284e2c370aa8decb3a8dc3fe0fe8e0ae7e0cd2d Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Thu, 12 Feb 2026 01:26:48 +0100 Subject: [PATCH 2/2] chore: complete workflow CLI command beans Refs: beans-mmyp --- .beans/beans-0ajg--beans-complete-command.md | 2 +- .beans/beans-18db--beans-milestones-command.md | 2 +- .beans/beans-jvkq--beans-start-command.md | 2 +- .beans/beans-m364--beans-progress-command.md | 2 +- .beans/beans-mmyp--workflow-cli-commands.md | 2 +- .beans/beans-p17z--beans-next-command.md | 2 +- .beans/beans-r780--beans-scrap-command.md | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.beans/beans-0ajg--beans-complete-command.md b/.beans/beans-0ajg--beans-complete-command.md index 6d804c5c..2066411d 100644 --- a/.beans/beans-0ajg--beans-complete-command.md +++ b/.beans/beans-0ajg--beans-complete-command.md @@ -1,7 +1,7 @@ --- # beans-0ajg title: beans complete command -status: todo +status: completed type: task priority: normal created_at: 2025-12-27T21:44:04Z diff --git a/.beans/beans-18db--beans-milestones-command.md b/.beans/beans-18db--beans-milestones-command.md index 96212ff4..d6aed9df 100644 --- a/.beans/beans-18db--beans-milestones-command.md +++ b/.beans/beans-18db--beans-milestones-command.md @@ -1,7 +1,7 @@ --- # beans-18db title: beans milestones command -status: todo +status: completed type: task priority: normal created_at: 2025-12-27T21:44:05Z diff --git a/.beans/beans-jvkq--beans-start-command.md b/.beans/beans-jvkq--beans-start-command.md index faafc42c..57d8133b 100644 --- a/.beans/beans-jvkq--beans-start-command.md +++ b/.beans/beans-jvkq--beans-start-command.md @@ -1,7 +1,7 @@ --- # beans-jvkq title: beans start command -status: todo +status: completed type: task priority: normal created_at: 2025-12-27T21:44:04Z diff --git a/.beans/beans-m364--beans-progress-command.md b/.beans/beans-m364--beans-progress-command.md index dcdd02ad..ee00c5a1 100644 --- a/.beans/beans-m364--beans-progress-command.md +++ b/.beans/beans-m364--beans-progress-command.md @@ -1,7 +1,7 @@ --- # beans-m364 title: beans progress command -status: todo +status: completed type: task priority: normal created_at: 2025-12-27T21:44:05Z diff --git a/.beans/beans-mmyp--workflow-cli-commands.md b/.beans/beans-mmyp--workflow-cli-commands.md index e628f098..00d7e7e5 100644 --- a/.beans/beans-mmyp--workflow-cli-commands.md +++ b/.beans/beans-mmyp--workflow-cli-commands.md @@ -1,7 +1,7 @@ --- # beans-mmyp title: Workflow CLI commands -status: in-progress +status: completed type: epic priority: normal created_at: 2025-12-27T21:43:38Z diff --git a/.beans/beans-p17z--beans-next-command.md b/.beans/beans-p17z--beans-next-command.md index b864f8bc..696191dd 100644 --- a/.beans/beans-p17z--beans-next-command.md +++ b/.beans/beans-p17z--beans-next-command.md @@ -1,7 +1,7 @@ --- # beans-p17z title: beans next command -status: todo +status: completed type: task priority: normal created_at: 2025-12-27T21:44:04Z diff --git a/.beans/beans-r780--beans-scrap-command.md b/.beans/beans-r780--beans-scrap-command.md index 7a8036cf..417a0b6a 100644 --- a/.beans/beans-r780--beans-scrap-command.md +++ b/.beans/beans-r780--beans-scrap-command.md @@ -1,7 +1,7 @@ --- # beans-r780 title: beans scrap command -status: todo +status: completed type: task priority: normal created_at: 2025-12-27T21:44:04Z