diff --git a/docs/src/fragments/commands/issue.md b/docs/src/fragments/commands/issue.md index f6e2ec98f..db8b3a5fd 100644 --- a/docs/src/fragments/commands/issue.md +++ b/docs/src/fragments/commands/issue.md @@ -160,3 +160,50 @@ sentry issue merge cli-k9 cli-15h --into cli-k9 # alias form # Cross-org merges are rejected — all issues must share an organization # Non-error issue types (performance, info, etc.) cannot be merged ``` + +### Archive and ignore issues + +Archive an issue to suppress alerts. Without `--until`, the issue is archived +forever. Use `--until` to set a condition for automatic unarchival: + +```bash +# Archive forever (fully silenced) +sentry issue archive CLI-G5 + +# Smart detection — unarchives when Sentry detects a spike in event frequency +sentry issue archive CLI-G5 --until auto + +# Duration-based +sentry issue archive CLI-G5 --until 1h # 1 hour +sentry issue archive CLI-G5 --until 7d # 7 days +sentry issue archive CLI-G5 --until 2026-12-31 # specific date + +# Count-based — unarchive after N more events +sentry issue archive CLI-G5 --until 100x + +# User-based — unarchive after N more users affected +sentry issue archive CLI-G5 --until 10u + +# Compound — count within a time window +sentry issue archive CLI-G5 --until 100x/1h # 100 events within 1 hour +sentry issue archive CLI-G5 --until 10u/1d # 10 users within 1 day + +# Verbose forms also work +sentry issue archive CLI-G5 --until 10events/2hours + +# 'ignore' is an alias for 'archive' +sentry issue ignore CLI-G5 --until auto +``` + +:::tip[`--until` syntax reference] +| Format | Meaning | +|--------|---------| +| `auto` | Unarchive on event frequency spike (recommended) | +| `30m`, `1h`, `7d`, `1w` | Duration (minutes, hours, days, weeks) | +| `2026-05-15` | Absolute date (computed as time delta) | +| `10x` or `10events` | After 10 more events | +| `10u` or `10users` | After 10 more users affected | +| `10x/5m` | 10 events within 5 minutes | +| `10users/2hours` | 10 users within 2 hours | +| *(omitted)* | Archive forever | +::: diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 22616d27e..9a359a9cc 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -210,12 +210,38 @@ Reopen a resolved issue Archive (ignore) an issue **Flags:** -- `--until-escalating - Archive until the issue escalates (spikes in frequency)` -- `--duration - Ignore for this many minutes` -- `--count - Ignore until this many more events occur` -- `--window - Time window in minutes for --count (events must occur within this window)` -- `--users - Ignore until this many more users are affected` -- `--user-window - Time window in minutes for --users (users must be affected within this window)` +- `-u, --until - Condition for unarchival: auto, 30m, 10x, 10u, 10x/5m, etc.` + +**Examples:** + +```bash +# Archive forever (fully silenced) +sentry issue archive CLI-G5 + +# Smart detection — unarchives when Sentry detects a spike in event frequency +sentry issue archive CLI-G5 --until auto + +# Duration-based +sentry issue archive CLI-G5 --until 1h # 1 hour +sentry issue archive CLI-G5 --until 7d # 7 days +sentry issue archive CLI-G5 --until 2026-12-31 # specific date + +# Count-based — unarchive after N more events +sentry issue archive CLI-G5 --until 100x + +# User-based — unarchive after N more users affected +sentry issue archive CLI-G5 --until 10u + +# Compound — count within a time window +sentry issue archive CLI-G5 --until 100x/1h # 100 events within 1 hour +sentry issue archive CLI-G5 --until 10u/1d # 10 users within 1 day + +# Verbose forms also work +sentry issue archive CLI-G5 --until 10events/2hours + +# 'ignore' is an alias for 'archive' +sentry issue ignore CLI-G5 --until auto +``` ### `sentry issue merge ` diff --git a/src/commands/issue/archive.ts b/src/commands/issue/archive.ts index b983a7253..ce9f61d5e 100644 --- a/src/commands/issue/archive.ts +++ b/src/commands/issue/archive.ts @@ -15,89 +15,304 @@ import { ValidationError } from "../../lib/errors.js"; import { formatIssueDetails, muted } from "../../lib/formatters/index.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; +import { parseRelativeParts, UNIT_SECONDS } from "../../lib/time-range.js"; import type { SentryIssue } from "../../types/index.js"; import type { IssueSubstatus } from "../../types/sentry.js"; import { issueIdPositional, resolveIssue } from "./utils.js"; const log = logger.withTag("issue.archive"); -/** Parse a flag value as a positive integer (≥ 1). */ -function parsePositiveInt(raw: string): number { - const n = Number(raw); - if (!Number.isInteger(n) || n < 1) { - throw new ValidationError(`expected a positive integer, got '${raw}'`); - } - return n; -} - const COMMAND = "archive"; -type ArchiveFlags = { - readonly json: boolean; - readonly fields?: string[]; - readonly "until-escalating"?: boolean; - readonly duration?: number; - readonly count?: number; - readonly window?: number; - readonly users?: number; - readonly "user-window"?: number; +// ── --until parser ───────────────────────────────────────────────── + +/** Verbose aliases for duration units. Short forms are handled by parseRelativeParts. */ +const DURATION_ALIASES: Record = { + min: "m", + mins: "m", + minute: "m", + minutes: "m", + hour: "h", + hours: "h", + day: "d", + days: "d", + week: "w", + weeks: "w", }; -function formatArchived(issue: SentryIssue): string { - return `${muted("Archived")}\n\n${formatIssueDetails(issue)}`; +/** Verbose aliases for count/user suffixes. */ +const COUNT_ALIASES: Record = { + events: "x", + event: "x", + users: "u", + user: "u", +}; + +/** Matches `` for verbose durations and counts. */ +const NUMERIC_WORD_RE = /^(\d+)\s*([a-z]+)$/i; + +/** Parsed result of a --until value. */ +export type UntilSpec = + | { kind: "escalating" } + | { kind: "duration"; minutes: number } + | { kind: "count"; count: number; windowMinutes?: number } + | { kind: "users"; users: number; windowMinutes?: number }; + +/** Try parsing as a short-form duration (e.g. "30m", "1h"). */ +function tryShortDuration(raw: string): number | undefined { + const parts = parseRelativeParts(raw); + if (!parts) { + return; + } + const secs = UNIT_SECONDS[parts.unit]; + if (secs === undefined) { + return; + } + const totalMinutes = Math.ceil((parts.value * secs) / 60); + return totalMinutes >= 1 ? totalMinutes : undefined; } -/** Validate flag dependencies and compute substatus + statusDetails. */ -function resolveArchiveOptions(flags: ArchiveFlags): { - substatus: IssueSubstatus; - statusDetails?: IgnoreStatusDetails; -} { - if (flags.window !== undefined && flags.count === undefined) { +/** Try parsing as a verbose duration (e.g. "30minutes", "2hours"). */ +function tryVerboseDuration(raw: string): number | undefined { + const m = NUMERIC_WORD_RE.exec(raw); + if (!m) { + return; + } + const num = Number(m[1]); + const word = m[2]?.toLowerCase() ?? ""; + const unit = DURATION_ALIASES[word]; + if (!(unit && Number.isInteger(num)) || num < 1) { + return; + } + const secs = UNIT_SECONDS[unit]; + return secs !== undefined ? Math.ceil((num * secs) / 60) : undefined; +} + +/** Try parsing as an absolute ISO date, returning delta in minutes from now. */ +function tryAbsoluteDate(raw: string): number | undefined { + const ts = Date.parse(raw); + if (Number.isNaN(ts)) { + return; + } + const deltaMinutes = Math.ceil((ts - Date.now()) / 60_000); + if (deltaMinutes < 1) { throw new ValidationError( - "--window requires --count (time window is only meaningful with an event count threshold)" + `--until date must be in the future, got '${raw}'` ); } - if (flags["user-window"] !== undefined && flags.users === undefined) { + return deltaMinutes; +} + +/** + * Parse a duration string into minutes. + * + * Accepts short (`30m`, `1h`, `7d`), verbose (`30minutes`, `2hours`), + * and ISO date strings resolved relative to now. + */ +function parseDurationMinutes(raw: string): number | undefined { + return ( + tryShortDuration(raw) ?? tryVerboseDuration(raw) ?? tryAbsoluteDate(raw) + ); +} + +/** Try parsing a single-char count suffix: "10x", "10u". */ +function tryShortCount( + raw: string +): { type: "x" | "u"; value: number } | undefined { + const lastChar = raw.at(-1)?.toLowerCase(); + if (lastChar !== "x" && lastChar !== "u") { + return; + } + const num = Number(raw.slice(0, -1)); + if (Number.isInteger(num) && num >= 1) { + return { type: lastChar, value: num }; + } + return; +} + +/** Try parsing a verbose count: "10events", "10users". */ +function tryVerboseCount( + raw: string +): { type: "x" | "u"; value: number } | undefined { + const m = NUMERIC_WORD_RE.exec(raw); + if (!m) { + return; + } + const num = Number(m[1]); + const word = m[2]?.toLowerCase() ?? ""; + const suffix = COUNT_ALIASES[word]; + if (suffix && Number.isInteger(num) && num >= 1) { + return { type: suffix, value: num }; + } + return; +} + +/** + * Parse a single atom: count (`10x`), user count (`10u`), or duration. + */ +function parseAtom( + raw: string +): + | { type: "x" | "u"; value: number } + | { type: "duration"; minutes: number } + | undefined { + const count = tryShortCount(raw) ?? tryVerboseCount(raw); + if (count) { + return count; + } + const minutes = parseDurationMinutes(raw); + if (minutes !== undefined) { + return { type: "duration", minutes }; + } + return; +} + +/** Parse a slash-separated pair like "10x/5m" into a count+window spec. */ +function parseSlashPair(raw: string, trimmed: string): UntilSpec { + const slashIdx = trimmed.indexOf("/"); + const left = trimmed.slice(0, slashIdx).trim(); + const right = trimmed.slice(slashIdx + 1).trim(); + if (!(left && right)) { throw new ValidationError( - "--user-window requires --users (time window is only meaningful with a user count threshold)" + `invalid --until format: '${raw}' (expected '/', e.g., '10x/5m')` ); } - const hasConditionFlags = - flags.duration !== undefined || - flags.count !== undefined || - flags.users !== undefined; + const leftAtom = parseAtom(left); + const rightAtom = parseAtom(right); - if (flags["until-escalating"] && hasConditionFlags) { + if (!(leftAtom && rightAtom)) { throw new ValidationError( - "--until-escalating cannot be combined with --duration, --count, or --users" + `invalid --until format: '${raw}' (expected '/', e.g., '10x/5m')` ); } - if (flags["until-escalating"]) { - return { substatus: "archived_until_escalating" }; + if (leftAtom.type === "duration") { + throw new ValidationError( + `invalid --until format: '${raw}' (left of '/' must be a count like '10x' or '10u', not a duration)` + ); } - if (!hasConditionFlags) { - return { substatus: "archived_forever" }; + if (rightAtom.type !== "duration") { + throw new ValidationError( + `invalid --until format: '${raw}' (right of '/' must be a duration like '5m' or '2h', not a count)` + ); } - const details: IgnoreStatusDetails = {}; - if (flags.duration !== undefined) { - details.ignoreDuration = flags.duration; + if (leftAtom.type === "x") { + return { + kind: "count", + count: leftAtom.value, + windowMinutes: rightAtom.minutes, + }; } - if (flags.count !== undefined) { - details.ignoreCount = flags.count; + return { + kind: "users", + users: leftAtom.value, + windowMinutes: rightAtom.minutes, + }; +} + +/** Build the error for unrecognized --until values with usage hints. */ +function throwUnrecognizedUntil(raw: string): never { + throw new ValidationError( + `invalid --until value: '${raw}'\n\n` + + "Expected one of:\n" + + " auto Archive until Sentry detects a spike\n" + + " 30m, 1h, 7d Archive for a duration\n" + + " 2026-05-15 Archive until a date\n" + + " 10x Archive until 10 more events\n" + + " 10u Archive until 10 more users\n" + + " 10x/5m 10 events within 5 minutes\n" + + " 10events/2hours Same, verbose form" + ); +} + +/** + * Parse the `--until` flag value into a structured spec. + * + * Grammar: + * ``` + * until := "auto" | "escalating" | atom | atom "/" atom + * atom := "x" | "u" | "events" | "users" + * | | + * ``` + */ +export function parseUntilSpec(raw: string): UntilSpec { + const trimmed = raw.trim(); + const lower = trimmed.toLowerCase(); + + if (lower === "auto" || lower === "escalating") { + return { kind: "escalating" }; } - if (flags.window !== undefined) { - details.ignoreWindow = flags.window; + + if (trimmed.includes("/")) { + return parseSlashPair(raw, trimmed); } - if (flags.users !== undefined) { - details.ignoreUserCount = flags.users; + + const atom = parseAtom(trimmed); + if (!atom) { + throwUnrecognizedUntil(raw); } - if (flags["user-window"] !== undefined) { - details.ignoreUserWindow = flags["user-window"]; + + if (atom.type === "duration") { + return { kind: "duration", minutes: atom.minutes }; + } + if (atom.type === "x") { + return { kind: "count", count: atom.value }; + } + return { kind: "users", users: atom.value }; +} + +// ── Command ──────────────────────────────────────────────────────── + +type ArchiveFlags = { + readonly json: boolean; + readonly fields?: string[]; + readonly until?: string; +}; + +function formatArchived(issue: SentryIssue): string { + return `${muted("Archived")}\n\n${formatIssueDetails(issue)}`; +} + +/** Convert a parsed --until spec into API parameters. */ +function specToApiOptions(spec: UntilSpec): { + substatus: IssueSubstatus; + statusDetails?: IgnoreStatusDetails; +} { + switch (spec.kind) { + case "escalating": + return { substatus: "archived_until_escalating" }; + case "duration": + return { + substatus: "archived_until_condition_met", + statusDetails: { ignoreDuration: spec.minutes }, + }; + case "count": + return { + substatus: "archived_until_condition_met", + statusDetails: { + ignoreCount: spec.count, + ...(spec.windowMinutes !== undefined + ? { ignoreWindow: spec.windowMinutes } + : {}), + }, + }; + case "users": + return { + substatus: "archived_until_condition_met", + statusDetails: { + ignoreUserCount: spec.users, + ...(spec.windowMinutes !== undefined + ? { ignoreUserWindow: spec.windowMinutes } + : {}), + }, + }; + default: { + const _exhaustive: never = spec; + return _exhaustive; + } } - return { substatus: "archived_until_condition_met", statusDetails: details }; } export const archiveCommand = buildCommand({ @@ -105,17 +320,31 @@ export const archiveCommand = buildCommand({ brief: "Archive (ignore) an issue", fullDescription: "Archive an issue, suppressing alerts until an optional condition is met.\n\n" + - "Archive modes:\n" + - " (no flags) Archive forever\n" + - " --until-escalating Archive until a spike in event frequency\n" + - " --duration Archive for a fixed time period\n" + - " --count/--users Archive until a threshold is reached\n\n" + + "Without --until, the issue is archived forever (equivalent to 'Archive Forever'\n" + + "in the Sentry UI). Use --until to control when the issue automatically unarchives.\n\n" + + "Modes:\n" + + " (no --until) Archive forever — fully silenced, no automatic unarchival\n" + + " --until auto Smart detection — unarchives when Sentry detects a spike in\n" + + " event frequency (recommended for most use cases)\n" + + " --until