diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8768332b6a..ecce1c4289 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -71,6 +71,7 @@ import { buildMcpClientTelemetry } from "../services/client-telemetry"; import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns"; import { buildRepoOutcomeCalibration, outcomeCalibrationSummary } from "../services/outcome-calibration"; import { computeFleetAnalytics } from "../orb/analytics"; +import { loadMaintainerNoiseReport, maintainerNoiseSummary } from "../services/maintainer-noise"; import { buildUnavailableQueueTrendReport } from "../services/queue-trends"; import { applyMcpPlanningChoices, @@ -599,6 +600,17 @@ const repoContextOutputSchema = { dataQuality: z.unknown().optional(), }; +const maintainerNoiseOutputSchema = { + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + score: z.number().optional(), + level: z.string().optional(), + noiseSources: z.array(z.string()).optional(), + maintainerActions: z.array(z.string()).optional(), + queueHealth: z.unknown().optional(), + summary: z.string().optional(), +}; + const freshnessResponseOutputSchema = { status: z.string().optional(), repoFullName: z.string().optional(), @@ -1040,6 +1052,16 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.getRepoContext(input)), ); + server.registerTool( + "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.", + inputSchema: ownerRepoShape, + outputSchema: maintainerNoiseOutputSchema, + }, + async (input) => this.toolResult(await this.getMaintainerNoise(input)), + ); + server.registerTool( "gittensory_get_burden_forecast", { @@ -1797,6 +1819,16 @@ export class GittensoryMcp { }; } + private async getMaintainerNoise(input: { owner: string; repo: string }): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoAccess(fullName); + const report = await loadMaintainerNoiseReport(this.env, fullName); + return { + summary: maintainerNoiseSummary(report), + data: report as unknown as Record, + }; + } + private async getBurdenForecast(input: { owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; await this.requireRepoAccess(fullName); diff --git a/src/services/maintainer-noise.ts b/src/services/maintainer-noise.ts new file mode 100644 index 0000000000..38507f6586 --- /dev/null +++ b/src/services/maintainer-noise.ts @@ -0,0 +1,20 @@ +import { getRepository, listIssueSignalSample, listOpenPullRequests, listRecentMergedPullRequests } from "../db/repositories"; +import { buildMaintainerNoiseReport, type MaintainerNoiseReport } from "../signals/reward-risk"; + +// Maintainer triage signal: which queue-noise sources to clear FIRST (open PRs without linked-issue context, +// broad/churn-style diffs, duplicate clusters, contributor-intake pressure). The deterministic builder already +// exists and powers the `@gittensory noise-report` PR command; this load-or-compute wrapper makes the same +// report available to the MCP tool surface (agent / CLI), mirroring the outcome-calibration serving (#1174). +export async function loadMaintainerNoiseReport(env: Env, fullName: string): Promise { + const [repo, issues, pullRequests, recentMergedPullRequests] = await Promise.all([ + getRepository(env, fullName), + listIssueSignalSample(env, fullName), + listOpenPullRequests(env, fullName), + listRecentMergedPullRequests(env, fullName), + ]); + return buildMaintainerNoiseReport(repo, issues, pullRequests, recentMergedPullRequests, fullName); +} + +export function maintainerNoiseSummary(report: MaintainerNoiseReport): string { + return `Gittensory maintainer noise report for ${report.repoFullName}: ${report.level} noise (score ${report.score}); ${report.noiseSources.length} source(s) to triage.`; +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 8cfb729657..27be323250 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -4875,6 +4875,7 @@ describe("api routes", () => { const toolsPayload = (await mcpJson(toolsList)) as { result: { tools: Array<{ name: string }> } }; const toolNames = toolsPayload.result.tools.map((tool) => tool.name); expect(toolNames).toContain("gittensory_get_repo_context"); + expect(toolNames).toContain("gittensory_get_maintainer_noise"); expect(toolNames).toContain("gittensory_get_issue_quality"); expect(toolNames).toContain("gittensory_get_burden_forecast"); expect(toolNames).toContain("gittensory_get_contributor_profile"); diff --git a/test/unit/maintainer-noise.test.ts b/test/unit/maintainer-noise.test.ts new file mode 100644 index 0000000000..a009ba3a4c --- /dev/null +++ b/test/unit/maintainer-noise.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { loadMaintainerNoiseReport, maintainerNoiseSummary } from "../../src/services/maintainer-noise"; +import { createTestEnv } from "../helpers/d1"; + +describe("maintainer noise report serving", () => { + it("loads repo signals and computes the noise report on demand", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + // An open PR with no linked issue and a broad/churn-style title → queue-noise sources. + await upsertPullRequestFromGitHub(env, "octo/demo", { number: 1, title: "misc cleanup and various refactors across modules", state: "open", user: { login: "alice" }, body: "" }); + const report = await loadMaintainerNoiseReport(env, "octo/demo"); + expect(report.repoFullName).toBe("octo/demo"); + expect(report.score).toBeGreaterThan(0); + expect(["low", "medium", "high", "critical"]).toContain(report.level); + expect(report.noiseSources.length).toBeGreaterThan(0); + // Public-safe: no private economic/identity terms leak through. + expect(JSON.stringify(report)).not.toMatch(/wallet|hotkey|coldkey|payout|reward|trust score/i); + }); + + it("returns a clean-queue report (no noise sources) for a repo with no open PRs", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "quiet", full_name: "octo/quiet", private: false, owner: { login: "octo" }, default_branch: "main" }); + const report = await loadMaintainerNoiseReport(env, "octo/quiet"); + expect(report.repoFullName).toBe("octo/quiet"); + expect(report.level).toBe("low"); + expect(report.noiseSources).toEqual([expect.stringMatching(/No major maintainer-noise source/i)]); + }); + + it("renders a public-safe one-line summary", () => { + const summary = maintainerNoiseSummary({ + repoFullName: "octo/demo", + generatedAt: "2026-06-01T00:00:00.000Z", + score: 42, + level: "medium", + noiseSources: ["a", "b"], + maintainerActions: ["review_now"], + queueHealth: {} as never, + summary: "", + }); + expect(summary).toContain("octo/demo"); + expect(summary).toContain("medium"); + expect(summary).toContain("42"); + expect(summary).toContain("2 source(s)"); + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 73ad8134c2..4db0eb5c4c 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -11,6 +11,7 @@ import { createTestEnv } from "../helpers/d1"; // Tools that ship an MCP-native output schema so modern clients can validate/render responses. const TOOLS_WITH_OUTPUT_SCHEMA = [ "gittensory_get_repo_context", + "gittensory_get_maintainer_noise", "gittensory_get_burden_forecast", "gittensory_get_repo_outcome_patterns", "gittensory_get_outcome_calibration", @@ -184,6 +185,21 @@ describe("MCP tool calls return schema-valid structured content", () => { expect(data.repoFullName).toBe("octo/demo"); }); + it("gittensory_get_maintainer_noise returns a structured noise triage report for a repo", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + await upsertPullRequestFromGitHub(env, "octo/demo", { number: 1, title: "misc cleanup and various refactors", state: "open", user: { login: "alice" }, body: "" }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ name: "gittensory_get_maintainer_noise", arguments: { owner: "octo", repo: "demo" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.repoFullName).toBe("octo/demo"); + expect(typeof data.score).toBe("number"); + expect(typeof data.level).toBe("string"); + expect(Array.isArray(data.noiseSources)).toBe(true); + expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); + }); + it("gittensory_validate_linked_issue reports multiplier eligibility for an uncached issue", async () => { const { client } = await connectTestClient(); const result = await client.callTool({ name: "gittensory_validate_linked_issue", arguments: { owner: "octo", repo: "demo", issueNumber: 1 } });