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:
@@ -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` |
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }` |
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user