From b42e38f3c9e0a0cca8e0d84fd4f20f690cf55cdb Mon Sep 17 00:00:00 2001 From: andriypolandki <=> Date: Tue, 7 Jul 2026 01:49:46 -0700 Subject: [PATCH 1/4] feat(mcp): add gittensory_find_opportunities to hosted Worker (#2308) --- src/api/routes.ts | 65 +++++- src/mcp/find-opportunities.ts | 250 +++++++++++++++++++++++ src/mcp/server.ts | 115 ++++++++++- test/integration/api.test.ts | 13 +- test/unit/find-opportunities.test.ts | 154 ++++++++++++++ test/unit/mcp-find-opportunities.test.ts | 115 +++++++++++ test/unit/mcp-output-schemas.test.ts | 1 + 7 files changed, 710 insertions(+), 3 deletions(-) create mode 100644 src/mcp/find-opportunities.ts create mode 100644 test/unit/find-opportunities.test.ts create mode 100644 test/unit/mcp-find-opportunities.test.ts diff --git a/src/api/routes.ts b/src/api/routes.ts index 30c63dd4fe..fefe3b9c41 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, @@ -905,7 +908,9 @@ export function createApp() { // Contributor extension tokens are STRICTLY self-only: like the pull-context token above, they are // confined to their own surface and may not reach any other path (control-panel /v1/app/*, the // session-mint endpoint, etc.). Without this they would be LESS confined than the maintainer token. - if (isExtensionContributorScopedSession(identity) && !isExtensionContributorContextPath(c.req.path)) return c.json({ error: "insufficient_scope" }, 403); + if (isExtensionContributorScopedSession(identity) && !isExtensionContributorContextPath(c.req.path) && c.req.path !== OPPORTUNITIES_FIND_PATH) { + return c.json({ error: "insufficient_scope" }, 403); + } return next(); }); @@ -2781,6 +2786,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 +5169,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"; @@ -5205,6 +5236,7 @@ function canSessionAccessPath(env: Env, identity: Extract/*`; the handler's @@ -5351,6 +5383,37 @@ 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; + if (isExtensionContributorScopedSession(identity)) 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..4416ecd7f6 --- /dev/null +++ b/src/mcp/find-opportunities.ts @@ -0,0 +1,250 @@ +// 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 { + if (env.GITHUB_PUBLIC_TOKEN) return env.GITHUB_PUBLIC_TOKEN; + for (const target of targets) { + const fullName = `${target.owner}/${target.repo}`; + const repo = await getRepository(env, fullName); + const installationId = repo?.installationId; + if (!installationId) continue; + try { + return await createInstallationToken(env, installationId); + } catch { + continue; + } + } + return null; +} + +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 = await resolveDiscoveryGithubToken(env, targets); + if (!token && targets.length > 0) { + const anyInstalled = await Promise.all( + targets.map(async (target) => Boolean(await getRepository(env, `${target.owner}/${target.repo}`))), + ); + if (!anyInstalled.some(Boolean)) { + 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 ranked = rankCandidateIssuesWithSummary(issues, { + goalSpecsByRepo: buildGoalSpecsByRepo(repoFullNames, parsed.goalSpec), + }); + 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..f33994e23a 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; + if (this.identity.session.scopes.includes("extension:contributor_context")) return; + const scope = await this.loadSessionAccessScope(); + if (scope.operator) return; + } + 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..5edb703fc9 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1216,6 +1216,17 @@ 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", + }); + // 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", @@ -5075,6 +5086,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 +5110,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..4cc925257c --- /dev/null +++ b/test/unit/mcp-find-opportunities.test.ts @@ -0,0 +1,115 @@ +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 { 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) { + const server = new GittensoryMcp(env).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); + }); +}); 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", From 05081653fbe9704d7687a4f2be31d031aa7fa026 Mon Sep 17 00:00:00 2001 From: andriypolandki <=> Date: Tue, 7 Jul 2026 02:07:01 -0700 Subject: [PATCH 2/4] fix --- src/api/routes.ts | 4 ++-- src/mcp/find-opportunities.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/api/routes.ts b/src/api/routes.ts index fefe3b9c41..5ce107bca5 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -5389,10 +5389,10 @@ async function requireDiscoveryAccessForApi(c: ProtectedRouteContext, identity: if (isExtensionContributorScopedSession(identity)) 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); + return c.json({ error: "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 c.json({ error: "cross_repo_search_requires_unscoped_mcp_read" }, 403); } return null; } diff --git a/src/mcp/find-opportunities.ts b/src/mcp/find-opportunities.ts index 4416ecd7f6..ae3b7e9203 100644 --- a/src/mcp/find-opportunities.ts +++ b/src/mcp/find-opportunities.ts @@ -231,9 +231,8 @@ export async function runFindOpportunities( } const repoFullNames = [...new Set(issues.map((issue) => issue.repoFullName))]; - const ranked = rankCandidateIssuesWithSummary(issues, { - goalSpecsByRepo: buildGoalSpecsByRepo(repoFullNames, parsed.goalSpec), - }); + const goalSpecsByRepo = buildGoalSpecsByRepo(repoFullNames, parsed.goalSpec); + const ranked = rankCandidateIssuesWithSummary(issues, goalSpecsByRepo ? { goalSpecsByRepo } : {}); const filtered = ranked.issues .map(toRankedEntry) .filter((entry) => entry.rankScore >= minRankScore) From c96c05c2045cac2772b46487b880127a6d94694d Mon Sep 17 00:00:00 2001 From: andriypolandki <=> Date: Tue, 7 Jul 2026 02:24:24 -0700 Subject: [PATCH 3/4] clean failed tests --- src/api/routes.ts | 9 ++---- src/mcp/find-opportunities.ts | 26 ++++++++------- src/mcp/server.ts | 2 +- test/integration/api.test.ts | 40 ++++++++++++++++++++++++ test/unit/mcp-find-opportunities.test.ts | 40 ++++++++++++++++++++++-- 5 files changed, 96 insertions(+), 21 deletions(-) diff --git a/src/api/routes.ts b/src/api/routes.ts index 5ce107bca5..e1ab491874 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -908,9 +908,7 @@ export function createApp() { // Contributor extension tokens are STRICTLY self-only: like the pull-context token above, they are // confined to their own surface and may not reach any other path (control-panel /v1/app/*, the // session-mint endpoint, etc.). Without this they would be LESS confined than the maintainer token. - if (isExtensionContributorScopedSession(identity) && !isExtensionContributorContextPath(c.req.path) && c.req.path !== OPPORTUNITIES_FIND_PATH) { - return c.json({ error: "insufficient_scope" }, 403); - } + if (isExtensionContributorScopedSession(identity) && !isExtensionContributorContextPath(c.req.path)) return c.json({ error: "insufficient_scope" }, 403); return next(); }); @@ -5386,13 +5384,12 @@ async function requireExtensionPullContextRepoAccess( async function requireDiscoveryAccessForApi(c: ProtectedRouteContext, identity: AuthIdentity): Promise { if (identity.kind === "session") { if (isAuthorizedGitHubSessionLogin(c.env, identity.actor)) return null; - if (isExtensionContributorScopedSession(identity)) return null; const scope = await loadControlPanelAccessScope(c.env, identity.actor); if (scope.operator) return null; - return c.json({ error: "cross_repo_search_requires_discovery_access" }, 403); + 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: "cross_repo_search_requires_unscoped_mcp_read" }, 403); + return c.json({ error: "forbidden", reason: "cross_repo_search_requires_unscoped_mcp_read" }, 403); } return null; } diff --git a/src/mcp/find-opportunities.ts b/src/mcp/find-opportunities.ts index ae3b7e9203..43d8827c30 100644 --- a/src/mcp/find-opportunities.ts +++ b/src/mcp/find-opportunities.ts @@ -153,20 +153,23 @@ function toRankedEntry( async function resolveDiscoveryGithubToken( env: Env, targets: readonly FindOpportunitiesTarget[], -): Promise { - if (env.GITHUB_PUBLIC_TOKEN) return env.GITHUB_PUBLIC_TOKEN; +): Promise<{ token: string | null; reposByFullName: Map>> }> { + const reposByFullName = new Map>>(); for (const target of targets) { const fullName = `${target.owner}/${target.repo}`; - const repo = await getRepository(env, fullName); + 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 await createInstallationToken(env, installationId); + return { token: await createInstallationToken(env, installationId), reposByFullName }; } catch { continue; } } - return null; + return { token: null, reposByFullName }; } export async function runFindOpportunities( @@ -186,12 +189,10 @@ export async function runFindOpportunities( const appliedLane = parsed.goalSpec?.lane?.trim() || undefined; const targets = parsed.targets ?? []; - const token = await resolveDiscoveryGithubToken(env, targets); + const { token, reposByFullName } = await resolveDiscoveryGithubToken(env, targets); if (!token && targets.length > 0) { - const anyInstalled = await Promise.all( - targets.map(async (target) => Boolean(await getRepository(env, `${target.owner}/${target.repo}`))), - ); - if (!anyInstalled.some(Boolean)) { + const anyInstalled = [...reposByFullName.values()].some(Boolean); + if (!anyInstalled) { return { status: "github_token_unavailable", ranked: [], @@ -231,8 +232,9 @@ export async function runFindOpportunities( } const repoFullNames = [...new Set(issues.map((issue) => issue.repoFullName))]; - const goalSpecsByRepo = buildGoalSpecsByRepo(repoFullNames, parsed.goalSpec); - const ranked = rankCandidateIssuesWithSummary(issues, goalSpecsByRepo ? { goalSpecsByRepo } : {}); + const ranked = rankCandidateIssuesWithSummary(issues, { + goalSpecsByRepo: buildGoalSpecsByRepo(repoFullNames, parsed.goalSpec), + }); const filtered = ranked.issues .map(toRankedEntry) .filter((entry) => entry.rankScore >= minRankScore) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f33994e23a..93f8f9aaf9 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2234,9 +2234,9 @@ export class GittensoryMcp { private async requireDiscoveryAccess(): Promise { if (this.identity.kind === "session") { if (isAuthorizedGitHubSessionLogin(this.env, this.identity.actor)) return; - if (this.identity.session.scopes.includes("extension:contributor_context")) 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."); diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 5edb703fc9..82540d7802 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1227,6 +1227,22 @@ describe("api routes", () => { 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", @@ -4022,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); diff --git a/test/unit/mcp-find-opportunities.test.ts b/test/unit/mcp-find-opportunities.test.ts index 4cc925257c..9ffeb49b6c 100644 --- a/test/unit/mcp-find-opportunities.test.ts +++ b/test/unit/mcp-find-opportunities.test.ts @@ -4,6 +4,8 @@ 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"; @@ -46,8 +48,8 @@ const issue = (number: number) => ({ html_url: `https://github.com/acme/allowed/issues/${number}`, }); -async function connect(env: Env) { - const server = new GittensoryMcp(env).createServer(); +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: {} }); @@ -112,4 +114,38 @@ describe("MCP gittensory_find_opportunities", () => { 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); + }); }); From 086d1cb14ffa640b706656440800a5317846c53e Mon Sep 17 00:00:00 2001 From: andriypolandki <=> Date: Tue, 7 Jul 2026 02:32:14 -0700 Subject: [PATCH 4/4] npm run typecheck pass --- src/api/routes.ts | 2 +- src/mcp/find-opportunities.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/api/routes.ts b/src/api/routes.ts index e1ab491874..dbd3c3ca6e 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -5180,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 { diff --git a/src/mcp/find-opportunities.ts b/src/mcp/find-opportunities.ts index 43d8827c30..0e6a310f31 100644 --- a/src/mcp/find-opportunities.ts +++ b/src/mcp/find-opportunities.ts @@ -232,8 +232,9 @@ export async function runFindOpportunities( } const repoFullNames = [...new Set(issues.map((issue) => issue.repoFullName))]; + const goalSpecsByRepo = buildGoalSpecsByRepo(repoFullNames, parsed.goalSpec); const ranked = rankCandidateIssuesWithSummary(issues, { - goalSpecsByRepo: buildGoalSpecsByRepo(repoFullNames, parsed.goalSpec), + ...(goalSpecsByRepo ? { goalSpecsByRepo } : {}), }); const filtered = ranked.issues .map(toRankedEntry)