From 19028f0f1c78c43ab0eace622af994ccba1836eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Thu, 26 Mar 2026 20:51:29 +0100 Subject: [PATCH 01/21] feat(alert): add alert issues and metrics list commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new list commands under `sentry alert`: - `sentry alert issues list` — list issue alert rules for one or more projects, with full multi-target resolution (DSN auto-detect, org-all, project-search, explicit org/project), compound cursor pagination, Phase 1 + Phase 2 budget redistribution, and graceful partial failure handling - `sentry alert metrics list` — list metric alert rules for an org Both commands support --json, --limit, --web, and -c next/prev pagination. --- src/app.ts | 2 + src/commands/alert/index.ts | 19 + src/commands/alert/issues/index.ts | 16 + src/commands/alert/issues/list.ts | 564 ++++++++++++++++++++++++++++ src/commands/alert/metrics/index.ts | 16 + src/commands/alert/metrics/list.ts | 318 ++++++++++++++++ src/lib/api-client.ts | 6 + src/lib/api/alerts.ts | 102 +++++ src/lib/sentry-urls.ts | 32 ++ 9 files changed, 1075 insertions(+) create mode 100644 src/commands/alert/index.ts create mode 100644 src/commands/alert/issues/index.ts create mode 100644 src/commands/alert/issues/list.ts create mode 100644 src/commands/alert/metrics/index.ts create mode 100644 src/commands/alert/metrics/list.ts create mode 100644 src/lib/api/alerts.ts diff --git a/src/app.ts b/src/app.ts index 3cd220f77..fc900df97 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,7 @@ import { UnexpectedPositionalError, } from "@stricli/core"; import { apiCommand } from "./commands/api.js"; +import { alertRoute } from "./commands/alert/index.js"; import { authRoute } from "./commands/auth/index.js"; import { whoamiCommand } from "./commands/auth/whoami.js"; import { cliRoute } from "./commands/cli/index.js"; @@ -67,6 +68,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..9accc6055 --- /dev/null +++ b/src/commands/alert/index.ts @@ -0,0 +1,19 @@ +import { buildRouteMap } from "@stricli/core"; +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/index.ts b/src/commands/alert/issues/index.ts new file mode 100644 index 000000000..b084f90d1 --- /dev/null +++ b/src/commands/alert/issues/index.ts @@ -0,0 +1,16 @@ +import { buildRouteMap } from "@stricli/core"; +import { listCommand } from "./list.js"; + +export const issuesRoute = buildRouteMap({ + routes: { + list: listCommand, + }, + 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", + hideRoute: {}, + }, +}); diff --git a/src/commands/alert/issues/list.ts b/src/commands/alert/issues/list.ts new file mode 100644 index 000000000..f85cd7b5a --- /dev/null +++ b/src/commands/alert/issues/list.ts @@ -0,0 +1,564 @@ +/** + * sentry alert issues list + * + * List issue alert rules for one or more Sentry projects. + * + * Supports the same target resolution as `sentry issue list`: + * - auto-detect → DSN detection / config defaults (may resolve multiple projects) + * - explicit → single org/project + * - org-all → all projects in an org (trailing slash required) + * - project-search → find project by slug across all orgs + */ + +import type { SentryContext } from "../../../context.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, + buildPaginationContextKey, + decodeCompoundCursor, + encodeCompoundCursor, + hasPreviousPage, + resolveCursor, +} from "../../../lib/db/pagination.js"; +import { ApiError, ContextError } from "../../../lib/errors.js"; +import { filterFields } from "../../../lib/formatters/json.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, + paginationHint, + targetPatternExplanation, +} from "../../../lib/list-command.js"; +import { logger } from "../../../lib/logger.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 { 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 /"; + +type ListFlags = { + readonly web: boolean; + readonly fresh: boolean; + readonly limit: number; + readonly cursor?: string; + readonly json: boolean; + readonly fields?: string[]; +}; + +// --------------------------------------------------------------------------- +// Result type +// --------------------------------------------------------------------------- + +type AlertRuleWithTarget = { + rule: IssueAlertRule; + target: ResolvedTarget; +}; + +type IssueAlertListResult = { + rulesWithTargets: AlertRuleWithTarget[]; + isMultiProject: boolean; + hasMore: boolean; + hasPrev?: boolean; + nextCursor?: string; + /** Used only in single-target mode for hint/URL */ + singleTarget?: ResolvedTarget; + footer?: string; +}; + +// --------------------------------------------------------------------------- +// Fetch +// --------------------------------------------------------------------------- + +type FetchForTargetResult = + | { success: true; rules: IssueAlertRule[]; nextCursor?: string } + | { success: false; error: Error }; + +async function fetchRulesForTarget( + target: ResolvedTarget, + opts: { limit: number; cursor?: string } +): Promise { + try { + const results: IssueAlertRule[] = []; + let serverCursor = opts.cursor; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await listIssueAlertsPaginated( + target.org, + target.project, + { + perPage: Math.min(opts.limit - results.length, API_MAX_PER_PAGE), + cursor: serverCursor, + } + ); + + for (const rule of data) { + results.push(rule); + if (results.length >= opts.limit) { + return { success: true, rules: results, nextCursor }; + } + } + + if (!nextCursor) { + return { success: true, rules: results }; + } + serverCursor = nextCursor; + } + + return { success: true, rules: results }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err : new Error(String(err)), + }; + } +} + +// --------------------------------------------------------------------------- +// Human output +// --------------------------------------------------------------------------- + +function formatIssueAlertListHuman(result: IssueAlertListResult): string { + if (result.rulesWithTargets.length === 0) { + const base = result.footer + ? `No issue alert rules found.\n\n${result.footer}` + : "No issue alert rules found."; + return base; + } + + type Row = { + id: string; + name: string; + project?: string; + conditions: string; + actions: string; + environment: string; + status: string; + }; + + const rows: Row[] = result.rulesWithTargets.map(({ rule: r, target }) => ({ + id: r.id, + name: escapeMarkdownCell(r.name), + ...(result.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 }, + ...(result.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) }; + writeTable(buffer, rows, columns); + + return parts.join("").trimEnd(); +} + +// --------------------------------------------------------------------------- +// JSON transform +// --------------------------------------------------------------------------- + +function jsonTransformIssueAlertList( + result: IssueAlertListResult, + fields?: string[] +): unknown { + const rules = result.rulesWithTargets.map(({ rule }) => rule); + const items = + fields && fields.length > 0 + ? rules.map((r) => filterFields(r, fields)) + : rules; + + const envelope: Record = { + data: items, + hasMore: result.hasMore, + hasPrev: !!result.hasPrev, + }; + if (result.nextCursor) { + envelope.nextCursor = result.nextCursor; + } + return envelope; +} + +// --------------------------------------------------------------------------- +// Hint +// --------------------------------------------------------------------------- + +function buildHint(result: IssueAlertListResult): string | undefined { + const { singleTarget } = result; + + const navRaw = + singleTarget && + paginationHint({ + hasPrev: !!result.hasPrev, + hasMore: result.hasMore, + prevHint: `sentry alert issues list ${singleTarget.org}/${singleTarget.project} -c prev`, + nextHint: `sentry alert issues list ${singleTarget.org}/${singleTarget.project} -c next`, + }); + const nav = navRaw ? ` ${navRaw}` : ""; + + const count = result.rulesWithTargets.length; + if (count === 0) { + return nav ? `No issue alert rules found.${nav}` : undefined; + } + + const parts: string[] = [`Showing ${count} rule(s).${nav}`]; + if (result.footer) { + parts.push(result.footer); + } + if (singleTarget) { + parts.push( + `Alert rules: ${buildIssueAlertsUrl(singleTarget.org, singleTarget.project)}` + ); + } + return parts.join("\n"); +} + +// --------------------------------------------------------------------------- +// Multi-target fetch +// --------------------------------------------------------------------------- + +/** + * Fetch alert rules from all targets in parallel with compound cursor support. + * + * Uses a two-phase strategy: + * 1. Phase 1: distribute `ceil(limit / activeTargets)` quota per target in parallel. + * 2. Phase 2: if total fetched < limit and some targets have more, redistribute + * the surplus among expandable targets and fetch one more page each. + * + * For multi-target mode, cursors are stored as a pipe-separated compound cursor + * so `-c next` / `-c prev` advances each project independently. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inherent multi-target fetch with compound cursor, Phase 2, and failure handling +async function fetchAllTargets( + targets: ResolvedTarget[], + flags: ListFlags +): Promise<{ + rulesWithTargets: AlertRuleWithTarget[]; + cursorToStore: string | undefined; + contextKey: string; + direction: "next" | "prev" | "first"; +}> { + const isSingleTarget = targets.length === 1; + // biome-ignore lint/style/noNonNullAssertion: guarded by isSingleTarget + const singleTarget = isSingleTarget ? targets[0]! : undefined; + + // Build the context key — single uses the project-scoped key, multi uses a + // fingerprint of all target org/project pairs so cursors are never crossed. + const contextKey = + isSingleTarget && singleTarget + ? buildPaginationContextKey("alert-issues", singleTarget.org, { + project: singleTarget.project, + }) + : buildMultiTargetContextKey(targets); + + // Sorted target keys must match the order used in buildMultiTargetContextKey. + const sortedTargetKeys = targets.map((t) => `${t.org}/${t.project}`).sort(); + + // Resolve stored cursor (handles --cursor flag and DB lookup). + const { cursor: rawCursor, direction } = resolveCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); + + // Decode per-target start cursors from the compound cursor. + const startCursors = new Map(); + const exhaustedTargets = new Set(); + if (rawCursor) { + if (isSingleTarget && singleTarget) { + startCursors.set( + `${singleTarget.org}/${singleTarget.project}`, + rawCursor + ); + } else { + 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 { + // null = project was exhausted on the previous page — skip entirely + exhaustedTargets.add(key); + } + } + } + } + + // Skip targets that were exhausted on the previous page. + const activeTargets = + exhaustedTargets.size > 0 + ? targets.filter((t) => !exhaustedTargets.has(`${t.org}/${t.project}`)) + : targets; + + const quota = Math.max(1, Math.ceil(flags.limit / activeTargets.length)); + const message = + activeTargets.length > 1 + ? `Fetching issue alert rules from ${activeTargets.length} projects...` + : `Fetching issue alert rules for ${singleTarget?.org}/${singleTarget?.project}...`; + + // Phase 1: fetch quota from each active target in parallel. + const phase1 = await withProgress({ message, json: flags.json }, () => + Promise.all( + activeTargets.map((t) => + fetchRulesForTarget(t, { + limit: quota, + cursor: startCursors.get(`${t.org}/${t.project}`), + }) + ) + ) + ); + + let totalFetched = phase1.reduce( + (sum, r) => sum + (r.success ? r.rules.length : 0), + 0 + ); + + // Phase 2: redistribute surplus among targets that still have more pages. + const surplus = flags.limit - totalFetched; + if (surplus > 0) { + const expandableIndices: number[] = []; + for (let i = 0; i < phase1.length; i++) { + const r = phase1[i]; + if (r?.success && r.rules.length >= quota && r.nextCursor) { + expandableIndices.push(i); + } + } + if (expandableIndices.length > 0) { + 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 t = activeTargets[i]!; + const r = phase1[i] as { + success: true; + rules: IssueAlertRule[]; + nextCursor?: string; + }; + // biome-ignore lint/style/noNonNullAssertion: expandableIndices only contains indices with a nextCursor + const cursor = r.nextCursor!; + return fetchRulesForTarget(t, { limit: extraQuota, 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.rules.push(...p2.rules); + p1.nextCursor = p2.nextCursor; + totalFetched += p2.rules.length; + } + } + } + } + + // Build the cursor to store for `-c next`. + // Index into sortedTargetKeys to keep compound cursor aligned. + const phase1ByKey = new Map(); + for (let i = 0; i < activeTargets.length; i++) { + const t = activeTargets[i]; + const r = phase1[i]; + if (t && r) { + phase1ByKey.set(`${t.org}/${t.project}`, r); + } + } + + let cursorToStore: string | undefined; + if (isSingleTarget) { + const r = phase1[0]; + cursorToStore = r?.success ? (r.nextCursor ?? undefined) : undefined; + } else { + const cursorValues: (string | null)[] = sortedTargetKeys.map((key) => { + if (exhaustedTargets.has(key)) { + return null; + } + const result = phase1ByKey.get(key); + if (result?.success) { + return result.nextCursor ?? null; + } + // Failed fetch: preserve the start cursor so the next `-c next` retries + // from the same position rather than restarting from scratch. + return startCursors.get(key) ?? null; + }); + const hasAnyCursor = cursorValues.some((c) => c !== null); + cursorToStore = hasAnyCursor + ? encodeCompoundCursor(cursorValues) + : undefined; + } + + // Surface total failure; log partial failures. + const failureIndices = phase1 + .map((r, i) => (r.success ? -1 : i)) + .filter((i) => i !== -1); + + if (failureIndices.length > 0) { + if (failureIndices.length === phase1.length) { + const first = phase1[0]; + const err = + first && !first.success ? first.error : new Error("All fetches failed"); + throw err instanceof ApiError ? err : new Error(err.message); + } + const names = failureIndices + // biome-ignore lint/style/noNonNullAssertion: index within bounds + .map((i) => `${activeTargets[i]!.org}/${activeTargets[i]!.project}`) + .join(", "); + logger.warn( + `Failed to fetch alert rules from ${names}. Showing results from remaining projects.` + ); + } + + // Combine valid results, sorted by name. + const rulesWithTargets: AlertRuleWithTarget[] = []; + for (let i = 0; i < phase1.length; i++) { + // biome-ignore lint/style/noNonNullAssertion: i is within bounds + const r = phase1[i]!; + // biome-ignore lint/style/noNonNullAssertion: i is within bounds + const t = activeTargets[i]!; + if (r.success) { + for (const rule of r.rules) { + rulesWithTargets.push({ rule, target: t }); + } + } + } + rulesWithTargets.sort((a, b) => a.rule.name.localeCompare(b.rule.name)); + + return { rulesWithTargets, cursorToStore, contextKey, direction }; +} + +// --------------------------------------------------------------------------- +// 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.", + }, + output: { + human: formatIssueAlertListHuman, + jsonTransform: jsonTransformIssueAlertList, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: + "/, / (all), (search), or omit to auto-detect", + parse: String, + optional: true, + }, + ], + }, + flags: { + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + limit: buildListLimitFlag("issue alert rules"), + }, + aliases: { w: "web", n: "limit" }, + }, + async *func(this: SentryContext, flags: ListFlags, target?: string) { + const { cwd } = this; + + const parsed = parseOrgProjectArg(target); + + const { targets, footer } = await withProgress( + { message: "Resolving targets...", json: flags.json }, + () => resolveTargetsFromParsedArg(parsed, { cwd, usageHint: USAGE_HINT }) + ); + + if (targets.length === 0) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + const singleTarget = targets.length === 1 ? targets[0] : undefined; + + if (flags.web && singleTarget) { + await openInBrowser( + buildIssueAlertsUrl(singleTarget.org, singleTarget.project), + "issue alert rules" + ); + return; + } + + const { rulesWithTargets, cursorToStore, contextKey, direction } = + await fetchAllTargets(targets, flags); + + advancePaginationState( + PAGINATION_KEY, + contextKey, + direction, + cursorToStore + ); + + const hasMore = !!cursorToStore; + const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); + + const outputData: IssueAlertListResult = { + rulesWithTargets: rulesWithTargets.slice(0, flags.limit), + isMultiProject: targets.length > 1, + hasMore, + hasPrev: hasPrev || undefined, + nextCursor: cursorToStore, + singleTarget, + footer, + }; + yield new CommandOutput(outputData); + + return { hint: buildHint(outputData) }; + }, +}); diff --git a/src/commands/alert/metrics/index.ts b/src/commands/alert/metrics/index.ts new file mode 100644 index 000000000..b28f33f42 --- /dev/null +++ b/src/commands/alert/metrics/index.ts @@ -0,0 +1,16 @@ +import { buildRouteMap } from "@stricli/core"; +import { listCommand } from "./list.js"; + +export const metricsRoute = buildRouteMap({ + routes: { + list: listCommand, + }, + 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", + hideRoute: {}, + }, +}); diff --git a/src/commands/alert/metrics/list.ts b/src/commands/alert/metrics/list.ts new file mode 100644 index 000000000..5e419680d --- /dev/null +++ b/src/commands/alert/metrics/list.ts @@ -0,0 +1,318 @@ +/** + * sentry alert metrics list + * + * List metric alert rules for a Sentry organization with cursor-based pagination. + */ + +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, + buildPaginationContextKey, + hasPreviousPage, + resolveCursor, +} from "../../../lib/db/pagination.js"; +import { ContextError } from "../../../lib/errors.js"; +import { filterFields } from "../../../lib/formatters/json.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, + paginationHint, +} from "../../../lib/list-command.js"; +import { withProgress } from "../../../lib/polling.js"; +import { resolveOrg } from "../../../lib/resolve-target.js"; +import { buildMetricAlertsUrl } from "../../../lib/sentry-urls.js"; +import { setOrgProjectContext } from "../../../lib/telemetry.js"; +import type { Writer } from "../../../types/index.js"; + +/** Command key for pagination cursor storage */ +export const PAGINATION_KEY = "alert-metrics-list"; + +type ListFlags = { + readonly web: boolean; + readonly fresh: boolean; + readonly limit: number; + readonly cursor?: string; + readonly json: boolean; + readonly fields?: string[]; +}; + +type MetricAlertListResult = { + rules: MetricAlertRule[]; + orgSlug: string; + hasMore: boolean; + hasPrev?: boolean; + nextCursor?: string; +}; + +// 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.rules.length === 0) { + return "No metric alert rules found."; + } + + type Row = { + id: string; + name: string; + aggregate: string; + dataset: string; + timeWindow: string; + environment: string; + status: string; + }; + + const url = buildMetricAlertsUrl(result.orgSlug); + const rows: Row[] = result.rules.map((r) => ({ + id: r.id, + name: `${escapeMarkdownCell(r.name)}\n${colorTag("muted", url)}`, + 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 }, + { 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) }; + writeTable(buffer, rows, columns); + + return parts.join("").trimEnd(); +} + +// JSON transform + +function jsonTransformMetricAlertList( + result: MetricAlertListResult, + fields?: string[] +): unknown { + const items = + fields && fields.length > 0 + ? result.rules.map((r) => filterFields(r, fields)) + : result.rules; + + const envelope: Record = { + data: items, + hasMore: result.hasMore, + hasPrev: !!result.hasPrev, + }; + if (result.nextCursor) { + envelope.nextCursor = result.nextCursor; + } + return envelope; +} + +// Fetch + +async function fetchMetricAlerts( + orgSlug: string, + opts: { + limit: number; + perPage: number; + cursor: string | undefined; + } +): Promise<{ rules: MetricAlertRule[]; cursorToStore: string | undefined }> { + let serverCursor = opts.cursor; + const results: MetricAlertRule[] = []; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await listMetricAlertsPaginated(orgSlug, { + perPage: opts.perPage, + cursor: serverCursor, + }); + + for (const rule of data) { + results.push(rule); + if (results.length >= opts.limit) { + return { + rules: results, + cursorToStore: nextCursor ?? undefined, + }; + } + } + + if (!nextCursor) { + return { rules: results, cursorToStore: undefined }; + } + serverCursor = nextCursor; + } + + return { rules: results, cursorToStore: undefined }; +} + +// Hint + +function buildHint(result: MetricAlertListResult): string | undefined { + const navRaw = paginationHint({ + hasPrev: !!result.hasPrev, + hasMore: result.hasMore, + prevHint: `sentry alert metrics list ${result.orgSlug}/ -c prev`, + nextHint: `sentry alert metrics list ${result.orgSlug}/ -c next`, + }); + const nav = navRaw ? ` ${navRaw}` : ""; + const url = buildMetricAlertsUrl(result.orgSlug); + + if (result.rules.length === 0) { + return nav ? `No metric alert rules found.${nav}` : undefined; + } + + return `Showing ${result.rules.length} rule(s).${nav}\nMetric alerts: ${url}`; +} + +// Org resolution (metric alerts are org-scoped) + +async function resolveOrgFromTarget( + target: string | undefined, + cwd: string +): Promise { + const parsed = parseOrgProjectArg(target); + switch (parsed.type) { + case "explicit": + case "org-all": + setOrgProjectContext([parsed.org], []); + return parsed.org; + case "project-search": + case "auto-detect": { + const resolved = await resolveOrg({ cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry alert metrics list /" + ); + } + return resolved.org; + } + default: { + const _exhaustive: never = parsed; + throw new Error( + `Unexpected parsed type: ${(_exhaustive as { type: string }).type}` + ); + } + } +} + +// Command + +export const listCommand = buildListCommand("alert", { + docs: { + brief: "List metric alert rules", + fullDescription: + "List metric alert rules for a Sentry organization.\n\n" + + "Metric alerts trigger notifications when a metric query crosses a threshold.\n\n" + + "Examples:\n" + + " sentry alert metrics list my-org/ # explicit org\n" + + " sentry alert metrics list # auto-detect\n" + + " sentry alert metrics list -c next # next page\n" + + " sentry alert metrics list --json # JSON output\n" + + " sentry alert metrics list --web # open in browser", + }, + output: { + human: formatMetricAlertListHuman, + jsonTransform: jsonTransformMetricAlertList, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/", + brief: "/ or omit to auto-detect", + parse: String, + optional: true, + }, + ], + }, + flags: { + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + limit: buildListLimitFlag("metric alert rules"), + }, + aliases: { w: "web", n: "limit" }, + }, + async *func(this: SentryContext, flags: ListFlags, target?: string) { + const { cwd } = this; + + const orgSlug = await resolveOrgFromTarget(target, cwd); + + if (flags.web) { + await openInBrowser(buildMetricAlertsUrl(orgSlug), "metric alert rules"); + return; + } + + const contextKey = buildPaginationContextKey("alert-metrics", orgSlug, {}); + const { cursor: rawCursor, direction } = resolveCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); + + const perPage = Math.min(flags.limit, API_MAX_PER_PAGE); + + const { rules, cursorToStore } = await withProgress( + { + message: `Fetching metric alert rules for ${orgSlug}...`, + json: flags.json, + }, + () => + fetchMetricAlerts(orgSlug, { + limit: flags.limit, + perPage, + cursor: rawCursor ?? undefined, + }) + ); + + advancePaginationState( + PAGINATION_KEY, + contextKey, + direction, + cursorToStore + ); + + const hasMore = !!cursorToStore; + const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); + + const outputData: MetricAlertListResult = { + rules, + orgSlug, + hasMore, + hasPrev: hasPrev || undefined, + nextCursor: cursorToStore, + }; + yield new CommandOutput(outputData); + + return { hint: buildHint(outputData) }; + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 9835819df..fb88692f4 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -19,6 +19,12 @@ * - users: current user info */ +export { + type IssueAlertRule, + listIssueAlertsPaginated, + type MetricAlertRule, + listMetricAlertsPaginated, +} from "./api/alerts.js"; export { createDashboard, getDashboard, diff --git a/src/lib/api/alerts.ts b/src/lib/api/alerts.ts new file mode 100644 index 000000000..3c2406bbc --- /dev/null +++ b/src/lib/api/alerts.ts @@ -0,0 +1,102 @@ +/** + * 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, + 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 }; +} + +// --------------------------------------------------------------------------- +// 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 }; +} diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index 24e850869..0d71a996e 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -221,3 +221,35 @@ 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/`; +} From 5401713953068b35c40c7e4d0262bbfdc89da4c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Thu, 26 Mar 2026 20:51:36 +0100 Subject: [PATCH 02/21] refactor(list): extract compound cursor and target resolution to shared lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves duplicated code from issue/list.ts and alert/issues/list.ts into shared modules so both commands use the same implementation: - lib/db/pagination.ts: exports CURSOR_SEP, encodeCompoundCursor, decodeCompoundCursor, buildMultiTargetContextKey - lib/resolve-target.ts: exports resolveTargetsFromParsedArg — handles all four target modes (auto-detect, explicit, org-all, project-search) with options for enrichProjectIds and checkIssueShortId (issue-list-specific) issue/list.ts drops ~260 lines of local duplicates; alert/issues/list.ts never needs to define them in the first place. --- src/commands/issue/list.ts | 278 +++---------------------------------- src/lib/db/pagination.ts | 75 ++++++++++ src/lib/resolve-target.ts | 205 ++++++++++++++++++++++++++- 3 files changed, 296 insertions(+), 262 deletions(-) diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 340aed9b8..08f4be086 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -10,24 +10,20 @@ import { buildOrgAwareAliases } 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 { - looksLikeIssueShortId, - parseOrgProjectArg, -} from "../../lib/arg-parsing.js"; +import { 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"; @@ -39,7 +35,6 @@ import { createDsnFingerprint } from "../../lib/dsn/index.js"; import { ApiError, ContextError, - ResolutionError, ValidationError, withAuthGuard, } from "../../lib/errors.js"; @@ -72,13 +67,9 @@ import { } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { - fetchProjectId, type ResolvedTarget, - resolveAllTargets, - toNumericId, + resolveTargetsFromParsedArg, } from "../../lib/resolve-target.js"; -import { getApiBaseUrl } from "../../lib/sentry-client.js"; -import { setOrgProjectContext } from "../../lib/telemetry.js"; import type { ProjectAliasEntry, SentryIssue, @@ -346,191 +337,6 @@ 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 - */ -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inherent multi-mode target resolution with per-mode error handling -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": { - // Detect when user passes an issue short ID instead of a project slug. - // Short IDs like "CONVERSATION-SVC-F" or "CLI-BM" are all-uppercase - // with a dash-separated suffix — a pattern that never occurs in project - // slugs (which are always lowercase). - if (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 /"] - ); - } - - // 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}`); - } - } -} - /** * Fetch issues for a single target project. * @@ -768,65 +574,6 @@ function trimWithProjectGuarantee( 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 -): 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(flags.period ?? "90d"); - const escapedSort = escapeContextKeyValue(flags.sort); - return ( - `host:${host}|type:multi:${targetFingerprint}` + - `|sort:${escapedSort}|period:${escapedPeriod}` + - (escapedQuery ? `|q:${escapedQuery}` : "") - ); -} - /** Build the CLI hint for fetching the next page, preserving active flags. */ /** Append active non-default issue list flags to a base command string. */ function appendIssueFlags(base: string, flags: ListFlags): string { @@ -1140,7 +887,12 @@ async function handleResolvedTargets( const { parsed, flags, cwd } = 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) { @@ -1154,7 +906,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); + const contextKey = buildMultiTargetContextKey(targets, { + sort: flags.sort, + query: flags.query, + period: flags.period, + }); // 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/db/pagination.ts b/src/lib/db/pagination.ts index c702918d9..3db955f80 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,77 @@ 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}` : "") + ); +} diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index c011a216e..c8fee2f07 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -23,7 +23,11 @@ import { getProject, listProjects, } 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 { @@ -1293,3 +1297,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}` + ); + } + } +} From 9821b07c41f103b7d6471429f023b9e34eca6e07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Mar 2026 19:52:19 +0000 Subject: [PATCH 03/21] chore: regenerate skill files --- docs/public/.well-known/skills/index.json | 1 + plugins/sentry-cli/skills/sentry-cli/SKILL.md | 9 +++++ .../skills/sentry-cli/references/alert.md | 34 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/alert.md diff --git a/docs/public/.well-known/skills/index.json b/docs/public/.well-known/skills/index.json index d7e648a67..1dbcdb94a 100644 --- a/docs/public/.well-known/skills/index.json +++ b/docs/public/.well-known/skills/index.json @@ -5,6 +5,7 @@ "description": "Guide for using the Sentry CLI to interact with Sentry from the command line. Use when the user asks about viewing issues, events, projects, organizations, making API calls, or authenticating with Sentry via CLI.", "files": [ "SKILL.md", + "references/alert.md", "references/api.md", "references/auth.md", "references/dashboards.md", diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index ab07e7ecb..bcd083a8f 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -254,6 +254,15 @@ 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 metrics list ` — List metric alert rules + +→ 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..7f5a080af --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -0,0 +1,34 @@ +--- +name: sentry-cli-alert +version: 0.21.0-dev.0 +description: Sentry CLI alert commands +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: "30")` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` + +### `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: "30")` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. From ed1f4844dfbddcc5edcfc8b42725190545f853b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Thu, 26 Mar 2026 21:30:49 +0100 Subject: [PATCH 04/21] refactor(alert): rewrite alert list commands to use dispatchOrgScopedList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both alert/issues/list and alert/metrics/list were implementing their own 4-mode target dispatch and compound cursor machinery from scratch, bypassing the shared dispatchOrgScopedList infrastructure every other list command uses. Replace with the standard pattern: - dispatchOrgScopedList with ListCommandMeta + 4 mode handler overrides - Simple parallel fetch-all for auto-detect/explicit/project-search modes (no compound cursor — alert rule lists are small datasets) - Single-cursor pagination for org-all mode (metrics: listMetricAlertsPaginated with cursor; issues: resolveTargetsFromParsedArg for project list + fetch all) Removes ~320 lines of custom dispatch and compound cursor logic. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/alert/issues/list.ts | 564 ++++++++++------------------- src/commands/alert/metrics/list.ts | 420 +++++++++++++-------- 2 files changed, 458 insertions(+), 526 deletions(-) diff --git a/src/commands/alert/issues/list.ts b/src/commands/alert/issues/list.ts index f85cd7b5a..5f87121d4 100644 --- a/src/commands/alert/issues/list.ts +++ b/src/commands/alert/issues/list.ts @@ -3,10 +3,10 @@ * * List issue alert rules for one or more Sentry projects. * - * Supports the same target resolution as `sentry issue list`: + * 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 (trailing slash required) + * - org-all → all projects in an org (all their alert rules combined) * - project-search → find project by slug across all orgs */ @@ -19,16 +19,7 @@ import { } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; -import { - advancePaginationState, - buildMultiTargetContextKey, - buildPaginationContextKey, - decodeCompoundCursor, - encodeCompoundCursor, - hasPreviousPage, - resolveCursor, -} from "../../../lib/db/pagination.js"; -import { ApiError, ContextError } from "../../../lib/errors.js"; +import { ContextError } from "../../../lib/errors.js"; import { filterFields } from "../../../lib/formatters/json.js"; import { colorTag, @@ -39,10 +30,13 @@ import { type Column, writeTable } from "../../../lib/formatters/table.js"; import { buildListCommand, buildListLimitFlag, - paginationHint, targetPatternExplanation, } from "../../../lib/list-command.js"; -import { logger } from "../../../lib/logger.js"; +import { + dispatchOrgScopedList, + type ListCommandMeta, + type ListResult, +} from "../../../lib/org-list.js"; import { withProgress } from "../../../lib/polling.js"; import { type ResolvedTarget, @@ -65,86 +59,192 @@ type ListFlags = { readonly fields?: string[]; }; -// --------------------------------------------------------------------------- -// Result type -// --------------------------------------------------------------------------- - type AlertRuleWithTarget = { rule: IssueAlertRule; target: ResolvedTarget; }; -type IssueAlertListResult = { - rulesWithTargets: AlertRuleWithTarget[]; - isMultiProject: boolean; - hasMore: boolean; - hasPrev?: boolean; - nextCursor?: string; - /** Used only in single-target mode for hint/URL */ - singleTarget?: ResolvedTarget; - footer?: string; +const issueAlertListMeta: ListCommandMeta = { + paginationKey: PAGINATION_KEY, + entityName: "issue alert rule", + entityPlural: "issue alert rules", + commandPrefix: "sentry alert issues list", }; // --------------------------------------------------------------------------- -// Fetch +// Fetch helpers // --------------------------------------------------------------------------- -type FetchForTargetResult = - | { success: true; rules: IssueAlertRule[]; nextCursor?: string } - | { success: false; error: Error }; - -async function fetchRulesForTarget( +/** + * Fetch all issue alert rules for one project (up to limit), across multiple pages. + */ +async function fetchIssueRulesForTarget( target: ResolvedTarget, - opts: { limit: number; cursor?: string } -): Promise { - try { - const results: IssueAlertRule[] = []; - let serverCursor = opts.cursor; - - for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { - const { data, nextCursor } = await listIssueAlertsPaginated( - target.org, - target.project, - { - perPage: Math.min(opts.limit - results.length, API_MAX_PER_PAGE), - cursor: serverCursor, - } - ); - - for (const rule of data) { - results.push(rule); - if (results.length >= opts.limit) { - return { success: true, rules: results, nextCursor }; - } + limit: number +): Promise { + const rules: IssueAlertRule[] = []; + let serverCursor: string | undefined; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await listIssueAlertsPaginated( + target.org, + target.project, + { + perPage: Math.min(limit - rules.length, API_MAX_PER_PAGE), + cursor: serverCursor, } + ); - if (!nextCursor) { - return { success: true, rules: results }; + for (const rule of data) { + rules.push(rule); + if (rules.length >= limit) { + return rules; } - serverCursor = nextCursor; } - return { success: true, rules: results }; - } catch (err) { - return { - success: false, - error: err instanceof Error ? err : new Error(String(err)), - }; + if (!nextCursor) { + break; + } + serverCursor = nextCursor; } + + return rules; +} + +/** + * Fetch issue alert rules from multiple targets in parallel and combine. + * Used by auto-detect, org-all, and project-search modes. + */ +async function fetchFromTargets( + targets: ResolvedTarget[], + limit: number, + json: boolean +): Promise { + const perTargetLimit = Math.max(limit, Math.ceil(limit / targets.length) * 2); + const results = await withProgress( + { + message: + targets.length > 1 + ? `Fetching issue alert rules from ${targets.length} projects...` + : `Fetching issue alert rules for ${targets[0]?.org}/${targets[0]?.project}...`, + json, + }, + () => + Promise.all( + targets.map(async (target) => { + const rules = await fetchIssueRulesForTarget(target, perTargetLimit); + return rules.map((rule) => ({ rule, target })); + }) + ) + ); + return results.flat().slice(0, limit); +} + +// --------------------------------------------------------------------------- +// Mode handlers +// --------------------------------------------------------------------------- + +async function handleAutoDetectIssueAlerts( + cwd: string, + flags: ListFlags +): Promise> { + const { targets, footer } = await withProgress( + { message: "Resolving targets...", json: flags.json }, + () => + resolveTargetsFromParsedArg( + { type: "auto-detect" }, + { cwd, usageHint: USAGE_HINT } + ) + ); + if (targets.length === 0) { + throw new ContextError("Organization and project", USAGE_HINT); + } + const items = await fetchFromTargets(targets, flags.limit, flags.json); + return { items, hasMore: false, hint: footer }; +} + +async function handleExplicitIssueAlerts( + org: string, + project: string, + flags: ListFlags +): Promise> { + const target: ResolvedTarget = { + org, + project, + orgDisplay: org, + projectDisplay: project, + }; + const rules = await withProgress( + { + message: `Fetching issue alert rules for ${org}/${project}...`, + json: flags.json, + }, + () => fetchIssueRulesForTarget(target, flags.limit) + ); + return { + items: rules.map((rule) => ({ rule, target })), + hasMore: false, + hint: `Alert rules: ${buildIssueAlertsUrl(org, project)}`, + }; +} + +async function handleOrgAllIssueAlerts( + org: string, + flags: ListFlags +): Promise> { + // org-all: list all projects in the org, then fetch alerts for each + const { targets } = await withProgress( + { message: `Listing projects in ${org}...`, json: flags.json }, + () => + resolveTargetsFromParsedArg( + { type: "org-all", org }, + { cwd: "", usageHint: USAGE_HINT } + ) + ); + const items = await fetchFromTargets(targets, flags.limit, flags.json); + return { + items, + hasMore: false, + hint: + targets.length > 1 + ? `Showing alert rules from ${targets.length} projects in ${org}.` + : undefined, + }; +} + +async function handleProjectSearchIssueAlerts( + projectSlug: string, + cwd: string, + flags: ListFlags +): Promise> { + const { targets } = await withProgress( + { message: `Searching for project '${projectSlug}'...`, json: flags.json }, + () => + resolveTargetsFromParsedArg( + { type: "project-search", projectSlug }, + { cwd, usageHint: USAGE_HINT } + ) + ); + const items = await fetchFromTargets(targets, flags.limit, flags.json); + return { items, hasMore: false }; } // --------------------------------------------------------------------------- // Human output // --------------------------------------------------------------------------- -function formatIssueAlertListHuman(result: IssueAlertListResult): string { - if (result.rulesWithTargets.length === 0) { - const base = result.footer - ? `No issue alert rules found.\n\n${result.footer}` - : "No issue alert rules found."; - return base; +function formatIssueAlertListHuman( + result: ListResult +): string { + if (result.items.length === 0) { + return result.hint ?? "No issue alert rules found."; } + const uniqueProjects = new Set( + result.items.map((r) => `${r.target.org}/${r.target.project}`) + ); + const isMultiProject = uniqueProjects.size > 1; + type Row = { id: string; name: string; @@ -155,10 +255,10 @@ function formatIssueAlertListHuman(result: IssueAlertListResult): string { status: string; }; - const rows: Row[] = result.rulesWithTargets.map(({ rule: r, target }) => ({ + const rows: Row[] = result.items.map(({ rule: r, target }) => ({ id: r.id, name: escapeMarkdownCell(r.name), - ...(result.isMultiProject && { + ...(isMultiProject && { project: `${target.org}/${target.project}`, }), conditions: String(r.conditions.length), @@ -173,7 +273,7 @@ function formatIssueAlertListHuman(result: IssueAlertListResult): string { const columns: Column[] = [ { header: "ID", value: (r) => r.id }, { header: "NAME", value: (r) => r.name }, - ...(result.isMultiProject + ...(isMultiProject ? [{ header: "PROJECT", value: (r: Row) => r.project ?? "" }] : []), { header: "CONDITIONS", value: (r) => r.conditions }, @@ -194,10 +294,10 @@ function formatIssueAlertListHuman(result: IssueAlertListResult): string { // --------------------------------------------------------------------------- function jsonTransformIssueAlertList( - result: IssueAlertListResult, + result: ListResult, fields?: string[] ): unknown { - const rules = result.rulesWithTargets.map(({ rule }) => rule); + const rules = result.items.map(({ rule }) => rule); const items = fields && fields.length > 0 ? rules.map((r) => filterFields(r, fields)) @@ -205,7 +305,7 @@ function jsonTransformIssueAlertList( const envelope: Record = { data: items, - hasMore: result.hasMore, + hasMore: !!result.hasMore, hasPrev: !!result.hasPrev, }; if (result.nextCursor) { @@ -214,258 +314,6 @@ function jsonTransformIssueAlertList( return envelope; } -// --------------------------------------------------------------------------- -// Hint -// --------------------------------------------------------------------------- - -function buildHint(result: IssueAlertListResult): string | undefined { - const { singleTarget } = result; - - const navRaw = - singleTarget && - paginationHint({ - hasPrev: !!result.hasPrev, - hasMore: result.hasMore, - prevHint: `sentry alert issues list ${singleTarget.org}/${singleTarget.project} -c prev`, - nextHint: `sentry alert issues list ${singleTarget.org}/${singleTarget.project} -c next`, - }); - const nav = navRaw ? ` ${navRaw}` : ""; - - const count = result.rulesWithTargets.length; - if (count === 0) { - return nav ? `No issue alert rules found.${nav}` : undefined; - } - - const parts: string[] = [`Showing ${count} rule(s).${nav}`]; - if (result.footer) { - parts.push(result.footer); - } - if (singleTarget) { - parts.push( - `Alert rules: ${buildIssueAlertsUrl(singleTarget.org, singleTarget.project)}` - ); - } - return parts.join("\n"); -} - -// --------------------------------------------------------------------------- -// Multi-target fetch -// --------------------------------------------------------------------------- - -/** - * Fetch alert rules from all targets in parallel with compound cursor support. - * - * Uses a two-phase strategy: - * 1. Phase 1: distribute `ceil(limit / activeTargets)` quota per target in parallel. - * 2. Phase 2: if total fetched < limit and some targets have more, redistribute - * the surplus among expandable targets and fetch one more page each. - * - * For multi-target mode, cursors are stored as a pipe-separated compound cursor - * so `-c next` / `-c prev` advances each project independently. - */ -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inherent multi-target fetch with compound cursor, Phase 2, and failure handling -async function fetchAllTargets( - targets: ResolvedTarget[], - flags: ListFlags -): Promise<{ - rulesWithTargets: AlertRuleWithTarget[]; - cursorToStore: string | undefined; - contextKey: string; - direction: "next" | "prev" | "first"; -}> { - const isSingleTarget = targets.length === 1; - // biome-ignore lint/style/noNonNullAssertion: guarded by isSingleTarget - const singleTarget = isSingleTarget ? targets[0]! : undefined; - - // Build the context key — single uses the project-scoped key, multi uses a - // fingerprint of all target org/project pairs so cursors are never crossed. - const contextKey = - isSingleTarget && singleTarget - ? buildPaginationContextKey("alert-issues", singleTarget.org, { - project: singleTarget.project, - }) - : buildMultiTargetContextKey(targets); - - // Sorted target keys must match the order used in buildMultiTargetContextKey. - const sortedTargetKeys = targets.map((t) => `${t.org}/${t.project}`).sort(); - - // Resolve stored cursor (handles --cursor flag and DB lookup). - const { cursor: rawCursor, direction } = resolveCursor( - flags.cursor, - PAGINATION_KEY, - contextKey - ); - - // Decode per-target start cursors from the compound cursor. - const startCursors = new Map(); - const exhaustedTargets = new Set(); - if (rawCursor) { - if (isSingleTarget && singleTarget) { - startCursors.set( - `${singleTarget.org}/${singleTarget.project}`, - rawCursor - ); - } else { - 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 { - // null = project was exhausted on the previous page — skip entirely - exhaustedTargets.add(key); - } - } - } - } - - // Skip targets that were exhausted on the previous page. - const activeTargets = - exhaustedTargets.size > 0 - ? targets.filter((t) => !exhaustedTargets.has(`${t.org}/${t.project}`)) - : targets; - - const quota = Math.max(1, Math.ceil(flags.limit / activeTargets.length)); - const message = - activeTargets.length > 1 - ? `Fetching issue alert rules from ${activeTargets.length} projects...` - : `Fetching issue alert rules for ${singleTarget?.org}/${singleTarget?.project}...`; - - // Phase 1: fetch quota from each active target in parallel. - const phase1 = await withProgress({ message, json: flags.json }, () => - Promise.all( - activeTargets.map((t) => - fetchRulesForTarget(t, { - limit: quota, - cursor: startCursors.get(`${t.org}/${t.project}`), - }) - ) - ) - ); - - let totalFetched = phase1.reduce( - (sum, r) => sum + (r.success ? r.rules.length : 0), - 0 - ); - - // Phase 2: redistribute surplus among targets that still have more pages. - const surplus = flags.limit - totalFetched; - if (surplus > 0) { - const expandableIndices: number[] = []; - for (let i = 0; i < phase1.length; i++) { - const r = phase1[i]; - if (r?.success && r.rules.length >= quota && r.nextCursor) { - expandableIndices.push(i); - } - } - if (expandableIndices.length > 0) { - 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 t = activeTargets[i]!; - const r = phase1[i] as { - success: true; - rules: IssueAlertRule[]; - nextCursor?: string; - }; - // biome-ignore lint/style/noNonNullAssertion: expandableIndices only contains indices with a nextCursor - const cursor = r.nextCursor!; - return fetchRulesForTarget(t, { limit: extraQuota, 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.rules.push(...p2.rules); - p1.nextCursor = p2.nextCursor; - totalFetched += p2.rules.length; - } - } - } - } - - // Build the cursor to store for `-c next`. - // Index into sortedTargetKeys to keep compound cursor aligned. - const phase1ByKey = new Map(); - for (let i = 0; i < activeTargets.length; i++) { - const t = activeTargets[i]; - const r = phase1[i]; - if (t && r) { - phase1ByKey.set(`${t.org}/${t.project}`, r); - } - } - - let cursorToStore: string | undefined; - if (isSingleTarget) { - const r = phase1[0]; - cursorToStore = r?.success ? (r.nextCursor ?? undefined) : undefined; - } else { - const cursorValues: (string | null)[] = sortedTargetKeys.map((key) => { - if (exhaustedTargets.has(key)) { - return null; - } - const result = phase1ByKey.get(key); - if (result?.success) { - return result.nextCursor ?? null; - } - // Failed fetch: preserve the start cursor so the next `-c next` retries - // from the same position rather than restarting from scratch. - return startCursors.get(key) ?? null; - }); - const hasAnyCursor = cursorValues.some((c) => c !== null); - cursorToStore = hasAnyCursor - ? encodeCompoundCursor(cursorValues) - : undefined; - } - - // Surface total failure; log partial failures. - const failureIndices = phase1 - .map((r, i) => (r.success ? -1 : i)) - .filter((i) => i !== -1); - - if (failureIndices.length > 0) { - if (failureIndices.length === phase1.length) { - const first = phase1[0]; - const err = - first && !first.success ? first.error : new Error("All fetches failed"); - throw err instanceof ApiError ? err : new Error(err.message); - } - const names = failureIndices - // biome-ignore lint/style/noNonNullAssertion: index within bounds - .map((i) => `${activeTargets[i]!.org}/${activeTargets[i]!.project}`) - .join(", "); - logger.warn( - `Failed to fetch alert rules from ${names}. Showing results from remaining projects.` - ); - } - - // Combine valid results, sorted by name. - const rulesWithTargets: AlertRuleWithTarget[] = []; - for (let i = 0; i < phase1.length; i++) { - // biome-ignore lint/style/noNonNullAssertion: i is within bounds - const r = phase1[i]!; - // biome-ignore lint/style/noNonNullAssertion: i is within bounds - const t = activeTargets[i]!; - if (r.success) { - for (const rule of r.rules) { - rulesWithTargets.push({ rule, target: t }); - } - } - } - rulesWithTargets.sort((a, b) => a.rule.name.localeCompare(b.rule.name)); - - return { rulesWithTargets, cursorToStore, contextKey, direction }; -} - // --------------------------------------------------------------------------- // Command // --------------------------------------------------------------------------- @@ -513,52 +361,38 @@ export const listCommand = buildListCommand("alert", { }, async *func(this: SentryContext, flags: ListFlags, target?: string) { const { cwd } = this; - const parsed = parseOrgProjectArg(target); - const { targets, footer } = await withProgress( - { message: "Resolving targets...", json: flags.json }, - () => resolveTargetsFromParsedArg(parsed, { cwd, usageHint: USAGE_HINT }) - ); - - if (targets.length === 0) { - throw new ContextError("Organization and project", USAGE_HINT); - } - - const singleTarget = targets.length === 1 ? targets[0] : undefined; - - if (flags.web && singleTarget) { + // --web: open browser when org and project are known from the target arg + if (flags.web && parsed.type === "explicit") { await openInBrowser( - buildIssueAlertsUrl(singleTarget.org, singleTarget.project), + buildIssueAlertsUrl(parsed.org, parsed.project), "issue alert rules" ); return; } - const { rulesWithTargets, cursorToStore, contextKey, direction } = - await fetchAllTargets(targets, flags); - - advancePaginationState( - PAGINATION_KEY, - contextKey, - direction, - cursorToStore - ); + const result = (await dispatchOrgScopedList({ + config: issueAlertListMeta, + cwd, + flags, + parsed, + orgSlugMatchBehavior: "redirect", + overrides: { + "auto-detect": (ctx) => handleAutoDetectIssueAlerts(ctx.cwd, flags), + explicit: (ctx) => + handleExplicitIssueAlerts(ctx.parsed.org, ctx.parsed.project, flags), + "org-all": (ctx) => handleOrgAllIssueAlerts(ctx.parsed.org, flags), + "project-search": (ctx) => + handleProjectSearchIssueAlerts( + ctx.parsed.projectSlug, + ctx.cwd, + flags + ), + }, + })) as ListResult; - const hasMore = !!cursorToStore; - const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); - - const outputData: IssueAlertListResult = { - rulesWithTargets: rulesWithTargets.slice(0, flags.limit), - isMultiProject: targets.length > 1, - hasMore, - hasPrev: hasPrev || undefined, - nextCursor: cursorToStore, - singleTarget, - footer, - }; - yield new CommandOutput(outputData); - - return { hint: buildHint(outputData) }; + yield new CommandOutput(result); + return { hint: result.hint }; }, }); diff --git a/src/commands/alert/metrics/list.ts b/src/commands/alert/metrics/list.ts index 5e419680d..92ad7bccb 100644 --- a/src/commands/alert/metrics/list.ts +++ b/src/commands/alert/metrics/list.ts @@ -1,7 +1,13 @@ /** * sentry alert metrics list * - * List metric alert rules for a Sentry organization with cursor-based pagination. + * 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"; @@ -15,7 +21,7 @@ import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { advancePaginationState, - buildPaginationContextKey, + buildOrgContextKey, hasPreviousPage, resolveCursor, } from "../../../lib/db/pagination.js"; @@ -31,16 +37,23 @@ import { buildListCommand, buildListLimitFlag, paginationHint, + targetPatternExplanation, } from "../../../lib/list-command.js"; +import { + dispatchOrgScopedList, + type ListCommandMeta, + type ListResult, +} from "../../../lib/org-list.js"; import { withProgress } from "../../../lib/polling.js"; -import { resolveOrg } from "../../../lib/resolve-target.js"; +import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; import { buildMetricAlertsUrl } from "../../../lib/sentry-urls.js"; -import { setOrgProjectContext } from "../../../lib/telemetry.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 /"; + type ListFlags = { readonly web: boolean; readonly fresh: boolean; @@ -50,15 +63,194 @@ type ListFlags = { readonly fields?: string[]; }; -type MetricAlertListResult = { - rules: MetricAlertRule[]; +type MetricAlertWithOrg = { + rule: MetricAlertRule; orgSlug: string; - hasMore: boolean; - hasPrev?: boolean; - nextCursor?: 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 starting from an optional cursor, + * fetching multiple API pages until `limit` is reached or no more pages exist. + * + * Returns the rules collected and a cursor pointing to the next page (if any). + */ +async function fetchMetricRulesPage( + orgSlug: string, + opts: { limit: number; cursor?: string } +): Promise<{ rules: MetricAlertRule[]; nextCursor?: string }> { + const rules: MetricAlertRule[] = []; + let serverCursor = opts.cursor; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await listMetricAlertsPaginated(orgSlug, { + perPage: Math.min(opts.limit - rules.length, API_MAX_PER_PAGE), + cursor: serverCursor, + }); + + for (const rule of data) { + rules.push(rule); + if (rules.length >= opts.limit) { + return { rules, nextCursor: nextCursor ?? undefined }; + } + } + + if (!nextCursor) { + return { rules }; + } + serverCursor = nextCursor; + } + + return { rules }; +} + +// --------------------------------------------------------------------------- +// Mode handlers +// --------------------------------------------------------------------------- + +/** + * Fetch from multiple orgs in parallel and combine results (no cursor). + * Used by auto-detect and project-search modes. + */ +async function fetchFromOrgs( + orgs: string[], + limit: number, + json: boolean +): Promise { + const results = await withProgress( + { + message: + orgs.length > 1 + ? `Fetching metric alert rules from ${orgs.length} organizations...` + : `Fetching metric alert rules for ${orgs[0]}...`, + json, + }, + () => + Promise.all( + orgs.map(async (org) => { + const { rules } = await fetchMetricRulesPage(org, { limit }); + return rules.map((rule) => ({ rule, orgSlug: org })); + }) + ) + ); + return results.flat().slice(0, limit); +} + +async function handleAutoDetectMetrics( + cwd: string, + flags: ListFlags +): Promise> { + const { targets, footer } = await withProgress( + { message: "Resolving targets...", json: flags.json }, + () => + resolveTargetsFromParsedArg( + { type: "auto-detect" }, + { cwd, usageHint: USAGE_HINT } + ) + ); + if (targets.length === 0) { + throw new ContextError("Organization", USAGE_HINT); + } + const uniqueOrgs = [...new Set(targets.map((t) => t.org))]; + const items = await fetchFromOrgs(uniqueOrgs, flags.limit, flags.json); + return { items, hasMore: false, hint: footer }; +} + +async function handleExplicitMetrics( + org: string, + flags: ListFlags +): Promise> { + const { rules } = await withProgress( + { message: `Fetching metric alert rules for ${org}...`, json: flags.json }, + () => fetchMetricRulesPage(org, { limit: flags.limit }) + ); + return { + items: rules.map((rule) => ({ rule, orgSlug: org })), + hasMore: false, + hint: `Metric alerts: ${buildMetricAlertsUrl(org)}`, + }; +} + +async function handleOrgAllMetrics( + org: string, + flags: ListFlags +): Promise> { + const contextKey = buildOrgContextKey(org); + const { cursor: startCursor, direction } = resolveCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); + + const { rules, nextCursor } = await withProgress( + { message: `Fetching metric alert rules for ${org}...`, json: flags.json }, + () => fetchMetricRulesPage(org, { limit: flags.limit, cursor: startCursor }) + ); + + advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); + const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); + + const items = rules.map((rule) => ({ rule, orgSlug: org })); + const nav = paginationHint({ + hasPrev, + hasMore: !!nextCursor, + prevHint: `sentry alert metrics list ${org}/ -c prev`, + nextHint: `sentry alert metrics list ${org}/ -c next`, + }); + + const hintParts: string[] = []; + if (items.length === 0) { + hintParts.push(`No metric alert rules found in '${org}'.`); + } else { + hintParts.push( + `Showing ${items.length} rule(s)${nextCursor ? " (more available)" : ""}.` + ); + hintParts.push(`Metric alerts: ${buildMetricAlertsUrl(org)}`); + } + if (nav) { + hintParts.push(nav); + } + + return { + items, + hasMore: !!nextCursor, + hasPrev, + nextCursor, + hint: hintParts.join("\n"), + }; +} + +async function handleProjectSearchMetrics( + projectSlug: string, + cwd: string, + flags: ListFlags +): Promise> { + const { targets } = await withProgress( + { message: `Searching for project '${projectSlug}'...`, json: flags.json }, + () => + resolveTargetsFromParsedArg( + { type: "project-search", projectSlug }, + { cwd, usageHint: USAGE_HINT } + ) + ); + const uniqueOrgs = [...new Set(targets.map((t) => t.org))]; + const items = await fetchFromOrgs(uniqueOrgs, flags.limit, flags.json); + return { items, hasMore: false }; +} + +// --------------------------------------------------------------------------- // Human output +// --------------------------------------------------------------------------- /** Format metric alert status: 0 = active, 1 = disabled */ function formatMetricStatus(status: number): string { @@ -67,14 +259,20 @@ function formatMetricStatus(status: number): string { : colorTag("muted", "disabled"); } -function formatMetricAlertListHuman(result: MetricAlertListResult): string { - if (result.rules.length === 0) { - return "No metric alert rules found."; +function formatMetricAlertListHuman( + result: ListResult +): string { + if (result.items.length === 0) { + return result.hint ?? "No metric alert rules found."; } + const uniqueOrgs = new Set(result.items.map((r) => r.orgSlug)); + const isMultiOrg = uniqueOrgs.size > 1; + type Row = { id: string; name: string; + org?: string; aggregate: string; dataset: string; timeWindow: string; @@ -82,10 +280,10 @@ function formatMetricAlertListHuman(result: MetricAlertListResult): string { status: string; }; - const url = buildMetricAlertsUrl(result.orgSlug); - const rows: Row[] = result.rules.map((r) => ({ + const rows: Row[] = result.items.map(({ rule: r, orgSlug }) => ({ id: r.id, - name: `${escapeMarkdownCell(r.name)}\n${colorTag("muted", url)}`, + name: escapeMarkdownCell(r.name), + ...(isMultiOrg && { org: orgSlug }), aggregate: r.aggregate, dataset: r.dataset, timeWindow: `${r.timeWindow}m`, @@ -96,6 +294,7 @@ function formatMetricAlertListHuman(result: MetricAlertListResult): string { 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 }, @@ -110,20 +309,23 @@ function formatMetricAlertListHuman(result: MetricAlertListResult): string { return parts.join("").trimEnd(); } +// --------------------------------------------------------------------------- // JSON transform +// --------------------------------------------------------------------------- function jsonTransformMetricAlertList( - result: MetricAlertListResult, + result: ListResult, fields?: string[] ): unknown { + const rules = result.items.map(({ rule }) => rule); const items = fields && fields.length > 0 - ? result.rules.map((r) => filterFields(r, fields)) - : result.rules; + ? rules.map((r) => filterFields(r, fields)) + : rules; const envelope: Record = { data: items, - hasMore: result.hasMore, + hasMore: !!result.hasMore, hasPrev: !!result.hasPrev, }; if (result.nextCursor) { @@ -132,109 +334,23 @@ function jsonTransformMetricAlertList( return envelope; } -// Fetch - -async function fetchMetricAlerts( - orgSlug: string, - opts: { - limit: number; - perPage: number; - cursor: string | undefined; - } -): Promise<{ rules: MetricAlertRule[]; cursorToStore: string | undefined }> { - let serverCursor = opts.cursor; - const results: MetricAlertRule[] = []; - - for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { - const { data, nextCursor } = await listMetricAlertsPaginated(orgSlug, { - perPage: opts.perPage, - cursor: serverCursor, - }); - - for (const rule of data) { - results.push(rule); - if (results.length >= opts.limit) { - return { - rules: results, - cursorToStore: nextCursor ?? undefined, - }; - } - } - - if (!nextCursor) { - return { rules: results, cursorToStore: undefined }; - } - serverCursor = nextCursor; - } - - return { rules: results, cursorToStore: undefined }; -} - -// Hint - -function buildHint(result: MetricAlertListResult): string | undefined { - const navRaw = paginationHint({ - hasPrev: !!result.hasPrev, - hasMore: result.hasMore, - prevHint: `sentry alert metrics list ${result.orgSlug}/ -c prev`, - nextHint: `sentry alert metrics list ${result.orgSlug}/ -c next`, - }); - const nav = navRaw ? ` ${navRaw}` : ""; - const url = buildMetricAlertsUrl(result.orgSlug); - - if (result.rules.length === 0) { - return nav ? `No metric alert rules found.${nav}` : undefined; - } - - return `Showing ${result.rules.length} rule(s).${nav}\nMetric alerts: ${url}`; -} - -// Org resolution (metric alerts are org-scoped) - -async function resolveOrgFromTarget( - target: string | undefined, - cwd: string -): Promise { - const parsed = parseOrgProjectArg(target); - switch (parsed.type) { - case "explicit": - case "org-all": - setOrgProjectContext([parsed.org], []); - return parsed.org; - case "project-search": - case "auto-detect": { - const resolved = await resolveOrg({ cwd }); - if (!resolved) { - throw new ContextError( - "Organization", - "sentry alert metrics list /" - ); - } - return resolved.org; - } - default: { - const _exhaustive: never = parsed; - throw new Error( - `Unexpected parsed type: ${(_exhaustive as { type: string }).type}` - ); - } - } -} - +// --------------------------------------------------------------------------- // Command +// --------------------------------------------------------------------------- export const listCommand = buildListCommand("alert", { docs: { brief: "List metric alert rules", fullDescription: - "List metric alert rules for a Sentry organization.\n\n" + + "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" + - "Examples:\n" + - " sentry alert metrics list my-org/ # explicit org\n" + - " sentry alert metrics list # auto-detect\n" + - " sentry alert metrics list -c next # next page\n" + - " sentry alert metrics list --json # JSON output\n" + - " sentry alert metrics list --web # open in browser", + "Target patterns:\n" + + " sentry alert metrics list # auto-detect from DSN or config\n" + + " sentry alert metrics list / # explicit org\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.", }, output: { human: formatMetricAlertListHuman, @@ -246,7 +362,8 @@ export const listCommand = buildListCommand("alert", { parameters: [ { placeholder: "org/", - brief: "/ or omit to auto-detect", + brief: + "/ (explicit), /, (search), or omit to auto-detect", parse: String, optional: true, }, @@ -264,55 +381,36 @@ export const listCommand = buildListCommand("alert", { }, async *func(this: SentryContext, flags: ListFlags, target?: string) { const { cwd } = this; - - const orgSlug = await resolveOrgFromTarget(target, cwd); - - if (flags.web) { - await openInBrowser(buildMetricAlertsUrl(orgSlug), "metric alert rules"); + 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; } - const contextKey = buildPaginationContextKey("alert-metrics", orgSlug, {}); - const { cursor: rawCursor, direction } = resolveCursor( - flags.cursor, - PAGINATION_KEY, - contextKey - ); - - const perPage = Math.min(flags.limit, API_MAX_PER_PAGE); - - const { rules, cursorToStore } = await withProgress( - { - message: `Fetching metric alert rules for ${orgSlug}...`, - json: flags.json, + const result = (await dispatchOrgScopedList({ + config: metricAlertListMeta, + cwd, + flags, + parsed, + orgSlugMatchBehavior: "redirect", + overrides: { + "auto-detect": (ctx) => handleAutoDetectMetrics(ctx.cwd, flags), + explicit: (ctx) => handleExplicitMetrics(ctx.parsed.org, flags), + "org-all": (ctx) => handleOrgAllMetrics(ctx.parsed.org, flags), + "project-search": (ctx) => + handleProjectSearchMetrics(ctx.parsed.projectSlug, ctx.cwd, flags), }, - () => - fetchMetricAlerts(orgSlug, { - limit: flags.limit, - perPage, - cursor: rawCursor ?? undefined, - }) - ); - - advancePaginationState( - PAGINATION_KEY, - contextKey, - direction, - cursorToStore - ); - - const hasMore = !!cursorToStore; - const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); - - const outputData: MetricAlertListResult = { - rules, - orgSlug, - hasMore, - hasPrev: hasPrev || undefined, - nextCursor: cursorToStore, - }; - yield new CommandOutput(outputData); + })) as ListResult; - return { hint: buildHint(outputData) }; + yield new CommandOutput(result); + return { hint: result.hint }; }, }); From e515403343eeb68f48ab7e288f5835a55f77e757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 27 Mar 2026 10:40:21 +0100 Subject: [PATCH 05/21] refactor(alert): align alert list commands with sentry issue list structure - Use jsonTransformListResult (shared) instead of custom JSON transforms - Separate raw rules into items (for JSON) and displayRows (for human output) - Add --query/-q flag for client-side name filtering on both alert commands - Restore api-client.ts alerts export to original shape Co-Authored-By: Claude Sonnet 4.6 --- src/commands/alert/issues/list.ts | 134 +++++++++++----------- src/commands/alert/metrics/list.ts | 173 ++++++++++++++++------------- src/lib/api-client.ts | 2 +- 3 files changed, 166 insertions(+), 143 deletions(-) diff --git a/src/commands/alert/issues/list.ts b/src/commands/alert/issues/list.ts index 5f87121d4..85b4eaa38 100644 --- a/src/commands/alert/issues/list.ts +++ b/src/commands/alert/issues/list.ts @@ -20,7 +20,6 @@ import { import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { ContextError } from "../../../lib/errors.js"; -import { filterFields } from "../../../lib/formatters/json.js"; import { colorTag, escapeMarkdownCell, @@ -30,10 +29,13 @@ import { type Column, writeTable } from "../../../lib/formatters/table.js"; import { buildListCommand, buildListLimitFlag, + LIST_BASE_ALIASES, + LIST_TARGET_POSITIONAL, targetPatternExplanation, } from "../../../lib/list-command.js"; import { dispatchOrgScopedList, + jsonTransformListResult, type ListCommandMeta, type ListResult, } from "../../../lib/org-list.js"; @@ -57,11 +59,18 @@ type ListFlags = { readonly cursor?: string; readonly json: boolean; readonly fields?: string[]; + readonly query?: string; }; -type AlertRuleWithTarget = { - rule: IssueAlertRule; - target: ResolvedTarget; +/** 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). Mirrors the IssueListResult pattern. + */ +type IssueAlertListResult = ListResult & { + displayRows?: AlertRuleRow[]; }; const issueAlertListMeta: ListCommandMeta = { @@ -119,7 +128,7 @@ async function fetchFromTargets( targets: ResolvedTarget[], limit: number, json: boolean -): Promise { +): Promise { const perTargetLimit = Math.max(limit, Math.ceil(limit / targets.length) * 2); const results = await withProgress( { @@ -140,6 +149,18 @@ async function fetchFromTargets( return results.flat().slice(0, limit); } +/** Client-side name filter applied after fetch (API has no query param). */ +function applyQueryFilter( + rows: AlertRuleRow[], + query: string | undefined +): AlertRuleRow[] { + if (!query) { + return rows; + } + const q = query.toLowerCase(); + return rows.filter((r) => r.rule.name.toLowerCase().includes(q)); +} + // --------------------------------------------------------------------------- // Mode handlers // --------------------------------------------------------------------------- @@ -147,7 +168,7 @@ async function fetchFromTargets( async function handleAutoDetectIssueAlerts( cwd: string, flags: ListFlags -): Promise> { +): Promise { const { targets, footer } = await withProgress( { message: "Resolving targets...", json: flags.json }, () => @@ -159,15 +180,23 @@ async function handleAutoDetectIssueAlerts( if (targets.length === 0) { throw new ContextError("Organization and project", USAGE_HINT); } - const items = await fetchFromTargets(targets, flags.limit, flags.json); - return { items, hasMore: false, hint: footer }; + const displayRows = applyQueryFilter( + await fetchFromTargets(targets, flags.limit, flags.json), + flags.query + ); + return { + items: displayRows.map((r) => r.rule), + displayRows, + hasMore: false, + hint: footer, + }; } async function handleExplicitIssueAlerts( org: string, project: string, flags: ListFlags -): Promise> { +): Promise { const target: ResolvedTarget = { org, project, @@ -181,8 +210,13 @@ async function handleExplicitIssueAlerts( }, () => fetchIssueRulesForTarget(target, flags.limit) ); + const displayRows = applyQueryFilter( + rules.map((rule) => ({ rule, target })), + flags.query + ); return { - items: rules.map((rule) => ({ rule, target })), + items: displayRows.map((r) => r.rule), + displayRows, hasMore: false, hint: `Alert rules: ${buildIssueAlertsUrl(org, project)}`, }; @@ -191,7 +225,7 @@ async function handleExplicitIssueAlerts( async function handleOrgAllIssueAlerts( org: string, flags: ListFlags -): Promise> { +): Promise { // org-all: list all projects in the org, then fetch alerts for each const { targets } = await withProgress( { message: `Listing projects in ${org}...`, json: flags.json }, @@ -201,9 +235,13 @@ async function handleOrgAllIssueAlerts( { cwd: "", usageHint: USAGE_HINT } ) ); - const items = await fetchFromTargets(targets, flags.limit, flags.json); + const displayRows = applyQueryFilter( + await fetchFromTargets(targets, flags.limit, flags.json), + flags.query + ); return { - items, + items: displayRows.map((r) => r.rule), + displayRows, hasMore: false, hint: targets.length > 1 @@ -216,7 +254,7 @@ async function handleProjectSearchIssueAlerts( projectSlug: string, cwd: string, flags: ListFlags -): Promise> { +): Promise { const { targets } = await withProgress( { message: `Searching for project '${projectSlug}'...`, json: flags.json }, () => @@ -225,23 +263,25 @@ async function handleProjectSearchIssueAlerts( { cwd, usageHint: USAGE_HINT } ) ); - const items = await fetchFromTargets(targets, flags.limit, flags.json); - return { items, hasMore: false }; + const displayRows = applyQueryFilter( + await fetchFromTargets(targets, flags.limit, flags.json), + flags.query + ); + return { items: displayRows.map((r) => r.rule), displayRows, hasMore: false }; } // --------------------------------------------------------------------------- // Human output // --------------------------------------------------------------------------- -function formatIssueAlertListHuman( - result: ListResult -): string { +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( - result.items.map((r) => `${r.target.org}/${r.target.project}`) + rows.map((r) => `${r.target.org}/${r.target.project}`) ); const isMultiProject = uniqueProjects.size > 1; @@ -255,7 +295,7 @@ function formatIssueAlertListHuman( status: string; }; - const rows: Row[] = result.items.map(({ rule: r, target }) => ({ + const tableRows: Row[] = rows.map(({ rule: r, target }) => ({ id: r.id, name: escapeMarkdownCell(r.name), ...(isMultiProject && { @@ -284,36 +324,11 @@ function formatIssueAlertListHuman( const parts: string[] = []; const buffer: Writer = { write: (s) => parts.push(s) }; - writeTable(buffer, rows, columns); + writeTable(buffer, tableRows, columns); return parts.join("").trimEnd(); } -// --------------------------------------------------------------------------- -// JSON transform -// --------------------------------------------------------------------------- - -function jsonTransformIssueAlertList( - result: ListResult, - fields?: string[] -): unknown { - const rules = result.items.map(({ rule }) => rule); - const items = - fields && fields.length > 0 - ? rules.map((r) => filterFields(r, fields)) - : rules; - - const envelope: Record = { - data: items, - hasMore: !!result.hasMore, - hasPrev: !!result.hasPrev, - }; - if (result.nextCursor) { - envelope.nextCursor = result.nextCursor; - } - return envelope; -} - // --------------------------------------------------------------------------- // Command // --------------------------------------------------------------------------- @@ -334,21 +349,10 @@ export const listCommand = buildListCommand("alert", { }, output: { human: formatIssueAlertListHuman, - jsonTransform: jsonTransformIssueAlertList, + jsonTransform: jsonTransformListResult, }, parameters: { - positional: { - kind: "tuple", - parameters: [ - { - placeholder: "org/project", - brief: - "/, / (all), (search), or omit to auto-detect", - parse: String, - optional: true, - }, - ], - }, + positional: LIST_TARGET_POSITIONAL, flags: { web: { kind: "boolean", @@ -356,8 +360,14 @@ export const listCommand = buildListCommand("alert", { default: false, }, limit: buildListLimitFlag("issue alert rules"), + query: { + kind: "parsed", + parse: String, + brief: "Filter rules by name", + optional: true, + }, }, - aliases: { w: "web", n: "limit" }, + aliases: { ...LIST_BASE_ALIASES, w: "web", q: "query" }, }, async *func(this: SentryContext, flags: ListFlags, target?: string) { const { cwd } = this; @@ -390,7 +400,7 @@ export const listCommand = buildListCommand("alert", { flags ), }, - })) as ListResult; + })) as IssueAlertListResult; yield new CommandOutput(result); return { hint: result.hint }; diff --git a/src/commands/alert/metrics/list.ts b/src/commands/alert/metrics/list.ts index 92ad7bccb..89bda2c01 100644 --- a/src/commands/alert/metrics/list.ts +++ b/src/commands/alert/metrics/list.ts @@ -26,7 +26,6 @@ import { resolveCursor, } from "../../../lib/db/pagination.js"; import { ContextError } from "../../../lib/errors.js"; -import { filterFields } from "../../../lib/formatters/json.js"; import { colorTag, escapeMarkdownCell, @@ -36,11 +35,14 @@ import { type Column, writeTable } from "../../../lib/formatters/table.js"; import { buildListCommand, buildListLimitFlag, + LIST_BASE_ALIASES, + LIST_TARGET_POSITIONAL, paginationHint, targetPatternExplanation, } from "../../../lib/list-command.js"; import { dispatchOrgScopedList, + jsonTransformListResult, type ListCommandMeta, type ListResult, } from "../../../lib/org-list.js"; @@ -61,11 +63,18 @@ type ListFlags = { readonly cursor?: string; readonly json: boolean; readonly fields?: string[]; + readonly query?: string; }; -type MetricAlertWithOrg = { - rule: MetricAlertRule; - orgSlug: string; +/** 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). Mirrors the IssueListResult pattern. + */ +type MetricAlertListResult = ListResult & { + displayRows?: MetricAlertRow[]; }; const metricAlertListMeta: ListCommandMeta = { @@ -82,8 +91,6 @@ const metricAlertListMeta: ListCommandMeta = { /** * Fetch metric alert rules for one org starting from an optional cursor, * fetching multiple API pages until `limit` is reached or no more pages exist. - * - * Returns the rules collected and a cursor pointing to the next page (if any). */ async function fetchMetricRulesPage( orgSlug: string, @@ -114,19 +121,15 @@ async function fetchMetricRulesPage( return { rules }; } -// --------------------------------------------------------------------------- -// Mode handlers -// --------------------------------------------------------------------------- - /** - * Fetch from multiple orgs in parallel and combine results (no cursor). + * Fetch metric alert rules from multiple orgs in parallel and combine. * Used by auto-detect and project-search modes. */ async function fetchFromOrgs( orgs: string[], limit: number, json: boolean -): Promise { +): Promise { const results = await withProgress( { message: @@ -146,10 +149,26 @@ async function fetchFromOrgs( return results.flat().slice(0, limit); } -async function handleAutoDetectMetrics( +/** Client-side name filter applied after fetch (API has no query param). */ +function applyQueryFilter( + rows: MetricAlertRow[], + query: string | undefined +): MetricAlertRow[] { + if (!query) { + return rows; + } + const q = query.toLowerCase(); + return rows.filter((r) => r.rule.name.toLowerCase().includes(q)); +} + +// --------------------------------------------------------------------------- +// Mode handlers +// --------------------------------------------------------------------------- + +async function handleAutoDetectMetricAlerts( cwd: string, flags: ListFlags -): Promise> { +): Promise { const { targets, footer } = await withProgress( { message: "Resolving targets...", json: flags.json }, () => @@ -162,29 +181,42 @@ async function handleAutoDetectMetrics( throw new ContextError("Organization", USAGE_HINT); } const uniqueOrgs = [...new Set(targets.map((t) => t.org))]; - const items = await fetchFromOrgs(uniqueOrgs, flags.limit, flags.json); - return { items, hasMore: false, hint: footer }; + const displayRows = applyQueryFilter( + await fetchFromOrgs(uniqueOrgs, flags.limit, flags.json), + flags.query + ); + return { + items: displayRows.map((r) => r.rule), + displayRows, + hasMore: false, + hint: footer, + }; } -async function handleExplicitMetrics( +async function handleExplicitMetricAlerts( org: string, flags: ListFlags -): Promise> { +): Promise { const { rules } = await withProgress( { message: `Fetching metric alert rules for ${org}...`, json: flags.json }, () => fetchMetricRulesPage(org, { limit: flags.limit }) ); + const displayRows = applyQueryFilter( + rules.map((rule) => ({ rule, orgSlug: org })), + flags.query + ); return { - items: rules.map((rule) => ({ rule, orgSlug: org })), + items: displayRows.map((r) => r.rule), + displayRows, hasMore: false, hint: `Metric alerts: ${buildMetricAlertsUrl(org)}`, }; } -async function handleOrgAllMetrics( +async function handleOrgAllMetricAlerts( org: string, flags: ListFlags -): Promise> { +): Promise { const contextKey = buildOrgContextKey(org); const { cursor: startCursor, direction } = resolveCursor( flags.cursor, @@ -200,7 +232,11 @@ async function handleOrgAllMetrics( advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); - const items = rules.map((rule) => ({ rule, orgSlug: org })); + const displayRows = applyQueryFilter( + rules.map((rule) => ({ rule, orgSlug: org })), + flags.query + ); + const nav = paginationHint({ hasPrev, hasMore: !!nextCursor, @@ -209,11 +245,11 @@ async function handleOrgAllMetrics( }); const hintParts: string[] = []; - if (items.length === 0) { + if (displayRows.length === 0) { hintParts.push(`No metric alert rules found in '${org}'.`); } else { hintParts.push( - `Showing ${items.length} rule(s)${nextCursor ? " (more available)" : ""}.` + `Showing ${displayRows.length} rule(s)${nextCursor ? " (more available)" : ""}.` ); hintParts.push(`Metric alerts: ${buildMetricAlertsUrl(org)}`); } @@ -222,7 +258,8 @@ async function handleOrgAllMetrics( } return { - items, + items: displayRows.map((r) => r.rule), + displayRows, hasMore: !!nextCursor, hasPrev, nextCursor, @@ -230,11 +267,11 @@ async function handleOrgAllMetrics( }; } -async function handleProjectSearchMetrics( +async function handleProjectSearchMetricAlerts( projectSlug: string, cwd: string, flags: ListFlags -): Promise> { +): Promise { const { targets } = await withProgress( { message: `Searching for project '${projectSlug}'...`, json: flags.json }, () => @@ -244,8 +281,11 @@ async function handleProjectSearchMetrics( ) ); const uniqueOrgs = [...new Set(targets.map((t) => t.org))]; - const items = await fetchFromOrgs(uniqueOrgs, flags.limit, flags.json); - return { items, hasMore: false }; + const displayRows = applyQueryFilter( + await fetchFromOrgs(uniqueOrgs, flags.limit, flags.json), + flags.query + ); + return { items: displayRows.map((r) => r.rule), displayRows, hasMore: false }; } // --------------------------------------------------------------------------- @@ -259,14 +299,13 @@ function formatMetricStatus(status: number): string { : colorTag("muted", "disabled"); } -function formatMetricAlertListHuman( - result: ListResult -): string { +function formatMetricAlertListHuman(result: MetricAlertListResult): string { if (result.items.length === 0) { return result.hint ?? "No metric alert rules found."; } - const uniqueOrgs = new Set(result.items.map((r) => r.orgSlug)); + const rows = result.displayRows ?? []; + const uniqueOrgs = new Set(rows.map((r) => r.orgSlug)); const isMultiOrg = uniqueOrgs.size > 1; type Row = { @@ -280,7 +319,7 @@ function formatMetricAlertListHuman( status: string; }; - const rows: Row[] = result.items.map(({ rule: r, orgSlug }) => ({ + const tableRows: Row[] = rows.map(({ rule: r, orgSlug }) => ({ id: r.id, name: escapeMarkdownCell(r.name), ...(isMultiOrg && { org: orgSlug }), @@ -304,36 +343,11 @@ function formatMetricAlertListHuman( const parts: string[] = []; const buffer: Writer = { write: (s) => parts.push(s) }; - writeTable(buffer, rows, columns); + writeTable(buffer, tableRows, columns); return parts.join("").trimEnd(); } -// --------------------------------------------------------------------------- -// JSON transform -// --------------------------------------------------------------------------- - -function jsonTransformMetricAlertList( - result: ListResult, - fields?: string[] -): unknown { - const rules = result.items.map(({ rule }) => rule); - const items = - fields && fields.length > 0 - ? rules.map((r) => filterFields(r, fields)) - : rules; - - const envelope: Record = { - data: items, - hasMore: !!result.hasMore, - hasPrev: !!result.hasPrev, - }; - if (result.nextCursor) { - envelope.nextCursor = result.nextCursor; - } - return envelope; -} - // --------------------------------------------------------------------------- // Command // --------------------------------------------------------------------------- @@ -346,7 +360,7 @@ export const listCommand = buildListCommand("alert", { "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\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` + @@ -354,21 +368,10 @@ export const listCommand = buildListCommand("alert", { }, output: { human: formatMetricAlertListHuman, - jsonTransform: jsonTransformMetricAlertList, + jsonTransform: jsonTransformListResult, }, parameters: { - positional: { - kind: "tuple", - parameters: [ - { - placeholder: "org/", - brief: - "/ (explicit), /, (search), or omit to auto-detect", - parse: String, - optional: true, - }, - ], - }, + positional: LIST_TARGET_POSITIONAL, flags: { web: { kind: "boolean", @@ -376,8 +379,14 @@ export const listCommand = buildListCommand("alert", { default: false, }, limit: buildListLimitFlag("metric alert rules"), + query: { + kind: "parsed", + parse: String, + brief: "Filter rules by name", + optional: true, + }, }, - aliases: { w: "web", n: "limit" }, + aliases: { ...LIST_BASE_ALIASES, w: "web", q: "query" }, }, async *func(this: SentryContext, flags: ListFlags, target?: string) { const { cwd } = this; @@ -402,13 +411,17 @@ export const listCommand = buildListCommand("alert", { parsed, orgSlugMatchBehavior: "redirect", overrides: { - "auto-detect": (ctx) => handleAutoDetectMetrics(ctx.cwd, flags), - explicit: (ctx) => handleExplicitMetrics(ctx.parsed.org, flags), - "org-all": (ctx) => handleOrgAllMetrics(ctx.parsed.org, flags), + "auto-detect": (ctx) => handleAutoDetectMetricAlerts(ctx.cwd, flags), + explicit: (ctx) => handleExplicitMetricAlerts(ctx.parsed.org, flags), + "org-all": (ctx) => handleOrgAllMetricAlerts(ctx.parsed.org, flags), "project-search": (ctx) => - handleProjectSearchMetrics(ctx.parsed.projectSlug, ctx.cwd, flags), + handleProjectSearchMetricAlerts( + ctx.parsed.projectSlug, + ctx.cwd, + flags + ), }, - })) as ListResult; + })) as MetricAlertListResult; yield new CommandOutput(result); return { hint: result.hint }; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index fb88692f4..09d7241cd 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -22,8 +22,8 @@ export { type IssueAlertRule, listIssueAlertsPaginated, - type MetricAlertRule, listMetricAlertsPaginated, + type MetricAlertRule, } from "./api/alerts.js"; export { createDashboard, From 30ed64b922f1d044ff658f125f034468a292cddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 27 Mar 2026 11:09:13 +0100 Subject: [PATCH 06/21] refactor(alert): rewrite alert issues list as structural replica of issue list Full structural alignment with sentry issue list: - FetchResult success/failure wrapping per target - fetchWithBudget with two-phase surplus redistribution - Compound cursor pagination (encodeCompoundCursor/decodeCompoundCursor) - buildProjectAliasMap with buildOrgAwareAliases + setProjectAliases - trimWithProjectGuarantee for per-project representation - One handleResolvedTargets for all 4 modes (alert issues have no org-level API) - allowCursorInModes for all modes including org-all - parseCursorFlag explicitly in cursor flag definition - logger.warn for partial failures - IssueAlertListResult with title/footerMode/moreHint/footer - jsonTransformListResult (shared JSON transform) Co-Authored-By: Claude Sonnet 4.6 --- src/commands/alert/issues/list.ts | 656 ++++++++++++++++++++++-------- 1 file changed, 497 insertions(+), 159 deletions(-) diff --git a/src/commands/alert/issues/list.ts b/src/commands/alert/issues/list.ts index 85b4eaa38..f6fd704a9 100644 --- a/src/commands/alert/issues/list.ts +++ b/src/commands/alert/issues/list.ts @@ -11,6 +11,7 @@ */ import type { SentryContext } from "../../../context.js"; +import { buildOrgAwareAliases } from "../../../lib/alias.js"; import type { IssueAlertRule } from "../../../lib/api/alerts.js"; import { MAX_PAGINATION_PAGES } from "../../../lib/api/infrastructure.js"; import { @@ -19,7 +20,21 @@ import { } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; -import { ContextError } from "../../../lib/errors.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, @@ -31,13 +46,16 @@ import { buildListLimitFlag, LIST_BASE_ALIASES, LIST_TARGET_POSITIONAL, + parseCursorFlag, targetPatternExplanation, } from "../../../lib/list-command.js"; +import { logger } from "../../../lib/logger.js"; import { dispatchOrgScopedList, jsonTransformListResult, type ListCommandMeta, type ListResult, + type ModeHandler, } from "../../../lib/org-list.js"; import { withProgress } from "../../../lib/polling.js"; import { @@ -45,13 +63,15 @@ import { resolveTargetsFromParsedArg, } from "../../../lib/resolve-target.js"; import { buildIssueAlertsUrl } from "../../../lib/sentry-urls.js"; -import type { Writer } from "../../../types/index.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; @@ -62,15 +82,38 @@ type ListFlags = { 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 = + | { success: true; data: AlertRuleListFetchResult } + | { success: false; error: Error }; + +/** Result of building project aliases */ +type AliasMapResult = { + aliasMap: Map; + entries: Record; +}; + /** 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). Mirrors the IssueListResult pattern. + * `displayRows` (for human output). */ type IssueAlertListResult = ListResult & { displayRows?: AlertRuleRow[]; + title?: string; + footerMode?: "single" | "multi" | "none"; + moreHint?: string; + footer?: string; }; const issueAlertListMeta: ListCommandMeta = { @@ -85,191 +128,443 @@ const issueAlertListMeta: ListCommandMeta = { // --------------------------------------------------------------------------- /** - * Fetch all issue alert rules for one project (up to limit), across multiple pages. + * Fetch issue alert rules for a single target project with auth guard. + * Paginates locally up to the given limit. */ -async function fetchIssueRulesForTarget( +async function fetchRulesForTarget( target: ResolvedTarget, - limit: number -): Promise { - const rules: IssueAlertRule[] = []; - let serverCursor: string | undefined; - - for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { - const { data, nextCursor } = await listIssueAlertsPaginated( - target.org, - target.project, - { - perPage: Math.min(limit - rules.length, API_MAX_PER_PAGE), - cursor: serverCursor, + 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: true, + nextCursor: nextCursor ?? undefined, + }; + } } - ); - for (const rule of data) { - rules.push(rule); - if (rules.length >= limit) { - return rules; + if (!nextCursor) { + return { target, rules, hasMore: false }; } + serverCursor = nextCursor; } - if (!nextCursor) { - break; - } - 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, + }); + }) + ); - return rules; + 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; + } + } } /** - * Fetch issue alert rules from multiple targets in parallel and combine. - * Used by auto-detect, org-all, and project-search modes. + * 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 fetchFromTargets( +async function fetchWithBudget( targets: ResolvedTarget[], - limit: number, - json: boolean -): Promise { - const perTargetLimit = Math.max(limit, Math.ceil(limit / targets.length) * 2); - const results = await withProgress( - { - message: - targets.length > 1 - ? `Fetching issue alert rules from ${targets.length} projects...` - : `Fetching issue alert rules for ${targets[0]?.org}/${targets[0]?.project}...`, - json, - }, - () => - Promise.all( - targets.map(async (target) => { - const rules = await fetchIssueRulesForTarget(target, perTargetLimit); - return rules.map((rule) => ({ rule, target })); - }) - ) + 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}`), + }) + ) ); - return results.flat().slice(0, limit); + + 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: phase1.some((r) => r.success && r.data.hasMore), + }; + } + + 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: false }; + } + + 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: phase1.some((r) => r.success && r.data.hasMore), + }; } -/** Client-side name filter applied after fetch (API has no query param). */ -function applyQueryFilter( +/** + * Build project alias map using shortest unique prefix of project slug. + */ +function buildProjectAliasMap( + results: AlertRuleListFetchResult[] +): 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 }; +} + +/** + * Trim display rows to the global limit while guaranteeing at least one row + * per project (when possible). + */ +function trimWithProjectGuarantee( rows: AlertRuleRow[], - query: string | undefined + limit: number ): AlertRuleRow[] { - if (!query) { + if (rows.length <= limit) { return rows; } - const q = query.toLowerCase(); - return rows.filter((r) => r.rule.name.toLowerCase().includes(q)); + + const seenProjects = new Set(); + const guaranteed = new Set(); + + for (let i = 0; i < rows.length && guaranteed.size < limit; i++) { + // biome-ignore lint/style/noNonNullAssertion: i is within bounds + const projectKey = `${rows[i]!.target.org}/${rows[i]!.target.project}`; + if (!seenProjects.has(projectKey)) { + seenProjects.add(projectKey); + guaranteed.add(i); + } + } + + 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)); } // --------------------------------------------------------------------------- -// Mode handlers +// Mode handler // --------------------------------------------------------------------------- -async function handleAutoDetectIssueAlerts( - cwd: string, - flags: ListFlags +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 { targets, footer } = await withProgress( - { message: "Resolving targets...", json: flags.json }, - () => - resolveTargetsFromParsedArg( - { type: "auto-detect" }, - { cwd, usageHint: USAGE_HINT } - ) + 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 displayRows = applyQueryFilter( - await fetchFromTargets(targets, flags.limit, flags.json), - flags.query - ); - return { - items: displayRows.map((r) => r.rule), - displayRows, - hasMore: false, - hint: footer, - }; -} -async function handleExplicitIssueAlerts( - org: string, - project: string, - flags: ListFlags -): Promise { - const target: ResolvedTarget = { - org, - project, - orgDisplay: org, - projectDisplay: project, - }; - const rules = await withProgress( - { - message: `Fetching issue alert rules for ${org}/${project}...`, - json: flags.json, - }, - () => fetchIssueRulesForTarget(target, flags.limit) + 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 ); - const displayRows = applyQueryFilter( - rules.map((rule) => ({ rule, target })), - flags.query - ); - return { - items: displayRows.map((r) => r.rule), - displayRows, - hasMore: false, - hint: `Alert rules: ${buildIssueAlertsUrl(org, project)}`, - }; -} + 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); + } + } + } -async function handleOrgAllIssueAlerts( - org: string, - flags: ListFlags -): Promise { - // org-all: list all projects in the org, then fetch alerts for each - const { targets } = await withProgress( - { message: `Listing projects in ${org}...`, json: flags.json }, - () => - resolveTargetsFromParsedArg( - { type: "org-all", org }, - { cwd: "", usageHint: USAGE_HINT } + 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 displayRows = applyQueryFilter( - await fetchFromTargets(targets, flags.limit, flags.json), - flags.query + + 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: false, hasPrev }; + } + + const title = + isSingleProject && firstTarget + ? `Issue alert rules in ${firstTarget.orgDisplay}/${firstTarget.projectDisplay}` + : `Issue alert rules from ${validResults.length} projects`; + + let footerMode: "single" | "multi" | "none" = "none"; + if (isMultiProject) { + footerMode = "multi"; + } else if (isSingleProject) { + footerMode = "single"; + } + + 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: displayRows.map((r) => r.rule), + items: allRules, + hasMore: hasMoreToShow, + hasPrev, displayRows, - hasMore: false, - hint: - targets.length > 1 - ? `Showing alert rules from ${targets.length} projects in ${org}.` - : undefined, + title, + footerMode, + moreHint, + footer, }; } -async function handleProjectSearchIssueAlerts( - projectSlug: string, - cwd: string, - flags: ListFlags -): Promise { - const { targets } = await withProgress( - { message: `Searching for project '${projectSlug}'...`, json: flags.json }, - () => - resolveTargetsFromParsedArg( - { type: "project-search", projectSlug }, - { cwd, usageHint: USAGE_HINT } - ) - ); - const displayRows = applyQueryFilter( - await fetchFromTargets(targets, flags.limit, flags.json), - flags.query - ); - return { items: displayRows.map((r) => r.rule), displayRows, hasMore: false }; -} - // --------------------------------------------------------------------------- // Human output // --------------------------------------------------------------------------- @@ -324,11 +619,18 @@ function formatIssueAlertListHuman(result: IssueAlertListResult): string { 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 // --------------------------------------------------------------------------- @@ -345,11 +647,12 @@ export const listCommand = buildListCommand("alert", { " 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.", + "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: jsonTransformListResult, + jsonTransform: jsonTransformIssueAlertList, }, parameters: { positional: LIST_TARGET_POSITIONAL, @@ -366,6 +669,13 @@ export const listCommand = buildListCommand("alert", { 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" }, }, @@ -382,27 +692,55 @@ export const listCommand = buildListCommand("alert", { 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": (ctx) => handleAutoDetectIssueAlerts(ctx.cwd, flags), - explicit: (ctx) => - handleExplicitIssueAlerts(ctx.parsed.org, ctx.parsed.project, flags), - "org-all": (ctx) => handleOrgAllIssueAlerts(ctx.parsed.org, flags), - "project-search": (ctx) => - handleProjectSearchIssueAlerts( - ctx.parsed.projectSlug, - ctx.cwd, - flags - ), + "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: result.hint }; + return { hint: combinedHint }; }, }); + +/** @internal Exported for testing only. */ +export const __testing = { + trimWithProjectGuarantee, + encodeCompoundCursor, + decodeCompoundCursor, + buildMultiTargetContextKey, + buildProjectAliasMap, + CURSOR_SEP, + MAX_LIMIT, +}; From e816eccdc40467710e6dc257ac9d1786838a109d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 27 Mar 2026 11:14:11 +0100 Subject: [PATCH 07/21] refactor(alert): rewrite alert metrics list as structural replica of issue list Full structural alignment with sentry issue list and alert issues list: - FetchResult success/failure wrapping per org with withAuthGuard - fetchWithBudget with two-phase surplus redistribution (per-org) - Compound cursor pagination across multiple orgs (auto-detect/explicit/project-search) - trimWithOrgGuarantee for per-org representation - handleResolvedOrgs for multi-org modes; handleOrgAllMetricAlerts for single-org cursor - allowCursorInModes for auto-detect/explicit/project-search - parseCursorFlag explicitly in cursor flag definition - logger.warn for partial org failures - MetricAlertListResult with title/footerMode/moreHint/footer - jsonTransformListResult (shared JSON transform) - buildMultiOrgContextKey isolates cursors per unique org set + query Co-Authored-By: Claude Sonnet 4.6 --- src/commands/alert/metrics/list.ts | 622 ++++++++++++++++++++++------- 1 file changed, 485 insertions(+), 137 deletions(-) diff --git a/src/commands/alert/metrics/list.ts b/src/commands/alert/metrics/list.ts index 89bda2c01..04d5fee90 100644 --- a/src/commands/alert/metrics/list.ts +++ b/src/commands/alert/metrics/list.ts @@ -22,10 +22,15 @@ import { openInBrowser } from "../../../lib/browser.js"; import { advancePaginationState, buildOrgContextKey, + buildPaginationContextKey, + CURSOR_SEP, + decodeCompoundCursor, + encodeCompoundCursor, + escapeContextKeyValue, hasPreviousPage, resolveCursor, } from "../../../lib/db/pagination.js"; -import { ContextError } from "../../../lib/errors.js"; +import { ContextError, withAuthGuard } from "../../../lib/errors.js"; import { colorTag, escapeMarkdownCell, @@ -38,16 +43,22 @@ import { LIST_BASE_ALIASES, LIST_TARGET_POSITIONAL, paginationHint, + parseCursorFlag, targetPatternExplanation, } from "../../../lib/list-command.js"; +import { logger } from "../../../lib/logger.js"; import { dispatchOrgScopedList, jsonTransformListResult, type ListCommandMeta, type ListResult, + type ModeHandler, } from "../../../lib/org-list.js"; import { withProgress } from "../../../lib/polling.js"; -import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; +import { + type ResolvedTarget, + resolveTargetsFromParsedArg, +} from "../../../lib/resolve-target.js"; import { buildMetricAlertsUrl } from "../../../lib/sentry-urls.js"; import type { Writer } from "../../../types/index.js"; @@ -56,6 +67,8 @@ 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; @@ -66,15 +79,32 @@ type ListFlags = { 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 = + | { success: true; data: MetricRuleFetchResult } + | { success: false; error: Error }; + /** 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). Mirrors the IssueListResult pattern. + * `displayRows` (for human output). */ type MetricAlertListResult = ListResult & { displayRows?: MetricAlertRow[]; + title?: string; + footerMode?: "single" | "multi" | "none"; + moreHint?: string; + footer?: string; }; const metricAlertListMeta: ListCommandMeta = { @@ -89,130 +119,412 @@ const metricAlertListMeta: ListCommandMeta = { // --------------------------------------------------------------------------- /** - * Fetch metric alert rules for one org starting from an optional cursor, - * fetching multiple API pages until `limit` is reached or no more pages exist. + * Fetch metric alert rules for one org with auth guard. + * Paginates locally up to the given limit. */ -async function fetchMetricRulesPage( +async function fetchRulesForOrg( orgSlug: string, - opts: { limit: number; cursor?: string } -): Promise<{ rules: MetricAlertRule[]; nextCursor?: string }> { - const rules: MetricAlertRule[] = []; - let serverCursor = opts.cursor; - - for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { - const { data, nextCursor } = await listMetricAlertsPaginated(orgSlug, { - perPage: Math.min(opts.limit - rules.length, API_MAX_PER_PAGE), - cursor: serverCursor, - }); - - for (const rule of data) { - rules.push(rule); - if (rules.length >= opts.limit) { - return { rules, nextCursor: nextCursor ?? undefined }; + 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: true, + nextCursor: nextCursor ?? undefined, + }; + } } - } - if (!nextCursor) { - return { rules }; + if (!nextCursor) { + return { orgSlug, rules, hasMore: false }; + } + serverCursor = nextCursor; } - 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 }; +} - return { rules }; +/** + * 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; + } + } } /** - * Fetch metric alert rules from multiple orgs in parallel and combine. - * Used by auto-detect and project-search modes. + * 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 fetchFromOrgs( +async function fetchWithBudget( orgs: string[], - limit: number, - json: boolean -): Promise { - const results = await withProgress( - { - message: - orgs.length > 1 - ? `Fetching metric alert rules from ${orgs.length} organizations...` - : `Fetching metric alert rules for ${orgs[0]}...`, - json, - }, - () => - Promise.all( - orgs.map(async (org) => { - const { rules } = await fetchMetricRulesPage(org, { limit }); - return rules.map((rule) => ({ rule, orgSlug: org })); - }) - ) + 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), + }) + ) ); - return results.flat().slice(0, limit); + + 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: phase1.some((r) => r.success && r.data.hasMore), + }; + } + + 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: false }; + } + + 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: phase1.some((r) => r.success && r.data.hasMore), + }; } -/** Client-side name filter applied after fetch (API has no query param). */ -function applyQueryFilter( +/** + * Trim display rows to the global limit while guaranteeing at least one row + * per org (when possible). + */ +function trimWithOrgGuarantee( rows: MetricAlertRow[], - query: string | undefined + limit: number ): MetricAlertRow[] { - if (!query) { + if (rows.length <= limit) { return rows; } - const q = query.toLowerCase(); - return rows.filter((r) => r.rule.name.toLowerCase().includes(q)); + + const seenOrgs = new Set(); + const guaranteed = new Set(); + + for (let i = 0; i < rows.length && guaranteed.size < limit; i++) { + // biome-ignore lint/style/noNonNullAssertion: i is within bounds + if (!seenOrgs.has(rows[i]!.orgSlug)) { + // biome-ignore lint/style/noNonNullAssertion: i is within bounds + seenOrgs.add(rows[i]!.orgSlug); + guaranteed.add(i); + } + } + + 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)); +} + +/** + * Build a compound context key for multi-org pagination. + * Encodes the sorted org set and optional query so cursors from different + * searches are never reused. + */ +function buildMultiOrgContextKey( + orgs: string[], + query: string | undefined +): string { + const sortedOrgs = [...orgs].sort().map(escapeContextKeyValue).join(","); + return buildPaginationContextKey("multi-org", sortedOrgs, { q: query }); } // --------------------------------------------------------------------------- // Mode handlers // --------------------------------------------------------------------------- -async function handleAutoDetectMetricAlerts( - cwd: string, - flags: ListFlags +type ResolvedOrgsOptions = { + parsed: ReturnType; + flags: ListFlags; + cwd: string; +}; + +/** + * Handle auto-detect, explicit, and project-search modes. + * + * All three share the same flow: resolve targets → deduplicate to unique 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 { targets, footer } = await withProgress( - { message: "Resolving targets...", json: flags.json }, - () => - resolveTargetsFromParsedArg( - { type: "auto-detect" }, - { cwd, usageHint: USAGE_HINT } - ) - ); + const { parsed, flags, cwd } = options; + + const { targets, footer } = await resolveTargetsFromParsedArg(parsed, { + cwd, + usageHint: USAGE_HINT, + }); + if (targets.length === 0) { throw new ContextError("Organization", USAGE_HINT); } - const uniqueOrgs = [...new Set(targets.map((t) => t.org))]; - const displayRows = applyQueryFilter( - await fetchFromOrgs(uniqueOrgs, flags.limit, flags.json), - flags.query + + const uniqueOrgs = [...new Set(targets.map((t: ResolvedTarget) => t.org))]; + + 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 ); - return { - items: displayRows.map((r) => r.rule), - displayRows, - hasMore: false, - hint: footer, - }; -} + 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); + } + } + } -async function handleExplicitMetricAlerts( - org: string, - flags: ListFlags -): Promise { - const { rules } = await withProgress( - { message: `Fetching metric alert rules for ${org}...`, json: flags.json }, - () => fetchMetricRulesPage(org, { limit: flags.limit }) + 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 displayRows = applyQueryFilter( - rules.map((rule) => ({ rule, orgSlug: org })), - flags.query + + 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 isMultiOrg = validResults.length > 1; + 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: false, hasPrev }; + } + + const title = + isSingleOrg && firstOrg + ? `Metric alert rules in ${firstOrg}` + : `Metric alert rules from ${validResults.length} organizations`; + + let footerMode: "single" | "multi" | "none" = "none"; + if (isMultiOrg) { + footerMode = "multi"; + } else if (isSingleOrg) { + footerMode = "single"; + } + + 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: displayRows.map((r) => r.rule), + items: allRules, + hasMore: hasMoreToShow, + hasPrev, displayRows, - hasMore: false, - hint: `Metric alerts: ${buildMetricAlertsUrl(org)}`, + title, + footerMode, + moreHint, + footer, }; } +/** + * Handle org-all mode: cursor-paginated listing of all metric alert rules in an org. + * + * Metric alerts are org-scoped, so this uses a single org-level cursor (not + * compound) with full bidirectional navigation. + */ async function handleOrgAllMetricAlerts( org: string, flags: ListFlags @@ -224,18 +536,27 @@ async function handleOrgAllMetricAlerts( contextKey ); - const { rules, nextCursor } = await withProgress( + const fetchResult = await withProgress( { message: `Fetching metric alert rules for ${org}...`, json: flags.json }, - () => fetchMetricRulesPage(org, { limit: flags.limit, cursor: startCursor }) + () => fetchRulesForOrg(org, { limit: flags.limit, startCursor }) ); + if (!fetchResult.success) { + throw fetchResult.error; + } + + const { rules, nextCursor } = fetchResult.data; + advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); - const displayRows = applyQueryFilter( - rules.map((rule) => ({ rule, orgSlug: org })), - flags.query - ); + const filteredRows: MetricAlertRow[] = flags.query + ? rules + .filter((rule) => + rule.name.toLowerCase().includes(flags.query?.toLowerCase() ?? "") + ) + .map((rule) => ({ rule, orgSlug: org })) + : rules.map((rule) => ({ rule, orgSlug: org })); const nav = paginationHint({ hasPrev, @@ -244,50 +565,39 @@ async function handleOrgAllMetricAlerts( nextHint: `sentry alert metrics list ${org}/ -c next`, }); - const hintParts: string[] = []; - if (displayRows.length === 0) { - hintParts.push(`No metric alert rules found in '${org}'.`); - } else { - hintParts.push( - `Showing ${displayRows.length} rule(s)${nextCursor ? " (more available)" : ""}.` - ); - hintParts.push(`Metric alerts: ${buildMetricAlertsUrl(org)}`); + if (filteredRows.length === 0) { + const hintParts: string[] = [`No metric alert rules found in '${org}'.`]; + if (nav) { + hintParts.push(nav); + } + return { + items: [], + hasMore: !!nextCursor, + hasPrev, + hint: hintParts.join("\n"), + }; } + + const hintParts: string[] = [ + `Showing ${filteredRows.length} rule(s)${nextCursor ? " (more available)" : ""}.`, + `Metric alerts: ${buildMetricAlertsUrl(org)}`, + ]; if (nav) { hintParts.push(nav); } return { - items: displayRows.map((r) => r.rule), - displayRows, + items: filteredRows.map((r) => r.rule), + displayRows: filteredRows, hasMore: !!nextCursor, hasPrev, nextCursor, + title: `Metric alert rules in ${org}`, + footerMode: "single", hint: hintParts.join("\n"), }; } -async function handleProjectSearchMetricAlerts( - projectSlug: string, - cwd: string, - flags: ListFlags -): Promise { - const { targets } = await withProgress( - { message: `Searching for project '${projectSlug}'...`, json: flags.json }, - () => - resolveTargetsFromParsedArg( - { type: "project-search", projectSlug }, - { cwd, usageHint: USAGE_HINT } - ) - ); - const uniqueOrgs = [...new Set(targets.map((t) => t.org))]; - const displayRows = applyQueryFilter( - await fetchFromOrgs(uniqueOrgs, flags.limit, flags.json), - flags.query - ); - return { items: displayRows.map((r) => r.rule), displayRows, hasMore: false }; -} - // --------------------------------------------------------------------------- // Human output // --------------------------------------------------------------------------- @@ -343,11 +653,18 @@ function formatMetricAlertListHuman(result: MetricAlertListResult): string { 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 // --------------------------------------------------------------------------- @@ -364,11 +681,12 @@ export const listCommand = buildListCommand("alert", { " 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.", + "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: jsonTransformListResult, + jsonTransform: jsonTransformMetricAlertList, }, parameters: { positional: LIST_TARGET_POSITIONAL, @@ -385,6 +703,13 @@ export const listCommand = buildListCommand("alert", { 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" }, }, @@ -404,26 +729,49 @@ export const listCommand = buildListCommand("alert", { 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", + // Multi-org modes handle compound cursor pagination via handleResolvedOrgs + allowCursorInModes: ["auto-detect", "explicit", "project-search"], overrides: { - "auto-detect": (ctx) => handleAutoDetectMetricAlerts(ctx.cwd, flags), - explicit: (ctx) => handleExplicitMetricAlerts(ctx.parsed.org, flags), + "auto-detect": resolveAndHandle, + explicit: resolveAndHandle, + "project-search": resolveAndHandle, "org-all": (ctx) => handleOrgAllMetricAlerts(ctx.parsed.org, flags), - "project-search": (ctx) => - handleProjectSearchMetricAlerts( - ctx.parsed.projectSlug, - ctx.cwd, - flags - ), }, })) 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: result.hint }; + return { hint: combinedHint }; }, }); + +/** @internal Exported for testing only. */ +export const __testing = { + trimWithOrgGuarantee, + encodeCompoundCursor, + decodeCompoundCursor, + buildMultiOrgContextKey, + CURSOR_SEP, + MAX_LIMIT, +}; From b04ef40423f9684005ab6991e1fc1d0688d6f07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 27 Mar 2026 11:28:35 +0100 Subject: [PATCH 08/21] refactor(alert): route org-all through handleResolvedOrgs in metrics list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the separate handleOrgAllMetricAlerts handler and routes all 4 modes (including org-all) through handleResolvedOrgs. org-all resolves all projects in the specified org, deduplicates to unique orgs (just the one), then uses fetchWithBudget with compound cursor — same path as auto-detect/explicit/project-search. This makes the structure identical to alert issues list and supports multi-org results from DSN detection consistently across all modes. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/alert/metrics/list.ts | 92 +++--------------------------- 1 file changed, 8 insertions(+), 84 deletions(-) diff --git a/src/commands/alert/metrics/list.ts b/src/commands/alert/metrics/list.ts index 04d5fee90..52a2cd89c 100644 --- a/src/commands/alert/metrics/list.ts +++ b/src/commands/alert/metrics/list.ts @@ -21,7 +21,6 @@ import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { advancePaginationState, - buildOrgContextKey, buildPaginationContextKey, CURSOR_SEP, decodeCompoundCursor, @@ -42,7 +41,6 @@ import { buildListLimitFlag, LIST_BASE_ALIASES, LIST_TARGET_POSITIONAL, - paginationHint, parseCursorFlag, targetPatternExplanation, } from "../../../lib/list-command.js"; @@ -519,85 +517,6 @@ async function handleResolvedOrgs( }; } -/** - * Handle org-all mode: cursor-paginated listing of all metric alert rules in an org. - * - * Metric alerts are org-scoped, so this uses a single org-level cursor (not - * compound) with full bidirectional navigation. - */ -async function handleOrgAllMetricAlerts( - org: string, - flags: ListFlags -): Promise { - const contextKey = buildOrgContextKey(org); - const { cursor: startCursor, direction } = resolveCursor( - flags.cursor, - PAGINATION_KEY, - contextKey - ); - - const fetchResult = await withProgress( - { message: `Fetching metric alert rules for ${org}...`, json: flags.json }, - () => fetchRulesForOrg(org, { limit: flags.limit, startCursor }) - ); - - if (!fetchResult.success) { - throw fetchResult.error; - } - - const { rules, nextCursor } = fetchResult.data; - - advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); - const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); - - const filteredRows: MetricAlertRow[] = flags.query - ? rules - .filter((rule) => - rule.name.toLowerCase().includes(flags.query?.toLowerCase() ?? "") - ) - .map((rule) => ({ rule, orgSlug: org })) - : rules.map((rule) => ({ rule, orgSlug: org })); - - const nav = paginationHint({ - hasPrev, - hasMore: !!nextCursor, - prevHint: `sentry alert metrics list ${org}/ -c prev`, - nextHint: `sentry alert metrics list ${org}/ -c next`, - }); - - if (filteredRows.length === 0) { - const hintParts: string[] = [`No metric alert rules found in '${org}'.`]; - if (nav) { - hintParts.push(nav); - } - return { - items: [], - hasMore: !!nextCursor, - hasPrev, - hint: hintParts.join("\n"), - }; - } - - const hintParts: string[] = [ - `Showing ${filteredRows.length} rule(s)${nextCursor ? " (more available)" : ""}.`, - `Metric alerts: ${buildMetricAlertsUrl(org)}`, - ]; - if (nav) { - hintParts.push(nav); - } - - return { - items: filteredRows.map((r) => r.rule), - displayRows: filteredRows, - hasMore: !!nextCursor, - hasPrev, - nextCursor, - title: `Metric alert rules in ${org}`, - footerMode: "single", - hint: hintParts.join("\n"), - }; -} - // --------------------------------------------------------------------------- // Human output // --------------------------------------------------------------------------- @@ -739,13 +658,18 @@ export const listCommand = buildListCommand("alert", { flags, parsed, orgSlugMatchBehavior: "redirect", - // Multi-org modes handle compound cursor pagination via handleResolvedOrgs - allowCursorInModes: ["auto-detect", "explicit", "project-search"], + // 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": (ctx) => handleOrgAllMetricAlerts(ctx.parsed.org, flags), + "org-all": resolveAndHandle, }, })) as MetricAlertListResult; From 218f81b2b4751fbc8ed4987ee30f9b6c7f1a67ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 27 Mar 2026 11:57:14 +0100 Subject: [PATCH 09/21] refactor(alert): deduplicate shared list utilities into lib Extract FetchResult, trimWithGroupGuarantee, buildProjectAliasMap, and buildMultiOrgContextKey into lib/ so all three list commands (issue, alert issues, alert metrics) share the same implementations. Each command retains a thin domain-specific wrapper where needed. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/alert/issues/list.ts | 68 +++---------------- src/commands/alert/metrics/list.ts | 45 ++----------- src/commands/issue/list.ts | 105 ++++------------------------- src/lib/alias.ts | 53 +++++++++++++++ src/lib/db/pagination.ts | 23 +++++++ src/lib/org-list.ts | 63 +++++++++++++++++ 6 files changed, 165 insertions(+), 192 deletions(-) diff --git a/src/commands/alert/issues/list.ts b/src/commands/alert/issues/list.ts index f6fd704a9..39de5c621 100644 --- a/src/commands/alert/issues/list.ts +++ b/src/commands/alert/issues/list.ts @@ -11,7 +11,7 @@ */ import type { SentryContext } from "../../../context.js"; -import { buildOrgAwareAliases } from "../../../lib/alias.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 { @@ -52,10 +52,12 @@ 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 { @@ -91,15 +93,7 @@ type AlertRuleListFetchResult = { }; /** Success/failure wrapper for per-target fetches */ -type FetchResult = - | { success: true; data: AlertRuleListFetchResult } - | { success: false; error: Error }; - -/** Result of building project aliases */ -type AliasMapResult = { - aliasMap: Map; - entries: Record; -}; +type FetchResult = FetchResultOf; /** Display row carrying per-rule project context for the human formatter. */ type AlertRuleRow = { rule: IssueAlertRule; target: ResolvedTarget }; @@ -289,33 +283,6 @@ async function fetchWithBudget( }; } -/** - * Build project alias map using shortest unique prefix of project slug. - */ -function buildProjectAliasMap( - results: AlertRuleListFetchResult[] -): 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 }; -} - /** * Trim display rows to the global limit while guaranteeing at least one row * per project (when possible). @@ -324,28 +291,11 @@ function trimWithProjectGuarantee( rows: AlertRuleRow[], limit: number ): AlertRuleRow[] { - if (rows.length <= limit) { - return rows; - } - - const seenProjects = new Set(); - const guaranteed = new Set(); - - for (let i = 0; i < rows.length && guaranteed.size < limit; i++) { - // biome-ignore lint/style/noNonNullAssertion: i is within bounds - const projectKey = `${rows[i]!.target.org}/${rows[i]!.target.project}`; - if (!seenProjects.has(projectKey)) { - seenProjects.add(projectKey); - guaranteed.add(i); - } - } - - 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)); + return trimWithGroupGuarantee( + rows, + limit, + (r) => `${r.target.org}/${r.target.project}` + ); } // --------------------------------------------------------------------------- diff --git a/src/commands/alert/metrics/list.ts b/src/commands/alert/metrics/list.ts index 52a2cd89c..239e399c1 100644 --- a/src/commands/alert/metrics/list.ts +++ b/src/commands/alert/metrics/list.ts @@ -21,11 +21,10 @@ import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { advancePaginationState, - buildPaginationContextKey, + buildMultiOrgContextKey, CURSOR_SEP, decodeCompoundCursor, encodeCompoundCursor, - escapeContextKeyValue, hasPreviousPage, resolveCursor, } from "../../../lib/db/pagination.js"; @@ -47,10 +46,12 @@ 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 { @@ -86,9 +87,7 @@ type MetricRuleFetchResult = { }; /** Success/failure wrapper for per-org fetches */ -type FetchResult = - | { success: true; data: MetricRuleFetchResult } - | { success: false; error: Error }; +type FetchResult = FetchResultOf; /** Display row carrying per-rule org context for the human formatter. */ type MetricAlertRow = { rule: MetricAlertRule; orgSlug: string }; @@ -275,41 +274,7 @@ function trimWithOrgGuarantee( rows: MetricAlertRow[], limit: number ): MetricAlertRow[] { - if (rows.length <= limit) { - return rows; - } - - const seenOrgs = new Set(); - const guaranteed = new Set(); - - for (let i = 0; i < rows.length && guaranteed.size < limit; i++) { - // biome-ignore lint/style/noNonNullAssertion: i is within bounds - if (!seenOrgs.has(rows[i]!.orgSlug)) { - // biome-ignore lint/style/noNonNullAssertion: i is within bounds - seenOrgs.add(rows[i]!.orgSlug); - guaranteed.add(i); - } - } - - 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)); -} - -/** - * Build a compound context key for multi-org pagination. - * Encodes the sorted org set and optional query so cursors from different - * searches are never reused. - */ -function buildMultiOrgContextKey( - orgs: string[], - query: string | undefined -): string { - const sortedOrgs = [...orgs].sort().map(escapeContextKeyValue).join(","); - return buildPaginationContextKey("multi-org", sortedOrgs, { q: query }); + return trimWithGroupGuarantee(rows, limit, (r) => r.orgSlug); } // --------------------------------------------------------------------------- diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 08f4be086..9c6aa4c12 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -6,7 +6,7 @@ */ import type { SentryContext } from "../../context.js"; -import { buildOrgAwareAliases } from "../../lib/alias.js"; +import { buildProjectAliasMap } from "../../lib/alias.js"; import { API_MAX_PER_PAGE, buildIssueListCollapse, @@ -60,21 +60,19 @@ 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 { type ResolvedTarget, resolveTargetsFromParsedArg, } from "../../lib/resolve-target.js"; -import type { - ProjectAliasEntry, - SentryIssue, - Writer, -} from "../../types/index.js"; +import type { SentryIssue, Writer } from "../../types/index.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "issue-list"; @@ -227,49 +225,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. * @@ -333,9 +288,7 @@ function getComparator( } } -type FetchResult = - | { success: true; data: IssueListFetchResult } - | { success: false; error: Error }; +type FetchResult = FetchResultOf; /** * Fetch issues for a single target project. @@ -526,52 +479,18 @@ 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)); + return trimWithGroupGuarantee( + issues, + limit, + (r) => `${r.orgSlug}/${r.formatOptions.projectSlug ?? ""}` + ); } /** Build the CLI hint for fetching the next page, preserving active flags. */ diff --git a/src/lib/alias.ts b/src/lib/alias.ts index bfc22da46..dd6d4845f 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 { ResolvedTarget } from "./resolve-target.js"; +import type { ProjectAliasEntry } from "../types/index.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/db/pagination.ts b/src/lib/db/pagination.ts index 3db955f80..0a676bc83 100644 --- a/src/lib/db/pagination.ts +++ b/src/lib/db/pagination.ts @@ -460,3 +460,26 @@ export function buildMultiTargetContextKey( (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 3133cf7fe..c4ddbb41f 100644 --- a/src/lib/org-list.ts +++ b/src/lib/org-list.ts @@ -133,6 +133,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. * From 76bbdb75a8efe95573d642d49b2a8ca0ab682dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 27 Mar 2026 12:58:59 +0100 Subject: [PATCH 10/21] fix(alert/metrics): skip listProjects for org-all/explicit modes For metric alerts (org-scoped), explicit and org-all modes already provide the org slug in the parsed arg. Routing them through resolveTargetsFromParsedArg caused an unnecessary listProjects API call just to re-derive the org slug. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/alert/metrics/list.ts | 38 +++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/commands/alert/metrics/list.ts b/src/commands/alert/metrics/list.ts index 239e399c1..f0b53c0fa 100644 --- a/src/commands/alert/metrics/list.ts +++ b/src/commands/alert/metrics/list.ts @@ -288,10 +288,33 @@ type ResolvedOrgsOptions = { }; /** - * Handle auto-detect, explicit, and project-search modes. + * Resolve the org slug(s) for a metric alert listing. * - * All three share the same flow: resolve targets → deduplicate to unique orgs - * → fetch within budget → compound cursor per org → display. + * 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( @@ -299,16 +322,13 @@ async function handleResolvedOrgs( ): Promise { const { parsed, flags, cwd } = options; - const { targets, footer } = await resolveTargetsFromParsedArg(parsed, { - cwd, - usageHint: USAGE_HINT, - }); + const { orgs: resolved, footer } = await resolveOrgs(parsed, cwd); - if (targets.length === 0) { + if (resolved.length === 0) { throw new ContextError("Organization", USAGE_HINT); } - const uniqueOrgs = [...new Set(targets.map((t: ResolvedTarget) => t.org))]; + const uniqueOrgs = [...new Set(resolved)]; const contextKey = buildMultiOrgContextKey(uniqueOrgs, flags.query); const sortedOrgKeys = [...uniqueOrgs].sort(); From 9682a61a8be20844d1e0d2e733c32bf9963f71d7 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 23 Apr 2026 23:10:04 +0800 Subject: [PATCH 11/21] fix(alert): preserve pagination state in alert list results Correct hasMore computation in alert issue/metric list flows so exact-limit pages without a next cursor don't report false positives, and filtered-empty pages still retain next-page availability. Add regression tests for both commands and clean up a leftover issue list merge marker/imports that blocked command loading. Made-with: Cursor --- src/commands/alert/issues/list.ts | 4 +- src/commands/alert/metrics/list.ts | 4 +- src/commands/issue/list.ts | 10 +- test/commands/alert/issues/list.test.ts | 161 ++++++++++++++++++++++ test/commands/alert/metrics/list.test.ts | 163 +++++++++++++++++++++++ 5 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 test/commands/alert/issues/list.test.ts create mode 100644 test/commands/alert/metrics/list.test.ts diff --git a/src/commands/alert/issues/list.ts b/src/commands/alert/issues/list.ts index 39de5c621..2ac86afcf 100644 --- a/src/commands/alert/issues/list.ts +++ b/src/commands/alert/issues/list.ts @@ -149,7 +149,7 @@ async function fetchRulesForTarget( return { target, rules, - hasMore: true, + hasMore: !!nextCursor, nextCursor: nextCursor ?? undefined, }; } @@ -468,7 +468,7 @@ async function handleResolvedTargets( const hint = footer ? `No issue alert rules found.\n\n${footer}` : "No issue alert rules found."; - return { items: [], hint, hasMore: false, hasPrev }; + return { items: [], hint, hasMore: hasMoreToShow, hasPrev }; } const title = diff --git a/src/commands/alert/metrics/list.ts b/src/commands/alert/metrics/list.ts index f0b53c0fa..bfc8ee489 100644 --- a/src/commands/alert/metrics/list.ts +++ b/src/commands/alert/metrics/list.ts @@ -139,7 +139,7 @@ async function fetchRulesForOrg( return { orgSlug, rules, - hasMore: true, + hasMore: !!nextCursor, nextCursor: nextCursor ?? undefined, }; } @@ -455,7 +455,7 @@ async function handleResolvedOrgs( const hint = footer ? `No metric alert rules found.\n\n${footer}` : "No metric alert rules found."; - return { items: [], hint, hasMore: false, hasPrev }; + return { items: [], hint, hasMore: hasMoreToShow, hasPrev }; } const title = diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index fcd391cef..3cbe23020 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -24,7 +24,11 @@ import { import { getActiveEnvVarName, isEnvTokenActive } from "../../lib/db/auth.js"; import { advancePaginationState, + buildMultiTargetContextKey, buildPaginationContextKey, + CURSOR_SEP, + decodeCompoundCursor, + encodeCompoundCursor, hasPreviousPage, resolveCursor, } from "../../lib/db/pagination.js"; @@ -70,7 +74,10 @@ import { trimWithGroupGuarantee, } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; -import type { ResolvedTarget } from "../../lib/resolve-target.js"; +import { + type ResolvedTarget, + resolveTargetsFromParsedArg, +} from "../../lib/resolve-target.js"; import { SEARCH_SYNTAX_REFERENCE, sanitizeQuery, @@ -882,7 +889,6 @@ 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. - <<<<<<< HEAD const contextKey = buildMultiTargetContextKey(targets, flags, timeRange); // Resolve per-target start cursors from the stored compound cursor (--cursor resume). diff --git a/test/commands/alert/issues/list.test.ts b/test/commands/alert/issues/list.test.ts new file mode 100644 index 000000000..a0a916c46 --- /dev/null +++ b/test/commands/alert/issues/list.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { 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); + }); +}); 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); + }); +}); From 9b4c5def24ee4af5baa87d67bc5a137c1e64d490 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 23 Apr 2026 23:14:23 +0800 Subject: [PATCH 12/21] feat(alert): add issue and metric alert view commands Add alert rule detail fetch endpoints and new `alert issues view` / `alert metrics view` commands with ID-or-name resolution across resolved targets, plus docs/skill reference updates for the new subcommands. Made-with: Cursor --- docs/src/content/docs/contributing.md | 1 + plugins/sentry-cli/skills/sentry-cli/SKILL.md | 4 +- .../skills/sentry-cli/references/alert.md | 32 ++- src/commands/alert/issues/index.ts | 7 +- src/commands/alert/issues/view.ts | 234 ++++++++++++++++++ src/commands/alert/metrics/index.ts | 7 +- src/commands/alert/metrics/view.ts | 230 +++++++++++++++++ src/commands/issue/list.ts | 6 +- src/lib/api-client.ts | 2 + src/lib/api/alerts.ts | 40 +++ 10 files changed, 549 insertions(+), 14 deletions(-) create mode 100644 src/commands/alert/issues/view.ts create mode 100644 src/commands/alert/metrics/view.ts diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index de345bd14..6e5cfa8a8 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -49,6 +49,7 @@ cli/ │ ├── app.ts # Stricli application setup │ ├── context.ts # Dependency injection context │ ├── commands/ # CLI commands +│ │ ├── alert/ # list, view, list, view │ │ ├── auth/ # login, logout, refresh, status, token, whoami │ │ ├── cli/ # defaults, feedback, fix, setup, upgrade │ │ ├── dashboard/ # list, view, create, add, edit, delete diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 5cecf5404..92738f0c8 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -326,7 +326,9 @@ Make an authenticated API request Manage Sentry alert rules - `sentry alert issues list ` — List issue alert rules -- `sentry alert metrics list ` — List metric alert rules +- `sentry alert issues view ` — View an issue alert rule +- `sentry alert metrics list ` — List metric alert rules +- `sentry alert metrics view ` — View a metric alert rule → Full flags and examples: `references/alert.md` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/plugins/sentry-cli/skills/sentry-cli/references/alert.md index 7f5a080af..d159a55a7 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -1,13 +1,13 @@ --- name: sentry-cli-alert -version: 0.21.0-dev.0 -description: Sentry CLI alert commands +version: 0.29.0-dev.0 +description: Manage Sentry alert rules requires: bins: ["sentry"] auth: true --- -# alert Commands +# Alert Commands Manage Sentry alert rules @@ -17,18 +17,34 @@ List issue alert rules **Flags:** - `-w, --web - Open in browser` -- `-n, --limit - Maximum number of issue alert rules to list - (default: "30")` +- `-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` -- `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` -### `sentry alert metrics list ` +### `sentry alert issues view ` + +View an issue alert rule + +**Flags:** +- `-w, --web - Open issue alert rules page in browser` + +### `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: "30")` +- `-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` -- `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` + +### `sentry alert metrics view ` + +View a metric alert rule + +**Flags:** +- `-w, --web - Open metric alert rules page in browser` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/src/commands/alert/issues/index.ts b/src/commands/alert/issues/index.ts index b084f90d1..2e4d37068 100644 --- a/src/commands/alert/issues/index.ts +++ b/src/commands/alert/issues/index.ts @@ -1,16 +1,19 @@ -import { buildRouteMap } from "@stricli/core"; +import { buildRouteMap } from "../../../lib/route-map.js"; import { listCommand } from "./list.js"; +import { viewCommand } from "./view.js"; export const issuesRoute = buildRouteMap({ routes: { list: listCommand, + view: viewCommand, }, 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", + " list List issue alert rules\n" + + " view View issue alert rule details", hideRoute: {}, }, }); diff --git a/src/commands/alert/issues/view.ts b/src/commands/alert/issues/view.ts new file mode 100644 index 000000000..e0f864f78 --- /dev/null +++ b/src/commands/alert/issues/view.ts @@ -0,0 +1,234 @@ +import type { SentryContext } from "../../../context.js"; +import { MAX_PAGINATION_PAGES } from "../../../lib/api/infrastructure.js"; +import { + API_MAX_PER_PAGE, + getIssueAlertRule, + type IssueAlertRule, + listIssueAlertsPaginated, +} from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { openInBrowser } from "../../../lib/browser.js"; +import { buildCommand } from "../../../lib/command.js"; +import { + ContextError, + ResolutionError, + ValidationError, +} from "../../../lib/errors.js"; +import { CommandOutput } from "../../../lib/formatters/output.js"; +import { fuzzyMatch } from "../../../lib/fuzzy.js"; +import { + type ResolvedTarget, + resolveTargetsFromParsedArg, +} from "../../../lib/resolve-target.js"; +import { buildIssueAlertsUrl } from "../../../lib/sentry-urls.js"; +import { isAllDigits } from "../../../lib/utils.js"; + +const USAGE_HINT = "sentry alert issues view //"; + +type ViewFlags = { + readonly web: boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type IssueAlertViewResult = { + target: ResolvedTarget; + rule: IssueAlertRule; +}; + +function parseIssueViewArg(arg: string): { + ref: string; + targetArg: string | undefined; +} { + if (!arg.includes("/")) { + return { ref: arg.trim(), targetArg: undefined }; + } + + const slash = arg.lastIndexOf("/"); + const targetPart = arg.slice(0, slash).trim(); + const ref = arg.slice(slash + 1).trim(); + if (!ref) { + throw new ValidationError( + `Invalid rule reference '${arg}'.\nUse: ${USAGE_HINT}` + ); + } + return { ref, targetArg: targetPart || undefined }; +} + +async function findRuleByName( + target: ResolvedTarget, + ref: string +): 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); + + const exact = data.find( + (rule) => rule.name.toLowerCase() === ref.toLowerCase() + ); + if (exact) { + return exact; + } + + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + + if (all.length === 0) { + return null; + } + + const names = all.map((rule) => rule.name); + const [match] = fuzzyMatch(ref, names, { maxResults: 1 }); + if (!match) { + return null; + } + return all.find((rule) => rule.name === match) ?? null; +} + +async function resolveIssueAlertRule( + targets: ResolvedTarget[], + ref: string +): Promise { + if (isAllDigits(ref)) { + const hits: IssueAlertViewResult[] = []; + for (const target of targets) { + try { + const rule = await getIssueAlertRule(target.org, target.project, ref); + hits.push({ target, rule }); + } catch { + // Best effort across targets. + } + } + + if (hits.length === 1) { + return hits[0] as IssueAlertViewResult; + } + if (hits.length > 1) { + throw new ValidationError( + `Alert rule ID '${ref}' matched multiple projects.\n` + + "Use an explicit target: sentry alert issues view //" + ); + } + throw new ResolutionError( + `Issue alert rule '${ref}'`, + "not found", + USAGE_HINT + ); + } + + const hits: IssueAlertViewResult[] = []; + for (const target of targets) { + const rule = await findRuleByName(target, ref); + if (rule) { + hits.push({ target, rule }); + } + } + + if (hits.length === 1) { + return hits[0] as IssueAlertViewResult; + } + if (hits.length > 1) { + throw new ValidationError( + `Alert rule name '${ref}' matched multiple projects.\n` + + "Use an explicit target: sentry alert issues view //" + ); + } + + throw new ResolutionError( + `Issue alert rule '${ref}'`, + "not found", + USAGE_HINT + ); +} + +function formatIssueAlertView(data: IssueAlertViewResult): 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 } = parseIssueViewArg(arg); + 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); + + 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/index.ts b/src/commands/alert/metrics/index.ts index b28f33f42..b8d2978c2 100644 --- a/src/commands/alert/metrics/index.ts +++ b/src/commands/alert/metrics/index.ts @@ -1,16 +1,19 @@ -import { buildRouteMap } from "@stricli/core"; +import { buildRouteMap } from "../../../lib/route-map.js"; import { listCommand } from "./list.js"; +import { viewCommand } from "./view.js"; export const metricsRoute = buildRouteMap({ routes: { list: listCommand, + view: viewCommand, }, 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", + " list List metric alert rules\n" + + " view View metric alert rule details", hideRoute: {}, }, }); diff --git a/src/commands/alert/metrics/view.ts b/src/commands/alert/metrics/view.ts new file mode 100644 index 000000000..a186dca8d --- /dev/null +++ b/src/commands/alert/metrics/view.ts @@ -0,0 +1,230 @@ +import type { SentryContext } from "../../../context.js"; +import { MAX_PAGINATION_PAGES } from "../../../lib/api/infrastructure.js"; +import { + API_MAX_PER_PAGE, + getMetricAlertRule, + listMetricAlertsPaginated, + type MetricAlertRule, +} from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { openInBrowser } from "../../../lib/browser.js"; +import { buildCommand } from "../../../lib/command.js"; +import { + ContextError, + ResolutionError, + ValidationError, +} from "../../../lib/errors.js"; +import { CommandOutput } from "../../../lib/formatters/output.js"; +import { fuzzyMatch } from "../../../lib/fuzzy.js"; +import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; +import { buildMetricAlertsUrl } from "../../../lib/sentry-urls.js"; +import { isAllDigits } from "../../../lib/utils.js"; + +const USAGE_HINT = "sentry alert metrics view /"; + +type ViewFlags = { + readonly web: boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +type MetricAlertViewResult = { + orgSlug: string; + rule: MetricAlertRule; +}; + +function parseMetricViewArg(arg: string): { + ref: string; + targetArg: string | undefined; +} { + if (!arg.includes("/")) { + return { ref: arg.trim(), targetArg: undefined }; + } + + const slash = arg.lastIndexOf("/"); + const targetPart = arg.slice(0, slash).trim(); + const ref = arg.slice(slash + 1).trim(); + if (!ref) { + throw new ValidationError( + `Invalid rule reference '${arg}'.\nUse: ${USAGE_HINT}` + ); + } + return { ref, targetArg: targetPart || undefined }; +} + +async function findRuleByName( + orgSlug: string, + ref: 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); + + const exact = data.find( + (rule) => rule.name.toLowerCase() === ref.toLowerCase() + ); + if (exact) { + return exact; + } + + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + + if (all.length === 0) { + return null; + } + + const names = all.map((rule) => rule.name); + const [match] = fuzzyMatch(ref, names, { maxResults: 1 }); + if (!match) { + return null; + } + return all.find((rule) => rule.name === match) ?? null; +} + +async function resolveMetricAlertRule( + orgSlugs: string[], + ref: string +): Promise { + if (isAllDigits(ref)) { + const hits: MetricAlertViewResult[] = []; + for (const orgSlug of orgSlugs) { + try { + const rule = await getMetricAlertRule(orgSlug, ref); + hits.push({ orgSlug, rule }); + } catch { + // Continue searching other orgs. + } + } + + if (hits.length === 1) { + return hits[0] as MetricAlertViewResult; + } + if (hits.length > 1) { + throw new ValidationError( + `Alert rule ID '${ref}' matched multiple organizations.\n` + + "Use an explicit target: sentry alert metrics view /" + ); + } + throw new ResolutionError( + `Metric alert rule '${ref}'`, + "not found", + USAGE_HINT + ); + } + + const hits: MetricAlertViewResult[] = []; + for (const orgSlug of orgSlugs) { + const rule = await findRuleByName(orgSlug, ref); + if (rule) { + hits.push({ orgSlug, rule }); + } + } + + if (hits.length === 1) { + return hits[0] as MetricAlertViewResult; + } + if (hits.length > 1) { + throw new ValidationError( + `Alert rule name '${ref}' matched multiple organizations.\n` + + "Use an explicit target: sentry alert metrics view /" + ); + } + + throw new ResolutionError( + `Metric alert rule '${ref}'`, + "not found", + USAGE_HINT + ); +} + +function formatMetricAlertView(data: MetricAlertViewResult): 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 } = parseMetricViewArg(arg); + 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); + if (flags.web) { + await openInBrowser( + buildMetricAlertsUrl(result.orgSlug), + "metric alert rules" + ); + return; + } + + yield new CommandOutput(result); + }, +}); diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 3cbe23020..75bea9748 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -889,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/api-client.ts b/src/lib/api-client.ts index e532fc831..87cb5e640 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -20,6 +20,8 @@ */ export { + getIssueAlertRule, + getMetricAlertRule, type IssueAlertRule, listIssueAlertsPaginated, listMetricAlertsPaginated, diff --git a/src/lib/api/alerts.ts b/src/lib/api/alerts.ts index 3c2406bbc..f61de0898 100644 --- a/src/lib/api/alerts.ts +++ b/src/lib/api/alerts.ts @@ -76,6 +76,27 @@ export async function listIssueAlertsPaginated( return { data, nextCursor }; } +/** + * 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 regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion( + regionUrl, + `/projects/${orgSlug}/${projectSlug}/rules/${ruleId}/` + ); + return data; +} + // --------------------------------------------------------------------------- // Metric alerts // --------------------------------------------------------------------------- @@ -100,3 +121,22 @@ export async function listMetricAlertsPaginated( const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); return { data, nextCursor }; } + +/** + * 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 regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/alert-rules/${ruleId}/` + ); + return data; +} From 9283c59fea3a72132b391372e0a260314ca05f57 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 23 Apr 2026 23:18:28 +0800 Subject: [PATCH 13/21] fix(ci): satisfy alert route lint and fragment checks Switch alert route wiring to the telemetry-aware route-map wrapper and add the missing alert command fragment so generated docs validation passes in CI. Made-with: Cursor --- docs/src/fragments/commands/alert.md | 40 +++++++++++++++++++ .../skills/sentry-cli/references/alert.md | 37 +++++++++++++++++ src/commands/alert/index.ts | 2 +- 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 docs/src/fragments/commands/alert.md diff --git a/docs/src/fragments/commands/alert.md b/docs/src/fragments/commands/alert.md new file mode 100644 index 000000000..4325fbf05 --- /dev/null +++ b/docs/src/fragments/commands/alert.md @@ -0,0 +1,40 @@ + + +## Examples + +### 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" +``` + +### 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" +``` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/plugins/sentry-cli/skills/sentry-cli/references/alert.md index d159a55a7..42c849c0e 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -22,6 +22,16 @@ List issue alert rules - `-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 @@ -29,6 +39,16 @@ 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 metrics list ` List metric alert rules @@ -40,6 +60,13 @@ List metric alert rules - `-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 @@ -47,4 +74,14 @@ 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" +``` + All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/src/commands/alert/index.ts b/src/commands/alert/index.ts index 9accc6055..4f49e0711 100644 --- a/src/commands/alert/index.ts +++ b/src/commands/alert/index.ts @@ -1,4 +1,4 @@ -import { buildRouteMap } from "@stricli/core"; +import { buildRouteMap } from "../../lib/route-map.js"; import { issuesRoute } from "./issues/index.js"; import { metricsRoute } from "./metrics/index.js"; From ed3069f4789d4aba95df1b16c59e3a027ed46bc2 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 23 Apr 2026 23:48:33 +0800 Subject: [PATCH 14/21] fix(alert): tighten view resolution and list hasMore - View: fail fast on malformed org/project, suppress only 404 in numeric lookup, name match exact then suggestions (no auto fuzzy pick) - List: preserve hasMore from phase-1 when phase-2 is skipped; drop dead footerMode; remove unused isMultiOrg - Add view tests and phase1HasMore list regression test Made-with: Cursor --- src/commands/alert/issues/list.ts | 24 ++-- src/commands/alert/issues/view.ts | 99 +++++++++++------ src/commands/alert/metrics/list.ts | 25 ++--- src/commands/alert/metrics/view.ts | 91 +++++++++------ test/commands/alert/issues/list.test.ts | 34 +++++- test/commands/alert/issues/view.test.ts | 134 +++++++++++++++++++++++ test/commands/alert/metrics/view.test.ts | 130 ++++++++++++++++++++++ 7 files changed, 442 insertions(+), 95 deletions(-) create mode 100644 test/commands/alert/issues/view.test.ts create mode 100644 test/commands/alert/metrics/view.test.ts diff --git a/src/commands/alert/issues/list.ts b/src/commands/alert/issues/list.ts index 2ac86afcf..31a0ae88f 100644 --- a/src/commands/alert/issues/list.ts +++ b/src/commands/alert/issues/list.ts @@ -105,7 +105,6 @@ type AlertRuleRow = { rule: IssueAlertRule; target: ResolvedTarget }; type IssueAlertListResult = ListResult & { displayRows?: AlertRuleRow[]; title?: string; - footerMode?: "single" | "multi" | "none"; moreHint?: string; footer?: string; }; @@ -216,6 +215,11 @@ async function runPhase2( } } +/** 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. * @@ -251,7 +255,7 @@ async function fetchWithBudget( if (surplus <= 0) { return { results: phase1, - hasMore: phase1.some((r) => r.success && r.data.hasMore), + hasMore: phase1HasMore(phase1), }; } @@ -264,7 +268,10 @@ async function fetchWithBudget( } if (expandableIndices.length === 0) { - return { results: phase1, hasMore: false }; + return { + results: phase1, + hasMore: phase1HasMore(phase1), + }; } await runPhase2(targets, phase1, expandableIndices, { surplus, options }); @@ -279,7 +286,7 @@ async function fetchWithBudget( return { results: phase1, - hasMore: phase1.some((r) => r.success && r.data.hasMore), + hasMore: phase1HasMore(phase1), }; } @@ -476,13 +483,6 @@ async function handleResolvedTargets( ? `Issue alert rules in ${firstTarget.orgDisplay}/${firstTarget.projectDisplay}` : `Issue alert rules from ${validResults.length} projects`; - let footerMode: "single" | "multi" | "none" = "none"; - if (isMultiProject) { - footerMode = "multi"; - } else if (isSingleProject) { - footerMode = "single"; - } - let moreHint: string | undefined; if (hasMoreToShow) { const higherLimit = Math.min(flags.limit * 2, MAX_LIMIT); @@ -509,7 +509,6 @@ async function handleResolvedTargets( hasPrev, displayRows, title, - footerMode, moreHint, footer, }; @@ -691,6 +690,7 @@ export const __testing = { decodeCompoundCursor, buildMultiTargetContextKey, buildProjectAliasMap, + phase1HasMore, CURSOR_SEP, MAX_LIMIT, }; diff --git a/src/commands/alert/issues/view.ts b/src/commands/alert/issues/view.ts index e0f864f78..7d21f1752 100644 --- a/src/commands/alert/issues/view.ts +++ b/src/commands/alert/issues/view.ts @@ -10,6 +10,7 @@ import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { buildCommand } from "../../../lib/command.js"; import { + ApiError, ContextError, ResolutionError, ValidationError, @@ -36,32 +37,53 @@ type IssueAlertViewResult = { 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. + */ function parseIssueViewArg(arg: string): { ref: string; targetArg: string | undefined; } { - if (!arg.includes("/")) { - return { ref: arg.trim(), targetArg: undefined }; + const trimmed = arg.trim(); + if (!trimmed) { + throw new ValidationError( + `Rule id or name is required.\nUse: ${USAGE_HINT}`, + "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: ${USAGE_HINT}\n` + + `Example: ${trimmed}/`, + "rule" + ); } - const slash = arg.lastIndexOf("/"); - const targetPart = arg.slice(0, slash).trim(); - const ref = arg.slice(slash + 1).trim(); + 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: ${USAGE_HINT}` + `Invalid rule reference '${arg}'.\nUse: ${USAGE_HINT}`, + "rule" ); } return { ref, targetArg: targetPart || undefined }; } -async function findRuleByName( - target: ResolvedTarget, - ref: string -): Promise { +/** List all issue alert rules for a project (paginated). */ +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, @@ -69,32 +91,19 @@ async function findRuleByName( { perPage: API_MAX_PER_PAGE, cursor } ); all.push(...data); - - const exact = data.find( - (rule) => rule.name.toLowerCase() === ref.toLowerCase() - ); - if (exact) { - return exact; - } - if (!nextCursor) { break; } cursor = nextCursor; } + return all; +} - if (all.length === 0) { - return null; - } - - const names = all.map((rule) => rule.name); - const [match] = fuzzyMatch(ref, names, { maxResults: 1 }); - if (!match) { - return null; - } - return all.find((rule) => rule.name === match) ?? null; +function isNotFoundApiError(error: unknown): boolean { + return error instanceof ApiError && error.status === 404; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per-target list + name resolution + disambiguation (same as metrics view) async function resolveIssueAlertRule( targets: ResolvedTarget[], ref: string @@ -105,8 +114,11 @@ async function resolveIssueAlertRule( try { const rule = await getIssueAlertRule(target.org, target.project, ref); hits.push({ target, rule }); - } catch { - // Best effort across targets. + } catch (e) { + if (isNotFoundApiError(e)) { + continue; + } + throw e; } } @@ -127,10 +139,18 @@ async function resolveIssueAlertRule( } const hits: IssueAlertViewResult[] = []; + const allRuleNames: string[] = []; + for (const target of targets) { - const rule = await findRuleByName(target, ref); - if (rule) { - hits.push({ target, rule }); + 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 }); } } @@ -144,6 +164,17 @@ async function resolveIssueAlertRule( ); } + 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", diff --git a/src/commands/alert/metrics/list.ts b/src/commands/alert/metrics/list.ts index bfc8ee489..9a1d8fc62 100644 --- a/src/commands/alert/metrics/list.ts +++ b/src/commands/alert/metrics/list.ts @@ -99,7 +99,6 @@ type MetricAlertRow = { rule: MetricAlertRule; orgSlug: string }; type MetricAlertListResult = ListResult & { displayRows?: MetricAlertRow[]; title?: string; - footerMode?: "single" | "multi" | "none"; moreHint?: string; footer?: string; }; @@ -199,6 +198,11 @@ async function runPhase2( } } +/** 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. * @@ -234,7 +238,7 @@ async function fetchWithBudget( if (surplus <= 0) { return { results: phase1, - hasMore: phase1.some((r) => r.success && r.data.hasMore), + hasMore: phase1HasMore(phase1), }; } @@ -247,7 +251,10 @@ async function fetchWithBudget( } if (expandableIndices.length === 0) { - return { results: phase1, hasMore: false }; + return { + results: phase1, + hasMore: phase1HasMore(phase1), + }; } await runPhase2(orgs, phase1, expandableIndices, surplus); @@ -262,7 +269,7 @@ async function fetchWithBudget( return { results: phase1, - hasMore: phase1.some((r) => r.success && r.data.hasMore), + hasMore: phase1HasMore(phase1), }; } @@ -430,7 +437,6 @@ async function handleResolvedOrgs( ); } - const isMultiOrg = validResults.length > 1; const isSingleOrg = validResults.length === 1; const firstOrg = validResults[0]?.orgSlug; @@ -463,13 +469,6 @@ async function handleResolvedOrgs( ? `Metric alert rules in ${firstOrg}` : `Metric alert rules from ${validResults.length} organizations`; - let footerMode: "single" | "multi" | "none" = "none"; - if (isMultiOrg) { - footerMode = "multi"; - } else if (isSingleOrg) { - footerMode = "single"; - } - let moreHint: string | undefined; if (hasMoreToShow) { const higherLimit = Math.min(flags.limit * 2, MAX_LIMIT); @@ -496,7 +495,6 @@ async function handleResolvedOrgs( hasPrev, displayRows, title, - footerMode, moreHint, footer, }; @@ -681,6 +679,7 @@ export const __testing = { encodeCompoundCursor, decodeCompoundCursor, buildMultiOrgContextKey, + phase1HasMore, CURSOR_SEP, MAX_LIMIT, }; diff --git a/src/commands/alert/metrics/view.ts b/src/commands/alert/metrics/view.ts index a186dca8d..a68e0b709 100644 --- a/src/commands/alert/metrics/view.ts +++ b/src/commands/alert/metrics/view.ts @@ -10,6 +10,7 @@ import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { buildCommand } from "../../../lib/command.js"; import { + ApiError, ContextError, ResolutionError, ValidationError, @@ -33,64 +34,62 @@ type MetricAlertViewResult = { rule: MetricAlertRule; }; +/** + * Parse `org/` (one slash), or bare `rule-id-or-name` for auto-detect. + * Trailing-only org is invalid (empty ref). + */ function parseMetricViewArg(arg: string): { ref: string; targetArg: string | undefined; } { - if (!arg.includes("/")) { - return { ref: arg.trim(), targetArg: undefined }; + const trimmed = arg.trim(); + if (!trimmed) { + throw new ValidationError( + `Rule id or name is required.\nUse: ${USAGE_HINT}`, + "rule" + ); + } + + if (!trimmed.includes("/")) { + return { ref: trimmed, targetArg: undefined }; } - const slash = arg.lastIndexOf("/"); - const targetPart = arg.slice(0, slash).trim(); - const ref = arg.slice(slash + 1).trim(); + 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: ${USAGE_HINT}` + `Invalid rule reference '${arg}' (missing id or name after org).\nUse: ${USAGE_HINT}`, + "rule" ); } return { ref, targetArg: targetPart || undefined }; } -async function findRuleByName( - orgSlug: string, - ref: string -): Promise { +function isNotFoundApiError(error: unknown): boolean { + return error instanceof ApiError && error.status === 404; +} + +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); - - const exact = data.find( - (rule) => rule.name.toLowerCase() === ref.toLowerCase() - ); - if (exact) { - return exact; - } - if (!nextCursor) { break; } cursor = nextCursor; } - - if (all.length === 0) { - return null; - } - - const names = all.map((rule) => rule.name); - const [match] = fuzzyMatch(ref, names, { maxResults: 1 }); - if (!match) { - return null; - } - return all.find((rule) => rule.name === match) ?? null; + return all; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per-org list + name resolution + disambiguation (same as issues view) async function resolveMetricAlertRule( orgSlugs: string[], ref: string @@ -101,8 +100,11 @@ async function resolveMetricAlertRule( try { const rule = await getMetricAlertRule(orgSlug, ref); hits.push({ orgSlug, rule }); - } catch { - // Continue searching other orgs. + } catch (e) { + if (isNotFoundApiError(e)) { + continue; + } + throw e; } } @@ -123,10 +125,18 @@ async function resolveMetricAlertRule( } const hits: MetricAlertViewResult[] = []; + const allNames: string[] = []; + for (const orgSlug of orgSlugs) { - const rule = await findRuleByName(orgSlug, ref); - if (rule) { - hits.push({ orgSlug, rule }); + 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 }); } } @@ -140,6 +150,17 @@ async function resolveMetricAlertRule( ); } + 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", diff --git a/test/commands/alert/issues/list.test.ts b/test/commands/alert/issues/list.test.ts index a0a916c46..78490621b 100644 --- a/test/commands/alert/issues/list.test.ts +++ b/test/commands/alert/issues/list.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { listCommand } from "../../../../src/commands/alert/issues/list.js"; +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 { @@ -159,3 +162,32 @@ describe("alert issues list pagination", () => { 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/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(); + }); +}); From 4178c177a2b9650a0eb7356e5ecc4ef589eae636 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 24 Apr 2026 00:08:03 +0800 Subject: [PATCH 15/21] feat(alert): add delete and edit for issue and metric alert rules - API: delete/put helpers, apiRequestToRegionNoContent for empty responses - Shared rule-resolve modules for view/delete/edit (parse + resolve) - issues|metrics delete: buildDeleteCommand, type org/project/id to confirm - issues|metrics edit: --name and --status (active|disabled) - Fix project-structure doc: dedupe subcommand label for nested routes (alert) - Regenerate SKILL + references/alert only; other skill refs left at HEAD to avoid unrelated example-date churn from generate:skill Made-with: Cursor --- docs/src/content/docs/contributing.md | 12 +- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 4 + .../skills/sentry-cli/references/alert.md | 34 ++++ script/generate-docs-sections.ts | 13 +- src/commands/alert/issues/delete.ts | 141 ++++++++++++++ src/commands/alert/issues/edit.ts | 153 +++++++++++++++ src/commands/alert/issues/index.ts | 10 +- src/commands/alert/issues/rule-resolve.ts | 173 +++++++++++++++++ src/commands/alert/issues/view.ts | 182 ++---------------- src/commands/alert/metrics/delete.ts | 117 +++++++++++ src/commands/alert/metrics/edit.ts | 142 ++++++++++++++ src/commands/alert/metrics/index.ts | 10 +- src/commands/alert/metrics/rule-resolve.ts | 161 ++++++++++++++++ src/commands/alert/metrics/view.ts | 166 +--------------- src/lib/api-client.ts | 7 + src/lib/api/alerts.ts | 102 ++++++++++ src/lib/api/infrastructure.ts | 56 ++++++ 17 files changed, 1142 insertions(+), 341 deletions(-) create mode 100644 src/commands/alert/issues/delete.ts create mode 100644 src/commands/alert/issues/edit.ts create mode 100644 src/commands/alert/issues/rule-resolve.ts create mode 100644 src/commands/alert/metrics/delete.ts create mode 100644 src/commands/alert/metrics/edit.ts create mode 100644 src/commands/alert/metrics/rule-resolve.ts diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 6e5cfa8a8..8ca21e9e0 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -49,21 +49,21 @@ cli/ │ ├── app.ts # Stricli application setup │ ├── context.ts # Dependency injection context │ ├── commands/ # CLI commands -│ │ ├── alert/ # list, view, list, view +│ │ ├── alert/ # 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/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 92738f0c8..f4adb6d9b 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -327,8 +327,12 @@ Manage Sentry alert rules - `sentry alert issues list ` — List issue alert rules - `sentry alert issues view ` — View 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 delete ` — Delete a metric alert rule +- `sentry alert metrics edit ` — Edit a metric alert rule → Full flags and examples: `references/alert.md` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/plugins/sentry-cli/skills/sentry-cli/references/alert.md index 42c849c0e..20928e9cd 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -49,6 +49,23 @@ sentry alert issues view my-org/my-project/12345 sentry alert issues view my-org/my-project/"Error Spike" ``` +### `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` + +### `sentry alert issues edit ` + +Edit an issue alert rule + +**Flags:** +- `--name - New rule name` +- `--status - Rule status: active or disabled` + ### `sentry alert metrics list ` List metric alert rules @@ -84,4 +101,21 @@ sentry alert metrics view my-org/67890 sentry alert metrics view my-org/"P95 latency alert" ``` +### `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` + +### `sentry alert metrics edit ` + +Edit a metric alert rule + +**Flags:** +- `--name - New rule name` +- `--status - active or disabled` + 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/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..79e7167fb --- /dev/null +++ b/src/commands/alert/issues/edit.ts @@ -0,0 +1,153 @@ +/** + * 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 } 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 { parseIssueRuleArg, resolveIssueAlertRule } from "./rule-resolve.js"; + +const USAGE_HINT = + "sentry alert issues edit // --name | --status active|disabled"; + +type EditFlags = { + readonly name?: string; + /** Present when --status was passed; omitted when the flag is absent. */ + readonly status?: "active" | "disabled" | undefined; + readonly json: boolean; + readonly fields?: string[]; +}; + +type EditResult = { + org: string; + project: string; + id: string; + name: string; + status: string; +}; + +function statusParser( + s: string | undefined +): "active" | "disabled" | undefined { + if (s === undefined || s === "") { + return; + } + const v = s.toLowerCase().trim(); + if (v === "active" || v === "disabled") { + return v; + } + throw new ValidationError( + `Status must be 'active' or 'disabled' (got ${JSON.stringify(s)}).`, + "status" + ); +} + +function formatEdited(r: EditResult): string { + return `Updated issue alert rule ${r.id} in ${r.org}/${r.project}: ${r.name} (${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", + }, + 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: statusParser, + optional: true, + brief: "Rule status: active or disabled", + }, + }, + }, + async *func(this: SentryContext, flags: EditFlags, arg: string) { + const { cwd } = this; + if (flags.name === undefined && flags.status === undefined) { + throw new ValidationError( + "Pass at least one of --name or --status to edit the rule.", + "name" + ); + } + + 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)), + }; + if (flags.name !== undefined) { + body.name = flags.name; + } + if (flags.status !== undefined) { + body.status = flags.status; + } + + const updated = await putIssueAlertRule( + target.org, + target.project, + rule.id, + body + ); + const name = String(updated.name ?? rule.name); + const status = String(updated.status ?? flags.status ?? rule.status); + + yield new CommandOutput({ + org: target.org, + project: target.project, + id: String(updated.id ?? rule.id), + name, + status, + } satisfies EditResult); + }, +}); diff --git a/src/commands/alert/issues/index.ts b/src/commands/alert/issues/index.ts index 2e4d37068..a8578a4ae 100644 --- a/src/commands/alert/issues/index.ts +++ b/src/commands/alert/issues/index.ts @@ -1,4 +1,6 @@ import { buildRouteMap } from "../../../lib/route-map.js"; +import { deleteCommand } from "./delete.js"; +import { editCommand } from "./edit.js"; import { listCommand } from "./list.js"; import { viewCommand } from "./view.js"; @@ -6,14 +8,18 @@ export const issuesRoute = buildRouteMap({ routes: { list: listCommand, view: viewCommand, + 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", + " list List issue alert rules\n" + + " view View issue alert rule details\n" + + " delete Delete an issue alert rule\n" + + " edit Update an issue alert rule (name, status)", hideRoute: {}, }, }); diff --git a/src/commands/alert/issues/rule-resolve.ts b/src/commands/alert/issues/rule-resolve.ts new file mode 100644 index 000000000..0c80c5a1d --- /dev/null +++ b/src/commands/alert/issues/rule-resolve.ts @@ -0,0 +1,173 @@ +/** + * 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, + listIssueAlertsPaginated, +} from "../../../lib/api-client.js"; +import { + ApiError, + 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; +} + +function isNotFoundApiError(error: unknown): boolean { + return error instanceof ApiError && error.status === 404; +} + +// 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 index 7d21f1752..335ff00a3 100644 --- a/src/commands/alert/issues/view.ts +++ b/src/commands/alert/issues/view.ts @@ -1,28 +1,16 @@ import type { SentryContext } from "../../../context.js"; -import { MAX_PAGINATION_PAGES } from "../../../lib/api/infrastructure.js"; -import { - API_MAX_PER_PAGE, - getIssueAlertRule, - type IssueAlertRule, - listIssueAlertsPaginated, -} from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { buildCommand } from "../../../lib/command.js"; -import { - ApiError, - ContextError, - ResolutionError, - ValidationError, -} from "../../../lib/errors.js"; +import { ContextError } from "../../../lib/errors.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; -import { fuzzyMatch } from "../../../lib/fuzzy.js"; -import { - type ResolvedTarget, - resolveTargetsFromParsedArg, -} from "../../../lib/resolve-target.js"; +import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; import { buildIssueAlertsUrl } from "../../../lib/sentry-urls.js"; -import { isAllDigits } from "../../../lib/utils.js"; +import { + type IssueRuleResolution, + parseIssueRuleArg, + resolveIssueAlertRule, +} from "./rule-resolve.js"; const USAGE_HINT = "sentry alert issues view //"; @@ -32,157 +20,9 @@ type ViewFlags = { readonly fields?: string[]; }; -type IssueAlertViewResult = { - 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. - */ -function parseIssueViewArg(arg: string): { - ref: string; - targetArg: string | undefined; -} { - const trimmed = arg.trim(); - if (!trimmed) { - throw new ValidationError( - `Rule id or name is required.\nUse: ${USAGE_HINT}`, - "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: ${USAGE_HINT}\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: ${USAGE_HINT}`, - "rule" - ); - } - return { ref, targetArg: targetPart || undefined }; -} - -/** List all issue alert rules for a project (paginated). */ -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; -} - -function isNotFoundApiError(error: unknown): boolean { - return error instanceof ApiError && error.status === 404; -} - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per-target list + name resolution + disambiguation (same as metrics view) -async function resolveIssueAlertRule( - targets: ResolvedTarget[], - ref: string -): Promise { - if (isAllDigits(ref)) { - const hits: IssueAlertViewResult[] = []; - 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 IssueAlertViewResult; - } - if (hits.length > 1) { - throw new ValidationError( - `Alert rule ID '${ref}' matched multiple projects.\n` + - "Use an explicit target: sentry alert issues view //" - ); - } - throw new ResolutionError( - `Issue alert rule '${ref}'`, - "not found", - USAGE_HINT - ); - } - - const hits: IssueAlertViewResult[] = []; - 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 IssueAlertViewResult; - } - if (hits.length > 1) { - throw new ValidationError( - `Alert rule name '${ref}' matched multiple projects.\n` + - "Use an explicit target: sentry alert issues view //" - ); - } - - 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", - USAGE_HINT - ); -} +type IssueAlertViewResult = IssueRuleResolution; -function formatIssueAlertView(data: IssueAlertViewResult): string { +function formatIssueAlertView(data: IssueRuleResolution): string { const { target, rule } = data; return [ `Issue alert rule in ${target.org}/${target.project}:`, @@ -239,7 +79,7 @@ export const viewCommand = buildCommand({ }, async *func(this: SentryContext, flags: ViewFlags, arg: string) { const { cwd } = this; - const { ref, targetArg } = parseIssueViewArg(arg); + const { ref, targetArg } = parseIssueRuleArg(arg, USAGE_HINT); const parsed = parseOrgProjectArg(targetArg); const { targets } = await resolveTargetsFromParsedArg(parsed, { @@ -250,7 +90,7 @@ export const viewCommand = buildCommand({ throw new ContextError("Organization and project", USAGE_HINT); } - const result = await resolveIssueAlertRule(targets, ref); + const result = await resolveIssueAlertRule(targets, ref, USAGE_HINT); if (flags.web) { await openInBrowser( 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..47d8dc652 --- /dev/null +++ b/src/commands/alert/metrics/edit.ts @@ -0,0 +1,142 @@ +/** + * 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 } 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 { 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 json: boolean; + readonly fields?: string[]; +}; + +type EditResult = { + org: string; + id: string; + name: string; + status: "active" | "disabled"; +}; + +function metricStatusParser( + s: string | undefined +): "active" | "disabled" | undefined { + if (s === undefined || s === "") { + return; + } + const v = s.toLowerCase().trim(); + if (v === "active" || v === "disabled") { + return v; + } + throw new ValidationError( + `Status must be 'active' or 'disabled' (got ${JSON.stringify(s)}).`, + "status" + ); +} + +function formatEdited(r: EditResult): string { + return `Updated metric alert rule ${r.id} in ${r.org}: ${r.name} (${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", + }, + 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: metricStatusParser, + optional: true, + brief: "active or disabled", + }, + }, + }, + async *func(this: SentryContext, flags: EditFlags, arg: string) { + const { cwd } = this; + if (flags.name === undefined && flags.status === undefined) { + throw new ValidationError( + "Pass at least one of --name or --status to edit the rule.", + "name" + ); + } + 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)), + }; + if (flags.name !== undefined) { + body.name = flags.name; + } + if (flags.status === "active") { + body.status = 0; + } else if (flags.status === "disabled") { + body.status = 1; + } + const updated = await putMetricAlertRule(orgSlug, rule.id, body); + const name = String(updated.name ?? rule.name); + const st = updated.status; + const status: "active" | "disabled" = + st === 0 || st === "0" ? "active" : "disabled"; + yield new CommandOutput({ + org: orgSlug, + id: String(updated.id ?? rule.id), + name, + status, + } satisfies EditResult); + }, +}); diff --git a/src/commands/alert/metrics/index.ts b/src/commands/alert/metrics/index.ts index b8d2978c2..572c52bca 100644 --- a/src/commands/alert/metrics/index.ts +++ b/src/commands/alert/metrics/index.ts @@ -1,4 +1,6 @@ import { buildRouteMap } from "../../../lib/route-map.js"; +import { deleteCommand } from "./delete.js"; +import { editCommand } from "./edit.js"; import { listCommand } from "./list.js"; import { viewCommand } from "./view.js"; @@ -6,14 +8,18 @@ export const metricsRoute = buildRouteMap({ routes: { list: listCommand, view: viewCommand, + 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", + " list List metric alert rules\n" + + " view View metric alert rule details\n" + + " delete Delete a metric alert rule\n" + + " edit Update a metric alert rule (name, status)", hideRoute: {}, }, }); diff --git a/src/commands/alert/metrics/rule-resolve.ts b/src/commands/alert/metrics/rule-resolve.ts new file mode 100644 index 000000000..dacb08be8 --- /dev/null +++ b/src/commands/alert/metrics/rule-resolve.ts @@ -0,0 +1,161 @@ +/** + * 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, + listMetricAlertsPaginated, + type MetricAlertRule, +} from "../../../lib/api-client.js"; +import { + ApiError, + 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 }; +} + +function isNotFoundApiError(error: unknown): boolean { + return error instanceof ApiError && error.status === 404; +} + +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 index a68e0b709..5775a82ca 100644 --- a/src/commands/alert/metrics/view.ts +++ b/src/commands/alert/metrics/view.ts @@ -1,25 +1,16 @@ import type { SentryContext } from "../../../context.js"; -import { MAX_PAGINATION_PAGES } from "../../../lib/api/infrastructure.js"; -import { - API_MAX_PER_PAGE, - getMetricAlertRule, - listMetricAlertsPaginated, - type MetricAlertRule, -} from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { buildCommand } from "../../../lib/command.js"; -import { - ApiError, - ContextError, - ResolutionError, - ValidationError, -} from "../../../lib/errors.js"; +import { ContextError } from "../../../lib/errors.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; -import { fuzzyMatch } from "../../../lib/fuzzy.js"; import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; import { buildMetricAlertsUrl } from "../../../lib/sentry-urls.js"; -import { isAllDigits } from "../../../lib/utils.js"; +import { + type MetricRuleResolution, + parseMetricRuleArg, + resolveMetricAlertRule, +} from "./rule-resolve.js"; const USAGE_HINT = "sentry alert metrics view /"; @@ -29,146 +20,9 @@ type ViewFlags = { readonly fields?: string[]; }; -type MetricAlertViewResult = { - orgSlug: string; - rule: MetricAlertRule; -}; - -/** - * Parse `org/` (one slash), or bare `rule-id-or-name` for auto-detect. - * Trailing-only org is invalid (empty ref). - */ -function parseMetricViewArg(arg: string): { - ref: string; - targetArg: string | undefined; -} { - const trimmed = arg.trim(); - if (!trimmed) { - throw new ValidationError( - `Rule id or name is required.\nUse: ${USAGE_HINT}`, - "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: ${USAGE_HINT}`, - "rule" - ); - } - return { ref, targetArg: targetPart || undefined }; -} - -function isNotFoundApiError(error: unknown): boolean { - return error instanceof ApiError && error.status === 404; -} - -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 + disambiguation (same as issues view) -async function resolveMetricAlertRule( - orgSlugs: string[], - ref: string -): Promise { - if (isAllDigits(ref)) { - const hits: MetricAlertViewResult[] = []; - 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 MetricAlertViewResult; - } - if (hits.length > 1) { - throw new ValidationError( - `Alert rule ID '${ref}' matched multiple organizations.\n` + - "Use an explicit target: sentry alert metrics view /" - ); - } - throw new ResolutionError( - `Metric alert rule '${ref}'`, - "not found", - USAGE_HINT - ); - } - - const hits: MetricAlertViewResult[] = []; - 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 MetricAlertViewResult; - } - if (hits.length > 1) { - throw new ValidationError( - `Alert rule name '${ref}' matched multiple organizations.\n` + - "Use an explicit target: sentry alert metrics view /" - ); - } - - 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", - USAGE_HINT - ); -} +type MetricAlertViewResult = MetricRuleResolution; -function formatMetricAlertView(data: MetricAlertViewResult): string { +function formatMetricAlertView(data: MetricRuleResolution): string { const { orgSlug, rule } = data; const status = rule.status === 0 ? "active" : "disabled"; return [ @@ -226,7 +80,7 @@ export const viewCommand = buildCommand({ }, async *func(this: SentryContext, flags: ViewFlags, arg: string) { const { cwd } = this; - const { ref, targetArg } = parseMetricViewArg(arg); + const { ref, targetArg } = parseMetricRuleArg(arg, USAGE_HINT); const parsed = parseOrgProjectArg(targetArg); const { targets } = await resolveTargetsFromParsedArg(parsed, { cwd, @@ -237,7 +91,7 @@ export const viewCommand = buildCommand({ throw new ContextError("Organization", USAGE_HINT); } - const result = await resolveMetricAlertRule(orgSlugs, ref); + const result = await resolveMetricAlertRule(orgSlugs, ref, USAGE_HINT); if (flags.web) { await openInBrowser( buildMetricAlertsUrl(result.orgSlug), diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 87cb5e640..1aaf71e50 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -20,12 +20,18 @@ */ export { + deleteIssueAlertRule, + deleteMetricAlertRule, getIssueAlertRule, + getIssueAlertRuleDocument, getMetricAlertRule, + getMetricAlertRuleDocument, type IssueAlertRule, listIssueAlertsPaginated, listMetricAlertsPaginated, type MetricAlertRule, + putIssueAlertRule, + putMetricAlertRule, } from "./api/alerts.js"; export { createDashboard, @@ -47,6 +53,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 index f61de0898..20745314d 100644 --- a/src/lib/api/alerts.ts +++ b/src/lib/api/alerts.ts @@ -9,6 +9,7 @@ import { resolveOrgRegion } from "../region.js"; import { apiRequestToRegion, + apiRequestToRegionNoContent, type PaginatedResponse, parseLinkHeader, } from "./infrastructure.js"; @@ -140,3 +141,104 @@ export async function getMetricAlertRule( ); return data; } + +// --------------------------------------------------------------------------- +// 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 async function getIssueAlertRuleDocument( + 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; +} + +/** + * 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; +} + +// --------------------------------------------------------------------------- +// 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 async function getMetricAlertRuleDocument( + orgSlug: string, + ruleId: string +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion>( + regionUrl, + `organizations/${orgSlug}/alert-rules/${encodeURIComponent(ruleId)}/` + ); + return data; +} + +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; +} 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. * From 6bb92f4b2b50fe781125925aa03fe09c21411a53 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 24 Apr 2026 00:20:09 +0800 Subject: [PATCH 16/21] refactor(alert): share 404 guard and single GET for alert rules - Add isNotFoundApiError in lib/api/error-guards (re-exported from api-client) - DRY fetchIssueAlertRuleJson / fetchMetricAlertRuleJson for get*Rule and get*Document - Re-exported from api-client, used by both rule-resolve modules Made-with: Cursor --- src/commands/alert/issues/rule-resolve.ts | 11 +--- src/commands/alert/metrics/rule-resolve.ts | 11 +--- src/lib/api-client.ts | 1 + src/lib/api/alerts.ts | 61 +++++++++++++--------- src/lib/api/error-guards.ts | 10 ++++ 5 files changed, 50 insertions(+), 44 deletions(-) create mode 100644 src/lib/api/error-guards.ts diff --git a/src/commands/alert/issues/rule-resolve.ts b/src/commands/alert/issues/rule-resolve.ts index 0c80c5a1d..eede27a7f 100644 --- a/src/commands/alert/issues/rule-resolve.ts +++ b/src/commands/alert/issues/rule-resolve.ts @@ -7,13 +7,10 @@ import { API_MAX_PER_PAGE, getIssueAlertRule, type IssueAlertRule, + isNotFoundApiError, listIssueAlertsPaginated, } from "../../../lib/api-client.js"; -import { - ApiError, - ResolutionError, - ValidationError, -} from "../../../lib/errors.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"; @@ -88,10 +85,6 @@ export async function listAllIssueRulesForTarget( return all; } -function isNotFoundApiError(error: unknown): boolean { - return error instanceof ApiError && error.status === 404; -} - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per-target list + name resolution export async function resolveIssueAlertRule( targets: ResolvedTarget[], diff --git a/src/commands/alert/metrics/rule-resolve.ts b/src/commands/alert/metrics/rule-resolve.ts index dacb08be8..6955c3d8d 100644 --- a/src/commands/alert/metrics/rule-resolve.ts +++ b/src/commands/alert/metrics/rule-resolve.ts @@ -6,14 +6,11 @@ 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 { - ApiError, - ResolutionError, - ValidationError, -} from "../../../lib/errors.js"; +import { ResolutionError, ValidationError } from "../../../lib/errors.js"; import { fuzzyMatch } from "../../../lib/fuzzy.js"; import { isAllDigits } from "../../../lib/utils.js"; @@ -57,10 +54,6 @@ export function parseMetricRuleArg( return { ref, targetArg: targetPart || undefined }; } -function isNotFoundApiError(error: unknown): boolean { - return error instanceof ApiError && error.status === 404; -} - export async function listAllMetricRulesForOrg( orgSlug: string ): Promise { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 1aaf71e50..c8979f37c 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -40,6 +40,7 @@ export { queryAllWidgets, updateDashboard, } from "./api/dashboards.js"; +export { isNotFoundApiError } from "./api/error-guards.js"; export { findEventAcrossOrgs, getEvent, diff --git a/src/lib/api/alerts.ts b/src/lib/api/alerts.ts index 20745314d..88a2dacac 100644 --- a/src/lib/api/alerts.ts +++ b/src/lib/api/alerts.ts @@ -77,6 +77,20 @@ export async function listIssueAlertsPaginated( 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. * @@ -90,12 +104,8 @@ export async function getIssueAlertRule( projectSlug: string, ruleId: string ): Promise { - const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion( - regionUrl, - `/projects/${orgSlug}/${projectSlug}/rules/${ruleId}/` - ); - return data; + const data = await fetchIssueAlertRuleJson(orgSlug, projectSlug, ruleId); + return data as IssueAlertRule; } // --------------------------------------------------------------------------- @@ -123,6 +133,19 @@ export async function listMetricAlertsPaginated( 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. * @@ -134,12 +157,8 @@ export async function getMetricAlertRule( orgSlug: string, ruleId: string ): Promise { - const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion( - regionUrl, - `/organizations/${orgSlug}/alert-rules/${ruleId}/` - ); - return data; + const data = await fetchMetricAlertRuleJson(orgSlug, ruleId); + return data as MetricAlertRule; } // --------------------------------------------------------------------------- @@ -167,17 +186,12 @@ export async function deleteIssueAlertRule( /** * Full document for PUT (includes conditions, actions, etc. from the API). */ -export async function getIssueAlertRuleDocument( +export function getIssueAlertRuleDocument( 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; + return fetchIssueAlertRuleJson(orgSlug, projectSlug, ruleId); } /** @@ -217,16 +231,11 @@ export async function deleteMetricAlertRule( ); } -export async function getMetricAlertRuleDocument( +export function getMetricAlertRuleDocument( orgSlug: string, ruleId: string ): Promise> { - const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion>( - regionUrl, - `organizations/${orgSlug}/alert-rules/${encodeURIComponent(ruleId)}/` - ); - return data; + return fetchMetricAlertRuleJson(orgSlug, ruleId); } export async function putMetricAlertRule( 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; +} From 348de423f6539e60e30d9f729461f767225d8fe9 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 24 Apr 2026 00:43:15 +0800 Subject: [PATCH 17/21] feat(alert): add create commands and expand edit payload updates Implement full alert CRUD coverage for issue and metric rules by adding create commands, broadening merge-based edit support, and updating API helpers, tests, and generated command docs. Made-with: Cursor --- docs/src/content/docs/contributing.md | 2 +- docs/src/fragments/commands/alert.md | 44 ++++ plugins/sentry-cli/skills/sentry-cli/SKILL.md | 2 + .../skills/sentry-cli/references/alert.md | 92 +++++++ .../skills/sentry-cli/references/dashboard.md | 2 +- .../skills/sentry-cli/references/event.md | 2 +- .../skills/sentry-cli/references/issue.md | 4 +- .../skills/sentry-cli/references/log.md | 2 +- .../skills/sentry-cli/references/span.md | 2 +- .../skills/sentry-cli/references/trace.md | 4 +- src/commands/alert/issues/create.ts | 240 ++++++++++++++++++ src/commands/alert/issues/edit.ts | 192 +++++++++++--- src/commands/alert/issues/index.ts | 5 +- src/commands/alert/metrics/create.ts | 224 ++++++++++++++++ src/commands/alert/metrics/edit.ts | 215 +++++++++++++--- src/commands/alert/metrics/index.ts | 5 +- src/commands/alert/mutation-utils.ts | 178 +++++++++++++ src/lib/api-client.ts | 2 + src/lib/api/alerts.ts | 33 +++ test/commands/alert/issues/create.test.ts | 148 +++++++++++ test/commands/alert/issues/edit.test.ts | 146 +++++++++++ test/commands/alert/metrics/create.test.ts | 154 +++++++++++ test/commands/alert/metrics/edit.test.ts | 152 +++++++++++ 23 files changed, 1763 insertions(+), 87 deletions(-) create mode 100644 src/commands/alert/issues/create.ts create mode 100644 src/commands/alert/metrics/create.ts create mode 100644 src/commands/alert/mutation-utils.ts create mode 100644 test/commands/alert/issues/create.test.ts create mode 100644 test/commands/alert/issues/edit.test.ts create mode 100644 test/commands/alert/metrics/create.test.ts create mode 100644 test/commands/alert/metrics/edit.test.ts diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 8ca21e9e0..ad72f0e55 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -49,7 +49,7 @@ cli/ │ ├── app.ts # Stricli application setup │ ├── context.ts # Dependency injection context │ ├── commands/ # CLI commands -│ │ ├── alert/ # delete, edit, list, view +│ │ ├── alert/ # create, delete, edit, list, view │ │ ├── auth/ # login, logout, refresh, status, token, whoami │ │ ├── cli/ # defaults, feedback, fix, setup, upgrade │ │ ├── dashboard/ # add, create, delete, edit, list, view diff --git a/docs/src/fragments/commands/alert.md b/docs/src/fragments/commands/alert.md index 4325fbf05..0c2c3f7ef 100644 --- a/docs/src/fragments/commands/alert.md +++ b/docs/src/fragments/commands/alert.md @@ -2,6 +2,17 @@ ## 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 @@ -22,6 +33,29 @@ sentry alert issues view my-org/my-project/12345 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 @@ -38,3 +72,13 @@ 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 f4adb6d9b..54c6403d7 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -327,10 +327,12 @@ 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 diff --git a/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/plugins/sentry-cli/skills/sentry-cli/references/alert.md index 20928e9cd..1611fd8d1 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -49,6 +49,33 @@ sentry alert issues view my-org/my-project/12345 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 @@ -58,6 +85,16 @@ Delete an issue alert rule - `-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 @@ -65,6 +102,14 @@ 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 ` @@ -101,6 +146,35 @@ sentry alert metrics view my-org/67890 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 @@ -110,6 +184,16 @@ Delete a metric alert rule - `-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 @@ -117,5 +201,13 @@ 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/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 9b8d3294e..be8cbbcd4 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -42,7 +42,7 @@ View a dashboard - `-w, --web - Open in browser` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28"` **Examples:** diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index f310e57fd..0b2865f9c 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -28,7 +28,7 @@ List events for an issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 8da412a66..50afab692 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -19,7 +19,7 @@ List issues in a project - `-q, --query - Search query (Sentry syntax, implicit AND, no OR operator)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "90d")` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "90d")` - `-c, --cursor - Pagination cursor (use "next" for next page, "prev" for previous)` - `--compact - Single-line rows for compact output (auto-detects if omitted)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -87,7 +87,7 @@ List events for a specific issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/log.md b/plugins/sentry-cli/skills/sentry-cli/references/log.md index d059f148e..81dcffab9 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -19,7 +19,7 @@ List logs from a project - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Filter query (e.g., "level:error", "project:backend", "project:[a,b]")` - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28"` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` - `--fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/span.md b/plugins/sentry-cli/skills/sentry-cli/references/span.md index b7e5fcc59..eaba67607 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -19,7 +19,7 @@ List spans in a project or trace - `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "project:backend", "project:[cli,api]")` - `-s, --sort - Sort order: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/plugins/sentry-cli/skills/sentry-cli/references/trace.md index e7f221e99..2ba996cd6 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -19,7 +19,7 @@ List recent traces in a project - `-n, --limit - Number of traces (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort by: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -91,7 +91,7 @@ View logs associated with a trace **Flags:** - `-w, --web - Open trace in browser` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "14d")` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "14d")` - `-n, --limit - Number of log entries (<=1000) - (default: "100")` - `-q, --query - Filter query (e.g., "level:error", "project:backend", "project:[a,b]")` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` diff --git a/src/commands/alert/issues/create.ts b/src/commands/alert/issues/create.ts new file mode 100644 index 000000000..db146be8d --- /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) => 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) => 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]; + + 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/edit.ts b/src/commands/alert/issues/edit.ts index 79e7167fb..cc23c549c 100644 --- a/src/commands/alert/issues/edit.ts +++ b/src/commands/alert/issues/edit.ts @@ -11,10 +11,16 @@ import { putIssueAlertRule, } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; -import { buildCommand } from "../../../lib/command.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 = @@ -22,38 +28,107 @@ const USAGE_HINT = type EditFlags = { readonly name?: string; - /** Present when --status was passed; omitted when the flag is absent. */ 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 = { +type EditResult = Record & { org: string; project: string; id: string; - name: string; - status: string; }; -function statusParser( - s: string | undefined -): "active" | "disabled" | undefined { - if (s === undefined || s === "") { - return; +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"]; } - const v = s.toLowerCase().trim(); - if (v === "active" || v === "disabled") { - return v; + if (flags.frequency !== undefined) { + body.frequency = flags.frequency; } - throw new ValidationError( - `Status must be 'active' or 'disabled' (got ${JSON.stringify(s)}).`, - "status" + 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}: ${r.name} (${r.status}).`; + return `Updated issue alert rule ${r.id} in ${r.org}/${r.project}: ${String(r.name)} (${String(r.status)}).`; } export const editCommand = buildCommand({ @@ -66,7 +141,8 @@ export const editCommand = buildCommand({ "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", + " 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, @@ -92,20 +168,71 @@ export const editCommand = buildCommand({ }, status: { kind: "parsed", - parse: statusParser, + 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) => 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) => 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; - if (flags.name === undefined && flags.status === undefined) { - throw new ValidationError( - "Pass at least one of --name or --status to edit the rule.", - "name" - ); - } + validateIssueEditFlags(flags); const { ref, targetArg } = parseIssueRuleArg(arg, USAGE_HINT); const parsed = parseOrgProjectArg(targetArg); @@ -125,13 +252,8 @@ export const editCommand = buildCommand({ const body = { ...(await getIssueAlertRuleDocument(target.org, target.project, rule.id)), - }; - if (flags.name !== undefined) { - body.name = flags.name; - } - if (flags.status !== undefined) { - body.status = flags.status; - } + } as Record; + applyIssueEdits(body, flags); const updated = await putIssueAlertRule( target.org, @@ -139,15 +261,11 @@ export const editCommand = buildCommand({ rule.id, body ); - const name = String(updated.name ?? rule.name); - const status = String(updated.status ?? flags.status ?? rule.status); - yield new CommandOutput({ + ...updated, org: target.org, project: target.project, id: String(updated.id ?? rule.id), - name, - status, } satisfies EditResult); }, }); diff --git a/src/commands/alert/issues/index.ts b/src/commands/alert/issues/index.ts index a8578a4ae..7af43497e 100644 --- a/src/commands/alert/issues/index.ts +++ b/src/commands/alert/issues/index.ts @@ -1,4 +1,5 @@ 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"; @@ -8,6 +9,7 @@ export const issuesRoute = buildRouteMap({ routes: { list: listCommand, view: viewCommand, + create: createCommand, delete: deleteCommand, edit: editCommand, }, @@ -18,8 +20,9 @@ export const issuesRoute = buildRouteMap({ "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 (name, status)", + " edit Update an issue alert rule", hideRoute: {}, }, }); diff --git a/src/commands/alert/metrics/create.ts b/src/commands/alert/metrics/create.ts new file mode 100644 index 000000000..eff9d4bc7 --- /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]; + + 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/edit.ts b/src/commands/alert/metrics/edit.ts index 47d8dc652..b09933119 100644 --- a/src/commands/alert/metrics/edit.ts +++ b/src/commands/alert/metrics/edit.ts @@ -10,10 +10,19 @@ import { putMetricAlertRule, } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; -import { buildCommand } from "../../../lib/command.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 = @@ -22,35 +31,120 @@ const USAGE_HINT = 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 = { +type EditResult = Record & { org: string; id: string; - name: string; - status: "active" | "disabled"; }; -function metricStatusParser( - s: string | undefined -): "active" | "disabled" | undefined { - if (s === undefined || s === "") { - return; +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) ?? []; } - const v = s.toLowerCase().trim(); - if (v === "active" || v === "disabled") { - return v; + if (flags.environment !== undefined) { + body.environment = + flags.environment.trim() === "" ? null : flags.environment; } - throw new ValidationError( - `Status must be 'active' or 'disabled' (got ${JSON.stringify(s)}).`, - "status" + 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}: ${r.name} (${r.status}).`; + return `Updated metric alert rule ${r.id} in ${r.org}: ${String(r.name)} (${String(r.status)}).`; } export const editCommand = buildCommand({ @@ -61,7 +155,8 @@ export const editCommand = buildCommand({ "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", + " 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, @@ -87,20 +182,70 @@ export const editCommand = buildCommand({ }, status: { kind: "parsed", - parse: metricStatusParser, + 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; - if (flags.name === undefined && flags.status === undefined) { - throw new ValidationError( - "Pass at least one of --name or --status to edit the rule.", - "name" - ); - } + validateMetricEditFlags(flags); const { ref, targetArg } = parseMetricRuleArg(arg, USAGE_HINT); const parsed = parseOrgProjectArg(targetArg); const { targets } = await resolveTargetsFromParsedArg(parsed, { @@ -118,25 +263,17 @@ export const editCommand = buildCommand({ ); const body = { ...(await getMetricAlertRuleDocument(orgSlug, rule.id)), - }; - if (flags.name !== undefined) { - body.name = flags.name; - } - if (flags.status === "active") { - body.status = 0; - } else if (flags.status === "disabled") { - body.status = 1; - } + } as Record; + applyMetricCoreFields(body, flags); + applyMetricOptionalFields(body, flags); + validateMetricBody(body); const updated = await putMetricAlertRule(orgSlug, rule.id, body); - const name = String(updated.name ?? rule.name); - const st = updated.status; - const status: "active" | "disabled" = - st === 0 || st === "0" ? "active" : "disabled"; yield new CommandOutput({ + ...updated, org: orgSlug, id: String(updated.id ?? rule.id), - name, - status, + 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 index 572c52bca..275f63699 100644 --- a/src/commands/alert/metrics/index.ts +++ b/src/commands/alert/metrics/index.ts @@ -1,4 +1,5 @@ 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"; @@ -8,6 +9,7 @@ export const metricsRoute = buildRouteMap({ routes: { list: listCommand, view: viewCommand, + create: createCommand, delete: deleteCommand, edit: editCommand, }, @@ -18,8 +20,9 @@ export const metricsRoute = buildRouteMap({ "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 (name, status)", + " edit Update a metric alert rule", hideRoute: {}, }, }); diff --git a/src/commands/alert/mutation-utils.ts b/src/commands/alert/mutation-utils.ts new file mode 100644 index 000000000..f71892092 --- /dev/null +++ b/src/commands/alert/mutation-utils.ts @@ -0,0 +1,178 @@ +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 parsed = parseJsonValue(values[0], 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/lib/api-client.ts b/src/lib/api-client.ts index c8979f37c..aa09df304 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -20,6 +20,8 @@ */ export { + createIssueAlertRule, + createMetricAlertRule, deleteIssueAlertRule, deleteMetricAlertRule, getIssueAlertRule, diff --git a/src/lib/api/alerts.ts b/src/lib/api/alerts.ts index 88a2dacac..9fda8696e 100644 --- a/src/lib/api/alerts.ts +++ b/src/lib/api/alerts.ts @@ -212,6 +212,23 @@ export async function putIssueAlertRule( 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 // --------------------------------------------------------------------------- @@ -251,3 +268,19 @@ export async function putMetricAlertRule( ); 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/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/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" }] }], + }); + }); +}); From 46a4947977d83ec5762d0752831e4501aa74f38c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 16:44:02 +0000 Subject: [PATCH 18/21] chore: regenerate docs --- plugins/sentry-cli/skills/sentry-cli/references/dashboard.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/event.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/issue.md | 4 ++-- plugins/sentry-cli/skills/sentry-cli/references/log.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/span.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/trace.md | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index be8cbbcd4..9b8d3294e 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -42,7 +42,7 @@ View a dashboard - `-w, --web - Open in browser` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28"` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` **Examples:** diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index 0b2865f9c..f310e57fd 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -28,7 +28,7 @@ List events for an issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 50afab692..8da412a66 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -19,7 +19,7 @@ List issues in a project - `-q, --query - Search query (Sentry syntax, implicit AND, no OR operator)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "90d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "90d")` - `-c, --cursor - Pagination cursor (use "next" for next page, "prev" for previous)` - `--compact - Single-line rows for compact output (auto-detects if omitted)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -87,7 +87,7 @@ List events for a specific issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/log.md b/plugins/sentry-cli/skills/sentry-cli/references/log.md index 81dcffab9..d059f148e 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -19,7 +19,7 @@ List logs from a project - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Filter query (e.g., "level:error", "project:backend", "project:[a,b]")` - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28"` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` - `--fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/span.md b/plugins/sentry-cli/skills/sentry-cli/references/span.md index eaba67607..b7e5fcc59 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -19,7 +19,7 @@ List spans in a project or trace - `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "project:backend", "project:[cli,api]")` - `-s, --sort - Sort order: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/plugins/sentry-cli/skills/sentry-cli/references/trace.md index 2ba996cd6..e7f221e99 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -19,7 +19,7 @@ List recent traces in a project - `-n, --limit - Number of traces (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort by: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -91,7 +91,7 @@ View logs associated with a trace **Flags:** - `-w, --web - Open trace in browser` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "14d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "14d")` - `-n, --limit - Number of log entries (<=1000) - (default: "100")` - `-q, --query - Filter query (e.g., "level:error", "project:backend", "project:[a,b]")` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` From d069e864fedf8846a23faf494de07c0c59b81922 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 24 Apr 2026 00:52:19 +0800 Subject: [PATCH 19/21] fix(alert): resolve alert command typing regressions Tighten alert create/edit typing to satisfy strict TypeScript and lint checks, and regenerate related skill reference docs so CI passes consistently. Made-with: Cursor --- .../sentry-cli/skills/sentry-cli/references/dashboard.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/event.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/issue.md | 4 ++-- plugins/sentry-cli/skills/sentry-cli/references/log.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/span.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/trace.md | 4 ++-- src/commands/alert/issues/create.ts | 6 +++--- src/commands/alert/issues/edit.ts | 4 ++-- src/commands/alert/metrics/create.ts | 2 +- src/commands/alert/mutation-utils.ts | 6 +++++- 10 files changed, 19 insertions(+), 15 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 9b8d3294e..be8cbbcd4 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -42,7 +42,7 @@ View a dashboard - `-w, --web - Open in browser` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28"` **Examples:** diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index f310e57fd..0b2865f9c 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -28,7 +28,7 @@ List events for an issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 8da412a66..50afab692 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -19,7 +19,7 @@ List issues in a project - `-q, --query - Search query (Sentry syntax, implicit AND, no OR operator)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "90d")` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "90d")` - `-c, --cursor - Pagination cursor (use "next" for next page, "prev" for previous)` - `--compact - Single-line rows for compact output (auto-detects if omitted)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -87,7 +87,7 @@ List events for a specific issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/log.md b/plugins/sentry-cli/skills/sentry-cli/references/log.md index d059f148e..81dcffab9 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -19,7 +19,7 @@ List logs from a project - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Filter query (e.g., "level:error", "project:backend", "project:[a,b]")` - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28"` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` - `--fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/span.md b/plugins/sentry-cli/skills/sentry-cli/references/span.md index b7e5fcc59..eaba67607 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -19,7 +19,7 @@ List spans in a project or trace - `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "project:backend", "project:[cli,api]")` - `-s, --sort - Sort order: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/plugins/sentry-cli/skills/sentry-cli/references/trace.md index e7f221e99..2ba996cd6 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -19,7 +19,7 @@ List recent traces in a project - `-n, --limit - Number of traces (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort by: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -91,7 +91,7 @@ View logs associated with a trace **Flags:** - `-w, --web - Open trace in browser` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "14d")` +- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "14d")` - `-n, --limit - Number of log entries (<=1000) - (default: "100")` - `-q, --query - Filter query (e.g., "level:error", "project:backend", "project:[a,b]")` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` diff --git a/src/commands/alert/issues/create.ts b/src/commands/alert/issues/create.ts index db146be8d..5249d490e 100644 --- a/src/commands/alert/issues/create.ts +++ b/src/commands/alert/issues/create.ts @@ -109,7 +109,7 @@ export const createCommand = buildCommand({ }, "action-match": { kind: "parsed", - parse: (value) => parseMatchMode(value, "action-match"), + parse: (value: string) => parseMatchMode(value, "action-match"), optional: true, brief: "Condition/action match mode: all or any", }, @@ -134,7 +134,7 @@ export const createCommand = buildCommand({ }, "filter-match": { kind: "parsed", - parse: (value) => parseMatchMode(value, "filter-match"), + parse: (value: string) => parseMatchMode(value, "filter-match"), optional: true, brief: "Filter match mode: all or any", }, @@ -191,7 +191,7 @@ export const createCommand = buildCommand({ "target" ); } - const target = targets[0]; + const target = targets[0] as (typeof targets)[number]; const body: Record = { name: flags.name, diff --git a/src/commands/alert/issues/edit.ts b/src/commands/alert/issues/edit.ts index cc23c549c..22ffe9583 100644 --- a/src/commands/alert/issues/edit.ts +++ b/src/commands/alert/issues/edit.ts @@ -188,7 +188,7 @@ export const editCommand = buildCommand({ }, "action-match": { kind: "parsed", - parse: (value) => parseMatchMode(value, "action-match"), + parse: (value: string) => parseMatchMode(value, "action-match"), optional: true, brief: "Condition/action match mode: all or any", }, @@ -213,7 +213,7 @@ export const editCommand = buildCommand({ }, "filter-match": { kind: "parsed", - parse: (value) => parseMatchMode(value, "filter-match"), + parse: (value: string) => parseMatchMode(value, "filter-match"), optional: true, brief: "Filter match mode: all or any", }, diff --git a/src/commands/alert/metrics/create.ts b/src/commands/alert/metrics/create.ts index eff9d4bc7..dd62ba45a 100644 --- a/src/commands/alert/metrics/create.ts +++ b/src/commands/alert/metrics/create.ts @@ -181,7 +181,7 @@ export const createCommand = buildCommand({ "target" ); } - const orgSlug = orgSlugs[0]; + const orgSlug = orgSlugs[0] as string; const body: Record = { name: flags.name, diff --git a/src/commands/alert/mutation-utils.ts b/src/commands/alert/mutation-utils.ts index f71892092..0543bb736 100644 --- a/src/commands/alert/mutation-utils.ts +++ b/src/commands/alert/mutation-utils.ts @@ -77,7 +77,11 @@ export function parseJsonObjectList( } if (values.length === 1) { - const parsed = parseJsonValue(values[0], field); + const onlyValue = values[0]; + if (onlyValue === undefined) { + return; + } + const parsed = parseJsonValue(onlyValue, field); if (Array.isArray(parsed)) { return parsed.map((entry) => toObject(entry, field)); } From 95b4deddbedc2c89fb6ae5312da4b6a4bdf6f714 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 16:52:53 +0000 Subject: [PATCH 20/21] chore: regenerate docs --- plugins/sentry-cli/skills/sentry-cli/references/dashboard.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/event.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/issue.md | 4 ++-- plugins/sentry-cli/skills/sentry-cli/references/log.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/span.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/trace.md | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index be8cbbcd4..9b8d3294e 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -42,7 +42,7 @@ View a dashboard - `-w, --web - Open in browser` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28"` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` **Examples:** diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index 0b2865f9c..f310e57fd 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -28,7 +28,7 @@ List events for an issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 50afab692..8da412a66 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -19,7 +19,7 @@ List issues in a project - `-q, --query - Search query (Sentry syntax, implicit AND, no OR operator)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "90d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "90d")` - `-c, --cursor - Pagination cursor (use "next" for next page, "prev" for previous)` - `--compact - Single-line rows for compact output (auto-detects if omitted)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -87,7 +87,7 @@ List events for a specific issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/log.md b/plugins/sentry-cli/skills/sentry-cli/references/log.md index 81dcffab9..d059f148e 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -19,7 +19,7 @@ List logs from a project - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Filter query (e.g., "level:error", "project:backend", "project:[a,b]")` - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28"` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` - `--fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/span.md b/plugins/sentry-cli/skills/sentry-cli/references/span.md index eaba67607..b7e5fcc59 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -19,7 +19,7 @@ List spans in a project or trace - `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "project:backend", "project:[cli,api]")` - `-s, --sort - Sort order: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/plugins/sentry-cli/skills/sentry-cli/references/trace.md index 2ba996cd6..e7f221e99 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -19,7 +19,7 @@ List recent traces in a project - `-n, --limit - Number of traces (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort by: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -91,7 +91,7 @@ View logs associated with a trace **Flags:** - `-w, --web - Open trace in browser` -- `-t, --period - Time range: "7d", "2026-02-28..2026-03-31", ">=2026-02-28" - (default: "14d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "14d")` - `-n, --limit - Number of log entries (<=1000) - (default: "100")` - `-q, --query - Filter query (e.g., "level:error", "project:backend", "project:[a,b]")` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` From 4e91ee02b2a401c514d95761b1fb366c508e1b70 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Fri, 24 Apr 2026 01:02:43 +0800 Subject: [PATCH 21/21] fix(time-range): make period examples timezone-stable Compute month-boundary period examples in UTC so generated skill/docs output does not drift between local environments and CI. Made-with: Cursor --- src/lib/time-range.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) 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 */