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..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 @@ -11,7 +11,7 @@ requires: Work with Sentry integrations -### `sentry integration list []` +### `sentry integration list [] []` List installed integrations @@ -44,6 +44,12 @@ sentry integration list my-org/ # Fetch the next page sentry integration list my-org/ -c next +# Only integrations whose name or provider matches a glob +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 bd56732ae..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 @@ -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,12 @@ 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-*' + +# 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 651a059b0..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 @@ -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,12 @@ 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-*' + +# 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 55a7e17ec..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 @@ -11,7 +11,7 @@ requires: Work with Sentry repositories -### `sentry repo list []` +### `sentry repo list [] []` List repositories @@ -46,6 +46,12 @@ 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/*' + +# 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 8de6462e6..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 @@ -11,7 +11,7 @@ requires: Work with Sentry teams -### `sentry team list []` +### `sentry team list [] []` List teams @@ -44,6 +44,12 @@ 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*' + +# 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 6fb77833f..f7b8ccd4f 100644 --- a/packages/cli/src/commands/integration/list.ts +++ b/packages/cli/src/commands/integration/list.ts @@ -48,6 +48,9 @@ const integrationListConfig: OrgListConfig< listForOrg: (org) => listIntegrations(org), listPaginated: (org, opts) => listIntegrationsPaginated(org, opts), withOrg: (integration, orgSlug) => ({ ...integration, orgSlug }), + // 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, @@ -61,8 +64,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 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" + " sentry integration list / -c next # fetch next page\n" + " sentry integration list / -c prev # fetch previous page\n\n" + @@ -80,6 +84,15 @@ const docs: OrgListCommandDocs = { description: "Fetch the next page", command: "sentry integration list my-org/ -c next", }, + { + description: "Only integrations whose name or provider matches a glob", + 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", 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..5dc504832 100644 --- a/packages/cli/src/commands/monitor/list.ts +++ b/packages/cli/src/commands/monitor/list.ts @@ -66,6 +66,8 @@ const monitorListConfig: OrgListConfig = { listForOrg: (org) => listMonitors(org), 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, @@ -78,8 +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" + + "`` 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" + @@ -97,6 +100,15 @@ 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: + "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 9a4734f48..d97f6fe74 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -13,10 +13,12 @@ import type { SentryContext } from "../../context.js"; import { getProject, + isNotFoundApiError, listOrganizations, listProjects, listProjectsAllPages, type PaginatedResponse, + searchProjects, } from "../../lib/api-client.js"; import { explicitProjectSlugs, @@ -43,10 +45,16 @@ import { buildListCommand, buildListLimitFlag, LIST_BASE_ALIASES, - LIST_TARGET_POSITIONAL, + LIST_TARGET_FILTER_POSITIONAL, paginationHint, targetPatternExplanation, } from "../../lib/list-command.js"; +import { + fetchByName, + filterByName, + orgFilterTarget, + splitListTargetAndFilter, +} from "../../lib/list-filter.js"; import { dispatchOrgScopedList, @@ -64,6 +72,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. */ @@ -81,6 +90,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) @@ -113,6 +128,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. @@ -155,6 +189,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,23 +346,33 @@ 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) { - 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() }; } /** 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 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. */ @@ -336,7 +414,7 @@ function selfHostedWarning( */ export async function handleAutoDetect( cwd: string, - flags: ListFlags, + flags: ProjectListFlags, ): Promise> { const { orgs: orgsToFetch, @@ -352,7 +430,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 +446,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) { @@ -390,7 +468,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, }; } @@ -402,13 +480,34 @@ 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 }, () => withAuthGuard(() => getProject(org, projectSlug)), ); if (!projectResult.ok) { + // `/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)) { + // 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.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 { items: [], hint: @@ -418,12 +517,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 +544,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 +578,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 +607,7 @@ async function handleExplicitProjects( export type OrgAllOptions = { org: string; - flags: ListFlags; + flags: ProjectListFlags; contextKey: string; cursor: string | undefined; direction: CursorDirection; @@ -513,6 +623,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 +697,53 @@ 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, + }, + () => + flags.filter + ? fetchOrgProjectsByName(org, flags.filter) + : 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 +798,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 +862,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" + + "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" + @@ -727,6 +884,15 @@ 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-*'", + }, + { + description: + "Projects named like 'frontend' (fuzzy when nothing matches exactly)", + command: "sentry project list my-org/frontnd", + }, ], }, output: { @@ -748,7 +914,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 +926,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..69bdf5908 100644 --- a/packages/cli/src/commands/repo/list.ts +++ b/packages/cli/src/commands/repo/list.ts @@ -49,6 +49,13 @@ const repoListConfig: OrgListConfig = { listForOrg: (org) => listRepositories(org), listPaginated: (org, opts) => listRepositoriesPaginated(org, opts), withOrg: (repo, orgSlug) => ({ ...repo, orgSlug }), + // `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), schema: SentryRepositorySchema, @@ -61,8 +68,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" + + "`` 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" + @@ -80,6 +88,15 @@ 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: + "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 9dfd9b194..7af610b3d 100644 --- a/packages/cli/src/commands/team/list.ts +++ b/packages/cli/src/commands/team/list.ts @@ -50,6 +50,8 @@ const teamListConfig: OrgListConfig = { listForOrg: (org) => listTeams(org), 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, @@ -64,6 +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" + + "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" + @@ -81,6 +84,15 @@ 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: + "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 71a264461..2e271a318 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,42 @@ export const LIST_ORG_TARGET_POSITIONAL = { ], }; +/** + * Optional name filter after the target (`sentry team list / back*`), + * 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", + 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). @@ -626,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) @@ -716,7 +755,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 +772,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..822de0bde 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 /back*`, `sentry repo list /sentry`): 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,114 @@ 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-*`, and `/web-*` is `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 }; + } + // `/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 }; +} + +/** + * 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 405bb5189..6afbea99f 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -30,8 +30,17 @@ * how to render the result — JSON envelope, human table, or custom formatting. */ -import { paginate } from "./api/infrastructure.js"; -import { listOrganizations, type PaginatedResponse } from "./api-client.js"; +import { + API_MAX_PER_PAGE, + autoPaginate, + MAX_PAGINATION_PAGES, + paginate, +} from "./api/infrastructure.js"; +import { + isNotFoundApiError, + listOrganizations, + type PaginatedResponse, +} from "./api-client.js"; import { explicitProjectSlugs, type ParsedOrgProject } from "./arg-parsing.js"; import { advancePaginationState, @@ -48,6 +57,12 @@ import { } from "./errors.js"; import { filterFields } from "./formatters/json.js"; import { paginationHint } from "./list-command.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"; @@ -394,7 +409,7 @@ export type OrgListConfig = ListCommandMeta & { */ listPaginated: ( orgSlug: string, - opts: { cursor?: string; perPage: number }, + opts: { cursor?: string; perPage: number; query?: string }, ) => Promise>; /** @@ -432,6 +447,29 @@ 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[]; + + /** + * 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; + + /** + * 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. */ @@ -463,6 +501,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; }; /** @@ -662,6 +706,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, @@ -669,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, @@ -684,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; @@ -718,7 +767,7 @@ export async function handleAutoDetect( if (limited.length > 0) { hintParts.push( - `Tip: Use '${config.commandPrefix} /' to filter by organization`, + `Tip: Use '${config.commandPrefix} ${orgFilterTarget("", filter)}' to filter by organization`, ); } @@ -785,6 +834,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 +853,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 +875,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 +889,8 @@ type ExplicitProjectOptions = { org: string; project: string; flags: BaseListFlags; + /** When true, omit the tip pointing at the unfiltered `/` listing. */ + filtered?: boolean; }; /** @@ -848,7 +904,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 +927,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; } @@ -995,6 +1051,9 @@ export async function handleProjectSearch( ); allItems = results.flat(); } + if (config.rankMerged) { + allItems = config.rankMerged(allItems); + } const limited = allItems.slice(0, flags.limit); @@ -1026,6 +1085,154 @@ export async function handleProjectSearch( }; } +/** + * 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, query }), + 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}'`, + }); +} + +/** + * 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. + // 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 }); + } + 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, + ); + 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; +} + +/** + * `config` narrowed to entities whose names match `filter`. Its fetchers + * 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, 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); + // 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, + 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)), + }), + }; +} + /** * Build the default `ModeHandlerMap` for the given config. * @@ -1037,8 +1244,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 { @@ -1061,15 +1274,20 @@ 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 (!filtered && isOrgListConfig(fullConfig) && fullConfig.namesOf) { + return handleExplicitByName(fullConfig, ctx.parsed, ctx.flags); + } if (config.listForProject) { return handleExplicitProject({ config, org: ctx.parsed.org, project: ctx.parsed.project, flags: ctx.flags, + filtered, }); } return handleExplicitOrg({ @@ -1077,19 +1295,26 @@ 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 + ? handleOrgAllFiltered(config, orgSlug, ctx.flags) + : runOrgAll(config, orgSlug, ctx.flags), originalSlug: ctx.parsed.originalSlug, org: ctx.parsed.org, projectSearchResolution: ctx.projectSearchResolution, }), "org-all": (ctx) => { + if (filtered) { + return handleOrgAllFiltered(config, ctx.parsed.org, ctx.flags); + } const contextKey = buildOrgContextKey(ctx.parsed.org); const { cursor, direction } = resolveCursor( ctx.flags.cursor, @@ -1129,6 +1354,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 +1449,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 +1486,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 +1495,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/integration/list.test.ts b/packages/cli/test/commands/integration/list.test.ts index a337f3449..4f0e6ed5c 100644 --- a/packages/cli/test/commands/integration/list.test.ts +++ b/packages/cli/test/commands/integration/list.test.ts @@ -130,11 +130,54 @@ 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(); + }); + + 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 5eaf6f2ec..1b33de9fc 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,98 @@ 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("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("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); + + 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); @@ -1516,6 +1617,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) @@ -1580,3 +1700,127 @@ 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("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( + 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/repo/list.test.ts b/packages/cli/test/commands/repo/list.test.ts index 6836d8715..82e3e629e 100644 --- a/packages/cli/test/commands/repo/list.test.ts +++ b/packages/cli/test/commands/repo/list.test.ts @@ -227,13 +227,75 @@ 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("/ 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(); + 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 () => { diff --git a/packages/cli/test/commands/team/list.test.ts b/packages/cli/test/commands/team/list.test.ts index ebe02e60c..3ceaa4b50 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,57 @@ 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("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)); + + 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 () => { @@ -548,3 +600,54 @@ 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); + listTeamsPaginatedSpy.mockResolvedValue({ + data: sampleTeams, + nextCursor: undefined, + }); + }); + + 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("/ 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, + // 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(); + }); + + 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/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", () => { diff --git a/packages/cli/test/lib/list-filter.test.ts b/packages/cli/test/lib/list-filter.test.ts index ea40c7f70..e6de65a05 100644 --- a/packages/cli/test/lib/list-filter.test.ts +++ b/packages/cli/test/lib/list-filter.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, test } from "vitest"; -import { filterByName, isGlobFilter } from "../../src/lib/list-filter.js"; +import { describe, expect, test, vi } from "vitest"; +import { + fetchByName, + filterByName, + isGlobFilter, + orgFilterTarget, + serverSearchText, + splitListTargetAndFilter, +} from "../../src/lib/list-filter.js"; type Item = { slug: string; name: string }; @@ -29,6 +36,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 +62,134 @@ 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*" }], + // `/` 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({ + ...(result.target !== undefined && { target: result.target }), + ...(result.filter !== undefined && { filter: result.filter }), + }).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 163b430fa..99181b65f 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, @@ -1246,3 +1247,236 @@ 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({ + listPaginated: vi.fn(() => + Promise.resolve({ data: WIDGETS, nextCursor: undefined }), + ), + namesOf: (widget) => [widget.name], + }); + } + + test("org-all fetches every widget, 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.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("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( + 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(), + 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("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({ + 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-*" }), + ); + }); +});