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
@@ -72,6 +72,7 @@
All write routes accept an optional `owner`/`repo` in the body to target a fork-network repo; otherwise the repo is resolved from `directory`. Unauthenticated -> `{ connected: false }`; `429` -> `503 { error: 'GitHub rate limited' }`; generic failures -> `500` with a generic error (raw upstream text is never leaked).
- `POST /api/github/issues/comment` — body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment? }` (via `octokit.rest.issues.createComment`, mapped to `GitHubIssueComment`).
- `POST /api/github/issues/create` — body `{ directory, title, body?, labels?, owner?, repo? }` -> `{ connected, repo?, issue? }` (via `octokit.rest.issues.create`; `labels` is a full-set list of names).
- `PATCH /api/github/issues/update` — body `{ directory, number, title?, body?, state?, labels?, assignees?, milestone?, owner?, repo? }` -> `{ connected, repo?, issue? }` (via `octokit.rest.issues.update`; `labels`/`assignees` replace the full set, `milestone` is a title resolved to a milestone number — `400 { error: 'Milestone not found' }` when it matches nothing, `null` clears it). Also works for pull requests (PRs are issues), so it serves PR metadata/state changes too.
- `POST /api/github/pulls/comment` — same input/result shape as `issues/comment`; posts to the PR's issue thread via `octokit.rest.issues.createComment`. Invalidates the PR context cache.
- `POST /api/github/pulls/review-comment` — body `{ directory, number, body, inReplyToId?, path?, line?, owner?, repo? }` -> `{ connected, repo?, comment? }` (via `octokit.rest.pulls.createReviewComment`). With `inReplyToId` it is a reply; otherwise `path` + `line` are required and the PR head commit is resolved first. Invalidates the PR context cache.
+319
View File
@@ -1508,6 +1508,251 @@ export function registerGitHubRoutes(app) {
}
});
// ================= GitHub Rich Lookup APIs =================
// Repo-scoped lookups for pickers/mentions. Each resolves the target repo
// (directory remote + owner/repo override), hits a GitHub endpoint that does
// not support server-side search, then filters client-side (case-insensitive
// substring on the primary field). `connected: false` means the lookup could
// not be performed — never an authoritative empty list.
app.get('/api/github/users/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, users: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, users: [] });
}
const list = await withTimeout(
octokit.rest.issues.listAssignees({ owner: repo.owner, repo: repo.repo, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'github users search',
);
const needle = query.toLowerCase();
const users = (Array.isArray(list?.data) ? list.data : [])
.map(mapGitHubUserSummary)
.filter(Boolean)
.filter((user) => !needle || user.login.toLowerCase().includes(needle) || (user.name || '').toLowerCase().includes(needle));
return res.json({ connected: true, repo, users });
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false, users: [] });
}
console.error('Failed to search GitHub users:', error);
return res.json({ connected: false, users: [] });
}
});
app.get('/api/github/labels/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, labels: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, labels: [] });
}
const list = await withTimeout(
octokit.rest.issues.listLabelsForRepo({ owner: repo.owner, repo: repo.repo, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'github labels search',
);
const needle = query.toLowerCase();
const labels = mapGitHubLabels(list?.data).filter(
(label) => !needle || label.name.toLowerCase().includes(needle),
);
return res.json({ connected: true, repo, labels });
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false, labels: [] });
}
console.error('Failed to search GitHub labels:', error);
return res.json({ connected: false, labels: [] });
}
});
app.get('/api/github/milestones/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, milestones: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, milestones: [] });
}
const list = await withTimeout(
octokit.rest.issues.listMilestonesForRepo({ owner: repo.owner, repo: repo.repo, state: 'all', per_page: 100 }),
ROUTE_TIMEOUT_MS,
'github milestones search',
);
const needle = query.toLowerCase();
const milestones = (Array.isArray(list?.data) ? list.data : [])
.map(mapGitHubMilestone)
.filter(Boolean)
.filter((milestone) => !needle || milestone.title.toLowerCase().includes(needle));
return res.json({ connected: true, repo, milestones });
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false, milestones: [] });
}
console.error('Failed to search GitHub milestones:', error);
return res.json({ connected: false, milestones: [] });
}
});
app.get('/api/github/branches/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, branches: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, branches: [] });
}
const branches = [];
const needle = query.toLowerCase();
let page = 1;
while (true) {
const response = await withTimeout(
octokit.rest.repos.listBranches({ owner: repo.owner, repo: repo.repo, per_page: 100, page }),
ROUTE_TIMEOUT_MS,
'github branches search',
);
if (!response.data || response.data.length === 0) break;
for (const branch of response.data) {
const name = typeof branch?.name === 'string' ? branch.name : '';
if (!name) continue;
if (!needle || name.toLowerCase().includes(needle)) branches.push(name);
}
if (response.data.length < 100) break;
page++;
}
return res.json({ connected: true, repo, branches });
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false, branches: [] });
}
console.error('Failed to search GitHub branches:', error);
return res.json({ connected: false, branches: [] });
}
});
app.get('/api/github/tags/search', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const query = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
if (!directory) {
return res.status(400).json({ error: 'directory is required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, tags: [] });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, tags: [] });
}
const list = await withTimeout(
octokit.rest.repos.listTags({ owner: repo.owner, repo: repo.repo, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'github tags search',
);
const needle = query.toLowerCase();
const tags = (Array.isArray(list?.data) ? list.data : [])
.map((tag) => (typeof tag?.name === 'string' ? tag.name : ''))
.filter(Boolean)
.filter((name) => !needle || name.toLowerCase().includes(needle));
return res.json({ connected: true, repo, tags });
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false, tags: [] });
}
console.error('Failed to search GitHub tags:', error);
return res.json({ connected: false, tags: [] });
}
});
// ================= GitHub Issue APIs =================
app.get('/api/github/issues/list', async (req, res) => {
@@ -1786,6 +2031,80 @@ export function registerGitHubRoutes(app) {
}
});
app.post('/api/github/issues/create', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
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 { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false });
}
const requestedRepo = getRequestedRepo(req);
const repo = await resolveRepoForRequest(octokit, directory, requestedRepo);
if (!repo) {
return res.json({ connected: true, repo: null, issue: null });
}
const result = await octokit.rest.issues.create({
owner: repo.owner,
repo: repo.repo,
title,
...(body !== undefined ? { body } : {}),
...(labels !== undefined ? { labels } : {}),
});
const item = result?.data;
if (!item) {
return res.status(500).json({ error: 'GitHub returned an error while creating the issue' });
}
return res.json({
connected: true,
repo,
issue: {
number: item.number,
title: typeof item.title === 'string' ? item.title : title,
url: typeof item.html_url === 'string' ? item.html_url : '',
state: item.state === 'closed' ? 'closed' : 'open',
author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
body: typeof item.body === 'string' ? item.body : '',
createdAt: item.created_at,
updatedAt: item.updated_at,
labels: Array.isArray(item.labels)
? item.labels
.map((label) => {
if (typeof label === 'string') return null;
const name = typeof label?.name === 'string' ? label.name : '';
if (!name) return null;
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
})
.filter(Boolean)
: [],
},
});
} catch (error) {
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
console.error('Failed to create GitHub issue:', error);
return res.status(500).json({ error: 'GitHub returned an error' });
}
});
app.patch('/api/github/issues/update', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
@@ -22,9 +22,16 @@ const mockState = vi.hoisted(() => ({
issues: {
listEventsForTimeline: vi.fn(),
createComment: vi.fn(),
create: vi.fn(),
update: vi.fn(),
listMilestonesForRepo: vi.fn(),
listComments: vi.fn(),
listAssignees: vi.fn(),
listLabelsForRepo: vi.fn(),
},
repos: {
listBranches: vi.fn(),
listTags: vi.fn(),
},
},
},
@@ -59,9 +66,14 @@ beforeEach(() => {
mockState.octokit.rest.pulls.listFiles.mockReset();
mockState.octokit.rest.issues.listEventsForTimeline.mockReset();
mockState.octokit.rest.issues.createComment.mockReset();
mockState.octokit.rest.issues.create.mockReset();
mockState.octokit.rest.issues.update.mockReset();
mockState.octokit.rest.issues.listMilestonesForRepo.mockReset();
mockState.octokit.rest.issues.listComments.mockReset();
mockState.octokit.rest.issues.listAssignees.mockReset();
mockState.octokit.rest.issues.listLabelsForRepo.mockReset();
mockState.octokit.rest.repos.listBranches.mockReset();
mockState.octokit.rest.repos.listTags.mockReset();
mockState.getOctokitOrNull.mockImplementation(() => mockState.octokit);
});
@@ -628,3 +640,235 @@ describe('GitHub write routes', () => {
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
});
});
describe('GitHub issues/create route', () => {
test('issues/create calls issues.create and returns the created issue', async () => {
mockState.octokit.rest.issues.create.mockResolvedValue({
data: {
number: 12,
title: 'Add feature',
html_url: 'https://github.com/owner/repo/issues/12',
state: 'open',
body: 'The body',
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
user: { login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
labels: [{ name: 'bug', color: 'd73a4a' }],
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/issues/create')
.send({ directory: '/tmp/work', title: 'Add feature', body: 'The body', labels: ['bug'] });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
issue: {
number: 12,
title: 'Add feature',
url: 'https://github.com/owner/repo/issues/12',
state: 'open',
body: 'The body',
author: { login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' },
labels: [{ name: 'bug', color: 'd73a4a' }],
},
});
expect(mockState.octokit.rest.issues.create).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
title: 'Add feature',
body: 'The body',
labels: ['bug'],
});
});
test('issues/create omits body and labels when not provided', async () => {
mockState.octokit.rest.issues.create.mockResolvedValue({
data: {
number: 13,
title: 'Title only',
html_url: 'https://github.com/owner/repo/issues/13',
state: 'open',
user: null,
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/issues/create')
.send({ directory: '/tmp/work', title: 'Title only' });
expect(response.status).toBe(200);
expect(response.body.issue.title).toBe('Title only');
expect(mockState.octokit.rest.issues.create).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
title: 'Title only',
});
});
test('issues/create returns connected:false when not authenticated', async () => {
mockState.getOctokitOrNull.mockImplementation(() => null);
const app = createApp();
const response = await request(app)
.post('/api/github/issues/create')
.send({ directory: '/tmp/work', title: 'Hi' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
expect(mockState.octokit.rest.issues.create).not.toHaveBeenCalled();
});
test('issues/create requires directory and title', async () => {
const app = createApp();
const response = await request(app)
.post('/api/github/issues/create')
.send({ directory: '/tmp/work' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory and title are required' });
});
});
describe('GitHub rich lookup routes', () => {
describe('users/search', () => {
test('maps assignable users and filters by query', async () => {
mockState.octokit.rest.issues.listAssignees.mockResolvedValue({
data: [
{ login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
{ login: 'bob', id: 43, avatar_url: 'https://avatars.githubusercontent.com/u/43' },
{ login: 'carol', id: 44, avatar_url: null, name: 'Carol Coder' },
],
});
const app = createApp();
const response = await request(app).get('/api/github/users/search?directory=%2Ftmp%2Fwork&query=al');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
users: [{ login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' }],
});
expect(mockState.octokit.rest.issues.listAssignees).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
per_page: 100,
});
});
test('returns connected:false when not authenticated', async () => {
mockState.getOctokitOrNull.mockImplementation(() => null);
const app = createApp();
const response = await request(app).get('/api/github/users/search?directory=%2Ftmp%2Fwork&query=al');
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false, users: [] });
});
});
describe('labels/search', () => {
test('maps repo labels and filters by query', async () => {
mockState.octokit.rest.issues.listLabelsForRepo.mockResolvedValue({
data: [
{ name: 'bug', color: 'd73a4a' },
{ name: 'feature', color: '0e8a16' },
],
});
const app = createApp();
const response = await request(app).get('/api/github/labels/search?directory=%2Ftmp%2Fwork&query=bug');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
labels: [{ name: 'bug', color: 'd73a4a' }],
});
expect(mockState.octokit.rest.issues.listLabelsForRepo).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
per_page: 100,
});
});
});
describe('milestones/search', () => {
test('maps milestone titles and states', async () => {
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({
data: [
{ title: 'v1.0', state: 'open' },
{ title: 'v2.0', state: 'closed' },
],
});
const app = createApp();
const response = await request(app).get('/api/github/milestones/search?directory=%2Ftmp%2Fwork&query=v1');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
milestones: [{ title: 'v1.0', state: 'open' }],
});
expect(mockState.octokit.rest.issues.listMilestonesForRepo).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
state: 'all',
per_page: 100,
});
});
});
describe('branches/search', () => {
test('aggregates branch names across pages and filters by query', async () => {
mockState.octokit.rest.repos.listBranches
.mockResolvedValueOnce({ data: [{ name: 'main' }, { name: 'feat/x' }] })
.mockResolvedValueOnce({ data: [] });
const app = createApp();
const response = await request(app).get('/api/github/branches/search?directory=%2Ftmp%2Fwork&query=main');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
branches: ['main'],
});
expect(mockState.octokit.rest.repos.listBranches).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
per_page: 100,
page: 1,
});
});
});
describe('tags/search', () => {
test('maps tag names and filters by query', async () => {
mockState.octokit.rest.repos.listTags.mockResolvedValue({
data: [{ name: 'v1.0.0' }, { name: 'v1.1.0' }],
});
const app = createApp();
const response = await request(app).get('/api/github/tags/search?directory=%2Ftmp%2Fwork&query=v1.0');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
tags: ['v1.0.0'],
});
expect(mockState.octokit.rest.repos.listTags).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
per_page: 100,
});
});
});
});