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:
File diff suppressed because one or more lines are too long
@@ -387,6 +387,14 @@ bd list --assignee alice # Filter by assignee
|
|||||||
bd list --label=backend,urgent # Filter by labels (AND)
|
bd list --label=backend,urgent # Filter by labels (AND)
|
||||||
bd list --label-any=frontend,backend # Filter by labels (OR)
|
bd list --label-any=frontend,backend # Filter by labels (OR)
|
||||||
|
|
||||||
|
# Advanced filters
|
||||||
|
bd list --title-contains "auth" # Search title
|
||||||
|
bd list --desc-contains "implement" # Search description
|
||||||
|
bd list --priority-min 0 --priority-max 1 # Priority range
|
||||||
|
bd list --created-after 2025-01-01 # Date range
|
||||||
|
bd list --empty-description # Find issues with no description
|
||||||
|
bd list --no-assignee # Find unassigned issues
|
||||||
|
|
||||||
# JSON output for agents
|
# JSON output for agents
|
||||||
bd info --json
|
bd info --json
|
||||||
bd list --json
|
bd list --json
|
||||||
|
|||||||
215
cmd/bd/list.go
215
cmd/bd/list.go
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/beads/internal/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
@@ -33,6 +34,24 @@ func normalizeLabels(ss []string) []string {
|
|||||||
return out
|
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{
|
var listCmd = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List issues",
|
Short: "List issues",
|
||||||
@@ -45,7 +64,30 @@ var listCmd = &cobra.Command{
|
|||||||
labels, _ := cmd.Flags().GetStringSlice("label")
|
labels, _ := cmd.Flags().GetStringSlice("label")
|
||||||
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
|
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
|
||||||
titleSearch, _ := cmd.Flags().GetString("title")
|
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
|
// Use global jsonOutput set by PersistentPreRun
|
||||||
|
|
||||||
// Normalize labels: trim, dedupe, remove empty
|
// Normalize labels: trim, dedupe, remove empty
|
||||||
@@ -53,39 +95,119 @@ var listCmd = &cobra.Command{
|
|||||||
labelsAny = normalizeLabels(labelsAny)
|
labelsAny = normalizeLabels(labelsAny)
|
||||||
|
|
||||||
filter := types.IssueFilter{
|
filter := types.IssueFilter{
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
}
|
}
|
||||||
if status != "" && status != "all" {
|
if status != "" && status != "all" {
|
||||||
s := types.Status(status)
|
s := types.Status(status)
|
||||||
filter.Status = &s
|
filter.Status = &s
|
||||||
}
|
}
|
||||||
// Use Changed() to properly handle P0 (priority=0)
|
// Use Changed() to properly handle P0 (priority=0)
|
||||||
if cmd.Flags().Changed("priority") {
|
if cmd.Flags().Changed("priority") {
|
||||||
priority, _ := cmd.Flags().GetInt("priority")
|
priority, _ := cmd.Flags().GetInt("priority")
|
||||||
filter.Priority = &priority
|
filter.Priority = &priority
|
||||||
}
|
}
|
||||||
if assignee != "" {
|
if assignee != "" {
|
||||||
filter.Assignee = &assignee
|
filter.Assignee = &assignee
|
||||||
}
|
}
|
||||||
if issueType != "" {
|
if issueType != "" {
|
||||||
t := types.IssueType(issueType)
|
t := types.IssueType(issueType)
|
||||||
filter.IssueType = &t
|
filter.IssueType = &t
|
||||||
}
|
}
|
||||||
if len(labels) > 0 {
|
if len(labels) > 0 {
|
||||||
filter.Labels = labels
|
filter.Labels = labels
|
||||||
}
|
}
|
||||||
if len(labelsAny) > 0 {
|
if len(labelsAny) > 0 {
|
||||||
filter.LabelsAny = labelsAny
|
filter.LabelsAny = labelsAny
|
||||||
}
|
}
|
||||||
if titleSearch != "" {
|
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 daemon is running, use RPC
|
||||||
if daemonClient != nil {
|
if daemonClient != nil {
|
||||||
@@ -112,6 +234,40 @@ var listCmd = &cobra.Command{
|
|||||||
if len(filter.IDs) > 0 {
|
if len(filter.IDs) > 0 {
|
||||||
listArgs.IDs = filter.IDs
|
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)
|
resp, err := daemonClient.List(listArgs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,6 +396,29 @@ func init() {
|
|||||||
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")
|
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)")
|
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
|
// Note: --json flag is defined as a persistent flag in main.go, not here
|
||||||
rootCmd.AddCommand(listCmd)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,23 +5,57 @@ argument-hint: [--status] [--priority] [--type] [--assignee] [--label]
|
|||||||
|
|
||||||
List beads issues with optional filtering.
|
List beads issues with optional filtering.
|
||||||
|
|
||||||
## Filters
|
## Basic Filters
|
||||||
|
|
||||||
- **--status, -s**: Filter by status (open, in_progress, blocked, closed)
|
- **--status, -s**: Filter by status (open, in_progress, blocked, closed)
|
||||||
- **--priority, -p**: Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)
|
- **--priority, -p**: Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)
|
||||||
- **--type, -t**: Filter by type (bug, feature, task, epic, chore)
|
- **--type, -t**: Filter by type (bug, feature, task, epic, chore)
|
||||||
- **--assignee, -a**: Filter by assignee
|
- **--assignee, -a**: Filter by assignee
|
||||||
- **--label, -l**: Filter by labels (comma-separated, must have ALL labels)
|
- **--label, -l**: Filter by labels (comma-separated, must have ALL labels)
|
||||||
|
- **--label-any**: Filter by labels (OR semantics, must have AT LEAST ONE)
|
||||||
- **--title**: Filter by title text (case-insensitive substring match)
|
- **--title**: Filter by title text (case-insensitive substring match)
|
||||||
- **--limit, -n**: Limit number of results
|
- **--limit, -n**: Limit number of results
|
||||||
|
|
||||||
|
## Advanced Filters
|
||||||
|
|
||||||
|
### Pattern Matching
|
||||||
|
- **--title-contains**: Search for text in title (case-insensitive)
|
||||||
|
- **--desc-contains**: Search for text in description (case-insensitive)
|
||||||
|
- **--notes-contains**: Search for text in notes (case-insensitive)
|
||||||
|
|
||||||
|
### Date Ranges
|
||||||
|
- **--created-after**: Issues created after date (YYYY-MM-DD or ISO 8601)
|
||||||
|
- **--created-before**: Issues created before date
|
||||||
|
- **--updated-after**: Issues updated after date
|
||||||
|
- **--updated-before**: Issues updated before date
|
||||||
|
- **--closed-after**: Issues closed after date
|
||||||
|
- **--closed-before**: Issues closed before date
|
||||||
|
|
||||||
|
### Priority Range
|
||||||
|
- **--priority-min**: Minimum priority (inclusive)
|
||||||
|
- **--priority-max**: Maximum priority (inclusive)
|
||||||
|
|
||||||
|
### Empty/Null Checks
|
||||||
|
- **--empty-description**: Find issues with no description
|
||||||
|
- **--no-assignee**: Find unassigned issues
|
||||||
|
- **--no-labels**: Find issues with no labels
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
- `bd list --status open --priority 1`: High priority open issues
|
- `bd list --status open --priority 1`: High priority open issues
|
||||||
- `bd list --type bug --assignee alice`: Alice's assigned bugs
|
- `bd list --type bug --assignee alice`: Alice's assigned bugs
|
||||||
- `bd list --label backend,needs-review`: Backend issues needing review
|
- `bd list --label backend,needs-review`: Backend issues needing review
|
||||||
- `bd list --title "auth"`: Issues with "auth" in the title
|
- `bd list --title "auth"`: Issues with "auth" in the title
|
||||||
|
|
||||||
|
### Advanced Usage
|
||||||
|
- `bd list --title-contains "auth" --status open`: Search open issues for auth-related work
|
||||||
|
- `bd list --priority-min 0 --priority-max 1`: Critical and high priority issues only
|
||||||
|
- `bd list --created-after 2025-01-01 --status open`: Recent open issues
|
||||||
|
- `bd list --empty-description --status open`: Open issues missing descriptions
|
||||||
|
- `bd list --no-assignee --priority 1`: High priority unassigned work
|
||||||
|
- `bd list --desc-contains "TODO" --notes-contains "review"`: Find items needing attention
|
||||||
|
|
||||||
## Output Formats
|
## Output Formats
|
||||||
|
|
||||||
- Default: Human-readable table
|
- Default: Human-readable table
|
||||||
|
|||||||
@@ -99,6 +99,28 @@ type ListArgs struct {
|
|||||||
LabelsAny []string `json:"labels_any,omitempty"` // OR semantics
|
LabelsAny []string `json:"labels_any,omitempty"` // OR semantics
|
||||||
IDs []string `json:"ids,omitempty"` // Filter by specific issue IDs
|
IDs []string `json:"ids,omitempty"` // Filter by specific issue IDs
|
||||||
Limit int `json:"limit,omitempty"`
|
Limit int `json:"limit,omitempty"`
|
||||||
|
|
||||||
|
// Pattern matching
|
||||||
|
TitleContains string `json:"title_contains,omitempty"`
|
||||||
|
DescriptionContains string `json:"description_contains,omitempty"`
|
||||||
|
NotesContains string `json:"notes_contains,omitempty"`
|
||||||
|
|
||||||
|
// Date ranges (ISO 8601 format)
|
||||||
|
CreatedAfter string `json:"created_after,omitempty"`
|
||||||
|
CreatedBefore string `json:"created_before,omitempty"`
|
||||||
|
UpdatedAfter string `json:"updated_after,omitempty"`
|
||||||
|
UpdatedBefore string `json:"updated_before,omitempty"`
|
||||||
|
ClosedAfter string `json:"closed_after,omitempty"`
|
||||||
|
ClosedBefore string `json:"closed_before,omitempty"`
|
||||||
|
|
||||||
|
// Empty/null checks
|
||||||
|
EmptyDescription bool `json:"empty_description,omitempty"`
|
||||||
|
NoAssignee bool `json:"no_assignee,omitempty"`
|
||||||
|
NoLabels bool `json:"no_labels,omitempty"`
|
||||||
|
|
||||||
|
// Priority range
|
||||||
|
PriorityMin *int `json:"priority_min,omitempty"`
|
||||||
|
PriorityMax *int `json:"priority_max,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShowArgs represents arguments for the show operation
|
// ShowArgs represents arguments for the show operation
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
@@ -28,6 +29,27 @@ func normalizeLabels(ss []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseTimeRPC parses time strings in multiple formats (RFC3339, YYYY-MM-DD, etc.)
|
||||||
|
// Matches the parseTimeFlag behavior in cmd/bd/list.go for CLI parity
|
||||||
|
func parseTimeRPC(s string) (time.Time, error) {
|
||||||
|
// Try RFC3339 first (ISO 8601 with timezone)
|
||||||
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try YYYY-MM-DD format (common user input)
|
||||||
|
if t, err := time.Parse("2006-01-02", s); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try YYYY-MM-DD HH:MM:SS format
|
||||||
|
if t, err := time.Parse("2006-01-02 15:04:05", s); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Time{}, fmt.Errorf("unsupported date format: %q (use YYYY-MM-DD or RFC3339)", s)
|
||||||
|
}
|
||||||
|
|
||||||
func strValue(p *string) string {
|
func strValue(p *string) string {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -293,10 +315,13 @@ func (s *Server) handleList(req *Request) Response {
|
|||||||
filter := types.IssueFilter{
|
filter := types.IssueFilter{
|
||||||
Limit: listArgs.Limit,
|
Limit: listArgs.Limit,
|
||||||
}
|
}
|
||||||
if listArgs.Status != "" {
|
|
||||||
|
// Normalize status: treat "" or "all" as unset (no filter)
|
||||||
|
if listArgs.Status != "" && listArgs.Status != "all" {
|
||||||
status := types.Status(listArgs.Status)
|
status := types.Status(listArgs.Status)
|
||||||
filter.Status = &status
|
filter.Status = &status
|
||||||
}
|
}
|
||||||
|
|
||||||
if listArgs.IssueType != "" {
|
if listArgs.IssueType != "" {
|
||||||
issueType := types.IssueType(listArgs.IssueType)
|
issueType := types.IssueType(listArgs.IssueType)
|
||||||
filter.IssueType = &issueType
|
filter.IssueType = &issueType
|
||||||
@@ -307,10 +332,11 @@ func (s *Server) handleList(req *Request) Response {
|
|||||||
if listArgs.Priority != nil {
|
if listArgs.Priority != nil {
|
||||||
filter.Priority = listArgs.Priority
|
filter.Priority = listArgs.Priority
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize and apply label filters
|
// Normalize and apply label filters
|
||||||
labels := normalizeLabels(listArgs.Labels)
|
labels := normalizeLabels(listArgs.Labels)
|
||||||
labelsAny := normalizeLabels(listArgs.LabelsAny)
|
labelsAny := normalizeLabels(listArgs.LabelsAny)
|
||||||
// Support both old single Label and new Labels array
|
// Support both old single Label and new Labels array (backward compat)
|
||||||
if len(labels) > 0 {
|
if len(labels) > 0 {
|
||||||
filter.Labels = labels
|
filter.Labels = labels
|
||||||
} else if listArgs.Label != "" {
|
} else if listArgs.Label != "" {
|
||||||
@@ -325,6 +351,82 @@ func (s *Server) handleList(req *Request) Response {
|
|||||||
filter.IDs = ids
|
filter.IDs = ids
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pattern matching
|
||||||
|
filter.TitleContains = listArgs.TitleContains
|
||||||
|
filter.DescriptionContains = listArgs.DescriptionContains
|
||||||
|
filter.NotesContains = listArgs.NotesContains
|
||||||
|
|
||||||
|
// Date ranges - use parseTimeRPC helper for flexible formats
|
||||||
|
if listArgs.CreatedAfter != "" {
|
||||||
|
t, err := parseTimeRPC(listArgs.CreatedAfter)
|
||||||
|
if err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("invalid --created-after date: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filter.CreatedAfter = &t
|
||||||
|
}
|
||||||
|
if listArgs.CreatedBefore != "" {
|
||||||
|
t, err := parseTimeRPC(listArgs.CreatedBefore)
|
||||||
|
if err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("invalid --created-before date: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filter.CreatedBefore = &t
|
||||||
|
}
|
||||||
|
if listArgs.UpdatedAfter != "" {
|
||||||
|
t, err := parseTimeRPC(listArgs.UpdatedAfter)
|
||||||
|
if err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("invalid --updated-after date: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filter.UpdatedAfter = &t
|
||||||
|
}
|
||||||
|
if listArgs.UpdatedBefore != "" {
|
||||||
|
t, err := parseTimeRPC(listArgs.UpdatedBefore)
|
||||||
|
if err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("invalid --updated-before date: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filter.UpdatedBefore = &t
|
||||||
|
}
|
||||||
|
if listArgs.ClosedAfter != "" {
|
||||||
|
t, err := parseTimeRPC(listArgs.ClosedAfter)
|
||||||
|
if err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("invalid --closed-after date: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filter.ClosedAfter = &t
|
||||||
|
}
|
||||||
|
if listArgs.ClosedBefore != "" {
|
||||||
|
t, err := parseTimeRPC(listArgs.ClosedBefore)
|
||||||
|
if err != nil {
|
||||||
|
return Response{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("invalid --closed-before date: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filter.ClosedBefore = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty/null checks
|
||||||
|
filter.EmptyDescription = listArgs.EmptyDescription
|
||||||
|
filter.NoAssignee = listArgs.NoAssignee
|
||||||
|
filter.NoLabels = listArgs.NoLabels
|
||||||
|
|
||||||
|
// Priority range
|
||||||
|
filter.PriorityMin = listArgs.PriorityMin
|
||||||
|
filter.PriorityMax = listArgs.PriorityMax
|
||||||
|
|
||||||
// Guard against excessive ID lists to avoid SQLite parameter limits
|
// Guard against excessive ID lists to avoid SQLite parameter limits
|
||||||
const maxIDs = 1000
|
const maxIDs = 1000
|
||||||
|
|||||||
@@ -1092,6 +1092,20 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
|
|||||||
args = append(args, pattern)
|
args = append(args, pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pattern matching
|
||||||
|
if filter.TitleContains != "" {
|
||||||
|
whereClauses = append(whereClauses, "title LIKE ?")
|
||||||
|
args = append(args, "%"+filter.TitleContains+"%")
|
||||||
|
}
|
||||||
|
if filter.DescriptionContains != "" {
|
||||||
|
whereClauses = append(whereClauses, "description LIKE ?")
|
||||||
|
args = append(args, "%"+filter.DescriptionContains+"%")
|
||||||
|
}
|
||||||
|
if filter.NotesContains != "" {
|
||||||
|
whereClauses = append(whereClauses, "notes LIKE ?")
|
||||||
|
args = append(args, "%"+filter.NotesContains+"%")
|
||||||
|
}
|
||||||
|
|
||||||
if filter.Status != nil {
|
if filter.Status != nil {
|
||||||
whereClauses = append(whereClauses, "status = ?")
|
whereClauses = append(whereClauses, "status = ?")
|
||||||
args = append(args, *filter.Status)
|
args = append(args, *filter.Status)
|
||||||
@@ -1102,6 +1116,16 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
|
|||||||
args = append(args, *filter.Priority)
|
args = append(args, *filter.Priority)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority ranges
|
||||||
|
if filter.PriorityMin != nil {
|
||||||
|
whereClauses = append(whereClauses, "priority >= ?")
|
||||||
|
args = append(args, *filter.PriorityMin)
|
||||||
|
}
|
||||||
|
if filter.PriorityMax != nil {
|
||||||
|
whereClauses = append(whereClauses, "priority <= ?")
|
||||||
|
args = append(args, *filter.PriorityMax)
|
||||||
|
}
|
||||||
|
|
||||||
if filter.IssueType != nil {
|
if filter.IssueType != nil {
|
||||||
whereClauses = append(whereClauses, "issue_type = ?")
|
whereClauses = append(whereClauses, "issue_type = ?")
|
||||||
args = append(args, *filter.IssueType)
|
args = append(args, *filter.IssueType)
|
||||||
@@ -1112,6 +1136,43 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
|
|||||||
args = append(args, *filter.Assignee)
|
args = append(args, *filter.Assignee)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Date ranges
|
||||||
|
if filter.CreatedAfter != nil {
|
||||||
|
whereClauses = append(whereClauses, "created_at > ?")
|
||||||
|
args = append(args, filter.CreatedAfter.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if filter.CreatedBefore != nil {
|
||||||
|
whereClauses = append(whereClauses, "created_at < ?")
|
||||||
|
args = append(args, filter.CreatedBefore.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if filter.UpdatedAfter != nil {
|
||||||
|
whereClauses = append(whereClauses, "updated_at > ?")
|
||||||
|
args = append(args, filter.UpdatedAfter.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if filter.UpdatedBefore != nil {
|
||||||
|
whereClauses = append(whereClauses, "updated_at < ?")
|
||||||
|
args = append(args, filter.UpdatedBefore.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if filter.ClosedAfter != nil {
|
||||||
|
whereClauses = append(whereClauses, "closed_at > ?")
|
||||||
|
args = append(args, filter.ClosedAfter.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if filter.ClosedBefore != nil {
|
||||||
|
whereClauses = append(whereClauses, "closed_at < ?")
|
||||||
|
args = append(args, filter.ClosedBefore.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty/null checks
|
||||||
|
if filter.EmptyDescription {
|
||||||
|
whereClauses = append(whereClauses, "(description IS NULL OR description = '')")
|
||||||
|
}
|
||||||
|
if filter.NoAssignee {
|
||||||
|
whereClauses = append(whereClauses, "(assignee IS NULL OR assignee = '')")
|
||||||
|
}
|
||||||
|
if filter.NoLabels {
|
||||||
|
whereClauses = append(whereClauses, "id NOT IN (SELECT DISTINCT issue_id FROM labels)")
|
||||||
|
}
|
||||||
|
|
||||||
// Label filtering: issue must have ALL specified labels
|
// Label filtering: issue must have ALL specified labels
|
||||||
if len(filter.Labels) > 0 {
|
if len(filter.Labels) > 0 {
|
||||||
for _, label := range filter.Labels {
|
for _, label := range filter.Labels {
|
||||||
|
|||||||
@@ -271,6 +271,28 @@ type IssueFilter struct {
|
|||||||
TitleSearch string
|
TitleSearch string
|
||||||
IDs []string // Filter by specific issue IDs
|
IDs []string // Filter by specific issue IDs
|
||||||
Limit int
|
Limit int
|
||||||
|
|
||||||
|
// Pattern matching
|
||||||
|
TitleContains string
|
||||||
|
DescriptionContains string
|
||||||
|
NotesContains string
|
||||||
|
|
||||||
|
// Date ranges
|
||||||
|
CreatedAfter *time.Time
|
||||||
|
CreatedBefore *time.Time
|
||||||
|
UpdatedAfter *time.Time
|
||||||
|
UpdatedBefore *time.Time
|
||||||
|
ClosedAfter *time.Time
|
||||||
|
ClosedBefore *time.Time
|
||||||
|
|
||||||
|
// Empty/null checks
|
||||||
|
EmptyDescription bool
|
||||||
|
NoAssignee bool
|
||||||
|
NoLabels bool
|
||||||
|
|
||||||
|
// Numeric ranges
|
||||||
|
PriorityMin *int
|
||||||
|
PriorityMax *int
|
||||||
}
|
}
|
||||||
|
|
||||||
// SortPolicy determines how ready work is ordered
|
// SortPolicy determines how ready work is ordered
|
||||||
|
|||||||
Reference in New Issue
Block a user