diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 5dc8b93840..055a3b3680 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -3338,6 +3338,8 @@ export function gateCheckPolicy( changedFileCount: sizeContext?.changedFileCount ?? null, changedLineCount: sizeContext?.changedLineCount ?? null, guardrailHit: sizeContext?.guardrailHit ?? false, + // #gate-dryrun: render the would-be merge/close/manual verdict (advisory promoted to block) without enforcing. + dryRun: settings.gateDryRun ?? false, }; } diff --git a/src/review/unified-comment-bridge.ts b/src/review/unified-comment-bridge.ts index 8332e54e1b..45e2a394d5 100644 --- a/src/review/unified-comment-bridge.ts +++ b/src/review/unified-comment-bridge.ts @@ -321,7 +321,10 @@ export function buildBeforeAfterCollapsible(routes: CaptureRoute[]): UnifiedColl * lets it override the reviewer recommendation. */ export function buildUnifiedCommentBody(args: UnifiedCommentBridgeArgs): string { - const verdict = gateConclusionToVerdict(args.gate.conclusion); + // #gate-dryrun: in dry-run mode the gate exposes the would-be conclusion (advisory promoted to block) as + // `displayConclusion` so the rendered merge/close/manual verdict reflects what it WOULD do; the posted check + // stays the real, non-enforcing `conclusion`. Outside dry-run, displayConclusion is absent ⇒ falls back. + const verdict = gateConclusionToVerdict(args.gate.displayConclusion ?? args.gate.conclusion); const consensusDefect = consensusDefectFromFindings(args.advisoryFindings); const reviews = buildDualReviewNotes({ aiReview: args.aiReview, diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index 47e6ba65f3..444490cab9 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -67,11 +67,19 @@ export type GateCheckPolicy = { * A guardrail hit HOLDS an otherwise-passing gate for manual review (neutral → "manual"), never auto-merged. * Always-on (the guardrail globs default to the crucial/config-as-code/engine paths). (#gate-guardrail) */ guardrailHit?: boolean | undefined; + /** Dry-run disposition (#gate-dryrun). When true, the gate ALSO computes the would-be conclusion with every + * `advisory` sub-gate promoted to `block` and exposes it as `displayConclusion` (the rendered merge/close/manual + * verdict), WITHOUT changing the posted, non-enforcing `conclusion`. Lets advisory mode show exactly what it WOULD + * do (close/merge/manual) before the maintainer flips to real enforcement. Default off. */ + dryRun?: boolean | undefined; }; export type GateCheckEvaluation = { enabled: boolean; conclusion: GateCheckConclusion; + /** Dry-run only (#gate-dryrun): the would-be conclusion (advisory sub-gates promoted to block) used to render the + * merge/close/manual verdict. Absent ⇒ the renderer falls back to `conclusion`. Never affects what is posted. */ + displayConclusion?: GateCheckConclusion | undefined; title: string; summary: string; blockers: AdvisoryFinding[]; @@ -424,7 +432,36 @@ function buildGuardrailHoldFinding(): AdvisoryFinding { }; } +/** Dry-run disposition (#gate-dryrun): promote every `advisory` sub-gate mode to `block` so the core eval yields the + * would-be conclusion. `off`/`block`/unset modes are untouched; non-mode policy (grace, size HOLD, guardrail) is + * preserved as-is, so the would-be verdict still honours newcomer grace and the manual-review holds. PURE. */ +function promoteAdvisoryToBlock(policy: GateCheckPolicy): GateCheckPolicy { + const block = (mode: GateRuleMode | undefined): GateRuleMode | undefined => (mode === "advisory" ? "block" : mode); + return { + ...policy, + dryRun: false, + linkedIssueGateMode: block(policy.linkedIssueGateMode), + duplicatePrGateMode: block(policy.duplicatePrGateMode), + qualityGateMode: block(policy.qualityGateMode), + aiReviewGateMode: block(policy.aiReviewGateMode), + slopGateMode: block(policy.slopGateMode), + mergeReadinessGateMode: block(policy.mergeReadinessGateMode), + manifestPolicyGateMode: block(policy.manifestPolicyGateMode), + selfAuthoredLinkedIssueGateMode: block(policy.selfAuthoredLinkedIssueGateMode), + }; +} + +/** Public entry. In normal mode this is exactly `evaluateGateCheckCore`. In dry-run mode (#gate-dryrun) it ALSO runs + * the core eval with advisory sub-gates promoted to block and attaches that as `displayConclusion` — the would-be + * merge/close/manual verdict — while the POSTED `conclusion` stays the real, non-enforcing one. */ export function evaluateGateCheck(advisoryResult: Advisory, policy: GateCheckPolicy = {}): GateCheckEvaluation { + const result = evaluateGateCheckCore(advisoryResult, policy); + if (!policy.dryRun) return result; + const wouldBe = evaluateGateCheckCore(advisoryResult, promoteAdvisoryToBlock(policy)); + return { ...result, displayConclusion: wouldBe.conclusion }; +} + +function evaluateGateCheckCore(advisoryResult: Advisory, policy: GateCheckPolicy = {}): GateCheckEvaluation { const warnings = advisoryResult.findings.filter((finding) => finding.severity === "warning"); // App/infra state (repo not synced yet, PR not cached): gittensory cannot evaluate this PR yet, so the // gate is NEUTRAL (non-blocking) and re-evaluates automatically on the next sync/webhook. Never block a diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index fa11eefa27..78ffd6c1d2 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -36,6 +36,7 @@ export type FocusManifestGateConfig = { mergeReadiness: GateRuleMode | null; manifestPolicy: GateRuleMode | null; selfAuthoredLinkedIssue: GateRuleMode | null; + dryRun: boolean | null; firstTimeContributorGrace: boolean | null; }; @@ -249,6 +250,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { mergeReadiness: null, manifestPolicy: null, selfAuthoredLinkedIssue: null, + dryRun: null, firstTimeContributorGrace: null, }; @@ -409,6 +411,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings), manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings), selfAuthoredLinkedIssue: normalizeOptionalGateMode(record.selfAuthoredLinkedIssue, "gate.selfAuthoredLinkedIssue", warnings), + dryRun: normalizeOptionalBoolean(record.dryRun, "gate.dryRun", warnings), firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings), }; gate.present = @@ -430,6 +433,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu gate.mergeReadiness !== null || gate.manifestPolicy !== null || gate.selfAuthoredLinkedIssue !== null || + gate.dryRun !== null || gate.firstTimeContributorGrace !== null; return gate; } @@ -471,6 +475,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness; if (gate.manifestPolicy !== null) out.manifestPolicy = gate.manifestPolicy; if (gate.selfAuthoredLinkedIssue !== null) out.selfAuthoredLinkedIssue = gate.selfAuthoredLinkedIssue; + if (gate.dryRun !== null) out.dryRun = gate.dryRun; if (gate.firstTimeContributorGrace !== null) out.firstTimeContributorGrace = gate.firstTimeContributorGrace; return out; } @@ -937,6 +942,7 @@ export function resolveEffectiveSettings(dbSettings: RepositorySettings, manifes if (gate.mergeReadiness !== null) effective.mergeReadinessGateMode = gate.mergeReadiness; if (gate.manifestPolicy !== null) effective.manifestPolicyGateMode = gate.manifestPolicy; if (gate.selfAuthoredLinkedIssue !== null) effective.selfAuthoredLinkedIssueGateMode = gate.selfAuthoredLinkedIssue; + if (gate.dryRun !== null) effective.gateDryRun = gate.dryRun; if (gate.firstTimeContributorGrace !== null) effective.firstTimeContributorGrace = gate.firstTimeContributorGrace; // The dashboard "Require linked issue" toggle must not silently diverge from gate blocking: when the // boolean is on but linkedIssueGateMode is still off, treat it as a block requirement (#797). diff --git a/src/types.ts b/src/types.ts index 64dc108ea7..d59a2fc179 100644 --- a/src/types.ts +++ b/src/types.ts @@ -509,6 +509,10 @@ export type RepositorySettings = { * >= 10 changed files OR >= 500 changed (added+deleted) lines that would otherwise pass is HELD for manual review * (neutral gate → "manual" verdict), never auto-merged and never a hard failure. Opt-in via `gate.size.mode`. */ sizeGateMode?: GateRuleMode | undefined; + /** Dry-run disposition (#gate-dryrun). When true, the gate renders the would-be merge/close/manual verdict (every + * advisory sub-gate promoted to block) WITHOUT enforcing — the posted check stays non-blocking. Lets advisory mode + * preview exactly what it would do before the maintainer flips to real enforcement. Default off. */ + gateDryRun?: boolean | undefined; /** Merge-readiness gate (#merge-readiness). `off`/`advisory`/`block`. No min-score. Default `off`. */ mergeReadinessGateMode: GateRuleMode; /** Focus-manifest policy gate (#555). When `block`, the focus manifest's declared policy (blocked paths, diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index bac19015aa..be5821f80d 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -477,7 +477,7 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], - gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, firstTimeContributorGrace: null }, + gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, profile: null, inlineComments: null, pathInstructions: [], instructions: null, excludePaths: [], preMergeChecks: [] }, features: { present: false, rag: null, reputation: null, unifiedComment: null, safety: null }, @@ -766,7 +766,7 @@ describe("parseFocusManifest gate config", () => { it("parses a full gate section including the readiness block", () => { const m = parseFocusManifest({ gate: { linkedIssue: "block", duplicates: "advisory", readiness: { mode: "block", minScore: 70 } } }); expect(m.present).toBe(true); - expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, firstTimeContributorGrace: null }); + expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null }); }); it("parses gate.mergeReadiness + gate.firstTimeContributorGrace, round-trips them, and warns on bad values (#822)", () => { @@ -1421,3 +1421,12 @@ describe("gate.size manual-review hold config (#gate-size)", () => { expect(round.gate.sizeMode).toBe("advisory"); }); }); + +describe("gate.dryRun dry-run disposition config (#gate-dryrun)", () => { + it("parses gate.dryRun, sets present, and round-trips via gateConfigToJson", () => { + const m = parseFocusManifest({ gate: { dryRun: true } }); + expect(m.gate.dryRun).toBe(true); + expect(m.gate.present).toBe(true); + expect(gateConfigToJson(m.gate)).toMatchObject({ dryRun: true }); + }); +}); diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index 44c450617c..0c3412f26c 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -602,3 +602,25 @@ describe("size + guardrail manual-review HOLD (#gate-size / #gate-guardrail)", ( expect(eff.sizeGateMode).toBe("advisory"); }); }); + +describe("dry-run disposition (#gate-dryrun): would-be verdict without enforcing", () => { + it("posts the real non-enforcing conclusion but exposes the would-be conclusion as displayConclusion", () => { + // advisory linked-issue ⇒ the missing-issue finding does NOT block (posted = success), but promoted to block it WOULD close + const out = evaluateGateCheck(missingIssueAdvisory(), { dryRun: true, linkedIssueGateMode: "advisory" }); + expect(out.conclusion).toBe("success"); // POSTED — non-blocking pass + expect(out.displayConclusion).toBe("failure"); // would-be — drives the "close" verdict in the comment + }); + it("a clean PR in dry-run shows a would-be PASS (displayConclusion = success)", () => { + const clean = { ...missingIssueAdvisory(), findings: [] }; + expect(evaluateGateCheck(clean, { dryRun: true, linkedIssueGateMode: "advisory" }).displayConclusion).toBe("success"); + }); + it("outside dry-run, displayConclusion is absent (the verdict falls back to the posted conclusion)", () => { + const out = evaluateGateCheck(missingIssueAdvisory(), { linkedIssueGateMode: "advisory" }); + expect(out.conclusion).toBe("success"); + expect(out.displayConclusion).toBeUndefined(); + }); + it("resolveEffectiveSettings maps gate.dryRun → gateDryRun", () => { + const eff = resolveEffectiveSettings(settings({}), parseFocusManifest({ gate: { dryRun: true } })); + expect(eff.gateDryRun).toBe(true); + }); +});