feat(ui): forge write operations — comments, replies, close/reopen, edit, reviews, draft, metadata
- server: write routes for all three providers (issue/PR comments, inline review-comment replies, issue/MR updates w/ labels-assignees-milestone, review submit, draft toggle) - ui: ForgeProvider gains six write ops; shared action components (composer, thread reply, state/review/draft/metadata/edit) wired into ForgeEntityDetailView and GitHub PR Overview
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
|
||||
### Client (`client.js`)
|
||||
|
||||
- `createGitLabClient({ token, baseUrl })`: raw-fetch REST v4 client with `request(path, { method, query, body })` plus convenience methods `user()`, `project(path)`, `issues(path, params)`, `issue(path, iid)`, `issueNotes(path, iid, params)`, `mergeRequests(path, params)`, `mergeRequest(path, iid)`, `mergeRequestDiffs(path, iid, params)`, `createMergeRequest(path, body)`, `updateMergeRequest(path, iid, body)`, `mergeMergeRequest(path, iid, body)`, `branches(path, params)`.
|
||||
- `createGitLabClient({ token, baseUrl })`: raw-fetch REST v4 client with `request(path, { method, query, body })` plus convenience methods `user()`, `project(path)`, `issues(path, params)`, `issue(path, iid)`, `issueNotes(path, iid, params)`, `createIssueNote(path, iid, body)`, `updateIssue(path, iid, params)`, `mergeRequests(path, params)`, `mergeRequest(path, iid)`, `mergeRequestDiffs(path, iid, params)`, `createMergeRequest(path, body)`, `updateMergeRequest(path, iid, body)`, `mergeMergeRequest(path, iid, body)`, `createMrNote(path, iid, body)`, `approveMr(path, iid)`, `milestones(path, params)`, `branches(path, params)`.
|
||||
- `getGitLabClientOrNull()`: client for the current account, or `null`.
|
||||
- `isGitLabRateLimited()` / `noteGitLabRateLimit(error)`: own module-level rate-limit cooldown (not shared with the GitHub module's `rate-limit.js`).
|
||||
|
||||
@@ -81,8 +81,13 @@ Nothing in the client or repo layers assumes the token came from a PAT.
|
||||
- MR notes: `GET /projects/:id/merge_requests/:merge_request_iid/notes?per_page=100`; the timeline route keeps `system: true` notes only and infers the event `type` from the note body text (best-effort heuristic, falls back to `'other'`).
|
||||
- MR notes: `GET /projects/:id/merge_requests/:merge_request_iid/notes?per_page=100`.
|
||||
- MR create: `POST /projects/:id/merge_requests` with `{ source_branch, target_branch, title, description?, remove_source_branch }` (description omitted when absent; `remove_source_branch` defaults to `false`).
|
||||
- MR update: `PUT /projects/:id/merge_requests/:merge_request_iid` with `{ title?, description? }` (undefined fields omitted).
|
||||
- MR update: `PUT /projects/:id/merge_requests/:merge_request_iid` with `{ title?, description?, state_event?, labels?, assignee_ids?, milestone_id? }` (undefined fields omitted; `state_event` is derived from `state`, milestone titles are resolved to ids).
|
||||
- MR merge: `PUT /projects/:id/merge_requests/:merge_request_iid/merge` with `{ squash? }`.
|
||||
- Issue comment write: `POST /projects/:id/issues/:issue_iid/notes` with `{ body }` (the route resolves the issue `web_url` first so the note links as `{issue_web_url}#note_{id}`).
|
||||
- Issue update: `PUT /projects/:id/issues/:issue_iid` with `{ title?, description?, state_event?, labels?, assignee_ids?, milestone_id? }` (`state: 'open'|'closed'` maps to `state_event: 'reopen'|'close'`; labels/assignees are full-set replaces per GitLab semantics; `milestone` titles are resolved to ids and `null` clears).
|
||||
- MR comment write: `POST /projects/:id/merge_requests/:merge_request_iid/notes` with `{ body }`.
|
||||
- MR approve: `POST /projects/:id/merge_requests/:merge_request_iid/approve` (approve-only; GitLab has no request-changes event via this API — the facade capability reflects that).
|
||||
- Milestones: `GET /projects/:id/milestones?state=all&per_page=100` (first page) for title-to-id resolution on issue/MR updates.
|
||||
- Branches: `GET /projects/:id/repository/branches?per_page=100&page=N`.
|
||||
- User: `GET /user` -> `{ id, username, name, state, avatar_url, web_url, email, ... }`.
|
||||
|
||||
@@ -103,8 +108,12 @@ Nothing in the client or repo layers assumes the token came from a PAT.
|
||||
| GET | `/api/gitlab/mrs/commits` | `?directory&number&namespace&project` -> `{ connected, repo?, commits[] }` |
|
||||
| GET | `/api/gitlab/mrs/timeline` | `?directory&number&namespace&project` -> `{ connected, repo?, events[] }` (system notes only; event `type` inferred from note body text — best-effort heuristic) |
|
||||
| POST | `/api/gitlab/mrs/create` | body `{ directory, title, sourceBranch, targetBranch, description?, removeSourceBranch? }` -> `{ connected, repo?, mr }`; `400` for missing fields, unresolvable repo, or a token without the `api` scope |
|
||||
| PUT | `/api/gitlab/mrs/update` | body `{ directory, number, title?, description? }` -> `{ connected, repo?, mr }`; `404` when the MR does not exist |
|
||||
| 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 }` |
|
||||
| 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 }` |
|
||||
| GET | `/api/gitlab/repo/branches` | `?namespace&project` -> `{ branches[], defaultBranch? }` (`defaultBranch` is `null` when the repo has no marked default branch or GitLab is disconnected) |
|
||||
|
||||
Conventions mirror `github/routes.js`:
|
||||
@@ -114,7 +123,7 @@ Conventions mirror `github/routes.js`:
|
||||
- Hard failures -> `4xx`/`5xx` with `{ error }`.
|
||||
- A GitLab `429` -> `503 { error: 'GitLab rate limited' }`.
|
||||
- Lazy-import pattern: route handlers import `./index.js` on first use, so the module never loads unless GitLab endpoints are hit.
|
||||
- Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout.
|
||||
- Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout. Write routes deliberately skip the route-level timeout (a timeout can orphan a write); the client's per-request timeout still bounds them.
|
||||
|
||||
## Consumers
|
||||
|
||||
@@ -127,6 +136,7 @@ Conventions mirror `github/routes.js`:
|
||||
- A repo that does not resolve from the local git remote yields `repo: null` with empty lists, matching the GitHub behavior. Write routes reject an unresolvable repo with `400 { error: 'Unable to resolve GitLab repo from directory' }`.
|
||||
- Invalid/expired tokens are cleared on `401`/`403` and reported as disconnected.
|
||||
- GitLab `403` on write routes means the token lacks the `api` scope; they respond `400 { error: 'Your GitLab token needs the api scope to ...' }`.
|
||||
- Milestone titles on issue/MR updates are resolved against `GET /projects/:id/milestones`; an unmatched title yields `400 { error: 'Milestone not found' }` and `null` clears the milestone (`milestone_id: null`).
|
||||
- MR merge rejections (`405`/`406`/`409`/`422` from GitLab) are surfaced as `{ connected, merged: false, message }` with the GitLab status so clients can show the message without treating it as a transport error (mirrors `github/pr/merge`).
|
||||
- Rate-limit and timeout failures surface explicit `503` responses so clients keep last-known state rather than clearing UI.
|
||||
|
||||
@@ -136,4 +146,4 @@ Conventions mirror `github/routes.js`:
|
||||
- Never log tokens. Error messages must not include the access token.
|
||||
- Do not double-encode project paths; convenience methods already call `encodeURIComponent` on the `pathWithNamespace`.
|
||||
- The ETag cache and rate-limit cooldown are module-level and per-instance — they are NOT shared with the GitHub module.
|
||||
- To add further GitLab write operations (comment, assign, issue writes), add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing MR write routes and the GitHub PR write routes.
|
||||
- To add further GitLab write operations, add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing issue/MR write routes and the GitHub PR write routes.
|
||||
|
||||
@@ -261,6 +261,10 @@ export function createGitLabClient({ token, baseUrl }) {
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}`),
|
||||
issueNotes: (pathWithNamespace, iid, params = {}) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { query: params }),
|
||||
createIssueNote: (pathWithNamespace, iid, body) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { method: 'POST', body: { body } }),
|
||||
updateIssue: (pathWithNamespace, iid, params) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}`, { method: 'PUT', body: params }),
|
||||
mergeRequests: (pathWithNamespace, params = {}) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests`, { query: params }),
|
||||
mergeRequest: (pathWithNamespace, iid) =>
|
||||
@@ -271,6 +275,12 @@ export function createGitLabClient({ token, baseUrl }) {
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/commits`, { query: params }),
|
||||
mergeRequestNotes: (pathWithNamespace, iid, params = {}) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/notes`, { query: params }),
|
||||
createMrNote: (pathWithNamespace, iid, body) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/notes`, { method: 'POST', body: { body } }),
|
||||
approveMr: (pathWithNamespace, iid) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/approve`, { method: 'POST' }),
|
||||
milestones: (pathWithNamespace, params = {}) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/milestones`, { query: params }),
|
||||
createMergeRequest: (pathWithNamespace, body) =>
|
||||
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests`, { method: 'POST', body }),
|
||||
updateMergeRequest: (pathWithNamespace, iid, body) =>
|
||||
|
||||
@@ -273,6 +273,71 @@ describe('merge request write methods', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue and review write methods', () => {
|
||||
test('createIssueNote POSTs a body to the issue notes endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ id: 5, body: 'hi' }, { status: 201 }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
const result = await client.createIssueNote('group/sub', 7, 'Nice catch');
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/issues/7/notes');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
|
||||
expect(result.status).toBe(201);
|
||||
});
|
||||
|
||||
test('createMrNote POSTs a body to the MR notes endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ id: 8, body: 'LGTM' }, { status: 201 }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.createMrNote('group/sub', 12, 'LGTM');
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/merge_requests/12/notes');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'LGTM' });
|
||||
});
|
||||
|
||||
test('updateIssue PUTs params to the issue endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ iid: 7, title: 'Updated' }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.updateIssue('group/sub', 7, { state_event: 'close', labels: ['bug'], milestone_id: 33 });
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/issues/7');
|
||||
expect(options.method).toBe('PUT');
|
||||
expect(JSON.parse(options.body)).toEqual({ state_event: 'close', labels: ['bug'], milestone_id: 33 });
|
||||
});
|
||||
|
||||
test('approveMr POSTs to the approve endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ id: 1, state: 'approved' }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.approveMr('group/sub', 12);
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/merge_requests/12/approve');
|
||||
expect(options.method).toBe('POST');
|
||||
});
|
||||
|
||||
test('milestones GETs the project milestones list', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse([{ id: 33, title: 'v1.0' }]));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.milestones('group/sub', { state: 'all', per_page: 100 });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/milestones?state=all&per_page=100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limiting', () => {
|
||||
// NOTE: these tests run last in this file. The rate-limit cooldown is
|
||||
// module-level and has no reset export, so earlier tests must not set one.
|
||||
|
||||
@@ -23,9 +23,12 @@ function withTimeout(promise, timeoutMs, label) {
|
||||
|
||||
const asString = (value) => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
// Resolve the requested project from the query (read routes) or the JSON body
|
||||
// (write routes). `namespace`/`project` override the directory-local git
|
||||
// remote for repos checked out from non-GitLab remotes.
|
||||
const getRequestedProject = (req) => {
|
||||
const namespace = asString(req.query?.namespace);
|
||||
const project = asString(req.query?.project);
|
||||
const namespace = asString(req.query?.namespace) || asString(req.body?.namespace);
|
||||
const project = asString(req.query?.project) || asString(req.body?.project);
|
||||
return namespace && project ? `${namespace}/${project}` : null;
|
||||
};
|
||||
|
||||
@@ -71,6 +74,23 @@ const mapIssueSummary = (item) => ({
|
||||
labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [],
|
||||
});
|
||||
|
||||
const mapIssue = (item) => ({
|
||||
...mapIssueSummary(item),
|
||||
body: typeof item.description === 'string' ? item.description : '',
|
||||
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
|
||||
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
|
||||
assignees: Array.isArray(item.assignees)
|
||||
? item.assignees.map(mapAuthor).filter(Boolean)
|
||||
: [],
|
||||
milestone: item.milestone && typeof item.milestone === 'object'
|
||||
? {
|
||||
title: typeof item.milestone.title === 'string' ? item.milestone.title : '',
|
||||
...(typeof item.milestone.state === 'string' ? { state: item.milestone.state } : {}),
|
||||
}
|
||||
: null,
|
||||
commentsCount: typeof item.user_notes_count === 'number' ? item.user_notes_count : undefined,
|
||||
});
|
||||
|
||||
const mapMergeRequestSummary = (item) => ({
|
||||
number: typeof item.iid === 'number' ? item.iid : Number(item.iid),
|
||||
title: typeof item.title === 'string' ? item.title : '',
|
||||
@@ -152,6 +172,24 @@ const gitLabErrorMessage = (data) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// GitLab update endpoints take `milestone_id` (numeric), not the title. Resolve
|
||||
// a title via the project milestones list (first page is enough for title-based
|
||||
// lookups); unmatched titles yield `milestoneId: null` so routes can surface
|
||||
// `400 { error: 'Milestone not found' }`.
|
||||
const resolveMilestoneId = async (client, projectPath, title) => {
|
||||
const resp = await client.milestones(projectPath, { state: 'all', per_page: 100 });
|
||||
if (resp.status === 429) {
|
||||
return { milestoneId: null, rateLimited: true };
|
||||
}
|
||||
if (resp.status !== 200 || !Array.isArray(resp.data)) {
|
||||
return { milestoneId: null, rateLimited: false };
|
||||
}
|
||||
const match = resp.data.find(
|
||||
(item) => typeof item?.title === 'string' && item.title.toLowerCase() === title.toLowerCase(),
|
||||
);
|
||||
return { milestoneId: typeof match?.id === 'number' ? match.id : null, rateLimited: false };
|
||||
};
|
||||
|
||||
const countDiffLines = (diffText) => {
|
||||
if (typeof diffText !== 'string') {
|
||||
return { additions: 0, deletions: 0, changes: 0 };
|
||||
@@ -478,27 +516,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
}
|
||||
|
||||
const item = resp.data;
|
||||
const issue = {
|
||||
number: typeof item.iid === 'number' ? item.iid : Number(item.iid),
|
||||
title: typeof item.title === 'string' ? item.title : '',
|
||||
url: typeof item.web_url === 'string' ? item.web_url : '',
|
||||
state: typeof item.state === 'string' ? item.state : 'opened',
|
||||
body: typeof item.description === 'string' ? item.description : '',
|
||||
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
|
||||
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
|
||||
author: mapAuthor(item.author) || {},
|
||||
assignees: Array.isArray(item.assignees)
|
||||
? item.assignees.map(mapAuthor).filter(Boolean)
|
||||
: [],
|
||||
labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [],
|
||||
milestone: item.milestone && typeof item.milestone === 'object'
|
||||
? {
|
||||
title: typeof item.milestone.title === 'string' ? item.milestone.title : '',
|
||||
...(typeof item.milestone.state === 'string' ? { state: item.milestone.state } : {}),
|
||||
}
|
||||
: null,
|
||||
commentsCount: typeof item.user_notes_count === 'number' ? item.user_notes_count : undefined,
|
||||
};
|
||||
const issue = mapIssue(item);
|
||||
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), issue });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitLab issue:', error);
|
||||
@@ -564,6 +582,146 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gitlab/issues/comment', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||
if (!directory || !number || !body) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
// GitLab notes carry no web URL; resolve the issue web_url first so the
|
||||
// note links as `{issue_web_url}#note_{id}` (mirrors issues/comments).
|
||||
const issueResp = await client.issue(projectPath, number);
|
||||
if (issueResp.status === 429) {
|
||||
return res.status(503).json({ error: 'GitLab rate limited' });
|
||||
}
|
||||
if (issueResp.status === 404) {
|
||||
return res.status(404).json({ error: 'Issue not found' });
|
||||
}
|
||||
if (issueResp.status !== 200 || !issueResp.data) {
|
||||
return res.status(502).json({ error: 'GitLab returned an error while fetching the issue' });
|
||||
}
|
||||
const webUrl = typeof issueResp.data.web_url === 'string' ? issueResp.data.web_url : '';
|
||||
|
||||
const resp = await client.createIssueNote(projectPath, number, body);
|
||||
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 issue comments' });
|
||||
}
|
||||
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 comment' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'GitLab returned an empty response while creating the comment' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
|
||||
comment: mapComment(resp.data, webUrl),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create GitLab issue comment:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to create GitLab issue comment' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/gitlab/issues/update', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
if (!directory || !number) {
|
||||
return res.status(400).json({ error: 'directory and number are required' });
|
||||
}
|
||||
|
||||
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 body = {};
|
||||
if (typeof req.body?.title === 'string') {
|
||||
body.title = req.body.title.trim();
|
||||
}
|
||||
if (typeof req.body?.body === 'string') {
|
||||
body.description = req.body.body;
|
||||
}
|
||||
// GitLab maps state transitions through `state_event` ('close'/'reopen').
|
||||
if (req.body?.state === 'open' || req.body?.state === 'closed') {
|
||||
body.state_event = req.body.state === 'closed' ? 'close' : 'reopen';
|
||||
}
|
||||
if (Array.isArray(req.body?.labels)) {
|
||||
body.labels = req.body.labels.filter((label) => typeof label === 'string');
|
||||
}
|
||||
if (Array.isArray(req.body?.assigneeIds)) {
|
||||
body.assignee_ids = req.body.assigneeIds.filter((id) => typeof id === 'number');
|
||||
}
|
||||
if (req.body?.milestone !== undefined) {
|
||||
if (req.body.milestone === null) {
|
||||
body.milestone_id = null;
|
||||
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
|
||||
const { milestoneId, rateLimited } = await resolveMilestoneId(client, projectPath, req.body.milestone.trim());
|
||||
if (rateLimited) {
|
||||
return res.status(503).json({ error: 'GitLab rate limited' });
|
||||
}
|
||||
if (milestoneId === null) {
|
||||
return res.status(400).json({ error: 'Milestone not found' });
|
||||
}
|
||||
body.milestone_id = milestoneId;
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await client.updateIssue(projectPath, number, body);
|
||||
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 update issues' });
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return res.status(404).json({ error: 'Issue not found' });
|
||||
}
|
||||
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 updating the issue' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'GitLab returned an empty response while updating the issue' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
|
||||
issue: mapIssue(resp.data),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update GitLab issue:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to update GitLab issue' });
|
||||
}
|
||||
});
|
||||
|
||||
// ================= GitLab Merge Request APIs =================
|
||||
|
||||
app.get('/api/gitlab/mrs/list', async (req, res) => {
|
||||
@@ -938,6 +1096,30 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
if (description !== undefined) {
|
||||
body.description = description;
|
||||
}
|
||||
// GitLab maps state transitions through `state_event` ('close'/'reopen').
|
||||
if (req.body?.state === 'open' || req.body?.state === 'closed') {
|
||||
body.state_event = req.body.state === 'closed' ? 'close' : 'reopen';
|
||||
}
|
||||
if (Array.isArray(req.body?.labels)) {
|
||||
body.labels = req.body.labels.filter((label) => typeof label === 'string');
|
||||
}
|
||||
if (Array.isArray(req.body?.assigneeIds)) {
|
||||
body.assignee_ids = req.body.assigneeIds.filter((id) => typeof id === 'number');
|
||||
}
|
||||
if (req.body?.milestone !== undefined) {
|
||||
if (req.body.milestone === null) {
|
||||
body.milestone_id = null;
|
||||
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
|
||||
const { milestoneId, rateLimited } = await resolveMilestoneId(client, projectPath, req.body.milestone.trim());
|
||||
if (rateLimited) {
|
||||
return res.status(503).json({ error: 'GitLab rate limited' });
|
||||
}
|
||||
if (milestoneId === null) {
|
||||
return res.status(400).json({ error: 'Milestone not found' });
|
||||
}
|
||||
body.milestone_id = milestoneId;
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await withTimeout(client.updateMergeRequest(projectPath, number, body), ROUTE_TIMEOUT_MS, 'gitlab mr update');
|
||||
if (resp.status === 429) {
|
||||
@@ -1025,6 +1207,111 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gitlab/mrs/comment', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||
if (!directory || !number || !body) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
// MR notes carry no web URL; resolve the MR web_url first so the note
|
||||
// links as `{mr_web_url}#note_{id}` (mirrors mrs/context).
|
||||
const mrResp = await client.mergeRequest(projectPath, number);
|
||||
if (mrResp.status === 429) {
|
||||
return res.status(503).json({ error: 'GitLab rate limited' });
|
||||
}
|
||||
if (mrResp.status === 404) {
|
||||
return res.status(404).json({ error: 'Merge request not found' });
|
||||
}
|
||||
if (mrResp.status !== 200 || !mrResp.data) {
|
||||
return res.status(502).json({ error: 'GitLab returned an error while fetching the merge request' });
|
||||
}
|
||||
const webUrl = typeof mrResp.data.web_url === 'string' ? mrResp.data.web_url : '';
|
||||
|
||||
const resp = await client.createMrNote(projectPath, number, body);
|
||||
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 comment on merge requests' });
|
||||
}
|
||||
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 comment' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'GitLab returned an empty response while creating the comment' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
|
||||
comment: mapComment(resp.data, webUrl),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create GitLab merge request comment:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to create GitLab merge request comment' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gitlab/mrs/approve', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
if (!directory || !number) {
|
||||
return res.status(400).json({ error: 'directory and number are required' });
|
||||
}
|
||||
|
||||
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 resp = await client.approveMr(projectPath, number);
|
||||
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 approve merge requests' });
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return res.status(404).json({ error: 'Merge request not found' });
|
||||
}
|
||||
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 approving the merge request' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
|
||||
approved: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to approve GitLab merge request:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to approve GitLab merge request' });
|
||||
}
|
||||
});
|
||||
|
||||
// ================= GitLab Repo APIs =================
|
||||
|
||||
app.get('/api/gitlab/repo/branches', async (req, res) => {
|
||||
|
||||
@@ -896,6 +896,332 @@ describe('GitLab data routes', () => {
|
||||
expect(timeline.body).toMatchObject({ connected: false, events: [] });
|
||||
});
|
||||
|
||||
test('issues/comment POSTs a note and links it to the issue URL', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url) && (!options.method || options.method === 'GET')) {
|
||||
return jsonResponse({ iid: 7, web_url: 'https://gitlab.com/group/sub/-/issues/7' });
|
||||
}
|
||||
if (matches(/\/issues\/7\/notes$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({ id: 5, body: 'Nice catch', author: { id: 42, username: 'alice', name: 'Alice Example' } }, { status: 201 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitlab/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7, body: 'Nice catch' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
repo: { namespace: 'group', project: 'sub', host: 'gitlab.com' },
|
||||
comment: {
|
||||
id: 5,
|
||||
url: 'https://gitlab.com/group/sub/-/issues/7#note_5',
|
||||
body: 'Nice catch',
|
||||
author: { username: 'alice', name: 'Alice Example', id: 42 },
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[1];
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
|
||||
});
|
||||
|
||||
test('issues/comment reports connected:false when not authenticated', async () => {
|
||||
clearGitLabAuth();
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitlab/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7, body: 'hello' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ connected: false });
|
||||
});
|
||||
|
||||
test('issues/comment surfaces a 403 as an api-scope error', async () => {
|
||||
scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url)) {
|
||||
return jsonResponse({ iid: 7, web_url: 'https://gitlab.com/group/sub/-/issues/7' });
|
||||
}
|
||||
if (matches(/\/issues\/7\/notes$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({ message: '403 Forbidden' }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitlab/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7, body: 'hello' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Your GitLab token needs the api scope to create issue comments' });
|
||||
});
|
||||
|
||||
test('issues/update maps state_event, labels, assignee_ids, and resolves the milestone title', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url) => (matches(/\/milestones\?/)(url)
|
||||
? jsonResponse([{ id: 33, title: 'v1.0', state: 'active' }])
|
||||
: null),
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url) && options.method === 'PUT') {
|
||||
return jsonResponse({
|
||||
iid: 7,
|
||||
title: 'Updated issue',
|
||||
web_url: 'https://gitlab.com/group/sub/-/issues/7',
|
||||
state: 'closed',
|
||||
description: 'New body',
|
||||
author: { id: 42, username: 'alice', name: 'Alice Example' },
|
||||
labels: ['bug'],
|
||||
assignees: [{ id: 43, username: 'bob' }],
|
||||
milestone: { id: 33, title: 'v1.0', state: 'active' },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.put('/api/gitlab/issues/update')
|
||||
.send({
|
||||
directory: '/tmp/work',
|
||||
number: 7,
|
||||
title: 'Updated issue',
|
||||
body: 'New body',
|
||||
state: 'closed',
|
||||
labels: ['bug'],
|
||||
assigneeIds: [43],
|
||||
milestone: 'v1.0',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
issue: {
|
||||
number: 7,
|
||||
title: 'Updated issue',
|
||||
state: 'closed',
|
||||
body: 'New body',
|
||||
labels: ['bug'],
|
||||
assignees: [{ username: 'bob', id: 43 }],
|
||||
milestone: { title: 'v1.0', state: 'active' },
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[1];
|
||||
expect(options.method).toBe('PUT');
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
title: 'Updated issue',
|
||||
description: 'New body',
|
||||
state_event: 'close',
|
||||
labels: ['bug'],
|
||||
assignee_ids: [43],
|
||||
milestone_id: 33,
|
||||
});
|
||||
});
|
||||
|
||||
test('issues/update maps state open to state_event reopen', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url) && options.method === 'PUT') {
|
||||
return jsonResponse({ iid: 7, title: 'T', web_url: 'u', state: 'opened', author: {} });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
await request(app)
|
||||
.put('/api/gitlab/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, state: 'open' });
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ state_event: 'reopen' });
|
||||
});
|
||||
|
||||
test('issues/update clears the milestone when null is sent', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url) && options.method === 'PUT') {
|
||||
return jsonResponse({ iid: 7, title: 'T', web_url: 'u', state: 'opened', author: {} });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
await request(app)
|
||||
.put('/api/gitlab/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, milestone: null });
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ milestone_id: null });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('issues/update returns 400 when the milestone title does not match', async () => {
|
||||
scriptedFetch([
|
||||
(url) => (matches(/\/milestones\?/)(url)
|
||||
? jsonResponse([{ id: 33, title: 'v1.0' }])
|
||||
: null),
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.put('/api/gitlab/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, milestone: 'v2.0' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Milestone not found' });
|
||||
});
|
||||
|
||||
test('mrs/comment POSTs an MR note and links it to the MR URL', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/merge_requests\/12$/)(url) && (!options.method || options.method === 'GET')) {
|
||||
return jsonResponse({ iid: 12, web_url: 'https://gitlab.com/group/sub/-/merge_requests/12' });
|
||||
}
|
||||
if (matches(/\/merge_requests\/12\/notes$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({ id: 8, body: 'LGTM', author: { id: 43, username: 'bob' } }, { status: 201 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitlab/mrs/comment')
|
||||
.send({ directory: '/tmp/work', number: 12, body: 'LGTM' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
comment: {
|
||||
id: 8,
|
||||
url: 'https://gitlab.com/group/sub/-/merge_requests/12#note_8',
|
||||
body: 'LGTM',
|
||||
author: { username: 'bob', id: 43 },
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[1];
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'LGTM' });
|
||||
});
|
||||
|
||||
test('mrs/approve POSTs the approve request and reports approved:true', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/merge_requests\/12\/approve$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({ id: 1, state: 'approved' });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitlab/mrs/approve')
|
||||
.send({ directory: '/tmp/work', number: 12 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
repo: { namespace: 'group', project: 'sub', host: 'gitlab.com' },
|
||||
approved: true,
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe('POST');
|
||||
});
|
||||
|
||||
test('mrs/approve returns 404 for a missing merge request', async () => {
|
||||
scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/merge_requests\/999\/approve$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({ message: '404 Not Found' }, { status: 404 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitlab/mrs/approve')
|
||||
.send({ directory: '/tmp/work', number: 999 });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Merge request not found' });
|
||||
});
|
||||
|
||||
test('mrs/update maps state, labels, assignee_ids, and resolves the milestone title', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url) => (matches(/\/milestones\?/)(url)
|
||||
? jsonResponse([{ id: 33, title: 'v1.0', state: 'active' }])
|
||||
: null),
|
||||
(url, options) => {
|
||||
if (matches(/\/merge_requests\/12$/)(url) && options.method === 'PUT') {
|
||||
return jsonResponse({
|
||||
iid: 12,
|
||||
title: 'Updated title',
|
||||
web_url: 'https://gitlab.com/group/sub/-/merge_requests/12',
|
||||
state: 'opened',
|
||||
draft: false,
|
||||
work_in_progress: false,
|
||||
author: { id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png' },
|
||||
source_branch: 'feat/add',
|
||||
target_branch: 'main',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.put('/api/gitlab/mrs/update')
|
||||
.send({
|
||||
directory: '/tmp/work',
|
||||
number: 12,
|
||||
title: 'Updated title',
|
||||
state: 'open',
|
||||
labels: ['ready'],
|
||||
assigneeIds: [43],
|
||||
milestone: 'v1.0',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
mr: { number: 12, title: 'Updated title', state: 'opened' },
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[1];
|
||||
expect(options.method).toBe('PUT');
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
title: 'Updated title',
|
||||
state_event: 'reopen',
|
||||
labels: ['ready'],
|
||||
assignee_ids: [43],
|
||||
milestone_id: 33,
|
||||
});
|
||||
});
|
||||
|
||||
test('mrs/update returns 400 when the milestone title does not match', async () => {
|
||||
scriptedFetch([
|
||||
(url) => (matches(/\/milestones\?/)(url)
|
||||
? jsonResponse([{ id: 33, title: 'v1.0' }])
|
||||
: null),
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.put('/api/gitlab/mrs/update')
|
||||
.send({ directory: '/tmp/work', number: 12, milestone: 'nope' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Milestone not found' });
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse({}, { status: 429, headers: { 'retry-after': '30' } }) : null)]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user