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. Ten 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 = await createProvider("github");
const repo = await github.repos.get("nitrojs", "nitro");
console.log(repo.fullName, repo.defaultBranch, repo.isFork);
const { items, nextCursor } = 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.
createProvider is async because it imports the provider module on demand: once the token is resolved, only the code for that one platform loads.
Same call, any platform
const gitlab = await createProvider("gitlab"); // GITLAB_TOKEN, then glab
const gitea = await 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 |
| Cloudflare Artifacts | artifacts | Authorization: Bearer | none | none |
Ten 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? }), listJobs(owner, repo, runId), readJobLog(owner, repo, jobId)
forge.commits; // list(owner, repo, { ref?, path?, since?, until? }), get(owner, repo, sha), listChecks(owner, repo, ref)
forge.releases; // list(owner, repo), get(owner, repo, tag), create, update(owner, repo, tag, input)
forge.issues; // list, search, searchGlobal, get, create, listComments, getComment, listReactions
forge.pullRequests; // list, listFiles, listChecks, listReviews, getReview, search, get, create, listComments, getComment, listReactions
forge.users; // get(username), authenticated()
forge.threads; // list, get, reply, resolve, unresolve
Every list is a PageResult<T>: items, nextCursor, and totalCount when the platform bothers to count. Searches add incomplete, true when the answer is known to be partial.
Why a cursor and not a page number? Because not every list counts pages. Artifacts hands out a token, and the REST lists already say where the next page lives in a Link header. So pass nextCursor back as cursor and stop when it's gone. It's opaque and it remembers the list, the repository, the filters and the page size it came from, so it won't open somebody else's page two. A URL inside it has to stay on the forge's own host, and with a token it's signed with a key drawn from that token and the instance. Write one by hand, or bring one minted under another token or another instance, and you get a 400, not your token sent somewhere else. The old page option gets a 400 too, with a note on what replaced it.
The requests go through forges by Daniel Roe. It picks the endpoints, follows the next links and keeps the token on the API host. One HTTP client fewer to keep honest. What comes back is mapped from the forge's own payload, so a date stays the exact string the forge sent.
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. The message keeps the reason the platform put in the error body, like Gitea's user does not exist for an owner it doesn't know, cut to 200 characters. An HTML error page adds nothing to it. 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.
A read that fails on a 429 or a 503 tries twice more. Does it wait a blind second? Not when the forge names the wait. Retry-After: 2 gets its two seconds. Ask for more than ten seconds and the read gives up right away. So does a spent rate limit that resets later than that. On a 429, retryAfter keeps the wait the forge asked for. Knocking three times on a door that opens in an hour isn't persistence.
And a write that dies on a 5xx? That one's awkward. The forge may have done it anyway and lost the answer on the way back. So the message says to check before you send it again. A blind retry there is how you get two identical issues. Lost connections count the same way once the request left. A refused one doesn't, nothing reached the forge.
Next
- Authentication: the token chain and self hosted endpoints.
- Repositories, Issues, Pull requests, Review threads, Commits, CI and releases.
- Agents: 55 tools over MCP, Pi, OMP and the CLI.
- Custom providers: extend
Providerfor a platform this package does not know.