feat: Add ancestor_id field and implement epic/child filtering
Amp-Thread-ID: https://ampcode.com/threads/T-22f7d7c5-6f7b-4783-beda-8494360d887a Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
215
cmd/bd/list.go
215
cmd/bd/list.go
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
@@ -33,6 +34,24 @@ func normalizeLabels(ss []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// parseTimeFlag parses time strings in multiple formats
|
||||
func parseTimeFlag(s string) (time.Time, error) {
|
||||
formats := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("unable to parse time %q (try formats: 2006-01-02, 2006-01-02T15:04:05, or RFC3339)", s)
|
||||
}
|
||||
|
||||
var listCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List issues",
|
||||
@@ -45,7 +64,30 @@ var listCmd = &cobra.Command{
|
||||
labels, _ := cmd.Flags().GetStringSlice("label")
|
||||
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
|
||||
titleSearch, _ := cmd.Flags().GetString("title")
|
||||
idFilter, _ := cmd.Flags().GetString("id")
|
||||
idFilter, _ := cmd.Flags().GetString("id")
|
||||
|
||||
// Pattern matching flags
|
||||
titleContains, _ := cmd.Flags().GetString("title-contains")
|
||||
descContains, _ := cmd.Flags().GetString("desc-contains")
|
||||
notesContains, _ := cmd.Flags().GetString("notes-contains")
|
||||
|
||||
// Date range flags
|
||||
createdAfter, _ := cmd.Flags().GetString("created-after")
|
||||
createdBefore, _ := cmd.Flags().GetString("created-before")
|
||||
updatedAfter, _ := cmd.Flags().GetString("updated-after")
|
||||
updatedBefore, _ := cmd.Flags().GetString("updated-before")
|
||||
closedAfter, _ := cmd.Flags().GetString("closed-after")
|
||||
closedBefore, _ := cmd.Flags().GetString("closed-before")
|
||||
|
||||
// Empty/null check flags
|
||||
emptyDesc, _ := cmd.Flags().GetBool("empty-description")
|
||||
noAssignee, _ := cmd.Flags().GetBool("no-assignee")
|
||||
noLabels, _ := cmd.Flags().GetBool("no-labels")
|
||||
|
||||
// Priority range flags
|
||||
priorityMin, _ := cmd.Flags().GetInt("priority-min")
|
||||
priorityMax, _ := cmd.Flags().GetInt("priority-max")
|
||||
|
||||
// Use global jsonOutput set by PersistentPreRun
|
||||
|
||||
// Normalize labels: trim, dedupe, remove empty
|
||||
@@ -53,39 +95,119 @@ var listCmd = &cobra.Command{
|
||||
labelsAny = normalizeLabels(labelsAny)
|
||||
|
||||
filter := types.IssueFilter{
|
||||
Limit: limit,
|
||||
Limit: limit,
|
||||
}
|
||||
if status != "" && status != "all" {
|
||||
s := types.Status(status)
|
||||
filter.Status = &s
|
||||
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
|
||||
priority, _ := cmd.Flags().GetInt("priority")
|
||||
filter.Priority = &priority
|
||||
}
|
||||
if assignee != "" {
|
||||
filter.Assignee = &assignee
|
||||
filter.Assignee = &assignee
|
||||
}
|
||||
if issueType != "" {
|
||||
t := types.IssueType(issueType)
|
||||
filter.IssueType = &t
|
||||
t := types.IssueType(issueType)
|
||||
filter.IssueType = &t
|
||||
}
|
||||
if len(labels) > 0 {
|
||||
filter.Labels = labels
|
||||
filter.Labels = labels
|
||||
}
|
||||
if len(labelsAny) > 0 {
|
||||
filter.LabelsAny = labelsAny
|
||||
filter.LabelsAny = labelsAny
|
||||
}
|
||||
if titleSearch != "" {
|
||||
filter.TitleSearch = titleSearch
|
||||
filter.TitleSearch = titleSearch
|
||||
}
|
||||
if idFilter != "" {
|
||||
ids := normalizeLabels(strings.Split(idFilter, ","))
|
||||
if len(ids) > 0 {
|
||||
filter.IDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern matching
|
||||
if titleContains != "" {
|
||||
filter.TitleContains = titleContains
|
||||
}
|
||||
if descContains != "" {
|
||||
filter.DescriptionContains = descContains
|
||||
}
|
||||
if notesContains != "" {
|
||||
filter.NotesContains = notesContains
|
||||
}
|
||||
|
||||
// Date ranges
|
||||
if createdAfter != "" {
|
||||
t, err := parseTimeFlag(createdAfter)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing --created-after: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
filter.CreatedAfter = &t
|
||||
}
|
||||
if createdBefore != "" {
|
||||
t, err := parseTimeFlag(createdBefore)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing --created-before: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
filter.CreatedBefore = &t
|
||||
}
|
||||
if updatedAfter != "" {
|
||||
t, err := parseTimeFlag(updatedAfter)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing --updated-after: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
filter.UpdatedAfter = &t
|
||||
}
|
||||
if updatedBefore != "" {
|
||||
t, err := parseTimeFlag(updatedBefore)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing --updated-before: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
filter.UpdatedBefore = &t
|
||||
}
|
||||
if closedAfter != "" {
|
||||
t, err := parseTimeFlag(closedAfter)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing --closed-after: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
filter.ClosedAfter = &t
|
||||
}
|
||||
if closedBefore != "" {
|
||||
t, err := parseTimeFlag(closedBefore)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing --closed-before: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
filter.ClosedBefore = &t
|
||||
}
|
||||
|
||||
// Empty/null checks
|
||||
if emptyDesc {
|
||||
filter.EmptyDescription = true
|
||||
}
|
||||
if noAssignee {
|
||||
filter.NoAssignee = true
|
||||
}
|
||||
if noLabels {
|
||||
filter.NoLabels = true
|
||||
}
|
||||
|
||||
// Priority ranges
|
||||
if cmd.Flags().Changed("priority-min") {
|
||||
filter.PriorityMin = &priorityMin
|
||||
}
|
||||
if cmd.Flags().Changed("priority-max") {
|
||||
filter.PriorityMax = &priorityMax
|
||||
}
|
||||
if idFilter != "" {
|
||||
ids := normalizeLabels(strings.Split(idFilter, ","))
|
||||
if len(ids) > 0 {
|
||||
filter.IDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
// If daemon is running, use RPC
|
||||
if daemonClient != nil {
|
||||
@@ -112,6 +234,40 @@ var listCmd = &cobra.Command{
|
||||
if len(filter.IDs) > 0 {
|
||||
listArgs.IDs = filter.IDs
|
||||
}
|
||||
|
||||
// Pattern matching
|
||||
listArgs.TitleContains = titleContains
|
||||
listArgs.DescriptionContains = descContains
|
||||
listArgs.NotesContains = notesContains
|
||||
|
||||
// Date ranges
|
||||
if filter.CreatedAfter != nil {
|
||||
listArgs.CreatedAfter = filter.CreatedAfter.Format(time.RFC3339)
|
||||
}
|
||||
if filter.CreatedBefore != nil {
|
||||
listArgs.CreatedBefore = filter.CreatedBefore.Format(time.RFC3339)
|
||||
}
|
||||
if filter.UpdatedAfter != nil {
|
||||
listArgs.UpdatedAfter = filter.UpdatedAfter.Format(time.RFC3339)
|
||||
}
|
||||
if filter.UpdatedBefore != nil {
|
||||
listArgs.UpdatedBefore = filter.UpdatedBefore.Format(time.RFC3339)
|
||||
}
|
||||
if filter.ClosedAfter != nil {
|
||||
listArgs.ClosedAfter = filter.ClosedAfter.Format(time.RFC3339)
|
||||
}
|
||||
if filter.ClosedBefore != nil {
|
||||
listArgs.ClosedBefore = filter.ClosedBefore.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// Empty/null checks
|
||||
listArgs.EmptyDescription = filter.EmptyDescription
|
||||
listArgs.NoAssignee = filter.NoAssignee
|
||||
listArgs.NoLabels = filter.NoLabels
|
||||
|
||||
// Priority range
|
||||
listArgs.PriorityMin = filter.PriorityMin
|
||||
listArgs.PriorityMax = filter.PriorityMax
|
||||
|
||||
resp, err := daemonClient.List(listArgs)
|
||||
if err != nil {
|
||||
@@ -240,6 +396,29 @@ func init() {
|
||||
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")
|
||||
listCmd.Flags().Bool("all", false, "Show all issues (default behavior; flag provided for CLI familiarity)")
|
||||
|
||||
// Pattern matching
|
||||
listCmd.Flags().String("title-contains", "", "Filter by title substring (case-insensitive)")
|
||||
listCmd.Flags().String("desc-contains", "", "Filter by description substring (case-insensitive)")
|
||||
listCmd.Flags().String("notes-contains", "", "Filter by notes substring (case-insensitive)")
|
||||
|
||||
// Date ranges
|
||||
listCmd.Flags().String("created-after", "", "Filter issues created after date (YYYY-MM-DD or RFC3339)")
|
||||
listCmd.Flags().String("created-before", "", "Filter issues created before date (YYYY-MM-DD or RFC3339)")
|
||||
listCmd.Flags().String("updated-after", "", "Filter issues updated after date (YYYY-MM-DD or RFC3339)")
|
||||
listCmd.Flags().String("updated-before", "", "Filter issues updated before date (YYYY-MM-DD or RFC3339)")
|
||||
listCmd.Flags().String("closed-after", "", "Filter issues closed after date (YYYY-MM-DD or RFC3339)")
|
||||
listCmd.Flags().String("closed-before", "", "Filter issues closed before date (YYYY-MM-DD or RFC3339)")
|
||||
|
||||
// Empty/null checks
|
||||
listCmd.Flags().Bool("empty-description", false, "Filter issues with empty or missing description")
|
||||
listCmd.Flags().Bool("no-assignee", false, "Filter issues with no assignee")
|
||||
listCmd.Flags().Bool("no-labels", false, "Filter issues with no labels")
|
||||
|
||||
// Priority ranges
|
||||
listCmd.Flags().Int("priority-min", 0, "Filter by minimum priority (inclusive)")
|
||||
listCmd.Flags().Int("priority-max", 0, "Filter by maximum priority (inclusive)")
|
||||
|
||||
// Note: --json flag is defined as a persistent flag in main.go, not here
|
||||
rootCmd.AddCommand(listCmd)
|
||||
}
|
||||
|
||||
@@ -221,3 +221,270 @@ func TestListCommand(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListQueryCapabilities(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "bd-test-query-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
testDB := filepath.Join(tmpDir, "test.db")
|
||||
st := newTestStore(t, testDB)
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
yesterday := now.Add(-24 * time.Hour)
|
||||
twoDaysAgo := now.Add(-48 * time.Hour)
|
||||
|
||||
// Create test issues with varied attributes
|
||||
issue1 := &types.Issue{
|
||||
Title: "Authentication Bug",
|
||||
Description: "Login fails with special characters",
|
||||
Notes: "Needs urgent fix",
|
||||
Priority: 0,
|
||||
IssueType: types.TypeBug,
|
||||
Status: types.StatusOpen,
|
||||
Assignee: "alice",
|
||||
}
|
||||
issue2 := &types.Issue{
|
||||
Title: "Add OAuth Support",
|
||||
Description: "", // Empty description
|
||||
Priority: 2,
|
||||
IssueType: types.TypeFeature,
|
||||
Status: types.StatusInProgress,
|
||||
// No assignee
|
||||
}
|
||||
issue3 := &types.Issue{
|
||||
Title: "Update Documentation",
|
||||
Description: "Update README with new features",
|
||||
Notes: "Include OAuth setup",
|
||||
Priority: 3,
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Assignee: "bob",
|
||||
}
|
||||
|
||||
for _, issue := range []*types.Issue{issue1, issue2, issue3} {
|
||||
if err := st.CreateIssue(ctx, issue, "test-user"); err != nil {
|
||||
t.Fatalf("Failed to create issue: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Close issue3 to set closed_at timestamp
|
||||
if err := st.CloseIssue(ctx, issue3.ID, "test-user", "Testing"); err != nil {
|
||||
t.Fatalf("Failed to close issue3: %v", err)
|
||||
}
|
||||
|
||||
// Add labels
|
||||
st.AddLabel(ctx, issue1.ID, "critical", "test-user")
|
||||
st.AddLabel(ctx, issue1.ID, "security", "test-user")
|
||||
st.AddLabel(ctx, issue3.ID, "docs", "test-user")
|
||||
|
||||
t.Run("pattern matching - title contains", func(t *testing.T) {
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
TitleContains: "Auth",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("Expected 2 results with 'Auth' in title, got %d", len(results))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pattern matching - description contains", func(t *testing.T) {
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
DescriptionContains: "special characters",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 result, got %d", len(results))
|
||||
}
|
||||
if len(results) > 0 && results[0].ID != issue1.ID {
|
||||
t.Errorf("Expected issue1, got %s", results[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pattern matching - notes contains", func(t *testing.T) {
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
NotesContains: "OAuth",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 result, got %d", len(results))
|
||||
}
|
||||
if len(results) > 0 && results[0].ID != issue3.ID {
|
||||
t.Errorf("Expected issue3, got %s", results[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty description check", func(t *testing.T) {
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
EmptyDescription: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 issue with empty description, got %d", len(results))
|
||||
}
|
||||
if len(results) > 0 && results[0].ID != issue2.ID {
|
||||
t.Errorf("Expected issue2, got %s", results[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no assignee check", func(t *testing.T) {
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
NoAssignee: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 issue with no assignee, got %d", len(results))
|
||||
}
|
||||
if len(results) > 0 && results[0].ID != issue2.ID {
|
||||
t.Errorf("Expected issue2, got %s", results[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no labels check", func(t *testing.T) {
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
NoLabels: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 issue with no labels, got %d", len(results))
|
||||
}
|
||||
if len(results) > 0 && results[0].ID != issue2.ID {
|
||||
t.Errorf("Expected issue2, got %s", results[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("priority range - min", func(t *testing.T) {
|
||||
minPrio := 2
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
PriorityMin: &minPrio,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("Expected 2 issues with priority >= 2, got %d", len(results))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("priority range - max", func(t *testing.T) {
|
||||
maxPrio := 1
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
PriorityMax: &maxPrio,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 issue with priority <= 1, got %d", len(results))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("priority range - min and max", func(t *testing.T) {
|
||||
minPrio := 1
|
||||
maxPrio := 2
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
PriorityMin: &minPrio,
|
||||
PriorityMax: &maxPrio,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 issue with priority between 1-2, got %d", len(results))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date range - created after", func(t *testing.T) {
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
CreatedAfter: &twoDaysAgo,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
// All issues created recently
|
||||
if len(results) != 3 {
|
||||
t.Errorf("Expected 3 issues created after two days ago, got %d", len(results))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date range - updated before", func(t *testing.T) {
|
||||
futureTime := now.Add(24 * time.Hour)
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
UpdatedBefore: &futureTime,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
// All issues updated before tomorrow
|
||||
if len(results) != 3 {
|
||||
t.Errorf("Expected 3 issues, got %d", len(results))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date range - closed after", func(t *testing.T) {
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
ClosedAfter: &yesterday,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 closed issue, got %d", len(results))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combined filters", func(t *testing.T) {
|
||||
minPrio := 0
|
||||
maxPrio := 2
|
||||
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
|
||||
TitleContains: "Auth",
|
||||
PriorityMin: &minPrio,
|
||||
PriorityMax: &maxPrio,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("Expected 2 results matching combined filters, got %d", len(results))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseTimeFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{"RFC3339", "2023-01-15T10:30:00Z", false},
|
||||
{"Date only", "2023-01-15", false},
|
||||
{"DateTime without zone", "2023-01-15T10:30:00", false},
|
||||
{"DateTime with space", "2023-01-15 10:30:00", false},
|
||||
{"Invalid format", "January 15, 2023", true},
|
||||
{"Empty string", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := parseTimeFlag(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseTimeFlag(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user