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:
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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?:\/\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user