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
@@ -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)]);