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
@@ -111,6 +111,7 @@ Nothing in the client or repo layers assumes the token came from a PAT.
| PUT | `/api/gitlab/mrs/update` | body `{ directory, number, title?, description?, state?, labels?, assigneeIds?, milestone? }` -> `{ connected, repo?, mr }`; `404` when the MR does not exist; `400 'Milestone not found'` when a milestone title does not match |
| PUT | `/api/gitlab/mrs/merge` | body `{ directory, number, squash? }` -> `{ connected, merged: true }` on success; non-mergeable MRs -> the GitLab status (`405`/`406`/`409`/`422`) with `{ connected, merged: false, message }` |
| POST | `/api/gitlab/issues/comment` | body `{ directory, number, body, namespace?, project? }` -> `{ connected, repo?, comment }` |
| POST | `/api/gitlab/issues/create` | body `{ directory, title, body?, labels?, namespace?, project? }` -> `{ connected, repo?, issue }` |
| PUT | `/api/gitlab/issues/update` | body `{ directory, number, title?, body?, state?, labels?, assigneeIds?, milestone?, namespace?, project? }` -> `{ connected, repo?, issue }`; `400 'Milestone not found'` when a milestone title does not match |
| POST | `/api/gitlab/mrs/comment` | body `{ directory, number, body, namespace?, project? }` -> `{ connected, repo?, comment }` |
| POST | `/api/gitlab/mrs/approve` | body `{ directory, number, namespace?, project? }` -> `{ connected, repo?, approved: true }` |
+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 }),
};
}
+305
View File
@@ -232,6 +232,32 @@ const mapDiffItem = (item) => {
};
};
// GitLab assigns by numeric user ID, while the facade deals in logins. Resolve
// a set of assignee logins to IDs via the project members list; unmatched
// logins yield `{ unknown: login }` so update routes can surface a precise
// `400 { error: 'Unknown assignee: ...' }` instead of silently dropping a user.
const resolveAssigneeIds = async (client, projectPath, logins) => {
const uniqueLogins = [...new Set(logins)];
const ids = [];
for (const login of uniqueLogins) {
const resp = await client.members(projectPath, { per_page: 100, query: login });
if (resp.status === 429) {
return { ids: null, rateLimited: true, unknown: null };
}
if (resp.status !== 200 || !Array.isArray(resp.data)) {
return { ids: null, rateLimited: false, unknown: null };
}
const match = resp.data.find(
(item) => typeof item?.username === 'string' && item.username.toLowerCase() === login.toLowerCase(),
);
if (typeof match?.id !== 'number') {
return { ids: null, rateLimited: false, unknown: login };
}
ids.push(match.id);
}
return { ids, rateLimited: false, unknown: null };
};
const repoRefFromProjectPath = (projectPath, baseUrl) => {
const segments = projectPath.split('/');
const project = segments[segments.length - 1] || '';
@@ -642,6 +668,60 @@ export function registerGitLabRoutes(app, options = {}) {
}
});
app.post('/api/gitlab/issues/create', async (req, res) => {
try {
const directory = asString(req.body?.directory);
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!directory || !title) {
return res.status(400).json({ error: 'directory and title are required' });
}
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : undefined;
const labels = Array.isArray(req.body?.labels)
? req.body.labels.filter((label) => typeof label === 'string' && label.length > 0)
: undefined;
const client = await getClient();
if (!client) {
return res.json({ connected: false });
}
const requestedProject = getRequestedProject(req);
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' });
}
const params = {
title,
...(body !== undefined ? { description: body } : {}),
...(labels !== undefined ? { labels } : {}),
};
const resp = await client.createIssue(projectPath, params);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your GitLab token needs the api scope to create issues' });
}
if (resp.status !== 200 && resp.status !== 201) {
const status = resp.status >= 500 ? 500 : 400;
return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while creating the issue' });
}
if (!resp.data) {
return res.status(500).json({ error: 'GitLab returned an empty response while creating the issue' });
}
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
issue: mapIssue(resp.data),
});
} catch (error) {
console.error('Failed to create GitLab issue:', error);
return res.status(500).json({ error: error.message || 'Failed to create GitLab issue' });
}
});
app.put('/api/gitlab/issues/update', async (req, res) => {
try {
const directory = asString(req.body?.directory);
@@ -675,6 +755,16 @@ export function registerGitLabRoutes(app, options = {}) {
if (Array.isArray(req.body?.labels)) {
body.labels = req.body.labels.filter((label) => typeof label === 'string');
}
if (Array.isArray(req.body?.assignees)) {
const { ids, rateLimited, unknown } = await resolveAssigneeIds(client, projectPath, req.body.assignees.filter((login) => typeof login === 'string'));
if (rateLimited) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (unknown !== null) {
return res.status(400).json({ error: `Unknown assignee: ${unknown}` });
}
body.assignee_ids = ids;
}
if (Array.isArray(req.body?.assigneeIds)) {
body.assignee_ids = req.body.assigneeIds.filter((id) => typeof id === 'number');
}
@@ -1103,6 +1193,16 @@ export function registerGitLabRoutes(app, options = {}) {
if (Array.isArray(req.body?.labels)) {
body.labels = req.body.labels.filter((label) => typeof label === 'string');
}
if (Array.isArray(req.body?.assignees)) {
const { ids, rateLimited, unknown } = await resolveAssigneeIds(client, projectPath, req.body.assignees.filter((login) => typeof login === 'string'));
if (rateLimited) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (unknown !== null) {
return res.status(400).json({ error: `Unknown assignee: ${unknown}` });
}
body.assignee_ids = ids;
}
if (Array.isArray(req.body?.assigneeIds)) {
body.assignee_ids = req.body.assigneeIds.filter((id) => typeof id === 'number');
}
@@ -1312,6 +1412,211 @@ export function registerGitLabRoutes(app, options = {}) {
}
});
// ================= GitLab Rich Lookup APIs =================
// Repo-scoped lookups for pickers/mentions. Each resolves the target project
// (directory remote + namespace/project override) and hits a GitLab endpoint
// that supports server-side `query` filtering where available. `connected:
// false` means the lookup could not be performed — never an authoritative
// empty list.
const lookupProject = async (req) => {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
if (!directory && !requestedProject) {
return { error: 'directory or namespace/project is required' };
}
const client = await getClient();
if (!client) {
return { client: null };
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
return { client, projectPath, repo };
};
app.get('/api/gitlab/users/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, users: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, users: [] });
}
const resp = await withTimeout(
client.members(projectPath, { per_page: 100, ...(query ? { query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab users search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching users' });
}
const users = (Array.isArray(resp.data) ? resp.data : [])
.map(mapGitLabUser)
.filter((user) => user && user.username);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), users });
} catch (error) {
console.error('Failed to search GitLab users:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab users' });
}
});
app.get('/api/gitlab/labels/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, labels: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, labels: [] });
}
const resp = await withTimeout(
client.labels(projectPath, { per_page: 100, ...(query ? { search: query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab labels search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching labels' });
}
const labels = (Array.isArray(resp.data) ? resp.data : [])
.map((label) => (typeof label?.name === 'string' ? label.name : ''))
.filter(Boolean);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), labels });
} catch (error) {
console.error('Failed to search GitLab labels:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab labels' });
}
});
app.get('/api/gitlab/milestones/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, milestones: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, milestones: [] });
}
const resp = await withTimeout(
client.milestones(projectPath, { state: 'all', per_page: 100, ...(query ? { search: query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab milestones search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching milestones' });
}
const milestones = (Array.isArray(resp.data) ? resp.data : [])
.map((item) => ({
title: typeof item?.title === 'string' ? item.title : '',
...(typeof item?.state === 'string' ? { state: item.state } : {}),
}))
.filter((item) => item.title);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), milestones });
} catch (error) {
console.error('Failed to search GitLab milestones:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab milestones' });
}
});
app.get('/api/gitlab/branches/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, branches: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, branches: [] });
}
const resp = await withTimeout(
client.branches(projectPath, { per_page: 100, ...(query ? { search: query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab branches search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching branches' });
}
const branches = (Array.isArray(resp.data) ? resp.data : [])
.map((branch) => (typeof branch?.name === 'string' ? branch.name : ''))
.filter(Boolean);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), branches });
} catch (error) {
console.error('Failed to search GitLab branches:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab branches' });
}
});
app.get('/api/gitlab/tags/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupProject(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, tags: [] });
}
const { client, projectPath, repo } = resolved;
if (!projectPath) {
return res.json({ connected: true, repo: null, tags: [] });
}
const resp = await withTimeout(
client.tags(projectPath, { per_page: 100, ...(query ? { search: query } : {}) }),
ROUTE_TIMEOUT_MS,
'gitlab tags search',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while searching tags' });
}
const tags = (Array.isArray(resp.data) ? resp.data : [])
.map((tag) => (typeof tag?.name === 'string' ? tag.name : ''))
.filter(Boolean);
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), tags });
} catch (error) {
console.error('Failed to search GitLab tags:', error);
return res.status(500).json({ error: error.message || 'Failed to search GitLab tags' });
}
});
// ================= GitLab Repo APIs =================
app.get('/api/gitlab/repo/branches', async (req, res) => {
@@ -1220,6 +1220,234 @@ describe('GitLab data routes', () => {
expect(response.body).toEqual({ error: 'Milestone not found' });
});
test('issues/create POSTs an issue and maps it', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/issues$/)(url) && options.method === 'POST') {
return jsonResponse(
{
iid: 8,
title: 'New issue',
web_url: 'https://gitlab.com/group/sub/-/issues/8',
state: 'opened',
description: 'The body',
labels: ['bug'],
author: { id: 42, username: 'alice', name: 'Alice Example' },
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
},
{ status: 201 },
);
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.post('/api/gitlab/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: { namespace: 'group', project: 'sub', host: 'gitlab.com' },
issue: {
number: 8,
title: 'New issue',
url: 'https://gitlab.com/group/sub/-/issues/8',
state: 'opened',
body: 'The body',
labels: ['bug'],
author: { username: 'alice', name: 'Alice Example', id: 42 },
},
});
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ title: 'New issue', description: 'The body', labels: ['bug'] });
});
test('issues/create requires directory and title', async () => {
const app = createApp();
const response = await request(app)
.post('/api/gitlab/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 () => {
clearGitLabAuth();
const app = createApp();
const response = await request(app)
.post('/api/gitlab/issues/create')
.send({ directory: '/tmp/work', title: 'Hi' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
});
describe('GitLab rich lookup routes', () => {
beforeEach(() => {
setGitLabAuth({ accessToken: 'gitlab-secret', baseUrl: 'https://gitlab.com', user: aliceUser });
});
test('users/search maps project members and passes the query', async () => {
const fetchMock = scriptedFetch([
(url) => (matches(/\/members\/all\?/)(url)
? jsonResponse([
{ id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png', web_url: 'https://gitlab.com/alice' },
{ id: 43, username: 'bob', name: 'Bob', avatar_url: 'https://gitlab.com/bob.png', web_url: 'https://gitlab.com/bob' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/users/search?directory=%2Ftmp%2Fwork&query=ali');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { namespace: 'group', project: 'sub' },
users: [
{ username: 'alice', id: 42, name: 'Alice Example', avatarUrl: 'https://gitlab.com/alice.png' },
{ username: 'bob', id: 43, name: 'Bob' },
],
});
const requestedUrl = String(fetchMock.mock.calls[0][0]);
expect(requestedUrl).toContain('query=ali');
});
test('users/search reports connected:false when not authenticated', async () => {
clearGitLabAuth();
const app = createApp();
const response = await request(app).get('/api/gitlab/users/search?directory=%2Ftmp%2Fwork&query=ali');
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false, users: [] });
});
test('labels/search returns label names and passes the search param', async () => {
const fetchMock = scriptedFetch([
(url) => (matches(/\/labels\?/)(url)
? jsonResponse([
{ id: 1, name: 'bug', color: '#d73a4a' },
{ id: 2, name: 'feature', color: '#0e8a16' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/labels/search?directory=%2Ftmp%2Fwork&query=bug');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true, labels: ['bug', 'feature'] });
const requestedUrl = String(fetchMock.mock.calls[0][0]);
expect(requestedUrl).toContain('search=bug');
});
test('milestones/search maps milestone titles and states', async () => {
scriptedFetch([
(url) => (matches(/\/milestones\?/)(url)
? jsonResponse([
{ id: 1, title: 'v1.0', state: 'active' },
{ id: 2, title: 'v2.0', state: 'closed' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/milestones/search?directory=%2Ftmp%2Fwork&query=v');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
milestones: [
{ title: 'v1.0', state: 'active' },
{ title: 'v2.0', state: 'closed' },
],
});
});
test('branches/search returns branch names', async () => {
scriptedFetch([
(url) => (matches(/\/repository\/branches\?/)(url)
? jsonResponse([
{ name: 'main', default: true },
{ name: 'feat/x', default: false },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/branches/search?directory=%2Ftmp%2Fwork&query=main');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true, branches: ['main', 'feat/x'] });
});
test('tags/search returns tag names', async () => {
scriptedFetch([
(url) => (matches(/\/repository\/tags\?/)(url)
? jsonResponse([
{ name: 'v1.0.0' },
{ name: 'v1.1.0' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/tags/search?directory=%2Ftmp%2Fwork&query=v1');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true, tags: ['v1.0.0', 'v1.1.0'] });
});
test('issues/update resolves assignee logins to IDs via project members', async () => {
const fetchMock = scriptedFetch([
(url) => (matches(/\/members\/all\?/)(url) && url.includes('query=alice')
? jsonResponse([{ id: 42, username: 'alice', name: 'Alice Example' }])
: null),
(url, options) => (matches(/\/issues\/7$/)(url) && options?.method === 'PUT'
? jsonResponse({
iid: 7,
title: 'Broken import',
web_url: 'https://gitlab.com/group/sub/-/issues/7',
state: 'opened',
author: { id: 42, username: 'alice', name: 'Alice Example' },
}, { status: 200 })
: null),
]);
const app = createApp();
const response = await request(app)
.put('/api/gitlab/issues/update')
.send({ directory: '/tmp/work', number: 7, assignees: ['alice'] });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: true });
const patchCall = fetchMock.mock.calls.find(([url, options]) => String(url).includes('/issues/7') && options.method === 'PUT');
expect(patchCall).toBeTruthy();
expect(JSON.parse(patchCall[1].body)).toEqual({ assignee_ids: [42] });
});
test('issues/update rejects an unknown assignee login with a precise error', async () => {
scriptedFetch([
(url) => (matches(/\/members\/all\?/)(url)
? jsonResponse([{ id: 42, username: 'alice', name: 'Alice Example' }])
: null),
]);
const app = createApp();
const response = await request(app)
.put('/api/gitlab/issues/update')
.send({ directory: '/tmp/work', number: 7, assignees: ['nobody'] });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Unknown assignee: nobody' });
});
});
// 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 GitLab rate limits', async () => {