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
+51
View File
@@ -6,11 +6,14 @@ import type {
GiteaIssueGetResult,
GiteaIssuesListResult,
GiteaPullRequest,
GiteaPullRequestCommitsResult,
GiteaPullRequestContextResult,
GiteaPullRequestCreateInput,
GiteaPullRequestMergeInput,
GiteaPullRequestMergeResult,
GiteaPullRequestReviewsResult,
GiteaPullRequestsListResult,
GiteaPullRequestStatusesResult,
GiteaPullRequestUpdateInput,
GiteaUserSummary,
} from '@openchamber/ui/lib/api/types';
@@ -186,6 +189,54 @@ export const createWebGiteaAPI = ({ urls }: WebGiteaAPIOptions): GiteaAPI => ({
return payload;
},
async prCommits(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestCommitsResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.owner) {
params.set('owner', options.owner);
}
if (options?.repo) {
params.set('repo', options.repo);
}
const response = await runtimeFetch(urls.api('/api/gitea/prs/commits', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GiteaPullRequestCommitsResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull request commits');
}
return payload;
},
async prStatuses(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestStatusesResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.owner) {
params.set('owner', options.owner);
}
if (options?.repo) {
params.set('repo', options.repo);
}
const response = await runtimeFetch(urls.api('/api/gitea/prs/statuses', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GiteaPullRequestStatusesResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull request statuses');
}
return payload;
},
async prReviews(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestReviewsResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.owner) {
params.set('owner', options.owner);
}
if (options?.repo) {
params.set('repo', options.repo);
}
const response = await runtimeFetch(urls.api('/api/gitea/prs/reviews', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GiteaPullRequestReviewsResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull request reviews');
}
return payload;
},
async prCreate(input: GiteaPullRequestCreateInput): Promise<GiteaPullRequest> {
const response = await runtimeFetch('/api/gitea/pr/create', {
method: 'POST',
+30
View File
@@ -5,6 +5,8 @@ import type {
GitHubIssueGetResult,
GitHubIssuesListResult,
GitHubPullRequestContextResult,
GitHubPullRequestCommitsResult,
GitHubPullRequestTimelineResult,
GitHubPullRequestsListResult,
GitHubPullRequest,
GitHubPullRequestCreateInput,
@@ -295,4 +297,32 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
}
return payload;
},
async prCommits(directory: string, number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }): Promise<GitHubPullRequestCommitsResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/pulls/commits', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitHubPullRequestCommitsResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load pull request commits');
}
return payload;
},
async prTimeline(directory: string, number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }): Promise<GitHubPullRequestTimelineResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
params.set('owner', options.sourceRepo.owner);
params.set('repo', options.sourceRepo.repo);
}
const response = await runtimeFetch(urls.api('/api/github/pulls/timeline', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitHubPullRequestTimelineResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load pull request timeline');
}
return payload;
},
});
+34
View File
@@ -6,12 +6,14 @@ import type {
GitLabIssueGetResult,
GitLabIssuesListResult,
GitLabMergeRequest,
GitLabMergeRequestCommitsResult,
GitLabMergeRequestContextResult,
GitLabMergeRequestCreateInput,
GitLabMergeRequestCreateResult,
GitLabMergeRequestMergeInput,
GitLabMergeRequestMergeResult,
GitLabMergeRequestsListResult,
GitLabMergeRequestTimelineResult,
GitLabMergeRequestUpdateInput,
GitLabMergeRequestUpdateResult,
GitLabUserSummary,
@@ -182,6 +184,38 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
return payload;
},
async mrCommits(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabMergeRequestCommitsResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.namespace) {
params.set('namespace', options.namespace);
}
if (options?.project) {
params.set('project', options.project);
}
const response = await runtimeFetch(urls.api('/api/gitlab/mrs/commits', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitLabMergeRequestCommitsResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load GitLab merge request commits');
}
return payload;
},
async mrTimeline(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabMergeRequestTimelineResult> {
const params = new URLSearchParams({ directory, number: String(number) });
if (options?.namespace) {
params.set('namespace', options.namespace);
}
if (options?.project) {
params.set('project', options.project);
}
const response = await runtimeFetch(urls.api('/api/gitlab/mrs/timeline', params), { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitLabMergeRequestTimelineResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load GitLab merge request timeline');
}
return payload;
},
async mrCreate(input: GitLabMergeRequestCreateInput): Promise<GitLabMergeRequest> {
const response = await runtimeFetch('/api/gitlab/mrs/create', {
method: 'POST',