mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
* feat(protocol): add forge check presentation metadata Expose optional rawStatus, isManual, and requiresAction fields while keeping older check payloads backward-compatible. * fix(gitea): model Actions jobs as tasks Gitea's /actions/tasks endpoint returns individual jobs rather than workflow runs. Preserve sibling tasks from the latest execution, avoid collapsing same-name jobs, and enrich blocked tasks with run-level approval metadata. * feat(app): distinguish forge check presentation states * fix(gitlab): preserve pipeline job semantics * refactor(app): centralize forge check presentation policy Use one canonical category order, count shape, visual policy, and translated copy mapping across sidebar, hover-card, PR-pane, and GitLab pipeline surfaces. * refactor: consolidate forge check handling * feat(forge): auto-attach pasted forge references * docs(compat): correct forge rollout metadata * refactor(forge): model check nuances as traits * fix(forge): harden checks from branch review Structural Gitea Actions shadow dedup by run id (verified upstream), rerun grouping without workflow_id, shared trait/status constants and Gitea state mapping in the protocol package, all cloud host aliases in pasted-link matching, logged glab job-detail failures, concurrent approvals/pipeline fetch, checks aggregate from the same pipeline as the job list, and removal of the unread check-level rawStatus. The released pipeline-level rawStatus is staged for removal under COMPAT(pipelineRawStatus). * fix(gitea): detect fork-PR Actions approval hold as action_required gitea.com represents a fork PR awaiting Actions approval as a run in status "waiting" with no tasks and pending shadow commit statuses; it does not expose need_approval. The previous mapping only set action_required from a task with blocked+need_approval, so the gate never surfaced (0 tasks -> early return; need_approval absent). Detect the hold from /actions/runs: a waiting/blocked run whose PR is cross-repo (head.repo.id != base.repo.id), or one that does expose need_approval (self-hosted). Tag the run's pending shadow statuses action_required. Runs are only fetched when a pending shadow has no covering task, keeping the common path at two requests. Verified live against gitea.com fork PR (run 715170, waiting, cross-repo). * fix(gitea): resolve cross-fork PRs by head sha The current-PR matcher gated candidates on the checkout owner (giteaHeadOwner === expectedHeadOwner), which excludes every fork PR: its head carries the fork owner while the checkout is the base repo, so a fork PR - the only shape that can be approval-held on Gitea - never resolved and its checks never showed in-app. GitLab has no such gate (it resolves fork MRs from the target project by source branch). Accept a recent PR whose head sha equals the workspace head sha regardless of owner; the sha is an unambiguous identity, so the anti-collision owner gate still applies when no sha match is available. Verified live against gitea.com fork PR #6 (resolves + renders action_required). * fix: resolve forge rebase drift * fix: type forge auto-attach boundary * fix(i18n): add Korean check status copy --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
312 lines
9.2 KiB
TypeScript
312 lines
9.2 KiB
TypeScript
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 { COUNTED_CHECK_PRESENTATIONS } from "@/git/check-presentation";
|
|
import { CheckPresentationIcon } from "@/git/check-presentation.view";
|
|
import {
|
|
CheckPresentationSummaryPill,
|
|
Section,
|
|
foregroundMutedColorMapping,
|
|
sectionKitStyles,
|
|
successColorMapping,
|
|
} from "@/git/pull-request-panel/section-kit";
|
|
import { useGitLabPipeline } from "@/git/pull-request-panel/use-pipeline";
|
|
import {
|
|
classifyGitlabPipelineJob,
|
|
deriveGitlabApprovals,
|
|
deriveGitlabPipelineSummary,
|
|
countGitlabPipelineJobs,
|
|
GitlabMergeFactsSchema,
|
|
isPipelineActiveStatus,
|
|
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);
|
|
}
|
|
|
|
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(
|
|
() => countGitlabPipelineJobs((pipeline?.stages ?? []).flatMap((stage) => stage.jobs)),
|
|
[pipeline],
|
|
);
|
|
|
|
const totalCounted = Object.values(counts).reduce((total, count) => total + count, 0);
|
|
const showBreakdown = !isPlaceholderData && totalCounted > 0;
|
|
const displayCounts = showBreakdown ? counts : countGitlabPipelineJobs([]);
|
|
|
|
const handleOpenPipeline = useCallback(() => {
|
|
if (summary.url) {
|
|
void openExternalUrl(summary.url);
|
|
}
|
|
}, [summary.url]);
|
|
|
|
const sectionSummary = (
|
|
<>
|
|
{COUNTED_CHECK_PRESENTATIONS.map((presentation) => (
|
|
<CheckPresentationSummaryPill
|
|
key={presentation}
|
|
count={displayCounts[presentation]}
|
|
presentation={presentation}
|
|
testID={`pr-pane-pipeline-${presentation}`}
|
|
/>
|
|
))}
|
|
{showBreakdown ? null : <GitLabPipelineStatusIcon status={summary.rawStatus} />}
|
|
</>
|
|
);
|
|
|
|
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"
|
|
>
|
|
<GitLabPipelineStatusIcon status={summary.rawStatus} />
|
|
<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}>
|
|
<GitLabPipelineStatusIcon status={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}>
|
|
<GitLabPipelineStatusIcon status={job.status} allowFailure={job.allowFailure} />
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function GitLabPipelineStatusIcon({
|
|
status,
|
|
allowFailure = false,
|
|
}: {
|
|
status: string;
|
|
allowFailure?: boolean;
|
|
}) {
|
|
const presentation = classifyGitlabPipelineJob({ status, allowFailure });
|
|
return <CheckPresentationIcon presentation={presentation} size={14} />;
|
|
}
|
|
|
|
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.sm,
|
|
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.sm,
|
|
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,
|
|
},
|
|
}));
|