diff --git a/TEMPLATES.md b/TEMPLATES.md index d87d73533..70cb1bcf8 100644 --- a/TEMPLATES.md +++ b/TEMPLATES.md @@ -7501,6 +7501,40 @@ ALTER TABLE `mutes` ๐Ÿ“‹ **Plan**: **1** table to alter +--- + +โ–ถ๏ธ **To apply** all schema changes from this PR, comment: +``` +schemabot apply -e production +``` + + + +
+Plan: Many Shards (32) + + +## Schema Change Plan โ€” Production + +**Database**: `cdb_resolute` | **Type**: `Strata` + +*Requested by @jackjackbits at 2026-01-01 00:00:00 UTC ยท planned from [`abcdef1`](https://github.com/block/schemabot/commit/abcdef1234567890abcdef1234567890abcdef12)* + +#### Keyspace: `cdb_resolute_sharded` +
+all 32 shards + +`-08`, `08-10`, `10-18`, `18-20`, `20-28`, `28-30`, `30-38`, `38-40`, `40-48`, `48-50`, `50-58`, `58-60`, `60-68`, `68-70`, `70-78`, `78-80`, `80-88`, `88-90`, `90-98`, `98-a0`, `a0-a8`, `a8-b0`, `b0-b8`, `b8-c0`, `c0-c8`, `c8-d0`, `d0-d8`, `d8-e0`, `e0-e8`, `e8-f0`, `f0-f8`, `f8-` + +
+ +```sql +ALTER TABLE `mutes` ADD INDEX `created_at`(`created_at`); +``` + +๐Ÿ“‹ **Plan**: **1** table to alter + + --- โ–ถ๏ธ **To apply** all schema changes from this PR, comment: diff --git a/pkg/cmd/internal/templates/preview_comment.go b/pkg/cmd/internal/templates/preview_comment.go index 308bfda50..309de4576 100644 --- a/pkg/cmd/internal/templates/preview_comment.go +++ b/pkg/cmd/internal/templates/preview_comment.go @@ -350,6 +350,7 @@ func previewCommentShardedAllOutput() { fn func() }{ {"PLAN: DIVERGENT SHARDS", func() { fmt.Print(webhooktemplates.PreviewCommentShardedPlanDivergent()) }}, + {"PLAN: MANY SHARDS (32)", func() { fmt.Print(webhooktemplates.PreviewCommentShardedPlanManyShards()) }}, {"PLAN: PARTIALLY APPLIED SHARDS", func() { fmt.Print(webhooktemplates.PreviewCommentShardedPlanPartiallyApplied()) }}, {"PLAN: UNSAFE CHANGE ON ONE SHARD", func() { fmt.Print(webhooktemplates.PreviewCommentShardedPlanUnsafe()) }}, {"APPLY IN PROGRESS", func() { fmt.Print(webhooktemplates.PreviewCommentShardedApplyInProgress()) }}, diff --git a/pkg/webhook/plan.go b/pkg/webhook/plan.go index 262d0a642..779ba85ad 100644 --- a/pkg/webhook/plan.go +++ b/pkg/webhook/plan.go @@ -650,6 +650,7 @@ func shardedUnsafeChanges(shards []*apitypes.ShardPlanResponse) []templates.Unsa if len(shards) == 0 { return nil } + total := plannedShardCount(shards) type key struct{ table, reason string } var order []key byKey := make(map[key]*templates.UnsafeChangeData) @@ -665,7 +666,7 @@ func shardedUnsafeChanges(shards []*apitypes.ShardPlanResponse) []templates.Unsa k := key{table: unsafeChange.Table, reason: unsafeChange.Reason} uc := byKey[k] if uc == nil { - uc = &templates.UnsafeChangeData{Table: unsafeChange.Table, Reason: unsafeChange.Reason} + uc = &templates.UnsafeChangeData{Table: unsafeChange.Table, Reason: unsafeChange.Reason, TotalShards: total} byKey[k] = uc order = append(order, k) } @@ -679,6 +680,19 @@ func shardedUnsafeChanges(shards []*apitypes.ShardPlanResponse) []templates.Unsa return out } +// plannedShardCount counts the shards the plan actually covers, so a shard +// list rendered against it states coverage over what was planned rather than +// over slots that carried no plan. +func plannedShardCount(shards []*apitypes.ShardPlanResponse) int { + total := 0 + for _, sp := range shards { + if sp != nil { + total++ + } + } + return total +} + // msgDeferCutoverAllDirect rejects --defer-cutover on a plan whose every // change the policy routes to direct execution: a direct statement has no // cutover to defer, so the flag is refused instead of silently ignored. @@ -708,6 +722,7 @@ func shardedDirectChanges(shards []*apitypes.ShardPlanResponse) []templates.Dire if len(shards) == 0 { return nil } + total := plannedShardCount(shards) type key struct{ table, reason string } var order []key byKey := make(map[key]*templates.DirectChangeData) @@ -722,7 +737,7 @@ func shardedDirectChanges(shards []*apitypes.ShardPlanResponse) []templates.Dire k := key{table: t.TableName, reason: t.ModeReason} dc := byKey[k] if dc == nil { - dc = &templates.DirectChangeData{Table: t.TableName, Reason: t.ModeReason} + dc = &templates.DirectChangeData{Table: t.TableName, Reason: t.ModeReason, TotalShards: total} byKey[k] = dc order = append(order, k) } @@ -744,6 +759,7 @@ func shardedBlockedChanges(shards []*apitypes.ShardPlanResponse) []templates.Blo if len(shards) == 0 { return nil } + total := plannedShardCount(shards) type key struct{ table, reason string } var order []key byKey := make(map[key]*templates.BlockedChangeData) @@ -758,7 +774,7 @@ func shardedBlockedChanges(shards []*apitypes.ShardPlanResponse) []templates.Blo k := key{table: t.TableName, reason: t.ModeReason} bc := byKey[k] if bc == nil { - bc = &templates.BlockedChangeData{Table: t.TableName, Reason: t.ModeReason} + bc = &templates.BlockedChangeData{Table: t.TableName, Reason: t.ModeReason, TotalShards: total} byKey[k] = bc order = append(order, k) } diff --git a/pkg/webhook/plan_test.go b/pkg/webhook/plan_test.go index d627fbf09..9b5f18345 100644 --- a/pkg/webhook/plan_test.go +++ b/pkg/webhook/plan_test.go @@ -68,6 +68,7 @@ func TestBuildPlanCommentData_PerShardUnsafe(t *testing.T) { require.Len(t, data.UnsafeChanges, 1) assert.Equal(t, "mutes", data.UnsafeChanges[0].Table) assert.Equal(t, []string{"40-80"}, data.UnsafeChanges[0].Shards, "the unsafe change is scoped to the drifted shard") + assert.Equal(t, 2, data.UnsafeChanges[0].TotalShards, "coverage is stated against every planned shard") } // A shard that already matches the desired schema while siblings change is diff --git a/pkg/webhook/templates/apply.go b/pkg/webhook/templates/apply.go index 26394d1dd..b24ebec7b 100644 --- a/pkg/webhook/templates/apply.go +++ b/pkg/webhook/templates/apply.go @@ -880,8 +880,7 @@ func renderShardSummary(sb *strings.Builder, table TableProgressData) { return // completed/pending/cancelled/failed: no breakdown, stay quiet } - const inlineLimit = 8 - if len(table.Shards) <= inlineLimit { + if len(table.Shards) <= shardNamesInlineLimit { parts := make([]string, 0, len(table.Shards)) for _, sh := range table.Shards { if isCopyingShardStatus(sh.Status) && sh.PercentComplete > 0 { diff --git a/pkg/webhook/templates/apply_commands.go b/pkg/webhook/templates/apply_commands.go index d8a4ea91b..2dcd52ce4 100644 --- a/pkg/webhook/templates/apply_commands.go +++ b/pkg/webhook/templates/apply_commands.go @@ -229,7 +229,7 @@ func RenderBlockedChangesApplyRejected(data PlanCommentData) string { for _, c := range data.BlockedChanges { table := "`" + c.Table + "`" if len(c.Shards) > 0 { - table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards)) + table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards, c.TotalShards)) } if reason := SanitizeInlineError(c.Reason); reason != "" { fmt.Fprintf(&sb, "- %s: %s\n", table, html.EscapeString(reason)) diff --git a/pkg/webhook/templates/errors.go b/pkg/webhook/templates/errors.go index c504e1454..910c3838a 100644 --- a/pkg/webhook/templates/errors.go +++ b/pkg/webhook/templates/errors.go @@ -46,11 +46,7 @@ func (d SchemaErrorData) EnvironmentHeader() string { case 1: return "**Environment**: " + markdownInlineCode(d.Environments[0]) default: - quoted := make([]string, len(d.Environments)) - for i, name := range d.Environments { - quoted[i] = markdownInlineCode(name) - } - return "**Environments**: " + strings.Join(quoted, ", ") + return "**Environments**: " + strings.Join(markdownInlineCodeList(d.Environments), ", ") } } @@ -298,10 +294,7 @@ func RenderInvalidCommand() string { // handles. The configured environment names are normalized for markdown // display so an unexpected character cannot break the comment. func RenderInvalidEnv(action string, available []string) string { - quoted := make([]string, len(available)) - for i, name := range available { - quoted[i] = markdownInlineCode(name) - } + quoted := markdownInlineCodeList(available) availableLine := "" if len(quoted) > 0 { availableLine = "\n**Available environments**: " + strings.Join(quoted, ", ") + "\n" @@ -321,6 +314,16 @@ func markdownInlineCode(s string) string { return "`" + strings.Join(strings.Fields(s), " ") + "`" } +// markdownInlineCodeList renders each value as a normalized markdown inline +// code span, ready to join into a comma-separated list. +func markdownInlineCodeList(values []string) []string { + quoted := make([]string, len(values)) + for i, v := range values { + quoted[i] = markdownInlineCode(v) + } + return quoted +} + // RenderMissingEnv generates an error message when -e flag is missing. func RenderMissingEnv(action string) string { return offerSupportChannel(fmt.Sprintf(`## โŒ Missing Argument diff --git a/pkg/webhook/templates/plan.go b/pkg/webhook/templates/plan.go index dd079105b..60dee32d2 100644 --- a/pkg/webhook/templates/plan.go +++ b/pkg/webhook/templates/plan.go @@ -28,6 +28,10 @@ type UnsafeChangeData struct { // where only some shards carry it. Empty for a non-sharded change (applies to // the whole table). Shards []string + // TotalShards is how many shards the plan covers in the keyspace, so a + // rendering too wide to name every shard can state coverage ("12 of 32 + // shards") instead of a bare count. Zero when unknown. + TotalShards int } // BlockedChangeData is a planned change the engine deterministically refuses: @@ -38,6 +42,10 @@ type BlockedChangeData struct { // Shards names the shards this blocked change applies to, for a sharded // plan where only some shards carry it. Empty for a non-sharded change. Shards []string + // TotalShards is how many shards the plan covers in the keyspace, so a + // rendering too wide to name every shard can state coverage ("12 of 32 + // shards") instead of a bare count. Zero when unknown. + TotalShards int } // DirectChangeData is a planned change the database's direct execution policy @@ -50,6 +58,10 @@ type DirectChangeData struct { // Shards names the shards this direct change applies to, for a sharded // plan where only some shards carry it. Empty for a non-sharded change. Shards []string + // TotalShards is how many shards the plan covers in the keyspace, so a + // rendering too wide to name every shard can state coverage ("12 of 32 + // shards") instead of a bare count. Zero when unknown. + TotalShards int } // AttributedChangeData is a table carrying a planned destructive change that @@ -774,14 +786,14 @@ func writeShardedPlanDDL(sb *strings.Builder, shards []KeyspaceShardChange) { // satisfied shards means nothing is changing, so render nothing rather than // an empty code block. if len(groups) == 1 && !groups[0].Satisfied { - fmt.Fprintf(sb, "**%s**\n\n", planShardList(groups[0].Shards)) + writeShardGroupHeading(sb, groups[0].Shards, len(shards)) writePlanDDLBlock(sb, groups[0].Statements) } return } sb.WriteString("Shards diverge โ€” what applies where:\n\n") for _, g := range groups { - fmt.Fprintf(sb, "**%s**\n\n", planShardList(g.Shards)) + writeShardGroupHeading(sb, g.Shards, len(shards)) // A satisfied group already matches the desired schema; say so instead // of rendering an empty code block. if g.Satisfied { @@ -834,18 +846,58 @@ func shardGroupSignature(s KeyspaceShardChange) string { return status + "\x02" + strings.Join(s.Statements, "\x01") } -// planShardList renders a group's shards as "shard `x`" or "shards `x`, `y`". -func planShardList(shards []string) string { - quoted := make([]string, len(shards)) - for i, s := range shards { - quoted[i] = fmt.Sprintf("`%s`", s) +// shardNamesInlineLimit caps how many shard names render inline in a PR +// comment. Beyond it, listing every range reads as a wall โ€” a wide keyspace +// collapses to a count, with the names behind a collapsed block where the +// rendering has room for one. +const shardNamesInlineLimit = 8 + +// planShardList renders a group's shards as "shard `x`" or "shards `x`, `y`" +// when few enough to read inline, stating coverage beyond that โ€” "12 of 32 +// shards", or "all 32 shards" when the group spans the keyspace. Used where +// the list rides inside a line item and has no room for a collapsed name +// list; the full names stay reachable in the DDL section's collapsed +// shard-group blocks. +func planShardList(shards []string, totalShards int) string { + if len(shards) > shardNamesInlineLimit { + return shardCoveragePhrase(len(shards), totalShards) } + quoted := markdownInlineCodeList(shards) if len(quoted) == 1 { return "shard " + quoted[0] } return "shards " + strings.Join(quoted, ", ") } +// shardCoveragePhrase states how much of a keyspace a shard group covers: +// "all 32 shards" when it covers every planned shard, "12 of 32 shards" for +// a subset, or a bare count when the keyspace total is unknown โ€” a subset +// must never read like whole-keyspace coverage. +func shardCoveragePhrase(count, totalShards int) string { + if count == totalShards { + return fmt.Sprintf("all %d shards", count) + } + if totalShards > 0 { + return fmt.Sprintf("%d of %d shards", count, totalShards) + } + return fmt.Sprintf("%d shards", count) +} + +// writeShardGroupHeading writes a shard group's bold heading above its DDL +// block. Few shards read inline by name; a wide group leads with how much of +// the keyspace it covers โ€” "all 32 shards" when it covers every planned +// shard, "19 of 32 shards" for a subset โ€” as a single collapsed line that +// expands into the full name list, so the names stay reachable without +// walling the comment. +func writeShardGroupHeading(sb *strings.Builder, shards []string, totalShards int) { + if len(shards) <= shardNamesInlineLimit { + fmt.Fprintf(sb, "**%s**\n\n", planShardList(shards, totalShards)) + return + } + fmt.Fprintf(sb, "
\n%s\n\n%s\n\n
\n\n", + shardCoveragePhrase(len(shards), totalShards), strings.Join(markdownInlineCodeList(shards), ", ")) +} + // writeDeploymentDrift renders the review-time drift rollup: a single uniform // line when every deployment matches the reviewed plan, or a per-deployment // breakdown naming which deployments diverged or could not be verified. It is a @@ -912,7 +964,7 @@ func writeBlockedChanges(sb *strings.Builder, changes []BlockedChangeData) { for _, c := range changes { table := "`" + c.Table + "`" if len(c.Shards) > 0 { - table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards)) + table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards, c.TotalShards)) } if c.Reason != "" { fmt.Fprintf(sb, "- %s: %s\n", table, c.Reason) @@ -955,7 +1007,7 @@ func writeDirectChanges(sb *strings.Builder, changes []DirectChangeData, databas for _, c := range changes { table := "`" + c.Table + "`" if len(c.Shards) > 0 { - table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards)) + table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards, c.TotalShards)) } if c.Reason != "" { fmt.Fprintf(sb, "- %s: %s\n", table, c.Reason) @@ -972,7 +1024,7 @@ func writeUnsafeWarning(sb *strings.Builder, changes []UnsafeChangeData, isMySQL for _, c := range changes { table := "`" + c.Table + "`" if len(c.Shards) > 0 { - table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards)) + table = fmt.Sprintf("%s (%s)", table, planShardList(c.Shards, c.TotalShards)) } writeUnsafeChangeItem(sb, table, c.Reason) } diff --git a/pkg/webhook/templates/preview_sharded.go b/pkg/webhook/templates/preview_sharded.go index 279f7f844..6b1602734 100644 --- a/pkg/webhook/templates/preview_sharded.go +++ b/pkg/webhook/templates/preview_sharded.go @@ -1,6 +1,7 @@ package templates import ( + "fmt" "time" "github.com/block/schemabot/pkg/apitypes" @@ -158,6 +159,42 @@ func PreviewCommentShardedPlanDivergent() string { }) } +// PreviewCommentShardedPlanManyShards renders a uniform sharded plan across a +// wide keyspace: the DDL shows once under an "all N shards" heading, with the +// shard names behind a collapsed block instead of walling the comment. +func PreviewCommentShardedPlanManyShards() string { + idx := "ALTER TABLE `mutes` ADD INDEX `created_at`(`created_at`)" + shards := make([]KeyspaceShardChange, 0, 32) + for i := range 32 { + shards = append(shards, KeyspaceShardChange{Shard: previewShardRange(i, 32), Statements: []string{idx}}) + } + return RenderPlanComment(PlanCommentData{ + Database: "cdb_resolute", Environment: "production", DatabaseType: "strata", + HeadSHA: previewHeadSHA, Repository: previewRepository, RequestedBy: previewRequestedBy, + Changes: []KeyspaceChangeData{{ + Keyspace: "cdb_resolute_sharded", + Statements: []string{idx}, + Shards: shards, + }}, + }) +} + +// previewShardRange returns shard i's keyrange name in an evenly-split +// keyspace of n shards, in Vitess notation: "-08", "08-10", โ€ฆ, "f8-". +func previewShardRange(i, n int) string { + width := 256 / n + lower := fmt.Sprintf("%02x", i*width) + upper := fmt.Sprintf("%02x", (i+1)*width) + switch i { + case 0: + return "-" + upper + case n - 1: + return lower + "-" + default: + return lower + "-" + upper + } +} + // PreviewCommentShardedPlanPartiallyApplied renders a sharded plan where one // shard already has the change (e.g. an interrupted earlier rollout) and the // rest still need it. The satisfied shard renders as an "already applied" group @@ -188,7 +225,7 @@ func PreviewCommentShardedPlanUnsafe() string { Database: "cdb_resolute", Environment: "production", DatabaseType: "strata", HeadSHA: previewHeadSHA, Repository: previewRepository, RequestedBy: previewRequestedBy, HasUnsafeChanges: true, - UnsafeChanges: []UnsafeChangeData{{Table: "mutes", Reason: "DROP COLUMN removes data and is irreversible", Shards: []string{"40-80"}}}, + UnsafeChanges: []UnsafeChangeData{{Table: "mutes", Reason: "DROP COLUMN removes data and is irreversible", Shards: []string{"40-80"}, TotalShards: 4}}, Changes: []KeyspaceChangeData{{ Keyspace: "cdb_resolute_sharded", Shards: []KeyspaceShardChange{ diff --git a/pkg/webhook/templates/sharded_plan_test.go b/pkg/webhook/templates/sharded_plan_test.go index c287a9a57..3eb738a48 100644 --- a/pkg/webhook/templates/sharded_plan_test.go +++ b/pkg/webhook/templates/sharded_plan_test.go @@ -1,6 +1,7 @@ package templates import ( + "fmt" "strings" "testing" @@ -153,6 +154,152 @@ func TestRenderPlanComment_ShardedOnlyPerShardDDLNotMiscounted(t *testing.T) { assert.Contains(t, out, "```sql", "the per-shard DDL is rendered") } +// A uniform plan across a wide keyspace leads with how much of the keyspace +// the change covers instead of walling the comment with every range: the +// heading reads "all N shards" and the names stay reachable behind a +// collapsed block. +func TestRenderPlanComment_ShardedUniformManyShardsCollapse(t *testing.T) { + stmt := "ALTER TABLE `mutes` ADD INDEX `created_at`(`created_at`)" + shards := make([]KeyspaceShardChange, 0, 32) + for i := range 32 { + shards = append(shards, KeyspaceShardChange{Shard: fmt.Sprintf("s%02d", i), Statements: []string{stmt}}) + } + out := RenderPlanComment(PlanCommentData{ + Database: "cdb_resolute", Environment: "staging", DatabaseType: "strata", + Changes: []KeyspaceChangeData{{ + Keyspace: "cdb_resolute_sharded", + Statements: []string{stmt}, + Shards: shards, + }}, + }) + + assert.Contains(t, out, "all 32 shards", "a uniform wide keyspace leads with its coverage on one collapsed line") + assert.NotContains(t, out, "**shards `", "the heading does not enumerate ranges inline") + assert.Contains(t, out, "`s00`, `s01`", "the collapsed block lists the shard names") + assert.Contains(t, out, "`s31`", "the collapsed block lists every shard") + assert.Equal(t, 1, strings.Count(out, "```sql"), "the shared DDL is shown once") +} + +// A divergent plan whose larger group is still a subset of the keyspace names +// its coverage as a fraction ("N of M shards"), so the operator can tell at a +// glance how much of the keyspace each change set touches. +func TestRenderPlanComment_ShardedDivergentWideGroupShowsFraction(t *testing.T) { + idx := "ALTER TABLE `mutes` ADD INDEX `created_at`(`created_at`)" + drift := "ALTER TABLE `mutes` ADD INDEX `created_at`(`created_at`), ADD COLUMN `reason` varchar(255)" + shards := make([]KeyspaceShardChange, 0, 16) + for i := range 15 { + shards = append(shards, KeyspaceShardChange{Shard: fmt.Sprintf("s%02d", i), Statements: []string{idx}}) + } + shards = append(shards, KeyspaceShardChange{Shard: "s15", Statements: []string{drift}}) + out := RenderPlanComment(PlanCommentData{ + Database: "cdb_resolute", Environment: "staging", DatabaseType: "strata", + Changes: []KeyspaceChangeData{{ + Keyspace: "cdb_resolute_sharded", + Statements: []string{idx}, + Shards: shards, + }}, + }) + + assert.Contains(t, out, "Shards diverge โ€” what applies where:") + assert.Contains(t, out, "15 of 16 shards", "a wide subset names its coverage as a fraction on one collapsed line") + assert.Contains(t, out, "**shard `s15`**", "a small group still names its shards inline") + assert.Contains(t, out, "ADD COLUMN `reason`", "the divergent statement is shown") +} + +// An unsafe change spanning a wide set of shards states its keyspace coverage +// on the finding line โ€” the line the reviewer consents against must never +// leave a subset reading like whole-keyspace coverage โ€” while the full names +// stay reachable in the DDL section's collapsed shard groups. +func TestRenderPlanComment_UnsafeChangeWideShardsStatesCoverage(t *testing.T) { + stmt := "ALTER TABLE `mutes` ADD INDEX a, DROP COLUMN `x`" + names := make([]string, 0, 12) + shards := make([]KeyspaceShardChange, 0, 12) + for i := range 12 { + name := fmt.Sprintf("s%02d", i) + names = append(names, name) + shards = append(shards, KeyspaceShardChange{Shard: name, Statements: []string{stmt}}) + } + cases := []struct { + name string + totalShards int + want string + }{ + {name: "a subset states its fraction of the keyspace", totalShards: 32, want: "`mutes` (12 of 32 shards)"}, + {name: "whole-keyspace coverage says all", totalShards: 12, want: "`mutes` (all 12 shards)"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out := RenderPlanComment(PlanCommentData{ + Database: "cdb_resolute", Environment: "staging", DatabaseType: "strata", + HasUnsafeChanges: true, + UnsafeChanges: []UnsafeChangeData{{Table: "mutes", Reason: "DROP COLUMN removes data", Shards: names, TotalShards: tc.totalShards}}, + Changes: []KeyspaceChangeData{{ + Keyspace: "cdb_resolute_sharded", + Shards: shards, + }}, + }) + + assert.Contains(t, out, tc.want, "the finding line states coverage against the keyspace") + assert.NotContains(t, out, "(shards `s00`", "the finding line does not enumerate ranges") + }) + } +} + +// The gate-line shard suffix reads inline names when few, and keyspace +// coverage when wide; with no known total it falls back to a bare count +// rather than overstating coverage. +func TestPlanShardList(t *testing.T) { + wide := make([]string, 12) + for i := range wide { + wide[i] = fmt.Sprintf("s%02d", i) + } + cases := []struct { + name string + shards []string + totalShards int + want string + }{ + {name: "one shard reads inline", shards: []string{"-40"}, totalShards: 4, want: "shard `-40`"}, + {name: "few shards read inline", shards: []string{"-40", "40-80"}, totalShards: 4, want: "shards `-40`, `40-80`"}, + {name: "a wide subset states its fraction", shards: wide, totalShards: 32, want: "12 of 32 shards"}, + {name: "a wide whole keyspace says all", shards: wide, totalShards: 12, want: "all 12 shards"}, + {name: "an unknown total falls back to a bare count", shards: wide, totalShards: 0, want: "12 shards"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, planShardList(tc.shards, tc.totalShards)) + }) + } +} + +// The inline/collapsed pivot sits exactly at shardNamesInlineLimit: a group +// at the limit still names every shard inline, one past it leads with its +// coverage on a collapsed line. +func TestWriteShardGroupHeading_InlineLimitBoundary(t *testing.T) { + shardNames := func(n int) []string { + names := make([]string, n) + for i := range names { + names[i] = fmt.Sprintf("s%02d", i) + } + return names + } + + t.Run("at the limit the shards read inline", func(t *testing.T) { + var sb strings.Builder + writeShardGroupHeading(&sb, shardNames(shardNamesInlineLimit), shardNamesInlineLimit) + assert.Contains(t, sb.String(), "**shards `s00`", "the heading names shards inline") + assert.Contains(t, sb.String(), fmt.Sprintf("`s%02d`**", shardNamesInlineLimit-1), "every shard is named") + assert.NotContains(t, sb.String(), "
", "no collapsed block at the limit") + }) + + t.Run("past the limit the heading collapses to coverage", func(t *testing.T) { + var sb strings.Builder + writeShardGroupHeading(&sb, shardNames(shardNamesInlineLimit+1), shardNamesInlineLimit+1) + assert.Contains(t, sb.String(), fmt.Sprintf("all %d shards", shardNamesInlineLimit+1), "the heading leads with coverage") + assert.Contains(t, sb.String(), "`s00`, `s01`", "the collapsed block lists the names") + }) +} + // An unsafe change confined to one shard is flagged with that shard in the // unsafe-changes warning. func TestRenderPlanComment_UnsafeShardChangeShowsShard(t *testing.T) {