Skip to content

Commit da34a25

Browse files
jongioCopilot
andauthored
Add stats command for session totals and breakdowns (#194)
Adds a non-interactive 'dispatch stats' subcommand that reads the session store and prints totals (sessions, turns, files), the date range covered, and counts grouped by repository, branch, and host type. A --json flag prints the same data as one JSON object, and repo, branch, folder, since, and until flags reuse the existing data filters. Closes #188 Co-authored-by: Copilot App <[email protected]>
1 parent 09ef7d9 commit da34a25

4 files changed

Lines changed: 570 additions & 3 deletions

File tree

cmd/dispatch/cli.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd
6969
showUpdateNotification(origStderr, updateCh)
7070
return true, cleanup, nil
7171

72+
case "stats":
73+
if sErr := runStats(os.Stdout, args); sErr != nil {
74+
fmt.Fprintf(os.Stderr, "stats: %v\n", sErr)
75+
return true, cleanup, sErr
76+
}
77+
return true, cleanup, nil
78+
7279
case "--demo":
7380
c, demoErr := setupDemo()
7481
if demoErr != nil {
@@ -138,7 +145,7 @@ func runCompletion(w io.Writer, shell string) error {
138145
const bashCompletionScript = `# bash completion for dispatch
139146
_dispatch_completion() {
140147
local cur="${COMP_WORDS[COMP_CWORD]}"
141-
local commands="help version update completion"
148+
local commands="help version update completion doctor stats"
142149
local flags="-h --help -v --version --demo --clear-cache --reindex"
143150
144151
if [[ "${COMP_CWORD}" -eq 1 ]]; then
@@ -157,7 +164,7 @@ complete -F _dispatch_completion dispatch disp
157164
const zshCompletionScript = `#compdef dispatch disp
158165
_dispatch_completion() {
159166
local -a commands shells flags
160-
commands=(help version update completion)
167+
commands=(help version update completion doctor stats)
161168
shells=(bash zsh powershell)
162169
flags=(-h --help -v --version --demo --clear-cache --reindex)
163170
@@ -175,7 +182,7 @@ _dispatch_completion "$@"
175182
`
176183

177184
const powershellCompletionScript = `# PowerShell completion for dispatch
178-
$script:DispatchCommands = @('help', 'version', 'update', 'completion')
185+
$script:DispatchCommands = @('help', 'version', 'update', 'completion', 'doctor', 'stats')
179186
$script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex')
180187
$script:DispatchShells = @('bash', 'zsh', 'powershell')
181188

cmd/dispatch/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,17 @@ Commands:
9292
version Print the version
9393
completion <shell> Print shell completion (bash, zsh, powershell)
9494
doctor Print environment diagnostics
95+
stats [flags] Print session totals and breakdowns
9596
update Update dispatch to the latest release
9697
98+
Stats flags:
99+
--json Print the summary as JSON
100+
--repo <name> Only count sessions for a repository
101+
--branch <name> Only count sessions on a branch
102+
--folder <path> Only count sessions under a folder
103+
--since <date> Only count sessions created on or after a date
104+
--until <date> Only count sessions created on or before a date
105+
97106
Flags:
98107
-h, --help Show this help message
99108
-v, --version Print the version

cmd/dispatch/stats.go

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"sort"
9+
"strings"
10+
"time"
11+
12+
"github.com/jongio/dispatch/internal/data"
13+
)
14+
15+
// statsListSessionsFn loads sessions for the stats command. It is a package
16+
// variable so tests can substitute a fixed set of sessions, matching the seam
17+
// pattern used elsewhere in this package (see cli.go).
18+
var statsListSessionsFn = defaultStatsListSessions
19+
20+
// statsQueryLimit is a high ceiling used so the summary covers every stored
21+
// session rather than the smaller default page size used by the TUI.
22+
const statsQueryLimit = 100_000
23+
24+
// statsOptions holds the parsed flags for the stats command.
25+
type statsOptions struct {
26+
filter data.FilterOptions
27+
json bool
28+
}
29+
30+
// countEntry is one label and count pair in a grouped breakdown.
31+
type countEntry struct {
32+
Label string `json:"label"`
33+
Count int `json:"count"`
34+
}
35+
36+
// statsReport is the aggregate summary produced by the stats command.
37+
type statsReport struct {
38+
TotalSessions int `json:"total_sessions"`
39+
TotalTurns int `json:"total_turns"`
40+
TotalFiles int `json:"total_files"`
41+
Earliest string `json:"earliest,omitempty"`
42+
Latest string `json:"latest,omitempty"`
43+
ByRepository []countEntry `json:"by_repository"`
44+
ByBranch []countEntry `json:"by_branch"`
45+
ByHostType []countEntry `json:"by_host_type"`
46+
}
47+
48+
// runStats prints aggregate counts for the stored sessions. args is the full
49+
// argument slice with args[0] == "stats".
50+
func runStats(w io.Writer, args []string) error {
51+
if w == nil {
52+
w = io.Discard
53+
}
54+
55+
opts, err := parseStatsArgs(args)
56+
if err != nil {
57+
return err
58+
}
59+
60+
sessions, err := statsListSessionsFn(opts.filter)
61+
if err != nil {
62+
return err
63+
}
64+
65+
report := buildStatsReport(sessions)
66+
if opts.json {
67+
return writeStatsJSON(w, report)
68+
}
69+
writeStatsText(w, report)
70+
return nil
71+
}
72+
73+
// parseStatsArgs reads the stats subcommand flags. args[0] is expected to be
74+
// "stats". It rejects positional arguments and unknown flags.
75+
func parseStatsArgs(args []string) (statsOptions, error) {
76+
var opts statsOptions
77+
78+
rest := args
79+
if len(rest) > 0 {
80+
rest = rest[1:] // drop the "stats" token
81+
}
82+
83+
takeValue := func(i int, name, inline string) (string, int, error) {
84+
if inline != "" {
85+
return inline, i, nil
86+
}
87+
if i+1 >= len(rest) {
88+
return "", i, fmt.Errorf("%s requires a value", name)
89+
}
90+
return rest[i+1], i + 1, nil
91+
}
92+
93+
for i := 0; i < len(rest); i++ {
94+
arg := rest[i]
95+
name, inline, hasInline := splitFlag(arg)
96+
97+
switch {
98+
case name == "--json":
99+
opts.json = true
100+
case name == "--repo" || name == "--repository":
101+
v, ni, err := takeValue(i, "--repo", inlineOrEmpty(inline, hasInline))
102+
if err != nil {
103+
return statsOptions{}, err
104+
}
105+
opts.filter.Repository = v
106+
i = ni
107+
case name == "--branch":
108+
v, ni, err := takeValue(i, "--branch", inlineOrEmpty(inline, hasInline))
109+
if err != nil {
110+
return statsOptions{}, err
111+
}
112+
opts.filter.Branch = v
113+
i = ni
114+
case name == "--folder":
115+
v, ni, err := takeValue(i, "--folder", inlineOrEmpty(inline, hasInline))
116+
if err != nil {
117+
return statsOptions{}, err
118+
}
119+
opts.filter.Folder = v
120+
i = ni
121+
case name == "--since":
122+
v, ni, err := takeValue(i, "--since", inlineOrEmpty(inline, hasInline))
123+
if err != nil {
124+
return statsOptions{}, err
125+
}
126+
t, ok := parseStatsTime(v)
127+
if !ok {
128+
return statsOptions{}, fmt.Errorf("invalid --since value %q (want YYYY-MM-DD or RFC3339)", v)
129+
}
130+
opts.filter.Since = &t
131+
i = ni
132+
case name == "--until":
133+
v, ni, err := takeValue(i, "--until", inlineOrEmpty(inline, hasInline))
134+
if err != nil {
135+
return statsOptions{}, err
136+
}
137+
t, ok := parseStatsTime(v)
138+
if !ok {
139+
return statsOptions{}, fmt.Errorf("invalid --until value %q (want YYYY-MM-DD or RFC3339)", v)
140+
}
141+
opts.filter.Until = &t
142+
i = ni
143+
case strings.HasPrefix(arg, "-"):
144+
return statsOptions{}, fmt.Errorf("unknown flag: %s", arg)
145+
default:
146+
return statsOptions{}, fmt.Errorf("stats does not take positional arguments, got %q", arg)
147+
}
148+
}
149+
150+
return opts, nil
151+
}
152+
153+
// splitFlag separates a flag token into its name and optional inline value,
154+
// e.g. "--repo=foo" becomes ("--repo", "foo", true).
155+
func splitFlag(arg string) (name, value string, hasValue bool) {
156+
if eq := strings.IndexByte(arg, '='); eq >= 0 {
157+
return arg[:eq], arg[eq+1:], true
158+
}
159+
return arg, "", false
160+
}
161+
162+
// inlineOrEmpty returns the inline value only when one was present, so that a
163+
// bare flag falls through to consuming the next argument.
164+
func inlineOrEmpty(value string, hasValue bool) string {
165+
if hasValue {
166+
return value
167+
}
168+
return ""
169+
}
170+
171+
// parseStatsTime parses a timestamp in RFC3339 or common date-only forms.
172+
func parseStatsTime(s string) (time.Time, bool) {
173+
layouts := []string{
174+
time.RFC3339,
175+
"2006-01-02T15:04:05.000Z",
176+
"2006-01-02T15:04:05",
177+
"2006-01-02",
178+
}
179+
for _, layout := range layouts {
180+
if t, err := time.Parse(layout, s); err == nil {
181+
return t, true
182+
}
183+
}
184+
return time.Time{}, false
185+
}
186+
187+
// buildStatsReport aggregates the given sessions into a summary.
188+
func buildStatsReport(sessions []data.Session) statsReport {
189+
report := statsReport{
190+
ByRepository: []countEntry{},
191+
ByBranch: []countEntry{},
192+
ByHostType: []countEntry{},
193+
}
194+
195+
repoCounts := map[string]int{}
196+
branchCounts := map[string]int{}
197+
hostCounts := map[string]int{}
198+
199+
var earliest, latest time.Time
200+
201+
for _, s := range sessions {
202+
report.TotalSessions++
203+
report.TotalTurns += s.TurnCount
204+
report.TotalFiles += s.FileCount
205+
206+
repoCounts[labelOr(s.Repository, "(none)")]++
207+
branchCounts[labelOr(s.Branch, "(none)")]++
208+
hostCounts[labelOr(s.HostType, "(unknown)")]++
209+
210+
if t, ok := parseStatsTime(s.CreatedAt); ok {
211+
if earliest.IsZero() || t.Before(earliest) {
212+
earliest = t
213+
}
214+
}
215+
if t, ok := latestTime(s); ok {
216+
if latest.IsZero() || t.After(latest) {
217+
latest = t
218+
}
219+
}
220+
}
221+
222+
if !earliest.IsZero() {
223+
report.Earliest = earliest.UTC().Format("2006-01-02")
224+
}
225+
if !latest.IsZero() {
226+
report.Latest = latest.UTC().Format("2006-01-02")
227+
}
228+
229+
report.ByRepository = sortedCounts(repoCounts)
230+
report.ByBranch = sortedCounts(branchCounts)
231+
report.ByHostType = sortedCounts(hostCounts)
232+
return report
233+
}
234+
235+
// latestTime returns the most recent timestamp for a session, preferring
236+
// LastActiveAt and falling back to UpdatedAt then CreatedAt.
237+
func latestTime(s data.Session) (time.Time, bool) {
238+
for _, ts := range []string{s.LastActiveAt, s.UpdatedAt, s.CreatedAt} {
239+
if t, ok := parseStatsTime(ts); ok {
240+
return t, true
241+
}
242+
}
243+
return time.Time{}, false
244+
}
245+
246+
// labelOr returns value, or fallback when value is empty.
247+
func labelOr(value, fallback string) string {
248+
if strings.TrimSpace(value) == "" {
249+
return fallback
250+
}
251+
return value
252+
}
253+
254+
// sortedCounts converts a label/count map into a slice ordered by count
255+
// descending, then label ascending for stable output.
256+
func sortedCounts(counts map[string]int) []countEntry {
257+
entries := make([]countEntry, 0, len(counts))
258+
for label, count := range counts {
259+
entries = append(entries, countEntry{Label: label, Count: count})
260+
}
261+
sort.Slice(entries, func(i, j int) bool {
262+
if entries[i].Count != entries[j].Count {
263+
return entries[i].Count > entries[j].Count
264+
}
265+
return entries[i].Label < entries[j].Label
266+
})
267+
return entries
268+
}
269+
270+
// writeStatsJSON prints the report as a single JSON object.
271+
func writeStatsJSON(w io.Writer, report statsReport) error {
272+
enc := json.NewEncoder(w)
273+
enc.SetIndent("", " ")
274+
return enc.Encode(report)
275+
}
276+
277+
// writeStatsText prints the report in a plain, human-readable layout.
278+
func writeStatsText(w io.Writer, report statsReport) {
279+
fmt.Fprintln(w, "Dispatch stats")
280+
fmt.Fprintln(w)
281+
fmt.Fprintf(w, "Sessions: %d\n", report.TotalSessions)
282+
fmt.Fprintf(w, "Turns: %d\n", report.TotalTurns)
283+
fmt.Fprintf(w, "Files: %d\n", report.TotalFiles)
284+
if report.Earliest != "" && report.Latest != "" {
285+
fmt.Fprintf(w, "Range: %s to %s\n", report.Earliest, report.Latest)
286+
}
287+
288+
if report.TotalSessions == 0 {
289+
fmt.Fprintln(w)
290+
fmt.Fprintln(w, "No sessions found.")
291+
return
292+
}
293+
294+
writeCountSection(w, "By repository", report.ByRepository)
295+
writeCountSection(w, "By branch", report.ByBranch)
296+
writeCountSection(w, "By host type", report.ByHostType)
297+
}
298+
299+
// writeCountSection prints a titled breakdown with aligned counts.
300+
func writeCountSection(w io.Writer, title string, entries []countEntry) {
301+
fmt.Fprintln(w)
302+
fmt.Fprintln(w, title)
303+
if len(entries) == 0 {
304+
fmt.Fprintln(w, " (no data)")
305+
return
306+
}
307+
width := 0
308+
for _, e := range entries {
309+
if len(e.Label) > width {
310+
width = len(e.Label)
311+
}
312+
}
313+
for _, e := range entries {
314+
fmt.Fprintf(w, " %-*s %d\n", width, e.Label, e.Count)
315+
}
316+
}
317+
318+
// defaultStatsListSessions loads every stored session matching the filter.
319+
func defaultStatsListSessions(filter data.FilterOptions) ([]data.Session, error) {
320+
store, err := data.Open()
321+
if err != nil {
322+
return nil, fmt.Errorf("opening session store: %w", err)
323+
}
324+
defer store.Close() //nolint:errcheck // read-only, best-effort close
325+
326+
sortOpts := data.SortOptions{Field: data.SortByCreated, Order: data.Ascending}
327+
sessions, err := store.ListSessions(context.Background(), filter, sortOpts, statsQueryLimit)
328+
if err != nil {
329+
return nil, fmt.Errorf("listing sessions: %w", err)
330+
}
331+
return sessions, nil
332+
}

0 commit comments

Comments
 (0)