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
37 changes: 2 additions & 35 deletions TEMPLATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1418,39 +1418,6 @@ schemabot apply -e staging --allow-unsafe

</details>

<details>
<summary><a name="drop-index-blocked"></a><strong>Drop Index Blocked</strong></summary>


## Schema Change Plan — Staging

**Database**: `testapp` | **Type**: `MySQL` | **Schema Name**: `testapp`

*Requested by @jackjackbits at 2026-01-01 00:00:00 UTC · planned from [`abcdef1`](https://github.com/block/schemabot/commit/abcdef1234567890abcdef1234567890abcdef12)*

```sql
ALTER TABLE `customers` DROP INDEX `idx_customers_email`;
```

📋 **Plan**: **1** table to alter

---

**⛔ Apply rejected**: 1 unsafe change detected
- `customers`: Unsafe operation detected: DROP INDEX `idx_customers_email`

**Destructive drop guidance:**

Before dropping an index in MySQL, first make the dropped index invisible and verify application queries no longer rely on it for safe performance.

**🚨 To proceed with these destructive changes, re-run with `--allow-unsafe`:**
```
schemabot apply -e staging --allow-unsafe
```
<!-- schemabot:offer-support-channel -->

</details>

<details>
<summary><a name="schema-lint-errors-blocked"></a><strong>Schema Lint Errors Blocked</strong></summary>

Expand Down Expand Up @@ -7906,8 +7873,8 @@ schemabot apply -e production
| --- | --- |
| `-40` | ✅ completed |
| `40-80` | ✅ completed |
| `80-c0` | cancelled |
| `c0-` | cancelled |
| `80-c0` | 🚫 cancelled |
| `c0-` | 🚫 cancelled |

---

Expand Down
3 changes: 1 addition & 2 deletions pkg/cmd/commands/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func (cmd *PreviewCmd) Run(g *Globals) error {
templates.PreviewCommentMySQLMultiSchema,
templates.PreviewCommentHelp, templates.PreviewCommentSupportChannel,
templates.PreviewCommentErrors, templates.PreviewCommentUnsafeBlocked,
templates.PreviewCommentDropColumnBlocked, templates.PreviewCommentDropIndexBlocked,
templates.PreviewCommentDropColumnBlocked,
templates.PreviewCommentLintErrorsBlocked,
templates.PreviewCommentApplyPlan, templates.PreviewCommentApplyPlanOptions,
templates.PreviewCommentApplyPlanUnsafe,
Expand Down Expand Up @@ -282,7 +282,6 @@ Comment Templates (GitHub PR comments):
comment_errors All error comment templates
comment_unsafe_blocked Unsafe changes blocked (no --allow-unsafe)
comment_drop_column_blocked Drop column blocked with destructive-drop guidance
comment_drop_index_blocked Drop index blocked with destructive-drop guidance
comment_lint_errors_blocked Error-severity schema lint violations block apply
comment_single_progress Single table: running (most common case)
comment_single_complete Single table: completed
Expand Down
1 change: 0 additions & 1 deletion pkg/cmd/internal/templates/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ const (
PreviewCommentErrors PreviewType = "comment_errors" // All error comment templates
PreviewCommentUnsafeBlocked PreviewType = "comment_unsafe_blocked" // Unsafe changes blocked (no --allow-unsafe)
PreviewCommentDropColumnBlocked PreviewType = "comment_drop_column_blocked" // Drop column blocked with destructive-drop guidance
PreviewCommentDropIndexBlocked PreviewType = "comment_drop_index_blocked" // Drop index blocked with destructive-drop guidance
PreviewCommentLintErrorsBlocked PreviewType = "comment_lint_errors_blocked" // Error-severity schema lint violations block apply
PreviewCommentApplyPlan PreviewType = "comment_apply_plan" // Locked apply-plan comment
PreviewCommentApplyPlanOptions PreviewType = "comment_apply_plan_options" // Locked apply-plan with options
Expand Down
2 changes: 0 additions & 2 deletions pkg/cmd/internal/templates/preview_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ func previewCommentAllOutput() {
{"SCHEMA CHANGE APPLY (UNSAFE + ALLOWED)", func() { fmt.Print(webhooktemplates.PreviewCommentApplyPlanUnsafe()) }},
{"UNSAFE CHANGES BLOCKED", func() { fmt.Print(webhooktemplates.PreviewCommentUnsafeBlocked()) }},
{"DROP COLUMN BLOCKED", func() { fmt.Print(webhooktemplates.PreviewCommentDropColumnBlocked()) }},
{"DROP INDEX BLOCKED", func() { fmt.Print(webhooktemplates.PreviewCommentDropIndexBlocked()) }},
{"SCHEMA LINT ERRORS BLOCKED", func() { fmt.Print(webhooktemplates.PreviewCommentLintErrorsBlocked()) }},
{"MULTI-ENV PLAN (IDENTICAL)", func() { fmt.Print(webhooktemplates.PreviewCommentMultiEnvPlan()) }},
{"MULTI-ENV PLAN (DIFFERENT)", func() { fmt.Print(webhooktemplates.PreviewCommentMultiEnvPlanDiff()) }},
Expand Down Expand Up @@ -188,7 +187,6 @@ func previewCommentPlanAllOutput() {
{"DEPLOYMENT DRIFT (DETECTED)", func() { fmt.Print(webhooktemplates.PreviewCommentPlanDriftDetected()) }},
{"DEPLOYMENT DRIFT (COULD NOT VERIFY)", func() { fmt.Print(webhooktemplates.PreviewCommentPlanDriftUnverified()) }},
{"DROP COLUMN BLOCKED", func() { fmt.Print(webhooktemplates.PreviewCommentDropColumnBlocked()) }},
{"DROP INDEX BLOCKED", func() { fmt.Print(webhooktemplates.PreviewCommentDropIndexBlocked()) }},
{"SCHEMA LINT ERRORS BLOCKED", func() { fmt.Print(webhooktemplates.PreviewCommentLintErrorsBlocked()) }},
{"HELP COMMENT", func() { fmt.Print(webhooktemplates.PreviewCommentHelp()) }},
{"SUPPORT CHANNEL FOOTER", func() { fmt.Print(webhooktemplates.PreviewCommentSupportChannel()) }},
Expand Down
2 changes: 0 additions & 2 deletions pkg/cmd/internal/templates/preview_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,6 @@ func PreviewCLIOutput(previewType PreviewType) {
fmt.Print(webhooktemplates.PreviewCommentUnsafeBlocked())
case PreviewCommentDropColumnBlocked:
fmt.Print(webhooktemplates.PreviewCommentDropColumnBlocked())
case PreviewCommentDropIndexBlocked:
fmt.Print(webhooktemplates.PreviewCommentDropIndexBlocked())
case PreviewCommentLintErrorsBlocked:
fmt.Print(webhooktemplates.PreviewCommentLintErrorsBlocked())
case PreviewCommentApplyPlan:
Expand Down
2 changes: 1 addition & 1 deletion pkg/webhook/templates/apply_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func RenderUnsafeChangesBlocked(data PlanCommentData) string {
writeUnsafeChangeItem(&sb, "`"+c.Table+"`", c.Reason)
}
sb.WriteString("\n")
writeUnsafeDropGuidance(&sb, data.UnsafeChanges, data.IsMySQL)
writeUnsafeDropGuidance(&sb, data.UnsafeChanges)

// Attribution comes before the opt-in this comment coaches: --allow-unsafe
// is consent to destroy the data, and whether the change is this pull
Expand Down
116 changes: 4 additions & 112 deletions pkg/webhook/templates/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,67 +339,7 @@ func TestRenderApplyStatusComment_ThrottledChecksumming(t *testing.T) {
assert.Contains(t, result, "- ℹ️ _Throttled: threads-running 21 > 18 · backing off while the database's active threads exceed its budget ([docs](https://github.com/block/schemabot/blob/main/docs/throttle.md))_")
}

func TestUnsafeDropIndexUsageTargets(t *testing.T) {
tests := []struct {
name string
changes []UnsafeChangeData
wantActionTarget string
wantInvisibleTarget string
wantQueryTarget string
wantOK bool
}{
{
name: "drop index",
changes: []UnsafeChangeData{
{Table: "customers", Reason: "Unsafe operation detected: DROP INDEX `idx_customers_email`"},
},
wantActionTarget: "an index",
wantInvisibleTarget: "the dropped index",
wantQueryTarget: "it",
wantOK: true,
},
{
name: "multiple drop indexes",
changes: []UnsafeChangeData{
{Table: "customers", Reason: "Unsafe operation detected: DROP INDEX `idx_customers_email`; Unsafe operation detected: DROP INDEX `idx_customers_phone`"},
},
wantActionTarget: "indexes",
wantInvisibleTarget: "any dropped indexes",
wantQueryTarget: "them",
wantOK: true,
},
{
name: "drop index with drop column",
changes: []UnsafeChangeData{
{Table: "customers", Reason: "Unsafe operation detected: DROP COLUMN `nickname`; Unsafe operation detected: DROP INDEX `idx_customers_email`"},
},
wantActionTarget: "an index",
wantInvisibleTarget: "the dropped index",
wantQueryTarget: "it",
wantOK: true,
},
{
name: "other unsafe change",
changes: []UnsafeChangeData{
{Table: "customers", Reason: "Unsafe operation detected: MODIFY COLUMN"},
},
wantOK: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotActionTarget, gotInvisibleTarget, gotQueryTarget, ok := unsafeDropIndexUsageTargets(tt.changes)

assert.Equal(t, tt.wantOK, ok)
assert.Equal(t, tt.wantActionTarget, gotActionTarget)
assert.Equal(t, tt.wantInvisibleTarget, gotInvisibleTarget)
assert.Equal(t, tt.wantQueryTarget, gotQueryTarget)
})
}
}

func TestRenderUnsafeChangesBlockedIncludesDropIndexGuidance(t *testing.T) {
func TestRenderUnsafeChangesBlockedIncludesDropColumnGuidance(t *testing.T) {
rendered := RenderUnsafeChangesBlocked(PlanCommentData{
Database: "testapp",
SchemaName: "testapp",
Expand All @@ -409,66 +349,18 @@ func TestRenderUnsafeChangesBlockedIncludesDropIndexGuidance(t *testing.T) {
{
Keyspace: "testapp",
Statements: []string{
"ALTER TABLE `customers` DROP COLUMN `nickname`, DROP INDEX `idx_customers_email`;",
"ALTER TABLE `customers` DROP COLUMN `nickname`;",
},
},
},
HasUnsafeChanges: true,
UnsafeChanges: []UnsafeChangeData{
{Table: "customers", Reason: "Unsafe operation detected: DROP COLUMN `nickname`; Unsafe operation detected: DROP INDEX `idx_customers_email`"},
{Table: "customers", Reason: "Unsafe operation detected: DROP COLUMN `nickname`"},
},
})

assert.Contains(t, rendered, "**Destructive drop guidance:**")
assert.Contains(t, rendered, "Before allowing a destructive drop, first deploy application code that no longer reads from or writes to the dropped column.")
assert.Contains(t, rendered, "Before dropping an index in MySQL, first make the dropped index invisible and verify application queries no longer rely on it for safe performance.")
assert.NotContains(t, rendered, "reads from or writes to the dropped index")
}

func TestRenderUnsafeChangesBlockedUsesPluralMySQLDropIndexGuidance(t *testing.T) {
rendered := RenderUnsafeChangesBlocked(PlanCommentData{
Database: "testapp",
SchemaName: "testapp",
Environment: "staging",
IsMySQL: true,
Changes: []KeyspaceChangeData{
{
Keyspace: "testapp",
Statements: []string{
"ALTER TABLE `customers` DROP INDEX `idx_customers_email`, DROP INDEX `idx_customers_phone`;",
},
},
},
HasUnsafeChanges: true,
UnsafeChanges: []UnsafeChangeData{
{Table: "customers", Reason: "Unsafe operation detected: DROP INDEX `idx_customers_email`; Unsafe operation detected: DROP INDEX `idx_customers_phone`"},
},
})

assert.Contains(t, rendered, "Before dropping indexes in MySQL, first make any dropped indexes invisible and verify application queries no longer rely on them for safe performance.")
assert.NotContains(t, rendered, "Before dropping an index in MySQL, first make any dropped indexes invisible")
}

func TestRenderUnsafeChangesBlockedDoesNotMentionInvisibleIndexesForVitess(t *testing.T) {
rendered := RenderUnsafeChangesBlocked(PlanCommentData{
Database: "testapp",
Environment: "staging",
IsMySQL: false,
Changes: []KeyspaceChangeData{
{
Keyspace: "testapp",
Statements: []string{
"ALTER TABLE `customers` DROP INDEX `idx_customers_email`;",
},
},
},
HasUnsafeChanges: true,
UnsafeChanges: []UnsafeChangeData{
{Table: "customers", Reason: "Unsafe operation detected: DROP INDEX `idx_customers_email`"},
},
})

assert.Contains(t, rendered, "Before allowing a destructive drop, verify application queries no longer rely on the dropped index for safe performance.")
assert.NotContains(t, rendered, "invisible")
}

func TestRenderApplyStatusComment_Running(t *testing.T) {
Expand Down
39 changes: 7 additions & 32 deletions pkg/webhook/templates/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ func RenderPlanComment(data PlanCommentData) string {
// operator acknowledged them with --allow-unsafe (apply-confirm re-checks
// and blocks otherwise), so repeating them there is noise.
if data.HasUnsafeChanges && len(data.UnsafeChanges) > 0 && !data.IsLocked {
writeUnsafeWarning(&sb, data.UnsafeChanges, data.IsMySQL)
writeUnsafeWarning(&sb, data.UnsafeChanges)
}

// Lint violations — shown on the plan comment for review, omitted on the
Expand Down Expand Up @@ -1057,7 +1057,7 @@ func writeDirectChanges(sb *strings.Builder, changes []DirectChangeData, databas
sb.WriteString("\n" + footer + "\n\n")
}

func writeUnsafeWarning(sb *strings.Builder, changes []UnsafeChangeData, isMySQL bool) {
func writeUnsafeWarning(sb *strings.Builder, changes []UnsafeChangeData) {
n := countUnsafeFindings(changes)
fmt.Fprintf(sb, glyph.Attention+" **Issues**: %d unsafe %s detected\n", n, pluralize("change", n))
for _, c := range changes {
Expand All @@ -1068,7 +1068,7 @@ func writeUnsafeWarning(sb *strings.Builder, changes []UnsafeChangeData, isMySQL
writeUnsafeChangeItem(sb, table, c.Reason)
}
sb.WriteString("\n")
writeUnsafeDropGuidance(sb, changes, isMySQL)
writeUnsafeDropGuidance(sb, changes)
}

// writeUnsafeChangeItem writes one table's unsafe findings as a list item:
Expand Down Expand Up @@ -1108,24 +1108,14 @@ func countUnsafeFindings(changes []UnsafeChangeData) int {
return n
}

func writeUnsafeDropGuidance(sb *strings.Builder, changes []UnsafeChangeData, isMySQL bool) {
func writeUnsafeDropGuidance(sb *strings.Builder, changes []UnsafeChangeData) {
applicationUsageTarget, hasApplicationUsageTarget := unsafeDropApplicationUsageTarget(changes)
indexActionTarget, indexInvisibleTarget, indexQueryTarget, hasIndexUsageTarget := unsafeDropIndexUsageTargets(changes)
if !hasApplicationUsageTarget && !hasIndexUsageTarget {
if !hasApplicationUsageTarget {
return
}

sb.WriteString("**Destructive drop guidance:**\n\n")
if hasApplicationUsageTarget {
fmt.Fprintf(sb, "Before allowing a destructive drop, first deploy application code that no longer reads from or writes to %s.\n\n", applicationUsageTarget)
}
if hasIndexUsageTarget {
if isMySQL {
fmt.Fprintf(sb, "Before dropping %s in MySQL, first make %s invisible and verify application queries no longer rely on %s for safe performance.\n\n", indexActionTarget, indexInvisibleTarget, indexQueryTarget)
} else {
fmt.Fprintf(sb, "Before allowing a destructive drop, verify application queries no longer rely on %s for safe performance.\n\n", indexInvisibleTarget)
}
}
fmt.Fprintf(sb, "Before allowing a destructive drop, first deploy application code that no longer reads from or writes to %s.\n\n", applicationUsageTarget)
}

func unsafeDropApplicationUsageTarget(changes []UnsafeChangeData) (string, bool) {
Expand Down Expand Up @@ -1164,21 +1154,6 @@ func unsafeDropApplicationUsageTarget(changes []UnsafeChangeData) (string, bool)
return "", false
}

func unsafeDropIndexUsageTargets(changes []UnsafeChangeData) (actionTarget, invisibleTarget, queryTarget string, ok bool) {
dropIndexes := 0
for _, change := range changes {
dropIndexes += strings.Count(strings.ToUpper(change.Reason), "DROP INDEX")
}

if dropIndexes == 1 {
return "an index", "the dropped index", "it", true
}
if dropIndexes > 1 {
return "indexes", "any dropped indexes", "them", true
}
return "", "", "", false
}

// lintWarningsFoldThreshold is the warning count above which the lint section
// collapses into a details block grouped by table. Short lists stay inline so
// a single advisory finding never needs a click; long lists stop dominating
Expand Down Expand Up @@ -1518,7 +1493,7 @@ func writeEnvironmentPlanSection(sb *strings.Builder, plan *PlanCommentData) {

// Unsafe changes warning
if plan.HasUnsafeChanges && len(plan.UnsafeChanges) > 0 {
writeUnsafeWarning(sb, plan.UnsafeChanges, plan.IsMySQL)
writeUnsafeWarning(sb, plan.UnsafeChanges)
}

// Lint violations
Expand Down
30 changes: 0 additions & 30 deletions pkg/webhook/templates/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -1035,36 +1035,6 @@ func PreviewCommentDropColumnBlocked() string {
})
}

// PreviewCommentDropIndexBlocked renders a sample plan where a destructive
// index drop is blocked until query performance has been reviewed.
func PreviewCommentDropIndexBlocked() string {
return RenderUnsafeChangesBlocked(PlanCommentData{
Database: "testapp",
SchemaName: "testapp",
Environment: "staging",
HeadSHA: previewHeadSHA,
Repository: previewRepository,
RequestedBy: previewRequestedBy,
IsMySQL: true,
DatabaseType: "mysql",
Changes: []KeyspaceChangeData{
{
Keyspace: "testapp",
Statements: []string{
"ALTER TABLE `customers` DROP INDEX `idx_customers_email`;",
},
},
},
HasUnsafeChanges: true,
UnsafeChanges: []UnsafeChangeData{
{
Table: "customers",
Reason: "Unsafe operation detected: DROP INDEX `idx_customers_email`",
},
},
})
}

// PreviewCommentLintErrorsBlocked renders a sample plan where error-severity
// schema lint violations block the apply. The reasons carry the raw
// "[ERROR] linter:" prefixes engines report; the renderer strips them.
Expand Down
Loading