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