From a894b3f6ed092810628f030dd8414d619ef24670 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:16:04 +0000 Subject: [PATCH 1/3] feat(cli): Complete issue short IDs and dashboards in shell completion Shell completion now suggests issues and dashboards the user has already seen, not only org and project slugs. Issue and dashboard API calls (list, get, short-ID lookup, dashboard create) record short IDs, dashboard IDs, and titles in a new `completion_cache` SQLite table (schema v20). `sentry __complete` reads it with no API calls. Positionals opt in with two new parsers in completion-target.ts: `parseIssueTarget` (issueIdPositional, issue merge) and `parseDashboardTarget` (dashboard view/restore/revisions and widget edit/delete). Issue completion keeps the org/project suggestions and adds short IDs, plus `org/SHORT-ID` after an org prefix. Dashboard completion suggests `org/` for the first positional and bare IDs after an org. Titles show as descriptions and are matched too. The cache keeps the 500 newest entries per kind and org, expires with the other caches, only suggests orgs of the active credential, and is cleared on logout. Refs #1563 Co-Authored-By: byk --- packages/cli/AGENTS.md | 17 +- .../cli/src/commands/dashboard/restore.ts | 4 +- .../cli/src/commands/dashboard/revisions.ts | 4 +- packages/cli/src/commands/dashboard/view.ts | 4 +- .../src/commands/dashboard/widget/delete.ts | 3 +- .../cli/src/commands/dashboard/widget/edit.ts | 3 +- packages/cli/src/commands/issue/merge.ts | 4 +- packages/cli/src/commands/issue/utils.ts | 6 +- packages/cli/src/lib/api/completion-seed.ts | 79 +++++++ packages/cli/src/lib/api/dashboards.ts | 16 +- packages/cli/src/lib/api/issues.ts | 10 +- packages/cli/src/lib/complete.ts | 195 +++++++++++++++++- packages/cli/src/lib/completion-target.ts | 57 +++-- packages/cli/src/lib/db/auth.ts | 6 +- packages/cli/src/lib/db/completion-cache.ts | 138 +++++++++++++ packages/cli/src/lib/db/index.ts | 3 + packages/cli/src/lib/db/schema.ts | 32 ++- packages/cli/src/lib/introspect.ts | 19 +- .../cli/test/lib/api-client.coverage.test.ts | 21 ++ packages/cli/test/lib/complete.test.ts | 181 ++++++++++++++++ packages/cli/test/lib/db/auth.test.ts | 19 ++ .../cli/test/lib/db/completion-cache.test.ts | 94 +++++++++ packages/cli/test/lib/db/schema.test.ts | 13 ++ packages/cli/test/lib/introspect.test.ts | 22 ++ 24 files changed, 894 insertions(+), 56 deletions(-) create mode 100644 packages/cli/src/lib/api/completion-seed.ts create mode 100644 packages/cli/src/lib/db/completion-cache.ts create mode 100644 packages/cli/test/lib/db/completion-cache.test.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 2e8d0c163..013ecc96e 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -239,11 +239,18 @@ const parsed = parseOrgProjectArg(targetArg); Reference: `span/list.ts`, `trace/view.ts`, `event/view.ts` -**Shell completion:** Target positionals that take an org or project use a -completion-aware parser from `src/lib/completion-target.ts` instead of -`String`: `parseOrgProjectTarget` completes `org/`, `org/project`, and project -aliases; `parseOrgTarget` completes org slugs only. `LIST_TARGET_POSITIONAL` -and `LIST_ORG_TARGET_POSITIONAL` already use them. +**Shell completion:** Target positionals that take an org, project, issue, or +dashboard use a completion-aware parser from `src/lib/completion-target.ts` +instead of `String`: `parseOrgProjectTarget` completes `org/`, `org/project`, +and project aliases; `parseOrgTarget` completes org slugs only; +`parseIssueTarget` adds cached issue short IDs; `parseDashboardTarget` +completes cached dashboards. `LIST_TARGET_POSITIONAL`, +`LIST_ORG_TARGET_POSITIONAL`, and `issueIdPositional` already use them. +Issue and dashboard suggestions come from the `completion_cache` table, which +the API layer seeds through `src/lib/api/completion-seed.ts` whenever issues or +dashboards are listed or fetched. To complete a new entity type, add a kind in +`src/lib/db/completion-cache.ts`, seed it from its API functions, and add a +parser and completer. `pnpm run generate:completions` collects these into the gitignored `src/generated/completion-targets.json` that the `__complete` fast path reads. `project create` keeps its dedicated `name:platform` completer in diff --git a/packages/cli/src/commands/dashboard/restore.ts b/packages/cli/src/commands/dashboard/restore.ts index 28587f711..9e78a021e 100644 --- a/packages/cli/src/commands/dashboard/restore.ts +++ b/packages/cli/src/commands/dashboard/restore.ts @@ -8,7 +8,7 @@ import type { SentryContext } from "../../context.js"; import { restoreDashboardRevision } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; +import { parseDashboardTarget } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -74,7 +74,7 @@ export const restoreCommand = buildCommand({ parameter: { placeholder: "org/dashboard", brief: "[] ", - parse: parseOrgProjectTarget, + parse: parseDashboardTarget, }, }, flags: { diff --git a/packages/cli/src/commands/dashboard/revisions.ts b/packages/cli/src/commands/dashboard/revisions.ts index 492858038..2412d257f 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -12,7 +12,7 @@ import { } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; +import { parseDashboardTarget } from "../../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -137,7 +137,7 @@ export const revisionsCommand = buildCommand({ parameter: { placeholder: "org/dashboard", brief: "[] ", - parse: parseOrgProjectTarget, + parse: parseDashboardTarget, }, }, flags: { diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index c6305adc2..ccd3de5d3 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -10,7 +10,7 @@ import { getDashboard, queryAllWidgets } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; +import { parseDashboardTarget } from "../../lib/completion-target.js"; import type { DashboardViewData } from "../../lib/formatters/dashboard.js"; import { createDashboardViewRenderer } from "../../lib/formatters/dashboard.js"; import { ClearScreen, CommandOutput } from "../../lib/formatters/output.js"; @@ -192,7 +192,7 @@ export const viewCommand = buildCommand({ parameter: { placeholder: "org/project/dashboard", brief: "[] ", - parse: parseOrgProjectTarget, + parse: parseDashboardTarget, }, }, flags: { diff --git a/packages/cli/src/commands/dashboard/widget/delete.ts b/packages/cli/src/commands/dashboard/widget/delete.ts index 6d337d15c..5abacb9a3 100644 --- a/packages/cli/src/commands/dashboard/widget/delete.ts +++ b/packages/cli/src/commands/dashboard/widget/delete.ts @@ -14,6 +14,7 @@ import type { SentryContext } from "../../../context.js"; import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { numberParser } from "../../../lib/command.js"; +import { parseDashboardTarget } from "../../../lib/completion-target.js"; import { ValidationError } from "../../../lib/errors.js"; import { formatWidgetDeleted } from "../../../lib/formatters/human.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -86,7 +87,7 @@ export const deleteCommand = buildDeleteCommand( parameter: { placeholder: "org/project/dashboard", brief: "[] ", - parse: String, + parse: parseDashboardTarget, }, }, flags: { diff --git a/packages/cli/src/commands/dashboard/widget/edit.ts b/packages/cli/src/commands/dashboard/widget/edit.ts index f9a5d5750..8be7ee2de 100644 --- a/packages/cli/src/commands/dashboard/widget/edit.ts +++ b/packages/cli/src/commands/dashboard/widget/edit.ts @@ -8,6 +8,7 @@ import type { SentryContext } from "../../../context.js"; import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; +import { parseDashboardTarget } from "../../../lib/completion-target.js"; import { ValidationError } from "../../../lib/errors.js"; import { formatWidgetEdited } from "../../../lib/formatters/human.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -213,7 +214,7 @@ export const editCommand = buildCommand({ parameter: { placeholder: "org/project/dashboard", brief: "[] ", - parse: String, + parse: parseDashboardTarget, }, }, flags: { diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index d8ced7e9d..3e02933c1 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -23,7 +23,7 @@ import type { SentryContext } from "../../context.js"; import { type MergeIssuesResult, mergeIssues } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; +import { parseIssueTarget } from "../../lib/completion-target.js"; import { ApiError, CliError, @@ -278,7 +278,7 @@ export const mergeCommand = buildCommand({ parameter: { placeholder: "issue", brief: "Issue IDs to merge (2 or more required)", - parse: parseOrgProjectTarget, + parse: parseIssueTarget, }, }, flags: { diff --git a/packages/cli/src/commands/issue/utils.ts b/packages/cli/src/commands/issue/utils.ts index 818ba0db2..ae49fbaac 100644 --- a/packages/cli/src/commands/issue/utils.ts +++ b/packages/cli/src/commands/issue/utils.ts @@ -27,7 +27,7 @@ import { parseIssueArg, splitNewlineArg, } from "../../lib/arg-parsing.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; +import { parseIssueTarget } from "../../lib/completion-target.js"; import { clearCachedIssueOrg, getCachedIssueOrg, @@ -71,7 +71,7 @@ export const issueIdPositional = { placeholder: "issue", brief: "Issue: @latest, @most_frequent, /ID, /#ID, -suffix, ID, or suffix", - parse: parseOrgProjectTarget, + parse: parseIssueTarget, }, ], } as const; @@ -82,7 +82,7 @@ export const issueIdsPositional = { parameter: { placeholder: "issue", brief: "One or more issue IDs", - parse: parseOrgProjectTarget, + parse: parseIssueTarget, }, } as const; diff --git a/packages/cli/src/lib/api/completion-seed.ts b/packages/cli/src/lib/api/completion-seed.ts new file mode 100644 index 000000000..b4a7788b6 --- /dev/null +++ b/packages/cli/src/lib/api/completion-seed.ts @@ -0,0 +1,79 @@ +/** + * Seed the shell-completion cache from API responses. + * + * Issue and dashboard API functions call these after a successful fetch so + * `sentry __complete` can suggest what the user has already seen without an + * API call (see `src/lib/db/completion-cache.ts`). Failures are reported and + * swallowed: a cache problem must never fail the command that fetched the + * data. + */ + +import type { SentryIssue } from "../../types/index.js"; +import { + type CompletionEntity, + type CompletionEntityKind, + cacheCompletionEntities, + DASHBOARD_ENTITY, + ISSUE_ENTITY, +} from "../db/completion-cache.js"; +import { reportCliError } from "../error-reporting.js"; +import { logger } from "../logger.js"; +import { isAllDigits } from "../utils.js"; + +const log = logger.withTag("completion-seed"); + +function seed( + kind: CompletionEntityKind, + orgSlug: string, + entities: CompletionEntity[], +): void { + try { + cacheCompletionEntities(kind, orgSlug, entities); + } catch (error) { + log.debug(`Failed to cache ${kind} completions for '${orgSlug}'`, error); + reportCliError(error); + } +} + +/** + * Remember issues by short ID, with the title as description. + * + * @param orgSlug - Organization the issues belong to + * @param issues - Issues in display order + */ +export function seedIssueCompletions( + orgSlug: string, + issues: readonly Pick[], +): void { + seed( + ISSUE_ENTITY, + orgSlug, + issues.map((issue) => ({ + value: issue.shortId ?? "", + label: issue.title, + })), + ); +} + +/** + * Remember dashboards by ID, with the title as description. + * + * Only numeric IDs are kept: dashboard commands read `/` as an ID + * only when the ID is numeric, so other IDs could not be completed in that + * form. + * + * @param orgSlug - Organization the dashboards belong to + * @param dashboards - Dashboards in display order + */ +export function seedDashboardCompletions( + orgSlug: string, + dashboards: readonly { id: string; title: string }[], +): void { + seed( + DASHBOARD_ENTITY, + orgSlug, + dashboards + .filter((dashboard) => isAllDigits(dashboard.id)) + .map((dashboard) => ({ value: dashboard.id, label: dashboard.title })), + ); +} diff --git a/packages/cli/src/lib/api/dashboards.ts b/packages/cli/src/lib/api/dashboards.ts index 5e115434a..e9a52a277 100644 --- a/packages/cli/src/lib/api/dashboards.ts +++ b/packages/cli/src/lib/api/dashboards.ts @@ -38,6 +38,7 @@ import { type WidgetDataResult, } from "../../types/dashboard.js"; import { stringifyUnknown } from "../errors.js"; +import { seedDashboardCompletions } from "./completion-seed.js"; import { resolveOrgRegion } from "../region.js"; @@ -73,10 +74,12 @@ export async function listDashboardsPaginated( query: { per_page: options.perPage, cursor: options.cursor }, }); - return unwrapPaginatedResult( + const page = unwrapPaginatedResult( result, "Failed to list dashboards", ); + seedDashboardCompletions(orgSlug, page.data); + return page; } /** @@ -100,10 +103,12 @@ export async function getDashboard( }, }); - return unwrapResult( + const dashboard = unwrapResult( result, `Failed to get dashboard '${dashboardId}'`, ); + seedDashboardCompletions(orgSlug, [dashboard]); + return dashboard; } /** @@ -127,7 +132,12 @@ export async function createDashboard( >[0]["body"], }); - return unwrapResult(result, "Failed to create dashboard"); + const dashboard = unwrapResult( + result, + "Failed to create dashboard", + ); + seedDashboardCompletions(orgSlug, [dashboard]); + return dashboard; } /** diff --git a/packages/cli/src/lib/api/issues.ts b/packages/cli/src/lib/api/issues.ts index 3f7daa8e1..1d81272d2 100644 --- a/packages/cli/src/lib/api/issues.ts +++ b/packages/cli/src/lib/api/issues.ts @@ -20,6 +20,7 @@ import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { invalidateCachedResponsesMatching } from "../response-cache.js"; import { getApiBaseUrl } from "../sentry-client.js"; +import { seedIssueCompletions } from "./completion-seed.js"; import { API_MAX_PER_PAGE, @@ -186,7 +187,12 @@ export async function listIssuesPaginated( }, }); - return unwrapPaginatedResult(result, "Failed to list issues"); + const page = unwrapPaginatedResult( + result, + "Failed to list issues", + ); + seedIssueCompletions(orgSlug, page.data); + return page; } /** Result from {@link listIssuesAllPages}. */ @@ -331,6 +337,7 @@ export async function getIssueInOrg( params: options?.collapse ? { collapse: options.collapse } : undefined, }, ); + seedIssueCompletions(orgSlug, [data]); return data; } @@ -391,6 +398,7 @@ export async function getIssueByShortId( "Issue not found", ); } + seedIssueCompletions(orgSlug, [data.group]); return data.group; } diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 65b886ce9..31270214c 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -7,7 +7,7 @@ * Designed for minimal startup time — no Stricli boot, no telemetry, * no auth check, no API calls. Opens the SQLite cache directly for ~1ms * reads. All data comes from caches already populated by normal CLI - * usage (org_regions, project_cache, project_aliases). + * usage (org_regions, project_cache, project_aliases, completion_cache). * * Protocol: * Input: `sentry __complete ... ` @@ -19,11 +19,19 @@ import generatedTargets from "../generated/completion-targets.json"; import { COMPLETION_TARGETS, type CompletionTarget, + DASHBOARD_TARGET, + ISSUE_TARGET, ORG_PROJECT_TARGET, ORG_TARGET, } from "./completion-target.js"; import { getConfiguredSentryUrl } from "./constants.js"; import { getCredentialContext } from "./db/auth.js"; +import { + type CompletionEntityKind, + DASHBOARD_ENTITY, + getCachedCompletionEntities, + ISSUE_ENTITY, +} from "./db/completion-cache.js"; import { queueCompletionTelemetry } from "./db/completion-telemetry.js"; import { getProjectAliases } from "./db/project-aliases.js"; import { getCachedProjectsForOrg } from "./db/project-cache.js"; @@ -117,6 +125,13 @@ const COMMAND_TARGETS = new Map( ), ); +/** The command named by the preceding words and what follows it. */ +type CommandMatch = { + target: CompletionTarget; + /** Words typed after the command path (positionals and flags). */ + args: string[]; +}; + /** * Find the completion target declared by the command named in `words`. * @@ -124,11 +139,11 @@ const COMMAND_TARGETS = new Map( * preceding words can name a command; everything after it is positionals * or flags. */ -function findCompletionTarget(words: string[]): CompletionTarget | undefined { +function findCompletionTarget(words: string[]): CommandMatch | undefined { for (let length = 1; length <= words.length; length++) { const target = COMMAND_TARGETS.get(words.slice(0, length).join(" ")); if (target) { - return target; + return { target, args: words.slice(length) }; } } return; @@ -159,11 +174,16 @@ export function getCompletions( return completeProjectCreateSpec(partial); } - switch (findCompletionTarget(precedingWords)) { + const match = findCompletionTarget(precedingWords); + switch (match?.target) { case ORG_PROJECT_TARGET: return completeOrgSlashProject(partial); case ORG_TARGET: return completeOrgSlugs(partial); + case ISSUE_TARGET: + return completeIssues(partial); + case DASHBOARD_TARGET: + return completeDashboards(partial, match.args); default: // Not a known command path — no dynamic completions return []; @@ -371,3 +391,170 @@ export function completeAliases(partial: string): Completion[] { }; }); } + +/** Most cached-entity suggestions returned for one completion. */ +const MAX_ENTITY_COMPLETIONS = 50; + +/** Where cached entities are looked up and how their values are written. */ +type EntityScope = { + /** Only suggest entities from this org; all completion orgs otherwise. */ + org?: string; + /** Prepended to every suggested value (e.g. `my-org/`). */ + prefix?: string; + /** Always write `org/value`, not only when a value exists in several orgs. */ + qualify?: boolean; +}; + +/** + * Complete entities remembered in the `completion_cache` table. + * + * Matches the partial against both the value and its label (issue or + * dashboard title), so typing part of a title finds its ID. An empty partial + * returns the most recently seen entities first. + * + * Without a scope, values are bare unless the same value exists in more than + * one org, in which case they are written as `org/value` to stay unambiguous. + * + * @param kind - Entity type to complete + * @param partial - Partial value or title + * @param scope - Optional org restriction and value formatting + */ +export function completeCachedEntities( + kind: CompletionEntityKind, + partial: string, + scope: EntityScope = {}, +): Completion[] { + const orgs = scope.org + ? [scope.org] + : getCompletionOrganizations().map((org) => org.slug); + const entities = getCachedCompletionEntities(kind, orgs); + if (entities.length === 0) { + return []; + } + + let ranked = entities; + if (partial !== "") { + const candidates = new Set(); + for (const entity of entities) { + candidates.add(entity.value); + if (entity.label) { + candidates.add(entity.label); + } + } + const rank = new Map( + fuzzyMatch(partial, [...candidates], { mode: "search" }).map( + (candidate, index) => [candidate, index], + ), + ); + const score = (entity: (typeof entities)[number]) => + Math.min( + rank.get(entity.value) ?? Number.POSITIVE_INFINITY, + entity.label + ? (rank.get(entity.label) ?? Number.POSITIVE_INFINITY) + : Number.POSITIVE_INFINITY, + ); + ranked = entities + .map((entity) => ({ entity, score: score(entity) })) + .filter(({ score: s }) => Number.isFinite(s)) + .sort((a, b) => a.score - b.score) + .map(({ entity }) => entity); + } + + const orgsPerValue = new Map(); + for (const entity of entities) { + orgsPerValue.set(entity.value, (orgsPerValue.get(entity.value) ?? 0) + 1); + } + + const prefix = scope.prefix ?? ""; + return ranked.slice(0, MAX_ENTITY_COMPLETIONS).map((entity) => { + const qualified = + scope.qualify || (orgsPerValue.get(entity.value) ?? 0) > 1 + ? `${entity.orgSlug}/${entity.value}` + : entity.value; + return { value: `${prefix}${qualified}`, description: entity.label }; + }); +} + +/** + * Complete an issue positional. + * + * Offers everything {@link completeOrgSlashProject} does plus issue short IDs + * remembered from earlier `issue list` / `issue view` runs: + * - No slash: `org/` prefixes, aliases, and short IDs (e.g. `CLI-1A2`) + * - `org/…`: projects in the org and `org/SHORT-ID` for its issues + * + * @param partial - The partial issue argument + */ +export function completeIssues(partial: string): Completion[] { + const slashIdx = partial.indexOf("/"); + if (slashIdx === -1) { + return [ + ...completeOrgSlashProject(partial), + ...completeCachedEntities(ISSUE_ENTITY, partial), + ]; + } + + const orgPart = partial.slice(0, slashIdx); + const org = orgPart ? fuzzyResolveOrg(orgPart) : undefined; + if (!org) { + return []; + } + const rest = partial.slice(slashIdx + 1); + return [ + ...completeProjectSlugs(rest, org), + ...completeCachedEntities(ISSUE_ENTITY, rest, { + org, + prefix: `${org}/`, + }), + ]; +} + +/** + * Complete a `[] ` positional. + * + * - First positional, no slash: `org/` prefixes and `org/` for + * remembered dashboards (the title is shown as description and matched) + * - First positional, `org/…`: `org/` for that org's dashboards + * - Second positional: bare IDs for the org named by the first one + * + * Words starting with `-` are treated as flags. A flag value given as a + * separate word counts as a positional, which can only hide suggestions. + * + * @param partial - The partial dashboard argument + * @param args - Words typed after the command path + */ +export function completeDashboards( + partial: string, + args: readonly string[], +): Completion[] { + const positionals = args.filter((word) => !word.startsWith("-")); + + if (positionals.length === 1) { + const orgPart = positionals[0]?.split("/")[0]; + const org = orgPart ? fuzzyResolveOrg(orgPart) : undefined; + return org + ? completeCachedEntities(DASHBOARD_ENTITY, partial, { org }) + : []; + } + if (positionals.length > 1) { + return []; + } + + const slashIdx = partial.indexOf("/"); + if (slashIdx === -1) { + return [ + ...completeOrgSlugsWithSlash(partial), + ...completeCachedEntities(DASHBOARD_ENTITY, partial, { qualify: true }), + ]; + } + + const orgPart = partial.slice(0, slashIdx); + const org = orgPart ? fuzzyResolveOrg(orgPart) : undefined; + if (!org) { + return []; + } + return completeCachedEntities(DASHBOARD_ENTITY, partial.slice(slashIdx + 1), { + org, + prefix: `${org}/`, + }); +} diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index b29ed62cb..70fb75d36 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -2,11 +2,11 @@ * Completion-aware positional parsers. * * Target positionals use these instead of `String` so shell completion can - * read the target kind straight from the command's `parameters`. Both parsers - * return their input unchanged; the kind is identified by which function a - * positional uses. `script/generate-completion-targets.ts` collects them at - * build time because the `__complete` fast path cannot boot Stricli to walk - * the route tree. + * read the target kind straight from the command's `parameters`. Every + * parser returns its input unchanged; the kind is identified by which + * function a positional uses. `script/generate-completion-targets.ts` + * collects them at build time because the `__complete` fast path cannot boot + * Stricli to walk the route tree. */ /** Completes organization slugs only (e.g. `org`, `org/version`, `org/id`). */ @@ -15,10 +15,24 @@ export const ORG_TARGET = "org"; /** Completes `org/` prefixes, `org/project` pairs, and project aliases. */ export const ORG_PROJECT_TARGET = "org/project"; +/** Completes everything {@link ORG_PROJECT_TARGET} does plus cached issue short IDs. */ +export const ISSUE_TARGET = "issue"; + +/** + * Completes `org/` prefixes and cached dashboards: `org/` in the first + * positional, bare IDs after an org positional. + */ +export const DASHBOARD_TARGET = "dashboard"; + /** Every completion target kind, in a stable order. */ -export const COMPLETION_TARGETS = [ORG_TARGET, ORG_PROJECT_TARGET] as const; +export const COMPLETION_TARGETS = [ + ORG_TARGET, + ORG_PROJECT_TARGET, + ISSUE_TARGET, + DASHBOARD_TARGET, +] as const; -/** Which cached slugs shell completion suggests for a target positional. */ +/** Which cached values shell completion suggests for a target positional. */ export type CompletionTarget = (typeof COMPLETION_TARGETS)[number]; /** Parser for positionals whose leading segment is an organization slug. */ @@ -31,20 +45,31 @@ export function parseOrgProjectTarget(input: string): string { return input; } +/** Parser for positionals that name an issue (short ID, `org/ID`, ...). */ +export function parseIssueTarget(input: string): string { + return input; +} + +/** Parser for `[] ` positionals. */ +export function parseDashboardTarget(input: string): string { + return input; +} + +const PARSER_TARGETS = new Map([ + [parseOrgTarget, ORG_TARGET], + [parseOrgProjectTarget, ORG_PROJECT_TARGET], + [parseIssueTarget, ISSUE_TARGET], + [parseDashboardTarget, DASHBOARD_TARGET], +]); + /** * Read the completion kind from a positional's `parse` function. * - * @returns The kind for {@link parseOrgTarget} / {@link parseOrgProjectTarget}, - * or `undefined` for any other parser + * @returns The kind for one of the parsers above, or `undefined` for any + * other parser */ export function getCompletionTarget( parse: unknown, ): CompletionTarget | undefined { - if (parse === parseOrgTarget) { - return ORG_TARGET; - } - if (parse === parseOrgProjectTarget) { - return ORG_PROJECT_TARGET; - } - return; + return PARSER_TARGETS.get(parse); } diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 06e0682c4..ee0643f2c 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -54,6 +54,7 @@ import { import { getAccountScope, resetAccountScopes } from "./account-scope.js"; import { hashIdentity } from "./identity-hash.js"; import { getDatabase } from "./index.js"; +import { clearCompletionCache } from "./completion-cache.js"; import { clearAllIssueOrgCache } from "./issue-org-cache.js"; import { clearTrustedHostState } from "./regions.js"; @@ -737,13 +738,14 @@ export function getAccountIdToReplace(host: string): string | undefined { /** * Clear caches whose entries are scoped to one identity's permissions but - * not keyed by it (pagination cursors, issue-id → org mappings), plus the - * in-process trust extension. + * not keyed by it (pagination cursors, issue-id → org mappings, completion + * entities), plus the in-process trust extension. */ function clearUnscopedIdentityCaches(): void { const db = getDatabase(); db.query("DELETE FROM pagination_cursors").run(); clearAllIssueOrgCache(); + clearCompletionCache(); clearTrustedHostState(); } diff --git a/packages/cli/src/lib/db/completion-cache.ts b/packages/cli/src/lib/db/completion-cache.ts new file mode 100644 index 000000000..d3641b810 --- /dev/null +++ b/packages/cli/src/lib/db/completion-cache.ts @@ -0,0 +1,138 @@ +/** + * Entities seen during normal CLI usage, kept for shell completion. + * + * The `__complete` fast path makes no API calls, so it can only suggest what + * earlier commands already fetched. The API layer records issue short IDs and + * dashboards here as they are listed or viewed; `src/lib/complete.ts` reads + * them back. Entries are best-effort hints: a stale one at worst suggests an + * issue that was since merged or a dashboard that was deleted. + * + * This module is imported by the `__complete` fast path, so it must not pull + * in the Sentry SDK; best-effort error reporting for writes lives in + * `src/lib/api/completion-seed.ts`. + * + * Storage: `completion_cache` SQLite table (schema v20). Each kind keeps at + * most {@link MAX_ENTITIES_PER_ORG} entries per org, rows expire with the + * other caches in `maybeCleanupCaches()`, and logout clears the table. + */ + +import { getDatabase, maybeCleanupCaches } from "./index.js"; +import { runUpsert } from "./utils.js"; + +/** Issues, completed by short ID with the title as description. */ +export const ISSUE_ENTITY = "issue"; + +/** Dashboards, completed by numeric ID with the title as description. */ +export const DASHBOARD_ENTITY = "dashboard"; + +/** Entity types stored for shell completion. */ +export type CompletionEntityKind = + | typeof ISSUE_ENTITY + | typeof DASHBOARD_ENTITY; + +/** Most recently seen entries kept per kind and org. */ +export const MAX_ENTITIES_PER_ORG = 500; + +/** One entity to remember for completion. */ +export type CompletionEntity = { + /** Text the shell inserts (e.g. `CLI-1A2` or a dashboard ID). */ + value: string; + /** Description shown next to the value (e.g. the issue title). */ + label?: string; +}; + +/** A remembered entity together with the org it belongs to. */ +export type CachedCompletionEntity = CompletionEntity & { orgSlug: string }; + +type CompletionCacheRow = { + org_slug: string; + value: string; + label: string | null; +}; + +/** + * Remember entities for shell completion. + * + * Entries earlier in `entities` are treated as more recent, so a list keeps + * its API order when completion shows the most recent entries first. Only + * the newest {@link MAX_ENTITIES_PER_ORG} entries per kind and org are kept. + * + * @param kind - Entity type + * @param orgSlug - Organization that owns the entities + * @param entities - Entities in display order; entries without a value are skipped + */ +export function cacheCompletionEntities( + kind: CompletionEntityKind, + orgSlug: string, + entities: readonly CompletionEntity[], +): void { + const valid = entities + .filter((entity) => entity.value) + .slice(0, MAX_ENTITIES_PER_ORG); + if (!orgSlug || valid.length === 0) { + return; + } + + const db = getDatabase(); + const now = Date.now(); + db.transaction(() => { + valid.forEach((entity, index) => { + runUpsert( + db, + "completion_cache", + { + kind, + org_slug: orgSlug, + value: entity.value, + label: entity.label ?? null, + last_seen: now - index, + }, + ["kind", "org_slug", "value"], + ); + }); + db.query( + `DELETE FROM completion_cache + WHERE kind = ? AND org_slug = ? AND rowid NOT IN ( + SELECT rowid FROM completion_cache + WHERE kind = ? AND org_slug = ? + ORDER BY last_seen DESC + LIMIT ? + )`, + ).run(kind, orgSlug, kind, orgSlug, MAX_ENTITIES_PER_ORG); + })(); + maybeCleanupCaches(); +} + +/** + * Read remembered entities of one kind, most recently seen first. + * + * @param kind - Entity type + * @param orgSlugs - Only return entities from these orgs + */ +export function getCachedCompletionEntities( + kind: CompletionEntityKind, + orgSlugs: readonly string[], +): CachedCompletionEntity[] { + if (orgSlugs.length === 0) { + return []; + } + const placeholders = orgSlugs.map(() => "?").join(", "); + const rows = getDatabase() + .query( + `SELECT org_slug, value, label FROM completion_cache + WHERE kind = ? AND org_slug IN (${placeholders}) + ORDER BY last_seen DESC`, + ) + .all(kind, ...orgSlugs) as CompletionCacheRow[]; + + return rows.map((row) => ({ + orgSlug: row.org_slug, + value: row.value, + label: row.label ?? undefined, + })); +} + +/** Drop every remembered entity (called on logout). */ +export function clearCompletionCache(): void { + getDatabase().query("DELETE FROM completion_cache").run(); +} diff --git a/packages/cli/src/lib/db/index.ts b/packages/cli/src/lib/db/index.ts index 8fbfad802..6839b2411 100644 --- a/packages/cli/src/lib/db/index.ts +++ b/packages/cli/src/lib/db/index.ts @@ -277,6 +277,9 @@ function cleanupExpiredCaches(): void { database .query("DELETE FROM project_aliases WHERE last_accessed < ?") .run(expiryTime); + database + .query("DELETE FROM completion_cache WHERE last_seen < ?") + .run(expiryTime); // project_root_cache uses ttl_expires_at instead of last_accessed database .query("DELETE FROM project_root_cache WHERE ttl_expires_at < ?") diff --git a/packages/cli/src/lib/db/schema.ts b/packages/cli/src/lib/db/schema.ts index 7ed756c5f..23d27b3ca 100644 --- a/packages/cli/src/lib/db/schema.ts +++ b/packages/cli/src/lib/db/schema.ts @@ -21,7 +21,7 @@ import type { Database } from "./sqlite.js"; const _require = createRequire(import.meta.url); -export const CURRENT_SCHEMA_VERSION = 19; +export const CURRENT_SCHEMA_VERSION = 20; /** Environment variable to disable auto-repair */ const NO_AUTO_REPAIR_ENV = "SENTRY_CLI_NO_AUTO_REPAIR"; @@ -263,6 +263,30 @@ export const TABLE_SCHEMAS: Record = { }, }, }, + /** + * Entities seen during normal CLI usage, kept for shell completion. + * + * `sentry __complete` reads this table instead of calling the API, so it can + * suggest issue short IDs and dashboards the user has already listed or + * viewed. `kind` names the entity type (see `db/completion-cache.ts`), + * `value` is what completion inserts and `label` is the description shells + * show next to it. Capped per kind and org, expired with the other caches, + * and cleared on logout because titles are scoped to the user's permissions. + */ + completion_cache: { + columns: { + kind: { type: "TEXT", notNull: true }, + org_slug: { type: "TEXT", notNull: true }, + value: { type: "TEXT", notNull: true }, + label: { type: "TEXT" }, + last_seen: { + type: "INTEGER", + notNull: true, + default: "(unixepoch() * 1000)", + }, + }, + compositePrimaryKey: ["kind", "org_slug", "value"], + }, project_root_cache: { columns: { cwd: { type: "TEXT", primaryKey: true }, @@ -1012,6 +1036,12 @@ export function runMigrations(db: Database): void { })(); } + // Migration 19 -> 20: Add completion_cache for issue and dashboard shell + // completion from entities seen during normal CLI usage. + if (currentVersion < 20) { + db.exec(EXPECTED_TABLES.completion_cache as string); + } + if (currentVersion < CURRENT_SCHEMA_VERSION) { db.query("UPDATE schema_version SET version = ?").run( CURRENT_SCHEMA_VERSION, diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index fb144c72a..b1cee8353 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -15,8 +15,6 @@ import { COMPLETION_TARGETS, type CompletionTarget, getCompletionTarget, - ORG_PROJECT_TARGET, - ORG_TARGET, } from "./completion-target.js"; import { extractSchemaFields, @@ -409,10 +407,10 @@ export type CompletionTargets = Record; /** * Read a command's completion target from its positional parsers. * - * A command completes org or org/project slugs when one of its positionals - * uses `parseOrgTarget` or `parseOrgProjectTarget` (see - * `completion-target.ts`). Mixing both kinds in one command is rejected - * because `__complete` classifies whole commands, not argument positions. + * A command declares a completion target when one of its positionals uses a + * completion-aware parser such as `parseOrgTarget` (see + * `completion-target.ts`). Mixing kinds in one command is rejected because + * `__complete` classifies whole commands, not argument positions. * * @param command - Command to inspect * @param path - Command path, used in the error message @@ -438,7 +436,7 @@ function getCommandCompletionTarget( } if (kinds.size > 1) { throw new Error( - `Command "${path}" mixes org and org/project completion targets`, + `Command "${path}" mixes completion targets: ${[...kinds].join(", ")}`, ); } return kinds.values().next().value; @@ -458,10 +456,9 @@ export function extractCompletionTargets( routeMap: RouteMap, prefix: readonly string[] = [], ): CompletionTargets { - const targets: CompletionTargets = { - [ORG_TARGET]: [], - [ORG_PROJECT_TARGET]: [], - }; + const targets = Object.fromEntries( + COMPLETION_TARGETS.map((kind) => [kind, [] as string[]]), + ) as CompletionTargets; for (const entry of routeMap.getAllEntries()) { if (entry.hidden) { diff --git a/packages/cli/test/lib/api-client.coverage.test.ts b/packages/cli/test/lib/api-client.coverage.test.ts index 4687ce842..bdefe4450 100644 --- a/packages/cli/test/lib/api-client.coverage.test.ts +++ b/packages/cli/test/lib/api-client.coverage.test.ts @@ -48,6 +48,10 @@ import { updateIssueStatus, } from "../../src/lib/api-client.js"; import { setAuthToken } from "../../src/lib/db/auth.js"; +import { + getCachedCompletionEntities, + ISSUE_ENTITY, +} from "../../src/lib/db/completion-cache.js"; import { setOrgRegion } from "../../src/lib/db/regions.js"; import { ApiError, AuthError } from "../../src/lib/errors.js"; import { mockFetch, useTestConfigDir } from "../helpers.js"; @@ -202,6 +206,23 @@ describe("issues.ts", () => { expect(result.shortId).toBe("TEST-1"); }); + test("remembers the issue for shell completion", async () => { + const issue = mockIssue({ shortId: "TEST-1", title: "Boom" }); + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ group: issue }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await getIssueByShortId("test-org", "test-1"); + + expect(getCachedCompletionEntities(ISSUE_ENTITY, ["test-org"])).toEqual([ + { orgSlug: "test-org", value: "TEST-1", label: "Boom" }, + ]); + }); + test("passes collapse query params when provided", async () => { const issue = mockIssue({ shortId: "TEST-1" }); globalThis.fetch = mockFetch(async (input, init) => { diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index 29632c043..8da35ef9f 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -9,12 +9,20 @@ import { describe, expect, test } from "vitest"; import { completeAliases, + completeCachedEntities, + completeDashboards, + completeIssues, completeOrgSlashProject, completeOrgSlugs, completeProjectCreateSpec, completeProjectSlugs, getCompletions, } from "../../src/lib/complete.js"; +import { + cacheCompletionEntities, + DASHBOARD_ENTITY, + ISSUE_ENTITY, +} from "../../src/lib/db/completion-cache.js"; import { setProjectAliases } from "../../src/lib/db/project-aliases.js"; import { setCachedProject } from "../../src/lib/db/project-cache.js"; import { @@ -412,3 +420,176 @@ describe("completeAliases", () => { expect(result.map((c) => c.value)).toEqual(["a", "abc"]); }); }); + +describe("completeCachedEntities", () => { + test("returns nothing when no entities are cached", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + expect(completeCachedEntities(ISSUE_ENTITY, "")).toEqual([]); + }); + + test("lists the most recently seen entities first for an empty partial", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities(ISSUE_ENTITY, "acme", [ + { value: "CLI-2", label: "Newer" }, + { value: "CLI-1", label: "Older" }, + ]); + + expect(completeCachedEntities(ISSUE_ENTITY, "")).toEqual([ + { value: "CLI-2", description: "Newer" }, + { value: "CLI-1", description: "Older" }, + ]); + }); + + test("matches the partial against values and titles", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities(ISSUE_ENTITY, "acme", [ + { value: "CLI-1", label: "TypeError in checkout" }, + { value: "WEB-7", label: "Timeout talking to db" }, + ]); + + expect( + completeCachedEntities(ISSUE_ENTITY, "cli").map((c) => c.value), + ).toEqual(["CLI-1"]); + expect( + completeCachedEntities(ISSUE_ENTITY, "checkout").map((c) => c.value), + ).toEqual(["CLI-1"]); + }); + + test("only suggests entities from orgs of the active credential", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities(ISSUE_ENTITY, "foreign", [{ value: "SECRET-1" }]); + + expect(completeCachedEntities(ISSUE_ENTITY, "")).toEqual([]); + }); + + test("qualifies values that exist in several orgs", async () => { + await seedOrgs([ + { slug: "acme", name: "Acme" }, + { slug: "beta", name: "Beta" }, + ]); + cacheCompletionEntities(ISSUE_ENTITY, "acme", [{ value: "CLI-1" }]); + cacheCompletionEntities(ISSUE_ENTITY, "beta", [ + { value: "CLI-1" }, + { value: "API-3" }, + ]); + + expect( + completeCachedEntities(ISSUE_ENTITY, "") + .map((c) => c.value) + .sort(), + ).toEqual(["API-3", "acme/CLI-1", "beta/CLI-1"]); + }); +}); + +describe("completeIssues", () => { + test("suggests org prefixes and cached short IDs", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities(ISSUE_ENTITY, "acme", [ + { value: "CLI-1A", label: "Crash on start" }, + ]); + + expect(completeIssues("")).toEqual([ + { value: "acme/", description: "Acme" }, + { value: "CLI-1A", description: "Crash on start" }, + ]); + }); + + test("completes org-qualified short IDs and projects after a slash", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + await seedProjects([ + { + orgId: "1", + projectId: "10", + orgSlug: "acme", + projectSlug: "cli", + projectName: "CLI", + }, + ]); + cacheCompletionEntities(ISSUE_ENTITY, "acme", [ + { value: "CLI-1A", label: "Crash on start" }, + ]); + + expect(completeIssues("acme/cl")).toEqual([ + { value: "acme/cli", description: "CLI" }, + { value: "acme/CLI-1A", description: "Crash on start" }, + ]); + expect(completeIssues("nope/")).toEqual([]); + }); + + test("is used by issue commands", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities(ISSUE_ENTITY, "acme", [{ value: "CLI-1A" }]); + + for (const command of [ + ["issue", "view"], + ["issue", "resolve"], + ["issue", "merge", "CLI-9"], + ["event", "list"], + ]) { + expect(getCompletions(command, "CLI").map((c) => c.value)).toContain( + "CLI-1A", + ); + } + // List commands take an org/project target, not an issue. + expect( + getCompletions(["issue", "list"], "CLI").map((c) => c.value), + ).not.toContain("CLI-1A"); + }); +}); + +describe("completeDashboards", () => { + test("suggests org prefixes and org-qualified dashboard IDs first", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities(DASHBOARD_ENTITY, "acme", [ + { value: "12", label: "Errors" }, + ]); + + expect(completeDashboards("", [])).toEqual([ + { value: "acme/", description: "Acme" }, + { value: "acme/12", description: "Errors" }, + ]); + // A title finds its ID. + expect(completeDashboards("err", [])).toEqual([ + { value: "acme/12", description: "Errors" }, + ]); + expect(completeDashboards("acme/", [])).toEqual([ + { value: "acme/12", description: "Errors" }, + ]); + }); + + test("suggests bare IDs after an org positional", async () => { + await seedOrgs([ + { slug: "acme", name: "Acme" }, + { slug: "beta", name: "Beta" }, + ]); + cacheCompletionEntities(DASHBOARD_ENTITY, "acme", [ + { value: "12", label: "Errors" }, + ]); + cacheCompletionEntities(DASHBOARD_ENTITY, "beta", [ + { value: "34", label: "Latency" }, + ]); + + expect(completeDashboards("", ["acme/"])).toEqual([ + { value: "12", description: "Errors" }, + ]); + expect(completeDashboards("", ["--web", "beta"])).toEqual([ + { value: "34", description: "Latency" }, + ]); + expect(completeDashboards("", ["acme/", "12"])).toEqual([]); + }); + + test("is used by dashboard commands that take an existing dashboard", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities(DASHBOARD_ENTITY, "acme", [{ value: "12" }]); + + for (const command of [ + ["dashboard", "view"], + ["dashboard", "revisions"], + ["dashboard", "widget", "edit"], + ]) { + expect(getCompletions(command, "acme/").map((c) => c.value)).toEqual([ + "acme/12", + ]); + } + }); +}); diff --git a/packages/cli/test/lib/db/auth.test.ts b/packages/cli/test/lib/db/auth.test.ts index 344dde160..660929947 100644 --- a/packages/cli/test/lib/db/auth.test.ts +++ b/packages/cli/test/lib/db/auth.test.ts @@ -243,6 +243,25 @@ describe("clearAuth: integration with per-account caches", () => { // their `issue view` fallback routing. expect(getCachedIssueOrg("12345")).toBeUndefined(); }); + + test("clearAuth drops remembered completion entities", async () => { + const { + cacheCompletionEntities, + getCachedCompletionEntities, + ISSUE_ENTITY, + } = await import("../../../src/lib/db/completion-cache.js"); + + await setAuthToken("test-token"); + cacheCompletionEntities(ISSUE_ENTITY, "previous-account-org", [ + { value: "CLI-1", label: "Private issue title" }, + ]); + + await clearAuth(); + + expect( + getCachedCompletionEntities(ISSUE_ENTITY, ["previous-account-org"]), + ).toEqual([]); + }); }); describe("getIdentityFingerprint", () => { diff --git a/packages/cli/test/lib/db/completion-cache.test.ts b/packages/cli/test/lib/db/completion-cache.test.ts new file mode 100644 index 000000000..1eb1323bc --- /dev/null +++ b/packages/cli/test/lib/db/completion-cache.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for the shell-completion entity cache. + */ + +import { describe, expect, test } from "vitest"; +import { + seedDashboardCompletions, + seedIssueCompletions, +} from "../../../src/lib/api/completion-seed.js"; +import { + cacheCompletionEntities, + clearCompletionCache, + DASHBOARD_ENTITY, + getCachedCompletionEntities, + ISSUE_ENTITY, + MAX_ENTITIES_PER_ORG, +} from "../../../src/lib/db/completion-cache.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("test-completion-cache-"); + +describe("completion cache", () => { + test("keeps list order as recency and refreshes labels", () => { + cacheCompletionEntities(ISSUE_ENTITY, "acme", [ + { value: "CLI-1", label: "First" }, + { value: "CLI-2", label: "Second" }, + ]); + cacheCompletionEntities(ISSUE_ENTITY, "acme", [ + { value: "CLI-2", label: "Second, renamed" }, + ]); + + expect(getCachedCompletionEntities(ISSUE_ENTITY, ["acme"])).toEqual([ + { orgSlug: "acme", value: "CLI-2", label: "Second, renamed" }, + { orgSlug: "acme", value: "CLI-1", label: "First" }, + ]); + }); + + test("scopes reads by kind and org", () => { + cacheCompletionEntities(ISSUE_ENTITY, "acme", [{ value: "CLI-1" }]); + cacheCompletionEntities(ISSUE_ENTITY, "other", [{ value: "WEB-1" }]); + cacheCompletionEntities(DASHBOARD_ENTITY, "acme", [{ value: "42" }]); + + expect( + getCachedCompletionEntities(ISSUE_ENTITY, ["acme"]).map((e) => e.value), + ).toEqual(["CLI-1"]); + expect(getCachedCompletionEntities(ISSUE_ENTITY, [])).toEqual([]); + }); + + test("keeps only the newest entries per kind and org", () => { + const issues = Array.from({ length: MAX_ENTITIES_PER_ORG }, (_, i) => ({ + value: `OLD-${i}`, + })); + cacheCompletionEntities(ISSUE_ENTITY, "acme", issues); + cacheCompletionEntities(ISSUE_ENTITY, "acme", [{ value: "NEW-1" }]); + + const cached = getCachedCompletionEntities(ISSUE_ENTITY, ["acme"]); + expect(cached).toHaveLength(MAX_ENTITIES_PER_ORG); + expect(cached[0]?.value).toBe("NEW-1"); + // The last (oldest) entry of the first batch was evicted. + expect(cached.map((e) => e.value)).not.toContain( + `OLD-${MAX_ENTITIES_PER_ORG - 1}`, + ); + }); + + test("clearCompletionCache drops every entry", () => { + cacheCompletionEntities(ISSUE_ENTITY, "acme", [{ value: "CLI-1" }]); + clearCompletionCache(); + expect(getCachedCompletionEntities(ISSUE_ENTITY, ["acme"])).toEqual([]); + }); +}); + +describe("completion seeding from API data", () => { + test("issues are stored by short ID with their title", () => { + seedIssueCompletions("acme", [ + { shortId: "CLI-1A", title: "TypeError: x is undefined" }, + { shortId: undefined, title: "No short ID" }, + ]); + + expect(getCachedCompletionEntities(ISSUE_ENTITY, ["acme"])).toEqual([ + { orgSlug: "acme", value: "CLI-1A", label: "TypeError: x is undefined" }, + ]); + }); + + test("dashboards are stored by numeric ID only", () => { + seedDashboardCompletions("acme", [ + { id: "12", title: "Errors" }, + { id: "default-overview", title: "Prebuilt" }, + ]); + + expect(getCachedCompletionEntities(DASHBOARD_ENTITY, ["acme"])).toEqual([ + { orgSlug: "acme", value: "12", label: "Errors" }, + ]); + }); +}); diff --git a/packages/cli/test/lib/db/schema.test.ts b/packages/cli/test/lib/db/schema.test.ts index 52dd3b4a8..45870709f 100644 --- a/packages/cli/test/lib/db/schema.test.ts +++ b/packages/cli/test/lib/db/schema.test.ts @@ -471,6 +471,19 @@ describe("runMigrations", () => { expect(version).toBe(CURRENT_SCHEMA_VERSION); db.close(); }); + + test("migration 19→20 creates completion_cache", () => { + const db = new Database(join(getTestDir(), "test.db")); + initSchema(db); + db.exec("DROP TABLE completion_cache"); + db.query("UPDATE schema_version SET version = 19").run(); + + runMigrations(db); + + expect(tableExists(db, "completion_cache")).toBe(true); + expect(getSchemaIssues(db)).toEqual([]); + db.close(); + }); }); describe("repairSchema: wrong primary key", () => { diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts index ed14813bd..1db62fcb4 100644 --- a/packages/cli/test/lib/introspect.test.ts +++ b/packages/cli/test/lib/introspect.test.ts @@ -7,8 +7,12 @@ import { describe, expect, test } from "vitest"; import { + DASHBOARD_TARGET, + ISSUE_TARGET, ORG_PROJECT_TARGET, ORG_TARGET, + parseDashboardTarget, + parseIssueTarget, parseOrgProjectTarget, parseOrgTarget, } from "../../src/lib/completion-target.js"; @@ -430,6 +434,8 @@ describe("extractCompletionTargets", () => { expect(extractCompletionTargets(topLevel)).toEqual({ [ORG_TARGET]: ["trial"], [ORG_PROJECT_TARGET]: ["issue list", "issue rules view", "issue view"], + [ISSUE_TARGET]: [], + [DASHBOARD_TARGET]: [], }); }); @@ -444,6 +450,22 @@ describe("extractCompletionTargets", () => { expect(extractCompletionTargets(topLevel)).toEqual({ [ORG_TARGET]: [], [ORG_PROJECT_TARGET]: [], + [ISSUE_TARGET]: [], + [DASHBOARD_TARGET]: [], + }); + }); + + test("classifies issue and dashboard positionals", () => { + const topLevel = makeRouteMap([ + makeEntry("issue", commandWith(parseIssueTarget)), + makeEntry("dashboard", commandWith(parseDashboardTarget)), + ]); + + expect(extractCompletionTargets(topLevel)).toEqual({ + [ORG_TARGET]: [], + [ORG_PROJECT_TARGET]: [], + [ISSUE_TARGET]: ["issue"], + [DASHBOARD_TARGET]: ["dashboard"], }); }); From 1116206bdec6b04d44ff0f83eb2b4e2e8326fdae Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:30:14 +0000 Subject: [PATCH 2/3] fix(cli): Skip flag values in issue and dashboard completion `__complete` treated the word after a value-taking flag as a target, so `dashboard view --period ` suggested dashboards, and `--period 7d` counted `7d` as a positional. The completion generator now also records each target command's value-taking flags (non-boolean flags and their aliases) next to the target sets in completion-targets.json. `__complete` returns nothing when the word being completed is such a flag's value, and dashboard completion counts positionals with flag values removed. This also stops org/project suggestions after flags like `issue list --query`. --- .../cli/script/generate-completion-targets.ts | 17 +++-- packages/cli/src/lib/complete.ts | 72 +++++++++++++++---- packages/cli/src/lib/introspect.ts | 66 +++++++++++++++++ packages/cli/test/lib/complete.test.ts | 39 +++++++++- .../cli/test/lib/completions.property.test.ts | 6 +- packages/cli/test/lib/introspect.test.ts | 33 +++++++++ 6 files changed, 210 insertions(+), 23 deletions(-) diff --git a/packages/cli/script/generate-completion-targets.ts b/packages/cli/script/generate-completion-targets.ts index d00c16cc1..11c0fd896 100644 --- a/packages/cli/script/generate-completion-targets.ts +++ b/packages/cli/script/generate-completion-targets.ts @@ -5,9 +5,10 @@ * The `sentry __complete` fast path (src/lib/complete.ts) skips the Stricli * boot, so it cannot walk the route tree at runtime to decide which commands * complete org or org/project slugs. This script walks the route tree at build - * time, collects every command whose positionals use `parseOrgTarget` or - * `parseOrgProjectTarget` (src/lib/completion-target.ts), and writes the - * command paths per completion target as JSON. + * time, collects every command whose positionals use a completion-aware + * parser (src/lib/completion-target.ts), and writes the command paths per + * completion target as JSON, together with each such command's + * value-taking flags so `__complete` can skip flag values. * * The output is gitignored and regenerated by `generate:docs`, which runs as * part of `dev`, `build`, `bundle`, `typecheck`, and `test`. @@ -37,16 +38,22 @@ if (!skillContentExists) { import type { RouteMap } from "../src/lib/introspect.js"; const { routes } = await import("../src/app.js"); -const { extractCompletionTargets } = await import("../src/lib/introspect.js"); +const { extractCompletionTargets, extractCompletionValueFlags } = + await import("../src/lib/introspect.js"); const OUTPUT_PATH = "src/generated/completion-targets.json"; const targets = extractCompletionTargets(routes as unknown as RouteMap); +const valueFlags = extractCompletionValueFlags(routes as unknown as RouteMap); // Plain JSON (like api-schema.json) so the output is produced by a real // serializer rather than hand-assembled source text. mkdirSync("src/generated", { recursive: true }); -await writeFile(OUTPUT_PATH, `${JSON.stringify(targets, null, 2)}\n`, "utf8"); +await writeFile( + OUTPUT_PATH, + `${JSON.stringify({ targets, valueFlags }, null, 2)}\n`, + "utf8", +); process.stdout.write( `Wrote ${OUTPUT_PATH} (${Object.entries(targets) .map(([kind, paths]) => `${paths.length} ${kind}`) diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 31270214c..3f62e4c94 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -112,26 +112,60 @@ export function handleComplete(args: string[]): void { /** * Command path → completion target, from `src/generated/completion-targets.json`. * - * Generated from the commands' positional parsers (`parseOrgTarget` / - * `parseOrgProjectTarget`) by `script/generate-completion-targets.ts` + * Generated from the commands' completion-aware positional parsers (see + * `completion-target.ts`) by `script/generate-completion-targets.ts` * because this fast path cannot boot Stricli to walk the route tree. */ const COMMAND_TARGETS = new Map( COMPLETION_TARGETS.flatMap((kind) => - generatedTargets[kind].map((path): [string, CompletionTarget] => [ + generatedTargets.targets[kind].map((path): [string, CompletionTarget] => [ path, kind, ]), ), ); +/** Command path → spellings of its flags that take a value. */ +const COMMAND_VALUE_FLAGS: Readonly> = + generatedTargets.valueFlags; + /** The command named by the preceding words and what follows it. */ type CommandMatch = { target: CompletionTarget; /** Words typed after the command path (positionals and flags). */ args: string[]; + /** Flags of this command that consume the next word as their value. */ + valueFlags: ReadonlySet; +}; + +/** Words typed after a command path, split into positionals and flags. */ +type ParsedArgs = { + positionals: string[]; + /** The word being completed is the value of the last flag (`--period `). */ + completingFlagValue: boolean; }; +/** + * Separate positionals from flags in the words typed after a command path. + * + * A value-taking flag written as `--period 7d` consumes the next word; + * `--period=7d` is a single word. Other `-`-prefixed words are flags. + */ +function parseArgs(match: CommandMatch): ParsedArgs { + const positionals: string[] = []; + let expectingValue = false; + for (const word of match.args) { + if (expectingValue) { + expectingValue = false; + } else if (word.startsWith("-")) { + expectingValue = match.valueFlags.has(word); + } else { + positionals.push(word); + } + } + return { positionals, completingFlagValue: expectingValue }; +} + /** * Find the completion target declared by the command named in `words`. * @@ -141,9 +175,14 @@ type CommandMatch = { */ function findCompletionTarget(words: string[]): CommandMatch | undefined { for (let length = 1; length <= words.length; length++) { - const target = COMMAND_TARGETS.get(words.slice(0, length).join(" ")); + const path = words.slice(0, length).join(" "); + const target = COMMAND_TARGETS.get(path); if (target) { - return { target, args: words.slice(length) }; + return { + target, + args: words.slice(length), + valueFlags: new Set(COMMAND_VALUE_FLAGS[path] ?? []), + }; } } return; @@ -175,7 +214,16 @@ export function getCompletions( } const match = findCompletionTarget(precedingWords); - switch (match?.target) { + if (!match) { + // Not a known command path — no dynamic completions + return []; + } + const args = parseArgs(match); + if (args.completingFlagValue) { + // The word is a flag value (e.g. after `--period`), not a target + return []; + } + switch (match.target) { case ORG_PROJECT_TARGET: return completeOrgSlashProject(partial); case ORG_TARGET: @@ -183,9 +231,8 @@ export function getCompletions( case ISSUE_TARGET: return completeIssues(partial); case DASHBOARD_TARGET: - return completeDashboards(partial, match.args); + return completeDashboards(partial, args.positionals); default: - // Not a known command path — no dynamic completions return []; } } @@ -517,18 +564,13 @@ export function completeIssues(partial: string): Completion[] { * - First positional, `org/…`: `org/` for that org's dashboards * - Second positional: bare IDs for the org named by the first one * - * Words starting with `-` are treated as flags. A flag value given as a - * separate word counts as a positional, which can only hide suggestions. - * * @param partial - The partial dashboard argument - * @param args - Words typed after the command path + * @param positionals - Positional words before the partial (flags removed) */ export function completeDashboards( partial: string, - args: readonly string[], + positionals: readonly string[], ): Completion[] { - const positionals = args.filter((word) => !word.startsWith("-")); - if (positionals.length === 1) { const orgPart = positionals[0]?.split("/")[0]; const org = orgPart ? fuzzyResolveOrg(orgPart) : undefined; diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index b1cee8353..039a1ffef 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -488,6 +488,72 @@ export function extractCompletionTargets( return targets; } +/** Value-taking flag spellings (`--name`, `-a`) per command path. */ +export type CompletionValueFlags = Record; + +/** + * Spellings of a command's flags that consume the next word as their value. + * + * Every non-boolean flag counts, including hidden ones, because the parser + * still accepts them. Single-letter aliases are included as `-x`. + */ +function getCommandValueFlags(command: Command): string[] { + const flags = command.parameters.flags ?? {}; + const names = new Set( + Object.entries(flags) + .filter(([, def]) => def.kind !== "boolean") + .map(([name]) => name), + ); + const spellings = [...names].map((name) => `--${name}`); + for (const [alias, name] of Object.entries( + command.parameters.aliases ?? {}, + )) { + if (names.has(name)) { + spellings.push(`-${alias}`); + } + } + return spellings.sort(); +} + +/** + * Collect the value-taking flags of every command with a completion target. + * + * `__complete` uses these to tell a flag value apart from a positional, so + * it does not suggest targets after `--period` or count `7d` as an + * argument. Commands without value flags are omitted. + * + * @param routeMap - Top-level Stricli route map + * @param prefix - Route segments above `routeMap` (used for recursion) + */ +export function extractCompletionValueFlags( + routeMap: RouteMap, + prefix: readonly string[] = [], +): CompletionValueFlags { + const result: CompletionValueFlags = {}; + for (const entry of routeMap.getAllEntries()) { + if (entry.hidden) { + continue; + } + const segments = [...prefix, entry.name.original]; + const target = entry.target; + if (isRouteMap(target)) { + Object.assign(result, extractCompletionValueFlags(target, segments)); + } else if (isCommand(target)) { + const path = segments.join(" "); + if (!getCommandCompletionTarget(target, path)) { + continue; + } + const flags = getCommandValueFlags(target); + if (flags.length > 0) { + result[path] = flags; + } + } + } + return Object.fromEntries( + Object.entries(result).sort(([a], [b]) => a.localeCompare(b)), + ); +} + /** Matches the "sentry " prefix at the start of a command path. */ const SENTRY_PREFIX_RE = /^sentry /; diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index 8da35ef9f..8f2b10b0f 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -95,6 +95,19 @@ describe("getCompletions: context detection", () => { expect(result.length).toBeGreaterThan(0); }); + test("returns nothing for the value of a value-taking flag", async () => { + await seedOrgs([{ slug: "my-org", name: "My Organization" }]); + expect(getCompletions(["issue", "list", "--query"], "")).toEqual([]); + expect(getCompletions(["issue", "list", "-q"], "")).toEqual([]); + // Once the value is given, the next word is a target again. + expect( + getCompletions(["issue", "list", "--query", "is:unresolved"], "").length, + ).toBeGreaterThan(0); + expect( + getCompletions(["issue", "list", "--query=is:unresolved"], "").length, + ).toBeGreaterThan(0); + }); + test("returns org/project completions for project list", async () => { await seedOrgs([{ slug: "test-org", name: "Test" }]); const result = getCompletions(["project", "list"], "test"); @@ -572,12 +585,36 @@ describe("completeDashboards", () => { expect(completeDashboards("", ["acme/"])).toEqual([ { value: "12", description: "Errors" }, ]); - expect(completeDashboards("", ["--web", "beta"])).toEqual([ + expect(completeDashboards("", ["beta"])).toEqual([ { value: "34", description: "Latency" }, ]); expect(completeDashboards("", ["acme/", "12"])).toEqual([]); }); + test("skips flag values when counting positionals", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities(DASHBOARD_ENTITY, "acme", [ + { value: "12", label: "Errors" }, + ]); + + // The value of --period is not a dashboard. + expect(getCompletions(["dashboard", "view", "--period"], "")).toEqual([]); + expect( + getCompletions(["dashboard", "restore", "acme", "--revision"], ""), + ).toEqual([]); + // `7d` is the flag value, so the next word is still the first positional. + expect( + getCompletions(["dashboard", "view", "--period", "7d"], "acme/"), + ).toEqual([{ value: "acme/12", description: "Errors" }]); + expect( + getCompletions(["dashboard", "view", "--period", "7d", "acme"], ""), + ).toEqual([{ value: "12", description: "Errors" }]); + // Boolean flags do not consume a value. + expect(getCompletions(["dashboard", "view", "--web", "acme"], "")).toEqual([ + { value: "12", description: "Errors" }, + ]); + }); + test("is used by dashboard commands that take an existing dashboard", async () => { await seedOrgs([{ slug: "acme", name: "Acme" }]); cacheCompletionEntities(DASHBOARD_ENTITY, "acme", [{ value: "12" }]); diff --git a/packages/cli/test/lib/completions.property.test.ts b/packages/cli/test/lib/completions.property.test.ts index 4b20d3e14..1605eb3e9 100644 --- a/packages/cli/test/lib/completions.property.test.ts +++ b/packages/cli/test/lib/completions.property.test.ts @@ -27,6 +27,7 @@ import { import { COMPLETION_TARGETS } from "../../src/lib/completion-target.js"; import { extractCompletionTargets, + extractCompletionValueFlags, type RouteMap, } from "../../src/lib/introspect.js"; import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; @@ -401,16 +402,17 @@ describe("complete.ts: generated completion targets", () => { // Use the ESM import (routes) instead of require() which fails under // vitest because Node's CJS require can't resolve .js→.ts for transitive imports. const targets = extractCompletionTargets(routes as unknown as RouteMap); + const valueFlags = extractCompletionValueFlags(routes as unknown as RouteMap); // A mismatch means src/generated/completion-targets.json is stale. // Regenerate with: pnpm run generate:completions test("generated completion targets match the route tree", () => { - expect(generatedTargets).toEqual(targets); + expect(generatedTargets).toEqual({ targets, valueFlags }); }); test("project create keeps its dedicated name:platform completer", () => { for (const kind of COMPLETION_TARGETS) { - expect(generatedTargets[kind]).not.toContain("project create"); + expect(generatedTargets.targets[kind]).not.toContain("project create"); } }); }); diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts index 1db62fcb4..5091fb21a 100644 --- a/packages/cli/test/lib/introspect.test.ts +++ b/packages/cli/test/lib/introspect.test.ts @@ -26,6 +26,7 @@ import { buildCommandInfo, extractAllRoutes, extractCompletionTargets, + extractCompletionValueFlags, extractFlags, extractPositionals, extractRouteGroupCommands, @@ -478,6 +479,38 @@ describe("extractCompletionTargets", () => { }); }); +describe("extractCompletionValueFlags", () => { + test("lists value-taking flags and aliases of commands with a target", () => { + const withFlags = makeCommand({ + parameters: { + positional: { + kind: "tuple", + parameters: [ + { placeholder: "dashboard", parse: parseDashboardTarget }, + ], + }, + flags: { + web: { kind: "boolean" }, + period: { kind: "parsed" }, + sort: { kind: "enum" }, + }, + aliases: { w: "web", t: "period" }, + }, + }); + const noTarget = makeCommand({ + parameters: { flags: { query: { kind: "parsed" } } }, + }); + const topLevel = makeRouteMap([ + makeEntry("dashboard", makeRouteMap([makeEntry("view", withFlags)])), + makeEntry("api", noTarget), + ]); + + expect(extractCompletionValueFlags(topLevel)).toEqual({ + "dashboard view": ["--period", "--sort", "-t"], + }); + }); +}); + // --------------------------------------------------------------------------- // resolveCommandPath // --------------------------------------------------------------------------- From 8c6b2d16d1735bbf8e510117890dd4e1847d3f60 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:35:50 +0000 Subject: [PATCH 3/3] fix(cli): Complete issues only in issue positionals `issue link` and `issue unlink` take ` `, but `__complete` suggested issue short IDs for the URL too, because issue completion never looked at how many positionals were already typed. The completion generator now records, for commands with a fixed list of positionals, which positions take the target. Issue completion returns nothing for any other position. Variadic commands such as `issue view` and `issue merge` still complete every positional. --- .../cli/script/generate-completion-targets.ts | 15 ++++-- packages/cli/src/lib/complete.ts | 14 ++++++ packages/cli/src/lib/introspect.ts | 46 +++++++++++++++++++ packages/cli/test/lib/complete.test.ts | 17 +++++++ .../cli/test/lib/completions.property.test.ts | 6 ++- packages/cli/test/lib/introspect.test.ts | 44 ++++++++++++++++++ 6 files changed, 137 insertions(+), 5 deletions(-) diff --git a/packages/cli/script/generate-completion-targets.ts b/packages/cli/script/generate-completion-targets.ts index 11c0fd896..d146bd4f0 100644 --- a/packages/cli/script/generate-completion-targets.ts +++ b/packages/cli/script/generate-completion-targets.ts @@ -8,7 +8,8 @@ * time, collects every command whose positionals use a completion-aware * parser (src/lib/completion-target.ts), and writes the command paths per * completion target as JSON, together with each such command's - * value-taking flags so `__complete` can skip flag values. + * value-taking flags so `__complete` can skip flag values, and which + * positionals take the target so it can skip the others. * * The output is gitignored and regenerated by `generate:docs`, which runs as * part of `dev`, `build`, `bundle`, `typecheck`, and `test`. @@ -38,20 +39,26 @@ if (!skillContentExists) { import type { RouteMap } from "../src/lib/introspect.js"; const { routes } = await import("../src/app.js"); -const { extractCompletionTargets, extractCompletionValueFlags } = - await import("../src/lib/introspect.js"); +const { + extractCompletionTargetPositions, + extractCompletionTargets, + extractCompletionValueFlags, +} = await import("../src/lib/introspect.js"); const OUTPUT_PATH = "src/generated/completion-targets.json"; const targets = extractCompletionTargets(routes as unknown as RouteMap); const valueFlags = extractCompletionValueFlags(routes as unknown as RouteMap); +const targetPositions = extractCompletionTargetPositions( + routes as unknown as RouteMap, +); // Plain JSON (like api-schema.json) so the output is produced by a real // serializer rather than hand-assembled source text. mkdirSync("src/generated", { recursive: true }); await writeFile( OUTPUT_PATH, - `${JSON.stringify({ targets, valueFlags }, null, 2)}\n`, + `${JSON.stringify({ targets, valueFlags, targetPositions }, null, 2)}\n`, "utf8", ); process.stdout.write( diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 3f62e4c94..c41824c1e 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -129,6 +129,13 @@ const COMMAND_TARGETS = new Map( const COMMAND_VALUE_FLAGS: Readonly> = generatedTargets.valueFlags; +/** + * Command path → positional indices that take the target. Commands with a + * variadic positional are absent: every positional takes the target. + */ +const COMMAND_TARGET_POSITIONS: Readonly> = + generatedTargets.targetPositions; + /** The command named by the preceding words and what follows it. */ type CommandMatch = { target: CompletionTarget; @@ -136,6 +143,8 @@ type CommandMatch = { args: string[]; /** Flags of this command that consume the next word as their value. */ valueFlags: ReadonlySet; + /** Positional indices that take the target; `undefined` when all do. */ + targetPositions: readonly number[] | undefined; }; /** Words typed after a command path, split into positionals and flags. */ @@ -182,6 +191,7 @@ function findCompletionTarget(words: string[]): CommandMatch | undefined { target, args: words.slice(length), valueFlags: new Set(COMMAND_VALUE_FLAGS[path] ?? []), + targetPositions: COMMAND_TARGET_POSITIONS[path], }; } } @@ -229,6 +239,10 @@ export function getCompletions( case ORG_TARGET: return completeOrgSlugs(partial); case ISSUE_TARGET: + // `issue link `: only the issue positional is an issue + if (match.targetPositions?.includes(args.positionals.length) === false) { + return []; + } return completeIssues(partial); case DASHBOARD_TARGET: return completeDashboards(partial, args.positionals); diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index cb78a5632..5262eba1a 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -545,6 +545,52 @@ export function extractCompletionValueFlags( ); } +/** Zero-based positional indices that take the target, per command path. */ +export type CompletionTargetPositions = Record; + +/** + * Collect which positionals take the target in commands with a fixed list of + * positionals. + * + * `__complete` uses these so a later positional, such as the URL in + * `issue link `, is not completed as a target. Commands with a + * variadic positional are omitted because every word takes the target. + * + * @param routeMap - Top-level Stricli route map + * @param prefix - Route segments above `routeMap` (used for recursion) + */ +export function extractCompletionTargetPositions( + routeMap: RouteMap, + prefix: readonly string[] = [], +): CompletionTargetPositions { + const result: CompletionTargetPositions = {}; + for (const entry of routeMap.getAllEntries()) { + if (entry.hidden) { + continue; + } + const segments = [...prefix, entry.name.original]; + const target = entry.target; + if (isRouteMap(target)) { + Object.assign(result, extractCompletionTargetPositions(target, segments)); + } else if (isCommand(target)) { + const path = segments.join(" "); + const positional = target.parameters.positional; + if ( + positional?.kind !== "tuple" || + !getCommandCompletionTarget(target, path) + ) { + continue; + } + result[path] = positional.parameters.flatMap((param, index) => + getCompletionTarget(param.parse) ? [index] : [], + ); + } + } + return Object.fromEntries( + Object.entries(result).sort(([a], [b]) => a.localeCompare(b)), + ); +} + /** Matches the "sentry " prefix at the start of a command path. */ const SENTRY_PREFIX_RE = /^sentry /; diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index 8f2b10b0f..067e631ab 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -548,6 +548,23 @@ describe("completeIssues", () => { getCompletions(["issue", "list"], "CLI").map((c) => c.value), ).not.toContain("CLI-1A"); }); + + test("skips positionals that do not take an issue", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities(ISSUE_ENTITY, "acme", [{ value: "CLI-1A" }]); + + // `issue link `: the second positional is a URL. + expect( + getCompletions(["issue", "link"], "CLI").map((c) => c.value), + ).toContain("CLI-1A"); + expect(getCompletions(["issue", "link", "CLI-1A"], "")).toEqual([]); + expect(getCompletions(["issue", "unlink", "CLI-1A"], "")).toEqual([]); + expect(getCompletions(["issue", "resolve", "CLI-1A"], "")).toEqual([]); + // Variadic issue commands complete every positional. + expect( + getCompletions(["issue", "view", "CLI-9"], "CLI").map((c) => c.value), + ).toContain("CLI-1A"); + }); }); describe("completeDashboards", () => { diff --git a/packages/cli/test/lib/completions.property.test.ts b/packages/cli/test/lib/completions.property.test.ts index 1605eb3e9..5ad4b91c9 100644 --- a/packages/cli/test/lib/completions.property.test.ts +++ b/packages/cli/test/lib/completions.property.test.ts @@ -26,6 +26,7 @@ import { } from "../../src/lib/completions.js"; import { COMPLETION_TARGETS } from "../../src/lib/completion-target.js"; import { + extractCompletionTargetPositions, extractCompletionTargets, extractCompletionValueFlags, type RouteMap, @@ -403,11 +404,14 @@ describe("complete.ts: generated completion targets", () => { // vitest because Node's CJS require can't resolve .js→.ts for transitive imports. const targets = extractCompletionTargets(routes as unknown as RouteMap); const valueFlags = extractCompletionValueFlags(routes as unknown as RouteMap); + const targetPositions = extractCompletionTargetPositions( + routes as unknown as RouteMap, + ); // A mismatch means src/generated/completion-targets.json is stale. // Regenerate with: pnpm run generate:completions test("generated completion targets match the route tree", () => { - expect(generatedTargets).toEqual({ targets, valueFlags }); + expect(generatedTargets).toEqual({ targets, valueFlags, targetPositions }); }); test("project create keeps its dedicated name:platform completer", () => { diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts index 2f8082494..4bd9e3f08 100644 --- a/packages/cli/test/lib/introspect.test.ts +++ b/packages/cli/test/lib/introspect.test.ts @@ -26,6 +26,7 @@ import { buildCommandInfo, extractAllRoutes, extractCompletionTargets, + extractCompletionTargetPositions, extractCompletionValueFlags, extractFlags, extractPositionals, @@ -517,6 +518,49 @@ describe("extractCompletionValueFlags", () => { }); }); +describe("extractCompletionTargetPositions", () => { + test("lists target positionals of tuple commands only", () => { + const link = makeCommand({ + parameters: { + positional: { + kind: "tuple", + parameters: [ + { placeholder: "issue", parse: parseIssueTarget }, + { placeholder: "url", parse: String }, + ], + }, + }, + }); + const merge = makeCommand({ + parameters: { + positional: { + kind: "array", + parameter: { placeholder: "issue", parse: parseIssueTarget }, + }, + }, + }); + const noTarget = makeCommand({ + parameters: { + positional: { + kind: "tuple", + parameters: [{ placeholder: "endpoint", parse: String }], + }, + }, + }); + const topLevel = makeRouteMap([ + makeEntry( + "issue", + makeRouteMap([makeEntry("link", link), makeEntry("merge", merge)]), + ), + makeEntry("api", noTarget), + ]); + + expect(extractCompletionTargetPositions(topLevel)).toEqual({ + "issue link": [0], + }); + }); +}); + // --------------------------------------------------------------------------- // resolveCommandPath // ---------------------------------------------------------------------------