# 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 the **`tea` CLI** as its transport instead of raw `fetch`. Auth is passed via the `GITEA_SERVER_TOKEN` environment variable (never on argv). The `tea` binary path is env-overridable via `TEA_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-backed `tea api` client (process spawn with 8s timeout, `--include` for HTTP status/headers, `--paginate` for list endpoints, `--header` for raw diff Accept). Token is passed via `GITEA_SERVER_TOKEN` env var. - `packages/web/server/lib/gitea/client.d.ts`: hand-written type declaration for `client.js` (the module is plain JS); consumed by the live-test harness. - `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. - `getGiteaDefaultBaseUrl()`: effective default base URL — configured `gitProviders.gitea.apiBaseUrl` from `settings.json`, else `https://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 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)`, `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 `.diff` text via the `raw` option), `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, or `null`. With `directory`, 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 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 primary source, then the effective default (configured `settings.json` `gitProviders.gitea.apiBaseUrl`, else `https://codeberg.org`). Stored entries without a usable base URL are dropped. - Per-project overrides: a per-project `gitProviders.gitea.apiBaseUrl` override (stored under `projects/.json`, resolved via `getEffectiveProviderApiBaseUrl('gitea', directory)` in `packages/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 forced `gitProviders.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 to `token:` when the username is missing. - Auth header on every request: `Authorization: token `. - Gitea's `GET /user` uses `login`/`full_name`/`html_url`; `setGiteaAuth` accepts both that and the `username`/`web_url` variants. ## Client behavior - Transport: each call spawns a `tea api` process with `--include` for HTTP status/headers. Auth is passed via `GITEA_SERVER_TOKEN` env var (never on argv). Binary path: `TEA_BIN` env or `/home/user/.local/bin/tea`. - Base URL: the `baseUrl` parameter is passed to the client constructor for compatibility but `tea` resolves the instance from its own login config. The `--include` flag provides HTTP status codes and response headers. - Per-request timeout: 8000 ms via `AbortSignal.timeout` on the spawned process. The process is killed with `SIGKILL` on timeout. - Pagination: `--paginate` is passed for GET requests with query params, causing `tea` to fetch all pages in a single call. The returned `page` object is `null` since pagination is handled by the CLI. - Raw diffs: `--header 'Accept: text/plain'` is passed for the `.diff` endpoint to get raw text output. - `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). - 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 returns `false`; `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=` (`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 create: `POST /repos/{owner}/{repo}/issues` with `{ title, body?, labels? }` (labels are label **names**; `body` omitted 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=`. 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 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 }`; `state` passes through, e.g. `APPROVED`/`REQUEST_CHANGES`). - Commit statuses: `GET /repos/{owner}/{repo}/commits/{sha}/statuses?limit=100` (the `prs/statuses` route resolves the PR `head.sha` first, then maps statuses to `{ state, name, description, url, createdAt }` with `state` lowercased). - 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?, state? }` (undefined fields omitted; the PR number IS the issue index, so the edit-issue `state` transition applies directly). - PR merge: `POST /repos/{owner}/{repo}/pulls/{number}/merge` with `{ Do: 'merge' | 'squash' | 'rebase' }` (`method` defaults to `'merge'`). `Do` is a string enum of the merge style — Gitea has no separate `MergeMethod` field. - Issue comment write: `POST /repos/{owner}/{repo}/issues/{number}/comments` with `{ body }` (PRs are issues at the API level, so `prs/comment` uses 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; `milestone` is resolved from a title to a milestone id and `null` sets `unset_milestone: true`). - Pull review write: `POST /repos/{owner}/{repo}/pulls/{number}/reviews` with `{ event, body? }` (`event` is `APPROVED`/`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=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[], 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` (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. 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`/`repo` query params override the directory-local git remote; write routes also accept them in the JSON body. ## 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. - `packages/web/scripts/gitea-live-test.ts` is a live-test harness for the raw client: run with `bun run gitea:live-test` (requires `GITEA_TOKEN`; `GITEA_BASE_URL` defaults to `https://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: 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 ...' }`. - Milestone titles on issue updates are resolved against `GET /repos/{owner}/{repo}/milestones`; an unmatched title yields `400 { error: 'Milestone not found' }` and `null` sets `unset_milestone: true`. - 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 `tea` CLI 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 /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, add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing issue/PR write routes and the GitHub PR write routes.