Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`[<org>/<project>/]<trace-id>`, 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
Expand Down
24 changes: 19 additions & 5 deletions packages/cli/script/generate-completion-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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}`)
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/dashboard/restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -87,7 +87,7 @@ export const restoreCommand = buildCommand({
parameter: {
placeholder: "org/dashboard",
brief: "[<org/project>] <dashboard-id-or-title>",
parse: parseOrgProjectTarget,
parse: parseDashboardTarget,
},
},
flags: {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/dashboard/revisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -150,7 +150,7 @@ export const revisionsCommand = buildCommand({
parameter: {
placeholder: "org/dashboard",
brief: "[<org/project>] <dashboard-id-or-title>",
parse: parseOrgProjectTarget,
parse: parseDashboardTarget,
},
},
flags: {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/dashboard/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -223,7 +223,7 @@ export const viewCommand = buildCommand({
parameter: {
placeholder: "org/project/dashboard",
brief: "[<org/project>] <dashboard-id-or-title>",
parse: parseOrgProjectTarget,
parse: parseDashboardTarget,
},
},
flags: {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/dashboard/widget/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -97,7 +98,7 @@ export const deleteCommand = buildDeleteCommand(
parameter: {
placeholder: "org/project/dashboard",
brief: "[<org/project>] <dashboard-id-or-title>",
parse: String,
parse: parseDashboardTarget,
},
},
flags: {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/dashboard/widget/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -230,7 +231,7 @@ export const editCommand = buildCommand({
parameter: {
placeholder: "org/project/dashboard",
brief: "[<org/project>] <dashboard-id-or-title>",
parse: String,
parse: parseDashboardTarget,
},
},
flags: {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/issue/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/issue/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -71,7 +71,7 @@ export const issueIdPositional = {
placeholder: "issue",
brief:
"Issue: @latest, @most_frequent, <org>/ID, <org>/<project>#ID, <project>-suffix, ID, or suffix",
parse: parseOrgProjectTarget,
parse: parseIssueTarget,
},
],
} as const;
Expand All @@ -82,7 +82,7 @@ export const issueIdsPositional = {
parameter: {
placeholder: "issue",
brief: "One or more issue IDs",
parse: parseOrgProjectTarget,
parse: parseIssueTarget,
},
} as const;

Expand Down
79 changes: 79 additions & 0 deletions packages/cli/src/lib/api/completion-seed.ts
Original file line number Diff line number Diff line change
@@ -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<SentryIssue, "shortId" | "title">[],
): 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 `<org>/<id>` 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 })),
);
}
16 changes: 13 additions & 3 deletions packages/cli/src/lib/api/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -73,10 +74,12 @@ export async function listDashboardsPaginated(
query: { per_page: options.perPage, cursor: options.cursor },
});

return unwrapPaginatedResult<DashboardListItem[]>(
const page = unwrapPaginatedResult<DashboardListItem[]>(
result,
"Failed to list dashboards",
);
seedDashboardCompletions(orgSlug, page.data);
return page;
}

/**
Expand All @@ -100,10 +103,12 @@ export async function getDashboard(
},
});

return unwrapResult<DashboardDetail>(
const dashboard = unwrapResult<DashboardDetail>(
result,
`Failed to get dashboard '${dashboardId}'`,
);
seedDashboardCompletions(orgSlug, [dashboard]);
return dashboard;
}

/**
Expand All @@ -127,7 +132,12 @@ export async function createDashboard(
>[0]["body"],
});

return unwrapResult<DashboardDetail>(result, "Failed to create dashboard");
const dashboard = unwrapResult<DashboardDetail>(
result,
"Failed to create dashboard",
);
seedDashboardCompletions(orgSlug, [dashboard]);
return dashboard;
}

/**
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/lib/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -196,7 +197,12 @@ export async function listIssuesPaginated(
},
});

return unwrapPaginatedResult<SentryIssue[]>(result, "Failed to list issues");
const page = unwrapPaginatedResult<SentryIssue[]>(
result,
"Failed to list issues",
);
seedIssueCompletions(orgSlug, page.data);
return page;
}

/** Result from {@link listIssuesAllPages}. */
Expand Down Expand Up @@ -322,6 +328,7 @@ export async function getIssueInOrg(
params: options?.collapse ? { collapse: options.collapse } : undefined,
},
);
seedIssueCompletions(orgSlug, [data]);
return data;
}

Expand Down Expand Up @@ -382,6 +389,7 @@ export async function getIssueByShortId(
"Issue not found",
);
}
seedIssueCompletions(orgSlug, [data.group]);
return data.group;
}

Expand Down
Loading
Loading