diff --git a/README.md b/README.md index bf2de97..635d45b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ` caps each repository, branch, and host breakdown to the first N entries. diff --git a/cmd/dispatch/stats.go b/cmd/dispatch/stats.go index f8eba51..c915bd5 100644 --- a/cmd/dispatch/stats.go +++ b/cmd/dispatch/stats.go @@ -28,6 +28,7 @@ type statsOptions struct { filter data.FilterOptions json bool csv bool + markdown bool calendar bool top int } @@ -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) @@ -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": @@ -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 @@ -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) diff --git a/cmd/dispatch/stats_test.go b/cmd/dispatch/stats_test.go index 5784f72..2dfeb6a 100644 --- a/cmd/dispatch/stats_test.go +++ b/cmd/dispatch/stats_test.go @@ -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 { @@ -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 @@ -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) @@ -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) + } +}