Add list mode so Pull Requests hosts without a selected PR display active project pull requests. - Fetch and render the active pull request list for no-id PullRequestView mounts. - Allow selecting a PR for detail view and returning to the active PR list. - Add list loading, empty, error, styling, i18n, regression tests, and a patch changeset. Files changed: .changeset/fn-7179-pr-sidebar-list.md | 7 + .../app/__tests__/pull-request-view.test.tsx | 122 ++++++++++++++- .../dashboard/app/components/PullRequestView.css | 96 ++++++++++++ .../dashboard/app/components/PullRequestView.tsx | 163 ++++++++++++++++++--- packages/i18n/locales/en/app.json | 6 + 5 files changed, 370 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-7179 Fusion-Task-Lineage: 712dccae-eb03-4aea-b89e-0a333db7c3fc Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
563 lines
22 KiB
TypeScript
563 lines
22 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
GitPullRequest,
|
|
GitMerge,
|
|
CheckCircle,
|
|
XCircle,
|
|
Clock,
|
|
AlertTriangle,
|
|
ExternalLink,
|
|
RotateCcw,
|
|
ThumbsUp,
|
|
MessageSquare,
|
|
} from "lucide-react";
|
|
import { api } from "../api";
|
|
import { ViewHeader } from "./ViewHeader";
|
|
import "./PullRequestView.css";
|
|
|
|
// Mirrors the route's serialized entity (register-pull-requests-routes.ts).
|
|
export type PrThread = {
|
|
prEntityId: string;
|
|
threadId: string;
|
|
headOid: string;
|
|
outcome: "fixed" | "disagreed" | "pending";
|
|
fixCommitSha?: string;
|
|
updatedAt: number;
|
|
};
|
|
|
|
export type PrSummary = {
|
|
mergeable: string;
|
|
reviewDecision: string | null;
|
|
checksRollup: string;
|
|
conflicting: boolean;
|
|
autoMerge: boolean;
|
|
autoMergeReason: string;
|
|
autoMergeReady: boolean;
|
|
actionable: boolean;
|
|
active: boolean;
|
|
pendingThreads: number;
|
|
disagreedThreads: number;
|
|
};
|
|
|
|
export type PrDetail = {
|
|
id: string;
|
|
sourceType: "task" | "branch-group";
|
|
sourceId: string;
|
|
repo: string;
|
|
headBranch: string;
|
|
baseBranch?: string;
|
|
state: "creating" | "open" | "responding" | "merged" | "closed" | "failed";
|
|
prNumber?: number;
|
|
prUrl?: string;
|
|
mergeable?: string;
|
|
checksRollup?: string;
|
|
reviewDecision?: string | null;
|
|
autoMerge: boolean;
|
|
unverified: boolean;
|
|
failureReason?: string;
|
|
responseRounds: number;
|
|
threads: PrThread[];
|
|
summary: PrSummary;
|
|
};
|
|
|
|
type ActionKind = "approve" | "merge" | "retry" | "close" | "automerge" | "retry-create";
|
|
|
|
export interface PullRequestViewProps {
|
|
/** When provided, render this detail directly (tests / parent-supplied data). */
|
|
detail?: PrDetail | null;
|
|
/** Entity id to self-fetch when `detail` is not provided. */
|
|
pullRequestId?: string;
|
|
projectId?: string;
|
|
/** Override the action dispatcher (tests). Defaults to the POST routes. */
|
|
onAction?: (kind: ActionKind, id: string, body?: Record<string, unknown>) => Promise<PrDetail>;
|
|
/** Override the detail fetcher (tests). */
|
|
loadPullRequest?: (id: string) => Promise<PrDetail>;
|
|
/** Override the list fetcher (tests). */
|
|
loadPullRequests?: () => Promise<PrDetail[]>;
|
|
}
|
|
|
|
function defaultLoad(projectId?: string) {
|
|
return async (id: string): Promise<PrDetail> => {
|
|
const q = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
|
const res = await api<{ pullRequest: PrDetail }>(`/pull-requests/${id}${q}`);
|
|
return res.pullRequest;
|
|
};
|
|
}
|
|
|
|
function defaultLoadList(projectId?: string) {
|
|
return async (): Promise<PrDetail[]> => {
|
|
const q = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
|
const res = await api<{ pullRequests: PrDetail[] }>(`/pull-requests${q}`);
|
|
return res.pullRequests;
|
|
};
|
|
}
|
|
|
|
function defaultAction(projectId?: string) {
|
|
return async (kind: ActionKind, id: string, body?: Record<string, unknown>): Promise<PrDetail> => {
|
|
const path = kind === "automerge" ? "automerge" : kind;
|
|
const res = await api<{ pullRequest: PrDetail }>(`/pull-requests/${id}/${path}`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ ...(body ?? {}), ...(projectId ? { projectId } : {}) }),
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
return res.pullRequest;
|
|
};
|
|
}
|
|
|
|
function ChecksIcon({ rollup }: { rollup: string }) {
|
|
if (rollup === "success") return <CheckCircle size={14} className="pr-icon-success" />;
|
|
if (rollup === "failure") return <XCircle size={14} className="pr-icon-failure" />;
|
|
if (rollup === "pending") return <Clock size={14} className="pr-icon-pending" />;
|
|
return <span className="pr-icon-none">—</span>;
|
|
}
|
|
|
|
const PR_LOAD_TIMEOUT_MS = 15000;
|
|
|
|
async function withPrTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
|
|
const timeout = new Promise<T>((_, reject) =>
|
|
setTimeout(() => reject(new Error(message)), PR_LOAD_TIMEOUT_MS),
|
|
);
|
|
return Promise.race([promise, timeout]);
|
|
}
|
|
|
|
export function PullRequestView(props: PullRequestViewProps) {
|
|
const { t } = useTranslation("app");
|
|
const { detail: detailProp, pullRequestId, projectId, onAction, loadPullRequest, loadPullRequests } = props;
|
|
// FNXC:PullRequests 2026-06-27-22:59: Optional host props may forward `detail={undefined}` while no PR is selected. Treat only concrete detail values (including explicit null) as controlled detail mode so undefined still follows the no-id list invariant.
|
|
const hasDetailProp = detailProp !== undefined;
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [detail, setDetail] = useState<PrDetail | null>(detailProp ?? null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [pullRequests, setPullRequests] = useState<PrDetail[]>([]);
|
|
const [listError, setListError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState<ActionKind | null>(null);
|
|
const [confirmingMerge, setConfirmingMerge] = useState(false);
|
|
// FNXC:PullRequests 2026-06-27-00:00: `loading` and `listLoading` are true ONLY while a fetch is in flight. No-id now enters bounded list mode so the sidebar shows active project PRs; explicit empty/detail states still never hang on an endless spinner.
|
|
const [loading, setLoading] = useState(false);
|
|
const [listLoading, setListLoading] = useState(false);
|
|
|
|
const load = useMemo(() => loadPullRequest ?? defaultLoad(projectId), [loadPullRequest, projectId]);
|
|
const loadList = useMemo(() => loadPullRequests ?? defaultLoadList(projectId), [loadPullRequests, projectId]);
|
|
const dispatch = useMemo(() => onAction ?? defaultAction(projectId), [onAction, projectId]);
|
|
const activePullRequestId = pullRequestId ?? selectedId ?? undefined;
|
|
const isListMode = !hasDetailProp && !pullRequestId && !selectedId;
|
|
const canReturnToList = !hasDetailProp && !pullRequestId && Boolean(selectedId);
|
|
|
|
const refresh = useCallback(async () => {
|
|
if (hasDetailProp) {
|
|
setDetail(detailProp ?? null);
|
|
setError(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (!activePullRequestId) {
|
|
/*
|
|
FNXC:PullRequests 2026-06-27-00:00:
|
|
The right-dock Pull Requests tab and main-content pull-requests view mount this component with no PR id. No-id must mean project PR list mode, not the old empty detail state, so active PRs remain visible and selectable from every host.
|
|
*/
|
|
try {
|
|
setError(null);
|
|
setListError(null);
|
|
setListLoading(true);
|
|
setDetail(null);
|
|
setPullRequests(await withPrTimeout(loadList(), "Timed out loading pull requests"));
|
|
} catch (err) {
|
|
setPullRequests([]);
|
|
setListError(err instanceof Error ? err.message : t("pr.view.listError", "Failed to load pull requests"));
|
|
} finally {
|
|
setListLoading(false);
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
setError(null);
|
|
setLoading(true);
|
|
// Time-bound the fetch (15s) so a hung request resolves into an error state.
|
|
setDetail(await withPrTimeout(load(activePullRequestId), "Timed out loading pull request"));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to load PR");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [activePullRequestId, detailProp, hasDetailProp, load, loadList, t]);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, [refresh]);
|
|
|
|
// Live updates: re-poll on the store-event / SSE channel the rest of the app
|
|
// uses. We listen for the lightweight "store-changed" window event the SSE
|
|
// bridge dispatches; each tick re-reads authoritative state from the route.
|
|
useEffect(() => {
|
|
if (hasDetailProp) return;
|
|
const handler = () => void refresh();
|
|
window.addEventListener("fusion:store-changed", handler);
|
|
return () => window.removeEventListener("fusion:store-changed", handler);
|
|
}, [hasDetailProp, refresh]);
|
|
|
|
const runAction = useCallback(
|
|
async (kind: ActionKind, body?: Record<string, unknown>) => {
|
|
if (!detail) return;
|
|
try {
|
|
setBusy(kind);
|
|
setError(null);
|
|
const fresh = await dispatch(kind, detail.id, body);
|
|
setDetail(fresh);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : `Action ${kind} failed`);
|
|
} finally {
|
|
setBusy(null);
|
|
setConfirmingMerge(false);
|
|
}
|
|
},
|
|
[detail, dispatch],
|
|
);
|
|
|
|
const viewHeader = <ViewHeader icon={GitPullRequest} title={t("pr.view.title", "Pull Requests")} />;
|
|
|
|
if (isListMode) {
|
|
if (listError) {
|
|
return (
|
|
<div className="pr-view pr-view--error" data-testid="pr-list-error">
|
|
{viewHeader}
|
|
<div className="pr-list-state pr-list-state--error">
|
|
<AlertTriangle size={16} /> {listError}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (listLoading) {
|
|
return (
|
|
<div className="pr-view pr-view--loading" data-testid="pr-list-loading">
|
|
{viewHeader}
|
|
<div className="pr-list-state">{t("pr.view.listLoading", "Loading pull requests…")}</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (pullRequests.length === 0) {
|
|
return (
|
|
<div className="pr-view pr-view--empty" data-testid="pr-list-empty">
|
|
{viewHeader}
|
|
<div className="pr-list-state">
|
|
<GitPullRequest size={16} /> {t("pr.view.listEmpty", "No active pull requests to show.")}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="pr-view" data-testid="pr-list">
|
|
{viewHeader}
|
|
<div className="pr-list-title">{t("pr.view.listTitle", "Active pull requests")}</div>
|
|
<div className="pr-list-items">
|
|
{pullRequests.map((pullRequest) => (
|
|
<button
|
|
key={pullRequest.id}
|
|
type="button"
|
|
className="pr-list-item"
|
|
data-testid="pr-list-item"
|
|
aria-label={t("pr.view.listItemLabel", "Open pull request {{repo}} {{number}}", {
|
|
repo: pullRequest.repo,
|
|
number: pullRequest.prNumber != null ? `#${pullRequest.prNumber}` : pullRequest.headBranch,
|
|
})}
|
|
onClick={() => {
|
|
setError(null);
|
|
setLoading(true);
|
|
setSelectedId(pullRequest.id);
|
|
}}
|
|
>
|
|
<span className="pr-list-item-main">
|
|
<span className="pr-list-item-repo">{pullRequest.repo}</span>
|
|
{pullRequest.prNumber != null && <span className="pr-list-item-number">#{pullRequest.prNumber}</span>}
|
|
<span className="pr-list-item-branch">{pullRequest.headBranch}</span>
|
|
</span>
|
|
<span className="pr-list-item-meta">
|
|
<span className={`pr-identity-state pr-identity-state--${pullRequest.state}`}>{pullRequest.state}</span>
|
|
<span className="pr-list-item-checks">
|
|
<ChecksIcon rollup={pullRequest.summary.checksRollup} /> {pullRequest.summary.checksRollup}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error && !detail) {
|
|
return (
|
|
<div className="pr-view pr-view--error" data-testid="pr-view-error">
|
|
<AlertTriangle size={16} /> {error}
|
|
</div>
|
|
);
|
|
}
|
|
if (!detail) {
|
|
// FNXC:PullRequests 2026-06-27-00:00: Only show the detail spinner while actually fetching an explicit PR; list mode owns no-id loading/empty/error so the view never hangs on an endless "Loading PR…".
|
|
if (loading) {
|
|
return (
|
|
<div className="pr-view pr-view--loading" data-testid="pr-view-loading">
|
|
{t("pr.view.loading", "Loading PR…")}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="pr-view pr-view--empty" data-testid="pr-view-empty">
|
|
<GitPullRequest size={16} /> {t("pr.view.empty", "No pull request to show.")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { state, summary } = detail;
|
|
|
|
/*
|
|
FNXC:PullRequests 2026-06-22-01:00:
|
|
Added the shared ViewHeader (GitPullRequest icon, matching the left-sidebar nav) at the top of every populated PR state so the view reads consistently with other main-content views. The PR-specific identity row (repo/number/branch/state) stays below it. ViewHeader supplies the standard --space-lg top/side padding; the view body must not repeat the top padding.
|
|
*/
|
|
const backToListControl = canReturnToList ? (
|
|
<button type="button" className="pr-back-to-list btn" data-testid="pr-back-to-list" onClick={() => setSelectedId(null)}>
|
|
{t("pr.view.backToList", "Back to list")}
|
|
</button>
|
|
) : null;
|
|
|
|
// ── creating ───────────────────────────────────────────────────────────────
|
|
if (state === "creating") {
|
|
return (
|
|
<div className="pr-view" data-testid="pr-view" data-state="creating">
|
|
{viewHeader}
|
|
{backToListControl}
|
|
<PrIdentityHeader detail={detail} />
|
|
<div className="pr-placeholder" data-testid="pr-creating">
|
|
<Clock size={16} /> {t("pr.view.creating", "Creating PR…")}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── failed ───────────────────────────────────────────────────────────────
|
|
if (state === "failed") {
|
|
return (
|
|
<div className="pr-view" data-testid="pr-view" data-state="failed">
|
|
{viewHeader}
|
|
{backToListControl}
|
|
<PrIdentityHeader detail={detail} />
|
|
<div className="pr-error-reason" data-testid="pr-failed">
|
|
<AlertTriangle size={16} className="pr-icon-failure" />
|
|
<span>{detail.failureReason ?? t("pr.view.creationFailed", "PR creation failed")}</span>
|
|
</div>
|
|
<div className="pr-action-bar">
|
|
<button
|
|
type="button"
|
|
className="pr-action pr-action--retry"
|
|
data-testid="pr-retry-create"
|
|
disabled={busy === "retry-create"}
|
|
onClick={() => void runAction("retry-create")}
|
|
>
|
|
<RotateCcw size={14} /> {t("pr.view.retryCreation", "Retry PR creation")}
|
|
</button>
|
|
</div>
|
|
{error && <div className="pr-inline-error">{error}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── unverified ─────────────────────────────────────────────────────────────
|
|
if (detail.unverified) {
|
|
return (
|
|
<div className="pr-view" data-testid="pr-view" data-state="unverified">
|
|
{viewHeader}
|
|
{backToListControl}
|
|
<PrIdentityHeader detail={detail} />
|
|
<div className="pr-notice pr-notice--unverified" data-testid="pr-unverified">
|
|
<Clock size={16} /> {t("pr.view.verifyingGithub", "Verifying with GitHub…")}
|
|
</div>
|
|
<div className="pr-action-bar">
|
|
<button
|
|
type="button"
|
|
className="pr-action"
|
|
data-testid="pr-merge"
|
|
disabled
|
|
title={t("pr.view.mergeDisabledUntilVerified", "Merge is disabled until GitHub verifies this PR")}
|
|
>
|
|
<GitMerge size={14} /> {t("pr.view.merge", "Merge")}
|
|
</button>
|
|
</div>
|
|
{/* checks/threads hidden while unverified */}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const conflicting = summary.conflicting;
|
|
|
|
return (
|
|
<div className="pr-view" data-testid="pr-view" data-state={state}>
|
|
{viewHeader}
|
|
{backToListControl}
|
|
<PrIdentityHeader detail={detail} />
|
|
|
|
{/* responding banner */}
|
|
{state === "responding" && (
|
|
<div className="pr-banner pr-banner--responding" data-testid="pr-responding">
|
|
<MessageSquare size={16} /> {t("pr.view.responsePending", "Response run in progress — {{count}} threads pending", { count: summary.pendingThreads })}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── action bar ──────────────────────────────────────────────────── */}
|
|
<div className="pr-action-bar" data-testid="pr-action-bar">
|
|
<button
|
|
type="button"
|
|
className="pr-action pr-action--approve"
|
|
data-testid="pr-approve"
|
|
disabled={state === "responding" || busy === "approve"}
|
|
onClick={() => void runAction("approve")}
|
|
>
|
|
<ThumbsUp size={14} /> {t("pr.view.approve", "Approve")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="pr-action pr-action--retry"
|
|
data-testid="pr-retry"
|
|
disabled={state === "responding" || busy === "retry"}
|
|
title={state === "responding" ? t("pr.view.responseAlreadyInProgress", "A response run is already in progress") : undefined}
|
|
onClick={() => void runAction("retry")}
|
|
>
|
|
<RotateCcw size={14} /> {t("pr.view.requestRetry", "Request retry")}
|
|
</button>
|
|
{!confirmingMerge ? (
|
|
<button
|
|
type="button"
|
|
className="pr-action pr-action--merge"
|
|
data-testid="pr-merge"
|
|
disabled={conflicting || state === "responding" || busy === "merge"}
|
|
title={conflicting ? t("pr.view.resolveConflictsBeforeMerge", "Resolve conflicts on GitHub before merging") : undefined}
|
|
onClick={() => setConfirmingMerge(true)}
|
|
>
|
|
<GitMerge size={14} /> {t("pr.view.merge", "Merge")}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="pr-action pr-action--merge-confirm"
|
|
data-testid="pr-merge-confirm"
|
|
disabled={busy === "merge"}
|
|
onClick={() => void runAction("merge")}
|
|
>
|
|
<GitMerge size={14} /> {t("pr.view.confirmMerge", "Confirm merge")}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="pr-action pr-action--close"
|
|
data-testid="pr-close"
|
|
disabled={busy === "close"}
|
|
onClick={() => void runAction("close")}
|
|
>
|
|
<XCircle size={14} /> {t("pr.view.close", "Close")}
|
|
</button>
|
|
|
|
<label className="pr-automerge-toggle" data-testid="pr-automerge">
|
|
<input
|
|
type="checkbox"
|
|
checked={detail.autoMerge}
|
|
disabled={busy === "automerge"}
|
|
onChange={(e) => void runAction("automerge", { enabled: e.target.checked })}
|
|
/>
|
|
<span>{t("pr.view.autoMerge", "Auto-merge")}</span>
|
|
<span className="pr-automerge-gate" data-testid="pr-automerge-gate">
|
|
{summary.autoMergeReason}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* conflict link */}
|
|
{conflicting && detail.prUrl && (
|
|
<a
|
|
className="pr-conflict-link"
|
|
data-testid="pr-conflict-link"
|
|
href={detail.prUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
{t("pr.view.resolveConflictsOnGithub", "Resolve conflicts on GitHub")} <ExternalLink size={12} />
|
|
</a>
|
|
)}
|
|
|
|
{/* ── merge-readiness summary ─────────────────────────────────────── */}
|
|
<div className="pr-summary" data-testid="pr-summary">
|
|
<span className="pr-summary-item" data-testid="pr-summary-mergeable">
|
|
{t("pr.view.mergeableLabel", "Mergeable:")} {summary.mergeable}
|
|
</span>
|
|
<span className="pr-summary-item" data-testid="pr-summary-review">
|
|
{t("pr.view.reviewLabel", "Review:")} {summary.reviewDecision ?? t("pr.view.none", "none")}
|
|
</span>
|
|
<span className="pr-summary-item" data-testid="pr-summary-checks">
|
|
<ChecksIcon rollup={summary.checksRollup} /> {summary.checksRollup}
|
|
</span>
|
|
</div>
|
|
|
|
{/* ── threads (agent replies nested) ───────────────────────────────── */}
|
|
<div className="pr-threads" data-testid="pr-threads">
|
|
{detail.threads.length === 0 ? (
|
|
<div className="pr-threads-empty">{t("pr.view.noReviewThreads", "No review threads.")}</div>
|
|
) : (
|
|
detail.threads.map((thread) => (
|
|
<div
|
|
key={`${thread.threadId}:${thread.headOid}`}
|
|
className={`pr-thread pr-thread--${thread.outcome} ${
|
|
thread.outcome === "disagreed" ? "pr-thread--agent-disagreement" : ""
|
|
}`}
|
|
data-testid={`pr-thread-${thread.outcome}`}
|
|
data-agent-disagreement={thread.outcome === "disagreed" ? "true" : "false"}
|
|
>
|
|
<div className="pr-thread-head">
|
|
{thread.outcome === "pending" && (
|
|
<span className="pr-thread-pending">
|
|
<Clock size={12} /> {t("pr.view.threadPending", "pending")}
|
|
</span>
|
|
)}
|
|
{thread.outcome === "disagreed" && (
|
|
<span className="pr-thread-disagreed">
|
|
<AlertTriangle size={12} /> {t("pr.view.agentDisagreed", "agent disagreed")}
|
|
</span>
|
|
)}
|
|
{thread.outcome === "fixed" && (
|
|
<span className="pr-thread-fixed">
|
|
<CheckCircle size={12} /> {t("pr.view.threadFixed", "fixed")}
|
|
</span>
|
|
)}
|
|
<span className="pr-thread-id">{thread.threadId}</span>
|
|
</div>
|
|
{thread.fixCommitSha && (
|
|
<div className="pr-thread-reply" data-testid="pr-thread-reply">
|
|
{t("pr.view.agentReplyFix", "Agent reply — fix {{sha}}", { sha: thread.fixCommitSha.slice(0, 8) })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{error && <div className="pr-inline-error">{error}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PrIdentityHeader({ detail }: { detail: PrDetail }) {
|
|
return (
|
|
<div className="pr-identity" data-testid="pr-identity">
|
|
<GitPullRequest size={16} />
|
|
<span className="pr-identity-repo">{detail.repo}</span>
|
|
{detail.prNumber != null ? (
|
|
detail.prUrl ? (
|
|
<a className="pr-identity-number" href={detail.prUrl} target="_blank" rel="noopener noreferrer">
|
|
#{detail.prNumber} <ExternalLink size={12} />
|
|
</a>
|
|
) : (
|
|
<span className="pr-identity-number">#{detail.prNumber}</span>
|
|
)
|
|
) : null}
|
|
<span className="pr-identity-branch">{detail.headBranch}</span>
|
|
<span className={`pr-identity-state pr-identity-state--${detail.state}`}>{detail.state}</span>
|
|
</div>
|
|
);
|
|
}
|