+ )}
+ >
+ );
+ })}
+
+ );
+}
diff --git a/cloudflare/test/queue.test.ts b/cloudflare/test/queue.test.ts
new file mode 100644
index 00000000..9f13862c
--- /dev/null
+++ b/cloudflare/test/queue.test.ts
@@ -0,0 +1,226 @@
+import { env } from "cloudflare:workers";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { app } from "../src/index";
+import { type Comment, type OpenPull, DROP_WINDOW, landingQueues } from "../src/github/queue";
+import { privateKey } from "./github-key";
+import { origin } from "./login-fixtures";
+import { maintainerOf } from "./project-fixtures";
+
+const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey };
+const now = Date.parse("2026-11-07T12:00:00Z");
+const since = now - DROP_WINDOW;
+const hours = (n: number) => new Date(now - n * 3_600_000).toISOString();
+
+function pull(number: number, fields: Partial = {}): OpenPull {
+ return {
+ number,
+ title: `PR ${number}`,
+ url: `https://github.com/o/r/pull/${number}`,
+ baseRefName: "main",
+ isMergeQueueEnabled: false,
+ autoMergeRequest: null,
+ mergeQueueEntry: null,
+ commits: { nodes: [{ commit: { checkSuites: { nodes: [] } } }] },
+ ...fields,
+ };
+}
+const check = (status: string, title: string, detailsUrl: string | null = null) => ({
+ commits: {
+ nodes: [{ commit: { checkSuites: { nodes: [{ checkRuns: { nodes: [{ status, title, detailsUrl }] } }] } } }],
+ },
+});
+function said(number: number, pick: string, kind: string, at: string, text = "said", bot = true): Comment {
+ return {
+ html_url: `https://github.com/o/r/pull/${number}#${kind}-${at}`,
+ issue_url: `https://api.github.com/repos/o/r/issues/${number}`,
+ body: `quest land watch: ${text}\n\nMore.\n\n`,
+ created_at: at,
+ author_association: "NONE",
+ user: { type: bot ? "Bot" : "User" },
+ };
+}
+
+describe("landing queues", () => {
+ it("orders GitHub's queue by position, links a build's run, then lists picks waiting to join", () => {
+ const queues = landingQueues(
+ [
+ pull(3, { isMergeQueueEnabled: true, autoMergeRequest: { enabledAt: hours(1) } }),
+ pull(2, {
+ isMergeQueueEnabled: true,
+ mergeQueueEntry: { position: 2, state: "QUEUED", headCommit: null },
+ }),
+ pull(1, {
+ isMergeQueueEnabled: true,
+ mergeQueueEntry: {
+ position: 1,
+ state: "AWAITING_CHECKS",
+ headCommit: {
+ statusCheckRollup: {
+ contexts: {
+ nodes: [
+ {
+ __typename: "CheckRun",
+ status: "COMPLETED",
+ detailsUrl: "https://example.test/review",
+ },
+ {
+ __typename: "CheckRun",
+ status: "IN_PROGRESS",
+ detailsUrl: "https://github.com/o/r/actions/runs/9/job/10",
+ },
+ ],
+ },
+ },
+ },
+ },
+ }),
+ ],
+ [],
+ [],
+ since,
+ );
+ expect(queues).toEqual([
+ {
+ base: "main",
+ mode: "merge-queue",
+ drops: [],
+ entries: [
+ expect.objectContaining({
+ number: 1,
+ status: "Building",
+ build: { url: "https://github.com/o/r/actions/runs/9" },
+ }),
+ expect.objectContaining({ number: 2, status: "Queued", build: null }),
+ expect.objectContaining({ number: 3, status: "Waiting on its checks to join", build: null }),
+ ],
+ },
+ ]);
+ });
+
+ it("orders the own queue by when each pick was armed, with its queue check's place and build", () => {
+ const [queue] = landingQueues(
+ [
+ pull(5, { autoMergeRequest: { enabledAt: hours(1) }, ...check("COMPLETED", "Removed from the queue") }),
+ pull(4, { autoMergeRequest: { enabledAt: hours(2) }, ...check("QUEUED", "2nd of 2") }),
+ pull(6, {
+ autoMergeRequest: { enabledAt: hours(3) },
+ ...check("IN_PROGRESS", "Building on main", "https://github.com/o/r/actions/runs/7"),
+ }),
+ pull(7),
+ ],
+ [],
+ [],
+ since,
+ );
+ expect(queue!.mode).toBe("own");
+ expect(queue!.entries.map((e) => [e.number, e.status, e.build])).toEqual([
+ [6, "Building on main", { url: "https://github.com/o/r/actions/runs/7" }],
+ [4, "2nd of 2", null],
+ [5, "Waiting on its checks", null],
+ ]);
+ });
+
+ it("keeps each base's queue apart", () => {
+ const queues = landingQueues(
+ [
+ pull(1, { baseRefName: "release", autoMergeRequest: { enabledAt: hours(1) } }),
+ pull(2, {
+ isMergeQueueEnabled: true,
+ mergeQueueEntry: { position: 1, state: "QUEUED", headCommit: null },
+ }),
+ ],
+ [],
+ [],
+ since,
+ );
+ expect(queues.map((q) => [q.base, q.mode, q.entries.map((e) => e.number)])).toEqual([
+ ["main", "merge-queue", [2]],
+ ["release", "own", [1]],
+ ]);
+ });
+
+ it("pairs each drop with the watcher's next word on that pick, newest drop first", () => {
+ const queues = landingQueues(
+ [pull(1), pull(2)],
+ [{ number: 3, title: "PR 3", url: "u3", baseRefName: "release", isMergeQueueEnabled: false }],
+ [
+ said(
+ 1,
+ "p1",
+ "removed",
+ hours(5),
+ "the queue removed this pull request because it conflicts (`merge_conflict`).",
+ ),
+ said(1, "p1", "requeued", hours(4), "re-enqueued this pull request"),
+ said(1, "p1", "removed", hours(3), "removed again (`failed_checks`)."),
+ said(1, "p0", "handback", hours(2), "an older pick's handback"),
+ said(2, "p2", "removed", hours(30), "too old (`failed_checks`)."),
+ said(2, "p2", "handback", hours(1), "after a drop outside the window"),
+ said(1, "p1", "removed", hours(1), "forged (`manual`).", false),
+ said(3, "p3", "removed", hours(2), "closed since (`checks_timed_out`)."),
+ said(3, "p3", "handback", hours(1), "handed back"),
+ ],
+ since,
+ );
+ const main = queues.find((q) => q.base === "main")!;
+ expect(
+ main.drops.map((d) => [d.pull.number, d.reason, d.outcome?.kind ?? null, d.outcome?.text ?? null]),
+ ).toEqual([
+ [1, "failed_checks", null, null],
+ [1, "merge_conflict", "requeued", "re-enqueued this pull request"],
+ ]);
+ expect(main.drops[1]!.text).toBe(
+ "the queue removed this pull request because it conflicts (`merge_conflict`).",
+ );
+ expect(queues.find((q) => q.base === "release")).toMatchObject({
+ mode: "own",
+ entries: [],
+ drops: [{ pull: { number: 3 }, reason: "checks_timed_out", outcome: { kind: "handback" } }],
+ });
+ });
+});
+
+describe("landing queue page", () => {
+ afterEach(() => vi.restoreAllMocks());
+ const queuePage = (cookie: string) =>
+ app.request(`${origin}/projects/github/upstream/project/queue`, { headers: { Cookie: cookie } }, testEnv);
+
+ it("shows a maintainer each base's queue, build, and drops, and refuses anyone without maintain", async () => {
+ const { cookie, repo, pr, other } = await maintainerOf();
+ repo.queue = true;
+ pr.queued = true;
+ pr.group = {
+ state: "AWAITING_CHECKS",
+ runs: [
+ {
+ name: "Queue",
+ conclusion: "SUCCESS",
+ url: "https://github.com/upstream/project/actions/runs/42/job/1",
+ },
+ ],
+ };
+ const at = (minutes: number) => new Date(Date.now() - minutes * 60_000).toISOString();
+ pr.comments = [
+ {
+ author: "quest-test[bot]",
+ body: "quest land watch: the merge queue removed this pull request because it conflicts with `trunk` (`merge_conflict`).\n\n",
+ at: at(30),
+ },
+ {
+ author: "quest-test[bot]",
+ body: "quest land watch: re-enqueued this pull request at the back of the merge queue.\n\n",
+ at: at(20),
+ },
+ ];
+ const response = await queuePage(cookie);
+ expect(response.status).toBe(200);
+ const html = await response.text();
+ expect(html).toContain('trunk through GitHub's merge queue');
+ expect(html).toContain('href="https://github.com/upstream/project/actions/runs/42"');
+ expect(html).toContain("Dropped for merge_conflict");
+ expect(html).toContain("Re-enqueued");
+
+ const reader = await other("read");
+ expect((await queuePage(reader.session)).status).toBe(403);
+ });
+});
diff --git a/quest/m0/queue-watcher/README.md b/quest/m0/queue-watcher/README.md
index b1cec71e..47f89942 100644
--- a/quest/m0/queue-watcher/README.md
+++ b/quest/m0/queue-watcher/README.md
@@ -121,8 +121,8 @@ GitHub Team: moq.pro leaves the host gate for an own serial queue in
third-party queue. The maintainer expects the own queue to become every
repository's, for visibility. The own queue landed in
[moq-dev/quest#540](https://github.com/moq-dev/quest/pull/540); the rest is
-recorded in [moq.pro's cutover](/quest/m0/queue-watcher/pro-cutover.md) and
-[the queue page](/quest/m0/queue-watcher/queue-page.md).
+recorded in [moq.pro's cutover](/quest/m0/queue-watcher/pro-cutover.md), and
+kix.quest shows each base's queue at `/projects/github///queue`.
Shared shape, for the children to follow:
@@ -152,4 +152,3 @@ Shared shape, for the children to follow:
- [moq.pro lands main through the own queue](/quest/m0/queue-watcher/pro-cutover.md) - moq.pro's `main` leaves the batching gate for the watcher's own queue
- [The watcher runs one instance per base branch](/quest/m0/queue-watcher/per-base.md) - each base of a repository gets its own watcher, clone, locks, state and `--method`, so moq.pro's `release` can follow `main`
- [Retire the batching gate](/quest/m0/queue-watcher/retire.md) - once no repository lands through `quest land serve`, its batching code and host units go
-- [kix.quest shows each repository's landing queue](/quest/m0/queue-watcher/queue-page.md) - one page per repository with its queue order, current build, and recent drops, in both modes
diff --git a/quest/m0/queue-watcher/per-base.md b/quest/m0/queue-watcher/per-base.md
index 2e7020ff..74789704 100644
--- a/quest/m0/queue-watcher/per-base.md
+++ b/quest/m0/queue-watcher/per-base.md
@@ -18,8 +18,7 @@ say one daemon per base rather than per repository.
Non-goals: declaring moq.pro's second instance on the host and moving its
`release` onto it, which moq.pro's own Land release quest does once
[its `main` cutover](/quest/m0/queue-watcher/pro-cutover.md) works, and
-showing each base's queue on kix.quest, which
-[the queue page](/quest/m0/queue-watcher/queue-page.md) owns.
+showing each base's queue on kix.quest, which its queue page already does.
## Plan
diff --git a/quest/m0/queue-watcher/queue-page.md b/quest/m0/queue-watcher/queue-page.md
deleted file mode 100644
index eb5e4bb0..00000000
--- a/quest/m0/queue-watcher/queue-page.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# [M] kix.quest shows each repository's landing queue
-
-## Goal
-
-quest.moq.dev shows each enrolled repository's landing queue, whether GitHub's
-merge queue or the watcher's own queue (the README's "The watcher's own
-queue") runs it:
-
-- the queued PRs in landing order;
-- the build in progress, with a link to its run;
-- recent drops, with each one's reason and the fixer's outcome or handback.
-
-A private repository's queue is visible only to viewers who can read the
-repository, as with the rest of kix.quest.
-
-Non-goals: a status API the watchers push to.
-
-## Plan
-
-Decided 2026-11-06 (maintainer, /quest-plan):
-
-- Scope:
- - ✅ Every repository, in both modes, so landing has one place to look
- - Own-queue repositories only
-- Data:
- - ✅ kix reads GitHub with the viewer's token, as it already reads a PR's
- auto-merge and merge-queue state (`cloudflare/src/github/service.ts`).
- It needs no new credential and adds no private-repository data to kix.
- The maintainer expects a status API the watchers push to later, for
- detail GitHub lacks, such as a fixer job's live progress.
- - Each watcher reports its state to kix with a per-host token
-
-Sources, per mode:
-
-- Order: `mergeQueueEntry.position` in GitHub's queue, and armed auto-merge
- `enabledAt` in the own queue.
-- Current build: the `merge_group` workflow run, or the own queue's per-PR
- check run and the queue-branch run it links.
-- Drops and fixer results: `RemovedFromMergeQueueEvent` and the watcher's
- comments, which name the reason and the fixer's outcome.
-
-## Related
-
-- [Private repositories](/quest/m0/kix/private.md) - how kix.quest gates a private repository's pages to its readers
From c96a33a70aca9b347ea71c59581bd4ae2a517b09 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Sat, 7 Nov 2026 16:32:17 -0800
Subject: [PATCH 3/3] fix(kix): show a drop's own disposition when no outcome
follows
Co-Authored-By: Claude Opus 5.5
---
cloudflare/README.md | 5 +++--
cloudflare/src/github/queue.ts | 18 ++++++++++++++++--
cloudflare/src/queue.tsx | 2 +-
cloudflare/test/queue.test.ts | 8 ++++++++
4 files changed, 28 insertions(+), 5 deletions(-)
diff --git a/cloudflare/README.md b/cloudflare/README.md
index eb4e1173..89a2e7e7 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -301,8 +301,9 @@ were armed, the watcher's own queue order, each with the `quest queue` check
the watcher writes, whose in-progress run links its build. Recent drops are
the watcher's marked removal comments from the last day, in both modes, each
paired with the watcher's next marked comment on that pick: re-enqueued,
-handed back, not applied, refused, or held. Only comments from an app or a
-writer count.
+handed back, not applied, refused, or held. A drop with no later word, such
+as one a hold, a push, or a person's removal ends, shows what its own comment
+said happens next. Only comments from an app or a writer count.
### Installed app and notifications
diff --git a/cloudflare/src/github/queue.ts b/cloudflare/src/github/queue.ts
index e71d3692..432ad226 100644
--- a/cloudflare/src/github/queue.ts
+++ b/cloudflare/src/github/queue.ts
@@ -88,6 +88,11 @@ export type Drop = {
/** GitHub's removal reason, such as `merge_conflict`, or null when the comment names none. */
reason: string | null;
text: string;
+ /**
+ * What the watcher said it does next, in the removal comment itself: the fixer taking it, or
+ * why the pick ends there, as for a hold, a push, or a person's removal, which get no later word.
+ */
+ next: string | null;
url: string;
at: string;
outcome: Outcome | null;
@@ -104,14 +109,22 @@ const OUTCOMES = new Set(["requeued", "handback", "ended", "ref
/** The comment's first paragraph as one bounded line, without the watcher's prefix. */
function excerpt(body: string) {
- const first = body.split(/\n\s*\n/, 1)[0] ?? "";
- return first
+ return line(body.split(/\n\s*\n/, 1)[0] ?? "");
+}
+function line(text: string) {
+ return text
.replace(/^quest land watch: /, "")
.replace(/[\x00-\x1f\x7f]+/g, " ")
.trim()
.slice(0, 300);
}
+/** A removal comment's second paragraph when it is prose, rather than its failing jobs' logs or its marker. */
+function next(body: string) {
+ const second = body.split(/\n\s*\n/)[1];
+ return second && /^[A-Z]/.test(second) ? line(second) : null;
+}
+
/** A watcher comment on a pull request: a drop or an outcome. Only an app or a writer speaks for the watcher. */
type Said = { number: number; pick: string; kind: "removed" | Outcome["kind"]; body: string; url: string; at: string };
function said(comment: Comment): Said | null {
@@ -235,6 +248,7 @@ export function landingQueues(
pull: ref(pull),
reason: /\(`([a-z_]+)`\)/.exec(word.body)?.[1] ?? null,
text: excerpt(word.body),
+ next: next(word.body),
url: word.url,
at: word.at,
outcome,
diff --git a/cloudflare/src/queue.tsx b/cloudflare/src/queue.tsx
index fbdee3d4..41c0f059 100644
--- a/cloudflare/src/queue.tsx
+++ b/cloudflare/src/queue.tsx
@@ -101,7 +101,7 @@ export function QueuePage(props: { owner: string; repository: string; queues: La
: {drop.outcome.text}
) : (
-
No outcome yet.
+
{drop.next ?? "No outcome yet."}
)}
))}
diff --git a/cloudflare/test/queue.test.ts b/cloudflare/test/queue.test.ts
index 9f13862c..6dce6939 100644
--- a/cloudflare/test/queue.test.ts
+++ b/cloudflare/test/queue.test.ts
@@ -172,6 +172,8 @@ describe("landing queues", () => {
expect(main.drops[1]!.text).toBe(
"the queue removed this pull request because it conflicts (`merge_conflict`).",
);
+ // A drop with no later word shows what its own comment said happens next.
+ expect(main.drops[0]!.next).toBe("More.");
expect(queues.find((q) => q.base === "release")).toMatchObject({
mode: "own",
entries: [],
@@ -211,6 +213,11 @@ describe("landing queue page", () => {
body: "quest land watch: re-enqueued this pull request at the back of the merge queue.\n\n",
at: at(20),
},
+ {
+ author: "quest-test[bot]",
+ body: "quest land watch: the merge queue removed this pull request because its `merge_group` build failed (`failed_checks`).\n\nAnother session holds this pull request, so the watcher stops acting on it.\n\n",
+ at: at(10),
+ },
];
const response = await queuePage(cookie);
expect(response.status).toBe(200);
@@ -219,6 +226,7 @@ describe("landing queue page", () => {
expect(html).toContain('href="https://github.com/upstream/project/actions/runs/42"');
expect(html).toContain("Dropped for merge_conflict");
expect(html).toContain("Re-enqueued");
+ expect(html).toContain("Another session holds this pull request, so the watcher stops acting on it.");
const reader = await other("read");
expect((await queuePage(reader.session)).status).toBe(403);