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
This commit is contained in:
2026-08-16 16:29:25 +00:00
parent 1f28b61c5a
commit 3800c84948
47 changed files with 4052 additions and 35 deletions
+94
View File
@@ -306,4 +306,98 @@ describe('createWebGiteaAPI', () => {
const api = await createAPI();
await expect(api.issuesList('/workspace')).rejects.toThrow('Internal Server Error');
});
it('passes directory/query/owner/repo to searchUsers and maps the response', async () => {
const result = {
connected: true,
repo: null,
users: [{ username: 'octocat', id: 1, name: 'Octo Cat', avatarUrl: 'https://gitea.example/octocat.png' }],
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'octo', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('omits owner/repo from searchUsers when not provided', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, repo: null, users: [] }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
const params = new URLSearchParams({ directory: '/workspace', query: 'octo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('throws the server error message when searchUsers fails', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'Gitea rate limited' }, { status: 503 }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).rejects.toThrow('Gitea rate limited');
});
it('passes directory/query to searchLabels and maps the response', async () => {
const result = { connected: true, repo: null, labels: [{ name: 'bug', color: 'd73a4a' }] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchLabels!('/workspace', 'feat', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/labels/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchMilestones and maps the response', async () => {
const result = { connected: true, repo: null, milestones: [{ title: 'v2.0', state: 'open' }] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchMilestones!('/workspace', 'v2', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'v2', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/milestones/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchBranches and maps the response', async () => {
const result = { connected: true, repo: null, branches: ['main', 'feat/api'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchBranches!('/workspace', 'feat', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/branches/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchTags and maps the response', async () => {
const result = { connected: true, repo: null, tags: ['v1.0', 'v1.1'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchTags!('/workspace', 'v1', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'v1', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/tags/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
});
+80
View File
@@ -2,13 +2,18 @@ import type {
GiteaAPI,
GiteaAuthStatus,
GiteaBranchesResult,
GiteaBranchesSearchResult,
GiteaIssueCommentInput,
GiteaIssueCommentResult,
GiteaIssueCommentsResult,
GiteaIssueCreateInput,
GiteaIssueCreateResult,
GiteaIssueGetResult,
GiteaIssuesListResult,
GiteaIssueUpdateInput,
GiteaIssueUpdateResult,
GiteaLabelsSearchResult,
GiteaMilestonesSearchResult,
GiteaPullRequest,
GiteaPullRequestCommitsResult,
GiteaPullRequestContextResult,
@@ -22,7 +27,9 @@ import type {
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';
@@ -98,6 +105,66 @@ export const createWebGiteaAPI = ({ urls }: WebGiteaAPIOptions): GiteaAPI => ({
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({
@@ -307,6 +374,19 @@ export const createWebGiteaAPI = ({ urls }: WebGiteaAPIOptions): GiteaAPI => ({
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',
+99
View File
@@ -0,0 +1,99 @@
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 { createWebGitHubAPI } = await import('./github');
return createWebGitHubAPI({ urls });
};
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
runtimeFetchMock.mockReset();
});
describe('createWebGitHubAPI', () => {
it('passes directory/query/owner/repo to searchUsers and maps the response', async () => {
const result = {
connected: true,
repo: null,
users: [{ login: 'octocat', id: 1, name: 'Octo Cat', avatarUrl: 'https://avatars.example/octocat.png' }],
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo', { sourceRepo: { owner: 'acme', repo: 'widget' } }))
.resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'octo', owner: 'acme', repo: 'widget' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/github/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('omits owner/repo from searchUsers when no sourceRepo is provided', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, repo: null, users: [] }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
const params = new URLSearchParams({ directory: '/workspace', query: 'octo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/github/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('defaults missing repo/users fields to null/empty when the response omits them', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
});
it('throws the server error message when searchUsers fails', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'GitHub rate limited' }, { status: 503 }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).rejects.toThrow('GitHub rate limited');
});
it('passes directory/query to searchLabels and maps the response', async () => {
const result = { connected: true, repo: null, labels: [{ name: 'bug', color: 'd73a4a' }] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchLabels!('/workspace', 'feat', { sourceRepo: { owner: 'acme', repo: 'widget' } }))
.resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', owner: 'acme', repo: 'widget' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/github/labels/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
});
+90
View File
@@ -1,13 +1,18 @@
import type {
GitHubAPI,
GitHubAuthStatus,
GitHubBranchesSearchResult,
GitHubIssueCommentsResult,
GitHubIssueCommentInput,
GitHubIssueCommentResult,
GitHubIssueCreateInput,
GitHubIssueCreateResult,
GitHubIssueGetResult,
GitHubIssueUpdateInput,
GitHubIssueUpdateResult,
GitHubIssuesListResult,
GitHubLabelsSearchResult,
GitHubMilestonesSearchResult,
GitHubPullRequestContextResult,
GitHubPullRequestCommitsResult,
GitHubPullRequestTimelineResult,
@@ -27,7 +32,9 @@ import type {
GitHubReviewCommentResult,
GitHubDeviceFlowComplete,
GitHubDeviceFlowStart,
GitHubTagsSearchResult,
GitHubUserSummary,
GitHubUsersSearchResult,
} from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
@@ -120,6 +127,76 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
return payload;
},
async searchUsers(directory, query, options): Promise<GitHubUsersSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/users/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubUsersSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub users');
}
return { connected: body.connected, repo: body.repo ?? null, users: body.users ?? [] };
},
async searchLabels(directory, query, options): Promise<GitHubLabelsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/labels/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubLabelsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub labels');
}
return { connected: body.connected, repo: body.repo ?? null, labels: body.labels ?? [] };
},
async searchMilestones(directory, query, options): Promise<GitHubMilestonesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/milestones/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubMilestonesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub milestones');
}
return { connected: body.connected, repo: body.repo ?? null, milestones: body.milestones ?? [] };
},
async searchBranches(directory, query, options): Promise<GitHubBranchesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/branches/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubBranchesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub branches');
}
return { connected: body.connected, repo: body.repo ?? null, branches: body.branches ?? [] };
},
async searchTags(directory, query, options): Promise<GitHubTagsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/tags/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubTagsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub tags');
}
return { connected: body.connected, repo: body.repo ?? null, tags: body.tags ?? [] };
},
async prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus> {
const params = new URLSearchParams({
directory,
@@ -347,6 +424,19 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
return body;
},
async issueCreate(input: GitHubIssueCreateInput): Promise<GitHubIssueCreateResult> {
const response = await runtimeFetch('/api/github/issues/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitHubIssueCreateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to create GitHub issue');
}
return body;
},
async issueUpdate(input: GitHubIssueUpdateInput): Promise<GitHubIssueUpdateResult> {
const response = await runtimeFetch('/api/github/issues/update', {
method: 'PATCH',
+94
View File
@@ -309,4 +309,98 @@ describe('createWebGitLabAPI', () => {
const api = await createAPI();
await expect(api.issuesList('/workspace')).rejects.toThrow('Internal Server Error');
});
it('passes directory/query/namespace/project to searchUsers and maps the response', async () => {
const result = {
connected: true,
repo: null,
users: [{ username: 'octocat', id: 1, name: 'Octo Cat', avatarUrl: 'https://gitlab.example/octocat.png' }],
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'octo', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('omits namespace/project from searchUsers when not provided', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, repo: null, users: [] }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
const params = new URLSearchParams({ directory: '/workspace', query: 'octo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('throws the server error message when searchUsers fails', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'GitLab rate limited' }, { status: 503 }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).rejects.toThrow('GitLab rate limited');
});
it('passes directory/query to searchLabels and maps the response', async () => {
const result = { connected: true, repo: null, labels: ['bug', 'frontend'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchLabels!('/workspace', 'feat', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/labels/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchMilestones and maps the response', async () => {
const result = { connected: true, repo: null, milestones: [{ title: 'v2.0', state: 'active' }] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchMilestones!('/workspace', 'v2', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'v2', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/milestones/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchBranches and maps the response', async () => {
const result = { connected: true, repo: null, branches: ['main', 'feat/api'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchBranches!('/workspace', 'feat', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/branches/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchTags and maps the response', async () => {
const result = { connected: true, repo: null, tags: ['v1.0', 'v1.1'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchTags!('/workspace', 'v1', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'v1', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/tags/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
});
+80
View File
@@ -2,13 +2,17 @@ import type {
GitLabAPI,
GitLabAuthStatus,
GitLabBranchesResult,
GitLabBranchesSearchResult,
GitLabIssueCommentResult,
GitLabIssueCommentsResult,
GitLabIssueCommentInput,
GitLabIssueCreateInput,
GitLabIssueCreateResult,
GitLabIssueGetResult,
GitLabIssuesListResult,
GitLabIssueUpdateInput,
GitLabIssueUpdateResult,
GitLabLabelsSearchResult,
GitLabMergeRequest,
GitLabMergeRequestCommitsResult,
GitLabMergeRequestContextResult,
@@ -20,11 +24,14 @@ import type {
GitLabMergeRequestTimelineResult,
GitLabMergeRequestUpdateInput,
GitLabMergeRequestUpdateResult,
GitLabMilestonesSearchResult,
GitLabMrApproveInput,
GitLabMrApproveResult,
GitLabMrNoteInput,
GitLabMrNoteResult,
GitLabTagsSearchResult,
GitLabUserSummary,
GitLabUsersSearchResult,
} from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
@@ -94,6 +101,66 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
return payload;
},
async searchUsers(directory, query, options): Promise<GitLabUsersSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/users/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabUsersSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab users');
}
return { connected: body.connected, repo: body.repo ?? null, users: body.users ?? [] };
},
async searchLabels(directory, query, options): Promise<GitLabLabelsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/labels/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabLabelsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab labels');
}
return { connected: body.connected, repo: body.repo ?? null, labels: body.labels ?? [] };
},
async searchMilestones(directory, query, options): Promise<GitLabMilestonesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/milestones/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabMilestonesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab milestones');
}
return { connected: body.connected, repo: body.repo ?? null, milestones: body.milestones ?? [] };
},
async searchBranches(directory, query, options): Promise<GitLabBranchesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/branches/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabBranchesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab branches');
}
return { connected: body.connected, repo: body.repo ?? null, branches: body.branches ?? [] };
},
async searchTags(directory, query, options): Promise<GitLabTagsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/tags/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabTagsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab tags');
}
return { connected: body.connected, repo: body.repo ?? null, tags: body.tags ?? [] };
},
async issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GitLabIssuesListResult> {
const page = options?.page ?? 1;
const params = new URLSearchParams({
@@ -287,6 +354,19 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
return body;
},
async issueCreate(input: GitLabIssueCreateInput): Promise<GitLabIssueCreateResult> {
const response = await runtimeFetch('/api/gitlab/issues/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitLabIssueCreateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to create GitLab issue');
}
return body;
},
async issueUpdate(input: GitLabIssueUpdateInput): Promise<GitLabIssueUpdateResult> {
const response = await runtimeFetch('/api/gitlab/issues/update', {
method: 'PUT',