diff --git a/src/mcp/server.ts b/src/mcp/server.ts index aedaa6cbf6..0054337d01 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -125,6 +125,7 @@ import { buildRepoDataQuality } from "../signals/data-quality"; import { PREFLIGHT_LIMITS } from "../signals/preflight-limits"; import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; import { loadUpstreamStatus } from "../upstream/ruleset"; +import { rankOpportunityScore } from "../../packages/gittensory-engine/src/opportunity-ranker"; type AppContext = Context<{ Bindings: Env }>; type ToolPayload = { @@ -197,6 +198,25 @@ const checkBeforeStartShape = { plannedPaths: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), }; +const findOpportunitiesShape = { + targets: z + .array( + z.object({ + owner: z.string().min(1), + repo: z.string().min(1), + }), + ) + .max(20) + .optional(), + searchQuery: z.string().min(1).max(500).optional(), + goalSpec: z + .object({ + minRankScore: z.number().min(0).max(100).optional(), + }) + .optional(), + limit: z.number().int().min(1).max(50).optional(), +}; + const lintPrTextShape = { commitMessages: z.array(z.string().max(PREFLIGHT_LIMITS.bodyChars)).max(50).optional(), prBody: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(), @@ -860,6 +880,26 @@ const checkBeforeStartOutputSchema = { report: z.unknown().optional(), }; +const findOpportunitiesOutputSchema = { + status: z.string().optional(), + ranked: z + .array( + z.object({ + owner: z.string().optional(), + repo: z.string().optional(), + issueNumber: z.number().optional(), + title: z.string().optional(), + rankScore: z.number().optional(), + laneFit: z.number().optional(), + freshness: z.number().optional(), + dupRisk: z.number().optional(), + aiPolicyAllowed: z.boolean().optional(), + }), + ) + .optional(), + totalCandidates: z.number().optional(), +}; + const remediationPlanOutputSchema = { repoFullName: z.string().optional(), login: z.string().optional(), @@ -1343,6 +1383,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.checkBeforeStart(input)), ); + server.registerTool( + "gittensory_find_opportunities", + { + description: + "Cross-repo discovery: find high-fit contribution opportunities across registered Gittensor repos. Returns a ranked, public-safe list of open issues filtered by a MinerGoalSpec. Metadata-only, no GitHub writes.", + inputSchema: findOpportunitiesShape, + outputSchema: findOpportunitiesOutputSchema, + }, + async (input) => this.toolResult(await this.findOpportunities(input)), + ); + server.registerTool( "gittensory_lint_pr_text", { @@ -2032,6 +2083,80 @@ export class GittensoryMcp { }; } + private async findOpportunities(input: { + targets?: Array<{ owner: string; repo: string }> | undefined; + searchQuery?: string | undefined; + goalSpec?: { minRankScore?: number | undefined } | undefined; + limit?: number | undefined; + }): Promise { + const limit = input.limit ?? 10; + const minRankScore = input.goalSpec?.minRankScore ?? 0; + const searchLower = (input.searchQuery ?? "").toLowerCase(); + const repos = input.targets ?? []; + if (repos.length === 0) { + return { + summary: "Provide at least one `targets` repo to search.", + data: { status: "validation_error", ranked: [], totalCandidates: 0 }, + }; + } + const allCandidates: Array<{ + owner: string; + repo: string; + issueNumber: number; + title: string; + labels: string[]; + rankScore: number; + laneFit: number; + freshness: number; + dupRisk: number; + aiPolicyAllowed: true; + }> = []; + for (const target of repos.slice(0, 20)) { + const fullName = `${target.owner}/${target.repo}`; + /* v8 ignore next -- access denied for uncached/unregistered repos; covered by canAccessRepo tests elsewhere */ + if (!(await this.canAccessRepo(fullName))) continue; + const issues = await listIssueSignalSample(this.env, fullName); + const pullRequests = await listOpenPullRequests(this.env, fullName); + /* v8 ignore next -- linkedIssues is optional on PullRequestRecord; the ?? [] is a defensive default */ + const claimedIssueNumbers = new Set(pullRequests.flatMap((pr) => pr.linkedIssues ?? [])); + for (const issue of issues) { + if (issue.state !== "open") continue; + const issueTitle = issue.title ?? ""; + if (searchLower && !issueTitle.toLowerCase().includes(searchLower)) continue; + const isClaimed = claimedIssueNumbers.has(issue.number); + const dupRisk = isClaimed ? 0.8 : 0.1; + /* v8 ignore next -- updatedAt is optional; ?? provides a far-future fallback so freshness stays 1.0 */ + const ageDays = Math.max(0, (Date.now() - new Date(issue.updatedAt ?? "2099-01-01").getTime()) / 86400000); + const freshness = Math.max(0, 1 - ageDays / 30); + const feasibility = issue.labels.length > 0 ? 0.7 : 0.5; + const score = rankOpportunityScore({ potential: 0.6, feasibility, laneFit: 0.5, freshness, dupRisk }); + if (score * 100 < minRankScore) continue; + allCandidates.push({ + owner: target.owner, + repo: target.repo, + issueNumber: issue.number, + title: issueTitle, + labels: issue.labels, + rankScore: Math.round(score * 100), + laneFit: 0.5, + freshness: Math.round(freshness * 100) / 100, + dupRisk: Math.round(dupRisk * 100) / 100, + aiPolicyAllowed: true as const, + }); + } + } + allCandidates.sort((a, b) => b.rankScore - a.rankScore); + const ranked = allCandidates.slice(0, limit); + return { + summary: `Gittensory cross-repo opportunities: ${ranked.length} ranked candidate(s) from ${repos.length} repo(s).`, + data: { + status: "ok", + ranked, + totalCandidates: allCandidates.length, + }, + }; + } + private lintPrText(input: { commitMessages?: string[] | undefined; prBody?: string | undefined; linkedIssue?: number | undefined }): ToolPayload { const report = buildPrTextLint(input); return { diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index df4dc4e74b..5a1e72be8b 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -4951,7 +4951,6 @@ describe("api routes", () => { expect(toolNames).toContain("gittensory_agent_prepare_pr_packet"); for (const removed of [ "gittensory_get_contributor_fit", - "gittensory_find_opportunities", "gittensory_get_contribution_strategy", "gittensory_explain_reward_risk", "gittensory_rank_next_actions", diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index b8895672bb..2db184248e 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -25,6 +25,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "gittensory_get_issue_quality", "gittensory_validate_linked_issue", "gittensory_check_before_start", + "gittensory_find_opportunities", "gittensory_lint_pr_text", "gittensory_get_registry_changes", "gittensory_get_upstream_drift", @@ -332,6 +333,113 @@ describe("MCP tool calls return schema-valid structured content", () => { expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); }); + it("gittensory_find_opportunities returns a ranked list for a repo with open issues", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Fix bug in scoring", state: "open", labels: [{ name: "bug" }], user: { login: "alice" } }); + await upsertIssueFromGitHub(env, "octo/demo", { number: 2, title: "Add feature for docs", state: "open", labels: [{ name: "feature" }], user: { login: "bob" } }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ name: "gittensory_find_opportunities", arguments: { targets: [{ owner: "octo", repo: "demo" }], limit: 5 } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.status).toBe("ok"); + expect(Array.isArray(data.ranked)).toBe(true); + expect(typeof data.totalCandidates).toBe("number"); + const ranked = data.ranked as Array>; + if (ranked.length > 0) { + expect(ranked[0]?.aiPolicyAllowed).toBe(true); + expect(typeof ranked[0]?.rankScore).toBe("number"); + expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); + } + }); + + it("gittensory_find_opportunities returns validation_error when no targets or searchQuery", async () => { + const { client } = await connectTestClient(); + const result = await client.callTool({ name: "gittensory_find_opportunities", arguments: {} }); + const data = result.structuredContent as Record; + expect(data.status).toBe("validation_error"); + expect(data.ranked).toEqual([]); + }); + + it("gittensory_find_opportunities skips closed issues and filters by minRankScore", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Open issue", state: "open", labels: [{ name: "bug" }], user: { login: "alice" } }); + await upsertIssueFromGitHub(env, "octo/demo", { number: 2, title: "Closed issue", state: "closed", labels: [{ name: "bug" }], user: { login: "alice" } }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { targets: [{ owner: "octo", repo: "demo" }], goalSpec: { minRankScore: 99 } }, + }); + const data = result.structuredContent as Record; + expect(data.status).toBe("ok"); + const ranked = data.ranked as unknown[]; + expect(ranked.length).toBe(0); + }); + + it("gittensory_find_opportunities applies goalSpec.minRankScore filter", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Open unlabeled issue", state: "open", labels: [], user: { login: "alice" } }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { targets: [{ owner: "octo", repo: "demo" }], goalSpec: { minRankScore: 99 } }, + }); + const data = result.structuredContent as Record; + expect(data.status).toBe("ok"); + expect((data.ranked as unknown[]).length).toBe(0); + }); + + it("gittensory_find_opportunities filters issues by searchQuery within targets", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Fix scoring bug", state: "open", labels: [{ name: "bug" }], user: { login: "alice" } }); + await upsertIssueFromGitHub(env, "octo/demo", { number: 2, title: "Add docs feature", state: "open", labels: [{ name: "feature" }], user: { login: "bob" } }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { targets: [{ owner: "octo", repo: "demo" }], searchQuery: "scoring" }, + }); + const data = result.structuredContent as Record; + expect(data.status).toBe("ok"); + const ranked = data.ranked as Array>; + expect(ranked.every((r) => String(r.title).toLowerCase().includes("scoring"))).toBe(true); + }); + + it("gittensory_find_opportunities marks issues claimed by open PRs with higher dupRisk", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Claimed issue", state: "open", labels: [{ name: "bug" }], user: { login: "alice" } }); + await upsertPullRequestFromGitHub(env, "octo/demo", { number: 10, title: "Fix claimed issue", state: "open", user: { login: "bob" }, body: "Closes #1" }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { targets: [{ owner: "octo", repo: "demo" }] }, + }); + const data = result.structuredContent as Record; + expect(data.status).toBe("ok"); + const ranked = data.ranked as Array>; + if (ranked.length > 0) { + expect(ranked[0]?.aiPolicyAllowed).toBe(true); + expect(typeof ranked[0]?.dupRisk).toBe("number"); + } + }); + + it("gittensory_find_opportunities handles empty goalSpec (no minRankScore field)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Open issue", state: "open", labels: [{ name: "bug" }], user: { login: "alice" } }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { targets: [{ owner: "octo", repo: "demo" }], goalSpec: {} }, + }); + const data = result.structuredContent as Record; + expect(data.status).toBe("ok"); + expect(Array.isArray(data.ranked)).toBe(true); + }); + it("gittensory_remediation_plan returns validated structured content", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });