From a4abd58b89fa4c692c672fb9e0c3157c5d51fe79 Mon Sep 17 00:00:00 2001 From: "david.lotfi" Date: Tue, 23 Jun 2026 22:01:05 +0000 Subject: [PATCH 1/3] Add mute api related functionality Added create/get/delete/update/list functionality as per the new mute REST api spec --- AGENTS.md | 1 + README.md | 6 + src/app.ts | 2 + src/commands/monitor-mute/create.ts | 105 ++++++++ src/commands/monitor-mute/delete.ts | 116 ++++++++ src/commands/monitor-mute/index.ts | 32 +++ src/commands/monitor-mute/list.ts | 264 +++++++++++++++++++ src/commands/monitor-mute/update.ts | 111 ++++++++ src/commands/monitor-mute/view.ts | 121 +++++++++ src/lib/json-input.ts | 30 +++ src/rest/client.ts | 3 + src/rest/monitor-mute/create-monitor-mute.ts | 19 ++ src/rest/monitor-mute/delete-monitor-mute.ts | 13 + src/rest/monitor-mute/get-monitor-mute.ts | 14 + src/rest/monitor-mute/list-monitor-mutes.ts | 15 ++ src/rest/monitor-mute/update-monitor-mute.ts | 22 ++ 16 files changed, 874 insertions(+) create mode 100644 src/commands/monitor-mute/create.ts create mode 100644 src/commands/monitor-mute/delete.ts create mode 100644 src/commands/monitor-mute/index.ts create mode 100644 src/commands/monitor-mute/list.ts create mode 100644 src/commands/monitor-mute/update.ts create mode 100644 src/commands/monitor-mute/view.ts create mode 100644 src/lib/json-input.ts create mode 100644 src/rest/monitor-mute/create-monitor-mute.ts create mode 100644 src/rest/monitor-mute/delete-monitor-mute.ts create mode 100644 src/rest/monitor-mute/get-monitor-mute.ts create mode 100644 src/rest/monitor-mute/list-monitor-mutes.ts create mode 100644 src/rest/monitor-mute/update-monitor-mute.ts diff --git a/AGENTS.md b/AGENTS.md index 0198696..176e231 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ src/ ├── context.ts # CLI context (process, env) ├── commands/ # CLI commands organized by resource │ ├── alert/ # Alert commands (list, view) +│ ├── monitor-mute/ # Monitor mute commands (list, view, create, update, delete) │ ├── auth/ # Auth commands (configure, login, logout, status) │ ├── cli/ # CLI management (install, uninstall, upgrade) │ ├── content/ # Content pack management diff --git a/README.md b/README.md index a7f6baa..42518bd 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Command line interface for [Observe Inc](https://www.observeinc.com). - **OPAL Query Execution** - Run OPAL queries directly from your terminal with schema-aware table output. - **AI Agent Skills** - List and view reusable AI-agent instruction documents stored in Observe. - **Alert Monitoring** - List and view alerts with severity filtering and active-only views. +- **Monitor Mutes** - Full CRUD for monitor mute rules (snoozes), targeting all monitors or a specific set. - **Content Packs** - Install and view Host Explorer, Kubernetes Explorer, and Trace Explorer content. - **Ingest Token Management** - Full CRUD for ingest tokens with datastream association. - **Data Integrations** - Create data connections and datasources (AWS, Kubernetes, host) and generate CloudFormation quick-create URLs for AWS filedrop deployments. @@ -53,6 +54,11 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe skill view` | View skill details and content | | `observe alert list` | List alerts with severity and status filtering | | `observe alert view` | View full alert details | +| `observe monitor-mute list` | List and search monitor mute rules | +| `observe monitor-mute view` | View full monitor mute rule details | +| `observe monitor-mute create` | Create a monitor mute rule | +| `observe monitor-mute update` | Update a monitor mute rule | +| `observe monitor-mute delete` | Delete a monitor mute rule | | `observe content host install` | Install Host Explorer content | | `observe content host view` | View Host Explorer content | | `observe content kubernetes install` | Install Kubernetes Explorer content | diff --git a/src/app.ts b/src/app.ts index a51b600..d6a7111 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,7 @@ import { buildApplication, buildRouteMap } from "@stricli/core"; import { name } from "../package.json"; import { alertRoutes } from "./commands/alert/index.js"; +import { monitorMuteRoutes } from "./commands/monitor-mute/index.js"; import { dataConnectionRoutes } from "./commands/data-connection/index.js"; import { datasourceRoutes } from "./commands/datasource/index.js"; import { datastreamTokenRoutes } from "./commands/datastream-token/index.js"; @@ -30,6 +31,7 @@ export const routes = buildRouteMap({ query: queryCommand, skill: skillRoutes, alert: alertRoutes, + "monitor-mute": monitorMuteRoutes, content: contentRoutes, "ingest-token": ingestTokenRoutes, datasource: datasourceRoutes, diff --git a/src/commands/monitor-mute/create.ts b/src/commands/monitor-mute/create.ts new file mode 100644 index 0000000..19feddd --- /dev/null +++ b/src/commands/monitor-mute/create.ts @@ -0,0 +1,105 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { createMonitorMute } from "../../rest/monitor-mute/create-monitor-mute"; +import { type MonitorMuteCreateRequest } from "../../rest/generated"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { muteStatusWriter } from "../../lib/writer"; +import { loadJsonInput } from "../../lib/json-input"; + +interface CreateMonitorMuteFlags { + data?: string; + file?: string; + json?: boolean; +} + +const BODY_EXAMPLE = `Example body: +{ + "label": "Snooze checkout during deploy", + "target": { "kind": "Monitors", "monitors": [{ "id": "41000001" }] }, + "schedule": { "kind": "OneTime", "oneTime": { "startTime": "2026-06-23T18:00:00Z", "endTime": "2026-06-23T20:00:00Z" } } +}`; + +async function create( + this: LocalContext, + flags: CreateMonitorMuteFlags, +): Promise { + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { muted: flags.json }); + + let body: MonitorMuteCreateRequest; + try { + body = loadJsonInput(flags.data, flags.file) as MonitorMuteCreateRequest; + } catch (e) { + writer.error(e instanceof Error ? e.message : String(e)); + writer.info(`\n${BODY_EXAMPLE}`); + process.exit(1); + return; + } + + try { + const config = loadConfig(); + + writer.info("Creating monitor mute..."); + + const mute = await createMonitorMute({ config, body }); + + if (flags.json) { + writer.write(JSON.stringify(mute, null, 2)); + return; + } + + writer.write( + chalk.green(`Created monitor mute `) + + chalk.bold(mute.id) + + chalk.green(` — "${mute.label}"`), + ); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const createCommand = buildCommand({ + loader: async () => create, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + data: { + kind: "parsed", + parse: String, + brief: "Mute rule body as an inline JSON string", + optional: true, + }, + file: { + kind: "parsed", + parse: String, + brief: "Path to a JSON file containing the mute rule body", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output the created mute rule as JSON", + optional: true, + }, + }, + aliases: { + d: "data", + f: "file", + }, + }, + docs: { + brief: "Create a monitor mute rule", + fullDescription: [ + "Create a monitor mute rule (snooze) via the /v1/monitor-mutes REST API.", + "", + "Provide the request body with --data '' or --file .", + "", + BODY_EXAMPLE, + ].join("\n"), + }, +}); diff --git a/src/commands/monitor-mute/delete.ts b/src/commands/monitor-mute/delete.ts new file mode 100644 index 0000000..e73f864 --- /dev/null +++ b/src/commands/monitor-mute/delete.ts @@ -0,0 +1,116 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import { createInterface } from "node:readline/promises"; +import type { LocalContext } from "../../context"; +import { getMonitorMute } from "../../rest/monitor-mute/get-monitor-mute"; +import { deleteMonitorMute } from "../../rest/monitor-mute/delete-monitor-mute"; +import { MonitorMuteTargetKind } from "../../rest/generated"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { muteStatusWriter } from "../../lib/writer"; + +interface DeleteMonitorMuteFlags { + yes?: boolean; + json?: boolean; +} + +async function confirm(this: LocalContext, question: string): Promise { + const { process } = this; + if (!process.stdin.isTTY) { + return false; + } + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = await rl.question(`${question} [y/N] `); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} + +async function remove( + this: LocalContext, + flags: DeleteMonitorMuteFlags, + id: string, +): Promise { + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { muted: flags.json }); + + try { + const config = loadConfig(); + + if (!flags.yes) { + // Fetch first so we can warn when deleting a rule that mutes several + // monitors — deleting it resumes notifications for ALL of them. + const mute = await getMonitorMute({ config, id }); + const target = + mute.target.kind === MonitorMuteTargetKind.Monitors + ? `${mute.target.monitors.length} monitor(s)` + : "ALL monitors (Global)"; + writer.warn( + `Deleting mute rule ${chalk.bold(id)} ("${mute.label}") will resume notifications for ${target}.`, + ); + const confirmed = await confirm.call(this, "Delete this mute rule?"); + if (!confirmed) { + writer.info("Aborted. Re-run with --yes to skip this prompt."); + process.exit(1); + return; + } + } + + await deleteMonitorMute({ config, id }); + + if (flags.json) { + writer.write(JSON.stringify({ success: true, id }, null, 2)); + return; + } + + writer.write(chalk.green(`Deleted monitor mute ${chalk.bold(id)}.`)); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const deleteCommand = buildCommand({ + loader: async () => remove, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Monitor mute rule ID", + parse: String, + }, + ], + }, + flags: { + yes: { + kind: "boolean", + brief: "Skip the confirmation prompt", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output the result as JSON", + optional: true, + }, + }, + aliases: { + y: "yes", + }, + }, + docs: { + brief: "Delete a monitor mute rule", + fullDescription: [ + "Delete a monitor mute rule by ID via the /v1/monitor-mutes REST API.", + "", + "Deleting a rule that targets multiple monitors resumes notifications for", + "all of them. To unmute a single monitor from a shared rule, update the", + "rule's target instead with 'observe monitor-mute update '.", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor-mute/index.ts b/src/commands/monitor-mute/index.ts new file mode 100644 index 0000000..5f712ac --- /dev/null +++ b/src/commands/monitor-mute/index.ts @@ -0,0 +1,32 @@ +import { buildRouteMap } from "@stricli/core"; +import { listCommand } from "./list"; +import { viewCommand } from "./view"; +import { createCommand } from "./create"; +import { updateCommand } from "./update"; +import { deleteCommand } from "./delete"; + +export const monitorMuteRoutes = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + create: createCommand, + update: updateCommand, + delete: deleteCommand, + }, + docs: { + brief: "Manage monitor mute rules", + fullDescription: [ + "View and manage monitor mute rules (snoozes) in Observe.", + "", + "A mute rule suppresses alert notifications during a defined window,", + "targeting either all monitors (Global) or a specific set (Monitors).", + "", + "Commands:", + " list Search and list monitor mute rules", + " view View details of a specific mute rule", + " create Create a mute rule", + " update Update a mute rule", + " delete Delete a mute rule", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor-mute/list.ts b/src/commands/monitor-mute/list.ts new file mode 100644 index 0000000..ce28188 --- /dev/null +++ b/src/commands/monitor-mute/list.ts @@ -0,0 +1,264 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { listMonitorMutes } from "../../rest/monitor-mute/list-monitor-mutes"; +import { + type MonitorMuteResource, + MonitorMuteTargetKind, +} from "../../rest/generated"; +import { celMatchesInsensitive } from "../../lib/cel"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { muteStatusWriter } from "../../lib/writer"; +import { parseNonNegativeInt } from "../../lib/parsers"; +import { + formatTable, + createColumnHelper, + type ColumnDef, +} from "../../lib/formatters/table"; +import { renderAsCSV } from "../../lib/formatters/csv"; + +type OutputFormat = "json" | "csv"; +type SortField = "id" | "label" | "createdAt" | "updatedAt"; + +interface ListMonitorMutesFlags { + match?: string; + kind?: MonitorMuteTargetKind; + limit: number; + offset?: number; + sort?: SortField; + format?: OutputFormat; + json?: boolean; + fields?: FieldName[]; +} + +const AVAILABLE_FIELDS = [ + "id", + "label", + "target", + "monitors", + "schedule", + "start", + "end", +] as const; + +type FieldName = (typeof AVAILABLE_FIELDS)[number]; + +const DEFAULT_FIELDS: FieldName[] = [ + "id", + "label", + "target", + "schedule", + "end", +]; + +function describeTarget(mute: MonitorMuteResource): string { + return mute.target.kind === MonitorMuteTargetKind.Monitors + ? `Monitors (${mute.target.monitors.length})` + : "Global"; +} + +const col = createColumnHelper(); + +const FIELD_COLUMNS = { + id: col.accessor((row) => row.id, { + header: "ID", + }), + label: col.accessor((row) => row.label, { + header: "LABEL", + }), + target: col.accessor(describeTarget, { + header: "TARGET", + format: (value) => + value === "Global" ? chalk.yellow(value) : chalk.cyan(value), + }), + monitors: col.accessor( + (row) => + row.target.kind === MonitorMuteTargetKind.Monitors + ? row.target.monitors.map((m) => m.id).join(", ") || "-" + : "-", + { + header: "MONITORS", + }, + ), + schedule: col.accessor((row) => row.schedule.kind, { + header: "SCHEDULE", + }), + start: col.accessor((row) => row.startTime ?? "-", { + header: "START", + }), + end: col.accessor((row) => row.endTime ?? chalk.dim("open-ended"), { + header: "END", + }), +} satisfies Record>; + +async function list( + this: LocalContext, + flags: ListMonitorMutesFlags, +): Promise { + const { process, writer: _writer } = this; + + const format = flags.json ? ("json" as const) : flags.format; + const writer = muteStatusWriter(_writer, { + muted: format === "json" || format === "csv", + }); + + try { + const config = loadConfig(); + + writer.info("Searching monitor mutes..."); + + const filters: string[] = []; + if (flags.match) { + filters.push(celMatchesInsensitive("label", flags.match)); + } + if (flags.kind) { + filters.push(`target.kind == "${flags.kind}"`); + } + const filter = filters.length > 0 ? filters.join(" && ") : undefined; + + const result = await listMonitorMutes({ + config, + filter, + limit: flags.limit, + offset: flags.offset, + orderBy: flags.sort, + }); + + const mutes = result.monitorMutes; + const fieldNames = flags.fields ?? DEFAULT_FIELDS; + + if (format === "json") { + writer.write(JSON.stringify(mutes, null, 2)); + return; + } + + if (format === "csv") { + writer.write(renderAsCSV(mutes)); + return; + } + + if (mutes.length === 0) { + writer.warn("No monitor mutes found."); + return; + } + + writer.write(chalk.green(`Found ${mutes.length} monitor mute(s):\n`)); + + const columns = fieldNames.map((field) => FIELD_COLUMNS[field]); + writer.write(formatTable(mutes, columns)); + + if (mutes.length === flags.limit) { + const nextOffset = (flags.offset ?? 0) + flags.limit; + writer.info( + `\nMore results may be available. Use --offset ${nextOffset} to see the next page.`, + ); + } + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +const MAX_LIMIT = 1000; +const MIN_LIMIT = 1; +const DEFAULT_LIMIT = 100; + +function parseLimit(value: string): number { + const num = Number(value); + if (isNaN(num) || num < MIN_LIMIT || num > MAX_LIMIT) { + throw new Error(`Limit must be between ${MIN_LIMIT} and ${MAX_LIMIT}`); + } + return num; +} + +function parseFields(value: string): FieldName[] { + const fields = value.split(",").map((f) => f.trim()) as FieldName[]; + for (const field of fields) { + if (!AVAILABLE_FIELDS.includes(field)) { + throw new Error( + `Invalid field: "${field}". Available fields: ${AVAILABLE_FIELDS.join(", ")}`, + ); + } + } + return fields; +} + +const VALID_KINDS = Object.values(MonitorMuteTargetKind); + +function parseKind(value: string): MonitorMuteTargetKind { + if (!VALID_KINDS.includes(value as MonitorMuteTargetKind)) { + throw new Error( + `Invalid kind: "${value}". Available kinds: ${VALID_KINDS.join(", ")}`, + ); + } + return value as MonitorMuteTargetKind; +} + +export const listCommand = buildCommand({ + loader: async () => list, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + match: { + kind: "parsed", + parse: String, + brief: "Search mute rules by label substring", + optional: true, + }, + kind: { + kind: "parsed", + parse: parseKind, + brief: `Filter by target kind: ${VALID_KINDS.join(", ")}`, + optional: true, + }, + limit: { + kind: "parsed", + parse: parseLimit, + brief: `Maximum number of mute rules to return (${MIN_LIMIT}-${MAX_LIMIT})`, + default: String(DEFAULT_LIMIT), + }, + offset: { + kind: "parsed", + parse: parseNonNegativeInt, + brief: "Offset for pagination (skip this many results)", + optional: true, + }, + sort: { + kind: "enum", + values: ["id", "label", "createdAt", "updatedAt"], + brief: + "Sort results by field (prefix with - for descending, e.g. -createdAt)", + optional: true, + }, + format: { + kind: "enum", + values: ["json", "csv"], + brief: "Output format (json, csv)", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output as JSON (shorthand for --format=json)", + optional: true, + }, + fields: { + kind: "parsed", + parse: parseFields, + brief: `Comma-separated list of fields: ${AVAILABLE_FIELDS.join(", ")}`, + optional: true, + }, + }, + aliases: { + m: "match", + l: "limit", + s: "sort", + }, + }, + docs: { + brief: "Search and list monitor mute rules in Observe", + }, +}); diff --git a/src/commands/monitor-mute/update.ts b/src/commands/monitor-mute/update.ts new file mode 100644 index 0000000..767afe9 --- /dev/null +++ b/src/commands/monitor-mute/update.ts @@ -0,0 +1,111 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { updateMonitorMute } from "../../rest/monitor-mute/update-monitor-mute"; +import { type MonitorMuteUpdateRequest } from "../../rest/generated"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { muteStatusWriter } from "../../lib/writer"; +import { loadJsonInput } from "../../lib/json-input"; + +interface UpdateMonitorMuteFlags { + data?: string; + file?: string; + json?: boolean; +} + +const BODY_EXAMPLE = `Only the fields you provide are changed (JSON Merge Patch). +Example body: +{ + "label": "Snooze checkout (extended)", + "schedule": { "kind": "OneTime", "oneTime": { "startTime": "2026-06-23T18:00:00Z", "endTime": "2026-06-23T22:00:00Z" } } +}`; + +async function update( + this: LocalContext, + flags: UpdateMonitorMuteFlags, + id: string, +): Promise { + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { muted: flags.json }); + + let body: MonitorMuteUpdateRequest; + try { + body = loadJsonInput(flags.data, flags.file) as MonitorMuteUpdateRequest; + } catch (e) { + writer.error(e instanceof Error ? e.message : String(e)); + writer.info(`\n${BODY_EXAMPLE}`); + process.exit(1); + return; + } + + try { + const config = loadConfig(); + + writer.info("Updating monitor mute..."); + + const mute = await updateMonitorMute({ config, id, body }); + + if (flags.json) { + writer.write(JSON.stringify(mute, null, 2)); + return; + } + + writer.write( + chalk.green(`Updated monitor mute `) + + chalk.bold(mute.id) + + chalk.green(` — "${mute.label}"`), + ); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const updateCommand = buildCommand({ + loader: async () => update, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Monitor mute rule ID", + parse: String, + }, + ], + }, + flags: { + data: { + kind: "parsed", + parse: String, + brief: "Partial mute rule body as an inline JSON string", + optional: true, + }, + file: { + kind: "parsed", + parse: String, + brief: "Path to a JSON file containing the partial mute rule body", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output the updated mute rule as JSON", + optional: true, + }, + }, + aliases: { + d: "data", + f: "file", + }, + }, + docs: { + brief: "Update a monitor mute rule", + fullDescription: [ + "Update a monitor mute rule (snooze) by ID via the /v1/monitor-mutes REST API.", + "", + "Provide the partial body with --data '' or --file .", + "", + BODY_EXAMPLE, + ].join("\n"), + }, +}); diff --git a/src/commands/monitor-mute/view.ts b/src/commands/monitor-mute/view.ts new file mode 100644 index 0000000..f6381ce --- /dev/null +++ b/src/commands/monitor-mute/view.ts @@ -0,0 +1,121 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { getMonitorMute } from "../../rest/monitor-mute/get-monitor-mute"; +import { + type MonitorMuteResource, + MonitorMuteTargetKind, +} from "../../rest/generated"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { muteStatusWriter } from "../../lib/writer"; +import { renderObject } from "../../lib/formatters/object"; +import { renderAsCSV } from "../../lib/formatters/csv"; + +type OutputFormat = "json" | "csv"; + +interface ViewMonitorMuteFlags { + format?: OutputFormat; + json?: boolean; +} + +function buildViewData(mute: MonitorMuteResource) { + return { + id: mute.id, + label: mute.label, + description: mute.description ?? "-", + target: { + kind: mute.target.kind, + monitors: + mute.target.kind === MonitorMuteTargetKind.Monitors + ? mute.target.monitors.map((m) => ({ + id: m.id, + name: m.record?.label ?? "-", + })) + : [], + }, + schedule: mute.schedule, + filter: mute.filter ?? "-", + start: mute.startTime ?? "-", + end: mute.endTime ?? "open-ended", + createdBy: mute.createdBy.label ?? mute.createdBy.id, + createdAt: mute.createdAt, + updatedBy: mute.updatedBy.label ?? mute.updatedBy.id, + updatedAt: mute.updatedAt, + }; +} + +async function view( + this: LocalContext, + flags: ViewMonitorMuteFlags, + id: string, +): Promise { + const format = flags.json ? ("json" as const) : flags.format; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { + muted: format === "json" || format === "csv", + }); + + try { + const config = loadConfig(); + + writer.info("Fetching monitor mute..."); + + const mute = await getMonitorMute({ config, id }); + + if (format === "json") { + writer.write(JSON.stringify(mute, null, 2)); + return; + } + + if (format === "csv") { + writer.write(renderAsCSV(mute)); + return; + } + + writer.write(""); + writer.write(chalk.bold.white(`Monitor mute ${mute.id}`)); + writer.write( + mute.target.kind === MonitorMuteTargetKind.Global + ? chalk.yellow("GLOBAL") + : chalk.cyan(`MONITORS (${mute.target.monitors.length})`), + ); + + renderObject(buildViewData(mute), (text) => writer.write(text)); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const viewCommand = buildCommand({ + loader: async () => view, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Monitor mute rule ID", + parse: String, + }, + ], + }, + flags: { + format: { + kind: "enum", + values: ["json", "csv"], + brief: "Output format (json, csv)", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output as JSON (shorthand for --format=json)", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "View details of a monitor mute rule", + }, +}); diff --git a/src/lib/json-input.ts b/src/lib/json-input.ts new file mode 100644 index 0000000..9661b64 --- /dev/null +++ b/src/lib/json-input.ts @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; + +/** + * Resolves a `--data` (inline JSON) / `--file` (path to JSON) pair into a + * parsed value. The two flags are mutually exclusive and one is required. + * Throws with an actionable message on bad/missing input. Callers narrow the + * returned value to the expected request body type. + */ +export function loadJsonInput( + inline: string | undefined, + filePath: string | undefined, + flagName = "data", +): unknown { + if (inline !== undefined && filePath !== undefined) { + throw new Error(`--${flagName} and --file are mutually exclusive`); + } + const raw = inline ?? (filePath ? readFileSync(filePath, "utf8") : undefined); + if (raw === undefined) { + throw new Error( + `Provide the request body via --${flagName} '' or --file `, + ); + } + try { + return JSON.parse(raw); + } catch { + const source = + inline !== undefined ? `--${flagName}` : `--file (${filePath ?? ""})`; + throw new Error(`${source} must be valid JSON`); + } +} diff --git a/src/rest/client.ts b/src/rest/client.ts index 0a663bc..819dcb1 100644 --- a/src/rest/client.ts +++ b/src/rest/client.ts @@ -4,6 +4,7 @@ import { Configuration, DatasetApi, ExportApi, + MonitorMuteApi, V2KnowledgeGraphApi, } from "./generated"; import { getApiBaseUrl, type Config } from "../lib/config"; @@ -21,6 +22,7 @@ export class ObserveRestSDK { public exportApi: ExportApi; public datasetApi: DatasetApi; public alertApi: AlertApi; + public monitorMuteApi: MonitorMuteApi; public knowledgeGraphApi: V2KnowledgeGraphApi; public skillsApi: SkillsApi; @@ -30,6 +32,7 @@ export class ObserveRestSDK { this.exportApi = new ExportApi(config); this.datasetApi = new DatasetApi(config); this.alertApi = new AlertApi(config); + this.monitorMuteApi = new MonitorMuteApi(config); this.knowledgeGraphApi = new V2KnowledgeGraphApi(config); this.skillsApi = new SkillsApi(config); } diff --git a/src/rest/monitor-mute/create-monitor-mute.ts b/src/rest/monitor-mute/create-monitor-mute.ts new file mode 100644 index 0000000..1d710ce --- /dev/null +++ b/src/rest/monitor-mute/create-monitor-mute.ts @@ -0,0 +1,19 @@ +import type { Config } from "../../lib/config"; +import type { + MonitorMuteCreateRequest, + MonitorMuteResource, +} from "../generated"; +import { ObserveRestSDK } from "../client"; + +export async function createMonitorMute({ + config, + body, +}: { + config: Config; + body: MonitorMuteCreateRequest; +}): Promise { + const sdk = new ObserveRestSDK(config); + return sdk.monitorMuteApi.createMonitorMute({ + monitorMuteCreateRequest: body, + }); +} diff --git a/src/rest/monitor-mute/delete-monitor-mute.ts b/src/rest/monitor-mute/delete-monitor-mute.ts new file mode 100644 index 0000000..215754c --- /dev/null +++ b/src/rest/monitor-mute/delete-monitor-mute.ts @@ -0,0 +1,13 @@ +import type { Config } from "../../lib/config"; +import { ObserveRestSDK } from "../client"; + +export async function deleteMonitorMute({ + config, + id, +}: { + config: Config; + id: string; +}): Promise { + const sdk = new ObserveRestSDK(config); + await sdk.monitorMuteApi.deleteMonitorMute({ id }); +} diff --git a/src/rest/monitor-mute/get-monitor-mute.ts b/src/rest/monitor-mute/get-monitor-mute.ts new file mode 100644 index 0000000..a4132f5 --- /dev/null +++ b/src/rest/monitor-mute/get-monitor-mute.ts @@ -0,0 +1,14 @@ +import type { Config } from "../../lib/config"; +import type { MonitorMuteResource } from "../generated"; +import { ObserveRestSDK } from "../client"; + +export async function getMonitorMute({ + config, + id, +}: { + config: Config; + id: string; +}): Promise { + const sdk = new ObserveRestSDK(config); + return sdk.monitorMuteApi.getMonitorMute({ id, expand: true }); +} diff --git a/src/rest/monitor-mute/list-monitor-mutes.ts b/src/rest/monitor-mute/list-monitor-mutes.ts new file mode 100644 index 0000000..ad3a55e --- /dev/null +++ b/src/rest/monitor-mute/list-monitor-mutes.ts @@ -0,0 +1,15 @@ +import type { Config } from "../../lib/config"; +import type { MonitorMuteApiListMonitorMutesRequest } from "../generated"; +import { ObserveRestSDK } from "../client"; + +export async function listMonitorMutes({ + config, + ...params +}: { config: Config } & Omit) { + const sdk = new ObserveRestSDK(config); + const response = await sdk.monitorMuteApi.listMonitorMutes({ + ...params, + expand: true, + }); + return response; +} diff --git a/src/rest/monitor-mute/update-monitor-mute.ts b/src/rest/monitor-mute/update-monitor-mute.ts new file mode 100644 index 0000000..36346e3 --- /dev/null +++ b/src/rest/monitor-mute/update-monitor-mute.ts @@ -0,0 +1,22 @@ +import type { Config } from "../../lib/config"; +import type { + MonitorMuteResource, + MonitorMuteUpdateRequest, +} from "../generated"; +import { ObserveRestSDK } from "../client"; + +export async function updateMonitorMute({ + config, + id, + body, +}: { + config: Config; + id: string; + body: MonitorMuteUpdateRequest; +}): Promise { + const sdk = new ObserveRestSDK(config); + return sdk.monitorMuteApi.updateMonitorMute({ + id, + monitorMuteUpdateRequest: body, + }); +} From 267ba79a7389644f8cfffdd0d61b391b4c52d086 Mon Sep 17 00:00:00 2001 From: "david.lotfi" Date: Tue, 23 Jun 2026 23:01:47 +0000 Subject: [PATCH 2/3] add mute related tests --- src/commands/monitor-mute/create.test.ts | 163 +++++++++++++++++++ src/commands/monitor-mute/create.ts | 10 +- src/commands/monitor-mute/delete.test.ts | 158 ++++++++++++++++++ src/commands/monitor-mute/delete.ts | 10 +- src/commands/monitor-mute/list.test.ts | 196 +++++++++++++++++++++++ src/commands/monitor-mute/list.ts | 10 +- src/commands/monitor-mute/update.test.ts | 147 +++++++++++++++++ src/commands/monitor-mute/update.ts | 10 +- src/commands/monitor-mute/view.test.ts | 140 ++++++++++++++++ src/commands/monitor-mute/view.ts | 10 +- 10 files changed, 844 insertions(+), 10 deletions(-) create mode 100644 src/commands/monitor-mute/create.test.ts create mode 100644 src/commands/monitor-mute/delete.test.ts create mode 100644 src/commands/monitor-mute/list.test.ts create mode 100644 src/commands/monitor-mute/update.test.ts create mode 100644 src/commands/monitor-mute/view.test.ts diff --git a/src/commands/monitor-mute/create.test.ts b/src/commands/monitor-mute/create.test.ts new file mode 100644 index 0000000..aa2d091 --- /dev/null +++ b/src/commands/monitor-mute/create.test.ts @@ -0,0 +1,163 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../.."); +const restModulePath = resolve( + repoRoot, + "src/rest/monitor-mute/create-monitor-mute.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const createMonitorMuteFn = mock((_args: unknown) => + Promise.resolve({ id: "mute-1", label: "Snooze checkout" }), +); + +let create: (typeof import("./create"))["create"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./create"))["create"]>[1]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(restModulePath, () => ({ + createMonitorMute: createMonitorMuteFn, + })); + + const mod = await import("./create.ts"); + create = mod.create; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +const VALID_BODY = JSON.stringify({ + label: "Snooze checkout", + target: { kind: "Monitors", monitors: [{ id: "42" }] }, + schedule: { kind: "OneTime", oneTime: { startTime: "2026-06-23T18:00:00Z" } }, +}); + +describe("monitor-mute create", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + createMonitorMuteFn.mockClear(); + }); + + test("parses --data and forwards the body to createMonitorMute", async () => { + const { context, stdout } = createMockContext(); + await create.call(context, { data: VALID_BODY, json: true }, deps); + + expect(createMonitorMuteFn).toHaveBeenCalledTimes(1); + const [args] = createMonitorMuteFn.mock.calls[0]!; + expect((args as { body: unknown }).body).toMatchObject({ + label: "Snooze checkout", + target: { kind: "Monitors", monitors: [{ id: "42" }] }, + }); + const output = JSON.parse(stdout.join("")); + expect(output.id).toBe("mute-1"); + }); + + test("exits 1 on invalid JSON without calling the API", async () => { + const { context, getExitCode } = createMockContext(); + try { + await create.call(context, { data: "{not json", json: true }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(createMonitorMuteFn).not.toHaveBeenCalled(); + }); + + test("exits 1 when no body is provided", async () => { + const { context, getExitCode } = createMockContext(); + try { + await create.call(context, { json: true }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(createMonitorMuteFn).not.toHaveBeenCalled(); + }); + + test("exits 1 on API error", async () => { + createMonitorMuteFn.mockImplementationOnce(() => { + throw new Error("bad target"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call(context, { data: VALID_BODY }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor-mute/create.ts b/src/commands/monitor-mute/create.ts index 19feddd..9395bd6 100644 --- a/src/commands/monitor-mute/create.ts +++ b/src/commands/monitor-mute/create.ts @@ -21,10 +21,16 @@ const BODY_EXAMPLE = `Example body: "schedule": { "kind": "OneTime", "oneTime": { "startTime": "2026-06-23T18:00:00Z", "endTime": "2026-06-23T20:00:00Z" } } }`; -async function create( +export interface CreateMonitorMuteDeps { + loadConfig?: typeof loadConfig; +} + +export async function create( this: LocalContext, flags: CreateMonitorMuteFlags, + deps: CreateMonitorMuteDeps = {}, ): Promise { + const { loadConfig: loadConfigImpl = loadConfig } = deps; const { process, writer: _writer } = this; const writer = muteStatusWriter(_writer, { muted: flags.json }); @@ -39,7 +45,7 @@ async function create( } try { - const config = loadConfig(); + const config = loadConfigImpl(); writer.info("Creating monitor mute..."); diff --git a/src/commands/monitor-mute/delete.test.ts b/src/commands/monitor-mute/delete.test.ts new file mode 100644 index 0000000..4e392d5 --- /dev/null +++ b/src/commands/monitor-mute/delete.test.ts @@ -0,0 +1,158 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../.."); +const getModulePath = resolve( + repoRoot, + "src/rest/monitor-mute/get-monitor-mute.ts", +); +const deleteModulePath = resolve( + repoRoot, + "src/rest/monitor-mute/delete-monitor-mute.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const getMonitorMuteFn = mock((_args: unknown) => + Promise.resolve({ + id: "mute-1", + label: "Snooze checkout", + target: { kind: "Monitors", monitors: [{ id: "42" }, { id: "43" }] }, + }), +); +const deleteMonitorMuteFn = mock((_args: unknown) => Promise.resolve()); + +let remove: (typeof import("./delete"))["remove"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./delete"))["remove"]>[2]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(getModulePath, () => ({ + getMonitorMute: getMonitorMuteFn, + })); + void mock.module(deleteModulePath, () => ({ + deleteMonitorMute: deleteMonitorMuteFn, + })); + + const mod = await import("./delete.ts"); + remove = mod.remove; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + // Non-interactive: the confirm() helper bails out unless --yes is passed. + stdin: { isTTY: false }, + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor-mute delete", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + getMonitorMuteFn.mockClear(); + deleteMonitorMuteFn.mockClear(); + }); + + test("with --yes, deletes without fetching or prompting", async () => { + const { context, stdout } = createMockContext(); + await remove.call(context, { yes: true }, "mute-1", deps); + + expect(getMonitorMuteFn).not.toHaveBeenCalled(); + expect(deleteMonitorMuteFn).toHaveBeenCalledTimes(1); + const [args] = deleteMonitorMuteFn.mock.calls[0]!; + expect((args as { id: string }).id).toBe("mute-1"); + expect(stdout.join("")).toContain("Deleted monitor mute"); + }); + + test("without --yes on a non-TTY, aborts (exit 1) and does not delete", async () => { + const { context, getExitCode } = createMockContext(); + try { + await remove.call(context, {}, "mute-1", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getMonitorMuteFn).toHaveBeenCalledTimes(1); + expect(deleteMonitorMuteFn).not.toHaveBeenCalled(); + expect(getExitCode()).toBe(1); + }); + + test("exits 1 on API error", async () => { + deleteMonitorMuteFn.mockImplementationOnce(() => { + throw new Error("not found"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await remove.call(context, { yes: true }, "missing", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor-mute/delete.ts b/src/commands/monitor-mute/delete.ts index e73f864..54a6d4d 100644 --- a/src/commands/monitor-mute/delete.ts +++ b/src/commands/monitor-mute/delete.ts @@ -31,16 +31,22 @@ async function confirm(this: LocalContext, question: string): Promise { } } -async function remove( +export interface DeleteMonitorMuteDeps { + loadConfig?: typeof loadConfig; +} + +export async function remove( this: LocalContext, flags: DeleteMonitorMuteFlags, id: string, + deps: DeleteMonitorMuteDeps = {}, ): Promise { + const { loadConfig: loadConfigImpl = loadConfig } = deps; const { process, writer: _writer } = this; const writer = muteStatusWriter(_writer, { muted: flags.json }); try { - const config = loadConfig(); + const config = loadConfigImpl(); if (!flags.yes) { // Fetch first so we can warn when deleting a rule that mutes several diff --git a/src/commands/monitor-mute/list.test.ts b/src/commands/monitor-mute/list.test.ts new file mode 100644 index 0000000..65a39db --- /dev/null +++ b/src/commands/monitor-mute/list.test.ts @@ -0,0 +1,196 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../context"; +import { MonitorMuteTargetKind } from "../../rest/generated"; +import { createWriter } from "../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../.."); +const restModulePath = resolve( + repoRoot, + "src/rest/monitor-mute/list-monitor-mutes.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const listMonitorMutesFn = mock((_args: unknown) => + Promise.resolve({ + monitorMutes: [ + { + id: "mute-1", + label: "Snooze checkout", + description: null, + target: { kind: "Monitors", monitors: [{ id: "42" }, { id: "43" }] }, + schedule: { + kind: "OneTime", + oneTime: { startTime: "t", endTime: "u" }, + }, + filter: null, + startTime: "t", + endTime: "u", + }, + { + id: "mute-2", + label: "Global maintenance", + description: null, + target: { kind: "Global", monitors: [] }, + schedule: { + kind: "OneTime", + oneTime: { startTime: "t", endTime: null }, + }, + filter: 'level == "Critical"', + startTime: "t", + endTime: null, + }, + ], + meta: { totalCount: 2 }, + }), +); + +let list: (typeof import("./list"))["list"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./list"))["list"]>[1]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(restModulePath, () => ({ + listMonitorMutes: listMonitorMutesFn, + })); + + const mod = await import("./list.ts"); + list = mod.list; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor-mute list", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + listMonitorMutesFn.mockClear(); + }); + + test("passes no filter when no flags are set", async () => { + const { context } = createMockContext(); + await list.call(context, { limit: 100, json: true }, deps); + + expect(listMonitorMutesFn).toHaveBeenCalledTimes(1); + const [args] = listMonitorMutesFn.mock.calls[0]!; + expect((args as { filter?: string }).filter).toBeUndefined(); + }); + + test("builds a label match filter from --match", async () => { + const { context } = createMockContext(); + await list.call( + context, + { limit: 100, match: "checkout", json: true }, + deps, + ); + + const [args] = listMonitorMutesFn.mock.calls[0]!; + expect((args as { filter?: string }).filter).toBe( + 'label.matches("(?i)checkout")', + ); + }); + + test("combines --match and --kind with &&", async () => { + const { context } = createMockContext(); + await list.call( + context, + { + limit: 100, + match: "checkout", + kind: MonitorMuteTargetKind.Global, + json: true, + }, + deps, + ); + + const [args] = listMonitorMutesFn.mock.calls[0]!; + expect((args as { filter?: string }).filter).toBe( + 'label.matches("(?i)checkout") && target.kind == "Global"', + ); + }); + + test("emits the resolved mute rules as JSON", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { limit: 100, json: true }, deps); + + const output = JSON.parse(stdout.join("")); + expect(output).toHaveLength(2); + expect(output[0].id).toBe("mute-1"); + }); + + test("renders an empty-state message when nothing matches", async () => { + listMonitorMutesFn.mockImplementationOnce(() => + Promise.resolve({ monitorMutes: [], meta: { totalCount: 0 } }), + ); + const { context, stdout } = createMockContext(); + await list.call(context, { limit: 100 }, deps); + + expect(stdout.join("")).toContain("No monitor mutes found."); + }); +}); diff --git a/src/commands/monitor-mute/list.ts b/src/commands/monitor-mute/list.ts index ce28188..12f8c63 100644 --- a/src/commands/monitor-mute/list.ts +++ b/src/commands/monitor-mute/list.ts @@ -92,10 +92,16 @@ const FIELD_COLUMNS = { }), } satisfies Record>; -async function list( +export interface ListMonitorMutesDeps { + loadConfig?: typeof loadConfig; +} + +export async function list( this: LocalContext, flags: ListMonitorMutesFlags, + deps: ListMonitorMutesDeps = {}, ): Promise { + const { loadConfig: loadConfigImpl = loadConfig } = deps; const { process, writer: _writer } = this; const format = flags.json ? ("json" as const) : flags.format; @@ -104,7 +110,7 @@ async function list( }); try { - const config = loadConfig(); + const config = loadConfigImpl(); writer.info("Searching monitor mutes..."); diff --git a/src/commands/monitor-mute/update.test.ts b/src/commands/monitor-mute/update.test.ts new file mode 100644 index 0000000..025a538 --- /dev/null +++ b/src/commands/monitor-mute/update.test.ts @@ -0,0 +1,147 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../.."); +const restModulePath = resolve( + repoRoot, + "src/rest/monitor-mute/update-monitor-mute.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const updateMonitorMuteFn = mock((_args: unknown) => + Promise.resolve({ id: "mute-1", label: "renamed" }), +); + +let update: (typeof import("./update"))["update"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./update"))["update"]>[2]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(restModulePath, () => ({ + updateMonitorMute: updateMonitorMuteFn, + })); + + const mod = await import("./update.ts"); + update = mod.update; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor-mute update", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + updateMonitorMuteFn.mockClear(); + }); + + test("forwards id and parsed body to updateMonitorMute", async () => { + const { context, stdout } = createMockContext(); + await update.call( + context, + { data: '{"label":"renamed"}', json: true }, + "mute-1", + deps, + ); + + expect(updateMonitorMuteFn).toHaveBeenCalledTimes(1); + const [args] = updateMonitorMuteFn.mock.calls[0]!; + expect(args).toMatchObject({ id: "mute-1", body: { label: "renamed" } }); + const output = JSON.parse(stdout.join("")); + expect(output.label).toBe("renamed"); + }); + + test("exits 1 on invalid JSON without calling the API", async () => { + const { context, getExitCode } = createMockContext(); + try { + await update.call(context, { data: "nope" }, "mute-1", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(updateMonitorMuteFn).not.toHaveBeenCalled(); + }); + + test("exits 1 on API error", async () => { + updateMonitorMuteFn.mockImplementationOnce(() => { + throw new Error("bad schedule"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.call(context, { data: '{"label":"x"}' }, "mute-1", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor-mute/update.ts b/src/commands/monitor-mute/update.ts index 767afe9..4c83a05 100644 --- a/src/commands/monitor-mute/update.ts +++ b/src/commands/monitor-mute/update.ts @@ -21,11 +21,17 @@ Example body: "schedule": { "kind": "OneTime", "oneTime": { "startTime": "2026-06-23T18:00:00Z", "endTime": "2026-06-23T22:00:00Z" } } }`; -async function update( +export interface UpdateMonitorMuteDeps { + loadConfig?: typeof loadConfig; +} + +export async function update( this: LocalContext, flags: UpdateMonitorMuteFlags, id: string, + deps: UpdateMonitorMuteDeps = {}, ): Promise { + const { loadConfig: loadConfigImpl = loadConfig } = deps; const { process, writer: _writer } = this; const writer = muteStatusWriter(_writer, { muted: flags.json }); @@ -40,7 +46,7 @@ async function update( } try { - const config = loadConfig(); + const config = loadConfigImpl(); writer.info("Updating monitor mute..."); diff --git a/src/commands/monitor-mute/view.test.ts b/src/commands/monitor-mute/view.test.ts new file mode 100644 index 0000000..fcabdd7 --- /dev/null +++ b/src/commands/monitor-mute/view.test.ts @@ -0,0 +1,140 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../context"; +import { createWriter } from "../../lib/writer"; + +const repoRoot = resolve(import.meta.dir, "../../.."); +const restModulePath = resolve( + repoRoot, + "src/rest/monitor-mute/get-monitor-mute.ts", +); + +const loadConfigFn = mock(() => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", +})); + +const getMonitorMuteFn = mock((_args: unknown) => + Promise.resolve({ + id: "mute-1", + label: "Snooze checkout", + description: "noisy deploy", + target: { + kind: "Monitors", + monitors: [{ id: "42", record: { label: "Checkout latency" } }], + }, + schedule: { kind: "OneTime", oneTime: { startTime: "t", endTime: "u" } }, + filter: null, + startTime: "t", + endTime: "u", + createdBy: { id: "u1", label: "Ada" }, + createdAt: "t", + updatedBy: { id: "u1", label: "Ada" }, + updatedAt: "t", + }), +); + +let view: (typeof import("./view"))["view"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, +} as Parameters<(typeof import("./view"))["view"]>[2]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + void mock.module(restModulePath, () => ({ + getMonitorMute: getMonitorMuteFn, + })); + + const mod = await import("./view.ts"); + view = mod.view; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor-mute view", () => { + beforeEach(() => { + loadConfigFn.mockClear(); + getMonitorMuteFn.mockClear(); + }); + + test("fetches by id and emits JSON", async () => { + const { context, stdout } = createMockContext(); + await view.call(context, { json: true }, "mute-1", deps); + + expect(getMonitorMuteFn).toHaveBeenCalledTimes(1); + const [args] = getMonitorMuteFn.mock.calls[0]!; + expect((args as { id: string }).id).toBe("mute-1"); + const output = JSON.parse(stdout.join("")); + expect(output.id).toBe("mute-1"); + }); + + test("renders the mute details in the default view", async () => { + const { context, stdout } = createMockContext(); + await view.call(context, {}, "mute-1", deps); + + const out = stdout.join(""); + expect(out).toContain("Monitor mute mute-1"); + expect(out).toContain("Snooze checkout"); + }); +}); diff --git a/src/commands/monitor-mute/view.ts b/src/commands/monitor-mute/view.ts index f6381ce..b8757ff 100644 --- a/src/commands/monitor-mute/view.ts +++ b/src/commands/monitor-mute/view.ts @@ -45,11 +45,17 @@ function buildViewData(mute: MonitorMuteResource) { }; } -async function view( +export interface ViewMonitorMuteDeps { + loadConfig?: typeof loadConfig; +} + +export async function view( this: LocalContext, flags: ViewMonitorMuteFlags, id: string, + deps: ViewMonitorMuteDeps = {}, ): Promise { + const { loadConfig: loadConfigImpl = loadConfig } = deps; const format = flags.json ? ("json" as const) : flags.format; const { process, writer: _writer } = this; const writer = muteStatusWriter(_writer, { @@ -57,7 +63,7 @@ async function view( }); try { - const config = loadConfig(); + const config = loadConfigImpl(); writer.info("Fetching monitor mute..."); From 0151aa26a592047fedcfdd68d0340adfef422828 Mon Sep 17 00:00:00 2001 From: "david.lotfi" Date: Wed, 24 Jun 2026 20:55:22 +0000 Subject: [PATCH 3/3] Added flags + parsing instead of raw data & rename --- AGENTS.md | 3 +- README.md | 10 +- src/app.ts | 4 +- src/commands/monitor-mute/create.ts | 111 ---------- src/commands/monitor-mute/update.ts | 117 ----------- src/commands/monitor/index.ts | 17 ++ .../mute}/create.test.ts | 111 ++++++++-- src/commands/monitor/mute/create.ts | 192 ++++++++++++++++++ .../mute}/delete.test.ts | 6 +- .../{monitor-mute => monitor/mute}/delete.ts | 14 +- .../{monitor-mute => monitor/mute}/index.ts | 0 .../mute}/list.test.ts | 8 +- .../{monitor-mute => monitor/mute}/list.ts | 20 +- src/commands/monitor/mute/parse.ts | 98 +++++++++ .../mute}/update.test.ts | 65 +++++- src/commands/monitor/mute/update.ts | 188 +++++++++++++++++ .../mute}/view.test.ts | 6 +- .../{monitor-mute => monitor/mute}/view.ts | 16 +- src/lib/json-input.ts | 30 --- 19 files changed, 685 insertions(+), 331 deletions(-) delete mode 100644 src/commands/monitor-mute/create.ts delete mode 100644 src/commands/monitor-mute/update.ts create mode 100644 src/commands/monitor/index.ts rename src/commands/{monitor-mute => monitor/mute}/create.test.ts (54%) create mode 100644 src/commands/monitor/mute/create.ts rename src/commands/{monitor-mute => monitor/mute}/delete.test.ts (96%) rename src/commands/{monitor-mute => monitor/mute}/delete.ts (87%) rename src/commands/{monitor-mute => monitor/mute}/index.ts (100%) rename src/commands/{monitor-mute => monitor/mute}/list.test.ts (95%) rename src/commands/{monitor-mute => monitor/mute}/list.ts (92%) create mode 100644 src/commands/monitor/mute/parse.ts rename src/commands/{monitor-mute => monitor/mute}/update.test.ts (62%) create mode 100644 src/commands/monitor/mute/update.ts rename src/commands/{monitor-mute => monitor/mute}/view.test.ts (95%) rename src/commands/{monitor-mute => monitor/mute}/view.ts (86%) delete mode 100644 src/lib/json-input.ts diff --git a/AGENTS.md b/AGENTS.md index 176e231..6ca762e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,8 @@ src/ ├── context.ts # CLI context (process, env) ├── commands/ # CLI commands organized by resource │ ├── alert/ # Alert commands (list, view) -│ ├── monitor-mute/ # Monitor mute commands (list, view, create, update, delete) +│ ├── monitor/ # Monitor commands +│ │ └── mute/ # Monitor mute commands (list, view, create, update, delete) │ ├── auth/ # Auth commands (configure, login, logout, status) │ ├── cli/ # CLI management (install, uninstall, upgrade) │ ├── content/ # Content pack management diff --git a/README.md b/README.md index 42518bd..3ae66b6 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe skill view` | View skill details and content | | `observe alert list` | List alerts with severity and status filtering | | `observe alert view` | View full alert details | -| `observe monitor-mute list` | List and search monitor mute rules | -| `observe monitor-mute view` | View full monitor mute rule details | -| `observe monitor-mute create` | Create a monitor mute rule | -| `observe monitor-mute update` | Update a monitor mute rule | -| `observe monitor-mute delete` | Delete a monitor mute rule | +| `observe monitor mute list` | List and search monitor mute rules | +| `observe monitor mute view` | View full monitor mute rule details | +| `observe monitor mute create` | Create a monitor mute rule | +| `observe monitor mute update` | Update a monitor mute rule | +| `observe monitor mute delete` | Delete a monitor mute rule | | `observe content host install` | Install Host Explorer content | | `observe content host view` | View Host Explorer content | | `observe content kubernetes install` | Install Kubernetes Explorer content | diff --git a/src/app.ts b/src/app.ts index d6a7111..0b01528 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,7 @@ import { buildApplication, buildRouteMap } from "@stricli/core"; import { name } from "../package.json"; import { alertRoutes } from "./commands/alert/index.js"; -import { monitorMuteRoutes } from "./commands/monitor-mute/index.js"; +import { monitorRoutes } from "./commands/monitor/index.js"; import { dataConnectionRoutes } from "./commands/data-connection/index.js"; import { datasourceRoutes } from "./commands/datasource/index.js"; import { datastreamTokenRoutes } from "./commands/datastream-token/index.js"; @@ -31,7 +31,7 @@ export const routes = buildRouteMap({ query: queryCommand, skill: skillRoutes, alert: alertRoutes, - "monitor-mute": monitorMuteRoutes, + monitor: monitorRoutes, content: contentRoutes, "ingest-token": ingestTokenRoutes, datasource: datasourceRoutes, diff --git a/src/commands/monitor-mute/create.ts b/src/commands/monitor-mute/create.ts deleted file mode 100644 index 9395bd6..0000000 --- a/src/commands/monitor-mute/create.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { buildCommand } from "@stricli/core"; -import chalk from "chalk"; -import type { LocalContext } from "../../context"; -import { createMonitorMute } from "../../rest/monitor-mute/create-monitor-mute"; -import { type MonitorMuteCreateRequest } from "../../rest/generated"; -import { loadConfig } from "../../lib/config"; -import { formatApiError } from "../../lib/format-error"; -import { muteStatusWriter } from "../../lib/writer"; -import { loadJsonInput } from "../../lib/json-input"; - -interface CreateMonitorMuteFlags { - data?: string; - file?: string; - json?: boolean; -} - -const BODY_EXAMPLE = `Example body: -{ - "label": "Snooze checkout during deploy", - "target": { "kind": "Monitors", "monitors": [{ "id": "41000001" }] }, - "schedule": { "kind": "OneTime", "oneTime": { "startTime": "2026-06-23T18:00:00Z", "endTime": "2026-06-23T20:00:00Z" } } -}`; - -export interface CreateMonitorMuteDeps { - loadConfig?: typeof loadConfig; -} - -export async function create( - this: LocalContext, - flags: CreateMonitorMuteFlags, - deps: CreateMonitorMuteDeps = {}, -): Promise { - const { loadConfig: loadConfigImpl = loadConfig } = deps; - const { process, writer: _writer } = this; - const writer = muteStatusWriter(_writer, { muted: flags.json }); - - let body: MonitorMuteCreateRequest; - try { - body = loadJsonInput(flags.data, flags.file) as MonitorMuteCreateRequest; - } catch (e) { - writer.error(e instanceof Error ? e.message : String(e)); - writer.info(`\n${BODY_EXAMPLE}`); - process.exit(1); - return; - } - - try { - const config = loadConfigImpl(); - - writer.info("Creating monitor mute..."); - - const mute = await createMonitorMute({ config, body }); - - if (flags.json) { - writer.write(JSON.stringify(mute, null, 2)); - return; - } - - writer.write( - chalk.green(`Created monitor mute `) + - chalk.bold(mute.id) + - chalk.green(` — "${mute.label}"`), - ); - } catch (error) { - writer.error(`Error: ${await formatApiError(error)}`); - process.exit(1); - } -} - -export const createCommand = buildCommand({ - loader: async () => create, - parameters: { - positional: { - kind: "tuple", - parameters: [], - }, - flags: { - data: { - kind: "parsed", - parse: String, - brief: "Mute rule body as an inline JSON string", - optional: true, - }, - file: { - kind: "parsed", - parse: String, - brief: "Path to a JSON file containing the mute rule body", - optional: true, - }, - json: { - kind: "boolean", - brief: "Output the created mute rule as JSON", - optional: true, - }, - }, - aliases: { - d: "data", - f: "file", - }, - }, - docs: { - brief: "Create a monitor mute rule", - fullDescription: [ - "Create a monitor mute rule (snooze) via the /v1/monitor-mutes REST API.", - "", - "Provide the request body with --data '' or --file .", - "", - BODY_EXAMPLE, - ].join("\n"), - }, -}); diff --git a/src/commands/monitor-mute/update.ts b/src/commands/monitor-mute/update.ts deleted file mode 100644 index 4c83a05..0000000 --- a/src/commands/monitor-mute/update.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { buildCommand } from "@stricli/core"; -import chalk from "chalk"; -import type { LocalContext } from "../../context"; -import { updateMonitorMute } from "../../rest/monitor-mute/update-monitor-mute"; -import { type MonitorMuteUpdateRequest } from "../../rest/generated"; -import { loadConfig } from "../../lib/config"; -import { formatApiError } from "../../lib/format-error"; -import { muteStatusWriter } from "../../lib/writer"; -import { loadJsonInput } from "../../lib/json-input"; - -interface UpdateMonitorMuteFlags { - data?: string; - file?: string; - json?: boolean; -} - -const BODY_EXAMPLE = `Only the fields you provide are changed (JSON Merge Patch). -Example body: -{ - "label": "Snooze checkout (extended)", - "schedule": { "kind": "OneTime", "oneTime": { "startTime": "2026-06-23T18:00:00Z", "endTime": "2026-06-23T22:00:00Z" } } -}`; - -export interface UpdateMonitorMuteDeps { - loadConfig?: typeof loadConfig; -} - -export async function update( - this: LocalContext, - flags: UpdateMonitorMuteFlags, - id: string, - deps: UpdateMonitorMuteDeps = {}, -): Promise { - const { loadConfig: loadConfigImpl = loadConfig } = deps; - const { process, writer: _writer } = this; - const writer = muteStatusWriter(_writer, { muted: flags.json }); - - let body: MonitorMuteUpdateRequest; - try { - body = loadJsonInput(flags.data, flags.file) as MonitorMuteUpdateRequest; - } catch (e) { - writer.error(e instanceof Error ? e.message : String(e)); - writer.info(`\n${BODY_EXAMPLE}`); - process.exit(1); - return; - } - - try { - const config = loadConfigImpl(); - - writer.info("Updating monitor mute..."); - - const mute = await updateMonitorMute({ config, id, body }); - - if (flags.json) { - writer.write(JSON.stringify(mute, null, 2)); - return; - } - - writer.write( - chalk.green(`Updated monitor mute `) + - chalk.bold(mute.id) + - chalk.green(` — "${mute.label}"`), - ); - } catch (error) { - writer.error(`Error: ${await formatApiError(error)}`); - process.exit(1); - } -} - -export const updateCommand = buildCommand({ - loader: async () => update, - parameters: { - positional: { - kind: "tuple", - parameters: [ - { - brief: "Monitor mute rule ID", - parse: String, - }, - ], - }, - flags: { - data: { - kind: "parsed", - parse: String, - brief: "Partial mute rule body as an inline JSON string", - optional: true, - }, - file: { - kind: "parsed", - parse: String, - brief: "Path to a JSON file containing the partial mute rule body", - optional: true, - }, - json: { - kind: "boolean", - brief: "Output the updated mute rule as JSON", - optional: true, - }, - }, - aliases: { - d: "data", - f: "file", - }, - }, - docs: { - brief: "Update a monitor mute rule", - fullDescription: [ - "Update a monitor mute rule (snooze) by ID via the /v1/monitor-mutes REST API.", - "", - "Provide the partial body with --data '' or --file .", - "", - BODY_EXAMPLE, - ].join("\n"), - }, -}); diff --git a/src/commands/monitor/index.ts b/src/commands/monitor/index.ts new file mode 100644 index 0000000..c427504 --- /dev/null +++ b/src/commands/monitor/index.ts @@ -0,0 +1,17 @@ +import { buildRouteMap } from "@stricli/core"; +import { monitorMuteRoutes } from "./mute/index"; + +export const monitorRoutes = buildRouteMap({ + routes: { + mute: monitorMuteRoutes, + }, + docs: { + brief: "Manage monitors", + fullDescription: [ + "Manage monitors in Observe.", + "", + "Commands:", + " mute Manage monitor mute rules (snoozes)", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor-mute/create.test.ts b/src/commands/monitor/mute/create.test.ts similarity index 54% rename from src/commands/monitor-mute/create.test.ts rename to src/commands/monitor/mute/create.test.ts index aa2d091..825d3bf 100644 --- a/src/commands/monitor-mute/create.test.ts +++ b/src/commands/monitor/mute/create.test.ts @@ -8,10 +8,10 @@ import { test, } from "bun:test"; import { resolve } from "node:path"; -import type { LocalContext } from "../../context"; -import { createWriter } from "../../lib/writer"; +import type { LocalContext } from "../../../context"; +import { createWriter } from "../../../lib/writer"; -const repoRoot = resolve(import.meta.dir, "../../.."); +const repoRoot = resolve(import.meta.dir, "../../../.."); const restModulePath = resolve( repoRoot, "src/rest/monitor-mute/create-monitor-mute.ts", @@ -96,53 +96,120 @@ function createMockContext() { return { context, stdout, stderr, getExitCode: () => exitCode }; } -const VALID_BODY = JSON.stringify({ - label: "Snooze checkout", - target: { kind: "Monitors", monitors: [{ id: "42" }] }, - schedule: { kind: "OneTime", oneTime: { startTime: "2026-06-23T18:00:00Z" } }, -}); - -describe("monitor-mute create", () => { +describe("monitor mute create", () => { beforeEach(() => { loadConfigFn.mockClear(); createMonitorMuteFn.mockClear(); }); - test("parses --data and forwards the body to createMonitorMute", async () => { + test("builds the request body from flags and forwards it", async () => { const { context, stdout } = createMockContext(); - await create.call(context, { data: VALID_BODY, json: true }, deps); + await create.call( + context, + { + label: "Snooze checkout", + monitors: ["42", "43"], + start: "2026-06-23T18:00:00Z", + end: "2026-06-23T20:00:00Z", + json: true, + }, + deps, + ); expect(createMonitorMuteFn).toHaveBeenCalledTimes(1); const [args] = createMonitorMuteFn.mock.calls[0]!; expect((args as { body: unknown }).body).toMatchObject({ label: "Snooze checkout", - target: { kind: "Monitors", monitors: [{ id: "42" }] }, + target: { kind: "Monitors", monitors: [{ id: "42" }, { id: "43" }] }, + schedule: { + kind: "OneTime", + oneTime: { + startTime: "2026-06-23T18:00:00Z", + endTime: "2026-06-23T20:00:00Z", + }, + }, }); const output = JSON.parse(stdout.join("")); expect(output.id).toBe("mute-1"); }); - test("exits 1 on invalid JSON without calling the API", async () => { - const { context, getExitCode } = createMockContext(); + test("builds a recurring + global body (with filter)", async () => { + const { context } = createMockContext(); + await create.call( + context, + { + label: "Weekday business hours", + global: true, + filter: 'level == "Critical"', + cron: "0 9 * * 1-5", + timezone: "America/Los_Angeles", + duration: 3600, + json: true, + }, + deps, + ); + + const [args] = createMonitorMuteFn.mock.calls[0]!; + expect((args as { body: unknown }).body).toMatchObject({ + target: { kind: "Global" }, + filter: 'level == "Critical"', + schedule: { + kind: "Recurring", + recurring: { + cronSchedule: { + rawCron: "0 9 * * 1-5", + timezone: "America/Los_Angeles", + }, + durationSeconds: 3600, + }, + }, + }); + }); + + test("exits 1 when no target is given", async () => { + const { context, getExitCode, stderr } = createMockContext(); + try { + await create.call( + context, + { label: "x", start: "2026-06-23T18:00:00Z", json: true }, + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("target"); + expect(createMonitorMuteFn).not.toHaveBeenCalled(); + }); + + test("exits 1 when no schedule is given", async () => { + const { context, getExitCode, stderr } = createMockContext(); try { - await create.call(context, { data: "{not json", json: true }, deps); + await create.call(context, { label: "x", monitors: ["42"] }, deps); throw new Error("expected process.exit"); } catch (error) { expect((error as Error).message).toBe("process.exit"); } expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("schedule"); expect(createMonitorMuteFn).not.toHaveBeenCalled(); }); - test("exits 1 when no body is provided", async () => { - const { context, getExitCode } = createMockContext(); + test("exits 1 when --global is used without --filter", async () => { + const { context, getExitCode, stderr } = createMockContext(); try { - await create.call(context, { json: true }, deps); + await create.call( + context, + { label: "x", global: true, start: "2026-06-23T18:00:00Z" }, + deps, + ); throw new Error("expected process.exit"); } catch (error) { expect((error as Error).message).toBe("process.exit"); } expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("--filter"); expect(createMonitorMuteFn).not.toHaveBeenCalled(); }); @@ -152,7 +219,11 @@ describe("monitor-mute create", () => { }); const { context, stderr, getExitCode } = createMockContext(); try { - await create.call(context, { data: VALID_BODY }, deps); + await create.call( + context, + { label: "x", monitors: ["42"], start: "2026-06-23T18:00:00Z" }, + deps, + ); throw new Error("expected process.exit"); } catch (error) { expect((error as Error).message).toBe("process.exit"); diff --git a/src/commands/monitor/mute/create.ts b/src/commands/monitor/mute/create.ts new file mode 100644 index 0000000..402b3cc --- /dev/null +++ b/src/commands/monitor/mute/create.ts @@ -0,0 +1,192 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../../context"; +import { createMonitorMute } from "../../../rest/monitor-mute/create-monitor-mute"; +import { + MonitorMuteTargetKind, + type MonitorMuteCreateRequest, +} from "../../../rest/generated"; +import { loadConfig } from "../../../lib/config"; +import { formatApiError } from "../../../lib/format-error"; +import { muteStatusWriter } from "../../../lib/writer"; +import { parseNonNegativeInt } from "../../../lib/parsers"; +import { buildSchedule, buildTarget, parseMonitorIds } from "./parse"; + +interface CreateMonitorMuteFlags { + label?: string; + description?: string; + monitors?: string[]; + global?: boolean; + filter?: string; + start?: string; + end?: string; + cron?: string; + timezone?: string; + duration?: number; + json?: boolean; +} + +export interface CreateMonitorMuteDeps { + loadConfig?: typeof loadConfig; +} + +export async function create( + this: LocalContext, + flags: CreateMonitorMuteFlags, + deps: CreateMonitorMuteDeps = {}, +): Promise { + const { loadConfig: loadConfigImpl = loadConfig } = deps; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { muted: flags.json }); + + let body: MonitorMuteCreateRequest; + try { + if (!flags.label) { + throw new Error("--label is required."); + } + const target = buildTarget(flags); + if (!target) { + throw new Error( + "Specify a target: --monitors for specific monitors, or --global for all monitors.", + ); + } + const schedule = buildSchedule(flags); + if (!schedule) { + throw new Error( + "Specify a schedule: --start [--end ] for a one-time window, or --cron/--timezone/--duration for a recurring one.", + ); + } + if (target.kind === MonitorMuteTargetKind.Global && !flags.filter) { + throw new Error("--filter is required when muting --global."); + } + body = { + label: flags.label, + description: flags.description, + target, + schedule, + filter: flags.filter, + }; + } catch (e) { + writer.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + return; + } + + try { + const config = loadConfigImpl(); + + writer.info("Creating monitor mute..."); + + const mute = await createMonitorMute({ config, body }); + + if (flags.json) { + writer.write(JSON.stringify(mute, null, 2)); + return; + } + + writer.write( + chalk.green(`Created monitor mute `) + + chalk.bold(mute.id) + + chalk.green(` — "${mute.label}"`), + ); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const createCommand = buildCommand({ + loader: async () => create, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + label: { + kind: "parsed", + parse: String, + brief: "Human-readable name for the mute rule", + optional: true, + }, + description: { + kind: "parsed", + parse: String, + brief: "Optional free-form description", + optional: true, + }, + monitors: { + kind: "parsed", + parse: parseMonitorIds, + brief: "Comma-separated monitor IDs to mute (target kind: Monitors)", + optional: true, + }, + global: { + kind: "boolean", + brief: "Mute all monitors (target kind: Global); requires --filter", + optional: true, + }, + filter: { + kind: "parsed", + parse: String, + brief: + 'CEL expression evaluated per fired alarm; required with --global (e.g. level == "Critical")', + optional: true, + }, + start: { + kind: "parsed", + parse: String, + brief: "One-time window start (ISO-8601, e.g. 2026-06-23T18:00:00Z)", + optional: true, + }, + end: { + kind: "parsed", + parse: String, + brief: "One-time window end (ISO-8601); omit for open-ended", + optional: true, + }, + cron: { + kind: "parsed", + parse: String, + brief: "Recurring schedule cron expression (e.g. '0 9 * * 1-5')", + optional: true, + }, + timezone: { + kind: "parsed", + parse: String, + brief: "Recurring schedule IANA timezone (e.g. America/Los_Angeles)", + optional: true, + }, + duration: { + kind: "parsed", + parse: parseNonNegativeInt, + brief: "Recurring schedule duration per occurrence, in seconds", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output the created mute rule as JSON", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "Create a monitor mute rule", + fullDescription: [ + "Create a monitor mute rule (snooze) via the /v1/monitor-mutes REST API.", + "", + "Target (one required):", + " --monitors mute specific monitors", + " --global mute all monitors (requires --filter)", + "", + "Schedule (one required):", + " --start [--end ] one-time window", + " --cron --timezone --duration recurring", + "", + "Example:", + " observe monitor mute create --label 'Snooze checkout' \\", + " --monitors 41000001 --start 2026-06-23T18:00:00Z --end 2026-06-23T20:00:00Z", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor-mute/delete.test.ts b/src/commands/monitor/mute/delete.test.ts similarity index 96% rename from src/commands/monitor-mute/delete.test.ts rename to src/commands/monitor/mute/delete.test.ts index 4e392d5..6122fe2 100644 --- a/src/commands/monitor-mute/delete.test.ts +++ b/src/commands/monitor/mute/delete.test.ts @@ -8,10 +8,10 @@ import { test, } from "bun:test"; import { resolve } from "node:path"; -import type { LocalContext } from "../../context"; -import { createWriter } from "../../lib/writer"; +import type { LocalContext } from "../../../context"; +import { createWriter } from "../../../lib/writer"; -const repoRoot = resolve(import.meta.dir, "../../.."); +const repoRoot = resolve(import.meta.dir, "../../../.."); const getModulePath = resolve( repoRoot, "src/rest/monitor-mute/get-monitor-mute.ts", diff --git a/src/commands/monitor-mute/delete.ts b/src/commands/monitor/mute/delete.ts similarity index 87% rename from src/commands/monitor-mute/delete.ts rename to src/commands/monitor/mute/delete.ts index 54a6d4d..17b4aaa 100644 --- a/src/commands/monitor-mute/delete.ts +++ b/src/commands/monitor/mute/delete.ts @@ -1,13 +1,13 @@ import { buildCommand } from "@stricli/core"; import chalk from "chalk"; import { createInterface } from "node:readline/promises"; -import type { LocalContext } from "../../context"; -import { getMonitorMute } from "../../rest/monitor-mute/get-monitor-mute"; -import { deleteMonitorMute } from "../../rest/monitor-mute/delete-monitor-mute"; -import { MonitorMuteTargetKind } from "../../rest/generated"; -import { loadConfig } from "../../lib/config"; -import { formatApiError } from "../../lib/format-error"; -import { muteStatusWriter } from "../../lib/writer"; +import type { LocalContext } from "../../../context"; +import { getMonitorMute } from "../../../rest/monitor-mute/get-monitor-mute"; +import { deleteMonitorMute } from "../../../rest/monitor-mute/delete-monitor-mute"; +import { MonitorMuteTargetKind } from "../../../rest/generated"; +import { loadConfig } from "../../../lib/config"; +import { formatApiError } from "../../../lib/format-error"; +import { muteStatusWriter } from "../../../lib/writer"; interface DeleteMonitorMuteFlags { yes?: boolean; diff --git a/src/commands/monitor-mute/index.ts b/src/commands/monitor/mute/index.ts similarity index 100% rename from src/commands/monitor-mute/index.ts rename to src/commands/monitor/mute/index.ts diff --git a/src/commands/monitor-mute/list.test.ts b/src/commands/monitor/mute/list.test.ts similarity index 95% rename from src/commands/monitor-mute/list.test.ts rename to src/commands/monitor/mute/list.test.ts index 65a39db..71136e2 100644 --- a/src/commands/monitor-mute/list.test.ts +++ b/src/commands/monitor/mute/list.test.ts @@ -8,11 +8,11 @@ import { test, } from "bun:test"; import { resolve } from "node:path"; -import type { LocalContext } from "../../context"; -import { MonitorMuteTargetKind } from "../../rest/generated"; -import { createWriter } from "../../lib/writer"; +import type { LocalContext } from "../../../context"; +import { MonitorMuteTargetKind } from "../../../rest/generated"; +import { createWriter } from "../../../lib/writer"; -const repoRoot = resolve(import.meta.dir, "../../.."); +const repoRoot = resolve(import.meta.dir, "../../../.."); const restModulePath = resolve( repoRoot, "src/rest/monitor-mute/list-monitor-mutes.ts", diff --git a/src/commands/monitor-mute/list.ts b/src/commands/monitor/mute/list.ts similarity index 92% rename from src/commands/monitor-mute/list.ts rename to src/commands/monitor/mute/list.ts index 12f8c63..82b3c90 100644 --- a/src/commands/monitor-mute/list.ts +++ b/src/commands/monitor/mute/list.ts @@ -1,22 +1,22 @@ import { buildCommand } from "@stricli/core"; import chalk from "chalk"; -import type { LocalContext } from "../../context"; -import { listMonitorMutes } from "../../rest/monitor-mute/list-monitor-mutes"; +import type { LocalContext } from "../../../context"; +import { listMonitorMutes } from "../../../rest/monitor-mute/list-monitor-mutes"; import { type MonitorMuteResource, MonitorMuteTargetKind, -} from "../../rest/generated"; -import { celMatchesInsensitive } from "../../lib/cel"; -import { loadConfig } from "../../lib/config"; -import { formatApiError } from "../../lib/format-error"; -import { muteStatusWriter } from "../../lib/writer"; -import { parseNonNegativeInt } from "../../lib/parsers"; +} from "../../../rest/generated"; +import { celMatchesInsensitive } from "../../../lib/cel"; +import { loadConfig } from "../../../lib/config"; +import { formatApiError } from "../../../lib/format-error"; +import { muteStatusWriter } from "../../../lib/writer"; +import { parseNonNegativeInt } from "../../../lib/parsers"; import { formatTable, createColumnHelper, type ColumnDef, -} from "../../lib/formatters/table"; -import { renderAsCSV } from "../../lib/formatters/csv"; +} from "../../../lib/formatters/table"; +import { renderAsCSV } from "../../../lib/formatters/csv"; type OutputFormat = "json" | "csv"; type SortField = "id" | "label" | "createdAt" | "updatedAt"; diff --git a/src/commands/monitor/mute/parse.ts b/src/commands/monitor/mute/parse.ts new file mode 100644 index 0000000..175df0c --- /dev/null +++ b/src/commands/monitor/mute/parse.ts @@ -0,0 +1,98 @@ +import { + MonitorMuteScheduleKind, + type MonitorMuteScheduleInput, + MonitorMuteTargetKind, + type MonitorMuteTargetInput, +} from "../../../rest/generated"; + +/** + * Flags shared by `create` and `update` that map onto a mute rule's `target` + * and `schedule`. Both commands build these the same way; only their + * required/optional handling differs (create requires them, update is partial). + */ +export interface MonitorMuteFieldFlags { + monitors?: string[]; + global?: boolean; + start?: string; + end?: string; + cron?: string; + timezone?: string; + duration?: number; +} + +/** `--global` → Global; `--monitors a,b` → Monitors; neither → undefined. */ +export function buildTarget( + flags: MonitorMuteFieldFlags, +): MonitorMuteTargetInput | undefined { + if (flags.global && flags.monitors && flags.monitors.length > 0) { + throw new Error("Pass either --global or --monitors, not both."); + } + if (flags.global) { + return { kind: MonitorMuteTargetKind.Global }; + } + if (flags.monitors && flags.monitors.length > 0) { + return { + kind: MonitorMuteTargetKind.Monitors, + monitors: flags.monitors.map((id) => ({ id })), + }; + } + return undefined; +} + +/** + * `--start [--end]` → OneTime; `--cron --timezone --duration` → Recurring; + * neither → undefined. The two schedule shapes are mutually exclusive. + */ +export function buildSchedule( + flags: MonitorMuteFieldFlags, +): MonitorMuteScheduleInput | undefined { + const hasRecurring = + flags.cron !== undefined || + flags.timezone !== undefined || + flags.duration !== undefined; + const hasOneTime = flags.start !== undefined || flags.end !== undefined; + + if (hasRecurring && hasOneTime) { + throw new Error( + "Pass either a one-time window (--start/--end) or a recurring schedule " + + "(--cron/--timezone/--duration), not both.", + ); + } + + if (hasRecurring) { + if (!flags.cron || !flags.timezone || flags.duration === undefined) { + throw new Error( + "A recurring schedule requires --cron, --timezone, and --duration together.", + ); + } + return { + kind: MonitorMuteScheduleKind.Recurring, + recurring: { + cronSchedule: { rawCron: flags.cron, timezone: flags.timezone }, + durationSeconds: flags.duration, + }, + }; + } + + if (hasOneTime) { + if (!flags.start) { + throw new Error( + "A one-time window requires --start (and optionally --end).", + ); + } + return { + kind: MonitorMuteScheduleKind.OneTime, + oneTime: { startTime: flags.start, endTime: flags.end ?? null }, + }; + } + + return undefined; +} + +/** Parse a comma-separated `--monitors` value into a list of ids. */ +export function parseMonitorIds(value: string): string[] { + return value + .split(",") + .map((id) => id.trim()) + .filter(Boolean); +} diff --git a/src/commands/monitor-mute/update.test.ts b/src/commands/monitor/mute/update.test.ts similarity index 62% rename from src/commands/monitor-mute/update.test.ts rename to src/commands/monitor/mute/update.test.ts index 025a538..e35b85a 100644 --- a/src/commands/monitor-mute/update.test.ts +++ b/src/commands/monitor/mute/update.test.ts @@ -8,10 +8,10 @@ import { test, } from "bun:test"; import { resolve } from "node:path"; -import type { LocalContext } from "../../context"; -import { createWriter } from "../../lib/writer"; +import type { LocalContext } from "../../../context"; +import { createWriter } from "../../../lib/writer"; -const repoRoot = resolve(import.meta.dir, "../../.."); +const repoRoot = resolve(import.meta.dir, "../../../.."); const restModulePath = resolve( repoRoot, "src/rest/monitor-mute/update-monitor-mute.ts", @@ -96,17 +96,17 @@ function createMockContext() { return { context, stdout, stderr, getExitCode: () => exitCode }; } -describe("monitor-mute update", () => { +describe("monitor mute update", () => { beforeEach(() => { loadConfigFn.mockClear(); updateMonitorMuteFn.mockClear(); }); - test("forwards id and parsed body to updateMonitorMute", async () => { + test("sends only the provided fields as a partial body", async () => { const { context, stdout } = createMockContext(); await update.call( context, - { data: '{"label":"renamed"}', json: true }, + { label: "renamed", json: true }, "mute-1", deps, ); @@ -114,19 +114,64 @@ describe("monitor-mute update", () => { expect(updateMonitorMuteFn).toHaveBeenCalledTimes(1); const [args] = updateMonitorMuteFn.mock.calls[0]!; expect(args).toMatchObject({ id: "mute-1", body: { label: "renamed" } }); + // Only the field supplied as a flag is present — no target/schedule churn. + const body = (args as { body: Record }).body; + expect(body).not.toHaveProperty("target"); + expect(body).not.toHaveProperty("schedule"); const output = JSON.parse(stdout.join("")); expect(output.label).toBe("renamed"); }); - test("exits 1 on invalid JSON without calling the API", async () => { - const { context, getExitCode } = createMockContext(); + test("replaces the schedule when a full one-time window is given", async () => { + const { context } = createMockContext(); + await update.call( + context, + { + start: "2026-06-23T18:00:00Z", + end: "2026-06-23T22:00:00Z", + json: true, + }, + "mute-1", + deps, + ); + + const [args] = updateMonitorMuteFn.mock.calls[0]!; + expect((args as { body: unknown }).body).toMatchObject({ + schedule: { + kind: "OneTime", + oneTime: { + startTime: "2026-06-23T18:00:00Z", + endTime: "2026-06-23T22:00:00Z", + }, + }, + }); + }); + + test("retargets to specific monitors when --monitors is given", async () => { + const { context } = createMockContext(); + await update.call( + context, + { monitors: ["42", "43"], json: true }, + "mute-1", + deps, + ); + + const [args] = updateMonitorMuteFn.mock.calls[0]!; + expect((args as { body: unknown }).body).toMatchObject({ + target: { kind: "Monitors", monitors: [{ id: "42" }, { id: "43" }] }, + }); + }); + + test("exits 1 when no fields are provided", async () => { + const { context, getExitCode, stderr } = createMockContext(); try { - await update.call(context, { data: "nope" }, "mute-1", deps); + await update.call(context, { json: true }, "mute-1", deps); throw new Error("expected process.exit"); } catch (error) { expect((error as Error).message).toBe("process.exit"); } expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Nothing to update"); expect(updateMonitorMuteFn).not.toHaveBeenCalled(); }); @@ -136,7 +181,7 @@ describe("monitor-mute update", () => { }); const { context, stderr, getExitCode } = createMockContext(); try { - await update.call(context, { data: '{"label":"x"}' }, "mute-1", deps); + await update.call(context, { label: "x" }, "mute-1", deps); throw new Error("expected process.exit"); } catch (error) { expect((error as Error).message).toBe("process.exit"); diff --git a/src/commands/monitor/mute/update.ts b/src/commands/monitor/mute/update.ts new file mode 100644 index 0000000..b1cde05 --- /dev/null +++ b/src/commands/monitor/mute/update.ts @@ -0,0 +1,188 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../../context"; +import { updateMonitorMute } from "../../../rest/monitor-mute/update-monitor-mute"; +import { type MonitorMuteUpdateRequest } from "../../../rest/generated"; +import { loadConfig } from "../../../lib/config"; +import { formatApiError } from "../../../lib/format-error"; +import { muteStatusWriter } from "../../../lib/writer"; +import { parseNonNegativeInt } from "../../../lib/parsers"; +import { buildSchedule, buildTarget, parseMonitorIds } from "./parse"; + +interface UpdateMonitorMuteFlags { + label?: string; + description?: string; + monitors?: string[]; + global?: boolean; + filter?: string; + start?: string; + end?: string; + cron?: string; + timezone?: string; + duration?: number; + json?: boolean; +} + +export interface UpdateMonitorMuteDeps { + loadConfig?: typeof loadConfig; +} + +export async function update( + this: LocalContext, + flags: UpdateMonitorMuteFlags, + id: string, + deps: UpdateMonitorMuteDeps = {}, +): Promise { + const { loadConfig: loadConfigImpl = loadConfig } = deps; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { muted: flags.json }); + + // JSON Merge Patch: only the fields provided as flags are sent. + let body: MonitorMuteUpdateRequest; + try { + const target = buildTarget(flags); + const schedule = buildSchedule(flags); + body = {}; + if (flags.label !== undefined) { + body.label = flags.label; + } + if (flags.description !== undefined) { + body.description = flags.description; + } + if (target) { + body.target = target; + } + if (schedule) { + body.schedule = schedule; + } + if (flags.filter !== undefined) { + body.filter = flags.filter; + } + if (Object.keys(body).length === 0) { + throw new Error( + "Nothing to update. Provide at least one of --label, --description, --monitors/--global, a schedule (--start/--end or --cron/--timezone/--duration), or --filter.", + ); + } + } catch (e) { + writer.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + return; + } + + try { + const config = loadConfigImpl(); + + writer.info("Updating monitor mute..."); + + const mute = await updateMonitorMute({ config, id, body }); + + if (flags.json) { + writer.write(JSON.stringify(mute, null, 2)); + return; + } + + writer.write( + chalk.green(`Updated monitor mute `) + + chalk.bold(mute.id) + + chalk.green(` — "${mute.label}"`), + ); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const updateCommand = buildCommand({ + loader: async () => update, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Monitor mute rule ID", + parse: String, + }, + ], + }, + flags: { + label: { + kind: "parsed", + parse: String, + brief: "New human-readable name", + optional: true, + }, + description: { + kind: "parsed", + parse: String, + brief: "New description", + optional: true, + }, + monitors: { + kind: "parsed", + parse: parseMonitorIds, + brief: "Replace the target with these comma-separated monitor IDs", + optional: true, + }, + global: { + kind: "boolean", + brief: "Replace the target with Global (all monitors)", + optional: true, + }, + filter: { + kind: "parsed", + parse: String, + brief: "New CEL filter expression", + optional: true, + }, + start: { + kind: "parsed", + parse: String, + brief: "New one-time window start (ISO-8601)", + optional: true, + }, + end: { + kind: "parsed", + parse: String, + brief: "New one-time window end (ISO-8601)", + optional: true, + }, + cron: { + kind: "parsed", + parse: String, + brief: "New recurring schedule cron expression", + optional: true, + }, + timezone: { + kind: "parsed", + parse: String, + brief: "New recurring schedule IANA timezone", + optional: true, + }, + duration: { + kind: "parsed", + parse: parseNonNegativeInt, + brief: "New recurring schedule duration per occurrence, in seconds", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output the updated mute rule as JSON", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "Update a monitor mute rule", + fullDescription: [ + "Update a monitor mute rule (snooze) by ID via the /v1/monitor-mutes REST API.", + "", + "Only the fields you pass as flags are changed (JSON Merge Patch). Provide a", + "new target with --monitors/--global, or a new schedule with --start/--end", + "or --cron/--timezone/--duration.", + "", + "Example:", + " observe monitor mute update mute-123 --label 'Snooze checkout (extended)'", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor-mute/view.test.ts b/src/commands/monitor/mute/view.test.ts similarity index 95% rename from src/commands/monitor-mute/view.test.ts rename to src/commands/monitor/mute/view.test.ts index fcabdd7..f40b726 100644 --- a/src/commands/monitor-mute/view.test.ts +++ b/src/commands/monitor/mute/view.test.ts @@ -8,10 +8,10 @@ import { test, } from "bun:test"; import { resolve } from "node:path"; -import type { LocalContext } from "../../context"; -import { createWriter } from "../../lib/writer"; +import type { LocalContext } from "../../../context"; +import { createWriter } from "../../../lib/writer"; -const repoRoot = resolve(import.meta.dir, "../../.."); +const repoRoot = resolve(import.meta.dir, "../../../.."); const restModulePath = resolve( repoRoot, "src/rest/monitor-mute/get-monitor-mute.ts", diff --git a/src/commands/monitor-mute/view.ts b/src/commands/monitor/mute/view.ts similarity index 86% rename from src/commands/monitor-mute/view.ts rename to src/commands/monitor/mute/view.ts index b8757ff..108b1ce 100644 --- a/src/commands/monitor-mute/view.ts +++ b/src/commands/monitor/mute/view.ts @@ -1,16 +1,16 @@ import { buildCommand } from "@stricli/core"; import chalk from "chalk"; -import type { LocalContext } from "../../context"; -import { getMonitorMute } from "../../rest/monitor-mute/get-monitor-mute"; +import type { LocalContext } from "../../../context"; +import { getMonitorMute } from "../../../rest/monitor-mute/get-monitor-mute"; import { type MonitorMuteResource, MonitorMuteTargetKind, -} from "../../rest/generated"; -import { loadConfig } from "../../lib/config"; -import { formatApiError } from "../../lib/format-error"; -import { muteStatusWriter } from "../../lib/writer"; -import { renderObject } from "../../lib/formatters/object"; -import { renderAsCSV } from "../../lib/formatters/csv"; +} from "../../../rest/generated"; +import { loadConfig } from "../../../lib/config"; +import { formatApiError } from "../../../lib/format-error"; +import { muteStatusWriter } from "../../../lib/writer"; +import { renderObject } from "../../../lib/formatters/object"; +import { renderAsCSV } from "../../../lib/formatters/csv"; type OutputFormat = "json" | "csv"; diff --git a/src/lib/json-input.ts b/src/lib/json-input.ts deleted file mode 100644 index 9661b64..0000000 --- a/src/lib/json-input.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { readFileSync } from "node:fs"; - -/** - * Resolves a `--data` (inline JSON) / `--file` (path to JSON) pair into a - * parsed value. The two flags are mutually exclusive and one is required. - * Throws with an actionable message on bad/missing input. Callers narrow the - * returned value to the expected request body type. - */ -export function loadJsonInput( - inline: string | undefined, - filePath: string | undefined, - flagName = "data", -): unknown { - if (inline !== undefined && filePath !== undefined) { - throw new Error(`--${flagName} and --file are mutually exclusive`); - } - const raw = inline ?? (filePath ? readFileSync(filePath, "utf8") : undefined); - if (raw === undefined) { - throw new Error( - `Provide the request body via --${flagName} '' or --file `, - ); - } - try { - return JSON.parse(raw); - } catch { - const source = - inline !== undefined ? `--${flagName}` : `--file (${filePath ?? ""})`; - throw new Error(`${source} must be valid JSON`); - } -}