diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index fa94955225..e011842832 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -756,6 +756,14 @@ export { type PredictedGateVerdict, type ContributorCalibrationSignal, } from "./predicted-gate.js"; +// #6741: public-safe PR body draft from local branch metadata — shared by MCP and the CLI stdio mirror. +export { + EXCLUDED_PRIVATE_PR_BODY_FIELDS, + buildPublicPrBodyDraft, + type PrBodyDraftSection, + type PrBodyDraftSource, + type PublicPrBodyDraft, +} from "./pr-body-draft.js"; // Focus-manifest parse/compile core (#2280): shared by the maintainer review stack and the miner's // `.loopover-miner.yml` goal-spec parser (see miner-goal-spec.ts for the parallel surface). export { diff --git a/packages/loopover-engine/src/pr-body-draft.ts b/packages/loopover-engine/src/pr-body-draft.ts new file mode 100644 index 0000000000..e58b877e2f --- /dev/null +++ b/packages/loopover-engine/src/pr-body-draft.ts @@ -0,0 +1,330 @@ +import { sanitizePublicComment } from "./github/sanitize-public-comment.js"; + +/** + * Drafts a public-safe, copy/paste PR body from local branch metadata (#6741). + * + * Moved into `@loopover/engine` so the CLI stdio mirror can compute the draft locally from the same + * analysis `loopover_prepare_pr_packet` already fetches — matching the local-write-tools re-export pattern. + * + * The draft is built ONLY from already-public-safe slices of a local branch analysis (the prepared packet, + * base freshness, linked-issue and overlap metadata). Internal analysis context is excluded by construction + * — those categories are listed in {@link EXCLUDED_PRIVATE_PR_BODY_FIELDS} — and every emitted line passes + * through {@link sanitizePublicComment} and a forbidden-language filter. + * + * 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 a local-branch analysis the drafter consumes (all public-safe). + * Extra fields from the full LocalBranchAnalysis are allowed so callers can pass the analysis + * object through without stripping (and so existing unit fixtures keep typechecking). */ +export type PrBodyDraftSource = { + repoFullName: string; + prPacket: { + titleSuggestion: string; + markdown?: string; + bodySections: Array<{ heading: string; lines: string[] }>; + validationSummary: { + passed: number; + failed: number; + notRun: number; + commands: Array<{ + command: string; + status: string; + summary?: string | undefined; + }>; + }; + publicSafeWarnings: string[]; + reviewerNotes?: string[]; + }; + baseFreshness: { + status: string; + changedFileCount: number; + testFileCount: number; + passedValidationCount?: number; + warnings: string[]; + recommendation?: string | undefined; + }; + manifestGuidance: { + present: boolean; + publicNextSteps: string[]; + source?: string; + linkedIssuePolicy?: string; + issueDiscoveryPolicy?: string; + matchedWantedPaths?: string[]; + preferredLabelHits?: string[]; + findings?: unknown[]; + warnings?: string[]; + summary?: string; + }; + preflight: { + linkedIssues: number[]; + collisions: Array<{ + id?: string; + risk?: string; + reason?: string; + items: Array<{ type: string; number: number; title?: string }>; + }>; + reviewBurden?: string | undefined; + }; +}; + +/** + * Categories of internal analysis context that must never appear in a public PR body draft. + * Labels intentionally avoid private/financial taxonomy because MCP clients may display + * the structured draft alongside the markdown. + */ +export const EXCLUDED_PRIVATE_PR_BODY_FIELDS = [ + "omitted analysis details", + "omitted forecast details", + "omitted signal details", + "omitted blocker details", + "omitted readiness details", + "omitted follow-up details", +] as const; + +// Mirrors src/signals/redaction.ts PUBLIC_UNSAFE_TERMS (duplicated so loopover-engine stays standalone). +const PUBLIC_UNSAFE_TERMS = String.raw`(?:reward|score|wallet|hotkey|coldkey|mnemonic|payout|ranking|cohort)\w*|miner[-_\s]?originated|human[-_\s]?originated|farming|raw[-_\s]?trust|trust[-_\s]?score|private[-_\s]?reviewability|reviewability`; +const RESIDUAL_PRIVATE_TERMS = new RegExp( + String.raw`\b(${PUBLIC_UNSAFE_TERMS})\b`, + "gi", +); +const LOCAL_PATH_SOURCE = String.raw`(?:(?): 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, + }; + } + 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/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index ee02c9923d..22555cdccb 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -5,7 +5,7 @@ import { homedir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { buildFeasibilityVerdict, buildPrTextLint, buildGateDispositions } from "@loopover/engine"; +import { buildFeasibilityVerdict, buildPrTextLint, buildGateDispositions, buildPublicPrBodyDraft } from "@loopover/engine"; // #6149: the miner write-tools are PURE local-execution spec builders (loopover never performs the write); // registering them locally is just importing the same engine builders the remote server uses. import { @@ -1175,6 +1175,12 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "branch", description: "Analyze the current git branch and return a public-safe PR packet. Sends metadata only.", }, + { + name: "loopover_draft_pr_body", + category: "branch", + 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. Optional format=markdown returns the rendered body as the primary payload.", + }, { name: "loopover_compare_local_variants", category: "branch", @@ -2217,6 +2223,38 @@ registerStdioTool( }, ); +// #6741: CLI stdio mirror of loopover_draft_pr_body — same analyzeCurrentBranch fetch as prepare_pr_packet, +// then the shared pure buildPublicPrBodyDraft (now exported from @loopover/engine) runs locally. +const draftPrBodyShape = { + ...currentBranchShape, + format: z.enum(["json", "markdown"]).optional(), +}; + +registerStdioTool( + "loopover_draft_pr_body", + { + description: stdioToolDescription("loopover_draft_pr_body"), + inputSchema: draftPrBodyShape, + }, + async (input) => { + const { format, ...branchInput } = input; + const result = await analyzeCurrentBranch(await withClientWorkspaceRoots(branchInput)); + const draft = buildPublicPrBodyDraft(result.analysis); + if (format === "markdown") { + return toolResult(`Public-safe PR body draft for ${draft.repoFullName} (markdown).\n\n${draft.markdown}`, { + markdown: draft.markdown, + title: draft.title, + repoFullName: draft.repoFullName, + sourceUploadDisabled: true, + }); + } + return toolResult( + `Public-safe PR body draft for ${draft.repoFullName} (metadata only; internal analysis context omitted).\n\n${draft.markdown}`, + draft, + ); + }, +); + registerStdioTool( "loopover_compare_local_variants", { diff --git a/src/services/pr-body-draft.ts b/src/services/pr-body-draft.ts index 65f9a384bd..9d77244cbf 100644 --- a/src/services/pr-body-draft.ts +++ b/src/services/pr-body-draft.ts @@ -1,195 +1,10 @@ -import { sanitizePublicComment } from "../github/commands"; -import { PUBLIC_UNSAFE_TERMS } from "../signals/redaction"; -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). Internal - * analysis context is excluded by construction — those categories are listed in - * {@link EXCLUDED_PRIVATE_PR_BODY_FIELDS} using public-safe labels — 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 internal analysis context that must never appear in a public PR body draft. - * Labels intentionally avoid private/financial taxonomy because MCP clients may display - * the structured draft alongside the markdown. - */ -export const EXCLUDED_PRIVATE_PR_BODY_FIELDS = [ - "omitted analysis details", - "omitted forecast details", - "omitted signal details", - "omitted blocker details", - "omitted readiness details", - "omitted follow-up details", -] 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. -// The term vocabulary is the canonical PUBLIC_UNSAFE_TERMS (#542) so this surface cannot drift it. -const RESIDUAL_PRIVATE_TERMS = new RegExp(String.raw`\b(${PUBLIC_UNSAFE_TERMS})\b`, "gi"); -const LOCAL_PATH_SOURCE = String.raw`(?:(?): 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`; -} +// #6741: buildPublicPrBodyDraft moved to @loopover/engine so the CLI stdio mirror can share it. +// Re-export from the engine SOURCE path (not the published dist) so vitest/Codecov attribute +// coverage to packages/loopover-engine/src/pr-body-draft.ts — same pattern as src/rules/predicted-gate.ts. +export { + EXCLUDED_PRIVATE_PR_BODY_FIELDS, + buildPublicPrBodyDraft, + type PrBodyDraftSection, + type PrBodyDraftSource, + type PublicPrBodyDraft, +} from "../../packages/loopover-engine/src/pr-body-draft.js"; diff --git a/test/unit/mcp-cli-draft-pr-body.test.ts b/test/unit/mcp-cli-draft-pr-body.test.ts new file mode 100644 index 0000000000..b2402ece2a --- /dev/null +++ b/test/unit/mcp-cli-draft-pr-body.test.ts @@ -0,0 +1,162 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { buildPublicPrBodyDraft } from "../../packages/loopover-engine/src/pr-body-draft"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + closeFixtureServer, + createPacketRepo, + run, + startFixtureServer, +} from "./support/mcp-cli-harness"; + +// #6741: CLI stdio mirror of loopover_draft_pr_body — analyzeCurrentBranch then local buildPublicPrBodyDraft. +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); +const FORBIDDEN_PUBLIC_TERMS = + /wallet\s*[:=]\s*\S+|hotkey\s*[:=]\s*\S+|coldkey\s*[:=]\s*\S+|raw trust score is|your trust score|reward estimate is|estimated reward/i; + +function structured(result: unknown): Record { + return (result as { structuredContent?: unknown }) + .structuredContent as Record; +} + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; +let repoDir: string; +let capturedRequests: Array<{ url: string; method: string }>; + +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "loopover-draft-pr-body-")); + repoDir = createPacketRepo(); + capturedRequests = []; + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if ( + request.url?.includes("/v1/local/branch-analysis") && + request.method === "POST" + ) { + capturedRequests.push({ + url: request.url ?? "", + method: request.method ?? "POST", + }); + } + }, + }); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + LOOPOVER_CONFIG_DIR: configDir, + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_API_TIMEOUT_MS: "5000", + }, + }); + client = new Client({ name: "draft-pr-body-cli-test", version: "0.0.1" }); + await client.connect(transport); +} + +async function disconnect() { + await client.close().catch(() => undefined); + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); + if (repoDir) rmSync(repoDir, { recursive: true, force: true }); +} + +describe("loopover_draft_pr_body stdio mirror (#6741)", () => { + beforeEach(connect); + afterEach(disconnect); + + it("registers the tool in the stdio server tool list", async () => { + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toContain("loopover_draft_pr_body"); + }); + + it("fetches branch analysis then returns a draft matching buildPublicPrBodyDraft", async () => { + const result = await client.callTool({ + name: "loopover_draft_pr_body", + arguments: { + login: "JSONbored", + cwd: repoDir, + repoFullName: "JSONbored/gittensory", + baseRef: "HEAD", + }, + }); + expect(capturedRequests.length).toBe(1); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + const data = structured(result); + expect(data.sourceUploadDisabled).toBe(true); + expect(data.markdown).toEqual( + expect.stringContaining("# Local branch preflight"), + ); + expect(data.title).toBe("Local branch preflight"); + // Parity: same engine export over the fixture analysis shape yields the same markdown. + const expected = buildPublicPrBodyDraft({ + repoFullName: "JSONbored/gittensory", + prPacket: { + titleSuggestion: "Local branch preflight", + bodySections: [ + { + heading: "Changed Paths", + lines: ["- src/widget.ts (modified, +8/-1)"], + }, + ], + validationSummary: { + passed: 1, + failed: 0, + notRun: 0, + commands: [{ command: "npm test", status: "passed", summary: "ok" }], + }, + publicSafeWarnings: [], + }, + baseFreshness: { + status: "fresh", + changedFileCount: 1, + testFileCount: 0, + warnings: [], + }, + manifestGuidance: { present: false, publicNextSteps: [] }, + preflight: { linkedIssues: [42], collisions: [] }, + }); + expect(data.markdown).toBe(expected.markdown); + }); + + it("honors format=markdown", async () => { + const result = await client.callTool({ + name: "loopover_draft_pr_body", + arguments: { + login: "JSONbored", + cwd: repoDir, + repoFullName: "JSONbored/gittensory", + baseRef: "HEAD", + format: "markdown", + }, + }); + expect(result.isError).toBeFalsy(); + const data = structured(result); + expect(data).toMatchObject({ + title: "Local branch preflight", + repoFullName: "JSONbored/gittensory", + sourceUploadDisabled: true, + }); + expect(typeof data.markdown).toBe("string"); + expect(data.sections).toBeUndefined(); + }); + + it("lists the tool via loopover-mcp tools", () => { + const payload = JSON.parse(run(["tools", "--json"])) as { + tools: Array<{ name: string; description: string }>; + }; + const tool = payload.tools.find( + (entry) => entry.name === "loopover_draft_pr_body", + ); + expect(tool?.description).toMatch(/PR body/i); + expect(tool?.description.trim().length).toBeGreaterThan(0); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 8fbc7fb8d4..ef51afdb1f 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -18,6 +18,7 @@ // (#6756 registered the loopover_plan_idea_claims CLI mirror, taking the count from 72 to 73.) // (#6734 registered the loopover_get_repo_outcome_patterns CLI mirror, taking the count from 74 to 75.) // (#6740 registered the loopover_explain_gate_disposition CLI mirror, taking the count from 75 to 76.) +// (#6741 registered the loopover_draft_pr_body CLI mirror, taking the count from 76 to 77.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -65,14 +66,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 76 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 77 loopover_ tools and zero gittensory_-prefixed aliases", async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); const primary = names.filter((n) => n.startsWith("loopover_")); const legacy = names.filter((n) => n.startsWith("gittensory_")); - expect(primary.length).toBe(76); + expect(primary.length).toBe(77); expect(legacy.length).toBe(0); - expect(names.length).toBe(76); + expect(names.length).toBe(77); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -84,14 +85,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 76-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 77-tool count the live server registers", async () => { const { tools } = await client.listTools(); const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }>; }; expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(76); + expect(payload.count).toBe(77); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), ); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 872d08aaff..85b3870a84 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -764,8 +764,47 @@ export function localBranchAnalysisFixture() { generatedAt: "2026-06-01T00:00:00.000Z", summary: "Local branch preflight fixture.", nextActions: [{ actionKind: "prepare_pr_packet", whyThisHelps: ["Keeps public packet safe."] }], - preflight: { status: "ready", findings: [] }, - prPacket: { titleSuggestion: "Local branch preflight", markdown: "# Public-safe PR packet\n" }, + // #6741: fields required by buildPublicPrBodyDraft (linkedIssues/collisions + packet slices). + preflight: { + status: "ready", + findings: [], + linkedIssues: [42], + collisions: [], + reviewBurden: "low", + }, + prPacket: { + titleSuggestion: "Local branch preflight", + markdown: "# Public-safe PR packet\n", + bodySections: [{ heading: "Changed Paths", lines: ["- src/widget.ts (modified, +8/-1)"] }], + validationSummary: { + passed: 1, + failed: 0, + notRun: 0, + commands: [{ command: "npm test", status: "passed", summary: "ok" }], + }, + publicSafeWarnings: [], + reviewerNotes: [], + }, + baseFreshness: { + status: "fresh", + changedFileCount: 1, + testFileCount: 0, + passedValidationCount: 1, + warnings: [], + recommendation: undefined, + }, + manifestGuidance: { + present: false, + source: "none", + linkedIssuePolicy: "optional", + issueDiscoveryPolicy: "neutral", + matchedWantedPaths: [], + preferredLabelHits: [], + findings: [], + publicNextSteps: [], + warnings: [], + summary: "", + }, workspaceIntelligence: { version: 2, changedFiles: { total: 1, binary: 0, deleted: 0, renamed: 0 },