@henningschneidr/forgekit
v0.2.0
Published
General-purpose Forgejo API client SDK.
Maintainers
Readme
forgekit
A general-purpose, standalone TypeScript client SDK for the Forgejo REST API. It gives any project — flow-maid's workflow engine first, but not exclusively — a typed, idiomatic way to talk to a self-hosted Forgejo instance without hand-rolling HTTP calls.
For the full design rationale (scope decisions, HTTP layer choice, auth model, etc.), see SPEC.md.
Scope (v1)
- Branches — create, list (paged or async-iterable), delete
- Pull requests — create, merge, close, comment, list (paged or async-iterable, filterable by head/base branch)
- Contents — read a file, create-or-update a file, delete a file (all with git-author/committer attribution), recursively list every entry under a branch/tag/commit (paginated internally)
- Admin — user creation (raw primitive; auto-provisioning policy is a consumer concern), and repo creation on behalf of another user (site-admin +
write:admintoken) - Repos — create a repo on the token owner's own account or under an org
Everything else Forgejo's API exposes (issues, webhooks, releases, directory listing, organizations themselves, ...) is deliberately left out until a real consumer need shows up.
Installation
pnpm add @henningschneidr/forgekitQuickstart
import { createForgejoClient } from '@henningschneidr/forgekit';
const client = createForgejoClient({
baseUrl: 'https://forgejo.example.org',
token: process.env.FORGEJO_TOKEN!,
});
const branch = await client.branches.create({
owner: 'my-org',
repo: 'my-repo',
newBranchName: 'feature/x',
});
const pr = await client.pulls.create({
owner: 'my-org',
repo: 'my-repo',
title: 'Add feature X',
head: 'feature/x',
base: 'main',
});
await client.pulls.merge({
owner: 'my-org',
repo: 'my-repo',
index: pr.number,
style: 'squash',
deleteBranchAfterMerge: true,
});
for await (const b of client.branches.list({ owner: 'my-org', repo: 'my-repo' })) {
console.log(b.name);
}
const user = await client.admin.createUser({
username: 'new-user',
email: '[email protected]',
password: 'a-strong-password',
mustChangePassword: true,
});
const ownRepo = await client.repos.create({
name: 'my-new-repo',
private: true,
autoInit: true,
});
// owner omitted -> created on the token owner's own account; pass `owner` to create under an org
const repoForUser = await client.admin.createRepo({
username: 'new-user',
name: 'their-repo',
autoInit: true,
});
// site-admin + write:admin token required, same gate as admin.createUser
const file = await client.contents.write({
owner: 'my-org',
repo: 'my-repo',
path: 'content/event/42.json',
branch: 'feature/x',
content: new TextEncoder().encode('{"name":"Summer Fest"}'),
message: 'Write event 42',
committer: { name: 'Jane Editor', email: '[email protected]' },
});
const read = await client.contents.get({
owner: 'my-org',
repo: 'my-repo',
path: 'content/event/42.json',
ref: 'feature/x',
});
// read?.sha === file.sha; read is undefined if the file doesn't exist
const entries = await client.contents.listTree({
owner: 'my-org',
repo: 'my-repo',
ref: 'main',
});
// entries: { path, type: 'blob' | 'tree' | 'commit', sha }[] - every entry in the tree, recursively
await client.contents.delete({
owner: 'my-org',
repo: 'my-repo',
path: 'content/event/42.json',
branch: 'feature/x',
message: 'Remove event 42',
});
// a no-op if the file doesn't already existConfiguration
interface CreateForgejoClientConfig {
baseUrl: string;
token: string; // sent as `Authorization: token <TOKEN>` — Forgejo's own scheme, not Bearer
timeout?: number;
retry?: {
count?: number; // default: 2
delay?: number; // default: 500 (ms)
statusCodes?: number[]; // default: [429, 500, 502, 503, 504]
};
}Error handling
ForgejoApiError— thrown for any HTTP response Forgejo rejected a request with. Carriesstatus,url,errors?, andrawBody(the parsed response body verbatim, since Forgejo'smessageis sometimes a raw Go function name rather than human-readable text).ForgejoNetworkError— thrown when a request never got a response at all (connection refused, DNS failure, timeout).
import { ForgejoApiError, ForgejoNetworkError } from '@henningschneidr/forgekit';
try {
await client.pulls.merge({ owner, repo, index, style: 'squash' });
} catch (err) {
if (err instanceof ForgejoApiError) {
console.error(err.status, err.errors, err.rawBody);
} else if (err instanceof ForgejoNetworkError) {
console.error('could not reach Forgejo:', err.message);
} else {
throw err;
}
}Local development
pnpm install
pnpm run build # tsup
pnpm run dev # tsup --watch
pnpm run lint # biome check
pnpm run typecheck # tsc --noEmitNeed a real Forgejo instance to develop against (not just forgekit itself, but a consuming project like flow-maid)? See forgekit-dev-server — forgekit intentionally doesn't own one itself; see docs/adr/0001 for why.
Tests
pnpm run test:unit # fast, no external dependencies
pnpm run test:integration # boots a real, disposable Forgejo container via testcontainers — needs Docker
pnpm run test # bothIntegration tests exercise real Forgejo behavior rather than mocked HTTP responses (see SPEC.md testing strategy).
Before handing over any change, run pnpm run ai-verify (lint + typecheck + unit tests scoped to what changed).
Related
- Any Forgejo instance serves its own interactive API reference at
<baseUrl>/api/swagger— that's the canonical, always-in-sync source for the raw endpoints forgekit's surface is designed against. - forgekit-dev-server — spins up a local Forgejo instance for developing against, for any consuming project.
- flow-maid — the primary consumer, via a
ForgejoClientinterface forgekit's client satisfies at runtime - time-and-space-frontend — depends on
@henningschneidr/forgekitdirectly for its own read-side tree listing (contents.listTree)
License
AGPL-3.0-or-later. If AGPL's terms don't work for your use case (e.g. you want to use forgekit in closed-source software), open an issue on this repo — alternative licensing arrangements can be discussed.
