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

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