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:
2026-08-16 16:29:24 +00:00
parent 92f0eced34
commit 8f5cfdcd62
44 changed files with 5383 additions and 62 deletions
+17 -6
View File
@@ -33,7 +33,7 @@
### Client (`client.js`)
- `createGiteaClient({ token, baseUrl })`: raw-fetch REST v1 client with `request(path, { method, query, body, signal, raw })` plus convenience methods `user()`, `repo(owner, repo)`, `issues(owner, repo, params)`, `issue(owner, repo, number)`, `issueComments(owner, repo, number, params)`, `pullRequests(owner, repo, params)`, `pullRequest(owner, repo, number)`, `pullRequestDiff(owner, repo, number)` (raw `.diff` text via the `raw` option), `pullRequestFiles(owner, repo, number, params)`, `pullRequestCommits(owner, repo, number, params)`, `pullRequestReviews(owner, repo, number, params)`, `commitStatuses(owner, repo, sha, params)`, `createPullRequest(owner, repo, body)`, `updatePullRequest(owner, repo, number, body)` (PATCH), `mergePullRequest(owner, repo, number, body)` (POST), `branches(owner, repo, params)`.
- `createGiteaClient({ token, baseUrl })`: raw-fetch REST v1 client with `request(path, { method, query, body, signal, raw })` plus convenience methods `user()`, `repo(owner, repo)`, `issues(owner, repo, params)`, `issue(owner, repo, number)`, `issueComments(owner, repo, number, params)`, `createIssueComment(owner, repo, number, body)`, `updateIssue(owner, repo, number, params)` (PATCH), `milestones(owner, repo, params)`, `repoLabels(owner, repo, params)`, `pullRequests(owner, repo, params)`, `pullRequest(owner, repo, number)`, `pullRequestDiff(owner, repo, number)` (raw `.diff` text via the `raw` option), `pullRequestFiles(owner, repo, number, params)`, `pullRequestCommits(owner, repo, number, params)`, `pullRequestReviews(owner, repo, number, params)`, `createPullReview(owner, repo, number, params)` (POST), `commitStatuses(owner, repo, sha, params)`, `createPullRequest(owner, repo, body)`, `updatePullRequest(owner, repo, number, body)` (PATCH), `mergePullRequest(owner, repo, number, body)` (POST), `branches(owner, repo, params)`.
- `getGiteaClientOrNull()`: client for the current account, or `null`.
- `isGiteaRateLimited()` / `noteGiteaRateLimit(error)`: own module-level rate-limit cooldown (not shared with the GitHub/GitLab modules).
@@ -76,8 +76,13 @@
- PR reviews: `GET /repos/{owner}/{repo}/pulls/{number}/reviews?limit=100` (mapped to `{ id, state, author, submittedAt, body, commitSha }`; `state` passes through, e.g. `APPROVED`/`REQUEST_CHANGES`).
- Commit statuses: `GET /repos/{owner}/{repo}/commits/{sha}/statuses?limit=100` (the `prs/statuses` route resolves the PR `head.sha` first, then maps statuses to `{ state, name, description, url, createdAt }` with `state` lowercased).
- PR create: `POST /repos/{owner}/{repo}/pulls` with `{ title, head, base, body? }` (body omitted when absent).
- PR update: `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{ title?, body? }` (undefined fields omitted).
- PR update: `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{ title?, body?, state? }` (undefined fields omitted; the PR number IS the issue index, so the edit-issue `state` transition applies directly).
- PR merge: `POST /repos/{owner}/{repo}/pulls/{number}/merge` with `{ Do: true, MergeMethod: 'merge' | 'squash' | 'rebase' }` (`method` defaults to `'merge'`).
- Issue comment write: `POST /repos/{owner}/{repo}/issues/{number}/comments` with `{ body }` (PRs are issues at the API level, so `prs/comment` uses the same endpoint with the PR number as the index).
- Issue update: `PATCH /repos/{owner}/{repo}/issues/{number}` with `{ title?, body?, state?, labels?, assignees?, milestone?, unset_milestone? }` (labels are label **names**, assignees are logins; `milestone` is resolved from a title to a milestone id and `null` sets `unset_milestone: true`).
- Pull review write: `POST /repos/{owner}/{repo}/pulls/{number}/reviews` with `{ event, body? }` (`event` is `APPROVED`/`REQUEST_CHANGES`/`COMMENT`).
- Milestones: `GET /repos/{owner}/{repo}/milestones?state=all&limit=50` (first page) for title-to-id resolution on issue updates.
- Repo labels: `GET /repos/{owner}/{repo}/labels?limit=100` (first page) so metadata editors can offer existing labels.
- Branches: `GET /repos/{owner}/{repo}/branches?limit=50&page=N` mapped to names, plus `GET /repos/{owner}/{repo}` for `default_branch` (Gitea branch objects carry no default flag).
- There is **no ready-for-review endpoint** in this module (Gitea has no GitLab-style ready_for_review action).
@@ -99,8 +104,13 @@
| GET | `/api/gitea/prs/reviews` | `?directory&number&owner&repo` -> `{ connected, repo?, reviews[] }` |
| GET | `/api/gitea/prs/statuses` | `?directory&number&owner&repo` -> `{ connected, repo?, statuses[] }` (resolves the PR `head.sha` first, then lists commit statuses for that SHA) |
| POST | `/api/gitea/pr/create` | body `{ directory, title, sourceBranch, targetBranch, description? }` -> `{ connected, repo?, pr }`; `400` for missing fields or an unresolvable repo |
| PATCH | `/api/gitea/pr/update` | body `{ directory, number, title?, description? }` -> `{ connected, repo?, pr }`; `404` when the PR does not exist |
| 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 }` |
| 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` |
| GET | `/api/gitea/repo/labels` | `?directory&owner&repo` -> `{ connected, repo?, labels[] }` |
| GET | `/api/gitea/repo/branches` | `?owner&repo` -> `{ branches[], defaultBranch? }` (`defaultBranch` is `null` when Gitea is disconnected or the repo has no default) |
Conventions mirror `github/routes.js` and `gitlab/routes.js`:
@@ -110,8 +120,8 @@ Conventions mirror `github/routes.js` and `gitlab/routes.js`:
- Hard failures -> `4xx`/`5xx` with `{ error }`.
- A Gitea `429` -> `503 { error: 'Gitea rate limited' }`.
- Lazy-import pattern: route handlers import `./index.js` on first use, so the module never loads unless Gitea endpoints are hit.
- Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout.
- Repo targeting: `owner`/`repo` query params override the directory-local git remote.
- 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.
- Repo targeting: `owner`/`repo` query params override the directory-local git remote; write routes also accept them in the JSON body.
## Consumers
@@ -124,6 +134,7 @@ Conventions mirror `github/routes.js` and `gitlab/routes.js`:
- A repo that does not resolve from the local git remote yields `repo: null` with empty lists, matching GitHub/GitLab behavior. Write routes reject an unresolvable repo with `400 { error: 'Unable to resolve Gitea repo from directory' }`.
- Invalid/expired tokens are cleared on `401`/`403` and reported as disconnected.
- Gitea `403` on write routes means the token lacks repository write scope; they respond `400 { error: 'Your Gitea token needs write:repository scope to ...' }`.
- Milestone titles on issue updates are resolved against `GET /repos/{owner}/{repo}/milestones`; an unmatched title yields `400 { error: 'Milestone not found' }` and `null` sets `unset_milestone: true`.
- PR merge rejections (`405`/`409`/`422` from Gitea) are surfaced as `{ connected, merged: false, message }` with the Gitea status so clients can show the message without treating it as a transport error (mirrors `github/pr/merge`).
- The pull-files endpoint returning `404` (older Gitea) yields `files: []` instead of failing the whole PR context; a missing `.diff` falls back to concatenated patches.
- Rate-limit and timeout failures surface explicit `503` responses so clients keep last-known state rather than clearing UI.
@@ -134,4 +145,4 @@ Conventions mirror `github/routes.js` and `gitlab/routes.js`:
- Never log tokens. Error messages must not include the access token.
- The ETag cache and rate-limit cooldown are module-level and per-instance — they are NOT shared with the GitHub or GitLab modules.
- Gitea `GET /user` returns `login`/`full_name`/`html_url`; the route mappers accept the GitHub-style `username`/`name`/`web_url` variants too, so Forgejo versions that differ still map.
- To add further Gitea 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 PR write routes and the GitHub PR write routes.
- To add further Gitea write operations, add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing issue/PR write routes and the GitHub PR write routes.
+10
View File
@@ -263,6 +263,14 @@ export function createGiteaClient({ token, baseUrl }) {
request(`/repos/${owner}/${repo}/issues/${number}`),
issueComments: (owner, repo, number, params = {}) =>
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 } }),
updateIssue: (owner, repo, number, params) =>
request(`/repos/${owner}/${repo}/issues/${number}`, { method: 'PATCH', body: params }),
milestones: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/milestones`, { query: params }),
repoLabels: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/labels`, { query: params }),
pullRequests: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/pulls`, { query: params }),
pullRequest: (owner, repo, number) =>
@@ -275,6 +283,8 @@ export function createGiteaClient({ token, baseUrl }) {
request(`/repos/${owner}/${repo}/pulls/${number}/commits`, { query: params }),
pullRequestReviews: (owner, repo, number, params = {}) =>
request(`/repos/${owner}/${repo}/pulls/${number}/reviews`, { query: params }),
createPullReview: (owner, repo, number, params) =>
request(`/repos/${owner}/${repo}/pulls/${number}/reviews`, { method: 'POST', body: params }),
commitStatuses: (owner, repo, sha, params = {}) =>
request(`/repos/${owner}/${repo}/commits/${sha}/statuses`, { query: params }),
createPullRequest: (owner, repo, body) =>
@@ -266,6 +266,70 @@ describe('pull request write methods', () => {
});
});
describe('issue, review, and repo write methods', () => {
test('createIssueComment POSTs a body to the issue comments endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 5, body: 'hi' }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.createIssueComment('owner', 'repo', 7, 'Nice catch');
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/issues/7/comments');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
expect(result.status).toBe(201);
});
test('updateIssue PATCHes params to the issue endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ number: 7, title: 'Updated' }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.updateIssue('owner', 'repo', 7, { state: 'closed', labels: ['bug'], milestone: 33 });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/issues/7');
expect(options.method).toBe('PATCH');
expect(JSON.parse(options.body)).toEqual({ state: 'closed', labels: ['bug'], milestone: 33 });
});
test('createPullReview POSTs event/body to the reviews endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 101, state: 'APPROVED' }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.createPullReview('owner', 'repo', 12, { event: 'APPROVED', body: 'LGTM' });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls/12/reviews');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ event: 'APPROVED', body: 'LGTM' });
});
test('milestones GETs the repo milestones list', async () => {
const fetchMock = vi.fn(async () => jsonResponse([{ id: 33, title: 'v1.0' }]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.milestones('owner', 'repo', { state: 'all', limit: 50 });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/milestones?state=all&limit=50');
});
test('repoLabels GETs the repo labels list', async () => {
const fetchMock = vi.fn(async () => jsonResponse([{ id: 1, name: 'bug', color: 'd73a4a' }]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.repoLabels('owner', 'repo', { limit: 100 });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/labels?limit=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.
+325 -8
View File
@@ -23,9 +23,12 @@ function withTimeout(promise, timeoutMs, label) {
const asString = (value) => (typeof value === 'string' ? value.trim() : '');
// Resolve the requested repo from the query (read routes) or the JSON body
// (write routes). `owner`/`repo` override the directory-local git remote for
// repos checked out from non-Gitea remotes.
const getRequestedRepo = (req) => {
const owner = asString(req.query?.owner);
const repo = asString(req.query?.repo);
const owner = asString(req.query?.owner) || asString(req.body?.owner);
const repo = asString(req.query?.repo) || asString(req.body?.repo);
return owner && repo ? { owner, repo } : null;
};
@@ -130,6 +133,13 @@ const mapGiteaComment = (comment) => ({
createdAt: typeof comment.created_at === 'string' ? comment.created_at : undefined,
});
const mapGiteaIssue = (item) => ({
...mapGiteaIssueSummary(item),
body: typeof item.body === 'string' ? item.body : '',
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
});
// Gitea's pull-files endpoint returns capitalized JSON fields
// (Filename/Status/Additions/Deletions/Patch); tolerate the lowercase GitHub
// style too for Forgejo versions that match GitHub output.
@@ -156,6 +166,24 @@ const giteaErrorMessage = (data) => {
return null;
};
// Gitea issue/PR update endpoints take `milestone` (numeric), not the title.
// Resolve a title via the repo 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, owner, repo, title) => {
const resp = await client.milestones(owner, repo, { state: 'all', limit: 50 });
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 repoRefFromOwnerRepo = (owner, repo, baseUrl) => {
let host = null;
let normalizedBaseUrl = null;
@@ -442,12 +470,7 @@ export function registerGiteaRoutes(app, options = {}) {
}
const item = resp.data;
const issue = {
...mapGiteaIssueSummary(item),
body: typeof item.body === 'string' ? item.body : '',
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
};
const issue = mapGiteaIssue(item);
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), issue });
} catch (error) {
console.error('Failed to fetch Gitea issue:', error);
@@ -497,6 +520,135 @@ export function registerGiteaRoutes(app, options = {}) {
}
});
app.post('/api/gitea/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 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 resp = await client.createIssueComment(owner, repo, number, body);
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 comment on 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: giteaErrorMessage(resp.data) || 'Gitea returned an error while creating the comment' });
}
if (!resp.data) {
return res.status(500).json({ error: 'Gitea returned an empty response while creating the comment' });
}
return res.json({
connected: true,
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
comment: mapGiteaComment(resp.data),
});
} catch (error) {
console.error('Failed to create Gitea issue comment:', error);
return res.status(500).json({ error: error.message || 'Failed to create Gitea issue comment' });
}
});
app.patch('/api/gitea/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 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 body = {};
if (typeof req.body?.title === 'string') {
body.title = req.body.title.trim();
}
if (typeof req.body?.body === 'string') {
body.body = req.body.body;
}
if (req.body?.state === 'open' || req.body?.state === 'closed') {
body.state = req.body.state;
}
// Gitea accepts label names (not ids) in the edit-issue payload.
if (Array.isArray(req.body?.labels)) {
body.labels = req.body.labels.filter((label) => typeof label === 'string');
}
if (Array.isArray(req.body?.assignees)) {
body.assignees = req.body.assignees.filter((login) => typeof login === 'string');
}
if (req.body?.milestone !== undefined) {
if (req.body.milestone === null) {
body.unset_milestone = true;
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
const { milestoneId, rateLimited } = await resolveMilestoneId(client, owner, repo, req.body.milestone.trim());
if (rateLimited) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (milestoneId === null) {
return res.status(400).json({ error: 'Milestone not found' });
}
body.milestone = milestoneId;
}
}
const resp = await client.updateIssue(owner, repo, number, body);
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 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: giteaErrorMessage(resp.data) || 'Gitea returned an error while updating the issue' });
}
if (!resp.data) {
return res.status(500).json({ error: 'Gitea returned an empty response while updating the issue' });
}
return res.json({
connected: true,
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
issue: mapGiteaIssue(resp.data),
});
} catch (error) {
console.error('Failed to update Gitea issue:', error);
return res.status(500).json({ error: error.message || 'Failed to update Gitea issue' });
}
});
// ================= Gitea Pull Request APIs =================
app.get('/api/gitea/prs/list', async (req, res) => {
@@ -963,6 +1115,11 @@ export function registerGiteaRoutes(app, options = {}) {
if (description !== undefined) {
body.body = description;
}
// PRs are issues at the API level in Gitea (the PR number IS the issue
// index), so the edit-issue `state` transition applies directly.
if (req.body?.state === 'open' || req.body?.state === 'closed') {
body.state = req.body.state;
}
const resp = await withTimeout(client.updatePullRequest(owner, repo, number, body), ROUTE_TIMEOUT_MS, 'gitea pr update');
if (resp.status === 429) {
@@ -1047,6 +1204,122 @@ export function registerGiteaRoutes(app, options = {}) {
}
});
// PRs are issues at the API level in Gitea, so a PR comment is an issue
// comment addressed by the PR number (which IS the issue index).
app.post('/api/gitea/prs/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 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 resp = await client.createIssueComment(owner, repo, number, body);
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 comment on pull requests' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Pull request not found' });
}
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 comment' });
}
if (!resp.data) {
return res.status(500).json({ error: 'Gitea returned an empty response while creating the comment' });
}
return res.json({
connected: true,
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
comment: mapGiteaComment(resp.data),
});
} catch (error) {
console.error('Failed to create Gitea pull request comment:', error);
return res.status(500).json({ error: error.message || 'Failed to create Gitea pull request comment' });
}
});
app.post('/api/gitea/prs/review', async (req, res) => {
try {
const directory = asString(req.body?.directory);
const number = typeof req.body?.number === 'number' ? req.body.number : null;
const event = typeof req.body?.event === 'string' ? req.body.event : '';
if (!directory || !number || !event) {
return res.status(400).json({ error: 'directory, number, event are required' });
}
if (event !== 'APPROVED' && event !== 'REQUEST_CHANGES' && event !== 'COMMENT') {
return res.status(400).json({ error: 'event must be APPROVED, REQUEST_CHANGES, or COMMENT' });
}
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 = { event };
if (typeof req.body?.body === 'string' && req.body.body) {
params.body = req.body.body;
}
const resp = await client.createPullReview(owner, repo, number, 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 review pull requests' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Pull request not found' });
}
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 submitting the review' });
}
if (!resp.data) {
return res.status(500).json({ error: 'Gitea returned an empty response while submitting the review' });
}
const review = resp.data;
return res.json({
connected: true,
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
review: {
id: String(review.id),
state: typeof review.state === 'string' ? review.state : event,
author: mapGiteaAuthor(review.user) || null,
...(typeof review.submitted_at === 'string' ? { submittedAt: review.submitted_at } : {}),
body: typeof review.body === 'string' ? review.body : null,
...(typeof review.commit_id === 'string' ? { commitSha: review.commit_id } : null),
},
});
} catch (error) {
console.error('Failed to submit Gitea pull request review:', error);
return res.status(500).json({ error: error.message || 'Failed to submit Gitea pull request review' });
}
});
// ================= Gitea Repo APIs =================
app.get('/api/gitea/repo/branches', async (req, res) => {
@@ -1101,4 +1374,48 @@ export function registerGiteaRoutes(app, options = {}) {
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea repo branches' });
}
});
app.get('/api/gitea/repo/labels', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedRepo = getRequestedRepo(req);
if (!directory && !requestedRepo) {
return res.status(400).json({ error: 'directory or owner/repo is required' });
}
const client = await getClient();
if (!client) {
return res.json({ connected: false, labels: [] });
}
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
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 repo labels',
);
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 fetching repo labels' });
}
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 } : {}),
}));
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), labels });
} catch (error) {
console.error('Failed to fetch Gitea repo labels:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea repo labels' });
}
});
}
@@ -1046,6 +1046,301 @@ describe('Gitea data routes', () => {
expect(statuses.body).toMatchObject({ connected: false, statuses: [] });
});
test('issues/comment POSTs a comment and maps it', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/issues\/7\/comments$/)(url) && options.method === 'POST') {
return jsonResponse({
id: 5,
body: 'Nice catch',
html_url: 'https://gitea.example.com/owner/repo/issues/7#issuecomment-5',
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
created_at: '2026-01-02T11:00:00Z',
}, { status: 201 });
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/issues/comment')
.send({ directory: '/tmp/work', number: 7, body: 'Nice catch' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' },
comment: {
id: 5,
body: 'Nice catch',
url: 'https://gitea.example.com/owner/repo/issues/7#issuecomment-5',
author: { username: 'alice', id: 42 },
createdAt: '2026-01-02T11:00:00Z',
},
});
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
});
test('issues/comment reports connected:false when not authenticated', async () => {
clearGiteaAuth();
const app = createApp();
const response = await request(app)
.post('/api/gitea/issues/comment')
.send({ directory: '/tmp/work', number: 7, body: 'hello' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
});
test('issues/update maps labels, assignees, state, and resolves the milestone title', async () => {
const fetchMock = scriptedFetch([
(url) => (matches(/\/milestones\?/)(url)
? jsonResponse([{ id: 33, title: 'v1.0', state: 'open' }])
: null),
(url, options) => {
if (matches(/\/issues\/7$/)(url) && options.method === 'PATCH') {
return jsonResponse({
number: 7,
title: 'Updated issue',
html_url: 'https://gitea.example.com/owner/repo/issues/7',
state: 'closed',
body: 'New body',
user: { id: 42, login: 'alice' },
labels: [{ id: 1, name: 'bug' }],
assignees: [{ id: 43, login: 'bob' }],
milestone: { id: 33, title: 'v1.0', state: 'open' },
});
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.patch('/api/gitea/issues/update')
.send({
directory: '/tmp/work',
number: 7,
title: 'Updated issue',
body: 'New body',
state: 'closed',
labels: ['bug'],
assignees: ['bob'],
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: 'open' },
},
});
const [, options] = fetchMock.mock.calls[1];
expect(options.method).toBe('PATCH');
expect(JSON.parse(options.body)).toEqual({
title: 'Updated issue',
body: 'New body',
state: 'closed',
labels: ['bug'],
assignees: ['bob'],
milestone: 33,
});
});
test('issues/update clears the milestone with unset_milestone', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/issues\/7$/)(url) && options.method === 'PATCH') {
return jsonResponse({ number: 7, title: 'T', html_url: 'u', state: 'open', user: { login: 'alice' } });
}
return null;
},
]);
const app = createApp();
await request(app)
.patch('/api/gitea/issues/update')
.send({ directory: '/tmp/work', number: 7, milestone: null });
const [, options] = fetchMock.mock.calls[0];
expect(JSON.parse(options.body)).toEqual({ unset_milestone: true });
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)
.patch('/api/gitea/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('prs/comment POSTs a comment on the PR index and maps it', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/issues\/12\/comments$/)(url) && options.method === 'POST') {
return jsonResponse({
id: 8,
body: 'LGTM',
html_url: 'https://gitea.example.com/owner/repo/pulls/12#issuecomment-8',
user: { id: 43, login: 'bob' },
}, { status: 201 });
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/prs/comment')
.send({ directory: '/tmp/work', number: 12, body: 'LGTM' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
comment: {
id: 8,
body: 'LGTM',
url: 'https://gitea.example.com/owner/repo/pulls/12#issuecomment-8',
author: { username: 'bob', id: 43 },
},
});
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ body: 'LGTM' });
});
test('prs/review POSTs event/body and maps the review', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/pulls\/12\/reviews$/)(url) && options.method === 'POST') {
return jsonResponse({
id: 101,
state: 'APPROVED',
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
submitted_at: '2026-01-02T11:00:00Z',
body: 'LGTM',
commit_id: 'abc123def4567890',
}, { status: 201 });
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.post('/api/gitea/prs/review')
.send({ directory: '/tmp/work', number: 12, event: 'APPROVED', body: 'LGTM' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
review: {
id: '101',
state: 'APPROVED',
author: { username: 'alice', id: 42 },
submittedAt: '2026-01-02T11:00:00Z',
body: 'LGTM',
commitSha: 'abc123def4567890',
},
});
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ event: 'APPROVED', body: 'LGTM' });
});
test('prs/review rejects an unsupported event with 400', async () => {
const app = createApp();
const response = await request(app)
.post('/api/gitea/prs/review')
.send({ directory: '/tmp/work', number: 12, event: 'PENDING' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'event must be APPROVED, REQUEST_CHANGES, or COMMENT' });
});
test('repo/labels returns mapped labels', async () => {
scriptedFetch([
(url) => (matches(/\/labels\?/)(url)
? jsonResponse([
{ id: 1, name: 'bug', color: 'd73a4a', description: 'A bug' },
{ id: 2, name: 'enhancement', color: 'a2eeef' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/repo/labels?directory=%2Ftmp%2Fwork');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' },
labels: [
{ id: 1, name: 'bug', color: 'd73a4a', description: 'A bug' },
{ id: 2, name: 'enhancement', color: 'a2eeef' },
],
});
});
test('repo/labels requires directory or owner/repo', async () => {
const app = createApp();
const response = await request(app).get('/api/gitea/repo/labels');
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory or owner/repo is required' });
});
test('pr/update passes state through to the PATCH payload', async () => {
const fetchMock = scriptedFetch([
(url, options) => {
if (matches(/\/pulls\/12$/)(url) && options.method === 'PATCH') {
return jsonResponse({
number: 12,
title: 'T',
html_url: 'u',
state: 'closed',
merged: false,
draft: false,
user: { login: 'alice' },
head: { ref: 'feat/add' },
base: { ref: 'main' },
});
}
return null;
},
]);
const app = createApp();
const response = await request(app)
.patch('/api/gitea/pr/update')
.send({ directory: '/tmp/work', number: 12, state: 'closed' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
pr: { number: 12, state: 'closed' },
});
const [, options] = fetchMock.mock.calls[0];
expect(JSON.parse(options.body)).toEqual({ state: 'closed' });
});
// 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 () => {
scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse({}, { status: 429, headers: { 'retry-after': '30' } }) : null)]);
@@ -67,6 +67,17 @@
- `GET /api/github/pulls/timeline?directory&number&owner&repo` -> `{ connected, repo?, events[] }` (via `octokit.rest.issues.listEventsForTimeline`, each event `{ id, type, author, createdAt, body, commitSha }` with the event name lowercased).
- Both follow the `issues/comments` envelope pattern: unauthenticated -> `connected: false`, unresolvable repo -> `repo: null` with an empty list, `429` -> `503 { error: 'GitHub rate limited' }`, other provider `4xx` -> `502`.
## Write APIs
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`).
- `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.
- `POST /api/github/pulls/review` — body `{ directory, number, event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', body?, owner?, repo? }` -> `{ connected, repo?, review? }` (via `octokit.rest.pulls.createReview`, mapped to `{ id, state, author, submittedAt, body, commitSha }`). Invalidates the PR context cache.
- `POST /api/github/pr/update` — existing route extended with optional `state`, `draft`, `labels`, `assignees`, `milestone`. When any extended field is present it branches to `octokit.rest.issues.update` (milestone title -> number; `draft` applied separately via `octokit.rest.pulls.update`); title/body-only updates keep using `pulls.update`. Invalidates the PR context cache and the repo pulls cache.
## Consumers of PR data
- `packages/ui/src/components/session/SessionSidebar.tsx` reads all PR entries and maps them to `directory::branch`.
+460 -9
View File
@@ -120,8 +120,14 @@ function withTimeout(promise, timeoutMs, label) {
}
function getRequestedRepo(req) {
const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : '';
const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : '';
// GET routes carry owner/repo in the query string; write routes (POST/PATCH)
// carry them in the body. Accept both so the same resolver guards every route.
const owner = typeof req.body?.owner === 'string' && req.body.owner.trim()
? req.body.owner.trim()
: (typeof req.query?.owner === 'string' ? req.query.owner.trim() : '');
const repo = typeof req.body?.repo === 'string' && req.body.repo.trim()
? req.body.repo.trim()
: (typeof req.query?.repo === 'string' ? req.query.repo.trim() : '');
return owner && repo ? { owner, repo } : null;
}
@@ -976,15 +982,74 @@ export function registerGitHubRoutes(app) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
const state = req.body?.state === 'open' || req.body?.state === 'closed' ? req.body.state : undefined;
const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined;
const labels = Array.isArray(req.body?.labels) ? req.body.labels : undefined;
const assignees = Array.isArray(req.body?.assignees) ? req.body.assignees : undefined;
const milestoneProvided = req.body?.milestone !== undefined;
const hasExtendedFields = state !== undefined
|| draft !== undefined
|| labels !== undefined
|| assignees !== undefined
|| milestoneProvided;
let updated;
try {
updated = await octokit.rest.pulls.update({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
title,
...(typeof body === 'string' ? { body } : {}),
});
if (hasExtendedFields) {
// PRs are issues: issues.update carries state/labels/assignees/milestone
// (plus title/body) and works on pull requests. draft is not an
// issues.update field, so it is applied through pulls.update instead.
const issuesParams = {
owner: repo.owner,
repo: repo.repo,
issue_number: number,
title,
...(typeof body === 'string' ? { body } : {}),
...(state !== undefined ? { state } : {}),
...(labels !== undefined ? { labels } : {}),
...(assignees !== undefined ? { assignees } : {}),
};
if (milestoneProvided) {
if (req.body.milestone === null) {
issuesParams.milestone = null;
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
// issues.update accepts the milestone number, not the title — resolve it.
const milestoneTitle = req.body.milestone.trim();
const milestones = await octokit.rest.issues.listMilestonesForRepo({
owner: repo.owner,
repo: repo.repo,
state: 'all',
per_page: 100,
});
const milestone = (Array.isArray(milestones?.data) ? milestones.data : []).find(
(item) => typeof item?.title === 'string' && item.title.toLowerCase() === milestoneTitle.toLowerCase()
);
if (!milestone) {
return res.status(400).json({ error: 'Milestone not found' });
}
issuesParams.milestone = milestone.number;
}
}
updated = await octokit.rest.issues.update(issuesParams);
if (draft !== undefined) {
const draftUpdated = await octokit.rest.pulls.update({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
draft,
});
// The draft write returns the freshest full PR payload.
updated = draftUpdated;
}
} else {
updated = await octokit.rest.pulls.update({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
title,
...(typeof body === 'string' ? { body } : {}),
});
}
} catch (error) {
if (error?.status === 401) {
return res.status(401).json({ error: 'GitHub not connected' });
@@ -1012,6 +1077,8 @@ export function registerGitHubRoutes(app) {
}
invalidatePrContextCache(directory, number);
const { invalidateRepoPullsCache } = await import('./pr-status.js');
invalidateRepoPullsCache(repo.owner, repo.repo);
return res.json({
number: pr.number,
title: pr.title,
@@ -1132,6 +1199,214 @@ export function registerGitHubRoutes(app) {
}
});
// PRs are issues at the API level, so issues.createComment posts a PR
// "comment" (the issue-thread comment, not a review comment).
app.post('/api/github/pulls/comment', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
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 { 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, comment: null });
}
const result = await octokit.rest.issues.createComment({
owner: repo.owner,
repo: repo.repo,
issue_number: number,
body,
});
const comment = result?.data;
if (!comment) {
return res.status(500).json({ error: 'GitHub returned an error while creating the comment' });
}
invalidatePrContextCache(directory, number);
return res.json({
connected: true,
repo,
comment: {
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
},
});
} 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 PR comment:', error);
return res.status(500).json({ error: 'GitHub returned an error' });
}
});
app.post('/api/github/pulls/review-comment', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const number = typeof req.body?.number === 'number' ? req.body.number : null;
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
const inReplyToId = typeof req.body?.inReplyToId === 'number' ? req.body.inReplyToId : undefined;
if (!directory || !number || !body) {
return res.status(400).json({ error: 'directory, number, body are required' });
}
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, comment: null });
}
const createParams = {
owner: repo.owner,
repo: repo.repo,
pull_number: number,
body,
};
if (inReplyToId !== undefined) {
createParams.in_reply_to_id = inReplyToId;
} else {
// New inline comment: requires a path/line anchor and the PR head commit.
const path = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
const line = typeof req.body?.line === 'number' ? req.body.line : null;
if (!path || !line) {
return res.status(400).json({ error: 'path and line are required for a new review comment' });
}
const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
const headSha = prResp?.data?.head?.sha;
if (!headSha) {
return res.status(500).json({ error: 'GitHub returned an error while resolving the PR head commit' });
}
createParams.commit_id = headSha;
createParams.path = path;
createParams.line = line;
}
const result = await octokit.rest.pulls.createReviewComment(createParams);
const comment = result?.data;
if (!comment) {
return res.status(500).json({ error: 'GitHub returned an error while creating the review comment' });
}
invalidatePrContextCache(directory, number);
return res.json({
connected: true,
repo,
comment: {
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
path: comment.path,
line: typeof comment.line === 'number' ? comment.line : null,
position: typeof comment.position === 'number' ? comment.position : null,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
},
});
} 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 review comment:', error);
return res.status(500).json({ error: 'GitHub returned an error' });
}
});
app.post('/api/github/pulls/review', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const number = typeof req.body?.number === 'number' ? req.body.number : null;
const event = typeof req.body?.event === 'string' ? req.body.event : '';
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
if (!directory || !number || !event) {
return res.status(400).json({ error: 'directory, number, event are required' });
}
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, review: null });
}
const result = await octokit.rest.pulls.createReview({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
event,
...(typeof body === 'string' ? { body } : {}),
});
const review = result?.data;
if (!review) {
return res.status(500).json({ error: 'GitHub returned an error while submitting the review' });
}
invalidatePrContextCache(directory, number);
return res.json({
connected: true,
repo,
review: {
id: String(review.id),
state: typeof review.state === 'string' ? review.state : '',
author: mapGitHubUserSummary(review.user),
submittedAt: review.submitted_at,
body: typeof review.body === 'string' ? review.body : null,
commitSha: typeof review.commit_id === 'string' ? review.commit_id : null,
},
});
} 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 submit GitHub review:', error);
return res.status(500).json({ error: 'GitHub returned an error' });
}
});
// ================= GitHub Repo APIs =================
app.get('/api/github/repo/upstream', async (req, res) => {
@@ -1453,6 +1728,182 @@ export function registerGitHubRoutes(app) {
}
});
app.post('/api/github/issues/comment', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
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 { 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, comment: null });
}
const result = await octokit.rest.issues.createComment({
owner: repo.owner,
repo: repo.repo,
issue_number: number,
body,
});
const comment = result?.data;
if (!comment) {
return res.status(500).json({ error: 'GitHub returned an error while creating the comment' });
}
return res.json({
connected: true,
repo,
comment: {
id: comment.id,
url: comment.html_url,
body: comment.body || '',
createdAt: comment.created_at,
updatedAt: comment.updated_at,
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
},
});
} 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 comment:', 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() : '';
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 { 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 params = {
owner: repo.owner,
repo: repo.repo,
issue_number: number,
};
if (typeof req.body?.title === 'string') {
params.title = req.body.title.trim();
}
if (typeof req.body?.body === 'string') {
params.body = req.body.body;
}
if (req.body?.state === 'open' || req.body?.state === 'closed') {
params.state = req.body.state;
}
if (Array.isArray(req.body?.labels)) {
params.labels = req.body.labels;
}
if (Array.isArray(req.body?.assignees)) {
params.assignees = req.body.assignees;
}
if (req.body?.milestone !== undefined) {
if (req.body.milestone === null) {
params.milestone = null;
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
// issues.update accepts the milestone number, not the title — resolve it.
const milestoneTitle = req.body.milestone.trim();
const milestones = await octokit.rest.issues.listMilestonesForRepo({
owner: repo.owner,
repo: repo.repo,
state: 'all',
per_page: 100,
});
const milestone = (Array.isArray(milestones?.data) ? milestones.data : []).find(
(item) => typeof item?.title === 'string' && item.title.toLowerCase() === milestoneTitle.toLowerCase()
);
if (!milestone) {
return res.status(400).json({ error: 'Milestone not found' });
}
params.milestone = milestone.number;
}
}
const result = await octokit.rest.issues.update(params);
const issue = result?.data;
if (!issue) {
return res.status(500).json({ error: 'GitHub returned an error while updating the issue' });
}
return res.json({
connected: true,
repo,
issue: {
number: issue.number,
title: issue.title,
url: issue.html_url,
state: issue.state === 'closed' ? 'closed' : 'open',
body: issue.body || '',
createdAt: issue.created_at,
updatedAt: issue.updated_at,
author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null,
assignees: Array.isArray(issue.assignees)
? issue.assignees
.map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null))
.filter(Boolean)
: [],
labels: Array.isArray(issue.labels)
? issue.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)
: [],
milestone: issue.milestone && typeof issue.milestone === 'object'
? {
title: typeof issue.milestone.title === 'string' ? issue.milestone.title : '',
...(typeof issue.milestone.state === 'string' ? { state: issue.milestone.state } : {}),
}
: null,
commentsCount: typeof issue.comments === 'number' ? issue.comments : undefined,
},
});
} 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 update GitHub issue:', error);
return res.status(500).json({ error: 'GitHub returned an error' });
}
});
// ================= GitHub Pull Request Context APIs =================
app.get('/api/github/pulls/list', async (req, res) => {
+498 -2
View File
@@ -10,8 +10,22 @@ const mockState = vi.hoisted(() => ({
clearGitHubAuth: vi.fn(),
octokit: {
rest: {
pulls: { listCommits: vi.fn() },
issues: { listEventsForTimeline: vi.fn() },
pulls: {
listCommits: vi.fn(),
get: vi.fn(),
update: vi.fn(),
createReview: vi.fn(),
createReviewComment: vi.fn(),
listReviewComments: vi.fn(),
listFiles: vi.fn(),
},
issues: {
listEventsForTimeline: vi.fn(),
createComment: vi.fn(),
update: vi.fn(),
listMilestonesForRepo: vi.fn(),
listComments: vi.fn(),
},
},
},
}));
@@ -37,7 +51,17 @@ beforeEach(() => {
mockState.getOctokitOrNull.mockReset();
mockState.clearGitHubAuth.mockReset();
mockState.octokit.rest.pulls.listCommits.mockReset();
mockState.octokit.rest.pulls.get.mockReset();
mockState.octokit.rest.pulls.update.mockReset();
mockState.octokit.rest.pulls.createReview.mockReset();
mockState.octokit.rest.pulls.createReviewComment.mockReset();
mockState.octokit.rest.pulls.listReviewComments.mockReset();
mockState.octokit.rest.pulls.listFiles.mockReset();
mockState.octokit.rest.issues.listEventsForTimeline.mockReset();
mockState.octokit.rest.issues.createComment.mockReset();
mockState.octokit.rest.issues.update.mockReset();
mockState.octokit.rest.issues.listMilestonesForRepo.mockReset();
mockState.octokit.rest.issues.listComments.mockReset();
mockState.getOctokitOrNull.mockImplementation(() => mockState.octokit);
});
@@ -132,3 +156,475 @@ describe('GitHub pull request enrichment routes', () => {
expect(response.body).toEqual({ error: 'directory and number are required' });
});
});
describe('GitHub write routes', () => {
test('issues/comment creates a comment and returns the envelope', async () => {
mockState.octokit.rest.issues.createComment.mockResolvedValue({
data: {
id: 1001,
html_url: 'https://github.com/owner/repo/issues/7#issuecomment-1001',
body: 'Hello',
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' },
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/issues/comment')
.send({ directory: '/tmp/work', number: 7, body: 'Hello' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
comment: {
id: 1001,
url: 'https://github.com/owner/repo/issues/7#issuecomment-1001',
body: 'Hello',
createdAt: '2026-01-01T10:00:00Z',
author: { login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' },
},
});
expect(mockState.octokit.rest.issues.createComment).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
issue_number: 7,
body: 'Hello',
});
});
test('issues/comment returns connected:false when not authenticated', async () => {
mockState.getOctokitOrNull.mockImplementation(() => null);
const app = createApp();
const response = await request(app)
.post('/api/github/issues/comment')
.send({ directory: '/tmp/work', number: 7, body: 'Hello' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
});
test('issues/comment requires directory, number, and body', async () => {
const app = createApp();
const response = await request(app)
.post('/api/github/issues/comment')
.send({ directory: '/tmp/work', number: 7 });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory, number, body are required' });
});
test('issues/update passes state and labels through', async () => {
mockState.octokit.rest.issues.update.mockResolvedValue({
data: {
number: 7,
title: 'Bug',
body: 'desc',
html_url: 'https://github.com/owner/repo/issues/7',
state: 'closed',
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-02T10:00:00Z',
user: { login: 'alice', id: 42, avatar_url: 'u' },
labels: [{ name: 'bug', color: 'd73a4a' }],
assignees: [{ login: 'bob', id: 43, avatar_url: 'u' }],
milestone: { title: 'v1.0', state: 'open' },
comments: 3,
},
});
const app = createApp();
const response = await request(app)
.patch('/api/github/issues/update')
.send({ directory: '/tmp/work', number: 7, state: 'closed', labels: ['bug'] });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
issue: {
number: 7,
title: 'Bug',
state: 'closed',
labels: [{ name: 'bug', color: 'd73a4a' }],
assignees: [{ login: 'bob', id: 43 }],
milestone: { title: 'v1.0', state: 'open' },
commentsCount: 3,
},
});
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
issue_number: 7,
state: 'closed',
labels: ['bug'],
});
});
test('issues/update resolves milestone title to a number (case-insensitive)', async () => {
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({
data: [{ number: 5, title: 'v1.0', state: 'open' }],
});
mockState.octokit.rest.issues.update.mockResolvedValue({
data: { number: 7, title: 'Bug', html_url: 'u', state: 'open', user: null, labels: [], assignees: [], milestone: null, body: '' },
});
const app = createApp();
const response = await request(app)
.patch('/api/github/issues/update')
.send({ directory: '/tmp/work', number: 7, milestone: 'V1.0' });
expect(response.status).toBe(200);
expect(mockState.octokit.rest.issues.listMilestonesForRepo).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
state: 'all',
per_page: 100,
});
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith(
expect.objectContaining({ milestone: 5 })
);
});
test('issues/update returns 400 when the milestone title matches nothing', async () => {
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({ data: [] });
const app = createApp();
const response = await request(app)
.patch('/api/github/issues/update')
.send({ directory: '/tmp/work', number: 7, milestone: 'nope' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Milestone not found' });
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
});
test('issues/update passes milestone null through to clear it', async () => {
mockState.octokit.rest.issues.update.mockResolvedValue({
data: { number: 7, title: 'Bug', html_url: 'u', state: 'open', user: null, labels: [], assignees: [], milestone: null, body: '' },
});
const app = createApp();
const response = await request(app)
.patch('/api/github/issues/update')
.send({ directory: '/tmp/work', number: 7, milestone: null });
expect(response.status).toBe(200);
expect(mockState.octokit.rest.issues.listMilestonesForRepo).not.toHaveBeenCalled();
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith(
expect.objectContaining({ milestone: null })
);
});
test('pulls/comment posts to the PR issue thread', async () => {
mockState.octokit.rest.issues.createComment.mockResolvedValue({
data: {
id: 2001,
html_url: 'https://github.com/owner/repo/pull/9#issuecomment-2001',
body: 'Thanks',
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
user: { login: 'alice', id: 42, avatar_url: 'u' },
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/pulls/comment')
.send({ directory: '/tmp/work', number: 9, body: 'Thanks' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
comment: { id: 2001, body: 'Thanks', author: { login: 'alice', id: 42 } },
});
expect(mockState.octokit.rest.issues.createComment).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
issue_number: 9,
body: 'Thanks',
});
});
test('pulls/review-comment creates a reply when inReplyToId is provided', async () => {
mockState.octokit.rest.pulls.createReviewComment.mockResolvedValue({
data: {
id: 3001,
html_url: 'u',
body: 'reply',
path: 'src/a.ts',
line: 3,
position: null,
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
user: { login: 'alice', id: 42, avatar_url: 'u' },
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/pulls/review-comment')
.send({ directory: '/tmp/work', number: 9, body: 'reply', inReplyToId: 2999 });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
comment: { id: 3001, body: 'reply', path: 'src/a.ts', line: 3 },
});
expect(mockState.octokit.rest.pulls.createReviewComment).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
body: 'reply',
in_reply_to_id: 2999,
});
expect(mockState.octokit.rest.pulls.get).not.toHaveBeenCalled();
});
test('pulls/review-comment resolves the PR head sha for a new inline comment', async () => {
mockState.octokit.rest.pulls.get.mockResolvedValue({ data: { head: { sha: 'abc123def4567890' } } });
mockState.octokit.rest.pulls.createReviewComment.mockResolvedValue({
data: {
id: 3002,
html_url: 'u',
body: 'nit',
path: 'src/a.ts',
line: 5,
position: 1,
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
user: { login: 'alice', id: 42, avatar_url: 'u' },
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/pulls/review-comment')
.send({ directory: '/tmp/work', number: 9, body: 'nit', path: 'src/a.ts', line: 5 });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
comment: { id: 3002, body: 'nit', path: 'src/a.ts', line: 5, position: 1 },
});
expect(mockState.octokit.rest.pulls.get).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
});
expect(mockState.octokit.rest.pulls.createReviewComment).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
body: 'nit',
commit_id: 'abc123def4567890',
path: 'src/a.ts',
line: 5,
});
});
test('pulls/review-comment requires path and line for a new inline comment', async () => {
const app = createApp();
const response = await request(app)
.post('/api/github/pulls/review-comment')
.send({ directory: '/tmp/work', number: 9, body: 'nit' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'path and line are required for a new review comment' });
expect(mockState.octokit.rest.pulls.createReviewComment).not.toHaveBeenCalled();
});
test('pulls/review maps the submitted review and invalidates the PR context cache', async () => {
mockState.octokit.rest.pulls.get.mockResolvedValue({
data: {
number: 9,
title: 'T',
body: '',
html_url: 'u',
state: 'open',
draft: false,
base: { ref: 'main' },
head: { ref: 'feature' },
user: { login: 'alice', id: 42, avatar_url: 'u' },
},
});
mockState.octokit.rest.issues.listComments.mockResolvedValue({ data: [] });
mockState.octokit.rest.pulls.listReviewComments.mockResolvedValue({ data: [] });
mockState.octokit.rest.pulls.listFiles.mockResolvedValue({ data: [] });
const app = createApp();
await request(app).get('/api/github/pulls/context?directory=%2Ftmp%2Fwork&number=9');
const pullsGetCallsAfterContext = mockState.octokit.rest.pulls.get.mock.calls.length;
mockState.octokit.rest.pulls.createReview.mockResolvedValue({
data: {
id: 4001,
state: 'APPROVED',
submitted_at: '2026-01-01T10:00:00Z',
body: 'LGTM',
commit_id: 'abc123def4567890',
user: { login: 'alice', id: 42, avatar_url: 'u' },
},
});
const reviewResponse = await request(app)
.post('/api/github/pulls/review')
.send({ directory: '/tmp/work', number: 9, event: 'APPROVE', body: 'LGTM' });
expect(reviewResponse.status).toBe(200);
expect(reviewResponse.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
review: {
id: '4001',
state: 'APPROVED',
submittedAt: '2026-01-01T10:00:00Z',
body: 'LGTM',
commitSha: 'abc123def4567890',
author: { login: 'alice', id: 42 },
},
});
expect(mockState.octokit.rest.pulls.createReview).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
event: 'APPROVE',
body: 'LGTM',
});
// The PR context cache must have been invalidated: the next context fetch
// re-resolves the PR instead of serving the cached copy.
await request(app).get('/api/github/pulls/context?directory=%2Ftmp%2Fwork&number=9');
expect(mockState.octokit.rest.pulls.get.mock.calls.length).toBe(pullsGetCallsAfterContext + 1);
});
test('pulls/review requires directory, number, and event', async () => {
const app = createApp();
const response = await request(app)
.post('/api/github/pulls/review')
.send({ directory: '/tmp/work', number: 9 });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory, number, event are required' });
});
test('pr/update branches to issues.update and applies draft via pulls.update', async () => {
mockState.octokit.rest.issues.update.mockResolvedValue({
data: {
number: 9,
title: 'T',
body: '',
html_url: 'u',
state: 'open',
draft: false,
base: { ref: 'main' },
head: { ref: 'feature' },
mergeable: true,
mergeable_state: 'clean',
user: null,
labels: [{ name: 'bug', color: 'd73a4a' }],
assignees: [],
milestone: null,
},
});
mockState.octokit.rest.pulls.update.mockResolvedValue({
data: {
number: 9,
title: 'T',
body: '',
html_url: 'u',
state: 'open',
draft: true,
base: { ref: 'main' },
head: { ref: 'feature' },
mergeable: true,
mergeable_state: 'clean',
user: null,
labels: [{ name: 'bug', color: 'd73a4a' }],
assignees: [],
milestone: null,
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/pr/update')
.send({
directory: '/tmp/work',
number: 9,
title: 'T',
state: 'closed',
draft: true,
labels: ['bug'],
assignees: ['alice'],
milestone: null,
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ number: 9, state: 'open', draft: true });
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith(
expect.objectContaining({
owner: 'owner',
repo: 'repo',
issue_number: 9,
state: 'closed',
labels: ['bug'],
assignees: ['alice'],
milestone: null,
})
);
expect(mockState.octokit.rest.pulls.update).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
draft: true,
});
});
test('pr/update keeps title/body on pulls.update when no extended fields are present', async () => {
mockState.octokit.rest.pulls.update.mockResolvedValue({
data: {
number: 9,
title: 'New title',
body: '',
html_url: 'u',
state: 'open',
draft: false,
base: { ref: 'main' },
head: { ref: 'feature' },
mergeable: true,
mergeable_state: 'clean',
user: null,
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/pr/update')
.send({ directory: '/tmp/work', number: 9, title: 'New title' });
expect(response.status).toBe(200);
expect(mockState.octokit.rest.pulls.update).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
title: 'New title',
});
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
});
test('pr/update returns 400 when the milestone title matches nothing', async () => {
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({ data: [] });
const app = createApp();
const response = await request(app)
.post('/api/github/pr/update')
.send({ directory: '/tmp/work', number: 9, title: 'T', milestone: 'nope' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Milestone not found' });
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
});
});
@@ -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.
+10
View File
@@ -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.
+310 -23
View File
@@ -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)]);
+78
View File
@@ -2,9 +2,13 @@ import type {
GiteaAPI,
GiteaAuthStatus,
GiteaBranchesResult,
GiteaIssueCommentInput,
GiteaIssueCommentResult,
GiteaIssueCommentsResult,
GiteaIssueGetResult,
GiteaIssuesListResult,
GiteaIssueUpdateInput,
GiteaIssueUpdateResult,
GiteaPullRequest,
GiteaPullRequestCommitsResult,
GiteaPullRequestContextResult,
@@ -15,6 +19,9 @@ import type {
GiteaPullRequestsListResult,
GiteaPullRequestStatusesResult,
GiteaPullRequestUpdateInput,
GiteaPullReviewInput,
GiteaPullReviewResult,
GiteaRepoLabelsResult,
GiteaUserSummary,
} from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
@@ -287,6 +294,77 @@ export const createWebGiteaAPI = ({ urls }: WebGiteaAPIOptions): GiteaAPI => ({
};
},
async issueComment(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult> {
const response = await runtimeFetch('/api/gitea/issues/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GiteaIssueCommentResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to post Gitea issue comment');
}
return body;
},
async issueUpdate(input: GiteaIssueUpdateInput): Promise<GiteaIssueUpdateResult> {
const response = await runtimeFetch('/api/gitea/issues/update', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GiteaIssueUpdateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to update Gitea issue');
}
return body;
},
async prComment(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult> {
const response = await runtimeFetch('/api/gitea/prs/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GiteaIssueCommentResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to post Gitea pull request comment');
}
return body;
},
async prSubmitReview(input: GiteaPullReviewInput): Promise<GiteaPullReviewResult> {
const response = await runtimeFetch('/api/gitea/prs/review', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GiteaPullReviewResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to submit Gitea pull request review');
}
return body;
},
async repoLabels(directory: string, options?: { owner?: string; repo?: string }): Promise<GiteaRepoLabelsResult> {
const params = new URLSearchParams({ directory });
if (options?.owner) {
params.set('owner', options.owner);
}
if (options?.repo) {
params.set('repo', options.repo);
}
const response = await runtimeFetch(
`/api/gitea/repo/labels?${params.toString()}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const body = await jsonOrNull<GiteaRepoLabelsResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to fetch Gitea repo labels');
}
return body;
},
async repoBranches(owner: string, repo: string): Promise<GiteaBranchesResult> {
const response = await runtimeFetch(
`/api/gitea/repo/branches?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`,
+73
View File
@@ -2,7 +2,11 @@ import type {
GitHubAPI,
GitHubAuthStatus,
GitHubIssueCommentsResult,
GitHubIssueCommentInput,
GitHubIssueCommentResult,
GitHubIssueGetResult,
GitHubIssueUpdateInput,
GitHubIssueUpdateResult,
GitHubIssuesListResult,
GitHubPullRequestContextResult,
GitHubPullRequestCommitsResult,
@@ -16,7 +20,11 @@ import type {
GitHubPullRequestReadyResult,
GitHubPullRequestUpdateInput,
GitHubPullRequestStatus,
GitHubPullRequestReviewInput,
GitHubPullRequestReviewResult,
GitHubRepoUpstreamResult,
GitHubReviewCommentInput,
GitHubReviewCommentResult,
GitHubDeviceFlowComplete,
GitHubDeviceFlowStart,
GitHubUserSummary,
@@ -325,4 +333,69 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
}
return payload;
},
async issueComment(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult> {
const response = await runtimeFetch('/api/github/issues/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitHubIssueCommentResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to post GitHub comment');
}
return body;
},
async issueUpdate(input: GitHubIssueUpdateInput): Promise<GitHubIssueUpdateResult> {
const response = await runtimeFetch('/api/github/issues/update', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitHubIssueUpdateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to update GitHub issue');
}
return body;
},
async prComment(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult> {
const response = await runtimeFetch('/api/github/pulls/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitHubIssueCommentResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to post GitHub PR comment');
}
return body;
},
async prReviewComment(input: GitHubReviewCommentInput): Promise<GitHubReviewCommentResult> {
const response = await runtimeFetch('/api/github/pulls/review-comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitHubReviewCommentResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to post GitHub review comment');
}
return body;
},
async prSubmitReview(input: GitHubPullRequestReviewInput): Promise<GitHubPullRequestReviewResult> {
const response = await runtimeFetch('/api/github/pulls/review', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitHubPullRequestReviewResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to submit GitHub review');
}
return body;
},
});
+60
View File
@@ -2,9 +2,13 @@ import type {
GitLabAPI,
GitLabAuthStatus,
GitLabBranchesResult,
GitLabIssueCommentResult,
GitLabIssueCommentsResult,
GitLabIssueCommentInput,
GitLabIssueGetResult,
GitLabIssuesListResult,
GitLabIssueUpdateInput,
GitLabIssueUpdateResult,
GitLabMergeRequest,
GitLabMergeRequestCommitsResult,
GitLabMergeRequestContextResult,
@@ -16,6 +20,10 @@ import type {
GitLabMergeRequestTimelineResult,
GitLabMergeRequestUpdateInput,
GitLabMergeRequestUpdateResult,
GitLabMrApproveInput,
GitLabMrApproveResult,
GitLabMrNoteInput,
GitLabMrNoteResult,
GitLabUserSummary,
} from '@openchamber/ui/lib/api/types';
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
@@ -266,6 +274,58 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
};
},
async issueComment(input: GitLabIssueCommentInput): Promise<GitLabIssueCommentResult> {
const response = await runtimeFetch('/api/gitlab/issues/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitLabIssueCommentResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to post GitLab issue comment');
}
return body;
},
async issueUpdate(input: GitLabIssueUpdateInput): Promise<GitLabIssueUpdateResult> {
const response = await runtimeFetch('/api/gitlab/issues/update', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitLabIssueUpdateResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to update GitLab issue');
}
return body;
},
async mrComment(input: GitLabMrNoteInput): Promise<GitLabMrNoteResult> {
const response = await runtimeFetch('/api/gitlab/mrs/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitLabMrNoteResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to post GitLab merge request comment');
}
return body;
},
async mrApprove(input: GitLabMrApproveInput): Promise<GitLabMrApproveResult> {
const response = await runtimeFetch('/api/gitlab/mrs/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(input),
});
const body = await jsonOrNull<GitLabMrApproveResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to approve GitLab merge request');
}
return body;
},
async repoBranches(namespace: string, project: string): Promise<GitLabBranchesResult> {
const response = await runtimeFetch(
`/api/gitlab/repo/branches?namespace=${encodeURIComponent(namespace)}&project=${encodeURIComponent(project)}`,