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
3 changes: 3 additions & 0 deletions apps/cli-docs/src/fragments/commands/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
# List issues in a specific project
sentry issue list my-org/frontend

# Several projects in the same org
sentry issue list my-org/frontend,backend,worker

# All projects in an org
sentry issue list my-org/

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@ Split by argument type — do not mix the conventions:
values on commas; commas may be part of the value. Every project passed to
`project create` requires a `name:platform` pair — there is no space-separated
form, with or without an explicit org. Project names cannot contain whitespace.
- **Exception: `org/project` list selectors.** That positional is a single
optional token, not a variadic list, and Sentry slugs cannot contain commas.
`issue list acme/web,api` is parsed as two project slugs. See
`splitProjectSelector` in `src/lib/arg-parsing.ts`.
- **Optional flags → comma-separated (sometimes also repeatable).** Split the
flag value on `,`: `--features errors,tracing`, set-commits `--path a,b`,
`auth login --scope a,b`. Use `value.split(",")` (repeatable array flags:
Expand Down
1 change: 1 addition & 0 deletions packages/cli/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ sentry issue list [<org>/<project>] [--json]

**Target syntax**:
- `<org>/<project>` - Explicit organization and project (e.g., `my-org/frontend`)
- `<org>/<project>,<project>` - Several projects in the same org (e.g., `my-org/web,api`)
- `<org>/` - All projects in the specified organization
- `<project>` - Search for project by name across all accessible organizations
- *(omit)* - Auto-detect from DSN or config
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ List issues in a project
# List issues in a specific project
sentry issue list my-org/frontend

# Several projects in the same org
sentry issue list my-org/frontend,backend,worker

# All projects in an org
sentry issue list my-org/

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1455,6 +1455,7 @@ export const listCommand = buildListCommand("issue", {
"Target patterns:\n" +
" sentry issue list # auto-detect from DSN or config\n" +
" sentry issue list <org>/<proj> # explicit org and project\n" +
" sentry issue list <org>/a,b # several projects in the same org\n" +
" sentry issue list <org>/ # all projects in org (trailing / required)\n" +
" sentry issue list <project> # find project across all orgs\n\n" +
`${targetPatternExplanation()}\n\n` +
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/commands/project/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ import {
listTeams,
MEMBER_PROJECT_CREATION_DISABLED_DETAIL,
} from "../../lib/api-client.js";
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
import {
explicitProjectSlugs,
parseOrgProjectArg,
} from "../../lib/arg-parsing.js";
import { buildCommand } from "../../lib/command.js";
import {
ApiError,
Expand Down Expand Up @@ -396,6 +399,12 @@ function parseProjectName(
const parsedName = parseOrgProjectArg(rawName);
switch (parsedName.type) {
case "explicit":
if (explicitProjectSlugs(parsedName).length > 1) {
throw new ValidationError(
"Create one project per name:platform pair. Comma-separated names are not supported.",
"name"
);
}
return {
org: parsedName.org,
name: parsedName.project,
Expand Down
49 changes: 45 additions & 4 deletions packages/cli/src/commands/project/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type PaginatedResponse,
} from "../../lib/api-client.js";
import {
explicitProjectSlugs,
type ParsedOrgProject,
parseOrgProjectArg,
} from "../../lib/arg-parsing.js";
Expand Down Expand Up @@ -176,9 +177,11 @@ export function buildContextKey(
case "auto-detect":
parts.push("type:auto");
break;
case "explicit":
parts.push(`type:explicit:${parsed.org}/${parsed.project}`);
case "explicit": {
const slugs = explicitProjectSlugs(parsed);
parts.push(`type:explicit:${parsed.org}/${slugs.join(",")}`);
break;
}
case "project-search":
parts.push(`type:search:${parsed.projectSlug}`);
break;
Expand Down Expand Up @@ -428,6 +431,45 @@ export async function handleExplicit(
};
}

/**
* Explicit `org/project` mode, including comma-separated slug lists.
*/
async function handleExplicitProjects(
parsed: Extract<ParsedOrgProject, { type: "explicit" }>,
flags: ListFlags
): Promise<ListResult<ProjectWithOrg>> {
const slugs = explicitProjectSlugs(parsed);
if (slugs.length === 1) {
return handleExplicit(parsed.org, slugs[0], flags);
}

const results = await Promise.all(
slugs.map((slug) => handleExplicit(parsed.org, slug, flags))
);
const items = results.flatMap((result) => result.items);
const missing = slugs.filter((_, index) => {
const result = results[index];
return result === undefined || result.items.length === 0;
});
Comment on lines +450 to +453

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: When listing multiple projects with a --platform filter, projects that exist but don't match the platform are incorrectly reported as "Missing" in the output hint.
Severity: MEDIUM

Suggested Fix

Modify the logic to distinguish between a project that was not found and a project that was found but filtered out by the platform. The handleExplicit function could return a status indicating the reason for an empty result, allowing handleExplicitProjects to generate a more accurate hint message for users.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/cli/src/commands/project/list.ts#L450-L453

Potential issue: In the `handleExplicitProjects` function, when a user lists multiple
projects (e.g., `org/proj1,proj2`) and applies a `--platform` filter, any project that
exists but does not match the platform is incorrectly reported as "Missing". This occurs
because the logic checks for `items.length === 0` to determine if a project is missing.
However, the `handleExplicit` function returns an empty `items` array both when a
project is not found (404) and when it is filtered out by the platform flag. This leads
to a confusing hint message suggesting a project does not exist when it was simply
filtered out.

Did we get this right? 👍 / 👎 to inform future reviews.


if (items.length === 0) {
return {
items: [],
hint:
`No projects found among: ${slugs.map((slug) => `'${slug}'`).join(", ")}.\n` +
`Tip: Use 'sentry project list ${parsed.org}/' to see all projects`,
};
}

return {
items,
hint:
missing.length > 0
? `Missing: ${missing.join(", ")}. Tip: Use 'sentry project list ${parsed.org}/' to see all projects`
: `Tip: Use 'sentry project view ${parsed.org}/<project>' for details`,
};
}

export type OrgAllOptions = {
org: string;
flags: ListFlags;
Expand Down Expand Up @@ -742,8 +784,7 @@ export const listCommand = buildListCommand("project", {
orgSlugMatchBehavior: "redirect",
overrides: {
"auto-detect": (ctx) => handleAutoDetect(ctx.cwd, flags),
explicit: (ctx) =>
handleExplicit(ctx.parsed.org, ctx.parsed.project, flags),
explicit: (ctx) => handleExplicitProjects(ctx.parsed, flags),
"org-all": (ctx) => {
// Build context key and resolve cursor only in org-all mode, after
// dispatchOrgScopedList has already validated --cursor is allowed here.
Expand Down
107 changes: 106 additions & 1 deletion packages/cli/src/lib/arg-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,60 @@ function looksLikeDisplayName(input: string): boolean {
return input.includes(" ");
}

/**
* Split a project selector on commas.
*
* Sentry slugs cannot contain commas, so `org/web,api,worker` is unambiguous.
* Trims whitespace, drops empty tokens, and de-duplicates while preserving
* first-seen order.
*
* @param rawProject - Raw project selector, potentially containing commas
* @returns A non-empty list of unique project slugs in input order
* @throws {ValidationError} When every token is empty (`org/,,,`)
*/
export function splitProjectSelector(
rawProject: string
): [string, ...string[]] {
const seen = new Set<string>();
const slugs: string[] = [];
for (const part of rawProject.split(",")) {
const slug = part.trim();
if (slug === "" || seen.has(slug)) {
continue;
}
seen.add(slug);
slugs.push(slug);
}
const first = slugs[0];
if (first === undefined) {
throw new ValidationError(
"Invalid project slug: comma-separated list is empty.",
"project"
);
}
return [first, ...slugs.slice(1)];
}

/**
* Project slugs from an explicit `org/project` parse.
*
* A single slug stays on {@link ParsedOrgProject}'s `project` field. A
* comma-separated list also sets `projects` (including the first slug).
*
* @param parsed - Explicit project target returned by {@link parseOrgProjectArg}
* @returns A non-empty list containing the selected project slugs
*/
export function explicitProjectSlugs(
parsed: Extract<ParsedOrgProject, { type: "explicit" }>
): [string, ...string[]] {
const { projects } = parsed;
if (projects === undefined) {
return [parsed.project];
}
const [first, ...rest] = projects;
return first === undefined ? [parsed.project] : [first, ...rest];
}

// ---------------------------------------------------------------------------
// Issue short ID detection
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -537,6 +591,12 @@ export type ParsedOrgProject =
type: typeof ProjectSpecificationType.Explicit;
org: string;
project: string;
/**
* All project slugs when the user passed a comma-separated list
* (`org/web,api`). Includes {@link project} as the first element.
* Absent for a single slug so existing equality checks stay stable.
*/
projects?: string[];
/** True if any slug was normalized */
normalized?: boolean;
}
Expand Down Expand Up @@ -722,7 +782,11 @@ function parseSlashOrgProject(input: string): ParsedOrgProject {
};
}

// "sentry/cli" → explicit org and project
// "sentry/cli" or "sentry/web,api,worker"
if (rawProject.includes(",")) {
return parseExplicitProjectList(no, rawProject);
}

rejectAtSelector(rawProject, "project slug");
if (looksLikeDisplayName(rawProject)) {
// Spaces → display name, not a slug. Skip slug validation and let the
Expand All @@ -747,13 +811,53 @@ function parseSlashOrgProject(input: string): ParsedOrgProject {
};
}

/**
* Parse `org/web,api,worker` into an explicit target with `projects` set.
*
* Display names are rejected here: a comma list is a slug selector, not a
* search. Each token is validated independently so a bad slug fails at parse
* time instead of as a 404 against the concatenated string.
*/
function parseExplicitProjectList(
org: { slug: string; normalized: boolean },
rawProject: string
): ParsedOrgProject {
const tokens = splitProjectSelector(rawProject);
if (tokens.some((token) => looksLikeDisplayName(token))) {
throw new ValidationError(
"Comma-separated project targets must be slugs, not display names.",
"project"
);
}

let anyNormalized = org.normalized;
const normalizeToken = (token: string): string => {
rejectAtSelector(token, "project slug");
const np = normalizeSlug(token);
validateResourceId(np.slug, "project slug");
anyNormalized = anyNormalized || np.normalized;
return np.slug;
};
const first = normalizeToken(tokens[0]);
const slugs = [first, ...tokens.slice(1).map(normalizeToken)];

return {
type: "explicit",
org: org.slug,
project: first,
...(slugs.length > 1 && { projects: slugs }),
...(anyNormalized && { normalized: true }),
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent first-project on multi-slug targets

High Severity

parseOrgProjectArg now splits org/a,b into multiple slugs, but many handlers still read only parsed.project. Those commands silently use the first slug instead of listing every project or rejecting the list. Shared help text advertises [,project...] for all list commands, so agents that already emit this form can operate on the wrong scope without an error.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b97dd36. Configure here.

}

/**
* Parse an org/project positional argument string.
*
* Supports the following patterns:
* - `undefined` or empty → auto-detect from DSN/config
* - `https://sentry.io/organizations/org/...` → extract from Sentry URL
* - `sentry/cli` → explicit org and project
* - `sentry/web,api` → explicit org and multiple projects
* - `sentry/` → org with all projects
* - `/cli` → search for project across all orgs (leading slash)
* - `cli` → search for project across all orgs
Expand All @@ -764,6 +868,7 @@ function parseSlashOrgProject(input: string): ParsedOrgProject {
* @example
* parseOrgProjectArg(undefined) // { type: "auto-detect" }
* parseOrgProjectArg("sentry/cli") // { type: "explicit", org: "sentry", project: "cli" }
* parseOrgProjectArg("sentry/web,api") // { type: "explicit", ..., projects: ["web","api"] }
* parseOrgProjectArg("sentry/") // { type: "org-all", org: "sentry" }
* parseOrgProjectArg("/cli") // { type: "project-search", projectSlug: "cli" }
* parseOrgProjectArg("cli") // { type: "project-search", projectSlug: "cli" }
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/lib/list-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ export const LIST_TARGET_POSITIONAL = {
parameters: [
{
placeholder: "org/project",
brief: "<org>/ (all projects), <org>/<project>, or <project> (search)",
brief:
"<org>/ (all projects), <org>/<project>[,project...], or <project>",
parse: String,
optional: true as const,
},
Expand Down Expand Up @@ -100,7 +101,8 @@ export function targetPatternExplanation(cursorNote?: string): string {
"The trailing slash on <org>/ is significant — without it, the argument " +
"is treated as a project name search (e.g., 'sentry' searches for a " +
"project named 'sentry', while 'sentry/' lists all projects in the " +
"'sentry' org).";
"'sentry' org). Comma-separated project slugs after the org " +
"(`acme/web,api,worker`) list those projects together.";
return cursorNote ? `${base} ${cursorNote}` : base;
}

Expand Down
35 changes: 27 additions & 8 deletions packages/cli/src/lib/resolve-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
resolveOrgDisplayName,
} from "./api-client.js";
import {
explicitProjectSlugs,
looksLikeIssueShortId,
type ParsedOrgProject,
parseOrgProjectArg,
Expand Down Expand Up @@ -63,6 +64,7 @@ import {
ContextError,
ResolutionError,
ValidationError,
validationError,
withAuthGuard,
} from "./errors.js";
import { fuzzyMatch } from "./fuzzy.js";
Expand Down Expand Up @@ -1958,6 +1960,15 @@ export async function resolveOrgProjectTarget(

switch (parsed.type) {
case "explicit": {
const slugs = explicitProjectSlugs(parsed);
if (slugs.length > 1) {
throw validationError(
`This command takes one project, not ${slugs.length}.`,
slugs.map((slug) => `sentry ${commandName} ${parsed.org}/${slug}`),
"project",
`List commands accept comma-separated slugs: sentry issue list ${parsed.org}/${slugs.join(",")}`
);
}
const org = await resolveEffectiveOrg(parsed.org);
return withTelemetryContext({ org, project: parsed.project });
}
Expand Down Expand Up @@ -2187,17 +2198,25 @@ export async function resolveTargetsFromParsedArg(
// Resolve DSN-style org identifiers (e.g. "o1081365" → "my-org") before
// hitting the API, mirroring resolveOrgProjectTarget's explicit branch.
const org = await resolveEffectiveOrg(parsed.org);
const projectId = await fetchProjectId(org, parsed.project);
return {
targets: [
{
const slugs = explicitProjectSlugs(parsed);
const targets: ResolvedTarget[] = await Promise.all(
slugs.map(async (project) => {
const projectId = await fetchProjectId(org, project);
return {
org,
project: parsed.project,
project,
projectId,
orgDisplay: org,
projectDisplay: parsed.project,
},
],
projectDisplay: project,
};
})
);
return {
targets,
footer:
targets.length > 1
? `Showing results from ${targets.length} projects in ${org}`
: undefined,
};
}

Expand Down
Loading
Loading