From f7aed42da93910ca6bfd9682bf66fdec3f58a490 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 17 Jun 2026 03:47:15 -0700 Subject: [PATCH] feat(config): expose gate.mergeReadiness + gate.firstTimeContributorGrace in .gittensory.yml (#822) Both settings shipped DB-only (#551, #552); their .gittensory.yml parity was deferred. Extend the focus-manifest gate-schema parser, the present predicate, the gateConfigToJson serializer, and resolveEffectiveSettings so a repo can drive both from its config file (manifest wins over DB, mirroring every other gate mode). gate.mergeReadiness: off|advisory|block -> mergeReadinessGateMode gate.firstTimeContributorGrace: true|false -> firstTimeContributorGrace Closes the recurring debt where each new DB gate setting shipped without its manifest mapping. --- src/signals/focus-manifest.ts | 14 +++++++++++++- test/unit/focus-manifest.test.ts | 16 ++++++++++++++-- test/unit/gate-check-policy.test.ts | 13 +++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 2596e3412b..f739456ee1 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -29,6 +29,8 @@ export type FocusManifestGateConfig = { aiReviewByok: boolean | null; aiReviewProvider: "anthropic" | "openai" | null; aiReviewModel: string | null; + mergeReadiness: GateRuleMode | null; + firstTimeContributorGrace: boolean | null; }; /** @@ -157,6 +159,8 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, + mergeReadiness: null, + firstTimeContributorGrace: null, }; const EMPTY_MANIFEST: FocusManifest = { @@ -297,6 +301,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu aiReviewByok: normalizeOptionalBoolean(aiReviewRecord?.byok, "gate.aiReview.byok", warnings), aiReviewProvider: normalizeOptionalEnum(aiReviewRecord?.provider, "gate.aiReview.provider", ["anthropic", "openai"] as const, warnings), aiReviewModel: normalizeOptionalString(aiReviewRecord?.model, "gate.aiReview.model", warnings), + mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings), + firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings), }; gate.present = gate.enabled !== null || @@ -311,7 +317,9 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu gate.aiReviewMode !== null || gate.aiReviewByok !== null || gate.aiReviewProvider !== null || - gate.aiReviewModel !== null; + gate.aiReviewModel !== null || + gate.mergeReadiness !== null || + gate.firstTimeContributorGrace !== null; return gate; } @@ -347,6 +355,8 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { if (gate.aiReviewModel !== null) aiReview.model = gate.aiReviewModel; out.aiReview = aiReview; } + if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness; + if (gate.firstTimeContributorGrace !== null) out.firstTimeContributorGrace = gate.firstTimeContributorGrace; return out; } @@ -492,6 +502,8 @@ export function resolveEffectiveSettings(dbSettings: RepositorySettings, manifes if (gate.aiReviewByok !== null) effective.aiReviewByok = gate.aiReviewByok; if (gate.aiReviewProvider !== null) effective.aiReviewProvider = gate.aiReviewProvider; if (gate.aiReviewModel !== null) effective.aiReviewModel = gate.aiReviewModel; + if (gate.mergeReadiness !== null) effective.mergeReadinessGateMode = gate.mergeReadiness; + if (gate.firstTimeContributorGrace !== null) effective.firstTimeContributorGrace = gate.firstTimeContributorGrace; return effective; } diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 3f9d28f5b1..34f704c143 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -400,7 +400,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, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null }, + gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, firstTimeContributorGrace: null }, settings: {}, review: { present: false, footerText: null, note: null, fields: {} }, warnings: [], @@ -688,7 +688,19 @@ 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, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: 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, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, firstTimeContributorGrace: null }); + }); + + it("parses gate.mergeReadiness + gate.firstTimeContributorGrace, round-trips them, and warns on bad values (#822)", () => { + const m = parseFocusManifest({ gate: { mergeReadiness: "block", firstTimeContributorGrace: true } }); + expect(m.gate.present).toBe(true); + expect(m.gate.mergeReadiness).toBe("block"); + expect(m.gate.firstTimeContributorGrace).toBe(true); + expect(gateConfigToJson(m.gate)).toMatchObject({ mergeReadiness: "block", firstTimeContributorGrace: true }); + const bad = parseFocusManifest({ gate: { mergeReadiness: "sometimes", firstTimeContributorGrace: "yes" } }); + expect(bad.gate.mergeReadiness).toBeNull(); + expect(bad.gate.firstTimeContributorGrace).toBeNull(); + expect(bad.gate.present).toBe(false); }); it("parses the gate.slop block, round-trips it, and warns on a non-mapping (#530/#532)", () => { diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index 5ab975dcf9..a7dc097484 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -76,6 +76,19 @@ describe(".gittensory.yml settings override (resolveEffectiveSettings)", () => { expect(evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, true)).conclusion).toBe("success"); }); + it("end-to-end: manifest gate.mergeReadiness:block drives the composite even when the DB sub-gate is advisory (#822)", () => { + const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "advisory", mergeReadinessGateMode: "off" }), parseFocusManifest({ gate: { mergeReadiness: "block" } })); + expect(eff.mergeReadinessGateMode).toBe("block"); + expect(evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, true)).conclusion).toBe("failure"); + }); + + it("end-to-end: manifest gate.firstTimeContributorGrace:true softens a newcomer's block to advisory (#822/#552)", () => { + const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "block", firstTimeContributorGrace: false }), parseFocusManifest({ gate: { firstTimeContributorGrace: true } })); + expect(eff.firstTimeContributorGrace).toBe(true); + const newcomerPolicy = gateCheckPolicy(eff, null, true, null, { mergedPrCount: 0, closedUnmergedPrCount: 0 }); + expect(evaluateGateCheck(missingIssueAdvisory(), newcomerPolicy).conclusion).toBe("neutral"); + }); + it("still only blocks confirmed contributors regardless of the config", () => { const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "advisory" }), parseFocusManifest({ gate: { linkedIssue: "block" } })); const nonConfirmed = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, false));