diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 3ce258fc75..f65ae7d242 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8006,6 +8006,14 @@ }, "slopAiAdvisory": { "type": "boolean" + }, + "mergeReadinessGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] } }, "required": [ @@ -8021,6 +8029,7 @@ "duplicatePrGateMode", "qualityGateMode", "slopGateMode", + "mergeReadinessGateMode", "slopAiAdvisory", "autoLabelEnabled", "gittensorLabel", @@ -8600,6 +8609,14 @@ "slopGateMinScore": { "type": "number", "nullable": true + }, + "mergeReadinessGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] } }, "required": [ @@ -8615,6 +8632,7 @@ "duplicatePrGateMode", "qualityGateMode", "slopGateMode", + "mergeReadinessGateMode", "autoLabelEnabled", "gittensorLabel", "createMissingLabel", diff --git a/migrations/0038_merge_readiness_gate.sql b/migrations/0038_merge_readiness_gate.sql new file mode 100644 index 0000000000..2e6154dc3f --- /dev/null +++ b/migrations/0038_merge_readiness_gate.sql @@ -0,0 +1,5 @@ +-- Merge-readiness composite gate (#551). One tunable `merge_readiness_gate_mode`: off (default) | advisory | +-- block. When set, it rolls the four sub-gates (linked-issue, duplicate, quality/readiness, slop) into a +-- single `Gittensory Gate` pass/fail so a maintainer keeps ONE required check instead of four. Default 'off' +-- preserves existing behavior for every current repo. +ALTER TABLE repository_settings ADD COLUMN merge_readiness_gate_mode TEXT NOT NULL DEFAULT 'off'; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 91e8bbac77..b8dd029da9 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -401,6 +401,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise qualityGateMode: "advisory", qualityGateMinScore: null, slopGateMode: "off", + mergeReadinessGateMode: "off", slopGateMinScore: null, slopAiAdvisory: false, aiReviewMode: "off", @@ -433,6 +434,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise qualityGateMode: parseGateRuleMode(row.qualityGateMode), qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore), slopGateMode: parseGateRuleMode(row.slopGateMode), + mergeReadinessGateMode: parseGateRuleMode(row.mergeReadinessGateMode), slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore), slopAiAdvisory: row.slopAiAdvisory, aiReviewMode: parseGateRuleMode(row.aiReviewMode), @@ -469,6 +471,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial isConfiguredGateBlocker(finding.code, policy)); - const qualityBlocker = buildQualityGateBlocker(policy); - const slopBlocker = buildSlopGateBlocker(policy); + // Merge-readiness composite (#551): when set, escalate every sub-gate to its mode so they roll into one + // pass/fail. When off, this is a no-op and each sub-gate keeps its own mode. + const effective = applyMergeReadinessGate(policy); + const configuredBlockers = advisoryResult.findings.filter((finding) => isConfiguredGateBlocker(finding.code, effective)); + const qualityBlocker = buildQualityGateBlocker(effective); + const slopBlocker = buildSlopGateBlocker(effective); const blockers = [...configuredBlockers, ...(qualityBlocker ? [qualityBlocker] : []), ...(slopBlocker ? [slopBlocker] : [])]; // Contributor-gated: ONLY confirmed Gittensor contributors can be hard-blocked. For everyone else the // gate is neutral (non-blocking) + the minimal advisory comment — gittensory must never block a // non-confirmed contributor, regardless of what blockers fired. - if (policy.confirmedContributor === false && blockers.length > 0) { + if (effective.confirmedContributor === false && blockers.length > 0) { return { enabled: true, conclusion: "neutral", @@ -610,6 +617,21 @@ function gateMode(value: GateRuleMode | null | undefined): GateRuleMode { return value === "off" || value === "block" ? value : "advisory"; } +// #551: the master merge-readiness composite. When mergeReadinessGateMode is set (advisory/block) it +// OVERRIDES the four sub-gates to its mode so they roll into one pass/fail; when off, the policy is returned +// unchanged and each sub-gate keeps its own mode. +function applyMergeReadinessGate(policy: GateCheckPolicy): GateCheckPolicy { + const composite = gateMode(policy.mergeReadinessGateMode ?? "off"); + if (composite === "off") return policy; + return { + ...policy, + linkedIssueGateMode: composite, + duplicatePrGateMode: composite, + qualityGateMode: composite, + slopGateMode: composite, + }; +} + function normalizeScore(value: number | null | undefined): number | null { if (typeof value !== "number" || !Number.isFinite(value)) return null; return Math.max(0, Math.min(100, Math.round(value))); diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 766f183cd3..a9cd14ee3d 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -188,6 +188,7 @@ export type RepoSettingsPreview = { qualityGateMode: RepositorySettings["qualityGateMode"]; qualityGateMinScore?: number | null | undefined; slopGateMode: RepositorySettings["slopGateMode"]; + mergeReadinessGateMode: RepositorySettings["mergeReadinessGateMode"]; slopGateMinScore?: number | null | undefined; autoLabelEnabled: boolean; gittensorLabel: string; @@ -302,6 +303,7 @@ export function buildRepoSettingsPreview(args: { qualityGateMode: settings.qualityGateMode, qualityGateMinScore: settings.qualityGateMinScore ?? null, slopGateMode: settings.slopGateMode, + mergeReadinessGateMode: settings.mergeReadinessGateMode, slopGateMinScore: settings.slopGateMinScore ?? null, autoLabelEnabled: settings.autoLabelEnabled, gittensorLabel: settings.gittensorLabel, diff --git a/src/types.ts b/src/types.ts index c10efbd031..cf8f4a2fb2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -413,6 +413,8 @@ export type RepositorySettings = { * score + warnings in context; `block` = ALSO hard-block when slopRisk >= slopGateMinScore (deterministic * only, confirmed-contributor-gated like every blocker). Default `off` — opt-in via .gittensory.yml. */ slopGateMode: GateRuleMode; + /** Merge-readiness gate (#merge-readiness). `off`/`advisory`/`block`. No min-score. Default `off`. */ + mergeReadinessGateMode: GateRuleMode; /** Slop-risk threshold (0-100) at/above which `slopGateMode: block` blocks. Default 60 (the `high` band). */ slopGateMinScore?: number | null | undefined; /** AI-assisted slop advisory (the `slopAiAdvisory` capability). When true AND `slopGateMode != off`, a diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index b9aaf2d6da..c13843f4c4 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -177,3 +177,32 @@ describe("slop gate (#530/#532)", () => { expect(blocked.blockers.map((finding) => finding.code)).toContain("slop_risk_above_threshold"); }); }); + +describe("merge-readiness composite gate (#551)", () => { + it("block escalates an otherwise-advisory sub-gate (linked-issue) into a hard blocker", () => { + const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "advisory", mergeReadinessGateMode: "block" }), parseFocusManifest(null)); + const result = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, true)); + expect(result.conclusion).toBe("failure"); + expect(result.summary).toContain("No linked issue detected"); + }); + + it("advisory keeps the composite non-blocking even when a sub-gate is individually set to block", () => { + const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "block", mergeReadinessGateMode: "advisory" }), parseFocusManifest(null)); + expect(evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, true)).conclusion).toBe("success"); + }); + + it("off is a no-op: sub-gates keep their own modes (linked-issue stays advisory -> non-blocking)", () => { + const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "advisory", mergeReadinessGateMode: "off" }), parseFocusManifest(null)); + expect(evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, true)).conclusion).toBe("success"); + }); + + it("the blocking summary lists each unmet sub-gate condition", () => { + const advisory = missingIssueAdvisory(); + advisory.findings.push({ code: "duplicate_pr_risk", title: "Possible duplicate PR", severity: "warning", detail: "Overlaps #9.", action: "Close the duplicate." }); + const eff = resolveEffectiveSettings(settings({ mergeReadinessGateMode: "block" }), parseFocusManifest(null)); + const result = evaluateGateCheck(advisory, gateCheckPolicy(eff, null, true)); + expect(result.conclusion).toBe("failure"); + expect(result.summary).toContain("No linked issue detected"); + expect(result.summary).toContain("Possible duplicate PR"); + }); +}); diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts index 7db0851938..344fe946aa 100644 --- a/test/unit/maintainer-activation.test.ts +++ b/test/unit/maintainer-activation.test.ts @@ -33,6 +33,7 @@ function settings(overrides: Partial = {}): RepositorySettin duplicatePrGateMode: "advisory", qualityGateMode: "advisory", slopGateMode: "off", + mergeReadinessGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/policy-sanitizer.test.ts b/test/unit/policy-sanitizer.test.ts index 65010737f9..2b00cdd617 100644 --- a/test/unit/policy-sanitizer.test.ts +++ b/test/unit/policy-sanitizer.test.ts @@ -67,6 +67,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin duplicatePrGateMode: "advisory", qualityGateMode: "advisory", slopGateMode: "off", + mergeReadinessGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/self-dogfood-registration-pack.test.ts b/test/unit/self-dogfood-registration-pack.test.ts index d889044863..885c283c61 100644 --- a/test/unit/self-dogfood-registration-pack.test.ts +++ b/test/unit/self-dogfood-registration-pack.test.ts @@ -60,6 +60,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin duplicatePrGateMode: "advisory", qualityGateMode: "advisory", slopGateMode: "off", + mergeReadinessGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 57d5c06e12..e8676208d4 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1530,6 +1530,7 @@ function repoSettings(repoFullName: string): RepositorySettings { duplicatePrGateMode: "advisory", qualityGateMode: "advisory", slopGateMode: "off", + mergeReadinessGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index c993676702..14b5d909c0 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -1624,6 +1624,7 @@ describe("v2 signal builders", () => { duplicatePrGateMode: "advisory", qualityGateMode: "advisory", slopGateMode: "off", + mergeReadinessGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index ffc108d31a..e4757cbca7 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -390,6 +390,7 @@ describe("world-class backend signals", () => { duplicatePrGateMode: "advisory" as const, qualityGateMode: "advisory" as const, slopGateMode: "off" as const, + mergeReadinessGateMode: "off" as const, slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, @@ -438,6 +439,7 @@ describe("world-class backend signals", () => { duplicatePrGateMode: "advisory" as const, qualityGateMode: "advisory" as const, slopGateMode: "off" as const, + mergeReadinessGateMode: "off" as const, slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, @@ -506,6 +508,7 @@ describe("world-class backend signals", () => { duplicatePrGateMode: "advisory", qualityGateMode: "advisory", slopGateMode: "off", + mergeReadinessGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, @@ -595,6 +598,7 @@ describe("world-class backend signals", () => { duplicatePrGateMode: "advisory", qualityGateMode: "advisory", slopGateMode: "off", + mergeReadinessGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, @@ -659,6 +663,7 @@ describe("world-class backend signals", () => { duplicatePrGateMode: "advisory", qualityGateMode: "advisory", slopGateMode: "off", + mergeReadinessGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true,