feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)

* 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>
This commit is contained in:
nllptrx
2026-07-17 15:03:26 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 04d1ebdce0
commit a8ebd390fa
187 changed files with 20988 additions and 2907 deletions
+83 -16
View File
@@ -23,6 +23,7 @@ export {
import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import {
normalizeBaseRefName,
type PaseoWorktreeChangeRequestLookupTarget,
readPaseoWorktreeMetadata,
readPaseoWorktreeRuntimePort,
writePaseoWorktreeMetadata,
@@ -159,14 +160,33 @@ export interface WorktreeRootOptions {
worktreesRoot?: string;
}
export interface WorktreeCheckoutRef {
remoteName?: string;
remoteRef: string;
}
export type WorktreeSource =
| { kind: "branch-off"; baseBranch: string; branchName: string }
| { kind: "checkout-branch"; branchName: string }
| {
kind: "checkout-change-request";
forge: string;
changeRequestNumber: number;
headRef: string;
headRepositoryOwner?: string;
baseRefName: string;
checkoutRefs?: WorktreeCheckoutRef[];
localBranchName?: string;
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
| {
kind: "checkout-github-pr";
githubPrNumber: number;
headRef: string;
headRepositoryOwner?: string;
baseRefName: string;
checkoutRefs?: WorktreeCheckoutRef[];
localBranchName?: string;
pushRemoteUrl?: string;
trackOriginHead?: boolean;
@@ -1216,7 +1236,12 @@ export const createWorktree = async ({
});
}
writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName });
writePaseoWorktreeMetadata(worktreePath, {
baseRefName: sourcePlan.metadataBaseRefName,
...(sourcePlan.changeRequestLookupTarget
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
: {}),
});
// If paseo.json exists in the main repo but wasn't checked into the worktree
// (e.g. uncommitted on first-time setup), seed the worktree with it so setup
@@ -1254,6 +1279,7 @@ interface ResolveWorktreeSourcePlanOptions {
interface WorktreeSourcePlan {
branchName: string;
metadataBaseRefName: string;
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
addArguments: string[];
pushRemote?: {
name: string;
@@ -1311,24 +1337,23 @@ async function resolveWorktreeSourcePlan({
addArguments: [source.branchName],
};
}
case "checkout-change-request":
case "checkout-github-pr": {
const localBranchCandidate = source.localBranchName ?? source.headRef;
await validateExistingWorktreeBranchName(cwd, localBranchCandidate);
const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate);
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
await runGitCommand(
[
"fetch",
"origin",
`refs/pull/${source.githubPrNumber}/head:refs/heads/${localBranchName}`,
"--force",
const changeRequestNumber =
source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber;
await fetchWorktreeCheckoutRefs({
cwd,
localBranchName,
checkoutRefs: source.checkoutRefs ?? [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
{
cwd,
timeout: 120_000,
},
);
const trackingRemote = source.trackOriginHead
});
const shouldTrackOriginHead = source.trackOriginHead === true;
const trackingRemote = shouldTrackOriginHead
? await tryFetchWorktreeTrackingRemote({
cwd,
remoteName: "origin",
@@ -1337,18 +1362,18 @@ async function resolveWorktreeSourcePlan({
: undefined;
const remotePlan: Pick<WorktreeSourcePlan, "pushRemote" | "trackingRemote"> = {};
if (source.pushRemoteUrl) {
const remoteName = `paseo-pr-${source.githubPrNumber}`;
const remoteName = `paseo-pr-${changeRequestNumber}`;
remotePlan.pushRemote = {
name: remoteName,
url: source.pushRemoteUrl,
headRef: source.headRef,
track: true,
};
} else if (source.trackOriginHead && localBranchName !== source.headRef) {
} else if (shouldTrackOriginHead && localBranchName !== source.headRef) {
const originUrl = await getWorktreeRemotePushUrl(cwd, "origin");
if (originUrl) {
remotePlan.pushRemote = {
name: `paseo-pr-${source.githubPrNumber}`,
name: `paseo-pr-${changeRequestNumber}`,
url: originUrl,
headRef: source.headRef,
track: false,
@@ -1362,6 +1387,13 @@ async function resolveWorktreeSourcePlan({
return {
branchName: localBranchName,
metadataBaseRefName: normalizedBaseRefName,
changeRequestLookupTarget: {
headRef: source.headRef,
...(source.headRepositoryOwner
? { headRepositoryOwner: source.headRepositoryOwner }
: {}),
changeRequestNumber,
},
addArguments: [localBranchName],
...remotePlan,
};
@@ -1414,6 +1446,41 @@ async function configureWorktreePushRemote(options: {
}
}
async function fetchWorktreeCheckoutRefs(options: {
cwd: string;
localBranchName: string;
checkoutRefs: WorktreeCheckoutRef[];
}): Promise<void> {
let lastResult:
| Awaited<ReturnType<typeof runGitCommand>>
| { stderr: string; stdout: string; exitCode: number | null }
| null = null;
for (const checkoutRef of options.checkoutRefs) {
lastResult = await runGitCommand(
[
"fetch",
checkoutRef.remoteName ?? "origin",
`+${checkoutRef.remoteRef}:refs/heads/${options.localBranchName}`,
"--force",
],
{
cwd: options.cwd,
timeout: 120_000,
acceptExitCodes: [0, 1, 128],
},
);
if (lastResult.exitCode === 0) {
return;
}
}
const attemptedRefs = options.checkoutRefs
.map((checkoutRef) => `${checkoutRef.remoteName ?? "origin"} ${checkoutRef.remoteRef}`)
.join(", ");
throw new Error(
`Unable to fetch change request refs for worktree branch ${options.localBranchName}: ${attemptedRefs}${lastResult?.stderr ? `\n${lastResult.stderr}` : ""}`,
);
}
async function tryFetchWorktreeTrackingRemote(options: {
cwd: string;
remoteName: string;