forgejo-ts
v0.4.1
Published
Zero-dependency TypeScript client for the Forgejo REST API
Readme
forgejo-ts
Zero-dependency TypeScript client for the Forgejo REST API. Works with Forgejo, Gitea, and Codeberg instances.
News
0.4.1
- Add typed repository branch and contents APIs, including page-level branch listing and bounded contents requests for remote repository browsers.
0.4.0
- Add typed page-level list/search APIs for pull requests, issues, PR files, reviews, comments, timelines, workflow runs, statuses, tags, releases, and repository search.
- Export shared pagination types so UI clients can implement load-more flows without raw API calls or unbounded list fetches.
- Keep all existing all-pages list methods backward compatible by building them on the new paginated APIs.
0.3.3
- Add typed issue and pull request list options, including server-side free-text search for issues and pull requests. Pull request search hydrates matching PR details in bounded batches.
0.3.2
- Add repository APIs for creating user repositories, creating organization repositories, and searching repositories.
- Paginate repository search and pull request reviews/commits, review comments, issue comments/timeline, and commit statuses so large result sets return complete results.
0.3.1
- Fix
getPullRequestFiles()pagination so large pull requests return all changed files.
Features
- Zero external dependencies (uses Node.js built-in
fetch) - TypeScript declarations with Node.js 18+ runtime support
- Full TypeScript types for all API responses
- Injectable logger interface
- Typed error classes (
ForgejoApiError,ForgejoNetworkError) - Covers pull requests, issues, repositories, CI/actions, tags, releases, file contents, reviews, and more
- Raw API escape hatch for any endpoint not covered by typed methods
Installation
# If published on npm
npm install forgejo-ts
# Or directly from git
npm install git+https://git.araj.me/maxking/forgejo-ts.gitQuick Start
import { ForgejoClient, ForgejoApiError } from 'forgejo-ts';
const client = new ForgejoClient({
instanceUrl: 'https://codeberg.org',
token: 'your-api-token', // optional for public repos
timeout: 30000, // optional, default 30s
});
// List open pull requests
const prs = await client.listPullRequests('owner', 'repo', 'open');
// Fetch one page for load-more UIs
const prPage = await client.listPullRequestsPage('owner', 'repo', { state: 'open', page: 1, limit: 50 });
// Search open pull requests by title/body
const matchingPrs = await client.listPullRequests('owner', 'repo', { state: 'open', query: 'bugfix' });
// Get issue details
const issue = await client.getIssue('owner', 'repo', 42);
// Search open issues by title/body
const matchingIssues = await client.listIssues('owner', 'repo', { state: 'open', query: 'crash' });
// Create a pull request
const pr = await client.createPullRequest('owner', 'repo', 'My PR', 'feature-branch', 'main', 'Description');
// Search repositories (paginates automatically)
const repos = await client.searchRepositories('forgejo-ts');
// Create a repository for the authenticated user
const repo = await client.createRepository({
name: 'new-project',
description: 'Created with forgejo-ts',
private: true,
auto_init: true,
});
// Error handling
try {
await client.getPullRequest('owner', 'repo', 999);
} catch (err) {
if (err instanceof ForgejoApiError) {
console.error(err.statusCode, err.responseBody);
}
}API
Constructor
new ForgejoClient(options: {
instanceUrl: string; // e.g. "https://codeberg.org"
token?: string; // personal access token
logger?: ForgejoLogger; // custom logger (default: silent)
timeout?: number; // request timeout in ms (default: 30000)
})Methods
| Category | Method | Description |
|----------|--------|-------------|
| Connection | testConnection() | Test connectivity, returns boolean |
| Pull Requests | | |
| | listPullRequests(owner, repo, stateOrOptions?) | List PRs (paginates automatically); accepts state string or { state, query } |
| | listPullRequestsPage(owner, repo, options?) | List one PR page with pagination metadata |
| | searchPullRequestsPage(owner, repo, options) | Search one PR page by title/body with pagination metadata |
| | getPullRequest(owner, repo, number) | Get PR details |
| | createPullRequest(owner, repo, title, head, base, body?) | Create a PR |
| | updatePullRequest(owner, repo, number, updates) | Update PR title/body/state |
| | mergePullRequest(owner, repo, number, method?, deleteBranch?) | Merge a PR |
| | closePullRequest(owner, repo, number) | Close a PR |
| | getPullRequestFiles(owner, repo, number) | List changed files (paginates automatically) |
| | getPullRequestFilesPage(owner, repo, number, options?) | List one changed-file page |
| | getPullRequestRefs(owner, repo, number) | Get head/base branch refs |
| | getPullRequestReviews(owner, repo, number) | List reviews (paginates automatically) |
| | getPullRequestReviewsPage(owner, repo, number, options?) | List one review page |
| | getPullRequestCommits(owner, repo, number) | List commits (paginates automatically) |
| | getPullRequestCommitsPage(owner, repo, number, options?) | List one commit page |
| Reviews | | |
| | getReviewComments(owner, repo, prNumber, reviewId) | Get review comments (paginates automatically) |
| | getReviewCommentsPage(owner, repo, prNumber, reviewId, options?) | Get one review-comment page |
| | createReview(owner, repo, number, state, body) | Create a review |
| | createReviewWithComments(owner, repo, prNumber, options) | Create review with inline comments |
| Issues | | |
| | listIssues(owner, repo, stateOrOptions?) | List issues (PRs filtered out, paginates automatically); accepts state string or { state, query } |
| | listIssuesPage(owner, repo, options?) | List/search one issue page with pagination metadata |
| | getIssue(owner, repo, number) | Get issue details |
| | createIssue(owner, repo, title, body?) | Create an issue |
| | updateIssue(owner, repo, number, updates) | Update issue title/body/state |
| | getIssueComments(owner, repo, number) | List comments (paginates automatically) |
| | getIssueCommentsPage(owner, repo, number, options?) | List one issue-comment page |
| | createComment(owner, repo, number, body) | Add a comment |
| | getIssueTimeline(owner, repo, number) | Get timeline events (paginates automatically) |
| | getIssueTimelinePage(owner, repo, number, options?) | Get one timeline page |
| Repositories | | |
| | createRepository(options) | Create a repository for the authenticated user |
| | createOrgRepository(org, options) | Create a repository in an organization |
| | searchRepositories(query?, limit?) | Search repositories (paginates automatically) |
| | searchRepositoriesPage(options?) | Search one repository page |
| Files | | |
| | getFileContents(owner, repo, filepath, ref) | Get decoded file contents |
| CI / Actions | | |
| | listWorkflowRuns(owner, repo, options?) | List workflow runs (paginates) |
| | listWorkflowRunsPage(owner, repo, options?) | List one workflow-run page |
| | getWorkflowRun(owner, repo, runId) | Get run details |
| | getWorkflowJobs(owner, repo, runId) | Get jobs for a run |
| | getWorkflowLogs(owner, repo, runNumber, jobIndex?) | Fetch job logs |
| | getJobSteps(owner, repo, runNumber, jobRef?) | Parse step summaries |
| | getRunJobMapping(owner, repo, runNumber) | Map job database IDs to positional indices |
| | rerunWorkflow(owner, repo, runId) | Re-run a workflow |
| | getCommitStatuses(owner, repo, sha) | Get commit statuses (paginates automatically) |
| | getCommitStatusesPage(owner, repo, sha, options?) | Get one commit-status page |
| Tags | | |
| | listTags(owner, repo) | List tags |
| | listTagsPage(owner, repo, options?) | List one tag page |
| | createTag(owner, repo, options) | Create a tag |
| | deleteTag(owner, repo, tagName) | Delete a tag |
| Releases | | |
| | listReleases(owner, repo) | List releases |
| | listReleasesPage(owner, repo, options?) | List one release page |
| | createRelease(owner, repo, options) | Create a release |
| | getRelease(owner, repo, id) | Get release by ID |
| | getReleaseByTag(owner, repo, tag) | Get release by tag name |
| | deleteRelease(owner, repo, id) | Delete a release |
| Raw | | |
| | rawRequest(method, endpoint, body?) | Escape hatch for any endpoint |
Custom Logger
Inject your own logger to capture client activity:
import { ForgejoClient, ForgejoLogger } from 'forgejo-ts';
const logger: ForgejoLogger = {
debug: (msg, ...args) => console.debug('[forgejo]', msg, ...args),
info: (msg, ...args) => console.info('[forgejo]', msg, ...args),
warn: (msg, ...args) => console.warn('[forgejo]', msg, ...args),
error: (msg, ...args) => console.error('[forgejo]', msg, ...args),
};
const client = new ForgejoClient({
instanceUrl: 'https://codeberg.org',
token: 'your-token',
logger,
});Error Types
ForgejoApiError-- HTTP error from the API. HasstatusCode,statusText,responseBody.ForgejoNetworkError-- Network/timeout error. Hasurl,cause.- Both extend
ForgejoErrorwhich extendsError.
How CI/Actions work in Forgejo (and this library)
Forgejo's Actions system has two different data sources with different ID spaces, which this library abstracts over.
The tasks API (/actions/tasks)
The only REST API endpoint that lists jobs for a repository. Returns a flat list of all jobs across all runs:
{
"workflow_runs": [
{ "id": 43113, "name": "smoke-test-vsix", "run_number": 471, "status": "success", ... },
{ "id": 43112, "name": "test (20)", "run_number": 471, "status": "success", ... }
]
}- Each item is a task with a task-level
id(e.g. 43113) - Items share
run_numberwhen they belong to the same workflow run - No
html_urlfield — only aurlpointing to the run page - No step/log data — just job metadata
Use listWorkflowRuns() to fetch this data.
The web pages (scraping)
Forgejo doesn't expose job steps or logs via REST API. The library scrapes them from the web UI at:
/{owner}/{repo}/actions/runs/{runNumber}/jobs/{positionalIndex}/attempt/1The HTML contains a data-initial-post-response attribute with embedded JSON:
{
"state": {
"run": {
"jobs": [
{ "id": 51313, "name": "test (18)", "status": "success" },
{ "id": 51314, "name": "test (20)", "status": "success" },
{ "id": 51315, "name": "smoke-test-vsix", "status": "success" }
]
},
"currentJob": {
"steps": [
{ "summary": "Set up job", "duration": "2s", "status": "success" },
{ "summary": "actions/checkout@v4", "duration": "1s", "status": "success" }
]
}
}
}Key details:
state.run.jobslists all jobs in the run in positional order (index 0, 1, 2...)state.currentJob.stepshas the steps for the job at the URL's positional index- The job
idvalues here (51313...) are different from the task IDs (43113...) returned by the REST API — they are different ID spaces
The ID mismatch problem
| Source | smoke-test-vsix ID | Positional index |
|--------|-------------------|-----------------|
| Tasks API (/actions/tasks) | 43113 | not provided |
| Scraped web page (state.run.jobs) | 51315 | 2 |
Forgejo URLs use positional indices (/jobs/0, /jobs/1, /jobs/2), not database IDs. So you can't use either ID directly in a URL.
How this library resolves it
When you call getJobSteps() or getWorkflowLogs() with a WorkflowJobRef:
// From the tasks API, you have the task ID and name
await client.getJobSteps('owner', 'repo', 471, {
jobId: 43113, // task ID from the API (won't match scraped IDs)
jobName: 'smoke-test-vsix' // name matches across both sources
});The library:
- Sees that
jobRefhas nojobIndexorjobHtmlUrl - Scrapes
/actions/runs/471/jobs/0/attempt/1to get thestate.run.jobsarray - Tries to match
jobIdagainst the scraped IDs — this may fail (different ID spaces) - Falls back to matching
jobNameagainst the scraped names — this works - Resolves
"smoke-test-vsix"→ positional index2 - Fetches
/actions/runs/471/jobs/2for the actual steps/logs - Caches the mapping so subsequent calls for the same run don't re-scrape
WorkflowJobRef priority
The WorkflowJobRef fields are resolved in this order:
| Field | Source | When to use |
|-------|--------|-------------|
| jobHtmlUrl | Server-provided URL (e.g. from /runs/{id}/jobs API if available) | Most reliable — used as-is |
| jobIndex | Known positional index | Direct — no resolution needed |
| jobName | Job name from any source | Resolved via scraping (cached) |
| jobId | Database ID from any source | Resolved via scraping (cached), may not match |
Methods
| Method | Description |
|--------|-------------|
| listWorkflowRuns(owner, repo) | Fetch all tasks from the REST API |
| getJobSteps(owner, repo, runNumber, jobRef?) | Scrape step summaries for a job |
| getWorkflowLogs(owner, repo, runNumber, jobRef?) | Scrape raw log output for a job |
| getRunJobMapping(owner, repo, runNumber) | Get the id/name → positional index mapping (cached) |
| rerunWorkflow(owner, repo, runId) | Re-run a workflow via REST API |
Changelog
0.3.0
- Fix
getJobSteps/getWorkflowLogsreturning wrong job's data by auto-resolving database job IDs to positional indices via scraping
0.2.1
- Initial public release
Development
npm install
npm run build # compile TypeScript to dist/
npm test # unit tests
npm run test:coverage # unit tests with coverage thresholds
npm run test:live # live tests (requires FORGEJO_TEST_URL and FORGEJO_TEST_TOKEN)
npm run lint # type checkOpen Source Readiness
- Contributing guide: CONTRIBUTING.md
- Security policy: SECURITY.md
- License: Apache-2.0
License
Apache-2.0
