Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ------------------ | ------------------------ |
Expand Down Expand Up @@ -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)`
Expand All @@ -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<T>` 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.
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions packages/omp/extensions/forges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions packages/pi/extensions/forges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions packages/shared/forges-tool-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export type {
Thread,
PageResult,
SearchPageResult,
CodeSearchItem,
CodeSearchOptions,
ListOptions,
ListCiRunsOptions,
ListCommentOptions,
Expand All @@ -51,6 +53,7 @@ export type {
ReplyThreadInput,
ProviderConfig,
RepositoryResource,
CodeSearchResource,
CiRunResource,
CommitResource,
IssueResource,
Expand Down
11 changes: 11 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Value } from "typebox/value";
import { ForgesError, RateLimitError } from "./errors.ts";
import {
authenticatedUserParameters,
codeSearchParameters,
commentParameters,
commitParameters,
createIssueParameters,
Expand Down Expand Up @@ -53,6 +54,7 @@ import {
listThreads,
reloadAuthentication,
replyToThread,
searchCode,
searchIssues,
searchPullRequests,
resolveThread,
Expand Down Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { ForgesError } from "./errors.ts";
import type {
CiRun,
CiRunResource,
CodeSearchItem,
CodeSearchOptions,
CodeSearchResource,
Comment,
Commit,
CommitResource,
Expand Down Expand Up @@ -65,6 +68,7 @@ export interface ProviderRawTypes {
*/
export abstract class Provider<Raw extends ProviderRawTypes = ProviderRawTypes> {
public readonly repos: RepositoryResource;
public readonly code: CodeSearchResource;
public readonly ciRuns: CiRunResource;
public readonly commits: CommitResource;
public readonly issues: IssueResource;
Expand All @@ -77,6 +81,17 @@ export abstract class Provider<Raw extends ProviderRawTypes = ProviderRawTypes>
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),
};
Expand Down Expand Up @@ -152,6 +167,12 @@ export abstract class Provider<Raw extends ProviderRawTypes = ProviderRawTypes>
options?: ListOptions,
): Promise<PageResult<Repository>>;
protected abstract getRepo(owner: string, repo: string): Promise<Repository>;
protected searchCode(
_query: string,
_options?: CodeSearchOptions,
): Promise<SearchPageResult<CodeSearchItem>> {
return Promise.reject(new ForgesError("Code search is not supported by this provider", 501));
}
protected listCiRuns(
_owner: string,
_repo: string,
Expand Down
89 changes: 89 additions & 0 deletions src/providers/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { Provider, type ProviderRawTypes } from "../provider.ts";
import type {
ProviderConfig,
Repository,
CodeSearchItem,
CodeSearchOptions,
CiRun,
Commit,
CommitSummary,
Expand Down Expand Up @@ -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) ---

Expand All @@ -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;
Expand Down Expand Up @@ -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<TRaw, TMapped>(
items: TRaw[],
headers: Headers,
Expand Down Expand Up @@ -742,6 +767,70 @@ export class GitHubProvider extends Provider<GitHubRawTypes> {
}
}

// --- Code search ---

protected override async searchCode(
searchQuery: string,
options?: CodeSearchOptions,
): Promise<SearchPageResult<CodeSearchItem>> {
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<string, string> = {
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<GitHubCodeSearchResponse>(
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(
Expand Down
Loading
Loading