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