diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 1668b4929f..dd36b0acf4 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). # Boundary-safe test-generation advisory (#1972). Bool | null. Default: null/false -- byte-identical (no # boundary scan runs at all). When true, a diff that touches a small, precise set of boundary-condition diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index f6061401c2..5c4e505e38 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). # Boundary-safe test-generation advisory (#1972). Bool | null. Default: null/false -- byte-identical (no # boundary scan runs at all). When true, a diff that touches a small, precise set of boundary-condition diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 1c6968eae6..c34c505fdf 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -7941,6 +7941,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; @@ -8453,6 +8454,7 @@ async function maybePublishPrPublicSurface( const deterministicReviewOverrides = resolveReviewPromptOverrides(reviewManifestForAutoReview); changedFilesSummaryEnabledForReview = deterministicReviewOverrides.changedFilesSummary; effortScoreEnabledForReview = deterministicReviewOverrides.effortScore; + autoMergeSummaryEnabledForReview = deterministicReviewOverrides.autoMergeSummary; minFindingSeverityForReview = deterministicReviewOverrides.minFindingSeverity; maybeAddRequiredAutoReviewSkipHold(env, { settings, @@ -9718,6 +9720,7 @@ async function maybePublishPrPublicSurface( : {}), maxFindingsCaps: reviewConfig.maxFindings, commentVerbosity: reviewConfig.commentVerbosity, + ...(autoMergeSummaryEnabledForReview ? { autoMergeSummary: true } : {}), }); } else { deterministicBody = buildPublicPrIntelligenceComment(commentArgs); diff --git a/src/review/unified-comment-bridge.ts b/src/review/unified-comment-bridge.ts index b44d58780c..c773a94e3d 100644 --- a/src/review/unified-comment-bridge.ts +++ b/src/review/unified-comment-bridge.ts @@ -339,6 +339,11 @@ export type UnifiedCommentBridgeArgs = { * this only when BOTH the operator's GITTENSORY_REVIEW_IMPACT_MAP flag and the per-repo manifest opt-in * are on — see `shouldComputeImpactMap`, `src/review/impact-map-wire.ts`). */ impactMap?: ImpactMapSummaryInput[] | undefined; + /** When true, the unified comment gains a read-only "Auto-merge conditions" collapsible (CI green, gate passing, + * mergeable-clean, linked issue) derived from readiness facts already on the comment path — display-only, never + * changes merge decisions. Default OFF (the processor passes this only when the manifest opts in — see + * `resolveReviewPromptOverrides`'s `autoMergeSummary`). (#2051) */ + autoMergeSummary?: boolean | undefined; /** The disposition holds this PR for owner review because its diff touches a hard-guardrail path — so an * otherwise-ready comment renders "held for review" instead of "safe to merge". (#guarded-hold-comment) */ heldForReview?: boolean | undefined; @@ -543,6 +548,47 @@ export function buildImpactMapCollapsible(entries: ImpactMapSummaryInput[]): Uni return { title: "Impact map", 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. */ @@ -661,10 +707,24 @@ export function buildUnifiedCommentBody(args: UnifiedCommentBridgeArgs): string const impactMapCollapsible = args.impactMap && args.impactMap.length > 0 ? buildImpactMapCollapsible(args.impactMap) : null; const withImpactMap = impactMapCollapsible !== null ? [...(withFindingCategories ?? []), impactMapCollapsible] : withFindingCategories; + // review.auto_merge_summary port (#2051): when the manifest opts in, append the read-only "Auto-merge conditions" + // collapsible right after Impact map (another structural, no-AI summary) and ahead of the visual preview. + const autoMergeCollapsible = + args.autoMergeSummary === true + ? buildAutoMergeSummaryCollapsible( + deriveAutoMergeSummaryInput({ + mergeReadiness: args.mergeReadiness, + gateConclusion: args.gate.conclusion, + panelRows: visibleRows, + }), + ) + : null; + const withAutoMergeSummary = + autoMergeCollapsible !== null ? [...(withImpactMap ?? []), autoMergeCollapsible] : withImpactMap; // 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 ? [...(withImpactMap ?? []), visualCollapsible] : withImpactMap; + 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 b6f12c83b2..65efcef900 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -388,6 +388,11 @@ export type FocusManifestReviewConfig = { * field only opts THIS repo in once the capability itself is enabled). null/false (default, absent) = no * section appended = byte-identical behavior. */ cultureProfile: 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 @@ -760,7 +765,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, testGeneration: null, impactMap: null, cultureProfile: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: 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, testGeneration: null, impactMap: null, cultureProfile: null, autoMergeSummary: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: 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 }, @@ -791,7 +796,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, testGeneration: null, impactMap: null, cultureProfile: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: 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, testGeneration: null, impactMap: null, cultureProfile: null, autoMergeSummary: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: 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 }, @@ -1766,7 +1771,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, testGeneration: null, impactMap: null, cultureProfile: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: 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 }; + 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, testGeneration: null, impactMap: null, cultureProfile: null, autoMergeSummary: null, findingCategories: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: 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.`); @@ -1809,6 +1814,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo const testGeneration = normalizeOptionalBoolean(r.test_generation, "review.test_generation", warnings); const impactMap = normalizeOptionalBoolean(r.impact_map, "review.impact_map", warnings); const cultureProfile = normalizeOptionalBoolean(r.culture_profile, "review.culture_profile", 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, @@ -1842,6 +1848,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo testGeneration !== null || impactMap !== null || cultureProfile !== null || + autoMergeSummary !== null || findingCategories !== null || minFindingSeverity !== null || maxFindingsPresent(maxFindings) || @@ -1876,6 +1883,7 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo effortScore, impactMap, cultureProfile, + autoMergeSummary, findingCategories, minFindingSeverity, maxFindings, @@ -2339,6 +2347,7 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue if (review.testGeneration !== null) out.test_generation = review.testGeneration; if (review.impactMap !== null) out.impact_map = review.impactMap; if (review.cultureProfile !== null) out.culture_profile = review.cultureProfile; + 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)) { @@ -2567,7 +2576,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; impactMap: boolean; cultureProfile: boolean; findingCategories: boolean; minFindingSeverity: ReviewFindingSeverity | null; maxFindings: MaxFindingsConfig; commentVerbosity: CommentVerbosity | null; 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; impactMap: boolean; cultureProfile: boolean; autoMergeSummary: boolean; findingCategories: boolean; minFindingSeverity: ReviewFindingSeverity | null; maxFindings: MaxFindingsConfig; commentVerbosity: CommentVerbosity | null; 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. @@ -2588,7 +2597,8 @@ export function resolveReviewPromptOverrides(manifest: FocusManifest | null): { // cultureProfile resolves the same way (#2995) — true ONLY when the manifest explicitly set // review.culture_profile: true. The caller ANDs this per-repo opt-in with the GITTENSORY_REVIEW_CULTURE_PROFILE // global kill-switch (mirrors how RAG/reputation/grounding compose a global flag with a per-repo override). - 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, impactMap: manifest?.review.impactMap === true, cultureProfile: manifest?.review.cultureProfile === true, findingCategories: manifest?.review.findingCategories === true, minFindingSeverity: manifest?.review.minFindingSeverity ?? null, maxFindings: manifest?.review.maxFindings ?? { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: manifest?.review.commentVerbosity ?? null, pathInstructions: manifest?.review.pathInstructions ?? [], instructions: manifest?.review.instructions ?? null, excludePaths: manifest?.review.excludePaths ?? [], pathFilters: manifest?.review.pathFilters ?? [], selfHostAiModel: resolveReviewSelfHostAiModel(manifest) }; + // autoMergeSummary resolves the same way (#2051) — display-only unified-comment section; no global kill-switch. + 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, impactMap: manifest?.review.impactMap === true, cultureProfile: manifest?.review.cultureProfile === true, autoMergeSummary: manifest?.review.autoMergeSummary === true, findingCategories: manifest?.review.findingCategories === true, minFindingSeverity: manifest?.review.minFindingSeverity ?? null, maxFindings: manifest?.review.maxFindings ?? { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: manifest?.review.commentVerbosity ?? null, pathInstructions: manifest?.review.pathInstructions ?? [], instructions: manifest?.review.instructions ?? null, excludePaths: manifest?.review.excludePaths ?? [], pathFilters: manifest?.review.pathFilters ?? [], selfHostAiModel: resolveReviewSelfHostAiModel(manifest) }; } /** Resolve `review.test_generation` (#2189, config slice of #1972) from a possibly-null manifest (null = load 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..d6f98b4ef1 --- /dev/null +++ b/test/unit/auto-merge-summary-collapsible.test.ts @@ -0,0 +1,162 @@ +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 }); + }); + + it("marks linked issue failing when the row exists but is not green", () => { + expect( + deriveAutoMergeSummaryInput({ + mergeReadiness: mergeReadinessPass, + gateConclusion: "success", + panelRows: [{ key: "linkedIssue", cells: ["Linked issue", "⚠️ Preferred", "#42", "Link one."] }], + }), + ).toEqual({ ciGreen: true, gatePassing: true, mergeableClean: true, linkedIssueOk: false }); + }); + + it("treats missing mergeStateLabel as not mergeable-clean", () => { + expect( + deriveAutoMergeSummaryInput({ + mergeReadiness: { ciState: "passed" }, + gateConclusion: "success", + panelRows: panelRowsAllPass, + }), + ).toEqual({ ciGreen: true, gatePassing: true, mergeableClean: false, linkedIssueOk: true }); + }); +}); + +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"); + expect(buildUnifiedCommentBody({ ...baseArgs, autoMergeSummary: false })).not.toContain("Auto-merge conditions"); + }); + + it("derives auto-merge conditions without mergeReadiness when the caller omits it (#2051)", () => { + const body = buildUnifiedCommentBody({ + gate: gate(), + aiReview: { notes: "Clean change." }, + panelRows: panelRowsAllPass, + readinessTotal: 88, + changedFiles: 1, + footerMarkdown: footer, + autoMergeSummary: true, + }); + expect(body).toContain("Auto-merge conditions"); + expect(body).toContain("| CI green | ❌ fail |"); + }); + + it("coexists with the Visual preview section when both are enabled (#2051)", () => { + const body = buildUnifiedCommentBody({ + gate: gate(), + aiReview: { notes: "Clean change." }, + panelRows: panelRowsAllPass, + readinessTotal: 88, + changedFiles: 1, + footerMarkdown: footer, + mergeReadiness: mergeReadinessPass, + autoMergeSummary: true, + beforeAfter: [{ path: "/", afterUrl: "https://api.example.dev/gittensory/shot?key=gittensory/shots/x.png" }], + }); + expect(body).toContain("Auto-merge conditions"); + expect(body).toContain("Visual preview"); + }); + + it("renders Visual preview without auto-merge when only beforeAfter is set (#2051)", () => { + const body = buildUnifiedCommentBody({ + gate: gate(), + aiReview: { notes: "Clean change." }, + panelRows: panelRowsAllPass, + readinessTotal: 88, + changedFiles: 1, + footerMarkdown: footer, + beforeAfter: [{ path: "/", afterUrl: "https://api.example.dev/gittensory/shot?key=gittensory/shots/x.png" }], + }); + expect(body).toContain("Visual preview"); + expect(body).not.toContain("Auto-merge conditions"); + }); +}); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index e763bcc69e..248c8eb06a 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)", () => { testGeneration: "test_generation:", impactMap: "impact_map:", cultureProfile: "culture_profile:", + autoMergeSummary: "auto_merge_summary:", findingCategories: "finding_categories:", minFindingSeverity: "min_finding_severity:", maxFindings: "max_findings:", @@ -784,7 +785,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, testGeneration: null, impactMap: null, cultureProfile: null, findingCategories: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: 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, testGeneration: null, impactMap: null, cultureProfile: null, autoMergeSummary: null, findingCategories: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: 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 }, @@ -2949,9 +2950,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, impact_map: true, culture_profile: true, finding_categories: true, comment_verbosity: "detailed", 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, impactMap: true, cultureProfile: true, findingCategories: true, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: "detailed", 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 + impact map + culture profile + finding categories + security focus default OFF. - expect(resolveReviewPromptOverrides(null)).toEqual({ profile: null, tone: null, securityFocus: false, inlineComments: false, suggestions: false, changedFilesSummary: false, effortScore: false, impactMap: false, cultureProfile: false, findingCategories: false, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], 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, impactMap: true, cultureProfile: true, autoMergeSummary: false, findingCategories: true, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: "detailed", 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 + impact map + culture profile + auto-merge summary + finding categories + security focus default OFF. + expect(resolveReviewPromptOverrides(null)).toEqual({ profile: null, tone: null, securityFocus: false, inlineComments: false, suggestions: false, changedFilesSummary: false, effortScore: false, impactMap: false, cultureProfile: false, autoMergeSummary: false, findingCategories: false, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: 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); @@ -2961,6 +2962,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: { impact_map: false } })).impactMap).toBe(false); expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { profile: "chill" } })).impactMap).toBe(false); expect(resolveReviewPromptOverrides(parseFocusManifest({ review: { finding_categories: false } })).findingCategories).toBe(false); @@ -3037,6 +3040,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.test_generation (default OFF), marks present, round-trips, and warns on a non-boolean (#1972)", () => { expect(parseFocusManifest({ review: { test_generation: true } }).review.testGeneration).toBe(true); const on = parseFocusManifest({ review: { test_generation: true } }); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 702d222aa4..261d572d1c 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -17019,6 +17019,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 896d4e5f58..0e29f3cbdd 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, testGeneration: null, impactMap: null, cultureProfile: null, findingCategories: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: 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, testGeneration: null, impactMap: null, cultureProfile: null, autoMergeSummary: null, findingCategories: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: 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