@@ -22,9 +22,12 @@ import (
2222 "database/sql"
2323 "encoding/json"
2424 "fmt"
25+ "log/slog"
2526 "net/http"
2627 "net/http/httptest"
2728 "net/url"
29+ "os"
30+ "strings"
2831 "sync"
2932 "testing"
3033 "time"
@@ -34,6 +37,7 @@ import (
3437 "github.com/stretchr/testify/require"
3538
3639 "github.com/block/schemabot/pkg/api"
40+ ghclient "github.com/block/schemabot/pkg/github"
3741 "github.com/block/schemabot/pkg/state"
3842 "github.com/block/schemabot/pkg/storage"
3943)
@@ -884,3 +888,89 @@ func TestE2ECheckSettleDefersToActivePreflightedApply(t *testing.T) {
884888 assert .Equal (t , applyInFlightBlock .blockingReason , stillHeld .BlockingReason ,
885889 "the active apply's holds must survive an earlier apply's settle" )
886890}
891+
892+ // TestE2ECheckPlanBornHeldDuringActivePreflightedApply verifies plan-time
893+ // merge gate awareness: while a preflighted apply is changing a target, a
894+ // sibling change that plans against that target must not mint a fresh
895+ // passing check — the preflight fan-out already held the checks that existed
896+ // when the apply started, and a new plan (a pushed commit or a manual plan
897+ // command) would otherwise sidestep those holds. A plan whose verdict would
898+ // pass is stored born held with the apply-in-flight blocking reason instead,
899+ // and the apply's settle fan-out re-plans it like any other held check. The
900+ // plan comment still posts normally: the hold changes the stored verdict,
901+ // not the plan UX.
902+ func TestE2ECheckPlanBornHeldDuringActivePreflightedApply (t * testing.T ) {
903+ clearMergeGateRequests (t )
904+ dbName := "webhook_mergegate_bornheld"
905+ svc := setupE2EService (t , dbName )
906+
907+ // The target already matches the PR schema, so the plan finds no changes
908+ // and its verdict would pass.
909+ ctx := t .Context ()
910+ appDSN := strings .Replace (e2eTargetDSN , "/target_test" , "/" + dbName , 1 ) + "&multiStatements=true"
911+ db , err := sql .Open ("mysql" , appDSN )
912+ require .NoError (t , err )
913+ _ , err = db .ExecContext (ctx , "CREATE TABLE `users` (\n `id` bigint unsigned NOT NULL AUTO_INCREMENT,\n `name` varchar(255) NOT NULL,\n PRIMARY KEY (`id`)\n ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci" )
914+ require .NoError (t , err )
915+ _ = db .Close ()
916+
917+ // Another change's apply is mid-flight on the target with a recorded
918+ // preflight, meaning its holds are (or are about to be) in force.
919+ apply := seedApplyWithLock (t , svc , dbName , state .Apply .Running , 2 )
920+ recordRefreshRequest (t , svc , & storage.MergeGateRequest {
921+ ApplyID : apply .ID ,
922+ Kind : storage .MergeGateKindPreflight ,
923+ ApplyIdentifier : apply .ApplyIdentifier ,
924+ Environment : "staging" ,
925+ DatabaseType : "mysql" ,
926+ DatabaseName : dbName ,
927+ Repository : "octocat/hello-world" ,
928+ ChangeKey : "2" ,
929+ RequestedBy : apply .Caller ,
930+ })
931+
932+ mux := http .NewServeMux ()
933+ server := httptest .NewServer (mux )
934+ t .Cleanup (server .Close )
935+
936+ client := gh .NewClient (nil )
937+ client .BaseURL , _ = url .Parse (server .URL + "/" )
938+
939+ schemabotConfig := fmt .Sprintf ("database: %s\n type: mysql\n " , dbName )
940+ schemaFiles := map [string ]string {
941+ "users.sql" : "CREATE TABLE `users` (\n `id` bigint unsigned NOT NULL AUTO_INCREMENT,\n `name` varchar(255) NOT NULL,\n PRIMARY KEY (`id`)\n ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;" ,
942+ }
943+ result := setupFakeGitHubForPlan (t , mux , schemaFiles , schemabotConfig , dbName )
944+
945+ logger := slog .New (slog .NewTextHandler (os .Stdout , & slog.HandlerOptions {Level : slog .LevelError }))
946+ installClient := ghclient .NewInstallationClient (client , logger )
947+ factory := & fakeClientFactory {client : installClient }
948+ h := NewHandler (svc , factory , nil , logger )
949+
950+ req := buildWebhookRequest (t , webhookPayloadOpts {
951+ comment : "schemabot plan -e staging" ,
952+ isPR : true ,
953+ }, nil )
954+
955+ rr := httptest .NewRecorder ()
956+ h .ServeHTTP (rr , req )
957+ require .Equal (t , http .StatusOK , rr .Code )
958+
959+ // The plan comment posts as usual.
960+ select {
961+ case body := <- result .comments :
962+ assert .Contains (t , body , "No schema changes detected" )
963+ case <- time .After (10 * time .Second ):
964+ t .Fatal ("timed out waiting for plan comment" )
965+ }
966+
967+ // The stored check is born held, not passing.
968+ check , err := svc .Storage ().Checks ().Get (ctx , "octocat/hello-world" , 1 , "staging" , "mysql" , dbName )
969+ require .NoError (t , err )
970+ require .NotNil (t , check , "expected a stored check record for the plan" )
971+ assert .False (t , check .HasChanges )
972+ assert .Equal (t , checkStatusCompleted , check .Status )
973+ assert .Equal (t , checkConclusionActionRequired , check .Conclusion )
974+ assert .Equal (t , applyInFlightBlock .blockingReason , check .BlockingReason )
975+ assert .Contains (t , check .ChangeSummary , "held: an apply in flight is changing " + dbName + " in staging" )
976+ }
0 commit comments