diff --git a/.github/workflows/release-queue.yml b/.github/workflows/release-queue.yml new file mode 100644 index 0000000000000..02c34918d5721 --- /dev/null +++ b/.github/workflows/release-queue.yml @@ -0,0 +1,58 @@ +# Routes docs PRs labeled as accepted to the matching umbrella PR. +# Can be run manually to refresh the open umbrella PRs (e.g. when a release PR +# changes from patch to minor in `withastro/astro`). +# `MODE` sets which PRs it acts on: see `scripts/lib/release-queue/modes.ts`. + +name: Release queue + +on: + # Needs a token that can comment on fork PRs. Never checks out the PR code. + # zizmor: ignore[dangerous-triggers] + pull_request_target: + types: [labeled] + workflow_dispatch: + +# Runs wait in a queue and execute one at a time, as joins update the same +# umbrella PR. The docs only show `queue` at the workflow level: runs for +# other labels are queued too. +concurrency: + group: release-queue + queue: max + +permissions: {} + +jobs: + route: + name: Route to release umbrella + if: ${{ github.repository_owner == 'withastro' && (github.event_name == 'workflow_dispatch' || github.event.label.name == 'accepted') }} + runs-on: ubuntu-latest + # The job takes seconds. A hung run would block every run queued behind it. + timeout-minutes: 5 + permissions: + checks: read # Reads the checks of the PR. + contents: read + pull-requests: write # Comments on PRs, and reviews umbrella PRs. + statuses: read # Reads the commit statuses of the PR. + steps: + # The script always comes from `main`, whatever the base branch of the PR. + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: main + persist-credentials: false + sparse-checkout: scripts + + # The script has no dependencies, only Node.js is required. + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + + - name: Route the PR + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Used outside of dry run to write the umbrella PRs. + BOT_TOKEN: ${{ secrets.FREDKBOT_GITHUB_TOKEN }} + # `dry-run` mode only simulates the routing without making changes. Use `stable-only` or `all` for actual routing. + MODE: dry-run + run: node --experimental-transform-types ./scripts/release-queue.ts diff --git a/package.json b/package.json index d248b4335246d..a962d5afe7e3f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "lint:slugcheck": "node ./scripts/lint-slugcheck.mjs", "lint:eslint": "eslint .", "lint:ja": "textlint --cache --config .textlintrc.ja.json src/content/docs/ja/**/*.{md,mdx}", - "lunaria:build": "node --experimental-transform-types ./scripts/lunaria.mts" + "lunaria:build": "node --experimental-transform-types ./scripts/lunaria.mts", + "test:release-queue": "node --experimental-transform-types --test './scripts/lib/release-queue/**/*.test.ts'" }, "devDependencies": { "@actions/core": "^1.11.1", diff --git a/scripts/lib/release-queue/cli.test.ts b/scripts/lib/release-queue/cli.test.ts new file mode 100644 index 0000000000000..aba1328efbc95 --- /dev/null +++ b/scripts/lib/release-queue/cli.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { chooseAction, parseEvent } from './cli.ts'; + +describe('chooseAction', () => { + it('handles the PR of the event', () => { + const action = chooseAction({ + eventName: 'pull_request_target', + eventPullRequest: 7, + eventSender: 'maintainer', + }); + assert.deepEqual(action, { type: 'handle', prNumber: 7, labelSender: 'maintainer' }); + }); + + it('handles the PR passed with --pr, labeled by --sender', () => { + assert.deepEqual(chooseAction({ pr: '14580', sender: 'maintainer' }), { + type: 'handle', + prNumber: 14580, + labelSender: 'maintainer', + }); + }); + + it('handles a PR without a sender in a local run', () => { + assert.deepEqual(chooseAction({ pr: '14580' }), { + type: 'handle', + prNumber: 14580, + labelSender: undefined, + }); + }); + + it('refuses a PR without a sender when the write access check must run', () => { + assert.throws( + () => chooseAction({ eventPullRequest: 7, isSenderRequired: true }), + /^Error: The event has no sender: the write access check needs one\.$/ + ); + }); + + it('refreshes on a workflow_dispatch, or with --preview-refresh', () => { + assert.deepEqual(chooseAction({ eventName: 'workflow_dispatch' }), { type: 'refresh' }); + assert.deepEqual(chooseAction({ isRefreshRequested: true }), { type: 'refresh' }); + }); + + it('refuses a missing PR number, instead of refreshing', () => { + assert.throws(() => chooseAction({}), /^Error: No PR to handle: pass --pr \.$/); + }); + + it('refuses anything but the digits of a PR number', () => { + for (const pr of ['abc', '1O3', '0', '-3', '7.5', '1e3', '0x10', ' 7 ', '']) { + assert.throws(() => chooseAction({ pr }), /^Error: Invalid PR number: .*\. Pass --pr/, pr); + } + }); + + it('refuses --preview-refresh with --pr', () => { + assert.throws(() => chooseAction({ pr: '7', isRefreshRequested: true }), /not both/); + }); +}); + +describe('parseEvent', () => { + const path = '/tmp/event.json'; + + it('reads the PR number and the sender of a labeled event', () => { + const event = { + action: 'labeled', + pull_request: { number: 7 }, + sender: { login: 'maintainer' }, + }; + assert.deepEqual(parseEvent(JSON.stringify(event), path), { + pullRequest: 7, + labelSender: 'maintainer', + }); + }); + + it('reads neither from a workflow_dispatch event', () => { + assert.deepEqual(parseEvent('{"inputs":{}}', path), { + pullRequest: undefined, + labelSender: undefined, + }); + }); + + it('names the file and the field of an unexpected event', () => { + assert.throws( + () => parseEvent('not json', path), + /The event in \/tmp\/event\.json isn't valid JSON/ + ); + assert.throws(() => parseEvent('[]', path), /isn't an object/); + assert.throws( + () => parseEvent('{"pull_request":{"number":"7"}}', path), + /has no number in `pull_request`/ + ); + assert.throws(() => parseEvent('{"sender":{}}', path), /has no login in `sender`/); + }); +}); diff --git a/scripts/lib/release-queue/cli.ts b/scripts/lib/release-queue/cli.ts new file mode 100644 index 0000000000000..0a10e522dfb2f --- /dev/null +++ b/scripts/lib/release-queue/cli.ts @@ -0,0 +1,99 @@ +import { isRecord, parseJson } from './formats/json.ts'; +import type { LabeledPullRequest } from './handle-accepted.ts'; + +/** A positive integer, written with digits only. */ +const PR_NUMBER = /^[1-9]\d*$/; + +type Action = ({ type: 'handle' } & LabeledPullRequest) | { type: 'refresh' }; + +/** + * Chooses what a run does. A `workflow_dispatch` event, or + * `--preview-refresh` in a local run, refreshes the open umbrellas. Otherwise, + * the run handles a PR. For the PR and its sender, a flag wins over the event. + * + * @param {object} signals - The flags and the event of the run. + * @param {string} [signals.pr] - The value of `--pr`. + * @param {string} [signals.sender] - The value of `--sender`. + * @param {boolean} [signals.isRefreshRequested] - Whether + * `--preview-refresh` was passed. + * @param {string} [signals.eventName] - The event that triggered the run. + * @param {number} [signals.eventPullRequest] - The PR of that event. + * @param {string} [signals.eventSender] - Who triggered that event. + * @param {boolean} [signals.isSenderRequired] - Whether the write access + * check must run, as in GitHub Actions. + * @returns {Action} The refresh, or the PR to handle with who labeled it. + * @throws {Error} When both a PR and a refresh are asked for, when the PR + * number is missing or invalid, or when a required sender is missing. + */ +export function chooseAction({ + pr, + sender, + isRefreshRequested = false, + eventName, + eventPullRequest, + eventSender, + isSenderRequired = false, +}: { + pr?: string | undefined; + sender?: string | undefined; + isRefreshRequested?: boolean | undefined; + eventName?: string | undefined; + eventPullRequest?: number | undefined; + eventSender?: string | undefined; + isSenderRequired?: boolean | undefined; +}): Action { + const shouldRefresh = isRefreshRequested || eventName === 'workflow_dispatch'; + if (shouldRefresh && pr !== undefined) + throw new Error('Pass --pr or --preview-refresh, not both.'); + if (shouldRefresh) return { type: 'refresh' }; + + const rawPrNumber = pr ?? eventPullRequest?.toString(); + if (rawPrNumber === undefined) throw new Error('No PR to handle: pass --pr .'); + // Digits only: `Number()` would also read `1e3`, `0x10` or ` 7 `. + if (!PR_NUMBER.test(rawPrNumber)) { + throw new Error(`Invalid PR number: ${rawPrNumber}. Pass --pr .`); + } + + const labelSender = sender ?? eventSender; + if (labelSender === undefined && isSenderRequired) { + throw new Error('The event has no sender: the write access check needs one.'); + } + return { type: 'handle', prNumber: Number(rawPrNumber), labelSender }; +} + +/** What a run reads from the event of GitHub Actions. */ +export interface RunEvent { + pullRequest?: number | undefined; + labelSender?: string | undefined; +} + +/** + * Reads the PR number and the login of who added the label from an event. + * + * @param {string} json - The content of the event file. + * @param {string} path - The path of the event file, for the errors. + * @returns {RunEvent} What the event holds. + * @throws {Error} When the event isn't a JSON object, or a field is malformed. + */ +export function parseEvent(json: string, path: string): RunEvent { + const event = parseJson(json, `The event in ${path}`); + if (!isRecord(event)) throw new Error(`The event in ${path} isn't an object.`); + return { + pullRequest: readPullRequestNumber(event.pull_request, path), + labelSender: readSenderLogin(event.sender, path), + }; +} + +function readPullRequestNumber(pullRequest: unknown, path: string): number | undefined { + if (pullRequest === undefined) return undefined; + if (isRecord(pullRequest) && Number.isInteger(pullRequest.number)) { + return Number(pullRequest.number); + } + throw new Error(`The event in ${path} has no number in \`pull_request\`.`); +} + +function readSenderLogin(sender: unknown, path: string): string | undefined { + if (sender === undefined) return undefined; + if (isRecord(sender) && typeof sender.login === 'string') return sender.login; + throw new Error(`The event in ${path} has no login in \`sender\`.`); +} diff --git a/scripts/lib/release-queue/config.ts b/scripts/lib/release-queue/config.ts new file mode 100644 index 0000000000000..da4c92ab831e3 --- /dev/null +++ b/scripts/lib/release-queue/config.ts @@ -0,0 +1,47 @@ +// The names the release queue uses: repositories, branches, labels, and the +// accounts and directives it relies on. + +// The docs repository. + +export const DOCS_REPO = 'withastro/docs'; +export const DOCS_STABLE_BRANCH = 'main'; +export const UMBRELLA_BRANCH_PREFIX = 'release-queue/'; + +/** The label meaning a docs PR ships with the next release. */ +export const ACCEPTED_LABEL = 'accepted'; +export const MERGE_ON_RELEASE_LABEL = 'merge-on-release'; +/** Added by `pr-labeler.yml` when translations change. */ +export const TRANSLATIONS_LABEL = 'i18n'; + +/** The login of the bot behind `GITHUB_TOKEN`. */ +export const MERGE_BLOCK_REVIEWER_LOGIN = 'github-actions[bot]'; + +export const LUNARIA_DIRECTIVE = '@lunaria-track:src/content/docs/en/**.mdx'; +export const LUNARIA_DIRECTIVE_DOCS = + 'https://lunaria.dev/guides/tracking/#triggering-changes-per-file'; + +// The framework repository. + +export const FRAMEWORK_REPO = 'withastro/astro'; +export const FRAMEWORK_CORE_PACKAGE = 'astro'; +export const FRAMEWORK_STABLE_BRANCH = 'main'; +export const FRAMEWORK_PRERELEASE_BRANCH = 'next'; +export const RELEASE_TITLE_PREFIX = '[ci] release'; + +// The release types, and the docs labels that name them. + +export const RELEASE_TYPES = { patch: 'patch', minor: 'minor', major: 'major' } as const; + +export type ReleaseType = (typeof RELEASE_TYPES)[keyof typeof RELEASE_TYPES]; + +export const RELEASE_LABELS = { + patch: `${RELEASE_TYPES.patch}-release`, + minor: `${RELEASE_TYPES.minor}-release`, + major: `${RELEASE_TYPES.major}-release`, +} as const satisfies { [Type in ReleaseType]: `${Type}-release` }; + +export type ReleaseLabel = (typeof RELEASE_LABELS)[keyof typeof RELEASE_LABELS]; + +export const ALL_RELEASE_LABELS: ReleaseLabel[] = Object.values(RELEASE_LABELS); + +export const FALLBACK_RELEASE_LABEL: ReleaseLabel = RELEASE_LABELS.patch; diff --git a/scripts/lib/release-queue/formats/json.ts b/scripts/lib/release-queue/formats/json.ts new file mode 100644 index 0000000000000..88d6e14f60a75 --- /dev/null +++ b/scripts/lib/release-queue/formats/json.ts @@ -0,0 +1,27 @@ +/** + * Whether a value is an object whose fields can be read, and not `null` or an + * array. + * + * @param {unknown} value - The value to check, such as parsed JSON. + * @returns {boolean} `true` for an object. + */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Parses a JSON text. + * + * @param {string} json - The text to parse. + * @param {string} source - What the text is, to name it in the error. + * @returns {unknown} The parsed value, to validate before use. + * @throws {Error} When the text isn't valid JSON, with the parse error as its + * cause. + */ +export function parseJson(json: string, source: string): unknown { + try { + return JSON.parse(json); + } catch (error) { + throw new Error(`${source} isn't valid JSON.`, { cause: error }); + } +} diff --git a/scripts/lib/release-queue/formats/markdown.ts b/scripts/lib/release-queue/formats/markdown.ts new file mode 100644 index 0000000000000..ee5363978cda1 --- /dev/null +++ b/scripts/lib/release-queue/formats/markdown.ts @@ -0,0 +1,22 @@ +// The Markdown text of PR descriptions, comments and reviews. + +/** + * Replaces with `\n` the `\r\n` line endings that GitHub keeps when a + * description or a review is edited in the browser. + * + * @param {string} text - A description or a review body read from GitHub. + * @returns {string} The text with `\n` line endings only. + */ +export function normalizeLineEndings(text: string): string { + return text.replaceAll('\r\n', '\n'); +} + +/** + * Writes text as inline code. + * + * @param {string} text - A name, such as a branch or a label. + * @returns {string} The text between backticks. + */ +export function wrapInCode(text: string): string { + return `\`${text}\``; +} diff --git a/scripts/lib/release-queue/github/client.test.ts b/scripts/lib/release-queue/github/client.test.ts new file mode 100644 index 0000000000000..8689409bdb1f8 --- /dev/null +++ b/scripts/lib/release-queue/github/client.test.ts @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import { describe, it, type TestContext } from 'node:test'; +import { json, stubGitHub } from '../testing/stub-github.ts'; +import { GitHubClient, PrintingGitHubClient } from './client.ts'; +import { GitHubError } from './error.ts'; + +/** Replaces `fetch` with one answering `response` to every request. */ +function answer(t: TestContext, response: Response) { + return t.mock.method(globalThis, 'fetch', async () => response); +} + +describe('GitHubClient', () => { + it('answers with the JSON body', async (t) => { + const fetch = answer(t, json({ number: 7 })); + + const data = await new GitHubClient('token').get('repos/withastro/docs/pulls/7'); + + assert.deepEqual(data, { number: 7 }); + const [url, init] = fetch.mock.calls[0].arguments; + assert.equal(url, 'https://api.github.com/repos/withastro/docs/pulls/7'); + assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer token'); + }); + + it('answers `undefined` without a body', async (t) => { + answer(t, new Response(null, { status: 204 })); + + const data = await new GitHubClient('token').write('DELETE', 'repos/withastro/docs/x'); + + assert.equal(data, undefined); + }); + + it('adds the details of a validation error to its reason', async (t) => { + answer( + t, + json( + { + message: 'Validation Failed', + errors: [{ resource: 'PullRequest', message: 'No commits between main and x' }], + }, + 422 + ) + ); + + await assert.rejects( + new GitHubClient('token').write('POST', 'repos/withastro/docs/pulls', {}), + (error: unknown) => { + assert.ok(error instanceof GitHubError); + assert.equal(error.status, 422); + assert.equal(error.githubReason, 'Validation Failed: No commits between main and x'); + return true; + } + ); + }); + + it('names the request when a JSON answer is malformed', async (t) => { + answer(t, new Response('{', { headers: { 'content-type': 'application/json' } })); + + await assert.rejects( + new GitHubClient('token').get('repos/withastro/docs/pulls/7'), + /^Error: GET repos\/withastro\/docs\/pulls\/7 isn't valid JSON\.$/ + ); + }); + + it('keeps the message alone when an error has no details', async (t) => { + answer(t, json({ message: 'Not Found' }, 404)); + + await assert.rejects(new GitHubClient('token').get('repos/withastro/docs/pulls/0'), { + githubReason: 'Not Found', + }); + }); + + for (const body of [null, 'Bad gateway']) { + it(`uses the status text of a JSON error body that is ${JSON.stringify(body)}`, async (t) => { + answer( + t, + new Response(JSON.stringify(body), { + status: 502, + statusText: 'Bad Gateway', + headers: { 'content-type': 'application/json' }, + }) + ); + + await assert.rejects(new GitHubClient('token').get('repos/withastro/docs/pulls/7'), { + name: 'GitHubError', + status: 502, + githubReason: 'Bad Gateway', + }); + }); + } + + it('uses the status text of an error page that is not JSON', async (t) => { + answer(t, new Response('Bad gateway', { status: 502, statusText: 'Bad Gateway' })); + + await assert.rejects(new GitHubClient('token').get('repos/withastro/docs/pulls/7'), { + githubReason: 'Bad Gateway', + }); + }); + + it('refuses a successful answer that is not JSON', async (t) => { + answer(t, new Response('Hello', { status: 200 })); + + await assert.rejects( + new GitHubClient('token').get('repos/withastro/docs/pulls/7'), + /^Error: GET repos\/withastro\/docs\/pulls\/7 answered 200 without JSON\.$/ + ); + }); + + it('gets every page of a list', async (t) => { + const items = Array.from({ length: 130 }, (_, index) => index); + const fetch = t.mock.method(globalThis, 'fetch', async (url: string) => { + const page = Number(new URL(url).searchParams.get('page')); + return json(items.slice((page - 1) * 100, page * 100)); + }); + + const all = await new GitHubClient('token').getAll('repos/withastro/docs/labels'); + + assert.deepEqual(all, items); + assert.deepEqual( + fetch.mock.calls.map(({ arguments: [url] }) => url), + [ + 'https://api.github.com/repos/withastro/docs/labels?per_page=100&page=1', + 'https://api.github.com/repos/withastro/docs/labels?per_page=100&page=2', + ] + ); + }); + + describe('poll', () => { + const path = 'repos/withastro/docs/pulls/7'; + const isReady = (data: { isReady: boolean }) => data.isReady; + + it('gets the path until the answer is ready', async (t) => { + let requests = 0; + const github = stubGitHub(t, { + getResponses: { [path]: () => ({ isReady: ++requests === 3 }) }, + }); + + assert.deepEqual(await github.client().poll(path, { isReady, attempts: 5 }), { + isReady: true, + }); + assert.equal(github.countReads(path), 3); + }); + + it('answers with the last answer once the attempts run out', async (t) => { + const github = stubGitHub(t, { getResponses: { [path]: { isReady: false } } }); + + assert.deepEqual(await github.client().poll(path, { isReady, attempts: 2 }), { + isReady: false, + }); + assert.equal(github.countReads(path), 2); + }); + }); +}); + +describe('PrintingGitHubClient', () => { + it('prints writes without sending them', async (t) => { + const fetch = answer(t, json({})); + t.mock.method(console, 'info', () => {}); + + const data = await new PrintingGitHubClient('token').write( + 'POST', + 'repos/withastro/docs/issues/7/comments', + { body: 'Hello' } + ); + + assert.equal(data, undefined); + assert.equal(fetch.mock.callCount(), 0); + }); +}); diff --git a/scripts/lib/release-queue/github/client.ts b/scripts/lib/release-queue/github/client.ts new file mode 100644 index 0000000000000..50502f09c8767 --- /dev/null +++ b/scripts/lib/release-queue/github/client.ts @@ -0,0 +1,176 @@ +import { setTimeout } from 'node:timers/promises'; +import { isRecord, parseJson } from '../formats/json.ts'; +import { GitHubError } from './error.ts'; + +const API_URL = 'https://api.github.com'; +const API_VERSION = '2022-11-28'; +const USER_AGENT = 'astro-docs/1.0'; +const PAGE_SIZE = 100; +const POLL_DELAY_MS = 2000; + +export type WriteMethod = 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + +/** + * A minimal GitHub REST client. + * + * The shapes callers declare for the answers aren't checked, because the API + * version is pinned and the queries map each answer to the bot's own types. + */ +export class GitHubClient { + #token: string; + #pollDelayMs: number; + + /** + * Creates a client that authenticates with a token. + * + * @param {string} token - The token sent with each request. + * @param {object} [options] - How the client waits. + * @param {number} [options.pollDelayMs] - The delay between two reads of + * `poll`. Two seconds by default. + */ + constructor(token: string, { pollDelayMs = POLL_DELAY_MS } = {}) { + this.#token = token; + this.#pollDelayMs = pollDelayMs; + } + + /** + * Reads an endpoint. + * + * @param {string} path - The endpoint, relative to the API root. + * @returns {Promise} The JSON answer. + * @throws {GitHubError} When GitHub answers with an error. + * @throws {Error} When the answer has no body. + */ + async get(path: string): Promise { + const data = await this.#request('GET', path); + if (data === undefined) throw new Error(`GET ${path} answered without a body.`); + return data; + } + + /** + * Reads an endpoint until its answer is ready, for data GitHub computes in + * the background. + * + * @param {string} path - The endpoint, relative to the API root. + * @param {object} options - When to stop. + * @param {(data: T) => boolean} options.isReady - Whether an answer is ready. + * @param {number} options.attempts - How many times to read at most. + * @returns {Promise} The first ready answer, or the last one once the + * attempts run out. + */ + async poll( + path: string, + { isReady, attempts }: { isReady: (data: T) => boolean; attempts: number } + ): Promise { + for (let attempt = 1; ; attempt++) { + const data = await this.get(path); + if (isReady(data) || attempt >= attempts) return data; + await setTimeout(this.#pollDelayMs); + } + } + + /** + * Gets every page of a list endpoint. + * + * @param {string} path - The endpoint, relative to the API root. + * @param {(data: R) => T[]} [pick] - Extracts the items when the endpoint + * wraps them in an object. + * @returns {Promise} The items of every page. + */ + async getAll( + path: string, + pick: (data: R) => T[] = (data) => data as T[] + ): Promise { + const items: T[] = []; + const separator = path.includes('?') ? '&' : '?'; + for (let page = 1; ; page++) { + const pageItems = pick( + await this.get(`${path}${separator}per_page=${PAGE_SIZE}&page=${page}`) + ); + items.push(...pageItems); + if (pageItems.length < PAGE_SIZE) return items; + } + } + + /** + * Sends a request that changes GitHub. + * + * @param {WriteMethod} method - The HTTP method. + * @param {string} path - The endpoint, relative to the API root. + * @param {unknown} [body] - The JSON body of the request. + * @returns {Promise} The JSON answer, or `undefined` when + * GitHub answers without a body. + * @throws {GitHubError} When GitHub answers with an error. + */ + async write(method: WriteMethod, path: string, body?: unknown): Promise { + return this.#request(method, path, body); + } + + async #request(method: string, path: string, body?: unknown): Promise { + const response = await fetch(`${API_URL}/${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${this.#token}`, + 'User-Agent': USER_AGENT, + 'X-GitHub-Api-Version': API_VERSION, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + const request = `${method} ${path}`; + if (response.status === 204) return undefined; + // Error pages from a proxy, such as a 502, are HTML. + const isJson = response.headers.get('content-type')?.includes('application/json'); + const text = await response.text(); + if (!response.ok) { + const reason = (isJson && getErrorReason(text)) || response.statusText; + throw new GitHubError({ status: response.status, githubReason: reason, request }); + } + if (!isJson) throw new Error(`${request} answered ${response.status} without JSON.`); + return parseJson(text, request) as T; + } +} + +/** + * A GitHub client that prints the requests that change GitHub instead of + * sending them, for local runs. Reads still go to GitHub. + */ +export class PrintingGitHubClient extends GitHubClient { + override async write( + method: WriteMethod, + path: string, + body?: unknown + ): Promise { + console.info(`[print] ${method} ${path}`); + if (body) console.info(JSON.stringify(body, null, 2)); + return undefined; + } +} + +/** + * Gets the reason of an error body, with its details, because a 422 only says + * "Validation Failed" in `message` and puts the details in `errors`. + */ +function getErrorReason(text: string): string | undefined { + const body = parseErrorBody(text); + if (!isRecord(body) || typeof body.message !== 'string' || !body.message) return undefined; + const errors = Array.isArray(body.errors) ? body.errors : []; + const details = errors.map(readErrorMessage).filter((detail) => detail !== undefined); + return details.length > 0 ? `${body.message}: ${details.join('; ')}` : body.message; +} + +/** Parses an error body. A malformed one has no reason to read. */ +function parseErrorBody(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** Reads an item of `errors`: a message, or an object with one. */ +function readErrorMessage(error: unknown): string | undefined { + if (typeof error === 'string') return error; + return isRecord(error) && typeof error.message === 'string' ? error.message : undefined; +} diff --git a/scripts/lib/release-queue/github/error.ts b/scripts/lib/release-queue/github/error.ts new file mode 100644 index 0000000000000..0635e16bb3472 --- /dev/null +++ b/scripts/lib/release-queue/github/error.ts @@ -0,0 +1,39 @@ +/** An error answer from the GitHub API. */ +export class GitHubError extends Error { + name = 'GitHubError'; + readonly status: number; + readonly githubReason: string; + + /** + * Creates the error of a failed request. + * + * @param {object} answer - The failed request and GitHub's answer. + * @param {number} answer.status - The HTTP status of the answer. + * @param {string} answer.githubReason - The reason GitHub gives, with its + * details. + * @param {string} answer.request - The method and the path of the request. + */ + constructor({ + status, + githubReason, + request, + }: { + status: number; + githubReason: string; + request: string; + }) { + super(`${request} failed (${status}): ${githubReason}`); + this.status = status; + this.githubReason = githubReason; + } +} + +/** + * Whether an error is a 404 answer from the GitHub API. + * + * @param {unknown} error - A caught error. + * @returns {boolean} `true` when the resource doesn't exist. + */ +export function isNotFound(error: unknown): error is GitHubError { + return error instanceof GitHubError && error.status === 404; +} diff --git a/scripts/lib/release-queue/github/repository.ts b/scripts/lib/release-queue/github/repository.ts new file mode 100644 index 0000000000000..5e0323ffef8d7 --- /dev/null +++ b/scripts/lib/release-queue/github/repository.ts @@ -0,0 +1,171 @@ +import type { GitHubClient, WriteMethod } from './client.ts'; +import { isNotFound } from './error.ts'; +import type { CompareResponse, PullRequestResponse } from './types.ts'; + +/** Labels to add to a PR, and labels to remove from it. */ +export interface LabelChanges { + toAdd: string[]; + toRemove: string[]; +} + +/** A GitHub client bound to a repository, with paths relative to it. */ +export class Repository { + readonly name: string; + readonly owner: string; + #client: GitHubClient; + + /** + * Binds a client to a repository. + * + * @param {GitHubClient} client - The client, with its token. + * @param {string} name - The repository, as `/`. + */ + constructor(client: GitHubClient, name: string) { + this.#client = client; + this.name = name; + [this.owner] = name.split('/'); + } + + get(path: string): Promise { + return this.#client.get(this.#url(path)); + } + + /** + * Reads an endpoint whose resource may not exist. + * + * @param {string} path - The endpoint, relative to the repository. + * @returns {Promise} The JSON answer, or `undefined` for a + * 404. + */ + async find(path: string): Promise { + try { + return await this.get(path); + } catch (error) { + if (isNotFound(error)) return undefined; + throw error; + } + } + + getAll(path: string, pick?: (data: R) => T[]): Promise { + return this.#client.getAll(this.#url(path), pick); + } + + poll(path: string, options: { isReady: (data: T) => boolean; attempts: number }): Promise { + return this.#client.poll(this.#url(path), options); + } + + write(method: WriteMethod, path: string, body?: unknown): Promise { + return this.#client.write(method, this.#url(path), body); + } + + /** + * Lists the open PRs of the repository, every page of them. + * + * @param {object} [filter] - Which open PRs to keep. + * @param {string} [filter.head] - The branch the PRs come from, in this + * repository. + * @param {string} [filter.base] - The branch the PRs merge into. + * @returns {Promise} The open PRs. + */ + listOpenPullRequests({ head, base }: { head?: string; base?: string } = {}): Promise< + PullRequestResponse[] + > { + const filters = [ + 'state=open', + // GitHub reads a head without an owner as no filter at all. + ...(head === undefined ? [] : [`head=${this.owner}:${head}`]), + ...(base === undefined ? [] : [`base=${base}`]), + ]; + return this.getAll(`pulls?${filters.join('&')}`); + } + + /** + * Compares two branches of the repository. + * + * @param {string} base - The branch to compare with. + * @param {string} head - The branch compared to `base`. + * @returns {Promise} The commits of `head` missing in + * `base`, and their count. + */ + compareBranches(base: string, head: string): Promise { + return this.get(`compare/${base}...${head}`); + } + + /** + * Counts the commits of a branch that aren't in another. + * + * @param {string} base - The branch to compare with. + * @param {string} head - The branch whose commits are counted. + * @returns {Promise} The number of commits of `head` missing in + * `base`. + */ + async countCommitsAhead(base: string, head: string): Promise { + const { ahead_by } = await this.compareBranches(base, head); + return ahead_by; + } + + /** + * Gets the commit a branch points to. + * + * @param {string} branch - The branch name. + * @returns {Promise} The commit SHA, or `undefined` when + * the branch doesn't exist. + */ + async getBranchSha(branch: string): Promise { + const ref = await this.find<{ object: { sha: string } }>(`git/ref/heads/${branch}`); + return ref?.object.sha; + } + + /** + * Comments on a PR. + * + * @param {number} prNumber - The number of the PR. + * @param {string} body - The comment, in Markdown. + */ + async postComment(prNumber: number, body: string) { + await this.write('POST', `issues/${prNumber}/comments`, { body }); + } + + /** + * Adds and removes labels on a PR. Its other labels stay. + * + * @param {number} prNumber - The number of the PR. + * @param {LabelChanges} changes - The labels to add and to remove. + */ + async updateLabels(prNumber: number, { toAdd, toRemove }: LabelChanges) { + if (toAdd.length > 0) await this.write('POST', `issues/${prNumber}/labels`, { labels: toAdd }); + await Promise.all(toRemove.map((label) => this.removeLabel(prNumber, label))); + } + + /** + * Removes a label from a PR. A label that is already gone is fine. + * + * @param {number} prNumber - The number of the PR. + * @param {string} label - The label to remove. + */ + async removeLabel(prNumber: number, label: string) { + try { + await this.write('DELETE', `issues/${prNumber}/labels/${label}`); + } catch (error) { + if (!isNotFound(error)) throw error; + } + } + + /** + * Whether a user can write to the repository. + * + * @param {string} user - The login of the user. + * @returns {Promise} `true` for the admin, maintain and write + * permissions. `false` for a user who isn't a collaborator. + */ + async canWrite(user: string): Promise { + const answer = await this.find<{ permission: string }>(`collaborators/${user}/permission`); + // The legacy `permission` field maps `maintain` to `write`, + // and `triage` to `read`. + return answer?.permission === 'admin' || answer?.permission === 'write'; + } + + #url(path: string): string { + return `repos/${this.name}/${path}`; + } +} diff --git a/scripts/lib/release-queue/github/types.ts b/scripts/lib/release-queue/github/types.ts new file mode 100644 index 0000000000000..db20406a6b21a --- /dev/null +++ b/scripts/lib/release-queue/github/types.ts @@ -0,0 +1,64 @@ +// The fields the bot reads from GitHub API responses. + +export type PullRequestState = 'open' | 'closed'; + +export interface PullRequestResponse { + number: number; + title: string; + html_url: string; + state: PullRequestState; + draft: boolean; + mergeable: boolean | null; + base: { ref: string }; + /** `repo` is `null` when the fork was deleted. */ + head: { ref: string; sha: string; repo?: { full_name: string } | null }; + labels: { name: string }[]; + body: string | null; + merged?: boolean; +} + +export interface CompareResponse { + ahead_by: number; + /** At most 250 commits, oldest first. */ + commits: { commit: { message: string } }[]; +} + +export type ReviewResponseState = + | 'APPROVED' + | 'CHANGES_REQUESTED' + | 'COMMENTED' + | 'DISMISSED' + | 'PENDING'; + +export interface ReviewResponse { + /** `null` for a deleted account. */ + user: { login: string } | null; + state: ReviewResponseState; +} + +export interface CheckRunResponse { + name: string; + status: 'queued' | 'in_progress' | 'completed' | 'waiting' | 'requested' | 'pending'; + conclusion: CheckConclusion | null; +} + +export type CheckConclusion = + | 'action_required' + | 'cancelled' + | 'failure' + | 'neutral' + | 'skipped' + | 'stale' + | 'success' + | 'timed_out'; + +export interface CommitStatusResponse { + context: string; + state: 'error' | 'failure' | 'pending' | 'success'; +} + +/** A rule of a ruleset. GitHub adds rule types over time. */ +export interface RuleResponse { + type: string; + parameters?: { required_status_checks?: { context: string }[] }; +} diff --git a/scripts/lib/release-queue/handle-accepted.test.ts b/scripts/lib/release-queue/handle-accepted.test.ts new file mode 100644 index 0000000000000..751e3a3bd7e81 --- /dev/null +++ b/scripts/lib/release-queue/handle-accepted.test.ts @@ -0,0 +1,444 @@ +import assert from 'node:assert/strict'; +import { describe, it, type TestContext } from 'node:test'; +import { GitHubError } from './github/error.ts'; +import { handleAcceptedPullRequest } from './handle-accepted.ts'; +import type { Mode } from './modes.ts'; +import { pullRequestResponse, REPO, umbrellaResponse } from './testing/fixtures.ts'; +import { ACTIONS_TOKEN, BOT_TOKEN, stubRunContext } from './testing/run-context.ts'; +import { readBody, stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; +import { formatUmbrellaBody } from './umbrella/body.ts'; + +const paths = { + pr: `repos/${REPO}/pulls/7`, + reviews: `repos/${REPO}/pulls/7/reviews`, + releasePullRequest: + 'repos/withastro/astro/pulls?state=open&head=withastro:changeset-release/main', + stableUmbrella: `repos/${REPO}/pulls?state=open&head=withastro:release-queue/main`, +}; + +/** + * GET responses for PR #7, ready to join the open umbrella #42 of `base`. The + * umbrella is up to date but for its body. + */ +function readyPullRequest({ base = 'main', label = 'minor-release', umbrellaBody = '' } = {}) { + const umbrellaBranch = `release-queue/${base}`; + const umbrella = umbrellaResponse({ + umbrellaBase: base, + title: base === 'main' ? '[ci] release astro@7.5.0 docs' : `[ci] release ${base} docs`, + labels: [{ name: 'merge-on-release' }, { name: label }], + body: umbrellaBody, + }); + return { + [paths.pr]: pullRequestResponse({ + base: { ref: base }, + labels: [{ name: 'accepted' }, { name: label }], + }), + [paths.reviews]: [{ user: { login: 'maintainer' }, state: 'APPROVED' }], + [`repos/${REPO}/collaborators/maintainer/permission`]: { permission: 'write' }, + [`repos/${REPO}/collaborators/contributor/permission`]: { permission: 'read' }, + [`repos/${REPO}/commits/head-sha/check-runs`]: { + check_runs: [{ name: 'eslint', status: 'completed', conclusion: 'success' }], + }, + [`repos/${REPO}/commits/head-sha/status`]: { statuses: [] }, + [`repos/${REPO}/rules/branches/main`]: [ + { + type: 'required_status_checks', + parameters: { required_status_checks: [{ context: 'eslint' }] }, + }, + ], + [paths.releasePullRequest]: [{ number: 1, html_url: '', body: '## astro@7.5.0\n' }], + [`repos/${REPO}/pulls?state=open&head=withastro:${umbrellaBranch}`]: [umbrella], + [`repos/${REPO}/pulls/42`]: umbrella, + [`repos/${REPO}/git/ref/heads/${base}`]: { object: { sha: 'base-sha' } }, + [`repos/${REPO}/compare/${base}...${umbrellaBranch}`]: { ahead_by: 0, commits: [] }, + // No merge-block review yet, and no other PR on the umbrella branch. + [`repos/${REPO}/pulls/42/reviews`]: [], + [`repos/${REPO}/pulls?state=open&base=${umbrellaBranch}`]: [], + }; +} + +/** Responses for a PR that `decideJoin` refuses: no approval. */ +const unapprovedPullRequest = () => ({ ...readyPullRequest(), [paths.reviews]: [] }); + +/** Responses for a PR that opens the `main` umbrella with its first join. */ +const firstJoin = () => ({ ...readyPullRequest(), [paths.stableUmbrella]: [] }); + +/** + * Responses where the `main` umbrella #42 is settled: it links release PR + * #18405, merged, while #1 is the open one. + */ +const settledUmbrella = () => ({ + ...readyPullRequest({ + umbrellaBody: formatUmbrellaBody('main', { releasePrNumber: 18405, included: [] }), + }), + 'repos/withastro/astro/pulls/18405': { number: 18405, merged: true }, +}); + +/** Stubs GitHub with PR #7 ready to join, unless `options` say otherwise. */ +const stubReadyPullRequest = (t: TestContext, options: StubOptions = {}) => + stubGitHub(t, { getResponses: readyPullRequest(), ...options }); + +/** Handles PR #7, and returns the writes of each identity. */ +async function run(t: TestContext, mode: Mode, options: StubOptions = {}, sender = 'maintainer') { + const github = stubReadyPullRequest(t, options); + await handleAcceptedPullRequest(stubRunContext(github, mode), { + prNumber: 7, + labelSender: sender, + }); + return { actionsWrites: github.writesBy(ACTIONS_TOKEN), botWrites: github.writesBy(BOT_TOKEN) }; +} + +const writtenPaths = (writes: Write[]) => writes.map(({ method, path }) => `${method} ${path}`); + +/** The body of the last comment in `writes`. */ +function commentBody(writes: Write[]): string { + const write = writes.findLast(({ path }) => path.endsWith('/comments')); + return readBody(write); +} + +const comment = `POST repos/${REPO}/issues/7/comments`; +const removeAccepted = `DELETE repos/${REPO}/issues/7/labels/accepted`; + +describe('handleAcceptedPullRequest', () => { + it('only comments in a dry run', async (t) => { + const { actionsWrites, botWrites } = await run(t, 'dry-run'); + + assert.deepEqual(writtenPaths(actionsWrites), [comment]); + assert.match(commentBody(actionsWrites), /would be squash-merged/); + assert.deepEqual(botWrites, []); + }); + + it('only comments on a refused PR in a dry run', async (t) => { + const { actionsWrites, botWrites } = await run(t, 'dry-run', { + getResponses: unapprovedPullRequest(), + }); + + assert.deepEqual(writtenPaths(actionsWrites), [comment]); + assert.match(commentBody(actionsWrites), /wouldn't join #42 into `main` yet:/); + assert.deepEqual(botWrites, []); + }); + + it('joins a PR shipping to main in stable-only mode', async (t) => { + const { actionsWrites, botWrites } = await run(t, 'stable-only'); + + // The merge-block review comes from `github-actions[bot]`. + assert.deepEqual(writtenPaths(actionsWrites), [`POST repos/${REPO}/pulls/42/reviews`]); + assert.deepEqual(writtenPaths(botWrites), [ + `PATCH repos/${REPO}/pulls/7`, + `PUT repos/${REPO}/pulls/7/merge`, + comment, + // The refresh: only the body of the umbrella is out of date. + `PATCH repos/${REPO}/pulls/42`, + ]); + }); + + it('lists the PRs still on the umbrella branch in the review', async (t) => { + const { actionsWrites } = await run(t, 'stable-only', { + getResponses: { + ...readyPullRequest(), + [`repos/${REPO}/pulls?state=open&base=release-queue/main`]: [{ number: 14590 }], + }, + }); + + const review = actionsWrites.find(({ path }) => path.endsWith('/reviews')); + assert.match(readBody(review), /These PRs still target `release-queue\/main`: #14590\./); + }); + + it('opens the umbrella PR with its first join, then labels it', async (t) => { + const { botWrites } = await run(t, 'stable-only', { + getResponses: { + ...firstJoin(), + [`repos/${REPO}/pulls/99/reviews`]: [], + [`repos/${REPO}/pulls/99`]: umbrellaResponse({ + number: 99, + title: '[ci] release astro@7.5.0 docs', + body: 'Release umbrella for `main`, managed by the release queue.', + labels: [], + }), + }, + writeResults: { [`POST repos/${REPO}/pulls`]: { number: 99 } }, + }); + + assert.deepEqual(writtenPaths(botWrites), [ + `POST repos/${REPO}/git/refs`, + `PATCH repos/${REPO}/pulls/7`, + `PUT repos/${REPO}/pulls/7/merge`, + `POST repos/${REPO}/pulls`, + comment, + `PATCH repos/${REPO}/pulls/99`, + `POST repos/${REPO}/issues/99/labels`, + ]); + assert.match(commentBody(botWrites), /the branch of #99 into `main`/); + assert.deepEqual(botWrites.at(-1)?.body, { labels: ['merge-on-release', 'minor-release'] }); + }); + + it('only comments on a PR shipping to a prerelease branch in stable-only mode', async (t) => { + const getResponses = readyPullRequest({ base: 'cloudflare-next', label: 'major-release' }); + const { actionsWrites, botWrites } = await run(t, 'stable-only', { getResponses }); + + assert.deepEqual(writtenPaths(actionsWrites), [comment]); + assert.deepEqual(botWrites, []); + }); + + it('joins a PR shipping to a prerelease branch in all mode', async (t) => { + const getResponses = readyPullRequest({ base: 'cloudflare-next', label: 'major-release' }); + const { botWrites } = await run(t, 'all', { getResponses }); + + assert.deepEqual(writtenPaths(botWrites), [ + `PATCH repos/${REPO}/pulls/7`, + `PUT repos/${REPO}/pulls/7/merge`, + comment, + // The refresh: only the body of the umbrella is out of date. + `PATCH repos/${REPO}/pulls/42`, + ]); + }); + + it('links the release PR of next in a prerelease umbrella', async (t) => { + const { botWrites } = await run(t, 'all', { + getResponses: { + ...readyPullRequest({ base: 'cloudflare-next', label: 'major-release' }), + 'repos/withastro/astro/contents/.changeset/pre.json?ref=next': { + content: Buffer.from('{"mode":"pre"}').toString('base64'), + }, + 'repos/withastro/astro/pulls?state=open&head=withastro:changeset-release/next': [ + { number: 18269, body: '' }, + ], + }, + }); + + const refresh = botWrites.find(({ path }) => path === `repos/${REPO}/pulls/42`); + assert.match(readBody(refresh), /^Ships with withastro\/astro#18269\./); + }); + + it("doesn't read the prerelease line for a PR shipping to main", async (t) => { + const github = stubReadyPullRequest(t); + + await handleAcceptedPullRequest(stubRunContext(github, 'stable-only'), { + prNumber: 7, + labelSender: 'maintainer', + }); + + assert.ok(!github.calls.some(({ path }) => path.includes('.changeset/pre.json'))); + }); + + for (const [name, mode, getResponses] of [ + [ + 'a dry-run comment', + 'dry-run', + readyPullRequest({ base: 'cloudflare-next', label: 'major-release' }), + ], + [ + 'a refusal', + 'all', + { + ...readyPullRequest({ base: 'cloudflare-next', label: 'major-release' }), + [paths.reviews]: [], + }, + ], + ] as const) { + it(`doesn't read the prerelease line for ${name} of a prerelease PR`, async (t) => { + const github = stubReadyPullRequest(t, { getResponses }); + + await handleAcceptedPullRequest(stubRunContext(github, mode), { + prNumber: 7, + labelSender: 'maintainer', + }); + + assert.ok(github.writes.some(({ path }) => path.endsWith('/comments'))); + assert.ok(!github.calls.some(({ path }) => path.includes('.changeset/pre.json'))); + }); + } + + it('keeps the accepted label after a join, and names the umbrella', async (t) => { + const { botWrites } = await run(t, 'stable-only'); + + assert.ok(!writtenPaths(botWrites).includes(removeAccepted)); + assert.match(commentBody(botWrites), /the branch of #42 into `main`/); + }); + + it('removes the label, then comments, when refusing a PR', async (t) => { + const { botWrites } = await run(t, 'stable-only', { getResponses: unapprovedPullRequest() }); + + assert.deepEqual(writtenPaths(botWrites), [removeAccepted, comment]); + assert.match(commentBody(botWrites), /no approval/); + }); + + it('refuses a PR whose required check is a pending commit status', async (t) => { + const { botWrites } = await run(t, 'stable-only', { + getResponses: { + ...readyPullRequest(), + [`repos/${REPO}/rules/branches/main`]: [ + { + type: 'required_status_checks', + parameters: { required_status_checks: [{ context: 'eslint' }, { context: 'deploy' }] }, + }, + ], + [`repos/${REPO}/commits/head-sha/status`]: { + statuses: [{ context: 'deploy', state: 'pending' }], + }, + }, + }); + + assert.deepEqual(writtenPaths(botWrites), [removeAccepted, comment]); + assert.match(commentBody(botWrites), /haven't finished yet: `deploy`\./); + }); + + it('still comments when the label is already gone', async (t) => { + const { botWrites } = await run(t, 'stable-only', { + getResponses: unapprovedPullRequest(), + writeFailures: { [removeAccepted]: { status: 404, message: 'Label does not exist' } }, + }); + + assert.deepEqual(writtenPaths(botWrites), [removeAccepted, comment]); + }); + + it('names the open main umbrella when the next astro release is a major', async (t) => { + const { botWrites } = await run(t, 'stable-only', { + getResponses: { + ...readyPullRequest(), + [paths.releasePullRequest]: [{ number: 1, html_url: '', body: '## astro@8.0.0\n' }], + }, + }); + + assert.deepEqual(writtenPaths(botWrites), [removeAccepted, comment]); + assert.match(commentBody(botWrites), /`astro@8.0.0`/); + assert.match(commentBody(botWrites), /The open `main` umbrella \(#42\)/); + }); + + describe('while the main umbrella is settled', () => { + it('refuses a PR it would retarget, before changing it', async (t) => { + const { botWrites } = await run(t, 'stable-only', { getResponses: settledUmbrella() }); + + assert.deepEqual(writtenPaths(botWrites), [removeAccepted, comment]); + assert.match( + commentBody(botWrites), + /#42 ships with withastro\/astro#18405, which is already merged\. Merge #42 first\./ + ); + }); + + it('shows the refusal in a dry run', async (t) => { + const { actionsWrites } = await run(t, 'dry-run', { getResponses: settledUmbrella() }); + + assert.match(commentBody(actionsWrites), /^\*\*Release queue \(dry run\)\*\*/); + assert.match(commentBody(actionsWrites), /- #42 ships with .*, which is already merged\./); + }); + + it('joins a PR against the umbrella branch', async (t) => { + const { botWrites } = await run(t, 'stable-only', { + getResponses: { + ...settledUmbrella(), + [paths.pr]: pullRequestResponse({ + base: { ref: 'release-queue/main' }, + labels: [{ name: 'accepted' }, { name: 'minor-release' }], + }), + }, + }); + + assert.deepEqual(writtenPaths(botWrites).slice(0, 2), [ + `PUT repos/${REPO}/pulls/7/merge`, + comment, + ]); + }); + + const prsThatCantUseIt = { + 'a PR against the umbrella branch': pullRequestResponse({ + base: { ref: 'release-queue/main' }, + labels: [{ name: 'accepted' }, { name: 'minor-release' }], + }), + 'a closed PR': pullRequestResponse({ state: 'closed' }), + }; + for (const [name, pr] of Object.entries(prsThatCantUseIt)) { + it(`doesn't read the state for ${name}`, async (t) => { + const github = stubReadyPullRequest(t, { + getResponses: { ...settledUmbrella(), [paths.pr]: pr }, + }); + + await handleAcceptedPullRequest(stubRunContext(github, 'dry-run'), { + prNumber: 7, + labelSender: 'maintainer', + }); + + assert.equal(github.countReads('repos/withastro/astro/pulls/18405'), 0); + }); + } + + it("doesn't affect a PR shipping to a prerelease branch", async (t) => { + const github = stubReadyPullRequest(t, { + getResponses: { + ...settledUmbrella(), + ...readyPullRequest({ base: 'cloudflare-next', label: 'major-release' }), + }, + }); + + await handleAcceptedPullRequest(stubRunContext(github, 'all'), { + prNumber: 7, + labelSender: 'maintainer', + }); + + assert.ok(writtenPaths(github.writes).includes(`PUT repos/${REPO}/pulls/7/merge`)); + assert.equal(github.countReads('repos/withastro/astro/pulls/18405'), 0); + }); + }); + + it('refuses a PR whose join is refused', async (t) => { + const { botWrites } = await run(t, 'stable-only', { + getResponses: { + ...firstJoin(), + [`repos/${REPO}/git/ref/heads/release-queue/main`]: { object: { sha: 'old-sha' } }, + [`repos/${REPO}/compare/main...release-queue/main`]: { ahead_by: 3, commits: [] }, + }, + }); + + assert.deepEqual(writtenPaths(botWrites), [removeAccepted, comment]); + assert.match(commentBody(botWrites), /has commits but no open umbrella/); + }); + + it("comments, then fails, when the umbrella PR couldn't be opened", async (t) => { + const github = stubReadyPullRequest(t, { + getResponses: firstJoin(), + writeFailures: { + [`POST repos/${REPO}/pulls`]: { status: 422, message: 'Validation Failed' }, + }, + }); + + await assert.rejects( + handleAcceptedPullRequest(stubRunContext(github, 'stable-only'), { + prNumber: 7, + labelSender: 'maintainer', + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /but the umbrella PR couldn't be opened\.$/); + assert.ok(error.cause instanceof GitHubError); + assert.equal(error.cause.status, 422); + return true; + } + ); + assert.match( + commentBody(github.writesBy(BOT_TOKEN)), + /couldn't be opened: Validation Failed\./ + ); + }); + + it('comments on the join, then fails, when the refresh fails', async (t) => { + const responses: Record = readyPullRequest(); + delete responses[`repos/${REPO}/compare/main...release-queue/main`]; + const github = stubReadyPullRequest(t, { getResponses: responses }); + + await assert.rejects( + handleAcceptedPullRequest(stubRunContext(github, 'stable-only'), { + prNumber: 7, + labelSender: 'maintainer', + }), + { name: 'GitHubError', status: 404 } + ); + assert.match(commentBody(github.writesBy(BOT_TOKEN)), /squash-merged into/); + }); + + it('ignores a sender without write access', async (t) => { + const { actionsWrites, botWrites } = await run(t, 'stable-only', {}, 'contributor'); + + assert.deepEqual(actionsWrites, []); + assert.deepEqual(botWrites, []); + }); +}); diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts new file mode 100644 index 0000000000000..7d89075b2a79e --- /dev/null +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -0,0 +1,227 @@ +import { ACCEPTED_LABEL } from './config.ts'; +import type { Repository } from './github/repository.ts'; +import { + formatDryRunJoinComment, + formatDryRunRefusalComment, + formatJoinComment, + formatRefusalComment, + formatUnopenedJoinComment, + type MessageContext, +} from './join/messages.ts'; +import { getPullRequest, getReadiness, type PullRequest } from './join/queries.ts'; +import { + decideJoin, + wouldRetargetFromStable, + type Decision, + type RefusedDecision, + type Route, +} from './join/routing.ts'; +import { UmbrellaJoin, type Umbrella } from './join/umbrella-join.ts'; +import { canActOn } from './modes.ts'; +import { readFrameworkReleases, type FrameworkReleases } from './release/queries.ts'; +import type { RunContext } from './run-context.ts'; +import { getUmbrellaBase, STABLE_UMBRELLA_BRANCH } from './umbrella/branch.ts'; +import { getUmbrellaTitle } from './umbrella/heading.ts'; +import { findOpenUmbrella, getUmbrella } from './umbrella/queries.ts'; +import { UmbrellaRefresher } from './umbrella/refresh.ts'; +import { getSettledStableUmbrella } from './umbrella/state.ts'; + +/** A docs PR labeled as accepted, with the decision on it. */ +interface DecidedPullRequest { + pr: PullRequest; + releases: FrameworkReleases; + decision: Decision; +} + +/** A docs PR labeled as accepted, and who added the label. */ +export interface LabeledPullRequest { + prNumber: number; + /** Without it, the write access check is skipped, as in a local run. */ + labelSender?: string | undefined; +} + +/** + * Joins a docs PR labeled as accepted to its umbrella, refuses it, or + * comments on it as a dry run. + * + * @param {RunContext} run - The repositories and the mode of the run. + * @param {LabeledPullRequest} labeled - The docs PR, and who added the label. + */ +export async function handleAcceptedPullRequest( + run: RunContext, + { prNumber, labelSender }: LabeledPullRequest +) { + if (!(await isSenderAllowed(run.actions, labelSender))) return; + + const { pr, releases, decision } = await readAcceptedPullRequest(run, prNumber); + console.info(JSON.stringify({ pr: pr.url, release: releases.stable, decision }, null, 2)); + const accepted = new AcceptedPullRequest(run, { pr, releases }); + if (!canActOn(run.mode, getUmbrellaBase(pr.base))) { + await accepted.commentDryRun(decision); + } else if (decision.status === 'refuse') { + await accepted.refuse(decision); + } else { + await accepted.join(decision.route); + } +} + +/** Whether `labelSender` can write to the repository, like maintainers do. */ +async function isSenderAllowed( + repo: Repository, + labelSender: string | undefined +): Promise { + if (labelSender === undefined) { + console.info('No label sender: skipping the write access check.'); + return true; + } + if (await repo.canWrite(labelSender)) return true; + console.info(`Ignored: ${labelSender} can't write to ${repo.name}.`); + return false; +} + +/** Reads a docs PR, and decides its join. */ +async function readAcceptedPullRequest( + { actions, framework }: RunContext, + prNumber: number +): Promise { + const pr = await getPullRequest(actions, prNumber); + const [readiness, releases] = await Promise.all([ + getReadiness(actions, pr), + readFrameworkReleases(framework), + ]); + const settledStableUmbrella = wouldRetargetFromStable(pr) + ? await getSettledStableUmbrella(actions, releases) + : undefined; + const decision = decideJoin(pr, readiness, { release: releases.stable, settledStableUmbrella }); + return { pr, releases, decision }; +} + +/** What the bot does with a docs PR labeled as accepted. */ +class AcceptedPullRequest { + #run: RunContext; + #pr: PullRequest; + #releases: FrameworkReleases; + + /** + * Prepares what the bot does with an accepted docs PR. + * + * @param {RunContext} run - The repositories of the run. + * @param {object} accepted - The docs PR, and the releases of the framework. + * @param {PullRequest} accepted.pr - The docs PR. + * @param {FrameworkReleases} accepted.releases - The releases of the + * framework. + */ + constructor(run: RunContext, { pr, releases }: { pr: PullRequest; releases: FrameworkReleases }) { + this.#run = run; + this.#pr = pr; + this.#releases = releases; + } + + /** + * Comments on the PR with what a join would do, or why it would be refused. + * + * @param {Decision} decision - The decision on the PR. + */ + async commentDryRun(decision: Decision) { + const { actions } = this.#run; + if (decision.status === 'join') { + const umbrella = await this.#findOrPlanUmbrella(decision.route); + await actions.postComment(this.#pr.number, formatDryRunJoinComment(decision.route, umbrella)); + return; + } + const context = await this.#getMessageContext(decision); + await actions.postComment(this.#pr.number, formatDryRunRefusalComment(decision, context)); + } + + /** + * Removes the accepted label, then says why on the PR. + * + * @param {RefusedDecision} decision - The refusals, and the route if any. + */ + async refuse(decision: RefusedDecision) { + const { bot } = this.#run; + const context = await this.#getMessageContext(decision); + // Removed first, so the comment is true once posted. + await bot.removeLabel(this.#pr.number, ACCEPTED_LABEL); + await bot.postComment(this.#pr.number, formatRefusalComment(decision, context)); + } + + /** + * Joins the PR to the umbrella of `route`, then says how it went on the PR. + * + * @param {Route} route - The umbrella branch and base the PR goes to. + * @throws {Error} When the PR was merged, but the umbrella PR couldn't be + * opened. + */ + async join(route: Route) { + const pr = this.#pr; + const umbrellaTitle = getUmbrellaTitle(route.umbrellaBase, this.#releases.stable); + const join = new UmbrellaJoin(this.#run.bot, { pr, route, umbrellaTitle }); + const result = await join.mergePullRequest(); + + switch (result.status) { + case 'joined': + await this.#announceJoin(route, result.umbrella); + return; + case 'refused': + await this.refuse({ status: 'refuse', route, refusals: [result.refusal] }); + return; + case 'unopened': + await this.#run.bot.postComment(pr.number, formatUnopenedJoinComment(route, result)); + // The PR is merged, but a maintainer needs to open the umbrella PR. + throw new Error( + `#${pr.number} was merged into ${route.umbrellaBranch}, but the umbrella PR couldn't be opened.`, + { cause: result.error } + ); + default: { + const unknown: never = result; + throw new Error(`Unknown join result: ${JSON.stringify(unknown)}`); + } + } + } + + /** + * Comments on the PR that joined `umbrella`, then refreshes the umbrella and + * blocks its merge until a maintainer checks it. + */ + async #announceJoin(route: Route, umbrella: Umbrella) { + const { bot } = this.#run; + await bot.postComment(this.#pr.number, formatJoinComment(route, umbrella)); + // A planned umbrella was only printed. + if (umbrella.status === 'planned') return; + const openUmbrella = await getUmbrella(bot, umbrella.number); + const refresher = new UmbrellaRefresher(this.#run, this.#releases); + await refresher.refresh(openUmbrella, { shouldBlock: true }); + } + + /** Finds the open umbrella PR of `route`, or names the one to create. */ + async #findOrPlanUmbrella(route: Route): Promise { + const openUmbrella = await findOpenUmbrella(this.#run.actions, route.umbrellaBranch); + if (openUmbrella) return { status: 'open', number: openUmbrella.number }; + return { + status: 'planned', + title: getUmbrellaTitle(route.umbrellaBase, this.#releases.stable), + }; + } + + async #getMessageContext(decision: RefusedDecision): Promise { + const [umbrella, stableUmbrella] = await Promise.all([ + decision.route && this.#findOrPlanUmbrella(decision.route), + findStableUmbrellaToMove(this.#run.actions, decision), + ]); + return { umbrella, stableUmbrella }; + } +} + +/** + * Finds the open stable umbrella that a maintainer moves to the prerelease + * branch before a major, when the decision refuses a PR for that major. + */ +async function findStableUmbrellaToMove( + repo: Repository, + decision: RefusedDecision +): Promise<{ number: number } | undefined> { + if (!decision.refusals.some((refusal) => refusal.code === 'major-on-stable')) return undefined; + const stableUmbrella = await findOpenUmbrella(repo, STABLE_UMBRELLA_BRANCH); + return stableUmbrella && { number: stableUmbrella.number }; +} diff --git a/scripts/lib/release-queue/join/messages.test.ts b/scripts/lib/release-queue/join/messages.test.ts new file mode 100644 index 0000000000000..4c433146a7e3a --- /dev/null +++ b/scripts/lib/release-queue/join/messages.test.ts @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { GitHubError } from '../github/error.ts'; +import { + formatDryRunJoinComment, + formatDryRunRefusalComment, + formatJoinComment, + formatRefusalComment, + formatUnopenedJoinComment, + type MessageContext, +} from './messages.ts'; +import type { Refusal, Route } from './routing.ts'; + +const route: Route = { + umbrellaBase: 'main', + umbrellaBranch: 'release-queue/main', + label: 'patch-release', + shouldRetarget: true, +}; + +/** Gets the line of a refusal comment that describes `refusal`. */ +function describeRefusal(refusal: Refusal, context: MessageContext = {}): string { + const comment = formatRefusalComment({ status: 'refuse', refusals: [refusal] }, context); + const line = comment.split('\n').find((text) => text.startsWith('- ')); + assert.ok(line, comment); + return line.slice('- '.length); +} + +describe('refusal descriptions', () => { + it('describes every refusal', () => { + const refusals: Refusal[] = [ + { code: 'closed' }, + { code: 'umbrella' }, + { code: 'draft' }, + { code: 'no-release-label' }, + { code: 'several-release-labels', labels: ['patch-release', 'minor-release'] }, + { code: 'mismatch', base: 'main', label: 'major-release' }, + { code: 'mismatch', base: 'v8', label: 'minor-release' }, + { code: 'major-on-stable', frameworkVersion: '8.0.0' }, + { code: 'settled-umbrella', number: 42, releasePrNumber: 18405 }, + { code: 'not-approved' }, + { code: 'changes-requested', reviewers: ['alice'] }, + { code: 'failing-checks', checks: ['Check Links'] }, + { code: 'pending-checks', checks: ['zizmor'] }, + { code: 'missing-checks', checks: ['zizmor'] }, + { code: 'conflict' }, + { code: 'umbrella-conflict', branch: 'release-queue/main' }, + { code: 'stale-umbrella-branch', branch: 'release-queue/main' }, + { code: 'merge-failed', reason: 'Pull Request is not mergeable' }, + ]; + for (const refusal of refusals) { + assert.match(describeRefusal(refusal), /\.$/, refusal.code); + } + }); + + it('suggests the fix matching a mismatched label', () => { + assert.match( + describeRefusal({ code: 'mismatch', base: 'main', label: 'major-release' }), + /this one targets `main`.*the label to `patch-release` or `minor-release`/ + ); + assert.match( + describeRefusal({ code: 'mismatch', base: 'v8', label: 'minor-release' }), + /`minor-release` PRs target `main`, but this one targets `v8`.*`major-release`/ + ); + }); + + it("doesn't add a period after a reason that ends with one", () => { + const reason = 'Head branch was modified. Review and try the merge again.'; + assert.equal( + describeRefusal({ code: 'merge-failed', reason }), + `GitHub refused the merge: ${reason}` + ); + }); + + it('mentions the open main umbrella when the next astro release is a major', () => { + const refusal: Refusal = { code: 'major-on-stable', frameworkVersion: '8.0.0' }; + assert.match(describeRefusal(refusal), /`astro@8.0.0`/); + assert.doesNotMatch(describeRefusal(refusal), /umbrella/); + assert.match(describeRefusal(refusal, { stableUmbrella: { number: 42 } }), /umbrella \(#42\)/); + }); +}); + +describe('formatDryRunJoinComment', () => { + it('names the open umbrella a ready PR would join', () => { + const comment = formatDryRunJoinComment(route, { status: 'open', number: 42 }); + assert.match(comment, /^\*\*Release queue \(dry run\)\*\*/); + assert.match( + comment, + /squash-merged into `release-queue\/main`, the branch of #42 into `main`/ + ); + assert.match(comment, /Its base would change to `release-queue\/main`/); + }); + + it('names the umbrella PR that would be created', () => { + const comment = formatDryRunJoinComment( + { ...route, shouldRetarget: false }, + { status: 'planned', title: '[ci] release astro@7.4.1 docs' } + ); + assert.match(comment, /a new umbrella PR \(`\[ci\] release astro@7.4.1 docs`\)/); + assert.doesNotMatch(comment, /base would change/); + }); +}); + +describe('formatDryRunRefusalComment', () => { + it('lists every reason a PR would be refused, and the umbrella it would join', () => { + const comment = formatDryRunRefusalComment( + { status: 'refuse', route, refusals: [{ code: 'draft' }, { code: 'not-approved' }] }, + { umbrella: { status: 'open', number: 42 } } + ); + assert.match(comment, /wouldn't join #42 into `main` yet:\n\n- .*draft.*\n- .*approval/); + assert.match(comment, /`accepted` label would be removed/); + }); + + it("doesn't name an umbrella when the PR has none", () => { + const comment = formatDryRunRefusalComment( + { status: 'refuse', refusals: [{ code: 'no-release-label' }] }, + {} + ); + assert.match(comment, /wouldn't join its release umbrella yet:/); + }); +}); + +describe('formatRefusalComment', () => { + it('lists every reason, and says the label was removed', () => { + const comment = formatRefusalComment( + { status: 'refuse', route, refusals: [{ code: 'not-approved' }] }, + { umbrella: { status: 'open', number: 42 } } + ); + assert.match(comment, /^\*\*Release queue\*\*: this PR didn't join #42 into `main`:/); + assert.match(comment, /- .*approval/); + assert.match(comment, /The `accepted` label was removed\. Add it again after fixing\.$/); + }); + + it('names the umbrella to create by its title', () => { + const comment = formatRefusalComment( + { status: 'refuse', route, refusals: [{ code: 'not-approved' }] }, + { umbrella: { status: 'planned', title: '[ci] release astro@7.4.1 docs' } } + ); + assert.match(comment, /didn't join a new umbrella PR \(`\[ci\] release astro@7.4.1 docs`\)/); + }); + + it("doesn't name an umbrella when the PR has none", () => { + const comment = formatRefusalComment( + { status: 'refuse', refusals: [{ code: 'no-release-label' }] }, + {} + ); + assert.match(comment, /didn't join its release umbrella:/); + }); +}); + +describe('formatJoinComment', () => { + it('names the umbrella the PR joined', () => { + assert.equal( + formatJoinComment(route, { status: 'open', number: 42 }), + '**Release queue**: squash-merged into `release-queue/main`, the branch of #42 into `main`. It ships when the umbrella PR is merged.' + ); + }); +}); + +describe('formatUnopenedJoinComment', () => { + const error = new GitHubError({ + status: 422, + githubReason: 'Validation Failed', + request: 'POST', + }); + + it("asks a maintainer to open the umbrella PR that couldn't be opened", () => { + assert.equal( + formatUnopenedJoinComment(route, { status: 'unopened', error }), + "**Release queue**: squash-merged into `release-queue/main`, but the umbrella PR into `main` couldn't be opened: Validation Failed. A maintainer needs to open it from `release-queue/main`." + ); + }); + + it('describes an error that is not from GitHub', () => { + const comment = formatUnopenedJoinComment(route, { + status: 'unopened', + error: new TypeError('fetch failed'), + }); + assert.match(comment, /couldn't be opened: fetch failed\./); + }); +}); diff --git a/scripts/lib/release-queue/join/messages.ts b/scripts/lib/release-queue/join/messages.ts new file mode 100644 index 0000000000000..8bdb4e5fd92e3 --- /dev/null +++ b/scripts/lib/release-queue/join/messages.ts @@ -0,0 +1,197 @@ +import { + ACCEPTED_LABEL, + DOCS_STABLE_BRANCH, + FRAMEWORK_CORE_PACKAGE, + FRAMEWORK_STABLE_BRANCH, + RELEASE_LABELS, +} from '../config.ts'; +import { wrapInCode } from '../formats/markdown.ts'; +import { GitHubError } from '../github/error.ts'; +import { formatFrameworkReference } from '../release/release.ts'; +import { STABLE_UMBRELLA_BRANCH } from '../umbrella/branch.ts'; +import type { Umbrella, UnopenedJoin } from './umbrella-join.ts'; +import type { Refusal, RefusalCode, RefusedDecision, Route } from './routing.ts'; + +export interface MessageContext { + /** The umbrella PR the docs PR would join. */ + umbrella?: Umbrella | undefined; + /** The open stable umbrella PR, named when the next release is a major. */ + stableUmbrella?: { number: number } | undefined; +} + +const joinAsCode = (items: string[]) => items.map(wrapInCode).join(', '); +// GitHub's reasons sometimes end with a period already. +const endWithPeriod = (text: string) => (/[.!?]$/.test(text) ? text : `${text}.`); + +type DescribeRefusal = ( + refusal: Refusal, + context: MessageContext +) => string; + +const PATCH_LABEL_CODE = wrapInCode(RELEASE_LABELS.patch); +const MINOR_LABEL_CODE = wrapInCode(RELEASE_LABELS.minor); +const MAJOR_LABEL_CODE = wrapInCode(RELEASE_LABELS.major); +const STABLE_BRANCH_CODE = wrapInCode(DOCS_STABLE_BRANCH); + +function describeMissingReleaseLabel(): string { + return `This PR has no release label. Add ${PATCH_LABEL_CODE} or ${MINOR_LABEL_CODE} for a PR against ${STABLE_BRANCH_CODE}, or ${MAJOR_LABEL_CODE} for a PR against a prerelease branch.`; +} + +function describeMismatch({ base, label }: Refusal<'mismatch'>): string { + const currentBase = wrapInCode(base); + if (label === RELEASE_LABELS.major) { + return `${MAJOR_LABEL_CODE} PRs target a prerelease branch, but this one targets ${currentBase}. Change the base to the prerelease branch, or the label to ${PATCH_LABEL_CODE} or ${MINOR_LABEL_CODE}.`; + } + return `${wrapInCode(label)} PRs target ${STABLE_BRANCH_CODE}, but this one targets ${currentBase}. Change the base to ${STABLE_BRANCH_CODE}, or the label to ${MAJOR_LABEL_CODE}.`; +} + +function describeMajorOnStable( + { frameworkVersion }: Refusal<'major-on-stable'>, + { stableUmbrella }: MessageContext +): string { + const corePackage = wrapInCode(FRAMEWORK_CORE_PACKAGE); + const frameworkBranch = wrapInCode(FRAMEWORK_STABLE_BRANCH); + const version = frameworkVersion + ? ` (${wrapInCode(`${FRAMEWORK_CORE_PACKAGE}@${frameworkVersion}`)})` + : ''; + const umbrellaToMove = stableUmbrella + ? ` The open ${STABLE_BRANCH_CODE} umbrella (#${stableUmbrella.number}) needs to be squashed into the prerelease branch by a maintainer.` + : ''; + return `The next ${corePackage} release from ${frameworkBranch} is a major${version}: docs for it belong on the prerelease branch. Change the base to the prerelease branch and the label to ${MAJOR_LABEL_CODE}.${umbrellaToMove}`; +} + +function describeSettledUmbrella({ number, releasePrNumber }: Refusal<'settled-umbrella'>): string { + const releasePr = formatFrameworkReference(releasePrNumber); + const umbrellaBranch = wrapInCode(STABLE_UMBRELLA_BRANCH); + return `#${number} ships with ${releasePr}, which is already merged. Merge #${number} first. If this PR documents that release, change its base to ${umbrellaBranch} instead.`; +} + +const REFUSAL_DESCRIPTIONS: { [C in RefusalCode]: DescribeRefusal } = { + closed: () => 'This PR is closed.', + umbrella: () => 'This PR is a release umbrella. Merge it when the release is out.', + draft: () => 'This PR is a draft. Mark it as ready for review.', + 'no-release-label': describeMissingReleaseLabel, + 'several-release-labels': ({ labels }) => + `This PR has several release labels (${joinAsCode(labels)}). Keep only one.`, + mismatch: describeMismatch, + 'major-on-stable': describeMajorOnStable, + 'settled-umbrella': describeSettledUmbrella, + 'not-approved': () => 'This PR has no approval from a maintainer. Ask a maintainer to review it.', + 'changes-requested': ({ reviewers }) => + `Changes are requested by ${reviewers.join(', ')}. Address them, then ask for a new review or dismiss the old one.`, + 'failing-checks': ({ checks }) => + `These required checks fail: ${joinAsCode(checks)}. Fix them, or re-run them if they failed for an unrelated reason.`, + 'pending-checks': ({ checks }) => + `These required checks haven't finished yet: ${joinAsCode(checks)}. Wait for them to finish.`, + 'missing-checks': ({ checks }) => + `These required checks haven't run: ${joinAsCode(checks)}. If the workflow runs are waiting for approval, approve them.`, + conflict: () => 'This PR has merge conflicts. Resolve them.', + 'umbrella-conflict': ({ branch }) => + `This PR conflicts with changes already in ${wrapInCode(branch)}. Resolve the conflicts with that branch.`, + 'stale-umbrella-branch': ({ branch }) => + `The branch ${wrapInCode(branch)} has commits but no open umbrella PR. A maintainer needs to open the umbrella PR from it, or delete it if its commits already shipped.`, + 'merge-failed': ({ reason }) => `GitHub refused the merge: ${endWithPeriod(reason)}`, +}; + +/** Describes a refusal and how to fix it. */ +function describeRefusal( + refusal: Refusal, + context: MessageContext +): string { + return REFUSAL_DESCRIPTIONS[refusal.code](refusal, context); +} + +function describeUmbrella(route: Route, umbrella: Umbrella): string { + const name = + umbrella.status === 'open' + ? `#${umbrella.number}` + : `a new umbrella PR (${wrapInCode(umbrella.title)})`; + return `${name} into ${wrapInCode(route.umbrellaBase)}`; +} + +function describeRoute(route: Route, umbrella: Umbrella): string { + const base = route.shouldRetarget + ? ` Its base would change to ${wrapInCode(route.umbrellaBranch)}.` + : ''; + return `This PR would be squash-merged into ${wrapInCode(route.umbrellaBranch)}, the branch of ${describeUmbrella(route, umbrella)}.${base}`; +} + +function listRefusals(refusals: Refusal[], context: MessageContext): string { + return refusals.map((refusal) => `- ${describeRefusal(refusal, context)}`).join('\n'); +} + +function describeUmbrellaOrFallback(route?: Route, umbrella?: Umbrella): string { + return route && umbrella ? describeUmbrella(route, umbrella) : 'its release umbrella'; +} + +function describeError(error: unknown): string { + if (error instanceof GitHubError) return error.githubReason; + return error instanceof Error ? error.message : String(error); +} + +const DRY_RUN_HEADING = '**Release queue (dry run)**: nothing was changed.'; + +/** + * Writes the dry-run comment of a PR that would join an umbrella. + * + * @param {Route} route - The umbrella branch and base the PR goes to. + * @param {Umbrella} umbrella - The umbrella PR it would join. + * @returns {string} The comment, in Markdown. + */ +export function formatDryRunJoinComment(route: Route, umbrella: Umbrella): string { + return `${DRY_RUN_HEADING}\n\n${describeRoute(route, umbrella)}`; +} + +/** + * Writes the dry-run comment of a PR that would be refused. + * + * @param {RefusedDecision} decision - The refusals, and the route if any. + * @param {MessageContext} context - The umbrellas the refusals name. + * @returns {string} The comment, in Markdown. + */ +export function formatDryRunRefusalComment( + { route, refusals }: RefusedDecision, + context: MessageContext +): string { + const umbrella = describeUmbrellaOrFallback(route, context.umbrella); + return `${DRY_RUN_HEADING}\n\nThis PR wouldn't join ${umbrella} yet:\n\n${listRefusals(refusals, context)}\n\nOutside a dry run, the ${wrapInCode(ACCEPTED_LABEL)} label would be removed. It would need to be added again after fixing.`; +} + +/** + * Writes the comment of a refusal, posted once the accepted label is removed. + * + * @param {RefusedDecision} decision - The refusals, and the route if any. + * @param {MessageContext} context - The umbrellas the refusals name. + * @returns {string} The comment, in Markdown. + */ +export function formatRefusalComment( + { route, refusals }: RefusedDecision, + context: MessageContext +): string { + const umbrella = describeUmbrellaOrFallback(route, context.umbrella); + return `**Release queue**: this PR didn't join ${umbrella}:\n\n${listRefusals(refusals, context)}\n\nThe ${wrapInCode(ACCEPTED_LABEL)} label was removed. Add it again after fixing.`; +} + +/** + * Writes the comment of a join. + * + * @param {Route} route - The umbrella branch and base the PR goes to. + * @param {Umbrella} umbrella - The umbrella PR it joined. + * @returns {string} The comment, in Markdown. + */ +export function formatJoinComment(route: Route, umbrella: Umbrella): string { + return `**Release queue**: squash-merged into ${wrapInCode(route.umbrellaBranch)}, the branch of ${describeUmbrella(route, umbrella)}. It ships when the umbrella PR is merged.`; +} + +/** + * Writes the comment of a merged PR whose umbrella PR couldn't be opened. + * + * @param {Route} route - The umbrella branch and base the PR goes to. + * @param {UnopenedJoin} join - The join, with the error that stopped it. + * @returns {string} The comment, in Markdown. + */ +export function formatUnopenedJoinComment(route: Route, { error }: UnopenedJoin): string { + const branch = wrapInCode(route.umbrellaBranch); + const reason = endWithPeriod(describeError(error)); + return `**Release queue**: squash-merged into ${branch}, but the umbrella PR into ${wrapInCode(route.umbrellaBase)} couldn't be opened: ${reason} A maintainer needs to open it from ${branch}.`; +} diff --git a/scripts/lib/release-queue/join/queries.test.ts b/scripts/lib/release-queue/join/queries.test.ts new file mode 100644 index 0000000000000..801a922196667 --- /dev/null +++ b/scripts/lib/release-queue/join/queries.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { pullRequestResponse, REPO } from '../testing/fixtures.ts'; +import { stubGitHub } from '../testing/stub-github.ts'; +import { getPullRequest } from './queries.ts'; + +const PR_PATH = `repos/${REPO}/pulls/7`; + +describe('getPullRequest', () => { + it('polls until GitHub knows the mergeability', async (t) => { + let requests = 0; + const github = stubGitHub(t, { + getResponses: { + [PR_PATH]: () => pullRequestResponse({ mergeable: ++requests < 3 ? null : true }), + }, + }); + + const pr = await getPullRequest(github.repository(), 7); + + assert.equal(pr.isMergeable, true); + assert.equal(github.countReads(PR_PATH), 3); + }); + + it('stops after the last attempt, with the mergeability still unknown', async (t) => { + const github = stubGitHub(t, { + getResponses: { [PR_PATH]: pullRequestResponse({ mergeable: null }) }, + }); + + const pr = await getPullRequest(github.repository(), 7); + + assert.equal(pr.isMergeable, null); + assert.equal(github.countReads(PR_PATH), 5); + }); + + it("doesn't poll for a closed PR", async (t) => { + const github = stubGitHub(t, { + getResponses: { [PR_PATH]: pullRequestResponse({ state: 'closed', mergeable: null }) }, + }); + + await getPullRequest(github.repository(), 7); + + assert.equal(github.countReads(PR_PATH), 1); + }); +}); diff --git a/scripts/lib/release-queue/join/queries.ts b/scripts/lib/release-queue/join/queries.ts new file mode 100644 index 0000000000000..0779bc6eca476 --- /dev/null +++ b/scripts/lib/release-queue/join/queries.ts @@ -0,0 +1,130 @@ +import { DOCS_STABLE_BRANCH } from '../config.ts'; +import type { Repository } from '../github/repository.ts'; +import type { + CheckRunResponse, + CommitStatusResponse, + PullRequestResponse, + PullRequestState, + ReviewResponse, + RuleResponse, +} from '../github/types.ts'; +import { + classifyChecks, + classifyReviewers, + getLatestReviewStates, + readCheckRun, + readCommitStatus, + type Readiness, + type Review, +} from './readiness.ts'; + +const MERGEABILITY_ATTEMPTS = 5; + +/** What routing reads from a docs PR. */ +export interface DocsPullRequest { + state: PullRequestState; + isDraft: boolean; + base: string; + head: string; + labels: string[]; + /** `null` while GitHub hasn't computed it yet. */ + isMergeable: boolean | null; +} + +export interface PullRequest extends DocsPullRequest { + number: number; + title: string; + url: string; + headSha: string; +} + +/** + * Gets a docs PR for routing, once GitHub knows if it's mergeable. + * + * @param {Repository} repo - The docs repository. + * @param {number} prNumber - The number of the docs PR. + * @returns {Promise} The PR. Its mergeability stays `null` when + * GitHub hasn't computed it after a few attempts. + */ +export async function getPullRequest(repo: Repository, prNumber: number): Promise { + const data = await getPullRequestWithMergeability(repo, prNumber); + return { + number: data.number, + title: data.title, + url: data.html_url, + state: data.state, + isDraft: data.draft, + base: data.base.ref, + head: data.head.ref, + headSha: data.head.sha, + labels: data.labels.map((label) => label.name), + isMergeable: data.mergeable, + }; +} + +/** + * Gets a PR once GitHub has computed its mergeability. The first request + * starts the computation. + */ +function getPullRequestWithMergeability(repo: Repository, prNumber: number) { + return repo.poll(`pulls/${prNumber}`, { + isReady: (data) => data.state !== 'open' || data.mergeable !== null, + attempts: MERGEABILITY_ATTEMPTS, + }); +} + +/** + * Gets the reviews and the checks of a docs PR that the ruleset of the stable + * branch counts: those of reviewers with write access, and the required checks. + * + * @param {Repository} repo - The docs repository. + * @param {PullRequest} pr - The docs PR. + * @returns {Promise} Its reviews and checks. + */ +export async function getReadiness(repo: Repository, pr: PullRequest): Promise { + const [reviews, checkRuns, statuses, required] = await Promise.all([ + repo.getAll(`pulls/${pr.number}/reviews`), + repo.getAll( + `commits/${pr.headSha}/check-runs`, + (data) => data.check_runs + ), + repo.getAll( + `commits/${pr.headSha}/status`, + (data) => data.statuses + ), + getRequiredChecks(repo), + ]); + + const states = getLatestReviewStates(toReviews(reviews)); + const writers = await getWriters(repo, [...states.keys()]); + return { + ...classifyReviewers(states, writers), + ...classifyChecks(required, [ + ...checkRuns.map(readCheckRun), + ...statuses.map(readCommitStatus), + ]), + }; +} + +/** Keeps the reviews whose author still has an account. */ +function toReviews(responses: ReviewResponse[]): Review[] { + return responses.flatMap(({ user, state }) => (user ? [{ user: user.login, state }] : [])); +} + +/** Gets the users who can write to the repository among `users`. */ +async function getWriters(repo: Repository, users: string[]): Promise> { + const canEachWrite = await Promise.all(users.map((user) => repo.canWrite(user))); + return new Set(users.filter((_, index) => canEachWrite[index])); +} + +/** + * Gets the checks the ruleset of the stable branch requires. They apply to + * every docs PR, whatever its base. + */ +async function getRequiredChecks(repo: Repository): Promise { + const rules = await repo.getAll(`rules/branches/${DOCS_STABLE_BRANCH}`); + return rules + .filter((rule) => rule.type === 'required_status_checks') + .flatMap((rule) => rule.parameters?.required_status_checks ?? []) + .map((check) => check.context); +} diff --git a/scripts/lib/release-queue/join/readiness.test.ts b/scripts/lib/release-queue/join/readiness.test.ts new file mode 100644 index 0000000000000..46574c8314dcd --- /dev/null +++ b/scripts/lib/release-queue/join/readiness.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + classifyChecks, + classifyReviewers, + getLatestReviewStates, + readCheckRun, + readCommitStatus, +} from './readiness.ts'; + +describe('getLatestReviewStates', () => { + it('keeps the latest approval or change request of each reviewer', () => { + const states = getLatestReviewStates([ + { user: 'alice', state: 'CHANGES_REQUESTED' }, + { user: 'bob', state: 'APPROVED' }, + { user: 'alice', state: 'APPROVED' }, + { user: 'bob', state: 'CHANGES_REQUESTED' }, + ]); + assert.deepEqual(Object.fromEntries(states), { alice: 'APPROVED', bob: 'CHANGES_REQUESTED' }); + }); + + it('ignores comments after an approval', () => { + const states = getLatestReviewStates([ + { user: 'alice', state: 'APPROVED' }, + { user: 'alice', state: 'COMMENTED' }, + ]); + assert.deepEqual(Object.fromEntries(states), { alice: 'APPROVED' }); + }); + + it('ignores a dismissed review', () => { + const states = getLatestReviewStates([{ user: 'alice', state: 'DISMISSED' }]); + assert.equal(states.size, 0); + }); + + it("doesn't count an earlier review again after a dismissal", () => { + const states = getLatestReviewStates([ + { user: 'alice', state: 'APPROVED' }, + { user: 'alice', state: 'DISMISSED' }, + ]); + assert.equal(states.size, 0); + }); +}); + +describe('classifyReviewers', () => { + it('counts the approval of a reviewer with write access', () => { + const states = new Map([['alice', 'APPROVED' as const]]); + assert.deepEqual(classifyReviewers(states, new Set(['alice'])), { + isApproved: true, + changesRequestedBy: [], + }); + }); + + it("doesn't count the approval of a reviewer without write access", () => { + const states = new Map([['bob', 'APPROVED' as const]]); + assert.deepEqual(classifyReviewers(states, new Set()), { + isApproved: false, + changesRequestedBy: [], + }); + }); + + it('lists change requests from reviewers with write access only', () => { + const states = new Map([ + ['alice', 'CHANGES_REQUESTED' as const], + ['bob', 'CHANGES_REQUESTED' as const], + ['carol', 'APPROVED' as const], + ]); + assert.deepEqual(classifyReviewers(states, new Set(['alice', 'carol'])), { + isApproved: true, + changesRequestedBy: ['alice'], + }); + }); +}); + +describe('readCheckRun', () => { + it('passes a successful, neutral or skipped run', () => { + for (const conclusion of ['success', 'neutral', 'skipped'] as const) { + const check = readCheckRun({ name: 'eslint', status: 'completed', conclusion }); + assert.deepEqual(check, { name: 'eslint', state: 'passing' }); + } + }); + + it('fails a completed run with any other conclusion, or none', () => { + for (const conclusion of ['failure', 'cancelled', 'timed_out', null] as const) { + const check = readCheckRun({ name: 'links', status: 'completed', conclusion }); + assert.deepEqual(check, { name: 'links', state: 'failing' }); + } + }); + + it('keeps an unfinished run pending', () => { + const check = readCheckRun({ name: 'check', status: 'in_progress', conclusion: null }); + assert.deepEqual(check, { name: 'check', state: 'pending' }); + }); +}); + +describe('readCommitStatus', () => { + it('reads a status by its context', () => { + assert.deepEqual(readCommitStatus({ context: 'deploy', state: 'pending' }), { + name: 'deploy', + state: 'pending', + }); + assert.deepEqual(readCommitStatus({ context: 'deploy', state: 'success' }), { + name: 'deploy', + state: 'passing', + }); + }); + + it('fails an error, like a failure', () => { + for (const state of ['error', 'failure'] as const) { + assert.equal(readCommitStatus({ context: 'external', state }).state, 'failing'); + } + }); +}); + +describe('classifyChecks', () => { + const required = ['eslint', 'links', 'check', 'deploy', 'external']; + + it('lists the failing and pending required checks', () => { + const checks = classifyChecks(required, [ + { name: 'eslint', state: 'passing' }, + { name: 'links', state: 'failing' }, + { name: 'check', state: 'pending' }, + { name: 'deploy', state: 'pending' }, + { name: 'external', state: 'failing' }, + ]); + assert.deepEqual(checks, { + failingChecks: ['links', 'external'], + pendingChecks: ['check', 'deploy'], + missingChecks: [], + }); + }); + + it('ignores checks that are not required', () => { + const checks = classifyChecks( + ['eslint'], + [ + { name: 'eslint', state: 'passing' }, + { name: 'triage', state: 'pending' }, + { name: 'Generate Lunaria Overview', state: 'failing' }, + ] + ); + assert.deepEqual(checks, { failingChecks: [], pendingChecks: [], missingChecks: [] }); + }); + + it('lists required checks that are not reported', () => { + assert.deepEqual(classifyChecks(['eslint'], []), { + failingChecks: [], + pendingChecks: [], + missingChecks: ['eslint'], + }); + }); + + it('accepts a commit when no check is required', () => { + assert.deepEqual(classifyChecks([], []), { + failingChecks: [], + pendingChecks: [], + missingChecks: [], + }); + }); +}); diff --git a/scripts/lib/release-queue/join/readiness.ts b/scripts/lib/release-queue/join/readiness.ts new file mode 100644 index 0000000000000..1983ef9335539 --- /dev/null +++ b/scripts/lib/release-queue/join/readiness.ts @@ -0,0 +1,124 @@ +import type { + CheckConclusion, + CheckRunResponse, + CommitStatusResponse, + ReviewResponseState, +} from '../github/types.ts'; + +export interface Review { + user: string; + state: ReviewResponseState; +} + +type ReviewState = Extract; + +export interface Readiness { + /** At least one reviewer with write access approved the PR. */ + isApproved: boolean; + /** Reviewers with write access whose latest review requests changes. */ + changesRequestedBy: string[]; + failingChecks: string[]; + pendingChecks: string[]; + /** Required checks not reported on the PR. */ + missingChecks: string[]; +} + +type ClassifiedReviewers = Pick; +type ClassifiedChecks = Pick; + +/** + * Gets the latest review state of each reviewer. + * + * A comment keeps the earlier state of its reviewer. A dismissed review clears + * the state, and an earlier review from the same reviewer no longer counts. + * + * @param {Review[]} reviews - The reviews, in the chronological order of the + * GitHub API. + * @returns {Map} The latest approval or change request, + * by reviewer. + */ +export function getLatestReviewStates(reviews: Review[]): Map { + const states = new Map(); + for (const { user, state } of reviews) { + if (state === 'APPROVED' || state === 'CHANGES_REQUESTED') states.set(user, state); + else if (state === 'DISMISSED') states.delete(user); + } + return states; +} + +/** + * Classifies the latest review states the way the ruleset of the stable branch + * counts them: only reviewers with write access approve or block a PR. + * + * @param {Map} states - The latest state by reviewer. + * @param {Set} writers - The reviewers with write access. + * @returns {ClassifiedReviewers} Whether a writer approved, and the writers + * who request changes. + */ +export function classifyReviewers( + states: Map, + writers: Set +): ClassifiedReviewers { + const reviewersWith = (state: ReviewState) => + [...states] + .filter(([user, reviewState]) => reviewState === state && writers.has(user)) + .map(([user]) => user); + return { + isApproved: reviewersWith('APPROVED').length > 0, + changesRequestedBy: reviewersWith('CHANGES_REQUESTED'), + }; +} + +const SUCCESSFUL_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']); + +/** A check reported on a commit, by a check run or a commit status. */ +export interface Check { + name: string; + state: 'pending' | 'failing' | 'passing'; +} + +/** + * Reads the state of a check run. A neutral or skipped run passes. + * + * @param {CheckRunResponse} checkRun - A check run of the commit. + * @returns {Check} The check, by the name of the run. + */ +export function readCheckRun({ name, status, conclusion }: CheckRunResponse): Check { + if (status !== 'completed') return { name, state: 'pending' }; + const isSuccessful = conclusion !== null && SUCCESSFUL_CONCLUSIONS.has(conclusion); + return { name, state: isSuccessful ? 'passing' : 'failing' }; +} + +/** + * Reads the state of a commit status. An error fails, like a failure. + * + * @param {CommitStatusResponse} status - A commit status. + * @returns {Check} The check, by the context of the status. + */ +export function readCommitStatus({ context, state }: CommitStatusResponse): Check { + if (state === 'pending') return { name: context, state: 'pending' }; + return { name: context, state: state === 'success' ? 'passing' : 'failing' }; +} + +/** + * Lists the required checks of a commit that are failing, pending, or missing. + * + * A check is missing when it isn't reported, for example while the workflow + * runs of a first-time contributor wait for approval. + * + * @param {string[]} required - The names of the required checks. + * @param {Check[]} checks - The checks reported on the commit. + * @returns {ClassifiedChecks} The required checks in each state. + */ +export function classifyChecks(required: string[], checks: Check[]): ClassifiedChecks { + const namesIn = (state: Check['state']) => + new Set(checks.filter((check) => check.state === state).map(({ name }) => name)); + const failing = namesIn('failing'); + const pending = namesIn('pending'); + const reported = new Set(checks.map(({ name }) => name)); + return { + failingChecks: required.filter((name) => failing.has(name)), + pendingChecks: required.filter((name) => pending.has(name)), + missingChecks: required.filter((name) => !reported.has(name)), + }; +} diff --git a/scripts/lib/release-queue/join/routing.test.ts b/scripts/lib/release-queue/join/routing.test.ts new file mode 100644 index 0000000000000..10bd93668d512 --- /dev/null +++ b/scripts/lib/release-queue/join/routing.test.ts @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { DocsPullRequest } from './queries.ts'; +import type { Readiness } from './readiness.ts'; +import { decideJoin, wouldRetargetFromStable, type Decision } from './routing.ts'; + +const pr = (overrides: Partial = {}): DocsPullRequest => ({ + state: 'open', + isDraft: false, + base: 'main', + head: 'feat/docs', + labels: ['accepted', 'patch-release'], + isMergeable: true, + ...overrides, +}); + +const ready: Readiness = { + isApproved: true, + changesRequestedBy: [], + failingChecks: [], + pendingChecks: [], + missingChecks: [], +}; + +/** The context `decideJoin` takes, besides the PR and its readiness. */ +type DecisionContext = Parameters[2]; + +const patch: DecisionContext = { release: { type: 'patch', frameworkVersion: '7.4.1' } }; +const major: DecisionContext = { release: { type: 'major', frameworkVersion: '8.0.0' } }; +/** The `main` umbrella #42 ships with a merged release PR. */ +const settled: DecisionContext = { + ...patch, + settledStableUmbrella: { number: 42, releasePrNumber: 18405 }, +}; + +const refusalsOf = (decision: Decision) => (decision.status === 'refuse' ? decision.refusals : []); + +describe('decideJoin', () => { + for (const label of ['patch-release', 'minor-release']) { + it(`routes a ready docs PR to the main umbrella with ${label}`, () => { + assert.deepEqual(decideJoin(pr({ labels: [label] }), ready, patch), { + status: 'join', + route: { + umbrellaBase: 'main', + umbrellaBranch: 'release-queue/main', + label, + shouldRetarget: true, + }, + }); + }); + } + + it('routes a ready docs PR to a prerelease umbrella with major-release', () => { + const decision = decideJoin( + pr({ base: 'cloudflare-next', labels: ['major-release'] }), + ready, + major + ); + assert.deepEqual(decision, { + status: 'join', + route: { + umbrellaBase: 'cloudflare-next', + umbrellaBranch: 'release-queue/cloudflare-next', + label: 'major-release', + shouldRetarget: true, + }, + }); + }); + + it('routes a ready docs PR to the umbrella it already targets, without retargeting', () => { + assert.deepEqual(decideJoin(pr({ base: 'release-queue/main' }), ready, patch), { + status: 'join', + route: { + umbrellaBase: 'main', + umbrellaBranch: 'release-queue/main', + label: 'patch-release', + shouldRetarget: false, + }, + }); + }); + + it('refuses a closed PR, and nothing else', () => { + const decision = decideJoin(pr({ state: 'closed', isDraft: true }), ready, patch); + assert.deepEqual(decision, { status: 'refuse', refusals: [{ code: 'closed' }] }); + }); + + it('refuses an umbrella PR, and nothing else', () => { + const decision = decideJoin(pr({ head: 'release-queue/main' }), ready, patch); + assert.deepEqual(decision, { status: 'refuse', refusals: [{ code: 'umbrella' }] }); + }); + + it('refuses a draft PR', () => { + assert.deepEqual(refusalsOf(decideJoin(pr({ isDraft: true }), ready, patch)), [ + { code: 'draft' }, + ]); + }); + + it('refuses a PR without a release label', () => { + const decision = decideJoin(pr({ labels: ['accepted'] }), ready, patch); + assert.deepEqual(decision, { status: 'refuse', refusals: [{ code: 'no-release-label' }] }); + }); + + it('refuses a PR with several release labels', () => { + const decision = decideJoin(pr({ labels: ['minor-release', 'patch-release'] }), ready, patch); + assert.deepEqual(refusalsOf(decision), [ + { code: 'several-release-labels', labels: ['patch-release', 'minor-release'] }, + ]); + }); + + it('refuses a major-release PR against main', () => { + const decision = decideJoin(pr({ labels: ['major-release'] }), ready, patch); + assert.deepEqual(refusalsOf(decision), [ + { code: 'mismatch', base: 'main', label: 'major-release' }, + ]); + }); + + it('refuses a minor-release PR against a prerelease branch', () => { + const decision = decideJoin(pr({ base: 'v8', labels: ['minor-release'] }), ready, patch); + assert.deepEqual(refusalsOf(decision), [ + { code: 'mismatch', base: 'v8', label: 'minor-release' }, + ]); + }); + + it('refuses a major-release PR against the main umbrella branch', () => { + const decision = decideJoin( + pr({ base: 'release-queue/main', labels: ['major-release'] }), + ready, + patch + ); + assert.deepEqual(refusalsOf(decision), [ + { code: 'mismatch', base: 'release-queue/main', label: 'major-release' }, + ]); + }); + + it('refuses a main PR when the next astro release from main is a major', () => { + const decision = decideJoin(pr(), ready, major); + assert.deepEqual(refusalsOf(decision), [ + { code: 'major-on-stable', frameworkVersion: '8.0.0' }, + ]); + }); + + it('refuses a PR that is not ready, with every reason', () => { + const decision = decideJoin( + pr({ isMergeable: false }), + { + isApproved: false, + changesRequestedBy: ['alice'], + failingChecks: ['links'], + pendingChecks: ['check'], + missingChecks: ['zizmor'], + }, + patch + ); + assert.deepEqual(refusalsOf(decision), [ + { code: 'not-approved' }, + { code: 'changes-requested', reviewers: ['alice'] }, + { code: 'failing-checks', checks: ['links'] }, + { code: 'pending-checks', checks: ['check'] }, + { code: 'missing-checks', checks: ['zizmor'] }, + { code: 'conflict' }, + ]); + }); + + it('refuses a PR with both a routing and a readiness problem, with both reasons', () => { + const decision = decideJoin(pr({ labels: [] }), { ...ready, isApproved: false }, patch); + assert.deepEqual(decision, { + status: 'refuse', + refusals: [{ code: 'no-release-label' }, { code: 'not-approved' }], + }); + }); + + it('refuses a main PR while the main umbrella is settled', () => { + assert.deepEqual(refusalsOf(decideJoin(pr(), ready, settled)), [ + { code: 'settled-umbrella', number: 42, releasePrNumber: 18405 }, + ]); + }); + + it('refuses a routed PR that is not ready, keeping its route', () => { + const decision = decideJoin(pr(), { ...ready, isApproved: false }, patch); + assert.equal(decision.status, 'refuse'); + assert.equal(decision.route?.umbrellaBranch, 'release-queue/main'); + }); + + it('routes a PR against the settled umbrella branch, which says where it belongs', () => { + assert.equal(decideJoin(pr({ base: 'release-queue/main' }), ready, settled).status, 'join'); + }); + + it('routes a prerelease PR while the main umbrella is settled', () => { + const prerelease = pr({ base: 'cloudflare-next', labels: ['major-release'] }); + assert.equal(decideJoin(prerelease, ready, settled).status, 'join'); + }); + + it('lets the merge decide while mergeability is unknown', () => { + assert.equal(decideJoin(pr({ isMergeable: null }), ready, patch).status, 'join'); + }); +}); + +describe('wouldRetargetFromStable', () => { + it('applies to an open PR against main', () => { + assert.equal(wouldRetargetFromStable(pr()), true); + }); + + it("doesn't apply to a PR against the umbrella branch, a prerelease, or a closed PR", () => { + assert.equal(wouldRetargetFromStable(pr({ base: 'release-queue/main' })), false); + assert.equal(wouldRetargetFromStable(pr({ base: 'cloudflare-next' })), false); + assert.equal(wouldRetargetFromStable(pr({ state: 'closed' })), false); + }); +}); diff --git a/scripts/lib/release-queue/join/routing.ts b/scripts/lib/release-queue/join/routing.ts new file mode 100644 index 0000000000000..794c4929af069 --- /dev/null +++ b/scripts/lib/release-queue/join/routing.ts @@ -0,0 +1,164 @@ +import { + ALL_RELEASE_LABELS, + DOCS_STABLE_BRANCH, + RELEASE_LABELS, + RELEASE_TYPES, + type ReleaseLabel, +} from '../config.ts'; +import type { StableRelease } from '../release/release.ts'; +import { getUmbrellaBase, getUmbrellaBranch, isUmbrellaBranch } from '../umbrella/branch.ts'; +import type { SettledUmbrella } from '../umbrella/state.ts'; +import type { DocsPullRequest } from './queries.ts'; +import type { Readiness } from './readiness.ts'; + +export interface Route { + umbrellaBase: string; + umbrellaBranch: string; + label: ReleaseLabel; + /** Whether the base of the docs PR must change to the umbrella branch. */ + shouldRetarget: boolean; +} + +/** What `decideJoin` needs besides the PR and its readiness. */ +interface DecisionContext { + release: StableRelease; + settledStableUmbrella?: SettledUmbrella | undefined; +} + +/** The details of each refusal, by code. */ +interface RefusalDetails { + closed: Record; + umbrella: Record; + draft: Record; + 'no-release-label': Record; + 'several-release-labels': { labels: ReleaseLabel[] }; + mismatch: { base: string; label: ReleaseLabel }; + 'major-on-stable': { frameworkVersion?: string | undefined }; + 'settled-umbrella': SettledUmbrella; + 'not-approved': Record; + 'changes-requested': { reviewers: string[] }; + 'failing-checks': { checks: string[] }; + 'pending-checks': { checks: string[] }; + 'missing-checks': { checks: string[] }; + conflict: Record; + // Found when joining: see `JoinRefusal`. + 'umbrella-conflict': { branch: string }; + /** The umbrella branch has commits but no open umbrella PR. */ + 'stale-umbrella-branch': { branch: string }; + 'merge-failed': { reason: string }; +} + +export type RefusalCode = keyof RefusalDetails; + +/** A reason not to merge a docs PR. Without a code, any refusal. */ +export type Refusal = { + [C in Code]: { code: C } & RefusalDetails[C]; +}[Code]; + +/** The refusals found only while joining, once `decideJoin` approved the PR. */ +export type JoinRefusal = Refusal<'umbrella-conflict' | 'stale-umbrella-branch' | 'merge-failed'>; + +/** + * A docs PR joins `route`, or is refused. A refused PR keeps its route when + * routing succeeded: comments use it to name the umbrella. + */ +export type Decision = + | { status: 'join'; route: Route } + | { status: 'refuse'; route?: Route; refusals: Refusal[] }; + +export type RefusedDecision = Extract; + +/** The route of a docs PR, or the refusal that leaves it without one. */ +type Routing = { status: 'routed'; route: Route } | { status: 'refused'; refusal: Refusal }; + +/** + * Whether the bot would change the base of an open docs PR from the stable + * branch to the stable umbrella branch. + * + * @param {DocsPullRequest} pr - The docs PR. + * @returns {boolean} `true` for an open PR against the stable branch. + */ +export function wouldRetargetFromStable(pr: DocsPullRequest): boolean { + return pr.state === 'open' && pr.base === DOCS_STABLE_BRANCH; +} + +/** + * Decides whether a docs PR joins an umbrella, and which one. + * + * @param {DocsPullRequest} pr - The docs PR labeled as accepted. + * @param {Readiness} readiness - Its reviews and checks. + * @param {DecisionContext} context - The next release, and the settled stable + * umbrella if there is one. + * @returns {Decision} The route to join, or every refusal at once. A closed + * PR or an umbrella PR gets only that refusal. + */ +export function decideJoin( + pr: DocsPullRequest, + readiness: Readiness, + context: DecisionContext +): Decision { + if (pr.state !== 'open') return { status: 'refuse', refusals: [{ code: 'closed' }] }; + if (isUmbrellaBranch(pr.head)) return { status: 'refuse', refusals: [{ code: 'umbrella' }] }; + + const routing = getRoute(pr, context); + const refusals: Refusal[] = [ + ...(pr.isDraft ? [{ code: 'draft' } as const] : []), + ...(routing.status === 'refused' ? [routing.refusal] : []), + ...getReadinessRefusals(readiness), + ...(pr.isMergeable === false ? [{ code: 'conflict' } as const] : []), + ]; + + if (routing.status === 'refused') return { status: 'refuse', refusals }; + if (refusals.length > 0) return { status: 'refuse', route: routing.route, refusals }; + return { status: 'join', route: routing.route }; +} + +const refuseRoute = (refusal: Refusal): Routing => ({ status: 'refused', refusal }); + +function getRoute( + pr: DocsPullRequest, + { release, settledStableUmbrella }: DecisionContext +): Routing { + const labels = ALL_RELEASE_LABELS.filter((label) => pr.labels.includes(label)); + if (labels.length === 0) return refuseRoute({ code: 'no-release-label' }); + if (labels.length > 1) return refuseRoute({ code: 'several-release-labels', labels }); + const [label] = labels; + + const umbrellaBase = getUmbrellaBase(pr.base); + // A PR against an umbrella branch, to fix something already merged in it. + const shouldRetarget = !isUmbrellaBranch(pr.base); + + // Nothing marks a prerelease branch: any base but the stable branch is one. + const isMajor = label === RELEASE_LABELS.major; + const isStableTarget = umbrellaBase === DOCS_STABLE_BRANCH; + if (isMajor === isStableTarget) return refuseRoute({ code: 'mismatch', base: pr.base, label }); + // Every feature on the stable branch of the framework now ships in the + // major, documented on a prerelease branch. + if (isStableTarget && release.type === RELEASE_TYPES.major) { + return refuseRoute({ code: 'major-on-stable', frameworkVersion: release.frameworkVersion }); + } + if (settledStableUmbrella && wouldRetargetFromStable(pr)) { + return refuseRoute({ code: 'settled-umbrella', ...settledStableUmbrella }); + } + + const umbrellaBranch = getUmbrellaBranch(umbrellaBase); + return { status: 'routed', route: { umbrellaBase, umbrellaBranch, label, shouldRetarget } }; +} + +function getReadinessRefusals(readiness: Readiness): Refusal[] { + const refusals: Refusal[] = []; + if (!readiness.isApproved) refusals.push({ code: 'not-approved' }); + if (readiness.changesRequestedBy.length > 0) { + refusals.push({ code: 'changes-requested', reviewers: readiness.changesRequestedBy }); + } + if (readiness.failingChecks.length > 0) { + refusals.push({ code: 'failing-checks', checks: readiness.failingChecks }); + } + if (readiness.pendingChecks.length > 0) { + refusals.push({ code: 'pending-checks', checks: readiness.pendingChecks }); + } + if (readiness.missingChecks.length > 0) { + refusals.push({ code: 'missing-checks', checks: readiness.missingChecks }); + } + return refusals; +} diff --git a/scripts/lib/release-queue/join/umbrella-join.test.ts b/scripts/lib/release-queue/join/umbrella-join.test.ts new file mode 100644 index 0000000000000..af316b30aa048 --- /dev/null +++ b/scripts/lib/release-queue/join/umbrella-join.test.ts @@ -0,0 +1,359 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { GitHubError } from '../github/error.ts'; +import { pullRequestResponse, REPO, umbrellaResponse } from '../testing/fixtures.ts'; +import { stubGitHub, type Write } from '../testing/stub-github.ts'; +import { UmbrellaJoin } from './umbrella-join.ts'; +import type { PullRequest } from './queries.ts'; +import type { Route } from './routing.ts'; + +const pr: PullRequest = { + number: 7, + title: 'Document the new option', + url: `https://github.com/${REPO}/pull/7`, + state: 'open', + isDraft: false, + base: 'main', + head: 'feat/option', + headSha: 'head-sha', + labels: ['accepted', 'minor-release'], + isMergeable: true, +}; + +const route: Route = { + umbrellaBase: 'main', + umbrellaBranch: 'release-queue/main', + label: 'minor-release', + shouldRetarget: true, +}; + +const umbrellaTitle = '[ci] release astro@7.5.0 docs'; + +const paths = { + openUmbrella: `repos/${REPO}/pulls?state=open&head=withastro:release-queue/main`, + stableRef: `repos/${REPO}/git/ref/heads/main`, + umbrellaRef: `repos/${REPO}/git/ref/heads/release-queue/main`, + compare: `repos/${REPO}/compare/main...release-queue/main`, + pr: `repos/${REPO}/pulls/7`, +}; + +const prResponse = (mergeable: boolean | null) => + pullRequestResponse({ mergeable, base: { ref: 'release-queue/main' } }); + +/** Responses without an open umbrella, for a PR that merges cleanly. */ +const withoutUmbrella = { + [paths.openUmbrella]: [], + [paths.stableRef]: { object: { sha: 'main-sha' } }, + [paths.pr]: prResponse(true), +}; + +const withUmbrella = { + ...withoutUmbrella, + [paths.openUmbrella]: [umbrellaResponse()], +}; + +const createBranch: Write = { + method: 'POST', + path: `repos/${REPO}/git/refs`, + body: { ref: 'refs/heads/release-queue/main', sha: 'main-sha' }, +}; + +const setBase = (base: string): Write => ({ + method: 'PATCH', + path: `repos/${REPO}/pulls/7`, + body: { base }, +}); + +const merge: Write = { + method: 'PUT', + path: `repos/${REPO}/pulls/7/merge`, + body: { + merge_method: 'squash', + sha: 'head-sha', + commit_title: 'Document the new option (#7)', + }, +}; + +const openUmbrella: Write = { + method: 'POST', + path: `repos/${REPO}/pulls`, + body: { + title: '[ci] release astro@7.5.0 docs', + head: 'release-queue/main', + base: 'main', + body: 'Release umbrella for `main`, managed by the release queue.', + }, +}; + +const createdUmbrella = { [`POST repos/${REPO}/pulls`]: { number: 99 } }; + +/** Joins a docs PR to its umbrella, as the handler does. */ +const mergeIntoUmbrella = (...args: ConstructorParameters) => + new UmbrellaJoin(...args).mergePullRequest(); + +describe('UmbrellaJoin', () => { + it('creates the branch, merges the PR, then opens the umbrella PR', async (t) => { + const github = stubGitHub(t, { getResponses: withoutUmbrella, writeResults: createdUmbrella }); + + const result = await mergeIntoUmbrella(github.repository(), { + pr: pr, + route: route, + umbrellaTitle, + }); + + assert.deepEqual(result, { status: 'joined', umbrella: { status: 'open', number: 99 } }); + assert.deepEqual(github.writes, [ + createBranch, + setBase('release-queue/main'), + merge, + openUmbrella, + ]); + }); + + it('joins the open umbrella PR', async (t) => { + const github = stubGitHub(t, { getResponses: withUmbrella }); + + const result = await mergeIntoUmbrella(github.repository(), { + pr: pr, + route: route, + umbrellaTitle, + }); + + assert.deepEqual(result, { status: 'joined', umbrella: { status: 'open', number: 42 } }); + assert.deepEqual(github.writes, [setBase('release-queue/main'), merge]); + }); + + it('ignores a PR from the umbrella branch into another base', async (t) => { + const github = stubGitHub(t, { + getResponses: { + ...withoutUmbrella, + [paths.openUmbrella]: [umbrellaResponse({ base: { ref: 'v6' } })], + }, + writeResults: createdUmbrella, + }); + + const result = await mergeIntoUmbrella(github.repository(), { + pr: pr, + route: route, + umbrellaTitle, + }); + + assert.deepEqual(result, { status: 'joined', umbrella: { status: 'open', number: 99 } }); + assert.deepEqual(github.writes[0], createBranch); + }); + + it("doesn't change the base of a PR against the umbrella branch", async (t) => { + const github = stubGitHub(t, { getResponses: withUmbrella }); + const umbrellaPr = { ...pr, base: 'release-queue/main' }; + + await mergeIntoUmbrella(github.repository(), { + pr: umbrellaPr, + route: { ...route, shouldRetarget: false }, + umbrellaTitle, + }); + + assert.deepEqual(github.writes, [merge]); + }); + + it('prepares the branch for a PR against it when no umbrella PR is open', async (t) => { + const github = stubGitHub(t, { getResponses: withoutUmbrella, writeResults: createdUmbrella }); + const umbrellaPr = { ...pr, base: 'release-queue/main' }; + + await mergeIntoUmbrella(github.repository(), { + pr: umbrellaPr, + route: { ...route, shouldRetarget: false }, + umbrellaTitle, + }); + + assert.deepEqual(github.writes, [createBranch, merge, openUmbrella]); + }); + + it('resets a leftover umbrella branch without commits', async (t) => { + const github = stubGitHub(t, { + getResponses: { + ...withoutUmbrella, + [paths.umbrellaRef]: { object: { sha: 'old-sha' } }, + [paths.compare]: { ahead_by: 0 }, + }, + writeResults: createdUmbrella, + }); + + await mergeIntoUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); + + assert.deepEqual(github.writes, [ + { + method: 'PATCH', + path: `repos/${REPO}/git/refs/heads/release-queue/main`, + body: { sha: 'main-sha', force: true }, + }, + setBase('release-queue/main'), + merge, + openUmbrella, + ]); + }); + + it('refuses a leftover umbrella branch with commits, without touching the PR', async (t) => { + const github = stubGitHub(t, { + getResponses: { + ...withoutUmbrella, + [paths.umbrellaRef]: { object: { sha: 'old-sha' } }, + [paths.compare]: { ahead_by: 3 }, + }, + }); + + const result = await mergeIntoUmbrella(github.repository(), { + pr: pr, + route: route, + umbrellaTitle, + }); + + assert.deepEqual(result, { + status: 'refused', + refusal: { code: 'stale-umbrella-branch', branch: 'release-queue/main' }, + }); + assert.deepEqual(github.writes, []); + }); + + it('throws when the target branch is missing, without touching the PR', async (t) => { + const withoutStable: Record = { ...withoutUmbrella }; + delete withoutStable[paths.stableRef]; + const github = stubGitHub(t, { getResponses: withoutStable }); + + await assert.rejects( + mergeIntoUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), + /doesn't exist/ + ); + assert.deepEqual(github.writes, []); + }); + + it('merges while GitHub still computes the mergeability', async (t) => { + const github = stubGitHub(t, { + getResponses: { ...withUmbrella, [paths.pr]: prResponse(null) }, + }); + + const result = await mergeIntoUmbrella(github.repository(), { + pr: pr, + route: route, + umbrellaTitle, + }); + + assert.equal(result.status, 'joined'); + assert.deepEqual(github.writes, [setBase('release-queue/main'), merge]); + }); + + it('restores the base when the PR conflicts with the umbrella branch', async (t) => { + const github = stubGitHub(t, { + getResponses: { ...withUmbrella, [paths.pr]: prResponse(false) }, + }); + + const result = await mergeIntoUmbrella(github.repository(), { + pr: pr, + route: route, + umbrellaTitle, + }); + + assert.deepEqual(result, { + status: 'refused', + refusal: { code: 'umbrella-conflict', branch: 'release-queue/main' }, + }); + assert.deepEqual(github.writes, [setBase('release-queue/main'), setBase('main')]); + }); + + it('restores the base when GitHub refuses the merge', async (t) => { + const github = stubGitHub(t, { + getResponses: withUmbrella, + writeFailures: { + [`PUT repos/${REPO}/pulls/7/merge`]: { + status: 405, + message: 'Pull Request is not mergeable', + }, + }, + }); + + const result = await mergeIntoUmbrella(github.repository(), { + pr: pr, + route: route, + umbrellaTitle, + }); + + assert.deepEqual(result, { + status: 'refused', + refusal: { code: 'merge-failed', reason: 'Pull Request is not mergeable' }, + }); + assert.deepEqual(github.writes, [setBase('release-queue/main'), merge, setBase('main')]); + }); + + it('restores the base, then rethrows an unexpected error', async (t) => { + const github = stubGitHub(t, { + getResponses: withUmbrella, + writeFailures: { [`PUT repos/${REPO}/pulls/7/merge`]: new TypeError('fetch failed') }, + }); + + await assert.rejects( + mergeIntoUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), + TypeError + ); + assert.deepEqual(github.writes, [setBase('release-queue/main'), merge, setBase('main')]); + }); + + it('keeps the reason for restoring when restoring the base fails', async (t) => { + const restoreError = new TypeError('fetch failed'); + const github = stubGitHub(t, { + getResponses: { ...withUmbrella, [paths.pr]: prResponse(false) }, + writeFailures: { [`PATCH repos/${REPO}/pulls/7 {"base":"main"}`]: restoreError }, + }); + + await assert.rejects( + mergeIntoUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), + (error: unknown) => { + assert.ok(error instanceof AggregateError); + assert.deepEqual(error.errors, [restoreError]); + assert.deepEqual(error.cause, { code: 'umbrella-conflict', branch: 'release-queue/main' }); + return true; + } + ); + }); + + it("reports a merged PR whose umbrella PR couldn't be opened", async (t) => { + const github = stubGitHub(t, { + getResponses: withoutUmbrella, + writeFailures: { + [`POST repos/${REPO}/pulls`]: { + status: 422, + message: 'Validation Failed', + errors: [{ message: 'A pull request already exists' }], + }, + }, + }); + + const result = await mergeIntoUmbrella(github.repository(), { + pr: pr, + route: route, + umbrellaTitle, + }); + + assert.equal(result.status, 'unopened'); + assert.ok(result.error instanceof GitHubError); + assert.equal(result.error.githubReason, 'Validation Failed: A pull request already exists'); + assert.deepEqual(github.writes, [ + createBranch, + setBase('release-queue/main'), + merge, + openUmbrella, + ]); + }); + + it('names the umbrella by its title when writes are only printed', async (t) => { + const github = stubGitHub(t, { getResponses: withoutUmbrella }); + t.mock.method(console, 'info', () => {}); + const printingClient = github.repository(REPO, { shouldPrintWrites: true }); + + const result = await mergeIntoUmbrella(printingClient, { pr: pr, route: route, umbrellaTitle }); + + assert.deepEqual(result, { + status: 'joined', + umbrella: { status: 'planned', title: '[ci] release astro@7.5.0 docs' }, + }); + assert.deepEqual(github.writes, []); + }); +}); diff --git a/scripts/lib/release-queue/join/umbrella-join.ts b/scripts/lib/release-queue/join/umbrella-join.ts new file mode 100644 index 0000000000000..f34f1bd9e6420 --- /dev/null +++ b/scripts/lib/release-queue/join/umbrella-join.ts @@ -0,0 +1,203 @@ +import { wrapInCode } from '../formats/markdown.ts'; +import { GitHubError } from '../github/error.ts'; +import type { Repository } from '../github/repository.ts'; +import { findOpenUmbrella } from '../umbrella/queries.ts'; +import { getPullRequest, type PullRequest } from './queries.ts'; +import type { JoinRefusal, Route } from './routing.ts'; + +/** A docs PR to join to the umbrella of `route`. */ +interface JoinRequest { + pr: PullRequest; + route: Route; + /** The title given to the umbrella PR when the join opens it. */ + umbrellaTitle: string; +} + +/** + * An open umbrella PR, or a planned one that a join would create. A planned + * umbrella has no number yet: its title names it. + */ +export type Umbrella = { status: 'open'; number: number } | { status: 'planned'; title: string }; + +/** The outcome of a join. */ +type JoinResult = + | { status: 'joined'; umbrella: Umbrella } + | { status: 'refused'; refusal: JoinRefusal } + | { status: 'unopened'; error: unknown }; + +/** A join that merged the PR, but couldn't open the umbrella PR. */ +export type UnopenedJoin = Extract; + +/** + * The umbrella branch before a join that opens the umbrella PR: missing, left + * over without commits (e.g. after a failed first merge), or left over with + * commits, which a maintainer has to look at. + */ +type UmbrellaBranch = { state: 'missing' | 'empty'; umbrellaBaseSha: string } | { state: 'stale' }; + +/** + * The join of a docs PR to its umbrella: the squash merge into the umbrella + * branch, and the umbrella PR when none is open yet. + */ +export class UmbrellaJoin { + #repo: Repository; + #pr: PullRequest; + #route: Route; + #umbrellaTitle: string; + + /** + * Prepares the join of a docs PR. + * + * @param {Repository} repo - The docs repository. + * @param {JoinRequest} request - The PR to merge, its route, and the + * umbrella title. + */ + constructor(repo: Repository, { pr, route, umbrellaTitle }: JoinRequest) { + this.#repo = repo; + this.#pr = pr; + this.#route = route; + this.#umbrellaTitle = umbrellaTitle; + } + + /** + * Squash-merges the docs PR into its umbrella branch, then opens the + * umbrella PR if none is open, because GitHub refuses a PR without commits. + * The refresh sets the labels and the body of a new umbrella PR. + * + * @returns {Promise} The umbrella the PR joined, or the expected + * failure that stopped the join. + * @throws {Error} On any other failure, once the PR base is restored if it + * changed. + */ + async mergePullRequest(): Promise { + const openUmbrella = await findOpenUmbrella(this.#repo, this.#route.umbrellaBranch); + const branchRefusal = openUmbrella ? undefined : await this.#prepareUmbrellaBranch(); + if (branchRefusal) return { status: 'refused', refusal: branchRefusal }; + + const mergeRefusal = this.#route.shouldRetarget + ? await this.#retargetAndMerge() + : await this.#squashMerge(); + if (mergeRefusal) return { status: 'refused', refusal: mergeRefusal }; + + if (!openUmbrella) return this.#createUmbrella(); + return { status: 'joined', umbrella: { status: 'open', number: openUmbrella.number } }; + } + + /** + * Starts the umbrella branch of a join that opens the umbrella PR. Refuses + * the join when the branch has commits left over. + */ + async #prepareUmbrellaBranch(): Promise { + const branch = await this.#readUmbrellaBranch(); + if (branch.state === 'stale') { + return { code: 'stale-umbrella-branch', branch: this.#route.umbrellaBranch }; + } + await this.#startUmbrellaBranch(branch); + return undefined; + } + + async #readUmbrellaBranch(): Promise { + const { umbrellaBase, umbrellaBranch } = this.#route; + const umbrellaBaseSha = await this.#repo.getBranchSha(umbrellaBase); + if (!umbrellaBaseSha) { + throw new Error(`The branch ${umbrellaBase} doesn't exist in ${this.#repo.name}.`); + } + if ((await this.#repo.getBranchSha(umbrellaBranch)) === undefined) { + return { state: 'missing', umbrellaBaseSha }; + } + const commitsAhead = await this.#repo.countCommitsAhead(umbrellaBase, umbrellaBranch); + return commitsAhead > 0 ? { state: 'stale' } : { state: 'empty', umbrellaBaseSha }; + } + + /** Creates the umbrella branch from its base, or resets an empty one. */ + async #startUmbrellaBranch(branch: Extract) { + const { umbrellaBranch } = this.#route; + if (branch.state === 'missing') { + await this.#repo.write('POST', 'git/refs', { + ref: `refs/heads/${umbrellaBranch}`, + sha: branch.umbrellaBaseSha, + }); + return; + } + await this.#repo.write('PATCH', `git/refs/heads/${umbrellaBranch}`, { + sha: branch.umbrellaBaseSha, + force: true, + }); + } + + #setBase(base: string) { + return this.#repo.write('PATCH', `pulls/${this.#pr.number}`, { base }); + } + + /** + * Changes the PR base to the umbrella branch, then squash-merges the PR. The + * base is restored when the merge is refused or throws. + */ + async #retargetAndMerge(): Promise { + await this.#setBase(this.#route.umbrellaBranch); + // Not a `try` around both paths because a failed restore after a refusal + // would then restore the base a second time. + const refusal = await this.#squashMerge().catch(async (error: unknown) => { + await this.#restoreBase(error); + throw error; + }); + if (refusal) await this.#restoreBase(refusal); + return refusal; + } + + /** Changes the PR base back. */ + async #restoreBase(cause: unknown) { + try { + await this.#setBase(this.#pr.base); + } catch (error) { + throw new AggregateError([error], `Couldn't restore the base of #${this.#pr.number}.`, { + cause, + }); + } + } + + async #squashMerge(): Promise { + const pr = this.#pr; + // GitHub computes the mergeability again after a base change. + const { isMergeable } = await getPullRequest(this.#repo, pr.number); + if (isMergeable === false) { + return { code: 'umbrella-conflict', branch: this.#route.umbrellaBranch }; + } + + try { + await this.#repo.write('PUT', `pulls/${pr.number}/merge`, { + merge_method: 'squash', + // Refuses the merge if the PR changed since its checks were read. + sha: pr.headSha, + // The title GitHub gives a squash merge in the browser. Set here, as + // the included PRs are read from it, whatever the squash setting of + // the repository. + commit_title: `${pr.title} (#${pr.number})`, + }); + return undefined; + } catch (error) { + if (error instanceof GitHubError) return { code: 'merge-failed', reason: error.githubReason }; + throw error; + } + } + + async #createUmbrella(): Promise | UnopenedJoin> { + const { umbrellaBase, umbrellaBranch } = this.#route; + const title = this.#umbrellaTitle; + try { + const created = await this.#repo.write<{ number: number }>('POST', 'pulls', { + title, + head: umbrellaBranch, + base: umbrellaBase, + body: `Release umbrella for ${wrapInCode(umbrellaBase)}, managed by the release queue.`, + }); + // Without an answer, the request was only printed. + const umbrella: Umbrella = created + ? { status: 'open', number: created.number } + : { status: 'planned', title }; + return { status: 'joined', umbrella }; + } catch (error) { + return { status: 'unopened', error }; + } + } +} diff --git a/scripts/lib/release-queue/main.test.ts b/scripts/lib/release-queue/main.test.ts new file mode 100644 index 0000000000000..4b601b355f203 --- /dev/null +++ b/scripts/lib/release-queue/main.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, type TestContext } from 'node:test'; +import { runReleaseQueue } from './main.ts'; +import { REPO, umbrellaResponse } from './testing/fixtures.ts'; +import { stubGitHub } from './testing/stub-github.ts'; + +const noFlags = { 'preview-refresh': false }; +const env = { GITHUB_TOKEN: 'actions-token', GITHUB_REPOSITORY: REPO }; + +/** Writes `event` to a temporary file, as GitHub Actions does. */ +async function writeEvent(event: unknown): Promise { + const path = join(await mkdtemp(join(tmpdir(), 'release-queue-')), 'event.json'); + await writeFile(path, JSON.stringify(event)); + return path; +} + +const silence = (t: TestContext) => t.mock.method(console, 'info', () => {}); + +describe('runReleaseQueue', () => { + it('previews the refresh of the open umbrellas with --preview-refresh', async (t) => { + silence(t); + const github = stubGitHub(t, { + getResponses: { + [`repos/${REPO}/pulls?state=open`]: [], + 'repos/withastro/astro/pulls?state=open&head=withastro:changeset-release/main': [], + }, + }); + + await runReleaseQueue({ flags: { 'preview-refresh': true }, env }); + + assert.equal(github.countReads(`repos/${REPO}/pulls?state=open`), 1); + assert.deepEqual(github.writes, []); + }); + + it('handles the PR and the sender of the event', async (t) => { + silence(t); + const github = stubGitHub(t, { + getResponses: { + [`repos/${REPO}/collaborators/contributor/permission`]: { permission: 'read' }, + }, + }); + const path = await writeEvent({ + pull_request: { number: 7 }, + sender: { login: 'contributor' }, + }); + + await runReleaseQueue({ + flags: noFlags, + env: { ...env, GITHUB_ACTIONS: 'true', GITHUB_EVENT_PATH: path }, + }); + + // The sender can't write: the run stops after checking it. + assert.deepEqual( + github.calls.map(({ path: called }) => called), + [`repos/${REPO}/collaborators/contributor/permission`] + ); + }); + + it('refuses an event without a sender in GitHub Actions', async (t) => { + const github = stubGitHub(t, {}); + const path = await writeEvent({ pull_request: { number: 7 } }); + + await assert.rejects( + runReleaseQueue({ + flags: noFlags, + env: { ...env, GITHUB_ACTIONS: 'true', GITHUB_EVENT_PATH: path }, + }), + /^Error: The event has no sender: the write access check needs one\.$/ + ); + assert.deepEqual(github.calls, []); + }); + + it('only prints the writes of a local run, without the bot token', async (t) => { + silence(t); + const github = stubGitHub(t, { + getResponses: { + [`repos/${REPO}/pulls?state=open`]: [umbrellaResponse()], + 'repos/withastro/astro/pulls?state=open&head=withastro:changeset-release/main': [], + [`repos/${REPO}/compare/main...release-queue/main`]: { ahead_by: 0, commits: [] }, + [`repos/${REPO}/pulls/42/reviews`]: [], + [`repos/${REPO}/pulls?state=open&base=release-queue/main`]: [], + }, + }); + + await runReleaseQueue({ flags: { 'preview-refresh': true }, env: { ...env, MODE: 'all' } }); + + assert.ok(github.calls.length > 0); + assert.deepEqual(github.writes, []); + }); + + it('requires the bot token to write outside a dry run', async () => { + await assert.rejects( + runReleaseQueue({ + flags: { 'preview-refresh': true }, + env: { ...env, GITHUB_ACTIONS: 'true', MODE: 'all' }, + }), + /^Error: BOT_TOKEN is required when MODE is all\.$/ + ); + }); +}); diff --git a/scripts/lib/release-queue/main.ts b/scripts/lib/release-queue/main.ts new file mode 100644 index 0000000000000..c706261785d5e --- /dev/null +++ b/scripts/lib/release-queue/main.ts @@ -0,0 +1,56 @@ +import { readFile } from 'node:fs/promises'; +import { chooseAction, parseEvent, type RunEvent } from './cli.ts'; +import { handleAcceptedPullRequest } from './handle-accepted.ts'; +import { refreshOpenUmbrellas } from './refresh-umbrellas.ts'; +import { createRunContext, isGitHubActions, type RunEnv } from './run-context.ts'; + +/** The command-line flags of `scripts/release-queue.ts`. */ +interface Flags { + pr?: string | undefined; + 'preview-refresh': boolean; + /** Who added the label, for the write access check. */ + sender?: string | undefined; +} + +/** The environment variables the release queue reads. */ +interface Env extends RunEnv { + // The variables below are set by GitHub Actions. + GITHUB_EVENT_NAME?: string | undefined; + GITHUB_EVENT_PATH?: string | undefined; +} + +/** + * Routes a docs PR labeled as accepted, or refreshes the open umbrella PRs. + * + * In GitHub Actions, the event says which, and the writes are sent. In a local + * run, the flags say which, and the writes are only printed. + * + * @param {object} input - The input of the run. + * @param {Flags} input.flags - The command-line flags. + * @param {Env} input.env - The environment variables. + * @throws {Error} When a token or the label sender is missing, or when the + * flags and the event don't say what to do. + */ +export async function runReleaseQueue({ flags, env }: { flags: Flags; env: Env }) { + const run = createRunContext(env); + const event = await readEvent(env.GITHUB_EVENT_PATH); + const action = chooseAction({ + pr: flags.pr, + sender: flags.sender, + isRefreshRequested: flags['preview-refresh'], + eventName: env.GITHUB_EVENT_NAME, + eventPullRequest: event.pullRequest, + eventSender: event.labelSender, + isSenderRequired: isGitHubActions(env), + }); + + if (action.type === 'refresh') { + await refreshOpenUmbrellas(run); + } else { + await handleAcceptedPullRequest(run, action); + } +} + +async function readEvent(path: string | undefined): Promise { + return path ? parseEvent(await readFile(path, 'utf-8'), path) : {}; +} diff --git a/scripts/lib/release-queue/modes.test.ts b/scripts/lib/release-queue/modes.test.ts new file mode 100644 index 0000000000000..93b251056763d --- /dev/null +++ b/scripts/lib/release-queue/modes.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { canActOn, isDryRun, parseMode } from './modes.ts'; + +describe('parseMode', () => { + it('defaults to a dry run', () => { + assert.equal(parseMode(), 'dry-run'); + }); + + it('reads a known mode, and refuses an unknown one', () => { + assert.equal(parseMode('stable-only'), 'stable-only'); + assert.throws(() => parseMode('main'), /Unknown MODE: main\. Expected one of: dry-run/); + }); +}); + +describe('canActOn', () => { + it('acts on the stable branch only in stable-only mode', () => { + assert.equal(canActOn('stable-only', 'main'), true); + assert.equal(canActOn('stable-only', 'cloudflare-next'), false); + }); + + it('acts on every umbrella base in all mode, and none in a dry run', () => { + assert.equal(canActOn('all', 'cloudflare-next'), true); + assert.equal(canActOn('dry-run', 'main'), false); + }); +}); + +describe('isDryRun', () => { + it('tells a dry run from the live modes', () => { + assert.equal(isDryRun('dry-run'), true); + assert.equal(isDryRun('stable-only'), false); + assert.equal(isDryRun('all'), false); + }); +}); diff --git a/scripts/lib/release-queue/modes.ts b/scripts/lib/release-queue/modes.ts new file mode 100644 index 0000000000000..012932f937854 --- /dev/null +++ b/scripts/lib/release-queue/modes.ts @@ -0,0 +1,49 @@ +import { DOCS_STABLE_BRANCH } from './config.ts'; + +const MODES = { dryRun: 'dry-run', stableOnly: 'stable-only', all: 'all' } as const; + +/** + * The modes of the bot: + * - `dry-run`: only comments with the umbrella a PR would join, or why not. + * - `stable-only`: acts on PRs shipping to the stable branch, dry run for the + * others. + * - `all`: acts on every PR. + */ +export type Mode = (typeof MODES)[keyof typeof MODES]; + +const ALL_MODES: Mode[] = Object.values(MODES); + +/** + * Reads the bot mode. + * + * @param {string} [value] - A `MODE` value. Defaults to dry run. + * @returns {Mode} The resolved mode. + * @throws {Error} When `value` isn't a valid mode. + */ +export function parseMode(value: string = MODES.dryRun): Mode { + const mode = ALL_MODES.find((candidate) => candidate === value); + if (!mode) throw new Error(`Unknown MODE: ${value}. Expected one of: ${ALL_MODES.join(', ')}.`); + return mode; +} + +/** + * Whether the mode lets the bot act on the PRs shipping to a branch: join, + * refuse, and refresh them, instead of a dry run. + * + * @param {Mode} mode - The bot mode. + * @param {string} umbrellaBase - The branch the umbrella PR merges into. + * @returns {boolean} `true` when the bot acts on these PRs. + */ +export function canActOn(mode: Mode, umbrellaBase: string): boolean { + return mode === MODES.all || (mode === MODES.stableOnly && umbrellaBase === DOCS_STABLE_BRANCH); +} + +/** + * Whether the bot only comments, without changing PRs. + * + * @param {Mode} mode - The bot mode. + * @returns {boolean} `true` in a dry run. + */ +export function isDryRun(mode: Mode): boolean { + return mode === MODES.dryRun; +} diff --git a/scripts/lib/release-queue/refresh-umbrellas.test.ts b/scripts/lib/release-queue/refresh-umbrellas.test.ts new file mode 100644 index 0000000000000..f3d9cb483760b --- /dev/null +++ b/scripts/lib/release-queue/refresh-umbrellas.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { refreshOpenUmbrellas } from './refresh-umbrellas.ts'; +import { + emptyMergeBlockResponses, + pullRequestResponse, + REPO, + umbrellaCommitResponses, + umbrellaResponse, +} from './testing/fixtures.ts'; +import { ACTIONS_TOKEN, BOT_TOKEN, stubRunContext } from './testing/run-context.ts'; +import { stubGitHub, type Write } from './testing/stub-github.ts'; +import { formatUmbrellaBody } from './umbrella/body.ts'; + +const included = [7]; + +describe('refreshOpenUmbrellas', () => { + /** + * Open PRs: the `main` and `cloudflare-next` umbrellas, a fork, a PR from an + * umbrella branch into another base, and a docs PR. + */ + const openPullRequests = { + [`repos/${REPO}/pulls?state=open`]: [ + umbrellaResponse(), + umbrellaResponse({ number: 43, umbrellaBase: 'cloudflare-next' }), + umbrellaResponse({ + number: 44, + head: { ref: 'release-queue/main', sha: 'fork-sha', repo: { full_name: 'someone/docs' } }, + }), + umbrellaResponse({ number: 45, base: { ref: 'v6' } }), + pullRequestResponse(), + ], + 'repos/withastro/astro/pulls?state=open&head=withastro:changeset-release/main': [ + { number: 1, html_url: '', body: '## astro@7.5.0\n' }, + ], + ...umbrellaCommitResponses(), + ...umbrellaCommitResponses('cloudflare-next'), + ...emptyMergeBlockResponses(42), + ...emptyMergeBlockResponses(43, 'cloudflare-next'), + }; + + const refreshedUmbrellas = (writes: Write[]) => + [...new Set(writes.map(({ path }) => path.match(/\/(?:pulls|issues)\/(\d+)/)?.[1]))].map( + Number + ); + + it('reads a description edited in the browser as up to date', async (t) => { + const body = formatUmbrellaBody('main', { releasePrNumber: 1, included }); + const github = stubGitHub(t, { + getResponses: { + ...openPullRequests, + [`repos/${REPO}/pulls?state=open`]: [ + umbrellaResponse({ + title: '[ci] release astro@7.5.0 docs', + labels: [{ name: 'merge-on-release' }, { name: 'minor-release' }], + // GitHub keeps the line endings of the browser. + body: body.replaceAll('\n', '\r\n'), + }), + ], + }, + }); + + await refreshOpenUmbrellas(stubRunContext(github, 'stable-only')); + + assert.deepEqual(github.writes, []); + }); + + it("doesn't change anything in a dry run", async (t) => { + const github = stubGitHub(t, { getResponses: openPullRequests }); + + await refreshOpenUmbrellas(stubRunContext(github, 'dry-run')); + + assert.deepEqual(github.writes, []); + }); + + it('refreshes only the main umbrella in stable-only mode', async (t) => { + const github = stubGitHub(t, { getResponses: openPullRequests }); + + await refreshOpenUmbrellas(stubRunContext(github, 'stable-only')); + + assert.deepEqual(refreshedUmbrellas(github.writesBy(BOT_TOKEN)), [42]); + // Without a join, no merge-block review is requested. + assert.deepEqual(github.writesBy(ACTIONS_TOKEN), []); + // The prerelease umbrella is skipped: nothing reads its release PR. + assert.ok(!github.calls.some(({ path }) => path.includes('.changeset/pre.json'))); + }); + + it('updates an active merge-block review', async (t) => { + const github = stubGitHub(t, { + getResponses: { + ...openPullRequests, + [`repos/${REPO}/pulls/42/reviews`]: [ + { + id: 1, + user: { login: 'github-actions[bot]' }, + state: 'CHANGES_REQUESTED', + body: 'Old', + }, + ], + }, + }); + + await refreshOpenUmbrellas(stubRunContext(github, 'stable-only')); + + assert.deepEqual( + github.writesBy(ACTIONS_TOKEN).map(({ method, path }) => `${method} ${path}`), + [`PUT repos/${REPO}/pulls/42/reviews/1`] + ); + }); + + it('refreshes every umbrella in all mode, but not a fork or another base', async (t) => { + const github = stubGitHub(t, { getResponses: openPullRequests }); + + await refreshOpenUmbrellas(stubRunContext(github, 'all')); + + assert.deepEqual(refreshedUmbrellas(github.writesBy(BOT_TOKEN)), [42, 43]); + }); + + it('refreshes the other umbrellas when one fails, then reports it', async (t) => { + const github = stubGitHub(t, { + getResponses: openPullRequests, + writeFailures: { [`PATCH repos/${REPO}/pulls/42`]: { status: 500, message: 'Oops' } }, + }); + + await assert.rejects(refreshOpenUmbrellas(stubRunContext(github, 'all')), (error) => { + assert.ok(error instanceof AggregateError); + assert.equal(error.message, "Couldn't refresh 1 of 2 umbrella PRs."); + assert.match(String(error.errors[0]), /Couldn't refresh #42\./); + return true; + }); + assert.deepEqual(refreshedUmbrellas(github.writesBy(BOT_TOKEN)), [42, 43]); + }); +}); diff --git a/scripts/lib/release-queue/refresh-umbrellas.ts b/scripts/lib/release-queue/refresh-umbrellas.ts new file mode 100644 index 0000000000000..2e4e113342005 --- /dev/null +++ b/scripts/lib/release-queue/refresh-umbrellas.ts @@ -0,0 +1,61 @@ +import { canActOn } from './modes.ts'; +import { readFrameworkReleases } from './release/queries.ts'; +import type { RunContext } from './run-context.ts'; +import { listOpenUmbrellas, type UmbrellaPullRequest } from './umbrella/queries.ts'; +import { UmbrellaRefresher } from './umbrella/refresh.ts'; + +/** + * Refreshes every open umbrella PR the mode acts on, for example after a + * change to a release PR. + * + * @param {RunContext} run - The repositories and the mode of the run. + * @throws {AggregateError} Once every umbrella was tried, when some of them + * couldn't be refreshed. + */ +export async function refreshOpenUmbrellas(run: RunContext) { + const { actions, framework, mode } = run; + const [umbrellas, releases] = await Promise.all([ + listOpenUmbrellas(actions), + readFrameworkReleases(framework), + ]); + const numbers = umbrellas.map(({ number }) => `#${number}`).join(', '); + console.info(`Open umbrellas: ${numbers || 'none'}.`); + + const { live: liveUmbrellas = [], skipped = [] } = Object.groupBy(umbrellas, (umbrella) => + canActOn(mode, umbrella.base) ? 'live' : 'skipped' + ); + for (const umbrella of skipped) { + console.info(`Skipped #${umbrella.number}: dry run for ${umbrella.base}.`); + } + const refresher = new UmbrellaRefresher(run, releases); + const failures: Error[] = []; + // One after the other, as GitHub advises for writes: concurrent ones can + // hit its secondary rate limits. + for (const umbrella of liveUmbrellas) { + const failure = await tryRefreshUmbrella(refresher, umbrella); + if (failure) failures.push(failure); + } + if (failures.length > 0) { + throw new AggregateError( + failures, + `Couldn't refresh ${failures.length} of ${liveUmbrellas.length} umbrella PRs.` + ); + } +} + +/** + * Refreshes an umbrella PR without a join. Catches a failure, so the other + * umbrellas still get refreshed. + */ +async function tryRefreshUmbrella( + refresher: UmbrellaRefresher, + umbrella: UmbrellaPullRequest +): Promise { + try { + // Without a join, a dismissed review still covers the included PRs. + await refresher.refresh(umbrella, { shouldBlock: false }); + return undefined; + } catch (error) { + return new Error(`Couldn't refresh #${umbrella.number}.`, { cause: error }); + } +} diff --git a/scripts/lib/release-queue/release/queries.test.ts b/scripts/lib/release-queue/release/queries.test.ts new file mode 100644 index 0000000000000..ee91fb693c728 --- /dev/null +++ b/scripts/lib/release-queue/release/queries.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { FRAMEWORK_REPO } from '../config.ts'; +import { GitHubClient } from '../github/client.ts'; +import { Repository } from '../github/repository.ts'; +import { stubGitHub } from '../testing/stub-github.ts'; +import { FrameworkReleases } from './queries.ts'; +import { getStableRelease } from './release.ts'; + +describe('FrameworkReleases.getPrereleasePullRequest', () => { + const preJson = 'repos/withastro/astro/contents/.changeset/pre.json?ref=next'; + const nextPullRequests = + 'repos/withastro/astro/pulls?state=open&head=withastro:changeset-release/next'; + const stablePullRequest = { number: 18405, versions: [] }; + /** Gets the prerelease PR, with `stablePullRequest` open on the stable branch. */ + const getPrereleasePullRequest = (framework: Repository) => + new FrameworkReleases( + framework, + getStableRelease(stablePullRequest) + ).getPrereleasePullRequest(); + const preMode = (mode: string) => ({ + content: Buffer.from(JSON.stringify({ mode, tag: 'beta' })).toString('base64'), + }); + + it('links the release PR of next while it is in prerelease mode', async (t) => { + const github = stubGitHub(t, { + getResponses: { + [preJson]: preMode('pre'), + [nextPullRequests]: [{ number: 18269, body: '## @astrojs/cloudflare@15.0.0-beta.2\n' }], + }, + }); + + const pr = await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO)); + + assert.deepEqual(pr, { + number: 18269, + versions: [{ name: '@astrojs/cloudflare', version: '15.0.0-beta.2' }], + }); + }); + + it('finds no release PR while next has none open', async (t) => { + const github = stubGitHub(t, { + getResponses: { [preJson]: preMode('pre'), [nextPullRequests]: [] }, + }); + + assert.equal(await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO)), undefined); + }); + + it('fails on an error other than a missing pre.json', async (t) => { + t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status: 500 })); + + await assert.rejects( + getPrereleasePullRequest(new Repository(new GitHubClient('token'), FRAMEWORK_REPO)), + { + name: 'GitHubError', + status: 500, + } + ); + }); + + it('fails on a pre.json that is not JSON', async (t) => { + const github = stubGitHub(t, { + getResponses: { [preJson]: { content: Buffer.from('{ mode: pre').toString('base64') } }, + }); + + await assert.rejects( + getPrereleasePullRequest(github.repository(FRAMEWORK_REPO)), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /`\.changeset\/pre\.json` of `next` isn't valid JSON/); + assert.ok(error.cause instanceof SyntaxError); + return true; + } + ); + }); + + for (const preState of ['null', '{"tag":"beta"}', '{"mode":1}']) { + it(`fails on a pre.json without a mode: ${preState}`, async (t) => { + const github = stubGitHub(t, { + getResponses: { [preJson]: { content: Buffer.from(preState).toString('base64') } }, + }); + + await assert.rejects( + getPrereleasePullRequest(github.repository(FRAMEWORK_REPO)), + /`\.changeset\/pre\.json` of `next` has no `mode`/ + ); + }); + } + + it('links the release PR of main once next left prerelease mode', async (t) => { + for (const getResponses of [{ [preJson]: preMode('exit') }, {}]) { + const github = stubGitHub(t, { getResponses }); + + assert.equal( + await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO)), + stablePullRequest + ); + assert.equal(github.countReads(nextPullRequests), 0); + } + }); +}); diff --git a/scripts/lib/release-queue/release/queries.ts b/scripts/lib/release-queue/release/queries.ts new file mode 100644 index 0000000000000..64a4a427baa0f --- /dev/null +++ b/scripts/lib/release-queue/release/queries.ts @@ -0,0 +1,114 @@ +import { FRAMEWORK_PRERELEASE_BRANCH, FRAMEWORK_STABLE_BRANCH } from '../config.ts'; +import { isRecord, parseJson } from '../formats/json.ts'; +import type { Repository } from '../github/repository.ts'; +import type { PullRequestResponse } from '../github/types.ts'; +import { + getStableRelease, + parseReleaseVersions, + type ReleasePullRequest, + type StableRelease, +} from './release.ts'; + +/** The branch prefix of the changesets release PRs. */ +const RELEASE_BRANCH_PREFIX = 'changeset-release/'; +/** The changesets file that holds the prerelease mode of a branch. */ +const PRE_STATE_PATH = '.changeset/pre.json'; + +/** + * Reads the next release from the stable branch of the framework repository. + * + * @param {Repository} framework - The framework repository. + * @returns {Promise} The releases of the framework. + */ +export async function readFrameworkReleases(framework: Repository): Promise { + const stablePullRequest = await getReleasePullRequest(framework, FRAMEWORK_STABLE_BRANCH); + return new FrameworkReleases(framework, getStableRelease(stablePullRequest)); +} + +/** + * The releases of the framework repository in a run: the next stable release, + * read once, and the release PRs read when an umbrella needs them. + */ +export class FrameworkReleases { + /** The next release from the stable branch. */ + readonly stable: StableRelease; + #framework: Repository; + + /** + * Binds the next stable release to the repository it was read from. + * + * @param {Repository} framework - The framework repository. + * @param {StableRelease} stable - The next release from the stable branch. + */ + constructor(framework: Repository, stable: StableRelease) { + this.#framework = framework; + this.stable = stable; + } + + /** + * Gets the release PR a prerelease umbrella ships with. + * + * @returns {Promise} The alpha or beta + * release PR while the prerelease branch is in prerelease mode. Once that + * branch is merged into the stable branch, the release PR of the stable + * branch. + */ + async getPrereleasePullRequest(): Promise { + return (await isPrereleaseBranchInPreMode(this.#framework)) + ? getReleasePullRequest(this.#framework, FRAMEWORK_PRERELEASE_BRANCH) + : this.stable.pullRequest; + } + + /** + * Whether a PR of the framework repository, such as a release PR, is merged. + * + * @param {number} prNumber - The number of the PR. + * @returns {Promise} `false` for a PR that doesn't exist. + */ + async isPullRequestMerged(prNumber: number): Promise { + const pr = await this.#framework.find(`pulls/${prNumber}`); + return pr?.merged === true; + } +} + +/** + * Gets the open release PR of a framework repository branch. + * + * @param {Repository} framework - The framework repository. + * @param {string} branch - The stable branch, or the prerelease branch for + * alpha and beta releases. + * @returns {Promise} The release PR with its + * package versions, or `undefined` when none is open. + */ +async function getReleasePullRequest( + framework: Repository, + branch: string +): Promise { + // Changesets keeps one release PR open per branch, and updates it. + const [pr] = await framework.listOpenPullRequests({ head: `${RELEASE_BRANCH_PREFIX}${branch}` }); + if (!pr) return undefined; + return { number: pr.number, versions: parseReleaseVersions(pr.body ?? '') }; +} + +/** Whether the prerelease branch of the framework is in prerelease mode. */ +async function isPrereleaseBranchInPreMode(framework: Repository): Promise { + const preState = await framework.find<{ content: string }>( + `contents/${PRE_STATE_PATH}?ref=${FRAMEWORK_PRERELEASE_BRANCH}` + ); + if (!preState) return false; + // `exit` means the prerelease ends: the branch is then merged into the + // stable branch. + return readPreMode(Buffer.from(preState.content, 'base64').toString()) === 'pre'; +} + +/** Reads the prerelease mode from the changesets file of the branch. */ +function readPreMode(json: string): string { + const source = `The \`${PRE_STATE_PATH}\` of \`${FRAMEWORK_PRERELEASE_BRANCH}\``; + const preState = parseJson(json, source); + if (!isPreState(preState)) throw new Error(`${source} has no \`mode\`.`); + return preState.mode; +} + +function isPreState(value: unknown): value is { mode: string } { + return isRecord(value) && typeof value.mode === 'string'; +} diff --git a/scripts/lib/release-queue/release/release.test.ts b/scripts/lib/release-queue/release/release.test.ts new file mode 100644 index 0000000000000..cee50bf29ef8a --- /dev/null +++ b/scripts/lib/release-queue/release/release.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getStableRelease, parseReleaseVersions } from './release.ts'; + +const body = `This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. + +# Releases +## astro@7.4.1 + +### Patch Changes + +- [#18339](https://github.com/withastro/astro/pull/18339) Fixes a bug with \`astro@7.4.0\` + +## @astrojs/vercel@11.0.14\r + +### Patch Changes + +## @astrojs/cloudflare@15.0.0-beta.2 +`; + +describe('parseReleaseVersions', () => { + it('reads every package heading, including scoped packages and prereleases', () => { + assert.deepEqual(parseReleaseVersions(body), [ + { name: 'astro', version: '7.4.1' }, + { name: '@astrojs/vercel', version: '11.0.14' }, + { name: '@astrojs/cloudflare', version: '15.0.0-beta.2' }, + ]); + }); + + it('returns no versions for a body without release headings', () => { + assert.deepEqual(parseReleaseVersions('No releases.'), []); + }); +}); + +describe('getStableRelease', () => { + const pullRequest = (versions: { name: string; version: string }[]) => ({ + number: 18459, + versions, + }); + + it('takes the version of the astro package by its exact name', () => { + const release = getStableRelease(pullRequest(parseReleaseVersions(body))); + assert.equal(release.frameworkVersion, '7.4.1'); + }); + + for (const [version, type] of [ + ['7.4.1', 'patch'], + ['7.4.0', 'minor'], + ['8.0.0', 'major'], + ['8.0.0-beta.1', 'patch'], + ['7.5.0-alpha.0', 'patch'], + ] as const) { + it(`reads astro@${version} as a ${type} release`, () => { + assert.equal(getStableRelease(pullRequest([{ name: 'astro', version }])).type, type); + }); + } + + it('reads a patch when there is no release PR', () => { + assert.deepEqual(getStableRelease(), { + type: 'patch', + frameworkVersion: undefined, + pullRequest: undefined, + }); + }); + + it('reads a patch when astro is not released', () => { + const release = getStableRelease(pullRequest([{ name: '@astrojs/vercel', version: '11.1.0' }])); + assert.equal(release.type, 'patch'); + assert.equal(release.frameworkVersion, undefined); + }); + + it('reads the release type from the astro version, and keeps the PR', () => { + const minor = pullRequest([{ name: 'astro', version: '7.5.0' }]); + assert.deepEqual(getStableRelease(minor), { + type: 'minor', + frameworkVersion: '7.5.0', + pullRequest: minor, + }); + assert.equal( + getStableRelease(pullRequest([{ name: 'astro', version: '8.0.0' }])).type, + 'major' + ); + }); +}); diff --git a/scripts/lib/release-queue/release/release.ts b/scripts/lib/release-queue/release/release.ts new file mode 100644 index 0000000000000..2239c3b0d1f5e --- /dev/null +++ b/scripts/lib/release-queue/release/release.ts @@ -0,0 +1,84 @@ +import { + FRAMEWORK_CORE_PACKAGE, + FRAMEWORK_REPO, + RELEASE_TYPES, + type ReleaseType, +} from '../config.ts'; + +/** A `## @` heading of a release PR body. */ +const RELEASE_HEADING = /^## (\S+)@(\d+\.\d+\.\d+\S*)\s*$/gm; +/** A stable version, without a prerelease suffix: its minor and patch. */ +const STABLE_VERSION = /^\d+\.(\d+)\.(\d+)$/; + +interface PackageVersion { + name: string; + version: string; +} + +/** A release PR of the framework repository. */ +export interface ReleasePullRequest { + number: number; + versions: PackageVersion[]; +} + +/** The next release from the stable branch of the framework repository. */ +export interface StableRelease { + type: ReleaseType; + /** The version of the framework package, if the release includes it. */ + frameworkVersion?: string | undefined; + /** The open release PR, if any. */ + pullRequest?: ReleasePullRequest | undefined; +} + +/** + * Reads the package versions of a release PR, from the headings of its body. + * + * @param {string} body - The body of the release PR. + * @returns {PackageVersion[]} The versions, in the order of the headings. + */ +export function parseReleaseVersions(body: string): PackageVersion[] { + return Array.from(body.matchAll(RELEASE_HEADING), ([, name, version]) => ({ + name, + version, + })); +} + +function findVersion(versions: PackageVersion[], name: string): string | undefined { + return versions.find((pkg) => pkg.name === name)?.version; +} + +/** + * Gets the type of a stable release from its version. Prereleases and missing + * versions count as patches, because only a new stable minor or major changes + * how docs PRs are routed. + */ +function getReleaseType(version: string | undefined): ReleaseType { + const match = version?.match(STABLE_VERSION); + if (!match) return RELEASE_TYPES.patch; + const [, minor, patch] = match; + if (patch !== '0') return RELEASE_TYPES.patch; + return minor === '0' ? RELEASE_TYPES.major : RELEASE_TYPES.minor; +} + +/** + * Gets the next release from the stable branch. + * + * @param {ReleasePullRequest} [pullRequest] - The open release PR of the + * stable branch. Without one, the release is a patch. + * @returns {StableRelease} The type and version of the release, with its PR. + */ +export function getStableRelease(pullRequest?: ReleasePullRequest): StableRelease { + const frameworkVersion = findVersion(pullRequest?.versions ?? [], FRAMEWORK_CORE_PACKAGE); + return { type: getReleaseType(frameworkVersion), frameworkVersion, pullRequest }; +} + +/** + * Writes a reference to a PR of the framework repository, which GitHub turns + * into a link. + * + * @param {number} prNumber - The number of the framework PR. + * @returns {string} The reference, such as `withastro/astro#18459`. + */ +export function formatFrameworkReference(prNumber: number): string { + return `${FRAMEWORK_REPO}#${prNumber}`; +} diff --git a/scripts/lib/release-queue/run-context.ts b/scripts/lib/release-queue/run-context.ts new file mode 100644 index 0000000000000..b187160bd1844 --- /dev/null +++ b/scripts/lib/release-queue/run-context.ts @@ -0,0 +1,74 @@ +import { DOCS_REPO, FRAMEWORK_REPO } from './config.ts'; +import { GitHubClient, PrintingGitHubClient } from './github/client.ts'; +import { Repository } from './github/repository.ts'; +import { isDryRun, parseMode, type Mode } from './modes.ts'; + +/** The environment variables that set how a run reaches GitHub. */ +export interface RunEnv { + /** Used for reads, dry-run comments and merge-block reviews. */ + GITHUB_TOKEN?: string | undefined; + /** Used for joins, refusals and refreshes. */ + BOT_TOKEN?: string | undefined; + /** The bot mode. */ + MODE?: string | undefined; + // The variables below are set by GitHub Actions. + GITHUB_ACTIONS?: string | undefined; + GITHUB_REPOSITORY?: string | undefined; +} + +/** The repositories a run acts through, and its mode. */ +export interface RunContext { + /** + * The docs repository through `GITHUB_TOKEN`: reads the PR, posts dry-run + * comments, and submits merge-block reviews. + */ + actions: Repository; + /** The docs repository as the bot account: joins, refuses, and refreshes. */ + bot: Repository; + /** The framework repository, read for its release PRs. */ + framework: Repository; + mode: Mode; +} + +/** + * Whether the run is in GitHub Actions, where writes are sent. + * + * @param {RunEnv} env - The environment variables of the run. + * @returns {boolean} `true` in GitHub Actions, `false` in a local run. + */ +export function isGitHubActions(env: RunEnv): boolean { + return env.GITHUB_ACTIONS === 'true'; +} + +/** + * Creates the repositories of a run from its environment. A local run gets + * repositories that print their writes instead of sending them. + * + * @param {RunEnv} env - The environment variables of the run. + * @returns {RunContext} The repositories and the mode of the run. + * @throws {Error} When `MODE` is unknown, `GITHUB_TOKEN` is missing, or + * `BOT_TOKEN` is missing in GitHub Actions outside a dry run. + */ +export function createRunContext(env: RunEnv): RunContext { + const mode = parseMode(env.MODE); + const token = env.GITHUB_TOKEN; + if (!token) throw new Error('GITHUB_TOKEN is required.'); + // A local run never changes GitHub: it prints what it would write. + const shouldWrite = isGitHubActions(env); + // Changes go through the bot account, as its merges into an umbrella branch + // trigger the checks of the umbrella PR. + const botToken = env.BOT_TOKEN; + if (shouldWrite && !isDryRun(mode) && !botToken) { + throw new Error(`BOT_TOKEN is required when MODE is ${mode}.`); + } + + const Client = shouldWrite ? GitHubClient : PrintingGitHubClient; + const actionsClient = new Client(token); + const repo = env.GITHUB_REPOSITORY ?? DOCS_REPO; + return { + actions: new Repository(actionsClient, repo), + bot: new Repository(new Client(botToken ?? token), repo), + framework: new Repository(actionsClient, FRAMEWORK_REPO), + mode, + }; +} diff --git a/scripts/lib/release-queue/testing/fixtures.ts b/scripts/lib/release-queue/testing/fixtures.ts new file mode 100644 index 0000000000000..b4be3048df289 --- /dev/null +++ b/scripts/lib/release-queue/testing/fixtures.ts @@ -0,0 +1,62 @@ +import type { PullRequestResponse } from '../github/types.ts'; + +export const REPO = 'withastro/docs'; + +const pullRequest: PullRequestResponse = { + number: 7, + title: 'Document the new option', + html_url: `https://github.com/${REPO}/pull/7`, + state: 'open', + draft: false, + mergeable: true, + base: { ref: 'main' }, + head: { ref: 'feat/option', sha: 'head-sha', repo: { full_name: REPO } }, + labels: [], + body: null, +}; + +/** A `GET /pulls/7` answer: an open docs PR against `main`, mergeable. */ +export function pullRequestResponse( + overrides: Partial = {} +): PullRequestResponse { + return { ...pullRequest, ...overrides }; +} + +/** + * A `GET /pulls/` answer for the open umbrella PR into `umbrellaBase`, + * with `merge-on-release`. + */ +export function umbrellaResponse({ + umbrellaBase = 'main', + ...overrides +}: { umbrellaBase?: string } & Partial = {}): PullRequestResponse { + return pullRequestResponse({ + number: 42, + title: `[ci] release ${umbrellaBase} docs`, + html_url: `https://github.com/${REPO}/pull/42`, + base: { ref: umbrellaBase }, + head: { ref: `release-queue/${umbrellaBase}`, sha: 'umbrella-sha', repo: { full_name: REPO } }, + labels: [{ name: 'merge-on-release' }], + ...overrides, + }); +} + +/** + * The GET answer for the commits of the umbrella branch of `umbrellaBase`: the + * squash commit of #7. + */ +export const umbrellaCommitResponses = (umbrellaBase = 'main') => ({ + [`repos/${REPO}/compare/${umbrellaBase}...release-queue/${umbrellaBase}`]: { + ahead_by: 1, + commits: [{ commit: { message: 'Document the new option (#7)\n\nDetails.' } }], + }, +}); + +/** + * The GET answers of the merge-block review of umbrella `number`: no review + * yet, and no open PR on its branch. + */ +export const emptyMergeBlockResponses = (number: number, umbrellaBase = 'main') => ({ + [`repos/${REPO}/pulls/${number}/reviews`]: [], + [`repos/${REPO}/pulls?state=open&base=release-queue/${umbrellaBase}`]: [], +}); diff --git a/scripts/lib/release-queue/testing/run-context.ts b/scripts/lib/release-queue/testing/run-context.ts new file mode 100644 index 0000000000000..552966d89b56d --- /dev/null +++ b/scripts/lib/release-queue/testing/run-context.ts @@ -0,0 +1,19 @@ +import { FRAMEWORK_REPO } from '../config.ts'; +import type { Mode } from '../modes.ts'; +import type { RunContext } from '../run-context.ts'; +import { REPO } from './fixtures.ts'; +import type { stubGitHub } from './stub-github.ts'; + +// Distinct tokens tell the writes of each identity apart. +export const ACTIONS_TOKEN = 'actions-token'; +export const BOT_TOKEN = 'bot-token'; + +/** A run context for both entry points, with a repository per identity. */ +export function stubRunContext(github: ReturnType, mode: Mode): RunContext { + return { + actions: github.repository(REPO, { token: ACTIONS_TOKEN }), + bot: github.repository(REPO, { token: BOT_TOKEN }), + framework: github.repository(FRAMEWORK_REPO, { token: ACTIONS_TOKEN }), + mode, + }; +} diff --git a/scripts/lib/release-queue/testing/stub-github.ts b/scripts/lib/release-queue/testing/stub-github.ts new file mode 100644 index 0000000000000..c4a32b5b20608 --- /dev/null +++ b/scripts/lib/release-queue/testing/stub-github.ts @@ -0,0 +1,126 @@ +import type { TestContext } from 'node:test'; +import { isRecord } from '../formats/json.ts'; +import { GitHubClient, PrintingGitHubClient } from '../github/client.ts'; +import { Repository } from '../github/repository.ts'; +import { REPO } from './fixtures.ts'; + +const API_URL = 'https://api.github.com/'; + +/** A request that changes GitHub, as tests compare them. */ +export interface Write { + method: string; + path: string; + body?: unknown; +} + +/** + * Reads the text of a write that sends one, such as a comment, a review, or a + * PR description. + */ +export function readBody(write: Write | undefined): string { + const body = write?.body; + if (isRecord(body) && typeof body.body === 'string') return body.body; + throw new Error(`No body text in the write: ${JSON.stringify(write)}`); +} + +export interface Call extends Write { + /** The token of the client that sent the request. */ + token: string; +} + +/** An error response, or an `Error` that makes `fetch` reject. */ +export type Failure = { status: number; message: string; errors?: unknown[] } | Error; + +export interface StubOptions { + /** + * Answers to GET requests, by path, without the paging parameters. Other + * paths answer with a 404. A function is called on each request, for + * answers that change over time. Lists must have fewer than 100 items: the + * stub answers one page (paging is tested in `github/client.test.ts`). + */ + getResponses?: Record; + /** JSON answers to writes, by ` `. Others answer with a 204. */ + writeResults?: Record; + /** + * Failures of writes, by ` `, or by + * ` ` to fail only the write with that body. + */ + writeFailures?: Record; +} + +export const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + +/** Keeps `body` only when there is one, so `deepEqual` can omit it. */ +const toWrite = (method: string, path: string, body: unknown): Write => + body === undefined ? { method, path } : { method, path, body }; + +/** Splits the paging parameters that `getAll` adds from the path. */ +function parseUrl(url: string): { path: string; page: number } { + const [pathname, query] = url.slice(API_URL.length).split('?'); + const params = query?.split('&') ?? []; + const page = params.find((param) => param.startsWith('page='))?.slice('page='.length); + const rest = params.filter((param) => !/^(per_)?page=/.test(param)); + return { + path: rest.length > 0 ? `${pathname}?${rest.join('&')}` : pathname, + page: Number(page ?? 1), + }; +} + +/** + * Replaces `fetch`, for the length of the test, with a GitHub API answering + * from fixed data, and records every request. + */ +export function stubGitHub( + t: TestContext, + { getResponses = {}, writeResults = {}, writeFailures = {} }: StubOptions = {} +) { + const calls: Call[] = []; + + t.mock.method(globalThis, 'fetch', async (url: string, init: RequestInit = {}) => { + const method = init.method ?? 'GET'; + const { path, page } = parseUrl(url); + const token = new Headers(init.headers).get('authorization')?.replace('Bearer ', '') ?? ''; + const body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined; + calls.push({ ...toWrite(method, path, body), token }); + + if (method === 'GET') { + if (page > 1) throw new Error(`The stub answers one page: shorten the list of ${path}.`); + if (!(path in getResponses)) return json({ message: 'Not Found' }, 404); + const response = getResponses[path]; + return json(typeof response === 'function' ? response() : response); + } + const key = `${method} ${path}`; + const failure = writeFailures[`${key} ${JSON.stringify(body)}`] ?? writeFailures[key]; + if (failure instanceof Error) throw failure; + if (failure) return json({ message: failure.message, errors: failure.errors }, failure.status); + return key in writeResults ? json(writeResults[key]) : new Response(null, { status: 204 }); + }); + + const writesBy = (token?: string): Write[] => + calls + .filter((call) => call.method !== 'GET' && (token === undefined || call.token === token)) + .map((call) => toWrite(call.method, call.path, call.body)); + + return { + calls, + /** The writes, without their token, in order. */ + get writes() { + return writesBy(); + }, + /** The writes of the client using `token`. */ + writesBy, + countReads: (path: string) => + calls.filter((call) => call.method === 'GET' && call.path === path).length, + /** A client of the stubbed API, which doesn't wait between polls. */ + client: (token = 'token') => new GitHubClient(token, { pollDelayMs: 0 }), + /** A repository of the stubbed API, which doesn't wait between polls. */ + repository: (name = REPO, { token = 'token', shouldPrintWrites = false } = {}) => { + const Client = shouldPrintWrites ? PrintingGitHubClient : GitHubClient; + return new Repository(new Client(token, { pollDelayMs: 0 }), name); + }, + }; +} diff --git a/scripts/lib/release-queue/umbrella/body.test.ts b/scripts/lib/release-queue/umbrella/body.test.ts new file mode 100644 index 0000000000000..8b7ed062d2caa --- /dev/null +++ b/scripts/lib/release-queue/umbrella/body.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { formatUmbrellaBody, parseReleaseMarker } from './body.ts'; + +describe('formatUmbrellaBody', () => { + const included = [14580, 14581]; + + it('links the release PR of the main umbrella, and lists the included PRs', () => { + assert.equal( + formatUmbrellaBody('main', { releasePrNumber: 18459, included }), + [ + 'Ships with withastro/astro#18459. This includes:', + '', + '- #14580', + '- #14581', + '', + 'Managed by the release queue: the bot rewrites this description after each join and each manual run.', + '', + ].join('\n') + ); + }); + + it('says when no release PR is linked, without a marker', () => { + const body = formatUmbrellaBody('main', { included }); + assert.match( + body, + /^Ships with the next release of `withastro\/astro`, once its `\[ci\] release` PR is open\. This includes:/ + ); + assert.equal(parseReleaseMarker(body), undefined); + }); + + it('links the release PR of a prerelease umbrella, with a marker', () => { + const body = formatUmbrellaBody('cloudflare-next', { releasePrNumber: 18269, included }); + assert.match(body, /^Ships with withastro\/astro#18269\. This includes:/); + assert.equal(parseReleaseMarker(body), 18269); + }); + + it('says when no prerelease PR is open', () => { + assert.match( + formatUmbrellaBody('cloudflare-next', { included }), + /^Ships with the next prerelease of `withastro\/astro`, once its `\[ci\] release` PR is open\. This includes:/ + ); + }); + + it('says when no PR is included yet', () => { + assert.match( + formatUmbrellaBody('main', { releasePrNumber: 18459, included: [] }), + /^Ships with withastro\/astro#18459\. No docs PR is included yet\.\n\n/ + ); + }); +}); + +describe('parseReleaseMarker', () => { + it('reads the release PR from the body the bot wrote', () => { + const body = formatUmbrellaBody('main', { releasePrNumber: 18405, included: [] }); + assert.equal(parseReleaseMarker(body), 18405); + }); + + it('finds no release PR in a body without a marker', () => { + assert.equal(parseReleaseMarker('Release umbrella for `main`.'), undefined); + }); + + it('reads only the marker at the end of the body', () => { + const body = formatUmbrellaBody('main', { releasePrNumber: 18405, included: [] }); + assert.equal(parseReleaseMarker(`\n${body}`), 18405); + assert.equal(parseReleaseMarker(`${body}\nEdited after the marker.`), undefined); + }); +}); diff --git a/scripts/lib/release-queue/umbrella/body.ts b/scripts/lib/release-queue/umbrella/body.ts new file mode 100644 index 0000000000000..7181e3d0288b3 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/body.ts @@ -0,0 +1,64 @@ +import { FRAMEWORK_REPO, RELEASE_TITLE_PREFIX, DOCS_STABLE_BRANCH } from '../config.ts'; +import { wrapInCode } from '../formats/markdown.ts'; +import { formatFrameworkReference } from '../release/release.ts'; + +/** Writes the hidden marker that keeps the release PR an umbrella links. */ +function formatReleaseMarker(releasePrNumber: number | string): string { + return ``; +} + +// Its characters outside the number match themselves in a regular expression. +// Anchored to the end of the body, as an edit above it could add a marker. +const RELEASE_MARKER = new RegExp(`${formatReleaseMarker('(\\d+)')}\\s*$`); + +/** + * Reads the release PR an umbrella links, from the marker at the end of its + * body. + * + * @param {string} body - The description of the umbrella PR. + * @returns {number | undefined} The number of the release PR, or `undefined` + * without a marker. + */ +export function parseReleaseMarker(body: string): number | undefined { + const match = body.match(RELEASE_MARKER); + return match ? Number(match[1]) : undefined; +} + +/** + * Writes the description of an umbrella PR: what it ships with, and the docs + * PRs it includes. + * + * A linked release PR is also kept in a hidden marker at the end, for the + * refresh to read it back. + * + * @param {string} umbrellaBase - The branch the umbrella PR merges into. + * @param {object} content - What the description says. + * @param {number} [content.releasePrNumber] - The release PR to link. + * @param {number[]} content.included - The numbers of the included docs PRs. + * @returns {string} The description, in Markdown. + */ +export function formatUmbrellaBody( + umbrellaBase: string, + { releasePrNumber, included }: { releasePrNumber?: number | undefined; included: number[] } +): string { + const summary = `${describeShipping(umbrellaBase, releasePrNumber)} ${describeIncluded(included)}`; + const footer = + 'Managed by the release queue: the bot rewrites this description after each join and each manual run.'; + const marker = releasePrNumber === undefined ? [] : [formatReleaseMarker(releasePrNumber)]; + return [`${summary}\n\n${footer}`, ...marker].join('\n'); +} + +function describeIncluded(included: number[]): string { + if (included.length === 0) return 'No docs PR is included yet.'; + // GitHub shows the title of a PR referenced alone in a list item. + const list = included.map((prNumber) => `- #${prNumber}`).join('\n'); + return `This includes:\n\n${list}`; +} + +function describeShipping(umbrellaBase: string, releasePrNumber: number | undefined): string { + if (releasePrNumber !== undefined) { + return `Ships with ${formatFrameworkReference(releasePrNumber)}.`; + } + const release = umbrellaBase === DOCS_STABLE_BRANCH ? 'release' : 'prerelease'; + return `Ships with the next ${release} of ${wrapInCode(FRAMEWORK_REPO)}, once its ${wrapInCode(RELEASE_TITLE_PREFIX)} PR is open.`; +} diff --git a/scripts/lib/release-queue/umbrella/branch.test.ts b/scripts/lib/release-queue/umbrella/branch.test.ts new file mode 100644 index 0000000000000..f87051e645657 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/branch.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getUmbrellaBase } from './branch.ts'; + +describe('getUmbrellaBase', () => { + it('is the base of a PR against a branch', () => { + assert.equal(getUmbrellaBase('main'), 'main'); + assert.equal(getUmbrellaBase('cloudflare-next'), 'cloudflare-next'); + }); + + it('is the base of the umbrella for a PR against an umbrella branch', () => { + assert.equal(getUmbrellaBase('release-queue/main'), 'main'); + assert.equal(getUmbrellaBase('release-queue/cloudflare-next'), 'cloudflare-next'); + }); +}); diff --git a/scripts/lib/release-queue/umbrella/branch.ts b/scripts/lib/release-queue/umbrella/branch.ts new file mode 100644 index 0000000000000..ea7ce4a239bb2 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/branch.ts @@ -0,0 +1,38 @@ +// What the reserved prefix of umbrella branches means. + +import { DOCS_STABLE_BRANCH, UMBRELLA_BRANCH_PREFIX } from '../config.ts'; + +/** + * Whether a branch is an umbrella branch, by its reserved prefix. + * + * @param {string} branch - The branch name. + * @returns {boolean} `true` for an umbrella branch. + */ +export function isUmbrellaBranch(branch: string): boolean { + return branch.startsWith(UMBRELLA_BRANCH_PREFIX); +} + +/** + * Gets the branch a docs PR ships to. + * + * @param {string} base - The base of the docs PR. + * @returns {string} The base itself, or the base of the umbrella when the PR + * targets an umbrella branch. + */ +export function getUmbrellaBase(base: string): string { + return isUmbrellaBranch(base) ? base.slice(UMBRELLA_BRANCH_PREFIX.length) : base; +} + +/** + * Gets the umbrella branch for a base branch. + * + * @param {string} umbrellaBase - The branch the umbrella PR merges into. + * @returns {string} The umbrella branch, with its reserved prefix. + */ +export function getUmbrellaBranch( + umbrellaBase: T +): `${typeof UMBRELLA_BRANCH_PREFIX}${T}` { + return `${UMBRELLA_BRANCH_PREFIX}${umbrellaBase}` as const; +} + +export const STABLE_UMBRELLA_BRANCH = getUmbrellaBranch(DOCS_STABLE_BRANCH); diff --git a/scripts/lib/release-queue/umbrella/heading.test.ts b/scripts/lib/release-queue/umbrella/heading.test.ts new file mode 100644 index 0000000000000..a9ebccf8bc341 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/heading.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getStableRelease } from '../release/release.ts'; +import { getUmbrellaReleaseLabel, getUmbrellaTitle } from './heading.ts'; + +const releasePullRequest = (frameworkVersion?: string) => ({ + number: 18459, + versions: frameworkVersion ? [{ name: 'astro', version: frameworkVersion }] : [], +}); + +describe('getUmbrellaTitle', () => { + it('names the main umbrella after the astro version', () => { + const release = getStableRelease(releasePullRequest('7.4.1')); + assert.equal(getUmbrellaTitle('main', release), '[ci] release astro@7.4.1 docs'); + }); + + it('falls back to the branch name when astro is not released', () => { + const release = getStableRelease(releasePullRequest()); + assert.equal(getUmbrellaTitle('main', release), '[ci] release main docs'); + }); + + it('names a prerelease umbrella after its branch', () => { + const release = getStableRelease(releasePullRequest('7.4.1')); + assert.equal(getUmbrellaTitle('cloudflare-next', release), '[ci] release cloudflare-next docs'); + }); +}); + +describe('getUmbrellaReleaseLabel', () => { + it('labels the main umbrella after the release type', () => { + const patch = getStableRelease(releasePullRequest('7.4.1')); + const minor = getStableRelease(releasePullRequest('7.5.0')); + assert.equal(getUmbrellaReleaseLabel('main', patch), 'patch-release'); + assert.equal(getUmbrellaReleaseLabel('main', minor), 'minor-release'); + }); + + it('labels the main umbrella as a patch when astro is not released', () => { + const release = getStableRelease(releasePullRequest()); + assert.equal(getUmbrellaReleaseLabel('main', release), 'patch-release'); + }); + + it('labels a prerelease umbrella as a major', () => { + const release = getStableRelease(releasePullRequest('7.4.1')); + assert.equal(getUmbrellaReleaseLabel('cloudflare-next', release), 'major-release'); + }); +}); diff --git a/scripts/lib/release-queue/umbrella/heading.ts b/scripts/lib/release-queue/umbrella/heading.ts new file mode 100644 index 0000000000000..3339edc83be58 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/heading.ts @@ -0,0 +1,48 @@ +import { + FRAMEWORK_CORE_PACKAGE, + RELEASE_LABELS, + RELEASE_TITLE_PREFIX, + DOCS_STABLE_BRANCH, + type ReleaseLabel, +} from '../config.ts'; +import type { StableRelease } from '../release/release.ts'; + +/** + * Gets the title of the umbrella PR merging into a branch. + * + * The stable umbrella is named after the version of the core package or, if + * unavailable, the stable branch. A prerelease umbrella is always named after + * its branch. + * + * @param {string} umbrellaBase - The branch the umbrella PR merges into. + * @param {StableRelease} release - The next release from the stable branch. + * @returns {string} The title of the umbrella PR. + */ +export function getUmbrellaTitle(umbrellaBase: string, release: StableRelease): string { + if (umbrellaBase !== DOCS_STABLE_BRANCH) return formatUmbrellaTitle(umbrellaBase); + return formatUmbrellaTitle( + release.frameworkVersion + ? `${FRAMEWORK_CORE_PACKAGE}@${release.frameworkVersion}` + : DOCS_STABLE_BRANCH + ); +} + +function formatUmbrellaTitle(name: string): string { + return `${RELEASE_TITLE_PREFIX} ${name} docs`; +} + +/** + * Gets the release label of the umbrella PR merging into a branch. + * + * @param {string} umbrellaBase - The branch the umbrella PR merges into. + * @param {StableRelease} release - The next release from the stable branch. + * Expected not to be a major for the stable umbrella. + * @returns {ReleaseLabel} The label of the release type for the stable + * umbrella, and the major label for a prerelease umbrella. + */ +export function getUmbrellaReleaseLabel( + umbrellaBase: string, + release: StableRelease +): ReleaseLabel { + return umbrellaBase === DOCS_STABLE_BRANCH ? RELEASE_LABELS[release.type] : RELEASE_LABELS.major; +} diff --git a/scripts/lib/release-queue/umbrella/labels.test.ts b/scripts/lib/release-queue/umbrella/labels.test.ts new file mode 100644 index 0000000000000..22654c4da27e7 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/labels.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getKeptLabelChanges, getLabelChanges } from './labels.ts'; + +describe('getLabelChanges', () => { + it('adds merge-on-release and the release label', () => { + assert.deepEqual(getLabelChanges(['i18n'], 'patch-release'), { + toAdd: ['merge-on-release', 'patch-release'], + toRemove: [], + }); + }); + + it('replaces another release label, and keeps the other labels', () => { + assert.deepEqual( + getLabelChanges(['merge-on-release', 'patch-release', 'i18n'], 'minor-release'), + { toAdd: ['minor-release'], toRemove: ['patch-release'] } + ); + }); + + it('changes nothing on an up-to-date umbrella', () => { + assert.deepEqual(getLabelChanges(['merge-on-release', 'minor-release'], 'minor-release'), { + toAdd: [], + toRemove: [], + }); + }); +}); + +describe('getKeptLabelChanges', () => { + it('keeps the release label an umbrella has', () => { + assert.deepEqual(getKeptLabelChanges(['merge-on-release', 'minor-release']), { + toAdd: [], + toRemove: [], + }); + }); + + it('gives a new umbrella merge-on-release and the fallback release label', () => { + assert.deepEqual(getKeptLabelChanges(['i18n']), { + toAdd: ['merge-on-release', 'patch-release'], + toRemove: [], + }); + }); +}); diff --git a/scripts/lib/release-queue/umbrella/labels.ts b/scripts/lib/release-queue/umbrella/labels.ts new file mode 100644 index 0000000000000..0b4dd57fca1e6 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/labels.ts @@ -0,0 +1,56 @@ +import { + ALL_RELEASE_LABELS, + FALLBACK_RELEASE_LABEL, + MERGE_ON_RELEASE_LABEL, + type ReleaseLabel, +} from '../config.ts'; +import type { LabelChanges } from '../github/repository.ts'; + +/** + * Gets the label changes of an umbrella that follows its release. + * + * The umbrella gets `MERGE_ON_RELEASE_LABEL` and the label of its release, + * and loses the other release labels. Its other labels stay. + * + * @param {string[]} current - The labels of the umbrella PR. + * @param {ReleaseLabel} releaseLabel - The label of the release it ships with. + * @returns {LabelChanges} The labels to add and to remove. + */ +export function getLabelChanges(current: string[], releaseLabel: ReleaseLabel): LabelChanges { + const wanted = [MERGE_ON_RELEASE_LABEL, releaseLabel]; + return { + toAdd: wanted.filter((label) => !current.includes(label)), + toRemove: ALL_RELEASE_LABELS.filter( + (label) => label !== releaseLabel && current.includes(label) + ), + }; +} + +/** + * Gets the label changes of an umbrella whose release can't be followed. + * + * @param {string[]} current - The labels of the umbrella PR. + * @returns {LabelChanges} `MERGE_ON_RELEASE_LABEL` if missing, and the + * fallback release label only when the umbrella has none. + */ +export function getKeptLabelChanges(current: string[]): LabelChanges { + const hasReleaseLabel = ALL_RELEASE_LABELS.some((label) => current.includes(label)); + return { + toAdd: [ + ...(current.includes(MERGE_ON_RELEASE_LABEL) ? [] : [MERGE_ON_RELEASE_LABEL]), + ...(hasReleaseLabel ? [] : [FALLBACK_RELEASE_LABEL]), + ], + toRemove: [], + }; +} + +/** + * Gets the labels of an umbrella PR once label changes are applied. + * + * @param {string[]} current - The labels of the umbrella PR. + * @param {LabelChanges} changes - The labels to add and to remove. + * @returns {string[]} The new labels. + */ +export function applyToLabels(current: string[], { toAdd, toRemove }: LabelChanges): string[] { + return [...current, ...toAdd].filter((label) => !toRemove.includes(label)); +} diff --git a/scripts/lib/release-queue/umbrella/merge-block.test.ts b/scripts/lib/release-queue/umbrella/merge-block.test.ts new file mode 100644 index 0000000000000..fdf61de72e086 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/merge-block.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { describe, it, type TestContext } from 'node:test'; +import { REPO } from '../testing/fixtures.ts'; +import { readBody, stubGitHub } from '../testing/stub-github.ts'; +import { formatUmbrellaBody } from './body.ts'; +import { syncMergeBlockReview } from './merge-block.ts'; +import type { UmbrellaPullRequest } from './queries.ts'; + +/** The `main` umbrella #42, linked to release PR #18405. */ +const umbrella: UmbrellaPullRequest = { + number: 42, + title: '[ci] release astro@7.5.0 docs', + body: formatUmbrellaBody('main', { releasePrNumber: 18405, included: [] }), + labels: ['merge-on-release', 'minor-release'], + base: 'main', + branch: 'release-queue/main', +}; + +const reviewsPath = `repos/${REPO}/pulls/42/reviews`; + +const checklist = [ + '**Before merging**, check:', + '', + '- [ ] withastro/astro#18405 is merged.', + '- [ ] No open PR targets `release-queue/main`.', + '- [ ] If the umbrella has the `i18n` label, add a [Lunaria directive](https://lunaria.dev/guides/tracking/#triggering-changes-per-file) to the commit body in the merge dialog. For example: `@lunaria-track:src/content/docs/en/**.mdx`.', + '', + 'Then dismiss this review. The release queue requests changes again if more docs PRs join.', +].join('\n'); + +/** Stubs the reviews of #42, and the open PRs on its branch. */ +function stub(t: TestContext, reviews: unknown[], openPullRequests: number[] = []) { + return stubGitHub(t, { + getResponses: { + [reviewsPath]: reviews, + [`repos/${REPO}/pulls?state=open&base=release-queue/main`]: openPullRequests.map( + (number) => ({ number }) + ), + }, + }); +} + +/** Submits the review of `reviewedUmbrella`, and returns its body. */ +async function submittedChecklist( + t: TestContext, + reviewedUmbrella: UmbrellaPullRequest, + openPullRequests: number[] = [] +): Promise { + const github = stub(t, [], openPullRequests); + await syncMergeBlockReview(github.repository(), reviewedUmbrella, { shouldBlock: true }); + return readBody(github.writes[0]); +} + +describe('syncMergeBlockReview', () => { + describe('checklist', () => { + it('checks the linked release PR and the PRs left on the branch', async (t) => { + assert.equal(await submittedChecklist(t, umbrella), checklist); + }); + + it('lists the PRs that still target the umbrella branch', async (t) => { + assert.match( + await submittedChecklist(t, umbrella, [14590, 14591]), + /- \[ \] These PRs still target `release-queue\/main`: #14590, #14591\. Merge them into the umbrella, or change their base to `main`/ + ); + }); + + it('names the release PR generically without a link', async (t) => { + assert.match( + await submittedChecklist(t, { ...umbrella, body: '' }), + /- \[ \] The `\[ci\] release` PR of `withastro\/astro` this umbrella ships with is merged\./ + ); + }); + + it('reminds of the Lunaria directive before the i18n label arrives', async (t) => { + // `pr-labeler.yml` adds `i18n` after the merge, in a run that doesn't + // trigger the release queue: the first review is written without it. + assert.doesNotMatch(umbrella.labels.join(), /i18n/); + assert.match( + await submittedChecklist(t, umbrella), + /- \[ \] If the umbrella has the `i18n` label, add a \[Lunaria directive\]/ + ); + }); + }); + + const review = ( + id: number, + state: string, + reviewBody = checklist, + login = 'github-actions[bot]' + ) => ({ id, user: { login }, state, body: reviewBody }); + + const submit = { + method: 'POST', + path: reviewsPath, + body: { event: 'REQUEST_CHANGES', body: checklist }, + }; + + it('requests changes after a join when no review is active', async (t) => { + const github = stub(t, []); + + await syncMergeBlockReview(github.repository(), umbrella, { shouldBlock: true }); + + assert.deepEqual(github.writes, [submit]); + }); + + it('requests changes again after a join once the review was dismissed', async (t) => { + const github = stub(t, [review(1, 'DISMISSED')]); + + await syncMergeBlockReview(github.repository(), umbrella, { shouldBlock: true }); + + assert.deepEqual(github.writes, [submit]); + }); + + it("doesn't block again without a join", async (t) => { + const github = stub(t, [review(1, 'DISMISSED')]); + + await syncMergeBlockReview(github.repository(), umbrella, { shouldBlock: false }); + + assert.deepEqual(github.writes, []); + }); + + it('updates the active review instead of submitting another', async (t) => { + const github = stub(t, [review(1, 'DISMISSED'), review(2, 'CHANGES_REQUESTED', 'Old')]); + + await syncMergeBlockReview(github.repository(), umbrella, { shouldBlock: true }); + + assert.deepEqual(github.writes, [ + { method: 'PUT', path: `${reviewsPath}/2`, body: { body: checklist } }, + ]); + }); + + it("doesn't write an active review that is up to date", async (t) => { + // As GitHub may return the body with its line endings changed. + const github = stub(t, [review(2, 'CHANGES_REQUESTED', checklist.replaceAll('\n', '\r\n'))]); + + await syncMergeBlockReview(github.repository(), umbrella, { shouldBlock: true }); + + assert.deepEqual(github.writes, []); + }); + + it("doesn't count a maintainer's change request as the merge block", async (t) => { + const github = stub(t, [review(3, 'CHANGES_REQUESTED', 'Fix the typo', 'maintainer')]); + + await syncMergeBlockReview(github.repository(), umbrella, { shouldBlock: true }); + + assert.deepEqual(github.writes, [submit]); + }); +}); diff --git a/scripts/lib/release-queue/umbrella/merge-block.ts b/scripts/lib/release-queue/umbrella/merge-block.ts new file mode 100644 index 0000000000000..218a524995def --- /dev/null +++ b/scripts/lib/release-queue/umbrella/merge-block.ts @@ -0,0 +1,128 @@ +import { + FRAMEWORK_REPO, + LUNARIA_DIRECTIVE, + LUNARIA_DIRECTIVE_DOCS, + MERGE_BLOCK_REVIEWER_LOGIN, + RELEASE_TITLE_PREFIX, + TRANSLATIONS_LABEL, +} from '../config.ts'; +import { normalizeLineEndings, wrapInCode } from '../formats/markdown.ts'; +import type { Repository } from '../github/repository.ts'; +import type { ReviewResponse } from '../github/types.ts'; +import { formatFrameworkReference } from '../release/release.ts'; +import { parseReleaseMarker } from './body.ts'; +import type { UmbrellaPullRequest } from './queries.ts'; + +/** A review of an umbrella PR, with its body. */ +interface SubmittedReview extends ReviewResponse { + id: number; + body: string; +} + +/** Writes the checklist to go through before merging the umbrella PR. */ +function formatMergeBlockBody(umbrella: UmbrellaPullRequest, openPullRequests: number[]): string { + const items = [ + describeReleaseItem(umbrella), + describeBranchItem(umbrella, openPullRequests), + describeLunariaItem(), + ]; + const checklist = items.map((item) => `- [ ] ${item}`).join('\n'); + return `**Before merging**, check:\n\n${checklist}\n\nThen dismiss this review. The release queue requests changes again if more docs PRs join.`; +} + +function describeReleaseItem(umbrella: UmbrellaPullRequest): string { + const releasePrNumber = parseReleaseMarker(umbrella.body); + return releasePrNumber === undefined + ? `The ${wrapInCode(RELEASE_TITLE_PREFIX)} PR of ${wrapInCode(FRAMEWORK_REPO)} this umbrella ships with is merged.` + : `${formatFrameworkReference(releasePrNumber)} is merged.`; +} + +function describeBranchItem( + { branch, base }: UmbrellaPullRequest, + openPullRequests: number[] +): string { + if (openPullRequests.length === 0) return `No open PR targets ${wrapInCode(branch)}.`; + const list = openPullRequests.map((prNumber) => `#${prNumber}`).join(', '); + return `These PRs still target ${wrapInCode(branch)}: ${list}. Merge them into the umbrella, or change their base to ${wrapInCode(base)} and update them after the umbrella is merged.`; +} + +/** + * Describes the Lunaria directive to add when the umbrella has the + * translations label. + * + * The item names the label instead of reading it: `pr-labeler.yml` adds it + * after the merge, with `GITHUB_TOKEN`, which doesn't trigger this workflow. + */ +function describeLunariaItem(): string { + return `If the umbrella has the ${wrapInCode(TRANSLATIONS_LABEL)} label, add a [Lunaria directive](${LUNARIA_DIRECTIVE_DOCS}) to the commit body in the merge dialog. For example: ${wrapInCode(LUNARIA_DIRECTIVE)}.`; +} + +/** + * Lists the reviews of an umbrella PR. + * + * @param {Repository} repo - The docs repository. + * @param {number} umbrellaNumber - The number of the umbrella PR. + * @returns {Promise} The reviews in chronological order, + * with `\n` line endings in their bodies. + */ +async function listUmbrellaReviews( + repo: Repository, + umbrellaNumber: number +): Promise { + const reviews = await repo.getAll(`pulls/${umbrellaNumber}/reviews`); + return reviews.map((review) => ({ ...review, body: normalizeLineEndings(review.body) })); +} + +/** + * Lists the open PRs that target a branch. + * + * @param {Repository} repo - The docs repository. + * @param {string} branch - The base of the PRs, such as an umbrella branch. + * @returns {Promise} The numbers of the open PRs. + */ +async function listOpenPullRequestsTargeting(repo: Repository, branch: string): Promise { + const pullRequests = await repo.listOpenPullRequests({ base: branch }); + return pullRequests.map(({ number }) => number); +} + +/** + * Finds the bot's review that still requests changes, as a dismissal changes + * its state. + */ +function findActiveReview(reviews: SubmittedReview[]): SubmittedReview | undefined { + return reviews.findLast( + ({ user, state }) => user?.login === MERGE_BLOCK_REVIEWER_LOGIN && state === 'CHANGES_REQUESTED' + ); +} + +/** + * Keeps the merge-block review of an umbrella PR up to date. + * + * @param {Repository} repo - The docs repository, through `GITHUB_TOKEN`. + * @param {UmbrellaPullRequest} umbrella - The umbrella PR, as refreshed. + * @param {object} options - When to block the merge again. + * @param {boolean} options.shouldBlock - Whether to submit a review when none + * is active. + */ +export async function syncMergeBlockReview( + repo: Repository, + umbrella: UmbrellaPullRequest, + { shouldBlock }: { shouldBlock: boolean } +) { + const [reviews, openPullRequests] = await Promise.all([ + listUmbrellaReviews(repo, umbrella.number), + listOpenPullRequestsTargeting(repo, umbrella.branch), + ]); + const body = formatMergeBlockBody(umbrella, openPullRequests); + const active = findActiveReview(reviews); + if (active) { + if (active.body !== body) { + await repo.write('PUT', `pulls/${umbrella.number}/reviews/${active.id}`, { body }); + } + } else if (shouldBlock) { + await repo.write('POST', `pulls/${umbrella.number}/reviews`, { + event: 'REQUEST_CHANGES', + body, + }); + } +} diff --git a/scripts/lib/release-queue/umbrella/queries.test.ts b/scripts/lib/release-queue/umbrella/queries.test.ts new file mode 100644 index 0000000000000..29f7a5968e278 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/queries.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { REPO } from '../testing/fixtures.ts'; +import { stubGitHub } from '../testing/stub-github.ts'; +import { listIncludedPullRequests, type UmbrellaPullRequest } from './queries.ts'; + +const umbrella: UmbrellaPullRequest = { + number: 42, + title: '[ci] release astro@7.5.0 docs', + body: '', + labels: [], + base: 'main', + branch: 'release-queue/main', +}; + +describe('listIncludedPullRequests', () => { + it('reads the PR number of each squash commit, in commit order', async (t) => { + const messages = [ + 'Document the new option (#7)\n\nDetails.', + "Merge branch 'main' into release-queue/main", + 'Fix (a typo) in the guide (#3)', + 'Revert the option (#12) (#15)', + ]; + const github = stubGitHub(t, { + getResponses: { + [`repos/${REPO}/compare/main...release-queue/main`]: { + ahead_by: messages.length, + commits: messages.map((message) => ({ commit: { message } })), + }, + }, + }); + + assert.deepEqual(await listIncludedPullRequests(github.repository(), umbrella), [7, 3, 15]); + }); +}); diff --git a/scripts/lib/release-queue/umbrella/queries.ts b/scripts/lib/release-queue/umbrella/queries.ts new file mode 100644 index 0000000000000..feabf65827417 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/queries.ts @@ -0,0 +1,101 @@ +import { normalizeLineEndings } from '../formats/markdown.ts'; +import type { Repository } from '../github/repository.ts'; +import type { PullRequestResponse } from '../github/types.ts'; +import { getUmbrellaBase, isUmbrellaBranch } from './branch.ts'; + +/** The title GitHub gives a squash commit: the PR title, then its number. */ +const SQUASH_TITLE = / \(#(\d+)\)$/; + +export interface UmbrellaPullRequest { + number: number; + title: string; + body: string; + labels: string[]; + base: string; + branch: string; +} + +/** Whether a PR is an umbrella PR of the repository. */ +function isUmbrellaPullRequest({ base, head }: PullRequestResponse, repo: Repository): boolean { + return ( + isUmbrellaBranch(head.ref) && + head.repo?.full_name === repo.name && + getUmbrellaBase(head.ref) === base.ref + ); +} + +function toUmbrellaPullRequest(data: PullRequestResponse): UmbrellaPullRequest { + return { + number: data.number, + title: data.title, + body: normalizeLineEndings(data.body ?? ''), + labels: data.labels.map((label) => label.name), + base: data.base.ref, + branch: data.head.ref, + }; +} + +/** + * Gets an umbrella PR, open or not. + * + * @param {Repository} repo - The docs repository. + * @param {number} umbrellaNumber - The number of the umbrella PR. + * @returns {Promise} The umbrella PR. + */ +export async function getUmbrella( + repo: Repository, + umbrellaNumber: number +): Promise { + return toUmbrellaPullRequest(await repo.get(`pulls/${umbrellaNumber}`)); +} + +/** + * Finds the open umbrella PR of an umbrella branch. + * + * @param {Repository} repo - The docs repository. + * @param {string} branch - The umbrella branch. + * @returns {Promise} The open umbrella PR, or + * `undefined` when none is open. + */ +export async function findOpenUmbrella( + repo: Repository, + branch: string +): Promise { + const pullRequests = await repo.listOpenPullRequests({ head: branch }); + const umbrella = pullRequests.find((pr) => isUmbrellaPullRequest(pr, repo)); + return umbrella && toUmbrellaPullRequest(umbrella); +} + +/** + * Lists the open umbrella PRs. + * + * @param {Repository} repo - The docs repository. + * @returns {Promise} The open umbrella PRs. + */ +export async function listOpenUmbrellas(repo: Repository): Promise { + const pullRequests = await repo.listOpenPullRequests(); + return pullRequests.filter((pr) => isUmbrellaPullRequest(pr, repo)).map(toUmbrellaPullRequest); +} + +/** + * Lists the docs PRs merged into an umbrella branch. + * + * Reads the PR number at the end of each commit title, which a rebase of the + * branch keeps. A commit without one, such as a merge of the base, is skipped. + * + * @param {Repository} repo - The docs repository. + * @param {UmbrellaPullRequest} umbrella - The umbrella PR. + * @returns {Promise} The numbers of the included PRs, in the order + * of the commits. + */ +export async function listIncludedPullRequests( + repo: Repository, + { base, branch }: UmbrellaPullRequest +): Promise { + const { commits } = await repo.compareBranches(base, branch); + return commits.flatMap(({ commit }) => { + const [commitTitle = ''] = commit.message.split('\n', 1); + const match = commitTitle.match(SQUASH_TITLE); + return match ? [Number(match[1])] : []; + }); +} diff --git a/scripts/lib/release-queue/umbrella/refresh.test.ts b/scripts/lib/release-queue/umbrella/refresh.test.ts new file mode 100644 index 0000000000000..e8bb0ae5779f7 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/refresh.test.ts @@ -0,0 +1,268 @@ +import assert from 'node:assert/strict'; +import { describe, it, type TestContext } from 'node:test'; +import { FrameworkReleases } from '../release/queries.ts'; +import { getStableRelease, type StableRelease } from '../release/release.ts'; +import { emptyMergeBlockResponses, REPO, umbrellaCommitResponses } from '../testing/fixtures.ts'; +import { ACTIONS_TOKEN, stubRunContext } from '../testing/run-context.ts'; +import { readBody, stubGitHub, type StubOptions, type Write } from '../testing/stub-github.ts'; +import { formatUmbrellaBody } from './body.ts'; +import type { UmbrellaPullRequest } from './queries.ts'; +import { UmbrellaRefresher } from './refresh.ts'; + +/** The next release from `main`, with release PR `number` open. */ +const releaseWith = (number: number, frameworkVersion: string) => + getStableRelease({ number, versions: [{ name: 'astro', version: frameworkVersion }] }); + +const noRelease = getStableRelease(); + +const included = [7]; + +/** The `main` umbrella, up to date with release PR #18405 (`astro@7.5.0`). */ +const umbrella: UmbrellaPullRequest = { + number: 42, + title: '[ci] release astro@7.5.0 docs', + body: formatUmbrellaBody('main', { releasePrNumber: 18405, included: [] }), + labels: ['merge-on-release', 'minor-release', 'i18n'], + base: 'main', + branch: 'release-queue/main', +}; + +/** `next` in prerelease mode, with its release PR `number` open. */ +const prereleaseOf = (number: number) => ({ + 'repos/withastro/astro/contents/.changeset/pre.json?ref=next': { + content: Buffer.from('{"mode":"pre"}').toString('base64'), + }, + 'repos/withastro/astro/pulls?state=open&head=withastro:changeset-release/next': [ + { number, body: '' }, + ], +}); + +/** Refreshes an umbrella as a refresh run does: without blocking its merge. */ +function refresh( + github: ReturnType, + umbrellaToRefresh: UmbrellaPullRequest, + release: StableRelease +) { + const run = stubRunContext(github, 'all'); + const releases = new FrameworkReleases(run.framework, release); + return new UmbrellaRefresher(run, releases).refresh(umbrellaToRefresh, { shouldBlock: false }); +} + +/** Answers whether release PR `number` of `withastro/astro` is merged. */ +const releaseMerged = (number: number, merged: boolean) => ({ + [`repos/withastro/astro/pulls/${number}`]: { number, merged }, +}); + +function stub(t: TestContext, getResponses: StubOptions['getResponses'] = {}) { + return stubGitHub(t, { + getResponses: { + ...umbrellaCommitResponses(), + ...emptyMergeBlockResponses(42), + ...getResponses, + }, + }); +} + +const patchOf = (writes: Write[]) => + writes.find(({ method }) => method === 'PATCH')?.body as + | { title?: string; body?: string } + | undefined; +const labelWrites = (writes: Write[]) => writes.filter(({ method }) => method !== 'PATCH'); + +const umbrellaBody = (releasePrNumber?: number) => + formatUmbrellaBody('main', { releasePrNumber, included }); + +describe('UmbrellaRefresher.refresh', () => { + describe('with a release PR open', () => { + it('links the open release PR of an umbrella without a link', async (t) => { + const github = stub(t); + const unlinked = { ...umbrella, body: 'Release umbrella for `main`.' }; + + await refresh(github, unlinked, releaseWith(18405, '7.5.0')); + + assert.deepEqual(patchOf(github.writes), { body: umbrellaBody(18405) }); + }); + + it('refreshes an umbrella that links the open release PR', async (t) => { + const github = stub(t); + + await refresh(github, umbrella, releaseWith(18405, '7.5.0')); + + assert.deepEqual(github.writes, [ + { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, + ]); + assert.ok(!github.calls.some(({ path }) => path.startsWith('repos/withastro/astro'))); + }); + + it('moves to the open release PR when the linked one is not merged', async (t) => { + const github = stub(t, releaseMerged(18405, false)); + + await refresh(github, umbrella, releaseWith(18460, '7.6.0')); + + assert.deepEqual(patchOf(github.writes), { + title: '[ci] release astro@7.6.0 docs', + body: umbrellaBody(18460), + }); + }); + + it('keeps a settled umbrella as it is, but for the list', async (t) => { + const github = stub(t, releaseMerged(18405, true)); + + await refresh(github, umbrella, releaseWith(18460, '7.6.0')); + + assert.deepEqual(github.writes, [ + { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, + ]); + }); + + it('follows a release that became a minor', async (t) => { + const github = stub(t); + const patchUmbrella = { + ...umbrella, + title: '[ci] release astro@7.4.2 docs', + labels: ['merge-on-release', 'patch-release'], + }; + + await refresh(github, patchUmbrella, releaseWith(18405, '7.5.0')); + + assert.equal(patchOf(github.writes)?.title, '[ci] release astro@7.5.0 docs'); + assert.deepEqual(labelWrites(github.writes), [ + { + method: 'POST', + path: `repos/${REPO}/issues/42/labels`, + body: { labels: ['minor-release'] }, + }, + { method: 'DELETE', path: `repos/${REPO}/issues/42/labels/patch-release` }, + ]); + }); + + it('treats a link to a release PR that does not exist as not merged', async (t) => { + // No answer for #18405: GitHub says 404. + const github = stub(t); + + await refresh(github, umbrella, releaseWith(18460, '7.6.0')); + + assert.deepEqual(patchOf(github.writes), { + title: '[ci] release astro@7.6.0 docs', + body: umbrellaBody(18460), + }); + }); + + it('keeps a settled umbrella when the next release is a major', async (t) => { + const github = stub(t, releaseMerged(18405, true)); + + await refresh(github, umbrella, releaseWith(18460, '8.0.0')); + + assert.deepEqual(github.writes, [ + { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, + ]); + }); + + it('leaves the main umbrella as it is before a major', async (t) => { + const github = stub(t); + + await refresh(github, umbrella, releaseWith(18405, '8.0.0')); + + assert.deepEqual(github.writes, []); + }); + }); + + describe('without a release PR open', () => { + it('keeps the title, label and link, and rewrites the list', async (t) => { + const github = stub(t, releaseMerged(18405, true)); + + await refresh(github, umbrella, noRelease); + + assert.deepEqual(github.writes, [ + { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, + ]); + }); + + it('keeps an umbrella whose release PR was closed without merging', async (t) => { + const github = stub(t, releaseMerged(18405, false)); + + await refresh(github, umbrella, noRelease); + + assert.deepEqual(github.writes, [ + { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, + ]); + }); + + it('gives a new umbrella its labels and the fallback sentence', async (t) => { + const github = stub(t); + const newUmbrella = { + ...umbrella, + title: '[ci] release main docs', + body: 'Release umbrella for `main`, managed by the release queue.', + labels: [], + }; + + await refresh(github, newUmbrella, noRelease); + + assert.deepEqual(patchOf(github.writes), { body: umbrellaBody() }); + assert.deepEqual(labelWrites(github.writes), [ + { + method: 'POST', + path: `repos/${REPO}/issues/42/labels`, + body: { labels: ['merge-on-release', 'patch-release'] }, + }, + ]); + }); + }); + + it('refreshes a prerelease umbrella, which always follows', async (t) => { + const github = stubGitHub(t, { + getResponses: { + ...umbrellaCommitResponses('cloudflare-next'), + ...emptyMergeBlockResponses(43, 'cloudflare-next'), + ...prereleaseOf(18269), + }, + }); + const prerelease: UmbrellaPullRequest = { + number: 43, + title: 'Old title', + body: '', + labels: [], + base: 'cloudflare-next', + branch: 'release-queue/cloudflare-next', + }; + + await refresh(github, prerelease, releaseWith(18405, '7.5.0')); + + assert.deepEqual(patchOf(github.writes), { + title: '[ci] release cloudflare-next docs', + body: formatUmbrellaBody('cloudflare-next', { releasePrNumber: 18269, included }), + }); + assert.deepEqual(labelWrites(github.writes), [ + { + method: 'POST', + path: `repos/${REPO}/issues/43/labels`, + body: { labels: ['merge-on-release', 'major-release'] }, + }, + ]); + }); + + it('blocks the merge with a review of the refreshed umbrella', async (t) => { + const github = stub(t); + const unlinked = { ...umbrella, body: 'Release umbrella for `main`.' }; + + const run = stubRunContext(github, 'all'); + const releases = new FrameworkReleases(run.framework, releaseWith(18405, '7.5.0')); + + await new UmbrellaRefresher(run, releases).refresh(unlinked, { shouldBlock: true }); + + const [review] = github.writesBy(ACTIONS_TOKEN); + assert.equal(review?.path, `repos/${REPO}/pulls/42/reviews`); + // The link the refresh just added. + assert.match(readBody(review), /withastro\/astro#18405 is merged\./); + }); + + it("doesn't write an umbrella that is up to date", async (t) => { + const github = stub(t); + const upToDate = { ...umbrella, body: umbrellaBody(18405) }; + + await refresh(github, upToDate, releaseWith(18405, '7.5.0')); + + assert.deepEqual(github.writes, []); + }); +}); diff --git a/scripts/lib/release-queue/umbrella/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts new file mode 100644 index 0000000000000..052450f708b28 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -0,0 +1,138 @@ +import { DOCS_STABLE_BRANCH, RELEASE_TYPES, type ReleaseLabel } from '../config.ts'; +import type { FrameworkReleases } from '../release/queries.ts'; +import type { StableRelease } from '../release/release.ts'; +import type { RunContext } from '../run-context.ts'; +import { formatUmbrellaBody } from './body.ts'; +import { getUmbrellaReleaseLabel, getUmbrellaTitle } from './heading.ts'; +import { applyToLabels, getKeptLabelChanges, getLabelChanges } from './labels.ts'; +import { syncMergeBlockReview } from './merge-block.ts'; +import { listIncludedPullRequests, type UmbrellaPullRequest } from './queries.ts'; +import { getReleaseState } from './state.ts'; + +/** + * What an umbrella PR shows of its release: its title, its release label, and + * the release PR it links. Without a label, it keeps the one it has. + */ +interface ShownRelease { + title: string; + releaseLabel?: ReleaseLabel | undefined; + releasePrNumber?: number | undefined; +} + +/** Brings the umbrella PRs of a run up to date. */ +export class UmbrellaRefresher { + #run: RunContext; + #releases: FrameworkReleases; + + /** + * Prepares the refreshes of a run. + * + * @param {RunContext} run - The repositories of the run. + * @param {FrameworkReleases} releases - The releases of the framework. + */ + constructor(run: RunContext, releases: FrameworkReleases) { + this.#run = run; + this.#releases = releases; + } + + /** + * Brings an umbrella PR up to date with its release and its included PRs, + * then its merge-block review. + * + * @param {UmbrellaPullRequest} umbrella - The umbrella PR to refresh. + * @param {object} options - When to block its merge. + * @param {boolean} options.shouldBlock - Whether to submit a merge-block + * review when none is active. + */ + async refresh(umbrella: UmbrellaPullRequest, { shouldBlock }: { shouldBlock: boolean }) { + const refreshed = await this.#refreshTitleBodyAndLabels(umbrella); + await syncMergeBlockReview(this.#run.actions, refreshed, { shouldBlock }); + } + + /** + * Writes what changed in the title, body and labels of an umbrella PR, and + * returns the umbrella as it is after. + */ + async #refreshTitleBodyAndLabels(umbrella: UmbrellaPullRequest): Promise { + const releaseState = await getReleaseState(umbrella, this.#releases); + const isFollowing = releaseState.status === 'follows'; + if (isFollowing && isBeforeMajor(umbrella, this.#releases.stable)) { + console.info(`Skipped #${umbrella.number}: the next stable release is a major.`); + return umbrella; + } + + // An umbrella that doesn't follow keeps its title and release label. + const shown: ShownRelease = isFollowing + ? await this.#getFollowedRelease(umbrella) + : { title: umbrella.title, releasePrNumber: releaseState.releasePrNumber }; + const text = await this.#writeText(umbrella, shown); + const labels = await this.#writeLabels(umbrella, shown.releaseLabel); + return { ...umbrella, ...text, labels }; + } + + /** Gets what an umbrella that follows its release shows. */ + async #getFollowedRelease(umbrella: UmbrellaPullRequest): Promise { + const { stable } = this.#releases; + const releasePullRequest = + umbrella.base === DOCS_STABLE_BRANCH + ? stable.pullRequest + : await this.#releases.getPrereleasePullRequest(); + return { + title: getUmbrellaTitle(umbrella.base, stable), + releaseLabel: getUmbrellaReleaseLabel(umbrella.base, stable), + releasePrNumber: releasePullRequest?.number, + }; + } + + /** + * Writes the title and the body of an umbrella PR when they changed, and + * returns them. + */ + async #writeText( + umbrella: UmbrellaPullRequest, + { title, releasePrNumber }: ShownRelease + ): Promise<{ title: string; body: string }> { + const docs = this.#run.bot; + const included = await listIncludedPullRequests(docs, umbrella); + const text = { title, body: formatUmbrellaBody(umbrella.base, { releasePrNumber, included }) }; + const textChanges = getTextChanges(umbrella, text); + if (Object.keys(textChanges).length > 0) { + await docs.write('PATCH', `pulls/${umbrella.number}`, textChanges); + } + return text; + } + + /** + * Gives an umbrella PR its release label, or keeps its own without one, and + * returns its labels after. + */ + async #writeLabels( + umbrella: UmbrellaPullRequest, + releaseLabel: ReleaseLabel | undefined + ): Promise { + const labelChanges = releaseLabel + ? getLabelChanges(umbrella.labels, releaseLabel) + : getKeptLabelChanges(umbrella.labels); + await this.#run.bot.updateLabels(umbrella.number, labelChanges); + return applyToLabels(umbrella.labels, labelChanges); + } +} + +/** + * Whether the stable umbrella waits for a major: the bot leaves it to a + * maintainer, who moves it to the prerelease branch. + */ +function isBeforeMajor(umbrella: UmbrellaPullRequest, release: StableRelease): boolean { + return umbrella.base === DOCS_STABLE_BRANCH && release.type === RELEASE_TYPES.major; +} + +/** Gets the title and body of `wantedText` that differ from the umbrella's. */ +function getTextChanges( + umbrella: UmbrellaPullRequest, + wantedText: { title: string; body: string } +): { title?: string; body?: string } { + return { + ...(wantedText.title === umbrella.title ? {} : { title: wantedText.title }), + ...(wantedText.body === umbrella.body ? {} : { body: wantedText.body }), + }; +} diff --git a/scripts/lib/release-queue/umbrella/state.ts b/scripts/lib/release-queue/umbrella/state.ts new file mode 100644 index 0000000000000..689b8418ec10a --- /dev/null +++ b/scripts/lib/release-queue/umbrella/state.ts @@ -0,0 +1,87 @@ +import { DOCS_STABLE_BRANCH } from '../config.ts'; +import type { Repository } from '../github/repository.ts'; +import type { FrameworkReleases } from '../release/queries.ts'; +import { parseReleaseMarker } from './body.ts'; +import { STABLE_UMBRELLA_BRANCH } from './branch.ts'; +import { findOpenUmbrella, type UmbrellaPullRequest } from './queries.ts'; + +/** + * An open stable umbrella whose release PR is already merged. It stays settled + * until a maintainer merges it. + */ +export interface SettledUmbrella { + number: number; + /** The merged release PR of the framework repository. */ + releasePrNumber: number; +} + +/** + * How an umbrella relates to the release PRs of the framework repository: + * - `follows`: it takes its title, label and link from the open release PR. + * - `kept`: no release PR is open. It keeps what it has, and its link if any. + * - `settled`: the release PR it links is merged. It keeps everything until a + * maintainer merges it, even when the next release PR opens. + */ +type ReleaseState = + | { status: 'follows' } + | { status: 'kept'; releasePrNumber?: number | undefined } + | { status: 'settled'; releasePrNumber: number }; + +/** + * Gets how an umbrella relates to the release PRs. + * + * @param {UmbrellaPullRequest} umbrella - The umbrella PR, with the release PR + * its body links. + * @param {FrameworkReleases} releases - The releases of the framework. + * @returns {Promise} The release state. + */ +export async function getReleaseState( + umbrella: UmbrellaPullRequest, + releases: FrameworkReleases +): Promise { + // A prerelease umbrella always follows: its branch has continuous betas. + if (umbrella.base !== DOCS_STABLE_BRANCH) return { status: 'follows' }; + const linkedReleasePrNumber = parseReleaseMarker(umbrella.body); + const openReleasePrNumber = releases.stable.pullRequest?.number; + if ( + linkedReleasePrNumber !== undefined && + (await isLinkedReleaseMerged(releases, linkedReleasePrNumber)) + ) { + return { status: 'settled', releasePrNumber: linkedReleasePrNumber }; + } + return openReleasePrNumber === undefined + ? { status: 'kept', releasePrNumber: linkedReleasePrNumber } + : { status: 'follows' }; +} + +/** + * Whether the linked release PR is merged. A release PR closed without + * merging settles nothing, and the open one isn't merged yet. + */ +async function isLinkedReleaseMerged( + releases: FrameworkReleases, + linkedReleasePrNumber: number +): Promise { + if (linkedReleasePrNumber === releases.stable.pullRequest?.number) return false; + return releases.isPullRequestMerged(linkedReleasePrNumber); +} + +/** + * Gets the open stable umbrella if it's settled. + * + * @param {Repository} docs - The docs repository. + * @param {FrameworkReleases} releases - The releases of the framework. + * @returns {Promise} The settled umbrella, or + * `undefined` when none is open or it isn't settled. + */ +export async function getSettledStableUmbrella( + docs: Repository, + releases: FrameworkReleases +): Promise { + const umbrella = await findOpenUmbrella(docs, STABLE_UMBRELLA_BRANCH); + if (!umbrella) return undefined; + const state = await getReleaseState(umbrella, releases); + return state.status === 'settled' + ? { number: umbrella.number, releasePrNumber: state.releasePrNumber } + : undefined; +} diff --git a/scripts/release-queue.ts b/scripts/release-queue.ts new file mode 100644 index 0000000000000..49a39f37c89c7 --- /dev/null +++ b/scripts/release-queue.ts @@ -0,0 +1,22 @@ +// Routes docs PRs labeled as accepted to the umbrella PR of their release. +// Locally: `GITHUB_TOKEN=$(gh auth token) node --experimental-transform-types ./scripts/release-queue.ts --pr `. + +import { parseArgs } from 'node:util'; +import { runReleaseQueue } from './lib/release-queue/main.ts'; + +// The flags are for local runs, which only print what they would write. In +// GitHub Actions, the PR and the sender come from the event, and a +// `workflow_dispatch` refreshes every open umbrella PR. +const { values: flags } = parseArgs({ + options: { + // The docs PR to route. + pr: { type: 'string' }, + // Print what a refresh of every open umbrella PR would change, instead of + // routing `--pr`. + 'preview-refresh': { type: 'boolean', default: false }, + // Who added the label, for the write access check. + sender: { type: 'string' }, + }, +}); + +await runReleaseQueue({ flags, env: process.env });