diff --git a/internal/cli/insight.go b/internal/cli/insight.go index a2e02bd..1d6a5bc 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -1,7 +1,10 @@ package cli import ( + "bytes" + "encoding/csv" "fmt" + "io" "github.com/flashcatcloud/go-flashduty" "github.com/spf13/cobra" @@ -16,8 +19,12 @@ func newInsightCmd() *cobra.Command { // (richer flag set: severities, *_ids, fields, aggregate-unit, …; relative // time on --start-time/--end-time). Their human tables are preserved via the // DimensionInsightItem / ResponderInsightItem entries in display_columns.go. + // incident-export is curated (below) so it can verify the exported CSV + // against the incident-list total; the generated twin is dropped by + // genAddLeaf. cmd.AddCommand(newInsightTopAlertsCmd()) cmd.AddCommand(newInsightIncidentsCmd()) + cmd.AddCommand(newInsightIncidentExportCmd()) return cmd } @@ -143,3 +150,226 @@ func newInsightIncidentsCmd() *cobra.Command { return cmd } + +// newInsightIncidentExportCmd exports the filtered incident analytics list as +// CSV. It replaces the generated incident-export command (same flags) because +// the export endpoint answers a one-shot CSV with no pagination cursor and +// caps its row count server-side without saying so in the payload: a wide +// time window comes back silently truncated. Since the endpoint cannot be +// paged, the command verifies completeness instead — it counts the CSV data +// rows and compares against the incident-list total for the same filter (the +// authoritative count the analytics dashboard pages over). A short export is +// still written to stdout, but the command exits non-zero stating written vs +// total, so a truncated CSV can never pass for a complete one. +func newInsightIncidentExportCmd() *cobra.Command { + var dataJSON string + var fAsc bool + var fChannelIDs []int + var fDescriptionHTMLToText bool + var fEndTime int64 + var fExportFields []string + var fIncidentIDs []string + var fIncludeEverMuted bool + var fIsMyTeam bool + var fOrderby string + var fQuery string + var fResponderIDs []int + var fSecondsToAckFrom int64 + var fSecondsToAckTo int64 + var fSecondsToCloseFrom int64 + var fSecondsToCloseTo int64 + var fSeverities []string + var fStartTime int64 + var fTeamIDs []int + var fTimeZone string + cmd := &cobra.Command{ + Use: "incident-export", + Short: "Export insight incidents", + Long: `Export insight incidents. + +Export the filtered incident analytics list as a CSV file (to stdout; redirect with '> file.csv'). CSV headers and formatted values use the request locale, falling back to the member locale and then the account locale. --start-time/--end-time take Unix seconds. + +The export endpoint returns a one-shot CSV and caps its row count server-side, so a wide window may come back truncated. After writing, this command compares the CSV data-row count against the incident-list total for the same filter: on a shortfall the partial CSV is still written, a 'rows=N' line (the actual written data-row count) goes to stderr, and the command exits non-zero stating written vs total — narrow the window and retry. + +API: POST /insight/incident/export (insightIncidentExport) + +Request fields: + --asc bool + --channel-ids []int + --description-html-to-text bool + --end-time int + --export-fields []string + --incident-ids []string + --include-ever-muted bool + --is-my-team bool + --orderby string + --query string + --responder-ids []int + --seconds-to-ack-from int + --seconds-to-ack-to int + --seconds-to-close-from int + --seconds-to-close-to int + --severities []string + --start-time int + --team-ids []int + --time-zone string + fields (JSON, via --data) + labels (JSON, via --data)`, + Example: ` flashduty insight incident-export --start-time 1712000000 --end-time 1712604800 > incidents.csv`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCommand(cmd, args, func(ctx *RunContext) error { + body, err := genAssembleBody(dataJSON, func(body map[string]any) error { + if cmd.Flags().Changed("asc") { + body["asc"] = fAsc + } + if cmd.Flags().Changed("channel-ids") { + body["channel_ids"] = fChannelIDs + } + if cmd.Flags().Changed("description-html-to-text") { + body["description_html_to_text"] = fDescriptionHTMLToText + } + if cmd.Flags().Changed("end-time") { + body["end_time"] = fEndTime + } + if cmd.Flags().Changed("export-fields") { + body["export_fields"] = fExportFields + } + if cmd.Flags().Changed("incident-ids") { + body["incident_ids"] = fIncidentIDs + } + if cmd.Flags().Changed("include-ever-muted") { + body["include_ever_muted"] = fIncludeEverMuted + } + if cmd.Flags().Changed("is-my-team") { + body["is_my_team"] = fIsMyTeam + } + if cmd.Flags().Changed("orderby") { + body["orderby"] = fOrderby + } + if cmd.Flags().Changed("query") { + body["query"] = fQuery + } + if cmd.Flags().Changed("responder-ids") { + body["responder_ids"] = fResponderIDs + } + if cmd.Flags().Changed("seconds-to-ack-from") { + body["seconds_to_ack_from"] = fSecondsToAckFrom + } + if cmd.Flags().Changed("seconds-to-ack-to") { + body["seconds_to_ack_to"] = fSecondsToAckTo + } + if cmd.Flags().Changed("seconds-to-close-from") { + body["seconds_to_close_from"] = fSecondsToCloseFrom + } + if cmd.Flags().Changed("seconds-to-close-to") { + body["seconds_to_close_to"] = fSecondsToCloseTo + } + if cmd.Flags().Changed("severities") { + body["severities"] = fSeverities + } + if cmd.Flags().Changed("start-time") { + body["start_time"] = fStartTime + } + if cmd.Flags().Changed("team-ids") { + body["team_ids"] = fTeamIDs + } + if cmd.Flags().Changed("time-zone") { + body["time_zone"] = fTimeZone + } + return nil + }) + if err != nil { + return err + } + req := new(flashduty.InsightFilter) + if err := genBindBody(body, req); err != nil { + return err + } + resp, err := ctx.Client.Analytics.IncidentExport(cmdContext(ctx.Cmd), req) + if err != nil { + return err + } + if resp == nil || len(resp.Raw) == 0 { + ctx.WriteResult("OK: POST /insight/incident/export") + return nil + } + rows, err := countCSVDataRows(resp.Raw) + if err != nil { + return fmt.Errorf("insight incident-export: cannot parse the exported CSV: %w", err) + } + if err := ctx.WriteRaw(resp.Raw); err != nil { + return err + } + _, _ = fmt.Fprintf(ctx.Cmd.ErrOrStderr(), "rows=%d\n", rows) + total, err := insightIncidentTotal(ctx, body) + if err != nil { + return fmt.Errorf("insight incident-export: wrote %d rows but cannot verify completeness against /insight/incident/list: %w", rows, err) + } + if int64(rows) < total { + return fmt.Errorf("insight incident-export: incomplete export — wrote %d of %d incidents matching the filter; narrow the time window (--start-time/--end-time) and retry", rows, total) + } + return nil + }) + }, + } + cmd.Flags().BoolVar(&fAsc, "asc", false, "Request field ") + cmd.Flags().IntSliceVar(&fChannelIDs, "channel-ids", nil, "Request field ") + cmd.Flags().BoolVar(&fDescriptionHTMLToText, "description-html-to-text", false, "Request field ") + cmd.Flags().Int64Var(&fEndTime, "end-time", 0, "Request field ") + cmd.Flags().StringSliceVar(&fExportFields, "export-fields", nil, "Request field ") + cmd.Flags().StringSliceVar(&fIncidentIDs, "incident-ids", nil, "Request field ") + cmd.Flags().BoolVar(&fIncludeEverMuted, "include-ever-muted", false, "Request field ") + cmd.Flags().BoolVar(&fIsMyTeam, "is-my-team", false, "Request field ") + cmd.Flags().StringVar(&fOrderby, "orderby", "", "Request field ") + cmd.Flags().StringVar(&fQuery, "query", "", "Request field ") + cmd.Flags().IntSliceVar(&fResponderIDs, "responder-ids", nil, "Request field ") + cmd.Flags().Int64Var(&fSecondsToAckFrom, "seconds-to-ack-from", 0, "Request field ") + cmd.Flags().Int64Var(&fSecondsToAckTo, "seconds-to-ack-to", 0, "Request field ") + cmd.Flags().Int64Var(&fSecondsToCloseFrom, "seconds-to-close-from", 0, "Request field ") + cmd.Flags().Int64Var(&fSecondsToCloseTo, "seconds-to-close-to", 0, "Request field ") + cmd.Flags().StringSliceVar(&fSeverities, "severities", nil, "Request field ") + cmd.Flags().Int64Var(&fStartTime, "start-time", 0, "Request field ") + cmd.Flags().IntSliceVar(&fTeamIDs, "team-ids", nil, "Request field ") + cmd.Flags().StringVar(&fTimeZone, "time-zone", "", "Request field ") + cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.") + return cmd +} + +// countCSVDataRows counts the data records in an exported CSV body — every +// record except the header row. encoding/csv handles quoted fields with +// embedded newlines, which a naive line count would over-count. +func countCSVDataRows(raw []byte) (int, error) { + r := csv.NewReader(bytes.NewReader(raw)) + r.FieldsPerRecord = -1 + n := 0 + for { + if _, err := r.Read(); err == io.EOF { + break + } else if err != nil { + return 0, err + } + n++ + } + if n == 0 { + return 0, nil + } + return n - 1, nil +} + +// insightIncidentTotal returns the number of incidents matching the export +// filter, per the incident-list endpoint — the authoritative total an export +// is verified against. Only the total is needed, so a single 1-item page is +// fetched. +func insightIncidentTotal(ctx *RunContext, body map[string]any) (int64, error) { + req := new(flashduty.InsightIncidentListRequest) + if err := genBindBody(body, req); err != nil { + return 0, err + } + req.Page = 1 + req.Limit = 1 + out, _, err := ctx.Client.Analytics.IncidentList(cmdContext(ctx.Cmd), req) + if err != nil { + return 0, err + } + return out.Total, nil +} diff --git a/internal/cli/insight_export_test.go b/internal/cli/insight_export_test.go new file mode 100644 index 0000000..b565470 --- /dev/null +++ b/internal/cli/insight_export_test.go @@ -0,0 +1,182 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/flashcatcloud/go-flashduty" +) + +// newInsightExportStub starts a stub server answering /insight/incident/export +// with the given raw CSV body and /insight/incident/list with an envelope +// carrying the given total. It records each request's decoded JSON body so a +// test can assert the filter reached both endpoints. +func newInsightExportStub(t *testing.T, csvBody string, total int) *insightExportStub { + t.Helper() + s := &insightExportStub{} + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if raw, err := io.ReadAll(r.Body); err == nil && len(raw) > 0 { + _ = json.Unmarshal(raw, &body) + } + switch r.URL.Path { + case "/insight/incident/export": + s.exportBody = body + w.Header().Set("Content-Type", "text/csv") + _, _ = io.WriteString(w, csvBody) + case "/insight/incident/list": + s.listBody = body + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "request_id": "test-request-id", + "error": map[string]any{"code": "OK", "message": ""}, + "data": map[string]any{"total": total, "items": []any{}, "has_next_page": false}, + }) + default: + http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound) + } + })) + t.Cleanup(s.server.Close) + + newClientFn = func() (*flashduty.Client, error) { + return flashduty.NewClient("test-key", flashduty.WithBaseURL(s.server.URL)) + } + return s +} + +type insightExportStub struct { + server *httptest.Server + exportBody map[string]any + listBody map[string]any +} + +// TestInsightIncidentExportComplete verifies the happy path: when the CSV +// data-row count matches the incident-list total, the command exits 0 and +// reports the actual written row count on stderr. +func TestInsightIncidentExportComplete(t *testing.T) { + saveAndResetGlobals(t) + csvBody := "incident_id,title\ninc-1,a\ninc-2,b\ninc-3,c\n" + newInsightExportStub(t, csvBody, 3) + + stdout, stderr, err := execCommandSplit("insight", "incident-export", "--start-time", "1000", "--end-time", "2000") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if stdout != csvBody { + t.Errorf("stdout = %q, want the CSV verbatim %q", stdout, csvBody) + } + if !strings.Contains(stderr, "rows=3") { + t.Errorf("stderr missing rows=3, got %q", stderr) + } +} + +// TestInsightIncidentExportTruncatedFailsLoudly is the core guard: when the +// server truncates the export (CSV data rows < incident-list total), the +// partial CSV is still written but the command must exit non-zero stating +// written vs total — a silent partial export with exit 0 is the defect. +func TestInsightIncidentExportTruncatedFailsLoudly(t *testing.T) { + saveAndResetGlobals(t) + csvBody := "incident_id,title\ninc-1,a\ninc-2,b\n" + newInsightExportStub(t, csvBody, 5) + + stdout, stderr, err := execCommandSplit("insight", "incident-export", "--start-time", "1000", "--end-time", "2000") + if err == nil { + t.Fatal("expected a non-nil error on truncated export, got nil") + } + if !strings.Contains(err.Error(), "2 of 5") { + t.Errorf("error %q does not state written vs total (2 of 5)", err.Error()) + } + if stdout != csvBody { + t.Errorf("stdout = %q, want the partial CSV verbatim %q", stdout, csvBody) + } + if !strings.Contains(stderr, "rows=2") { + t.Errorf("stderr missing rows=2, got %q", stderr) + } +} + +// TestInsightIncidentExportCountsQuotedRows verifies row counting goes through +// encoding/csv: a quoted field with an embedded newline is one record, not +// two, so such exports are not misjudged as truncated. +func TestInsightIncidentExportCountsQuotedRows(t *testing.T) { + saveAndResetGlobals(t) + csvBody := "incident_id,description\ninc-1,\"line one\nline two\"\ninc-2,\"a\nb\nc\"\n" + newInsightExportStub(t, csvBody, 2) + + _, stderr, err := execCommandSplit("insight", "incident-export", "--start-time", "1000", "--end-time", "2000") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if !strings.Contains(stderr, "rows=2") { + t.Errorf("stderr missing rows=2, got %q", stderr) + } +} + +// TestInsightIncidentExportForwardsFilterToBothEndpoints verifies the export +// request and the verification request carry the same filter window, and the +// verification asks for a single 1-item page. +func TestInsightIncidentExportForwardsFilterToBothEndpoints(t *testing.T) { + saveAndResetGlobals(t) + stub := newInsightExportStub(t, "incident_id\ninc-1\n", 1) + + if _, _, err := execCommandSplit("insight", "incident-export", + "--start-time", "1000", "--end-time", "2000", "--severities", "Critical"); err != nil { + t.Fatalf("execCommand: %v", err) + } + for name, body := range map[string]map[string]any{"export": stub.exportBody, "list": stub.listBody} { + if body == nil { + t.Fatalf("%s endpoint was never called", name) + } + if got, _ := body["start_time"].(float64); got != 1000 { + t.Errorf("%s start_time = %#v, want 1000", name, body["start_time"]) + } + if got, _ := body["end_time"].(float64); got != 2000 { + t.Errorf("%s end_time = %#v, want 2000", name, body["end_time"]) + } + if got := fmt.Sprint(body["severities"]); got != "[Critical]" { + t.Errorf("%s severities = %q, want [Critical]", name, got) + } + } + if got, _ := stub.listBody["limit"].(float64); got != 1 { + t.Errorf("list limit = %#v, want 1", stub.listBody["limit"]) + } +} + +// TestCountCSVDataRows covers the counter directly, including the header-only +// and empty-body edges. +func TestCountCSVDataRows(t *testing.T) { + cases := []struct { + name string + raw string + want int + wantErr bool + }{ + {"empty body", "", 0, false}, + {"header only", "a,b\n", 0, false}, + {"header plus rows", "a,b\n1,2\n3,4\n", 2, false}, + {"quoted newline is one row", "a,b\n\"x\ny\",2\n", 1, false}, + {"no trailing newline", "a,b\n1,2", 1, false}, + {"unterminated quote errors", "a,b\n\"x,2\n", 0, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := countCSVDataRows([]byte(tc.raw)) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("countCSVDataRows = %d, want %d", got, tc.want) + } + }) + } +}