Skip to content
Open
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
21 changes: 15 additions & 6 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ itself, and an omitted target arrives as auto-detect. The
`sentry-cli/require-target-resolution` lint rule fails a function that takes
one of these types and never reads `.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,
multi })` for org-only completion, name globs, and comma-separated projects).
`dispatchOrgScopedList` resolves each target mode its own way, so `func`
takes a `ListCommandTarget` and passes `const { parsed, filter } = await
target.resolved` to it; resolving only applies a pasted URL's server. The
lint rule covers `ListCommandTarget` too.

Resolvers need no command name: they read it from `this.commandPrefix`, which
Stricli's `forCommand` and the SDK invoker set (test mock contexts set it
too). In a destructive command (`buildDeleteCommand`, or `buildCommand` with
Expand All @@ -276,12 +285,12 @@ dashboard use a completion-aware parser from `src/lib/completion-target.ts`
instead of `String`: `parseOrgProjectTarget` completes `org/`, `org/project`,
and project aliases; `parseOrgTarget` completes org slugs only;
`parseIssueTarget` adds cached issue short IDs; `parseDashboardTarget`
completes cached dashboards. `LIST_TARGET_POSITIONAL`,
`LIST_ORG_TARGET_POSITIONAL`, and `issueIdPositional` already use them. A
completes cached dashboards. `issueIdPositional` already uses them. A
parser tagged with `withCompletionTarget`, such as the `target-positional.ts`
parsers, completes the same way. 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
parsers behind `listTargetPositional`, completes the same way. 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
(`is:unr` → `is:unresolved`) and every flag that parses with `parsePeriod`
completes relative periods. Fixed values (search keys, period units) come
from the modules that define them through `COMPLETION_VALUES`.
Expand Down Expand Up @@ -791,7 +800,7 @@ boundary.

Two abstraction levels exist for list commands:

1. **`src/lib/list-command.ts`** — `buildOrgListCommand` factory + shared Stricli parameter constants (`LIST_TARGET_POSITIONAL`, `LIST_JSON_FLAG`, `LIST_CURSOR_FLAG`, `buildListLimitFlag`). Use this for simple entity lists like `team list` and `repo list`.
1. **`src/lib/list-command.ts`** — `buildOrgListCommand` factory + shared Stricli parameter constants (`LIST_TARGET_POSITIONAL` and `listTargetPositional`, `LIST_JSON_FLAG`, `LIST_CURSOR_FLAG`, `buildListLimitFlag`). Use this for simple entity lists like `team list` and `repo list`.

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`.

Expand Down
1 change: 1 addition & 0 deletions packages/cli/lint-rules/cli-oxlint-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function readsProperty(node, object, property) {

/** Target positional types from `src/lib/target-positional.ts`. */
const BOUND_TARGET_TYPES = new Set([
"ListCommandTarget",
"OrgOptionalTarget",
"OrgProjectTarget",
"ProjectBoundTarget",
Expand Down
15 changes: 10 additions & 5 deletions packages/cli/src/commands/alert/issues/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { buildProjectAliasMap } from "../../../lib/alias.js";
import type { IssueAlertRule } from "../../../lib/api/alerts.js";
import { paginate } from "../../../lib/api/infrastructure.js";
import { listIssueAlertsPaginated } from "../../../lib/api-client.js";
import { parseOrgProjectArg } from "../../../lib/arg-parsing.js";
import type { ParsedOrgProject } from "../../../lib/arg-parsing.js";
import { openInBrowser } from "../../../lib/browser.js";
import {
advancePaginationState,
Expand Down Expand Up @@ -69,6 +69,7 @@ import {
resolveProjectBoundTargets,
} from "../../../lib/resolve-target.js";
import { buildIssueAlertsUrl } from "../../../lib/sentry-web-urls.js";
import type { ListCommandTarget } from "../../../lib/target-positional.js";
import type { ProjectAliasEntry, Writer } from "../../../types/index.js";
import {
assertAlertListLimit,
Expand Down Expand Up @@ -156,7 +157,7 @@ async function fetchRulesForTarget(
}

async function resolveWebUrl(
parsed: ReturnType<typeof parseOrgProjectArg>,
parsed: ParsedOrgProject,
cwd: string,
): Promise<string> {
if (parsed.type === "explicit") {
Expand Down Expand Up @@ -210,7 +211,7 @@ async function resolveWebUrl(
// Mode handler

type ResolvedTargetsOptions = {
parsed: ReturnType<typeof parseOrgProjectArg>;
parsed: ParsedOrgProject;
flags: ListFlags;
cwd: string;
projectSearchResolution?: ProjectSearchTargetResolution;
Expand Down Expand Up @@ -553,9 +554,13 @@ export const listCommand = buildListCommand("alert issues", {
},
aliases: { ...LIST_BASE_ALIASES, w: "web", q: "query" },
},
async *func(this: SentryContext, flags: ListFlags, target?: string) {
async *func(
this: SentryContext,
flags: ListFlags,
target: ListCommandTarget,
) {
const { cwd } = this;
const parsed = parseOrgProjectArg(target);
const { parsed } = await target.resolved;
assertAlertListLimit(flags.limit);

if (flags.web) {
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/commands/dsn/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
listOrganizationDsns,
listProjectDsns,
} from "../../lib/api/projects.js";
import { parseOrgProjectArg, validateLimit } from "../../lib/arg-parsing.js";
import { validateLimit } from "../../lib/arg-parsing.js";
import {
advancePaginationState,
buildMultiTargetContextKey,
Expand Down Expand Up @@ -52,6 +52,7 @@ import {
resolveProjectBoundTargets,
resolveTargetSlugs,
} from "../../lib/resolve-target.js";
import type { ListCommandTarget } from "../../lib/target-positional.js";

const PAGINATION_KEY = "dsn-list";
const USAGE_HINT = "sentry dsn list <org>/<project>";
Expand Down Expand Up @@ -349,10 +350,14 @@ export const listCommand = buildListCommand("dsn", {
},
aliases: { n: "limit" },
},
async *func(this: SentryContext, flags: BaseListFlags, target?: string) {
async *func(
this: SentryContext,
flags: BaseListFlags,
target: ListCommandTarget,
) {
const result: ListResult<DsnListItem> = await dispatchOrgScopedList({
config: listConfig,
parsed: parseOrgProjectArg(target),
parsed: (await target.resolved).parsed,
cwd: this.cwd,
flags,
allowCursorInModes: ["auto-detect", "explicit", "project-search"],
Expand Down
21 changes: 13 additions & 8 deletions packages/cli/src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { extractRequiredScopes } from "../../lib/api-scope.js";
import {
formatProjectTarget,
looksLikeIssueShortId,
parseOrgProjectArg,
type ParsedOrgProject,
rejectIssueCommandTokenListTarget,
} from "../../lib/arg-parsing.js";

Expand Down Expand Up @@ -61,7 +61,7 @@ import {
CURSOR_KEYWORDS,
LIST_BASE_ALIASES,
LIST_MAX_LIMIT,
LIST_TARGET_POSITIONAL,
listTargetPositional,
paginationHint,
parseCursorFlag,
targetPatternExplanation,
Expand Down Expand Up @@ -90,6 +90,7 @@ import {
unscopedCountFilterKeys,
} from "../../lib/search-query.js";
import { isSaaS } from "../../lib/sentry-web-urls.js";
import type { ListCommandTarget } from "../../lib/target-positional.js";
import {
appendPeriodHint,
formatTimeRangeFlag,
Expand Down Expand Up @@ -1001,7 +1002,7 @@ async function handleOrganizationIssues(

/** Options for {@link handleResolvedTargets}. */
type ResolvedTargetsOptions = {
parsed: ReturnType<typeof parseOrgProjectArg>;
parsed: ParsedOrgProject;
flags: ListFlags;
cwd: string;
timeRange: TimeRange;
Expand Down Expand Up @@ -1710,7 +1711,7 @@ export const listCommand = buildListCommand("issue", {
},
output: issueListOutput,
parameters: {
positional: LIST_TARGET_POSITIONAL,
positional: listTargetPositional({ multi: true }),
flags: {
query: {
kind: "parsed",
Expand Down Expand Up @@ -1753,7 +1754,11 @@ export const listCommand = buildListCommand("issue", {
t: "period",
},
},
async *func(this: SentryContext, rawFlags: ListFlagsInput, target?: string) {
async *func(
this: SentryContext,
rawFlags: ListFlagsInput,
target: ListCommandTarget,
) {
const { cwd } = this;
const log = logger.withTag("issue.list");

Expand All @@ -1765,11 +1770,11 @@ export const listCommand = buildListCommand("issue", {
sort: rawFlags.sort ?? defaultIssueSort(),
};

if (target) {
rejectIssueCommandTokenListTarget(target);
if (target.raw) {
rejectIssueCommandTokenListTarget(target.raw);
}

const parsed = parseOrgProjectArg(target, { multi: true });
const { parsed } = await target.resolved;

// Auto-recover: user passed an issue short ID (e.g., "ARMAX-3E" or a
// lowercase/multi-segment variant like "javascript-react-mr-1b") instead
Expand Down
14 changes: 9 additions & 5 deletions packages/cli/src/commands/project/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
explicitProjectSlugs,
formatProjectTarget,
type ParsedOrgProject,
parseListTarget,
} from "../../lib/arg-parsing.js";
import { getDefaultOrganization } from "../../lib/db/defaults.js";
import {
Expand All @@ -45,7 +44,7 @@ import {
buildListCommand,
buildListLimitFlag,
LIST_BASE_ALIASES,
NAMED_LIST_TARGET_POSITIONAL,
listTargetPositional,
paginationHint,
targetPatternExplanation,
} from "../../lib/list-command.js";
Expand All @@ -71,6 +70,7 @@ import {
resolveAllTargets,
} from "../../lib/resolve-target.js";
import { getApiBaseUrl } from "../../lib/sentry-client.js";
import type { ListCommandTarget } from "../../lib/target-positional.js";
import { shellQuote } from "../../lib/utils.js";
import type { SentryProject } from "../../types/index.js";

Expand Down Expand Up @@ -922,7 +922,7 @@ export const listCommand = buildListCommand("project", {
jsonTransform: jsonTransformListResult,
} satisfies OutputConfig<ListResult<ProjectWithOrg>>,
parameters: {
positional: NAMED_LIST_TARGET_POSITIONAL,
positional: listTargetPositional({ names: true, multi: true }),
flags: {
limit: buildListLimitFlag("projects"),
platform: {
Expand All @@ -934,10 +934,14 @@ export const listCommand = buildListCommand("project", {
},
aliases: { ...LIST_BASE_ALIASES, p: "platform" },
},
async *func(this: SentryContext, cliFlags: ListFlags, rawTarget?: string) {
async *func(
this: SentryContext,
cliFlags: ListFlags,
target: ListCommandTarget,
) {
const { cwd } = this;

const { parsed, filter } = parseListTarget(rawTarget, { multi: true });
const { parsed, filter } = await target.resolved;
const flags: ProjectListFlags = { ...cliFlags, filter };

const result = await dispatchOrgScopedList({
Expand Down
14 changes: 8 additions & 6 deletions packages/cli/src/commands/release/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ import {
listReleasesPaginated,
type ReleaseSortValue,
} from "../../lib/api-client.js";
import {
type ParsedOrgProject,
parseOrgProjectArg,
} from "../../lib/arg-parsing.js";
import type { ParsedOrgProject } from "../../lib/arg-parsing.js";
import {
colorTag,
escapeMarkdownInline,
Expand Down Expand Up @@ -45,6 +42,7 @@ import {
toNumericId,
} from "../../lib/resolve-target.js";
import { buildReleaseUrl } from "../../lib/sentry-web-urls.js";
import type { ListCommandTarget } from "../../lib/target-positional.js";
import type { SentryRelease } from "../../types/index.js";
import { fmtCrashFree } from "./view.js";

Expand Down Expand Up @@ -711,9 +709,13 @@ export const listCommand = buildListCommand("release", {
},
aliases: { ...LIST_BASE_ALIASES, s: "sort", e: "environment", t: "period" },
},
async *func(this: SentryContext, flags: ListFlags, target?: string) {
async *func(
this: SentryContext,
flags: ListFlags,
target: ListCommandTarget,
) {
const { cwd } = this;
const parsed = parseOrgProjectArg(target);
const { parsed } = await target.resolved;
// Flatten: -e prod,dev -e staging → ["prod", "dev", "staging"]
const envFilter = flags.environment
? [...flags.environment]
Expand Down
69 changes: 47 additions & 22 deletions packages/cli/src/lib/arg-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,8 @@ export type ParsedOrgProject =

/**
* A list target that filters names (`<org>/web-*`, `web-*`), from the
* target grammar when {@link parseListTarget} passes its `isNameFilter` hook.
* target grammar when {@link parseListTargetInput} passes its
* `isNameFilter` hook.
*/
export type ParsedNameFilter = {
type: "name-filter";
Expand Down Expand Up @@ -803,37 +804,61 @@ export type ListTarget = {
readonly filter?: string;
};

/** Which target forms a list command accepts beyond `[<org>/][<project>]`. */
export type ListTargetOptions = {
/** Read a glob as a name filter (`<org>/web-*`) */
readonly names?: boolean;
/** Accept comma-separated projects (`<org>/web,api`) */
readonly multi?: boolean;
};

/** A parsed list target and the server of a pasted Sentry URL. */
export type ListTargetInput = {
readonly target: ListTarget;
/** Base URL of a pasted Sentry web URL, for `applySentryUrlContext` */
readonly baseUrl?: string;
};

/**
* Parse the target of a list command that filters by name. A glob is a
* filter rather than a project (no slug contains glob characters; the
* grammar decides): `<org>/web-*` lists that organization's matches, and
* `web-*` or `/web-*` the auto-detected ones. Every other form parses like
* {@link parseOrgProjectArg}, which also applies a pasted URL's server.
* Parse the target of a list command, without side effects: the server of a
* pasted Sentry URL is returned as `baseUrl` instead of applied (see
* {@link parseOrgProjectInput}).
*
* With `names`, a glob is a filter rather than a project (no slug contains
* glob characters; the grammar decides): `<org>/web-*` lists that
* organization's matches, and `web-*` or `/web-*` the auto-detected ones.
* Every other form parses like {@link parseOrgProjectInput}.
*
* @example
* parseListTarget("acme/web-*") // { parsed: { type: "org-all", org: "acme" }, filter: "web-*" }
* parseListTarget("web-*") // { parsed: { type: "auto-detect" }, filter: "web-*" }
* parseListTarget("acme/web") // { parsed: { type: "explicit", org: "acme", project: "web" } }
* parseListTargetInput("acme/web-*", { names: true }).target
* // { parsed: { type: "org-all", org: "acme" }, filter: "web-*" }
* parseListTargetInput("web-*", { names: true }).target
* // { parsed: { type: "auto-detect" }, filter: "web-*" }
* parseListTargetInput("acme/web").target
* // { parsed: { type: "explicit", org: "acme", project: "web" } }
*/
export function parseListTarget(
export function parseListTargetInput(
arg: string | undefined,
options: { multi?: boolean } = {},
): ListTarget {
options: ListTargetOptions = {},
): ListTargetInput {
const hooks = targetHooks(options);
const { parsed, baseUrl } = parseTargetInput(arg, (text) =>
parseTarget(text, { ...targetHooks(options), isNameFilter: isGlobFilter }),
options.names
? parseTarget(text, { ...hooks, isNameFilter: isGlobFilter })
: parseTarget(text, hooks),
);
if (baseUrl) {
applySentryUrlContext(baseUrl);
}
if (parsed.type !== "name-filter") {
return { parsed };
return { target: { parsed }, baseUrl };
}
return {
parsed:
parsed.org === undefined
? { type: "auto-detect" }
: { type: "org-all", org: parsed.org },
filter: parsed.filter,
target: {
parsed:
parsed.org === undefined
? { type: "auto-detect" }
: { type: "org-all", org: parsed.org },
filter: parsed.filter,
},
baseUrl,
};
}

Expand Down
Loading
Loading