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.tsxBlame491 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.
12 */
13
14import { Hono } from "hono";
15import { and, eq } from "drizzle-orm";
16import { db } from "../db";
17import {
18 mergeQueueEntries,
19 prComments,
20 pullRequests,
21 repositories,
22 users,
23} from "../db/schema";
24import { Layout } from "../views/layout";
25import { RepoHeader, RepoNav } from "../views/components";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import {
29 enqueuePr,
30 dequeueEntry,
31 listQueueWithPrs,
32 markHeadRunning,
33 completeEntry,
34 peekHead,
35} from "../lib/merge-queue";
36import { runAllGateChecks } from "../lib/gate";
37import { resolveRef, getRepoPath } from "../git/repository";
38import { audit } from "../lib/notify";
39
40const queue = new Hono<AuthEnv>();
41queue.use("*", softAuth);
42
43async function loadRepo(ownerName: string, repoName: string) {
44 try {
45 const [row] = await db
46 .select({
47 id: repositories.id,
48 name: repositories.name,
49 ownerId: repositories.ownerId,
50 defaultBranch: repositories.defaultBranch,
51 starCount: repositories.starCount,
52 forkCount: repositories.forkCount,
53 })
54 .from(repositories)
55 .innerJoin(users, eq(repositories.ownerId, users.id))
56 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
57 .limit(1);
58 return row || null;
59 } catch {
60 return null;
61 }
62}
63
64function relTime(d: Date | string): string {
65 const t = typeof d === "string" ? new Date(d) : d;
66 const diffMs = Date.now() - t.getTime();
67 const mins = Math.floor(diffMs / 60000);
68 if (mins < 1) return "just now";
69 if (mins < 60) return `${mins}m ago`;
70 const hrs = Math.floor(mins / 60);
71 if (hrs < 24) return `${hrs}h ago`;
72 const days = Math.floor(hrs / 24);
73 if (days < 30) return `${days}d ago`;
74 return t.toLocaleDateString();
75}
76
77// ---------- Queue list ----------
78
79queue.get("/:owner/:repo/queue", async (c) => {
80 const user = c.get("user");
81 const { owner, repo } = c.req.param();
82 const repoRow = await loadRepo(owner, repo);
83 if (!repoRow) {
84 return c.html(
85 <Layout title="Not found" user={user}>
86 <div class="empty-state">
87 <h2>Repository not found</h2>
88 </div>
89 </Layout>,
90 404
91 );
92 }
93
94 const entries = await listQueueWithPrs(repoRow.id);
95 const byBranch = new Map<string, typeof entries>();
96 for (const e of entries) {
97 const arr = byBranch.get(e.baseBranch) || [];
98 arr.push(e);
99 byBranch.set(e.baseBranch, arr);
100 }
101
102 const isOwner = !!user && user.id === repoRow.ownerId;
103 const success = c.req.query("success");
104 const error = c.req.query("error");
105
106 const stateBadge = (s: string) => {
107 const style: Record<string, string> = {
108 queued: "background:#30363d;color:#c9d1d9",
109 running: "background:#1f6feb;color:white",
110 merged: "background:#8957e5;color:white",
111 failed: "background:#da3633;color:white",
112 dequeued: "background:#484f58;color:#c9d1d9",
113 };
114 return (
115 <span
116 style={`${style[s] || style.queued};padding:2px 8px;border-radius:3px;font-size:11px;text-transform:uppercase;font-weight:600`}
117 >
118 {s}
119 </span>
120 );
121 };
122
123 return c.html(
124 <Layout title={`Merge queue — ${owner}/${repo}`} user={user}>
125 <RepoHeader
126 owner={owner}
127 repo={repo}
128 starCount={repoRow.starCount}
129 forkCount={repoRow.forkCount}
130 currentUser={user?.username || null}
131 />
132 <RepoNav owner={owner} repo={repo} active="pulls" />
133
134 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
135 <h3>Merge queue</h3>
136 <a href={`/${owner}/${repo}/pulls`} class="btn btn-sm">
137 Back to PRs
138 </a>
139 </div>
140
141 <p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
142 Serialised merges: PRs queued here re-run gates against the latest base
143 before being merged. This prevents green-in-isolation / red-after-merge
144 races.
145 </p>
146
147 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
148 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
149
150 {entries.length === 0 ? (
151 <div class="empty-state">
152 <p>Queue is empty. Enqueue a PR from the pull-request page.</p>
153 </div>
154 ) : (
155 Array.from(byBranch.entries()).map(([branch, items]) => {
156 const active = items.filter(
157 (i) => i.state === "queued" || i.state === "running"
158 );
159 return (
160 <div class="panel" style="margin-bottom:20px;overflow:hidden">
161 <div style="padding:12px 14px;background:var(--bg-tertiary);display:flex;justify-content:space-between;align-items:center">
162 <div style="font-weight:600">
163 Base: <code>{branch}</code>{" "}
164 <span style="font-size:12px;color:var(--text-muted);font-weight:400;margin-left:8px">
165 {active.length} active
166 </span>
167 </div>
168 {isOwner && active.length > 0 && (
169 <form
cce7944Claude170 method="post"
a79a9edClaude171 action={`/${owner}/${repo}/queue/process-next?base=${encodeURIComponent(branch)}`}
172 >
173 <button type="submit" class="btn btn-sm btn-primary">
174 Process next
175 </button>
176 </form>
177 )}
178 </div>
179 {items.map((it) => (
180 <div
181 class="panel-item"
182 style="justify-content:space-between;align-items:flex-start"
183 >
184 <div style="flex:1;min-width:0">
185 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
186 {stateBadge(it.state)}
187 {it.prNumber != null ? (
188 <a
189 href={`/${owner}/${repo}/pulls/${it.prNumber}`}
190 style="font-weight:600"
191 >
192 #{it.prNumber} {it.prTitle}
193 </a>
194 ) : (
195 <span style="color:var(--text-muted)">(PR gone)</span>
196 )}
197 </div>
198 <div style="font-size:12px;color:var(--text-muted);margin-top:4px">
199 pos {it.position} ·{" "}
200 {it.prHeadBranch ? <code>{it.prHeadBranch}</code> : ""} ·
201 enqueued {relTime(it.enqueuedAt)}
202 {it.startedAt
203 ? ` · started ${relTime(it.startedAt)}`
204 : ""}
205 {it.finishedAt
206 ? ` · finished ${relTime(it.finishedAt)}`
207 : ""}
208 </div>
209 {it.errorMessage && (
210 <div style="font-size:12px;color:var(--red);margin-top:4px">
211 {it.errorMessage}
212 </div>
213 )}
214 </div>
215 {(it.state === "queued" || it.state === "running") &&
216 user &&
217 (isOwner || user.id === it.enqueuedBy) && (
218 <form
cce7944Claude219 method="post"
a79a9edClaude220 action={`/${owner}/${repo}/queue/${it.id}/dequeue`}
221 onsubmit="return confirm('Remove from queue?')"
222 >
223 <button type="submit" class="btn btn-sm">
224 Remove
225 </button>
226 </form>
227 )}
228 </div>
229 ))}
230 </div>
231 );
232 })
233 )}
234 </Layout>
235 );
236});
237
238// ---------- Enqueue a PR ----------
239
240queue.post("/:owner/:repo/pulls/:number/enqueue", requireAuth, async (c) => {
241 const user = c.get("user")!;
242 const { owner, repo } = c.req.param();
243 const prNum = parseInt(c.req.param("number"), 10);
244 const repoRow = await loadRepo(owner, repo);
245 if (!repoRow) return c.notFound();
246
247 const [pr] = await db
248 .select()
249 .from(pullRequests)
250 .where(
251 and(
252 eq(pullRequests.repositoryId, repoRow.id),
253 eq(pullRequests.number, prNum)
254 )
255 )
256 .limit(1);
257 if (!pr || pr.state !== "open") {
258 return c.redirect(
259 `/${owner}/${repo}/pulls/${prNum}?error=${encodeURIComponent(
260 "PR must be open to enqueue."
261 )}`
262 );
263 }
264 if (pr.isDraft) {
265 return c.redirect(
266 `/${owner}/${repo}/pulls/${prNum}?error=${encodeURIComponent(
267 "Cannot enqueue a draft PR."
268 )}`
269 );
270 }
271
272 const result = await enqueuePr({
273 repositoryId: repoRow.id,
274 pullRequestId: pr.id,
275 baseBranch: pr.baseBranch,
276 enqueuedBy: user.id,
277 });
278 if (!result.ok) {
279 return c.redirect(
280 `/${owner}/${repo}/pulls/${prNum}?error=${encodeURIComponent(
281 result.reason || "Enqueue failed"
282 )}`
283 );
284 }
285
286 await audit({
287 userId: user.id,
288 repositoryId: repoRow.id,
289 action: "merge_queue.enqueue",
290 targetId: pr.id,
291 metadata: { prNumber: pr.number, baseBranch: pr.baseBranch },
292 });
293
294 return c.redirect(
295 `/${owner}/${repo}/queue?success=${encodeURIComponent(
296 `PR #${pr.number} enqueued`
297 )}`
298 );
299});
300
301// ---------- Dequeue ----------
302
303queue.post("/:owner/:repo/queue/:id/dequeue", requireAuth, async (c) => {
304 const user = c.get("user")!;
305 const { owner, repo, id } = c.req.param();
306 const repoRow = await loadRepo(owner, repo);
307 if (!repoRow) return c.notFound();
308
309 const [entry] = await db
310 .select()
311 .from(mergeQueueEntries)
312 .where(
313 and(
314 eq(mergeQueueEntries.id, id),
315 eq(mergeQueueEntries.repositoryId, repoRow.id)
316 )
317 )
318 .limit(1);
319 if (!entry) {
320 return c.redirect(
321 `/${owner}/${repo}/queue?error=${encodeURIComponent("Entry not found")}`
322 );
323 }
324 const isOwner = user.id === repoRow.ownerId;
325 if (!isOwner && entry.enqueuedBy !== user.id) {
326 return c.redirect(
327 `/${owner}/${repo}/queue?error=${encodeURIComponent(
328 "Only the enqueuer or a repo owner can remove this entry."
329 )}`
330 );
331 }
332
333 const ok = await dequeueEntry(id);
334 if (!ok) {
335 return c.redirect(
336 `/${owner}/${repo}/queue?error=${encodeURIComponent("Could not remove entry")}`
337 );
338 }
339
340 await audit({
341 userId: user.id,
342 repositoryId: repoRow.id,
343 action: "merge_queue.dequeue",
344 targetId: entry.pullRequestId,
345 });
346
347 return c.redirect(
348 `/${owner}/${repo}/queue?success=${encodeURIComponent("Entry removed")}`
349 );
350});
351
352// ---------- Process next ----------
353
354queue.post("/:owner/:repo/queue/process-next", requireAuth, async (c) => {
355 const user = c.get("user")!;
356 const { owner, repo } = c.req.param();
357 const base = c.req.query("base");
358 const repoRow = await loadRepo(owner, repo);
359 if (!repoRow) return c.notFound();
360 if (repoRow.ownerId !== user.id) {
361 return c.redirect(
362 `/${owner}/${repo}/queue?error=${encodeURIComponent(
363 "Only repo owners can process the queue."
364 )}`
365 );
366 }
367
368 const targetBase = base || repoRow.defaultBranch || "main";
369 const head = await peekHead(repoRow.id, targetBase);
370 if (!head) {
371 return c.redirect(
372 `/${owner}/${repo}/queue?error=${encodeURIComponent(
373 `No queued entries for base ${targetBase}`
374 )}`
375 );
376 }
377
378 const started = await markHeadRunning(repoRow.id, targetBase);
379 if (!started) {
380 return c.redirect(
381 `/${owner}/${repo}/queue?error=${encodeURIComponent(
382 "Could not transition head to running"
383 )}`
384 );
385 }
386
387 // Re-run gates against latest base.
388 const [pr] = await db
389 .select()
390 .from(pullRequests)
391 .where(eq(pullRequests.id, started.pullRequestId))
392 .limit(1);
393 if (!pr) {
394 await completeEntry(started.id, "failed", "Pull request no longer exists.");
395 return c.redirect(
396 `/${owner}/${repo}/queue?error=${encodeURIComponent("PR vanished")}`
397 );
398 }
399 if (pr.state !== "open") {
400 await completeEntry(started.id, "failed", "Pull request is no longer open.");
401 return c.redirect(
402 `/${owner}/${repo}/queue?error=${encodeURIComponent("PR is no longer open")}`
403 );
404 }
405
406 const headSha = await resolveRef(owner, repo, pr.headBranch);
407 if (!headSha) {
408 await completeEntry(started.id, "failed", "Head branch not found.");
409 return c.redirect(
410 `/${owner}/${repo}/queue?error=${encodeURIComponent("Head branch not found")}`
411 );
412 }
413
414 const gateResult = await runAllGateChecks(
415 owner,
416 repo,
417 pr.baseBranch,
418 pr.headBranch,
419 headSha,
420 true
421 );
422 const hardFailures = gateResult.checks.filter(
423 (check) => !check.passed && check.name !== "Merge check"
424 );
425 if (hardFailures.length > 0) {
426 const msg = hardFailures
427 .map((f) => `${f.name}: ${f.details}`)
428 .join("; ");
429 await completeEntry(started.id, "failed", msg);
430 try {
431 await db.insert(prComments).values({
432 pullRequestId: pr.id,
433 authorId: user.id,
434 body: `**Merge queue:** gates failed on latest base — ${msg}`,
435 isAiReview: false,
436 });
437 } catch {}
438 return c.redirect(
439 `/${owner}/${repo}/queue?error=${encodeURIComponent(msg)}`
440 );
441 }
442
443 // Gates passed — merge by updating base ref to head.
444 const repoDir = getRepoPath(owner, repo);
445 const proc = Bun.spawn(
446 [
447 "git",
448 "update-ref",
449 `refs/heads/${pr.baseBranch}`,
450 `refs/heads/${pr.headBranch}`,
451 ],
452 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
453 );
454 const exit = await proc.exited;
455 if (exit !== 0) {
456 await completeEntry(started.id, "failed", "update-ref failed");
457 return c.redirect(
458 `/${owner}/${repo}/queue?error=${encodeURIComponent(
459 "Merge failed — unable to update base ref"
460 )}`
461 );
462 }
463
464 await db
465 .update(pullRequests)
466 .set({
467 state: "merged",
468 mergedAt: new Date(),
469 mergedBy: user.id,
470 updatedAt: new Date(),
471 })
472 .where(eq(pullRequests.id, pr.id));
473
474 await completeEntry(started.id, "merged");
475
476 await audit({
477 userId: user.id,
478 repositoryId: repoRow.id,
479 action: "merge_queue.merged",
480 targetId: pr.id,
481 metadata: { prNumber: pr.number, baseBranch: pr.baseBranch },
482 });
483
484 return c.redirect(
485 `/${owner}/${repo}/queue?success=${encodeURIComponent(
486 `PR #${pr.number} merged via queue`
487 )}`
488 );
489});
490
491export default queue;