Replace raw fetch transport with CLI subprocess calls: - Gitea: spawn 'tea api --include' with GITEA_SERVER_TOKEN env var - GitLab: spawn 'glab api --include' with GITLAB_TOKEN env var Binary paths env-overridable (TEA_BIN / GLAB_BIN). 8s request timeout via AbortSignal on spawned process. ETag cache and rate-limit cooldown dropped (tradeoff documented). Pagination via --paginate for list endpoints. Tests mock child_process.spawn instead of globalThis.fetch.
18 KiB
18 KiB
Gitea Module Documentation
Purpose
- This module owns Gitea/Forgejo auth (Personal Access Token), CLI-backed REST v1 client access, remote-URL repo resolution, and Gitea issue / pull-request (PR) APIs for OpenChamber, including issue create/update and PR create/update/merge writes.
- From a user perspective, this is the layer that lets the app show Gitea issues and pull requests for a local project, including comments and per-file diffs, and create, edit, and merge pull requests.
- Gitea and Forgejo share the same GitHub-style REST v1 API, so this module serves both. Gitea calls remote work pull requests (PR), not merge requests. Gitea repos are flat
owner/repo— there are no multi-segment namespaces. - The module mirrors
packages/web/server/lib/gitlab/but uses theteaCLI as its transport instead of rawfetch. Auth is passed via theGITEA_SERVER_TOKENenvironment variable (never on argv). Theteabinary path is env-overridable viaTEA_BIN, defaulting to/home/user/.local/bin/tea.
Entrypoints and structure
packages/web/server/lib/gitea/index.js: public server entrypoint re-exports.packages/web/server/lib/gitea/routes.js: Express route registration for/api/gitea/*endpoints.packages/web/server/lib/gitea/auth.js: PAT auth storage, multi-account support, base URL normalization.packages/web/server/lib/gitea/client.js: CLI-backedtea apiclient (process spawn with 8s timeout,--includefor HTTP status/headers,--paginatefor list endpoints,--headerfor raw diff Accept). Token is passed viaGITEA_SERVER_TOKENenv var.packages/web/server/lib/gitea/client.d.ts: hand-written type declaration forclient.js(the module is plain JS); consumed by the live-test harness.packages/web/server/lib/gitea/repo.js: Gitea remote URL parsing (flatowner/repo) and directory-to-repo resolution.packages/web/server/lib/opencode/feature-routes-runtime.js: API route layer that calls this module (viaregisterGiteaRoutes).packages/web/src/api/gitea.ts: web client wrapper for Gitea endpoints.packages/ui/src/lib/api/types.ts: shared response types consumed by web, desktop, VS Code, and mobile.
Public exports
Auth (auth.js)
getGiteaAuth(): current auth entry.getGiteaAuthAccounts(): all configured accounts ({ id, user, baseUrl, current }).setGiteaAuth({ accessToken, baseUrl, user }): save or update an account (validatingusercomes fromGET /user).baseUrlis required — throws when missing/invalid.activateGiteaAuth(accountId): switch active account.clearGiteaAuth(): remove the current account.normalizeBaseUrl(raw): addhttps://when a scheme is missing, strip trailing slash, returnnullfor invalid input.GITEA_AUTH_FILE: auth file path.getGiteaDefaultBaseUrl(): effective default base URL — configuredgitProviders.gitea.apiBaseUrlfromsettings.json, elsehttps://codeberg.org. Used to prefill the connect form and as the connect/status default; stored accounts still require an explicit base URL.- The only built-in default base URL is codeberg.org (a well-known public Forgejo instance); any other Gitea/Forgejo instance URL is user-provided.
Client (client.js)
createGiteaClient({ token, baseUrl }): raw-fetch REST v1 client withrequest(path, { method, query, body, signal, raw })plus convenience methodsuser(),repo(owner, repo),issues(owner, repo, params),issue(owner, repo, number),issueComments(owner, repo, number, params),createIssueComment(owner, repo, number, body),createIssue(owner, repo, params)(POST),updateIssue(owner, repo, number, params)(PATCH),milestones(owner, repo, params),repoLabels(owner, repo, params),pullRequests(owner, repo, params),pullRequest(owner, repo, number),pullRequestDiff(owner, repo, number)(raw.difftext via therawoption),pullRequestFiles(owner, repo, number, params),pullRequestCommits(owner, repo, number, params),pullRequestReviews(owner, repo, number, params),createPullReview(owner, repo, number, params)(POST),commitStatuses(owner, repo, sha, params),createPullRequest(owner, repo, body),updatePullRequest(owner, repo, number, body)(PATCH),mergePullRequest(owner, repo, number, body)(POST),branches(owner, repo, params).getGiteaClientOrNull(directory?): client for the current account, ornull. Withdirectory, a per-project API base override wins over the account's base URL for that project (see "Per-project overrides").isGiteaRateLimited()/noteGiteaRateLimit(error): own module-level rate-limit cooldown (not shared with the GitHub/GitLab modules).
Repo (repo.js)
parseGiteaRemoteUrl(raw, knownHosts?): parse SSH/HTTPS remote URL into{ owner, repo, host, baseUrl, url }(exactly two path segments; never matchesgithub.comorgitlab.com).resolveGiteaRepoFromDirectory(directory, remoteName?): resolve a Gitea repo from a local git remote.
Auth storage and config
- Auth storage:
~/.config/openchamber/gitea-auth.json(override withOPENCHAMBER_DATA_DIR). - Writes are atomic (tmp file + rename) and file mode is
0o600. - Base URL resolution: the caller-supplied
baseUrl(normalized) is the primary source, then the effective default (configuredsettings.jsongitProviders.gitea.apiBaseUrl, elsehttps://codeberg.org). Stored entries without a usable base URL are dropped. - Per-project overrides: a per-project
gitProviders.gitea.apiBaseUrloverride (stored underprojects/<projectId>.json, resolved viagetEffectiveProviderApiBaseUrl('gitea', directory)inpackages/web/server/lib/git-providers/project-config.js) replaces the account's base URL for that project's data routes (getGiteaClientOrNull(directory)), and its host is accepted for directory-to-repo resolution (resolveGiteaRepoFromDirectory). A forcedgitProviders.provider: 'gitea'accepts any remote host for directory resolution. Global routes (auth/status,auth/connect,auth/activate, DELETE auth,me,repo/branches) stay global. - Account id:
`${host}:${username}`(e.g.gitea.example.com:alice), falling back totoken:<first8>when the username is missing. - Auth header on every request:
Authorization: token <pat>. - Gitea's
GET /useruseslogin/full_name/html_url;setGiteaAuthaccepts both that and theusername/web_urlvariants.
Client behavior
- Transport: each call spawns a
tea apiprocess with--includefor HTTP status/headers. Auth is passed viaGITEA_SERVER_TOKENenv var (never on argv). Binary path:TEA_BINenv or/home/user/.local/bin/tea. - Base URL: the
baseUrlparameter is passed to the client constructor for compatibility buttearesolves the instance from its own login config. The--includeflag provides HTTP status codes and response headers. - Per-request timeout: 8000 ms via
AbortSignal.timeouton the spawned process. The process is killed withSIGKILLon timeout. - Pagination:
--paginateis passed for GET requests with query params, causingteato fetch all pages in a single call. The returnedpageobject isnullsince pagination is handled by the CLI. - Raw diffs:
--header 'Accept: text/plain'is passed for the.diffendpoint to get raw text output. requestnever throws for HTTP error statuses — callers branch onstatus. Theraw: trueoption returns the response body as text (used for the.diffendpoint).- ETag cache and rate-limit cooldown have been dropped with the CLI pivot. Each call spawns a fresh process, so there is no persistent connection for conditional requests or shared rate-limit state.
isGiteaRateLimited()always returnsfalse;noteGiteaRateLimit()is a no-op.
API integration overview
- Issues/PRs are repo-scoped by number (GitHub-style, not per-namespace iid).
- User:
GET /user->{ id, login, full_name, avatar_url, html_url, email, ... }. - Issue list:
GET /repos/{owner}/{repo}/issues?type=issues&state=open&limit=50&page=N&q=<query>(type=issuesexcludes pull requests; entries carrying apull_requestfield are skipped client-side as a backstop). - Issue detail:
GET /repos/{owner}/{repo}/issues/{number}. - Issue create:
POST /repos/{owner}/{repo}/issueswith{ title, body?, labels? }(labels are label names;bodyomitted when absent). - Issue/PR comments:
GET /repos/{owner}/{repo}/issues/{number}/comments. - PR list:
GET /repos/{owner}/{repo}/pulls?state=open&limit=50&page=N&q=<query>. Gitea has no server-side source-branch filter, so whensourceBranchis requested the route scansstate=allpages (cap 10 pages) and filters byhead.ref === sourceBranchclient-side, returning all matching states (open and merged). - PR detail:
GET /repos/{owner}/{repo}/pulls/{number}. - PR files:
GET /repos/{owner}/{repo}/pulls/{number}/files?patch=true(capitalized JSON fieldsFilename/Status/Additions/Deletions/Patch; a404on older Gitea instances falls back tofiles: []). - PR diff:
GET /repos/{owner}/{repo}/pulls/{number}.diff(raw text; falls back to concatenated per-file patches when it fails). - PR commits:
GET /repos/{owner}/{repo}/pulls/{number}/commits?limit=100(mapped to{ sha, message, summary, author, committedAt, parents }). - PR reviews:
GET /repos/{owner}/{repo}/pulls/{number}/reviews?limit=100(mapped to{ id, state, author, submittedAt, body, commitSha };statepasses through, e.g.APPROVED/REQUEST_CHANGES). - Commit statuses:
GET /repos/{owner}/{repo}/commits/{sha}/statuses?limit=100(theprs/statusesroute resolves the PRhead.shafirst, then maps statuses to{ state, name, description, url, createdAt }withstatelowercased). - PR create:
POST /repos/{owner}/{repo}/pullswith{ title, head, base, body? }(body omitted when absent). - PR update:
PATCH /repos/{owner}/{repo}/pulls/{number}with{ title?, body?, state? }(undefined fields omitted; the PR number IS the issue index, so the edit-issuestatetransition applies directly). - PR merge:
POST /repos/{owner}/{repo}/pulls/{number}/mergewith{ Do: 'merge' | 'squash' | 'rebase' }(methoddefaults to'merge').Dois a string enum of the merge style — Gitea has no separateMergeMethodfield. - Issue comment write:
POST /repos/{owner}/{repo}/issues/{number}/commentswith{ body }(PRs are issues at the API level, soprs/commentuses the same endpoint with the PR number as the index). - Issue update:
PATCH /repos/{owner}/{repo}/issues/{number}with{ title?, body?, state?, labels?, assignees?, milestone?, unset_milestone? }(labels are label names, assignees are logins;milestoneis resolved from a title to a milestone id andnullsetsunset_milestone: true). - Pull review write:
POST /repos/{owner}/{repo}/pulls/{number}/reviewswith{ event, body? }(eventisAPPROVED/REQUEST_CHANGES/COMMENT). - Milestones:
GET /repos/{owner}/{repo}/milestones?state=all&limit=50(first page) for title-to-id resolution on issue updates. - Repo labels:
GET /repos/{owner}/{repo}/labels?limit=100(first page) so metadata editors can offer existing labels. - Branches:
GET /repos/{owner}/{repo}/branches?limit=50&page=Nmapped to names, plusGET /repos/{owner}/{repo}fordefault_branch(Gitea branch objects carry no default flag). - There is no ready-for-review endpoint in this module (Gitea has no GitLab-style ready_for_review action).
Route contract (/api/gitea/*)
| Method | Path | Shape |
|---|---|---|
| GET | /api/gitea/auth/status |
{ connected, user?, accounts[], defaultBaseUrl? } (defaultBaseUrl present when connected; the effective default — configured gitProviders.gitea.apiBaseUrl, else https://codeberg.org) |
| POST | /api/gitea/auth/connect |
body { accessToken, baseUrl? } -> { connected, user, accounts }; 400 for missing/invalid token; 400 when neither a valid baseUrl nor a configured default exists |
| POST | /api/gitea/auth/activate |
body { accountId } -> { connected, user, accounts }; 404 unknown account |
| DELETE | /api/gitea/auth |
{ removed } |
| GET | /api/gitea/me |
{ username, id, name, avatarUrl, webUrl, email? }; 401 when not connected |
| GET | /api/gitea/issues/list |
?directory&page&query -> { connected, repo?, issues[], page, hasMore } |
| GET | /api/gitea/issues/get |
?directory&number&owner&repo -> { connected, repo?, issue } |
| GET | /api/gitea/issues/comments |
?directory&number&owner&repo -> { connected, repo?, comments[] } |
| GET | /api/gitea/prs/list |
?directory&page&query&sourceBranch -> { connected, repo?, prs[], page, hasMore } |
| GET | /api/gitea/pr/context |
?directory&number&includeDiff&owner&repo -> { connected, repo?, pr, comments[], files[], diff? } |
| GET | /api/gitea/prs/commits |
?directory&number&owner&repo -> { connected, repo?, commits[] } |
| GET | /api/gitea/prs/reviews |
?directory&number&owner&repo -> { connected, repo?, reviews[] } |
| GET | /api/gitea/prs/statuses |
?directory&number&owner&repo -> { connected, repo?, statuses[] } (resolves the PR head.sha first, then lists commit statuses for that SHA) |
| POST | /api/gitea/pr/create |
body { directory, title, sourceBranch, targetBranch, description? } -> { connected, repo?, pr }; 400 for missing fields or an unresolvable repo |
| PATCH | /api/gitea/pr/update |
body { directory, number, title?, description?, state? } -> { connected, repo?, pr }; 404 when the PR does not exist |
| POST | /api/gitea/pr/merge |
body { directory, number, method? } -> { connected, merged: true } on success; non-mergeable PRs -> the Gitea status (405/409/422) with { connected, merged: false, message } |
| POST | /api/gitea/issues/comment |
body { directory, number, body, owner?, repo? } -> { connected, repo?, comment } |
| POST | /api/gitea/issues/create |
body { directory, title, body?, labels?, owner?, repo? } -> { connected, repo?, issue } |
| PATCH | /api/gitea/issues/update |
body { directory, number, title?, body?, state?, labels?, assignees?, milestone?, owner?, repo? } -> { connected, repo?, issue }; 400 'Milestone not found' when a milestone title does not match |
| POST | /api/gitea/prs/comment |
body { directory, number, body, owner?, repo? } -> { connected, repo?, comment } (PRs are issues at the API level, so the PR number is the issue index) |
| POST | /api/gitea/prs/review |
body { directory, number, event, body?, owner?, repo? } -> { connected, repo?, review }; 400 when event is not APPROVED/REQUEST_CHANGES/COMMENT |
| GET | /api/gitea/repo/labels |
?directory&owner&repo -> { connected, repo?, labels[] } |
| GET | /api/gitea/repo/branches |
?owner&repo -> { branches[], defaultBranch? } (defaultBranch is null when Gitea is disconnected or the repo has no default) |
Conventions mirror github/routes.js and gitlab/routes.js:
- Not authenticated ->
connected: false(or401for/me). - Missing/invalid params ->
400with{ error }. - Hard failures ->
4xx/5xxwith{ error }. - A Gitea
429->503 { error: 'Gitea rate limited' }. - Lazy-import pattern: route handlers import
./index.json first use, so the module never loads unless Gitea endpoints are hit. - Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout. Write routes deliberately skip the route-level timeout (a timeout can orphan a write); the client's per-request timeout still bounds them.
- Repo targeting:
owner/repoquery params override the directory-local git remote; write routes also accept them in the JSON body.
Consumers
packages/web/src/api/gitea.tscalls every/api/gitea/*endpoint and maps them to the shared types.packages/ui/src/lib/api/types.tsdefines the sharedGitea*response types used across web, desktop, VS Code, and mobile.packages/web/scripts/gitea-live-test.tsis a live-test harness for the raw client: run withbun run gitea:live-test(requiresGITEA_TOKEN;GITEA_BASE_URLdefaults tohttps://git.example.com). It exercises every client method against a real instance, reports PASS/WARN/FAIL/SKIP per endpoint, and runs a controlled write pass (scratch issue plus a scratch-repo PR lifecycle that is deleted afterward).
Failure handling
- If Gitea is disconnected, read routes return
connected: false. - A repo that does not resolve from the local git remote yields
repo: nullwith empty lists, matching GitHub/GitLab behavior. Write routes reject an unresolvable repo with400 { error: 'Unable to resolve Gitea repo from directory' }. - Invalid/expired tokens are cleared on
401/403and reported as disconnected. - Gitea
403on write routes means the token lacks repository write scope; they respond400 { error: 'Your Gitea token needs write:repository scope to ...' }. - Milestone titles on issue updates are resolved against
GET /repos/{owner}/{repo}/milestones; an unmatched title yields400 { error: 'Milestone not found' }andnullsetsunset_milestone: true. - PR merge rejections (
405/409/422from Gitea) are surfaced as{ connected, merged: false, message }with the Gitea status so clients can show the message without treating it as a transport error (mirrorsgithub/pr/merge). - The pull-files endpoint returning
404(older Gitea) yieldsfiles: []instead of failing the whole PR context; a missing.difffalls back to concatenated patches. - Rate-limit and timeout failures surface explicit
503responses so clients keep last-known state rather than clearing UI.
Notes for contributors
- Keep the response shapes in lockstep with
Gitea*types inpackages/ui/src/lib/api/types.ts. - Never log tokens. Error messages must not include the access token.
- The
teaCLI handles authentication, base URL resolution, and pagination internally. The client does not maintain its own ETag cache or rate-limit cooldown — each call spawns a fresh process. - Gitea
GET /userreturnslogin/full_name/html_url; the route mappers accept the GitHub-styleusername/name/web_urlvariants too, so Forgejo versions that differ still map. - To add further Gitea write operations, add the endpoint in
routes.js, add a convenience method inclient.js, and extend the shared types — mirror the existing issue/PR write routes and the GitHub PR write routes.