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
+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',