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:
co-authored by
Mohamed Boudra
parent
04d1ebdce0
commit
a8ebd390fa
@@ -0,0 +1,6 @@
|
||||
import { GITEA_FAMILY_URL_GRAMMAR, type ClientForgeLogicModule } from "@/git/client-forge-module";
|
||||
|
||||
export const codebergForgeLogic = {
|
||||
id: "codeberg",
|
||||
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
|
||||
} satisfies ClientForgeLogicModule;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { CodebergIcon } from "@/components/icons/codeberg-icon";
|
||||
import type { ClientForgeViewModule } from "@/git/client-forge-module";
|
||||
|
||||
export const codebergForgeView = {
|
||||
id: "codeberg",
|
||||
icon: CodebergIcon,
|
||||
brandColor: {
|
||||
light: "#2185D0",
|
||||
dark: "#2185D0",
|
||||
},
|
||||
} satisfies ClientForgeViewModule;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { GITEA_FAMILY_URL_GRAMMAR, type ClientForgeLogicModule } from "@/git/client-forge-module";
|
||||
|
||||
export const forgejoForgeLogic = {
|
||||
id: "forgejo",
|
||||
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
|
||||
} satisfies ClientForgeLogicModule;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ForgejoIcon } from "@/components/icons/forgejo-icon";
|
||||
import type { ClientForgeViewModule } from "@/git/client-forge-module";
|
||||
|
||||
export const forgejoForgeView = {
|
||||
id: "forgejo",
|
||||
icon: ForgejoIcon,
|
||||
brandColor: {
|
||||
light: "#FB923C",
|
||||
dark: "#FB923C",
|
||||
},
|
||||
} satisfies ClientForgeViewModule;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defineForgeFacts,
|
||||
defineNativeFallbackCheck,
|
||||
GITEA_FAMILY_URL_GRAMMAR,
|
||||
type ClientForgeLogicModule,
|
||||
type MergeCapability,
|
||||
} from "@/git/client-forge-module";
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { mapCheckStatus, type CheckStatus } from "@/git/pull-request-panel/check-status";
|
||||
|
||||
const GiteaMergeFactsSchema = z
|
||||
.object({
|
||||
forge: z.literal("gitea"),
|
||||
mergeable: z.boolean().optional().default(false),
|
||||
hasMerged: z.boolean().optional().default(false),
|
||||
ciStatus: z.string().nullable().optional().default(null),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type GiteaMergeFacts = z.infer<typeof GiteaMergeFactsSchema>;
|
||||
|
||||
// forgeSpecific.ciStatus carries Gitea's raw aggregate CI string. Server twin:
|
||||
// packages/server/src/services/gitea-service.ts (mapGiteaCommitStatus) — "warning"
|
||||
// and "error" are terminal, non-passing states, but the generic mapCheckStatus
|
||||
// would show them as pending, so interpret them here where the module owns Gitea
|
||||
// facts.
|
||||
function mapGiteaCiStatus(ciStatus: string): CheckStatus {
|
||||
if (ciStatus === "warning" || ciStatus === "error") {
|
||||
return "failure";
|
||||
}
|
||||
return mapCheckStatus(ciStatus);
|
||||
}
|
||||
|
||||
const GITEA_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"];
|
||||
|
||||
function deriveGiteaMergeCapability(gitea: GiteaMergeFacts): MergeCapability {
|
||||
return {
|
||||
directMergeReady: gitea.mergeable && !gitea.hasMerged,
|
||||
canEnableAutoMerge: false,
|
||||
autoMergeEnabled: false,
|
||||
canDisableAutoMerge: false,
|
||||
mergeBlockedByQueue: false,
|
||||
allowedMethods: GITEA_MERGE_METHODS,
|
||||
preferredMethod: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const giteaForgeLogic = {
|
||||
id: "gitea",
|
||||
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
|
||||
facts: defineForgeFacts({
|
||||
family: "gitea",
|
||||
schema: GiteaMergeFactsSchema,
|
||||
deriveMergeCapability: deriveGiteaMergeCapability,
|
||||
nativeFallbackChecks: [
|
||||
defineNativeFallbackCheck(GiteaMergeFactsSchema, {
|
||||
contribute: (facts, status, forge) => {
|
||||
if (!facts.ciStatus) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: forge,
|
||||
name: "CI",
|
||||
status: mapGiteaCiStatus(facts.ciStatus),
|
||||
url: status.url,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
} satisfies ClientForgeLogicModule<GiteaMergeFacts>;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { GiteaIcon } from "@/components/icons/gitea-icon";
|
||||
import type { ClientForgeViewModule } from "@/git/client-forge-module";
|
||||
|
||||
export const giteaForgeView = {
|
||||
id: "gitea",
|
||||
icon: GiteaIcon,
|
||||
brandColor: {
|
||||
light: "#609926",
|
||||
dark: "#609926",
|
||||
},
|
||||
} satisfies ClientForgeViewModule;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defineForgeFacts,
|
||||
GITHUB_LINE_ANCHOR,
|
||||
type ClientForgeLogicModule,
|
||||
type MergeCapability,
|
||||
} from "@/git/client-forge-module";
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
|
||||
const GithubAutoMergeRequestSchema = z
|
||||
.object({
|
||||
enabledAt: z.string().nullable().optional().default(null),
|
||||
mergeMethod: z.string().nullable().optional().default(null),
|
||||
enabledBy: z.string().nullable().optional().default(null),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.default(null);
|
||||
|
||||
const GithubRepositoryPolicySchema = z
|
||||
.object({
|
||||
autoMergeAllowed: z.boolean().optional().default(false),
|
||||
mergeCommitAllowed: z.boolean().optional().default(false),
|
||||
squashMergeAllowed: z.boolean().optional().default(false),
|
||||
rebaseMergeAllowed: z.boolean().optional().default(false),
|
||||
viewerDefaultMergeMethod: z.string().nullable().optional().default(null),
|
||||
})
|
||||
.optional()
|
||||
.default({
|
||||
autoMergeAllowed: false,
|
||||
mergeCommitAllowed: false,
|
||||
squashMergeAllowed: false,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: null,
|
||||
});
|
||||
|
||||
const GithubMergeFactsSchema = z
|
||||
.object({
|
||||
forge: z.literal("github"),
|
||||
mergeStateStatus: z.string().nullable().optional().default(null),
|
||||
autoMergeRequest: GithubAutoMergeRequestSchema,
|
||||
viewerCanEnableAutoMerge: z.boolean().optional().default(false),
|
||||
viewerCanDisableAutoMerge: z.boolean().optional().default(false),
|
||||
viewerCanMergeAsAdmin: z.boolean().optional().default(false),
|
||||
viewerCanUpdateBranch: z.boolean().optional().default(false),
|
||||
repository: GithubRepositoryPolicySchema,
|
||||
isMergeQueueEnabled: z.boolean().optional().default(false),
|
||||
isInMergeQueue: z.boolean().optional().default(false),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type GithubMergeFacts = z.infer<typeof GithubMergeFactsSchema>;
|
||||
|
||||
const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
|
||||
|
||||
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
|
||||
if (value === "SQUASH") return "squash";
|
||||
if (value === "MERGE") return "merge";
|
||||
if (value === "REBASE") return "rebase";
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveGithubMergeCapability(github: GithubMergeFacts): MergeCapability {
|
||||
const repository = github.repository;
|
||||
const allowedMethods: CheckoutPrMergeMethod[] = [];
|
||||
if (repository.mergeCommitAllowed) allowedMethods.push("merge");
|
||||
if (repository.squashMergeAllowed) allowedMethods.push("squash");
|
||||
if (repository.rebaseMergeAllowed) allowedMethods.push("rebase");
|
||||
return {
|
||||
directMergeReady: GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? ""),
|
||||
canEnableAutoMerge:
|
||||
github.mergeStateStatus === "BLOCKED" &&
|
||||
repository.autoMergeAllowed &&
|
||||
github.viewerCanEnableAutoMerge,
|
||||
autoMergeEnabled: github.autoMergeRequest !== null,
|
||||
canDisableAutoMerge: github.viewerCanDisableAutoMerge === true,
|
||||
mergeBlockedByQueue: github.isMergeQueueEnabled || github.isInMergeQueue,
|
||||
allowedMethods,
|
||||
preferredMethod: normalizeGithubMergeMethod(repository.viewerDefaultMergeMethod ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
export const githubForgeLogic = {
|
||||
id: "github",
|
||||
urlGrammar: {
|
||||
treeInfix: "/tree/",
|
||||
blobInfix: "/blob/",
|
||||
lineAnchor: GITHUB_LINE_ANCHOR,
|
||||
},
|
||||
facts: defineForgeFacts({
|
||||
family: "github",
|
||||
schema: GithubMergeFactsSchema,
|
||||
deriveMergeCapability: deriveGithubMergeCapability,
|
||||
}),
|
||||
} satisfies ClientForgeLogicModule<GithubMergeFacts>;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import type { ClientForgeViewModule } from "@/git/client-forge-module";
|
||||
|
||||
export const githubForgeView = {
|
||||
id: "github",
|
||||
icon: GitHubIcon,
|
||||
brandColor: null,
|
||||
} satisfies ClientForgeViewModule;
|
||||
@@ -0,0 +1,155 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defineForgeFacts,
|
||||
type ClientForgeLogicModule,
|
||||
type MergeCapability,
|
||||
} from "@/git/client-forge-module";
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import type { CheckStatus } from "@/git/pull-request-panel/check-status";
|
||||
|
||||
const gitlabLineAnchor = (start: number, end?: number): string =>
|
||||
end && end > start ? `#L${start}-${end}` : `#L${start}`;
|
||||
|
||||
/**
|
||||
* Canonical set of GitLab pipeline statuses that count as "still active" (a
|
||||
* pipeline that has not reached a terminal state). Server-side twin:
|
||||
* packages/server/src/services/gitlab-facts.ts (GITLAB_ACTIVE_PIPELINE_STATUSES),
|
||||
* duplicated because the app can't depend on the server package.
|
||||
*/
|
||||
const GITLAB_ACTIVE_PIPELINE_STATUSES = [
|
||||
"created",
|
||||
"waiting_for_resource",
|
||||
"preparing",
|
||||
"pending",
|
||||
"running",
|
||||
"scheduled",
|
||||
] as const;
|
||||
|
||||
const GITLAB_ACTIVE_PIPELINE_STATUS_SET = new Set<string>(GITLAB_ACTIVE_PIPELINE_STATUSES);
|
||||
|
||||
export function isPipelineActiveStatus(status: string): boolean {
|
||||
return GITLAB_ACTIVE_PIPELINE_STATUS_SET.has(status);
|
||||
}
|
||||
|
||||
export function mapPipelineStatus(status: string): CheckStatus {
|
||||
switch (status) {
|
||||
case "success":
|
||||
case "passed":
|
||||
return "success";
|
||||
case "failed":
|
||||
return "failure";
|
||||
case "running":
|
||||
case "pending":
|
||||
case "created":
|
||||
case "waiting_for_resource":
|
||||
case "preparing":
|
||||
case "scheduled":
|
||||
case "manual":
|
||||
return "pending";
|
||||
case "canceled":
|
||||
case "cancelled":
|
||||
case "skipped":
|
||||
return "skipped";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
const GitlabMergeFactsSchema = z
|
||||
.object({
|
||||
forge: z.literal("gitlab"),
|
||||
detailedMergeStatus: z.string().nullable().optional().default(null),
|
||||
mergeStatus: z.string().nullable().optional().default(null),
|
||||
hasConflicts: z.boolean().optional().default(false),
|
||||
blockingDiscussionsResolved: z.boolean().optional().default(true),
|
||||
approvalsRequired: z.number().optional().default(0),
|
||||
approvalsGiven: z.number().optional().default(0),
|
||||
pipelineStatus: z.string().nullable().optional().default(null),
|
||||
pipelineId: z.number().nullable().optional().default(null),
|
||||
pipelineUrl: z.string().nullable().optional().default(null),
|
||||
mergeWhenPipelineSucceeds: z.boolean().optional().default(false),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type GitlabMergeFacts = z.infer<typeof GitlabMergeFactsSchema>;
|
||||
|
||||
export { GitlabMergeFactsSchema };
|
||||
|
||||
const GITLAB_MERGEABLE_STATUS = "mergeable";
|
||||
const GITLAB_LEGACY_MERGEABLE_STATUS = "can_be_merged";
|
||||
const GITLAB_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"];
|
||||
|
||||
/**
|
||||
* Direct-merge readiness from GitLab's merge signals. `detailedMergeStatus` is
|
||||
* GitLab 15.6+ only; when it is absent (older self-managed instances) fall back
|
||||
* to the legacy `mergeStatus === "can_be_merged"` with no conflicts so merge is
|
||||
* not silently refused everywhere.
|
||||
*/
|
||||
function isGitlabDirectMergeReady(gitlab: GitlabMergeFacts): boolean {
|
||||
if (gitlab.detailedMergeStatus != null) {
|
||||
return gitlab.detailedMergeStatus === GITLAB_MERGEABLE_STATUS;
|
||||
}
|
||||
return gitlab.mergeStatus === GITLAB_LEGACY_MERGEABLE_STATUS && gitlab.hasConflicts !== true;
|
||||
}
|
||||
|
||||
function deriveGitlabMergeCapability(gitlab: GitlabMergeFacts): MergeCapability {
|
||||
const autoMergeEnabled = gitlab.mergeWhenPipelineSucceeds === true;
|
||||
const hasActivePipeline =
|
||||
gitlab.pipelineStatus !== null && isPipelineActiveStatus(gitlab.pipelineStatus);
|
||||
return {
|
||||
directMergeReady: isGitlabDirectMergeReady(gitlab),
|
||||
canEnableAutoMerge: !autoMergeEnabled && hasActivePipeline,
|
||||
autoMergeEnabled,
|
||||
canDisableAutoMerge: autoMergeEnabled,
|
||||
mergeBlockedByQueue: false,
|
||||
allowedMethods: GITLAB_MERGE_METHODS,
|
||||
preferredMethod: null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GitlabPipelineSummary {
|
||||
id: number;
|
||||
status: CheckStatus;
|
||||
rawStatus: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface GitlabApprovals {
|
||||
given: number;
|
||||
required: number;
|
||||
}
|
||||
|
||||
export function deriveGitlabPipelineSummary(facts: GitlabMergeFacts): GitlabPipelineSummary | null {
|
||||
if (facts.pipelineId == null) {
|
||||
return null;
|
||||
}
|
||||
const rawStatus = facts.pipelineStatus ?? "";
|
||||
return {
|
||||
id: facts.pipelineId,
|
||||
status: mapPipelineStatus(rawStatus),
|
||||
rawStatus,
|
||||
url: facts.pipelineUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveGitlabApprovals(facts: GitlabMergeFacts): GitlabApprovals | null {
|
||||
const required = facts.approvalsRequired ?? 0;
|
||||
if (required <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { given: facts.approvalsGiven ?? 0, required };
|
||||
}
|
||||
|
||||
export const gitlabForgeLogic = {
|
||||
id: "gitlab",
|
||||
urlGrammar: {
|
||||
treeInfix: "/-/tree/",
|
||||
blobInfix: "/-/blob/",
|
||||
lineAnchor: gitlabLineAnchor,
|
||||
},
|
||||
facts: defineForgeFacts({
|
||||
family: "gitlab",
|
||||
schema: GitlabMergeFactsSchema,
|
||||
deriveMergeCapability: deriveGitlabMergeCapability,
|
||||
}),
|
||||
} satisfies ClientForgeLogicModule<GitlabMergeFacts>;
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { CircleCheck, ExternalLink } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CheckoutPipelineJob, CheckoutPipelineStage } from "@getpaseo/protocol/messages";
|
||||
import { GitLabIcon } from "@/components/icons/gitlab-icon";
|
||||
import {
|
||||
definePaneContribution,
|
||||
type ClientForgeViewModule,
|
||||
type PaneChecksSlotContext,
|
||||
} from "@/git/client-forge-module";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { formatDuration } from "@/utils/time";
|
||||
import {
|
||||
CheckStatusIcon,
|
||||
Section,
|
||||
SUMMARY_DANGER_ICON,
|
||||
SUMMARY_SUCCESS_ICON,
|
||||
SUMMARY_WARNING_ICON,
|
||||
SummaryPill,
|
||||
foregroundMutedColorMapping,
|
||||
sectionKitStyles,
|
||||
successColorMapping,
|
||||
} from "@/git/pull-request-panel/section-kit";
|
||||
import { useGitLabPipeline } from "@/git/pull-request-panel/use-pipeline";
|
||||
import {
|
||||
deriveGitlabApprovals,
|
||||
deriveGitlabPipelineSummary,
|
||||
GitlabMergeFactsSchema,
|
||||
isPipelineActiveStatus,
|
||||
mapPipelineStatus,
|
||||
type GitlabApprovals,
|
||||
type GitlabMergeFacts,
|
||||
type GitlabPipelineSummary,
|
||||
} from "./gitlab";
|
||||
|
||||
function renderGitlabHeaderMeta(facts: GitlabMergeFacts): ReactNode {
|
||||
const approvals = deriveGitlabApprovals(facts);
|
||||
if (!approvals) {
|
||||
return null;
|
||||
}
|
||||
return <GitlabApprovalsBadge approvals={approvals} />;
|
||||
}
|
||||
|
||||
function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotContext): ReactNode {
|
||||
if (!ctx.enabled) {
|
||||
return null;
|
||||
}
|
||||
const summary = deriveGitlabPipelineSummary(facts);
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<GitLabPipelineSection
|
||||
serverId={ctx.serverId}
|
||||
cwd={ctx.cwd}
|
||||
changeRequestNumber={ctx.changeRequestNumber}
|
||||
summary={summary}
|
||||
open={ctx.open}
|
||||
onToggle={ctx.onToggle}
|
||||
canFetchCheckDetails={ctx.canFetchCheckDetails}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
|
||||
function GitlabApprovalsBadge({ approvals }: { approvals: GitlabApprovals }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.approvalsBadge} testID="pr-pane-approvals">
|
||||
<ThemedCircleCheck
|
||||
size={11}
|
||||
uniProps={
|
||||
approvals.given >= approvals.required ? successColorMapping : foregroundMutedColorMapping
|
||||
}
|
||||
/>
|
||||
<Text style={styles.approvalsText}>
|
||||
{t("workspace.git.pr.approvals", { given: approvals.given, required: approvals.required })}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
|
||||
return [sectionKitStyles.checkRow, Boolean(hovered) && styles.hoverable];
|
||||
}
|
||||
|
||||
function jobRowPressableStyle({ hovered }: { hovered?: boolean }) {
|
||||
return [styles.pipelineJobRow, Boolean(hovered) && styles.hoverable];
|
||||
}
|
||||
|
||||
function formatPipelineDuration(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) {
|
||||
return "";
|
||||
}
|
||||
return formatDuration(seconds * 1000);
|
||||
}
|
||||
|
||||
interface PipelineJobCounts {
|
||||
passed: number;
|
||||
failed: number;
|
||||
pending: number;
|
||||
}
|
||||
|
||||
function countPipelineJobs(jobs: CheckoutPipelineJob[]): PipelineJobCounts {
|
||||
const counts: PipelineJobCounts = { passed: 0, failed: 0, pending: 0 };
|
||||
for (const job of jobs) {
|
||||
const status = mapPipelineStatus(job.status);
|
||||
if (status === "success") counts.passed += 1;
|
||||
else if (status === "failure") counts.failed += 1;
|
||||
else if (status === "pending") counts.pending += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function GitLabPipelineSection({
|
||||
serverId,
|
||||
cwd,
|
||||
changeRequestNumber,
|
||||
summary,
|
||||
open,
|
||||
onToggle,
|
||||
canFetchCheckDetails,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
changeRequestNumber: number;
|
||||
summary: GitlabPipelineSummary;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
canFetchCheckDetails: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { pipeline, isLoading, isPlaceholderData, error } = useGitLabPipeline({
|
||||
serverId,
|
||||
cwd,
|
||||
pipelineId: summary.id,
|
||||
changeRequestNumber,
|
||||
enabled: open && canFetchCheckDetails,
|
||||
live: isPipelineActiveStatus(summary.rawStatus),
|
||||
});
|
||||
|
||||
const counts = useMemo(
|
||||
() => countPipelineJobs((pipeline?.stages ?? []).flatMap((stage) => stage.jobs)),
|
||||
[pipeline],
|
||||
);
|
||||
|
||||
const totalCounted = counts.passed + counts.failed + counts.pending;
|
||||
const showBreakdown = !isPlaceholderData && totalCounted > 0;
|
||||
const displayCounts = showBreakdown ? counts : { passed: 0, failed: 0, pending: 0 };
|
||||
|
||||
const handleOpenPipeline = useCallback(() => {
|
||||
if (summary.url) {
|
||||
void openExternalUrl(summary.url);
|
||||
}
|
||||
}, [summary.url]);
|
||||
|
||||
const sectionSummary = (
|
||||
<>
|
||||
<SummaryPill
|
||||
count={displayCounts.passed}
|
||||
icon={SUMMARY_SUCCESS_ICON}
|
||||
variant="success"
|
||||
testID="pr-pane-pipeline-passed"
|
||||
/>
|
||||
<SummaryPill
|
||||
count={displayCounts.failed}
|
||||
icon={SUMMARY_DANGER_ICON}
|
||||
variant="danger"
|
||||
testID="pr-pane-pipeline-failed"
|
||||
/>
|
||||
<SummaryPill
|
||||
count={displayCounts.pending}
|
||||
icon={SUMMARY_WARNING_ICON}
|
||||
variant="warning"
|
||||
testID="pr-pane-pipeline-pending"
|
||||
/>
|
||||
{showBreakdown ? null : <CheckStatusIcon status={summary.status} />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t("workspace.git.pr.sections.pipeline")}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
summary={sectionSummary}
|
||||
>
|
||||
<Pressable
|
||||
onPress={handleOpenPipeline}
|
||||
style={rowPressableStyle}
|
||||
disabled={!summary.url}
|
||||
testID="pr-pane-pipeline-link"
|
||||
>
|
||||
<CheckStatusIcon status={summary.status} />
|
||||
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
|
||||
{`Pipeline #${summary.id}`}
|
||||
</Text>
|
||||
{summary.rawStatus ? (
|
||||
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
|
||||
{summary.rawStatus}
|
||||
</Text>
|
||||
) : null}
|
||||
{summary.url ? (
|
||||
<View style={sectionKitStyles.checkTrailing}>
|
||||
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{isLoading ? (
|
||||
<Text style={sectionKitStyles.emptyText}>
|
||||
{t("workspace.git.pr.empty.loadingPipeline")}
|
||||
</Text>
|
||||
) : null}
|
||||
{!isLoading && pipeline && pipeline.stages.length === 0 ? (
|
||||
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noJobs")}</Text>
|
||||
) : null}
|
||||
{!isLoading && pipeline && pipeline.stages.length > 0
|
||||
? pipeline.stages.map((stage) => <PipelineStageGroup key={stage.name} stage={stage} />)
|
||||
: null}
|
||||
{!isLoading && !pipeline && error ? (
|
||||
<Text style={sectionKitStyles.emptyText}>
|
||||
{t("workspace.git.pr.empty.pipelineJobsLoadFailed")}
|
||||
</Text>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineStageGroup({ stage }: { stage: CheckoutPipelineStage }) {
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.pipelineStageHeader}>
|
||||
<CheckStatusIcon status={mapPipelineStatus(stage.status)} />
|
||||
<Text style={styles.pipelineStageName} numberOfLines={1}>
|
||||
{stage.name}
|
||||
</Text>
|
||||
</View>
|
||||
{stage.jobs.map((job) => (
|
||||
<PipelineJobRow key={job.id} job={job} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineJobRow({ job }: { job: CheckoutPipelineJob }) {
|
||||
const { t } = useTranslation();
|
||||
const handlePress = useCallback(() => {
|
||||
if (job.url) {
|
||||
void openExternalUrl(job.url);
|
||||
}
|
||||
}, [job.url]);
|
||||
const duration = formatPipelineDuration(job.durationSeconds);
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={jobRowPressableStyle} disabled={!job.url}>
|
||||
<CheckStatusIcon status={mapPipelineStatus(job.status)} />
|
||||
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
|
||||
{job.name}
|
||||
</Text>
|
||||
{job.allowFailure ? (
|
||||
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
|
||||
{t("workspace.git.pr.empty.allowedToFail")}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={sectionKitStyles.checkTrailing}>
|
||||
{duration ? <Text style={sectionKitStyles.checkDuration}>{duration}</Text> : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export const gitlabForgeView = {
|
||||
id: "gitlab",
|
||||
icon: GitLabIcon,
|
||||
brandColor: {
|
||||
light: "#FC6D26",
|
||||
dark: "#FC6D26",
|
||||
},
|
||||
paneContributions: [
|
||||
definePaneContribution(GitlabMergeFactsSchema, {
|
||||
renderHeaderMeta: renderGitlabHeaderMeta,
|
||||
renderChecksSection: renderGitlabChecksSection,
|
||||
}),
|
||||
],
|
||||
} satisfies ClientForgeViewModule;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
approvalsBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
approvalsText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
hoverable: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
pipelineStageHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
pipelineStageName: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
flexShrink: 1,
|
||||
},
|
||||
pipelineJobRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingLeft: theme.spacing[6],
|
||||
minHeight: 32,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLIENT_FORGE_LOGIC_MODULES } from "./index";
|
||||
import { CLIENT_FORGE_VIEW_MODULES } from "./view";
|
||||
|
||||
/**
|
||||
* Adding a forge is "create forges/<id>.ts (logic) + forges/<id>.view.tsx (view)
|
||||
* + register one line in each registry". This test removes the only failure mode
|
||||
* of the explicit registries — forgetting a line — without resorting to
|
||||
* bundler-specific auto-discovery that Metro, Vite, and tsgo would each need to
|
||||
* understand differently. The logic/view split keeps logic consumers (URL
|
||||
* builders, merge-capability, native checks, and the Node e2e harness) free of
|
||||
* the client rendering stack.
|
||||
*/
|
||||
const forgesDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
const dirEntries = readdirSync(forgesDir);
|
||||
|
||||
const logicModuleIds = dirEntries
|
||||
.filter((file) => /^[a-z0-9-]+\.ts$/.test(file) && file !== "index.ts" && file !== "view.ts")
|
||||
.map((file) => file.replace(/\.ts$/, ""))
|
||||
.sort();
|
||||
|
||||
const viewModuleIds = dirEntries
|
||||
.filter((file) => /^[a-z0-9-]+\.view\.tsx$/.test(file))
|
||||
.map((file) => file.replace(/\.view\.tsx$/, ""))
|
||||
.sort();
|
||||
|
||||
describe("CLIENT_FORGE_LOGIC_MODULES completeness", () => {
|
||||
it("registers every forge logic module file in the directory", () => {
|
||||
const registeredIds = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id).sort();
|
||||
expect(registeredIds).toEqual(logicModuleIds);
|
||||
});
|
||||
|
||||
it("has no duplicate forge ids", () => {
|
||||
const ids = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLIENT_FORGE_VIEW_MODULES completeness", () => {
|
||||
it("registers every forge view module file in the directory", () => {
|
||||
const registeredIds = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id).sort();
|
||||
expect(registeredIds).toEqual(viewModuleIds);
|
||||
});
|
||||
|
||||
it("has no duplicate forge ids", () => {
|
||||
const ids = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forge logic and view registries stay paired", () => {
|
||||
it("has a view module for every logic module and vice versa", () => {
|
||||
const logicIds = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id).sort();
|
||||
const viewIds = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id).sort();
|
||||
expect(logicIds).toEqual(viewIds);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ClientForgeLogicModule, ForgeSpecificEnvelope } from "@/git/client-forge-module";
|
||||
import { codebergForgeLogic } from "./codeberg";
|
||||
import { forgejoForgeLogic } from "./forgejo";
|
||||
import { giteaForgeLogic } from "./gitea";
|
||||
import { githubForgeLogic } from "./github";
|
||||
import { gitlabForgeLogic } from "./gitlab";
|
||||
|
||||
/**
|
||||
* Pure logic registry. Import this (never the view registry) from URL builders,
|
||||
* merge-capability, and native-check derivations so those paths — and the
|
||||
* Node-based e2e harness that transitively imports them — stay free of the
|
||||
* client rendering stack (react-native, react-native-svg, unistyles).
|
||||
*/
|
||||
export const CLIENT_FORGE_LOGIC_MODULES: readonly ClientForgeLogicModule[] = [
|
||||
githubForgeLogic,
|
||||
gitlabForgeLogic,
|
||||
giteaForgeLogic,
|
||||
forgejoForgeLogic,
|
||||
codebergForgeLogic,
|
||||
];
|
||||
|
||||
export function getClientForgeLogicModule(id: string): ClientForgeLogicModule | null {
|
||||
return CLIENT_FORGE_LOGIC_MODULES.find((module) => module.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function parseClientForgeFacts(facts: unknown): ForgeSpecificEnvelope | null {
|
||||
for (const module of CLIENT_FORGE_LOGIC_MODULES) {
|
||||
const parsed = module.facts?.parse(facts);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ClientForgeViewModule } from "@/git/client-forge-module";
|
||||
import { codebergForgeView } from "./codeberg.view";
|
||||
import { forgejoForgeView } from "./forgejo.view";
|
||||
import { giteaForgeView } from "./gitea.view";
|
||||
import { githubForgeView } from "./github.view";
|
||||
import { gitlabForgeView } from "./gitlab.view";
|
||||
|
||||
/**
|
||||
* View registry: brand marks, brand colors, and PR-pane render contributions.
|
||||
* Import this only from rendering code (icon lookup, the PR pane); it pulls the
|
||||
* client rendering stack, so logic paths must use the logic registry instead.
|
||||
*/
|
||||
export const CLIENT_FORGE_VIEW_MODULES: readonly ClientForgeViewModule[] = [
|
||||
githubForgeView,
|
||||
gitlabForgeView,
|
||||
giteaForgeView,
|
||||
forgejoForgeView,
|
||||
codebergForgeView,
|
||||
];
|
||||
|
||||
export function getClientForgeViewModule(id: string): ClientForgeViewModule | null {
|
||||
return CLIENT_FORGE_VIEW_MODULES.find((module) => module.id === id) ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user