From b516428d32c257fb6f5e865f4250ed3fb62b9fb8 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 13 Oct 2026 06:51:29 -0700 Subject: [PATCH] feat(cli): Add interactive Seer fixes and resumable JSON output Refs #1490 Co-Authored-By: GPT-6.1 Sol --- .github/workflows/cli-seer-platform.yml | 50 ++ .../cli-docs/src/content/docs/contributing.md | 2 +- apps/cli-docs/src/fragments/commands/issue.md | 67 +++ .../sentry-cli/skills/sentry-cli/SKILL.md | 1 + .../skills/sentry-cli/references/issue.md | 22 + packages/cli/src/commands/issue/fix.ts | 158 +++++ packages/cli/src/commands/issue/index.ts | 5 +- packages/cli/src/lib/api-client.ts | 3 + packages/cli/src/lib/api/infrastructure.ts | 17 +- packages/cli/src/lib/api/seer.ts | 82 +++ packages/cli/src/lib/complete.ts | 1 + packages/cli/src/lib/formatters/seer-fix.ts | 132 +++++ packages/cli/src/lib/seer-fix.ts | 315 ++++++++++ packages/cli/src/lib/sentry-client.ts | 68 ++- packages/cli/src/types/seer-fix.ts | 114 ++++ .../test/commands/debug-files/upload.test.ts | 9 +- packages/cli/test/constants.ts | 10 +- packages/cli/test/e2e/seer-fix.test.ts | 556 ++++++++++++++++++ packages/cli/test/fixture.ts | 3 + packages/cli/test/lib/api-client.seer.test.ts | 85 +++ packages/cli/test/lib/api-client.test.ts | 11 +- packages/cli/test/lib/complete.test.ts | 10 + .../cli/test/lib/formatters/seer-fix.test.ts | 104 ++++ packages/cli/test/lib/install-script.test.ts | 5 + packages/cli/test/lib/seer-fix.test.ts | 308 ++++++++++ .../lib/sentry-client.request-policy.test.ts | 147 +++++ 26 files changed, 2252 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/cli-seer-platform.yml create mode 100644 packages/cli/src/commands/issue/fix.ts create mode 100644 packages/cli/src/lib/formatters/seer-fix.ts create mode 100644 packages/cli/src/lib/seer-fix.ts create mode 100644 packages/cli/src/types/seer-fix.ts create mode 100644 packages/cli/test/e2e/seer-fix.test.ts create mode 100644 packages/cli/test/lib/formatters/seer-fix.test.ts create mode 100644 packages/cli/test/lib/seer-fix.test.ts create mode 100644 packages/cli/test/lib/sentry-client.request-policy.test.ts diff --git a/.github/workflows/cli-seer-platform.yml b/.github/workflows/cli-seer-platform.yml new file mode 100644 index 000000000..d6bb99438 --- /dev/null +++ b/.github/workflows/cli-seer-platform.yml @@ -0,0 +1,50 @@ +name: Seer CLI platforms + +on: + pull_request: + paths: + - "packages/cli/**" + - "packages/toolkit-core/**" + - "packages/mcp-core/**" + - "packages/mcp-server/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "package.json" + - ".github/workflows/cli-seer-platform.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + seer-fix: + name: Seer fix (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + env: + TZ: UTC + SENTRY_CLIENT_ID: test-client-id + SENTRY_CLI_NO_TELEMETRY: "1" + SENTRY_CLI_NO_UPDATE_CHECK: "1" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "24" + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build CLI bundle + run: pnpm --filter sentry run bundle + - name: Test source Seer flow and patch safety + run: pnpm --filter sentry exec vitest run seer-fix seer-patch sentry-client.request-policy --maxWorkers=2 + - name: Test bundled CLI Seer flow + env: + SENTRY_CLI_BINARY: ${{ github.workspace }}/packages/cli/dist/bin.cjs + run: pnpm --filter sentry exec vitest run test/e2e/seer-fix.test.ts --maxWorkers=1 diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 21665597c..9041c9865 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -68,7 +68,7 @@ toolkit/ │ │ │ ├── dsn/ # list │ │ │ ├── event/ # list, send, view │ │ │ ├── feedback/ # list, resolve, spam, unresolve, view -│ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view +│ │ │ ├── issue/ # archive, events, explain, fix, link, list, merge, plan, resolve, unlink, unresolve, view │ │ │ ├── local/ # run, serve │ │ │ ├── log/ # list, view │ │ │ ├── monitor/ # list, run diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 2c3256152..0cdf0d463 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -418,3 +418,70 @@ is also accepted. The organization's “Let Members Delete Events” setting doe not restrict unlinking on updated Sentry versions. Granting a token more scopes does not override project-access policy. + +### Generate and review a fix + +Use Seer to generate code changes and preview their diffs: + +```bash +sentry issue fix my-org/FRONT-123 +sentry issue fix @latest --instruction "Keep the existing API compatible" +``` + +The chat asks Seer to investigate the issue and generate code changes for +review. In a terminal, the CLI shows new messages, +asks Seer's questions, and lets you approve or reject each proposed file +change. Approval lets Seer continue generating the fix. Completed fixes preview +all generated diffs; local files are not modified and no pull request is created. + +The organization needs Seer coding access, linked repositories, and code +mappings. Interactive updates require a signed-in Sentry user. Use +`sentry auth login` if a token has no associated user identity. + +Each invocation starts a new interactive Seer chat. Use `--run-id` to resume a +specific chat. The issue argument resolves the organization and provides context +for new chats; the explicit run ID selects the chat when resuming. +`--instruction` supplies context for a new run and cannot be combined with +`--run-id`. `--fresh` also refreshes issue resolution; run polling always fetches +fresh state. + +### Resume a fix from JSON output + +JSON output and sessions without a terminal never prompt. A paused result +contains `run_id`, `status`, `pending_user_input`, `patches`, `pr_urls`, and a +`next_action` hint. Supply the returned run ID to continue that exact run: + +```bash +sentry issue fix my-org/FRONT-123 --json +sentry issue fix my-org/FRONT-123 --run-id --json +sentry issue fix my-org/FRONT-123 --run-id --response answer.json --json +``` + +For `ask_user_question`, `answer.json` contains one answer for each question: + +```json +{"input_id":"","response_data":{"answers":["Keep the current behavior"]}} +``` + +For `file_change_approval`, include one boolean for each proposed patch: + +```json +{"input_id":"","response_data":{"decisions":[true,false]}} +``` + +The CLI validates the input ID, response type, and answer count against the +current pending input before submitting. `--response` requires `--run-id`. +Seer questions and file change approvals work in the CLI. Browser-session +permission elevation (`agent_write_approval`), provider reconnection, and unknown +input types pause with guidance to continue in Sentry. Permission elevation +requires the browser approval flow before the run can continue. The handoff +hint links directly to the selected chat, preserving its run ID. The CLI never +answers these requests automatically. + +The default automated wait is six minutes. Change it with `--timeout `. +A terminal prompt stays open until you respond or cancel; an answer entered +after the deadline is not submitted. A timeout +returns the run ID and resume command because Seer may still be working. Runs +that fail, cancel, or time out return a nonzero exit status with their JSON result. +A resumed completed run without patches explains that no code changes were +produced and offers a new full fix. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4a98046b5..10a8651f6 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -412,6 +412,7 @@ Manage Sentry issues - `sentry issue events ` — List events for a specific issue - `sentry issue explain ` — Analyze one or more issues using Seer AI - `sentry issue plan ` — Generate a solution plan using Seer AI +- `sentry issue fix ` — Generate and review code changes using Seer AI - `sentry issue view ` — View details of one or more issues - `sentry issue resolve ` — Mark an issue as resolved - `sentry issue unresolve ` — Reopen a resolved issue diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 0403fd9b4..e2cd75f50 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -189,6 +189,28 @@ sentry issue plan 123456789 sentry issue plan 123456789 --force ``` +### `sentry issue fix ` + +Generate and review code changes using Seer AI + +**Flags:** +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `--instruction - Context for a new fix` +- `--run-id - Resume this Seer run` +- `--response - JSON file with input_id and response_data (requires --run-id)` +- `--timeout - Maximum wait in seconds - (default: "360")` + +**Examples:** + +```bash +sentry issue fix my-org/FRONT-123 +sentry issue fix @latest --instruction "Keep the existing API compatible" + +sentry issue fix my-org/FRONT-123 --json +sentry issue fix my-org/FRONT-123 --run-id --json +sentry issue fix my-org/FRONT-123 --run-id --response answer.json --json +``` + ### `sentry issue view ` View details of one or more issues diff --git a/packages/cli/src/commands/issue/fix.ts b/packages/cli/src/commands/issue/fix.ts new file mode 100644 index 000000000..5c9a8924a --- /dev/null +++ b/packages/cli/src/commands/issue/fix.ts @@ -0,0 +1,158 @@ +/** Generate and review code changes with Seer; resume structured input safely. */ +import { readFile } from "node:fs/promises"; +import { isatty } from "node:tty"; +import { safeParse } from "valibot"; +import type { SentryContext } from "../../context.js"; +import { + UserInputResponseSchema, + type UserInputResponse, +} from "../../types/seer-fix.js"; +import { buildCommand } from "../../lib/command.js"; +import { ApiError, OutputError, ValidationError } from "../../lib/errors.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { handleSeerApiError } from "../../lib/formatters/seer.js"; +import { + createSeerBlockRenderer, + formatSeerFix, + promptSeerInput, +} from "../../lib/formatters/seer-fix.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; +import { runSeerFix } from "../../lib/seer-fix.js"; +import { issueIdPositional, resolveOrgAndIssueId } from "./utils.js"; + +type FixFlags = { + readonly json: boolean; + readonly fresh: boolean; + readonly instruction?: string; + readonly "run-id"?: string; + readonly response?: string; + readonly timeout: number; + readonly fields?: string[]; +}; + +async function readResponse( + path: string | undefined, +): Promise { + if (path === undefined) { + return; + } + let value: unknown; + try { + value = JSON.parse(await readFile(path, "utf8")); + } catch { + throw new ValidationError( + "Unable to read --response as a JSON file.", + "--response", + ); + } + const parsed = safeParse(UserInputResponseSchema, value); + if (!parsed.success) { + throw new ValidationError( + "--response must contain input_id and response_data with answers or decisions.", + "--response", + ); + } + return parsed.output; +} + +function parseTimeout(value: string): number { + const seconds = Number(value); + if (!(Number.isFinite(seconds) && seconds > 0 && seconds <= 86_400)) { + throw new ValidationError( + "--timeout must be a positive number of seconds, up to 86400.", + "--timeout", + ); + } + return seconds; +} + +export const fixCommand = buildCommand({ + docs: { + brief: "Generate and review code changes using Seer AI", + fullDescription: + "Generate code changes for an issue using Seer AI. Review questions and proposed file changes in an interactive terminal.\n\nJSON and non-interactive sessions pause with a run ID and pending input. Resume using --run-id and --response with a JSON file containing input_id and response_data.\n\nCompleted fixes preview diffs. No files are changed locally. Requires Seer coding access, linked repositories, and a signed-in Sentry user.\n\nEach invocation starts a new interactive chat. Use --run-id to resume a specific chat; the issue argument resolves its organization. --instruction supplies context for a new fix.\n\nExamples:\n sentry issue fix @latest\n sentry issue fix my-org/CLI-G --instruction 'Preserve compatibility'\n sentry issue fix my-org/CLI-G --json\n sentry issue fix my-org/CLI-G --run-id --response answer.json --json", + }, + output: { human: formatSeerFix }, + parameters: { + positional: issueIdPositional, + flags: { + fresh: FRESH_FLAG, + instruction: { + kind: "parsed", + parse: String, + brief: "Context for a new fix", + optional: true, + }, + "run-id": { + kind: "parsed", + parse: String, + brief: "Resume this Seer run", + optional: true, + }, + response: { + kind: "parsed", + parse: String, + brief: "JSON file with input_id and response_data (requires --run-id)", + optional: true, + }, + timeout: { + kind: "parsed", + parse: parseTimeout, + brief: "Maximum wait in seconds", + default: "360", + }, + }, + aliases: FRESH_ALIASES, + }, + async *func(this: SentryContext, flags: FixFlags, issueArg: string) { + applyFreshFlag(flags); + const runId = flags["run-id"]; + if (flags.response && !runId) { + throw new ValidationError("--response requires --run-id.", "--response"); + } + const response = await readResponse(flags.response); + const interactive = !flags.json && isatty(0) && isatty(2); + let org: string | undefined; + const abort = new AbortController(); + const onInterrupt = () => abort.abort(); + process.once("SIGINT", onInterrupt); + try { + const resolved = await resolveOrgAndIssueId({ + issueArg, + cwd: this.cwd, + command: "fix", + }); + org = resolved.org; + const result = await runSeerFix({ + org, + issueId: resolved.issueId, + issueArg, + runId, + response, + instruction: flags.instruction, + timeoutMs: flags.timeout * 1000, + signal: abort.signal, + interactive, + prompt: (input) => promptSeerInput(input, this.stderr), + onBlocks: interactive + ? createSeerBlockRenderer(this.stderr) + : undefined, + }); + if (["ERROR", "CANCELLED", "TIMEOUT"].includes(result.status)) { + throw new OutputError(result); + } + yield new CommandOutput(result); + } catch (error) { + if (error instanceof ApiError) { + throw handleSeerApiError(error.status, error.detail, org); + } + throw error; + } finally { + process.removeListener("SIGINT", onInterrupt); + } + }, +}); diff --git a/packages/cli/src/commands/issue/index.ts b/packages/cli/src/commands/issue/index.ts index 64538c81d..42ff6ebcb 100644 --- a/packages/cli/src/commands/issue/index.ts +++ b/packages/cli/src/commands/issue/index.ts @@ -1,6 +1,7 @@ import { buildRouteMap } from "../../lib/route-map.js"; import { archiveCommand } from "./archive.js"; import { eventsCommand } from "./events.js"; +import { fixCommand } from "./fix.js"; import { explainCommand } from "./explain.js"; import { linkCommand } from "./link.js"; import { listCommand } from "./list.js"; @@ -17,6 +18,7 @@ export const issueRoute = buildRouteMap({ events: eventsCommand, explain: explainCommand, plan: planCommand, + fix: fixCommand, view: viewCommand, resolve: resolveCommand, unresolve: unresolveCommand, @@ -38,13 +40,14 @@ export const issueRoute = buildRouteMap({ " view View details of one or more issues\n" + " explain Analyze one or more issues using Seer AI\n" + " plan Generate a solution plan using Seer AI\n" + + " fix Generate and review code changes using Seer AI\n" + " resolve Mark an issue as resolved (optionally in a release)\n" + " unresolve Reopen a resolved issue (alias: reopen)\n" + " archive Archive/ignore an issue (alias: ignore)\n" + " merge Merge 2+ issues into a single group\n" + " link Link an existing external issue\n" + " unlink Remove an external issue link\n\n" + - "Magic selectors (available for view, events, explain, plan, resolve, unresolve, archive):\n" + + "Magic selectors (available for view, events, explain, plan, fix, resolve, unresolve, archive):\n" + " @latest Most recent unresolved issue\n" + " @most_frequent Issue with the highest event frequency\n\n" + "Examples:\n" + diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index 9ab8ef62e..de2ffde17 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -183,6 +183,9 @@ export { } from "./api/repositories.js"; export { getAutofixState, + getSeerExplorerState, + triggerIssueFix, + respondToSeerUserInput, triggerRootCauseAnalysis, triggerSolutionPlanning, } from "./api/seer.js"; diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 9a7d3cfc5..4a9c9fc9b 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -200,6 +200,12 @@ export type ApiRequestOptions = { credential?: CredentialContext; /** Manually validate each redirect before forwarding credentials. */ validatedRedirects?: boolean; + /** Disable retries for mutations whose outcome cannot safely be replayed. */ + retry?: boolean; + /** Always read state from the upstream, without storing a cache response. */ + bypassCache?: boolean; + /** Bound the request by the caller's operation deadline. */ + signal?: AbortSignal; }; /** @@ -590,8 +596,16 @@ export async function apiRequestToRegion( schema, credential, validatedRedirects, + retry, + bypassCache, + signal, } = options; - const config = getSdkConfig(regionUrl, { credential, validatedRedirects }); + const config = getSdkConfig(regionUrl, { + credential, + validatedRedirects, + retry, + bypassCache, + }); const endpointWithParams = appendSearchParams(endpoint, params); const url = buildSentryApiUrl(config.baseUrl, endpointWithParams); @@ -614,6 +628,7 @@ export async function apiRequestToRegion( method, headers, body: requestBody, + signal, }); if (!response.ok) { diff --git a/packages/cli/src/lib/api/seer.ts b/packages/cli/src/lib/api/seer.ts index 7e6b1326d..422ce23bd 100644 --- a/packages/cli/src/lib/api/seer.ts +++ b/packages/cli/src/lib/api/seer.ts @@ -12,6 +12,13 @@ import { type AutofixState, } from "../../types/seer.js"; +import { + type ExplorerSession, + ExplorerResponseSchema, + type UserInputResponse, +} from "../../types/seer-fix.js"; + +import { buildIssueUrl } from "../sentry-urls.js"; import { resolveOrgRegion } from "../region.js"; import { apiRequestToRegion } from "./infrastructure.js"; @@ -97,6 +104,7 @@ export async function triggerRootCauseAnalysis( export async function getAutofixState( orgSlug: string, issueId: string, + options: { bypassCache?: boolean; signal?: AbortSignal } = {}, ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); @@ -106,6 +114,7 @@ export async function getAutofixState( { params: EXPLORER_MODE_PARAMS, schema: AutofixResponseSchema, + ...options, }, ); @@ -155,3 +164,76 @@ export async function triggerSolutionPlanning( ); return data; } + +/** Start an interactive chat; the autofix kickoff disables interactive tools. */ +export async function triggerIssueFix( + orgSlug: string, + issueId: string, + options: { instruction?: string; signal?: AbortSignal } = {}, +): Promise<{ run_id?: number; sentry_run_id?: string | null }> { + const regionUrl = await resolveOrgRegion(orgSlug); + const query = [ + `Investigate Sentry issue ${orgSlug}/${issueId}. Explain the root cause, propose a fix, and generate code changes for review.`, + "Ask clarifying questions and request approvals as needed. Stop after generating code changes so the user can review them.", + "Do not create pull requests, commit changes, or push branches.", + ...(options.instruction === undefined + ? [] + : [`Additional instructions from the user: ${options.instruction}`]), + ].join("\n\n"); + const { data } = await apiRequestToRegion<{ + run_id?: number; + sentry_run_id?: string | null; + }>( + regionUrl, + `/organizations/${encodeURIComponent(orgSlug)}/seer/explorer-chat/`, + { + method: "POST", + retry: false, + signal: options.signal, + body: { + query, + on_page_context: `Sentry issue: ${orgSlug}/${issueId}\nIssue URL: ${buildIssueUrl(orgSlug, issueId)}`, + }, + }, + ); + return data; +} + +/** Read a specific run, never the issue's mutable current-run pointer. */ +export async function getSeerExplorerState( + orgSlug: string, + runId: string | number, + options: { signal?: AbortSignal } = {}, +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion( + regionUrl, + `/organizations/${encodeURIComponent(orgSlug)}/seer/explorer-chat/${encodeURIComponent(String(runId))}/`, + { schema: ExplorerResponseSchema, bypassCache: true, ...options }, + ); + if (!data.session) { + return null; + } + data.session.status = normalizeAgentStatus(data.session.status); + return data.session; +} + +/** Submit exactly one structured answer. Uncertain POSTs must never be replayed. */ +export async function respondToSeerUserInput( + orgSlug: string, + runId: string | number, + response: UserInputResponse, + options: { signal?: AbortSignal } = {}, +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + await apiRequestToRegion( + regionUrl, + `/organizations/${encodeURIComponent(orgSlug)}/seer/explorer-update/${encodeURIComponent(String(runId))}/`, + { + method: "POST", + retry: false, + signal: options.signal, + body: { payload: { type: "user_input_response", ...response } }, + }, + ); +} diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 3fec1796a..d0984d300 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -110,6 +110,7 @@ export const ORG_PROJECT_COMMANDS = new Set([ "issue view", "issue explain", "issue plan", + "issue fix", "issue resolve", "issue link", "issue unlink", diff --git a/packages/cli/src/lib/formatters/seer-fix.ts b/packages/cli/src/lib/formatters/seer-fix.ts new file mode 100644 index 000000000..82d1804c3 --- /dev/null +++ b/packages/cli/src/lib/formatters/seer-fix.ts @@ -0,0 +1,132 @@ +/** Human output and interactive review for Seer fixes. */ +import { safeParse } from "valibot"; +import { + FileApprovalDataSchema, + type FixResult, + type PendingUserInput, + QuestionDataSchema, + type SeerResponseData, +} from "../../types/seer-fix.js"; +import type { Writer } from "../../types/index.js"; +import type { ExplorerBlock } from "../../types/seer-fix.js"; +import { logger } from "../logger.js"; +import { escapeMarkdownInline, renderMarkdown } from "./markdown.js"; + +export function formatSeerFix(result: FixResult): string { + const lines = [ + `# Seer fix`, + `Run: ${escapeMarkdownInline(String(result.run_id))}`, + `Status: ${escapeMarkdownInline(result.status)}`, + ]; + if (result.pending_user_input) { + lines.push( + `Waiting for input: ${escapeMarkdownInline(result.pending_user_input.input_type)} (${escapeMarkdownInline(result.pending_user_input.id)})`, + ); + } + for (const patch of result.patches) { + lines.push( + `## ${escapeMarkdownInline(patch.repo_name)}: ${escapeMarkdownInline(patch.patch.path)}`, + "```diff", + patch.diff, + "```", + ); + } + if (result.status === "COMPLETED" && result.patches.length === 0) { + lines.push("Seer completed without code changes."); + } + for (const url of result.pr_urls) { + lines.push(url); + } + if (result.error) { + lines.push(escapeMarkdownInline(result.error)); + } + if (result.next_action) { + lines.push("", result.next_action); + } + return renderMarkdown(lines.join("\n\n")); +} + +/** Prompt only for the exact input contracts the backend has verified. */ +export async function promptSeerInput( + input: PendingUserInput, + writer: Writer, +): Promise { + const log = logger.withTag("issue.fix"); + if (input.input_type === "ask_user_question") { + const parsed = safeParse(QuestionDataSchema, input.data); + if (!parsed.success) { + return null; + } + const answers: string[] = []; + for (const question of parsed.output.questions) { + for (const option of question.options) { + writer.write(`${option.label}: ${option.description}\n`); + } + const selection = + question.options.length > 0 + ? await log.prompt(question.question, { + type: "select", + cancel: "symbol", + options: [ + ...question.options.map((option) => option.label), + "Other (enter an answer)", + ], + }) + : "Other (enter an answer)"; + const answer = + selection === "Other (enter an answer)" + ? await log.prompt(question.question, { + type: "text", + cancel: "symbol", + }) + : selection; + if (typeof answer !== "string" || !answer.trim()) { + return null; + } + answers.push(answer); + } + return { answers }; + } + if (input.input_type === "file_change_approval") { + const parsed = safeParse(FileApprovalDataSchema, input.data); + if (!parsed.success) { + return null; + } + const decisions: boolean[] = []; + for (const entry of parsed.output.patches) { + writer.write(`${entry.repo_name}: ${entry.patch.path}\n`); + for (const hunk of entry.patch.hunks) { + writer.write( + `@@ -${hunk.source_start},${hunk.source_length} +${hunk.target_start},${hunk.target_length} @@ ${hunk.section_header}\n`, + ); + for (const line of hunk.lines) { + writer.write(`${line.line_type}${line.value}\n`); + } + } + const decision = await log.prompt("Approve this proposed file change?", { + type: "confirm", + initial: false, + cancel: "symbol", + }); + if (typeof decision !== "boolean") { + return null; + } + decisions.push(decision); + } + return { decisions }; + } + return null; +} + +/** Render private transcript directly to the terminal, avoiding telemetry reporters. */ +export function createSeerBlockRenderer( + writer: Writer, +): (blocks: ExplorerBlock[]) => void { + return (blocks) => { + for (const block of blocks) { + if (block.message?.content) { + writer.write(`${renderMarkdown(block.message.content)}\n`); + } + } + }; +} diff --git a/packages/cli/src/lib/seer-fix.ts b/packages/cli/src/lib/seer-fix.ts new file mode 100644 index 000000000..08bf23e01 --- /dev/null +++ b/packages/cli/src/lib/seer-fix.ts @@ -0,0 +1,315 @@ +/** Interactive Seer orchestration; JSON callers receive one resumable result. */ +import { setTimeout as sleep } from "node:timers/promises"; +import { safeParse } from "valibot"; +import { isTerminalStatus } from "../types/seer.js"; +import { + type ExplorerBlock, + type ExplorerSession, + FileApprovalDataSchema, + type FixResult, + type PendingUserInput, + QuestionDataSchema, + type SeerResponseData, + type UserInputResponse, +} from "../types/seer-fix.js"; +import { + getSeerExplorerState, + respondToSeerUserInput, + triggerIssueFix, +} from "./api-client.js"; +import { redactCredentialText } from "./credential-redaction.js"; +import { ValidationError } from "./errors.js"; +import { buildIssueUrl } from "./sentry-urls.js"; + +export type FixOptions = { + org: string; + issueId: string; + issueArg: string; + runId?: string | number; + instruction?: string; + response?: UserInputResponse; + interactive: boolean; + prompt?: (input: PendingUserInput) => Promise; + onBlocks?: (blocks: ExplorerBlock[]) => void; + timeoutMs?: number; + pollIntervalMs?: number; + signal?: AbortSignal; +}; + +/** Match the UI: aggregate merged patches, with later blocks overriding each file. */ +export function extractFixPatches( + session: ExplorerSession, +): FixResult["patches"] { + const blocks = session.blocks ?? []; + const hasMerged = blocks.some( + (block) => + block.merged_file_patches !== null && + block.merged_file_patches !== undefined, + ); + const patches = new Map(); + for (const block of blocks) { + for (const patch of (hasMerged + ? block.merged_file_patches + : block.file_patches) ?? []) { + patches.set(`${patch.repo_name}:${patch.patch.path}`, patch); + } + } + return [...patches.values()]; +} + +/** Validate the answer against the current question, never just the file's shape. */ +export function validateSeerResponse( + input: PendingUserInput, + response: UserInputResponse, +): void { + if (response.input_id !== input.id) { + throw new ValidationError( + "Response does not match the current pending input ID.", + "--response", + ); + } + const data = response.response_data; + if (input.input_type === "ask_user_question") { + const parsed = safeParse(QuestionDataSchema, input.data); + if ( + parsed.success && + "answers" in data && + data.answers.length === parsed.output.questions.length && + data.answers.every((answer) => answer.trim().length > 0) + ) { + return; + } + } else if (input.input_type === "file_change_approval") { + const parsed = safeParse(FileApprovalDataSchema, input.data); + if ( + parsed.success && + "decisions" in data && + data.decisions.length === parsed.output.patches.length + ) { + return; + } + } else { + throw new ValidationError( + `Input type '${input.input_type}' requires the Sentry UI.`, + "--response", + ); + } + throw new ValidationError( + "Response data must include one answer or decision for each current question or patch.", + "--response", + ); +} + +function supportsPrompt(input: PendingUserInput): boolean { + if (input.input_type === "ask_user_question") { + const result = safeParse(QuestionDataSchema, input.data); + return result.success && result.output.questions.length > 0; + } + if (input.input_type === "file_change_approval") { + const result = safeParse(FileApprovalDataSchema, input.data); + return result.success && result.output.patches.length > 0; + } + return false; +} + +function resumeCommand(options: FixOptions, runId: string | number): string { + return `sentry issue fix ${options.issueArg} --run-id ${runId}`; +} + +/** Preserve the selected chat when browser authorization is required. */ +function buildSeerRunUrl(options: FixOptions, runId: string | number): string { + const url = new URL(buildIssueUrl(options.org, options.issueId)); + url.searchParams.set("explorerRunId", String(runId)); + return url.toString(); +} + +/** Shared result contract is also used by local patch and PR delivery. */ +export function buildFixResult( + runId: string | number, + session: ExplorerSession, + nextAction?: string, +): FixResult { + return { + run_id: runId, + status: + session.status === "COMPLETED" && session.pending_user_input + ? "WAITING_FOR_USER_RESPONSE" + : session.status, + pending_user_input: session.pending_user_input ?? null, + patches: extractFixPatches(session), + pr_urls: [ + ...new Set( + Object.values(session.repo_pr_states ?? {}).flatMap((state) => + state.pr_url ? [state.pr_url] : [], + ), + ), + ], + ...(nextAction ? { next_action: nextAction } : {}), + }; +} + +async function selectRun( + options: FixOptions, + signal: AbortSignal, +): Promise { + if (options.runId !== undefined) { + return options.runId; + } + const run = await triggerIssueFix(options.org, options.issueId, { + instruction: options.instruction, + signal, + }); + const runId = run.sentry_run_id ?? run.run_id; + if (runId === undefined) { + throw new Error("Unexpected fix response: missing run ID."); + } + return runId; +} + +/** Poll the pinned run, submitting each pending ID at most once. */ +export async function runSeerFix(options: FixOptions): Promise { + if (options.response && options.runId === undefined) { + throw new ValidationError("--response requires --run-id.", "--response"); + } + if (options.instruction !== undefined && options.runId !== undefined) { + throw new ValidationError( + "--instruction applies to a new fix; omit --run-id.", + "--instruction", + ); + } + const timeoutMs = Math.max(1, Math.ceil(options.timeoutMs ?? 360_000)); + const deadline = Date.now() + timeoutMs; + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const signal = options.signal + ? AbortSignal.any([options.signal, timeoutSignal]) + : timeoutSignal; + const runId = await selectRun(options, signal); + let session: ExplorerSession = { status: "PROCESSING" }; + let fileResponse = options.response; + const submitted = new Set(); + const rendered = new Set(); + try { + while (Date.now() < deadline && !signal.aborted) { + let state: ExplorerSession | null; + try { + state = await getSeerExplorerState(options.org, runId, { signal }); + } catch (error) { + if (signal.aborted || Date.now() >= deadline) { + break; + } + throw error; + } + if (state) { + session = state; + const newBlocks = (session.blocks ?? []).filter( + (block) => + !rendered.has(block.id) && block.message?.content && !block.loading, + ); + for (const block of newBlocks) { + rendered.add(block.id); + } + options.onBlocks?.(newBlocks); + const input = session.pending_user_input; + if (fileResponse) { + if (!input) { + throw new ValidationError( + "The run has no current pending input.", + "--response", + ); + } + validateSeerResponse(input, fileResponse); + submitted.add(input.id); + const response = fileResponse; + fileResponse = undefined; + signal.throwIfAborted(); + await respondToSeerUserInput(options.org, runId, response, { + signal, + }); + } else if (input && !submitted.has(input.id)) { + if ( + !(options.interactive && options.prompt && supportsPrompt(input)) + ) { + const action = supportsPrompt(input) + ? `Resume with ${resumeCommand(options, runId)} --response (containing input_id and response_data).` + : `Open ${buildSeerRunUrl(options, runId)} to handle '${input.input_type}', then resume with ${resumeCommand(options, runId)}.`; + return buildFixResult(runId, session, action); + } + const responseData = await options.prompt(input); + if (responseData === null) { + return { + ...buildFixResult( + runId, + session, + `Resume with ${resumeCommand(options, runId)}.`, + ), + status: "CANCELLED", + }; + } + const response = { input_id: input.id, response_data: responseData }; + validateSeerResponse(input, response); + submitted.add(input.id); + signal.throwIfAborted(); + await respondToSeerUserInput(options.org, runId, response, { + signal, + }); + } else if (!input && isTerminalStatus(session.status)) { + const action = + session.status === "COMPLETED" && + extractFixPatches(session).length === 0 + ? `Seer produced no code changes. Start a new fix with sentry issue fix ${options.issueArg}.` + : undefined; + return buildFixResult(runId, session, action); + } else if ( + !input && + (session.status === "WAITING_FOR_USER_RESPONSE" || + session.status === "NEED_MORE_INFORMATION") + ) { + return buildFixResult( + runId, + session, + `Open ${buildSeerRunUrl(options, runId)} to provide input, then resume with ${resumeCommand(options, runId)}.`, + ); + } + } + try { + await sleep( + Math.min( + options.pollIntervalMs ?? 1000, + Math.max(0, deadline - Date.now()), + ), + undefined, + { signal }, + ); + } catch (error) { + if (!signal.aborted) { + throw error; + } + } + } + } catch (error) { + if (error instanceof ValidationError) { + throw error; + } + if (!signal.aborted) { + return { + ...buildFixResult( + runId, + session, + `The update may have been accepted. Refresh the run with ${resumeCommand(options, runId)} before deciding whether to respond again.`, + ), + status: "ERROR", + error: redactCredentialText( + error instanceof Error ? error.message : "Seer request failed.", + ), + }; + } + } + return { + ...buildFixResult( + runId, + session, + `The run may still be active. Resume with ${resumeCommand(options, runId)}.`, + ), + status: options.signal?.aborted ? "CANCELLED" : "TIMEOUT", + }; +} diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index 996d172c9..6fc57a6c6 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -584,6 +584,8 @@ type RetryContext = { fullUrl: string; credential: CredentialContext; validatedRedirects: boolean; + retry: boolean; + bypassCache: boolean; }; async function fetchWithRetry({ @@ -593,6 +595,8 @@ async function fetchWithRetry({ fullUrl, credential, validatedRedirects, + retry, + bypassCache, }: RetryContext): Promise { const issuedAt = credential.issuedAt ?? @@ -619,12 +623,19 @@ async function fetchWithRetry({ const attemptFactory = await buildAttemptFactory(input, init); const timeoutMs = resolveTimeoutMs(fullUrl); - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - const isLastAttempt = attempt === MAX_RETRIES; + // A 307/308 would resend the mutation body even when retries are disabled. + // Require callers to inspect an unexpected redirect before resubmitting. + const attemptInitPolicy = retry ? undefined : { redirect: "error" as const }; + + const maxRetries = retry ? MAX_RETRIES : 0; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const isLastAttempt = attempt === maxRetries; const { input: attemptInput, init: attemptInit } = attemptFactory(); const result = await executeAttempt({ input: attemptInput, - init: attemptInit, + init: attemptInitPolicy + ? { ...attemptInit, ...attemptInitPolicy } + : attemptInit, headers, credential: effectiveCredential, isLastAttempt, @@ -633,15 +644,18 @@ async function fetchWithRetry({ }); if (result.action === "done") { - cacheResponse( - { - method, - fullUrl, - requestHeaders: { authorization: headers.get("Authorization") ?? "" }, - identity: credential.identity, - }, - result.response, - ); + if (!bypassCache) + cacheResponse( + { + method, + fullUrl, + requestHeaders: { + authorization: headers.get("Authorization") ?? "", + }, + identity: credential.identity, + }, + result.response, + ); await invalidateAfterMutation( method, fullUrl, @@ -687,6 +701,10 @@ async function fetchWithRetry({ type AuthenticatedFetchOptions = { credential?: CredentialContext; validatedRedirects?: boolean; + /** Set false when a mutation must not be replayed after an uncertain response. */ + retry?: boolean; + /** Bypass both cache lookup and storage for live state polling. */ + bypassCache?: boolean; }; function createAuthenticatedFetch( @@ -729,14 +747,15 @@ function createAuthenticatedFetch( } // A synthetic HTTP cache response has no validated final-origin or // credential provenance. Discovery must obtain both from the network. - const cached = options.validatedRedirects - ? undefined - : await tryCacheHit( - method, - fullUrl, - authHeaders(credential.token), - credential.identity, - ); + const cached = + options.validatedRedirects || options.bypassCache + ? undefined + : await tryCacheHit( + method, + fullUrl, + authHeaders(credential.token), + credential.identity, + ); if (cached) { span.setAttribute("http.response.status_code", cached.status); log.debug( @@ -752,6 +771,8 @@ function createAuthenticatedFetch( fullUrl, credential, validatedRedirects: options.validatedRedirects === true, + retry: options.retry !== false, + bypassCache: options.bypassCache === true, }); span.setAttribute("http.response.status_code", response.status); if (!response.ok) { @@ -1001,7 +1022,12 @@ let cachedFetch: typeof fetch | null = null; function getAuthenticatedFetch( options: AuthenticatedFetchOptions = {}, ): typeof fetch { - if (options.credential || options.validatedRedirects) { + if ( + options.credential || + options.validatedRedirects || + options.retry === false || + options.bypassCache + ) { return createAuthenticatedFetch(options) as unknown as typeof fetch; } if (!cachedFetch) { diff --git a/packages/cli/src/types/seer-fix.ts b/packages/cli/src/types/seer-fix.ts new file mode 100644 index 000000000..0d2198eee --- /dev/null +++ b/packages/cli/src/types/seer-fix.ts @@ -0,0 +1,114 @@ +/** Wire contracts verified against Sentry's Seer Explorer UI and endpoints. */ +import { + array, + boolean, + type InferOutput, + looseObject, + nullable, + number, + object, + optional, + picklist, + record, + string, + union, + unknown, +} from "valibot"; + +const DiffLineSchema = object({ + diff_line_no: nullable(number()), + line_type: picklist(["+", "-", " "]), + source_line_no: nullable(number()), + target_line_no: nullable(number()), + value: string(), +}); +export const FilePatchSchema = object({ + added: number(), + removed: number(), + path: string(), + source_file: string(), + target_file: string(), + type: picklist(["A", "M", "D"]), + hunks: array( + object({ + lines: array(DiffLineSchema), + section_header: string(), + source_length: number(), + source_start: number(), + target_length: number(), + target_start: number(), + }), + ), +}); +export const ExplorerFilePatchSchema = object({ + code_url: optional(nullable(string())), + diff: string(), + patch: FilePatchSchema, + repo_name: string(), +}); +export type ExplorerFilePatch = InferOutput; +export const PendingUserInputSchema = object({ + id: string(), + input_type: string(), + data: record(string(), unknown()), +}); +export type PendingUserInput = InferOutput; +export const QuestionDataSchema = object({ + questions: array( + object({ + question: string(), + options: array(object({ label: string(), description: string() })), + }), + ), +}); +export const FileApprovalDataSchema = object({ + patches: array(object({ patch: FilePatchSchema, repo_name: string() })), +}); +export const UserInputResponseSchema = object({ + input_id: string(), + response_data: union([ + object({ answers: array(string()) }), + object({ decisions: array(boolean()) }), + ]), +}); +export type UserInputResponse = InferOutput; +export type SeerResponseData = UserInputResponse["response_data"]; +export const ExplorerBlockSchema = looseObject({ + id: string(), + message: optional( + looseObject({ content: nullable(string()), role: string() }), + ), + file_patches: optional(nullable(array(ExplorerFilePatchSchema))), + merged_file_patches: optional(nullable(array(ExplorerFilePatchSchema))), +}); +export type ExplorerBlock = InferOutput; +export const ExplorerSessionSchema = looseObject({ + status: string(), + blocks: optional(array(ExplorerBlockSchema)), + pending_user_input: optional(nullable(PendingUserInputSchema)), + repo_pr_states: optional( + record( + string(), + looseObject({ + repo_name: string(), + pr_url: optional(nullable(string())), + pr_creation_status: optional(nullable(string())), + pr_creation_error: optional(nullable(string())), + }), + ), + ), +}); +export type ExplorerSession = InferOutput; +export const ExplorerResponseSchema = object({ + session: nullable(ExplorerSessionSchema), + sentry_run_id: optional(nullable(string())), +}); +export type FixResult = { + run_id: string | number; + status: string; + pending_user_input: PendingUserInput | null; + patches: ExplorerFilePatch[]; + pr_urls: string[]; + next_action?: string; + error?: string; +}; diff --git a/packages/cli/test/commands/debug-files/upload.test.ts b/packages/cli/test/commands/debug-files/upload.test.ts index 7af8d006e..960682572 100644 --- a/packages/cli/test/commands/debug-files/upload.test.ts +++ b/packages/cli/test/commands/debug-files/upload.test.ts @@ -9,7 +9,7 @@ import { readFileSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { run } from "@stricli/core"; import { zipSync } from "fflate"; @@ -22,6 +22,11 @@ import * as chunkUpload from "../../../src/lib/api/chunk-upload.js"; import * as debugFilesApi from "../../../src/lib/api/debug-files.js"; import { useTestConfigDir } from "../../helpers.js"; +vi.mock("node:os", async (importOriginal) => { + const os = await importOriginal(); + return { ...os, homedir: vi.fn(os.homedir) }; +}); + useTestConfigDir("debug-files-upload-"); const BREAKPAD_FIXTURE = [ @@ -39,6 +44,8 @@ let savedEnv: Record; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "df-upload-test-")); + // DerivedData scans must stay inside the fixture, including on macOS. + vi.mocked(homedir).mockReturnValue(tempDir); savedEnv = { SENTRY_ORG: process.env.SENTRY_ORG, SENTRY_PROJECT: process.env.SENTRY_PROJECT, diff --git a/packages/cli/test/constants.ts b/packages/cli/test/constants.ts index fc4522f54..698cfd9d3 100644 --- a/packages/cli/test/constants.ts +++ b/packages/cli/test/constants.ts @@ -11,10 +11,10 @@ import { join } from "node:path"; /** * Namespaced subdirectory under the OS temp dir for all test artifacts. * - * Under `bun test --parallel`, each worker process gets its own subdir - * keyed by `VITEST_POOL_ID` so workers don't wipe each other's - * temp state during preload. Serial runs (no worker ID) use a plain - * `sentry-cli-test` dir — same as before. + * Vitest recycles pool IDs, including across separate runner invocations. + * Include the process ID so preload cleanup cannot delete another fork's + * database or fixtures. Serial scripts without a worker ID retain the + * `sentry-cli-test` directory. * * Tests that create fixed-name subdirs under `TEST_TMP_DIR` (e.g. * `upgrade-lock-test`) still get a unique path per worker because the @@ -22,5 +22,5 @@ import { join } from "node:path"; */ const WORKER_ID = process.env.VITEST_POOL_ID; export const TEST_TMP_DIR = WORKER_ID - ? join(tmpdir(), `sentry-cli-test-w${WORKER_ID}`) + ? join(tmpdir(), `sentry-cli-test-w${WORKER_ID}-p${process.pid}`) : join(tmpdir(), "sentry-cli-test"); diff --git a/packages/cli/test/e2e/seer-fix.test.ts b/packages/cli/test/e2e/seer-fix.test.ts new file mode 100644 index 000000000..0f58331d4 --- /dev/null +++ b/packages/cli/test/e2e/seer-fix.test.ts @@ -0,0 +1,556 @@ +/** + * Subprocess coverage for resumable Seer fixes. The local API fixtures follow + * Sentry's interactive explorer chat/update endpoint contracts. + */ + +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { createE2EContext, type E2EContext } from "../fixture.js"; +import issueFixture from "../fixtures/issue.json"; +import { useTestConfigDir } from "../helpers.js"; +import { + apiRoutes, + TEST_ISSUE_ID, + TEST_ORG, + TEST_TOKEN, +} from "../mocks/routes.js"; +import { + createMockServer, + type MockResponse, + type MockRoute, + type MockServer, + type RouteHandler, +} from "../mocks/server.js"; + +const RUN_ID = "b8781a24-930f-4d50-b9cb-ef460b471cef"; +const OTHER_RUN_ID = "5a1e12bc-027d-4c92-8345-03e7dfd2a0ba"; +const ISSUE = `${TEST_ORG}/${TEST_ISSUE_ID}`; +const AUTOFIX = `/api/0/organizations/${TEST_ORG}/issues/${TEST_ISSUE_ID}/autofix/`; +const CHAT = `/api/0/organizations/${TEST_ORG}/seer/explorer-chat/:runId/`; +const START = `/api/0/organizations/${TEST_ORG}/seer/explorer-chat/`; +const UPDATE = `/api/0/organizations/${TEST_ORG}/seer/explorer-update/:runId/`; +const QUESTION = { + id: "question-1", + input_type: "ask_user_question", + data: { + questions: [ + { + question: "How should missing users be handled?", + options: [ + { label: "Return null", description: "Preserve the existing API" }, + ], + }, + ], + }, +}; +const PATCH = { + repo_name: "example/backend", + code_url: null, + diff: "--- a/users.ts\n+++ b/users.ts\n@@ -1 +1 @@\n-return user.name;\n+return user?.name;\n", + patch: { + added: 1, + removed: 1, + hunks: [ + { + source_start: 1, + source_length: 1, + target_start: 1, + target_length: 1, + section_header: "", + lines: [ + { + line_type: "-", + value: "return user.name;", + source_line_no: 1, + target_line_no: null, + diff_line_no: 1, + }, + { + line_type: "+", + value: "return user?.name;", + source_line_no: null, + target_line_no: 1, + diff_line_no: 2, + }, + ], + }, + ], + path: "users.ts", + source_file: "users.ts", + target_file: "users.ts", + type: "M", + }, +}; + +type RecordedRequest = { method: string; path: string; body: unknown }; +type Session = { + status: string; + updated_at: string; + pending_user_input: + | typeof QUESTION + | { id: string; input_type: string; data: Record } + | null; + blocks: unknown[]; + repo_pr_states: Record; +}; + +/** Produce a complete explorer session envelope using the backend's wire names. */ +function session( + status: string, + pending: Session["pending_user_input"] = null, +): Session { + return { + status, + updated_at: "2026-10-13T12:00:00Z", + pending_user_input: pending, + blocks: [], + repo_pr_states: {}, + }; +} + +const getConfigDir = useTestConfigDir("e2e-seer-fix-"); +let mockServer: MockServer; +let ctx: E2EContext; +let requests: RecordedRequest[]; +let currentAutofix: unknown; +let activeSession: Session; +let chatHandler: RouteHandler; +let startResponse: MockResponse; +let updateResponse: MockResponse; + +/** Record both custom Seer routes and shared auth/issue fixture requests. */ +function recordRoute(route: MockRoute): MockRoute { + return { + ...route, + response: async ( + req: Request, + params: Record, + serverUrl: string, + ) => { + const url = new URL(req.url); + requests.push({ + method: req.method, + path: url.pathname, + body: req.method === "GET" ? null : await req.clone().json(), + }); + return typeof route.response === "function" + ? (route.response as RouteHandler)(req, params, serverUrl) + : { status: route.status, body: route.response }; + }, + }; +} + +beforeEach(async () => { + requests = []; + currentAutofix = null; + activeSession = session("awaiting_user_input", QUESTION); + startResponse = { status: 202, body: { run_id: 72, sentry_run_id: RUN_ID } }; + updateResponse = { status: 202, body: { success: true } }; + chatHandler = () => ({ + body: { session: activeSession, sentry_run_id: RUN_ID }, + }); + const routes: MockRoute[] = [ + { + method: "GET", + path: `/api/0/organizations/${TEST_ORG}/issues/${TEST_ISSUE_ID}/`, + response: issueFixture, + }, + { + method: "GET", + path: AUTOFIX, + response: () => ({ body: { autofix: currentAutofix } }), + }, + { method: "POST", path: START, response: () => startResponse }, + { + method: "GET", + path: CHAT, + response: (req, params, url) => chatHandler(req, params, url), + }, + { method: "POST", path: UPDATE, response: () => updateResponse }, + ...apiRoutes, + ]; + mockServer = createMockServer(routes.map(recordRoute), { + validTokens: [TEST_TOKEN], + }); + await mockServer.start(); + ctx = createE2EContext(getConfigDir(), mockServer.url); + await ctx.setAuthToken(TEST_TOKEN); +}); + +afterEach(() => mockServer.stop()); + +/** Return only mutations so the tests enforce no unintended or duplicate POST. */ +function mutations(): RecordedRequest[] { + return requests.filter((req) => req.method === "POST"); +} + +/** Write a response file inside the test's automatically cleaned config directory. */ +async function responseFile(value: unknown): Promise { + const path = join(getConfigDir(), "response.json"); + await writeFile(path, JSON.stringify(value), "utf8"); + return path; +} + +describe("sentry issue fix", () => { + test("starts coding and returns one JSON question result pinned to the UUID", async () => { + const result = await ctx.run([ + "issue", + "fix", + ISSUE, + "--json", + "--instruction", + "Keep the API compatible", + ]); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + run_id: RUN_ID, + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: QUESTION, + patches: [], + pr_urls: [], + }); + expect(JSON.parse(result.stdout).next_action).toContain(RUN_ID); + expect(JSON.parse(result.stdout).next_action).toContain("--response"); + expect(mutations()).toEqual([ + { + method: "POST", + path: START, + body: { + query: expect.stringContaining("Keep the API compatible"), + on_page_context: expect.stringContaining(`Sentry issue: ${ISSUE}`), + }, + }, + ]); + expect(mutations()[0]?.body).toMatchObject({ + query: expect.stringContaining(ISSUE), + }); + expect(mutations()[0]?.body).toMatchObject({ + query: expect.stringMatching(/do not create pull requests/i), + }); + expect(requests.filter((req) => req.path === AUTOFIX)).toEqual([]); + expect( + requests.some((req) => req.path.endsWith(`/explorer-chat/${RUN_ID}/`)), + ).toBe(true); + expect(result.stdout).not.toContain(TEST_TOKEN); + }); + + test("resumes the requested run, submits a wrapped response once and returns completed diffs", async () => { + const path = await responseFile({ + input_id: QUESTION.id, + response_data: { answers: ["Return null"] }, + }); + chatHandler = () => ({ + body: { + session: mutations().length + ? { + ...session("completed"), + blocks: [ + { + id: "patch-1", + timestamp: "2026-10-13T12:00:00Z", + message: { role: "assistant", content: "Fix ready" }, + merged_file_patches: [PATCH], + }, + ], + repo_pr_states: { + "example/backend": { + repo_name: "example/backend", + pr_url: "https://github.com/example/backend/pull/42", + pr_creation_status: "completed", + }, + }, + } + : activeSession, + sentry_run_id: RUN_ID, + }, + }); + + const result = await ctx.run([ + "issue", + "fix", + ISSUE, + "--run-id", + RUN_ID, + "--response", + path, + "--json", + ]); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + run_id: RUN_ID, + status: "COMPLETED", + pending_user_input: null, + patches: [PATCH], + pr_urls: ["https://github.com/example/backend/pull/42"], + }); + expect(mutations()).toEqual([ + { + method: "POST", + path: UPDATE.replace(":runId", RUN_ID), + body: { + payload: { + type: "user_input_response", + input_id: QUESTION.id, + response_data: { answers: ["Return null"] }, + }, + }, + }, + ]); + expect(requests.filter((req) => req.path === AUTOFIX)).toEqual([]); + expect( + requests + .filter((req) => req.path.includes("explorer-chat/")) + .every((req) => req.path.endsWith(`/${RUN_ID}/`)), + ).toBe(true); + }); + + test("resumes an explicitly selected completed run without starting a new one", async () => { + activeSession = { + ...session("completed"), + blocks: [ + { + id: "existing-patch", + message: { role: "assistant", content: "Existing fix" }, + file_patches: [PATCH], + }, + ], + }; + chatHandler = () => ({ + body: { session: activeSession, sentry_run_id: OTHER_RUN_ID }, + }); + + const result = await ctx.run([ + "issue", + "fix", + ISSUE, + "--run-id", + OTHER_RUN_ID, + "--json", + ]); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).run_id).toBe(OTHER_RUN_ID); + expect(JSON.parse(result.stdout).patches).toEqual([PATCH]); + expect(mutations()).toEqual([]); + expect(requests.filter((req) => req.path === AUTOFIX)).toEqual([]); + }); + + test("starts a new interactive fix without consulting the issue's earlier autofix run", async () => { + currentAutofix = { + run_id: 73, + sentry_run_id: OTHER_RUN_ID, + status: "completed", + blocks: [], + }; + const result = await ctx.run(["issue", "fix", ISSUE, "--json"]); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).run_id).toBe(RUN_ID); + expect(mutations()).toHaveLength(1); + expect(mutations()[0]?.path).toBe(START); + expect(requests.filter((req) => req.path === AUTOFIX)).toEqual([]); + }); + + test("does not replay a submitted answer when the same pending input is still visible", async () => { + const path = await responseFile({ + input_id: QUESTION.id, + response_data: { answers: ["Return null"] }, + }); + const result = await ctx.run([ + "issue", + "fix", + ISSUE, + "--run-id", + RUN_ID, + "--response", + path, + "--timeout", + "1", + "--json", + ]); + + expect(JSON.parse(result.stdout)).toMatchObject({ + run_id: RUN_ID, + pending_user_input: QUESTION, + }); + expect(mutations()).toHaveLength(1); + expect(mutations()[0]?.path).toBe(UPDATE.replace(":runId", RUN_ID)); + }); + + test.each([ + { input_id: "stale-question", response_data: { answers: ["Return null"] } }, + { input_id: QUESTION.id, response_data: { decisions: [true] } }, + ])( + "rejects stale or wrong-kind answers before any mutation: %j", + async (answer) => { + const path = await responseFile(answer); + const result = await ctx.run([ + "issue", + "fix", + ISSUE, + "--run-id", + RUN_ID, + "--response", + path, + "--json", + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr + result.stdout).toMatch( + /pending|response|input|answer/i, + ); + expect(mutations()).toEqual([]); + }, + ); + + test("rejects malformed response files and response without run ID", async () => { + const path = join(getConfigDir(), "malformed.json"); + await writeFile(path, "{not json", "utf8"); + const malformed = await ctx.run([ + "issue", + "fix", + ISSUE, + "--run-id", + RUN_ID, + "--response", + path, + "--json", + ]); + const noRun = await ctx.run([ + "issue", + "fix", + ISSUE, + "--response", + path, + "--json", + ]); + + expect(malformed.exitCode).not.toBe(0); + expect(noRun.exitCode).not.toBe(0); + expect(noRun.stderr + noRun.stdout).toMatch(/run-id/i); + expect(mutations()).toEqual([]); + }); + + test("pauses unknown approvals without submitting an automatic approval", async () => { + activeSession = session("awaiting_user_input", { + id: "approval-1", + input_type: "future_approval", + data: { action: "write" }, + }); + const result = await ctx.run([ + "issue", + "fix", + ISSUE, + "--run-id", + RUN_ID, + "--json", + ]); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + run_id: RUN_ID, + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: activeSession.pending_user_input, + }); + expect(mutations()).toEqual([]); + }); + + test("non-TTY human output pauses with the run ID and previews completed diffs", async () => { + const paused = await ctx.run(["issue", "fix", ISSUE, "--run-id", RUN_ID]); + expect(paused.exitCode, paused.stderr).toBe(0); + expect(paused.stdout + paused.stderr).toContain(RUN_ID); + expect(mutations()).toEqual([]); + + activeSession = { + ...session("completed"), + blocks: [ + { + id: "patch-1", + timestamp: "2026-10-13T12:00:00Z", + message: { role: "assistant", content: "Fix ready" }, + file_patches: [PATCH], + }, + ], + }; + const completed = await ctx.run([ + "issue", + "fix", + ISSUE, + "--run-id", + RUN_ID, + "--fresh", + ]); + expect(completed.exitCode, completed.stderr).toBe(0); + expect(completed.stdout).toContain("users.ts"); + expect(completed.stdout).toContain("return user?.name;"); + expect(mutations()).toEqual([]); + }); + + test("bounds processing timeout and keeps the run ID available for resumption", async () => { + activeSession = session("processing"); + const started = Date.now(); + const result = await ctx.run([ + "issue", + "fix", + ISSUE, + "--run-id", + RUN_ID, + "--timeout", + "1", + "--json", + ]); + + expect(result.exitCode).not.toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + run_id: RUN_ID, + status: "TIMEOUT", + patches: [], + pr_urls: [], + }); + expect(Date.now() - started).toBeLessThan(10_000); + expect(mutations()).toEqual([]); + }); + + test("surfaces coding access errors without retrying the start mutation", async () => { + startResponse = { + status: 403, + body: { detail: "Code generation is disabled for this organization" }, + }; + const result = await ctx.run(["issue", "fix", ISSUE, "--json"]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr + result.stdout).toMatch( + /disabled|permission|access/i, + ); + expect(mutations()).toHaveLength(1); + }); + + test("does not retry an answer mutation after a service error", async () => { + updateResponse = { + status: 503, + body: { detail: "Seer temporarily unavailable" }, + }; + const path = await responseFile({ + input_id: QUESTION.id, + response_data: { answers: ["Return null"] }, + }); + const result = await ctx.run([ + "issue", + "fix", + ISSUE, + "--run-id", + RUN_ID, + "--response", + path, + "--json", + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain(RUN_ID); + expect(mutations()).toHaveLength(1); + expect(mutations()[0]?.path).toBe(UPDATE.replace(":runId", RUN_ID)); + }); +}); diff --git a/packages/cli/test/fixture.ts b/packages/cli/test/fixture.ts index 669fa5720..1614963e3 100644 --- a/packages/cli/test/fixture.ts +++ b/packages/cli/test/fixture.ts @@ -58,6 +58,9 @@ export type CliResult = { export function getCliCommand(): string[] { const binaryPath = process.env.SENTRY_CLI_BINARY; if (binaryPath) { + if (/\.[cm]?js$/i.test(binaryPath)) { + return [process.execPath, binaryPath]; + } return [binaryPath]; } return [ diff --git a/packages/cli/test/lib/api-client.seer.test.ts b/packages/cli/test/lib/api-client.seer.test.ts index 8512e4a40..7ac5cf399 100644 --- a/packages/cli/test/lib/api-client.seer.test.ts +++ b/packages/cli/test/lib/api-client.seer.test.ts @@ -8,6 +8,9 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { normalizeAgentStatus } from "../../src/lib/api/seer.js"; import { getAutofixState, + getSeerExplorerState, + respondToSeerUserInput, + triggerIssueFix, triggerRootCauseAnalysis, triggerSolutionPlanning, } from "../../src/lib/api-client.js"; @@ -386,3 +389,85 @@ describe("normalizeAgentStatus", () => { expect(normalizeAgentStatus("some_new_status")).toBe("SOME_NEW_STATUS"); }); }); + +describe("interactive issue fixes", () => { + const runId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + test("starts a fix through code changes with the supplied instruction", async () => { + let body: unknown; + let url = ""; + globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + url = request.url; + body = await request.json(); + return new Response(JSON.stringify({ sentry_run_id: runId }), { + status: 202, + }); + }; + await triggerIssueFix("test-org", "12345", { + instruction: "Keep the public API", + }); + expect(url).toContain("/organizations/test-org/seer/explorer-chat/"); + expect(body).toMatchObject({ + query: expect.stringContaining("test-org/12345"), + on_page_context: expect.stringContaining("test-org/12345"), + }); + expect(body).toMatchObject({ + query: expect.stringContaining("Keep the public API"), + }); + expect(body).toMatchObject({ + query: expect.stringContaining("Do not create pull requests"), + }); + }); + test("reads the pinned session and normalizes its status", async () => { + let url = ""; + globalThis.fetch = async (input, init) => { + url = new Request(input, init).url; + return new Response( + JSON.stringify({ + session: { + status: "awaiting_user_input", + blocks: [], + pending_user_input: { + id: "q1", + input_type: "ask_user_question", + data: { + questions: [{ question: "Which behavior?", options: [] }], + }, + }, + }, + sentry_run_id: runId, + }), + ); + }; + const state = await getSeerExplorerState("test-org", runId); + expect(url).toContain( + `/organizations/test-org/seer/explorer-chat/${runId}/`, + ); + expect(state?.status).toBe("WAITING_FOR_USER_RESPONSE"); + expect(state?.pending_user_input?.id).toBe("q1"); + }); + test("wraps a structured answer in the explorer update payload", async () => { + let body: unknown; + let url = ""; + globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + url = request.url; + body = await request.json(); + return new Response(JSON.stringify({ success: true })); + }; + await respondToSeerUserInput("test-org", runId, { + input_id: "q1", + response_data: { answers: ["Keep behavior"] }, + }); + expect(url).toContain( + `/organizations/test-org/seer/explorer-update/${runId}/`, + ); + expect(body).toEqual({ + payload: { + type: "user_input_response", + input_id: "q1", + response_data: { answers: ["Keep behavior"] }, + }, + }); + }); +}); diff --git a/packages/cli/test/lib/api-client.test.ts b/packages/cli/test/lib/api-client.test.ts index 83912265b..f8f45f9e0 100644 --- a/packages/cli/test/lib/api-client.test.ts +++ b/packages/cli/test/lib/api-client.test.ts @@ -1885,12 +1885,17 @@ describe("getLogs", () => { // Should have made 2 separate requests expect(capturedUrls).toHaveLength(2); - // First batch: 100 IDs - const url1 = new URL(capturedUrls[0]); + // Both batches run concurrently, so network arrival order is unspecified. + const orderedUrls = [...capturedUrls].sort( + (a, b) => + Number(new URL(b).searchParams.get("per_page")) - + Number(new URL(a).searchParams.get("per_page")), + ); + const url1 = new URL(orderedUrls[0]); expect(url1.searchParams.get("per_page")).toBe(String(API_MAX_PER_PAGE)); // Second batch: 50 IDs - const url2 = new URL(capturedUrls[1]); + const url2 = new URL(orderedUrls[1]); expect(url2.searchParams.get("per_page")).toBe("50"); // Results from both batches should be flattened diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index b10bfc6ea..7fb4b0954 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -15,6 +15,7 @@ import { completeProjectSlugs, getCompletions, } from "../../src/lib/complete.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; import { setProjectAliases } from "../../src/lib/db/project-aliases.js"; import { setCachedProject } from "../../src/lib/db/project-cache.js"; import { @@ -368,3 +369,12 @@ describe("completeAliases", () => { expect(result.some((c) => c.value === "abc")).toBe(true); // prefix match }); }); + +test("completes organization prefixes for issue fix", async () => { + await setAuthToken("completion-fix-test-token"); + await seedOrgs([{ slug: "fix-org", name: "Fix Organization" }]); + expect(getCompletions(["issue", "fix"], "fix")).toContainEqual({ + value: "fix-org/", + description: "Fix Organization", + }); +}); diff --git a/packages/cli/test/lib/formatters/seer-fix.test.ts b/packages/cli/test/lib/formatters/seer-fix.test.ts new file mode 100644 index 000000000..0546f4e3b --- /dev/null +++ b/packages/cli/test/lib/formatters/seer-fix.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, expect, test, vi } from "vitest"; +import { + createSeerBlockRenderer, + promptSeerInput, +} from "../../../src/lib/formatters/seer-fix.js"; +const log = vi.hoisted(() => ({ prompt: vi.fn(), info: vi.fn() })); +vi.mock("../../../src/lib/logger.js", () => ({ + logger: { withTag: () => log }, +})); +beforeEach(() => { + vi.resetAllMocks(); +}); +test("renders private transcript directly to the writer", () => { + let output = ""; + createSeerBlockRenderer({ + write: (chunk) => { + output += chunk; + }, + })([ + { + id: "b1", + message: { role: "assistant", content: "Repository private context" }, + }, + ]); + expect(output).toContain("Repository private context"); + expect(log.info).not.toHaveBeenCalled(); +}); +test("previews file changes without forwarding their content to telemetry", async () => { + let output = ""; + log.prompt.mockResolvedValue(false); + const result = await promptSeerInput( + { + id: "f1", + input_type: "file_change_approval", + data: { + patches: [ + { + repo_name: "private/repo", + patch: { + added: 1, + removed: 0, + path: "index.ts", + source_file: "index.ts", + target_file: "index.ts", + type: "M", + hunks: [ + { + section_header: "", + source_length: 0, + source_start: 1, + target_length: 1, + target_start: 1, + lines: [ + { + diff_line_no: 1, + line_type: "+", + source_line_no: null, + target_line_no: 1, + value: "private repository code", + }, + ], + }, + ], + }, + }, + ], + }, + }, + { + write: (chunk) => { + output += chunk; + }, + }, + ); + expect(result).toEqual({ decisions: [false] }); + expect(output).toContain("+private repository code"); + expect(log.info).not.toHaveBeenCalled(); + expect(log.prompt).toHaveBeenCalledWith( + "Approve this proposed file change?", + { type: "confirm", initial: false, cancel: "symbol" }, + ); +}); +test("allows a custom answer for a question and stops on cancellation", async () => { + log.prompt + .mockResolvedValueOnce("Other (enter an answer)") + .mockResolvedValueOnce("Custom answer"); + const input = { + id: "q1", + input_type: "ask_user_question", + data: { + questions: [ + { + question: "Behavior?", + options: [{ label: "Keep", description: "Preserve behavior" }], + }, + ], + }, + }; + expect(await promptSeerInput(input, { write: () => {} })).toEqual({ + answers: ["Custom answer"], + }); + log.prompt.mockResolvedValueOnce(Symbol("cancel")); + expect(await promptSeerInput(input, { write: () => {} })).toBeNull(); +}); diff --git a/packages/cli/test/lib/install-script.test.ts b/packages/cli/test/lib/install-script.test.ts index 62f20c550..7c6a7c10d 100644 --- a/packages/cli/test/lib/install-script.test.ts +++ b/packages/cli/test/lib/install-script.test.ts @@ -665,6 +665,11 @@ process.exitCode = result.status ?? 1; }); test("directs musl users to the npm package before downloading a glibc binary", () => { + writeFileSync( + join(binDir, "uname"), + "#!/bin/sh\ncase \"$1\" in -s) printf 'Linux\\n';; -m) printf 'x86_64\\n';; esac\n", + { mode: 0o700 }, + ); writeFileSync( join(binDir, "ldd"), "#!/bin/sh\nprintf 'musl libc (x86_64)\\n'\n", diff --git a/packages/cli/test/lib/seer-fix.test.ts b/packages/cli/test/lib/seer-fix.test.ts new file mode 100644 index 000000000..f56a6f47d --- /dev/null +++ b/packages/cli/test/lib/seer-fix.test.ts @@ -0,0 +1,308 @@ +import { beforeEach, expect, test, vi } from "vitest"; +import { runSeerFix } from "../../src/lib/seer-fix.js"; +import { + getAutofixState, + getSeerExplorerState, + respondToSeerUserInput, + triggerIssueFix, +} from "../../src/lib/api-client.js"; +vi.mock("../../src/lib/api-client.js", () => ({ + getAutofixState: vi.fn(), + getSeerExplorerState: vi.fn(), + respondToSeerUserInput: vi.fn(), + triggerIssueFix: vi.fn(), +})); +const pending = { + id: "q1", + input_type: "ask_user_question", + data: { + questions: [ + { + question: "Behavior?", + options: [{ label: "Keep", description: "Keep behavior" }], + }, + ], + }, +}; +const base = { + org: "test-org", + issueId: "12345", + issueArg: "test-org/12345", + interactive: false, + pollIntervalMs: 1, +}; +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getAutofixState).mockResolvedValue(null); + vi.mocked(triggerIssueFix).mockResolvedValue({ sentry_run_id: "run-1" }); +}); +test("JSON pauses with the current input and a pinned resume command", async () => { + vi.mocked(getSeerExplorerState).mockResolvedValue({ + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: pending, + }); + const result = await runSeerFix(base); + expect(result).toMatchObject({ + run_id: "run-1", + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: pending, + patches: [], + pr_urls: [], + }); + expect(result.next_action).toContain("--run-id run-1"); + expect(respondToSeerUserInput).not.toHaveBeenCalled(); +}); +test("submits a matching response once while the same input remains visible", async () => { + vi.mocked(getSeerExplorerState) + .mockResolvedValueOnce({ + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: pending, + }) + .mockResolvedValueOnce({ + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: pending, + }) + .mockResolvedValue({ status: "COMPLETED" }); + const result = await runSeerFix({ + ...base, + runId: "run-1", + response: { input_id: "q1", response_data: { answers: ["Keep"] } }, + }); + expect(result.status).toBe("COMPLETED"); + expect(respondToSeerUserInput).toHaveBeenCalledTimes(1); + expect(getAutofixState).not.toHaveBeenCalled(); + expect(triggerIssueFix).not.toHaveBeenCalled(); +}); +test("rejects a stale response before submission", async () => { + vi.mocked(getSeerExplorerState).mockResolvedValue({ + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: pending, + }); + await expect( + runSeerFix({ + ...base, + runId: "run-1", + response: { input_id: "old", response_data: { answers: ["Keep"] } }, + }), + ).rejects.toThrow("current pending input"); + expect(respondToSeerUserInput).not.toHaveBeenCalled(); +}); +test("preserves a run ID when polling times out", async () => { + vi.mocked(getSeerExplorerState).mockResolvedValue({ status: "PROCESSING" }); + const result = await runSeerFix({ ...base, timeoutMs: 5 }); + expect(result.status).toBe("TIMEOUT"); + expect(result.run_id).toBe("run-1"); + expect(result.next_action).toContain("--run-id run-1"); +}); +test("does not autoanswer unsupported write approvals", async () => { + vi.mocked(getSeerExplorerState).mockResolvedValue({ + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: { + id: "w1", + input_type: "agent_write_approval", + data: {}, + }, + }); + const prompt = vi.fn(); + const result = await runSeerFix({ ...base, interactive: true, prompt }); + expect(result.pending_user_input?.id).toBe("w1"); + expect(prompt).not.toHaveBeenCalled(); +}); +test("keeps the latest merged patch per file across repositories", async () => { + const patch = { + repo_name: "example/a", + diff: "old", + patch: { + added: 1, + removed: 1, + hunks: [], + path: "index.ts", + source_file: "index.ts", + target_file: "index.ts", + type: "M" as const, + }, + }; + vi.mocked(getSeerExplorerState).mockResolvedValue({ + status: "COMPLETED", + blocks: [ + { id: "one", merged_file_patches: [patch] }, + { + id: "two", + merged_file_patches: [ + { ...patch, repo_name: "example/b", diff: "other" }, + ], + }, + { id: "three", merged_file_patches: [{ ...patch, diff: "new" }] }, + ], + }); + const result = await runSeerFix({ ...base, runId: "run-1" }); + expect(result.patches.map((item) => [item.repo_name, item.diff])).toEqual([ + ["example/a", "new"], + ["example/b", "other"], + ]); +}); +test("starts an interactive fix independently of prior analysis", async () => { + vi.mocked(getAutofixState).mockResolvedValue({ + sentry_run_id: "old", + status: "COMPLETED", + blocks: [], + }); + vi.mocked(getSeerExplorerState).mockResolvedValue({ status: "COMPLETED" }); + const result = await runSeerFix(base); + expect(result.run_id).toBe("run-1"); + expect(triggerIssueFix).toHaveBeenCalledOnce(); +}); +test("file approval answers must match the number of proposed patches", async () => { + vi.mocked(getSeerExplorerState).mockResolvedValue({ + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: { + id: "f1", + input_type: "file_change_approval", + data: { + patches: [ + { + repo_name: "example/a", + patch: { + added: 1, + removed: 1, + hunks: [], + path: "index.ts", + source_file: "index.ts", + target_file: "index.ts", + type: "M", + }, + }, + ], + }, + }, + }); + await expect( + runSeerFix({ + ...base, + runId: "run-1", + response: { input_id: "f1", response_data: { decisions: [] } }, + }), + ).rejects.toThrow("each current"); + expect(respondToSeerUserInput).not.toHaveBeenCalled(); +}); +test("preserves a run after an uncertain answer submission fails", async () => { + vi.mocked(getSeerExplorerState).mockResolvedValue({ + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: pending, + }); + vi.mocked(respondToSeerUserInput).mockRejectedValue( + new Error("Service unavailable"), + ); + const result = await runSeerFix({ + ...base, + runId: "run-1", + response: { input_id: "q1", response_data: { answers: ["Keep"] } }, + }); + expect(result).toMatchObject({ + run_id: "run-1", + status: "ERROR", + error: "Service unavailable", + }); + expect(result.next_action).toContain("--run-id run-1"); + expect(respondToSeerUserInput).toHaveBeenCalledOnce(); +}); +test("honors a new instruction instead of returning an old completed fix", async () => { + vi.mocked(getAutofixState).mockResolvedValue({ + sentry_run_id: "old", + status: "COMPLETED", + blocks: [ + { + id: "old", + file_patches: [ + { + repo_name: "example/a", + diff: "old", + patch: { + added: 1, + removed: 1, + hunks: [], + path: "index.ts", + source_file: "index.ts", + target_file: "index.ts", + type: "M", + }, + }, + ], + }, + ], + }); + vi.mocked(getSeerExplorerState).mockResolvedValue({ status: "COMPLETED" }); + const result = await runSeerFix({ ...base, instruction: "New constraint" }); + expect(result.run_id).toBe("run-1"); +}); +test("does not submit a human answer after the operation deadline", async () => { + vi.mocked(getSeerExplorerState).mockResolvedValue({ + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: pending, + }); + const prompt = () => + new Promise<{ answers: string[] }>((resolve) => { + setTimeout(() => resolve({ answers: ["Keep"] }), 10); + }); + const result = await runSeerFix({ + ...base, + runId: "run-1", + timeoutMs: 5, + interactive: true, + prompt, + }); + expect(result.status).toBe("TIMEOUT"); + expect(respondToSeerUserInput).not.toHaveBeenCalled(); +}); +test("returns a cancelled resumable result when a human cancels a prompt", async () => { + vi.mocked(getSeerExplorerState).mockResolvedValue({ + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: pending, + }); + const result = await runSeerFix({ + ...base, + runId: "run-1", + interactive: true, + prompt: async () => null, + }); + expect(result).toMatchObject({ + run_id: "run-1", + status: "CANCELLED", + pending_user_input: pending, + }); + expect(result.next_action).toContain("--run-id run-1"); + expect(respondToSeerUserInput).not.toHaveBeenCalled(); +}); +test("pauses completed sessions that still contain unsupported pending input", async () => { + vi.mocked(getSeerExplorerState).mockResolvedValue({ + status: "COMPLETED", + pending_user_input: { + id: "w1", + input_type: "agent_write_approval", + data: { requiredScopes: ["event:write"], sessionId: "session-1" }, + }, + }); + const result = await runSeerFix({ ...base, runId: "run-1" }); + expect(result.status).toBe("WAITING_FOR_USER_RESPONSE"); + expect(result.pending_user_input?.id).toBe("w1"); + expect(respondToSeerUserInput).not.toHaveBeenCalled(); +}); +test.each([ + { + status: "WAITING_FOR_USER_RESPONSE", + pending_user_input: { + id: "browser", + input_type: "reauth_monitoring_provider", + data: {}, + }, + }, + { status: "WAITING_FOR_USER_RESPONSE" }, +])("links the pinned chat for browser recovery: %j", async (session) => { + vi.mocked(getSeerExplorerState).mockResolvedValue(session); + const result = await runSeerFix({ ...base, runId: "run-1" }); + const runUrl = result.next_action?.match(/https?:\/\/\S+/)?.[0]; + expect(runUrl).toBeDefined(); + const url = new URL(runUrl ?? "https://invalid.example"); + expect(url.pathname).toContain("/issues/12345/"); + expect(url.searchParams.get("explorerRunId")).toBe("run-1"); +}); diff --git a/packages/cli/test/lib/sentry-client.request-policy.test.ts b/packages/cli/test/lib/sentry-client.request-policy.test.ts new file mode 100644 index 000000000..f3e7004b9 --- /dev/null +++ b/packages/cli/test/lib/sentry-client.request-policy.test.ts @@ -0,0 +1,147 @@ +/** Request policies for state polling and mutations with uncertain outcomes. */ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { createServer } from "node:http"; +import { getIdentityFingerprint, setAuthToken } from "../../src/lib/db/auth.js"; +import { apiRequestToRegion } from "../../src/lib/api/infrastructure.js"; +import { resetAuthenticatedFetch } from "../../src/lib/sentry-client.js"; +import { + getCachedResponse, + resetCacheState, +} from "../../src/lib/response-cache.js"; +import { useEnvSandbox, useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("request-policy-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_NO_CACHE", +]); +const originalFetch = globalThis.fetch; +beforeEach(async () => { + process.env.SENTRY_AUTH_TOKEN = ""; + process.env.SENTRY_TOKEN = ""; + process.env.SENTRY_FORCE_ENV_TOKEN = ""; + process.env.SENTRY_NO_CACHE = ""; + await setAuthToken("request-policy-token", undefined, undefined, { + host: "https://sentry.io", + }); + resetAuthenticatedFetch(); + resetCacheState(); +}); +afterEach(() => { + globalThis.fetch = originalFetch; + resetAuthenticatedFetch(); + resetCacheState(); +}); + +test("does not repeat a POST whose server returned 503 after accepting it", async () => { + let requests = 0; + globalThis.fetch = async () => { + requests++; + return Response.json({ detail: "uncertain outcome" }, { status: 503 }); + }; + await expect( + apiRequestToRegion( + "https://sentry.io", + "/organizations/test/seer/explorer-update/run/", + { + method: "POST", + body: { payload: { type: "user_input_response" } }, + retry: false, + }, + ), + ).rejects.toMatchObject({ status: 503 }); + expect(requests).toBe(1); +}); + +test("does not repeat a POST when the connection drops", async () => { + let requests = 0; + globalThis.fetch = async () => { + requests++; + throw new TypeError("fetch failed"); + }; + await expect( + apiRequestToRegion( + "https://sentry.io", + "/organizations/test/seer/explorer-update/run/", + { + method: "POST", + body: { payload: { type: "create_pr" } }, + retry: false, + }, + ), + ).rejects.toThrow("fetch failed"); + expect(requests).toBe(1); +}); + +test("refuses a redirect that would replay a mutation", async () => { + let starts = 0; + let replays = 0; + const server = createServer((req, res) => { + if (req.url?.endsWith("/start/")) { + starts++; + res.writeHead(307, { Location: "/api/0/replayed/" }); + res.end(); + } else { + replays++; + res.end(JSON.stringify({ accepted: true })); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + try { + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("No server address"); + const host = `http://127.0.0.1:${address.port}`; + await setAuthToken("request-policy-token", undefined, undefined, { host }); + const realFetch = ( + globalThis as typeof globalThis & { __originalFetch?: typeof fetch } + ).__originalFetch; + if (!realFetch) throw new Error("Missing native fetch"); + globalThis.fetch = realFetch; + await expect( + apiRequestToRegion(host, "/start/", { + method: "POST", + body: { payload: { type: "create_pr" } }, + retry: false, + }), + ).rejects.toThrow(); + expect(starts).toBe(1); + expect(replays).toBe(0); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test("fresh polling bypasses a previously cached run response", async () => { + let requests = 0; + globalThis.fetch = async () => + Response.json( + { status: ++requests === 1 ? "processing" : "completed" }, + { + headers: { "cache-control": "private, max-age=600" }, + }, + ); + const path = "/organizations/test/seer/explorer-chat/run/"; + await apiRequestToRegion("https://sentry.io", path); + await vi.waitFor(async () => { + expect( + await getCachedResponse( + "GET", + `https://sentry.io/api/0${path}`, + { authorization: "Bearer request-policy-token" }, + getIdentityFingerprint(), + ), + ).toBeDefined(); + }); + const { data } = await apiRequestToRegion("https://sentry.io", path, { + bypassCache: true, + }); + expect(data).toEqual({ status: "completed" }); + expect(requests).toBe(2); +});