|
| 1 | +// The events a pull request's sequence is made of, each played on the stack as GitHub and the hub would see it, and the |
| 2 | +// seeded random walks the rules are checked over past the sequences played in full. |
| 3 | +import { pullOf, sha, type CheckRun } from "../chain/github.ts"; |
| 4 | +import type { modelStack } from "./stack.ts"; |
| 5 | + |
| 6 | +type Stack = Awaited<ReturnType<typeof modelStack>>; |
| 7 | + |
| 8 | +export const LETTERS = ["push", "ci-pending", "ci-success", "ci-failure", "ci-cancelled", "ci-stale", "edit", "draft", "ready", "duplicate", "run-first", "run-second"] as const; |
| 9 | +export type Letter = (typeof LETTERS)[number]; |
| 10 | + |
| 11 | +/** One pull request a sequence plays on: its number, every head it had, newest last, and whether it is a draft. */ |
| 12 | +export type Played = { number: number; heads: string[]; draft: boolean }; |
| 13 | + |
| 14 | +const BUILD = "build"; |
| 15 | + |
| 16 | +function current(pull: Played): string { |
| 17 | + return pull.heads.at(-1)!; |
| 18 | +} |
| 19 | + |
| 20 | +/** |
| 21 | + * The bridge takes in no webhook of a draft but its conversion to one (apps/hub/src/github/bridge.ts), so a draft's |
| 22 | + * events reach the hub as nothing. |
| 23 | + */ |
| 24 | +async function webhook(stack: Stack, pull: Played, event: Parameters<Stack["webhook"]>[1], head = current(pull)): Promise<void> { |
| 25 | + if (pull.draft && event !== "drafted") return; |
| 26 | + await stack.webhook(pull.number, event, head); |
| 27 | +} |
| 28 | + |
| 29 | +/** CI on the head settles; GitHub sends a webhook only for a completed check, never for one still running. */ |
| 30 | +async function checks(stack: Stack, pull: Played, run: CheckRun, head = current(pull)): Promise<void> { |
| 31 | + stack.github.setChecks(stack.repo, head, [run]); |
| 32 | + if (run.status === "completed") await webhook(stack, pull, "checks", head); |
| 33 | +} |
| 34 | + |
| 35 | +/** Plays `letter`; false when it changes nothing in this state, so the sequence equals a shorter one. */ |
| 36 | +export async function play(stack: Stack, pull: Played, letter: Letter): Promise<boolean> { |
| 37 | + switch (letter) { |
| 38 | + case "push": { |
| 39 | + const head = sha(`${pull.number}-${pull.heads.length}`); |
| 40 | + pull.heads.push(head); |
| 41 | + stack.github.pull(stack.repo, pull.number).head = head; |
| 42 | + stack.github.setChecks(stack.repo, head, []); |
| 43 | + await webhook(stack, pull, "updated"); |
| 44 | + return true; |
| 45 | + } |
| 46 | + case "ci-pending": |
| 47 | + await checks(stack, pull, { name: BUILD, status: "in_progress", conclusion: null }); |
| 48 | + return true; |
| 49 | + case "ci-success": |
| 50 | + await checks(stack, pull, { name: BUILD, status: "completed", conclusion: "success" }); |
| 51 | + return true; |
| 52 | + case "ci-failure": |
| 53 | + await checks(stack, pull, { name: BUILD, status: "completed", conclusion: "failure" }); |
| 54 | + return true; |
| 55 | + case "ci-cancelled": |
| 56 | + await checks(stack, pull, { name: BUILD, status: "completed", conclusion: "cancelled" }); |
| 57 | + return true; |
| 58 | + case "ci-stale": { |
| 59 | + const old = pull.heads.at(-2); |
| 60 | + if (old === undefined) return false; |
| 61 | + await checks(stack, pull, { name: BUILD, status: "completed", conclusion: "success" }, old); |
| 62 | + return true; |
| 63 | + } |
| 64 | + case "edit": |
| 65 | + await webhook(stack, pull, "updated"); |
| 66 | + return true; |
| 67 | + case "draft": |
| 68 | + if (pull.draft) return false; |
| 69 | + pull.draft = true; |
| 70 | + stack.github.pull(stack.repo, pull.number).draft = true; |
| 71 | + await webhook(stack, pull, "drafted"); |
| 72 | + return true; |
| 73 | + case "ready": |
| 74 | + if (!pull.draft) return false; |
| 75 | + pull.draft = false; |
| 76 | + stack.github.pull(stack.repo, pull.number).draft = false; |
| 77 | + await webhook(stack, pull, "ready"); |
| 78 | + return true; |
| 79 | + case "duplicate": { |
| 80 | + const [first] = stack.queue; |
| 81 | + if (!first) return false; |
| 82 | + stack.queue.push(first); |
| 83 | + return true; |
| 84 | + } |
| 85 | + case "run-first": |
| 86 | + if (stack.queue.length === 0) return false; |
| 87 | + await stack.run(0); |
| 88 | + return true; |
| 89 | + case "run-second": |
| 90 | + if (stack.queue.length < 2) return false; |
| 91 | + await stack.run(1); |
| 92 | + return true; |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +/** Opens pull request `number` at its first head. */ |
| 97 | +export async function open(stack: Stack, number: number): Promise<Played> { |
| 98 | + const pull = { number, heads: [sha(`${number}-0`)], draft: false }; |
| 99 | + await stack.open(pullOf(number, current(pull))); |
| 100 | + return pull; |
| 101 | +} |
| 102 | + |
| 103 | +/** |
| 104 | + * Lets the pull request settle as it does once people stop acting on it: a draft is marked ready, CI on its head |
| 105 | + * finishes, and every mail is run. |
| 106 | + */ |
| 107 | +export async function settle(stack: Stack, pull: Played): Promise<void> { |
| 108 | + if (pull.draft) await play(stack, pull, "ready"); |
| 109 | + await stack.drain(); |
| 110 | + const [run] = stack.github.checksOf(stack.repo, current(pull)); |
| 111 | + if (run?.status !== "completed") await play(stack, pull, "ci-success"); |
| 112 | + await stack.drain(); |
| 113 | +} |
| 114 | + |
| 115 | +/** A random walk of `length` letters from `seed`, the same on every machine. */ |
| 116 | +export function walk(seed: number, length: number): Letter[] { |
| 117 | + let state = seed >>> 0; |
| 118 | + function next(): number { |
| 119 | + state = (state + 0x6d2b79f5) >>> 0; |
| 120 | + let t = state; |
| 121 | + t = Math.imul(t ^ (t >>> 15), t | 1); |
| 122 | + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); |
| 123 | + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; |
| 124 | + } |
| 125 | + return Array.from({ length }, () => LETTERS[Math.floor(next() * LETTERS.length)]!); |
| 126 | +} |
0 commit comments