Getting Started
Why this exists
Every Git platform does the same things and none of them agree on how. GitLab says merge request, GitHub says pull request. GitLab paginates with an x-next-page header, GitHub with Link, Gitea wants limit where everyone else wants per_page. GitLab auth is Private-Token, GitHub and Gitea are Authorization: token. And GitLab numbers issues twice, an iid per project and an id per instance, and the one you want is the iid. Keep one client per platform in an agent and you have four ways to misread a number.
So @agntn/forges puts one abstract Provider in front of all of it. Nine resources, same method shapes, one Repository, one Issue, one PullRequest, one Thread. And it finds the token for you.
Install
pnpm add @agntn/forges
First call
If gh is installed and logged in, this works with no config at all:
import { createProvider } from "@agntn/forges";
// Token from GITHUB_TOKEN, then from `gh auth token`
const github = createProvider("github");
const repo = await github.repos.get("nitrojs", "nitro");
console.log(repo.fullName, repo.defaultBranch, repo.isFork);
const { items, hasNextPage } = await github.pullRequests.list("nitrojs", "nitro", {
state: "open",
});
for (const pr of items) {
console.log(`#${pr.number} ${pr.title} (${pr.sourceBranch} → ${pr.targetBranch})`);
}
Same for GitLab with glab, and Gitea with tea or GITEA_TOKEN. The whole chain is in Authentication.
Same call, any platform
const gitlab = createProvider("gitlab"); // GITLAB_TOKEN, then glab
const gitea = createProvider("gitea", { baseURL: "https://codeberg.org" }); // GITEA_TOKEN
await gitlab.repos.get("gitlab-org", "cli");
await gitea.repos.get("forgejo", "forgejo");
The Explorer runs these exact calls against the docs worker, so you see what comes back before writing a line.
What ships
| Platform | Provider | Auth header | Threads | Code search |
|---|---|---|---|---|
| GitHub | github | Authorization: token | GraphQL, real flags | global, owner, repository |
| GitLab | gitlab | Private-Token | REST discussions | token required, Premium for global |
| Gitea, Forgejo, Codeberg | gitea + baseURL | Authorization: token | one thread per review comment | none |
| GitBucket | github + baseURL | Authorization: token | none | none |
Nine resources
forge.repos; // list(owner), get(owner, repo)
forge.contributionTemplates; // list(owner, repo, kind), get(owner, repo, kind, key)
forge.code; // search(query, { owner?, repo? })
forge.ciRuns; // list(owner, repo, { branch? })
forge.commits; // list(owner, repo, { ref?, path?, since?, until? }), get(owner, repo, sha)
forge.issues; // list, search, get, create, listComments, getComment
forge.pullRequests; // list, listFiles, listChecks, search, get, create, listComments, getComment
forge.users; // get(username), authenticated()
forge.threads; // list, get, reply, resolve, unresolve
Every list is a PageResult<T>: items, hasNextPage, nextPage, and totalCount when the platform bothers to count. Searches add incomplete, true when the answer is known to be partial.
Errors
import { AuthenticationError, NotFoundError, PermissionError, RateLimitError } from "@agntn/forges";
try {
await forge.repos.get("owner", "nope");
} catch (error) {
if (error instanceof NotFoundError) {
// 404 on any platform
}
if (error instanceof RateLimitError) {
console.log(error.retryAfter); // seconds, when the platform said
}
// every one has error.status, error.platform and error.originalError
}
A 404 from GitHub and a 404 from GitLab are both NotFoundError. 401 is AuthenticationError, a 403 that is not a rate limit is PermissionError, 429 is RateLimitError. Everything else is a plain ForgesError with the status. Something a provider simply does not have, like code search on Gitea, is a ForgesError with status 501 and a sentence saying so. No pretending.
Next
- Authentication: the token chain and self hosted endpoints.
- Repositories, Issues, Pull requests, Review threads, Commits and CI.
- Agents: thirty tools over MCP, Pi and OMP.
- Custom providers: extend
Providerfor a platform this package does not know.