Platforms

Gitea

API v1 with limit instead of per_page, empty strings and nulls where fields are missing, and Codeberg or any Forgejo instance through baseURL.
IDgitea03 / 05gitea.com/api/v1
Platform / gitea

Gitea

Spoken by GiteaProvider. Same resources, same shapes, its own quirks kept inside.

Access

CreatecreateProvider("gitea")

Importimport { GiteaProvider } from"@agntn/forges/gitea"

EnvGITEA_TOKEN

CLItea login (config file)

Config~/.config/tea/config.yml

Tryexplorer against the docs worker

Header
Authorization: token
Anonymous
public repositories
Threads
one thread per review comment
Code search
unsupported, explicit error
Templates
repository scope only

Address it

ts
import { createProvider } from "@agntn/forges";

const gitea = await createProvider("gitea", { token: process.env.GITEA_TOKEN }); // gitea.com
const codeberg = await createProvider("gitea", { baseURL: "https://codeberg.org" });
const forgejo = await createProvider("gitea", { baseURL: "https://forgejo.example.com" });

/api/v1 is added when the base URL lacks it. Forgejo is a Gitea fork with the same API, so Codeberg and every Forgejo instance go through this provider. The token chain reads GITEA_TOKEN, then the tea config file for the host. tea has no command that prints the token, so the CLI step is skipped.

What it reads

ResourceEndpoint
reposGET /repos/:owner/:repo, GET /users/:owner/repos, GET …/tags
issuesGET /repos/:owner/:repo/issues, POST …/issues, PATCH …/issues/:index after a read, …/issues/:index/comments
pull requestsGET /repos/:owner/:repo/pulls, …/pulls/:index/files, …/commits/:sha/status
pull request searchGET /repos/issues/search?type=pulls, …/repos/:owner/:repo/issues for one repository
issue and pull request editsPATCH /repos/:owner/:repo/pulls/:index, POST …/issues/:index/labels, DELETE …/labels/:id, after a read
pull request mergesPOST /repos/:owner/:repo/pulls/:index/merge, then a read of the pull request
commitsGET /repos/:owner/:repo/commits, …/git/commits/:sha, …/compare/:base...:head
commit checksGET /repos/:owner/:repo/commits/:ref/status, …/git/commits/:ref when that comes back empty
CI runsGET /repos/:owner/:repo/actions/runs, …/actions/runs/:run/jobs, …/actions/jobs/:job_id/logs
releasesGET /repos/:owner/:repo/releases, …/releases/tags/:tag, POST …/releases
release editsPATCH /repos/:owner/:repo/releases/:id, after the tag read
reactionsGET …/issues/:index/reactions, …/issues/comments/:id/reactions next to a read of the issue and the comment
threadsGET /repos/:owner/:repo/pulls/:index/reviews, …/reviews/:id/comments
templatesGET /repos/:owner/:repo/contents/.gitea/…, .github/…

Pagination uses limit, not per_page, and reads the Link header.

What comes back

Fields Gitea leaves out come back empty rather than undefined: company is "", counts per file on a commit are null. Every review comment is its own Thread with one comment, because the API has no parent id on review comments. isOutdated is always false. Pull request checks are the commit statuses of the head SHA.

merge comes from allow_merge_commits, allow_squash_merge, allow_rebase and default_merge_style, even on an anonymous read. With pull requests off, every flag is false and methods is empty.

Templates are repository scope only. There is no owner inheritance to claim, so none is claimed.

Gotchas

  • commits.list rejects the path filter on Gitea. That API ignores limit when path is set and would hand you the whole history. Forgejo paginates the filter. ref, since and until work on both.
  • commits.compare gets no file list for the range and no count of what head lacks. files, behindBy, mergeBaseSha and status are null, and the whole range arrives newest first in one response, so it is reversed and paged locally.
  • Code search does not exist. code.search is a ForgesError with status 501 and a sentence.
  • pullRequests.searchGlobal and issues.searchGlobal without an owner work on gitea.com, but Codeberg answers them with a 500. Pass an owner there.
  • gitea.com filters searchGlobal by author. Forgejo drops created_by on the search route without a word, so a page with anyone else's item on it is a 501.
  • Both drop a label name they don't know and return the rows unfiltered, and the search route matches any of several labels, not all of them. issues.searchGlobal keeps only rows with every label and answers an empty page when the filter was dropped, see issue search.
  • Discussion comments arrive as one response, so the page you asked for is cut locally. A comment's reactions too.
  • Reactions on an issue or pull request page without a Link header, and the server may hand back fewer rows than limit asked for. x-total-count decides whether there's more.
  • Older Forgejo responses keep an Actions run's branch only inside the webhook payload. It is read from there when the top level field is missing.
  • Forgejo hands over a run's jobs as a bare array. It shrugs at limit and page, so the page is cut locally. Skinny rows too: no steps, no times, and no URL before Forgejo 17. Name and state survive. That's enough to spot the red one.
  • Forgejo 16, so Codeberg too, has no route for one job. Just its log. So readJobLog skips ahead and reads it top to bottom, like GitLab. The first line names the job. Near the end sits 🏁 Job succeeded or 🏁 Job failed. Why not split at the ⭐ Run lines? Two real jobs printed them only around checkout. The whole build would land under it. A cancelled job gets no banner, so it stays in_progress. A job no runner took has no log at all. That's a NotFoundError there, not started: false. Forgejo 17 adds the job route with steps, and the failing step goes first again.
  • Combined statuses on Forgejo paginate without a Link header. The next page is probed only when the current one is full.
  • Forgejo answers a ref that doesn't exist with a 200 and no statuses. A commit nobody built looks exactly the same. So an empty commits.listChecks reads the ref once more. A typo becomes NotFoundError, not a quiet "no CI yet". gitea.com says 404 on its own.
  • Reviews paginate without a Link header on Gitea and Forgejo alike. x-total-count says how many there are, and thread listing walks the reviews by that count.
  • Ask for 100 rows and you get 50. Both cap limit at the server's maximum, and some lists skip Link on top of that. So is a short page the end? max_response_items from /settings/api says how big a page really is, read once per provider. If the host won't say, an empty page gets the last word. Reactions, reviews and Actions jobs page that way. Tags and runs go through forges, which counts its own pages.

Where it lives

src/providers/gitea.ts.