feat(cli): render a storage plan the way every other plan is rendered - #1407
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Critical unresolved issues prevent reliable CLI behavior and accurate reporting of gated changes.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR adapts storage-schema plan and apply output to the shared CLI templates for MySQL and PostgreSQL.
Changes:
- Extends shared templates with engine/schema labels and change notices.
- Adds storage schema rendering and convergence guidance.
- Adds rendering scenario tests.
File summaries
| File | Review summary |
|---|---|
pkg/cmd/internal/templates/plan.go |
Adds optional labels and configurable change notices. |
pkg/cmd/commands/storage_schema_render.go |
Implements storage rendering; unresolved critical issues include missing CLI wiring and incorrect display of manually gated changes. Additional summary, hint, environment, and error-context issues remain. |
pkg/cmd/commands/storage_schema_render_test.go |
Adds coverage for storage rendering scenarios and summaries. |
Review details
Suppressed comments (7)
pkg/cmd/commands/storage_schema_render.go:90
- This bare return drops the context that the failure occurred while rendering the outstanding section; the same helper is used for multiple report lists. Wrap the error so a caller can distinguish which storage-schema section failed.
return err
pkg/cmd/commands/storage_schema_render.go:96
- This bare return drops the context that the failure occurred while rendering the destructive section. Wrap the error so the command reports which storage-schema section could not be rendered.
return err
pkg/cmd/commands/storage_schema_render.go:171
- The planned-report rendering error is returned without context, so callers cannot tell whether the preview or the remaining-report rendering failed. Wrap this branch with its operation.
return err
pkg/cmd/commands/storage_schema_render.go:185
- The remaining-report rendering error is returned without context, so a convergence failure is not identified as the remaining-state report. Wrap this branch with its operation.
})
pkg/cmd/commands/storage_schema_render.go:303
- This hint is not valid for
--schema-dir: that path can point at an unreleased checkout, and its report source isthe schema files in <path>, not a published release with a matching binary. The current wording nevertheless tells the operator to run “that release's binary”, which can name a nonexistent or mismatched next step; carry the source kind into the renderer and emit directory-specific guidance to preserve UX-4.
func storageSchemaPlanHints(report *apitypes.StorageSchemaReport) []string {
return []string{fmt.Sprintf("These are what %s needs in order to match %s. To converge them, run that release's binary against this database — its container image is that release — or let the release's first boot converge them.", storageSchemaHeaderDatabase(report), report.SchemaSource)}
pkg/cmd/commands/storage_schema_render.go:185
- A manual-only apply returns the planned report unchanged before any destructive statement exists, but this unconditional hint still says that a destructive statement was refused and recommends
--allow-destructive. That is false for PostgreSQL manual remediation and can send the operator toward an irrelevant, potentially unsafe retry; choose the hint from the remaining report's actualManualandDestructivesets.
return writeStorageSchemaBody(remaining, true, []string{
"These were not run. A destructive statement is refused unless --allow-destructive is passed; a manual entry has to be resolved by hand before anything else converges.",
})
pkg/cmd/commands/storage_schema_render.go:182
- On the
withPlan == falseapply path, this is the only line identifying the target, butstorageSchemaHeaderDatabaseomitsreport.Environment. A staging and production report for the same deployment/database can therefore produce indistinguishableRan ... against ...output. Include the environment in the target label here, or render the environment heading whenever no plan header was printed.
fmt.Printf("✓ Ran %d %s against %s.\n\n",
applied, pluralStatements(applied), storageSchemaHeaderDatabase(planned))
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
ecc8ef4 to
05e29e2
Compare
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1407, 05e29e2. Both finder lenses completed. Adversarial verification was capped at 8 candidates, so 1 lower-ranked candidate was never verified either way and is not reported here. Non-blockingThe gated list feeds reason-less The DDL fallback itself is the mechanism, and no test covers it. storage_schema_render.go:291 sets An apply that was refused outright still headlines with a success check mark, and with General suggestions
The one thing that could have broken, verified
Verified correct
This review was generated by Claude Code (claude-opus-5). |
eb847a7 to
cff3502
Compare
|
🤖 All four addressed in The gated list feeds reason-less That last part is the real defect underneath your finding.
...which rendered as two numbered findings, the second being the remedy for the first. So the operator was told to fix two things, one of which was the fix for the other. There is now a test pinning that reason unsplit, and one pinning one line per statement with no The DDL fallback itself is the mechanism, and no test covers it. The mechanism is deleted rather than tested, which I think is the right disposition — your note that newlines pass through An apply that was refused outright still headlines with a success check mark, and with
One note on the shape of the fix: adding a sibling Replied by Claude Code (claude-opus-5) on Armand's behalf. |
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1407, e296dca. Non-blockingThe duplicate-refusal guard is conditioned on The Both are non-blocking only because the function has no production caller at this head, so the duplication is latent; wire-up should fix the guard first. The one thing that could have broken, verifiedThe refactor of Verified correct
This review was generated by Claude Code (claude-opus-5). |
6702dfe to
e279115
Compare
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1407, e279115. Verdict: 2 findings — 1 non-blocking (refusal printed twice on a mixed run), 1 suggestion (renderer has no caller). Non-blockingThe "one refusal reported twice" guard only covers General suggestionsNothing in the CLI reaches this renderer — it ships as test-only code. The one thing that could have broken, verifiedChanging the Verified correct
This review was generated by Claude Code (claude-opus-5). |
e279115 to
98ac2c6
Compare
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1407, 98ac2c6. Non-blockingThe duplicate-refusal guard in General suggestionsDead code shipped with its own test: The gated-changes heading is hard-coded to The copyable re-run command is not last on screen on the convergence path. The blocked-apply refusal is deliberately held until after the summary because "it belongs last on screen" (#L132-L135), but The one thing that could have broken, verifiedRe-signaturing Verified correct
This review was generated by Claude Code (claude-opus-5). |
f74ae13 to
46528f7
Compare
46528f7 to
e22c4d2
Compare
8fb35cd to
e631e0c
Compare
e631e0c to
682ae14
Compare
|
🤖 Four commits since the last round: A mixed run printed one refusal twice. Fixed in
The copyable command was not last on screen on the convergence path. Fixed in The plan's own hint is gone, in
It was also wrong on half of its own input. The hint was one format string over The hints parameter stays. The gated-changes heading's glyph — left as Replied by Claude Code (claude-opus-5) on Armand's behalf. |
…thing A change notice numbers one line per change and prints its reason as it was written. Splitting a reason into findings is right for a lint report, which is a concatenation of them, and wrong for a reason written about one change: a PostgreSQL manual entry reads "definition is NOT NULL without a DEFAULT; add it manually or ship the column with a DEFAULT", and split on that semicolon it lists the remedy as a second thing to fix. A statement with no reason is now named by what it would do rather than by its DDL. The gated list is where that shows: a gated set is every statement in the report, and the outstanding ones among them carry no reason, so a create_table put a whole schema file on one numbered line and the list disappeared inside it. A convergence that ran nothing and left everything now says so with the refusal glyph instead of headlining "✓ Ran 0 statements". Under --yes the plan is printed above the result and what remains is what was planned, so the sections are printed once rather than either side of the result line.
The refusal named a flag that does not exist anywhere else in the CLI, so an operator who followed it got kong's unknown-flag error. `schemabot apply` already permits destructive changes with --allow-unsafe; the storage convergence takes the same spelling, and the refused-by-apply notice borrows the phrasing the normal flow uses for the same instruction. The two flag-agnostic headings are left as they are. A target's standing storage policy can allow destructive changes with no flag on the line, so a heading that named one would be wrong on exactly the deployments that opted in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The apply's refusal notice said the surplus state stays in place, which reads as a report on a convergence that already happened. The same notice also has to be true before one runs, where the refusal is what stops it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e templates The three dispositions a destructive storage statement can be in are the three the CLI already renders for a schema change -- disclosed by a plan, refused by an apply, permitted and running -- so they now render from the same templates instead of a parallel set of notices. A blocked storage apply gains what the schema change apply always had: the finding count in the heading, and the command that permits what was refused. Two things had made those templates unusable here, and both are fixed in them rather than worked around: Reasons on UnsafeChange lets a producer separate its own findings. The list and the count split Reason on "; " because an engine joins a table's violations that way; a reason written for one change is one sentence, and splitting it numbers the remedy for a problem as a second problem. The convergence writes exactly such a reason for a statement whose clauses could not be partitioned. WriteUnsafeChangesBlocked takes the re-run command instead of building one, and WriteUnsafeWarningAllowed takes what permitted the changes. The command has to carry the target flags forward, because it is meant to be copied and one that dropped them would name a different database; the consent is not always a flag, since a deployment's storage policy can grant it with nothing on the command line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nts it The refusal carries the command that permits what was refused, so it is what an operator copies off the screen. It was printed above the plan summary, leaving a count of tables the run will not touch as the final word. A schema change apply prints its own refusal after the summary for the same reason. A plan still discloses before it summarizes, which is the order `plan` prints those two in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suppression that keeps an unattended run from printing its refusal twice was nested under "nothing ran", so a convergence that applied one statement and refused another fell past it: the plan printed the refusal, the result line said a statement ran, and then the same "Apply blocked" block and the same copyable re-run command printed again underneath. An operator reads "Apply blocked" above a line saying a statement ran, twice, for one refusal. What remains is always a subset of what was planned, so once the plan has been printed the sections below it are a repeat whatever the run managed to do. Also drops storageSchemaDatabaseLabel, which had no caller outside its own test. storageSchemaHeaderDatabase is the one wired into the header, and it carries the reasoning the label's comment held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… environment storageSchemaDatabaseLabel has no caller at this head, but the commands that prompt for and refuse a convergence are the callers, and they arrive one PR later — it reads as dead only from here. Its deployment clause is what needed the fix: with a deployment and no environment it appended "in " and stopped, ending the label on a preposition with no object. That pairing is refused where a request is resolved, so it should not arrive, but a label is read during an incident and a truncated one invites the reader to wonder what was lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cks it The refusal is deliberately held until after the summary because it carries the command an operator copies, so it belongs last on screen. On the attended convergence path it was not last: that path passes a hint explaining what was left behind, and the hint printed after the copyable command. So hints print before the refusal now, which puts the command last on every path that prints one and leaves the plan paths — which pass no refusal — rendering exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every storage plan ended with a paragraph restating its own header and then naming the release's boot as the way to converge what it listed. Both halves were wrong for the command they were printed under. The first sentence repeated the database and the schema source from the box directly above it. The second pointed an operator at a boot, when converging the storage ahead of a deploy — from the new release's binary, before the roll rather than during it — is what this CLI is for. It was also wrong on the other half of its own input. The hint is one format string over the report's schema source, so a plan run with --schema-dir, the selector that exists for a commit that was never tagged, told the operator to run "that release's binary" when they had named a directory and there was no release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
205acb6 to
500d1d5
Compare
…nual twice "Needs manual remediation; nothing converges until these are resolved by hand" carries the same fact twice: a remediation described as manual is one a person performs, so "by hand" adds a clause an operator has to read past to reach what the line is actually telling them, which is that the whole drift set is held until these are dealt with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A storage plan withheld what it was not going to run: a destructive statement never reached the SQL section unless consent was already in effect, a manual remediation pulled every other statement out of it into a numbered gated list, and neither counted in the summary. `plan` does none of that. It prints the whole difference between the two schemas, warns underneath about the part it will refuse, and counts all of it. Now so does this. Each disposition still renders its own section rather than one combined list, because the MySQL formatter combines a table's alters into a single statement: an ALTER split so its safe half could run would be recombined into a statement nothing is going to run, and the split the refusal exists to make would be off screen.
…sent A manual remediation outranks the destructive refusal: while one is outstanding the convergence runs nothing, so --allow-unsafe would permit the DROP and converge nothing. The statement is still disclosed as destructive, and the remedy on screen stays the one that unblocks.
|
🤖 The plan was still doing its own thing with what it would not run — it no longer does Three pieces of bespoke rendering are gone. A destructive statement reached the SQL section only when consent was already in effect; a manual remediation pulled every other statement out of it into a numbered "gated" list that existed nowhere else in the CLI; and neither counted in the summary, so a refused plan printed no A plan against storage carrying a surplus table:
Two things kept deliberately, both in e7b12af and 0873816:
The one local helper left on the plan path is The PR summary's previews are regenerated from the renderer, not hand-edited. CI is green. Replied by Claude Code (claude-opus-5) on Armand's behalf. |
What this adds
SchemaBot runs schema changes against your databases, and it has a database of its own: the bookkeeping storage that holds plans, applies, checks, leases and locks. That storage has a schema too, which every release converges when it starts up, and
storage planis how an operator asks what a release will converge before rolling it out.This renders that plan through the same templates as
plan: the header box, the environment heading, the per-table sections with their change symbols, the disclosure of changes that will not run, and the summary line. The question being asked is the same one either way — here is a live database, here is a desired schema, here is the DDL between them — and only the target differs. So anyone who can readplancan readstorage plan, which matters becauseplanis read every day andstorage planis read during an incident.Two shared templates grow an optional field, and keep their existing behavior when it is empty: one to name the database family in the title (SchemaBot's own storage is MySQL or PostgreSQL, which the existing MySQL/Vitess flag cannot express), and one to rename the row carrying the desired schema, which here is a release or a path rather than a directory.
A destructive statement renders through the CLI's existing unsafe-change templates rather than a parallel set. The three dispositions it can be in are the three
planandapplyalready have: disclosed by a plan, refused by an apply, or running under consent already in effect. Two of those had a detail hard-coded that only a schema change apply could supply — the re-run command to copy, and the phrase naming what granted consent — so both became parameters. For storage the consent is not necessarily a flag at all, since a deployment's own policy can permit destructive changes with nothing on the command line.Three rules the rendering follows:
DROPto weigh, the manual remediation to run by hand — and the sections underneath say which of it will run. That is whatplandoes with an unsafe change it is about to refuse. Each disposition gets its own SQL section, because the MySQL formatter combines a table's alters into a single statement: anALTERsplit so its safe half could run would otherwise be recombined into a statement nothing is going to run.--allow-unsafewould permit theDROPand converge nothing. The statement is still disclosed as destructive, but the blocked-apply heading and its re-run command are not printed: a remedy that changes nothing is worse than none.storage plan — outstanding changes, with one refused
storage apply — the same statement, refused
The refusal is written after the summary, which is where
applywrites its own: it carries the command an operator copies, so it belongs last on screen. A plan's disclosure stays above the summary, again matchingplan.storage plan — a change that needs manual remediation
Invariants
DROPand converge nothing.Opened by Claude Code (Opus 5).