diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8b046e8013..b80e629f47 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -72,6 +72,7 @@ import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outco import { buildRepoOutcomeCalibration, outcomeCalibrationSummary } from "../services/outcome-calibration"; import { computeFleetAnalytics } from "../orb/analytics"; import { loadMaintainerNoiseReport, maintainerNoiseSummary } from "../services/maintainer-noise"; +import { loadMaintainerLaneReport, maintainerLaneSummary } from "../services/maintainer-lane"; import { buildUnavailableQueueTrendReport } from "../services/queue-trends"; import { applyMcpPlanningChoices, @@ -611,6 +612,19 @@ const maintainerNoiseOutputSchema = { summary: z.string().optional(), }; +const maintainerLaneOutputSchema = { + repoFullName: z.string().optional(), + generatedAt: z.string().optional(), + lane: z.unknown().optional(), + maintainerCut: z.number().optional(), + maintainerCutConfigured: z.boolean().optional(), + queueHealth: z.unknown().optional(), + configQuality: z.unknown().optional(), + contributorIntakeHealth: z.unknown().optional(), + findings: z.array(z.unknown()).optional(), + summary: z.string().optional(), +}; + const freshnessResponseOutputSchema = { status: z.string().optional(), repoFullName: z.string().optional(), @@ -1062,6 +1076,16 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.getMaintainerNoise(input)), ); + server.registerTool( + "gittensory_get_maintainer_lane", + { + description: "Return the maintainer-lane triage report for a repo: the lane recommendation alongside the configured maintainer cut, queue health, config quality, and contributor-intake health. Maintainer-authenticated; advisory only.", + inputSchema: ownerRepoShape, + outputSchema: maintainerLaneOutputSchema, + }, + async (input) => this.toolResult(await this.getMaintainerLane(input)), + ); + server.registerTool( "gittensory_get_burden_forecast", { @@ -1829,6 +1853,16 @@ export class GittensoryMcp { }; } + private async getMaintainerLane(input: { owner: string; repo: string }): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoAccess(fullName); + const report = await loadMaintainerLaneReport(this.env, fullName); + return { + summary: maintainerLaneSummary(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-lane.ts b/src/services/maintainer-lane.ts new file mode 100644 index 0000000000..bc1dd3c4f4 --- /dev/null +++ b/src/services/maintainer-lane.ts @@ -0,0 +1,22 @@ +import { getRepository, listIssueSignalSample, listOpenPullRequests, listRecentMergedPullRequests } from "../db/repositories"; +import { buildCollisionReport, buildMaintainerLaneReport, type MaintainerLaneReport } from "../signals/engine"; + +// Maintainer-lane triage synthesis: the lane recommendation in the context of the configured maintainer cut, +// queue health, config quality, and contributor-intake health — i.e. "how should this repo's maintainer treat +// their own lane right now". The deterministic builder already powers the repo-intelligence response; this +// load-or-compute wrapper makes the same report available to the MCP tool surface (agent / CLI), mirroring the +// outcome-calibration / maintainer-noise serving. +export async function loadMaintainerLaneReport(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), + ]); + const collisions = buildCollisionReport(fullName, issues, pullRequests, recentMergedPullRequests); + return buildMaintainerLaneReport(repo, issues, pullRequests, fullName, collisions); +} + +export function maintainerLaneSummary(report: MaintainerLaneReport): string { + return `Gittensory maintainer lane for ${report.repoFullName}: maintainer_cut ${report.maintainerCutConfigured ? "configured" : "not configured"}; contributor intake ${report.contributorIntakeHealth.level}.`; +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index c28ca47b0e..c1e5e9ba1e 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -4900,6 +4900,7 @@ describe("api routes", () => { 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_maintainer_lane"); 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-lane.test.ts b/test/unit/maintainer-lane.test.ts new file mode 100644 index 0000000000..56f0aeae70 --- /dev/null +++ b/test/unit/maintainer-lane.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { loadMaintainerLaneReport, maintainerLaneSummary } from "../../src/services/maintainer-lane"; +import { createTestEnv } from "../helpers/d1"; + +describe("maintainer lane report serving", () => { + it("loads repo signals and computes the lane report on demand", 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: "Fix retry backoff", state: "open", user: { login: "alice" }, body: "" }); + const report = await loadMaintainerLaneReport(env, "octo/demo"); + expect(report.repoFullName).toBe("octo/demo"); + // No maintainer_cut in the registry config → not configured, and a finding flags it. + expect(report.maintainerCutConfigured).toBe(false); + expect(report.findings.some((finding) => finding.code === "maintainer_cut_not_configured")).toBe(true); + expect(report.lane).toBeTruthy(); + expect(report.queueHealth).toBeTruthy(); + expect(report.configQuality).toBeTruthy(); + expect(typeof report.contributorIntakeHealth.level).toBe("string"); + // Public-safe: no private economic/identity terms leak through. + expect(JSON.stringify(report)).not.toMatch(/wallet|hotkey|coldkey|payout|reward|trust score/i); + }); + + it("renders a public-safe one-line summary", () => { + const summary = maintainerLaneSummary({ + repoFullName: "octo/demo", + generatedAt: "2026-06-01T00:00:00.000Z", + lane: {} as never, + maintainerCut: 0, + maintainerCutConfigured: false, + queueHealth: {} as never, + configQuality: {} as never, + contributorIntakeHealth: { level: "healthy" } as never, + summary: "", + findings: [], + }); + expect(summary).toContain("octo/demo"); + expect(summary).toContain("not configured"); + expect(summary).toContain("healthy"); + + // Cover the configured-cut side of the summary ternary. + const configured = maintainerLaneSummary({ + repoFullName: "octo/demo", + generatedAt: "2026-06-01T00:00:00.000Z", + lane: {} as never, + maintainerCut: 0.1, + maintainerCutConfigured: true, + queueHealth: {} as never, + configQuality: {} as never, + contributorIntakeHealth: { level: "developing" } as never, + summary: "", + findings: [], + }); + expect(configured).toContain("maintainer_cut configured"); + expect(configured).not.toContain("not configured"); + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 5a92a925bf..99ac17dbb0 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -13,6 +13,7 @@ import { createTestEnv } from "../helpers/d1"; const TOOLS_WITH_OUTPUT_SCHEMA = [ "gittensory_get_repo_context", "gittensory_get_maintainer_noise", + "gittensory_get_maintainer_lane", "gittensory_get_burden_forecast", "gittensory_get_repo_outcome_patterns", "gittensory_get_outcome_calibration", @@ -233,6 +234,21 @@ describe("MCP tool calls return schema-valid structured content", () => { expect(result.structuredContent).toBeUndefined(); }); + it("gittensory_get_maintainer_lane returns a structured lane 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: "Fix retry backoff", state: "open", user: { login: "alice" }, body: "" }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ name: "gittensory_get_maintainer_lane", arguments: { owner: "octo", repo: "demo" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.repoFullName).toBe("octo/demo"); + expect(typeof data.maintainerCutConfigured).toBe("boolean"); + expect(data.lane).toBeTruthy(); + expect(data.contributorIntakeHealth).toBeTruthy(); + 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 } });