diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 4b4bf527e4..91619d178e 100644 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -359,6 +359,10 @@ const STDIO_TOOL_DESCRIPTORS = [ name: "gittensory_get_repo_context", description: "Return the canonical repo intelligence bundle from the private Gittensory API.", }, + { + name: "gittensory_get_maintainer_noise", + description: "Return the maintainer queue-noise triage report for a repo: a noise score/level, the specific noise sources to clear first, and recommended maintainer actions. Maintainer-authenticated; advisory only.", + }, { name: "gittensory_preflight_pr", description: "Preflight planned PR metadata against lane, duplicate, linked issue, test, and queue signals.", @@ -513,6 +517,18 @@ server.registerTool( }, ); +server.registerTool( + "gittensory_get_maintainer_noise", + { + description: stdioToolDescription("gittensory_get_maintainer_noise"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return toolResult("Gittensory maintainer noise report.", await apiGet(`${prefix}/maintainer-noise`)); + }, +); + server.registerTool( "gittensory_preflight_pr", { diff --git a/src/api/routes.ts b/src/api/routes.ts index bc7ccfab42..6869489a4f 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -202,6 +202,7 @@ import { } from "../services/weekly-value-report"; import { generateAndSendReviewRecap } from "../services/review-recap"; import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; +import { loadMaintainerNoiseReport } from "../services/maintainer-noise"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; import { buildUnavailableQueueTrendReport } from "../services/queue-trends"; import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns"; @@ -2504,6 +2505,15 @@ export function createApp() { return c.json(await loadGatePrecisionReport(c.env, fullName, windowDays !== undefined ? { windowDays } : {})); }); + // #2228 maintainer queue-noise triage: read-only report for MCP stdio proxy + maintainer tooling. + // Maintainer-authenticated, repo-scoped; replaces the removed legacy public route with the same path shape. + app.get("/v1/repos/:owner/:repo/maintainer-noise", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const gate = await requireRepoMaintainer(c, fullName); + if (gate instanceof Response) return gate; + return c.json(await loadMaintainerNoiseReport(c.env, fullName)); + }); + // One-click "enable advisory mode" — turns on the gate + deterministic rules in advisory (non-blocking) // mode. Merges onto current settings so unrelated fields are preserved. app.post("/v1/repos/:owner/:repo/activation", async (c) => { @@ -5258,6 +5268,7 @@ function canSessionAccessPath(env: Env, identity: Extract { const gatePrecisionNoWindow = await app.request("/v1/repos/entrius/allways-ui/gate-precision", { headers: apiHeaders(env) }, env); await expect(gatePrecisionNoWindow.json()).resolves.toMatchObject({ windowDays: null }); + const maintainerNoiseUnauthenticated = await app.request("/v1/repos/entrius/allways-ui/maintainer-noise", {}, env); + expect(maintainerNoiseUnauthenticated.status).toBe(401); + const maintainerNoise = await app.request("/v1/repos/entrius/allways-ui/maintainer-noise", { headers: apiHeaders(env) }, env); + expect(maintainerNoise.status).toBe(200); + await expect(maintainerNoise.json()).resolves.toMatchObject({ + repoFullName: "entrius/allways-ui", + score: expect.any(Number), + level: expect.any(String), + noiseSources: expect.any(Array), + }); + const settingsPreviewUnauthenticated = await app.request("/v1/repos/entrius/allways-ui/settings-preview", { method: "POST", body: "{}" }, env); expect(settingsPreviewUnauthenticated.status).toBe(401); @@ -931,7 +942,6 @@ describe("api routes", () => { "/v1/repos/entrius/allways-ui/maintainer-lane", "/v1/repos/entrius/allways-ui/maintainer-cut-readiness", "/v1/repos/entrius/allways-ui/contributor-intake-health", - "/v1/repos/entrius/allways-ui/maintainer-noise", ]) { const legacy = await app.request(path, { headers: apiHeaders(env) }, env); expect(legacy.status).toBe(404); diff --git a/test/unit/mcp-cli-maintainer-noise.test.ts b/test/unit/mcp-cli-maintainer-noise.test.ts new file mode 100644 index 0000000000..3e3b07b8a7 --- /dev/null +++ b/test/unit/mcp-cli-maintainer-noise.test.ts @@ -0,0 +1,83 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +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, run, startFixtureServer } from "./support/mcp-cli-harness"; + +const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-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; + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; +let apiUrl: string; +let capturedRequests: Array<{ url: string; method: string }>; + +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "gittensory-maintainer-noise-")); + capturedRequests = []; + apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/maintainer-noise")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + GITTENSORY_CONFIG_DIR: configDir, + GITTENSORY_API_URL: apiUrl, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_API_TIMEOUT_MS: "5000", + }, + }); + client = new Client({ name: "maintainer-noise-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 }); +} + +describe("gittensory_get_maintainer_noise stdio proxy", () => { + 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("gittensory_get_maintainer_noise"); + }); + + it("proxies the call to /maintainer-noise via apiGet and returns the payload", async () => { + const result = await client.callTool({ + name: "gittensory_get_maintainer_noise", + arguments: { owner: "owner", repo: "repo" }, + }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/repos/owner/repo/maintainer-noise"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + expect(text).toContain("owner/repo"); + expect(text).toContain("noiseSources"); + expect(text).toContain("medium"); + }); + + it("lists the tool via gittensory-mcp tools", () => { + const payload = JSON.parse(run(["tools", "--json"])) as { + tools: Array<{ name: string; description: string }>; + }; + const tool = payload.tools.find((entry) => entry.name === "gittensory_get_maintainer_noise"); + expect(tool?.description).toMatch(/maintainer queue-noise triage report/i); + expect(tool?.description.trim().length).toBeGreaterThan(0); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 936a58ab58..3976398b26 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -303,6 +303,21 @@ export async function startFixtureServer( response.end(JSON.stringify({ repoFullName: "owner/repo", pendingActions: [{ id: "pa-1", actionClass: "merge", pullNumber: 7, reason: "clean", status: "pending" }] })); return; } + if (request.url === "/v1/repos/owner/repo/maintainer-noise" && request.method === "GET") { + response.end( + JSON.stringify({ + repoFullName: "owner/repo", + generatedAt: "2026-06-01T00:00:00.000Z", + score: 42, + level: "medium", + noiseSources: ["3 open PRs lack linked issue context."], + maintainerActions: ["review_now"], + queueHealth: { signals: { openPullRequests: 2 } }, + summary: "Gittensory maintainer noise report for owner/repo: medium noise (score 42); 1 source(s) to triage.", + }), + ); + return; + } if (request.url?.startsWith("/v1/repos/owner/repo/agent/pending-actions/") && request.method === "POST") { const accepted = request.url.endsWith("/accept"); response.end(JSON.stringify(accepted ? { status: "accepted", executionOutcome: "completed" } : { status: "rejected" }));