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
30 changes: 17 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,20 @@ test/

**Where to put new code:**

| Task | Location | Notes |
| -------------------- | ----------------------------------- | ------------------------------------------------------------------ |
| Add new provider | `src/providers/` | Copy github.ts as template. Extend the abstract `Provider` base |
| Add new resource | `src/types.ts` → provider files | Define interface in types.ts, implement in each provider |
| Change auth logic | `src/auth.ts` | `resolveToken()` chain — order matters |
| Change cache backend | `src/cache.ts` | `configureStorage()` swaps unstorage driver |
| Fix pagination | `src/pagination.ts` | `parseLinkHeader()` for GitHub/Gitea, `x-next-page` for GitLab |
| Fix error mapping | `src/errors.ts` | `normalizeError()` maps FetchError → ForgesError subtypes |
| Add sub-path export | `build.config.mjs` + `package.json` | Must update both: entries array + exports map |
| Add agent tool | `src/tool-operations.ts` | Executor first, then `src/mcp.ts` and both extensions |
| Change tool schema | `packages/shared/` | MCP and Pi share it; OMP rebuilds it from `pi.typebox` |
| Debug HTTP | `src/http.ts` | `rawFetch()` returns headers, `createHttpClient()` configures auth |
| Add tests | `test/` | Name must match `test/<module>.test.ts` |
| Task | Location | Notes |
| ----------------------------- | ----------------------------------- | ------------------------------------------------------------------ |
| Add new provider | `src/providers/` | Copy github.ts as template. Extend the abstract `Provider` base |
| Add new resource | `src/types.ts` → provider files | Define interface in types.ts, implement in each provider |
| Change contribution templates | `src/provider.ts` + provider files | Keep lists metadata-only; `get` must resolve an exact listed key |
| Change auth logic | `src/auth.ts` | `resolveToken()` chain: order matters |
| Change cache backend | `src/cache.ts` | `configureStorage()` swaps unstorage driver |
| Fix pagination | `src/pagination.ts` | `parseLinkHeader()` for GitHub/Gitea, `x-next-page` for GitLab |
| Fix error mapping | `src/errors.ts` | `normalizeError()` maps FetchError → ForgesError subtypes |
| Add sub-path export | `build.config.mjs` + `package.json` | Must update both: entries array + exports map |
| Add agent tool | `src/tool-operations.ts` | Executor first, then `src/mcp.ts` and both extensions |
| Change tool schema | `packages/shared/` | MCP and Pi share it; OMP rebuilds it from `pi.typebox` |
| Debug HTTP | `src/http.ts` | `rawFetch()` returns headers, `createHttpClient()` configures auth |
| Add tests | `test/` | Name must match `test/<module>.test.ts` |

## Code Conventions

Expand Down Expand Up @@ -210,6 +211,9 @@ vi.mock("../src/cache.ts", () => ({ cachedFetch: mocks.cachedFetch }));
- **GitBucket** works via GitHub provider with custom `baseURL` — no separate provider needed.
- **GitLab `/users/:owner/projects`** returns 404 for groups — `listRepos` falls back to `/groups/:owner/projects` only on 404, re-throws other errors.
- **GitHub `/issues` returns PRs** — filtered by absence of `pull_request` key.
- **GitHub template scope is explicit:** repository files and inherited owner `.github` defaults are different scopes; local overrides apply independently to issue and pull-request templates.
- **GitLab uses `iid`** (project-scoped) not `id` (global) for issue/MR numbers.
- **GitLab template provenance can be hidden:** use the effective template API and leave inherited source fields unknown rather than guessing a group or instance source.
- **Gitea uses `limit`** param, not `per_page`.
- **Gitea templates are repository-scoped:** do not claim GitHub-style owner inheritance.
- **unstorage memory driver has no TTL** — that's why lru-cache driver is used.
12 changes: 8 additions & 4 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-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:
The same thirty tools - repositories, contribution templates, 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 All @@ -120,7 +120,7 @@ Speaks MCP over stdio. Point a client at it:
}
```

An MCP client sees the text a tool returns and nothing else, so the text carries the whole answer as JSON. Issue and pull-request lists and searches drop bodies outright and name the tool that reads one in full, because one page of a busy repository is otherwise large enough to crowd out the conversation that asked for it. Pull-request search also leaves revision details to `forges_pull_requests_get`; GitHub and Gitea search responses do not carry them, and extra detail requests would make one search page unnecessarily expensive. `forges_threads_list` bounds each comment instead - twelve lines, four thousand characters - but keeps every comment of every thread on the page, so ask it for a small `perPage` on a heavily reviewed pull request. `forges_issues_comments` and `forges_pull_requests_comments` carry the same per-comment bound, and their `_get` variants read a single comment whole.
An MCP client sees the text a tool returns and nothing else, so the text carries the whole answer as JSON. Contribution-template lists, issue and pull-request lists, and searches drop bodies outright and name the tool that reads one in full, because one page of a busy repository is otherwise large enough to crowd out the conversation that asked for it. Pull-request search also leaves revision details to `forges_pull_requests_get`; GitHub and Gitea search responses do not carry them, and extra detail requests would make one search page unnecessarily expensive. `forges_threads_list` bounds each comment instead - twelve lines, four thousand characters - but keeps every comment of every thread on the page, so ask it for a small `perPage` on a heavily reviewed pull request. `forges_issues_comments` and `forges_pull_requests_comments` carry the same per-comment bound, and their `_get` variants read a single comment whole.

A failure names the status and, on a rate limit, the retry window; it never repeats the endpoint the request went to, so a self-hosted `FORGES_*_BASE_URL` stays out of the model's context even when the platform answers with an error.

Expand All @@ -138,10 +138,12 @@ The extensions add the details the harnesses render; MCP drops them and keeps th

## API

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.
Every provider gives you nine 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)`

**contributionTemplates** - `list(owner, repo, kind, opts?)`, `get(owner, repo, kind, key)`

**code** - `search(query, opts?)`

**ciRuns** - `list(owner, repo, opts?)`
Expand All @@ -156,6 +158,8 @@ Every provider gives you eight 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)`

Contribution-template lists accept `issue` or `pull_request` as `kind` and paginate metadata without returning template bodies. Each `ContributionTemplateSummary` carries an opaque `key`, `scope`, `inherited`, `sourceRepository`, `sourcePath`, and `sourceRef`; pass the returned kind and key unchanged to `get` for the complete source body. On GitHub, `scope: "repository"` means a local file under the target repository, such as `agntn/repo` plus `.github/ISSUE_TEMPLATE/bug.yml`. `scope: "owner"` means an inherited default from the `agntn/.github` repository. Local issue and pull-request overrides are resolved independently. GitHub-compatible hosts without GitHub Enterprise headers expose repository scope only instead of guessing at owner inheritance. Discovery follows each platform's recognized locations and precedence but does not lint template frontmatter or form schemas. GitLab uses its effective project template API, including group and instance inheritance; when that API hides the winning inherited source, `scope` is `unknown` and the three source fields are `null` instead of guesses. Gitea and Forgejo expose repository scope only.

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.
Expand Down Expand Up @@ -217,7 +221,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 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.
`Provider` is the abstract base class for every implementation. It owns the nine resource accessors, while concrete classes implement the typed mappers and platform-specific API operations. Custom providers that do not implement contribution templates, 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
46 changes: 46 additions & 0 deletions packages/omp/extensions/forges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void {
);
const owner = Type.String({ description: "Repository owner or organization", minLength: 1 });
const repo = Type.String({ description: "Repository name", minLength: 1 });
const contributionTemplateKind = Type.Union(
[Type.Literal("issue"), Type.Literal("pull_request")],
{ description: "Contribution template kind" },
);
const contributionTemplateKey = Type.String({
description: "Provider key returned by the contribution-template list operation",
minLength: 1,
});
const sha = Type.String({ description: "Commit SHA", minLength: 1 });
const branch = Type.Optional(Type.String({ description: "Filter by branch", minLength: 1 }));
const ref = Type.Optional(
Expand Down Expand Up @@ -68,6 +76,21 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void {

const listRepositoriesParameters = Type.Object({ platform, owner, page, perPage });
const repositoryParameters = Type.Object({ platform, owner, repo });
const listContributionTemplatesParameters = Type.Object({
platform,
owner,
repo,
kind: contributionTemplateKind,
page,
perPage,
});
const contributionTemplateParameters = Type.Object({
platform,
owner,
repo,
kind: contributionTemplateKind,
key: contributionTemplateKey,
});
const codeSearchParameters = Type.Object({
platform,
query: Type.String({
Expand Down Expand Up @@ -191,6 +214,29 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void {
},
});

pi.registerTool({
name: "forges_contribution_templates_list",
label: "Forges Contribution Templates",
description:
"List paged metadata for effective issue or pull-request templates, including inheritance provenance",
parameters: listContributionTemplatesParameters,
approval: "read",
async execute(_toolCallId, params) {
return (await loadToolOperations()).listContributionTemplates(params);
},
});

pi.registerTool({
name: "forges_contribution_templates_get",
label: "Forges Contribution Template",
description: "Get one effective contribution template with its complete source body",
parameters: contributionTemplateParameters,
approval: "read",
async execute(_toolCallId, params) {
return (await loadToolOperations()).getContributionTemplate(params);
},
});

pi.registerTool({
name: "forges_code_search",
label: "Search Forges Code",
Expand Down
31 changes: 31 additions & 0 deletions packages/pi/extensions/forges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import {
codeSearchParameters,
commentParameters,
commitParameters,
contributionTemplateParameters,
createIssueParameters,
createPullRequestParameters,
listCiRunsParameters,
listCommentsParameters,
listCommitsParameters,
listContributionTemplatesParameters,
listPullRequestChecksParameters,
listPullRequestFilesParameters,
listRepositoriesParameters,
Expand Down Expand Up @@ -112,6 +114,35 @@ export default function forgesExtension(pi: ExtensionAPI): void {
},
});

pi.registerTool({
name: "forges_contribution_templates_list",
label: "Forges Contribution Templates",
description:
"List paged metadata for effective issue or pull-request templates, including inheritance provenance",
promptSnippet: "Discover the contribution templates that apply to a repository.",
promptGuidelines: [
"Use forges_contribution_templates_list before drafting an issue or pull request; pass one returned kind and key to forges_contribution_templates_get when its full body is needed.",
],
parameters: listContributionTemplatesParameters,
async execute(_toolCallId, params) {
return (await loadToolOperations()).listContributionTemplates(params);
},
});

pi.registerTool({
name: "forges_contribution_templates_get",
label: "Forges Contribution Template",
description: "Get one effective contribution template with its complete source body",
promptSnippet: "Read one issue or pull-request template in full.",
promptGuidelines: [
"Use forges_contribution_templates_get only with the exact kind and key returned by forges_contribution_templates_list.",
],
parameters: contributionTemplateParameters,
async execute(_toolCallId, params) {
return (await loadToolOperations()).getContributionTemplate(params);
},
});

pi.registerTool({
name: "forges_code_search",
label: "Search Forges Code",
Expand Down
Loading
Loading