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
- Header
- Authorization: token
- Anonymous
- public repositories
- Threads
- one thread per review comment
- Code search
- unsupported, explicit error
- Templates
- repository scope only
Address it
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
| Resource | Endpoint |
|---|---|
| repos | GET /repos/:owner/:repo, GET /users/:owner/repos, GET …/tags |
| issues | GET /repos/:owner/:repo/issues, POST …/issues, PATCH …/issues/:index after a read, …/issues/:index/comments |
| pull requests | GET /repos/:owner/:repo/pulls, …/pulls/:index/files, …/commits/:sha/status |
| pull request search | GET /repos/issues/search?type=pulls, …/repos/:owner/:repo/issues for one repository |
| issue and pull request edits | PATCH /repos/:owner/:repo/pulls/:index, POST …/issues/:index/labels, DELETE …/labels/:id, after a read |
| pull request merges | POST /repos/:owner/:repo/pulls/:index/merge, then a read of the pull request |
| commits | GET /repos/:owner/:repo/commits, …/git/commits/:sha, …/compare/:base...:head |
| commit checks | GET /repos/:owner/:repo/commits/:ref/status, …/git/commits/:ref when that comes back empty |
| CI runs | GET /repos/:owner/:repo/actions/runs, …/actions/runs/:run/jobs, …/actions/jobs/:job_id/logs |
| releases | GET /repos/:owner/:repo/releases, …/releases/tags/:tag, POST …/releases |
| release edits | PATCH /repos/:owner/:repo/releases/:id, after the tag read |
| reactions | GET …/issues/:index/reactions, …/issues/comments/:id/reactions next to a read of the issue and the comment |
| threads | GET /repos/:owner/:repo/pulls/:index/reviews, …/reviews/:id/comments |
| templates | GET /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.listrejects thepathfilter on Gitea. That API ignoreslimitwhenpathis set and would hand you the whole history. Forgejo paginates the filter.ref,sinceanduntilwork on both.commits.comparegets no file list for the range and no count of what head lacks.files,behindBy,mergeBaseShaandstatusarenull, and the whole range arrives newest first in one response, so it is reversed and paged locally.- Code search does not exist.
code.searchis aForgesErrorwith status 501 and a sentence. pullRequests.searchGlobalandissues.searchGlobalwithout anownerwork on gitea.com, but Codeberg answers them with a 500. Pass anownerthere.- gitea.com filters
searchGlobalbyauthor. Forgejo dropscreated_byon 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.searchGlobalkeeps 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
Linkheader, and the server may hand back fewer rows thanlimitasked for.x-total-countdecides 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
limitandpage, 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
readJobLogskips ahead and reads it top to bottom, like GitLab. The first line names the job. Near the end sits🏁 Job succeededor🏁 Job failed. Why not split at the⭐ Runlines? Two real jobs printed them only around checkout. The whole build would land under it. A cancelled job gets no banner, so it staysin_progress. A job no runner took has no log at all. That's aNotFoundErrorthere, notstarted: false. Forgejo 17 adds the job route with steps, and the failing step goes first again. - Combined statuses on Forgejo paginate without a
Linkheader. 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.listChecksreads the ref once more. A typo becomesNotFoundError, not a quiet "no CI yet". gitea.com says 404 on its own. - Reviews paginate without a
Linkheader on Gitea and Forgejo alike.x-total-countsays how many there are, and thread listing walks the reviews by that count. - Ask for 100 rows and you get 50. Both cap
limitat the server's maximum, and some lists skipLinkon top of that. So is a short page the end?max_response_itemsfrom/settings/apisays 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 throughforges, which counts its own pages.
Where it lives
src/providers/gitea.ts.