From 3f596fdd5579048fcedbe6a26509a79f608ddf94 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:39:29 +0200 Subject: [PATCH 1/2] feat(mcp): add follow-up issue local-write action spec (#2177) Compose a boundary-safe gh issue-create spec from deferred review findings so contributors can file follow-up work locally without a new MCP write path. Co-authored-by: Cursor --- src/mcp/local-write-tools.ts | 79 +++++++++++++++++++++++++++++ test/unit/local-write-tools.test.ts | 70 +++++++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/src/mcp/local-write-tools.ts b/src/mcp/local-write-tools.ts index 47609e2855..8a338f75f7 100644 --- a/src/mcp/local-write-tools.ts +++ b/src/mcp/local-write-tools.ts @@ -43,6 +43,85 @@ export function buildFileIssueSpec(input: { repoFullName: string; title: string; return spec("file_issue", "File a new issue.", { repoFullName: input.repoFullName, title: input.title, body: input.body, labels }, command); } +export type DeferredReviewFinding = { + title: string; + detail: string; + path?: string | undefined; + action?: string | undefined; +}; + +const FOLLOW_UP_ISSUE_TITLE_MAX = 120; +const FOLLOW_UP_ISSUE_BODY_MAX = 4000; + +function stripFollowUpMarkers(value: string): string { + return value.replace(//g, "").replace(/\r\n/g, "\n").trim(); +} + +function boundFollowUpLine(value: string, max: number): string { + const cleaned = stripFollowUpMarkers(value).replace(/\s+/g, " ").trim(); + if (cleaned.length <= max) return cleaned; + return `${cleaned.slice(0, Math.max(0, max - 1)).trimEnd()}…`; +} + +function boundFollowUpBody(value: string, max: number): string { + const cleaned = stripFollowUpMarkers(value).trim(); + if (cleaned.length <= max) return cleaned; + return `${cleaned.slice(0, Math.max(0, max - 1)).trimEnd()}…`; +} + +function composeFollowUpIssueTitle(finding: DeferredReviewFinding): string { + const cleaned = stripFollowUpMarkers(finding.title); + if (/^follow-up:/i.test(cleaned)) { + return boundFollowUpLine(cleaned, FOLLOW_UP_ISSUE_TITLE_MAX); + } + const prefix = "Follow-up: "; + return `${prefix}${boundFollowUpLine(cleaned, FOLLOW_UP_ISSUE_TITLE_MAX - prefix.length)}`; +} + +function composeFollowUpIssueBody(input: { finding: DeferredReviewFinding; pullNumber?: number | undefined }): string { + const lines: string[] = []; + if (input.pullNumber !== undefined) lines.push(`Deferred from review on PR #${input.pullNumber}.`); + if (input.finding.path) lines.push(`File: \`${input.finding.path}\``); + lines.push("", boundFollowUpLine(input.finding.detail, FOLLOW_UP_ISSUE_BODY_MAX)); + if (input.finding.action) { + lines.push("", "**Suggested next step**", boundFollowUpLine(input.finding.action, 500)); + } + lines.push("", "_Filed locally from a deferred review finding — gittensory supplies content only._"); + return boundFollowUpBody(lines.join("\n"), FOLLOW_UP_ISSUE_BODY_MAX); +} + +/** File a follow-up issue for a deferred review finding (#2177, #1962 slice). */ +export function buildFollowUpIssueSpec(input: { + repoFullName: string; + finding: DeferredReviewFinding; + labels?: string[] | undefined; + pullNumber?: number | undefined; +}): LocalWriteActionSpec { + const title = composeFollowUpIssueTitle(input.finding); + const body = composeFollowUpIssueBody({ finding: input.finding, pullNumber: input.pullNumber }); + const fileSpec = buildFileIssueSpec({ + repoFullName: input.repoFullName, + title, + body, + labels: input.labels, + }); + return { + ...fileSpec, + action: "follow_up_issue", + description: `File a follow-up issue for a deferred review finding: ${title}`, + inputs: { + ...fileSpec.inputs, + finding: { + title: input.finding.title, + detail: input.finding.detail, + ...(input.finding.path ? { path: input.finding.path } : {}), + ...(input.finding.action ? { action: input.finding.action } : {}), + }, + ...(input.pullNumber !== undefined ? { pullNumber: input.pullNumber } : {}), + }, + }; +} + /** Add labels to an issue or PR (gh issue edit also targets PRs). */ export function buildApplyLabelsSpec(input: { repoFullName: string; number: number; labels: string[] }): LocalWriteActionSpec { const labelArgs = input.labels.map((label) => ` --add-label ${sq(label)}`).join(""); diff --git a/test/unit/local-write-tools.test.ts b/test/unit/local-write-tools.test.ts index ca1c70822a..e75165d8eb 100644 --- a/test/unit/local-write-tools.test.ts +++ b/test/unit/local-write-tools.test.ts @@ -5,6 +5,7 @@ import { buildCreateBranchSpec, buildDeleteBranchSpec, buildFileIssueSpec, + buildFollowUpIssueSpec, buildOpenPrSpec, buildPostEligibilityCommentSpec, buildTestGenSpec, @@ -92,3 +93,72 @@ describe("buildTestGenSpec (#2188)", () => { expect(s.command).toContain("it'\\''s edge case"); }); }); + +// #2177 (follow-up-issue action spec slice of #1962). +describe("buildFollowUpIssueSpec (#2177)", () => { + it("builds a follow_up_issue spec with composed title/body, labels, and the local-execution boundary", () => { + const s = buildFollowUpIssueSpec({ + repoFullName: "o/r", + pullNumber: 42, + labels: ["gittensor:bug"], + finding: { + title: "Handle null branch in widget loader", + detail: "The loader never guards a null response.", + path: "src/widget.ts", + action: "Add a regression test for the null path.", + }, + }); + expect(s.action).toBe("follow_up_issue"); + expect(s.boundary).toBe(LOCAL_WRITE_BOUNDARY); + expect(s.description).toContain("Follow-up: Handle null branch in widget loader"); + expect(s.command).toBe( + "gh issue create --repo 'o/r' --title 'Follow-up: Handle null branch in widget loader' --body 'Deferred from review on PR #42.\nFile: `src/widget.ts`\n\nThe loader never guards a null response.\n\n**Suggested next step**\nAdd a regression test for the null path.\n\n_Filed locally from a deferred review finding — gittensory supplies content only._' --label 'gittensor:bug'", + ); + expect(s.inputs).toMatchObject({ labels: ["gittensor:bug"], pullNumber: 42 }); + expect(s.inputs.finding).toMatchObject({ + title: "Handle null branch in widget loader", + detail: "The loader never guards a null response.", + path: "src/widget.ts", + action: "Add a regression test for the null path.", + }); + }); + + it("omits labels and optional finding fields when absent, and strips HTML comment markers from the finding text", () => { + const s = buildFollowUpIssueSpec({ + repoFullName: "o/r", + finding: { + title: "Follow-up: it's noisy", + detail: "Detail stays public-safe.", + }, + }); + expect(s.command).toContain("--title 'Follow-up: it'\\''s noisy'"); + expect(s.command).not.toContain("