* refactor(forge): forge-neutral foundation (GitHub-only) Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged. - Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry. - GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved. - forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client). - createPullRequest reports typed CLI/auth errors instead of a generic message. - forge-resolver host/remote caches are LRU-bounded. - Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com. - Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology. - Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs. * feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals. - threadIsResolved is part of the neutral timeline item. - Pipeline load failures show an error instead of an empty section. - Manual pipeline jobs render as pending. - Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request). * feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg) One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts. - CI status aggregates commit statuses and Actions runs together. - Gitea's terminal "warning" state maps to failure on server and client. - Gitea Actions check details are reachable from the PR pane by workflowRunId. * refactor(forge): localize compatibility handling * test(forge): expect normalized GitLab facts --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
526 lines
15 KiB
TypeScript
526 lines
15 KiB
TypeScript
import type { ForgeSearchKind } from "@getpaseo/protocol/messages";
|
|
|
|
export type ForgeSearchRequestKind = ForgeSearchKind | "github-issue" | "github-pr" | "pr";
|
|
|
|
export function normalizeForgeSearchKinds(
|
|
kinds: readonly ForgeSearchRequestKind[] | undefined,
|
|
): ForgeSearchKind[] {
|
|
if (!kinds) return ["issue", "change_request"];
|
|
|
|
return kinds.map((kind) => {
|
|
// COMPAT(githubSearchKind): added in v0.1.106, remove with the legacy
|
|
// github_search_request RPC after 2026-12-28.
|
|
if (kind === "github-issue") return "issue";
|
|
if (kind === "github-pr" || kind === "pr") return "change_request";
|
|
return kind;
|
|
});
|
|
}
|
|
|
|
export interface PullRequestSummary {
|
|
number: number;
|
|
title: string;
|
|
url: string;
|
|
state: string;
|
|
body: string | null;
|
|
projectPath?: string;
|
|
baseRefName: string;
|
|
headRefName: string;
|
|
labels: string[];
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface PullRequestCheckoutTarget {
|
|
number: number;
|
|
baseRefName: string;
|
|
headRefName: string;
|
|
checkoutRefs?: PullRequestCheckoutRef[];
|
|
headOwnerLogin: string | null;
|
|
headRepositorySshUrl: string | null;
|
|
headRepositoryUrl: string | null;
|
|
isCrossRepository: boolean;
|
|
}
|
|
|
|
export interface PullRequestCheckoutRef {
|
|
remoteName?: string;
|
|
remoteRef: string;
|
|
}
|
|
|
|
export interface IssueSummary {
|
|
number: number;
|
|
title: string;
|
|
url: string;
|
|
state: string;
|
|
body: string | null;
|
|
projectPath?: string;
|
|
labels: string[];
|
|
updatedAt: string;
|
|
}
|
|
|
|
export type PullRequestCheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped";
|
|
|
|
export interface PullRequestCheck {
|
|
name: string;
|
|
status: PullRequestCheckStatus;
|
|
url: string | null;
|
|
workflow?: string;
|
|
duration?: string;
|
|
checkRunId?: number;
|
|
workflowRunId?: number;
|
|
}
|
|
|
|
export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure";
|
|
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
|
|
export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
|
|
export function computeChecksStatus(checks: PullRequestCheck[]): PullRequestChecksStatus {
|
|
if (checks.length === 0) {
|
|
return "none";
|
|
}
|
|
if (checks.some((check) => check.status === "failure")) {
|
|
return "failure";
|
|
}
|
|
if (checks.some((check) => check.status === "pending")) {
|
|
return "pending";
|
|
}
|
|
return "success";
|
|
}
|
|
|
|
/**
|
|
* Why a forge's PR/MR features are (un)available for a workspace. Replaces the
|
|
* lossy "authenticated yes/no" boolean so the UI can offer the precise next step
|
|
* (install the CLI vs sign in) instead of a single generic dead-end. "no_remote"
|
|
* covers anything where the feature simply does not apply (no resolvable forge
|
|
* remote, or no branch to look up). "error" is reserved for non-auth failures
|
|
* where the UI should show the actual error instead of setup guidance.
|
|
*/
|
|
export type ForgeAuthState =
|
|
| "authenticated"
|
|
| "unauthenticated"
|
|
| "cli_missing"
|
|
| "no_remote"
|
|
| "error";
|
|
|
|
/**
|
|
* Open envelope for a forge's native merge facts on the neutral PR status.
|
|
* Per-adapter modules own their typed fact interfaces and guards; shared server
|
|
* code only requires the runtime facts-family tag.
|
|
*/
|
|
export type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
|
|
|
|
export interface CurrentPullRequestStatus {
|
|
number?: number;
|
|
repoOwner?: string;
|
|
repoName?: string;
|
|
/**
|
|
* The forge's full project path (e.g. nested GitLab namespaces like
|
|
* `group/subgroup/repo`). Adapters that can report it precisely set it here;
|
|
* otherwise consumers fall back to deriving it from owner/name.
|
|
*/
|
|
projectPath?: string;
|
|
url: string;
|
|
title: string;
|
|
state: string;
|
|
baseRefName: string;
|
|
headRefName: string;
|
|
isMerged: boolean;
|
|
isDraft?: boolean;
|
|
mergeable: PullRequestMergeable;
|
|
checks: PullRequestCheck[];
|
|
checksStatus: PullRequestChecksStatus;
|
|
reviewDecision: PullRequestReviewDecision;
|
|
forgeSpecific?: ForgeSpecificStatusFacts;
|
|
}
|
|
|
|
export type PullRequestTimelineReviewState = "approved" | "changes_requested" | "commented";
|
|
|
|
interface PullRequestTimelineItemBase {
|
|
id: string;
|
|
author: string;
|
|
authorUrl: string | null;
|
|
avatarUrl: string | null;
|
|
body: string;
|
|
createdAt: number;
|
|
url: string;
|
|
}
|
|
|
|
export type PullRequestTimelineItem =
|
|
| (PullRequestTimelineItemBase & {
|
|
kind: "review";
|
|
reviewState: PullRequestTimelineReviewState;
|
|
})
|
|
| (PullRequestTimelineItemBase & {
|
|
kind: "comment";
|
|
reviewId?: string;
|
|
threadId?: string;
|
|
threadIsResolved?: boolean;
|
|
location?: PullRequestTimelineCommentLocation;
|
|
});
|
|
|
|
export interface PullRequestTimelineCommentLocation {
|
|
path: string;
|
|
line?: number;
|
|
startLine?: number;
|
|
threadId?: string;
|
|
isResolved?: boolean;
|
|
isOutdated?: boolean;
|
|
}
|
|
|
|
export type PullRequestTimelineErrorKind = "not_found" | "forbidden" | "unknown";
|
|
|
|
export interface PullRequestTimelineError {
|
|
kind: PullRequestTimelineErrorKind;
|
|
message: string;
|
|
}
|
|
|
|
export interface PullRequestTimeline {
|
|
prNumber: number;
|
|
repoOwner: string;
|
|
repoName: string;
|
|
items: PullRequestTimelineItem[];
|
|
truncated: boolean;
|
|
error: PullRequestTimelineError | null;
|
|
}
|
|
|
|
export interface PullRequestCreateResult {
|
|
url: string;
|
|
number: number;
|
|
}
|
|
|
|
export type PullRequestMergeMethod = "merge" | "squash" | "rebase";
|
|
|
|
export interface PullRequestCommandStatus {
|
|
mergeable?: PullRequestMergeable;
|
|
forgeSpecific?: ForgeSpecificStatusFacts;
|
|
}
|
|
|
|
export interface MergePullRequestOptions {
|
|
cwd: string;
|
|
prNumber: number;
|
|
mergeMethod: PullRequestMergeMethod;
|
|
status?: PullRequestCommandStatus | null;
|
|
}
|
|
|
|
export interface EnablePullRequestAutoMergeOptions {
|
|
cwd: string;
|
|
prNumber: number;
|
|
mergeMethod: PullRequestMergeMethod;
|
|
status?: PullRequestCommandStatus | null;
|
|
}
|
|
|
|
export interface DisablePullRequestAutoMergeOptions {
|
|
cwd: string;
|
|
prNumber: number;
|
|
status?: PullRequestCommandStatus | null;
|
|
}
|
|
|
|
export interface PullRequestMergeResult {
|
|
success: true;
|
|
}
|
|
|
|
export interface PullRequestAutoMergeResult {
|
|
success: true;
|
|
}
|
|
|
|
export type ForgeReadOptions =
|
|
| {
|
|
force?: false;
|
|
reason?: string;
|
|
}
|
|
| {
|
|
force: true;
|
|
reason: string;
|
|
};
|
|
|
|
export type ListPullRequestsOptions = {
|
|
cwd: string;
|
|
query?: string;
|
|
limit?: number;
|
|
} & ForgeReadOptions;
|
|
|
|
export type ListIssuesOptions = {
|
|
cwd: string;
|
|
query?: string;
|
|
limit?: number;
|
|
} & ForgeReadOptions;
|
|
|
|
export type GetPullRequestOptions = {
|
|
cwd: string;
|
|
number: number;
|
|
} & ForgeReadOptions;
|
|
|
|
export type GetPullRequestTimelineOptions = {
|
|
cwd: string;
|
|
prNumber: number;
|
|
repoOwner: string;
|
|
repoName: string;
|
|
} & ForgeReadOptions;
|
|
|
|
export type GetCheckDetailsOptions = {
|
|
cwd: string;
|
|
/**
|
|
* GitHub-only: the GitHub adapter addresses the check run by owner/name. The
|
|
* GitLab adapter resolves the project from the cwd's remote and ignores these.
|
|
*/
|
|
repoOwner?: string;
|
|
repoName?: string;
|
|
/**
|
|
* Check-run id. Optional because some checks are addressed only by
|
|
* workflowRunId (Gitea Actions runs carry no check-run id). Callers pass at
|
|
* least one of checkRunId/workflowRunId.
|
|
*/
|
|
checkRunId?: number;
|
|
workflowRunId?: number;
|
|
/**
|
|
* GitLab-only: the change request iid. GitLab routes the fetch to the change
|
|
* request's head pipeline (`glab ci get --merge-request`), so a fork/detached
|
|
* MR pipeline living in the source project resolves instead of 404ing against
|
|
* the checkout's target project. GitHub ignores it.
|
|
*/
|
|
changeRequestNumber?: number;
|
|
} & ForgeReadOptions;
|
|
|
|
export interface CheckAnnotation {
|
|
path?: string;
|
|
startLine?: number;
|
|
endLine?: number;
|
|
annotationLevel?: string;
|
|
message?: string;
|
|
title?: string;
|
|
rawDetails?: string;
|
|
}
|
|
|
|
export interface CheckFailedJob {
|
|
jobId: number;
|
|
name: string;
|
|
status?: string | null;
|
|
conclusion?: string | null;
|
|
url?: string | null;
|
|
completedAt?: string;
|
|
logTail?: string;
|
|
logTruncated?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Normalized lifecycle of a CI job/pipeline, neutral across forges. Adapters
|
|
* map their forge's raw status strings onto this; readers keep the raw string
|
|
* too (see {@link PipelineJob.rawStatus}) for display fidelity.
|
|
*/
|
|
export type PipelineJobStatus =
|
|
| "success"
|
|
| "failed"
|
|
| "running"
|
|
| "pending"
|
|
| "canceled"
|
|
| "skipped"
|
|
| "manual"
|
|
| "created"
|
|
| "unknown";
|
|
|
|
export interface PipelineJob {
|
|
id: number;
|
|
name: string;
|
|
stage: string;
|
|
status: PipelineJobStatus;
|
|
rawStatus: string;
|
|
url: string | null;
|
|
allowFailure: boolean;
|
|
durationSeconds: number | null;
|
|
}
|
|
|
|
export interface PipelineStage {
|
|
name: string;
|
|
status: PipelineJobStatus;
|
|
jobs: PipelineJob[];
|
|
}
|
|
|
|
/**
|
|
* A CI pipeline as a stage → job tree. Forges that model CI as a pipeline
|
|
* (GitLab) populate this; forges that model it as flat check runs (GitHub)
|
|
* leave {@link CheckDetails.pipeline} undefined and use the check-run fields.
|
|
*/
|
|
export interface PipelineDetails {
|
|
id: number;
|
|
status: PipelineJobStatus;
|
|
rawStatus: string;
|
|
url: string | null;
|
|
ref: string | null;
|
|
sha: string | null;
|
|
stages: PipelineStage[];
|
|
}
|
|
|
|
export interface CheckDetails {
|
|
checkRunId: number;
|
|
workflowRunId?: number | null;
|
|
name: string;
|
|
status?: string | null;
|
|
conclusion?: string | null;
|
|
url?: string | null;
|
|
detailsUrl?: string | null;
|
|
output?: {
|
|
title?: string | null;
|
|
summary?: string | null;
|
|
text?: string | null;
|
|
} | null;
|
|
annotations: CheckAnnotation[];
|
|
failedJobs: CheckFailedJob[];
|
|
truncated: boolean;
|
|
/**
|
|
* Structured pipeline (stages → jobs) for forges that model CI as a pipeline.
|
|
* GitLab populates it; GitHub leaves it undefined and keeps using the flat
|
|
* check-run fields above.
|
|
*/
|
|
pipeline?: PipelineDetails | null;
|
|
}
|
|
|
|
export interface SearchResult {
|
|
items: Array<{
|
|
kind: "issue" | "change_request";
|
|
forge?: string;
|
|
number: number;
|
|
title: string;
|
|
url: string;
|
|
state: string;
|
|
body: string | null;
|
|
labels: string[];
|
|
projectPath?: string;
|
|
baseRefName?: string | null;
|
|
headRefName?: string | null;
|
|
updatedAt?: string;
|
|
}>;
|
|
featuresEnabled: boolean;
|
|
authState: ForgeAuthState;
|
|
/**
|
|
* COMPAT(githubFeaturesEnabled): added in v0.1.106, remove after 2026-12-28.
|
|
*/
|
|
githubFeaturesEnabled?: boolean;
|
|
}
|
|
|
|
export function createUnavailableSearchResult(
|
|
authState: Exclude<ForgeAuthState, "authenticated">,
|
|
): SearchResult {
|
|
return {
|
|
items: [],
|
|
featuresEnabled: false,
|
|
authState,
|
|
githubFeaturesEnabled: false,
|
|
};
|
|
}
|
|
|
|
export type SearchIssuesAndPrsOptions = {
|
|
cwd: string;
|
|
query: string;
|
|
limit?: number;
|
|
kinds?: ForgeSearchRequestKind[];
|
|
} & ForgeReadOptions;
|
|
|
|
export interface CreatePullRequestOptions {
|
|
cwd: string;
|
|
title: string;
|
|
head: string;
|
|
base: string;
|
|
body?: string;
|
|
}
|
|
|
|
export interface ForgeService {
|
|
listPullRequests(options: ListPullRequestsOptions): Promise<PullRequestSummary[]>;
|
|
listIssues(options: ListIssuesOptions): Promise<IssueSummary[]>;
|
|
getPullRequest(options: GetPullRequestOptions): Promise<PullRequestSummary>;
|
|
getPullRequestHeadRef(options: GetPullRequestOptions): Promise<string>;
|
|
getPullRequestCheckoutTarget(options: GetPullRequestOptions): Promise<PullRequestCheckoutTarget>;
|
|
/**
|
|
* Refs to fetch for a change-request checkout when the resolved checkout
|
|
* target carries none. Adapters that expose a universal change-request head
|
|
* ref (GitHub's refs/pull/N/head) return it here; others let the shell fall
|
|
* back to the head branch.
|
|
*/
|
|
defaultCheckoutRefs?(params: {
|
|
changeRequestNumber: number;
|
|
headRef: string;
|
|
}): PullRequestCheckoutRef[];
|
|
/**
|
|
* Local branch name for a checked-out change request when the adapter
|
|
* disambiguates cross-repository heads (GitHub prefixes the fork owner).
|
|
* Returns undefined to keep the head ref name as-is.
|
|
*/
|
|
buildPrLocalBranchName?(params: {
|
|
headRef: string;
|
|
checkoutTarget: PullRequestCheckoutTarget;
|
|
}): string | undefined;
|
|
/**
|
|
* True when the adapter can fetch a cross-repository change-request head with
|
|
* no explicit refs on the checkout target (GitHub via refs/pull/N/head).
|
|
* Others reject a cross-repo checkout that carries no refs.
|
|
*/
|
|
supportsCrossRepoCheckoutWithoutRefs?: boolean;
|
|
getCurrentPullRequestStatus(
|
|
options: {
|
|
cwd: string;
|
|
headRef: string;
|
|
headRepositoryOwner?: string;
|
|
} & ForgeReadOptions,
|
|
): Promise<CurrentPullRequestStatus | null>;
|
|
getPullRequestTimeline(options: GetPullRequestTimelineOptions): Promise<PullRequestTimeline>;
|
|
getCheckDetails(options: GetCheckDetailsOptions): Promise<CheckDetails>;
|
|
searchIssuesAndPrs(options: SearchIssuesAndPrsOptions): Promise<SearchResult>;
|
|
createPullRequest(options: CreatePullRequestOptions): Promise<PullRequestCreateResult>;
|
|
mergePullRequest(options: MergePullRequestOptions): Promise<PullRequestMergeResult>;
|
|
enablePullRequestAutoMerge(
|
|
options: EnablePullRequestAutoMergeOptions,
|
|
): Promise<PullRequestAutoMergeResult>;
|
|
disablePullRequestAutoMerge(
|
|
options: DisablePullRequestAutoMergeOptions,
|
|
): Promise<PullRequestAutoMergeResult>;
|
|
/**
|
|
* Check whether the adapter can authenticate against the forge for `cwd`.
|
|
* An adapter picks exactly one of two contracts:
|
|
*
|
|
* - Resolve to `false` on any auth failure (missing CLI, expired token,
|
|
* unreachable host, ...) and never reject. This is the default contract;
|
|
* most adapters should implement it this way.
|
|
* - Reject with a classified error distinguishing the failure kind (e.g.
|
|
* "CLI not installed" vs "not logged in") instead of collapsing
|
|
* everything to `false`. An adapter that does this MUST also set
|
|
* {@link authProbeCanThrow} to `true`, or callers have no way to know the
|
|
* rejection is meaningful and will treat it as an unhandled error.
|
|
*/
|
|
isAuthenticated(options: { cwd: string } & ForgeReadOptions): Promise<boolean>;
|
|
/**
|
|
* True when isAuthenticated() throws a classified error (missing CLI /
|
|
* auth failure) on failure instead of just resolving to false. Callers that
|
|
* want the precise failure kind before falling back to a PR-status lookup
|
|
* (which can't distinguish "unauthenticated" from "no PR found") should
|
|
* only probe isAuthenticated() when this is set — otherwise the call
|
|
* cannot change the outcome and is pure overhead.
|
|
*/
|
|
authProbeCanThrow?: boolean;
|
|
retainCurrentPullRequestStatusPoll?(options: {
|
|
cwd: string;
|
|
headRef: string;
|
|
headRepositoryOwner?: string;
|
|
onStatus?: (status: CurrentPullRequestStatus | null) => void;
|
|
onError?: (error: unknown) => void;
|
|
}): { unsubscribe: () => void };
|
|
invalidate(options: { cwd: string }): void;
|
|
dispose?(): void;
|
|
}
|
|
|
|
/** Parse an optional ISO timestamp to epoch ms, 0 when absent or unparseable. */
|
|
export function parseOptionalTime(value: string | null | undefined): number {
|
|
if (!value) {
|
|
return 0;
|
|
}
|
|
const parsed = Date.parse(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
|
|
/** Stable timeline ordering shared by every adapter: by createdAt, then id. */
|
|
export function compareTimelineItems(
|
|
left: PullRequestTimelineItem,
|
|
right: PullRequestTimelineItem,
|
|
): number {
|
|
if (left.createdAt !== right.createdAt) {
|
|
return left.createdAt - right.createdAt;
|
|
}
|
|
return left.id.localeCompare(right.id);
|
|
}
|