From 5efcdc705640c017f645dff37c191ae09411fb15 Mon Sep 17 00:00:00 2001 From: Nick M <274344962+nickmopen@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:09:12 -0500 Subject: [PATCH 1/2] feat(config): add review.linkedIssueSatisfaction knob (off/advisory/block, default off) (#2173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config-as-code slice for #1961: parse + normalize the per-repo knob through the FocusManifestReviewConfig parse path only. Default null (unset) ⇒ byte-identical to today. Does NOT touch the merge/close decision — that maintainer-only slice reads the resolved mode separately. - focus-manifest.ts: LinkedIssueSatisfactionMode ('off'|'advisory'|'block') + linkedIssueSatisfaction field; normalizeOptionalEnum parse (warn+ignore on malformed), default null; reviewConfigToJson round-trip (omitted when null ⇒ byte-identical) + present + EMPTY literals. - .gittensory.yml.example: documented review.linkedIssueSatisfaction (distinct from the existing linkedIssuePolicy, which only checks a link EXISTS). - Tests: absent→null (omitted), each valid mode round-trips + marks present, malformed + non-string → warn+null. Ships the .gittensory.yml parity per the config-as-code rule (enforced by the existing "documents review.*" parity test). --- .gittensory.yml.example | 6 +++ src/signals/focus-manifest.ts | 19 ++++++++-- test/unit/focus-manifest.test.ts | 3 +- .../review-linked-issue-satisfaction.test.ts | 37 +++++++++++++++++++ test/unit/signals-coverage.test.ts | 2 +- 5 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 test/unit/review-linked-issue-satisfaction.test.ts diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 7d9eddbe32..d93713c7b8 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -753,6 +753,12 @@ settings: # # fallback for whatever it omits. Only takes effect when inline_comments is already on. Bool or null. # # Default: null/false. # 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. +# # off | advisory | block. Default: off (byte-identical when unset). +# linkedIssueSatisfaction: off # # Maintainer-declared DETERMINISTIC content assertions (title/description must contain a phrase, a # # label must be present), optionally gated to a path glob. A failed check is advisory by default; # # `enforce: true` makes it a hard gate blocker. Empty/default ⇒ no finding (no AI judgment involved). diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 000eb84b5a..62cc8d8a90 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -378,8 +378,17 @@ export type FocusManifestReviewConfig = { * GITTENSORY_REVIEW_SCREENSHOTS + the repo cutover allowlist — this config narrows/redirects that * feature, it never turns it on by itself. */ visual: VisualConfig; + /** `review.linkedIssueSatisfaction`: how strictly a linked issue must actually be SATISFIED by the PR — `off` + * (default; not evaluated), `advisory` (surface a finding), or `block` (can become a hard blocker). CONFIG SLICE + * ONLY (#2173, for #1961): parsed + normalized here; the merge/close decision that reads this mode is a separate + * maintainer-only slice. null (default, absent) ⇒ byte-identical to today. */ + linkedIssueSatisfaction: LinkedIssueSatisfactionMode | null; }; +/** `review.linkedIssueSatisfaction` modes (#2173). `off` = not evaluated (same as unset). */ +export const LINKED_ISSUE_SATISFACTION_MODES = ["off", "advisory", "block"] as const; +export type LinkedIssueSatisfactionMode = (typeof LINKED_ISSUE_SATISFACTION_MODES)[number]; + /** One `review.labeling_rules[]` entry: a non-reserved `label` plus the deterministic `when` criteria that must ALL * match for it to fire. A rule always has at least one criterion (enforced at parse). */ export type LabelingRule = { @@ -633,7 +642,7 @@ const EMPTY_MANIFEST: FocusManifest = { publicNotes: [], gate: { ...EMPTY_GATE_CONFIG }, settings: {}, - review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, findingCategories: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG } }, + review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, findingCategories: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null }, features: { ...EMPTY_FEATURES_CONFIG }, contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG }, @@ -663,7 +672,7 @@ function emptyManifest(source: FocusManifestSource, warnings: string[] = []): Fo warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {}, - review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, findingCategories: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG } }, + review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, findingCategories: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null }, features: { ...EMPTY_FEATURES_CONFIG }, contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG }, @@ -1594,7 +1603,7 @@ function parsePublicSafeText(value: JsonValue | undefined, field: string, warnin * throws; invalid/unsafe values are dropped with warnings. */ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestReviewConfig { - const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, findingCategories: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG } }; + const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, findingCategories: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null }; if (value === undefined || value === null) return empty; if (typeof value !== "object" || Array.isArray(value)) { warnings.push(`Manifest field "review" must be a mapping; ignoring it.`); @@ -1643,6 +1652,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo const labelingRules = parseReviewLabelingRules(r.labeling_rules, warnings); const aiModel = parseSelfHostAiModelConfig(r.ai_model, warnings); const visual = parseVisualConfig(r.visual, warnings); + const linkedIssueSatisfaction = normalizeOptionalEnum(r.linkedIssueSatisfaction, "review.linkedIssueSatisfaction", LINKED_ISSUE_SATISFACTION_MODES, warnings); return { present: footerText !== null || @@ -1663,6 +1673,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo labelingRules.length > 0 || selfHostAiModelPresent(aiModel) || visualConfigPresent(visual) || + linkedIssueSatisfaction !== null || Object.keys(fields).length > 0 || Object.keys(enrichmentAnalyzers).length > 0, footerText, @@ -1671,6 +1682,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo autoReview, aiModel, visual, + linkedIssueSatisfaction, enrichmentAnalyzers, profile, tone, @@ -2113,6 +2125,7 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue } out.visual = visual; } + if (review.linkedIssueSatisfaction !== null) out.linkedIssueSatisfaction = review.linkedIssueSatisfaction; return out; } diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index f65c146a04..d1c6ca04ce 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -361,6 +361,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => { labelingRules: "labeling_rules:", aiModel: "ai_model:", visual: "visual:", + linkedIssueSatisfaction: "linkedIssueSatisfaction:", } satisfies Record, string>; it.each(Object.entries(REVIEW_FIELD_TOKENS))("documents review.%s", (_field, token) => { @@ -761,7 +762,7 @@ describe("compileFocusManifestPolicy", () => { publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], gate: { present: false, enabled: null, checkMode: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null }, settings: {}, - review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, findingCategories: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG } }, + review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, findingCategories: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null }, features: { present: false, rag: null, reputation: null, unifiedComment: null, safety: null }, contentLane: { present: false, entryFileGlob: null, providerFileGlob: null, artifactGlob: null, collectionField: null, maxAppendedEntries: null, duplicateKeyFields: [], validatorId: null }, repoDocGeneration: { present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false, refreshIntervalDays: 7 }, diff --git a/test/unit/review-linked-issue-satisfaction.test.ts b/test/unit/review-linked-issue-satisfaction.test.ts new file mode 100644 index 0000000000..8a76df38a4 --- /dev/null +++ b/test/unit/review-linked-issue-satisfaction.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { parseFocusManifest, reviewConfigToJson } from "../../src/signals/focus-manifest"; + +const reviewOf = (linkedIssueSatisfaction: unknown) => + parseFocusManifest({ review: { linkedIssueSatisfaction } }); + +describe("review.linkedIssueSatisfaction config knob (#2173)", () => { + it("absent ⇒ null and OMITTED on serialize (byte-identical to today)", () => { + const review = parseFocusManifest({ review: { note: "x" } }).review; + expect(review.linkedIssueSatisfaction).toBe(null); + expect("linkedIssueSatisfaction" in (reviewConfigToJson(review) as Record)).toBe(false); + }); + + it("each valid mode parses, marks present, and round-trips", () => { + for (const mode of ["off", "advisory", "block"] as const) { + const review = reviewOf(mode).review; + expect(review.linkedIssueSatisfaction).toBe(mode); + expect(review.present).toBe(true); + const json = reviewConfigToJson(review) as Record; + expect(json.linkedIssueSatisfaction).toBe(mode); + expect(parseFocusManifest({ review: json }).review.linkedIssueSatisfaction).toBe(mode); + } + }); + + it("a malformed value warns and falls back to null (ignored)", () => { + const m = reviewOf("sometimes"); + expect(m.review.linkedIssueSatisfaction).toBe(null); + expect(m.review.present).toBe(false); + expect(m.warnings.some((w) => /review\.linkedIssueSatisfaction/.test(w))).toBe(true); + }); + + it("a non-string value is also rejected with a warning", () => { + const m = reviewOf(true); + expect(m.review.linkedIssueSatisfaction).toBe(null); + expect(m.warnings.some((w) => /review\.linkedIssueSatisfaction/.test(w))).toBe(true); + }); +}); diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 2bf86dbf69..384e49e994 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1127,7 +1127,7 @@ describe("signal coverage edge cases", () => { collisions: buildCollisionReport(directRepo.fullName, [], [currentPr]), preflight: buildPreflightResult({ repoFullName: directRepo.fullName, title: "Fix isolated issue", body: "Fixes #99", linkedIssues: [99] }, directRepo, [], [currentPr]), settings: gateSettings, - review: { present: true, footerText: "Reviewed by the Acme maintainer bot.", note: "Run npm test before pushing.", fields: { relatedWork: false }, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, findingCategories: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { skipDrafts: null, ignoreAuthors: [], ignoreTitleKeywords: [], baseBranches: [], autoPauseAfterReviewedCommits: null }, labelingRules: [], aiModel: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null }, visual: { preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null } } }, + review: { present: true, footerText: "Reviewed by the Acme maintainer bot.", note: "Run npm test before pushing.", fields: { relatedWork: false }, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, findingCategories: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { skipDrafts: null, ignoreAuthors: [], ignoreTitleKeywords: [], baseBranches: [], autoPauseAfterReviewedCommits: null }, labelingRules: [], aiModel: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null }, visual: { preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null } }, linkedIssueSatisfaction: null }, aiReview: { notes: "The change is focused.\n\n**Nits (2)**\n- Add a test for the edge case.\n- Keep the validator helper scoped." }, }); expect(customizedComment).toContain("Reviewed by the Acme maintainer bot."); // custom footer lead From 2f313a1c7d4ee3b6ac8f70b106c0f2ea3f109ff5 Mon Sep 17 00:00:00 2001 From: Nick M <274344962+nickmopen@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:21:28 -0500 Subject: [PATCH 2/2] docs: sync review.linkedIssueSatisfaction into gittensory.full.yml mirror (#2173) The config-templates test (#1682) asserts gittensory.full.yml is byte-identical to .gittensory.yml.example from the WHERE IT LIVES marker onward; mirror the new doc block. --- config/examples/gittensory.full.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index 5a48b72246..f9188652f6 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -766,6 +766,12 @@ settings: # # fallback for whatever it omits. Only takes effect when inline_comments is already on. Bool or null. # # Default: null/false. # 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. +# # off | advisory | block. Default: off (byte-identical when unset). +# linkedIssueSatisfaction: off # # Maintainer-declared DETERMINISTIC content assertions (title/description must contain a phrase, a # # label must be present), optionally gated to a path glob. A failed check is advisory by default; # # `enforce: true` makes it a hard gate blocker. Empty/default ⇒ no finding (no AI judgment involved).