|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { GITHUB_REPO_URL } from "@lib/site-config"; |
| 4 | +import { classifyPR, type PullData, type PRStats, type KanbanColumn } from "./pr-dashboard-shared"; |
| 5 | + |
| 6 | +// ── Column Config ─────────────────────────────────────────────────────── |
| 7 | + |
| 8 | +const COLUMN_CONFIG: Array<{ |
| 9 | + key: KanbanColumn; |
| 10 | + title: string; |
| 11 | + emptyText: string; |
| 12 | + headerColor: string; |
| 13 | +}> = [ |
| 14 | + { |
| 15 | + key: "draft", |
| 16 | + title: "Draft", |
| 17 | + emptyText: "No draft PRs", |
| 18 | + headerColor: "text-muted-foreground", |
| 19 | + }, |
| 20 | + { |
| 21 | + key: "ci-issues", |
| 22 | + title: "CI Running / Failing", |
| 23 | + emptyText: "All CI checks passing", |
| 24 | + headerColor: "text-yellow-600", |
| 25 | + }, |
| 26 | + { |
| 27 | + key: "needs-review", |
| 28 | + title: "Needs Review", |
| 29 | + emptyText: "No PRs awaiting review", |
| 30 | + headerColor: "text-blue-600", |
| 31 | + }, |
| 32 | + { |
| 33 | + key: "approved", |
| 34 | + title: "Approved", |
| 35 | + emptyText: "No approved PRs", |
| 36 | + headerColor: "text-green-600", |
| 37 | + }, |
| 38 | +]; |
| 39 | + |
| 40 | +// ── CI Status Badge ──────────────────────────────────────────────────── |
| 41 | +// TODO: extract CiStatusBadge, MergeStatusBadge, and their style objects |
| 42 | +// to shared pr-badges.tsx (also used by system-health/open-prs-table.tsx) |
| 43 | + |
| 44 | +const CI_STYLES: Record<string, { cls: string; label: string }> = { |
| 45 | + success: { cls: "bg-green-500/15 text-green-600", label: "passing" }, |
| 46 | + failure: { cls: "bg-red-500/15 text-red-500", label: "failing" }, |
| 47 | + pending: { cls: "bg-yellow-500/15 text-yellow-600", label: "building" }, |
| 48 | + error: { cls: "bg-red-500/15 text-red-500", label: "error" }, |
| 49 | + unknown: { cls: "bg-muted text-muted-foreground", label: "unknown" }, |
| 50 | +}; |
| 51 | + |
| 52 | +function CiStatusBadge({ status }: { status: string }) { |
| 53 | + const style = CI_STYLES[status] ?? CI_STYLES.unknown; |
| 54 | + return ( |
| 55 | + <span |
| 56 | + className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ${style.cls}`} |
| 57 | + > |
| 58 | + {style.label} |
| 59 | + </span> |
| 60 | + ); |
| 61 | +} |
| 62 | + |
| 63 | +// ── Merge Status Badge ───────────────────────────────────────────────── |
| 64 | + |
| 65 | +const MERGE_STYLES: Record<string, { cls: string; label: string }> = { |
| 66 | + mergeable: { cls: "bg-green-500/15 text-green-600", label: "clean" }, |
| 67 | + conflicting: { cls: "bg-red-500/15 text-red-500", label: "conflicts" }, |
| 68 | + unknown: { cls: "bg-muted text-muted-foreground", label: "pending" }, |
| 69 | +}; |
| 70 | + |
| 71 | +function MergeStatusBadge({ status }: { status: string }) { |
| 72 | + const style = MERGE_STYLES[status] ?? MERGE_STYLES.unknown; |
| 73 | + return ( |
| 74 | + <span |
| 75 | + className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ${style.cls}`} |
| 76 | + > |
| 77 | + {style.label} |
| 78 | + </span> |
| 79 | + ); |
| 80 | +} |
| 81 | + |
| 82 | +// ── Label Pill ───────────────────────────────────────────────────────── |
| 83 | + |
| 84 | +function LabelPill({ name }: { name: string }) { |
| 85 | + const isBlock = name.startsWith("block:"); |
| 86 | + const isWarning = |
| 87 | + name.startsWith("needs:") || name.startsWith("waiting:"); |
| 88 | + const cls = isBlock |
| 89 | + ? "bg-red-500/15 text-red-600" |
| 90 | + : isWarning |
| 91 | + ? "bg-orange-500/15 text-orange-600" |
| 92 | + : "bg-muted text-muted-foreground"; |
| 93 | + return ( |
| 94 | + <span |
| 95 | + className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${cls}`} |
| 96 | + > |
| 97 | + {name} |
| 98 | + </span> |
| 99 | + ); |
| 100 | +} |
| 101 | + |
| 102 | +// ── Relative Time ────────────────────────────────────────────────────── |
| 103 | + |
| 104 | +function relativeTime(date: string): string { |
| 105 | + const now = Date.now(); |
| 106 | + const then = new Date(date).getTime(); |
| 107 | + const hoursAgo = Math.round((now - then) / 3600000); |
| 108 | + |
| 109 | + if (hoursAgo < 1) return "<1h ago"; |
| 110 | + if (hoursAgo < 24) return `${hoursAgo}h ago`; |
| 111 | + return `${Math.round(hoursAgo / 24)}d ago`; |
| 112 | +} |
| 113 | + |
| 114 | +// ── PR Card ──────────────────────────────────────────────────────────── |
| 115 | + |
| 116 | +function PRCard({ pr }: { pr: PullData }) { |
| 117 | + // Filter labels that are purely workflow/stage markers from display |
| 118 | + const displayLabels = pr.labels.filter( |
| 119 | + (l) => |
| 120 | + l !== "stage:approved" && |
| 121 | + l !== "ready-to-merge" && |
| 122 | + l !== "claude-working" && |
| 123 | + l !== "filed-by-agent" |
| 124 | + ); |
| 125 | + |
| 126 | + return ( |
| 127 | + <div className="rounded-lg border border-border/60 bg-background p-3 shadow-sm"> |
| 128 | + {/* Header: PR number + author */} |
| 129 | + <div className="flex items-center justify-between mb-1"> |
| 130 | + <a |
| 131 | + href={`${GITHUB_REPO_URL}/pull/${pr.number}`} |
| 132 | + target="_blank" |
| 133 | + rel="noopener noreferrer" |
| 134 | + className="text-xs text-blue-600 hover:underline tabular-nums font-medium" |
| 135 | + > |
| 136 | + #{pr.number} |
| 137 | + </a> |
| 138 | + <span className="text-[11px] text-muted-foreground">{pr.author}</span> |
| 139 | + </div> |
| 140 | + |
| 141 | + {/* Title */} |
| 142 | + <a |
| 143 | + href={`${GITHUB_REPO_URL}/pull/${pr.number}`} |
| 144 | + target="_blank" |
| 145 | + rel="noopener noreferrer" |
| 146 | + className="block text-sm leading-snug mb-2 truncate hover:underline" |
| 147 | + title={pr.title} |
| 148 | + > |
| 149 | + {pr.title} |
| 150 | + </a> |
| 151 | + |
| 152 | + {/* Badges row */} |
| 153 | + <div className="flex flex-wrap items-center gap-1.5 mb-2"> |
| 154 | + <CiStatusBadge status={pr.ciStatus} /> |
| 155 | + <MergeStatusBadge status={pr.mergeable} /> |
| 156 | + {pr.unresolvedThreads > 0 && ( |
| 157 | + <span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-purple-500/15 text-purple-600"> |
| 158 | + {pr.unresolvedThreads} thread{pr.unresolvedThreads !== 1 ? "s" : ""} |
| 159 | + </span> |
| 160 | + )} |
| 161 | + </div> |
| 162 | + |
| 163 | + {/* Labels */} |
| 164 | + {displayLabels.length > 0 && ( |
| 165 | + <div className="flex flex-wrap gap-1 mb-2"> |
| 166 | + {displayLabels.map((label) => ( |
| 167 | + <LabelPill key={label} name={label} /> |
| 168 | + ))} |
| 169 | + </div> |
| 170 | + )} |
| 171 | + |
| 172 | + {/* Footer: size + age */} |
| 173 | + <div className="flex items-center justify-between text-[11px] text-muted-foreground"> |
| 174 | + <span className="tabular-nums"> |
| 175 | + <span className="text-green-600">+{pr.additions}</span> |
| 176 | + {" / "} |
| 177 | + <span className="text-red-500">-{pr.deletions}</span> |
| 178 | + </span> |
| 179 | + <span className="tabular-nums" suppressHydrationWarning> |
| 180 | + {relativeTime(pr.createdAt)} |
| 181 | + </span> |
| 182 | + </div> |
| 183 | + </div> |
| 184 | + ); |
| 185 | +} |
| 186 | + |
| 187 | +// ── Column ───────────────────────────────────────────────────────────── |
| 188 | + |
| 189 | +function KanbanColumnComponent({ |
| 190 | + title, |
| 191 | + headerColor, |
| 192 | + emptyText, |
| 193 | + pulls, |
| 194 | +}: { |
| 195 | + title: string; |
| 196 | + headerColor: string; |
| 197 | + emptyText: string; |
| 198 | + pulls: PullData[]; |
| 199 | +}) { |
| 200 | + return ( |
| 201 | + <div className="flex flex-col min-w-[260px]"> |
| 202 | + {/* Column header */} |
| 203 | + <div className="flex items-center gap-2 mb-3"> |
| 204 | + <h3 className={`text-sm font-semibold ${headerColor}`}>{title}</h3> |
| 205 | + <span className="inline-flex items-center justify-center rounded-full bg-muted px-1.5 text-[11px] font-medium text-muted-foreground min-w-[20px]"> |
| 206 | + {pulls.length} |
| 207 | + </span> |
| 208 | + </div> |
| 209 | + |
| 210 | + {/* Cards */} |
| 211 | + <div className="flex flex-col gap-2"> |
| 212 | + {pulls.length === 0 ? ( |
| 213 | + <div className="rounded-lg border border-dashed border-border/60 p-4 text-center text-xs text-muted-foreground"> |
| 214 | + {emptyText} |
| 215 | + </div> |
| 216 | + ) : ( |
| 217 | + pulls.map((pr) => <PRCard key={pr.number} pr={pr} />) |
| 218 | + )} |
| 219 | + </div> |
| 220 | + </div> |
| 221 | + ); |
| 222 | +} |
| 223 | + |
| 224 | +// ── Stats Bar ────────────────────────────────────────────────────────── |
| 225 | + |
| 226 | +function StatsBar({ stats }: { stats: PRStats }) { |
| 227 | + return ( |
| 228 | + <div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground mb-6"> |
| 229 | + <span> |
| 230 | + Open PRs:{" "} |
| 231 | + <span className="font-semibold text-foreground">{stats.total}</span> |
| 232 | + </span> |
| 233 | + <span> |
| 234 | + Draft:{" "} |
| 235 | + <span className="font-semibold text-foreground">{stats.draft}</span> |
| 236 | + </span> |
| 237 | + <span> |
| 238 | + CI Failing:{" "} |
| 239 | + <span |
| 240 | + className={`font-semibold ${stats.ciFailing > 0 ? "text-red-500" : "text-foreground"}`} |
| 241 | + > |
| 242 | + {stats.ciFailing} |
| 243 | + </span> |
| 244 | + </span> |
| 245 | + <span> |
| 246 | + Needs Review:{" "} |
| 247 | + <span |
| 248 | + className={`font-semibold ${stats.needsReview > 0 ? "text-blue-600" : "text-foreground"}`} |
| 249 | + > |
| 250 | + {stats.needsReview} |
| 251 | + </span> |
| 252 | + </span> |
| 253 | + <span> |
| 254 | + Conflicting:{" "} |
| 255 | + <span |
| 256 | + className={`font-semibold ${stats.conflicting > 0 ? "text-red-500" : "text-foreground"}`} |
| 257 | + > |
| 258 | + {stats.conflicting} |
| 259 | + </span> |
| 260 | + </span> |
| 261 | + </div> |
| 262 | + ); |
| 263 | +} |
| 264 | + |
| 265 | +// ── Board ────────────────────────────────────────────────────────────── |
| 266 | + |
| 267 | +export function PRDashboardBoard({ |
| 268 | + pulls, |
| 269 | + stats, |
| 270 | +}: { |
| 271 | + pulls: PullData[]; |
| 272 | + stats: PRStats; |
| 273 | +}) { |
| 274 | + // Classify PRs into columns |
| 275 | + const columns: Record<KanbanColumn, PullData[]> = { |
| 276 | + draft: [], |
| 277 | + "ci-issues": [], |
| 278 | + "needs-review": [], |
| 279 | + approved: [], |
| 280 | + }; |
| 281 | + |
| 282 | + for (const pr of pulls) { |
| 283 | + const col = classifyPR(pr); |
| 284 | + columns[col].push(pr); |
| 285 | + } |
| 286 | + |
| 287 | + // Sort each column: most recently updated first |
| 288 | + for (const col of Object.values(columns)) { |
| 289 | + col.sort( |
| 290 | + (a, b) => |
| 291 | + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() |
| 292 | + ); |
| 293 | + } |
| 294 | + |
| 295 | + if (pulls.length === 0) { |
| 296 | + return ( |
| 297 | + <div className="rounded-lg border border-border/60 p-8 text-center text-muted-foreground"> |
| 298 | + <p className="text-lg font-medium mb-2">No open pull requests</p> |
| 299 | + <p className="text-sm"> |
| 300 | + Open PRs will appear here when agents or contributors create them. |
| 301 | + </p> |
| 302 | + </div> |
| 303 | + ); |
| 304 | + } |
| 305 | + |
| 306 | + return ( |
| 307 | + <> |
| 308 | + <StatsBar stats={stats} /> |
| 309 | + |
| 310 | + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"> |
| 311 | + {COLUMN_CONFIG.map((cfg) => ( |
| 312 | + <KanbanColumnComponent |
| 313 | + key={cfg.key} |
| 314 | + title={cfg.title} |
| 315 | + headerColor={cfg.headerColor} |
| 316 | + emptyText={cfg.emptyText} |
| 317 | + pulls={columns[cfg.key]} |
| 318 | + /> |
| 319 | + ))} |
| 320 | + </div> |
| 321 | + </> |
| 322 | + ); |
| 323 | +} |
0 commit comments