diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index dc077ace6..e9651689d 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -268,13 +268,20 @@ tests that call `command.loader()` keep passing raw strings. Positionals whose meaning depends on how many arguments were given, such as `[//]`, keep parsing in `func`. -**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. A parser tagged with -`withCompletionTarget`, such as the `target-positional.ts` parsers, completes -the same way. +**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. A +parser tagged with `withCompletionTarget`, such as the `target-positional.ts` +parsers, completes the same way. +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/script/generate-completion-targets.ts b/packages/cli/script/generate-completion-targets.ts index d00c16cc1..d146bd4f0 100644 --- a/packages/cli/script/generate-completion-targets.ts +++ b/packages/cli/script/generate-completion-targets.ts @@ -5,9 +5,11 @@ * 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, 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`. @@ -37,16 +39,28 @@ 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 { + 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, null, 2)}\n`, "utf8"); +await writeFile( + OUTPUT_PATH, + `${JSON.stringify({ targets, valueFlags, targetPositions }, 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/commands/dashboard/restore.ts b/packages/cli/src/commands/dashboard/restore.ts index d55f5c99b..fc9d71432 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"; @@ -87,7 +87,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 2bd67519f..b15a7751d 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -9,7 +9,7 @@ import { paginate } from "../../lib/api/infrastructure.js"; import { listDashboardRevisionsPaginated } 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, @@ -150,7 +150,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 08a70c3a8..571a1a42b 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"; @@ -223,7 +223,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 c2865d3c4..2ff2518a9 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"; @@ -97,7 +98,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 6c33b395d..9c360c7b0 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"; @@ -230,7 +231,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 41a28971b..94b7cc180 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, @@ -295,7 +295,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 0461d44b5..28f8ba1f3 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 40a4132c1..60e2bfa46 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, @@ -196,7 +197,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}. */ @@ -322,6 +328,7 @@ export async function getIssueInOrg( params: options?.collapse ? { collapse: options.collapse } : undefined, }, ); + seedIssueCompletions(orgSlug, [data]); return data; } @@ -382,6 +389,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..c41824c1e 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"; @@ -104,19 +112,69 @@ 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; + +/** + * 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; + /** 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; + /** 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. */ +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`. * @@ -124,11 +182,17 @@ 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(" ")); + const path = words.slice(0, length).join(" "); + const target = COMMAND_TARGETS.get(path); if (target) { - return target; + return { + target, + args: words.slice(length), + valueFlags: new Set(COMMAND_VALUE_FLAGS[path] ?? []), + targetPositions: COMMAND_TARGET_POSITIONS[path], + }; } } return; @@ -159,13 +223,30 @@ export function getCompletions( return completeProjectCreateSpec(partial); } - switch (findCompletionTarget(precedingWords)) { + const match = findCompletionTarget(precedingWords); + 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: 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); default: - // Not a known command path — no dynamic completions return []; } } @@ -371,3 +452,165 @@ 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 + * + * @param partial - The partial dashboard argument + * @param positionals - Positional words before the partial (flags removed) + */ +export function completeDashboards( + partial: string, + positionals: readonly string[], +): Completion[] { + 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 9992f8dcd..ac42dfc15 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -7,9 +7,10 @@ * collects them at build time because the `__complete` fast path cannot boot * Stricli to walk the route tree. * - * {@link parseOrgTarget} and {@link parseOrgProjectTarget} return their input - * unchanged and only mark the kind. Parsers that also parse the value, such - * as those in `target-positional.ts`, use the same tag. + * {@link parseOrgTarget}, {@link parseOrgProjectTarget}, + * {@link parseIssueTarget}, and {@link parseDashboardTarget} return their + * input unchanged and only mark the kind. Parsers that also parse the value, + * such as those in `target-positional.ts`, use the same tag. */ /** Completes organization slugs only (e.g. `org`, `org/version`, `org/id`). */ @@ -18,10 +19,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]; const COMPLETION_TARGET_KEY = Symbol("completionTarget"); @@ -51,6 +66,18 @@ export const parseOrgProjectTarget = withCompletionTarget( (input: string): string => input, ); +/** Parser for positionals that name an issue (short ID, `org/ID`, ...). */ +export const parseIssueTarget = withCompletionTarget( + ISSUE_TARGET, + (input: string): string => input, +); + +/** Parser for `[] ` positionals. */ +export const parseDashboardTarget = withCompletionTarget( + DASHBOARD_TARGET, + (input: string): string => input, +); + /** * Read the completion kind from a positional's `parse` function. * diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 1e391a80e..a41d54c6a 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -65,6 +65,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"; @@ -843,13 +844,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..77a466def --- /dev/null +++ b/packages/cli/src/lib/db/completion-cache.ts @@ -0,0 +1,145 @@ +/** + * 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(); + db.transaction(() => { + // Rank this batch above everything already stored, even when two writes + // land in the same millisecond, so the newest batch always sorts first. + const latest = db + .query( + "SELECT MAX(last_seen) AS latest FROM completion_cache WHERE kind = ? AND org_slug = ?", + ) + .get(kind, orgSlug) as { latest: number | null } | undefined; + const now = Math.max(Date.now(), (latest?.latest ?? 0) + valid.length); + 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 185209854..a96dd3950 100644 --- a/packages/cli/src/lib/db/index.ts +++ b/packages/cli/src/lib/db/index.ts @@ -280,6 +280,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 3c8e59d12..5262eba1a 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, @@ -400,10 +398,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 @@ -429,7 +427,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; @@ -449,10 +447,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) { @@ -482,6 +479,118 @@ 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)), + ); +} + +/** 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/api-client.coverage.test.ts b/packages/cli/test/lib/api-client.coverage.test.ts index 9c53edd89..bd910c2b4 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..067e631ab 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 { @@ -87,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"); @@ -412,3 +433,217 @@ 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"); + }); + + 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", () => { + 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("", ["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" }]); + + 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/completions.property.test.ts b/packages/cli/test/lib/completions.property.test.ts index 4b20d3e14..5ad4b91c9 100644 --- a/packages/cli/test/lib/completions.property.test.ts +++ b/packages/cli/test/lib/completions.property.test.ts @@ -26,7 +26,9 @@ import { } from "../../src/lib/completions.js"; import { COMPLETION_TARGETS } from "../../src/lib/completion-target.js"; import { + extractCompletionTargetPositions, extractCompletionTargets, + extractCompletionValueFlags, type RouteMap, } from "../../src/lib/introspect.js"; import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; @@ -401,16 +403,20 @@ 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); + 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); + expect(generatedTargets).toEqual({ targets, valueFlags, targetPositions }); }); 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/db/auth.test.ts b/packages/cli/test/lib/db/auth.test.ts index 6fb5b3a03..840a2b076 100644 --- a/packages/cli/test/lib/db/auth.test.ts +++ b/packages/cli/test/lib/db/auth.test.ts @@ -246,6 +246,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"); + + 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 3d0e942b8..4bd9e3f08 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"; @@ -22,6 +26,8 @@ import { buildCommandInfo, extractAllRoutes, extractCompletionTargets, + extractCompletionTargetPositions, + extractCompletionValueFlags, extractFlags, extractPositionals, extractRouteGroupCommands, @@ -436,6 +442,8 @@ describe("extractCompletionTargets", () => { expect(extractCompletionTargets(topLevel)).toEqual({ [ORG_TARGET]: ["trial"], [ORG_PROJECT_TARGET]: ["issue list", "issue rules view", "issue view"], + [ISSUE_TARGET]: [], + [DASHBOARD_TARGET]: [], }); }); @@ -450,6 +458,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"], }); }); @@ -462,6 +486,81 @@ 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"], + }); + }); +}); + +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 // ---------------------------------------------------------------------------