diff --git a/src/gate/schema-change.test.ts b/src/gate/schema-change.test.ts new file mode 100644 index 0000000..0b31abe --- /dev/null +++ b/src/gate/schema-change.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { gateSchemaChange } from "./schema-change.ts"; + +describe("gateSchemaChange", () => { + it("blocks a schema change by default", () => { + expect(gateSchemaChange({ changed: true })).toEqual({ + conclusion: "failure", + message: expect.stringContaining("changes the database schema"), + }); + }); + + it("softens to a non-blocking warning under a warn policy", () => { + expect( + gateSchemaChange({ changed: true }, { "schema-drift": "warn" })?.conclusion, + ).toBe("neutral"); + }); + + it("drops the gate under an off policy", () => { + expect( + gateSchemaChange({ changed: true }, { "schema-drift": "off" }), + ).toBeNull(); + }); + + it("passes when the run reports no schema change", () => { + expect(gateSchemaChange({ changed: false })).toBeNull(); + }); + + it("passes when the API returned no schema-change signal", () => { + expect(gateSchemaChange(null)).toBeNull(); + expect(gateSchemaChange(undefined)).toBeNull(); + }); +}); diff --git a/src/gate/schema-change.ts b/src/gate/schema-change.ts new file mode 100644 index 0000000..8d6de8f --- /dev/null +++ b/src/gate/schema-change.ts @@ -0,0 +1,43 @@ +// The schema-change gate (Site#3289). The API already computes the real schema +// diff between the PR and the baseline (`runMetadata.schemaChange`); this gate +// only decides the check outcome from it. It blocks by default so a person +// validates the migration before merge — the value is a human eyeball on every +// schema change, independent of any cost delta. A repo softens it to `warn` or +// `off` via the schema-drift policy (#3500). Detection is not this gate's job. + +import type { RepoPolicyConfig } from "@query-doctor/core"; +import { resolveVerdict } from "./policy.ts"; + +/** The schema diff the API returns on a run (`runMetadata.schemaChange`). */ +export interface SchemaChangeSignal { + changed: boolean; +} + +export interface SchemaGateResult { + /** `failure` blocks the check; `neutral` is a surfaced, non-blocking warning. */ + conclusion: "failure" | "neutral"; + message: string; +} + +const MESSAGE = + "This PR changes the database schema — validate the migration before merge. " + + "To stop schema changes from blocking this repo, set the schema-drift check " + + "to warn or off in CI settings."; + +/** + * Decide the schema-change gate from the API's schema diff and the repo policy. + * Returns the check outcome when the PR changes the schema and the policy + * surfaces it, or `null` when there is no change or the policy is `off`. + */ +export function gateSchemaChange( + schemaChange: SchemaChangeSignal | null | undefined, + config: RepoPolicyConfig = {}, +): SchemaGateResult | null { + if (!schemaChange?.changed) return null; + const { conclusion, surfaced } = resolveVerdict( + { condition: "schema-drift", verdictClass: "finding" }, + config, + ); + if (!surfaced || conclusion === "success") return null; + return { conclusion, message: MESSAGE }; +} diff --git a/src/main.ts b/src/main.ts index 521d649..1a94264 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { formatCost, queryPreview } from "./reporters/github/github.ts"; import { fetchPrChangedFiles } from "./gate/changed-files.ts"; import { evaluateTestPresence } from "./gate/test-presence.ts"; import { gateRegression } from "./gate/regression.ts"; +import { gateSchemaChange } from "./gate/schema-change.ts"; import { resolveVerdict } from "./gate/policy.ts"; import { DEFAULT_CONFIG } from "./config.ts"; import { ApiClient } from "./remote/api-client.ts"; @@ -276,6 +277,22 @@ async function runInCI( } } + // Schema-change gate (Site#3289). The API already computed the schema diff + // (`runMetadata.schemaChange`) and the comment renders it; this only decides + // the check. Block by default so a person validates the migration; a repo + // softens it to `warn`/`off` via the schema-drift policy. + const schemaGate = gateSchemaChange( + reportContext.runMetadata?.schemaChange, + "conditionPolicies" in config ? config.conditionPolicies : undefined, + ); + if (schemaGate) { + if (schemaGate.conclusion === "failure") { + core.setFailed(schemaGate.message); + } else { + core.warning(schemaGate.message); + } + } + // Block PR if regressions exceed thresholds, or if a brand-new query ships // with a high-confidence index recommendation (#3281). New queries have no // baseline so they can never regress; the new-query gate catches the missing