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
12 changes: 11 additions & 1 deletion packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,16 @@ parsing (`parseSlashSeparatedArg`, `HEX_ID_RE`, ...) in commands and kinds
defined outside `ID_KINDS`. Issue IDs (and `event view`, which takes them)
keep their own syntax.

**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
`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, ...)`
instead of the raw string.

```typescript
import { ID_KINDS, parseIdArgument } from "../../lib/id-arguments.js";

Expand Down Expand Up @@ -334,7 +344,7 @@ 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. `issueIdPositional` already uses them. A
completes cached dashboards. `issueIdsPositional` already uses them. A
parser tagged with `withCompletionTarget`, such as the `target-positional.ts`
parsers behind `listTargetPositional`, completes the same way. Shared value
parsers are not tagged where they live; `PARSER_TARGETS` in
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/grammar/issue-argument.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Types of the parser generated from issue-argument.pegjs. The generator
// copies this file to src/generated/issue-argument-grammar.d.ts.

/** What the caller decides for the grammar. See `parseIssueArgument()`. */
export type IssueArgumentGrammarOptions = {
/** Whether `value` is an `@` selector, such as `@latest`. */
isSelector: (value: string) => boolean;
};

/** Why an issue argument is malformed. */
export type IssueSyntaxError =
/** `<org>/` without an ID */
| "missing-id"
/** `<org>/<project>/<id>` with an empty project or ID */
| "missing-project-or-id"
/** `<org>/-<suffix>` */
| "slash-before-suffix"
/** `-<suffix>` */
| "missing-project"
/** `<project>-` */
| "missing-suffix";

/**
* The parts of an issue argument, as typed: neither validated nor
* normalized.
*/
export type IssueSyntax =
| {
readonly form: "selector";
readonly org?: string;
readonly selector: string;
}
/** `[<prefix>]#<fragment>`, split at the first `#` */
| {
readonly form: "hash";
readonly prefix: string;
readonly fragment: string;
}
| { readonly form: "numeric"; readonly id: string }
/** `<project>:<id>` */
| {
readonly form: "project-id";
readonly project: string;
readonly id: string;
}
/** `[/]<project>-<suffix>` */
| {
readonly form: "short-id";
readonly project: string;
readonly suffix: string;
}
/** `[/]<suffix>` */
| { readonly form: "suffix"; readonly suffix: string }
/** `<org>/<numeric-id>` or `<org>/<project>/<numeric-id>` */
| { readonly form: "org-numeric"; readonly org: string; readonly id: string }
/** `<org>/<project>/<id>` */
| {
readonly form: "org-project-id";
readonly org: string;
readonly project: string;
readonly id: string;
}
/** `<org>/<project>-<suffix>` */
| {
readonly form: "org-short-id";
readonly org: string;
readonly project: string;
readonly suffix: string;
}
/** `<org>/<suffix>` */
| {
readonly form: "org-suffix";
readonly org: string;
readonly suffix: string;
}
| { readonly form: "invalid"; readonly reason: IssueSyntaxError };

export declare class SyntaxError extends Error {
expected: unknown;
found: string | null;
location: unknown;
}

/** Split one trimmed, non-empty issue argument that is not a URL. */
export declare function parse(
input: string,
options: IssueArgumentGrammarOptions,
): IssueSyntax;
111 changes: 111 additions & 0 deletions packages/cli/grammar/issue-argument.pegjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Grammar for one argument that names an issue, for `issue view`, `issue
// explain`, and every other command that takes one:
//
// <numeric-id> 123456789
// [<org>/]@<selector> @latest, my-org/@most_frequent
// <project>-<suffix> cli-G, spotlight-electron-4Y (the project
// is found in any org)
// <suffix> G (the project is auto-detected)
// <project>:<id> CLI:CLI-G, CLI:123456789
// [<org>/]<project>#<id> GitHub-style: my-org/cli#CLI-G, #CLI-G
// <org>/<numeric-id> my-org/123456789
// <org>/<project>-<suffix> my-org/cli-G
// <org>/<suffix> my-org/G (no project: always an error)
// <org>/<project>/<id> my-org/cli/G, my-org/cli/CLI-G,
// my-org/cli/123456789
//
// `pnpm run generate:grammar` compiles it, with the types in
// issue-argument.d.ts, into the gitignored src/generated/.
//
// `parseIssueArgument()` (src/lib/issue-arguments.ts) hands it one line,
// trimmed and not empty; Sentry URLs never get here. The grammar only splits
// the argument into its parts: it accepts any input and never throws, and
// returns `invalid` for a malformed one. The caller validates characters,
// normalizes case, and turns the parts into a `ParsedIssueArg`. It parses
// the parts of a `#` form again, without the `#`.

{
const { isSelector } = options;

function invalid(reason) {
return { form: "invalid", reason };
}
}

argument
= selector
/ hash
/ plain

// `[<org>/]@<selector>`: the org is everything before the first slash
selector
= o:$[^/]* "/" s:$.+ end &{ return isSelector(s); }
{ return { form: "selector", org: o, selector: s }; }
/ s:$.+ end &{ return isSelector(s); } { return { form: "selector", selector: s }; }

// `[<prefix>]#<fragment>`, split at the first `#`; the caller validates both
// and parses them again
hash
= p:$[^#]* "#" f:$.* end { return { form: "hash", prefix: p, fragment: f }; }

// Anything else. A slash takes precedence over a dash, so `my-org/123` names
// org `my-org`, not project `my`.
plain
= i:digits end { return { form: "numeric", id: i }; }
// Both parts non-empty, and no slash in either; else it is no colon form
/ p:$[^:/]+ ":" i:$[^/]+ end { return { form: "project-id", project: p, id: i }; }
/ path
/ shortId
/ s:$.+ end { return { form: "suffix", suffix: s }; }

// `<org>/<rest>`, split at the first slash
path
// `<org>/` (or `/`) without an ID
= [^/]* "/" end { return invalid("missing-id"); }
// `/<rest>`: no org, so the project is found or detected
/ "/" @shortId
/ "/" s:$.+ end { return { form: "suffix", suffix: s }; }
/ o:$[^/]+ "/" r:orgRest { return r.form === "invalid" ? r : { org: o, ...r }; }

// What follows `<org>/`
orgRest
= i:digits end { return { form: "org-numeric", id: i }; }
/ p:$[^/]* "/" r:$.* end {
if (p === "" || r === "") {
return invalid("missing-project-or-id");
}
// `<org>/<project>/<numeric-id>`: the project is redundant
if (/^[0-9]+$/.test(r)) {
return { form: "org-numeric", id: r };
}
return { form: "org-project-id", project: p, id: r };
}
/ p:lastDashPrefix "-" s:$[^-]* end {
if (p === "") {
return invalid("slash-before-suffix");
}
if (s === "") {
return invalid("missing-suffix");
}
return { form: "org-short-id", project: p, suffix: s };
}
/ s:$.+ end { return { form: "org-suffix", suffix: s }; }

// `<project>-<suffix>`, split at the last dash
shortId
= p:lastDashPrefix "-" s:$[^-]* end {
if (p === "") {
return invalid("missing-project");
}
if (s === "") {
return invalid("missing-suffix");
}
return { form: "short-id", project: p, suffix: s };
}

// Everything before the last dash
lastDashPrefix = $(!("-" [^-]* end) .)*

digits = $[0-9]+

end = !.
29 changes: 12 additions & 17 deletions packages/cli/src/commands/event/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,8 @@ import {
timeRangeToApiParams,
} from "../../lib/time-range.js";
import { IssueEventSchema } from "../../types/index.js";
import {
buildCommandHint,
issueIdPositional,
resolveIssue,
} from "../issue/utils.js";
import { type IssueTarget, issuePositional } from "../issue/positional.js";
import { buildCommandHint } from "../issue/utils.js";
import {
appendEventsFlags,
EVENTS_ALIASES,
Expand Down Expand Up @@ -132,28 +129,26 @@ export const listCommand = buildListCommand("event", {
schema: IssueEventSchema,
},
parameters: {
positional: issueIdPositional,
positional: issuePositional({
command: COMMAND_NAME,
commandBase: COMMAND_BASE,
}),
flags: EVENTS_FLAGS,
aliases: EVENTS_ALIASES,
},
async *func(this: SentryContext, flags: EventsFlags, issueArg: string) {
// interceptSubcommand (in buildListCommand) may replace the positional
// with undefined when the user types e.g. "sentry events view" via the
// plural alias. The tip message is already printed — just bail out.
async *func(this: SentryContext, flags: EventsFlags, target: IssueTarget) {
// interceptSubcommand (in buildListCommand) may drop the positional when
// the user types e.g. "sentry events view" via the plural alias. The tip
// message is already printed — just bail out.
const issueArg = target.raw;
if (!issueArg) {
return;
}

const { cwd } = this;
const timeRange = flags.period;

// Resolve issue using shared resolution logic (supports @latest, short IDs, etc.)
const { org, issue } = await resolveIssue({
issueArg,
cwd,
command: COMMAND_NAME,
commandBase: COMMAND_BASE,
});
const { org, issue } = await target.resolved;

// Org is required for region-routed events endpoint
if (!org) {
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/commands/feedback/positional.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* The positional of Feedback commands: a selector or an issue-style
* identifier, parsed before the command runs.
*/

import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js";
import { ContextError } from "../../lib/errors.js";
import {
type IssueArgument,
parseIssueArgument,
} from "../../lib/issue-arguments.js";
import {
type BoundPositional,
boundPositional,
} from "../../lib/positional-parse.js";
import { type ResolvedFeedback, resolveFeedback } from "./utils.js";

/**
* A parsed Feedback argument that resolves to the Feedback. `parsed` is
* `undefined` only when the argument was left out (the SDK invoke path).
*/
export type FeedbackTarget = BoundPositional<
IssueArgument | undefined,
ResolvedFeedback
>;

/**
* Positional for a Feedback selector or issue-style identifier. The argument
* is parsed before the command runs; `target.resolved` resolves it with
* {@link resolveFeedback}.
*
* @param command - The subcommand, for recovery hints
*/
export function feedbackPositional(command: string) {
return {
kind: "tuple",
parameters: [
{
placeholder: "feedback",
brief:
"Feedback: @latest, numeric ID, short ID, <org>/SHORT-ID, or <org>/<project>/<suffix>",
parse: boundPositional(
ORG_PROJECT_TARGET,
(raw) => (raw === undefined ? undefined : parseIssueArgument(raw)),
async (feedback, context: { readonly cwd: string }) => {
if (!feedback) {
throw new ContextError(
"Feedback ID",
`sentry feedback ${command} <feedback>`,
);
}
return await resolveFeedback(feedback, context.cwd, command);
},
),
},
],
} as const;
}
12 changes: 4 additions & 8 deletions packages/cli/src/commands/feedback/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { formatResolvedFeedback } from "../../lib/formatters/feedback.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import { disableResponseCache } from "../../lib/response-cache.js";
import { SentryFeedbackSchema } from "../../types/index.js";
import { feedbackIdPositional, resolveFeedback } from "./utils.js";
import { type FeedbackTarget, feedbackPositional } from "./positional.js";

export const resolveCommand = buildCommand({
docs: {
Expand Down Expand Up @@ -43,16 +43,12 @@ export const resolveCommand = buildCommand({
schema: SentryFeedbackSchema,
},
parameters: {
positional: feedbackIdPositional,
positional: feedbackPositional("resolve"),
},
async *func(this: SentryContext, _flags, feedbackArg: string) {
async *func(this: SentryContext, _flags, target: FeedbackTarget) {
// A cached inbox could select Feedback that has already been resolved elsewhere.
disableResponseCache();
const { org, feedback } = await resolveFeedback(
feedbackArg,
this.cwd,
"resolve",
);
const { org, feedback } = await target.resolved;
const updated = await updateIssueStatus(feedback.id, "resolved", {
orgSlug: org,
});
Expand Down
12 changes: 4 additions & 8 deletions packages/cli/src/commands/feedback/spam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { formatSpamFeedback } from "../../lib/formatters/feedback.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import { disableResponseCache } from "../../lib/response-cache.js";
import { SentryFeedbackSchema } from "../../types/index.js";
import { feedbackIdPositional, resolveFeedback } from "./utils.js";
import { type FeedbackTarget, feedbackPositional } from "./positional.js";

export const spamCommand = buildCommand({
docs: {
Expand Down Expand Up @@ -43,15 +43,11 @@ export const spamCommand = buildCommand({
schema: SentryFeedbackSchema,
},
parameters: {
positional: feedbackIdPositional,
positional: feedbackPositional("spam"),
},
async *func(this: SentryContext, _flags, feedbackArg: string) {
async *func(this: SentryContext, _flags, target: FeedbackTarget) {
disableResponseCache();
const { org, feedback } = await resolveFeedback(
feedbackArg,
this.cwd,
"spam",
);
const { org, feedback } = await target.resolved;
const updated = await updateIssueStatus(feedback.id, "ignored", {
orgSlug: org,
});
Expand Down
Loading
Loading