feat(ui): rich forge entity views — commits, files/diff, timeline, checks, metadata chips

- server: new read routes for PR/MR commits, timeline, reviews, commit-statuses across github/gitlab/gitea; enrich PR/issue summaries with labels/assignees/milestone/commentsCount
- ui: forge facade gains getCommits/getTimeline/getChecks; shared ForgeEntityDetailView + section components; mounted into PR/MR views and issue sections
This commit is contained in:
2026-08-16 16:29:24 +00:00
parent f02c33700b
commit 92f0eced34
44 changed files with 3996 additions and 684 deletions
@@ -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)`, `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)`, `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)`.
- `getGiteaClientOrNull()`: client for the current account, or `null`.
- `isGiteaRateLimited()` / `noteGiteaRateLimit(error)`: own module-level rate-limit cooldown (not shared with the GitHub/GitLab modules).
@@ -72,6 +72,9 @@
- PR detail: `GET /repos/{owner}/{repo}/pulls/{number}`.
- PR files: `GET /repos/{owner}/{repo}/pulls/{number}/files?patch=true` (capitalized JSON fields `Filename`/`Status`/`Additions`/`Deletions`/`Patch`; a `404` on older Gitea instances falls back to `files: []`).
- PR diff: `GET /repos/{owner}/{repo}/pulls/{number}.diff` (raw text; falls back to concatenated per-file patches when it fails).
- PR commits: `GET /repos/{owner}/{repo}/pulls/{number}/commits?limit=100` (mapped to `{ sha, message, summary, author, committedAt, parents }`).
- 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 merge: `POST /repos/{owner}/{repo}/pulls/{number}/merge` with `{ Do: true, MergeMethod: 'merge' | 'squash' | 'rebase' }` (`method` defaults to `'merge'`).
@@ -92,6 +95,9 @@
| GET | `/api/gitea/issues/comments` | `?directory&number&owner&repo` -> `{ connected, repo?, comments[] }` |
| GET | `/api/gitea/prs/list` | `?directory&page&query&sourceBranch` -> `{ connected, repo?, prs[], page, hasMore }` |
| GET | `/api/gitea/pr/context` | `?directory&number&includeDiff&owner&repo` -> `{ connected, repo?, pr, comments[], files[], diff? }` |
| GET | `/api/gitea/prs/commits` | `?directory&number&owner&repo` -> `{ connected, repo?, commits[] }` |
| 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 |
| 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 }` |
+6
View File
@@ -271,6 +271,12 @@ export function createGiteaClient({ token, baseUrl }) {
request(`/repos/${owner}/${repo}/pulls/${number}.diff`, { raw: true }),
pullRequestFiles: (owner, repo, number, params = {}) =>
request(`/repos/${owner}/${repo}/pulls/${number}/files`, { query: params }),
pullRequestCommits: (owner, repo, number, params = {}) =>
request(`/repos/${owner}/${repo}/pulls/${number}/commits`, { query: params }),
pullRequestReviews: (owner, repo, number, params = {}) =>
request(`/repos/${owner}/${repo}/pulls/${number}/reviews`, { query: params }),
commitStatuses: (owner, repo, sha, params = {}) =>
request(`/repos/${owner}/${repo}/commits/${sha}/statuses`, { query: params }),
createPullRequest: (owner, repo, body) =>
request(`/repos/${owner}/${repo}/pulls`, { method: 'POST', body }),
updatePullRequest: (owner, repo, number, body) =>
+217
View File
@@ -75,6 +75,16 @@ const mapGiteaIssueSummary = (item) => ({
state: typeof item.state === 'string' ? item.state : 'open',
author: mapGiteaAuthor(item.user) || {},
labels: mapGiteaLabels(item.labels),
assignees: Array.isArray(item.assignees)
? item.assignees.map(mapGiteaAuthor).filter((user) => user?.username)
: [],
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.comments === 'number' ? item.comments : undefined,
});
const mapGiteaPullRequestSummary = (item) => {
@@ -88,6 +98,16 @@ const mapGiteaPullRequestSummary = (item) => {
draft: Boolean(item.draft),
author: mapGiteaAuthor(item.user) || {},
labels: mapGiteaLabels(item.labels),
assignees: Array.isArray(item.assignees)
? item.assignees.map(mapGiteaAuthor).filter((user) => user?.username)
: [],
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.comments === 'number' ? item.comments : undefined,
sourceBranch: typeof item.head?.ref === 'string' ? item.head.ref : '',
targetBranch: typeof item.base?.ref === 'string' ? item.base.ref : '',
};
@@ -657,6 +677,203 @@ export function registerGiteaRoutes(app, options = {}) {
}
});
// ================= Gitea Pull Request Enrichment APIs =================
// Gitea commit objects carry a top-level `author` (the GitHub-style user,
// with `login`) plus a `commit.author` (git identity: name/email/date).
// Prefer the user when present, else fall back to the git identity so the
// author chip still renders something useful.
const mapGiteaCommitAuthor = (commit) => {
const userAuthor = mapGiteaAuthor(commit?.author);
if (userAuthor?.username) {
return userAuthor;
}
const gitAuthor = commit?.commit?.author;
if (gitAuthor && typeof gitAuthor.name === 'string' && gitAuthor.name) {
return {
username: gitAuthor.name,
...(typeof gitAuthor.email === 'string' ? { email: gitAuthor.email } : {}),
};
}
return null;
};
app.get('/api/gitea/prs/commits', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedRepo = getRequestedRepo(req);
const number = getRequiredNumber(req);
if (!directory && !requestedRepo) {
return res.status(400).json({ error: 'directory or owner/repo is required' });
}
if (!number) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
if (!client) {
return res.json({ connected: false, commits: [] });
}
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
if (!owner || !repo) {
return res.json({ connected: true, repo: null, commits: [] });
}
const resp = await withTimeout(
client.pullRequestCommits(owner, repo, number, { limit: 100 }),
ROUTE_TIMEOUT_MS,
'gitea pr commits',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Pull request not found' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'Gitea returned an error while fetching pull request commits' });
}
const commits = (Array.isArray(resp.data) ? resp.data : []).map((commit) => {
const message = typeof commit.commit?.message === 'string' ? commit.commit.message : '';
return {
sha: typeof commit.sha === 'string' ? commit.sha : '',
message,
...(message.split('\n')[0] ? { summary: message.split('\n')[0] } : {}),
author: mapGiteaCommitAuthor(commit),
...(typeof commit.commit?.author?.date === 'string' ? { committedAt: commit.commit.author.date } : {}),
parents: Array.isArray(commit.parents) ? commit.parents.map((parent) => parent.sha) : [],
};
});
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), commits });
} catch (error) {
console.error('Failed to fetch Gitea pull request commits:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea pull request commits' });
}
});
app.get('/api/gitea/prs/reviews', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedRepo = getRequestedRepo(req);
const number = getRequiredNumber(req);
if (!directory && !requestedRepo) {
return res.status(400).json({ error: 'directory or owner/repo is required' });
}
if (!number) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
if (!client) {
return res.json({ connected: false, reviews: [] });
}
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
if (!owner || !repo) {
return res.json({ connected: true, repo: null, reviews: [] });
}
const resp = await withTimeout(
client.pullRequestReviews(owner, repo, number, { limit: 100 }),
ROUTE_TIMEOUT_MS,
'gitea pr reviews',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Pull request not found' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'Gitea returned an error while fetching pull request reviews' });
}
const reviews = (Array.isArray(resp.data) ? resp.data : []).map((review) => ({
id: String(review.id),
state: typeof review.state === 'string' ? review.state : 'PENDING',
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),
}));
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), reviews });
} catch (error) {
console.error('Failed to fetch Gitea pull request reviews:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea pull request reviews' });
}
});
app.get('/api/gitea/prs/statuses', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedRepo = getRequestedRepo(req);
const number = getRequiredNumber(req);
if (!directory && !requestedRepo) {
return res.status(400).json({ error: 'directory or owner/repo is required' });
}
if (!number) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
if (!client) {
return res.json({ connected: false, statuses: [] });
}
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
if (!owner || !repo) {
return res.json({ connected: true, repo: null, statuses: [] });
}
// Resolve the PR head SHA first — Gitea's commit-status endpoint is
// keyed by commit SHA, not PR number. The PR payload's `head.sha` is the
// head commit (same field family as `head.ref` used elsewhere).
const prResp = await withTimeout(client.pullRequest(owner, repo, number), ROUTE_TIMEOUT_MS, 'gitea pr statuses');
if (prResp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (prResp.status === 404) {
return res.status(404).json({ error: 'Pull request not found' });
}
if (prResp.status !== 200 || !prResp.data) {
return res.status(502).json({ error: 'Gitea returned an error while fetching the pull request' });
}
const headSha = typeof prResp.data.head?.sha === 'string' ? prResp.data.head.sha : null;
if (!headSha) {
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), statuses: [] });
}
const statusesResp = await withTimeout(
client.commitStatuses(owner, repo, headSha, { limit: 100 }),
ROUTE_TIMEOUT_MS,
'gitea commit statuses',
);
if (statusesResp.status === 429) {
return res.status(503).json({ error: 'Gitea rate limited' });
}
if (statusesResp.status !== 200) {
return res.status(502).json({ error: 'Gitea returned an error while fetching commit statuses' });
}
const statuses = (Array.isArray(statusesResp.data) ? statusesResp.data : []).map((status) => ({
state: typeof status.state === 'string' ? status.state.toLowerCase() : 'unknown',
name: typeof status.context === 'string' ? status.context : '',
...(typeof status.description === 'string' ? { description: status.description } : null),
...(typeof status.target_url === 'string' ? { url: status.target_url } : null),
...(typeof status.created_at === 'string' ? { createdAt: status.created_at } : {}),
}));
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), statuses });
} catch (error) {
console.error('Failed to fetch Gitea pull request statuses:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea pull request statuses' });
}
});
// ================= Gitea Pull Request Write APIs =================
app.post('/api/gitea/pr/create', async (req, res) => {
@@ -921,6 +921,131 @@ describe('Gitea data routes', () => {
expect(response.body).toEqual({ error: 'Your Gitea token needs write:repository scope to merge pull requests' });
});
test('prs/commits maps pull request commits with summaries', async () => {
scriptedFetch([
(url) => (matches(/\/pulls\/9\/commits\?/)(url)
? jsonResponse([
{
sha: 'abc123def4567890',
commit: {
message: 'Add the API\n\nAdds the public API',
author: { name: 'Alice Example', email: 'alice@example.com', date: '2026-01-01T10:00:00Z' },
},
author: { id: 42, login: 'alice', full_name: 'Alice Example' },
parents: [{ sha: 'parent-one' }, { sha: 'parent-two' }],
},
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/prs/commits?directory=%2Ftmp%2Fwork&number=9');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
commits: [
{
sha: 'abc123def4567890',
message: 'Add the API\n\nAdds the public API',
summary: 'Add the API',
author: { username: 'alice', id: 42 },
committedAt: '2026-01-01T10:00:00Z',
parents: ['parent-one', 'parent-two'],
},
],
});
});
test('prs/reviews passes review state through and maps the author', async () => {
scriptedFetch([
(url) => (matches(/\/pulls\/9\/reviews\?/)(url)
? 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',
},
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/prs/reviews?directory=%2Ftmp%2Fwork&number=9');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
reviews: [
{
id: '101',
state: 'APPROVED',
author: { username: 'alice', id: 42 },
submittedAt: '2026-01-02T11:00:00Z',
body: 'LGTM',
commitSha: 'abc123def4567890',
},
],
});
});
test('prs/statuses resolves the PR head SHA then maps commit statuses', async () => {
const fetchMock = scriptedFetch([
(url) => (matches(/\/pulls\/9$/)(url)
? jsonResponse({
number: 9,
title: 'Add the API',
html_url: 'u',
state: 'open',
merged: false,
user: { login: 'alice' },
head: { ref: 'feat/api', sha: 'abc123def4567890' },
base: { ref: 'main' },
})
: null),
(url) => (matches(/\/commits\/abc123def4567890\/statuses\?/)(url)
? jsonResponse([
{ id: 1, state: 'success', context: 'ci/test', description: 'All good', target_url: 'https://ci.example.com/run/1', created_at: '2026-01-02T12:00:00Z' },
{ id: 2, state: 'error', context: 'lint' },
{ id: 3, state: 'warning', context: 'docs' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitea/prs/statuses?directory=%2Ftmp%2Fwork&number=9');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
statuses: [
{ state: 'success', name: 'ci/test', description: 'All good', url: 'https://ci.example.com/run/1', createdAt: '2026-01-02T12:00:00Z' },
{ state: 'error', name: 'lint' },
{ state: 'warning', name: 'docs' },
],
});
const requestedUrls = fetchMock.mock.calls.map(([url]) => String(url));
expect(requestedUrls.some((url) => url.includes('/pulls/9'))).toBe(true);
expect(requestedUrls.some((url) => url.includes('/commits/abc123def4567890/statuses'))).toBe(true);
});
test('prs/commits, prs/reviews, and prs/statuses report connected:false when not authenticated', async () => {
clearGiteaAuth();
const app = createApp();
const commits = await request(app).get('/api/gitea/prs/commits?directory=%2Ftmp%2Fwork&number=9');
const reviews = await request(app).get('/api/gitea/prs/reviews?directory=%2Ftmp%2Fwork&number=9');
const statuses = await request(app).get('/api/gitea/prs/statuses?directory=%2Ftmp%2Fwork&number=9');
expect(commits.body).toMatchObject({ connected: false, commits: [] });
expect(reviews.body).toMatchObject({ connected: false, reviews: [] });
expect(statuses.body).toMatchObject({ connected: false, statuses: [] });
});
test('data routes surface a 503 when Gitea rate limits', async () => {
scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse({}, { status: 429, headers: { 'retry-after': '30' } }) : null)]);
@@ -61,6 +61,12 @@
- The route then enriches that result with checks, mergeability, and permission-related fields.
- The client caches and shares the result between sidebar and Git view.
## Enrichment read APIs
- `GET /api/github/pulls/commits?directory&number&owner&repo` -> `{ connected, repo?, commits[] }` (via `octokit.rest.pulls.listCommits`, mapped to `{ sha, shortSha, message, summary, author, committer, committedAt, parents }`).
- `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`.
## Consumers of PR data
- `packages/ui/src/components/session/SessionSidebar.tsx` reads all PR entries and maps them to `directory::branch`.
+164
View File
@@ -11,6 +11,10 @@ let resolvedAuthLoginPromise = null;
const PR_CONTEXT_CACHE_TTL_MS = 30_000;
const PR_CONTEXT_CACHE_MAX_ENTRIES = 50;
const prContextCache = new Map();
// Route-level budget for single-call enrichment routes (pulls/commits,
// pulls/timeline). Octokit bounds each request at 8s; this caps the whole
// route so a slow upstream cannot hold a response (and a client socket) open.
const ROUTE_TIMEOUT_MS = 15_000;
function invalidatePrContextCache(directory, number) {
for (const key of prContextCache.keys()) {
@@ -139,6 +143,40 @@ async function resolveRepoForRequest(octokit, directory, requestedRepo) {
return allowed ? requestedRepo : null;
}
// Shared mappers for PR/issue enrichment fields (labels/assignees/milestone/
// comments) used by pulls/list, pulls/context, and issues/get.
const mapGitHubUserSummary = (user) => (
user && typeof user === 'object'
? { login: user.login, id: user.id, avatarUrl: user.avatar_url }
: null
);
const mapGitHubLabels = (labels) => (
Array.isArray(labels)
? 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)
: []
);
const mapGitHubMilestone = (milestone) => (
milestone && typeof milestone === 'object'
? {
title: typeof milestone.title === 'string' ? milestone.title : '',
...(typeof milestone.state === 'string' ? { state: milestone.state } : {}),
}
: null
);
const mapGitHubAssignees = (assignees) => (
Array.isArray(assignees) ? assignees.map(mapGitHubUserSummary).filter(Boolean) : []
);
function setPrStatusCache(key, data, fetchedAt) {
// Evict oldest entry when cache exceeds max size
if (prStatusCache.size >= PR_STATUS_CACHE_MAX_ENTRIES && !prStatusCache.has(key)) {
@@ -1357,6 +1395,13 @@ export function registerGitHubRoutes(app) {
})
.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) {
@@ -1460,6 +1505,10 @@ export function registerGitHubRoutes(app) {
mergeable: pr.mergeable,
mergeableState: pr.mergeable_state,
author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null,
labels: mapGitHubLabels(pr.labels),
assignees: mapGitHubAssignees(pr.assignees),
milestone: mapGitHubMilestone(pr.milestone),
commentsCount: typeof pr.comments === 'number' ? pr.comments : undefined,
headLabel: pr.head?.label,
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url
? headRepo
@@ -1638,6 +1687,10 @@ export function registerGitHubRoutes(app) {
mergeable: prData.mergeable,
mergeableState: prData.mergeable_state,
author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null,
labels: mapGitHubLabels(prData.labels),
assignees: mapGitHubAssignees(prData.assignees),
milestone: mapGitHubMilestone(prData.milestone),
commentsCount: typeof prData.comments === 'number' ? prData.comments : undefined,
headLabel: prData.head?.label,
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null,
body: prData.body || '',
@@ -1905,4 +1958,115 @@ export function registerGitHubRoutes(app) {
return res.status(500).json({ error: error.message || 'Failed to load GitHub PR context' });
}
});
// ================= GitHub Pull Request Enrichment APIs =================
app.get('/api/github/pulls/commits', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const number = typeof req.query?.number === 'string' ? Number(req.query.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, commits: [] });
}
const result = await withTimeout(
octokit.rest.pulls.listCommits({ owner: repo.owner, repo: repo.repo, pull_number: number, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'GitHub PR commits',
);
const commits = (Array.isArray(result?.data) ? result.data : []).map((commit) => {
const message = typeof commit.commit?.message === 'string' ? commit.commit.message : '';
return {
sha: commit.sha,
shortSha: typeof commit.sha === 'string' ? commit.sha.slice(0, 7) : commit.sha,
message,
...(message.split('\n')[0] ? { summary: message.split('\n')[0] } : {}),
author: mapGitHubUserSummary(commit.author),
committer: mapGitHubUserSummary(commit.committer),
...(typeof commit.commit?.committer?.date === 'string' ? { committedAt: commit.commit.committer.date } : {}),
parents: Array.isArray(commit.parents) ? commit.parents.map((parent) => parent.sha) : [],
};
});
return res.json({ connected: true, repo, commits });
} catch (error) {
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (Number.isInteger(error?.status) && error.status >= 400) {
return res.status(502).json({ error: 'GitHub returned an error while fetching pull request commits' });
}
console.error('Failed to load GitHub pull request commits:', error);
return res.status(500).json({ error: error.message || 'Failed to load GitHub pull request commits' });
}
});
app.get('/api/github/pulls/timeline', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const number = typeof req.query?.number === 'string' ? Number(req.query.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, events: [] });
}
const result = await withTimeout(
octokit.rest.issues.listEventsForTimeline({ owner: repo.owner, repo: repo.repo, issue_number: number, per_page: 100 }),
ROUTE_TIMEOUT_MS,
'GitHub PR timeline',
);
const events = (Array.isArray(result?.data) ? result.data : []).map((event) => ({
id: String(event.id),
type: typeof event.event === 'string' ? event.event.toLowerCase() : 'other',
author: mapGitHubUserSummary(event.actor),
createdAt: event.created_at,
body: typeof event.body === 'string' ? event.body : null,
commitSha: typeof event.commit_id === 'string' ? event.commit_id : null,
}));
return res.json({ connected: true, repo, events });
} catch (error) {
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
if (error?.status === 429) {
return res.status(503).json({ error: 'GitHub rate limited' });
}
if (Number.isInteger(error?.status) && error.status >= 400) {
return res.status(502).json({ error: 'GitHub returned an error while fetching the pull request timeline' });
}
console.error('Failed to load GitHub pull request timeline:', error);
return res.status(500).json({ error: error.message || 'Failed to load GitHub pull request timeline' });
}
});
}
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
// The GitHub route handlers lazy-import ./index.js (via getGitHubLibraries)
// for auth + repo resolution, so mocking the module is enough to exercise the
// read routes without real GitHub credentials or a temp data dir.
const mockState = vi.hoisted(() => ({
getOctokitOrNull: vi.fn(),
clearGitHubAuth: vi.fn(),
octokit: {
rest: {
pulls: { listCommits: vi.fn() },
issues: { listEventsForTimeline: vi.fn() },
},
},
}));
vi.mock('./index.js', () => ({
getOctokitOrNull: mockState.getOctokitOrNull,
clearGitHubAuth: mockState.clearGitHubAuth,
resolveGitHubRepoFromDirectory: vi.fn(async () => ({
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
})),
}));
const { registerGitHubRoutes } = await import('./routes.js');
const createApp = () => {
const app = express();
app.use(express.json());
registerGitHubRoutes(app);
return app;
};
beforeEach(() => {
mockState.getOctokitOrNull.mockReset();
mockState.clearGitHubAuth.mockReset();
mockState.octokit.rest.pulls.listCommits.mockReset();
mockState.octokit.rest.issues.listEventsForTimeline.mockReset();
mockState.getOctokitOrNull.mockImplementation(() => mockState.octokit);
});
describe('GitHub pull request enrichment routes', () => {
test('pulls/commits maps commits with shortSha and summary', async () => {
mockState.octokit.rest.pulls.listCommits.mockResolvedValue({
data: [
{
sha: 'abc123def4567890',
commit: {
message: 'Add the API\n\nAdds the public API',
committer: { date: '2026-01-01T10:00:00Z' },
},
author: { login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
committer: null,
parents: [{ sha: 'parent-one' }],
},
],
});
const app = createApp();
const response = await request(app).get('/api/github/pulls/commits?directory=%2Ftmp%2Fwork&number=9');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
commits: [
{
sha: 'abc123def4567890',
shortSha: 'abc123d',
message: 'Add the API\n\nAdds the public API',
summary: 'Add the API',
author: { login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' },
committer: null,
committedAt: '2026-01-01T10:00:00Z',
parents: ['parent-one'],
},
],
});
expect(mockState.octokit.rest.pulls.listCommits).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
per_page: 100,
});
});
test('pulls/timeline maps timeline events with lowercased types', async () => {
mockState.octokit.rest.issues.listEventsForTimeline.mockResolvedValue({
data: [
{ id: 1, event: 'committed', actor: { login: 'alice', id: 42, avatar_url: 'u' }, created_at: '2026-01-01T10:00:00Z', commit_id: 'abc123def4567890' },
{ id: 2, event: 'CLOSED', actor: { login: 'alice', id: 42, avatar_url: 'u' }, created_at: '2026-01-02T10:00:00Z' },
{ id: 3, event: 'reviewed', actor: null, created_at: '2026-01-03T10:00:00Z', body: 'LGTM' },
],
});
const app = createApp();
const response = await request(app).get('/api/github/pulls/timeline?directory=%2Ftmp%2Fwork&number=9');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
events: [
{ id: '1', type: 'committed', author: { login: 'alice', id: 42 }, createdAt: '2026-01-01T10:00:00Z', commitSha: 'abc123def4567890' },
{ id: '2', type: 'closed', author: { login: 'alice', id: 42 } },
{ id: '3', type: 'reviewed', author: null, body: 'LGTM' },
],
});
expect(mockState.octokit.rest.issues.listEventsForTimeline).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
issue_number: 9,
per_page: 100,
});
});
test('pulls/commits returns connected:false when not authenticated', async () => {
mockState.getOctokitOrNull.mockImplementation(() => null);
const app = createApp();
const response = await request(app).get('/api/github/pulls/commits?directory=%2Ftmp%2Fwork&number=9');
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
});
test('pulls/commits requires directory and number', async () => {
const app = createApp();
const response = await request(app).get('/api/github/pulls/commits?directory=%2Ftmp%2Fwork');
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory and number are required' });
});
});
@@ -77,6 +77,8 @@ Nothing in the client or repo layers assumes the token came from a PAT.
- MR list: `GET /projects/:id/merge_requests?state=opened&scope=all&per_page=50&page=N&search=<query>&source_branch=<branch>` (the route passes `sourceBranch` through to `source_branch` when present, matching local-branch MR-status UIs).
- MR detail: `GET /projects/:id/merge_requests/:merge_request_iid`.
- MR diffs: `GET /projects/:id/merge_requests/:merge_request_iid/diffs?per_page=100&page=N` (paginated; the route caps at 10 pages / 3000 files).
- MR commits: `GET /projects/:id/merge_requests/:merge_request_iid/commits?per_page=100` (mapped to `{ sha, shortSha, message, summary, authorName, committedAt, parents }`).
- 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).
@@ -98,6 +100,8 @@ Nothing in the client or repo layers assumes the token came from a PAT.
| GET | `/api/gitlab/issues/comments` | `?directory&number&namespace&project` -> `{ connected, repo?, comments[] }` |
| GET | `/api/gitlab/mrs/list` | `?directory&page&query&sourceBranch` -> `{ connected, repo?, mrs[], page, hasMore }` |
| GET | `/api/gitlab/mrs/context` | `?directory&number&diff&namespace&project` -> `{ connected, repo?, mr, comments[], files[], diff? }` |
| 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/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 }` |
+4
View File
@@ -267,6 +267,10 @@ export function createGitLabClient({ token, baseUrl }) {
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}`),
mergeRequestDiffs: (pathWithNamespace, iid, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/diffs`, { query: params }),
mergeRequestCommits: (pathWithNamespace, iid, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/commits`, { query: params }),
mergeRequestNotes: (pathWithNamespace, iid, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/notes`, { query: params }),
createMergeRequest: (pathWithNamespace, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests`, { method: 'POST', body }),
updateMergeRequest: (pathWithNamespace, iid, body) =>
+161
View File
@@ -80,8 +80,41 @@ const mapMergeRequestSummary = (item) => ({
author: mapAuthor(item.author) || {},
sourceBranch: typeof item.source_branch === 'string' ? item.source_branch : '',
targetBranch: typeof item.target_branch === 'string' ? item.target_branch : '',
labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [],
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,
});
// GitLab v4 MR notes expose `system: boolean` but carry no machine-readable
// action field — the timeline event type must be inferred from the system
// note's rendered body text. Match the prefixes GitLab produces for known
// actions and fall back to 'other'. Best-effort heuristic; it may drift across
// GitLab versions, so the UI must treat unknown types generically.
const mapGitLabSystemNoteType = (note) => {
const body = typeof note?.body === 'string' ? note.body.toLowerCase() : '';
if (!body) {
return 'other';
}
if (body.includes('merged')) return 'merged';
if (body.includes('closed')) return 'closed';
if (body.includes('reopened')) return 'reopened';
if (body.includes('approved')) return 'approved';
if (body.includes('unassigned')) return 'unassigned';
if (body.includes('assigned')) return 'assigned';
if (body.includes('label')) return body.includes('removed') ? 'unlabeled' : 'labeled';
if (body.includes('milestone')) return body.includes('removed') ? 'demilestoned' : 'milestoned';
if (body.includes('merge request') && body.includes('created')) return 'opened';
return 'other';
};
const mapComment = (note, webUrl) => ({
id: typeof note.id === 'number' ? note.id : Number(note.id),
url: webUrl ? `${webUrl}#note_${note.id}` : '',
@@ -458,6 +491,13 @@ export function registerGitLabRoutes(app, options = {}) {
? 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,
};
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), issue });
} catch (error) {
@@ -624,6 +664,17 @@ export function registerGitLabRoutes(app, options = {}) {
author: mapAuthor(item.author) || {},
sourceBranch: typeof item.source_branch === 'string' ? item.source_branch : '',
targetBranch: typeof item.target_branch === 'string' ? item.target_branch : '',
labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [],
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,
headSha: typeof item.sha === 'string' ? item.sha : (typeof item.diff_head_sha === 'string' ? item.diff_head_sha : undefined),
};
@@ -684,6 +735,116 @@ export function registerGitLabRoutes(app, options = {}) {
}
});
app.get('/api/gitlab/mrs/commits', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
const number = getRequiredNumber(req);
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project is required' });
}
if (!number) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
if (!client) {
return res.json({ connected: false, commits: [] });
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, commits: [] });
}
const resp = await withTimeout(
client.mergeRequestCommits(projectPath, number, { per_page: 100 }),
ROUTE_TIMEOUT_MS,
'gitlab mr commits',
);
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Merge request not found' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while fetching merge request commits' });
}
const commits = (Array.isArray(resp.data) ? resp.data : []).map((commit) => ({
sha: typeof commit.id === 'string' ? commit.id : '',
shortSha: typeof commit.short_id === 'string' ? commit.short_id : '',
message: typeof commit.message === 'string' ? commit.message : '',
...(typeof commit.title === 'string' && commit.title ? { summary: commit.title } : {}),
...(typeof commit.author_name === 'string' && commit.author_name ? { authorName: commit.author_name } : {}),
...(typeof commit.committed_date === 'string' ? { committedAt: commit.committed_date } : {}),
parents: Array.isArray(commit.parent_ids) ? commit.parent_ids : [],
}));
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), commits });
} catch (error) {
console.error('Failed to fetch GitLab merge request commits:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitLab merge request commits' });
}
});
app.get('/api/gitlab/mrs/timeline', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
const number = getRequiredNumber(req);
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project is required' });
}
if (!number) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
if (!client) {
return res.json({ connected: false, events: [] });
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, events: [] });
}
const notesResp = await withTimeout(
client.mergeRequestNotes(projectPath, number, { per_page: 100 }),
ROUTE_TIMEOUT_MS,
'gitlab mr timeline notes',
);
if (notesResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (notesResp.status === 404) {
return res.status(404).json({ error: 'Merge request not found' });
}
if (notesResp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while fetching merge request timeline' });
}
// Timeline = system notes only (GitLab records state changes as system
// notes; human comments are surfaced by mrs/context).
const events = (Array.isArray(notesResp.data) ? notesResp.data : [])
.filter((note) => note.system === true)
.map((note) => ({
id: String(note.id),
type: mapGitLabSystemNoteType(note),
body: typeof note.body === 'string' ? note.body : null,
author: mapAuthor(note.author),
createdAt: typeof note.created_at === 'string' ? note.created_at : undefined,
}));
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), events });
} catch (error) {
console.error('Failed to fetch GitLab merge request timeline:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitLab merge request timeline' });
}
});
// ================= GitLab Merge Request Write APIs =================
app.post('/api/gitlab/mrs/create', async (req, res) => {
@@ -824,6 +824,78 @@ describe('GitLab data routes', () => {
expect(response.body).toEqual({ error: 'Your GitLab token needs the api scope to create merge requests' });
});
test('mrs/commits maps merge request commits with shortSha and summary', async () => {
scriptedFetch([
(url) => (matches(/\/merge_requests\/9\/commits\?/)(url)
? jsonResponse([
{
id: 'abc123def456',
short_id: 'abc123d',
title: 'Add the API',
message: 'Add the API\n\nAdds the public API',
author_name: 'Alice Example',
author_email: 'alice@example.com',
committed_date: '2026-01-01T10:00:00Z',
parent_ids: ['parent-one'],
},
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/mrs/commits?directory=%2Ftmp%2Fwork&number=9');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { namespace: 'group', project: 'sub' },
commits: [
{
sha: 'abc123def456',
shortSha: 'abc123d',
message: 'Add the API\n\nAdds the public API',
summary: 'Add the API',
authorName: 'Alice Example',
committedAt: '2026-01-01T10:00:00Z',
parents: ['parent-one'],
},
],
});
});
test('mrs/timeline keeps only system notes and infers event types', async () => {
scriptedFetch([
(url) => (matches(/\/merge_requests\/9\/notes\?/)(url)
? jsonResponse([
{ id: 1, body: 'alice merged changes', system: true, author: { id: 42, username: 'alice' }, created_at: '2026-01-02T11:00:00Z' },
{ id: 2, body: 'Looks good to me', system: false, author: { id: 42, username: 'alice' }, created_at: '2026-01-02T12:00:00Z' },
{ id: 3, body: 'removed label bug', system: true, author: { id: 1, username: 'system' }, created_at: '2026-01-02T13:00:00Z' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/mrs/timeline?directory=%2Ftmp%2Fwork&number=9');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
events: [
{ id: '1', type: 'merged', body: 'alice merged changes', author: { username: 'alice', id: 42 }, createdAt: '2026-01-02T11:00:00Z' },
{ id: '3', type: 'unlabeled', body: 'removed label bug', createdAt: '2026-01-02T13:00:00Z' },
],
});
});
test('mrs/commits and mrs/timeline report connected:false when not authenticated', async () => {
clearGitLabAuth();
const app = createApp();
const commits = await request(app).get('/api/gitlab/mrs/commits?directory=%2Ftmp%2Fwork&number=9');
const timeline = await request(app).get('/api/gitlab/mrs/timeline?directory=%2Ftmp%2Fwork&number=9');
expect(commits.body).toMatchObject({ connected: false, commits: [] });
expect(timeline.body).toMatchObject({ connected: false, events: [] });
});
test('data routes surface a 503 when GitLab rate limits', async () => {
scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse({}, { status: 429, headers: { 'retry-after': '30' } }) : null)]);