From 8f5c07792473f5a09d615a158470a9a9d7f9ae67 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 16:11:56 -0800 Subject: [PATCH 1/3] chore(quest): claim quest/m0/queue-watcher/queue-page.md Quest-Claim: 8bd9cbc3f1fdcc9a From a82dcdfb6068a929bf24269d955b3543e3be83e4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 16:29:07 -0800 Subject: [PATCH 2/3] feat(kix): show each base's landing queue /projects/github///queue lists each base's queue in landing order, its builds with their runs, and the watcher's drops from the last day with each one's outcome, for GitHub's merge queue and the watcher's own queue alike. It reads GitHub when served, under the maintainer check every project page uses. Co-Authored-By: Claude Opus 5.5 --- cloudflare/README.md | 15 ++ cloudflare/dev/github.ts | 91 ++++++++++ cloudflare/src/github/queue.ts | 244 +++++++++++++++++++++++++++ cloudflare/src/github/service.ts | 62 +++++++ cloudflare/src/inbox/page.tsx | 1 + cloudflare/src/index.tsx | 19 +++ cloudflare/src/projects.tsx | 2 + cloudflare/src/queue.tsx | 117 +++++++++++++ cloudflare/test/queue.test.ts | 226 +++++++++++++++++++++++++ quest/m0/queue-watcher/README.md | 5 +- quest/m0/queue-watcher/per-base.md | 3 +- quest/m0/queue-watcher/queue-page.md | 44 ----- 12 files changed, 780 insertions(+), 49 deletions(-) create mode 100644 cloudflare/src/github/queue.ts create mode 100644 cloudflare/src/queue.tsx create mode 100644 cloudflare/test/queue.test.ts delete mode 100644 quest/m0/queue-watcher/queue-page.md diff --git a/cloudflare/README.md b/cloudflare/README.md index 9de6305d..eb4e1173 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -289,6 +289,21 @@ the item showed (a question, a GitHub change, a quest's text). A newer version is unread again, and skipping an item lasts until it changes. A finished or stopped job sets its item aside the same way. +### Landing queue + +`/projects/github///queue` (`src/queue.tsx`) shows each base's +landing queue, under the same maintainer check as the project's other pages, +read from GitHub when the page loads (`src/github/queue.ts`); kix stores none +of it. A base with GitHub's merge queue lists its entries by position, building +entries linking their merge group's run, then the PRs whose armed auto-merge +waits on checks to join. Any other base lists its armed PRs in the order they +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. + ### Installed app and notifications kix installs as a progressive web app (`src/manifest.webmanifest`, diff --git a/cloudflare/dev/github.ts b/cloudflare/dev/github.ts index aedb2868..d955826d 100644 --- a/cloudflare/dev/github.ts +++ b/cloudflare/dev/github.ts @@ -61,6 +61,8 @@ export type Pull = Issue & { landing?: boolean; /** It is in its base's merge queue; only a base with `queue` takes it. */ queued?: boolean; + /** Its merge queue entry's state, `QUEUED` by default, and its merge group's check runs, while `queued`. */ + group?: { state: string; runs: NonNullable }; /** The enabled auto-merge's time and commit body; `quest land` reads its queue from them. */ autoMerge?: { enabledAt: string; commitBody: string }; /** A fork's head branch accepts pushes from the upstream's maintainers. */ @@ -463,6 +465,50 @@ function contextPage(pull: Pick, after: unknown) { }; } +/** An open pull request as kix's landing queue page reads it: its landing, merge group, and `quest queue` check. */ +function landingTarget(repo: Repo, pull: Pull, queued: Pull[]) { + const head = headOf(repo, pull); + const run = (repo.checkRuns ?? []).filter((r) => r.name === "quest queue" && r.head_sha === head).at(-1); + return { + number: pull.number, + title: pull.title, + url: `https://github.com/${repo.owner}/${repo.name}/pull/${pull.number}`, + baseRefName: pull.base ?? defaultOf(repo), + isMergeQueueEnabled: repo.queue === true && (pull.base ?? defaultOf(repo)) === defaultOf(repo), + autoMergeRequest: pull.landing ? { enabledAt: pull.autoMerge?.enabledAt ?? updatedAt } : null, + mergeQueueEntry: pull.queued + ? { + position: queued.indexOf(pull) + 1, + state: pull.group?.state ?? "QUEUED", + headCommit: pull.group ? { statusCheckRollup: { contexts: contextPage(pull.group, null) } } : null, + } + : null, + commits: { + nodes: [ + { + commit: { + checkSuites: page( + run + ? [ + { + checkRuns: page([ + { + status: (run.status ?? "completed").toUpperCase(), + title: run.output.title ?? null, + detailsUrl: run.details_url ?? null, + }, + ]), + }, + ] + : [], + ), + }, + }, + ], + }, + }; +} + function graphql(repo: Repo, query: string, variables: Record) { const blobs = [...query.matchAll(/(q\d+): object\(oid: "([0-9a-f]{40})"\)/g)]; if (blobs.length) { @@ -559,6 +605,25 @@ function graphql(repo: Repo, query: string, variables: Record) }, }; } + if (query.includes('checkName: "quest queue"')) { + const open = repo.pulls.filter((pull) => !pull.closed); + const queued = open.filter((pull) => pull.queued); + return { data: { repository: { pullRequests: page(open.map((pull) => landingTarget(repo, pull, queued))) } } }; + } + // The pull requests a landing drop names, by number, which may have closed. + const dropped = [...query.matchAll(/(p\d+): pullRequest\(number: (\d+)\)/g)]; + if (dropped.length) { + const found = Object.fromEntries( + dropped.map(([, alias, number]) => { + const pull = repo.pulls.find((p) => p.number === Number(number)); + return [alias, pull ? landingTarget(repo, pull, []) : null]; + }), + ); + const errors = dropped + .filter(([, alias]) => !found[alias!]) + .map(([, alias]) => ({ type: "NOT_FOUND", path: ["repository", alias] })); + return { data: { repository: found }, ...(errors.length ? { errors } : {}) }; + } if (query.includes("defaultBranchRef")) return { data: { repository: { defaultBranchRef: { name: defaultOf(repo) } } } }; if (query.includes("queue:pullRequests(")) { @@ -1264,6 +1329,32 @@ export function fakeGitHub(world: World) { message: "Pull Request successfully merged", }); } + // Every issue and pull request comment, oldest first, updated at or after `since`. + if (tail === "/issues/comments" && method === "GET") { + if (!scope.permissions.issues && !scope.permissions.pull_requests) return forbidden(); + const since = Date.parse(url.searchParams.get("since") ?? "1970-01-01T00:00:00Z"); + const page = Number(url.searchParams.get("page") ?? 1); + const size = Number(url.searchParams.get("per_page") ?? 30); + const all = [...repo.pulls, ...repo.issues] + .flatMap((item) => + (item.comments ?? []).map((c, i) => ({ + id: item.number * 1000 + i, + html_url: `https://github.com/${fullName}/pull/${item.number}#issuecomment-${item.number * 1000 + i}`, + issue_url: `https://api.github.com/repos/${fullName}/issues/${item.number}`, + body: c.body, + created_at: c.at, + updated_at: c.at, + user: c.author && { login: c.author, type: c.author.endsWith("[bot]") ? "Bot" : "User" }, + author_association: + c.author && ["admin", "maintain", "write"].includes(roleOf(repo, c.author) ?? "") + ? "COLLABORATOR" + : "NONE", + })), + ) + .filter((c) => Date.parse(c.created_at) >= since) + .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at)); + return json(all.slice((page - 1) * size, page * size)); + } const comment = tail.match(/^\/issues\/(\d+)\/comments$/); if (comment && method === "GET") { const item = [...repo.pulls, ...repo.issues].find((i) => String(i.number) === comment[1]); diff --git a/cloudflare/src/github/queue.ts b/cloudflare/src/github/queue.ts new file mode 100644 index 00000000..e71d3692 --- /dev/null +++ b/cloudflare/src/github/queue.ts @@ -0,0 +1,244 @@ +// A repository's landing queues, read from GitHub alone: GitHub's merge queue +// on a base that has one, and otherwise the watcher's own queue (`quest land +// watch`). Order and builds come from the open pull requests; drops and the +// fixer's outcomes from the watcher's marked comments, which it posts in both +// modes. Everything here is untrusted GitHub content, rendered as text. + +/** The check run each pick in the watcher's own queue carries; `QUEUE_CHECK` in `src/cli/land-queue.ts`. */ +export const QUEUE_CHECK = "quest queue"; + +/** How far back a repository's comments are read for drops. */ +export const DROP_WINDOW = 24 * 60 * 60_000; + +const contexts = `contexts(first: 100) { nodes { __typename + ... on CheckRun { status detailsUrl } ... on StatusContext { state targetUrl } } }`; + +/** One page of a repository's open pull requests, with what landing order and builds need. */ +export const OPEN_PULLS = `query($owner: String!, $name: String!, $after: String) { repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { + number title url baseRefName isMergeQueueEnabled autoMergeRequest { enabledAt } + mergeQueueEntry { position state headCommit { statusCheckRollup { ${contexts} } } } + commits(last: 1) { nodes { commit { checkSuites(first: 10, filterBy: { checkName: "${QUEUE_CHECK}" }) { nodes { + checkRuns(first: 1, filterBy: { checkName: "${QUEUE_CHECK}" }) { nodes { status title detailsUrl } } } } } } } } } } }`; + +/** The fields read about a pull request a drop names, which may have closed since. */ +export const DROPPED_FIELDS = "number title url baseRefName isMergeQueueEnabled"; + +type Rollup = { + contexts: { + nodes: ({ + __typename: string; + status?: string; + detailsUrl?: string | null; + state?: string; + targetUrl?: string | null; + } | null)[]; + }; +} | null; +/** A pull request as `OPEN_PULLS` reads it. */ +export type OpenPull = { + number: number; + title: string; + url: string; + baseRefName: string; + isMergeQueueEnabled: boolean | null; + autoMergeRequest: { enabledAt: string } | null; + mergeQueueEntry: { position: number; state: string; headCommit: { statusCheckRollup: Rollup } | null } | null; + commits: { + nodes: { + commit: { + checkSuites: { + nodes: { + checkRuns: { nodes: { status: string; title: string | null; detailsUrl: string | null }[] }; + }[]; + } | null; + }; + }[]; + }; +}; +/** A pull request a drop names, as `DROPPED_FIELDS` reads it. */ +export type DroppedPull = Pick; +/** An issue comment, as GitHub's REST API lists them. */ +export type Comment = { + html_url: string; + issue_url: string; + body?: string | null; + created_at: string; + author_association?: string; + user: { type?: string } | null; +}; + +export type PullRef = { number: number; title: string; url: string }; +/** A pull request in a base's queue, in landing order. */ +export type QueueEntry = PullRef & { + /** Where it stands, as GitHub or the watcher's queue check says. */ + status: string; + /** Its build in progress, when it has one, and the run to follow. */ + build: { url: string | null } | null; +}; +/** What became of a dropped pick: the watcher's next word on it. */ +export type Outcome = { + kind: "requeued" | "handback" | "ended" | "refused" | "held"; + text: string; + url: string; + at: string; +}; +export type Drop = { + pull: PullRef; + /** GitHub's removal reason, such as `merge_conflict`, or null when the comment names none. */ + reason: string | null; + text: string; + url: string; + at: string; + outcome: Outcome | null; +}; +export type LandingQueue = { + base: string; + mode: "merge-queue" | "own"; + entries: QueueEntry[]; + drops: Drop[]; +}; + +const MARKER = //; +const OUTCOMES = new Set(["requeued", "handback", "ended", "refused", "held"]); + +/** 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 + .replace(/^quest land watch: /, "") + .replace(/[\x00-\x1f\x7f]+/g, " ") + .trim() + .slice(0, 300); +} + +/** 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 { + const trusted = + comment.user?.type === "Bot" || ["OWNER", "MEMBER", "COLLABORATOR"].includes(comment.author_association ?? ""); + const match = trusted && typeof comment.body === "string" ? MARKER.exec(comment.body) : null; + const number = Number(comment.issue_url.split("/").at(-1)); + if (!match || !Number.isSafeInteger(number) || number < 1) return null; + return { + number, + pick: match[1]!, + kind: match[2] as Said["kind"], + body: comment.body!, + url: comment.html_url, + at: comment.created_at, + }; +} + +/** The pull request numbers the comments' drops name, which the open pull requests may not include. */ +export function droppedNumbers(comments: Comment[]): number[] { + return [...new Set(comments.flatMap((c) => (said(c)?.kind === "removed" ? [said(c)!.number] : [])))]; +} + +/** The URL of a merge group's build to follow: its first unfinished check, else its first. */ +function buildURL(rollup: Rollup): string | null { + const nodes = (rollup?.contexts.nodes ?? []).flatMap((node) => { + if (!node) return []; + const run = node.__typename === "CheckRun"; + const url = run ? node.detailsUrl : node.targetUrl; + if (!url) return []; + return [ + { url, pending: run ? node.status !== "COMPLETED" : node.state === "PENDING" || node.state === "EXPECTED" }, + ]; + }); + const url = (nodes.find((node) => node.pending) ?? nodes[0])?.url ?? null; + // An Actions check links its job; the run holds every job of the build. + return url?.replace(/(\/actions\/runs\/\d+)\/job\/\d+$/, "$1") ?? null; +} + +const QUEUE_STATES: Record = { + QUEUED: "Queued", + AWAITING_CHECKS: "Building", + MERGEABLE: "Passed, merging", + UNMERGEABLE: "Failing", + LOCKED: "Merging", +}; + +/** + * Each base's queue: its pull requests in landing order with their builds, and the drops since + * `since`, newest first, each with the watcher's outcome. Bases with nothing queued or dropped are left out. + */ +export function landingQueues( + open: OpenPull[], + dropped: DroppedPull[], + comments: Comment[], + since: number, +): LandingQueue[] { + const queues = new Map(); + const queue = (base: string, mergeQueue: boolean) => { + let found = queues.get(base); + if (!found) + queues.set(base, (found = { base, mode: mergeQueue ? "merge-queue" : "own", entries: [], drops: [] })); + if (mergeQueue) found.mode = "merge-queue"; + return found; + }; + const ref = (pull: DroppedPull): PullRef => ({ number: pull.number, title: pull.title, url: pull.url }); + + // GitHub's queue in position order, then the picks armed to join it once their checks pass. + const queued = open + .filter((pull) => pull.mergeQueueEntry) + .sort((a, b) => a.mergeQueueEntry!.position - b.mergeQueueEntry!.position); + for (const pull of queued) { + const entry = pull.mergeQueueEntry!; + const building = entry.state === "AWAITING_CHECKS"; + queue(pull.baseRefName, true).entries.push({ + ...ref(pull), + status: QUEUE_STATES[entry.state] ?? entry.state.toLowerCase(), + build: building ? { url: buildURL(entry.headCommit?.statusCheckRollup ?? null) } : null, + }); + } + const armed = open + .filter((pull) => pull.autoMergeRequest && !pull.mergeQueueEntry) + .sort((a, b) => Date.parse(a.autoMergeRequest!.enabledAt) - Date.parse(b.autoMergeRequest!.enabledAt)); + for (const pull of armed) { + if (pull.isMergeQueueEnabled) { + queue(pull.baseRefName, true).entries.push({ + ...ref(pull), + status: "Waiting on its checks to join", + build: null, + }); + continue; + } + // The own queue's order is the order picks were armed; its check says where each stands. + const run = pull.commits.nodes[0]?.commit.checkSuites?.nodes.flatMap((suite) => suite.checkRuns.nodes)[0]; + const live = run && run.status !== "COMPLETED" ? run : null; + queue(pull.baseRefName, false).entries.push({ + ...ref(pull), + status: live?.title ?? "Waiting on its checks", + build: live?.status === "IN_PROGRESS" ? { url: live.detailsUrl } : null, + }); + } + // A base whose open picks all left still shows its recent drops in its own mode. + const pulls = new Map([...dropped, ...open].map((pull) => [pull.number, pull])); + + const words = comments + .flatMap((comment) => said(comment) ?? []) + .sort((a, b) => Date.parse(a.at) - Date.parse(b.at)); + words.forEach((word, i) => { + if (word.kind !== "removed" || Date.parse(word.at) < since) return; + const pull = pulls.get(word.number); + if (!pull) return; + // The outcome is the watcher's next word on the same pick, before any later drop of it. + let outcome: Outcome | null = null; + for (const next of words.slice(i + 1)) { + if (next.number !== word.number || next.pick !== word.pick) continue; + if (next.kind === "removed") break; + outcome = { kind: next.kind, text: excerpt(next.body), url: next.url, at: next.at }; + break; + } + queue(pull.baseRefName, pull.isMergeQueueEnabled === true).drops.unshift({ + pull: ref(pull), + reason: /\(`([a-z_]+)`\)/.exec(word.body)?.[1] ?? null, + text: excerpt(word.body), + url: word.url, + at: word.at, + outcome, + }); + }); + return [...queues.values()].sort((a, b) => a.base.localeCompare(b.base)); +} diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index bd7878c1..c8f41a11 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -23,6 +23,16 @@ import { import { BODY_LIMIT } from "../jobs/trail"; import { osiApproved } from "../licenses"; import { CI_PERMISSIONS, type CIPermission, ciProblems } from "./ci"; +import { + type Comment as QueueComment, + DROP_WINDOW, + DROPPED_FIELDS, + type DroppedPull, + droppedNumbers, + landingQueues, + OPEN_PULLS, + type OpenPull, +} from "./queue"; import { OBSERVED, type Observed, observedGap, REVIEW_CHECK, unreviewed } from "./review"; import { GOAL_LIMIT, SETUP_BRANCH, STUBS, planCheck, planned, setupGoal, setupPull, treeCheckout } from "./setup"; import { positiveId } from "./webhook"; @@ -1684,6 +1694,58 @@ export class GitHubService { ), })); } + /** + * Each base's landing queue (`./queue.ts`): its open pull requests in landing order with their + * builds, and the watcher's drops in the last day with their outcomes. + */ + readQueues(repo: GitHubRepository, userId: string, now: number) { + const permissions: Permissions = { pull_requests: "read", issues: "read", checks: "read", statuses: "read" }; + return this.#authorized(repo, userId, permissions, async (token) => { + const graphql = async (query: string, variables: Record): Promise => { + const result = await this.#api<{ data?: { repository: T | null }; errors?: unknown[] }>( + token, + "/graphql", + "POST", + { query, variables }, + ); + if (result.errors?.length || !result.data?.repository) + throw new GitHubError("The repository's pull requests are unavailable"); + return result.data.repository; + }; + const variables = { owner: repo.owner, name: repo.name }; + const open: OpenPull[] = []; + for (let after: string | null = null; ;) { + type Page = { + pullRequests: { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: OpenPull[] }; + }; + const { pullRequests }: Page = await graphql(OPEN_PULLS, { ...variables, after }); + open.push(...pullRequests.nodes); + if (!pullRequests.pageInfo.hasNextPage) break; + after = pullRequests.pageInfo.endCursor; + } + const since = now - DROP_WINDOW; + const comments: QueueComment[] = []; + const path = `${repositoryPath(repo)}/issues/comments?since=${new Date(since).toISOString()}&sort=created&direction=asc&per_page=100`; + for (let page = 1; ; page++) { + const items = await this.#api(token, `${path}&page=${page}`); + comments.push(...items); + if (items.length < 100) break; + } + const known = new Set(open.map((pull) => pull.number)); + const missing = droppedNumbers(comments).filter((n) => !known.has(n)); + const dropped = missing.length + ? Object.values( + await graphql>( + `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${missing + .map((n) => `p${number(n)}: pullRequest(number: ${n}) { ${DROPPED_FIELDS} }`) + .join(" ")} } }`, + variables, + ), + ) + : []; + return landingQueues(open, dropped, comments, since); + }); + } /** * What a job's agent reads about its issue or pull request, which its * checkout does not hold: an issue's text and comments, or a pull request's diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx index c07cb7fa..a8130dad 100644 --- a/cloudflare/src/inbox/page.tsx +++ b/cloudflare/src/inbox/page.tsx @@ -702,6 +702,7 @@ function Project(props: { view: ProjectView; runners: Runners }) { {state?.failure && Background read failed: {state.failure}} Quest board Activity + Landing queue )} diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx index a734cd5a..11fe9b1c 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -31,6 +31,7 @@ import * as landing from "./jobs/landing"; import { BootstrapRequired, fromForm, hub, inboxURL, jobs, nextURL, resume, stop } from "./jobs/routes"; import { JobError, active, listActivityJobs, listRunners, moveAccount, registerRunner } from "./jobs/store"; import { Layout, type Page, type User } from "./layout"; +import { QueuePage } from "./queue"; import { RunnersPage, connectRequest } from "./runners"; import * as push from "./push/notify"; @@ -307,6 +308,24 @@ app.get("/projects/github/:owner/:repository/state", async (c) => { />, ); }); +// Each base's landing queue, read live from GitHub under the same authority as the project's other pages. +app.get("/projects/github/:owner/:repository/queue", async (c) => { + const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers }); + if (!session) return c.redirect(`/sign-in?next=${encodeURIComponent(here(c))}`, 303); + const github = new GitHubService(c.env); + const repo = await github.enrolledProject(c.req.param("owner"), c.req.param("repository"), session.user.id); + if (!repo) throw new GitHubError("Project is not enrolled"); + const now = Date.now(); + const queues = await github.readQueues(repo, session.user.id, now); + return page( + c, + { + title: `${repo.owner}/${repo.name} landing`, + description: "Each base's landing queue, builds, and recent drops.", + }, + , + ); +}); app.get("/projects/github/:owner/:repository/data", async (c) => { c.header("Cache-Control", "no-store"); return c.json((await liveProject(c)).state); diff --git a/cloudflare/src/projects.tsx b/cloudflare/src/projects.tsx index 8b2ff71d..6bcbb39b 100644 --- a/cloudflare/src/projects.tsx +++ b/cloudflare/src/projects.tsx @@ -485,6 +485,8 @@ export function ProjectStatePage(props: {

Quest board {" · "} + Landing queue + {" · "} Observed data

Claimed quests

diff --git a/cloudflare/src/queue.tsx b/cloudflare/src/queue.tsx new file mode 100644 index 00000000..fbdee3d4 --- /dev/null +++ b/cloudflare/src/queue.tsx @@ -0,0 +1,117 @@ +import type { Drop, LandingQueue, PullRef } from "./github/queue"; +import { ago } from "./inbox/activity"; +import { projectURL } from "./projects"; + +const MODES: Record = { + "merge-queue": "GitHub's merge queue", + own: "the watcher's queue", +}; +const OUTCOMES: Record["kind"], string> = { + requeued: "Re-enqueued", + handback: "Handed back", + ended: "Fix not applied", + refused: "Refused", + held: "Held", +}; + +function Pull({ pull }: { pull: PullRef }) { + return ( + + #{pull.number} {pull.title} + + ); +} + +function When({ at, now }: { at: string; now: number }) { + return ; +} + +/** Each base's landing queue: the order, the builds in progress, and the last day's drops. */ +export function QueuePage(props: { owner: string; repository: string; queues: LandingQueue[]; now: number }) { + const base = projectURL(props.owner, props.repository); + return ( +
+

+ Activity + {" · "} + Quest board +

+

+ {props.owner}/{props.repository} landing +

+

+ Read from GitHub when this page loads. Drops are those the watcher commented on in the last day. +

+ {props.queues.length === 0 &&

Nothing is landing, and nothing was dropped in the last day.

} + {props.queues.map((queue) => { + const builds = queue.entries.filter((entry) => entry.build); + return ( + <> +

+ {queue.base} through {MODES[queue.mode]} +

+

Building

+ {builds.length ? ( +
    + {builds.map((entry) => ( +
  • + + +
  • + ))} +
+ ) : ( +

Nothing is building.

+ )} +

Queue

+ {queue.entries.length ? ( +
    + {queue.entries.map((entry) => ( +
  1. + + +
  2. + ))} +
+ ) : ( +

Nothing is queued.

+ )} +

Recent drops

+ {queue.drops.length ? ( + + ) : ( +

No drops in the last day.

+ )} + + ); + })} +
+ ); +} 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);