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
+38 -19
View File
@@ -1,4 +1,4 @@
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -8,7 +8,10 @@ import {
isWorkspaceAttachment,
userAttachmentsOnly,
} from "@/attachments/workspace-attachment-utils";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
splitComposerAttachmentsForSubmit,
type ComposerAttachmentSubmitFormat,
} from "@/composer/attachments/submit";
import {
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
@@ -162,6 +165,7 @@ export interface DispatchComposerAgentMessageInput {
agentId: string;
text: string;
attachments: ComposerAttachment[];
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
encodeImages: (
images: AttachmentMetadata[],
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
@@ -171,7 +175,9 @@ export interface DispatchComposerAgentMessageInput {
export async function dispatchComposerAgentMessage(
input: DispatchComposerAgentMessageInput,
): Promise<void> {
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat,
});
const messageId = generateMessageId();
const userMessage = buildOptimisticUserMessage({
id: messageId,
@@ -342,33 +348,44 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
input.openExternalUrl(input.attachment.item.url);
}
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
export function buildForgeAttachment(item: ForgeSearchItem): UserComposerAttachment {
return item.kind === "change_request"
? { kind: "forge_change_request", item }
: { kind: "forge_issue", item };
}
function isGithubAttachment(
function isForgeAttachment(
attachment: UserComposerAttachment,
): attachment is Extract<UserComposerAttachment, { kind: "github_issue" } | { kind: "github_pr" }> {
return attachment.kind === "github_issue" || attachment.kind === "github_pr";
): attachment is Extract<
UserComposerAttachment,
{ kind: "forge_issue" | "forge_change_request" | "github_issue" | "github_pr" }
> {
return (
attachment.kind === "forge_issue" ||
attachment.kind === "forge_change_request" ||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
attachment.kind === "github_issue" ||
attachment.kind === "github_pr"
);
}
export function toggleGithubAttachment(
export function toggleForgeAttachment(
current: UserComposerAttachment[],
item: GitHubSearchItem,
item: ForgeSearchItem,
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
isGithubAttachment(attachment) &&
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
return current.filter((attachment) => !matches(attachment));
}
return [...current, buildGithubAttachment(item)];
return [...current, buildForgeAttachment(item)];
}
interface ToggleGithubAttachmentFromPickerInput {
current: UserComposerAttachment[];
item: GitHubSearchItem;
item: ForgeSearchItem;
markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void;
}
@@ -379,31 +396,33 @@ export function toggleGithubAttachmentFromPicker({
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
const existingAttachment = current.find(
(attachment) =>
isGithubAttachment(attachment) &&
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
if (existingAttachment) {
markGithubAttachmentRemoved(existingAttachment);
}
return toggleGithubAttachment(current, item);
return toggleForgeAttachment(current, item);
}
export function findGithubItemByOption(
items: readonly GitHubSearchItem[],
items: readonly ForgeSearchItem[],
optionId: string,
): GitHubSearchItem | undefined {
): ForgeSearchItem | undefined {
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
}
export function isAttachmentSelectedForGithubItem(
current: readonly ComposerAttachment[],
item: GitHubSearchItem,
item: ForgeSearchItem,
): boolean {
return userAttachmentsOnly(current).some(
(attachment) =>
isGithubAttachment(attachment) &&
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
}
export const toggleGithubAttachment = toggleForgeAttachment;