Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
5 changes: 4 additions & 1 deletion src/review/unified-comment-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type FocusManifestGateConfig = {
mergeReadiness: GateRuleMode | null;
manifestPolicy: GateRuleMode | null;
selfAuthoredLinkedIssue: GateRuleMode | null;
dryRun: boolean | null;
firstTimeContributorGrace: boolean | null;
};

Expand Down Expand Up @@ -249,6 +250,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
mergeReadiness: null,
manifestPolicy: null,
selfAuthoredLinkedIssue: null,
dryRun: null,
firstTimeContributorGrace: null,
};

Expand Down Expand Up @@ -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 =
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions test/unit/focus-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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)", () => {
Expand Down Expand Up @@ -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 });
});
});
22 changes: 22 additions & 0 deletions test/unit/gate-check-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading