Skip to content
32 changes: 29 additions & 3 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,30 @@ import type { SentryContext } from "../../context.js";
import { getAuthToken } from "../../lib/db/auth.js";
```

### Target Resolution

`parseOrgProjectArg()` owns target syntax: a value without `/` is a
`project-search`; `<org>/` is `org-all`; `<org>/<project>` is explicit.
`classifyProjectSearchTarget()` owns the lookup order for project-search:
exact project(s), exact organization, fuzzy project, then not found.

Commands select a capability wrapper instead of performing API lookups:

- `resolveProjectBoundSlug()`, `resolveProjectBoundTarget()`, and
`resolveProjectBoundFromArg()` require one project. An organization match is
an actionable error.
- `resolveProjectBoundTargets()` resolves one or more project targets.
- `resolveOrgOptionalTarget()` / `resolveOrgOptionalFromArg()` allow either an
organization or a project.
- `resolveOrgOnlyTarget()` / `resolveOrgOnlyFromArg()` return the effective
organization while preserving project-first precedence for bare targets.

Do not call `findProjectsBySlug()` or implement org fallback directly in a
command. `sentry init` is the intentional not-found exception: it calls the
classifier with fuzzy recovery disabled, then treats `not-found` as a new
project name. Issue-short-ID recovery in `commands/issue/utils.ts` is not CLI
target resolution and may perform its own project lookup.

### List Command Infrastructure

Two abstraction levels exist for list commands:
Expand All @@ -664,11 +688,13 @@ Two abstraction levels exist for list commands:
2. **`src/lib/org-list.ts`** — `dispatchOrgScopedList` with `OrgListConfig` and a 4-mode handler map: `auto-detect`, `explicit`, `org-all`, `project-search`. Complex commands (`project list`, `issue list`) call `dispatchOrgScopedList` with an `overrides` map directly instead of using `buildOrgListCommand`.

Key rules when writing overrides:
- Each mode handler receives a `HandlerContext<T>` with the narrowed `parsed` plus shared I/O (`stdout`, `cwd`, `flags`). Access parsed fields via `ctx.parsed.org`, `ctx.parsed.projectSlug`, etc. — no manual `Extract<>` casts needed.
- Each mode handler receives a `HandlerContext<T>` with narrowed `parsed`, `cwd`, `flags`, and optional request-scoped project-search classification. Access parsed fields via `ctx.parsed.org`, `ctx.parsed.projectSlug`, etc. — no manual `Extract<>` casts needed.
- Commands with extra fields (e.g., `stderr`, `setContext`) spread the context and add them: `(ctx) => handle({ ...ctx, flags, stderr, setContext })`. Override `ctx.flags` with the command-specific flags type when needed.
- `resolveCursor()` must be called **inside** the `org-all` override closure, not before `dispatchOrgScopedList`, so that `--cursor` validation errors fire correctly for non-org-all modes.
- `handleProjectSearch` errors must use `"Project"` as the `ContextError` resource, not `config.entityName`.
- Always set `orgSlugMatchBehavior` on `dispatchOrgScopedList` to declare how bare-slug org matches are handled. Use `"redirect"` for commands where listing all entities in the org makes sense (e.g., `project list`, `team list`, `issue list`). Use `"error"` for commands where org-all redirect is inappropriate. The pre-check uses cached orgs to avoid N API calls — when the cache is cold, the handler's own org-slug check serves as a safety net (throws `ResolutionError` with a hint).
- For an unscoped bare target, `dispatchOrgScopedList` applies the shared
classifier before dispatch: an organization match becomes `org-all`, while
project results are passed request-scoped to the handler. Scoped and
display-name searches stay in `project-search` and are classified there.

3. **Standalone list commands** (e.g., `span list`, `trace list`) that don't use org-scoped dispatch wire pagination directly in `func()`. See the "List Command Pagination" section above for the pattern.

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ Manage Sentry alert rules
- `sentry alert issues edit <org/project/rule-id-or-name>` — Edit an issue alert rule
- `sentry alert metrics list <target>` — List metric alert rules
- `sentry alert metrics view <org/rule-id-or-name>` — View a metric alert rule
- `sentry alert metrics create <org>` — Create a metric alert rule
- `sentry alert metrics create <target>` — Create a metric alert rule
- `sentry alert metrics delete <org/rule-id-or-name>` — Delete a metric alert rule
- `sentry alert metrics edit <org/rule-id-or-name>` — Edit a metric alert rule

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ sentry alert metrics view my-org/67890
sentry alert metrics view my-org/"P95 latency alert"
```

### `sentry alert metrics create <org>`
### `sentry alert metrics create <target>`

Create a metric alert rule

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/alert/issues/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ 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 { resolveProjectBoundTargets } from "../../../lib/resolve-target.js";
import {
matchToLogicType,
parseJsonObjectList,
Expand Down Expand Up @@ -185,7 +185,7 @@ export const createCommand = buildCommand({
validateIssueRuleArrays(conditions, actions, "actions");

const parsed = parseOrgProjectArg(arg);
const { targets } = await resolveTargetsFromParsedArg(parsed, {
const { targets } = await resolveProjectBoundTargets(parsed, {
cwd,
usageHint: USAGE_HINT,
});
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/alert/issues/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
isConfirmationBypassed,
requireExplicitTarget,
} from "../../../lib/mutate-command.js";
import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js";
import { resolveProjectBoundTargets } from "../../../lib/resolve-target.js";
import { parseIssueRuleArg, resolveIssueAlertRule } from "./rule-resolve.js";

const USAGE_HINT =
Expand Down Expand Up @@ -100,7 +100,7 @@ export const deleteCommand = buildDeleteCommand({
);
}

const { targets } = await resolveTargetsFromParsedArg(parsed, {
const { targets } = await resolveProjectBoundTargets(parsed, {
cwd,
usageHint: USAGE_HINT,
});
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/alert/issues/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { parseOrgProjectArg } from "../../../lib/arg-parsing.js";
import { buildCommand, numberParser } from "../../../lib/command.js";
import { ContextError, ValidationError } from "../../../lib/errors.js";
import { CommandOutput } from "../../../lib/formatters/output.js";
import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js";
import { resolveProjectBoundTargets } from "../../../lib/resolve-target.js";
import {
matchToLogicType,
parseJsonObjectList,
Expand Down Expand Up @@ -260,7 +260,7 @@ export const editCommand = buildCommand({

const { ref, targetArg } = parseIssueRuleArg(arg, USAGE_HINT);
const parsed = parseOrgProjectArg(targetArg);
const { targets } = await resolveTargetsFromParsedArg(parsed, {
const { targets } = await resolveProjectBoundTargets(parsed, {
cwd,
usageHint: USAGE_HINT,
});
Expand Down
32 changes: 25 additions & 7 deletions packages/cli/src/commands/alert/issues/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ import {
} from "../../../lib/org-list.js";
import { withProgress } from "../../../lib/polling.js";
import {
classifyProjectSearchTarget,
type ProjectSearchTargetResolution,
type ResolvedTarget,
resolveTargetsFromParsedArg,
resolveProjectBoundTargets,
} from "../../../lib/resolve-target.js";
import { buildIssueAlertsUrl } from "../../../lib/sentry-urls.js";
import type { ProjectAliasEntry, Writer } from "../../../types/index.js";
Expand Down Expand Up @@ -120,7 +122,6 @@ type IssueAlertListResult = ListResult<IssueAlertRule> & {

const issueAlertListMeta: ListCommandMeta = {
paginationKey: PAGINATION_KEY,
entityName: "issue alert rule",
entityPlural: "issue alert rules",
commandPrefix: "sentry alert issues list",
};
Expand Down Expand Up @@ -196,9 +197,26 @@ async function resolveWebUrl(
return buildIssueAlertsUrl(parsed.org);
}

const { targets } = await resolveTargetsFromParsedArg(parsed, {
let projectSearchResolution: ProjectSearchTargetResolution | undefined;
if (
parsed.type === "project-search" &&
parsed.org === undefined &&
parsed.originalSlug === undefined
) {
const resolution = await classifyProjectSearchTarget(parsed);
if (resolution.kind === "organization") {
logger.warn(
`'${parsed.projectSlug}' is an organization, not a project. Opening organization '${resolution.org}'.`
);
return buildIssueAlertsUrl(resolution.org);
}
projectSearchResolution = resolution;
}

const { targets } = await resolveProjectBoundTargets(parsed, {
cwd,
usageHint: USAGE_HINT,
projectSearchResolution,
});
if (targets.length === 0) {
throw new ContextError("Organization and project", USAGE_HINT);
Expand Down Expand Up @@ -226,17 +244,18 @@ type ResolvedTargetsOptions = {
parsed: ReturnType<typeof parseOrgProjectArg>;
flags: ListFlags;
cwd: string;
projectSearchResolution?: ProjectSearchTargetResolution;
};

// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inherent multi-target resolution, compound cursor, error handling, and display logic
async function handleResolvedTargets(
options: ResolvedTargetsOptions
): Promise<IssueAlertListResult> {
const { parsed, flags, cwd } = options;
const { parsed, flags, cwd, projectSearchResolution } = options;

const { targets, footer, detectedDsns } = await resolveTargetsFromParsedArg(
const { targets, footer, detectedDsns } = await resolveProjectBoundTargets(
parsed,
{ cwd, usageHint: USAGE_HINT }
{ cwd, usageHint: USAGE_HINT, projectSearchResolution }
);

if (targets.length === 0) {
Expand Down Expand Up @@ -580,7 +599,6 @@ export const listCommand = buildListCommand("alert issues", {
cwd,
flags,
parsed,
orgSlugMatchBehavior: "redirect",
// All modes use per-project fetching with compound cursor support
allowCursorInModes: [
"auto-detect",
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/alert/issues/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { openInBrowser } from "../../../lib/browser.js";
import { buildCommand } from "../../../lib/command.js";
import { ContextError } from "../../../lib/errors.js";
import { CommandOutput } from "../../../lib/formatters/output.js";
import { resolveTargetsFromParsedArg } from "../../../lib/resolve-target.js";
import { resolveProjectBoundTargets } from "../../../lib/resolve-target.js";
import { buildIssueAlertsUrl } from "../../../lib/sentry-urls.js";
import {
type IssueRuleResolution,
Expand Down Expand Up @@ -90,7 +90,7 @@ export const viewCommand = buildCommand({
return;
}

const { targets } = await resolveTargetsFromParsedArg(parsed, {
const { targets } = await resolveProjectBoundTargets(parsed, {
cwd,
usageHint: "sentry alert issues view <org>/<project>/<rule-id-or-name>",
});
Expand Down
36 changes: 13 additions & 23 deletions packages/cli/src/commands/alert/metrics/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@

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 { ValidationError } from "../../../lib/errors.js";
import { warning } from "../../../lib/formatters/colors.js";
import { CommandOutput } from "../../../lib/formatters/output.js";
import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../../lib/mutate-command.js";
import { resolveOrg } from "../../../lib/resolve-target.js";
import { resolveOrgOnlyFromArg } from "../../../lib/resolve-target.js";
import {
normalizeProjectList,
parseJsonObjectList,
Expand Down Expand Up @@ -54,25 +53,16 @@ function formatCreated(result: CreateResult): string {
return `Created metric alert rule ${result.id ?? "(unknown id)"} in ${result.org}: ${result.name} (${result.status ?? "active"}).`;
}

async function resolveMetricCreateOrg(
function resolveMetricCreateOrg(
arg: string | undefined,
cwd: string
): Promise<string> {
const parsed = parseOrgProjectArg(arg);
let org: string | undefined;
if (parsed.type === "explicit" || parsed.type === "org-all") {
org = parsed.org;
} else if (parsed.type === "project-search") {
// Metric alert rules are org-scoped, so a bare target is always treated
// as an organization slug here, never as a project search.
org = parsed.projectSlug;
}

const resolved = await resolveOrg({ org, cwd });
if (!resolved) {
throw new ContextError("Organization", "sentry alert metrics create <org>");
}
return resolved.org;
return resolveOrgOnlyFromArg(
arg,
cwd,
"alert metrics create",
"sentry alert metrics create <org>/"
);
}

export const createCommand = buildCommand({
Expand All @@ -85,11 +75,11 @@ export const createCommand = buildCommand({
"Optional fields:\n" +
" --environment, --owner\n\n" +
"Examples:\n" +
" sentry alert metrics create my-org --name 'P95 latency' \\\n" +
" sentry alert metrics create my-org/ --name 'P95 latency' \\\n" +
" --query 'environment:prod' --aggregate 'p95(span.duration)' \\\n" +
" --dataset spans --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" +
" 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",
Expand All @@ -103,8 +93,8 @@ export const createCommand = buildCommand({
kind: "tuple",
parameters: [
{
placeholder: "org",
brief: "Target organization",
placeholder: "target",
brief: "Target project or organization",
parse: String,
},
],
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/alert/metrics/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
isConfirmationBypassed,
requireExplicitTarget,
} from "../../../lib/mutate-command.js";
import { resolveOrgOptionalProjectFromArg } from "../../../lib/resolve-target.js";
import { resolveOrgOnlyFromArg } from "../../../lib/resolve-target.js";
import { parseMetricRuleArg, resolveMetricAlertRule } from "./rule-resolve.js";

const USAGE_HINT = "sentry alert metrics delete <org>/<rule-id-or-name>";
Expand Down Expand Up @@ -79,7 +79,7 @@ export const deleteCommand = buildDeleteCommand({
"Metric alert target",
USAGE_HINT
);
const { org } = await resolveOrgOptionalProjectFromArg(
const org = await resolveOrgOnlyFromArg(
targetArg,
cwd,
"alert metrics delete"
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/alert/metrics/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { buildCommand, numberParser } from "../../../lib/command.js";
import { ValidationError } from "../../../lib/errors.js";
import { warning } from "../../../lib/formatters/colors.js";
import { CommandOutput } from "../../../lib/formatters/output.js";
import { resolveOrgOptionalProjectFromArg } from "../../../lib/resolve-target.js";
import { resolveOrgOnlyFromArg } from "../../../lib/resolve-target.js";
import {
normalizeMetricDataset,
normalizeProjectList,
Expand Down Expand Up @@ -266,7 +266,7 @@ export const editCommand = buildCommand({
const { cwd } = this;
validateMetricEditFlags(flags);
const { ref, targetArg } = parseMetricRuleArg(arg, USAGE_HINT);
const { org } = await resolveOrgOptionalProjectFromArg(
const org = await resolveOrgOnlyFromArg(
targetArg,
cwd,
"alert metrics edit"
Expand Down
Loading
Loading