Files
openchamber/packages/web/src/api/gitea.ts
T
bot-hermes 3800c84948 feat(ui): forge user lookup — assignee combobox, @-mentions, repo-scoped user search
Repo-scoped assignable-user search for GitHub, GitLab, and Gitea, surfaced as
an assignee combobox in the metadata editor and @-mention autocomplete in
forge comment/reply/review surfaces.

- server: GET /api/{provider}/users/search (assignees / project members),
  query + directory/override repo resolution, 429 -> 503, connected:false
  degradation; GitLab assignee writes resolve login -> ID server-side
- wire: searchUsers (+ searchLabels/milestones/branches/tags) on the three
  API clients with tests
- facade: userSearch capability (all three), searchUsers adapters,
  mapGithubAssignee/mapGitlabMember/mapGiteaAssignee -> ForgeUser
- ui: ForgeLookupCombobox (keyboard nav, debounced 30s-TTL cache,
  connected-only caching), ForgeMentionTextarea (@ token parsing, caret
  restore), free-text fallback when lookup is unavailable; i18n in 12 locales
- extras sharing the same infrastructure: GitLab create-issue dialog and
  label/milestone/branch/tag lookups in the metadata editor
2026-08-16 16:29:25 +00:00

463 lines
20 KiB
TypeScript

import type {
GiteaAPI,
GiteaAuthStatus,
GiteaBranchesResult,
GiteaBranchesSearchResult,
GiteaIssueCommentInput,
GiteaIssueCommentResult,
GiteaIssueCommentsResult,
GiteaIssueCreateInput,
GiteaIssueCreateResult,
GiteaIssueGetResult,
GiteaIssuesListResult,
GiteaIssueUpdateInput,
GiteaIssueUpdateResult,
GiteaLabelsSearchResult,
GiteaMilestonesSearchResult,
GiteaPullRequest,
GiteaPullRequestCommitsResult,
GiteaPullRequestContextResult,
GiteaPullRequestCreateInput,
GiteaPullRequestMergeInput,
GiteaPullRequestMergeResult,
GiteaPullRequestReviewsResult,
GiteaPullRequestsListResult,
GiteaPullRequestStatusesResult,
GiteaPullRequestUpdateInput,
GiteaPullReviewInput,
GiteaPullReviewResult,
GiteaRepoLabelsResult,
GiteaTagsSearchResult,
GiteaUserSummary,
GiteaUsersSearchResult,
} 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 searchUsers(directory, query, options): Promise<GiteaUsersSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/users/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaUsersSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea users');
}
return { connected: body.connected, repo: body.repo ?? null, users: body.users ?? [] };
},
async searchLabels(directory, query, options): Promise<GiteaLabelsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/labels/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaLabelsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea labels');
}
return { connected: body.connected, repo: body.repo ?? null, labels: body.labels ?? [] };
},
async searchMilestones(directory, query, options): Promise<GiteaMilestonesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/milestones/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaMilestonesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea milestones');
}
return { connected: body.connected, repo: body.repo ?? null, milestones: body.milestones ?? [] };
},
async searchBranches(directory, query, options): Promise<GiteaBranchesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/branches/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaBranchesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea branches');
}
return { connected: body.connected, repo: body.repo ?? null, branches: body.branches ?? [] };
},
async searchTags(directory, query, options): Promise<GiteaTagsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/tags/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaTagsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea tags');
}
return { connected: body.connected, repo: body.repo ?? null, tags: body.tags ?? [] };
},
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 prCommits(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestCommitsResult> {
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/prs/commits', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GiteaPullRequestCommitsResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull request commits');
}
return payload;
},
async prStatuses(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestStatusesResult> {
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/prs/statuses', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GiteaPullRequestStatusesResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull request statuses');
}
return payload;
},
async prReviews(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestReviewsResult> {
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/prs/reviews', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GiteaPullRequestReviewsResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull request reviews');
}
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 issueComment(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult> {
const response = await runtimeFetch('/api/gitea/issues/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GiteaIssueCommentResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to post Gitea issue comment');
}
return body;
},
async issueCreate(input: GiteaIssueCreateInput): Promise<GiteaIssueCreateResult> {
const response = await runtimeFetch('/api/gitea/issues/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GiteaIssueCreateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to create Gitea issue');
}
return body;
},
async issueUpdate(input: GiteaIssueUpdateInput): Promise<GiteaIssueUpdateResult> {
const response = await runtimeFetch('/api/gitea/issues/update', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GiteaIssueUpdateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to update Gitea issue');
}
return body;
},
async prComment(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult> {
const response = await runtimeFetch('/api/gitea/prs/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GiteaIssueCommentResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to post Gitea pull request comment');
}
return body;
},
async prSubmitReview(input: GiteaPullReviewInput): Promise<GiteaPullReviewResult> {
const response = await runtimeFetch('/api/gitea/prs/review', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GiteaPullReviewResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to submit Gitea pull request review');
}
return body;
},
async repoLabels(directory: string, options?: { owner?: string; repo?: string }): Promise<GiteaRepoLabelsResult> {
const params = new URLSearchParams({ directory });
if (options?.owner) {
params.set('owner', options.owner);
}
if (options?.repo) {
params.set('repo', options.repo);
}
const response = await runtimeFetch(
`/api/gitea/repo/labels?${params.toString()}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const body = await jsonOrNull<GiteaRepoLabelsResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to fetch Gitea repo labels');
}
return body;
},
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,
};
},
});