diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index de345bd14..ad72f0e55 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -49,20 +49,21 @@ cli/ │ ├── app.ts # Stricli application setup │ ├── context.ts # Dependency injection context │ ├── commands/ # CLI commands +│ │ ├── alert/ # create, delete, edit, list, view │ │ ├── auth/ # login, logout, refresh, status, token, whoami │ │ ├── cli/ # defaults, feedback, fix, setup, upgrade -│ │ ├── dashboard/ # list, view, create, add, edit, delete -│ │ ├── event/ # view, list -│ │ ├── issue/ # list, events, explain, plan, view, resolve, unresolve, merge +│ │ ├── dashboard/ # add, create, delete, edit, list, view +│ │ ├── event/ # list, view +│ │ ├── issue/ # events, explain, list, merge, plan, resolve, unresolve, view │ │ ├── log/ # list, view │ │ ├── org/ # list, view │ │ ├── project/ # create, delete, list, view -│ │ ├── release/ # list, view, create, finalize, delete, deploy, deploys, set-commits, propose-version +│ │ ├── release/ # create, delete, deploy, deploys, finalize, list, propose-version, set-commits, view │ │ ├── repo/ # list │ │ ├── sourcemap/ # inject, upload │ │ ├── span/ # list, view │ │ ├── team/ # list -│ │ ├── trace/ # list, view, logs +│ │ ├── trace/ # list, logs, view │ │ ├── trial/ # list, start │ │ ├── api.ts # Make an authenticated API request │ │ ├── help.ts # Help command diff --git a/docs/src/fragments/commands/alert.md b/docs/src/fragments/commands/alert.md new file mode 100644 index 000000000..0c2c3f7ef --- /dev/null +++ b/docs/src/fragments/commands/alert.md @@ -0,0 +1,84 @@ + + +## Examples + +### Create an issue alert rule + +```bash +# Create an issue alert rule with inline JSON condition/action +sentry alert issues create my-org/my-project \ + --name "Error Spike" \ + --condition '{"id":"sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}' \ + --action '{"id":"sentry.mail.actions.NotifyEmailAction","targetType":"Team","targetIdentifier":1}' \ + --action-match any +``` + +### List issue alert rules + +```bash +# List issue alert rules for a project +sentry alert issues list my-org/my-project + +# Filter rules by name +sentry alert issues list my-org/my-project --query "spike" +``` + +### View an issue alert rule + +```bash +# View by ID +sentry alert issues view my-org/my-project/12345 + +# View by name +sentry alert issues view my-org/my-project/"Error Spike" +``` + +### Edit and delete an issue alert rule + +```bash +# Edit issue alert name/status +sentry alert issues edit my-org/my-project/12345 --name "Prod Error Spike" --status disabled + +# Delete with preview +sentry alert issues delete my-org/my-project/12345 --dry-run +``` + +### Create a metric alert rule + +```bash +# Create an organization metric alert rule +sentry alert metrics create my-org \ + --name "P95 Latency" \ + --query "environment:prod" \ + --aggregate "p95(transaction.duration)" \ + --dataset transactions \ + --time-window 5 \ + --trigger '{"alertThreshold":500,"actions":[{"id":"sentry.mail.actions.NotifyEmailAction","targetType":"Team","targetIdentifier":1}]}' +``` + +### List metric alert rules + +```bash +# List metric alert rules for an organization +sentry alert metrics list my-org/ +``` + +### View a metric alert rule + +```bash +# View by ID +sentry alert metrics view my-org/67890 + +# View by name +sentry alert metrics view my-org/"P95 latency alert" +``` + +### Edit and delete a metric alert rule + +```bash +# Edit metric alert query/window +sentry alert metrics edit my-org/67890 --query "environment:prod event.type:error" --time-window 15 + +# Delete without prompt +sentry alert metrics delete my-org/67890 --yes +``` diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 553f9b9ae..54c6403d7 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -321,6 +321,23 @@ Make an authenticated API request → Full flags and examples: `references/api.md` +### Alert + +Manage Sentry alert rules + +- `sentry alert issues list ` — List issue alert rules +- `sentry alert issues view ` — View an issue alert rule +- `sentry alert issues create ` — Create an issue alert rule +- `sentry alert issues delete ` — Delete an issue alert rule +- `sentry alert issues edit ` — Edit an issue alert rule +- `sentry alert metrics list ` — List metric alert rules +- `sentry alert metrics view ` — View a metric alert rule +- `sentry alert metrics create ` — Create a metric alert rule +- `sentry alert metrics delete ` — Delete a metric alert rule +- `sentry alert metrics edit ` — Edit a metric alert rule + +→ Full flags and examples: `references/alert.md` + ### CLI CLI-related commands diff --git a/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/plugins/sentry-cli/skills/sentry-cli/references/alert.md new file mode 100644 index 000000000..1611fd8d1 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -0,0 +1,213 @@ +--- +name: sentry-cli-alert +version: 0.29.0-dev.0 +description: Manage Sentry alert rules +requires: + bins: ["sentry"] + auth: true +--- + +# Alert Commands + +Manage Sentry alert rules + +### `sentry alert issues list ` + +List issue alert rules + +**Flags:** +- `-w, --web - Open in browser` +- `-n, --limit - Maximum number of issue alert rules to list - (default: "25")` +- `-q, --query - Filter rules by name` +- `-c, --cursor - Pagination cursor (use "next" for next page, "prev" for previous)` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` + +**Examples:** + +```bash +# List issue alert rules for a project +sentry alert issues list my-org/my-project + +# Filter rules by name +sentry alert issues list my-org/my-project --query "spike" +``` + +### `sentry alert issues view ` + +View an issue alert rule + +**Flags:** +- `-w, --web - Open issue alert rules page in browser` + +**Examples:** + +```bash +# View by ID +sentry alert issues view my-org/my-project/12345 + +# View by name +sentry alert issues view my-org/my-project/"Error Spike" +``` + +### `sentry alert issues create ` + +Create an issue alert rule + +**Flags:** +- `--name - Rule name` +- `-c, --condition ... - Condition object JSON (repeatable, or pass one JSON array)` +- `-a, --action ... - Action object JSON (repeatable, or pass one JSON array)` +- `-m, --action-match - Condition/action match mode: all or any` +- `--frequency - Frequency in minutes (default: 30) - (default: 30)` +- `--environment - Environment filter` +- `--filter ... - Filter object JSON (repeatable, or pass one JSON array)` +- `--filter-match - Filter match mode: all or any` +- `--owner - Owner (team:user style value accepted by Sentry API)` +- `-n, --dry-run - Show what would happen without making changes` + +**Examples:** + +```bash +# Create an issue alert rule with inline JSON condition/action +sentry alert issues create my-org/my-project \ + --name "Error Spike" \ + --condition '{"id":"sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}' \ + --action '{"id":"sentry.mail.actions.NotifyEmailAction","targetType":"Team","targetIdentifier":1}' \ + --action-match any +``` + +### `sentry alert issues delete ` + +Delete an issue alert rule + +**Flags:** +- `-y, --yes - Skip confirmation prompt` +- `-f, --force - Force the operation without confirmation` +- `-n, --dry-run - Show what would happen without making changes` + +**Examples:** + +```bash +# Edit issue alert name/status +sentry alert issues edit my-org/my-project/12345 --name "Prod Error Spike" --status disabled + +# Delete with preview +sentry alert issues delete my-org/my-project/12345 --dry-run +``` + +### `sentry alert issues edit ` + +Edit an issue alert rule + +**Flags:** +- `--name - New rule name` +- `--status - Rule status: active or disabled` +- `-c, --condition ... - Condition object JSON (repeatable, or pass one JSON array)` +- `-a, --action ... - Action object JSON (repeatable, or pass one JSON array)` +- `-m, --action-match - Condition/action match mode: all or any` +- `--frequency - Frequency in minutes` +- `--environment - Environment value (pass empty string to clear)` +- `--filter ... - Filter object JSON (repeatable, or pass one JSON array)` +- `--filter-match - Filter match mode: all or any` +- `--owner - Owner value (pass empty string to clear)` + +### `sentry alert metrics list ` + +List metric alert rules + +**Flags:** +- `-w, --web - Open in browser` +- `-n, --limit - Maximum number of metric alert rules to list - (default: "25")` +- `-q, --query - Filter rules by name` +- `-c, --cursor - Pagination cursor (use "next" for next page, "prev" for previous)` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` + +**Examples:** + +```bash +# List metric alert rules for an organization +sentry alert metrics list my-org/ +``` + +### `sentry alert metrics view ` + +View a metric alert rule + +**Flags:** +- `-w, --web - Open metric alert rules page in browser` + +**Examples:** + +```bash +# View by ID +sentry alert metrics view my-org/67890 + +# View by name +sentry alert metrics view my-org/"P95 latency alert" +``` + +### `sentry alert metrics create ` + +Create a metric alert rule + +**Flags:** +- `--name - Rule name` +- `--query - Metric query filter string` +- `--aggregate - Aggregate expression (for example count(), p95(transaction.duration))` +- `--dataset - Dataset (errors, transactions, sessions, events, spans, metrics)` +- `--time-window - Evaluation window in minutes` +- `-t, --trigger ... - Trigger object JSON (repeatable, or pass one JSON array)` +- `-p, --project ... - Project slug filter (repeatable or comma-separated)` +- `--environment - Environment filter` +- `--owner - Owner value accepted by Sentry API` +- `-n, --dry-run - Show what would happen without making changes` + +**Examples:** + +```bash +# Create an organization metric alert rule +sentry alert metrics create my-org \ + --name "P95 Latency" \ + --query "environment:prod" \ + --aggregate "p95(transaction.duration)" \ + --dataset transactions \ + --time-window 5 \ + --trigger '{"alertThreshold":500,"actions":[{"id":"sentry.mail.actions.NotifyEmailAction","targetType":"Team","targetIdentifier":1}]}' +``` + +### `sentry alert metrics delete ` + +Delete a metric alert rule + +**Flags:** +- `-y, --yes - Skip confirmation prompt` +- `-f, --force - Force the operation without confirmation` +- `-n, --dry-run - Show what would happen without making changes` + +**Examples:** + +```bash +# Edit metric alert query/window +sentry alert metrics edit my-org/67890 --query "environment:prod event.type:error" --time-window 15 + +# Delete without prompt +sentry alert metrics delete my-org/67890 --yes +``` + +### `sentry alert metrics edit ` + +Edit a metric alert rule + +**Flags:** +- `--name - New rule name` +- `--status - active or disabled` +- `--query - Metric query filter` +- `--aggregate - Aggregate expression` +- `--dataset - Dataset (errors, transactions, sessions, events, spans, metrics)` +- `--time-window - Evaluation window in minutes` +- `-t, --trigger ... - Trigger object JSON (repeatable, or pass one JSON array)` +- `-p, --project ... - Project slug filter (repeatable or comma-separated)` +- `--environment - Environment value (pass empty string to clear)` +- `--owner - Owner value (pass empty string to clear)` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/script/generate-docs-sections.ts b/script/generate-docs-sections.ts index d195ebaa8..c4af89010 100644 --- a/script/generate-docs-sections.ts +++ b/script/generate-docs-sections.ts @@ -99,13 +99,18 @@ function isStandaloneCommand(route: RouteInfo): boolean { /** * Get subcommand names for a route group (e.g., "list, view, create"). - * Extracts the last path segment from each command's path. + * Extracts the last path segment from each command's path, deduplicated + * and sorted (nested routes like `issues` / `metrics` would otherwise repeat + * the same subcommand list twice, e.g. for `alert/`). */ -function getSubcommandNames(route: RouteInfo): string[] { - return route.commands.map((cmd) => { +function getSubcommandLabel(route: RouteInfo): string { + const raw = route.commands.map((cmd) => { const parts = cmd.path.split(" "); return parts.at(-1) ?? route.name; }); + return Array.from(new Set(raw)) + .sort((a, b) => a.localeCompare(b)) + .join(", "); } /** @@ -144,7 +149,7 @@ function generateProjectStructure(allRoutes: RouteInfo[]): string { // Render group directories (always use ├── since standalones follow) for (const route of groups) { - const subcmds = getSubcommandNames(route).join(", "); + const subcmds = getSubcommandLabel(route); lines.push(`│ │ ├── ${`${route.name}/`.padEnd(13)}# ${subcmds}`); } diff --git a/src/app.ts b/src/app.ts index 43e4cb468..be4971bc7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import { UnexpectedPositionalError, UnsatisfiedPositionalError, } from "@stricli/core"; +import { alertRoute } from "./commands/alert/index.js"; import { apiCommand } from "./commands/api.js"; import { authRoute } from "./commands/auth/index.js"; import { whoamiCommand } from "./commands/auth/whoami.js"; @@ -79,6 +80,7 @@ const PLURAL_TO_SINGULAR: Record = { export const routes = buildRouteMap({ routes: { help: helpCommand, + alert: alertRoute, auth: authRoute, cli: cliRoute, dashboard: dashboardRoute, diff --git a/src/commands/alert/index.ts b/src/commands/alert/index.ts new file mode 100644 index 000000000..4f49e0711 --- /dev/null +++ b/src/commands/alert/index.ts @@ -0,0 +1,19 @@ +import { buildRouteMap } from "../../lib/route-map.js"; +import { issuesRoute } from "./issues/index.js"; +import { metricsRoute } from "./metrics/index.js"; + +export const alertRoute = buildRouteMap({ + routes: { + issues: issuesRoute, + metrics: metricsRoute, + }, + docs: { + brief: "Manage Sentry alert rules", + fullDescription: + "View and manage alert rules in your Sentry organization.\n\n" + + "Alert types:\n" + + " issues Issue alert rules — trigger on matching error events (project-scoped)\n" + + " metrics Metric alert rules — trigger on metric query thresholds (org-scoped)", + hideRoute: {}, + }, +}); diff --git a/src/commands/alert/issues/create.ts b/src/commands/alert/issues/create.ts new file mode 100644 index 000000000..5249d490e --- /dev/null +++ b/src/commands/alert/issues/create.ts @@ -0,0 +1,240 @@ +/** + * sentry alert issues create + * + * Create a project-scoped issue alert rule. + */ + +import type { SentryContext } from "../../../context.js"; +import { createIssueAlertRule } from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { buildCommand, numberParser } from "../../../lib/command.js"; +import { ContextError, ValidationError } from "../../../lib/errors.js"; +import { CommandOutput } from "../../../lib/formatters/output.js"; +import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../../lib/mutate-command.js"; +import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; +import { + parseJsonObjectList, + parseMatchMode, + validateIssueRuleArrays, +} from "../mutation-utils.js"; + +const USAGE_HINT = + "sentry alert issues create / --name --condition --action --action-match all|any"; + +type CreateFlags = { + readonly name: string; + readonly condition?: string[]; + readonly action?: string[]; + readonly "action-match"?: "all" | "any"; + readonly frequency: number; + readonly environment?: string; + readonly filter?: string[]; + readonly "filter-match"?: "all" | "any"; + readonly owner?: string; + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type CreateResult = { + org: string; + project: string; + id?: string; + name: string; + status?: string; + dryRun?: boolean; + body?: Record; +}; + +function formatCreated(result: CreateResult): string { + if (result.dryRun) { + return `Would create issue alert rule '${result.name}' in ${result.org}/${result.project}.`; + } + return `Created issue alert rule ${result.id ?? "(unknown id)"} in ${result.org}/${result.project}: ${result.name} (${result.status ?? "active"}).`; +} + +export const createCommand = buildCommand({ + docs: { + brief: "Create an issue alert rule", + fullDescription: + "Create a project-scoped issue alert rule.\n\n" + + "Required fields:\n" + + " --name, --condition (>=1), --action (>=1), --action-match all|any\n\n" + + "Optional fields:\n" + + " --frequency, --environment, --filter, --filter-match, --owner\n\n" + + "Examples:\n" + + " sentry alert issues create my-org/my-app --name 'Error Spike' \\\n" + + ' --condition \'{"id":"sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}\' \\\n' + + ' --action \'{"id":"sentry.mail.actions.NotifyEmailAction","targetType":"Team","targetIdentifier":1}\' \\\n' + + " --action-match any\n\n" + + " sentry alert issues create my-org/my-app --name 'Prod Errors' \\\n" + + ' --condition \'[{"id":"sentry.rules.conditions.every_event.EveryEventCondition"}]\' \\\n' + + ' --action \'[{"id":"sentry.mail.actions.NotifyEmailAction","targetType":"Team","targetIdentifier":1}]\' \\\n' + + " --action-match all --frequency 30 --dry-run", + }, + output: { + human: formatCreated, + jsonTransform: (result: CreateResult) => result, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: "Target organization/project", + parse: String, + }, + ], + }, + flags: { + name: { + kind: "parsed", + parse: String, + brief: "Rule name", + }, + condition: { + kind: "parsed", + parse: String, + variadic: true, + optional: true, + brief: "Condition object JSON (repeatable, or pass one JSON array)", + }, + action: { + kind: "parsed", + parse: String, + variadic: true, + optional: true, + brief: "Action object JSON (repeatable, or pass one JSON array)", + }, + "action-match": { + kind: "parsed", + parse: (value: string) => parseMatchMode(value, "action-match"), + optional: true, + brief: "Condition/action match mode: all or any", + }, + frequency: { + kind: "parsed", + parse: numberParser, + default: 30, + brief: "Frequency in minutes (default: 30)", + }, + environment: { + kind: "parsed", + parse: String, + optional: true, + brief: "Environment filter", + }, + filter: { + kind: "parsed", + parse: String, + variadic: true, + optional: true, + brief: "Filter object JSON (repeatable, or pass one JSON array)", + }, + "filter-match": { + kind: "parsed", + parse: (value: string) => parseMatchMode(value, "filter-match"), + optional: true, + brief: "Filter match mode: all or any", + }, + owner: { + kind: "parsed", + parse: String, + optional: true, + brief: "Owner (team:user style value accepted by Sentry API)", + }, + "dry-run": DRY_RUN_FLAG, + }, + aliases: { + ...DRY_RUN_ALIASES, + c: "condition", + a: "action", + m: "action-match", + }, + }, + async *func(this: SentryContext, flags: CreateFlags, arg: string) { + const { cwd } = this; + if (!flags.name.trim()) { + throw new ValidationError("Rule name cannot be empty.", "name"); + } + if (flags.frequency <= 0) { + throw new ValidationError( + "frequency must be greater than 0.", + "frequency" + ); + } + if (!flags["action-match"]) { + throw new ValidationError( + "Pass --action-match with one of: all, any.", + "action-match" + ); + } + + const conditions = parseJsonObjectList(flags.condition, "condition"); + const actions = parseJsonObjectList(flags.action, "action"); + const filters = parseJsonObjectList(flags.filter, "filter"); + validateIssueRuleArrays(conditions, actions, "conditions"); + validateIssueRuleArrays(conditions, actions, "actions"); + + const parsed = parseOrgProjectArg(arg); + const { targets } = await resolveTargetsFromParsedArg(parsed, { + cwd, + usageHint: USAGE_HINT, + }); + if (targets.length === 0) { + throw new ContextError("Organization and project", USAGE_HINT); + } + if (targets.length !== 1) { + throw new ValidationError( + "Provide a single explicit org/project target for create.", + "target" + ); + } + const target = targets[0] as (typeof targets)[number]; + + const body: Record = { + name: flags.name, + conditions, + actions, + actionMatch: flags["action-match"], + frequency: flags.frequency, + }; + if (flags.environment !== undefined) { + body.environment = flags.environment; + } + if (filters && filters.length > 0) { + body.filters = filters; + body.filterMatch = flags["filter-match"] ?? "all"; + } else if (flags["filter-match"] !== undefined) { + body.filterMatch = flags["filter-match"]; + } + if (flags.owner !== undefined) { + body.owner = flags.owner; + } + + if (flags["dry-run"]) { + yield new CommandOutput({ + org: target.org, + project: target.project, + name: flags.name, + dryRun: true, + body, + } satisfies CreateResult); + return { hint: "Dry run - no issue alert rule was created." }; + } + + const created = await createIssueAlertRule( + target.org, + target.project, + body + ); + yield new CommandOutput({ + org: target.org, + project: target.project, + id: String(created.id ?? ""), + name: String(created.name ?? flags.name), + status: String(created.status ?? "active"), + } satisfies CreateResult); + }, +}); diff --git a/src/commands/alert/issues/delete.ts b/src/commands/alert/issues/delete.ts new file mode 100644 index 000000000..bbe53e43d --- /dev/null +++ b/src/commands/alert/issues/delete.ts @@ -0,0 +1,141 @@ +/** + * sentry alert issues delete + * + * Permanently delete an issue alert rule in a project. + */ + +import type { SentryContext } from "../../../context.js"; +import { deleteIssueAlertRule } from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { ContextError } from "../../../lib/errors.js"; +import { CommandOutput } from "../../../lib/formatters/output.js"; +import { logger } from "../../../lib/logger.js"; +import { + buildDeleteCommand, + confirmByTyping, + isConfirmationBypassed, +} from "../../../lib/mutate-command.js"; +import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; +import { parseIssueRuleArg, resolveIssueAlertRule } from "./rule-resolve.js"; + +const USAGE_HINT = + "sentry alert issues delete //"; + +type DeleteFlags = { + readonly yes: boolean; + readonly force: boolean; + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type DeleteResult = { + org: string; + project: string; + ruleId: string; + name: string; + dryRun?: boolean; +}; + +function formatDeleted(r: DeleteResult): string { + if (r.dryRun) { + return `Would delete issue alert rule '${r.name}' (id ${r.ruleId}) in ${r.org}/${r.project}.`; + } + return `Deleted issue alert rule '${r.name}' (id ${r.ruleId}) in ${r.org}/${r.project}.`; +} + +export const deleteCommand = buildDeleteCommand({ + docs: { + brief: "Delete an issue alert rule", + fullDescription: + "Permanently remove an issue alert rule from a project. This cannot be undone.\n\n" + + "You will be asked to type org/project/rule-id to confirm, unless you pass " + + "--yes or --force, or use --dry-run to preview only.\n\n" + + "Examples:\n" + + " sentry alert issues delete my-org/my-app/12345\n" + + " sentry alert issues delete my-org/my-app/'My Rule' --yes\n" + + " sentry alert issues delete 12345 --dry-run", + }, + output: { + human: formatDeleted, + jsonTransform: (r: DeleteResult) => + r.dryRun + ? { + dryRun: true, + org: r.org, + project: r.project, + id: r.ruleId, + name: r.name, + } + : { + deleted: true, + org: r.org, + project: r.project, + id: r.ruleId, + name: r.name, + }, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project/rule-id-or-name", + brief: "Rule id or name (same as view)", + parse: String, + }, + ], + }, + }, + async *func(this: SentryContext, flags: DeleteFlags, arg: string) { + const { cwd } = this; + const { ref, targetArg } = parseIssueRuleArg(arg, USAGE_HINT); + const parsed = parseOrgProjectArg(targetArg); + + const { targets } = await resolveTargetsFromParsedArg(parsed, { + cwd, + usageHint: USAGE_HINT, + }); + if (targets.length === 0) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + const { target, rule } = await resolveIssueAlertRule( + targets, + ref, + USAGE_HINT + ); + + const key = `${target.org}/${target.project}/${rule.id}`; + const name = rule.name; + if (flags["dry-run"]) { + yield new CommandOutput({ + org: target.org, + project: target.project, + ruleId: rule.id, + name, + dryRun: true, + } satisfies DeleteResult); + return; + } + + if (!isConfirmationBypassed(flags)) { + const ok = await confirmByTyping( + key, + `Type '${key}' to permanently delete this issue alert rule:` + ); + if (!ok) { + logger.info("Delete cancelled."); + return; + } + } + + await deleteIssueAlertRule(target.org, target.project, rule.id); + yield new CommandOutput({ + org: target.org, + project: target.project, + ruleId: rule.id, + name, + } satisfies DeleteResult); + }, +}); diff --git a/src/commands/alert/issues/edit.ts b/src/commands/alert/issues/edit.ts new file mode 100644 index 000000000..22ffe9583 --- /dev/null +++ b/src/commands/alert/issues/edit.ts @@ -0,0 +1,271 @@ +/** + * sentry alert issues edit + * + * Update an issue alert rule (name and/or status). Fetches the current rule, + * applies changes, and PUTs the full document to the Sentry API. + */ + +import type { SentryContext } from "../../../context.js"; +import { + getIssueAlertRuleDocument, + putIssueAlertRule, +} from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { buildCommand, numberParser } from "../../../lib/command.js"; +import { ContextError, ValidationError } from "../../../lib/errors.js"; +import { CommandOutput } from "../../../lib/formatters/output.js"; +import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; +import { + parseJsonObjectList, + parseMatchMode, + parseStatusFlag, + validateIssueRuleArrays, +} from "../mutation-utils.js"; +import { parseIssueRuleArg, resolveIssueAlertRule } from "./rule-resolve.js"; + +const USAGE_HINT = + "sentry alert issues edit // --name | --status active|disabled"; + +type EditFlags = { + readonly name?: string; + readonly status?: "active" | "disabled" | undefined; + readonly condition?: string[]; + readonly action?: string[]; + readonly "action-match"?: "all" | "any"; + readonly frequency?: number; + readonly environment?: string; + readonly filter?: string[]; + readonly "filter-match"?: "all" | "any"; + readonly owner?: string; + readonly json: boolean; + readonly fields?: string[]; +}; + +type EditResult = Record & { + org: string; + project: string; + id: string; +}; + +function hasIssueMutations(flags: EditFlags): boolean { + return ( + flags.name !== undefined || + flags.status !== undefined || + flags.condition !== undefined || + flags.action !== undefined || + flags["action-match"] !== undefined || + flags.frequency !== undefined || + flags.environment !== undefined || + flags.filter !== undefined || + flags["filter-match"] !== undefined || + flags.owner !== undefined + ); +} + +function validateIssueEditFlags(flags: EditFlags): void { + if (!hasIssueMutations(flags)) { + throw new ValidationError( + "Pass at least one editable field (for example --name or --status).", + "name" + ); + } + if (flags.frequency !== undefined && flags.frequency <= 0) { + throw new ValidationError("frequency must be greater than 0.", "frequency"); + } +} + +function applyIssueEdits( + body: Record, + flags: EditFlags +): Record { + const conditions = parseJsonObjectList(flags.condition, "condition"); + const actions = parseJsonObjectList(flags.action, "action"); + const filters = parseJsonObjectList(flags.filter, "filter"); + + if (flags.name !== undefined) { + body.name = flags.name; + } + if (flags.status !== undefined) { + body.status = flags.status; + } + if (conditions !== undefined) { + body.conditions = conditions; + } + if (actions !== undefined) { + body.actions = actions; + } + if (flags["action-match"] !== undefined) { + body.actionMatch = flags["action-match"]; + } + if (flags.frequency !== undefined) { + body.frequency = flags.frequency; + } + if (flags.environment !== undefined) { + body.environment = + flags.environment.trim() === "" ? null : flags.environment; + } + if (filters !== undefined) { + body.filters = filters; + } + if (flags["filter-match"] !== undefined) { + body.filterMatch = flags["filter-match"]; + } + if (flags.owner !== undefined) { + body.owner = flags.owner.trim() === "" ? null : flags.owner; + } + + validateIssueRuleArrays( + body.conditions as Record[] | undefined, + body.actions as Record[] | undefined, + "conditions" + ); + validateIssueRuleArrays( + body.conditions as Record[] | undefined, + body.actions as Record[] | undefined, + "actions" + ); + return body; +} + +function formatEdited(r: EditResult): string { + return `Updated issue alert rule ${r.id} in ${r.org}/${r.project}: ${String(r.name)} (${String(r.status)}).`; +} + +export const editCommand = buildCommand({ + docs: { + brief: "Edit an issue alert rule", + fullDescription: + "Update an issue alert rule by id or name. You must set at least one of --name or " + + "--status.\n\n" + + "The CLI loads the current rule, applies your changes, and updates it via the API.\n\n" + + "Examples:\n" + + " sentry alert issues edit my-org/my-app/12 --name 'Prod errors'\n" + + " sentry alert issues edit my-org/my-app/'Old name' --status disabled\n" + + " sentry alert issues edit 12 --name 'Renamed' --status active\n" + + ' sentry alert issues edit my-org/my-app/12 --condition \'{"id":"sentry.rules.conditions.first_seen_event.FirstSeenEventCondition"}\'', + }, + output: { + human: formatEdited, + jsonTransform: (r: EditResult) => r, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project/rule-id-or-name", + brief: "Rule id or name (same as view)", + parse: String, + }, + ], + }, + flags: { + name: { + kind: "parsed", + parse: String, + optional: true, + brief: "New rule name", + }, + status: { + kind: "parsed", + parse: parseStatusFlag, + optional: true, + brief: "Rule status: active or disabled", + }, + condition: { + kind: "parsed", + parse: String, + variadic: true, + optional: true, + brief: "Condition object JSON (repeatable, or pass one JSON array)", + }, + action: { + kind: "parsed", + parse: String, + variadic: true, + optional: true, + brief: "Action object JSON (repeatable, or pass one JSON array)", + }, + "action-match": { + kind: "parsed", + parse: (value: string) => parseMatchMode(value, "action-match"), + optional: true, + brief: "Condition/action match mode: all or any", + }, + frequency: { + kind: "parsed", + parse: numberParser, + optional: true, + brief: "Frequency in minutes", + }, + environment: { + kind: "parsed", + parse: String, + optional: true, + brief: "Environment value (pass empty string to clear)", + }, + filter: { + kind: "parsed", + parse: String, + variadic: true, + optional: true, + brief: "Filter object JSON (repeatable, or pass one JSON array)", + }, + "filter-match": { + kind: "parsed", + parse: (value: string) => parseMatchMode(value, "filter-match"), + optional: true, + brief: "Filter match mode: all or any", + }, + owner: { + kind: "parsed", + parse: String, + optional: true, + brief: "Owner value (pass empty string to clear)", + }, + }, + aliases: { + c: "condition", + a: "action", + m: "action-match", + }, + }, + async *func(this: SentryContext, flags: EditFlags, arg: string) { + const { cwd } = this; + validateIssueEditFlags(flags); + + const { ref, targetArg } = parseIssueRuleArg(arg, USAGE_HINT); + const parsed = parseOrgProjectArg(targetArg); + const { targets } = await resolveTargetsFromParsedArg(parsed, { + cwd, + usageHint: USAGE_HINT, + }); + if (targets.length === 0) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + const { target, rule } = await resolveIssueAlertRule( + targets, + ref, + USAGE_HINT + ); + + const body = { + ...(await getIssueAlertRuleDocument(target.org, target.project, rule.id)), + } as Record; + applyIssueEdits(body, flags); + + const updated = await putIssueAlertRule( + target.org, + target.project, + rule.id, + body + ); + yield new CommandOutput({ + ...updated, + org: target.org, + project: target.project, + id: String(updated.id ?? rule.id), + } satisfies EditResult); + }, +}); diff --git a/src/commands/alert/issues/index.ts b/src/commands/alert/issues/index.ts new file mode 100644 index 000000000..7af43497e --- /dev/null +++ b/src/commands/alert/issues/index.ts @@ -0,0 +1,28 @@ +import { buildRouteMap } from "../../../lib/route-map.js"; +import { createCommand } from "./create.js"; +import { deleteCommand } from "./delete.js"; +import { editCommand } from "./edit.js"; +import { listCommand } from "./list.js"; +import { viewCommand } from "./view.js"; + +export const issuesRoute = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + create: createCommand, + delete: deleteCommand, + edit: editCommand, + }, + docs: { + brief: "Manage issue alert rules", + fullDescription: + "View and manage issue alert rules in your Sentry projects.\n\n" + + "Commands:\n" + + " list List issue alert rules\n" + + " view View issue alert rule details\n" + + " create Create an issue alert rule\n" + + " delete Delete an issue alert rule\n" + + " edit Update an issue alert rule", + hideRoute: {}, + }, +}); diff --git a/src/commands/alert/issues/list.ts b/src/commands/alert/issues/list.ts new file mode 100644 index 000000000..31a0ae88f --- /dev/null +++ b/src/commands/alert/issues/list.ts @@ -0,0 +1,696 @@ +/** + * sentry alert issues list + * + * List issue alert rules for one or more Sentry projects. + * + * Issue alerts are project-scoped. Supports all four target modes: + * - auto-detect → DSN detection / config defaults (may resolve multiple projects) + * - explicit → single org/project + * - org-all → all projects in an org (all their alert rules combined) + * - project-search → find project by slug across all orgs + */ + +import type { SentryContext } from "../../../context.js"; +import { buildProjectAliasMap } from "../../../lib/alias.js"; +import type { IssueAlertRule } from "../../../lib/api/alerts.js"; +import { MAX_PAGINATION_PAGES } from "../../../lib/api/infrastructure.js"; +import { + API_MAX_PER_PAGE, + listIssueAlertsPaginated, +} from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { openInBrowser } from "../../../lib/browser.js"; +import { + advancePaginationState, + buildMultiTargetContextKey, + CURSOR_SEP, + decodeCompoundCursor, + encodeCompoundCursor, + hasPreviousPage, + resolveCursor, +} from "../../../lib/db/pagination.js"; +import { + clearProjectAliases, + setProjectAliases, +} from "../../../lib/db/project-aliases.js"; +import { createDsnFingerprint } from "../../../lib/dsn/index.js"; +import { ContextError, withAuthGuard } from "../../../lib/errors.js"; +import { + colorTag, + escapeMarkdownCell, +} from "../../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../../lib/formatters/output.js"; +import { type Column, writeTable } from "../../../lib/formatters/table.js"; +import { + buildListCommand, + buildListLimitFlag, + LIST_BASE_ALIASES, + LIST_TARGET_POSITIONAL, + parseCursorFlag, + targetPatternExplanation, +} from "../../../lib/list-command.js"; +import { logger } from "../../../lib/logger.js"; +import { + dispatchOrgScopedList, + type FetchResult as FetchResultOf, + jsonTransformListResult, + type ListCommandMeta, + type ListResult, + type ModeHandler, + trimWithGroupGuarantee, +} from "../../../lib/org-list.js"; +import { withProgress } from "../../../lib/polling.js"; +import { + type ResolvedTarget, + resolveTargetsFromParsedArg, +} from "../../../lib/resolve-target.js"; +import { buildIssueAlertsUrl } from "../../../lib/sentry-urls.js"; +import type { ProjectAliasEntry, Writer } from "../../../types/index.js"; + +/** Command key for pagination cursor storage */ +export const PAGINATION_KEY = "alert-issues-list"; + +const USAGE_HINT = "sentry alert issues list /"; + +const MAX_LIMIT = 1000; + +type ListFlags = { + readonly web: boolean; + readonly fresh: boolean; + readonly limit: number; + readonly cursor?: string; + readonly json: boolean; + readonly fields?: string[]; + readonly query?: string; +}; + +/** Per-target fetch result */ +type AlertRuleListFetchResult = { + target: ResolvedTarget; + rules: IssueAlertRule[]; + hasMore?: boolean; + nextCursor?: string; +}; + +/** Success/failure wrapper for per-target fetches */ +type FetchResult = FetchResultOf; + +/** Display row carrying per-rule project context for the human formatter. */ +type AlertRuleRow = { rule: IssueAlertRule; target: ResolvedTarget }; + +/** + * Extended result type: raw rules in `items` (for JSON), display rows in + * `displayRows` (for human output). + */ +type IssueAlertListResult = ListResult & { + displayRows?: AlertRuleRow[]; + title?: string; + moreHint?: string; + footer?: string; +}; + +const issueAlertListMeta: ListCommandMeta = { + paginationKey: PAGINATION_KEY, + entityName: "issue alert rule", + entityPlural: "issue alert rules", + commandPrefix: "sentry alert issues list", +}; + +// --------------------------------------------------------------------------- +// Fetch helpers +// --------------------------------------------------------------------------- + +/** + * Fetch issue alert rules for a single target project with auth guard. + * Paginates locally up to the given limit. + */ +async function fetchRulesForTarget( + target: ResolvedTarget, + options: { limit: number; startCursor?: string } +): Promise { + const result = await withAuthGuard(async () => { + const rules: IssueAlertRule[] = []; + let serverCursor = options.startCursor; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await listIssueAlertsPaginated( + target.org, + target.project, + { + perPage: Math.min(options.limit - rules.length, API_MAX_PER_PAGE), + cursor: serverCursor, + } + ); + + for (const rule of data) { + rules.push(rule); + if (rules.length >= options.limit) { + return { + target, + rules, + hasMore: !!nextCursor, + nextCursor: nextCursor ?? undefined, + }; + } + } + + if (!nextCursor) { + return { target, rules, hasMore: false }; + } + serverCursor = nextCursor; + } + + return { target, rules, hasMore: false }; + }); + + if (!result.ok) { + const error = + result.error instanceof Error + ? result.error + : new Error(String(result.error)); + return { success: false, error }; + } + return { success: true, data: result.value }; +} + +/** + * Execute Phase 2: redistribute surplus budget to expandable targets. + */ +async function runPhase2( + targets: ResolvedTarget[], + phase1: FetchResult[], + expandableIndices: number[], + context: { + surplus: number; + options: { limit: number }; + } +): Promise { + const { surplus } = context; + const extraQuota = Math.max(1, Math.ceil(surplus / expandableIndices.length)); + + const phase2 = await Promise.all( + expandableIndices.map((i) => { + // biome-ignore lint/style/noNonNullAssertion: guaranteed by expandableIndices filter + const target = targets[i]!; + const r = phase1[i] as { success: true; data: AlertRuleListFetchResult }; + // biome-ignore lint/style/noNonNullAssertion: same guarantee + const cursor = r.data.nextCursor!; + return fetchRulesForTarget(target, { + limit: extraQuota, + startCursor: cursor, + }); + }) + ); + + for (let j = 0; j < expandableIndices.length; j++) { + // biome-ignore lint/style/noNonNullAssertion: j is within expandableIndices bounds + const i = expandableIndices[j]!; + const p2 = phase2[j]; + const p1 = phase1[i]; + if (p1?.success && p2?.success) { + p1.data.rules.push(...p2.data.rules); + p1.data.hasMore = p2.data.hasMore; + p1.data.nextCursor = p2.data.nextCursor; + } + } +} + +/** True if any target in phase 1 still has additional pages to fetch. */ +function phase1HasMore(phase1: FetchResult[]): boolean { + return phase1.some((r) => r.success && r.data.hasMore); +} + +/** + * Fetch alert rules from multiple targets within a global limit budget. + * + * Phase 1: distribute quota per target, fetch in parallel. + * Phase 2: redistribute surplus to expandable targets. + */ +async function fetchWithBudget( + targets: ResolvedTarget[], + options: { limit: number; startCursors?: Map }, + onProgress: (fetched: number) => void +): Promise<{ results: FetchResult[]; hasMore: boolean }> { + const { limit, startCursors } = options; + const quota = Math.max(1, Math.ceil(limit / targets.length)); + + const phase1 = await Promise.all( + targets.map((t) => + fetchRulesForTarget(t, { + limit: quota, + startCursor: startCursors?.get(`${t.org}/${t.project}`), + }) + ) + ); + + let totalFetched = 0; + for (const r of phase1) { + if (r.success) { + totalFetched += r.data.rules.length; + } + } + onProgress(totalFetched); + + const surplus = limit - totalFetched; + if (surplus <= 0) { + return { + results: phase1, + hasMore: phase1HasMore(phase1), + }; + } + + const expandableIndices: number[] = []; + for (let i = 0; i < phase1.length; i++) { + const r = phase1[i]; + if (r?.success && r.data.rules.length >= quota && r.data.nextCursor) { + expandableIndices.push(i); + } + } + + if (expandableIndices.length === 0) { + return { + results: phase1, + hasMore: phase1HasMore(phase1), + }; + } + + await runPhase2(targets, phase1, expandableIndices, { surplus, options }); + + totalFetched = 0; + for (const r of phase1) { + if (r.success) { + totalFetched += r.data.rules.length; + } + } + onProgress(totalFetched); + + return { + results: phase1, + hasMore: phase1HasMore(phase1), + }; +} + +/** + * Trim display rows to the global limit while guaranteeing at least one row + * per project (when possible). + */ +function trimWithProjectGuarantee( + rows: AlertRuleRow[], + limit: number +): AlertRuleRow[] { + return trimWithGroupGuarantee( + rows, + limit, + (r) => `${r.target.org}/${r.target.project}` + ); +} + +// --------------------------------------------------------------------------- +// Mode handler +// --------------------------------------------------------------------------- + +type ResolvedTargetsOptions = { + parsed: ReturnType; + flags: ListFlags; + cwd: string; +}; + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inherent multi-target resolution, compound cursor, error handling, and display logic +async function handleResolvedTargets( + options: ResolvedTargetsOptions +): Promise { + const { parsed, flags, cwd } = options; + + const { targets, footer, detectedDsns } = await resolveTargetsFromParsedArg( + parsed, + { cwd, usageHint: USAGE_HINT } + ); + + if (targets.length === 0) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + const contextKey = buildMultiTargetContextKey(targets, { + query: flags.query, + }); + + const sortedTargetKeys = targets.map((t) => `${t.org}/${t.project}`).sort(); + const startCursors = new Map(); + const exhaustedTargets = new Set(); + const { cursor: rawCursor, direction } = resolveCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); + if (rawCursor) { + const decoded = decodeCompoundCursor(rawCursor); + for (let i = 0; i < decoded.length && i < sortedTargetKeys.length; i++) { + const cursor = decoded[i]; + // biome-ignore lint/style/noNonNullAssertion: i is within bounds + const key = sortedTargetKeys[i]!; + if (cursor) { + startCursors.set(key, cursor); + } else { + exhaustedTargets.add(key); + } + } + } + + const activeTargets = + exhaustedTargets.size > 0 + ? targets.filter((t) => !exhaustedTargets.has(`${t.org}/${t.project}`)) + : targets; + + const targetCount = activeTargets.length; + const baseMessage = + targetCount > 1 + ? `Fetching issue alert rules from ${targetCount} projects` + : "Fetching issue alert rules"; + + const { results, hasMore } = await withProgress( + { message: `${baseMessage} (up to ${flags.limit})...`, json: flags.json }, + (setMessage) => + fetchWithBudget( + activeTargets, + { limit: flags.limit, startCursors }, + (fetched) => { + setMessage( + `${baseMessage}, ${fetched} and counting (up to ${flags.limit})...` + ); + } + ) + ); + + const cursorValues: (string | null)[] = sortedTargetKeys.map((key) => { + if (exhaustedTargets.has(key)) { + return null; + } + const result = results.find((r) => { + if (!r.success) { + return false; + } + return `${r.data.target.org}/${r.data.target.project}` === key; + }); + if (result?.success) { + return result.data.nextCursor ?? null; + } + return startCursors.get(key) ?? null; + }); + const hasAnyCursor = cursorValues.some((c) => c !== null); + const compoundNextCursor = hasAnyCursor + ? encodeCompoundCursor(cursorValues) + : undefined; + advancePaginationState( + PAGINATION_KEY, + contextKey, + direction, + compoundNextCursor + ); + const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); + + const validResults: AlertRuleListFetchResult[] = []; + const failures: { target: ResolvedTarget; error: Error }[] = []; + + for (let i = 0; i < results.length; i++) { + // biome-ignore lint/style/noNonNullAssertion: index within bounds + const result = results[i]!; + if (result.success) { + validResults.push(result.data); + } else { + // biome-ignore lint/style/noNonNullAssertion: index within bounds + failures.push({ target: activeTargets[i]!, error: result.error }); + } + } + + if (validResults.length === 0 && failures.length > 0) { + // biome-ignore lint/style/noNonNullAssertion: guarded by failures.length > 0 + const { error: first } = failures[0]!; + throw new Error( + `Failed to fetch alert rules from ${targets.length} project(s): ${first.message}` + ); + } + + if (failures.length > 0) { + const failedNames = failures + .map(({ target: t }) => `${t.org}/${t.project}`) + .join(", "); + logger.warn( + `Failed to fetch alert rules from ${failedNames}. Showing results from ${validResults.length} project(s).` + ); + } + + const isMultiProject = validResults.length > 1; + const isSingleProject = validResults.length === 1; + const firstTarget = validResults[0]?.target; + + const { entries } = isMultiProject + ? buildProjectAliasMap(validResults) + : { entries: {} as Record }; + + if (isMultiProject) { + const fingerprint = createDsnFingerprint(detectedDsns ?? []); + setProjectAliases(entries, fingerprint); + } else { + clearProjectAliases(); + } + + // Apply client-side name filter + const allRows: AlertRuleRow[] = validResults.flatMap((r) => + r.rules.map((rule) => ({ rule, target: r.target })) + ); + const filteredRows = flags.query + ? allRows.filter((row) => + row.rule.name.toLowerCase().includes(flags.query?.toLowerCase() ?? "") + ) + : allRows; + + const displayRows = trimWithProjectGuarantee(filteredRows, flags.limit); + const trimmed = displayRows.length < filteredRows.length; + const hasMoreToShow = hasMore || hasAnyCursor || trimmed; + const canPaginate = hasAnyCursor; + + const allRules = displayRows.map((r) => r.rule); + + if (displayRows.length === 0) { + const hint = footer + ? `No issue alert rules found.\n\n${footer}` + : "No issue alert rules found."; + return { items: [], hint, hasMore: hasMoreToShow, hasPrev }; + } + + const title = + isSingleProject && firstTarget + ? `Issue alert rules in ${firstTarget.orgDisplay}/${firstTarget.projectDisplay}` + : `Issue alert rules from ${validResults.length} projects`; + + let moreHint: string | undefined; + if (hasMoreToShow) { + const higherLimit = Math.min(flags.limit * 2, MAX_LIMIT); + const canIncreaseLimit = higherLimit > flags.limit; + const actionParts: string[] = []; + if (canIncreaseLimit) { + actionParts.push(`-n ${higherLimit}`); + } + if (canPaginate) { + actionParts.push("-c next"); + } + if (actionParts.length > 0) { + moreHint = `More alert rules available — use ${actionParts.join(" or ")} for more.`; + } + } + if (hasPrev) { + const prevPart = "Prev: -c prev"; + moreHint = moreHint ? `${moreHint}\n${prevPart}` : prevPart; + } + + return { + items: allRules, + hasMore: hasMoreToShow, + hasPrev, + displayRows, + title, + moreHint, + footer, + }; +} + +// --------------------------------------------------------------------------- +// Human output +// --------------------------------------------------------------------------- + +function formatIssueAlertListHuman(result: IssueAlertListResult): string { + if (result.items.length === 0) { + return result.hint ?? "No issue alert rules found."; + } + + const rows = result.displayRows ?? []; + const uniqueProjects = new Set( + rows.map((r) => `${r.target.org}/${r.target.project}`) + ); + const isMultiProject = uniqueProjects.size > 1; + + type Row = { + id: string; + name: string; + project?: string; + conditions: string; + actions: string; + environment: string; + status: string; + }; + + const tableRows: Row[] = rows.map(({ rule: r, target }) => ({ + id: r.id, + name: escapeMarkdownCell(r.name), + ...(isMultiProject && { + project: `${target.org}/${target.project}`, + }), + conditions: String(r.conditions.length), + actions: String(r.actions.length), + environment: r.environment ?? "all", + status: + r.status === "active" + ? colorTag("green", "active") + : colorTag("muted", r.status), + })); + + const columns: Column[] = [ + { header: "ID", value: (r) => r.id }, + { header: "NAME", value: (r) => r.name }, + ...(isMultiProject + ? [{ header: "PROJECT", value: (r: Row) => r.project ?? "" }] + : []), + { header: "CONDITIONS", value: (r) => r.conditions }, + { header: "ACTIONS", value: (r) => r.actions }, + { header: "ENVIRONMENT", value: (r) => r.environment }, + { header: "STATUS", value: (r) => r.status }, + ]; + + const parts: string[] = []; + const buffer: Writer = { write: (s) => parts.push(s) }; + + if (result.title) { + parts.push(`${result.title}:\n\n`); + } + + writeTable(buffer, tableRows, columns); + + return parts.join("").trimEnd(); +} + +const jsonTransformIssueAlertList = jsonTransformListResult; + +// --------------------------------------------------------------------------- +// Command +// --------------------------------------------------------------------------- + +export const listCommand = buildListCommand("alert", { + docs: { + brief: "List issue alert rules", + fullDescription: + "List issue alert rules for one or more Sentry projects.\n\n" + + "Issue alerts trigger notifications when error events match conditions.\n\n" + + "Target patterns:\n" + + " sentry alert issues list # auto-detect from DSN or config\n" + + " sentry alert issues list / # explicit org and project\n" + + " sentry alert issues list / # all projects in org\n" + + " sentry alert issues list # find project across all orgs\n\n" + + `${targetPatternExplanation()}\n\n` + + "In monorepos with multiple Sentry projects, shows alert rules from all detected projects.\n\n" + + "Use --cursor / -c next / -c prev to paginate through larger result sets.", + }, + output: { + human: formatIssueAlertListHuman, + jsonTransform: jsonTransformIssueAlertList, + }, + parameters: { + positional: LIST_TARGET_POSITIONAL, + flags: { + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + limit: buildListLimitFlag("issue alert rules"), + query: { + kind: "parsed", + parse: String, + brief: "Filter rules by name", + optional: true, + }, + cursor: { + kind: "parsed", + parse: parseCursorFlag, + brief: + 'Pagination cursor (use "next" for next page, "prev" for previous)', + optional: true, + }, + }, + aliases: { ...LIST_BASE_ALIASES, w: "web", q: "query" }, + }, + async *func(this: SentryContext, flags: ListFlags, target?: string) { + const { cwd } = this; + const parsed = parseOrgProjectArg(target); + + // --web: open browser when org and project are known from the target arg + if (flags.web && parsed.type === "explicit") { + await openInBrowser( + buildIssueAlertsUrl(parsed.org, parsed.project), + "issue alert rules" + ); + return; + } + + // biome-ignore lint/suspicious/noExplicitAny: shared handler accepts any mode variant + const resolveAndHandle: ModeHandler = (ctx) => + handleResolvedTargets({ ...ctx, flags }); + + const result = (await dispatchOrgScopedList({ + config: issueAlertListMeta, + cwd, + flags, + parsed, + orgSlugMatchBehavior: "redirect", + // All modes use per-project fetching with compound cursor support + allowCursorInModes: [ + "auto-detect", + "explicit", + "project-search", + "org-all", + ], + overrides: { + "auto-detect": resolveAndHandle, + explicit: resolveAndHandle, + "project-search": resolveAndHandle, + "org-all": resolveAndHandle, + }, + })) as IssueAlertListResult; + + let combinedHint: string | undefined; + if (result.items.length > 0) { + const hintParts: string[] = []; + if (result.moreHint) { + hintParts.push(result.moreHint); + } + if (result.footer) { + hintParts.push(result.footer); + } + combinedHint = hintParts.length > 0 ? hintParts.join("\n") : result.hint; + } + + yield new CommandOutput(result); + return { hint: combinedHint }; + }, +}); + +/** @internal Exported for testing only. */ +export const __testing = { + trimWithProjectGuarantee, + encodeCompoundCursor, + decodeCompoundCursor, + buildMultiTargetContextKey, + buildProjectAliasMap, + phase1HasMore, + CURSOR_SEP, + MAX_LIMIT, +}; diff --git a/src/commands/alert/issues/rule-resolve.ts b/src/commands/alert/issues/rule-resolve.ts new file mode 100644 index 000000000..eede27a7f --- /dev/null +++ b/src/commands/alert/issues/rule-resolve.ts @@ -0,0 +1,166 @@ +/** + * Shared issue alert rule reference parsing and resolution (view / delete / edit). + */ + +import { MAX_PAGINATION_PAGES } from "../../../lib/api/infrastructure.js"; +import { + API_MAX_PER_PAGE, + getIssueAlertRule, + type IssueAlertRule, + isNotFoundApiError, + listIssueAlertsPaginated, +} from "../../../lib/api-client.js"; +import { ResolutionError, ValidationError } from "../../../lib/errors.js"; +import { fuzzyMatch } from "../../../lib/fuzzy.js"; +import type { ResolvedTarget } from "../../../lib/resolve-target.js"; +import { isAllDigits } from "../../../lib/utils.js"; + +export type IssueRuleResolution = { + target: ResolvedTarget; + rule: IssueAlertRule; +}; + +/** + * Parse `org/project/` (two+ slashes) or a bare `rule-id-or-name` (no slashes). + * A single `org/project` (exactly one slash) is invalid — the rule id or name is required. + */ +export function parseIssueRuleArg( + arg: string, + usageHint: string +): { + ref: string; + targetArg: string | undefined; +} { + const trimmed = arg.trim(); + if (!trimmed) { + throw new ValidationError( + `Rule id or name is required.\nUse: ${usageHint}`, + "rule" + ); + } + + const slashCount = [...trimmed].filter((c) => c === "/").length; + if (slashCount === 0) { + return { ref: trimmed, targetArg: undefined }; + } + if (slashCount === 1) { + throw new ValidationError( + `Missing rule id or name after the project (got '${trimmed}').\n` + + `Use: ${usageHint}\n` + + `Example: ${trimmed}/`, + "rule" + ); + } + + const lastSlash = trimmed.lastIndexOf("/"); + const targetPart = trimmed.slice(0, lastSlash).trim(); + const ref = trimmed.slice(lastSlash + 1).trim(); + if (!ref) { + throw new ValidationError( + `Invalid rule reference '${arg}'.\nUse: ${usageHint}`, + "rule" + ); + } + return { ref, targetArg: targetPart || undefined }; +} + +/** List all issue alert rules for a project (paginated). */ +export async function listAllIssueRulesForTarget( + target: ResolvedTarget +): Promise { + const all: IssueAlertRule[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await listIssueAlertsPaginated( + target.org, + target.project, + { perPage: API_MAX_PER_PAGE, cursor } + ); + all.push(...data); + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + return all; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per-target list + name resolution +export async function resolveIssueAlertRule( + targets: ResolvedTarget[], + ref: string, + usageHint: string +): Promise { + if (isAllDigits(ref)) { + const hits: IssueRuleResolution[] = []; + for (const target of targets) { + try { + const rule = await getIssueAlertRule(target.org, target.project, ref); + hits.push({ target, rule }); + } catch (e) { + if (isNotFoundApiError(e)) { + continue; + } + throw e; + } + } + + if (hits.length === 1) { + return hits[0] as IssueRuleResolution; + } + if (hits.length > 1) { + throw new ValidationError( + `Alert rule ID '${ref}' matched multiple projects.\n` + + "Use an explicit target: sentry alert issues //" + ); + } + throw new ResolutionError( + `Issue alert rule '${ref}'`, + "not found", + usageHint + ); + } + + const hits: IssueRuleResolution[] = []; + const allRuleNames: string[] = []; + + for (const target of targets) { + const rules = await listAllIssueRulesForTarget(target); + for (const r of rules) { + allRuleNames.push(r.name); + } + const exact = rules.find( + (rule) => rule.name.toLowerCase() === ref.toLowerCase() + ); + if (exact) { + hits.push({ target, rule: exact }); + } + } + + if (hits.length === 1) { + return hits[0] as IssueRuleResolution; + } + if (hits.length > 1) { + throw new ValidationError( + `Alert rule name '${ref}' matched multiple projects.\n` + + "Use an explicit target: sentry alert issues //" + ); + } + + const uniqueNames = [...new Set(allRuleNames)]; + const similar = fuzzyMatch(ref, uniqueNames, { maxResults: 5 }); + if (similar.length > 0) { + const lines = similar.map((n) => ` ${n}`).join("\n"); + throw new ValidationError( + `No issue alert rule named '${ref}' in the selected project(s).\n\n` + + `Did you mean:\n${lines}`, + "rule" + ); + } + + throw new ResolutionError( + `Issue alert rule '${ref}'`, + "not found", + usageHint + ); +} diff --git a/src/commands/alert/issues/view.ts b/src/commands/alert/issues/view.ts new file mode 100644 index 000000000..335ff00a3 --- /dev/null +++ b/src/commands/alert/issues/view.ts @@ -0,0 +1,105 @@ +import type { SentryContext } from "../../../context.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { openInBrowser } from "../../../lib/browser.js"; +import { buildCommand } from "../../../lib/command.js"; +import { ContextError } from "../../../lib/errors.js"; +import { CommandOutput } from "../../../lib/formatters/output.js"; +import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; +import { buildIssueAlertsUrl } from "../../../lib/sentry-urls.js"; +import { + type IssueRuleResolution, + parseIssueRuleArg, + resolveIssueAlertRule, +} from "./rule-resolve.js"; + +const USAGE_HINT = "sentry alert issues view //"; + +type ViewFlags = { + readonly web: boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type IssueAlertViewResult = IssueRuleResolution; + +function formatIssueAlertView(data: IssueRuleResolution): string { + const { target, rule } = data; + return [ + `Issue alert rule in ${target.org}/${target.project}:`, + "", + `ID: ${rule.id}`, + `Name: ${rule.name}`, + `Status: ${rule.status}`, + `Action Match: ${rule.actionMatch}`, + `Frequency: ${rule.frequency}m`, + `Conditions: ${rule.conditions.length}`, + `Actions: ${rule.actions.length}`, + `Environment: ${rule.environment ?? "all"}`, + `Owner: ${rule.owner ?? "none"}`, + ].join("\n"); +} + +export const viewCommand = buildCommand({ + docs: { + brief: "View an issue alert rule", + fullDescription: + "View a single issue alert rule by ID or name.\n\n" + + "Examples:\n" + + " sentry alert issues view 12345\n" + + " sentry alert issues view my-org/my-project/12345\n" + + " sentry alert issues view my-org/my-project/'Error Spike'", + }, + output: { + human: formatIssueAlertView, + jsonTransform: (data: IssueAlertViewResult) => ({ + ...data.rule, + org: data.target.org, + project: data.target.project, + }), + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project/rule-id-or-name", + brief: "Issue alert rule ID or name", + parse: String, + }, + ], + }, + flags: { + web: { + kind: "boolean", + brief: "Open issue alert rules page in browser", + default: false, + }, + }, + aliases: { w: "web" }, + }, + async *func(this: SentryContext, flags: ViewFlags, arg: string) { + const { cwd } = this; + const { ref, targetArg } = parseIssueRuleArg(arg, USAGE_HINT); + const parsed = parseOrgProjectArg(targetArg); + + const { targets } = await resolveTargetsFromParsedArg(parsed, { + cwd, + usageHint: "sentry alert issues view //", + }); + if (targets.length === 0) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + const result = await resolveIssueAlertRule(targets, ref, USAGE_HINT); + + if (flags.web) { + await openInBrowser( + buildIssueAlertsUrl(result.target.org, result.target.project), + "issue alert rules" + ); + return; + } + + yield new CommandOutput(result); + }, +}); diff --git a/src/commands/alert/metrics/create.ts b/src/commands/alert/metrics/create.ts new file mode 100644 index 000000000..dd62ba45a --- /dev/null +++ b/src/commands/alert/metrics/create.ts @@ -0,0 +1,224 @@ +/** + * sentry alert metrics create + * + * Create an organization-scoped metric alert rule. + */ + +import type { SentryContext } from "../../../context.js"; +import { createMetricAlertRule } from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { buildCommand, numberParser } from "../../../lib/command.js"; +import { ContextError, ValidationError } from "../../../lib/errors.js"; +import { CommandOutput } from "../../../lib/formatters/output.js"; +import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../../lib/mutate-command.js"; +import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; +import { + normalizeProjectList, + parseJsonObjectList, + validateMetricDataset, + validateMetricTimeWindow, + validateMetricTriggers, +} from "../mutation-utils.js"; + +const USAGE_HINT = + "sentry alert metrics create --name --query --aggregate --dataset --time-window --trigger "; + +type CreateFlags = { + readonly name: string; + readonly query: string; + readonly aggregate: string; + readonly dataset: string; + readonly "time-window": number; + readonly trigger?: string[]; + readonly project?: string[]; + readonly environment?: string; + readonly owner?: string; + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type CreateResult = { + org: string; + id?: string; + name: string; + status?: "active" | "disabled"; + dryRun?: boolean; + body?: Record; +}; + +function formatCreated(result: CreateResult): string { + if (result.dryRun) { + return `Would create metric alert rule '${result.name}' in ${result.org}.`; + } + return `Created metric alert rule ${result.id ?? "(unknown id)"} in ${result.org}: ${result.name} (${result.status ?? "active"}).`; +} + +export const createCommand = buildCommand({ + docs: { + brief: "Create a metric alert rule", + fullDescription: + "Create an organization-scoped metric alert rule.\n\n" + + "Required fields:\n" + + " --name, --query, --aggregate, --dataset, --time-window, --trigger (>=1)\n\n" + + "Optional fields:\n" + + " --project (repeatable), --environment, --owner\n\n" + + "Examples:\n" + + " sentry alert metrics create my-org --name 'P95 latency' \\\n" + + " --query 'environment:prod' --aggregate 'p95(transaction.duration)' \\\n" + + " --dataset transactions --time-window 5 \\\n" + + ' --trigger \'{"alertThreshold":500,"actions":[{"id":"sentry.mail.actions.NotifyEmailAction","targetType":"Team","targetIdentifier":1}]}\'\n\n' + + " sentry alert metrics create my-org --name 'Error volume' \\\n" + + " --query 'event.type:error' --aggregate 'count()' --dataset errors \\\n" + + ' --time-window 15 --trigger \'[{"alertThreshold":100,"actions":[{"id":"sentry.mail.actions.NotifyEmailAction","targetType":"Team","targetIdentifier":1}]}]\' \\\n' + + " --project my-app --dry-run", + }, + output: { + human: formatCreated, + jsonTransform: (result: CreateResult) => result, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org", + brief: "Target organization", + parse: String, + }, + ], + }, + flags: { + name: { + kind: "parsed", + parse: String, + brief: "Rule name", + }, + query: { + kind: "parsed", + parse: String, + brief: "Metric query filter string", + }, + aggregate: { + kind: "parsed", + parse: String, + brief: + "Aggregate expression (for example count(), p95(transaction.duration))", + }, + dataset: { + kind: "parsed", + parse: String, + brief: + "Dataset (errors, transactions, sessions, events, spans, metrics)", + }, + "time-window": { + kind: "parsed", + parse: numberParser, + brief: "Evaluation window in minutes", + }, + trigger: { + kind: "parsed", + parse: String, + variadic: true, + optional: true, + brief: "Trigger object JSON (repeatable, or pass one JSON array)", + }, + project: { + kind: "parsed", + parse: String, + variadic: true, + optional: true, + brief: "Project slug filter (repeatable or comma-separated)", + }, + environment: { + kind: "parsed", + parse: String, + optional: true, + brief: "Environment filter", + }, + owner: { + kind: "parsed", + parse: String, + optional: true, + brief: "Owner value accepted by Sentry API", + }, + "dry-run": DRY_RUN_FLAG, + }, + aliases: { ...DRY_RUN_ALIASES, t: "trigger", p: "project" }, + }, + async *func(this: SentryContext, flags: CreateFlags, arg: string) { + const { cwd } = this; + if (!flags.name.trim()) { + throw new ValidationError("Rule name cannot be empty.", "name"); + } + if (!flags.query.trim()) { + throw new ValidationError("query cannot be empty.", "query"); + } + if (!flags.aggregate.trim()) { + throw new ValidationError("aggregate cannot be empty.", "aggregate"); + } + + const dataset = flags.dataset.trim().toLowerCase(); + validateMetricDataset(dataset); + validateMetricTimeWindow(flags["time-window"]); + + const triggers = parseJsonObjectList(flags.trigger, "trigger"); + validateMetricTriggers(triggers); + const projects = normalizeProjectList(flags.project); + + const parsed = parseOrgProjectArg(arg); + const { targets } = await resolveTargetsFromParsedArg(parsed, { + cwd, + usageHint: USAGE_HINT, + }); + const orgSlugs = [...new Set(targets.map((target) => target.org))]; + if (orgSlugs.length === 0) { + throw new ContextError("Organization", USAGE_HINT); + } + if (orgSlugs.length !== 1) { + throw new ValidationError( + "Provide a single explicit organization target for create.", + "target" + ); + } + const orgSlug = orgSlugs[0] as string; + + const body: Record = { + name: flags.name, + query: flags.query, + aggregate: flags.aggregate, + dataset, + timeWindow: flags["time-window"], + triggers, + }; + if (projects && projects.length > 0) { + body.projects = projects; + } + if (flags.environment !== undefined) { + body.environment = flags.environment; + } + if (flags.owner !== undefined) { + body.owner = flags.owner; + } + + if (flags["dry-run"]) { + yield new CommandOutput({ + org: orgSlug, + name: flags.name, + dryRun: true, + body, + } satisfies CreateResult); + return { hint: "Dry run - no metric alert rule was created." }; + } + + const created = await createMetricAlertRule(orgSlug, body); + const status: "active" | "disabled" = + created.status === 0 || created.status === "0" ? "active" : "disabled"; + yield new CommandOutput({ + org: orgSlug, + id: String(created.id ?? ""), + name: String(created.name ?? flags.name), + status, + } satisfies CreateResult); + }, +}); diff --git a/src/commands/alert/metrics/delete.ts b/src/commands/alert/metrics/delete.ts new file mode 100644 index 000000000..1a956448c --- /dev/null +++ b/src/commands/alert/metrics/delete.ts @@ -0,0 +1,117 @@ +/** + * sentry alert metrics delete + * + * Permanently delete a metric (organization) alert rule. + */ + +import type { SentryContext } from "../../../context.js"; +import { deleteMetricAlertRule } from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { ContextError } from "../../../lib/errors.js"; +import { CommandOutput } from "../../../lib/formatters/output.js"; +import { logger } from "../../../lib/logger.js"; +import { + buildDeleteCommand, + confirmByTyping, + isConfirmationBypassed, +} from "../../../lib/mutate-command.js"; +import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; +import { parseMetricRuleArg, resolveMetricAlertRule } from "./rule-resolve.js"; + +const USAGE_HINT = "sentry alert metrics delete /"; + +type DeleteFlags = { + readonly yes: boolean; + readonly force: boolean; + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type DeleteResult = { + org: string; + ruleId: string; + name: string; + dryRun?: boolean; +}; + +function formatDeleted(r: DeleteResult): string { + if (r.dryRun) { + return `Would delete metric alert rule '${r.name}' (id ${r.ruleId}) in ${r.org}.`; + } + return `Deleted metric alert rule '${r.name}' (id ${r.ruleId}) in ${r.org}.`; +} + +export const deleteCommand = buildDeleteCommand({ + docs: { + brief: "Delete a metric alert rule", + fullDescription: + "Permanently remove a metric alert rule from an organization. " + + "Type org/rule-id to confirm, or use --yes / --force, or --dry-run.\n\n" + + "Examples:\n" + + " sentry alert metrics delete my-org/12345\n" + + " sentry alert metrics delete my-org/'P95 alert' --yes", + }, + output: { + human: formatDeleted, + jsonTransform: (r: DeleteResult) => + r.dryRun + ? { dryRun: true, org: r.org, id: r.ruleId, name: r.name } + : { deleted: true, org: r.org, id: r.ruleId, name: r.name }, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/rule-id-or-name", + brief: "Rule id or name (same as view)", + parse: String, + }, + ], + }, + }, + async *func(this: SentryContext, flags: DeleteFlags, arg: string) { + const { cwd } = this; + const { ref, targetArg } = parseMetricRuleArg(arg, USAGE_HINT); + const parsed = parseOrgProjectArg(targetArg); + const { targets } = await resolveTargetsFromParsedArg(parsed, { + cwd, + usageHint: USAGE_HINT, + }); + const orgSlugs = [...new Set(targets.map((t) => t.org))]; + if (orgSlugs.length === 0) { + throw new ContextError("Organization", USAGE_HINT); + } + + const { orgSlug, rule } = await resolveMetricAlertRule( + orgSlugs, + ref, + USAGE_HINT + ); + const key = `${orgSlug}/${rule.id}`; + const name = rule.name; + if (flags["dry-run"]) { + yield new CommandOutput({ + org: orgSlug, + ruleId: rule.id, + name, + dryRun: true, + } satisfies DeleteResult); + return; + } + if (!isConfirmationBypassed(flags)) { + const ok = await confirmByTyping( + key, + `Type '${key}' to permanently delete this metric alert rule:` + ); + if (!ok) { + logger.info("Delete cancelled."); + return; + } + } + + await deleteMetricAlertRule(orgSlug, rule.id); + yield new CommandOutput({ org: orgSlug, ruleId: rule.id, name }); + }, +}); diff --git a/src/commands/alert/metrics/edit.ts b/src/commands/alert/metrics/edit.ts new file mode 100644 index 000000000..b09933119 --- /dev/null +++ b/src/commands/alert/metrics/edit.ts @@ -0,0 +1,279 @@ +/** + * sentry alert metrics edit + * + * Update a metric alert rule (name and/or enabled status). + */ + +import type { SentryContext } from "../../../context.js"; +import { + getMetricAlertRuleDocument, + putMetricAlertRule, +} from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { buildCommand, numberParser } from "../../../lib/command.js"; +import { ContextError, ValidationError } from "../../../lib/errors.js"; +import { CommandOutput } from "../../../lib/formatters/output.js"; +import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; +import { + normalizeProjectList, + parseJsonObjectList, + parseStatusFlag, + statusToMetricValue, + validateMetricDataset, + validateMetricTimeWindow, + validateMetricTriggers, +} from "../mutation-utils.js"; +import { parseMetricRuleArg, resolveMetricAlertRule } from "./rule-resolve.js"; + +const USAGE_HINT = + "sentry alert metrics edit / --name | --status active|disabled"; + +type EditFlags = { + readonly name?: string; + readonly status?: "active" | "disabled" | undefined; + readonly query?: string; + readonly aggregate?: string; + readonly dataset?: string; + readonly "time-window"?: number; + readonly trigger?: string[]; + readonly project?: string[]; + readonly environment?: string; + readonly owner?: string; + readonly json: boolean; + readonly fields?: string[]; +}; + +type EditResult = Record & { + org: string; + id: string; +}; + +function hasMetricMutations(flags: EditFlags): boolean { + return ( + flags.name !== undefined || + flags.status !== undefined || + flags.query !== undefined || + flags.aggregate !== undefined || + flags.dataset !== undefined || + flags["time-window"] !== undefined || + flags.trigger !== undefined || + flags.project !== undefined || + flags.environment !== undefined || + flags.owner !== undefined + ); +} + +function validateMetricEditFlags(flags: EditFlags): void { + if (!hasMetricMutations(flags)) { + throw new ValidationError( + "Pass at least one editable field (for example --name or --status).", + "name" + ); + } +} + +function applyMetricCoreFields( + body: Record, + flags: EditFlags +): Record { + if (flags.name !== undefined) { + body.name = flags.name; + } + if (flags.status !== undefined) { + body.status = statusToMetricValue(flags.status); + } + if (flags.query !== undefined) { + if (!flags.query.trim()) { + throw new ValidationError("query cannot be empty.", "query"); + } + body.query = flags.query; + } + if (flags.aggregate !== undefined) { + if (!flags.aggregate.trim()) { + throw new ValidationError("aggregate cannot be empty.", "aggregate"); + } + body.aggregate = flags.aggregate; + } + if (flags.dataset !== undefined) { + const dataset = flags.dataset.trim().toLowerCase(); + validateMetricDataset(dataset); + body.dataset = dataset; + } + if (flags["time-window"] !== undefined) { + validateMetricTimeWindow(flags["time-window"]); + body.timeWindow = flags["time-window"]; + } + return body; +} + +function applyMetricOptionalFields( + body: Record, + flags: EditFlags +): Record { + if (flags.trigger !== undefined) { + body.triggers = parseJsonObjectList(flags.trigger, "trigger"); + } + if (flags.project !== undefined) { + body.projects = normalizeProjectList(flags.project) ?? []; + } + if (flags.environment !== undefined) { + body.environment = + flags.environment.trim() === "" ? null : flags.environment; + } + if (flags.owner !== undefined) { + body.owner = flags.owner.trim() === "" ? null : flags.owner; + } + return body; +} + +function validateMetricBody(body: Record): void { + validateMetricTriggers( + body.triggers as Record[] | undefined + ); + validateMetricDataset(String(body.dataset ?? "")); + validateMetricTimeWindow(Number(body.timeWindow ?? 0)); + if (typeof body.query !== "string" || body.query.trim() === "") { + throw new ValidationError("query must be present and non-empty.", "query"); + } + if (typeof body.aggregate !== "string" || body.aggregate.trim() === "") { + throw new ValidationError( + "aggregate must be present and non-empty.", + "aggregate" + ); + } +} + +function formatEdited(r: EditResult): string { + return `Updated metric alert rule ${r.id} in ${r.org}: ${String(r.name)} (${String(r.status)}).`; +} + +export const editCommand = buildCommand({ + docs: { + brief: "Edit a metric alert rule", + fullDescription: + "Update a metric alert rule. Pass at least one of --name or --status. " + + "Status 'active' enables the rule; 'disabled' sets it to disabled (API status 1).\n\n" + + "Examples:\n" + + " sentry alert metrics edit my-org/9 --name 'Error budget'\n" + + " sentry alert metrics edit my-org/9 --status disabled\n" + + " sentry alert metrics edit my-org/9 --time-window 15 --dataset transactions", + }, + output: { + human: formatEdited, + jsonTransform: (r: EditResult) => r, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/rule-id-or-name", + brief: "Rule id or name (same as view)", + parse: String, + }, + ], + }, + flags: { + name: { + kind: "parsed", + parse: String, + optional: true, + brief: "New rule name", + }, + status: { + kind: "parsed", + parse: parseStatusFlag, + optional: true, + brief: "active or disabled", + }, + query: { + kind: "parsed", + parse: String, + optional: true, + brief: "Metric query filter", + }, + aggregate: { + kind: "parsed", + parse: String, + optional: true, + brief: "Aggregate expression", + }, + dataset: { + kind: "parsed", + parse: String, + optional: true, + brief: + "Dataset (errors, transactions, sessions, events, spans, metrics)", + }, + "time-window": { + kind: "parsed", + parse: numberParser, + optional: true, + brief: "Evaluation window in minutes", + }, + trigger: { + kind: "parsed", + parse: String, + variadic: true, + optional: true, + brief: "Trigger object JSON (repeatable, or pass one JSON array)", + }, + project: { + kind: "parsed", + parse: String, + variadic: true, + optional: true, + brief: "Project slug filter (repeatable or comma-separated)", + }, + environment: { + kind: "parsed", + parse: String, + optional: true, + brief: "Environment value (pass empty string to clear)", + }, + owner: { + kind: "parsed", + parse: String, + optional: true, + brief: "Owner value (pass empty string to clear)", + }, + }, + aliases: { + t: "trigger", + p: "project", + }, + }, + async *func(this: SentryContext, flags: EditFlags, arg: string) { + const { cwd } = this; + validateMetricEditFlags(flags); + const { ref, targetArg } = parseMetricRuleArg(arg, USAGE_HINT); + const parsed = parseOrgProjectArg(targetArg); + const { targets } = await resolveTargetsFromParsedArg(parsed, { + cwd, + usageHint: USAGE_HINT, + }); + const orgSlugs = [...new Set(targets.map((t) => t.org))]; + if (orgSlugs.length === 0) { + throw new ContextError("Organization", USAGE_HINT); + } + const { orgSlug, rule } = await resolveMetricAlertRule( + orgSlugs, + ref, + USAGE_HINT + ); + const body = { + ...(await getMetricAlertRuleDocument(orgSlug, rule.id)), + } as Record; + applyMetricCoreFields(body, flags); + applyMetricOptionalFields(body, flags); + validateMetricBody(body); + const updated = await putMetricAlertRule(orgSlug, rule.id, body); + yield new CommandOutput({ + ...updated, + org: orgSlug, + id: String(updated.id ?? rule.id), + status: + updated.status === 0 || updated.status === "0" ? "active" : "disabled", + } satisfies EditResult); + }, +}); diff --git a/src/commands/alert/metrics/index.ts b/src/commands/alert/metrics/index.ts new file mode 100644 index 000000000..275f63699 --- /dev/null +++ b/src/commands/alert/metrics/index.ts @@ -0,0 +1,28 @@ +import { buildRouteMap } from "../../../lib/route-map.js"; +import { createCommand } from "./create.js"; +import { deleteCommand } from "./delete.js"; +import { editCommand } from "./edit.js"; +import { listCommand } from "./list.js"; +import { viewCommand } from "./view.js"; + +export const metricsRoute = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + create: createCommand, + delete: deleteCommand, + edit: editCommand, + }, + docs: { + brief: "Manage metric alert rules", + fullDescription: + "View and manage metric alert rules in your Sentry organization.\n\n" + + "Commands:\n" + + " list List metric alert rules\n" + + " view View metric alert rule details\n" + + " create Create a metric alert rule\n" + + " delete Delete a metric alert rule\n" + + " edit Update a metric alert rule", + hideRoute: {}, + }, +}); diff --git a/src/commands/alert/metrics/list.ts b/src/commands/alert/metrics/list.ts new file mode 100644 index 000000000..9a1d8fc62 --- /dev/null +++ b/src/commands/alert/metrics/list.ts @@ -0,0 +1,685 @@ +/** + * sentry alert metrics list + * + * List metric alert rules for one or more Sentry organizations. + * + * Metric alerts are org-scoped. Supports all four target modes: + * - auto-detect → DSN detection / config defaults (may resolve multiple orgs) + * - explicit → single org/project (project part ignored, metric alerts are org-scoped) + * - org-all → all metric alert rules for the specified org (cursor-paginated) + * - project-search → find project across orgs, use its org + */ + +import type { SentryContext } from "../../../context.js"; +import type { MetricAlertRule } from "../../../lib/api/alerts.js"; +import { MAX_PAGINATION_PAGES } from "../../../lib/api/infrastructure.js"; +import { + API_MAX_PER_PAGE, + listMetricAlertsPaginated, +} from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { openInBrowser } from "../../../lib/browser.js"; +import { + advancePaginationState, + buildMultiOrgContextKey, + CURSOR_SEP, + decodeCompoundCursor, + encodeCompoundCursor, + hasPreviousPage, + resolveCursor, +} from "../../../lib/db/pagination.js"; +import { ContextError, withAuthGuard } from "../../../lib/errors.js"; +import { + colorTag, + escapeMarkdownCell, +} from "../../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../../lib/formatters/output.js"; +import { type Column, writeTable } from "../../../lib/formatters/table.js"; +import { + buildListCommand, + buildListLimitFlag, + LIST_BASE_ALIASES, + LIST_TARGET_POSITIONAL, + parseCursorFlag, + targetPatternExplanation, +} from "../../../lib/list-command.js"; +import { logger } from "../../../lib/logger.js"; +import { + dispatchOrgScopedList, + type FetchResult as FetchResultOf, + jsonTransformListResult, + type ListCommandMeta, + type ListResult, + type ModeHandler, + trimWithGroupGuarantee, +} from "../../../lib/org-list.js"; +import { withProgress } from "../../../lib/polling.js"; +import { + type ResolvedTarget, + resolveTargetsFromParsedArg, +} from "../../../lib/resolve-target.js"; +import { buildMetricAlertsUrl } from "../../../lib/sentry-urls.js"; +import type { Writer } from "../../../types/index.js"; + +/** Command key for pagination cursor storage */ +export const PAGINATION_KEY = "alert-metrics-list"; + +const USAGE_HINT = "sentry alert metrics list /"; + +const MAX_LIMIT = 1000; + +type ListFlags = { + readonly web: boolean; + readonly fresh: boolean; + readonly limit: number; + readonly cursor?: string; + readonly json: boolean; + readonly fields?: string[]; + readonly query?: string; +}; + +/** Per-org fetch result */ +type MetricRuleFetchResult = { + orgSlug: string; + rules: MetricAlertRule[]; + hasMore?: boolean; + nextCursor?: string; +}; + +/** Success/failure wrapper for per-org fetches */ +type FetchResult = FetchResultOf; + +/** Display row carrying per-rule org context for the human formatter. */ +type MetricAlertRow = { rule: MetricAlertRule; orgSlug: string }; + +/** + * Extended result type: raw rules in `items` (for JSON), display rows in + * `displayRows` (for human output). + */ +type MetricAlertListResult = ListResult & { + displayRows?: MetricAlertRow[]; + title?: string; + moreHint?: string; + footer?: string; +}; + +const metricAlertListMeta: ListCommandMeta = { + paginationKey: PAGINATION_KEY, + entityName: "metric alert rule", + entityPlural: "metric alert rules", + commandPrefix: "sentry alert metrics list", +}; + +// --------------------------------------------------------------------------- +// Fetch helpers +// --------------------------------------------------------------------------- + +/** + * Fetch metric alert rules for one org with auth guard. + * Paginates locally up to the given limit. + */ +async function fetchRulesForOrg( + orgSlug: string, + options: { limit: number; startCursor?: string } +): Promise { + const result = await withAuthGuard(async () => { + const rules: MetricAlertRule[] = []; + let serverCursor = options.startCursor; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await listMetricAlertsPaginated(orgSlug, { + perPage: Math.min(options.limit - rules.length, API_MAX_PER_PAGE), + cursor: serverCursor, + }); + + for (const rule of data) { + rules.push(rule); + if (rules.length >= options.limit) { + return { + orgSlug, + rules, + hasMore: !!nextCursor, + nextCursor: nextCursor ?? undefined, + }; + } + } + + if (!nextCursor) { + return { orgSlug, rules, hasMore: false }; + } + serverCursor = nextCursor; + } + + return { orgSlug, rules, hasMore: false }; + }); + + if (!result.ok) { + const error = + result.error instanceof Error + ? result.error + : new Error(String(result.error)); + return { success: false, error }; + } + return { success: true, data: result.value }; +} + +/** + * Execute Phase 2: redistribute surplus budget to expandable orgs. + */ +async function runPhase2( + orgs: string[], + phase1: FetchResult[], + expandableIndices: number[], + surplus: number +): Promise { + const extraQuota = Math.max(1, Math.ceil(surplus / expandableIndices.length)); + + const phase2 = await Promise.all( + expandableIndices.map((i) => { + // biome-ignore lint/style/noNonNullAssertion: guaranteed by expandableIndices filter + const org = orgs[i]!; + const r = phase1[i] as { success: true; data: MetricRuleFetchResult }; + // biome-ignore lint/style/noNonNullAssertion: same guarantee + const cursor = r.data.nextCursor!; + return fetchRulesForOrg(org, { limit: extraQuota, startCursor: cursor }); + }) + ); + + for (let j = 0; j < expandableIndices.length; j++) { + // biome-ignore lint/style/noNonNullAssertion: j is within expandableIndices bounds + const i = expandableIndices[j]!; + const p2 = phase2[j]; + const p1 = phase1[i]; + if (p1?.success && p2?.success) { + p1.data.rules.push(...p2.data.rules); + p1.data.hasMore = p2.data.hasMore; + p1.data.nextCursor = p2.data.nextCursor; + } + } +} + +/** True if any org in phase 1 still has additional pages to fetch. */ +function phase1HasMore(phase1: FetchResult[]): boolean { + return phase1.some((r) => r.success && r.data.hasMore); +} + +/** + * Fetch metric alert rules from multiple orgs within a global limit budget. + * + * Phase 1: distribute quota per org, fetch in parallel. + * Phase 2: redistribute surplus to expandable orgs. + */ +async function fetchWithBudget( + orgs: string[], + options: { limit: number; startCursors?: Map }, + onProgress: (fetched: number) => void +): Promise<{ results: FetchResult[]; hasMore: boolean }> { + const { limit, startCursors } = options; + const quota = Math.max(1, Math.ceil(limit / orgs.length)); + + const phase1 = await Promise.all( + orgs.map((org) => + fetchRulesForOrg(org, { + limit: quota, + startCursor: startCursors?.get(org), + }) + ) + ); + + let totalFetched = 0; + for (const r of phase1) { + if (r.success) { + totalFetched += r.data.rules.length; + } + } + onProgress(totalFetched); + + const surplus = limit - totalFetched; + if (surplus <= 0) { + return { + results: phase1, + hasMore: phase1HasMore(phase1), + }; + } + + const expandableIndices: number[] = []; + for (let i = 0; i < phase1.length; i++) { + const r = phase1[i]; + if (r?.success && r.data.rules.length >= quota && r.data.nextCursor) { + expandableIndices.push(i); + } + } + + if (expandableIndices.length === 0) { + return { + results: phase1, + hasMore: phase1HasMore(phase1), + }; + } + + await runPhase2(orgs, phase1, expandableIndices, surplus); + + totalFetched = 0; + for (const r of phase1) { + if (r.success) { + totalFetched += r.data.rules.length; + } + } + onProgress(totalFetched); + + return { + results: phase1, + hasMore: phase1HasMore(phase1), + }; +} + +/** + * Trim display rows to the global limit while guaranteeing at least one row + * per org (when possible). + */ +function trimWithOrgGuarantee( + rows: MetricAlertRow[], + limit: number +): MetricAlertRow[] { + return trimWithGroupGuarantee(rows, limit, (r) => r.orgSlug); +} + +// --------------------------------------------------------------------------- +// Mode handlers +// --------------------------------------------------------------------------- + +type ResolvedOrgsOptions = { + parsed: ReturnType; + flags: ListFlags; + cwd: string; +}; + +/** + * Resolve the org slug(s) for a metric alert listing. + * + * Metric alerts are org-scoped — no project enumeration is needed. + * `explicit` and `org-all` give us the org directly from the parsed arg. + * `auto-detect` and `project-search` need full target resolution (DSN + * detection / cross-org project search) to discover the org(s). + */ +async function resolveOrgs( + parsed: ReturnType, + cwd: string +): Promise<{ orgs: string[]; footer?: string }> { + if (parsed.type === "explicit" || parsed.type === "org-all") { + return { orgs: [parsed.org] }; + } + const { targets, footer } = await resolveTargetsFromParsedArg(parsed, { + cwd, + usageHint: USAGE_HINT, + }); + return { + orgs: [...new Set(targets.map((t: ResolvedTarget) => t.org))], + footer, + }; +} + +/** + * Handle all four modes: resolve orgs → fetch within budget → compound cursor + * per org → display. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inherent multi-org resolution, compound cursor, error handling, and display logic +async function handleResolvedOrgs( + options: ResolvedOrgsOptions +): Promise { + const { parsed, flags, cwd } = options; + + const { orgs: resolved, footer } = await resolveOrgs(parsed, cwd); + + if (resolved.length === 0) { + throw new ContextError("Organization", USAGE_HINT); + } + + const uniqueOrgs = [...new Set(resolved)]; + + const contextKey = buildMultiOrgContextKey(uniqueOrgs, flags.query); + const sortedOrgKeys = [...uniqueOrgs].sort(); + + const startCursors = new Map(); + const exhaustedOrgs = new Set(); + const { cursor: rawCursor, direction } = resolveCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); + if (rawCursor) { + const decoded = decodeCompoundCursor(rawCursor); + for (let i = 0; i < decoded.length && i < sortedOrgKeys.length; i++) { + const cursor = decoded[i]; + // biome-ignore lint/style/noNonNullAssertion: i is within bounds + const key = sortedOrgKeys[i]!; + if (cursor) { + startCursors.set(key, cursor); + } else { + exhaustedOrgs.add(key); + } + } + } + + const activeOrgs = + exhaustedOrgs.size > 0 + ? uniqueOrgs.filter((org) => !exhaustedOrgs.has(org)) + : uniqueOrgs; + + const orgCount = activeOrgs.length; + const baseMessage = + orgCount > 1 + ? `Fetching metric alert rules from ${orgCount} organizations` + : "Fetching metric alert rules"; + + const { results, hasMore } = await withProgress( + { message: `${baseMessage} (up to ${flags.limit})...`, json: flags.json }, + (setMessage) => + fetchWithBudget( + activeOrgs, + { limit: flags.limit, startCursors }, + (fetched) => { + setMessage( + `${baseMessage}, ${fetched} and counting (up to ${flags.limit})...` + ); + } + ) + ); + + const cursorValues: (string | null)[] = sortedOrgKeys.map((key) => { + if (exhaustedOrgs.has(key)) { + return null; + } + const result = results.find((r) => r.success && r.data.orgSlug === key); + if (result?.success) { + return result.data.nextCursor ?? null; + } + return startCursors.get(key) ?? null; + }); + const hasAnyCursor = cursorValues.some((c) => c !== null); + const compoundNextCursor = hasAnyCursor + ? encodeCompoundCursor(cursorValues) + : undefined; + advancePaginationState( + PAGINATION_KEY, + contextKey, + direction, + compoundNextCursor + ); + const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); + + const validResults: MetricRuleFetchResult[] = []; + const failures: { orgSlug: string; error: Error }[] = []; + + for (let i = 0; i < results.length; i++) { + // biome-ignore lint/style/noNonNullAssertion: index within bounds + const result = results[i]!; + if (result.success) { + validResults.push(result.data); + } else { + // biome-ignore lint/style/noNonNullAssertion: index within bounds + failures.push({ orgSlug: activeOrgs[i]!, error: result.error }); + } + } + + if (validResults.length === 0 && failures.length > 0) { + // biome-ignore lint/style/noNonNullAssertion: guarded by failures.length > 0 + const { error: first } = failures[0]!; + throw new Error( + `Failed to fetch metric alert rules from ${uniqueOrgs.length} organization(s): ${first.message}` + ); + } + + if (failures.length > 0) { + const failedNames = failures.map(({ orgSlug }) => orgSlug).join(", "); + logger.warn( + `Failed to fetch metric alert rules from ${failedNames}. Showing results from ${validResults.length} organization(s).` + ); + } + + const isSingleOrg = validResults.length === 1; + const firstOrg = validResults[0]?.orgSlug; + + // Apply client-side name filter + const allRows: MetricAlertRow[] = validResults.flatMap((r) => + r.rules.map((rule) => ({ rule, orgSlug: r.orgSlug })) + ); + const filteredRows = flags.query + ? allRows.filter((row) => + row.rule.name.toLowerCase().includes(flags.query?.toLowerCase() ?? "") + ) + : allRows; + + const displayRows = trimWithOrgGuarantee(filteredRows, flags.limit); + const trimmed = displayRows.length < filteredRows.length; + const hasMoreToShow = hasMore || hasAnyCursor || trimmed; + const canPaginate = hasAnyCursor; + + const allRules = displayRows.map((r) => r.rule); + + if (displayRows.length === 0) { + const hint = footer + ? `No metric alert rules found.\n\n${footer}` + : "No metric alert rules found."; + return { items: [], hint, hasMore: hasMoreToShow, hasPrev }; + } + + const title = + isSingleOrg && firstOrg + ? `Metric alert rules in ${firstOrg}` + : `Metric alert rules from ${validResults.length} organizations`; + + let moreHint: string | undefined; + if (hasMoreToShow) { + const higherLimit = Math.min(flags.limit * 2, MAX_LIMIT); + const canIncreaseLimit = higherLimit > flags.limit; + const actionParts: string[] = []; + if (canIncreaseLimit) { + actionParts.push(`-n ${higherLimit}`); + } + if (canPaginate) { + actionParts.push("-c next"); + } + if (actionParts.length > 0) { + moreHint = `More alert rules available — use ${actionParts.join(" or ")} for more.`; + } + } + if (hasPrev) { + const prevPart = "Prev: -c prev"; + moreHint = moreHint ? `${moreHint}\n${prevPart}` : prevPart; + } + + return { + items: allRules, + hasMore: hasMoreToShow, + hasPrev, + displayRows, + title, + moreHint, + footer, + }; +} + +// --------------------------------------------------------------------------- +// Human output +// --------------------------------------------------------------------------- + +/** Format metric alert status: 0 = active, 1 = disabled */ +function formatMetricStatus(status: number): string { + return status === 0 + ? colorTag("green", "active") + : colorTag("muted", "disabled"); +} + +function formatMetricAlertListHuman(result: MetricAlertListResult): string { + if (result.items.length === 0) { + return result.hint ?? "No metric alert rules found."; + } + + const rows = result.displayRows ?? []; + const uniqueOrgs = new Set(rows.map((r) => r.orgSlug)); + const isMultiOrg = uniqueOrgs.size > 1; + + type Row = { + id: string; + name: string; + org?: string; + aggregate: string; + dataset: string; + timeWindow: string; + environment: string; + status: string; + }; + + const tableRows: Row[] = rows.map(({ rule: r, orgSlug }) => ({ + id: r.id, + name: escapeMarkdownCell(r.name), + ...(isMultiOrg && { org: orgSlug }), + aggregate: r.aggregate, + dataset: r.dataset, + timeWindow: `${r.timeWindow}m`, + environment: r.environment ?? "all", + status: formatMetricStatus(r.status), + })); + + const columns: Column[] = [ + { header: "ID", value: (r) => r.id }, + { header: "NAME", value: (r) => r.name }, + ...(isMultiOrg ? [{ header: "ORG", value: (r: Row) => r.org ?? "" }] : []), + { header: "AGGREGATE", value: (r) => r.aggregate }, + { header: "DATASET", value: (r) => r.dataset }, + { header: "WINDOW", value: (r) => r.timeWindow }, + { header: "ENVIRONMENT", value: (r) => r.environment }, + { header: "STATUS", value: (r) => r.status }, + ]; + + const parts: string[] = []; + const buffer: Writer = { write: (s) => parts.push(s) }; + + if (result.title) { + parts.push(`${result.title}:\n\n`); + } + + writeTable(buffer, tableRows, columns); + + return parts.join("").trimEnd(); +} + +const jsonTransformMetricAlertList = jsonTransformListResult; + +// --------------------------------------------------------------------------- +// Command +// --------------------------------------------------------------------------- + +export const listCommand = buildListCommand("alert", { + docs: { + brief: "List metric alert rules", + fullDescription: + "List metric alert rules for one or more Sentry organizations.\n\n" + + "Metric alerts trigger notifications when a metric query crosses a threshold.\n\n" + + "Target patterns:\n" + + " sentry alert metrics list # auto-detect from DSN or config\n" + + " sentry alert metrics list / # explicit org (paginated)\n" + + " sentry alert metrics list / # explicit org (project ignored)\n" + + " sentry alert metrics list # find project across all orgs\n\n" + + `${targetPatternExplanation()}\n\n` + + "Metric alert rules are org-scoped; the project part is ignored when provided.\n\n" + + "Use --cursor / -c next / -c prev to paginate through larger result sets.", + }, + output: { + human: formatMetricAlertListHuman, + jsonTransform: jsonTransformMetricAlertList, + }, + parameters: { + positional: LIST_TARGET_POSITIONAL, + flags: { + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + limit: buildListLimitFlag("metric alert rules"), + query: { + kind: "parsed", + parse: String, + brief: "Filter rules by name", + optional: true, + }, + cursor: { + kind: "parsed", + parse: parseCursorFlag, + brief: + 'Pagination cursor (use "next" for next page, "prev" for previous)', + optional: true, + }, + }, + aliases: { ...LIST_BASE_ALIASES, w: "web", q: "query" }, + }, + async *func(this: SentryContext, flags: ListFlags, target?: string) { + const { cwd } = this; + const parsed = parseOrgProjectArg(target); + + // --web: open browser when org is known from the target arg + if ( + flags.web && + (parsed.type === "explicit" || parsed.type === "org-all") + ) { + await openInBrowser( + buildMetricAlertsUrl(parsed.org), + "metric alert rules" + ); + return; + } + + // biome-ignore lint/suspicious/noExplicitAny: shared handler accepts any mode variant + const resolveAndHandle: ModeHandler = (ctx) => + handleResolvedOrgs({ ...ctx, flags }); + + const result = (await dispatchOrgScopedList({ + config: metricAlertListMeta, + cwd, + flags, + parsed, + orgSlugMatchBehavior: "redirect", + // All modes use per-org fetching with compound cursor support + allowCursorInModes: [ + "auto-detect", + "explicit", + "project-search", + "org-all", + ], + overrides: { + "auto-detect": resolveAndHandle, + explicit: resolveAndHandle, + "project-search": resolveAndHandle, + "org-all": resolveAndHandle, + }, + })) as MetricAlertListResult; + + let combinedHint: string | undefined; + if (result.items.length > 0) { + const hintParts: string[] = []; + if (result.moreHint) { + hintParts.push(result.moreHint); + } + if (result.footer) { + hintParts.push(result.footer); + } + combinedHint = hintParts.length > 0 ? hintParts.join("\n") : result.hint; + } + + yield new CommandOutput(result); + return { hint: combinedHint }; + }, +}); + +/** @internal Exported for testing only. */ +export const __testing = { + trimWithOrgGuarantee, + encodeCompoundCursor, + decodeCompoundCursor, + buildMultiOrgContextKey, + phase1HasMore, + CURSOR_SEP, + MAX_LIMIT, +}; diff --git a/src/commands/alert/metrics/rule-resolve.ts b/src/commands/alert/metrics/rule-resolve.ts new file mode 100644 index 000000000..6955c3d8d --- /dev/null +++ b/src/commands/alert/metrics/rule-resolve.ts @@ -0,0 +1,154 @@ +/** + * Shared metric alert rule reference parsing and resolution (view / delete / edit). + */ + +import { MAX_PAGINATION_PAGES } from "../../../lib/api/infrastructure.js"; +import { + API_MAX_PER_PAGE, + getMetricAlertRule, + isNotFoundApiError, + listMetricAlertsPaginated, + type MetricAlertRule, +} from "../../../lib/api-client.js"; +import { ResolutionError, ValidationError } from "../../../lib/errors.js"; +import { fuzzyMatch } from "../../../lib/fuzzy.js"; +import { isAllDigits } from "../../../lib/utils.js"; + +export type MetricRuleResolution = { + orgSlug: string; + rule: MetricAlertRule; +}; + +/** + * Parse `org/` (one slash), or bare `rule-id-or-name` for auto-detect. + * Trailing-only org is invalid (empty ref). + */ +export function parseMetricRuleArg( + arg: string, + usageHint: string +): { + ref: string; + targetArg: string | undefined; +} { + const trimmed = arg.trim(); + if (!trimmed) { + throw new ValidationError( + `Rule id or name is required.\nUse: ${usageHint}`, + "rule" + ); + } + + if (!trimmed.includes("/")) { + return { ref: trimmed, targetArg: undefined }; + } + + const lastSlash = trimmed.lastIndexOf("/"); + const targetPart = trimmed.slice(0, lastSlash).trim(); + const ref = trimmed.slice(lastSlash + 1).trim(); + if (!ref) { + throw new ValidationError( + `Invalid rule reference '${arg}' (missing id or name after org).\nUse: ${usageHint}`, + "rule" + ); + } + return { ref, targetArg: targetPart || undefined }; +} + +export async function listAllMetricRulesForOrg( + orgSlug: string +): Promise { + const all: MetricAlertRule[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await listMetricAlertsPaginated(orgSlug, { + perPage: API_MAX_PER_PAGE, + cursor, + }); + all.push(...data); + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + return all; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per-org list + name resolution +export async function resolveMetricAlertRule( + orgSlugs: string[], + ref: string, + usageHint: string +): Promise { + if (isAllDigits(ref)) { + const hits: MetricRuleResolution[] = []; + for (const orgSlug of orgSlugs) { + try { + const rule = await getMetricAlertRule(orgSlug, ref); + hits.push({ orgSlug, rule }); + } catch (e) { + if (isNotFoundApiError(e)) { + continue; + } + throw e; + } + } + + if (hits.length === 1) { + return hits[0] as MetricRuleResolution; + } + if (hits.length > 1) { + throw new ValidationError( + `Alert rule ID '${ref}' matched multiple organizations.\n` + + "Use an explicit target: sentry alert metrics /" + ); + } + throw new ResolutionError( + `Metric alert rule '${ref}'`, + "not found", + usageHint + ); + } + + const hits: MetricRuleResolution[] = []; + const allNames: string[] = []; + + for (const orgSlug of orgSlugs) { + const rules = await listAllMetricRulesForOrg(orgSlug); + for (const r of rules) { + allNames.push(r.name); + } + const exact = rules.find( + (rule) => rule.name.toLowerCase() === ref.toLowerCase() + ); + if (exact) { + hits.push({ orgSlug, rule: exact }); + } + } + + if (hits.length === 1) { + return hits[0] as MetricRuleResolution; + } + if (hits.length > 1) { + throw new ValidationError( + `Alert rule name '${ref}' matched multiple organizations.\n` + + "Use an explicit target: sentry alert metrics /" + ); + } + + const uniqueNames = [...new Set(allNames)]; + const similar = fuzzyMatch(ref, uniqueNames, { maxResults: 5 }); + if (similar.length > 0) { + const lines = similar.map((n) => ` ${n}`).join("\n"); + throw new ValidationError( + `No metric alert rule named '${ref}' in the selected organization(s).\n\n` + + `Did you mean:\n${lines}`, + "rule" + ); + } + + throw new ResolutionError( + `Metric alert rule '${ref}'`, + "not found", + usageHint + ); +} diff --git a/src/commands/alert/metrics/view.ts b/src/commands/alert/metrics/view.ts new file mode 100644 index 000000000..5775a82ca --- /dev/null +++ b/src/commands/alert/metrics/view.ts @@ -0,0 +1,105 @@ +import type { SentryContext } from "../../../context.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { openInBrowser } from "../../../lib/browser.js"; +import { buildCommand } from "../../../lib/command.js"; +import { ContextError } from "../../../lib/errors.js"; +import { CommandOutput } from "../../../lib/formatters/output.js"; +import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; +import { buildMetricAlertsUrl } from "../../../lib/sentry-urls.js"; +import { + type MetricRuleResolution, + parseMetricRuleArg, + resolveMetricAlertRule, +} from "./rule-resolve.js"; + +const USAGE_HINT = "sentry alert metrics view /"; + +type ViewFlags = { + readonly web: boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type MetricAlertViewResult = MetricRuleResolution; + +function formatMetricAlertView(data: MetricRuleResolution): string { + const { orgSlug, rule } = data; + const status = rule.status === 0 ? "active" : "disabled"; + return [ + `Metric alert rule in ${orgSlug}:`, + "", + `ID: ${rule.id}`, + `Name: ${rule.name}`, + `Status: ${status}`, + `Dataset: ${rule.dataset}`, + `Aggregate: ${rule.aggregate}`, + `Query: ${rule.query || "(none)"}`, + `Time Window: ${rule.timeWindow}m`, + `Projects: ${rule.projects.join(", ") || "(all)"}`, + `Environment: ${rule.environment ?? "all"}`, + `Owner: ${rule.owner ?? "none"}`, + ].join("\n"); +} + +export const viewCommand = buildCommand({ + docs: { + brief: "View a metric alert rule", + fullDescription: + "View a single metric alert rule by ID or name.\n\n" + + "Examples:\n" + + " sentry alert metrics view 12345\n" + + " sentry alert metrics view my-org/12345\n" + + " sentry alert metrics view my-org/'p95 latency alert'", + }, + output: { + human: formatMetricAlertView, + jsonTransform: (data: MetricAlertViewResult) => ({ + ...data.rule, + org: data.orgSlug, + }), + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/rule-id-or-name", + brief: "Metric alert rule ID or name", + parse: String, + }, + ], + }, + flags: { + web: { + kind: "boolean", + brief: "Open metric alert rules page in browser", + default: false, + }, + }, + aliases: { w: "web" }, + }, + async *func(this: SentryContext, flags: ViewFlags, arg: string) { + const { cwd } = this; + const { ref, targetArg } = parseMetricRuleArg(arg, USAGE_HINT); + const parsed = parseOrgProjectArg(targetArg); + const { targets } = await resolveTargetsFromParsedArg(parsed, { + cwd, + usageHint: USAGE_HINT, + }); + const orgSlugs = [...new Set(targets.map((target) => target.org))]; + if (orgSlugs.length === 0) { + throw new ContextError("Organization", USAGE_HINT); + } + + const result = await resolveMetricAlertRule(orgSlugs, ref, USAGE_HINT); + if (flags.web) { + await openInBrowser( + buildMetricAlertsUrl(result.orgSlug), + "metric alert rules" + ); + return; + } + + yield new CommandOutput(result); + }, +}); diff --git a/src/commands/alert/mutation-utils.ts b/src/commands/alert/mutation-utils.ts new file mode 100644 index 000000000..0543bb736 --- /dev/null +++ b/src/commands/alert/mutation-utils.ts @@ -0,0 +1,182 @@ +import { ValidationError } from "../../lib/errors.js"; + +const ISSUE_MATCH_MODES = new Set(["all", "any"]); +const METRIC_DATASET_VALUES = new Set([ + "errors", + "transactions", + "sessions", + "events", + "spans", + "metrics", +]); +const METRIC_TIME_WINDOWS = new Set([ + 1, 5, 10, 15, 30, 60, 120, 240, 360, 720, 1440, +]); + +export function parseMatchMode( + value: string | undefined, + field: "action-match" | "filter-match" +): "all" | "any" | undefined { + if (value === undefined || value === "") { + return; + } + const normalized = value.trim().toLowerCase(); + if (ISSUE_MATCH_MODES.has(normalized)) { + return normalized as "all" | "any"; + } + throw new ValidationError( + `${field} must be 'all' or 'any' (got ${JSON.stringify(value)}).`, + field + ); +} + +export function parseStatusFlag( + value: string | undefined +): "active" | "disabled" | undefined { + if (value === undefined || value === "") { + return; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "active" || normalized === "disabled") { + return normalized; + } + throw new ValidationError( + `Status must be 'active' or 'disabled' (got ${JSON.stringify(value)}).`, + "status" + ); +} + +function parseJsonValue(raw: string, field: string): unknown { + try { + return JSON.parse(raw); + } catch { + throw new ValidationError( + `${field} must be valid JSON (got ${JSON.stringify(raw)}).`, + field + ); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function toObject(value: unknown, field: string): Record { + if (!isRecord(value)) { + throw new ValidationError(`${field} entries must be JSON objects.`, field); + } + return value; +} + +export function parseJsonObjectList( + values: readonly string[] | undefined, + field: string +): Record[] | undefined { + if (!values || values.length === 0) { + return; + } + + if (values.length === 1) { + const onlyValue = values[0]; + if (onlyValue === undefined) { + return; + } + const parsed = parseJsonValue(onlyValue, field); + if (Array.isArray(parsed)) { + return parsed.map((entry) => toObject(entry, field)); + } + return [toObject(parsed, field)]; + } + + return values.map((value) => toObject(parseJsonValue(value, field), field)); +} + +export function validateIssueRuleArrays( + conditions: readonly Record[] | undefined, + actions: readonly Record[] | undefined, + field: "conditions" | "actions" +): void { + if (field === "conditions") { + if (!conditions || conditions.length === 0) { + throw new ValidationError( + "Pass at least one --condition JSON object.", + "condition" + ); + } + return; + } + + if (!actions || actions.length === 0) { + throw new ValidationError( + "Pass at least one --action JSON object.", + "action" + ); + } +} + +export function normalizeProjectList( + projects: readonly string[] | undefined +): string[] | undefined { + if (!projects || projects.length === 0) { + return; + } + + const values = projects + .flatMap((project) => project.split(",")) + .map((project) => project.trim()) + .filter((project) => project.length > 0); + return values.length > 0 ? values : undefined; +} + +export function validateMetricDataset(dataset: string): void { + const normalized = dataset.trim().toLowerCase(); + if (METRIC_DATASET_VALUES.has(normalized)) { + return; + } + throw new ValidationError( + `dataset must be one of: ${[...METRIC_DATASET_VALUES].join(", ")}.`, + "dataset" + ); +} + +export function validateMetricTimeWindow(timeWindow: number): void { + if (METRIC_TIME_WINDOWS.has(timeWindow)) { + return; + } + throw new ValidationError( + `timeWindow must be one of: ${[...METRIC_TIME_WINDOWS].join(", ")} minutes.`, + "timeWindow" + ); +} + +export function validateMetricTriggers( + triggers: readonly Record[] | undefined +): void { + if (!triggers || triggers.length === 0) { + throw new ValidationError( + "Pass at least one --trigger JSON object.", + "trigger" + ); + } + + for (const trigger of triggers) { + const threshold = trigger.alertThreshold; + if (typeof threshold !== "number" && typeof threshold !== "string") { + throw new ValidationError( + "Each trigger must include alertThreshold.", + "trigger" + ); + } + const actions = trigger.actions; + if (!Array.isArray(actions) || actions.length === 0) { + throw new ValidationError( + "Each trigger must include a non-empty actions array.", + "trigger" + ); + } + } +} + +export function statusToMetricValue(status: "active" | "disabled"): 0 | 1 { + return status === "active" ? 0 : 1; +} diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index ed337e30e..75bea9748 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -6,29 +6,29 @@ */ import type { SentryContext } from "../../context.js"; -import { buildOrgAwareAliases } from "../../lib/alias.js"; +import { buildProjectAliasMap } from "../../lib/alias.js"; import { API_MAX_PER_PAGE, buildIssueListCollapse, - findProjectsByPattern, - findProjectsBySlug, - getProject, type IssueCollapseField, type IssuesPage, listIssuesAllPages, listIssuesPaginated, - listProjects, } from "../../lib/api-client.js"; import { extractRequiredScopes } from "../../lib/api-scope.js"; import { looksLikeIssueShortId, parseOrgProjectArg, } from "../../lib/arg-parsing.js"; + import { getActiveEnvVarName, isEnvTokenActive } from "../../lib/db/auth.js"; import { advancePaginationState, + buildMultiTargetContextKey, buildPaginationContextKey, - escapeContextKeyValue, + CURSOR_SEP, + decodeCompoundCursor, + encodeCompoundCursor, hasPreviousPage, resolveCursor, } from "../../lib/db/pagination.js"; @@ -40,7 +40,6 @@ import { createDsnFingerprint } from "../../lib/dsn/index.js"; import { ApiError, ContextError, - ResolutionError, ValidationError, withAuthGuard, } from "../../lib/errors.js"; @@ -67,24 +66,22 @@ import { import { logger } from "../../lib/logger.js"; import { dispatchOrgScopedList, + type FetchResult as FetchResultOf, jsonTransformListResult, type ListCommandMeta, type ListResult, type ModeHandler, + trimWithGroupGuarantee, } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { - fetchProjectId, type ResolvedTarget, - resolveAllTargets, - toNumericId, + resolveTargetsFromParsedArg, } from "../../lib/resolve-target.js"; import { SEARCH_SYNTAX_REFERENCE, sanitizeQuery, } from "../../lib/search-query.js"; -import { getApiBaseUrl } from "../../lib/sentry-client.js"; -import { setOrgProjectContext } from "../../lib/telemetry.js"; import { appendPeriodHint, formatTimeRangeFlag, @@ -95,7 +92,6 @@ import { timeRangeToApiParams, } from "../../lib/time-range.js"; import { - type ProjectAliasEntry, type SentryIssue, SentryIssueSchema, type Writer, @@ -249,49 +245,6 @@ function formatListFooter(mode: "single" | "multi" | "none"): string { nextCursor?: string; }; -/** Result of building project aliases */ -/** @internal */ export type AliasMapResult = { - aliasMap: Map; - entries: Record; -}; - -/** - * Build project alias map using shortest unique prefix of project slug. - * Handles cross-org slug collisions by prefixing with org abbreviation. - * Strips common word prefix before computing unique prefixes for cleaner aliases. - * - * Single org examples: - * spotlight-electron, spotlight-website, spotlight → e, w, s - * frontend, functions, backend → fr, fu, b - * - * Cross-org collision example: - * org1/dashboard, org2/dashboard → o1/d, o2/d - */ -function buildProjectAliasMap(results: IssueListFetchResult[]): AliasMapResult { - const entries: Record = {}; - - // Build org-aware aliases that handle cross-org collisions - const pairs = results.map((r) => ({ - org: r.target.org, - project: r.target.project, - })); - const { aliasMap } = buildOrgAwareAliases(pairs); - - // Build entries record for storage - for (const result of results) { - const key = `${result.target.org}/${result.target.project}`; - const alias = aliasMap.get(key); - if (alias) { - entries[alias] = { - orgSlug: result.target.org, - projectSlug: result.target.project, - }; - } - } - - return { aliasMap, entries }; -} - /** * Attach formatting options to each issue based on alias map. * @@ -355,183 +308,7 @@ function getComparator( } } -type FetchResult = - | { success: true; data: IssueListFetchResult } - | { success: false; error: Error }; - -/** Result of resolving targets from parsed argument */ -type TargetResolutionResult = { - targets: ResolvedTarget[]; - footer?: string; - skippedSelfHosted?: number; - detectedDsns?: import("../../lib/dsn/index.js").DetectedDsn[]; -}; - -/** - * Resolve targets based on parsed org/project argument. - * - * Handles all four cases: - * - auto-detect: Use DSN detection / config defaults - * - explicit: Single org/project target - * - org-all: All projects in specified org - * - project-search: Find project across all orgs - */ -async function resolveTargetsFromParsedArg( - parsed: ReturnType, - cwd: string -): Promise { - switch (parsed.type) { - case "auto-detect": { - // Use existing resolution logic (DSN detection, config defaults) - const result = await resolveAllTargets({ cwd, usageHint: USAGE_HINT }); - // DSN-detected and directory-inferred targets already carry a projectId. - // Env var / config-default paths return targets without one, so enrich - // them now using the project API. Any failure silently falls back to - // slug-based querying — the target was already resolved, so we never - // surface a ResolutionError here (that's only for the explicit case). - result.targets = await Promise.all( - result.targets.map(async (t) => { - if (t.projectId !== undefined) { - return t; - } - try { - const info = await getProject(t.org, t.project); - const id = toNumericId(info.id); - return id !== undefined ? { ...t, projectId: id } : t; - } catch { - return t; - } - }) - ); - return result; - } - - case "explicit": { - // Single explicit target — fetch project ID for API query param - // Telemetry context is set by dispatchOrgScopedList before this handler runs. - const projectId = await fetchProjectId(parsed.org, parsed.project); - return { - targets: [ - { - org: parsed.org, - project: parsed.project, - projectId, - orgDisplay: parsed.org, - projectDisplay: parsed.project, - }, - ], - }; - } - - case "org-all": { - // List all projects in the specified org - // Telemetry context is set by dispatchOrgScopedList before this handler runs. - const projects = await listProjects(parsed.org); - const targets: ResolvedTarget[] = projects.map((p) => ({ - org: parsed.org, - project: p.slug, - projectId: toNumericId(p.id), - orgDisplay: parsed.org, - projectDisplay: p.name, - })); - - if (targets.length === 0) { - throw new ResolutionError( - `Organization '${parsed.org}'`, - "has no accessible projects", - `sentry project list ${parsed.org}/`, - ["Check that you have access to projects in this organization"] - ); - } - - return { - targets, - footer: - targets.length > 1 - ? `Showing issues from ${targets.length} projects in ${parsed.org}` - : undefined, - }; - } - - case "project-search": { - // Issue short IDs are intercepted early in func() and auto-recovered - // (see the looksLikeIssueShortId check above dispatchOrgScopedList). - - // Find project across all orgs - const { projects: matches, orgs } = await findProjectsBySlug( - parsed.projectSlug - ); - - if (matches.length === 0) { - // Check if the slug matches an organization — common mistake. - // The orgSlugMatchBehavior: "redirect" pre-check handles this for - // cached orgs (hot path). This is the cold-cache fallback: org - // isn't cached yet, so the pre-check couldn't fire. We throw a - // ResolutionError with a hint — after this command, the org will - // be cached and future runs will auto-redirect. - const isOrg = orgs.some((o) => o.slug === parsed.projectSlug); - if (isOrg) { - throw new ResolutionError( - `'${parsed.projectSlug}'`, - "is an organization, not a project", - `sentry issue list ${parsed.projectSlug}/`, - [ - `List projects: sentry project list ${parsed.projectSlug}/`, - `Specify a project: sentry issue list ${parsed.projectSlug}/`, - ] - ); - } - - // Try word-boundary matching to suggest similar projects (CLI-A4, 16 users). - // Uses the same findProjectsByPattern used by directory name inference. - // Only runs on the error path, so the extra API cost is acceptable. - const similar = await findProjectsByPattern(parsed.projectSlug); - const suggestions: string[] = []; - if (similar.length > 0) { - const names = similar - .slice(0, 3) - .map((p) => `'${p.orgSlug}/${p.slug}'`); - suggestions.push(`Similar projects: ${names.join(", ")}`); - } - suggestions.push( - "No project with this slug found in any accessible organization" - ); - throw new ResolutionError( - `Project '${parsed.projectSlug}'`, - "not found", - "sentry project list", - suggestions - ); - } - - const targets: ResolvedTarget[] = matches.map((m) => ({ - org: m.orgSlug, - project: m.slug, - projectId: toNumericId(m.id), - orgDisplay: m.orgSlug, - projectDisplay: m.name, - })); - - const uniqueOrgs = [...new Set(targets.map((t) => t.org))]; - const uniqueProjects = [...new Set(targets.map((t) => t.project))]; - setOrgProjectContext(uniqueOrgs, uniqueProjects); - - return { - targets, - footer: - matches.length > 1 - ? `Found '${parsed.projectSlug}' in ${matches.length} organizations` - : undefined, - }; - } - - default: { - // TypeScript exhaustiveness check - this should never be reached - const _exhaustiveCheck: never = parsed; - throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); - } - } -} +type FetchResult = FetchResultOf; /** * Fetch issues for a single target project. @@ -732,111 +509,17 @@ async function fetchWithBudget( } /** - * Trim an array of issues to the global limit while guaranteeing at least one - * issue per project (when possible). - * - * Algorithm: - * 1. Walk the globally-sorted list, taking the first issue from each unseen - * project until `limit` slots are filled or all projects are represented. - * 2. Fill remaining slots from the top of the sorted list, skipping already- - * selected issues. - * 3. Return the final set in original sorted order. - * - * When there are more projects than the limit, the projects whose first issue - * ranks highest in the sorted order get representation. - * - * @param issues - Globally sorted array (input order is preserved in output) - * @param limit - Maximum number of issues to return - * @returns Trimmed array in the same sorted order + * Trim issues to the global limit while guaranteeing at least one issue per + * project. Thin wrapper around {@link trimWithGroupGuarantee} for `IssueTableRow`. */ function trimWithProjectGuarantee( issues: IssueTableRow[], limit: number ): IssueTableRow[] { - if (issues.length <= limit) { - return issues; - } - - const seenProjects = new Set(); - const guaranteed = new Set(); - - // Pass 1: pick one representative per project from the sorted list - for (let i = 0; i < issues.length && guaranteed.size < limit; i++) { - // biome-ignore lint/style/noNonNullAssertion: i is within bounds - const projectKey = `${issues[i]!.orgSlug}/${issues[i]!.formatOptions.projectSlug ?? ""}`; - if (!seenProjects.has(projectKey)) { - seenProjects.add(projectKey); - guaranteed.add(i); - } - } - - // Pass 2: fill remaining budget from the top of the sorted list - const selected = new Set(guaranteed); - for (let i = 0; i < issues.length && selected.size < limit; i++) { - selected.add(i); - } - - // Return in original sorted order - return issues.filter((_, i) => selected.has(i)); -} - -/** Separator for compound cursor entries (pipe — not present in Sentry cursors). */ -const CURSOR_SEP = "|"; - -/** - * Encode per-target cursors as a pipe-separated string for storage. - * - * The position of each entry matches the **sorted** target order encoded in - * the context key fingerprint, so we only need to store the cursor values — - * no org/project metadata is needed in the cursor string itself. - * - * Empty string = project exhausted (no more pages). - * - * @example "1735689600:0:0||1735689601:0:0" — 3 targets, middle one exhausted - */ -function encodeCompoundCursor(cursors: (string | null)[]): string { - return cursors.map((c) => c ?? "").join(CURSOR_SEP); -} - -/** - * Decode a compound cursor string back to an array of per-target cursors. - * - * Returns `null` for exhausted entries (empty segments) and `string` for active - * cursors. Returns an empty array if `raw` is empty or looks like a legacy - * JSON cursor (starts with `[`), causing a fresh start. - */ -function decodeCompoundCursor(raw: string): (string | null)[] { - // Guard against legacy JSON compound cursors or corrupted data - if (!raw || raw.startsWith("[")) { - return []; - } - return raw.split(CURSOR_SEP).map((s) => (s === "" ? null : s)); -} - -/** - * Build a compound cursor context key that encodes the full target set, sort, - * query, and period so that a cursor from one search is never reused for a - * different search. - */ -function buildMultiTargetContextKey( - targets: ResolvedTarget[], - flags: Pick, - timeRange: TimeRange -): string { - const host = getApiBaseUrl(); - const targetFingerprint = targets - .map((t) => `${t.org}/${t.project}`) - .sort() - .join(","); - const escapedQuery = flags.query - ? escapeContextKeyValue(flags.query) - : undefined; - const escapedPeriod = escapeContextKeyValue(serializeTimeRange(timeRange)); - const escapedSort = escapeContextKeyValue(flags.sort); - return ( - `host:${host}|type:multi:${targetFingerprint}` + - `|sort:${escapedSort}|period:${escapedPeriod}` + - (escapedQuery ? `|q:${escapedQuery}` : "") + return trimWithGroupGuarantee( + issues, + limit, + (r) => `${r.orgSlug}/${r.formatOptions.projectSlug ?? ""}` ); } @@ -1185,7 +868,12 @@ async function handleResolvedTargets( const { parsed, flags, cwd, timeRange } = options; const { targets, footer, skippedSelfHosted, detectedDsns } = - await resolveTargetsFromParsedArg(parsed, cwd); + await resolveTargetsFromParsedArg(parsed, { + cwd, + usageHint: USAGE_HINT, + enrichProjectIds: true, + checkIssueShortId: true, + }); if (targets.length === 0) { if (skippedSelfHosted) { @@ -1201,7 +889,11 @@ async function handleResolvedTargets( // Build a compound cursor context key that encodes the full target set + // search parameters so a cursor from one search is never reused for another. - const contextKey = buildMultiTargetContextKey(targets, flags, timeRange); + const contextKey = buildMultiTargetContextKey(targets, { + sort: flags.sort, + query: flags.query, + period: serializeTimeRange(timeRange), + }); // Resolve per-target start cursors from the stored compound cursor (--cursor resume). // Sorted target keys must match the order used in buildMultiTargetContextKey. diff --git a/src/lib/alias.ts b/src/lib/alias.ts index bfc22da46..1a61195c0 100644 --- a/src/lib/alias.ts +++ b/src/lib/alias.ts @@ -5,6 +5,9 @@ * Used by issue list to create short identifiers like "e" for "spotlight-electron". */ +import type { ProjectAliasEntry } from "../types/index.js"; +import type { ResolvedTarget } from "./resolve-target.js"; + /** * Find the common word prefix shared by strings that have word boundaries. * Word boundaries are hyphens or underscores. @@ -320,3 +323,53 @@ export function buildOrgAwareAliases( return { aliasMap }; } + +// --------------------------------------------------------------------------- +// Project alias map — shared by list commands that resolve multiple targets +// --------------------------------------------------------------------------- + +/** + * Result of {@link buildProjectAliasMap}: a lookup map plus the entry record + * used to persist aliases to the database for `sentry issue view `. + */ +export type AliasMapResult = { + /** Map from "org/project" composite key to alias string */ + aliasMap: Map; + /** Alias → `{ orgSlug, projectSlug }` entries for DB storage */ + entries: Record; +}; + +/** + * Build a project alias map and DB entry record from a list of fetch results. + * + * Uses {@link buildOrgAwareAliases} to compute the shortest unique prefix for + * each project slug, handling cross-org collisions with an org abbreviation + * prefix. The returned `aliasMap` is keyed by `"org/project"` composite key; + * `entries` is suitable for passing to `setProjectAliases`. + * + * @param results - Fetch results that each carry a {@link ResolvedTarget} + * @returns Alias map and DB entries + */ +export function buildProjectAliasMap( + results: T[] +): AliasMapResult { + const entries: Record = {}; + const pairs = results.map((r) => ({ + org: r.target.org, + project: r.target.project, + })); + const { aliasMap } = buildOrgAwareAliases(pairs); + + for (const result of results) { + const key = `${result.target.org}/${result.target.project}`; + const alias = aliasMap.get(key); + if (alias) { + entries[alias] = { + orgSlug: result.target.org, + projectSlug: result.target.project, + }; + } + } + + return { aliasMap, entries }; +} diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index b2ae8fca4..aa09df304 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -19,6 +19,22 @@ * - users: current user info */ +export { + createIssueAlertRule, + createMetricAlertRule, + deleteIssueAlertRule, + deleteMetricAlertRule, + getIssueAlertRule, + getIssueAlertRuleDocument, + getMetricAlertRule, + getMetricAlertRuleDocument, + type IssueAlertRule, + listIssueAlertsPaginated, + listMetricAlertsPaginated, + type MetricAlertRule, + putIssueAlertRule, + putMetricAlertRule, +} from "./api/alerts.js"; export { createDashboard, getDashboard, @@ -26,6 +42,7 @@ export { queryAllWidgets, updateDashboard, } from "./api/dashboards.js"; +export { isNotFoundApiError } from "./api/error-guards.js"; export { findEventAcrossOrgs, getEvent, @@ -39,6 +56,7 @@ export { type ApiRequestOptions, apiRequest, apiRequestToRegion, + apiRequestToRegionNoContent, buildSearchParams, ORG_FANOUT_CONCURRENCY, type PaginatedResponse, diff --git a/src/lib/api/alerts.ts b/src/lib/api/alerts.ts new file mode 100644 index 000000000..9fda8696e --- /dev/null +++ b/src/lib/api/alerts.ts @@ -0,0 +1,286 @@ +/** + * Alert rules API functions + * + * Fetch operations for Sentry alert rules: + * - Issue alerts: event-based rules that trigger on matching errors (per-project) + * - Metric alerts: threshold-based rules that trigger on metric queries (org-wide) + */ + +import { resolveOrgRegion } from "../region.js"; +import { + apiRequestToRegion, + apiRequestToRegionNoContent, + type PaginatedResponse, + parseLinkHeader, +} from "./infrastructure.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A single issue alert rule (event-based, project-scoped) */ +export type IssueAlertRule = { + id: string; + name: string; + /** "active" | "disabled" */ + status: string; + actionMatch: string; + conditions: unknown[]; + actions: unknown[]; + frequency: number; + environment: string | null; + owner: string | null; + projects: string[]; + dateCreated: string; +}; + +/** A single metric alert rule (threshold-based, org-scoped) */ +export type MetricAlertRule = { + id: string; + name: string; + /** 0 = active, 1 = disabled */ + status: number; + query: string; + aggregate: string; + dataset: string; + timeWindow: number; + environment: string | null; + owner: string | null; + projects: string[]; + dateCreated: string; +}; + +// --------------------------------------------------------------------------- +// Issue alerts +// --------------------------------------------------------------------------- + +/** + * List issue alert rules for a project with cursor-based pagination. + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug + * @param options - Pagination parameters (perPage, cursor) + * @returns Paginated response with issue alert rules and optional next cursor + */ +export async function listIssueAlertsPaginated( + orgSlug: string, + projectSlug: string, + options: { perPage?: number; cursor?: string } = {} +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data, headers } = await apiRequestToRegion( + regionUrl, + `/projects/${orgSlug}/${projectSlug}/rules/`, + { params: { per_page: options.perPage, cursor: options.cursor } } + ); + const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); + return { data, nextCursor }; +} + +/** Single GET for project issue alert rule (typed or full JSON for PUT). */ +async function fetchIssueAlertRuleJson( + orgSlug: string, + projectSlug: string, + ruleId: string +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion>( + regionUrl, + `projects/${orgSlug}/${projectSlug}/rules/${encodeURIComponent(ruleId)}/` + ); + return data; +} + +/** + * Get a single issue alert rule by ID. + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug + * @param ruleId - Alert rule ID + * @returns The issue alert rule + */ +export async function getIssueAlertRule( + orgSlug: string, + projectSlug: string, + ruleId: string +): Promise { + const data = await fetchIssueAlertRuleJson(orgSlug, projectSlug, ruleId); + return data as IssueAlertRule; +} + +// --------------------------------------------------------------------------- +// Metric alerts +// --------------------------------------------------------------------------- + +/** + * List metric alert rules for an organization with cursor-based pagination. + * + * @param orgSlug - Organization slug + * @param options - Pagination parameters (perPage, cursor) + * @returns Paginated response with metric alert rules and optional next cursor + */ +export async function listMetricAlertsPaginated( + orgSlug: string, + options: { perPage?: number; cursor?: string } = {} +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data, headers } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/alert-rules/`, + { params: { per_page: options.perPage, cursor: options.cursor } } + ); + const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); + return { data, nextCursor }; +} + +/** Single GET for org metric alert rule (typed or full JSON for PUT). */ +async function fetchMetricAlertRuleJson( + orgSlug: string, + ruleId: string +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion>( + regionUrl, + `organizations/${orgSlug}/alert-rules/${encodeURIComponent(ruleId)}/` + ); + return data; +} + +/** + * Get a single metric alert rule by ID. + * + * @param orgSlug - Organization slug + * @param ruleId - Alert rule ID + * @returns The metric alert rule + */ +export async function getMetricAlertRule( + orgSlug: string, + ruleId: string +): Promise { + const data = await fetchMetricAlertRuleJson(orgSlug, ruleId); + return data as MetricAlertRule; +} + +// --------------------------------------------------------------------------- +// Issue alert write operations +// --------------------------------------------------------------------------- + +/** + * Delete an issue (project) alert rule. + * + * Succeeds with 204 No Content and no response body. + */ +export async function deleteIssueAlertRule( + orgSlug: string, + projectSlug: string, + ruleId: string +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + await apiRequestToRegionNoContent( + regionUrl, + `projects/${orgSlug}/${projectSlug}/rules/${encodeURIComponent(ruleId)}/`, + { method: "DELETE" } + ); +} + +/** + * Full document for PUT (includes conditions, actions, etc. from the API). + */ +export function getIssueAlertRuleDocument( + orgSlug: string, + projectSlug: string, + ruleId: string +): Promise> { + return fetchIssueAlertRuleJson(orgSlug, projectSlug, ruleId); +} + +/** + * Replace an issue alert rule (Sentry PUT is a full replacement). + */ +export async function putIssueAlertRule( + orgSlug: string, + projectSlug: string, + ruleId: string, + body: Record +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion>( + regionUrl, + `projects/${orgSlug}/${projectSlug}/rules/${encodeURIComponent(ruleId)}/`, + { method: "PUT", body } + ); + return data; +} + +/** + * Create an issue (project) alert rule. + */ +export async function createIssueAlertRule( + orgSlug: string, + projectSlug: string, + body: Record +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion>( + regionUrl, + `projects/${orgSlug}/${projectSlug}/rules/`, + { method: "POST", body } + ); + return data; +} + +// --------------------------------------------------------------------------- +// Metric alert (org) write operations +// --------------------------------------------------------------------------- + +/** + * Delete a metric (organization) alert rule. May return 202 with no body. + */ +export async function deleteMetricAlertRule( + orgSlug: string, + ruleId: string +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + await apiRequestToRegionNoContent( + regionUrl, + `organizations/${orgSlug}/alert-rules/${encodeURIComponent(ruleId)}/`, + { method: "DELETE" } + ); +} + +export function getMetricAlertRuleDocument( + orgSlug: string, + ruleId: string +): Promise> { + return fetchMetricAlertRuleJson(orgSlug, ruleId); +} + +export async function putMetricAlertRule( + orgSlug: string, + ruleId: string, + body: Record +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion>( + regionUrl, + `organizations/${orgSlug}/alert-rules/${encodeURIComponent(ruleId)}/`, + { method: "PUT", body } + ); + return data; +} + +/** + * Create a metric (organization) alert rule. + */ +export async function createMetricAlertRule( + orgSlug: string, + body: Record +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion>( + regionUrl, + `organizations/${orgSlug}/alert-rules/`, + { method: "POST", body } + ); + return data; +} diff --git a/src/lib/api/error-guards.ts b/src/lib/api/error-guards.ts new file mode 100644 index 000000000..362b5d16f --- /dev/null +++ b/src/lib/api/error-guards.ts @@ -0,0 +1,10 @@ +/** + * Reusable tests for Sentry API client errors. + */ + +import { ApiError } from "../errors.js"; + +/** True if the error is a Sentry 404 (resource missing for this id/path). */ +export function isNotFoundApiError(error: unknown): boolean { + return error instanceof ApiError && error.status === 404; +} diff --git a/src/lib/api/infrastructure.ts b/src/lib/api/infrastructure.ts index 1c1b0d644..216b6ea9e 100644 --- a/src/lib/api/infrastructure.ts +++ b/src/lib/api/infrastructure.ts @@ -315,6 +315,62 @@ export async function apiRequestToRegion( return { data: data as T, headers: response.headers }; } +/** + * Make an authenticated request to a Sentry region where success has no JSON body + * (e.g. DELETE returning 204 No Content, or 202 Accepted with an empty body). + */ +export async function apiRequestToRegionNoContent( + regionUrl: string, + endpoint: string, + options: Omit = {} +): Promise { + const { method = "GET", body, params } = options; + const config = getSdkConfig(regionUrl); + + const searchParams = buildSearchParams(params); + const normalizedEndpoint = endpoint.startsWith("/") + ? endpoint.slice(1) + : endpoint; + const queryString = searchParams ? `?${searchParams.toString()}` : ""; + const url = `${config.baseUrl}/api/0/${normalizedEndpoint}${queryString}`; + + const fetchFn = config.fetch; + const headers: Record = { + "Content-Type": "application/json", + }; + const response = await fetchFn(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + let detail: string | undefined; + try { + const text = await response.text(); + try { + const parsed = JSON.parse(text) as { detail?: string }; + detail = parsed.detail ?? JSON.stringify(parsed); + } catch { + detail = text; + } + } catch { + detail = response.statusText; + } + throw new ApiError( + `API request failed: ${response.status} ${response.statusText}`, + response.status, + detail, + endpoint + ); + } + + if (response.status === 204 || response.status === 205) { + return; + } + await response.text(); +} + /** * Make an authenticated request to the default Sentry API. * diff --git a/src/lib/db/pagination.ts b/src/lib/db/pagination.ts index c702918d9..0a676bc83 100644 --- a/src/lib/db/pagination.ts +++ b/src/lib/db/pagination.ts @@ -14,6 +14,7 @@ import { ValidationError } from "../errors.js"; import { CURSOR_KEYWORDS } from "../list-command.js"; +import type { ResolvedTarget } from "../resolve-target.js"; import { getApiBaseUrl } from "../sentry-client.js"; import { getDatabase } from "./index.js"; import { runUpsert } from "./utils.js"; @@ -385,3 +386,100 @@ export function buildPaginationContextKey( export function buildOrgContextKey(org: string): string { return buildPaginationContextKey("org", org); } + +// --------------------------------------------------------------------------- +// Compound cursor utilities — shared between multi-project list commands +// --------------------------------------------------------------------------- + +/** Separator for compound cursor entries (pipe — not present in Sentry cursors). */ +export const CURSOR_SEP = "|"; + +/** + * Encode per-target cursors as a pipe-separated string for storage. + * + * The position of each entry matches the **sorted** target order encoded in + * the context key fingerprint, so we only need to store the cursor values — + * no org/project metadata is needed in the cursor string itself. + * + * Empty string = project exhausted (no more pages). + * + * @example "1735689600:0:0||1735689601:0:0" — 3 targets, middle one exhausted + */ +export function encodeCompoundCursor(cursors: (string | null)[]): string { + return cursors.map((c) => c ?? "").join(CURSOR_SEP); +} + +/** + * Decode a compound cursor string back to an array of per-target cursors. + * + * Returns `null` for exhausted entries (empty segments) and `string` for active + * cursors. Returns an empty array if `raw` is empty or looks like a legacy + * JSON cursor (starts with `[`), causing a fresh start. + */ +export function decodeCompoundCursor(raw: string): (string | null)[] { + // Guard against legacy JSON compound cursors or corrupted data + if (!raw || raw.startsWith("[")) { + return []; + } + return raw.split(CURSOR_SEP).map((s) => (s === "" ? null : s)); +} + +/** + * Build a compound cursor context key encoding the full target set and optional + * query filters so a cursor from one search is never reused for a different search. + * + * @param targets - Resolved org/project targets (sorted internally by key) + * @param filters - Optional filter parameters for commands that have them + * (sort, query, period). When provided they are appended so cursors are + * isolated per unique query. + */ +export function buildMultiTargetContextKey( + targets: ResolvedTarget[], + filters?: { sort?: string; query?: string; period?: string } +): string { + const host = getApiBaseUrl(); + const targetFingerprint = targets + .map((t) => escapeContextKeyValue(`${t.org}/${t.project}`)) + .sort() + .join(","); + const base = `host:${host}|type:multi:${targetFingerprint}`; + if (!filters) { + return base; + } + const escapedPeriod = escapeContextKeyValue(filters.period ?? "90d"); + const escapedSort = filters.sort + ? escapeContextKeyValue(filters.sort) + : undefined; + const escapedQuery = filters.query + ? escapeContextKeyValue(filters.query) + : undefined; + return ( + `${base}` + + (escapedSort ? `|sort:${escapedSort}` : "") + + `|period:${escapedPeriod}` + + (escapedQuery ? `|q:${escapedQuery}` : "") + ); +} + +/** + * Build a compound context key for multi-org pagination cursor storage. + * + * Analogous to {@link buildMultiTargetContextKey} but keyed by org slugs + * rather than org/project pairs. Used by metric alert rules and other + * org-scoped commands that may fetch from multiple orgs simultaneously. + * + * Cursors from different org sets or queries are never mixed because the + * context key encodes both. + * + * @param orgs - Organization slugs (sorted internally) + * @param query - Optional client-side name filter; included so cursors from + * different filter values don't collide + * @returns Composite context key string + */ +export function buildMultiOrgContextKey( + orgs: string[], + query: string | undefined +): string { + const sortedOrgs = [...orgs].sort().map(escapeContextKeyValue).join(","); + return buildPaginationContextKey("multi-org", sortedOrgs, { q: query }); +} diff --git a/src/lib/org-list.ts b/src/lib/org-list.ts index 46e425dd7..68f6a9e88 100644 --- a/src/lib/org-list.ts +++ b/src/lib/org-list.ts @@ -137,6 +137,69 @@ export function jsonTransformListResult( return items; } +/** + * Per-target (or per-org) fetch result for budget-aware list commands. + * + * Wraps a success value or a captured error so that `fetchWithBudget` + * can run all fetches in parallel and report partial failures via + * `logger.warn` instead of throwing. + * + * @template T - The success payload type (e.g. `AlertRuleListFetchResult`) + */ +export type FetchResult = + | { success: true; data: T } + | { success: false; error: Error }; + +/** + * Trim an array to `limit` entries while guaranteeing at least one entry + * per group (when possible). + * + * Algorithm: + * 1. Walk the list, picking the first entry from each unseen group key until + * `limit` slots are filled or all groups are represented. + * 2. Fill remaining slots from the top of the list, skipping already-selected + * entries. + * 3. Return the final set in original order. + * + * When there are more groups than the limit, groups whose first entry ranks + * highest in the input order get representation. + * + * @param rows - Input array (order is preserved in output) + * @param limit - Maximum number of entries to return + * @param getGroupKey - Returns the group key for an entry (e.g. "org/project") + * @returns Trimmed array in the same order as the input + */ +export function trimWithGroupGuarantee( + rows: T[], + limit: number, + getGroupKey: (row: T) => string +): T[] { + if (rows.length <= limit) { + return rows; + } + + const seenGroups = new Set(); + const guaranteed = new Set(); + + // Pass 1: pick one representative per group from the list + for (let i = 0; i < rows.length && guaranteed.size < limit; i++) { + // biome-ignore lint/style/noNonNullAssertion: i is within bounds + const key = getGroupKey(rows[i]!); + if (!seenGroups.has(key)) { + seenGroups.add(key); + guaranteed.add(i); + } + } + + // Pass 2: fill remaining budget from the top of the list + const selected = new Set(guaranteed); + for (let i = 0; i < rows.length && selected.size < limit; i++) { + selected.add(i); + } + + return rows.filter((_, i) => selected.has(i)); +} + /** * Metadata required by all list commands. * diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 971660b1d..eb777aa51 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -26,7 +26,11 @@ import { listProjects, resolveOrgDisplayName, } from "./api-client.js"; -import { type ParsedOrgProject, parseOrgProjectArg } from "./arg-parsing.js"; +import { + looksLikeIssueShortId, + type ParsedOrgProject, + parseOrgProjectArg, +} from "./arg-parsing.js"; import { getDefaultOrganization, getDefaultProject } from "./db/defaults.js"; import { getCachedDsn, setCachedDsn } from "./db/dsn-cache.js"; import { @@ -1734,3 +1738,202 @@ export function resolveOrgProjectFromArg( ): Promise { return resolveOrgProjectTarget(parseOrgProjectArg(target), cwd, commandName); } + +// --------------------------------------------------------------------------- +// Multi-target resolution — shared between project-scoped list commands +// --------------------------------------------------------------------------- + +/** + * Result of resolving targets from a parsed org/project argument. + * Mirrors the shape used by issue list and alert issue list commands. + */ +export type MultiTargetResolutionResult = { + targets: ResolvedTarget[]; + footer?: string; + skippedSelfHosted?: number; + detectedDsns?: DetectedDsn[]; +}; + +/** Options for {@link resolveTargetsFromParsedArg}. */ +export type ResolveTargetsOptions = { + /** Current working directory, for DSN auto-detection. */ + cwd: string; + /** Usage hint shown in error messages (e.g. "sentry issue list /"). */ + usageHint: string; + /** + * Auto-detect mode only: enrich targets that lack a numeric `projectId` by + * fetching from the project API. Useful when env-var / config-default paths + * do not carry IDs (needed for issue list query filters, not needed for alert list). + */ + enrichProjectIds?: boolean; + /** + * Project-search mode only: reject inputs that look like issue short IDs + * (e.g. "CLI-123") before attempting cross-org project search. + */ + checkIssueShortId?: boolean; +}; + +/** + * Resolve one or more {@link ResolvedTarget}s from a parsed org/project argument. + * + * Handles all four target modes: + * - **auto-detect** — DSN detection / config defaults (may resolve multiple projects) + * - **explicit** — single `org/project` target + * - **org-all** — all projects in the specified org (trailing slash required) + * - **project-search** — find a project by slug across all accessible orgs + * + * This is the canonical shared implementation used by project-scoped list commands + * (issue list, alert issue list, …). Pass `opts.enrichProjectIds` or + * `opts.checkIssueShortId` to enable command-specific behaviour. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inherent multi-mode target resolution with per-mode error handling +export async function resolveTargetsFromParsedArg( + parsed: ReturnType, + opts: ResolveTargetsOptions +): Promise { + const { cwd, usageHint, enrichProjectIds, checkIssueShortId } = opts; + + switch (parsed.type) { + case "auto-detect": { + const result = await resolveAllTargets({ cwd, usageHint }); + if (enrichProjectIds) { + result.targets = await Promise.all( + result.targets.map(async (t) => { + if (t.projectId !== undefined) { + return t; + } + try { + const info = await getProject(t.org, t.project); + const id = toNumericId(info.id); + return id !== undefined ? { ...t, projectId: id } : t; + } catch { + return t; + } + }) + ); + } + return result; + } + + case "explicit": { + const projectId = await fetchProjectId(parsed.org, parsed.project); + return { + targets: [ + { + org: parsed.org, + project: parsed.project, + projectId, + orgDisplay: parsed.org, + projectDisplay: parsed.project, + }, + ], + }; + } + + case "org-all": { + const projects = await listProjects(parsed.org); + const targets: ResolvedTarget[] = projects.map((p) => ({ + org: parsed.org, + project: p.slug, + projectId: toNumericId(p.id), + orgDisplay: parsed.org, + projectDisplay: p.name, + })); + + if (targets.length === 0) { + throw new ResolutionError( + `Organization '${parsed.org}'`, + "has no accessible projects", + `sentry project list ${parsed.org}/`, + ["Check that you have access to projects in this organization"] + ); + } + + return { + targets, + footer: + targets.length > 1 + ? `Showing results from ${targets.length} projects in ${parsed.org}` + : undefined, + }; + } + + case "project-search": { + if (checkIssueShortId && looksLikeIssueShortId(parsed.projectSlug)) { + throw new ResolutionError( + `'${parsed.projectSlug}'`, + "looks like an issue short ID, not a project slug", + `sentry issue view ${parsed.projectSlug}`, + ["To list issues in a project: sentry issue list /"] + ); + } + + const { projects: matches, orgs } = await findProjectsBySlug( + parsed.projectSlug + ); + + if (matches.length === 0) { + const isOrg = orgs.some((o) => o.slug === parsed.projectSlug); + if (isOrg) { + // Derive the base command from the usage hint (strip trailing placeholder). + // e.g. "sentry issue list /" → "sentry issue list" + const prefix = usageHint.split(" <")[0]; + throw new ResolutionError( + `'${parsed.projectSlug}'`, + "is an organization, not a project", + `${prefix} ${parsed.projectSlug}/`, + [ + `List projects: sentry project list ${parsed.projectSlug}/`, + `Specify a project: ${prefix} ${parsed.projectSlug}/`, + ] + ); + } + + const similar = await findProjectsByPattern(parsed.projectSlug); + const suggestions: string[] = []; + if (similar.length > 0) { + const names = similar + .slice(0, 3) + .map((p) => `'${p.orgSlug}/${p.slug}'`); + suggestions.push(`Similar projects: ${names.join(", ")}`); + } + suggestions.push( + "No project with this slug found in any accessible organization" + ); + throw new ResolutionError( + `Project '${parsed.projectSlug}'`, + "not found", + "sentry project list", + suggestions + ); + } + + const targets: ResolvedTarget[] = matches.map((m) => ({ + org: m.orgSlug, + project: m.slug, + projectId: toNumericId(m.id), + orgDisplay: m.orgSlug, + projectDisplay: m.name, + })); + + const uniqueOrgs = [...new Set(targets.map((t) => t.org))]; + const uniqueProjects = [...new Set(targets.map((t) => t.project))]; + setOrgProjectContext(uniqueOrgs, uniqueProjects); + + return { + targets, + footer: + matches.length > 1 + ? `Found '${parsed.projectSlug}' in ${matches.length} organizations` + : undefined, + }; + } + + default: { + const _exhaustive: never = parsed; + throw new Error( + `Unexpected parsed type: ${(_exhaustive as { type: string }).type}` + ); + } + } +} diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index 74d24972e..0cd637e93 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -222,6 +222,38 @@ export function buildTraceUrl(orgSlug: string, traceId: string): string { return `${getSentryBaseUrl()}/organizations/${orgSlug}/traces/${traceId}/`; } +// Alert URLs + +/** + * Build URL to the issue alert rules list for a project. + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug + * @returns Full URL to the issue alert rules page + */ +export function buildIssueAlertsUrl( + orgSlug: string, + projectSlug: string +): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/alerts/rules/?project=${projectSlug}`; + } + return `${getSentryBaseUrl()}/organizations/${orgSlug}/alerts/rules/?project=${projectSlug}`; +} + +/** + * Build URL to the metric alert rules list for an organization. + * + * @param orgSlug - Organization slug + * @returns Full URL to the metric alert rules page + */ +export function buildMetricAlertsUrl(orgSlug: string): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/alerts/metric-rules/`; + } + return `${getSentryBaseUrl()}/organizations/${orgSlug}/alerts/metric-rules/`; +} + /** * Build URL to view a release in Sentry. * diff --git a/src/lib/time-range.ts b/src/lib/time-range.ts index 4466e5497..6ff790138 100644 --- a/src/lib/time-range.ts +++ b/src/lib/time-range.ts @@ -64,15 +64,18 @@ const PERIOD_UNITS = new Set(Object.keys(UNIT_SECONDS)); * without causing constant regeneration churn in committed skill files. */ const EXAMPLE_START = (() => { - const d = new Date(); - d.setDate(1); - d.setMonth(d.getMonth() - 1); - return d.toISOString().slice(0, 10); + const now = new Date(); + const previousMonthStartUtc = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1) + ); + return previousMonthStartUtc.toISOString().slice(0, 10); })(); const EXAMPLE_END = (() => { - const d = new Date(); - d.setDate(1); - return d.toISOString().slice(0, 10); + const now = new Date(); + const currentMonthStartUtc = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) + ); + return currentMonthStartUtc.toISOString().slice(0, 10); })(); /** Brief text for --period flag help, shared across commands */ diff --git a/test/commands/alert/issues/create.test.ts b/test/commands/alert/issues/create.test.ts new file mode 100644 index 000000000..ee95d49a1 --- /dev/null +++ b/test/commands/alert/issues/create.test.ts @@ -0,0 +1,148 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { createCommand } from "../../../../src/commands/alert/issues/create.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../../src/lib/api-client.js"; +import { ValidationError } from "../../../../src/lib/errors.js"; +import type { ResolvedTarget } from "../../../../src/lib/resolve-target.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../../src/lib/resolve-target.js"; +import { useTestConfigDir } from "../../../helpers.js"; + +const getConfigDir = useTestConfigDir("test-alert-issues-create-", { + isolateProjectRoot: true, +}); + +const sampleTarget: ResolvedTarget = { + org: "test-org", + project: "test-project", + orgDisplay: "test-org", + projectDisplay: "test-project", +}; + +type CreateFlags = { + readonly name: string; + readonly condition?: string[]; + readonly action?: string[]; + readonly "action-match"?: "all" | "any"; + readonly frequency: number; + readonly "dry-run": boolean; + readonly json: boolean; +}; + +function createContext() { + return { + stdout: { write: mock(() => true) }, + stderr: { write: mock(() => true) }, + cwd: getConfigDir(), + }; +} + +describe("alert issues create", () => { + let resolveSpy: ReturnType; + let createSpy: ReturnType; + + beforeEach(() => { + resolveSpy = spyOn(resolveTarget, "resolveTargetsFromParsedArg"); + createSpy = spyOn(apiClient, "createIssueAlertRule"); + }); + + afterEach(() => { + resolveSpy.mockRestore(); + createSpy.mockRestore(); + }); + + test("requires --action-match", async () => { + const context = createContext(); + const func = (await createCommand.loader()) as unknown as ( + this: unknown, + flags: CreateFlags, + arg: string + ) => Promise; + + await expect( + func.call( + context, + { + name: "Rule A", + condition: ['{"id":"condition-a"}'], + action: ['{"id":"action-a"}'], + frequency: 30, + "dry-run": true, + json: true, + }, + "test-org/test-project" + ) + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("dry run does not call create API", async () => { + const context = createContext(); + resolveSpy.mockResolvedValue({ targets: [sampleTarget] }); + const func = (await createCommand.loader()) as unknown as ( + this: unknown, + flags: CreateFlags, + arg: string + ) => Promise; + + await func.call( + context, + { + name: "Rule A", + condition: ['{"id":"condition-a"}'], + action: ['{"id":"action-a"}'], + "action-match": "all", + frequency: 30, + "dry-run": true, + json: true, + }, + "test-org/test-project" + ); + + expect(createSpy).not.toHaveBeenCalled(); + }); + + test("calls create API with parsed body", async () => { + const context = createContext(); + resolveSpy.mockResolvedValue({ targets: [sampleTarget] }); + createSpy.mockResolvedValue({ + id: "99", + name: "Rule A", + status: "active", + }); + const func = (await createCommand.loader()) as unknown as ( + this: unknown, + flags: CreateFlags, + arg: string + ) => Promise; + + await func.call( + context, + { + name: "Rule A", + condition: ['{"id":"condition-a"}'], + action: ['{"id":"action-a"}'], + "action-match": "any", + frequency: 15, + "dry-run": false, + json: true, + }, + "test-org/test-project" + ); + + expect(createSpy).toHaveBeenCalledWith("test-org", "test-project", { + name: "Rule A", + conditions: [{ id: "condition-a" }], + actions: [{ id: "action-a" }], + actionMatch: "any", + frequency: 15, + }); + }); +}); diff --git a/test/commands/alert/issues/edit.test.ts b/test/commands/alert/issues/edit.test.ts new file mode 100644 index 000000000..a22c9589b --- /dev/null +++ b/test/commands/alert/issues/edit.test.ts @@ -0,0 +1,146 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { editCommand } from "../../../../src/commands/alert/issues/edit.js"; +import type { IssueAlertRule } from "../../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../../src/lib/api-client.js"; +import { ValidationError } from "../../../../src/lib/errors.js"; +import type { ResolvedTarget } from "../../../../src/lib/resolve-target.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../../src/lib/resolve-target.js"; +import { useTestConfigDir } from "../../../helpers.js"; + +const getConfigDir = useTestConfigDir("test-alert-issues-edit-", { + isolateProjectRoot: true, +}); + +const sampleTarget: ResolvedTarget = { + org: "test-org", + project: "test-project", + orgDisplay: "test-org", + projectDisplay: "test-project", +}; + +const sampleRule: IssueAlertRule = { + id: "42", + name: "Rule Alpha", + status: "active", + actionMatch: "any", + conditions: [], + actions: [], + frequency: 30, + environment: null, + owner: null, + projects: ["test-project"], + dateCreated: "2026-01-01T00:00:00Z", +}; + +type EditFlags = { + readonly name?: string; + readonly status?: "active" | "disabled"; + readonly condition?: string[]; + readonly action?: string[]; + readonly "action-match"?: "all" | "any"; + readonly json: boolean; +}; + +function createContext() { + return { + stdout: { write: mock(() => true) }, + stderr: { write: mock(() => true) }, + cwd: getConfigDir(), + }; +} + +describe("alert issues edit", () => { + let getRuleSpy: ReturnType; + let getDocSpy: ReturnType; + let putSpy: ReturnType; + let resolveSpy: ReturnType; + + beforeEach(() => { + getRuleSpy = spyOn(apiClient, "getIssueAlertRule"); + getDocSpy = spyOn(apiClient, "getIssueAlertRuleDocument"); + putSpy = spyOn(apiClient, "putIssueAlertRule"); + resolveSpy = spyOn(resolveTarget, "resolveTargetsFromParsedArg"); + }); + + afterEach(() => { + getRuleSpy.mockRestore(); + getDocSpy.mockRestore(); + putSpy.mockRestore(); + resolveSpy.mockRestore(); + }); + + test("requires at least one mutation flag", async () => { + const context = createContext(); + const func = (await editCommand.loader()) as unknown as ( + this: unknown, + flags: EditFlags, + arg: string + ) => Promise; + + await expect( + func.call(context, { json: true }, "test-org/test-project/42") + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("merges additional fields into full PUT body", async () => { + const context = createContext(); + resolveSpy.mockResolvedValue({ targets: [sampleTarget] }); + getRuleSpy.mockResolvedValue(sampleRule); + getDocSpy.mockResolvedValue({ + id: "42", + name: "Rule Alpha", + status: "active", + actionMatch: "any", + conditions: [{ id: "old-condition" }], + actions: [{ id: "old-action" }], + frequency: 30, + }); + putSpy.mockResolvedValue({ + id: "42", + name: "Rule Beta", + status: "disabled", + actionMatch: "all", + conditions: [{ id: "new-condition" }], + actions: [{ id: "new-action" }], + frequency: 30, + }); + const func = (await editCommand.loader()) as unknown as ( + this: unknown, + flags: EditFlags, + arg: string + ) => Promise; + + await func.call( + context, + { + name: "Rule Beta", + status: "disabled", + condition: ['{"id":"new-condition"}'], + action: ['{"id":"new-action"}'], + "action-match": "all", + json: true, + }, + "test-org/test-project/42" + ); + + expect(putSpy).toHaveBeenCalledWith("test-org", "test-project", "42", { + id: "42", + name: "Rule Beta", + status: "disabled", + actionMatch: "all", + conditions: [{ id: "new-condition" }], + actions: [{ id: "new-action" }], + frequency: 30, + }); + }); +}); diff --git a/test/commands/alert/issues/list.test.ts b/test/commands/alert/issues/list.test.ts new file mode 100644 index 000000000..78490621b --- /dev/null +++ b/test/commands/alert/issues/list.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { + __testing, + listCommand, +} from "../../../../src/commands/alert/issues/list.js"; +import { DEFAULT_SENTRY_URL } from "../../../../src/lib/constants.js"; +import { setAuthToken } from "../../../../src/lib/db/auth.js"; +import { + setDefaultOrganization, + setDefaultProject, +} from "../../../../src/lib/db/defaults.js"; +import { setOrgRegion } from "../../../../src/lib/db/regions.js"; +import { mockFetch, useTestConfigDir } from "../../../helpers.js"; + +const getConfigDir = useTestConfigDir("test-alert-issues-list-", { + isolateProjectRoot: true, +}); + +type ListFlags = { + readonly web: boolean; + readonly fresh: boolean; + readonly limit: number; + readonly cursor?: string; + readonly json: boolean; + readonly fields?: string[]; + readonly query?: string; +}; + +type ListFunc = ( + this: unknown, + flags: ListFlags, + target?: string +) => Promise; + +function createContext() { + const stdout = { + output: "", + write(s: string) { + stdout.output += s; + }, + }; + const stderr = { + output: "", + write(s: string) { + stderr.output += s; + }, + }; + return { + context: { + process, + stdout, + stderr, + cwd: getConfigDir(), + }, + stdout, + }; +} + +describe("alert issues list pagination", () => { + let func: ListFunc; + + beforeEach(async () => { + func = (await listCommand.loader()) as unknown as ListFunc; + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + setDefaultOrganization("test-org"); + setDefaultProject("test-project"); + }); + + test("hasMore is false when limit is reached without next cursor", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + if (req.url.includes("/projects/test-org/test-project/rules/")) { + return new Response( + JSON.stringify([ + { + id: "1", + name: "Rule Alpha", + status: "active", + actionMatch: "any", + conditions: [], + actions: [], + frequency: 30, + environment: null, + owner: null, + projects: ["test-project"], + dateCreated: "2026-01-01T00:00:00Z", + }, + ]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="false"; cursor="0:0:0"', + }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }); + + const { context, stdout } = createContext(); + await func.call(context, { + web: false, + fresh: false, + limit: 1, + json: true, + }); + + const parsed = JSON.parse(stdout.output); + expect(parsed.hasMore).toBe(false); + expect(parsed.data).toHaveLength(1); + }); + + test("empty query page keeps hasMore when next cursor exists", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + if (req.url.includes("/projects/test-org/test-project/rules/")) { + return new Response( + JSON.stringify([ + { + id: "1", + name: "Rule Alpha", + status: "active", + actionMatch: "any", + conditions: [], + actions: [], + frequency: 30, + environment: null, + owner: null, + projects: ["test-project"], + dateCreated: "2026-01-01T00:00:00Z", + }, + ]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="true"; cursor="next:0:0"', + }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }); + + const { context, stdout } = createContext(); + await func.call(context, { + web: false, + fresh: false, + limit: 1, + json: true, + query: "zzz", + }); + + const parsed = JSON.parse(stdout.output); + expect(parsed.data).toEqual([]); + expect(parsed.hasMore).toBe(true); + }); +}); + +/** + * `phase1HasMore` is shared (same `some(success && hasMore)` logic) in metrics list; + * unit tests live here only to avoid duplicating the same cases across both files. + */ +describe("alert issues list __testing", () => { + const { phase1HasMore } = __testing; + const sampleTarget = { + org: "a", + project: "b", + orgDisplay: "a", + projectDisplay: "b", + }; + + test("phase1HasMore: OR of per-target hasMore, ignores failed fetches", () => { + const success = (hasMore: boolean) => ({ + success: true as const, + data: { hasMore, rules: [] as const, target: sampleTarget }, + }); + expect(phase1HasMore([success(false), success(true)])).toBe(true); + expect(phase1HasMore([success(false), success(false)])).toBe(false); + expect( + phase1HasMore([ + { success: false, error: new Error("nope") }, + success(true), + ]) + ).toBe(true); + }); +}); diff --git a/test/commands/alert/issues/view.test.ts b/test/commands/alert/issues/view.test.ts new file mode 100644 index 000000000..ab64ec9c7 --- /dev/null +++ b/test/commands/alert/issues/view.test.ts @@ -0,0 +1,134 @@ +/** + * Alert issues view — parsing, name resolution, and non-404 API error propagation. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { viewCommand } from "../../../../src/commands/alert/issues/view.js"; +import type { IssueAlertRule } from "../../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../../src/lib/api-client.js"; +import { ApiError, ValidationError } from "../../../../src/lib/errors.js"; +import type { ResolvedTarget } from "../../../../src/lib/resolve-target.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../../src/lib/resolve-target.js"; +import { useTestConfigDir } from "../../../helpers.js"; + +const getConfigDir = useTestConfigDir("test-alert-issues-view-", { + isolateProjectRoot: true, +}); + +const sampleTarget: ResolvedTarget = { + org: "test-org", + project: "test-project", + orgDisplay: "test-org", + projectDisplay: "test-project", +}; + +const baseRule: IssueAlertRule = { + id: "1", + name: "Rule Alpha", + status: "active", + actionMatch: "any", + conditions: [], + actions: [], + frequency: 30, + environment: null, + owner: null, + projects: ["test-project"], + dateCreated: "2026-01-01T00:00:00Z", +}; + +type ViewFlags = { readonly web: boolean; readonly json: boolean }; + +function createContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: getConfigDir(), + }, + stdoutWrite, + }; +} + +describe("alert issues view", () => { + let getRuleSpy: ReturnType; + let listRulesSpy: ReturnType; + let resolveSpy: ReturnType; + + beforeEach(() => { + getRuleSpy = spyOn(apiClient, "getIssueAlertRule"); + listRulesSpy = spyOn(apiClient, "listIssueAlertsPaginated"); + resolveSpy = spyOn(resolveTarget, "resolveTargetsFromParsedArg"); + }); + + afterEach(() => { + getRuleSpy.mockRestore(); + listRulesSpy.mockRestore(); + resolveSpy.mockRestore(); + }); + + test("rejects a single org/project (missing rule) with ValidationError", async () => { + const { context } = createContext(); + // Parse fails before target resolution; no need to mock resolve + const func = (await viewCommand.loader()) as unknown as ( + this: unknown, + flags: ViewFlags, + arg: string + ) => Promise; + + await expect( + func.call(context, { web: false, json: true }, "acme/frontend") + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("numeric id: propagates non-404 API errors (e.g. 500)", async () => { + const { context } = createContext(); + resolveSpy.mockResolvedValue({ targets: [sampleTarget] }); + getRuleSpy.mockRejectedValue(new ApiError("Server error", 500, "nope")); + const func = (await viewCommand.loader()) as unknown as ( + this: unknown, + flags: ViewFlags, + arg: string + ) => Promise; + + await expect( + func.call(context, { web: false, json: true }, "test-org/test-project/42") + ).rejects.toBeInstanceOf(ApiError); + }); + + test("name: no exact match with suggestions returns ValidationError with Did you mean", async () => { + const { context, stdoutWrite } = createContext(); + resolveSpy.mockResolvedValue({ targets: [sampleTarget] }); + listRulesSpy.mockResolvedValue({ + data: [baseRule], + nextCursor: undefined, + }); + const func = (await viewCommand.loader()) as unknown as ( + this: unknown, + flags: ViewFlags, + arg: string + ) => Promise; + + const err = await func + .call( + context, + { web: false, json: true }, + "test-org/test-project/Rule Alph" + ) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).message).toContain("Did you mean"); + expect((err as ValidationError).message).toContain("Rule Alpha"); + expect(stdoutWrite).not.toHaveBeenCalled(); + }); +}); diff --git a/test/commands/alert/metrics/create.test.ts b/test/commands/alert/metrics/create.test.ts new file mode 100644 index 000000000..51751d62f --- /dev/null +++ b/test/commands/alert/metrics/create.test.ts @@ -0,0 +1,154 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { createCommand } from "../../../../src/commands/alert/metrics/create.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../../src/lib/api-client.js"; +import { ValidationError } from "../../../../src/lib/errors.js"; +import type { ResolvedTarget } from "../../../../src/lib/resolve-target.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../../src/lib/resolve-target.js"; +import { useTestConfigDir } from "../../../helpers.js"; + +const getConfigDir = useTestConfigDir("test-alert-metrics-create-", { + isolateProjectRoot: true, +}); + +const sampleTarget: ResolvedTarget = { + org: "test-org", + project: "ignored", + orgDisplay: "test-org", + projectDisplay: "ignored", +}; + +type CreateFlags = { + readonly name: string; + readonly query: string; + readonly aggregate: string; + readonly dataset: string; + readonly "time-window": number; + readonly trigger?: string[]; + readonly "dry-run": boolean; + readonly json: boolean; +}; + +function createContext() { + return { + stdout: { write: mock(() => true) }, + stderr: { write: mock(() => true) }, + cwd: getConfigDir(), + }; +} + +describe("alert metrics create", () => { + let resolveSpy: ReturnType; + let createSpy: ReturnType; + + beforeEach(() => { + resolveSpy = spyOn(resolveTarget, "resolveTargetsFromParsedArg"); + createSpy = spyOn(apiClient, "createMetricAlertRule"); + }); + + afterEach(() => { + resolveSpy.mockRestore(); + createSpy.mockRestore(); + }); + + test("rejects unsupported dataset", async () => { + const context = createContext(); + const func = (await createCommand.loader()) as unknown as ( + this: unknown, + flags: CreateFlags, + arg: string + ) => Promise; + + await expect( + func.call( + context, + { + name: "Metric Rule", + query: "event.type:error", + aggregate: "count()", + dataset: "unknown", + "time-window": 5, + trigger: ['{"alertThreshold":100,"actions":[{"id":"notify"}]}'], + "dry-run": true, + json: true, + }, + "test-org" + ) + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("dry run does not call create API", async () => { + const context = createContext(); + resolveSpy.mockResolvedValue({ targets: [sampleTarget] }); + const func = (await createCommand.loader()) as unknown as ( + this: unknown, + flags: CreateFlags, + arg: string + ) => Promise; + + await func.call( + context, + { + name: "Metric Rule", + query: "event.type:error", + aggregate: "count()", + dataset: "errors", + "time-window": 5, + trigger: ['{"alertThreshold":100,"actions":[{"id":"notify"}]}'], + "dry-run": true, + json: true, + }, + "test-org" + ); + + expect(createSpy).not.toHaveBeenCalled(); + }); + + test("calls create API with parsed trigger payload", async () => { + const context = createContext(); + resolveSpy.mockResolvedValue({ targets: [sampleTarget] }); + createSpy.mockResolvedValue({ + id: "77", + name: "Metric Rule", + status: 0, + }); + const func = (await createCommand.loader()) as unknown as ( + this: unknown, + flags: CreateFlags, + arg: string + ) => Promise; + + await func.call( + context, + { + name: "Metric Rule", + query: "event.type:error", + aggregate: "count()", + dataset: "errors", + "time-window": 5, + trigger: ['{"alertThreshold":100,"actions":[{"id":"notify"}]}'], + "dry-run": false, + json: true, + }, + "test-org" + ); + + expect(createSpy).toHaveBeenCalledWith("test-org", { + name: "Metric Rule", + query: "event.type:error", + aggregate: "count()", + dataset: "errors", + timeWindow: 5, + triggers: [{ alertThreshold: 100, actions: [{ id: "notify" }] }], + }); + }); +}); diff --git a/test/commands/alert/metrics/edit.test.ts b/test/commands/alert/metrics/edit.test.ts new file mode 100644 index 000000000..c4a178066 --- /dev/null +++ b/test/commands/alert/metrics/edit.test.ts @@ -0,0 +1,152 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { editCommand } from "../../../../src/commands/alert/metrics/edit.js"; +import type { MetricAlertRule } from "../../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../../src/lib/api-client.js"; +import { ValidationError } from "../../../../src/lib/errors.js"; +import type { ResolvedTarget } from "../../../../src/lib/resolve-target.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../../src/lib/resolve-target.js"; +import { useTestConfigDir } from "../../../helpers.js"; + +const getConfigDir = useTestConfigDir("test-alert-metrics-edit-", { + isolateProjectRoot: true, +}); + +const sampleTarget: ResolvedTarget = { + org: "test-org", + project: "ignored", + orgDisplay: "test-org", + projectDisplay: "ignored", +}; + +const sampleRule: MetricAlertRule = { + id: "9", + name: "Metric Rule", + status: 0, + query: "event.type:error", + aggregate: "count()", + dataset: "errors", + timeWindow: 5, + environment: null, + owner: null, + projects: [], + dateCreated: "2026-01-01T00:00:00Z", +}; + +type EditFlags = { + readonly name?: string; + readonly status?: "active" | "disabled"; + readonly dataset?: string; + readonly "time-window"?: number; + readonly trigger?: string[]; + readonly query?: string; + readonly aggregate?: string; + readonly json: boolean; +}; + +function createContext() { + return { + stdout: { write: mock(() => true) }, + stderr: { write: mock(() => true) }, + cwd: getConfigDir(), + }; +} + +describe("alert metrics edit", () => { + let getRuleSpy: ReturnType; + let getDocSpy: ReturnType; + let putSpy: ReturnType; + let resolveSpy: ReturnType; + + beforeEach(() => { + getRuleSpy = spyOn(apiClient, "getMetricAlertRule"); + getDocSpy = spyOn(apiClient, "getMetricAlertRuleDocument"); + putSpy = spyOn(apiClient, "putMetricAlertRule"); + resolveSpy = spyOn(resolveTarget, "resolveTargetsFromParsedArg"); + }); + + afterEach(() => { + getRuleSpy.mockRestore(); + getDocSpy.mockRestore(); + putSpy.mockRestore(); + resolveSpy.mockRestore(); + }); + + test("requires at least one mutation flag", async () => { + const context = createContext(); + const func = (await editCommand.loader()) as unknown as ( + this: unknown, + flags: EditFlags, + arg: string + ) => Promise; + + await expect( + func.call(context, { json: true }, "test-org/9") + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("merges advanced fields and validates trigger payload", async () => { + const context = createContext(); + resolveSpy.mockResolvedValue({ targets: [sampleTarget] }); + getRuleSpy.mockResolvedValue(sampleRule); + getDocSpy.mockResolvedValue({ + id: "9", + name: "Metric Rule", + status: 0, + query: "event.type:error", + aggregate: "count()", + dataset: "errors", + timeWindow: 5, + triggers: [{ alertThreshold: 100, actions: [{ id: "notify" }] }], + }); + putSpy.mockResolvedValue({ + id: "9", + name: "Metric Rule Updated", + status: 1, + query: "event.type:error environment:prod", + aggregate: "count()", + dataset: "transactions", + timeWindow: 15, + triggers: [{ alertThreshold: 200, actions: [{ id: "notify" }] }], + }); + const func = (await editCommand.loader()) as unknown as ( + this: unknown, + flags: EditFlags, + arg: string + ) => Promise; + + await func.call( + context, + { + status: "disabled", + query: "event.type:error environment:prod", + aggregate: "count()", + dataset: "transactions", + "time-window": 15, + trigger: ['{"alertThreshold":200,"actions":[{"id":"notify"}]}'], + json: true, + }, + "test-org/9" + ); + + expect(putSpy).toHaveBeenCalledWith("test-org", "9", { + id: "9", + name: "Metric Rule", + status: 1, + query: "event.type:error environment:prod", + aggregate: "count()", + dataset: "transactions", + timeWindow: 15, + triggers: [{ alertThreshold: 200, actions: [{ id: "notify" }] }], + }); + }); +}); diff --git a/test/commands/alert/metrics/list.test.ts b/test/commands/alert/metrics/list.test.ts new file mode 100644 index 000000000..48f02b8e9 --- /dev/null +++ b/test/commands/alert/metrics/list.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { listCommand } from "../../../../src/commands/alert/metrics/list.js"; +import { DEFAULT_SENTRY_URL } from "../../../../src/lib/constants.js"; +import { setAuthToken } from "../../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../../src/lib/db/regions.js"; +import { mockFetch, useTestConfigDir } from "../../../helpers.js"; + +const getConfigDir = useTestConfigDir("test-alert-metrics-list-", { + isolateProjectRoot: true, +}); + +type ListFlags = { + readonly web: boolean; + readonly fresh: boolean; + readonly limit: number; + readonly cursor?: string; + readonly json: boolean; + readonly fields?: string[]; + readonly query?: string; +}; + +type ListFunc = ( + this: unknown, + flags: ListFlags, + target?: string +) => Promise; + +function createContext() { + const stdout = { + output: "", + write(s: string) { + stdout.output += s; + }, + }; + const stderr = { + output: "", + write(s: string) { + stderr.output += s; + }, + }; + return { + context: { + process, + stdout, + stderr, + cwd: getConfigDir(), + }, + stdout, + }; +} + +describe("alert metrics list pagination", () => { + let func: ListFunc; + + beforeEach(async () => { + func = (await listCommand.loader()) as unknown as ListFunc; + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + test("hasMore is false when limit is reached without next cursor", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + if (req.url.includes("/organizations/test-org/alert-rules/")) { + return new Response( + JSON.stringify([ + { + id: "1", + name: "Metric Rule Alpha", + status: 0, + query: "event.type:error", + aggregate: "count()", + dataset: "errors", + timeWindow: 5, + environment: null, + owner: null, + projects: [], + dateCreated: "2026-01-01T00:00:00Z", + }, + ]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="false"; cursor="0:0:0"', + }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }); + + const { context, stdout } = createContext(); + await func.call( + context, + { + web: false, + fresh: false, + limit: 1, + json: true, + }, + "test-org/" + ); + + const parsed = JSON.parse(stdout.output); + expect(parsed.hasMore).toBe(false); + expect(parsed.data).toHaveLength(1); + }); + + test("empty query page keeps hasMore when next cursor exists", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input, init); + if (req.url.includes("/organizations/test-org/alert-rules/")) { + return new Response( + JSON.stringify([ + { + id: "1", + name: "Metric Rule Alpha", + status: 0, + query: "event.type:error", + aggregate: "count()", + dataset: "errors", + timeWindow: 5, + environment: null, + owner: null, + projects: [], + dateCreated: "2026-01-01T00:00:00Z", + }, + ]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="true"; cursor="next:0:0"', + }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }); + + const { context, stdout } = createContext(); + await func.call( + context, + { + web: false, + fresh: false, + limit: 1, + json: true, + query: "zzz", + }, + "test-org/" + ); + + const parsed = JSON.parse(stdout.output); + expect(parsed.data).toEqual([]); + expect(parsed.hasMore).toBe(true); + }); +}); diff --git a/test/commands/alert/metrics/view.test.ts b/test/commands/alert/metrics/view.test.ts new file mode 100644 index 000000000..74ff4a216 --- /dev/null +++ b/test/commands/alert/metrics/view.test.ts @@ -0,0 +1,130 @@ +/** + * Alert metrics view — parsing, name resolution, and non-404 API error propagation. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { viewCommand } from "../../../../src/commands/alert/metrics/view.js"; +import type { MetricAlertRule } from "../../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../../src/lib/api-client.js"; +import { ApiError, ValidationError } from "../../../../src/lib/errors.js"; +import type { ResolvedTarget } from "../../../../src/lib/resolve-target.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../../src/lib/resolve-target.js"; +import { useTestConfigDir } from "../../../helpers.js"; + +const getConfigDir = useTestConfigDir("test-alert-metrics-view-", { + isolateProjectRoot: true, +}); + +const sampleTarget: ResolvedTarget = { + org: "test-org", + project: "ignored", + orgDisplay: "test-org", + projectDisplay: "ignored", +}; + +const baseRule: MetricAlertRule = { + id: "1", + name: "Metric Rule Alpha", + status: 0, + query: "event.type:error", + aggregate: "count()", + dataset: "errors", + timeWindow: 5, + environment: null, + owner: null, + projects: [], + dateCreated: "2026-01-01T00:00:00Z", +}; + +type ViewFlags = { readonly web: boolean; readonly json: boolean }; + +function createContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: getConfigDir(), + }, + stdoutWrite, + }; +} + +describe("alert metrics view", () => { + let getRuleSpy: ReturnType; + let listRulesSpy: ReturnType; + let resolveSpy: ReturnType; + + beforeEach(() => { + getRuleSpy = spyOn(apiClient, "getMetricAlertRule"); + listRulesSpy = spyOn(apiClient, "listMetricAlertsPaginated"); + resolveSpy = spyOn(resolveTarget, "resolveTargetsFromParsedArg"); + }); + + afterEach(() => { + getRuleSpy.mockRestore(); + listRulesSpy.mockRestore(); + resolveSpy.mockRestore(); + }); + + test("rejects org/ with no rule id or name (ValidationError)", async () => { + const { context } = createContext(); + // Parse fails before target resolution; no need to mock resolve + const func = (await viewCommand.loader()) as unknown as ( + this: unknown, + flags: ViewFlags, + arg: string + ) => Promise; + + await expect( + func.call(context, { web: false, json: true }, "acme/") + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("numeric id: propagates non-404 API errors (e.g. 500)", async () => { + const { context } = createContext(); + resolveSpy.mockResolvedValue({ targets: [sampleTarget] }); + getRuleSpy.mockRejectedValue(new ApiError("Server error", 500, "nope")); + const func = (await viewCommand.loader()) as unknown as ( + this: unknown, + flags: ViewFlags, + arg: string + ) => Promise; + + await expect( + func.call(context, { web: false, json: true }, "test-org/42") + ).rejects.toBeInstanceOf(ApiError); + }); + + test("name: no exact match with suggestions returns ValidationError with Did you mean", async () => { + const { context, stdoutWrite } = createContext(); + resolveSpy.mockResolvedValue({ targets: [sampleTarget] }); + listRulesSpy.mockResolvedValue({ + data: [baseRule], + nextCursor: undefined, + }); + const func = (await viewCommand.loader()) as unknown as ( + this: unknown, + flags: ViewFlags, + arg: string + ) => Promise; + + const err = await func + .call(context, { web: false, json: true }, "test-org/Metric Rule Alph") + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).message).toContain("Did you mean"); + expect((err as ValidationError).message).toContain("Metric Rule Alpha"); + expect(stdoutWrite).not.toHaveBeenCalled(); + }); +});