Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ Run `dispatch stats` to print session totals and breakdowns by repository, branc
dispatch stats
dispatch stats --json
dispatch stats --csv
dispatch stats --markdown
dispatch stats --calendar
dispatch stats --repo jongio/dispatch --since 2026-01-01
dispatch stats --top 5
Expand All @@ -258,6 +259,7 @@ dispatch stats --top 5
Flags:

- `--json` prints the summary as a single JSON object.
- `--markdown` prints the summary as Markdown tables for reports and issue comments.
- `--calendar` adds a GitHub-style activity heatmap of sessions per day, with an intensity legend. It honors the `--repo`, `--branch`, `--since`, and `--until` filters.
- `--repo`, `--branch`, `--folder`, `--since`, and `--until` narrow which sessions are counted.
- `--top <n>` caps each repository, branch, and host breakdown to the first N entries.
Expand Down
79 changes: 77 additions & 2 deletions cmd/dispatch/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type statsOptions struct {
filter data.FilterOptions
json bool
csv bool
markdown bool
calendar bool
top int
}
Expand Down Expand Up @@ -94,6 +95,10 @@ func runStats(w io.Writer, args []string) error {
if opts.json {
return writeStatsJSON(w, report)
}
if opts.markdown {
writeStatsMarkdown(w, report)
return nil
}
writeStatsText(w, report)
if opts.calendar {
writeActivityCalendar(w, *report.Calendar)
Expand Down Expand Up @@ -130,6 +135,8 @@ func parseStatsArgs(args []string) (statsOptions, error) {
opts.json = true
case name == "--csv":
opts.csv = true
case name == "--markdown":
opts.markdown = true
case name == "--calendar":
opts.calendar = true
case name == "--top":
Expand Down Expand Up @@ -193,8 +200,8 @@ func parseStatsArgs(args []string) (statsOptions, error) {
}
}

if opts.json && opts.csv {
return statsOptions{}, fmt.Errorf("--json and --csv cannot be combined")
if (opts.json && opts.csv) || (opts.json && opts.markdown) || (opts.csv && opts.markdown) {
return statsOptions{}, fmt.Errorf("--json, --csv, and --markdown cannot be combined")
}

return opts, nil
Expand Down Expand Up @@ -435,6 +442,74 @@ func writeStatsText(w io.Writer, report statsReport) {
writeCountSection(w, "By host type", report.ByHostType)
}

// writeStatsMarkdown prints the report as Markdown tables for pasting into
// issues, PRs, or reports.
func writeStatsMarkdown(w io.Writer, report statsReport) {
fmt.Fprintln(w, "# Dispatch stats")
fmt.Fprintln(w)
fmt.Fprintln(w, "| Metric | Value |")
fmt.Fprintln(w, "|---|---:|")
fmt.Fprintf(w, "| Sessions | %d |\n", report.TotalSessions)
fmt.Fprintf(w, "| Turns | %d |\n", report.TotalTurns)
fmt.Fprintf(w, "| Files | %d |\n", report.TotalFiles)
if report.Earliest != "" && report.Latest != "" {
fmt.Fprintf(w, "| Range | %s to %s |\n", markdownCell(report.Earliest), markdownCell(report.Latest))
}

if report.TotalSessions == 0 {
fmt.Fprintln(w)
fmt.Fprintln(w, "No sessions found.")
return
}

writeMarkdownCountSection(w, "By repository", report.ByRepository)
writeMarkdownCountSection(w, "By branch", report.ByBranch)
writeMarkdownCountSection(w, "By host type", report.ByHostType)
if report.Calendar != nil {
writeMarkdownCalendar(w, *report.Calendar)
}
}

func writeMarkdownCountSection(w io.Writer, title string, entries []countEntry) {
fmt.Fprintln(w)
fmt.Fprintf(w, "## %s\n\n", title)
if len(entries) == 0 {
fmt.Fprintln(w, "_No data._")
return
}
fmt.Fprintln(w, "| Label | Count |")
fmt.Fprintln(w, "|---|---:|")
for _, e := range entries {
fmt.Fprintf(w, "| %s | %d |\n", markdownCell(e.Label), e.Count)
}
}

func writeMarkdownCalendar(w io.Writer, cal activityCalendar) {
fmt.Fprintln(w)
fmt.Fprintln(w, "## Activity")
fmt.Fprintln(w)
if len(cal.Days) == 0 {
fmt.Fprintln(w, "_No data._")
return
}
fmt.Fprintln(w, "| Date | Sessions |")
fmt.Fprintln(w, "|---|---:|")
for _, d := range cal.Days {
if d.Count == 0 {
continue
}
fmt.Fprintf(w, "| %s | %d |\n", markdownCell(d.Date), d.Count)
}
}

func markdownCell(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ")
return s
}

// writeCountSection prints a titled breakdown with aligned counts.
func writeCountSection(w io.Writer, title string, entries []countEntry) {
fmt.Fprintln(w)
Expand Down
61 changes: 61 additions & 0 deletions cmd/dispatch/stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,16 @@ func TestParseStatsArgs_CSV(t *testing.T) {
}
}

func TestParseStatsArgs_Markdown(t *testing.T) {
opts, err := parseStatsArgs([]string{"stats", "--markdown"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !opts.markdown {
t.Error("expected markdown=true")
}
}

func TestParseStatsArgs_CSVAndJSONConflict(t *testing.T) {
_, err := parseStatsArgs([]string{"stats", "--csv", "--json"})
if err == nil {
Expand All @@ -299,6 +309,18 @@ func TestParseStatsArgs_CSVAndJSONConflict(t *testing.T) {
}
}

func TestParseStatsArgs_MarkdownConflict(t *testing.T) {
cases := [][]string{
{"stats", "--markdown", "--json"},
{"stats", "--markdown", "--csv"},
}
for _, args := range cases {
if _, err := parseStatsArgs(args); err == nil {
t.Errorf("parseStatsArgs(%v) expected conflict error", args)
}
}
}

func TestRunStatsCSV(t *testing.T) {
withStatsList(t, func(data.FilterOptions) ([]data.Session, error) {
return sampleSessions(), nil
Expand All @@ -308,6 +330,7 @@ func TestRunStatsCSV(t *testing.T) {
if err := runStats(&buf, []string{"stats", "--csv"}); err != nil {
t.Fatalf("runStats --csv: %v", err)
}

out := buf.String()
if !strings.Contains(out, "section,label,count") {
t.Errorf("CSV missing header, got:\n%s", out)
Expand Down Expand Up @@ -361,3 +384,41 @@ func TestCsvSafe(t *testing.T) {
}
}
}

func TestRunStatsMarkdown(t *testing.T) {
withStatsList(t, func(data.FilterOptions) ([]data.Session, error) {
return sampleSessions(), nil
})

var buf bytes.Buffer
if err := runStats(&buf, []string{"stats", "--markdown"}); err != nil {
t.Fatalf("runStats --markdown: %v", err)
}
out := buf.String()
for _, want := range []string{"# Dispatch stats", "| Metric | Value |", "| Sessions | 3 |", "## By repository", "| jongio/dispatch | 2 |"} {
if !strings.Contains(out, want) {
t.Errorf("Markdown output missing %q, got:\n%s", want, out)
}
}
}

func TestRunStatsMarkdownTop(t *testing.T) {
withStatsList(t, func(data.FilterOptions) ([]data.Session, error) {
return sampleSessions(), nil
})

var buf bytes.Buffer
if err := runStats(&buf, []string{"stats", "--markdown", "--top", "1"}); err != nil {
t.Fatalf("runStats --markdown --top: %v", err)
}
out := buf.String()
if strings.Contains(out, "| feature |") {
t.Errorf("top 1 should hide lower branch rows\n%s", out)
}
}

func TestMarkdownCell(t *testing.T) {
if got := markdownCell("repo|branch\\name\nnext"); got != "repo\\|branch\\\\name next" {
t.Errorf("markdownCell escaped value = %q", got)
}
}