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
@@ -107,6 +107,7 @@
| PATCH | `/api/gitea/pr/update` | body `{ directory, number, title?, description?, state? }` -> `{ connected, repo?, pr }`; `404` when the PR does not exist |
| POST | `/api/gitea/pr/merge` | body `{ directory, number, method? }` -> `{ connected, merged: true }` on success; non-mergeable PRs -> the Gitea status (`405`/`409`/`422`) with `{ connected, merged: false, message }` |
| POST | `/api/gitea/issues/comment` | body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment }` |
| POST | `/api/gitea/issues/create` | body `{ directory, title, body?, labels?, owner?, repo? }` -> `{ connected, repo?, issue }` |
| PATCH | `/api/gitea/issues/update` | body `{ directory, number, title?, body?, state?, labels?, assignees?, milestone?, owner?, repo? }` -> `{ connected, repo?, issue }`; `400 'Milestone not found'` when a milestone title does not match |
| POST | `/api/gitea/prs/comment` | body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment }` (PRs are issues at the API level, so the PR number is the issue index) |
| POST | `/api/gitea/prs/review` | body `{ directory, number, event, body?, owner?, repo? }` -> `{ connected, repo?, review }`; `400` when `event` is not `APPROVED`/`REQUEST_CHANGES`/`COMMENT` |
+8
View File
@@ -265,6 +265,8 @@ export function createGiteaClient({ token, baseUrl }) {
request(`/repos/${owner}/${repo}/issues/${number}/comments`, { query: params }),
createIssueComment: (owner, repo, number, body) =>
request(`/repos/${owner}/${repo}/issues/${number}/comments`, { method: 'POST', body: { body } }),
createIssue: (owner, repo, params) =>
request(`/repos/${owner}/${repo}/issues`, { method: 'POST', body: params }),
updateIssue: (owner, repo, number, params) =>
request(`/repos/${owner}/${repo}/issues/${number}`, { method: 'PATCH', body: params }),
milestones: (owner, repo, params = {}) =>
@@ -295,6 +297,12 @@ export function createGiteaClient({ token, baseUrl }) {
request(`/repos/${owner}/${repo}/pulls/${number}/merge`, { method: 'POST', body }),
branches: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/branches`, { query: params }),
// Assignable users (collaborators with role access + org members) are the
// mention/assign candidate set; Gitea mirrors the GitHub assignees route.
assignees: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/assignees`, { query: params }),
tags: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/tags`, { query: params }),
};
}
+267
View File
@@ -569,6 +569,60 @@ export function registerGiteaRoutes(app, options = {}) {
}
});
app.post('/api/gitea/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 requestedRepo = getRequestedRepo(req);
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
if (!owner || !repo) {
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
}
const params = {
title,
...(body !== undefined ? { body } : {}),
...(labels !== undefined ? { labels } : {}),
};
const resp = await client.createIssue(owner, repo, params);
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status === 403) {
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to create issues' });
}
if (resp.status !== 200 && resp.status !== 201) {
const status = resp.status >= 500 ? 500 : 400;
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while creating the issue' });
}
if (!resp.data) {
return res.status(500).json({ error: 'Gitea returned an empty response while creating the issue' });
}
return res.json({
connected: true,
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
issue: mapGiteaIssue(resp.data),
});
} catch (error) {
console.error('Failed to create Gitea issue:', error);
return res.status(500).json({ error: error.message || 'Failed to create Gitea issue' });
}
});
app.patch('/api/gitea/issues/update', async (req, res) => {
try {
const directory = asString(req.body?.directory);
@@ -1418,4 +1472,217 @@ export function registerGiteaRoutes(app, options = {}) {
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea repo labels' });
}
});
// ================= Gitea Rich Lookup APIs =================
// Repo-scoped lookups for pickers/mentions. Each resolves the target repo
// (directory remote + owner/repo override) and hits a Gitea 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.
const lookupRepo = async (req) => {
const directory = asString(req.query?.directory);
const requestedRepo = getRequestedRepo(req);
if (!directory && !requestedRepo) {
return { error: 'directory or owner/repo is required' };
}
const client = await getClient();
if (!client) {
return { client: null };
}
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
return { client, owner, repo, repoRef };
};
app.get('/api/gitea/users/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupRepo(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, users: [] });
}
const { client, owner, repo, repoRef } = resolved;
if (!owner || !repo) {
return res.json({ connected: true, repo: null, users: [] });
}
const resp = await withTimeout(client.assignees(owner, repo, { limit: 100 }), ROUTE_TIMEOUT_MS, 'gitea users search');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'Gitea returned an error while searching users' });
}
const needle = query.toLowerCase();
const users = (Array.isArray(resp.data) ? resp.data : [])
.map(mapGiteaUser)
.filter((user) => user && user.username)
.filter((user) => !needle || user.username.toLowerCase().includes(needle) || (user.name || '').toLowerCase().includes(needle));
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), users });
} catch (error) {
console.error('Failed to search Gitea users:', error);
return res.status(500).json({ error: error.message || 'Failed to search Gitea users' });
}
});
app.get('/api/gitea/labels/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupRepo(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, labels: [] });
}
const { client, owner, repo, repoRef } = resolved;
if (!owner || !repo) {
return res.json({ connected: true, repo: null, labels: [] });
}
const resp = await withTimeout(client.repoLabels(owner, repo, { limit: 100 }), ROUTE_TIMEOUT_MS, 'gitea labels search');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'Gitea returned an error while searching labels' });
}
const needle = query.toLowerCase();
const labels = (Array.isArray(resp.data) ? resp.data : [])
.map((label) => ({
...(typeof label.id === 'number' ? { id: label.id } : {}),
name: typeof label.name === 'string' ? label.name : '',
...(typeof label.color === 'string' ? { color: label.color } : {}),
...(typeof label.description === 'string' ? { description: label.description } : {}),
}))
.filter((label) => label.name && (!needle || label.name.toLowerCase().includes(needle)));
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), labels });
} catch (error) {
console.error('Failed to search Gitea labels:', error);
return res.status(500).json({ error: error.message || 'Failed to search Gitea labels' });
}
});
app.get('/api/gitea/milestones/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupRepo(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, milestones: [] });
}
const { client, owner, repo, repoRef } = resolved;
if (!owner || !repo) {
return res.json({ connected: true, repo: null, milestones: [] });
}
const resp = await withTimeout(client.milestones(owner, repo, { state: 'all', limit: 100 }), ROUTE_TIMEOUT_MS, 'gitea milestones search');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'Gitea returned an error while searching milestones' });
}
const needle = query.toLowerCase();
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 && (!needle || item.title.toLowerCase().includes(needle)));
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), milestones });
} catch (error) {
console.error('Failed to search Gitea milestones:', error);
return res.status(500).json({ error: error.message || 'Failed to search Gitea milestones' });
}
});
app.get('/api/gitea/branches/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupRepo(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, branches: [] });
}
const { client, owner, repo, repoRef } = resolved;
if (!owner || !repo) {
return res.json({ connected: true, repo: null, branches: [] });
}
const branches = [];
const needle = query.toLowerCase();
let page = 1;
while (page <= 10) {
const resp = await withTimeout(client.branches(owner, repo, { limit: 50, page }), ROUTE_TIMEOUT_MS, 'gitea branches search');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status !== 200 || !Array.isArray(resp.data)) {
break;
}
const chunk = resp.data;
for (const branch of chunk) {
const name = typeof branch?.name === 'string' ? branch.name : '';
if (!name) continue;
if (!needle || name.toLowerCase().includes(needle)) branches.push(name);
}
if (chunk.length < 50 || !resp.page?.hasMore) {
break;
}
page += 1;
}
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), branches });
} catch (error) {
console.error('Failed to search Gitea branches:', error);
return res.status(500).json({ error: error.message || 'Failed to search Gitea branches' });
}
});
app.get('/api/gitea/tags/search', async (req, res) => {
try {
const query = asString(req.query?.query);
const resolved = await lookupRepo(req);
if (resolved.error) {
return res.status(400).json({ error: resolved.error });
}
if (!resolved.client) {
return res.json({ connected: false, tags: [] });
}
const { client, owner, repo, repoRef } = resolved;
if (!owner || !repo) {
return res.json({ connected: true, repo: null, tags: [] });
}
const resp = await withTimeout(client.tags(owner, repo, { limit: 100 }), ROUTE_TIMEOUT_MS, 'gitea tags search');
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'Gitea returned an error while searching tags' });
}
const needle = query.toLowerCase();
const tags = (Array.isArray(resp.data) ? resp.data : [])
.map((tag) => (typeof tag?.name === 'string' ? tag.name : ''))
.filter(Boolean)
.filter((name) => !needle || name.toLowerCase().includes(needle));
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), tags });
} catch (error) {
console.error('Failed to search Gitea tags:', error);
return res.status(500).json({ error: error.message || 'Failed to search Gitea tags' });
}
});
}
@@ -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 () => {
@@ -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,
});
});
});
});
@@ -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 () => {
+94
View File
@@ -306,4 +306,98 @@ describe('createWebGiteaAPI', () => {
const api = await createAPI();
await expect(api.issuesList('/workspace')).rejects.toThrow('Internal Server Error');
});
it('passes directory/query/owner/repo to searchUsers and maps the response', async () => {
const result = {
connected: true,
repo: null,
users: [{ username: 'octocat', id: 1, name: 'Octo Cat', avatarUrl: 'https://gitea.example/octocat.png' }],
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'octo', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('omits owner/repo from searchUsers when not provided', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, repo: null, users: [] }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
const params = new URLSearchParams({ directory: '/workspace', query: 'octo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('throws the server error message when searchUsers fails', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'Gitea rate limited' }, { status: 503 }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).rejects.toThrow('Gitea rate limited');
});
it('passes directory/query to searchLabels and maps the response', async () => {
const result = { connected: true, repo: null, labels: [{ name: 'bug', color: 'd73a4a' }] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchLabels!('/workspace', 'feat', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/labels/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchMilestones and maps the response', async () => {
const result = { connected: true, repo: null, milestones: [{ title: 'v2.0', state: 'open' }] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchMilestones!('/workspace', 'v2', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'v2', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/milestones/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchBranches and maps the response', async () => {
const result = { connected: true, repo: null, branches: ['main', 'feat/api'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchBranches!('/workspace', 'feat', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/branches/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchTags and maps the response', async () => {
const result = { connected: true, repo: null, tags: ['v1.0', 'v1.1'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchTags!('/workspace', 'v1', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'v1', owner: 'group', repo: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/tags/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
});
+80
View File
@@ -2,13 +2,18 @@ import type {
GiteaAPI,
GiteaAuthStatus,
GiteaBranchesResult,
GiteaBranchesSearchResult,
GiteaIssueCommentInput,
GiteaIssueCommentResult,
GiteaIssueCommentsResult,
GiteaIssueCreateInput,
GiteaIssueCreateResult,
GiteaIssueGetResult,
GiteaIssuesListResult,
GiteaIssueUpdateInput,
GiteaIssueUpdateResult,
GiteaLabelsSearchResult,
GiteaMilestonesSearchResult,
GiteaPullRequest,
GiteaPullRequestCommitsResult,
GiteaPullRequestContextResult,
@@ -22,7 +27,9 @@ import type {
GiteaPullReviewInput,
GiteaPullReviewResult,
GiteaRepoLabelsResult,
GiteaTagsSearchResult,
GiteaUserSummary,
GiteaUsersSearchResult,
} from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
@@ -98,6 +105,66 @@ export const createWebGiteaAPI = ({ urls }: WebGiteaAPIOptions): GiteaAPI => ({
return payload;
},
async searchUsers(directory, query, options): Promise<GiteaUsersSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/users/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaUsersSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea users');
}
return { connected: body.connected, repo: body.repo ?? null, users: body.users ?? [] };
},
async searchLabels(directory, query, options): Promise<GiteaLabelsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/labels/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaLabelsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea labels');
}
return { connected: body.connected, repo: body.repo ?? null, labels: body.labels ?? [] };
},
async searchMilestones(directory, query, options): Promise<GiteaMilestonesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/milestones/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaMilestonesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea milestones');
}
return { connected: body.connected, repo: body.repo ?? null, milestones: body.milestones ?? [] };
},
async searchBranches(directory, query, options): Promise<GiteaBranchesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/branches/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaBranchesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea branches');
}
return { connected: body.connected, repo: body.repo ?? null, branches: body.branches ?? [] };
},
async searchTags(directory, query, options): Promise<GiteaTagsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.owner) params.set('owner', options.owner);
if (options?.repo) params.set('repo', options.repo);
const response = await runtimeFetch(urls.api('/api/gitea/tags/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GiteaTagsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search Gitea tags');
}
return { connected: body.connected, repo: body.repo ?? null, tags: body.tags ?? [] };
},
async issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GiteaIssuesListResult> {
const page = options?.page ?? 1;
const params = new URLSearchParams({
@@ -307,6 +374,19 @@ export const createWebGiteaAPI = ({ urls }: WebGiteaAPIOptions): GiteaAPI => ({
return body;
},
async issueCreate(input: GiteaIssueCreateInput): Promise<GiteaIssueCreateResult> {
const response = await runtimeFetch('/api/gitea/issues/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GiteaIssueCreateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to create Gitea issue');
}
return body;
},
async issueUpdate(input: GiteaIssueUpdateInput): Promise<GiteaIssueUpdateResult> {
const response = await runtimeFetch('/api/gitea/issues/update', {
method: 'PATCH',
+99
View File
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { RuntimeUrlQuery, RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
const runtimeFetchMock = vi.fn();
vi.mock('@openchamber/ui/lib/runtime-fetch', () => ({
runtimeFetch: runtimeFetchMock,
}));
const toUrl = (path: string, query?: RuntimeUrlQuery): string => {
const params = query instanceof URLSearchParams ? query : new URLSearchParams();
const queryString = params.toString();
return queryString ? `${path}?${queryString}` : path;
};
const urls: RuntimeUrlResolver = {
api: toUrl,
authenticatedAsset: toUrl,
auth: toUrl,
health: (query?: RuntimeUrlQuery) => toUrl('/health', query),
rawFile: (path: string) => toUrl('/api/fs/raw', new URLSearchParams({ path })),
sse: toUrl,
websocket: toUrl,
};
const createAPI = async () => {
const { createWebGitHubAPI } = await import('./github');
return createWebGitHubAPI({ urls });
};
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
runtimeFetchMock.mockReset();
});
describe('createWebGitHubAPI', () => {
it('passes directory/query/owner/repo to searchUsers and maps the response', async () => {
const result = {
connected: true,
repo: null,
users: [{ login: 'octocat', id: 1, name: 'Octo Cat', avatarUrl: 'https://avatars.example/octocat.png' }],
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo', { sourceRepo: { owner: 'acme', repo: 'widget' } }))
.resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'octo', owner: 'acme', repo: 'widget' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/github/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('omits owner/repo from searchUsers when no sourceRepo is provided', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, repo: null, users: [] }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
const params = new URLSearchParams({ directory: '/workspace', query: 'octo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/github/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('defaults missing repo/users fields to null/empty when the response omits them', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
});
it('throws the server error message when searchUsers fails', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'GitHub rate limited' }, { status: 503 }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).rejects.toThrow('GitHub rate limited');
});
it('passes directory/query to searchLabels and maps the response', async () => {
const result = { connected: true, repo: null, labels: [{ name: 'bug', color: 'd73a4a' }] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchLabels!('/workspace', 'feat', { sourceRepo: { owner: 'acme', repo: 'widget' } }))
.resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', owner: 'acme', repo: 'widget' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/github/labels/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
});
+90
View File
@@ -1,13 +1,18 @@
import type {
GitHubAPI,
GitHubAuthStatus,
GitHubBranchesSearchResult,
GitHubIssueCommentsResult,
GitHubIssueCommentInput,
GitHubIssueCommentResult,
GitHubIssueCreateInput,
GitHubIssueCreateResult,
GitHubIssueGetResult,
GitHubIssueUpdateInput,
GitHubIssueUpdateResult,
GitHubIssuesListResult,
GitHubLabelsSearchResult,
GitHubMilestonesSearchResult,
GitHubPullRequestContextResult,
GitHubPullRequestCommitsResult,
GitHubPullRequestTimelineResult,
@@ -27,7 +32,9 @@ import type {
GitHubReviewCommentResult,
GitHubDeviceFlowComplete,
GitHubDeviceFlowStart,
GitHubTagsSearchResult,
GitHubUserSummary,
GitHubUsersSearchResult,
} from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
@@ -120,6 +127,76 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
return payload;
},
async searchUsers(directory, query, options): Promise<GitHubUsersSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/users/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubUsersSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub users');
}
return { connected: body.connected, repo: body.repo ?? null, users: body.users ?? [] };
},
async searchLabels(directory, query, options): Promise<GitHubLabelsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/labels/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubLabelsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub labels');
}
return { connected: body.connected, repo: body.repo ?? null, labels: body.labels ?? [] };
},
async searchMilestones(directory, query, options): Promise<GitHubMilestonesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/milestones/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubMilestonesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub milestones');
}
return { connected: body.connected, repo: body.repo ?? null, milestones: body.milestones ?? [] };
},
async searchBranches(directory, query, options): Promise<GitHubBranchesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/branches/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubBranchesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub branches');
}
return { connected: body.connected, repo: body.repo ?? null, branches: body.branches ?? [] };
},
async searchTags(directory, query, options): Promise<GitHubTagsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/tags/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubTagsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitHub tags');
}
return { connected: body.connected, repo: body.repo ?? null, tags: body.tags ?? [] };
},
async prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus> {
const params = new URLSearchParams({
directory,
@@ -347,6 +424,19 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
return body;
},
async issueCreate(input: GitHubIssueCreateInput): Promise<GitHubIssueCreateResult> {
const response = await runtimeFetch('/api/github/issues/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitHubIssueCreateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to create GitHub issue');
}
return body;
},
async issueUpdate(input: GitHubIssueUpdateInput): Promise<GitHubIssueUpdateResult> {
const response = await runtimeFetch('/api/github/issues/update', {
method: 'PATCH',
+94
View File
@@ -309,4 +309,98 @@ describe('createWebGitLabAPI', () => {
const api = await createAPI();
await expect(api.issuesList('/workspace')).rejects.toThrow('Internal Server Error');
});
it('passes directory/query/namespace/project to searchUsers and maps the response', async () => {
const result = {
connected: true,
repo: null,
users: [{ username: 'octocat', id: 1, name: 'Octo Cat', avatarUrl: 'https://gitlab.example/octocat.png' }],
};
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'octo', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('omits namespace/project from searchUsers when not provided', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, repo: null, users: [] }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
const params = new URLSearchParams({ directory: '/workspace', query: 'octo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/users/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('throws the server error message when searchUsers fails', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'GitLab rate limited' }, { status: 503 }));
const api = await createAPI();
await expect(api.searchUsers!('/workspace', 'octo')).rejects.toThrow('GitLab rate limited');
});
it('passes directory/query to searchLabels and maps the response', async () => {
const result = { connected: true, repo: null, labels: ['bug', 'frontend'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchLabels!('/workspace', 'feat', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/labels/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchMilestones and maps the response', async () => {
const result = { connected: true, repo: null, milestones: [{ title: 'v2.0', state: 'active' }] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchMilestones!('/workspace', 'v2', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'v2', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/milestones/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchBranches and maps the response', async () => {
const result = { connected: true, repo: null, branches: ['main', 'feat/api'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchBranches!('/workspace', 'feat', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/branches/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('passes directory/query to searchTags and maps the response', async () => {
const result = { connected: true, repo: null, tags: ['v1.0', 'v1.1'] };
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
const api = await createAPI();
await expect(api.searchTags!('/workspace', 'v1', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
const params = new URLSearchParams({ directory: '/workspace', query: 'v1', namespace: 'group', project: 'repo' });
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/tags/search?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
});
+80
View File
@@ -2,13 +2,17 @@ import type {
GitLabAPI,
GitLabAuthStatus,
GitLabBranchesResult,
GitLabBranchesSearchResult,
GitLabIssueCommentResult,
GitLabIssueCommentsResult,
GitLabIssueCommentInput,
GitLabIssueCreateInput,
GitLabIssueCreateResult,
GitLabIssueGetResult,
GitLabIssuesListResult,
GitLabIssueUpdateInput,
GitLabIssueUpdateResult,
GitLabLabelsSearchResult,
GitLabMergeRequest,
GitLabMergeRequestCommitsResult,
GitLabMergeRequestContextResult,
@@ -20,11 +24,14 @@ import type {
GitLabMergeRequestTimelineResult,
GitLabMergeRequestUpdateInput,
GitLabMergeRequestUpdateResult,
GitLabMilestonesSearchResult,
GitLabMrApproveInput,
GitLabMrApproveResult,
GitLabMrNoteInput,
GitLabMrNoteResult,
GitLabTagsSearchResult,
GitLabUserSummary,
GitLabUsersSearchResult,
} from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
@@ -94,6 +101,66 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
return payload;
},
async searchUsers(directory, query, options): Promise<GitLabUsersSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/users/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabUsersSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab users');
}
return { connected: body.connected, repo: body.repo ?? null, users: body.users ?? [] };
},
async searchLabels(directory, query, options): Promise<GitLabLabelsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/labels/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabLabelsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab labels');
}
return { connected: body.connected, repo: body.repo ?? null, labels: body.labels ?? [] };
},
async searchMilestones(directory, query, options): Promise<GitLabMilestonesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/milestones/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabMilestonesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab milestones');
}
return { connected: body.connected, repo: body.repo ?? null, milestones: body.milestones ?? [] };
},
async searchBranches(directory, query, options): Promise<GitLabBranchesSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/branches/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabBranchesSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab branches');
}
return { connected: body.connected, repo: body.repo ?? null, branches: body.branches ?? [] };
},
async searchTags(directory, query, options): Promise<GitLabTagsSearchResult> {
const params = new URLSearchParams({ directory, query });
if (options?.namespace) params.set('namespace', options.namespace);
if (options?.project) params.set('project', options.project);
const response = await runtimeFetch(urls.api('/api/gitlab/tags/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitLabTagsSearchResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to search GitLab tags');
}
return { connected: body.connected, repo: body.repo ?? null, tags: body.tags ?? [] };
},
async issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GitLabIssuesListResult> {
const page = options?.page ?? 1;
const params = new URLSearchParams({
@@ -287,6 +354,19 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
return body;
},
async issueCreate(input: GitLabIssueCreateInput): Promise<GitLabIssueCreateResult> {
const response = await runtimeFetch('/api/gitlab/issues/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitLabIssueCreateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to create GitLab issue');
}
return body;
},
async issueUpdate(input: GitLabIssueUpdateInput): Promise<GitLabIssueUpdateResult> {
const response = await runtimeFetch('/api/gitlab/issues/update', {
method: 'PUT',