feat(github): stop an apply that would discard work in progress - #1102
Conversation
6a215ba to
bda75f1
Compare
26fa812 to
2eddfac
Compare
An apply whose plan will throw away an unfinished copy on the target no longer runs in one step on its own. It posts the locked comment carrying the disclosure and waits, so the operator decides whether hours of copied rows are expendable before anything is destroyed. `-y` is the acknowledgement. It already means "apply without stopping to confirm", so an operator who knows the copy is expendable says so in the command they were going to run anyway rather than learning a second flag. The automatic apply re-plan gates the same way: the copy is read fresh every time, so a discard can appear between the operator's review and the apply. A disposition this build does not recognize counts as a discard. The gate exists to protect work already done, and an unreadable verdict is not a reason to skip it.
…gress The copy-discard gate had an escape: an apply carrying `-y` proceeded in one step, disclosing the copy on the way through instead of stopping. That made the gate weaker than its own sibling, since a direct-execution change downgrades unconditionally, and it put the decision behind a flag on a surface with nothing to confirm interactively. The gate now stops every automatic apply that would discard, and the confirm path stays exempt through the stored plan that marks the automatic path, which is what carried that exemption all along. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`-y` was recognized on the `apply` comment command and then never read: every safety gate it looked like it would skip ran anyway. A flag that reads as consent but records none is worse than no flag, and there is nothing on a comment for it to mean, since a comment has no interactive prompt to skip. Comment commands now reject `-y` and say where it does work. The CLI keeps its own `-y` (`--auto-approve`), which skips a prompt that genuinely exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate's own preview is the discard rendering an operator actually meets, and it was the only one of the three not naming the schema change the copy was started for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2eddfac to
c9353f2
Compare
There was a problem hiding this comment.
Pull request overview
This PR adds a confirmation gate for automatic schema applies that would discard unfinished copies, while updating comment-command flag handling and related UX.
Changes:
- Pause destructive automatic applies until
apply-confirmorunlock. - Handle discarded and unknown copy dispositions.
- Update templates, previews, documentation, and integration tests.
- Reject comment-level
-y/--yesflags while retaining CLI auto-approval.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Summary / review status |
|---|---|
TEMPLATES.md |
Adds paused-copy preview. Nit: correct the duration wording to “last progress 3h 12m ago.” |
pkg/webhook/templates/preview.go |
Adds the paused-plan preview fixture. |
pkg/webhook/templates/issue_comment.go |
Updates unsupported-flag guidance. |
pkg/webhook/templates/issue_comment_test.go |
Tests the updated guidance. |
pkg/webhook/plan.go |
Uses shared copy dispositions and downgrade messaging. |
pkg/webhook/issue_comment.go |
Moderate: restrict -y detection to parsed directives and valid token boundaries. |
pkg/webhook/existing_copy_test.go |
Aligns copy-disposition tests with API constants. |
pkg/webhook/durable_issue_comment_test.go |
Tests durable gate parity. |
pkg/webhook/copy_discard_gate_integration_test.go |
Moderate: verify the lock and copy state remain unchanged; nit: validate and close the database pool correctly. |
pkg/webhook/commands.go |
Removes comment-level auto-confirm support. |
pkg/webhook/commands_test.go |
Updates command parsing tests. |
pkg/webhook/apply_integration_test.go |
Updates automatic-apply integration coverage. |
pkg/webhook/apply_handlers.go |
Critical: fail closed on copy-inspection errors and ensure persistence failures cannot leave a passing check. |
pkg/webhook/apply_execute.go |
Critical: only bypass the re-plan gate when discard was disclosed; moderate: add coverage for copies appearing or expiring during re-planning. |
pkg/cmd/internal/templates/preview.go |
Registers the paused preview type. |
pkg/cmd/internal/templates/preview_dispatch.go |
Dispatches the paused preview. |
pkg/cmd/internal/templates/preview_comment.go |
Includes the preview in grouped output. |
pkg/cmd/commands/preview.go |
Exposes the new preview command. |
pkg/apitypes/apitypes.go |
Adds disposition constants and discard filtering. |
pkg/apitypes/apitypes_test.go |
Tests discarded-copy handling. |
docs/check-runs.md |
Documents automatic-apply gating behavior. |
Suppressed comments (7)
pkg/webhook/apply_execute.go:157
- The re-plan gate has the same fail-open behavior: if the final
findExistingCopyinspection fails,planResp.DiscardedCopies()is empty and this automatic apply continues. That can destroy a copy precisely when the last safety check cannot determine its disposition; propagate an unknown/error state and pause or reject rather than treating the read failure as no copy.
if discarded := planResp.DiscardedCopies(); len(discarded) > 0 {
pkg/webhook/apply_execute.go:160
- This re-plan is only a snapshot: after it reports no discard, the handler still performs GitHub/App setup and queues the apply, while Spirit evaluates checkpoint age later. A copy just below the age limit can expire in that window and be discarded by an automatic apply without this gate running. The destructive-copy disposition needs admission-time or atomic revalidation (or the automatic path must fail closed on a changed target), not only this earlier prediction.
if discarded := planResp.DiscardedCopies(); len(discarded) > 0 {
h.logger.Info("automatic apply downgraded: re-plan discards an existing copy",
"repo", repo, "pr", pr, "database", database, "environment", environment,
"discarded_copies", len(discarded))
pkg/webhook/apply_execute.go:160
- The new re-plan downgrade log also omits
database_typeeven thoughdbTypeis available. This makes a production pause harder to distinguish and triage across database engines; include the database type in the structured attributes.
h.logger.Info("automatic apply downgraded: re-plan discards an existing copy",
"repo", repo, "pr", pr, "database", database, "environment", environment,
"discarded_copies", len(discarded))
pkg/webhook/apply_handlers.go:436
postCommentlogs GitHub failures but returns no status, so this branch can store the plan and return success with the lock held even when the safety disclosure was never posted. The durable command will not retry, leaving the operator without the copy warning or the copy-pasteableapply-confirm/unlockactions. Make failure to publish this gate retryable, or persist a notification/reconciliation path before treating the downgrade as handled.
h.postComment(repo, pr, installationID, templates.RenderPlanComment(commentData))
pkg/webhook/apply_handlers.go:434
- This new safety-transition log omits
database_type, even though the handler hasdbTypein scope. When multiple engine types are routed through the same webhook logs, an operator cannot fully identify which target was paused from this event; include the canonical database type with the existing repo, PR, database, and environment fields.
h.logger.Info("automatic apply downgraded: applying discards an existing copy",
"repo", repo, "pr", pr, "database", database, "environment", environment,
"discarded_copies", len(discarded))
pkg/webhook/apply_handlers.go:439
storeApplyPlanCheckRecordwrites SchemaBot's stored per-database check state and can fail while fetching the PR or writing storage; it does not create a per-database GitHub Check Run. This message misclassifies those failures as Check Run creation failures, which makes a safety-gate incident harder to triage. Log the stored-state operation instead and include the database, database type, and environment.
h.logger.Error("failed to create apply plan check run", "repo", repo, "pr", pr, "error", checkRunErr)
pkg/webhook/templates/preview.go:226
Ageis checkpoint age, and the generated rendering labels it as “last progress ... ago”; it is not time spent copying or the amount of work that will be lost. The PR description's sample presents3h 12mas “3h 12m of copying” and “already spent,” which overstates what this field measures. Align the sample with the generated text or expose a real copied-duration metric.
Age: "3h 12m",
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A comment command that carries `-y` / `--yes` is rejected, because a comment has no prompt to skip and the gates that stop an apply stop it so the operator sees what they are consenting to. That rejection was reached from a substring of the whole comment: an environment ending in `-y` matched the flag, and so did any prose or fenced CLI example that mentioned it. The flag is now a token on the parsed directive line, so `-e staging-y` applies and a comment describing `--yes` still runs the command it actually asked for. The discard-gate test also proves the pause costs nothing: the copy's shadow table and checkpoint are still on the target, and the lock still holds the PR's claim pinned to the plan that disclosed the discard.
… check state A storage failure while recording the apply plan check no longer acknowledges the pause: the gate stores the check record before posting the paused comment, and on failure releases the pinned lock and returns the command as retryable, so branch protection never shows a passing check while an apply waits for destructive confirmation over unknown stored check state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The line carried three clauses joined by a semicolon where every sibling disclosure closes on one or two short sentences, so it read as a wall of prose beside the ♻️ section it renders next to. It now names the cost and the remedy and stops. The elapsed-copying framing goes with it: what applying spends is the whole copy over again, which is work, not a duration the copy can report.
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1102, 5853636. Verdict: 6 findings — no blockers; 4 non-blocking (confirm-path gap, fail-open signal, flag-probe asymmetry, test coverage), 2 suggestions. Non-blocking
General suggestions
The one thing that could have broken, verifiedThe riskiest mechanism is the time-of-check-to-time-of-use window the PR exists to close: the copy disposition is read at plan time and consumed at dispatch time, with an operator in between. I traced all three consumption points and the single producer.
Net: the gate is a real improvement over Verified correct
This review was generated by Claude Code (claude-opus-5). |
HasAutoConfirmFlag reads the directive line so prose and fenced examples cannot reject a command; its two siblings still matched the whole comment body, so `-d` or `--defer-cutover` mentioned in prose rejected a valid command. Route all three probes through the same directive-line reading and pin the prose/fence cases for each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The re-plan gate exists for the window between the reviewed plan and the automatic dispatch, so the test stores a plan against a clean target, seeds the copy inside the window, and drives the dispatch core directly: the apply downgrades to manual confirmation, the copy survives, and no apply starts. Also correct the gate's comment on the confirm-path exemption: the exemption does not know what the confirmed comment disclosed, so it cannot claim the operator acknowledged this copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Thanks — addressed at 03f7eb7, finding by finding:
This reply was generated by Claude Code (Claude Fable 5). |
Why this matters
A schema change that throws away an unfinished copy destroys work already done on the target, often hours of it, and until now it happened in one step. The plan comment disclosed the discard and the apply started in the same breath: by the time anyone read the warning, the copy was gone.
Disclosing something nobody can act on is not a safety property. This makes the disclosure a decision.
What it does
An automatic apply that would discard an unfinished copy stops for confirmation instead. The operator reads what is being destroyed on a comment where it still exists, then confirms, and the remedy on that comment is still reachable: applying the schema change the copy was made for resumes it rather than restarting it.
There is no flag that skips the stop.
-ywas recognized on theapplycomment command and then never read, so every gate it looked like it would skip ran anyway; it is now rejected there, with a reply pointing at the surface it belongs to. The CLI keeps its own-y(--auto-approve), which skips an interactive prompt that genuinely exists. A comment has no prompt to skip, and consent to destroying hours of copying is not something a flag can express in advance. The flag is read off the parsed command rather than the comment body, so an environment ending in-ystill applies and a comment that merely mentions--yesin prose is not rejected for it. The-dand--defer-cutoverprobes now read the same directive line, so prose or a fenced example mentioning either no longer rejects an unrelated command.The gate also runs on the re-plan inside the automatic path. A copy can appear between review and confirmation (another apply starts one, or an adopted copy's checkpoint ages out), so a confirmation collected against a comment that showed no discard does not authorize one. The confirm path itself is exempt through the stored plan that marks the automatic path. That exemption is only as strong as the comment the operator confirmed: the disposition can flip after the disclosure, and this path does not know what that comment showed. Carrying the confirmed disclosure durably on the lock is the stacked follow-up's job.
Even where the gate fires, it is a prediction rather than enforcement: the disposition is read at the re-plan and the apply is queued, so an unbounded queue delay or apply-time statement routing can still change what the engine compares at dispatch. Closing that window needs a copy fingerprint carried on the apply request — a separate design change.
PlanResponse.DiscardedCopies()treats an unrecognized disposition as discarded. Deciding whether an operator must confirm before work is destroyed is not a place to fail open on a value this build does not know.The pause is acknowledged only once the stored check state blocks the merge gate on the pending changes: the gate stores the check record before posting the paused comment, and a storage failure releases the lock and leaves the command retryable rather than pausing over unknown check state.
Automatic apply paused because it would discard a copy
Schema Change Apply — Staging
Database:
testapp| Type:MySQL| Schema Name:testappRequested by @jackjackbits at 2026-01-01 00:00:00 UTC · planned from
abcdef1🔒 Lock acquired by
block/schemabot#42at 2026-01-15 14:30:00 UTCordersintestapp(last progress 3h 12m ago): the schema change differs from the one that started it, which wasALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at)Applying restarts the copy from zero rows. To keep the work already done, apply the schema change that started it.
📋 Plan: 1 table to alter
Review the plan above, then confirm manually:
🔓 To discard this plan and unlock, comment:
The same copy on a comment announcing an apply already under way
Schema Change Apply — Staging
Database:
testapp| Type:MySQL| Schema Name:testappRequested by @jackjackbits at 2026-01-01 00:00:00 UTC · planned from
abcdef1🔒 Lock acquired by
block/schemabot#42at 2026-01-15 14:30:00 UTCℹ️ This apply destroys work in progress: 1 unfinished copy on the target
ordersintestapp(last progress 3h 12m ago): the schema change differs from the one that started it, which wasALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at)📋 Plan: 1 table to alter
Applying automatically
-yon a comment commandThe
-yflag is not supported forapply.-ybelongs to the CLI, where it skips an interactive confirmation prompt. A PR comment has no prompt to skip: when a command stops for confirmation, it is asking you to read what it discloses and reply with the confirm command it posts.How it moves us toward the northstar
The engine has been able to predict this for a while, and the two PRs before this one made the prediction visible. Visibility alone still let the destructive step happen unattended. This is the link that turns a prediction into something an operator gets to decide, which is the difference between SchemaBot telling you what it did and SchemaBot asking first.
Opened by Claude (Opus 5).