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
This commit is contained in:
2026-08-16 16:27:49 +00:00
parent be13272eb1
commit ca91fd7e2d
65 changed files with 10667 additions and 101 deletions
@@ -0,0 +1,131 @@
# 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.
+329
View File
@@ -0,0 +1,329 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const STORAGE_DIR = OPENCHAMBER_DATA_DIR;
const STORAGE_FILE = path.join(STORAGE_DIR, 'gitea-auth.json');
// Gitea/Forgejo are self-hosted — there is deliberately NO default base URL.
// The instance URL is always user-provided (see `normalizeBaseUrl`); auth.js
// never invents a host for a stored account.
function ensureStorageDir() {
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
}
function readJsonFile() {
ensureStorageDir();
if (!fs.existsSync(STORAGE_FILE)) {
return null;
}
try {
const raw = fs.readFileSync(STORAGE_FILE, 'utf8');
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed;
} catch (error) {
console.error('Failed to read Gitea auth file:', error);
return null;
}
}
function writeJsonFile(payload) {
ensureStorageDir();
// Atomic write so multiple OpenChamber instances can safely share the same file.
const tmpFile = `${STORAGE_FILE}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
try {
fs.chmodSync(tmpFile, 0o600);
} catch {
// best-effort
}
fs.renameSync(tmpFile, STORAGE_FILE);
try {
fs.chmodSync(STORAGE_FILE, 0o600);
} catch {
// best-effort
}
}
/**
* Normalize a user-provided Gitea/Forgejo base URL. Adds `https://` when no
* scheme is present, strips a trailing slash, and returns null for anything
* unparseable. There is no default base URL: self-hosted Gitea instances are
* always named explicitly by the user.
*/
export function normalizeBaseUrl(raw) {
if (typeof raw !== 'string') {
return null;
}
let value = raw.trim();
if (!value) {
return null;
}
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value)) {
value = `https://${value}`;
}
let parsed;
try {
parsed = new URL(value);
} catch {
return null;
}
if (!parsed.hostname) {
return null;
}
parsed.hash = '';
parsed.search = '';
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
return parsed.href.replace(/\/+$/, '');
}
function hostFromBaseUrl(baseUrl) {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) {
return null;
}
try {
return new URL(normalized).hostname || null;
} catch {
return null;
}
}
function resolveAccountId({ username, accessToken, baseUrl, accountId }) {
if (typeof accountId === 'string' && accountId.trim()) {
return accountId.trim();
}
const host = hostFromBaseUrl(baseUrl);
if (typeof username === 'string' && username.trim()) {
return host ? `${host}:${username.trim()}` : username.trim();
}
if (typeof accessToken === 'string' && accessToken.trim()) {
return `token:${accessToken.slice(0, 8)}`;
}
return '';
}
function normalizeAuthEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : '';
if (!accessToken) return null;
const baseUrl = normalizeBaseUrl(entry.baseUrl);
// No default base URL exists for Gitea; an entry without a usable instance
// URL cannot make any API call, so it is dropped.
if (!baseUrl) return null;
const username = typeof entry.username === 'string' ? entry.username : '';
const accountId = resolveAccountId({
username,
accessToken,
baseUrl,
accountId: typeof entry.accountId === 'string' ? entry.accountId : '',
});
return {
accessToken,
baseUrl,
username: username || null,
name: typeof entry.name === 'string' ? entry.name : null,
avatarUrl: typeof entry.avatarUrl === 'string' ? entry.avatarUrl : null,
webUrl: typeof entry.webUrl === 'string' ? entry.webUrl : null,
email: typeof entry.email === 'string' ? entry.email : null,
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
current: Boolean(entry.current),
accountId,
};
}
function normalizeAuthList(raw) {
const list = (Array.isArray(raw) ? raw : [raw])
.map((entry) => normalizeAuthEntry(entry))
.filter(Boolean);
if (!list.length) {
return { list: [], changed: false };
}
let changed = false;
let currentFound = false;
list.forEach((entry) => {
if (entry.current && !currentFound) {
currentFound = true;
} else if (entry.current && currentFound) {
entry.current = false;
changed = true;
}
});
if (!currentFound && list[0]) {
list[0].current = true;
changed = true;
}
list.forEach((entry) => {
if (!entry.accountId) {
entry.accountId = resolveAccountId(entry);
changed = true;
}
});
return { list, changed };
}
function readAuthList() {
const data = readJsonFile();
if (!data) {
return [];
}
const { list, changed } = normalizeAuthList(data);
if (changed) {
writeJsonFile(list);
}
return list;
}
function writeAuthList(list) {
writeJsonFile(list);
}
export function getGiteaAuth() {
const list = readAuthList();
if (!list.length) {
return null;
}
const current = list.find((entry) => entry.current) || list[0];
if (!current?.accessToken) {
return null;
}
return current;
}
export function getGiteaAuthAccounts() {
const list = readAuthList();
return list
.filter((entry) => entry?.accountId && entry?.baseUrl)
.map((entry) => ({
id: entry.accountId,
user: {
username: entry.username || null,
name: entry.name || null,
avatarUrl: entry.avatarUrl || null,
webUrl: entry.webUrl || null,
},
baseUrl: entry.baseUrl,
current: Boolean(entry.current),
}));
}
export function setGiteaAuth({ accessToken, baseUrl, user }) {
if (!accessToken || typeof accessToken !== 'string') {
throw new Error('accessToken is required');
}
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
throw new Error('baseUrl is required and must be a valid URL');
}
// Gitea/Forgejo `GET /user` uses `login`/`full_name`; tolerate the snake_case
// variants too so stored entries stay robust across API versions.
const normalizedUser = user && typeof user === 'object'
? {
username: typeof user.login === 'string' ? user.login : (typeof user.username === 'string' ? user.username : undefined),
name: typeof user.full_name === 'string' ? user.full_name : (typeof user.name === 'string' ? user.name : undefined),
avatarUrl: typeof user.avatar_url === 'string' ? user.avatar_url : undefined,
webUrl: typeof user.html_url === 'string' ? user.html_url : (typeof user.web_url === 'string' ? user.web_url : undefined),
email: typeof user.email === 'string' ? user.email : undefined,
}
: undefined;
const username = normalizedUser?.username || '';
const resolvedAccountId = resolveAccountId({
username,
accessToken,
baseUrl: normalizedBaseUrl,
accountId: '',
});
const list = readAuthList();
const existingIndex = list.findIndex((entry) => entry.accountId === resolvedAccountId);
const nextEntry = {
accessToken,
baseUrl: normalizedBaseUrl,
username: username || null,
name: normalizedUser?.name ?? null,
avatarUrl: normalizedUser?.avatarUrl ?? null,
webUrl: normalizedUser?.webUrl ?? null,
email: normalizedUser?.email ?? null,
createdAt: Date.now(),
current: true,
accountId: resolvedAccountId,
};
if (existingIndex >= 0) {
list[existingIndex] = nextEntry;
} else {
list.push(nextEntry);
}
list.forEach((entry, index) => {
entry.current = index === (existingIndex >= 0 ? existingIndex : list.length - 1);
});
writeAuthList(list);
return nextEntry;
}
export function activateGiteaAuth(accountId) {
if (typeof accountId !== 'string' || !accountId.trim()) {
return false;
}
const list = readAuthList();
const index = list.findIndex((entry) => entry.accountId === accountId.trim());
if (index === -1) {
return false;
}
list.forEach((entry, idx) => {
entry.current = idx === index;
});
writeAuthList(list);
return true;
}
export function clearGiteaAuth() {
try {
const list = readAuthList();
if (!list.length) {
return true;
}
const remaining = list.filter((entry) => !entry.current);
if (!remaining.length) {
if (fs.existsSync(STORAGE_FILE)) {
fs.unlinkSync(STORAGE_FILE);
}
return true;
}
remaining.forEach((entry, index) => {
entry.current = index === 0;
});
writeAuthList(remaining);
return true;
} catch (error) {
console.error('Failed to clear Gitea auth file:', error);
return false;
}
}
export const GITEA_AUTH_FILE = STORAGE_FILE;
+178
View File
@@ -0,0 +1,178 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, afterEach, describe, expect, test } from 'vitest';
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitea-auth-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
const {
getGiteaAuth,
getGiteaAuthAccounts,
setGiteaAuth,
activateGiteaAuth,
clearGiteaAuth,
normalizeBaseUrl,
GITEA_AUTH_FILE,
} = await import('./auth.js');
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
afterEach(() => {
if (fs.existsSync(GITEA_AUTH_FILE)) {
fs.unlinkSync(GITEA_AUTH_FILE);
}
});
const aliceUser = {
id: 42,
login: 'alice',
full_name: 'Alice Example',
avatar_url: 'https://gitea.example.com/avatars/alice.png',
html_url: 'https://gitea.example.com/alice',
email: 'alice@example.com',
};
describe('normalizeBaseUrl', () => {
test('adds https scheme when missing', () => {
expect(normalizeBaseUrl('gitea.example.com')).toBe('https://gitea.example.com');
});
test('strips trailing slash', () => {
expect(normalizeBaseUrl('https://gitea.example.com/')).toBe('https://gitea.example.com');
expect(normalizeBaseUrl('https://gitea.example.com/gitea/')).toBe('https://gitea.example.com/gitea');
});
test('keeps an explicit scheme', () => {
expect(normalizeBaseUrl('http://localhost:3000')).toBe('http://localhost:3000');
});
test('returns null for invalid input', () => {
expect(normalizeBaseUrl('')).toBeNull();
expect(normalizeBaseUrl('not a url')).toBeNull();
expect(normalizeBaseUrl('://bad')).toBeNull();
expect(normalizeBaseUrl(null)).toBeNull();
expect(normalizeBaseUrl(undefined)).toBeNull();
});
});
describe('setGiteaAuth', () => {
test('stores an account with a host-prefixed accountId', () => {
setGiteaAuth({ accessToken: 'gitea-secret', baseUrl: 'gitea.example.com', user: aliceUser });
const auth = getGiteaAuth();
expect(auth).not.toBeNull();
expect(auth.accountId).toBe('gitea.example.com:alice');
expect(auth.baseUrl).toBe('https://gitea.example.com');
expect(auth.username).toBe('alice');
expect(auth.name).toBe('Alice Example');
expect(auth.avatarUrl).toBe('https://gitea.example.com/avatars/alice.png');
expect(auth.webUrl).toBe('https://gitea.example.com/alice');
expect(auth.email).toBe('alice@example.com');
expect(auth.current).toBe(true);
expect(auth.createdAt).toEqual(expect.any(Number));
});
test('writes the auth file with 0600 permissions', () => {
setGiteaAuth({ accessToken: 'gitea-secret', baseUrl: 'https://gitea.example.com', user: aliceUser });
const stats = fs.statSync(GITEA_AUTH_FILE);
// 0o600 mask
expect(stats.mode & 0o777).toBe(0o600);
});
test('replaces the same account instead of duplicating it', () => {
setGiteaAuth({ accessToken: 'gitea-old', baseUrl: 'gitea.example.com', user: aliceUser });
setGiteaAuth({
accessToken: 'gitea-new',
baseUrl: 'https://gitea.example.com',
user: { ...aliceUser, full_name: 'Alice Renamed' },
});
const accounts = getGiteaAuthAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0].user.name).toBe('Alice Renamed');
expect(getGiteaAuth().accessToken).toBe('gitea-new');
});
test('falls back to a token prefix accountId when username is missing', () => {
setGiteaAuth({ accessToken: 'gitea-prefixtest', baseUrl: 'gitea.example.com', user: { id: 1 } });
const accounts = getGiteaAuthAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0].id).toBe('token:gitea-pr');
});
test('requires an access token', () => {
expect(() => setGiteaAuth({ baseUrl: 'gitea.example.com', user: aliceUser })).toThrow('accessToken is required');
});
test('requires a base URL (no default instance)', () => {
expect(() => setGiteaAuth({ accessToken: 'gitea-secret', user: aliceUser })).toThrow('baseUrl is required and must be a valid URL');
expect(() => setGiteaAuth({ accessToken: 'gitea-secret', baseUrl: 'not a url', user: aliceUser })).toThrow('baseUrl is required and must be a valid URL');
});
});
describe('multi-account switching', () => {
test('tracks a single current account and can switch it', () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
setGiteaAuth({
accessToken: 'gitea-b',
baseUrl: 'https://gitea.other.example',
user: { ...aliceUser, login: 'bob', full_name: 'Bob' },
});
expect(getGiteaAuth().accountId).toBe('gitea.other.example:bob');
const switched = activateGiteaAuth('gitea.example.com:alice');
expect(switched).toBe(true);
expect(getGiteaAuth().accountId).toBe('gitea.example.com:alice');
expect(getGiteaAuthAccounts().find((a) => a.id === 'gitea.other.example:bob')?.current).toBe(false);
});
test('activate returns false for an unknown account', () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
expect(activateGiteaAuth('gitea.example.com:nobody')).toBe(false);
expect(activateGiteaAuth('')).toBe(false);
expect(activateGiteaAuth(undefined)).toBe(false);
});
});
describe('clearGiteaAuth', () => {
test('removes the current account and deletes the file when empty', () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
const removed = clearGiteaAuth();
expect(removed).toBe(true);
expect(getGiteaAuth()).toBeNull();
expect(fs.existsSync(GITEA_AUTH_FILE)).toBe(false);
});
test('keeps other accounts and promotes the first remaining', () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
setGiteaAuth({
accessToken: 'gitea-b',
baseUrl: 'https://gitea.other.example',
user: { ...aliceUser, login: 'bob' },
});
clearGiteaAuth();
const accounts = getGiteaAuthAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0].id).toBe('gitea.example.com:alice');
expect(accounts[0].current).toBe(true);
});
});
describe('no default base URL', () => {
test('the module exports no DEFAULT_GITEA_BASE_URL', async () => {
const module = await import('./auth.js');
expect(module.DEFAULT_GITEA_BASE_URL).toBeUndefined();
});
test('accounts always carry a real baseUrl', () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
for (const account of getGiteaAuthAccounts()) {
expect(account.baseUrl).toMatch(/^https?:\/\//);
}
});
});
+292
View File
@@ -0,0 +1,292 @@
import { getGiteaAuth } from './auth.js';
// Per-request timeout for every Gitea call. Self-hosted instances can hang
// under load; bounding each request lets the caller fail fast and serve
// cached/last-known state instead of holding a socket open.
const REQUEST_TIMEOUT_MS = 8000;
const timeoutFetch = (url, options = {}) => {
// Respect a caller-provided signal if present; otherwise attach our timeout.
if (options.signal) {
return fetch(url, options);
}
return fetch(url, { ...options, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
};
// Conditional-request cache for GET calls: Gitea serves 304 Not Modified for
// matching If-None-Match, so polling unchanged issues/PRs stays cheap. Keyed by
// token+URL so different identities never share responses.
const ETAG_CACHE_MAX_ENTRIES = 300;
const etagCache = new Map();
const rememberEtag = (key, etag, body, headers) => {
etagCache.delete(key);
etagCache.set(key, { etag, body, headers });
if (etagCache.size > ETAG_CACHE_MAX_ENTRIES) {
const oldest = etagCache.keys().next().value;
if (oldest !== undefined) {
etagCache.delete(oldest);
}
}
};
const createConditionalFetch = (token) => async (url, options = {}) => {
const method = (options.method || 'GET').toUpperCase();
if (method !== 'GET') {
return timeoutFetch(url, options);
}
const cacheKey = `${token}\n${url}`;
const cached = etagCache.get(cacheKey);
const headers = { ...(options.headers || {}) };
if (cached?.etag) {
headers['if-none-match'] = cached.etag;
}
const response = await timeoutFetch(url, { ...options, headers });
if (response.status === 304 && cached) {
// Touch for LRU and replay the cached success response.
rememberEtag(cacheKey, cached.etag, cached.body, cached.headers);
return new Response(cached.body, { status: 200, headers: cached.headers });
}
if (response.ok) {
const etag = response.headers.get('etag');
if (etag) {
const body = await response.arrayBuffer();
rememberEtag(cacheKey, etag, body, response.headers);
return new Response(body, { status: response.status, headers: response.headers });
}
}
return response;
};
// ---- Own rate-limit cooldown (deliberately NOT shared with github/gitlab) ----
const MAX_COOLDOWN_MS = 15 * 60 * 1000;
const DEFAULT_COOLDOWN_MS = 60 * 1000;
let rateLimitedUntil = 0;
const headerValue = (headers, name) => {
if (!headers) return undefined;
if (typeof headers.get === 'function') return headers.get(name);
return headers[name];
};
/**
* Record a cooldown after a Gitea 429. Accepts a fetch Response or any object
* carrying headers, honoring `Retry-After` (seconds) or `X-RateLimit-Reset`
* (Unix seconds) when present.
*/
export function noteGiteaRateLimit(error) {
const headers = error?.headers;
let retryMs = null;
const retryAfter = headerValue(headers, 'retry-after');
if (retryAfter !== undefined && retryAfter !== null) {
const secs = Number(retryAfter);
if (Number.isFinite(secs) && secs > 0) retryMs = secs * 1000;
}
if (retryMs === null) {
// Gitea sends `X-RateLimit-Reset`; check the generic name too for robustness.
const reset = headerValue(headers, 'x-ratelimit-reset') ?? headerValue(headers, 'ratelimit-reset');
if (reset !== undefined && reset !== null) {
const delta = Number(reset) * 1000 - Date.now();
if (Number.isFinite(delta) && delta > 0) retryMs = delta;
}
}
if (retryMs === null) retryMs = DEFAULT_COOLDOWN_MS;
retryMs = Math.min(retryMs, MAX_COOLDOWN_MS);
const until = Date.now() + retryMs;
if (until > rateLimitedUntil) {
rateLimitedUntil = until;
console.warn(`[gitea] rate limited — pausing Gitea calls for ~${Math.round(retryMs / 1000)}s`);
}
}
export function isGiteaRateLimited() {
return Date.now() < rateLimitedUntil;
}
// ---- Response helpers ----
const joinApiUrl = (baseUrl, path) => {
const base = String(baseUrl || '').replace(/\/+$/, '');
const p = typeof path === 'string' && path ? (path.startsWith('/') ? path : `/${path}`) : '';
return `${base}/api/v1${p}`;
};
const headersToObject = (headers) => {
const out = {};
if (!headers) return out;
if (typeof headers.forEach === 'function') {
headers.forEach((value, key) => {
out[key] = value;
});
} else if (typeof headers === 'object') {
for (const [key, value] of Object.entries(headers)) {
out[key] = value;
}
}
return out;
};
const parsePageInfo = (headers) => {
const get = (name) => {
const value = headerValue(headers, name);
return typeof value === 'string' ? value : '';
};
// Gitea paginates list endpoints via the `Link` header (rel="next") and
// reports the total via `X-Total-Count`.
const linkHeader = get('link');
const relNextMatch = linkHeader.match(/<([^>]+)>\s*;\s*rel="next"/);
const totalRaw = get('x-total-count');
const total = totalRaw ? Number(totalRaw) : null;
const parsed = {
page: null,
next: null,
total: total !== null && Number.isFinite(total) ? total : null,
hasMore: Boolean(relNextMatch),
};
if (relNextMatch) {
parsed.nextUrl = relNextMatch[1];
}
return parsed;
};
const parseData = async (response, raw) => {
const text = await response.text();
if (raw) {
return text;
}
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return null;
}
};
/**
* Create a raw-fetch Gitea/Forgejo REST v1 client. `request` never throws for
* HTTP error statuses — it returns `{ status, headers, data, page }` so callers
* can branch on status codes. On 429 it also sets `error: 'Gitea rate limited'`
* and records a module-level cooldown.
*/
export function createGiteaClient({ token, baseUrl }) {
const effectiveBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim().replace(/\/+$/, '') : '';
const request = async (path, options = {}) => {
const method = (typeof options.method === 'string' ? options.method : 'GET').toUpperCase();
const query = options.query && typeof options.query === 'object' ? options.query : {};
const body = options.body;
const callerSignal = options.signal;
const raw = options.raw === true;
if (isGiteaRateLimited()) {
return { status: 429, headers: {}, data: null, page: null, error: 'Gitea rate limited' };
}
let url = joinApiUrl(effectiveBaseUrl, path);
const qs = new URLSearchParams();
let hasQuery = false;
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue;
qs.set(key, String(value));
hasQuery = true;
}
if (hasQuery) {
url += `${url.includes('?') ? '&' : '?'}${qs.toString()}`;
}
// Gitea/Forgejo PAT auth: `Authorization: token <pat>`.
const headers = {
Authorization: `token ${token}`,
accept: raw ? 'text/plain' : 'application/json',
};
const fetchOptions = {
method,
headers,
redirect: 'manual',
};
if (body !== undefined) {
headers['content-type'] = 'application/json';
fetchOptions.body = JSON.stringify(body);
}
if (callerSignal) {
fetchOptions.signal = callerSignal;
}
const conditionalFetch = createConditionalFetch(token);
let response = await conditionalFetch(url, fetchOptions);
// Follow redirects (301/302/308) exactly once. Gitea serves them for moved
// repos/users; a manual redirect keeps our Authorization header across the hop.
let redirects = 0;
while (
(response.status === 301 || response.status === 302 || response.status === 308)
&& headerValue(response.headers, 'location')
&& redirects < 1
) {
const location = headerValue(response.headers, 'location');
const nextUrl = new URL(location, url).toString();
response = await conditionalFetch(nextUrl, fetchOptions);
redirects += 1;
}
const result = {
status: response.status,
headers: headersToObject(response.headers),
data: await parseData(response, raw),
page: parsePageInfo(response.headers),
};
if (response.status === 429) {
noteGiteaRateLimit(response);
result.error = 'Gitea rate limited';
}
return result;
};
return {
request,
baseUrl: effectiveBaseUrl,
user: () => request('/user'),
repo: (owner, repo) => request(`/repos/${owner}/${repo}`),
issues: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/issues`, { query: params }),
issue: (owner, repo, number) =>
request(`/repos/${owner}/${repo}/issues/${number}`),
issueComments: (owner, repo, number, params = {}) =>
request(`/repos/${owner}/${repo}/issues/${number}/comments`, { query: params }),
pullRequests: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/pulls`, { query: params }),
pullRequest: (owner, repo, number) =>
request(`/repos/${owner}/${repo}/pulls/${number}`),
pullRequestDiff: (owner, repo, number) =>
request(`/repos/${owner}/${repo}/pulls/${number}.diff`, { raw: true }),
pullRequestFiles: (owner, repo, number, params = {}) =>
request(`/repos/${owner}/${repo}/pulls/${number}/files`, { query: params }),
createPullRequest: (owner, repo, body) =>
request(`/repos/${owner}/${repo}/pulls`, { method: 'POST', body }),
updatePullRequest: (owner, repo, number, body) =>
request(`/repos/${owner}/${repo}/pulls/${number}`, { method: 'PATCH', body }),
mergePullRequest: (owner, repo, number, body) =>
request(`/repos/${owner}/${repo}/pulls/${number}/merge`, { method: 'POST', body }),
branches: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/branches`, { query: params }),
};
}
/** Picks the current account (from auth.js) token + base URL, or null. */
export function getGiteaClientOrNull() {
const auth = getGiteaAuth();
if (!auth?.accessToken || !auth?.baseUrl) {
return null;
}
return createGiteaClient({ token: auth.accessToken, baseUrl: auth.baseUrl });
}
@@ -0,0 +1,308 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, afterEach, describe, expect, test, vi } from 'vitest';
// Isolate auth storage so getGiteaClientOrNull never reads a real account.
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitea-client-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
const {
createGiteaClient,
getGiteaClientOrNull,
isGiteaRateLimited,
noteGiteaRateLimit,
} = await import('./client.js');
const jsonResponse = (data, { status = 200, headers = {} } = {}) =>
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', ...headers } });
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('createGiteaClient request basics', () => {
test('calls {baseUrl}/api/v1{path} and sends the token Authorization header', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 42, login: 'alice' }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 'gitea-token', baseUrl: 'https://gitea.example.com' });
const result = await client.user();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/user');
expect(options.headers.Authorization).toBe('token gitea-token');
expect(result).toMatchObject({ status: 200, data: { id: 42, login: 'alice' } });
expect(result.error).toBeUndefined();
});
test('joins a custom base URL with a path without duplicating /api/v1', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com/gitea/' });
await client.issues('owner', 'repo', { state: 'open' });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/gitea/api/v1/repos/owner/repo/issues?state=open');
});
test('serializes query params and omits empty ones', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.pullRequests('owner', 'repo', { state: 'open', limit: 50, page: 2, q: '', sort: null });
const [url] = fetchMock.mock.calls[0];
const query = String(url).split('?')[1];
expect(query).toContain('state=open');
expect(query).toContain('limit=50');
expect(query).toContain('page=2');
expect(query).not.toContain('q');
expect(query).not.toContain('sort');
});
test('POST requests send a JSON body', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ ok: true }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.request('/some/action', { method: 'POST', body: { hello: 'world' } });
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('POST');
expect(options.headers['content-type']).toBe('application/json');
expect(options.body).toBe(JSON.stringify({ hello: 'world' }));
});
test('surfaces error statuses without throwing', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ message: 'nope' }, { status: 401 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.user();
expect(result.status).toBe(401);
expect(result.data).toEqual({ message: 'nope' });
});
test('attaches a caller signal when provided, else a timeout signal', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const controller = new AbortController();
await client.branches('owner', 'repo', { limit: 50 });
await client.request('/user', { signal: controller.signal });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][1].signal).toEqual(expect.any(AbortSignal));
expect(fetchMock.mock.calls[1][1].signal).toBe(controller.signal);
});
test('raw requests return the body as text', async () => {
const fetchMock = vi.fn(async () => new Response('diff --git a/src/a.ts b/src/a.ts\n', { status: 200 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.pullRequestDiff('owner', 'repo', 5);
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls/5.diff');
expect(options.headers.accept).toBe('text/plain');
expect(result.status).toBe(200);
expect(result.data).toBe('diff --git a/src/a.ts b/src/a.ts\n');
});
});
describe('pagination', () => {
test('parses the Link rel=next header into the page object', async () => {
const fetchMock = vi.fn(async () => jsonResponse([], {
headers: {
link: '<https://gitea.example.com/api/v1/repos/o/r/issues?page=3>; rel="next", <...>; rel="last"',
'x-total-count': '57',
},
}));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.issues('o', 'r', { page: 2 });
expect(result.page.hasMore).toBe(true);
expect(result.page.nextUrl).toBe('https://gitea.example.com/api/v1/repos/o/r/issues?page=3');
expect(result.page.total).toBe(57);
});
test('reports hasMore=false on the last page', async () => {
const fetchMock = vi.fn(async () => jsonResponse([], { headers: {} }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.issues('o', 'r', { page: 1 });
expect(result.page.hasMore).toBe(false);
});
});
describe('redirect handling', () => {
test('follows a redirect exactly once, preserving the Authorization header', async () => {
const movedUrl = 'https://gitea.example.com/api/v1/repos/newowner/home/issues';
const fetchMock = vi.fn(async (url) => {
if (String(url).includes('/repos/owner/repo/issues')) {
return jsonResponse({}, { status: 301, headers: { location: '/api/v1/repos/newowner/home/issues' } });
}
if (String(url) === movedUrl) {
return jsonResponse([{ number: 1 }]);
}
return jsonResponse({}, { status: 404 });
});
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 'gitea-t', baseUrl: 'https://gitea.example.com' });
const result = await client.issues('owner', 'repo');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result.status).toBe(200);
expect(result.data).toEqual([{ number: 1 }]);
const [, secondOptions] = fetchMock.mock.calls[1];
expect(secondOptions.headers.Authorization).toBe('token gitea-t');
});
});
describe('etag conditional cache', () => {
test('sends if-none-match and replays a 304 as a 200 with cached body', async () => {
const fetchMock = vi.fn(async (_url, options) => {
if (options.headers['if-none-match'] === '"v1"') {
return new Response(null, { status: 304 });
}
return jsonResponse({ ok: true }, { headers: { etag: '"v1"' } });
});
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 'gitea-t', baseUrl: 'https://gitea.example.com' });
const first = await client.user();
expect(first.status).toBe(200);
expect(first.data).toEqual({ ok: true });
const second = await client.user();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][1].headers['if-none-match']).toBe('"v1"');
expect(second.status).toBe(200);
expect(second.data).toEqual({ ok: true });
});
test('does not cache POST responses', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ ok: true }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 'gitea-t', baseUrl: 'https://gitea.example.com' });
await client.request('/thing', { method: 'POST', body: {} });
await client.request('/thing', { method: 'POST', body: {} });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('pull request write methods', () => {
test('createPullRequest POSTs title/head/base to the pulls endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ number: 5, title: 'New PR' }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.createPullRequest('owner', 'repo', {
title: 'New PR',
head: 'feat/x',
base: 'main',
});
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls');
expect(options.method).toBe('POST');
expect(options.headers['content-type']).toBe('application/json');
expect(JSON.parse(options.body)).toEqual({ title: 'New PR', head: 'feat/x', base: 'main' });
expect(result.status).toBe(201);
expect(result.data).toEqual({ number: 5, title: 'New PR' });
});
test('updatePullRequest PATCHes a JSON body to the pull request endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ number: 5, title: 'Updated' }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.updatePullRequest('owner', 'repo', 5, { title: 'Updated', body: 'Body text' });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls/5');
expect(options.method).toBe('PATCH');
expect(options.headers['content-type']).toBe('application/json');
expect(JSON.parse(options.body)).toEqual({ title: 'Updated', body: 'Body text' });
});
test('mergePullRequest POSTs Do and MergeMethod to the merge endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ merged: true }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.mergePullRequest('owner', 'repo', 5, { Do: true, MergeMethod: 'squash' });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls/5/merge');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ Do: true, MergeMethod: 'squash' });
});
test('write methods surface error statuses without throwing', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ message: 'Conflict' }, { status: 409 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.mergePullRequest('owner', 'repo', 5, { Do: true, MergeMethod: 'merge' });
expect(result.status).toBe(409);
expect(result.data).toEqual({ message: 'Conflict' });
});
});
describe('rate limiting', () => {
// NOTE: these tests run last in this file. The rate-limit cooldown is
// module-level and has no reset export, so earlier tests must not set one.
test('429 surfaces error and records a cooldown', async () => {
const fetchMock = vi.fn(async () => jsonResponse({}, { status: 429, headers: { 'retry-after': '5' } }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 'gitea-t', baseUrl: 'https://gitea.example.com' });
const result = await client.user();
expect(result.status).toBe(429);
expect(result.error).toBe('Gitea rate limited');
expect(isGiteaRateLimited()).toBe(true);
});
test('short-circuits while the cooldown is active without calling fetch', async () => {
noteGiteaRateLimit({ headers: new Headers({ 'retry-after': '120' }) });
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 'gitea-t', baseUrl: 'https://gitea.example.com' });
const gated = await client.issues('o', 'r');
expect(gated.status).toBe(429);
expect(gated.error).toBe('Gitea rate limited');
expect(fetchMock).not.toHaveBeenCalled();
});
test('parses Retry-After seconds into the cooldown', () => {
noteGiteaRateLimit({ headers: new Headers({ 'retry-after': '120' }) });
expect(isGiteaRateLimited()).toBe(true);
});
test('honors X-RateLimit-Reset for the cooldown', () => {
noteGiteaRateLimit({ headers: new Headers({ 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 60) }) });
expect(isGiteaRateLimited()).toBe(true);
});
test('getGiteaClientOrNull returns null without stored auth', () => {
expect(getGiteaClientOrNull()).toBeNull();
});
});
+21
View File
@@ -0,0 +1,21 @@
export {
getGiteaAuth,
getGiteaAuthAccounts,
setGiteaAuth,
activateGiteaAuth,
clearGiteaAuth,
normalizeBaseUrl,
GITEA_AUTH_FILE,
} from './auth.js';
export {
createGiteaClient,
getGiteaClientOrNull,
isGiteaRateLimited,
noteGiteaRateLimit,
} from './client.js';
export {
parseGiteaRemoteUrl,
resolveGiteaRepoFromDirectory,
} from './repo.js';
+124
View File
@@ -0,0 +1,124 @@
import { getRemoteUrl } from '../git/index.js';
import { getGiteaAuthAccounts, normalizeBaseUrl } from './auth.js';
// When no explicit host allowlist is provided, accept any host that matches the
// base URL of a stored Gitea account. Gitea/Forgejo is self-hosted, so there is
// no default host. Never github.com or gitlab.com — those belong to other
// providers and must not be classified as Gitea.
function acceptedHosts(knownHosts) {
const hosts = new Set();
if (knownHosts instanceof Set) {
for (const host of knownHosts) {
if (typeof host === 'string' && host.trim()) {
hosts.add(host.trim().toLowerCase());
}
}
return hosts;
}
if (Array.isArray(knownHosts)) {
for (const host of knownHosts) {
if (typeof host === 'string' && host.trim()) {
hosts.add(host.trim().toLowerCase());
}
}
return hosts;
}
for (const account of getGiteaAuthAccounts()) {
try {
const host = new URL(normalizeBaseUrl(account.baseUrl) || account.baseUrl).hostname.toLowerCase();
if (host) {
hosts.add(host);
}
} catch {
// ignore malformed stored account base URLs
}
}
return hosts;
}
/**
* Parse a Gitea/Forgejo remote URL into `{ owner, repo, host, baseUrl, url }`.
*
* Gitea repos are flat `owner/repo` (no multi-segment namespaces). Supports:
* - `git@HOST:owner/repo.git`
* - `ssh://git@HOST/owner/repo.git`
* - `https://HOST/owner/repo(.git)`
*
* `knownHosts` (optional Set of hostnames) restricts which hosts are accepted.
* When omitted, hosts from stored auth accounts are accepted. `github.com` and
* `gitlab.com` are never accepted.
*/
export const parseGiteaRemoteUrl = (raw, knownHosts) => {
if (typeof raw !== 'string') {
return null;
}
const value = raw.trim();
if (!value) {
return null;
}
let host = '';
let path = '';
// git@HOST:owner/repo.git
const scpLike = value.match(/^git@([^:]+):(.+)$/);
if (scpLike) {
host = scpLike[1].toLowerCase();
path = scpLike[2];
} else if (value.startsWith('ssh://') || /^https?:\/\//.test(value)) {
try {
const url = new URL(value);
host = url.hostname.toLowerCase();
path = url.pathname.replace(/^\/+/, '');
} catch {
return null;
}
} else {
return null;
}
if (!host) {
return null;
}
if (host === 'github.com' || host === 'gitlab.com') {
return null;
}
if (!acceptedHosts(knownHosts).has(host)) {
return null;
}
path = path.replace(/\/+$/, '');
if (path.endsWith('.git')) {
path = path.slice(0, -4);
}
const segments = path.split('/').filter(Boolean);
// Gitea repos are flat owner/repo — exactly two segments.
if (segments.length !== 2) {
return null;
}
const owner = segments[0];
const repo = segments[1];
if (!owner || !repo) {
return null;
}
return {
owner,
repo,
host,
baseUrl: `https://${host}`,
url: `https://${host}/${owner}/${repo}`,
};
};
export async function resolveGiteaRepoFromDirectory(directory, remoteName = 'origin') {
const remoteUrl = await getRemoteUrl(directory, remoteName).catch(() => null);
if (!remoteUrl) {
return { repo: null, remoteUrl: null };
}
return {
repo: parseGiteaRemoteUrl(remoteUrl),
remoteUrl,
};
}
+125
View File
@@ -0,0 +1,125 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitea-repo-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
vi.mock('../git/index.js', () => ({
getRemoteUrl: vi.fn(async () => null),
}));
const { parseGiteaRemoteUrl, resolveGiteaRepoFromDirectory } = await import('./repo.js');
const { getRemoteUrl } = await import('../git/index.js');
const { setGiteaAuth, clearGiteaAuth } = await import('./auth.js');
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
clearGiteaAuth();
});
describe('parseGiteaRemoteUrl', () => {
test('parses scp-like git@host:owner/repo.git', () => {
expect(parseGiteaRemoteUrl('git@gitea.example.com:group/project.git', new Set(['gitea.example.com']))).toEqual({
owner: 'group',
repo: 'project',
host: 'gitea.example.com',
baseUrl: 'https://gitea.example.com',
url: 'https://gitea.example.com/group/project',
});
});
test('parses ssh:// URLs', () => {
expect(parseGiteaRemoteUrl('ssh://git@gitea.example.com/owner/proj.git', new Set(['gitea.example.com']))).toMatchObject({
owner: 'owner',
repo: 'proj',
host: 'gitea.example.com',
});
});
test('parses https URLs with and without .git suffix', () => {
expect(parseGiteaRemoteUrl('https://gitea.example.com/owner/proj.git', new Set(['gitea.example.com']))).toMatchObject({
owner: 'owner',
repo: 'proj',
host: 'gitea.example.com',
});
expect(parseGiteaRemoteUrl('https://gitea.example.com/owner/proj', new Set(['gitea.example.com']))).toMatchObject({
owner: 'owner',
repo: 'proj',
});
});
test('rejects multi-segment paths (Gitea repos are flat owner/repo)', () => {
expect(parseGiteaRemoteUrl('git@gitea.example.com:a/b/c/proj.git', new Set(['gitea.example.com']))).toBeNull();
expect(parseGiteaRemoteUrl('https://gitea.example.com/a/b/proj.git', new Set(['gitea.example.com']))).toBeNull();
});
test('rejects hosts not in knownHosts', () => {
expect(parseGiteaRemoteUrl('git@gitea.example.com:owner/app.git', new Set(['other.example.com']))).toBeNull();
});
test('accepts hosts stored in auth accounts when knownHosts is omitted', () => {
setGiteaAuth({
accessToken: 'gitea-account-test',
baseUrl: 'https://git.internal.example',
user: { id: 1, login: 'worker' },
});
const result = parseGiteaRemoteUrl('git@git.internal.example:team/app.git');
expect(result).toMatchObject({ host: 'git.internal.example', owner: 'team', repo: 'app' });
});
test('never accepts github.com or gitlab.com', () => {
expect(parseGiteaRemoteUrl('git@github.com:owner/repo.git')).toBeNull();
expect(parseGiteaRemoteUrl('git@gitlab.com:owner/repo.git')).toBeNull();
expect(parseGiteaRemoteUrl('https://github.com/owner/repo.git', new Set(['github.com']))).toBeNull();
expect(parseGiteaRemoteUrl('https://gitlab.com/owner/repo.git', new Set(['gitlab.com']))).toBeNull();
});
test('returns null for malformed input', () => {
expect(parseGiteaRemoteUrl('')).toBeNull();
expect(parseGiteaRemoteUrl('not a remote')).toBeNull();
expect(parseGiteaRemoteUrl('git@gitea.example.com:onlyone')).toBeNull();
expect(parseGiteaRemoteUrl(null)).toBeNull();
expect(parseGiteaRemoteUrl(undefined)).toBeNull();
});
});
describe('resolveGiteaRepoFromDirectory', () => {
// Gitea has no default host, so directory resolution only accepts hosts from
// stored accounts — set one up like a connected user would.
beforeEach(() => {
setGiteaAuth({
accessToken: 'gitea-dir-test',
baseUrl: 'https://gitea.example.com',
user: { id: 1, login: 'worker' },
});
});
test('resolves the repo from the origin remote', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitea.example.com:acme/widgets.git');
const { repo, remoteUrl } = await resolveGiteaRepoFromDirectory('/some/project');
expect(remoteUrl).toBe('git@gitea.example.com:acme/widgets.git');
expect(repo).toMatchObject({ owner: 'acme', repo: 'widgets', host: 'gitea.example.com' });
});
test('uses a custom remote name', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('https://gitea.example.com/acme/widgets.git');
await resolveGiteaRepoFromDirectory('/some/project', 'upstream');
expect(getRemoteUrl).toHaveBeenCalledWith('/some/project', 'upstream');
});
test('returns null repo when the remote is not Gitea', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@github.com:owner/repo.git');
const { repo, remoteUrl } = await resolveGiteaRepoFromDirectory('/some/project');
expect(repo).toBeNull();
expect(remoteUrl).toBe('git@github.com:owner/repo.git');
});
test('returns null when there is no remote URL', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue(null);
const { repo, remoteUrl } = await resolveGiteaRepoFromDirectory('/some/project');
expect(repo).toBeNull();
expect(remoteUrl).toBeNull();
});
});
+887
View File
@@ -0,0 +1,887 @@
// Route-level budget for composite Gitea calls (lists, comments, PR context).
// The client bounds each individual request at 8s; this caps the whole route
// so a slow self-hosted instance cannot hold a response (and a client socket)
// open indefinitely. The client keeps its last-known state on error.
const ROUTE_TIMEOUT_MS = 15_000;
// PR list pagination cap for the source-branch scan: never loop more than 10
// pages when aggregating PRs for a local branch.
const PRS_MAX_PAGES = 10;
function withTimeout(promise, timeoutMs, label) {
let timer;
const timeout = new Promise((_resolve, reject) => {
timer = setTimeout(() => {
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
error.code = 'ETIMEDOUT';
reject(error);
}, timeoutMs);
if (typeof timer.unref === 'function') timer.unref();
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
const asString = (value) => (typeof value === 'string' ? value.trim() : '');
const getRequestedRepo = (req) => {
const owner = asString(req.query?.owner);
const repo = asString(req.query?.repo);
return owner && repo ? { owner, repo } : null;
};
const getRequiredNumber = (req) => {
const raw = typeof req.query?.number === 'string' ? req.query.number : '';
const number = Number(raw);
return Number.isFinite(number) && number > 0 ? number : null;
};
// Gitea/Forgejo `GET /user` (and user sub-objects on issues/PRs) carry the
// username in `login` (plus `full_name`, `avatar_url`, `html_url`). Tolerate
// the `username`/`web_url` variants so mapping stays robust across API versions.
const mapGiteaUser = (data) => {
if (!data || typeof data !== 'object') {
return null;
}
return {
username: typeof data.login === 'string' ? data.login : (typeof data.username === 'string' ? data.username : null),
id: typeof data.id === 'number' ? data.id : null,
name: typeof data.full_name === 'string' ? data.full_name : (typeof data.name === 'string' ? data.name : null),
avatarUrl: typeof data.avatar_url === 'string' ? data.avatar_url : null,
webUrl: typeof data.html_url === 'string' ? data.html_url : (typeof data.web_url === 'string' ? data.web_url : null),
email: typeof data.email === 'string' ? data.email : null,
};
};
const mapGiteaAuthor = (user) => {
if (!user || typeof user !== 'object') {
return null;
}
return {
username: typeof user.login === 'string' ? user.login : (typeof user.username === 'string' ? user.username : ''),
id: typeof user.id === 'number' ? user.id : undefined,
};
};
const mapGiteaLabels = (labels) => (
Array.isArray(labels)
? labels.map((label) => (label && typeof label.name === 'string' ? label.name : '')).filter(Boolean)
: []
);
const mapGiteaIssueSummary = (item) => ({
number: typeof item.number === 'number' ? item.number : Number(item.number),
title: typeof item.title === 'string' ? item.title : '',
url: typeof item.html_url === 'string' ? item.html_url : '',
state: typeof item.state === 'string' ? item.state : 'open',
author: mapGiteaAuthor(item.user) || {},
labels: mapGiteaLabels(item.labels),
});
const mapGiteaPullRequestSummary = (item) => {
const merged = Boolean(item.merged);
const closed = item.state === 'closed';
return {
number: typeof item.number === 'number' ? item.number : Number(item.number),
title: typeof item.title === 'string' ? item.title : '',
url: typeof item.html_url === 'string' ? item.html_url : '',
state: merged ? 'merged' : (closed ? 'closed' : 'open'),
draft: Boolean(item.draft),
author: mapGiteaAuthor(item.user) || {},
labels: mapGiteaLabels(item.labels),
sourceBranch: typeof item.head?.ref === 'string' ? item.head.ref : '',
targetBranch: typeof item.base?.ref === 'string' ? item.base.ref : '',
};
};
const mapGiteaPullRequest = (item) => ({
...mapGiteaPullRequestSummary(item),
body: typeof item.body === 'string' ? item.body : undefined,
mergeable: typeof item.mergeable === 'boolean' ? item.mergeable : undefined,
merged: Boolean(item.merged),
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
});
const mapGiteaComment = (comment) => ({
id: typeof comment.id === 'number' ? comment.id : Number(comment.id),
body: typeof comment.body === 'string' ? comment.body : '',
url: typeof comment.html_url === 'string' ? comment.html_url : undefined,
author: mapGiteaAuthor(comment.user) || {},
createdAt: typeof comment.created_at === 'string' ? comment.created_at : undefined,
});
// Gitea's pull-files endpoint returns capitalized JSON fields
// (Filename/Status/Additions/Deletions/Patch); tolerate the lowercase GitHub
// style too for Forgejo versions that match GitHub output.
const mapGiteaFile = (file) => ({
filename: typeof file.Filename === 'string' ? file.Filename : (typeof file.filename === 'string' ? file.filename : ''),
status: typeof file.Status === 'string' ? file.Status : (typeof file.status === 'string' ? file.status : undefined),
additions: typeof file.Additions === 'number' ? file.Additions : undefined,
deletions: typeof file.Deletions === 'number' ? file.Deletions : undefined,
patch: typeof file.Patch === 'string' ? file.Patch : (typeof file.patch === 'string' ? file.patch : undefined),
});
// Gitea error bodies carry `message` (string) or `error` (string). Flatten
// whichever shape is present into one readable string for write routes.
const giteaErrorMessage = (data) => {
if (!data || typeof data !== 'object') {
return null;
}
if (typeof data.message === 'string' && data.message) {
return data.message;
}
if (typeof data.error === 'string' && data.error) {
return data.error;
}
return null;
};
const repoRefFromOwnerRepo = (owner, repo, baseUrl) => {
let host = null;
let normalizedBaseUrl = null;
let url = null;
if (baseUrl) {
try {
const parsed = new URL(baseUrl);
host = parsed.hostname;
normalizedBaseUrl = parsed.href.replace(/\/+$/, '');
url = `${normalizedBaseUrl}/${owner}/${repo}`;
} catch {
// fall back to unknown host
}
}
return { owner, repo, host, baseUrl: normalizedBaseUrl, url };
};
export function registerGiteaRoutes(app, options = {}) {
let giteaLibraries = null;
const getGiteaLibraries = async () => {
if (!giteaLibraries) {
giteaLibraries = await import('./index.js');
}
return giteaLibraries;
};
const getClient = async () => {
const { getGiteaClientOrNull } = await getGiteaLibraries();
return getGiteaClientOrNull();
};
// Resolve which Gitea repo a request targets. A directory-local git remote
// is the primary source; `owner`/`repo` query params override it (needed for
// repos checked out from non-Gitea remotes).
const resolveRepoForRequest = async (directory, requestedRepo) => {
if (requestedRepo) {
return { owner: requestedRepo.owner, repo: requestedRepo.repo, repoRef: null, fromDirectory: false };
}
if (!directory) {
return { owner: null, repo: null, repoRef: null, fromDirectory: false };
}
const { resolveGiteaRepoFromDirectory } = await getGiteaLibraries();
const { repo } = await resolveGiteaRepoFromDirectory(directory);
if (!repo) {
return { owner: null, repo: null, repoRef: null, fromDirectory: false };
}
return { owner: repo.owner, repo: repo.repo, repoRef: repo, fromDirectory: true };
};
// ================= Gitea Auth APIs =================
app.get('/api/gitea/auth/status', async (_req, res) => {
try {
const { getGiteaAuth, getGiteaAuthAccounts, clearGiteaAuth } = await getGiteaLibraries();
const auth = getGiteaAuth();
const accounts = getGiteaAuthAccounts();
if (!auth?.accessToken) {
return res.json({ connected: false, accounts });
}
const client = await getClient();
let user = null;
if (client) {
const resp = await client.user();
if (resp.status === 401 || resp.status === 403) {
clearGiteaAuth();
return res.json({ connected: false, accounts: getGiteaAuthAccounts() });
}
if (resp.status === 200 && resp.data) {
user = mapGiteaUser(resp.data);
}
}
return res.json({
connected: true,
...(user ? { user } : {}),
accounts,
});
} catch (error) {
console.error('Failed to get Gitea auth status:', error);
return res.status(500).json({ error: error.message || 'Failed to get Gitea auth status' });
}
});
app.post('/api/gitea/auth/connect', async (req, res) => {
try {
const accessToken = asString(req.body?.accessToken);
if (!accessToken) {
return res.status(400).json({ error: 'accessToken is required' });
}
const { normalizeBaseUrl, setGiteaAuth, getGiteaAuthAccounts } = await getGiteaLibraries();
const baseUrl = normalizeBaseUrl(req.body?.baseUrl);
if (!baseUrl) {
return res.status(400).json({ error: 'baseUrl is required and must be a valid URL' });
}
const { createGiteaClient } = await getGiteaLibraries();
const client = createGiteaClient({ token: accessToken, baseUrl });
const resp = await client.user();
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status === 401 || resp.status === 403 || resp.status >= 400 || !mapGiteaUser(resp.data)?.username) {
return res.status(400).json({ error: 'Invalid Gitea access token' });
}
setGiteaAuth({ accessToken, baseUrl, user: resp.data });
return res.json({
connected: true,
user: mapGiteaUser(resp.data),
accounts: getGiteaAuthAccounts(),
});
} catch (error) {
console.error('Failed to connect Gitea:', error);
return res.status(500).json({ error: error.message || 'Failed to connect Gitea' });
}
});
app.post('/api/gitea/auth/activate', async (req, res) => {
try {
const accountId = asString(req.body?.accountId);
if (!accountId) {
return res.status(400).json({ error: 'accountId is required' });
}
const { activateGiteaAuth, getGiteaAuth, getGiteaAuthAccounts } = await getGiteaLibraries();
const activated = activateGiteaAuth(accountId);
if (!activated) {
return res.status(404).json({ error: 'Gitea account not found' });
}
const auth = getGiteaAuth();
const accounts = getGiteaAuthAccounts();
if (!auth?.accessToken) {
return res.json({ connected: false, accounts });
}
let user = auth.username
? {
username: auth.username,
id: null,
name: auth.name,
avatarUrl: auth.avatarUrl,
webUrl: auth.webUrl,
email: auth.email,
}
: null;
const client = await getClient();
if (client) {
const resp = await client.user();
if (resp.status === 200 && resp.data) {
user = mapGiteaUser(resp.data);
}
}
return res.json({ connected: true, user, accounts });
} catch (error) {
console.error('Failed to activate Gitea account:', error);
return res.status(500).json({ error: error.message || 'Failed to activate Gitea account' });
}
});
app.delete('/api/gitea/auth', async (_req, res) => {
try {
const { clearGiteaAuth } = await getGiteaLibraries();
const removed = clearGiteaAuth();
return res.json({ removed });
} catch (error) {
console.error('Failed to disconnect Gitea:', error);
return res.status(500).json({ error: error.message || 'Failed to disconnect Gitea' });
}
});
app.get('/api/gitea/me', async (_req, res) => {
try {
const { clearGiteaAuth } = await getGiteaLibraries();
const client = await getClient();
if (!client) {
return res.status(401).json({ error: 'Gitea not connected' });
}
const resp = await client.user();
if (resp.status === 401 || resp.status === 403) {
clearGiteaAuth();
return res.status(401).json({ error: 'Gitea token expired or revoked' });
}
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status !== 200 || !resp.data) {
return res.status(500).json({ error: 'Failed to fetch Gitea user' });
}
return res.json(mapGiteaUser(resp.data));
} catch (error) {
console.error('Failed to fetch Gitea user:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea user' });
}
});
// ================= Gitea Issue APIs =================
app.get('/api/gitea/issues/list', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedRepo = getRequestedRepo(req);
if (!directory && !requestedRepo) {
return res.status(400).json({ error: 'directory or owner/repo is required' });
}
const rawPage = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
const effectivePage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;
const searchQuery = asString(req.query?.query);
const client = await getClient();
if (!client) {
return res.json({ connected: false, issues: [], page: effectivePage, hasMore: false });
}
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
if (!owner || !repo) {
return res.json({ connected: true, repo: null, issues: [], page: effectivePage, hasMore: false });
}
// `type=issues` excludes pull requests from the issue list; the client-side
// filter is a backstop for instances that ignore it.
const params = { state: 'open', type: 'issues', limit: 50, page: effectivePage };
if (searchQuery) {
params.q = searchQuery;
}
const resp = await withTimeout(client.issues(owner, repo, params), ROUTE_TIMEOUT_MS, 'gitea issues list');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'Gitea returned an error while listing issues' });
}
const issues = (Array.isArray(resp.data) ? resp.data : [])
.filter((item) => !item?.pull_request)
.map(mapGiteaIssueSummary);
return res.json({
connected: true,
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
issues,
page: effectivePage,
hasMore: Boolean(resp.page?.hasMore),
});
} catch (error) {
console.error('Failed to list Gitea issues:', error);
return res.status(500).json({ error: error.message || 'Failed to list Gitea issues' });
}
});
app.get('/api/gitea/issues/get', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedRepo = getRequestedRepo(req);
const number = getRequiredNumber(req);
if (!directory && !requestedRepo) {
return res.status(400).json({ error: 'directory or owner/repo is required' });
}
if (!number) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
if (!client) {
return res.json({ connected: false, issue: null });
}
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
if (!owner || !repo) {
return res.json({ connected: true, repo: null, issue: null });
}
const resp = await withTimeout(client.issue(owner, repo, number), ROUTE_TIMEOUT_MS, 'gitea issue get');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Issue not found' });
}
if (resp.status !== 200 || !resp.data) {
return res.status(502).json({ error: 'Gitea returned an error while fetching the issue' });
}
const item = resp.data;
const issue = {
...mapGiteaIssueSummary(item),
body: typeof item.body === 'string' ? item.body : '',
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
};
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), issue });
} catch (error) {
console.error('Failed to fetch Gitea issue:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea issue' });
}
});
app.get('/api/gitea/issues/comments', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedRepo = getRequestedRepo(req);
const number = getRequiredNumber(req);
if (!directory && !requestedRepo) {
return res.status(400).json({ error: 'directory or owner/repo is required' });
}
if (!number) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
if (!client) {
return res.json({ connected: false, comments: [] });
}
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
if (!owner || !repo) {
return res.json({ connected: true, repo: null, comments: [] });
}
const commentsResp = await withTimeout(
client.issueComments(owner, repo, number, { limit: 100 }),
ROUTE_TIMEOUT_MS,
'gitea issue comments',
);
if (commentsResp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (commentsResp.status !== 200) {
return res.status(502).json({ error: 'Gitea returned an error while fetching issue comments' });
}
const comments = (Array.isArray(commentsResp.data) ? commentsResp.data : []).map(mapGiteaComment);
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), comments });
} catch (error) {
console.error('Failed to fetch Gitea issue comments:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea issue comments' });
}
});
// ================= Gitea Pull Request APIs =================
app.get('/api/gitea/prs/list', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedRepo = getRequestedRepo(req);
if (!directory && !requestedRepo) {
return res.status(400).json({ error: 'directory or owner/repo is required' });
}
const rawPage = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
const effectivePage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;
const searchQuery = asString(req.query?.query);
const sourceBranch = asString(req.query?.sourceBranch);
const client = await getClient();
if (!client) {
return res.json({ connected: false, prs: [], page: effectivePage, hasMore: false });
}
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
if (!owner || !repo) {
return res.json({ connected: true, repo: null, prs: [], page: effectivePage, hasMore: false });
}
if (sourceBranch) {
// Gitea has no server-side source-branch filter on pulls, so fetch all
// states and filter by head.ref client-side — this returns open and
// merged PRs for the local branch so the UI can prefer the open one.
const matching = [];
let hasMore = false;
let page = 1;
for (let depth = 0; depth < PRS_MAX_PAGES; depth += 1) {
const resp = await withTimeout(
client.pullRequests(owner, repo, { state: 'all', limit: 50, page }),
ROUTE_TIMEOUT_MS,
'gitea prs list',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status !== 200 || !Array.isArray(resp.data)) {
break;
}
for (const item of resp.data) {
if (typeof item.head?.ref === 'string' && item.head.ref === sourceBranch) {
matching.push(mapGiteaPullRequestSummary(item));
}
}
hasMore = Boolean(resp.page?.hasMore);
if (!hasMore || resp.data.length === 0) {
break;
}
page += 1;
}
return res.json({
connected: true,
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
prs: matching,
page: effectivePage,
hasMore,
});
}
const params = { state: 'open', limit: 50, page: effectivePage };
if (searchQuery) {
params.q = searchQuery;
}
const resp = await withTimeout(client.pullRequests(owner, repo, params), ROUTE_TIMEOUT_MS, 'gitea prs list');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'Gitea returned an error while listing pull requests' });
}
const prs = (Array.isArray(resp.data) ? resp.data : []).map(mapGiteaPullRequestSummary);
return res.json({
connected: true,
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
prs,
page: effectivePage,
hasMore: Boolean(resp.page?.hasMore),
});
} catch (error) {
console.error('Failed to list Gitea pull requests:', error);
return res.status(500).json({ error: error.message || 'Failed to list Gitea pull requests' });
}
});
app.get('/api/gitea/pr/context', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedRepo = getRequestedRepo(req);
const number = getRequiredNumber(req);
const includeDiff = req.query?.includeDiff === '1' || req.query?.includeDiff === 'true';
if (!directory && !requestedRepo) {
return res.status(400).json({ error: 'directory or owner/repo is required' });
}
if (!number) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
if (!client) {
return res.json({ connected: false, pr: null, comments: [], files: [] });
}
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
if (!owner || !repo) {
return res.json({ connected: true, repo: null, pr: null, comments: [], files: [] });
}
const prResp = await withTimeout(client.pullRequest(owner, repo, number), ROUTE_TIMEOUT_MS, 'gitea pr context');
if (prResp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (prResp.status === 404) {
return res.status(404).json({ error: 'Pull request not found' });
}
if (prResp.status !== 200 || !prResp.data) {
return res.status(502).json({ error: 'Gitea returned an error while fetching the pull request' });
}
const pr = mapGiteaPullRequest(prResp.data);
// PR comments live on the issue comments endpoint in Gitea.
const commentsResp = await withTimeout(
client.issueComments(owner, repo, number, { limit: 100 }),
ROUTE_TIMEOUT_MS,
'gitea pr context comments',
);
if (commentsResp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
const comments = (commentsResp.status === 200 && Array.isArray(commentsResp.data) ? commentsResp.data : [])
.map(mapGiteaComment);
// Per-file patches. Older Gitea instances 404 on this endpoint; fall back
// to an empty list rather than failing the whole context.
const filesResp = await withTimeout(
client.pullRequestFiles(owner, repo, number, { patch: 'true' }),
ROUTE_TIMEOUT_MS,
'gitea pr context files',
);
if (filesResp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
const files = (filesResp.status === 200 && Array.isArray(filesResp.data) ? filesResp.data : [])
.map(mapGiteaFile);
let diff;
if (includeDiff) {
// Raw unified diff; fall back to concatenated per-file patches.
const diffResp = await withTimeout(client.pullRequestDiff(owner, repo, number), ROUTE_TIMEOUT_MS, 'gitea pr context diff');
if (diffResp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (diffResp.status === 200 && typeof diffResp.data === 'string' && diffResp.data.trim()) {
diff = diffResp.data;
} else {
const patches = files.map((file) => file.patch || '').filter(Boolean);
if (patches.length > 0) {
diff = patches.join('\n');
}
}
}
return res.json({
connected: true,
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
pr,
comments,
files,
...(diff ? { diff } : {}),
});
} catch (error) {
console.error('Failed to load Gitea pull request context:', error);
return res.status(500).json({ error: error.message || 'Failed to load Gitea pull request context' });
}
});
// ================= Gitea Pull Request Write APIs =================
app.post('/api/gitea/pr/create', async (req, res) => {
try {
const directory = asString(req.body?.directory);
const title = asString(req.body?.title);
const sourceBranch = asString(req.body?.sourceBranch);
const targetBranch = asString(req.body?.targetBranch);
if (!directory || !title || !sourceBranch || !targetBranch) {
return res.status(400).json({ error: 'directory, title, sourceBranch, targetBranch are required' });
}
const description = typeof req.body?.description === 'string' && req.body.description
? req.body.description
: undefined;
const client = await getClient();
if (!client) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
if (!owner || !repo) {
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
}
const body = {
title,
head: sourceBranch,
base: targetBranch,
};
if (description !== undefined) {
body.body = description;
}
const resp = await withTimeout(client.createPullRequest(owner, repo, body), ROUTE_TIMEOUT_MS, 'gitea pr create');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to create pull requests' });
}
if (resp.status !== 200 && resp.status !== 201) {
const status = resp.status >= 500 ? 500 : 400;
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while creating the pull request' });
}
if (!resp.data) {
return res.status(500).json({ error: 'Gitea returned an empty response while creating the pull request' });
}
return res.json({
connected: true,
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
pr: mapGiteaPullRequest(resp.data),
});
} catch (error) {
console.error('Failed to create Gitea pull request:', error);
return res.status(500).json({ error: error.message || 'Failed to create Gitea pull request' });
}
});
app.patch('/api/gitea/pr/update', async (req, res) => {
try {
const directory = asString(req.body?.directory);
const number = typeof req.body?.number === 'number' ? req.body.number : null;
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const title = asString(req.body?.title);
const description = typeof req.body?.description === 'string' ? req.body.description : undefined;
const client = await getClient();
if (!client) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
if (!owner || !repo) {
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
}
const body = {};
if (title) {
body.title = title;
}
if (description !== undefined) {
body.body = description;
}
const resp = await withTimeout(client.updatePullRequest(owner, repo, number, body), ROUTE_TIMEOUT_MS, 'gitea pr update');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to update pull requests' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Pull request not found' });
}
if (resp.status !== 200 && resp.status !== 201) {
const status = resp.status >= 500 ? 500 : 400;
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while updating the pull request' });
}
if (!resp.data) {
return res.status(500).json({ error: 'Gitea returned an empty response while updating the pull request' });
}
return res.json({
connected: true,
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
pr: mapGiteaPullRequest(resp.data),
});
} catch (error) {
console.error('Failed to update Gitea pull request:', error);
return res.status(500).json({ error: error.message || 'Failed to update Gitea pull request' });
}
});
app.post('/api/gitea/pr/merge', async (req, res) => {
try {
const directory = asString(req.body?.directory);
const number = typeof req.body?.number === 'number' ? req.body.number : null;
if (!directory || !number) {
return res.status(400).json({ error: 'directory and number are required' });
}
const method = ['merge', 'squash', 'rebase'].includes(req.body?.method) ? req.body.method : 'merge';
const client = await getClient();
if (!client) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const { owner, repo } = await resolveRepoForRequest(directory, requestedRepo);
if (!owner || !repo) {
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
}
const body = { Do: true, MergeMethod: method };
const resp = await withTimeout(client.mergePullRequest(owner, repo, number, body), ROUTE_TIMEOUT_MS, 'gitea pr merge');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to merge pull requests' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Pull request not found' });
}
// Gitea rejects non-mergeable requests with 405/409/422 and a `message`
// in the body — surface it as a merge rejection (mirrors the GitHub
// pr/merge contract) instead of a generic error.
if (resp.status === 405 || resp.status === 409 || resp.status === 422) {
return res.status(resp.status).json({
connected: true,
merged: false,
message: giteaErrorMessage(resp.data) || 'Pull request not mergeable',
});
}
if (resp.status !== 200 && resp.status !== 201) {
const status = resp.status >= 500 ? 500 : 400;
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while merging the pull request' });
}
return res.json({ connected: true, merged: true });
} catch (error) {
console.error('Failed to merge Gitea pull request:', error);
return res.status(500).json({ error: error.message || 'Failed to merge Gitea pull request' });
}
});
// ================= Gitea Repo APIs =================
app.get('/api/gitea/repo/branches', async (req, res) => {
try {
const owner = asString(req.query?.owner);
const repo = asString(req.query?.repo);
if (!owner || !repo) {
return res.status(400).json({ error: 'owner and repo are required' });
}
const client = await getClient();
if (!client) {
return res.json({ branches: [], defaultBranch: null });
}
const branches = [];
let page = 1;
while (page <= 10) {
const resp = await withTimeout(client.branches(owner, repo, { limit: 50, page }), ROUTE_TIMEOUT_MS, 'gitea repo branches');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status !== 200 || !Array.isArray(resp.data)) {
break;
}
const chunk = resp.data;
for (const branch of chunk) {
if (typeof branch?.name === 'string') {
branches.push(branch.name);
}
}
if (chunk.length < 50 || !resp.page?.hasMore) {
break;
}
page += 1;
}
// Gitea branch objects carry no default flag; read `default_branch` from
// the repo object instead.
let defaultBranch = null;
const repoResp = await withTimeout(client.repo(owner, repo), ROUTE_TIMEOUT_MS, 'gitea repo');
if (repoResp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (repoResp.status === 200 && repoResp.data) {
defaultBranch = typeof repoResp.data.default_branch === 'string' ? repoResp.data.default_branch : null;
}
return res.json({ branches, defaultBranch });
} catch (error) {
console.error('Failed to fetch Gitea repo branches:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea repo branches' });
}
});
}
@@ -0,0 +1,932 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitea-routes-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
// Resolve a fake git remote so directory-based repo resolution finds a Gitea
// repo without touching the real filesystem/git.
vi.mock('../git/index.js', () => ({
getRemoteUrl: vi.fn(async () => 'git@gitea.example.com:owner/repo.git'),
}));
const { registerGiteaRoutes } = await import('./routes.js');
const { setGiteaAuth, clearGiteaAuth, getGiteaAuth, getGiteaAuthAccounts, GITEA_AUTH_FILE } = await import('./index.js');
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
// clearGiteaAuth only drops the *current* account (multi-account model), so a
// full wipe is done by removing the auth file between tests.
const resetAuthFile = () => {
if (fs.existsSync(GITEA_AUTH_FILE)) {
fs.unlinkSync(GITEA_AUTH_FILE);
}
};
const aliceUser = {
id: 42,
login: 'alice',
full_name: 'Alice Example',
avatar_url: 'https://gitea.example.com/avatars/alice.png',
html_url: 'https://gitea.example.com/alice',
email: 'alice@example.com',
};
const jsonResponse = (data, { status = 200, headers = {} } = {}) =>
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', ...headers } });
const scriptedFetch = (handlers) => {
const fetchMock = vi.fn(async (url, options) => {
const str = String(url);
for (const handler of handlers) {
const result = handler(str, options);
if (result !== null && result !== undefined) {
return result;
}
}
return jsonResponse({ message: `unhandled request: ${str}` }, { status: 500 });
});
globalThis.fetch = fetchMock;
return fetchMock;
};
const matches = (pattern) => (url) => pattern.test(url);
const createApp = () => {
const app = express();
app.use(express.json());
registerGiteaRoutes(app);
return app;
};
describe('Gitea auth routes', () => {
beforeEach(() => {
resetAuthFile();
vi.restoreAllMocks();
delete globalThis.fetch;
});
test('auth/status returns disconnected with no default base URL', async () => {
const app = createApp();
const response = await request(app).get('/api/gitea/auth/status');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: false,
accounts: [],
});
expect(response.body.defaultBaseUrl).toBeUndefined();
});
test('auth/connect validates the token, stores the account, and reports connected', async () => {
scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/auth/connect')
.send({ accessToken: 'gitea-valid', baseUrl: 'https://gitea.example.com' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
user: { username: 'alice', id: 42, name: 'Alice Example', avatarUrl: 'https://gitea.example.com/avatars/alice.png', webUrl: 'https://gitea.example.com/alice', email: 'alice@example.com' },
});
expect(response.body.accounts).toEqual([
{ id: 'gitea.example.com:alice', user: { username: 'alice', name: 'Alice Example', avatarUrl: 'https://gitea.example.com/avatars/alice.png', webUrl: 'https://gitea.example.com/alice' }, baseUrl: 'https://gitea.example.com', current: true },
]);
expect(getGiteaAuth()?.accessToken).toBe('gitea-valid');
});
test('auth/connect rejects an invalid token with 400', async () => {
scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse({ message: '401 Unauthorized' }, { status: 401 }) : null)]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/auth/connect')
.send({ accessToken: 'gitea-invalid', baseUrl: 'https://gitea.example.com' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Invalid Gitea access token' });
expect(getGiteaAuth()).toBeNull();
});
test('auth/connect requires an access token', async () => {
const app = createApp();
const response = await request(app)
.post('/api/gitea/auth/connect')
.send({ baseUrl: 'https://gitea.example.com' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'accessToken is required' });
});
test('auth/connect requires a base URL (no default instance)', async () => {
const app = createApp();
const response = await request(app).post('/api/gitea/auth/connect').send({ accessToken: 'gitea-valid' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'baseUrl is required and must be a valid URL' });
});
test('auth/connect normalizes a scheme-less base URL', async () => {
const fetchMock = scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
await request(app)
.post('/api/gitea/auth/connect')
.send({ accessToken: 'gitea-valid', baseUrl: 'gitea.example.com' });
expect(fetchMock.mock.calls[0][0]).toBe('https://gitea.example.com/api/v1/user');
});
test('auth/status reports connected with the live user', async () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app).get('/api/gitea/auth/status');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
user: { username: 'alice', id: 42 },
});
expect(response.body.accounts).toEqual([
{ id: 'gitea.example.com:alice', user: { username: 'alice', name: 'Alice Example', avatarUrl: 'https://gitea.example.com/avatars/alice.png', webUrl: 'https://gitea.example.com/alice' }, baseUrl: 'https://gitea.example.com', current: true },
]);
});
test('auth/activate returns 404 for an unknown account', async () => {
const app = createApp();
const response = await request(app).post('/api/gitea/auth/activate').send({ accountId: 'gitea.example.com:nobody' });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Gitea account not found' });
});
test('auth/activate switches the current account', async () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
setGiteaAuth({
accessToken: 'gitea-b',
baseUrl: 'https://gitea.other.example',
user: { ...aliceUser, login: 'bob', full_name: 'Bob' },
});
scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app).post('/api/gitea/auth/activate').send({ accountId: 'gitea.example.com:alice' });
expect(response.status).toBe(200);
expect(response.body.connected).toBe(true);
expect(response.body.accounts.find((a) => a.id === 'gitea.example.com:alice')?.current).toBe(true);
expect(getGiteaAuth()?.accountId).toBe('gitea.example.com:alice');
});
test('DELETE /api/gitea/auth clears the account', async () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
const app = createApp();
const response = await request(app).delete('/api/gitea/auth');
expect(response.status).toBe(200);
expect(response.body).toEqual({ removed: true });
expect(getGiteaAuth()).toBeNull();
});
test('me returns 401 when not connected', async () => {
const app = createApp();
const response = await request(app).get('/api/gitea/me');
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'Gitea not connected' });
});
test('me returns the connected user', async () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app).get('/api/gitea/me');
expect(response.status).toBe(200);
expect(response.body).toEqual({
username: 'alice',
id: 42,
name: 'Alice Example',
avatarUrl: 'https://gitea.example.com/avatars/alice.png',
webUrl: 'https://gitea.example.com/alice',
email: 'alice@example.com',
});
});
});
describe('Gitea data routes', () => {
beforeEach(() => {
resetAuthFile();
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
vi.restoreAllMocks();
delete globalThis.fetch;
});
test('issues/list returns mapped issues with pagination info', async () => {
scriptedFetch([
(url) => (matches(/\/api\/v1\/repos\/owner\/repo\/issues\?/)(url)
? jsonResponse(
[
{
number: 3,
title: 'Fix the widget',
html_url: 'https://gitea.example.com/owner/repo/issues/3',
state: 'open',
user: { id: 42, login: 'alice', full_name: 'Alice Example', avatar_url: 'https://gitea.example.com/alice.png' },
labels: [{ id: 1, name: 'bug' }, { id: 2, name: 'priority:high' }],
},
],
{ headers: { link: '<https://gitea.example.com/api/v1/repos/owner/repo/issues?page=2>; rel="next"' } },
)
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/issues/list?directory=%2Ftmp%2Fwork&page=1');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com', url: 'https://gitea.example.com/owner/repo' },
issues: [
{
number: 3,
title: 'Fix the widget',
url: 'https://gitea.example.com/owner/repo/issues/3',
state: 'open',
author: { username: 'alice', id: 42 },
labels: ['bug', 'priority:high'],
},
],
page: 1,
hasMore: true,
});
});
test('issues/list sends the search query, open state, and type=issues', async () => {
const fetchMock = scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse([]) : null)]);
const app = createApp();
await request(app).get('/api/gitea/issues/list?directory=%2Ftmp%2Fwork&query=login');
const requestedUrl = String(fetchMock.mock.calls[0][0]);
expect(requestedUrl).toContain('state=open');
expect(requestedUrl).toContain('type=issues');
expect(requestedUrl).toContain('q=login');
expect(requestedUrl).toContain('limit=50');
});
test('issues/list skips entries carrying a pull_request field', async () => {
scriptedFetch([
(url) => (matches(/\/issues\?/)(url)
? jsonResponse([
{ number: 1, title: 'An issue', html_url: 'u', state: 'open', user: { login: 'alice' }, labels: [] },
{ number: 2, title: 'A pull request', html_url: 'u', state: 'open', user: { login: 'alice' }, labels: [], pull_request: { number: 2 } },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/issues/list?directory=%2Ftmp%2Fwork');
expect(response.status).toBe(200);
expect(response.body.issues).toHaveLength(1);
expect(response.body.issues[0].number).toBe(1);
});
test('issues/list honors owner/repo query params as an override', async () => {
const fetchMock = scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse([]) : null)]);
const app = createApp();
await request(app).get('/api/gitea/issues/list?directory=%2Ftmp%2Fwork&owner=acme&repo=widgets');
const requestedUrl = String(fetchMock.mock.calls[0][0]);
expect(requestedUrl).toContain('/repos/acme/widgets/issues');
});
test('issues/list reports connected:false when not authenticated', async () => {
clearGiteaAuth();
const app = createApp();
const response = await request(app).get('/api/gitea/issues/list?directory=%2Ftmp%2Fwork');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: false, issues: [] });
});
test('issues/get returns a full issue', async () => {
scriptedFetch([
(url) => (matches(/\/issues\/7$/)(url)
? jsonResponse({
number: 7,
title: 'Broken import',
html_url: 'https://gitea.example.com/owner/repo/issues/7',
state: 'open',
body: 'It breaks at startup',
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-02T10:00:00Z',
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
labels: [{ id: 1, name: 'bug' }],
})
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/issues/get?directory=%2Ftmp%2Fwork&number=7');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
issue: {
number: 7,
title: 'Broken import',
state: 'open',
body: 'It breaks at startup',
createdAt: '2026-01-01T10:00:00Z',
updatedAt: '2026-01-02T10:00:00Z',
author: { username: 'alice', id: 42 },
labels: ['bug'],
},
});
});
test('issues/get returns 404 for a missing issue', async () => {
scriptedFetch([(url) => (matches(/\/issues\/999$/)(url) ? jsonResponse({ message: 'Not found' }, { status: 404 }) : null)]);
const app = createApp();
const response = await request(app).get('/api/gitea/issues/get?directory=%2Ftmp%2Fwork&number=999');
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Issue not found' });
});
test('issues/comments maps Gitea comments', async () => {
scriptedFetch([
(url) => (matches(/\/issues\/7\/comments\?/)(url)
? jsonResponse([
{
id: 2,
html_url: 'https://gitea.example.com/owner/repo/issues/7#issuecomment-2',
body: 'Looks good to me',
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
created_at: '2026-01-01T01:00:00Z',
},
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/issues/comments?directory=%2Ftmp%2Fwork&number=7');
expect(response.status).toBe(200);
expect(response.body.comments).toEqual([
{
id: 2,
body: 'Looks good to me',
url: 'https://gitea.example.com/owner/repo/issues/7#issuecomment-2',
author: { username: 'alice', id: 42 },
createdAt: '2026-01-01T01:00:00Z',
},
]);
});
test('prs/list returns mapped pull requests with open state by default', async () => {
scriptedFetch([
(url) => (matches(/\/pulls\?/)(url)
? jsonResponse([
{
number: 9,
title: 'Add the API',
html_url: 'https://gitea.example.com/owner/repo/pulls/9',
state: 'open',
merged: false,
draft: false,
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
labels: [{ id: 1, name: 'feature' }],
head: { ref: 'feat/api' },
base: { ref: 'main' },
},
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/prs/list?directory=%2Ftmp%2Fwork');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
prs: [
{
number: 9,
title: 'Add the API',
state: 'open',
draft: false,
author: { username: 'alice', id: 42 },
labels: ['feature'],
sourceBranch: 'feat/api',
targetBranch: 'main',
},
],
page: 1,
hasMore: false,
});
});
test('prs/list maps merged and closed states correctly', async () => {
scriptedFetch([
(url) => (matches(/\/pulls\?/)(url)
? jsonResponse([
{ number: 1, title: 'Merged PR', html_url: 'u', state: 'closed', merged: true, draft: false, user: { login: 'alice' }, labels: [], head: { ref: 'a' }, base: { ref: 'main' } },
{ number: 2, title: 'Closed PR', html_url: 'u', state: 'closed', merged: false, draft: false, user: { login: 'alice' }, labels: [], head: { ref: 'b' }, base: { ref: 'main' } },
{ number: 3, title: 'Open PR', html_url: 'u', state: 'open', merged: false, draft: true, user: { login: 'alice' }, labels: [], head: { ref: 'c' }, base: { ref: 'main' } },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/prs/list?directory=%2Ftmp%2Fwork');
const prs = response.body.prs;
expect(prs[0]).toMatchObject({ number: 1, state: 'merged' });
expect(prs[1]).toMatchObject({ number: 2, state: 'closed' });
expect(prs[2]).toMatchObject({ number: 3, state: 'open', draft: true });
});
test('prs/list with sourceBranch requests all states and filters by head.ref', async () => {
const fetchMock = scriptedFetch([
(url) => (matches(/\/pulls\?/)(url)
? jsonResponse([
{
number: 5,
title: 'Open PR for feat/api',
html_url: 'u',
state: 'open',
merged: false,
draft: false,
user: { login: 'alice' },
labels: [],
head: { ref: 'feat/api' },
base: { ref: 'main' },
},
{
number: 6,
title: 'Merged PR for feat/api',
html_url: 'u',
state: 'closed',
merged: true,
draft: false,
user: { login: 'alice' },
labels: [],
head: { ref: 'feat/api' },
base: { ref: 'main' },
},
{
number: 7,
title: 'PR for another branch',
html_url: 'u',
state: 'open',
merged: false,
draft: false,
user: { login: 'alice' },
labels: [],
head: { ref: 'feat/other' },
base: { ref: 'main' },
},
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/prs/list?directory=%2Ftmp%2Fwork&sourceBranch=feat%2Fapi');
expect(response.status).toBe(200);
const requestedUrl = String(fetchMock.mock.calls[0][0]);
expect(requestedUrl).toContain('state=all');
const prs = response.body.prs;
expect(prs).toHaveLength(2);
expect(prs.find((pr) => pr.number === 5)).toMatchObject({ state: 'open', sourceBranch: 'feat/api' });
expect(prs.find((pr) => pr.number === 6)).toMatchObject({ state: 'merged', sourceBranch: 'feat/api' });
});
test('prs/list omits the source branch filter when not provided', async () => {
const fetchMock = scriptedFetch([(url) => (matches(/\/pulls\?/)(url) ? jsonResponse([]) : null)]);
const app = createApp();
await request(app).get('/api/gitea/prs/list?directory=%2Ftmp%2Fwork');
const requestedUrl = String(fetchMock.mock.calls[0][0]);
expect(requestedUrl).not.toContain('state=all');
expect(requestedUrl).toContain('state=open');
});
test('pr/context returns pr, comments, files, and a raw diff', async () => {
scriptedFetch([
(url) => (matches(/\/pulls\/9$/)(url)
? jsonResponse({
number: 9,
title: 'Add the API',
html_url: 'https://gitea.example.com/owner/repo/pulls/9',
state: 'open',
merged: false,
draft: false,
body: 'Adds the public API',
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-02T10:00:00Z',
mergeable: true,
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
labels: [{ id: 1, name: 'feature' }],
head: { ref: 'feat/api' },
base: { ref: 'main' },
})
: null),
(url) => (matches(/\/issues\/9\/comments\?/)(url)
? jsonResponse([
{ id: 11, html_url: 'u', body: 'LGTM', user: { id: 43, login: 'bob', full_name: 'Bob' }, created_at: '2026-01-02T11:00:00Z' },
])
: null),
(url) => (matches(/\/pulls\/9\/files\?/)(url)
? jsonResponse([
{
Filename: 'src/a.ts',
Status: 'modified',
Additions: 1,
Deletions: 1,
Patch: '--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1,3 +1,4 @@\n+export const added = 1\n-export const old = 2\n',
},
{
Filename: 'src/new.ts',
Status: 'added',
Additions: 2,
Deletions: 0,
Patch: '--- a/src/new.ts\n+++ b/src/new.ts\n@@ -0,0 +1,2 @@\n+line one\n+line two\n',
},
])
: null),
(url) => (matches(/\/pulls\/9\.diff$/)(url)
? new Response('diff --git a/src/a.ts b/src/a.ts\n@@ -1,3 +1,4 @@\n+export const added = 1\n', { status: 200 })
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/pr/context?directory=%2Ftmp%2Fwork&number=9&includeDiff=1');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
pr: {
number: 9,
title: 'Add the API',
state: 'open',
draft: false,
body: 'Adds the public API',
mergeable: true,
merged: false,
sourceBranch: 'feat/api',
targetBranch: 'main',
},
comments: [{ id: 11, body: 'LGTM', author: { username: 'bob', id: 43 } }],
files: [
{ filename: 'src/a.ts', status: 'modified', additions: 1, deletions: 1 },
{ filename: 'src/new.ts', status: 'added', additions: 2, deletions: 0 },
],
});
expect(response.body.diff).toContain('export const added = 1');
});
test('pr/context falls back to empty files when the files endpoint 404s', async () => {
scriptedFetch([
(url) => (matches(/\/pulls\/9$/)(url)
? jsonResponse({ number: 9, title: 'T', html_url: 'u', state: 'open', merged: false, draft: false, user: { login: 'alice' }, head: { ref: 'a' }, base: { ref: 'main' } })
: null),
(url) => (matches(/\/issues\/9\/comments\?/)(url) ? jsonResponse([]) : null),
(url) => (matches(/\/pulls\/9\/files\?/)(url) ? jsonResponse({ message: 'Not Found' }, { status: 404 }) : null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/pr/context?directory=%2Ftmp%2Fwork&number=9');
expect(response.status).toBe(200);
expect(response.body.files).toEqual([]);
});
test('repo/branches returns branch names and the default branch from the repo', async () => {
scriptedFetch([
(url) => (matches(/\/repos\/owner\/repo\/branches\?/)(url)
? jsonResponse([{ name: 'main' }, { name: 'feat/api' }])
: null),
(url) => (matches(/\/api\/v1\/repos\/owner\/repo$/)(url)
? jsonResponse({ id: 1, full_name: 'owner/repo', default_branch: 'main' })
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/repo/branches?owner=owner&repo=repo');
expect(response.status).toBe(200);
expect(response.body).toEqual({ branches: ['main', 'feat/api'], defaultBranch: 'main' });
});
test('repo/branches returns null defaultBranch when disconnected', async () => {
clearGiteaAuth();
const app = createApp();
const response = await request(app).get('/api/gitea/repo/branches?owner=owner&repo=repo');
expect(response.status).toBe(200);
expect(response.body).toEqual({ branches: [], defaultBranch: null });
});
test('repo/branches requires owner and repo', async () => {
const app = createApp();
const response = await request(app).get('/api/gitea/repo/branches?owner=owner');
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'owner and repo are required' });
});
test('pr/create POSTs title/head/base/body and returns the created PR', async () => {
const createdPr = {
number: 12,
title: 'Add feature',
html_url: 'https://gitea.example.com/owner/repo/pulls/12',
state: 'open',
merged: false,
draft: false,
body: 'Adds the feature',
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
head: { ref: 'feat/add' },
base: { ref: 'main' },
};
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/pulls$/)(url) && options.method === 'POST') {
return jsonResponse(createdPr, { status: 201 });
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/pr/create')
.send({
directory: '/tmp/work',
title: 'Add feature',
sourceBranch: 'feat/add',
targetBranch: 'main',
description: 'Adds the feature',
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' },
pr: {
number: 12,
title: 'Add feature',
url: 'https://gitea.example.com/owner/repo/pulls/12',
state: 'open',
draft: false,
author: { username: 'alice', id: 42 },
sourceBranch: 'feat/add',
targetBranch: 'main',
},
});
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({
title: 'Add feature',
head: 'feat/add',
base: 'main',
body: 'Adds the feature',
});
});
test('pr/create omits body when no description is given', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/pulls$/)(url) && options.method === 'POST') {
return jsonResponse(
{ number: 1, title: 'T', html_url: 'u', state: 'open', merged: false, draft: false, user: { login: 'alice' }, head: { ref: 's' }, base: { ref: 'm' } },
{ status: 201 },
);
}
return null;
},
]);
const app = createApp();
await request(app)
.post('/api/gitea/pr/create')
.send({ directory: '/tmp/work', title: 'T', sourceBranch: 's', targetBranch: 'm' });
const [, options] = fetchMock.mock.calls[0];
const body = JSON.parse(options.body);
expect(body).toEqual({ title: 'T', head: 's', base: 'm' });
expect(body.body).toBeUndefined();
});
test('pr/create rejects missing fields with 400', async () => {
const app = createApp();
const response = await request(app)
.post('/api/gitea/pr/create')
.send({ directory: '/tmp/work', title: 'Add feature' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory, title, sourceBranch, targetBranch are required' });
});
test('pr/create reports connected:false when not authenticated', async () => {
clearGiteaAuth();
const app = createApp();
const response = await request(app)
.post('/api/gitea/pr/create')
.send({ directory: '/tmp/work', title: 'Add feature', sourceBranch: 'feat/add', targetBranch: 'main' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
});
test('pr/create surfaces a 403 as a scope error', async () => {
scriptedFetch([
(url, options) => {
if (matches(/\/pulls$/)(url) && options.method === 'POST') {
return jsonResponse({ message: '403 Forbidden' }, { status: 403 });
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/pr/create')
.send({ directory: '/tmp/work', title: 'Add feature', sourceBranch: 'feat/add', targetBranch: 'main' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Your Gitea token needs write:repository scope to create pull requests' });
});
test('pr/update PATCHes title/body and returns the updated PR', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/pulls\/12$/)(url) && options.method === 'PATCH') {
return jsonResponse({
number: 12,
title: 'Updated title',
html_url: 'u',
state: 'open',
merged: false,
draft: false,
user: { id: 42, login: 'alice' },
head: { ref: 'feat/add' },
base: { ref: 'main' },
});
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.patch('/api/gitea/pr/update')
.send({ directory: '/tmp/work', number: 12, title: 'Updated title', description: 'New body' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
pr: { number: 12, title: 'Updated title', state: 'open', sourceBranch: 'feat/add', targetBranch: 'main' },
});
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('PATCH');
expect(JSON.parse(options.body)).toEqual({ title: 'Updated title', body: 'New body' });
});
test('pr/update omits title/body when not provided', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/pulls\/12$/)(url) && options.method === 'PATCH') {
return jsonResponse({
number: 12,
title: 'T',
html_url: 'u',
state: 'open',
merged: false,
draft: false,
user: { login: 'alice' },
head: { ref: 's' },
base: { ref: 'm' },
});
}
return null;
},
]);
const app = createApp();
await request(app).patch('/api/gitea/pr/update').send({ directory: '/tmp/work', number: 12 });
const [, options] = fetchMock.mock.calls[0];
expect(JSON.parse(options.body)).toEqual({});
});
test('pr/update returns 404 for a missing pull request', async () => {
scriptedFetch([
(url, options) => {
if (matches(/\/pulls\/999$/)(url) && options.method === 'PATCH') {
return jsonResponse({ message: '404 Not Found' }, { status: 404 });
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.patch('/api/gitea/pr/update')
.send({ directory: '/tmp/work', number: 999, title: 'x' });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Pull request not found' });
});
test('pr/merge POSTs Do/MergeMethod and reports merged:true', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/pulls\/12\/merge$/)(url) && options.method === 'POST') {
return jsonResponse({ merged: true, message: 'pull request was merged' });
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/pr/merge')
.send({ directory: '/tmp/work', number: 12 });
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: true, merged: true });
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ Do: true, MergeMethod: 'merge' });
});
test('pr/merge maps the method to MergeMethod', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/pulls\/12\/merge$/)(url) && options.method === 'POST') {
return jsonResponse({ merged: true });
}
return null;
},
]);
const app = createApp();
await request(app)
.post('/api/gitea/pr/merge')
.send({ directory: '/tmp/work', number: 12, method: 'squash' });
const [, options] = fetchMock.mock.calls[0];
expect(JSON.parse(options.body)).toEqual({ Do: true, MergeMethod: 'squash' });
});
test('pr/merge passes through a Gitea merge rejection as merged:false', async () => {
scriptedFetch([
(url, options) => {
if (matches(/\/pulls\/12\/merge$/)(url) && options.method === 'POST') {
return jsonResponse({ message: 'This PR is already merged' }, { status: 409 });
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/pr/merge')
.send({ directory: '/tmp/work', number: 12 });
expect(response.status).toBe(409);
expect(response.body).toEqual({
connected: true,
merged: false,
message: 'This PR is already merged',
});
});
test('pr/merge surfaces a 403 as a scope error', async () => {
scriptedFetch([
(url, options) => {
if (matches(/\/pulls\/12\/merge$/)(url) && options.method === 'POST') {
return jsonResponse({ message: '403 Forbidden' }, { status: 403 });
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/pr/merge')
.send({ directory: '/tmp/work', number: 12 });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Your Gitea token needs write:repository scope to merge pull requests' });
});
test('data routes surface a 503 when Gitea rate limits', async () => {
scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse({}, { status: 429, headers: { 'retry-after': '30' } }) : null)]);
const app = createApp();
const response = await request(app).get('/api/gitea/issues/list?directory=%2Ftmp%2Fwork');
expect(response.status).toBe(503);
expect(response.body).toEqual({ error: 'Gitea rate limited' });
});
});
@@ -5,6 +5,7 @@ import { registerWalkthroughRoutes } from '../walkthrough/routes.js';
import { registerSessionGoalRoutes } from '../session-goal/routes.js';
import { registerGitHubRoutes } from '../github/routes.js';
import { registerGitLabRoutes } from '../gitlab/routes.js';
import { registerGiteaRoutes } from '../gitea/routes.js';
import { registerGitRoutes } from '../git/routes.js';
import { registerDevServerRoutes } from '../dev-servers/routes.js';
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
@@ -299,6 +300,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
registerSessionGoalRoutes(app);
registerGitHubRoutes(app);
registerGitLabRoutes(app);
registerGiteaRoutes(app);
registerGitRoutes(app);
registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts });
registerMagicPromptRoutes(app, {