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
+11
View File
@@ -263,6 +263,8 @@ export function createGitLabClient({ token, baseUrl }) {
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { query: params }),
createIssueNote: (pathWithNamespace, iid, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { method: 'POST', body: { body } }),
createIssue: (pathWithNamespace, params) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues`, { method: 'POST', body: params }),
updateIssue: (pathWithNamespace, iid, params) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}`, { method: 'PUT', body: params }),
mergeRequests: (pathWithNamespace, params = {}) =>
@@ -289,6 +291,15 @@ export function createGitLabClient({ token, baseUrl }) {
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/merge`, { method: 'PUT', body }),
branches: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/repository/branches`, { query: params }),
// Project members (direct + inherited) are the assignable/mentionable user
// set. `members/all` includes inherited group members; `query` filters
// server-side by username/name/email.
members: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/members/all`, { query: params }),
labels: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/labels`, { query: params }),
tags: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/repository/tags`, { query: params }),
};
}