diff --git a/plugins/flow/mcp-server/dist/cli.js b/plugins/flow/mcp-server/dist/cli.js index 50460de5..54a0690b 100644 --- a/plugins/flow/mcp-server/dist/cli.js +++ b/plugins/flow/mcp-server/dist/cli.js @@ -30369,6 +30369,34 @@ function resolveGhRepoIdentity(execSyncImpl) { return null; } } +function resolvePluginRepoIdentity(readPkgJsonImpl) { + try { + const readImpl = readPkgJsonImpl ?? (() => { + const { readFileSync: readFileSync6 } = __require("node:fs"); + const { fileURLToPath: fileURLToPath4 } = __require("node:url"); + const nodePath = __require("node:path"); + const here = nodePath.dirname(fileURLToPath4(import.meta.url)); + const pkgPath = nodePath.resolve(here, "..", "package.json"); + return readFileSync6(pkgPath, "utf-8"); + }); + const raw = readImpl(); + if (raw === null) return null; + const pkg = JSON.parse(raw); + const repoField = pkg.repository; + if (!repoField) return null; + const urlStr = typeof repoField === "string" ? repoField : repoField.url ?? ""; + if (!urlStr) return null; + const match = urlStr.match( + /github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?(?:\/)?$/ + ); + if (!match) return null; + const [, owner, repo] = match; + if (!owner || !repo) return null; + return { owner, repo }; + } catch { + return null; + } +} // src/tools/write-native-story.ts var WriteNativeStoryInputSchema = external_exports.object({ @@ -31448,7 +31476,7 @@ async function recordMaintainerFeedback(opts) { let issueUrl; let issueTitle; let issueBody; - const repoIdentity = resolveGhRepoIdentity(opts.execSyncImpl); + const repoIdentity = resolvePluginRepoIdentity(opts.readPluginPkgJsonImpl); if (repoIdentity !== null) { issueTitle = composeFeedbackIssueTitle(validated); issueBody = composeFeedbackIssueBody(validated); @@ -45137,7 +45165,7 @@ async function reviewMaintainerInbox(opts) { if (filenames.length === 0) { return { ok: true, emptyInbox: true, items: [], malformedCount: 0 }; } - const repoIdentity = resolveGhRepoIdentity(opts.execSyncImpl); + const repoIdentity = resolvePluginRepoIdentity(opts.readPluginPkgJsonImpl); const items = []; let malformedCount = 0; for (const filename of filenames) { diff --git a/plugins/flow/mcp-server/dist/index.js b/plugins/flow/mcp-server/dist/index.js index 34f84b3d..4c88803e 100644 --- a/plugins/flow/mcp-server/dist/index.js +++ b/plugins/flow/mcp-server/dist/index.js @@ -52005,6 +52005,34 @@ function resolveGhRepoIdentity(execSyncImpl) { return null; } } +function resolvePluginRepoIdentity(readPkgJsonImpl) { + try { + const readImpl = readPkgJsonImpl ?? (() => { + const { readFileSync: readFileSync6 } = __require("node:fs"); + const { fileURLToPath: fileURLToPath4 } = __require("node:url"); + const nodePath = __require("node:path"); + const here = nodePath.dirname(fileURLToPath4(import.meta.url)); + const pkgPath = nodePath.resolve(here, "..", "package.json"); + return readFileSync6(pkgPath, "utf-8"); + }); + const raw = readImpl(); + if (raw === null) return null; + const pkg = JSON.parse(raw); + const repoField = pkg.repository; + if (!repoField) return null; + const urlStr = typeof repoField === "string" ? repoField : repoField.url ?? ""; + if (!urlStr) return null; + const match = urlStr.match( + /github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?(?:\/)?$/ + ); + if (!match) return null; + const [, owner, repo] = match; + if (!owner || !repo) return null; + return { owner, repo }; + } catch { + return null; + } +} // src/tools/write-native-story.ts var WriteNativeStoryInputSchema = external_exports.object({ @@ -52487,7 +52515,7 @@ async function recordMaintainerFeedback(opts) { let issueUrl; let issueTitle; let issueBody; - const repoIdentity = resolveGhRepoIdentity(opts.execSyncImpl); + const repoIdentity = resolvePluginRepoIdentity(opts.readPluginPkgJsonImpl); if (repoIdentity !== null) { issueTitle = composeFeedbackIssueTitle(validated); issueBody = composeFeedbackIssueBody(validated); @@ -58688,7 +58716,7 @@ async function reviewMaintainerInbox(opts) { if (filenames.length === 0) { return { ok: true, emptyInbox: true, items: [], malformedCount: 0 }; } - const repoIdentity = resolveGhRepoIdentity(opts.execSyncImpl); + const repoIdentity = resolvePluginRepoIdentity(opts.readPluginPkgJsonImpl); const items = []; let malformedCount = 0; for (const filename of filenames) { diff --git a/plugins/flow/mcp-server/package.json b/plugins/flow/mcp-server/package.json index 2fe3705d..709d7bc9 100644 --- a/plugins/flow/mcp-server/package.json +++ b/plugins/flow/mcp-server/package.json @@ -4,6 +4,10 @@ "private": true, "type": "module", "main": "dist/index.js", + "repository": { + "type": "git", + "url": "https://github.com/jackmcintyre/crew" + }, "scripts": { "build": "tsc -p tsconfig.json && node scripts/normalise-dist.mjs && node scripts/bundle.mjs && node scripts/assert-bundle.mjs dist/index.js dist/cli.js && node scripts/assert-clean-install.mjs", "build:watch": "node scripts/watch-and-normalise.mjs", diff --git a/plugins/flow/mcp-server/src/tools/__tests__/dismiss-maintainer-feedback.test.ts b/plugins/flow/mcp-server/src/tools/__tests__/dismiss-maintainer-feedback.test.ts index e2957ff5..de09ce57 100644 --- a/plugins/flow/mcp-server/src/tools/__tests__/dismiss-maintainer-feedback.test.ts +++ b/plugins/flow/mcp-server/src/tools/__tests__/dismiss-maintainer-feedback.test.ts @@ -29,12 +29,11 @@ import type { MaintainerFeedbackItem } from "../../schemas/maintainer-feedback.j let scratch: string; let root: string; -const STUB_EXEC_SYNC = (cmd: string, _opts: { encoding: "utf-8" }): string => { - if (cmd === "gh repo view --json owner,name") { - return JSON.stringify({ owner: { login: "test-owner" }, name: "test-repo" }); - } - throw new Error(`Unexpected command: ${cmd}`); -}; +const STUB_READ_PLUGIN_PKG_JSON = (): string => + JSON.stringify({ + name: "flow", + repository: { type: "git", url: "https://github.com/test-plugin-owner/test-plugin-repo" }, + }); const INBOX_REL = path.join(".flow", "maintainer-inbox"); @@ -121,7 +120,7 @@ describe("dismissMaintainerFeedback (b) — reviewMaintainerInbox ignores dismis // Sanity: both present before dismiss. const before = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(before.items.map((i) => i.id).sort()).toEqual([ITEM_A.id, ITEM_B.id].sort()); @@ -129,7 +128,7 @@ describe("dismissMaintainerFeedback (b) — reviewMaintainerInbox ignores dismis const after = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(after.items).toHaveLength(1); expect(after.items[0]!.id).toBe(ITEM_B.id); @@ -142,7 +141,7 @@ describe("dismissMaintainerFeedback (b) — reviewMaintainerInbox ignores dismis const after = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(after.emptyInbox).toBe(true); expect(after.items).toHaveLength(0); diff --git a/plugins/flow/mcp-server/src/tools/__tests__/feedback-issue-url.test.ts b/plugins/flow/mcp-server/src/tools/__tests__/feedback-issue-url.test.ts index 9046da2a..ab5f7818 100644 --- a/plugins/flow/mcp-server/src/tools/__tests__/feedback-issue-url.test.ts +++ b/plugins/flow/mcp-server/src/tools/__tests__/feedback-issue-url.test.ts @@ -41,6 +41,17 @@ import { let scratch: string; let root: string; +/** Stub plugin package.json — returns a recognisable plugin repo identity. */ +const STUB_READ_PLUGIN_PKG_JSON = (): string => + JSON.stringify({ + name: "flow", + repository: { type: "git", url: "https://github.com/test-plugin-owner/test-plugin-repo" }, + }); + +/** Simulates the repository field being absent / the package.json unreadable. */ +const FAILING_READ_PLUGIN_PKG_JSON = (): string | null => null; + +// Keep STUB_EXEC_SYNC for resolveGhRepoIdentity unit tests (non-retargeted path). const STUB_EXEC_SYNC = (cmd: string, _opts: { encoding: "utf-8" }): string => { if (cmd === "gh repo view --json owner,name") { return JSON.stringify({ owner: { login: "test-owner" }, name: "test-repo" }); @@ -352,7 +363,7 @@ describe("renderFeedbackLinkBlock (AC1/AC3)", () => { // --------------------------------------------------------------------------- describe("recordMaintainerFeedback → issueUrl (AC1/AC2)", () => { - it("returns issueUrl when gh stub succeeds (AC1)", async () => { + it("returns issueUrl targeting the plugin repo when plugin pkg.json stub succeeds (AC1)", async () => { const result = await recordMaintainerFeedback({ targetRepoRoot: root, item: { @@ -361,13 +372,13 @@ describe("recordMaintainerFeedback → issueUrl (AC1/AC2)", () => { trigger: "retro-analyst / cycle-end retro, story native:01TESTXYZ", suggested_direction: "Add a recordMaintainerFeedback seam.", }, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.ok).toBe(true); expect(result.issueUrl).toBeDefined(); expect(result.issueUrl).toMatch( - /^https:\/\/github\.com\/test-owner\/test-repo\/issues\/new\?/, + /^https:\/\/github\.com\/test-plugin-owner\/test-plugin-repo\/issues\/new\?/, ); expect(result.issueUrl).toContain("title="); expect(result.issueUrl).toContain("body="); @@ -381,7 +392,7 @@ describe("recordMaintainerFeedback → issueUrl (AC1/AC2)", () => { tool_area: "test-area", trigger: "generalist-dev / story native:01TEST", }, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); const url = result.issueUrl ?? ""; @@ -394,7 +405,7 @@ describe("recordMaintainerFeedback → issueUrl (AC1/AC2)", () => { expect(url).toContain("?title="); }); - it("issueUrl is absent (not null, just missing) when gh is unavailable — inbox still written (AC2/fail-soft)", async () => { + it("issueUrl is absent (not null, just missing) when plugin pkg.json field absent — inbox still written (AC2/fail-soft)", async () => { const result = await recordMaintainerFeedback({ targetRepoRoot: root, item: { @@ -402,7 +413,7 @@ describe("recordMaintainerFeedback → issueUrl (AC1/AC2)", () => { tool_area: "test-area", trigger: "generalist-dev / story native:01TEST", }, - execSyncImpl: FAILING_EXEC_SYNC, + readPluginPkgJsonImpl: FAILING_READ_PLUGIN_PKG_JSON, }); // Inbox write must still succeed. @@ -425,7 +436,7 @@ describe("recordMaintainerFeedback → issueUrl (AC1/AC2)", () => { trigger: "sentinel-trigger-text", suggested_direction: "sentinel-suggested-direction", }, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); const url = result.issueUrl ?? ""; @@ -445,7 +456,7 @@ describe("recordMaintainerFeedback → issueUrl (AC1/AC2)", () => { tool_area: "some-tool", trigger: "some-trigger", }, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.ok).toBe(true); diff --git a/plugins/flow/mcp-server/src/tools/__tests__/record-maintainer-feedback.test.ts b/plugins/flow/mcp-server/src/tools/__tests__/record-maintainer-feedback.test.ts index 9fddc85d..7449ff93 100644 --- a/plugins/flow/mcp-server/src/tools/__tests__/record-maintainer-feedback.test.ts +++ b/plugins/flow/mcp-server/src/tools/__tests__/record-maintainer-feedback.test.ts @@ -412,3 +412,127 @@ describe("maintainerInboxItemPath helper", () => { expect(path.basename(p)).not.toContain(":"); }); }); + +// --------------------------------------------------------------------------- +// AC2 (story native:01KW5WMS33XC463QM60AXDGK81) +// recordMaintainerFeedback — live-session issueUrl targets the plugin's own repo +// --------------------------------------------------------------------------- + +/** Stub plugin package.json returning a recognisable plugin repo identity. */ +const STUB_READ_PLUGIN_PKG_JSON = (): string => + JSON.stringify({ + name: "flow", + repository: { + type: "git", + url: "https://github.com/test-plugin-owner/test-plugin-repo", + }, + }); + +describe("recordMaintainerFeedback (AC2) — live-session issueUrl targets plugin repo", () => { + it("issueUrl is present and targets the plugin repo when readPluginPkgJsonImpl succeeds", async () => { + const result = await recordMaintainerFeedback({ + targetRepoRoot: root, + item: { + problem: "The run seam cannot route a structural finding.", + tool_area: "run-dev-terminal-action", + trigger: "generalist-dev / story native:01TESTAC2", + }, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, + }); + + expect(result.ok).toBe(true); + expect(result.issueUrl).toBeDefined(); + // Must target the plugin repo, not whatever cwd project is active. + expect(result.issueUrl).toContain("test-plugin-owner/test-plugin-repo"); + expect(result.issueUrl).not.toContain("my-cwd-owner"); + }); + + it("issueUrl is a valid pre-filled GitHub new-issue URL (not an API or auto-file endpoint)", async () => { + const result = await recordMaintainerFeedback({ + targetRepoRoot: root, + item: { + problem: "Issue URL shape check.", + tool_area: "record-maintainer-feedback", + trigger: "generalist-dev / story native:01TESTAC2B", + }, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, + }); + + expect(result.issueUrl).toMatch( + /^https:\/\/github\.com\/test-plugin-owner\/test-plugin-repo\/issues\/new\?/, + ); + expect(result.issueUrl).not.toContain("api/v3"); + expect(result.issueUrl).not.toContain("/issues/create"); + }); + + it("issueUrl body includes the problem and trigger fields", async () => { + const problem = "The reviewer cannot flag a structural engine defect."; + const trigger = "generalist-reviewer / story native:01TESTAC2C"; + + const result = await recordMaintainerFeedback({ + targetRepoRoot: root, + item: { problem, tool_area: "run-reviewer-session", trigger }, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, + }); + + const url = result.issueUrl ?? ""; + const bodyParam = new URL(url).searchParams.get("body") ?? ""; + expect(bodyParam).toContain(problem); + expect(bodyParam).toContain(trigger); + }); + + it("issueUrl is absent when readPluginPkgJsonImpl returns null (fail-soft)", async () => { + // When the plugin package.json is unreadable, the inbox write still + // succeeds — only the URL bonus is suppressed (fail-soft). + const result = await recordMaintainerFeedback({ + targetRepoRoot: root, + item: { + problem: "Fail-soft check — inbox write must still succeed.", + tool_area: "managed-fs", + trigger: "generalist-dev / story native:01TESTAC2D", + }, + readPluginPkgJsonImpl: () => null, + }); + + expect(result.ok).toBe(true); + expect(result.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + expect(result.issueUrl).toBeUndefined(); + + // The inbox file must still have been written. + const stat = await import("node:fs").then((m) => m.promises.stat(result.absPath)); + expect(stat.isFile()).toBe(true); + }); + + it("issueUrl is absent when the repository field is absent in the plugin package.json (fail-soft)", async () => { + const result = await recordMaintainerFeedback({ + targetRepoRoot: root, + item: { + problem: "Repository field absent check.", + tool_area: "build-feedback-issue-url", + trigger: "generalist-dev / story native:01TESTAC2E", + }, + readPluginPkgJsonImpl: () => JSON.stringify({ name: "flow" }), + }); + + expect(result.ok).toBe(true); + expect(result.issueUrl).toBeUndefined(); + }); + + it("also returns issueTitle and issueBody when issueUrl is present", async () => { + const result = await recordMaintainerFeedback({ + targetRepoRoot: root, + item: { + problem: "Title and body fields check.", + tool_area: "review-maintainer-inbox", + trigger: "generalist-dev / story native:01TESTAC2F", + }, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, + }); + + expect(result.issueUrl).toBeDefined(); + expect(result.issueTitle).toBeDefined(); + expect(result.issueBody).toBeDefined(); + expect(result.issueTitle).toContain("review-maintainer-inbox"); + expect(result.issueBody).toContain("Title and body fields check."); + }); +}); diff --git a/plugins/flow/mcp-server/src/tools/__tests__/review-maintainer-feedback.test.ts b/plugins/flow/mcp-server/src/tools/__tests__/review-maintainer-feedback.test.ts new file mode 100644 index 00000000..d4d2528e --- /dev/null +++ b/plugins/flow/mcp-server/src/tools/__tests__/review-maintainer-feedback.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for AC4 — Story native:01KW5WMS33XC463QM60AXDGK81. + * + * AC4 — Integration: only the two maintainer-feedback link paths are retargeted + * to the plugin repo identity; non-maintainer gh-issue paths that correctly + * want the cwd project repo remain unchanged. + * + * Specifically verifies that: + * - `resolveGhRepoIdentity` (used by write-native-story and other non-maintainer + * paths) continues to resolve the cwd repo from `gh repo view` as before. + * - `buildFeedbackIssueUrl` and `buildStoredItemIssueUrl` (pure URL builders) + * still use the caller-supplied owner/repo rather than hard-coding the plugin. + * - `resolvePluginRepoIdentity` and `resolveGhRepoIdentity` are independent — + * neither interferes with the other. + */ + +import { describe, it, expect } from "vitest"; +import { + resolveGhRepoIdentity, + resolvePluginRepoIdentity, + buildFeedbackIssueUrl, +} from "../build-feedback-issue-url.js"; +import { buildStoredItemIssueUrl } from "../review-maintainer-inbox.js"; +import type { MaintainerFeedbackItem } from "../../schemas/maintainer-feedback.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/** Sample live-session feedback input (MaintainerFeedbackInput shape). */ +const SAMPLE_INPUT = { + problem: "Non-maintainer path test: reviewer cannot flag a gap.", + tool_area: "run-reviewer-session", + trigger: "generalist-reviewer / story native:01TESTAC4", +} as const; + +/** Sample stored inbox item (MaintainerFeedbackItem shape). */ +const SAMPLE_STORED_ITEM: MaintainerFeedbackItem = { + id: "01KW5AC4TEST000000000000001", + raised_at: "2026-06-29T10:00:00.000Z", + problem: "Non-maintainer path stored item test.", + tool_area: "gather-retro-inputs", + trigger: "retro-analyst / story native:01TESTAC4B", +}; + +// --------------------------------------------------------------------------- +// resolveGhRepoIdentity — unchanged cwd-bound resolver (AC4) +// --------------------------------------------------------------------------- + +describe("resolveGhRepoIdentity — cwd-bound identity resolver is unchanged after retarget (AC4)", () => { + it("still resolves owner/repo from the cwd gh remote when execSyncImpl is provided", () => { + const result = resolveGhRepoIdentity((cmd, _opts) => { + if (cmd === "gh repo view --json owner,name") { + return JSON.stringify({ + owner: { login: "my-cwd-owner" }, + name: "my-cwd-repo", + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + expect(result).toEqual({ owner: "my-cwd-owner", repo: "my-cwd-repo" }); + }); + + it("returns null when execSyncImpl throws — fail-soft behavior is unchanged", () => { + const result = resolveGhRepoIdentity(() => { + throw new Error("gh not available"); + }); + + expect(result).toBeNull(); + }); + + it("returns null when the gh output is malformed JSON — unchanged behavior", () => { + const result = resolveGhRepoIdentity(() => "not-json"); + + expect(result).toBeNull(); + }); + + it("returns null when owner or repo are empty strings — unchanged behavior", () => { + const result = resolveGhRepoIdentity(() => + JSON.stringify({ owner: { login: "" }, name: "" }), + ); + + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolvePluginRepoIdentity — plugin identity source is independent (AC4) +// --------------------------------------------------------------------------- + +describe("resolvePluginRepoIdentity — reads from plugin package.json, not gh repo view (AC4)", () => { + it("returns the plugin repo identity from the package.json repository field", () => { + const result = resolvePluginRepoIdentity(() => + JSON.stringify({ + name: "flow", + repository: { type: "git", url: "https://github.com/plugin-owner/plugin-repo" }, + }), + ); + + expect(result).toEqual({ owner: "plugin-owner", repo: "plugin-repo" }); + }); + + it("does NOT call execSync / gh repo view — it reads from package.json only", () => { + // If resolvePluginRepoIdentity were to call gh, this would be visible because + // we inject a readPkgJsonImpl that doesn't involve gh at all. + let pkgJsonReadCount = 0; + const result = resolvePluginRepoIdentity(() => { + pkgJsonReadCount++; + return JSON.stringify({ + repository: "https://github.com/plugin-owner/plugin-repo", + }); + }); + + expect(pkgJsonReadCount).toBe(1); // exactly one read of package.json + expect(result).toEqual({ owner: "plugin-owner", repo: "plugin-repo" }); + }); + + it("is independent of resolveGhRepoIdentity — both can coexist with different results", () => { + const cwdResult = resolveGhRepoIdentity(() => + JSON.stringify({ owner: { login: "cwd-owner" }, name: "cwd-repo" }), + ); + const pluginResult = resolvePluginRepoIdentity(() => + JSON.stringify({ + repository: { url: "https://github.com/plugin-owner/plugin-repo" }, + }), + ); + + // The two functions return different identities from different sources. + expect(cwdResult).toEqual({ owner: "cwd-owner", repo: "cwd-repo" }); + expect(pluginResult).toEqual({ owner: "plugin-owner", repo: "plugin-repo" }); + // They do not interfere with each other. + expect(cwdResult).not.toEqual(pluginResult); + }); +}); + +// --------------------------------------------------------------------------- +// buildFeedbackIssueUrl — pure URL builder uses caller-supplied owner/repo (AC4) +// --------------------------------------------------------------------------- + +describe("buildFeedbackIssueUrl — non-maintainer path URL builder is unchanged (AC4)", () => { + it("uses the caller-supplied owner/repo (cwd-targeted), not a hard-coded plugin repo", () => { + const result = buildFeedbackIssueUrl({ + owner: "cwd-project-owner", + repo: "cwd-project-repo", + item: SAMPLE_INPUT, + }); + + expect(result.url).toContain("cwd-project-owner/cwd-project-repo"); + // Explicitly NOT the plugin repo — the caller controls the target. + expect(result.url).not.toContain("jackmcintyre"); + expect(result.url).not.toContain("plugin-owner"); + }); + + it("still returns a valid pre-filled GitHub new-issue URL for any caller-supplied repo", () => { + const result = buildFeedbackIssueUrl({ + owner: "some-org", + repo: "some-project", + item: SAMPLE_INPUT, + }); + + expect(result.url).toMatch( + /^https:\/\/github\.com\/some-org\/some-project\/issues\/new\?/, + ); + expect(result.bodyShortened).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// buildStoredItemIssueUrl — pure URL builder uses caller-supplied owner/repo (AC4) +// --------------------------------------------------------------------------- + +describe("buildStoredItemIssueUrl — non-maintainer path URL builder is unchanged (AC4)", () => { + it("uses the caller-supplied owner/repo (cwd-targeted), not a hard-coded plugin repo", () => { + const result = buildStoredItemIssueUrl( + "cwd-owner", + "cwd-repo", + SAMPLE_STORED_ITEM, + ); + + expect(result.url).toContain("cwd-owner/cwd-repo"); + expect(result.url).not.toContain("plugin-owner"); + expect(result.url).not.toContain("jackmcintyre"); + }); + + it("still returns a valid pre-filled GitHub new-issue URL for any caller-supplied repo", () => { + const result = buildStoredItemIssueUrl( + "another-org", + "another-project", + SAMPLE_STORED_ITEM, + ); + + expect(result.url).toMatch( + /^https:\/\/github\.com\/another-org\/another-project\/issues\/new\?/, + ); + expect(result.bodyShortened).toBe(false); + }); +}); diff --git a/plugins/flow/mcp-server/src/tools/__tests__/review-maintainer-inbox.test.ts b/plugins/flow/mcp-server/src/tools/__tests__/review-maintainer-inbox.test.ts index 59bbf412..8a214991 100644 --- a/plugins/flow/mcp-server/src/tools/__tests__/review-maintainer-inbox.test.ts +++ b/plugins/flow/mcp-server/src/tools/__tests__/review-maintainer-inbox.test.ts @@ -36,16 +36,18 @@ import type { MaintainerFeedbackItem } from "../../schemas/maintainer-feedback.j let scratch: string; let root: string; -const STUB_EXEC_SYNC = (cmd: string, _opts: { encoding: "utf-8" }): string => { - if (cmd === "gh repo view --json owner,name") { - return JSON.stringify({ owner: { login: "test-owner" }, name: "test-repo" }); - } - throw new Error(`Unexpected command: ${cmd}`); -}; +/** Stub for the plugin's own package.json — returns a recognisable plugin repo identity. */ +const STUB_READ_PLUGIN_PKG_JSON = (): string => + JSON.stringify({ + name: "flow", + repository: { + type: "git", + url: "https://github.com/test-plugin-owner/test-plugin-repo", + }, + }); -const FAILING_EXEC_SYNC = (_cmd: string, _opts: { encoding: "utf-8" }): string => { - throw new Error("gh not available"); -}; +/** Simulates the `repository` field being absent / the package.json unreadable. */ +const FAILING_READ_PLUGIN_PKG_JSON = (): string | null => null; /** Write a single inbox item as JSON (simulating what `recordMaintainerFeedback` does). */ async function writeInboxItem( @@ -237,7 +239,7 @@ describe("reviewMaintainerInbox (AC1) — items are surfaced clearly", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.ok).toBe(true); @@ -251,7 +253,7 @@ describe("reviewMaintainerInbox (AC1) — items are surfaced clearly", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); const item = result.items[0]!; @@ -267,7 +269,7 @@ describe("reviewMaintainerInbox (AC1) — items are surfaced clearly", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.items[0]!.suggested_direction).toBe(SAMPLE_ITEM.suggested_direction); @@ -278,7 +280,7 @@ describe("reviewMaintainerInbox (AC1) — items are surfaced clearly", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.items[0]!.suggested_direction).toBeUndefined(); @@ -291,7 +293,7 @@ describe("reviewMaintainerInbox (AC1) — items are surfaced clearly", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); // Items should be in filename-alphabetical (chronological) order. @@ -305,17 +307,17 @@ describe("reviewMaintainerInbox (AC1) — items are surfaced clearly", () => { // --------------------------------------------------------------------------- describe("reviewMaintainerInbox (AC2) — pre-filled GitHub issue URL", () => { - it("each item includes a pre-filled GitHub new-issue URL when gh stub succeeds", async () => { + it("each item includes a pre-filled GitHub new-issue URL when plugin pkg.json stub succeeds", async () => { await writeInboxItem(root, SAMPLE_ITEM); const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.items[0]!.issueUrl).toBeDefined(); expect(result.items[0]!.issueUrl).toMatch( - /^https:\/\/github\.com\/test-owner\/test-repo\/issues\/new\?/, + /^https:\/\/github\.com\/test-plugin-owner\/test-plugin-repo\/issues\/new\?/, ); }); @@ -324,7 +326,7 @@ describe("reviewMaintainerInbox (AC2) — pre-filled GitHub issue URL", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); const url = result.items[0]!.issueUrl ?? ""; @@ -339,7 +341,7 @@ describe("reviewMaintainerInbox (AC2) — pre-filled GitHub issue URL", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); const url = result.items[0]!.issueUrl ?? ""; @@ -353,7 +355,7 @@ describe("reviewMaintainerInbox (AC2) — pre-filled GitHub issue URL", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); const url = result.items[0]!.issueUrl ?? ""; @@ -372,7 +374,7 @@ describe("reviewMaintainerInbox (AC3) — plain web URL, no gh CLI in the link", const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); const url = result.items[0]!.issueUrl ?? ""; @@ -383,12 +385,12 @@ describe("reviewMaintainerInbox (AC3) — plain web URL, no gh CLI in the link", expect(url).not.toContain("--repo"); }); - it("items are still listed (without issueUrl) when gh is unavailable (AC3 — fail-soft)", async () => { + it("items are still listed (without issueUrl) when plugin pkg.json has no repository field (AC3 — fail-soft)", async () => { await writeInboxItem(root, SAMPLE_ITEM); const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: FAILING_EXEC_SYNC, + readPluginPkgJsonImpl: FAILING_READ_PLUGIN_PKG_JSON, }); // The item should still be returned. @@ -404,7 +406,7 @@ describe("reviewMaintainerInbox (AC3) — plain web URL, no gh CLI in the link", const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); const url = result.items[0]!.issueUrl ?? ""; @@ -427,7 +429,7 @@ describe("reviewMaintainerInbox (AC4) — empty inbox handling", () => { // No inbox directory created. const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.ok).toBe(true); @@ -442,7 +444,7 @@ describe("reviewMaintainerInbox (AC4) — empty inbox handling", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.ok).toBe(true); @@ -453,7 +455,7 @@ describe("reviewMaintainerInbox (AC4) — empty inbox handling", () => { it("emits no link when the inbox is empty (AC4)", async () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); // No items means no links — cannot produce a blank or malformed URL. @@ -469,7 +471,7 @@ describe("reviewMaintainerInbox (AC4) — empty inbox handling", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.emptyInbox).toBe(false); @@ -491,7 +493,7 @@ describe("reviewMaintainerInbox — resilience (malformed files skipped)", () => const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.ok).toBe(true); @@ -510,7 +512,7 @@ describe("reviewMaintainerInbox — resilience (malformed files skipped)", () => const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.items).toHaveLength(1); @@ -525,7 +527,7 @@ describe("reviewMaintainerInbox — resilience (malformed files skipped)", () => const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.items).toHaveLength(1); @@ -549,7 +551,7 @@ describe("reviewMaintainerInbox — three-element link block (AC2)", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.items).toHaveLength(1); @@ -577,7 +579,7 @@ describe("reviewMaintainerInbox — three-element link block (AC2)", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); const item = result.items[0]!; @@ -597,7 +599,7 @@ describe("reviewMaintainerInbox — three-element link block (AC2)", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.items).toHaveLength(2); @@ -615,17 +617,17 @@ describe("reviewMaintainerInbox — three-element link block (AC2)", () => { } }); - it("when gh is unavailable, renderFeedbackLinkBlock returns null — no link block emitted (AC3)", async () => { + it("when plugin pkg.json has no repository field, renderFeedbackLinkBlock returns null — no link block emitted (AC3)", async () => { await writeInboxItem(root, SAMPLE_ITEM); const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: FAILING_EXEC_SYNC, + readPluginPkgJsonImpl: FAILING_READ_PLUGIN_PKG_JSON, }); expect(result.items).toHaveLength(1); const item = result.items[0]!; - // No issueUrl when gh is unavailable. + // No issueUrl when plugin pkg.json has no repository field (fail-soft). expect(item.issueUrl).toBeUndefined(); // Attempting to build a link block with no URL should return null cleanly. @@ -653,7 +655,7 @@ describe("reviewMaintainerInbox — URL length guard", () => { const result = await reviewMaintainerInbox({ targetRepoRoot: root, - execSyncImpl: STUB_EXEC_SYNC, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, }); expect(result.items).toHaveLength(1); @@ -662,3 +664,131 @@ describe("reviewMaintainerInbox — URL length guard", () => { expect(result.items[0]!.bodyShortened).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// AC1 (story native:01KW5WMS33XC463QM60AXDGK81) +// reviewMaintainerInbox — issue URL targets the plugin's own repo, not cwd's +// --------------------------------------------------------------------------- + +describe("reviewMaintainerInbox (AC1) — issue URL targets plugin repo, not cwd gh origin", () => { + it("URL uses the plugin repo owner/name from the plugin package.json, not the cwd project repo", async () => { + // The readPluginPkgJsonImpl stub represents the plugin's own package.json. + // The URL must point to the plugin repo regardless of what cwd project + // the operator is running Flow inside. + await writeInboxItem(root, SAMPLE_ITEM); + + const result = await reviewMaintainerInbox({ + targetRepoRoot: root, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, + }); + + expect(result.items).toHaveLength(1); + const url = result.items[0]!.issueUrl ?? ""; + // Must target the plugin repo (test-plugin-owner/test-plugin-repo). + expect(url).toContain("test-plugin-owner/test-plugin-repo"); + // Must NOT target a different cwd-bound repo identity. + expect(url).not.toContain("test-owner/test-repo"); + }); + + it("URL is a valid GitHub new-issue link targeting the plugin repo", async () => { + await writeInboxItem(root, SAMPLE_ITEM); + + const result = await reviewMaintainerInbox({ + targetRepoRoot: root, + readPluginPkgJsonImpl: STUB_READ_PLUGIN_PKG_JSON, + }); + + const url = result.items[0]!.issueUrl ?? ""; + expect(url).toMatch( + /^https:\/\/github\.com\/test-plugin-owner\/test-plugin-repo\/issues\/new\?/, + ); + }); + + it("issueUrl is absent when the plugin package.json repository field is absent (fail-soft)", async () => { + await writeInboxItem(root, SAMPLE_ITEM); + + const result = await reviewMaintainerInbox({ + targetRepoRoot: root, + readPluginPkgJsonImpl: () => + JSON.stringify({ name: "flow" /* no repository field */ }), + }); + + // Fail-soft: item is still returned, just without an issueUrl. + expect(result.items).toHaveLength(1); + expect(result.items[0]!.issueUrl).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// AC3 (story native:01KW5WMS33XC463QM60AXDGK81) +// reviewMaintainerInbox — plugin package.json `repository` field as the identity source +// --------------------------------------------------------------------------- + +describe("reviewMaintainerInbox (AC3) — plugin package.json repository field", () => { + it("reads owner/repo from a url object-form repository field", async () => { + await writeInboxItem(root, SAMPLE_ITEM); + + const result = await reviewMaintainerInbox({ + targetRepoRoot: root, + readPluginPkgJsonImpl: () => + JSON.stringify({ + name: "flow", + repository: { type: "git", url: "https://github.com/pkg-owner/pkg-repo" }, + }), + }); + + const url = result.items[0]!.issueUrl ?? ""; + expect(url).toContain("pkg-owner/pkg-repo"); + }); + + it("reads owner/repo from a shorthand string repository field", async () => { + await writeInboxItem(root, SAMPLE_ITEM); + + const result = await reviewMaintainerInbox({ + targetRepoRoot: root, + readPluginPkgJsonImpl: () => + JSON.stringify({ + name: "flow", + repository: "https://github.com/string-owner/string-repo", + }), + }); + + const url = result.items[0]!.issueUrl ?? ""; + expect(url).toContain("string-owner/string-repo"); + }); + + it("fails soft to no-link when the repository field is absent", async () => { + await writeInboxItem(root, SAMPLE_ITEM); + + const result = await reviewMaintainerInbox({ + targetRepoRoot: root, + readPluginPkgJsonImpl: () => JSON.stringify({ name: "flow" }), + }); + + expect(result.items[0]!.issueUrl).toBeUndefined(); + }); + + it("fails soft to no-link when readPluginPkgJsonImpl returns null", async () => { + await writeInboxItem(root, SAMPLE_ITEM); + + const result = await reviewMaintainerInbox({ + targetRepoRoot: root, + readPluginPkgJsonImpl: FAILING_READ_PLUGIN_PKG_JSON, + }); + + expect(result.items[0]!.issueUrl).toBeUndefined(); + }); + + it("items are still returned even when issueUrl is absent (fail-soft — no-link, not no-item)", async () => { + await writeInboxItem(root, SAMPLE_ITEM); + + const result = await reviewMaintainerInbox({ + targetRepoRoot: root, + readPluginPkgJsonImpl: FAILING_READ_PLUGIN_PKG_JSON, + }); + + expect(result.ok).toBe(true); + expect(result.items).toHaveLength(1); + expect(result.items[0]!.problem).toBe(SAMPLE_ITEM.problem); + }); +}); diff --git a/plugins/flow/mcp-server/src/tools/build-feedback-issue-url.ts b/plugins/flow/mcp-server/src/tools/build-feedback-issue-url.ts index ca457e66..f19f0ea6 100644 --- a/plugins/flow/mcp-server/src/tools/build-feedback-issue-url.ts +++ b/plugins/flow/mcp-server/src/tools/build-feedback-issue-url.ts @@ -269,3 +269,89 @@ export function resolveGhRepoIdentity( return null; } } + +// --------------------------------------------------------------------------- +// Plugin-repo identity resolver (reads from the plugin's own package.json) +// --------------------------------------------------------------------------- + +/** + * Resolve the Flow plugin's own GitHub repo identity from its package.json + * `repository` field. + * + * This is the correct source for maintainer-feedback GitHub links: feedback + * items are Flow bugs by construction and must always land in the Flow repo, + * not whatever cwd project the operator happens to be running Flow inside. + * Using `resolveGhRepoIdentity` (which runs `gh repo view` against the cwd) + * would file issues in the wrong tracker when the operator is in a different + * project. + * + * Reads the standard npm `repository` field. Handles the object form + * `{ type: "git", url: "..." }` and the shorthand string form. Recognised + * GitHub URL shapes: + * - `"https://github.com/owner/repo"` + * - `"https://github.com/owner/repo.git"` + * - `"git+https://github.com/owner/repo.git"` + * - `"git://github.com/owner/repo.git"` + * + * Fails soft: returns `null` when the field is absent, the package.json + * cannot be read, or the URL is not a recognisable GitHub URL. Callers + * degrade to no-link rather than falling back to the cwd gh origin. + * + * @param readPkgJsonImpl — Test seam: inject a function that returns the + * raw package.json content as a string (or `null` to simulate absence / + * read failure). Production callers omit this; the real package.json is + * located relative to the bundle entrypoint via `import.meta.url`. + */ +export function resolvePluginRepoIdentity( + readPkgJsonImpl?: () => string | null, +): { owner: string; repo: string } | null { + try { + const readImpl = + readPkgJsonImpl ?? + (() => { + // In the bundled dist/ output import.meta.url resolves to the bundle + // file (e.g. dist/index.js), so ../package.json is the mcp-server + // package.json that carries the `repository` field. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { readFileSync } = require("node:fs") as { + readFileSync: (path: string, encoding: "utf-8") => string; + }; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { fileURLToPath } = require("node:url") as { + fileURLToPath: (url: string | URL) => string; + }; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const nodePath = require("node:path") as typeof import("node:path"); + const here = nodePath.dirname(fileURLToPath(import.meta.url)); + const pkgPath = nodePath.resolve(here, "..", "package.json"); + return readFileSync(pkgPath, "utf-8"); + }); + + const raw = readImpl(); + if (raw === null) return null; + + const pkg = JSON.parse(raw) as { + repository?: string | { type?: string; url?: string }; + }; + const repoField = pkg.repository; + if (!repoField) return null; + + // Normalise to a URL string (handle both object and shorthand forms). + const urlStr = + typeof repoField === "string" ? repoField : (repoField.url ?? ""); + if (!urlStr) return null; + + // Extract owner/repo from a GitHub URL. + // Matches: github.com/owner/repo, github.com/owner/repo.git, etc. + const match = urlStr.match( + /github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?(?:\/)?$/, + ); + if (!match) return null; + + const [, owner, repo] = match; + if (!owner || !repo) return null; + return { owner, repo }; + } catch { + return null; + } +} diff --git a/plugins/flow/mcp-server/src/tools/record-maintainer-feedback.ts b/plugins/flow/mcp-server/src/tools/record-maintainer-feedback.ts index aec12f74..fd654fe2 100644 --- a/plugins/flow/mcp-server/src/tools/record-maintainer-feedback.ts +++ b/plugins/flow/mcp-server/src/tools/record-maintainer-feedback.ts @@ -56,7 +56,7 @@ import { buildFeedbackIssueUrl, composeFeedbackIssueTitle, composeFeedbackIssueBody, - resolveGhRepoIdentity, + resolvePluginRepoIdentity, } from "./build-feedback-issue-url.js"; export interface RecordMaintainerFeedbackOptions { @@ -69,11 +69,13 @@ export interface RecordMaintainerFeedbackOptions { */ item: unknown; /** - * Test seam: inject a stub for `execSync("gh repo view ...")` so tests - * can control the owner/name without spawning a real `gh` process. - * Production callers omit this; the real `execSync` is used. + * Test seam: inject a function that returns the raw content of the plugin's + * own `package.json` (or `null` to simulate the field being absent / the + * file being unreadable). Used to resolve the plugin's repo identity for the + * pre-filled GitHub issue URL. Production callers omit this; the real + * `mcp-server/package.json` is located automatically via `import.meta.url`. */ - execSyncImpl?: (cmd: string, opts: { encoding: "utf-8" }) => string; + readPluginPkgJsonImpl?: () => string | null; } export interface RecordMaintainerFeedbackResult { @@ -193,7 +195,11 @@ export async function recordMaintainerFeedback( let issueUrl: string | undefined; let issueTitle: string | undefined; let issueBody: string | undefined; - const repoIdentity = resolveGhRepoIdentity(opts.execSyncImpl); + // Resolve the plugin's own repo identity from its package.json `repository` + // field. Maintainer-feedback items are Flow bugs — they must always link to + // the Flow plugin's repo, not whatever cwd project the operator is running + // Flow inside (Story native:01KW5WMS33XC463QM60AXDGK81). + const repoIdentity = resolvePluginRepoIdentity(opts.readPluginPkgJsonImpl); if (repoIdentity !== null) { issueTitle = composeFeedbackIssueTitle(validated); issueBody = composeFeedbackIssueBody(validated); diff --git a/plugins/flow/mcp-server/src/tools/review-maintainer-inbox.ts b/plugins/flow/mcp-server/src/tools/review-maintainer-inbox.ts index 65f9b793..15a30c69 100644 --- a/plugins/flow/mcp-server/src/tools/review-maintainer-inbox.ts +++ b/plugins/flow/mcp-server/src/tools/review-maintainer-inbox.ts @@ -47,7 +47,7 @@ import * as path from "node:path"; import { MaintainerFeedbackItemSchema } from "../schemas/maintainer-feedback.js"; import type { MaintainerFeedbackItem } from "../schemas/maintainer-feedback.js"; import { - resolveGhRepoIdentity, + resolvePluginRepoIdentity, } from "./build-feedback-issue-url.js"; // --------------------------------------------------------------------------- @@ -195,11 +195,13 @@ export interface ReviewMaintainerInboxOptions { /** Absolute path to the target repository root. */ targetRepoRoot: string; /** - * Test seam: inject a stub for `execSync("gh repo view ...")` so tests - * can control the owner/name without spawning a real `gh` process. - * Production callers omit this. + * Test seam: inject a function that returns the raw content of the plugin's + * own `package.json` (or `null` to simulate the field being absent / the + * file being unreadable). Used to resolve the plugin's repo identity for the + * pre-filled GitHub issue URL. Production callers omit this; the real + * `mcp-server/package.json` is located automatically via `import.meta.url`. */ - execSyncImpl?: (cmd: string, opts: { encoding: "utf-8" }) => string; + readPluginPkgJsonImpl?: () => string | null; } export interface ReviewMaintainerInboxResult { @@ -256,8 +258,12 @@ export async function reviewMaintainerInbox( return { ok: true, emptyInbox: true, items: [], malformedCount: 0 }; } - // Step 2: Resolve GitHub repo identity once (fail-soft when gh unavailable). - const repoIdentity = resolveGhRepoIdentity(opts.execSyncImpl); + // Step 2: Resolve the plugin's own GitHub repo identity once (fail-soft when + // the package.json `repository` field is absent or unreadable). + // Maintainer-feedback items are Flow bugs by construction — they must always + // link to the Flow plugin's own repo, not whatever cwd project the operator + // is running Flow inside (Story native:01KW5WMS33XC463QM60AXDGK81). + const repoIdentity = resolvePluginRepoIdentity(opts.readPluginPkgJsonImpl); // Step 3: Parse each file and build the per-item result. const items: ReviewedInboxItem[] = [];