diff --git a/src/api/routes.ts b/src/api/routes.ts index 30c63dd4fe..dbd3c3ca6e 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -21,6 +21,7 @@ import { extractBrowserSessionToken, extractCookieValue, isAuthorizedGitHubSessionLogin, + isMcpReadRepoAllowed, isMcpReadUnscoped, revokeSession, timingSafeEqual, @@ -176,9 +177,11 @@ import { } from "../services/miner-dashboard-recommendations"; import { buildStaticControlPanelRoleSummary, + canLoginAccessRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, } from "../services/control-panel-roles"; +import { runFindOpportunities, validateFindOpportunitiesInput, type FindOpportunitiesInput } from "../mcp/find-opportunities"; import { buildMcpCompatibilityMetadata, LATEST_RECOMMENDED_MCP_VERSION, @@ -2781,6 +2784,31 @@ export function createApp() { return c.json({ ...buildIssueSlopAssessment(parsed.data), rubric: ISSUE_SLOP_RUBRIC_MARKDOWN }); }); + app.post(OPPORTUNITIES_FIND_PATH, async (c) => { + const identity = await authenticateRequestIdentity(c); + /* v8 ignore next -- Protected middleware rejects unauthenticated private routes before route-specific guards. */ + if (!identity) return c.json({ error: "unauthorized" }, 401); + const body = await c.req.json().catch(() => null); + const parsed = validateFindOpportunitiesInput((body ?? {}) as FindOpportunitiesInput); + if (!parsed.ok) { + return c.json({ status: "invalid_request", ranked: [], totalCandidates: 0, reason: parsed.reason }, 400); + } + if (parsed.value.searchQuery) { + const forbidden = await requireDiscoveryAccessForApi(c, identity); + if (forbidden) return forbidden; + } else { + for (const target of parsed.value.targets ?? []) { + const fullName = `${target.owner}/${target.repo}`; + const forbidden = await requireApiRepoReadAccess(c, identity, fullName); + if (forbidden) return forbidden; + } + } + const result = await runFindOpportunities(c.env, parsed.value, { + canAccessRepo: (repoFullName) => canApiAccessRepo(c.env, identity, repoFullName), + }); + return c.json(result); + }); + app.post("/v1/preflight/pr", async (c) => { const body = await c.req.json().catch(() => null); const parsed = preflightSchema.safeParse(body); @@ -5139,6 +5167,7 @@ function contributorEvidenceFromProfile(profile: { const EXTENSION_PULL_CONTEXT_PATH = "/v1/extension/pull-context"; const EXTENSION_PULL_CONTEXT_SCOPE = "extension:pull_context"; +const OPPORTUNITIES_FIND_PATH = "/v1/opportunities/find"; const LINT_PR_TEXT_PATH = "/v1/lint/pr-text"; const LINT_SLOP_RISK_PATH = "/v1/lint/slop-risk"; const LINT_ISSUE_SLOP_PATH = "/v1/lint/issue-slop"; @@ -5151,7 +5180,7 @@ const EXTENSION_CONTRIBUTOR_CONTEXT_PATH = /^\/v1\/extension\/contributors\/[^/] type ProtectedRouteContext = { env: Env; req: { header: (name: string) => string | undefined | null }; - json: (object: { error: string }, status?: number) => Response; + json: (object: { error: string; reason?: string }, status?: number) => Response; }; function isExtensionScopedSession(identity: AuthIdentity): boolean { @@ -5205,6 +5234,7 @@ function canSessionAccessPath(env: Env, identity: Extract/*`; the handler's @@ -5351,6 +5381,36 @@ async function requireExtensionPullContextRepoAccess( return requireSessionRepoAccess(c, identity, repoFullName, repo); } +async function requireDiscoveryAccessForApi(c: ProtectedRouteContext, identity: AuthIdentity): Promise { + if (identity.kind === "session") { + if (isAuthorizedGitHubSessionLogin(c.env, identity.actor)) return null; + const scope = await loadControlPanelAccessScope(c.env, identity.actor); + if (scope.operator) return null; + return c.json({ error: "forbidden", reason: "cross_repo_search_requires_discovery_access" }, 403); + } + if (identity.kind === "static" && identity.actor === "mcp" && !isMcpReadUnscoped(c.env.MCP_READ_REPO_ALLOWLIST)) { + return c.json({ error: "forbidden", reason: "cross_repo_search_requires_unscoped_mcp_read" }, 403); + } + return null; +} + +async function canApiAccessRepo(env: Env, identity: AuthIdentity, repoFullName: string): Promise { + if (identity.kind === "session") return canLoginAccessRepo(env, identity.actor, repoFullName); + if (identity.kind === "static" && identity.actor === "mcp") { + return isMcpReadRepoAllowed(env.MCP_READ_REPO_ALLOWLIST, repoFullName); + } + return true; +} + +async function requireApiRepoReadAccess( + c: ProtectedRouteContext, + identity: AuthIdentity, + repoFullName: string, +): Promise { + if (await canApiAccessRepo(c.env, identity, repoFullName)) return null; + return c.json({ error: "forbidden_repo" }, 403); +} + async function requireSessionRepoAccess( c: ProtectedRouteContext, identity: Extract, diff --git a/src/mcp/find-opportunities.ts b/src/mcp/find-opportunities.ts new file mode 100644 index 0000000000..0e6a310f31 --- /dev/null +++ b/src/mcp/find-opportunities.ts @@ -0,0 +1,252 @@ +// Hosted `gittensory_find_opportunities` (#2308): metadata-only cross-repo discovery that composes the +// opportunity fan-out (#2307), deterministic ranker (#2302), and goal-model signals from +// `@jsonbored/gittensory-engine` — never clones source, never uploads metadata, never writes to GitHub. +// Banned repos are hard-skipped upstream in fan-out AI-policy resolution; only `aiPolicyAllowed: true` +// rows are ever returned. + +import { + DEFAULT_MINER_GOAL_SPEC, + type MinerGoalSpec, +} from "../../packages/gittensory-engine/src/miner-goal-spec.js"; +import { + fetchCandidateIssuesWithSummary, + searchCandidateIssuesWithSummary, +} from "../../packages/gittensory-miner/lib/opportunity-fanout.js"; +import { rankCandidateIssuesWithSummary } from "../../packages/gittensory-miner/lib/opportunity-ranker.js"; +import { createInstallationToken } from "../github/app"; +import { getRepository } from "../db/repositories"; + +export type FindOpportunitiesTarget = { owner: string; repo: string }; + +export type FindOpportunitiesGoalSpec = { + lane?: string | undefined; + minRankScore?: number | undefined; + languages?: string[] | undefined; +}; + +export type FindOpportunitiesInput = { + targets?: FindOpportunitiesTarget[] | undefined; + searchQuery?: string | undefined; + goalSpec?: FindOpportunitiesGoalSpec | undefined; + limit?: number | undefined; +}; + +export type FindOpportunitiesRankedEntry = { + owner: string; + repo: string; + issueNumber: number; + title: string; + rankScore: number; + laneFit: number; + freshness: number; + dupRisk: number; + aiPolicyAllowed: true; +}; + +export type FindOpportunitiesResult = { + status: "ok" | "invalid_request" | "github_token_unavailable"; + ranked: FindOpportunitiesRankedEntry[]; + totalCandidates: number; + appliedLane?: string | undefined; + appliedMinRankScore?: number | undefined; + reason?: string | undefined; + warnings?: Array<{ repoFullName: string; stage: string; message: string }> | undefined; +}; + +const DEFAULT_LIMIT = 5; +const MAX_LIMIT = 50; + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +/** Public-safe 0–100 rank score derived from the ranker's 0–1 product score. */ +export function publicRankScore(rankScore: number): number { + return Math.round(clamp01(rankScore) * 100); +} + +export function normalizeFindOpportunitiesLimit(limit: number | null | undefined): number { + if (!Number.isFinite(limit)) return DEFAULT_LIMIT; + return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(limit!))); +} + +export function validateFindOpportunitiesInput( + input: FindOpportunitiesInput, +): { ok: true; value: FindOpportunitiesInput } | { ok: false; reason: string } { + const targets = Array.isArray(input.targets) ? input.targets : undefined; + const searchQuery = typeof input.searchQuery === "string" ? input.searchQuery.trim() : ""; + const hasTargets = Boolean(targets && targets.length > 0); + const hasSearch = searchQuery.length > 0; + if (!hasTargets && !hasSearch) { + return { ok: false, reason: "targets_or_search_query_required" }; + } + if (hasTargets) { + for (const target of targets!) { + const owner = typeof target?.owner === "string" ? target.owner.trim() : ""; + const repo = typeof target?.repo === "string" ? target.repo.trim() : ""; + if (!owner || !repo) return { ok: false, reason: "invalid_target" }; + } + } + if (hasSearch && searchQuery.length > 500) return { ok: false, reason: "search_query_too_long" }; + const minRankScore = input.goalSpec?.minRankScore; + if (minRankScore !== undefined && (!Number.isFinite(minRankScore) || minRankScore < 0 || minRankScore > 100)) { + return { ok: false, reason: "invalid_min_rank_score" }; + } + return { + ok: true, + value: { + ...(hasTargets ? { targets } : {}), + ...(hasSearch ? { searchQuery } : {}), + ...(input.goalSpec ? { goalSpec: input.goalSpec } : {}), + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }, + }; +} + +function buildGoalSpecsByRepo( + repoFullNames: readonly string[], + goalSpec: FindOpportunitiesGoalSpec | undefined, +): Record | undefined { + const lane = typeof goalSpec?.lane === "string" ? goalSpec.lane.trim() : ""; + const languages = Array.isArray(goalSpec?.languages) + ? goalSpec.languages.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : []; + if (!lane && languages.length === 0) return undefined; + const spec: MinerGoalSpec = { + ...DEFAULT_MINER_GOAL_SPEC, + ...(lane ? { preferredLabels: [lane] } : {}), + ...(languages.length > 0 + ? { wantedPaths: languages.map((language) => `**/*.${language.trim().toLowerCase()}`) } + : {}), + }; + const out: Record = {}; + for (const repoFullName of repoFullNames) out[repoFullName] = spec; + return out; +} + +function toRankedEntry( + issue: { + owner: string; + repo: string; + issueNumber: number; + title: string; + rankScore: number; + laneFit: number; + freshness: number; + dupRisk: number; + }, +): FindOpportunitiesRankedEntry { + return { + owner: issue.owner, + repo: issue.repo, + issueNumber: issue.issueNumber, + title: issue.title, + rankScore: publicRankScore(issue.rankScore), + laneFit: clamp01(issue.laneFit), + freshness: clamp01(issue.freshness), + dupRisk: clamp01(issue.dupRisk), + aiPolicyAllowed: true, + }; +} + +async function resolveDiscoveryGithubToken( + env: Env, + targets: readonly FindOpportunitiesTarget[], +): Promise<{ token: string | null; reposByFullName: Map>> }> { + const reposByFullName = new Map>>(); + for (const target of targets) { + const fullName = `${target.owner}/${target.repo}`; + reposByFullName.set(fullName, await getRepository(env, fullName)); + } + if (env.GITHUB_PUBLIC_TOKEN) return { token: env.GITHUB_PUBLIC_TOKEN, reposByFullName }; + for (const repo of reposByFullName.values()) { + const installationId = repo?.installationId; + if (!installationId) continue; + try { + return { token: await createInstallationToken(env, installationId), reposByFullName }; + } catch { + continue; + } + } + return { token: null, reposByFullName }; +} + +export async function runFindOpportunities( + env: Env, + input: FindOpportunitiesInput, + options: { + canAccessRepo?: ((repoFullName: string) => Promise | boolean) | undefined; + } = {}, +): Promise { + const validated = validateFindOpportunitiesInput(input); + if (!validated.ok) { + return { status: "invalid_request", ranked: [], totalCandidates: 0, reason: validated.reason }; + } + const parsed = validated.value; + const limit = normalizeFindOpportunitiesLimit(parsed.limit); + const minRankScore = parsed.goalSpec?.minRankScore ?? 0; + const appliedLane = parsed.goalSpec?.lane?.trim() || undefined; + + const targets = parsed.targets ?? []; + const { token, reposByFullName } = await resolveDiscoveryGithubToken(env, targets); + if (!token && targets.length > 0) { + const anyInstalled = [...reposByFullName.values()].some(Boolean); + if (!anyInstalled) { + return { + status: "github_token_unavailable", + ranked: [], + totalCandidates: 0, + reason: "github_token_unavailable", + }; + } + } + + let issues: Awaited>["issues"] = []; + let warnings: Array<{ repoFullName: string; stage: string; message: string }> = []; + if (parsed.searchQuery) { + const search = await searchCandidateIssuesWithSummary(parsed.searchQuery, token ?? "", {}); + issues = search.issues; + warnings = search.warnings; + } else { + const allowedTargets: FindOpportunitiesTarget[] = []; + for (const target of targets) { + const fullName = `${target.owner}/${target.repo}`; + if (options.canAccessRepo && !(await options.canAccessRepo(fullName))) continue; + allowedTargets.push(target); + } + if (allowedTargets.length === 0) { + return { status: "invalid_request", ranked: [], totalCandidates: 0, reason: "no_accessible_targets" }; + } + const fetched = await fetchCandidateIssuesWithSummary(allowedTargets, token ?? "", {}); + issues = fetched.issues; + warnings = fetched.warnings; + } + + if (parsed.searchQuery && options.canAccessRepo) { + const filtered = []; + for (const issue of issues) { + if (await options.canAccessRepo(issue.repoFullName)) filtered.push(issue); + } + issues = filtered; + } + + const repoFullNames = [...new Set(issues.map((issue) => issue.repoFullName))]; + const goalSpecsByRepo = buildGoalSpecsByRepo(repoFullNames, parsed.goalSpec); + const ranked = rankCandidateIssuesWithSummary(issues, { + ...(goalSpecsByRepo ? { goalSpecsByRepo } : {}), + }); + const filtered = ranked.issues + .map(toRankedEntry) + .filter((entry) => entry.rankScore >= minRankScore) + .slice(0, limit); + + return { + status: "ok", + ranked: filtered, + totalCandidates: ranked.issues.length, + ...(appliedLane ? { appliedLane } : {}), + ...(minRankScore > 0 ? { appliedMinRankScore: minRankScore } : {}), + ...(warnings.length > 0 ? { warnings } : {}), + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4645910093..93f8f9aaf9 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -4,7 +4,16 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { ElicitResultSchema, type ServerNotification, type ServerRequest } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; -import { authenticatePrivateToken, extractBearerToken, isMcpActuationRepoAllowed, isMcpReadRepoAllowed, isMcpReadUnscoped, type AuthIdentity } from "../auth/security"; +import { runFindOpportunities, validateFindOpportunitiesInput } from "./find-opportunities"; +import { + authenticatePrivateToken, + extractBearerToken, + isAuthorizedGitHubSessionLogin, + isMcpActuationRepoAllowed, + isMcpReadRepoAllowed, + isMcpReadUnscoped, + type AuthIdentity, +} from "../auth/security"; import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles"; import { countOpenIssues, @@ -202,6 +211,26 @@ 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), + }), + ) + .optional(), + searchQuery: z.string().min(1).max(500).optional(), + goalSpec: z + .object({ + lane: z.string().min(1).optional(), + minRankScore: z.number().min(0).max(100).optional(), + languages: z.array(z.string().min(1)).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(), @@ -916,6 +945,38 @@ const checkBeforeStartOutputSchema = { report: z.unknown().optional(), }; +const findOpportunitiesOutputSchema = { + status: z.string().optional(), + ranked: z + .array( + z.object({ + owner: z.string(), + repo: z.string(), + issueNumber: z.number(), + title: z.string(), + rankScore: z.number(), + laneFit: z.number(), + freshness: z.number(), + dupRisk: z.number(), + aiPolicyAllowed: z.literal(true), + }), + ) + .optional(), + totalCandidates: z.number().optional(), + appliedLane: z.string().optional(), + appliedMinRankScore: z.number().optional(), + reason: z.string().optional(), + warnings: z + .array( + z.object({ + repoFullName: z.string(), + stage: z.string(), + message: z.string(), + }), + ) + .optional(), +}; + const remediationPlanOutputSchema = { repoFullName: z.string().optional(), login: z.string().optional(), @@ -1410,6 +1471,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.checkBeforeStart(input)), ); + server.registerTool( + "gittensory_find_opportunities", + { + description: + "Metadata-only, no GitHub writes: discover and rank cross-repo open issues for miner targeting. Composes deterministic fan-out, AI-policy filtering (banned repos never appear), and opportunity ranking. Returns only public-safe fields — never raw reward/score internals.", + inputSchema: findOpportunitiesShape, + outputSchema: findOpportunitiesOutputSchema, + }, + async (input) => this.toolResult(await this.findOpportunities(input)), + ); + server.registerTool( "gittensory_lint_pr_text", { @@ -2130,6 +2202,47 @@ export class GittensoryMcp { }; } + private async findOpportunities(input: z.infer>): Promise { + const validated = validateFindOpportunitiesInput(input); + if (!validated.ok) { + return { + summary: "Invalid find-opportunities request.", + data: { status: "invalid_request", ranked: [], totalCandidates: 0, reason: validated.reason }, + }; + } + if (validated.value.searchQuery) { + await this.requireDiscoveryAccess(); + } else { + for (const target of validated.value.targets ?? []) { + await this.requireRepoAccess(`${target.owner}/${target.repo}`); + } + } + const result = await runFindOpportunities(this.env, validated.value, { + canAccessRepo: (repoFullName) => this.canAccessRepo(repoFullName), + }); + const count = result.ranked.length; + return { + summary: + result.status === "ok" + ? `Gittensory ranked ${count} metadata-only opportunit${count === 1 ? "y" : "ies"}.` + : "Gittensory could not rank opportunities for this request.", + data: result as unknown as Record, + }; + } + + /** Cross-repo search requires unscoped MCP read (wildcard allowlist) or operator/session authority. */ + private async requireDiscoveryAccess(): Promise { + if (this.identity.kind === "session") { + if (isAuthorizedGitHubSessionLogin(this.env, this.identity.actor)) return; + const scope = await this.loadSessionAccessScope(); + if (scope.operator) return; + throw new Error("Forbidden: cross-repo opportunity search requires operator or unscoped MCP read access."); + } + if (this.identity.kind === "static" && this.identity.actor === "mcp" && !isMcpReadUnscoped(this.env.MCP_READ_REPO_ALLOWLIST)) { + throw new Error("Forbidden: cross-repo opportunity search requires unscoped MCP read access."); + } + } + 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 33ec7dfd45..82540d7802 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1216,6 +1216,33 @@ describe("api routes", () => { const invalidLintPrText = await app.request("/v1/lint/pr-text", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ linkedIssue: -1 }) }, env); expect(invalidLintPrText.status).toBe(400); + const invalidFindOpportunities = await app.request( + "/v1/opportunities/find", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({}) }, + env, + ); + expect(invalidFindOpportunities.status).toBe(400); + await expect(invalidFindOpportunities.json()).resolves.toMatchObject({ + status: "invalid_request", + reason: "targets_or_search_query_required", + }); + + const { token: minerSessionToken } = await createSessionForGitHubUser(env, { login: "ordinary-mcp-user", id: 4243 }); + const minerSearchForbidden = await app.request( + "/v1/opportunities/find", + { + method: "POST", + headers: { authorization: `Bearer ${minerSessionToken}`, "content-type": "application/json" }, + body: JSON.stringify({ searchQuery: "test coverage" }), + }, + env, + ); + expect(minerSearchForbidden.status).toBe(403); + await expect(minerSearchForbidden.json()).resolves.toMatchObject({ + error: "forbidden", + reason: "cross_repo_search_requires_discovery_access", + }); + // Agent-native slop self-checks (mirror the gittensory_check_slop_risk / gittensory_check_issue_slop MCP tools). const slopRisk = await app.request( "/v1/lint/slop-risk", @@ -4011,6 +4038,30 @@ describe("api routes", () => { await expect(privateIssueFit.json()).resolves.toMatchObject({ error: "forbidden_repo" }); const privateIssueBadges = await app.request("/v1/extension/contributors/contributor-dev/issue-badges?owner=victim-org&repo=secret", { headers: bearer }, env); expect(privateIssueBadges.status).toBe(403); + + const extensionFindOpportunities = await app.request( + "/v1/opportunities/find", + { + method: "POST", + headers: { ...bearer, "content-type": "application/json" }, + body: JSON.stringify({ searchQuery: "test coverage" }), + }, + env, + ); + expect(extensionFindOpportunities.status).toBe(403); + await expect(extensionFindOpportunities.json()).resolves.toMatchObject({ error: "insufficient_scope" }); + + const extensionTargetedFind = await app.request( + "/v1/opportunities/find", + { + method: "POST", + headers: { ...bearer, "content-type": "application/json" }, + body: JSON.stringify({ targets: [{ owner: "octo", repo: "demo" }] }), + }, + env, + ); + expect(extensionTargetedFind.status).toBe(403); + await expect(extensionTargetedFind.json()).resolves.toMatchObject({ error: "insufficient_scope" }); await expect(privateIssueBadges.json()).resolves.toMatchObject({ error: "forbidden_repo" }); expect((await app.request("/v1/extension/contributors/contributor-dev/pr-status?owner=victim-org&repo=secret&pullNumber=101", { headers: bearer }, env)).status).toBe(403); expect((await app.request("/v1/extension/contributors/contributor-dev/pr-status?owner=victim-org&repo=secret&pullNumber=999", { headers: bearer }, env)).status).toBe(403); @@ -5075,6 +5126,7 @@ describe("api routes", () => { expect(toolNames).toContain("gittensory_get_decision_pack"); expect(toolNames).toContain("gittensory_explain_repo_decision"); expect(toolNames).toContain("gittensory_preflight_pr"); + expect(toolNames).toContain("gittensory_find_opportunities"); expect(toolNames).toContain("gittensory_preflight_local_diff"); expect(toolNames).toContain("gittensory_preview_local_pr_score"); expect(toolNames).toContain("gittensory_explain_score_breakdown"); @@ -5098,7 +5150,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/find-opportunities.test.ts b/test/unit/find-opportunities.test.ts new file mode 100644 index 0000000000..e921719c65 --- /dev/null +++ b/test/unit/find-opportunities.test.ts @@ -0,0 +1,154 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + normalizeFindOpportunitiesLimit, + publicRankScore, + runFindOpportunities, + validateFindOpportunitiesInput, +} from "../../src/mcp/find-opportunities"; +import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), "../fixtures/ai-policy"); + +function readFixture(name: string): string { + return readFileSync(join(fixtureDir, name), "utf8"); +} + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return Response.json(body, { + ...init, + headers: { + "x-ratelimit-remaining": "42", + "x-ratelimit-reset": "1800000000", + ...(init.headers ?? {}), + }, + }); +} + +function contentResponse(content: string) { + return jsonResponse({ + type: "file", + encoding: "base64", + content: Buffer.from(content, "utf8").toString("base64"), + }); +} + +const issue = (number: number) => ({ + number, + title: `Issue ${number}`, + labels: ["good first issue"], + comments: 1, + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T01:00:00Z", + html_url: `https://github.com/acme/allowed/issues/${number}`, +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("validateFindOpportunitiesInput", () => { + it("requires targets or searchQuery", () => { + expect(validateFindOpportunitiesInput({})).toEqual({ ok: false, reason: "targets_or_search_query_required" }); + }); + + it("rejects invalid targets and oversized search queries", () => { + expect(validateFindOpportunitiesInput({ targets: [{ owner: "", repo: "demo" }] })).toEqual({ ok: false, reason: "invalid_target" }); + expect(validateFindOpportunitiesInput({ searchQuery: "x".repeat(501) })).toEqual({ ok: false, reason: "search_query_too_long" }); + expect( + validateFindOpportunitiesInput({ searchQuery: "docs", goalSpec: { minRankScore: 101 } }), + ).toEqual({ ok: false, reason: "invalid_min_rank_score" }); + }); + + it("accepts trimmed targets and search queries", () => { + const parsed = validateFindOpportunitiesInput({ + targets: [{ owner: " acme ", repo: " widgets " }], + goalSpec: { lane: "docs", minRankScore: 40 }, + limit: 3, + }); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.value.targets?.[0]).toEqual({ owner: " acme ", repo: " widgets " }); + expect(parsed.value.goalSpec).toEqual({ lane: "docs", minRankScore: 40 }); + expect(parsed.value.limit).toBe(3); + } + }); +}); + +describe("find-opportunities helpers", () => { + it("normalizes limits and public rank scores", () => { + expect(normalizeFindOpportunitiesLimit(undefined)).toBe(5); + expect(normalizeFindOpportunitiesLimit(99)).toBe(50); + expect(normalizeFindOpportunitiesLimit(0)).toBe(1); + expect(publicRankScore(0.876)).toBe(88); + expect(publicRankScore(Number.NaN)).toBe(0); + }); +}); + +describe("runFindOpportunities", () => { + it("hard-skips banned repos before returning ranked opportunities", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "test-token" }); + const bannedPolicy = readFixture("banned-ai-usage.md"); + const allowedPolicy = readFixture("allowed-silent.md"); + + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/repos/acme/banned/contents/AI-USAGE.md")) return contentResponse(bannedPolicy); + if (url.includes("/repos/acme/banned/issues?")) throw new Error("banned repo must be hard-skipped"); + if (url.includes("/repos/acme/allowed/contents/AI-USAGE.md")) return jsonResponse({}, { status: 404 }); + if (url.includes("/repos/acme/allowed/contents/CONTRIBUTING.md")) return contentResponse(allowedPolicy); + if (url.includes("/repos/acme/allowed/issues?")) return jsonResponse([issue(7)]); + return jsonResponse({}, { status: 404 }); + }); + + const result = await runFindOpportunities(env, { + targets: [ + { owner: "acme", repo: "banned" }, + { owner: "acme", repo: "allowed" }, + ], + }); + + expect(result.status).toBe("ok"); + expect(result.ranked.map((entry) => `${entry.owner}/${entry.repo}#${entry.issueNumber}`)).toEqual(["acme/allowed#7"]); + expect(result.ranked.every((entry) => entry.aiPolicyAllowed === true)).toBe(true); + expect(JSON.stringify(result)).not.toMatch(/wallet|hotkey|reward estimate/i); + }); + + it("returns github_token_unavailable when no token and no installed targets", async () => { + const env = createTestEnv(); + const result = await runFindOpportunities(env, { targets: [{ owner: "missing", repo: "repo" }] }); + expect(result).toMatchObject({ + status: "github_token_unavailable", + ranked: [], + totalCandidates: 0, + reason: "github_token_unavailable", + }); + }); + + it("filters inaccessible targets via canAccessRepo", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "test-token" }); + await upsertRepositoryFromGitHub(env, { name: "allowed", full_name: "acme/allowed" }); + const allowedPolicy = readFixture("allowed-silent.md"); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/repos/acme/allowed/contents/AI-USAGE.md")) return jsonResponse({}, { status: 404 }); + if (url.includes("/repos/acme/allowed/contents/CONTRIBUTING.md")) return contentResponse(allowedPolicy); + if (url.includes("/repos/acme/allowed/issues?")) return jsonResponse([issue(3)]); + return jsonResponse({}, { status: 404 }); + }); + + const blocked = await runFindOpportunities(env, { targets: [{ owner: "acme", repo: "blocked" }] }, { canAccessRepo: async () => false }); + expect(blocked).toMatchObject({ status: "invalid_request", reason: "no_accessible_targets" }); + + const allowed = await runFindOpportunities(env, { targets: [{ owner: "acme", repo: "allowed" }] }, { canAccessRepo: async () => true }); + expect(allowed.status).toBe("ok"); + expect(allowed.ranked).toHaveLength(1); + }); +}); diff --git a/test/unit/mcp-find-opportunities.test.ts b/test/unit/mcp-find-opportunities.test.ts new file mode 100644 index 0000000000..9ffeb49b6c --- /dev/null +++ b/test/unit/mcp-find-opportunities.test.ts @@ -0,0 +1,151 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSessionForGitHubUser, type AuthIdentity } from "../../src/auth/security"; +import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { GittensoryMcp } from "../../src/mcp/server"; +import { createTestEnv } from "../helpers/d1"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), "../fixtures/ai-policy"); + +function readFixture(name: string): string { + return readFileSync(join(fixtureDir, name), "utf8"); +} + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return Response.json(body, { + ...init, + headers: { + "x-ratelimit-remaining": "42", + "x-ratelimit-reset": "1800000000", + ...(init.headers ?? {}), + }, + }); +} + +function contentResponse(content: string) { + return jsonResponse({ + type: "file", + encoding: "base64", + content: Buffer.from(content, "utf8").toString("base64"), + }); +} + +const issue = (number: number) => ({ + number, + title: `Issue ${number}`, + labels: ["good first issue"], + comments: 1, + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T01:00:00Z", + html_url: `https://github.com/acme/allowed/issues/${number}`, +}); + +async function connect(env: Env, identity?: AuthIdentity) { + const server = new GittensoryMcp(env, identity).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "gittensory-find-opportunities-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("MCP gittensory_find_opportunities", () => { + it("registers the tool and rejects empty requests", async () => { + const env = createTestEnv(); + const client = await connect(env); + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toContain("gittensory_find_opportunities"); + + const invalid = await client.callTool({ name: "gittensory_find_opportunities", arguments: {} }); + expect(invalid.isError).toBeFalsy(); + expect(invalid.structuredContent).toMatchObject({ + status: "invalid_request", + reason: "targets_or_search_query_required", + ranked: [], + }); + }); + + it("returns a public-safe ranked list and never includes banned repos", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "test-token" }); + const bannedPolicy = readFixture("banned-ai-usage.md"); + const allowedPolicy = readFixture("allowed-silent.md"); + + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/repos/acme/banned/contents/AI-USAGE.md")) return contentResponse(bannedPolicy); + if (url.includes("/repos/acme/banned/issues?")) throw new Error("banned repo must be hard-skipped"); + if (url.includes("/repos/acme/allowed/contents/AI-USAGE.md")) return jsonResponse({}, { status: 404 }); + if (url.includes("/repos/acme/allowed/contents/CONTRIBUTING.md")) return contentResponse(allowedPolicy); + if (url.includes("/repos/acme/allowed/issues?")) return jsonResponse([issue(11)]); + return jsonResponse({}, { status: 404 }); + }); + + const client = await connect(env); + const result = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { + targets: [ + { owner: "acme", repo: "banned" }, + { owner: "acme", repo: "allowed" }, + ], + limit: 2, + }, + }); + + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { + status: string; + ranked: Array<{ owner: string; repo: string; issueNumber: number; rankScore: number; aiPolicyAllowed: true }>; + }; + expect(data.status).toBe("ok"); + expect(data.ranked.map((entry) => `${entry.owner}/${entry.repo}`)).toEqual(["acme/allowed"]); + expect(data.ranked[0]?.aiPolicyAllowed).toBe(true); + expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward estimate|trust score/i); + }); + + it("rejects cross-repo search for non-operator sessions", async () => { + const env = createTestEnv(); + const { session } = await createSessionForGitHubUser(env, { login: "miner1", id: 1 }); + const client = await connect(env, { kind: "session", actor: "miner1", session }); + + const result = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { searchQuery: "test coverage" }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toMatch(/cross-repo opportunity search/i); + }); + + it("rejects extension-contributor sessions for search and out-of-scope targets", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "private-roadmap", full_name: "victimco/private-roadmap", private: true, owner: { login: "victimco" } }); + const { session } = await createSessionForGitHubUser(env, { login: "contributor-dev", id: 555 }, { scopes: ["extension:contributor_context"] }); + const client = await connect(env, { kind: "session", actor: "contributor-dev", session }); + + const search = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { searchQuery: "test coverage" }, + }); + expect(search.isError).toBe(true); + expect(JSON.stringify(search.content)).toMatch(/cross-repo opportunity search/i); + + const targets = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { targets: [{ owner: "victimco", repo: "private-roadmap" }] }, + }); + expect(targets.isError).toBe(true); + expect(JSON.stringify(targets.content)).toMatch(/session cannot access this repository/i); + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index b8895672bb..ccb925f0c4 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",