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
@@ -1339,6 +1339,174 @@ describe('Gitea data routes', () => {
expect(JSON.parse(options.body)).toEqual({ state: 'closed' });
});
test('issues/create POSTs an issue and maps it', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/issues$/)(url) && options.method === 'POST') {
return jsonResponse({
number: 9,
title: 'New issue',
html_url: 'https://gitea.example.com/owner/repo/issues/9',
state: 'open',
body: 'The body',
labels: [{ id: 1, name: 'bug', color: 'd73a4a' }],
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
created_at: '2026-01-02T11:00:00Z',
updated_at: '2026-01-02T11:00:00Z',
}, { status: 201 });
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/issues/create')
.send({ directory: '/tmp/work', title: 'New issue', body: 'The body', labels: ['bug'] });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' },
issue: {
number: 9,
title: 'New issue',
url: 'https://gitea.example.com/owner/repo/issues/9',
state: 'open',
body: 'The body',
labels: ['bug'],
author: { username: 'alice', id: 42 },
},
});
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ title: 'New issue', body: 'The body', labels: ['bug'] });
});
test('issues/create requires directory and title', async () => {
const app = createApp();
const response = await request(app)
.post('/api/gitea/issues/create')
.send({ directory: '/tmp/work' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory and title are required' });
});
test('issues/create reports connected:false when not authenticated', async () => {
clearGiteaAuth();
const app = createApp();
const response = await request(app)
.post('/api/gitea/issues/create')
.send({ directory: '/tmp/work', title: 'Hi' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
});
describe('Gitea rich lookup routes', () => {
beforeEach(() => {
setGiteaAuth({ accessToken: 'gitea-secret', baseUrl: 'https://gitea.example.com', user: aliceUser });
});
test('users/search maps repo assignees and filters by query', async () => {
scriptedFetch([
(url) => (matches(/\/repos\/owner\/repo\/assignees\?/)(url)
? jsonResponse([
{ id: 42, login: 'alice', full_name: 'Alice Example', avatar_url: 'https://gitea.example.com/alice.png', html_url: 'https://gitea.example.com/alice' },
{ id: 43, login: 'bob', full_name: 'Bob', avatar_url: 'https://gitea.example.com/bob.png', html_url: 'https://gitea.example.com/bob' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/users/search?directory=%2Ftmp%2Fwork&query=ali');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
users: [{ username: 'alice', id: 42, name: 'Alice Example', avatarUrl: 'https://gitea.example.com/alice.png' }],
});
});
test('users/search reports connected:false when not authenticated', async () => {
clearGiteaAuth();
const app = createApp();
const response = await request(app).get('/api/gitea/users/search?directory=%2Ftmp%2Fwork&query=ali');
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false, users: [] });
});
test('labels/search returns labeled objects filtered by name', async () => {
scriptedFetch([
(url) => (matches(/\/repos\/owner\/repo\/labels\?/)(url)
? jsonResponse([
{ id: 1, name: 'bug', color: 'd73a4a', description: 'a bug' },
{ id: 2, name: 'feature', color: '0e8a16' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/labels/search?directory=%2Ftmp%2Fwork&query=bug');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
labels: [{ name: 'bug', color: 'd73a4a', description: 'a bug' }],
});
});
test('milestones/search maps milestone titles and states', async () => {
scriptedFetch([
(url) => (matches(/\/repos\/owner\/repo\/milestones\?/)(url)
? jsonResponse([
{ id: 1, title: 'v1.0', state: 'open' },
{ id: 2, title: 'v2.0', state: 'closed' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/milestones/search?directory=%2Ftmp%2Fwork&query=v1');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
milestones: [{ title: 'v1.0', state: 'open' }],
});
});
test('branches/search returns branch names', async () => {
scriptedFetch([
(url) => (matches(/\/repos\/owner\/repo\/branches\?/)(url)
? jsonResponse([{ name: 'main' }, { name: 'feat/x' }])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/branches/search?directory=%2Ftmp%2Fwork&query=main');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true, branches: ['main'] });
});
test('tags/search returns tag names', async () => {
scriptedFetch([
(url) => (matches(/\/repos\/owner\/repo\/tags\?/)(url)
? jsonResponse([{ name: 'v1.0.0' }, { name: 'v1.1.0' }])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/tags/search?directory=%2Ftmp%2Fwork&query=v1.0');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true, tags: ['v1.0.0'] });
});
});
// NOTE: keep this test last in the file. The rate-limit cooldown is
// module-level and has no reset export, so tests after it would short-circuit.
test('data routes surface a 503 when Gitea rate limits', async () => {