From f79249bf3d6bc6a0c4c9e72fac761ee810df6e35 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Fri, 4 Sep 2026 03:46:59 -0700 Subject: [PATCH 1/2] docs(skill): route usage/quota and per-person notification questions in the domain index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the SKILL.md Domain index so question types it previously did not cover resolve to a card (or an explicit no-CLI answer) instead of a --help sweep: - usage/quota/billing 额度/用量/计费: route per product — RUM session usage/quota to reference/rum.md resource-info (free_cnt/used_cnt, session_limit_reached, billing window); AI-SRE token usage to safari session-list (token_usage is the account-billing source of truth); anything else (e.g. 语音通知额度) has no CLI verb — point to the console 费用中心 instead of sweeping --help. - per-person notification counts 通知次数: route to insight responder (total_notifications); note that per-person delivery outcome detail is incident timeline i_notify entries (person_id + failed_reason only — there is no answer/接通 field). - add aggregated-metrics (insight) vs raw-feed (incident timeline) disambiguation terms (通知量 / 通知明细) to the existing insight and incident rows, plus a matching line in insight.md's Route-here block. --- skills/flashduty/SKILL.md | 6 ++++-- skills/flashduty/reference/insight.md | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/skills/flashduty/SKILL.md b/skills/flashduty/SKILL.md index a34b513..766b704 100644 --- a/skills/flashduty/SKILL.md +++ b/skills/flashduty/SKILL.md @@ -65,7 +65,7 @@ Some asks span several commands. For those the skill ships a script that fetches | intent / 意图 (terms route in either language) | card | |---|---| -| incident / fault / 故障 / 事件 / triage 分诊 / acknowledge 认领 / merge 合并 / escalate 升级 / **summarize or analyze an incident 故障汇总分析** | **`reference/incident.md`** | +| incident / fault / 故障 / 事件 / triage 分诊 / acknowledge 认领 / merge 合并 / escalate 升级 / **summarize or analyze an incident 故障汇总分析** / raw notification feed 通知明细 (per-event, via `timeline` `i_notify` entries — not aggregates) | **`reference/incident.md`** | | post-mortem / postmortem 复盘 / 复盘报告 / 复盘模板 / post-incident review / RCA report | **`reference/postmortem.md`** | | alert / 告警 / dedup 去重 / alert fields 告警字段 / alert pipeline 告警管道 | **`reference/alert.md`** | | change / 变更 / deployment 部署 / release 发布 / correlated change 变更关联 / what changed | **`reference/change.md`** | @@ -81,7 +81,8 @@ Some asks span several commands. For those the skill ships a script that fetches | dispatch rule 分派策略 / 分派规则 / escalation rule 升级规则 / notify layers 通知层级 / who gets paged | **`reference/escalation.md`** | | silence 静默 / 屏蔽 / inhibit 抑制 / drop rule 丢弃 / noise reduction 降噪 / maintenance silence 维护窗口静默 | **`reference/noise.md`** | | enrichment / 数据加工 / 富化 / label mapping 字段映射 / extraction 提取 / mapping schema 集成 schema | **`reference/enrichment.md`** | -| insight / 洞察 / stats 统计 / trend 趋势 / MTTA / MTTR / top alerts Top 告警 / incident export 故障导出 | **`reference/insight.md`** | +| insight / 洞察 / stats 统计 / trend 趋势 / MTTA / MTTR / top alerts Top 告警 / incident export 故障导出 / notification volume 通知量 (aggregated metrics — not the raw per-event feed) | **`reference/insight.md`** | +| per-person notification counts 通知次数 (how many notifications a person received) | **`reference/insight.md`** (`insight responder` → `total_notifications`); per-person delivery outcome detail → `incident timeline` `i_notify` entries (`person_id` + `failed_reason` only — there is no answer/接通 field) | | schedule / on-call / 值班 / 排班 / rotation 轮值 / who is on call 谁在值班 / shift 班次 / next responder 下一班 | **`reference/schedule.md`** | | calendar / 日历 / on-call calendar 值班日历 / calendar event 日历事件 / holiday 休假 | **`reference/calendar.md`** | | template / 通知模板 / message template 消息模板 / card template 卡片模板 | **`reference/template.md`** | @@ -94,5 +95,6 @@ Some asks span several commands. For those the skill ships a script that fetches | sourcemap / source map / source mapping / symbolication / deobfuscate / stack enrich / dSYM / miniprogram source map | **`reference/sourcemap.md`** | | status page / 状态页 / public incident 公开事件 / public timeline 公开时间线 / maintenance window 维护窗口 / subscriber 订阅者 | **`reference/status-page.md`** | | AI-SRE platform / customize / 安装配置 MCP server (connector) 连接器 / install mcp / skill upload 上传技能 / A2A agent / session export 会话导出 | **`reference/safari.md`** | +| usage / quota / billing 额度 / 用量 / 计费 — route per product | RUM session usage/quota → **`reference/rum.md`** §resource-info (`free_cnt`/`used_cnt`, `session_limit_reached`, billing window); AI-SRE token usage → **`reference/safari.md`** `session-list` (items carry `token_usage`, the account-billing source of truth); anything else (e.g. 语音通知额度) → **no CLI verb** — point to the console 费用中心, do NOT sweep `--help` | Shared reference: `reference/filters.md` — read it before composing any `filters` / `source_filters` / `target_filters` value (silence / inhibit / drop / escalation rules); it carries the condition shape, operators, and the valid key set per rule family. diff --git a/skills/flashduty/reference/insight.md b/skills/flashduty/reference/insight.md index 71c7b9c..68c6f81 100644 --- a/skills/flashduty/reference/insight.md +++ b/skills/flashduty/reference/insight.md @@ -6,6 +6,8 @@ Prereq: `SKILL.md` read. All `insight` verbs are **read-only** — no mutations, "噪声治理 / 高频告警 / MTTA / MTTR / 绩效复盘 / 月报 / SRE review / noise reduction / alert fatigue / who responds fastest / channel performance / team metrics / incident export / CSV export" → **insight**. +"per-person notification counts / 通知次数 / 通知量 (how many notifications did a person receive)" → **insight responder** (`total_notifications`); per-person delivery outcome detail lives in `incident timeline` `i_notify` entries (`person_id` + `failed_reason` only — no answer/接通 field), not here. + Do **not** hand-aggregate from `alert list` / `incident list` — `insight` does server-side aggregation and gives authoritative numbers. Key IDs you may need: `--team-ids` and `--channel-ids` from `fduty channel list` or `fduty team list`; `--responder-ids` from `fduty member list`. ## Intent → verb From 3436149b57510bc8a645a9d4f919317731d8ca91 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Fri, 4 Sep 2026 04:05:31 -0700 Subject: [PATCH 2/2] Bound oversized structured list output from generated list verbs and insight incidents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated list verbs printed their full response as unbounded dense JSON/TOON; a large page could dump tens of kilobytes in one shot. Route their structured output through the existing bounding machinery (boundProjectedList, 16 KiB limit, stderr note) already used by the curated incident/alert/alert-event projections: - printGenericResult measures the encoded payload; anything under the limit takes the pre-existing printer path, byte-identical. Over-cap list payloads — a top-level object array, or an items/docs/list envelope whose siblings are scalar pagination metadata (the shape cligen's listEnvelope recognizes) — are reduced to the leading rows that fit, with envelope keys kept and the reduction announced on stderr. A single oversized row is shortened with "..." markers instead; identifier fields are never shortened. - The typed response reaches the bounding machinery through a JSON round trip that decodes numbers as json.Number before narrowing to int64/float64, so integer IDs above 2^53 (channel_id, team_id, ...) keep full precision in bounded output. Rows inside an envelope are re-fit against the limit minus the observed envelope overhead, so the whole printed payload lands under it. - Detail-shaped single objects are never reduced or rejected. Curated insight incidents gains the incident-list treatment in json/toon mode: a compact default projection (incident_id,title,severity,channel_name,seconds_to_ack,seconds_to_close,notifications) with a stderr note, a --fields flag to choose a different projection, and the same bounding. Table mode is unchanged. Tests: stub-driven end-to-end coverage for the bounded envelope and top-level-array paths, >2^53 ID precision, oversized-detail passthrough, and the single-row shorten path; insight incidents projection, --fields override, bounding, and unchanged table output. --- internal/cli/gen_support.go | 171 +++++++++++++++++++- internal/cli/gen_support_test.go | 216 +++++++++++++++++++++++++- internal/cli/insight.go | 33 +++- internal/cli/insight_export_test.go | 144 +++++++++++++++++ skills/flashduty/reference/insight.md | 1 + 5 files changed, 558 insertions(+), 7 deletions(-) diff --git a/internal/cli/gen_support.go b/internal/cli/gen_support.go index 4aebadc..37fb4f5 100644 --- a/internal/cli/gen_support.go +++ b/internal/cli/gen_support.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "encoding/json" "fmt" "io" @@ -11,6 +12,7 @@ import ( "github.com/spf13/cobra" + "github.com/flashcatcloud/flashduty-cli/internal/output" "github.com/flashcatcloud/flashduty-cli/internal/timeutil" ) @@ -320,16 +322,177 @@ func bindURLTagged(body map[string]any, rv reflect.Value) { // printGenericResult renders a generated command's typed response. In // machine-readable mode (TOON/JSON) it marshals the whole value — which is what -// the agent reads. In human (table) mode it derives an aligned table by -// reflection (renderGenericTable), since generated commands carry no hand-written -// column set; anything that isn't a list or object falls back to indented JSON. +// the agent reads. A list-shaped response (a top-level array of objects, or an +// items/docs/list page envelope whose other fields are scalar pagination +// metadata) that overflows compactListOutputLimit is first bounded to the +// leading rows that fit via boundProjectedList, with the reduction announced on +// stderr; a payload that fits, and any detail-shaped single object, prints +// untouched. In human (table) mode it derives an aligned table by reflection +// (renderGenericTable), since generated commands carry no hand-written column +// set; anything that isn't a list or object falls back to indented JSON. func printGenericResult(ctx *RunContext, data any) error { if ctx.Structured() { - return ctx.Printer.Print(data, nil) + return printBoundedGenericResult(ctx, data) } return renderGenericTable(ctx, data) } +// printBoundedGenericResult is printGenericResult's structured-mode half. The +// under-cap fast path is the pre-bound behavior verbatim — same printer call, +// byte-identical output. Only an over-cap list payload detours through the +// bounding machinery, and only there does the output change (fewer rows; a +// rebuilt envelope, so key order is no longer the struct's field order). +func printBoundedGenericResult(ctx *RunContext, data any) error { + encoded, err := marshalStructured(data) + if err != nil || len(encoded)+1 < compactListOutputLimit { + // Fits the budget (or cannot be measured, in which case the printer + // surfaces the same marshal error): emit untouched. + return ctx.Printer.Print(data, nil) + } + + generic, err := genericStructured(data) + if err != nil { + return ctx.Printer.Print(data, nil) + } + + switch value := generic.(type) { + case []any: + rows, ok := objectRows(value) + if !ok { + return ctx.Printer.Print(data, nil) + } + bounded, note, err := boundProjectedList(rows, compactListOutputLimit) + if err != nil { + return err + } + noteProjectionBound(ctx.Cmd.ErrOrStderr(), note) + return ctx.Printer.Print(bounded, nil) + case map[string]any: + key, ok := listEnvelopeKey(value) + if !ok { + // Detail-shaped single object: never bounded, never errored — a + // shortened id or status would pass for a real value. + return ctx.Printer.Print(data, nil) + } + rows, ok := objectRows(value[key].([]any)) + if !ok { + return ctx.Printer.Print(data, nil) + } + // boundProjectedList sizes the rows standalone, but printed inside the + // envelope they share the budget with the pagination siblings (and, in + // indented JSON, sit one indent level deeper). Fit against the full + // limit, then re-fit with the observed envelope overhead subtracted + // until the whole payload is under it. + budget := compactListOutputLimit + for { + bounded, note, err := boundProjectedList(rows, budget) + if err != nil { + return err + } + value[key] = bounded + out, err := marshalStructured(value) + if err != nil { + return err + } + if len(out)+1 < compactListOutputLimit { + noteProjectionBound(ctx.Cmd.ErrOrStderr(), note) + return ctx.Printer.Print(value, nil) + } + budget -= len(out) + 2 - compactListOutputLimit + } + default: + return ctx.Printer.Print(data, nil) + } +} + +// genericStructured decodes data through its JSON encoding into plain +// maps/slices/scalars, so the list-bounding machinery can walk rows of any SDK +// response type. Numbers decode as json.Number first (UseNumber) and are then +// narrowed by narrowNumbers: decoding straight to float64 would round integer +// IDs above 2^53 (channel_id, team_id, …) in the bounded output. Unset SDK +// timestamps go in as null (NullUnsetInstants), matching what the printer +// would have emitted for the unbounded payload. +func genericStructured(data any) (any, error) { + raw, err := json.Marshal(output.NullUnsetInstants(data)) + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var generic any + if err := dec.Decode(&generic); err != nil { + return nil, err + } + return narrowNumbers(generic), nil +} + +// narrowNumbers rewrites every json.Number in a decoded generic value to its +// int64 form when the literal is an integer (exact for IDs beyond 2^53), else +// float64 — both encoders (JSON and TOON) render those natively. +func narrowNumbers(value any) any { + switch v := value.(type) { + case json.Number: + if i, err := v.Int64(); err == nil { + return i + } + if f, err := v.Float64(); err == nil { + return f + } + return v.String() + case map[string]any: + for key, item := range v { + v[key] = narrowNumbers(item) + } + return v + case []any: + for i, item := range v { + v[i] = narrowNumbers(item) + } + return v + default: + return value + } +} + +// listEnvelopeKey reports whether value is a paginated list envelope — exactly +// one array field named items/docs/list with only scalar siblings (total, +// has_next_page, search_after_ctx, …) — and returns the row array's key. It +// mirrors cligen's listEnvelope (internal/cmd/cligen), which classifies the +// same shape when generating these commands. +func listEnvelopeKey(value map[string]any) (string, bool) { + key := "" + for name, field := range value { + _, isArray := field.([]any) + if isArray && (name == "items" || name == "docs" || name == "list") { + if key != "" { + return "", false // two candidate row arrays: not a flat list envelope + } + key = name + continue + } + switch field.(type) { + case map[string]any, []any: + return "", false // non-scalar sibling: a richer response, not a flat list + } + } + return key, key != "" +} + +// objectRows converts a decoded JSON array to rows for boundProjectedList. ok +// is false when any element is not an object: an array of scalars has no row +// fields to bound and prints unbounded instead. +func objectRows(items []any) ([]map[string]any, bool) { + rows := make([]map[string]any, len(items)) + for i, item := range items { + row, ok := item.(map[string]any) + if !ok { + return nil, false + } + rows[i] = row + } + return rows, true +} + // genParseTimeFlag parses a relative-or-absolute time flag into unix seconds, // mirroring the curated incident-list --since/--until handling: a Go duration // ("7d", "24h") is "now minus duration", "+7d" is the future, "now" is now, and diff --git a/internal/cli/gen_support_test.go b/internal/cli/gen_support_test.go index 965eb1c..6ba00bf 100644 --- a/internal/cli/gen_support_test.go +++ b/internal/cli/gen_support_test.go @@ -1,6 +1,15 @@ package cli -import "testing" +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + "unicode/utf8" + + "github.com/flashcatcloud/flashduty-cli/internal/output" +) func TestGenBindBodyAllowsNullForRequiredNullableField(t *testing.T) { req := new(struct { @@ -14,3 +23,208 @@ func TestGenBindBodyAllowsNullForRequiredNullableField(t *testing.T) { t.Fatalf("Value = %v, want nil", req.Value) } } + +// oversizedInsightRows returns n insight-incident rows whose fat description +// fields push any page of them well past compactListOutputLimit, plus the +// incident IDs in row order so a test can tell emitted rows from dropped ones. +func oversizedInsightRows(n int) ([]any, []string) { + rows := make([]any, n) + ids := make([]string, n) + for i := range rows { + ids[i] = fmt.Sprintf("inc-%024d", i) + rows[i] = map[string]any{ + "incident_id": ids[i], + "title": fmt.Sprintf("Database failover on db-%d", i), + "severity": "Critical", + "channel_id": 12345, + "channel_name": "db-alerts", + "description": strings.Repeat(fmt.Sprintf("row %d root-cause detail ", i), 40), + "seconds_to_ack": 42, + "seconds_to_close": 3600, + "notifications": 3, + } + } + return rows, ids +} + +// TestPrintGenericResultBoundsListEnvelope drives a generated list verb whose +// response is an items[] page envelope (insight incident-list): an oversized +// page must come back under the structured-output limit with the reduction +// announced on stderr and the envelope keys intact, in both structured +// formats. +func TestPrintGenericResultBoundsListEnvelope(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + rows, ids := oversizedInsightRows(40) + stub.data = map[string]any{ + "items": rows, + "total": 40, + "has_next_page": true, + "search_after_ctx": "cursor-1", + } + + out, stderrText, err := execCommandSplit("insight", "incident-list", + "--start-time", "7d", "--end-time", "now", "--output-format", format) + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Errorf("bounded %s envelope is %d bytes, want <%d", format, len([]byte(out)), compactListOutputLimit) + } + if !strings.Contains(stderrText, "note: emitted") { + t.Errorf("reduced %s page should announce itself on stderr, got:\n%s", format, stderrText) + } + // The first row survives intact; the last was dropped by the + // prefix reduction. + if !strings.Contains(out, ids[0]) { + t.Errorf("bounded %s output lost leading row %q:\n%s", format, ids[0], out) + } + if strings.Contains(out, ids[len(ids)-1]) { + t.Errorf("bounded %s output still contains trailing row %q", format, ids[len(ids)-1]) + } + // The pagination envelope rides along with the bounded rows. + for _, key := range []string{"total", "has_next_page", "search_after_ctx"} { + if !strings.Contains(out, key) { + t.Errorf("bounded %s output lost envelope key %q:\n%s", format, key, out) + } + } + if format == "json" { + var envelope map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &envelope); err != nil { + t.Fatalf("bounded json is not an object: %v", err) + } + if _, ok := envelope["items"].([]any); !ok { + t.Fatalf("bounded json lost the items array: %v", envelope) + } + } + }) + } +} + +// TestPrintGenericResultBoundsTopLevelArray drives a generated verb whose +// response is a bare top-level array (monit rule-list-basic): an oversized +// page must be bounded the same way as an items[] envelope. +func TestPrintGenericResultBoundsTopLevelArray(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + rows := make([]any, 40) + for i := range rows { + rows[i] = map[string]any{ + "id": i + 1, + "name": strings.Repeat(fmt.Sprintf("rule %d ", i), 40), + "folder_id": 100, + "ds_type": "prometheus", + "cron_pattern": "0 * * * * *", + "enabled": true, + } + } + stub.data = rows + + out, stderrText, err := execCommandSplit("monit", "rule-list-basic", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Errorf("bounded top-level array is %d bytes, want <%d", len([]byte(out)), compactListOutputLimit) + } + if !strings.Contains(stderrText, "note: emitted") { + t.Errorf("reduced page should announce itself on stderr, got:\n%s", stderrText) + } + var decoded []map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &decoded); err != nil { + t.Fatalf("bounded top-level array is not a JSON array: %v\n%s", err, out) + } + if len(decoded) == 0 || len(decoded) >= len(rows) { + t.Errorf("bounded array has %d rows, want a reduced page in [1, %d)", len(decoded), len(rows)) + } +} + +// TestPrintGenericResultKeepsLargeIDsExact is the precision guard for the +// typed-slice round trip: an integer ID above 2^53 (channel_id) must reach +// the bounded output with every digit intact, where a float64 decode would +// have rounded it. +func TestPrintGenericResultKeepsLargeIDsExact(t *testing.T) { + const bigChannelID = "9007199254740993" // 2^53 + 1 + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + rows, _ := oversizedInsightRows(40) + for _, row := range rows { + row.(map[string]any)["channel_id"] = json.Number(bigChannelID) + } + stub.data = map[string]any{"items": rows, "total": 40} + + out, _, err := execCommandSplit("insight", "incident-list", + "--start-time", "7d", "--end-time", "now", "--output-format", format) + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if !strings.Contains(out, bigChannelID) { + t.Errorf("bounded %s output lost digits of channel_id %s", format, bigChannelID) + } + if strings.Contains(out, "9007199254740992") { + t.Errorf("bounded %s output rounded channel_id to the nearest float64", format) + } + }) + } +} + +// TestPrintGenericResultDetailNeverBounded: a detail-shaped single object is +// excluded from list bounding no matter its size — never reduced, never +// errored — and prints byte-identical to the direct printer. +func TestPrintGenericResultDetailNeverBounded(t *testing.T) { + detail := &heuristicRow{Name: strings.Repeat("x", 40*1024), Count: 7} + + for _, f := range []output.Format{output.FormatJSON, output.FormatTOON} { + var got, want bytes.Buffer + if err := printGenericResult(structuredCtx(&got, f), detail); err != nil { + t.Fatalf("%v oversized detail errored: %v", f, err) + } + if err := output.NewPrinter(f, false, &want).Print(detail, nil); err != nil { + t.Fatalf("%v reference: %v", f, err) + } + if got.String() != want.String() { + t.Errorf("oversized detail output changed for %v\n got:\n%s\nwant:\n%s", f, got.String(), want.String()) + } + if len(got.Bytes()) < compactListOutputLimit { + t.Errorf("detail payload should pass through unbounded, got %d bytes", len(got.Bytes())) + } + } +} + +// TestPrintGenericResultShortenedRowStaysUTF8 covers the single-row-overflow +// path through a generated verb: one row too big on its own is shortened with +// a "..." marker, valid UTF-8, and a stderr note — never an unmarked clip. +func TestPrintGenericResultShortenedRowStaysUTF8(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{ + "items": []any{map[string]any{ + "incident_id": "inc-1", + "title": strings.Repeat("数据库故障", 5000), + "severity": "Critical", + }}, + "total": 1, + } + + out, stderrText, err := execCommandSplit("insight", "incident-list", + "--start-time", "7d", "--end-time", "now", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Fatalf("shortened single row is %d bytes, want <%d", len([]byte(out)), compactListOutputLimit) + } + if !utf8.ValidString(out) || !strings.Contains(out, "...") { + t.Fatalf("shortened row must retain valid UTF-8 and show the truncation marker") + } + if !strings.Contains(out, "inc-1") { + t.Errorf("identifier field must survive shortening intact, got:\n%s", out) + } + if !strings.Contains(stderrText, "were shortened to fit") { + t.Errorf("shortened row should announce the clipped fields on stderr, got:\n%s", stderrText) + } +} diff --git a/internal/cli/insight.go b/internal/cli/insight.go index 485df4c..d404df8 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -84,13 +84,14 @@ func newInsightTopAlertsCmd() *cobra.Command { } func newInsightIncidentsCmd() *cobra.Command { - var since, until string + var since, until, fields string var limit, page int + defaultStructuredFields := []string{"incident_id", "title", "severity", "channel_name", "seconds_to_ack", "seconds_to_close", "notifications"} cmd := &cobra.Command{ Use: "incidents", Short: "Query incidents with performance metrics", - Long: curatedLong("List incidents with per-incident performance metrics (MTTA, MTTR, notifications) over a time window.", "Analytics", "IncidentList"), + Long: curatedLong("List incidents with per-incident performance metrics (MTTA, MTTR, notifications) over a time window. In json/toon mode, rows default to the compact fields incident_id,title,severity,channel_name,seconds_to_ack,seconds_to_close,notifications; pass --fields to choose a different projection.", "Analytics", "IncidentList"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) @@ -114,6 +115,33 @@ func newInsightIncidentsCmd() *cobra.Command { return err } + if ctx.Structured() { + selectedFields := defaultStructuredFields + if cmd.Flags().Changed("fields") { + selectedFields = parseStringSlice(fields) + if len(selectedFields) == 0 { + return fmt.Errorf("--fields must name at least one field") + } + } else { + noteDefaultProjection(cmd.ErrOrStderr(), selectedFields) + } + proj, err := projectFields(result.Items, selectedFields) + if err != nil { + return err + } + bounded, note, err := boundProjectedOutput(proj, compactListOutputLimit) + if err != nil { + return err + } + proj = bounded.([]map[string]any) + noteProjectionBound(cmd.ErrOrStderr(), note) + effectiveLimit := limit + if len(proj) < len(result.Items) { + effectiveLimit = len(proj) + } + return ctx.PrintList(proj, nil, len(proj), page, effectiveLimit, int(result.Total)) + } + cols := []output.Column{ {Header: "ID", Field: func(v any) string { return v.(flashduty.IncidentRawItem).IncidentID @@ -147,6 +175,7 @@ func newInsightIncidentsCmd() *cobra.Command { cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") cmd.Flags().IntVar(&page, "page", 1, "Page number") + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,severity); ignored in table mode. Use to avoid dumping the full nested record.") return cmd } diff --git a/internal/cli/insight_export_test.go b/internal/cli/insight_export_test.go index b565470..839479a 100644 --- a/internal/cli/insight_export_test.go +++ b/internal/cli/insight_export_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "unicode/utf8" "github.com/flashcatcloud/go-flashduty" ) @@ -55,6 +56,149 @@ type insightExportStub struct { listBody map[string]any } +// insightIncidentRow builds one /insight/incident/list row for the stub, +// carrying both the compact-projection fields and the full-record fields a +// default projection must drop. +func insightIncidentRow() map[string]any { + return map[string]any{ + "incident_id": "inc-1", + "title": "Disk full on db-01", + "severity": "Critical", + "progress": "Triggered", + "channel_id": 12345, + "channel_name": "db-alerts", + "seconds_to_ack": 42, + "seconds_to_close": 3600, + "notifications": 3, + "description": "root volume at 98%", + "labels": map[string]any{"service": "db", "env": "prod"}, + "responders": []map[string]any{{"person_id": 101, "person_name": "Alice"}}, + } +} + +// TestInsightIncidentsStructuredDefaultUsesCompactProjection mirrors incident +// list: structured mode must not dump the full nested SDK row when --fields +// is omitted, and the default projection announces itself on stderr. +func TestInsightIncidentsStructuredDefaultUsesCompactProjection(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{insightIncidentRow()}, "total": 1} + + out, stderrText, err := execCommandSplit("insight", "incidents", "--output-format", format) + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + for _, key := range []string{"incident_id", "title", "severity", "channel_name", "seconds_to_ack", "seconds_to_close", "notifications"} { + if !strings.Contains(out, key) { + t.Errorf("default %s output missing compact key %q, got:\n%s", format, key, out) + } + } + // Full-record keys must not leak. (stdout only: the stderr note + // embeds the compact field names, never these.) + for _, key := range []string{"description", "labels", "responders"} { + if strings.Contains(out, key) { + t.Errorf("default %s output should not contain full-record key %q, got:\n%s", format, key, out) + } + } + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } + }) + } +} + +// TestInsightIncidentsStructuredFieldsFlag: an explicit --fields wins over the +// default projection and stays exactly the named fields. +func TestInsightIncidentsStructuredFieldsFlag(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{insightIncidentRow()}, "total": 1} + + out, stderrText, err := execCommandSplit("insight", "incidents", + "--fields", "incident_id,severity", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + assertProjectedJSONFields(t, out, []string{"incident_id", "severity"}) + if strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("explicit --fields must not print the default-projection note, got:\n%s", stderrText) + } +} + +// TestInsightIncidentsStructuredBounded: an oversized projected page is +// bounded below the structured-output limit — reduced to the leading intact +// rows, or a single oversized row shortened with a marked, announced clip. +func TestInsightIncidentsStructuredBounded(t *testing.T) { + t.Run("reduced page keeps every value intact", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + rows := make([]any, 10) + for i := range rows { + row := insightIncidentRow() + row["incident_id"] = fmt.Sprintf("inc-%d", i) + row["title"] = strings.Repeat(fmt.Sprintf("db-%d failover ", i), 200) + rows[i] = row + } + stub.data = map[string]any{"items": rows, "total": 10} + + out, stderrText, err := execCommandSplit("insight", "incidents", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Fatalf("bounded page is %d bytes, want <%d", len([]byte(out)), compactListOutputLimit) + } + if !strings.Contains(stderrText, "note: emitted") { + t.Errorf("reduced page should announce itself on stderr, got:\n%s", stderrText) + } + if strings.Contains(out, "...") { + t.Errorf("page reduction must never shorten a value, got:\n%s", out) + } + }) + + t.Run("single oversized row is shortened and announced", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + row := insightIncidentRow() + row["title"] = strings.Repeat("数据库故障", 5000) + stub.data = map[string]any{"items": []any{row}, "total": 1} + + out, stderrText, err := execCommandSplit("insight", "incidents", "--output-format", "json") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Fatalf("shortened row is %d bytes, want <%d", len([]byte(out)), compactListOutputLimit) + } + if !utf8.ValidString(out) || !strings.Contains(out, "...") { + t.Fatalf("shortened row must retain valid UTF-8 and show the truncation marker") + } + if !strings.Contains(stderrText, "were shortened to fit") || !strings.Contains(stderrText, "title") { + t.Errorf("shortened row should announce the clipped field on stderr, got:\n%s", stderrText) + } + }) +} + +// TestInsightIncidentsTableUnchanged: the human table keeps its full column +// set — the structured projection must not leak into table mode. +func TestInsightIncidentsTableUnchanged(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{insightIncidentRow()}, "total": 1} + + out, _, err := execCommandSplit("insight", "incidents") + if err != nil { + t.Fatalf("execCommandSplit: %v", err) + } + for _, want := range []string{"ID", "TITLE", "SEVERITY", "CHANNEL", "MTTA", "MTTR", "NOTIFICATIONS", "inc-1", "db-alerts"} { + if !strings.Contains(out, want) { + t.Errorf("table output missing %q, got:\n%s", want, out) + } + } +} + // 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. diff --git a/skills/flashduty/reference/insight.md b/skills/flashduty/reference/insight.md index 71c7b9c..58d9577 100644 --- a/skills/flashduty/reference/insight.md +++ b/skills/flashduty/reference/insight.md @@ -227,6 +227,7 @@ List insight incidents ### incidents Query incidents with performance metrics +- `--fields` string - `--limit` int - `--page` int - `--since` string