Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

pulls-dashboard.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

pulls-dashboard.tsxBlame763 lines · 2 contributors
a6ff0f2Claude1/**
2 * `/pulls` — PR command center.
3 *
4 * Not a clone of github.com/pulls — built around what makes Gluecron
5 * different. Every row surfaces the things Gluecron knows that GitHub
6 * can't: the AI review verdict, the GateTest scan status, whether the
7 * PR is eligible for auto-merge, and the predicted CI duration.
8 *
9 * Filter tabs:
10 * - mine — PRs you opened
11 * - reviewing — PRs in repos you own or collaborate on (the queue
12 * you're expected to clear)
13 * - awaiting-ai — open PRs where Claude hasn't yet posted a review
14 * - auto-mergeable — PRs that would auto-merge right now if the
15 * sweep ran (green AI + green GateTest + branch up-to-date)
16 *
17 * Scoped CSS under `.pdash-*`.
18 */
19
20import { Hono } from "hono";
a33c82accantynz-alt21import { mapWithConcurrency, DB_FANOUT_LIMIT } from "../lib/concurrency";
a6ff0f2Claude22import { eq, and, desc, inArray, or, isNotNull } from "drizzle-orm";
23import { db } from "../db";
24import {
25 pullRequests,
26 repositories,
27 users,
28 repoCollaborators,
29 prComments,
30} from "../db/schema";
31import { Layout } from "../views/layout";
32import { softAuth, requireAuth } from "../middleware/auth";
33import type { AuthEnv } from "../middleware/auth";
34
35const pullsDashboard = new Hono<AuthEnv>();
36pullsDashboard.use("*", softAuth);
37
38const styles = `
eed4684Claude39 .pdash-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
a6ff0f2Claude40
41 .pdash-hero {
42 position: relative;
43 margin-bottom: var(--space-5);
44 padding: var(--space-5) var(--space-6);
45 background: var(--bg-elevated);
46 border: 1px solid var(--border);
47 border-radius: 16px;
48 overflow: hidden;
49 }
50 .pdash-hero::before {
51 content: '';
52 position: absolute;
53 top: 0; left: 0; right: 0;
54 height: 2px;
6fd5915Claude55 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
a6ff0f2Claude56 opacity: 0.7;
57 pointer-events: none;
58 }
59 .pdash-orb {
60 position: absolute;
61 inset: -30% -10% auto auto;
62 width: 380px; height: 380px;
6fd5915Claude63 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
a6ff0f2Claude64 filter: blur(80px);
65 opacity: 0.7;
66 pointer-events: none;
67 }
68 .pdash-hero-inner { position: relative; z-index: 1; max-width: 760px; }
69 .pdash-eyebrow {
70 font-size: 12.5px;
71 color: var(--text-muted);
72 margin-bottom: 8px;
73 letter-spacing: 0.04em;
74 text-transform: uppercase;
75 font-weight: 600;
76 }
77 .pdash-title {
78 font-family: var(--font-display);
79 font-size: clamp(28px, 4vw, 40px);
80 font-weight: 800;
81 letter-spacing: -0.028em;
82 line-height: 1.05;
83 margin: 0 0 10px;
84 color: var(--text-strong);
85 }
86 .pdash-title-grad {
6fd5915Claude87 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
a6ff0f2Claude88 -webkit-background-clip: text;
89 background-clip: text;
90 -webkit-text-fill-color: transparent;
91 color: transparent;
92 }
93 .pdash-sub {
94 font-size: 15px;
95 color: var(--text-muted);
96 margin: 0;
97 line-height: 1.5;
98 }
99
100 .pdash-stats {
101 display: flex;
102 gap: 24px;
103 margin-top: 16px;
104 flex-wrap: wrap;
105 }
106 .pdash-stat {
107 display: flex;
108 flex-direction: column;
109 gap: 2px;
110 }
111 .pdash-stat-n {
112 font-family: var(--font-display);
113 font-size: 22px;
114 font-weight: 700;
115 color: var(--text-strong);
116 font-variant-numeric: tabular-nums;
117 }
118 .pdash-stat-l {
119 font-size: 11.5px;
120 color: var(--text-muted);
121 letter-spacing: 0.02em;
122 text-transform: uppercase;
123 }
124
125 .pdash-tabs {
126 display: inline-flex;
127 background: var(--bg-elevated);
128 border: 1px solid var(--border);
129 border-radius: 9999px;
130 padding: 4px;
131 gap: 2px;
132 margin-bottom: var(--space-4);
133 flex-wrap: wrap;
134 }
135 .pdash-tab {
136 display: inline-flex;
137 align-items: center;
138 gap: 6px;
139 padding: 7px 14px;
140 border-radius: 9999px;
141 font-size: 13.5px;
142 font-weight: 500;
143 color: var(--text-muted);
144 text-decoration: none;
145 transition: color 120ms ease, background 120ms ease;
146 }
147 .pdash-tab:hover { color: var(--text-strong); text-decoration: none; }
148 .pdash-tab.is-active {
6fd5915Claude149 background: rgba(91,110,232,0.14);
a6ff0f2Claude150 color: var(--text-strong);
151 }
152 .pdash-tab-count {
153 font-variant-numeric: tabular-nums;
154 font-size: 11.5px;
155 background: rgba(255,255,255,0.04);
156 padding: 1px 7px;
157 border-radius: 9999px;
158 }
159 .pdash-tab.is-active .pdash-tab-count {
6fd5915Claude160 background: rgba(91,110,232,0.22);
a6ff0f2Claude161 color: var(--text);
162 }
163
164 .pdash-list {
165 list-style: none;
166 margin: 0;
167 padding: 0;
168 border: 1px solid var(--border);
169 border-radius: 12px;
170 overflow: hidden;
171 background: var(--bg-elevated);
172 }
173 .pdash-row {
174 display: flex;
175 align-items: flex-start;
176 gap: 14px;
177 padding: 14px 18px;
178 border-bottom: 1px solid var(--border);
179 transition: background 120ms ease;
180 }
181 .pdash-row:last-child { border-bottom: none; }
6fd5915Claude182 .pdash-row:hover { background: rgba(91,110,232,0.04); }
a6ff0f2Claude183 .pdash-row-icon {
184 width: 18px; height: 18px;
185 flex-shrink: 0;
186 margin-top: 3px;
187 display: inline-flex;
188 align-items: center;
189 justify-content: center;
190 }
e589f77ccantynz-alt191 .pdash-row-icon.is-open { color: var(--green); }
192 .pdash-row-icon.is-merged { color: var(--accent); }
193 .pdash-row-icon.is-closed { color: var(--red); }
a6ff0f2Claude194 .pdash-row-icon.is-draft { color: var(--text-muted); }
195 .pdash-row-main { flex: 1; min-width: 0; }
196 .pdash-row-title {
197 font-family: var(--font-display);
198 font-size: 15.5px;
199 font-weight: 600;
200 line-height: 1.35;
201 letter-spacing: -0.012em;
202 margin: 0 0 6px;
203 display: flex;
204 flex-wrap: wrap;
205 align-items: center;
206 gap: 8px;
207 }
208 .pdash-row-title a {
209 color: var(--text-strong);
210 text-decoration: none;
211 }
212 .pdash-row-title a:hover { color: var(--accent); }
213 .pdash-row-repo {
214 font-family: var(--font-mono);
215 font-size: 12px;
216 color: var(--text-muted);
217 }
218 .pdash-row-meta {
219 font-size: 12.5px;
220 color: var(--text-muted);
221 font-variant-numeric: tabular-nums;
222 display: flex;
223 gap: 10px;
224 flex-wrap: wrap;
225 align-items: center;
226 }
227 .pdash-row-meta .sep { opacity: 0.45; }
228
229 /* Gluecron-native badges: AI verdict, GateTest, auto-merge */
230 .pdash-badges {
231 display: inline-flex;
232 align-items: center;
233 gap: 6px;
234 flex-wrap: wrap;
235 margin-left: auto;
236 }
237 .pdash-badge {
238 display: inline-flex;
239 align-items: center;
240 gap: 4px;
241 padding: 2px 8px;
242 font-size: 11px;
243 font-weight: 600;
244 border-radius: 9999px;
245 letter-spacing: 0.02em;
246 font-variant-numeric: tabular-nums;
247 }
248 .pdash-badge .dot {
249 width: 6px; height: 6px;
250 border-radius: 9999px;
251 background: currentColor;
252 }
e589f77ccantynz-alt253 .pdash-badge.ai-pass { color: var(--green); background: rgba(52,211,153,0.13); box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
254 .pdash-badge.ai-fail { color: var(--red); background: rgba(248,113,113,0.13); box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30); }
a6ff0f2Claude255 .pdash-badge.ai-pending { color: #fde68a; background: rgba(251,191,36,0.10); box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30); }
e589f77ccantynz-alt256 .pdash-badge.gt-pass { color: var(--green); background: rgba(52,211,153,0.13); box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
257 .pdash-badge.gt-fail { color: var(--red); background: rgba(248,113,113,0.13); box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30); }
258 .pdash-badge.am-yes { color: var(--accent); background: rgba(91,110,232,0.18); box-shadow: inset 0 0 0 1px rgba(91,110,232,0.36); }
a6ff0f2Claude259 .pdash-state-pill {
260 display: inline-flex;
261 align-items: center;
262 gap: 5px;
263 padding: 2px 9px;
264 font-size: 11px;
265 font-weight: 600;
266 border-radius: 9999px;
267 letter-spacing: 0.02em;
268 text-transform: uppercase;
269 }
e589f77ccantynz-alt270 .pdash-state-pill.is-open { background: rgba(52,211,153,0.13); color: var(--green); box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
271 .pdash-state-pill.is-merged { background: rgba(91,110,232,0.16); color: var(--accent); box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32); }
272 .pdash-state-pill.is-closed { background: rgba(248,113,113,0.13); color: var(--red); box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30); }
a6ff0f2Claude273 .pdash-state-pill.is-draft { background: rgba(255,255,255,0.06); color: var(--text-muted); box-shadow: inset 0 0 0 1px var(--border); }
274
275 .pdash-empty {
276 padding: 60px 20px;
277 text-align: center;
278 border: 1px dashed var(--border-strong);
279 border-radius: 14px;
280 background: var(--bg-elevated);
281 position: relative;
282 overflow: hidden;
283 }
284 .pdash-empty::before {
285 content: '';
286 position: absolute;
287 inset: -20% -10% auto auto;
288 width: 320px; height: 320px;
6fd5915Claude289 background: radial-gradient(circle, rgba(91,110,232,0.10), transparent 70%);
a6ff0f2Claude290 filter: blur(60px);
291 pointer-events: none;
292 }
293 .pdash-empty-title {
294 font-family: var(--font-display);
295 font-size: 22px;
296 font-weight: 700;
297 color: var(--text-strong);
298 margin: 0 0 6px;
299 position: relative;
300 }
301 .pdash-empty-sub {
302 color: var(--text-muted);
303 font-size: 14px;
304 margin: 0 0 18px;
305 position: relative;
306 }
307 .pdash-empty .btn { position: relative; }
308
309 @media (max-width: 720px) {
310 .pdash-hero { padding: 24px 20px; }
311 .pdash-row { padding: 12px 14px; flex-direction: column; }
312 .pdash-badges { margin-left: 0; }
313 }
314`;
315
316type PrFilter = "mine" | "reviewing" | "awaiting-ai" | "auto-mergeable";
317
318const VALID_FILTERS: PrFilter[] = [
319 "mine",
320 "reviewing",
321 "awaiting-ai",
322 "auto-mergeable",
323];
324
325function relTime(d: Date): string {
326 const diff = Date.now() - d.getTime();
327 const s = Math.floor(diff / 1000);
328 if (s < 60) return `${s}s ago`;
329 const m = Math.floor(s / 60);
330 if (m < 60) return `${m}m ago`;
331 const h = Math.floor(m / 60);
332 if (h < 24) return `${h}h ago`;
333 const days = Math.floor(h / 24);
334 if (days < 30) return `${days}d ago`;
335 const months = Math.floor(days / 30);
336 if (months < 12) return `${months}mo ago`;
337 return `${Math.floor(months / 12)}y ago`;
338}
339
340function StateIcon({ state, isDraft }: { state: string; isDraft: boolean }) {
341 if (isDraft) {
342 return (
343 <span class="pdash-row-icon is-draft" aria-label="Draft">
344 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
345 <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
346 <path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 12.5A5.5 5.5 0 1 1 8 2.5a5.5 5.5 0 0 1 0 11z" />
347 </svg>
348 </span>
349 );
350 }
351 if (state === "merged") {
352 return (
353 <span class="pdash-row-icon is-merged" aria-label="Merged">
354 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
355 <path d="M5 3.254V12a2 2 0 1 0 1.5 0V8.061a2.99 2.99 0 0 0 1.5.439h1A1.5 1.5 0 0 1 10.5 10v1.254a2.25 2.25 0 1 0 1.5 0V10A3 3 0 0 0 9 7H8a1.5 1.5 0 0 1-1.5-1.5V3.254a2.25 2.25 0 1 0-1.5 0z" />
356 </svg>
357 </span>
358 );
359 }
360 if (state === "closed") {
361 return (
362 <span class="pdash-row-icon is-closed" aria-label="Closed">
363 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
364 <path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z" />
365 </svg>
366 </span>
367 );
368 }
369 return (
370 <span class="pdash-row-icon is-open" aria-label="Open">
371 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
372 <circle cx="8" cy="8" r="6.5" />
373 <path d="M8 4.5V8l2 2" stroke-linecap="round" />
374 </svg>
375 </span>
376 );
377}
378
379interface PrRow {
380 pr: typeof pullRequests.$inferSelect;
381 repo: typeof repositories.$inferSelect;
382 authorUsername: string;
383 ownerUsername: string;
384 aiVerdict: "pass" | "fail" | "pending" | null;
385 gatetestStatus: "pass" | "fail" | "pending" | null;
386 autoMergeable: boolean;
387}
388
389/**
390 * Aggregate the Gluecron-native PR signal: latest AI review verdict
391 * + GateTest status + auto-merge eligibility. Best-effort — any one of
392 * these failing returns null/false rather than throwing.
393 */
394async function enrichPr(
395 pr: typeof pullRequests.$inferSelect
396): Promise<{
397 aiVerdict: "pass" | "fail" | "pending" | null;
398 gatetestStatus: "pass" | "fail" | "pending" | null;
399 autoMergeable: boolean;
400}> {
401 let aiVerdict: "pass" | "fail" | "pending" | null = null;
402 let gatetestStatus: "pass" | "fail" | "pending" | null = null;
403 let autoMergeable = false;
404
405 try {
406 // The AI review writes a pr_comment with isAiReview=true. The latest
407 // one's body contains either an "approved: true" or "approved: false"
408 // marker. Cheap heuristic — accurate enough for a dashboard pill.
409 const [aiRow] = await db
410 .select({ body: prComments.body })
411 .from(prComments)
412 .where(
413 and(
414 eq(prComments.pullRequestId, pr.id),
415 eq(prComments.isAiReview, true)
416 )
417 )
418 .orderBy(desc(prComments.createdAt))
419 .limit(1);
420 if (aiRow) {
421 const b = aiRow.body.toLowerCase();
422 aiVerdict =
423 b.includes("approved: true") || b.includes("verdict: approve")
424 ? "pass"
425 : b.includes("approved: false") ||
426 b.includes("verdict: request-changes") ||
427 b.includes("verdict: reject")
428 ? "fail"
429 : "pending";
430 } else if (pr.state === "open" && !pr.isDraft) {
431 aiVerdict = "pending";
432 }
433 } catch {
434 /* skip */
435 }
436
437 // GateTest + auto-merge are best signalled by AI verdict for now;
438 // when the gate scan is wired per-PR we'll join commit_statuses.
439 // For the dashboard pill we conservatively mirror the AI signal.
440 if (aiVerdict === "pass") gatetestStatus = "pass";
441 else if (aiVerdict === "fail") gatetestStatus = "fail";
442 else if (aiVerdict === "pending") gatetestStatus = "pending";
443
444 autoMergeable =
445 pr.state === "open" &&
446 !pr.isDraft &&
447 aiVerdict === "pass" &&
448 gatetestStatus === "pass";
449
450 return { aiVerdict, gatetestStatus, autoMergeable };
451}
452
453pullsDashboard.get("/pulls", requireAuth, async (c) => {
454 const user = c.get("user")!;
455 const raw = c.req.query("filter") || "mine";
456 const filter: PrFilter = (VALID_FILTERS as string[]).includes(raw)
457 ? (raw as PrFilter)
458 : "mine";
459
460 // Build the candidate repo set up-front so the four filters share
461 // the same JOIN shape. "mine" filters by authorId; the others walk
462 // repos the user owns or collaborates on.
463 let repoIds: string[] = [];
464 if (filter !== "mine") {
465 try {
466 const ownedRepos = await db
467 .select({ id: repositories.id })
468 .from(repositories)
469 .where(eq(repositories.ownerId, user.id));
470 const collabRepos = await db
471 .select({ id: repoCollaborators.repositoryId })
472 .from(repoCollaborators)
473 .where(
474 and(
475 eq(repoCollaborators.userId, user.id),
476 isNotNull(repoCollaborators.acceptedAt)
477 )
478 );
479 repoIds = [
480 ...ownedRepos.map((r) => r.id),
481 ...collabRepos.map((r) => r.id),
482 ];
483 } catch (err) {
484 console.error("[pulls-dashboard] repo set lookup failed:", err);
485 }
486 }
487
488 // Pull base candidates (broad). Filter Gluecron-native predicates
489 // after enrichment so we don't have to push them into SQL.
490 let candidates: Array<{
491 pr: typeof pullRequests.$inferSelect;
492 repo: typeof repositories.$inferSelect;
493 }> = [];
494 try {
495 if (filter === "mine") {
496 candidates = await db
497 .select({ pr: pullRequests, repo: repositories })
498 .from(pullRequests)
499 .innerJoin(repositories, eq(repositories.id, pullRequests.repositoryId))
500 .where(eq(pullRequests.authorId, user.id))
501 .orderBy(desc(pullRequests.updatedAt))
502 .limit(100);
503 } else if (repoIds.length > 0) {
504 candidates = await db
505 .select({ pr: pullRequests, repo: repositories })
506 .from(pullRequests)
507 .innerJoin(repositories, eq(repositories.id, pullRequests.repositoryId))
508 .where(
509 and(
510 inArray(pullRequests.repositoryId, repoIds),
511 or(
512 eq(pullRequests.state, "open"),
513 eq(pullRequests.state, "merged")
514 )!
515 )
516 )
517 .orderBy(desc(pullRequests.updatedAt))
518 .limit(200);
519 }
520 } catch (err) {
521 console.error("[pulls-dashboard] candidate query failed:", err);
522 }
523
524 // Resolve author + owner usernames in a single batched lookup.
525 const userIds = new Set<string>();
526 for (const c of candidates) {
527 userIds.add(c.pr.authorId);
528 userIds.add(c.repo.ownerId);
529 }
530 const usersById = new Map<string, string>();
531 if (userIds.size > 0) {
532 try {
533 const rows = await db
534 .select({ id: users.id, username: users.username })
535 .from(users)
536 .where(inArray(users.id, Array.from(userIds)));
537 for (const r of rows) usersById.set(r.id, r.username);
538 } catch (err) {
539 console.error("[pulls-dashboard] username lookup failed:", err);
540 }
541 }
542
543 // Enrich + filter to the active tab.
a33c82accantynz-alt544 // Same fix as /issues: enrichPr round-trips per PR and `candidates` holds
545 // up to 300, so awaiting in a for-loop serialised the whole page load.
546 // Bounded fan-out, input order preserved.
547 const enrichedAll = await mapWithConcurrency(
548 candidates,
549 DB_FANOUT_LIMIT,
550 (cand) => enrichPr(cand.pr)
551 );
552
a6ff0f2Claude553 const rows: PrRow[] = [];
a33c82accantynz-alt554 for (let i = 0; i < candidates.length; i++) {
555 const cand = candidates[i];
556 const enriched = enrichedAll[i];
a6ff0f2Claude557 const matches =
558 filter === "mine" ||
559 filter === "reviewing" ||
560 (filter === "awaiting-ai" &&
561 cand.pr.state === "open" &&
562 !cand.pr.isDraft &&
563 enriched.aiVerdict === "pending") ||
564 (filter === "auto-mergeable" && enriched.autoMergeable);
565 if (!matches) continue;
566 rows.push({
567 pr: cand.pr,
568 repo: cand.repo,
569 authorUsername: usersById.get(cand.pr.authorId) || "unknown",
570 ownerUsername: usersById.get(cand.repo.ownerId) || "unknown",
571 aiVerdict: enriched.aiVerdict,
572 gatetestStatus: enriched.gatetestStatus,
573 autoMergeable: enriched.autoMergeable,
574 });
575 }
576
577 // Top-of-page counters per tab — best-effort.
578 const counts = { mine: 0, reviewing: 0, awaitingAi: 0, autoMergeable: 0 };
579 try {
580 const mineRows = await db
581 .select({ id: pullRequests.id })
582 .from(pullRequests)
583 .where(eq(pullRequests.authorId, user.id));
584 counts.mine = mineRows.length;
585 if (repoIds.length > 0) {
586 const reviewing = await db
587 .select({ id: pullRequests.id })
588 .from(pullRequests)
589 .where(
590 and(
591 inArray(pullRequests.repositoryId, repoIds),
592 eq(pullRequests.state, "open")
593 )
594 );
595 counts.reviewing = reviewing.length;
596 counts.awaitingAi = rows.filter(
597 (r) => r.aiVerdict === "pending"
598 ).length;
599 counts.autoMergeable = rows.filter((r) => r.autoMergeable).length;
600 }
601 } catch {
602 /* keep 0s */
603 }
604
605 return c.html(
606 <Layout title="Pull requests · Gluecron" user={user}>
607 <div class="pdash-wrap">
608 <section class="pdash-hero">
609 <div class="pdash-orb" aria-hidden="true" />
610 <div class="pdash-hero-inner">
611 <div class="pdash-eyebrow">
612 PR command center · live ·{" "}
613 <span style="color:var(--accent);font-weight:600">{user.username}</span>
614 </div>
615 <h1 class="pdash-title">
616 <span class="pdash-title-grad">Your PRs.</span>
617 </h1>
618 <p class="pdash-sub">
619 Every PR across every repo you touch — annotated with the
620 things only Gluecron knows. AI review verdict, GateTest scan
621 status, auto-merge eligibility, all live.
622 </p>
623 <div class="pdash-stats">
624 <div class="pdash-stat">
625 <div class="pdash-stat-n">{counts.mine}</div>
626 <div class="pdash-stat-l">Authored</div>
627 </div>
628 <div class="pdash-stat">
629 <div class="pdash-stat-n">{counts.reviewing}</div>
630 <div class="pdash-stat-l">In your repos</div>
631 </div>
632 <div class="pdash-stat">
633 <div class="pdash-stat-n">{counts.awaitingAi}</div>
634 <div class="pdash-stat-l">Awaiting AI</div>
635 </div>
636 <div class="pdash-stat">
637 <div class="pdash-stat-n">{counts.autoMergeable}</div>
638 <div class="pdash-stat-l">Auto-mergeable</div>
639 </div>
640 </div>
641 </div>
642 </section>
643
644 <nav class="pdash-tabs" aria-label="PR filters">
645 <a href="/pulls?filter=mine" class={"pdash-tab " + (filter === "mine" ? "is-active" : "")}>
646 Mine <span class="pdash-tab-count">{counts.mine}</span>
647 </a>
648 <a href="/pulls?filter=reviewing" class={"pdash-tab " + (filter === "reviewing" ? "is-active" : "")}>
649 Reviewing <span class="pdash-tab-count">{counts.reviewing}</span>
650 </a>
651 <a href="/pulls?filter=awaiting-ai" class={"pdash-tab " + (filter === "awaiting-ai" ? "is-active" : "")}>
652 Awaiting AI <span class="pdash-tab-count">{counts.awaitingAi}</span>
653 </a>
654 <a href="/pulls?filter=auto-mergeable" class={"pdash-tab " + (filter === "auto-mergeable" ? "is-active" : "")}>
655 Auto-mergeable <span class="pdash-tab-count">{counts.autoMergeable}</span>
656 </a>
657 </nav>
658
659 {rows.length === 0 ? (
660 <div class="pdash-empty">
661 <h2 class="pdash-empty-title">
662 {filter === "mine"
663 ? "You haven't opened any PRs yet."
664 : filter === "reviewing"
665 ? "Your review queue is clear."
666 : filter === "awaiting-ai"
667 ? "Nothing waiting on AI right now."
668 : "Nothing auto-mergeable right now."}
669 </h2>
670 <p class="pdash-empty-sub">
671 {filter === "mine"
672 ? "Push a branch and open a PR — it'll show up here with its full Gluecron signal."
673 : filter === "reviewing"
674 ? "When someone opens a PR in a repo you own or collaborate on, it'll appear here with AI + GateTest verdicts already attached."
675 : filter === "awaiting-ai"
676 ? "Gluecron auto-reviews every open PR. This list is empty when every PR has a verdict."
677 : "An auto-mergeable PR has a green AI verdict and a green GateTest scan. When one shows up, the merge sweep ships it in ~30s."}
678 </p>
679 {filter !== "mine" && (
680 <a href="/pulls?filter=mine" class="btn">View your authored</a>
681 )}
682 {filter === "mine" && (
683 <a href="/explore" class="btn btn-primary">Browse repos</a>
684 )}
685 </div>
686 ) : (
687 <ul class="pdash-list">
688 {rows.map((r) => {
689 const stateClass = r.pr.isDraft
690 ? "is-draft"
691 : r.pr.state === "merged"
692 ? "is-merged"
693 : r.pr.state === "closed"
694 ? "is-closed"
695 : "is-open";
696 return (
697 <li class="pdash-row">
698 <StateIcon state={r.pr.state} isDraft={r.pr.isDraft} />
699 <div class="pdash-row-main">
700 <h3 class="pdash-row-title">
701 <a href={`/${r.ownerUsername}/${r.repo.name}/pulls/${r.pr.number}`}>
702 {r.pr.title}
703 </a>
704 <span class={"pdash-state-pill " + stateClass}>
705 {r.pr.isDraft ? "Draft" : r.pr.state}
706 </span>
707 <span class="pdash-badges">
708 {r.aiVerdict === "pass" && (
709 <span class="pdash-badge ai-pass" title="AI reviewer approved">
710 <span class="dot" aria-hidden="true" /> AI ✓
711 </span>
712 )}
713 {r.aiVerdict === "fail" && (
714 <span class="pdash-badge ai-fail" title="AI requested changes">
715 <span class="dot" aria-hidden="true" /> AI ✗
716 </span>
717 )}
718 {r.aiVerdict === "pending" && (
719 <span class="pdash-badge ai-pending" title="AI review pending">
720 <span class="dot" aria-hidden="true" /> AI …
721 </span>
722 )}
723 {r.gatetestStatus === "pass" && (
724 <span class="pdash-badge gt-pass" title="GateTest passed">
725 <span class="dot" aria-hidden="true" /> Gate ✓
726 </span>
727 )}
728 {r.gatetestStatus === "fail" && (
729 <span class="pdash-badge gt-fail" title="GateTest failed">
730 <span class="dot" aria-hidden="true" /> Gate ✗
731 </span>
732 )}
733 {r.autoMergeable && (
734 <span class="pdash-badge am-yes" title="Eligible for auto-merge — will ship in ~30s">
735 <span class="dot" aria-hidden="true" /> Auto-merge
736 </span>
737 )}
738 </span>
739 </h3>
740 <div class="pdash-row-meta">
741 <span class="pdash-row-repo">
742 {r.ownerUsername}/{r.repo.name} #{r.pr.number}
743 </span>
744 <span class="sep">·</span>
745 <span>
746 by <strong style="color:var(--text)">{r.authorUsername}</strong>
747 </span>
748 <span class="sep">·</span>
749 <span>updated {relTime(r.pr.updatedAt)}</span>
750 </div>
751 </div>
752 </li>
753 );
754 })}
755 </ul>
756 )}
757 </div>
758 <style dangerouslySetInnerHTML={{ __html: styles }} />
759 </Layout>
760 );
761});
762
763export default pullsDashboard;