Skip to content
Closed
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 .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,8 @@ review:
# When true, the unified review comment gains a deterministic "Changed files" summary table.
# effort_score: false
# When true, the unified review comment gains a compact "review effort: N/5 (~M min)" chip.
# auto_merge_summary: false
# When true, the unified comment gains a read-only "Auto-merge conditions" table (display-only).

# Display-only floor for inline AI findings (`critical` | `major` | `minor` | `nitpick`). Findings below the
# configured level are suppressed from inline comments — never from gate blockers. Default: null (show all).
Expand Down
2 changes: 2 additions & 0 deletions config/examples/gittensory.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,8 @@ review:
# When true, the unified review comment gains a deterministic "Changed files" summary table.
# effort_score: false
# When true, the unified review comment gains a compact "review effort: N/5 (~M min)" chip.
# auto_merge_summary: false
# When true, the unified comment gains a read-only "Auto-merge conditions" table (display-only).

# Display-only floor for inline AI findings (`critical` | `major` | `minor` | `nitpick`). Findings below the
# configured level are suppressed from inline comments — never from gate blockers. Default: null (show all).
Expand Down
3 changes: 3 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7832,6 +7832,7 @@ async function maybePublishPrPublicSurface(
let suggestionsEnabledForReview = false;
let changedFilesSummaryEnabledForReview = false;
let effortScoreEnabledForReview = false;
let autoMergeSummaryEnabledForReview = false;
let findingCategoriesEnabledForReview = false;
let minFindingSeverityForReview: ReviewFindingSeverity | null = null;
let aiReviewExpected = false;
Expand Down Expand Up @@ -8344,6 +8345,7 @@ async function maybePublishPrPublicSurface(
const deterministicReviewOverrides = resolveReviewPromptOverrides(reviewManifestForAutoReview);
changedFilesSummaryEnabledForReview = deterministicReviewOverrides.changedFilesSummary;
effortScoreEnabledForReview = deterministicReviewOverrides.effortScore;
autoMergeSummaryEnabledForReview = deterministicReviewOverrides.autoMergeSummary;
minFindingSeverityForReview = deterministicReviewOverrides.minFindingSeverity;
maybeAddRequiredAutoReviewSkipHold(env, {
settings,
Expand Down Expand Up @@ -9597,6 +9599,7 @@ async function maybePublishPrPublicSurface(
...(findingCategoriesEnabledForReview && aiReview?.inlineFindings?.length
? { findingCategories: aiReview.inlineFindings }
: {}),
autoMergeSummary: autoMergeSummaryEnabledForReview,
maxFindingsCaps: reviewConfig.maxFindings,
});
} else {
Expand Down
58 changes: 57 additions & 1 deletion src/review/unified-comment-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ export type UnifiedCommentBridgeArgs = {
* through to `buildUnifiedReviewInput`'s `reviewEffort`). No AI. Default OFF (the processor passes this only
* when the manifest opts in — see `resolveReviewPromptOverrides`'s `effortScore`). (#1955) */
reviewEffort?: { band: 1 | 2 | 3 | 4 | 5; minutes: number } | undefined;
/** Read-only auto-merge conditions table (`review.auto_merge_summary` port). Default OFF — the processor passes
* true only when the manifest opts in (see `resolveReviewPromptOverrides`'s `autoMergeSummary`). (#2051) */
autoMergeSummary?: boolean | undefined;
/** Display-only caps from `review.max_findings` (#2049). */
maxFindingsCaps?: { blockers: number | null; nits: number | null } | undefined;
/** Line-anchored AI findings, one entry per inline finding (review.finding_categories port). When present +
Expand Down Expand Up @@ -480,6 +483,47 @@ export function buildChangedFilesSummaryCollapsible(files: ChangedFileSummaryInp
return { title: "Changed files", body };
}

/** Read-only pass/fail flags for the auto-merge conditions table — derived from readiness facts the unified
* comment already resolved; never re-derives merge/close decisions. (#2051) */
export type AutoMergeSummaryInput = {
ciGreen: boolean;
gatePassing: boolean;
mergeableClean: boolean;
linkedIssueOk: boolean;
};

/** Derive the four auto-merge condition flags from signals the caller already computed for the comment. */
export function deriveAutoMergeSummaryInput(args: {
mergeReadiness?: MergeReadiness | undefined;
gateConclusion: GateCheckConclusion;
panelRows: PublicPrPanelSignalRow[];
}): AutoMergeSummaryInput {
const linkedRow = args.panelRows.find((row) => row.key === "linkedIssue");
const mergeLabel = args.mergeReadiness?.mergeStateLabel?.trim().toLowerCase();
return {
ciGreen: args.mergeReadiness?.ciState === "passed",
gatePassing: args.gateConclusion === "success",
mergeableClean: mergeLabel === "clean",
linkedIssueOk: linkedRow !== undefined && linkedRow.cells[1].startsWith("✅"),
};
}

/** Build the read-only "Auto-merge conditions" collapsible — a pass/fail table only; never changes decisions. */
export function buildAutoMergeSummaryCollapsible(conditions: AutoMergeSummaryInput): UnifiedCollapsible {
const row = (label: string, ok: boolean): string => `| ${label} | ${ok ? "✅ pass" : "❌ fail"} |`;
const body = [
"_Read-only — does not change merge decisions._",
"",
"| Condition | Status |",
"| --- | --- |",
row("CI green", conditions.ciGreen),
row("Gate passing", conditions.gatePassing),
row("Mergeable (clean)", conditions.mergeableClean),
row("Linked issue", conditions.linkedIssueOk),
].join("\n");
return { title: "Auto-merge conditions", body };
}

/** A finding's path + body — everything `buildFindingCategoryCollapsible` needs to use the finding's own
* `category` when present, or fall back to `classifyFindingCategory` when it isn't. Deliberately narrower than
* `InlineFinding` (no line/severity/suggestion) so the bridge's pure-rendering surface stays minimal. */
Expand Down Expand Up @@ -591,10 +635,22 @@ export function buildUnifiedCommentBody(args: UnifiedCommentBridgeArgs): string
: null;
const withFindingCategories =
findingCategoryCollapsible !== null ? [...(withChangedFiles ?? []), findingCategoryCollapsible] : withChangedFiles;
const autoMergeCollapsible =
args.autoMergeSummary === true
? buildAutoMergeSummaryCollapsible(
deriveAutoMergeSummaryInput({
...(args.mergeReadiness !== undefined ? { mergeReadiness: args.mergeReadiness } : {}),
gateConclusion: args.gate.conclusion,
panelRows: visibleRows,
}),
)
: null;
const withAutoMergeSummary =
autoMergeCollapsible !== null ? [...(withFindingCategories ?? []), autoMergeCollapsible] : withFindingCategories;
// Visual-capture port: when before/after routes are present, append a "Visual preview" collapsible to the
// extra sections. Flag-OFF (the processor passes no beforeAfter) ⇒ extraCollapsibles is unchanged.
const visualCollapsible = args.beforeAfter && args.beforeAfter.length > 0 ? buildBeforeAfterCollapsible(args.beforeAfter) : null;
const withVisual = visualCollapsible !== null ? [...(withFindingCategories ?? []), visualCollapsible] : withFindingCategories;
const withVisual = visualCollapsible !== null ? [...(withAutoMergeSummary ?? []), visualCollapsible] : withAutoMergeSummary;
// #3612: "Scroll preview" renders ALONGSIDE "Visual preview" (never replacing it) — self-host + gif:true
// only, so this is null (no section, no behavior change) for every repo that hasn't opted in.
const scrollCollapsible = args.beforeAfter && args.beforeAfter.length > 0 ? buildScrollPreviewCollapsible(args.beforeAfter) : null;
Expand Down
19 changes: 14 additions & 5 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,11 @@ export type FocusManifestReviewConfig = {
* source, same display-only (never touches the AI prompt) shape. null/false (default, absent) = no chip =
* byte-identical behavior. (#1955) */
effortScore: boolean | null;
/** `review.auto_merge_summary`: when true, the unified review comment gains a read-only "Auto-merge conditions"
* collapsible — a pass/fail table for CI green, gate passing, mergeable-clean, and linked-issue signals derived
* from readiness facts the comment already computed. Display-only — never changes merge/close decisions.
* null/false (default, absent) = no section = byte-identical behavior. (#2051) */
autoMergeSummary: boolean | null;
/** `review.finding_categories`: when true, an inline finding is ALSO tagged with a category (security/
* correctness/performance/maintainability/tests/style) — the AI reviewer is asked to self-categorize, with a
* deterministic path/keyword fallback (`classifyFindingCategory`) covering whatever it omits. Only takes
Expand Down Expand Up @@ -695,7 +700,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, effortScore: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, 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 },
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, effortScore: null, autoMergeSummary: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, 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 @@ -725,7 +730,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, effortScore: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, 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 },
review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, effortScore: null, autoMergeSummary: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, 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 @@ -1676,7 +1681,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, effortScore: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, 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 };
const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, suggestions: null, changedFilesSummary: null, effortScore: null, autoMergeSummary: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, 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 @@ -1716,6 +1721,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo
const suggestions = normalizeOptionalBoolean(r.suggestions, "review.suggestions", warnings);
const changedFilesSummary = normalizeOptionalBoolean(r.changed_files_summary, "review.changed_files_summary", warnings);
const effortScore = normalizeOptionalBoolean(r.effort_score, "review.effort_score", warnings);
const autoMergeSummary = normalizeOptionalBoolean(r.auto_merge_summary, "review.auto_merge_summary", warnings);
const findingCategories = normalizeOptionalBoolean(r.finding_categories, "review.finding_categories", warnings);
const minFindingSeverity = normalizeOptionalEnum(
r.min_finding_severity,
Expand Down Expand Up @@ -1745,6 +1751,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo
suggestions !== null ||
changedFilesSummary !== null ||
effortScore !== null ||
autoMergeSummary !== null ||
findingCategories !== null ||
minFindingSeverity !== null ||
maxFindingsPresent(maxFindings) ||
Expand Down Expand Up @@ -1775,6 +1782,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo
suggestions,
changedFilesSummary,
effortScore,
autoMergeSummary,
findingCategories,
minFindingSeverity,
maxFindings,
Expand Down Expand Up @@ -2234,6 +2242,7 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue
if (review.suggestions !== null) out.suggestions = review.suggestions;
if (review.changedFilesSummary !== null) out.changed_files_summary = review.changedFilesSummary;
if (review.effortScore !== null) out.effort_score = review.effortScore;
if (review.autoMergeSummary !== null) out.auto_merge_summary = review.autoMergeSummary;
if (review.findingCategories !== null) out.finding_categories = review.findingCategories;
if (review.minFindingSeverity !== null) out.min_finding_severity = review.minFindingSeverity;
if (maxFindingsPresent(review.maxFindings)) {
Expand Down Expand Up @@ -2461,7 +2470,7 @@ export function composeManifestReviewInstructions(instructions: string | null, t
* failure). A null manifest yields the byte-identical defaults. Centralized so the AI-review caller threads them
* in one place with the null-manifest branch covered here (unit-tested) rather than inline in the processor.
* (#review-profile / #review-tone / #review-security-focus / #review-path-instructions / #review-exclude-paths / #2043 / #selfhost-ai-model-override / #1956) */
export function resolveReviewPromptOverrides(manifest: FocusManifest | null): { profile: ReviewProfile | null; tone: string | null; securityFocus: boolean; inlineComments: boolean; suggestions: boolean; changedFilesSummary: boolean; effortScore: boolean; findingCategories: boolean; minFindingSeverity: ReviewFindingSeverity | null; maxFindings: MaxFindingsConfig; pathInstructions: ReviewPathInstruction[]; instructions: string | null; excludePaths: string[]; pathFilters: string[]; selfHostAiModel: SelfHostAiModelConfig } {
export function resolveReviewPromptOverrides(manifest: FocusManifest | null): { profile: ReviewProfile | null; tone: string | null; securityFocus: boolean; inlineComments: boolean; suggestions: boolean; changedFilesSummary: boolean; effortScore: boolean; autoMergeSummary: boolean; findingCategories: boolean; minFindingSeverity: ReviewFindingSeverity | null; maxFindings: MaxFindingsConfig; pathInstructions: ReviewPathInstruction[]; instructions: string | null; excludePaths: string[]; pathFilters: string[]; selfHostAiModel: SelfHostAiModelConfig } {
// inlineComments resolves to a strict boolean — true ONLY when the manifest explicitly set review.inline_comments:
// true; null/false/absent ⇒ false. The caller ANDs this per-repo toggle with the operator flag + cutover allowlist.
// securityFocus resolves the same way — true ONLY when the manifest explicitly set review.security_focus: true.
Expand All @@ -2473,7 +2482,7 @@ export function resolveReviewPromptOverrides(manifest: FocusManifest | null): {
// (never touches the AI prompt) and only needs the unified-comment convergence feature to be on.
// findingCategories resolves the same way (#1958) — like suggestions, the caller further ANDs it with the
// already-resolved inlineComments gate, since a category has nothing to categorize without an inline finding.
return { profile: manifest?.review.profile ?? null, tone: manifest?.review.tone ?? null, securityFocus: manifest?.review.securityFocus === true, inlineComments: manifest?.review.inlineComments === true, suggestions: manifest?.review.suggestions === true, changedFilesSummary: manifest?.review.changedFilesSummary === true, effortScore: manifest?.review.effortScore === true, findingCategories: manifest?.review.findingCategories === true, minFindingSeverity: manifest?.review.minFindingSeverity ?? null, maxFindings: manifest?.review.maxFindings ?? { ...EMPTY_MAX_FINDINGS_CONFIG }, pathInstructions: manifest?.review.pathInstructions ?? [], instructions: manifest?.review.instructions ?? null, excludePaths: manifest?.review.excludePaths ?? [], pathFilters: manifest?.review.pathFilters ?? [], selfHostAiModel: resolveReviewSelfHostAiModel(manifest) };
return { profile: manifest?.review.profile ?? null, tone: manifest?.review.tone ?? null, securityFocus: manifest?.review.securityFocus === true, inlineComments: manifest?.review.inlineComments === true, suggestions: manifest?.review.suggestions === true, changedFilesSummary: manifest?.review.changedFilesSummary === true, effortScore: manifest?.review.effortScore === true, autoMergeSummary: manifest?.review.autoMergeSummary === true, findingCategories: manifest?.review.findingCategories === true, minFindingSeverity: manifest?.review.minFindingSeverity ?? null, maxFindings: manifest?.review.maxFindings ?? { ...EMPTY_MAX_FINDINGS_CONFIG }, pathInstructions: manifest?.review.pathInstructions ?? [], instructions: manifest?.review.instructions ?? null, excludePaths: manifest?.review.excludePaths ?? [], pathFilters: manifest?.review.pathFilters ?? [], selfHostAiModel: resolveReviewSelfHostAiModel(manifest) };
}

/** Resolve `review.pre_merge_checks` from a possibly-null manifest (null = load failure ⇒ no checks). Centralized
Expand Down
Loading