diff --git a/cloudflare/README.md b/cloudflare/README.md index 9de6305d..89a2e7e7 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -289,6 +289,22 @@ 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. 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 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..432ad226 --- /dev/null +++ b/cloudflare/src/github/queue.ts @@ -0,0 +1,258 @@ +// 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; + /** + * 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; +}; +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) { + 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 { + 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), + next: next(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..41c0f059 --- /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..6dce6939 --- /dev/null +++ b/cloudflare/test/queue.test.ts @@ -0,0 +1,234 @@ +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`).", + ); + // 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: [], + 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), + }, + { + 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); + 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"); + 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); + }); +}); diff --git a/quest/m0/queue-watcher/README.md b/quest/m0/queue-watcher/README.md index 11201104..70b4dddb 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: 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