diff --git a/.gittensory.yml.example b/.gittensory.yml.example index c1e54b9192..e19fe86784 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -285,7 +285,9 @@ gate: # comment, but never blocks; block = a confidence-floor-passing "unaddressed" # verdict ALSO becomes a hard blocker (linked_issue_scope_mismatch). # off | advisory | block. Default: off. DB-backed (dashboard-settable too); - # this overrides the stored value. + # this overrides the stored value. When unset, the review.linkedIssueSatisfaction + # knob further below is used as a fallback instead (#4149) -- setting either + # spelling has the same real effect; this one wins if both are set. linkedIssueSatisfaction: off # Gate-check dry-run. When true, the posted check conclusion remains the real @@ -983,8 +985,8 @@ settings: # finding_categories: false # # How strictly a linked issue must actually be SATISFIED by the PR (distinct from linkedIssuePolicy, # # which only checks a link EXISTS). off = not evaluated; advisory = surface a finding; block = can -# # become a hard blocker (confirmed-contributor-gated). This is the config knob only — parsed and -# # normalized here; the merge/close decision that reads it is a separate maintainer slice. +# # become a hard blocker (confirmed-contributor-gated). Fallback alias for gate.linkedIssueSatisfaction +# # above (#4149) -- used only when that field is unset; setting either spelling has the same real effect. # # off | advisory | block. Default: off (byte-identical when unset). # linkedIssueSatisfaction: off # # Maintainer-declared DETERMINISTIC content assertions (title/description must contain a phrase, a diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index 06fadd51d2..b64808fdf2 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -298,7 +298,9 @@ gate: # comment, but never blocks; block = a confidence-floor-passing "unaddressed" # verdict ALSO becomes a hard blocker (linked_issue_scope_mismatch). # off | advisory | block. Default: off. DB-backed (dashboard-settable too); - # this overrides the stored value. + # this overrides the stored value. When unset, the review.linkedIssueSatisfaction + # knob further below is used as a fallback instead (#4149) -- setting either + # spelling has the same real effect; this one wins if both are set. linkedIssueSatisfaction: off # Gate-check dry-run. When true, the posted check conclusion remains the real @@ -996,8 +998,8 @@ settings: # finding_categories: false # # How strictly a linked issue must actually be SATISFIED by the PR (distinct from linkedIssuePolicy, # # which only checks a link EXISTS). off = not evaluated; advisory = surface a finding; block = can -# # become a hard blocker (confirmed-contributor-gated). This is the config knob only — parsed and -# # normalized here; the merge/close decision that reads it is a separate maintainer slice. +# # become a hard blocker (confirmed-contributor-gated). Fallback alias for gate.linkedIssueSatisfaction +# # above (#4149) -- used only when that field is unset; setting either spelling has the same real effect. # # off | advisory | block. Default: off (byte-identical when unset). # linkedIssueSatisfaction: off # # Maintainer-declared DETERMINISTIC content assertions (title/description must contain a phrase, a diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index be7118e998..13bd934027 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -588,6 +588,15 @@ export function resolveEffectiveSettings( }; } applyGateConfigOverrides(effective, manifest.gate); + // #4149: `review.linkedIssueSatisfaction` (#2173) is a near-identically-named but functionally distinct + // phantom field -- parsed, but until now never wired to the real DB-backed gate + // (linkedIssueSatisfactionGateMode), unlike every other typed `gate.*` alias. Fold it in as a fallback: + // `gate.linkedIssueSatisfaction` (applied above) always wins when set; otherwise an explicit + // `review.linkedIssueSatisfaction` takes effect instead of being silently discarded, so a self-hoster who + // sets either spelling gets the same real gate behavior. + if (manifest.gate.linkedIssueSatisfaction === null && manifest.review.linkedIssueSatisfaction !== null) { + effective.linkedIssueSatisfactionGateMode = manifest.review.linkedIssueSatisfaction; + } // 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). // #4618: the yml-only top-level `linkedIssuePolicy: required` knob gets the same promotion -- previously a diff --git a/src/types.ts b/src/types.ts index c97bcac689..22f5f89ecd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -815,10 +815,13 @@ export type RepositorySettings = { * satisfies its primary linked issue's intent never runs (byte-identical to today). `advisory` = it runs * and renders as a collapsible section in the review comment, but never blocks. `block` = ALSO let a * confidence-floor-passing "unaddressed" verdict become a gate blocker (`linked_issue_scope_mismatch`, - * confirmed-contributors only, like every other blocker). Distinct from the config-as-code-only - * `review.linkedIssueSatisfaction` manifest field (#2173) — this is the DB-backed, dashboard-settable - * gate-mode counterpart; `.gittensory.yml gate.linkedIssueSatisfaction` overrides it exactly like every - * other `gate:` field overrides its `RepositorySettings` counterpart. Default `off` — opt-in. */ + * confirmed-contributors only, like every other blocker). This is the DB-backed, dashboard-settable + * counterpart; `.gittensory.yml gate.linkedIssueSatisfaction` overrides it exactly like every other + * `gate:` field overrides its `RepositorySettings` counterpart. The near-identically-named, config-as- + * code-only `review.linkedIssueSatisfaction` manifest field (#2173) is folded in as a fallback alias + * (#4149) when `gate.linkedIssueSatisfaction` is unset — see `resolveEffectiveSettings` in + * `signals/focus-manifest.ts` — so setting either spelling has the same real effect. Default `off` — + * opt-in. */ linkedIssueSatisfactionGateMode: GateRuleMode; /** First-time-contributor grace (#552). RESERVED / currently INERT (#2266): parsed, clamped, and threaded * end-to-end, but the gate evaluator never reads it — a genuine newcomer with a real blocker is still diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index e5fd6fbc96..0c1960cae5 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -3105,6 +3105,37 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(eff.linkedIssueGateMode).toBe("advisory"); }); + describe("review.linkedIssueSatisfaction phantom-field alias (#4149)", () => { + it("an explicit review.linkedIssueSatisfaction takes effect when gate.linkedIssueSatisfaction is unset", () => { + const eff = resolveEffectiveSettings( + { linkedIssueSatisfactionGateMode: "off" } as RepositorySettings, + parseFocusManifest({ review: { linkedIssueSatisfaction: "block" } }), + ); + expect(eff.linkedIssueSatisfactionGateMode).toBe("block"); + }); + + it("gate.linkedIssueSatisfaction always wins over review.linkedIssueSatisfaction when both are set", () => { + const eff = resolveEffectiveSettings( + { linkedIssueSatisfactionGateMode: "off" } as RepositorySettings, + parseFocusManifest({ review: { linkedIssueSatisfaction: "block" }, gate: { linkedIssueSatisfaction: "advisory" } }), + ); + expect(eff.linkedIssueSatisfactionGateMode).toBe("advisory"); + }); + + it("falls through to the DB value when neither spelling is set", () => { + const eff = resolveEffectiveSettings({ linkedIssueSatisfactionGateMode: "block" } as RepositorySettings, parseFocusManifest(null)); + expect(eff.linkedIssueSatisfactionGateMode).toBe("block"); + }); + + it("review.linkedIssueSatisfaction: off is a real, distinct value from unset -- it still applies (not silently skipped)", () => { + const eff = resolveEffectiveSettings( + { linkedIssueSatisfactionGateMode: "block" } as RepositorySettings, + parseFocusManifest({ review: { linkedIssueSatisfaction: "off" } }), + ); + expect(eff.linkedIssueSatisfactionGateMode).toBe("off"); + }); + }); + it("REGRESSION: downgrades a pre-existing DB qualityGateMode: block to advisory, even with no gate.readiness.mode override (#2267)", () => { // Simulates a repo whose DB row already has quality_gate_mode = "block" from before the write-time guards // (the settings.qualityGateMode parser, the settings-write API routes) existed — the dashboard/API path's