One TypeScript API for GitHub and GitLab. Write bots, CI scripts and other automation once, and run them against either forge.
interforge is a typed client for the GitHub REST API and the GitLab REST API behind a single interface, so you don't need Octokit for one and gitbeaker for the other. It covers issues, pull requests and merge requests, comments, commit statuses and reading repository files. It runs on Node.js 22+ and uses only web APIs, so it's built to work in browsers and Workers-style runtimes too. The types are generated from each forge's own OpenAPI description, and the object model follows Python's ogr.
Warning
Experimental. interforge is a prototype: it isn't on npm yet, and it's only partly verified against live forges (see Verified so far). Expect breaking changes, and don't depend on it for anything that matters yet.
import { getProject, servicesFromEnv } from 'interforge';
// GITHUB_TOKEN and/or GITLAB_TOKEN (+ CI_SERVER_URL for self-managed GitLab)
const project = await getProject(
'https://gitlab.com/group/sub/widgets',
servicesFromEnv(),
);
const issue = await project.createIssue('Widgets wobble', 'They wobble.', {
labels: ['bug'],
assignees: ['bob'],
});
await issue.comment('Looking into it');
for (const comment of await issue.getComments()) {
console.log(comment.author, comment.body); // system notes ("added ~bug") are left out
}| GitHub | GitLab | |
|---|---|---|
| Projects: get, from URL, exists | ✓ | ✓ (nested groups) |
| Issues: list/filter, get, create, close, title/description, labels, assignees | ✓ | ✓ |
| Comments on issues and PRs/MRs: list/filter, get, create, edit | ✓ | ✓ |
| Pull/merge requests: list by status, get, create, update, close, merge, labels | ✓ | ✓ |
| Commit statuses: set, list, PR head statuses | ✓ | ✓ |
| File contents at a branch, tag or commit | ✓ (incl. files over 1 MB) | ✓ |
| List files: top level or recursive, regex filter, at a ref | ✓ (walks trees GitHub truncates) | ✓ (keyset pagination) |
| Retries, timeouts, rate limits, remaining quota | ✓ | ✓ |
| Lazy iteration and limits for issues, PRs, comments, statuses | ✓ | ✓ |
| Dry run (reads go through, writes are recorded) | ✓ | ✓ |
| Current user | ✓ | ✓ |
Differences are explicit: something one forge can't do throws OperationNotSupported instead of silently doing less. For example, GitHub has no private issues, and GitLab Free would silently keep only the first assignee, so interforge throws instead.
Every list can be iterated lazily. Pages are fetched only as you consume them, so stopping early saves requests:
for await (const issue of project.iterateIssues({ status: 'all' })) {
if (issue.title.includes('flaky')) break; // no more pages are fetched
}
await project.getIssueList({ labels: ['bug'], limit: 10 }); // asks for 10, not 100
await issue.getComments({ limit: 5 }); // oldest five
await issue.getComments({ reverse: true, limit: 5 }); // newest five (fetches all)The same iterate*() and limit pairs cover issues, pull requests, comments and commit statuses.
Like ogr's read-only mode: reads go to the forge as usual, but writes are skipped and recorded.
- Creates return stand-in objects with
id: 0. - Updates change only the local object.
- Recording: every skipped write goes into
service.dryRunLog, and to your callback if you pass one.
const service = new GitlabService({
token,
dryRun: ({ action, target, details }) =>
console.log(`[dry run] ${action} ${target}`, details),
});
const issue = await project.createIssue('Title', 'Body', { labels: ['bug'] });
// issue.id === 0, nothing was created, and service.dryRunLog has the createIssueservice.dryRun can be switched at any time. Stand-ins are only useful while dry run is on, because their id: 0 doesn't exist on the forge.
- Runtimes: Node 22+, browsers, and Workers-style runtimes. The library uses only
fetch,atobandTextDecoder. CI bundles it for the browser with esbuild, which fails on any Node built-in, including inside dependencies. - GitHub.com: requests REST API version
2026-03-10(GITHUB_API_VERSION), the version the types are generated from. - GitHub Enterprise Server: set
instanceUrl, and requests go to<instance>/api/v3. A server that doesn't know the default API version rejects every request, so pass a version it supports, such asapiVersion: '2022-11-28'. Not tested against a real server. - GitLab: built from GitLab 19.4's description and tested on gitlab.com. Older self-managed versions aren't tested. Two things degrade gracefully on older instances:
- If
activity_filteris ignored, system notes are still filtered out locally. - If the repository tree has no keyset pagination, file listing falls back to page numbers.
- If
- When the specs move ahead of a server: the weekly spec update tracks the latest releases. The mappers only require the fields that the required-fields overlay (
03-required-and-nullable-fields.yaml) declares, and those were checked against live gitlab.com traffic.
Both clients retry and time out by default:
- Rate limits are retried for any method, because a rate-limited request wasn't carried out. That includes GitHub's 403-style limits and secondary limits, not just 429. interforge waits for
Retry-Afteror the reset time. - Long limits: if a limit won't clear within
maxWait, the call throwsRateLimitErrorwithresetAtinstead of sleeping. - Server errors and network failures (500/502/503/504) are retried with backoff and jitter, for reads only. A failed write may still have taken effect.
- Rate-limit quota:
service.rateLimitholds the latest rate-limit headers, andgetRateLimitRemaining()reports the remaining quota. GitHub checks this without spending quota; GitLab uses the last response's headers.
new GitlabService({
token,
retry: {
retries: 3, // after the first attempt
maxWait: 60_000, // ms; longer limits throw RateLimitError
baseDelay: 1000, // first backoff; doubles each retry
timeout: 30_000, // per attempt; false for none
onRetry: ({ reason, wait, url }) =>
console.warn(`retrying ${url}: ${reason}`),
},
});
new GithubService({ token, retry: false }); // no retriesThe object model follows packit/ogr's ogr/abstract/* (GitService → GitProject → Issue / PullRequest → Comment, plus CommitFlag, status enums and the exception hierarchy), with ogr's names converted to camelCase. interforge borrows the design only; no code is ported. ogr is Python and lazy (reading issue.title can make a request). interforge uses hydrated objects instead:
await project.getIssue(5)returns anIssuewhose fields are plain data. Reading a field never makes a request.- Anything that talks to the forge is an async method, such as
await issue.close()orawait issue.setTitle('…'), and updates the object in place. refresh()re-fetches the object, andtoJSON()returns plain data.
There's no Octokit or gitbeaker. Each forge's client is openapi-fetch over types generated from the forge's own OpenAPI description:
spec/<forge>/config.json pinned upstream description + the operations we use
spec/gitlab/overlays/*.yaml OpenAPI Overlays that fix upstream mistakes
│ npm run specs:update
▼
spec/<forge>/openapi.json trimmed description (used to validate test traffic)
src/services/<forge>/openapi.ts generated types (used by the client)
npm run specs:update downloads the pinned descriptions, applies the overlays, keeps only the listed operations and unused-component-pruned schemas, and regenerates the types. It fails if an overlay stops matching (upstream may have fixed the issue) or if a listed operation disappears. CI checks that the generated files are up to date.
A scheduled workflow (.github/workflows/update-specs.yml, Mondays and on demand) runs npm run specs:bump to move the pins to the latest upstream versions: the newest commit and dated API version of github/rest-api-description, and the newest stable GitLab release tag. It then regenerates, runs the typecheck and tests, and opens or updates a pull request with the results. If an overlay stops matching or an operation disappears, the run fails instead, because that needs a person to look at it.
The upstream problems found so far, each fixed by an overlay in spec/<forge>/overlays/:
- GitLab list endpoints (
GET …/issues,…/merge_requests,…/notes,…/statuses,/users) are declared to return a single object, not an array. - GitLab
assigneesandreviewersare declared as a single user, not an array. - GitLab marks no properties as
requiredand misses nullable fields (description,closed_at,merged_at,source_project_id, …). Overlay 03 declares the fields interforge relies on. That list was checked against live gitlab.com projects, issues, merge requests and users, but not yet notes or statuses. - GitLab's repository file endpoint has no response schema at all.
- GitHub marks
issue.pull_request.merged_atas non-nullable, but the API returnsnullfor unmerged pull requests. - GitHub's pull request list schema marks label
descriptionas non-nullable, although the full pull request schema, and the API, allownull. - GitHub's bundled examples lag its schemas. For example,
full-repositorylackslanguage, and labels lackarchived_at. These examples are only used in tests, which patch them.
About 60 more nullability gaps show up in real traffic, almost all in fields interforge doesn't read (milestones, time stats, auto_merge, …). These are left alone until something depends on them.
Runtime dependencies:
- openapi-fetch for the typed HTTP client.
- ky for retries, backoff, jitter and timeouts.
- http-link-header for pagination links.
- git-url-parse for project URLs.
Build and test tooling:
- openapi-format and openapi-typescript build the specs and types.
- msw with openapi-msw runs the fakes. A misspelled fake route fails to compile.
- openapi-backend validates every request and response against the spec.
- openapi-sampler builds sample objects from the spec.
Custom code is kept only where no suitable library exists:
| Custom code | Why |
|---|---|
| Forge-specific rate-limit rules | ky can't express GitHub's 403 limits, or failing fast instead of capping the wait. |
| Rate-limit header parser | The only one on npm (ratelimit-header-parser) is an unmaintained 0.1.0 release from 2023. |
Keeping only the used operations (scripts/update-specs.ts) |
openapi-format's inverseOperationIds also deletes schema properties named after HTTP methods, such as head in GitHub's create-PR body (openapi-format#238). |
Converting nullable for the test validator |
openapi-format's 3.1 conversion drops nullable next to allOf (openapi-format#239), @openapi-contrib/openapi-schema-to-json-schema drops it next to allOf and oneOf, and @scalar/openapi-upgrader drops it next to oneOf. GitHub's spec has both. |
Recognizing GitHub sub-page URLs (…/pull/7/files) |
git-url-parse reads these as part of the repository path. |
| Mappers and comment filtering | This is the library's own logic. |
test/support/fake-{github,gitlab}.ts are small stateful fakes built on msw. GitHub's fake starts from the examples in GitHub's description. GitLab's is sampled from the property examples in GitLab's description. Every request (path, query and body) and every response they handle is validated against the OpenAPI description, so a fake that drifts from the spec, or a mapper that sends the wrong shape, fails the test.
test/conformance/has one suite that runs unchanged against both forges.test/services/covers forge-specific behavior: GitLab system notes, nested groups, the assignee limit on Free, and GitHub pull requests appearing in issue lists.
npm test # vitest
npm run lint # eslint + prettier
npm run typecheck
npm run build
npm run specs:update
npm run specs:bump # move pins to the latest upstream descriptions
# Live tests against a scratch repository (writes to it; cleans up after)
INTERFORGE_GITHUB_TOKEN=… INTERFORGE_GITHUB_REPO=owner/repo npm run test:live- The unit and conformance tests all run against the fakes.
- A read-only run against real data:
benbalter/word-to-markdownon GitHub, andgitlab-org/api/client-goon gitlab.com anonymously. It covered project lookup (including nested-group%2Fencoding), issue and PR/MR lists withLinkpagination, comma-separated label filters, file contents (nested paths and refs), PRs being left out of GitHub issue lists, GitHub comments, and merged-status filtering. - GitHub, writes included:
npm run test:liveruns the full lifecycle against a scratch repository. That covers issues, comments, labels, assignees, files on a branch, pull requests (including merging) and commit statuses, plus a dry run. Every response is checked against GitHub's spec, with 0 mismatches after the overlays. GitHub's list endpoints can lag a write by a few seconds, so the suite polls for those checks. - GitLab: not yet verified live for writes, notes or commit statuses. gitlab.com answers
401for those without a token, even on public projects. - Anonymous gitlab.com requests get a reduced project view without
issues_enabled.hasIssuesthen defaults totrue.
- Run the live suite against GitLab. It needs a token and a scratch project.
- Try GitHub Enterprise Server and an older self-managed GitLab.
- Try it in practice: port bulk-issue-creator onto interforge on a branch and run it against GitLab.
- See ROADMAP.md for the path to full ogr parity and more forges.
MIT, except the files generated from GitLab's OpenAPI description (spec/gitlab/ and src/services/gitlab/openapi.ts), which keep GitLab's CC BY-SA 4.0 license. See NOTICE for details and attribution.