diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 9253c5fca6..d3734f63e4 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -9756,6 +9756,7 @@ async function maybePublishPrPublicSurface( let suggestionsEnabledForReview = false; let changedFilesSummaryEnabledForReview = false; let effortScoreEnabledForReview = false; + let autoMergeSummaryEnabledForReview = false; let reviewMemoryEnabledForReview = false; let findingCategoriesEnabledForReview = false; let fixHandoffEnabledForReview = false; @@ -10340,6 +10341,7 @@ async function maybePublishPrPublicSurface( const deterministicReviewOverrides = resolveReviewPromptOverrides(reviewManifestForAutoReview); changedFilesSummaryEnabledForReview = deterministicReviewOverrides.changedFilesSummary; effortScoreEnabledForReview = deterministicReviewOverrides.effortScore; + autoMergeSummaryEnabledForReview = deterministicReviewOverrides.autoMergeSummary; minFindingSeverityForReview = deterministicReviewOverrides.minFindingSeverity; inlineCommentsPerCategoryForReview = deterministicReviewOverrides.inlineCommentsPerCategory; // review.memory (#2179, part of #1964): deterministic, no-AI -- resolved the same unconditional way as @@ -11831,6 +11833,21 @@ async function maybePublishPrPublicSurface( ...(aiReview !== undefined ? { aiReview } : {}), advisoryFindings: advisory.findings, ...(linkedIssueSatisfaction !== null ? { linkedIssueSatisfaction } : {}), + // review.auto_merge_summary (#2051/#4147): deterministic, no-AI — reuses the SAME ciState/ + // mergeStateLabel/gate/linkedIssues facts this pass already resolved for mergeReadiness and the gate + // verdict above, no extra fetch. gatePassing mirrors the gate's own "no hard blocker" definition + // (conclusion === "success"); linkedIssueValid mirrors missing_linked_issue's own "has at least one + // linked issue reference" check (pr.linkedIssues.length > 0). + ...(autoMergeSummaryEnabledForReview + ? { + autoMergeSummary: { + ciGreen: ciState === "passed", + gatePassing: renderedGate.conclusion === "success", + mergeableClean: mergeStateLabel === "clean", + linkedIssueValid: pr.linkedIssues.length > 0, + }, + } + : {}), panelRows: rows, ...(reviewConfig?.fields !== undefined ? { reviewFields: reviewConfig.fields } diff --git a/src/review/unified-comment-bridge.ts b/src/review/unified-comment-bridge.ts index f039cd7701..57e27be579 100644 --- a/src/review/unified-comment-bridge.ts +++ b/src/review/unified-comment-bridge.ts @@ -34,8 +34,10 @@ import { githubPrFileDiffUrl } from "./changed-files-diff-link"; import { classifyFindingCategory, FINDING_CATEGORIES, type FindingCategory } from "./finding-category-classify"; import type { FixHandoffBlock } from "./fix-handoff-render"; import { + buildAutoMergeSummaryCollapsible, buildUnifiedReviewInput, renderUnifiedReviewComment, + type AutoMergeSummarySignals, type DualReviewNote, type MergeReadiness, type ReviewNotes, @@ -346,6 +348,12 @@ 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 readiness" conditions table (review.auto_merge_summary port, #2051/#4147). When + * present, an "Auto-merge readiness" collapsible listing which auto-merge conditions currently pass/fail + * is appended — informational only, never a decision or a promise to merge (the gate/status chip above it + * remains the actual verdict). No AI, no network. Default OFF (the processor passes this only when the + * manifest opts in — see `resolveReviewPromptOverrides`'s `autoMergeSummary`). */ + autoMergeSummary?: AutoMergeSummarySignals | undefined; /** Display-only caps from `review.max_findings` (#2049). */ maxFindingsCaps?: { blockers: number | null; nits: number | null } | undefined; /** `review.comment_verbosity` port (#2047): how much collapsible detail renders — `quiet` drops the Nits @@ -762,6 +770,13 @@ export function buildUnifiedCommentBody(args: UnifiedCommentBridgeArgs): string args.manifestWarnings && args.manifestWarnings.length > 0 ? buildManifestValidationCollapsible(args.manifestWarnings) : null; const withManifestValidation = manifestValidationCollapsible !== null ? [manifestValidationCollapsible, ...(args.extraCollapsibles ?? [])] : args.extraCollapsibles; + // review.auto_merge_summary port (#2051/#4147): when the manifest opts in, the processor hands us the + // already-computed auto-merge condition signals here; append the read-only "Auto-merge readiness" + // collapsible right after manifest validation (decision-relevant, so ahead of the structural/visual + // summaries below). Flag-OFF (the processor passes undefined) ⇒ extraCollapsibles is unchanged. + const autoMergeSummaryCollapsible = args.autoMergeSummary !== undefined ? buildAutoMergeSummaryCollapsible(args.autoMergeSummary) : null; + const withAutoMergeSummary = + autoMergeSummaryCollapsible !== null ? [...(withManifestValidation ?? []), autoMergeSummaryCollapsible] : withManifestValidation; // review.changed_files_summary port: when the manifest opts in, the processor hands us every changed file's // path + deltas here; append the grouped "Changed files" collapsible ahead of the visual preview (structure // before pixels). Flag-OFF (the processor passes undefined) ⇒ extraCollapsibles is unchanged. (#1957) @@ -770,7 +785,7 @@ export function buildUnifiedCommentBody(args: UnifiedCommentBridgeArgs): string ? buildChangedFilesSummaryCollapsible(args.changedFilesSummary, args.changedFilesSummaryContext) : null; const withChangedFiles = - changedFilesCollapsible !== null ? [...(withManifestValidation ?? []), changedFilesCollapsible] : withManifestValidation; + changedFilesCollapsible !== null ? [...(withAutoMergeSummary ?? []), changedFilesCollapsible] : withAutoMergeSummary; // review.finding_categories port: when the manifest opts in, the processor hands us this review's line-anchored // AI findings here; append the "Finding categories" collapsible right after Changed files (both are structural // review-shape summaries, ahead of the visual preview). Flag-OFF (the processor passes undefined) ⇒ diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index be7118e998..dbc5ab4214 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -263,7 +263,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; inlineCommentsPerCategory: number | null; minFindingSeverity: ReviewFindingSeverity | null; maxFindings: MaxFindingsConfig; commentVerbosity: CommentVerbosity | null; e2eTestDelivery: E2eTestDeliveryMode | 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; autoMergeSummary: boolean; impactMap: boolean; cultureProfile: boolean; findingCategories: boolean; inlineCommentsPerCategory: number | null; minFindingSeverity: ReviewFindingSeverity | null; maxFindings: MaxFindingsConfig; commentVerbosity: CommentVerbosity | null; e2eTestDelivery: E2eTestDeliveryMode | 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. `shouldRequestInlineFindings` (#4099) only ever checks `=== true`, so null // and false are functionally identical to it — collapsing here (matching every sibling field below) is simpler @@ -286,7 +286,11 @@ 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, inlineCommentsPerCategory: manifest?.review.inlineCommentsPerCategory ?? null, minFindingSeverity: manifest?.review.minFindingSeverity ?? null, maxFindings: manifest?.review.maxFindings ?? { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: manifest?.review.commentVerbosity ?? null, e2eTestDelivery: manifest?.review.e2eTestDelivery ?? 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/#4147) — like changedFilesSummary/effortScore, it is + // deterministic/display-only (never touches the AI prompt) and only needs the unified-comment convergence + // feature itself to be on; the caller supplies the already-computed AutoMergeSummarySignals unconditionally + // once this is true (no separate global kill-switch, matching changedFilesSummary/effortScore's shape). + 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, impactMap: manifest?.review.impactMap === true, cultureProfile: manifest?.review.cultureProfile === true, findingCategories: manifest?.review.findingCategories === true, inlineCommentsPerCategory: manifest?.review.inlineCommentsPerCategory ?? null, minFindingSeverity: manifest?.review.minFindingSeverity ?? null, maxFindings: manifest?.review.maxFindings ?? { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: manifest?.review.commentVerbosity ?? null, e2eTestDelivery: manifest?.review.e2eTestDelivery ?? null, pathInstructions: manifest?.review.pathInstructions ?? [], instructions: manifest?.review.instructions ?? null, excludePaths: manifest?.review.excludePaths ?? [], pathFilters: manifest?.review.pathFilters ?? [], selfHostAiModel: resolveReviewSelfHostAiModel(manifest) }; } /** Resolve `review.memory` (#2179, config slice of #1964) from a possibly-null manifest (null = load failure ⇒ diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index e5fd6fbc96..9065ff7c8a 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -3388,14 +3388,14 @@ 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, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: "detailed", e2eTestDelivery: 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 } }); + const manifest = parseFocusManifest({ review: { profile: "chill", security_focus: true, inline_comments: true, suggestions: true, changed_files_summary: true, effort_score: true, auto_merge_summary: 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, autoMergeSummary: true, impactMap: true, cultureProfile: true, findingCategories: true, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: "detailed", e2eTestDelivery: 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 + impact map + culture profile + finding categories + security focus - // all default OFF (strict false) — inlineComments collapses the same way as every sibling flag on this - // object (#4099: shouldRequestInlineFindings only ever checks `=== true`, so null/false/absent are - // functionally identical to it; no tri-state needed here). - expect(resolveReviewPromptOverrides(null)).toEqual({ profile: null, tone: null, securityFocus: false, inlineComments: false, suggestions: false, changedFilesSummary: false, effortScore: false, impactMap: false, cultureProfile: false, findingCategories: false, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, e2eTestDelivery: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], selfHostAiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG } }); + // changed-files summary + effort score + auto-merge summary + impact map + culture profile + finding + // categories + security focus all default OFF (strict false) — inlineComments collapses the same way as + // every sibling flag on this object (#4099: shouldRequestInlineFindings only ever checks `=== true`, so + // null/false/absent are functionally identical to it; no tri-state needed here). + expect(resolveReviewPromptOverrides(null)).toEqual({ profile: null, tone: null, securityFocus: false, inlineComments: false, suggestions: false, changedFilesSummary: false, effortScore: false, autoMergeSummary: false, impactMap: false, cultureProfile: false, findingCategories: false, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, e2eTestDelivery: 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); @@ -3405,6 +3405,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); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index f5654cae01..31abe4ae03 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -20130,6 +20130,179 @@ describe("queue processors", () => { } }); + // #2051/#4147: with the unified comment on AND `.gittensory.yml` opting into `review.auto_merge_summary`, + // the rendered comment gains the deterministic, no-AI "Auto-merge readiness" collapsible — computed from the + // SAME live CI state, gate conclusion, mergeable_state, and linked-issue facts this pass already resolves + // for the readiness chip and gate verdict, no extra fetch. Mirrors the effort_score test above but asserts + // the auto-merge-readiness table's presence + condition marks instead. + it("renders the Auto-merge readiness collapsible when review.auto_merge_summary is on in .gittensory.yml", 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", reviewCheckMode: "required", + backfillEnabled: 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" }]); + // .gittensory.yml opts into the deterministic auto-merge summary — no AI involved. + 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: 10, deletions: 1, status: "modified" }]); + // mergeable_state: "clean" -> mergeableClean: true in the rendered table. + if (/\/pulls\/3(?:\?|$)/.test(url)) return Response.json({ number: 3, mergeable_state: "clean" }); + // Gate check-run — must succeed so `gateEvaluation` concludes "success" -> gatePassing: true. + 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: "unified789" }, + labels: [{ name: "bug" }], + // A linked issue (#1) is present -> linkedIssueValid: true in the rendered table. + body: "Fixes #1\n\nValidation: npm test", + }, + }, + }); + + expect(calls.comments).toBe(2); + expect(postedBody).toContain(""); + expect(postedBody).toContain("Auto-merge readiness"); + expect(postedBody).toContain("_Read-only snapshot of the current auto-merge conditions"); + // All four conditions pass with this fixture: CI green, gate passing, branch mergeable clean, valid + // linked issue. + expect(postedBody).toContain("| CI checks green | ✅ |"); + expect(postedBody).toContain("| Gate passing | ✅ |"); + expect(postedBody).toContain("| Branch mergeable (clean) | ✅ |"); + expect(postedBody).toContain("| Valid linked issue | ✅ |"); + } finally { + liveCiSpy.mockRestore(); + } + }); + // #2044: `.gittensory.yml` `review.tone` is folded into the AI reviewer's system prompt by // composeManifestReviewInstructions (src/signals/focus-manifest.ts), consumed by // src/queue/processors.ts's aiReviewCacheReadDecideAndRun. That composition is unit-tested in isolation diff --git a/test/unit/unified-comment-bridge.test.ts b/test/unit/unified-comment-bridge.test.ts index 162ff19701..d6279f4ef1 100644 --- a/test/unit/unified-comment-bridge.test.ts +++ b/test/unit/unified-comment-bridge.test.ts @@ -364,6 +364,31 @@ describe("buildUnifiedCommentBody", () => { expect(without).not.toContain("Linked issue satisfaction"); }); + it("renders the read-only auto-merge readiness collapsible when autoMergeSummary is present, and omits it otherwise (#2051/#4147)", () => { + const withSummary = buildUnifiedCommentBody({ + gate: gate(), + aiReview: { notes: "Clean change." }, + panelRows, + readinessTotal: 88, + changedFiles: 3, + footerMarkdown: footer, + autoMergeSummary: { ciGreen: true, gatePassing: true, mergeableClean: false, linkedIssueValid: true }, + }); + expect(withSummary).toContain("Auto-merge readiness"); + expect(withSummary).toContain("CI checks green"); + expect(withSummary).toContain("Branch mergeable (clean)"); + expect(withSummary).toContain("_Read-only snapshot of the current auto-merge conditions"); + const without = buildUnifiedCommentBody({ + gate: gate(), + aiReview: { notes: "Clean change." }, + panelRows, + readinessTotal: 88, + changedFiles: 3, + footerMarkdown: footer, + }); + expect(without).not.toContain("Auto-merge readiness"); + }); + it("forwards maxFindings caps into the rendered blocker/nit sections (#2049)", () => { const body = buildUnifiedCommentBody({ gate: gate({