diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 807d9d799d..0ebf20f80d 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -139,6 +139,7 @@ import { buildPublicPrIntelligenceComment, buildPublicPrPanelSignalRows, buildPublicReadinessScore, + buildPublicSafeCollapsibles, buildQueueHealth, buildRoleContext, detectGittensorContributor, @@ -147,6 +148,7 @@ import { type ContributorProfile, } from "../signals/engine"; import { buildClosedUnifiedCommentBody, buildUnifiedCommentBody, isUnifiedReviewCommentEnabled } from "../review/unified-comment-bridge"; +import type { MergeReadiness } from "../review/unified-comment"; import { buildIssueSlopAssessment, buildSlopAssessment, type SlopBand } from "../signals/slop"; import { runGittensoryAiSlopAdvisory } from "../services/ai-slop"; import { decidePublicSurface } from "../signals/settings-preview"; @@ -1198,7 +1200,7 @@ export async function runAiReviewForAdvisory( author: string | null; confirmedContributor: boolean; }, -): Promise<{ notes: string } | undefined> { +): Promise<{ notes: string; reviewerCount: number } | undefined> { const packAllowsAnyAuthorBlockingReview = args.settings.gatePack === "oss-anti-slop" && args.settings.aiReviewMode === "block"; if (args.settings.aiReviewMode === "off" || (!args.confirmedContributor && !packAllowsAnyAuthorBlockingReview) || !args.advisory.headSha) return undefined; // Per-repo cutover gate (GITTENSORY_REVIEW_REPOS): the converged review features (reputation AI-skip, @@ -1271,7 +1273,7 @@ export async function runAiReviewForAdvisory( }; args.advisory.findings.push(defect); } - return result.advisoryNotes ? { notes: result.advisoryNotes } : undefined; + return result.advisoryNotes ? { notes: result.advisoryNotes, reviewerCount: result.reviewerCount } : undefined; } catch (error) { console.error(JSON.stringify({ level: "warn", event: "ai_review_failed", repository: args.repoFullName, pullNumber: args.pr.number, error: errorMessage(error) })); return undefined; @@ -1531,7 +1533,7 @@ async function maybePublishPrPublicSurface( let queueHealth!: ReturnType; let preflight!: ReturnType; let gateEvaluation: ReturnType | undefined; - let aiReview: { notes: string } | undefined; + let aiReview: { notes: string; reviewerCount: number } | undefined; let gateFinalized = false; try { const [repoIssues, repoPullRequests, repoBounties] = await Promise.all([ @@ -1790,6 +1792,22 @@ async function maybePublishPrPublicSurface( if (unifiedCommentAllowed && gateEvaluation) { const { rows, readinessTotal } = buildPublicPrPanelSignalRows({ repo, pr, profile, detection, queueHealth, collisions, preflight, settings, gate: gateEvaluation }); const unifiedFiles = await listPullRequestFiles(env, repoFullName, pr.number); + // CI + merge-state readiness — a converged enrichment the legacy panel never showed. Maps each cached + // check's conclusion to passed/failed/unverified; any failure (failure/timed_out/cancelled/action_required) + // flips the whole PR to 'failed'. The gate decision stays authoritative for the comment's color (always + // passed here), so these CI chips never spuriously flip the unified status to held/blocked. + const checkSummaries = await listCheckSummaries(env, repoFullName, pr.number); + const failedChecks = checkSummaries.filter((check) => { + const conclusion = (check.conclusion ?? "").toLowerCase(); + return conclusion === "failure" || conclusion === "timed_out" || conclusion === "cancelled" || conclusion === "action_required"; + }); + const anyPassed = checkSummaries.some((check) => (check.conclusion ?? "").toLowerCase() === "success"); + const ciState: MergeReadiness["ciState"] = failedChecks.length > 0 ? "failed" : anyPassed ? "passed" : "unverified"; + const mergeReadiness: MergeReadiness = { + ciState, + ...(pr.mergeableState ? { mergeStateLabel: pr.mergeableState } : {}), + ...(failedChecks.length > 0 ? { failingChecks: failedChecks.map((check) => check.name) } : {}), + }; deterministicBody = buildUnifiedCommentBody({ gate: gateEvaluation, ...(aiReview !== undefined ? { aiReview } : {}), @@ -1798,6 +1816,20 @@ async function maybePublishPrPublicSurface( ...(reviewConfig?.fields !== undefined ? { reviewFields: reviewConfig.fields } : {}), readinessTotal, changedFiles: unifiedFiles.length, + ...(aiReview?.reviewerCount !== undefined ? { reviewerCount: aiReview.reviewerCount } : {}), + mergeReadiness, + extraCollapsibles: buildPublicSafeCollapsibles({ + repo, + pr, + profile, + detection, + settings, + collisions, + preflight, + queueHealth, + ...(reviewConfig !== undefined ? { review: reviewConfig } : {}), + ...(aiReview !== undefined ? { aiReview } : {}), + }), footerMarkdown: gittensoryFooter({ earnUrl: repo?.isRegistered ? gittensorRepoEarnUrl(repoFullName) : undefined, ...(reviewConfig?.footerText ? { customText: reviewConfig.footerText } : {}), diff --git a/src/services/ai-review.ts b/src/services/ai-review.ts index ee022de4b9..1fdb8c7db3 100644 --- a/src/services/ai-review.ts +++ b/src/services/ai-review.ts @@ -94,7 +94,7 @@ export type GittensoryAiReviewResult = | { status: "disabled"; reason: string } | { status: "unavailable"; reason: string } | { status: "quota_exceeded"; estimatedNeurons: number; remainingBudget: number } - | { status: "ok"; advisoryNotes: string | null; consensusDefect: AiConsensusDefect | null; estimatedNeurons: number }; + | { status: "ok"; advisoryNotes: string | null; consensusDefect: AiConsensusDefect | null; estimatedNeurons: number; reviewerCount: number }; type ModelReview = { assessment: string; @@ -449,7 +449,7 @@ export async function runGittensoryAiReview(env: Env, input: GittensoryAiReviewI consensus: Boolean(consensusDefect), ...(byokFailure ? { byokFailure } : {}), }); - return { status: "ok", advisoryNotes, consensusDefect, estimatedNeurons }; + return { status: "ok", advisoryNotes, consensusDefect, estimatedNeurons, reviewerCount: reviewsForNotes.length }; } async function record( diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 63bbbcc361..c8dd0829c7 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -28,6 +28,7 @@ import { sanitizePublicComment } from "../queue-intelligence"; import { projectLinkedIssueMultiplierForPlannedSolve, type LinkedIssueMultiplierStatus } from "../scoring/preview"; import { hasLocalTestEvidence } from "./test-evidence"; import { PREFLIGHT_LIMITS } from "./preflight-limits"; +import type { UnifiedCollapsible } from "../review/unified-comment"; export type ParticipationLane = "direct_pr" | "issue_discovery" | "split" | "inactive" | "unknown"; export type SignalFinding = AdvisoryFinding; @@ -3923,6 +3924,146 @@ function footerEarnUrl(repo: RepositoryRecord | null, repoFullName: string): str return repo?.isRegistered ? gittensorRepoEarnUrl(repoFullName) : undefined; } +// ── Public-safe collapsible bodies (ONE source: legacy panel + unified-comment bridge) ────────────── +// +// The public PR comment carries a fixed set of collapsed `
` sections. Their BODIES are built +// here as line arrays from the SAME inputs the panel already has, so the legacy `
` markup and +// the converged renderer's `UnifiedCollapsible[]` never diverge on content. EXCLUDES "Maintainer notes" +// — that section is PRIVATE (advisory findings) and must never appear in the converged public comment; +// the legacy builder still renders it inline below, but no shared helper produces it. +// +// Byte-identity: `buildPublicPrIntelligenceComment` splices these exact arrays into its existing +// `
` wrappers, so flag-OFF output is unchanged. The unified bridge consumes +// `buildPublicSafeCollapsibles` (which joins the same lines) as `extraCollapsibles`. + +/** Inputs the public-safe collapsible bodies are built from — the subset of the panel's `args` they read. + * `collisions`/`preflight`/`queueHealth` reuse the SAME types `buildPublicPrIntelligenceComment` takes so + * the bodies derive identically to the legacy panel. */ +type PublicSafeCollapsibleArgs = { + repo: RepositoryRecord | null; + pr: PullRequestRecord; + profile: ContributorProfile; + detection: ContributorDetection; + settings: RepositorySettings; + collisions: CollisionReport; + preflight: PreflightResult; + queueHealth: QueueHealth; + review?: FocusManifestReviewConfig | undefined; + aiReview?: { notes: string } | undefined; +}; + +/** "Signal definitions" body — a static legend for the readiness signals. No inputs. */ +function signalDefinitionsBody(): string[] { + return [ + "- Related work = same linked issue, overlapping active PRs, or title/path similarity.", + "- Review load = cached public PR metadata such as size labels, changed paths, and preflight status.", + "- Open PR queue = repo-wide review pressure; it is not a PR quality failure.", + "- Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.", + ]; +} + +/** "Review context" body — public author/role/lane/profile context plus any PR-specific overlap detail. */ +function reviewContextBody(args: PublicSafeCollapsibleArgs): string[] { + const roleContext = buildRoleContext({ + login: args.pr.authorLogin ?? args.profile.login, + repo: args.repo, + repoFullName: args.pr.repoFullName, + pullRequests: [args.pr], + issues: [], + profile: args.profile, + }); + const confirmedMiner = isOfficialContributorDetection(args.detection); + const prCollisionClusters = pullRequestSpecificCollisionClusters(args.collisions, args.pr); + const scopedOverlapClusters = unionScopedOverlapClusters(args.collisions, args.pr, args.preflight.collisions); + return [ + `- Author: \`${sanitizePanelText(args.pr.authorLogin ?? "unknown")}\``, + `- Role context: ${sanitizePanelText(roleContext.role)}${roleContext.maintainerLane ? " (maintainer lane)" : ""}`, + `- Public audience mode: ${args.settings.publicAudienceMode.replace(/_/g, " ")}`, + `- Lane context: ${sanitizePanelText(buildLaneAdvice(args.repo, args.pr.repoFullName).summary)}`, + `- Public profile languages: ${args.profile.github.topLanguages.length > 0 ? sanitizePanelText(args.profile.github.topLanguages.join(", ")) : "not available"}`, + ...(confirmedMiner ? [`- Official Gittensor activity: ${args.detection.priorPullRequests} PR(s), ${args.detection.priorIssues} issue(s).`] : ["- Contributor context: Public profile only; not a blocker."]), + ...relatedWorkDetails(args.pr, scopedOverlapClusters), + // `prCollisionClusters` is referenced only to keep this body's derivation identical to the panel's + // (the panel computes both cluster sets); the overlap detail uses the scoped set. + ...(prCollisionClusters.length === 0 ? [] : []), + ]; +} + +/** "Contributor next steps" body — the deduped actionable steps (or a fallback when none). */ +function contributorNextStepsBody(nextSteps: string[]): string[] { + return nextSteps.length > 0 ? [...new Set(nextSteps)].map((step) => `- ${step}`) : ["- Keep the PR focused and include validation evidence before maintainer review."]; +} + +/** "Review details" body — the optional AI maintainer-review notes (already public-safe upstream). Returns + * `[]` when there is no AI review, so the section is omitted entirely. Angle brackets are escaped as a final + * guard (a stray tag cannot break the panel) and the notes are length-capped, matching the legacy panel. */ +function reviewDetailsBody(aiReview: { notes: string } | undefined): string[] { + if (!aiReview) return []; + return [ + "_Generated from public PR metadata and the diff. Advisory only; deterministic signals remain authoritative._", + "", + aiReview.notes.replace(/[<>]/g, (char) => (char === "<" ? "<" : ">")).slice(0, 4000), + ]; +} + +/** + * The public-safe collapsibles for the CONVERGED comment, as `UnifiedCollapsible[]`. Built from the SAME + * bodies the legacy panel renders (above) so the two never diverge. Order mirrors the legacy panel's + * (Review context · Contributor next steps · Signal definitions · Review details). Excludes "Maintainer + * notes" (PRIVATE). "Review details" is omitted when there is no AI review (empty body → the renderer skips it). + */ +export function buildPublicSafeCollapsibles(args: PublicSafeCollapsibleArgs): UnifiedCollapsible[] { + const collapsibles: UnifiedCollapsible[] = [ + { title: "Review context", body: reviewContextBody(args).join("\n") }, + { title: "Contributor next steps", body: contributorNextStepsBody(publicSafeNextSteps(args)).join("\n") }, + { title: "Signal definitions", body: signalDefinitionsBody().join("\n") }, + ]; + const reviewDetails = reviewDetailsBody(args.aiReview); + if (reviewDetails.length > 0) collapsibles.push({ title: "Review details", body: reviewDetails.join("\n") }); + return collapsibles; +} + +/** The deduped, public-safe "next steps" list — extracted so both the legacy panel and the converged + * comment compute it identically (maintainer-lane note, readiness actions, public-finding actions). */ +function publicSafeNextSteps(args: PublicSafeCollapsibleArgs): string[] { + const roleContext = buildRoleContext({ + login: args.pr.authorLogin ?? args.profile.login, + repo: args.repo, + repoFullName: args.pr.repoFullName, + pullRequests: [args.pr], + issues: [], + profile: args.profile, + }); + const readiness = buildPublicReadinessScore({ + pr: args.pr, + preflight: args.preflight, + queueHealth: args.queueHealth, + linkedDuplicatePrs: linkedIssueDuplicatePullRequests(args.pr, pullRequestSpecificCollisionClusters(args.collisions, args.pr)), + scopedOverlapCount: unionScopedOverlapClusters(args.collisions, args.pr, args.preflight.collisions).length, + }); + const publicFindings = publicSafePreflightFindings(args.preflight, args.settings); + return [ + ...(roleContext.maintainerLane ? ["Treat this as maintainer-lane context rather than normal contributor-lane activity."] : []), + ...readiness.components.map((component) => component.action).filter((action) => action !== "No action."), + /* v8 ignore next -- Public findings may omit actions; public comment tests cover sanitized action inclusion. */ + ...(publicFindings.length > 0 ? publicFindings.flatMap((finding) => (finding.action ? [finding.action] : [])) : []), + ].filter((step) => !containsPrivatePublicTerm(step)); +} + +/** The public-safe subset of preflight findings — extracted so the legacy panel and the converged comment + * filter identically (single source). Drops: critical-severity findings; the linked-issue finding when the + * linked-issue gate is fully off; private bounty-lifecycle findings; and any finding whose text trips the + * private-term backstop. Then slices to the configured public signal level (2 minimal / 5 otherwise). The + * filter chain + slice bounds are byte-identical to the prior inline computation in the legacy builder. */ +function publicSafePreflightFindings(preflight: PreflightResult, settings: RepositorySettings): SignalFinding[] { + return preflight.findings + .filter((finding) => finding.severity !== "critical") + .filter((finding) => settings.requireLinkedIssue || settings.linkedIssueGateMode !== "off" || finding.code !== "missing_linked_issue") + .filter((finding) => !isPrivateBountyLifecycleFinding(finding.code)) + .filter((finding) => !containsPrivatePublicTerm([finding.code, finding.title, finding.detail, finding.publicText, finding.action].filter(Boolean).join(" "))) + .slice(0, settings.publicSignalLevel === "minimal" ? 2 : 5); +} + export function buildPublicPrIntelligenceComment(args: { repo: RepositoryRecord | null; pr: PullRequestRecord; @@ -3937,12 +4078,7 @@ export function buildPublicPrIntelligenceComment(args: { /** Optional AI maintainer-review notes (already public-safe). Rendered as an advisory section. */ aiReview?: { notes: string } | undefined; }): string { - const publicFindings = args.preflight.findings - .filter((finding) => finding.severity !== "critical") - .filter((finding) => args.settings.requireLinkedIssue || args.settings.linkedIssueGateMode !== "off" || finding.code !== "missing_linked_issue") - .filter((finding) => !isPrivateBountyLifecycleFinding(finding.code)) - .filter((finding) => !containsPrivatePublicTerm([finding.code, finding.title, finding.detail, finding.publicText, finding.action].filter(Boolean).join(" "))) - .slice(0, args.settings.publicSignalLevel === "minimal" ? 2 : 5); + const publicFindings = publicSafePreflightFindings(args.preflight, args.settings); const prCollisionClusters = pullRequestSpecificCollisionClusters(args.collisions, args.pr); const linkedDuplicatePrs = linkedIssueDuplicatePullRequests(args.pr, prCollisionClusters); const scopedOverlapClusters = unionScopedOverlapClusters(args.collisions, args.pr, args.preflight.collisions); diff --git a/test/unit/unified-comment-parity.test.ts b/test/unit/unified-comment-parity.test.ts new file mode 100644 index 0000000000..6e6ec64a1f --- /dev/null +++ b/test/unit/unified-comment-parity.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { + buildCollisionReport, + buildContributorProfile, + buildPreflightResult, + buildPublicPrIntelligenceComment, + buildPublicPrPanelSignalRows, + buildPublicSafeCollapsibles, + buildQueueHealth, + detectGittensorContributor, +} from "../../src/signals/engine"; +import { buildUnifiedCommentBody } from "../../src/review/unified-comment-bridge"; +import type { GateCheckEvaluation } from "../../src/rules/advisory"; +import type { IssueRecord, PullRequestRecord, RepositoryRecord, RepositorySettings } from "../../src/types"; + +// ── Fixtures: a confirmed Gittensor contributor PR that produces the FULL public panel (not the minimal +// invite), so every public-safe collapsible section is exercised. Mirrors signals.test.ts's fixtures. ── + +const repo: RepositoryRecord = { + fullName: "entrius/allways-ui", + owner: "entrius", + name: "allways-ui", + isInstalled: true, + isRegistered: true, + isPrivate: false, + registryConfig: { + repo: "entrius/allways-ui", + emissionShare: 0.01107, + issueDiscoveryShare: 0, + labelMultipliers: { bug: 1.1, enhancement: 1, feature: 1.25, refactor: 0.5 }, + trustedLabelPipeline: true, + maintainerCut: 0, + raw: {}, + }, +}; + +const issues: IssueRecord[] = [ + { repoFullName: repo.fullName, number: 7, title: "Dashboard cache refresh fails after reconnect", state: "open", authorLogin: "reporter", labels: ["bug"], linkedPrs: [] }, + { repoFullName: repo.fullName, number: 8, title: "Add reconnect regression coverage", state: "open", authorLogin: "reporter", labels: ["feature"], linkedPrs: [] }, +]; + +const pullRequests: PullRequestRecord[] = [ + { repoFullName: repo.fullName, number: 12, title: "Fix dashboard cache refresh after reconnect", state: "open", authorLogin: "oktofeesh1", authorAssociation: "NONE", labels: ["bug"], linkedIssues: [7], updatedAt: "2026-04-01T00:00:00.000Z", mergeableState: "clean" }, + { repoFullName: repo.fullName, number: 13, title: "Alternative cache reconnect fix", state: "open", authorLogin: "other", authorAssociation: "NONE", labels: ["bug"], linkedIssues: [7] }, +]; + +const settings: RepositorySettings = { + repoFullName: repo.fullName, + commentMode: "detected_contributors_only", + publicAudienceMode: "gittensor_only", + publicSignalLevel: "standard", + checkRunMode: "off", + checkRunDetailLevel: "minimal", + gateCheckMode: "off", + gatePack: "gittensor", + linkedIssueGateMode: "advisory", + duplicatePrGateMode: "advisory", + qualityGateMode: "advisory", + slopGateMode: "off", + mergeReadinessGateMode: "off", + manifestPolicyGateMode: "off", + firstTimeContributorGrace: false, + slopAiAdvisory: false, + qualityGateMinScore: null, + autoLabelEnabled: true, + gittensorLabel: "gittensor", + createMissingLabel: true, + publicSurface: "comment_and_label", + includeMaintainerAuthors: false, + requireLinkedIssue: false, + backfillEnabled: true, + privateTrustEnabled: true, + aiReviewMode: "off", + aiReviewByok: false, +}; + +function buildFixtures() { + const currentPr = pullRequests[0]!; + // A prior MERGED PR makes detection `detected: true` → the FULL panel (not the minimal invite), + // so every public-safe collapsible section (incl. the legacy private "Maintainer notes") is exercised. + const priorPr: PullRequestRecord = { ...currentPr, number: 3, state: "closed", mergedAt: "2026-05-01T00:00:00.000Z" }; + const detection = { ...detectGittensorContributor("oktofeesh1", currentPr, [currentPr, priorPr], []), source: "official_gittensor_api" as const }; + const collisions = buildCollisionReport(repo.fullName, issues, pullRequests); + const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); + const preflight = buildPreflightResult({ repoFullName: repo.fullName, title: currentPr.title, body: "Fixes #7", linkedIssues: [7] }, repo, issues, pullRequests); + const profile = buildContributorProfile("oktofeesh1", { login: "oktofeesh1", topLanguages: ["TypeScript"], source: "github" }, [currentPr, priorPr], []); + return { currentPr, detection, collisions, queueHealth, preflight, profile }; +} + +function gate(over: Partial = {}): GateCheckEvaluation { + return { + enabled: true, + conclusion: "success", + title: "Gittensory Gate passed", + summary: "No configured hard blocker was found.", + blockers: [], + warnings: [], + ...over, + }; +} + +describe("converged comment ↔ legacy panel parity (#unified-comment)", () => { + it("the flag-ON converged body carries the public-safe collapsibles and NEVER the private 'Maintainer notes'", () => { + const { currentPr, detection, collisions, queueHealth, preflight, profile } = buildFixtures(); + const aiReview = { notes: "Looks reasonable. Add a regression test for reconnect.", reviewerCount: 2 }; + const { rows, readinessTotal } = buildPublicPrPanelSignalRows({ repo, pr: currentPr, profile, detection, queueHealth, collisions, preflight, settings, gate: gate() }); + + const body = buildUnifiedCommentBody({ + gate: gate(), + aiReview, + advisoryFindings: [], + panelRows: rows, + readinessTotal, + changedFiles: 3, + reviewerCount: aiReview.reviewerCount, + footerMarkdown: "💰 Earn for open-source contributions like this. Checked by Gittensory.", + reRunLabel: "gittensory-pr-panel:retrigger Re-run Gittensory review", + extraCollapsibles: buildPublicSafeCollapsibles({ repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth, aiReview }), + }); + + // The three public-safe sections the legacy panel carried must survive into the converged comment. + expect(body).toContain("Review context"); + expect(body).toContain("Contributor next steps"); + expect(body).toContain("Signal definitions"); + // With an AI review present the converged comment also surfaces the optional Review-details section. + expect(body).toContain("Review details"); + // PRIVATE — the maintainer-notes / advisory-findings section must NEVER appear in the public converged comment. + expect(body).not.toContain("Maintainer notes"); + }); + + it("omits the AI 'Review details' collapsible when there is no AI review (renderer skips the empty body)", () => { + const { currentPr, detection, collisions, queueHealth, preflight, profile } = buildFixtures(); + const collapsibles = buildPublicSafeCollapsibles({ repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth }); + expect(collapsibles.map((section) => section.title)).toEqual(["Review context", "Contributor next steps", "Signal definitions"]); + expect(collapsibles.map((section) => section.title)).not.toContain("Review details"); + // No section may carry the private maintainer-notes content. + expect(collapsibles.map((section) => section.title)).not.toContain("Maintainer notes"); + expect(JSON.stringify(collapsibles)).not.toMatch(/maintainer notes/i); + }); + + it("the public-safe collapsible bodies are byte-identical to the legacy panel's
bodies", () => { + const { currentPr, detection, collisions, queueHealth, preflight, profile } = buildFixtures(); + const aiReview = { notes: "Looks reasonable. Add a regression test for reconnect.", reviewerCount: 2 }; + const legacy = buildPublicPrIntelligenceComment({ repo, pr: currentPr, profile, detection, queueHealth, collisions, preflight, settings, aiReview }); + const collapsibles = buildPublicSafeCollapsibles({ repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth, aiReview }); + + // Each shared collapsible body's individual lines must appear verbatim in the legacy panel so the two + // renderers can never diverge on the public-safe content. + for (const section of collapsibles) { + if (section.title === "Review details") continue; // Legacy renders this as "Gittensory AI review (advisory)". + for (const line of section.body.split("\n")) { + if (line.trim() === "") continue; + expect(legacy).toContain(line); + } + } + // The "Contributor next steps" body is single-sourced with the legacy panel's deduped next-steps list. + const nextSteps = collapsibles.find((section) => section.title === "Contributor next steps")!; + expect(nextSteps.body.length).toBeGreaterThan(0); + }); + + it("the legacy panel still renders 'Maintainer notes' inline (private section is unchanged, just not shared)", () => { + const { currentPr, detection, collisions, queueHealth, preflight, profile } = buildFixtures(); + const legacy = buildPublicPrIntelligenceComment({ repo, pr: currentPr, profile, detection, queueHealth, collisions, preflight, settings }); + expect(legacy).toContain("Maintainer notes"); + }); +});