One API for GitHub, GitLab, Gitea, and GitBucket. Write your code once, swap the provider string, done.
If you've ever had to maintain separate API integrations for different git platforms, you know the pain. They all do the same things but none of them agree on how. GitLab calls them "merge requests", GitHub calls them "pull requests". GitLab paginates with x-next-page headers, GitHub uses Link headers. GitLab authenticates with Private-Token, GitHub with Authorization: token. And so on.
@agntn/forges normalizes all of that behind one abstract provider API.
pnpm add @agntn/forges
# or: npm install @agntn/forgesIf you have gh (GitHub CLI) installed and logged in, this just works:
import { createProvider } from "@agntn/forges";
// No token needed - picked up from `gh auth token`
const github = createProvider("github");
const { items: repos } = await github.repos.list("unjs");
const repo = await github.repos.get("agntn", "forges");
console.log(repo.fullName, repo.defaultBranch);Same for GitLab with glab, and Gitea with tea or env vars.
You can always pass a token explicitly:
const github = createProvider("github", {
token: process.env.GITHUB_TOKEN,
});
const gitlab = createProvider("gitlab", {
token: "glpat-...",
baseURL: "https://gitlab.example.com",
});The detection chain is: explicit token > env vars (GITHUB_TOKEN, GITLAB_TOKEN, GITEA_TOKEN) > CLI tools (gh, glab) > CLI config files. First match wins.
GitBucket implements the GitHub API, so the same provider handles both. Just change the baseURL:
// GitHub - token auto-detected from gh CLI
const gh = createProvider("github");
// GitBucket - same provider, different URL
const gb = createProvider("github", {
token: "...",
baseURL: "https://my-gitbucket.example.com/api/v3",
});Uses Private-Token auth and the v4 API internally. You don't need to care about that.
// Token auto-detected from glab CLI or GITLAB_TOKEN env var
const gl = createProvider("gitlab");
// Or explicit
const gl2 = createProvider("gitlab", {
token: "glpat-...",
baseURL: "https://gitlab.example.com",
});Forgejo is a Gitea fork with the same API, so both work.
const gt = createProvider("gitea", {
baseURL: "https://codeberg.org", // or any Gitea/Forgejo instance
});The same nineteen tools - repositories, issues, pull requests, users, discussion comments, and review threads - are exposed over MCP and through the Pi and OMP extensions. They use the normal token detection chain. For a trusted self-hosted endpoint, set the matching local environment variable to the full API base URL:
| Platform | Environment variable |
|---|---|
| GitHub / GitBucket | FORGES_GITHUB_BASE_URL |
| GitLab | FORGES_GITLAB_BASE_URL |
| Gitea / Forgejo | FORGES_GITEA_BASE_URL |
These values are read from the agent process environment and are never exposed as model-callable tool arguments. Neither is a token, so nothing a model says can point an operation at a host of its own choosing.
forges mcpSpeaks MCP over stdio. Point a client at it:
{
"mcpServers": {
"forges": { "command": "npx", "args": ["-y", "@agntn/forges", "mcp"] }
}
}An MCP client sees the text a tool returns and nothing else, so the text carries the whole answer as JSON. The issue and pull-request lists 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. 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.
Five tools write: forges_issues_create, forges_pull_requests_create, forges_threads_reply, forges_threads_resolve and forges_threads_unresolve. They are advertised as writes so a client can gate them, and forges_users_authenticated names the account they would write as. The credential is resolved once per platform and endpoint and then held, so a login switched in the CLI underneath a running server reaches it only after a restart. A failed operation comes back as a tool error rather than a transport failure: an unknown repository, a rejected token, an exhausted rate limit.
createMcpServer() is exported from @agntn/forges/mcp for hosts that bring their own transport.
pi install npm:@agntn/forgesThe extensions add the details the harnesses render; MCP drops them and keeps the text. All three surfaces call the executors in src/tool-operations.ts, so they answer identically.
Every provider gives you five 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)
issues - list(owner, repo, opts?), get(owner, repo, number), create(owner, repo, input), listComments(owner, repo, number, opts?)
pullRequests - list(owner, repo, opts?), get(owner, repo, number), create(owner, repo, input), listComments(owner, repo, number, opts?)
users - get(username), authenticated()
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)
List operations accept ListOptions: page, perPage, and state ('open' | 'closed' | 'all'). They return PageResult<T> with items, hasNextPage, nextPage, and an optional totalCount.
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.
User lookups return the whole profile: bio, company, location, website, follower counts, the account creation date and the profile URL. On GitLab that takes two requests, because the username search returns only a bare stub; get resolves the id from it and then reads the full profile. Anything a platform does not expose comes back as an empty string or a zero count, like company on Gitea.
Thread list operations accept ListThreadOptions: page, perPage, and state ('unresolved' | 'resolved' | 'all'). GitHub review-thread list/get/resolve uses GraphQL so isResolved and isOutdated stay accurate; replies still go through the REST comment-reply endpoint. GitLab and Gitea have no equivalent flag, so isOutdated is always false there. GitBucket serves only REST v3, so thread operations against it fail with an explicit unsupported-endpoint error rather than a bare 404.
GET requests are cached automatically using unstorage with an LRU driver (5 min TTL, 500 entries). Entries are scoped to the client's base URL and a hash of its token, so two providers in one process — different hosts, or different tokens on the same host — never read each other's responses. Works out of the box, but you can tweak it:
const github = createProvider("github", {
cache: {
ttl: 60_000, // 1 minute
enabled: false, // or turn it off entirely
},
});All providers throw the same error types:
import { NotFoundError, AuthenticationError, RateLimitError } from "@agntn/forges";
try {
await provider.repos.get("owner", "nope");
} catch (err) {
if (err instanceof NotFoundError) {
// 404
}
if (err instanceof RateLimitError) {
// 429, check err.retryAfter
}
// All errors have err.status, err.platform, err.originalError
}A 404 from GitHub and a 404 from GitLab both become NotFoundError. Same for 401 (AuthenticationError) and 429 (RateLimitError). Everything else is a generic ForgesError.
If you only need one provider, import it directly. Better for tree-shaking.
import { Provider } from "@agntn/forges";
import { GitHubProvider } from "@agntn/forges/github";
import { GitLabProvider } from "@agntn/forges/gitlab";
import { GiteaProvider } from "@agntn/forges/gitea";
import type { Repository } from "@agntn/forges/types";
const gitea = new GiteaProvider({ token: process.env.GITEA_TOKEN });
console.log(gitea instanceof Provider); // trueProvider is the abstract base class for every implementation. It owns the
five resource accessors and requires typed mapping methods for owners,
repositories, issues, pull requests, users, and review threads. Concrete classes implement
those mappers and the platform-specific API operations.
The runtime base class is also available from @agntn/forges/provider. The
@agntn/forges/types subpath contains only TypeScript models and resource interfaces.
A few lower-level pieces are exported if you need them:
import { resolveToken } from "@agntn/forges";
import { fetchAllPages, paginate } from "@agntn/forges";resolveToken('github') runs the same auth detection chain without creating a provider. Useful for checking if credentials exist.
fetchAllPages(fetcher, url) collects every page into a single array. paginate(fetcher, url) is the async generator version if you want to process pages as they come.
This is an MVP. It covers repos, issues, PRs, users, and review threads. It does not handle:
- File/content operations (reading files, commits, trees)
- Webhooks
- Branch/tag management
- GraphQL outside GitHub review threads
- Admin operations
These might come later. For now the scope is intentionally small.
pnpm install
pnpm test # vitest in watch mode
pnpm run build # obuild