diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md index 171656835..3543c9861 100644 --- a/apps/cli-docs/src/fragments/commands/auth.md +++ b/apps/cli-docs/src/fragments/commands/auth.md @@ -74,6 +74,7 @@ that fits what they target, without changing the active account: `sentry auth status` show it with its user when the server reports one. It can't be selected with `sentry auth switch`. + ```bash # Active account is personal; this uses the work account that knows acme-corp sentry issue list acme-corp/ diff --git a/apps/cli-docs/src/fragments/commands/event.md b/apps/cli-docs/src/fragments/commands/event.md index 1c273cf96..8472ba6dd 100644 --- a/apps/cli-docs/src/fragments/commands/event.md +++ b/apps/cli-docs/src/fragments/commands/event.md @@ -22,11 +22,7 @@ The DSN is resolved in priority order: ## Attachments `sentry event view --json` lists an event's attachment metadata. Download an -attachment with `sentry api`: - -```bash -sentry api "https://sentry.io/api/0/projects/my-org/my-project/events/EVENT_ID/attachments/ATTACHMENT_ID/?download=1" > screenshot.png -``` +attachment with `sentry api` and the attachment's `?download=1` URL. ## Finding Event IDs @@ -38,8 +34,5 @@ Event IDs can be found: ## Backward compatibility -The old sentry-cli top-level command is available as a hidden alias: - -```bash -sentry send-event # same as: sentry event send -``` +The old sentry-cli top-level command `sentry send-event` is available as a +hidden alias for `sentry event send`. diff --git a/apps/cli-docs/src/fragments/commands/index.md b/apps/cli-docs/src/fragments/commands/index.md index d1ab7faf7..d6d1c96a9 100644 --- a/apps/cli-docs/src/fragments/commands/index.md +++ b/apps/cli-docs/src/fragments/commands/index.md @@ -13,6 +13,7 @@ All commands support the following global options: Most list and view commands support `--json` flag for JSON output, making it easy to integrate with other tools: + ```bash sentry org list --json | jq '.[] | .slug' ``` @@ -21,6 +22,7 @@ sentry org list --json | jq '.[] | .slug' View commands support `-w` or `--web` flag to open the resource in your browser: + ```bash sentry issue view PROJ-123 -w ``` diff --git a/apps/cli-docs/src/fragments/commands/local.md b/apps/cli-docs/src/fragments/commands/local.md index 18120282d..2a918146b 100644 --- a/apps/cli-docs/src/fragments/commands/local.md +++ b/apps/cli-docs/src/fragments/commands/local.md @@ -39,15 +39,7 @@ Sentry.init({ spotlight: process.env.NEXT_PUBLIC_SENTRY_SPOTLIGHT ?? false }); ## Browser UI -Use `--open` to launch the Sentry Local UI ([local.sentry.dev](https://local.sentry.dev)) in your browser. The UI connects to the local receiver via the loopback stream endpoint and provides a visual workspace for browsing errors, traces, logs, and AI spans captured during the session. - -```bash -# Start the server and open the UI -sentry local --open - -# Run your app with the UI -sentry local run --open -- npm run dev -``` +Use `--open` with `sentry local` or `sentry local run` to launch the Sentry Local UI ([local.sentry.dev](https://local.sentry.dev)) in your browser. The UI connects to the local receiver via the loopback stream endpoint and provides a visual workspace for browsing errors, traces, logs, and AI spans captured during the session. The `--open` flag requires a loopback `--host` (localhost, 127.0.0.1, or ::1). The hosted UI reads the SSE stream without changing retained receiver data. A loopback-served viewer can clear a receiver and reset or close a scoped daemon session. Session data stays in memory for the duration of the server; nothing is sent to sentry.io unless a DSN is configured. @@ -72,11 +64,8 @@ By default, incoming envelopes are pretty-printed to the terminal: Errors show the exception type, message, and top stack frame. Transactions show the operation, duration, and span count. Logs show the severity level, message, and custom attributes. -Use `--filter` / `-f` to narrow the output to specific event types (repeatable): - -```bash -sentry local -f error -f log # only errors and logs -``` +Use `--filter` / `-f` to narrow the output to specific event types. The flag is +repeatable, so `-f error -f log` shows only errors and logs. Use `--quiet` to suppress tail output entirely if you only need the SSE stream. @@ -93,30 +82,17 @@ Use `--quiet` to suppress tail output entirely if you only need the SSE stream. GenAI operations show the model name, MCP tool calls show the tool being invoked, and database queries show the system and query summary. This works automatically when your Sentry SDK is configured with AI/LLM integrations. -To watch only agent activity, filter to the `ai` item type: - -```bash -sentry local -f ai # only AI/agent spans -sentry local -f ai -f error # agent spans and errors -``` +To watch only agent activity, filter to the `ai` item type with `-f ai`, and add +`-f error` to keep errors alongside agent spans. ## JSON output -Use `--format json` (or `-F json`) for machine-readable NDJSON output, one JSON object per envelope item: - -```bash -sentry local --format json -``` +Use `--format json` (or `-F json`) for machine-readable NDJSON output, one JSON object per envelope item. `local run` supports the same JSON, attribute, and filter options while it starts your app and injects the receiver URL. For an agent-friendly stream -without SDK housekeeping envelopes, use: - -```bash -sentry local run --format json \ - --filter error --filter transaction --filter log --filter ai \ - -- npm run dev -``` +without SDK housekeeping envelopes, filter to `error`, `transaction`, `log`, and +`ai` items. Each JSON line looks like this: ```json {"type":"transaction","timestamp":1700000001,"op":"gen_ai","label":"chat anthropic/claude-4-sonnet","duration_ms":1200,"span_count":5,"source":"server"} @@ -142,6 +118,7 @@ local server unless you explicitly set `SENTRY_DSN`. In one terminal, start the local receiver: + ```bash sentry local serve --format json --attributes ``` @@ -173,6 +150,7 @@ after your foreground command exits. Sessions keep up to 500 envelopes for three hours after closing and subsequent ingestion. Stopping or restarting the daemon clears all sessions. + ```bash sentry local run --session web -- npm run dev sentry local serve --session worker diff --git a/apps/cli-docs/src/fragments/commands/log.md b/apps/cli-docs/src/fragments/commands/log.md index 977a8fffe..1d7d5048f 100644 --- a/apps/cli-docs/src/fragments/commands/log.md +++ b/apps/cli-docs/src/fragments/commands/log.md @@ -19,10 +19,6 @@ available scan accuracy. ## JSON Output -Use `--json` for machine-readable output: - -```bash -sentry log list --json | jq '.data[] | select(.severity == "error")' -``` +Use `--json` for machine-readable output. In streaming mode with `--json`, each log entry is output as a separate JSON object (newline-delimited JSON), making it suitable for piping to other tools. diff --git a/apps/cli-docs/src/fragments/commands/org.md b/apps/cli-docs/src/fragments/commands/org.md index 006803d1e..595aa050a 100644 --- a/apps/cli-docs/src/fragments/commands/org.md +++ b/apps/cli-docs/src/fragments/commands/org.md @@ -20,6 +20,7 @@ and `--account` to a single account. The stored active account never changes. With `--json`, each entry's `account` carries that notation as `name`, with its parts alongside: `email` (or `username`) and `server`. + ``` $ sentry org list SLUG NAME ACCOUNT diff --git a/apps/cli-docs/src/fragments/commands/release.md b/apps/cli-docs/src/fragments/commands/release.md index a310ac31a..91c2fd82c 100644 --- a/apps/cli-docs/src/fragments/commands/release.md +++ b/apps/cli-docs/src/fragments/commands/release.md @@ -5,6 +5,7 @@ A typical CI release creates the release, associates commits, finalizes it, and records a deploy: + ```bash sentry release create my-org/1.0.0 --project my-project sentry release set-commits my-org/1.0.0 --auto diff --git a/apps/cli-docs/src/fragments/commands/sourcemap.md b/apps/cli-docs/src/fragments/commands/sourcemap.md index eb8a884c5..54adde5e4 100644 --- a/apps/cli-docs/src/fragments/commands/sourcemap.md +++ b/apps/cli-docs/src/fragments/commands/sourcemap.md @@ -37,8 +37,4 @@ common cause is a bundler that isn't emitting `.map` files: For CI steps that may run against legitimately-empty directories (e.g., library-only repos, conditional release skips), pass `--allow-empty` to -suppress the error: - -```bash -sentry sourcemap upload ./dist --allow-empty -``` +suppress the error. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index e5e673631..fb3d15d95 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -491,6 +491,7 @@ All command docs and skill files are generated via `pnpm run generate:docs` (whi - **Skill files** (`plugins/sentry-cli/skills/sentry-cli/`) are **committed** (consumed by external plugin systems) and auto-committed by CI when stale. - **Examples belong to the command**: add them to `docs.examples` (`{ description, command }[]`, see `src/commands/agent-conversation/list.ts`). They render in `--help`, the generated docs, and the skill references. Don't hand-write an `Examples:` block in `fullDescription` or an Examples heading in a fragment; the `sentry-cli/no-examples-in-full-description` lint rule and `pnpm run check:fragments` reject both. - Use fragments in `apps/cli-docs/src/fragments/commands/` only for prose the command metadata can't carry: guides, permissions, caveats. +- `pnpm run check:fragments` also rejects any shell code block in a fragment that runs a `sentry` command, whatever its heading. For a real workflow, sample output, or cross-command guide, put `` on the line directly above the block. - `pnpm run check:fragments` validates fragment ↔ route consistency. - Positional `placeholder` values must be descriptive: `"org/project/trace-id"` not `"args"`. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md index d28e1cfbf..173514763 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -74,6 +74,9 @@ sentry event view abc123def456abc123def456abc12345 # Open in the browser sentry event view abc123def456abc123def456abc12345 -w + +# Download an attachment listed by --json (with sentry api) +sentry api 'https://sentry.io/api/0/projects/my-org/my-project/events/EVENT_ID/attachments/ATTACHMENT_ID/?download=1' > screenshot.png ``` ### `sentry event list ` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index 3bed51cc3..e045731d1 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -75,8 +75,17 @@ sentry local --port 9000 # Only show errors and logs (filter out transactions) sentry local -f error -f log +# Only show AI/agent spans +sentry local -f ai + # Suppress per-envelope tail output sentry local --quiet + +# Stream NDJSON for scripts and agents +sentry local --format json + +# Start the server and open the Sentry Local UI +sentry local --open ``` ### `sentry local session create ` @@ -136,6 +145,12 @@ sentry local run -- npm run dev # Run a Django dev server with the local server enabled sentry local run -- python manage.py runserver + +# Run your app and open the Sentry Local UI +sentry local run --open -- npm run dev + +# Stream agent-relevant NDJSON without SDK housekeeping envelopes +sentry local run --format json -f error -f transaction -f log -f ai -- npm run dev ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/script/check-fragments.ts b/packages/cli/script/check-fragments.ts index c0d0fdcf8..4198d1e2c 100644 --- a/packages/cli/script/check-fragments.ts +++ b/packages/cli/script/check-fragments.ts @@ -9,6 +9,8 @@ * 4. Top-level fragments (e.g., configuration.md) exist * 5. Fragment content covers all subcommands in a route (warnings only) * 6. Fragments don't contain Examples sections (examples belong in docs.examples) + * 7. Fragment shell code blocks don't run `sentry` commands unless marked + * with `` (see fragment-commands.ts) * * Usage: * tsx script/check-fragments.ts # Warnings for missing subcommands @@ -18,6 +20,7 @@ import { mkdirSync, readdirSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; import type { RouteInfo, RouteMap } from "../src/lib/introspect.js"; +import { findFragmentCommandIssues } from "./fragment-commands.js"; import { markdownHeadings } from "./markdown.js"; import { DOCS_FRAGMENTS } from "./paths.js"; @@ -119,6 +122,10 @@ for (const file of fragmentFiles) { `Fragment contains an Examples section: ${FRAGMENTS_DIR}/${file} (move command examples to the command's docs.examples metadata so they appear in help, generated docs, and skills)`, ); } + + for (const { line, message } of findFragmentCommandIssues(content)) { + errors.push(`${FRAGMENTS_DIR}/${file}:${line}: ${message}`); + } } // --------------------------------------------------------------------------- @@ -283,7 +290,7 @@ for (const [routeName, route] of multiCommandRoutes) { const msg = `Fragment missing subcommand coverage: ${fragmentPath}\n` + ` Not documented: ${missing.join(", ")}\n` + - ` Hint: add a heading or code example for each (e.g., "sentry ${routeName} ${missing[0]}")`; + ` Hint: add docs.examples to each command, or mention it in a heading or prose (e.g., "sentry ${routeName} ${missing[0]}")`; if (isStrict) { errors.push(msg); } else { diff --git a/packages/cli/script/fragment-commands.ts b/packages/cli/script/fragment-commands.ts new file mode 100644 index 000000000..9af1e2b2b --- /dev/null +++ b/packages/cli/script/fragment-commands.ts @@ -0,0 +1,161 @@ +/** + * Find `sentry` command invocations in doc fragment code blocks. + * + * Command examples belong in the command's `docs.examples` metadata so they + * show up in `--help`, the generated docs, and the skill references. A shell + * code block in a fragment that runs `sentry …` is almost always an example + * in disguise, whatever its heading says. + * + * Blocks that legitimately need commands (multi-step workflows, sample output, + * cross-command guides) opt out with an HTML comment directly above the block: + * + * + * ```bash + * sentry release create … + * ``` + * + * The reason is required so reviewers can judge each exception. + */ + +import { marked, type Token } from "marked"; + +/** Code block languages treated as shell. An empty string is an unlabeled block. */ +const SHELL_LANGS = new Set(["", "bash", "sh", "shell", "console", "zsh"]); + +/** + * A `sentry` invocation: at the start of the line or after a shell operator, + * optionally behind a `$ ` prompt and `VAR=value` assignments. `sentry-cli` + * and `sentry.io` don't match. + */ +const SENTRY_COMMAND_RE = + /(?:^|[|;&(]\s*|\$\s+)(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*sentry(?=\s|$)/; + +/** + * Opt-out marker on its own line. Group 1 is the colon and group 2 the reason; + * both are checked so a bare `` is reported, not ignored. + */ +const ALLOW_MARKER_SOURCE = + "^[ \\t]*[ \\t]*$"; +const ALLOW_MARKER_RE = new RegExp(ALLOW_MARKER_SOURCE); +const ALLOW_MARKER_LINES_RE = new RegExp(`${ALLOW_MARKER_SOURCE}\\n?`, "gm"); + +const MISPLACED_MARKER = + "allow-commands marker must sit directly above a code block; remove or move it"; + +/** Remove opt-out markers so they don't leak into generated docs and skills. */ +export function stripAllowCommandMarkers(md: string): string { + return md.replace(ALLOW_MARKER_LINES_RE, ""); +} + +/** One problem found in a fragment. */ +export type FragmentCommandIssue = { + /** 1-based line of the offending block (or its enclosing block when nested). */ + line: number; + message: string; +}; + +/** `sentry` command lines of a shell code block, skipping comment lines. */ +function sentryCommandLines(code: string, lang: string): string[] { + if (!SHELL_LANGS.has(lang)) { + return []; + } + return code + .split("\n") + .map((line) => line.trim()) + .filter((line) => !line.startsWith("#") && SENTRY_COMMAND_RE.test(line)); +} + +type Marker = { reason: string } | { invalid: true }; + +function parseMarker(token: Token | undefined): Marker | undefined { + if (token?.type !== "html") { + return; + } + const match = ALLOW_MARKER_RE.exec(token.raw.trim()); + if (!match) { + return; + } + const reason = match[2]?.trim(); + return match[1] && reason ? { reason } : { invalid: true }; +} + +/** Child token lists of a token: inline/block children and list items. */ +function childTokenLists(token: Token): Token[][] { + const lists: Token[][] = []; + if ("tokens" in token && Array.isArray(token.tokens)) { + lists.push(token.tokens as Token[]); + } + if (token.type === "list") { + lists.push(token.items as Token[]); + } + return lists; +} + +function checkTokens( + tokens: Token[], + line: (token: Token) => number, + issues: FragmentCommandIssue[], +): void { + let previous: Token | undefined; + for (const token of tokens) { + if (token.type === "space") { + continue; + } + const marker = parseMarker(previous); + if (token.type === "code") { + const lang = (token.lang ?? "").split(" ")[0] ?? ""; + const commands = sentryCommandLines(token.text, lang); + if (marker && "invalid" in marker) { + issues.push({ + line: line(token), + message: + "allow-commands marker needs a reason: ", + }); + } else if (marker && commands.length === 0) { + issues.push({ + line: line(token), + message: + "allow-commands marker on a block without sentry commands; remove the marker", + }); + } else if (!marker && commands.length > 0) { + issues.push({ + line: line(token), + message: `code block runs \`${commands[0]}\`; move it to the command's docs.examples, or mark a workflow or sample-output block with on the line above`, + }); + } + } else if (marker) { + issues.push({ + line: line(previous as Token), + message: MISPLACED_MARKER, + }); + } + for (const children of childTokenLists(token)) { + checkTokens(children, () => line(token), issues); + } + previous = token; + } + const trailing = parseMarker(previous); + if (trailing && previous) { + issues.push({ + line: line(previous), + message: MISPLACED_MARKER, + }); + } +} + +/** + * Report unmarked code blocks that run `sentry` commands, plus markers that + * are missing a reason or don't cover a command block. + */ +export function findFragmentCommandIssues(md: string): FragmentCommandIssue[] { + const tokens = marked.lexer(md); + const lines = new Map(); + let current = 1; + for (const token of tokens) { + lines.set(token, current); + current += token.raw.split("\n").length - 1; + } + const issues: FragmentCommandIssue[] = []; + checkTokens(tokens, (token) => lines.get(token) ?? 1, issues); + return issues; +} diff --git a/packages/cli/script/generate-command-docs.ts b/packages/cli/script/generate-command-docs.ts index e09d28d39..5a8cd57bc 100644 --- a/packages/cli/script/generate-command-docs.ts +++ b/packages/cli/script/generate-command-docs.ts @@ -9,7 +9,7 @@ * * Each generated page combines: * 1. Auto-generated reference — from CLI metadata or env registry - * 2. Hand-written fragment — examples, guides, tips (from docs/src/fragments/) + * 2. Hand-written fragment — guides, tips (from docs/src/fragments/) * * The generated output is gitignored. Fragment files are the committed * source of truth for custom content. @@ -29,6 +29,7 @@ import { formatCommandArguments, formatCommandExamples, } from "./generate-skill-markdown.js"; +import { stripAllowCommandMarkers } from "./fragment-commands.js"; import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; // Ensure src/generated/skill-content.ts exists before importing the route tree. @@ -347,12 +348,15 @@ function generateConfigurationPage(registry: readonly EnvVarEntry[]): string { /** * Read hand-written custom content from a command fragment file. * Fragment files live in docs/src/fragments/commands/ and contain only - * the custom sections (examples, guides) — no frontmatter or generated content. + * the custom sections (guides, caveats) — no frontmatter or generated content. + * `check:fragments` opt-out markers are stripped from the result. * Returns empty string if the fragment file doesn't exist. */ async function readCustomContent(fragmentName: string): Promise { try { - return await readFile(`${FRAGMENTS_DIR}/${fragmentName}.md`, "utf-8"); + return stripAllowCommandMarkers( + await readFile(`${FRAGMENTS_DIR}/${fragmentName}.md`, "utf-8"), + ); } catch { return ""; } diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index f97d27d19..3ada70a40 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -1187,6 +1187,12 @@ export const viewCommand = buildCommand({ description: "Open in the browser", command: "sentry event view abc123def456abc123def456abc12345 -w", }, + { + description: + "Download an attachment listed by --json (with sentry api)", + command: + "sentry api 'https://sentry.io/api/0/projects/my-org/my-project/events/EVENT_ID/attachments/ATTACHMENT_ID/?download=1' > screenshot.png", + }, ], }, output: { diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index a89395d02..c3a138106 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -404,6 +404,16 @@ export const runCommand = buildCommand({ description: "Run a Django dev server with the local server enabled", command: "sentry local run -- python manage.py runserver", }, + { + description: "Run your app and open the Sentry Local UI", + command: "sentry local run --open -- npm run dev", + }, + { + description: + "Stream agent-relevant NDJSON without SDK housekeeping envelopes", + command: + "sentry local run --format json -f error -f transaction -f log -f ai -- npm run dev", + }, ], }, parameters: { diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index 7635fd6d0..b56eb1dc2 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -1008,10 +1008,22 @@ export const serverCommand = buildCommand({ description: "Only show errors and logs (filter out transactions)", command: "sentry local -f error -f log", }, + { + description: "Only show AI/agent spans", + command: "sentry local -f ai", + }, { description: "Suppress per-envelope tail output", command: "sentry local --quiet", }, + { + description: "Stream NDJSON for scripts and agents", + command: "sentry local --format json", + }, + { + description: "Start the server and open the Sentry Local UI", + command: "sentry local --open", + }, ], }, parameters: { diff --git a/packages/cli/test/script/fragment-commands.test.ts b/packages/cli/test/script/fragment-commands.test.ts new file mode 100644 index 000000000..b11174819 --- /dev/null +++ b/packages/cli/test/script/fragment-commands.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "vitest"; +import { + findFragmentCommandIssues, + stripAllowCommandMarkers, +} from "../../script/fragment-commands.js"; + +function lines(...parts: string[]): string { + return parts.join("\n"); +} + +function block(lang: string, code: string): string { + return lines(`\`\`\`${lang}`, code, "```"); +} + +describe("findFragmentCommandIssues", () => { + test.each([ + "sentry issue list", + "$ sentry org list", + "SENTRY_HOST=x sentry auth status", + "npm run build && sentry sourcemap upload ./dist", + "cat file | sentry api /foo/", + "sentry", + ])("flags `%s`", (command) => { + expect(findFragmentCommandIssues(block("bash", command))).toHaveLength(1); + }); + + test.each([ + ["bash", "# sentry local --open"], + ["bash", "sentry-cli releases new 1.0"], + ["bash", "curl https://sentry.io/api/0/"], + ["bash", "npx @sentry/wizard"], + ["ts", "sentry issue list"], + ["json", "sentry issue list"], + ])("ignores %s block `%s`", (lang, code) => { + expect(findFragmentCommandIssues(block(lang, code))).toEqual([]); + }); + + test("flags commented examples under a non-Examples heading", () => { + const md = lines( + "## Using Attribute Keys", + "", + "Attribute keys work with other commands:", + "", + "```bash", + "# Add the attribute as a column", + "sentry span list my-org/my-project --fields gen_ai.request.model", + "```", + ); + const issues = findFragmentCommandIssues(md); + expect(issues).toHaveLength(1); + expect(issues[0]?.line).toBe(5); + expect(issues[0]?.message).toContain("docs.examples"); + }); + + test("flags unlabeled and nested blocks", () => { + const md = lines( + "```", + "$ sentry org list", + "```", + "", + "- Step one:", + "", + " ```bash", + " sentry release finalize 1.0", + " ```", + ); + expect(findFragmentCommandIssues(md)).toHaveLength(2); + }); + + test("accepts a marked block", () => { + const md = lines( + "Text.", + "", + "", + "```bash", + "sentry release create my-org/1.0.0", + "sentry release finalize my-org/1.0.0", + "```", + ); + expect(findFragmentCommandIssues(md)).toEqual([]); + }); + + test("accepts a marker separated by a blank line", () => { + const md = lines( + "", + "", + "```", + "$ sentry org list", + "```", + ); + expect(findFragmentCommandIssues(md)).toEqual([]); + }); + + test("requires a reason on the marker", () => { + const md = lines( + "", + "```bash", + "sentry release create my-org/1.0.0", + "```", + ); + const issues = findFragmentCommandIssues(md); + expect(issues).toHaveLength(1); + expect(issues[0]?.message).toContain("needs a reason"); + }); + + test("rejects markers that don't cover a command block", () => { + const md = lines( + "", + "```bash", + "npm run build", + "```", + "", + "", + "", + "Some prose.", + "", + "", + ); + const messages = findFragmentCommandIssues(md).map((i) => i.message); + expect(messages).toHaveLength(3); + expect(messages[0]).toContain("without sentry commands"); + expect(messages[1]).toContain("directly above a code block"); + expect(messages[2]).toContain("directly above a code block"); + }); + + test("ignores other HTML comments and inline code", () => { + const md = lines( + "", + "Run `sentry issue list` to see issues.", + "", + "```ts", + "// sentry local run", + "```", + ); + expect(findFragmentCommandIssues(md)).toEqual([]); + }); +}); + +describe("stripAllowCommandMarkers", () => { + test("removes marker lines and keeps other comments", () => { + const md = lines( + "", + "", + "```bash", + "sentry release finalize 1.0", + "```", + ); + expect(stripAllowCommandMarkers(md)).toBe( + lines( + "", + "```bash", + "sentry release finalize 1.0", + "```", + ), + ); + }); +});