From 9636fad41cf9c20479b4dd96445154af02aad2e6 Mon Sep 17 00:00:00 2001 From: jony376 Date: Mon, 6 Jul 2026 03:39:08 -0700 Subject: [PATCH] feat(review): add review.auto_merge_summary read-only conditions table (#2051) Wire the manifest knob through focus-manifest parse/serialize, the unified-comment bridge collapsible, and the processor publish path with unit + queue integration tests. Co-authored-by: Cursor --- .gittensory.yml.example | 2 + config/examples/gittensory.full.yml | 2 + src/queue/processors.ts | 3 + src/review/unified-comment-bridge.ts | 58 ++++++- src/signals/focus-manifest.ts | 19 ++- .../auto-merge-summary-collapsible.test.ts | 97 +++++++++++ test/unit/focus-manifest.test.ts | 24 ++- test/unit/queue.test.ts | 161 ++++++++++++++++++ test/unit/signals-coverage.test.ts | 2 +- 9 files changed, 358 insertions(+), 10 deletions(-) create mode 100644 test/unit/auto-merge-summary-collapsible.test.ts diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 7e13a1bc34..159ccab49b 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -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). diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index b4753960fa..d128db7213 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -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). diff --git a/src/queue/processors.ts b/src/queue/processors.ts index c603cad9a6..4467127df9 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -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; @@ -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, @@ -9597,6 +9599,7 @@ async function maybePublishPrPublicSurface( ...(findingCategoriesEnabledForReview && aiReview?.inlineFindings?.length ? { findingCategories: aiReview.inlineFindings } : {}), + autoMergeSummary: autoMergeSummaryEnabledForReview, maxFindingsCaps: reviewConfig.maxFindings, }); } else { diff --git a/src/review/unified-comment-bridge.ts b/src/review/unified-comment-bridge.ts index 416bcbff67..f7dd3ae683 100644 --- a/src/review/unified-comment-bridge.ts +++ b/src/review/unified-comment-bridge.ts @@ -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 + @@ -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. */ @@ -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; diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index faf44b7f06..9d0292c0ae 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -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 @@ -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 }, @@ -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 }, @@ -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.`); @@ -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, @@ -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) || @@ -1775,6 +1782,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo suggestions, changedFilesSummary, effortScore, + autoMergeSummary, findingCategories, minFindingSeverity, maxFindings, @@ -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)) { @@ -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. @@ -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 diff --git a/test/unit/auto-merge-summary-collapsible.test.ts b/test/unit/auto-merge-summary-collapsible.test.ts new file mode 100644 index 0000000000..4bce9c6d5d --- /dev/null +++ b/test/unit/auto-merge-summary-collapsible.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + buildAutoMergeSummaryCollapsible, + buildUnifiedCommentBody, + deriveAutoMergeSummaryInput, +} from "../../src/review/unified-comment-bridge"; +import type { GateCheckEvaluation } from "../../src/rules/advisory"; +import type { PublicPrPanelSignalRow } from "../../src/signals/engine"; +import type { MergeReadiness } from "../../src/review/unified-comment"; + +function gate(over: Partial = {}): GateCheckEvaluation { + return { + enabled: true, + conclusion: "success", + title: "Gittensory Orb Review Agent passed", + summary: "No configured hard blocker was found.", + blockers: [], + warnings: [], + ...over, + }; +} + +const footer = "💰 Earn for open-source contributions. Checked by Gittensory."; + +const panelRowsAllPass: PublicPrPanelSignalRow[] = [ + { key: "linkedIssue", cells: ["Linked issue", "✅ Linked", "#42", "None."] }, + { key: "gateResult", cells: ["Gate result", "✅ Passing", "No configured blocker found.", "No action."] }, +]; + +const mergeReadinessPass: MergeReadiness = { ciState: "passed", mergeStateLabel: "clean" }; + +describe("deriveAutoMergeSummaryInput / buildAutoMergeSummaryCollapsible (#2051)", () => { + it("marks all four conditions pass when signals are green", () => { + expect( + deriveAutoMergeSummaryInput({ + mergeReadiness: mergeReadinessPass, + gateConclusion: "success", + panelRows: panelRowsAllPass, + }), + ).toEqual({ ciGreen: true, gatePassing: true, mergeableClean: true, linkedIssueOk: true }); + const c = buildAutoMergeSummaryCollapsible({ + ciGreen: true, + gatePassing: true, + mergeableClean: true, + linkedIssueOk: true, + }); + expect(c.title).toBe("Auto-merge conditions"); + expect(c.body).toContain("✅ pass"); + expect(c.body).not.toContain("❌ fail"); + expect(c.body).toContain("Read-only"); + }); + + it("marks failures for red CI, non-success gate, dirty merge state, and missing linked issue", () => { + expect( + deriveAutoMergeSummaryInput({ + mergeReadiness: { ciState: "failed", mergeStateLabel: "dirty" }, + gateConclusion: "failure", + panelRows: [{ key: "linkedIssue", cells: ["Linked issue", "❌ Missing", "None", "Link one."] }], + }), + ).toEqual({ ciGreen: false, gatePassing: false, mergeableClean: false, linkedIssueOk: false }); + const c = buildAutoMergeSummaryCollapsible({ + ciGreen: false, + gatePassing: false, + mergeableClean: false, + linkedIssueOk: false, + }); + expect(c.body.match(/❌ fail/g)?.length).toBe(4); + }); + + it("treats absent merge state and linked-issue row as failing", () => { + expect( + deriveAutoMergeSummaryInput({ + gateConclusion: "success", + panelRows: [], + }), + ).toEqual({ ciGreen: false, gatePassing: true, mergeableClean: false, linkedIssueOk: false }); + }); +}); + +describe("buildUnifiedCommentBody auto_merge_summary wiring (#2051)", () => { + it("renders the Auto-merge conditions section when enabled and omits it otherwise", () => { + const baseArgs = { + gate: gate(), + aiReview: { notes: "Clean change." }, + panelRows: panelRowsAllPass, + readinessTotal: 88, + changedFiles: 1, + footerMarkdown: footer, + mergeReadiness: mergeReadinessPass, + }; + const withSummary = buildUnifiedCommentBody({ ...baseArgs, autoMergeSummary: true }); + expect(withSummary).toContain("Auto-merge conditions"); + expect(withSummary).toContain("| CI green | ✅ pass |"); + const withoutSummary = buildUnifiedCommentBody(baseArgs); + expect(withoutSummary).not.toContain("Auto-merge conditions"); + }); +}); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 984c848f18..2e7afa0ab8 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -355,6 +355,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => { suggestions: "suggestions:", changedFilesSummary: "changed_files_summary:", effortScore: "effort_score:", + autoMergeSummary: "auto_merge_summary:", findingCategories: "finding_categories:", minFindingSeverity: "min_finding_severity:", maxFindings: "max_findings:", @@ -768,7 +769,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, effortScore: null, findingCategories: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: 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 }, + 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: { blockers: null, nits: 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 }, @@ -2873,9 +2874,9 @@ describe("resolveReviewPathInstructions (#review-path-instructions)", () => { it("resolveReviewPromptOverrides: non-null manifest passes the config through; null manifest → defaults", () => { const manifest = parseFocusManifest({ review: { profile: "chill", security_focus: true, inline_comments: true, suggestions: true, changed_files_summary: true, effort_score: true, finding_categories: true, path_instructions: [{ path: "src/**", instructions: "be strict" }], instructions: "Follow our async-error conventions.", exclude_paths: ["**/*.lock"], path_filters: ["src/**", "!src/generated/**"] } }); - expect(resolveReviewPromptOverrides(manifest)).toEqual({ profile: "chill", tone: null, securityFocus: true, inlineComments: true, suggestions: true, changedFilesSummary: true, effortScore: true, findingCategories: true, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, pathInstructions: [{ path: "src/**", instructions: "be strict" }], instructions: "Follow our async-error conventions.", excludePaths: ["**/*.lock"], pathFilters: ["src/**", "!src/generated/**"], selfHostAiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } }); + expect(resolveReviewPromptOverrides(manifest)).toEqual({ profile: "chill", tone: null, securityFocus: true, inlineComments: true, suggestions: true, changedFilesSummary: true, effortScore: true, autoMergeSummary: false, findingCategories: true, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, pathInstructions: [{ path: "src/**", instructions: "be strict" }], instructions: "Follow our async-error conventions.", excludePaths: ["**/*.lock"], pathFilters: ["src/**", "!src/generated/**"], selfHostAiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } }); // A null manifest (load failure) yields the byte-identical defaults; inline comments + suggestions + changed-files summary + effort score + finding categories + security focus default OFF. - expect(resolveReviewPromptOverrides(null)).toEqual({ profile: null, tone: null, securityFocus: false, inlineComments: false, suggestions: false, changedFilesSummary: false, effortScore: false, findingCategories: false, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], selfHostAiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } }); + expect(resolveReviewPromptOverrides(null)).toEqual({ profile: null, tone: null, securityFocus: false, inlineComments: false, suggestions: false, changedFilesSummary: false, effortScore: false, autoMergeSummary: false, findingCategories: false, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], selfHostAiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } }); // An explicit false / absent toggle both resolve to the strict-boolean false. expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { inline_comments: false } })).inlineComments).toBe(false); expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { profile: "chill" } })).inlineComments).toBe(false); @@ -2885,6 +2886,8 @@ describe("resolveReviewPathInstructions (#review-path-instructions)", () => { expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { profile: "chill" } })).changedFilesSummary).toBe(false); expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { effort_score: false } })).effortScore).toBe(false); expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { profile: "chill" } })).effortScore).toBe(false); + expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { auto_merge_summary: false } })).autoMergeSummary).toBe(false); + expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { profile: "chill" } })).autoMergeSummary).toBe(false); expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { finding_categories: false } })).findingCategories).toBe(false); expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { profile: "chill" } })).findingCategories).toBe(false); expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { security_focus: false } })).securityFocus).toBe(false); @@ -2959,6 +2962,21 @@ describe("resolveReviewPathInstructions (#review-path-instructions)", () => { expect(bad.warnings.some((w) => /review\.effort_score.*must be a boolean/.test(w))).toBe(true); }); + it("parses review.auto_merge_summary (default OFF), marks present, round-trips, and warns on a non-boolean (#2051)", () => { + expect(parseFocusManifest({ review: { auto_merge_summary: true } }).review.autoMergeSummary).toBe(true); + const on = parseFocusManifest({ review: { auto_merge_summary: true } }); + expect(on.review.present).toBe(true); + expect(parseFocusManifest({ review: reviewConfigToJson(on.review) }).review).toEqual(on.review); + const off = parseFocusManifest({ review: { auto_merge_summary: false } }); + expect(off.review.autoMergeSummary).toBe(false); + expect(off.review.present).toBe(true); + expect(parseFocusManifest({ review: {} }).review.autoMergeSummary).toBeNull(); + const bad = parseFocusManifest({ review: { auto_merge_summary: "yes" } }); + expect(bad.review.autoMergeSummary).toBeNull(); + expect(bad.warnings.some((w) => /review\.auto_merge_summary.*must be a boolean/.test(w))).toBe(true); + expect(resolveReviewPromptOverrides(on).autoMergeSummary).toBe(true); + }); + it("parses review.finding_categories (default OFF), marks present, round-trips, and warns on a non-boolean (#1958)", () => { expect(parseFocusManifest({ review: { finding_categories: true } }).review.findingCategories).toBe(true); const on = parseFocusManifest({ review: { finding_categories: true } }); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 798423a3cf..788d740a4c 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -16850,6 +16850,167 @@ describe("queue processors", () => { } }); + // #2051: with the unified comment on AND `.gittensory.yml` opting into `review.auto_merge_summary`, the rendered + // comment gains the read-only auto-merge conditions table from readiness facts already on the comment path. + it("renders the auto-merge conditions table when review.auto_merge_summary is on in .gittensory.yml (#2051)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_UNIFIED_COMMENT: "1" }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "detected_contributors_only", + publicAudienceMode: "gittensor_only", + publicSignalLevel: "standard", + publicSurface: "comment_and_label", + autoLabelEnabled: false, + checkRunMode: "off", + checkRunDetailLevel: "minimal", + gateCheckMode: "enabled", + backfillEnabled: true, + privateTrustEnabled: true, + autonomy: { update_branch: "auto" }, + }); + let postedBody = ""; + const calls = { comments: 0, gateChecks: 0 }; + let gateFinalized = false; + let failedPostGateMint = false; + const liveCiSpy = vi + .spyOn(backfillModule, "fetchLiveCiAggregatePreferGraphQl") + .mockRejectedValueOnce(new Error("transient CI read failed")) + .mockResolvedValue({ + ciState: "passed", + hasPending: false, + hasVisiblePending: false, + hasMissingRequiredContext: false, + failingDetails: [], + nonRequiredFailingDetails: [], + ciCompletenessWarning: null, + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") { + return Response.json([ + { + uid: 7, + githubUsername: "oktofeesh1", + githubId: "123", + totalPrs: 4, + totalMergedPrs: 3, + totalOpenPrs: 1, + totalClosedPrs: 0, + totalOpenIssues: 0, + totalClosedIssues: 0, + totalSolvedIssues: 0, + totalValidSolvedIssues: 0, + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + hotkey: "must-not-leak", + }, + ]); + } + if (url === "https://api.gittensor.io/miners/123") { + return Response.json({ + repositories: [ + { + repositoryFullName: "JSONbored/gittensory", + totalPrs: "4", + totalMergedPrs: "3", + totalOpenPrs: "1", + totalClosedPrs: "0", + totalOpenIssues: "0", + totalClosedIssues: "0", + isEligible: true, + credibility: "1.000000", + }, + ], + }); + } + if (url === "https://api.gittensor.io/miners/123/prs") return Response.json([]); + if (url === "https://mirror.gittensor.io/api/v1/miners/123/issues") return Response.json({ issues: [] }); + if (url.endsWith("/users/oktofeesh1")) return Response.json({ login: "oktofeesh1", public_repos: 2, followers: 1 }); + if (url.includes("/users/oktofeesh1/repos")) return Response.json([{ language: "TypeScript" }]); + if (url === "https://raw.githubusercontent.com/JSONbored/gittensory/HEAD/.gittensory.yml") { + return new Response("review:\n auto_merge_summary: true\n"); + } + if (url.includes("/access_tokens")) { + if (gateFinalized && !failedPostGateMint) { + failedPostGateMint = true; + return new Response("mint failed", { status: 500 }); + } + return Response.json({ token: "installation-token", expires_at: "2026-05-28T00:04:00.000Z" }); + } + if (url.includes("/pulls/3/files")) + return Response.json([{ filename: "src/cache.ts", additions: 5, deletions: 1, status: "modified" }]); + if (/\/pulls\/3(?:\?|$)/.test(url)) return Response.json({ number: 3, mergeable_state: "clean" }); + if (url.includes("/check-runs") && method === "GET") return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") { + calls.gateChecks += 1; + const body = JSON.parse(String(init?.body ?? "{}")) as { status?: string; conclusion?: string }; + if (body.status !== "in_progress" || body.conclusion) { + gateFinalized = true; + clearInstallationTokenCacheForTest(); + } + return Response.json({ id: 901 }, { status: 201 }); + } + if (url.includes("/check-runs/901") && method === "PATCH") { + calls.gateChecks += 1; + gateFinalized = true; + clearInstallationTokenCacheForTest(); + return Response.json({ id: 901 }); + } + if (url.includes("/issues/3/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/3/comments") && method === "POST") { + calls.comments += 1; + postedBody = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); + return Response.json({ id: 1, html_url: "https://github.com/comment/1" }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + try { + await processJob(env, { + type: "github-webhook", + deliveryId: "pr-unified-comment-auto-merge-summary", + eventName: "pull_request", + payload: { + action: "synchronize", + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read", issues: "write", checks: "write" }, + events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], + }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { + number: 3, + title: "Fix webhook duplicate delivery again", + state: "open", + user: { login: "oktofeesh1" }, + head: { sha: "unifiedautomerge" }, + labels: [{ name: "bug" }], + body: "Fixes #1\n\nValidation: npm test", + }, + }, + }); + + expect(calls.comments).toBe(2); + expect(postedBody).toContain(""); + expect(postedBody).toContain("Auto-merge conditions"); + expect(postedBody).toContain("| Gate passing | ✅ pass |"); + } finally { + liveCiSpy.mockRestore(); + } + }); + // #1955: the review-effort minutes persisted onto the public-stats audit event (independent of // review.effort_score, which only gates the unified-comment CHIP) must never block the publish itself when the // estimator throws — the publish still completes and simply omits `reviewEffortMinutes` from the event metadata diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 4203d5d187..126a469858 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, effortScore: null, findingCategories: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { skipDrafts: null, ignoreAuthors: [], ignoreTitleKeywords: [], skipLabels: [], skipDocsOnly: null, maxAddedLines: 0, maxFiles: 0, baseBranches: [], autoPauseAfterReviewedCommits: null }, labelingRules: [], aiModel: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null }, visual: { preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: [], gif: false }, linkedIssueSatisfaction: 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, effortScore: null, autoMergeSummary: null, findingCategories: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { skipDrafts: null, ignoreAuthors: [], ignoreTitleKeywords: [], skipLabels: [], skipDocsOnly: null, maxAddedLines: 0, maxFiles: 0, baseBranches: [], autoPauseAfterReviewedCommits: null }, labelingRules: [], aiModel: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null }, visual: { preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: [], gif: false }, 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