feat(ui): forge facade foundation — normalized types, capability flags, three adapters
This commit is contained in:
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user