feat(FN-4757): complete Step 2 — build PrPanel component
Fusion-Task-Id: FN-4757 Fusion-Task-Lineage: 32236172-0b3b-4c19-b3a1-8ee0b91ea3ac
This commit is contained in:
committed by
gsxdsm
parent
6721005e88
commit
3c6be85b96
142
packages/dashboard/app/components/PrPanel.css
Normal file
142
packages/dashboard/app/components/PrPanel.css
Normal file
@@ -0,0 +1,142 @@
|
||||
.pr-panel-section {
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-panel-row-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pr-panel-checks-rollup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.pr-panel-tone-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.pr-panel-tone-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.pr-panel-tone-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.pr-panel-tone-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.pr-panel-checks-details {
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-panel-checks-details summary {
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pr-panel-check-list {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
list-style: none;
|
||||
margin: var(--space-sm) 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pr-panel-check-item {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.pr-panel-check-dot {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.pr-panel-check-name {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pr-panel-required {
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.6875rem;
|
||||
padding: 0 var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-panel-check-chip,
|
||||
.pr-panel-review-badge {
|
||||
border: var(--btn-border-width) solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 0 var(--space-sm);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pr-panel-check-chip--success,
|
||||
.pr-panel-review-badge--success {
|
||||
background: color-mix(in srgb, var(--color-success) 20%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.pr-panel-check-chip--error,
|
||||
.pr-panel-review-badge--error {
|
||||
background: color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.pr-panel-check-chip--warning,
|
||||
.pr-panel-review-badge--warning {
|
||||
background: color-mix(in srgb, var(--color-warning) 20%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.pr-panel-check-chip--muted,
|
||||
.pr-panel-review-badge--muted {
|
||||
background: color-mix(in srgb, var(--text-muted) 20%, transparent);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pr-panel-comment-time {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.pr-panel-refresh-icon--muted {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pr-panel-checks-rollup {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.pr-panel-check-item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pr-panel-required,
|
||||
.pr-panel-check-chip {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
235
packages/dashboard/app/components/PrPanel.tsx
Normal file
235
packages/dashboard/app/components/PrPanel.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare, CircleDot, XCircle, GitMerge } from "lucide-react";
|
||||
import type { PrInfo, PrCheckState, PrCheckStatus } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import { refreshPrStatus, type PrRefreshResponse } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import "./PrPanel.css";
|
||||
|
||||
interface PrPanelProps {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
prInfo?: PrInfo;
|
||||
automationStatus?: string | null;
|
||||
autoMerge?: boolean;
|
||||
isManualPrFlow?: boolean;
|
||||
prAuthAvailable: boolean;
|
||||
onPrUpdated: (prInfo: PrInfo) => void;
|
||||
onRequestCreatePr?: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
const STATUS_ICONS: Record<string, React.ReactNode> = {
|
||||
open: <CircleDot size={16} />,
|
||||
closed: <XCircle size={16} />,
|
||||
merged: <GitMerge size={16} />,
|
||||
};
|
||||
|
||||
const PASSING_STATES = new Set<PrCheckState>(["success", "neutral", "skipped"]);
|
||||
const FAILING_STATES = new Set<PrCheckState>(["failure", "error", "cancelled", "timed_out", "action_required", "startup_failure"]);
|
||||
const PENDING_STATES = new Set<PrCheckState>(["pending", "stale"]);
|
||||
|
||||
function getCheckStateTone(state: PrCheckState): "success" | "error" | "warning" | "muted" {
|
||||
if (PASSING_STATES.has(state)) return "success";
|
||||
if (FAILING_STATES.has(state)) return "error";
|
||||
if (PENDING_STATES.has(state)) return "warning";
|
||||
return "muted";
|
||||
}
|
||||
|
||||
function getReviewTone(reviewDecision: PrRefreshResponse["reviewDecision"]): "success" | "error" | "warning" | "muted" {
|
||||
if (reviewDecision === "APPROVED") return "success";
|
||||
if (reviewDecision === "CHANGES_REQUESTED") return "error";
|
||||
if (reviewDecision === "REVIEW_REQUIRED") return "warning";
|
||||
return "muted";
|
||||
}
|
||||
|
||||
export function PrPanel({
|
||||
taskId,
|
||||
projectId,
|
||||
prInfo,
|
||||
automationStatus,
|
||||
autoMerge = false,
|
||||
isManualPrFlow = false,
|
||||
prAuthAvailable,
|
||||
onPrUpdated,
|
||||
onRequestCreatePr,
|
||||
addToast,
|
||||
}: PrPanelProps) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [refreshState, setRefreshState] = useState<PrRefreshResponse | null>(null);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!prInfo) return;
|
||||
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const updated = await refreshPrStatus(taskId, projectId);
|
||||
setRefreshState(updated);
|
||||
onPrUpdated(updated.prInfo);
|
||||
addToast("PR status refreshed", "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to refresh PR", "error");
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [taskId, projectId, prInfo, onPrUpdated, addToast]);
|
||||
|
||||
if (!prInfo) {
|
||||
if (automationStatus === "creating-pr") {
|
||||
return (
|
||||
<div className="pr-section">
|
||||
<h4>
|
||||
<GitPullRequest size={16} className="pr-section-icon" />
|
||||
Pull Request
|
||||
</h4>
|
||||
<div className="pr-hint pr-hint--muted">fn is creating a pull request automatically for this task.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (autoMerge) {
|
||||
return (
|
||||
<div className="pr-section">
|
||||
<h4>
|
||||
<GitPullRequest size={16} className="pr-section-icon" />
|
||||
Pull Request
|
||||
</h4>
|
||||
<div className="pr-hint pr-hint--muted">Auto-merge will handle this task automatically.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const createDisabled = !prAuthAvailable || !onRequestCreatePr;
|
||||
|
||||
return (
|
||||
<div className="pr-section">
|
||||
<h4>
|
||||
<GitPullRequest size={16} className="pr-section-icon" />
|
||||
Pull Request
|
||||
</h4>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={onRequestCreatePr}
|
||||
disabled={createDisabled}
|
||||
title={prAuthAvailable ? "Create a PR for this task" : "PR auth unavailable — run 'gh auth login'"}
|
||||
>
|
||||
<Plus />
|
||||
Create PR
|
||||
</button>
|
||||
{isManualPrFlow && <div className="pr-hint pr-hint--subtle">Use the footer action to run PR-first completion for this task.</div>}
|
||||
{(!prAuthAvailable || !onRequestCreatePr) && (
|
||||
<div className="pr-hint pr-hint--subtle">
|
||||
Run <code>gh auth login</code> to enable PR creation.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusIcon = STATUS_ICONS[prInfo.status] ?? <CircleDot size={16} />;
|
||||
const blockingReasons = refreshState?.blockingReasons ?? [];
|
||||
const checks = refreshState?.checks;
|
||||
const reviewDecision = refreshState?.reviewDecision ?? null;
|
||||
|
||||
const checkSummary = useMemo(() => {
|
||||
if (!checks) return null;
|
||||
return checks.reduce(
|
||||
(acc, check) => {
|
||||
if (PASSING_STATES.has(check.state)) acc.passing += 1;
|
||||
else if (FAILING_STATES.has(check.state)) acc.failing += 1;
|
||||
else if (PENDING_STATES.has(check.state)) acc.pending += 1;
|
||||
return acc;
|
||||
},
|
||||
{ passing: 0, failing: 0, pending: 0 }
|
||||
);
|
||||
}, [checks]);
|
||||
|
||||
return (
|
||||
<div className="pr-section">
|
||||
<h4>
|
||||
<GitPullRequest size={16} className="pr-section-icon" />
|
||||
Pull Request
|
||||
</h4>
|
||||
<div className={`pr-card pr-card--status-${prInfo.status}`}>
|
||||
<div className="pr-header">
|
||||
<span className="pr-status-icon">{statusIcon}</span>
|
||||
<span className={`pr-status-badge pr-status-badge--${prInfo.status}`}>{prInfo.status}</span>
|
||||
<span className="pr-number">#{prInfo.number}</span>
|
||||
<div className="pr-spacer" />
|
||||
<button className="btn btn-sm pr-refresh-btn" onClick={handleRefresh} disabled={isRefreshing} title="Refresh PR status">
|
||||
<RefreshCw size={14} className={isRefreshing ? "spin pr-panel-refresh-icon--muted" : undefined} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="pr-title">{prInfo.title}</div>
|
||||
<div className="pr-meta">
|
||||
<span>{prInfo.headBranch}</span>
|
||||
<span className="pr-meta-arrow">→</span>
|
||||
<span>{prInfo.baseBranch}</span>
|
||||
</div>
|
||||
|
||||
<div className="pr-panel-section">
|
||||
<div className="pr-panel-row-label">Checks</div>
|
||||
{checkSummary ? (
|
||||
<>
|
||||
<div className="pr-panel-checks-rollup">
|
||||
<span className="pr-panel-tone-success">{checkSummary.passing} passing</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="pr-panel-tone-error">{checkSummary.failing} failing</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="pr-panel-tone-warning">{checkSummary.pending} pending</span>
|
||||
</div>
|
||||
<details className="pr-panel-checks-details">
|
||||
<summary>Recent checks</summary>
|
||||
<ul className="pr-panel-check-list">
|
||||
{checks.map((check: PrCheckStatus) => (
|
||||
<li key={`${check.name}-${check.state}`} className="pr-panel-check-item">
|
||||
<span className={`status-dot pr-panel-check-dot status-dot--${getCheckStateTone(check.state) === "success" ? "online" : getCheckStateTone(check.state) === "error" ? "error" : "pending"}`} />
|
||||
<span className="pr-panel-check-name">{check.name}</span>
|
||||
{check.required && <span className="pr-panel-required">Required</span>}
|
||||
<span className={`pr-panel-check-chip pr-panel-check-chip--${getCheckStateTone(check.state)}`}>{check.state}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<div className="pr-hint pr-hint--subtle">Checks not yet loaded — refresh to fetch.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pr-panel-section">
|
||||
<div className="pr-panel-row-label">Review</div>
|
||||
{reviewDecision ? (
|
||||
<span className={`pr-panel-review-badge pr-panel-review-badge--${getReviewTone(reviewDecision)}`}>{reviewDecision}</span>
|
||||
) : (
|
||||
<span className="pr-panel-tone-muted">No reviews yet</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{automationStatus === "merging-pr" && <div className="pr-hint pr-hint--info">fn is merging this pull request automatically.</div>}
|
||||
{automationStatus === "awaiting-pr-checks" && (
|
||||
<div className="pr-hint pr-hint--info">
|
||||
{blockingReasons.length > 0
|
||||
? `Waiting for: ${blockingReasons.join("; ")}`
|
||||
: "Waiting for required checks or review feedback before auto-merge."}
|
||||
</div>
|
||||
)}
|
||||
{prInfo.status === "merged" && (
|
||||
<div className="pr-hint pr-hint--info">This PR is merged. fn will finish local cleanup and move the task to Done.</div>
|
||||
)}
|
||||
|
||||
<div className="pr-footer">
|
||||
<span className="pr-comments">
|
||||
<MessageSquare size={14} />
|
||||
{prInfo.commentCount}
|
||||
{prInfo.lastCommentAt ? <span className="pr-panel-comment-time">Last: {new Date(prInfo.lastCommentAt).toLocaleString()}</span> : null}
|
||||
</span>
|
||||
<a href={prInfo.url} target="_blank" rel="noopener noreferrer" className="pr-link">
|
||||
<ExternalLink size={14} />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user