- Add checkAndAutoImport() that detects empty DB with issues in git - Automatically imports from git HEAD:.beads/issues.jsonl - Integrated into list, ready, and stats commands - Zero cognitive load for agents in fresh clones - Makes JSONL truly the source of truth - DB becomes ephemeral cache that auto-rebuilds Also: - Update AGENTS.md onboarding section with import instructions - Merge PR #98 (enhanced .gitignore) Amp-Thread-ID: https://ampcode.com/threads/T-ffcb5e95-e5a0-486b-a0ae-ce8bd861ab9d Co-authored-by: Amp <amp@ampcode.com>
341 lines
9.1 KiB
Go
341 lines
9.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/rpc"
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// normalizeLabels trims whitespace, removes empty strings, and deduplicates labels
|
|
func normalizeLabels(ss []string) []string {
|
|
seen := make(map[string]struct{})
|
|
out := make([]string, 0, len(ss))
|
|
for _, s := range ss {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[s]; ok {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
var listCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List issues",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
status, _ := cmd.Flags().GetString("status")
|
|
assignee, _ := cmd.Flags().GetString("assignee")
|
|
issueType, _ := cmd.Flags().GetString("type")
|
|
limit, _ := cmd.Flags().GetInt("limit")
|
|
formatStr, _ := cmd.Flags().GetString("format")
|
|
labels, _ := cmd.Flags().GetStringSlice("label")
|
|
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
|
|
titleSearch, _ := cmd.Flags().GetString("title")
|
|
|
|
// Normalize labels: trim, dedupe, remove empty
|
|
labels = normalizeLabels(labels)
|
|
labelsAny = normalizeLabels(labelsAny)
|
|
|
|
filter := types.IssueFilter{
|
|
Limit: limit,
|
|
}
|
|
if status != "" && status != "all" {
|
|
s := types.Status(status)
|
|
filter.Status = &s
|
|
}
|
|
// Use Changed() to properly handle P0 (priority=0)
|
|
if cmd.Flags().Changed("priority") {
|
|
priority, _ := cmd.Flags().GetInt("priority")
|
|
filter.Priority = &priority
|
|
}
|
|
if assignee != "" {
|
|
filter.Assignee = &assignee
|
|
}
|
|
if issueType != "" {
|
|
t := types.IssueType(issueType)
|
|
filter.IssueType = &t
|
|
}
|
|
if len(labels) > 0 {
|
|
filter.Labels = labels
|
|
}
|
|
if len(labelsAny) > 0 {
|
|
filter.LabelsAny = labelsAny
|
|
}
|
|
if titleSearch != "" {
|
|
filter.TitleSearch = titleSearch
|
|
}
|
|
|
|
// If daemon is running, use RPC
|
|
if daemonClient != nil {
|
|
listArgs := &rpc.ListArgs{
|
|
Status: status,
|
|
IssueType: issueType,
|
|
Assignee: assignee,
|
|
Limit: limit,
|
|
}
|
|
if cmd.Flags().Changed("priority") {
|
|
priority, _ := cmd.Flags().GetInt("priority")
|
|
listArgs.Priority = &priority
|
|
}
|
|
if len(labels) > 0 {
|
|
listArgs.Labels = labels
|
|
}
|
|
if len(labelsAny) > 0 {
|
|
listArgs.LabelsAny = labelsAny
|
|
}
|
|
// Forward title search via Query field (searches title/description/id)
|
|
if titleSearch != "" {
|
|
listArgs.Query = titleSearch
|
|
}
|
|
|
|
resp, err := daemonClient.List(listArgs)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
var issues []*types.Issue
|
|
if err := json.Unmarshal(resp.Data, &issues); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(issues)
|
|
} else {
|
|
fmt.Printf("\nFound %d issues:\n\n", len(issues))
|
|
for _, issue := range issues {
|
|
fmt.Printf("%s [P%d] [%s] %s\n", issue.ID, issue.Priority, issue.IssueType, issue.Status)
|
|
fmt.Printf(" %s\n", issue.Title)
|
|
if issue.Assignee != "" {
|
|
fmt.Printf(" Assignee: %s\n", issue.Assignee)
|
|
}
|
|
if len(issue.Labels) > 0 {
|
|
fmt.Printf(" Labels: %v\n", issue.Labels)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Direct mode
|
|
ctx := context.Background()
|
|
issues, err := store.SearchIssues(ctx, "", filter)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// If no issues found, check if git has issues and auto-import
|
|
if len(issues) == 0 {
|
|
if checkAndAutoImport(ctx, store) {
|
|
// Re-run the query after import
|
|
issues, err = store.SearchIssues(ctx, "", filter)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
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 {
|
|
// Populate labels for JSON output
|
|
for _, issue := range issues {
|
|
issue.Labels, _ = store.GetLabels(ctx, issue.ID)
|
|
}
|
|
outputJSON(issues)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("\nFound %d issues:\n\n", len(issues))
|
|
for _, issue := range issues {
|
|
// Load labels for display
|
|
labels, _ := store.GetLabels(ctx, issue.ID)
|
|
|
|
fmt.Printf("%s [P%d] [%s] %s\n", issue.ID, issue.Priority, issue.IssueType, issue.Status)
|
|
fmt.Printf(" %s\n", issue.Title)
|
|
if issue.Assignee != "" {
|
|
fmt.Printf(" Assignee: %s\n", issue.Assignee)
|
|
}
|
|
if len(labels) > 0 {
|
|
fmt.Printf(" Labels: %v\n", labels)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)")
|
|
listCmd.Flags().IntP("priority", "p", 0, "Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)")
|
|
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
|
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
|
|
listCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL). Can combine with --label-any")
|
|
listCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label")
|
|
listCmd.Flags().String("title", "", "Filter by title text (case-insensitive substring match)")
|
|
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 including ID, type, priority, and status
|
|
for _, issue := range issues {
|
|
// Build label with ID, type, priority, and title (using actual newlines)
|
|
label := fmt.Sprintf("%s\n[%s P%d]\n%s\n(%s)",
|
|
issue.ID,
|
|
issue.IssueType,
|
|
issue.Priority,
|
|
issue.Title,
|
|
issue.Status)
|
|
|
|
// Color by status only - keep it simple
|
|
fillColor := "white"
|
|
fontColor := "black"
|
|
|
|
switch issue.Status {
|
|
case "closed":
|
|
fillColor = "lightgray"
|
|
fontColor = "dimgray"
|
|
case "in_progress":
|
|
fillColor = "lightyellow"
|
|
case "blocked":
|
|
fillColor = "lightcoral"
|
|
}
|
|
|
|
fmt.Printf(" %q [label=%q, style=\"rounded,filled\", fillcolor=%q, fontcolor=%q];\n",
|
|
issue.ID, label, fillColor, fontColor)
|
|
}
|
|
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
|
|
}
|