Skip to content

Commit 62dfbef

Browse files
committed
feat: magic @ selectors (@latest, @most_frequent) for issue commands
Add magic `@` selectors that resolve to issues dynamically: - `@latest` → issue with most recent `lastSeen` timestamp - `@most_frequent` → issue with highest event frequency Supports bare selectors (`@latest`) and org-prefixed (`sentry/@latest`). Case-insensitive with alternative spellings (`@mostfrequent`, `@most-frequent`). Works with all issue commands: `issue view`, `issue explain`, `issue plan`. Fixes #45
1 parent 6a36176 commit 62dfbef

File tree

10 files changed

+531
-28
lines changed

10 files changed

+531
-28
lines changed

AGENTS.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -629,11 +629,17 @@ mock.module("./some-module", () => ({
629629
### Architecture
630630

631631
<!-- lore:019cbeba-e4d3-748c-ad50-fe3c3d5c0a0d -->
632-
* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Authentication in \`src/lib/db/auth.ts\` follows layered precedence: \`SENTRY\_AUTH\_TOKEN\` env var > \`SENTRY\_TOKEN\` env var > SQLite-stored OAuth token. \`getEnvToken()\` reads env vars with \`.trim()\` (empty/whitespace = unset). \`AuthSource\` type tracks provenance: \`"env:SENTRY\_AUTH\_TOKEN"\` | \`"env:SENTRY\_TOKEN"\` | \`"oauth"\`. \`ENV\_SOURCE\_PREFIX = "env:"\` constant is used for parsing source strings (use \`.length\` not hardcoded 4). \`getActiveEnvVarName()\` is the shared helper for commands needing the env var name — calls \`getEnvToken()\` directly (no DB fallback). Env tokens bypass all refresh/expiry logic. \`isEnvTokenActive()\` is the guard for auth commands. Logout must NOT clear stored auth when env token active — just inform user to unset the env var. \`getEnvToken\`/\`isEnvTokenActive\` stay in \`db/auth.ts\` despite not touching DB, because they're tightly coupled with \`getAuthToken\`/\`getAuthConfig\`/\`refreshToken\`.
632+
* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth in \`src/lib/db/auth.ts\` follows layered precedence: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. These functions stay in \`db/auth.ts\` despite not touching DB because they're tightly coupled with token retrieval.
633633
634634
<!-- lore:019cbaa2-e4a2-76c0-8f64-917a97ae20c5 -->
635635
* **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: Consola is the CLI logger with Sentry createConsolaReporter integration. Two reporters: FancyReporter (stderr) + Sentry structured logs. Level via \`SENTRY\_LOG\_LEVEL\`: error=0, warn=1, info=3, debug=4, trace=5. \`buildCommand\` in \`src/lib/command.ts\` injects hidden \`--log-level\`/\`--verbose\` flags, strips before calling handler. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates via registry. All user-facing output must use consola, not raw stderr. HandlerContext intentionally omits stderr.
636636
637+
<!-- lore:019cce8d-f2c5-726e-8a04-3f48caba45ec -->
638+
* **Input validation layer: src/lib/input-validation.ts guards CLI arg parsing**: Four validators in \`src/lib/input-validation.ts\` guard against agent-hallucinated inputs: \`rejectControlChars\` (ASCII < 0x20), \`rejectPreEncoded\` (%XX), \`validateResourceId\` (rejects ?, #, %, whitespace), \`validateEndpoint\` (rejects \`..\` traversal). Applied in \`parseSlashOrgProject\`, bare-slug path in \`parseOrgProjectArg\`, \`parseIssueArg\`, and \`normalizeEndpoint\` (api.ts). NOT applied in \`parseSlashSeparatedArg\` for no-slash plain IDs — those may contain structural separators (newlines for log view batch IDs) that callers split downstream. Validation targets user-facing parse boundaries only; env vars and DB cache values are trusted.
639+
640+
<!-- lore:019cd2d1-aa47-7fc1-92f9-cc6c49b19460 -->
641+
* **Magic @ selectors resolve issues dynamically via sort-based list API queries**: Magic \`@\` selectors (\`@latest\`, \`@most\_frequent\`) in \`parseIssueArg\` are detected early (before \`validateResourceId\`) because \`@\` is not in the forbidden charset. \`SELECTOR\_MAP\` provides case-insensitive matching with common variations (\`@mostfrequent\`, \`@most-frequent\`). Resolution in \`resolveSelector\` (issue/utils.ts) maps selectors to \`IssueSort\` values (\`date\`, \`freq\`), calls \`listIssuesPaginated\` with \`perPage: 1\` and \`query: 'is:unresolved'\`. Supports org-prefixed form: \`sentry/@latest\`. Unrecognized \`@\`-prefixed strings fall through to suffix-only parsing (not an error). The \`ParsedIssueArg\` union includes \`{ type: 'selector'; selector: IssueSelector; org?: string }\`.
642+
637643
### Gotcha
638644
639645
<!-- lore:019cc484-f0e1-7016-a851-177fb9ad2cc4 -->
@@ -642,12 +648,6 @@ mock.module("./some-module", () => ({
642648
<!-- lore:019cc40e-e56e-71e9-bc5d-545f97df732b -->
643649
* **Consola prompt cancel returns truthy Symbol, not false**: When a user cancels a \`consola\` / \`@clack/prompts\` confirmation prompt (Ctrl+C), the return value is \`Symbol(clack:cancel)\`, not \`false\`. Since Symbols are truthy in JavaScript, checking \`!confirmed\` will be \`false\` and the code falls through as if the user confirmed. Fix: use \`confirmed !== true\` (strict equality) instead of \`!confirmed\` to correctly handle cancel, false, and any other non-true values.
644650
645-
<!-- lore:019cc484-f0e7-7a64-bea1-f3f98e9c56c1 -->
646-
* **Craft v2 GitHub App must be installed per-repo**: The Craft v2 release/publish workflows use \`actions/create-github-app-token@v1\` which requires the GitHub App to be installed on the specific repository. If the app is configured for "Only select repositories", adding a new repo to the Craft pipeline requires manually adding it at GitHub Settings → Installations → \[App] → Configure. The \`APP\_ID\` variable and \`APP\_PRIVATE\_KEY\` secret are set in the \`production\` environment, not at repo level. Symptom: 404 on \`GET /repos/{owner}/{repo}/installation\`.
647-
648-
<!-- lore:019cc303-e399-7390-a693-46b68354c15f -->
649-
* **Sentry.setContext persists on scope — stale data across retries**: \`Sentry.setContext(key, data)\` sets context on the current scope and persists until overwritten. In retry/polling loops (e.g., follow mode), if an error sets diagnostic context and a subsequent iteration succeeds, the stale context remains attached to future events from that scope. Low severity for one-shot throw-on-failure patterns like \`apiRequestToRegion\`, but worth noting for long-lived scopes with intermittent failures.
650-
651651
<!-- lore:019cbe0d-d03e-716c-b372-b09998c07ed6 -->
652652
* **Stricli rejects unknown flags — pre-parsed global flags must be consumed from argv**: Stricli's arg parser is strict: any \`--flag\` not registered on a command throws \`No flag registered for --flag\`. Global flags (parsed before Stricli in bin.ts) MUST be spliced out of argv. \`--log-level\` was correctly consumed but \`--verbose\` was intentionally left in (for the \`api\` command's own \`--verbose\`). This breaks every other command. Also, \`argv.indexOf('--flag')\` doesn't match \`--flag=value\` form — must check both space-separated and equals-sign forms when pre-parsing. A Biome \`noRestrictedImports\` lint rule in \`biome.jsonc\` now blocks \`import { buildCommand } from "@stricli/core"\` at error level — only \`src/lib/command.ts\` is exempted. Other \`@stricli/core\` exports (\`buildRouteMap\`, \`run\`, etc.) are allowed.
653653
@@ -656,6 +656,9 @@ mock.module("./some-module", () => ({
656656
657657
### Pattern
658658
659+
<!-- lore:019cce8d-f2d6-7862-9105-7a0048f0e993 -->
660+
* **Property-based tests for input validators use stringMatching for forbidden char coverage**: In \`test/lib/input-validation.property.test.ts\`, forbidden-character arbitraries are built with \`stringMatching\` targeting specific regex patterns (e.g., \`/^\[^\x00-\x1f]\*\[\x00-\x1f]\[^\x00-\x1f]\*$/\` for control chars). This ensures fast-check generates strings that always contain the forbidden character while varying surrounding content. The \`biome-ignore lint/suspicious/noControlCharactersInRegex\` suppression is needed on the control char regex constant in \`input-validation.ts\`.
661+
659662
<!-- lore:019cbe44-7687-7288-81a2-662feefc28ea -->
660663
* **SKILL.md generator must filter hidden Stricli flags**: \`script/generate-skill.ts\` introspects Stricli's route tree to auto-generate \`plugins/sentry-cli/skills/sentry-cli/SKILL.md\`. The \`FlagDef\` type must include \`hidden?: boolean\` and \`extractFlags\` must propagate it to \`FlagInfo\`. The filter in \`generateCommandDoc\` must exclude \`f.hidden\` alongside \`help\`/\`helpAll\`. Without this, hidden flags injected by \`buildCommand\` (like \`--log-level\`, \`--verbose\`) appear on every command in the AI agent skill file. Global flags should instead be documented once in \`docs/src/content/docs/commands/index.md\` Global Options section, which the generator pulls into SKILL.md via \`loadCommandsOverview\`.
661664
<!-- End lore-managed section -->

src/commands/issue/explain.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,16 @@ export const explainCommand = buildCommand({
4242
"The analysis may take a few minutes for new issues.\n" +
4343
"Use --force to trigger a fresh analysis even if one already exists.\n\n" +
4444
"Issue formats:\n" +
45-
" <org>/ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" +
45+
" @latest - Most recent unresolved issue\n" +
46+
" @most_frequent - Issue with highest event frequency\n" +
47+
" <org>/ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" +
48+
" <org>/@selector - Selector with org: my-org/@latest\n" +
4649
" <project>-suffix - Project + suffix: cli-G, spotlight-electron-4Y\n" +
47-
" ID - Short ID: CLI-G (searches across orgs)\n" +
48-
" suffix - Suffix only: G (requires DSN context)\n" +
49-
" numeric - Numeric ID: 123456789\n\n" +
50+
" ID - Short ID: CLI-G (searches across orgs)\n" +
51+
" suffix - Suffix only: G (requires DSN context)\n" +
52+
" numeric - Numeric ID: 123456789\n\n" +
5053
"Examples:\n" +
54+
" sentry issue explain @latest\n" +
5155
" sentry issue explain 123456789\n" +
5256
" sentry issue explain sentry/EXTENSION-7\n" +
5357
" sentry issue explain cli-G\n" +

src/commands/issue/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,14 @@ export const issueRoute = buildRouteMap({
1919
" list List issues in a project\n" +
2020
" view View details of a specific issue\n" +
2121
" explain Analyze an issue using Seer AI\n" +
22-
" plan Generate a solution plan using Seer AI",
22+
" plan Generate a solution plan using Seer AI\n\n" +
23+
"Magic selectors (available for view, explain, plan):\n" +
24+
" @latest Most recent unresolved issue\n" +
25+
" @most_frequent Issue with the highest event frequency\n\n" +
26+
"Examples:\n" +
27+
" sentry issue view @latest\n" +
28+
" sentry issue explain @most_frequent\n" +
29+
" sentry issue plan my-org/@latest",
2330
hideRoute: {},
2431
},
2532
});

src/commands/issue/plan.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,19 @@ export const planCommand = buildCommand({
147147
"If multiple root causes are identified, use --cause to specify which one.\n" +
148148
"Use --force to regenerate a plan even if one already exists.\n\n" +
149149
"Issue formats:\n" +
150-
" <org>/ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" +
150+
" @latest - Most recent unresolved issue\n" +
151+
" @most_frequent - Issue with highest event frequency\n" +
152+
" <org>/ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" +
153+
" <org>/@selector - Selector with org: my-org/@latest\n" +
151154
" <project>-suffix - Project + suffix: cli-G, spotlight-electron-4Y\n" +
152-
" ID - Short ID: CLI-G (searches across orgs)\n" +
153-
" suffix - Suffix only: G (requires DSN context)\n" +
154-
" numeric - Numeric ID: 123456789\n\n" +
155+
" ID - Short ID: CLI-G (searches across orgs)\n" +
156+
" suffix - Suffix only: G (requires DSN context)\n" +
157+
" numeric - Numeric ID: 123456789\n\n" +
155158
"Prerequisites:\n" +
156159
" - GitHub integration configured for your organization\n" +
157160
" - Code mappings set up for your project\n\n" +
158161
"Examples:\n" +
162+
" sentry issue plan @latest --cause 0\n" +
159163
" sentry issue plan 123456789 --cause 0\n" +
160164
" sentry issue plan sentry/EXTENSION-7 --cause 1\n" +
161165
" sentry issue plan cli-G --cause 0\n" +

src/commands/issue/utils.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import {
1010
getIssue,
1111
getIssueByShortId,
1212
getIssueInOrg,
13+
type IssueSort,
14+
listIssuesPaginated,
1315
triggerRootCauseAnalysis,
1416
} from "../../lib/api-client.js";
15-
import { parseIssueArg } from "../../lib/arg-parsing.js";
17+
import { type IssueSelector, parseIssueArg } from "../../lib/arg-parsing.js";
1618
import { getProjectByAlias } from "../../lib/db/project-aliases.js";
1719
import { detectAllDsns } from "../../lib/dsn/index.js";
1820
import { ApiError, ContextError, ResolutionError } from "../../lib/errors.js";
@@ -33,7 +35,7 @@ export const issueIdPositional = {
3335
{
3436
placeholder: "issue",
3537
brief:
36-
"Issue: <org>/ID, <project>-suffix, ID, or suffix (e.g., sentry/CLI-G, cli-G, CLI-G, G)",
38+
"Issue: @latest, @most_frequent, <org>/ID, <project>-suffix, ID, or suffix",
3739
parse: String,
3840
},
3941
],
@@ -51,6 +53,10 @@ export const issueIdPositional = {
5153
* @param issueId - The user-provided issue ID
5254
*/
5355
export function buildCommandHint(command: string, issueId: string): string {
56+
// Selectors already include the @ prefix and are self-contained
57+
if (issueId.startsWith("@")) {
58+
return `sentry issue ${command} <org>/${issueId}`;
59+
}
5460
// Numeric IDs always need org context - can't be combined with project
5561
if (isAllDigits(issueId)) {
5662
return `sentry issue ${command} <org>/${issueId}`;
@@ -231,6 +237,83 @@ function resolveExplicitOrgSuffix(
231237
);
232238
}
233239

240+
/**
241+
* Map magic selectors to Sentry issue list sort parameters.
242+
*
243+
* `@latest` → `"date"` (most recent `lastSeen` timestamp)
244+
* `@most_frequent` → `"freq"` (highest event frequency)
245+
*/
246+
const SELECTOR_SORT_MAP: Record<IssueSelector, IssueSort> = {
247+
"@latest": "date",
248+
"@most_frequent": "freq",
249+
};
250+
251+
/**
252+
* Human-readable labels for selectors (used in error messages).
253+
*/
254+
const SELECTOR_LABELS: Record<IssueSelector, string> = {
255+
"@latest": "most recent",
256+
"@most_frequent": "most frequent",
257+
};
258+
259+
/**
260+
* Resolve a magic `@` selector to the top matching issue.
261+
*
262+
* Fetches the issue list sorted by the selector's criteria and returns
263+
* the first result. Requires organization context (explicit or auto-detected).
264+
*
265+
* @param selector - The magic selector (e.g., `@latest`, `@most_frequent`)
266+
* @param explicitOrg - Optional explicit org slug from `org/@selector` format
267+
* @param cwd - Current working directory for context resolution
268+
* @param commandHint - Hint for error messages
269+
* @returns The resolved issue with org context
270+
* @throws {ContextError} When organization cannot be resolved
271+
* @throws {ResolutionError} When no issues match the selector
272+
*/
273+
async function resolveSelector(
274+
selector: IssueSelector,
275+
explicitOrg: string | undefined,
276+
cwd: string,
277+
commandHint: string
278+
): Promise<StrictResolvedIssue> {
279+
// Resolve org: explicit from `org/@latest` or auto-detected from DSN/defaults
280+
let orgSlug: string;
281+
if (explicitOrg) {
282+
orgSlug = await resolveEffectiveOrg(explicitOrg);
283+
} else {
284+
const resolved = await resolveOrg({ cwd });
285+
if (!resolved) {
286+
throw new ContextError("Organization", commandHint);
287+
}
288+
orgSlug = resolved.org;
289+
}
290+
291+
const sort = SELECTOR_SORT_MAP[selector];
292+
const label = SELECTOR_LABELS[selector];
293+
294+
// Fetch just the top issue with the appropriate sort
295+
const { data: issues } = await listIssuesPaginated(orgSlug, "", {
296+
sort,
297+
perPage: 1,
298+
query: "is:unresolved",
299+
});
300+
301+
const issue = issues[0];
302+
if (!issue) {
303+
throw new ResolutionError(
304+
`Selector '${selector}'`,
305+
"no unresolved issues found",
306+
commandHint,
307+
[
308+
`No unresolved issues found in org '${orgSlug}'.`,
309+
`The ${label} issue selector only matches unresolved issues.`,
310+
]
311+
);
312+
}
313+
314+
return { org: orgSlug, issue };
315+
}
316+
234317
/**
235318
* Options for resolving an issue ID.
236319
*/
@@ -304,6 +387,7 @@ async function resolveNumericIssue(
304387
* Resolve an issue ID to organization slug and full issue object.
305388
*
306389
* Supports all issue ID formats (now parsed by parseIssueArg in arg-parsing.ts):
390+
* - selector: "@latest", "sentry/@most_frequent" → top issue by criteria
307391
* - explicit: "sentry/cli-G" → org + project + suffix
308392
* - explicit-org-suffix: "sentry/G" → org + suffix (needs DSN for project)
309393
* - explicit-org-numeric: "sentry/123456789" → org + numeric ID
@@ -376,6 +460,10 @@ export async function resolveIssue(
376460
// Just suffix - need DSN for org and project
377461
return resolveSuffixOnly(parsed.suffix, cwd, commandHint);
378462

463+
case "selector":
464+
// Magic @ selector - fetch top issue by sort criteria
465+
return resolveSelector(parsed.selector, parsed.org, cwd, commandHint);
466+
379467
default: {
380468
// Exhaustive check - this should never be reached
381469
const _exhaustive: never = parsed;

src/commands/issue/view.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,14 @@ export const viewCommand = buildCommand({
8484
"View detailed information about a Sentry issue by its ID or short ID. " +
8585
"The latest event is automatically included for full context.\n\n" +
8686
"Issue formats:\n" +
87-
" <org>/ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" +
87+
" @latest - Most recent unresolved issue\n" +
88+
" @most_frequent - Issue with highest event frequency\n" +
89+
" <org>/ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" +
90+
" <org>/@selector - Selector with org: my-org/@latest\n" +
8891
" <project>-suffix - Project + suffix: cli-G, spotlight-electron-4Y\n" +
89-
" ID - Short ID: CLI-G (searches across orgs)\n" +
90-
" suffix - Suffix only: G (requires DSN context)\n" +
91-
" numeric - Numeric ID: 123456789\n\n" +
92+
" ID - Short ID: CLI-G (searches across orgs)\n" +
93+
" suffix - Suffix only: G (requires DSN context)\n" +
94+
" numeric - Numeric ID: 123456789\n\n" +
9295
"In multi-project mode (after 'issue list'), use alias-suffix format (e.g., 'f-g' " +
9396
"where 'f' is the project alias shown in the list).",
9497
},

0 commit comments

Comments
 (0)