From 3231331af29564c9f0a6c27ee1679febd1eb9547 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:59:46 +0000 Subject: [PATCH 1/8] feat(cli): Name filter for project, team, repo, monitor and integration lists Bring `sentry org list [filter]`'s name filter to the other org-scoped list commands, as an optional second positional: sentry project list my-org/ 'web-*' # glob on slug or name sentry team list my-org/ backnd # fuzzy text sentry project list 'web-*' # lone glob: auto-detected org(s) - `OrgListConfig.namesOf` opts a `buildOrgListCommand` command in (team, repo, monitor, integration); its default handlers filter each fetched set with `filterByName`. `project list` applies it in its own handlers, together with `--platform`. - Matching and fuzzy ranking need the whole set, so `/` with a filter fetches every page instead of one cursor page, and `--cursor` with a filter is rejected. `--limit` applies to the matches. - `splitListTargetAndFilter` treats a lone glob (or `/`) as a filter, since no slug contains glob characters. A lone bare name is still a project search. - Glob `*` now also matches `/`, so `*-sdk` matches repository names like `getsentry/sentry-python-sdk`. Refs #1554 Co-Authored-By: byk Co-Authored-By: BYK --- .../sentry-cli/skills/sentry-cli/SKILL.md | 10 +- .../sentry-cli/references/integration.md | 5 +- .../skills/sentry-cli/references/monitor.md | 5 +- .../skills/sentry-cli/references/project.md | 5 +- .../skills/sentry-cli/references/repo.md | 5 +- .../skills/sentry-cli/references/team.md | 5 +- packages/cli/src/commands/integration/list.ts | 9 ++ packages/cli/src/commands/monitor/list.ts | 9 ++ packages/cli/src/commands/project/list.ts | 147 +++++++++++++++--- packages/cli/src/commands/repo/list.ts | 9 ++ packages/cli/src/commands/team/list.ts | 9 ++ packages/cli/src/lib/list-command.ts | 47 +++++- packages/cli/src/lib/list-filter.ts | 35 ++++- packages/cli/src/lib/org-list.ts | 105 ++++++++++++- .../cli/test/commands/project/list.test.ts | 106 +++++++++++++ packages/cli/test/commands/team/list.test.ts | 42 +++++ packages/cli/test/lib/list-filter.test.ts | 38 ++++- packages/cli/test/lib/org-list.test.ts | 125 +++++++++++++++ 18 files changed, 675 insertions(+), 41 deletions(-) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index f5536126b..c5aef6d8d 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -420,7 +420,7 @@ Work with Sentry projects - `sentry project create [/]:...` — Create one or more projects - `sentry project delete ` — Delete a project -- `sentry project list []` — List projects +- `sentry project list [] []` — List projects - `sentry project view []` — View details of a project → Full flags and examples: `references/project.md` @@ -630,7 +630,7 @@ Work with Sentry releases Work with Sentry repositories -- `sentry repo list []` — List repositories +- `sentry repo list [] []` — List repositories → Full flags and examples: `references/repo.md` @@ -638,7 +638,7 @@ Work with Sentry repositories Work with Sentry teams -- `sentry team list []` — List teams +- `sentry team list [] []` — List teams → Full flags and examples: `references/team.md` @@ -646,7 +646,7 @@ Work with Sentry teams Work with Sentry integrations -- `sentry integration list []` — List installed integrations +- `sentry integration list [] []` — List installed integrations - `sentry integration delete ` — Remove an installed integration → Full flags and examples: `references/integration.md` @@ -693,7 +693,7 @@ Start a local Sentry MCP server Work with Sentry cron monitors - `sentry monitor run ` — Wrap a command with cron monitor check-ins -- `sentry monitor list []` — List cron monitors +- `sentry monitor list [] []` — List cron monitors → Full flags and examples: `references/monitor.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/integration.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/integration.md index 3a40c3e63..d03b07d8f 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/integration.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/integration.md @@ -11,7 +11,7 @@ requires: Work with Sentry integrations -### `sentry integration list []` +### `sentry integration list [] []` List installed integrations @@ -44,6 +44,9 @@ sentry integration list my-org/ # Fetch the next page sentry integration list my-org/ -c next +# Only integrations whose name matches a glob +sentry integration list my-org/ 'git*' + # Output as JSON sentry integration list --json ``` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/monitor.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/monitor.md index bd56732ae..8bf394938 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/monitor.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/monitor.md @@ -38,7 +38,7 @@ sentry monitor run nightly-job npm run task sentry monitor run nightly-job -s "0 0 * * *" --max-runtime 30 --timezone UTC -- ./backup.sh ``` -### `sentry monitor list []` +### `sentry monitor list [] []` List cron monitors @@ -72,6 +72,9 @@ sentry monitor list my-org/ # Fetch the next page sentry monitor list my-org/ -c next +# Only monitors whose slug or name matches a glob +sentry monitor list my-org/ 'nightly-*' + # Output as JSON sentry monitor list --json ``` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md index 651a059b0..a2c1228c4 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md @@ -62,7 +62,7 @@ sentry project delete my-org/old-project --yes sentry project delete my-org/old-project --dry-run ``` -### `sentry project list []` +### `sentry project list [] []` List projects @@ -83,6 +83,9 @@ sentry project list my-org/frontend,backend # Filter by platform sentry project list my-org/ --platform javascript + +# Only projects whose slug or name matches a glob +sentry project list my-org/ 'web-*' ``` ### `sentry project view []` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/repo.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/repo.md index 55a7e17ec..9a896e6f8 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/repo.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/repo.md @@ -11,7 +11,7 @@ requires: Work with Sentry repositories -### `sentry repo list []` +### `sentry repo list [] []` List repositories @@ -46,6 +46,9 @@ sentry repo list my-org/ # Fetch the next page sentry repo list my-org/ -c next +# Only repositories whose name matches a glob +sentry repo list my-org/ 'getsentry/*' + # Output as JSON sentry repo list --json ``` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/team.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/team.md index 8de6462e6..f295c5993 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/team.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/team.md @@ -11,7 +11,7 @@ requires: Work with Sentry teams -### `sentry team list []` +### `sentry team list [] []` List teams @@ -44,6 +44,9 @@ sentry team list my-org/ # Fetch the next page sentry team list my-org/ -c next +# Only teams whose slug or name matches a glob +sentry team list my-org/ 'back*' + # Output as JSON sentry team list --json ``` diff --git a/packages/cli/src/commands/integration/list.ts b/packages/cli/src/commands/integration/list.ts index 6fb77833f..9cf7402f5 100644 --- a/packages/cli/src/commands/integration/list.ts +++ b/packages/cli/src/commands/integration/list.ts @@ -48,6 +48,7 @@ const integrationListConfig: OrgListConfig< listForOrg: (org) => listIntegrations(org), listPaginated: (org, opts) => listIntegrationsPaginated(org, opts), withOrg: (integration, orgSlug) => ({ ...integration, orgSlug }), + namesOf: (integration) => [integration.name], displayTable: (integrations: IntegrationWithOrg[]) => formatTable(integrations, INTEGRATION_COLUMNS), schema: SentryIntegrationSchema, @@ -63,6 +64,10 @@ const docs: OrgListCommandDocs = { " sentry integration list / # list all integrations in org (paginated)\n" + " sentry integration list / # list integrations in org (project context)\n" + " sentry integration list # list integrations in org\n\n" + + "Name filter (searches every page, so it can't be paginated):\n" + + " sentry integration list / github # fuzzy match on the name\n" + + " sentry integration list / 'git*' # glob on the name\n" + + " sentry integration list 'git*' # a lone glob filters the auto-detected org(s)\n\n" + "Pagination:\n" + " sentry integration list / -c next # fetch next page\n" + " sentry integration list / -c prev # fetch previous page\n\n" + @@ -80,6 +85,10 @@ const docs: OrgListCommandDocs = { description: "Fetch the next page", command: "sentry integration list my-org/ -c next", }, + { + description: "Only integrations whose name matches a glob", + command: "sentry integration list my-org/ 'git*'", + }, { description: "Output as JSON", command: "sentry integration list --json", diff --git a/packages/cli/src/commands/monitor/list.ts b/packages/cli/src/commands/monitor/list.ts index 537c419ea..62cb299b7 100644 --- a/packages/cli/src/commands/monitor/list.ts +++ b/packages/cli/src/commands/monitor/list.ts @@ -66,6 +66,7 @@ const monitorListConfig: OrgListConfig = { listForOrg: (org) => listMonitors(org), listPaginated: (org, opts) => listMonitorsPaginated(org, opts), withOrg: (monitor, orgSlug) => ({ ...monitor, orgSlug }), + namesOf: (monitor) => [monitor.slug, monitor.name], displayTable: (monitors: MonitorWithOrg[]) => formatTable(monitors, MONITOR_COLUMNS), schema: SentryMonitorSchema, @@ -80,6 +81,10 @@ const docs: OrgListCommandDocs = { " sentry monitor list / # list all monitors in org (paginated)\n" + " sentry monitor list / # list monitors in org (project context)\n" + " sentry monitor list # list monitors in org\n\n" + + "Name filter (searches every page, so it can't be paginated):\n" + + " sentry monitor list / nightly # fuzzy match on the slug or name\n" + + " sentry monitor list / 'nightly-*' # glob on the slug or name\n" + + " sentry monitor list 'nightly-*' # a lone glob filters the auto-detected org(s)\n\n" + "Pagination:\n" + " sentry monitor list / -c next # fetch next page\n" + " sentry monitor list / -c prev # fetch previous page\n\n" + @@ -97,6 +102,10 @@ const docs: OrgListCommandDocs = { description: "Fetch the next page", command: "sentry monitor list my-org/ -c next", }, + { + description: "Only monitors whose slug or name matches a glob", + command: "sentry monitor list my-org/ 'nightly-*'", + }, { description: "Output as JSON", command: "sentry monitor list --json" }, ], }; diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 9a4734f48..f67f6b045 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -43,10 +43,14 @@ import { buildListCommand, buildListLimitFlag, LIST_BASE_ALIASES, - LIST_TARGET_POSITIONAL, + LIST_TARGET_FILTER_POSITIONAL, paginationHint, targetPatternExplanation, } from "../../lib/list-command.js"; +import { + filterByName, + splitListTargetAndFilter, +} from "../../lib/list-filter.js"; import { dispatchOrgScopedList, @@ -81,6 +85,12 @@ type ListFlags = { readonly fields?: string[]; }; +/** + * {@link ListFlags} plus the optional name filter positional + * (`sentry project list / `), which the handlers apply. + */ +type ProjectListFlags = ListFlags & { readonly filter?: string }; + /** * Project with optional organization context for display. * Uses optional orgSlug since some internal functions (e.g., filterByPlatform) @@ -155,6 +165,40 @@ export function filterByPlatform( ); } +/** The names a project name filter matches: slug and display name. */ +function projectNamesOf(project: SentryProject): string[] { + return [project.slug, project.name]; +} + +/** + * Projects matching `--platform` and the name filter. With a name filter, + * best matches come first (see {@link filterByName}). + */ +function filterProjects( + projects: ProjectWithOrg[], + flags: ProjectListFlags, +): ProjectWithOrg[] { + return filterByName( + filterByPlatform(projects, flags.platform), + flags.filter, + projectNamesOf, + ); +} + +/** ` matching ''` for messages, or `""` without a name filter. */ +function matchingFilter(flags: ProjectListFlags): string { + return flags.filter ? ` matching '${flags.filter}'` : ""; +} + +/** Why a named project was left out by `--platform` and/or the name filter. */ +function noMatchHint(projectSlug: string, flags: ProjectListFlags): string { + if (!flags.filter) { + return `No project '${projectSlug}' found matching platform '${flags.platform}'.`; + } + const platformCtx = flags.platform ? ` on platform '${flags.platform}'` : ""; + return `Project '${projectSlug}' doesn't match '${flags.filter}'${platformCtx}.`; +} + /** * Build a context key for pagination cursor validation. * Captures the query parameters that affect result ordering, @@ -278,9 +322,9 @@ async function fetchPaginatedSafe( */ async function fetchAutoDetectProjects( orgs: string[], - flags: ListFlags, + flags: ProjectListFlags, ): Promise<{ projects: ProjectWithOrg[]; nextCursor?: string }> { - if (orgs.length === 1 && !flags.platform) { + if (orgs.length === 1 && !flags.platform && !flags.filter) { return await fetchPaginatedSafe(orgs[0] as string, flags.limit); } if (orgs.length > 0) { @@ -336,7 +380,7 @@ function selfHostedWarning( */ export async function handleAutoDetect( cwd: string, - flags: ListFlags, + flags: ProjectListFlags, ): Promise> { const { orgs: orgsToFetch, @@ -352,7 +396,7 @@ export async function handleAutoDetect( () => fetchAutoDetectProjects(orgsToFetch, flags), ); - const filtered = filterByPlatform(allProjects, flags.platform); + const filtered = filterProjects(allProjects, flags); const limitCount = orgsToFetch.length > 1 ? flags.limit * orgsToFetch.length : flags.limit; const limited = filtered.slice(0, limitCount); @@ -368,7 +412,7 @@ export async function handleAutoDetect( const hintParts: string[] = []; if (limited.length === 0) { - hintParts.push("No projects found."); + hintParts.push(`No projects${matchingFilter(flags)} found.`); } else { // header is rendered inline by the human formatter — don't duplicate here if (footer) { @@ -402,7 +446,7 @@ export async function handleAutoDetect( export async function handleExplicit( org: string, projectSlug: string, - flags: ListFlags, + flags: ProjectListFlags, ): Promise> { const projectResult = await withProgress( { message: "Fetching project...", json: flags.json }, @@ -418,12 +462,12 @@ export async function handleExplicit( } const project: ProjectWithOrg = { ...projectResult.value, orgSlug: org }; - const filtered = filterByPlatform([project], flags.platform); + const filtered = filterProjects([project], flags); if (filtered.length === 0) { return { items: [], - hint: `No project '${projectSlug}' found matching platform '${flags.platform}'.`, + hint: noMatchHint(projectSlug, flags), }; } @@ -445,13 +489,19 @@ export async function handleExplicit( async function handleExplicitProjects( org: string, slugs: readonly string[], - flags: ListFlags, + flags: ProjectListFlags, ): Promise> { const { found, missing } = await withProgress( { message: "Fetching projects...", json: flags.json }, () => findProjectsInOrg(org, slugs), ); - const selected = found.map((project) => ({ ...project, orgSlug: org })); + const withOrg = found.map((project) => ({ ...project, orgSlug: org })); + // Name filter first, so the --platform hint only lists projects it excluded. + const selected = filterByName(withOrg, flags.filter, projectNamesOf); + const selectedSlugs = new Set(selected.map((project) => project.slug)); + const nameFiltered = withOrg + .filter((project) => !selectedSlugs.has(project.slug)) + .map((project) => project.slug); const filtered = filterByPlatform(selected, flags.platform); const includedSlugs = new Set(filtered.map((project) => project.slug)); const platformFiltered = selected @@ -473,6 +523,11 @@ async function handleExplicitProjects( `Missing: ${missing.join(", ")}. Tip: Use 'sentry project list ${org}/' to see all projects`, ); } + if (nameFiltered.length > 0) { + hintParts.push( + `No match for '${flags.filter}': ${nameFiltered.join(", ")}`, + ); + } if (platformFiltered.length > 0) { hintParts.push( `No match for platform '${flags.platform}': ${platformFiltered.join(", ")}`, @@ -497,7 +552,7 @@ async function handleExplicitProjects( export type OrgAllOptions = { org: string; - flags: ListFlags; + flags: ProjectListFlags; contextKey: string; cursor: string | undefined; direction: CursorDirection; @@ -513,6 +568,9 @@ export async function handleOrgAll( options: OrgAllOptions, ): Promise> { const { org, flags, contextKey, cursor, direction } = options; + if (flags.filter) { + return await handleOrgAllFiltered(org, flags); + } const response: PaginatedResponse = await withProgress( { message: `Fetching projects (up to ${flags.limit})...`, @@ -584,13 +642,50 @@ export async function handleOrgAll( }; } +/** + * Org-all mode with a name filter (`sentry project list / web-*`). + * Matching (and fuzzy ranking) needs every project, so this fetches all + * pages instead of one cursor page; `--limit` applies to the matches. + */ +async function handleOrgAllFiltered( + org: string, + flags: ProjectListFlags, +): Promise> { + const projects = await withProgress( + { + message: `Fetching projects${matchingFilter(flags)}...`, + json: flags.json, + }, + () => fetchOrgProjects(org), + ); + const matches = filterProjects(projects, flags); + const items = matches.slice(0, flags.limit); + if (items.length === 0) { + const platformCtx = flags.platform + ? ` on platform '${flags.platform}'` + : ""; + return { + items: [], + hint: `No projects${matchingFilter(flags)}${platformCtx} in organization '${org}'.`, + }; + } + return { + items, + header: + matches.length > items.length + ? `Showing ${items.length} of ${matches.length} matches. Use --limit to show more.` + : `Showing ${items.length} projects`, + hint: "Tip: Use 'sentry project view /' for details", + }; +} + /** * Handle project-search mode (bare slug, e.g., "sentry"). * Searches for the project across all accessible organizations. */ export async function handleProjectSearch( projectSlug: string, - flags: ListFlags, + flags: ProjectListFlags, options?: { /** Original user input before normalization — for clearer messages. */ originalSlug?: string; @@ -645,11 +740,11 @@ export async function handleProjectSearch( : resolution.projects; const resolvedProjectSlug = resolution.kind === "fuzzy-project" ? resolution.project : projectSlug; - const filtered = filterByPlatform(projects, flags.platform); - if (filtered.length === 0 && flags.platform) { + const filtered = filterProjects(projects, flags); + if (filtered.length === 0 && (flags.platform || flags.filter)) { return { items: [], - hint: `No project '${resolvedProjectSlug}' found matching platform '${flags.platform}'.`, + hint: noMatchHint(resolvedProjectSlug, flags), }; } @@ -709,6 +804,10 @@ export const listCommand = buildListCommand("project", { " sentry project list / -c next # next page\n" + " sentry project list / -c prev # previous page\n" + " sentry project list / -c # resume at specific cursor\n\n" + + "Name filter (a glob, or text matched fuzzily; searches every page):\n" + + " sentry project list / 'web-*' # slug or name matches the glob\n" + + " sentry project list / frontnd # fuzzy: finds 'frontend'\n" + + " sentry project list 'web-*' # a lone glob filters auto-detected orgs\n\n" + "Filtering and output:\n" + " sentry project list --platform javascript # filter by platform\n" + " sentry project list --limit 50 # show more results\n" + @@ -727,6 +826,10 @@ export const listCommand = buildListCommand("project", { description: "Filter by platform", command: "sentry project list my-org/ --platform javascript", }, + { + description: "Only projects whose slug or name matches a glob", + command: "sentry project list my-org/ 'web-*'", + }, ], }, output: { @@ -748,7 +851,7 @@ export const listCommand = buildListCommand("project", { jsonTransform: jsonTransformListResult, } satisfies OutputConfig>, parameters: { - positional: LIST_TARGET_POSITIONAL, + positional: LIST_TARGET_FILTER_POSITIONAL, flags: { limit: buildListLimitFlag("projects"), platform: { @@ -760,16 +863,24 @@ export const listCommand = buildListCommand("project", { }, aliases: { ...LIST_BASE_ALIASES, p: "platform" }, }, - async *func(this: SentryContext, flags: ListFlags, target?: string) { + async *func( + this: SentryContext, + cliFlags: ListFlags, + rawTarget?: string, + rawFilter?: string, + ) { const { cwd } = this; + const { target, filter } = splitListTargetAndFilter(rawTarget, rawFilter); const parsed = parseOrgProjectArg(target, { multi: true }); + const flags: ProjectListFlags = { ...cliFlags, filter }; const result = await dispatchOrgScopedList({ config: projectListMeta, cwd, flags, parsed, + filter, overrides: { "auto-detect": (ctx) => handleAutoDetect(ctx.cwd, flags), explicit: (ctx) => diff --git a/packages/cli/src/commands/repo/list.ts b/packages/cli/src/commands/repo/list.ts index c5c4fc080..7b8d88a66 100644 --- a/packages/cli/src/commands/repo/list.ts +++ b/packages/cli/src/commands/repo/list.ts @@ -49,6 +49,7 @@ const repoListConfig: OrgListConfig = { listForOrg: (org) => listRepositories(org), listPaginated: (org, opts) => listRepositoriesPaginated(org, opts), withOrg: (repo, orgSlug) => ({ ...repo, orgSlug }), + namesOf: (repo) => [repo.name], displayTable: (repos: RepositoryWithOrg[]) => formatTable(repos, REPO_COLUMNS), schema: SentryRepositorySchema, @@ -63,6 +64,10 @@ const docs: OrgListCommandDocs = { " sentry repo list / # list all repos in org (paginated)\n" + " sentry repo list / # list repos in org (project context)\n" + " sentry repo list # list repos in org\n\n" + + "Name filter (searches every page, so it can't be paginated):\n" + + " sentry repo list / sentry # fuzzy match on the name\n" + + " sentry repo list / 'getsentry/*' # glob on the name\n" + + " sentry repo list '*-sdk' # a lone glob filters the auto-detected org(s)\n\n" + "Pagination:\n" + " sentry repo list / -c next # fetch next page\n" + " sentry repo list / -c prev # fetch previous page\n\n" + @@ -80,6 +85,10 @@ const docs: OrgListCommandDocs = { description: "Fetch the next page", command: "sentry repo list my-org/ -c next", }, + { + description: "Only repositories whose name matches a glob", + command: "sentry repo list my-org/ 'getsentry/*'", + }, { description: "Output as JSON", command: "sentry repo list --json" }, ], }; diff --git a/packages/cli/src/commands/team/list.ts b/packages/cli/src/commands/team/list.ts index 9dfd9b194..7f1ebd038 100644 --- a/packages/cli/src/commands/team/list.ts +++ b/packages/cli/src/commands/team/list.ts @@ -50,6 +50,7 @@ const teamListConfig: OrgListConfig = { listForOrg: (org) => listTeams(org), listPaginated: (org, opts) => listTeamsPaginated(org, opts), withOrg: (team, orgSlug) => ({ ...team, orgSlug }), + namesOf: (team) => [team.slug, team.name], displayTable: (teams: TeamWithOrg[]) => formatTable(teams, TEAM_COLUMNS), listForProject: (org, project) => listProjectTeams(org, project), schema: SentryTeamSchema, @@ -64,6 +65,10 @@ const docs: OrgListCommandDocs = { " sentry team list / # list all teams in org (paginated)\n" + " sentry team list / # list teams in org (project context)\n" + " sentry team list # list teams in org\n\n" + + "Name filter (searches every page, so it can't be paginated):\n" + + " sentry team list / backend # fuzzy match on the slug or name\n" + + " sentry team list / 'back*' # glob on the slug or name\n" + + " sentry team list 'back*' # a lone glob filters the auto-detected org(s)\n\n" + "Pagination:\n" + " sentry team list / -c next # fetch next page\n" + " sentry team list / -c prev # fetch previous page\n\n" + @@ -81,6 +86,10 @@ const docs: OrgListCommandDocs = { description: "Fetch the next page", command: "sentry team list my-org/ -c next", }, + { + description: "Only teams whose slug or name matches a glob", + command: "sentry team list my-org/ 'back*'", + }, { description: "Output as JSON", command: "sentry team list --json" }, ], }; diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 71a264461..680f40120 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -44,6 +44,7 @@ import { type ListResult, type OrgListConfig, } from "./org-list.js"; +import { splitListTargetAndFilter } from "./list-filter.js"; import { applyLazyPositionals, rawPositional } from "./positional-parse.js"; import { disableResponseCache } from "./response-cache.js"; import { PERIOD_BRIEF, parsePeriod } from "./time-range.js"; @@ -102,6 +103,41 @@ export const LIST_ORG_TARGET_POSITIONAL = { ], }; +/** + * Optional name filter after the target (`sentry team list / back*`), + * matched with `filterByName` from `list-filter.ts`. + */ +export const LIST_NAME_FILTER_PARAMETER = { + placeholder: "filter", + brief: "Only names matching a glob (web-*) or text (fuzzy)", + parse: String, + optional: true as const, +}; + +/** + * {@link LIST_TARGET_POSITIONAL} followed by {@link LIST_NAME_FILTER_PARAMETER}. + * Split the two with {@link splitListTargetAndFilter}. + */ +export const LIST_TARGET_FILTER_POSITIONAL = { + kind: "tuple" as const, + parameters: [ + ...LIST_TARGET_POSITIONAL.parameters, + LIST_NAME_FILTER_PARAMETER, + ], +}; + +/** + * {@link LIST_ORG_TARGET_POSITIONAL} followed by + * {@link LIST_NAME_FILTER_PARAMETER}, for org-scoped resources. + */ +export const LIST_ORG_TARGET_FILTER_POSITIONAL = { + kind: "tuple" as const, + parameters: [ + ...LIST_ORG_TARGET_POSITIONAL.parameters, + LIST_NAME_FILTER_PARAMETER, + ], +}; + /** * Short note for commands that accept a bare project name but do not support * org-all mode (e.g. trace list, log list, project view). @@ -716,7 +752,9 @@ export function buildOrgListCommand( schema: config.schema, } satisfies OutputConfig>, parameters: { - positional: LIST_ORG_TARGET_POSITIONAL, + positional: config.namesOf + ? LIST_ORG_TARGET_FILTER_POSITIONAL + : LIST_ORG_TARGET_POSITIONAL, flags: { limit: buildListLimitFlag(config.entityPlural), }, @@ -731,15 +769,20 @@ export function buildOrgListCommand( readonly fresh: boolean; readonly fields?: string[]; }, - target?: string, + rawTarget?: string, + rawFilter?: string, ) { const { cwd } = this; + const { target, filter } = config.namesOf + ? splitListTargetAndFilter(rawTarget, rawFilter) + : { target: rawTarget, filter: undefined }; const parsed = parseOrgProjectArg(target); const result = await dispatchOrgScopedList({ config, cwd, flags, parsed, + filter, }); yield new CommandOutput(result); // Only forward hint to the footer when items exist — empty results diff --git a/packages/cli/src/lib/list-filter.ts b/packages/cli/src/lib/list-filter.ts index 00b76f301..44ffb29e2 100644 --- a/packages/cli/src/lib/list-filter.ts +++ b/packages/cli/src/lib/list-filter.ts @@ -1,6 +1,7 @@ /** - * Name filters for list commands (`sentry org list `): a glob, or - * text matched with the shared fuzzy search. + * Name filters for list commands (`sentry org list `, + * `sentry team list / `): a glob, or text matched with the + * shared fuzzy search. */ import picomatch from "picomatch"; @@ -31,7 +32,9 @@ export function filterByName( return [...items]; } if (isGlobFilter(needle)) { - const matches = picomatch(needle, { dot: true }); + // Names aren't paths: `bash` lets `*` match `/` too, so `*-sdk` matches + // the repository `getsentry/sentry-python-sdk`. + const matches = picomatch(needle, { dot: true, bash: true }); return items.filter((item) => namesOf(item).some((name) => matches(name.toLowerCase())), ); @@ -57,3 +60,29 @@ export function filterByName( .sort((a, b) => a.rank - b.rank) .map(({ item }) => item); } + +/** + * Split a list command's `[] []` positionals into the + * target and name filter. With no second positional, a glob in the lone + * argument is a filter, since no slug contains glob characters: + * `web-*` → filter `web-*` (auto-detected org), `acme/web-*` → target + * `acme/` and filter `web-*`. Any other lone argument stays the target. + */ +export function splitListTargetAndFilter( + target: string | undefined, + filter: string | undefined, +): { target?: string; filter?: string } { + const trimmedFilter = filter?.trim() || undefined; + if (trimmedFilter || !target) { + return { target, filter: trimmedFilter }; + } + const slash = target.indexOf("/"); + if (slash < 0) { + return isGlobFilter(target) ? { filter: target.trim() } : { target }; + } + const rest = target.slice(slash + 1); + if (rest.includes("/") || !isGlobFilter(rest)) { + return { target }; + } + return { target: target.slice(0, slash + 1), filter: rest }; +} diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index 405bb5189..7e3cb00db 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -48,6 +48,7 @@ import { } from "./errors.js"; import { filterFields } from "./formatters/json.js"; import { paginationHint } from "./list-command.js"; +import { filterByName } from "./list-filter.js"; import { logger } from "./logger.js"; import { withProgress } from "./polling.js"; import { resolveEffectiveOrg } from "./region.js"; @@ -432,6 +433,13 @@ export type OrgListConfig = ListCommandMeta & { * `--help`, `sentry help`, and SKILL.md can document available fields. */ schema?: import("valibot").GenericSchema; + + /** + * The names a name filter matches against (e.g. slug and display name). + * When set, `buildOrgListCommand` accepts `[] []` and the + * default handlers keep only matching entities (see {@link filterByName}). + */ + namesOf?: (entity: TEntity) => readonly string[]; }; /** Extract a specific variant from the {@link ParsedOrgProject} union by its `type` discriminant. */ @@ -463,6 +471,12 @@ export type HandlerContext< * Present only when project-search mode continues to a handler. */ projectSearchResolution?: ProjectSearchTargetResolution; + /** + * Name filter (glob or fuzzy text) from the command line, if any. Handlers + * must apply it to the whole result set before `--limit`; the dispatcher + * already rejects it with `--cursor`. + */ + filter?: string; }; /** @@ -785,6 +799,11 @@ type ExplicitOrgOptions = { flags: BaseListFlags; /** When true, include a note that the entity type is org-scoped. */ noteOrgScoped?: boolean; + /** + * When true, omit the tip pointing at the paginated `/` listing, + * which would drop a name filter. + */ + filtered?: boolean; }; /** @@ -799,7 +818,7 @@ type ExplicitOrgOptions = { export async function handleExplicitOrg( options: ExplicitOrgOptions, ): Promise> { - const { config, org, flags, noteOrgScoped = false } = options; + const { config, org, flags, noteOrgScoped = false, filtered } = options; const items = await withProgress( { message: `Fetching ${config.entityPlural} (up to ${flags.limit})...`, @@ -821,7 +840,7 @@ export async function handleExplicitOrg( result.header = result.header ? `${note}\n${result.header}` : note; } - if (items.length > 0) { + if (items.length > 0 && !filtered) { const tip = `Tip: Use '${config.commandPrefix} ${org}/' for paginated results`; result.hint = result.hint ? `${result.hint}\n${tip}` : tip; } @@ -835,6 +854,8 @@ type ExplicitProjectOptions = { org: string; project: string; flags: BaseListFlags; + /** When true, omit the tip pointing at the unfiltered `/` listing. */ + filtered?: boolean; }; /** @@ -848,7 +869,7 @@ type ExplicitProjectOptions = { export async function handleExplicitProject( options: ExplicitProjectOptions, ): Promise> { - const { config, org, project, flags } = options; + const { config, org, project, flags, filtered } = options; const listForProject = config.listForProject; if (!listForProject) { throw new Error( @@ -871,7 +892,7 @@ export async function handleExplicitProject( contextLabel: `project '${org}/${project}'`, }); - if (items.length > 0) { + if (items.length > 0 && !filtered) { const tip = `Tip: Use '${config.commandPrefix} ${org}/' to see all ${config.entityPlural} in the org`; result.hint = result.hint ? `${result.hint}\n${tip}` : tip; } @@ -1026,6 +1047,34 @@ export async function handleProjectSearch( }; } +/** + * `config` narrowed to entities whose names match `filter`. Its fetchers + * filter (and rank) each fetched set, and `entityPlural` names the filter + * (`teams matching 'back*'`) so progress, empty, and count messages say what + * was matched. + */ +function withNameFilter( + config: OrgListConfig, + filter: string, +): OrgListConfig { + const { namesOf, listForOrg, listForProject } = config; + if (!namesOf) { + throw new Error( + `'${config.commandPrefix}' got a name filter but its config has no namesOf.`, + ); + } + const keep = (entities: TEntity[]) => filterByName(entities, filter, namesOf); + return { + ...config, + entityPlural: `${config.entityPlural} matching '${filter}'`, + listForOrg: async (org) => keep(await listForOrg(org)), + ...(listForProject && { + listForProject: async (org: string, project: string) => + keep(await listForProject(org, project)), + }), + }; +} + /** * Build the default `ModeHandlerMap` for the given config. * @@ -1037,8 +1086,14 @@ export async function handleProjectSearch( * covered by the caller's overrides, which would be a programming error. */ function buildDefaultHandlers( - config: ListCommandMeta | OrgListConfig, + fullConfig: ListCommandMeta | OrgListConfig, + filter?: string, ): ModeHandlerMap { + const config = + filter && isOrgListConfig(fullConfig) + ? withNameFilter(fullConfig, filter) + : fullConfig; + const filtered = filter !== undefined; function notSupported( mode: string, ): ModeHandler { @@ -1070,6 +1125,7 @@ function buildDefaultHandlers( org: ctx.parsed.org, project: ctx.parsed.project, flags: ctx.flags, + filtered, }); } return handleExplicitOrg({ @@ -1077,19 +1133,37 @@ function buildDefaultHandlers( org: ctx.parsed.org, flags: ctx.flags, noteOrgScoped: true, + filtered, }); }, "project-search": (ctx) => handleProjectSearch(config, ctx.parsed.projectSlug, { flags: ctx.flags, - orgAllFallback: (orgSlug) => runOrgAll(config, orgSlug, ctx.flags), + orgAllFallback: (orgSlug) => + filtered + ? handleExplicitOrg({ + config, + org: orgSlug, + flags: ctx.flags, + filtered, + }) + : runOrgAll(config, orgSlug, ctx.flags), originalSlug: ctx.parsed.originalSlug, org: ctx.parsed.org, projectSearchResolution: ctx.projectSearchResolution, }), "org-all": (ctx) => { + // A filter needs every entity, so it skips cursor pagination. + if (filtered) { + return handleExplicitOrg({ + config, + org: ctx.parsed.org, + flags: ctx.flags, + filtered, + }); + } const contextKey = buildOrgContextKey(ctx.parsed.org); const { cursor, direction } = resolveCursor( ctx.flags.cursor, @@ -1129,6 +1203,12 @@ export type DispatchOptions = { * `issue list`) can list those mode types here to bypass the guard. */ allowCursorInModes?: readonly ParsedOrgProject["type"][]; + /** + * Name filter from the command line (glob or fuzzy text). Default + * handlers apply it with the config's `namesOf`; overrides receive it as + * `ctx.filter`. Rejected together with `--cursor`. + */ + filter?: string; }; type OrgSlugMatchResult = { @@ -1218,6 +1298,16 @@ export async function dispatchOrgScopedList( ...(options.allowCursorInModes ?? []), ]; + const filter = options.filter?.trim() || undefined; + if (filter && flags.cursor) { + throw new ValidationError( + `The --cursor flag can't be combined with a name filter ('${filter}'): ` + + "filtering searches every page, so the matches arrive as one list. " + + "Use --limit to see more of them.", + "cursor", + ); + } + if (effectiveParsed.type === "project-search") { const resolution = await resolveOrgSlugMatch( effectiveParsed, @@ -1245,7 +1335,7 @@ export async function dispatchOrgScopedList( effectiveParsed = await resolveOrgInParsed(effectiveParsed); - const defaults = buildDefaultHandlers(config); + const defaults = buildDefaultHandlers(config, filter); const handlers: ModeHandlerMap = { ...defaults, ...overrides }; const handler = handlers[effectiveParsed.type]; @@ -1254,6 +1344,7 @@ export async function dispatchOrgScopedList( cwd, flags, projectSearchResolution, + filter, }; // oxlint-disable-next-line typescript/no-explicit-any -- safe — dispatch guarantees type match diff --git a/packages/cli/test/commands/project/list.test.ts b/packages/cli/test/commands/project/list.test.ts index 5eaf6f2ec..802c4c2b4 100644 --- a/packages/cli/test/commands/project/list.test.ts +++ b/packages/cli/test/commands/project/list.test.ts @@ -1580,3 +1580,109 @@ describe("handleAutoDetect", () => { expect(result.hasMore).toBe(false); }); }); + +describe("project list: name filter", () => { + const flags = { limit: 30, json: true, fresh: false }; + const projects: SentryProject[] = [ + ...sampleProjects, + { + id: "3", + slug: "web-checkout", + name: "Web Checkout", + platform: "javascript", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + }, + ]; + let func: Awaited>; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + func = await listCommand.loader(); + setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + async function run( + runFlags: Record, + ...args: (string | undefined)[] + ): Promise { + let output = ""; + const context = { + process, + env: process.env, + stdout: { + write(value: string) { + output += value; + return true; + }, + }, + stderr: { write: () => true }, + cwd: getConfigDir(), + }; + await func.call(context, { ...flags, ...runFlags }, ...args); + return output; + } + + function slugs(output: string): string[] { + const parsed = JSON.parse(output) as + | SentryProject[] + | { data: SentryProject[] }; + const items = Array.isArray(parsed) ? parsed : parsed.data; + return items.map(({ slug }) => slug); + } + + test("/ keeps projects whose slug or name matches", async () => { + globalThis.fetch = mockProjectFetch(projects); + expect(slugs(await run({}, "test-org/", "*end"))).toEqual([ + "frontend", + "backend", + ]); + expect(slugs(await run({}, "test-org/", "web checkout"))).toEqual([ + "web-checkout", + ]); + }); + + test("plain text is fuzzy", async () => { + globalThis.fetch = mockProjectFetch(projects); + expect(slugs(await run({}, "test-org/", "frontnd"))).toEqual(["frontend"]); + }); + + test("a lone / splits into target and filter", async () => { + globalThis.fetch = mockProjectFetch(projects); + expect(slugs(await run({}, "test-org/web-*"))).toEqual(["web-checkout"]); + }); + + test("combines with --platform", async () => { + globalThis.fetch = mockProjectFetch(projects); + expect( + slugs(await run({ platform: "javascript" }, "test-org/", "*end")), + ).toEqual(["frontend"]); + }); + + test("--limit applies after filtering", async () => { + globalThis.fetch = mockProjectFetch(projects); + expect(slugs(await run({ limit: 1 }, "test-org/", "*end"))).toEqual([ + "frontend", + ]); + }); + + test("names the filter when nothing matches", async () => { + globalThis.fetch = mockProjectFetch(projects); + const output = await run({ json: false }, "test-org/", "nope-*"); + expect(output).toContain( + "No projects matching 'nope-*' in organization 'test-org'.", + ); + }); + + test("rejects --cursor with a filter", async () => { + globalThis.fetch = mockProjectFetch(projects); + await expect(run({ cursor: "next" }, "test-org/", "web-*")).rejects.toThrow( + ValidationError, + ); + }); +}); diff --git a/packages/cli/test/commands/team/list.test.ts b/packages/cli/test/commands/team/list.test.ts index ebe02e60c..530f8e85a 100644 --- a/packages/cli/test/commands/team/list.test.ts +++ b/packages/cli/test/commands/team/list.test.ts @@ -548,3 +548,45 @@ describe("listCommand.func — org-all mode (cursor pagination)", () => { ); }); }); + +describe("listCommand.func — name filter", () => { + const listTeamsSpy = vi.mocked(apiClient.listTeams); + const listTeamsPaginatedSpy = vi.mocked(apiClient.listTeamsPaginated); + const resolveOrgsForListingSpy = vi.mocked( + resolveTarget.resolveOrgsForListing, + ); + + beforeEach(() => { + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + listTeamsSpy.mockResolvedValue(sampleTeams); + }); + + afterEach(() => { + listTeamsSpy.mockReset(); + listTeamsPaginatedSpy.mockReset(); + resolveOrgsForListingSpy.mockReset(); + }); + + async function runJson(...args: (string | undefined)[]) { + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: true }, ...args); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + const items: SentryTeam[] = Array.isArray(parsed) ? parsed : parsed.data; + return items.map((team) => team.slug); + } + + test("/ lists the whole org and keeps matching teams", async () => { + expect(await runJson("test-org/", "front*")).toEqual(["frontend"]); + expect(await runJson("test-org/", "backend team")).toEqual(["backend"]); + expect(listTeamsSpy).toHaveBeenCalledWith("test-org"); + expect(listTeamsPaginatedSpy).not.toHaveBeenCalled(); + }); + + test("a lone glob filters the auto-detected org", async () => { + resolveOrgsForListingSpy.mockResolvedValue({ orgs: ["test-org"] }); + expect(await runJson("*end")).toEqual(["backend", "frontend"]); + expect(await runJson("back*")).toEqual(["backend"]); + }); +}); diff --git a/packages/cli/test/lib/list-filter.test.ts b/packages/cli/test/lib/list-filter.test.ts index ea40c7f70..1adc52ae1 100644 --- a/packages/cli/test/lib/list-filter.test.ts +++ b/packages/cli/test/lib/list-filter.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "vitest"; -import { filterByName, isGlobFilter } from "../../src/lib/list-filter.js"; +import { + filterByName, + isGlobFilter, + splitListTargetAndFilter, +} from "../../src/lib/list-filter.js"; type Item = { slug: string; name: string }; @@ -29,6 +33,16 @@ describe("filterByName", () => { expect(filterByName(ITEMS, "acme?", namesOf)).toEqual([]); }); + test("a glob's * also matches /, since names aren't paths", () => { + const repos = [ + { name: "getsentry/sentry-python-sdk" }, + { name: "acme/web" }, + ]; + expect( + filterByName(repos, "*-sdk", (repo) => [repo.name]).map((r) => r.name), + ).toEqual(["getsentry/sentry-python-sdk"]); + }); + test("plain text is a fuzzy search, best match first", () => { expect(slugs(filterByName(ITEMS, "acme-dev", namesOf))[0]).toBe("acme-dev"); expect(slugs(filterByName(ITEMS, "widgts", namesOf))).toEqual(["widgets"]); @@ -45,3 +59,25 @@ describe("isGlobFilter", () => { expect(isGlobFilter(filter)).toBe(expected); }); }); + +describe("splitListTargetAndFilter", () => { + test.each([ + [[undefined, undefined], {}], + [["my-org/", undefined], { target: "my-org/" }], + [["my-org/", " web-* "], { target: "my-org/", filter: "web-*" }], + [["my-org/", " "], { target: "my-org/" }], + // A lone bare name is still a project search. + [["web", undefined], { target: "web" }], + // No slug has glob characters, so a lone glob is a filter. + [["web-*", undefined], { filter: "web-*" }], + [["my-org/web-*", undefined], { target: "my-org/", filter: "web-*" }], + [["my-org/web,api", undefined], { target: "my-org/web,api" }], + [["my-org/proj/x*", undefined], { target: "my-org/proj/x*" }], + ] as const)("%j → %j", ([target, filter], expected) => { + const result = splitListTargetAndFilter(target, filter); + expect({ + ...(result.target !== undefined && { target: result.target }), + ...(result.filter !== undefined && { filter: result.filter }), + }).toEqual(expected); + }); +}); diff --git a/packages/cli/test/lib/org-list.test.ts b/packages/cli/test/lib/org-list.test.ts index 163b430fa..dd2bd6582 100644 --- a/packages/cli/test/lib/org-list.test.ts +++ b/packages/cli/test/lib/org-list.test.ts @@ -1246,3 +1246,128 @@ describe("dispatchOrgScopedList", () => { }); }); }); + +// --------------------------------------------------------------------------- +// dispatchOrgScopedList — name filter +// --------------------------------------------------------------------------- + +describe("dispatchOrgScopedList name filter", () => { + const WIDGETS: FakeEntity[] = [ + { id: "1", name: "web-frontend" }, + { id: "2", name: "api" }, + { id: "3", name: "web-backend" }, + ]; + let resolveEffectiveOrgSpy: ReturnType; + + beforeEach(() => { + resolveEffectiveOrgSpy = vi + .spyOn(region, "resolveEffectiveOrg") + .mockImplementation((org: string) => Promise.resolve(org)); + }); + + afterEach(() => { + resolveEffectiveOrgSpy.mockRestore(); + }); + + function filterConfig() { + return makeConfig({ + listForOrg: vi.fn(() => Promise.resolve(WIDGETS)), + namesOf: (widget) => [widget.name], + }); + } + + test("org-all fetches every widget instead of one page, then filters", async () => { + const config = filterConfig(); + const result = await dispatchOrgScopedList({ + config, + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "org-all", org: "my-org" }, + filter: "web-*", + }); + + expect(result.items.map((w: FakeWithOrg) => w.name)).toEqual([ + "web-frontend", + "web-backend", + ]); + expect(config.listForOrg).toHaveBeenCalledWith("my-org"); + expect(config.listPaginated).not.toHaveBeenCalled(); + // The paginated `/` tip would drop the filter. + expect(result.hint ?? "").not.toContain("paginated"); + }); + + test("plain text is fuzzy and --limit applies to the matches", async () => { + const result = await dispatchOrgScopedList({ + config: filterConfig(), + cwd: "/tmp", + flags: { limit: 1, json: true }, + parsed: { type: "org-all", org: "my-org" }, + filter: "web-backnd", + }); + + expect(result.items.map((w: FakeWithOrg) => w.name)).toEqual([ + "web-backend", + ]); + }); + + test("an empty result names the filter", async () => { + const result = await dispatchOrgScopedList({ + config: filterConfig(), + cwd: "/tmp", + flags: { limit: 10, json: false }, + parsed: { type: "org-all", org: "my-org" }, + filter: "nope-*", + }); + + expect(result.items).toEqual([]); + expect(result.hint).toBe( + "No widgets matching 'nope-*' found in organization 'my-org'.", + ); + }); + + test("filters project-scoped listings too", async () => { + const listForProject = vi.fn(() => Promise.resolve(WIDGETS)); + const config = makeConfig({ + listForProject, + namesOf: (widget) => [widget.name], + }); + const result = await dispatchOrgScopedList({ + config, + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "explicit", org: "my-org", project: "proj" }, + filter: "api", + }); + + expect(result.items.map((w: FakeWithOrg) => w.name)).toEqual(["api"]); + expect(listForProject).toHaveBeenCalledWith("my-org", "proj"); + }); + + test("rejects --cursor with a filter", async () => { + await expect( + dispatchOrgScopedList({ + config: filterConfig(), + cwd: "/tmp", + flags: { limit: 10, json: false, cursor: "next" }, + parsed: { type: "org-all", org: "my-org" }, + filter: "web-*", + }), + ).rejects.toThrow(/name filter/); + }); + + test("overrides receive the filter as ctx.filter", async () => { + const orgAll = vi.fn(() => Promise.resolve({ items: [] })); + await dispatchOrgScopedList({ + config: META_ONLY, + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "org-all", org: "my-org" }, + filter: " web-* ", + overrides: { "org-all": orgAll }, + }); + + expect(orgAll).toHaveBeenCalledWith( + expect.objectContaining({ filter: "web-*" }), + ); + }); +}); From 4b33e4bc0dad549710731cb5d89c85b0ced93c1a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:25:44 +0000 Subject: [PATCH 2/8] fix(cli): Read every page and surface API errors for filtered org lists A name filter on `/` went through `listForOrg`, which for teams, repositories and integrations returns only the first API page, so later matches were dropped. It also used `fetchOrgSafe`, which turns a missing org, 403 or network failure into an empty "no matches" result. With a filter, the default handlers now walk every page of `listPaginated`, and org-all mode lets API errors propagate as the unfiltered `/` listing does. Co-Authored-By: BYK --- packages/cli/src/lib/org-list.ts | 73 +++++++++++++++----- packages/cli/test/commands/team/list.test.ts | 15 ++-- packages/cli/test/lib/org-list.test.ts | 57 +++++++++++++-- 3 files changed, 118 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index 7e3cb00db..f1f21ba28 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -30,7 +30,12 @@ * how to render the result — JSON envelope, human table, or custom formatting. */ -import { paginate } from "./api/infrastructure.js"; +import { + API_MAX_PER_PAGE, + autoPaginate, + MAX_PAGINATION_PAGES, + paginate, +} from "./api/infrastructure.js"; import { listOrganizations, type PaginatedResponse } from "./api-client.js"; import { explicitProjectSlugs, type ParsedOrgProject } from "./arg-parsing.js"; import { @@ -1047,17 +1052,58 @@ export async function handleProjectSearch( }; } +/** + * Every entity in `org`, walking all pages of `listPaginated`. Some + * `listForOrg` implementations return only the first page, which a name + * filter can't rely on. Errors propagate. + */ +async function listEveryPage( + config: OrgListConfig, + org: string, +): Promise { + const { data } = await autoPaginate( + (cursor) => + config.listPaginated(org, { cursor, perPage: API_MAX_PER_PAGE }), + MAX_PAGINATION_PAGES * API_MAX_PER_PAGE, + ); + return data; +} + +/** + * Org-all mode with a name filter (`sentry team list / back*`): every + * page instead of one cursor page, since matching and fuzzy ranking need the + * whole set; `--limit` applies to the matches. Like unfiltered org-all, API + * errors propagate rather than reading as "no matches". `config` comes from + * {@link withNameFilter}. + */ +async function handleOrgAllFiltered( + config: OrgListConfig, + org: string, + flags: BaseListFlags, +): Promise> { + const entities = await withProgress( + { message: `Fetching ${config.entityPlural}...`, json: flags.json }, + () => config.listForOrg(org), + ); + return buildFetchedItemsResult({ + config, + items: entities.map((entity) => config.withOrg(entity, org)), + flags, + contextLabel: `organization '${org}'`, + }); +} + /** * `config` narrowed to entities whose names match `filter`. Its fetchers - * filter (and rank) each fetched set, and `entityPlural` names the filter - * (`teams matching 'back*'`) so progress, empty, and count messages say what - * was matched. + * read every page and filter (and rank) the set, and `entityPlural` names the + * filter (`teams matching 'back*'`) so progress, empty, and count messages + * say what was matched. */ function withNameFilter( config: OrgListConfig, filter: string, ): OrgListConfig { - const { namesOf, listForOrg, listForProject } = config; + const { namesOf, listForProject } = config; if (!namesOf) { throw new Error( `'${config.commandPrefix}' got a name filter but its config has no namesOf.`, @@ -1067,7 +1113,7 @@ function withNameFilter( return { ...config, entityPlural: `${config.entityPlural} matching '${filter}'`, - listForOrg: async (org) => keep(await listForOrg(org)), + listForOrg: async (org) => keep(await listEveryPage(config, org)), ...(listForProject && { listForProject: async (org: string, project: string) => keep(await listForProject(org, project)), @@ -1142,12 +1188,7 @@ function buildDefaultHandlers( flags: ctx.flags, orgAllFallback: (orgSlug) => filtered - ? handleExplicitOrg({ - config, - org: orgSlug, - flags: ctx.flags, - filtered, - }) + ? handleOrgAllFiltered(config, orgSlug, ctx.flags) : runOrgAll(config, orgSlug, ctx.flags), originalSlug: ctx.parsed.originalSlug, org: ctx.parsed.org, @@ -1155,14 +1196,8 @@ function buildDefaultHandlers( }), "org-all": (ctx) => { - // A filter needs every entity, so it skips cursor pagination. if (filtered) { - return handleExplicitOrg({ - config, - org: ctx.parsed.org, - flags: ctx.flags, - filtered, - }); + return handleOrgAllFiltered(config, ctx.parsed.org, ctx.flags); } const contextKey = buildOrgContextKey(ctx.parsed.org); const { cursor, direction } = resolveCursor( diff --git a/packages/cli/test/commands/team/list.test.ts b/packages/cli/test/commands/team/list.test.ts index 530f8e85a..6596d5b07 100644 --- a/packages/cli/test/commands/team/list.test.ts +++ b/packages/cli/test/commands/team/list.test.ts @@ -558,7 +558,10 @@ describe("listCommand.func — name filter", () => { beforeEach(() => { setOrgRegion("test-org", DEFAULT_SENTRY_URL); - listTeamsSpy.mockResolvedValue(sampleTeams); + listTeamsPaginatedSpy.mockResolvedValue({ + data: sampleTeams, + nextCursor: undefined, + }); }); afterEach(() => { @@ -577,11 +580,15 @@ describe("listCommand.func — name filter", () => { return items.map((team) => team.slug); } - test("/ lists the whole org and keeps matching teams", async () => { + test("/ reads every page and keeps matching teams", async () => { expect(await runJson("test-org/", "front*")).toEqual(["frontend"]); expect(await runJson("test-org/", "backend team")).toEqual(["backend"]); - expect(listTeamsSpy).toHaveBeenCalledWith("test-org"); - expect(listTeamsPaginatedSpy).not.toHaveBeenCalled(); + // listTeams returns only the first page, so a filter walks the pages. + expect(listTeamsPaginatedSpy).toHaveBeenCalledWith("test-org", { + cursor: undefined, + perPage: 100, + }); + expect(listTeamsSpy).not.toHaveBeenCalled(); }); test("a lone glob filters the auto-detected org", async () => { diff --git a/packages/cli/test/lib/org-list.test.ts b/packages/cli/test/lib/org-list.test.ts index dd2bd6582..4d4580d92 100644 --- a/packages/cli/test/lib/org-list.test.ts +++ b/packages/cli/test/lib/org-list.test.ts @@ -53,6 +53,7 @@ vi.mock("../../src/lib/db/regions.js", async (importOriginal) => { }); import { + ApiError, AuthError, ResolutionError, ValidationError, @@ -1271,12 +1272,14 @@ describe("dispatchOrgScopedList name filter", () => { function filterConfig() { return makeConfig({ - listForOrg: vi.fn(() => Promise.resolve(WIDGETS)), + listPaginated: vi.fn(() => + Promise.resolve({ data: WIDGETS, nextCursor: undefined }), + ), namesOf: (widget) => [widget.name], }); } - test("org-all fetches every widget instead of one page, then filters", async () => { + test("org-all fetches every widget, then filters", async () => { const config = filterConfig(); const result = await dispatchOrgScopedList({ config, @@ -1290,12 +1293,58 @@ describe("dispatchOrgScopedList name filter", () => { "web-frontend", "web-backend", ]); - expect(config.listForOrg).toHaveBeenCalledWith("my-org"); - expect(config.listPaginated).not.toHaveBeenCalled(); + expect(config.listPaginated).toHaveBeenCalledWith("my-org", { + cursor: undefined, + perPage: 100, + }); + // listForOrg may return only the first page. + expect(config.listForOrg).not.toHaveBeenCalled(); // The paginated `/` tip would drop the filter. expect(result.hint ?? "").not.toContain("paginated"); }); + test("walks every page, so matches past the first page are kept", async () => { + const listPaginated = vi.fn((_org: string, opts: { cursor?: string }) => + Promise.resolve( + opts.cursor + ? { data: [{ id: "9", name: "web-late" }], nextCursor: undefined } + : { data: WIDGETS, nextCursor: "page-2" }, + ), + ); + const result = await dispatchOrgScopedList({ + config: makeConfig({ listPaginated, namesOf: (w) => [w.name] }), + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "org-all", org: "my-org" }, + filter: "web-*", + }); + + expect(result.items.map((w: FakeWithOrg) => w.name)).toEqual([ + "web-frontend", + "web-backend", + "web-late", + ]); + expect(listPaginated).toHaveBeenCalledTimes(2); + }); + + test("API errors propagate instead of reading as no matches", async () => { + const config = makeConfig({ + listPaginated: vi.fn(() => + Promise.reject(new ApiError("Organization not found", 404)), + ), + namesOf: (widget) => [widget.name], + }); + await expect( + dispatchOrgScopedList({ + config, + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "org-all", org: "missing-org" }, + filter: "web-*", + }), + ).rejects.toThrow(ApiError); + }); + test("plain text is fuzzy and --limit applies to the matches", async () => { const result = await dispatchOrgScopedList({ config: filterConfig(), From 810f03ecf7757dac4173e46b1230076537506ff5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:27:58 +0000 Subject: [PATCH 3/8] fix(cli): Keep the name filter in auto-detect list hints Auto-detect tips (` /`) and project list's JSON pagination hint dropped the name filter, so following them listed a different set. --- packages/cli/src/commands/project/list.ts | 11 +++++---- packages/cli/src/lib/org-list.ts | 9 ++++++-- .../cli/test/commands/project/list.test.ts | 19 +++++++++++++++ packages/cli/test/lib/org-list.test.ts | 23 +++++++++++++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index f67f6b045..590d7884a 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -68,6 +68,7 @@ import { resolveAllTargets, } from "../../lib/resolve-target.js"; import { getApiBaseUrl } from "../../lib/sentry-client.js"; +import { shellQuote } from "../../lib/utils.js"; import type { SentryProject } from "../../types/index.js"; /** Extended result type with optional title shown above the table. */ @@ -335,10 +336,10 @@ async function fetchAutoDetectProjects( } /** Build a pagination hint for auto-detect JSON output. */ -function autoDetectPaginationHint(orgs: string[]): string { - return orgs.length === 1 - ? `sentry project list ${orgs[0]}/ --json` - : "sentry project list / --json"; +function autoDetectPaginationHint(orgs: string[], filter?: string): string { + const target = orgs.length === 1 ? `${orgs[0]}/` : "/"; + const filterArg = filter === undefined ? "" : ` ${shellQuote(filter)}`; + return `sentry project list ${target}${filterArg} --json`; } /** Build the truncation header for auto-detect mode. */ @@ -434,7 +435,7 @@ export async function handleAutoDetect( header, hint: hintParts.length > 0 ? hintParts.join("\n") : undefined, jsonExtra: hasMore - ? { hint: autoDetectPaginationHint(orgsToFetch) } + ? { hint: autoDetectPaginationHint(orgsToFetch, flags.filter) } : undefined, }; } diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index f1f21ba28..eb75191f9 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -64,6 +64,7 @@ import { resolveOrgsForListing, } from "./resolve-target.js"; import { setOrgProjectContext } from "./telemetry.js"; +import { shellQuote } from "./utils.js"; const log = logger.withTag("org-list"); @@ -681,6 +682,8 @@ export async function handleAutoDetect( config: OrgListConfig, cwd: string, flags: BaseListFlags, + /** Name filter already applied by `config`; kept in the `/` tip. */ + filter?: string, ): Promise> { const { orgs: orgsToFetch, @@ -736,8 +739,9 @@ export async function handleAutoDetect( } if (limited.length > 0) { + const filterArg = filter === undefined ? "" : ` ${shellQuote(filter)}`; hintParts.push( - `Tip: Use '${config.commandPrefix} /' to filter by organization`, + `Tip: Use '${config.commandPrefix} /${filterArg}' to filter by organization`, ); } @@ -1162,7 +1166,8 @@ function buildDefaultHandlers( } return { - "auto-detect": (ctx) => handleAutoDetect(config, ctx.cwd, ctx.flags), + "auto-detect": (ctx) => + handleAutoDetect(config, ctx.cwd, ctx.flags, filter), explicit: (ctx) => { if (config.listForProject) { diff --git a/packages/cli/test/commands/project/list.test.ts b/packages/cli/test/commands/project/list.test.ts index 802c4c2b4..6139541fa 100644 --- a/packages/cli/test/commands/project/list.test.ts +++ b/packages/cli/test/commands/project/list.test.ts @@ -1516,6 +1516,25 @@ describe("handleAutoDetect", () => { expect(jsonHint).toContain("--json"); }); + test("name filter: jsonExtra hint keeps the filter", async () => { + setDefaultOrganization("test-org"); + const manyProjects = Array.from({ length: 5 }, (_, i) => + makeProject({ id: String(i), slug: `web-${i}`, name: `Web ${i}` }), + ); + globalThis.fetch = mockProjectFetch(manyProjects); + + const result = await handleAutoDetect("/tmp/test-project", { + limit: 2, + json: true, + fresh: false, + filter: "web-*", + }); + + expect(result.hasMore).toBe(true); + const jsonHint = (result.jsonExtra as Record)?.hint; + expect(jsonHint).toBe("sentry project list test-org/ 'web-*' --json"); + }); + test("fast path: non-auth API errors return empty results instead of throwing", async () => { setDefaultOrganization("test-org"); // Mock returns 403 for projects endpoint (stale org, no access) diff --git a/packages/cli/test/lib/org-list.test.ts b/packages/cli/test/lib/org-list.test.ts index 4d4580d92..c374397cc 100644 --- a/packages/cli/test/lib/org-list.test.ts +++ b/packages/cli/test/lib/org-list.test.ts @@ -1303,6 +1303,29 @@ describe("dispatchOrgScopedList name filter", () => { expect(result.hint ?? "").not.toContain("paginated"); }); + test("auto-detect keeps the filter in the / tip", async () => { + const defaultOrgSpy = vi + .spyOn(defaults, "getDefaultOrganization") + .mockReturnValue("my-org"); + try { + const result = await dispatchOrgScopedList({ + config: filterConfig(), + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "auto-detect" }, + filter: "web-*", + }); + + expect(result.items.map((w: FakeWithOrg) => w.name)).toEqual([ + "web-frontend", + "web-backend", + ]); + expect(result.hint).toContain("sentry widget list / 'web-*'"); + } finally { + defaultOrgSpy.mockRestore(); + } + }); + test("walks every page, so matches past the first page are kept", async () => { const listPaginated = vi.fn((_org: string, opts: { cursor?: string }) => Promise.resolve( From f9b38fe5187db0b1a438b279a28a2003dee9a6fd Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:58:15 +0000 Subject: [PATCH 4/8] feat(cli): Server-side name search and / fuzzy fallback Review follow-ups for the list name filter: - Narrow name filters with the API's `?query=` (case-insensitive substring on name/slug) for projects, teams, repos and monitors instead of reading every page. A glob sends its longest literal part (`web-*` -> `web-`). Plain text the server finds nothing for falls back to fuzzy matching over every page. Integrations have no name search and still read every page. - `/` tries a direct match first, then fuzzy: project list falls back to matching project names when no such project exists; team list does the same when the project 404s; repo, monitor and integration lists match entity names and, when nothing matches, keep listing the whole org with the org-scoped note. - Document `/` as the primary form and move the filter examples out of free-text `fullDescription` into `docs.examples`. Co-Authored-By: BYK --- .../sentry-cli/references/integration.md | 5 +- .../skills/sentry-cli/references/monitor.md | 5 +- .../skills/sentry-cli/references/project.md | 5 +- .../skills/sentry-cli/references/repo.md | 5 +- .../skills/sentry-cli/references/team.md | 5 +- packages/cli/src/commands/integration/list.ts | 14 ++- packages/cli/src/commands/monitor/list.ts | 15 ++- packages/cli/src/commands/project/list.ts | 73 ++++++++++-- packages/cli/src/commands/repo/list.ts | 15 ++- packages/cli/src/commands/team/list.ts | 13 +- packages/cli/src/lib/api-client.ts | 1 + packages/cli/src/lib/api/monitors.ts | 8 +- packages/cli/src/lib/api/projects.ts | 34 +++++- packages/cli/src/lib/api/teams.ts | 8 +- packages/cli/src/lib/list-command.ts | 3 +- packages/cli/src/lib/list-filter.ts | 84 ++++++++++++- packages/cli/src/lib/org-list.ts | 94 +++++++++++++-- .../test/commands/integration/list.test.ts | 21 ++++ .../cli/test/commands/project/list.test.ts | 64 +++++++++- packages/cli/test/commands/repo/list.test.ts | 32 +++++ packages/cli/test/commands/team/list.test.ts | 41 ++++++- packages/cli/test/lib/list-filter.test.ts | 111 +++++++++++++++++- packages/cli/test/lib/org-list.test.ts | 2 +- 23 files changed, 589 insertions(+), 69 deletions(-) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/integration.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/integration.md index d03b07d8f..df6fc82ec 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/integration.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/integration.md @@ -45,7 +45,10 @@ sentry integration list my-org/ sentry integration list my-org/ -c next # Only integrations whose name matches a glob -sentry integration list my-org/ 'git*' +sentry integration list 'my-org/git*' + +# Integrations named like 'GitHub' (fuzzy when nothing matches exactly) +sentry integration list my-org/githb # Output as JSON sentry integration list --json diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/monitor.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/monitor.md index 8bf394938..e4a33643b 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/monitor.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/monitor.md @@ -73,7 +73,10 @@ sentry monitor list my-org/ sentry monitor list my-org/ -c next # Only monitors whose slug or name matches a glob -sentry monitor list my-org/ 'nightly-*' +sentry monitor list 'my-org/nightly-*' + +# Monitors named like 'nightly' (fuzzy when nothing matches exactly) +sentry monitor list my-org/nigthly # Output as JSON sentry monitor list --json diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md index a2c1228c4..f751bad02 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md @@ -85,7 +85,10 @@ sentry project list my-org/frontend,backend sentry project list my-org/ --platform javascript # Only projects whose slug or name matches a glob -sentry project list my-org/ 'web-*' +sentry project list 'my-org/web-*' + +# Projects named like 'frontend' (fuzzy when nothing matches exactly) +sentry project list my-org/frontnd ``` ### `sentry project view []` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/repo.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/repo.md index 9a896e6f8..cdd4f5f90 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/repo.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/repo.md @@ -47,7 +47,10 @@ sentry repo list my-org/ sentry repo list my-org/ -c next # Only repositories whose name matches a glob -sentry repo list my-org/ 'getsentry/*' +sentry repo list 'my-org/getsentry/*' + +# Repositories named like 'sentry-python' (fuzzy when nothing matches exactly) +sentry repo list my-org/sentry-pyhton # Output as JSON sentry repo list --json diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/team.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/team.md index f295c5993..203790f7b 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/team.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/team.md @@ -45,7 +45,10 @@ sentry team list my-org/ sentry team list my-org/ -c next # Only teams whose slug or name matches a glob -sentry team list my-org/ 'back*' +sentry team list 'my-org/back*' + +# Teams named like 'backend' when no such project exists (fuzzy) +sentry team list my-org/backnd # Output as JSON sentry team list --json diff --git a/packages/cli/src/commands/integration/list.ts b/packages/cli/src/commands/integration/list.ts index 9cf7402f5..88b7ef4ce 100644 --- a/packages/cli/src/commands/integration/list.ts +++ b/packages/cli/src/commands/integration/list.ts @@ -62,12 +62,9 @@ const docs: OrgListCommandDocs = { "Target specification:\n" + " sentry integration list # auto-detect from DSN or config\n" + " sentry integration list / # list all integrations in org (paginated)\n" + - " sentry integration list / # list integrations in org (project context)\n" + + " sentry integration list / # integrations whose name matches \n" + " sentry integration list # list integrations in org\n\n" + - "Name filter (searches every page, so it can't be paginated):\n" + - " sentry integration list / github # fuzzy match on the name\n" + - " sentry integration list / 'git*' # glob on the name\n" + - " sentry integration list 'git*' # a lone glob filters the auto-detected org(s)\n\n" + + "`` is a glob (`/git*`) or text: exact or partial matches first, then close (fuzzy) matches. Name filters can't be combined with --cursor.\n\n" + "Pagination:\n" + " sentry integration list / -c next # fetch next page\n" + " sentry integration list / -c prev # fetch previous page\n\n" + @@ -87,7 +84,12 @@ const docs: OrgListCommandDocs = { }, { description: "Only integrations whose name matches a glob", - command: "sentry integration list my-org/ 'git*'", + command: "sentry integration list 'my-org/git*'", + }, + { + description: + "Integrations named like 'GitHub' (fuzzy when nothing matches exactly)", + command: "sentry integration list my-org/githb", }, { description: "Output as JSON", diff --git a/packages/cli/src/commands/monitor/list.ts b/packages/cli/src/commands/monitor/list.ts index 62cb299b7..5dc504832 100644 --- a/packages/cli/src/commands/monitor/list.ts +++ b/packages/cli/src/commands/monitor/list.ts @@ -67,6 +67,7 @@ const monitorListConfig: OrgListConfig = { listPaginated: (org, opts) => listMonitorsPaginated(org, opts), withOrg: (monitor, orgSlug) => ({ ...monitor, orgSlug }), namesOf: (monitor) => [monitor.slug, monitor.name], + searchesNames: true, displayTable: (monitors: MonitorWithOrg[]) => formatTable(monitors, MONITOR_COLUMNS), schema: SentryMonitorSchema, @@ -79,12 +80,9 @@ const docs: OrgListCommandDocs = { "Target specification:\n" + " sentry monitor list # auto-detect from DSN or config\n" + " sentry monitor list / # list all monitors in org (paginated)\n" + - " sentry monitor list / # list monitors in org (project context)\n" + + " sentry monitor list / # monitors whose slug or name matches \n" + " sentry monitor list # list monitors in org\n\n" + - "Name filter (searches every page, so it can't be paginated):\n" + - " sentry monitor list / nightly # fuzzy match on the slug or name\n" + - " sentry monitor list / 'nightly-*' # glob on the slug or name\n" + - " sentry monitor list 'nightly-*' # a lone glob filters the auto-detected org(s)\n\n" + + "`` is a glob (`/nightly-*`) or text: exact or partial matches first, then close (fuzzy) matches. Name filters can't be combined with --cursor.\n\n" + "Pagination:\n" + " sentry monitor list / -c next # fetch next page\n" + " sentry monitor list / -c prev # fetch previous page\n\n" + @@ -104,7 +102,12 @@ const docs: OrgListCommandDocs = { }, { description: "Only monitors whose slug or name matches a glob", - command: "sentry monitor list my-org/ 'nightly-*'", + command: "sentry monitor list 'my-org/nightly-*'", + }, + { + description: + "Monitors named like 'nightly' (fuzzy when nothing matches exactly)", + command: "sentry monitor list my-org/nigthly", }, { description: "Output as JSON", command: "sentry monitor list --json" }, ], diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 590d7884a..4ec622c22 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -17,6 +17,7 @@ import { listProjects, listProjectsAllPages, type PaginatedResponse, + searchProjects, } from "../../lib/api-client.js"; import { explicitProjectSlugs, @@ -48,7 +49,9 @@ import { targetPatternExplanation, } from "../../lib/list-command.js"; import { + fetchByName, filterByName, + orgFilterTarget, splitListTargetAndFilter, } from "../../lib/list-filter.js"; @@ -124,6 +127,25 @@ export async function fetchOrgProjectsSafe( return result.ok ? result.value : []; } +/** + * Projects in one org whose slug or name matches the name `filter`. The + * server's `?query=` narrows the list first; text it finds nothing for + * falls back to fuzzy matching over every project (see {@link fetchByName}). + * Errors propagate. + */ +export async function fetchOrgProjectsByName( + orgSlug: string, + filter: string, +): Promise { + const projects = await fetchByName({ + filter, + namesOf: projectNamesOf, + fetchAll: () => listProjects(orgSlug), + search: (query) => searchProjects(orgSlug, query), + }); + return projects.map((p) => ({ ...p, orgSlug })); +} + /** * Fetch projects from all accessible organizations. * Skips orgs where the user lacks access. @@ -329,7 +351,18 @@ async function fetchAutoDetectProjects( return await fetchPaginatedSafe(orgs[0] as string, flags.limit); } if (orgs.length > 0) { - const results = await Promise.all(orgs.map(fetchOrgProjectsSafe)); + const { filter } = flags; + const results = await Promise.all( + orgs.map(async (org) => { + if (!filter) { + return await fetchOrgProjectsSafe(org); + } + const result = await withAuthGuard(() => + fetchOrgProjectsByName(org, filter), + ); + return result.ok ? result.value : []; + }), + ); return { projects: results.flat() }; } return { projects: await fetchAllOrgProjects() }; @@ -337,9 +370,8 @@ async function fetchAutoDetectProjects( /** Build a pagination hint for auto-detect JSON output. */ function autoDetectPaginationHint(orgs: string[], filter?: string): string { - const target = orgs.length === 1 ? `${orgs[0]}/` : "/"; - const filterArg = filter === undefined ? "" : ` ${shellQuote(filter)}`; - return `sentry project list ${target}${filterArg} --json`; + const org = orgs.length === 1 ? (orgs[0] as string) : ""; + return `sentry project list ${orgFilterTarget(org, filter, shellQuote)} --json`; } /** Build the truncation header for auto-detect mode. */ @@ -454,6 +486,17 @@ export async function handleExplicit( () => withAuthGuard(() => getProject(org, projectSlug)), ); if (!projectResult.ok) { + // `/frontnd`: no direct match, so match names (fuzzily) instead. + if (!flags.filter) { + const fallback = await handleOrgAllFiltered(org, { + ...flags, + filter: projectSlug, + }); + if (fallback.items.length > 0) { + const note = `No project '${projectSlug}' in '${org}'. Showing projects matching '${projectSlug}'.`; + return { ...fallback, title: note } as ProjectListResult; + } + } return { items: [], hint: @@ -644,7 +687,7 @@ export async function handleOrgAll( } /** - * Org-all mode with a name filter (`sentry project list / web-*`). + * Org-all mode with a name filter (`sentry project list /web-*`). * Matching (and fuzzy ranking) needs every project, so this fetches all * pages instead of one cursor page; `--limit` applies to the matches. */ @@ -657,7 +700,10 @@ async function handleOrgAllFiltered( message: `Fetching projects${matchingFilter(flags)}...`, json: flags.json, }, - () => fetchOrgProjects(org), + () => + flags.filter + ? fetchOrgProjectsByName(org, flags.filter) + : fetchOrgProjects(org), ); const matches = filterProjects(projects, flags); const items = matches.slice(0, flags.limit); @@ -805,10 +851,10 @@ export const listCommand = buildListCommand("project", { " sentry project list / -c next # next page\n" + " sentry project list / -c prev # previous page\n" + " sentry project list / -c # resume at specific cursor\n\n" + - "Name filter (a glob, or text matched fuzzily; searches every page):\n" + - " sentry project list / 'web-*' # slug or name matches the glob\n" + - " sentry project list / frontnd # fuzzy: finds 'frontend'\n" + - " sentry project list 'web-*' # a lone glob filters auto-detected orgs\n\n" + + "A glob after the slash (`/web-*`) lists projects whose slug or name matches. " + + "When `/` names no project, it matches project names instead: " + + "exact or partial matches first, then close (fuzzy) matches. " + + "Name filters can't be combined with --cursor.\n\n" + "Filtering and output:\n" + " sentry project list --platform javascript # filter by platform\n" + " sentry project list --limit 50 # show more results\n" + @@ -829,7 +875,12 @@ export const listCommand = buildListCommand("project", { }, { description: "Only projects whose slug or name matches a glob", - command: "sentry project list my-org/ 'web-*'", + command: "sentry project list 'my-org/web-*'", + }, + { + description: + "Projects named like 'frontend' (fuzzy when nothing matches exactly)", + command: "sentry project list my-org/frontnd", }, ], }, diff --git a/packages/cli/src/commands/repo/list.ts b/packages/cli/src/commands/repo/list.ts index 7b8d88a66..f8bd87052 100644 --- a/packages/cli/src/commands/repo/list.ts +++ b/packages/cli/src/commands/repo/list.ts @@ -50,6 +50,7 @@ const repoListConfig: OrgListConfig = { listPaginated: (org, opts) => listRepositoriesPaginated(org, opts), withOrg: (repo, orgSlug) => ({ ...repo, orgSlug }), namesOf: (repo) => [repo.name], + searchesNames: true, displayTable: (repos: RepositoryWithOrg[]) => formatTable(repos, REPO_COLUMNS), schema: SentryRepositorySchema, @@ -62,12 +63,9 @@ const docs: OrgListCommandDocs = { "Target specification:\n" + " sentry repo list # auto-detect from DSN or config\n" + " sentry repo list / # list all repos in org (paginated)\n" + - " sentry repo list / # list repos in org (project context)\n" + + " sentry repo list / # repos whose name matches \n" + " sentry repo list # list repos in org\n\n" + - "Name filter (searches every page, so it can't be paginated):\n" + - " sentry repo list / sentry # fuzzy match on the name\n" + - " sentry repo list / 'getsentry/*' # glob on the name\n" + - " sentry repo list '*-sdk' # a lone glob filters the auto-detected org(s)\n\n" + + "`` is a glob (`/getsentry/*`) or text: exact or partial matches first, then close (fuzzy) matches. Name filters can't be combined with --cursor.\n\n" + "Pagination:\n" + " sentry repo list / -c next # fetch next page\n" + " sentry repo list / -c prev # fetch previous page\n\n" + @@ -87,7 +85,12 @@ const docs: OrgListCommandDocs = { }, { description: "Only repositories whose name matches a glob", - command: "sentry repo list my-org/ 'getsentry/*'", + command: "sentry repo list 'my-org/getsentry/*'", + }, + { + description: + "Repositories named like 'sentry-python' (fuzzy when nothing matches exactly)", + command: "sentry repo list my-org/sentry-pyhton", }, { description: "Output as JSON", command: "sentry repo list --json" }, ], diff --git a/packages/cli/src/commands/team/list.ts b/packages/cli/src/commands/team/list.ts index 7f1ebd038..7af610b3d 100644 --- a/packages/cli/src/commands/team/list.ts +++ b/packages/cli/src/commands/team/list.ts @@ -51,6 +51,7 @@ const teamListConfig: OrgListConfig = { listPaginated: (org, opts) => listTeamsPaginated(org, opts), withOrg: (team, orgSlug) => ({ ...team, orgSlug }), namesOf: (team) => [team.slug, team.name], + searchesNames: true, displayTable: (teams: TeamWithOrg[]) => formatTable(teams, TEAM_COLUMNS), listForProject: (org, project) => listProjectTeams(org, project), schema: SentryTeamSchema, @@ -65,10 +66,7 @@ const docs: OrgListCommandDocs = { " sentry team list / # list all teams in org (paginated)\n" + " sentry team list / # list teams in org (project context)\n" + " sentry team list # list teams in org\n\n" + - "Name filter (searches every page, so it can't be paginated):\n" + - " sentry team list / backend # fuzzy match on the slug or name\n" + - " sentry team list / 'back*' # glob on the slug or name\n" + - " sentry team list 'back*' # a lone glob filters the auto-detected org(s)\n\n" + + "With a glob after the slash (`/back*`), only teams whose slug or name matches are listed. When `` is not a project, it matches team names instead: exact or partial matches first, then close (fuzzy) matches. Name filters can't be combined with --cursor.\n\n" + "Pagination:\n" + " sentry team list / -c next # fetch next page\n" + " sentry team list / -c prev # fetch previous page\n\n" + @@ -88,7 +86,12 @@ const docs: OrgListCommandDocs = { }, { description: "Only teams whose slug or name matches a glob", - command: "sentry team list my-org/ 'back*'", + command: "sentry team list 'my-org/back*'", + }, + { + description: + "Teams named like 'backend' when no such project exists (fuzzy)", + command: "sentry team list my-org/backnd", }, { description: "Output as JSON", command: "sentry team list --json" }, ], diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index 3a4efd603..5d28c0965 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -145,6 +145,7 @@ export { type ProjectSearchResult, type ProjectWithOrg, resolveOrgDisplayName, + searchProjects, tryGetPrimaryDsn, } from "./api/projects.js"; export { diff --git a/packages/cli/src/lib/api/monitors.ts b/packages/cli/src/lib/api/monitors.ts index 755a195bc..1539fc7a5 100644 --- a/packages/cli/src/lib/api/monitors.ts +++ b/packages/cli/src/lib/api/monitors.ts @@ -55,12 +55,13 @@ export async function listMonitors(orgSlug: string): Promise { * Returns a single page of results with cursor metadata. * * @param orgSlug - Organization slug - * @param options - Pagination options + * @param options - Pagination options; `query` keeps only monitors whose + * slug or name contains it (case-insensitive, matched by the server) * @returns Single page of monitors with cursor metadata */ export async function listMonitorsPaginated( orgSlug: string, - options: { cursor?: string; perPage?: number } = {}, + options: { cursor?: string; perPage?: number; query?: string } = {}, ): Promise> { const config = await getOrgSdkConfig(orgSlug); @@ -70,7 +71,8 @@ export async function listMonitorsPaginated( query: { cursor: options.cursor, per_page: options.perPage ?? API_MAX_PER_PAGE, - } as { cursor?: string; per_page?: number }, + query: options.query, + } as { cursor?: string; per_page?: number; query?: string }, }); return unwrapPaginatedResult( diff --git a/packages/cli/src/lib/api/projects.ts b/packages/cli/src/lib/api/projects.ts index 5b4178cb4..7a0727298 100644 --- a/packages/cli/src/lib/api/projects.ts +++ b/packages/cli/src/lib/api/projects.ts @@ -115,12 +115,13 @@ export async function listProjects(orgSlug: string): Promise { * returns 400 for larger values instead of silently truncating. * * @param orgSlug - Organization slug - * @param options - Pagination options + * @param options - Pagination options; `query` keeps only projects whose + * slug or name contains it (case-insensitive, matched by the server) * @returns Single page of projects with cursor metadata */ export async function listProjectsPaginated( orgSlug: string, - options: { cursor?: string; perPage?: number } = {}, + options: { cursor?: string; perPage?: number; query?: string } = {}, ): Promise> { const config = await getOrgSdkConfig(orgSlug); const perPage = Math.min( @@ -134,7 +135,8 @@ export async function listProjectsPaginated( query: { cursor: options.cursor, per_page: perPage, - } as { cursor?: string; per_page?: number }, + query: options.query, + } as { cursor?: string; per_page?: number; query?: string }, }); return unwrapPaginatedResult( @@ -143,6 +145,32 @@ export async function listProjectsPaginated( ); } +/** + * Every project in an organization whose slug or name contains `query` + * (case-insensitive, matched by the server's `?query=`), across all pages. + * Unlike {@link listProjects}, it leaves the completion cache alone, since + * the result is only part of the org. + * + * @param orgSlug - Organization slug + * @param query - Text the slug or name must contain + * @returns Matching projects + */ +export async function searchProjects( + orgSlug: string, + query: string, +): Promise { + const { data } = await autoPaginate( + (cursor) => + listProjectsPaginated(orgSlug, { + cursor, + perPage: API_MAX_PER_PAGE, + query, + }), + MAX_PAGINATION_PAGES * API_MAX_PER_PAGE, + ); + return data; +} + /** * List projects up to `limit`, auto-paginating when the limit exceeds * {@link API_MAX_PER_PAGE}. diff --git a/packages/cli/src/lib/api/teams.ts b/packages/cli/src/lib/api/teams.ts index 2df82d54f..0c761f7b8 100644 --- a/packages/cli/src/lib/api/teams.ts +++ b/packages/cli/src/lib/api/teams.ts @@ -44,12 +44,13 @@ export async function listTeams(orgSlug: string): Promise { * Returns a single page of results with cursor metadata. * * @param orgSlug - Organization slug - * @param options - Pagination options + * @param options - Pagination options; `query` keeps only teams whose slug + * or name contains it (case-insensitive, matched by the server) * @returns Single page of teams with cursor metadata */ export async function listTeamsPaginated( orgSlug: string, - options: { cursor?: string; perPage?: number } = {}, + options: { cursor?: string; perPage?: number; query?: string } = {}, ): Promise> { const config = await getOrgSdkConfig(orgSlug); @@ -59,7 +60,8 @@ export async function listTeamsPaginated( query: { cursor: options.cursor, per_page: options.perPage ?? 25, - } as { cursor?: string; per_page?: number }, + query: options.query, + } as { cursor?: string; per_page?: number; query?: string }, }); return unwrapPaginatedResult(result, "Failed to list teams"); diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 680f40120..aa0e4c668 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -105,7 +105,8 @@ export const LIST_ORG_TARGET_POSITIONAL = { /** * Optional name filter after the target (`sentry team list / back*`), - * matched with `filterByName` from `list-filter.ts`. + * matched with `filterByName` from `list-filter.ts`. A glob can also join + * the target (`/back*`); see `splitListTargetAndFilter`. */ export const LIST_NAME_FILTER_PARAMETER = { placeholder: "filter", diff --git a/packages/cli/src/lib/list-filter.ts b/packages/cli/src/lib/list-filter.ts index 44ffb29e2..31532374d 100644 --- a/packages/cli/src/lib/list-filter.ts +++ b/packages/cli/src/lib/list-filter.ts @@ -1,7 +1,7 @@ /** * Name filters for list commands (`sentry org list `, - * `sentry team list / `): a glob, or text matched with the - * shared fuzzy search. + * `sentry team list /back*`, `sentry repo list /sentry`): a glob, + * or text matched with the shared fuzzy search. */ import picomatch from "picomatch"; @@ -86,3 +86,83 @@ export function splitListTargetAndFilter( } return { target: target.slice(0, slash + 1), filter: rest }; } + +/** + * Characters that make a filter unsafe to send as Sentry's `?query=`: + * `tokenize_query` reads `:` and quotes as search syntax, and globs with + * negation, extglobs, or escapes have no required literal text. + */ +const UNSEARCHABLE_RE = /[:"'\\!()|+@]|\s{2}/; + +/** Glob parts that stand for something other than literal text. */ +const GLOB_WILDCARD_RE = /\[[^\]]*\]|\{[^}]*\}|[*?[\]{}]/; + +/** Shortest literal part of a glob worth sending to the server. */ +const MIN_GLOB_SEARCH_LENGTH = 2; + +/** + * Text for Sentry's list `?query=` (a case-insensitive substring match on + * names) that every name matching `filter` contains, or `undefined` when + * the server can't narrow it. Plain text is sent as is. A glob sends its + * longest literal part (`web-*` → `web-`), since each match contains it. + */ +export function serverSearchText(filter: string): string | undefined { + const needle = filter.trim(); + if (!needle || UNSEARCHABLE_RE.test(needle)) { + return; + } + if (!isGlobFilter(needle)) { + return needle; + } + const longest = needle + .split(GLOB_WILDCARD_RE) + .map((part) => part.trim()) + .reduce((best, part) => (part.length > best.length ? part : best), ""); + return longest.length >= MIN_GLOB_SEARCH_LENGTH ? longest : undefined; +} + +/** + * Items whose names match `filter` (see {@link filterByName}), fetching as + * little as possible. With `search` (a fetch narrowed by Sentry's + * `?query=`), the server narrows the set first. Plain text that no name + * contains then falls back to fuzzy matching over `fetchAll`, so typos still + * find their target. A glob never needs the fallback: every match contains + * the text it sent. + */ +export async function fetchByName(options: { + filter: string; + namesOf: (item: T) => readonly string[]; + fetchAll: () => Promise; + search?: (query: string) => Promise; +}): Promise { + const { filter, namesOf, fetchAll, search } = options; + const query = search ? serverSearchText(filter) : undefined; + if (search && query !== undefined) { + const narrowed = filterByName(await search(query), filter, namesOf); + if (narrowed.length > 0 || isGlobFilter(filter)) { + return narrowed; + } + } + return filterByName(await fetchAll(), filter, namesOf); +} + +/** + * `/` with a name filter, as list commands take it: a glob joins the + * target (`acme/web-*`), while text stays a separate argument + * (`acme/ frontend`), since `acme/frontend` names a project first. + * + * @param quote - Applied to each argument, e.g. `shellQuote` for a command + * meant to be copied into a shell + */ +export function orgFilterTarget( + org: string, + filter: string | undefined, + quote: (arg: string) => string = (arg) => arg, +): string { + if (!filter) { + return `${org}/`; + } + return isGlobFilter(filter) + ? quote(`${org}/${filter}`) + : `${org}/ ${quote(filter)}`; +} diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index eb75191f9..c429f1f68 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -36,7 +36,11 @@ import { MAX_PAGINATION_PAGES, paginate, } from "./api/infrastructure.js"; -import { listOrganizations, type PaginatedResponse } from "./api-client.js"; +import { + isNotFoundApiError, + listOrganizations, + type PaginatedResponse, +} from "./api-client.js"; import { explicitProjectSlugs, type ParsedOrgProject } from "./arg-parsing.js"; import { advancePaginationState, @@ -53,7 +57,7 @@ import { } from "./errors.js"; import { filterFields } from "./formatters/json.js"; import { paginationHint } from "./list-command.js"; -import { filterByName } from "./list-filter.js"; +import { fetchByName, filterByName, orgFilterTarget } from "./list-filter.js"; import { logger } from "./logger.js"; import { withProgress } from "./polling.js"; import { resolveEffectiveOrg } from "./region.js"; @@ -64,7 +68,6 @@ import { resolveOrgsForListing, } from "./resolve-target.js"; import { setOrgProjectContext } from "./telemetry.js"; -import { shellQuote } from "./utils.js"; const log = logger.withTag("org-list"); @@ -401,7 +404,7 @@ export type OrgListConfig = ListCommandMeta & { */ listPaginated: ( orgSlug: string, - opts: { cursor?: string; perPage: number }, + opts: { cursor?: string; perPage: number; query?: string }, ) => Promise>; /** @@ -446,6 +449,14 @@ export type OrgListConfig = ListCommandMeta & { * default handlers keep only matching entities (see {@link filterByName}). */ namesOf?: (entity: TEntity) => readonly string[]; + + /** + * Whether `listPaginated` honors `query` (Sentry's `?query=`) as a + * case-insensitive substring match on the names from {@link namesOf}. + * When true, a name filter lets the server narrow the list instead of + * reading every page (see {@link fetchByName}). + */ + searchesNames?: boolean; }; /** Extract a specific variant from the {@link ParsedOrgProject} union by its `type` discriminant. */ @@ -739,9 +750,8 @@ export async function handleAutoDetect( } if (limited.length > 0) { - const filterArg = filter === undefined ? "" : ` ${shellQuote(filter)}`; hintParts.push( - `Tip: Use '${config.commandPrefix} /${filterArg}' to filter by organization`, + `Tip: Use '${config.commandPrefix} ${orgFilterTarget("", filter)}' to filter by organization`, ); } @@ -1057,24 +1067,26 @@ export async function handleProjectSearch( } /** - * Every entity in `org`, walking all pages of `listPaginated`. Some - * `listForOrg` implementations return only the first page, which a name - * filter can't rely on. Errors propagate. + * Every entity in `org` (or every one matching the server-side `query`), + * walking all pages of `listPaginated`. Some `listForOrg` implementations + * return only the first page, which a name filter can't rely on. Errors + * propagate. */ async function listEveryPage( config: OrgListConfig, org: string, + query?: string, ): Promise { const { data } = await autoPaginate( (cursor) => - config.listPaginated(org, { cursor, perPage: API_MAX_PER_PAGE }), + config.listPaginated(org, { cursor, perPage: API_MAX_PER_PAGE, query }), MAX_PAGINATION_PAGES * API_MAX_PER_PAGE, ); return data; } /** - * Org-all mode with a name filter (`sentry team list / back*`): every + * Org-all mode with a name filter (`sentry team list /back*`): every * page instead of one cursor page, since matching and fuzzy ranking need the * whole set; `--limit` applies to the matches. Like unfiltered org-all, API * errors propagate rather than reading as "no matches". `config` comes from @@ -1097,6 +1109,53 @@ async function handleOrgAllFiltered( }); } +/** + * Explicit `/` for a config with {@link OrgListConfig.namesOf}. + * + * With `listForProject` (teams), `` is a project first, as before; when + * no such project exists, it falls back to matching entity names. Without it + * (repos, monitors, integrations), there is no project to scope to, so + * `` matches entity names first; when nothing matches, it is treated + * as an ignored project, as before, and the whole org is listed. Matching + * is direct first (the server's substring search where available), then + * fuzzy (see {@link fetchByName}). + */ +async function handleExplicitByName( + config: OrgListConfig, + parsed: Extract, + flags: BaseListFlags, +): Promise> { + const { org, project: name } = parsed; + if (!config.listForProject) { + const matches = await handleOrgAllFiltered( + withNameFilter(config, name), + org, + flags, + ); + // Nothing named like it: most likely an `/` target, which + // these org-scoped entities ignore, so list the whole org as before. + if (matches.items.length > 0) { + return matches; + } + return await handleExplicitOrg({ config, org, flags, noteOrgScoped: true }); + } + try { + return await handleExplicitProject({ config, org, project: name, flags }); + } catch (error) { + if (!isNotFoundApiError(error)) { + throw error; + } + } + const result = await handleOrgAllFiltered( + withNameFilter(config, name), + org, + flags, + ); + const note = `No project '${name}' in '${org}'. Matching ${config.entityPlural} by name instead.`; + result.header = result.header ? `${note}\n${result.header}` : note; + return result; +} + /** * `config` narrowed to entities whose names match `filter`. Its fetchers * read every page and filter (and rank) the set, and `entityPlural` names the @@ -1117,7 +1176,15 @@ function withNameFilter( return { ...config, entityPlural: `${config.entityPlural} matching '${filter}'`, - listForOrg: async (org) => keep(await listEveryPage(config, org)), + listForOrg: (org) => + fetchByName({ + filter, + namesOf, + fetchAll: () => listEveryPage(config, org), + search: config.searchesNames + ? (query) => listEveryPage(config, org, query) + : undefined, + }), ...(listForProject && { listForProject: async (org: string, project: string) => keep(await listForProject(org, project)), @@ -1170,6 +1237,9 @@ function buildDefaultHandlers( handleAutoDetect(config, ctx.cwd, ctx.flags, filter), explicit: (ctx) => { + if (!filtered && isOrgListConfig(fullConfig) && fullConfig.namesOf) { + return handleExplicitByName(fullConfig, ctx.parsed, ctx.flags); + } if (config.listForProject) { return handleExplicitProject({ config, diff --git a/packages/cli/test/commands/integration/list.test.ts b/packages/cli/test/commands/integration/list.test.ts index a337f3449..6903b9275 100644 --- a/packages/cli/test/commands/integration/list.test.ts +++ b/packages/cli/test/commands/integration/list.test.ts @@ -130,11 +130,32 @@ describe("integration list", () => { test("explicit org lists that org's integrations", async () => { listSpy.mockResolvedValue(sampleIntegrations); + listPaginatedSpy.mockResolvedValue({ + data: sampleIntegrations, + nextCursor: undefined, + }); const { context } = createMockContext(); const func = await listCommand.loader(); + // No integration is named like `my-project`, so it reads as a project. await func.call(context, { limit: 30, json: true }, "my-org/my-project"); expect(listSpy).toHaveBeenCalledWith("my-org"); }); + + test("/ keeps integrations named like it, fuzzily", async () => { + listPaginatedSpy.mockResolvedValue({ + data: sampleIntegrations, + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: true }, "my-org/sentri"); + + const parsed = JSON.parse(stdoutWrite.mock.calls.map((c) => c[0]).join("")); + const items = Array.isArray(parsed) ? parsed : parsed.data; + expect(items.map((i: { name: string }) => i.name)).toEqual(["Sentry"]); + expect(listSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/test/commands/project/list.test.ts b/packages/cli/test/commands/project/list.test.ts index 6139541fa..95533237e 100644 --- a/packages/cli/test/commands/project/list.test.ts +++ b/packages/cli/test/commands/project/list.test.ts @@ -346,7 +346,16 @@ function mockProjectFetch( } else { linkParts.push(`<${url}>; rel="next"; results="false"; cursor="0:0:0"`); } - return new Response(JSON.stringify(projects), { + // Like Sentry: `?query=` keeps slugs or names containing it. + const query = new URL(url).searchParams.get("query")?.toLowerCase(); + const page = query + ? projects.filter( + ({ slug, name }) => + slug.toLowerCase().includes(query) || + name.toLowerCase().includes(query), + ) + : projects; + return new Response(JSON.stringify(page), { status: 200, headers: { "Content-Type": "application/json", @@ -435,6 +444,39 @@ describe("handleExplicit", () => { expect(result.hint).toContain("Tip:"); }); + test("no direct match falls back to fuzzy name matching", async () => { + const requests: string[] = []; + const fetch = mockProjectFetch(sampleProjects); + globalThis.fetch = (input, init) => { + requests.push(new Request(input, init).url); + return fetch(input, init); + }; + + const result = (await handleExplicit("test-org", "frontnd", { + limit: 30, + json: false, + fresh: false, + })) as Awaited> & { title?: string }; + + expect(result.items.map((p) => p.slug)).toEqual(["frontend"]); + expect(result.title).toContain("No project 'frontnd'"); + // The server's substring search finds nothing, so every project is read. + expect(requests.some((url) => url.includes("query=frontnd"))).toBe(true); + }); + + test("no direct or fuzzy match keeps the not-found hint", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + + const result = await handleExplicit("test-org", "zzzzzz", { + limit: 30, + json: false, + fresh: false, + }); + + expect(result.items).toHaveLength(0); + expect(result.hint).toContain("No project 'zzzzzz' found"); + }); + test("platform filter with no match returns empty with hint", async () => { globalThis.fetch = mockProjectFetch(sampleProjects); @@ -1532,7 +1574,7 @@ describe("handleAutoDetect", () => { expect(result.hasMore).toBe(true); const jsonHint = (result.jsonExtra as Record)?.hint; - expect(jsonHint).toBe("sentry project list test-org/ 'web-*' --json"); + expect(jsonHint).toBe("sentry project list 'test-org/web-*' --json"); }); test("fast path: non-auth API errors return empty results instead of throwing", async () => { @@ -1676,6 +1718,24 @@ describe("project list: name filter", () => { expect(slugs(await run({}, "test-org/web-*"))).toEqual(["web-checkout"]); }); + test("the server narrows a glob by its literal text", async () => { + const urls: string[] = []; + const fetch = mockProjectFetch(projects); + globalThis.fetch = (input, init) => { + urls.push(new Request(input, init).url); + return fetch(input, init); + }; + expect(slugs(await run({}, "test-org/web-*"))).toEqual(["web-checkout"]); + const listed = urls.filter((url) => url.includes("/organizations/")); + expect(listed.length).toBeGreaterThan(0); + expect(listed.every((url) => url.includes("query=web-"))).toBe(true); + }); + + test("/ with no such project matches names fuzzily", async () => { + globalThis.fetch = mockProjectFetch(projects); + expect(slugs(await run({}, "test-org/frontnd"))).toEqual(["frontend"]); + }); + test("combines with --platform", async () => { globalThis.fetch = mockProjectFetch(projects); expect( diff --git a/packages/cli/test/commands/repo/list.test.ts b/packages/cli/test/commands/repo/list.test.ts index 6836d8715..49c8226e6 100644 --- a/packages/cli/test/commands/repo/list.test.ts +++ b/packages/cli/test/commands/repo/list.test.ts @@ -227,13 +227,45 @@ describe("listCommand.func — project-search (bare slug)", () => { describe("listCommand.func — explicit org/project (org-scoped with note)", () => { const listRepositoriesSpy = vi.mocked(apiClient.listRepositories); + const listRepositoriesPaginatedSpy = vi.mocked( + apiClient.listRepositoriesPaginated, + ); beforeEach(async () => { setOrgRegion("my-org", DEFAULT_SENTRY_URL); + // `/` tries repository names first; none is like `my-project`. + listRepositoriesPaginatedSpy.mockResolvedValue({ + data: sampleRepos, + nextCursor: undefined, + }); }); afterEach(() => { listRepositoriesSpy.mockReset(); + listRepositoriesPaginatedSpy.mockReset(); + }); + + test("/ keeps repositories named like it", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { limit: 30, json: true }, + "my-org/sentry-javascript", + ); + + const parsed = JSON.parse(stdoutWrite.mock.calls.map((c) => c[0]).join("")); + const items = Array.isArray(parsed) ? parsed : parsed.data; + expect(items.map((repo: { name: string }) => repo.name)).toEqual([ + "getsentry/sentry-javascript", + ]); + // The server narrows by name; the whole-org listing is never read. + expect(listRepositoriesPaginatedSpy).toHaveBeenCalledWith("my-org", { + cursor: undefined, + perPage: 100, + query: "sentry-javascript", + }); + expect(listRepositoriesSpy).not.toHaveBeenCalled(); }); test("explicit org/project uses org part (repos are org-scoped)", async () => { diff --git a/packages/cli/test/commands/team/list.test.ts b/packages/cli/test/commands/team/list.test.ts index 6596d5b07..ce9979513 100644 --- a/packages/cli/test/commands/team/list.test.ts +++ b/packages/cli/test/commands/team/list.test.ts @@ -38,7 +38,7 @@ vi.mock("../../../src/lib/db/pagination.js", async (importOriginal) => { // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for vi.mocked access import * as paginationDb from "../../../src/lib/db/pagination.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; -import { ValidationError } from "../../../src/lib/errors.js"; +import { ApiError, ValidationError } from "../../../src/lib/errors.js"; vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { const actual = @@ -223,6 +223,7 @@ describe("listCommand.func — project-search (bare slug)", () => { describe("listCommand.func — explicit org/project", () => { const listProjectTeamsSpy = vi.mocked(apiClient.listProjectTeams); + const listTeamsPaginatedSpy = vi.mocked(apiClient.listTeamsPaginated); beforeEach(async () => { setOrgRegion("my-org", DEFAULT_SENTRY_URL); @@ -230,6 +231,40 @@ describe("listCommand.func — explicit org/project", () => { afterEach(() => { listProjectTeamsSpy.mockReset(); + listTeamsPaginatedSpy.mockReset(); + }); + + test("a missing project falls back to matching team names", async () => { + listProjectTeamsSpy.mockRejectedValue(new ApiError("Not found", 404)); + listTeamsPaginatedSpy.mockImplementation((_org, opts) => + Promise.resolve({ + // Like Sentry: `?query=` keeps slugs or names containing it. + data: sampleTeams.filter( + (team) => !opts?.query || team.slug.includes(opts.query), + ), + nextCursor: undefined, + }), + ); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: false }, "my-org/backnd"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No project 'backnd' in 'my-org'"); + expect(output).toContain("backend"); + expect(output).not.toContain("frontend"); + }); + + test("errors other than a missing project propagate", async () => { + listProjectTeamsSpy.mockRejectedValue(new ApiError("Forbidden", 403)); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await expect( + func.call(context, { limit: 30, json: false }, "my-org/my-project"), + ).rejects.toThrow("Forbidden"); + expect(listTeamsPaginatedSpy).not.toHaveBeenCalled(); }); test("explicit org/project calls listProjectTeams for that project", async () => { @@ -583,10 +618,12 @@ describe("listCommand.func — name filter", () => { test("/ reads every page and keeps matching teams", async () => { expect(await runJson("test-org/", "front*")).toEqual(["frontend"]); expect(await runJson("test-org/", "backend team")).toEqual(["backend"]); - // listTeams returns only the first page, so a filter walks the pages. + // listTeams returns only the first page, so a filter walks the pages, + // letting the server narrow them to names containing the glob's text. expect(listTeamsPaginatedSpy).toHaveBeenCalledWith("test-org", { cursor: undefined, perPage: 100, + query: "front", }); expect(listTeamsSpy).not.toHaveBeenCalled(); }); diff --git a/packages/cli/test/lib/list-filter.test.ts b/packages/cli/test/lib/list-filter.test.ts index 1adc52ae1..0b0f1dcdf 100644 --- a/packages/cli/test/lib/list-filter.test.ts +++ b/packages/cli/test/lib/list-filter.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { + fetchByName, filterByName, isGlobFilter, + orgFilterTarget, + serverSearchText, splitListTargetAndFilter, } from "../../src/lib/list-filter.js"; @@ -81,3 +84,109 @@ describe("splitListTargetAndFilter", () => { }).toEqual(expected); }); }); + +describe("serverSearchText", () => { + test.each([ + ["frontend", "frontend"], + [" web ", "web"], + // A glob sends its longest literal part: every match contains it. + ["web-*", "web-"], + ["*-sdk", "-sdk"], + ["getsentry/*", "getsentry/"], + ["api-?-prod*", "-prod"], + ["{web,api}-front*", "-front"], + ["[ab]ackend", "ackend"], + // Too little literal text to narrow anything. + ["*", undefined], + ["a*", undefined], + // Search syntax, negation, extglobs, and escapes aren't sent. + ["team:backend", undefined], + ['"quoted"', undefined], + ["!web-*", undefined], + ["web-+(a|b)", undefined], + ["web\\*", undefined], + ] as const)("%j → %j", (filter, expected) => { + expect(serverSearchText(filter)).toBe(expected); + }); +}); + +describe("fetchByName", () => { + /** A server `?query=`: case-insensitive substring on slug or name. */ + const search = vi.fn((query: string) => + Promise.resolve( + ITEMS.filter((item) => + namesOf(item).some((name) => + name.toLowerCase().includes(query.toLowerCase()), + ), + ), + ), + ); + const fetchAll = vi.fn(() => Promise.resolve(ITEMS)); + + test("lets the server narrow a direct match", async () => { + search.mockClear(); + fetchAll.mockClear(); + const items = await fetchByName({ + filter: "acme", + namesOf, + fetchAll, + search, + }); + expect(slugs(items).sort()).toEqual(["acme-dev", "acme-prod"]); + expect(search).toHaveBeenCalledWith("acme"); + expect(fetchAll).not.toHaveBeenCalled(); + }); + + test("falls back to fuzzy matching when nothing matches directly", async () => { + search.mockClear(); + fetchAll.mockClear(); + const items = await fetchByName({ + filter: "widgtes", + namesOf, + fetchAll, + search, + }); + expect(slugs(items)).toEqual(["widgets"]); + expect(search).toHaveBeenCalledWith("widgtes"); + expect(fetchAll).toHaveBeenCalledOnce(); + }); + + test("a glob trusts the server's narrowing, even when empty", async () => { + search.mockClear(); + fetchAll.mockClear(); + const items = await fetchByName({ + filter: "nope-*", + namesOf, + fetchAll, + search, + }); + expect(items).toEqual([]); + expect(search).toHaveBeenCalledWith("nope-"); + expect(fetchAll).not.toHaveBeenCalled(); + }); + + test("without search, matches over every item", async () => { + fetchAll.mockClear(); + const items = await fetchByName({ filter: "acme-d*", namesOf, fetchAll }); + expect(slugs(items)).toEqual(["acme-dev"]); + expect(fetchAll).toHaveBeenCalledOnce(); + }); +}); + +describe("orgFilterTarget", () => { + test.each([ + [undefined, "acme/"], + ["web-*", "acme/web-*"], + ["frontend", "acme/ frontend"], + ] as const)("%j → %j", (filter, expected) => { + expect(orgFilterTarget("acme", filter)).toBe(expected); + }); + + test("quotes each argument", () => { + const quote = (arg: string) => `'${arg}'`; + expect(orgFilterTarget("acme", "web-*", quote)).toBe("'acme/web-*'"); + expect(orgFilterTarget("acme", "front end", quote)).toBe( + "acme/ 'front end'", + ); + }); +}); diff --git a/packages/cli/test/lib/org-list.test.ts b/packages/cli/test/lib/org-list.test.ts index c374397cc..f520daab5 100644 --- a/packages/cli/test/lib/org-list.test.ts +++ b/packages/cli/test/lib/org-list.test.ts @@ -1320,7 +1320,7 @@ describe("dispatchOrgScopedList name filter", () => { "web-frontend", "web-backend", ]); - expect(result.hint).toContain("sentry widget list / 'web-*'"); + expect(result.hint).toContain("sentry widget list /web-*'"); } finally { defaultOrgSpy.mockRestore(); } From f0cdd5b32e0743000c65dd8a1f2ada323b3873fb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:05:01 +0000 Subject: [PATCH 5/8] fix(cli): Tighten / fallbacks for missing projects - project list: only a 404 from the project lookup falls back to name matching; 403s, 5xx and network errors keep the plain not-found hint instead of listing other projects. - team list: when the project is missing and no team matches the name either, say both in the hint, since empty results don't render the header note. --- packages/cli/src/commands/project/list.ts | 6 +++-- packages/cli/src/lib/org-list.ts | 9 +++++++ .../cli/test/commands/project/list.test.ts | 27 +++++++++++++++++++ packages/cli/test/commands/team/list.test.ts | 17 ++++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 4ec622c22..e3052e0d1 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -13,6 +13,7 @@ import type { SentryContext } from "../../context.js"; import { getProject, + isNotFoundApiError, listOrganizations, listProjects, listProjectsAllPages, @@ -486,8 +487,9 @@ export async function handleExplicit( () => withAuthGuard(() => getProject(org, projectSlug)), ); if (!projectResult.ok) { - // `/frontnd`: no direct match, so match names (fuzzily) instead. - if (!flags.filter) { + // `/frontnd`: no such project, so match names (fuzzily) instead. + // Other failures (403, 5xx, network) keep the plain not-found hint. + if (!flags.filter && isNotFoundApiError(projectResult.error)) { const fallback = await handleOrgAllFiltered(org, { ...flags, filter: projectSlug, diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index c429f1f68..a31cdc8ec 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -1151,6 +1151,15 @@ async function handleExplicitByName( org, flags, ); + if (result.items.length === 0) { + // Empty results render only the hint, so it must say both lookups failed. + return { + items: [], + hint: + `No project '${name}' in '${org}', and no ${config.entityPlural} named like it.\n` + + `Tip: Use '${config.commandPrefix} ${org}/' to see all ${config.entityPlural}`, + }; + } const note = `No project '${name}' in '${org}'. Matching ${config.entityPlural} by name instead.`; result.header = result.header ? `${note}\n${result.header}` : note; return result; diff --git a/packages/cli/test/commands/project/list.test.ts b/packages/cli/test/commands/project/list.test.ts index 95533237e..c91e16511 100644 --- a/packages/cli/test/commands/project/list.test.ts +++ b/packages/cli/test/commands/project/list.test.ts @@ -464,6 +464,33 @@ describe("handleExplicit", () => { expect(requests.some((url) => url.includes("query=frontnd"))).toBe(true); }); + test("only a 404 falls back to name matching", async () => { + const urls: string[] = []; + const fetch = mockProjectFetch(sampleProjects); + globalThis.fetch = (input, init) => { + const url = new Request(input, init).url; + urls.push(url); + if (url.includes("/projects/test-org/frontnd/")) { + return Promise.resolve( + new Response(JSON.stringify({ detail: "Forbidden" }), { + status: 403, + }), + ); + } + return fetch(input, init); + }; + + const result = await handleExplicit("test-org", "frontnd", { + limit: 30, + json: false, + fresh: false, + }); + + expect(result.items).toHaveLength(0); + expect(result.hint).toContain("No project 'frontnd' found"); + expect(urls.some((url) => url.includes("/organizations/"))).toBe(false); + }); + test("no direct or fuzzy match keeps the not-found hint", async () => { globalThis.fetch = mockProjectFetch(sampleProjects); diff --git a/packages/cli/test/commands/team/list.test.ts b/packages/cli/test/commands/team/list.test.ts index ce9979513..3ceaa4b50 100644 --- a/packages/cli/test/commands/team/list.test.ts +++ b/packages/cli/test/commands/team/list.test.ts @@ -256,6 +256,23 @@ describe("listCommand.func — explicit org/project", () => { expect(output).not.toContain("frontend"); }); + test("says the project is missing when no team matches either", async () => { + listProjectTeamsSpy.mockRejectedValue(new ApiError("Not found", 404)); + listTeamsPaginatedSpy.mockResolvedValue({ + data: sampleTeams, + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: false }, "my-org/zzzzzz"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain( + "No project 'zzzzzz' in 'my-org', and no teams named like it.", + ); + }); + test("errors other than a missing project propagate", async () => { listProjectTeamsSpy.mockRejectedValue(new ApiError("Forbidden", 403)); From f640d0408f2f09ccfdc224d7f252469214f9b2f4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:51:08 +0000 Subject: [PATCH 6/8] fix(cli): Close list name-filter fallback gaps from review - An `/` with no matches stays empty instead of falling back to the whole org: a glob is never a project slug. - `project list /`: a failing fuzzy fallback (e.g. a bad org) keeps the not-found hint rather than throwing, and fuzzy matches removed by --platform say so in the hint. - Integration name filters also match the provider (`GitHub`), since `name` is the install label (e.g. a GitHub org). - Plural aliases (`sentry projects list my-org/`) shift positionals when dropping the subcommand token, so the target is not read as the new name filter. --- .../sentry-cli/references/integration.md | 2 +- packages/cli/src/commands/integration/list.ts | 8 +++-- packages/cli/src/commands/project/list.ts | 21 ++++++++---- packages/cli/src/lib/list-command.ts | 4 ++- packages/cli/src/lib/org-list.ts | 10 ++++-- .../test/commands/integration/list.test.ts | 22 +++++++++++++ .../cli/test/commands/project/list.test.ts | 32 +++++++++++++++++++ packages/cli/test/commands/repo/list.test.ts | 12 +++++++ packages/cli/test/e2e/project.test.ts | 20 ++++++++++++ 9 files changed, 118 insertions(+), 13 deletions(-) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/integration.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/integration.md index df6fc82ec..af366d8e4 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/integration.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/integration.md @@ -44,7 +44,7 @@ sentry integration list my-org/ # Fetch the next page sentry integration list my-org/ -c next -# Only integrations whose name matches a glob +# Only integrations whose name or provider matches a glob sentry integration list 'my-org/git*' # Integrations named like 'GitHub' (fuzzy when nothing matches exactly) diff --git a/packages/cli/src/commands/integration/list.ts b/packages/cli/src/commands/integration/list.ts index 88b7ef4ce..f7b8ccd4f 100644 --- a/packages/cli/src/commands/integration/list.ts +++ b/packages/cli/src/commands/integration/list.ts @@ -48,7 +48,9 @@ const integrationListConfig: OrgListConfig< listForOrg: (org) => listIntegrations(org), listPaginated: (org, opts) => listIntegrationsPaginated(org, opts), withOrg: (integration, orgSlug) => ({ ...integration, orgSlug }), - namesOf: (integration) => [integration.name], + // The install label (e.g. a GitHub org) and the product (`GitHub`), so + // `/git*` finds GitHub installs whatever their label. + namesOf: (integration) => [integration.name, integration.provider.name], displayTable: (integrations: IntegrationWithOrg[]) => formatTable(integrations, INTEGRATION_COLUMNS), schema: SentryIntegrationSchema, @@ -62,7 +64,7 @@ const docs: OrgListCommandDocs = { "Target specification:\n" + " sentry integration list # auto-detect from DSN or config\n" + " sentry integration list / # list all integrations in org (paginated)\n" + - " sentry integration list / # integrations whose name matches \n" + + " sentry integration list / # integrations whose name or provider matches \n" + " sentry integration list # list integrations in org\n\n" + "`` is a glob (`/git*`) or text: exact or partial matches first, then close (fuzzy) matches. Name filters can't be combined with --cursor.\n\n" + "Pagination:\n" + @@ -83,7 +85,7 @@ const docs: OrgListCommandDocs = { command: "sentry integration list my-org/ -c next", }, { - description: "Only integrations whose name matches a glob", + description: "Only integrations whose name or provider matches a glob", command: "sentry integration list 'my-org/git*'", }, { diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index e3052e0d1..d97f6fe74 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -490,13 +490,22 @@ export async function handleExplicit( // `/frontnd`: no such project, so match names (fuzzily) instead. // Other failures (403, 5xx, network) keep the plain not-found hint. if (!flags.filter && isNotFoundApiError(projectResult.error)) { - const fallback = await handleOrgAllFiltered(org, { - ...flags, - filter: projectSlug, - }); - if (fallback.items.length > 0) { + // A failing fallback (e.g. a bad org) still ends in the not-found hint. + const fallback = await withAuthGuard(() => + handleOrgAllFiltered(org, { ...flags, filter: projectSlug }), + ); + if (fallback.ok && fallback.value.items.length > 0) { const note = `No project '${projectSlug}' in '${org}'. Showing projects matching '${projectSlug}'.`; - return { ...fallback, title: note } as ProjectListResult; + return { ...fallback.value, title: note } as ProjectListResult; + } + if (fallback.ok && flags.platform) { + // Name matches may exist but not on this platform; say so. + return { + items: [], + hint: + `No project '${projectSlug}' in '${org}', and no projects matching '${projectSlug}' on platform '${flags.platform}'.\n` + + `Tip: Use 'sentry project list ${org}/' to see all projects`, + }; } } return { diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index aa0e4c668..2e271a318 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -663,7 +663,9 @@ export function buildListCommand< // All list commands use SentryContext which has stderr at top level const ctx = this as unknown as { stderr: { write(s: string): void } }; if (interceptSubcommand(rawTarget, ctx.stderr, routeName) === undefined) { - args[0] = undefined; + // Drop the subcommand token so later positionals move up a slot: + // `sentry projects list my-org/` targets `my-org/`, not a filter. + args.shift(); } } // A dropped lazy target becomes an omitted one (auto-detect) diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index a31cdc8ec..038f46c98 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -57,7 +57,12 @@ import { } from "./errors.js"; import { filterFields } from "./formatters/json.js"; import { paginationHint } from "./list-command.js"; -import { fetchByName, filterByName, orgFilterTarget } from "./list-filter.js"; +import { + fetchByName, + filterByName, + isGlobFilter, + orgFilterTarget, +} from "./list-filter.js"; import { logger } from "./logger.js"; import { withProgress } from "./polling.js"; import { resolveEffectiveOrg } from "./region.js"; @@ -1134,7 +1139,8 @@ async function handleExplicitByName( ); // Nothing named like it: most likely an `/` target, which // these org-scoped entities ignore, so list the whole org as before. - if (matches.items.length > 0) { + // A glob is never a slug, so an empty glob match stays empty. + if (matches.items.length > 0 || isGlobFilter(name)) { return matches; } return await handleExplicitOrg({ config, org, flags, noteOrgScoped: true }); diff --git a/packages/cli/test/commands/integration/list.test.ts b/packages/cli/test/commands/integration/list.test.ts index 6903b9275..4f0e6ed5c 100644 --- a/packages/cli/test/commands/integration/list.test.ts +++ b/packages/cli/test/commands/integration/list.test.ts @@ -158,4 +158,26 @@ describe("integration list", () => { expect(items.map((i: { name: string }) => i.name)).toEqual(["Sentry"]); expect(listSpy).not.toHaveBeenCalled(); }); + + test.each(["my-org/githb", "my-org/git*"])( + "%s matches the provider, not just the install label", + async (target) => { + listPaginatedSpy.mockResolvedValue({ + data: sampleIntegrations, + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: true }, target); + + const parsed = JSON.parse( + stdoutWrite.mock.calls.map((c) => c[0]).join(""), + ); + const items = Array.isArray(parsed) ? parsed : parsed.data; + // The GitHub install is labeled with its GitHub org, `getsentry`. + expect(items.map((i: { name: string }) => i.name)).toEqual(["getsentry"]); + expect(listSpy).not.toHaveBeenCalled(); + }, + ); }); diff --git a/packages/cli/test/commands/project/list.test.ts b/packages/cli/test/commands/project/list.test.ts index c91e16511..1b33de9fc 100644 --- a/packages/cli/test/commands/project/list.test.ts +++ b/packages/cli/test/commands/project/list.test.ts @@ -491,6 +491,38 @@ describe("handleExplicit", () => { expect(urls.some((url) => url.includes("/organizations/"))).toBe(false); }); + test("a failing name fallback keeps the not-found hint", async () => { + // A bad org: the project lookup and the fallback listing both 404. + globalThis.fetch = (() => + Promise.resolve( + new Response(JSON.stringify({ detail: "Not found" }), { status: 404 }), + )) as unknown as typeof globalThis.fetch; + + const result = await handleExplicit("test-org", "frontnd", { + limit: 30, + json: false, + fresh: false, + }); + + expect(result.items).toHaveLength(0); + expect(result.hint).toContain("No project 'frontnd' found"); + }); + + test("fuzzy matches dropped by --platform say so", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + + const result = await handleExplicit("test-org", "frontnd", { + limit: 30, + json: false, + platform: "rust", + fresh: false, + }); + + expect(result.items).toHaveLength(0); + expect(result.hint).toContain("No project 'frontnd' in 'test-org'"); + expect(result.hint).toContain("on platform 'rust'"); + }); + test("no direct or fuzzy match keeps the not-found hint", async () => { globalThis.fetch = mockProjectFetch(sampleProjects); diff --git a/packages/cli/test/commands/repo/list.test.ts b/packages/cli/test/commands/repo/list.test.ts index 49c8226e6..958818fac 100644 --- a/packages/cli/test/commands/repo/list.test.ts +++ b/packages/cli/test/commands/repo/list.test.ts @@ -268,6 +268,18 @@ describe("listCommand.func — explicit org/project (org-scoped with note)", () expect(listRepositoriesSpy).not.toHaveBeenCalled(); }); + test("/ with no matches stays empty", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: true }, "my-org/other/*"); + + const parsed = JSON.parse(stdoutWrite.mock.calls.map((c) => c[0]).join("")); + const items = Array.isArray(parsed) ? parsed : parsed.data; + expect(items).toEqual([]); + // A glob is never a project slug: no whole-org fallback. + expect(listRepositoriesSpy).not.toHaveBeenCalled(); + }); + test("explicit org/project uses org part (repos are org-scoped)", async () => { listRepositoriesSpy.mockResolvedValue(sampleRepos); diff --git a/packages/cli/test/e2e/project.test.ts b/packages/cli/test/e2e/project.test.ts index 910e975c4..5f2a86a49 100644 --- a/packages/cli/test/e2e/project.test.ts +++ b/packages/cli/test/e2e/project.test.ts @@ -123,6 +123,26 @@ describe("sentry project list", () => { const data = Array.isArray(parsed) ? parsed : parsed.data; expect(Array.isArray(data)).toBe(true); }); + + test( + "plural alias keeps the target after the 'list' token", + { timeout: 30_000 }, + async () => { + await ctx.setAuthToken(TEST_TOKEN); + + // `sentry projects list /` drops the "list" token; `/` must + // stay the target. Were it a name filter, auto-detect would list + // nothing and exit 0 instead of failing on the unknown org. + const args = ["other-org/", "--json"]; + const direct = await ctx.run(["project", "list", ...args]); + const alias = await ctx.run(["projects", "list", ...args]); + + expect(direct.exitCode).not.toBe(0); + expect(alias.exitCode).toBe(direct.exitCode); + expect(alias.stdout).toBe(direct.stdout); + expect(alias.stderr).toContain("is a subcommand"); + }, + ); }); describe("sentry org view", () => { From b6cedd8676526a7a1ba1bc171fc754f94d185983 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:25:09 +0000 Subject: [PATCH 7/8] fix(cli): Match repos by short name; treat / as a filter - Repository name filters also match the name without its owner (`sentry-python` for `getsentry/sentry-python`), so a typo in the short name fuzzy-matches instead of falling back to the whole org. - A lone `/web-*` is `web-*` (a filter on the detected org) rather than target `/`, which the target grammar rejects. --- packages/cli/src/commands/repo/list.ts | 7 ++++++- packages/cli/src/lib/list-filter.ts | 7 ++++++- packages/cli/test/commands/repo/list.test.ts | 18 ++++++++++++++++++ packages/cli/test/lib/list-filter.test.ts | 3 +++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/repo/list.ts b/packages/cli/src/commands/repo/list.ts index f8bd87052..69bdf5908 100644 --- a/packages/cli/src/commands/repo/list.ts +++ b/packages/cli/src/commands/repo/list.ts @@ -49,7 +49,12 @@ const repoListConfig: OrgListConfig = { listForOrg: (org) => listRepositories(org), listPaginated: (org, opts) => listRepositoriesPaginated(org, opts), withOrg: (repo, orgSlug) => ({ ...repo, orgSlug }), - namesOf: (repo) => [repo.name], + // `getsentry/sentry-python` also matches as `sentry-python`, so a typo + // in the short name is still close enough to match fuzzily. + namesOf: (repo) => { + const short = repo.name.slice(repo.name.lastIndexOf("/") + 1); + return short && short !== repo.name ? [repo.name, short] : [repo.name]; + }, searchesNames: true, displayTable: (repos: RepositoryWithOrg[]) => formatTable(repos, REPO_COLUMNS), diff --git a/packages/cli/src/lib/list-filter.ts b/packages/cli/src/lib/list-filter.ts index 31532374d..822de0bde 100644 --- a/packages/cli/src/lib/list-filter.ts +++ b/packages/cli/src/lib/list-filter.ts @@ -66,7 +66,8 @@ export function filterByName( * target and name filter. With no second positional, a glob in the lone * argument is a filter, since no slug contains glob characters: * `web-*` → filter `web-*` (auto-detected org), `acme/web-*` → target - * `acme/` and filter `web-*`. Any other lone argument stays the target. + * `acme/` and filter `web-*`, and `/web-*` is `web-*`. Any other lone + * argument stays the target. */ export function splitListTargetAndFilter( target: string | undefined, @@ -84,6 +85,10 @@ export function splitListTargetAndFilter( if (rest.includes("/") || !isGlobFilter(rest)) { return { target }; } + // `/web-*` has no org, so it is `web-*`: a filter on the detected org. + if (slash === 0) { + return { filter: rest.trim() }; + } return { target: target.slice(0, slash + 1), filter: rest }; } diff --git a/packages/cli/test/commands/repo/list.test.ts b/packages/cli/test/commands/repo/list.test.ts index 958818fac..82e3e629e 100644 --- a/packages/cli/test/commands/repo/list.test.ts +++ b/packages/cli/test/commands/repo/list.test.ts @@ -268,6 +268,24 @@ describe("listCommand.func — explicit org/project (org-scoped with note)", () expect(listRepositoriesSpy).not.toHaveBeenCalled(); }); + test("/ fuzzily matches the repo name without its owner", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { limit: 30, json: true }, + "my-org/sentry-javscript", + ); + + const parsed = JSON.parse(stdoutWrite.mock.calls.map((c) => c[0]).join("")); + const items = Array.isArray(parsed) ? parsed : parsed.data; + expect(items.map((repo: { name: string }) => repo.name)).toEqual([ + "getsentry/sentry-javascript", + ]); + // Matched by name, so no whole-org fallback. + expect(listRepositoriesSpy).not.toHaveBeenCalled(); + }); + test("/ with no matches stays empty", async () => { const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); diff --git a/packages/cli/test/lib/list-filter.test.ts b/packages/cli/test/lib/list-filter.test.ts index 0b0f1dcdf..e6de65a05 100644 --- a/packages/cli/test/lib/list-filter.test.ts +++ b/packages/cli/test/lib/list-filter.test.ts @@ -76,6 +76,9 @@ describe("splitListTargetAndFilter", () => { [["my-org/web-*", undefined], { target: "my-org/", filter: "web-*" }], [["my-org/web,api", undefined], { target: "my-org/web,api" }], [["my-org/proj/x*", undefined], { target: "my-org/proj/x*" }], + // `/` means ``, so `/web-*` is a filter, not target `/`. + [["/web-*", undefined], { filter: "web-*" }], + [["/web", undefined], { target: "/web" }], ] as const)("%j → %j", ([target, filter], expected) => { const result = splitListTargetAndFilter(target, filter); expect({ From ce69f1988314b18ec307c06aafa62314de702b35 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:59:51 +0000 Subject: [PATCH 8/8] fix(cli): Re-rank name-filtered matches merged across orgs The name filter ranks matches within each org or project fetch, but auto-detect and project-search concatenated those lists and applied --limit, so a weaker match from an earlier org could hide a better one from a later org. The filtered config now re-ranks merged items (by the names of the entity behind each) before the limit. --- packages/cli/src/lib/org-list.ts | 28 ++++++++++++++++++- packages/cli/test/lib/org-list.test.ts | 37 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index 038f46c98..6afbea99f 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -462,6 +462,14 @@ export type OrgListConfig = ListCommandMeta & { * reading every page (see {@link fetchByName}). */ searchesNames?: boolean; + + /** + * Re-rank items merged from several fetches (orgs or projects). Set by + * the name filter, which ranks matches within each fetch: merging must + * rank again before `--limit` so a weaker match from an earlier fetch + * can't hide a better one from a later fetch. + */ + rankMerged?: (items: TWithOrg[]) => TWithOrg[]; }; /** Extract a specific variant from the {@link ParsedOrgProject} union by its `type` discriminant. */ @@ -707,7 +715,7 @@ export async function handleAutoDetect( skippedSelfHosted, } = await resolveOrgsForListing(undefined, cwd); - const allItems = await withProgress( + const fetchedItems = await withProgress( { message: `Fetching ${config.entityPlural} (up to ${flags.limit})...`, json: flags.json, @@ -722,6 +730,9 @@ export async function handleAutoDetect( return await fetchAllOrgs(config); }, ); + const allItems = config.rankMerged + ? config.rankMerged(fetchedItems) + : fetchedItems; const limitCount = orgsToFetch.length > 1 ? flags.limit * orgsToFetch.length : flags.limit; @@ -1040,6 +1051,9 @@ export async function handleProjectSearch( ); allItems = results.flat(); } + if (config.rankMerged) { + allItems = config.rankMerged(allItems); + } const limited = allItems.slice(0, flags.limit); @@ -1188,9 +1202,21 @@ function withNameFilter( ); } const keep = (entities: TEntity[]) => filterByName(entities, filter, namesOf); + // The entity behind each item, so merged items can be ranked by name. + const entityOf = new Map(); return { ...config, entityPlural: `${config.entityPlural} matching '${filter}'`, + withOrg: (entity, org) => { + const item = config.withOrg(entity, org); + entityOf.set(item, entity); + return item; + }, + rankMerged: (items) => + filterByName(items, filter, (item) => { + const entity = entityOf.get(item); + return entity === undefined ? [] : namesOf(entity); + }), listForOrg: (org) => fetchByName({ filter, diff --git a/packages/cli/test/lib/org-list.test.ts b/packages/cli/test/lib/org-list.test.ts index f520daab5..99181b65f 100644 --- a/packages/cli/test/lib/org-list.test.ts +++ b/packages/cli/test/lib/org-list.test.ts @@ -1415,6 +1415,43 @@ describe("dispatchOrgScopedList name filter", () => { expect(listForProject).toHaveBeenCalledWith("my-org", "proj"); }); + test("ranks matches merged across projects before --limit", async () => { + // The same project slug in two orgs: org-a only has a contains match, + // org-b has the exact one, which must win the single slot. + const findSpy = vi + .spyOn(apiClient, "findProjectsBySlug") + .mockResolvedValue({ + projects: [ + { orgSlug: "org-a", slug: "my-proj", id: "1", name: "My Project" }, + { orgSlug: "org-b", slug: "my-proj", id: "2", name: "My Project" }, + ], + orgs: [], + }); + try { + const teams: Record = { + "org-a": [{ id: "1", name: "backend-old" }], + "org-b": [{ id: "2", name: "backend" }], + }; + const config = makeConfig({ + listForProject: (org: string) => Promise.resolve(teams[org] ?? []), + namesOf: (team) => [team.name], + }); + const result = await dispatchOrgScopedList({ + config, + cwd: "/tmp", + flags: { limit: 1, json: true }, + parsed: { type: "project-search", projectSlug: "my-proj" }, + filter: "backend", + }); + + expect(result.items.map((t: FakeWithOrg) => [t.orgSlug, t.name])).toEqual( + [["org-b", "backend"]], + ); + } finally { + findSpy.mockRestore(); + } + }); + test("rejects --cursor with a filter", async () => { await expect( dispatchOrgScopedList({