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

merge-queue.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.

merge-queue.tsxBlame1039 lines · 2 contributors
a79a9edClaude1/**
2 * Block E5 — Merge queue UI + actions.
3 *
4 * GET /:owner/:repo/queue — queue history + current state
5 * POST /:owner/:repo/pulls/:n/enqueue — enqueue a PR (requireAuth)
6 * POST /:owner/:repo/queue/:id/dequeue — remove entry (owner OR enqueuer)
7 * POST /:owner/:repo/queue/process-next — owner-only: run the head
8 *
9 * The "process-next" handler is v1 — it just re-runs gates against the base
10 * and, if green, merges by updating the base branch ref. A full background
11 * worker is future work; this keeps the feature usable without a daemon.
93812e4Claude12 *
13 * 2026 polish: scoped `.mq-*` class system, gradient hero + section cards
14 * mirror admin-integrations.tsx / admin-ops.tsx. State pills use the same
15 * traffic-light dot pattern as collaborators.tsx. RepoHeader / RepoNav are
16 * left untouched — we only own the content beneath them.
a79a9edClaude17 */
18
19import { Hono } from "hono";
fc623bfccantynz-alt20import { parseIdNumber } from "../lib/route-params";
a79a9edClaude21import { and, eq } from "drizzle-orm";
22import { db } from "../db";
23import {
24 mergeQueueEntries,
25 prComments,
26 pullRequests,
27 repositories,
28 users,
29} from "../db/schema";
30import { Layout } from "../views/layout";
31import { RepoHeader, RepoNav } from "../views/components";
32import { softAuth, requireAuth } from "../middleware/auth";
33import type { AuthEnv } from "../middleware/auth";
34import {
35 enqueuePr,
36 dequeueEntry,
37 listQueueWithPrs,
38 markHeadRunning,
39 completeEntry,
40 peekHead,
41} from "../lib/merge-queue";
42import { runAllGateChecks } from "../lib/gate";
43import { resolveRef, getRepoPath } from "../git/repository";
44import { audit } from "../lib/notify";
45
46const queue = new Hono<AuthEnv>();
47queue.use("*", softAuth);
48
93812e4Claude49/* ─────────────────────────────────────────────────────────────────────────
50 * Scoped CSS — every class prefixed `.mq-*`. Gradient hairline hero, card
51 * sections, status pills + tabular-nums timing. Hard-pinned to the
52 * 1100px content width spec.
53 * ───────────────────────────────────────────────────────────────────── */
54const mqStyles = `
eed4684Claude55 .mq-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
93812e4Claude56
57 /* ─── Hero ─── */
58 .mq-hero {
59 position: relative;
60 margin-bottom: var(--space-5);
61 padding: var(--space-5) var(--space-6);
62 background: var(--bg-elevated);
63 border: 1px solid var(--border);
64 border-radius: 16px;
65 overflow: hidden;
66 }
67 .mq-hero::before {
68 content: '';
69 position: absolute;
70 top: 0; left: 0; right: 0;
71 height: 2px;
6fd5915Claude72 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
93812e4Claude73 opacity: 0.7;
74 pointer-events: none;
75 }
76 .mq-hero-orb {
77 position: absolute;
78 inset: -20% -10% auto auto;
79 width: 380px; height: 380px;
6fd5915Claude80 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
93812e4Claude81 filter: blur(80px);
82 opacity: 0.7;
83 pointer-events: none;
84 z-index: 0;
85 }
86 .mq-hero-inner {
87 position: relative;
88 z-index: 1;
89 display: flex;
90 align-items: flex-end;
91 justify-content: space-between;
92 gap: var(--space-4);
93 flex-wrap: wrap;
94 }
95 .mq-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
96 .mq-eyebrow {
97 display: inline-flex;
98 align-items: center;
99 gap: 8px;
100 text-transform: uppercase;
101 font-family: var(--font-mono);
102 font-size: 11px;
103 letter-spacing: 0.16em;
104 color: var(--text-muted);
105 font-weight: 600;
106 margin-bottom: 10px;
107 }
108 .mq-eyebrow-dot {
109 width: 8px; height: 8px;
110 border-radius: 9999px;
6fd5915Claude111 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
112 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
93812e4Claude113 }
114 .mq-title {
115 font-family: var(--font-display);
116 font-size: clamp(28px, 4vw, 40px);
117 font-weight: 800;
118 letter-spacing: -0.028em;
119 line-height: 1.05;
120 margin: 0 0 var(--space-2);
121 color: var(--text-strong);
122 }
123 .mq-title-grad {
6fd5915Claude124 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
93812e4Claude125 -webkit-background-clip: text;
126 background-clip: text;
127 -webkit-text-fill-color: transparent;
128 color: transparent;
129 }
130 .mq-sub {
131 font-size: 15px;
132 color: var(--text-muted);
133 margin: 0;
134 line-height: 1.5;
135 max-width: 620px;
136 }
137 .mq-hero-cta {
138 display: inline-flex;
139 align-items: center;
140 gap: 6px;
141 padding: 10px 18px;
142 font-size: 13.5px;
143 font-weight: 600;
144 border-radius: 10px;
145 border: 1px solid transparent;
146 cursor: pointer;
147 font: inherit;
148 line-height: 1;
149 white-space: nowrap;
150 text-decoration: none;
151 color: #ffffff;
6fd5915Claude152 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
153 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
93812e4Claude154 transition: transform 120ms ease, box-shadow 120ms ease;
155 }
156 .mq-hero-cta:hover {
157 transform: translateY(-1px);
6fd5915Claude158 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
93812e4Claude159 text-decoration: none;
160 color: #ffffff;
161 }
162
163 /* ─── Banners ─── */
164 .mq-banner {
165 margin-bottom: var(--space-4);
166 padding: 10px 14px;
167 border-radius: 10px;
168 font-size: 13.5px;
169 border: 1px solid var(--border);
170 background: rgba(255,255,255,0.025);
171 color: var(--text);
172 display: flex;
173 align-items: center;
174 gap: 10px;
175 }
176 .mq-banner.is-ok {
177 border-color: rgba(52,211,153,0.40);
178 background: rgba(52,211,153,0.08);
179 color: #bbf7d0;
180 }
181 .mq-banner.is-error {
182 border-color: rgba(248,113,113,0.40);
183 background: rgba(248,113,113,0.08);
184 color: #fecaca;
185 }
186 .mq-banner-dot {
187 width: 8px; height: 8px;
188 border-radius: 9999px;
189 background: currentColor;
190 flex-shrink: 0;
191 }
192
193 /* ─── Base-branch card ─── */
194 .mq-group {
195 margin-bottom: var(--space-5);
196 background: var(--bg-elevated);
197 border: 1px solid var(--border);
198 border-radius: 14px;
199 overflow: hidden;
200 position: relative;
201 }
202 .mq-group::before {
203 content: '';
204 position: absolute;
205 top: 0; left: 0; right: 0;
206 height: 2px;
6fd5915Claude207 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
93812e4Claude208 opacity: 0.45;
209 pointer-events: none;
210 }
211 .mq-group-head {
212 padding: var(--space-4) var(--space-5);
213 border-bottom: 1px solid var(--border);
214 display: flex;
215 align-items: center;
216 justify-content: space-between;
217 gap: var(--space-3);
218 flex-wrap: wrap;
219 }
220 .mq-group-head-text { flex: 1; min-width: 200px; }
221 .mq-group-title {
222 margin: 0;
223 font-family: var(--font-display);
224 font-size: 15.5px;
225 font-weight: 700;
226 letter-spacing: -0.018em;
227 color: var(--text-strong);
228 display: flex;
229 align-items: center;
230 gap: 10px;
231 flex-wrap: wrap;
232 }
233 .mq-group-title-icon {
234 display: inline-flex;
235 align-items: center;
236 justify-content: center;
237 width: 26px; height: 26px;
238 border-radius: 8px;
6fd5915Claude239 background: rgba(91,110,232,0.12);
240 color: #5b6ee8;
241 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
93812e4Claude242 flex-shrink: 0;
243 }
244 .mq-base-chip {
245 display: inline-flex;
246 align-items: center;
247 gap: 6px;
248 padding: 3px 9px;
249 border-radius: 8px;
250 background: var(--bg-tertiary);
251 border: 1px solid var(--border);
252 font-family: var(--font-mono);
253 font-size: 12px;
254 color: var(--text-strong);
255 }
256 .mq-group-count {
257 font-family: var(--font-mono);
258 font-size: 12px;
259 color: var(--text-muted);
260 font-weight: 500;
261 font-variant-numeric: tabular-nums;
262 }
263
264 /* ─── Entry rows ─── */
265 .mq-list { display: flex; flex-direction: column; }
266 .mq-row {
267 display: flex;
268 align-items: flex-start;
269 gap: 14px;
270 padding: 14px var(--space-5);
271 border-top: 1px solid var(--border);
272 }
273 .mq-row:first-child { border-top: none; }
274 .mq-row-body { flex: 1; min-width: 0; }
275 .mq-row-head {
276 display: flex;
277 align-items: center;
278 gap: 10px;
279 flex-wrap: wrap;
280 }
281 .mq-row-link {
282 font-family: var(--font-display);
283 font-weight: 700;
284 font-size: 14.5px;
285 color: var(--text-strong);
286 text-decoration: none;
287 letter-spacing: -0.005em;
288 }
289 .mq-row-link:hover { text-decoration: underline; }
290 .mq-row-num {
291 font-family: var(--font-mono);
292 font-size: 13px;
293 color: var(--text-muted);
294 font-variant-numeric: tabular-nums;
295 margin-right: 2px;
296 }
297 .mq-row-meta {
298 margin-top: 6px;
299 display: flex;
300 align-items: center;
301 gap: 10px;
302 flex-wrap: wrap;
303 font-size: 12px;
304 color: var(--text-muted);
305 font-variant-numeric: tabular-nums;
306 }
307 .mq-row-meta .sep { opacity: 0.45; }
308 .mq-row-meta code {
309 font-family: var(--font-mono);
310 font-size: 11.5px;
311 color: var(--text);
312 background: var(--bg-tertiary);
313 padding: 1px 5px;
314 border-radius: 4px;
315 }
316 .mq-row-err {
317 margin-top: 8px;
318 padding: 8px 10px;
319 background: rgba(248,113,113,0.06);
320 border: 1px solid rgba(248,113,113,0.28);
321 border-radius: 8px;
322 font-family: var(--font-mono);
323 font-size: 12px;
324 color: #fecaca;
325 line-height: 1.45;
326 }
327 .mq-row-action { flex-shrink: 0; }
328 .mq-row-action form { margin: 0; }
329
330 /* ─── State pills ─── */
331 .mq-pill {
332 display: inline-flex;
333 align-items: center;
334 gap: 6px;
335 padding: 3px 9px;
336 border-radius: 9999px;
337 font-size: 11px;
338 font-weight: 600;
339 letter-spacing: 0.04em;
340 text-transform: uppercase;
341 }
342 .mq-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
343 .mq-pill.is-queued {
344 background: rgba(148,163,184,0.16);
345 color: #cbd5e1;
346 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
347 }
348 .mq-pill.is-running {
6fd5915Claude349 background: rgba(95,143,160,0.14);
93812e4Claude350 color: #67e8f9;
6fd5915Claude351 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.32);
93812e4Claude352 }
353 .mq-pill.is-merged {
6fd5915Claude354 background: rgba(91,110,232,0.16);
93812e4Claude355 color: #c4b5fd;
6fd5915Claude356 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
93812e4Claude357 }
358 .mq-pill.is-failed {
359 background: rgba(248,113,113,0.14);
360 color: #fca5a5;
361 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
362 }
363 .mq-pill.is-dequeued {
364 background: rgba(107,114,128,0.18);
365 color: #d1d5db;
366 box-shadow: inset 0 0 0 1px rgba(107,114,128,0.32);
367 }
368
369 /* ─── Buttons ─── */
370 .mq-btn {
371 display: inline-flex;
372 align-items: center;
373 justify-content: center;
374 gap: 6px;
375 padding: 7px 14px;
376 border-radius: 9px;
377 font-size: 12.5px;
378 font-weight: 600;
379 text-decoration: none;
380 border: 1px solid transparent;
381 cursor: pointer;
382 font: inherit;
383 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
384 line-height: 1;
385 white-space: nowrap;
386 }
387 .mq-btn-primary {
6fd5915Claude388 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
93812e4Claude389 color: #ffffff;
6fd5915Claude390 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
93812e4Claude391 }
392 .mq-btn-primary:hover {
393 transform: translateY(-1px);
6fd5915Claude394 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
93812e4Claude395 color: #ffffff;
396 text-decoration: none;
397 }
398 .mq-btn-ghost {
399 background: transparent;
400 color: var(--text);
401 border-color: var(--border-strong);
402 }
403 .mq-btn-ghost:hover {
6fd5915Claude404 background: rgba(91,110,232,0.06);
405 border-color: rgba(91,110,232,0.45);
93812e4Claude406 color: var(--text-strong);
407 text-decoration: none;
408 }
409
410 /* ─── Crumb back link ─── */
411 .mq-crumbs {
412 display: flex;
413 align-items: center;
414 gap: 12px;
415 flex-wrap: wrap;
416 margin-bottom: var(--space-4);
417 font-size: 12.5px;
418 }
419 .mq-crumbs a {
420 display: inline-flex;
421 align-items: center;
422 gap: 5px;
423 padding: 6px 11px;
424 background: rgba(255,255,255,0.025);
425 border: 1px solid var(--border);
426 border-radius: 8px;
427 color: var(--text-muted);
428 text-decoration: none;
429 font-weight: 500;
430 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
431 }
432 .mq-crumbs a:hover {
433 border-color: var(--border-strong);
434 color: var(--text-strong);
435 background: rgba(255,255,255,0.04);
436 text-decoration: none;
437 }
438
439 /* ─── Empty state ─── */
440 .mq-empty {
441 position: relative;
442 overflow: hidden;
443 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
444 text-align: center;
445 background: var(--bg-elevated);
446 border: 1px dashed var(--border-strong);
447 border-radius: 16px;
448 }
449 .mq-empty-orb {
450 position: absolute;
451 inset: -40% 30% auto 30%;
452 height: 280px;
6fd5915Claude453 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
93812e4Claude454 filter: blur(70px);
455 opacity: 0.7;
456 pointer-events: none;
457 z-index: 0;
458 }
459 .mq-empty-inner { position: relative; z-index: 1; }
460 .mq-empty-icon {
461 width: 56px; height: 56px;
462 border-radius: 9999px;
6fd5915Claude463 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
464 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
93812e4Claude465 display: inline-flex;
466 align-items: center;
467 justify-content: center;
468 color: #c4b5fd;
469 margin-bottom: 14px;
470 }
471 .mq-empty-title {
472 font-family: var(--font-display);
473 font-size: 18px;
474 font-weight: 700;
475 margin: 0 0 6px;
476 color: var(--text-strong);
477 }
478 .mq-empty-sub {
479 margin: 0 auto 16px;
480 font-size: 13.5px;
481 color: var(--text-muted);
482 max-width: 460px;
483 line-height: 1.5;
484 }
485`;
486
487function IconQueue() {
488 return (
489 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
490 <line x1="8" y1="6" x2="21" y2="6" />
491 <line x1="8" y1="12" x2="21" y2="12" />
492 <line x1="8" y1="18" x2="21" y2="18" />
493 <line x1="3" y1="6" x2="3.01" y2="6" />
494 <line x1="3" y1="12" x2="3.01" y2="12" />
495 <line x1="3" y1="18" x2="3.01" y2="18" />
496 </svg>
497 );
498}
499function IconBranch() {
500 return (
501 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
502 <line x1="6" y1="3" x2="6" y2="15" />
503 <circle cx="18" cy="6" r="3" />
504 <circle cx="6" cy="18" r="3" />
505 <path d="M18 9a9 9 0 0 1-9 9" />
506 </svg>
507 );
508}
509function IconArrowLeft() {
510 return (
511 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
512 <line x1="19" y1="12" x2="5" y2="12" />
513 <polyline points="12 19 5 12 12 5" />
514 </svg>
515 );
516}
517
a79a9edClaude518async function loadRepo(ownerName: string, repoName: string) {
519 try {
520 const [row] = await db
521 .select({
522 id: repositories.id,
523 name: repositories.name,
524 ownerId: repositories.ownerId,
525 defaultBranch: repositories.defaultBranch,
526 starCount: repositories.starCount,
527 forkCount: repositories.forkCount,
528 })
529 .from(repositories)
530 .innerJoin(users, eq(repositories.ownerId, users.id))
531 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
532 .limit(1);
533 return row || null;
534 } catch {
535 return null;
536 }
537}
538
539function relTime(d: Date | string): string {
540 const t = typeof d === "string" ? new Date(d) : d;
541 const diffMs = Date.now() - t.getTime();
542 const mins = Math.floor(diffMs / 60000);
543 if (mins < 1) return "just now";
544 if (mins < 60) return `${mins}m ago`;
545 const hrs = Math.floor(mins / 60);
546 if (hrs < 24) return `${hrs}h ago`;
547 const days = Math.floor(hrs / 24);
548 if (days < 30) return `${days}d ago`;
549 return t.toLocaleDateString();
550}
551
93812e4Claude552function statePill(state: string) {
553 const cls =
554 state === "running"
555 ? "is-running"
556 : state === "merged"
557 ? "is-merged"
558 : state === "failed"
559 ? "is-failed"
560 : state === "dequeued"
561 ? "is-dequeued"
562 : "is-queued";
563 return (
564 <span class={`mq-pill ${cls}`}>
565 <span class="dot" aria-hidden="true" />
566 {state}
567 </span>
568 );
569}
570
a79a9edClaude571// ---------- Queue list ----------
572
573queue.get("/:owner/:repo/queue", async (c) => {
574 const user = c.get("user");
575 const { owner, repo } = c.req.param();
576 const repoRow = await loadRepo(owner, repo);
577 if (!repoRow) {
578 return c.html(
579 <Layout title="Not found" user={user}>
93812e4Claude580 <div class="mq-wrap">
581 <div class="mq-empty">
582 <div class="mq-empty-orb" aria-hidden="true" />
583 <div class="mq-empty-inner">
584 <div class="mq-empty-icon" aria-hidden="true">
585 <IconQueue />
586 </div>
587 <h2 class="mq-empty-title">Repository not found</h2>
588 <p class="mq-empty-sub">
589 Check the owner and repo name in the URL.
590 </p>
591 </div>
592 </div>
a79a9edClaude593 </div>
93812e4Claude594 <style dangerouslySetInnerHTML={{ __html: mqStyles }} />
a79a9edClaude595 </Layout>,
596 404
597 );
598 }
599
600 const entries = await listQueueWithPrs(repoRow.id);
601 const byBranch = new Map<string, typeof entries>();
602 for (const e of entries) {
603 const arr = byBranch.get(e.baseBranch) || [];
604 arr.push(e);
605 byBranch.set(e.baseBranch, arr);
606 }
607
608 const isOwner = !!user && user.id === repoRow.ownerId;
609 const success = c.req.query("success");
610 const error = c.req.query("error");
611
612 return c.html(
613 <Layout title={`Merge queue — ${owner}/${repo}`} user={user}>
614 <RepoHeader
615 owner={owner}
616 repo={repo}
617 starCount={repoRow.starCount}
618 forkCount={repoRow.forkCount}
619 currentUser={user?.username || null}
620 />
621 <RepoNav owner={owner} repo={repo} active="pulls" />
622
93812e4Claude623 <div class="mq-wrap">
624 <div class="mq-crumbs">
625 <a href={`/${owner}/${repo}/pulls`}>
626 <IconArrowLeft />
627 Back to pull requests
628 </a>
629 </div>
a79a9edClaude630
93812e4Claude631 <section class="mq-hero">
632 <div class="mq-hero-orb" aria-hidden="true" />
633 <div class="mq-hero-inner">
634 <div class="mq-hero-text">
635 <div class="mq-eyebrow">
636 <span class="mq-eyebrow-dot" aria-hidden="true" />
637 Repository · Merge queue
638 </div>
639 <h1 class="mq-title">
640 <span class="mq-title-grad">Serialised merges.</span>
641 </h1>
642 <p class="mq-sub">
643 Queued PRs re-run gates against the latest base before merging.
644 This prevents green-in-isolation, red-after-merge races.
645 </p>
646 </div>
647 <a href={`/${owner}/${repo}/pulls`} class="mq-hero-cta">
648 <IconQueue />
649 Browse PRs
650 </a>
651 </div>
652 </section>
a79a9edClaude653
93812e4Claude654 {success && (
655 <div class="mq-banner is-ok" role="status">
656 <span class="mq-banner-dot" aria-hidden="true" />
657 {decodeURIComponent(success)}
658 </div>
659 )}
660 {error && (
661 <div class="mq-banner is-error" role="alert">
662 <span class="mq-banner-dot" aria-hidden="true" />
663 {decodeURIComponent(error)}
664 </div>
665 )}
a79a9edClaude666
93812e4Claude667 {entries.length === 0 ? (
668 <div class="mq-empty">
669 <div class="mq-empty-orb" aria-hidden="true" />
670 <div class="mq-empty-inner">
671 <div class="mq-empty-icon" aria-hidden="true">
672 <IconQueue />
a79a9edClaude673 </div>
93812e4Claude674 <h2 class="mq-empty-title">Queue is empty</h2>
675 <p class="mq-empty-sub">
676 Enqueue an open, non-draft PR from its pull-request page to
677 start a serialised merge.
678 </p>
679 <a href={`/${owner}/${repo}/pulls`} class="mq-btn mq-btn-primary">
680 Pick a PR to enqueue
681 </a>
682 </div>
683 </div>
684 ) : (
685 Array.from(byBranch.entries()).map(([branch, items]) => {
686 const active = items.filter(
687 (i) => i.state === "queued" || i.state === "running"
688 );
689 return (
690 <section class="mq-group">
691 <header class="mq-group-head">
692 <div class="mq-group-head-text">
693 <h2 class="mq-group-title">
694 <span class="mq-group-title-icon" aria-hidden="true">
695 <IconBranch />
696 </span>
697 Base
698 <span class="mq-base-chip">{branch}</span>
699 <span class="mq-group-count">{active.length} active</span>
700 </h2>
a79a9edClaude701 </div>
93812e4Claude702 {isOwner && active.length > 0 && (
703 <form
704 method="post"
705 action={`/${owner}/${repo}/queue/process-next?base=${encodeURIComponent(branch)}`}
706 >
707 <button type="submit" class="mq-btn mq-btn-primary">
708 Process next
709 </button>
710 </form>
711 )}
712 </header>
713 <div class="mq-list">
714 {items.map((it) => (
715 <div class="mq-row">
716 <div class="mq-row-body">
717 <div class="mq-row-head">
718 {statePill(it.state)}
719 {it.prNumber != null ? (
720 <a
721 href={`/${owner}/${repo}/pulls/${it.prNumber}`}
722 class="mq-row-link"
723 >
724 <span class="mq-row-num">#{it.prNumber}</span>
725 {it.prTitle}
726 </a>
727 ) : (
728 <span style="color:var(--text-muted)">(PR gone)</span>
729 )}
730 </div>
731 <div class="mq-row-meta">
732 <span>pos {it.position}</span>
733 {it.prHeadBranch && (
734 <>
735 <span class="sep">·</span>
736 <code>{it.prHeadBranch}</code>
737 </>
738 )}
739 <span class="sep">·</span>
740 <span>enqueued {relTime(it.enqueuedAt)}</span>
741 {it.startedAt && (
742 <>
743 <span class="sep">·</span>
744 <span>started {relTime(it.startedAt)}</span>
745 </>
746 )}
747 {it.finishedAt && (
748 <>
749 <span class="sep">·</span>
750 <span>finished {relTime(it.finishedAt)}</span>
751 </>
752 )}
753 </div>
754 {it.errorMessage && (
755 <div class="mq-row-err">{it.errorMessage}</div>
756 )}
757 </div>
758 {(it.state === "queued" || it.state === "running") &&
759 user &&
760 (isOwner || user.id === it.enqueuedBy) && (
761 <div class="mq-row-action">
762 <form
763 method="post"
764 action={`/${owner}/${repo}/queue/${it.id}/dequeue`}
765 onsubmit="return confirm('Remove from queue?')"
766 >
767 <button type="submit" class="mq-btn mq-btn-ghost">
768 Remove
769 </button>
770 </form>
771 </div>
772 )}
773 </div>
774 ))}
a79a9edClaude775 </div>
93812e4Claude776 </section>
777 );
778 })
779 )}
780 </div>
781 <style dangerouslySetInnerHTML={{ __html: mqStyles }} />
a79a9edClaude782 </Layout>
783 );
784});
785
786// ---------- Enqueue a PR ----------
787
788queue.post("/:owner/:repo/pulls/:number/enqueue", requireAuth, async (c) => {
789 const user = c.get("user")!;
790 const { owner, repo } = c.req.param();
fc623bfccantynz-alt791 const prNum = (parseIdNumber(c.req.param("number")) ?? -1);
a79a9edClaude792 const repoRow = await loadRepo(owner, repo);
793 if (!repoRow) return c.notFound();
794
795 const [pr] = await db
796 .select()
797 .from(pullRequests)
798 .where(
799 and(
800 eq(pullRequests.repositoryId, repoRow.id),
801 eq(pullRequests.number, prNum)
802 )
803 )
804 .limit(1);
805 if (!pr || pr.state !== "open") {
806 return c.redirect(
807 `/${owner}/${repo}/pulls/${prNum}?error=${encodeURIComponent(
808 "PR must be open to enqueue."
809 )}`
810 );
811 }
812 if (pr.isDraft) {
813 return c.redirect(
814 `/${owner}/${repo}/pulls/${prNum}?error=${encodeURIComponent(
815 "Cannot enqueue a draft PR."
816 )}`
817 );
818 }
819
820 const result = await enqueuePr({
821 repositoryId: repoRow.id,
822 pullRequestId: pr.id,
823 baseBranch: pr.baseBranch,
824 enqueuedBy: user.id,
825 });
826 if (!result.ok) {
827 return c.redirect(
828 `/${owner}/${repo}/pulls/${prNum}?error=${encodeURIComponent(
829 result.reason || "Enqueue failed"
830 )}`
831 );
832 }
833
834 await audit({
835 userId: user.id,
836 repositoryId: repoRow.id,
837 action: "merge_queue.enqueue",
838 targetId: pr.id,
839 metadata: { prNumber: pr.number, baseBranch: pr.baseBranch },
840 });
841
842 return c.redirect(
843 `/${owner}/${repo}/queue?success=${encodeURIComponent(
844 `PR #${pr.number} enqueued`
845 )}`
846 );
847});
848
849// ---------- Dequeue ----------
850
851queue.post("/:owner/:repo/queue/:id/dequeue", requireAuth, async (c) => {
852 const user = c.get("user")!;
853 const { owner, repo, id } = c.req.param();
854 const repoRow = await loadRepo(owner, repo);
855 if (!repoRow) return c.notFound();
856
857 const [entry] = await db
858 .select()
859 .from(mergeQueueEntries)
860 .where(
861 and(
862 eq(mergeQueueEntries.id, id),
863 eq(mergeQueueEntries.repositoryId, repoRow.id)
864 )
865 )
866 .limit(1);
867 if (!entry) {
868 return c.redirect(
869 `/${owner}/${repo}/queue?error=${encodeURIComponent("Entry not found")}`
870 );
871 }
872 const isOwner = user.id === repoRow.ownerId;
873 if (!isOwner && entry.enqueuedBy !== user.id) {
874 return c.redirect(
875 `/${owner}/${repo}/queue?error=${encodeURIComponent(
876 "Only the enqueuer or a repo owner can remove this entry."
877 )}`
878 );
879 }
880
881 const ok = await dequeueEntry(id);
882 if (!ok) {
883 return c.redirect(
884 `/${owner}/${repo}/queue?error=${encodeURIComponent("Could not remove entry")}`
885 );
886 }
887
888 await audit({
889 userId: user.id,
890 repositoryId: repoRow.id,
891 action: "merge_queue.dequeue",
892 targetId: entry.pullRequestId,
893 });
894
895 return c.redirect(
896 `/${owner}/${repo}/queue?success=${encodeURIComponent("Entry removed")}`
897 );
898});
899
900// ---------- Process next ----------
901
902queue.post("/:owner/:repo/queue/process-next", requireAuth, async (c) => {
903 const user = c.get("user")!;
904 const { owner, repo } = c.req.param();
905 const base = c.req.query("base");
906 const repoRow = await loadRepo(owner, repo);
907 if (!repoRow) return c.notFound();
908 if (repoRow.ownerId !== user.id) {
909 return c.redirect(
910 `/${owner}/${repo}/queue?error=${encodeURIComponent(
911 "Only repo owners can process the queue."
912 )}`
913 );
914 }
915
916 const targetBase = base || repoRow.defaultBranch || "main";
917 const head = await peekHead(repoRow.id, targetBase);
918 if (!head) {
919 return c.redirect(
920 `/${owner}/${repo}/queue?error=${encodeURIComponent(
921 `No queued entries for base ${targetBase}`
922 )}`
923 );
924 }
925
926 const started = await markHeadRunning(repoRow.id, targetBase);
927 if (!started) {
928 return c.redirect(
929 `/${owner}/${repo}/queue?error=${encodeURIComponent(
930 "Could not transition head to running"
931 )}`
932 );
933 }
934
935 // Re-run gates against latest base.
936 const [pr] = await db
937 .select()
938 .from(pullRequests)
939 .where(eq(pullRequests.id, started.pullRequestId))
940 .limit(1);
941 if (!pr) {
942 await completeEntry(started.id, "failed", "Pull request no longer exists.");
943 return c.redirect(
944 `/${owner}/${repo}/queue?error=${encodeURIComponent("PR vanished")}`
945 );
946 }
947 if (pr.state !== "open") {
948 await completeEntry(started.id, "failed", "Pull request is no longer open.");
949 return c.redirect(
950 `/${owner}/${repo}/queue?error=${encodeURIComponent("PR is no longer open")}`
951 );
952 }
953
954 const headSha = await resolveRef(owner, repo, pr.headBranch);
955 if (!headSha) {
956 await completeEntry(started.id, "failed", "Head branch not found.");
957 return c.redirect(
958 `/${owner}/${repo}/queue?error=${encodeURIComponent("Head branch not found")}`
959 );
960 }
961
962 const gateResult = await runAllGateChecks(
963 owner,
964 repo,
965 pr.baseBranch,
966 pr.headBranch,
967 headSha,
968 true
969 );
970 const hardFailures = gateResult.checks.filter(
971 (check) => !check.passed && check.name !== "Merge check"
972 );
973 if (hardFailures.length > 0) {
974 const msg = hardFailures
975 .map((f) => `${f.name}: ${f.details}`)
976 .join("; ");
977 await completeEntry(started.id, "failed", msg);
978 try {
979 await db.insert(prComments).values({
980 pullRequestId: pr.id,
981 authorId: user.id,
982 body: `**Merge queue:** gates failed on latest base — ${msg}`,
983 isAiReview: false,
984 });
985 } catch {}
986 return c.redirect(
987 `/${owner}/${repo}/queue?error=${encodeURIComponent(msg)}`
988 );
989 }
990
991 // Gates passed — merge by updating base ref to head.
992 const repoDir = getRepoPath(owner, repo);
993 const proc = Bun.spawn(
994 [
995 "git",
996 "update-ref",
997 `refs/heads/${pr.baseBranch}`,
998 `refs/heads/${pr.headBranch}`,
999 ],
1000 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1001 );
1002 const exit = await proc.exited;
1003 if (exit !== 0) {
1004 await completeEntry(started.id, "failed", "update-ref failed");
1005 return c.redirect(
1006 `/${owner}/${repo}/queue?error=${encodeURIComponent(
1007 "Merge failed — unable to update base ref"
1008 )}`
1009 );
1010 }
1011
1012 await db
1013 .update(pullRequests)
1014 .set({
1015 state: "merged",
1016 mergedAt: new Date(),
1017 mergedBy: user.id,
1018 updatedAt: new Date(),
1019 })
1020 .where(eq(pullRequests.id, pr.id));
1021
1022 await completeEntry(started.id, "merged");
1023
1024 await audit({
1025 userId: user.id,
1026 repositoryId: repoRow.id,
1027 action: "merge_queue.merged",
1028 targetId: pr.id,
1029 metadata: { prNumber: pr.number, baseBranch: pr.baseBranch },
1030 });
1031
1032 return c.redirect(
1033 `/${owner}/${repo}/queue?success=${encodeURIComponent(
1034 `PR #${pr.number} merged via queue`
1035 )}`
1036 );
1037});
1038
1039export default queue;