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
+308 -2
View File
@@ -35,12 +35,14 @@ import {
mapGiteaCommits,
mapGiteaComment,
mapGiteaContext,
mapGiteaAssignee,
mapGiteaIssue,
mapGiteaPr,
mapGiteaRepoRef,
mapGiteaReviewsToEvents,
mapGiteaReview,
mapGiteaStatuses,
mapGithubAssignee,
mapGithubCommits,
mapGithubContext,
mapGithubIssue,
@@ -53,6 +55,7 @@ import {
mapGitlabCommits,
mapGitlabContext,
mapGitlabIssue,
mapGitlabMember,
mapGitlabMr,
mapGitlabNoteComment,
mapGitlabRepoRef,
@@ -69,6 +72,11 @@ const GITHUB_CAPABILITIES: ForgeProviderCapabilities = {
timelineEvents: true,
inlineComments: true,
threads: true,
userSearch: true,
labelSearch: true,
milestoneSearch: true,
branchSearch: true,
tagSearch: true,
};
const GITLAB_CAPABILITIES: ForgeProviderCapabilities = {
@@ -81,6 +89,11 @@ const GITLAB_CAPABILITIES: ForgeProviderCapabilities = {
timelineEvents: true,
inlineComments: false,
threads: true,
userSearch: true,
labelSearch: true,
milestoneSearch: true,
branchSearch: true,
tagSearch: true,
};
const GITEA_CAPABILITIES: ForgeProviderCapabilities = {
@@ -93,6 +106,11 @@ const GITEA_CAPABILITIES: ForgeProviderCapabilities = {
timelineEvents: true,
inlineComments: true,
threads: true,
userSearch: true,
labelSearch: true,
milestoneSearch: true,
branchSearch: true,
tagSearch: true,
};
// Gitea's 'commit-statuses' checks and inline comments land once Slice B adds
@@ -341,6 +359,79 @@ export const createGithubForgeProvider = (api: GitHubAPI): ForgeProvider => ({
return null;
},
async searchUsers(directory, query, options) {
if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
try {
const result = await api.searchUsers(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
return {
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
users: (result.users ?? []).map(mapGithubAssignee),
};
} catch {
return { connected: false, repo: null, users: [], error: LOAD_ERROR };
}
},
async searchLabels(directory, query, options) {
if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
try {
const result = await api.searchLabels(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
return {
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
labels: (result.labels ?? []).map((label) => ({ name: label.name, color: label.color })),
};
} catch {
return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
}
},
async searchMilestones(directory, query, options) {
if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
try {
const result = await api.searchMilestones(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
return {
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
milestones: (result.milestones ?? []).map((milestone) => ({
title: milestone.title,
...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
})),
};
} catch {
return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
}
},
async searchBranches(directory, query, options) {
if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
try {
const result = await api.searchBranches(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
return {
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
branches: result.branches ?? [],
};
} catch {
return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
}
},
async searchTags(directory, query, options) {
if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
try {
const result = await api.searchTags(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
return {
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
tags: result.tags ?? [],
};
} catch {
return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
}
},
async addComment(directory, ref, input, options) {
const selector = parseOwnerRepo(options?.sourceRepo);
const owner = selector?.owner;
@@ -365,6 +456,25 @@ export const createGithubForgeProvider = (api: GitHubAPI): ForgeProvider => ({
}
},
async createIssue(directory, input, options) {
if (!api.issueCreate) return { ok: false, error: WRITE_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.issueCreate({
directory,
title: input.title,
...(input.body !== undefined ? { body: input.body } : {}),
...(input.labels !== undefined ? { labels: input.labels } : {}),
owner: selector?.owner,
repo: selector?.repo,
});
if (!result.connected) return { ok: false, error: WRITE_ERROR };
return { ok: true, issue: result.issue ? mapGithubIssue(result.issue) : null };
} catch {
return { ok: false, error: WRITE_ERROR };
}
},
async replyToThread(directory, ref, input, options) {
if (ref.kind !== 'pull') {
// Issues have no inline review comments; reply as a flat thread comment.
@@ -651,6 +761,84 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
return null;
},
async searchUsers(directory, query, options) {
if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
try {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
const result = await api.searchUsers(directory, query, { namespace, project });
return {
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
users: (result.users ?? []).map(mapGitlabMember),
};
} catch {
return { connected: false, repo: null, users: [], error: LOAD_ERROR };
}
},
async searchLabels(directory, query, options) {
if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
try {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
const result = await api.searchLabels(directory, query, { namespace, project });
return {
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
labels: (result.labels ?? []).map((name) => ({ name })),
};
} catch {
return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
}
},
async searchMilestones(directory, query, options) {
if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
try {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
const result = await api.searchMilestones(directory, query, { namespace, project });
return {
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
milestones: (result.milestones ?? []).map((milestone) => ({
title: milestone.title,
...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
})),
};
} catch {
return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
}
},
async searchBranches(directory, query, options) {
if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
try {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
const result = await api.searchBranches(directory, query, { namespace, project });
return {
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
branches: result.branches ?? [],
};
} catch {
return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
}
},
async searchTags(directory, query, options) {
if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
try {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
const result = await api.searchTags(directory, query, { namespace, project });
return {
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
tags: result.tags ?? [],
};
} catch {
return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
}
},
async addComment(directory, ref, input, options) {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
if (ref.kind === 'issue') {
@@ -673,6 +861,25 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
}
},
async createIssue(directory, input, options) {
if (!api.issueCreate) return { ok: false, error: WRITE_ERROR };
try {
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
const result = await api.issueCreate({
directory,
title: input.title,
...(input.body !== undefined ? { body: input.body } : {}),
...(input.labels !== undefined ? { labels: input.labels } : {}),
namespace,
project,
});
if (!result.connected) return { ok: false, error: WRITE_ERROR };
return { ok: true, issue: result.issue ? mapGitlabIssue(result.issue) : null };
} catch {
return { ok: false, error: WRITE_ERROR };
}
},
async replyToThread(directory, ref, input, options) {
// GitLab's note-reply API is not wired up yet; reply as a flat comment.
return this.addComment!(directory, ref, { body: input.body }, options);
@@ -754,12 +961,13 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
if (ref.kind === 'issue') {
if (!api.issueUpdate) return { ok: false, error: WRITE_ERROR };
try {
// GitLab assigns by user ID, not login; the facade takes logins, so
// assignees are left unset until an id lookup exists.
// GitLab assigns by user ID; the server resolves the facade's login
// list to IDs via project members (see gitlab routes resolveAssigneeIds).
const result = await api.issueUpdate({
directory,
number: ref.number,
labels: input.labels,
assignees: input.assignees,
milestone: input.milestone,
namespace,
project,
@@ -776,6 +984,7 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
directory,
number: ref.number,
labels: input.labels,
assignees: input.assignees,
milestone: input.milestone,
});
return { ok: true, entity: mapGitlabMr(mr) };
@@ -926,6 +1135,84 @@ export const createGiteaForgeProvider = (api: GiteaAPI): ForgeProvider => ({
}
},
async searchUsers(directory, query, options) {
if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.searchUsers(directory, query, { owner: selector?.owner, repo: selector?.repo });
return {
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
users: (result.users ?? []).map(mapGiteaAssignee),
};
} catch {
return { connected: false, repo: null, users: [], error: LOAD_ERROR };
}
},
async searchLabels(directory, query, options) {
if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.searchLabels(directory, query, { owner: selector?.owner, repo: selector?.repo });
return {
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
labels: (result.labels ?? []).map((label) => ({ name: label.name, color: label.color })),
};
} catch {
return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
}
},
async searchMilestones(directory, query, options) {
if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.searchMilestones(directory, query, { owner: selector?.owner, repo: selector?.repo });
return {
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
milestones: (result.milestones ?? []).map((milestone) => ({
title: milestone.title,
...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
})),
};
} catch {
return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
}
},
async searchBranches(directory, query, options) {
if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.searchBranches(directory, query, { owner: selector?.owner, repo: selector?.repo });
return {
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
branches: result.branches ?? [],
};
} catch {
return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
}
},
async searchTags(directory, query, options) {
if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.searchTags(directory, query, { owner: selector?.owner, repo: selector?.repo });
return {
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
tags: result.tags ?? [],
};
} catch {
return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
}
},
async getChecks(directory, number, options) {
if (!api.prStatuses) return EMPTY_CHECKS;
try {
@@ -968,6 +1255,25 @@ export const createGiteaForgeProvider = (api: GiteaAPI): ForgeProvider => ({
}
},
async createIssue(directory, input, options) {
if (!api.issueCreate) return { ok: false, error: WRITE_ERROR };
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.issueCreate({
directory,
title: input.title,
...(input.body !== undefined ? { body: input.body } : {}),
...(input.labels !== undefined ? { labels: input.labels } : {}),
owner: selector?.owner,
repo: selector?.repo,
});
if (!result.connected) return { ok: false, error: WRITE_ERROR };
return { ok: true, issue: result.issue ? mapGiteaIssue(result.issue) : null };
} catch {
return { ok: false, error: WRITE_ERROR };
}
},
async replyToThread(directory, ref, input, options) {
// Gitea's thread-reply API is not wired up yet; reply as a flat comment.
return this.addComment!(directory, ref, { body: input.body }, options);
+358 -2
View File
@@ -24,6 +24,7 @@ import {
aggregateStatusState,
firstLine,
mapCheckRunState,
mapGiteaAssignee,
mapGiteaCommits,
mapGiteaComment,
mapGiteaContext,
@@ -32,6 +33,7 @@ import {
mapGiteaReviewsToEvents,
mapGiteaReview,
mapGiteaStatuses,
mapGithubAssignee,
mapGithubCheckSummary,
mapGithubCommits,
mapGithubContext,
@@ -44,6 +46,7 @@ import {
mapGitlabCommits,
mapGitlabContext,
mapGitlabIssue,
mapGitlabMember,
mapGitlabMr,
mapGitlabNoteComment,
mapGitlabTimelineEvents,
@@ -500,6 +503,37 @@ describe('gitea normalization', () => {
});
});
describe('repo-scoped lookup normalization', () => {
test('maps a GitHub repo assignee', () => {
expect(mapGithubAssignee(githubUser())).toEqual({
id: 'octocat',
login: 'octocat',
name: 'Octo Cat',
avatarUrl: 'https://avatars.example/octocat',
});
});
test('maps a GitLab project member', () => {
expect(mapGitlabMember(gitlabUser())).toEqual({
id: '5',
login: 'gluser',
name: 'GL User',
avatarUrl: 'https://avatars.example/gluser',
url: 'https://gitlab.example/gluser',
});
});
test('maps a Gitea repo assignee', () => {
expect(mapGiteaAssignee(giteaUser())).toEqual({
id: '3',
login: 'guser',
name: 'G User',
avatarUrl: 'https://avatars.example/guser',
url: 'https://gitea.example/guser',
});
});
});
// ---------------------------------------------------------------------------
// Rich-view normalization (commits / timeline / checks)
// ---------------------------------------------------------------------------
@@ -1126,7 +1160,7 @@ describe('write operations: updateMetadata', () => {
expect(issueArgs.milestone).toBe('v2.0');
});
test('gitlab sends labels/milestone but never assignees (id-based)', async () => {
test('gitlab sends labels/assignee logins/milestone; the server resolves logins to IDs', async () => {
let issueArgs: Record<string, unknown> = {};
const api = {
issueUpdate: async (input: Record<string, unknown>) => {
@@ -1142,7 +1176,7 @@ describe('write operations: updateMetadata', () => {
expect(result.ok).toBe(true);
expect(issueArgs.labels).toEqual(['frontend']);
expect(issueArgs.milestone).toBeNull();
expect(issueArgs.assignees).toBe(undefined);
expect(issueArgs.assignees).toEqual(['gluser']);
});
test('gitea issue metadata passes through; PR metadata is unsupported', async () => {
@@ -1246,6 +1280,11 @@ describe('buildForgeProvider', () => {
timelineEvents: true,
inlineComments: true,
threads: true,
userSearch: true,
labelSearch: true,
milestoneSearch: true,
branchSearch: true,
tagSearch: true,
});
});
@@ -1262,6 +1301,11 @@ describe('buildForgeProvider', () => {
timelineEvents: true,
inlineComments: false,
threads: true,
userSearch: true,
labelSearch: true,
milestoneSearch: true,
branchSearch: true,
tagSearch: true,
});
});
@@ -1278,6 +1322,11 @@ describe('buildForgeProvider', () => {
timelineEvents: true,
inlineComments: true,
threads: true,
userSearch: true,
labelSearch: true,
milestoneSearch: true,
branchSearch: true,
tagSearch: true,
});
});
});
@@ -1577,3 +1626,310 @@ describe('getForgeProviderForDirectory', () => {
expect(await getForgeProviderForDirectory('/repo')).toBeNull();
});
});
describe('write operations: createIssue', () => {
test('github passes title/body/labels and parses sourceRepo', async () => {
let args: Record<string, unknown> = {};
const api = {
issueCreate: async (input: Record<string, unknown>) => {
args = input;
return { connected: true, issue: githubIssue };
},
} as unknown as GitHubAPI;
const provider = createGithubForgeProvider(api);
const result = await provider.createIssue!(
'/repo',
{ title: 'Add feature', body: 'The body', labels: ['bug'] },
{ sourceRepo: 'upstream/widget' },
);
expect(result.ok).toBe(true);
expect(result.issue?.number).toBe(7);
expect(args).toEqual({
directory: '/repo',
title: 'Add feature',
body: 'The body',
labels: ['bug'],
owner: 'upstream',
repo: 'widget',
});
});
test('github omits optional fields and fails closed when the api is absent', async () => {
let called = false;
const api = {
issueCreate: async () => {
called = true;
return { connected: true, issue: githubIssue };
},
} as unknown as GitHubAPI;
const provider = createGithubForgeProvider(api);
const result = await provider.createIssue!('/repo', { title: 'T' });
expect(result.ok).toBe(true);
expect(called).toBe(true);
const empty = createGithubForgeProvider({} as unknown as GitHubAPI);
const failed = await empty.createIssue!('/repo', { title: 'T' });
expect(failed.ok).toBe(false);
});
test('gitlab passes namespace/project from a multi-segment sourceRepo', async () => {
let args: Record<string, unknown> = {};
const api = {
issueCreate: async (input: Record<string, unknown>) => {
args = input;
return { connected: true, issue: gitlabIssue };
},
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
const result = await provider.createIssue!(
'/repo',
{ title: 'Add feature', body: 'The body' },
{ sourceRepo: 'group/sub/proj' },
);
expect(result.ok).toBe(true);
expect(result.issue?.number).toBe(8);
expect(args).toEqual({
directory: '/repo',
title: 'Add feature',
body: 'The body',
namespace: 'group/sub',
project: 'proj',
});
});
test('gitea passes owner/repo and maps the created issue', async () => {
let args: Record<string, unknown> = {};
const api = {
issueCreate: async (input: Record<string, unknown>) => {
args = input;
return { connected: true, issue: giteaIssue };
},
} as unknown as GiteaAPI;
const provider = createGiteaForgeProvider(api);
const result = await provider.createIssue!('/repo', { title: 'Add feature', labels: ['bug'] });
expect(result.ok).toBe(true);
expect(result.issue?.number).toBe(12);
expect(args).toEqual({
directory: '/repo',
title: 'Add feature',
labels: ['bug'],
owner: undefined,
repo: undefined,
});
});
test('createIssue degrades to ok:false without throwing on wire failure', async () => {
const api = {
issueCreate: async () => {
throw new Error('boom');
},
} as unknown as GitHubAPI;
const provider = createGithubForgeProvider(api);
const result = await provider.createIssue!('/repo', { title: 'T' });
expect(result.ok).toBe(false);
expect(result.error).toBeTruthy();
});
});
describe('repo-scoped user search (searchUsers)', () => {
test('github parses sourceRepo and maps assignees by login', async () => {
let receivedDirectory = '';
let receivedQuery = '';
let receivedOptions: { sourceRepo?: { owner: string; repo: string } | null } | undefined;
const api = {
searchUsers: async (
directory: string,
query: string,
options?: { sourceRepo?: { owner: string; repo: string } | null },
) => {
receivedDirectory = directory;
receivedQuery = query;
receivedOptions = options;
return {
connected: true,
repo: { owner: 'acme', repo: 'widget', url: 'https://github.com/acme/widget' },
users: [githubUser()],
};
},
} as unknown as GitHubAPI;
const provider = createGithubForgeProvider(api);
const result = await provider.searchUsers!('/repo', 'octo', { sourceRepo: 'upstream/widget' });
expect(receivedDirectory).toBe('/repo');
expect(receivedQuery).toBe('octo');
expect(receivedOptions).toEqual({ sourceRepo: { owner: 'upstream', repo: 'widget' } });
expect(result.connected).toBe(true);
expect(result.repo?.owner).toBe('acme');
expect(result.users).toEqual([{
id: 'octocat',
login: 'octocat',
name: 'Octo Cat',
avatarUrl: 'https://avatars.example/octocat',
}]);
});
test('github passes connected:false through without an authoritative list', async () => {
const api = {
searchUsers: async () => ({ connected: false, repo: null, users: [] }),
} as unknown as GitHubAPI;
const provider = createGithubForgeProvider(api);
const result = await provider.searchUsers!('/repo', 'octo');
expect(result.connected).toBe(false);
expect(result.users).toEqual([]);
expect(result.error).toBeFalsy();
});
test('github fails closed with an error when searchUsers is missing', async () => {
const provider = createGithubForgeProvider({} as unknown as GitHubAPI);
expect(await provider.searchUsers!('/repo', 'octo')).toEqual({
connected: false,
repo: null,
users: [],
error: 'failed to load',
});
});
test('github fails closed with an error when the wire call throws', async () => {
const api = {
searchUsers: async () => { throw new Error('boom'); },
} as unknown as GitHubAPI;
const provider = createGithubForgeProvider(api);
expect(await provider.searchUsers!('/repo', 'octo')).toEqual({
connected: false,
repo: null,
users: [],
error: 'failed to load',
});
});
test('gitlab parses namespace/project from a multi-segment sourceRepo and maps members', async () => {
let receivedOptions: { namespace?: string; project?: string } | undefined;
const api = {
searchUsers: async (
_directory: string,
_query: string,
options?: { namespace?: string; project?: string },
) => {
receivedOptions = options;
return { connected: true, repo: null, users: [gitlabUser()] };
},
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
const result = await provider.searchUsers!('/repo', 'gl', { sourceRepo: 'group/sub/proj' });
expect(receivedOptions).toEqual({ namespace: 'group/sub', project: 'proj' });
expect(result.connected).toBe(true);
expect(result.users).toEqual([{
id: '5',
login: 'gluser',
name: 'GL User',
avatarUrl: 'https://avatars.example/gluser',
url: 'https://gitlab.example/gluser',
}]);
});
test('gitlab passes connected:false through without an authoritative list', async () => {
const api = {
searchUsers: async () => ({ connected: false, repo: null, users: [] }),
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
const result = await provider.searchUsers!('/repo', 'gl');
expect(result.connected).toBe(false);
expect(result.users).toEqual([]);
expect(result.error).toBeFalsy();
});
test('gitlab fails closed with an error when searchUsers is missing', async () => {
const provider = createGitlabForgeProvider({} as unknown as GitLabAPI);
expect(await provider.searchUsers!('/repo', 'gl')).toEqual({
connected: false,
repo: null,
users: [],
error: 'failed to load',
});
});
test('gitlab fails closed with an error when the wire call throws', async () => {
const api = {
searchUsers: async () => { throw new Error('boom'); },
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
expect(await provider.searchUsers!('/repo', 'gl')).toEqual({
connected: false,
repo: null,
users: [],
error: 'failed to load',
});
});
test('gitea parses owner/repo and maps repo assignees', async () => {
let receivedOptions: { owner?: string; repo?: string } | undefined;
const api = {
searchUsers: async (
_directory: string,
_query: string,
options?: { owner?: string; repo?: string },
) => {
receivedOptions = options;
return { connected: true, repo: null, users: [giteaUser()] };
},
} as unknown as GiteaAPI;
const provider = createGiteaForgeProvider(api);
const result = await provider.searchUsers!('/repo', 'g', { sourceRepo: 'acme/widget' });
expect(receivedOptions).toEqual({ owner: 'acme', repo: 'widget' });
expect(result.connected).toBe(true);
expect(result.users).toEqual([{
id: '3',
login: 'guser',
name: 'G User',
avatarUrl: 'https://avatars.example/guser',
url: 'https://gitea.example/guser',
}]);
});
test('gitea passes connected:false through without an authoritative list', async () => {
const api = {
searchUsers: async () => ({ connected: false, repo: null, users: [] }),
} as unknown as GiteaAPI;
const provider = createGiteaForgeProvider(api);
const result = await provider.searchUsers!('/repo', 'g');
expect(result.connected).toBe(false);
expect(result.users).toEqual([]);
expect(result.error).toBeFalsy();
});
test('gitea fails closed with an error when searchUsers is missing', async () => {
const provider = createGiteaForgeProvider({} as unknown as GiteaAPI);
expect(await provider.searchUsers!('/repo', 'g')).toEqual({
connected: false,
repo: null,
users: [],
error: 'failed to load',
});
});
test('gitea fails closed with an error when the wire call throws', async () => {
const api = {
searchUsers: async () => { throw new Error('boom'); },
} as unknown as GiteaAPI;
const provider = createGiteaForgeProvider(api);
expect(await provider.searchUsers!('/repo', 'g')).toEqual({
connected: false,
repo: null,
users: [],
error: 'failed to load',
});
});
});
+8
View File
@@ -41,6 +41,11 @@ export type {
ForgeCommitsResult,
ForgeTimelineResult,
ForgeChecksResult,
ForgeUsersResult,
ForgeLabelsResult,
ForgeMilestonesResult,
ForgeBranchesResult,
ForgeTagsResult,
ForgeProvider,
} from './provider';
@@ -50,6 +55,7 @@ export {
firstLine,
normalizeEventType,
mapGithubUser,
mapGithubAssignee,
mapGithubPr,
mapGithubIssue,
mapGithubIssueComment,
@@ -60,6 +66,7 @@ export {
mapGithubCommits,
mapGithubTimelineEvents,
mapGitlabUser,
mapGitlabMember,
mapGitlabMr,
mapGitlabIssue,
mapGitlabNoteComment,
@@ -68,6 +75,7 @@ export {
mapGitlabCommits,
mapGitlabTimelineEvents,
mapGiteaUser,
mapGiteaAssignee,
mapGiteaPr,
mapGiteaIssue,
mapGiteaComment,
+30
View File
@@ -117,6 +117,36 @@ export const mapGiteaUser = (user: GiteaUserSummary): ForgeUser => ({
url: user.webUrl,
});
// ---------------------------------------------------------------------------
// Repo-scoped lookup results
// ---------------------------------------------------------------------------
/** Map a GitHub repo assignee item onto `ForgeUser` (same shape as GitHubUserSummary). */
export const mapGithubAssignee = (assignee: GitHubUserSummary): ForgeUser => ({
id: assignee.login,
login: assignee.login,
name: assignee.name,
avatarUrl: assignee.avatarUrl,
});
/** Map a GitLab project member (wire `members/all` item) onto `ForgeUser`. */
export const mapGitlabMember = (member: GitLabUserSummary): ForgeUser => ({
id: String(member.id ?? member.username),
login: member.username,
name: member.name,
avatarUrl: member.avatarUrl,
url: member.webUrl,
});
/** Map a Gitea repo-assignee item onto `ForgeUser` (same shape as GiteaUserSummary). */
export const mapGiteaAssignee = (assignee: GiteaUserSummary): ForgeUser => ({
id: String(assignee.id ?? assignee.username),
login: assignee.username,
name: assignee.name,
avatarUrl: assignee.avatarUrl,
url: assignee.webUrl,
});
// ---------------------------------------------------------------------------
// Pull requests / merge requests
// ---------------------------------------------------------------------------
+110
View File
@@ -4,12 +4,15 @@ import type {
ForgeCommit,
ForgeFileChange,
ForgeIssue,
ForgeLabel,
ForgeMilestone,
ForgeProviderCapabilities,
ForgeProviderKind,
ForgePullRequest,
ForgeRepoRef,
ForgeReview,
ForgeTimelineEvent,
ForgeUser,
} from './types';
/**
@@ -95,6 +98,51 @@ export interface ForgeTimelineResult {
error?: string | null;
}
/**
* Repo-scoped user lookup for mentions/assignees. Wraps the per-provider
* assignable-user endpoints (github `issues.listAssignees`, gitlab project
* members, gitea repo assignees). `connected: false` means the lookup failed —
* never treat it as an authoritative empty list.
*/
export interface ForgeUsersResult {
connected: boolean;
repo?: ForgeRepoRef | null;
users: ForgeUser[];
error?: string | null;
}
/** Repo-scoped label lookup for pickers. */
export interface ForgeLabelsResult {
connected: boolean;
repo?: ForgeRepoRef | null;
labels: ForgeLabel[];
error?: string | null;
}
/** Repo-scoped milestone lookup for pickers. */
export interface ForgeMilestonesResult {
connected: boolean;
repo?: ForgeRepoRef | null;
milestones: ForgeMilestone[];
error?: string | null;
}
/** Repo-scoped branch lookup. */
export interface ForgeBranchesResult {
connected: boolean;
repo?: ForgeRepoRef | null;
branches: string[];
error?: string | null;
}
/** Repo-scoped tag lookup. */
export interface ForgeTagsResult {
connected: boolean;
repo?: ForgeRepoRef | null;
tags: string[];
error?: string | null;
}
/** Rolled-up checks for a PR/MR; only non-null for providers with a dedicated checks surface. */
export interface ForgeChecksResult {
connected: boolean;
@@ -135,6 +183,20 @@ export interface ForgeCommentResult {
comment?: ForgeComment | null;
}
/** Input for creating an issue; `labels` is a full-set list of label names. */
export interface ForgeIssueCreateInput {
title: string;
body?: string;
labels?: string[];
}
/** Result of creating an issue; `issue` is the created entity when `ok`. */
export interface ForgeIssueCreateResult {
ok: boolean;
error?: string | null;
issue?: ForgeIssue | null;
}
/** Result of an entity update; `entity` is the refreshed issue/PR when `ok`. */
export interface ForgeUpdateResult {
ok: boolean;
@@ -230,6 +292,43 @@ export interface ForgeProvider {
*/
getChecks?(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeChecksResult | null>;
// --- Rich lookups (pickers / mentions) ---
//
// Repo-scoped searches for the fields the UI offers as pickers. Every method
// resolves the target repo from the working directory (remotes + connected
// accounts) and takes a directory argument, matching the per-provider APIs.
// All are optional and capability-flagged (`capabilities.userSearch`,
// `.labelSearch`, `.milestoneSearch`, `.branchSearch`, `.tagSearch`); the UI
// gates on method presence plus the flag before enabling an affordance.
// Results return `{ connected: false }` — never throw — when the runtime API
// is missing, the wire call fails, or the provider cannot resolve the repo.
/**
* Search the repo's assignable users (matches for assignees and mentions).
* Wraps github `issues.listAssignees`, gitlab project members, and gitea repo
* assignees; `query` is a free-text substring (case-insensitive). `sourceRepo`
* selects a cross-repo (fork) repository.
*/
searchUsers?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeUsersResult>;
/**
* Search the repo's labels. Wraps github `issues.listLabelsForRepo`, gitlab
* project labels, and gitea repo labels.
*/
searchLabels?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeLabelsResult>;
/**
* Search the repo's milestones. Wraps github `issues.listMilestonesForRepo`,
* gitlab project milestones, and gitea repo milestones.
*/
searchMilestones?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeMilestonesResult>;
/** Search the repo's branches. */
searchBranches?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeBranchesResult>;
/** Search the repo's tags. */
searchTags?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeTagsResult>;
// --- Write operations ---
//
// Every write method is optional and capability-flagged: the UI gates on
@@ -252,6 +351,17 @@ export interface ForgeProvider {
options?: { sourceRepo?: string | null },
): Promise<ForgeCommentResult>;
/**
* Create a new issue in the repository. Wraps `github issueCreate`, `gitlab
* issueCreate`, and `gitea issueCreate`; labels are a full-set list of names
* on the create payload (provider-dependent support).
*/
createIssue?(
directory: string,
input: ForgeIssueCreateInput,
options?: { sourceRepo?: string | null },
): Promise<ForgeIssueCreateResult>;
/**
* Reply to a comment thread. On GitHub this posts a proper inline
* review-comment reply via `prReviewComment` (anchored on `inReplyToId`);
+10
View File
@@ -45,6 +45,16 @@ export interface ForgeProviderCapabilities {
inlineComments: boolean;
/** Comment threads that can be replied to. */
threads: boolean;
/** Repo-scoped user search (assignable users) for mentions/assignees. */
userSearch: boolean;
/** Repo-scoped label search for the label picker. */
labelSearch: boolean;
/** Repo-scoped milestone search for the milestone picker. */
milestoneSearch: boolean;
/** Repo-scoped branch search. */
branchSearch: boolean;
/** Repo-scoped tag search. */
tagSearch: boolean;
}
/** A person as surfaced by the forge (issue author, reviewer, commit author, ...). */