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:
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user