Skip to content

Commit b0037c5

Browse files
authored
Merge pull request #1839 from quantified-uncertainty/claude/pr-kanban-dashboard
feat: add PR Kanban Dashboard (E1011)
2 parents 1f794e4 + e11b023 commit b0037c5

File tree

9 files changed

+504
-0
lines changed

9 files changed

+504
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { redirect } from "next/navigation";
2+
3+
export default function PRDashboardPage() {
4+
redirect("/wiki/E1011");
5+
}
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
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+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { fetchDetailed, withApiFallback, type FetchResult } from "@lib/wiki-server";
2+
import { DataSourceBanner } from "@components/internal/DataSourceBanner";
3+
import { PRDashboardBoard } from "./pr-dashboard-board";
4+
import { computeStats, type PullData } from "./pr-dashboard-shared";
5+
6+
// Re-export shared types so existing consumers (mdx-components) keep working
7+
export type { PullData, PRStats, KanbanColumn } from "./pr-dashboard-shared";
8+
export { classifyPR } from "./pr-dashboard-shared";
9+
10+
// ── Data Loading ────────────────────────────────────────────────────────
11+
12+
interface PullsResponse {
13+
pulls: PullData[];
14+
error?: string;
15+
}
16+
17+
async function loadFromApi(): Promise<FetchResult<PullsResponse>> {
18+
return fetchDetailed<PullsResponse>("/api/github/pulls", { revalidate: 15 });
19+
}
20+
21+
function noLocalFallback(): PullsResponse {
22+
return { pulls: [], error: "Wiki-server unavailable" };
23+
}
24+
25+
// ── Content Component (server) ───────────────────────────────────────────
26+
27+
export async function PRDashboardContent() {
28+
const { data, source, apiError } = await withApiFallback(
29+
loadFromApi,
30+
noLocalFallback
31+
);
32+
33+
const pulls = data.pulls ?? [];
34+
const error = data.error ?? undefined;
35+
const stats = computeStats(pulls);
36+
37+
return (
38+
<>
39+
<DataSourceBanner source={source} apiError={apiError} />
40+
{error && (
41+
<div className="border-l-4 border-amber-400 bg-amber-50 dark:bg-amber-950/30 px-4 py-3 mb-4 not-prose">
42+
<p className="text-sm text-amber-800 dark:text-amber-200">
43+
{error}
44+
</p>
45+
</div>
46+
)}
47+
<PRDashboardBoard pulls={pulls} stats={stats} />
48+
</>
49+
);
50+
}

0 commit comments

Comments
 (0)