diff --git a/apps/cli-docs/src/fragments/commands/alert.md b/apps/cli-docs/src/fragments/commands/alert.md index 8f0630f619..1d47e382b0 100644 --- a/apps/cli-docs/src/fragments/commands/alert.md +++ b/apps/cli-docs/src/fragments/commands/alert.md @@ -8,9 +8,8 @@ # 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 + --condition '{"type":"first_seen_event","comparison":true,"conditionResult":true}' \ + --action '{"type":"email","data":{},"config":{"targetType":"team","targetIdentifier":"1"}}' ``` ### List issue alert rules diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md index e9fabefe43..cd0ee82cb2 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -57,11 +57,10 @@ Create an issue alert rule - `--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` +- `-m, --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` @@ -71,9 +70,8 @@ Create an issue alert rule # 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 + --condition '{"type":"first_seen_event","comparison":true,"conditionResult":true}' \ + --action '{"type":"email","data":{},"config":{"targetType":"team","targetIdentifier":"1"}}' ``` ### `sentry alert issues delete ` @@ -101,11 +99,10 @@ Edit an issue alert rule - `--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` +- `-m, --filter-match - Filter match mode: all or any` - `--owner - Owner value (pass empty string to clear)` **Examples:** diff --git a/packages/cli/src/commands/alert/issues/create.ts b/packages/cli/src/commands/alert/issues/create.ts index 190fcdfc77..cadbfa8397 100644 --- a/packages/cli/src/commands/alert/issues/create.ts +++ b/packages/cli/src/commands/alert/issues/create.ts @@ -5,7 +5,10 @@ */ import type { SentryContext } from "../../../context.js"; -import { createIssueAlertRule } from "../../../lib/api-client.js"; +import { + createIssueAlertRule, + resolveErrorDetectorId, +} 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"; @@ -13,19 +16,20 @@ 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 { + matchToLogicType, parseJsonObjectList, parseMatchMode, + triggerLogicType, validateIssueRuleArrays, } from "../mutation-utils.js"; const USAGE_HINT = - "sentry alert issues create --name --condition --action --action-match all|any"; + "sentry alert issues create --name --condition --action "; 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[]; @@ -61,18 +65,25 @@ export const createCommand = buildCommand({ "/, an auto-detected project, or a bare project search when " + "it resolves to exactly one project.\n\n" + "Required fields:\n" + - " --name, --condition (>=1), --action (>=1), --action-match all|any\n\n" + + " --name, --condition (>=1), --action (>=1)\n\n" + "Optional fields:\n" + " --frequency, --environment, --filter, --filter-match, --owner\n\n" + + "Conditions and actions are workflow-native JSON (this targets the\n" + + "org-scoped workflows endpoint):\n" + + " --condition a trigger data-condition: {type, comparison, conditionResult}\n" + + " --action an action: {type, data, config}\n" + + " --filter an action-filter condition (same shape as --condition)\n\n" + + "Match mode: --filter-match all|any controls how the action-filter\n" + + "conditions combine. Issue-alert triggers always evaluate as 'any-short'\n" + + "(they fire on a single error detector), so there is no trigger match flag.\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", + " sentry alert issues create my-org/my-app --name 'New Issues' \\\n" + + ' --condition \'{"type":"first_seen_event","comparison":true,"conditionResult":true}\' \\\n' + + ' --action \'{"type":"email","data":{},"config":{"targetType":"team","targetIdentifier":"1"}}\'\n\n' + + " sentry alert issues create my-org/my-app --name 'High Priority' \\\n" + + ' --condition \'{"type":"new_high_priority_issue","comparison":true,"conditionResult":true}\' \\\n' + + ' --action \'{"type":"email","data":{},"config":{"targetType":"user","targetIdentifier":"56789"}}\' \\\n' + + " --frequency 30 --dry-run", }, output: { human: formatCreated, @@ -111,12 +122,6 @@ export const createCommand = buildCommand({ optional: true, brief: "Action object JSON (repeatable, or pass one JSON array)", }, - "action-match": { - kind: "parsed", - parse: (value: string) => parseMatchMode(value, "action-match"), - optional: true, - brief: "Condition/action match mode: all or any", - }, frequency: { kind: "parsed", parse: numberParser, @@ -154,7 +159,7 @@ export const createCommand = buildCommand({ ...DRY_RUN_ALIASES, c: "condition", a: "action", - m: "action-match", + m: "filter-match", }, }, async *func( @@ -172,12 +177,6 @@ export const createCommand = buildCommand({ "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"); @@ -201,22 +200,31 @@ export const createCommand = buildCommand({ } const target = targets[0] as (typeof targets)[number]; + // Issue alerts fire on a project's error detector; connect via detector_ids. + const detectorId = await resolveErrorDetectorId(target.org, target.project); + + // Assemble the workflow-shaped body. The user supplies workflow-native + // conditions/actions; the CLI only builds the envelope (triggers + + // action_filters + logic types), mirroring the backend dual-write mapping. const body: Record = { name: flags.name, - conditions, - actions, - actionMatch: flags["action-match"], - frequency: flags.frequency, + detectorIds: [detectorId], + config: { frequency: flags.frequency }, + triggers: { + logicType: triggerLogicType(), + conditions, + }, + actionFilters: [ + { + logicType: matchToLogicType(flags["filter-match"]), + conditions: filters ?? [], + actions, + }, + ], }; 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; } @@ -232,17 +240,15 @@ export const createCommand = buildCommand({ return { hint: "Dry run - no issue alert rule was created." }; } - const created = await createIssueAlertRule( - target.org, - target.project, - body - ); + const created = await createIssueAlertRule(target.org, 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"), + status: String( + created.status ?? (created.enabled === false ? "disabled" : "active") + ), } satisfies CreateResult); }, }); diff --git a/packages/cli/src/commands/alert/issues/edit.ts b/packages/cli/src/commands/alert/issues/edit.ts index 1d1f5e5ffe..94e32508e6 100644 --- a/packages/cli/src/commands/alert/issues/edit.ts +++ b/packages/cli/src/commands/alert/issues/edit.ts @@ -7,8 +7,8 @@ import type { SentryContext } from "../../../context.js"; import { - getIssueAlertRuleDocument, - putIssueAlertRule, + getIssueAlertWorkflowDocument, + updateIssueAlertRule, } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; @@ -16,9 +16,11 @@ import { ContextError, ValidationError } from "../../../lib/errors.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js"; import { + matchToLogicType, parseJsonObjectList, parseMatchMode, parseStatusFlag, + triggerLogicType, validateIssueRuleArrays, } from "../mutation-utils.js"; import { parseIssueRuleArg, resolveIssueAlertRule } from "./rule-resolve.js"; @@ -31,7 +33,6 @@ type EditFlags = { 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[]; @@ -53,7 +54,6 @@ function hasIssueMutations(flags: EditFlags): boolean { flags.status !== undefined || flags.condition !== undefined || flags.action !== undefined || - flags["action-match"] !== undefined || flags.frequency !== undefined || flags.environment !== undefined || flags.filter !== undefined || @@ -87,34 +87,56 @@ function applyIssueEdits( 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"]; + // Workflows use an `enabled` boolean rather than a status string. + body.enabled = flags.status === "active"; } if (flags.frequency !== undefined) { - body.frequency = flags.frequency; + const config = (body.config as Record | undefined) ?? {}; + config.frequency = flags.frequency; + body.config = config; } 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; } + // Triggers: the "when" data-condition group. Issue-alert triggers always use + // the 'any-short' logic type (see triggerLogicType), so we pin it whenever the + // conditions change rather than exposing a trigger match flag. + if (conditions !== undefined) { + const triggers = + (body.triggers as Record | undefined) ?? {}; + triggers.conditions = conditions; + triggers.logicType = triggerLogicType(); + body.triggers = triggers; + } + + // Action filter: the "if" group plus its actions. Issue alerts use one filter. + if ( + actions !== undefined || + filters !== undefined || + flags["filter-match"] !== undefined + ) { + const actionFilters = Array.isArray(body.actionFilters) + ? (body.actionFilters as Record[]) + : []; + const filter = (actionFilters[0] as Record) ?? {}; + if (actions !== undefined) { + filter.actions = actions; + } + if (filters !== undefined) { + filter.conditions = filters; + } + if (flags["filter-match"] !== undefined) { + filter.logicType = matchToLogicType(flags["filter-match"]); + } + actionFilters[0] = filter; + body.actionFilters = actionFilters; + } + if (conditions !== undefined) { validateIssueRuleArrays(conditions, actions, "conditions"); } @@ -183,12 +205,6 @@ export const editCommand = buildCommand({ optional: true, brief: "Action object JSON (repeatable, or pass one JSON array)", }, - "action-match": { - kind: "parsed", - parse: (value: string) => parseMatchMode(value, "action-match"), - optional: true, - brief: "Condition/action match mode: all or any", - }, frequency: { kind: "parsed", parse: numberParser, @@ -224,7 +240,7 @@ export const editCommand = buildCommand({ aliases: { c: "condition", a: "action", - m: "action-match", + m: "filter-match", }, }, async *func(this: SentryContext, flags: EditFlags, arg: string) { @@ -248,21 +264,21 @@ export const editCommand = buildCommand({ ); const body = { - ...(await getIssueAlertRuleDocument(target.org, target.project, rule.id)), + ...(await getIssueAlertWorkflowDocument(target.org, rule.id)), } as Record; applyIssueEdits(body, flags); - const updated = await putIssueAlertRule( - target.org, - target.project, - rule.id, - body - ); + const updated = await updateIssueAlertRule(target.org, rule.id, body); yield new CommandOutput({ ...updated, org: target.org, project: target.project, id: String(updated.id ?? rule.id), + // The workflows endpoint returns `enabled` rather than `status`; map it + // back to a status label so human/JSON output matches the create path. + status: String( + updated.status ?? (updated.enabled === false ? "disabled" : "active") + ), } satisfies EditResult); }, }); diff --git a/packages/cli/src/commands/alert/mutation-utils.ts b/packages/cli/src/commands/alert/mutation-utils.ts index 077c689cb4..13ac8eb3ee 100644 --- a/packages/cli/src/commands/alert/mutation-utils.ts +++ b/packages/cli/src/commands/alert/mutation-utils.ts @@ -35,6 +35,36 @@ export function parseMatchMode( ); } +/** + * Map an action-filter match mode to a workflow DataConditionGroup logic type. + * + * Applies to the **action-filter** ("if") group only. Mirrors the backend + * issue-alert dual-write: "any" → "any-short", "all" → "all". The filter group + * never holds trigger-type conditions, so the workflows create validator + * accepts either logic type here. + */ +export function matchToLogicType( + match: "all" | "any" | undefined +): "all" | "any-short" { + return match === "any" ? "any-short" : "all"; +} + +/** + * Logic type for an issue alert's **trigger** ("when") DataConditionGroup. + * + * Always "any-short". The org-scoped workflows create endpoint rejects a + * trigger group that carries an issue-alert trigger condition + * (first_seen_event / regression_event / reappeared_event / + * issue_resolved_trigger) with any other logic type — see + * `BaseDataConditionGroupValidator._validate_logic_type` in getsentry/sentry. + * An issue alert attaches to one error detector, so `all` vs `any` on the + * trigger group is not a meaningful choice; use `--filter-match` to control + * the action-filter group's match mode instead. + */ +export function triggerLogicType(): "any-short" { + return "any-short"; +} + /** Parse and validate an "active" | "disabled" status flag. Returns `undefined` when absent. */ export function parseStatusFlag( value: string | undefined diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index 162fd9478c..8c6f049468 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -26,15 +26,16 @@ export { deleteIssueAlertRule, deleteMetricAlertRule, getIssueAlertRule, - getIssueAlertRuleDocument, + getIssueAlertWorkflowDocument, getMetricAlertRule, getMetricAlertRuleDocument, type IssueAlertRule, listIssueAlertsPaginated, listMetricAlertsPaginated, type MetricAlertRule, - putIssueAlertRule, putMetricAlertRule, + resolveErrorDetectorId, + updateIssueAlertRule, } from "./api/alerts.js"; export { createDashboard, diff --git a/packages/cli/src/lib/api/alerts.ts b/packages/cli/src/lib/api/alerts.ts index 1a70196c11..84eda02a44 100644 --- a/packages/cli/src/lib/api/alerts.ts +++ b/packages/cli/src/lib/api/alerts.ts @@ -7,8 +7,11 @@ */ import { + createOrganizationWorkflow, getOrganizationDetector, + getOrganizationWorkflow, listOrganizationDetectors, + updateOrganizationWorkflow, } from "@sentry/api"; import { ApiError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; @@ -262,24 +265,59 @@ export async function listIssueAlertsPaginated( } /** - * Single GET for a project issue alert rule as full JSON (used as the edit - * baseline for PUT). - * - * NOTE: still uses the deprecated project-scoped `/rules/` endpoint. It backs - * the mutation path (`getIssueAlertRuleDocument` → edit), which is migrating to - * `/workflows/` in a follow-up (getsentry/cli#1182). + * Full workflow document for the edit baseline (name, config, environment, + * triggers, action_filters, detector_ids, ...), read from the org-scoped + * `/organizations/{org}/workflows/{id}/` detail endpoint. */ -async function fetchIssueAlertRuleJson( +export async function getIssueAlertWorkflowDocument( orgSlug: string, - projectSlug: string, - ruleId: string + workflowId: string ): Promise> { - const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion>( - regionUrl, - `/projects/${orgSlug}/${projectSlug}/rules/${encodeURIComponent(ruleId)}/` + const config = await getOrgSdkConfig(orgSlug); + const result = await getOrganizationWorkflow({ + ...config, + path: { + organization_id_or_slug: orgSlug, + workflow_id: Number(workflowId), + }, + }); + return unwrapResult>( + result, + "Failed to fetch issue alert rule" ); - return data; +} + +/** + * Resolve the id of a project's error detector ("Error Monitor"). An issue alert + * workflow must connect to it via `detector_ids` to fire on new issues. Reads the + * org-scoped detectors endpoint filtered to the project and the `error` type. + * + * @throws {ApiError} 404 if the project has no error detector + */ +export async function resolveErrorDetectorId( + orgSlug: string, + projectSlug: string +): Promise { + const config = await getOrgSdkConfig(orgSlug); + const result = await listOrganizationDetectors({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { project: [projectSlug], query: "type:error" }, + }); + const detectors = unwrapResult>( + result, + "Failed to resolve error detector" + ); + const detector = detectors[0]; + if (!detector) { + throw new ApiError( + `No error detector found for project '${projectSlug}'`, + 404, + undefined, + `/organizations/${orgSlug}/detectors/` + ); + } + return Number(detector.id); } /** @@ -452,49 +490,60 @@ export async function deleteIssueAlertRule( } /** - * Full document for PUT (includes conditions, actions, etc. from the API). - */ -export function getIssueAlertRuleDocument( - orgSlug: string, - projectSlug: string, - ruleId: string -): Promise> { - return fetchIssueAlertRuleJson(orgSlug, projectSlug, ruleId); -} - -/** - * Replace an issue alert rule (Sentry PUT is a full replacement). + * Replace (update) an issue alert workflow. Sentry PUT is a full replacement. + * + * `workflowId` is the id surfaced by the migrated read path; the update is keyed + * by id on the org-scoped `/workflows/{id}/` endpoint. */ -export async function putIssueAlertRule( +export async function updateIssueAlertRule( orgSlug: string, - projectSlug: string, - ruleId: string, + workflowId: string, body: Record ): Promise> { - const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion>( - regionUrl, - `/projects/${orgSlug}/${projectSlug}/rules/${encodeURIComponent(ruleId)}/`, - { method: "PUT", body } + const config = await getOrgSdkConfig(orgSlug); + const result = await updateOrganizationWorkflow({ + ...config, + path: { + organization_id_or_slug: orgSlug, + workflow_id: Number(workflowId), + }, + // The SDK types conditions/actions as `unknown[]`; the CLI supplies the + // workflow-native body as a plain record, so cast into the typed arg. + body: body as unknown as Parameters< + typeof updateOrganizationWorkflow + >[0]["body"], + }); + return unwrapResult>( + result, + "Failed to update issue alert rule" ); - return data; } /** - * Create an issue (project) alert rule. + * Create an issue alert workflow on the org-scoped `/workflows/` endpoint. + * + * `body` must be workflow-shaped (name, detector_ids, config, triggers, + * action_filters, ...). Project linkage is carried by `detector_ids`, so no + * project slug is part of the request. */ 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 } + const config = await getOrgSdkConfig(orgSlug); + const result = await createOrganizationWorkflow({ + ...config, + path: { organization_id_or_slug: orgSlug }, + // The SDK types conditions/actions as `unknown[]`; the CLI supplies the + // workflow-native body as a plain record, so cast into the typed arg. + body: body as unknown as Parameters< + typeof createOrganizationWorkflow + >[0]["body"], + }); + return unwrapResult>( + result, + "Failed to create issue alert rule" ); - return data; } // Metric alert (org) write operations diff --git a/packages/cli/test/commands/alert/issues/create.test.ts b/packages/cli/test/commands/alert/issues/create.test.ts index c642dba51b..8c510a0623 100644 --- a/packages/cli/test/commands/alert/issues/create.test.ts +++ b/packages/cli/test/commands/alert/issues/create.test.ts @@ -23,7 +23,6 @@ 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[]; @@ -46,39 +45,19 @@ function createContext() { describe("alert issues create", () => { let resolveSpy: ReturnType; let createSpy: ReturnType; + let detectorSpy: ReturnType; beforeEach(() => { resolveSpy = vi.spyOn(resolveTarget, "resolveTargetsFromParsedArg"); createSpy = vi.spyOn(apiClient, "createIssueAlertRule"); + detectorSpy = vi.spyOn(apiClient, "resolveErrorDetectorId"); + detectorSpy.mockResolvedValue(100); }); 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); + detectorSpy.mockRestore(); }); test("rejects blank rule name", async () => { @@ -96,7 +75,6 @@ describe("alert issues create", () => { name: " ", condition: ['{"id":"condition-a"}'], action: ['{"id":"action-a"}'], - "action-match": "any", frequency: 30, "dry-run": true, json: true, @@ -121,7 +99,6 @@ describe("alert issues create", () => { name: "Rule A", condition: ['{"id":"condition-a"}'], action: ['{"id":"action-a"}'], - "action-match": "any", frequency: 0, "dry-run": true, json: true, @@ -146,7 +123,6 @@ describe("alert issues create", () => { name: "Rule A", condition: ['{"id":"condition-a"}'], action: ['{"id":"action-a"}'], - "action-match": "all", frequency: 30, "dry-run": true, json: true, @@ -172,7 +148,6 @@ describe("alert issues create", () => { name: "Rule A", condition: ['{"id":"condition-a"}'], action: ['{"id":"action-a"}'], - "action-match": "all", frequency: 30, environment: "prod", filter: ['{"id":"filter-a"}'], @@ -193,13 +168,20 @@ describe("alert issues create", () => { dryRun: true, body: { name: "Rule A", - conditions: [{ id: "condition-a" }], - actions: [{ id: "action-a" }], - actionMatch: "all", - frequency: 30, + detectorIds: [100], + config: { frequency: 30 }, + triggers: { + logicType: "any-short", + conditions: [{ id: "condition-a" }], + }, + actionFilters: [ + { + logicType: "all", + conditions: [{ id: "filter-a" }], + actions: [{ id: "action-a" }], + }, + ], environment: "prod", - filters: [{ id: "filter-a" }], - filterMatch: "all", owner: "team:ops", }, }); @@ -231,7 +213,6 @@ describe("alert issues create", () => { name: "Rule A", condition: ['{"id":"condition-a"}'], action: ['{"id":"action-a"}'], - "action-match": "any", frequency: 30, "dry-run": true, json: true, @@ -262,7 +243,6 @@ describe("alert issues create", () => { name: "Rule A", condition: ['{"id":"condition-a"}'], action: ['{"id":"action-a"}'], - "action-match": "any", frequency: 15, "dry-run": false, json: true, @@ -270,12 +250,14 @@ describe("alert issues create", () => { "test-org/test-project" ); - expect(createSpy).toHaveBeenCalledWith("test-org", "test-project", { + expect(createSpy).toHaveBeenCalledWith("test-org", { name: "Rule A", - conditions: [{ id: "condition-a" }], - actions: [{ id: "action-a" }], - actionMatch: "any", - frequency: 15, + detectorIds: [100], + config: { frequency: 15 }, + triggers: { logicType: "any-short", conditions: [{ id: "condition-a" }] }, + actionFilters: [ + { logicType: "all", conditions: [], actions: [{ id: "action-a" }] }, + ], }); }); }); diff --git a/packages/cli/test/commands/alert/issues/edit.test.ts b/packages/cli/test/commands/alert/issues/edit.test.ts index 725d9b900f..8f0a2615a4 100644 --- a/packages/cli/test/commands/alert/issues/edit.test.ts +++ b/packages/cli/test/commands/alert/issues/edit.test.ts @@ -39,7 +39,6 @@ type EditFlags = { readonly status?: "active" | "disabled"; readonly condition?: string[]; readonly action?: string[]; - readonly "action-match"?: "all" | "any"; readonly json: boolean; }; @@ -59,8 +58,8 @@ describe("alert issues edit", () => { beforeEach(() => { getRuleSpy = vi.spyOn(apiClient, "getIssueAlertRule"); - getDocSpy = vi.spyOn(apiClient, "getIssueAlertRuleDocument"); - putSpy = vi.spyOn(apiClient, "putIssueAlertRule"); + getDocSpy = vi.spyOn(apiClient, "getIssueAlertWorkflowDocument"); + putSpy = vi.spyOn(apiClient, "updateIssueAlertRule"); resolveSpy = vi.spyOn(resolveTarget, "resolveTargetsFromParsedArg"); }); @@ -84,27 +83,27 @@ describe("alert issues edit", () => { ).rejects.toBeInstanceOf(ValidationError); }); - test("merges additional fields into full PUT body", async () => { + test("merges additional fields into full workflow update 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, + enabled: true, + config: { frequency: 30 }, + triggers: { + logicType: "any-short", + conditions: [{ id: "old-condition" }], + }, + actionFilters: [ + { logicType: "all", conditions: [], actions: [{ id: "old-action" }] }, + ], }); putSpy.mockResolvedValue({ id: "42", name: "Rule Beta", - status: "disabled", - actionMatch: "all", - conditions: [{ id: "new-condition" }], - actions: [{ id: "new-action" }], - frequency: 30, + enabled: false, }); const func = (await editCommand.loader()) as unknown as ( this: unknown, @@ -119,20 +118,49 @@ describe("alert issues edit", () => { 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", { + expect(putSpy).toHaveBeenCalledWith("test-org", "42", { id: "42", name: "Rule Beta", - status: "disabled", - actionMatch: "all", - conditions: [{ id: "new-condition" }], - actions: [{ id: "new-action" }], - frequency: 30, + enabled: false, + config: { frequency: 30 }, + triggers: { + logicType: "any-short", + conditions: [{ id: "new-condition" }], + }, + actionFilters: [ + { logicType: "all", conditions: [], actions: [{ id: "new-action" }] }, + ], + }); + }); + + test("maps the workflow enabled field to a status label in output", async () => { + const context = createContext(); + resolveSpy.mockResolvedValue({ targets: [sampleTarget] }); + getRuleSpy.mockResolvedValue(sampleRule); + getDocSpy.mockResolvedValue({ + id: "42", + name: "Rule Alpha", + enabled: true, }); + putSpy.mockResolvedValue({ id: "42", name: "Rule Alpha", enabled: false }); + const func = (await editCommand.loader()) as unknown as ( + this: unknown, + flags: EditFlags, + arg: string + ) => Promise; + + await func.call( + context, + { status: "disabled", json: true }, + "test-org/test-project/42" + ); + + const output = context.stdout.write.mock.calls.map((c) => c[0]).join(""); + expect(JSON.parse(output)).toMatchObject({ id: "42", status: "disabled" }); }); }); diff --git a/packages/cli/test/commands/alert/mutation-utils.test.ts b/packages/cli/test/commands/alert/mutation-utils.test.ts index 0d28134c7d..3e9b9d1d66 100644 --- a/packages/cli/test/commands/alert/mutation-utils.test.ts +++ b/packages/cli/test/commands/alert/mutation-utils.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "vitest"; import { + matchToLogicType, normalizeProjectList, parseJsonObjectList, parseMatchMode, parseStatusFlag, statusToMetricValue, + triggerLogicType, validateIssueRuleArrays, validateMetricDataset, validateMetricTimeWindow, @@ -41,6 +43,26 @@ describe("parseMatchMode", () => { }); }); +describe("matchToLogicType", () => { + test('maps "any" to "any-short"', () => { + expect(matchToLogicType("any")).toBe("any-short"); + }); + + test('maps "all" to "all"', () => { + expect(matchToLogicType("all")).toBe("all"); + }); + + test('defaults undefined to "all"', () => { + expect(matchToLogicType(undefined)).toBe("all"); + }); +}); + +describe("triggerLogicType", () => { + test("is always any-short (workflows endpoint rejects other logic types for issue triggers)", () => { + expect(triggerLogicType()).toBe("any-short"); + }); +}); + describe("parseStatusFlag", () => { test("returns undefined for undefined", () => { expect(parseStatusFlag(undefined)).toBeUndefined(); diff --git a/packages/cli/test/lib/api/alerts.test.ts b/packages/cli/test/lib/api/alerts.test.ts index db37ea993a..a01fd23b00 100644 --- a/packages/cli/test/lib/api/alerts.test.ts +++ b/packages/cli/test/lib/api/alerts.test.ts @@ -1,11 +1,15 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { + createIssueAlertRule, deleteIssueAlertRule, deleteMetricAlertRule, getIssueAlertRule, + getIssueAlertWorkflowDocument, getMetricAlertRule, listIssueAlertsPaginated, listMetricAlertsPaginated, + resolveErrorDetectorId, + updateIssueAlertRule, } from "../../../src/lib/api/alerts.js"; import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; import { setAuthToken } from "../../../src/lib/db/auth.js"; @@ -279,6 +283,82 @@ describe("getMetricAlertRule", () => { }); }); +describe("createIssueAlertRule", () => { + test("POSTs the body to the org-scoped /workflows/ endpoint", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("POST"); + expect(new URL(req.url).pathname).toBe( + "/api/0/organizations/test-org/workflows/" + ); + expect(await req.json()).toEqual({ name: "New", detectorIds: [7] }); + return Response.json(workflowRule({ id: "5", name: "New" })); + }); + + const created = await createIssueAlertRule("test-org", { + name: "New", + detectorIds: [7], + }); + expect(created.id).toBe("5"); + }); +}); + +describe("updateIssueAlertRule", () => { + test("PUTs the body to the /workflows/{id}/ endpoint", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + expect(req.method).toBe("PUT"); + expect(new URL(req.url).pathname).toBe( + "/api/0/organizations/test-org/workflows/42/" + ); + expect(await req.json()).toEqual({ name: "Renamed" }); + return Response.json(workflowRule({ id: "42", name: "Renamed" })); + }); + + const updated = await updateIssueAlertRule("test-org", "42", { + name: "Renamed", + }); + expect(updated.name).toBe("Renamed"); + }); +}); + +describe("getIssueAlertWorkflowDocument", () => { + test("reads the single workflow detail endpoint", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const url = new URL(new Request(input!, init).url); + expect(url.pathname).toBe("/api/0/organizations/test-org/workflows/42/"); + return Response.json(workflowRule({ id: "42" })); + }); + + const doc = await getIssueAlertWorkflowDocument("test-org", "42"); + expect(doc.id).toBe("42"); + }); +}); + +describe("resolveErrorDetectorId", () => { + test("returns the id of the project's error detector", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const url = new URL(new Request(input!, init).url); + expect(url.pathname).toBe("/api/0/organizations/test-org/detectors/"); + expect(url.searchParams.get("project")).toBe("test-project"); + expect(url.searchParams.get("query")).toBe("type:error"); + return Response.json([{ id: 7, type: "error" }]); + }); + + await expect( + resolveErrorDetectorId("test-org", "test-project") + ).resolves.toBe(7); + }); + + test("throws 404 ApiError when the project has no error detector", async () => { + globalThis.fetch = mockFetch(async () => Response.json([])); + + await expect( + resolveErrorDetectorId("test-org", "test-project") + ).rejects.toMatchObject({ name: "ApiError", status: 404 }); + }); +}); + describe("deleteMetricAlertRule", () => { test("treats empty-body 202 as success", async () => { globalThis.fetch = mockFetch(async (input, init) => {