feat(gitea): add Gitea/Forgejo as a git provider

Full parity with the existing GitLab provider:
- Server module packages/web/server/lib/gitea (auth/client/repo/routes + docs + tests)
  with Gitea REST v1 API, PAT + base URL auth, multi-account storage
- Shared GiteaAPI types and web API client
- Provider detection generalized with user-configurable custom domains
  per provider (github/gitlab/gitea), additive with built-in defaults
  (github.com, gitlab.com) and connected-account hosts; precedence
  github -> gitlab -> gitea
- Gitea PR view, issues section, pickers, integration dialog, branch
  PR status helper, settings UI (PAT + base URL + custom domains)
- Magic prompts (gitea.pr.review, gitea.issue.review) and full 11-locale
  i18n parity
This commit is contained in:
2026-08-16 16:27:49 +00:00
parent be13272eb1
commit ca91fd7e2d
65 changed files with 10667 additions and 101 deletions
+309
View File
@@ -0,0 +1,309 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { RuntimeUrlQuery, RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
const runtimeFetchMock = vi.fn();
vi.mock('@openchamber/ui/lib/runtime-fetch', () => ({
runtimeFetch: runtimeFetchMock,
}));
const toUrl = (path: string, query?: RuntimeUrlQuery): string => {
const params = query instanceof URLSearchParams ? query : new URLSearchParams();
const queryString = params.toString();
return queryString ? `${path}?${queryString}` : path;
};
const urls: RuntimeUrlResolver = {
api: toUrl,
authenticatedAsset: toUrl,
auth: toUrl,
health: (query?: RuntimeUrlQuery) => toUrl('/health', query),
rawFile: (path: string) => toUrl('/api/fs/raw', new URLSearchParams({ path })),
sse: toUrl,
websocket: toUrl,
};
const createAPI = async () => {
const { createWebGiteaAPI } = await import('./gitea');
return createWebGiteaAPI({ urls });
};
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
runtimeFetchMock.mockReset();
});
describe('createWebGiteaAPI', () => {
it('parses auth status payloads', async () => {
const status = {
connected: true,
user: { username: 'octocat', id: 1, name: 'Octo Cat' },
accounts: [],
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(status));
const api = await createAPI();
await expect(api.authStatus()).resolves.toEqual(status);
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/auth/status', {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('sends the access token and base URL when connecting', async () => {
const status = {
connected: true,
user: { username: 'octocat', id: 1 },
accounts: [],
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(status));
const api = await createAPI();
await expect(api.authConnect({ accessToken: 'gitea-123', baseUrl: 'https://gitea.example' })).resolves.toEqual(status);
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/auth/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ accessToken: 'gitea-123', baseUrl: 'https://gitea.example' }),
});
});
it('passes directory, number, owner and repo query params to issueGet', async () => {
const result = {
connected: true,
repo: null,
issue: { number: 42, title: 'Broken build', url: 'https://gitea.example/o/r/issues/42', state: 'open', author: { username: 'octocat', id: 1 }, labels: [] },
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.issueGet('/workspace', 42, { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', number: '42', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/issues/get?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes includeDiff and repo params to prContext', async () => {
const result = { connected: true, repo: null, pr: undefined };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.prContext('/workspace', 7, { includeDiff: true, owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', number: '7', includeDiff: '1', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/pr/context?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes sourceBranch query param to prsList', async () => {
const result = {
connected: true,
repo: null,
prs: [],
page: 1,
hasMore: false,
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.prsList('/workspace', { page: 1, query: 'search', sourceBranch: 'feat/api' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', page: '1', query: 'search', sourceBranch: 'feat/api' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/prs/list?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('omits sourceBranch when not provided to prsList', async () => {
const result = {
connected: true,
repo: null,
prs: [],
page: 1,
hasMore: false,
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.prsList('/workspace', { page: 1 })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', page: '1' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/prs/list?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('posts to /api/gitea/pr/create with the input body and returns the created PR', async () => {
const created = {
connected: true,
repo: null,
pr: {
number: 12,
title: 'Add feature',
url: 'https://gitea.example/owner/repo/pulls/12',
state: 'open',
draft: false,
author: { username: 'octocat', id: 1 },
labels: [],
sourceBranch: 'feat/add',
targetBranch: 'main',
},
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(created));
const api = await createAPI();
await expect(api.prCreate({
directory: '/workspace',
title: 'Add feature',
sourceBranch: 'feat/add',
targetBranch: 'main',
})).resolves.toEqual(created.pr);
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/pr/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
directory: '/workspace',
title: 'Add feature',
sourceBranch: 'feat/add',
targetBranch: 'main',
}),
});
});
it('throws the server error when prCreate fails', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json(
{ error: 'Your Gitea token needs write:repository scope to create pull requests' },
{ status: 400 },
));
const api = await createAPI();
await expect(api.prCreate({
directory: '/workspace',
title: 'Add feature',
sourceBranch: 'feat/add',
targetBranch: 'main',
})).rejects.toThrow('Your Gitea token needs write:repository scope to create pull requests');
});
it('PATCHes to /api/gitea/pr/update with the input body and returns the updated PR', async () => {
const updated = {
connected: true,
repo: null,
pr: { number: 12, title: 'Renamed', url: 'u', state: 'open', draft: false, author: { username: 'octocat', id: 1 }, labels: [], sourceBranch: 'feat/add', targetBranch: 'main' },
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(updated));
const api = await createAPI();
await expect(api.prUpdate({ directory: '/workspace', number: 12, title: 'Renamed', description: 'New body' })).resolves.toEqual(updated.pr);
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/pr/update', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ directory: '/workspace', number: 12, title: 'Renamed', description: 'New body' }),
});
});
it('returns merged:false without throwing when the server rejects a merge', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json(
{ connected: true, merged: false, message: '409 Conflict: already merged' },
{ status: 409 },
));
const api = await createAPI();
await expect(api.prMerge({ directory: '/workspace', number: 12, method: 'merge' })).resolves.toEqual({
connected: true,
merged: false,
message: '409 Conflict: already merged',
});
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/pr/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ directory: '/workspace', number: 12, method: 'merge' }),
});
});
it('resolves merged:true on a successful merge', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, merged: true }));
const api = await createAPI();
await expect(api.prMerge({ directory: '/workspace', number: 12 })).resolves.toEqual({ connected: true, merged: true });
});
it('throws the server error when prMerge hits a real error payload', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json(
{ error: 'Your Gitea token needs write:repository scope to merge pull requests' },
{ status: 400 },
));
const api = await createAPI();
await expect(api.prMerge({ directory: '/workspace', number: 12 })).rejects.toThrow(
'Your Gitea token needs write:repository scope to merge pull requests',
);
});
it('throws the response status text when prMerge has no parseable payload', async () => {
runtimeFetchMock.mockResolvedValueOnce(new Response('upstream gone', { status: 502, statusText: 'Bad Gateway' }));
const api = await createAPI();
await expect(api.prMerge({ directory: '/workspace', number: 12 })).rejects.toThrow('Bad Gateway');
});
it('parses branches and the default branch from repoBranches', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ branches: ['main', 'feat/api'], defaultBranch: 'main' }));
const api = await createAPI();
await expect(api.repoBranches('group', 'sub')).resolves.toEqual({ branches: ['main', 'feat/api'], defaultBranch: 'main' });
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/repo/branches?owner=group&repo=sub', {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('defaults defaultBranch to null when repoBranches omits it', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ branches: ['main'] }));
const api = await createAPI();
await expect(api.repoBranches('group', 'sub')).resolves.toEqual({ branches: ['main'], defaultBranch: null });
});
it('throws the server error message when repoBranches fails', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'Gitea rate limited' }, { status: 503 }));
const api = await createAPI();
await expect(api.repoBranches('group', 'sub')).rejects.toThrow('Gitea rate limited');
});
it('throws the response status text when repoBranches has no parseable payload', async () => {
runtimeFetchMock.mockResolvedValueOnce(new Response('upstream gone', { status: 502, statusText: 'Bad Gateway' }));
const api = await createAPI();
await expect(api.repoBranches('group', 'sub')).rejects.toThrow('Bad Gateway');
});
it('throws the server error message on {error} payloads', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'Not connected to Gitea' }, { status: 401 }));
const api = await createAPI();
await expect(api.authStatus()).rejects.toThrow('Not connected to Gitea');
});
it('throws the response status text when no error payload is present', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({}, { status: 500, statusText: 'Internal Server Error' }));
const api = await createAPI();
await expect(api.issuesList('/workspace')).rejects.toThrow('Internal Server Error');
});
});
+253
View File
@@ -0,0 +1,253 @@
import type {
GiteaAPI,
GiteaAuthStatus,
GiteaBranchesResult,
GiteaIssueCommentsResult,
GiteaIssueGetResult,
GiteaIssuesListResult,
GiteaPullRequest,
GiteaPullRequestContextResult,
GiteaPullRequestCreateInput,
GiteaPullRequestMergeInput,
GiteaPullRequestMergeResult,
GiteaPullRequestsListResult,
GiteaPullRequestUpdateInput,
GiteaUserSummary,
} from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
interface WebGiteaAPIOptions {
urls: RuntimeUrlResolver;
}
interface GiteaPullRequestWriteResult {
connected: boolean;
repo?: { owner: string; repo: string; url?: string } | null;
pr?: GiteaPullRequest;
}
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
return (await response.json().catch(() => null)) as T | null;
};
export const createWebGiteaAPI = ({ urls }: WebGiteaAPIOptions): GiteaAPI => ({
async authStatus(): Promise<GiteaAuthStatus> {
const response = await runtimeFetch('/api/gitea/auth/status', { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GiteaAuthStatus & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea status');
}
return payload;
},
async authConnect(input: { accessToken: string; baseUrl: string }): Promise<GiteaAuthStatus> {
const response = await runtimeFetch('/api/gitea/auth/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const payload = await jsonOrNull<GiteaAuthStatus & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to connect Gitea');
}
return payload;
},
async authActivate(accountId: string): Promise<GiteaAuthStatus> {
const response = await runtimeFetch('/api/gitea/auth/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ accountId }),
});
const payload = await jsonOrNull<GiteaAuthStatus & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to activate Gitea account');
}
return payload;
},
async authDisconnect(): Promise<{ removed: boolean }> {
const response = await runtimeFetch('/api/gitea/auth', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
});
const payload = await jsonOrNull<{ removed?: boolean; error?: string }>(response);
if (!response.ok) {
throw new Error(payload?.error || response.statusText || 'Failed to disconnect Gitea');
}
return { removed: Boolean(payload?.removed) };
},
async me(): Promise<GiteaUserSummary> {
const response = await runtimeFetch('/api/gitea/me', { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GiteaUserSummary & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to fetch Gitea user');
}
return payload;
},
async issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GiteaIssuesListResult> {
const page = options?.page ?? 1;
const params = new URLSearchParams({
directory,
page: String(page),
});
if (options?.query) {
params.set('query', options.query);
}
const response = await runtimeFetch(
`/api/gitea/issues/list?${params.toString()}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const payload = await jsonOrNull<GiteaIssuesListResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea issues');
}
return payload;
},
async issueGet(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaIssueGetResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.owner) {
params.set('owner', options.owner);
}
if (options?.repo) {
params.set('repo', options.repo);
}
const response = await runtimeFetch(urls.api('/api/gitea/issues/get', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GiteaIssueGetResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea issue');
}
return payload;
},
async issueComments(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaIssueCommentsResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.owner) {
params.set('owner', options.owner);
}
if (options?.repo) {
params.set('repo', options.repo);
}
const response = await runtimeFetch(urls.api('/api/gitea/issues/comments', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GiteaIssueCommentsResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea issue comments');
}
return payload;
},
async prsList(directory: string, options?: { page?: number; query?: string; sourceBranch?: string }): Promise<GiteaPullRequestsListResult> {
const page = options?.page ?? 1;
const params = new URLSearchParams({
directory,
page: String(page),
});
if (options?.query) {
params.set('query', options.query);
}
if (options?.sourceBranch) {
params.set('sourceBranch', options.sourceBranch);
}
const response = await runtimeFetch(
`/api/gitea/prs/list?${params.toString()}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const payload = await jsonOrNull<GiteaPullRequestsListResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull requests');
}
return payload;
},
async prContext(
directory: string,
number: number,
options?: { includeDiff?: boolean; owner?: string; repo?: string }
): Promise<GiteaPullRequestContextResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.includeDiff) {
params.set('includeDiff', '1');
}
if (options?.owner) {
params.set('owner', options.owner);
}
if (options?.repo) {
params.set('repo', options.repo);
}
const response = await runtimeFetch(urls.api('/api/gitea/pr/context', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GiteaPullRequestContextResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull request context');
}
return payload;
},
async prCreate(input: GiteaPullRequestCreateInput): Promise<GiteaPullRequest> {
const response = await runtimeFetch('/api/gitea/pr/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const payload = await jsonOrNull<GiteaPullRequestWriteResult & { error?: string }>(response);
if (!response.ok || !payload?.pr) {
throw new Error(payload?.error || response.statusText || 'Failed to create Gitea pull request');
}
return payload.pr;
},
async prUpdate(input: GiteaPullRequestUpdateInput): Promise<GiteaPullRequest> {
const response = await runtimeFetch('/api/gitea/pr/update', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const payload = await jsonOrNull<GiteaPullRequestWriteResult & { error?: string }>(response);
if (!response.ok || !payload?.pr) {
throw new Error(payload?.error || response.statusText || 'Failed to update Gitea pull request');
}
return payload.pr;
},
async prMerge(input: GiteaPullRequestMergeInput): Promise<GiteaPullRequestMergeResult> {
const response = await runtimeFetch('/api/gitea/pr/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const payload = await jsonOrNull<GiteaPullRequestMergeResult & { error?: string }>(response);
// The server rejects non-mergeable requests with 405/409/422 and a
// `{ connected, merged: false, message }` body — parse it and return it
// instead of throwing. Only throw when there is no parseable payload
// (network failure) or the server surfaced a real `{ error }`.
if (!payload) {
throw new Error(response.statusText || 'Failed to merge Gitea pull request');
}
if (payload.error) {
throw new Error(payload.error);
}
return {
connected: Boolean(payload.connected),
merged: Boolean(payload.merged),
...(payload.message ? { message: payload.message } : {}),
};
},
async repoBranches(owner: string, repo: string): Promise<GiteaBranchesResult> {
const response = await runtimeFetch(
`/api/gitea/repo/branches?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const body = await jsonOrNull<GiteaBranchesResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to fetch Gitea repo branches');
}
return {
branches: body.branches ?? [],
defaultBranch: body.defaultBranch ?? null,
};
},
});
+2
View File
@@ -16,6 +16,7 @@ import { createWebToolsAPI } from './tools';
import { createWebPushAPI } from './push';
import { createWebGitHubAPI } from './github';
import { createWebGitLabAPI } from './gitlab';
import { createWebGiteaAPI } from './gitea';
import { createWebClientAuthAPI } from './clientAuth';
export interface WebAPIsOptions {
@@ -47,6 +48,7 @@ export const createWebAPIs = (options: WebAPIsOptions = {}): RuntimeAPIs => {
notifications: createWebNotificationsAPI(),
github: createWebGitHubAPI({ urls: activeUrls }),
gitlab: createWebGitLabAPI({ urls: activeUrls }),
gitea: createWebGiteaAPI({ urls: activeUrls }),
push: createWebPushAPI(),
clientAuth: createWebClientAuthAPI(),
tools: createWebToolsAPI(),