From 4c3baca8dde9b9695aedb1bb172274df528abf0d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 30 Apr 2026 23:28:06 +0000 Subject: [PATCH 1/4] feat(issue): replace individual archive flags with unified --until Replace --until-escalating, --duration, --count, --window, --users, --user-window with a single --until flag that accepts a compact grammar: --until auto Sentry's smart escalation detection --until 30m / 1h / 7d Duration (reuses parsePeriod units) --until 2026-05-15 Absolute date (computed as delta) --until 10x Event count threshold --until 10u User count threshold --until 10x/5m Count within time window --until 10events/2hours Verbose form Supports both terse (x/u/m/h/d) and verbose (events/users/minutes/hours) suffixes. 'auto' and 'escalating' are both accepted as keywords. Exports UNIT_SECONDS and parseRelativeParts from time-range.ts for reuse. --- .../skills/sentry-cli/references/issue.md | 7 +- src/commands/issue/archive.ts | 386 +++++++++++++----- src/lib/time-range.ts | 6 +- test/commands/issue/archive.func.test.ts | 255 +++++++----- 4 files changed, 455 insertions(+), 199 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 22616d27e..9839aab0c 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -210,12 +210,7 @@ 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.` ### `sentry issue merge ` diff --git a/src/commands/issue/archive.ts b/src/commands/issue/archive.ts index b983a7253..2cbf70f63 100644 --- a/src/commands/issue/archive.ts +++ b/src/commands/issue/archive.ts @@ -15,89 +15,302 @@ 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: + return { substatus: "archived_forever" }; } - return { substatus: "archived_until_condition_met", statusDetails: details }; } export const archiveCommand = buildCommand({ @@ -105,17 +318,21 @@ 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. Use --until to set\n" + + "a condition for automatic unarchival:\n\n" + + " --until auto Archive until Sentry detects a spike\n" + + " --until 30m Archive for 30 minutes\n" + + " --until 7d Archive for 7 days\n" + + " --until 2026-12-31 Archive until a specific date\n" + + " --until 10x Archive until 10 more events\n" + + " --until 10u Archive until 10 more users affected\n" + + " --until 10x/5m Archive until 10 events within 5 minutes\n" + + " --until 10users/2hours Same, verbose form\n\n" + "Examples:\n" + " sentry issue archive CLI-12Z\n" + - " sentry issue archive CLI-12Z --until-escalating\n" + - " sentry issue archive CLI-12Z --duration 60\n" + - " sentry issue archive CLI-12Z --count 100 --window 60\n" + - " sentry issue archive CLI-12Z --users 10", + " sentry issue archive CLI-12Z --until auto\n" + + " sentry issue archive CLI-12Z --until 1h\n" + + " sentry issue archive CLI-12Z --until 100x/1h", }, output: { human: formatArchived, @@ -123,48 +340,25 @@ export const archiveCommand = buildCommand({ parameters: { positional: issueIdPositional, flags: { - "until-escalating": { - kind: "boolean", - brief: "Archive until the issue escalates (spikes in frequency)", - optional: true, - default: false, - }, - duration: { - kind: "parsed", - parse: parsePositiveInt, - brief: "Ignore for this many minutes", - optional: true, - }, - count: { - kind: "parsed", - parse: parsePositiveInt, - brief: "Ignore until this many more events occur", - optional: true, - }, - window: { + until: { kind: "parsed", - parse: parsePositiveInt, - brief: - "Time window in minutes for --count (events must occur within this window)", - optional: true, - }, - users: { - kind: "parsed", - parse: parsePositiveInt, - brief: "Ignore until this many more users are affected", - optional: true, - }, - "user-window": { - kind: "parsed", - parse: parsePositiveInt, - brief: - "Time window in minutes for --users (users must be affected within this window)", + parse: String, + brief: "Condition for unarchival: auto, 30m, 10x, 10u, 10x/5m, etc.", optional: true, }, }, + aliases: { u: "until" }, }, async *func(this: SentryContext, flags: ArchiveFlags, issueArg: string) { - const { substatus, statusDetails } = resolveArchiveOptions(flags); + let substatus: IssueSubstatus = "archived_forever"; + let statusDetails: IgnoreStatusDetails | undefined; + + if (flags.until) { + const spec = parseUntilSpec(flags.until); + const opts = specToApiOptions(spec); + substatus = opts.substatus; + statusDetails = opts.statusDetails; + } const { org, issue } = await resolveIssue({ issueArg, diff --git a/src/lib/time-range.ts b/src/lib/time-range.ts index 03cb81859..b84543e19 100644 --- a/src/lib/time-range.ts +++ b/src/lib/time-range.ts @@ -46,8 +46,8 @@ export type TimeRangeApiParams = { // Constants // --------------------------------------------------------------------------- -/** Seconds per unit for relative period computation */ -const UNIT_SECONDS: Record = { +/** Seconds per unit for relative period computation. @internal Exported for reuse in duration parsers. */ +export const UNIT_SECONDS: Record = { s: 1, m: 60, h: 3600, @@ -82,7 +82,7 @@ export const PERIOD_BRIEF = `Time range: "7d", "${EXAMPLE_START}..${EXAMPLE_END} * Try to parse a relative period string (e.g., "7d") into its numeric value and unit. * Returns null if the string isn't a valid relative period. */ -function parseRelativeParts( +export function parseRelativeParts( value: string ): { value: number; unit: string } | null { if (value.length < 2) { diff --git a/test/commands/issue/archive.func.test.ts b/test/commands/issue/archive.func.test.ts index d36f3b7e4..2b47b810c 100644 --- a/test/commands/issue/archive.func.test.ts +++ b/test/commands/issue/archive.func.test.ts @@ -1,8 +1,8 @@ /** * Issue Archive Command Tests * - * Tests for `sentry issue archive` func() body — substatus selection, - * statusDetails construction, flag validation, and human output. + * Tests for `sentry issue archive` — the --until parser, API call + * construction, validation errors, and human output. */ import { @@ -14,7 +14,10 @@ import { spyOn, test, } from "bun:test"; -import { archiveCommand } from "../../../src/commands/issue/archive.js"; +import { + archiveCommand, + parseUntilSpec, +} from "../../../src/commands/issue/archive.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as issueUtils from "../../../src/commands/issue/utils.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking @@ -52,6 +55,148 @@ function createMockContext() { }; } +// ── parseUntilSpec unit tests ────────────────────────────────────── + +describe("parseUntilSpec", () => { + test("'auto' → escalating", () => { + expect(parseUntilSpec("auto")).toEqual({ kind: "escalating" }); + }); + + test("'Auto' (case-insensitive) → escalating", () => { + expect(parseUntilSpec("Auto")).toEqual({ kind: "escalating" }); + }); + + test("'escalating' → escalating", () => { + expect(parseUntilSpec("escalating")).toEqual({ kind: "escalating" }); + }); + + test("'30m' → duration 30 minutes", () => { + expect(parseUntilSpec("30m")).toEqual({ kind: "duration", minutes: 30 }); + }); + + test("'1h' → duration 60 minutes", () => { + expect(parseUntilSpec("1h")).toEqual({ kind: "duration", minutes: 60 }); + }); + + test("'7d' → duration 10080 minutes", () => { + expect(parseUntilSpec("7d")).toEqual({ + kind: "duration", + minutes: 7 * 24 * 60, + }); + }); + + test("'2hours' (verbose) → duration 120 minutes", () => { + expect(parseUntilSpec("2hours")).toEqual({ + kind: "duration", + minutes: 120, + }); + }); + + test("'30minutes' (verbose) → duration 30 minutes", () => { + expect(parseUntilSpec("30minutes")).toEqual({ + kind: "duration", + minutes: 30, + }); + }); + + test("'10x' → count 10", () => { + expect(parseUntilSpec("10x")).toEqual({ kind: "count", count: 10 }); + }); + + test("'10events' (verbose) → count 10", () => { + expect(parseUntilSpec("10events")).toEqual({ kind: "count", count: 10 }); + }); + + test("'5u' → users 5", () => { + expect(parseUntilSpec("5u")).toEqual({ kind: "users", users: 5 }); + }); + + test("'5users' (verbose) → users 5", () => { + expect(parseUntilSpec("5users")).toEqual({ kind: "users", users: 5 }); + }); + + test("'10x/5m' → count 10 with 5 minute window", () => { + expect(parseUntilSpec("10x/5m")).toEqual({ + kind: "count", + count: 10, + windowMinutes: 5, + }); + }); + + test("'10events/2hours' (verbose) → count 10 with 120 minute window", () => { + expect(parseUntilSpec("10events/2hours")).toEqual({ + kind: "count", + count: 10, + windowMinutes: 120, + }); + }); + + test("'5u/1h' → users 5 with 60 minute window", () => { + expect(parseUntilSpec("5u/1h")).toEqual({ + kind: "users", + users: 5, + windowMinutes: 60, + }); + }); + + test("'5users/30m' → users 5 with 30 minute window", () => { + expect(parseUntilSpec("5users/30m")).toEqual({ + kind: "users", + users: 5, + windowMinutes: 30, + }); + }); + + test("'100x/1d' → count 100 with 1 day window", () => { + expect(parseUntilSpec("100x/1d")).toEqual({ + kind: "count", + count: 100, + windowMinutes: 24 * 60, + }); + }); + + test("future ISO date → duration in minutes", () => { + const future = new Date(Date.now() + 3_600_000).toISOString(); + const spec = parseUntilSpec(future); + expect(spec.kind).toBe("duration"); + if (spec.kind === "duration") { + // Should be approximately 60 minutes, allow some slack + expect(spec.minutes).toBeGreaterThanOrEqual(59); + expect(spec.minutes).toBeLessThanOrEqual(61); + } + }); + + test("past ISO date → throws ValidationError", () => { + expect(() => parseUntilSpec("2020-01-01")).toThrow(ValidationError); + }); + + test("invalid value → throws with helpful message", () => { + expect(() => parseUntilSpec("garbage")).toThrow(ValidationError); + }); + + test("'0x' → throws (zero count)", () => { + expect(() => parseUntilSpec("0x")).toThrow(ValidationError); + }); + + test("'-1x' → throws (negative count)", () => { + expect(() => parseUntilSpec("-1x")).toThrow(ValidationError); + }); + + test("empty slash → throws", () => { + expect(() => parseUntilSpec("10x/")).toThrow(ValidationError); + }); + + test("duration/duration → throws (left must be count)", () => { + expect(() => parseUntilSpec("5m/10m")).toThrow(ValidationError); + }); + + test("count/count → throws (right must be duration)", () => { + expect(() => parseUntilSpec("10x/5x")).toThrow(ValidationError); + }); +}); + +// ── archiveCommand.func() integration tests ──────────────────────── + describe("archiveCommand.func()", () => { let resolveIssueSpy: ReturnType; let updateSpy: ReturnType; @@ -68,7 +213,7 @@ describe("archiveCommand.func()", () => { updateSpy.mockRestore(); }); - test("archives forever when no flags provided", async () => { + test("no --until → archives forever", async () => { resolveIssueSpy.mockResolvedValue({ org: "test-org", issue: makeMockIssue({ status: "unresolved" }), @@ -79,12 +224,13 @@ describe("archiveCommand.func()", () => { await func.call(context, { json: false }, "CLI-G5"); expect(updateSpy).toHaveBeenCalledWith("123456789", "ignored", { + statusDetails: undefined, substatus: "archived_forever", orgSlug: "test-org", }); }); - test("archives until escalating with --until-escalating", async () => { + test("--until auto → archives until escalating", async () => { resolveIssueSpy.mockResolvedValue({ org: "test-org", issue: makeMockIssue(), @@ -92,19 +238,16 @@ describe("archiveCommand.func()", () => { updateSpy.mockResolvedValue(makeMockIssue()); const { context } = createMockContext(); - await func.call( - context, - { json: false, "until-escalating": true }, - "CLI-G5" - ); + await func.call(context, { json: false, until: "auto" }, "CLI-G5"); expect(updateSpy).toHaveBeenCalledWith("123456789", "ignored", { + statusDetails: undefined, substatus: "archived_until_escalating", orgSlug: "test-org", }); }); - test("archives with --duration sends ignoreDuration + condition substatus", async () => { + test("--until 1h → archives with 60 min duration", async () => { resolveIssueSpy.mockResolvedValue({ org: "test-org", issue: makeMockIssue(), @@ -112,7 +255,7 @@ describe("archiveCommand.func()", () => { updateSpy.mockResolvedValue(makeMockIssue()); const { context } = createMockContext(); - await func.call(context, { json: false, duration: 60 }, "CLI-G5"); + await func.call(context, { json: false, until: "1h" }, "CLI-G5"); expect(updateSpy).toHaveBeenCalledWith("123456789", "ignored", { statusDetails: { ignoreDuration: 60 }, @@ -121,7 +264,7 @@ describe("archiveCommand.func()", () => { }); }); - test("archives with --count and --window sends both fields", async () => { + test("--until 10x/5m → archives with count + window", async () => { resolveIssueSpy.mockResolvedValue({ org: "test-org", issue: makeMockIssue(), @@ -129,16 +272,16 @@ describe("archiveCommand.func()", () => { updateSpy.mockResolvedValue(makeMockIssue()); const { context } = createMockContext(); - await func.call(context, { json: false, count: 100, window: 60 }, "CLI-G5"); + await func.call(context, { json: false, until: "10x/5m" }, "CLI-G5"); expect(updateSpy).toHaveBeenCalledWith("123456789", "ignored", { - statusDetails: { ignoreCount: 100, ignoreWindow: 60 }, + statusDetails: { ignoreCount: 10, ignoreWindow: 5 }, substatus: "archived_until_condition_met", orgSlug: "test-org", }); }); - test("archives with --users and --user-window sends both fields", async () => { + test("--until 5u → archives with user count", async () => { resolveIssueSpy.mockResolvedValue({ org: "test-org", issue: makeMockIssue(), @@ -146,31 +289,10 @@ describe("archiveCommand.func()", () => { updateSpy.mockResolvedValue(makeMockIssue()); const { context } = createMockContext(); - await func.call( - context, - { json: false, users: 10, "user-window": 120 }, - "CLI-G5" - ); + await func.call(context, { json: false, until: "5u" }, "CLI-G5"); expect(updateSpy).toHaveBeenCalledWith("123456789", "ignored", { - statusDetails: { ignoreUserCount: 10, ignoreUserWindow: 120 }, - substatus: "archived_until_condition_met", - orgSlug: "test-org", - }); - }); - - test("--count alone without --window sends only ignoreCount", async () => { - resolveIssueSpy.mockResolvedValue({ - org: "test-org", - issue: makeMockIssue(), - }); - updateSpy.mockResolvedValue(makeMockIssue()); - - const { context } = createMockContext(); - await func.call(context, { json: false, count: 50 }, "CLI-G5"); - - expect(updateSpy).toHaveBeenCalledWith("123456789", "ignored", { - statusDetails: { ignoreCount: 50 }, + statusDetails: { ignoreUserCount: 5 }, substatus: "archived_until_condition_met", orgSlug: "test-org", }); @@ -191,58 +313,3 @@ describe("archiveCommand.func()", () => { expect(output).toContain("CLI-G5"); }); }); - -describe("archiveCommand.func() — validation", () => { - let func: Awaited>; - - beforeEach(async () => { - func = await archiveCommand.loader(); - }); - - test("--window without --count throws ValidationError", async () => { - const { context } = createMockContext(); - await expect( - func.call(context, { json: false, window: 60 }, "CLI-G5") - ).rejects.toBeInstanceOf(ValidationError); - }); - - test("--user-window without --users throws ValidationError", async () => { - const { context } = createMockContext(); - await expect( - func.call(context, { json: false, "user-window": 120 }, "CLI-G5") - ).rejects.toBeInstanceOf(ValidationError); - }); - - test("--until-escalating with --duration throws ValidationError", async () => { - const { context } = createMockContext(); - await expect( - func.call( - context, - { json: false, "until-escalating": true, duration: 60 }, - "CLI-G5" - ) - ).rejects.toBeInstanceOf(ValidationError); - }); - - test("--until-escalating with --count throws ValidationError", async () => { - const { context } = createMockContext(); - await expect( - func.call( - context, - { json: false, "until-escalating": true, count: 100 }, - "CLI-G5" - ) - ).rejects.toBeInstanceOf(ValidationError); - }); - - test("--until-escalating with --users throws ValidationError", async () => { - const { context } = createMockContext(); - await expect( - func.call( - context, - { json: false, "until-escalating": true, users: 10 }, - "CLI-G5" - ) - ).rejects.toBeInstanceOf(ValidationError); - }); -}); From 0243d321499afb86c4a7f9330bb7b291b7b822f1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 30 Apr 2026 23:36:17 +0000 Subject: [PATCH 2/4] docs: enrich archive command help text, examples, and skill reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expand fullDescription with mode explanations, time format reference, compound condition syntax, and 9 commented examples - Fix stale --duration 60 example in issue route map → --until auto - Add full 'Archive and ignore issues' section to docs fragment with syntax reference table - Skill reference auto-regenerated with all new examples --- docs/src/fragments/commands/issue.md | 47 +++++++++++++++++++ .../skills/sentry-cli/references/issue.md | 31 ++++++++++++ src/commands/issue/archive.ts | 38 +++++++++------ src/commands/issue/index.ts | 2 +- 4 files changed, 103 insertions(+), 15 deletions(-) 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 9839aab0c..9a359a9cc 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -212,6 +212,37 @@ Archive (ignore) an issue **Flags:** - `-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 ` Merge 2+ issues into a single canonical group diff --git a/src/commands/issue/archive.ts b/src/commands/issue/archive.ts index 2cbf70f63..64760cc9e 100644 --- a/src/commands/issue/archive.ts +++ b/src/commands/issue/archive.ts @@ -318,21 +318,31 @@ export const archiveCommand = buildCommand({ brief: "Archive (ignore) an issue", fullDescription: "Archive an issue, suppressing alerts until an optional condition is met.\n\n" + - "Without --until, the issue is archived forever. Use --until to set\n" + - "a condition for automatic unarchival:\n\n" + - " --until auto Archive until Sentry detects a spike\n" + - " --until 30m Archive for 30 minutes\n" + - " --until 7d Archive for 7 days\n" + - " --until 2026-12-31 Archive until a specific date\n" + - " --until 10x Archive until 10 more events\n" + - " --until 10u Archive until 10 more users affected\n" + - " --until 10x/5m Archive until 10 events within 5 minutes\n" + - " --until 10users/2hours Same, verbose form\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