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.
| a79a9ed | 1 | /** |
| 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 | ||
| 14 | import { Hono } from "hono"; | |
| 15 | import { and, eq } from "drizzle-orm"; | |
| 16 | import { db } from "../db"; | |
| 17 | import { | |
| 18 | mergeQueueEntries, | |
| 19 | prComments, | |
| 20 | pullRequests, | |
| 21 | repositories, | |
| 22 | users, | |
| 23 | } from "../db/schema"; | |
| 24 | import { Layout } from "../views/layout"; | |
| 25 | import { RepoHeader, RepoNav } from "../views/components"; | |
| 26 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 27 | import type { AuthEnv } from "../middleware/auth"; | |
| 28 | import { | |
| 29 | enqueuePr, | |
| 30 | dequeueEntry, | |
| 31 | listQueueWithPrs, | |
| 32 | markHeadRunning, | |
| 33 | completeEntry, | |
| 34 | peekHead, | |
| 35 | } from "../lib/merge-queue"; | |
| 36 | import { runAllGateChecks } from "../lib/gate"; | |
| 37 | import { resolveRef, getRepoPath } from "../git/repository"; | |
| 38 | import { audit } from "../lib/notify"; | |
| 39 | ||
| 40 | const queue = new Hono<AuthEnv>(); | |
| 41 | queue.use("*", softAuth); | |
| 42 | ||
| 43 | async 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 | ||
| 64 | function 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 | ||
| 79 | queue.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 | |
| 9837657 | 170 | method="post" |
| a79a9ed | 171 | 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 | |
| 9837657 | 219 | method="post" |
| a79a9ed | 220 | 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 | ||
| 240 | queue.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 | ||
| 303 | queue.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 | ||
| 354 | queue.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 | ||
| 491 | export default queue; |