From 6f357ea536e6838666872f66d04b14a107c665f9 Mon Sep 17 00:00:00 2001 From: Travis Cline Date: Wed, 15 Oct 2025 14:10:46 -0700 Subject: [PATCH] list: add --format flag with template support Add flexible --format flag to 'bd list' command supporting: - Built-in presets: 'digraph' (basic 'from to' format) and 'dot' (Graphviz) - Custom Go templates for dependency output - Template variables: IssueID, DependsOnID, Type, Issue, Dependency This enables graph analysis with tools like golang.org/x/tools/cmd/digraph while allowing users to customize output format for their specific needs. Examples: bd list --format=digraph | digraph nodes bd list --format=dot | dot -Tsvg -o deps.svg bd list --format='{{.IssueID}} -> {{.DependsOnID}} [{{.Type}}]' --- cmd/bd/list.go | 131 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/cmd/bd/list.go b/cmd/bd/list.go index 8640a930..c5d81681 100644 --- a/cmd/bd/list.go +++ b/cmd/bd/list.go @@ -1,11 +1,14 @@ package main import ( + "bytes" "context" "fmt" "os" + "text/template" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" ) @@ -17,6 +20,7 @@ var listCmd = &cobra.Command{ assignee, _ := cmd.Flags().GetString("assignee") issueType, _ := cmd.Flags().GetString("type") limit, _ := cmd.Flags().GetInt("limit") + formatStr, _ := cmd.Flags().GetString("format") filter := types.IssueFilter{ Limit: limit, @@ -45,6 +49,15 @@ var listCmd = &cobra.Command{ os.Exit(1) } + // Handle format flag + if formatStr != "" { + if err := outputFormattedList(ctx, store, issues, formatStr); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + if jsonOutput { outputJSON(issues) return @@ -68,5 +81,123 @@ func init() { listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)") listCmd.Flags().IntP("limit", "n", 0, "Limit results") + listCmd.Flags().String("format", "", "Output format: 'digraph' (for golang.org/x/tools/cmd/digraph), 'dot' (Graphviz), or Go template") rootCmd.AddCommand(listCmd) } + +// outputDotFormat outputs issues in Graphviz DOT format +func outputDotFormat(ctx context.Context, store storage.Storage, issues []*types.Issue) error { + fmt.Println("digraph dependencies {") + fmt.Println(" rankdir=TB;") + fmt.Println(" node [shape=box, style=rounded];") + fmt.Println() + + // Build map of all issues for quick lookup + issueMap := make(map[string]*types.Issue) + for _, issue := range issues { + issueMap[issue.ID] = issue + } + + // Output nodes with labels + for _, issue := range issues { + // Escape quotes in title + title := issue.Title + title = fmt.Sprintf("%q", title) // Go's %q handles escaping + fmt.Printf(" %q [label=%s];\n", issue.ID, title) + } + fmt.Println() + + // Output edges with labels for dependency type + for _, issue := range issues { + deps, err := store.GetDependencyRecords(ctx, issue.ID) + if err != nil { + continue + } + for _, dep := range deps { + // Only output edges where both nodes are in the filtered list + if issueMap[dep.DependsOnID] != nil { + // Color code by dependency type + color := "black" + style := "solid" + switch dep.Type { + case "blocks": + color = "red" + style = "bold" + case "parent-child": + color = "blue" + case "discovered-from": + color = "green" + style = "dashed" + case "related": + color = "gray" + style = "dashed" + } + fmt.Printf(" %q -> %q [label=%q, color=%s, style=%s];\n", + issue.ID, dep.DependsOnID, dep.Type, color, style) + } + } + } + + fmt.Println("}") + return nil +} + +// outputFormattedList outputs issues in a custom format (preset or Go template) +func outputFormattedList(ctx context.Context, store storage.Storage, issues []*types.Issue, formatStr string) error { + // Handle special 'dot' format (Graphviz output) + if formatStr == "dot" { + return outputDotFormat(ctx, store, issues) + } + + // Built-in format presets + presets := map[string]string{ + "digraph": "{{.IssueID}} {{.DependsOnID}}", + } + + // Check if it's a preset + templateStr, isPreset := presets[formatStr] + if !isPreset { + templateStr = formatStr + } + + // Parse template + tmpl, err := template.New("format").Parse(templateStr) + if err != nil { + return fmt.Errorf("invalid format template: %w", err) + } + + // Build map of all issues for quick lookup + issueMap := make(map[string]bool) + for _, issue := range issues { + issueMap[issue.ID] = true + } + + // For each issue, output its dependencies using the template + for _, issue := range issues { + deps, err := store.GetDependencyRecords(ctx, issue.ID) + if err != nil { + continue + } + for _, dep := range deps { + // Only output edges where both nodes are in the filtered list + if issueMap[dep.DependsOnID] { + // Template data includes both issue and dependency info + data := map[string]interface{}{ + "IssueID": issue.ID, + "DependsOnID": dep.DependsOnID, + "Type": dep.Type, + "Issue": issue, + "Dependency": dep, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("template execution error: %w", err) + } + fmt.Println(buf.String()) + } + } + } + + return nil +}