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
+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) => {