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
6 changes: 6 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions config/examples/gittensory.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
19 changes: 16 additions & 3 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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.`);
Expand Down Expand Up @@ -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 ||
Expand All @@ -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,
Expand All @@ -1671,6 +1682,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo
autoReview,
aiModel,
visual,
linkedIssueSatisfaction,
enrichmentAnalyzers,
profile,
tone,
Expand Down Expand Up @@ -2113,6 +2125,7 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue
}
out.visual = visual;
}
if (review.linkedIssueSatisfaction !== null) out.linkedIssueSatisfaction = review.linkedIssueSatisfaction;
return out;
}

Expand Down
3 changes: 2 additions & 1 deletion test/unit/focus-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => {
labelingRules: "labeling_rules:",
aiModel: "ai_model:",
visual: "visual:",
linkedIssueSatisfaction: "linkedIssueSatisfaction:",
} satisfies Record<Exclude<keyof FocusManifestReviewConfig, "present">, string>;

it.each(Object.entries(REVIEW_FIELD_TOKENS))("documents review.%s", (_field, token) => {
Expand Down Expand Up @@ -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 },
Expand Down
37 changes: 37 additions & 0 deletions test/unit/review-linked-issue-satisfaction.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)).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<string, unknown>;
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);
});
});
2 changes: 1 addition & 1 deletion test/unit/signals-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 </details> edge case.\n- Keep the validator helper scoped." },
});
expect(customizedComment).toContain("Reviewed by the Acme maintainer bot."); // custom footer lead
Expand Down
Loading