feat: Add 'bd count' command for counting and grouping issues
Implements a new 'bd count' command that provides efficient issue counting with filtering and grouping capabilities. Features: - Basic count: Returns total count of issues matching filters - All filtering options from 'bd list' (status, priority, type, assignee, labels, dates, etc.) - Grouping via --by-* flags: status, priority, type, assignee, label - JSON output support for both simple and grouped counts - Both daemon and direct mode support Implementation: - Added OpCount operation and CountArgs to RPC protocol - Added Count() method to RPC client - Implemented handleCount() server-side handler with optimized bulk label fetching - Created cmd/bd/count.go with full CLI implementation Performance optimization: - Pre-fetches all labels in a single query when using --by-label to avoid N+1 queries 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
462
cmd/bd/count.go
Normal file
462
cmd/bd/count.go
Normal file
@@ -0,0 +1,462 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/util"
|
||||
)
|
||||
|
||||
var countCmd = &cobra.Command{
|
||||
Use: "count",
|
||||
Short: "Count issues matching filters",
|
||||
Long: `Count issues matching the specified filters.
|
||||
|
||||
By default, returns the total count of issues matching the filters.
|
||||
Use --by-* flags to group counts by different attributes.
|
||||
|
||||
Examples:
|
||||
bd count # Count all issues
|
||||
bd count --status open # Count open issues
|
||||
bd count --by-status # Group count by status
|
||||
bd count --by-priority # Group count by priority
|
||||
bd count --by-type # Group count by issue type
|
||||
bd count --by-assignee # Group count by assignee
|
||||
bd count --by-label # Group count by label
|
||||
bd count --assignee alice --by-status # Count alice's issues by status
|
||||
`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
status, _ := cmd.Flags().GetString("status")
|
||||
assignee, _ := cmd.Flags().GetString("assignee")
|
||||
issueType, _ := cmd.Flags().GetString("type")
|
||||
labels, _ := cmd.Flags().GetStringSlice("label")
|
||||
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
|
||||
titleSearch, _ := cmd.Flags().GetString("title")
|
||||
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")
|
||||
|
||||
// Group by flags
|
||||
byStatus, _ := cmd.Flags().GetBool("by-status")
|
||||
byPriority, _ := cmd.Flags().GetBool("by-priority")
|
||||
byType, _ := cmd.Flags().GetBool("by-type")
|
||||
byAssignee, _ := cmd.Flags().GetBool("by-assignee")
|
||||
byLabel, _ := cmd.Flags().GetBool("by-label")
|
||||
|
||||
// Determine groupBy value
|
||||
groupBy := ""
|
||||
groupCount := 0
|
||||
if byStatus {
|
||||
groupBy = "status"
|
||||
groupCount++
|
||||
}
|
||||
if byPriority {
|
||||
groupBy = "priority"
|
||||
groupCount++
|
||||
}
|
||||
if byType {
|
||||
groupBy = "type"
|
||||
groupCount++
|
||||
}
|
||||
if byAssignee {
|
||||
groupBy = "assignee"
|
||||
groupCount++
|
||||
}
|
||||
if byLabel {
|
||||
groupBy = "label"
|
||||
groupCount++
|
||||
}
|
||||
|
||||
if groupCount > 1 {
|
||||
fmt.Fprintf(os.Stderr, "Error: only one --by-* flag can be specified\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Normalize labels
|
||||
labels = util.NormalizeLabels(labels)
|
||||
labelsAny = util.NormalizeLabels(labelsAny)
|
||||
|
||||
// Check database freshness before reading
|
||||
ctx := rootCtx
|
||||
if daemonClient == nil {
|
||||
if err := ensureDatabaseFresh(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// If daemon is running, use RPC
|
||||
if daemonClient != nil {
|
||||
countArgs := &rpc.CountArgs{
|
||||
Status: status,
|
||||
IssueType: issueType,
|
||||
Assignee: assignee,
|
||||
GroupBy: groupBy,
|
||||
}
|
||||
if cmd.Flags().Changed("priority") {
|
||||
priority, _ := cmd.Flags().GetInt("priority")
|
||||
countArgs.Priority = &priority
|
||||
}
|
||||
if len(labels) > 0 {
|
||||
countArgs.Labels = labels
|
||||
}
|
||||
if len(labelsAny) > 0 {
|
||||
countArgs.LabelsAny = labelsAny
|
||||
}
|
||||
if titleSearch != "" {
|
||||
countArgs.Query = titleSearch
|
||||
}
|
||||
if idFilter != "" {
|
||||
ids := util.NormalizeLabels(strings.Split(idFilter, ","))
|
||||
if len(ids) > 0 {
|
||||
countArgs.IDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern matching
|
||||
countArgs.TitleContains = titleContains
|
||||
countArgs.DescriptionContains = descContains
|
||||
countArgs.NotesContains = notesContains
|
||||
|
||||
// Date ranges
|
||||
countArgs.CreatedAfter = createdAfter
|
||||
countArgs.CreatedBefore = createdBefore
|
||||
countArgs.UpdatedAfter = updatedAfter
|
||||
countArgs.UpdatedBefore = updatedBefore
|
||||
countArgs.ClosedAfter = closedAfter
|
||||
countArgs.ClosedBefore = closedBefore
|
||||
|
||||
// Empty/null checks
|
||||
countArgs.EmptyDescription = emptyDesc
|
||||
countArgs.NoAssignee = noAssignee
|
||||
countArgs.NoLabels = noLabels
|
||||
|
||||
// Priority range
|
||||
if cmd.Flags().Changed("priority-min") {
|
||||
countArgs.PriorityMin = &priorityMin
|
||||
}
|
||||
if cmd.Flags().Changed("priority-max") {
|
||||
countArgs.PriorityMax = &priorityMax
|
||||
}
|
||||
|
||||
resp, err := daemonClient.Count(countArgs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if groupBy == "" {
|
||||
// Simple count
|
||||
var result struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(result)
|
||||
} else {
|
||||
fmt.Println(result.Count)
|
||||
}
|
||||
} else {
|
||||
// Grouped count
|
||||
var result struct {
|
||||
Total int `json:"total"`
|
||||
Groups []struct {
|
||||
Group string `json:"group"`
|
||||
Count int `json:"count"`
|
||||
} `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(result)
|
||||
} else {
|
||||
// Sort groups for consistent output
|
||||
sort.Slice(result.Groups, func(i, j int) bool {
|
||||
return result.Groups[i].Group < result.Groups[j].Group
|
||||
})
|
||||
|
||||
fmt.Printf("Total: %d\n\n", result.Total)
|
||||
for _, g := range result.Groups {
|
||||
fmt.Printf("%s: %d\n", g.Group, g.Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Direct mode
|
||||
filter := types.IssueFilter{}
|
||||
if status != "" && status != "all" {
|
||||
s := types.Status(status)
|
||||
filter.Status = &s
|
||||
}
|
||||
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 idFilter != "" {
|
||||
ids := util.NormalizeLabels(strings.Split(idFilter, ","))
|
||||
if len(ids) > 0 {
|
||||
filter.IDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern matching
|
||||
filter.TitleContains = titleContains
|
||||
filter.DescriptionContains = descContains
|
||||
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
|
||||
filter.EmptyDescription = emptyDesc
|
||||
filter.NoAssignee = noAssignee
|
||||
filter.NoLabels = noLabels
|
||||
|
||||
// Priority range
|
||||
if cmd.Flags().Changed("priority-min") {
|
||||
filter.PriorityMin = &priorityMin
|
||||
}
|
||||
if cmd.Flags().Changed("priority-max") {
|
||||
filter.PriorityMax = &priorityMax
|
||||
}
|
||||
|
||||
issues, err := store.SearchIssues(ctx, "", filter)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// If no grouping, just print count
|
||||
if groupBy == "" {
|
||||
if jsonOutput {
|
||||
result := struct {
|
||||
Count int `json:"count"`
|
||||
}{Count: len(issues)}
|
||||
outputJSON(result)
|
||||
} else {
|
||||
fmt.Println(len(issues))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Group by the specified field
|
||||
counts := make(map[string]int)
|
||||
|
||||
// For label grouping, fetch all labels in one query to avoid N+1
|
||||
var labelsMap map[string][]string
|
||||
if groupBy == "label" {
|
||||
issueIDs := make([]string, len(issues))
|
||||
for i, issue := range issues {
|
||||
issueIDs[i] = issue.ID
|
||||
}
|
||||
var err error
|
||||
labelsMap, err = store.GetLabelsForIssues(ctx, issueIDs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting labels: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
var groupKey string
|
||||
switch groupBy {
|
||||
case "status":
|
||||
groupKey = string(issue.Status)
|
||||
case "priority":
|
||||
groupKey = fmt.Sprintf("P%d", issue.Priority)
|
||||
case "type":
|
||||
groupKey = string(issue.IssueType)
|
||||
case "assignee":
|
||||
if issue.Assignee == "" {
|
||||
groupKey = "(unassigned)"
|
||||
} else {
|
||||
groupKey = issue.Assignee
|
||||
}
|
||||
case "label":
|
||||
// For labels, count each label separately
|
||||
labels := labelsMap[issue.ID]
|
||||
if len(labels) > 0 {
|
||||
for _, label := range labels {
|
||||
counts[label]++
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
groupKey = "(no labels)"
|
||||
}
|
||||
}
|
||||
counts[groupKey]++
|
||||
}
|
||||
|
||||
type GroupCount struct {
|
||||
Group string `json:"group"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
groups := make([]GroupCount, 0, len(counts))
|
||||
for group, count := range counts {
|
||||
groups = append(groups, GroupCount{Group: group, Count: count})
|
||||
}
|
||||
|
||||
// Sort for consistent output
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
return groups[i].Group < groups[j].Group
|
||||
})
|
||||
|
||||
if jsonOutput {
|
||||
result := struct {
|
||||
Total int `json:"total"`
|
||||
Groups []GroupCount `json:"groups"`
|
||||
}{
|
||||
Total: len(issues),
|
||||
Groups: groups,
|
||||
}
|
||||
outputJSON(result)
|
||||
} else {
|
||||
fmt.Printf("Total: %d\n\n", len(issues))
|
||||
for _, g := range groups {
|
||||
fmt.Printf("%s: %d\n", g.Group, g.Count)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Filter flags (same as list command)
|
||||
countCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)")
|
||||
countCmd.Flags().IntP("priority", "p", 0, "Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)")
|
||||
countCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
||||
countCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
|
||||
countCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)")
|
||||
countCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE)")
|
||||
countCmd.Flags().String("title", "", "Filter by title text (case-insensitive substring match)")
|
||||
countCmd.Flags().String("id", "", "Filter by specific issue IDs (comma-separated)")
|
||||
|
||||
// Pattern matching
|
||||
countCmd.Flags().String("title-contains", "", "Filter by title substring")
|
||||
countCmd.Flags().String("desc-contains", "", "Filter by description substring")
|
||||
countCmd.Flags().String("notes-contains", "", "Filter by notes substring")
|
||||
|
||||
// Date ranges
|
||||
countCmd.Flags().String("created-after", "", "Filter issues created after date (YYYY-MM-DD or RFC3339)")
|
||||
countCmd.Flags().String("created-before", "", "Filter issues created before date (YYYY-MM-DD or RFC3339)")
|
||||
countCmd.Flags().String("updated-after", "", "Filter issues updated after date (YYYY-MM-DD or RFC3339)")
|
||||
countCmd.Flags().String("updated-before", "", "Filter issues updated before date (YYYY-MM-DD or RFC3339)")
|
||||
countCmd.Flags().String("closed-after", "", "Filter issues closed after date (YYYY-MM-DD or RFC3339)")
|
||||
countCmd.Flags().String("closed-before", "", "Filter issues closed before date (YYYY-MM-DD or RFC3339)")
|
||||
|
||||
// Empty/null checks
|
||||
countCmd.Flags().Bool("empty-description", false, "Filter issues with empty description")
|
||||
countCmd.Flags().Bool("no-assignee", false, "Filter issues with no assignee")
|
||||
countCmd.Flags().Bool("no-labels", false, "Filter issues with no labels")
|
||||
|
||||
// Priority ranges
|
||||
countCmd.Flags().Int("priority-min", 0, "Filter by minimum priority (inclusive)")
|
||||
countCmd.Flags().Int("priority-max", 0, "Filter by maximum priority (inclusive)")
|
||||
|
||||
// Grouping flags
|
||||
countCmd.Flags().Bool("by-status", false, "Group count by status")
|
||||
countCmd.Flags().Bool("by-priority", false, "Group count by priority")
|
||||
countCmd.Flags().Bool("by-type", false, "Group count by issue type")
|
||||
countCmd.Flags().Bool("by-assignee", false, "Group count by assignee")
|
||||
countCmd.Flags().Bool("by-label", false, "Group count by label")
|
||||
|
||||
rootCmd.AddCommand(countCmd)
|
||||
}
|
||||
Reference in New Issue
Block a user