Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion apps/cli-docs/src/fragments/commands/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,9 @@ 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.
all generated diffs. Without delivery flags, JSON and non-interactive sessions
only preview the fix. In a terminal, the CLI then offers Apply locally, Create PR,
Both, and the default Skip. Cancelling this optional choice also skips delivery.

The organization needs Seer coding access, linked repositories, and code
mappings. Interactive updates require a signed-in Sentry user. Use
Expand Down Expand Up @@ -485,3 +487,39 @@ 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.

### Apply a fix locally or create pull requests

Request delivery explicitly for scripts and agent callers:

```bash
sentry issue fix my-org/FRONT-123 --run-id <run-id> --apply --json
sentry issue fix my-org/FRONT-123 --run-id <run-id> --pr --json
sentry issue fix my-org/FRONT-123 --run-id <run-id> --apply --pr --json
```

`--apply` validates the entire patch batch against the current Git checkout before
writing. The checkout's remote must match the patch repository. Target files must
have no dirty or untracked conflicts; unrelated local changes are preserved.
Unsupported paths, symlink escapes, metadata/diff mismatches, and patches for
multiple repositories are refused. The CLI uses `git apply --check` before
applying and does not commit or push local changes.
Paths that differ only by case or Unicode normalization are refused to avoid
ambiguous writes on macOS and Windows.

`--pr` requests a pull request for each affected repository and waits for its
repository-specific PR state, independently of the chat's completed status.
Existing completed PRs are reused, and PR creation already in progress is awaited.
An uncertain create request is never repeated automatically; inspect the run in
Sentry before retrying.

Delivery requires a completed fix with patches and no pending input. The CLI
refreshes the pinned chat before writing, verifying that its patches still match
the reviewed result. A new question or changed patch pauses delivery. Incomplete
incremental diffs require a merged diff before delivery. Analysis and delivery
share one timeout budget, and a delivery choice after its deadline
never starts a mutation.

JSON output remains a single result. Successful local application adds
`applied_files`; delivery failures add `delivery_errors` and return a nonzero exit
status while retaining generated diffs, run ID, and any completed PR URLs.
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ Generate and review code changes using Seer AI

**Flags:**
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
- `--apply - Apply the validated patch batch to the current checkout`
- `--pr - Create or await pull requests for affected repositories`
- `--instruction <value> - Context for a new fix`
- `--run-id <value> - Resume this Seer run`
- `--response <value> - JSON file with input_id and response_data (requires --run-id)`
Expand All @@ -209,6 +211,10 @@ 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 <run-id> --json
sentry issue fix my-org/FRONT-123 --run-id <run-id> --response answer.json --json

sentry issue fix my-org/FRONT-123 --run-id <run-id> --apply --json
sentry issue fix my-org/FRONT-123 --run-id <run-id> --pr --json
sentry issue fix my-org/FRONT-123 --run-id <run-id> --apply --pr --json
```

### `sentry issue view <issue...>`
Expand Down
71 changes: 67 additions & 4 deletions packages/cli/src/commands/issue/fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,27 @@ import { CommandOutput } from "../../lib/formatters/output.js";
import { handleSeerApiError } from "../../lib/formatters/seer.js";
import {
createSeerBlockRenderer,
formatSeerFix,
createSeerFixRenderer,
promptSeerDelivery,
promptSeerInput,
} from "../../lib/formatters/seer-fix.js";
import {
applyFreshFlag,
FRESH_ALIASES,
FRESH_FLAG,
} from "../../lib/list-command.js";
import {
deliverSeerFix,
type FixDeliveryResult,
} from "../../lib/seer-fix-delivery.js";
import { runSeerFix } from "../../lib/seer-fix.js";
import { issueIdPositional, resolveOrgAndIssueId } from "./utils.js";

type FixFlags = {
readonly json: boolean;
readonly fresh: boolean;
readonly apply: boolean;
readonly pr: boolean;
readonly instruction?: string;
readonly "run-id"?: string;
readonly response?: string;
Expand Down Expand Up @@ -74,13 +81,23 @@ 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 <run> --response answer.json --json",
"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. Use --apply to apply the validated patch batch locally or --pr to create pull requests. In an interactive terminal, choose apply, PR, both, or the default Skip after reviewing the preview. JSON and non-interactive sessions deliver only with explicit flags. 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 <run> --response answer.json --json",
},
output: { human: formatSeerFix },
output: { human: createSeerFixRenderer },
parameters: {
positional: issueIdPositional,
flags: {
fresh: FRESH_FLAG,
apply: {
kind: "boolean",
brief: "Apply the validated patch batch to the current checkout",
default: false,
},
pr: {
kind: "boolean",
brief: "Create or await pull requests for affected repositories",
default: false,
},
instruction: {
kind: "parsed",
parse: String,
Expand Down Expand Up @@ -127,6 +144,7 @@ export const fixCommand = buildCommand({
command: "fix",
});
org = resolved.org;
const deadline = Date.now() + flags.timeout * 1000;
const result = await runSeerFix({
org,
issueId: resolved.issueId,
Expand All @@ -145,7 +163,52 @@ export const fixCommand = buildCommand({
if (["ERROR", "CANCELLED", "TIMEOUT"].includes(result.status)) {
throw new OutputError(result);
}
yield new CommandOutput(result);
const canDeliver =
result.status === "COMPLETED" &&
result.pending_user_input === null &&
result.patches.length > 0;
let choice = { apply: flags.apply, pr: flags.pr };
let previewed = false;
if (canDeliver && interactive && !choice.apply && !choice.pr) {
yield new CommandOutput(result);
previewed = true;
choice = await promptSeerDelivery();
}
if (!(canDeliver && (choice.apply || choice.pr))) {
if (!previewed) {
yield new CommandOutput(result);
}
return;
}
// JSON callers receive one aggregate document. Human output previews before writes.
if (!previewed && !flags.json) {
yield new CommandOutput(result);
}
const remaining = deadline - Date.now();
if (remaining <= 0 || abort.signal.aborted) {
const timedOut: FixDeliveryResult = {
...result,
status: abort.signal.aborted ? "CANCELLED" : "TIMEOUT",
next_action: `Delivery did not start. Review the fix with sentry issue fix ${issueArg} --run-id ${result.run_id}.`,
};
throw new OutputError(timedOut);
}
const delivered = await deliverSeerFix({
org,
result,
cwd: this.cwd,
apply: choice.apply,
pr: choice.pr,
timeoutMs: remaining,
signal: abort.signal,
});
if (
["ERROR", "CANCELLED", "TIMEOUT"].includes(delivered.status) ||
(delivered.delivery_errors?.length ?? 0) > 0
) {
throw new OutputError(delivered);
}
yield new CommandOutput(delivered);
} catch (error) {
if (error instanceof ApiError) {
throw handleSeerApiError(error.status, error.detail, org);
Expand Down
70 changes: 70 additions & 0 deletions packages/cli/src/lib/formatters/seer-fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
} from "../../types/seer-fix.js";
import type { Writer } from "../../types/index.js";
import type { ExplorerBlock } from "../../types/seer-fix.js";
import type { FixDeliveryResult } from "../seer-fix-delivery.js";
import type { HumanRenderer } from "./output.js";
import { logger } from "../logger.js";
import { escapeMarkdownInline, renderMarkdown } from "./markdown.js";

Expand Down Expand Up @@ -130,3 +132,71 @@ export function createSeerBlockRenderer(
}
};
}

export type SeerDeliveryChoice = { apply: boolean; pr: boolean };

/** Delivery is optional; selecting or cancelling the default keeps the fix read-only. */
export async function promptSeerDelivery(): Promise<SeerDeliveryChoice> {
const selection = await logger
.withTag("issue.fix")
.prompt("What would you like to do with this fix?", {
type: "select",
initial: "Skip",
cancel: "symbol",
options: ["Apply locally", "Create PR", "Both", "Skip"],
});
return {
apply: selection === "Apply locally" || selection === "Both",
pr: selection === "Create PR" || selection === "Both",
};
}

function formatDeliverySummary(
result: FixDeliveryResult,
includeContext: boolean,
): string {
const lines: string[] = [];
if (includeContext) {
lines.push(`# Delivery`, `Status: ${escapeMarkdownInline(result.status)}`);
}
if (result.applied_files && result.applied_files.length > 0) {
lines.push(
"Applied locally:",
...result.applied_files.map((path) => `- ${escapeMarkdownInline(path)}`),
);
}
if (includeContext) {
for (const url of result.pr_urls) {
lines.push(url);
}
}
const errors = result.delivery_errors ?? [];
for (const error of errors) {
if (includeContext || error !== result.error) {
lines.push(`Delivery failed: ${escapeMarkdownInline(error)}`);
}
}
if (includeContext && result.error && !errors.includes(result.error)) {
lines.push(escapeMarkdownInline(result.error));
}
if (includeContext && result.next_action) {
lines.push("", result.next_action);
}
return lines.length > 0 ? renderMarkdown(lines.join("\n\n")) : "";
}

/** Each invocation previews its diffs once, followed only by delivery summaries. */
export function createSeerFixRenderer(): HumanRenderer<FixDeliveryResult> {
let previewed = false;
return {
render(result) {
if (previewed) {
return formatDeliverySummary(result, true);
}
previewed = true;
return [formatSeerFix(result), formatDeliverySummary(result, false)]
.filter(Boolean)
.join("\n\n");
},
};
}
Loading
Loading