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
31 changes: 23 additions & 8 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,18 +248,33 @@ target is known. The result is a `target` string for `parseOrgProjectInput`
and the IDs. Add a kind to `ID_KINDS` rather than validating IDs in a command;
the `sentry-cli/no-ad-hoc-id-arguments` lint rule rejects hand-written ID
parsing (`parseSlashSeparatedArg`, `HEX_ID_RE`, ...) in commands and kinds
defined outside `ID_KINDS`. Issue IDs keep their own syntax; `event view`,
which also takes them, checks for an issue first (`parseEventViewInput`).
defined outside `ID_KINDS`. A kind without names gets a hint to search
(`search`) when text is passed for an ID: `validate()` throws it, so it comes
after any recovery.

Some kinds name items without an ID (`IdKind.names`): a dashboard by its
title (`ID_KINDS.dashboard`; unquoted words after a target are one title),
an issue by search text (`ID_KINDS.issue`). Then what is not an ID
comes back as `name`. A bare first word before a name comes back as
`nameWithTarget` too: the parser does not decide whether it names an org, so
dashboard commands check once they resolve (`resolveDashboardInput`), and
fall back to the whole input as the title. Dashboard commands take
`dashboardArguments` from `commands/dashboard/arguments.ts`; `dashboard list`
reads a target and a title filter instead (`parseDashboardListArgs`).
`event view` checks for an issue before an event ID, with
`ID_KINDS.issue.distinctForm` (`parseEventViewInput`).
`test/lib/argument-conventions.test.ts` feeds the same inputs to every
command that takes IDs, issues, or releases, through the command's own
positional; add a new command to its tables, and encode a legitimate
difference (such as `needsProject`) in its row.

**Issue positionals:** issue IDs, short IDs, selectors, and issue URLs are
parsed by `parseIssueArgument` (`src/lib/issue-arguments.ts`), whose forms
are defined by `grammar/issue-argument.pegjs`. A command that takes one
issue declares `issuePositional({ command })` (or `issueParameter` inside a
tuple) from `src/commands/issue/positional.ts`: `func` takes an
**Issue positionals:** issue IDs, short IDs, selectors, issue URLs, and
search text are parsed by `parseIssueArgument` (`src/lib/issue-arguments.ts`)
with `ID_KINDS.issue`. An identifier is one whole token, which
`src/lib/issue-identifier.ts` takes apart with `grammar/issue-argument.pegjs`;
text with spaces is a search, which resolves to the one issue it matches.
A command that takes one issue declares `issuePositional({ command })` (or
`issueParameter` inside a tuple) from `src/commands/issue/positional.ts`: `func` takes an
`IssueTarget` and awaits `target.resolved` for the org and issue. A command
that resolves it another way (`resolveOrgAndIssueId`, or recovery hints that
keep its other arguments) passes `requireIssueArgument(target.parsed, ...)`
Expand Down Expand Up @@ -366,7 +381,7 @@ 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`, `parseDashboardTarget`) remain for the allow-listed
`parseIssueTarget`) remain for the allow-listed
commands that still parse in `func`; new 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
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/grammar/id-arguments.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ export type IdArgumentsGrammarOptions = {
mayBeId: (value: string) => boolean;
/** The ID in a Sentry URL, or `null` for anything else. */
idUrl: (value: string) => IdUrl | null;
/** Whether the kind names items without an ID, too */
names: boolean;
/**
* Whether an ID is a whole token, with its own syntax for the org,
* project, and URL. Only kinds with names use it.
*/
wholeIds: boolean;
};

/** A token that names an ID, maybe after a target: `[<target>/]<id>`. */
Expand All @@ -45,6 +52,10 @@ export type IdArgumentsSyntax = {
readonly baseUrl?: string;
/** Whether the ID came before the target */
readonly reversed?: boolean;
/** The name, when the tokens name no ID (kinds with names only) */
readonly name?: string;
/** Whether `target` is a bare word before `name`, maybe its first word */
readonly bareTarget?: boolean;
};

export declare class SyntaxError extends Error {
Expand Down
74 changes: 73 additions & 1 deletion packages/cli/grammar/id-arguments.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@
// (`<target>/<id>`). The ID is always the last path segment, and slashes
// pasted after it are ignored.
//
// A kind may also name an item without its ID (`options.names`), such as a
// dashboard by its title. Then anything that is not an ID is a name rather
// than a target or a malformed ID:
//
// <name> one token that is no `[<target>/]<id>`,
// slashes and all
// <target> <name> after a target; the name takes all the
// tokens left, joined with spaces
//
// A token after a target is an ID when the kind says it is one, or may be
// one; the rest of the tokens are then IDs too. A kind whose IDs carry their
// own org, project, and URL syntax (`options.wholeIds`, such as issue short
// IDs) has its IDs as whole tokens, for the kind to take apart.
//
// `pnpm run generate:grammar` compiles it, with the types in
// id-arguments.d.ts, into the gitignored src/generated/; the build,
// typecheck, and test scripts run it.
Expand All @@ -26,7 +40,12 @@
// the IDs, validates them, and parses the target like any org/project target.

{
const { optional, isId, mayBeId, idUrl } = options;
const { optional, isId, mayBeId, idUrl, names, wholeIds } = options;

// Whether a token is, or may be, an ID rather than a name
function idLike(value) {
return isId(value) || mayBeId(value);
}

// `[<org>/[<project>/]]` before an ID; the caller makes it a target
function pathTarget(prefix) {
Expand All @@ -51,6 +70,7 @@ arguments
...(u.baseUrl && { baseUrl: u.baseUrl }),
};
}
/ &{ return names; } @namedArguments
// Any other URL is a target
/ sep t:url x:more { return { target: t, ids: x }; }
// `<id> <org>/<project> [<id>...]`: the arguments are reversed. When IDs
Expand Down Expand Up @@ -83,6 +103,48 @@ arguments
// `<target> [<id>...]`
/ sep t:token x:more { return { target: t, ids: x }; }

// The arguments of a kind with names. A name is never a malformed ID: what
// may be an ID is one.
namedArguments
// An ID with its own syntax: the whole token, and whole tokens after it
= &{ return wholeIds; } sep i:token &{ return idLike(i); }
x:(sep t:token { return { text: t, id: t }; })*
{ return { ids: [{ text: i, id: i }, ...x] }; }
// Any other URL is a target
/ sep t:url n:afterTarget { return { target: t, ...n }; }
// `<id> <org>/<project> [<id>...]`: reversed; only a real ID can come
// first
/ !{ return wholeIds; } sep i:segment &{ return isId(i); } sep !idPath
t:$([^/\0]* "/" [^\0]*) x:more
{ return { target: t, ids: [{ text: i, id: i }, ...x], reversed: true }; }
// `[<org>/[<project>/]]<id> [<id>...]`, with slugs before the ID: a
// name may hold slashes too (`Latency/p95`, `Errors.v2/2026`)
/ !{ return wholeIds; } sep p:slugPath x:more {
return {
...pathTarget(p.target ?? ""),
ids: [{ text: p.text, id: p.id }, ...x],
};
}
// Anything else in one token is a name, slashes and all
/ sep n:token end { return { ids: [], name: n }; }
// `<target> <id> [<id>...]` or `<target> <name>`; a bare word as the
// target may instead be the name's first word, which the caller can
// check once it knows what the word names
/ sep t:token n:afterTarget {
return {
target: t,
...n,
...(n.name !== undefined && !t.includes("/") && { bareTarget: true }),
};
}

// What follows a target: IDs, when the first token is or may be one, or a
// name, which takes every token left.
afterTarget
= end { return { ids: [] }; }
/ &(sep i:token &{ return idLike(i); }) x:more { return { ids: x }; }
/ x:(sep @token)+ { return { ids: [], name: x.join(" ") }; }

// The IDs after the first token. Each may repeat the target:
// `<target>/<id>`.
more
Expand All @@ -95,6 +157,16 @@ idPath
{ return { text: text(), target: t, id: i }; }
/ i:idSegment argEnd { return { text: text(), id: i }; }

// `[<org>/[<project>/]]<id>` in one token, where the org and project are
// slugs
slugPath
= t:$(slug "/" slug) "/" i:idSegment argEnd
{ return { text: text(), target: t, id: i }; }
/ t:slug "/" i:idSegment argEnd { return { text: text(), target: t, id: i }; }
/ i:idSegment argEnd { return { text: text(), id: i }; }

slug = $[a-zA-Z0-9_-]+

// An ID, and the slashes after it
idSegment
= i:segment "/"* &{ return isId(i); } { return i; }
Expand Down
17 changes: 3 additions & 14 deletions packages/cli/lint-rules/cli-oxlint-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ const BOUND_TARGET_TYPES = new Set([
"ProjectBoundTarget",
"ReleaseTarget",
"BoundArguments",
"DashboardArguments",
"DashboardListArguments",
"EventViewArguments",
"LogListArguments",
"LogViewArguments",
Expand All @@ -75,6 +77,7 @@ const BOUND_TARGET_TYPES = new Set([
"SpanViewArguments",
"TraceLogsArguments",
"TraceViewArguments",
"WidgetAddArguments",
]);

function boundTargetParams(fn) {
Expand Down Expand Up @@ -176,12 +179,6 @@ const AD_HOC_ID_PARSING = new Set([
* whose file no longer uses them is reported, so the list only shrinks.
*/
const AD_HOC_ID_PARSING_ALLOWED = new Map([
// #1730 parses dashboard arguments with their own grammar
["/src/commands/dashboard/restore.ts", "#1730"],
["/src/commands/dashboard/revisions.ts", "#1730"],
["/src/commands/dashboard/view.ts", "#1730"],
["/src/commands/dashboard/widget/delete.ts", "#1730"],
["/src/commands/dashboard/widget/edit.ts", "#1730"],
// Follow-up (#1564): several issues per command (`issue view`,
// `issue explain`, `issue merge`) need an array issue positional
["/src/commands/issue/utils.ts", "#1564: array issue positional"],
Expand All @@ -207,14 +204,6 @@ const UNBOUND_TARGET_PARSING = new Set([

/** Commands still allowed to, each with its follow-up; a ratchet too. */
const UNBOUND_TARGET_PARSING_ALLOWED = new Map([
// #1730 parses dashboard arguments with their own grammar
["/src/commands/dashboard/list.ts", "#1730"],
["/src/commands/dashboard/restore.ts", "#1730"],
["/src/commands/dashboard/revisions.ts", "#1730"],
["/src/commands/dashboard/view.ts", "#1730"],
["/src/commands/dashboard/widget/add.ts", "#1730"],
["/src/commands/dashboard/widget/delete.ts", "#1730"],
["/src/commands/dashboard/widget/edit.ts", "#1730"],
// Follow-ups (#1564):
// `[<org>/[<project>/]]<rule>`, resolving to every matching project
["/src/commands/alert/issues/create.ts", "#1564: alert rule target"],
Expand Down
Loading
Loading