Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,19 +313,23 @@ itself, and an omitted target arrives as auto-detect. The
one of these types and never reads `.resolved`. It also rejects, in commands,
what leaves a target for `func` to parse: the completion-only parsers
`parseOrgTarget` and `parseOrgProjectTarget`, `parseOrgProjectArg`, and the
`resolve*FromArg` helpers. Commands not migrated yet are on
`UNBOUND_TARGET_PARSING_ALLOWED` in `lint-rules/cli-oxlint-plugin.js`, each
with its follow-up; the list is a ratchet, so a file that stops using them
fails until its entry is removed. `sentry-cli/no-ad-hoc-id-arguments` has no
allow-list: no command may parse IDs by hand, and the completion-only
`parseIssueTarget` and `parseDashboardTarget` are rejected too.
`resolve*FromArg` helpers. Neither rule has an allow-list: no command may
parse a target or an ID by hand. `sentry-cli/no-ad-hoc-id-arguments` rejects
the completion-only `parseIssueTarget` and `parseDashboardTarget` too.

**Release positionals:** `[<org>/]<version>` uses
`releaseParameter(USAGE_HINT, brief)` from `src/commands/release/parse.ts`
inside a tuple positional. `func` takes a `ReleaseTarget` and gets
`{ version, org, detectedFrom }` from `await target.resolved`; a blank version
fails the parse.

**Alert rule positionals:** `<org>/<project>/<rule-id-or-name>` (issue
alerts) and `<org>/<rule-id-or-name>` (metric alerts) use
`issueRuleParameter` / `metricRuleParameter` from the alert `rule-resolve.ts`
files inside a tuple positional. Rule names may contain spaces, so they keep
their own syntax instead of `ID_KINDS`. `func` takes an `AlertRuleArgument`
and gets `{ ref, target }` from `await rule.resolved`.

List commands take their target from `listTargetPositional()` in
`src/lib/list-command.ts` (`LIST_TARGET_POSITIONAL`,
`LIST_ORG_TARGET_POSITIONAL`, or `listTargetPositional({ orgScoped, names,
Expand Down Expand Up @@ -388,8 +392,8 @@ dashboard are tagged with a completion kind from `src/lib/completion-target.ts`
the `target-positional.ts` parsers behind `listTargetPositional`,
`issuePositional`, and `releaseParameter` complete the right values. The
completion-only parsers (`parseOrgProjectTarget`, `parseOrgTarget`,
`parseIssueTarget`) remain for the allow-listed
commands that still parse in `func`; new commands use a bound parser. Shared value
`parseIssueTarget`) are rejected in commands by the lint rules above; commands
use a bound parser. Shared value
parsers are not tagged where they live; `PARSER_TARGETS` in
`completion-target.ts` lists them instead, so every flag that parses with
`sanitizeIssueQuery` completes issue search terms
Expand Down
55 changes: 2 additions & 53 deletions packages/cli/lint-rules/cli-oxlint-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const BOUND_TARGET_TYPES = new Set([
"OrgTarget",
"ProjectBoundTarget",
"ReleaseTarget",
"AlertRuleArgument",
"BoundArguments",
"ConversationArguments",
"DashboardArguments",
Expand Down Expand Up @@ -199,33 +200,6 @@ const UNBOUND_TARGET_PARSING = new Set([
"resolveProjectBoundFromArg",
]);

/**
* Commands still allowed to, each with its follow-up. A ratchet: an entry
* whose file no longer uses them is reported, so the list only shrinks.
*/
const UNBOUND_TARGET_PARSING_ALLOWED = new Map([
// Follow-ups (#1564):
// `[<org>/[<project>/]]<rule>`, resolving to every matching project
["/src/commands/alert/issues/create.ts", "#1564: alert rule target"],
["/src/commands/alert/issues/delete.ts", "#1564: alert rule target"],
["/src/commands/alert/issues/edit.ts", "#1564: alert rule target"],
["/src/commands/alert/issues/view.ts", "#1564: alert rule target"],
// `[<org>/]<rule>`, org-scoped
["/src/commands/alert/metrics/create.ts", "#1564: alert rule target"],
["/src/commands/alert/metrics/delete.ts", "#1564: alert rule target"],
["/src/commands/alert/metrics/edit.ts", "#1564: alert rule target"],
["/src/commands/alert/metrics/list.ts", "#1564: alert rule target"],
["/src/commands/alert/metrics/view.ts", "#1564: alert rule target"],
]);

/** The follow-up of `file` in an allow-list, if it is on it. */
function allowedFollowUp(allowed, file) {
for (const [path, followUp] of allowed) {
if (file.endsWith(path)) return followUp;
}
return undefined;
}

/** Whether `node` uses one of `names`; imports and `typeof` do not. */
function usesName(node, names) {
return (
Expand All @@ -245,30 +219,6 @@ function bannedNames(file, names, report, message) {
};
}

/**
* Like {@link bannedNames}, except in files on `allowed`; those are reported
* instead when they no longer use any, so the allow-list only shrinks.
*/
function ratchetedNames(file, names, allowed, report, message) {
if (!file.includes("/src/commands/")) return {};
const followUp = allowedFollowUp(allowed, file);
let used = false;
return {
Identifier(node) {
if (!usesName(node, names)) return;
used = true;
if (followUp === undefined) report(node, message);
},
"Program:exit"(node) {
if (followUp !== undefined && !used)
report(
node,
`Remove this file from the allow-list (${followUp}): it no longer parses its positional by hand.`,
);
},
};
}

const ID_ARGUMENT_PARSERS = new Set(["parseIdArguments", "parseIdArgument"]);

/** Whether `node` is `ID_KINDS.<kind>`. */
Expand Down Expand Up @@ -445,10 +395,9 @@ const rules = {
FunctionDeclaration: check,
FunctionExpression: check,
ArrowFunctionExpression: check,
...ratchetedNames(
...bannedNames(
file,
UNBOUND_TARGET_PARSING,
UNBOUND_TARGET_PARSING_ALLOWED,
report,
"Take the target as a bound positional (src/lib/target-positional.ts, boundArguments) instead of parsing it in func.",
),
Expand Down
87 changes: 87 additions & 0 deletions packages/cli/src/commands/alert/arguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Alert rule positionals, parsed before `func` runs.
*
* {@link alertRuleParameter} takes a rule ID or name and where to find it:
* `<org>/<project>/<rule>` for issue alerts, `<org>/<rule>` for metric
* alerts. Rule names may hold spaces, so these keep their own syntax
* (`parseIssueRuleArg()`, `parseMetricRuleArg()`) rather than the ID
* grammar of `id-arguments.ts`.
*
* The parse has no side effects; the server of a pasted Sentry URL is
* applied when the value resolves, after the auth guard.
*/

import {
type ParsedOrgProject,
parseOrgProjectInput,
} from "../../lib/arg-parsing.js";
import type {
ORG_PROJECT_TARGET,
ORG_TARGET,
} from "../../lib/completion-target.js";
import {
type BoundPositional,
boundPositional,
} from "../../lib/positional-parse.js";
import { applySentryUrlContext } from "../../lib/sentry-url-parser.js";

/** A rule ID or name and the target to find it in. */
export type AlertRuleRef = {
/** The rule's numeric ID or name, as typed */
readonly ref: string;
/** Where to look the rule up */
readonly target: ParsedOrgProject;
};

/**
* An alert rule positional. `await rule.resolved` applies a pasted URL's
* server and yields the rule reference; the command then looks the rule up
* in the target's projects or org.
*/
export type AlertRuleArgument = BoundPositional<AlertRuleRef, AlertRuleRef>;

/** Splits a rule argument into the rule reference and the target argument. */
type RuleArgSplitter = (
arg: string,
usageHint: string,
) => { ref: string; targetArg: string | undefined };

/**
* The rule parameter of an alert command's tuple positional.
*
* @param options.split - The rule syntax: `parseIssueRuleArg` or
* `parseMetricRuleArg`; throws a `ValidationError` for an invalid rule
* @param options.kind - Shell completion for the target part
*/
export function alertRuleParameter(options: {
readonly placeholder: string;
readonly brief: string;
readonly kind: typeof ORG_TARGET | typeof ORG_PROJECT_TARGET;
readonly split: RuleArgSplitter;
readonly usageHint: string;
}) {
const { placeholder, brief, kind, split, usageHint } = options;
const parse = (raw: string | undefined) => {
const { ref, targetArg } = split(raw ?? "", usageHint);
return { ref, ...parseOrgProjectInput(targetArg) };
};
return {
placeholder,
brief,
parse: boundPositional(
kind,
(raw): AlertRuleRef => {
const { ref, parsed } = parse(raw);
return { ref, target: parsed };
},
async (rule: AlertRuleRef, _context: unknown, raw) => {
// Parsing again is cheap and pure; it recovers the URL's server
const { baseUrl } = parse(raw);
if (baseUrl) {
applySentryUrlContext(baseUrl);
}
return rule;
},
),
} as const;
}
73 changes: 47 additions & 26 deletions packages/cli/src/commands/alert/issues/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
createIssueAlertRule,
resolveErrorDetectorId,
} from "../../../lib/api-client.js";
import { parseOrgProjectArg } from "../../../lib/arg-parsing.js";
import type { ParsedOrgProject } from "../../../lib/arg-parsing.js";
import { buildCommand, numberParser } from "../../../lib/command.js";
import {
ContextError,
Expand All @@ -18,7 +18,14 @@ import {
} from "../../../lib/errors.js";
import { CommandOutput } from "../../../lib/formatters/output.js";
import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../../lib/mutate-command.js";
import { resolveProjectBoundTargets } from "../../../lib/resolve-target.js";
import {
type ResolvedTarget,
resolveProjectBoundTargets,
} from "../../../lib/resolve-target.js";
import {
type OrgProjectTarget,
orgProjectTarget,
} from "../../../lib/target-positional.js";
import {
matchToLogicType,
parseJsonObjectList,
Expand Down Expand Up @@ -61,6 +68,33 @@ function formatCreated(result: CreateResult): string {
return `Created issue alert rule ${result.id ?? "(unknown id)"} in ${result.org}/${result.project}: ${result.name} (${result.status ?? "active"}).`;
}

/**
* Resolve the create target to exactly one project.
*
* @throws {ContextError} When no project is found
* @throws {ValidationError} When the target matches several projects
*/
async function resolveCreateTarget(
parsed: ParsedOrgProject,
cwd: string,
): Promise<ResolvedTarget> {
const { targets } = await resolveProjectBoundTargets(parsed, {
cwd,
usageHint: USAGE_HINT,
});
const [target, ...others] = targets;
if (!target) {
throw new ContextError("Organization and project", USAGE_HINT);
}
if (others.length > 0) {
throw new ValidationError(
"Provide a target that resolves to exactly one project for create.",
"target",
);
}
return target;
}

export const createCommand = buildCommand({
docs: {
brief: "Create an issue alert rule",
Expand Down Expand Up @@ -106,7 +140,9 @@ export const createCommand = buildCommand({
placeholder: "target",
brief:
"<org>/<project>, auto-detected project, or <project> (search)",
parse: String,
parse: orgProjectTarget((parsed, { cwd }) =>
resolveCreateTarget(parsed, cwd),
),
optional: true as const,
},
],
Expand Down Expand Up @@ -174,9 +210,8 @@ export const createCommand = buildCommand({
async *func(
this: SentryContext,
flags: CreateFlags,
arg: string | undefined,
target: OrgProjectTarget<ResolvedTarget>,
) {
const { cwd } = this;
if (!flags.name.trim()) {
throw new ValidationError("Rule name cannot be empty.", "name");
}
Expand All @@ -193,24 +228,10 @@ export const createCommand = buildCommand({
validateIssueRuleArrays(conditions, actions, "conditions");
validateIssueRuleArrays(conditions, actions, "actions");

const parsed = parseOrgProjectArg(arg);
const { targets } = await resolveProjectBoundTargets(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 target that resolves to exactly one project for create.",
"target",
);
}
const target = targets[0] as (typeof targets)[number];
const { org, project } = await target.resolved;

// Issue alerts fire on a project's error detector; connect via detector_ids.
const detectorId = await resolveErrorDetectorId(target.org, target.project);
const detectorId = await resolveErrorDetectorId(org, project);

// Assemble the workflow-shaped body. The user supplies workflow-native
// conditions/actions; the CLI only builds the envelope (triggers +
Expand Down Expand Up @@ -240,19 +261,19 @@ export const createCommand = buildCommand({

if (flags["dry-run"]) {
yield new CommandOutput({
org: target.org,
project: target.project,
org,
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, body);
const created = await createIssueAlertRule(org, body);
yield new CommandOutput({
org: target.org,
project: target.project,
org,
project,
id: stringifyUnknown(created.id ?? ""),
name: stringifyUnknown(created.name ?? flags.name),
status: stringifyUnknown(
Expand Down
19 changes: 9 additions & 10 deletions packages/cli/src/commands/alert/issues/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import type { SentryContext } from "../../../context.js";
import { deleteIssueAlertRule } from "../../../lib/api-client.js";
import { parseOrgProjectArg } from "../../../lib/arg-parsing.js";
import { ContextError, ValidationError } from "../../../lib/errors.js";
import { CommandOutput } from "../../../lib/formatters/output.js";
import { logger } from "../../../lib/logger.js";
Expand All @@ -17,7 +16,8 @@ import {
requireExplicitTarget,
} from "../../../lib/mutate-command.js";
import { resolveProjectBoundTargets } from "../../../lib/resolve-target.js";
import { parseIssueRuleArg, resolveIssueAlertRule } from "./rule-resolve.js";
import type { AlertRuleArgument } from "../arguments.js";
import { issueRuleParameter, resolveIssueAlertRule } from "./rule-resolve.js";

const USAGE_HINT =
"sentry alert issues delete <org>/<project>/<rule-id-or-name>";
Expand Down Expand Up @@ -90,18 +90,17 @@ export const deleteCommand = buildDeleteCommand({
positional: {
kind: "tuple",
parameters: [
{
placeholder: "org/project/rule-id-or-name",
brief: "Rule id or name (same as view)",
parse: String,
},
issueRuleParameter(USAGE_HINT, "Rule id or name (same as view)"),
],
},
},
async *func(this: SentryContext, flags: DeleteFlags, arg: string) {
async *func(
this: SentryContext,
flags: DeleteFlags,
ruleArg: AlertRuleArgument,
) {
const { cwd } = this;
const { ref, targetArg } = parseIssueRuleArg(arg, USAGE_HINT);
const parsed = parseOrgProjectArg(targetArg);
const { ref, target: parsed } = await ruleArg.resolved;
requireExplicitTarget(parsed, "Issue alert target", USAGE_HINT);
if (parsed.type !== "explicit") {
throw new ValidationError(
Expand Down
Loading
Loading