diff --git a/README.md b/README.md index 17922e8..3c87627 100644 --- a/README.md +++ b/README.md @@ -186,32 +186,105 @@ flashduty field list [flags] # List custom field definitions Supports `--name`. -### `statuspage` - Status Page Management (5 command groups) +### `status-page` - Status Page Management (28 commands) + +The group is `status-page` (hyphenated), not `statuspage`. Nested object/array +fields carry no typed flag and must be supplied as JSON through `--data`; +`--data -` reads the entire request body from stdin. Positional arguments and +explicitly-set typed flags override the matching keys inside `--data`. + +**Pages, components, sections** + +```bash +flashduty status-page list # List status pages (JSON: {"items":[...]}) +flashduty status-page info # Page detail, incl. component and section IDs +flashduty status-page create --name --url-name --type \ + --date-view --display-uptime-mode +flashduty status-page update [--name ] [--url-name ] ... # Update a page +flashduty status-page delete # Delete a page +flashduty status-page component-upsert --data '{"components":[{"name":"API","section_id":""}]}' +flashduty status-page component-delete [...] --page-id +flashduty status-page section-upsert --data '{"sections":[{"name":"Core"}]}' +flashduty status-page section-delete [...] --page-id +``` + +**Events (incident / maintenance) and their timeline** + +```bash +flashduty status-page change-active-list --type # Only in-progress events +flashduty status-page change-list --type --status +flashduty status-page change-info --page-id --change-id +flashduty status-page change-create --type --title \ + --status <status> --description <text> --data '{"updates":[...]}' +flashduty status-page change-update --page-id <page-id> --change-id <change-id> [--title <title>] +flashduty status-page change-delete --page-id <page-id> --change-id <change-id> +flashduty status-page change-timeline-create --page-id <page-id> --change-id <change-id> \ + --status <status> --description <text> [--data '{"component_changes":[...]}'] +flashduty status-page change-timeline-update --page-id <page-id> --change-id <change-id> --update-id <update-id> [--description <text>] +flashduty status-page change-timeline-delete --page-id <page-id> --change-id <change-id> --update-id <update-id> +``` + +`change-create` takes `<page-id>` as a **required positional argument**, and its +required `updates` array (with the nested `component_changes`) has no flag — so a +real `change-create` call always carries a `--data` payload: + +```bash +flashduty status-page change-create 5750613685214 --type incident \ + --title "API latency elevated" --status investigating \ + --description "Investigating elevated latency." \ + --data '{"updates":[{"status":"investigating","description":"Team is investigating.","component_changes":[{"component_id":"01KC3GAZ6ZJE40H55GM31RPWZE","status":"degraded"}]}]}' +``` + +The whole body can also come from stdin with `--data -`: + +```bash +cat change.json | flashduty status-page change-create 5750613685214 --data - +``` + +Resolving an incident goes through `change-timeline-create`; every component the +event touched must be moved back to `operational`: + +```bash +flashduty status-page change-timeline-create --page-id 5750613685214 --change-id 5821693893131 \ + --status resolved --description "Recovered." \ + --data '{"component_changes":[{"component_id":"01KC3GAZ6ZJE40H55GM31RPWZE","status":"operational"}]}' +``` + +**Subscribers and templates** + +```bash +flashduty status-page subscriber-list <page-id> [--component-ids <ids>] [--page <n>] [--limit <n>] +flashduty status-page subscriber-import <page-id> --method <email|im> --data '{"subscribers":[...]}' +flashduty status-page subscriber-export <page-id> [--component-ids <ids>] +flashduty status-page template-list <page-id> --type <pre_defined|message> +flashduty status-page template-upsert <page-id> --type <pre_defined|message> --data '{"template":{...}}' +flashduty status-page template-delete --page-id <page-id> --template-id <template-id> --type <pre_defined|message> +``` + +**Migration from Atlassian Statuspage** ```bash -flashduty statuspage list [--id <ids>] # List status pages -flashduty statuspage changes --page-id <id> --type <incident|maintenance> # List active changes -flashduty statuspage create-incident --page-id <id> --title <title> # Create status incident -flashduty statuspage create-timeline --page-id <id> --change <id> --message <msg> # Add timeline update -flashduty statuspage migrate structure --from atlassian --source-page-id <id> --api-key <key> # Start structure/history migration -flashduty statuspage migrate email-subscribers --from atlassian --source-page-id <id> --target-page-id <id> --api-key <key> # Start email subscriber migration -flashduty statuspage migrate status --job-id <id> # Check migration job status -flashduty statuspage migrate cancel --job-id <id> # Cancel a running migration job +flashduty status-page migrate-structure <source-page-id> --api-key <key> [--url-name <slug>] # Structure + history +flashduty status-page migrate-email-subscribers --source-page-id <id> --target-page-id <id> --api-key <key> +flashduty status-page migration-status <job-id> # Check migration job status +flashduty status-page migration-cancel <job-id> # Cancel a running migration job ``` -Migration jobs are asynchronous. After starting `structure` or `email-subscribers`, use: +Migration jobs are asynchronous. After starting `migrate-structure` or +`migrate-email-subscribers`, poll the returned `job_id`: ```bash -flashduty statuspage migrate status --job-id <job_id> +flashduty status-page migration-status <job-id> ``` Typical flow: ```bash -flashduty statuspage migrate structure --from atlassian --source-page-id page_123 --api-key $ATLASSIAN_STATUSPAGE_API_KEY -flashduty statuspage migrate status --job-id <structure_job_id> -flashduty statuspage migrate email-subscribers --from atlassian --source-page-id page_123 --target-page-id <target_page_id> --api-key $ATLASSIAN_STATUSPAGE_API_KEY -flashduty statuspage migrate status --job-id <subscriber_job_id> +flashduty status-page migrate-structure page_123 --api-key $ATLASSIAN_STATUSPAGE_API_KEY +flashduty status-page migration-status <structure_job_id> +flashduty status-page migrate-email-subscribers --source-page-id page_123 \ + --target-page-id <target_page_id> --api-key $ATLASSIAN_STATUSPAGE_API_KEY +flashduty status-page migration-status <subscriber_job_id> ``` ### `template` - Notification Template Management (4 commands) diff --git a/README_zh.md b/README_zh.md index 2d4e2c5..7cdc250 100644 --- a/README_zh.md +++ b/README_zh.md @@ -185,13 +185,102 @@ flashduty field list [flags] # 列出自定义字段定义 支持 `--name`。 -### `statuspage` - 状态页管理(4 个命令) +### `status-page` - 状态页管理(28 个命令) + +命令组名是 `status-page`(带连字符),不是 `statuspage`。嵌套对象、数组类字段没有 +对应的 flag,必须通过 `--data` 传 JSON;`--data -` 表示整个请求体从 stdin 读取。 +位置参数和显式设置的 flag 会覆盖 `--data` 里的同名字段。 + +**状态页、组件、分组** + +```bash +flashduty status-page list # 列出状态页(JSON 形如 {"items":[...]}) +flashduty status-page info <page-id> # 状态页详情,含组件 ID 和分组 ID +flashduty status-page create --name <name> --url-name <slug> --type <public|internal> \ + --date-view <calendar|list> --display-uptime-mode <chart_and_percentage|chart|none> +flashduty status-page update <page-id> [--name <name>] [--url-name <slug>] ... # 更新状态页 +flashduty status-page delete <page-id> # 删除状态页 +flashduty status-page component-upsert <page-id> --data '{"components":[{"name":"API","section_id":"<section-id>"}]}' +flashduty status-page component-delete <component-id> [<id2>...] --page-id <page-id> +flashduty status-page section-upsert <page-id> --data '{"sections":[{"name":"核心服务"}]}' +flashduty status-page section-delete <section-id> [<id2>...] --page-id <page-id> +``` + +**事件(故障 / 维护)与时间线** + +```bash +flashduty status-page change-active-list <page-id> --type <incident|maintenance> # 只列进行中的事件 +flashduty status-page change-list <page-id> --type <incident|maintenance> --status <status> +flashduty status-page change-info --page-id <page-id> --change-id <change-id> +flashduty status-page change-create <page-id> --type <incident|maintenance> --title <title> \ + --status <status> --description <text> --data '{"updates":[...]}' +flashduty status-page change-update --page-id <page-id> --change-id <change-id> [--title <title>] +flashduty status-page change-delete --page-id <page-id> --change-id <change-id> +flashduty status-page change-timeline-create --page-id <page-id> --change-id <change-id> \ + --status <status> --description <text> [--data '{"component_changes":[...]}'] +flashduty status-page change-timeline-update --page-id <page-id> --change-id <change-id> --update-id <update-id> [--description <text>] +flashduty status-page change-timeline-delete --page-id <page-id> --change-id <change-id> --update-id <update-id> +``` + +`change-create` 的 `<page-id>` 是**必填位置参数**;必填的 `updates` 数组(以及嵌套在里面的 +`component_changes`)没有对应的 flag,所以真实的 `change-create` 调用一定带 `--data`: + +```bash +flashduty status-page change-create 5750613685214 --type incident \ + --title "API 延迟升高" --status investigating \ + --description "正在排查延迟升高问题。" \ + --data '{"updates":[{"status":"investigating","description":"团队正在排查。","component_changes":[{"component_id":"01KC3GAZ6ZJE40H55GM31RPWZE","status":"degraded"}]}]}' +``` + +整个请求体也可以用 `--data -` 从 stdin 读: + +```bash +cat change.json | flashduty status-page change-create 5750613685214 --data - +``` + +关闭事件走 `change-timeline-create`,并且事件涉及的每个组件都要改回 `operational`: + +```bash +flashduty status-page change-timeline-create --page-id 5750613685214 --change-id 5821693893131 \ + --status resolved --description "已恢复。" \ + --data '{"component_changes":[{"component_id":"01KC3GAZ6ZJE40H55GM31RPWZE","status":"operational"}]}' +``` + +**订阅者与模板** + +```bash +flashduty status-page subscriber-list <page-id> [--component-ids <ids>] [--page <n>] [--limit <n>] +flashduty status-page subscriber-import <page-id> --method <email|im> --data '{"subscribers":[...]}' +flashduty status-page subscriber-export <page-id> [--component-ids <ids>] +flashduty status-page template-list <page-id> --type <pre_defined|message> +flashduty status-page template-upsert <page-id> --type <pre_defined|message> --data '{"template":{...}}' +flashduty status-page template-delete --page-id <page-id> --template-id <template-id> --type <pre_defined|message> +``` + +**从 Atlassian Statuspage 迁移** + +```bash +flashduty status-page migrate-structure <source-page-id> --api-key <key> [--url-name <slug>] # 迁移结构与历史 +flashduty status-page migrate-email-subscribers --source-page-id <id> --target-page-id <id> --api-key <key> +flashduty status-page migration-status <job-id> # 查询迁移任务状态 +flashduty status-page migration-cancel <job-id> # 取消正在跑的迁移任务 +``` + +迁移任务是异步的。启动 `migrate-structure` 或 `migrate-email-subscribers` 之后, +用返回的 `job_id` 轮询: + +```bash +flashduty status-page migration-status <job-id> +``` + +典型流程: ```bash -flashduty statuspage list [--id <ids>] # 列出状态页 -flashduty statuspage changes --page-id <id> --type <incident|maintenance> # 列出活跃的变更 -flashduty statuspage create-incident --page-id <id> --title <title> # 创建状态页事件 -flashduty statuspage create-timeline --page-id <id> --change <id> --message <msg> # 添加时间线更新 +flashduty status-page migrate-structure page_123 --api-key $ATLASSIAN_STATUSPAGE_API_KEY +flashduty status-page migration-status <structure_job_id> +flashduty status-page migrate-email-subscribers --source-page-id page_123 \ + --target-page-id <target_page_id> --api-key $ATLASSIAN_STATUSPAGE_API_KEY +flashduty status-page migration-status <subscriber_job_id> ``` ### `template` - 通知模板管理(4 个命令) diff --git a/e2e/auth_global_test.go b/e2e/auth_global_test.go index 5b4904b..4680a72 100644 --- a/e2e/auth_global_test.go +++ b/e2e/auth_global_test.go @@ -58,7 +58,7 @@ func TestHelpForEverySubcommand(t *testing.T) { // Test 110: all top-level commands show help without errors commands := []string{ "channel", "member", "team", "field", "escalation-rule", - "statuspage", "template", "change", "config", "login", + "status-page", "template", "change", "config", "login", } for _, cmd := range commands { t.Run(cmd, func(t *testing.T) { diff --git a/e2e/edge_case_test.go b/e2e/edge_case_test.go index ad9d4cf..8335fa3 100644 --- a/e2e/edge_case_test.go +++ b/e2e/edge_case_test.go @@ -49,7 +49,7 @@ func TestNoTruncOnFieldList(t *testing.T) { } func TestNoTruncOnStatusPageList(t *testing.T) { - r := runCLI(t, "statuspage", "list", "--no-trunc") + r := runCLI(t, "status-page", "list", "--no-trunc") requireSuccess(t, r) } diff --git a/e2e/resource_list_test.go b/e2e/resource_list_test.go index ecda3db..6d8329d 100644 --- a/e2e/resource_list_test.go +++ b/e2e/resource_list_test.go @@ -3,6 +3,7 @@ package e2e_test import ( + "strings" "testing" ) @@ -113,16 +114,25 @@ func TestChangeListJSON(t *testing.T) { // StatusPage // --------------------------------------------------------------------------- -// Test 248: statuspage list +// Test 248: status-page list +// +// `status-page list` is a generated command with no displayColumns entry, so the +// table columns are the reflective heuristic: the first 8 scalar fields of +// StatusPageItem, headed by their upper-cased JSON tag. Components/sections are +// nested arrays and are skipped by that heuristic, so there is no COMPONENTS +// column, and PAGE_ID/NAME/URL_NAME fall past the 8-column cut. func TestStatusPageList(t *testing.T) { - r := runCLI(t, "statuspage", "list") + r := runCLI(t, "status-page", "list") requireSuccess(t, r) - requireTableHeaders(t, r.Stdout, "ID", "NAME", "SLUG", "STATUS", "COMPONENTS") + if strings.HasPrefix(strings.TrimSpace(r.Stdout), "No results.") { + t.Skip("no status pages available") + } + requireTableHeaders(t, r.Stdout, "CONTACT_INFO", "CUSTOM_DOMAIN", "DATE_VIEW", "DISPLAY_UPTIME_MODE") } -// Test 252: statuspage list JSON +// Test 252: status-page list JSON func TestStatusPageListJSON(t *testing.T) { - r := runCLI(t, "statuspage", "list", "--json") + r := runCLI(t, "status-page", "list", "--json") requireSuccess(t, r) requireValidJSON(t, r.Stdout) } diff --git a/e2e/statuspage_test.go b/e2e/statuspage_test.go index d30cf67..af9db1f 100644 --- a/e2e/statuspage_test.go +++ b/e2e/statuspage_test.go @@ -9,14 +9,35 @@ import ( "testing" ) +// The CLI group is `status-page` (hyphenated) and its verbs come straight from +// the API paths — `change-active-list`, `change-create`, +// `change-timeline-create`, … — see internal/cli/zz_generated_status_pages.go. +// `statuspage`, `create-incident` and `create-timeline` never existed on this +// command tree. +// +// Generated list commands print their response envelope verbatim, so `--json` +// output is {"items":[...]} — an object, NOT a top-level array. + +// statusPageItems unmarshals the {"items":[...]} envelope of a status-page list +// response. +func statusPageItems(stdout string) ([]map[string]any, error) { + var envelope struct { + Items []map[string]any `json:"items"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope); err != nil { + return nil, err + } + return envelope.Items, nil +} + // getFirstStatusPageID gets the first status page ID from the API. func getFirstStatusPageID(t *testing.T) string { t.Helper() - r := runCLI(t, "statuspage", "list", "--json") + r := runCLI(t, "status-page", "list", "--json") requireSuccess(t, r) - var pages []map[string]any - if err := json.Unmarshal([]byte(strings.TrimSpace(r.Stdout)), &pages); err != nil { - t.Skipf("could not parse statuspage list JSON: %v", err) + pages, err := statusPageItems(r.Stdout) + if err != nil { + t.Skipf("could not parse status-page list JSON: %v", err) } if len(pages) == 0 { t.Skip("no status pages available") @@ -46,53 +67,51 @@ func stringifyNumericID(t *testing.T, value any, field string) string { } } +// getStatusPageChanges lists the in-progress events of one type for a page. +// `page-id` is POSITIONAL on change-active-list; only `--type` is a flag. func getStatusPageChanges(t *testing.T, pageID, changeType string) []map[string]any { t.Helper() - r := runCLI(t, "statuspage", "changes", "--page-id", pageID, "--type", changeType, "--json") + r := runCLI(t, "status-page", "change-active-list", pageID, "--type", changeType, "--json") requireSuccess(t, r) requireValidJSON(t, r.Stdout) - var changes []map[string]any - if err := json.Unmarshal([]byte(strings.TrimSpace(r.Stdout)), &changes); err != nil { - t.Fatalf("could not parse statuspage changes JSON: %v\n%s", err, r.Stdout) + changes, err := statusPageItems(r.Stdout) + if err != nil { + t.Fatalf("could not parse status-page change-active-list JSON: %v\n%s", err, r.Stdout) } if len(changes) == 0 { - t.Logf("no %s statuspage changes returned for page_id=%s", changeType, pageID) + t.Logf("no active %s events returned for page_id=%s", changeType, pageID) } return changes } -// Test 249: statuspage list --id filter -func TestStatusPageListByID(t *testing.T) { +// Test 249: status-page info <page-id> returns the requested page. +// There is no `--id` filter on `list` (its only flag is `--data`), so a +// single-page lookup goes through `info`, whose response is a bare object. +func TestStatusPageInfoByID(t *testing.T) { id := getFirstStatusPageID(t) - r := runCLI(t, "statuspage", "list", "--id", id, "--json") + r := runCLI(t, "status-page", "info", id, "--json") requireSuccess(t, r) requireValidJSON(t, r.Stdout) - var pages []map[string]any - if err := json.Unmarshal([]byte(strings.TrimSpace(r.Stdout)), &pages); err != nil { - t.Fatalf("could not parse statuspage list JSON: %v\n%s", err, r.Stdout) - } - if len(pages) == 0 { - t.Skipf("statuspage list returned no rows for known page_id=%s", id) + var page map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(r.Stdout)), &page); err != nil { + t.Fatalf("could not parse status-page info JSON: %v\n%s", err, r.Stdout) } - for _, page := range pages { - pageID := stringifyNumericID(t, page["page_id"], "page_id") - if pageID != id { - t.Fatalf("expected page_id=%s, got %s", id, pageID) - } + if got := stringifyNumericID(t, page["page_id"], "page_id"); got != id { + t.Fatalf("expected page_id=%s, got %s", id, got) } } -// Test 251: statuspage list --id invalid -func TestStatusPageListByIDInvalid(t *testing.T) { - r := runCLI(t, "statuspage", "list", "--id", "abc") +// Test 251: a non-numeric positional page id is rejected before any request. +func TestStatusPageChangeActiveListInvalidPageID(t *testing.T) { + r := runCLI(t, "status-page", "change-active-list", "abc", "--type", "incident") requireFailure(t, r) - requireContains(t, r.Stderr, "invalid --id") + requireContains(t, r.Stderr, "invalid page_id") } -// Test 253: statuspage changes --type incident +// Test 253: change-active-list --type incident func TestStatusPageChangesIncident(t *testing.T) { id := getFirstStatusPageID(t) changes := getStatusPageChanges(t, id, "incident") @@ -106,7 +125,7 @@ func TestStatusPageChangesIncident(t *testing.T) { } } -// Test 254: statuspage changes --type maintenance +// Test 254: change-active-list --type maintenance func TestStatusPageChangesMaintenance(t *testing.T) { id := getFirstStatusPageID(t) changes := getStatusPageChanges(t, id, "maintenance") @@ -120,20 +139,22 @@ func TestStatusPageChangesMaintenance(t *testing.T) { } } -// Test 255: statuspage changes missing --page-id +// Test 255: change-active-list without the <page-id> positional func TestStatusPageChangesMissingPageID(t *testing.T) { - r := runCLI(t, "statuspage", "changes", "--type", "incident") + r := runCLI(t, "status-page", "change-active-list", "--type", "incident") requireFailure(t, r) + requireContains(t, r.Stderr, "missing page_id") } -// Test 256: statuspage changes missing --type +// Test 256: change-active-list missing --type func TestStatusPageChangesMissingType(t *testing.T) { id := getFirstStatusPageID(t) - r := runCLI(t, "statuspage", "changes", "--page-id", id) + r := runCLI(t, "status-page", "change-active-list", id) requireFailure(t, r) + requireContains(t, r.Stderr, "missing required request fields: type") } -// Test 257: statuspage changes JSON +// Test 257: change-active-list JSON func TestStatusPageChangesJSON(t *testing.T) { id := getFirstStatusPageID(t) changes := getStatusPageChanges(t, id, "incident") @@ -147,32 +168,53 @@ func TestStatusPageChangesJSON(t *testing.T) { } } -// Test 261: statuspage create-incident missing --page-id -func TestStatusPageCreateIncidentMissingPageID(t *testing.T) { - r := runCLI(t, "statuspage", "create-incident", "--title", "test") +// Test 261: change-create without the <page-id> positional (and without the +// --page-id / --data fallbacks) fails in the argument validator. +func TestStatusPageChangeCreateMissingPageID(t *testing.T) { + r := runCLI(t, "status-page", "change-create", "--title", "test") + requireFailure(t, r) + requireContains(t, r.Stderr, "missing page_id") +} + +// Test 262: change-create with only the positional reports every missing +// required body field. +func TestStatusPageChangeCreateMissingTitle(t *testing.T) { + r := runCLI(t, "status-page", "change-create", "1", "--type", "incident", "--status", "investigating") requireFailure(t, r) + requireContains(t, r.Stderr, "title") } -// Test 262: statuspage create-incident missing --title -func TestStatusPageCreateIncidentMissingTitle(t *testing.T) { - r := runCLI(t, "statuspage", "create-incident", "--page-id", "1") +// Test 263: `updates` (and the nested `component_changes`) has NO typed flag, so +// a change-create that sets every scalar flag still fails until --data supplies +// it. This is the invariant that makes --data mandatory on real calls. +func TestStatusPageChangeCreateRequiresUpdatesViaData(t *testing.T) { + r := runCLI(t, "status-page", "change-create", "1", + "--type", "incident", "--status", "investigating", + "--title", "e2e", "--description", "e2e") requireFailure(t, r) + requireContains(t, r.Stderr, "missing required request fields: updates") } -// Test 267: statuspage create-timeline missing --page-id -func TestStatusPageCreateTimelineMissingPageID(t *testing.T) { - r := runCLI(t, "statuspage", "create-timeline", "--change", "1", "--message", "test") +// Test 267: change-timeline-create missing --page-id (both IDs are flags here, +// this command has no positional). +func TestStatusPageChangeTimelineCreateMissingPageID(t *testing.T) { + r := runCLI(t, "status-page", "change-timeline-create", "--change-id", "1", "--status", "investigating", "--description", "test") requireFailure(t, r) + requireContains(t, r.Stderr, "missing required request fields: page_id") } -// Test 268: statuspage create-timeline missing --change -func TestStatusPageCreateTimelineMissingChange(t *testing.T) { - r := runCLI(t, "statuspage", "create-timeline", "--page-id", "1", "--message", "test") +// Test 268: change-timeline-create missing --change-id +func TestStatusPageChangeTimelineCreateMissingChangeID(t *testing.T) { + r := runCLI(t, "status-page", "change-timeline-create", "--page-id", "1", "--status", "investigating", "--description", "test") requireFailure(t, r) + requireContains(t, r.Stderr, "missing required request fields: change_id") } -// Test 269: statuspage create-timeline missing --message -func TestStatusPageCreateTimelineMissingMessage(t *testing.T) { - r := runCLI(t, "statuspage", "create-timeline", "--page-id", "1", "--change", "1") +// Test 269: change-timeline-create missing --status. +// (--description is `omitempty` in the SDK request, so omitting it is caught by +// the server, not locally; --status is the locally-validated required field.) +func TestStatusPageChangeTimelineCreateMissingStatus(t *testing.T) { + r := runCLI(t, "status-page", "change-timeline-create", "--page-id", "1", "--change-id", "1", "--description", "test") requireFailure(t, r) + requireContains(t, r.Stderr, "missing required request fields: status") }