Files
openchamber/packages/web/server/lib/gitea/DOCUMENTATION.md
T
bot-hermes ca91fd7e2d feat(gitea): add Gitea/Forgejo as a git provider
Full parity with the existing GitLab provider:
- Server module packages/web/server/lib/gitea (auth/client/repo/routes + docs + tests)
  with Gitea REST v1 API, PAT + base URL auth, multi-account storage
- Shared GiteaAPI types and web API client
- Provider detection generalized with user-configurable custom domains
  per provider (github/gitlab/gitea), additive with built-in defaults
  (github.com, gitlab.com) and connected-account hosts; precedence
  github -> gitlab -> gitea
- Gitea PR view, issues section, pickers, integration dialog, branch
  PR status helper, settings UI (PAT + base URL + custom domains)
- Magic prompts (gitea.pr.review, gitea.issue.review) and full 11-locale
  i18n parity
2026-08-16 16:27:49 +00:00

12 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 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 the Authorization: token <pat> header and a user-supplied base URL (Gitea is self-hosted; there is no default instance).

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: raw fetch Gitea 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 (flat owner/repo) and directory-to-repo resolution.
  • packages/web/server/lib/opencode/feature-routes-runtime.js: API route layer that calls this module (via registerGiteaRoutes).
  • 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 (validating user comes from GET /user). baseUrl is required — throws when missing/invalid.
  • activateGiteaAuth(accountId): switch active account.
  • clearGiteaAuth(): remove the current account.
  • normalizeBaseUrl(raw): add https:// when a scheme is missing, strip trailing slash, return null for invalid input.
  • GITEA_AUTH_FILE: auth file path.
  • There is no default base URL: Gitea/Forgejo is self-hosted, so the instance URL is always user-provided.

Client (client.js)

  • createGiteaClient({ token, baseUrl }): raw-fetch REST v1 client with request(path, { method, query, body, signal, raw }) plus convenience methods user(), repo(owner, repo), issues(owner, repo, params), issue(owner, repo, number), issueComments(owner, repo, number, params), pullRequests(owner, repo, params), pullRequest(owner, repo, number), pullRequestDiff(owner, repo, number) (raw .diff text via the raw option), pullRequestFiles(owner, repo, number, params), createPullRequest(owner, repo, body), updatePullRequest(owner, repo, number, body) (PATCH), mergePullRequest(owner, repo, number, body) (POST), branches(owner, repo, params).
  • getGiteaClientOrNull(): client for the current account, or null.
  • 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 matches github.com or gitlab.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 with OPENCHAMBER_DATA_DIR).
  • Writes are atomic (tmp file + rename) and file mode is 0o600.
  • Base URL resolution: the caller-supplied baseUrl (normalized) is the only source — there is no default instance. Stored entries without a usable base URL are dropped.
  • Account id: `${host}:${username}` (e.g. gitea.example.com:alice), falling back to token:<first8> when the username is missing.
  • Auth header on every request: Authorization: token <pat>.
  • Gitea's GET /user uses login/full_name/html_url; setGiteaAuth accepts both that and the username/web_url variants.

Client behavior

  • Base URL joining: {baseUrl}/api/v1{path}. Gitea repos are flat owner/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; a 304 is replayed from cache as a 200. GET only.
  • Pagination: Gitea list endpoints return a Link header (rel="next") plus X-Total-Count; both are parsed into the returned page object (hasMore = a next page exists). List requests use page + limit query params (Gitea caps limit at 50).
  • Redirects: 301/302/308 with a Location header are followed exactly once with redirect: 'manual', preserving the Authorization header across the hop.
  • Rate limits: a 429 records a module-level cooldown (honoring Retry-After seconds / X-RateLimit-Reset Unix seconds when present) and surfaces { status: 429, error: 'Gitea rate limited' }. While the cooldown is active, requests short-circuit without hitting the network.
  • request never throws for HTTP error statuses — callers branch on status. The raw: true option returns the response body as text (used for the .diff endpoint).

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=issues excludes pull requests; entries carrying a pull_request field are skipped client-side as a backstop).
  • Issue detail: GET /repos/{owner}/{repo}/issues/{number}.
  • 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 when sourceBranch is requested the route scans state=all pages (cap 10 pages) and filters by head.ref === sourceBranch client-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 fields Filename/Status/Additions/Deletions/Patch; a 404 on older Gitea instances falls back to files: []).
  • PR diff: GET /repos/{owner}/{repo}/pulls/{number}.diff (raw text; falls back to concatenated per-file patches when it fails).
  • PR create: POST /repos/{owner}/{repo}/pulls with { title, head, base, body? } (body omitted when absent).
  • PR update: PATCH /repos/{owner}/{repo}/pulls/{number} with { title?, body? } (undefined fields omitted).
  • PR merge: POST /repos/{owner}/{repo}/pulls/{number}/merge with { Do: true, MergeMethod: 'merge' | 'squash' | 'rebase' } (method defaults to 'merge').
  • Branches: GET /repos/{owner}/{repo}/branches?limit=50&page=N mapped to names, plus GET /repos/{owner}/{repo} for default_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[] }
POST /api/gitea/auth/connect body { accessToken, baseUrl } -> { connected, user, accounts }; 400 for missing/invalid token or base URL
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? }
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? } -> { 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 }
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 (or 401 for /me).
  • Missing/invalid params -> 400 with { error }.
  • Hard failures -> 4xx/5xx with { error }.
  • A Gitea 429 -> 503 { error: 'Gitea rate limited' }.
  • Lazy-import pattern: route handlers import ./index.js on 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.
  • Repo targeting: owner/repo query params override the directory-local git remote.

Consumers

  • packages/web/src/api/gitea.ts calls every /api/gitea/* endpoint and maps them to the shared types.
  • packages/ui/src/lib/api/types.ts defines the shared Gitea* 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: null with empty lists, matching GitHub/GitLab behavior. Write routes reject an unresolvable repo with 400 { error: 'Unable to resolve Gitea repo from directory' }.
  • Invalid/expired tokens are cleared on 401/403 and reported as disconnected.
  • Gitea 403 on write routes means the token lacks repository write scope; they respond 400 { error: 'Your Gitea token needs write:repository scope to ...' }.
  • PR merge rejections (405/409/422 from 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 (mirrors github/pr/merge).
  • The pull-files endpoint returning 404 (older Gitea) yields files: [] instead of failing the whole PR context; a missing .diff falls back to concatenated patches.
  • Rate-limit and timeout failures surface explicit 503 responses so clients keep last-known state rather than clearing UI.

Notes for contributors

  • Keep the response shapes in lockstep with Gitea* types in packages/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 /user returns login/full_name/html_url; the route mappers accept the GitHub-style username/name/web_url variants too, so Forgejo versions that differ still map.
  • To add further Gitea write operations (comment, assign, issue writes), add the endpoint in routes.js, add a convenience method in client.js, and extend the shared types — mirror the existing PR write routes and the GitHub PR write routes.