From 1603ece7c2e49b9ab505e8379ed85f2485ec67dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 21 Sep 2026 17:49:00 +0200 Subject: [PATCH] feat(arg-parsing): accept comma-separated project slugs Allow same-org project subsets on issue and project list. Issue listing passes all slugs through one SDK request; other commands preserve commas as literal positional content. Co-authored-by: Cursor --- apps/cli-docs/src/fragments/commands/issue.md | 3 + packages/cli/AGENTS.md | 6 + packages/cli/CONTRIBUTING.md | 1 + .../skills/sentry-cli/references/issue.md | 3 + packages/cli/src/commands/issue/list.ts | 210 +++++++++++++----- packages/cli/src/commands/project/list.ts | 71 +++++- packages/cli/src/lib/alias.ts | 9 +- packages/cli/src/lib/api/feedback.ts | 3 +- packages/cli/src/lib/api/issues.ts | 38 ++-- packages/cli/src/lib/arg-parsing.ts | 145 +++++++++++- packages/cli/src/lib/org-list.ts | 4 +- packages/cli/src/lib/resolve-target.ts | 116 +++++++++- packages/cli/test/commands/issue/list.test.ts | 184 ++++++++++++++- .../cli/test/commands/project/create.test.ts | 15 ++ .../cli/test/commands/project/list.test.ts | 145 +++++++++++- packages/cli/test/lib/api-client.test.ts | 32 ++- .../cli/test/lib/arg-parsing.property.test.ts | 40 ++++ packages/cli/test/lib/arg-parsing.test.ts | 60 +++++ 18 files changed, 985 insertions(+), 100 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 531e2c2be0..b7d5146336 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -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/ diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 155e16c99b..46071eb09b 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -248,6 +248,12 @@ 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: opted-in `org/project` list selectors.** Commands whose API can + query several projects together may parse their single optional target token + as a list because Sentry slugs cannot contain commas. For example, + `issue list acme/web,api` selects two projects. Pass + `{ allowProjectList: true }` to `parseOrgProjectArg`; never enable it for + mutation or single-project commands. - **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: diff --git a/packages/cli/CONTRIBUTING.md b/packages/cli/CONTRIBUTING.md index 4397e3b864..94154813c9 100644 --- a/packages/cli/CONTRIBUTING.md +++ b/packages/cli/CONTRIBUTING.md @@ -18,6 +18,7 @@ sentry issue list [/] [--json] **Target syntax**: - `/` - Explicit organization and project (e.g., `my-org/frontend`) +- `/,` - Several projects in the same org (e.g., `my-org/web,api`) - `/` - All projects in the specified organization - `` - Search for project by name across all accessible organizations - *(omit)* - Auto-detect from DSN or config diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index a6dce81d7c..c98b7316a0 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -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/ diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index cc7a46849c..92370885a8 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -17,6 +17,7 @@ import { } from "../../lib/api-client.js"; import { extractRequiredScopes } from "../../lib/api-scope.js"; import { + formatProjectTarget, looksLikeIssueShortId, parseOrgProjectArg, rejectIssueCommandTokenListTarget, @@ -80,6 +81,7 @@ import { type ProjectSearchTargetResolution, type ResolvedTarget, resolveProjectBoundTargets, + resolveProjectIdsInOrg, } from "../../lib/resolve-target.js"; import { SEARCH_SYNTAX_REFERENCE, @@ -426,7 +428,8 @@ async function fetchIssuesForTarget( target.project, { ...options, - projectId: target.projectId, + projects: + target.projectId === undefined ? undefined : [target.projectId], groupStatsPeriod: options.groupStatsPeriod, start: options.start, end: options.end, @@ -641,32 +644,35 @@ function appendIssueFlags(base: string, flags: ListFlags): string { return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; } -function nextPageHint(org: string, flags: ListFlags): string { - return appendIssueFlags(`sentry issue list ${org}/ -c next`, flags); -} - -function prevPageHint(org: string, flags: ListFlags): string { - return appendIssueFlags(`sentry issue list ${org}/ -c prev`, flags); +/** Build the `-c next` / `-c prev` resume command for an org-scoped listing. */ +function pageHint( + direction: "next" | "prev", + target: string, + flags: ListFlags +): string { + return appendIssueFlags(`sentry issue list ${target} -c ${direction}`, flags); } /** - * Fetch org-wide issues, auto-paginating from the start or resuming from a cursor. + * Fetch issues for an organization, optionally restricted to a project set. * * When `cursor` is provided (--cursor resume), fetches a single page to keep the * cursor chain intact. Otherwise auto-paginates up to the requested limit. */ -async function fetchOrgAllIssues( +async function fetchOrganizationIssues( org: string, flags: Pick, timeRange: TimeRange, options: { cursor?: string; + /** Resolved numeric project IDs; omit for an org-wide listing. */ + projects?: readonly number[]; onPage?: (fetched: number, limit: number) => void; } ): Promise { const apiOpts = buildListApiOptions(flags.json, flags.fields); const timeParams = timeRangeToApiParams(timeRange); - const { cursor, onPage } = options; + const { cursor, onPage, projects } = options; // When resuming with --cursor, fetch a single page so the cursor chain stays intact. if (cursor) { @@ -677,6 +683,7 @@ async function fetchOrgAllIssues( perPage, sort: flags.sort, ...timeParams, + projects, groupStatsPeriod: apiOpts.groupStatsPeriod, collapse: apiOpts.collapse, }); @@ -689,6 +696,7 @@ async function fetchOrgAllIssues( limit: flags.limit, sort: flags.sort, ...timeParams, + projects, groupStatsPeriod: apiOpts.groupStatsPeriod, collapse: apiOpts.collapse, onPage, @@ -696,30 +704,99 @@ async function fetchOrgAllIssues( return { issues, nextCursor }; } -/** Options for {@link handleOrgAllIssues}. */ -type OrgAllIssuesOptions = { +/** Options for {@link handleOrganizationIssues}. */ +type OrganizationIssuesOptions = { org: string; + /** Project slugs from `org/a,b`; omit for an org-wide listing. */ + projects?: readonly string[]; flags: ListFlags; timeRange: TimeRange; }; /** - * Handle org-all mode for issues: cursor-paginated listing of all issues in an org. + * Compute and persist project aliases for a comma-separated selector so the + * ALIAS column and `sentry issue view ` work exactly as they do for + * multi-target listings. + * + * An explicit selector does not depend on the working directory, so the + * aliases are stored without a DSN fingerprint and resolve from any + * directory, including one whose DSNs point at other projects. + * + * @returns Map from `org/project` to its alias + */ +function registerSelectorAliases( + org: string, + projects: readonly string[] +): Map { + const { aliasMap, entries } = buildProjectAliasMap( + projects.map((project) => ({ target: { org, project } })) + ); + setProjectAliases(entries); + return aliasMap; +} + +/** Resolved request scope for {@link handleOrganizationIssues}. */ +type OrganizationIssuesScope = { + /** Org slug; the dispatcher has already resolved DSN-style identifiers. */ + org: string; + /** Numeric project IDs for the SDK `project` param; absent for org-all. */ + projectFilter?: number[]; + /** Positional form used in titles and pagination hints. */ + target: string; + /** Cursor-stack context key. */ + contextKey: string; +}; + +/** + * Resolve the project filter and cursor context for an org-scoped listing. + * + * Org-all keeps its historical `org:` context key so stored cursors + * survive upgrades. Selectors get a `projects:/` key built from the + * sorted slugs: the server returns the same results in any order, so + * `-c next` keeps working when the slugs are retyped in a different order. + */ +async function resolveOrganizationIssuesScope( + options: OrganizationIssuesOptions +): Promise { + const { org, projects, flags, timeRange } = options; + const searchParams = { + sort: flags.sort, + period: serializeTimeRange(timeRange), + q: flags.query, + }; + if (!projects) { + return { + org, + target: formatProjectTarget(org), + contextKey: buildPaginationContextKey("org", org, searchParams), + }; + } + return { + org, + projectFilter: await resolveProjectIdsInOrg(org, projects), + target: formatProjectTarget(org, projects), + contextKey: buildPaginationContextKey( + "projects", + formatProjectTarget(org, [...projects].sort()), + searchParams + ), + }; +} + +/** + * List issues for one organization, either org-wide (`org/`) or filtered to + * an explicit project set (`org/web,api`) in a single server-side request. * * Uses a sort+query-aware context key so cursors from different searches are * never accidentally reused. Returns an {@link IssueListResult} — the caller * is responsible for rendering (JSON or human output). */ -async function handleOrgAllIssues( - options: OrgAllIssuesOptions +async function handleOrganizationIssues( + options: OrganizationIssuesOptions ): Promise { - const { org, flags, timeRange } = options; - // Encode sort + query in context key so cursors from different searches don't collide. - const contextKey = buildPaginationContextKey("org", org, { - sort: flags.sort, - period: serializeTimeRange(timeRange), - q: flags.query, - }); + const { projects, flags, timeRange } = options; + const { org, projectFilter, target, contextKey } = + await resolveOrganizationIssuesScope(options); const { cursor, direction } = resolveCursor( flags.cursor, PAGINATION_KEY, @@ -734,8 +811,9 @@ async function handleOrgAllIssues( json: flags.json, }, (setMessage) => - fetchOrgAllIssues(org, flags, timeRange, { + fetchOrganizationIssues(org, flags, timeRange, { cursor, + projects: projectFilter, onPage: (fetched, limit) => setMessage( `Fetching issues, ${fetched} and counting (up to ${limit})...` @@ -752,37 +830,47 @@ async function handleOrgAllIssues( const hasMore = !!nextCursor; const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); + const navHints = { + prevHint: pageHint("prev", target, flags), + nextHint: pageHint("next", target, flags), + }; + + // Register aliases before the empty-page return so `issue view ` + // always reflects the selector that was just queried, never a previous one. + const aliasMap = projects + ? registerSelectorAliases(org, projects) + : undefined; + if (issues.length === 0) { - const nav = paginationHint({ - hasPrev, - hasMore, - prevHint: prevPageHint(org, flags), - nextHint: nextPageHint(org, flags), - }); - const hint = nav - ? `No issues on this page. ${nav}` - : `No issues found in organization '${org}'.`; + const nav = paginationHint({ hasPrev, hasMore, ...navHints }); + let hint: string; + if (nav) { + hint = `No issues on this page. ${nav}`; + } else if (projects) { + hint = `No issues found in ${target}.`; + } else { + hint = `No issues found in organization '${org}'.`; + } return { items: [], hasMore, hasPrev, nextCursor, hint }; } - // isMultiProject=true: org-all shows issues from every project, so the ALIAS - // column is needed to identify which project each issue belongs to. - const displayRows: IssueTableRow[] = issues.map((issue) => ({ - issue, - // org-all: org context comes from the `org` param; issue.organization may be absent - orgSlug: org, - formatOptions: { - projectSlug: issue.project?.slug ?? "", - isMultiProject: true, - }, - })); - - const nav = paginationHint({ - hasPrev, - hasMore, - prevHint: prevPageHint(org, flags), - nextHint: nextPageHint(org, flags), + // Org-wide and explicit project-list queries can contain several projects, + // so the ALIAS column identifies which project each issue belongs to. + const displayRows: IssueTableRow[] = issues.map((issue) => { + const projectSlug = issue.project?.slug ?? ""; + return { + issue, + // org-all: org context comes from the `org` param; issue.organization may be absent + orgSlug: org, + formatOptions: { + projectSlug, + projectAlias: aliasMap?.get(`${org}/${projectSlug}`), + isMultiProject: true, + }, + }; }); + + const nav = paginationHint({ hasPrev, hasMore, ...navHints }); const hintParts: string[] = []; if (hasMore) { hintParts.push(`Showing ${issues.length} issues (more available)`); @@ -800,7 +888,10 @@ async function handleOrgAllIssues( nextCursor, hint: hintParts.join("\n"), displayRows, - title: `Issues in ${org}`, + title: projects ? `Issues in ${target}` : `Issues in ${org}`, + // Org-all never showed a footer tip; a project selector spans several + // projects, so point at the ALIAS-based `issue view` form. + ...(projects && { footerMode: "multi" as const }), compact: resolveCompact(flags.compact, displayRows.length), }; } @@ -1457,6 +1548,7 @@ export const listCommand = buildListCommand("issue", { "Target patterns:\n" + " sentry issue list # auto-detect from DSN or config\n" + " sentry issue list / # explicit org and project\n" + + " sentry issue list /a,b # several projects in the same org\n" + " sentry issue list / # all projects in org (trailing / required)\n" + " sentry issue list # find project across all orgs\n\n" + `${targetPatternExplanation()}\n\n` + @@ -1539,7 +1631,7 @@ export const listCommand = buildListCommand("issue", { rejectIssueCommandTokenListTarget(target); } - const parsed = parseOrgProjectArg(target); + const parsed = parseOrgProjectArg(target, { allowProjectList: true }); // Auto-recover: user passed an issue short ID (e.g., "ARMAX-3E" or a // lowercase/multi-segment variant like "javascript-react-mr-1b") instead @@ -1613,14 +1705,24 @@ export const listCommand = buildListCommand("issue", { // Bare slug: the project wins when one exists. If none does and the // slug is an organization, list that org. `/` is the explicit form. // Multi-target modes (auto-detect, explicit, project-search) handle - // compound cursor pagination themselves via handleResolvedTargets. + // compound cursor pagination themselves via handleResolvedTargets. A + // comma-separated `/a,b` selector is one org-scoped request + // instead, so it shares the org-all handler and its server-side cursor. allowCursorInModes: ["auto-detect", "explicit", "project-search"], overrides: { "auto-detect": resolveAndHandle, - explicit: resolveAndHandle, + explicit: (ctx) => + ctx.parsed.projects + ? handleOrganizationIssues({ + org: ctx.parsed.org, + projects: ctx.parsed.projects, + flags, + timeRange, + }) + : resolveAndHandle(ctx), "project-search": resolveAndHandle, "org-all": (ctx) => - handleOrgAllIssues({ + handleOrganizationIssues({ org: ctx.parsed.org, flags, timeRange, diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 586c3eba7a..7e70e0fa7b 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -19,6 +19,8 @@ import { type PaginatedResponse, } from "../../lib/api-client.js"; import { + explicitProjectSlugs, + formatProjectTarget, type ParsedOrgProject, parseOrgProjectArg, } from "../../lib/arg-parsing.js"; @@ -55,6 +57,7 @@ import { import { withProgress } from "../../lib/polling.js"; import { classifyProjectSearchTarget, + findProjectsInOrg, type ProjectSearchTargetResolution, projectSearchNotFoundSuggestions, type ResolvedTarget, @@ -176,7 +179,9 @@ export function buildContextKey( parts.push("type:auto"); break; case "explicit": - parts.push(`type:explicit:${parsed.org}/${parsed.project}`); + parts.push( + `type:explicit:${formatProjectTarget(parsed.org, explicitProjectSlugs(parsed))}` + ); break; case "project-search": parts.push(`type:search:${parsed.projectSlug}`); @@ -428,6 +433,63 @@ export async function handleExplicit( }; } +/** + * Comma-separated `org/web,api` mode. + * + * Each slug is looked up directly ({@link findProjectsInOrg}); the hint + * reports slugs that do not exist separately from slugs that exist but were + * excluded by `--platform`. Lookup failures other than 404 propagate like + * org-all so a network or permission error is never rendered as "no + * projects found". + */ +async function handleExplicitProjects( + org: string, + slugs: readonly string[], + flags: ListFlags +): Promise> { + const { found, missing } = await withProgress( + { message: "Fetching projects...", json: flags.json }, + () => findProjectsInOrg(org, slugs) + ); + const selected = found.map((project) => ({ ...project, orgSlug: org })); + const items = filterByPlatform(selected, flags.platform); + const includedSlugs = new Set(items.map((project) => project.slug)); + const platformFiltered = selected + .filter((project) => !includedSlugs.has(project.slug)) + .map((project) => project.slug); + + if (found.length === 0) { + return { + items: [], + hint: + `No projects found among: ${slugs.map((slug) => `'${slug}'`).join(", ")}.\n` + + `Tip: Use 'sentry project list ${org}/' to see all projects`, + }; + } + + const hintParts: string[] = []; + if (missing.length > 0) { + hintParts.push( + `Missing: ${missing.join(", ")}. Tip: Use 'sentry project list ${org}/' to see all projects` + ); + } + if (platformFiltered.length > 0) { + hintParts.push( + `No match for platform '${flags.platform}': ${platformFiltered.join(", ")}` + ); + } + if (hintParts.length === 0) { + hintParts.push( + `Tip: Use 'sentry project view ${org}/' for details` + ); + } + + return { + items, + hint: hintParts.join("\n"), + }; +} + export type OrgAllOptions = { org: string; flags: ListFlags; @@ -635,6 +697,7 @@ export const listCommand = buildListCommand("project", { " sentry project list # auto-detect from DSN or config\n" + " sentry project list / # all projects in org (paginated)\n" + " sentry project list / # show specific project\n" + + " sentry project list /a,b # show several projects\n" + " sentry project list # find project across all orgs\n\n" + `${targetPatternExplanation("Cursor pagination (--cursor) requires the / form.")}\n\n` + "Pagination:\n" + @@ -681,7 +744,7 @@ export const listCommand = buildListCommand("project", { async *func(this: SentryContext, flags: ListFlags, target?: string) { const { cwd } = this; - const parsed = parseOrgProjectArg(target); + const parsed = parseOrgProjectArg(target, { allowProjectList: true }); const result = await dispatchOrgScopedList({ config: projectListMeta, @@ -691,7 +754,9 @@ export const listCommand = buildListCommand("project", { overrides: { "auto-detect": (ctx) => handleAutoDetect(ctx.cwd, flags), explicit: (ctx) => - handleExplicit(ctx.parsed.org, ctx.parsed.project, flags), + ctx.parsed.projects + ? handleExplicitProjects(ctx.parsed.org, ctx.parsed.projects, flags) + : handleExplicit(ctx.parsed.org, ctx.parsed.project, flags), "org-all": (ctx) => { // Build context key and resolve cursor only in org-all mode, after // dispatchOrgScopedList has already validated --cursor is allowed here. diff --git a/packages/cli/src/lib/alias.ts b/packages/cli/src/lib/alias.ts index 5b29831a06..1a88005783 100644 --- a/packages/cli/src/lib/alias.ts +++ b/packages/cli/src/lib/alias.ts @@ -349,12 +349,13 @@ export type AliasMapResult = { * prefix. The returned `aliasMap` is keyed by `"org/project"` composite key; * `entries` is suitable for passing to `setProjectAliases`. * - * @param results - Fetch results that each carry a {@link ResolvedTarget} + * @param results - Fetch results that each carry at least the `org` and + * `project` slugs of a {@link ResolvedTarget} * @returns Alias map and DB entries */ -export function buildProjectAliasMap( - results: T[] -): AliasMapResult { +export function buildProjectAliasMap< + T extends { target: Pick }, +>(results: T[]): AliasMapResult { const entries: Record = {}; const pairs = results.map((r) => ({ org: r.target.org, diff --git a/packages/cli/src/lib/api/feedback.ts b/packages/cli/src/lib/api/feedback.ts index 798acbf4cc..f7825d7afe 100644 --- a/packages/cli/src/lib/api/feedback.ts +++ b/packages/cli/src/lib/api/feedback.ts @@ -89,7 +89,8 @@ export async function listFeedback( start: options.start, end: options.end, startCursor: options.cursor, - projectId: options.projectId, + projects: + options.projectId === undefined ? undefined : [options.projectId], collapse: buildIssueListCollapse({ shouldCollapseStats: false }), } ); diff --git a/packages/cli/src/lib/api/issues.ts b/packages/cli/src/lib/api/issues.ts index 19c08614bb..ee68e8ab1d 100644 --- a/packages/cli/src/lib/api/issues.ts +++ b/packages/cli/src/lib/api/issues.ts @@ -117,13 +117,14 @@ export const ISSUE_DETAIL_COLLAPSE: IssueCollapseField[] = [ ]; /** - * List issues for a project with pagination control. + * List issues for an organization with optional project filtering. * * Uses the @sentry/api SDK's `listOrganizationIssues` for type-safe * query parameters, and extracts pagination from the response Link header. * * @param orgSlug - Organization slug - * @param projectSlug - Project slug (empty string for org-wide listing) + * @param projectSlug - Single-slug fallback used when `options.projects` is + * absent; pass an empty string for org-wide listing * @param options - Query and pagination options * @returns Single page of issues with cursor metadata */ @@ -136,10 +137,12 @@ export async function listIssuesPaginated( perPage?: number; sort?: IssueSort; statsPeriod?: string; - /** Numeric project ID. When provided, uses the `project` query param - * instead of `project:` search syntax, avoiding "not actively - * selected" errors. */ - projectId?: number; + /** + * Project IDs or slugs sent as the repeated `project` query param. Selects + * projects directly, bypassing the "actively selected" requirement that + * `project:` search syntax is subject to. + */ + projects?: ReadonlyArray; /** Controls the time resolution of inline stats data. "auto" adapts to statsPeriod. */ groupStatsPeriod?: "" | "14d" | "24h" | "auto"; /** Fields to collapse (omit) from the response for performance. @@ -151,13 +154,11 @@ export async function listIssuesPaginated( end?: string; } = {} ): Promise> { - // When we have a numeric project ID, use the `project` query param (Array) - // instead of `project:` in the search query. The API's `project` param - // selects the project directly, bypassing the "actively selected" requirement. - let projectFilter = ""; - if (!options.projectId && projectSlug) { - projectFilter = `project:${projectSlug}`; - } + // Prefer the API's repeated `project` parameter. A lone slug retains the + // search-query fallback for callers that have not resolved project identity. + const projects = options.projects?.length ? [...options.projects] : undefined; + const projectFilter = + projects === undefined && projectSlug ? `project:${projectSlug}` : ""; const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); const config = await getOrgSdkConfig(orgSlug); @@ -166,7 +167,7 @@ export async function listIssuesPaginated( ...config, path: { organization_id_or_slug: orgSlug }, query: { - project: options.projectId ? [options.projectId] : undefined, + project: projects, // Convert empty string to undefined so the SDK omits the param entirely; // sending `query=` causes the Sentry API to behave differently than // omitting the parameter. @@ -205,7 +206,8 @@ export type IssuesPage = { * Safety-bounded by {@link MAX_PAGINATION_PAGES} to prevent runaway requests. * * @param orgSlug - Organization slug - * @param projectSlug - Project slug (empty string for org-wide) + * @param projectSlug - Single-slug fallback used when `options.projects` is + * absent; pass an empty string for org-wide listing * @param options - Query, sort, and limit options * @returns Issues (up to `limit` items) and a cursor for the next page if available */ @@ -217,8 +219,8 @@ export async function listIssuesAllPages( limit: number; sort?: IssueSort; statsPeriod?: string; - /** Numeric project ID for direct project selection via query param. */ - projectId?: number; + /** Project IDs or slugs to select in one organization-scoped request. */ + projects?: ReadonlyArray; /** Controls the time resolution of inline stats data. "auto" adapts to statsPeriod. */ groupStatsPeriod?: "" | "14d" | "24h" | "auto"; /** Resume pagination from this cursor instead of starting from the beginning. */ @@ -254,7 +256,7 @@ export async function listIssuesAllPages( statsPeriod: options.statsPeriod, start: options.start, end: options.end, - projectId: options.projectId, + projects: options.projects, groupStatsPeriod: options.groupStatsPeriod, collapse: options.collapse, }); diff --git a/packages/cli/src/lib/arg-parsing.ts b/packages/cli/src/lib/arg-parsing.ts index f7afaa8720..621dfed466 100644 --- a/packages/cli/src/lib/arg-parsing.ts +++ b/packages/cli/src/lib/arg-parsing.ts @@ -48,6 +48,63 @@ 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/,,,`) + */ +function splitProjectSelector(rawProject: string): [string, ...string[]] { + const [first, ...rest] = [ + ...new Set(rawProject.split(",").map((part) => part.trim())), + ].filter((slug) => slug !== ""); + if (first === undefined) { + throw new ValidationError( + "Invalid project slug: comma-separated list is empty.", + "project" + ); + } + return [first, ...rest]; +} + +/** + * 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 +): [string, ...string[]] { + return parsed.projects ?? [parsed.project]; +} + +/** + * Format an explicit target back into its positional form. + * + * Used for titles, pagination hints, and cursor context keys so every + * surface renders `org/web,api` (or `org/` for org-wide) identically. + * + * @param org - Organization slug + * @param projects - Project slugs; omit or pass an empty list for `org/` + */ +export function formatProjectTarget( + org: string, + projects?: readonly string[] +): string { + return projects && projects.length > 0 + ? `${org}/${projects.join(",")}` + : `${org}/`; +} + // --------------------------------------------------------------------------- // Issue short ID detection // --------------------------------------------------------------------------- @@ -537,6 +594,13 @@ 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 and + * always holds at least two slugs. Absent for a single slug so existing + * equality checks stay stable. + */ + projects?: [string, string, ...string[]]; /** True if any slug was normalized */ normalized?: boolean; } @@ -679,7 +743,10 @@ function rejectAtSelector(value: string, label: string): void { * Applies {@link normalizeSlug} to both components and validates against * URL injection characters. */ -function parseSlashOrgProject(input: string): ParsedOrgProject { +function parseSlashOrgProject( + input: string, + allowProjectList: boolean +): ParsedOrgProject { const slashIndex = input.indexOf("/"); const rawOrg = input.slice(0, slashIndex); const rawProject = input.slice(slashIndex + 1); @@ -722,7 +789,11 @@ function parseSlashOrgProject(input: string): ParsedOrgProject { }; } - // "sentry/cli" → explicit org and project + // "sentry/web,api,worker" for list commands that explicitly opt in + if (allowProjectList && 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 @@ -736,8 +807,7 @@ function parseSlashOrgProject(input: string): ParsedOrgProject { org: no.slug, }; } - const np = normalizeSlug(rawProject); - validateResourceId(np.slug, "project slug"); + const np = validateProjectSlugToken(rawProject); const normalized = no.normalized || np.normalized; return { type: "explicit", @@ -747,6 +817,59 @@ function parseSlashOrgProject(input: string): ParsedOrgProject { }; } +/** + * Validate one project slug token: reject `@` selectors, normalize, and + * check for URL-injection characters. Shared by the single-slug and + * comma-list explicit paths. + */ +function validateProjectSlugToken(token: string): { + slug: string; + normalized: boolean; +} { + rejectAtSelector(token, "project slug"); + const np = normalizeSlug(token); + validateResourceId(np.slug, "project slug"); + return np; +} + +/** + * 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 [firstToken, ...restTokens] = splitProjectSelector(rawProject); + if ([firstToken, ...restTokens].some(looksLikeDisplayName)) { + throw new ValidationError( + "Comma-separated project targets must be slugs, not display names.", + "project" + ); + } + + const first = validateProjectSlugToken(firstToken); + const rest = restTokens.map(validateProjectSlugToken); + const normalized = + org.normalized || + first.normalized || + rest.some((token) => token.normalized); + const [second, ...others] = rest; + + return { + type: "explicit", + org: org.slug, + project: first.slug, + ...(second !== undefined && { + projects: [first.slug, second.slug, ...others.map((t) => t.slug)], + }), + ...(normalized && { normalized: true }), + }; +} + /** * Parse an org/project positional argument string. * @@ -754,21 +877,31 @@ function parseSlashOrgProject(input: string): ParsedOrgProject { * - `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 when + * `options.allowProjectList` is true * - `sentry/` → org with all projects * - `/cli` → search for project across all orgs (leading slash) * - `cli` → search for project across all orgs * * @param arg - Input string from CLI positional argument + * @param options.allowProjectList - Parse comma-separated project slugs after + * the org. Only commands that can query multiple projects together should + * enable it. * @returns Parsed result with type discrimination * * @example * parseOrgProjectArg(undefined) // { type: "auto-detect" } * parseOrgProjectArg("sentry/cli") // { type: "explicit", org: "sentry", project: "cli" } + * parseOrgProjectArg("sentry/web,api", { allowProjectList: true }) + * // { 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" } */ -export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { +export function parseOrgProjectArg( + arg: string | undefined, + options: { allowProjectList?: boolean } = {} +): ParsedOrgProject { if (!arg || arg.trim() === "") { return { type: "auto-detect" }; } @@ -784,7 +917,7 @@ export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { let parsed: ParsedOrgProject; if (trimmed.includes("/")) { - parsed = parseSlashOrgProject(trimmed); + parsed = parseSlashOrgProject(trimmed, options.allowProjectList === true); } else { // No slash → search for project across all orgs rejectAtSelector(trimmed, "project slug"); diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index 6a94d515a1..23b2987222 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -32,7 +32,7 @@ import { paginate } from "./api/infrastructure.js"; import { listOrganizations, type PaginatedResponse } from "./api-client.js"; -import type { ParsedOrgProject } from "./arg-parsing.js"; +import { explicitProjectSlugs, type ParsedOrgProject } from "./arg-parsing.js"; import { advancePaginationState, buildOrgContextKey, @@ -1062,7 +1062,7 @@ async function resolveOrgInParsed( if (resolved.type === "explicit" || resolved.type === "org-all") { setOrgProjectContext( [effectiveOrg], - resolved.type === "explicit" ? [resolved.project] : [] + resolved.type === "explicit" ? explicitProjectSlugs(resolved) : [] ); } return resolved; diff --git a/packages/cli/src/lib/resolve-target.ts b/packages/cli/src/lib/resolve-target.ts index de08b9d8fb..c431269f1c 100644 --- a/packages/cli/src/lib/resolve-target.ts +++ b/packages/cli/src/lib/resolve-target.ts @@ -25,6 +25,7 @@ import { getProject, listOrganizations, listProjects, + ORG_FANOUT_CONCURRENCY, type ProjectWithOrg, resolveOrgDisplayName, } from "./api-client.js"; @@ -965,7 +966,8 @@ function buildProjectNotFoundSuggestions( * * Throws on auth errors and 404s (user-actionable). Returns undefined * for transient failures (network, 500s) so the command can still - * attempt slug-based querying as a fallback. + * attempt slug-based querying as a fallback. Callers that cannot fall + * back to slug scoping pass `strict` to get the underlying error instead. * * On 404, attempts to list similar projects in the org to help the * user find the correct slug (CLI-C0, 36 users). @@ -975,10 +977,14 @@ function buildProjectNotFoundSuggestions( * invocation. The cache is populated by `listProjects()` (batch) and * by DSN resolution. Cache entries without a `projectId` fall through * to the API call (older rows from before schema v7). + * + * @param options.strict - Re-throw non-404 lookup failures instead of + * returning `undefined` */ export async function fetchProjectId( org: string, - project: string + project: string, + options: { strict?: boolean } = {} ): Promise { // Cache-first: avoid a round trip when `listProjects()` or DSN resolution // has already populated the entry for this (org, project) slug pair. @@ -1006,6 +1012,9 @@ export async function fetchProjectId( buildProjectNotFoundSuggestions(org, project, similar) ); } + if (options.strict) { + throw projectResult.error; + } return; } @@ -1036,6 +1045,109 @@ export async function fetchProjectId( return toNumericId(project_.id); } +/** + * Look up the projects of a comma-separated selector (`org/web,api`). + * + * Each slug is fetched directly and in parallel, so the cost follows the + * number of slugs rather than the size of the organization, and a project is + * never reported missing just because a truncated catalog did not reach it. + * The caller decides how to treat `missing`. + * + * @param org - Organization slug + * @param slugs - Project slugs to find + * @returns `found` in input order plus the slugs that returned 404 + * @throws The underlying error for any lookup failure other than a 404 + */ +export async function findProjectsInOrg( + org: string, + slugs: readonly string[] +): Promise<{ found: SentryProject[]; missing: string[] }> { + const limit = pLimit(ORG_FANOUT_CONCURRENCY); + const lookups = await Promise.all( + slugs.map((slug) => + limit(async () => { + const result = await withAuthGuard(() => getProject(org, slug)); + if (result.ok) { + return { slug, project: result.value }; + } + if (result.error instanceof ApiError && result.error.status === 404) { + return { slug, project: undefined }; + } + throw result.error; + }) + ) + ); + return { + found: lookups.flatMap(({ project }) => (project ? [project] : [])), + missing: lookups.flatMap(({ slug, project }) => (project ? [] : [slug])), + }; +} + +/** + * Resolve the project slugs of a comma-separated selector to numeric IDs. + * + * Each slug takes the same cache-first path as a single `/` + * target ({@link fetchProjectId}), in parallel: a warm cache costs no + * requests and a cold one a single `getProject` per slug. Unknown slugs are + * reported here because the issues endpoint answers them with a 403 that + * reads like a permissions problem. Lookups are strict: a slug is never sent + * in place of an ID, since self-hosted releases before 26.6 reject + * non-numeric `project` values. + * + * @param org - Organization slug + * @param slugs - Project slugs to resolve + * @returns Numeric project IDs in input order + * @throws {ResolutionError} When any slug does not exist in `org`; a single + * miss keeps the similar-project suggestions of the single-project path + * @throws The underlying error when a lookup fails for any other reason + */ +export async function resolveProjectIdsInOrg( + org: string, + slugs: readonly string[] +): Promise { + const limit = pLimit(ORG_FANOUT_CONCURRENCY); + const lookups = await Promise.all( + slugs.map((slug) => + limit(() => + fetchProjectId(org, slug, { strict: true }).then( + (id) => ({ slug, id, error: undefined }), + (error: unknown) => ({ slug, id: undefined, error }) + ) + ) + ) + ); + + const failure = lookups.find( + ({ error }) => error !== undefined && !(error instanceof ResolutionError) + ); + if (failure) { + throw failure.error; + } + const missing = lookups.filter(({ error }) => error !== undefined); + const [onlyMissing] = missing; + if (onlyMissing && missing.length === 1) { + throw onlyMissing.error; + } + if (missing.length > 1) { + throw new ResolutionError( + `Projects ${missing.map(({ slug }) => `'${slug}'`).join(", ")}`, + `not found in organization '${org}'`, + `sentry project list ${org}/`, + ["Check the project slugs and try again"] + ); + } + return lookups.map(({ slug, id }) => { + if (id === undefined) { + throw new ResolutionError( + `Project '${slug}'`, + `has no numeric ID in organization '${org}'`, + `sentry project list ${org}/` + ); + } + return id; + }); +} + /** * Resolve a project slug to its numeric ID for Events API queries, tolerating failures. * diff --git a/packages/cli/test/commands/issue/list.test.ts b/packages/cli/test/commands/issue/list.test.ts index 8c0cd58e5b..9dde8ce8a2 100644 --- a/packages/cli/test/commands/issue/list.test.ts +++ b/packages/cli/test/commands/issue/list.test.ts @@ -51,8 +51,16 @@ import { } from "../../../src/lib/db/defaults.js"; // biome-ignore lint/performance/noNamespaceImport: namespace needed for vi.spyOn on mocked module import * as paginationDb from "../../../src/lib/db/pagination.js"; +import { + getProjectByAlias, + setProjectAliases, +} from "../../../src/lib/db/project-aliases.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; -import { ApiError, ValidationError } from "../../../src/lib/errors.js"; +import { + ApiError, + ResolutionError, + ValidationError, +} from "../../../src/lib/errors.js"; import type { TimeRange } from "../../../src/lib/time-range.js"; import { parsePeriod } from "../../../src/lib/time-range.js"; import { mockFetch, useTestConfigDir } from "../../helpers.js"; @@ -1902,6 +1910,180 @@ describe("appendIssueFlags", () => { }); }); +describe("issue list: comma-separated project slugs", () => { + const catalog = [ + { id: "1", slug: "web", name: "Web" }, + { id: "2", slug: "api", name: "API" }, + { id: "3", slug: "worker", name: "Worker" }, + ] as Awaited>; + const getProjectMock = vi.mocked(projectsApi.getProject); + const listProjectsMock = vi.mocked(projectsApi.listProjects); + const baseFlags = { + limit: 10, + sort: "date", + period: parsePeriod("90d"), + json: true, + } as const; + + beforeEach(() => { + listIssuesAllPagesMock.mockReset(); + listIssuesPaginatedMock.mockReset(); + resolveCursorMock.mockReset(); + advancePaginationStateMock.mockReset(); + getProjectMock.mockReset(); + getProjectMock.mockImplementation(async (_org, slug) => { + const project = catalog.find((candidate) => candidate.slug === slug); + if (!project) { + throw new ApiError("Not found", 404); + } + return project; + }); + listProjectsMock.mockReset(); + listProjectsMock.mockResolvedValue(catalog); + }); + + afterEach(() => { + getProjectMock.mockReset(); + listProjectsMock.mockReset(); + }); + + test("looks up each slug and selects all IDs in one SDK request", async () => { + listIssuesAllPagesMock.mockResolvedValue({ + issues: [ + mockIssue({ id: "web", shortId: "WEB-1", project: { slug: "web" } }), + mockIssue({ id: "api", shortId: "API-1", project: { slug: "api" } }), + ], + nextCursor: undefined, + }); + + const { context, stdout } = createContext(); + await func.call(context, baseFlags, "test-org/web,api"); + + expect(getProjectMock).toHaveBeenCalledTimes(2); + expect(listProjectsMock).not.toHaveBeenCalled(); + expect(listIssuesAllPagesMock).toHaveBeenCalledTimes(1); + expect(listIssuesAllPagesMock).toHaveBeenCalledWith( + "test-org", + "", + expect.objectContaining({ limit: 10, projects: [1, 2] }) + ); + const output = JSON.parse(stdout.output); + expect(output.data).toHaveLength(2); + }); + + test("registers aliases that resolve from any directory", async () => { + listIssuesAllPagesMock.mockResolvedValue({ + issues: [ + mockIssue({ id: "1", shortId: "WEB-1", project: { slug: "web" } }), + mockIssue({ id: "2", shortId: "API-1", project: { slug: "api" } }), + ], + nextCursor: undefined, + }); + + const { context, stdout } = createContext(); + await func.call(context, { ...baseFlags, json: false }, "test-org/web,api"); + + expect(stdout.output).toContain("Issues in test-org/web,api"); + expect(stdout.output).toContain("sentry issue view "); + expect(stdout.output).toMatch(/\bw-1\b/); + expect(stdout.output).toMatch(/\ba-1\b/); + // `issue view` passes the fingerprint of the DSNs in its working directory. + expect(getProjectByAlias("w", "o1:2")).toEqual({ + orgSlug: "test-org", + projectSlug: "web", + }); + expect(getProjectByAlias("a", "")).toEqual({ + orgSlug: "test-org", + projectSlug: "api", + }); + }); + + test("replaces stored aliases even when the selector returns no issues", async () => { + setProjectAliases({ stale: { orgSlug: "old-org", projectSlug: "old" } }); + listIssuesAllPagesMock.mockResolvedValue({ + issues: [], + nextCursor: undefined, + }); + + const { context, stdout } = createContext(); + await func.call(context, { ...baseFlags, json: false }, "test-org/web,api"); + + expect(stdout.output).toContain("No issues found in test-org/web,api"); + expect(getProjectByAlias("stale")).toBeUndefined(); + expect(getProjectByAlias("w")).toEqual({ + orgSlug: "test-org", + projectSlug: "web", + }); + }); + + test("resumes the cursor when the slugs are retyped in another order", async () => { + listIssuesAllPagesMock.mockResolvedValue({ + issues: [ + mockIssue({ id: "1", shortId: "WEB-1", project: { slug: "web" } }), + ], + nextCursor: "1735689600:0:1", + }); + listIssuesPaginatedMock.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + await func.call(createContext().context, baseFlags, "test-org/web,api"); + await func.call( + createContext().context, + { ...baseFlags, cursor: "next" }, + "test-org/api,web" + ); + + expect(listIssuesPaginatedMock).toHaveBeenCalledWith( + "test-org", + "", + expect.objectContaining({ cursor: "1735689600:0:1", projects: [2, 1] }) + ); + }); + + test("rejects an unknown slug before querying issues", async () => { + const error = await func + .call(createContext().context, baseFlags, "test-org/web,nope") + .catch((caught: Error) => caught); + + expect(error).toBeInstanceOf(ResolutionError); + expect((error as Error).message).toContain( + "Project 'nope' not found in organization 'test-org'" + ); + expect(listIssuesAllPagesMock).not.toHaveBeenCalled(); + }); + + test("reports every unknown slug in one error", async () => { + const error = await func + .call(createContext().context, baseFlags, "test-org/nope,web,gone") + .catch((caught: Error) => caught); + + expect(error).toBeInstanceOf(ResolutionError); + expect((error as Error).message).toContain( + "Projects 'nope', 'gone' not found in organization 'test-org'" + ); + expect(listIssuesAllPagesMock).not.toHaveBeenCalled(); + }); + + test("fails instead of guessing when a project lookup errors", async () => { + getProjectMock.mockImplementation(async (_org, slug) => { + if (slug === "api") { + throw new ApiError("Forbidden", 403); + } + return catalog[0] as (typeof catalog)[number]; + }); + + const error = await func + .call(createContext().context, baseFlags, "test-org/web,api") + .catch((caught: Error) => caught); + + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(403); + expect(listIssuesAllPagesMock).not.toHaveBeenCalled(); + }); +}); + // --------------------------------------------------------------------------- // sanitizeQuery — tests moved to test/lib/search-query.test.ts // --------------------------------------------------------------------------- diff --git a/packages/cli/test/commands/project/create.test.ts b/packages/cli/test/commands/project/create.test.ts index 43b44b6cfa..ff66a59dc4 100644 --- a/packages/cli/test/commands/project/create.test.ts +++ b/packages/cli/test/commands/project/create.test.ts @@ -1071,6 +1071,21 @@ describe("project create", () => { ); }); + test("preserves commas inside an organization-scoped project name", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "acme/web,api:node"); + + expect(resolveOrgSpy).toHaveBeenCalledWith( + expect.objectContaining({ org: "acme" }) + ); + expect(createProjectWithDsnSpy).toHaveBeenCalledWith( + "acme-corp", + "engineering", + { name: "web,api", platform: "node" } + ); + }); + test("splits a project specification on its final colon", async () => { const { context } = createMockContext(); const func = await createCommand.loader(); diff --git a/packages/cli/test/commands/project/list.test.ts b/packages/cli/test/commands/project/list.test.ts index cace32f8c0..87399e2839 100644 --- a/packages/cli/test/commands/project/list.test.ts +++ b/packages/cli/test/commands/project/list.test.ts @@ -14,7 +14,7 @@ import { property, tuple, } from "fast-check"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { buildContextKey, displayProjectTable, @@ -26,6 +26,7 @@ import { handleExplicit, handleOrgAll, handleProjectSearch, + listCommand, PAGINATION_KEY, } from "../../../src/commands/project/list.js"; import type { ParsedOrgProject } from "../../../src/lib/arg-parsing.js"; @@ -39,6 +40,7 @@ import { } from "../../../src/lib/db/pagination.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; import { + ApiError, AuthError, ResolutionError, ValidationError, @@ -47,7 +49,9 @@ import type { SentryProject } from "../../../src/types/index.js"; import { useTestConfigDir } from "../../helpers.js"; import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; -useTestConfigDir("test-project-list-", { isolateProjectRoot: true }); +const getConfigDir = useTestConfigDir("test-project-list-", { + isolateProjectRoot: true, +}); /** Create a minimal project for testing */ function makeProject( @@ -307,8 +311,13 @@ function mockProjectFetch( // getProject (single project fetch via /projects/{org}/{slug}/) if (url.match(/\/projects\/[^/]+\/[^/]+\//)) { - if (projects.length > 0) { - return new Response(JSON.stringify(projects[0]), { + const projectSlug = new URL(url).pathname + .split("/") + .filter(Boolean) + .at(-1); + const project = projects.find(({ slug }) => slug === projectSlug); + if (project) { + return new Response(JSON.stringify(project), { status: 200, headers: { "Content-Type": "application/json" }, }); @@ -456,6 +465,134 @@ describe("handleExplicit", () => { }); }); +describe("project list: comma-separated project slugs", () => { + const flags = { limit: 30, json: false, fresh: false }; + let func: Awaited>; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + func = await listCommand.loader(); + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function createCommandContext() { + const stdout = { + output: "", + write(value: string) { + stdout.output += value; + return true; + }, + }; + const stderr = { write: () => true }; + const context = { + process, + env: process.env, + stdout, + stderr, + cwd: getConfigDir(), + }; + return { context, stdout }; + } + + test("looks up each project directly and keeps input order", async () => { + const fetchSpy = vi.fn(mockProjectFetch(sampleProjects)); + globalThis.fetch = fetchSpy; + const { context, stdout } = createCommandContext(); + + await func.call( + context, + { ...flags, json: true }, + "test-org/backend,frontend" + ); + + const output = JSON.parse(stdout.output) as SentryProject[]; + expect(output.map(({ slug }) => slug)).toEqual(["backend", "frontend"]); + const requestedUrls = fetchSpy.mock.calls.map( + ([input, init]) => new Request(input, init).url + ); + expect( + requestedUrls.some((url) => + url.includes("/organizations/test-org/projects/") + ) + ).toBe(false); + expect( + requestedUrls.filter( + (url) => + url.includes("/projects/test-org/frontend/") || + url.includes("/projects/test-org/backend/") + ) + ).toHaveLength(2); + }); + + test("propagates lookup errors other than not found", async () => { + const projectFetch = mockProjectFetch(sampleProjects); + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit + ) => { + if ( + new Request(input, init).url.includes("/projects/test-org/backend/") + ) { + return new Response(JSON.stringify({ detail: "Forbidden" }), { + status: 403, + }); + } + return projectFetch(input, init); + }) as typeof globalThis.fetch; + const { context } = createCommandContext(); + + const error = await func + .call(context, flags, "test-org/frontend,backend") + .catch((caught: Error) => caught); + + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(403); + }); + + test("returns matches and names missing projects", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects.slice(0, 1)); + const { context, stdout } = createCommandContext(); + + await func.call(context, flags, "test-org/frontend,backend"); + + expect(stdout.output).toContain("frontend"); + expect(stdout.output).toContain("Missing: backend"); + }); + + test("distinguishes platform-filtered projects from missing projects", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { context, stdout } = createCommandContext(); + + await func.call( + context, + { ...flags, platform: "javascript" }, + "test-org/frontend,backend" + ); + + expect(stdout.output).toContain("frontend"); + expect(stdout.output).toContain( + "No match for platform 'javascript': backend" + ); + expect(stdout.output).not.toContain("Missing: backend"); + }); + + test("reports every requested project when none exist", async () => { + globalThis.fetch = mockProjectFetch([]); + const { context, stdout } = createCommandContext(); + + await func.call(context, flags, "test-org/frontend,backend"); + + expect(stdout.output).toContain( + "No projects found among: 'frontend', 'backend'." + ); + }); +}); + describe("handleOrgAll", () => { beforeEach(async () => { originalFetch = globalThis.fetch; diff --git a/packages/cli/test/lib/api-client.test.ts b/packages/cli/test/lib/api-client.test.ts index e8130673ba..383dc2fac8 100644 --- a/packages/cli/test/lib/api-client.test.ts +++ b/packages/cli/test/lib/api-client.test.ts @@ -1339,7 +1339,7 @@ describe("listIssuesPaginated", () => { expect(url.searchParams.get("sort")).toBe("freq"); }); - test("uses project query param instead of project:slug when projectId is provided", async () => { + test("uses project query param instead of project:slug when a project ID is provided", async () => { let capturedUrl = ""; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -1354,7 +1354,7 @@ describe("listIssuesPaginated", () => { return new Response(JSON.stringify([]), { status: 200 }); }; - await listIssuesPaginated("my-org", "my-proj", { projectId: 12_345 }); + await listIssuesPaginated("my-org", "my-proj", { projects: [12_345] }); const url = new URL(capturedUrl); // Should use project=12345 query param @@ -1364,7 +1364,29 @@ describe("listIssuesPaginated", () => { expect(query).not.toContain("project:my-proj"); }); - test("uses project:slug in query when projectId is not provided", async () => { + test("sends multiple project IDs as repeated project query params", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/issues/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listIssuesPaginated("my-org", "", { projects: [1, 2] }); + + const url = new URL(capturedUrl); + expect(url.searchParams.getAll("project")).toEqual(["1", "2"]); + expect(url.searchParams.has("query")).toBe(false); + }); + + test("uses project:slug in query when no project IDs are provided", async () => { let capturedUrl = ""; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -1388,7 +1410,7 @@ describe("listIssuesPaginated", () => { expect(url.searchParams.get("query")).toContain("project:my-proj"); }); - test("combines projectId with custom query without project:slug", async () => { + test("combines a project ID with custom query without project:slug", async () => { let capturedUrl = ""; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -1404,7 +1426,7 @@ describe("listIssuesPaginated", () => { }; await listIssuesPaginated("my-org", "my-proj", { - projectId: 12_345, + projects: [12_345], query: "is:unresolved", }); diff --git a/packages/cli/test/lib/arg-parsing.property.test.ts b/packages/cli/test/lib/arg-parsing.property.test.ts index a3c56e43ea..c94e548cd1 100644 --- a/packages/cli/test/lib/arg-parsing.property.test.ts +++ b/packages/cli/test/lib/arg-parsing.property.test.ts @@ -285,12 +285,52 @@ describe("parseOrgProjectArg properties", () => { if (result.type === "explicit") { expect(result.org).toBe(org); expect(result.project).toBe(project); + expect(result.projects).toBeUndefined(); } }), { numRuns: DEFAULT_NUM_RUNS } ); }); + test("org/a,b stays one project unless list parsing is enabled", async () => { + await fcAssert( + property( + tuple(orgSlugArb, projectSlugArb, projectSlugArb), + ([org, first, second]) => { + const result = parseOrgProjectArg(`${org}/${first},${second}`); + expect(result.type).toBe("explicit"); + if (result.type === "explicit") { + expect(result.project).toBe(`${first},${second}`); + expect(result.projects).toBeUndefined(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("org/a,b returns both slugs when list parsing is enabled", async () => { + await fcAssert( + property( + tuple(orgSlugArb, projectSlugArb, projectSlugArb), + ([org, first, second]) => { + if (first === second) { + return; + } + const result = parseOrgProjectArg(`${org}/${first},${second}`, { + allowProjectList: true, + }); + expect(result.type).toBe("explicit"); + if (result.type === "explicit") { + expect(result.project).toBe(first); + expect(result.projects).toEqual([first, second]); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + test("org/ (trailing slash) returns type 'org-all'", async () => { await fcAssert( property(orgSlugArb, (org) => { diff --git a/packages/cli/test/lib/arg-parsing.test.ts b/packages/cli/test/lib/arg-parsing.test.ts index c23f6680d1..2736d2cb42 100644 --- a/packages/cli/test/lib/arg-parsing.test.ts +++ b/packages/cli/test/lib/arg-parsing.test.ts @@ -21,6 +21,8 @@ import { import { stripDsnOrgPrefix } from "../../src/lib/dsn/index.js"; import { ValidationError } from "../../src/lib/errors.js"; +const PROJECT_LIST_OPTIONS = { allowProjectList: true } as const; + describe("stripDsnOrgPrefix", () => { test("strips 'o' prefix from DSN-style org IDs", () => { expect(stripDsnOrgPrefix("o1081365")).toBe("1081365"); @@ -71,6 +73,64 @@ describe("parseOrgProjectArg", () => { }); }); + test("comma-separated slugs return explicit with projects", () => { + expect( + parseOrgProjectArg("acme/frontend,backend", PROJECT_LIST_OPTIONS) + ).toEqual({ + type: "explicit", + org: "acme", + project: "frontend", + projects: ["frontend", "backend"], + }); + }); + + test("trims and de-duplicates comma-separated slugs", () => { + expect( + parseOrgProjectArg("acme/web, api,web", PROJECT_LIST_OPTIONS) + ).toEqual({ + type: "explicit", + org: "acme", + project: "web", + projects: ["web", "api"], + }); + }); + + test("trailing comma is treated as a single slug", () => { + expect(parseOrgProjectArg("acme/web,", PROJECT_LIST_OPTIONS)).toEqual({ + type: "explicit", + org: "acme", + project: "web", + }); + }); + + test("empty comma list throws ValidationError", () => { + expect(() => parseOrgProjectArg("acme/,,,", PROJECT_LIST_OPTIONS)).toThrow( + ValidationError + ); + expect(() => parseOrgProjectArg("acme/,,,", PROJECT_LIST_OPTIONS)).toThrow( + "empty" + ); + }); + + test("bare comma list is a single project-search slug", () => { + expect(parseOrgProjectArg("web,api")).toEqual({ + type: "project-search", + projectSlug: "web,api", + }); + }); + + test("comma-separated display names are rejected", () => { + expect(() => + parseOrgProjectArg("acme/My App,Other App", PROJECT_LIST_OPTIONS) + ).toThrow("must be slugs"); + }); + + test("invalid character in one comma token throws", () => { + expect(() => + parseOrgProjectArg("acme/web,api?x", PROJECT_LIST_OPTIONS) + ).toThrow(ValidationError); + }); + // Error case - verify specific message test("just slash throws error", () => { expect(() => parseOrgProjectArg("/")).toThrow(