From ec832efe20b9020b1f0ea35f2e445b7a758b6144 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:43:57 -0700 Subject: [PATCH] feat: add crash report json list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/report.go | 57 ++++++++++++++++++++++++++++++++++++++++----- cmd/report_test.go | 51 ++++++++++++++++++++++++++++++++++++++++ docs/report-json.md | 9 +++++++ 3 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 docs/report-json.md diff --git a/cmd/report.go b/cmd/report.go index 2a973a09..73114e3b 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "fmt" + "io" "net/url" + "time" "github.com/jongio/grut/internal/crashlog" "github.com/jongio/grut/internal/panels" @@ -15,8 +17,22 @@ const ( githubRepo = "jongio/grut" issueURL = "https://github.com/" + githubRepo + "/issues/new" maxURLLen = 4000 + + reportJSONFlag = "json" ) +type crashReportSummary struct { + ID string `json:"id"` + Timestamp string `json:"timestamp"` + Version string `json:"version"` + GoVersion string `json:"go_version"` + OS string `json:"os"` + Arch string `json:"arch"` + Terminal string `json:"terminal"` + PanicValue string `json:"panic_value"` + Context string `json:"context"` +} + // newReportCmd creates the report command for viewing crash reports // and filing GitHub issues. func newReportCmd() *cobra.Command { @@ -36,6 +52,7 @@ all stored reports, --show to inspect one, or --clear to remove them all.`, cmd.Flags().String("show", "", "Show a specific crash report by ID") cmd.Flags().Bool("clear", false, "Clear all crash reports") cmd.Flags().Bool("no-browser", false, "Print the GitHub issue URL instead of opening the browser") + cmd.Flags().Bool(reportJSONFlag, false, "Print list output as JSON") return cmd } @@ -47,8 +64,9 @@ func runReport(cmd *cobra.Command, _ []string) error { } list, _ := cmd.Flags().GetBool("list") + jsonOutput, _ := cmd.Flags().GetBool(reportJSONFlag) if list { - return runReportList() + return runReportList(cmd, jsonOutput) } showID, _ := cmd.Flags().GetString("show") @@ -56,6 +74,10 @@ func runReport(cmd *cobra.Command, _ []string) error { return runReportShow(showID) } + if jsonOutput { + return fmt.Errorf("--json can only be used with --list") + } + // Default / --latest: open issue for most recent crash. noBrowser, _ := cmd.Flags().GetBool("no-browser") return runReportLatest(noBrowser) @@ -70,18 +92,23 @@ func runReportClear() error { return nil } -func runReportList() error { +func runReportList(cmd *cobra.Command, jsonOutput bool) error { reports, err := crashlog.List() if err != nil { return fmt.Errorf("listing crash reports: %w", err) } + if jsonOutput { + return writeCrashReportListJSON(cmd.OutOrStdout(), reports) + } + + w := cmd.OutOrStdout() if len(reports) == 0 { - fmt.Println("No crash reports found.") + fmt.Fprintln(w, "No crash reports found.") return nil } - fmt.Printf("Crash Reports (%d found)\n\n", len(reports)) - fmt.Printf(" %-12s %-22s %-42s %s\n", "ID", "Timestamp", "Panic", "Context") + fmt.Fprintf(w, "Crash Reports (%d found)\n\n", len(reports)) + fmt.Fprintf(w, " %-12s %-22s %-42s %s\n", "ID", "Timestamp", "Panic", "Context") for _, r := range reports { id := r.ID if runes := []rune(id); len(runes) > 8 { @@ -89,11 +116,29 @@ func runReportList() error { } ts := r.Timestamp.Format("2006-01-02 15:04:05") pv := truncate(r.PanicValue, 50) - fmt.Printf(" %-12s %-22s %-42s %s\n", id, ts, pv, r.Context) + fmt.Fprintf(w, " %-12s %-22s %-42s %s\n", id, ts, pv, r.Context) } return nil } +func writeCrashReportListJSON(w io.Writer, reports []*crashlog.CrashReport) error { + summaries := make([]crashReportSummary, 0, len(reports)) + for _, r := range reports { + summaries = append(summaries, crashReportSummary{ + ID: r.ID, + Timestamp: r.Timestamp.Format(time.RFC3339), + Version: r.Version, + GoVersion: r.GoVersion, + OS: r.OS, + Arch: r.Arch, + Terminal: r.Terminal, + PanicValue: r.PanicValue, + Context: r.Context, + }) + } + return json.NewEncoder(w).Encode(summaries) +} + func runReportShow(id string) error { report, err := crashlog.Read(id) if err != nil { diff --git a/cmd/report_test.go b/cmd/report_test.go index f2d7d327..5e46b41b 100644 --- a/cmd/report_test.go +++ b/cmd/report_test.go @@ -1,12 +1,15 @@ package cmd import ( + "bytes" + "encoding/json" "strings" "testing" "time" "github.com/jongio/grut/internal/crashlog" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // --------------------------------------------------------------------------- @@ -146,6 +149,7 @@ func TestNewReportCmd_FlagsRegistered(t *testing.T) { {"show", "string"}, {"clear", "bool"}, {"no-browser", "bool"}, + {"json", "bool"}, } for _, tt := range tests { @@ -192,6 +196,53 @@ func TestRunReport_ShowFlag_NonexistentID(t *testing.T) { assert.Error(t, err) } +func TestRunReport_JSONWithoutListReturnsError(t *testing.T) { + cmd := newReportCmd() + cmd.SetArgs([]string{"--json"}) + + err := cmd.Execute() + + assert.Error(t, err) + assert.Contains(t, err.Error(), "--json can only be used with --list") +} + +func TestWriteCrashReportListJSON_Empty(t *testing.T) { + var out bytes.Buffer + + err := writeCrashReportListJSON(&out, nil) + + assert.NoError(t, err) + assert.JSONEq(t, `[]`, out.String()) +} + +func TestWriteCrashReportListJSON_SummaryFields(t *testing.T) { + when := time.Date(2026, 7, 11, 18, 30, 0, 0, time.UTC) + reports := []*crashlog.CrashReport{{ + ID: "crash-1", + Timestamp: when, + Version: "1.2.3", + GoVersion: "go1.26.5", + OS: "windows", + Arch: "amd64", + Terminal: "Windows Terminal", + PanicValue: "nil pointer", + Context: "preview render", + StackTrace: "full stack stays out of the summary", + }} + var out bytes.Buffer + + err := writeCrashReportListJSON(&out, reports) + + require.NoError(t, err) + var got []crashReportSummary + require.NoError(t, json.Unmarshal(out.Bytes(), &got)) + require.Len(t, got, 1) + assert.Equal(t, "crash-1", got[0].ID) + assert.Equal(t, "2026-07-11T18:30:00Z", got[0].Timestamp) + assert.Equal(t, "nil pointer", got[0].PanicValue) + assert.Equal(t, "preview render", got[0].Context) +} + func TestRunReport_LatestDefault_NoReports(t *testing.T) { // Default behavior (no flags) when no crash reports exist should // print "No crash reports found." and succeed. diff --git a/docs/report-json.md b/docs/report-json.md new file mode 100644 index 00000000..13fbd1b9 --- /dev/null +++ b/docs/report-json.md @@ -0,0 +1,9 @@ +# Crash report JSON + +Use JSON output when scripts need to inspect stored crash reports: + +```bash +grut report --list --json +``` + +The command prints an array of summaries. Use `grut report --show ` to print one full report.