diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e4239ede01..43a9569953 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,6 +3,7 @@ import type { Context } from "hono"; 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 { DEFAULT_MINER_GOAL_SPEC, rankOpportunities, type MinerGoalSpec, type OpportunityRankInput } from "@jsonbored/gittensory-engine"; import { z } from "zod"; import { authenticatePrivateToken, extractBearerToken, isMcpActuationRepoAllowed, isMcpReadRepoAllowed, isMcpReadUnscoped, type AuthIdentity } from "../auth/security"; import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles"; @@ -101,6 +102,7 @@ import { buildQueueHealth, buildRegistryChangeReport, buildRoleContext, + type LaneAdvice, } from "../signals/engine"; import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; @@ -125,6 +127,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 type { IssueRecord, PullRequestRecord, RepositoryRecord } from "../types"; type AppContext = Context<{ Bindings: Env }>; type ToolPayload = { @@ -133,6 +136,18 @@ type ToolPayload = { }; type McpToolExtra = RequestHandlerExtra; +const FIND_OPPORTUNITIES_DEFAULT_LIMIT = 10; +const FIND_OPPORTUNITIES_MAX_LIMIT = 25; +const FIND_OPPORTUNITIES_MAX_TARGETS = 25; +const FIND_OPPORTUNITIES_MAX_SEARCH_REPOS = 50; +const FIND_OPPORTUNITY_LANE_FIT: Record = { + direct_pr: 1, + split: 0.9, + issue_discovery: 0.65, + inactive: 0.25, + unknown: 0.25, +}; + function decisionPackSummary(login: string, freshness: string, rebuildEnqueued: boolean): string { if (freshness === "fresh") return `Gittensory decision pack for ${login}.`; if (rebuildEnqueued) return `Gittensory decision pack for ${login} (stale; background rebuild enqueued).`; @@ -197,6 +212,34 @@ const checkBeforeStartShape = { plannedPaths: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), }; +const minerGoalSpecShape = z + .object({ + minerEnabled: z.boolean().optional(), + wantedPaths: z.array(z.string().min(1).max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), + blockedPaths: z.array(z.string().min(1).max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), + preferredLabels: z.array(z.string().min(1).max(PREFLIGHT_LIMITS.labelChars)).max(PREFLIGHT_LIMITS.labels).optional(), + maxConcurrentClaims: z.number().int().positive().optional(), + issueDiscoveryPolicy: z.enum(["encouraged", "neutral", "discouraged"]).optional(), + }) + .strict(); + +const findOpportunitiesShape = { + targets: z + .array( + z + .object({ + owner: z.string().min(1).max(100), + repo: z.string().min(1).max(100), + }) + .strict(), + ) + .max(FIND_OPPORTUNITIES_MAX_TARGETS) + .optional(), + searchQuery: z.string().min(1).max(200).optional(), + goalSpec: minerGoalSpecShape.optional(), + limit: z.number().int().min(1).max(FIND_OPPORTUNITIES_MAX_LIMIT).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 +903,26 @@ const checkBeforeStartOutputSchema = { report: z.unknown().optional(), }; +const findOpportunitiesOutputSchema = { + source: z.literal("cached_metadata"), + searchedRepositories: z.number(), + candidateCount: z.number(), + opportunities: 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), + }), + ), + warnings: z.array(z.string()).optional(), +}; + const remediationPlanOutputSchema = { repoFullName: z.string().optional(), login: z.string().optional(), @@ -1000,6 +1063,144 @@ const agentExplainNextActionOutputSchema = { topAction: z.unknown().optional(), }; +type FindOpportunitiesInput = z.infer>; +type FindOpportunitiesGoalSpec = NonNullable; +type FindOpportunityRecord = { + owner: string; + repo: string; + issueNumber: number; + title: string; + rankScore: number; + laneFit: number; + freshness: number; + dupRisk: number; + aiPolicyAllowed: true; +}; +type FindOpportunityCandidate = Omit & OpportunityRankInput; + +function boundedOpportunityLimit(limit: number | undefined): number { + return Math.min(FIND_OPPORTUNITIES_MAX_LIMIT, Math.max(1, limit ?? FIND_OPPORTUNITIES_DEFAULT_LIMIT)); +} + +function normalizedFindOpportunityTargets(targets: FindOpportunitiesInput["targets"]): Array<{ owner: string; repo: string; fullName: string }> { + const seen = new Set(); + return (targets ?? []).flatMap((target) => { + const owner = target.owner.trim(); + const repo = target.repo.trim(); + const fullName = `${owner}/${repo}`; + const key = fullName.toLowerCase(); + if (!owner || !repo || seen.has(key)) return []; + seen.add(key); + return [{ owner, repo, fullName }]; + }); +} + +function normalizedSearchTerms(searchQuery: string | undefined): string[] { + return (searchQuery ?? "") + .toLowerCase() + .split(/\s+/) + .map((term) => term.trim()) + .filter(Boolean); +} + +function issueMatchesSearch(issue: IssueRecord, repo: RepositoryRecord, terms: string[]): boolean { + if (terms.length === 0) return true; + const haystack = [repo.fullName, issue.title, issue.body ?? "", ...issue.labels].join(" ").toLowerCase(); + return terms.every((term) => haystack.includes(term)); +} + +function hasLabelOverlap(left: readonly string[] | undefined, right: readonly string[]): boolean { + const wanted = new Set((left ?? []).map((label) => label.toLowerCase())); + return wanted.size > 0 && right.some((label) => wanted.has(label.toLowerCase())); +} + +function signal(value: number): number { + return Math.round(Math.min(1, Math.max(0, value)) * 10_000) / 10_000; +} + +function normalizedMinerGoalSpec(goalSpec: FindOpportunitiesGoalSpec | undefined): MinerGoalSpec { + return { + minerEnabled: goalSpec?.minerEnabled ?? DEFAULT_MINER_GOAL_SPEC.minerEnabled, + wantedPaths: goalSpec?.wantedPaths ?? DEFAULT_MINER_GOAL_SPEC.wantedPaths, + blockedPaths: goalSpec?.blockedPaths ?? DEFAULT_MINER_GOAL_SPEC.blockedPaths, + preferredLabels: goalSpec?.preferredLabels ?? DEFAULT_MINER_GOAL_SPEC.preferredLabels, + maxConcurrentClaims: goalSpec?.maxConcurrentClaims ?? DEFAULT_MINER_GOAL_SPEC.maxConcurrentClaims, + issueDiscoveryPolicy: goalSpec?.issueDiscoveryPolicy ?? DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy, + }; +} + +function issueFreshness(issue: IssueRecord, nowMs: number): number { + const timestamp = Date.parse(String(issue.updatedAt)); + if (!Number.isFinite(timestamp)) return 0.5; + const ageDays = Math.max(0, (nowMs - timestamp) / 86_400_000); + return signal(1 - Math.min(0.9, ageDays / 100)); +} + +function issueDupRisk(issue: IssueRecord, openPullRequests: PullRequestRecord[]): number { + const linked = issue.linkedPrs.length > 0 || openPullRequests.some((pullRequest) => pullRequest.linkedIssues.includes(issue.number)); + if (linked) return 1; + return signal(openPullRequests.length / 10); +} + +function issueLaneFit(repo: RepositoryRecord, issue: IssueRecord, goalSpec: MinerGoalSpec): number { + const lane = buildLaneAdvice(repo, repo.fullName).lane; + const base = FIND_OPPORTUNITY_LANE_FIT[lane]; + const policyAdjustment = goalSpec.issueDiscoveryPolicy === "discouraged" && lane === "issue_discovery" ? -0.3 : 0; + const labelAdjustment = hasLabelOverlap(goalSpec.preferredLabels, issue.labels) ? 0.1 : 0; + return signal(base + policyAdjustment + labelAdjustment); +} + +function issuePotential(issue: IssueRecord, goalSpec: MinerGoalSpec): number { + const maintainerAuthored = ["OWNER", "MEMBER", "COLLABORATOR"].includes((issue.authorAssociation ?? "").toUpperCase()); + const labelBoost = hasLabelOverlap(goalSpec.preferredLabels, issue.labels) ? 0.1 : 0; + return signal((maintainerAuthored ? 0.95 : 0.75) + labelBoost); +} + +function issueFeasibility(issue: IssueRecord): number { + const labels = issue.labels.map((label) => label.toLowerCase()); + if (labels.some((label) => /duplicate|invalid|wontfix|not planned|won't fix/.test(label))) return 0; + if (labels.some((label) => /needs[-\s]?proof|blocked|question/.test(label))) return 0.55; + return 0.9; +} + +function buildFindOpportunityRecord( + repo: RepositoryRecord, + issue: IssueRecord, + openPullRequests: PullRequestRecord[], + goalSpec: MinerGoalSpec, + nowMs: number, +): FindOpportunityCandidate { + const laneFit = issueLaneFit(repo, issue, goalSpec); + const freshness = issueFreshness(issue, nowMs); + const dupRisk = issueDupRisk(issue, openPullRequests); + return { + owner: repo.owner, + repo: repo.name, + issueNumber: issue.number, + title: sanitizePublicComment(issue.title), + potential: issuePotential(issue, goalSpec), + feasibility: issueFeasibility(issue), + laneFit, + freshness, + dupRisk, + aiPolicyAllowed: true, + }; +} + +function toFindOpportunityRecord(candidate: FindOpportunityCandidate & { rankScore: number }): FindOpportunityRecord { + return { + owner: candidate.owner, + repo: candidate.repo, + issueNumber: candidate.issueNumber, + title: candidate.title, + rankScore: signal(candidate.rankScore), + laneFit: candidate.laneFit, + freshness: candidate.freshness, + dupRisk: candidate.dupRisk, + aiPolicyAllowed: true, + }; +} + export async function handleMcpRequest(c: AppContext): Promise { if (c.req.method === "OPTIONS") return new Response(null, { status: 204 }); const identity = await authenticateMcpRequest(c); @@ -1343,6 +1544,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.checkBeforeStart(input)), ); + server.registerTool( + "gittensory_find_opportunities", + { + description: + "Find ranked contributor opportunities from cached repo and issue metadata. Metadata-only, no source upload, no GitHub writes.", + inputSchema: findOpportunitiesShape, + outputSchema: findOpportunitiesOutputSchema, + }, + async (input) => this.toolResult(await this.findOpportunities(input)), + ); + server.registerTool( "gittensory_lint_pr_text", { @@ -2032,6 +2244,83 @@ export class GittensoryMcp { }; } + private async findOpportunities(input: FindOpportunitiesInput): Promise { + const targets = normalizedFindOpportunityTargets(input.targets); + const searchTerms = normalizedSearchTerms(input.searchQuery); + if (targets.length === 0 && searchTerms.length === 0) throw new Error("targets_or_search_query_required"); + + const limit = boundedOpportunityLimit(input.limit); + const goalSpec = normalizedMinerGoalSpec(input.goalSpec); + if (!goalSpec.minerEnabled) { + return { + summary: "Gittensory found 0 ranked opportunity/opportunities because the supplied goalSpec disables miner targeting.", + data: { + source: "cached_metadata", + searchedRepositories: 0, + candidateCount: 0, + opportunities: [], + warnings: ["goalSpec.minerEnabled is false; no repositories were scanned."], + }, + }; + } + + const repositoriesByName = new Map(); + const warnings: string[] = []; + for (const target of targets) { + const repository = await getRepository(this.env, target.fullName); + if (!repository) { + warnings.push(`Skipping ${target.fullName}: repository is not cached.`); + continue; + } + repositoriesByName.set(repository.fullName.toLowerCase(), repository); + } + if (searchTerms.length > 0) { + const searchableRepositories = (await listRepositories(this.env)) + .filter((candidate) => candidate.isRegistered) + .slice(0, FIND_OPPORTUNITIES_MAX_SEARCH_REPOS); + for (const repository of searchableRepositories) { + repositoriesByName.set(repository.fullName.toLowerCase(), repository); + } + } + + const candidates: FindOpportunityCandidate[] = []; + let searchedRepositories = 0; + for (const repository of repositoriesByName.values()) { + if (!repository.isRegistered) { + warnings.push(`Skipping ${repository.fullName}: repository is not registered in the local cache.`); + continue; + } + if (!(await this.canAccessRepo(repository.fullName))) { + warnings.push(`Skipping ${repository.fullName}: caller cannot access cached repository metadata.`); + continue; + } + searchedRepositories += 1; + const [issues, openPullRequests] = await Promise.all([ + listIssueSignalSample(this.env, repository.fullName), + listOpenPullRequests(this.env, repository.fullName), + ]); + for (const issue of issues) { + if (!issueMatchesSearch(issue, repository, searchTerms)) continue; + const candidate = buildFindOpportunityRecord(repository, issue, openPullRequests, goalSpec, Date.now()); + if (candidate.dupRisk >= 1) continue; + candidates.push(candidate); + } + } + + const opportunities = rankOpportunities(candidates).filter((opportunity) => opportunity.rankScore > 0); + const ranked = opportunities.slice(0, limit).map(toFindOpportunityRecord); + return { + summary: `Gittensory found ${ranked.length} ranked opportunity/opportunities from cached metadata.`, + data: { + source: "cached_metadata", + searchedRepositories, + candidateCount: opportunities.length, + opportunities: ranked, + ...(warnings.length > 0 ? { warnings } : {}), + }, + }; + } + 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..ee684bd8ec 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -4949,9 +4949,9 @@ describe("api routes", () => { expect(toolNames).toContain("gittensory_agent_get_run"); expect(toolNames).toContain("gittensory_agent_explain_next_action"); expect(toolNames).toContain("gittensory_agent_prepare_pr_packet"); + expect(toolNames).toContain("gittensory_find_opportunities"); 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..18571cc9eb 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", @@ -126,6 +127,10 @@ describe("MCP output schema discovery", () => { const registryChangesProps = Object.keys((registryChanges?.outputSchema?.properties ?? {}) as Record); expect(registryChangesProps).toEqual(expect.arrayContaining(["currentSnapshotId", "previousSnapshotId", "addedRepos", "removedRepos", "changedRepos", "summary"])); expect(registryChangesProps).not.toEqual(expect.arrayContaining(["previous", "current", "added", "removed", "changed", "warnings"])); + + const opportunities = byName.get("gittensory_find_opportunities"); + const opportunitiesProps = Object.keys((opportunities?.outputSchema?.properties ?? {}) as Record); + expect(opportunitiesProps).toEqual(expect.arrayContaining(["source", "searchedRepositories", "candidateCount", "opportunities"])); }); it("preserves the full tool inventory while adding output schemas", async () => { @@ -332,6 +337,192 @@ 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 ranks cached target issues and filters claimed or non-rankable work", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-03T00:00:00.000Z")); + try { + const env = createTestEnv(); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "octo/demo": { emission_share: 0.02, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false } }, + { kind: "raw-github", url: "fixture://registry" }, + "2026-07-03T00:00:00.000Z", + ), + ); + 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 cache retry failure", + state: "open", + user: { login: "maintainer" }, + author_association: "OWNER", + labels: [{ name: "bug" }], + body: "Reproduction and expected behavior are available.", + updated_at: "2026-07-02T00:00:00.000Z", + }); + await upsertIssueFromGitHub(env, "octo/demo", { + number: 2, + title: "Clarify retry question", + state: "open", + user: { login: "reporter" }, + labels: [{ name: "question" }], + body: "Needs a maintainer answer before coding.", + }); + await env.DB.prepare(`UPDATE issues SET payload_json = ? WHERE repo_full_name = ? AND number = ?`) + .bind(JSON.stringify({ body: "Needs a maintainer answer before coding.", updated_at: "not-a-date", closed_at: null }), "octo/demo", 2) + .run(); + await upsertIssueFromGitHub(env, "octo/demo", { + number: 3, + title: "Already claimed retry bug", + state: "open", + user: { login: "reporter" }, + labels: [{ name: "bug" }], + body: "Someone is already working on this.", + }); + await upsertIssueFromGitHub(env, "octo/demo", { + number: 4, + title: "Duplicate retry report", + state: "open", + user: { login: "reporter" }, + labels: [{ name: "duplicate" }], + body: "Duplicate of the other issue.", + }); + await upsertPullRequestFromGitHub(env, "octo/demo", { + number: 9, + title: "Fix claimed retry bug", + state: "open", + user: { login: "contributor" }, + labels: [], + body: "Closes #3", + }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { + targets: [ + { owner: "octo", repo: "demo" }, + { owner: "octo", repo: "demo" }, + ], + goalSpec: { preferredLabels: ["bug"], issueDiscoveryPolicy: "neutral" }, + limit: 5, + }, + }); + + expect(result.isError, JSON.stringify(result.content)).toBeFalsy(); + const data = result.structuredContent as { opportunities: Array>; searchedRepositories: number; candidateCount: number }; + expect(data.searchedRepositories).toBe(1); + expect(data.candidateCount).toBe(2); + expect(data.opportunities.map((entry) => entry.issueNumber)).toEqual([1, 2]); + expect(data.opportunities[0]).toMatchObject({ + owner: "octo", + repo: "demo", + issueNumber: 1, + aiPolicyAllowed: true, + laneFit: 1, + }); + expect(data.opportunities[0]?.rankScore as number).toBeGreaterThan(data.opportunities[1]?.rankScore as number); + expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); + } finally { + vi.useRealTimers(); + } + }); + + it("gittensory_find_opportunities searches cached repos, honors access filters, and handles disabled goal specs", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-03T00:00:00.000Z")); + try { + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "octo/searchable,ghost/missing" }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "octo/searchable": { emission_share: 0.02, issue_discovery_share: 1, label_multipliers: {}, trusted_label_pipeline: false }, + "victim/private": { emission_share: 0.02, issue_discovery_share: 0.5, label_multipliers: {}, trusted_label_pipeline: false }, + "octo/unregistered": { emission_share: 0, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + }, + { kind: "raw-github", url: "fixture://registry" }, + "2026-07-03T00:00:00.000Z", + ), + ); + await upsertRepositoryFromGitHub(env, { name: "searchable", full_name: "octo/searchable", private: false, owner: { login: "octo" }, default_branch: "main" }); + await upsertRepositoryFromGitHub(env, { name: "private", full_name: "victim/private", private: true, owner: { login: "victim" }, default_branch: "main" }); + await upsertRepositoryFromGitHub(env, { name: "unregistered", full_name: "octo/unregistered", private: false, owner: { login: "octo" }, default_branch: "main" }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "octo/searchable": { emission_share: 0.02, issue_discovery_share: 1, label_multipliers: {}, trusted_label_pipeline: false }, + "victim/private": { emission_share: 0.02, issue_discovery_share: 0.5, label_multipliers: {}, trusted_label_pipeline: false }, + }, + { kind: "raw-github", url: "fixture://registry-2" }, + "2026-07-03T00:01:00.000Z", + ), + ); + await upsertIssueFromGitHub(env, "octo/searchable", { + number: 8, + title: "Parser target bug", + state: "open", + user: { login: "reporter" }, + labels: [{ name: "enhancement" }], + updated_at: "2026-07-01T00:00:00.000Z", + }); + await upsertIssueFromGitHub(env, "octo/searchable", { + number: 10, + title: "Unrelated docs cleanup", + state: "open", + user: { login: "reporter" }, + labels: [{ name: "documentation" }], + body: "This cached issue covers unrelated repository housekeeping.", + updated_at: "2026-07-01T00:00:00.000Z", + }); + await upsertIssueFromGitHub(env, "victim/private", { + number: 9, + title: "Parser target secret", + state: "open", + user: { login: "reporter" }, + labels: [{ name: "bug" }], + body: "Should not be returned to this caller.", + updated_at: "2026-07-01T00:00:00.000Z", + }); + const { client } = await connectTestClient(env); + const searched = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { + targets: [ + { owner: "octo", repo: "unregistered" }, + { owner: "ghost", repo: "missing" }, + ], + searchQuery: "parser target", + goalSpec: { issueDiscoveryPolicy: "discouraged" }, + limit: 3, + }, + }); + expect(searched.isError, JSON.stringify(searched.content)).toBeFalsy(); + const searchedData = searched.structuredContent as { opportunities: Array>; candidateCount: number; warnings?: string[] }; + expect(searchedData.candidateCount).toBe(1); + expect(searchedData.opportunities).toHaveLength(1); + expect(searchedData.opportunities[0]).toMatchObject({ owner: "octo", repo: "searchable", issueNumber: 8, aiPolicyAllowed: true }); + expect(searchedData.opportunities[0]?.laneFit).toBe(0.35); + expect(searchedData.warnings?.some((warning) => /victim\/private/.test(warning))).toBe(true); + expect(searchedData.warnings?.some((warning) => /octo\/unregistered/.test(warning))).toBe(true); + expect(searchedData.warnings?.some((warning) => /ghost\/missing/.test(warning))).toBe(true); + + const disabled = await client.callTool({ + name: "gittensory_find_opportunities", + arguments: { targets: [{ owner: "octo", repo: "searchable" }], goalSpec: { minerEnabled: false } }, + }); + expect(disabled.isError).toBeFalsy(); + expect(disabled.structuredContent).toMatchObject({ opportunities: [], searchedRepositories: 0, candidateCount: 0 }); + + const invalid = await client.callTool({ name: "gittensory_find_opportunities", arguments: {} }); + expect(invalid.isError).toBe(true); + expect(JSON.stringify(invalid.content)).toMatch(/targets_or_search_query_required/); + } finally { + vi.useRealTimers(); + } + }); + 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" });