diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index de75936eb3..60f6ff68a1 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/routes-maintainer-noise.test.ts b/test/unit/routes-maintainer-noise.test.ts new file mode 100644 index 0000000000..55ae06e5c8 --- /dev/null +++ b/test/unit/routes-maintainer-noise.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { upsertInstallation, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +function stubMinerDetection(): void { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("gittensor.io")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); +} + +async function seedOwnedRepo(env: Env, owner: string, name: string, installationId: number): Promise { + await upsertInstallation(env, { + installation: { id: installationId, account: { login: owner, id: installationId, type: "User" }, repository_selection: "selected", permissions: { metadata: "read" }, events: ["repository"] }, + }); + await upsertRepositoryFromGitHub(env, { name, full_name: `${owner}/${name}`, private: false, owner: { login: owner } }, installationId); + await env.DB.prepare("UPDATE repositories SET is_registered = 1 WHERE full_name = ?").bind(`${owner}/${name}`).run(); +} + +describe("maintainer-noise route (#2228)", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("rejects unauthenticated access", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, "owner", "repo", 101); + const res = await app.request("/v1/repos/owner/repo/maintainer-noise", {}, env); + expect(res.status).toBe(401); + }); + + it("allows a repository owner session to read maintainer-noise on their repo", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, "owner", "repo", 101); + stubMinerDetection(); + const { token } = await createSessionForGitHubUser(env, { login: "owner", id: 101 }); + + const res = await app.request("/v1/repos/owner/repo/maintainer-noise", { headers: { cookie: `gittensory_session=${token}` } }, env); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + repoFullName: "owner/repo", + score: expect.any(Number), + level: expect.any(String), + noiseSources: expect.any(Array), + }); + }); + + it("forbids a contributor (non-maintainer) session even though the coarse allowlist permits the path", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await upsertInstallation(env, { + installation: { id: 5, account: { login: "owner", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read" }, events: ["pull_request"] }, + }); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + stubMinerDetection(); + const { token } = await createSessionForGitHubUser(env, { login: "contributor", id: 999 }); + + const res = await app.request("/v1/repos/owner/repo/maintainer-noise", { headers: { authorization: `Bearer ${token}` } }, env); + + expect([401, 403]).toContain(res.status); + }); + + it("forbids a maintainer of repo A from reading repo B maintainer-noise", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, "alice", "repo-a", 101); + await seedOwnedRepo(env, "bob", "repo-b", 102); + stubMinerDetection(); + const { token } = await createSessionForGitHubUser(env, { login: "alice", id: 101 }); + + const res = await app.request("/v1/repos/bob/repo-b/maintainer-noise", { headers: { cookie: `gittensory_session=${token}` } }, env); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ error: "forbidden_repo" }); + }); +}); 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" }));