Per provider (github|gitlab|gitea) a project override stored in ~/.config/openchamber/projects/<projectId>.json under gitProviders wins over the global settings.json value (precedence: project override > global > built-in default). Server forge routes resolve the override per request directory (worktree-aware via git-common-dir + containment + path fallback, 60s TTL cache); the override host is also accepted for remote parsing and client detection. New GET/PUT /api/projects/:projectId/git-providers route; client openchamberConfig preserves the server-owned gitProviders key; Projects page gains a Git provider API base URLs section; detection store hydrates per-project overrides (memory-only, server-authoritative).
17 KiB
17 KiB
Gitea Module Documentation
Purpose
- This module owns Gitea/Forgejo auth (Personal Access Token), raw 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/(PAT auth + raw-fetch client) but uses a Personal Access Token against theAuthorization: token <pat>header and a user-supplied base URL (Gitea is self-hosted; codeberg.org is the only built-in default).
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: rawfetchGitea REST v1 client (timeout, ETag conditional GET, rate-limit cooldown,Link-header pagination, redirect handling).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). 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
- Base URL joining:
{baseUrl}/api/v1{path}. Gitea repos are flatowner/repo, so owner/repo segments are interpolated directly (single path segments, no encoding needed). - Per-request timeout: 8000 ms via
AbortSignal.timeout, unless the caller passes its own signal. - ETag conditional-GET cache: keyed
token\nurl, max 300 LRU entries; a304is replayed from cache as a200. GET only. - Pagination: Gitea list endpoints return a
Linkheader (rel="next") plusX-Total-Count; both are parsed into the returnedpageobject (hasMore= a next page exists). List requests usepage+limitquery params (Gitea capslimitat 50). - Redirects:
301/302/308with aLocationheader are followed exactly once withredirect: 'manual', preserving theAuthorizationheader across the hop. - Rate limits: a
429records a module-level cooldown (honoringRetry-Afterseconds /X-RateLimit-ResetUnix seconds when present) and surfaces{ status: 429, error: 'Gitea rate limited' }. While the cooldown is active, requests short-circuit without hitting the network. requestnever throws for HTTP error statuses — callers branch onstatus. Theraw: trueoption returns the response body as text (used for the.diffendpoint).
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: true, MergeMethod: 'merge' | 'squash' | 'rebase' }(methoddefaults to'merge'). - 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.
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 ETag cache and rate-limit cooldown are module-level and per-instance — they are NOT shared with the GitHub or GitLab modules.
- 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.