From f02c33700bd665c7c1a0d0f29612e49b00b40215 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Fri, 14 Aug 2026 04:43:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20forge=20facade=20foundation=20?= =?UTF-8?q?=E2=80=94=20normalized=20types,=20capability=20flags,=20three?= =?UTF-8?q?=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/src/hooks/useForgeProvider.ts | 64 ++ packages/ui/src/lib/forge/adapters.ts | 451 ++++++++++++++ packages/ui/src/lib/forge/forge.test.ts | 712 ++++++++++++++++++++++ packages/ui/src/lib/forge/index.ts | 74 +++ packages/ui/src/lib/forge/normalize.ts | 420 +++++++++++++ packages/ui/src/lib/forge/provider.ts | 136 +++++ packages/ui/src/lib/forge/types.ts | 275 +++++++++ 7 files changed, 2132 insertions(+) create mode 100644 packages/ui/src/hooks/useForgeProvider.ts create mode 100644 packages/ui/src/lib/forge/adapters.ts create mode 100644 packages/ui/src/lib/forge/forge.test.ts create mode 100644 packages/ui/src/lib/forge/index.ts create mode 100644 packages/ui/src/lib/forge/normalize.ts create mode 100644 packages/ui/src/lib/forge/provider.ts create mode 100644 packages/ui/src/lib/forge/types.ts diff --git a/packages/ui/src/hooks/useForgeProvider.ts b/packages/ui/src/hooks/useForgeProvider.ts new file mode 100644 index 00000000..bb5fdccb --- /dev/null +++ b/packages/ui/src/hooks/useForgeProvider.ts @@ -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 => { + 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, + }); +}; diff --git a/packages/ui/src/lib/forge/adapters.ts b/packages/ui/src/lib/forge/adapters.ts new file mode 100644 index 00000000..75d0af06 --- /dev/null +++ b/packages/ui/src/lib/forge/adapters.ts @@ -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; + } +}; diff --git a/packages/ui/src/lib/forge/forge.test.ts b/packages/ui/src/lib/forge/forge.test.ts new file mode 100644 index 00000000..372de56f --- /dev/null +++ b/packages/ui/src/lib/forge/forge.test.ts @@ -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(); + }); +}); diff --git a/packages/ui/src/lib/forge/index.ts b/packages/ui/src/lib/forge/index.ts new file mode 100644 index 00000000..9dc77723 --- /dev/null +++ b/packages/ui/src/lib/forge/index.ts @@ -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'; diff --git a/packages/ui/src/lib/forge/normalize.ts b/packages/ui/src/lib/forge/normalize.ts new file mode 100644 index 00000000..fb172f65 --- /dev/null +++ b/packages/ui/src/lib/forge/normalize.ts @@ -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>; + +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>; + +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>; + +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, +}); diff --git a/packages/ui/src/lib/forge/provider.ts b/packages/ui/src/lib/forge/provider.ts new file mode 100644 index 00000000..cce5053c --- /dev/null +++ b/packages/ui/src/lib/forge/provider.ts @@ -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; + + /** + * 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; + + /** + * 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; + + // --- 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; + + /** + * 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; + + // NOTE: Slice B (next chunk) will add getCommits / getTimeline / checks for + // the rich entity view. Do NOT add them here yet. +} diff --git a/packages/ui/src/lib/forge/types.ts b/packages/ui/src/lib/forge/types.ts new file mode 100644 index 00000000..7e9c4c1e --- /dev/null +++ b/packages/ui/src/lib/forge/types.ts @@ -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; +}