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:
Steve Yegge
2025-11-21 19:20:09 -05:00
parent 897e952ac3
commit d7f4189e3e
5 changed files with 740 additions and 0 deletions

462
cmd/bd/count.go Normal file
View 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)
}

View File

@@ -294,6 +294,11 @@ func (c *Client) List(args *ListArgs) (*Response, error) {
return c.Execute(OpList, args) return c.Execute(OpList, args)
} }
// Count counts issues via the daemon
func (c *Client) Count(args *CountArgs) (*Response, error) {
return c.Execute(OpCount, args)
}
// Show shows an issue via the daemon // Show shows an issue via the daemon
func (c *Client) Show(args *ShowArgs) (*Response, error) { func (c *Client) Show(args *ShowArgs) (*Response, error) {
return c.Execute(OpShow, args) return c.Execute(OpShow, args)

View File

@@ -14,6 +14,7 @@ const (
OpUpdate = "update" OpUpdate = "update"
OpClose = "close" OpClose = "close"
OpList = "list" OpList = "list"
OpCount = "count"
OpShow = "show" OpShow = "show"
OpReady = "ready" OpReady = "ready"
OpStale = "stale" OpStale = "stale"
@@ -127,6 +128,44 @@ type ListArgs struct {
PriorityMax *int `json:"priority_max,omitempty"` PriorityMax *int `json:"priority_max,omitempty"`
} }
// CountArgs represents arguments for the count operation
type CountArgs struct {
// Supports all the same filters as ListArgs
Query string `json:"query,omitempty"`
Status string `json:"status,omitempty"`
Priority *int `json:"priority,omitempty"`
IssueType string `json:"issue_type,omitempty"`
Assignee string `json:"assignee,omitempty"`
Labels []string `json:"labels,omitempty"`
LabelsAny []string `json:"labels_any,omitempty"`
IDs []string `json:"ids,omitempty"`
// Pattern matching
TitleContains string `json:"title_contains,omitempty"`
DescriptionContains string `json:"description_contains,omitempty"`
NotesContains string `json:"notes_contains,omitempty"`
// Date ranges
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"`
// Grouping option (only one can be specified)
GroupBy string `json:"group_by,omitempty"` // "status", "priority", "type", "assignee", "label"
}
// ShowArgs represents arguments for the show operation // ShowArgs represents arguments for the show operation
type ShowArgs struct { type ShowArgs struct {
ID string `json:"id"` ID string `json:"id"`

View File

@@ -529,6 +529,238 @@ func (s *Server) handleList(req *Request) Response {
} }
} }
func (s *Server) handleCount(req *Request) Response {
var countArgs CountArgs
if err := json.Unmarshal(req.Args, &countArgs); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid count args: %v", err),
}
}
store := s.storage
if store == nil {
return Response{
Success: false,
Error: "storage not available (global daemon deprecated - use local daemon instead with 'bd daemon' in your project)",
}
}
filter := types.IssueFilter{}
// Normalize status: treat "" or "all" as unset (no filter)
if countArgs.Status != "" && countArgs.Status != "all" {
status := types.Status(countArgs.Status)
filter.Status = &status
}
if countArgs.IssueType != "" {
issueType := types.IssueType(countArgs.IssueType)
filter.IssueType = &issueType
}
if countArgs.Assignee != "" {
filter.Assignee = &countArgs.Assignee
}
if countArgs.Priority != nil {
filter.Priority = countArgs.Priority
}
// Normalize and apply label filters
labels := util.NormalizeLabels(countArgs.Labels)
labelsAny := util.NormalizeLabels(countArgs.LabelsAny)
if len(labels) > 0 {
filter.Labels = labels
}
if len(labelsAny) > 0 {
filter.LabelsAny = labelsAny
}
if len(countArgs.IDs) > 0 {
ids := util.NormalizeLabels(countArgs.IDs)
if len(ids) > 0 {
filter.IDs = ids
}
}
// Pattern matching
filter.TitleContains = countArgs.TitleContains
filter.DescriptionContains = countArgs.DescriptionContains
filter.NotesContains = countArgs.NotesContains
// Date ranges - use parseTimeRPC helper for flexible formats
if countArgs.CreatedAfter != "" {
t, err := parseTimeRPC(countArgs.CreatedAfter)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --created-after date: %v", err),
}
}
filter.CreatedAfter = &t
}
if countArgs.CreatedBefore != "" {
t, err := parseTimeRPC(countArgs.CreatedBefore)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --created-before date: %v", err),
}
}
filter.CreatedBefore = &t
}
if countArgs.UpdatedAfter != "" {
t, err := parseTimeRPC(countArgs.UpdatedAfter)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --updated-after date: %v", err),
}
}
filter.UpdatedAfter = &t
}
if countArgs.UpdatedBefore != "" {
t, err := parseTimeRPC(countArgs.UpdatedBefore)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --updated-before date: %v", err),
}
}
filter.UpdatedBefore = &t
}
if countArgs.ClosedAfter != "" {
t, err := parseTimeRPC(countArgs.ClosedAfter)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --closed-after date: %v", err),
}
}
filter.ClosedAfter = &t
}
if countArgs.ClosedBefore != "" {
t, err := parseTimeRPC(countArgs.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 = countArgs.EmptyDescription
filter.NoAssignee = countArgs.NoAssignee
filter.NoLabels = countArgs.NoLabels
// Priority range
filter.PriorityMin = countArgs.PriorityMin
filter.PriorityMax = countArgs.PriorityMax
ctx := s.reqCtx(req)
issues, err := store.SearchIssues(ctx, countArgs.Query, filter)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to count issues: %v", err),
}
}
// If no grouping, just return the count
if countArgs.GroupBy == "" {
type CountResult struct {
Count int `json:"count"`
}
data, _ := json.Marshal(CountResult{Count: len(issues)})
return Response{
Success: true,
Data: data,
}
}
// Group by the specified field
type GroupCount struct {
Group string `json:"group"`
Count int `json:"count"`
}
counts := make(map[string]int)
// For label grouping, fetch all labels in one query to avoid N+1
var labelsMap map[string][]string
if countArgs.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 {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get labels: %v", err),
}
}
}
for _, issue := range issues {
var groupKey string
switch countArgs.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)"
}
default:
return Response{
Success: false,
Error: fmt.Sprintf("invalid group_by value: %s (must be one of: status, priority, type, assignee, label)", countArgs.GroupBy),
}
}
counts[groupKey]++
}
// Convert map to sorted slice
groups := make([]GroupCount, 0, len(counts))
for group, count := range counts {
groups = append(groups, GroupCount{Group: group, Count: count})
}
type GroupedCountResult struct {
Total int `json:"total"`
Groups []GroupCount `json:"groups"`
}
result := GroupedCountResult{
Total: len(issues),
Groups: groups,
}
data, _ := json.Marshal(result)
return Response{
Success: true,
Data: data,
}
}
func (s *Server) handleResolveID(req *Request) Response { func (s *Server) handleResolveID(req *Request) Response {
var args ResolveIDArgs var args ResolveIDArgs
if err := json.Unmarshal(req.Args, &args); err != nil { if err := json.Unmarshal(req.Args, &args); err != nil {

View File

@@ -178,6 +178,8 @@ func (s *Server) handleRequest(req *Request) Response {
resp = s.handleClose(req) resp = s.handleClose(req)
case OpList: case OpList:
resp = s.handleList(req) resp = s.handleList(req)
case OpCount:
resp = s.handleCount(req)
case OpShow: case OpShow:
resp = s.handleShow(req) resp = s.handleShow(req)
case OpResolveID: case OpResolveID: