From a5e41b841ad6690590bdd363ae77df51b474359d Mon Sep 17 00:00:00 2001 From: Aei <256851514+aeitwoen@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:47:32 +0200 Subject: [PATCH] feat: add repository code search --- README.md | 10 ++- packages/omp/extensions/forges.ts | 23 +++++ packages/pi/extensions/forges.ts | 17 ++++ packages/shared/forges-tool-schemas.ts | 11 +++ src/index.ts | 3 + src/mcp.ts | 11 +++ src/provider.ts | 21 +++++ src/providers/github.ts | 89 +++++++++++++++++++ src/providers/gitlab.ts | 86 ++++++++++++++++++ src/tool-operations.ts | 18 ++++ src/types.ts | 20 +++++ test/eval-packed-extensions.mjs | 1 + test/extensions.test.ts | 35 +++++++- test/gitea.test.ts | 8 ++ test/github.test.ts | 116 +++++++++++++++++++++++++ test/gitlab.test.ts | 113 ++++++++++++++++++++++++ test/integration.test.ts | 20 +++++ test/mcp.test.ts | 42 ++++++++- test/tool-operations.test.ts | 32 +++++++ 19 files changed, 671 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index eb439ad..f15fae8 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ const gt = createProvider("gitea", { ## Agent tools -The same twenty-seven tools - repositories, CI runs, commits, issues, pull requests and their changed files and checks, users, authentication reload, discussion comments, and review threads - are exposed over MCP and through the Pi and OMP extensions. Read tools use the normal token detection chain, then fall back to anonymous access when no credential exists. Writes, `forges_users_authenticated`, and `forges_auth_reload` still require a credential. For a trusted self-hosted endpoint, set the matching local environment variable to the full API base URL: +The same twenty-eight tools - repositories, code search, CI runs, commits, issues, pull requests and their changed files and checks, users, authentication reload, discussion comments, and review threads - are exposed over MCP and through the Pi and OMP extensions. Read tools use the normal token detection chain, then fall back to anonymous access when no credential exists. Writes, `forges_users_authenticated`, and `forges_auth_reload` still require a credential. For a trusted self-hosted endpoint, set the matching local environment variable to the full API base URL: | Platform | Environment variable | | ------------------ | ------------------------ | @@ -138,10 +138,12 @@ The extensions add the details the harnesses render; MCP drops them and keeps th ## API -Every provider gives you seven resources with the same method shapes. Thread semantics still follow the platform: GitHub and GitLab return real multi-comment conversations, while Gitea has no parent id on review comments, so each one comes back as its own single-comment thread. +Every provider gives you eight resources with the same method shapes. Thread semantics still follow the platform: GitHub and GitLab return real multi-comment conversations, while Gitea has no parent id on review comments, so each one comes back as its own single-comment thread. **repos** - `list(owner, opts?)`, `get(owner, repo)` +**code** - `search(query, opts?)` + **ciRuns** - `list(owner, repo, opts?)` **commits** - `list(owner, repo, opts?)`, `get(owner, repo, sha)` @@ -154,6 +156,8 @@ Every provider gives you seven resources with the same method shapes. Thread sem **threads** - `list(owner, repo, number, opts?)`, `get(owner, repo, number, threadId)`, `reply(owner, repo, number, threadId, input)`, `resolve(owner, repo, number, threadId)`, `unresolve(owner, repo, number, threadId)` +Code search accepts `CodeSearchOptions`: `page`, `perPage`, and optional `owner` and `repo` scope. A repository scope requires its owner. GitHub supports global, owner, and repository search. GitLab uses global search, group search for owner-only scope, and project search for repository scope. GitLab requires authentication for every search API call; global and group code search also require Premium or Ultimate with advanced or exact code search enabled. Gitea, Forgejo, and GitHub-compatible hosts without a code-search endpoint return an explicit unsupported error. Results are `CodeSearchItem` rows with `repository`, `path`, and `url`, wrapped in `SearchPageResult` so callers can follow pagination and inspect `incomplete`. For GitHub, `incomplete` is true when the search times out, scope enforcement drops an unexpected row, or the match count exceeds the 1,000-result retrieval cap. + CI-run lists accept `ListCiRunsOptions`: `page`, `perPage`, and an optional `branch` filter. They normalize GitHub Actions runs, GitLab pipelines, and Gitea Actions runs to branch, revision SHA, lifecycle status, terminal conclusion, and URL. Commit lists accept `ListCommitOptions`: `page`, `perPage`, `ref`, `path`, `since`, and `until`. They return metadata-only `CommitSummary` rows. Gitea rejects `path` because its API ignores pagination limits for that filter; the other filters remain supported. Commit reads return the SHA, message, author, committer, parent SHAs, URL, and changed-file rows without patches. GitHub pages are collected through its 3,000-file API cap; GitLab reads at most 10,000 rows per call. Gitea per-file counts and withheld GitLab diff counts are `null`. `filesComplete` is `true` when GitHub confirms a complete result and `null` when provider or safety limits make completeness unknowable. Pull-request file lists accept `ListPullRequestFilesOptions`: `page` and `perPage`. They normalize each changed file to path, status, additions, and deletions without returning patches; GitLab counts are `null` when its API withholds a collapsed or oversized diff. Pull-request check lists accept `ListPullRequestChecksOptions`: `page` and `perPage`. They read GitHub check runs for the head SHA, GitLab merge-request pipelines for the current head SHA, and Gitea commit statuses, normalized to name, lifecycle status, terminal conclusion, and URL. Issue and pull request lists accept `ListOptions`: `page`, `perPage`, and `state` (`'open' | 'closed' | 'all'`); repository lists use its pagination fields. Lists return `PageResult` with `items`, `hasNextPage`, `nextPage`, and an optional `totalCount`. Issue and pull-request searches accept the same options and return those fields plus `incomplete`, which is true when the result is known to be partial. Queries keep the selected platform's syntax: GitHub qualifiers work on GitHub, while GitLab and Gitea treat them as text. Pull-request search returns `PullRequestSearchItem`; call `get` for branches, revisions, and mergeability. `listComments` reads the discussion under an issue or pull request oldest first and accepts `ListCommentOptions`: `page` and `perPage`. On GitHub and Gitea the two variants read the same endpoint, because both platforms index pull requests as issues. GitLab notes are fetched with an explicit ascending sort, and both its system notes about label and state churn and its inline DiffNotes, which belong to the thread surface, are dropped, so a short page whose `hasNextPage` is true means keep paging. Gitea answers with the whole discussion in one response, so the requested page is cut locally. @@ -213,7 +217,7 @@ const gitea = new GiteaProvider({ token: process.env.GITEA_TOKEN }); console.log(gitea instanceof Provider); // true ``` -`Provider` is the abstract base class for every implementation. It owns the six resource accessors, while concrete classes implement the typed mappers and platform-specific API operations. Custom providers that do not implement CI runs or pull-request checks get an explicit unsupported-operation error. +`Provider` is the abstract base class for every implementation. It owns the eight resource accessors, while concrete classes implement the typed mappers and platform-specific API operations. Custom providers that do not implement code search, CI runs, or pull-request checks get an explicit unsupported-operation error. The runtime base class is also available from `@agntn/forges/provider`. The `@agntn/forges/types` subpath contains only TypeScript models and resource interfaces. diff --git a/packages/omp/extensions/forges.ts b/packages/omp/extensions/forges.ts index 6ece661..b6ac8ef 100644 --- a/packages/omp/extensions/forges.ts +++ b/packages/omp/extensions/forges.ts @@ -68,6 +68,17 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void { const listRepositoriesParameters = Type.Object({ platform, owner, page, perPage }); const repositoryParameters = Type.Object({ platform, owner, repo }); + const codeSearchParameters = Type.Object({ + platform, + query: Type.String({ + description: "Search query in the selected provider's syntax", + minLength: 1, + }), + owner: Type.Optional(owner), + repo: Type.Optional(repo), + page, + perPage, + }); const commitParameters = Type.Object({ platform, owner, repo, sha }); const listCommitsParameters = Type.Object({ platform, @@ -180,6 +191,18 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void { }, }); + pi.registerTool({ + name: "forges_code_search", + label: "Search Forges Code", + description: + "Search code across repositories with optional owner and repository scope; GitLab requires authentication, and global or group scope requires Premium or Ultimate with advanced or exact code search; Gitea, Forgejo, and GitHub-compatible hosts without the endpoint are unsupported", + parameters: codeSearchParameters, + approval: "read", + async execute(_toolCallId, params) { + return (await loadToolOperations()).searchCode(params); + }, + }); + pi.registerTool({ name: "forges_ci_runs_list", label: "Forges CI Runs", diff --git a/packages/pi/extensions/forges.ts b/packages/pi/extensions/forges.ts index 853e26b..cfd2887 100644 --- a/packages/pi/extensions/forges.ts +++ b/packages/pi/extensions/forges.ts @@ -7,6 +7,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type * as ForgesTools from "../../../dist/tool-operations.d.mts"; import { authenticatedUserParameters, + codeSearchParameters, commentParameters, commitParameters, createIssueParameters, @@ -111,6 +112,22 @@ export default function forgesExtension(pi: ExtensionAPI): void { }, }); + pi.registerTool({ + name: "forges_code_search", + label: "Search Forges Code", + description: "Search code across repositories with optional owner and repository scope", + promptSnippet: "Search repository code on GitHub or GitLab.", + promptGuidelines: [ + "Use forges_code_search to discover repositories from code or file fragments instead of invoking a platform CLI.", + "forges_code_search on GitLab requires authentication; global and group scope also require Premium or Ultimate with advanced or exact code search.", + "forges_code_search returns unsupported on Gitea, Forgejo, and GitHub-compatible hosts without a code-search endpoint.", + ], + parameters: codeSearchParameters, + async execute(_toolCallId, params) { + return (await loadToolOperations()).searchCode(params); + }, + }); + pi.registerTool({ name: "forges_ci_runs_list", label: "Forges CI Runs", diff --git a/packages/shared/forges-tool-schemas.ts b/packages/shared/forges-tool-schemas.ts index 89974cb..4263200 100644 --- a/packages/shared/forges-tool-schemas.ts +++ b/packages/shared/forges-tool-schemas.ts @@ -69,6 +69,17 @@ const assignees = Type.Optional( export const listRepositoriesParameters = Type.Object({ platform, owner, page, perPage }); export const repositoryParameters = Type.Object({ platform, owner, repo }); +export const codeSearchParameters = Type.Object({ + platform, + query: Type.String({ + description: "Search query in the selected provider's syntax", + minLength: 1, + }), + owner: Type.Optional(owner), + repo: Type.Optional(repo), + page, + perPage, +}); export const commitParameters = Type.Object({ platform, owner, repo, sha }); export const listCommitsParameters = Type.Object({ platform, diff --git a/src/index.ts b/src/index.ts index ccf5ae7..13e6294 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,8 @@ export type { Thread, PageResult, SearchPageResult, + CodeSearchItem, + CodeSearchOptions, ListOptions, ListCiRunsOptions, ListCommentOptions, @@ -51,6 +53,7 @@ export type { ReplyThreadInput, ProviderConfig, RepositoryResource, + CodeSearchResource, CiRunResource, CommitResource, IssueResource, diff --git a/src/mcp.ts b/src/mcp.ts index c802f85..2e077b1 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -10,6 +10,7 @@ import { Value } from "typebox/value"; import { ForgesError, RateLimitError } from "./errors.ts"; import { authenticatedUserParameters, + codeSearchParameters, commentParameters, commitParameters, createIssueParameters, @@ -53,6 +54,7 @@ import { listThreads, reloadAuthentication, replyToThread, + searchCode, searchIssues, searchPullRequests, resolveThread, @@ -135,6 +137,15 @@ const tools: ToolDefinition[] = [ annotations: readAnnotations, execute: getRepository, }), + defineTool({ + name: "forges_code_search", + title: "Search Repository Code", + description: + "Search code across repositories, optionally scoped to an owner or one repository. Results contain normalized repository names, paths, and web URLs. Results are paged, and incomplete says whether the search is known to be partial. GitLab requires authentication, and its global or group code search requires Premium or Ultimate with advanced or exact code search. Gitea, Forgejo, and GitHub-compatible hosts without the endpoint return an explicit unsupported error.", + inputSchema: codeSearchParameters, + annotations: readAnnotations, + execute: searchCode, + }), defineTool({ name: "forges_ci_runs_list", title: "List CI Runs", diff --git a/src/provider.ts b/src/provider.ts index 9ba838b..1c05b86 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -7,6 +7,9 @@ import { ForgesError } from "./errors.ts"; import type { CiRun, CiRunResource, + CodeSearchItem, + CodeSearchOptions, + CodeSearchResource, Comment, Commit, CommitResource, @@ -65,6 +68,7 @@ export interface ProviderRawTypes { */ export abstract class Provider { public readonly repos: RepositoryResource; + public readonly code: CodeSearchResource; public readonly ciRuns: CiRunResource; public readonly commits: CommitResource; public readonly issues: IssueResource; @@ -77,6 +81,17 @@ export abstract class Provider list: (owner, options) => this.listRepos(owner, options), get: (owner, repo) => this.getRepo(owner, repo), }; + this.code = { + search: async (query, options) => { + if (query.trim() === "") { + throw new ForgesError("Code search query must not be empty", 400); + } + if (options?.repo !== undefined && options.owner === undefined) { + throw new ForgesError("Code search repository scope requires an owner", 400); + } + return this.searchCode(query, options); + }, + }; this.ciRuns = { list: (owner, repo, options) => this.listCiRuns(owner, repo, options), }; @@ -152,6 +167,12 @@ export abstract class Provider options?: ListOptions, ): Promise>; protected abstract getRepo(owner: string, repo: string): Promise; + protected searchCode( + _query: string, + _options?: CodeSearchOptions, + ): Promise> { + return Promise.reject(new ForgesError("Code search is not supported by this provider", 501)); + } protected listCiRuns( _owner: string, _repo: string, diff --git a/src/providers/github.ts b/src/providers/github.ts index f0b1309..204968d 100644 --- a/src/providers/github.ts +++ b/src/providers/github.ts @@ -7,6 +7,8 @@ import { Provider, type ProviderRawTypes } from "../provider.ts"; import type { ProviderConfig, Repository, + CodeSearchItem, + CodeSearchOptions, CiRun, Commit, CommitSummary, @@ -44,6 +46,7 @@ import { normalizeCiRunState } from "../ci-run.ts"; import { normalizeChangedFileStatus } from "../changed-file.ts"; const MAX_COMMIT_FILE_PAGES = 30; +const GITHUB_SEARCH_RESULT_LIMIT = 1000; // --- GitHub API response types (snake_case) --- @@ -57,6 +60,20 @@ interface GitHubRepositoryParent { html_url: string; } +interface GitHubCodeSearchItem { + path: string; + html_url: string; + repository: { + full_name: string; + }; +} + +interface GitHubCodeSearchResponse { + total_count: number; + incomplete_results: boolean; + items: GitHubCodeSearchItem[]; +} + interface GitHubRepo { id: number; name: string; @@ -416,6 +433,14 @@ function paginationFromLink(headers: Headers): { }; } +function githubSearchQualifierSegment(value: string): string { + encodePathSegment(value); + if (/[\s:'"]/u.test(value)) { + throw new TypeError("Invalid GitHub search qualifier segment"); + } + return value; +} + function buildPageResult( items: TRaw[], headers: Headers, @@ -742,6 +767,70 @@ export class GitHubProvider extends Provider { } } + // --- Code search --- + + protected override async searchCode( + searchQuery: string, + options?: CodeSearchOptions, + ): Promise> { + try { + const qualifiers: string[] = []; + if (options?.owner !== undefined && options.repo !== undefined) { + const owner = githubSearchQualifierSegment(options.owner); + const repo = githubSearchQualifierSegment(options.repo); + qualifiers.push(`repo:${owner}/${repo}`); + } else if (options?.owner !== undefined) { + qualifiers.push(`user:${githubSearchQualifierSegment(options.owner)}`); + } + + const query: Record = { + q: qualifiers.length === 0 ? searchQuery : `${searchQuery} ${qualifiers.join(" ")}`, + }; + if (options?.page) query.page = String(options.page); + if (options?.perPage) query.per_page = String(options.perPage); + + const { data, headers } = await rawFetch( + this.client, + "/search/code", + { query }, + ); + const rawItems = data?.items ?? []; + const expectedOwner = options?.owner?.toLowerCase(); + const expectedRepository = + options?.owner !== undefined && options.repo !== undefined + ? `${options.owner}/${options.repo}`.toLowerCase() + : undefined; + const scopedItems = rawItems.filter((item) => { + const repository = item.repository.full_name.toLowerCase(); + if (expectedRepository !== undefined) return repository === expectedRepository; + if (expectedOwner !== undefined) return repository.startsWith(`${expectedOwner}/`); + return true; + }); + return { + ...buildPageResult(scopedItems, headers, (raw) => ({ + repository: raw.repository.full_name, + path: raw.path, + url: raw.html_url, + })), + totalCount: data?.total_count, + incomplete: + (data?.incomplete_results ?? false) || + (data?.total_count ?? 0) > GITHUB_SEARCH_RESULT_LIMIT || + scopedItems.length !== rawItems.length, + }; + } catch (error) { + if (error instanceof FetchError && (error.status === 404 || error.status === 405)) { + throw new ForgesError( + "Code search is not supported by this GitHub-compatible host", + 501, + "github", + error, + ); + } + throw normalizeError(error, "github"); + } + } + // --- Issues --- protected override async listIssues( diff --git a/src/providers/gitlab.ts b/src/providers/gitlab.ts index aa35d41..6f55af8 100644 --- a/src/providers/gitlab.ts +++ b/src/providers/gitlab.ts @@ -16,6 +16,8 @@ import type { ProviderConfig, Repository, RepositoryPermission, + CodeSearchItem, + CodeSearchOptions, CiRun, Commit, CommitSummary, @@ -51,6 +53,7 @@ import { normalizeCiRunState } from "../ci-run.ts"; import { countDiffLines } from "../changed-file.ts"; const MAX_COMMIT_DIFF_PAGES = 100; +const MAX_CODE_SEARCH_PROJECT_REQUESTS = 5; // GitLab API response types (internal) @@ -63,6 +66,12 @@ interface GitLabProjectAccess { access_level: number; } +interface GitLabCodeSearchItem { + path: string; + ref: string; + project_id: number; +} + interface GitLabProject { id: number; name: string; @@ -243,6 +252,10 @@ function encodeProjectPath(owner: string, repo: string): string { return `${encodeNamespacePath(owner)}%2F${encodePathSegment(repo)}`; } +function encodeWebPath(path: string): string { + return path.split("/").map(encodePathSegment).join("/"); +} + const DEFAULT_PROJECT_ID_CACHE_MAX = 500; const DEFAULT_PROJECT_ID_CACHE_TTL = 300000; @@ -760,6 +773,79 @@ export class GitLabProvider extends Provider { } } + // --- Code search --- + + protected override async searchCode( + search: string, + options?: CodeSearchOptions, + ): Promise> { + try { + let route = "/search"; + let scopedProject: GitLabProject | undefined; + if (options?.owner !== undefined && options.repo !== undefined) { + scopedProject = await this.client( + `/projects/${encodeProjectPath(options.owner, options.repo)}`, + ); + this.setCachedProjectId(`${options.owner}/${options.repo}`, scopedProject.id); + route = `/projects/${scopedProject.id}/search`; + } else if (options?.owner !== undefined) { + route = `/groups/${encodeNamespacePath(options.owner)}/search`; + } + + const response = await rawFetch(this.client, route, { + query: { + scope: "blobs", + search, + page: options?.page ?? 1, + per_page: options?.perPage ?? 30, + }, + }); + const rawItems = response.data ?? []; + // Blob rows expose only project_id, so repository names and web URLs need + // separate project reads. Deduplicate them and cap concurrency per page. + const projects = new Map(); + if (scopedProject !== undefined) projects.set(scopedProject.id, scopedProject); + const projectIds = [...new Set(rawItems.map((item) => item.project_id))].filter( + (projectId) => !projects.has(projectId), + ); + let enrichmentError: unknown; + for (let offset = 0; offset < projectIds.length; offset += MAX_CODE_SEARCH_PROJECT_REQUESTS) { + const batch = projectIds.slice(offset, offset + MAX_CODE_SEARCH_PROJECT_REQUESTS); + await Promise.all( + batch.map(async (projectId) => { + try { + const project = await this.client(`/projects/${projectId}`); + projects.set(projectId, project); + } catch (error: unknown) { + enrichmentError ??= error; + } + }), + ); + } + if (rawItems.length > 0 && projects.size === 0 && enrichmentError !== undefined) { + throw enrichmentError; + } + + const items = rawItems.flatMap((raw): CodeSearchItem[] => { + const project = projects.get(raw.project_id); + if (!project) return []; + return [ + { + repository: project.path_with_namespace, + path: raw.path, + url: `${project.web_url}/-/blob/${encodeWebPath(raw.ref)}/${encodeWebPath(raw.path)}`, + }, + ]; + }); + return { + ...this.parsePagination(items, response.headers), + incomplete: items.length !== rawItems.length, + }; + } catch (error: unknown) { + throw normalizeError(error, "gitlab"); + } + } + // --- Issues --- protected override async listIssues( diff --git a/src/tool-operations.ts b/src/tool-operations.ts index 6ad6616..16d0e99 100644 --- a/src/tool-operations.ts +++ b/src/tool-operations.ts @@ -5,6 +5,8 @@ import type { ForgesPlatform } from "../packages/shared/forges-tool-schemas.ts"; import type { Provider } from "./provider.ts"; import type { CiRun, + CodeSearchItem, + CodeSearchOptions, Comment, Commit, CommitSummary, @@ -165,6 +167,10 @@ export interface ListRepositoriesParams extends OwnerParams { export type GetRepositoryParams = RepositoryParams; +export interface SearchCodeParams extends PlatformParams, CodeSearchOptions { + query: string; +} + export interface GetCommitParams extends RepositoryParams { sha: string; } @@ -308,6 +314,18 @@ export async function getRepository( return result(params.platform, repository); } +export async function searchCode( + params: SearchCodeParams, +): Promise>> { + const search = await readProvider(params.platform).code.search(params.query, { + owner: params.owner, + repo: params.repo, + page: params.page, + perPage: params.perPage, + }); + return result(params.platform, search); +} + export async function listCiRuns( params: ListCiRunsParams, ): Promise>> { diff --git a/src/types.ts b/src/types.ts index f91816b..b66a85c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -190,6 +190,21 @@ export interface SearchPageResult extends PageResult { incomplete: boolean; } +/** One normalized file match from repository code search. */ +export interface CodeSearchItem { + repository: string; + path: string; + url: string; +} + +/** Pagination and optional repository scope for code search. */ +export interface CodeSearchOptions { + owner?: string; + repo?: string; + page?: number; + perPage?: number; +} + /** * List operation options */ @@ -346,6 +361,11 @@ export interface RepositoryResource { get(owner: string, repo: string): Promise; } +/** Resource accessor for repository code search. */ +export interface CodeSearchResource { + search(query: string, options?: CodeSearchOptions): Promise>; +} + /** Resource accessor for repository CI runs. */ export interface CiRunResource { list(owner: string, repo: string, options?: ListCiRunsOptions): Promise>; diff --git a/test/eval-packed-extensions.mjs b/test/eval-packed-extensions.mjs index 8349600..18694ce 100644 --- a/test/eval-packed-extensions.mjs +++ b/test/eval-packed-extensions.mjs @@ -15,6 +15,7 @@ const originalEnvironment = environmentKeys.map((key) => [key, process.env[key]] const expectedToolNames = [ "forges_repos_list", "forges_repos_get", + "forges_code_search", "forges_ci_runs_list", "forges_commits_list", "forges_commits_get", diff --git a/test/extensions.test.ts b/test/extensions.test.ts index 54a2d24..0904927 100644 --- a/test/extensions.test.ts +++ b/test/extensions.test.ts @@ -18,6 +18,7 @@ import { resetPinnedProviders } from "../src/tool-operations.ts"; const mocks = vi.hoisted(() => { const repos = { list: vi.fn(), get: vi.fn() }; + const code = { search: vi.fn() }; const ciRuns = { list: vi.fn() }; const commits = { list: vi.fn(), get: vi.fn() }; const issues = { @@ -44,12 +45,13 @@ const mocks = vi.hoisted(() => { resolve: vi.fn(), unresolve: vi.fn(), }; - const provider = { repos, ciRuns, commits, issues, pullRequests, users, threads }; + const provider = { repos, code, ciRuns, commits, issues, pullRequests, users, threads }; return { resolveToken: vi.fn(() => ({ token: "test-token", source: "env" as const })), createProvider: vi.fn(() => provider), repos, + code, ciRuns, commits, issues, @@ -67,6 +69,7 @@ vi.mock("../src/index.ts", () => ({ const toolNames = [ "forges_repos_list", "forges_repos_get", + "forges_code_search", "forges_ci_runs_list", "forges_commits_list", "forges_commits_get", @@ -156,6 +159,7 @@ beforeEach(() => { vi.stubEnv("FORGES_GITLAB_BASE_URL", undefined); vi.stubEnv("FORGES_GITEA_BASE_URL", undefined); mocks.repos.list.mockResolvedValue({ items: [], hasNextPage: false }); + mocks.code.search.mockResolvedValue({ items: [], incomplete: false, hasNextPage: false }); mocks.ciRuns.list.mockResolvedValue({ items: [], hasNextPage: false }); mocks.commits.list.mockResolvedValue({ items: [], hasNextPage: false }); mocks.commits.get.mockResolvedValue({ sha: "abc", files: [], filesComplete: true }); @@ -242,6 +246,35 @@ describe("Forges Pi extension", () => { }); }); + it("executes code search through the shared provider operation", async () => { + const tool = requirePiTool(registerPiTools(), "forges_code_search"); + const result = await tool.execute( + "test", + { + platform: "github", + query: "Provider", + owner: "agntn", + repo: "forges", + page: 2, + perPage: 10, + }, + undefined, + undefined, + unusedPiContext, + ); + + expect(mocks.code.search).toHaveBeenCalledWith("Provider", { + owner: "agntn", + repo: "forges", + page: 2, + perPage: 10, + }); + expect(result.details).toEqual({ + platform: "github", + result: { items: [], incomplete: false, hasNextPage: false }, + }); + }); + it("executes CI-run listing through the shared provider operation", async () => { const tool = requirePiTool(registerPiTools(), "forges_ci_runs_list"); const result = await tool.execute( diff --git a/test/gitea.test.ts b/test/gitea.test.ts index bad5949..f95ed0b 100644 --- a/test/gitea.test.ts +++ b/test/gitea.test.ts @@ -203,6 +203,14 @@ describe("Gitea Provider", () => { provider = new GiteaProvider({ token: "test-token" }); }); + describe("code.search", () => { + it("fails explicitly because the provider has no code-search endpoint", async () => { + await expect(provider.code.search("Provider")).rejects.toMatchObject({ status: 501 }); + expect(mockedRawFetch).not.toHaveBeenCalled(); + expect(mockClient).not.toHaveBeenCalled(); + }); + }); + describe("provider setup", () => { it("extends the abstract Provider base class", () => { expect(provider).toBeInstanceOf(Provider); diff --git a/test/github.test.ts b/test/github.test.ts index 397fab8..a3e2ccd 100644 --- a/test/github.test.ts +++ b/test/github.test.ts @@ -52,6 +52,15 @@ const ghRepo = { }, }; +const ghCodeSearchItem = { + name: "provider.ts", + path: "src/provider.ts", + html_url: "https://github.com/agntn/forges/blob/main/src/provider.ts", + repository: { + full_name: "agntn/forges", + }, +}; + const ghCiRun = { id: 9876, head_branch: "main", @@ -219,6 +228,113 @@ describe("GitHubProvider", () => { }); }); + describe("code.search", () => { + it("maps scoped code results and pagination metadata", async () => { + mocks.rawFetch.mockResolvedValueOnce({ + data: { + total_count: 12, + incomplete_results: true, + items: [ghCodeSearchItem], + }, + headers: makeHeaders('; rel="next"'), + }); + + const result = await gh.code.search("Provider", { + owner: "agntn", + repo: "forges", + page: 2, + perPage: 1, + }); + + expect(mocks.rawFetch).toHaveBeenCalledWith(mocks.client, "/search/code", { + query: { + q: "Provider repo:agntn/forges", + page: "2", + per_page: "1", + }, + }); + expect(result).toEqual({ + items: [ + { + repository: "agntn/forges", + path: "src/provider.ts", + url: "https://github.com/agntn/forges/blob/main/src/provider.ts", + }, + ], + totalCount: 12, + incomplete: true, + hasNextPage: true, + nextPage: 3, + }); + }); + + it("marks searches above GitHub's 1,000-result cap as incomplete", async () => { + mocks.rawFetch.mockResolvedValueOnce({ + data: { + total_count: 1001, + incomplete_results: false, + items: [ghCodeSearchItem], + }, + headers: makeHeaders(), + }); + + const result = await gh.code.search("Provider"); + + expect(result.incomplete).toBe(true); + }); + + it("uses an owner qualifier and drops out-of-scope API results", async () => { + mocks.rawFetch.mockResolvedValueOnce({ + data: { + total_count: 2, + incomplete_results: false, + items: [ + ghCodeSearchItem, + { + ...ghCodeSearchItem, + repository: { full_name: "other/forges" }, + }, + ], + }, + headers: makeHeaders(), + }); + + const result = await gh.code.search("Provider", { owner: "agntn" }); + + expect(mocks.rawFetch).toHaveBeenCalledWith(mocks.client, "/search/code", { + query: { q: "Provider user:agntn" }, + }); + expect(result.items).toEqual([ + { + repository: "agntn/forges", + path: "src/provider.ts", + url: "https://github.com/agntn/forges/blob/main/src/provider.ts", + }, + ]); + expect(result.incomplete).toBe(true); + }); + + it("reports GitHub-compatible hosts without the endpoint as unsupported", async () => { + mocks.rawFetch.mockRejectedValueOnce(makeFetchError(404)); + + await expect(gh.code.search("Provider")).rejects.toMatchObject({ + status: 501, + platform: "github", + }); + }); + + it("rejects invalid searches before transport", async () => { + await expect(gh.code.search("Provider", { repo: "forges" })).rejects.toThrow( + "Code search repository scope requires an owner", + ); + await expect(gh.code.search(" ")).rejects.toThrow("Code search query must not be empty"); + await expect(gh.code.search("Provider", { owner: "agntn repo:other" })).rejects.toThrow( + "Invalid GitHub search qualifier segment", + ); + expect(mocks.rawFetch).not.toHaveBeenCalled(); + }); + }); + // --- Repos --- describe("repos.list", () => { diff --git a/test/gitlab.test.ts b/test/gitlab.test.ts index 21edaf1..602851d 100644 --- a/test/gitlab.test.ts +++ b/test/gitlab.test.ts @@ -64,6 +64,17 @@ const glProjectWithOwner = { }, }; +const glCodeSearchItem = { + basename: "provider.ts", + data: "export abstract class Provider", + path: "src/provider.ts", + filename: "provider.ts", + id: "cb9d4e5dc0f07fd9504b74e6ef58c37e9a32af38", + ref: "main", + startline: 12, + project_id: 278964, +}; + const glPipeline = { id: 9001, ref: "main", @@ -269,6 +280,108 @@ describe("GitLabProvider", () => { }); }); + describe("code.search", () => { + it.each([ + [{}, "/search"], + [{ owner: "gitlab-org" }, "/groups/gitlab-org/search"], + ])("routes global and group searches through %s", async (scope, route) => { + mocks.rawFetch.mockResolvedValueOnce({ data: [], headers: glHeaders() }); + + await gl.code.search("Provider", scope); + + expect(mocks.rawFetch).toHaveBeenCalledWith(mocks.client, route, { + query: { scope: "blobs", search: "Provider", page: 1, per_page: 30 }, + }); + }); + + it("searches a scoped project and enriches normalized result URLs", async () => { + mocks.client.mockResolvedValueOnce(glProject); + mocks.rawFetch.mockResolvedValueOnce({ + data: [glCodeSearchItem], + headers: glHeaders({ nextPage: "3", total: "12" }), + }); + + const result = await gl.code.search("Provider", { + owner: "gitlab-org", + repo: "gitlab-foss", + page: 2, + perPage: 1, + }); + + expect(mocks.rawFetch).toHaveBeenCalledWith(mocks.client, "/projects/278964/search", { + query: { scope: "blobs", search: "Provider", page: 2, per_page: 1 }, + }); + expect(mocks.client).toHaveBeenCalledTimes(1); + expect(mocks.client).toHaveBeenCalledWith("/projects/gitlab-org%2Fgitlab-foss"); + expect(result).toEqual({ + items: [ + { + repository: "gitlab-org/gitlab-foss", + path: "src/provider.ts", + url: "https://gitlab.com/gitlab-org/gitlab-foss/-/blob/main/src/provider.ts", + }, + ], + totalCount: 12, + incomplete: false, + hasNextPage: true, + nextPage: 3, + }); + }); + + it("limits project enrichment to five concurrent requests", async () => { + const projectIds = [1, 2, 3, 4, 5, 6]; + mocks.rawFetch.mockResolvedValueOnce({ + data: projectIds.map((projectId) => ({ ...glCodeSearchItem, project_id: projectId })), + headers: glHeaders(), + }); + const gates = projectIds.slice(0, 5).map(() => Promise.withResolvers()); + let active = 0; + let maximumActive = 0; + mocks.client.mockImplementation(async (url: string) => { + const projectId = Number(url.split("/").at(-1)); + active += 1; + maximumActive = Math.max(maximumActive, active); + const gate = gates[projectId - 1]; + if (gate) await gate.promise; + active -= 1; + return { + ...glProject, + id: projectId, + path_with_namespace: `group/project-${projectId}`, + web_url: `https://gitlab.com/group/project-${projectId}`, + }; + }); + + const search = gl.code.search("Provider", { perPage: 100 }); + await vi.waitFor(() => expect(mocks.client).toHaveBeenCalledTimes(5)); + expect(maximumActive).toBe(5); + for (const gate of gates) gate.resolve(); + + const result = await search; + expect(result.items).toHaveLength(6); + expect(maximumActive).toBe(5); + }); + + it("keeps a partial global page when one project cannot be enriched", async () => { + mocks.rawFetch.mockResolvedValueOnce({ + data: [glCodeSearchItem, { ...glCodeSearchItem, path: "src/other.ts", project_id: 99 }], + headers: glHeaders({ total: "2" }), + }); + mocks.client.mockResolvedValueOnce(glProject).mockRejectedValueOnce(new Error("gone")); + + const result = await gl.code.search("Provider"); + + expect(result.items).toEqual([ + { + repository: "gitlab-org/gitlab-foss", + path: "src/provider.ts", + url: "https://gitlab.com/gitlab-org/gitlab-foss/-/blob/main/src/provider.ts", + }, + ]); + expect(result).toMatchObject({ totalCount: 2, incomplete: true }); + }); + }); + describe("constructor", () => { it("creates http client with Private-Token auth", () => { expect(mocks.createHttpClient).toHaveBeenCalledWith({ diff --git a/test/integration.test.ts b/test/integration.test.ts index 5d3f96a..c7c96e2 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -51,6 +51,7 @@ import { AuthenticationError, ForgesError } from "../src/errors.ts"; import type { ProviderConfig, RepositoryResource, + CodeSearchResource, CiRunResource, CommitResource, IssueResource, @@ -72,6 +73,7 @@ describe("createProvider factory", () => { expect(provider).toBeDefined(); expect(provider.repos).toBeDefined(); + expect(provider.code).toBeDefined(); expect(provider.issues).toBeDefined(); expect(provider.pullRequests).toBeDefined(); expect(provider.users).toBeDefined(); @@ -82,6 +84,7 @@ describe("createProvider factory", () => { expect(provider).toBeDefined(); expect(provider.repos).toBeDefined(); + expect(provider.code).toBeDefined(); expect(provider.issues).toBeDefined(); expect(provider.pullRequests).toBeDefined(); expect(provider.users).toBeDefined(); @@ -92,6 +95,7 @@ describe("createProvider factory", () => { expect(provider).toBeDefined(); expect(provider.repos).toBeDefined(); + expect(provider.code).toBeDefined(); expect(provider.issues).toBeDefined(); expect(provider.pullRequests).toBeDefined(); expect(provider.users).toBeDefined(); @@ -141,6 +145,7 @@ describe("createProvider factory", () => { }); it.each([ + ["searchCode", "Code search is not supported by this provider"], ["listCiRuns", "CI-run listing is not supported by this provider"], ["listPullRequestChecks", "Pull request checks are not supported by this provider"], ["searchIssues", "Issue search is not supported by this provider"], @@ -176,6 +181,14 @@ describe("cross-provider class consistency", () => { } }); + it("all providers have code search resource", () => { + for (const platform of platforms) { + const p = providers[platform]; + expect(p.code).toBeDefined(); + expect(typeof p.code.search).toBe("function"); + } + }); + it("all providers have CI runs resource", () => { for (const platform of platforms) { const p = providers[platform]; @@ -249,6 +262,7 @@ describe("cross-provider class consistency", () => { expect(p.repos.list.length).toBeGreaterThanOrEqual(1); expect(p.repos.get.length).toBeGreaterThanOrEqual(2); + expect(p.code.search.length).toBeGreaterThanOrEqual(1); expect(p.ciRuns.list.length).toBeGreaterThanOrEqual(2); expect(p.commits.list.length).toBeGreaterThanOrEqual(2); expect(p.commits.get.length).toBeGreaterThanOrEqual(3); @@ -371,6 +385,7 @@ describe("direct provider instantiation", () => { }); expect(provider.repos).toBeDefined(); + expect(provider.code).toBeDefined(); expect(provider.issues).toBeDefined(); expect(provider.pullRequests).toBeDefined(); expect(provider.users).toBeDefined(); @@ -384,6 +399,7 @@ describe("direct provider instantiation", () => { }); expect(provider.repos).toBeDefined(); + expect(provider.code).toBeDefined(); expect(provider.issues).toBeDefined(); expect(provider.pullRequests).toBeDefined(); expect(provider.users).toBeDefined(); @@ -394,6 +410,7 @@ describe("direct provider instantiation", () => { const provider = new GiteaProvider({ token: "test" }); expect(provider.repos).toBeDefined(); + expect(provider.code).toBeDefined(); expect(provider.issues).toBeDefined(); expect(provider.pullRequests).toBeDefined(); expect(provider.users).toBeDefined(); @@ -407,6 +424,7 @@ describe("direct provider instantiation", () => { }); expect(provider.repos).toBeDefined(); + expect(provider.code).toBeDefined(); expect(provider.issues).toBeDefined(); expect(provider.pullRequests).toBeDefined(); expect(provider.users).toBeDefined(); @@ -434,6 +452,7 @@ describe("type-level consistency", () => { // These should compile without errors const repos: RepositoryResource = provider.repos; + const code: CodeSearchResource = provider.code; const ciRuns: CiRunResource = provider.ciRuns; const commits: CommitResource = provider.commits; const issues: IssueResource = provider.issues; @@ -442,6 +461,7 @@ describe("type-level consistency", () => { const threads: ThreadResource = provider.threads; expect(repos).toBeDefined(); + expect(code).toBeDefined(); expect(ciRuns).toBeDefined(); expect(commits).toBeDefined(); expect(issues).toBeDefined(); diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 86d84d2..af6a756 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -8,6 +8,7 @@ import { resetPinnedProviders } from "../src/tool-operations.ts"; const mocks = vi.hoisted(() => { const repos = { list: vi.fn(), get: vi.fn() }; + const code = { search: vi.fn() }; const ciRuns = { list: vi.fn() }; const commits = { list: vi.fn(), get: vi.fn() }; const issues = { list: vi.fn(), search: vi.fn(), get: vi.fn(), create: vi.fn() }; @@ -27,12 +28,13 @@ const mocks = vi.hoisted(() => { resolve: vi.fn(), unresolve: vi.fn(), }; - const provider = { repos, ciRuns, commits, issues, pullRequests, users, threads }; + const provider = { repos, code, ciRuns, commits, issues, pullRequests, users, threads }; return { resolveToken: vi.fn(() => ({ token: "test-token", source: "env" as const })), createProvider: vi.fn(() => provider), repos, + code, ciRuns, commits, issues, @@ -50,6 +52,7 @@ vi.mock("../src/index.ts", () => ({ const toolNames = [ "forges_repos_list", "forges_repos_get", + "forges_code_search", "forges_ci_runs_list", "forges_commits_list", "forges_commits_get", @@ -189,6 +192,43 @@ describe("forges MCP server", () => { expect(response.structuredContent).toBeUndefined(); }); + it("searches code through the shared operation", async () => { + const search = { + items: [ + { + repository: "agntn/forges", + path: "src/provider.ts", + url: "https://github.com/agntn/forges/blob/main/src/provider.ts", + }, + ], + totalCount: 1, + incomplete: false, + hasNextPage: false, + }; + mocks.code.search.mockResolvedValue(search); + const client = await connectTestClient(); + + const response = await client.callTool({ + name: "forges_code_search", + arguments: { + platform: "github", + query: "Provider", + owner: "agntn", + repo: "forges", + page: 2, + perPage: 10, + }, + }); + + expect(mocks.code.search).toHaveBeenCalledWith("Provider", { + owner: "agntn", + repo: "forges", + page: 2, + perPage: 10, + }); + expect(JSON.parse(text(response.content))).toEqual({ platform: "github", result: search }); + }); + it("lists CI runs through the shared operation", async () => { const runs = { items: [ diff --git a/test/tool-operations.test.ts b/test/tool-operations.test.ts index daa8a10..6054417 100644 --- a/test/tool-operations.test.ts +++ b/test/tool-operations.test.ts @@ -6,6 +6,7 @@ import { getAuthenticatedUser, getRepository, reloadAuthentication, + searchCode, resetPinnedProviders, } from "../src/tool-operations.ts"; @@ -31,6 +32,21 @@ const mocks = vi.hoisted(() => { const login = anonymous ? "anonymous" : localLogin.current; return { + code: { + search: vi.fn(async (query: string, options: unknown) => ({ + items: [ + { + repository: "agntn/forges", + path: "src/provider.ts", + url: "https://github.com/agntn/forges/blob/main/src/provider.ts", + }, + ], + query, + options, + incomplete: false, + hasNextPage: false, + })), + }, repos: { list: vi.fn(), get: vi.fn(async (owner: string, repo: string) => ({ @@ -104,6 +120,22 @@ afterEach(() => { }); describe("configured provider", () => { + it("passes optional scope and pagination to code search", async () => { + const searched = await searchCode({ + platform: "github", + query: "Provider", + owner: "agntn", + repo: "forges", + page: 2, + perPage: 10, + }); + + expect(searched.details.result).toMatchObject({ + query: "Provider", + options: { owner: "agntn", repo: "forges", page: 2, perPage: 10 }, + }); + }); + it("writes as the account the authenticated check named, even after the local login moves", async () => { const identity = await getAuthenticatedUser({ platform: "github" }); // Another process runs `gh auth switch` between the confirmation and the write.