From 1d2708d01df9446811c2fc2e965433ebe1acab57 Mon Sep 17 00:00:00 2001 From: web-dev0521 Date: Thu, 4 Jun 2026 05:21:32 -0600 Subject: [PATCH] feat(mcp): add public-safe PR body drafting command Adds gittensory_draft_pr_body, which turns local branch metadata into a copy/paste, maintainer-friendly PR body without leaking private scoreability. - src/services/pr-body-draft.ts: buildPublicPrBodyDraft composes the issue's sections (Summary, Changed files, Tests run, Linked issue, Duplicate/WIP check, Branch freshness, Next steps) from already-public-safe analysis slices. Missing tests produce a public-safe caveat; overlap is phrased as hygiene, not accusation. Every line passes through sanitizePublicComment plus a residual scrub of private/financial terms and local paths, and private scoring fields (score preview, scenario projections, risk signals, score-gate blockers, eligibility gate, ranked actions) are excluded by construction and documented in excludedPrivateFields. Output is metadata-only; sourceUploadDisabled is set. - src/mcp/server.ts: register gittensory_draft_pr_body; the tool returns the rendered markdown body (human-readable) and the structured draft (JSON), and reuses analyzeLocalBranch so source contents are never uploaded. - Tests: unit fixtures for clean branch, missing tests, duplicate risk, stale base, source-upload guard, private-field exclusion, and forbidden-language invariants; an integration tool-call asserts the tool stays public-safe. Global coverage stays above the gate (branches 97.04%, lines 99.66%, statements 99.08%, functions 98.42%); the new module is at 100% branch coverage. --- src/mcp/server.ts | 20 +++ src/services/pr-body-draft.ts | 191 +++++++++++++++++++++++++ test/integration/api.test.ts | 13 ++ test/unit/pr-body-draft.test.ts | 239 ++++++++++++++++++++++++++++++++ 4 files changed, 463 insertions(+) create mode 100644 src/services/pr-body-draft.ts create mode 100644 test/unit/pr-body-draft.test.ts diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 76c01d1c34..86f91dff9f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -40,6 +40,7 @@ import { startAgentRun, } from "../services/agent-orchestrator"; import { loadContributorDecisionPackForServing, repoDecisionFromPack } from "../services/decision-pack"; +import { buildPublicPrBodyDraft } from "../services/pr-body-draft"; import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; import { buildMcpClientTelemetry } from "../services/client-telemetry"; @@ -648,6 +649,15 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.localBranchSlice(input, "prPacket")), ); + server.registerTool( + "gittensory_draft_pr_body", + { + description: "Draft a public-safe, copy/paste PR body from local branch metadata (changed files, tests run, linked issue, duplicate/WIP caution, branch freshness, next steps). Private scoreability/reward/trust context is excluded; source contents are not uploaded.", + inputSchema: localBranchAnalysisShape, + }, + async (input) => this.toolResult(await this.draftPrBody(input)), + ); + server.registerTool( "gittensory_compare_local_variants", { @@ -1162,6 +1172,16 @@ export class GittensoryMcp { }; } + private async draftPrBody(input: z.infer>): Promise { + const analysis = await this.analyzeLocalBranch(input); + const draft = buildPublicPrBodyDraft(analysis); + // Human-readable summary carries the rendered markdown body; structured draft is returned as JSON. + return { + summary: `Public-safe PR body draft for ${analysis.repoFullName} (metadata only; private scoreability excluded).\n\n${draft.markdown}`, + data: draft as unknown as Record, + }; + } + private async analyzeLocalBranch(input: z.infer>) { this.requireContributorAccess(input.login); const [context, repo, issues, pullRequests, recentMergedPullRequests, bounties, snapshot, issueQuality, repoManifest] = await Promise.all([ diff --git a/src/services/pr-body-draft.ts b/src/services/pr-body-draft.ts new file mode 100644 index 0000000000..7d932f7b45 --- /dev/null +++ b/src/services/pr-body-draft.ts @@ -0,0 +1,191 @@ +import { sanitizePublicComment } from "../github/commands"; +import type { LocalDiffPreflightResult } from "../signals/engine"; +import type { LocalBranchAnalysis } from "../signals/local-branch"; + +/** + * Drafts a public-safe, copy/paste PR body from local branch metadata. + * + * The draft is built ONLY from already-public-safe slices of {@link LocalBranchAnalysis} + * (the prepared packet, base freshness, linked-issue and overlap metadata). Private + * scoreability, reward/risk, raw trust, and reviewability context are excluded by + * construction — their field names are listed in {@link EXCLUDED_PRIVATE_PR_BODY_FIELDS} + * — and every emitted line additionally passes through {@link sanitizePublicComment} and a + * forbidden-language filter, so no private/financial language reaches GitHub. + * + * Input is metadata only; source contents are never read or uploaded. + */ +export type PrBodyDraftSection = { + heading: string; + lines: string[]; +}; + +export type PublicPrBodyDraft = { + repoFullName: string; + title: string; + sections: PrBodyDraftSection[]; + markdown: string; + caveats: string[]; + excludedPrivateFields: string[]; + sourceUploadDisabled: true; +}; + +/** Structural subset of {@link LocalBranchAnalysis} the drafter consumes (all public-safe). */ +export type PrBodyDraftSource = Pick & { + preflight: Pick; +}; + +/** + * Categories of private analysis context that must never appear in a public PR body draft. + * Phrased as public-safe labels (no private/financial terms) so the list itself stays + * safe to surface; it documents that private scoreability/risk context is excluded. + */ +export const EXCLUDED_PRIVATE_PR_BODY_FIELDS = [ + "private score preview", + "private scenario projections", + "private risk signals", + "private score-gate blockers", + "branch eligibility gate", + "private ranked next actions", +] as const; + +// Residual private/financial terms that sanitizePublicComment does not rewrite on its own +// (e.g. a bare "reward"/"score"/"ranking"); scrubbed to a neutral phrase as defense-in-depth. +const RESIDUAL_PRIVATE_TERMS = /\b(reward\w*|score\w*|farming|payout|ranking|raw[-_\s]?trust|trust[-_\s]?score|private[-_\s]?reviewability|reviewability|wallet|hotkey|coldkey|mnemonic)\b/gi; +const LOCAL_PATH_PATTERN = /(?:\/Users\/|\/home\/|\/tmp\/|[A-Za-z]:\\Users\\)[^\s"';]*/g; +// Final guard used to drop anything that still looks unsafe after scrubbing. +const FORBIDDEN_PR_BODY_LANGUAGE = /\b(reward\w*|score\w*|wallet|hotkey|coldkey|mnemonic|farming|payout|ranking|raw[-_\s]?trust|trust[-_\s]?score|private[-_\s]?reviewability|reviewability)\b|\/Users\/|\/home\/|\/tmp\/|[A-Za-z]:\\Users\\/i; + +function sanitizeLine(line: string): string { + return sanitizePublicComment(line) + .replace(RESIDUAL_PRIVATE_TERMS, "private context") + .replace(LOCAL_PATH_PATTERN, "[local path]") + .replace(/\s+/g, " ") + .trim(); +} + +/** Scrub, trim, drop empties, and drop any residual unsafe line. */ +function safeLines(lines: Array): string[] { + const out: string[] = []; + for (const raw of lines) { + if (!raw) continue; + const clean = sanitizeLine(raw); + if (clean.length > 0 && !FORBIDDEN_PR_BODY_LANGUAGE.test(clean)) out.push(clean); + } + return out; +} + +function changedFilesSection(source: PrBodyDraftSource): PrBodyDraftSection { + const { changedFileCount, testFileCount } = source.baseFreshness; + const countLine = `${changedFileCount} file(s) changed${testFileCount > 0 ? `, including ${testFileCount} test file(s)` : ""}.`; + const pathLines = sectionLines(source.prPacket.bodySections, "Changed Paths").filter((line) => !/no changed paths/i.test(line)); + return { heading: "Changed files", lines: safeLines([countLine, ...pathLines]) }; +} + +function validationSection(source: PrBodyDraftSource): { section: PrBodyDraftSection; missingTests: boolean } { + const { passed, failed, notRun, commands } = source.prPacket.validationSummary; + const ran = commands.filter((entry) => entry.status === "passed" || entry.status === "focused" || entry.status === "failed"); + const missingTests = ran.length === 0; + const lines = missingTests + ? ["No automated tests were recorded for this branch. Add validation evidence (commands + results) before requesting review."] + : [ + `Validation summary: ${passed} passed, ${failed} failed, ${notRun} not run.`, + ...commands.map((entry) => `- ${entry.status}: ${entry.command}${entry.summary ? ` (${entry.summary})` : ""}`), + ]; + return { section: { heading: "Tests run", lines: safeLines(lines) }, missingTests }; +} + +function linkedIssueSection(source: PrBodyDraftSource): PrBodyDraftSection { + const issues = source.preflight.linkedIssues; + const lines = issues.length > 0 ? issues.map((issue) => `Closes #${issue}`) : ["No linked issue detected. If this is intentional, explain why a tracked issue is not needed."]; + return { heading: "Linked issue", lines: safeLines(lines) }; +} + +function duplicateSection(source: PrBodyDraftSource): { section: PrBodyDraftSection; hasOverlap: boolean } { + const collisions = source.preflight.collisions; + if (collisions.length === 0) { + return { section: { heading: "Duplicate / WIP check", lines: safeLines(["No overlapping open work was detected from cached issue/PR metadata."]) }, hasOverlap: false }; + } + // Phrased as hygiene, never as an accusation. + const lines = collisions.slice(0, 3).map((cluster) => { + const refs = cluster.items + .slice(0, 3) + .map((item) => `${item.type === "pull_request" ? "PR" : item.type === "issue" ? "issue" : "recent merge"} #${item.number}`) + .join(", "); + return `Possible overlap with existing work: double-check ${refs} before review to avoid duplicate effort.`; + }); + return { section: { heading: "Duplicate / WIP check", lines: safeLines(lines) }, hasOverlap: true }; +} + +function branchFreshnessSection(source: PrBodyDraftSource): { section: PrBodyDraftSection; stale: boolean } { + const freshness = source.baseFreshness; + const stale = freshness.status === "stale" || freshness.status === "possibly_stale"; + const lines = [ + `Base freshness: ${freshness.status.replace(/_/g, " ")}.`, + ...freshness.warnings, + ...(freshness.recommendation ? [freshness.recommendation] : []), + ]; + return { section: { heading: "Branch freshness", lines: safeLines(lines) }, stale }; +} + +function nextStepsSection(source: PrBodyDraftSource, caveats: string[]): PrBodyDraftSection { + const manifestSteps = source.manifestGuidance.present ? source.manifestGuidance.publicNextSteps : []; + const lines = [ + ...source.prPacket.publicSafeWarnings, + ...manifestSteps, + ...caveats, + "Keep source upload disabled; this draft is built from local git metadata only.", + ]; + return { heading: "Next steps", lines: dedupe(safeLines(lines)).slice(0, 8) }; +} + +/** Build a public-safe PR body draft from the public-safe slices of a local branch analysis. */ +export function buildPublicPrBodyDraft(source: PrBodyDraftSource): PublicPrBodyDraft { + const title = sanitizeLine(source.prPacket.titleSuggestion) || "Describe this change"; + + const summary: PrBodyDraftSection = { + heading: "Summary", + lines: safeLines(["Briefly describe the user-visible change or maintainer-facing improvement in this PR."]), + }; + const changedFiles = changedFilesSection(source); + const { section: tests, missingTests } = validationSection(source); + const linkedIssue = linkedIssueSection(source); + const { section: duplicate, hasOverlap } = duplicateSection(source); + const { section: freshness, stale } = branchFreshnessSection(source); + + const caveats = safeLines([ + missingTests ? "No test evidence was supplied; reviewers may ask for validation before merge." : undefined, + stale ? "Base branch may be stale; rebase or refresh before requesting review." : undefined, + hasOverlap ? "Possible overlap with existing work; confirm this is not a duplicate before review." : undefined, + ]); + + const nextSteps = nextStepsSection(source, caveats); + + const sections = [summary, changedFiles, tests, linkedIssue, duplicate, freshness, nextSteps].filter((section) => section.lines.length > 0); + + return { + repoFullName: source.repoFullName, + title, + sections, + markdown: renderMarkdown(title, sections), + caveats, + excludedPrivateFields: [...EXCLUDED_PRIVATE_PR_BODY_FIELDS], + sourceUploadDisabled: true, + }; +} + +function sectionLines(bodySections: PrBodyDraftSource["prPacket"]["bodySections"], heading: string): string[] { + const match = bodySections.find((section) => section.heading === heading); + return match ? match.lines.map((line) => line.replace(/^-\s*/, "")) : []; +} + +function dedupe(lines: string[]): string[] { + return [...new Set(lines)]; +} + +function renderMarkdown(title: string, sections: PrBodyDraftSection[]): string { + const blocks = [`# ${title}`]; + for (const section of sections) { + blocks.push("", `## ${section.heading}`, ...section.lines.map((line) => (section.heading === "Summary" ? line : `- ${line}`))); + } + return `${blocks.join("\n").trim()}\n`; +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index bb147be733..db4b955fa0 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -3911,6 +3911,19 @@ describe("api routes", () => { ], }, ], + [ + "gittensory_draft_pr_body", + { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + branchName: "fix-cache-reconnect", + body: "Fixes #7", + changedFiles: [ + { path: "src/cache.ts", additions: 42, deletions: 4, status: "modified" }, + { path: "test/cache.test.ts", additions: 20, deletions: 0, status: "added" }, + ], + }, + ], [ "gittensory_compare_local_variants", { diff --git a/test/unit/pr-body-draft.test.ts b/test/unit/pr-body-draft.test.ts new file mode 100644 index 0000000000..e2651ca303 --- /dev/null +++ b/test/unit/pr-body-draft.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "vitest"; +import { buildPublicPrBodyDraft, EXCLUDED_PRIVATE_PR_BODY_FIELDS, type PrBodyDraftSource } from "../../src/services/pr-body-draft"; + +const FORBIDDEN_PUBLIC_LANGUAGE = + /\b(wallet|hotkey|coldkey|mnemonic|raw trust score|raw[-_\s]?trust|trust score|payout|reward estimate|reward|farming|private reviewability|reviewability|public score estimate|scoreability|ranking)\b|\/Users\/|\/home\/|\/tmp\/|[A-Za-z]:\\Users\\/i; + +function source(overrides: Partial = {}): PrBodyDraftSource { + return { + repoFullName: "octo/demo", + prPacket: { + titleSuggestion: "Fix cache refresh race", + markdown: "# Fix cache refresh race\n", + bodySections: [ + { heading: "Changed Paths", lines: ["- src/cache.ts (modified, +12/-3)", "- src/cache.test.ts (added, +20/-0)"] }, + ], + reviewerNotes: [], + validationSummary: { + passed: 1, + failed: 0, + notRun: 0, + commands: [{ command: "npm run test:ci", status: "passed", summary: "all green" }], + }, + publicSafeWarnings: [], + ...overrides.prPacket, + }, + baseFreshness: { + status: "fresh", + changedFileCount: 2, + testFileCount: 1, + passedValidationCount: 1, + warnings: [], + recommendation: undefined, + ...overrides.baseFreshness, + }, + manifestGuidance: { + present: false, + source: "none", + linkedIssuePolicy: "optional", + issueDiscoveryPolicy: "neutral", + matchedWantedPaths: [], + matchedBlockedPaths: [], + preferredLabelHits: [], + findings: [], + publicNextSteps: [], + warnings: [], + summary: "", + ...overrides.manifestGuidance, + }, + preflight: { + linkedIssues: [42], + collisions: [], + reviewBurden: "low", + ...overrides.preflight, + }, + ...overrides, + }; +} + +function headings(draft: ReturnType): string[] { + return draft.sections.map((section) => section.heading); +} + +function section(draft: ReturnType, heading: string): string[] { + return draft.sections.find((s) => s.heading === heading)?.lines ?? []; +} + +// ── Clean branch ──────────────────────────────────────────────────────────── + +describe("buildPublicPrBodyDraft — clean branch", () => { + it("produces a concise, useful draft with all expected sections and a metadata-only guard", () => { + const draft = buildPublicPrBodyDraft(source()); + expect(draft.title).toBe("Fix cache refresh race"); + expect(headings(draft)).toEqual(["Summary", "Changed files", "Tests run", "Linked issue", "Duplicate / WIP check", "Branch freshness", "Next steps"]); + expect(section(draft, "Changed files").join(" ")).toMatch(/2 file\(s\) changed, including 1 test file\(s\)/); + expect(section(draft, "Tests run").join(" ")).toMatch(/1 passed/); + expect(section(draft, "Linked issue")).toContain("Closes #42"); + expect(draft.caveats).toEqual([]); + expect(draft.sourceUploadDisabled).toBe(true); + expect(section(draft, "Next steps").join(" ")).toMatch(/source upload disabled/i); + expect(draft.markdown).toContain("# Fix cache refresh race"); + expect(draft.markdown).toContain("## Changed files"); + }); + + it("falls back to a default title when none is suggested", () => { + const draft = buildPublicPrBodyDraft(source({ prPacket: { ...source().prPacket, titleSuggestion: "" } })); + expect(draft.title).toBe("Describe this change"); + }); +}); + +// ── Missing tests ─────────────────────────────────────────────────────────── + +describe("buildPublicPrBodyDraft — missing tests", () => { + it("emits a public-safe caveat when no tests were recorded", () => { + const draft = buildPublicPrBodyDraft( + source({ prPacket: { ...source().prPacket, validationSummary: { passed: 0, failed: 0, notRun: 0, commands: [] } } }), + ); + expect(section(draft, "Tests run").join(" ")).toMatch(/No automated tests were recorded/i); + expect(draft.caveats.join(" ")).toMatch(/No test evidence was supplied/i); + }); + + it("treats only not_run/skipped commands as missing tests", () => { + const draft = buildPublicPrBodyDraft( + source({ prPacket: { ...source().prPacket, validationSummary: { passed: 0, failed: 0, notRun: 1, commands: [{ command: "npm test", status: "not_run" }] } } }), + ); + expect(section(draft, "Tests run").join(" ")).toMatch(/No automated tests were recorded/i); + }); +}); + +// ── Duplicate / WIP risk ──────────────────────────────────────────────────── + +describe("buildPublicPrBodyDraft — duplicate risk", () => { + it("phrases overlap as hygiene, never as an accusation", () => { + const draft = buildPublicPrBodyDraft( + source({ + preflight: { + linkedIssues: [], + reviewBurden: "medium", + collisions: [{ id: "c1", risk: "medium", reason: "shared files", items: [{ type: "pull_request", number: 12, title: "Other work" }] }], + }, + }), + ); + const text = section(draft, "Duplicate / WIP check").join(" "); + expect(text).toMatch(/possible overlap with existing work/i); + expect(text).toMatch(/double-check PR #12 before review to avoid duplicate effort/i); + expect(text).not.toMatch(/\b(stole|stolen|copied|plagiar|you (?:copied|took))\b/i); + expect(draft.caveats.join(" ")).toMatch(/confirm this is not a duplicate/i); + }); + + it("reports no overlap when there are no collisions", () => { + const draft = buildPublicPrBodyDraft(source()); + expect(section(draft, "Duplicate / WIP check").join(" ")).toMatch(/No overlapping open work was detected/i); + }); +}); + +// ── Stale base ────────────────────────────────────────────────────────────── + +describe("buildPublicPrBodyDraft — stale base", () => { + it("flags a stale base branch as a public-safe caveat", () => { + const draft = buildPublicPrBodyDraft( + source({ baseFreshness: { ...source().baseFreshness, status: "stale", warnings: ["Base branch advanced since this branch was cut."], recommendation: "Rebase onto the latest base." } }), + ); + expect(section(draft, "Branch freshness").join(" ")).toMatch(/base freshness: stale/i); + expect(draft.caveats.join(" ")).toMatch(/Base branch may be stale/i); + }); +}); + +// ── Source-upload guard ───────────────────────────────────────────────────── + +describe("buildPublicPrBodyDraft — source-upload guard", () => { + it("always marks source upload disabled and never leaks local paths", () => { + const draft = buildPublicPrBodyDraft( + source({ + prPacket: { + ...source().prPacket, + bodySections: [{ heading: "Changed Paths", lines: ["- /Users/dev/secret/file.ts (modified, +1/-0)", "- src/ok.ts (modified, +1/-0)"] }], + publicSafeWarnings: ["Reviewed at /home/dev/workspace before posting."], + }, + }), + ); + expect(draft.sourceUploadDisabled).toBe(true); + const blob = JSON.stringify(draft); + expect(blob).not.toMatch(/\/Users\/|\/home\/|\/tmp\/|[A-Za-z]:\\Users\\/); + }); + + it("lists the private analysis fields it deliberately excludes", () => { + const draft = buildPublicPrBodyDraft(source()); + expect(draft.excludedPrivateFields).toEqual([...EXCLUDED_PRIVATE_PR_BODY_FIELDS]); + // The exclusion list itself stays public-safe (no private/financial terms). + expect(JSON.stringify(draft.excludedPrivateFields)).not.toMatch(FORBIDDEN_PUBLIC_LANGUAGE); + expect(draft.excludedPrivateFields.join(" ")).toMatch(/score|risk|eligibility/i); + }); +}); + +// ── Forbidden-language invariant ──────────────────────────────────────────── + +describe("buildPublicPrBodyDraft — public-output safety", () => { + it("strips forbidden private/financial language injected through any source field", () => { + const draft = buildPublicPrBodyDraft( + source({ + prPacket: { + ...source().prPacket, + titleSuggestion: "Boost reward payout and raw trust score", + publicSafeWarnings: ["Maximize your scoreability and reward estimate", "Mention wallet hotkey farming"], + bodySections: [{ heading: "Changed Paths", lines: ["- src/ok.ts (modified, +1/-0)"] }], + }, + baseFreshness: { ...source().baseFreshness, warnings: ["public score estimate looks high"] }, + manifestGuidance: { ...source().manifestGuidance, present: true, publicNextSteps: ["Improve your private reviewability ranking"] }, + }), + ); + const blob = JSON.stringify(draft); + expect(blob).not.toMatch(FORBIDDEN_PUBLIC_LANGUAGE); + expect(draft.markdown).not.toMatch(FORBIDDEN_PUBLIC_LANGUAGE); + }); + + it("keeps the rendered markdown free of forbidden language across clean and risky fixtures", () => { + const drafts = [ + buildPublicPrBodyDraft(source()), + buildPublicPrBodyDraft(source({ prPacket: { ...source().prPacket, validationSummary: { passed: 0, failed: 1, notRun: 0, commands: [{ command: "npm test", status: "failed" }] } } })), + buildPublicPrBodyDraft(source({ preflight: { linkedIssues: [], reviewBurden: "high", collisions: [{ id: "c", risk: "high", reason: "overlap", items: [{ type: "issue", number: 9, title: "x" }] }] } })), + ]; + for (const draft of drafts) { + expect(draft.markdown).not.toMatch(FORBIDDEN_PUBLIC_LANGUAGE); + } + }); +}); + +// ── Edge cases / fallback branches ────────────────────────────────────────── + +describe("buildPublicPrBodyDraft — fallback branches", () => { + it("omits the test-file note when no test files changed", () => { + const draft = buildPublicPrBodyDraft(source({ baseFreshness: { ...source().baseFreshness, changedFileCount: 1, testFileCount: 0 } })); + expect(section(draft, "Changed files")[0]).toBe("1 file(s) changed."); + }); + + it("handles empty linked-issue and collision metadata", () => { + const draft = buildPublicPrBodyDraft(source({ preflight: { linkedIssues: [], collisions: [], reviewBurden: "low" } })); + expect(section(draft, "Linked issue").join(" ")).toMatch(/No linked issue detected/i); + expect(section(draft, "Duplicate / WIP check").join(" ")).toMatch(/No overlapping open work/i); + }); + + it("labels recent-merge overlap items distinctly", () => { + const draft = buildPublicPrBodyDraft( + source({ preflight: { linkedIssues: [], reviewBurden: "low", collisions: [{ id: "c", risk: "low", reason: "overlap", items: [{ type: "recent_merged_pull_request", number: 7, title: "x" }] }] } }), + ); + expect(section(draft, "Duplicate / WIP check").join(" ")).toMatch(/recent merge #7/); + }); + + it("omits the changed-files paths when no Changed Paths section is present", () => { + const draft = buildPublicPrBodyDraft(source({ prPacket: { ...source().prPacket, bodySections: [] } })); + expect(section(draft, "Changed files")).toEqual(["2 file(s) changed, including 1 test file(s)."]); + }); + + it("drops whitespace-only source lines", () => { + const draft = buildPublicPrBodyDraft(source({ prPacket: { ...source().prPacket, publicSafeWarnings: [" ", "Keep PRs focused."] } })); + const steps = section(draft, "Next steps"); + expect(steps).toContain("Keep PRs focused."); + expect(steps.every((line) => line.trim().length > 0)).toBe(true); + }); +});