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
@@ -2,10 +2,12 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { ForgeEntityDetailView } from '@/components/views/forge';
import { ForgeCreateIssueDialog } from '@/components/views/forge/actions';
import { buildForgeProvider } from '@/lib/forge';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useUIStore } from '@/stores/useUIStore';
import type { GitHubIssueSummary, GitHubRepoSelector } from '@/lib/api/types';
import type { ForgeIssue } from '@/lib/forge';
import { useI18n } from '@/lib/i18n';
const issueLabelBadgeClass =
@@ -46,6 +48,8 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory
>(null);
const [selectedUrl, setSelectedUrl] = React.useState<string | null>(null);
const [createOpen, setCreateOpen] = React.useState(false);
const issueProvider = React.useMemo(() => (github ? buildForgeProvider('github', { github }) : null), [github]);
const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
@@ -55,6 +59,16 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]);
const handleIssueCreated = React.useCallback(
(issue: ForgeIssue) => {
// Open the freshly created issue's detail and refresh the list behind it.
setSelectedNumber(issue.number);
setSelectedUrl(issue.url ?? null);
setRetryToken((value) => value + 1);
},
[],
);
// A different repository invalidates the previously loaded list and detail so
// a stale repository's issues never leak into the new one.
React.useEffect(() => {
@@ -180,8 +194,14 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory
return (
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-col gap-0.5">
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="typography-ui-header font-semibold text-foreground">{t('gitView.pullRequest.issues.listSectionTitle')}</div>
{issueProvider?.createIssue ? (
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2" onClick={() => setCreateOpen(true)}>
<Icon name="add" className="size-4" />
{t('forge.actions.newIssue')}
</Button>
) : null}
</div>
{!github?.issuesList ? (
@@ -255,6 +275,16 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory
) : null}
</div>
)}
{issueProvider?.createIssue ? (
<ForgeCreateIssueDialog
provider={issueProvider}
directory={directory}
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={handleIssueCreated}
/>
) : null}
</div>
);
};