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}}]'
This commit is contained in:
131
cmd/bd/list.go
131
cmd/bd/list.go
@@ -1,11 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +20,7 @@ var listCmd = &cobra.Command{
|
|||||||
assignee, _ := cmd.Flags().GetString("assignee")
|
assignee, _ := cmd.Flags().GetString("assignee")
|
||||||
issueType, _ := cmd.Flags().GetString("type")
|
issueType, _ := cmd.Flags().GetString("type")
|
||||||
limit, _ := cmd.Flags().GetInt("limit")
|
limit, _ := cmd.Flags().GetInt("limit")
|
||||||
|
formatStr, _ := cmd.Flags().GetString("format")
|
||||||
|
|
||||||
filter := types.IssueFilter{
|
filter := types.IssueFilter{
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
@@ -45,6 +49,15 @@ var listCmd = &cobra.Command{
|
|||||||
os.Exit(1)
|
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 {
|
if jsonOutput {
|
||||||
outputJSON(issues)
|
outputJSON(issues)
|
||||||
return
|
return
|
||||||
@@ -68,5 +81,123 @@ func init() {
|
|||||||
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
||||||
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
|
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
|
||||||
listCmd.Flags().IntP("limit", "n", 0, "Limit results")
|
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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user