feat(ui): forge facade foundation — normalized types, capability flags, three adapters

This commit is contained in:
2026-08-16 16:27:50 +00:00
parent bb46866879
commit f02c33700b
7 changed files with 2132 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import { useMemo } from 'react';
import { resolveGitProvider, useGitProvider } from '@/lib/gitProvider';
import type { GitProviderHosts } from '@/lib/gitProvider';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { buildForgeProvider } from '@/lib/forge/adapters';
import type { ForgeProvider } from '@/lib/forge/provider';
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
/**
* Provider-host sets derived from the connected accounts and the
* user-configured custom domains, mirroring the `hosts` memo inside
* `useGitProvider` so the imperative resolver classifies directories the same
* way the hook does.
*/
const buildProviderHosts = (): GitProviderHosts => {
const gitlabAccounts = useGitLabAuthStore.getState().status?.accounts;
const giteaAccounts = useGiteaAuthStore.getState().status?.accounts;
const domains = useGitProviderDomainsStore.getState().domains;
return {
github: domains.github,
gitlab: [...(gitlabAccounts ?? []).map((account) => account.baseUrl), ...domains.gitlab],
gitea: [...(giteaAccounts ?? []).map((account) => account.baseUrl), ...domains.gitea],
};
};
/**
* Resolve the forge provider for `directory` reactively: the provider kind is
* detected from the directory's remotes via `useGitProvider`, and the provider
* adapters are built from the registered runtime APIs. Returns null for 'other'
* providers (no forge-backed UI) and for kinds whose runtime API is missing.
*/
export const useForgeProvider = (directory: string | null | undefined): ForgeProvider | null => {
const kind = useGitProvider(directory);
const runtimeApis = useRuntimeAPIs();
const apis = useMemo(
() => ({ github: runtimeApis.github, gitlab: runtimeApis.gitlab, gitea: runtimeApis.gitea }),
[runtimeApis.github, runtimeApis.gitlab, runtimeApis.gitea],
);
return useMemo(
() => (kind && kind !== 'other' ? buildForgeProvider(kind, apis) : null),
[kind, apis],
);
};
/**
* Imperative counterpart of `useForgeProvider` for non-React code paths.
* Resolves the directory's provider from the auth stores' connected accounts
* and the runtime's registered APIs in one async step.
*/
export const getForgeProviderForDirectory = async (directory: string): Promise<ForgeProvider | null> => {
const hosts = buildProviderHosts();
const kind = await resolveGitProvider(directory, hosts);
if (!kind || kind === 'other') return null;
const apis = getRegisteredRuntimeAPIs();
if (!apis) return null;
return buildForgeProvider(kind, {
github: apis.github,
gitlab: apis.gitlab,
gitea: apis.gitea,
});
};
+451
View File
@@ -0,0 +1,451 @@
/**
* Forge provider adapters.
*
* Each adapter implements `ForgeProvider` against one provider's wire API
* (`GitHubAPI` / `GitLabAPI` / `GiteaAPI` from `@/lib/api/types.ts`) and
* normalizes results through `./normalize`. Adapters are deliberately
* defensive: when the underlying runtime API or a specific method is missing,
* or the wire call throws, they return the graceful envelope (`connected:
* false`, empty collections) instead of throwing — the caller treats
* `connected: false` as "no authoritative data", never as an empty success.
*/
import type {
GiteaAPI,
GitHubAPI,
GitHubRepoSelector,
GitLabAPI,
} from '@/lib/api/types';
import type {
ForgeIssueDetail,
ForgeIssuesResult,
ForgeProvider,
ForgePullRequestContext,
ForgePullRequestsResult,
} from './provider';
import type { ForgeProviderCapabilities, ForgeProviderKind } from './types';
import {
mapGiteaComment,
mapGiteaContext,
mapGiteaIssue,
mapGiteaPr,
mapGiteaRepoRef,
mapGithubContext,
mapGithubIssue,
mapGithubIssueComment,
mapGithubPr,
mapGithubRepoRef,
mapGitlabContext,
mapGitlabIssue,
mapGitlabMr,
mapGitlabNoteComment,
mapGitlabRepoRef,
} from './normalize';
const GITHUB_CAPABILITIES: ForgeProviderCapabilities = {
checks: 'check-runs',
reviews: 'submit',
draft: true,
labels: true,
assignees: true,
milestones: true,
timelineEvents: true,
inlineComments: true,
threads: true,
};
const GITLAB_CAPABILITIES: ForgeProviderCapabilities = {
checks: 'none',
reviews: 'approve-only',
draft: true,
labels: true,
assignees: true,
milestones: true,
timelineEvents: true,
inlineComments: false,
threads: true,
};
const GITEA_CAPABILITIES: ForgeProviderCapabilities = {
checks: 'commit-statuses',
reviews: 'submit',
draft: false,
labels: true,
assignees: true,
milestones: true,
timelineEvents: true,
inlineComments: true,
threads: true,
};
// Gitea's 'commit-statuses' checks and inline comments land once Slice B adds
// the commit-status / review-comment routes to the Gitea wire API.
const EMPTY_PR_LIST = (page: number): ForgePullRequestsResult => ({
connected: false,
repo: null,
prs: [],
page,
hasMore: false,
});
const EMPTY_ISSUE_LIST = (page: number): ForgeIssuesResult => ({
connected: false,
repo: null,
issues: [],
page,
hasMore: false,
});
const EMPTY_CONTEXT: ForgePullRequestContext = {
connected: false,
repo: null,
pr: null,
issueComments: [],
reviewComments: [],
files: [],
checks: null,
};
const EMPTY_ISSUE_DETAIL: ForgeIssueDetail = {
connected: false,
repo: null,
issue: null,
comments: [],
commentsError: null,
};
// Stable, detail-free marker for comment-fetch failures: surfaces the partial
// result without leaking the underlying error message.
const COMMENTS_ERROR = 'comments failed to load';
/**
* Split a `"owner/repo"` selector into its parts, as used by the
* `sourceRepo` option of the forge interface. Returns null for anything that
* does not carry both segments.
*/
const parseOwnerRepo = (sourceRepo?: string | null): GitHubRepoSelector | null => {
if (!sourceRepo) return null;
const [owner, repo] = sourceRepo.split('/');
if (!owner || !repo) return null;
return { owner, repo };
};
export const createGithubForgeProvider = (api: GitHubAPI): ForgeProvider => ({
kind: 'github',
capabilities: GITHUB_CAPABILITIES,
async getPullRequestForBranch(directory, branch, options) {
if (!api.prStatus) return null;
try {
const status = await api.prStatus(directory, branch, options?.remote);
return status.pr ? mapGithubPr(status.pr) : null;
} catch {
return null;
}
},
async listPullRequests(directory, options) {
if (!api.prsList) return EMPTY_PR_LIST(options?.page ?? 1);
try {
const result = await api.prsList(directory, { page: options?.page, query: options?.query });
return {
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
prs: (result.prs ?? []).map(mapGithubPr),
page: result.page ?? 1,
hasMore: result.hasMore ?? false,
};
} catch {
return EMPTY_PR_LIST(options?.page ?? 1);
}
},
async getPullRequestContext(directory, number, options) {
if (!api.prContext) return EMPTY_CONTEXT;
try {
const result = await api.prContext(directory, number, {
includeDiff: options?.includeDiff,
sourceRepo: parseOwnerRepo(options?.sourceRepo),
});
return mapGithubContext(result);
} catch {
return EMPTY_CONTEXT;
}
},
async listIssues(directory, options) {
if (!api.issuesList) return EMPTY_ISSUE_LIST(options?.page ?? 1);
try {
const result = await api.issuesList(directory, { page: options?.page, query: options?.query });
return {
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
issues: (result.issues ?? []).map(mapGithubIssue),
page: result.page ?? 1,
hasMore: result.hasMore ?? false,
};
} catch {
return EMPTY_ISSUE_LIST(options?.page ?? 1);
}
},
async getIssue(directory, number, options) {
if (!api.issueGet) return EMPTY_ISSUE_DETAIL;
try {
const result = await api.issueGet(directory, number, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
if (!result.connected) {
return { connected: false, repo: result.repo ? mapGithubRepoRef(result.repo) : null, issue: null, comments: [], commentsError: null };
}
let comments: ForgePullRequestContext['issueComments'] = [];
let commentsError: string | null = null;
if (api.issueComments) {
try {
const commentsResult = await api.issueComments(directory, number, {
sourceRepo: parseOwnerRepo(options?.sourceRepo),
});
comments = (commentsResult.comments ?? []).map(mapGithubIssueComment);
} catch {
// The issue itself is authoritative; a comment failure must not hide
// it, but it also must not masquerade as an authoritative empty list.
comments = [];
commentsError = COMMENTS_ERROR;
}
}
return {
connected: true,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
issue: result.issue ? mapGithubIssue(result.issue) : null,
comments,
commentsError,
};
} catch {
return EMPTY_ISSUE_DETAIL;
}
},
});
export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
kind: 'gitlab',
capabilities: GITLAB_CAPABILITIES,
async getPullRequestForBranch(directory, branch) {
if (!api.mrsList) return null;
try {
const result = await api.mrsList(directory, { sourceBranch: branch });
const mrs = result.mrs ?? [];
const mr = mrs.find((item) => item.state === 'opened')
?? mrs.find((item) => item.state === 'merged')
?? null;
return mr ? mapGitlabMr(mr) : null;
} catch {
return null;
}
},
async listPullRequests(directory, options) {
if (!api.mrsList) return EMPTY_PR_LIST(options?.page ?? 1);
try {
const result = await api.mrsList(directory, { page: options?.page, query: options?.query });
return {
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
prs: result.mrs.map(mapGitlabMr),
page: result.page,
hasMore: result.hasMore,
};
} catch {
return EMPTY_PR_LIST(options?.page ?? 1);
}
},
async getPullRequestContext(directory, number, options) {
if (!api.mrContext) return EMPTY_CONTEXT;
try {
const result = await api.mrContext(directory, number, { includeDiff: options?.includeDiff });
return mapGitlabContext(result);
} catch {
return EMPTY_CONTEXT;
}
},
async listIssues(directory, options) {
if (!api.issuesList) return EMPTY_ISSUE_LIST(options?.page ?? 1);
try {
const result = await api.issuesList(directory, { page: options?.page, query: options?.query });
return {
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
issues: result.issues.map(mapGitlabIssue),
page: result.page,
hasMore: result.hasMore,
};
} catch {
return EMPTY_ISSUE_LIST(options?.page ?? 1);
}
},
async getIssue(directory, number, options) {
if (!api.issueGet) return EMPTY_ISSUE_DETAIL;
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.issueGet(directory, number, {
namespace: selector?.owner,
project: selector?.repo,
});
if (!result.connected) {
return { connected: false, repo: result.repo ? mapGitlabRepoRef(result.repo) : null, issue: null, comments: [], commentsError: null };
}
let comments: ForgePullRequestContext['issueComments'] = [];
let commentsError: string | null = null;
if (api.issueComments) {
try {
const commentsResult = await api.issueComments(directory, number, {
namespace: selector?.owner,
project: selector?.repo,
});
comments = commentsResult.comments.map(mapGitlabNoteComment);
} catch {
// The issue itself is authoritative; a comment failure must not hide
// it, but it also must not masquerade as an authoritative empty list.
comments = [];
commentsError = COMMENTS_ERROR;
}
}
return {
connected: true,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
issue: result.issue ? mapGitlabIssue(result.issue) : null,
comments,
commentsError,
};
} catch {
return EMPTY_ISSUE_DETAIL;
}
},
});
export const createGiteaForgeProvider = (api: GiteaAPI): ForgeProvider => ({
kind: 'gitea',
capabilities: GITEA_CAPABILITIES,
async getPullRequestForBranch(directory, branch) {
if (!api.prsList) return null;
try {
const result = await api.prsList(directory, { sourceBranch: branch });
const prs = result.prs ?? [];
const pr = prs.find((item) => item.state === 'open')
?? prs.find((item) => item.state === 'merged')
?? null;
return pr ? mapGiteaPr(pr) : null;
} catch {
return null;
}
},
async listPullRequests(directory, options) {
if (!api.prsList) return EMPTY_PR_LIST(options?.page ?? 1);
try {
const result = await api.prsList(directory, { page: options?.page, query: options?.query });
return {
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
prs: result.prs.map(mapGiteaPr),
page: result.page,
hasMore: result.hasMore,
};
} catch {
return EMPTY_PR_LIST(options?.page ?? 1);
}
},
async getPullRequestContext(directory, number, options) {
if (!api.prContext) return EMPTY_CONTEXT;
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.prContext(directory, number, {
includeDiff: options?.includeDiff,
owner: selector?.owner,
repo: selector?.repo,
});
return mapGiteaContext(result);
} catch {
return EMPTY_CONTEXT;
}
},
async listIssues(directory, options) {
if (!api.issuesList) return EMPTY_ISSUE_LIST(options?.page ?? 1);
try {
const result = await api.issuesList(directory, { page: options?.page, query: options?.query });
return {
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
issues: result.issues.map(mapGiteaIssue),
page: result.page,
hasMore: result.hasMore,
};
} catch {
return EMPTY_ISSUE_LIST(options?.page ?? 1);
}
},
async getIssue(directory, number, options) {
if (!api.issueGet) return EMPTY_ISSUE_DETAIL;
try {
const selector = parseOwnerRepo(options?.sourceRepo);
const result = await api.issueGet(directory, number, {
owner: selector?.owner,
repo: selector?.repo,
});
if (!result.connected) {
return { connected: false, repo: result.repo ? mapGiteaRepoRef(result.repo) : null, issue: null, comments: [], commentsError: null };
}
let comments: ForgePullRequestContext['issueComments'] = [];
let commentsError: string | null = null;
if (api.issueComments) {
try {
const commentsResult = await api.issueComments(directory, number, {
owner: selector?.owner,
repo: selector?.repo,
});
comments = commentsResult.comments.map(mapGiteaComment);
} catch {
// The issue itself is authoritative; a comment failure must not hide
// it, but it also must not masquerade as an authoritative empty list.
comments = [];
commentsError = COMMENTS_ERROR;
}
}
return {
connected: true,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
issue: result.issue ? mapGiteaIssue(result.issue) : null,
comments,
commentsError,
};
} catch {
return EMPTY_ISSUE_DETAIL;
}
},
});
/**
* Build the adapter for `kind` from the available runtime APIs, or null when
* the provider's API is not present in the runtime.
*/
export const buildForgeProvider = (
kind: ForgeProviderKind,
apis: { github?: GitHubAPI; gitlab?: GitLabAPI; gitea?: GiteaAPI },
): ForgeProvider | null => {
switch (kind) {
case 'github':
return apis.github ? createGithubForgeProvider(apis.github) : null;
case 'gitlab':
return apis.gitlab ? createGitlabForgeProvider(apis.gitlab) : null;
case 'gitea':
return apis.gitea ? createGiteaForgeProvider(apis.gitea) : null;
default:
return null;
}
};
+712
View File
@@ -0,0 +1,712 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import type {
GiteaAPI,
GiteaComment,
GiteaIssue,
GiteaPullRequest,
GiteaUserSummary,
GitHubAPI,
GitHubCheckRun,
GitHubChecksSummary,
GitHubIssue,
GitHubIssueComment,
GitHubPullRequestSummary,
GitHubUserSummary,
GitLabAPI,
GitLabIssue,
GitLabIssueComment,
GitLabMergeRequest,
GitLabUserSummary,
} from '@/lib/api/types';
import { buildForgeProvider, createGitlabForgeProvider } from '@/lib/forge/adapters';
import {
mapCheckRunState,
mapGithubCheckSummary,
mapGithubContext,
mapGithubIssue,
mapGithubIssueComment,
mapGithubPr,
mapGithubReviewComment,
mapGiteaComment,
mapGiteaContext,
mapGiteaIssue,
mapGiteaPr,
mapGitlabContext,
mapGitlabIssue,
mapGitlabMr,
mapGitlabNoteComment,
stateOf,
} from '@/lib/forge/normalize';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const githubUser = (): GitHubUserSummary => ({
login: 'octocat',
id: 1,
name: 'Octo Cat',
avatarUrl: 'https://avatars.example/octocat',
});
const githubPr: GitHubPullRequestSummary = {
number: 42,
title: 'Add forge facade',
body: 'A body',
url: 'https://github.com/acme/widget/pull/42',
state: 'open',
draft: true,
base: 'main',
head: 'feat/forge',
headSha: 'abc123',
mergeable: true,
mergeableState: 'clean',
author: githubUser(),
createdAt: '2026-01-02T03:04:05Z',
updatedAt: '2026-01-03T04:05:06Z',
};
const githubIssue: GitHubIssue = {
number: 7,
title: 'Bug in forge',
body: 'Details',
url: 'https://github.com/acme/widget/issues/7',
state: 'closed',
author: githubUser(),
labels: [{ name: 'bug', color: 'd73a4a' }],
assignees: [githubUser()],
createdAt: '2026-01-02T03:04:05Z',
updatedAt: '2026-01-04T05:06:07Z',
};
const githubIssueComment: GitHubIssueComment = {
id: 1001,
body: 'First!',
url: 'https://github.com/acme/widget/issues/7#issuecomment-1001',
author: githubUser(),
createdAt: '2026-01-02T04:00:00Z',
};
const gitlabUser = (): GitLabUserSummary => ({
username: 'gluser',
id: 5,
name: 'GL User',
avatarUrl: 'https://avatars.example/gluser',
webUrl: 'https://gitlab.example/gluser',
});
const gitlabMr: GitLabMergeRequest = {
number: 99,
title: 'Draft: Add MR support',
body: 'MR body',
url: 'https://gitlab.example/acme/widget/-/merge_requests/99',
state: 'opened',
draft: false,
author: gitlabUser(),
sourceBranch: 'feat/mr',
targetBranch: 'main',
createdAt: '2026-02-01T00:00:00Z',
updatedAt: '2026-02-02T00:00:00Z',
headSha: 'def456',
};
const gitlabIssue: GitLabIssue = {
number: 8,
title: 'GL issue',
body: 'GL body',
url: 'https://gitlab.example/acme/widget/-/issues/8',
state: 'opened',
author: gitlabUser(),
assignees: [gitlabUser()],
labels: ['frontend', 'bug'],
createdAt: '2026-02-01T00:00:00Z',
updatedAt: '2026-02-03T00:00:00Z',
};
const gitlabNote: GitLabIssueComment = {
id: 3003,
body: 'a note',
url: 'https://gitlab.example/acme/widget/-/issues/8#note_3003',
author: gitlabUser(),
createdAt: '2026-02-01T01:00:00Z',
};
const giteaUser = (): GiteaUserSummary => ({
username: 'guser',
id: 3,
name: 'G User',
avatarUrl: 'https://avatars.example/guser',
webUrl: 'https://gitea.example/guser',
});
const giteaPr: GiteaPullRequest = {
number: 11,
title: 'Add gitea PR',
body: 'gitea body',
url: 'https://gitea.example/acme/widget/pulls/11',
state: 'merged',
author: giteaUser(),
labels: ['backend'],
sourceBranch: 'feat/gitea',
targetBranch: 'main',
mergeable: true,
createdAt: '2026-03-01T00:00:00Z',
updatedAt: '2026-03-02T00:00:00Z',
};
const giteaIssue: GiteaIssue = {
number: 12,
title: 'gitea issue',
body: 'issue body',
url: 'https://gitea.example/acme/widget/issues/12',
state: 'open',
author: giteaUser(),
labels: ['bug'],
createdAt: '2026-03-01T00:00:00Z',
updatedAt: '2026-03-02T00:00:00Z',
};
const giteaComment: GiteaComment = {
id: 4004,
body: 'gitea note',
url: 'https://gitea.example/acme/widget/issues/12#issuecomment-4004',
author: giteaUser(),
createdAt: '2026-03-01T01:00:00Z',
};
const checks: GitHubChecksSummary = { state: 'failure', total: 3, success: 1, failure: 1, pending: 1 };
const checkRuns: GitHubCheckRun[] = [
{
name: 'build',
status: 'completed',
conclusion: 'success',
startedAt: '2026-01-01T00:00:00Z',
completedAt: '2026-01-01T00:01:00Z',
detailsUrl: 'https://github.com/acme/widget/actions/runs/1',
output: { title: 'Build', summary: 'all green' },
},
{ name: 'lint', status: 'in_progress', startedAt: '2026-01-01T00:00:00Z' },
{ name: 'test', status: 'completed', conclusion: 'cancelled' },
{ name: 'doc', status: 'completed', conclusion: 'skipped' },
{ name: 'perf', status: 'completed', conclusion: 'timed_out' },
{
name: 'annotated',
status: 'completed',
conclusion: 'failure',
annotations: [{ path: 'src/a.ts', startLine: 3, endLine: 3, level: 'error', message: 'boom', title: 'TS error' }],
},
];
// ---------------------------------------------------------------------------
// Normalization
// ---------------------------------------------------------------------------
describe('stateOf', () => {
test('maps provider state strings onto the normalized lifecycle state', () => {
expect(stateOf('open')).toBe('open');
expect(stateOf('opened')).toBe('open');
expect(stateOf('closed')).toBe('closed');
expect(stateOf('merged')).toBe('merged');
expect(stateOf(undefined)).toBe('closed');
expect(stateOf(null)).toBe('closed');
expect(stateOf('unexpected')).toBe('closed');
});
});
describe('github normalization', () => {
test('maps a GitHub PR', () => {
const pr = mapGithubPr(githubPr);
expect(pr.number).toBe(42);
expect(pr.state).toBe('open');
expect(pr.draft).toBe(true);
expect(pr.base.ref).toBe('main');
expect(pr.head.ref).toBe('feat/forge');
expect(pr.head.repo).toBeNull();
expect(pr.headSha).toBe('abc123');
expect(pr.mergeable).toBe(true);
expect(pr.mergeableState).toBe('clean');
expect(pr.labels).toEqual([]);
expect(pr.assignees).toEqual([]);
expect(pr.author?.id).toBe('octocat');
expect(pr.author?.login).toBe('octocat');
expect(pr.url).toBe('https://github.com/acme/widget/pull/42');
});
test('maps a GitHub issue', () => {
const issue = mapGithubIssue(githubIssue);
expect(issue.number).toBe(7);
expect(issue.state).toBe('closed');
expect(issue.body).toBe('Details');
expect(issue.labels).toEqual([{ name: 'bug', color: 'd73a4a' }]);
expect(issue.assignees).toHaveLength(1);
expect(issue.assignees?.[0]?.id).toBe('octocat');
expect(issue.milestone).toBeNull();
expect(issue.url).toBe('https://github.com/acme/widget/issues/7');
});
test('maps GitHub issue and review comments', () => {
const comment = mapGithubIssueComment(githubIssueComment);
expect(comment.id).toBe('1001');
expect(comment.body).toBe('First!');
expect(comment.author?.id).toBe('octocat');
expect(comment.inReplyToId).toBeNull();
expect(comment.path).toBeNull();
expect(comment.line).toBeNull();
const reviewComment = {
id: 2002,
body: 'Lint this',
url: 'https://github.com/acme/widget/pull/42#discussion_r2002',
author: githubUser(),
path: 'src/forge.ts',
position: 12,
createdAt: '2026-01-03T05:00:00Z',
}; // Shape of GitHubPullRequestReviewComment, which api/types keeps local.
const mapped = mapGithubReviewComment(reviewComment);
expect(mapped.id).toBe('2002');
expect(mapped.path).toBe('src/forge.ts');
expect(mapped.line).toBe(12);
expect(mapped.inReplyToId).toBeNull();
expect(mapped.commitSha).toBeNull();
});
test('maps a GitHub PR context', () => {
const context = mapGithubContext({
connected: true,
repo: { owner: 'acme', repo: 'widget', url: 'https://github.com/acme/widget' },
pr: githubPr,
issueComments: [githubIssueComment],
reviewComments: [],
files: [{ filename: 'src/forge.ts', status: 'modified', additions: 2, deletions: 1, patch: '@@' }],
diff: '--- a/src/forge.ts',
checks,
checkRuns,
});
expect(context.connected).toBe(true);
expect(context.repo?.owner).toBe('acme');
expect(context.repo?.provider).toBe('github');
expect(context.pr?.number).toBe(42);
expect(context.issueComments).toHaveLength(1);
expect(context.reviewComments).toEqual([]);
expect(context.files[0]).toEqual({
filename: 'src/forge.ts',
status: 'modified',
additions: 2,
deletions: 1,
patch: '@@',
});
expect(context.diff).toContain('forge.ts');
expect(context.checks?.state).toBe('failure');
});
});
describe('github checks', () => {
test('maps the check summary and check runs', () => {
const summary = mapGithubCheckSummary(checks, checkRuns);
expect(summary.state).toBe('failure');
expect(summary.total).toBe(3);
expect(summary.success).toBe(1);
expect(summary.checks).toHaveLength(6);
const byName = Object.fromEntries(summary.checks.map((c) => [c.name, c.state]));
expect(byName['build']).toBe('success');
expect(byName['lint']).toBe('pending');
expect(byName['test']).toBe('cancelled');
expect(byName['doc']).toBe('skipped');
expect(byName['perf']).toBe('failure');
const build = summary.checks.find((c) => c.name === 'build');
expect(build?.kind).toBe('check-run');
expect(build?.url).toBe('https://github.com/acme/widget/actions/runs/1');
expect(build?.details?.title).toBe('Build');
expect(build?.details?.summary).toBe('all green');
const annotated = summary.checks.find((c) => c.name === 'annotated');
expect(annotated?.details?.annotations?.[0]).toEqual({
path: 'src/a.ts',
startLine: 3,
endLine: 3,
level: 'error',
message: 'boom',
title: 'TS error',
});
});
test('omits checks when the context carries none', () => {
const context = mapGithubContext({ connected: true, pr: githubPr });
expect(context.checks).toBeNull();
});
test('maps check run states from status/conclusion pairs', () => {
expect(mapCheckRunState('queued')).toBe('pending');
expect(mapCheckRunState('in_progress')).toBe('pending');
expect(mapCheckRunState('completed')).toBe('unknown');
expect(mapCheckRunState('completed', 'success')).toBe('success');
expect(mapCheckRunState('completed', 'neutral')).toBe('success');
expect(mapCheckRunState('completed', 'failure')).toBe('failure');
expect(mapCheckRunState('completed', 'timed_out')).toBe('failure');
expect(mapCheckRunState('completed', 'cancelled')).toBe('cancelled');
expect(mapCheckRunState('completed', 'skipped')).toBe('skipped');
expect(mapCheckRunState('completed', 'stale')).toBe('skipped');
expect(mapCheckRunState('completed', 'action_required')).toBe('pending');
expect(mapCheckRunState('completed', 'made-up')).toBe('unknown');
});
});
describe('gitlab normalization', () => {
test('maps a GitLab MR, mapping opened state and draft-by-title-prefix', () => {
const mr = mapGitlabMr(gitlabMr);
expect(mr.number).toBe(99);
expect(mr.state).toBe('open');
expect(mr.draft).toBe(true);
expect(mr.base.ref).toBe('main');
expect(mr.head.ref).toBe('feat/mr');
expect(mr.headSha).toBe('def456');
expect(mr.labels).toEqual([]);
expect(mr.assignees).toEqual([]);
expect(mr.author?.id).toBe('5');
expect(mr.author?.url).toBe('https://gitlab.example/gluser');
expect(mr.url).toBe('https://gitlab.example/acme/widget/-/merge_requests/99');
});
test('GitLab MR draft detection follows the draft flag when the title has no prefix', () => {
expect(mapGitlabMr({ ...gitlabMr, title: 'Add MR support' }).draft).toBe(false);
expect(mapGitlabMr({ ...gitlabMr, title: 'Add MR support', draft: true }).draft).toBe(true);
});
test('maps a GitLab issue', () => {
const issue = mapGitlabIssue(gitlabIssue);
expect(issue.number).toBe(8);
expect(issue.state).toBe('open');
expect(issue.body).toBe('GL body');
expect(issue.labels).toEqual([{ name: 'frontend' }, { name: 'bug' }]);
expect(issue.assignees).toHaveLength(1);
expect(issue.assignees?.[0]?.id).toBe('5');
expect(issue.milestone).toBeNull();
});
test('maps a GitLab note comment', () => {
const comment = mapGitlabNoteComment(gitlabNote);
expect(comment.id).toBe('3003');
expect(comment.body).toBe('a note');
expect(comment.author?.login).toBe('gluser');
});
test('maps a GitLab MR context', () => {
const context = mapGitlabContext({
connected: true,
repo: {
namespace: 'acme',
project: 'widget',
host: 'gitlab.example',
url: 'https://gitlab.example/acme/widget',
baseUrl: 'https://gitlab.example',
},
mr: gitlabMr,
comments: [gitlabNote],
files: [{ filename: 'src/gl.ts', status: 'added', additions: 1, deletions: 0 }],
diff: '--- a/src/gl.ts',
});
expect(context.connected).toBe(true);
expect(context.repo?.owner).toBe('acme');
expect(context.repo?.repo).toBe('widget');
expect(context.pr?.number).toBe(99);
expect(context.issueComments).toHaveLength(1);
expect(context.reviewComments).toEqual([]);
expect(context.files[0]?.filename).toBe('src/gl.ts');
expect(context.checks).toBeNull();
});
});
describe('gitea normalization', () => {
test('maps a Gitea PR', () => {
const pr = mapGiteaPr(giteaPr);
expect(pr.number).toBe(11);
expect(pr.state).toBe('merged');
expect(pr.draft).toBe(false);
expect(pr.base.ref).toBe('main');
expect(pr.head.ref).toBe('feat/gitea');
expect(pr.labels).toEqual([{ name: 'backend' }]);
expect(pr.assignees).toEqual([]);
expect(pr.mergeable).toBe(true);
expect(pr.author?.id).toBe('3');
expect(pr.url).toBe('https://gitea.example/acme/widget/pulls/11');
});
test('maps a Gitea issue', () => {
const issue = mapGiteaIssue(giteaIssue);
expect(issue.number).toBe(12);
expect(issue.state).toBe('open');
expect(issue.labels).toEqual([{ name: 'bug' }]);
expect(issue.assignees).toEqual([]);
expect(issue.body).toBe('issue body');
});
test('maps a Gitea comment', () => {
const comment = mapGiteaComment(giteaComment);
expect(comment.id).toBe('4004');
expect(comment.body).toBe('gitea note');
expect(comment.author?.login).toBe('guser');
expect(comment.url).toBe('https://gitea.example/acme/widget/issues/12#issuecomment-4004');
});
test('maps a Gitea PR context', () => {
const context = mapGiteaContext({
connected: true,
repo: { owner: 'acme', repo: 'widget', url: 'https://gitea.example/acme/widget' },
pr: giteaPr,
comments: [giteaComment],
files: [{ filename: 'src/gitea.ts', status: 'modified', additions: 3, deletions: 1, patch: '@@' }],
diff: '--- a/src/gitea.ts',
});
expect(context.connected).toBe(true);
expect(context.repo?.provider).toBe('gitea');
expect(context.pr?.number).toBe(11);
expect(context.issueComments).toHaveLength(1);
expect(context.reviewComments).toEqual([]);
expect(context.files[0]?.additions).toBe(3);
expect(context.checks).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Adapter factory
// ---------------------------------------------------------------------------
describe('buildForgeProvider', () => {
test('returns null when the kind has no registered API', () => {
expect(buildForgeProvider('github', {})).toBeNull();
expect(buildForgeProvider('gitlab', {})).toBeNull();
expect(buildForgeProvider('gitea', {})).toBeNull();
});
test('builds a GitHub adapter with GitHub capabilities', () => {
const provider = buildForgeProvider('github', { github: {} as GitHubAPI });
expect(provider?.kind).toBe('github');
expect(provider?.capabilities).toEqual({
checks: 'check-runs',
reviews: 'submit',
draft: true,
labels: true,
assignees: true,
milestones: true,
timelineEvents: true,
inlineComments: true,
threads: true,
});
});
test('builds a GitLab adapter with approve-only reviews and no inline comments', () => {
const provider = buildForgeProvider('gitlab', { gitlab: {} as GitLabAPI });
expect(provider?.kind).toBe('gitlab');
expect(provider?.capabilities).toEqual({
checks: 'none',
reviews: 'approve-only',
draft: true,
labels: true,
assignees: true,
milestones: true,
timelineEvents: true,
inlineComments: false,
threads: true,
});
});
test('builds a Gitea adapter with commit-statuses checks and no drafts', () => {
const provider = buildForgeProvider('gitea', { gitea: {} as GiteaAPI });
expect(provider?.kind).toBe('gitea');
expect(provider?.capabilities).toEqual({
checks: 'commit-statuses',
reviews: 'submit',
draft: false,
labels: true,
assignees: true,
milestones: true,
timelineEvents: true,
inlineComments: true,
threads: true,
});
});
});
describe('adapters gracefully degrade', () => {
test('return null / disconnected envelopes when runtime methods are missing', async () => {
const provider = createGitlabForgeProvider({} as unknown as GitLabAPI);
expect(await provider.getPullRequestForBranch('/repo', 'main')).toBeNull();
expect(await provider.listPullRequests('/repo')).toEqual({
connected: false,
repo: null,
prs: [],
page: 1,
hasMore: false,
});
expect(await provider.listIssues('/repo')).toEqual({
connected: false,
repo: null,
issues: [],
page: 1,
hasMore: false,
});
const context = await provider.getPullRequestContext('/repo', 1);
expect(context.connected).toBe(false);
expect(context.issueComments).toEqual([]);
expect(context.reviewComments).toEqual([]);
const detail = await provider.getIssue('/repo', 1);
expect(detail.connected).toBe(false);
expect(detail.comments).toEqual([]);
expect(detail.commentsError).toBeNull();
});
test('swallow wire failures into the graceful envelope', async () => {
const api = {
mrsList: async () => {
throw new Error('boom');
},
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
expect(await provider.getPullRequestForBranch('/repo', 'main')).toBeNull();
const list = await provider.listPullRequests('/repo');
expect(list.connected).toBe(false);
expect(list.prs).toEqual([]);
});
test('mark commentsError when the issue loads but its comments fail', async () => {
const api = {
issueGet: async () => ({
connected: true,
issue: { number: 1, title: 't', url: 'u', state: 'opened', author: { username: 'u', id: 1 }, labels: [] },
}),
issueComments: async () => {
throw new Error('boom');
},
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
const detail = await provider.getIssue('/repo', 1);
expect(detail.connected).toBe(true);
expect(detail.issue?.number).toBe(1);
expect(detail.comments).toEqual([]);
expect(detail.commentsError).toBeTruthy();
expect(typeof detail.commentsError).toBe('string');
});
test('clear commentsError when the issue and its comments load', async () => {
const api = {
issueGet: async () => ({
connected: true,
issue: { number: 1, title: 't', url: 'u', state: 'opened', author: { username: 'u', id: 1 }, labels: [] },
}),
issueComments: async () => ({
connected: true,
comments: [{ id: 1, body: 'hi', url: 'u', author: { username: 'u', id: 1 }, createdAt: '2026-01-01T00:00:00Z' }],
}),
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
const detail = await provider.getIssue('/repo', 1);
expect(detail.connected).toBe(true);
expect(detail.comments).toHaveLength(1);
expect(detail.commentsError).toBeNull();
});
});
describe('getPullRequestForBranch', () => {
test('GitLab prefers the opened MR over a merged one for the branch', async () => {
const api = {
mrsList: async () => ({
connected: true,
mrs: [
{ number: 3, title: 'MR 3', url: 'u3', state: 'merged', draft: false, author: { username: 'u', id: 1 }, sourceBranch: 'feat/a', targetBranch: 'main' },
{ number: 7, title: 'MR 7', url: 'u7', state: 'opened', draft: false, author: { username: 'u', id: 1 }, sourceBranch: 'feat/a', targetBranch: 'main' },
],
page: 1,
hasMore: false,
}),
} as unknown as GitLabAPI;
const provider = createGitlabForgeProvider(api);
const pr = await provider.getPullRequestForBranch('/repo', 'feat/a');
expect(pr?.number).toBe(7);
expect(pr?.state).toBe('open');
});
test('Gitea falls back to a merged PR when no open one exists', async () => {
const api = {
prsList: async () => ({
connected: true,
prs: [
{ number: 5, title: 'PR 5', url: 'u5', state: 'merged', draft: false, author: { username: 'u', id: 1 }, labels: [], sourceBranch: 'feat/b', targetBranch: 'main' },
{ number: 9, title: 'PR 9', url: 'u9', state: 'closed', draft: false, author: { username: 'u', id: 1 }, labels: [], sourceBranch: 'feat/b', targetBranch: 'main' },
],
page: 1,
hasMore: false,
}),
} as unknown as GiteaAPI;
const provider = buildForgeProvider('gitea', { gitea: api });
const pr = await provider?.getPullRequestForBranch('/repo', 'feat/b');
expect(pr?.number).toBe(5);
expect(pr?.state).toBe('merged');
});
});
// ---------------------------------------------------------------------------
// Imperative helper (getForgeProviderForDirectory)
// ---------------------------------------------------------------------------
let gitProviderKind: 'github' | 'gitlab' | 'gitea' | 'other' | null = null;
let hasRegisteredForgeApis = false;
mock.module('@/lib/gitProvider', () => ({
resolveGitProvider: async () => gitProviderKind,
useGitProvider: () => gitProviderKind,
}));
mock.module('@/contexts/runtimeAPIRegistry', () => ({
getRegisteredRuntimeAPIs: () => {
if (!hasRegisteredForgeApis) return null;
return {
github: {
prsList: async () => ({ connected: true, prs: [], page: 1, hasMore: false }),
},
};
},
}));
const { getForgeProviderForDirectory } = await import('@/hooks/useForgeProvider');
describe('getForgeProviderForDirectory', () => {
beforeEach(() => {
gitProviderKind = 'github';
hasRegisteredForgeApis = true;
});
afterEach(() => {
gitProviderKind = null;
hasRegisteredForgeApis = false;
});
test('builds a provider for a detected kind with a registered API', async () => {
const provider = await getForgeProviderForDirectory('/repo');
expect(provider?.kind).toBe('github');
expect(provider?.capabilities.checks).toBe('check-runs');
});
test('returns null when the directory is classified as other', async () => {
gitProviderKind = 'other';
expect(await getForgeProviderForDirectory('/repo')).toBeNull();
});
test('returns null when the runtime has no registered APIs', async () => {
hasRegisteredForgeApis = false;
expect(await getForgeProviderForDirectory('/repo')).toBeNull();
});
test('returns null when the API for the detected kind is absent', async () => {
gitProviderKind = 'gitlab';
expect(await getForgeProviderForDirectory('/repo')).toBeNull();
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Provider-agnostic git-forge facade.
*
* Barrel for the forge contract: normalized entity types (`./types`), the
* `ForgeProvider` interface + capability model (`./provider`), the pure
* normalization mappers (`./normalize`), and the per-provider adapters +
* factory (`./adapters`).
*/
export type {
ForgeProviderKind,
ForgeChecksCapability,
ForgeReviewsCapability,
ForgeProviderCapabilities,
ForgeUser,
ForgeLabel,
ForgeMilestone,
ForgeRepoRef,
ForgeEntityState,
ForgeIssue,
ForgeBranchRef,
ForgePullRequest,
ForgeComment,
ForgeTimelineEventType,
ForgeTimelineEvent,
ForgeCommit,
ForgeFileChange,
ForgeCheckState,
ForgeCheckKind,
ForgeCheckAnnotation,
ForgeCheck,
ForgeChecksSummary,
ForgeReview,
} from './types';
export type {
ForgePullRequestsResult,
ForgePullRequestContext,
ForgeIssuesResult,
ForgeIssueDetail,
ForgeProvider,
} from './provider';
export {
stateOf,
mapCheckRunState,
mapGithubUser,
mapGithubPr,
mapGithubIssue,
mapGithubIssueComment,
mapGithubReviewComment,
mapGithubCheckSummary,
mapGithubContext,
mapGithubRepoRef,
mapGitlabUser,
mapGitlabMr,
mapGitlabIssue,
mapGitlabNoteComment,
mapGitlabContext,
mapGitlabRepoRef,
mapGiteaUser,
mapGiteaPr,
mapGiteaIssue,
mapGiteaComment,
mapGiteaContext,
mapGiteaRepoRef,
} from './normalize';
export {
buildForgeProvider,
createGithubForgeProvider,
createGitlabForgeProvider,
createGiteaForgeProvider,
} from './adapters';
+420
View File
@@ -0,0 +1,420 @@
/**
* Pure normalization mappers from the per-provider wire shapes
* (`@/lib/api/types.ts`: GitHubAPI / GitLabAPI / GiteaAPI) onto the
* provider-agnostic `Forge*` entities (`./types`).
*
* Every mapper is a pure projection — no network, no state, no throw. Missing
* optional fields are dropped from the output, and list-shaped fields default
* to `[]` so consumers never see `undefined` where the contract promises an
* array. All mappers are exported so tests can feed them fixture payloads and
* spot-check the field mapping.
*/
import type {
GiteaComment,
GiteaIssue,
GiteaIssueSummary,
GiteaPullRequest,
GiteaPullRequestContextResult,
GiteaUserSummary,
GitHubCheckRun,
GitHubChecksSummary,
GitHubIssue,
GitHubIssueComment,
GitHubIssueSummary,
GitHubPullRequestContextResult,
GitHubPullRequestSummary,
GitHubUserSummary,
GitLabIssue,
GitLabIssueComment,
GitLabIssueSummary,
GitLabMergeRequest,
GitLabMergeRequestContextResult,
GitLabRepoRef,
GitLabUserSummary,
} from '@/lib/api/types';
import type { ForgePullRequestContext } from './provider';
import type {
ForgeCheck,
ForgeCheckState,
ForgeChecksSummary,
ForgeComment,
ForgeEntityState,
ForgeFileChange,
ForgeIssue,
ForgePullRequest,
ForgeRepoRef,
ForgeUser,
} from './types';
// GitHubPullRequestReviewComment is not exported from '@/lib/api/types';
// redeclare the subset the mapper consumes.
type GithubReviewComment = {
id: number;
url: string;
body: string;
author?: GitHubUserSummary | null;
path?: string;
line?: number | null;
position?: number | null;
createdAt?: string;
updatedAt?: string;
};
/**
* Map a provider state string onto the normalized lifecycle state. GitLab uses
* 'opened' where GitHub/Gitea use 'open'; anything unrecognized collapses to
* 'closed' so an unknown state never renders as an active (open) entity.
*/
export const stateOf = (value: string | null | undefined): ForgeEntityState => {
switch (value) {
case 'open':
case 'opened':
return 'open';
case 'merged':
return 'merged';
default:
return 'closed';
}
};
// ---------------------------------------------------------------------------
// Users
// ---------------------------------------------------------------------------
export const mapGithubUser = (user: GitHubUserSummary): ForgeUser => ({
id: user.login,
login: user.login,
name: user.name,
avatarUrl: user.avatarUrl,
});
export const mapGitlabUser = (user: GitLabUserSummary): ForgeUser => ({
id: String(user.id ?? user.username),
login: user.username,
name: user.name,
avatarUrl: user.avatarUrl,
url: user.webUrl,
});
export const mapGiteaUser = (user: GiteaUserSummary): ForgeUser => ({
id: String(user.id ?? user.username),
login: user.username,
name: user.name,
avatarUrl: user.avatarUrl,
url: user.webUrl,
});
// ---------------------------------------------------------------------------
// Pull requests / merge requests
// ---------------------------------------------------------------------------
export const mapGithubPr = (pr: GitHubPullRequestSummary): ForgePullRequest => ({
number: pr.number,
title: pr.title,
body: pr.body,
state: stateOf(pr.state),
draft: !!pr.draft,
author: pr.author ? mapGithubUser(pr.author) : undefined,
createdAt: pr.createdAt,
updatedAt: pr.updatedAt,
base: { ref: pr.base ?? '' },
head: { ref: pr.head ?? '', repo: null },
headSha: pr.headSha,
mergeable: pr.mergeable ?? null,
mergeableState: pr.mergeableState ?? null,
labels: [],
assignees: [],
milestone: null,
url: pr.url,
});
export const mapGitlabMr = (mr: GitLabMergeRequest): ForgePullRequest => ({
number: mr.number,
title: mr.title,
body: mr.body,
state: stateOf(mr.state),
draft: !!mr.draft || /^Draft:/.test(mr.title),
author: mapGitlabUser(mr.author),
createdAt: mr.createdAt,
updatedAt: mr.updatedAt,
base: { ref: mr.targetBranch ?? '' },
head: { ref: mr.sourceBranch ?? '' },
headSha: mr.headSha,
labels: [],
assignees: [],
milestone: null,
url: mr.url,
});
export const mapGiteaPr = (pr: GiteaPullRequest): ForgePullRequest => ({
number: pr.number,
title: pr.title,
body: pr.body,
state: stateOf(pr.state),
// Gitea/Forgejo have no draft concept: PRs are either open or mergeable.
draft: false,
author: mapGiteaUser(pr.author),
createdAt: pr.createdAt,
updatedAt: pr.updatedAt,
base: { ref: pr.targetBranch ?? '' },
head: { ref: pr.sourceBranch ?? '' },
mergeable: pr.mergeable ?? null,
labels: (pr.labels ?? []).map((name) => ({ name })),
assignees: [],
milestone: null,
url: pr.url,
});
// ---------------------------------------------------------------------------
// Issues
// ---------------------------------------------------------------------------
// The list API returns summaries (no body/assignees/createdAt/updatedAt), the
// detail API returns the full issue. The mapper accepts both by reading the
// detail fields as optional, so the same projection serves both call sites.
type GithubIssueInput = GitHubIssueSummary & Partial<Pick<GitHubIssue, 'body' | 'assignees' | 'createdAt' | 'updatedAt'>>;
export const mapGithubIssue = (issue: GithubIssueInput): ForgeIssue => ({
number: issue.number,
title: issue.title,
body: issue.body,
state: stateOf(issue.state),
author: issue.author ? mapGithubUser(issue.author) : undefined,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
labels: (issue.labels ?? []).map((label) => ({ name: label.name, color: label.color })),
assignees: (issue.assignees ?? []).map(mapGithubUser),
milestone: null,
url: issue.url,
});
type GitlabIssueInput = GitLabIssueSummary & Partial<Pick<GitLabIssue, 'body' | 'assignees' | 'createdAt' | 'updatedAt'>>;
export const mapGitlabIssue = (issue: GitlabIssueInput): ForgeIssue => ({
number: issue.number,
title: issue.title,
body: issue.body,
state: stateOf(issue.state),
author: mapGitlabUser(issue.author),
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
labels: (issue.labels ?? []).map((name) => ({ name })),
assignees: (issue.assignees ?? []).map(mapGitlabUser),
milestone: null,
url: issue.url,
});
type GiteaIssueInput = GiteaIssueSummary & Partial<Pick<GiteaIssue, 'body' | 'createdAt' | 'updatedAt'>>;
export const mapGiteaIssue = (issue: GiteaIssueInput): ForgeIssue => ({
number: issue.number,
title: issue.title,
body: issue.body,
state: stateOf(issue.state),
author: mapGiteaUser(issue.author),
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
labels: (issue.labels ?? []).map((name) => ({ name })),
// Gitea's wire issue type carries no assignees.
assignees: [],
milestone: null,
url: issue.url,
});
// ---------------------------------------------------------------------------
// Comments
// ---------------------------------------------------------------------------
export const mapGithubIssueComment = (comment: GitHubIssueComment): ForgeComment => ({
id: String(comment.id),
body: comment.body,
author: comment.author ? mapGithubUser(comment.author) : undefined,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
url: comment.url,
inReplyToId: null,
path: null,
line: null,
commitSha: null,
});
export const mapGithubReviewComment = (comment: GithubReviewComment): ForgeComment => ({
id: String(comment.id),
body: comment.body,
author: comment.author ? mapGithubUser(comment.author) : undefined,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
url: comment.url,
path: comment.path,
line: comment.line ?? comment.position ?? null,
inReplyToId: null,
commitSha: null,
});
export const mapGitlabNoteComment = (comment: GitLabIssueComment): ForgeComment => ({
id: String(comment.id),
body: comment.body,
author: mapGitlabUser(comment.author),
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
url: comment.url,
});
export const mapGiteaComment = (comment: GiteaComment): ForgeComment => ({
id: String(comment.id),
body: comment.body,
author: mapGiteaUser(comment.author),
createdAt: comment.createdAt,
url: comment.url,
});
// ---------------------------------------------------------------------------
// Checks
// ---------------------------------------------------------------------------
/**
* Map a GitHub Check Run status/conclusion pair onto the normalized check
* state. A conclusion always wins; absent conclusions fall back to the run
* status (anything not yet 'completed' is pending).
*/
export const mapCheckRunState = (status?: string, conclusion?: string | null): ForgeCheckState => {
const c = conclusion;
if (c && c !== '') {
switch (c) {
case 'success':
case 'neutral':
return 'success';
case 'failure':
case 'error':
case 'timed_out':
case 'startup_failure':
case 'deadline_exceeded':
return 'failure';
case 'cancelled':
return 'cancelled';
case 'skipped':
case 'stale':
return 'skipped';
case 'action_required':
return 'pending';
default:
return 'unknown';
}
}
// No conclusion yet: queued, in_progress, waiting, ... are all still running.
return status === 'completed' ? 'unknown' : 'pending';
};
export const mapGithubCheckSummary = (
checks: GitHubChecksSummary,
checkRuns?: GitHubCheckRun[],
): ForgeChecksSummary => ({
state: checks.state,
total: checks.total,
success: checks.success,
failure: checks.failure,
pending: checks.pending,
checks: (checkRuns ?? []).map((run): ForgeCheck => ({
kind: 'check-run',
name: run.name,
state: mapCheckRunState(run.status, run.conclusion),
startedAt: run.startedAt,
completedAt: run.completedAt,
url: run.detailsUrl,
description: run.output?.summary,
details: {
title: run.output?.title,
summary: run.output?.summary,
text: run.output?.text,
annotations: (run.annotations ?? []).map((annotation) => ({
path: annotation.path,
startLine: annotation.startLine,
endLine: annotation.endLine,
level: annotation.level,
message: annotation.message,
title: annotation.title,
})),
},
})),
});
// ---------------------------------------------------------------------------
// Contexts
// ---------------------------------------------------------------------------
export const mapGithubContext = (result: GitHubPullRequestContextResult): ForgePullRequestContext => ({
connected: result.connected,
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
pr: result.pr ? mapGithubPr(result.pr) : null,
issueComments: (result.issueComments ?? []).map(mapGithubIssueComment),
reviewComments: (result.reviewComments ?? []).map(mapGithubReviewComment),
files: (result.files ?? []).map(mapFileChange),
diff: result.diff,
checks: result.checks ? mapGithubCheckSummary(result.checks, result.checkRuns) : null,
fetchedAt: result.fetchedAt,
});
export const mapGitlabContext = (result: GitLabMergeRequestContextResult): ForgePullRequestContext => ({
connected: result.connected,
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
pr: result.mr ? mapGitlabMr(result.mr) : null,
issueComments: (result.comments ?? []).map(mapGitlabNoteComment),
reviewComments: [],
files: (result.files ?? []).map(mapFileChange),
diff: result.diff,
checks: null,
});
export const mapGiteaContext = (result: GiteaPullRequestContextResult): ForgePullRequestContext => ({
connected: result.connected,
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
pr: result.pr ? mapGiteaPr(result.pr) : null,
issueComments: (result.comments ?? []).map(mapGiteaComment),
reviewComments: [],
files: (result.files ?? []).map(mapFileChange),
diff: result.diff,
checks: null,
});
// ---------------------------------------------------------------------------
// Internal helpers (repo refs and file changes; not part of the public API)
// ---------------------------------------------------------------------------
export const mapGithubRepoRef = (ref: { owner: string; repo: string; url: string }): ForgeRepoRef => ({
owner: ref.owner,
repo: ref.repo,
url: ref.url,
provider: 'github',
});
export const mapGitlabRepoRef = (ref: GitLabRepoRef): ForgeRepoRef => ({
owner: ref.namespace,
repo: ref.project,
url: ref.url,
baseUrl: ref.baseUrl,
provider: 'gitlab',
});
export const mapGiteaRepoRef = (ref: { owner: string; repo: string; url?: string }): ForgeRepoRef => ({
owner: ref.owner,
repo: ref.repo,
url: ref.url,
provider: 'gitea',
});
type WireFileChange = {
filename: string;
status?: string;
additions?: number;
deletions?: number;
patch?: string;
};
const mapFileChange = (file: WireFileChange): ForgeFileChange => ({
filename: file.filename,
status: file.status,
additions: file.additions,
deletions: file.deletions,
patch: file.patch,
});
+136
View File
@@ -0,0 +1,136 @@
import type {
ForgeChecksSummary,
ForgeComment,
ForgeFileChange,
ForgeIssue,
ForgeProviderCapabilities,
ForgeProviderKind,
ForgePullRequest,
ForgeRepoRef,
} from './types';
/**
* Provider-agnostic git-forge facade.
*
* Adapters implement this interface against the per-provider wire APIs declared
* in `@/lib/api/types.ts` and normalize the results onto the shapes in
* `./types.ts`. Consumers (issue/PR views) depend only on this interface plus
* the capability flags, never on a specific provider's API.
*
* Result envelopes mirror the existing wire envelopes (`{ connected, repo, ... }`)
* from `@/lib/api/types.ts`, so `connected: false` keeps the same meaning: the
* forge was not reachable/authenticated and no authoritative data was fetched.
* Callers must never treat a `connected: false` envelope as an empty success.
*/
/** Paginated pull request list result (see `GitHubPullRequestsListResult` / `GitLabMergeRequestsListResult`). */
export interface ForgePullRequestsResult {
connected: boolean;
repo?: ForgeRepoRef | null;
prs: ForgePullRequest[];
page: number;
hasMore: boolean;
}
/**
* Full context for one pull request: comments, review comments, files, diff,
* and check summary. Roughly the union of `GitHubPullRequestContextResult`
* (comments + review comments + files + diff + checkDetails), the GitLab
* merge-request context (comments + files + diff), and the Gitea PR context
* (comments + files + diff).
*/
export interface ForgePullRequestContext {
connected: boolean;
repo?: ForgeRepoRef | null;
pr?: ForgePullRequest | null;
issueComments: ForgeComment[];
reviewComments: ForgeComment[];
files: ForgeFileChange[];
diff?: string;
checks?: ForgeChecksSummary | null;
/** Timestamp (epoch ms) the context was fetched at; adapters may set it for staleness. */
fetchedAt?: number;
}
/** Paginated issue list result (see `GitHubIssuesListResult` / `GitLabIssuesListResult`). */
export interface ForgeIssuesResult {
connected: boolean;
repo?: ForgeRepoRef | null;
issues: ForgeIssue[];
page: number;
hasMore: boolean;
}
/** Issue detail plus its comments (see `GitHubIssueGetResult` + `issueComments`, `GitLabIssueGetResult`). */
export interface ForgeIssueDetail {
connected: boolean;
repo?: ForgeRepoRef | null;
issue?: ForgeIssue | null;
comments: ForgeComment[];
/**
* Set when the issue loaded but its comments failed to fetch — never
* masquerade as authoritative empty. null when comments fetched cleanly or
* were never attempted (e.g. the whole fetch failed).
*/
commentsError?: string | null;
}
/**
* Provider-agnostic forge operations. Every method resolves the target
* repository from the working directory (remotes + connected accounts) and
* takes a directory argument, matching the per-provider APIs in
* `@/lib/api/types.ts`.
*/
export interface ForgeProvider {
readonly kind: ForgeProviderKind;
readonly capabilities: ForgeProviderCapabilities;
// --- Pull requests ---
/**
* Resolve the PR/MR open on `branch` (falling back to the merged one), or
* null when there is none. Wraps `github prStatus` (GitHubPullRequestStatus),
* `gitlab mrsList` filtered by `sourceBranch`, and `gitea prsList` filtered
* by `sourceBranch`. Used to link the current worktree branch to its PR.
*/
getPullRequestForBranch(directory: string, branch: string, options?: { remote?: string }): Promise<ForgePullRequest | null>;
/**
* List pull requests, paginated. Wraps `github prsList`, `gitlab mrsList`,
* and `gitea prsList`; `query` is passed through to the provider's search
* where supported. Normalization intent: `state` maps to the provider's
* open/merged/closed vocabulary and `draft` is always a boolean.
*/
listPullRequests(directory: string, options?: { page?: number; query?: string }): Promise<ForgePullRequestsResult>;
/**
* Full context for one PR: issue comments + review comments + files + diff +
* checks. Wraps `github prContext`, `gitlab mrContext`, and `gitea prContext`,
* plus the checks APIs where the provider exposes them. `sourceRepo` selects
* a cross-repo (fork) repository, mirroring `GitHubRepoSelector`.
*/
getPullRequestContext(
directory: string,
number: number,
options?: { includeDiff?: boolean; sourceRepo?: string | null },
): Promise<ForgePullRequestContext>;
// --- Issues ---
/**
* List issues, paginated. Wraps `github issuesList`, `gitlab issuesList`,
* and `gitea issuesList`. `query` passes through to the provider's search
* where supported.
*/
listIssues(directory: string, options?: { page?: number; query?: string }): Promise<ForgeIssuesResult>;
/**
* Fetch a single issue plus its comments. Wraps `github issueGet` +
* `issueComments`, `gitlab issueGet` + `issueComments`, and `gitea issueGet`
* + `issueComments`. `sourceRepo` selects a cross-repo (fork) repository.
*/
getIssue(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeIssueDetail>;
// NOTE: Slice B (next chunk) will add getCommits / getTimeline / checks for
// the rich entity view. Do NOT add them here yet.
}
+275
View File
@@ -0,0 +1,275 @@
/**
* Normalized, provider-agnostic entities for the git-forge facade.
*
* Every type here is a projection of the per-provider wire shapes defined in
* `@/lib/api/types.ts` (GitHubAPI / GitLabAPI / GiteaAPI) plus provider-native
* APIs (GitHub checks, GitLab review approvals). Adapters map provider payloads
* onto these shapes in a follow-up chunk; this file declares the contract only
* and contains no runtime code.
*
* Provider terminology differences are intentional:
* - GitHub: issues + pull requests (PR), checks via Check Runs / Commit Statuses.
* - GitLab: issues + merge requests (MR), pipelines/statuses as commit statuses.
* - Gitea/Forgejo: issues + pull requests (PR), GitHub-style REST v1.
* The normalized vocabulary always uses the GitHub-ish noun ("pull request",
* "check run"), with the provider-specific term documented where it differs.
*/
/** Which git-forge backend an adapter talks to. */
export type ForgeProviderKind = 'github' | 'gitlab' | 'gitea';
/** What the provider exposes for CI/status on a PR/MR. */
export type ForgeChecksCapability = 'check-runs' | 'commit-statuses' | 'none';
/** What the provider lets the UI do with reviews. */
export type ForgeReviewsCapability = 'submit' | 'approve-only' | 'none';
/**
* Declared capabilities of a forge provider.
*
* The UI uses these to enable/disable affordances (draft toggle, label editor,
* per-line review comments, ...) rather than guessing from the provider kind.
* A provider must be honest here: `submit` reviews implies the API also exposes
* review objects with distinct states (see `ForgeReview.state`).
*/
export interface ForgeProviderCapabilities {
checks: ForgeChecksCapability;
reviews: ForgeReviewsCapability;
/** Draft <-> ready toggle supported (GitHub draft PRs; GitLab/Gitea draft markers). */
draft: boolean;
labels: boolean;
assignees: boolean;
milestones: boolean;
/** Provider exposes distinct event types (opened/approved/merged markers), not just comments. */
timelineEvents: boolean;
/** Per-line (path:line) review comments. */
inlineComments: boolean;
/** Comment threads that can be replied to. */
threads: boolean;
}
/** A person as surfaced by the forge (issue author, reviewer, commit author, ...). */
export interface ForgeUser {
id: string;
login: string;
name?: string;
avatarUrl?: string;
url?: string;
}
/** A label as displayed on issues/PRs. GitHub colors are hex; GitLab/Gitea carry none. */
export interface ForgeLabel {
name: string;
color?: string;
description?: string;
}
/** A milestone a PR/issue can be attached to. `active` is GitLab's open-milestone state. */
export interface ForgeMilestone {
title: string;
state?: 'open' | 'closed' | 'active';
}
/**
* A repository reference scoped to its forge.
*
* GitHub and Gitea are flat `owner/repo`. GitLab uses a top-level namespace as
* `owner` with multi-segment project paths (e.g. `group/sub`) in `namespace`;
* the combined project path is `namespace + '/' + repo` when `namespace` is set.
*/
export interface ForgeRepoRef {
/** For gitlab: top-level namespace; multi-segment namespaces go in `namespace`. */
owner: string;
/** GitLab multi-segment namespace path (e.g. 'group/sub'). */
namespace?: string;
repo: string;
url?: string;
/** Provider API base (for self-hosted gitlab/gitea). */
baseUrl?: string;
provider: ForgeProviderKind;
}
/** Open/closed lifecycle state, shared by issues and PRs/MRs. 'merged' is PR-only. */
export type ForgeEntityState = 'open' | 'closed' | 'merged';
/** A normalized issue (GitLab issue / Gitea issue map 1:1; GitHub issue maps directly). */
export interface ForgeIssue {
number: number;
title: string;
body?: string;
state: ForgeEntityState;
author?: ForgeUser;
createdAt?: string;
updatedAt?: string;
closedAt?: string;
labels: ForgeLabel[];
assignees: ForgeUser[];
milestone?: ForgeMilestone | null;
commentsCount?: number;
url?: string;
}
/** A branch reference on a PR/MR. `repo` is present for cross-repo (fork) head/base. */
export interface ForgeBranchRef {
ref: string;
/** Cross-repo (fork) head/base; null/absent means the same repository. */
repo?: ForgeRepoRef | null;
}
/** A normalized pull request / merge request. */
export interface ForgePullRequest {
number: number;
title: string;
body?: string;
state: ForgeEntityState;
draft: boolean;
author?: ForgeUser;
createdAt?: string;
updatedAt?: string;
closedAt?: string;
base: ForgeBranchRef;
head: ForgeBranchRef;
/** HEAD sha of the source branch; nil for forks when not expanded by the provider. */
headSha?: string;
mergeable?: boolean | null;
/** Provider mergeability detail (GitHub `mergeable_state`, GitLab `detailed_merge_status`). */
mergeableState?: string | null;
labels: ForgeLabel[];
assignees: ForgeUser[];
milestone?: ForgeMilestone | null;
commentsCount?: number;
url?: string;
}
/**
* A normalized comment.
*
* One shape for both issue comments and review comments: inline review comments
* carry `path`/`line`/`commitSha`, and thread replies carry `inReplyToId`
* (GitHub review-comment reply, GitLab note reply).
*/
export interface ForgeComment {
id: string;
body: string;
author?: ForgeUser;
createdAt?: string;
updatedAt?: string;
url?: string;
/** Thread reply anchor (github review comment / gitlab note reply). */
inReplyToId?: string | null;
/** For inline (review) comments: file path. */
path?: string | null;
/** For inline comments: line number. */
line?: number | null;
/** For review comments. */
commitSha?: string | null;
}
/**
* Distinct timeline/activity event kinds.
*
* Which of these actually occur depends on the provider's `timelineEvents`
* capability: GitHub exposes a rich timeline (opened/approved/merged markers),
* GitLab exposes system notes, Gitea typically only comments.
*/
export type ForgeTimelineEventType =
| 'opened' | 'reopened' | 'closed' | 'merged'
| 'committed' | 'reviewed' | 'approved' | 'requested-changes'
| 'commented' | 'referenced' | 'labeled' | 'unlabeled' | 'assigned' | 'unassigned'
| 'milestoned' | 'demilestoned' | 'other';
/** A single entry in an issue/PR timeline. */
export interface ForgeTimelineEvent {
id: string;
type: ForgeTimelineEventType;
author?: ForgeUser;
createdAt?: string;
body?: string;
commitSha?: string;
/**
* Provider name for provenance, e.g. 'github-timeline' | 'gitlab-system-note'
* | 'gitea-synthesized'.
*/
source: string;
}
/** A normalized commit. */
export interface ForgeCommit {
sha: string;
shortSha: string;
message: string;
/** First line of the message. */
summary?: string;
author?: ForgeUser;
committer?: ForgeUser;
committedAt?: string;
parents: string[];
}
/** A file changed by a PR/MR, with diff stats and optional patch. */
export interface ForgeFileChange {
filename: string;
/** 'added' | 'modified' | 'removed' | 'renamed'. */
status?: string;
additions?: number;
deletions?: number;
patch?: string;
}
/**
* Rolled-up CI/status state of a PR/MR.
* 'success' also covers GitLab/Gitea statuses where a passing pipeline is 'success'.
*/
export type ForgeCheckState = 'success' | 'failure' | 'pending' | 'cancelled' | 'skipped' | 'unknown';
/** Whether the check is a GitHub Check Run or a flat status (commit status / GitLab pipeline). */
export type ForgeCheckKind = 'check-run' | 'commit-status';
/** An annotation attached to a check run, pointing at a file/line range. */
export interface ForgeCheckAnnotation {
path?: string;
startLine?: number;
endLine?: number;
level?: string;
message?: string;
title?: string;
}
/** A single check run or commit status on a PR/MR. */
export interface ForgeCheck {
kind: ForgeCheckKind;
name: string;
state: ForgeCheckState;
startedAt?: string;
completedAt?: string;
url?: string;
description?: string;
details?: {
title?: string;
summary?: string;
text?: string;
annotations?: ForgeCheckAnnotation[];
};
}
/** Aggregate check state for a PR/MR plus the individual checks. */
export interface ForgeChecksSummary {
state: ForgeCheckState;
total: number;
success: number;
failure: number;
pending: number;
checks: ForgeCheck[];
}
/**
* A review submitted on a PR/MR.
* GitLab's single approval maps to 'approved'; GitHub pull-request reviews map
* directly onto the states.
*/
export interface ForgeReview {
id: string;
state: 'approved' | 'requested-changes' | 'commented' | 'pending' | 'dismissed';
author?: ForgeUser;
submittedAt?: string;
body?: string;
commitSha?: string;
}