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