Skip to content

Commit a910dee

Browse files
grokifyclaude
andcommitted
feat(cli): add stats subcommand for issue aggregation
Add stats command to aggregate issues by field: - Group by built-in fields: status, type, priority, assignee, resolution, project - Group by custom fields: customfield_XXXXX - Output formats: toon (default), json, table - Uses toon-go library for token-optimized output Example: gojira stats --jql "project = FOO" --by status --format table Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 910e502 commit a910dee

3 files changed

Lines changed: 340 additions & 22 deletions

File tree

cmd/gojira/stats.go

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"sort"
8+
"strings"
9+
10+
"github.com/grokify/gojira/rest"
11+
"github.com/spf13/cobra"
12+
toon "github.com/toon-format/toon-go"
13+
)
14+
15+
var statsCmd = &cobra.Command{
16+
Use: "stats [flags]",
17+
Short: "Show issue statistics grouped by field",
18+
Long: `Show aggregate statistics for issues grouped by a field.
19+
20+
Examples:
21+
# Count by status
22+
gojira stats --jql "project = FOO" --by status
23+
24+
# Count by type
25+
gojira stats --jql "project = FOO" --by type
26+
27+
# Count by priority
28+
gojira stats --jql "project = FOO" --by priority
29+
30+
# Count by custom field
31+
gojira stats --jql "project = FOO" --by customfield_12345
32+
33+
# Count by assignee
34+
gojira stats --jql "project = FOO" --by assignee
35+
36+
# Output formats (default: toon)
37+
gojira stats --jql "..." --by status --format toon # Token-optimized (default)
38+
gojira stats --jql "..." --by status --format json # JSON
39+
gojira stats --jql "..." --by status --format table # Human-readable`,
40+
RunE: runStats,
41+
}
42+
43+
var (
44+
statsJQL string
45+
statsBy string
46+
statsFormat string
47+
)
48+
49+
func init() {
50+
rootCmd.AddCommand(statsCmd)
51+
52+
statsCmd.Flags().StringVar(&statsJQL, "jql", "", "JQL query to search issues (required)")
53+
statsCmd.Flags().StringVar(&statsBy, "by", "", "Field to group by: status, type, priority, assignee, project, or customfield_XXXXX (required)")
54+
statsCmd.Flags().StringVar(&statsFormat, "format", "toon", "Output format: toon (default), json, table")
55+
56+
_ = statsCmd.MarkFlagRequired("jql")
57+
_ = statsCmd.MarkFlagRequired("by")
58+
}
59+
60+
// StatResult represents a single count result.
61+
type StatResult struct {
62+
Value string `json:"value" toon:"v"`
63+
Count uint `json:"count" toon:"n"`
64+
}
65+
66+
// StatsOutput represents the full stats output.
67+
type StatsOutput struct {
68+
Field string `json:"field" toon:"f"`
69+
Total uint `json:"total" toon:"t"`
70+
Results []StatResult `json:"results" toon:"r"`
71+
}
72+
73+
func runStats(cmd *cobra.Command, args []string) error {
74+
// Validate format
75+
format := strings.ToLower(statsFormat)
76+
if format != "toon" && format != "json" && format != "table" {
77+
return fmt.Errorf("invalid format %q: use toon, json, or table", statsFormat)
78+
}
79+
80+
// Get client
81+
client, err := NewClientFromOptions(getAuthOptions())
82+
if err != nil {
83+
return fmt.Errorf("authentication failed: %w", err)
84+
}
85+
86+
// Search issues
87+
if !flagQuiet {
88+
fmt.Fprintf(os.Stderr, "Searching issues...\n")
89+
}
90+
91+
issues, err := client.IssueAPI.SearchIssues(statsJQL, false)
92+
if err != nil {
93+
return fmt.Errorf("search failed: %w", err)
94+
}
95+
96+
if len(issues) == 0 {
97+
if !flagQuiet {
98+
fmt.Fprintln(os.Stderr, "No issues found")
99+
}
100+
return nil
101+
}
102+
103+
if !flagQuiet {
104+
fmt.Fprintf(os.Stderr, "Found %d issues\n", len(issues))
105+
}
106+
107+
// Compute counts
108+
counts, err := computeCounts(issues, statsBy)
109+
if err != nil {
110+
return err
111+
}
112+
113+
// Build output
114+
output := StatsOutput{
115+
Field: statsBy,
116+
Total: uint(len(issues)),
117+
}
118+
119+
// Sort by count descending
120+
type kv struct {
121+
Key string
122+
Value uint
123+
}
124+
var sorted []kv
125+
for k, v := range counts {
126+
sorted = append(sorted, kv{k, v})
127+
}
128+
sort.Slice(sorted, func(i, j int) bool {
129+
return sorted[i].Value > sorted[j].Value
130+
})
131+
132+
for _, item := range sorted {
133+
output.Results = append(output.Results, StatResult{
134+
Value: item.Key,
135+
Count: item.Value,
136+
})
137+
}
138+
139+
// Output
140+
return outputStats(output, format)
141+
}
142+
143+
func computeCounts(issues rest.Issues, field string) (map[string]uint, error) {
144+
field = strings.ToLower(field)
145+
146+
// Convert to IssuesSet for most operations
147+
issuesSet, err := issues.IssuesSet(nil)
148+
if err != nil {
149+
return nil, fmt.Errorf("failed to create issues set: %w", err)
150+
}
151+
152+
// Handle built-in fields
153+
switch field {
154+
case "status":
155+
return issuesSet.CountsByStatus(), nil
156+
case "type":
157+
return issuesSet.CountsByType(true, true), nil
158+
case "project":
159+
return issuesSet.CountsByProject(), nil
160+
case "priority":
161+
return countsByPriority(issues), nil
162+
case "assignee":
163+
return countsByAssignee(issues), nil
164+
case "resolution":
165+
return countsByResolution(issues), nil
166+
}
167+
168+
// Handle custom fields
169+
if strings.HasPrefix(field, "customfield_") {
170+
return issuesSet.CountsByCustomFieldValues(field)
171+
}
172+
173+
return nil, fmt.Errorf("unknown field %q: use status, type, priority, assignee, project, resolution, or customfield_XXXXX", field)
174+
}
175+
176+
func countsByPriority(issues rest.Issues) map[string]uint {
177+
counts := make(map[string]uint)
178+
for _, iss := range issues {
179+
priority := "(none)"
180+
if iss.Fields != nil && iss.Fields.Priority != nil {
181+
priority = iss.Fields.Priority.Name
182+
}
183+
counts[priority]++
184+
}
185+
return counts
186+
}
187+
188+
func countsByAssignee(issues rest.Issues) map[string]uint {
189+
counts := make(map[string]uint)
190+
for _, iss := range issues {
191+
im := rest.NewIssueMore(&iss)
192+
assignee := im.AssigneeName()
193+
if assignee == "" {
194+
assignee = "(unassigned)"
195+
}
196+
counts[assignee]++
197+
}
198+
return counts
199+
}
200+
201+
func countsByResolution(issues rest.Issues) map[string]uint {
202+
counts := make(map[string]uint)
203+
for _, iss := range issues {
204+
im := rest.NewIssueMore(&iss)
205+
resolution := im.Resolution()
206+
if resolution == "" {
207+
resolution = "(unresolved)"
208+
}
209+
counts[resolution]++
210+
}
211+
return counts
212+
}
213+
214+
func outputStats(output StatsOutput, format string) error {
215+
switch format {
216+
case "json":
217+
data, err := json.MarshalIndent(output, "", " ")
218+
if err != nil {
219+
return err
220+
}
221+
fmt.Println(string(data))
222+
case "table":
223+
return outputStatsTable(output)
224+
default: // toon
225+
data, err := toon.Marshal(output)
226+
if err != nil {
227+
return err
228+
}
229+
fmt.Println(string(data))
230+
}
231+
return nil
232+
}
233+
234+
func outputStatsTable(output StatsOutput) error {
235+
// Calculate max width for value column
236+
maxWidth := 5 // minimum "VALUE"
237+
for _, r := range output.Results {
238+
if len(r.Value) > maxWidth {
239+
maxWidth = len(r.Value)
240+
}
241+
}
242+
if maxWidth > 40 {
243+
maxWidth = 40
244+
}
245+
246+
// Print header
247+
fmt.Printf("%-*s %6s %6s\n", maxWidth, "VALUE", "COUNT", "%")
248+
fmt.Printf("%s %s %s\n", strings.Repeat("-", maxWidth), "------", "------")
249+
250+
// Print rows
251+
for _, r := range output.Results {
252+
value := r.Value
253+
if len(value) > maxWidth {
254+
value = value[:maxWidth-3] + "..."
255+
}
256+
var pct float64
257+
if output.Total > 0 {
258+
pct = float64(r.Count) / float64(output.Total) * 100
259+
}
260+
fmt.Printf("%-*s %6d %5.1f%%\n", maxWidth, value, r.Count, pct)
261+
}
262+
263+
// Print total
264+
fmt.Printf("%s %s %s\n", strings.Repeat("-", maxWidth), "------", "------")
265+
fmt.Printf("%-*s %6d %5.1f%%\n", maxWidth, "TOTAL", output.Total, 100.0)
266+
267+
return nil
268+
}

go.mod

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,17 @@ require (
1111
github.com/olekukonko/errors v1.2.0
1212
github.com/olekukonko/tablewriter v1.1.4
1313
github.com/spf13/cobra v1.10.2
14-
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
14+
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
1515
)
1616

1717
require (
18-
cloud.google.com/go/auth v0.18.2 // indirect
18+
cloud.google.com/go/auth v0.20.0 // indirect
1919
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
2020
cloud.google.com/go/compute/metadata v0.9.0 // indirect
2121
github.com/cespare/xxhash/v2 v2.3.0 // indirect
2222
github.com/clipperhouse/displaywidth v0.11.0 // indirect
2323
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
24-
github.com/fatih/color v1.18.0 // indirect
24+
github.com/fatih/color v1.19.0 // indirect
2525
github.com/fatih/structs v1.1.0 // indirect
2626
github.com/felixge/httpsnoop v1.0.4 // indirect
2727
github.com/go-logr/logr v1.4.3 // indirect
@@ -32,44 +32,45 @@ require (
3232
github.com/google/go-querystring v1.2.0 // indirect
3333
github.com/google/s2a-go v0.1.9 // indirect
3434
github.com/google/uuid v1.6.0 // indirect
35-
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
36-
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
35+
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
36+
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
3737
github.com/grokify/base36 v1.0.5 // indirect
3838
github.com/huandu/xstrings v1.5.0 // indirect
3939
github.com/inconshreveable/mousetrap v1.1.0 // indirect
4040
github.com/json-iterator/go v1.1.12 // indirect
4141
github.com/mattn/go-colorable v0.1.14 // indirect
42-
github.com/mattn/go-isatty v0.0.20 // indirect
43-
github.com/mattn/go-runewidth v0.0.21 // indirect
42+
github.com/mattn/go-isatty v0.0.21 // indirect
43+
github.com/mattn/go-runewidth v0.0.23 // indirect
4444
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
4545
github.com/modern-go/reflect2 v1.0.2 // indirect
4646
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
47-
github.com/olekukonko/ll v0.1.7 // indirect
47+
github.com/olekukonko/ll v0.1.8 // indirect
4848
github.com/pkg/errors v0.9.1 // indirect
4949
github.com/richardlehane/mscfb v1.0.6 // indirect
5050
github.com/richardlehane/msoleps v1.0.6 // indirect
51-
github.com/spf13/pflag v1.0.9 // indirect
51+
github.com/spf13/pflag v1.0.10 // indirect
5252
github.com/tiendc/go-deepcopy v1.7.2 // indirect
53+
github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c // indirect
5354
github.com/trivago/tgo v1.0.7 // indirect
5455
github.com/valyala/bytebufferpool v1.0.0 // indirect
5556
github.com/valyala/quicktemplate v1.8.0 // indirect
5657
github.com/xuri/efp v0.0.1 // indirect
5758
github.com/xuri/excelize/v2 v2.10.1 // indirect
5859
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
5960
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
60-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
61-
go.opentelemetry.io/otel v1.40.0 // indirect
62-
go.opentelemetry.io/otel/metric v1.40.0 // indirect
63-
go.opentelemetry.io/otel/trace v1.40.0 // indirect
64-
golang.org/x/crypto v0.49.0 // indirect
65-
golang.org/x/image v0.38.0 // indirect
66-
golang.org/x/net v0.52.0 // indirect
67-
golang.org/x/oauth2 v0.35.0 // indirect
68-
golang.org/x/sys v0.42.0 // indirect
69-
golang.org/x/text v0.35.0 // indirect
61+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
62+
go.opentelemetry.io/otel v1.43.0 // indirect
63+
go.opentelemetry.io/otel/metric v1.43.0 // indirect
64+
go.opentelemetry.io/otel/trace v1.43.0 // indirect
65+
golang.org/x/crypto v0.50.0 // indirect
66+
golang.org/x/image v0.39.0 // indirect
67+
golang.org/x/net v0.53.0 // indirect
68+
golang.org/x/oauth2 v0.36.0 // indirect
69+
golang.org/x/sys v0.43.0 // indirect
70+
golang.org/x/text v0.36.0 // indirect
7071
gonum.org/v1/gonum v0.17.0 // indirect
71-
google.golang.org/api v0.267.0 // indirect
72-
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
73-
google.golang.org/grpc v1.79.1 // indirect
72+
google.golang.org/api v0.275.0 // indirect
73+
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
74+
google.golang.org/grpc v1.80.0 // indirect
7475
google.golang.org/protobuf v1.36.11 // indirect
7576
)

0 commit comments

Comments
 (0)