From 8d34cb288b8886b02ba20931a27f399df35bf4c2 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Wed, 21 Oct 2026 17:18:10 +0200 Subject: [PATCH 01/35] Add release queue workflow in dry run mode --- .github/workflows/release-queue.yml | 52 ++++++ package.json | 3 +- scripts/lib/release-queue/github.ts | 83 ++++++++++ scripts/lib/release-queue/messages.test.ts | 92 +++++++++++ scripts/lib/release-queue/messages.ts | 96 +++++++++++ scripts/lib/release-queue/queries.ts | 171 ++++++++++++++++++++ scripts/lib/release-queue/readiness.test.ts | 117 ++++++++++++++ scripts/lib/release-queue/readiness.ts | 93 +++++++++++ scripts/lib/release-queue/release.test.ts | 80 +++++++++ scripts/lib/release-queue/release.ts | 46 ++++++ scripts/lib/release-queue/routing.test.ts | 158 ++++++++++++++++++ scripts/lib/release-queue/routing.ts | 117 ++++++++++++++ scripts/lib/release-queue/umbrella.test.ts | 20 +++ scripts/lib/release-queue/umbrella.ts | 10 ++ scripts/release-queue.ts | 79 +++++++++ 15 files changed, 1216 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-queue.yml create mode 100644 scripts/lib/release-queue/github.ts create mode 100644 scripts/lib/release-queue/messages.test.ts create mode 100644 scripts/lib/release-queue/messages.ts create mode 100644 scripts/lib/release-queue/queries.ts create mode 100644 scripts/lib/release-queue/readiness.test.ts create mode 100644 scripts/lib/release-queue/readiness.ts create mode 100644 scripts/lib/release-queue/release.test.ts create mode 100644 scripts/lib/release-queue/release.ts create mode 100644 scripts/lib/release-queue/routing.test.ts create mode 100644 scripts/lib/release-queue/routing.ts create mode 100644 scripts/lib/release-queue/umbrella.test.ts create mode 100644 scripts/lib/release-queue/umbrella.ts create mode 100644 scripts/release-queue.ts diff --git a/.github/workflows/release-queue.yml b/.github/workflows/release-queue.yml new file mode 100644 index 0000000000000..d612d4949da11 --- /dev/null +++ b/.github/workflows/release-queue.yml @@ -0,0 +1,52 @@ +# Routes docs PRs labeled `accepted` to the umbrella PR of their release. +# Dry run for now: only comments with the umbrella a PR would join, or why not. + +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] + +# 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.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 the PR. + 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: Node.js is enough, no `pnpm install`. + - 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 }} + MODE: dry-run + run: node --experimental-transform-types ./scripts/release-queue.ts diff --git a/package.json b/package.json index 7cfdc1e610e2e..a7d8160851cb8 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/github.ts b/scripts/lib/release-queue/github.ts new file mode 100644 index 0000000000000..6a47257067243 --- /dev/null +++ b/scripts/lib/release-queue/github.ts @@ -0,0 +1,83 @@ +const PAGE_SIZE = 100; + +export class GitHubError extends Error { + name = 'GitHubError'; + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +type WriteMethod = 'POST'; + +/** + * A minimal GitHub REST client. It can print the requests that change GitHub + * instead of sending them. + */ +export class GitHubClient { + #token: string; + #shouldPrintWrites: boolean; + + constructor(token: string, { shouldPrintWrites = false } = {}) { + this.#token = token; + this.#shouldPrintWrites = shouldPrintWrites; + } + + get(path: string): Promise { + return this.#request('GET', path); + } + + /** + * Gets every page of a list endpoint. + * + * @param pick Extracts the items when the endpoint wraps them in an object. + */ + 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; + } + } + + async write(method: WriteMethod, path: string, body?: unknown): Promise { + if (this.#shouldPrintWrites) { + console.info(`[print] ${method} ${path}`); + if (body) console.info(JSON.stringify(body, null, 2)); + return undefined; + } + return this.#request(method, path, body); + } + + async #request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`https://api.github.com/${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${this.#token}`, + 'User-Agent': 'astro-docs/1.0', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (res.status === 204) return undefined as T; + // Error pages from a proxy (e.g. a 502) are HTML. + const isJson = res.headers.get('content-type')?.includes('application/json'); + const data = isJson ? await res.json() : await res.text(); + if (!res.ok) { + const message = isJson ? data?.message : res.statusText; + throw new GitHubError(res.status, `${method} ${path} failed (${res.status}): ${message}`); + } + return data; + } +} diff --git a/scripts/lib/release-queue/messages.test.ts b/scripts/lib/release-queue/messages.test.ts new file mode 100644 index 0000000000000..5d7c0c9258341 --- /dev/null +++ b/scripts/lib/release-queue/messages.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { describeRefusal, formatDryRunComment } from './messages.ts'; +import type { Refusal, Route } from './routing.ts'; + +const route: Route = { + target: 'main', + umbrellaBranch: 'release-queue/main', + label: 'patch-release', + shouldRetarget: true, +}; + +describe('describeRefusal', () => { + 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-main', astroVersion: '8.0.0' }, + { 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' }, + ]; + 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('mentions the open main umbrella when the next astro release is a major', () => { + const refusal: Refusal = { code: 'major-on-main', astroVersion: '8.0.0' }; + assert.match(describeRefusal(refusal), /`astro@8.0.0`/); + assert.doesNotMatch(describeRefusal(refusal), /umbrella/); + assert.match(describeRefusal(refusal, { mainUmbrella: { number: 42 } }), /umbrella \(#42\)/); + }); +}); + +describe('formatDryRunComment', () => { + it('names the open umbrella a ready PR would join', () => { + const comment = formatDryRunComment({ route, refusals: [] }, { umbrella: { 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 = formatDryRunComment( + { route: { ...route, shouldRetarget: false }, refusals: [] }, + { umbrella: { 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/); + }); + + it('lists every reason a PR would be refused, and the umbrella it would join', () => { + const comment = formatDryRunComment( + { route, refusals: [{ code: 'draft' }, { code: 'not-approved' }] }, + { umbrella: { 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 = formatDryRunComment({ refusals: [{ code: 'no-release-label' }] }); + assert.match(comment, /wouldn't join its release umbrella yet:/); + }); + + it('throws when a PR without refusals has no umbrella', () => { + assert.throws(() => formatDryRunComment({ route, refusals: [] })); + }); +}); diff --git a/scripts/lib/release-queue/messages.ts b/scripts/lib/release-queue/messages.ts new file mode 100644 index 0000000000000..b75d3e9c06485 --- /dev/null +++ b/scripts/lib/release-queue/messages.ts @@ -0,0 +1,96 @@ +import { + ACCEPTED_LABEL, + type Decision, + type Refusal, + type RefusalCode, + type Route, +} from './routing.ts'; + +/** An open umbrella PR, or the title of the one to create. */ +export type Umbrella = { number: number } | { title: string }; + +export interface MessageContext { + /** The umbrella PR the docs PR would join. */ + umbrella?: Umbrella; + /** The open `main` umbrella PR, named when the next release is a major. */ + mainUmbrella?: { number: number }; +} + +const code = (text: string) => `\`${text}\``; +const list = (items: string[]) => items.map(code).join(', '); + +type DescribeRefusal = ( + refusal: Refusal, + context: MessageContext +) => string; + +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': () => + 'This PR has no release label. Add `patch-release` or `minor-release` for a PR against `main`, or `major-release` for a PR against a prerelease branch.', + 'several-release-labels': ({ labels }) => + `This PR has several release labels (${list(labels)}). Keep only one.`, + mismatch: ({ base, label }) => + label === 'major-release' + ? `\`major-release\` PRs target a prerelease branch, but this one targets ${code(base)}. Change the base to the prerelease branch, or the label to \`patch-release\` or \`minor-release\`.` + : `${code(label)} PRs target \`main\`, but this one targets ${code(base)}. Change the base to \`main\`, or the label to \`major-release\`.`, + 'major-on-main': ({ astroVersion }, { mainUmbrella }) => { + const release = astroVersion ? ` (${code(`astro@${astroVersion}`)})` : ''; + const umbrella = mainUmbrella + ? ` The open \`main\` umbrella (#${mainUmbrella.number}) needs to be squashed into the prerelease branch by a maintainer.` + : ''; + return `The next \`astro\` release from \`main\` is a major${release}: docs for it belong on the prerelease branch. Change the base to the prerelease branch and the label to \`major-release\`.${umbrella}`; + }, + '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: ${list(checks)}. Fix them, or re-run them if they failed for an unrelated reason.`, + 'pending-checks': ({ checks }) => + `These required checks haven't finished yet: ${list(checks)}. Wait for them to finish.`, + 'missing-checks': ({ checks }) => + `These required checks haven't run: ${list(checks)}. If the workflow runs are waiting for approval, approve them.`, + conflict: () => 'This PR has merge conflicts. Resolve them.', +}; + +/** Describes a refusal and how to fix it. */ +export function describeRefusal( + refusal: Refusal, + context: MessageContext = {} +): string { + return REFUSAL_DESCRIPTIONS[refusal.code](refusal, context); +} + +function describeUmbrella(route: Route, umbrella: Umbrella): string { + const name = + 'number' in umbrella ? `#${umbrella.number}` : `a new umbrella PR (${code(umbrella.title)})`; + return `${name} into ${code(route.target)}`; +} + +function describeRoute(route: Route, umbrella: Umbrella): string { + const base = route.shouldRetarget + ? ` Its base would change to ${code(route.umbrellaBranch)}.` + : ''; + return `This PR would be squash-merged into ${code(route.umbrellaBranch)}, the branch of ${describeUmbrella(route, umbrella)}.${base}`; +} + +/** Writes the comment of a dry run: what the bot would do, without doing it. */ +export function formatDryRunComment( + { route, refusals }: Decision, + context: MessageContext = {} +): string { + const heading = '**Release queue (dry run)**: nothing was changed.'; + const { umbrella } = context; + if (refusals.length === 0) { + if (!route || !umbrella) { + throw new Error('A PR without refusals needs a route and an umbrella.'); + } + return `${heading}\n\n${describeRoute(route, umbrella)}`; + } + + const reasons = refusals.map((refusal) => `- ${describeRefusal(refusal, context)}`).join('\n'); + const target = route && umbrella ? describeUmbrella(route, umbrella) : 'its release umbrella'; + return `${heading}\n\nThis PR wouldn't join ${target} yet:\n\n${reasons}\n\nOutside a dry run, the ${code(ACCEPTED_LABEL)} label would be removed. It would need to be added again after fixing.`; +} diff --git a/scripts/lib/release-queue/queries.ts b/scripts/lib/release-queue/queries.ts new file mode 100644 index 0000000000000..24a6549236529 --- /dev/null +++ b/scripts/lib/release-queue/queries.ts @@ -0,0 +1,171 @@ +import { setTimeout } from 'node:timers/promises'; +import { GitHubError, type GitHubClient } from './github.ts'; +import { + getLatestReviewStates, + summarizeChecks, + summarizeReviews, + type CheckRun, + type CommitStatus, + type Readiness, +} from './readiness.ts'; +import { parseReleaseVersions, type PackageVersion } from './release.ts'; +import type { DocsPullRequest } from './routing.ts'; + +const FRAMEWORK_REPO = 'withastro/astro'; +const MERGEABILITY_ATTEMPTS = 5; +const MERGEABILITY_DELAY_MS = 2000; + +export interface PullRequest extends DocsPullRequest { + number: number; + url: string; + headSha: string; + isMergeable: boolean | null; +} + +export interface ReleasePullRequest { + number: number; + url: string; + versions: PackageVersion[]; +} + +// The fields the bot reads from GitHub API responses. + +interface PullRequestResponse { + number: number; + html_url: string; + state: string; + draft: boolean; + mergeable: boolean | null; + base: { ref: string }; + head: { ref: string; sha: string }; + labels: { name: string }[]; + body: string | null; +} + +interface ReviewResponse { + /** `null` for a deleted account. */ + user: { login: string } | null; + state: string; +} + +interface RuleResponse { + type: string; + parameters?: { required_status_checks?: { context: string }[] }; +} + +export async function getPullRequest( + gh: GitHubClient, + repo: string, + number: number +): Promise { + const data = await getPullRequestWithMergeability(gh, repo, number); + return { + number: data.number, + 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. + */ +async function getPullRequestWithMergeability(gh: GitHubClient, repo: string, number: number) { + for (let attempt = 1; ; attempt++) { + const data = await gh.get(`repos/${repo}/pulls/${number}`); + const isKnown = data.state !== 'open' || data.mergeable !== null; + if (isKnown || attempt === MERGEABILITY_ATTEMPTS) return data; + await setTimeout(MERGEABILITY_DELAY_MS); + } +} + +/** Finds the open PR whose head is `branch`, such as an umbrella PR. */ +export async function findOpenPullRequest(gh: GitHubClient, repo: string, branch: string) { + const [owner] = repo.split('/'); + const [pr] = await gh.get( + `repos/${repo}/pulls?state=open&head=${owner}:${branch}` + ); + return pr ? { number: pr.number, url: pr.html_url } : undefined; +} + +export async function canWrite(gh: GitHubClient, repo: string, user: string): Promise { + try { + const { permission } = await gh.get<{ permission: string }>( + `repos/${repo}/collaborators/${user}/permission` + ); + // The legacy `permission` field maps `maintain` to `write`, + // and `triage` to `read`. + return permission === 'admin' || permission === 'write'; + } catch (error) { + if (error instanceof GitHubError && error.status === 404) return false; + throw error; + } +} + +/** + * Gets what decides whether a PR is ready to merge, with the rules of the + * `main` ruleset: only reviewers with write access count, and only the checks + * required on `main`. + */ +export async function getReadiness( + gh: GitHubClient, + repo: string, + pr: PullRequest +): Promise { + const [reviews, checkRuns, combinedStatus, required] = await Promise.all([ + gh.getAll(`repos/${repo}/pulls/${pr.number}/reviews`), + gh.getAll( + `repos/${repo}/commits/${pr.headSha}/check-runs`, + (data) => data.check_runs + ), + gh.get<{ statuses: CommitStatus[] }>(`repos/${repo}/commits/${pr.headSha}/status`), + getRequiredChecks(gh, repo), + ]); + + const states = getLatestReviewStates( + reviews.flatMap(({ user, state }) => (user ? [{ user: user.login, state }] : [])) + ); + const writers = new Set(); + await Promise.all( + [...states.keys()].map(async (user) => { + if (await canWrite(gh, repo, user)) writers.add(user); + }) + ); + + const checks = summarizeChecks(required, checkRuns, combinedStatus.statuses); + + return { + ...summarizeReviews(states, writers), + failingChecks: checks.failing, + pendingChecks: checks.pending, + missingChecks: checks.missing, + isMergeable: pr.isMergeable, + }; +} + +/** Gets the checks required to merge into `main`. */ +async function getRequiredChecks(gh: GitHubClient, repo: string): Promise { + const rules = await gh.getAll(`repos/${repo}/rules/branches/main`); + return rules + .filter((rule) => rule.type === 'required_status_checks') + .flatMap((rule) => rule.parameters?.required_status_checks ?? []) + .map((check) => check.context); +} + +/** Gets the open `[ci] release` PR of `main` in `withastro/astro`. */ +export async function getReleasePullRequest( + gh: GitHubClient +): Promise { + const [owner] = FRAMEWORK_REPO.split('/'); + const [pr] = await gh.get( + `repos/${FRAMEWORK_REPO}/pulls?state=open&head=${owner}:changeset-release/main` + ); + if (!pr) return undefined; + return { number: pr.number, url: pr.html_url, versions: parseReleaseVersions(pr.body ?? '') }; +} diff --git a/scripts/lib/release-queue/readiness.test.ts b/scripts/lib/release-queue/readiness.test.ts new file mode 100644 index 0000000000000..c8bf7db6c5977 --- /dev/null +++ b/scripts/lib/release-queue/readiness.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getLatestReviewStates, summarizeChecks, summarizeReviews } 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('summarizeReviews', () => { + it('counts the approval of a reviewer with write access', () => { + const states = new Map([['alice', 'APPROVED' as const]]); + assert.deepEqual(summarizeReviews(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(summarizeReviews(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(summarizeReviews(states, new Set(['alice', 'carol'])), { + isApproved: true, + changesRequestedBy: ['alice'], + }); + }); +}); + +describe('summarizeChecks', () => { + const required = ['eslint', 'lint-ja', 'links', 'check', 'deploy', 'external', 'zizmor']; + + it('lists unfinished and unsuccessful required check runs and statuses', () => { + const summary = summarizeChecks( + required, + [ + { name: 'eslint', status: 'completed', conclusion: 'success' }, + { name: 'lint-ja', status: 'completed', conclusion: 'skipped' }, + { name: 'links', status: 'completed', conclusion: 'failure' }, + { name: 'check', status: 'in_progress', conclusion: null }, + { name: 'zizmor', status: 'completed', conclusion: 'neutral' }, + ], + [ + { context: 'deploy', state: 'pending' }, + { context: 'external', state: 'error' }, + ] + ); + assert.deepEqual(summary, { + failing: ['links', 'external'], + pending: ['check', 'deploy'], + missing: [], + }); + }); + + it('ignores checks that are not required', () => { + const summary = summarizeChecks( + ['eslint'], + [ + { name: 'eslint', status: 'completed', conclusion: 'success' }, + { name: 'triage', status: 'in_progress', conclusion: null }, + { name: 'Generate Lunaria Overview', status: 'completed', conclusion: 'failure' }, + ], + [] + ); + assert.deepEqual(summary, { failing: [], pending: [], missing: [] }); + }); + + it('lists required checks that are not reported', () => { + assert.deepEqual(summarizeChecks(['eslint'], [], []), { + failing: [], + pending: [], + missing: ['eslint'], + }); + }); + + it('accepts a commit when no check is required', () => { + assert.deepEqual(summarizeChecks([], [], []), { failing: [], pending: [], missing: [] }); + }); +}); diff --git a/scripts/lib/release-queue/readiness.ts b/scripts/lib/release-queue/readiness.ts new file mode 100644 index 0000000000000..07b36a1e39c0d --- /dev/null +++ b/scripts/lib/release-queue/readiness.ts @@ -0,0 +1,93 @@ +export interface Review { + user: string; + state: string; +} + +export interface CheckRun { + name: string; + status: string; + conclusion: string | null; +} + +export interface CommitStatus { + context: string; + state: string; +} + +export type ReviewState = 'APPROVED' | 'CHANGES_REQUESTED'; + +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[]; + /** `null` while GitHub hasn't computed it yet. */ + isMergeable: boolean | null; +} + +/** + * Gets the latest review state of each reviewer. Comments don't replace an + * earlier state. A dismissed review clears it: an earlier review from the same + * reviewer doesn't count again. + * + * @param reviews Reviews in chronological order, as returned by the GitHub API. + */ +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; +} + +/** + * Summarizes the latest review states the way the `main` ruleset counts them: + * only reviewers with write access approve or block a PR. + */ +export function summarizeReviews(states: Map, writers: Set) { + const reviewersWith = (state: ReviewState) => + [...states].filter(([user, s]) => s === 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']); + +/** + * 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. + */ +export function summarizeChecks( + required: string[], + checkRuns: CheckRun[], + statuses: CommitStatus[] +) { + const failing = new Set(); + const pending = new Set(); + const reported = new Set(); + + for (const run of checkRuns) { + reported.add(run.name); + if (run.status !== 'completed') pending.add(run.name); + else if (!SUCCESSFUL_CONCLUSIONS.has(run.conclusion ?? '')) failing.add(run.name); + } + for (const status of statuses) { + reported.add(status.context); + if (status.state === 'pending') pending.add(status.context); + else if (status.state !== 'success') failing.add(status.context); + } + + return { + failing: required.filter((check) => failing.has(check)), + pending: required.filter((check) => pending.has(check)), + missing: required.filter((check) => !reported.has(check)), + }; +} diff --git a/scripts/lib/release-queue/release.test.ts b/scripts/lib/release-queue/release.test.ts new file mode 100644 index 0000000000000..ac2615e015677 --- /dev/null +++ b/scripts/lib/release-queue/release.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { findVersion, getMainRelease, getReleaseType, 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('findVersion', () => { + it('finds a package by its exact name', () => { + const versions = parseReleaseVersions(body); + assert.equal(findVersion(versions, 'astro'), '7.4.1'); + assert.equal(findVersion(versions, '@astrojs/node'), undefined); + }); +}); + +describe('getReleaseType', () => { + 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'], + [undefined, 'patch'], + ] as const) { + it(`reads ${version} as a ${type} release`, () => { + assert.equal(getReleaseType(version), type); + }); + } +}); + +describe('getMainRelease', () => { + it('reads a patch when there is no release PR', () => { + assert.deepEqual(getMainRelease(), { type: 'patch', astroVersion: undefined }); + }); + + it('reads a patch when astro is not released', () => { + assert.deepEqual(getMainRelease([{ name: '@astrojs/vercel', version: '11.1.0' }]), { + type: 'patch', + astroVersion: undefined, + }); + }); + + it('reads the release type from the astro version', () => { + assert.deepEqual(getMainRelease([{ name: 'astro', version: '7.5.0' }]), { + type: 'minor', + astroVersion: '7.5.0', + }); + assert.deepEqual(getMainRelease([{ name: 'astro', version: '8.0.0' }]), { + type: 'major', + astroVersion: '8.0.0', + }); + }); +}); diff --git a/scripts/lib/release-queue/release.ts b/scripts/lib/release-queue/release.ts new file mode 100644 index 0000000000000..cf187ad94b741 --- /dev/null +++ b/scripts/lib/release-queue/release.ts @@ -0,0 +1,46 @@ +export interface PackageVersion { + name: string; + version: string; +} + +export type ReleaseType = 'patch' | 'minor' | 'major'; + +/** The next release from the `main` branch of `withastro/astro`. */ +export interface MainRelease { + type: ReleaseType; + astroVersion?: string; +} + +/** Reads the `## @` headings of a release PR body. */ +export function parseReleaseVersions(body: string): PackageVersion[] { + return Array.from(body.matchAll(/^## (\S+)@(\d+\.\d+\.\d+\S*)\s*$/gm), ([, name, version]) => ({ + name, + version, + })); +} + +export 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: only a new stable minor or major changes how docs + * PRs are routed. + */ +export function getReleaseType(version: string | undefined): ReleaseType { + const match = version?.match(/^\d+\.(\d+)\.(\d+)$/); + if (!match) return 'patch'; + const [, minor, patch] = match; + if (patch !== '0') return 'patch'; + return minor === '0' ? 'major' : 'minor'; +} + +/** + * Gets the next release from `main` from the versions of its release PR, or + * from none when there is no open release PR. + */ +export function getMainRelease(versions: PackageVersion[] = []): MainRelease { + const astroVersion = findVersion(versions, 'astro'); + return { type: getReleaseType(astroVersion), astroVersion }; +} diff --git a/scripts/lib/release-queue/routing.test.ts b/scripts/lib/release-queue/routing.test.ts new file mode 100644 index 0000000000000..c1269e096ab4b --- /dev/null +++ b/scripts/lib/release-queue/routing.test.ts @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { Readiness } from './readiness.ts'; +import type { MainRelease } from './release.ts'; +import { decide, type DocsPullRequest } from './routing.ts'; + +const pr = (overrides: Partial = {}): DocsPullRequest => ({ + state: 'open', + isDraft: false, + base: 'main', + head: 'feat/docs', + labels: ['accepted', 'patch-release'], + ...overrides, +}); + +const ready: Readiness = { + isApproved: true, + changesRequestedBy: [], + failingChecks: [], + pendingChecks: [], + missingChecks: [], + isMergeable: true, +}; + +const patch: MainRelease = { type: 'patch', astroVersion: '7.4.1' }; + +describe('decide', () => { + describe('routes a ready docs PR', () => { + for (const label of ['patch-release', 'minor-release']) { + it(`to the main umbrella with ${label}`, () => { + assert.deepEqual(decide(pr({ labels: [label] }), ready, patch), { + route: { + target: 'main', + umbrellaBranch: 'release-queue/main', + label, + shouldRetarget: true, + }, + refusals: [], + }); + }); + } + + it('to a prerelease umbrella with major-release', () => { + const decision = decide(pr({ base: 'cloudflare-next', labels: ['major-release'] }), ready, { + type: 'major', + astroVersion: '8.0.0', + }); + assert.deepEqual(decision, { + route: { + target: 'cloudflare-next', + umbrellaBranch: 'release-queue/cloudflare-next', + label: 'major-release', + shouldRetarget: true, + }, + refusals: [], + }); + }); + + it('to the umbrella it already targets, without retargeting', () => { + const decision = decide(pr({ base: 'release-queue/main' }), ready, patch); + assert.equal(decision.route?.umbrellaBranch, 'release-queue/main'); + assert.equal(decision.route?.shouldRetarget, false); + assert.deepEqual(decision.refusals, []); + }); + }); + + describe('refuses', () => { + it('a closed PR, and nothing else', () => { + const decision = decide(pr({ state: 'closed', isDraft: true }), ready, patch); + assert.deepEqual(decision, { refusals: [{ code: 'closed' }] }); + }); + + it('an umbrella PR, and nothing else', () => { + const decision = decide(pr({ head: 'release-queue/main' }), ready, patch); + assert.deepEqual(decision, { refusals: [{ code: 'umbrella' }] }); + }); + + it('a draft PR', () => { + assert.deepEqual(decide(pr({ isDraft: true }), ready, patch).refusals, [{ code: 'draft' }]); + }); + + it('a PR without a release label', () => { + const decision = decide(pr({ labels: ['accepted'] }), ready, patch); + assert.deepEqual(decision, { refusals: [{ code: 'no-release-label' }] }); + }); + + it('a PR with several release labels', () => { + const decision = decide(pr({ labels: ['minor-release', 'patch-release'] }), ready, patch); + assert.deepEqual(decision.refusals, [ + { code: 'several-release-labels', labels: ['patch-release', 'minor-release'] }, + ]); + }); + + it('a major-release PR against main', () => { + const decision = decide(pr({ labels: ['major-release'] }), ready, patch); + assert.deepEqual(decision.refusals, [ + { code: 'mismatch', base: 'main', label: 'major-release' }, + ]); + }); + + it('a minor-release PR against a prerelease branch', () => { + const decision = decide(pr({ base: 'v8', labels: ['minor-release'] }), ready, patch); + assert.deepEqual(decision.refusals, [ + { code: 'mismatch', base: 'v8', label: 'minor-release' }, + ]); + }); + + it('a major-release PR against the main umbrella branch', () => { + const decision = decide( + pr({ base: 'release-queue/main', labels: ['major-release'] }), + ready, + patch + ); + assert.deepEqual(decision.refusals, [ + { code: 'mismatch', base: 'release-queue/main', label: 'major-release' }, + ]); + }); + + it('a main PR when the next astro release from main is a major', () => { + const decision = decide(pr(), ready, { type: 'major', astroVersion: '8.0.0' }); + assert.deepEqual(decision.refusals, [{ code: 'major-on-main', astroVersion: '8.0.0' }]); + }); + + it('a PR that is not ready, with every reason', () => { + const decision = decide( + pr(), + { + isApproved: false, + changesRequestedBy: ['alice'], + failingChecks: ['links'], + pendingChecks: ['check'], + missingChecks: ['zizmor'], + isMergeable: false, + }, + patch + ); + assert.deepEqual(decision.refusals, [ + { 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('a PR with both a routing and a readiness problem, with both reasons', () => { + const decision = decide(pr({ labels: [] }), { ...ready, isApproved: false }, patch); + assert.deepEqual(decision, { + refusals: [{ code: 'no-release-label' }, { code: 'not-approved' }], + }); + }); + }); + + it('lets the merge decide while mergeability is unknown', () => { + assert.deepEqual(decide(pr(), { ...ready, isMergeable: null }, patch).refusals, []); + }); +}); diff --git a/scripts/lib/release-queue/routing.ts b/scripts/lib/release-queue/routing.ts new file mode 100644 index 0000000000000..8c9d6bf8a647a --- /dev/null +++ b/scripts/lib/release-queue/routing.ts @@ -0,0 +1,117 @@ +import type { Readiness } from './readiness.ts'; +import type { MainRelease } from './release.ts'; + +export const ACCEPTED_LABEL = 'accepted'; +export const UMBRELLA_PREFIX = 'release-queue/'; +export const RELEASE_LABELS = ['patch-release', 'minor-release', 'major-release'] as const; + +export type ReleaseLabel = (typeof RELEASE_LABELS)[number]; + +export interface DocsPullRequest { + state: string; + isDraft: boolean; + base: string; + head: string; + labels: string[]; +} + +export interface Route { + /** The branch the umbrella PR merges into. */ + target: string; + umbrellaBranch: string; + label: ReleaseLabel; + /** Whether the base of the docs PR must change to the umbrella branch. */ + shouldRetarget: boolean; +} + +/** The details of each refusal, by code. */ +interface RefusalDetails { + closed: unknown; + umbrella: unknown; + draft: unknown; + 'no-release-label': unknown; + 'several-release-labels': { labels: ReleaseLabel[] }; + mismatch: { base: string; label: ReleaseLabel }; + 'major-on-main': { astroVersion?: string | undefined }; + 'not-approved': unknown; + 'changes-requested': { reviewers: string[] }; + 'failing-checks': { checks: string[] }; + 'pending-checks': { checks: string[] }; + 'missing-checks': { checks: string[] }; + conflict: unknown; +} + +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 docs PR joins `route` when there are no refusals. */ +export interface Decision { + route?: Route; + refusals: Refusal[]; +} + +/** + * Decides whether a docs PR joins an umbrella, and which one. Returns every + * refusal at once, except for a closed PR or an umbrella PR. Returns the route + * even with readiness refusals: comments use it to name the umbrella. + */ +export function decide(pr: DocsPullRequest, readiness: Readiness, release: MainRelease): Decision { + if (pr.state !== 'open') return { refusals: [{ code: 'closed' }] }; + if (pr.head.startsWith(UMBRELLA_PREFIX)) return { refusals: [{ code: 'umbrella' }] }; + + const refusals: Refusal[] = []; + if (pr.isDraft) refusals.push({ code: 'draft' }); + + const route = getRoute(pr, release); + if ('code' in route) refusals.push(route); + + refusals.push(...getReadinessRefusals(readiness)); + + return 'code' in route ? { refusals } : { route, refusals }; +} + +function getRoute(pr: DocsPullRequest, release: MainRelease): Route | Refusal { + const labels = RELEASE_LABELS.filter((label) => pr.labels.includes(label)); + if (labels.length === 0) return { code: 'no-release-label' }; + if (labels.length > 1) return { code: 'several-release-labels', labels }; + const [label] = labels; + + // A PR against an umbrella branch, to fix something already merged in it. + const shouldRetarget = !pr.base.startsWith(UMBRELLA_PREFIX); + const target = shouldRetarget ? pr.base : pr.base.slice(UMBRELLA_PREFIX.length); + + // Nothing marks a prerelease branch: any base other than `main` is one. + const isMajor = label === 'major-release'; + const isMainTarget = target === 'main'; + if (isMajor === isMainTarget) return { code: 'mismatch', base: pr.base, label }; + // Every feature on `main` in `withastro/astro` now ships in the major, + // documented on a prerelease branch. + if (isMainTarget && release.type === 'major') { + return { code: 'major-on-main', astroVersion: release.astroVersion }; + } + + return { target, umbrellaBranch: UMBRELLA_PREFIX + target, 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 }); + } + if (readiness.isMergeable === false) refusals.push({ code: 'conflict' }); + return refusals; +} diff --git a/scripts/lib/release-queue/umbrella.test.ts b/scripts/lib/release-queue/umbrella.test.ts new file mode 100644 index 0000000000000..df1e42bb7b79f --- /dev/null +++ b/scripts/lib/release-queue/umbrella.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getUmbrellaTitle } from './umbrella.ts'; + +describe('getUmbrellaTitle', () => { + it('uses the astro version for the main umbrella', () => { + assert.equal(getUmbrellaTitle('main', '7.4.1'), '[ci] release astro@7.4.1 docs'); + }); + + it('falls back to the branch name when astro is not released', () => { + assert.equal(getUmbrellaTitle('main'), '[ci] release main docs'); + }); + + it('uses the branch name for a prerelease umbrella', () => { + assert.equal( + getUmbrellaTitle('cloudflare-next', '7.4.0-beta.1'), + '[ci] release cloudflare-next docs' + ); + }); +}); diff --git a/scripts/lib/release-queue/umbrella.ts b/scripts/lib/release-queue/umbrella.ts new file mode 100644 index 0000000000000..863ad5ab58325 --- /dev/null +++ b/scripts/lib/release-queue/umbrella.ts @@ -0,0 +1,10 @@ +/** + * Gets the title of the umbrella PR merging into `target`, after the + * `[ci] release` PRs of `withastro/astro`. The `main` umbrella uses the + * `astro` version when there is one, as merged PRs are posted to Discord. + * A prerelease umbrella uses its branch name, which says what it is for. + */ +export function getUmbrellaTitle(target: string, astroVersion?: string): string { + const release = target === 'main' && astroVersion ? `astro@${astroVersion}` : target; + return `[ci] release ${release} docs`; +} diff --git a/scripts/release-queue.ts b/scripts/release-queue.ts new file mode 100644 index 0000000000000..45bd0497ccbd2 --- /dev/null +++ b/scripts/release-queue.ts @@ -0,0 +1,79 @@ +// Routes docs PRs labeled `accepted` to the umbrella PR of their release. +// Locally: `GITHUB_TOKEN=$(gh auth token) node --experimental-transform-types ./scripts/release-queue.ts --pr `. + +import { readFile } from 'node:fs/promises'; +import { parseArgs } from 'node:util'; +import { GitHubClient } from './lib/release-queue/github.ts'; +import { formatDryRunComment, type MessageContext } from './lib/release-queue/messages.ts'; +import { + canWrite, + findOpenPullRequest, + getPullRequest, + getReadiness, + getReleasePullRequest, +} from './lib/release-queue/queries.ts'; +import { getMainRelease } from './lib/release-queue/release.ts'; +import { decide, UMBRELLA_PREFIX } from './lib/release-queue/routing.ts'; +import { getUmbrellaTitle } from './lib/release-queue/umbrella.ts'; + +// `dry-run`: only comments with the umbrella a PR would join, or why not. +const MODES = ['dry-run']; + +const { values } = parseArgs({ + options: { + pr: { type: 'string' }, + sender: { type: 'string' }, + write: { type: 'boolean', default: false }, + }, +}); + +const repo = process.env.GITHUB_REPOSITORY ?? 'withastro/docs'; +const mode = process.env.MODE ?? 'dry-run'; +if (!MODES.includes(mode)) throw new Error(`Unknown MODE: ${mode}. Expected one of: ${MODES}.`); + +const token = process.env.GITHUB_TOKEN; +if (!token) throw new Error('GITHUB_TOKEN is required.'); +// Outside GitHub Actions, only print the writes, unless `--write` is passed. +const shouldWrite = process.env.GITHUB_ACTIONS === 'true' || values.write; +const gh = new GitHubClient(token, { shouldPrintWrites: !shouldWrite }); + +const event = process.env.GITHUB_EVENT_PATH + ? JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, 'utf-8')) + : undefined; +const prNumber = Number(values.pr ?? event?.pull_request?.number); +if (!prNumber) throw new Error('No PR to process: pass --pr .'); + +await processPullRequest(prNumber, values.sender ?? event?.sender?.login); + +async function processPullRequest(number: number, sender: string | undefined) { + if (sender === undefined) { + console.info('No sender: skipping the write access check.'); + } else if (!(await canWrite(gh, repo, sender))) { + console.info(`Ignored: ${sender} can't write to ${repo}.`); + return; + } + + const pr = await getPullRequest(gh, repo, number); + const [readiness, releasePullRequest] = await Promise.all([ + getReadiness(gh, repo, pr), + getReleasePullRequest(gh), + ]); + const mainRelease = getMainRelease(releasePullRequest?.versions); + const decision = decide(pr, readiness, mainRelease); + console.info(JSON.stringify({ pr: pr.url, readiness, mainRelease, decision }, null, 2)); + + const context: MessageContext = {}; + if (decision.route) { + const { umbrellaBranch, target } = decision.route; + context.umbrella = (await findOpenPullRequest(gh, repo, umbrellaBranch)) ?? { + title: getUmbrellaTitle(target, mainRelease.astroVersion), + }; + } + if (decision.refusals.some((refusal) => refusal.code === 'major-on-main')) { + context.mainUmbrella = await findOpenPullRequest(gh, repo, `${UMBRELLA_PREFIX}main`); + } + + await gh.write('POST', `repos/${repo}/issues/${number}/comments`, { + body: formatDryRunComment(decision, context), + }); +} From 4506d93cd3ac7baa7fccf32fbbbb4107d0867508 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Wed, 21 Oct 2026 21:34:54 +0200 Subject: [PATCH 02/35] Join accepted PRs to their release umbrella --- .github/workflows/release-queue.yml | 4 + scripts/lib/release-queue/github.test.ts | 147 ++++++++ scripts/lib/release-queue/github.ts | 59 ++- .../lib/release-queue/handle-accepted.test.ts | 249 +++++++++++++ scripts/lib/release-queue/handle-accepted.ts | 203 +++++++++++ scripts/lib/release-queue/join.test.ts | 338 ++++++++++++++++++ scripts/lib/release-queue/join.ts | 191 ++++++++++ scripts/lib/release-queue/messages.test.ts | 109 +++++- scripts/lib/release-queue/messages.ts | 78 +++- scripts/lib/release-queue/queries.test.ts | 44 +++ scripts/lib/release-queue/queries.ts | 64 +++- scripts/lib/release-queue/routing.test.ts | 64 +++- scripts/lib/release-queue/routing.ts | 47 ++- scripts/lib/release-queue/testing/fixtures.ts | 19 + .../lib/release-queue/testing/stub-github.ts | 109 ++++++ scripts/lib/release-queue/umbrella.test.ts | 13 +- scripts/lib/release-queue/umbrella.ts | 24 +- scripts/release-queue.ts | 77 ++-- 18 files changed, 1709 insertions(+), 130 deletions(-) create mode 100644 scripts/lib/release-queue/github.test.ts create mode 100644 scripts/lib/release-queue/handle-accepted.test.ts create mode 100644 scripts/lib/release-queue/handle-accepted.ts create mode 100644 scripts/lib/release-queue/join.test.ts create mode 100644 scripts/lib/release-queue/join.ts create mode 100644 scripts/lib/release-queue/queries.test.ts create mode 100644 scripts/lib/release-queue/testing/fixtures.ts create mode 100644 scripts/lib/release-queue/testing/stub-github.ts diff --git a/.github/workflows/release-queue.yml b/.github/workflows/release-queue.yml index d612d4949da11..f89073c3721e6 100644 --- a/.github/workflows/release-queue.yml +++ b/.github/workflows/release-queue.yml @@ -48,5 +48,9 @@ jobs: - name: Route the PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Writes as the bot account outside a dry run: its merges trigger CI + # on the umbrella PR. Also passed in dry run: going live only changes + # MODE. + BOT_TOKEN: ${{ secrets.FREDKBOT_GITHUB_TOKEN }} MODE: dry-run run: node --experimental-transform-types ./scripts/release-queue.ts diff --git a/scripts/lib/release-queue/github.test.ts b/scripts/lib/release-queue/github.test.ts new file mode 100644 index 0000000000000..43a199ebf0d33 --- /dev/null +++ b/scripts/lib/release-queue/github.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { describe, it, type TestContext } from 'node:test'; +import { GitHubClient, GitHubError } from './github.ts'; +import { json, stubGitHub } from './testing/stub-github.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((init?.headers as Record).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.reason, 'Validation Failed: No commits between main and x'); + return true; + } + ); + }); + + 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'), { + reason: '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, + reason: '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'), { + reason: 'Bad Gateway', + }); + }); + + it('prints writes without sending them when asked to', async (t) => { + const fetch = answer(t, json({})); + t.mock.method(console, 'info', () => {}); + + const data = await new GitHubClient('token', { shouldPrintWrites: true }).write( + 'POST', + 'repos/withastro/docs/issues/7/comments', + { body: 'Hello' } + ); + + assert.equal(data, undefined); + assert.equal(fetch.mock.callCount(), 0); + }); + + 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); + }); + }); +}); diff --git a/scripts/lib/release-queue/github.ts b/scripts/lib/release-queue/github.ts index 6a47257067243..1f512d31b3652 100644 --- a/scripts/lib/release-queue/github.ts +++ b/scripts/lib/release-queue/github.ts @@ -1,16 +1,28 @@ +import { setTimeout } from 'node:timers/promises'; + const PAGE_SIZE = 100; +const POLL_DELAY_MS = 2000; export class GitHubError extends Error { name = 'GitHubError'; readonly status: number; + /** The reason GitHub gives, without the request. */ + readonly reason: string; - constructor(status: number, message: string) { - super(message); + constructor({ status, reason, request }: { status: number; reason: string; request: string }) { + super(`${request} failed (${status}): ${reason}`); this.status = status; + this.reason = reason; } } -type WriteMethod = 'POST'; +type WriteMethod = 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + +/** An error body from GitHub. A validation error details it in `errors`. */ +interface ErrorBody { + message?: string; + errors?: (string | { message?: string })[]; +} /** * A minimal GitHub REST client. It can print the requests that change GitHub @@ -19,16 +31,33 @@ type WriteMethod = 'POST'; export class GitHubClient { #token: string; #shouldPrintWrites: boolean; + #pollDelayMs: number; - constructor(token: string, { shouldPrintWrites = false } = {}) { + constructor(token: string, { shouldPrintWrites = false, pollDelayMs = POLL_DELAY_MS } = {}) { this.#token = token; this.#shouldPrintWrites = shouldPrintWrites; + this.#pollDelayMs = pollDelayMs; } get(path: string): Promise { return this.#request('GET', path); } + /** + * Gets `path` until `isReady` accepts the answer, or the attempts run out, + * for data GitHub computes in the background. Returns the last answer. + */ + 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. * @@ -49,6 +78,10 @@ export class GitHubClient { } } + /** + * Sends a request that changes GitHub. Resolves to `undefined` when the + * request was only printed, or when GitHub answers without a body (204). + */ async write(method: WriteMethod, path: string, body?: unknown): Promise { if (this.#shouldPrintWrites) { console.info(`[print] ${method} ${path}`); @@ -75,9 +108,23 @@ export class GitHubClient { const isJson = res.headers.get('content-type')?.includes('application/json'); const data = isJson ? await res.json() : await res.text(); if (!res.ok) { - const message = isJson ? data?.message : res.statusText; - throw new GitHubError(res.status, `${method} ${path} failed (${res.status}): ${message}`); + const reason = (isJson && getErrorReason(data)) || res.statusText; + throw new GitHubError({ status: res.status, reason, request: `${method} ${path}` }); } return data; } } + +/** + * Gets the reason of an error body, with its details: a 422 only says + * "Validation Failed" in `message`. + */ +function getErrorReason(body: ErrorBody | null): string | undefined { + // A proxy could answer JSON that isn't an object. + if (typeof body !== 'object' || !body?.message) return undefined; + const errors = Array.isArray(body.errors) ? body.errors : []; + const details = errors + .map((error) => (typeof error === 'string' ? error : error?.message)) + .filter((detail) => detail !== undefined); + return details.length > 0 ? `${body.message}: ${details.join('; ')}` : body.message; +} 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..44cede7dabf5b --- /dev/null +++ b/scripts/lib/release-queue/handle-accepted.test.ts @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict'; +import { describe, it, type TestContext } from 'node:test'; +import { GitHubError } from './github.ts'; +import { + handleAcceptedPullRequest, + needsBotToken, + type HandleOptions, + type Mode, +} from './handle-accepted.ts'; +import { pullRequestResponse, REPO } from './testing/fixtures.ts'; +import { stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; + +const paths = { + reviews: `repos/${REPO}/pulls/7/reviews`, + releasePullRequest: + 'repos/withastro/astro/pulls?state=open&head=withastro:changeset-release/main', + mainUmbrella: `repos/${REPO}/pulls?state=open&head=withastro:release-queue/main`, +}; + +/** GET responses for PR #7, ready to join the umbrella of `base`. */ +function readyPullRequest({ base = 'main', label = 'minor-release' } = {}) { + const umbrellaBranch = `release-queue/${base}`; + return { + [`repos/${REPO}/pulls/7`]: 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}`]: [ + { number: 42, html_url: `https://github.com/${REPO}/pull/42` }, + ], + [`repos/${REPO}/git/ref/heads/${base}`]: { object: { sha: 'base-sha' } }, + }; +} + +/** Responses for a PR that `decide` 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.mainUmbrella]: [] }); + +// Distinct tokens tell the writes of each identity apart. +const ACTIONS_TOKEN = 'actions-token'; +const BOT_TOKEN = 'bot-token'; + +type StubbedGitHub = ReturnType; + +const handleOptions = (github: StubbedGitHub, mode: Mode): HandleOptions => ({ + actionsClient: github.client(ACTIONS_TOKEN), + botClient: github.client(BOT_TOKEN), + repo: REPO, + mode, +}); + +/** 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(handleOptions(github, mode), 7, 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 (write?.body as { body: string } | undefined)?.body ?? ''; +} + +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 main-only mode', async (t) => { + const { actionsWrites, botWrites } = await run(t, 'main-only'); + + assert.deepEqual(actionsWrites, []); + assert.deepEqual(writtenPaths(botWrites), [ + `PATCH repos/${REPO}/pulls/7`, + `PUT repos/${REPO}/pulls/7/merge`, + comment, + ]); + }); + + it('only comments on a PR shipping to a prerelease branch in main-only mode', async (t) => { + const getResponses = readyPullRequest({ base: 'cloudflare-next', label: 'major-release' }); + const { actionsWrites, botWrites } = await run(t, 'main-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, + ]); + }); + + it('keeps the accepted label after a join, and names the umbrella', async (t) => { + const { botWrites } = await run(t, 'main-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, 'main-only', { getResponses: unapprovedPullRequest() }); + + assert.deepEqual(writtenPaths(botWrites), [removeAccepted, comment]); + assert.match(commentBody(botWrites), /no approval/); + }); + + it('still comments when the label is already gone', async (t) => { + const { botWrites } = await run(t, 'main-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, 'main-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\)/); + }); + + it('refuses a PR whose join is refused', async (t) => { + const { botWrites } = await run(t, 'main-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 }, + }, + }); + + 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(handleOptions(github, 'main-only'), 7, '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, then fails, when the umbrella labels couldn't be set", async (t) => { + const github = stubReadyPullRequest(t, { + getResponses: firstJoin(), + writeResults: { [`POST repos/${REPO}/pulls`]: { number: 99 } }, + writeFailures: { + [`POST repos/${REPO}/issues/99/labels`]: { status: 500, message: 'Server Error' }, + }, + }); + + await assert.rejects( + handleAcceptedPullRequest(handleOptions(github, 'main-only'), 7, 'maintainer'), + /but the labels of the umbrella PR couldn't be set\.$/ + ); + assert.match( + commentBody(github.writesBy(BOT_TOKEN)), + /the branch of #99 into `main`, but its labels/ + ); + }); + + it('ignores a sender without write access', async (t) => { + const { actionsWrites, botWrites } = await run(t, 'main-only', {}, 'contributor'); + + assert.deepEqual(actionsWrites, []); + assert.deepEqual(botWrites, []); + }); +}); + +describe('needsBotToken', () => { + it('is required to write in a live mode', () => { + assert.equal(needsBotToken('main-only', true), true); + assert.equal(needsBotToken('all', true), true); + }); + + it('is not required in a dry run, or when writes are only printed', () => { + assert.equal(needsBotToken('dry-run', true), false); + assert.equal(needsBotToken('all', false), false); + }); +}); diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts new file mode 100644 index 0000000000000..d4331e3e0e5c0 --- /dev/null +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -0,0 +1,203 @@ +import type { GitHubClient } from './github.ts'; +import { joinUmbrella } from './join.ts'; +import { + formatDryRunJoinComment, + formatDryRunRefusalComment, + formatIncompleteJoinComment, + formatJoinComment, + formatRefusalComment, + type MessageContext, +} from './messages.ts'; +import { + canWrite, + findOpenPullRequest, + getPullRequest, + getReadiness, + getReleasePullRequest, + postComment, + removeLabel, + type PullRequest, +} from './queries.ts'; +import { getMainRelease, type MainRelease } from './release.ts'; +import { + ACCEPTED_LABEL, + decide, + getTarget, + MAIN_BRANCH, + UMBRELLA_PREFIX, + type Decision, + type RefusedDecision, + type Route, +} from './routing.ts'; +import { getUmbrellaReleaseLabel, getUmbrellaTitle, type Umbrella } from './umbrella.ts'; + +/** + * - `dry-run`: only comments with the umbrella a PR would join, or why not. + * - `main-only`: acts on PRs shipping to `main`, dry run for the others. + * - `all`: acts on every PR. + */ +export const MODES = ['dry-run', 'main-only', 'all'] as const; + +export type Mode = (typeof MODES)[number]; + +/** + * Whether the bot token is required. Without it, merges would go through + * `GITHUB_TOKEN`, and the umbrella PR would stop getting CI. + */ +export function needsBotToken(mode: Mode, shouldWrite: boolean): boolean { + return mode !== 'dry-run' && shouldWrite; +} + +export interface HandleOptions { + /** + * Reads the PR and the release PR, and posts dry-run comments, as + * `github-actions[bot]`. + */ + actionsClient: GitHubClient; + /** Joins and refuses PRs as the bot account, reads included. */ + botClient: GitHubClient; + repo: string; + mode: Mode; +} + +/** + * Processes a docs PR labeled `accepted`: reads the PR and the release PR of + * `withastro/astro`, decides, then comments as a dry run, refuses, or joins. + * + * @param sender Who added the label. Without one, the write access check is + * skipped. + */ +export async function handleAcceptedPullRequest( + options: HandleOptions, + number: number, + sender?: string +) { + const { actionsClient, repo } = options; + if (sender === undefined) { + console.info('No sender: skipping the write access check.'); + } else if (!(await canWrite(actionsClient, repo, sender))) { + console.info(`Ignored: ${sender} can't write to ${repo}.`); + return; + } + + const pr = await getPullRequest(actionsClient, repo, number); + const [readiness, releasePullRequest] = await Promise.all([ + getReadiness(actionsClient, repo, pr), + getReleasePullRequest(actionsClient), + ]); + const mainRelease = getMainRelease(releasePullRequest?.versions); + const decision = decide(pr, readiness, mainRelease); + console.info(JSON.stringify({ pr: pr.url, readiness, mainRelease, decision }, null, 2)); + + if (!isLiveFor(options.mode, getTarget(pr.base))) { + await commentDryRun(options, number, decision, mainRelease); + } else if (decision.status === 'refuse') { + await refuse(options, number, decision, mainRelease); + } else { + await join(options, pr, decision.route, mainRelease); + } +} + +/** Whether the bot changes PRs shipping to `target`, rather than a dry run. */ +function isLiveFor(mode: Mode, target: string): boolean { + return mode === 'all' || (mode === 'main-only' && target === MAIN_BRANCH); +} + +async function commentDryRun( + options: HandleOptions, + number: number, + decision: Decision, + mainRelease: MainRelease +) { + const { actionsClient, repo } = options; + if (decision.status === 'join') { + const umbrella = await findUmbrella(options, decision.route, mainRelease); + await postComment( + actionsClient, + repo, + number, + formatDryRunJoinComment(decision.route, umbrella) + ); + return; + } + const context = await getMessageContext(options, decision, mainRelease); + await postComment(actionsClient, repo, number, formatDryRunRefusalComment(decision, context)); +} + +async function refuse( + options: HandleOptions, + number: number, + decision: RefusedDecision, + mainRelease: MainRelease +) { + const { botClient, repo } = options; + const context = await getMessageContext(options, decision, mainRelease); + // Removed first, so the comment is true once posted. + await removeLabel(botClient, repo, number, ACCEPTED_LABEL); + await postComment(botClient, repo, number, formatRefusalComment(decision, context)); +} + +async function join( + options: HandleOptions, + pr: PullRequest, + route: Route, + mainRelease: MainRelease +) { + const { botClient, repo } = options; + const result = await joinUmbrella(botClient, repo, pr, route, { + title: getUmbrellaTitle(route.target, mainRelease.astroVersion), + releaseLabel: getUmbrellaReleaseLabel(route.target, mainRelease.type), + }); + + switch (result.status) { + case 'joined': + await postComment(botClient, repo, pr.number, formatJoinComment(route, result.umbrella)); + return; + case 'refused': { + const decision: RefusedDecision = { status: 'refuse', route, refusals: [result.refusal] }; + await refuse(options, pr.number, decision, mainRelease); + return; + } + default: { + await postComment(botClient, repo, pr.number, formatIncompleteJoinComment(route, result)); + // The PR is merged, but a maintainer needs to finish the join. + const missing = + result.status === 'unopened' + ? "the umbrella PR couldn't be opened" + : "the labels of the umbrella PR couldn't be set"; + throw new Error(`#${pr.number} was merged into ${route.umbrellaBranch}, but ${missing}.`, { + cause: result.error, + }); + } + } +} + +/** Finds the open umbrella PR of `route`, or names the one to create. */ +async function findUmbrella( + { actionsClient, repo }: HandleOptions, + route: Route, + mainRelease: MainRelease +): Promise { + const openUmbrella = await findOpenPullRequest(actionsClient, repo, route.umbrellaBranch); + if (openUmbrella) return { number: openUmbrella.number }; + return { title: getUmbrellaTitle(route.target, mainRelease.astroVersion) }; +} + +async function getMessageContext( + options: HandleOptions, + decision: RefusedDecision, + mainRelease: MainRelease +): Promise { + const context: MessageContext = {}; + if (decision.route) context.umbrella = await findUmbrella(options, decision.route, mainRelease); + if (decision.refusals.some((refusal) => refusal.code === 'major-on-main')) { + const { actionsClient, repo } = options; + const mainUmbrella = await findOpenPullRequest( + actionsClient, + repo, + `${UMBRELLA_PREFIX}${MAIN_BRANCH}` + ); + if (mainUmbrella) context.mainUmbrella = { number: mainUmbrella.number }; + } + return context; +} diff --git a/scripts/lib/release-queue/join.test.ts b/scripts/lib/release-queue/join.test.ts new file mode 100644 index 0000000000000..56749e5e194d5 --- /dev/null +++ b/scripts/lib/release-queue/join.test.ts @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { GitHubError } from './github.ts'; +import { joinUmbrella, type NewUmbrella } from './join.ts'; +import type { PullRequest } from './queries.ts'; +import type { Route } from './routing.ts'; +import { pullRequestResponse, REPO } from './testing/fixtures.ts'; +import { stubGitHub, type Write } from './testing/stub-github.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 = { + target: 'main', + umbrellaBranch: 'release-queue/main', + label: 'minor-release', + shouldRetarget: true, +}; + +const newUmbrella: NewUmbrella = { + title: '[ci] release astro@7.5.0 docs', + releaseLabel: 'minor-release', +}; + +const paths = { + openUmbrella: `repos/${REPO}/pulls?state=open&head=withastro:release-queue/main`, + mainRef: `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.mainRef]: { object: { sha: 'main-sha' } }, + [paths.pr]: prResponse(true), +}; + +const withUmbrella = { + ...withoutUmbrella, + [paths.openUmbrella]: [{ number: 42, html_url: `https://github.com/${REPO}/pull/42` }], +}; + +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 labelUmbrella: Write = { + method: 'POST', + path: `repos/${REPO}/issues/99/labels`, + body: { labels: ['merge-on-release', 'minor-release'] }, +}; + +const createdUmbrella = { [`POST repos/${REPO}/pulls`]: { number: 99 } }; + +describe('joinUmbrella', () => { + 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 joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + + assert.deepEqual(result, { status: 'joined', umbrella: { number: 99 } }); + assert.deepEqual(github.writes, [ + createBranch, + setBase('release-queue/main'), + merge, + openUmbrella, + labelUmbrella, + ]); + }); + + it('joins the open umbrella PR', async (t) => { + const github = stubGitHub(t, { getResponses: withUmbrella }); + + const result = await joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + + assert.deepEqual(result, { status: 'joined', umbrella: { number: 42 } }); + assert.deepEqual(github.writes, [setBase('release-queue/main'), merge]); + }); + + 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 joinUmbrella( + github.client(), + REPO, + umbrellaPr, + { ...route, shouldRetarget: false }, + newUmbrella + ); + + 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 joinUmbrella( + github.client(), + REPO, + umbrellaPr, + { ...route, shouldRetarget: false }, + newUmbrella + ); + + assert.deepEqual(github.writes, [createBranch, merge, openUmbrella, labelUmbrella]); + }); + + 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 joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + + 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, + labelUmbrella, + ]); + }); + + 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 joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + + 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 withoutMain: Record = { ...withoutUmbrella }; + delete withoutMain[paths.mainRef]; + const github = stubGitHub(t, { getResponses: withoutMain }); + + await assert.rejects( + joinUmbrella(github.client(), REPO, pr, route, newUmbrella), + /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 joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + + 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 joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + + 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 joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + + 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(joinUmbrella(github.client(), REPO, pr, route, newUmbrella), 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( + joinUmbrella(github.client(), REPO, pr, route, newUmbrella), + (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 joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + + assert.equal(result.status, 'unopened'); + assert.ok(result.error instanceof GitHubError); + assert.equal(result.error.reason, 'Validation Failed: A pull request already exists'); + assert.deepEqual(github.writes, [ + createBranch, + setBase('release-queue/main'), + merge, + openUmbrella, + ]); + }); + + it("reports an umbrella PR whose labels couldn't be set", async (t) => { + const github = stubGitHub(t, { + getResponses: withoutUmbrella, + writeResults: createdUmbrella, + writeFailures: { + [`POST repos/${REPO}/issues/99/labels`]: { status: 500, message: 'Server Error' }, + }, + }); + + const result = await joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + + assert.equal(result.status, 'unlabeled'); + assert.deepEqual(result.umbrella, { number: 99 }); + assert.deepEqual(result.labels, ['merge-on-release', 'minor-release']); + assert.ok(result.error instanceof GitHubError); + assert.equal(result.error.reason, 'Server Error'); + }); + + 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.client('token', { shouldPrintWrites: true }); + + const result = await joinUmbrella(printingClient, REPO, pr, route, newUmbrella); + + assert.deepEqual(result, { + status: 'joined', + umbrella: { title: '[ci] release astro@7.5.0 docs' }, + }); + assert.deepEqual(github.writes, []); + }); +}); diff --git a/scripts/lib/release-queue/join.ts b/scripts/lib/release-queue/join.ts new file mode 100644 index 0000000000000..4b4a94851e5f4 --- /dev/null +++ b/scripts/lib/release-queue/join.ts @@ -0,0 +1,191 @@ +import { GitHubError, type GitHubClient } from './github.ts'; +import { + countCommitsAhead, + findOpenPullRequest, + getBranchSha, + getPullRequest, + type PullRequest, +} from './queries.ts'; +import type { JoinRefusal, ReleaseLabel, Route } from './routing.ts'; +import type { Umbrella } from './umbrella.ts'; + +export const MERGE_ON_RELEASE_LABEL = 'merge-on-release'; + +/** What the umbrella PR gets when the first join creates it. */ +export interface NewUmbrella { + title: string; + releaseLabel: ReleaseLabel; +} + +/** + * The outcome of a join. `unopened` and `unlabeled` mean the PR was merged + * into the umbrella branch, but the umbrella PR couldn't be opened, or its + * labels couldn't be set. + */ +export type JoinResult = + | { status: 'joined'; umbrella: Umbrella } + | { status: 'refused'; refusal: JoinRefusal } + | { status: 'unopened'; error: unknown } + | { status: 'unlabeled'; umbrella: { number: number }; labels: string[]; error: unknown }; + +/** A join that merged the PR, but couldn't finish. */ +export type IncompleteJoin = Extract; + +/** + * Squash-merges a docs PR into its umbrella branch. Prepares the branch before + * changing the PR, then changes the PR base, merges, and opens the umbrella + * PR if none is open: GitHub refuses a PR without commits. A refused merge + * restores the PR base. Expected failures come back as a result, anything + * else throws. + */ +export async function joinUmbrella( + gh: GitHubClient, + repo: string, + pr: PullRequest, + route: Route, + newUmbrella: NewUmbrella +): Promise { + const openUmbrella = await findOpenPullRequest(gh, repo, route.umbrellaBranch); + if (!openUmbrella) { + const refusal = await prepareUmbrellaBranch(gh, repo, route); + if (refusal) return { status: 'refused', refusal }; + } + + if (route.shouldRetarget) await setBase(gh, repo, pr.number, route.umbrellaBranch); + const refusal = await mergeOrRestoreBase(gh, repo, pr, route); + if (refusal) return { status: 'refused', refusal }; + + if (openUmbrella) return { status: 'joined', umbrella: { number: openUmbrella.number } }; + return createUmbrella(gh, repo, route, newUmbrella); +} + +/** + * Makes sure the umbrella branch starts from its target. A branch left over + * without commits, for example after a failed first merge, is reset. One with + * commits is left to a maintainer. Returns a refusal, or `undefined` when the + * branch is ready. + */ +async function prepareUmbrellaBranch( + gh: GitHubClient, + repo: string, + { target, umbrellaBranch }: Route +): Promise { + const targetSha = await getBranchSha(gh, repo, target); + if (!targetSha) throw new Error(`The branch ${target} doesn't exist in ${repo}.`); + + const umbrellaSha = await getBranchSha(gh, repo, umbrellaBranch); + if (umbrellaSha === undefined) { + await gh.write('POST', `repos/${repo}/git/refs`, { + ref: `refs/heads/${umbrellaBranch}`, + sha: targetSha, + }); + return undefined; + } + if ((await countCommitsAhead(gh, repo, target, umbrellaBranch)) > 0) { + return { code: 'stale-umbrella-branch', branch: umbrellaBranch }; + } + await gh.write('PATCH', `repos/${repo}/git/refs/heads/${umbrellaBranch}`, { + sha: targetSha, + force: true, + }); + return undefined; +} + +function setBase(gh: GitHubClient, repo: string, number: number, base: string) { + return gh.write('PATCH', `repos/${repo}/pulls/${number}`, { base }); +} + +/** + * Squash-merges the PR, and restores its base when the merge is refused or + * throws. Returns a refusal, or `undefined` once merged. + */ +async function mergeOrRestoreBase( + gh: GitHubClient, + repo: string, + pr: PullRequest, + route: Route +): Promise { + let refusal: JoinRefusal | undefined; + try { + refusal = await squashMerge(gh, repo, pr, route); + } catch (error) { + await restoreBase(gh, repo, pr, route, error); + throw error; + } + if (refusal) await restoreBase(gh, repo, pr, route, refusal); + return refusal; +} + +/** + * Changes the PR base back. If that fails too, the error keeps both: the + * failure to restore, and `cause`, the reason for restoring. + */ +async function restoreBase( + gh: GitHubClient, + repo: string, + pr: PullRequest, + route: Route, + cause: unknown +) { + if (!route.shouldRetarget) return; + try { + await setBase(gh, repo, pr.number, pr.base); + } catch (error) { + throw new AggregateError([error], `Couldn't restore the base of #${pr.number}.`, { cause }); + } +} + +async function squashMerge( + gh: GitHubClient, + repo: string, + pr: PullRequest, + route: Route +): Promise { + // GitHub computes the mergeability again after a base change. + const { isMergeable } = await getPullRequest(gh, repo, pr.number); + if (isMergeable === false) return { code: 'umbrella-conflict', branch: route.umbrellaBranch }; + + try { + await gh.write('PUT', `repos/${repo}/pulls/${pr.number}/merge`, { + merge_method: 'squash', + // Refuses the merge if the PR changed since its checks were read. + sha: pr.headSha, + // Set here rather than relying on how the API applies the squash + // title setting of the repository. + commit_title: `${pr.title} (#${pr.number})`, + }); + return undefined; + } catch (error) { + if (error instanceof GitHubError) return { code: 'merge-failed', reason: error.reason }; + throw error; + } +} + +async function createUmbrella( + gh: GitHubClient, + repo: string, + { target, umbrellaBranch }: Route, + { title, releaseLabel }: NewUmbrella +): Promise | IncompleteJoin> { + let created: { number: number } | undefined; + try { + created = await gh.write<{ number: number }>('POST', `repos/${repo}/pulls`, { + title, + head: umbrellaBranch, + base: target, + body: `Release umbrella for \`${target}\`, managed by the release queue.`, + }); + } catch (error) { + return { status: 'unopened', error }; + } + if (!created) return { status: 'joined', umbrella: { title } }; + + const umbrella = { number: created.number }; + const labels = [MERGE_ON_RELEASE_LABEL, releaseLabel]; + try { + await gh.write('POST', `repos/${repo}/issues/${umbrella.number}/labels`, { labels }); + } catch (error) { + return { status: 'unlabeled', umbrella, labels, error }; + } + return { status: 'joined', umbrella }; +} diff --git a/scripts/lib/release-queue/messages.test.ts b/scripts/lib/release-queue/messages.test.ts index 5d7c0c9258341..e108d3da9dcf0 100644 --- a/scripts/lib/release-queue/messages.test.ts +++ b/scripts/lib/release-queue/messages.test.ts @@ -1,6 +1,14 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { describeRefusal, formatDryRunComment } from './messages.ts'; +import { GitHubError } from './github.ts'; +import { + describeRefusal, + formatDryRunJoinComment, + formatDryRunRefusalComment, + formatIncompleteJoinComment, + formatJoinComment, + formatRefusalComment, +} from './messages.ts'; import type { Refusal, Route } from './routing.ts'; const route: Route = { @@ -27,6 +35,9 @@ describe('describeRefusal', () => { { 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); @@ -44,6 +55,14 @@ describe('describeRefusal', () => { ); }); + 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-main', astroVersion: '8.0.0' }; assert.match(describeRefusal(refusal), /`astro@8.0.0`/); @@ -52,9 +71,9 @@ describe('describeRefusal', () => { }); }); -describe('formatDryRunComment', () => { +describe('formatDryRunJoinComment', () => { it('names the open umbrella a ready PR would join', () => { - const comment = formatDryRunComment({ route, refusals: [] }, { umbrella: { number: 42 } }); + const comment = formatDryRunJoinComment(route, { number: 42 }); assert.match(comment, /^\*\*Release queue \(dry run\)\*\*/); assert.match( comment, @@ -64,17 +83,19 @@ describe('formatDryRunComment', () => { }); it('names the umbrella PR that would be created', () => { - const comment = formatDryRunComment( - { route: { ...route, shouldRetarget: false }, refusals: [] }, - { umbrella: { title: '[ci] release astro@7.4.1 docs' } } + const comment = formatDryRunJoinComment( + { ...route, shouldRetarget: false }, + { 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 = formatDryRunComment( - { route, refusals: [{ code: 'draft' }, { code: 'not-approved' }] }, + const comment = formatDryRunRefusalComment( + { status: 'refuse', route, refusals: [{ code: 'draft' }, { code: 'not-approved' }] }, { umbrella: { number: 42 } } ); assert.match(comment, /wouldn't join #42 into `main` yet:\n\n- .*draft.*\n- .*approval/); @@ -82,11 +103,77 @@ describe('formatDryRunComment', () => { }); it("doesn't name an umbrella when the PR has none", () => { - const comment = formatDryRunComment({ refusals: [{ code: 'no-release-label' }] }); + 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: { 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: { 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, { 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('formatIncompleteJoinComment', () => { + const error = new GitHubError({ status: 422, reason: 'Validation Failed', request: 'POST' }); + + it("asks a maintainer to open the umbrella PR that couldn't be opened", () => { + assert.equal( + formatIncompleteJoinComment(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("asks a maintainer to add the labels that couldn't be set", () => { + const comment = formatIncompleteJoinComment(route, { + status: 'unlabeled', + umbrella: { number: 99 }, + labels: ['merge-on-release', 'patch-release'], + error, + }); + assert.match(comment, /the branch of #99 into `main`, but its labels couldn't be set/); + assert.match(comment, /add `merge-on-release`, `patch-release`\.$/); + }); - it('throws when a PR without refusals has no umbrella', () => { - assert.throws(() => formatDryRunComment({ route, refusals: [] })); + it('describes an error that is not from GitHub', () => { + const comment = formatIncompleteJoinComment(route, { + status: 'unopened', + error: new TypeError('fetch failed'), + }); + assert.match(comment, /couldn't be opened: fetch failed\./); }); }); diff --git a/scripts/lib/release-queue/messages.ts b/scripts/lib/release-queue/messages.ts index b75d3e9c06485..b0e27d8e5af34 100644 --- a/scripts/lib/release-queue/messages.ts +++ b/scripts/lib/release-queue/messages.ts @@ -1,13 +1,13 @@ +import { GitHubError } from './github.ts'; +import type { IncompleteJoin } from './join.ts'; import { ACCEPTED_LABEL, - type Decision, type Refusal, type RefusalCode, + type RefusedDecision, type Route, } from './routing.ts'; - -/** An open umbrella PR, or the title of the one to create. */ -export type Umbrella = { number: number } | { title: string }; +import type { Umbrella } from './umbrella.ts'; export interface MessageContext { /** The umbrella PR the docs PR would join. */ @@ -18,6 +18,8 @@ export interface MessageContext { const code = (text: string) => `\`${text}\``; const list = (items: string[]) => items.map(code).join(', '); +// GitHub's reasons sometimes end with a period already. +const sentence = (text: string) => (/[.!?]$/.test(text) ? text : `${text}.`); type DescribeRefusal = ( refusal: Refusal, @@ -53,6 +55,11 @@ const REFUSAL_DESCRIPTIONS: { [C in RefusalCode]: DescribeRefusal } = { 'missing-checks': ({ checks }) => `These required checks haven't run: ${list(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 ${code(branch)}. Resolve the conflicts with that branch.`, + 'stale-umbrella-branch': ({ branch }) => + `The branch ${code(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: ${sentence(reason)}`, }; /** Describes a refusal and how to fix it. */ @@ -76,21 +83,56 @@ function describeRoute(route: Route, umbrella: Umbrella): string { return `This PR would be squash-merged into ${code(route.umbrellaBranch)}, the branch of ${describeUmbrella(route, umbrella)}.${base}`; } -/** Writes the comment of a dry run: what the bot would do, without doing it. */ -export function formatDryRunComment( - { route, refusals }: Decision, +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.reason; + 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 `umbrella`. */ +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. */ +export function formatDryRunRefusalComment( + { route, refusals }: RefusedDecision, context: MessageContext = {} ): string { - const heading = '**Release queue (dry run)**: nothing was changed.'; - const { umbrella } = context; - if (refusals.length === 0) { - if (!route || !umbrella) { - throw new Error('A PR without refusals needs a route and an umbrella.'); - } - return `${heading}\n\n${describeRoute(route, umbrella)}`; - } + 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 ${code(ACCEPTED_LABEL)} label would be removed. It would need to be added again after fixing.`; +} - const reasons = refusals.map((refusal) => `- ${describeRefusal(refusal, context)}`).join('\n'); - const target = route && umbrella ? describeUmbrella(route, umbrella) : 'its release umbrella'; - return `${heading}\n\nThis PR wouldn't join ${target} yet:\n\n${reasons}\n\nOutside a dry run, the ${code(ACCEPTED_LABEL)} label would be removed. It would need to be added again after fixing.`; +/** Writes the comment of a refusal, posted once `accepted` is removed. */ +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 ${code(ACCEPTED_LABEL)} label was removed. Add it again after fixing.`; +} + +/** Writes the comment of a join: the umbrella the PR is now part of. */ +export function formatJoinComment(route: Route, umbrella: Umbrella): string { + return `**Release queue**: squash-merged into ${code(route.umbrellaBranch)}, the branch of ${describeUmbrella(route, umbrella)}. It ships when the umbrella PR is merged.`; +} + +/** Writes the comment of a join that merged the PR, but couldn't finish. */ +export function formatIncompleteJoinComment(route: Route, result: IncompleteJoin): string { + const branch = code(route.umbrellaBranch); + const reason = sentence(describeError(result.error)); + if (result.status === 'unopened') { + return `**Release queue**: squash-merged into ${branch}, but the umbrella PR into ${code(route.target)} couldn't be opened: ${reason} A maintainer needs to open it from ${branch}.`; + } + const umbrella = describeUmbrella(route, result.umbrella); + return `**Release queue**: squash-merged into ${branch}, the branch of ${umbrella}, but its labels couldn't be set: ${reason} A maintainer needs to add ${list(result.labels)}.`; } diff --git a/scripts/lib/release-queue/queries.test.ts b/scripts/lib/release-queue/queries.test.ts new file mode 100644 index 0000000000000..f0c0ce9db39e1 --- /dev/null +++ b/scripts/lib/release-queue/queries.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getPullRequest } from './queries.ts'; +import { pullRequestResponse, REPO } from './testing/fixtures.ts'; +import { stubGitHub } from './testing/stub-github.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.client(), REPO, 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.client(), REPO, 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.client(), REPO, 7); + + assert.equal(github.countReads(PR_PATH), 1); + }); +}); diff --git a/scripts/lib/release-queue/queries.ts b/scripts/lib/release-queue/queries.ts index 24a6549236529..af8b8aa17b5e7 100644 --- a/scripts/lib/release-queue/queries.ts +++ b/scripts/lib/release-queue/queries.ts @@ -1,4 +1,3 @@ -import { setTimeout } from 'node:timers/promises'; import { GitHubError, type GitHubClient } from './github.ts'; import { getLatestReviewStates, @@ -9,14 +8,14 @@ import { type Readiness, } from './readiness.ts'; import { parseReleaseVersions, type PackageVersion } from './release.ts'; -import type { DocsPullRequest } from './routing.ts'; +import { MAIN_BRANCH, type DocsPullRequest } from './routing.ts'; const FRAMEWORK_REPO = 'withastro/astro'; const MERGEABILITY_ATTEMPTS = 5; -const MERGEABILITY_DELAY_MS = 2000; export interface PullRequest extends DocsPullRequest { number: number; + title: string; url: string; headSha: string; isMergeable: boolean | null; @@ -32,6 +31,7 @@ export interface ReleasePullRequest { interface PullRequestResponse { number: number; + title: string; html_url: string; state: string; draft: boolean; @@ -61,6 +61,7 @@ export async function getPullRequest( const data = await getPullRequestWithMergeability(gh, repo, number); return { number: data.number, + title: data.title, url: data.html_url, state: data.state, isDraft: data.draft, @@ -76,13 +77,11 @@ export async function getPullRequest( * Gets a PR once GitHub has computed its mergeability. The first request * starts the computation. */ -async function getPullRequestWithMergeability(gh: GitHubClient, repo: string, number: number) { - for (let attempt = 1; ; attempt++) { - const data = await gh.get(`repos/${repo}/pulls/${number}`); - const isKnown = data.state !== 'open' || data.mergeable !== null; - if (isKnown || attempt === MERGEABILITY_ATTEMPTS) return data; - await setTimeout(MERGEABILITY_DELAY_MS); - } +function getPullRequestWithMergeability(gh: GitHubClient, repo: string, number: number) { + return gh.poll(`repos/${repo}/pulls/${number}`, { + isReady: (data) => data.state !== 'open' || data.mergeable !== null, + attempts: MERGEABILITY_ATTEMPTS, + }); } /** Finds the open PR whose head is `branch`, such as an umbrella PR. */ @@ -94,6 +93,49 @@ export async function findOpenPullRequest(gh: GitHubClient, repo: string, branch return pr ? { number: pr.number, url: pr.html_url } : undefined; } +/** Gets the commit a branch points to, or `undefined` if it doesn't exist. */ +export async function getBranchSha( + gh: GitHubClient, + repo: string, + branch: string +): Promise { + try { + const { object } = await gh.get<{ object: { sha: string } }>( + `repos/${repo}/git/ref/heads/${branch}` + ); + return object.sha; + } catch (error) { + if (error instanceof GitHubError && error.status === 404) return undefined; + throw error; + } +} + +/** Counts the commits of `head` that aren't in `base`. */ +export async function countCommitsAhead( + gh: GitHubClient, + repo: string, + base: string, + head: string +) { + const { ahead_by } = await gh.get<{ ahead_by: number }>( + `repos/${repo}/compare/${base}...${head}` + ); + return ahead_by; +} + +export async function postComment(gh: GitHubClient, repo: string, number: number, body: string) { + await gh.write('POST', `repos/${repo}/issues/${number}/comments`, { body }); +} + +/** Removes a label from a PR. A label that is already gone is fine. */ +export async function removeLabel(gh: GitHubClient, repo: string, number: number, label: string) { + try { + await gh.write('DELETE', `repos/${repo}/issues/${number}/labels/${label}`); + } catch (error) { + if (!(error instanceof GitHubError && error.status === 404)) throw error; + } +} + export async function canWrite(gh: GitHubClient, repo: string, user: string): Promise { try { const { permission } = await gh.get<{ permission: string }>( @@ -151,7 +193,7 @@ export async function getReadiness( /** Gets the checks required to merge into `main`. */ async function getRequiredChecks(gh: GitHubClient, repo: string): Promise { - const rules = await gh.getAll(`repos/${repo}/rules/branches/main`); + const rules = await gh.getAll(`repos/${repo}/rules/branches/${MAIN_BRANCH}`); return rules .filter((rule) => rule.type === 'required_status_checks') .flatMap((rule) => rule.parameters?.required_status_checks ?? []) diff --git a/scripts/lib/release-queue/routing.test.ts b/scripts/lib/release-queue/routing.test.ts index c1269e096ab4b..025aee2c0d669 100644 --- a/scripts/lib/release-queue/routing.test.ts +++ b/scripts/lib/release-queue/routing.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { Readiness } from './readiness.ts'; import type { MainRelease } from './release.ts'; -import { decide, type DocsPullRequest } from './routing.ts'; +import { decide, getTarget, type Decision, type DocsPullRequest } from './routing.ts'; const pr = (overrides: Partial = {}): DocsPullRequest => ({ state: 'open', @@ -24,18 +24,20 @@ const ready: Readiness = { const patch: MainRelease = { type: 'patch', astroVersion: '7.4.1' }; +const refusalsOf = (decision: Decision) => (decision.status === 'refuse' ? decision.refusals : []); + describe('decide', () => { describe('routes a ready docs PR', () => { for (const label of ['patch-release', 'minor-release']) { it(`to the main umbrella with ${label}`, () => { assert.deepEqual(decide(pr({ labels: [label] }), ready, patch), { + status: 'join', route: { target: 'main', umbrellaBranch: 'release-queue/main', label, shouldRetarget: true, }, - refusals: [], }); }); } @@ -46,61 +48,68 @@ describe('decide', () => { astroVersion: '8.0.0', }); assert.deepEqual(decision, { + status: 'join', route: { target: 'cloudflare-next', umbrellaBranch: 'release-queue/cloudflare-next', label: 'major-release', shouldRetarget: true, }, - refusals: [], }); }); it('to the umbrella it already targets, without retargeting', () => { - const decision = decide(pr({ base: 'release-queue/main' }), ready, patch); - assert.equal(decision.route?.umbrellaBranch, 'release-queue/main'); - assert.equal(decision.route?.shouldRetarget, false); - assert.deepEqual(decision.refusals, []); + assert.deepEqual(decide(pr({ base: 'release-queue/main' }), ready, patch), { + status: 'join', + route: { + target: 'main', + umbrellaBranch: 'release-queue/main', + label: 'patch-release', + shouldRetarget: false, + }, + }); }); }); describe('refuses', () => { it('a closed PR, and nothing else', () => { const decision = decide(pr({ state: 'closed', isDraft: true }), ready, patch); - assert.deepEqual(decision, { refusals: [{ code: 'closed' }] }); + assert.deepEqual(decision, { status: 'refuse', refusals: [{ code: 'closed' }] }); }); it('an umbrella PR, and nothing else', () => { const decision = decide(pr({ head: 'release-queue/main' }), ready, patch); - assert.deepEqual(decision, { refusals: [{ code: 'umbrella' }] }); + assert.deepEqual(decision, { status: 'refuse', refusals: [{ code: 'umbrella' }] }); }); it('a draft PR', () => { - assert.deepEqual(decide(pr({ isDraft: true }), ready, patch).refusals, [{ code: 'draft' }]); + assert.deepEqual(refusalsOf(decide(pr({ isDraft: true }), ready, patch)), [ + { code: 'draft' }, + ]); }); it('a PR without a release label', () => { const decision = decide(pr({ labels: ['accepted'] }), ready, patch); - assert.deepEqual(decision, { refusals: [{ code: 'no-release-label' }] }); + assert.deepEqual(decision, { status: 'refuse', refusals: [{ code: 'no-release-label' }] }); }); it('a PR with several release labels', () => { const decision = decide(pr({ labels: ['minor-release', 'patch-release'] }), ready, patch); - assert.deepEqual(decision.refusals, [ + assert.deepEqual(refusalsOf(decision), [ { code: 'several-release-labels', labels: ['patch-release', 'minor-release'] }, ]); }); it('a major-release PR against main', () => { const decision = decide(pr({ labels: ['major-release'] }), ready, patch); - assert.deepEqual(decision.refusals, [ + assert.deepEqual(refusalsOf(decision), [ { code: 'mismatch', base: 'main', label: 'major-release' }, ]); }); it('a minor-release PR against a prerelease branch', () => { const decision = decide(pr({ base: 'v8', labels: ['minor-release'] }), ready, patch); - assert.deepEqual(decision.refusals, [ + assert.deepEqual(refusalsOf(decision), [ { code: 'mismatch', base: 'v8', label: 'minor-release' }, ]); }); @@ -111,14 +120,14 @@ describe('decide', () => { ready, patch ); - assert.deepEqual(decision.refusals, [ + assert.deepEqual(refusalsOf(decision), [ { code: 'mismatch', base: 'release-queue/main', label: 'major-release' }, ]); }); it('a main PR when the next astro release from main is a major', () => { const decision = decide(pr(), ready, { type: 'major', astroVersion: '8.0.0' }); - assert.deepEqual(decision.refusals, [{ code: 'major-on-main', astroVersion: '8.0.0' }]); + assert.deepEqual(refusalsOf(decision), [{ code: 'major-on-main', astroVersion: '8.0.0' }]); }); it('a PR that is not ready, with every reason', () => { @@ -134,7 +143,7 @@ describe('decide', () => { }, patch ); - assert.deepEqual(decision.refusals, [ + assert.deepEqual(refusalsOf(decision), [ { code: 'not-approved' }, { code: 'changes-requested', reviewers: ['alice'] }, { code: 'failing-checks', checks: ['links'] }, @@ -147,12 +156,31 @@ describe('decide', () => { it('a PR with both a routing and a readiness problem, with both reasons', () => { const decision = decide(pr({ labels: [] }), { ...ready, isApproved: false }, patch); assert.deepEqual(decision, { + status: 'refuse', refusals: [{ code: 'no-release-label' }, { code: 'not-approved' }], }); }); + + it('a routed PR that is not ready, keeping its route', () => { + const decision = decide(pr(), { ...ready, isApproved: false }, patch); + assert.equal(decision.status, 'refuse'); + assert.equal(decision.route?.umbrellaBranch, 'release-queue/main'); + }); }); it('lets the merge decide while mergeability is unknown', () => { - assert.deepEqual(decide(pr(), { ...ready, isMergeable: null }, patch).refusals, []); + assert.equal(decide(pr(), { ...ready, isMergeable: null }, patch).status, 'join'); + }); +}); + +describe('getTarget', () => { + it('is the base of a PR against a branch', () => { + assert.equal(getTarget('main'), 'main'); + assert.equal(getTarget('cloudflare-next'), 'cloudflare-next'); + }); + + it('is the base of the umbrella for a PR against an umbrella branch', () => { + assert.equal(getTarget('release-queue/main'), 'main'); + assert.equal(getTarget('release-queue/cloudflare-next'), 'cloudflare-next'); }); }); diff --git a/scripts/lib/release-queue/routing.ts b/scripts/lib/release-queue/routing.ts index 8c9d6bf8a647a..1eac822533ae1 100644 --- a/scripts/lib/release-queue/routing.ts +++ b/scripts/lib/release-queue/routing.ts @@ -2,6 +2,7 @@ import type { Readiness } from './readiness.ts'; import type { MainRelease } from './release.ts'; export const ACCEPTED_LABEL = 'accepted'; +export const MAIN_BRANCH = 'main'; export const UMBRELLA_PREFIX = 'release-queue/'; export const RELEASE_LABELS = ['patch-release', 'minor-release', 'major-release'] as const; @@ -39,6 +40,11 @@ interface RefusalDetails { 'pending-checks': { checks: string[] }; 'missing-checks': { checks: string[] }; conflict: unknown; + // 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; @@ -48,20 +54,35 @@ export type Refusal = { [C in Code]: { code: C } & RefusalDetails[C]; }[Code]; -/** The docs PR joins `route` when there are no refusals. */ -export interface Decision { - route?: Route; - refusals: Refusal[]; +/** The refusals found only while joining, after `decide` 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; + +export function isUmbrellaBranch(branch: string): boolean { + return branch.startsWith(UMBRELLA_PREFIX); +} + +/** Gets the branch a docs PR ships to: its base, or its umbrella's base. */ +export function getTarget(base: string): string { + return isUmbrellaBranch(base) ? base.slice(UMBRELLA_PREFIX.length) : base; } /** * Decides whether a docs PR joins an umbrella, and which one. Returns every - * refusal at once, except for a closed PR or an umbrella PR. Returns the route - * even with readiness refusals: comments use it to name the umbrella. + * refusal at once, except for a closed PR or an umbrella PR. */ export function decide(pr: DocsPullRequest, readiness: Readiness, release: MainRelease): Decision { - if (pr.state !== 'open') return { refusals: [{ code: 'closed' }] }; - if (pr.head.startsWith(UMBRELLA_PREFIX)) return { refusals: [{ code: 'umbrella' }] }; + if (pr.state !== 'open') return { status: 'refuse', refusals: [{ code: 'closed' }] }; + if (isUmbrellaBranch(pr.head)) return { status: 'refuse', refusals: [{ code: 'umbrella' }] }; const refusals: Refusal[] = []; if (pr.isDraft) refusals.push({ code: 'draft' }); @@ -71,7 +92,9 @@ export function decide(pr: DocsPullRequest, readiness: Readiness, release: MainR refusals.push(...getReadinessRefusals(readiness)); - return 'code' in route ? { refusals } : { route, refusals }; + if ('code' in route) return { status: 'refuse', refusals }; + if (refusals.length > 0) return { status: 'refuse', route, refusals }; + return { status: 'join', route }; } function getRoute(pr: DocsPullRequest, release: MainRelease): Route | Refusal { @@ -80,13 +103,13 @@ function getRoute(pr: DocsPullRequest, release: MainRelease): Route | Refusal { if (labels.length > 1) return { code: 'several-release-labels', labels }; const [label] = labels; + const target = getTarget(pr.base); // A PR against an umbrella branch, to fix something already merged in it. - const shouldRetarget = !pr.base.startsWith(UMBRELLA_PREFIX); - const target = shouldRetarget ? pr.base : pr.base.slice(UMBRELLA_PREFIX.length); + const shouldRetarget = !isUmbrellaBranch(pr.base); // Nothing marks a prerelease branch: any base other than `main` is one. const isMajor = label === 'major-release'; - const isMainTarget = target === 'main'; + const isMainTarget = target === MAIN_BRANCH; if (isMajor === isMainTarget) return { code: 'mismatch', base: pr.base, label }; // Every feature on `main` in `withastro/astro` now ships in the major, // documented on a prerelease branch. diff --git a/scripts/lib/release-queue/testing/fixtures.ts b/scripts/lib/release-queue/testing/fixtures.ts new file mode 100644 index 0000000000000..64a96b9286e74 --- /dev/null +++ b/scripts/lib/release-queue/testing/fixtures.ts @@ -0,0 +1,19 @@ +export const REPO = 'withastro/docs'; + +const pullRequest = { + number: 7, + title: 'Document the new option', + html_url: `https://github.com/${REPO}/pull/7`, + state: 'open', + draft: false, + mergeable: true as boolean | null, + base: { ref: 'main' }, + head: { ref: 'feat/option', sha: 'head-sha' }, + labels: [] as { name: string }[], + body: null as string | null, +}; + +/** A `GET /pulls/7` answer: an open docs PR against `main`, mergeable. */ +export function pullRequestResponse(overrides: Partial = {}) { + return { ...pullRequest, ...overrides }; +} 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..d4e6c95b07fcf --- /dev/null +++ b/scripts/lib/release-queue/testing/stub-github.ts @@ -0,0 +1,109 @@ +import type { TestContext } from 'node:test'; +import { GitHubClient } from '../github.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; +} + +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.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', { shouldPrintWrites = false } = {}) => + new GitHubClient(token, { shouldPrintWrites, pollDelayMs: 0 }), + }; +} diff --git a/scripts/lib/release-queue/umbrella.test.ts b/scripts/lib/release-queue/umbrella.test.ts index df1e42bb7b79f..8c88a87f60564 100644 --- a/scripts/lib/release-queue/umbrella.test.ts +++ b/scripts/lib/release-queue/umbrella.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { getUmbrellaTitle } from './umbrella.ts'; +import { getUmbrellaReleaseLabel, getUmbrellaTitle } from './umbrella.ts'; describe('getUmbrellaTitle', () => { it('uses the astro version for the main umbrella', () => { @@ -18,3 +18,14 @@ describe('getUmbrellaTitle', () => { ); }); }); + +describe('getUmbrellaReleaseLabel', () => { + it('labels the main umbrella from the astro release type', () => { + assert.equal(getUmbrellaReleaseLabel('main', 'minor'), 'minor-release'); + assert.equal(getUmbrellaReleaseLabel('main', 'patch'), 'patch-release'); + }); + + it('labels a prerelease umbrella as a major release', () => { + assert.equal(getUmbrellaReleaseLabel('v8', 'patch'), 'major-release'); + }); +}); diff --git a/scripts/lib/release-queue/umbrella.ts b/scripts/lib/release-queue/umbrella.ts index 863ad5ab58325..724795ee2c090 100644 --- a/scripts/lib/release-queue/umbrella.ts +++ b/scripts/lib/release-queue/umbrella.ts @@ -1,3 +1,12 @@ +import type { ReleaseType } from './release.ts'; +import { MAIN_BRANCH, type ReleaseLabel } from './routing.ts'; + +/** + * An umbrella PR: an open one, or the one a join creates, named by its title. + * The title also stands for an umbrella whose creation was only printed. + */ +export type Umbrella = { number: number } | { title: string }; + /** * Gets the title of the umbrella PR merging into `target`, after the * `[ci] release` PRs of `withastro/astro`. The `main` umbrella uses the @@ -5,6 +14,19 @@ * A prerelease umbrella uses its branch name, which says what it is for. */ export function getUmbrellaTitle(target: string, astroVersion?: string): string { - const release = target === 'main' && astroVersion ? `astro@${astroVersion}` : target; + const release = target === MAIN_BRANCH && astroVersion ? `astro@${astroVersion}` : target; return `[ci] release ${release} docs`; } + +/** + * Gets the release label of the umbrella PR merging into `target`. On `main`, + * anything but a minor is a patch, including a release without `astro`. A + * major never gets here: `decide` refuses `main` PRs before a major. + */ +export function getUmbrellaReleaseLabel( + target: string, + mainReleaseType: ReleaseType +): ReleaseLabel { + if (target !== MAIN_BRANCH) return 'major-release'; + return mainReleaseType === 'minor' ? 'minor-release' : 'patch-release'; +} diff --git a/scripts/release-queue.ts b/scripts/release-queue.ts index 45bd0497ccbd2..0689d3edf777e 100644 --- a/scripts/release-queue.ts +++ b/scripts/release-queue.ts @@ -4,21 +4,15 @@ import { readFile } from 'node:fs/promises'; import { parseArgs } from 'node:util'; import { GitHubClient } from './lib/release-queue/github.ts'; -import { formatDryRunComment, type MessageContext } from './lib/release-queue/messages.ts'; import { - canWrite, - findOpenPullRequest, - getPullRequest, - getReadiness, - getReleasePullRequest, -} from './lib/release-queue/queries.ts'; -import { getMainRelease } from './lib/release-queue/release.ts'; -import { decide, UMBRELLA_PREFIX } from './lib/release-queue/routing.ts'; -import { getUmbrellaTitle } from './lib/release-queue/umbrella.ts'; - -// `dry-run`: only comments with the umbrella a PR would join, or why not. -const MODES = ['dry-run']; - + MODES, + handleAcceptedPullRequest, + needsBotToken, +} from './lib/release-queue/handle-accepted.ts'; + +// In GitHub Actions, the PR and the sender come from the event. +// `--sender`: who added the label, for the write access check. +// `--write`: send the writes instead of printing them, outside GitHub Actions. const { values } = parseArgs({ options: { pr: { type: 'string' }, @@ -28,14 +22,22 @@ const { values } = parseArgs({ }); const repo = process.env.GITHUB_REPOSITORY ?? 'withastro/docs'; -const mode = process.env.MODE ?? 'dry-run'; -if (!MODES.includes(mode)) throw new Error(`Unknown MODE: ${mode}. Expected one of: ${MODES}.`); +const mode = MODES.find((value) => value === (process.env.MODE ?? 'dry-run')); +if (!mode) throw new Error(`Unknown MODE: ${process.env.MODE}. Expected one of: ${MODES}.`); const token = process.env.GITHUB_TOKEN; if (!token) throw new Error('GITHUB_TOKEN is required.'); -// Outside GitHub Actions, only print the writes, unless `--write` is passed. const shouldWrite = process.env.GITHUB_ACTIONS === 'true' || values.write; -const gh = new GitHubClient(token, { shouldPrintWrites: !shouldWrite }); +// Changes go through the bot account: unlike `GITHUB_TOKEN`, its merges into +// an umbrella branch trigger the checks of the umbrella PR. +const botToken = process.env.BOT_TOKEN; +if (needsBotToken(mode, shouldWrite) && !botToken) { + throw new Error(`BOT_TOKEN is required in ${mode} mode.`); +} + +const clientOptions = { shouldPrintWrites: !shouldWrite }; +const actionsClient = new GitHubClient(token, clientOptions); +const botClient = new GitHubClient(botToken ?? token, clientOptions); const event = process.env.GITHUB_EVENT_PATH ? JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, 'utf-8')) @@ -43,37 +45,8 @@ const event = process.env.GITHUB_EVENT_PATH const prNumber = Number(values.pr ?? event?.pull_request?.number); if (!prNumber) throw new Error('No PR to process: pass --pr .'); -await processPullRequest(prNumber, values.sender ?? event?.sender?.login); - -async function processPullRequest(number: number, sender: string | undefined) { - if (sender === undefined) { - console.info('No sender: skipping the write access check.'); - } else if (!(await canWrite(gh, repo, sender))) { - console.info(`Ignored: ${sender} can't write to ${repo}.`); - return; - } - - const pr = await getPullRequest(gh, repo, number); - const [readiness, releasePullRequest] = await Promise.all([ - getReadiness(gh, repo, pr), - getReleasePullRequest(gh), - ]); - const mainRelease = getMainRelease(releasePullRequest?.versions); - const decision = decide(pr, readiness, mainRelease); - console.info(JSON.stringify({ pr: pr.url, readiness, mainRelease, decision }, null, 2)); - - const context: MessageContext = {}; - if (decision.route) { - const { umbrellaBranch, target } = decision.route; - context.umbrella = (await findOpenPullRequest(gh, repo, umbrellaBranch)) ?? { - title: getUmbrellaTitle(target, mainRelease.astroVersion), - }; - } - if (decision.refusals.some((refusal) => refusal.code === 'major-on-main')) { - context.mainUmbrella = await findOpenPullRequest(gh, repo, `${UMBRELLA_PREFIX}main`); - } - - await gh.write('POST', `repos/${repo}/issues/${number}/comments`, { - body: formatDryRunComment(decision, context), - }); -} +await handleAcceptedPullRequest( + { actionsClient, botClient, repo, mode }, + prNumber, + values.sender ?? event?.sender?.login +); From a5d53f30886e03411aaeff63465fa787f796d964 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Wed, 21 Oct 2026 23:47:31 +0200 Subject: [PATCH 03/35] Refresh release umbrellas after each join --- .github/workflows/release-queue.yml | 5 +- scripts/lib/release-queue/cli.test.ts | 35 +++ scripts/lib/release-queue/cli.ts | 31 ++ .../lib/release-queue/handle-accepted.test.ts | 194 ++++++++---- scripts/lib/release-queue/handle-accepted.ts | 144 ++++----- scripts/lib/release-queue/join.test.ts | 82 +++-- scripts/lib/release-queue/join.ts | 58 ++-- scripts/lib/release-queue/messages.test.ts | 20 +- scripts/lib/release-queue/messages.ts | 16 +- scripts/lib/release-queue/modes.test.ts | 38 +++ scripts/lib/release-queue/modes.ts | 43 +++ scripts/lib/release-queue/queries.ts | 59 ++-- scripts/lib/release-queue/refresh.test.ts | 288 ++++++++++++++++++ scripts/lib/release-queue/refresh.ts | 77 +++++ scripts/lib/release-queue/release.test.ts | 27 +- scripts/lib/release-queue/release.ts | 21 +- scripts/lib/release-queue/routing.test.ts | 56 +++- scripts/lib/release-queue/routing.ts | 42 ++- scripts/lib/release-queue/testing/fixtures.ts | 26 +- .../release-queue/testing/queue-options.ts | 17 ++ scripts/lib/release-queue/umbrella-queries.ts | 91 ++++++ scripts/lib/release-queue/umbrella-state.ts | 53 ++++ scripts/lib/release-queue/umbrella.test.ts | 159 +++++++++- scripts/lib/release-queue/umbrella.ts | 133 ++++++-- scripts/release-queue.ts | 36 ++- 25 files changed, 1401 insertions(+), 350 deletions(-) create mode 100644 scripts/lib/release-queue/cli.test.ts create mode 100644 scripts/lib/release-queue/cli.ts create mode 100644 scripts/lib/release-queue/modes.test.ts create mode 100644 scripts/lib/release-queue/modes.ts create mode 100644 scripts/lib/release-queue/refresh.test.ts create mode 100644 scripts/lib/release-queue/refresh.ts create mode 100644 scripts/lib/release-queue/testing/queue-options.ts create mode 100644 scripts/lib/release-queue/umbrella-queries.ts create mode 100644 scripts/lib/release-queue/umbrella-state.ts diff --git a/.github/workflows/release-queue.yml b/.github/workflows/release-queue.yml index f89073c3721e6..3fd9c5115e3fa 100644 --- a/.github/workflows/release-queue.yml +++ b/.github/workflows/release-queue.yml @@ -1,4 +1,6 @@ # Routes docs PRs labeled `accepted` to the umbrella PR of their release. +# Run it manually to refresh the open umbrella PRs, for example after the +# release PR of `withastro/astro` changed. # Dry run for now: only comments with the umbrella a PR would join, or why not. name: Release queue @@ -8,6 +10,7 @@ on: # 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 @@ -21,7 +24,7 @@ permissions: {} jobs: route: name: Route to release umbrella - if: ${{ github.repository_owner == 'withastro' && github.event.label.name == 'accepted' }} + 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 diff --git a/scripts/lib/release-queue/cli.test.ts b/scripts/lib/release-queue/cli.test.ts new file mode 100644 index 0000000000000..080f9c4952f59 --- /dev/null +++ b/scripts/lib/release-queue/cli.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { chooseAction } from './cli.ts'; + +describe('chooseAction', () => { + it('handles the PR of the event', () => { + assert.deepEqual(chooseAction({ eventName: 'pull_request_target', eventPullRequest: 7 }), { + type: 'handle', + number: 7, + }); + }); + + it('handles the PR passed with --pr', () => { + assert.deepEqual(chooseAction({ pr: '14580' }), { type: 'handle', number: 14580 }); + }); + + it('refreshes on a workflow_dispatch, or with --refresh', () => { + assert.deepEqual(chooseAction({ eventName: 'workflow_dispatch' }), { type: 'refresh' }); + assert.deepEqual(chooseAction({ refresh: 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 --refresh with --pr', () => { + assert.throws(() => chooseAction({ pr: '7', refresh: true }), /not both/); + }); +}); diff --git a/scripts/lib/release-queue/cli.ts b/scripts/lib/release-queue/cli.ts new file mode 100644 index 0000000000000..51c21a412a774 --- /dev/null +++ b/scripts/lib/release-queue/cli.ts @@ -0,0 +1,31 @@ +export type Action = { type: 'handle'; number: number } | { type: 'refresh' }; + +/** + * Chooses what a run does, from an explicit signal: a refresh of the open + * umbrellas (`workflow_dispatch`, or `--refresh` locally), or a PR to handle + * (`--pr`, or the PR of the `labeled` event). A missing or invalid PR number + * is an error, not a refresh. + */ +export function chooseAction({ + pr, + refresh = false, + eventName, + eventPullRequest, +}: { + pr?: string | undefined; + refresh?: boolean | undefined; + eventName?: string | undefined; + eventPullRequest?: number | undefined; +}): Action { + const shouldRefresh = refresh || eventName === 'workflow_dispatch'; + if (shouldRefresh && pr !== undefined) throw new Error('Pass --pr or --refresh, not both.'); + if (shouldRefresh) return { type: 'refresh' }; + + const value = pr ?? eventPullRequest?.toString(); + if (value === undefined) throw new Error('No PR to handle: pass --pr .'); + // Only digits: `Number()` would also read `1e3`, `0x10` or ` 7 `. + if (!/^\d+$/.test(value) || Number(value) === 0) { + throw new Error(`Invalid PR number: ${value}. Pass --pr .`); + } + return { type: 'handle', number: Number(value) }; +} diff --git a/scripts/lib/release-queue/handle-accepted.test.ts b/scripts/lib/release-queue/handle-accepted.test.ts index 44cede7dabf5b..4666871861f59 100644 --- a/scripts/lib/release-queue/handle-accepted.test.ts +++ b/scripts/lib/release-queue/handle-accepted.test.ts @@ -1,27 +1,35 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; import { GitHubError } from './github.ts'; -import { - handleAcceptedPullRequest, - needsBotToken, - type HandleOptions, - type Mode, -} from './handle-accepted.ts'; -import { pullRequestResponse, REPO } from './testing/fixtures.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, queueOptions } from './testing/queue-options.ts'; import { stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; +import { getUmbrellaBody } from './umbrella.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', mainUmbrella: `repos/${REPO}/pulls?state=open&head=withastro:release-queue/main`, }; -/** GET responses for PR #7, ready to join the umbrella of `base`. */ -function readyPullRequest({ base = 'main', label = 'minor-release' } = {}) { +/** + * 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({ + target: 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 { - [`repos/${REPO}/pulls/7`]: pullRequestResponse({ + [paths.pr]: pullRequestResponse({ base: { ref: base }, labels: [{ name: 'accepted' }, { name: label }], }), @@ -39,10 +47,11 @@ function readyPullRequest({ base = 'main', label = 'minor-release' } = {}) { }, ], [paths.releasePullRequest]: [{ number: 1, html_url: '', body: '## astro@7.5.0\n' }], - [`repos/${REPO}/pulls?state=open&head=withastro:${umbrellaBranch}`]: [ - { number: 42, html_url: `https://github.com/${REPO}/pull/42` }, - ], + [`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}/pulls?state=closed&base=${umbrellaBranch}`]: [], + [`repos/${REPO}/compare/${base}...${umbrellaBranch}`]: { ahead_by: 0, commits: [] }, }; } @@ -52,17 +61,15 @@ const unapprovedPullRequest = () => ({ ...readyPullRequest(), [paths.reviews]: [ /** Responses for a PR that opens the `main` umbrella with its first join. */ const firstJoin = () => ({ ...readyPullRequest(), [paths.mainUmbrella]: [] }); -// Distinct tokens tell the writes of each identity apart. -const ACTIONS_TOKEN = 'actions-token'; -const BOT_TOKEN = 'bot-token'; - -type StubbedGitHub = ReturnType; - -const handleOptions = (github: StubbedGitHub, mode: Mode): HandleOptions => ({ - actionsClient: github.client(ACTIONS_TOKEN), - botClient: github.client(BOT_TOKEN), - repo: REPO, - mode, +/** + * Responses where the `main` umbrella #42 is settled: it links release PR + * #18405, merged, while #1 is the open one. + */ +const settledUmbrella = () => ({ + ...readyPullRequest({ + umbrellaBody: getUmbrellaBody('main', { releasePullRequest: 18405, included: [] }), + }), + 'repos/withastro/astro/pulls/18405': { number: 18405, merged: true }, }); /** Stubs GitHub with PR #7 ready to join, unless `options` say otherwise. */ @@ -72,7 +79,7 @@ const stubReadyPullRequest = (t: TestContext, options: StubOptions = {}) => /** 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(handleOptions(github, mode), 7, sender); + await handleAcceptedPullRequest(queueOptions(github, mode), 7, sender); return { actionsWrites: github.writesBy(ACTIONS_TOKEN), botWrites: github.writesBy(BOT_TOKEN) }; } @@ -114,7 +121,36 @@ describe('handleAcceptedPullRequest', () => { `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('opens the umbrella PR with its first join, then labels it', async (t) => { + const { botWrites } = await run(t, 'main-only', { + getResponses: { + ...firstJoin(), + [`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 main-only mode', async (t) => { @@ -133,6 +169,8 @@ describe('handleAcceptedPullRequest', () => { `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`, ]); }); @@ -172,12 +210,81 @@ describe('handleAcceptedPullRequest', () => { 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, 'main-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, 'main-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(queueOptions(github, 'dry-run'), 7, '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(queueOptions(github, 'all'), 7, '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, 'main-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 }, + [`repos/${REPO}/compare/main...release-queue/main`]: { ahead_by: 3, commits: [] }, }, }); @@ -194,7 +301,7 @@ describe('handleAcceptedPullRequest', () => { }); await assert.rejects( - handleAcceptedPullRequest(handleOptions(github, 'main-only'), 7, 'maintainer'), + handleAcceptedPullRequest(queueOptions(github, 'main-only'), 7, 'maintainer'), (error: unknown) => { assert.ok(error instanceof Error); assert.match(error.message, /but the umbrella PR couldn't be opened\.$/); @@ -209,23 +316,16 @@ describe('handleAcceptedPullRequest', () => { ); }); - it("comments, then fails, when the umbrella labels couldn't be set", async (t) => { - const github = stubReadyPullRequest(t, { - getResponses: firstJoin(), - writeResults: { [`POST repos/${REPO}/pulls`]: { number: 99 } }, - writeFailures: { - [`POST repos/${REPO}/issues/99/labels`]: { status: 500, message: 'Server Error' }, - }, - }); + it('comments on the join, then fails, when the refresh fails', async (t) => { + const responses: Record = readyPullRequest(); + delete responses[`repos/${REPO}/pulls?state=closed&base=release-queue/main`]; + const github = stubReadyPullRequest(t, { getResponses: responses }); await assert.rejects( - handleAcceptedPullRequest(handleOptions(github, 'main-only'), 7, 'maintainer'), - /but the labels of the umbrella PR couldn't be set\.$/ - ); - assert.match( - commentBody(github.writesBy(BOT_TOKEN)), - /the branch of #99 into `main`, but its labels/ + handleAcceptedPullRequest(queueOptions(github, 'main-only'), 7, 'maintainer'), + { name: 'GitHubError', status: 404 } ); + assert.match(commentBody(github.writesBy(BOT_TOKEN)), /squash-merged into/); }); it('ignores a sender without write access', async (t) => { @@ -235,15 +335,3 @@ describe('handleAcceptedPullRequest', () => { assert.deepEqual(botWrites, []); }); }); - -describe('needsBotToken', () => { - it('is required to write in a live mode', () => { - assert.equal(needsBotToken('main-only', true), true); - assert.equal(needsBotToken('all', true), true); - }); - - it('is not required in a dry run, or when writes are only printed', () => { - assert.equal(needsBotToken('dry-run', true), false); - assert.equal(needsBotToken('all', false), false); - }); -}); diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts index d4331e3e0e5c0..4361a30f90451 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -1,16 +1,15 @@ -import type { GitHubClient } from './github.ts'; import { joinUmbrella } from './join.ts'; import { formatDryRunJoinComment, formatDryRunRefusalComment, - formatIncompleteJoinComment, formatJoinComment, formatRefusalComment, + formatUnopenedJoinComment, type MessageContext, } from './messages.ts'; +import { isLiveFor, type QueueOptions } from './modes.ts'; import { canWrite, - findOpenPullRequest, getPullRequest, getReadiness, getReleasePullRequest, @@ -18,6 +17,7 @@ import { removeLabel, type PullRequest, } from './queries.ts'; +import { refreshUmbrella } from './refresh.ts'; import { getMainRelease, type MainRelease } from './release.ts'; import { ACCEPTED_LABEL, @@ -28,47 +28,22 @@ import { type Decision, type RefusedDecision, type Route, + usesSettledUmbrella, } from './routing.ts'; -import { getUmbrellaReleaseLabel, getUmbrellaTitle, type Umbrella } from './umbrella.ts'; +import { findOpenUmbrella, getUmbrella } from './umbrella-queries.ts'; +import { getSettledMainUmbrella } from './umbrella-state.ts'; +import { getUmbrellaHeading, type Umbrella } from './umbrella.ts'; /** - * - `dry-run`: only comments with the umbrella a PR would join, or why not. - * - `main-only`: acts on PRs shipping to `main`, dry run for the others. - * - `all`: acts on every PR. - */ -export const MODES = ['dry-run', 'main-only', 'all'] as const; - -export type Mode = (typeof MODES)[number]; - -/** - * Whether the bot token is required. Without it, merges would go through - * `GITHUB_TOKEN`, and the umbrella PR would stop getting CI. - */ -export function needsBotToken(mode: Mode, shouldWrite: boolean): boolean { - return mode !== 'dry-run' && shouldWrite; -} - -export interface HandleOptions { - /** - * Reads the PR and the release PR, and posts dry-run comments, as - * `github-actions[bot]`. - */ - actionsClient: GitHubClient; - /** Joins and refuses PRs as the bot account, reads included. */ - botClient: GitHubClient; - repo: string; - mode: Mode; -} - -/** - * Processes a docs PR labeled `accepted`: reads the PR and the release PR of - * `withastro/astro`, decides, then comments as a dry run, refuses, or joins. + * Processes a docs PR labeled `accepted`: reads the PR, the release PR of + * `withastro/astro` and the state of the `main` umbrella, decides, then + * comments as a dry run, refuses, or joins. * * @param sender Who added the label. Without one, the write access check is * skipped. */ export async function handleAcceptedPullRequest( - options: HandleOptions, + options: QueueOptions, number: number, sender?: string ) { @@ -85,33 +60,32 @@ export async function handleAcceptedPullRequest( getReadiness(actionsClient, repo, pr), getReleasePullRequest(actionsClient), ]); - const mainRelease = getMainRelease(releasePullRequest?.versions); - const decision = decide(pr, readiness, mainRelease); - console.info(JSON.stringify({ pr: pr.url, readiness, mainRelease, decision }, null, 2)); + const release = getMainRelease(releasePullRequest); + const target = getTarget(pr.base); + const settledMainUmbrella = usesSettledUmbrella(pr) + ? await getSettledMainUmbrella(actionsClient, repo, release) + : undefined; + const decision = decide(pr, readiness, { release, settledMainUmbrella }); + console.info(JSON.stringify({ pr: pr.url, readiness, release, decision }, null, 2)); - if (!isLiveFor(options.mode, getTarget(pr.base))) { - await commentDryRun(options, number, decision, mainRelease); + if (!isLiveFor(options.mode, target)) { + await commentDryRun(options, number, decision, release); } else if (decision.status === 'refuse') { - await refuse(options, number, decision, mainRelease); + await refuse(options, number, decision, release); } else { - await join(options, pr, decision.route, mainRelease); + await join(options, pr, decision.route, release); } } -/** Whether the bot changes PRs shipping to `target`, rather than a dry run. */ -function isLiveFor(mode: Mode, target: string): boolean { - return mode === 'all' || (mode === 'main-only' && target === MAIN_BRANCH); -} - async function commentDryRun( - options: HandleOptions, + options: QueueOptions, number: number, decision: Decision, - mainRelease: MainRelease + release: MainRelease ) { const { actionsClient, repo } = options; if (decision.status === 'join') { - const umbrella = await findUmbrella(options, decision.route, mainRelease); + const umbrella = await findUmbrella(options, decision.route, release); await postComment( actionsClient, repo, @@ -120,79 +94,79 @@ async function commentDryRun( ); return; } - const context = await getMessageContext(options, decision, mainRelease); + const context = await getMessageContext(options, decision, release); await postComment(actionsClient, repo, number, formatDryRunRefusalComment(decision, context)); } async function refuse( - options: HandleOptions, + options: QueueOptions, number: number, decision: RefusedDecision, - mainRelease: MainRelease + release: MainRelease ) { const { botClient, repo } = options; - const context = await getMessageContext(options, decision, mainRelease); + const context = await getMessageContext(options, decision, release); // Removed first, so the comment is true once posted. await removeLabel(botClient, repo, number, ACCEPTED_LABEL); await postComment(botClient, repo, number, formatRefusalComment(decision, context)); } -async function join( - options: HandleOptions, - pr: PullRequest, - route: Route, - mainRelease: MainRelease -) { +async function join(options: QueueOptions, pr: PullRequest, route: Route, release: MainRelease) { const { botClient, repo } = options; - const result = await joinUmbrella(botClient, repo, pr, route, { - title: getUmbrellaTitle(route.target, mainRelease.astroVersion), - releaseLabel: getUmbrellaReleaseLabel(route.target, mainRelease.type), - }); + const { title } = getUmbrellaHeading(route.target, release); + const result = await joinUmbrella(botClient, repo, pr, route, title); switch (result.status) { - case 'joined': - await postComment(botClient, repo, pr.number, formatJoinComment(route, result.umbrella)); + case 'joined': { + const { umbrella } = result; + await postComment(botClient, repo, pr.number, formatJoinComment(route, umbrella)); + // Without a number, the umbrella creation was only printed. + if ('number' in umbrella) { + const openUmbrella = await getUmbrella(botClient, repo, umbrella.number); + await refreshUmbrella(botClient, repo, openUmbrella, release); + } return; + } case 'refused': { const decision: RefusedDecision = { status: 'refuse', route, refusals: [result.refusal] }; - await refuse(options, pr.number, decision, mainRelease); + await refuse(options, pr.number, decision, release); return; } + case 'unopened': + await postComment(botClient, repo, 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: { - await postComment(botClient, repo, pr.number, formatIncompleteJoinComment(route, result)); - // The PR is merged, but a maintainer needs to finish the join. - const missing = - result.status === 'unopened' - ? "the umbrella PR couldn't be opened" - : "the labels of the umbrella PR couldn't be set"; - throw new Error(`#${pr.number} was merged into ${route.umbrellaBranch}, but ${missing}.`, { - cause: result.error, - }); + const unknown: never = result; + throw new Error(`Unknown join result: ${JSON.stringify(unknown)}`); } } } /** Finds the open umbrella PR of `route`, or names the one to create. */ async function findUmbrella( - { actionsClient, repo }: HandleOptions, + { actionsClient, repo }: QueueOptions, route: Route, - mainRelease: MainRelease + release: MainRelease ): Promise { - const openUmbrella = await findOpenPullRequest(actionsClient, repo, route.umbrellaBranch); + const openUmbrella = await findOpenUmbrella(actionsClient, repo, route.umbrellaBranch); if (openUmbrella) return { number: openUmbrella.number }; - return { title: getUmbrellaTitle(route.target, mainRelease.astroVersion) }; + return { title: getUmbrellaHeading(route.target, release).title }; } async function getMessageContext( - options: HandleOptions, + options: QueueOptions, decision: RefusedDecision, - mainRelease: MainRelease + release: MainRelease ): Promise { const context: MessageContext = {}; - if (decision.route) context.umbrella = await findUmbrella(options, decision.route, mainRelease); + if (decision.route) context.umbrella = await findUmbrella(options, decision.route, release); if (decision.refusals.some((refusal) => refusal.code === 'major-on-main')) { const { actionsClient, repo } = options; - const mainUmbrella = await findOpenPullRequest( + const mainUmbrella = await findOpenUmbrella( actionsClient, repo, `${UMBRELLA_PREFIX}${MAIN_BRANCH}` diff --git a/scripts/lib/release-queue/join.test.ts b/scripts/lib/release-queue/join.test.ts index 56749e5e194d5..e32c5b942b0db 100644 --- a/scripts/lib/release-queue/join.test.ts +++ b/scripts/lib/release-queue/join.test.ts @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { GitHubError } from './github.ts'; -import { joinUmbrella, type NewUmbrella } from './join.ts'; +import { joinUmbrella } from './join.ts'; import type { PullRequest } from './queries.ts'; import type { Route } from './routing.ts'; -import { pullRequestResponse, REPO } from './testing/fixtures.ts'; +import { pullRequestResponse, REPO, umbrellaResponse } from './testing/fixtures.ts'; import { stubGitHub, type Write } from './testing/stub-github.ts'; const pr: PullRequest = { @@ -27,10 +27,7 @@ const route: Route = { shouldRetarget: true, }; -const newUmbrella: NewUmbrella = { - title: '[ci] release astro@7.5.0 docs', - releaseLabel: 'minor-release', -}; +const umbrellaTitle = '[ci] release astro@7.5.0 docs'; const paths = { openUmbrella: `repos/${REPO}/pulls?state=open&head=withastro:release-queue/main`, @@ -52,7 +49,7 @@ const withoutUmbrella = { const withUmbrella = { ...withoutUmbrella, - [paths.openUmbrella]: [{ number: 42, html_url: `https://github.com/${REPO}/pull/42` }], + [paths.openUmbrella]: [umbrellaResponse()], }; const createBranch: Write = { @@ -88,19 +85,13 @@ const openUmbrella: Write = { }, }; -const labelUmbrella: Write = { - method: 'POST', - path: `repos/${REPO}/issues/99/labels`, - body: { labels: ['merge-on-release', 'minor-release'] }, -}; - const createdUmbrella = { [`POST repos/${REPO}/pulls`]: { number: 99 } }; describe('joinUmbrella', () => { 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 joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); assert.deepEqual(result, { status: 'joined', umbrella: { number: 99 } }); assert.deepEqual(github.writes, [ @@ -108,19 +99,33 @@ describe('joinUmbrella', () => { setBase('release-queue/main'), merge, openUmbrella, - labelUmbrella, ]); }); it('joins the open umbrella PR', async (t) => { const github = stubGitHub(t, { getResponses: withUmbrella }); - const result = await joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); assert.deepEqual(result, { status: 'joined', umbrella: { 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 joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); + + assert.deepEqual(result, { status: 'joined', umbrella: { 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' }; @@ -130,7 +135,7 @@ describe('joinUmbrella', () => { REPO, umbrellaPr, { ...route, shouldRetarget: false }, - newUmbrella + umbrellaTitle ); assert.deepEqual(github.writes, [merge]); @@ -145,10 +150,10 @@ describe('joinUmbrella', () => { REPO, umbrellaPr, { ...route, shouldRetarget: false }, - newUmbrella + umbrellaTitle ); - assert.deepEqual(github.writes, [createBranch, merge, openUmbrella, labelUmbrella]); + assert.deepEqual(github.writes, [createBranch, merge, openUmbrella]); }); it('resets a leftover umbrella branch without commits', async (t) => { @@ -161,7 +166,7 @@ describe('joinUmbrella', () => { writeResults: createdUmbrella, }); - await joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); assert.deepEqual(github.writes, [ { @@ -172,7 +177,6 @@ describe('joinUmbrella', () => { setBase('release-queue/main'), merge, openUmbrella, - labelUmbrella, ]); }); @@ -185,7 +189,7 @@ describe('joinUmbrella', () => { }, }); - const result = await joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); assert.deepEqual(result, { status: 'refused', @@ -200,7 +204,7 @@ describe('joinUmbrella', () => { const github = stubGitHub(t, { getResponses: withoutMain }); await assert.rejects( - joinUmbrella(github.client(), REPO, pr, route, newUmbrella), + joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle), /doesn't exist/ ); assert.deepEqual(github.writes, []); @@ -211,7 +215,7 @@ describe('joinUmbrella', () => { getResponses: { ...withUmbrella, [paths.pr]: prResponse(null) }, }); - const result = await joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); assert.equal(result.status, 'joined'); assert.deepEqual(github.writes, [setBase('release-queue/main'), merge]); @@ -222,7 +226,7 @@ describe('joinUmbrella', () => { getResponses: { ...withUmbrella, [paths.pr]: prResponse(false) }, }); - const result = await joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); assert.deepEqual(result, { status: 'refused', @@ -242,7 +246,7 @@ describe('joinUmbrella', () => { }, }); - const result = await joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); assert.deepEqual(result, { status: 'refused', @@ -257,7 +261,7 @@ describe('joinUmbrella', () => { writeFailures: { [`PUT repos/${REPO}/pulls/7/merge`]: new TypeError('fetch failed') }, }); - await assert.rejects(joinUmbrella(github.client(), REPO, pr, route, newUmbrella), TypeError); + await assert.rejects(joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle), TypeError); assert.deepEqual(github.writes, [setBase('release-queue/main'), merge, setBase('main')]); }); @@ -269,7 +273,7 @@ describe('joinUmbrella', () => { }); await assert.rejects( - joinUmbrella(github.client(), REPO, pr, route, newUmbrella), + joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle), (error: unknown) => { assert.ok(error instanceof AggregateError); assert.deepEqual(error.errors, [restoreError]); @@ -291,7 +295,7 @@ describe('joinUmbrella', () => { }, }); - const result = await joinUmbrella(github.client(), REPO, pr, route, newUmbrella); + const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); assert.equal(result.status, 'unopened'); assert.ok(result.error instanceof GitHubError); @@ -304,30 +308,12 @@ describe('joinUmbrella', () => { ]); }); - it("reports an umbrella PR whose labels couldn't be set", async (t) => { - const github = stubGitHub(t, { - getResponses: withoutUmbrella, - writeResults: createdUmbrella, - writeFailures: { - [`POST repos/${REPO}/issues/99/labels`]: { status: 500, message: 'Server Error' }, - }, - }); - - const result = await joinUmbrella(github.client(), REPO, pr, route, newUmbrella); - - assert.equal(result.status, 'unlabeled'); - assert.deepEqual(result.umbrella, { number: 99 }); - assert.deepEqual(result.labels, ['merge-on-release', 'minor-release']); - assert.ok(result.error instanceof GitHubError); - assert.equal(result.error.reason, 'Server Error'); - }); - 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.client('token', { shouldPrintWrites: true }); - const result = await joinUmbrella(printingClient, REPO, pr, route, newUmbrella); + const result = await joinUmbrella(printingClient, REPO, pr, route, umbrellaTitle); assert.deepEqual(result, { status: 'joined', diff --git a/scripts/lib/release-queue/join.ts b/scripts/lib/release-queue/join.ts index 4b4a94851e5f4..dacca7663f004 100644 --- a/scripts/lib/release-queue/join.ts +++ b/scripts/lib/release-queue/join.ts @@ -1,51 +1,36 @@ import { GitHubError, type GitHubClient } from './github.ts'; -import { - countCommitsAhead, - findOpenPullRequest, - getBranchSha, - getPullRequest, - type PullRequest, -} from './queries.ts'; -import type { JoinRefusal, ReleaseLabel, Route } from './routing.ts'; +import { countCommitsAhead, getBranchSha, getPullRequest, type PullRequest } from './queries.ts'; +import type { JoinRefusal, Route } from './routing.ts'; +import { findOpenUmbrella } from './umbrella-queries.ts'; import type { Umbrella } from './umbrella.ts'; -export const MERGE_ON_RELEASE_LABEL = 'merge-on-release'; - -/** What the umbrella PR gets when the first join creates it. */ -export interface NewUmbrella { - title: string; - releaseLabel: ReleaseLabel; -} - /** - * The outcome of a join. `unopened` and `unlabeled` mean the PR was merged - * into the umbrella branch, but the umbrella PR couldn't be opened, or its - * labels couldn't be set. + * The outcome of a join. `unopened` means the PR was merged into the umbrella + * branch, but the umbrella PR couldn't be opened. */ export type JoinResult = | { status: 'joined'; umbrella: Umbrella } | { status: 'refused'; refusal: JoinRefusal } - | { status: 'unopened'; error: unknown } - | { status: 'unlabeled'; umbrella: { number: number }; labels: string[]; error: unknown }; + | { status: 'unopened'; error: unknown }; -/** A join that merged the PR, but couldn't finish. */ -export type IncompleteJoin = Extract; +/** A join that merged the PR, but couldn't open the umbrella PR. */ +export type UnopenedJoin = Extract; /** * Squash-merges a docs PR into its umbrella branch. Prepares the branch before * changing the PR, then changes the PR base, merges, and opens the umbrella - * PR if none is open: GitHub refuses a PR without commits. A refused merge - * restores the PR base. Expected failures come back as a result, anything - * else throws. + * PR titled `umbrellaTitle` if none is open: GitHub refuses a PR without + * commits. A refused merge restores the PR base. Expected failures come back + * as a result, anything else throws. The refresh sets the labels and body. */ export async function joinUmbrella( gh: GitHubClient, repo: string, pr: PullRequest, route: Route, - newUmbrella: NewUmbrella + umbrellaTitle: string ): Promise { - const openUmbrella = await findOpenPullRequest(gh, repo, route.umbrellaBranch); + const openUmbrella = await findOpenUmbrella(gh, repo, route.umbrellaBranch); if (!openUmbrella) { const refusal = await prepareUmbrellaBranch(gh, repo, route); if (refusal) return { status: 'refused', refusal }; @@ -56,7 +41,7 @@ export async function joinUmbrella( if (refusal) return { status: 'refused', refusal }; if (openUmbrella) return { status: 'joined', umbrella: { number: openUmbrella.number } }; - return createUmbrella(gh, repo, route, newUmbrella); + return createUmbrella(gh, repo, route, umbrellaTitle); } /** @@ -165,8 +150,8 @@ async function createUmbrella( gh: GitHubClient, repo: string, { target, umbrellaBranch }: Route, - { title, releaseLabel }: NewUmbrella -): Promise | IncompleteJoin> { + title: string +): Promise | UnopenedJoin> { let created: { number: number } | undefined; try { created = await gh.write<{ number: number }>('POST', `repos/${repo}/pulls`, { @@ -178,14 +163,5 @@ async function createUmbrella( } catch (error) { return { status: 'unopened', error }; } - if (!created) return { status: 'joined', umbrella: { title } }; - - const umbrella = { number: created.number }; - const labels = [MERGE_ON_RELEASE_LABEL, releaseLabel]; - try { - await gh.write('POST', `repos/${repo}/issues/${umbrella.number}/labels`, { labels }); - } catch (error) { - return { status: 'unlabeled', umbrella, labels, error }; - } - return { status: 'joined', umbrella }; + return { status: 'joined', umbrella: created ? { number: created.number } : { title } }; } diff --git a/scripts/lib/release-queue/messages.test.ts b/scripts/lib/release-queue/messages.test.ts index e108d3da9dcf0..9dffdd53545df 100644 --- a/scripts/lib/release-queue/messages.test.ts +++ b/scripts/lib/release-queue/messages.test.ts @@ -5,7 +5,7 @@ import { describeRefusal, formatDryRunJoinComment, formatDryRunRefusalComment, - formatIncompleteJoinComment, + formatUnopenedJoinComment, formatJoinComment, formatRefusalComment, } from './messages.ts'; @@ -29,6 +29,7 @@ describe('describeRefusal', () => { { code: 'mismatch', base: 'main', label: 'major-release' }, { code: 'mismatch', base: 'v8', label: 'minor-release' }, { code: 'major-on-main', astroVersion: '8.0.0' }, + { code: 'settled-umbrella', number: 42, releasePullRequest: 18405 }, { code: 'not-approved' }, { code: 'changes-requested', reviewers: ['alice'] }, { code: 'failing-checks', checks: ['Check Links'] }, @@ -148,29 +149,18 @@ describe('formatJoinComment', () => { }); }); -describe('formatIncompleteJoinComment', () => { +describe('formatUnopenedJoinComment', () => { const error = new GitHubError({ status: 422, reason: 'Validation Failed', request: 'POST' }); it("asks a maintainer to open the umbrella PR that couldn't be opened", () => { assert.equal( - formatIncompleteJoinComment(route, { status: 'unopened', error }), + 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("asks a maintainer to add the labels that couldn't be set", () => { - const comment = formatIncompleteJoinComment(route, { - status: 'unlabeled', - umbrella: { number: 99 }, - labels: ['merge-on-release', 'patch-release'], - error, - }); - assert.match(comment, /the branch of #99 into `main`, but its labels couldn't be set/); - assert.match(comment, /add `merge-on-release`, `patch-release`\.$/); - }); - it('describes an error that is not from GitHub', () => { - const comment = formatIncompleteJoinComment(route, { + const comment = formatUnopenedJoinComment(route, { status: 'unopened', error: new TypeError('fetch failed'), }); diff --git a/scripts/lib/release-queue/messages.ts b/scripts/lib/release-queue/messages.ts index b0e27d8e5af34..6ddcd79fe8320 100644 --- a/scripts/lib/release-queue/messages.ts +++ b/scripts/lib/release-queue/messages.ts @@ -1,5 +1,5 @@ import { GitHubError } from './github.ts'; -import type { IncompleteJoin } from './join.ts'; +import type { UnopenedJoin } from './join.ts'; import { ACCEPTED_LABEL, type Refusal, @@ -45,6 +45,8 @@ const REFUSAL_DESCRIPTIONS: { [C in RefusalCode]: DescribeRefusal } = { : ''; return `The next \`astro\` release from \`main\` is a major${release}: docs for it belong on the prerelease branch. Change the base to the prerelease branch and the label to \`major-release\`.${umbrella}`; }, + 'settled-umbrella': ({ number, releasePullRequest }) => + `#${number} ships with withastro/astro#${releasePullRequest}, which is already merged. Merge #${number} first. If this PR documents that release, change its base to \`release-queue/main\` instead.`, '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.`, @@ -126,13 +128,9 @@ export function formatJoinComment(route: Route, umbrella: Umbrella): string { return `**Release queue**: squash-merged into ${code(route.umbrellaBranch)}, the branch of ${describeUmbrella(route, umbrella)}. It ships when the umbrella PR is merged.`; } -/** Writes the comment of a join that merged the PR, but couldn't finish. */ -export function formatIncompleteJoinComment(route: Route, result: IncompleteJoin): string { +/** Writes the comment of a merged PR whose umbrella PR couldn't be opened. */ +export function formatUnopenedJoinComment(route: Route, { error }: UnopenedJoin): string { const branch = code(route.umbrellaBranch); - const reason = sentence(describeError(result.error)); - if (result.status === 'unopened') { - return `**Release queue**: squash-merged into ${branch}, but the umbrella PR into ${code(route.target)} couldn't be opened: ${reason} A maintainer needs to open it from ${branch}.`; - } - const umbrella = describeUmbrella(route, result.umbrella); - return `**Release queue**: squash-merged into ${branch}, the branch of ${umbrella}, but its labels couldn't be set: ${reason} A maintainer needs to add ${list(result.labels)}.`; + const reason = sentence(describeError(error)); + return `**Release queue**: squash-merged into ${branch}, but the umbrella PR into ${code(route.target)} couldn't be opened: ${reason} A maintainer needs to open it from ${branch}.`; } diff --git a/scripts/lib/release-queue/modes.test.ts b/scripts/lib/release-queue/modes.test.ts new file mode 100644 index 0000000000000..5a73660db2249 --- /dev/null +++ b/scripts/lib/release-queue/modes.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { isLiveFor, needsBotToken, 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('main-only'), 'main-only'); + assert.throws(() => parseMode('main'), /Unknown MODE: main\. Expected one of: dry-run/); + }); +}); + +describe('isLiveFor', () => { + it('acts on main only in main-only mode', () => { + assert.equal(isLiveFor('main-only', 'main'), true); + assert.equal(isLiveFor('main-only', 'cloudflare-next'), false); + }); + + it('acts on every target in all mode, and none in a dry run', () => { + assert.equal(isLiveFor('all', 'cloudflare-next'), true); + assert.equal(isLiveFor('dry-run', 'main'), false); + }); +}); + +describe('needsBotToken', () => { + it('is required to write in a live mode', () => { + assert.equal(needsBotToken('main-only', true), true); + assert.equal(needsBotToken('all', true), true); + }); + + it('is not required in a dry run, or when writes are only printed', () => { + assert.equal(needsBotToken('dry-run', true), false); + assert.equal(needsBotToken('all', false), false); + }); +}); diff --git a/scripts/lib/release-queue/modes.ts b/scripts/lib/release-queue/modes.ts new file mode 100644 index 0000000000000..72e644829a125 --- /dev/null +++ b/scripts/lib/release-queue/modes.ts @@ -0,0 +1,43 @@ +import type { GitHubClient } from './github.ts'; +import { MAIN_BRANCH } from './routing.ts'; + +/** + * - `dry-run`: only comments with the umbrella a PR would join, or why not. + * - `main-only`: acts on PRs shipping to `main`, dry run for the others. + * - `all`: acts on every PR. + */ +export const MODES = ['dry-run', 'main-only', 'all'] as const; + +export type Mode = (typeof MODES)[number]; + +/** What the bot runs with, whether it handles a PR or refreshes umbrellas. */ +export interface QueueOptions { + /** + * Reads the PR and the release PR, and posts dry-run comments, as + * `github-actions[bot]`. + */ + actionsClient: GitHubClient; + /** Joins, refuses, and refreshes as the bot account, reads included. */ + botClient: GitHubClient; + repo: string; + mode: Mode; +} + +export function parseMode(value = 'dry-run'): Mode { + const mode = MODES.find((candidate) => candidate === value); + if (!mode) throw new Error(`Unknown MODE: ${value}. Expected one of: ${MODES.join(', ')}.`); + return mode; +} + +/** Whether the bot changes PRs shipping to `target`, rather than a dry run. */ +export function isLiveFor(mode: Mode, target: string): boolean { + return mode === 'all' || (mode === 'main-only' && target === MAIN_BRANCH); +} + +/** + * Whether the bot token is required. Without it, merges would go through + * `GITHUB_TOKEN`, and the umbrella PR would stop getting CI. + */ +export function needsBotToken(mode: Mode, shouldWrite: boolean): boolean { + return mode !== 'dry-run' && shouldWrite; +} diff --git a/scripts/lib/release-queue/queries.ts b/scripts/lib/release-queue/queries.ts index af8b8aa17b5e7..756e145b9444e 100644 --- a/scripts/lib/release-queue/queries.ts +++ b/scripts/lib/release-queue/queries.ts @@ -7,7 +7,7 @@ import { type CommitStatus, type Readiness, } from './readiness.ts'; -import { parseReleaseVersions, type PackageVersion } from './release.ts'; +import { parseReleaseVersions, type ReleasePullRequest } from './release.ts'; import { MAIN_BRANCH, type DocsPullRequest } from './routing.ts'; const FRAMEWORK_REPO = 'withastro/astro'; @@ -21,25 +21,22 @@ export interface PullRequest extends DocsPullRequest { isMergeable: boolean | null; } -export interface ReleasePullRequest { - number: number; - url: string; - versions: PackageVersion[]; -} - // The fields the bot reads from GitHub API responses. -interface PullRequestResponse { +export interface PullRequestResponse { number: number; title: string; html_url: string; state: string; draft: boolean; mergeable: boolean | null; + merge_commit_sha: string | null; base: { ref: string }; - head: { ref: string; sha: 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; } interface ReviewResponse { @@ -84,15 +81,6 @@ function getPullRequestWithMergeability(gh: GitHubClient, repo: string, number: }); } -/** Finds the open PR whose head is `branch`, such as an umbrella PR. */ -export async function findOpenPullRequest(gh: GitHubClient, repo: string, branch: string) { - const [owner] = repo.split('/'); - const [pr] = await gh.get( - `repos/${repo}/pulls?state=open&head=${owner}:${branch}` - ); - return pr ? { number: pr.number, url: pr.html_url } : undefined; -} - /** Gets the commit a branch points to, or `undefined` if it doesn't exist. */ export async function getBranchSha( gh: GitHubClient, @@ -117,10 +105,25 @@ export async function countCommitsAhead( base: string, head: string ) { - const { ahead_by } = await gh.get<{ ahead_by: number }>( + const { ahead_by } = await compare(gh, repo, base, head); + return ahead_by; +} + +/** Lists the commits of `head` that aren't in `base`, oldest first. */ +export async function listCommitsAhead( + gh: GitHubClient, + repo: string, + base: string, + head: string +): Promise { + const { commits } = await compare(gh, repo, base, head); + return commits.map(({ sha }) => sha); +} + +function compare(gh: GitHubClient, repo: string, base: string, head: string) { + return gh.get<{ ahead_by: number; commits: { sha: string }[] }>( `repos/${repo}/compare/${base}...${head}` ); - return ahead_by; } export async function postComment(gh: GitHubClient, repo: string, number: number, body: string) { @@ -209,5 +212,19 @@ export async function getReleasePullRequest( `repos/${FRAMEWORK_REPO}/pulls?state=open&head=${owner}:changeset-release/main` ); if (!pr) return undefined; - return { number: pr.number, url: pr.html_url, versions: parseReleaseVersions(pr.body ?? '') }; + return { number: pr.number, versions: parseReleaseVersions(pr.body ?? '') }; +} + +/** + * Whether a PR of `withastro/astro`, such as a release PR, is merged. A PR + * that doesn't exist, for example from an edited marker, isn't. + */ +export async function isFrameworkPullRequestMerged(gh: GitHubClient, number: number) { + try { + const { merged } = await gh.get(`repos/${FRAMEWORK_REPO}/pulls/${number}`); + return merged === true; + } catch (error) { + if (error instanceof GitHubError && error.status === 404) return false; + throw error; + } } diff --git a/scripts/lib/release-queue/refresh.test.ts b/scripts/lib/release-queue/refresh.test.ts new file mode 100644 index 0000000000000..bdd39b7926c63 --- /dev/null +++ b/scripts/lib/release-queue/refresh.test.ts @@ -0,0 +1,288 @@ +import assert from 'node:assert/strict'; +import { describe, it, type TestContext } from 'node:test'; +import { refreshOpenUmbrellas, refreshUmbrella } from './refresh.ts'; +import { getMainRelease } from './release.ts'; +import { pullRequestResponse, REPO, umbrellaResponse } from './testing/fixtures.ts'; +import { BOT_TOKEN, queueOptions } from './testing/queue-options.ts'; +import { stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; +import type { OpenUmbrella } from './umbrella-queries.ts'; +import { getUmbrellaBody } from './umbrella.ts'; + +/** The next release from `main`, with release PR `number` open. */ +const releaseWith = (number: number, astroVersion: string) => + getMainRelease({ + number, + versions: [{ name: 'astro', version: astroVersion }], + }); + +const noRelease = getMainRelease(); + +const included = [{ number: 7, title: 'Document the new option' }]; + +/** The `main` umbrella, up to date with release PR #18405 (`astro@7.5.0`). */ +const umbrella: OpenUmbrella = { + number: 42, + title: '[ci] release astro@7.5.0 docs', + body: getUmbrellaBody('main', { releasePullRequest: 18405, included: [] }), + labels: ['merge-on-release', 'minor-release', 'i18n'], + target: 'main', + branch: 'release-queue/main', +}; + +/** #7 is on the umbrella branch. #3 had the same base in an earlier release. */ +const history = (target = 'main') => ({ + [`repos/${REPO}/pulls?state=closed&base=release-queue/${target}`]: [ + pullRequestResponse({ number: 3, title: 'Earlier release', merge_commit_sha: 'old-sha' }), + pullRequestResponse({ number: 7, title: 'Document the new option', merge_commit_sha: 'sha-7' }), + ], + [`repos/${REPO}/compare/${target}...release-queue/${target}`]: { + ahead_by: 1, + commits: [{ sha: 'sha-7' }], + }, +}); + +/** 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: { ...history(), ...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 = (releasePullRequest?: number) => + getUmbrellaBody('main', { releasePullRequest, included }); + +describe('refreshUmbrella', () => { + 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 refreshUmbrella(github.client(), REPO, 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 refreshUmbrella(github.client(), REPO, 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 refreshUmbrella(github.client(), REPO, 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 refreshUmbrella(github.client(), REPO, 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 refreshUmbrella(github.client(), REPO, 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 refreshUmbrella(github.client(), REPO, 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 refreshUmbrella(github.client(), REPO, 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 refreshUmbrella(github.client(), REPO, umbrella, releaseWith(18405, '8.0.0')); + + assert.deepEqual(github.calls, []); + }); + }); + + 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 refreshUmbrella(github.client(), REPO, 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 refreshUmbrella(github.client(), REPO, 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 refreshUmbrella(github.client(), REPO, 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: history('cloudflare-next') }); + const prerelease: OpenUmbrella = { + number: 43, + title: 'Old title', + body: '', + labels: [], + target: 'cloudflare-next', + branch: 'release-queue/cloudflare-next', + }; + + await refreshUmbrella(github.client(), REPO, prerelease, releaseWith(18405, '7.5.0')); + + assert.deepEqual(patchOf(github.writes), { + title: '[ci] release cloudflare-next docs', + body: getUmbrellaBody('cloudflare-next', { included }), + }); + assert.deepEqual(labelWrites(github.writes), [ + { + method: 'POST', + path: `repos/${REPO}/issues/43/labels`, + body: { labels: ['merge-on-release', 'major-release'] }, + }, + ]); + }); + + it("doesn't write an umbrella that is up to date", async (t) => { + const github = stub(t); + // As GitHub returns a description edited in the browser. + const upToDate = { ...umbrella, body: umbrellaBody(18405).replaceAll('\n', '\r\n') }; + + await refreshUmbrella(github.client(), REPO, upToDate, releaseWith(18405, '7.5.0')); + + assert.deepEqual(github.writes, []); + }); +}); + +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, target: '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' }, + ], + ...history(), + ...history('cloudflare-next'), + }; + + const refreshedUmbrellas = (writes: Write[]) => + [...new Set(writes.map(({ path }) => path.match(/\/(?:pulls|issues)\/(\d+)/)?.[1]))].map( + Number + ); + + it("doesn't change anything in a dry run", async (t) => { + const github = stubGitHub(t, { getResponses: openPullRequests }); + + await refreshOpenUmbrellas(queueOptions(github, 'dry-run')); + + assert.deepEqual(github.writes, []); + }); + + it('refreshes only the main umbrella in main-only mode', async (t) => { + const github = stubGitHub(t, { getResponses: openPullRequests }); + + await refreshOpenUmbrellas(queueOptions(github, 'main-only')); + + assert.deepEqual(refreshedUmbrellas(github.writesBy(BOT_TOKEN)), [42]); + }); + + it('refreshes every umbrella in all mode, but not a fork or another base', async (t) => { + const github = stubGitHub(t, { getResponses: openPullRequests }); + + await refreshOpenUmbrellas(queueOptions(github, 'all')); + + assert.deepEqual(refreshedUmbrellas(github.writesBy(BOT_TOKEN)), [42, 43]); + }); +}); diff --git a/scripts/lib/release-queue/refresh.ts b/scripts/lib/release-queue/refresh.ts new file mode 100644 index 0000000000000..339a0e25b46e7 --- /dev/null +++ b/scripts/lib/release-queue/refresh.ts @@ -0,0 +1,77 @@ +import type { GitHubClient } from './github.ts'; +import { isLiveFor, type QueueOptions } from './modes.ts'; +import { getReleasePullRequest, removeLabel } from './queries.ts'; +import { getMainRelease, type MainRelease } from './release.ts'; +import { MAIN_BRANCH } from './routing.ts'; +import { getUmbrellaHistory, listOpenUmbrellas, type OpenUmbrella } from './umbrella-queries.ts'; +import { getReleaseState } from './umbrella-state.ts'; +import { + getKeptLabelChanges, + getLabelChanges, + getUmbrellaBody, + getUmbrellaHeading, + selectIncludedPullRequests, +} from './umbrella.ts'; + +/** + * Brings an umbrella PR up to date, writing only what changed. Its body + * always lists the included docs PRs. Its title, release label and linked + * release PR follow the open release PR, unless the umbrella is `kept` or + * `settled`. Leaves the `main` umbrella as it is before a major: a + * maintainer moves it to the prerelease branch. + */ +export async function refreshUmbrella( + gh: GitHubClient, + repo: string, + umbrella: OpenUmbrella, + release: MainRelease +) { + const state = await getReleaseState(gh, umbrella, release); + const heading = getUmbrellaHeading(umbrella.target, release); + const isFollowing = state.status === 'follows'; + if (isFollowing && umbrella.target === MAIN_BRANCH && release.type === 'major') { + console.info(`Skipped #${umbrella.number}: the next release from main is a major.`); + return; + } + + const { commits, merged } = await getUmbrellaHistory(gh, repo, umbrella); + const body = getUmbrellaBody(umbrella.target, { + releasePullRequest: isFollowing ? heading.releasePullRequest : state.releasePullRequest, + included: selectIncludedPullRequests(merged, commits), + }); + + const changes: { title?: string; body?: string } = {}; + if (isFollowing && heading.title !== umbrella.title) changes.title = heading.title; + // GitHub keeps the line endings of a description edited in the browser. + if (body !== umbrella.body.replaceAll('\r\n', '\n')) changes.body = body; + if (Object.keys(changes).length > 0) { + await gh.write('PATCH', `repos/${repo}/pulls/${umbrella.number}`, changes); + } + + const { toAdd, toRemove } = isFollowing + ? getLabelChanges(umbrella.labels, heading.releaseLabel) + : getKeptLabelChanges(umbrella.labels); + if (toAdd.length > 0) { + await gh.write('POST', `repos/${repo}/issues/${umbrella.number}/labels`, { labels: toAdd }); + } + await Promise.all(toRemove.map((label) => removeLabel(gh, repo, umbrella.number, label))); +} + +/** Refreshes every open umbrella PR, for example after a release PR change. */ +export async function refreshOpenUmbrellas({ actionsClient, botClient, repo, mode }: QueueOptions) { + const [umbrellas, releasePullRequest] = await Promise.all([ + listOpenUmbrellas(actionsClient, repo), + getReleasePullRequest(actionsClient), + ]); + const release = getMainRelease(releasePullRequest); + const numbers = umbrellas.map(({ number }) => `#${number}`).join(', '); + console.info(`Open umbrellas: ${numbers || 'none'}.`); + + for (const umbrella of umbrellas) { + if (!isLiveFor(mode, umbrella.target)) { + console.info(`Skipped #${umbrella.number}: dry run for ${umbrella.target}.`); + continue; + } + await refreshUmbrella(botClient, repo, umbrella, release); + } +} diff --git a/scripts/lib/release-queue/release.test.ts b/scripts/lib/release-queue/release.test.ts index ac2615e015677..a15c969c9535c 100644 --- a/scripts/lib/release-queue/release.test.ts +++ b/scripts/lib/release-queue/release.test.ts @@ -56,25 +56,32 @@ describe('getReleaseType', () => { }); describe('getMainRelease', () => { - it('reads a patch when there is no release PR', () => { - assert.deepEqual(getMainRelease(), { type: 'patch', astroVersion: undefined }); + const pullRequest = (versions: { name: string; version: string }[]) => ({ + number: 18459, + versions, }); - it('reads a patch when astro is not released', () => { - assert.deepEqual(getMainRelease([{ name: '@astrojs/vercel', version: '11.1.0' }]), { + it('reads a patch when there is no release PR', () => { + assert.deepEqual(getMainRelease(), { type: 'patch', astroVersion: undefined, + pullRequest: undefined, }); }); - it('reads the release type from the astro version', () => { - assert.deepEqual(getMainRelease([{ name: 'astro', version: '7.5.0' }]), { + it('reads a patch when astro is not released', () => { + const release = getMainRelease(pullRequest([{ name: '@astrojs/vercel', version: '11.1.0' }])); + assert.equal(release.type, 'patch'); + assert.equal(release.astroVersion, 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(getMainRelease(minor), { type: 'minor', astroVersion: '7.5.0', + pullRequest: minor, }); - assert.deepEqual(getMainRelease([{ name: 'astro', version: '8.0.0' }]), { - type: 'major', - astroVersion: '8.0.0', - }); + assert.equal(getMainRelease(pullRequest([{ name: 'astro', version: '8.0.0' }])).type, 'major'); }); }); diff --git a/scripts/lib/release-queue/release.ts b/scripts/lib/release-queue/release.ts index cf187ad94b741..0853c05d266a1 100644 --- a/scripts/lib/release-queue/release.ts +++ b/scripts/lib/release-queue/release.ts @@ -5,10 +5,18 @@ export interface PackageVersion { export type ReleaseType = 'patch' | 'minor' | 'major'; +/** A `[ci] release` PR of `withastro/astro`. */ +export interface ReleasePullRequest { + number: number; + versions: PackageVersion[]; +} + /** The next release from the `main` branch of `withastro/astro`. */ export interface MainRelease { type: ReleaseType; - astroVersion?: string; + astroVersion?: string | undefined; + /** The open release PR. Without one, the release is a patch. */ + pullRequest?: ReleasePullRequest | undefined; } /** Reads the `## @` headings of a release PR body. */ @@ -36,11 +44,8 @@ export function getReleaseType(version: string | undefined): ReleaseType { return minor === '0' ? 'major' : 'minor'; } -/** - * Gets the next release from `main` from the versions of its release PR, or - * from none when there is no open release PR. - */ -export function getMainRelease(versions: PackageVersion[] = []): MainRelease { - const astroVersion = findVersion(versions, 'astro'); - return { type: getReleaseType(astroVersion), astroVersion }; +/** Gets the next release from `main` from its open release PR, if any. */ +export function getMainRelease(pullRequest?: ReleasePullRequest): MainRelease { + const astroVersion = findVersion(pullRequest?.versions ?? [], 'astro'); + return { type: getReleaseType(astroVersion), astroVersion, pullRequest }; } diff --git a/scripts/lib/release-queue/routing.test.ts b/scripts/lib/release-queue/routing.test.ts index 025aee2c0d669..1f04587f33d91 100644 --- a/scripts/lib/release-queue/routing.test.ts +++ b/scripts/lib/release-queue/routing.test.ts @@ -1,8 +1,14 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { Readiness } from './readiness.ts'; -import type { MainRelease } from './release.ts'; -import { decide, getTarget, type Decision, type DocsPullRequest } from './routing.ts'; +import { + decide, + getTarget, + type DecideContext, + type Decision, + type DocsPullRequest, + usesSettledUmbrella, +} from './routing.ts'; const pr = (overrides: Partial = {}): DocsPullRequest => ({ state: 'open', @@ -22,7 +28,13 @@ const ready: Readiness = { isMergeable: true, }; -const patch: MainRelease = { type: 'patch', astroVersion: '7.4.1' }; +const patch: DecideContext = { release: { type: 'patch', astroVersion: '7.4.1' } }; +const major: DecideContext = { release: { type: 'major', astroVersion: '8.0.0' } }; +/** The `main` umbrella #42 ships with a merged release PR. */ +const settled: DecideContext = { + ...patch, + settledMainUmbrella: { number: 42, releasePullRequest: 18405 }, +}; const refusalsOf = (decision: Decision) => (decision.status === 'refuse' ? decision.refusals : []); @@ -43,10 +55,11 @@ describe('decide', () => { } it('to a prerelease umbrella with major-release', () => { - const decision = decide(pr({ base: 'cloudflare-next', labels: ['major-release'] }), ready, { - type: 'major', - astroVersion: '8.0.0', - }); + const decision = decide( + pr({ base: 'cloudflare-next', labels: ['major-release'] }), + ready, + major + ); assert.deepEqual(decision, { status: 'join', route: { @@ -126,7 +139,7 @@ describe('decide', () => { }); it('a main PR when the next astro release from main is a major', () => { - const decision = decide(pr(), ready, { type: 'major', astroVersion: '8.0.0' }); + const decision = decide(pr(), ready, major); assert.deepEqual(refusalsOf(decision), [{ code: 'major-on-main', astroVersion: '8.0.0' }]); }); @@ -161,6 +174,12 @@ describe('decide', () => { }); }); + it('a main PR while the main umbrella is settled', () => { + assert.deepEqual(refusalsOf(decide(pr(), ready, settled)), [ + { code: 'settled-umbrella', number: 42, releasePullRequest: 18405 }, + ]); + }); + it('a routed PR that is not ready, keeping its route', () => { const decision = decide(pr(), { ...ready, isApproved: false }, patch); assert.equal(decision.status, 'refuse'); @@ -168,6 +187,15 @@ describe('decide', () => { }); }); + it('routes a PR against the settled umbrella branch, which says where it belongs', () => { + assert.equal(decide(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(decide(prerelease, ready, settled).status, 'join'); + }); + it('lets the merge decide while mergeability is unknown', () => { assert.equal(decide(pr(), { ...ready, isMergeable: null }, patch).status, 'join'); }); @@ -184,3 +212,15 @@ describe('getTarget', () => { assert.equal(getTarget('release-queue/cloudflare-next'), 'cloudflare-next'); }); }); + +describe('usesSettledUmbrella', () => { + it('applies to an open PR against main', () => { + assert.equal(usesSettledUmbrella(pr()), true); + }); + + it("doesn't apply to a PR against the umbrella branch, a prerelease, or a closed PR", () => { + assert.equal(usesSettledUmbrella(pr({ base: 'release-queue/main' })), false); + assert.equal(usesSettledUmbrella(pr({ base: 'cloudflare-next' })), false); + assert.equal(usesSettledUmbrella(pr({ state: 'closed' })), false); + }); +}); diff --git a/scripts/lib/release-queue/routing.ts b/scripts/lib/release-queue/routing.ts index 1eac822533ae1..ac142466978cd 100644 --- a/scripts/lib/release-queue/routing.ts +++ b/scripts/lib/release-queue/routing.ts @@ -25,6 +25,22 @@ export interface Route { shouldRetarget: boolean; } +/** + * The open `main` umbrella once the release PR it ships with is merged, until + * the umbrella itself is merged. + */ +export interface SettledUmbrella { + number: number; + /** The merged release PR of `withastro/astro`. */ + releasePullRequest: number; +} + +/** What `decide` needs besides the PR. */ +export interface DecideContext { + release: MainRelease; + settledMainUmbrella?: SettledUmbrella | undefined; +} + /** The details of each refusal, by code. */ interface RefusalDetails { closed: unknown; @@ -34,6 +50,7 @@ interface RefusalDetails { 'several-release-labels': { labels: ReleaseLabel[] }; mismatch: { base: string; label: ReleaseLabel }; 'major-on-main': { astroVersion?: string | undefined }; + 'settled-umbrella': SettledUmbrella; 'not-approved': unknown; 'changes-requested': { reviewers: string[] }; 'failing-checks': { checks: string[] }; @@ -71,6 +88,15 @@ export function isUmbrellaBranch(branch: string): boolean { return branch.startsWith(UMBRELLA_PREFIX); } +/** + * Whether a settled `main` umbrella refuses this PR: an open PR the bot would + * retarget from `main`. A PR against the umbrella branch says it belongs to + * that umbrella. The handler reads the settled state only for these PRs. + */ +export function usesSettledUmbrella(pr: DocsPullRequest): boolean { + return pr.state === 'open' && pr.base === MAIN_BRANCH; +} + /** Gets the branch a docs PR ships to: its base, or its umbrella's base. */ export function getTarget(base: string): string { return isUmbrellaBranch(base) ? base.slice(UMBRELLA_PREFIX.length) : base; @@ -80,14 +106,18 @@ export function getTarget(base: string): string { * Decides whether a docs PR joins an umbrella, and which one. Returns every * refusal at once, except for a closed PR or an umbrella PR. */ -export function decide(pr: DocsPullRequest, readiness: Readiness, release: MainRelease): Decision { +export function decide( + pr: DocsPullRequest, + readiness: Readiness, + context: DecideContext +): Decision { if (pr.state !== 'open') return { status: 'refuse', refusals: [{ code: 'closed' }] }; if (isUmbrellaBranch(pr.head)) return { status: 'refuse', refusals: [{ code: 'umbrella' }] }; const refusals: Refusal[] = []; if (pr.isDraft) refusals.push({ code: 'draft' }); - const route = getRoute(pr, release); + const route = getRoute(pr, context); if ('code' in route) refusals.push(route); refusals.push(...getReadinessRefusals(readiness)); @@ -97,7 +127,10 @@ export function decide(pr: DocsPullRequest, readiness: Readiness, release: MainR return { status: 'join', route }; } -function getRoute(pr: DocsPullRequest, release: MainRelease): Route | Refusal { +function getRoute( + pr: DocsPullRequest, + { release, settledMainUmbrella }: DecideContext +): Route | Refusal { const labels = RELEASE_LABELS.filter((label) => pr.labels.includes(label)); if (labels.length === 0) return { code: 'no-release-label' }; if (labels.length > 1) return { code: 'several-release-labels', labels }; @@ -116,6 +149,9 @@ function getRoute(pr: DocsPullRequest, release: MainRelease): Route | Refusal { if (isMainTarget && release.type === 'major') { return { code: 'major-on-main', astroVersion: release.astroVersion }; } + if (settledMainUmbrella && usesSettledUmbrella(pr)) { + return { code: 'settled-umbrella', ...settledMainUmbrella }; + } return { target, umbrellaBranch: UMBRELLA_PREFIX + target, label, shouldRetarget }; } diff --git a/scripts/lib/release-queue/testing/fixtures.ts b/scripts/lib/release-queue/testing/fixtures.ts index 64a96b9286e74..591b1c6980a0f 100644 --- a/scripts/lib/release-queue/testing/fixtures.ts +++ b/scripts/lib/release-queue/testing/fixtures.ts @@ -7,8 +7,13 @@ const pullRequest = { state: 'open', draft: false, mergeable: true as boolean | null, + merge_commit_sha: null as string | null, base: { ref: 'main' }, - head: { ref: 'feat/option', sha: 'head-sha' }, + head: { + ref: 'feat/option', + sha: 'head-sha', + repo: { full_name: REPO } as { full_name: string } | null, + }, labels: [] as { name: string }[], body: null as string | null, }; @@ -17,3 +22,22 @@ const pullRequest = { export function pullRequestResponse(overrides: Partial = {}) { return { ...pullRequest, ...overrides }; } + +/** + * A `GET /pulls/` answer for the open umbrella PR into `target`, with + * `merge-on-release`. + */ +export function umbrellaResponse({ + target = 'main', + ...overrides +}: { target?: string } & Partial = {}) { + return pullRequestResponse({ + number: 42, + title: `[ci] release ${target} docs`, + html_url: `https://github.com/${REPO}/pull/42`, + base: { ref: target }, + head: { ref: `release-queue/${target}`, sha: 'umbrella-sha', repo: { full_name: REPO } }, + labels: [{ name: 'merge-on-release' }], + ...overrides, + }); +} diff --git a/scripts/lib/release-queue/testing/queue-options.ts b/scripts/lib/release-queue/testing/queue-options.ts new file mode 100644 index 0000000000000..3d95dadfd5663 --- /dev/null +++ b/scripts/lib/release-queue/testing/queue-options.ts @@ -0,0 +1,17 @@ +import type { Mode, QueueOptions } from '../modes.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'; + +/** Options for both entry points, with a client per identity of the stub. */ +export function queueOptions(github: ReturnType, mode: Mode): QueueOptions { + return { + actionsClient: github.client(ACTIONS_TOKEN), + botClient: github.client(BOT_TOKEN), + repo: REPO, + mode, + }; +} diff --git a/scripts/lib/release-queue/umbrella-queries.ts b/scripts/lib/release-queue/umbrella-queries.ts new file mode 100644 index 0000000000000..895f6d99061bf --- /dev/null +++ b/scripts/lib/release-queue/umbrella-queries.ts @@ -0,0 +1,91 @@ +import type { GitHubClient } from './github.ts'; +import { listCommitsAhead, type PullRequestResponse } from './queries.ts'; +import { getTarget, isUmbrellaBranch } from './routing.ts'; + +/** An open umbrella PR, as the refresh reads it. */ +export interface OpenUmbrella { + number: number; + title: string; + body: string; + labels: string[]; + /** The branch the umbrella PR merges into. */ + target: string; + branch: string; +} + +/** A PR merged into an umbrella branch, in this release or an earlier one. */ +export interface MergedPullRequest { + number: number; + title: string; + mergeCommitSha: string | null; +} + +/** + * Whether a PR is an umbrella PR: from an umbrella branch of `repo` into the + * branch the name implies. + */ +function isUmbrellaPullRequest({ base, head }: PullRequestResponse, repo: string): boolean { + return ( + isUmbrellaBranch(head.ref) && head.repo?.full_name === repo && getTarget(head.ref) === base.ref + ); +} + +function toOpenUmbrella(data: PullRequestResponse): OpenUmbrella { + return { + number: data.number, + title: data.title, + body: data.body ?? '', + labels: data.labels.map((label) => label.name), + target: data.base.ref, + branch: data.head.ref, + }; +} + +export async function getUmbrella(gh: GitHubClient, repo: string, number: number) { + return toOpenUmbrella(await gh.get(`repos/${repo}/pulls/${number}`)); +} + +/** Finds the open umbrella PR of `branch`, if any. */ +export async function findOpenUmbrella( + gh: GitHubClient, + repo: string, + branch: string +): Promise { + const [owner] = repo.split('/'); + const pullRequests = await gh.get( + `repos/${repo}/pulls?state=open&head=${owner}:${branch}` + ); + const umbrella = pullRequests.find((pr) => isUmbrellaPullRequest(pr, repo)); + return umbrella && toOpenUmbrella(umbrella); +} + +/** + * Lists the open umbrella PRs: PRs from an umbrella branch of `repo` into + * the branch it names. + */ +export async function listOpenUmbrellas(gh: GitHubClient, repo: string): Promise { + const pullRequests = await gh.getAll(`repos/${repo}/pulls?state=open`); + return pullRequests.filter((pr) => isUmbrellaPullRequest(pr, repo)).map(toOpenUmbrella); +} + +/** + * Gets what makes up an umbrella: the commits of its branch, and the PRs ever + * merged into that branch. The closed PRs of the branch build up over the + * releases, about one page of 100 for 100 merged PRs. + */ +export async function getUmbrellaHistory( + gh: GitHubClient, + repo: string, + { target, branch }: OpenUmbrella +): Promise<{ commits: string[]; merged: MergedPullRequest[] }> { + const [commits, closed] = await Promise.all([ + listCommitsAhead(gh, repo, target, branch), + gh.getAll(`repos/${repo}/pulls?state=closed&base=${branch}`), + ]); + const merged = closed.map(({ number, title, merge_commit_sha }) => ({ + number, + title, + mergeCommitSha: merge_commit_sha, + })); + return { commits, merged }; +} diff --git a/scripts/lib/release-queue/umbrella-state.ts b/scripts/lib/release-queue/umbrella-state.ts new file mode 100644 index 0000000000000..92050dbfae33a --- /dev/null +++ b/scripts/lib/release-queue/umbrella-state.ts @@ -0,0 +1,53 @@ +import type { GitHubClient } from './github.ts'; +import { isFrameworkPullRequestMerged } from './queries.ts'; +import type { MainRelease } from './release.ts'; +import { MAIN_BRANCH, UMBRELLA_PREFIX, type SettledUmbrella } from './routing.ts'; +import { findOpenUmbrella, type OpenUmbrella } from './umbrella-queries.ts'; +import { parseReleaseMarker } from './umbrella.ts'; + +/** + * How an umbrella relates to the release PRs of `withastro/astro`: + * - `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'; releasePullRequest?: number | undefined } + | { status: 'settled'; releasePullRequest: number }; + +/** + * Gets the release state of an umbrella. Prerelease umbrellas always follow: + * their branch has continuous betas. + */ +export async function getReleaseState( + gh: GitHubClient, + umbrella: OpenUmbrella, + release: MainRelease +): Promise { + if (umbrella.target !== MAIN_BRANCH) return { status: 'follows' }; + const linked = parseReleaseMarker(umbrella.body); + const open = release.pullRequest?.number; + // A release PR closed without merging settles nothing. + if (linked !== undefined && linked !== open && (await isFrameworkPullRequestMerged(gh, linked))) { + return { status: 'settled', releasePullRequest: linked }; + } + return open === undefined + ? { status: 'kept', releasePullRequest: linked } + : { status: 'follows' }; +} + +/** Gets the open `main` umbrella if it's settled, for `decide`. */ +export async function getSettledMainUmbrella( + gh: GitHubClient, + repo: string, + release: MainRelease +): Promise { + const umbrella = await findOpenUmbrella(gh, repo, `${UMBRELLA_PREFIX}${MAIN_BRANCH}`); + if (!umbrella) return undefined; + const state = await getReleaseState(gh, umbrella, release); + return state.status === 'settled' + ? { number: umbrella.number, releasePullRequest: state.releasePullRequest } + : undefined; +} diff --git a/scripts/lib/release-queue/umbrella.test.ts b/scripts/lib/release-queue/umbrella.test.ts index 8c88a87f60564..a25c52bcb91a8 100644 --- a/scripts/lib/release-queue/umbrella.test.ts +++ b/scripts/lib/release-queue/umbrella.test.ts @@ -1,31 +1,160 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { getUmbrellaReleaseLabel, getUmbrellaTitle } from './umbrella.ts'; +import { getMainRelease } from './release.ts'; +import { + getKeptLabelChanges, + getLabelChanges, + getUmbrellaBody, + getUmbrellaHeading, + parseReleaseMarker, + selectIncludedPullRequests, +} from './umbrella.ts'; -describe('getUmbrellaTitle', () => { - it('uses the astro version for the main umbrella', () => { - assert.equal(getUmbrellaTitle('main', '7.4.1'), '[ci] release astro@7.4.1 docs'); +const releasePullRequest = (astroVersion?: string) => ({ + number: 18459, + versions: astroVersion ? [{ name: 'astro', version: astroVersion }] : [], +}); + +describe('getUmbrellaHeading', () => { + it('names the main umbrella after the astro version, and links the release PR', () => { + assert.deepEqual(getUmbrellaHeading('main', getMainRelease(releasePullRequest('7.4.1'))), { + title: '[ci] release astro@7.4.1 docs', + releaseLabel: 'patch-release', + releasePullRequest: 18459, + }); }); - it('falls back to the branch name when astro is not released', () => { - assert.equal(getUmbrellaTitle('main'), '[ci] release main docs'); + it('labels the main umbrella of a minor release', () => { + const heading = getUmbrellaHeading('main', getMainRelease(releasePullRequest('7.5.0'))); + assert.equal(heading.releaseLabel, 'minor-release'); }); - it('uses the branch name for a prerelease umbrella', () => { + it('falls back to the branch name and a patch when astro is not released', () => { + assert.deepEqual(getUmbrellaHeading('main', getMainRelease(releasePullRequest())), { + title: '[ci] release main docs', + releaseLabel: 'patch-release', + releasePullRequest: 18459, + }); + }); + + it('names a prerelease umbrella after its branch, as a major', () => { + const release = getMainRelease(releasePullRequest('7.4.0-beta.1')); + assert.deepEqual(getUmbrellaHeading('cloudflare-next', release), { + title: '[ci] release cloudflare-next docs', + releaseLabel: 'major-release', + }); + }); +}); + +describe('selectIncludedPullRequests', () => { + it('keeps the PRs whose squash commit is on the branch, in commit order', () => { + const included = selectIncludedPullRequests( + [ + { number: 3, title: 'Third', mergeCommitSha: 'c' }, + { number: 1, title: 'First', mergeCommitSha: 'a' }, + { number: 9, title: 'From an earlier release', mergeCommitSha: 'old' }, + { number: 5, title: 'Closed without merging', mergeCommitSha: null }, + ], + ['a', 'main-merge', 'c'] + ); + assert.deepEqual(included, [ + { number: 1, title: 'First' }, + { number: 3, title: 'Third' }, + ]); + }); +}); + +describe('getUmbrellaBody', () => { + const included = [ + { number: 14580, title: 'Document remote image fetch retries' }, + { number: 14581, title: 'Fix a typo' }, + ]; + + it('links the release PR of the main umbrella, and lists the included PRs', () => { assert.equal( - getUmbrellaTitle('cloudflare-next', '7.4.0-beta.1'), - '[ci] release cloudflare-next docs' + getUmbrellaBody('main', { releasePullRequest: 18459, included }), + [ + 'Ships with withastro/astro#18459. This includes:', + '', + '- Document remote image fetch retries (#14580)', + '- Fix a typo (#14581)', + '', + 'Managed by the release queue: the bot rewrites this description after each join.', + '', + ].join('\n') + ); + }); + + it('says when no release PR is linked, without a marker', () => { + const body = getUmbrellaBody('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('says a prerelease umbrella ships with its branch', () => { + assert.match( + getUmbrellaBody('cloudflare-next', { included }), + /^Ships with the prerelease branch\. This includes:/ ); }); + + it('says when no PR is included yet', () => { + assert.match( + getUmbrellaBody('main', { releasePullRequest: 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 = getUmbrellaBody('main', { releasePullRequest: 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); + }); +}); + +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('getUmbrellaReleaseLabel', () => { - it('labels the main umbrella from the astro release type', () => { - assert.equal(getUmbrellaReleaseLabel('main', 'minor'), 'minor-release'); - assert.equal(getUmbrellaReleaseLabel('main', 'patch'), 'patch-release'); +describe('getKeptLabelChanges', () => { + it('keeps the release label an umbrella has', () => { + assert.deepEqual(getKeptLabelChanges(['merge-on-release', 'minor-release']), { + toAdd: [], + toRemove: [], + }); }); - it('labels a prerelease umbrella as a major release', () => { - assert.equal(getUmbrellaReleaseLabel('v8', 'patch'), 'major-release'); + 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.ts b/scripts/lib/release-queue/umbrella.ts index 724795ee2c090..6db050a700509 100644 --- a/scripts/lib/release-queue/umbrella.ts +++ b/scripts/lib/release-queue/umbrella.ts @@ -1,5 +1,11 @@ -import type { ReleaseType } from './release.ts'; -import { MAIN_BRANCH, type ReleaseLabel } from './routing.ts'; +import type { MainRelease } from './release.ts'; +import { MAIN_BRANCH, RELEASE_LABELS, type ReleaseLabel } from './routing.ts'; + +/** Marks umbrella PRs, so they can be found in the PR list. */ +export const MERGE_ON_RELEASE_LABEL = 'merge-on-release'; + +/** The label of a release whose type isn't known yet. */ +const FALLBACK_RELEASE_LABEL: ReleaseLabel = 'patch-release'; /** * An umbrella PR: an open one, or the one a join creates, named by its title. @@ -7,26 +13,119 @@ import { MAIN_BRANCH, type ReleaseLabel } from './routing.ts'; */ export type Umbrella = { number: number } | { title: string }; +/** What an umbrella PR takes from the release it ships with. */ +export interface UmbrellaHeading { + title: string; + releaseLabel: ReleaseLabel; + /** The release PR of `withastro/astro` the umbrella links. */ + releasePullRequest?: number | undefined; +} + /** - * Gets the title of the umbrella PR merging into `target`, after the - * `[ci] release` PRs of `withastro/astro`. The `main` umbrella uses the - * `astro` version when there is one, as merged PRs are posted to Discord. - * A prerelease umbrella uses its branch name, which says what it is for. + * Gets what the umbrella PR merging into `target` takes from the next release + * from `main`. The `main` umbrella is named after the `astro` version, as + * merged PRs are posted to Discord, and links the release PR. A prerelease + * umbrella uses its branch name, which says what it is for, and is a major. + * On `main`, anything but a minor is a patch, including a release without + * `astro`. A major never gets here: `decide` refuses `main` PRs before one. */ -export function getUmbrellaTitle(target: string, astroVersion?: string): string { - const release = target === MAIN_BRANCH && astroVersion ? `astro@${astroVersion}` : target; - return `[ci] release ${release} docs`; +export function getUmbrellaHeading(target: string, release: MainRelease): UmbrellaHeading { + if (target !== MAIN_BRANCH) { + return { title: `[ci] release ${target} docs`, releaseLabel: 'major-release' }; + } + const name = release.astroVersion ? `astro@${release.astroVersion}` : target; + return { + title: `[ci] release ${name} docs`, + releaseLabel: release.type === 'minor' ? 'minor-release' : FALLBACK_RELEASE_LABEL, + releasePullRequest: release.pullRequest?.number, + }; +} + +/** A docs PR merged into an umbrella branch. */ +export interface IncludedPullRequest { + number: number; + title: string; } /** - * Gets the release label of the umbrella PR merging into `target`. On `main`, - * anything but a minor is a patch, including a release without `astro`. A - * major never gets here: `decide` refuses `main` PRs before a major. + * Keeps the PRs whose squash commit is on the umbrella branch, in the order of + * the commits. The branch name comes back each release, so PRs of earlier + * releases have the same base. */ -export function getUmbrellaReleaseLabel( +export function selectIncludedPullRequests( + merged: (IncludedPullRequest & { mergeCommitSha: string | null })[], + umbrellaCommits: string[] +): IncludedPullRequest[] { + const byCommit = new Map(merged.map((pr) => [pr.mergeCommitSha, pr])); + return umbrellaCommits.flatMap((sha) => { + const pr = byCommit.get(sha); + return pr ? [{ number: pr.number, title: pr.title }] : []; + }); +} + +const RELEASE_MARKER = //; + +/** Reads the release PR an umbrella links, from the marker in its body. */ +export function parseReleaseMarker(body: string): number | undefined { + const match = body.match(RELEASE_MARKER); + return match ? Number(match[1]) : undefined; +} + +/** + * Writes the description of the umbrella PR merging into `target`: what it + * ships with, and the docs PRs it includes. A linked release PR is also kept + * in a hidden marker, as the refresh needs it. + */ +export function getUmbrellaBody( target: string, - mainReleaseType: ReleaseType -): ReleaseLabel { - if (target !== MAIN_BRANCH) return 'major-release'; - return mainReleaseType === 'minor' ? 'minor-release' : 'patch-release'; + { + releasePullRequest, + included, + }: { releasePullRequest?: number | undefined; included: IncludedPullRequest[] } +): string { + const contents = + included.length > 0 + ? `This includes:\n\n${included.map(({ number, title }) => `- ${title} (#${number})`).join('\n')}` + : 'No docs PR is included yet.'; + const marker = + releasePullRequest === undefined + ? '' + : `\n`; + return `${describeShipping(target, releasePullRequest)} ${contents}\n\nManaged by the release queue: the bot rewrites this description after each join.${marker}`; +} + +function describeShipping(target: string, releasePullRequest: number | undefined): string { + // The PR header and the title already name the branch. + if (target !== MAIN_BRANCH) return 'Ships with the prerelease branch.'; + if (releasePullRequest === undefined) { + return 'Ships with the next release of `withastro/astro`, once its `[ci] release` PR is open.'; + } + return `Ships with withastro/astro#${releasePullRequest}.`; +} + +/** + * Gets the label changes that give an umbrella PR `merge-on-release` and its + * release label. Other labels, like `i18n`, stay. + */ +export function getLabelChanges(current: string[], releaseLabel: ReleaseLabel) { + const wanted = [MERGE_ON_RELEASE_LABEL, releaseLabel]; + return { + toAdd: wanted.filter((label) => !current.includes(label)), + toRemove: RELEASE_LABELS.filter((label) => label !== releaseLabel && current.includes(label)), + }; +} + +/** + * Gets the label changes of an umbrella whose release can't be followed: it + * keeps its release label, and gets the fallback one only if it has none. + */ +export function getKeptLabelChanges(current: string[]) { + const hasReleaseLabel = RELEASE_LABELS.some((label) => current.includes(label)); + return { + toAdd: [ + ...(current.includes(MERGE_ON_RELEASE_LABEL) ? [] : [MERGE_ON_RELEASE_LABEL]), + ...(hasReleaseLabel ? [] : [FALLBACK_RELEASE_LABEL]), + ], + toRemove: [], + }; } diff --git a/scripts/release-queue.ts b/scripts/release-queue.ts index 0689d3edf777e..8e51e3ae86b15 100644 --- a/scripts/release-queue.ts +++ b/scripts/release-queue.ts @@ -3,27 +3,28 @@ import { readFile } from 'node:fs/promises'; import { parseArgs } from 'node:util'; +import { chooseAction } from './lib/release-queue/cli.ts'; import { GitHubClient } from './lib/release-queue/github.ts'; -import { - MODES, - handleAcceptedPullRequest, - needsBotToken, -} from './lib/release-queue/handle-accepted.ts'; +import { handleAcceptedPullRequest } from './lib/release-queue/handle-accepted.ts'; +import { needsBotToken, parseMode, type QueueOptions } from './lib/release-queue/modes.ts'; +import { refreshOpenUmbrellas } from './lib/release-queue/refresh.ts'; -// In GitHub Actions, the PR and the sender come from the event. +// In GitHub Actions, the PR and the sender come from the event, and a +// `workflow_dispatch` refreshes every open umbrella PR. +// `--refresh`: refresh every open umbrella PR, instead of handling `--pr`. // `--sender`: who added the label, for the write access check. // `--write`: send the writes instead of printing them, outside GitHub Actions. const { values } = parseArgs({ options: { pr: { type: 'string' }, + refresh: { type: 'boolean', default: false }, sender: { type: 'string' }, write: { type: 'boolean', default: false }, }, }); const repo = process.env.GITHUB_REPOSITORY ?? 'withastro/docs'; -const mode = MODES.find((value) => value === (process.env.MODE ?? 'dry-run')); -if (!mode) throw new Error(`Unknown MODE: ${process.env.MODE}. Expected one of: ${MODES}.`); +const mode = parseMode(process.env.MODE); const token = process.env.GITHUB_TOKEN; if (!token) throw new Error('GITHUB_TOKEN is required.'); @@ -38,15 +39,20 @@ if (needsBotToken(mode, shouldWrite) && !botToken) { const clientOptions = { shouldPrintWrites: !shouldWrite }; const actionsClient = new GitHubClient(token, clientOptions); const botClient = new GitHubClient(botToken ?? token, clientOptions); +const options: QueueOptions = { actionsClient, botClient, repo, mode }; const event = process.env.GITHUB_EVENT_PATH ? JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, 'utf-8')) : undefined; -const prNumber = Number(values.pr ?? event?.pull_request?.number); -if (!prNumber) throw new Error('No PR to process: pass --pr .'); +const action = chooseAction({ + pr: values.pr, + refresh: values.refresh, + eventName: process.env.GITHUB_EVENT_NAME, + eventPullRequest: event?.pull_request?.number, +}); -await handleAcceptedPullRequest( - { actionsClient, botClient, repo, mode }, - prNumber, - values.sender ?? event?.sender?.login -); +if (action.type === 'refresh') { + await refreshOpenUmbrellas(options); +} else { + await handleAcceptedPullRequest(options, action.number, values.sender ?? event?.sender?.login); +} From a63654b420967fbd35a53fbd52656852edc858c8 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Thu, 22 Oct 2026 15:09:45 +0200 Subject: [PATCH 04/35] Block umbrella merges until a maintainer checks them --- .github/workflows/release-queue.yml | 2 +- .../lib/release-queue/handle-accepted.test.ts | 22 ++- scripts/lib/release-queue/handle-accepted.ts | 6 +- scripts/lib/release-queue/merge-block.test.ts | 134 ++++++++++++++++++ scripts/lib/release-queue/merge-block.ts | 87 ++++++++++++ scripts/lib/release-queue/queries.ts | 2 +- scripts/lib/release-queue/refresh.test.ts | 57 +++++++- scripts/lib/release-queue/refresh.ts | 16 ++- 8 files changed, 314 insertions(+), 12 deletions(-) create mode 100644 scripts/lib/release-queue/merge-block.test.ts create mode 100644 scripts/lib/release-queue/merge-block.ts diff --git a/.github/workflows/release-queue.yml b/.github/workflows/release-queue.yml index 3fd9c5115e3fa..a28d95a8401d7 100644 --- a/.github/workflows/release-queue.yml +++ b/.github/workflows/release-queue.yml @@ -31,7 +31,7 @@ jobs: permissions: checks: read # Reads the checks of the PR. contents: read - pull-requests: write # Comments on the PR. + 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. diff --git a/scripts/lib/release-queue/handle-accepted.test.ts b/scripts/lib/release-queue/handle-accepted.test.ts index 4666871861f59..d90f7c0199332 100644 --- a/scripts/lib/release-queue/handle-accepted.test.ts +++ b/scripts/lib/release-queue/handle-accepted.test.ts @@ -52,6 +52,9 @@ function readyPullRequest({ base = 'main', label = 'minor-release', umbrellaBody [`repos/${REPO}/git/ref/heads/${base}`]: { object: { sha: 'base-sha' } }, [`repos/${REPO}/pulls?state=closed&base=${umbrellaBranch}`]: [], [`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}`]: [], }; } @@ -116,7 +119,8 @@ describe('handleAcceptedPullRequest', () => { it('joins a PR shipping to main in main-only mode', async (t) => { const { actionsWrites, botWrites } = await run(t, 'main-only'); - assert.deepEqual(actionsWrites, []); + // 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`, @@ -126,10 +130,26 @@ describe('handleAcceptedPullRequest', () => { ]); }); + it('lists the PRs still on the umbrella branch in the review', async (t) => { + const { actionsWrites } = await run(t, 'main-only', { + getResponses: { + ...readyPullRequest(), + [`repos/${REPO}/pulls?state=open&base=release-queue/main`]: [{ number: 14590 }], + }, + }); + + const review = actionsWrites.find(({ path }) => path.endsWith('/reviews')); + assert.match( + (review?.body as { body: string }).body, + /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, 'main-only', { getResponses: { ...firstJoin(), + [`repos/${REPO}/pulls/99/reviews`]: [], [`repos/${REPO}/pulls/99`]: umbrellaResponse({ number: 99, title: '[ci] release astro@7.5.0 docs', diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts index 4361a30f90451..a8ee320f344c5 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -1,4 +1,5 @@ import { joinUmbrella } from './join.ts'; +import { syncMergeBlockReview } from './merge-block.ts'; import { formatDryRunJoinComment, formatDryRunRefusalComment, @@ -112,7 +113,7 @@ async function refuse( } async function join(options: QueueOptions, pr: PullRequest, route: Route, release: MainRelease) { - const { botClient, repo } = options; + const { actionsClient, botClient, repo } = options; const { title } = getUmbrellaHeading(route.target, release); const result = await joinUmbrella(botClient, repo, pr, route, title); @@ -123,7 +124,8 @@ async function join(options: QueueOptions, pr: PullRequest, route: Route, releas // Without a number, the umbrella creation was only printed. if ('number' in umbrella) { const openUmbrella = await getUmbrella(botClient, repo, umbrella.number); - await refreshUmbrella(botClient, repo, openUmbrella, release); + const refreshed = await refreshUmbrella(botClient, repo, openUmbrella, release); + await syncMergeBlockReview(actionsClient, repo, refreshed, { shouldBlock: true }); } return; } diff --git a/scripts/lib/release-queue/merge-block.test.ts b/scripts/lib/release-queue/merge-block.test.ts new file mode 100644 index 0000000000000..4a769e9f9292b --- /dev/null +++ b/scripts/lib/release-queue/merge-block.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { describe, it, type TestContext } from 'node:test'; +import { getMergeBlockBody, syncMergeBlockReview } from './merge-block.ts'; +import { REPO } from './testing/fixtures.ts'; +import { stubGitHub } from './testing/stub-github.ts'; +import type { OpenUmbrella } from './umbrella-queries.ts'; +import { getUmbrellaBody } from './umbrella.ts'; + +/** The `main` umbrella #42, linked to release PR #18405. */ +const umbrella: OpenUmbrella = { + number: 42, + title: '[ci] release astro@7.5.0 docs', + body: getUmbrellaBody('main', { releasePullRequest: 18405, included: [] }), + labels: ['merge-on-release', 'minor-release'], + target: 'main', + branch: 'release-queue/main', +}; + +describe('getMergeBlockBody', () => { + it('checks the linked release PR and the PRs left on the branch', () => { + assert.equal( + getMergeBlockBody(umbrella, []), + [ + '**Before merging**, check:', + '', + '- [ ] withastro/astro#18405 is merged.', + '- [ ] No open PR targets `release-queue/main`.', + '- [ ] If the umbrella has the `i18n` label, the commit body has a [Lunaria directive](https://lunaria.dev/guides/tracking/#triggering-changes-per-file), for example `@lunaria-track:src/content/docs/en/**.mdx`. The squash commit body is empty by default: add it in the merge dialog.', + '', + 'Then dismiss this review. The release queue requests changes again if more docs PRs join.', + ].join('\n') + ); + }); + + it('lists the PRs that still target the umbrella branch', () => { + assert.match( + getMergeBlockBody(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', () => { + assert.match( + getMergeBlockBody({ ...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', () => { + // `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( + getMergeBlockBody(umbrella, []), + /- \[ \] If the umbrella has the `i18n` label, the commit body has a \[Lunaria directive\]/ + ); + }); +}); + +describe('syncMergeBlockReview', () => { + const reviewsPath = `repos/${REPO}/pulls/42/reviews`; + const body = getMergeBlockBody(umbrella, []); + + function stub(t: TestContext, reviews: unknown[]) { + return stubGitHub(t, { + getResponses: { + [reviewsPath]: reviews, + [`repos/${REPO}/pulls?state=open&base=release-queue/main`]: [], + }, + }); + } + + const review = (id: number, state: string, reviewBody = body, login = 'github-actions[bot]') => ({ + id, + user: { login }, + state, + body: reviewBody, + }); + + const submit = { + method: 'POST', + path: reviewsPath, + body: { event: 'REQUEST_CHANGES', body }, + }; + + it('requests changes after a join when no review is active', async (t) => { + const github = stub(t, []); + + await syncMergeBlockReview(github.client(), REPO, 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.client(), REPO, 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.client(), REPO, 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.client(), REPO, umbrella, { shouldBlock: true }); + + assert.deepEqual(github.writes, [{ method: 'PUT', path: `${reviewsPath}/2`, body: { body } }]); + }); + + 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', body.replaceAll('\n', '\r\n'))]); + + await syncMergeBlockReview(github.client(), REPO, 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.client(), REPO, umbrella, { shouldBlock: true }); + + assert.deepEqual(github.writes, [submit]); + }); +}); diff --git a/scripts/lib/release-queue/merge-block.ts b/scripts/lib/release-queue/merge-block.ts new file mode 100644 index 0000000000000..e22e6ed764830 --- /dev/null +++ b/scripts/lib/release-queue/merge-block.ts @@ -0,0 +1,87 @@ +import type { GitHubClient } from './github.ts'; +import type { ReviewResponse } from './queries.ts'; +import type { OpenUmbrella } from './umbrella-queries.ts'; +import { parseReleaseMarker } from './umbrella.ts'; + +/** + * Who submits the review: `GITHUB_TOKEN`. The bot account opens the umbrella + * PR, and GitHub doesn't let the author of a PR review it. + */ +const REVIEWER = 'github-actions[bot]'; + +/** + * The label `pr-labeler.yml` adds when translations change. It comes after + * the merge that brings them, with `GITHUB_TOKEN`, which doesn't trigger this + * workflow: the checklist can't wait for it, so it names it. + */ +const TRANSLATIONS_LABEL = 'i18n'; + +const LUNARIA_DIRECTIVE = '@lunaria-track:src/content/docs/en/**.mdx'; +const LUNARIA_DIRECTIVE_DOCS = 'https://lunaria.dev/guides/tracking/#triggering-changes-per-file'; + +interface SubmittedReview extends ReviewResponse { + id: number; + body: string; +} + +/** + * Writes the checklist a maintainer goes through before merging an umbrella + * PR, then dismisses the review that holds it. + * + * @param openPullRequests PRs that still target the umbrella branch. + */ +export function getMergeBlockBody(umbrella: OpenUmbrella, openPullRequests: number[]): string { + const releasePullRequest = parseReleaseMarker(umbrella.body); + const items = [ + releasePullRequest === undefined + ? 'The `[ci] release` PR of `withastro/astro` this umbrella ships with is merged.' + : `withastro/astro#${releasePullRequest} is merged.`, + openPullRequests.length === 0 + ? `No open PR targets \`${umbrella.branch}\`.` + : `These PRs still target \`${umbrella.branch}\`: ${openPullRequests.map((number) => `#${number}`).join(', ')}. Merge them into the umbrella, or change their base to \`${umbrella.target}\` and update them after the umbrella is merged.`, + `If the umbrella has the \`${TRANSLATIONS_LABEL}\` label, the commit body has a [Lunaria directive](${LUNARIA_DIRECTIVE_DOCS}), for example \`${LUNARIA_DIRECTIVE}\`. The squash commit body is empty by default: add it in the merge dialog.`, + ]; + 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.`; +} + +/** + * Keeps the merge-block review of an umbrella PR up to date. Updates the + * active review when there is one. Otherwise, submits a new one when + * `shouldBlock`: after a join, as a dismissed review no longer covers the PRs + * that joined since. + */ +export async function syncMergeBlockReview( + gh: GitHubClient, + repo: string, + umbrella: OpenUmbrella, + { shouldBlock }: { shouldBlock: boolean } +) { + const [reviews, openPullRequests] = await Promise.all([ + gh.getAll(`repos/${repo}/pulls/${umbrella.number}/reviews`), + gh.getAll<{ number: number }>(`repos/${repo}/pulls?state=open&base=${umbrella.branch}`), + ]); + const body = getMergeBlockBody( + umbrella, + openPullRequests.map(({ number }) => number) + ); + + // A dismissed review changes its state to `DISMISSED`. + const active = reviews.findLast( + ({ user, state }) => user?.login === REVIEWER && state === 'CHANGES_REQUESTED' + ); + if (active) { + if (active.body.replaceAll('\r\n', '\n') !== body) { + await gh.write('PUT', `repos/${repo}/pulls/${umbrella.number}/reviews/${active.id}`, { + body, + }); + } + return; + } + if (shouldBlock) { + await gh.write('POST', `repos/${repo}/pulls/${umbrella.number}/reviews`, { + event: 'REQUEST_CHANGES', + body, + }); + } +} diff --git a/scripts/lib/release-queue/queries.ts b/scripts/lib/release-queue/queries.ts index 756e145b9444e..c79784b9999bf 100644 --- a/scripts/lib/release-queue/queries.ts +++ b/scripts/lib/release-queue/queries.ts @@ -39,7 +39,7 @@ export interface PullRequestResponse { merged?: boolean; } -interface ReviewResponse { +export interface ReviewResponse { /** `null` for a deleted account. */ user: { login: string } | null; state: string; diff --git a/scripts/lib/release-queue/refresh.test.ts b/scripts/lib/release-queue/refresh.test.ts index bdd39b7926c63..1f8a6685d7ef0 100644 --- a/scripts/lib/release-queue/refresh.test.ts +++ b/scripts/lib/release-queue/refresh.test.ts @@ -3,7 +3,7 @@ import { describe, it, type TestContext } from 'node:test'; import { refreshOpenUmbrellas, refreshUmbrella } from './refresh.ts'; import { getMainRelease } from './release.ts'; import { pullRequestResponse, REPO, umbrellaResponse } from './testing/fixtures.ts'; -import { BOT_TOKEN, queueOptions } from './testing/queue-options.ts'; +import { ACTIONS_TOKEN, BOT_TOKEN, queueOptions } from './testing/queue-options.ts'; import { stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; import type { OpenUmbrella } from './umbrella-queries.ts'; import { getUmbrellaBody } from './umbrella.ts'; @@ -41,6 +41,12 @@ const history = (target = 'main') => ({ }, }); +/** No merge-block review on umbrella `number`, and no PR on its branch. */ +const reviews = (number: number, target = 'main') => ({ + [`repos/${REPO}/pulls/${number}/reviews`]: [], + [`repos/${REPO}/pulls?state=open&base=release-queue/${target}`]: [], +}); + /** Answers whether release PR `number` of `withastro/astro` is merged. */ const releaseMerged = (number: number, merged: boolean) => ({ [`repos/withastro/astro/pulls/${number}`]: { number, merged }, @@ -110,9 +116,21 @@ describe('refreshUmbrella', () => { labels: ['merge-on-release', 'patch-release'], }; - await refreshUmbrella(github.client(), REPO, patchUmbrella, releaseWith(18405, '7.5.0')); + const refreshed = await refreshUmbrella( + github.client(), + REPO, + patchUmbrella, + releaseWith(18405, '7.5.0') + ); assert.equal(patchOf(github.writes)?.title, '[ci] release astro@7.5.0 docs'); + // The merge-block review reads the umbrella as it is after the refresh. + assert.deepEqual(refreshed, { + ...patchUmbrella, + title: '[ci] release astro@7.5.0 docs', + body: umbrellaBody(18405), + labels: ['merge-on-release', 'minor-release'], + }); assert.deepEqual(labelWrites(github.writes), [ { method: 'POST', @@ -148,9 +166,15 @@ describe('refreshUmbrella', () => { it('leaves the main umbrella as it is before a major', async (t) => { const github = stub(t); - await refreshUmbrella(github.client(), REPO, umbrella, releaseWith(18405, '8.0.0')); + const refreshed = await refreshUmbrella( + github.client(), + REPO, + umbrella, + releaseWith(18405, '8.0.0') + ); assert.deepEqual(github.calls, []); + assert.equal(refreshed, umbrella); }); }); @@ -255,6 +279,8 @@ describe('refreshOpenUmbrellas', () => { ], ...history(), ...history('cloudflare-next'), + ...reviews(42), + ...reviews(43, 'cloudflare-next'), }; const refreshedUmbrellas = (writes: Write[]) => @@ -276,6 +302,31 @@ describe('refreshOpenUmbrellas', () => { await refreshOpenUmbrellas(queueOptions(github, 'main-only')); assert.deepEqual(refreshedUmbrellas(github.writesBy(BOT_TOKEN)), [42]); + // Without a join, no merge-block review is requested. + assert.deepEqual(github.writesBy(ACTIONS_TOKEN), []); + }); + + 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(queueOptions(github, 'main-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) => { diff --git a/scripts/lib/release-queue/refresh.ts b/scripts/lib/release-queue/refresh.ts index 339a0e25b46e7..e5a374482983d 100644 --- a/scripts/lib/release-queue/refresh.ts +++ b/scripts/lib/release-queue/refresh.ts @@ -1,4 +1,5 @@ import type { GitHubClient } from './github.ts'; +import { syncMergeBlockReview } from './merge-block.ts'; import { isLiveFor, type QueueOptions } from './modes.ts'; import { getReleasePullRequest, removeLabel } from './queries.ts'; import { getMainRelease, type MainRelease } from './release.ts'; @@ -18,20 +19,21 @@ import { * always lists the included docs PRs. Its title, release label and linked * release PR follow the open release PR, unless the umbrella is `kept` or * `settled`. Leaves the `main` umbrella as it is before a major: a - * maintainer moves it to the prerelease branch. + * maintainer moves it to the prerelease branch. Returns the umbrella as it is + * after the refresh. */ export async function refreshUmbrella( gh: GitHubClient, repo: string, umbrella: OpenUmbrella, release: MainRelease -) { +): Promise { const state = await getReleaseState(gh, umbrella, release); const heading = getUmbrellaHeading(umbrella.target, release); const isFollowing = state.status === 'follows'; if (isFollowing && umbrella.target === MAIN_BRANCH && release.type === 'major') { console.info(`Skipped #${umbrella.number}: the next release from main is a major.`); - return; + return umbrella; } const { commits, merged } = await getUmbrellaHistory(gh, repo, umbrella); @@ -55,6 +57,10 @@ export async function refreshUmbrella( await gh.write('POST', `repos/${repo}/issues/${umbrella.number}/labels`, { labels: toAdd }); } await Promise.all(toRemove.map((label) => removeLabel(gh, repo, umbrella.number, label))); + + const removed = new Set(toRemove); + const labels = [...umbrella.labels, ...toAdd].filter((label) => !removed.has(label)); + return { ...umbrella, ...changes, labels }; } /** Refreshes every open umbrella PR, for example after a release PR change. */ @@ -72,6 +78,8 @@ export async function refreshOpenUmbrellas({ actionsClient, botClient, repo, mod console.info(`Skipped #${umbrella.number}: dry run for ${umbrella.target}.`); continue; } - await refreshUmbrella(botClient, repo, umbrella, release); + const refreshed = await refreshUmbrella(botClient, repo, umbrella, release); + // Without a join, a dismissed review still covers the included PRs. + await syncMergeBlockReview(actionsClient, repo, refreshed, { shouldBlock: false }); } } From d076a440007a0a3e9216067aeb4375d9656c346c Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Thu, 22 Oct 2026 15:30:39 +0200 Subject: [PATCH 05/35] Link prerelease umbrellas to their release PR --- .../lib/release-queue/handle-accepted.test.ts | 50 ++++++++++++++ scripts/lib/release-queue/queries.test.ts | 66 ++++++++++++++++++- scripts/lib/release-queue/queries.ts | 36 +++++++++- scripts/lib/release-queue/refresh.test.ts | 23 +++++-- scripts/lib/release-queue/refresh.ts | 33 +++++++--- scripts/lib/release-queue/umbrella.test.ts | 16 +++-- scripts/lib/release-queue/umbrella.ts | 20 ++---- 7 files changed, 207 insertions(+), 37 deletions(-) diff --git a/scripts/lib/release-queue/handle-accepted.test.ts b/scripts/lib/release-queue/handle-accepted.test.ts index d90f7c0199332..cb6bb06f3cd65 100644 --- a/scripts/lib/release-queue/handle-accepted.test.ts +++ b/scripts/lib/release-queue/handle-accepted.test.ts @@ -194,6 +194,56 @@ describe('handleAcceptedPullRequest', () => { ]); }); + 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((refresh?.body as { body: string }).body, /^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(queueOptions(github, 'main-only'), 7, '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(queueOptions(github, mode), 7, '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, 'main-only'); diff --git a/scripts/lib/release-queue/queries.test.ts b/scripts/lib/release-queue/queries.test.ts index f0c0ce9db39e1..d9d23b6ce8631 100644 --- a/scripts/lib/release-queue/queries.test.ts +++ b/scripts/lib/release-queue/queries.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { getPullRequest } from './queries.ts'; +import { GitHubClient } from './github.ts'; +import { getPrereleasePullRequest, getPullRequest } from './queries.ts'; import { pullRequestResponse, REPO } from './testing/fixtures.ts'; import { stubGitHub } from './testing/stub-github.ts'; @@ -42,3 +43,66 @@ describe('getPullRequest', () => { assert.equal(github.countReads(PR_PATH), 1); }); }); + +describe('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 mainPullRequest = { number: 18405, versions: [] }; + 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.client(), mainPullRequest); + + 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.client(), mainPullRequest), 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 GitHubClient('token'), mainPullRequest), { + 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.client(), mainPullRequest), SyntaxError); + }); + + 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.client(), mainPullRequest), + mainPullRequest + ); + assert.equal(github.countReads(nextPullRequests), 0); + } + }); +}); diff --git a/scripts/lib/release-queue/queries.ts b/scripts/lib/release-queue/queries.ts index c79784b9999bf..bc08c492843dc 100644 --- a/scripts/lib/release-queue/queries.ts +++ b/scripts/lib/release-queue/queries.ts @@ -203,18 +203,48 @@ async function getRequiredChecks(gh: GitHubClient, repo: string): Promise check.context); } -/** Gets the open `[ci] release` PR of `main` in `withastro/astro`. */ +/** + * Gets the open `[ci] release` PR of a branch of `withastro/astro`: `main`, or + * `next` for its alpha and beta releases. + */ export async function getReleasePullRequest( - gh: GitHubClient + gh: GitHubClient, + branch: 'main' | 'next' = 'main' ): Promise { const [owner] = FRAMEWORK_REPO.split('/'); const [pr] = await gh.get( - `repos/${FRAMEWORK_REPO}/pulls?state=open&head=${owner}:changeset-release/main` + `repos/${FRAMEWORK_REPO}/pulls?state=open&head=${owner}:changeset-release/${branch}` ); if (!pr) return undefined; return { number: pr.number, versions: parseReleaseVersions(pr.body ?? '') }; } +/** Whether `next` in `withastro/astro` is in changesets prerelease mode. */ +async function isNextInPreMode(gh: GitHubClient): Promise { + try { + const { content } = await gh.get<{ content: string }>( + `repos/${FRAMEWORK_REPO}/contents/.changeset/pre.json?ref=next` + ); + // `exit` means the prerelease ends: `next` is then merged into `main`. + return JSON.parse(Buffer.from(content, 'base64').toString()).mode === 'pre'; + } catch (error) { + if (error instanceof GitHubError && error.status === 404) return false; + throw error; + } +} + +/** + * Gets the release PR a prerelease umbrella ships with: the alpha or beta + * release PR of `next` while it's in prerelease mode, then the release PR of + * `main`, which `next` was merged into. + */ +export async function getPrereleasePullRequest( + gh: GitHubClient, + mainPullRequest: ReleasePullRequest | undefined +): Promise { + return (await isNextInPreMode(gh)) ? getReleasePullRequest(gh, 'next') : mainPullRequest; +} + /** * Whether a PR of `withastro/astro`, such as a release PR, is merged. A PR * that doesn't exist, for example from an edited marker, isn't. diff --git a/scripts/lib/release-queue/refresh.test.ts b/scripts/lib/release-queue/refresh.test.ts index 1f8a6685d7ef0..442dec3fdfa27 100644 --- a/scripts/lib/release-queue/refresh.test.ts +++ b/scripts/lib/release-queue/refresh.test.ts @@ -10,10 +10,7 @@ import { getUmbrellaBody } from './umbrella.ts'; /** The next release from `main`, with release PR `number` open. */ const releaseWith = (number: number, astroVersion: string) => - getMainRelease({ - number, - versions: [{ name: 'astro', version: astroVersion }], - }); + getMainRelease({ number, versions: [{ name: 'astro', version: astroVersion }] }); const noRelease = getMainRelease(); @@ -47,6 +44,16 @@ const reviews = (number: number, target = 'main') => ({ [`repos/${REPO}/pulls?state=open&base=release-queue/${target}`]: [], }); +/** `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: '' }, + ], +}); + /** Answers whether release PR `number` of `withastro/astro` is merged. */ const releaseMerged = (number: number, merged: boolean) => ({ [`repos/withastro/astro/pulls/${number}`]: { number, merged }, @@ -222,7 +229,9 @@ describe('refreshUmbrella', () => { }); it('refreshes a prerelease umbrella, which always follows', async (t) => { - const github = stubGitHub(t, { getResponses: history('cloudflare-next') }); + const github = stubGitHub(t, { + getResponses: { ...history('cloudflare-next'), ...prereleaseOf(18269) }, + }); const prerelease: OpenUmbrella = { number: 43, title: 'Old title', @@ -236,7 +245,7 @@ describe('refreshUmbrella', () => { assert.deepEqual(patchOf(github.writes), { title: '[ci] release cloudflare-next docs', - body: getUmbrellaBody('cloudflare-next', { included }), + body: getUmbrellaBody('cloudflare-next', { releasePullRequest: 18269, included }), }); assert.deepEqual(labelWrites(github.writes), [ { @@ -304,6 +313,8 @@ describe('refreshOpenUmbrellas', () => { 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) => { diff --git a/scripts/lib/release-queue/refresh.ts b/scripts/lib/release-queue/refresh.ts index e5a374482983d..8a4a29ab90edb 100644 --- a/scripts/lib/release-queue/refresh.ts +++ b/scripts/lib/release-queue/refresh.ts @@ -1,8 +1,8 @@ import type { GitHubClient } from './github.ts'; import { syncMergeBlockReview } from './merge-block.ts'; import { isLiveFor, type QueueOptions } from './modes.ts'; -import { getReleasePullRequest, removeLabel } from './queries.ts'; -import { getMainRelease, type MainRelease } from './release.ts'; +import { getPrereleasePullRequest, getReleasePullRequest, removeLabel } from './queries.ts'; +import { getMainRelease, type MainRelease, type ReleasePullRequest } from './release.ts'; import { MAIN_BRANCH } from './routing.ts'; import { getUmbrellaHistory, listOpenUmbrellas, type OpenUmbrella } from './umbrella-queries.ts'; import { getReleaseState } from './umbrella-state.ts'; @@ -16,11 +16,12 @@ import { /** * Brings an umbrella PR up to date, writing only what changed. Its body - * always lists the included docs PRs. Its title, release label and linked - * release PR follow the open release PR, unless the umbrella is `kept` or - * `settled`. Leaves the `main` umbrella as it is before a major: a - * maintainer moves it to the prerelease branch. Returns the umbrella as it is - * after the refresh. + * always lists the included docs PRs. The `main` umbrella takes its title, + * release label and link from the open release PR of `main`, unless it is + * `kept` or `settled`. A prerelease umbrella takes its title and label from + * its branch, and links the release PR of its prerelease. Leaves the `main` + * umbrella as it is before a major: a maintainer moves it to the prerelease + * branch. Returns the umbrella as it is after the refresh. */ export async function refreshUmbrella( gh: GitHubClient, @@ -36,9 +37,12 @@ export async function refreshUmbrella( return umbrella; } + const linked = isFollowing + ? (await getLinkedReleasePullRequest(gh, umbrella, release))?.number + : state.releasePullRequest; const { commits, merged } = await getUmbrellaHistory(gh, repo, umbrella); const body = getUmbrellaBody(umbrella.target, { - releasePullRequest: isFollowing ? heading.releasePullRequest : state.releasePullRequest, + releasePullRequest: linked, included: selectIncludedPullRequests(merged, commits), }); @@ -63,6 +67,19 @@ export async function refreshUmbrella( return { ...umbrella, ...changes, labels }; } +/** + * Gets the release PR an umbrella that follows its release links: the one of + * `main`, or the one of the prerelease the umbrella ships with. + */ +async function getLinkedReleasePullRequest( + gh: GitHubClient, + umbrella: OpenUmbrella, + release: MainRelease +): Promise { + if (umbrella.target === MAIN_BRANCH) return release.pullRequest; + return getPrereleasePullRequest(gh, release.pullRequest); +} + /** Refreshes every open umbrella PR, for example after a release PR change. */ export async function refreshOpenUmbrellas({ actionsClient, botClient, repo, mode }: QueueOptions) { const [umbrellas, releasePullRequest] = await Promise.all([ diff --git a/scripts/lib/release-queue/umbrella.test.ts b/scripts/lib/release-queue/umbrella.test.ts index a25c52bcb91a8..3daaf8d137b80 100644 --- a/scripts/lib/release-queue/umbrella.test.ts +++ b/scripts/lib/release-queue/umbrella.test.ts @@ -16,11 +16,10 @@ const releasePullRequest = (astroVersion?: string) => ({ }); describe('getUmbrellaHeading', () => { - it('names the main umbrella after the astro version, and links the release PR', () => { + it('names the main umbrella after the astro version', () => { assert.deepEqual(getUmbrellaHeading('main', getMainRelease(releasePullRequest('7.4.1'))), { title: '[ci] release astro@7.4.1 docs', releaseLabel: 'patch-release', - releasePullRequest: 18459, }); }); @@ -33,12 +32,11 @@ describe('getUmbrellaHeading', () => { assert.deepEqual(getUmbrellaHeading('main', getMainRelease(releasePullRequest())), { title: '[ci] release main docs', releaseLabel: 'patch-release', - releasePullRequest: 18459, }); }); it('names a prerelease umbrella after its branch, as a major', () => { - const release = getMainRelease(releasePullRequest('7.4.0-beta.1')); + const release = getMainRelease(releasePullRequest('7.4.1')); assert.deepEqual(getUmbrellaHeading('cloudflare-next', release), { title: '[ci] release cloudflare-next docs', releaseLabel: 'major-release', @@ -94,10 +92,16 @@ describe('getUmbrellaBody', () => { assert.equal(parseReleaseMarker(body), undefined); }); - it('says a prerelease umbrella ships with its branch', () => { + it('links the release PR of a prerelease umbrella, with a marker', () => { + const body = getUmbrellaBody('cloudflare-next', { releasePullRequest: 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( getUmbrellaBody('cloudflare-next', { included }), - /^Ships with the prerelease branch\. This includes:/ + /^Ships with the next prerelease of `withastro\/astro`, once its `\[ci\] release` PR is open\. This includes:/ ); }); diff --git a/scripts/lib/release-queue/umbrella.ts b/scripts/lib/release-queue/umbrella.ts index 6db050a700509..2278b80aacdbd 100644 --- a/scripts/lib/release-queue/umbrella.ts +++ b/scripts/lib/release-queue/umbrella.ts @@ -17,17 +17,15 @@ export type Umbrella = { number: number } | { title: string }; export interface UmbrellaHeading { title: string; releaseLabel: ReleaseLabel; - /** The release PR of `withastro/astro` the umbrella links. */ - releasePullRequest?: number | undefined; } /** * Gets what the umbrella PR merging into `target` takes from the next release * from `main`. The `main` umbrella is named after the `astro` version, as - * merged PRs are posted to Discord, and links the release PR. A prerelease - * umbrella uses its branch name, which says what it is for, and is a major. - * On `main`, anything but a minor is a patch, including a release without - * `astro`. A major never gets here: `decide` refuses `main` PRs before one. + * merged PRs are posted to Discord. On `main`, anything but a minor is a + * patch, including a release without `astro`. A major never gets here: + * `decide` refuses `main` PRs before one. A prerelease umbrella uses its + * branch name, which says what it is for, and is a major. */ export function getUmbrellaHeading(target: string, release: MainRelease): UmbrellaHeading { if (target !== MAIN_BRANCH) { @@ -37,7 +35,6 @@ export function getUmbrellaHeading(target: string, release: MainRelease): Umbrel return { title: `[ci] release ${name} docs`, releaseLabel: release.type === 'minor' ? 'minor-release' : FALLBACK_RELEASE_LABEL, - releasePullRequest: release.pullRequest?.number, }; } @@ -95,12 +92,9 @@ export function getUmbrellaBody( } function describeShipping(target: string, releasePullRequest: number | undefined): string { - // The PR header and the title already name the branch. - if (target !== MAIN_BRANCH) return 'Ships with the prerelease branch.'; - if (releasePullRequest === undefined) { - return 'Ships with the next release of `withastro/astro`, once its `[ci] release` PR is open.'; - } - return `Ships with withastro/astro#${releasePullRequest}.`; + if (releasePullRequest !== undefined) return `Ships with withastro/astro#${releasePullRequest}.`; + const release = target === MAIN_BRANCH ? 'release' : 'prerelease'; + return `Ships with the next ${release} of \`withastro/astro\`, once its \`[ci] release\` PR is open.`; } /** From cf6adcdd97176fd6a09495555e48b3f0945a5bf6 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Thu, 22 Oct 2026 23:02:07 +0200 Subject: [PATCH 06/35] First pass to make the release queue code more readable - Bind the GitHub clients to their repository - Gather the configuration in one module - Split out the API types - Split the umbrella module by purpose - Move the entry point logic to a testable `runReleaseQueue()` - Rename generic names - Derive types from constants - Remove test-only exports - Complete doc comments --- .github/workflows/release-queue.yml | 2 +- scripts/lib/release-queue/cli.test.ts | 42 +++- scripts/lib/release-queue/cli.ts | 40 +++- scripts/lib/release-queue/config.ts | 45 ++++ scripts/lib/release-queue/github-error.ts | 23 ++ scripts/lib/release-queue/github-links.ts | 6 + scripts/lib/release-queue/github-types.ts | 59 +++++ scripts/lib/release-queue/github.test.ts | 23 +- scripts/lib/release-queue/github.ts | 66 +++--- .../lib/release-queue/handle-accepted.test.ts | 8 +- scripts/lib/release-queue/handle-accepted.ts | 171 ++++++++------ scripts/lib/release-queue/join.test.ts | 67 +++--- scripts/lib/release-queue/join.ts | 165 ++++++++------ scripts/lib/release-queue/json.ts | 13 ++ scripts/lib/release-queue/main.test.ts | 71 ++++++ scripts/lib/release-queue/main.ts | 81 +++++++ scripts/lib/release-queue/merge-block.test.ts | 144 ++++++------ scripts/lib/release-queue/merge-block.ts | 110 +++++---- scripts/lib/release-queue/messages.test.ts | 48 ++-- scripts/lib/release-queue/messages.ts | 87 +++---- scripts/lib/release-queue/modes.ts | 35 +-- .../release-queue/printing-github-client.ts | 17 ++ scripts/lib/release-queue/queries.test.ts | 54 ++++- scripts/lib/release-queue/queries.ts | 215 ++++++++---------- scripts/lib/release-queue/readiness.test.ts | 40 ++-- scripts/lib/release-queue/readiness.ts | 58 ++--- scripts/lib/release-queue/refresh.test.ts | 67 ++++-- scripts/lib/release-queue/refresh.ts | 98 ++++---- scripts/lib/release-queue/release.test.ts | 30 +-- scripts/lib/release-queue/release.ts | 9 +- scripts/lib/release-queue/repository.ts | 44 ++++ scripts/lib/release-queue/routing.test.ts | 62 ++--- scripts/lib/release-queue/routing.ts | 57 ++--- .../release-queue/testing/queue-options.ts | 9 +- .../lib/release-queue/testing/stub-github.ts | 11 +- scripts/lib/release-queue/text.ts | 7 + .../lib/release-queue/umbrella-body.test.ts | 87 +++++++ scripts/lib/release-queue/umbrella-body.ts | 70 ++++++ .../release-queue/umbrella-heading.test.ts | 38 ++++ scripts/lib/release-queue/umbrella-heading.ts | 41 ++++ .../lib/release-queue/umbrella-labels.test.ts | 42 ++++ scripts/lib/release-queue/umbrella-labels.ts | 46 ++++ scripts/lib/release-queue/umbrella-queries.ts | 55 +++-- scripts/lib/release-queue/umbrella-state.ts | 24 +- scripts/lib/release-queue/umbrella.test.ts | 164 ------------- scripts/lib/release-queue/umbrella.ts | 125 ---------- scripts/release-queue.ts | 50 +--- 47 files changed, 1720 insertions(+), 1106 deletions(-) create mode 100644 scripts/lib/release-queue/config.ts create mode 100644 scripts/lib/release-queue/github-error.ts create mode 100644 scripts/lib/release-queue/github-links.ts create mode 100644 scripts/lib/release-queue/github-types.ts create mode 100644 scripts/lib/release-queue/json.ts create mode 100644 scripts/lib/release-queue/main.test.ts create mode 100644 scripts/lib/release-queue/main.ts create mode 100644 scripts/lib/release-queue/printing-github-client.ts create mode 100644 scripts/lib/release-queue/repository.ts create mode 100644 scripts/lib/release-queue/text.ts create mode 100644 scripts/lib/release-queue/umbrella-body.test.ts create mode 100644 scripts/lib/release-queue/umbrella-body.ts create mode 100644 scripts/lib/release-queue/umbrella-heading.test.ts create mode 100644 scripts/lib/release-queue/umbrella-heading.ts create mode 100644 scripts/lib/release-queue/umbrella-labels.test.ts create mode 100644 scripts/lib/release-queue/umbrella-labels.ts delete mode 100644 scripts/lib/release-queue/umbrella.test.ts delete mode 100644 scripts/lib/release-queue/umbrella.ts diff --git a/.github/workflows/release-queue.yml b/.github/workflows/release-queue.yml index a28d95a8401d7..5c7b25e37e2f5 100644 --- a/.github/workflows/release-queue.yml +++ b/.github/workflows/release-queue.yml @@ -1,7 +1,7 @@ # Routes docs PRs labeled `accepted` to the umbrella PR of their release. # Run it manually to refresh the open umbrella PRs, for example after the # release PR of `withastro/astro` changed. -# Dry run for now: only comments with the umbrella a PR would join, or why not. +# `MODE` sets which PRs it acts on: see `scripts/lib/release-queue/modes.ts`. name: Release queue diff --git a/scripts/lib/release-queue/cli.test.ts b/scripts/lib/release-queue/cli.test.ts index 080f9c4952f59..be52ca68e9a35 100644 --- a/scripts/lib/release-queue/cli.test.ts +++ b/scripts/lib/release-queue/cli.test.ts @@ -1,17 +1,17 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { chooseAction } from './cli.ts'; +import { chooseAction, parseEvent } from './cli.ts'; describe('chooseAction', () => { it('handles the PR of the event', () => { assert.deepEqual(chooseAction({ eventName: 'pull_request_target', eventPullRequest: 7 }), { type: 'handle', - number: 7, + prNumber: 7, }); }); it('handles the PR passed with --pr', () => { - assert.deepEqual(chooseAction({ pr: '14580' }), { type: 'handle', number: 14580 }); + assert.deepEqual(chooseAction({ pr: '14580' }), { type: 'handle', prNumber: 14580 }); }); it('refreshes on a workflow_dispatch, or with --refresh', () => { @@ -33,3 +33,39 @@ describe('chooseAction', () => { assert.throws(() => chooseAction({ pr: '7', refresh: 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, + sender: 'maintainer', + }); + }); + + it('reads neither from a workflow_dispatch event', () => { + assert.deepEqual(parseEvent('{"inputs":{}}', path), { + pullRequest: undefined, + sender: 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 index 51c21a412a774..51083a0ff415a 100644 --- a/scripts/lib/release-queue/cli.ts +++ b/scripts/lib/release-queue/cli.ts @@ -1,4 +1,6 @@ -export type Action = { type: 'handle'; number: number } | { type: 'refresh' }; +import { isRecord, parseJson } from './json.ts'; + +export type Action = { type: 'handle'; prNumber: number } | { type: 'refresh' }; /** * Chooses what a run does, from an explicit signal: a refresh of the open @@ -27,5 +29,39 @@ export function chooseAction({ if (!/^\d+$/.test(value) || Number(value) === 0) { throw new Error(`Invalid PR number: ${value}. Pass --pr .`); } - return { type: 'handle', number: Number(value) }; + return { type: 'handle', prNumber: Number(value) }; +} + +/** What a run reads from the event of GitHub Actions. */ +export interface RunEvent { + pullRequest?: number | undefined; + /** Who added the label. */ + sender?: string | undefined; +} + +/** + * Reads the PR number and the sender from the event file at `path`. A + * `workflow_dispatch` event has neither. + */ +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), + sender: 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..86dbc41254d6d --- /dev/null +++ b/scripts/lib/release-queue/config.ts @@ -0,0 +1,45 @@ +// The names the release queue uses. The labels, the repositories, the +// reviewer and the Lunaria directive can change here. The branch names are +// also vocabulary: they appear in names like `main-only` or `major-on-main`, +// and in comments. + +export const DOCS_REPO = 'withastro/docs'; +export const FRAMEWORK_REPO = 'withastro/astro'; +/** The package of `withastro/astro` that names its releases. */ +export const FRAMEWORK_PACKAGE = 'astro'; +export const FRAMEWORK_MAIN_BRANCH = 'main'; +/** The branch of `withastro/astro` for its alpha and beta releases. */ +export const FRAMEWORK_PRERELEASE_BRANCH = 'next'; +/** The title prefix of `withastro/astro` release PRs, shared by umbrellas. */ +export const RELEASE_TITLE_PREFIX = '[ci] release'; + +export const MAIN_BRANCH = 'main'; +/** The reserved branch prefix for umbrellas. */ +export const UMBRELLA_PREFIX = 'release-queue/'; +export const MAIN_UMBRELLA_BRANCH = `${UMBRELLA_PREFIX}${MAIN_BRANCH}`; + +/** 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 label of each release type. */ +export const RELEASE_LABELS = { + patch: 'patch-release', + minor: 'minor-release', + major: 'major-release', +} as const; + +export type ReleaseLabel = (typeof RELEASE_LABELS)[keyof typeof RELEASE_LABELS]; + +export const ALL_RELEASE_LABELS: ReleaseLabel[] = Object.values(RELEASE_LABELS); + +/** The label of a release whose type isn't known yet. */ +export const FALLBACK_RELEASE_LABEL: ReleaseLabel = RELEASE_LABELS.patch; + +/** The bot behind `GITHUB_TOKEN`, which blocks umbrella merges. */ +export const 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'; diff --git a/scripts/lib/release-queue/github-error.ts b/scripts/lib/release-queue/github-error.ts new file mode 100644 index 0000000000000..78480d95ee27e --- /dev/null +++ b/scripts/lib/release-queue/github-error.ts @@ -0,0 +1,23 @@ +/** + * An error answer from the GitHub API. `githubReason` is the reason GitHub + * gives, with its details. + */ +export class GitHubError extends Error { + name = 'GitHubError'; + readonly status: number; + readonly githubReason: string; + + constructor({ + status, + githubReason, + request, + }: { + status: number; + githubReason: string; + request: string; + }) { + super(`${request} failed (${status}): ${githubReason}`); + this.status = status; + this.githubReason = githubReason; + } +} diff --git a/scripts/lib/release-queue/github-links.ts b/scripts/lib/release-queue/github-links.ts new file mode 100644 index 0000000000000..0542e18032ae0 --- /dev/null +++ b/scripts/lib/release-queue/github-links.ts @@ -0,0 +1,6 @@ +import { FRAMEWORK_REPO } from './config.ts'; + +/** Writes the reference to a PR of `withastro/astro`, which GitHub links. */ +export function formatFrameworkReference(prNumber: number): string { + return `${FRAMEWORK_REPO}#${prNumber}`; +} diff --git a/scripts/lib/release-queue/github-types.ts b/scripts/lib/release-queue/github-types.ts new file mode 100644 index 0000000000000..93edd865d31d9 --- /dev/null +++ b/scripts/lib/release-queue/github-types.ts @@ -0,0 +1,59 @@ +// 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; + merge_commit_sha: string | 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 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/github.test.ts b/scripts/lib/release-queue/github.test.ts index 43a199ebf0d33..4e5332dc2f163 100644 --- a/scripts/lib/release-queue/github.test.ts +++ b/scripts/lib/release-queue/github.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; -import { GitHubClient, GitHubError } from './github.ts'; +import { GitHubClient } from './github.ts'; +import { GitHubError } from './github-error.ts'; +import { PrintingGitHubClient } from './printing-github-client.ts'; import { json, stubGitHub } from './testing/stub-github.ts'; /** Replaces `fetch` with one answering `response` to every request. */ @@ -45,7 +47,7 @@ describe('GitHubClient', () => { (error: unknown) => { assert.ok(error instanceof GitHubError); assert.equal(error.status, 422); - assert.equal(error.reason, 'Validation Failed: No commits between main and x'); + assert.equal(error.githubReason, 'Validation Failed: No commits between main and x'); return true; } ); @@ -55,7 +57,7 @@ describe('GitHubClient', () => { answer(t, json({ message: 'Not Found' }, 404)); await assert.rejects(new GitHubClient('token').get('repos/withastro/docs/pulls/0'), { - reason: 'Not Found', + githubReason: 'Not Found', }); }); @@ -73,7 +75,7 @@ describe('GitHubClient', () => { await assert.rejects(new GitHubClient('token').get('repos/withastro/docs/pulls/7'), { name: 'GitHubError', status: 502, - reason: 'Bad Gateway', + githubReason: 'Bad Gateway', }); }); } @@ -82,15 +84,24 @@ describe('GitHubClient', () => { answer(t, new Response('Bad gateway', { status: 502, statusText: 'Bad Gateway' })); await assert.rejects(new GitHubClient('token').get('repos/withastro/docs/pulls/7'), { - reason: 'Bad Gateway', + 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('prints writes without sending them when asked to', async (t) => { const fetch = answer(t, json({})); t.mock.method(console, 'info', () => {}); - const data = await new GitHubClient('token', { shouldPrintWrites: true }).write( + const data = await new PrintingGitHubClient('token').write( 'POST', 'repos/withastro/docs/issues/7/comments', { body: 'Hello' } diff --git a/scripts/lib/release-queue/github.ts b/scripts/lib/release-queue/github.ts index 1f512d31b3652..13f06f2e25e54 100644 --- a/scripts/lib/release-queue/github.ts +++ b/scripts/lib/release-queue/github.ts @@ -1,22 +1,13 @@ import { setTimeout } from 'node:timers/promises'; +import { GitHubError } from './github-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 class GitHubError extends Error { - name = 'GitHubError'; - readonly status: number; - /** The reason GitHub gives, without the request. */ - readonly reason: string; - - constructor({ status, reason, request }: { status: number; reason: string; request: string }) { - super(`${request} failed (${status}): ${reason}`); - this.status = status; - this.reason = reason; - } -} - -type WriteMethod = 'POST' | 'PATCH' | 'PUT' | 'DELETE'; +export type WriteMethod = 'POST' | 'PATCH' | 'PUT' | 'DELETE'; /** An error body from GitHub. A validation error details it in `errors`. */ interface ErrorBody { @@ -25,20 +16,20 @@ interface ErrorBody { } /** - * A minimal GitHub REST client. It can print the requests that change GitHub - * instead of sending them. + * A minimal GitHub REST client. Callers declare the shape of the answers they + * read, which isn't checked: the API version is pinned, and the queries map + * each answer to the types the bot works with. */ export class GitHubClient { #token: string; - #shouldPrintWrites: boolean; #pollDelayMs: number; - constructor(token: string, { shouldPrintWrites = false, pollDelayMs = POLL_DELAY_MS } = {}) { + constructor(token: string, { pollDelayMs = POLL_DELAY_MS } = {}) { this.#token = token; - this.#shouldPrintWrites = shouldPrintWrites; this.#pollDelayMs = pollDelayMs; } + /** Gets `path`, relative to the API root. */ get(path: string): Promise { return this.#request('GET', path); } @@ -61,7 +52,9 @@ export class GitHubClient { /** * Gets every page of a list endpoint. * - * @param pick Extracts the items when the endpoint wraps them in an object. + * @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. */ async getAll( path: string, @@ -79,38 +72,39 @@ export class GitHubClient { } /** - * Sends a request that changes GitHub. Resolves to `undefined` when the - * request was only printed, or when GitHub answers without a body (204). + * Sends a request that changes GitHub. Resolves to `undefined` when GitHub + * answers without a body (204), or when the writes are only printed + * (`PrintingGitHubClient`). */ async write(method: WriteMethod, path: string, body?: unknown): Promise { - if (this.#shouldPrintWrites) { - console.info(`[print] ${method} ${path}`); - if (body) console.info(JSON.stringify(body, null, 2)); - return undefined; - } return this.#request(method, path, body); } async #request(method: string, path: string, body?: unknown): Promise { - const res = await fetch(`https://api.github.com/${path}`, { + const response = await fetch(`${API_URL}/${path}`, { method, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${this.#token}`, - 'User-Agent': 'astro-docs/1.0', - 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': USER_AGENT, + 'X-GitHub-Api-Version': API_VERSION, }, body: body === undefined ? undefined : JSON.stringify(body), }); - if (res.status === 204) return undefined as T; + if (response.status === 204) return undefined as T; // Error pages from a proxy (e.g. a 502) are HTML. - const isJson = res.headers.get('content-type')?.includes('application/json'); - const data = isJson ? await res.json() : await res.text(); - if (!res.ok) { - const reason = (isJson && getErrorReason(data)) || res.statusText; - throw new GitHubError({ status: res.status, reason, request: `${method} ${path}` }); + const isJson = response.headers.get('content-type')?.includes('application/json'); + const data = isJson ? await response.json() : await response.text(); + if (!response.ok) { + const reason = (isJson && getErrorReason(data)) || response.statusText; + throw new GitHubError({ + status: response.status, + githubReason: reason, + request: `${method} ${path}`, + }); } + if (!isJson) throw new Error(`${method} ${path} answered ${response.status} without JSON.`); return data; } } diff --git a/scripts/lib/release-queue/handle-accepted.test.ts b/scripts/lib/release-queue/handle-accepted.test.ts index cb6bb06f3cd65..ce50f83de58e5 100644 --- a/scripts/lib/release-queue/handle-accepted.test.ts +++ b/scripts/lib/release-queue/handle-accepted.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; -import { GitHubError } from './github.ts'; +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, queueOptions } from './testing/queue-options.ts'; import { stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; -import { getUmbrellaBody } from './umbrella.ts'; +import { formatUmbrellaBody } from './umbrella-body.ts'; const paths = { pr: `repos/${REPO}/pulls/7`, @@ -58,7 +58,7 @@ function readyPullRequest({ base = 'main', label = 'minor-release', umbrellaBody }; } -/** Responses for a PR that `decide` refuses: no approval. */ +/** 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. */ @@ -70,7 +70,7 @@ const firstJoin = () => ({ ...readyPullRequest(), [paths.mainUmbrella]: [] }); */ const settledUmbrella = () => ({ ...readyPullRequest({ - umbrellaBody: getUmbrellaBody('main', { releasePullRequest: 18405, included: [] }), + umbrellaBody: formatUmbrellaBody('main', { releasePullRequest: 18405, included: [] }), }), 'repos/withastro/astro/pulls/18405': { number: 18405, merged: true }, }); diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts index a8ee320f344c5..66debe0e35a82 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -1,3 +1,4 @@ +import { ACCEPTED_LABEL, FRAMEWORK_MAIN_BRANCH, MAIN_UMBRELLA_BRANCH } from './config.ts'; import { joinUmbrella } from './join.ts'; import { syncMergeBlockReview } from './merge-block.ts'; import { @@ -20,122 +21,136 @@ import { } from './queries.ts'; import { refreshUmbrella } from './refresh.ts'; import { getMainRelease, type MainRelease } from './release.ts'; +import type { Repository } from './repository.ts'; import { - ACCEPTED_LABEL, - decide, + decideJoin, getTarget, - MAIN_BRANCH, - UMBRELLA_PREFIX, type Decision, type RefusedDecision, type Route, - usesSettledUmbrella, + isRetargetedFromMain, } from './routing.ts'; +import { getUmbrellaHeading, type Umbrella } from './umbrella-heading.ts'; import { findOpenUmbrella, getUmbrella } from './umbrella-queries.ts'; import { getSettledMainUmbrella } from './umbrella-state.ts'; -import { getUmbrellaHeading, type Umbrella } from './umbrella.ts'; /** - * Processes a docs PR labeled `accepted`: reads the PR, the release PR of - * `withastro/astro` and the state of the `main` umbrella, decides, then - * comments as a dry run, refuses, or joins. + * A docs PR labeled `accepted`, with the release it ships with and the + * decision on it. + */ +interface AcceptedPullRequest { + pr: PullRequest; + release: MainRelease; + decision: D; +} + +type JoinDecision = Extract; + +/** + * Handles a docs PR labeled `accepted`: comments on it as a dry run, refuses + * it, or joins it to its umbrella. * - * @param sender Who added the label. Without one, the write access check is - * skipped. + * @param {QueueOptions} options - The repositories and the mode of the run. + * @param {number} prNumber - The number of the docs PR. + * @param {string} [sender] - Who added the label. Without it, the write + * access check is skipped, as in a local run. */ export async function handleAcceptedPullRequest( options: QueueOptions, - number: number, + prNumber: number, sender?: string ) { - const { actionsClient, repo } = options; + if (!(await isSenderAllowed(options.actions, sender))) return; + + const accepted = await readAcceptedPullRequest(options, prNumber); + const { pr, release, decision } = accepted; + console.info(JSON.stringify({ pr: pr.url, release, decision }, null, 2)); + if (!isLiveFor(options.mode, getTarget(pr.base))) { + await commentDryRun(options, accepted); + } else if (decision.status === 'refuse') { + await refuse(options, { ...accepted, decision }); + } else { + await joinAndAnnounce(options, { ...accepted, decision }); + } +} + +/** Whether `sender` can write to the repository, like maintainers do. */ +async function isSenderAllowed(repo: Repository, sender: string | undefined): Promise { if (sender === undefined) { console.info('No sender: skipping the write access check.'); - } else if (!(await canWrite(actionsClient, repo, sender))) { - console.info(`Ignored: ${sender} can't write to ${repo}.`); - return; + return true; } + if (await canWrite(repo, sender)) return true; + console.info(`Ignored: ${sender} can't write to ${repo.name}.`); + return false; +} - const pr = await getPullRequest(actionsClient, repo, number); +/** + * Reads the PR, the release PR of `withastro/astro` and the state of the + * `main` umbrella, then decides. + */ +async function readAcceptedPullRequest( + { actions, framework }: QueueOptions, + prNumber: number +): Promise { + const pr = await getPullRequest(actions, prNumber); const [readiness, releasePullRequest] = await Promise.all([ - getReadiness(actionsClient, repo, pr), - getReleasePullRequest(actionsClient), + getReadiness(actions, pr), + getReleasePullRequest(framework, FRAMEWORK_MAIN_BRANCH), ]); const release = getMainRelease(releasePullRequest); - const target = getTarget(pr.base); - const settledMainUmbrella = usesSettledUmbrella(pr) - ? await getSettledMainUmbrella(actionsClient, repo, release) + const settledMainUmbrella = isRetargetedFromMain(pr) + ? await getSettledMainUmbrella({ docs: actions, framework }, release) : undefined; - const decision = decide(pr, readiness, { release, settledMainUmbrella }); - console.info(JSON.stringify({ pr: pr.url, readiness, release, decision }, null, 2)); - - if (!isLiveFor(options.mode, target)) { - await commentDryRun(options, number, decision, release); - } else if (decision.status === 'refuse') { - await refuse(options, number, decision, release); - } else { - await join(options, pr, decision.route, release); - } + const decision = decideJoin(pr, readiness, { release, settledMainUmbrella }); + return { pr, release, decision }; } async function commentDryRun( options: QueueOptions, - number: number, - decision: Decision, - release: MainRelease + { pr, release, decision }: AcceptedPullRequest ) { - const { actionsClient, repo } = options; if (decision.status === 'join') { const umbrella = await findUmbrella(options, decision.route, release); await postComment( - actionsClient, - repo, - number, + options.actions, + pr.number, formatDryRunJoinComment(decision.route, umbrella) ); return; } const context = await getMessageContext(options, decision, release); - await postComment(actionsClient, repo, number, formatDryRunRefusalComment(decision, context)); + await postComment(options.actions, pr.number, formatDryRunRefusalComment(decision, context)); } async function refuse( options: QueueOptions, - number: number, - decision: RefusedDecision, - release: MainRelease + { pr, release, decision }: AcceptedPullRequest ) { - const { botClient, repo } = options; const context = await getMessageContext(options, decision, release); // Removed first, so the comment is true once posted. - await removeLabel(botClient, repo, number, ACCEPTED_LABEL); - await postComment(botClient, repo, number, formatRefusalComment(decision, context)); + await removeLabel(options.bot, pr.number, ACCEPTED_LABEL); + await postComment(options.bot, pr.number, formatRefusalComment(decision, context)); } -async function join(options: QueueOptions, pr: PullRequest, route: Route, release: MainRelease) { - const { actionsClient, botClient, repo } = options; +/** Joins the PR to its umbrella, then says how it went on the PR. */ +async function joinAndAnnounce(options: QueueOptions, accepted: AcceptedPullRequest) { + const { pr, release, decision } = accepted; + const { route } = decision; const { title } = getUmbrellaHeading(route.target, release); - const result = await joinUmbrella(botClient, repo, pr, route, title); + const result = await joinUmbrella(options.bot, { pr, route, umbrellaTitle: title }); switch (result.status) { - case 'joined': { - const { umbrella } = result; - await postComment(botClient, repo, pr.number, formatJoinComment(route, umbrella)); - // Without a number, the umbrella creation was only printed. - if ('number' in umbrella) { - const openUmbrella = await getUmbrella(botClient, repo, umbrella.number); - const refreshed = await refreshUmbrella(botClient, repo, openUmbrella, release); - await syncMergeBlockReview(actionsClient, repo, refreshed, { shouldBlock: true }); - } + case 'joined': + await announceJoin(options, accepted, result.umbrella); return; - } case 'refused': { - const decision: RefusedDecision = { status: 'refuse', route, refusals: [result.refusal] }; - await refuse(options, pr.number, decision, release); + const refused: RefusedDecision = { status: 'refuse', route, refusals: [result.refusal] }; + await refuse(options, { ...accepted, decision: refused }); return; } case 'unopened': - await postComment(botClient, repo, pr.number, formatUnopenedJoinComment(route, result)); + await postComment(options.bot, 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.`, @@ -148,15 +163,32 @@ async function join(options: QueueOptions, pr: PullRequest, route: Route, releas } } +/** + * Comments on the PR that joined `umbrella`, then refreshes the umbrella and + * blocks its merge until a maintainer checks it. + */ +async function announceJoin( + { actions, bot, framework }: QueueOptions, + { pr, release, decision }: AcceptedPullRequest, + umbrella: Umbrella +) { + await postComment(bot, pr.number, formatJoinComment(decision.route, umbrella)); + // A planned umbrella was only printed. + if (umbrella.status === 'planned') return; + const openUmbrella = await getUmbrella(bot, umbrella.number); + const refreshed = await refreshUmbrella({ docs: bot, framework }, openUmbrella, release); + await syncMergeBlockReview(actions, refreshed, { shouldBlock: true }); +} + /** Finds the open umbrella PR of `route`, or names the one to create. */ async function findUmbrella( - { actionsClient, repo }: QueueOptions, + { actions }: QueueOptions, route: Route, release: MainRelease ): Promise { - const openUmbrella = await findOpenUmbrella(actionsClient, repo, route.umbrellaBranch); - if (openUmbrella) return { number: openUmbrella.number }; - return { title: getUmbrellaHeading(route.target, release).title }; + const openUmbrella = await findOpenUmbrella(actions, route.umbrellaBranch); + if (openUmbrella) return { status: 'open', number: openUmbrella.number }; + return { status: 'planned', title: getUmbrellaHeading(route.target, release).title }; } async function getMessageContext( @@ -167,12 +199,7 @@ async function getMessageContext( const context: MessageContext = {}; if (decision.route) context.umbrella = await findUmbrella(options, decision.route, release); if (decision.refusals.some((refusal) => refusal.code === 'major-on-main')) { - const { actionsClient, repo } = options; - const mainUmbrella = await findOpenUmbrella( - actionsClient, - repo, - `${UMBRELLA_PREFIX}${MAIN_BRANCH}` - ); + const mainUmbrella = await findOpenUmbrella(options.actions, MAIN_UMBRELLA_BRANCH); if (mainUmbrella) context.mainUmbrella = { number: mainUmbrella.number }; } return context; diff --git a/scripts/lib/release-queue/join.test.ts b/scripts/lib/release-queue/join.test.ts index e32c5b942b0db..32dbb053127ae 100644 --- a/scripts/lib/release-queue/join.test.ts +++ b/scripts/lib/release-queue/join.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { GitHubError } from './github.ts'; +import { GitHubError } from './github-error.ts'; import { joinUmbrella } from './join.ts'; import type { PullRequest } from './queries.ts'; import type { Route } from './routing.ts'; @@ -91,9 +91,9 @@ describe('joinUmbrella', () => { 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 joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); + const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); - assert.deepEqual(result, { status: 'joined', umbrella: { number: 99 } }); + assert.deepEqual(result, { status: 'joined', umbrella: { status: 'open', number: 99 } }); assert.deepEqual(github.writes, [ createBranch, setBase('release-queue/main'), @@ -105,9 +105,9 @@ describe('joinUmbrella', () => { it('joins the open umbrella PR', async (t) => { const github = stubGitHub(t, { getResponses: withUmbrella }); - const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); + const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); - assert.deepEqual(result, { status: 'joined', umbrella: { number: 42 } }); + assert.deepEqual(result, { status: 'joined', umbrella: { status: 'open', number: 42 } }); assert.deepEqual(github.writes, [setBase('release-queue/main'), merge]); }); @@ -120,9 +120,9 @@ describe('joinUmbrella', () => { writeResults: createdUmbrella, }); - const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); + const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); - assert.deepEqual(result, { status: 'joined', umbrella: { number: 99 } }); + assert.deepEqual(result, { status: 'joined', umbrella: { status: 'open', number: 99 } }); assert.deepEqual(github.writes[0], createBranch); }); @@ -130,13 +130,11 @@ describe('joinUmbrella', () => { const github = stubGitHub(t, { getResponses: withUmbrella }); const umbrellaPr = { ...pr, base: 'release-queue/main' }; - await joinUmbrella( - github.client(), - REPO, - umbrellaPr, - { ...route, shouldRetarget: false }, - umbrellaTitle - ); + await joinUmbrella(github.repository(), { + pr: umbrellaPr, + route: { ...route, shouldRetarget: false }, + umbrellaTitle, + }); assert.deepEqual(github.writes, [merge]); }); @@ -145,13 +143,11 @@ describe('joinUmbrella', () => { const github = stubGitHub(t, { getResponses: withoutUmbrella, writeResults: createdUmbrella }); const umbrellaPr = { ...pr, base: 'release-queue/main' }; - await joinUmbrella( - github.client(), - REPO, - umbrellaPr, - { ...route, shouldRetarget: false }, - umbrellaTitle - ); + await joinUmbrella(github.repository(), { + pr: umbrellaPr, + route: { ...route, shouldRetarget: false }, + umbrellaTitle, + }); assert.deepEqual(github.writes, [createBranch, merge, openUmbrella]); }); @@ -166,7 +162,7 @@ describe('joinUmbrella', () => { writeResults: createdUmbrella, }); - await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); + await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); assert.deepEqual(github.writes, [ { @@ -189,7 +185,7 @@ describe('joinUmbrella', () => { }, }); - const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); + const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); assert.deepEqual(result, { status: 'refused', @@ -204,7 +200,7 @@ describe('joinUmbrella', () => { const github = stubGitHub(t, { getResponses: withoutMain }); await assert.rejects( - joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle), + joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), /doesn't exist/ ); assert.deepEqual(github.writes, []); @@ -215,7 +211,7 @@ describe('joinUmbrella', () => { getResponses: { ...withUmbrella, [paths.pr]: prResponse(null) }, }); - const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); + const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); assert.equal(result.status, 'joined'); assert.deepEqual(github.writes, [setBase('release-queue/main'), merge]); @@ -226,7 +222,7 @@ describe('joinUmbrella', () => { getResponses: { ...withUmbrella, [paths.pr]: prResponse(false) }, }); - const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); + const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); assert.deepEqual(result, { status: 'refused', @@ -246,7 +242,7 @@ describe('joinUmbrella', () => { }, }); - const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); + const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); assert.deepEqual(result, { status: 'refused', @@ -261,7 +257,10 @@ describe('joinUmbrella', () => { writeFailures: { [`PUT repos/${REPO}/pulls/7/merge`]: new TypeError('fetch failed') }, }); - await assert.rejects(joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle), TypeError); + await assert.rejects( + joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), + TypeError + ); assert.deepEqual(github.writes, [setBase('release-queue/main'), merge, setBase('main')]); }); @@ -273,7 +272,7 @@ describe('joinUmbrella', () => { }); await assert.rejects( - joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle), + joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), (error: unknown) => { assert.ok(error instanceof AggregateError); assert.deepEqual(error.errors, [restoreError]); @@ -295,11 +294,11 @@ describe('joinUmbrella', () => { }, }); - const result = await joinUmbrella(github.client(), REPO, pr, route, umbrellaTitle); + const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); assert.equal(result.status, 'unopened'); assert.ok(result.error instanceof GitHubError); - assert.equal(result.error.reason, 'Validation Failed: A pull request already exists'); + assert.equal(result.error.githubReason, 'Validation Failed: A pull request already exists'); assert.deepEqual(github.writes, [ createBranch, setBase('release-queue/main'), @@ -311,13 +310,13 @@ describe('joinUmbrella', () => { 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.client('token', { shouldPrintWrites: true }); + const printingClient = github.repository(REPO, { shouldPrintWrites: true }); - const result = await joinUmbrella(printingClient, REPO, pr, route, umbrellaTitle); + const result = await joinUmbrella(printingClient, { pr: pr, route: route, umbrellaTitle }); assert.deepEqual(result, { status: 'joined', - umbrella: { title: '[ci] release astro@7.5.0 docs' }, + umbrella: { status: 'planned', title: '[ci] release astro@7.5.0 docs' }, }); assert.deepEqual(github.writes, []); }); diff --git a/scripts/lib/release-queue/join.ts b/scripts/lib/release-queue/join.ts index dacca7663f004..584ef4fa551a5 100644 --- a/scripts/lib/release-queue/join.ts +++ b/scripts/lib/release-queue/join.ts @@ -1,8 +1,17 @@ -import { GitHubError, type GitHubClient } from './github.ts'; +import { GitHubError } from './github-error.ts'; import { countCommitsAhead, getBranchSha, getPullRequest, type PullRequest } from './queries.ts'; +import type { Repository } from './repository.ts'; import type { JoinRefusal, Route } from './routing.ts'; +import type { Umbrella } from './umbrella-heading.ts'; import { findOpenUmbrella } from './umbrella-queries.ts'; -import type { Umbrella } from './umbrella.ts'; + +/** A docs PR to join to the umbrella of `route`. */ +export interface Join { + pr: PullRequest; + route: Route; + /** The title of the umbrella PR, if the join opens it. */ + umbrellaTitle: string; +} /** * The outcome of a join. `unopened` means the PR was merged into the umbrella @@ -16,88 +25,104 @@ export type JoinResult = /** 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 (for example after a failed first merge), or left over + * with commits, which a maintainer has to look at. + */ +type UmbrellaBranch = { state: 'missing' | 'empty'; targetSha: string } | { state: 'stale' }; + /** * Squash-merges a docs PR into its umbrella branch. Prepares the branch before * changing the PR, then changes the PR base, merges, and opens the umbrella - * PR titled `umbrellaTitle` if none is open: GitHub refuses a PR without - * commits. A refused merge restores the PR base. Expected failures come back - * as a result, anything else throws. The refresh sets the labels and body. + * PR if none is open: GitHub refuses a PR without commits. A refused merge + * restores the PR base. Expected failures come back as a result, anything + * else throws. The refresh sets the labels and body. + * + * @param {Repository} repo - The docs repository, written as the bot account. + * @param {Join} join - The PR to merge, its route, and the umbrella title. */ export async function joinUmbrella( - gh: GitHubClient, - repo: string, - pr: PullRequest, - route: Route, - umbrellaTitle: string + repo: Repository, + { pr, route, umbrellaTitle }: Join ): Promise { - const openUmbrella = await findOpenUmbrella(gh, repo, route.umbrellaBranch); + const openUmbrella = await findOpenUmbrella(repo, route.umbrellaBranch); if (!openUmbrella) { - const refusal = await prepareUmbrellaBranch(gh, repo, route); - if (refusal) return { status: 'refused', refusal }; + const branch = await readUmbrellaBranch(repo, route); + if (branch.state === 'stale') { + const refusal: JoinRefusal = { code: 'stale-umbrella-branch', branch: route.umbrellaBranch }; + return { status: 'refused', refusal }; + } + await startUmbrellaBranch(repo, route, branch); } - if (route.shouldRetarget) await setBase(gh, repo, pr.number, route.umbrellaBranch); - const refusal = await mergeOrRestoreBase(gh, repo, pr, route); + const refusal = route.shouldRetarget + ? await retargetAndMerge(repo, pr, route) + : await squashMerge(repo, pr, route); if (refusal) return { status: 'refused', refusal }; - if (openUmbrella) return { status: 'joined', umbrella: { number: openUmbrella.number } }; - return createUmbrella(gh, repo, route, umbrellaTitle); + if (openUmbrella) + return { + status: 'joined', + umbrella: { status: 'open', number: openUmbrella.number }, + }; + return createUmbrella(repo, route, umbrellaTitle); } -/** - * Makes sure the umbrella branch starts from its target. A branch left over - * without commits, for example after a failed first merge, is reset. One with - * commits is left to a maintainer. Returns a refusal, or `undefined` when the - * branch is ready. - */ -async function prepareUmbrellaBranch( - gh: GitHubClient, - repo: string, +async function readUmbrellaBranch( + repo: Repository, { target, umbrellaBranch }: Route -): Promise { - const targetSha = await getBranchSha(gh, repo, target); - if (!targetSha) throw new Error(`The branch ${target} doesn't exist in ${repo}.`); +): Promise { + const targetSha = await getBranchSha(repo, target); + if (!targetSha) throw new Error(`The branch ${target} doesn't exist in ${repo.name}.`); + if ((await getBranchSha(repo, umbrellaBranch)) === undefined) { + return { state: 'missing', targetSha }; + } + const commitsAhead = await countCommitsAhead(repo, target, umbrellaBranch); + return commitsAhead > 0 ? { state: 'stale' } : { state: 'empty', targetSha }; +} - const umbrellaSha = await getBranchSha(gh, repo, umbrellaBranch); - if (umbrellaSha === undefined) { - await gh.write('POST', `repos/${repo}/git/refs`, { +/** Creates the umbrella branch from its target, or resets an empty one. */ +async function startUmbrellaBranch( + repo: Repository, + { umbrellaBranch }: Route, + branch: Extract +) { + if (branch.state === 'missing') { + await repo.write('POST', 'git/refs', { ref: `refs/heads/${umbrellaBranch}`, - sha: targetSha, + sha: branch.targetSha, }); - return undefined; + return; } - if ((await countCommitsAhead(gh, repo, target, umbrellaBranch)) > 0) { - return { code: 'stale-umbrella-branch', branch: umbrellaBranch }; - } - await gh.write('PATCH', `repos/${repo}/git/refs/heads/${umbrellaBranch}`, { - sha: targetSha, + await repo.write('PATCH', `git/refs/heads/${umbrellaBranch}`, { + sha: branch.targetSha, force: true, }); - return undefined; } -function setBase(gh: GitHubClient, repo: string, number: number, base: string) { - return gh.write('PATCH', `repos/${repo}/pulls/${number}`, { base }); +function setBase(repo: Repository, prNumber: number, base: string) { + return repo.write('PATCH', `pulls/${prNumber}`, { base }); } /** - * Squash-merges the PR, and restores its base when the merge is refused or - * throws. Returns a refusal, or `undefined` once merged. + * Changes the PR base to the umbrella branch, then squash-merges the PR. The + * base is restored when the merge is refused or throws. Returns a refusal, or + * `undefined` once merged. */ -async function mergeOrRestoreBase( - gh: GitHubClient, - repo: string, +async function retargetAndMerge( + repo: Repository, pr: PullRequest, route: Route ): Promise { - let refusal: JoinRefusal | undefined; - try { - refusal = await squashMerge(gh, repo, pr, route); - } catch (error) { - await restoreBase(gh, repo, pr, route, error); + await setBase(repo, pr.number, route.umbrellaBranch); + // Not a `try` around both paths: a failed restore after a refusal would + // then restore the base a second time. + const refusal = await squashMerge(repo, pr, route).catch(async (error: unknown) => { + await restoreBase(repo, pr, error); throw error; - } - if (refusal) await restoreBase(gh, repo, pr, route, refusal); + }); + if (refusal) await restoreBase(repo, pr, refusal); return refusal; } @@ -105,33 +130,25 @@ async function mergeOrRestoreBase( * Changes the PR base back. If that fails too, the error keeps both: the * failure to restore, and `cause`, the reason for restoring. */ -async function restoreBase( - gh: GitHubClient, - repo: string, - pr: PullRequest, - route: Route, - cause: unknown -) { - if (!route.shouldRetarget) return; +async function restoreBase(repo: Repository, pr: PullRequest, cause: unknown) { try { - await setBase(gh, repo, pr.number, pr.base); + await setBase(repo, pr.number, pr.base); } catch (error) { throw new AggregateError([error], `Couldn't restore the base of #${pr.number}.`, { cause }); } } async function squashMerge( - gh: GitHubClient, - repo: string, + repo: Repository, pr: PullRequest, route: Route ): Promise { // GitHub computes the mergeability again after a base change. - const { isMergeable } = await getPullRequest(gh, repo, pr.number); + const { isMergeable } = await getPullRequest(repo, pr.number); if (isMergeable === false) return { code: 'umbrella-conflict', branch: route.umbrellaBranch }; try { - await gh.write('PUT', `repos/${repo}/pulls/${pr.number}/merge`, { + await 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, @@ -141,27 +158,29 @@ async function squashMerge( }); return undefined; } catch (error) { - if (error instanceof GitHubError) return { code: 'merge-failed', reason: error.reason }; + if (error instanceof GitHubError) return { code: 'merge-failed', reason: error.githubReason }; throw error; } } async function createUmbrella( - gh: GitHubClient, - repo: string, + repo: Repository, { target, umbrellaBranch }: Route, title: string ): Promise | UnopenedJoin> { - let created: { number: number } | undefined; try { - created = await gh.write<{ number: number }>('POST', `repos/${repo}/pulls`, { + const created = await repo.write<{ number: number }>('POST', 'pulls', { title, head: umbrellaBranch, base: target, body: `Release umbrella for \`${target}\`, 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 }; } - return { status: 'joined', umbrella: created ? { number: created.number } : { title } }; } diff --git a/scripts/lib/release-queue/json.ts b/scripts/lib/release-queue/json.ts new file mode 100644 index 0000000000000..e675f088613bf --- /dev/null +++ b/scripts/lib/release-queue/json.ts @@ -0,0 +1,13 @@ +/** Whether a parsed JSON value is an object, and not an array. */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Parses `json`, or throws an error that names the `source` of it. */ +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/main.test.ts b/scripts/lib/release-queue/main.test.ts new file mode 100644 index 0000000000000..e4fd529a44c2d --- /dev/null +++ b/scripts/lib/release-queue/main.test.ts @@ -0,0 +1,71 @@ +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 } from './testing/fixtures.ts'; +import { stubGitHub } from './testing/stub-github.ts'; + +const noFlags = { refresh: false, write: 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('refreshes the open umbrellas with --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: { ...noFlags, 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('requires the bot token to write outside a dry run', async () => { + await assert.rejects( + runReleaseQueue({ + flags: { ...noFlags, refresh: true }, + env: { ...env, GITHUB_ACTIONS: 'true', MODE: 'all' }, + }), + /^Error: BOT_TOKEN is required in all mode\.$/ + ); + }); +}); diff --git a/scripts/lib/release-queue/main.ts b/scripts/lib/release-queue/main.ts new file mode 100644 index 0000000000000..9a0fc8893c363 --- /dev/null +++ b/scripts/lib/release-queue/main.ts @@ -0,0 +1,81 @@ +import { readFile } from 'node:fs/promises'; +import { chooseAction, parseEvent, type RunEvent } from './cli.ts'; +import { DOCS_REPO, FRAMEWORK_REPO } from './config.ts'; +import { GitHubClient } from './github.ts'; +import { handleAcceptedPullRequest } from './handle-accepted.ts'; +import { needsBotToken, parseMode, type QueueOptions } from './modes.ts'; +import { PrintingGitHubClient } from './printing-github-client.ts'; +import { refreshOpenUmbrellas } from './refresh.ts'; +import { Repository } from './repository.ts'; + +/** The command-line flags of `scripts/release-queue.ts`. */ +export interface Flags { + pr?: string | undefined; + refresh: boolean; + sender?: string | undefined; + write: boolean; +} + +/** The environment variables the release queue reads. */ +export interface Env { + /** Reads, dry-run comments and merge-block reviews. */ + GITHUB_TOKEN?: string | undefined; + /** The bot account, for joins, refusals and refreshes. */ + BOT_TOKEN?: string | undefined; + /** `dry-run` by default: see `Mode`. */ + MODE?: string | undefined; + // Set by GitHub Actions. + GITHUB_ACTIONS?: string | undefined; + GITHUB_EVENT_NAME?: string | undefined; + GITHUB_EVENT_PATH?: string | undefined; + GITHUB_REPOSITORY?: string | undefined; +} + +/** + * Routes a docs PR labeled `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 printed unless `write`. + */ +export async function runReleaseQueue({ flags, env }: { flags: Flags; env: Env }) { + const shouldWrite = env.GITHUB_ACTIONS === 'true' || flags.write; + const options = createOptions(env, shouldWrite); + const event = await readEvent(env.GITHUB_EVENT_PATH); + const action = chooseAction({ + pr: flags.pr, + refresh: flags.refresh, + eventName: env.GITHUB_EVENT_NAME, + eventPullRequest: event.pullRequest, + }); + + if (action.type === 'refresh') { + await refreshOpenUmbrellas(options); + } else { + await handleAcceptedPullRequest(options, action.prNumber, flags.sender ?? event.sender); + } +} + +function createOptions(env: Env, shouldWrite: boolean): QueueOptions { + const mode = parseMode(env.MODE); + const token = env.GITHUB_TOKEN; + if (!token) throw new Error('GITHUB_TOKEN is required.'); + // Changes go through the bot account: unlike `GITHUB_TOKEN`, its merges into + // an umbrella branch trigger the checks of the umbrella PR. + const botToken = env.BOT_TOKEN; + if (needsBotToken(mode, shouldWrite) && !botToken) { + throw new Error(`BOT_TOKEN is required in ${mode} 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, + }; +} + +async function readEvent(path: string | undefined): Promise { + return path ? parseEvent(await readFile(path, 'utf-8'), path) : {}; +} diff --git a/scripts/lib/release-queue/merge-block.test.ts b/scripts/lib/release-queue/merge-block.test.ts index 4a769e9f9292b..18392f3e5c23f 100644 --- a/scripts/lib/release-queue/merge-block.test.ts +++ b/scripts/lib/release-queue/merge-block.test.ts @@ -1,92 +1,104 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; -import { getMergeBlockBody, syncMergeBlockReview } from './merge-block.ts'; +import { syncMergeBlockReview } from './merge-block.ts'; import { REPO } from './testing/fixtures.ts'; import { stubGitHub } from './testing/stub-github.ts'; +import { formatUmbrellaBody } from './umbrella-body.ts'; import type { OpenUmbrella } from './umbrella-queries.ts'; -import { getUmbrellaBody } from './umbrella.ts'; /** The `main` umbrella #42, linked to release PR #18405. */ const umbrella: OpenUmbrella = { number: 42, title: '[ci] release astro@7.5.0 docs', - body: getUmbrellaBody('main', { releasePullRequest: 18405, included: [] }), + body: formatUmbrellaBody('main', { releasePullRequest: 18405, included: [] }), labels: ['merge-on-release', 'minor-release'], target: 'main', branch: 'release-queue/main', }; -describe('getMergeBlockBody', () => { - it('checks the linked release PR and the PRs left on the branch', () => { - assert.equal( - getMergeBlockBody(umbrella, []), - [ - '**Before merging**, check:', - '', - '- [ ] withastro/astro#18405 is merged.', - '- [ ] No open PR targets `release-queue/main`.', - '- [ ] If the umbrella has the `i18n` label, the commit body has a [Lunaria directive](https://lunaria.dev/guides/tracking/#triggering-changes-per-file), for example `@lunaria-track:src/content/docs/en/**.mdx`. The squash commit body is empty by default: add it in the merge dialog.', - '', - 'Then dismiss this review. The release queue requests changes again if more docs PRs join.', - ].join('\n') - ); +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, the commit body has a [Lunaria directive](https://lunaria.dev/guides/tracking/#triggering-changes-per-file), for example `@lunaria-track:src/content/docs/en/**.mdx`. The squash commit body is empty by default: add it in the merge dialog.', + '', + '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: OpenUmbrella, + openPullRequests: number[] = [] +): Promise { + const github = stub(t, [], openPullRequests); + await syncMergeBlockReview(github.repository(), reviewedUmbrella, { shouldBlock: true }); + return (github.writes[0].body as { body: string }).body; +} - it('lists the PRs that still target the umbrella branch', () => { - assert.match( - getMergeBlockBody(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', () => { - assert.match( - getMergeBlockBody({ ...umbrella, body: '' }, []), - /- \[ \] The `\[ci\] release` PR of `withastro\/astro` this umbrella ships with is merged\./ - ); - }); +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('reminds of the Lunaria directive before the i18n label arrives', () => { - // `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( - getMergeBlockBody(umbrella, []), - /- \[ \] If the umbrella has the `i18n` label, the commit body has a \[Lunaria directive\]/ - ); - }); -}); + 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`/ + ); + }); -describe('syncMergeBlockReview', () => { - const reviewsPath = `repos/${REPO}/pulls/42/reviews`; - const body = getMergeBlockBody(umbrella, []); - - function stub(t: TestContext, reviews: unknown[]) { - return stubGitHub(t, { - getResponses: { - [reviewsPath]: reviews, - [`repos/${REPO}/pulls?state=open&base=release-queue/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\./ + ); }); - } - const review = (id: number, state: string, reviewBody = body, login = 'github-actions[bot]') => ({ - id, - user: { login }, - state, - body: reviewBody, + 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, the commit body has 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 }, + 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.client(), REPO, umbrella, { shouldBlock: true }); + await syncMergeBlockReview(github.repository(), umbrella, { shouldBlock: true }); assert.deepEqual(github.writes, [submit]); }); @@ -94,7 +106,7 @@ describe('syncMergeBlockReview', () => { it('requests changes again after a join once the review was dismissed', async (t) => { const github = stub(t, [review(1, 'DISMISSED')]); - await syncMergeBlockReview(github.client(), REPO, umbrella, { shouldBlock: true }); + await syncMergeBlockReview(github.repository(), umbrella, { shouldBlock: true }); assert.deepEqual(github.writes, [submit]); }); @@ -102,7 +114,7 @@ describe('syncMergeBlockReview', () => { it("doesn't block again without a join", async (t) => { const github = stub(t, [review(1, 'DISMISSED')]); - await syncMergeBlockReview(github.client(), REPO, umbrella, { shouldBlock: false }); + await syncMergeBlockReview(github.repository(), umbrella, { shouldBlock: false }); assert.deepEqual(github.writes, []); }); @@ -110,16 +122,18 @@ describe('syncMergeBlockReview', () => { 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.client(), REPO, umbrella, { shouldBlock: true }); + await syncMergeBlockReview(github.repository(), umbrella, { shouldBlock: true }); - assert.deepEqual(github.writes, [{ method: 'PUT', path: `${reviewsPath}/2`, body: { body } }]); + 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', body.replaceAll('\n', '\r\n'))]); + const github = stub(t, [review(2, 'CHANGES_REQUESTED', checklist.replaceAll('\n', '\r\n'))]); - await syncMergeBlockReview(github.client(), REPO, umbrella, { shouldBlock: true }); + await syncMergeBlockReview(github.repository(), umbrella, { shouldBlock: true }); assert.deepEqual(github.writes, []); }); @@ -127,7 +141,7 @@ describe('syncMergeBlockReview', () => { 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.client(), REPO, umbrella, { shouldBlock: true }); + await syncMergeBlockReview(github.repository(), umbrella, { shouldBlock: true }); assert.deepEqual(github.writes, [submit]); }); diff --git a/scripts/lib/release-queue/merge-block.ts b/scripts/lib/release-queue/merge-block.ts index e22e6ed764830..90b3525d0337a 100644 --- a/scripts/lib/release-queue/merge-block.ts +++ b/scripts/lib/release-queue/merge-block.ts @@ -1,23 +1,17 @@ -import type { GitHubClient } from './github.ts'; -import type { ReviewResponse } from './queries.ts'; +import { + FRAMEWORK_REPO, + LUNARIA_DIRECTIVE, + LUNARIA_DIRECTIVE_DOCS, + RELEASE_TITLE_PREFIX, + REVIEWER_LOGIN, + TRANSLATIONS_LABEL, +} from './config.ts'; +import { formatFrameworkReference } from './github-links.ts'; +import type { ReviewResponse } from './github-types.ts'; +import type { Repository } from './repository.ts'; +import { normalizeLineEndings } from './text.ts'; +import { parseReleaseMarker } from './umbrella-body.ts'; import type { OpenUmbrella } from './umbrella-queries.ts'; -import { parseReleaseMarker } from './umbrella.ts'; - -/** - * Who submits the review: `GITHUB_TOKEN`. The bot account opens the umbrella - * PR, and GitHub doesn't let the author of a PR review it. - */ -const REVIEWER = 'github-actions[bot]'; - -/** - * The label `pr-labeler.yml` adds when translations change. It comes after - * the merge that brings them, with `GITHUB_TOKEN`, which doesn't trigger this - * workflow: the checklist can't wait for it, so it names it. - */ -const TRANSLATIONS_LABEL = 'i18n'; - -const LUNARIA_DIRECTIVE = '@lunaria-track:src/content/docs/en/**.mdx'; -const LUNARIA_DIRECTIVE_DOCS = 'https://lunaria.dev/guides/tracking/#triggering-changes-per-file'; interface SubmittedReview extends ReviewResponse { id: number; @@ -25,26 +19,51 @@ interface SubmittedReview extends ReviewResponse { } /** - * Writes the checklist a maintainer goes through before merging an umbrella - * PR, then dismisses the review that holds it. - * - * @param openPullRequests PRs that still target the umbrella branch. + * Writes the body of the merge-block review: the checklist a maintainer goes + * through before merging an umbrella PR with `openPullRequests` still on its + * branch, and the dismissal that lifts the block. */ -export function getMergeBlockBody(umbrella: OpenUmbrella, openPullRequests: number[]): string { - const releasePullRequest = parseReleaseMarker(umbrella.body); +function formatMergeBlockBody(umbrella: OpenUmbrella, openPullRequests: number[]): string { const items = [ - releasePullRequest === undefined - ? 'The `[ci] release` PR of `withastro/astro` this umbrella ships with is merged.' - : `withastro/astro#${releasePullRequest} is merged.`, - openPullRequests.length === 0 - ? `No open PR targets \`${umbrella.branch}\`.` - : `These PRs still target \`${umbrella.branch}\`: ${openPullRequests.map((number) => `#${number}`).join(', ')}. Merge them into the umbrella, or change their base to \`${umbrella.target}\` and update them after the umbrella is merged.`, - `If the umbrella has the \`${TRANSLATIONS_LABEL}\` label, the commit body has a [Lunaria directive](${LUNARIA_DIRECTIVE_DOCS}), for example \`${LUNARIA_DIRECTIVE}\`. The squash commit body is empty by default: add it in the merge dialog.`, + 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: OpenUmbrella): string { + const releasePullRequest = parseReleaseMarker(umbrella.body); + return releasePullRequest === undefined + ? `The \`${RELEASE_TITLE_PREFIX}\` PR of \`${FRAMEWORK_REPO}\` this umbrella ships with is merged.` + : `${formatFrameworkReference(releasePullRequest)} is merged.`; +} + +function describeBranchItem({ branch, target }: OpenUmbrella, openPullRequests: number[]): string { + if (openPullRequests.length === 0) return `No open PR targets \`${branch}\`.`; + const list = openPullRequests.map((prNumber) => `#${prNumber}`).join(', '); + return `These PRs still target \`${branch}\`: ${list}. Merge them into the umbrella, or change their base to \`${target}\` and update them after the umbrella is merged.`; +} + +/** + * Names the `i18n` 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 \`${TRANSLATIONS_LABEL}\` label, the commit body has a [Lunaria directive](${LUNARIA_DIRECTIVE_DOCS}), for example \`${LUNARIA_DIRECTIVE}\`. The squash commit body is empty by default: add it in the merge dialog.`; +} + +/** + * Finds the bot's review that still requests changes: a dismissal changes its + * state. + */ +function findActiveReview(reviews: SubmittedReview[]): SubmittedReview | undefined { + return reviews.findLast( + ({ user, state }) => user?.login === REVIEWER_LOGIN && state === 'CHANGES_REQUESTED' + ); +} + /** * Keeps the merge-block review of an umbrella PR up to date. Updates the * active review when there is one. Otherwise, submits a new one when @@ -52,34 +71,25 @@ export function getMergeBlockBody(umbrella: OpenUmbrella, openPullRequests: numb * that joined since. */ export async function syncMergeBlockReview( - gh: GitHubClient, - repo: string, + repo: Repository, umbrella: OpenUmbrella, { shouldBlock }: { shouldBlock: boolean } ) { const [reviews, openPullRequests] = await Promise.all([ - gh.getAll(`repos/${repo}/pulls/${umbrella.number}/reviews`), - gh.getAll<{ number: number }>(`repos/${repo}/pulls?state=open&base=${umbrella.branch}`), + repo.getAll(`pulls/${umbrella.number}/reviews`), + repo.getAll<{ number: number }>(`pulls?state=open&base=${umbrella.branch}`), ]); - const body = getMergeBlockBody( + const body = formatMergeBlockBody( umbrella, openPullRequests.map(({ number }) => number) ); - - // A dismissed review changes its state to `DISMISSED`. - const active = reviews.findLast( - ({ user, state }) => user?.login === REVIEWER && state === 'CHANGES_REQUESTED' - ); + const active = findActiveReview(reviews); if (active) { - if (active.body.replaceAll('\r\n', '\n') !== body) { - await gh.write('PUT', `repos/${repo}/pulls/${umbrella.number}/reviews/${active.id}`, { - body, - }); + if (normalizeLineEndings(active.body) !== body) { + await repo.write('PUT', `pulls/${umbrella.number}/reviews/${active.id}`, { body }); } - return; - } - if (shouldBlock) { - await gh.write('POST', `repos/${repo}/pulls/${umbrella.number}/reviews`, { + } else if (shouldBlock) { + await repo.write('POST', `pulls/${umbrella.number}/reviews`, { event: 'REQUEST_CHANGES', body, }); diff --git a/scripts/lib/release-queue/messages.test.ts b/scripts/lib/release-queue/messages.test.ts index 9dffdd53545df..36c6dee979659 100644 --- a/scripts/lib/release-queue/messages.test.ts +++ b/scripts/lib/release-queue/messages.test.ts @@ -1,13 +1,13 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { GitHubError } from './github.ts'; +import { GitHubError } from './github-error.ts'; import { - describeRefusal, formatDryRunJoinComment, formatDryRunRefusalComment, formatUnopenedJoinComment, formatJoinComment, formatRefusalComment, + type MessageContext, } from './messages.ts'; import type { Refusal, Route } from './routing.ts'; @@ -18,7 +18,15 @@ const route: Route = { shouldRetarget: true, }; -describe('describeRefusal', () => { +/** 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' }, @@ -74,7 +82,7 @@ describe('describeRefusal', () => { describe('formatDryRunJoinComment', () => { it('names the open umbrella a ready PR would join', () => { - const comment = formatDryRunJoinComment(route, { number: 42 }); + const comment = formatDryRunJoinComment(route, { status: 'open', number: 42 }); assert.match(comment, /^\*\*Release queue \(dry run\)\*\*/); assert.match( comment, @@ -86,7 +94,7 @@ describe('formatDryRunJoinComment', () => { it('names the umbrella PR that would be created', () => { const comment = formatDryRunJoinComment( { ...route, shouldRetarget: false }, - { title: '[ci] release astro@7.4.1 docs' } + { 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/); @@ -97,17 +105,17 @@ 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: { number: 42 } } + { 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' }], - }); + const comment = formatDryRunRefusalComment( + { status: 'refuse', refusals: [{ code: 'no-release-label' }] }, + {} + ); assert.match(comment, /wouldn't join its release umbrella yet:/); }); }); @@ -116,7 +124,7 @@ describe('formatRefusalComment', () => { it('lists every reason, and says the label was removed', () => { const comment = formatRefusalComment( { status: 'refuse', route, refusals: [{ code: 'not-approved' }] }, - { umbrella: { number: 42 } } + { umbrella: { status: 'open', number: 42 } } ); assert.match(comment, /^\*\*Release queue\*\*: this PR didn't join #42 into `main`:/); assert.match(comment, /- .*approval/); @@ -126,16 +134,16 @@ describe('formatRefusalComment', () => { it('names the umbrella to create by its title', () => { const comment = formatRefusalComment( { status: 'refuse', route, refusals: [{ code: 'not-approved' }] }, - { umbrella: { title: '[ci] release astro@7.4.1 docs' } } + { 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' }], - }); + const comment = formatRefusalComment( + { status: 'refuse', refusals: [{ code: 'no-release-label' }] }, + {} + ); assert.match(comment, /didn't join its release umbrella:/); }); }); @@ -143,14 +151,18 @@ describe('formatRefusalComment', () => { describe('formatJoinComment', () => { it('names the umbrella the PR joined', () => { assert.equal( - formatJoinComment(route, { number: 42 }), + 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, reason: 'Validation Failed', request: 'POST' }); + 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( diff --git a/scripts/lib/release-queue/messages.ts b/scripts/lib/release-queue/messages.ts index 6ddcd79fe8320..b3a11aa2c1477 100644 --- a/scripts/lib/release-queue/messages.ts +++ b/scripts/lib/release-queue/messages.ts @@ -1,13 +1,16 @@ -import { GitHubError } from './github.ts'; -import type { UnopenedJoin } from './join.ts'; import { ACCEPTED_LABEL, - type Refusal, - type RefusalCode, - type RefusedDecision, - type Route, -} from './routing.ts'; -import type { Umbrella } from './umbrella.ts'; + FRAMEWORK_MAIN_BRANCH, + FRAMEWORK_PACKAGE, + MAIN_BRANCH, + MAIN_UMBRELLA_BRANCH, + RELEASE_LABELS, +} from './config.ts'; +import { GitHubError } from './github-error.ts'; +import { formatFrameworkReference } from './github-links.ts'; +import type { UnopenedJoin } from './join.ts'; +import type { Refusal, RefusalCode, RefusedDecision, Route } from './routing.ts'; +import type { Umbrella } from './umbrella-heading.ts'; export interface MessageContext { /** The umbrella PR the docs PR would join. */ @@ -16,10 +19,10 @@ export interface MessageContext { mainUmbrella?: { number: number }; } -const code = (text: string) => `\`${text}\``; -const list = (items: string[]) => items.map(code).join(', '); +const wrapInCode = (text: string) => `\`${text}\``; +const joinAsCode = (items: string[]) => items.map(wrapInCode).join(', '); // GitHub's reasons sometimes end with a period already. -const sentence = (text: string) => (/[.!?]$/.test(text) ? text : `${text}.`); +const endWithPeriod = (text: string) => (/[.!?]$/.test(text) ? text : `${text}.`); type DescribeRefusal = ( refusal: Refusal, @@ -31,58 +34,60 @@ const REFUSAL_DESCRIPTIONS: { [C in RefusalCode]: DescribeRefusal } = { 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': () => - 'This PR has no release label. Add `patch-release` or `minor-release` for a PR against `main`, or `major-release` for a PR against a prerelease branch.', + `This PR has no release label. Add ${wrapInCode(RELEASE_LABELS.patch)} or ${wrapInCode(RELEASE_LABELS.minor)} for a PR against ${wrapInCode(MAIN_BRANCH)}, or ${wrapInCode(RELEASE_LABELS.major)} for a PR against a prerelease branch.`, 'several-release-labels': ({ labels }) => - `This PR has several release labels (${list(labels)}). Keep only one.`, + `This PR has several release labels (${joinAsCode(labels)}). Keep only one.`, mismatch: ({ base, label }) => - label === 'major-release' - ? `\`major-release\` PRs target a prerelease branch, but this one targets ${code(base)}. Change the base to the prerelease branch, or the label to \`patch-release\` or \`minor-release\`.` - : `${code(label)} PRs target \`main\`, but this one targets ${code(base)}. Change the base to \`main\`, or the label to \`major-release\`.`, + label === RELEASE_LABELS.major + ? `${wrapInCode(RELEASE_LABELS.major)} PRs target a prerelease branch, but this one targets ${wrapInCode(base)}. Change the base to the prerelease branch, or the label to ${wrapInCode(RELEASE_LABELS.patch)} or ${wrapInCode(RELEASE_LABELS.minor)}.` + : `${wrapInCode(label)} PRs target ${wrapInCode(MAIN_BRANCH)}, but this one targets ${wrapInCode(base)}. Change the base to ${wrapInCode(MAIN_BRANCH)}, or the label to ${wrapInCode(RELEASE_LABELS.major)}.`, 'major-on-main': ({ astroVersion }, { mainUmbrella }) => { - const release = astroVersion ? ` (${code(`astro@${astroVersion}`)})` : ''; + const release = astroVersion ? ` (${wrapInCode(`${FRAMEWORK_PACKAGE}@${astroVersion}`)})` : ''; const umbrella = mainUmbrella - ? ` The open \`main\` umbrella (#${mainUmbrella.number}) needs to be squashed into the prerelease branch by a maintainer.` + ? ` The open ${wrapInCode(MAIN_BRANCH)} umbrella (#${mainUmbrella.number}) needs to be squashed into the prerelease branch by a maintainer.` : ''; - return `The next \`astro\` release from \`main\` is a major${release}: docs for it belong on the prerelease branch. Change the base to the prerelease branch and the label to \`major-release\`.${umbrella}`; + return `The next ${wrapInCode(FRAMEWORK_PACKAGE)} release from ${wrapInCode(FRAMEWORK_MAIN_BRANCH)} is a major${release}: docs for it belong on the prerelease branch. Change the base to the prerelease branch and the label to ${wrapInCode(RELEASE_LABELS.major)}.${umbrella}`; }, 'settled-umbrella': ({ number, releasePullRequest }) => - `#${number} ships with withastro/astro#${releasePullRequest}, which is already merged. Merge #${number} first. If this PR documents that release, change its base to \`release-queue/main\` instead.`, + `#${number} ships with ${formatFrameworkReference(releasePullRequest)}, which is already merged. Merge #${number} first. If this PR documents that release, change its base to ${wrapInCode(MAIN_UMBRELLA_BRANCH)} instead.`, '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: ${list(checks)}. Fix them, or re-run them if they failed for an unrelated reason.`, + `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: ${list(checks)}. Wait for them to finish.`, + `These required checks haven't finished yet: ${joinAsCode(checks)}. Wait for them to finish.`, 'missing-checks': ({ checks }) => - `These required checks haven't run: ${list(checks)}. If the workflow runs are waiting for approval, approve them.`, + `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 ${code(branch)}. Resolve the conflicts with that branch.`, + `This PR conflicts with changes already in ${wrapInCode(branch)}. Resolve the conflicts with that branch.`, 'stale-umbrella-branch': ({ branch }) => - `The branch ${code(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: ${sentence(reason)}`, + `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. */ -export function describeRefusal( +function describeRefusal( refusal: Refusal, - context: MessageContext = {} + context: MessageContext ): string { return REFUSAL_DESCRIPTIONS[refusal.code](refusal, context); } function describeUmbrella(route: Route, umbrella: Umbrella): string { const name = - 'number' in umbrella ? `#${umbrella.number}` : `a new umbrella PR (${code(umbrella.title)})`; - return `${name} into ${code(route.target)}`; + umbrella.status === 'open' + ? `#${umbrella.number}` + : `a new umbrella PR (${wrapInCode(umbrella.title)})`; + return `${name} into ${wrapInCode(route.target)}`; } function describeRoute(route: Route, umbrella: Umbrella): string { const base = route.shouldRetarget - ? ` Its base would change to ${code(route.umbrellaBranch)}.` + ? ` Its base would change to ${wrapInCode(route.umbrellaBranch)}.` : ''; - return `This PR would be squash-merged into ${code(route.umbrellaBranch)}, the branch of ${describeUmbrella(route, umbrella)}.${base}`; + 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 { @@ -94,7 +99,7 @@ function describeUmbrellaOrFallback(route?: Route, umbrella?: Umbrella): string } function describeError(error: unknown): string { - if (error instanceof GitHubError) return error.reason; + if (error instanceof GitHubError) return error.githubReason; return error instanceof Error ? error.message : String(error); } @@ -108,29 +113,29 @@ export function formatDryRunJoinComment(route: Route, umbrella: Umbrella): strin /** Writes the dry-run comment of a PR that would be refused. */ export function formatDryRunRefusalComment( { route, refusals }: RefusedDecision, - context: MessageContext = {} + 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 ${code(ACCEPTED_LABEL)} label would be removed. It would need to be added again after fixing.`; + 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 `accepted` is removed. */ export function formatRefusalComment( { route, refusals }: RefusedDecision, - context: MessageContext = {} + 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 ${code(ACCEPTED_LABEL)} label was removed. Add it again after fixing.`; + 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: the umbrella the PR is now part of. */ export function formatJoinComment(route: Route, umbrella: Umbrella): string { - return `**Release queue**: squash-merged into ${code(route.umbrellaBranch)}, the branch of ${describeUmbrella(route, umbrella)}. It ships when the umbrella PR is merged.`; + 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. */ export function formatUnopenedJoinComment(route: Route, { error }: UnopenedJoin): string { - const branch = code(route.umbrellaBranch); - const reason = sentence(describeError(error)); - return `**Release queue**: squash-merged into ${branch}, but the umbrella PR into ${code(route.target)} couldn't be opened: ${reason} A maintainer needs to open it from ${branch}.`; + const branch = wrapInCode(route.umbrellaBranch); + const reason = endWithPeriod(describeError(error)); + return `**Release queue**: squash-merged into ${branch}, but the umbrella PR into ${wrapInCode(route.target)} couldn't be opened: ${reason} A maintainer needs to open it from ${branch}.`; } diff --git a/scripts/lib/release-queue/modes.ts b/scripts/lib/release-queue/modes.ts index 72e644829a125..825ccce882416 100644 --- a/scripts/lib/release-queue/modes.ts +++ b/scripts/lib/release-queue/modes.ts @@ -1,37 +1,42 @@ -import type { GitHubClient } from './github.ts'; -import { MAIN_BRANCH } from './routing.ts'; +import { MAIN_BRANCH } from './config.ts'; +import type { Repository } from './repository.ts'; + +const MODES = { dryRun: 'dry-run', mainOnly: 'main-only', all: 'all' } as const; /** + * The modes of the bot, from the most cautious: * - `dry-run`: only comments with the umbrella a PR would join, or why not. * - `main-only`: acts on PRs shipping to `main`, dry run for the others. * - `all`: acts on every PR. */ -export const MODES = ['dry-run', 'main-only', 'all'] as const; +export type Mode = (typeof MODES)[keyof typeof MODES]; -export type Mode = (typeof MODES)[number]; +const ALL_MODES: Mode[] = Object.values(MODES); /** What the bot runs with, whether it handles a PR or refreshes umbrellas. */ export interface QueueOptions { /** - * Reads the PR and the release PR, and posts dry-run comments, as - * `github-actions[bot]`. + * The docs repository as `github-actions[bot]`: reads the PR, posts dry-run + * comments, and submits merge-block reviews. */ - actionsClient: GitHubClient; - /** Joins, refuses, and refreshes as the bot account, reads included. */ - botClient: GitHubClient; - repo: string; + actions: Repository; + /** The docs repository as the bot account: joins, refuses, and refreshes. */ + bot: Repository; + /** `withastro/astro`, read for its release PRs. */ + framework: Repository; mode: Mode; } -export function parseMode(value = 'dry-run'): Mode { - const mode = MODES.find((candidate) => candidate === value); - if (!mode) throw new Error(`Unknown MODE: ${value}. Expected one of: ${MODES.join(', ')}.`); +/** Reads `MODE`, a dry run when it isn't set. */ +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 bot changes PRs shipping to `target`, rather than a dry run. */ export function isLiveFor(mode: Mode, target: string): boolean { - return mode === 'all' || (mode === 'main-only' && target === MAIN_BRANCH); + return mode === MODES.all || (mode === MODES.mainOnly && target === MAIN_BRANCH); } /** @@ -39,5 +44,5 @@ export function isLiveFor(mode: Mode, target: string): boolean { * `GITHUB_TOKEN`, and the umbrella PR would stop getting CI. */ export function needsBotToken(mode: Mode, shouldWrite: boolean): boolean { - return mode !== 'dry-run' && shouldWrite; + return mode !== MODES.dryRun && shouldWrite; } diff --git a/scripts/lib/release-queue/printing-github-client.ts b/scripts/lib/release-queue/printing-github-client.ts new file mode 100644 index 0000000000000..490eb0a5f3593 --- /dev/null +++ b/scripts/lib/release-queue/printing-github-client.ts @@ -0,0 +1,17 @@ +import { GitHubClient, type WriteMethod } from './github.ts'; + +/** + * 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; + } +} diff --git a/scripts/lib/release-queue/queries.test.ts b/scripts/lib/release-queue/queries.test.ts index d9d23b6ce8631..01bf30f73ac46 100644 --- a/scripts/lib/release-queue/queries.test.ts +++ b/scripts/lib/release-queue/queries.test.ts @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { FRAMEWORK_REPO } from './config.ts'; import { GitHubClient } from './github.ts'; import { getPrereleasePullRequest, getPullRequest } from './queries.ts'; +import { Repository } from './repository.ts'; import { pullRequestResponse, REPO } from './testing/fixtures.ts'; import { stubGitHub } from './testing/stub-github.ts'; @@ -16,7 +18,7 @@ describe('getPullRequest', () => { }, }); - const pr = await getPullRequest(github.client(), REPO, 7); + const pr = await getPullRequest(github.repository(), 7); assert.equal(pr.isMergeable, true); assert.equal(github.countReads(PR_PATH), 3); @@ -27,7 +29,7 @@ describe('getPullRequest', () => { getResponses: { [PR_PATH]: pullRequestResponse({ mergeable: null }) }, }); - const pr = await getPullRequest(github.client(), REPO, 7); + const pr = await getPullRequest(github.repository(), 7); assert.equal(pr.isMergeable, null); assert.equal(github.countReads(PR_PATH), 5); @@ -38,7 +40,7 @@ describe('getPullRequest', () => { getResponses: { [PR_PATH]: pullRequestResponse({ state: 'closed', mergeable: null }) }, }); - await getPullRequest(github.client(), REPO, 7); + await getPullRequest(github.repository(), 7); assert.equal(github.countReads(PR_PATH), 1); }); @@ -61,7 +63,7 @@ describe('getPrereleasePullRequest', () => { }, }); - const pr = await getPrereleasePullRequest(github.client(), mainPullRequest); + const pr = await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), mainPullRequest); assert.deepEqual(pr, { number: 18269, @@ -74,16 +76,25 @@ describe('getPrereleasePullRequest', () => { getResponses: { [preJson]: preMode('pre'), [nextPullRequests]: [] }, }); - assert.equal(await getPrereleasePullRequest(github.client(), mainPullRequest), undefined); + assert.equal( + await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), mainPullRequest), + 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 GitHubClient('token'), mainPullRequest), { - name: 'GitHubError', - status: 500, - }); + await assert.rejects( + getPrereleasePullRequest( + new Repository(new GitHubClient('token'), FRAMEWORK_REPO), + mainPullRequest + ), + { + name: 'GitHubError', + status: 500, + } + ); }); it('fails on a pre.json that is not JSON', async (t) => { @@ -91,15 +102,36 @@ describe('getPrereleasePullRequest', () => { getResponses: { [preJson]: { content: Buffer.from('{ mode: pre').toString('base64') } }, }); - await assert.rejects(getPrereleasePullRequest(github.client(), mainPullRequest), SyntaxError); + await assert.rejects( + getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), mainPullRequest), + (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), mainPullRequest), + /`\.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.client(), mainPullRequest), + await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), mainPullRequest), mainPullRequest ); assert.equal(github.countReads(nextPullRequests), 0); diff --git a/scripts/lib/release-queue/queries.ts b/scripts/lib/release-queue/queries.ts index bc08c492843dc..da2afe2f2e907 100644 --- a/scripts/lib/release-queue/queries.ts +++ b/scripts/lib/release-queue/queries.ts @@ -1,16 +1,29 @@ -import { GitHubError, type GitHubClient } from './github.ts'; +import { FRAMEWORK_PRERELEASE_BRANCH, MAIN_BRANCH } from './config.ts'; +import { GitHubError } from './github-error.ts'; +import type { + CheckRunResponse, + CommitStatusResponse, + PullRequestResponse, + ReviewResponse, + RuleResponse, +} from './github-types.ts'; +import { isRecord, parseJson } from './json.ts'; import { + classifyChecks, + classifyReviewers, getLatestReviewStates, - summarizeChecks, - summarizeReviews, - type CheckRun, - type CommitStatus, type Readiness, + type Review, } from './readiness.ts'; import { parseReleaseVersions, type ReleasePullRequest } from './release.ts'; -import { MAIN_BRANCH, type DocsPullRequest } from './routing.ts'; +import type { Repository } from './repository.ts'; +import type { DocsPullRequest } from './routing.ts'; + +/** The branch prefix of the release PRs that changesets opens. */ +const RELEASE_BRANCH_PREFIX = 'changeset-release/'; +/** The changesets file that holds the prerelease mode of a branch. */ +const PRE_STATE_PATH = '.changeset/pre.json'; -const FRAMEWORK_REPO = 'withastro/astro'; const MERGEABILITY_ATTEMPTS = 5; export interface PullRequest extends DocsPullRequest { @@ -21,41 +34,9 @@ export interface PullRequest extends DocsPullRequest { isMergeable: boolean | null; } -// The fields the bot reads from GitHub API responses. - -export interface PullRequestResponse { - number: number; - title: string; - html_url: string; - state: string; - draft: boolean; - mergeable: boolean | null; - merge_commit_sha: string | 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 ReviewResponse { - /** `null` for a deleted account. */ - user: { login: string } | null; - state: string; -} - -interface RuleResponse { - type: string; - parameters?: { required_status_checks?: { context: string }[] }; -} - -export async function getPullRequest( - gh: GitHubClient, - repo: string, - number: number -): Promise { - const data = await getPullRequestWithMergeability(gh, repo, number); +/** Gets a docs PR for routing, once GitHub knows if it's mergeable. */ +export async function getPullRequest(repo: Repository, prNumber: number): Promise { + const data = await getPullRequestWithMergeability(repo, prNumber); return { number: data.number, title: data.title, @@ -74,23 +55,17 @@ export async function getPullRequest( * Gets a PR once GitHub has computed its mergeability. The first request * starts the computation. */ -function getPullRequestWithMergeability(gh: GitHubClient, repo: string, number: number) { - return gh.poll(`repos/${repo}/pulls/${number}`, { +function getPullRequestWithMergeability(repo: Repository, prNumber: number) { + return repo.poll(`pulls/${prNumber}`, { isReady: (data) => data.state !== 'open' || data.mergeable !== null, attempts: MERGEABILITY_ATTEMPTS, }); } /** Gets the commit a branch points to, or `undefined` if it doesn't exist. */ -export async function getBranchSha( - gh: GitHubClient, - repo: string, - branch: string -): Promise { +export async function getBranchSha(repo: Repository, branch: string): Promise { try { - const { object } = await gh.get<{ object: { sha: string } }>( - `repos/${repo}/git/ref/heads/${branch}` - ); + const { object } = await repo.get<{ object: { sha: string } }>(`git/ref/heads/${branch}`); return object.sha; } catch (error) { if (error instanceof GitHubError && error.status === 404) return undefined; @@ -99,50 +74,44 @@ export async function getBranchSha( } /** Counts the commits of `head` that aren't in `base`. */ -export async function countCommitsAhead( - gh: GitHubClient, - repo: string, - base: string, - head: string -) { - const { ahead_by } = await compare(gh, repo, base, head); +export async function countCommitsAhead(repo: Repository, base: string, head: string) { + const { ahead_by } = await compare(repo, base, head); return ahead_by; } /** Lists the commits of `head` that aren't in `base`, oldest first. */ export async function listCommitsAhead( - gh: GitHubClient, - repo: string, + repo: Repository, base: string, head: string ): Promise { - const { commits } = await compare(gh, repo, base, head); + const { commits } = await compare(repo, base, head); return commits.map(({ sha }) => sha); } -function compare(gh: GitHubClient, repo: string, base: string, head: string) { - return gh.get<{ ahead_by: number; commits: { sha: string }[] }>( - `repos/${repo}/compare/${base}...${head}` - ); +function compare(repo: Repository, base: string, head: string) { + return repo.get<{ ahead_by: number; commits: { sha: string }[] }>(`compare/${base}...${head}`); } -export async function postComment(gh: GitHubClient, repo: string, number: number, body: string) { - await gh.write('POST', `repos/${repo}/issues/${number}/comments`, { body }); +/** Comments on a PR. */ +export async function postComment(repo: Repository, prNumber: number, body: string) { + await repo.write('POST', `issues/${prNumber}/comments`, { body }); } /** Removes a label from a PR. A label that is already gone is fine. */ -export async function removeLabel(gh: GitHubClient, repo: string, number: number, label: string) { +export async function removeLabel(repo: Repository, prNumber: number, label: string) { try { - await gh.write('DELETE', `repos/${repo}/issues/${number}/labels/${label}`); + await repo.write('DELETE', `issues/${prNumber}/labels/${label}`); } catch (error) { if (!(error instanceof GitHubError && error.status === 404)) throw error; } } -export async function canWrite(gh: GitHubClient, repo: string, user: string): Promise { +/** Whether `user` can write to the repository, from their permission on it. */ +export async function canWrite(repo: Repository, user: string): Promise { try { - const { permission } = await gh.get<{ permission: string }>( - `repos/${repo}/collaborators/${user}/permission` + const { permission } = await repo.get<{ permission: string }>( + `collaborators/${user}/permission` ); // The legacy `permission` field maps `maintain` to `write`, // and `triage` to `read`. @@ -158,45 +127,46 @@ export async function canWrite(gh: GitHubClient, repo: string, user: string): Pr * `main` ruleset: only reviewers with write access count, and only the checks * required on `main`. */ -export async function getReadiness( - gh: GitHubClient, - repo: string, - pr: PullRequest -): Promise { - const [reviews, checkRuns, combinedStatus, required] = await Promise.all([ - gh.getAll(`repos/${repo}/pulls/${pr.number}/reviews`), - gh.getAll( - `repos/${repo}/commits/${pr.headSha}/check-runs`, +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 ), - gh.get<{ statuses: CommitStatus[] }>(`repos/${repo}/commits/${pr.headSha}/status`), - getRequiredChecks(gh, repo), + repo.getAll( + `commits/${pr.headSha}/status`, + (data) => data.statuses + ), + getRequiredChecks(repo), ]); - const states = getLatestReviewStates( - reviews.flatMap(({ user, state }) => (user ? [{ user: user.login, state }] : [])) - ); - const writers = new Set(); - await Promise.all( - [...states.keys()].map(async (user) => { - if (await canWrite(gh, repo, user)) writers.add(user); - }) - ); - - const checks = summarizeChecks(required, checkRuns, combinedStatus.statuses); - + const states = getLatestReviewStates(toReviews(reviews)); + const writers = await getWriters(repo, [...states.keys()]); return { - ...summarizeReviews(states, writers), - failingChecks: checks.failing, - pendingChecks: checks.pending, - missingChecks: checks.missing, + ...classifyReviewers(states, writers), + ...classifyChecks(required, checkRuns, statuses), isMergeable: pr.isMergeable, }; } -/** Gets the checks required to merge into `main`. */ -async function getRequiredChecks(gh: GitHubClient, repo: string): Promise { - const rules = await gh.getAll(`repos/${repo}/rules/branches/${MAIN_BRANCH}`); +/** 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) => canWrite(repo, user))); + return new Set(users.filter((_, index) => canEachWrite[index])); +} + +/** + * Gets the checks the ruleset of `main` requires. They apply to every docs + * PR, whatever its base. + */ +async function getRequiredChecks(repo: Repository): Promise { + const rules = await repo.getAll(`rules/branches/${MAIN_BRANCH}`); return rules .filter((rule) => rule.type === 'required_status_checks') .flatMap((rule) => rule.parameters?.required_status_checks ?? []) @@ -208,50 +178,63 @@ async function getRequiredChecks(gh: GitHubClient, repo: string): Promise { - const [owner] = FRAMEWORK_REPO.split('/'); - const [pr] = await gh.get( - `repos/${FRAMEWORK_REPO}/pulls?state=open&head=${owner}:changeset-release/${branch}` + const [pr] = await framework.get( + `pulls?state=open&head=${framework.owner}:${RELEASE_BRANCH_PREFIX}${branch}` ); if (!pr) return undefined; return { number: pr.number, versions: parseReleaseVersions(pr.body ?? '') }; } /** Whether `next` in `withastro/astro` is in changesets prerelease mode. */ -async function isNextInPreMode(gh: GitHubClient): Promise { +async function isNextInPreMode(framework: Repository): Promise { try { - const { content } = await gh.get<{ content: string }>( - `repos/${FRAMEWORK_REPO}/contents/.changeset/pre.json?ref=next` + const { content } = await framework.get<{ content: string }>( + `contents/${PRE_STATE_PATH}?ref=${FRAMEWORK_PRERELEASE_BRANCH}` ); // `exit` means the prerelease ends: `next` is then merged into `main`. - return JSON.parse(Buffer.from(content, 'base64').toString()).mode === 'pre'; + return readPreMode(Buffer.from(content, 'base64').toString()) === 'pre'; } catch (error) { if (error instanceof GitHubError && error.status === 404) return false; throw error; } } +/** Reads the `mode` of the `.changeset/pre.json` of `next`. */ +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'; +} + /** * Gets the release PR a prerelease umbrella ships with: the alpha or beta * release PR of `next` while it's in prerelease mode, then the release PR of * `main`, which `next` was merged into. */ export async function getPrereleasePullRequest( - gh: GitHubClient, + framework: Repository, mainPullRequest: ReleasePullRequest | undefined ): Promise { - return (await isNextInPreMode(gh)) ? getReleasePullRequest(gh, 'next') : mainPullRequest; + return (await isNextInPreMode(framework)) + ? getReleasePullRequest(framework, FRAMEWORK_PRERELEASE_BRANCH) + : mainPullRequest; } /** * Whether a PR of `withastro/astro`, such as a release PR, is merged. A PR * that doesn't exist, for example from an edited marker, isn't. */ -export async function isFrameworkPullRequestMerged(gh: GitHubClient, number: number) { +export async function isFrameworkPullRequestMerged(framework: Repository, prNumber: number) { try { - const { merged } = await gh.get(`repos/${FRAMEWORK_REPO}/pulls/${number}`); + const { merged } = await framework.get(`pulls/${prNumber}`); return merged === true; } catch (error) { if (error instanceof GitHubError && error.status === 404) return false; diff --git a/scripts/lib/release-queue/readiness.test.ts b/scripts/lib/release-queue/readiness.test.ts index c8bf7db6c5977..17b9fd4c18b89 100644 --- a/scripts/lib/release-queue/readiness.test.ts +++ b/scripts/lib/release-queue/readiness.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { getLatestReviewStates, summarizeChecks, summarizeReviews } from './readiness.ts'; +import { getLatestReviewStates, classifyChecks, classifyReviewers } from './readiness.ts'; describe('getLatestReviewStates', () => { it('keeps the latest approval or change request of each reviewer', () => { @@ -35,10 +35,10 @@ describe('getLatestReviewStates', () => { }); }); -describe('summarizeReviews', () => { +describe('classifyReviewers', () => { it('counts the approval of a reviewer with write access', () => { const states = new Map([['alice', 'APPROVED' as const]]); - assert.deepEqual(summarizeReviews(states, new Set(['alice'])), { + assert.deepEqual(classifyReviewers(states, new Set(['alice'])), { isApproved: true, changesRequestedBy: [], }); @@ -46,7 +46,7 @@ describe('summarizeReviews', () => { it("doesn't count the approval of a reviewer without write access", () => { const states = new Map([['bob', 'APPROVED' as const]]); - assert.deepEqual(summarizeReviews(states, new Set()), { + assert.deepEqual(classifyReviewers(states, new Set()), { isApproved: false, changesRequestedBy: [], }); @@ -58,18 +58,18 @@ describe('summarizeReviews', () => { ['bob', 'CHANGES_REQUESTED' as const], ['carol', 'APPROVED' as const], ]); - assert.deepEqual(summarizeReviews(states, new Set(['alice', 'carol'])), { + assert.deepEqual(classifyReviewers(states, new Set(['alice', 'carol'])), { isApproved: true, changesRequestedBy: ['alice'], }); }); }); -describe('summarizeChecks', () => { +describe('classifyChecks', () => { const required = ['eslint', 'lint-ja', 'links', 'check', 'deploy', 'external', 'zizmor']; it('lists unfinished and unsuccessful required check runs and statuses', () => { - const summary = summarizeChecks( + const checks = classifyChecks( required, [ { name: 'eslint', status: 'completed', conclusion: 'success' }, @@ -83,15 +83,15 @@ describe('summarizeChecks', () => { { context: 'external', state: 'error' }, ] ); - assert.deepEqual(summary, { - failing: ['links', 'external'], - pending: ['check', 'deploy'], - missing: [], + assert.deepEqual(checks, { + failingChecks: ['links', 'external'], + pendingChecks: ['check', 'deploy'], + missingChecks: [], }); }); it('ignores checks that are not required', () => { - const summary = summarizeChecks( + const checks = classifyChecks( ['eslint'], [ { name: 'eslint', status: 'completed', conclusion: 'success' }, @@ -100,18 +100,22 @@ describe('summarizeChecks', () => { ], [] ); - assert.deepEqual(summary, { failing: [], pending: [], missing: [] }); + assert.deepEqual(checks, { failingChecks: [], pendingChecks: [], missingChecks: [] }); }); it('lists required checks that are not reported', () => { - assert.deepEqual(summarizeChecks(['eslint'], [], []), { - failing: [], - pending: [], - missing: ['eslint'], + assert.deepEqual(classifyChecks(['eslint'], [], []), { + failingChecks: [], + pendingChecks: [], + missingChecks: ['eslint'], }); }); it('accepts a commit when no check is required', () => { - assert.deepEqual(summarizeChecks([], [], []), { failing: [], pending: [], missing: [] }); + assert.deepEqual(classifyChecks([], [], []), { + failingChecks: [], + pendingChecks: [], + missingChecks: [], + }); }); }); diff --git a/scripts/lib/release-queue/readiness.ts b/scripts/lib/release-queue/readiness.ts index 07b36a1e39c0d..43e847ddc791a 100644 --- a/scripts/lib/release-queue/readiness.ts +++ b/scripts/lib/release-queue/readiness.ts @@ -1,20 +1,16 @@ +import type { + CheckConclusion, + CheckRunResponse, + CommitStatusResponse, + ReviewResponseState, +} from './github-types.ts'; + export interface Review { user: string; - state: string; -} - -export interface CheckRun { - name: string; - status: string; - conclusion: string | null; -} - -export interface CommitStatus { - context: string; - state: string; + state: ReviewResponseState; } -export type ReviewState = 'APPROVED' | 'CHANGES_REQUESTED'; +export type ReviewState = Extract; export interface Readiness { /** At least one reviewer with write access approved the PR. */ @@ -30,11 +26,10 @@ export interface Readiness { } /** - * Gets the latest review state of each reviewer. Comments don't replace an - * earlier state. A dismissed review clears it: an earlier review from the same + * Gets the latest review state of each reviewer, from `reviews` in the + * chronological order of the GitHub API. Comments don't replace an earlier + * state. A dismissed review clears it: an earlier review from the same * reviewer doesn't count again. - * - * @param reviews Reviews in chronological order, as returned by the GitHub API. */ export function getLatestReviewStates(reviews: Review[]): Map { const states = new Map(); @@ -46,30 +41,35 @@ export function getLatestReviewStates(reviews: Review[]): Map, writers: Set) { +export function classifyReviewers( + states: Map, + writers: Set +): Pick { const reviewersWith = (state: ReviewState) => - [...states].filter(([user, s]) => s === state && writers.has(user)).map(([user]) => user); + [...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']); +const SUCCESSFUL_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']); /** * 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. */ -export function summarizeChecks( +export function classifyChecks( required: string[], - checkRuns: CheckRun[], - statuses: CommitStatus[] -) { + checkRuns: CheckRunResponse[], + statuses: CommitStatusResponse[] +): Pick { const failing = new Set(); const pending = new Set(); const reported = new Set(); @@ -77,7 +77,7 @@ export function summarizeChecks( for (const run of checkRuns) { reported.add(run.name); if (run.status !== 'completed') pending.add(run.name); - else if (!SUCCESSFUL_CONCLUSIONS.has(run.conclusion ?? '')) failing.add(run.name); + else if (!run.conclusion || !SUCCESSFUL_CONCLUSIONS.has(run.conclusion)) failing.add(run.name); } for (const status of statuses) { reported.add(status.context); @@ -86,8 +86,8 @@ export function summarizeChecks( } return { - failing: required.filter((check) => failing.has(check)), - pending: required.filter((check) => pending.has(check)), - missing: required.filter((check) => !reported.has(check)), + failingChecks: required.filter((check) => failing.has(check)), + pendingChecks: required.filter((check) => pending.has(check)), + missingChecks: required.filter((check) => !reported.has(check)), }; } diff --git a/scripts/lib/release-queue/refresh.test.ts b/scripts/lib/release-queue/refresh.test.ts index 442dec3fdfa27..e411eb6611da0 100644 --- a/scripts/lib/release-queue/refresh.test.ts +++ b/scripts/lib/release-queue/refresh.test.ts @@ -1,12 +1,13 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; +import { FRAMEWORK_REPO } from './config.ts'; import { refreshOpenUmbrellas, refreshUmbrella } from './refresh.ts'; import { getMainRelease } from './release.ts'; import { pullRequestResponse, REPO, umbrellaResponse } from './testing/fixtures.ts'; import { ACTIONS_TOKEN, BOT_TOKEN, queueOptions } from './testing/queue-options.ts'; import { stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; import type { OpenUmbrella } from './umbrella-queries.ts'; -import { getUmbrellaBody } from './umbrella.ts'; +import { formatUmbrellaBody } from './umbrella-body.ts'; /** The next release from `main`, with release PR `number` open. */ const releaseWith = (number: number, astroVersion: string) => @@ -20,7 +21,7 @@ const included = [{ number: 7, title: 'Document the new option' }]; const umbrella: OpenUmbrella = { number: 42, title: '[ci] release astro@7.5.0 docs', - body: getUmbrellaBody('main', { releasePullRequest: 18405, included: [] }), + body: formatUmbrellaBody('main', { releasePullRequest: 18405, included: [] }), labels: ['merge-on-release', 'minor-release', 'i18n'], target: 'main', branch: 'release-queue/main', @@ -54,6 +55,12 @@ const prereleaseOf = (number: number) => ({ ], }); +/** The repositories a refresh reads, from the stub. */ +const repositories = (github: ReturnType) => ({ + docs: github.repository(), + framework: github.repository(FRAMEWORK_REPO), +}); + /** Answers whether release PR `number` of `withastro/astro` is merged. */ const releaseMerged = (number: number, merged: boolean) => ({ [`repos/withastro/astro/pulls/${number}`]: { number, merged }, @@ -70,7 +77,7 @@ const patchOf = (writes: Write[]) => const labelWrites = (writes: Write[]) => writes.filter(({ method }) => method !== 'PATCH'); const umbrellaBody = (releasePullRequest?: number) => - getUmbrellaBody('main', { releasePullRequest, included }); + formatUmbrellaBody('main', { releasePullRequest, included }); describe('refreshUmbrella', () => { describe('with a release PR open', () => { @@ -78,7 +85,7 @@ describe('refreshUmbrella', () => { const github = stub(t); const unlinked = { ...umbrella, body: 'Release umbrella for `main`.' }; - await refreshUmbrella(github.client(), REPO, unlinked, releaseWith(18405, '7.5.0')); + await refreshUmbrella(repositories(github), unlinked, releaseWith(18405, '7.5.0')); assert.deepEqual(patchOf(github.writes), { body: umbrellaBody(18405) }); }); @@ -86,7 +93,7 @@ describe('refreshUmbrella', () => { it('refreshes an umbrella that links the open release PR', async (t) => { const github = stub(t); - await refreshUmbrella(github.client(), REPO, umbrella, releaseWith(18405, '7.5.0')); + await refreshUmbrella(repositories(github), umbrella, releaseWith(18405, '7.5.0')); assert.deepEqual(github.writes, [ { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, @@ -97,7 +104,7 @@ describe('refreshUmbrella', () => { it('moves to the open release PR when the linked one is not merged', async (t) => { const github = stub(t, releaseMerged(18405, false)); - await refreshUmbrella(github.client(), REPO, umbrella, releaseWith(18460, '7.6.0')); + await refreshUmbrella(repositories(github), umbrella, releaseWith(18460, '7.6.0')); assert.deepEqual(patchOf(github.writes), { title: '[ci] release astro@7.6.0 docs', @@ -108,7 +115,7 @@ describe('refreshUmbrella', () => { it('keeps a settled umbrella as it is, but for the list', async (t) => { const github = stub(t, releaseMerged(18405, true)); - await refreshUmbrella(github.client(), REPO, umbrella, releaseWith(18460, '7.6.0')); + await refreshUmbrella(repositories(github), umbrella, releaseWith(18460, '7.6.0')); assert.deepEqual(github.writes, [ { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, @@ -124,8 +131,7 @@ describe('refreshUmbrella', () => { }; const refreshed = await refreshUmbrella( - github.client(), - REPO, + repositories(github), patchUmbrella, releaseWith(18405, '7.5.0') ); @@ -152,7 +158,7 @@ describe('refreshUmbrella', () => { // No answer for #18405: GitHub says 404. const github = stub(t); - await refreshUmbrella(github.client(), REPO, umbrella, releaseWith(18460, '7.6.0')); + await refreshUmbrella(repositories(github), umbrella, releaseWith(18460, '7.6.0')); assert.deepEqual(patchOf(github.writes), { title: '[ci] release astro@7.6.0 docs', @@ -163,7 +169,7 @@ describe('refreshUmbrella', () => { it('keeps a settled umbrella when the next release is a major', async (t) => { const github = stub(t, releaseMerged(18405, true)); - await refreshUmbrella(github.client(), REPO, umbrella, releaseWith(18460, '8.0.0')); + await refreshUmbrella(repositories(github), umbrella, releaseWith(18460, '8.0.0')); assert.deepEqual(github.writes, [ { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, @@ -174,8 +180,7 @@ describe('refreshUmbrella', () => { const github = stub(t); const refreshed = await refreshUmbrella( - github.client(), - REPO, + repositories(github), umbrella, releaseWith(18405, '8.0.0') ); @@ -189,7 +194,7 @@ describe('refreshUmbrella', () => { it('keeps the title, label and link, and rewrites the list', async (t) => { const github = stub(t, releaseMerged(18405, true)); - await refreshUmbrella(github.client(), REPO, umbrella, noRelease); + await refreshUmbrella(repositories(github), umbrella, noRelease); assert.deepEqual(github.writes, [ { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, @@ -199,7 +204,7 @@ describe('refreshUmbrella', () => { it('keeps an umbrella whose release PR was closed without merging', async (t) => { const github = stub(t, releaseMerged(18405, false)); - await refreshUmbrella(github.client(), REPO, umbrella, noRelease); + await refreshUmbrella(repositories(github), umbrella, noRelease); assert.deepEqual(github.writes, [ { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, @@ -215,7 +220,7 @@ describe('refreshUmbrella', () => { labels: [], }; - await refreshUmbrella(github.client(), REPO, newUmbrella, noRelease); + await refreshUmbrella(repositories(github), newUmbrella, noRelease); assert.deepEqual(patchOf(github.writes), { body: umbrellaBody() }); assert.deepEqual(labelWrites(github.writes), [ @@ -241,11 +246,11 @@ describe('refreshUmbrella', () => { branch: 'release-queue/cloudflare-next', }; - await refreshUmbrella(github.client(), REPO, prerelease, releaseWith(18405, '7.5.0')); + await refreshUmbrella(repositories(github), prerelease, releaseWith(18405, '7.5.0')); assert.deepEqual(patchOf(github.writes), { title: '[ci] release cloudflare-next docs', - body: getUmbrellaBody('cloudflare-next', { releasePullRequest: 18269, included }), + body: formatUmbrellaBody('cloudflare-next', { releasePullRequest: 18269, included }), }); assert.deepEqual(labelWrites(github.writes), [ { @@ -258,10 +263,9 @@ describe('refreshUmbrella', () => { it("doesn't write an umbrella that is up to date", async (t) => { const github = stub(t); - // As GitHub returns a description edited in the browser. - const upToDate = { ...umbrella, body: umbrellaBody(18405).replaceAll('\n', '\r\n') }; + const upToDate = { ...umbrella, body: umbrellaBody(18405) }; - await refreshUmbrella(github.client(), REPO, upToDate, releaseWith(18405, '7.5.0')); + await refreshUmbrella(repositories(github), upToDate, releaseWith(18405, '7.5.0')); assert.deepEqual(github.writes, []); }); @@ -297,6 +301,27 @@ describe('refreshOpenUmbrellas', () => { Number ); + it('reads a description edited in the browser as up to date', async (t) => { + const body = formatUmbrellaBody('main', { releasePullRequest: 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(queueOptions(github, 'main-only')); + + assert.deepEqual(github.writes, []); + }); + it("doesn't change anything in a dry run", async (t) => { const github = stubGitHub(t, { getResponses: openPullRequests }); diff --git a/scripts/lib/release-queue/refresh.ts b/scripts/lib/release-queue/refresh.ts index 8a4a29ab90edb..aecf6ebde7601 100644 --- a/scripts/lib/release-queue/refresh.ts +++ b/scripts/lib/release-queue/refresh.ts @@ -1,18 +1,19 @@ -import type { GitHubClient } from './github.ts'; import { syncMergeBlockReview } from './merge-block.ts'; import { isLiveFor, type QueueOptions } from './modes.ts'; import { getPrereleasePullRequest, getReleasePullRequest, removeLabel } from './queries.ts'; import { getMainRelease, type MainRelease, type ReleasePullRequest } from './release.ts'; -import { MAIN_BRANCH } from './routing.ts'; +import { FRAMEWORK_MAIN_BRANCH, MAIN_BRANCH } from './config.ts'; import { getUmbrellaHistory, listOpenUmbrellas, type OpenUmbrella } from './umbrella-queries.ts'; +import type { Repositories, Repository } from './repository.ts'; import { getReleaseState } from './umbrella-state.ts'; +import { formatUmbrellaBody, selectIncludedPullRequests } from './umbrella-body.ts'; +import { getUmbrellaHeading } from './umbrella-heading.ts'; import { + applyToLabels, getKeptLabelChanges, getLabelChanges, - getUmbrellaBody, - getUmbrellaHeading, - selectIncludedPullRequests, -} from './umbrella.ts'; + type LabelChanges, +} from './umbrella-labels.ts'; /** * Brings an umbrella PR up to date, writing only what changed. Its body @@ -24,47 +25,66 @@ import { * branch. Returns the umbrella as it is after the refresh. */ export async function refreshUmbrella( - gh: GitHubClient, - repo: string, + { docs, framework }: Repositories, umbrella: OpenUmbrella, release: MainRelease ): Promise { - const state = await getReleaseState(gh, umbrella, release); - const heading = getUmbrellaHeading(umbrella.target, release); + const state = await getReleaseState(framework, umbrella, release); const isFollowing = state.status === 'follows'; - if (isFollowing && umbrella.target === MAIN_BRANCH && release.type === 'major') { - console.info(`Skipped #${umbrella.number}: the next release from main is a major.`); + if (isFollowing && isBeforeMajor(umbrella, release)) { + console.info( + `Skipped #${umbrella.number}: the next release from ${FRAMEWORK_MAIN_BRANCH} is a major.` + ); return umbrella; } + const heading = getUmbrellaHeading(umbrella.target, release); const linked = isFollowing - ? (await getLinkedReleasePullRequest(gh, umbrella, release))?.number + ? (await getLinkedReleasePullRequest(framework, umbrella, release))?.number : state.releasePullRequest; - const { commits, merged } = await getUmbrellaHistory(gh, repo, umbrella); - const body = getUmbrellaBody(umbrella.target, { - releasePullRequest: linked, - included: selectIncludedPullRequests(merged, commits), + const { commits, closed } = await getUmbrellaHistory(docs, umbrella); + const textChanges = getTextChanges(umbrella, { + title: isFollowing ? heading.title : umbrella.title, + body: formatUmbrellaBody(umbrella.target, { + releasePullRequest: linked, + included: selectIncludedPullRequests(closed, commits), + }), }); - - const changes: { title?: string; body?: string } = {}; - if (isFollowing && heading.title !== umbrella.title) changes.title = heading.title; - // GitHub keeps the line endings of a description edited in the browser. - if (body !== umbrella.body.replaceAll('\r\n', '\n')) changes.body = body; - if (Object.keys(changes).length > 0) { - await gh.write('PATCH', `repos/${repo}/pulls/${umbrella.number}`, changes); + if (Object.keys(textChanges).length > 0) { + await docs.write('PATCH', `pulls/${umbrella.number}`, textChanges); } - const { toAdd, toRemove } = isFollowing + const labelChanges = isFollowing ? getLabelChanges(umbrella.labels, heading.releaseLabel) : getKeptLabelChanges(umbrella.labels); - if (toAdd.length > 0) { - await gh.write('POST', `repos/${repo}/issues/${umbrella.number}/labels`, { labels: toAdd }); - } - await Promise.all(toRemove.map((label) => removeLabel(gh, repo, umbrella.number, label))); + await applyLabelChanges(docs, umbrella.number, labelChanges); + return { ...umbrella, ...textChanges, labels: applyToLabels(umbrella.labels, labelChanges) }; +} - const removed = new Set(toRemove); - const labels = [...umbrella.labels, ...toAdd].filter((label) => !removed.has(label)); - return { ...umbrella, ...changes, labels }; +/** + * Whether the `main` umbrella waits for a major: the bot leaves it to a + * maintainer. + */ +function isBeforeMajor(umbrella: OpenUmbrella, release: MainRelease): boolean { + return umbrella.target === MAIN_BRANCH && release.type === 'major'; +} + +/** Gets the title and body of `wanted` that differ from the umbrella's. */ +function getTextChanges( + umbrella: OpenUmbrella, + wanted: { title: string; body: string } +): { title?: string; body?: string } { + return { + ...(wanted.title === umbrella.title ? {} : { title: wanted.title }), + ...(wanted.body === umbrella.body ? {} : { body: wanted.body }), + }; +} + +async function applyLabelChanges(docs: Repository, umbrellaNumber: number, changes: LabelChanges) { + if (changes.toAdd.length > 0) { + await docs.write('POST', `issues/${umbrellaNumber}/labels`, { labels: changes.toAdd }); + } + await Promise.all(changes.toRemove.map((label) => removeLabel(docs, umbrellaNumber, label))); } /** @@ -72,19 +92,19 @@ export async function refreshUmbrella( * `main`, or the one of the prerelease the umbrella ships with. */ async function getLinkedReleasePullRequest( - gh: GitHubClient, + framework: Repository, umbrella: OpenUmbrella, release: MainRelease ): Promise { if (umbrella.target === MAIN_BRANCH) return release.pullRequest; - return getPrereleasePullRequest(gh, release.pullRequest); + return getPrereleasePullRequest(framework, release.pullRequest); } /** Refreshes every open umbrella PR, for example after a release PR change. */ -export async function refreshOpenUmbrellas({ actionsClient, botClient, repo, mode }: QueueOptions) { +export async function refreshOpenUmbrellas({ actions, bot, framework, mode }: QueueOptions) { const [umbrellas, releasePullRequest] = await Promise.all([ - listOpenUmbrellas(actionsClient, repo), - getReleasePullRequest(actionsClient), + listOpenUmbrellas(actions), + getReleasePullRequest(framework, FRAMEWORK_MAIN_BRANCH), ]); const release = getMainRelease(releasePullRequest); const numbers = umbrellas.map(({ number }) => `#${number}`).join(', '); @@ -95,8 +115,8 @@ export async function refreshOpenUmbrellas({ actionsClient, botClient, repo, mod console.info(`Skipped #${umbrella.number}: dry run for ${umbrella.target}.`); continue; } - const refreshed = await refreshUmbrella(botClient, repo, umbrella, release); + const refreshed = await refreshUmbrella({ docs: bot, framework }, umbrella, release); // Without a join, a dismissed review still covers the included PRs. - await syncMergeBlockReview(actionsClient, repo, refreshed, { shouldBlock: false }); + await syncMergeBlockReview(actions, refreshed, { shouldBlock: false }); } } diff --git a/scripts/lib/release-queue/release.test.ts b/scripts/lib/release-queue/release.test.ts index a15c969c9535c..b4218417ac922 100644 --- a/scripts/lib/release-queue/release.test.ts +++ b/scripts/lib/release-queue/release.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { findVersion, getMainRelease, getReleaseType, parseReleaseVersions } from './release.ts'; +import { getMainRelease, parseReleaseVersions } from './release.ts'; const body = `This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. @@ -32,34 +32,28 @@ describe('parseReleaseVersions', () => { }); }); -describe('findVersion', () => { - it('finds a package by its exact name', () => { - const versions = parseReleaseVersions(body); - assert.equal(findVersion(versions, 'astro'), '7.4.1'); - assert.equal(findVersion(versions, '@astrojs/node'), undefined); +describe('getMainRelease', () => { + const pullRequest = (versions: { name: string; version: string }[]) => ({ + number: 18459, + versions, + }); + + it('takes the version of the astro package by its exact name', () => { + const release = getMainRelease(pullRequest(parseReleaseVersions(body))); + assert.equal(release.astroVersion, '7.4.1'); }); -}); -describe('getReleaseType', () => { 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'], - [undefined, 'patch'], ] as const) { - it(`reads ${version} as a ${type} release`, () => { - assert.equal(getReleaseType(version), type); + it(`reads astro@${version} as a ${type} release`, () => { + assert.equal(getMainRelease(pullRequest([{ name: 'astro', version }])).type, type); }); } -}); - -describe('getMainRelease', () => { - const pullRequest = (versions: { name: string; version: string }[]) => ({ - number: 18459, - versions, - }); it('reads a patch when there is no release PR', () => { assert.deepEqual(getMainRelease(), { diff --git a/scripts/lib/release-queue/release.ts b/scripts/lib/release-queue/release.ts index 0853c05d266a1..e798719f5f54e 100644 --- a/scripts/lib/release-queue/release.ts +++ b/scripts/lib/release-queue/release.ts @@ -1,3 +1,5 @@ +import { FRAMEWORK_PACKAGE } from './config.ts'; + export interface PackageVersion { name: string; version: string; @@ -27,7 +29,8 @@ export function parseReleaseVersions(body: string): PackageVersion[] { })); } -export function findVersion(versions: PackageVersion[], name: string): string | undefined { +/** Finds the version of the package `name` among the released `versions`. */ +function findVersion(versions: PackageVersion[], name: string): string | undefined { return versions.find((pkg) => pkg.name === name)?.version; } @@ -36,7 +39,7 @@ export function findVersion(versions: PackageVersion[], name: string): string | * versions count as patches: only a new stable minor or major changes how docs * PRs are routed. */ -export function getReleaseType(version: string | undefined): ReleaseType { +function getReleaseType(version: string | undefined): ReleaseType { const match = version?.match(/^\d+\.(\d+)\.(\d+)$/); if (!match) return 'patch'; const [, minor, patch] = match; @@ -46,6 +49,6 @@ export function getReleaseType(version: string | undefined): ReleaseType { /** Gets the next release from `main` from its open release PR, if any. */ export function getMainRelease(pullRequest?: ReleasePullRequest): MainRelease { - const astroVersion = findVersion(pullRequest?.versions ?? [], 'astro'); + const astroVersion = findVersion(pullRequest?.versions ?? [], FRAMEWORK_PACKAGE); return { type: getReleaseType(astroVersion), astroVersion, pullRequest }; } diff --git a/scripts/lib/release-queue/repository.ts b/scripts/lib/release-queue/repository.ts new file mode 100644 index 0000000000000..e1c3309f6f1f5 --- /dev/null +++ b/scripts/lib/release-queue/repository.ts @@ -0,0 +1,44 @@ +import type { GitHubClient, WriteMethod } from './github.ts'; + +/** The docs repository, and `withastro/astro` for its release PRs. */ +export interface Repositories { + docs: Repository; + framework: Repository; +} + +/** + * A GitHub repository, read and changed through a client. Its methods are + * those of `GitHubClient`, with paths relative to the repository. + */ +export class Repository { + readonly name: string; + readonly owner: string; + #client: GitHubClient; + + /** Binds `client` to the repository `name`, such as `withastro/docs`. */ + 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)); + } + + 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); + } + + #url(path: string): string { + return `repos/${this.name}/${path}`; + } +} diff --git a/scripts/lib/release-queue/routing.test.ts b/scripts/lib/release-queue/routing.test.ts index 1f04587f33d91..f2cc7af8175e4 100644 --- a/scripts/lib/release-queue/routing.test.ts +++ b/scripts/lib/release-queue/routing.test.ts @@ -2,12 +2,12 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { Readiness } from './readiness.ts'; import { - decide, + decideJoin, getTarget, - type DecideContext, + type DecisionContext, type Decision, type DocsPullRequest, - usesSettledUmbrella, + isRetargetedFromMain, } from './routing.ts'; const pr = (overrides: Partial = {}): DocsPullRequest => ({ @@ -28,21 +28,21 @@ const ready: Readiness = { isMergeable: true, }; -const patch: DecideContext = { release: { type: 'patch', astroVersion: '7.4.1' } }; -const major: DecideContext = { release: { type: 'major', astroVersion: '8.0.0' } }; +const patch: DecisionContext = { release: { type: 'patch', astroVersion: '7.4.1' } }; +const major: DecisionContext = { release: { type: 'major', astroVersion: '8.0.0' } }; /** The `main` umbrella #42 ships with a merged release PR. */ -const settled: DecideContext = { +const settled: DecisionContext = { ...patch, settledMainUmbrella: { number: 42, releasePullRequest: 18405 }, }; const refusalsOf = (decision: Decision) => (decision.status === 'refuse' ? decision.refusals : []); -describe('decide', () => { +describe('decideJoin', () => { describe('routes a ready docs PR', () => { for (const label of ['patch-release', 'minor-release']) { it(`to the main umbrella with ${label}`, () => { - assert.deepEqual(decide(pr({ labels: [label] }), ready, patch), { + assert.deepEqual(decideJoin(pr({ labels: [label] }), ready, patch), { status: 'join', route: { target: 'main', @@ -55,7 +55,7 @@ describe('decide', () => { } it('to a prerelease umbrella with major-release', () => { - const decision = decide( + const decision = decideJoin( pr({ base: 'cloudflare-next', labels: ['major-release'] }), ready, major @@ -72,7 +72,7 @@ describe('decide', () => { }); it('to the umbrella it already targets, without retargeting', () => { - assert.deepEqual(decide(pr({ base: 'release-queue/main' }), ready, patch), { + assert.deepEqual(decideJoin(pr({ base: 'release-queue/main' }), ready, patch), { status: 'join', route: { target: 'main', @@ -86,49 +86,49 @@ describe('decide', () => { describe('refuses', () => { it('a closed PR, and nothing else', () => { - const decision = decide(pr({ state: 'closed', isDraft: true }), ready, patch); + const decision = decideJoin(pr({ state: 'closed', isDraft: true }), ready, patch); assert.deepEqual(decision, { status: 'refuse', refusals: [{ code: 'closed' }] }); }); it('an umbrella PR, and nothing else', () => { - const decision = decide(pr({ head: 'release-queue/main' }), ready, patch); + const decision = decideJoin(pr({ head: 'release-queue/main' }), ready, patch); assert.deepEqual(decision, { status: 'refuse', refusals: [{ code: 'umbrella' }] }); }); it('a draft PR', () => { - assert.deepEqual(refusalsOf(decide(pr({ isDraft: true }), ready, patch)), [ + assert.deepEqual(refusalsOf(decideJoin(pr({ isDraft: true }), ready, patch)), [ { code: 'draft' }, ]); }); it('a PR without a release label', () => { - const decision = decide(pr({ labels: ['accepted'] }), ready, patch); + const decision = decideJoin(pr({ labels: ['accepted'] }), ready, patch); assert.deepEqual(decision, { status: 'refuse', refusals: [{ code: 'no-release-label' }] }); }); it('a PR with several release labels', () => { - const decision = decide(pr({ labels: ['minor-release', 'patch-release'] }), ready, patch); + 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('a major-release PR against main', () => { - const decision = decide(pr({ labels: ['major-release'] }), ready, patch); + const decision = decideJoin(pr({ labels: ['major-release'] }), ready, patch); assert.deepEqual(refusalsOf(decision), [ { code: 'mismatch', base: 'main', label: 'major-release' }, ]); }); it('a minor-release PR against a prerelease branch', () => { - const decision = decide(pr({ base: 'v8', labels: ['minor-release'] }), ready, patch); + const decision = decideJoin(pr({ base: 'v8', labels: ['minor-release'] }), ready, patch); assert.deepEqual(refusalsOf(decision), [ { code: 'mismatch', base: 'v8', label: 'minor-release' }, ]); }); it('a major-release PR against the main umbrella branch', () => { - const decision = decide( + const decision = decideJoin( pr({ base: 'release-queue/main', labels: ['major-release'] }), ready, patch @@ -139,12 +139,12 @@ describe('decide', () => { }); it('a main PR when the next astro release from main is a major', () => { - const decision = decide(pr(), ready, major); + const decision = decideJoin(pr(), ready, major); assert.deepEqual(refusalsOf(decision), [{ code: 'major-on-main', astroVersion: '8.0.0' }]); }); it('a PR that is not ready, with every reason', () => { - const decision = decide( + const decision = decideJoin( pr(), { isApproved: false, @@ -167,7 +167,7 @@ describe('decide', () => { }); it('a PR with both a routing and a readiness problem, with both reasons', () => { - const decision = decide(pr({ labels: [] }), { ...ready, isApproved: false }, patch); + const decision = decideJoin(pr({ labels: [] }), { ...ready, isApproved: false }, patch); assert.deepEqual(decision, { status: 'refuse', refusals: [{ code: 'no-release-label' }, { code: 'not-approved' }], @@ -175,29 +175,29 @@ describe('decide', () => { }); it('a main PR while the main umbrella is settled', () => { - assert.deepEqual(refusalsOf(decide(pr(), ready, settled)), [ + assert.deepEqual(refusalsOf(decideJoin(pr(), ready, settled)), [ { code: 'settled-umbrella', number: 42, releasePullRequest: 18405 }, ]); }); it('a routed PR that is not ready, keeping its route', () => { - const decision = decide(pr(), { ...ready, isApproved: false }, patch); + 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(decide(pr({ base: 'release-queue/main' }), ready, settled).status, 'join'); + 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(decide(prerelease, ready, settled).status, 'join'); + assert.equal(decideJoin(prerelease, ready, settled).status, 'join'); }); it('lets the merge decide while mergeability is unknown', () => { - assert.equal(decide(pr(), { ...ready, isMergeable: null }, patch).status, 'join'); + assert.equal(decideJoin(pr(), { ...ready, isMergeable: null }, patch).status, 'join'); }); }); @@ -213,14 +213,14 @@ describe('getTarget', () => { }); }); -describe('usesSettledUmbrella', () => { +describe('isRetargetedFromMain', () => { it('applies to an open PR against main', () => { - assert.equal(usesSettledUmbrella(pr()), true); + assert.equal(isRetargetedFromMain(pr()), true); }); it("doesn't apply to a PR against the umbrella branch, a prerelease, or a closed PR", () => { - assert.equal(usesSettledUmbrella(pr({ base: 'release-queue/main' })), false); - assert.equal(usesSettledUmbrella(pr({ base: 'cloudflare-next' })), false); - assert.equal(usesSettledUmbrella(pr({ state: 'closed' })), false); + assert.equal(isRetargetedFromMain(pr({ base: 'release-queue/main' })), false); + assert.equal(isRetargetedFromMain(pr({ base: 'cloudflare-next' })), false); + assert.equal(isRetargetedFromMain(pr({ state: 'closed' })), false); }); }); diff --git a/scripts/lib/release-queue/routing.ts b/scripts/lib/release-queue/routing.ts index ac142466978cd..a262c98e267af 100644 --- a/scripts/lib/release-queue/routing.ts +++ b/scripts/lib/release-queue/routing.ts @@ -1,15 +1,16 @@ +import { + ALL_RELEASE_LABELS, + MAIN_BRANCH, + RELEASE_LABELS, + UMBRELLA_PREFIX, + type ReleaseLabel, +} from './config.ts'; +import type { PullRequestState } from './github-types.ts'; import type { Readiness } from './readiness.ts'; import type { MainRelease } from './release.ts'; -export const ACCEPTED_LABEL = 'accepted'; -export const MAIN_BRANCH = 'main'; -export const UMBRELLA_PREFIX = 'release-queue/'; -export const RELEASE_LABELS = ['patch-release', 'minor-release', 'major-release'] as const; - -export type ReleaseLabel = (typeof RELEASE_LABELS)[number]; - export interface DocsPullRequest { - state: string; + state: PullRequestState; isDraft: boolean; base: string; head: string; @@ -35,28 +36,28 @@ export interface SettledUmbrella { releasePullRequest: number; } -/** What `decide` needs besides the PR. */ -export interface DecideContext { +/** What `decideJoin` needs besides the PR. */ +export interface DecisionContext { release: MainRelease; settledMainUmbrella?: SettledUmbrella | undefined; } /** The details of each refusal, by code. */ interface RefusalDetails { - closed: unknown; - umbrella: unknown; - draft: unknown; - 'no-release-label': unknown; + closed: Record; + umbrella: Record; + draft: Record; + 'no-release-label': Record; 'several-release-labels': { labels: ReleaseLabel[] }; mismatch: { base: string; label: ReleaseLabel }; 'major-on-main': { astroVersion?: string | undefined }; 'settled-umbrella': SettledUmbrella; - 'not-approved': unknown; + 'not-approved': Record; 'changes-requested': { reviewers: string[] }; 'failing-checks': { checks: string[] }; 'pending-checks': { checks: string[] }; 'missing-checks': { checks: string[] }; - conflict: unknown; + conflict: Record; // Found when joining (see `JoinRefusal`). 'umbrella-conflict': { branch: string }; /** The umbrella branch has commits but no open umbrella PR. */ @@ -71,7 +72,7 @@ export type Refusal = { [C in Code]: { code: C } & RefusalDetails[C]; }[Code]; -/** The refusals found only while joining, after `decide` approved the PR. */ +/** The refusals found only while joining, once `decideJoin` approved the PR. */ export type JoinRefusal = Refusal<'umbrella-conflict' | 'stale-umbrella-branch' | 'merge-failed'>; /** @@ -84,16 +85,18 @@ export type Decision = export type RefusedDecision = Extract; +/** Whether `branch` is an umbrella branch, by its reserved prefix. */ export function isUmbrellaBranch(branch: string): boolean { return branch.startsWith(UMBRELLA_PREFIX); } /** - * Whether a settled `main` umbrella refuses this PR: an open PR the bot would - * retarget from `main`. A PR against the umbrella branch says it belongs to - * that umbrella. The handler reads the settled state only for these PRs. + * Whether the bot would retarget this open PR from `main`: the only PRs a + * settled `main` umbrella refuses. A PR against the umbrella branch says it + * belongs to that umbrella. The handler reads the settled state only for + * these PRs. */ -export function usesSettledUmbrella(pr: DocsPullRequest): boolean { +export function isRetargetedFromMain(pr: DocsPullRequest): boolean { return pr.state === 'open' && pr.base === MAIN_BRANCH; } @@ -106,10 +109,10 @@ export function getTarget(base: string): string { * Decides whether a docs PR joins an umbrella, and which one. Returns every * refusal at once, except for a closed PR or an umbrella PR. */ -export function decide( +export function decideJoin( pr: DocsPullRequest, readiness: Readiness, - context: DecideContext + context: DecisionContext ): Decision { if (pr.state !== 'open') return { status: 'refuse', refusals: [{ code: 'closed' }] }; if (isUmbrellaBranch(pr.head)) return { status: 'refuse', refusals: [{ code: 'umbrella' }] }; @@ -129,9 +132,9 @@ export function decide( function getRoute( pr: DocsPullRequest, - { release, settledMainUmbrella }: DecideContext + { release, settledMainUmbrella }: DecisionContext ): Route | Refusal { - const labels = RELEASE_LABELS.filter((label) => pr.labels.includes(label)); + const labels = ALL_RELEASE_LABELS.filter((label) => pr.labels.includes(label)); if (labels.length === 0) return { code: 'no-release-label' }; if (labels.length > 1) return { code: 'several-release-labels', labels }; const [label] = labels; @@ -141,7 +144,7 @@ function getRoute( const shouldRetarget = !isUmbrellaBranch(pr.base); // Nothing marks a prerelease branch: any base other than `main` is one. - const isMajor = label === 'major-release'; + const isMajor = label === RELEASE_LABELS.major; const isMainTarget = target === MAIN_BRANCH; if (isMajor === isMainTarget) return { code: 'mismatch', base: pr.base, label }; // Every feature on `main` in `withastro/astro` now ships in the major, @@ -149,7 +152,7 @@ function getRoute( if (isMainTarget && release.type === 'major') { return { code: 'major-on-main', astroVersion: release.astroVersion }; } - if (settledMainUmbrella && usesSettledUmbrella(pr)) { + if (settledMainUmbrella && isRetargetedFromMain(pr)) { return { code: 'settled-umbrella', ...settledMainUmbrella }; } diff --git a/scripts/lib/release-queue/testing/queue-options.ts b/scripts/lib/release-queue/testing/queue-options.ts index 3d95dadfd5663..5b12e7256344a 100644 --- a/scripts/lib/release-queue/testing/queue-options.ts +++ b/scripts/lib/release-queue/testing/queue-options.ts @@ -1,3 +1,4 @@ +import { FRAMEWORK_REPO } from '../config.ts'; import type { Mode, QueueOptions } from '../modes.ts'; import { REPO } from './fixtures.ts'; import type { stubGitHub } from './stub-github.ts'; @@ -6,12 +7,12 @@ import type { stubGitHub } from './stub-github.ts'; export const ACTIONS_TOKEN = 'actions-token'; export const BOT_TOKEN = 'bot-token'; -/** Options for both entry points, with a client per identity of the stub. */ +/** Options for both entry points, with a repository per identity. */ export function queueOptions(github: ReturnType, mode: Mode): QueueOptions { return { - actionsClient: github.client(ACTIONS_TOKEN), - botClient: github.client(BOT_TOKEN), - repo: REPO, + 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 index d4e6c95b07fcf..bf36c09530720 100644 --- a/scripts/lib/release-queue/testing/stub-github.ts +++ b/scripts/lib/release-queue/testing/stub-github.ts @@ -1,5 +1,8 @@ import type { TestContext } from 'node:test'; import { GitHubClient } from '../github.ts'; +import { PrintingGitHubClient } from '../printing-github-client.ts'; +import { Repository } from '../repository.ts'; +import { REPO } from './fixtures.ts'; const API_URL = 'https://api.github.com/'; @@ -103,7 +106,11 @@ export function stubGitHub( 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', { shouldPrintWrites = false } = {}) => - new GitHubClient(token, { shouldPrintWrites, pollDelayMs: 0 }), + 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/text.ts b/scripts/lib/release-queue/text.ts new file mode 100644 index 0000000000000..7c3c6ee4d7b97 --- /dev/null +++ b/scripts/lib/release-queue/text.ts @@ -0,0 +1,7 @@ +/** + * Replaces the `\r\n` line endings GitHub keeps from a description or review + * edited in the browser with `\n`. + */ +export function normalizeLineEndings(text: string): string { + return text.replaceAll('\r\n', '\n'); +} 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..c4613e390e98e --- /dev/null +++ b/scripts/lib/release-queue/umbrella-body.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + formatUmbrellaBody, + parseReleaseMarker, + selectIncludedPullRequests, +} from './umbrella-body.ts'; + +describe('selectIncludedPullRequests', () => { + it('keeps the PRs whose squash commit is on the branch, in commit order', () => { + const included = selectIncludedPullRequests( + [ + { number: 3, title: 'Third', mergeCommitSha: 'c' }, + { number: 1, title: 'First', mergeCommitSha: 'a' }, + { number: 9, title: 'From an earlier release', mergeCommitSha: 'old' }, + { number: 5, title: 'Closed without merging', mergeCommitSha: null }, + ], + ['a', 'main-merge', 'c'] + ); + assert.deepEqual(included, [ + { number: 1, title: 'First' }, + { number: 3, title: 'Third' }, + ]); + }); +}); + +describe('formatUmbrellaBody', () => { + const included = [ + { number: 14580, title: 'Document remote image fetch retries' }, + { number: 14581, title: 'Fix a typo' }, + ]; + + it('links the release PR of the main umbrella, and lists the included PRs', () => { + assert.equal( + formatUmbrellaBody('main', { releasePullRequest: 18459, included }), + [ + 'Ships with withastro/astro#18459. This includes:', + '', + '- Document remote image fetch retries (#14580)', + '- Fix a typo (#14581)', + '', + 'Managed by the release queue: the bot rewrites this description after each join.', + '', + ].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', { releasePullRequest: 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', { releasePullRequest: 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', { releasePullRequest: 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); + }); +}); diff --git a/scripts/lib/release-queue/umbrella-body.ts b/scripts/lib/release-queue/umbrella-body.ts new file mode 100644 index 0000000000000..6a55606ba4e8f --- /dev/null +++ b/scripts/lib/release-queue/umbrella-body.ts @@ -0,0 +1,70 @@ +import { FRAMEWORK_REPO, MAIN_BRANCH, RELEASE_TITLE_PREFIX } from './config.ts'; +import { formatFrameworkReference } from './github-links.ts'; + +/** A docs PR merged into an umbrella branch. */ +export interface IncludedPullRequest { + number: number; + title: string; +} + +/** + * Keeps the PRs whose squash commit is on the umbrella branch, in the order of + * the commits. The branch name comes back each release, so PRs of earlier + * releases have the same base. + */ +export function selectIncludedPullRequests( + closed: (IncludedPullRequest & { mergeCommitSha: string | null })[], + umbrellaCommits: string[] +): IncludedPullRequest[] { + const byCommit = new Map(closed.map((pr) => [pr.mergeCommitSha, pr])); + return umbrellaCommits.flatMap((sha) => { + const pr = byCommit.get(sha); + return pr ? [{ number: pr.number, title: pr.title }] : []; + }); +} + +/** Writes the hidden marker that keeps the release PR an umbrella links. */ +function formatReleaseMarker(releasePullRequest: number | string): string { + return ``; +} + +// Its characters outside the number match themselves in a regular expression. +const RELEASE_MARKER = new RegExp(formatReleaseMarker('(\\d+)')); + +/** Reads the release PR an umbrella links, from the marker in its body. */ +export function parseReleaseMarker(body: string): number | undefined { + const match = body.match(RELEASE_MARKER); + return match ? Number(match[1]) : undefined; +} + +/** + * Writes the description of the umbrella PR merging into `target`: what it + * ships with, and the docs PRs it includes. A linked release PR is also kept + * in a hidden marker, as the refresh needs it. + */ +export function formatUmbrellaBody( + target: string, + { + releasePullRequest, + included, + }: { releasePullRequest?: number | undefined; included: IncludedPullRequest[] } +): string { + const summary = `${describeShipping(target, releasePullRequest)} ${describeIncluded(included)}`; + const footer = + 'Managed by the release queue: the bot rewrites this description after each join.'; + const marker = releasePullRequest === undefined ? [] : [formatReleaseMarker(releasePullRequest)]; + return [`${summary}\n\n${footer}`, ...marker].join('\n'); +} + +function describeIncluded(included: IncludedPullRequest[]): string { + if (included.length === 0) return 'No docs PR is included yet.'; + const list = included.map(({ number, title }) => `- ${title} (#${number})`).join('\n'); + return `This includes:\n\n${list}`; +} + +function describeShipping(target: string, releasePullRequest: number | undefined): string { + if (releasePullRequest !== undefined) + return `Ships with ${formatFrameworkReference(releasePullRequest)}.`; + const release = target === MAIN_BRANCH ? 'release' : 'prerelease'; + return `Ships with the next ${release} of \`${FRAMEWORK_REPO}\`, once its \`${RELEASE_TITLE_PREFIX}\` PR is open.`; +} 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..5a9cb277da624 --- /dev/null +++ b/scripts/lib/release-queue/umbrella-heading.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getMainRelease } from './release.ts'; +import { getUmbrellaHeading } from './umbrella-heading.ts'; + +const releasePullRequest = (astroVersion?: string) => ({ + number: 18459, + versions: astroVersion ? [{ name: 'astro', version: astroVersion }] : [], +}); + +describe('getUmbrellaHeading', () => { + it('names the main umbrella after the astro version', () => { + assert.deepEqual(getUmbrellaHeading('main', getMainRelease(releasePullRequest('7.4.1'))), { + title: '[ci] release astro@7.4.1 docs', + releaseLabel: 'patch-release', + }); + }); + + it('labels the main umbrella of a minor release', () => { + const heading = getUmbrellaHeading('main', getMainRelease(releasePullRequest('7.5.0'))); + assert.equal(heading.releaseLabel, 'minor-release'); + }); + + it('falls back to the branch name and a patch when astro is not released', () => { + assert.deepEqual(getUmbrellaHeading('main', getMainRelease(releasePullRequest())), { + title: '[ci] release main docs', + releaseLabel: 'patch-release', + }); + }); + + it('names a prerelease umbrella after its branch, as a major', () => { + const release = getMainRelease(releasePullRequest('7.4.1')); + assert.deepEqual(getUmbrellaHeading('cloudflare-next', release), { + title: '[ci] release cloudflare-next docs', + releaseLabel: '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..4f2fbded49929 --- /dev/null +++ b/scripts/lib/release-queue/umbrella-heading.ts @@ -0,0 +1,41 @@ +import { + FALLBACK_RELEASE_LABEL, + FRAMEWORK_PACKAGE, + MAIN_BRANCH, + RELEASE_LABELS, + RELEASE_TITLE_PREFIX, + type ReleaseLabel, +} from './config.ts'; +import type { MainRelease } from './release.ts'; + +/** + * An umbrella PR: an open one, or a planned one that a join would create, + * named by its title. An umbrella whose creation was only printed stays + * planned. + */ +export type Umbrella = { status: 'open'; number: number } | { status: 'planned'; title: string }; + +/** What an umbrella PR takes from the release it ships with. */ +export interface UmbrellaHeading { + title: string; + releaseLabel: ReleaseLabel; +} + +/** + * Gets what the umbrella PR merging into `target` takes from the next release + * from `main`. The `main` umbrella is named after the `astro` version, as + * merged PRs are posted to Discord. On `main`, anything but a minor is a + * patch, including a release without `astro`. A major never gets here: + * `decideJoin` refuses `main` PRs before one. A prerelease umbrella uses its + * branch name, which says what it is for, and is a major. + */ +export function getUmbrellaHeading(target: string, release: MainRelease): UmbrellaHeading { + if (target !== MAIN_BRANCH) { + return { title: `${RELEASE_TITLE_PREFIX} ${target} docs`, releaseLabel: RELEASE_LABELS.major }; + } + const name = release.astroVersion ? `${FRAMEWORK_PACKAGE}@${release.astroVersion}` : target; + return { + title: `${RELEASE_TITLE_PREFIX} ${name} docs`, + releaseLabel: release.type === 'minor' ? RELEASE_LABELS.minor : FALLBACK_RELEASE_LABEL, + }; +} 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..1e2b05fe05cc6 --- /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 './umbrella-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..b43ba8dca28f5 --- /dev/null +++ b/scripts/lib/release-queue/umbrella-labels.ts @@ -0,0 +1,46 @@ +import { + ALL_RELEASE_LABELS, + FALLBACK_RELEASE_LABEL, + MERGE_ON_RELEASE_LABEL, + type ReleaseLabel, +} from './config.ts'; + +/** Labels to add to an umbrella PR, and release labels to remove from it. */ +export interface LabelChanges { + toAdd: string[]; + toRemove: string[]; +} + +/** + * Gets the label changes that give an umbrella PR `merge-on-release` and its + * release label. Other labels, like `i18n`, stay. + */ +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: it + * keeps its release label, and gets the fallback one only if it 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 `changes` are applied. */ +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-queries.ts b/scripts/lib/release-queue/umbrella-queries.ts index 895f6d99061bf..a26f7e0860ac4 100644 --- a/scripts/lib/release-queue/umbrella-queries.ts +++ b/scripts/lib/release-queue/umbrella-queries.ts @@ -1,6 +1,8 @@ -import type { GitHubClient } from './github.ts'; -import { listCommitsAhead, type PullRequestResponse } from './queries.ts'; +import type { Repository } from './repository.ts'; +import type { PullRequestResponse } from './github-types.ts'; +import { listCommitsAhead } from './queries.ts'; import { getTarget, isUmbrellaBranch } from './routing.ts'; +import { normalizeLineEndings } from './text.ts'; /** An open umbrella PR, as the refresh reads it. */ export interface OpenUmbrella { @@ -13,8 +15,11 @@ export interface OpenUmbrella { branch: string; } -/** A PR merged into an umbrella branch, in this release or an earlier one. */ -export interface MergedPullRequest { +/** + * A PR closed on an umbrella branch, in this release or an earlier one. Only + * a merged PR has a squash commit on the branch. + */ +export interface ClosedPullRequest { number: number; title: string; mergeCommitSha: string | null; @@ -24,9 +29,11 @@ export interface MergedPullRequest { * Whether a PR is an umbrella PR: from an umbrella branch of `repo` into the * branch the name implies. */ -function isUmbrellaPullRequest({ base, head }: PullRequestResponse, repo: string): boolean { +function isUmbrellaPullRequest({ base, head }: PullRequestResponse, repo: Repository): boolean { return ( - isUmbrellaBranch(head.ref) && head.repo?.full_name === repo && getTarget(head.ref) === base.ref + isUmbrellaBranch(head.ref) && + head.repo?.full_name === repo.name && + getTarget(head.ref) === base.ref ); } @@ -34,26 +41,25 @@ function toOpenUmbrella(data: PullRequestResponse): OpenUmbrella { return { number: data.number, title: data.title, - body: data.body ?? '', + body: normalizeLineEndings(data.body ?? ''), labels: data.labels.map((label) => label.name), target: data.base.ref, branch: data.head.ref, }; } -export async function getUmbrella(gh: GitHubClient, repo: string, number: number) { - return toOpenUmbrella(await gh.get(`repos/${repo}/pulls/${number}`)); +/** Gets the umbrella PR `umbrellaNumber`, open or not. */ +export async function getUmbrella(repo: Repository, umbrellaNumber: number) { + return toOpenUmbrella(await repo.get(`pulls/${umbrellaNumber}`)); } /** Finds the open umbrella PR of `branch`, if any. */ export async function findOpenUmbrella( - gh: GitHubClient, - repo: string, + repo: Repository, branch: string ): Promise { - const [owner] = repo.split('/'); - const pullRequests = await gh.get( - `repos/${repo}/pulls?state=open&head=${owner}:${branch}` + const pullRequests = await repo.get( + `pulls?state=open&head=${repo.owner}:${branch}` ); const umbrella = pullRequests.find((pr) => isUmbrellaPullRequest(pr, repo)); return umbrella && toOpenUmbrella(umbrella); @@ -63,29 +69,28 @@ export async function findOpenUmbrella( * Lists the open umbrella PRs: PRs from an umbrella branch of `repo` into * the branch it names. */ -export async function listOpenUmbrellas(gh: GitHubClient, repo: string): Promise { - const pullRequests = await gh.getAll(`repos/${repo}/pulls?state=open`); +export async function listOpenUmbrellas(repo: Repository): Promise { + const pullRequests = await repo.getAll(`pulls?state=open`); return pullRequests.filter((pr) => isUmbrellaPullRequest(pr, repo)).map(toOpenUmbrella); } /** * Gets what makes up an umbrella: the commits of its branch, and the PRs ever - * merged into that branch. The closed PRs of the branch build up over the + * closed on that branch. The closed PRs of the branch build up over the * releases, about one page of 100 for 100 merged PRs. */ export async function getUmbrellaHistory( - gh: GitHubClient, - repo: string, + repo: Repository, { target, branch }: OpenUmbrella -): Promise<{ commits: string[]; merged: MergedPullRequest[] }> { - const [commits, closed] = await Promise.all([ - listCommitsAhead(gh, repo, target, branch), - gh.getAll(`repos/${repo}/pulls?state=closed&base=${branch}`), +): Promise<{ commits: string[]; closed: ClosedPullRequest[] }> { + const [commits, closedResponses] = await Promise.all([ + listCommitsAhead(repo, target, branch), + repo.getAll(`pulls?state=closed&base=${branch}`), ]); - const merged = closed.map(({ number, title, merge_commit_sha }) => ({ + const closed = closedResponses.map(({ number, title, merge_commit_sha }) => ({ number, title, mergeCommitSha: merge_commit_sha, })); - return { commits, merged }; + return { commits, closed }; } diff --git a/scripts/lib/release-queue/umbrella-state.ts b/scripts/lib/release-queue/umbrella-state.ts index 92050dbfae33a..083f768d0bf6c 100644 --- a/scripts/lib/release-queue/umbrella-state.ts +++ b/scripts/lib/release-queue/umbrella-state.ts @@ -1,9 +1,10 @@ -import type { GitHubClient } from './github.ts'; import { isFrameworkPullRequestMerged } from './queries.ts'; import type { MainRelease } from './release.ts'; -import { MAIN_BRANCH, UMBRELLA_PREFIX, type SettledUmbrella } from './routing.ts'; +import { MAIN_BRANCH, MAIN_UMBRELLA_BRANCH } from './config.ts'; +import type { SettledUmbrella } from './routing.ts'; +import type { Repositories, Repository } from './repository.ts'; import { findOpenUmbrella, type OpenUmbrella } from './umbrella-queries.ts'; -import { parseReleaseMarker } from './umbrella.ts'; +import { parseReleaseMarker } from './umbrella-body.ts'; /** * How an umbrella relates to the release PRs of `withastro/astro`: @@ -22,7 +23,7 @@ type ReleaseState = * their branch has continuous betas. */ export async function getReleaseState( - gh: GitHubClient, + framework: Repository, umbrella: OpenUmbrella, release: MainRelease ): Promise { @@ -30,7 +31,11 @@ export async function getReleaseState( const linked = parseReleaseMarker(umbrella.body); const open = release.pullRequest?.number; // A release PR closed without merging settles nothing. - if (linked !== undefined && linked !== open && (await isFrameworkPullRequestMerged(gh, linked))) { + if ( + linked !== undefined && + linked !== open && + (await isFrameworkPullRequestMerged(framework, linked)) + ) { return { status: 'settled', releasePullRequest: linked }; } return open === undefined @@ -38,15 +43,14 @@ export async function getReleaseState( : { status: 'follows' }; } -/** Gets the open `main` umbrella if it's settled, for `decide`. */ +/** Gets the open `main` umbrella if it's settled, for `decideJoin`. */ export async function getSettledMainUmbrella( - gh: GitHubClient, - repo: string, + { docs, framework }: Repositories, release: MainRelease ): Promise { - const umbrella = await findOpenUmbrella(gh, repo, `${UMBRELLA_PREFIX}${MAIN_BRANCH}`); + const umbrella = await findOpenUmbrella(docs, MAIN_UMBRELLA_BRANCH); if (!umbrella) return undefined; - const state = await getReleaseState(gh, umbrella, release); + const state = await getReleaseState(framework, umbrella, release); return state.status === 'settled' ? { number: umbrella.number, releasePullRequest: state.releasePullRequest } : undefined; diff --git a/scripts/lib/release-queue/umbrella.test.ts b/scripts/lib/release-queue/umbrella.test.ts deleted file mode 100644 index 3daaf8d137b80..0000000000000 --- a/scripts/lib/release-queue/umbrella.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { getMainRelease } from './release.ts'; -import { - getKeptLabelChanges, - getLabelChanges, - getUmbrellaBody, - getUmbrellaHeading, - parseReleaseMarker, - selectIncludedPullRequests, -} from './umbrella.ts'; - -const releasePullRequest = (astroVersion?: string) => ({ - number: 18459, - versions: astroVersion ? [{ name: 'astro', version: astroVersion }] : [], -}); - -describe('getUmbrellaHeading', () => { - it('names the main umbrella after the astro version', () => { - assert.deepEqual(getUmbrellaHeading('main', getMainRelease(releasePullRequest('7.4.1'))), { - title: '[ci] release astro@7.4.1 docs', - releaseLabel: 'patch-release', - }); - }); - - it('labels the main umbrella of a minor release', () => { - const heading = getUmbrellaHeading('main', getMainRelease(releasePullRequest('7.5.0'))); - assert.equal(heading.releaseLabel, 'minor-release'); - }); - - it('falls back to the branch name and a patch when astro is not released', () => { - assert.deepEqual(getUmbrellaHeading('main', getMainRelease(releasePullRequest())), { - title: '[ci] release main docs', - releaseLabel: 'patch-release', - }); - }); - - it('names a prerelease umbrella after its branch, as a major', () => { - const release = getMainRelease(releasePullRequest('7.4.1')); - assert.deepEqual(getUmbrellaHeading('cloudflare-next', release), { - title: '[ci] release cloudflare-next docs', - releaseLabel: 'major-release', - }); - }); -}); - -describe('selectIncludedPullRequests', () => { - it('keeps the PRs whose squash commit is on the branch, in commit order', () => { - const included = selectIncludedPullRequests( - [ - { number: 3, title: 'Third', mergeCommitSha: 'c' }, - { number: 1, title: 'First', mergeCommitSha: 'a' }, - { number: 9, title: 'From an earlier release', mergeCommitSha: 'old' }, - { number: 5, title: 'Closed without merging', mergeCommitSha: null }, - ], - ['a', 'main-merge', 'c'] - ); - assert.deepEqual(included, [ - { number: 1, title: 'First' }, - { number: 3, title: 'Third' }, - ]); - }); -}); - -describe('getUmbrellaBody', () => { - const included = [ - { number: 14580, title: 'Document remote image fetch retries' }, - { number: 14581, title: 'Fix a typo' }, - ]; - - it('links the release PR of the main umbrella, and lists the included PRs', () => { - assert.equal( - getUmbrellaBody('main', { releasePullRequest: 18459, included }), - [ - 'Ships with withastro/astro#18459. This includes:', - '', - '- Document remote image fetch retries (#14580)', - '- Fix a typo (#14581)', - '', - 'Managed by the release queue: the bot rewrites this description after each join.', - '', - ].join('\n') - ); - }); - - it('says when no release PR is linked, without a marker', () => { - const body = getUmbrellaBody('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 = getUmbrellaBody('cloudflare-next', { releasePullRequest: 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( - getUmbrellaBody('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( - getUmbrellaBody('main', { releasePullRequest: 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 = getUmbrellaBody('main', { releasePullRequest: 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); - }); -}); - -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.ts b/scripts/lib/release-queue/umbrella.ts deleted file mode 100644 index 2278b80aacdbd..0000000000000 --- a/scripts/lib/release-queue/umbrella.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { MainRelease } from './release.ts'; -import { MAIN_BRANCH, RELEASE_LABELS, type ReleaseLabel } from './routing.ts'; - -/** Marks umbrella PRs, so they can be found in the PR list. */ -export const MERGE_ON_RELEASE_LABEL = 'merge-on-release'; - -/** The label of a release whose type isn't known yet. */ -const FALLBACK_RELEASE_LABEL: ReleaseLabel = 'patch-release'; - -/** - * An umbrella PR: an open one, or the one a join creates, named by its title. - * The title also stands for an umbrella whose creation was only printed. - */ -export type Umbrella = { number: number } | { title: string }; - -/** What an umbrella PR takes from the release it ships with. */ -export interface UmbrellaHeading { - title: string; - releaseLabel: ReleaseLabel; -} - -/** - * Gets what the umbrella PR merging into `target` takes from the next release - * from `main`. The `main` umbrella is named after the `astro` version, as - * merged PRs are posted to Discord. On `main`, anything but a minor is a - * patch, including a release without `astro`. A major never gets here: - * `decide` refuses `main` PRs before one. A prerelease umbrella uses its - * branch name, which says what it is for, and is a major. - */ -export function getUmbrellaHeading(target: string, release: MainRelease): UmbrellaHeading { - if (target !== MAIN_BRANCH) { - return { title: `[ci] release ${target} docs`, releaseLabel: 'major-release' }; - } - const name = release.astroVersion ? `astro@${release.astroVersion}` : target; - return { - title: `[ci] release ${name} docs`, - releaseLabel: release.type === 'minor' ? 'minor-release' : FALLBACK_RELEASE_LABEL, - }; -} - -/** A docs PR merged into an umbrella branch. */ -export interface IncludedPullRequest { - number: number; - title: string; -} - -/** - * Keeps the PRs whose squash commit is on the umbrella branch, in the order of - * the commits. The branch name comes back each release, so PRs of earlier - * releases have the same base. - */ -export function selectIncludedPullRequests( - merged: (IncludedPullRequest & { mergeCommitSha: string | null })[], - umbrellaCommits: string[] -): IncludedPullRequest[] { - const byCommit = new Map(merged.map((pr) => [pr.mergeCommitSha, pr])); - return umbrellaCommits.flatMap((sha) => { - const pr = byCommit.get(sha); - return pr ? [{ number: pr.number, title: pr.title }] : []; - }); -} - -const RELEASE_MARKER = //; - -/** Reads the release PR an umbrella links, from the marker in its body. */ -export function parseReleaseMarker(body: string): number | undefined { - const match = body.match(RELEASE_MARKER); - return match ? Number(match[1]) : undefined; -} - -/** - * Writes the description of the umbrella PR merging into `target`: what it - * ships with, and the docs PRs it includes. A linked release PR is also kept - * in a hidden marker, as the refresh needs it. - */ -export function getUmbrellaBody( - target: string, - { - releasePullRequest, - included, - }: { releasePullRequest?: number | undefined; included: IncludedPullRequest[] } -): string { - const contents = - included.length > 0 - ? `This includes:\n\n${included.map(({ number, title }) => `- ${title} (#${number})`).join('\n')}` - : 'No docs PR is included yet.'; - const marker = - releasePullRequest === undefined - ? '' - : `\n`; - return `${describeShipping(target, releasePullRequest)} ${contents}\n\nManaged by the release queue: the bot rewrites this description after each join.${marker}`; -} - -function describeShipping(target: string, releasePullRequest: number | undefined): string { - if (releasePullRequest !== undefined) return `Ships with withastro/astro#${releasePullRequest}.`; - const release = target === MAIN_BRANCH ? 'release' : 'prerelease'; - return `Ships with the next ${release} of \`withastro/astro\`, once its \`[ci] release\` PR is open.`; -} - -/** - * Gets the label changes that give an umbrella PR `merge-on-release` and its - * release label. Other labels, like `i18n`, stay. - */ -export function getLabelChanges(current: string[], releaseLabel: ReleaseLabel) { - const wanted = [MERGE_ON_RELEASE_LABEL, releaseLabel]; - return { - toAdd: wanted.filter((label) => !current.includes(label)), - toRemove: RELEASE_LABELS.filter((label) => label !== releaseLabel && current.includes(label)), - }; -} - -/** - * Gets the label changes of an umbrella whose release can't be followed: it - * keeps its release label, and gets the fallback one only if it has none. - */ -export function getKeptLabelChanges(current: string[]) { - const hasReleaseLabel = RELEASE_LABELS.some((label) => current.includes(label)); - return { - toAdd: [ - ...(current.includes(MERGE_ON_RELEASE_LABEL) ? [] : [MERGE_ON_RELEASE_LABEL]), - ...(hasReleaseLabel ? [] : [FALLBACK_RELEASE_LABEL]), - ], - toRemove: [], - }; -} diff --git a/scripts/release-queue.ts b/scripts/release-queue.ts index 8e51e3ae86b15..73122ff81afe2 100644 --- a/scripts/release-queue.ts +++ b/scripts/release-queue.ts @@ -1,58 +1,22 @@ // Routes docs PRs labeled `accepted` to the umbrella PR of their release. // Locally: `GITHUB_TOKEN=$(gh auth token) node --experimental-transform-types ./scripts/release-queue.ts --pr `. -import { readFile } from 'node:fs/promises'; import { parseArgs } from 'node:util'; -import { chooseAction } from './lib/release-queue/cli.ts'; -import { GitHubClient } from './lib/release-queue/github.ts'; -import { handleAcceptedPullRequest } from './lib/release-queue/handle-accepted.ts'; -import { needsBotToken, parseMode, type QueueOptions } from './lib/release-queue/modes.ts'; -import { refreshOpenUmbrellas } from './lib/release-queue/refresh.ts'; +import { runReleaseQueue } from './lib/release-queue/main.ts'; // In GitHub Actions, the PR and the sender come from the event, and a // `workflow_dispatch` refreshes every open umbrella PR. -// `--refresh`: refresh every open umbrella PR, instead of handling `--pr`. -// `--sender`: who added the label, for the write access check. -// `--write`: send the writes instead of printing them, outside GitHub Actions. -const { values } = parseArgs({ +const { values: flags } = parseArgs({ options: { + // The docs PR to route. pr: { type: 'string' }, + // Refresh every open umbrella PR, instead of routing `--pr`. refresh: { type: 'boolean', default: false }, + // Who added the label, for the write access check. sender: { type: 'string' }, + // Send the writes instead of printing them, in a local run. write: { type: 'boolean', default: false }, }, }); -const repo = process.env.GITHUB_REPOSITORY ?? 'withastro/docs'; -const mode = parseMode(process.env.MODE); - -const token = process.env.GITHUB_TOKEN; -if (!token) throw new Error('GITHUB_TOKEN is required.'); -const shouldWrite = process.env.GITHUB_ACTIONS === 'true' || values.write; -// Changes go through the bot account: unlike `GITHUB_TOKEN`, its merges into -// an umbrella branch trigger the checks of the umbrella PR. -const botToken = process.env.BOT_TOKEN; -if (needsBotToken(mode, shouldWrite) && !botToken) { - throw new Error(`BOT_TOKEN is required in ${mode} mode.`); -} - -const clientOptions = { shouldPrintWrites: !shouldWrite }; -const actionsClient = new GitHubClient(token, clientOptions); -const botClient = new GitHubClient(botToken ?? token, clientOptions); -const options: QueueOptions = { actionsClient, botClient, repo, mode }; - -const event = process.env.GITHUB_EVENT_PATH - ? JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, 'utf-8')) - : undefined; -const action = chooseAction({ - pr: values.pr, - refresh: values.refresh, - eventName: process.env.GITHUB_EVENT_NAME, - eventPullRequest: event?.pull_request?.number, -}); - -if (action.type === 'refresh') { - await refreshOpenUmbrellas(options); -} else { - await handleAcceptedPullRequest(options, action.number, values.sender ?? event?.sender?.login); -} +await runReleaseQueue({ flags, env: process.env }); From 4602e6fc1ffb8706ac25df2cb23ac7a5cd30745d Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Fri, 23 Oct 2026 14:21:39 +0200 Subject: [PATCH 07/35] Second pass to make the release queue code more readable - Anchor the release marker to the end of the umbrella body - List the included PRs from the squash commit titles - Require a sender in GitHub Actions - Keep refreshing the other umbrellas when one fails - Name the request of a malformed JSON answer. - Name the stable branch after its role - Document return values and errors in tags - Type the test fixtures. --- .github/workflows/release-queue.yml | 2 +- scripts/lib/release-queue/cli.test.ts | 8 +- scripts/lib/release-queue/cli.ts | 48 +++-- scripts/lib/release-queue/config.ts | 26 ++- scripts/lib/release-queue/github-error.ts | 14 +- scripts/lib/release-queue/github-links.ts | 6 - scripts/lib/release-queue/github-text.ts | 25 +++ scripts/lib/release-queue/github-types.ts | 7 +- scripts/lib/release-queue/github.test.ts | 11 +- scripts/lib/release-queue/github.ts | 95 +++++++--- .../lib/release-queue/handle-accepted.test.ts | 56 +++--- scripts/lib/release-queue/handle-accepted.ts | 100 +++++----- scripts/lib/release-queue/join.test.ts | 12 +- scripts/lib/release-queue/join.ts | 97 +++++----- scripts/lib/release-queue/json.ts | 17 +- scripts/lib/release-queue/main.test.ts | 14 ++ scripts/lib/release-queue/main.ts | 51 +++-- scripts/lib/release-queue/merge-block.test.ts | 14 +- scripts/lib/release-queue/merge-block.ts | 47 +++-- scripts/lib/release-queue/messages.test.ts | 10 +- scripts/lib/release-queue/messages.ts | 80 +++++--- scripts/lib/release-queue/modes.test.ts | 26 ++- scripts/lib/release-queue/modes.ts | 53 +++--- scripts/lib/release-queue/queries.test.ts | 16 +- scripts/lib/release-queue/queries.ts | 149 +++++++++++---- scripts/lib/release-queue/readiness.ts | 35 +++- scripts/lib/release-queue/refresh.test.ts | 69 ++++--- scripts/lib/release-queue/refresh.ts | 177 ++++++++++++------ scripts/lib/release-queue/release.test.ts | 27 +-- scripts/lib/release-queue/release.ts | 58 ++++-- scripts/lib/release-queue/repository.ts | 24 ++- scripts/lib/release-queue/routing.test.ts | 42 +++-- scripts/lib/release-queue/routing.ts | 125 ++++++++----- scripts/lib/release-queue/testing/fixtures.ts | 35 ++-- .../release-queue/testing/queue-options.ts | 3 +- .../lib/release-queue/testing/stub-github.ts | 11 ++ scripts/lib/release-queue/text.ts | 7 - .../lib/release-queue/umbrella-body.test.ts | 49 +++-- scripts/lib/release-queue/umbrella-body.ts | 93 ++++++--- .../release-queue/umbrella-heading.test.ts | 14 +- scripts/lib/release-queue/umbrella-heading.ts | 54 +++--- scripts/lib/release-queue/umbrella-labels.ts | 26 ++- scripts/lib/release-queue/umbrella-queries.ts | 90 +++++---- scripts/lib/release-queue/umbrella-state.ts | 76 +++++--- scripts/release-queue.ts | 2 +- 45 files changed, 1263 insertions(+), 738 deletions(-) delete mode 100644 scripts/lib/release-queue/github-links.ts create mode 100644 scripts/lib/release-queue/github-text.ts delete mode 100644 scripts/lib/release-queue/text.ts diff --git a/.github/workflows/release-queue.yml b/.github/workflows/release-queue.yml index 5c7b25e37e2f5..67b04cdda3047 100644 --- a/.github/workflows/release-queue.yml +++ b/.github/workflows/release-queue.yml @@ -1,4 +1,4 @@ -# Routes docs PRs labeled `accepted` to the umbrella PR of their release. +# Routes docs PRs labeled as accepted to the umbrella PR of their release. # Run it manually to refresh the open umbrella PRs, for example after the # release PR of `withastro/astro` changed. # `MODE` sets which PRs it acts on: see `scripts/lib/release-queue/modes.ts`. diff --git a/scripts/lib/release-queue/cli.test.ts b/scripts/lib/release-queue/cli.test.ts index be52ca68e9a35..24a81bc109d5c 100644 --- a/scripts/lib/release-queue/cli.test.ts +++ b/scripts/lib/release-queue/cli.test.ts @@ -16,7 +16,7 @@ describe('chooseAction', () => { it('refreshes on a workflow_dispatch, or with --refresh', () => { assert.deepEqual(chooseAction({ eventName: 'workflow_dispatch' }), { type: 'refresh' }); - assert.deepEqual(chooseAction({ refresh: true }), { type: 'refresh' }); + assert.deepEqual(chooseAction({ isRefreshRequested: true }), { type: 'refresh' }); }); it('refuses a missing PR number, instead of refreshing', () => { @@ -30,7 +30,7 @@ describe('chooseAction', () => { }); it('refuses --refresh with --pr', () => { - assert.throws(() => chooseAction({ pr: '7', refresh: true }), /not both/); + assert.throws(() => chooseAction({ pr: '7', isRefreshRequested: true }), /not both/); }); }); @@ -45,14 +45,14 @@ describe('parseEvent', () => { }; assert.deepEqual(parseEvent(JSON.stringify(event), path), { pullRequest: 7, - sender: 'maintainer', + labelSender: 'maintainer', }); }); it('reads neither from a workflow_dispatch event', () => { assert.deepEqual(parseEvent('{"inputs":{}}', path), { pullRequest: undefined, - sender: undefined, + labelSender: undefined, }); }); diff --git a/scripts/lib/release-queue/cli.ts b/scripts/lib/release-queue/cli.ts index 51083a0ff415a..efab0eca005e0 100644 --- a/scripts/lib/release-queue/cli.ts +++ b/scripts/lib/release-queue/cli.ts @@ -1,54 +1,70 @@ import { isRecord, parseJson } from './json.ts'; +/** A positive integer, written with digits only. */ +const PR_NUMBER = /^[1-9]\d*$/; + export type Action = { type: 'handle'; prNumber: number } | { type: 'refresh' }; /** - * Chooses what a run does, from an explicit signal: a refresh of the open - * umbrellas (`workflow_dispatch`, or `--refresh` locally), or a PR to handle - * (`--pr`, or the PR of the `labeled` event). A missing or invalid PR number - * is an error, not a refresh. + * Chooses what a run does, from an explicit signal. + * + * A `workflow_dispatch` event, or `isRefreshRequested` in a local run, + * refreshes the open + * umbrellas. Otherwise, the run handles `pr`, or the PR of the event. + * + * @param {object} signals - The flags and the event of the run. + * @param {string} [signals.pr] - The value of `--pr`. + * @param {boolean} [signals.isRefreshRequested] - Whether `--refresh` was + * passed. + * @param {string} [signals.eventName] - The event that triggered the run. + * @param {number} [signals.eventPullRequest] - The PR of that event. + * @returns {Action} The refresh, or the PR to handle. + * @throws {Error} When both a PR and a refresh are asked for, or when the PR + * number is missing or invalid. */ export function chooseAction({ pr, - refresh = false, + isRefreshRequested = false, eventName, eventPullRequest, }: { pr?: string | undefined; - refresh?: boolean | undefined; + isRefreshRequested?: boolean | undefined; eventName?: string | undefined; eventPullRequest?: number | undefined; }): Action { - const shouldRefresh = refresh || eventName === 'workflow_dispatch'; + const shouldRefresh = isRefreshRequested || eventName === 'workflow_dispatch'; if (shouldRefresh && pr !== undefined) throw new Error('Pass --pr or --refresh, not both.'); if (shouldRefresh) return { type: 'refresh' }; const value = pr ?? eventPullRequest?.toString(); if (value === undefined) throw new Error('No PR to handle: pass --pr .'); - // Only digits: `Number()` would also read `1e3`, `0x10` or ` 7 `. - if (!/^\d+$/.test(value) || Number(value) === 0) { - throw new Error(`Invalid PR number: ${value}. Pass --pr .`); - } + // Digits only: `Number()` would also read `1e3`, `0x10` or ` 7 `. + if (!PR_NUMBER.test(value)) throw new Error(`Invalid PR number: ${value}. Pass --pr .`); return { type: 'handle', prNumber: Number(value) }; } /** What a run reads from the event of GitHub Actions. */ export interface RunEvent { pullRequest?: number | undefined; - /** Who added the label. */ - sender?: string | undefined; + labelSender?: string | undefined; } /** - * Reads the PR number and the sender from the event file at `path`. A - * `workflow_dispatch` event has neither. + * 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. A `workflow_dispatch` event has + * neither field. + * @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), - sender: readSenderLogin(event.sender, path), + labelSender: readSenderLogin(event.sender, path), }; } diff --git a/scripts/lib/release-queue/config.ts b/scripts/lib/release-queue/config.ts index 86dbc41254d6d..8174eb98d6ca2 100644 --- a/scripts/lib/release-queue/config.ts +++ b/scripts/lib/release-queue/config.ts @@ -1,29 +1,25 @@ -// The names the release queue uses. The labels, the repositories, the -// reviewer and the Lunaria directive can change here. The branch names are -// also vocabulary: they appear in names like `main-only` or `major-on-main`, -// and in comments. +// The names the release queue uses: repositories, branches, labels, and the +// accounts and directives it relies on. export const DOCS_REPO = 'withastro/docs'; export const FRAMEWORK_REPO = 'withastro/astro'; -/** The package of `withastro/astro` that names its releases. */ +/** The package of the framework repository that names its releases. */ export const FRAMEWORK_PACKAGE = 'astro'; -export const FRAMEWORK_MAIN_BRANCH = 'main'; -/** The branch of `withastro/astro` for its alpha and beta releases. */ +export const FRAMEWORK_STABLE_BRANCH = 'main'; +/** The branch of the framework repository for its alpha and beta releases. */ export const FRAMEWORK_PRERELEASE_BRANCH = 'next'; -/** The title prefix of `withastro/astro` release PRs, shared by umbrellas. */ +/** The title prefix of the framework's release PRs, shared by umbrellas. */ export const RELEASE_TITLE_PREFIX = '[ci] release'; -export const MAIN_BRANCH = 'main'; -/** The reserved branch prefix for umbrellas. */ -export const UMBRELLA_PREFIX = 'release-queue/'; -export const MAIN_UMBRELLA_BRANCH = `${UMBRELLA_PREFIX}${MAIN_BRANCH}`; +export const STABLE_BRANCH = 'main'; +export const UMBRELLA_BRANCH_PREFIX = 'release-queue/'; +export const STABLE_UMBRELLA_BRANCH = `${UMBRELLA_BRANCH_PREFIX}${STABLE_BRANCH}`; /** 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 label of each release type. */ export const RELEASE_LABELS = { patch: 'patch-release', minor: 'minor-release', @@ -37,8 +33,8 @@ export const ALL_RELEASE_LABELS: ReleaseLabel[] = Object.values(RELEASE_LABELS); /** The label of a release whose type isn't known yet. */ export const FALLBACK_RELEASE_LABEL: ReleaseLabel = RELEASE_LABELS.patch; -/** The bot behind `GITHUB_TOKEN`, which blocks umbrella merges. */ -export const REVIEWER_LOGIN = 'github-actions[bot]'; +/** 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 = diff --git a/scripts/lib/release-queue/github-error.ts b/scripts/lib/release-queue/github-error.ts index 78480d95ee27e..e764efe98c1a6 100644 --- a/scripts/lib/release-queue/github-error.ts +++ b/scripts/lib/release-queue/github-error.ts @@ -1,12 +1,18 @@ -/** - * An error answer from the GitHub API. `githubReason` is the reason GitHub - * gives, with its details. - */ +/** 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, diff --git a/scripts/lib/release-queue/github-links.ts b/scripts/lib/release-queue/github-links.ts deleted file mode 100644 index 0542e18032ae0..0000000000000 --- a/scripts/lib/release-queue/github-links.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { FRAMEWORK_REPO } from './config.ts'; - -/** Writes the reference to a PR of `withastro/astro`, which GitHub links. */ -export function formatFrameworkReference(prNumber: number): string { - return `${FRAMEWORK_REPO}#${prNumber}`; -} diff --git a/scripts/lib/release-queue/github-text.ts b/scripts/lib/release-queue/github-text.ts new file mode 100644 index 0000000000000..389f2303171b3 --- /dev/null +++ b/scripts/lib/release-queue/github-text.ts @@ -0,0 +1,25 @@ +// The text the bot reads from GitHub and writes to it. + +import { FRAMEWORK_REPO } from './config.ts'; + +/** + * Replaces the `\r\n` line endings GitHub keeps from a description or review + * edited in the browser with `\n`. + * + * @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 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/github-types.ts b/scripts/lib/release-queue/github-types.ts index 93edd865d31d9..db20406a6b21a 100644 --- a/scripts/lib/release-queue/github-types.ts +++ b/scripts/lib/release-queue/github-types.ts @@ -9,7 +9,6 @@ export interface PullRequestResponse { state: PullRequestState; draft: boolean; mergeable: boolean | null; - merge_commit_sha: string | null; base: { ref: string }; /** `repo` is `null` when the fork was deleted. */ head: { ref: string; sha: string; repo?: { full_name: string } | null }; @@ -18,6 +17,12 @@ export interface PullRequestResponse { merged?: boolean; } +export interface CompareResponse { + ahead_by: number; + /** At most 250 commits, oldest first. */ + commits: { commit: { message: string } }[]; +} + export type ReviewResponseState = | 'APPROVED' | 'CHANGES_REQUESTED' diff --git a/scripts/lib/release-queue/github.test.ts b/scripts/lib/release-queue/github.test.ts index 4e5332dc2f163..9515fc7be7a4e 100644 --- a/scripts/lib/release-queue/github.test.ts +++ b/scripts/lib/release-queue/github.test.ts @@ -19,7 +19,7 @@ describe('GitHubClient', () => { 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((init?.headers as Record).Authorization, 'Bearer token'); + assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer token'); }); it('answers `undefined` without a body', async (t) => { @@ -53,6 +53,15 @@ describe('GitHubClient', () => { ); }); + 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)); diff --git a/scripts/lib/release-queue/github.ts b/scripts/lib/release-queue/github.ts index 13f06f2e25e54..97eec84c6ac95 100644 --- a/scripts/lib/release-queue/github.ts +++ b/scripts/lib/release-queue/github.ts @@ -1,5 +1,6 @@ import { setTimeout } from 'node:timers/promises'; import { GitHubError } from './github-error.ts'; +import { isRecord, parseJson } from './json.ts'; const API_URL = 'https://api.github.com'; const API_VERSION = '2022-11-28'; @@ -9,34 +10,51 @@ const POLL_DELAY_MS = 2000; export type WriteMethod = 'POST' | 'PATCH' | 'PUT' | 'DELETE'; -/** An error body from GitHub. A validation error details it in `errors`. */ -interface ErrorBody { - message?: string; - errors?: (string | { message?: string })[]; -} - /** - * A minimal GitHub REST client. Callers declare the shape of the answers they - * read, which isn't checked: the API version is pinned, and the queries map - * each answer to the types the bot works with. + * A minimal GitHub REST client. + * + * Callers declare the shape of the answers they read, which isn't checked: + * the API version is pinned, and the queries map each answer to the types the + * bot works with. */ 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; } - /** Gets `path`, relative to the API root. */ + /** + * 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. + */ get(path: string): Promise { return this.#request('GET', path); } /** - * Gets `path` until `isReady` accepts the answer, or the attempts run out, - * for data GitHub computes in the background. Returns the last answer. + * 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, @@ -55,6 +73,7 @@ export class GitHubClient { * @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, @@ -72,9 +91,15 @@ export class GitHubClient { } /** - * Sends a request that changes GitHub. Resolves to `undefined` when GitHub - * answers without a body (204), or when the writes are only printed - * (`PrintingGitHubClient`). + * 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. `undefined` when GitHub + * answers without a body, or when `PrintingGitHubClient` only prints the + * request. + * @throws {GitHubError} When GitHub answers with an error. */ async write(method: WriteMethod, path: string, body?: unknown): Promise { return this.#request(method, path, body); @@ -92,33 +117,43 @@ export class GitHubClient { body: body === undefined ? undefined : JSON.stringify(body), }); + const request = `${method} ${path}`; if (response.status === 204) return undefined as T; - // Error pages from a proxy (e.g. a 502) are HTML. + // Error pages from a proxy, such as a 502, are HTML. const isJson = response.headers.get('content-type')?.includes('application/json'); - const data = isJson ? await response.json() : await response.text(); + const text = await response.text(); if (!response.ok) { - const reason = (isJson && getErrorReason(data)) || response.statusText; - throw new GitHubError({ - status: response.status, - githubReason: reason, - request: `${method} ${path}`, - }); + const reason = (isJson && getErrorReason(text)) || response.statusText; + throw new GitHubError({ status: response.status, githubReason: reason, request }); } - if (!isJson) throw new Error(`${method} ${path} answered ${response.status} without JSON.`); - return data; + if (!isJson) throw new Error(`${request} answered ${response.status} without JSON.`); + return parseJson(text, request) as T; } } /** * Gets the reason of an error body, with its details: a 422 only says - * "Validation Failed" in `message`. + * "Validation Failed" in `message`, and details it in `errors`. */ -function getErrorReason(body: ErrorBody | null): string | undefined { - // A proxy could answer JSON that isn't an object. - if (typeof body !== 'object' || !body?.message) return undefined; +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((error) => (typeof error === 'string' ? error : error?.message)) + .map((error: unknown) => (typeof error === 'string' ? error : readErrorMessage(error))) .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; + } +} + +function readErrorMessage(error: unknown): string | undefined { + return isRecord(error) && typeof error.message === 'string' ? error.message : undefined; +} diff --git a/scripts/lib/release-queue/handle-accepted.test.ts b/scripts/lib/release-queue/handle-accepted.test.ts index ce50f83de58e5..785e4c0f76b64 100644 --- a/scripts/lib/release-queue/handle-accepted.test.ts +++ b/scripts/lib/release-queue/handle-accepted.test.ts @@ -5,7 +5,7 @@ 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, queueOptions } from './testing/queue-options.ts'; -import { stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; +import { readBody, stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; import { formatUmbrellaBody } from './umbrella-body.ts'; const paths = { @@ -13,7 +13,7 @@ const paths = { reviews: `repos/${REPO}/pulls/7/reviews`, releasePullRequest: 'repos/withastro/astro/pulls?state=open&head=withastro:changeset-release/main', - mainUmbrella: `repos/${REPO}/pulls?state=open&head=withastro:release-queue/main`, + stableUmbrella: `repos/${REPO}/pulls?state=open&head=withastro:release-queue/main`, }; /** @@ -23,7 +23,7 @@ const paths = { function readyPullRequest({ base = 'main', label = 'minor-release', umbrellaBody = '' } = {}) { const umbrellaBranch = `release-queue/${base}`; const umbrella = umbrellaResponse({ - target: base, + 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, @@ -50,7 +50,6 @@ function readyPullRequest({ base = 'main', label = 'minor-release', umbrellaBody [`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}/pulls?state=closed&base=${umbrellaBranch}`]: [], [`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`]: [], @@ -62,7 +61,7 @@ function readyPullRequest({ base = 'main', label = 'minor-release', umbrellaBody const unapprovedPullRequest = () => ({ ...readyPullRequest(), [paths.reviews]: [] }); /** Responses for a PR that opens the `main` umbrella with its first join. */ -const firstJoin = () => ({ ...readyPullRequest(), [paths.mainUmbrella]: [] }); +const firstJoin = () => ({ ...readyPullRequest(), [paths.stableUmbrella]: [] }); /** * Responses where the `main` umbrella #42 is settled: it links release PR @@ -70,7 +69,7 @@ const firstJoin = () => ({ ...readyPullRequest(), [paths.mainUmbrella]: [] }); */ const settledUmbrella = () => ({ ...readyPullRequest({ - umbrellaBody: formatUmbrellaBody('main', { releasePullRequest: 18405, included: [] }), + umbrellaBody: formatUmbrellaBody('main', { releasePrNumber: 18405, included: [] }), }), 'repos/withastro/astro/pulls/18405': { number: 18405, merged: true }, }); @@ -91,7 +90,7 @@ const writtenPaths = (writes: Write[]) => writes.map(({ method, path }) => `${me /** The body of the last comment in `writes`. */ function commentBody(writes: Write[]): string { const write = writes.findLast(({ path }) => path.endsWith('/comments')); - return (write?.body as { body: string } | undefined)?.body ?? ''; + return readBody(write); } const comment = `POST repos/${REPO}/issues/7/comments`; @@ -116,8 +115,8 @@ describe('handleAcceptedPullRequest', () => { assert.deepEqual(botWrites, []); }); - it('joins a PR shipping to main in main-only mode', async (t) => { - const { actionsWrites, botWrites } = await run(t, 'main-only'); + 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`]); @@ -131,7 +130,7 @@ describe('handleAcceptedPullRequest', () => { }); it('lists the PRs still on the umbrella branch in the review', async (t) => { - const { actionsWrites } = await run(t, 'main-only', { + const { actionsWrites } = await run(t, 'stable-only', { getResponses: { ...readyPullRequest(), [`repos/${REPO}/pulls?state=open&base=release-queue/main`]: [{ number: 14590 }], @@ -139,14 +138,11 @@ describe('handleAcceptedPullRequest', () => { }); const review = actionsWrites.find(({ path }) => path.endsWith('/reviews')); - assert.match( - (review?.body as { body: string }).body, - /These PRs still target `release-queue\/main`: #14590\./ - ); + 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, 'main-only', { + const { botWrites } = await run(t, 'stable-only', { getResponses: { ...firstJoin(), [`repos/${REPO}/pulls/99/reviews`]: [], @@ -173,9 +169,9 @@ describe('handleAcceptedPullRequest', () => { assert.deepEqual(botWrites.at(-1)?.body, { labels: ['merge-on-release', 'minor-release'] }); }); - it('only comments on a PR shipping to a prerelease branch in main-only mode', async (t) => { + 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, 'main-only', { getResponses }); + const { actionsWrites, botWrites } = await run(t, 'stable-only', { getResponses }); assert.deepEqual(writtenPaths(actionsWrites), [comment]); assert.deepEqual(botWrites, []); @@ -208,13 +204,13 @@ describe('handleAcceptedPullRequest', () => { }); const refresh = botWrites.find(({ path }) => path === `repos/${REPO}/pulls/42`); - assert.match((refresh?.body as { body: string }).body, /^Ships with withastro\/astro#18269\./); + 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(queueOptions(github, 'main-only'), 7, 'maintainer'); + await handleAcceptedPullRequest(queueOptions(github, 'stable-only'), 7, 'maintainer'); assert.ok(!github.calls.some(({ path }) => path.includes('.changeset/pre.json'))); }); @@ -245,21 +241,21 @@ describe('handleAcceptedPullRequest', () => { } it('keeps the accepted label after a join, and names the umbrella', async (t) => { - const { botWrites } = await run(t, 'main-only'); + 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, 'main-only', { getResponses: unapprovedPullRequest() }); + const { botWrites } = await run(t, 'stable-only', { getResponses: unapprovedPullRequest() }); assert.deepEqual(writtenPaths(botWrites), [removeAccepted, comment]); assert.match(commentBody(botWrites), /no approval/); }); it('still comments when the label is already gone', async (t) => { - const { botWrites } = await run(t, 'main-only', { + const { botWrites } = await run(t, 'stable-only', { getResponses: unapprovedPullRequest(), writeFailures: { [removeAccepted]: { status: 404, message: 'Label does not exist' } }, }); @@ -268,7 +264,7 @@ describe('handleAcceptedPullRequest', () => { }); it('names the open main umbrella when the next astro release is a major', async (t) => { - const { botWrites } = await run(t, 'main-only', { + const { botWrites } = await run(t, 'stable-only', { getResponses: { ...readyPullRequest(), [paths.releasePullRequest]: [{ number: 1, html_url: '', body: '## astro@8.0.0\n' }], @@ -282,7 +278,7 @@ describe('handleAcceptedPullRequest', () => { describe('while the main umbrella is settled', () => { it('refuses a PR it would retarget, before changing it', async (t) => { - const { botWrites } = await run(t, 'main-only', { getResponses: settledUmbrella() }); + const { botWrites } = await run(t, 'stable-only', { getResponses: settledUmbrella() }); assert.deepEqual(writtenPaths(botWrites), [removeAccepted, comment]); assert.match( @@ -299,7 +295,7 @@ describe('handleAcceptedPullRequest', () => { }); it('joins a PR against the umbrella branch', async (t) => { - const { botWrites } = await run(t, 'main-only', { + const { botWrites } = await run(t, 'stable-only', { getResponses: { ...settledUmbrella(), [paths.pr]: pullRequestResponse({ @@ -350,7 +346,7 @@ describe('handleAcceptedPullRequest', () => { }); it('refuses a PR whose join is refused', async (t) => { - const { botWrites } = await run(t, 'main-only', { + const { botWrites } = await run(t, 'stable-only', { getResponses: { ...firstJoin(), [`repos/${REPO}/git/ref/heads/release-queue/main`]: { object: { sha: 'old-sha' } }, @@ -371,7 +367,7 @@ describe('handleAcceptedPullRequest', () => { }); await assert.rejects( - handleAcceptedPullRequest(queueOptions(github, 'main-only'), 7, 'maintainer'), + handleAcceptedPullRequest(queueOptions(github, 'stable-only'), 7, 'maintainer'), (error: unknown) => { assert.ok(error instanceof Error); assert.match(error.message, /but the umbrella PR couldn't be opened\.$/); @@ -388,18 +384,18 @@ describe('handleAcceptedPullRequest', () => { it('comments on the join, then fails, when the refresh fails', async (t) => { const responses: Record = readyPullRequest(); - delete responses[`repos/${REPO}/pulls?state=closed&base=release-queue/main`]; + delete responses[`repos/${REPO}/compare/main...release-queue/main`]; const github = stubReadyPullRequest(t, { getResponses: responses }); await assert.rejects( - handleAcceptedPullRequest(queueOptions(github, 'main-only'), 7, 'maintainer'), + handleAcceptedPullRequest(queueOptions(github, 'stable-only'), 7, '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, 'main-only', {}, 'contributor'); + 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 index 66debe0e35a82..02b3b11628248 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -1,4 +1,4 @@ -import { ACCEPTED_LABEL, FRAMEWORK_MAIN_BRANCH, MAIN_UMBRELLA_BRANCH } from './config.ts'; +import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH, STABLE_UMBRELLA_BRANCH } from './config.ts'; import { joinUmbrella } from './join.ts'; import { syncMergeBlockReview } from './merge-block.ts'; import { @@ -9,7 +9,7 @@ import { formatUnopenedJoinComment, type MessageContext, } from './messages.ts'; -import { isLiveFor, type QueueOptions } from './modes.ts'; +import { isLiveFor } from './modes.ts'; import { canWrite, getPullRequest, @@ -20,52 +20,52 @@ import { type PullRequest, } from './queries.ts'; import { refreshUmbrella } from './refresh.ts'; -import { getMainRelease, type MainRelease } from './release.ts'; -import type { Repository } from './repository.ts'; +import { getStableRelease, type StableRelease } from './release.ts'; +import type { QueueOptions, Repository } from './repository.ts'; import { decideJoin, - getTarget, + getUmbrellaBase, type Decision, type RefusedDecision, type Route, - isRetargetedFromMain, + wouldRetargetFromStable, } from './routing.ts'; -import { getUmbrellaHeading, type Umbrella } from './umbrella-heading.ts'; -import { findOpenUmbrella, getUmbrella } from './umbrella-queries.ts'; -import { getSettledMainUmbrella } from './umbrella-state.ts'; +import { getUmbrellaHeading } from './umbrella-heading.ts'; +import { findOpenUmbrella, getUmbrella, type Umbrella } from './umbrella-queries.ts'; +import { getSettledStableUmbrella } from './umbrella-state.ts'; /** - * A docs PR labeled `accepted`, with the release it ships with and the + * A docs PR labeled as accepted, with the release it ships with and the * decision on it. */ interface AcceptedPullRequest { pr: PullRequest; - release: MainRelease; + release: StableRelease; decision: D; } type JoinDecision = Extract; /** - * Handles a docs PR labeled `accepted`: comments on it as a dry run, refuses - * it, or joins it to its umbrella. + * Joins a docs PR labeled as accepted to its umbrella, refuses it, or + * comments on it as a dry run. * * @param {QueueOptions} options - The repositories and the mode of the run. * @param {number} prNumber - The number of the docs PR. - * @param {string} [sender] - Who added the label. Without it, the write + * @param {string} [labelSender] - Who added the label. Without it, the write * access check is skipped, as in a local run. */ export async function handleAcceptedPullRequest( options: QueueOptions, prNumber: number, - sender?: string + labelSender?: string ) { - if (!(await isSenderAllowed(options.actions, sender))) return; + if (!(await isSenderAllowed(options.actions, labelSender))) return; const accepted = await readAcceptedPullRequest(options, prNumber); const { pr, release, decision } = accepted; console.info(JSON.stringify({ pr: pr.url, release, decision }, null, 2)); - if (!isLiveFor(options.mode, getTarget(pr.base))) { + if (!isLiveFor(options.mode, getUmbrellaBase(pr.base))) { await commentDryRun(options, accepted); } else if (decision.status === 'refuse') { await refuse(options, { ...accepted, decision }); @@ -74,20 +74,23 @@ export async function handleAcceptedPullRequest( } } -/** Whether `sender` can write to the repository, like maintainers do. */ -async function isSenderAllowed(repo: Repository, sender: string | undefined): Promise { - if (sender === undefined) { - console.info('No sender: skipping the write access check.'); +/** 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 canWrite(repo, sender)) return true; - console.info(`Ignored: ${sender} can't write to ${repo.name}.`); + if (await canWrite(repo, labelSender)) return true; + console.info(`Ignored: ${labelSender} can't write to ${repo.name}.`); return false; } /** - * Reads the PR, the release PR of `withastro/astro` and the state of the - * `main` umbrella, then decides. + * Reads a docs PR with what decides its join: its readiness, the next stable + * release, and the settled stable umbrella. */ async function readAcceptedPullRequest( { actions, framework }: QueueOptions, @@ -96,13 +99,13 @@ async function readAcceptedPullRequest( const pr = await getPullRequest(actions, prNumber); const [readiness, releasePullRequest] = await Promise.all([ getReadiness(actions, pr), - getReleasePullRequest(framework, FRAMEWORK_MAIN_BRANCH), + getReleasePullRequest(framework, FRAMEWORK_STABLE_BRANCH), ]); - const release = getMainRelease(releasePullRequest); - const settledMainUmbrella = isRetargetedFromMain(pr) - ? await getSettledMainUmbrella({ docs: actions, framework }, release) + const release = getStableRelease(releasePullRequest); + const settledStableUmbrella = wouldRetargetFromStable(pr) + ? await getSettledStableUmbrella({ docs: actions, framework }, release) : undefined; - const decision = decideJoin(pr, readiness, { release, settledMainUmbrella }); + const decision = decideJoin(pr, readiness, { release, settledStableUmbrella }); return { pr, release, decision }; } @@ -111,7 +114,7 @@ async function commentDryRun( { pr, release, decision }: AcceptedPullRequest ) { if (decision.status === 'join') { - const umbrella = await findUmbrella(options, decision.route, release); + const umbrella = await findOrPlanUmbrella(options, decision.route, release); await postComment( options.actions, pr.number, @@ -137,7 +140,7 @@ async function refuse( async function joinAndAnnounce(options: QueueOptions, accepted: AcceptedPullRequest) { const { pr, release, decision } = accepted; const { route } = decision; - const { title } = getUmbrellaHeading(route.target, release); + const { title } = getUmbrellaHeading(route.umbrellaBase, release); const result = await joinUmbrella(options.bot, { pr, route, umbrellaTitle: title }); switch (result.status) { @@ -181,26 +184,37 @@ async function announceJoin( } /** Finds the open umbrella PR of `route`, or names the one to create. */ -async function findUmbrella( +async function findOrPlanUmbrella( { actions }: QueueOptions, route: Route, - release: MainRelease + release: StableRelease ): Promise { const openUmbrella = await findOpenUmbrella(actions, route.umbrellaBranch); if (openUmbrella) return { status: 'open', number: openUmbrella.number }; - return { status: 'planned', title: getUmbrellaHeading(route.target, release).title }; + return { status: 'planned', title: getUmbrellaHeading(route.umbrellaBase, release).title }; } async function getMessageContext( options: QueueOptions, decision: RefusedDecision, - release: MainRelease + release: StableRelease ): Promise { - const context: MessageContext = {}; - if (decision.route) context.umbrella = await findUmbrella(options, decision.route, release); - if (decision.refusals.some((refusal) => refusal.code === 'major-on-main')) { - const mainUmbrella = await findOpenUmbrella(options.actions, MAIN_UMBRELLA_BRANCH); - if (mainUmbrella) context.mainUmbrella = { number: mainUmbrella.number }; - } - return context; + const [umbrella, stableUmbrella] = await Promise.all([ + decision.route && findOrPlanUmbrella(options, decision.route, release), + findStableUmbrellaToMove(options.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.test.ts b/scripts/lib/release-queue/join.test.ts index 32dbb053127ae..5da9247b95066 100644 --- a/scripts/lib/release-queue/join.test.ts +++ b/scripts/lib/release-queue/join.test.ts @@ -21,7 +21,7 @@ const pr: PullRequest = { }; const route: Route = { - target: 'main', + umbrellaBase: 'main', umbrellaBranch: 'release-queue/main', label: 'minor-release', shouldRetarget: true, @@ -31,7 +31,7 @@ const umbrellaTitle = '[ci] release astro@7.5.0 docs'; const paths = { openUmbrella: `repos/${REPO}/pulls?state=open&head=withastro:release-queue/main`, - mainRef: `repos/${REPO}/git/ref/heads/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`, @@ -43,7 +43,7 @@ const prResponse = (mergeable: boolean | null) => /** Responses without an open umbrella, for a PR that merges cleanly. */ const withoutUmbrella = { [paths.openUmbrella]: [], - [paths.mainRef]: { object: { sha: 'main-sha' } }, + [paths.stableRef]: { object: { sha: 'main-sha' } }, [paths.pr]: prResponse(true), }; @@ -195,9 +195,9 @@ describe('joinUmbrella', () => { }); it('throws when the target branch is missing, without touching the PR', async (t) => { - const withoutMain: Record = { ...withoutUmbrella }; - delete withoutMain[paths.mainRef]; - const github = stubGitHub(t, { getResponses: withoutMain }); + const withoutStable: Record = { ...withoutUmbrella }; + delete withoutStable[paths.stableRef]; + const github = stubGitHub(t, { getResponses: withoutStable }); await assert.rejects( joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), diff --git a/scripts/lib/release-queue/join.ts b/scripts/lib/release-queue/join.ts index 584ef4fa551a5..734d5dd10608e 100644 --- a/scripts/lib/release-queue/join.ts +++ b/scripts/lib/release-queue/join.ts @@ -2,8 +2,8 @@ import { GitHubError } from './github-error.ts'; import { countCommitsAhead, getBranchSha, getPullRequest, type PullRequest } from './queries.ts'; import type { Repository } from './repository.ts'; import type { JoinRefusal, Route } from './routing.ts'; -import type { Umbrella } from './umbrella-heading.ts'; -import { findOpenUmbrella } from './umbrella-queries.ts'; +import { formatSquashTitle } from './umbrella-body.ts'; +import { findOpenUmbrella, type Umbrella } from './umbrella-queries.ts'; /** A docs PR to join to the umbrella of `route`. */ export interface Join { @@ -27,62 +27,75 @@ export type UnopenedJoin = Extract; /** * The umbrella branch before a join that opens the umbrella PR: missing, left - * over without commits (for example after a failed first merge), or left over + * over without commits, for example after a failed first merge, or left over * with commits, which a maintainer has to look at. */ -type UmbrellaBranch = { state: 'missing' | 'empty'; targetSha: string } | { state: 'stale' }; +type UmbrellaBranch = { state: 'missing' | 'empty'; umbrellaBaseSha: string } | { state: 'stale' }; /** - * Squash-merges a docs PR into its umbrella branch. Prepares the branch before - * changing the PR, then changes the PR base, merges, and opens the umbrella - * PR if none is open: GitHub refuses a PR without commits. A refused merge - * restores the PR base. Expected failures come back as a result, anything - * else throws. The refresh sets the labels and body. + * Squash-merges a docs PR into its umbrella branch. + * + * Prepares the branch before changing the PR, then changes the PR base, + * merges, and opens the umbrella PR if none is open: GitHub refuses a PR + * without commits. A refused merge restores the PR base. The refresh sets the + * labels and the body of the umbrella PR. * * @param {Repository} repo - The docs repository, written as the bot account. * @param {Join} join - The PR to merge, its route, and the umbrella title. + * @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. */ export async function joinUmbrella( repo: Repository, { pr, route, umbrellaTitle }: Join ): Promise { const openUmbrella = await findOpenUmbrella(repo, route.umbrellaBranch); - if (!openUmbrella) { - const branch = await readUmbrellaBranch(repo, route); - if (branch.state === 'stale') { - const refusal: JoinRefusal = { code: 'stale-umbrella-branch', branch: route.umbrellaBranch }; - return { status: 'refused', refusal }; - } - await startUmbrellaBranch(repo, route, branch); - } + const branchRefusal = openUmbrella ? undefined : await prepareUmbrellaBranch(repo, route); + if (branchRefusal) return { status: 'refused', refusal: branchRefusal }; - const refusal = route.shouldRetarget + const mergeRefusal = route.shouldRetarget ? await retargetAndMerge(repo, pr, route) : await squashMerge(repo, pr, route); - if (refusal) return { status: 'refused', refusal }; - - if (openUmbrella) - return { - status: 'joined', - umbrella: { status: 'open', number: openUmbrella.number }, - }; - return createUmbrella(repo, route, umbrellaTitle); + if (mergeRefusal) return { status: 'refused', refusal: mergeRefusal }; + + if (!openUmbrella) return createUmbrella(repo, route, umbrellaTitle); + 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 function prepareUmbrellaBranch( + repo: Repository, + route: Route +): Promise { + const branch = await readUmbrellaBranch(repo, route); + if (branch.state === 'stale') { + return { code: 'stale-umbrella-branch', branch: route.umbrellaBranch }; + } + await startUmbrellaBranch(repo, route, branch); + return undefined; } async function readUmbrellaBranch( repo: Repository, - { target, umbrellaBranch }: Route + { umbrellaBase, umbrellaBranch }: Route ): Promise { - const targetSha = await getBranchSha(repo, target); - if (!targetSha) throw new Error(`The branch ${target} doesn't exist in ${repo.name}.`); + const umbrellaBaseSha = await getBranchSha(repo, umbrellaBase); + if (!umbrellaBaseSha) { + throw new Error(`The branch ${umbrellaBase} doesn't exist in ${repo.name}.`); + } if ((await getBranchSha(repo, umbrellaBranch)) === undefined) { - return { state: 'missing', targetSha }; + return { state: 'missing', umbrellaBaseSha }; } - const commitsAhead = await countCommitsAhead(repo, target, umbrellaBranch); - return commitsAhead > 0 ? { state: 'stale' } : { state: 'empty', targetSha }; + const commitsAhead = await countCommitsAhead(repo, umbrellaBase, umbrellaBranch); + return commitsAhead > 0 ? { state: 'stale' } : { state: 'empty', umbrellaBaseSha }; } -/** Creates the umbrella branch from its target, or resets an empty one. */ +/** Creates the umbrella branch from its base, or resets an empty one. */ async function startUmbrellaBranch( repo: Repository, { umbrellaBranch }: Route, @@ -91,12 +104,12 @@ async function startUmbrellaBranch( if (branch.state === 'missing') { await repo.write('POST', 'git/refs', { ref: `refs/heads/${umbrellaBranch}`, - sha: branch.targetSha, + sha: branch.umbrellaBaseSha, }); return; } await repo.write('PATCH', `git/refs/heads/${umbrellaBranch}`, { - sha: branch.targetSha, + sha: branch.umbrellaBaseSha, force: true, }); } @@ -107,8 +120,7 @@ function setBase(repo: Repository, prNumber: number, base: string) { /** * Changes the PR base to the umbrella branch, then squash-merges the PR. The - * base is restored when the merge is refused or throws. Returns a refusal, or - * `undefined` once merged. + * base is restored when the merge is refused or throws. */ async function retargetAndMerge( repo: Repository, @@ -153,8 +165,9 @@ async function squashMerge( // Refuses the merge if the PR changed since its checks were read. sha: pr.headSha, // Set here rather than relying on how the API applies the squash - // title setting of the repository. - commit_title: `${pr.title} (#${pr.number})`, + // title setting of the repository. The included PRs are read from + // this title. + commit_title: formatSquashTitle(pr), }); return undefined; } catch (error) { @@ -165,15 +178,15 @@ async function squashMerge( async function createUmbrella( repo: Repository, - { target, umbrellaBranch }: Route, + { umbrellaBase, umbrellaBranch }: Route, title: string ): Promise | UnopenedJoin> { try { const created = await repo.write<{ number: number }>('POST', 'pulls', { title, head: umbrellaBranch, - base: target, - body: `Release umbrella for \`${target}\`, managed by the release queue.`, + base: umbrellaBase, + body: `Release umbrella for \`${umbrellaBase}\`, managed by the release queue.`, }); // Without an answer, the request was only printed. const umbrella: Umbrella = created diff --git a/scripts/lib/release-queue/json.ts b/scripts/lib/release-queue/json.ts index e675f088613bf..1f90e1c4e427d 100644 --- a/scripts/lib/release-queue/json.ts +++ b/scripts/lib/release-queue/json.ts @@ -1,9 +1,22 @@ -/** Whether a parsed JSON value is an object, and not an array. */ +/** + * Whether a parsed JSON value is an object, and not an array. + * + * @param {unknown} value - The parsed value. + * @returns {boolean} `true` for an object whose fields can be read. + */ export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -/** Parses `json`, or throws an error that names the `source` of it. */ +/** + * 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); diff --git a/scripts/lib/release-queue/main.test.ts b/scripts/lib/release-queue/main.test.ts index e4fd529a44c2d..b3aaac11d56cc 100644 --- a/scripts/lib/release-queue/main.test.ts +++ b/scripts/lib/release-queue/main.test.ts @@ -59,6 +59,20 @@ describe('runReleaseQueue', () => { ); }); + 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('requires the bot token to write outside a dry run', async () => { await assert.rejects( runReleaseQueue({ diff --git a/scripts/lib/release-queue/main.ts b/scripts/lib/release-queue/main.ts index 9a0fc8893c363..ad8d755d7cc8a 100644 --- a/scripts/lib/release-queue/main.ts +++ b/scripts/lib/release-queue/main.ts @@ -3,26 +3,27 @@ import { chooseAction, parseEvent, type RunEvent } from './cli.ts'; import { DOCS_REPO, FRAMEWORK_REPO } from './config.ts'; import { GitHubClient } from './github.ts'; import { handleAcceptedPullRequest } from './handle-accepted.ts'; -import { needsBotToken, parseMode, type QueueOptions } from './modes.ts'; +import { isDryRun, parseMode } from './modes.ts'; import { PrintingGitHubClient } from './printing-github-client.ts'; import { refreshOpenUmbrellas } from './refresh.ts'; -import { Repository } from './repository.ts'; +import { Repository, type QueueOptions } from './repository.ts'; /** The command-line flags of `scripts/release-queue.ts`. */ export interface Flags { pr?: string | undefined; refresh: boolean; + /** Who added the label, for the write access check. */ sender?: string | undefined; write: boolean; } /** The environment variables the release queue reads. */ export interface Env { - /** Reads, dry-run comments and merge-block reviews. */ + /** For reads, dry-run comments and merge-block reviews. */ GITHUB_TOKEN?: string | undefined; - /** The bot account, for joins, refusals and refreshes. */ + /** The token of the bot account, for joins, refusals and refreshes. */ BOT_TOKEN?: string | undefined; - /** `dry-run` by default: see `Mode`. */ + /** A dry run by default: see `Mode`. */ MODE?: string | undefined; // Set by GitHub Actions. GITHUB_ACTIONS?: string | undefined; @@ -32,17 +33,23 @@ export interface Env { } /** - * Routes a docs PR labeled `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 printed unless `write`. + * 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 printed unless `write` is set. + * + * @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 shouldWrite = env.GITHUB_ACTIONS === 'true' || flags.write; - const options = createOptions(env, shouldWrite); + const options = createOptions(flags, env); const event = await readEvent(env.GITHUB_EVENT_PATH); const action = chooseAction({ pr: flags.pr, - refresh: flags.refresh, + isRefreshRequested: flags.refresh, eventName: env.GITHUB_EVENT_NAME, eventPullRequest: event.pullRequest, }); @@ -50,18 +57,22 @@ export async function runReleaseQueue({ flags, env }: { flags: Flags; env: Env } if (action.type === 'refresh') { await refreshOpenUmbrellas(options); } else { - await handleAcceptedPullRequest(options, action.prNumber, flags.sender ?? event.sender); + const labelSender = getLabelSender(flags, env, event); + await handleAcceptedPullRequest(options, action.prNumber, labelSender); } } -function createOptions(env: Env, shouldWrite: boolean): QueueOptions { +const isGitHubActions = (env: Env) => env.GITHUB_ACTIONS === 'true'; + +function createOptions(flags: Flags, env: Env): QueueOptions { const mode = parseMode(env.MODE); const token = env.GITHUB_TOKEN; if (!token) throw new Error('GITHUB_TOKEN is required.'); + const shouldWrite = isGitHubActions(env) || flags.write; // Changes go through the bot account: unlike `GITHUB_TOKEN`, its merges into // an umbrella branch trigger the checks of the umbrella PR. const botToken = env.BOT_TOKEN; - if (needsBotToken(mode, shouldWrite) && !botToken) { + if (shouldWrite && !isDryRun(mode) && !botToken) { throw new Error(`BOT_TOKEN is required in ${mode} mode.`); } @@ -79,3 +90,15 @@ function createOptions(env: Env, shouldWrite: boolean): QueueOptions { async function readEvent(path: string | undefined): Promise { return path ? parseEvent(await readFile(path, 'utf-8'), path) : {}; } + +/** + * Gets who added the label. Only a local run can go without one, which skips + * the write access check. + */ +function getLabelSender(flags: Flags, env: Env, event: RunEvent): string | undefined { + const labelSender = flags.sender ?? event.labelSender; + if (labelSender === undefined && isGitHubActions(env)) { + throw new Error('The event has no sender: the write access check needs one.'); + } + return labelSender; +} diff --git a/scripts/lib/release-queue/merge-block.test.ts b/scripts/lib/release-queue/merge-block.test.ts index 18392f3e5c23f..2debfa79b990b 100644 --- a/scripts/lib/release-queue/merge-block.test.ts +++ b/scripts/lib/release-queue/merge-block.test.ts @@ -2,17 +2,17 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; import { syncMergeBlockReview } from './merge-block.ts'; import { REPO } from './testing/fixtures.ts'; -import { stubGitHub } from './testing/stub-github.ts'; +import { readBody, stubGitHub } from './testing/stub-github.ts'; import { formatUmbrellaBody } from './umbrella-body.ts'; -import type { OpenUmbrella } from './umbrella-queries.ts'; +import type { UmbrellaPullRequest } from './umbrella-queries.ts'; /** The `main` umbrella #42, linked to release PR #18405. */ -const umbrella: OpenUmbrella = { +const umbrella: UmbrellaPullRequest = { number: 42, title: '[ci] release astro@7.5.0 docs', - body: formatUmbrellaBody('main', { releasePullRequest: 18405, included: [] }), + body: formatUmbrellaBody('main', { releasePrNumber: 18405, included: [] }), labels: ['merge-on-release', 'minor-release'], - target: 'main', + base: 'main', branch: 'release-queue/main', }; @@ -43,12 +43,12 @@ function stub(t: TestContext, reviews: unknown[], openPullRequests: number[] = [ /** Submits the review of `reviewedUmbrella`, and returns its body. */ async function submittedChecklist( t: TestContext, - reviewedUmbrella: OpenUmbrella, + reviewedUmbrella: UmbrellaPullRequest, openPullRequests: number[] = [] ): Promise { const github = stub(t, [], openPullRequests); await syncMergeBlockReview(github.repository(), reviewedUmbrella, { shouldBlock: true }); - return (github.writes[0].body as { body: string }).body; + return readBody(github.writes[0]); } describe('syncMergeBlockReview', () => { diff --git a/scripts/lib/release-queue/merge-block.ts b/scripts/lib/release-queue/merge-block.ts index 90b3525d0337a..43bb6e5073721 100644 --- a/scripts/lib/release-queue/merge-block.ts +++ b/scripts/lib/release-queue/merge-block.ts @@ -3,15 +3,14 @@ import { LUNARIA_DIRECTIVE, LUNARIA_DIRECTIVE_DOCS, RELEASE_TITLE_PREFIX, - REVIEWER_LOGIN, + MERGE_BLOCK_REVIEWER_LOGIN, TRANSLATIONS_LABEL, } from './config.ts'; -import { formatFrameworkReference } from './github-links.ts'; import type { ReviewResponse } from './github-types.ts'; import type { Repository } from './repository.ts'; -import { normalizeLineEndings } from './text.ts'; +import { formatFrameworkReference, normalizeLineEndings } from './github-text.ts'; import { parseReleaseMarker } from './umbrella-body.ts'; -import type { OpenUmbrella } from './umbrella-queries.ts'; +import type { UmbrellaPullRequest } from './umbrella-queries.ts'; interface SubmittedReview extends ReviewResponse { id: number; @@ -23,7 +22,7 @@ interface SubmittedReview extends ReviewResponse { * through before merging an umbrella PR with `openPullRequests` still on its * branch, and the dismissal that lifts the block. */ -function formatMergeBlockBody(umbrella: OpenUmbrella, openPullRequests: number[]): string { +function formatMergeBlockBody(umbrella: UmbrellaPullRequest, openPullRequests: number[]): string { const items = [ describeReleaseItem(umbrella), describeBranchItem(umbrella, openPullRequests), @@ -33,22 +32,25 @@ function formatMergeBlockBody(umbrella: OpenUmbrella, openPullRequests: number[] 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: OpenUmbrella): string { - const releasePullRequest = parseReleaseMarker(umbrella.body); - return releasePullRequest === undefined +function describeReleaseItem(umbrella: UmbrellaPullRequest): string { + const releasePrNumber = parseReleaseMarker(umbrella.body); + return releasePrNumber === undefined ? `The \`${RELEASE_TITLE_PREFIX}\` PR of \`${FRAMEWORK_REPO}\` this umbrella ships with is merged.` - : `${formatFrameworkReference(releasePullRequest)} is merged.`; + : `${formatFrameworkReference(releasePrNumber)} is merged.`; } -function describeBranchItem({ branch, target }: OpenUmbrella, openPullRequests: number[]): string { +function describeBranchItem( + { branch, base }: UmbrellaPullRequest, + openPullRequests: number[] +): string { if (openPullRequests.length === 0) return `No open PR targets \`${branch}\`.`; const list = openPullRequests.map((prNumber) => `#${prNumber}`).join(', '); - return `These PRs still target \`${branch}\`: ${list}. Merge them into the umbrella, or change their base to \`${target}\` and update them after the umbrella is merged.`; + return `These PRs still target \`${branch}\`: ${list}. Merge them into the umbrella, or change their base to \`${base}\` and update them after the umbrella is merged.`; } /** - * Names the `i18n` label instead of reading it: `pr-labeler.yml` adds it after - * the merge, with `GITHUB_TOKEN`, which doesn't trigger this workflow. + * Names the translations 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 \`${TRANSLATIONS_LABEL}\` label, the commit body has a [Lunaria directive](${LUNARIA_DIRECTIVE_DOCS}), for example \`${LUNARIA_DIRECTIVE}\`. The squash commit body is empty by default: add it in the merge dialog.`; @@ -60,19 +62,26 @@ function describeLunariaItem(): string { */ function findActiveReview(reviews: SubmittedReview[]): SubmittedReview | undefined { return reviews.findLast( - ({ user, state }) => user?.login === REVIEWER_LOGIN && state === 'CHANGES_REQUESTED' + ({ user, state }) => user?.login === MERGE_BLOCK_REVIEWER_LOGIN && state === 'CHANGES_REQUESTED' ); } /** - * Keeps the merge-block review of an umbrella PR up to date. Updates the - * active review when there is one. Otherwise, submits a new one when - * `shouldBlock`: after a join, as a dismissed review no longer covers the PRs - * that joined since. + * Keeps the merge-block review of an umbrella PR up to date. + * + * Updates the active review when there is one. Otherwise, submits a new one + * when `shouldBlock` is set. + * + * @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: after a join, as a dismissed review doesn't cover the PRs that + * joined since. */ export async function syncMergeBlockReview( repo: Repository, - umbrella: OpenUmbrella, + umbrella: UmbrellaPullRequest, { shouldBlock }: { shouldBlock: boolean } ) { const [reviews, openPullRequests] = await Promise.all([ diff --git a/scripts/lib/release-queue/messages.test.ts b/scripts/lib/release-queue/messages.test.ts index 36c6dee979659..3e8f50bacebc5 100644 --- a/scripts/lib/release-queue/messages.test.ts +++ b/scripts/lib/release-queue/messages.test.ts @@ -12,7 +12,7 @@ import { import type { Refusal, Route } from './routing.ts'; const route: Route = { - target: 'main', + umbrellaBase: 'main', umbrellaBranch: 'release-queue/main', label: 'patch-release', shouldRetarget: true, @@ -36,8 +36,8 @@ describe('refusal descriptions', () => { { 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-main', astroVersion: '8.0.0' }, - { code: 'settled-umbrella', number: 42, releasePullRequest: 18405 }, + { 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'] }, @@ -73,10 +73,10 @@ describe('refusal descriptions', () => { }); it('mentions the open main umbrella when the next astro release is a major', () => { - const refusal: Refusal = { code: 'major-on-main', astroVersion: '8.0.0' }; + 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, { mainUmbrella: { number: 42 } }), /umbrella \(#42\)/); + assert.match(describeRefusal(refusal, { stableUmbrella: { number: 42 } }), /umbrella \(#42\)/); }); }); diff --git a/scripts/lib/release-queue/messages.ts b/scripts/lib/release-queue/messages.ts index b3a11aa2c1477..8818ce7143330 100644 --- a/scripts/lib/release-queue/messages.ts +++ b/scripts/lib/release-queue/messages.ts @@ -1,22 +1,22 @@ import { ACCEPTED_LABEL, - FRAMEWORK_MAIN_BRANCH, + FRAMEWORK_STABLE_BRANCH, FRAMEWORK_PACKAGE, - MAIN_BRANCH, - MAIN_UMBRELLA_BRANCH, + STABLE_BRANCH, + STABLE_UMBRELLA_BRANCH, RELEASE_LABELS, } from './config.ts'; import { GitHubError } from './github-error.ts'; -import { formatFrameworkReference } from './github-links.ts'; +import { formatFrameworkReference } from './github-text.ts'; import type { UnopenedJoin } from './join.ts'; import type { Refusal, RefusalCode, RefusedDecision, Route } from './routing.ts'; -import type { Umbrella } from './umbrella-heading.ts'; +import type { Umbrella } from './umbrella-queries.ts'; export interface MessageContext { /** The umbrella PR the docs PR would join. */ - umbrella?: Umbrella; - /** The open `main` umbrella PR, named when the next release is a major. */ - mainUmbrella?: { number: number }; + umbrella?: Umbrella | undefined; + /** The open stable umbrella PR, named when the next release is a major. */ + stableUmbrella?: { number: number } | undefined; } const wrapInCode = (text: string) => `\`${text}\``; @@ -34,22 +34,24 @@ const REFUSAL_DESCRIPTIONS: { [C in RefusalCode]: DescribeRefusal } = { 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': () => - `This PR has no release label. Add ${wrapInCode(RELEASE_LABELS.patch)} or ${wrapInCode(RELEASE_LABELS.minor)} for a PR against ${wrapInCode(MAIN_BRANCH)}, or ${wrapInCode(RELEASE_LABELS.major)} for a PR against a prerelease branch.`, + `This PR has no release label. Add ${wrapInCode(RELEASE_LABELS.patch)} or ${wrapInCode(RELEASE_LABELS.minor)} for a PR against ${wrapInCode(STABLE_BRANCH)}, or ${wrapInCode(RELEASE_LABELS.major)} for a PR against a prerelease branch.`, 'several-release-labels': ({ labels }) => `This PR has several release labels (${joinAsCode(labels)}). Keep only one.`, mismatch: ({ base, label }) => label === RELEASE_LABELS.major ? `${wrapInCode(RELEASE_LABELS.major)} PRs target a prerelease branch, but this one targets ${wrapInCode(base)}. Change the base to the prerelease branch, or the label to ${wrapInCode(RELEASE_LABELS.patch)} or ${wrapInCode(RELEASE_LABELS.minor)}.` - : `${wrapInCode(label)} PRs target ${wrapInCode(MAIN_BRANCH)}, but this one targets ${wrapInCode(base)}. Change the base to ${wrapInCode(MAIN_BRANCH)}, or the label to ${wrapInCode(RELEASE_LABELS.major)}.`, - 'major-on-main': ({ astroVersion }, { mainUmbrella }) => { - const release = astroVersion ? ` (${wrapInCode(`${FRAMEWORK_PACKAGE}@${astroVersion}`)})` : ''; - const umbrella = mainUmbrella - ? ` The open ${wrapInCode(MAIN_BRANCH)} umbrella (#${mainUmbrella.number}) needs to be squashed into the prerelease branch by a maintainer.` + : `${wrapInCode(label)} PRs target ${wrapInCode(STABLE_BRANCH)}, but this one targets ${wrapInCode(base)}. Change the base to ${wrapInCode(STABLE_BRANCH)}, or the label to ${wrapInCode(RELEASE_LABELS.major)}.`, + 'major-on-stable': ({ frameworkVersion }, { stableUmbrella }) => { + const release = frameworkVersion + ? ` (${wrapInCode(`${FRAMEWORK_PACKAGE}@${frameworkVersion}`)})` : ''; - return `The next ${wrapInCode(FRAMEWORK_PACKAGE)} release from ${wrapInCode(FRAMEWORK_MAIN_BRANCH)} is a major${release}: docs for it belong on the prerelease branch. Change the base to the prerelease branch and the label to ${wrapInCode(RELEASE_LABELS.major)}.${umbrella}`; + const umbrella = stableUmbrella + ? ` The open ${wrapInCode(STABLE_BRANCH)} umbrella (#${stableUmbrella.number}) needs to be squashed into the prerelease branch by a maintainer.` + : ''; + return `The next ${wrapInCode(FRAMEWORK_PACKAGE)} release from ${wrapInCode(FRAMEWORK_STABLE_BRANCH)} is a major${release}: docs for it belong on the prerelease branch. Change the base to the prerelease branch and the label to ${wrapInCode(RELEASE_LABELS.major)}.${umbrella}`; }, - 'settled-umbrella': ({ number, releasePullRequest }) => - `#${number} ships with ${formatFrameworkReference(releasePullRequest)}, which is already merged. Merge #${number} first. If this PR documents that release, change its base to ${wrapInCode(MAIN_UMBRELLA_BRANCH)} instead.`, + 'settled-umbrella': ({ number, releasePrNumber }) => + `#${number} ships with ${formatFrameworkReference(releasePrNumber)}, which is already merged. Merge #${number} first. If this PR documents that release, change its base to ${wrapInCode(STABLE_UMBRELLA_BRANCH)} instead.`, '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.`, @@ -80,7 +82,7 @@ function describeUmbrella(route: Route, umbrella: Umbrella): string { umbrella.status === 'open' ? `#${umbrella.number}` : `a new umbrella PR (${wrapInCode(umbrella.title)})`; - return `${name} into ${wrapInCode(route.target)}`; + return `${name} into ${wrapInCode(route.umbrellaBase)}`; } function describeRoute(route: Route, umbrella: Umbrella): string { @@ -105,12 +107,24 @@ function describeError(error: unknown): string { const DRY_RUN_HEADING = '**Release queue (dry run)**: nothing was changed.'; -/** Writes the dry-run comment of a PR that would join `umbrella`. */ +/** + * 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. */ +/** + * 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 @@ -119,7 +133,13 @@ export function formatDryRunRefusalComment( 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 `accepted` is removed. */ +/** + * 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 @@ -128,14 +148,26 @@ export function formatRefusalComment( 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: the umbrella the PR is now part of. */ +/** + * Writes the comment of a join: the umbrella the PR is now part of. + * + * @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. */ +/** + * 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.target)} couldn't be opened: ${reason} A maintainer needs to open it from ${branch}.`; + 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/modes.test.ts b/scripts/lib/release-queue/modes.test.ts index 5a73660db2249..f2d222db984af 100644 --- a/scripts/lib/release-queue/modes.test.ts +++ b/scripts/lib/release-queue/modes.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { isLiveFor, needsBotToken, parseMode } from './modes.ts'; +import { isDryRun, isLiveFor, parseMode } from './modes.ts'; describe('parseMode', () => { it('defaults to a dry run', () => { @@ -8,31 +8,27 @@ describe('parseMode', () => { }); it('reads a known mode, and refuses an unknown one', () => { - assert.equal(parseMode('main-only'), 'main-only'); + assert.equal(parseMode('stable-only'), 'stable-only'); assert.throws(() => parseMode('main'), /Unknown MODE: main\. Expected one of: dry-run/); }); }); describe('isLiveFor', () => { - it('acts on main only in main-only mode', () => { - assert.equal(isLiveFor('main-only', 'main'), true); - assert.equal(isLiveFor('main-only', 'cloudflare-next'), false); + it('acts on the stable branch only in stable-only mode', () => { + assert.equal(isLiveFor('stable-only', 'main'), true); + assert.equal(isLiveFor('stable-only', 'cloudflare-next'), false); }); - it('acts on every target in all mode, and none in a dry run', () => { + it('acts on every umbrella base in all mode, and none in a dry run', () => { assert.equal(isLiveFor('all', 'cloudflare-next'), true); assert.equal(isLiveFor('dry-run', 'main'), false); }); }); -describe('needsBotToken', () => { - it('is required to write in a live mode', () => { - assert.equal(needsBotToken('main-only', true), true); - assert.equal(needsBotToken('all', true), true); - }); - - it('is not required in a dry run, or when writes are only printed', () => { - assert.equal(needsBotToken('dry-run', true), false); - assert.equal(needsBotToken('all', false), 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 index 825ccce882416..70d9846c82675 100644 --- a/scripts/lib/release-queue/modes.ts +++ b/scripts/lib/release-queue/modes.ts @@ -1,48 +1,49 @@ -import { MAIN_BRANCH } from './config.ts'; -import type { Repository } from './repository.ts'; +import { STABLE_BRANCH } from './config.ts'; -const MODES = { dryRun: 'dry-run', mainOnly: 'main-only', all: 'all' } as const; +const MODES = { dryRun: 'dry-run', stableOnly: 'stable-only', all: 'all' } as const; /** * The modes of the bot, from the most cautious: * - `dry-run`: only comments with the umbrella a PR would join, or why not. - * - `main-only`: acts on PRs shipping to `main`, dry run for the others. + * - `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); -/** What the bot runs with, whether it handles a PR or refreshes umbrellas. */ -export interface QueueOptions { - /** - * The docs repository as `github-actions[bot]`: 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; - /** `withastro/astro`, read for its release PRs. */ - framework: Repository; - mode: Mode; -} - -/** Reads `MODE`, a dry run when it isn't set. */ +/** + * Reads the mode of the bot. + * + * @param {string} [value] - The value of `MODE`. Without one, a dry run. + * @returns {Mode} The mode. + * @throws {Error} When `value` isn't a 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 bot changes PRs shipping to `target`, rather than a dry run. */ -export function isLiveFor(mode: Mode, target: string): boolean { - return mode === MODES.all || (mode === MODES.mainOnly && target === MAIN_BRANCH); +/** + * Whether the bot changes the PRs shipping to `umbrellaBase`, instead of a dry + * run. + * + * @param {Mode} mode - The mode of the bot. + * @param {string} umbrellaBase - The branch the umbrella PR merges into. + * @returns {boolean} `true` when the bot joins, refuses, and refreshes there. + */ +export function isLiveFor(mode: Mode, umbrellaBase: string): boolean { + return mode === MODES.all || (mode === MODES.stableOnly && umbrellaBase === STABLE_BRANCH); } /** - * Whether the bot token is required. Without it, merges would go through - * `GITHUB_TOKEN`, and the umbrella PR would stop getting CI. + * Whether the bot only comments, without changing PRs. + * + * @param {Mode} mode - The mode of the bot. + * @returns {boolean} `true` in a dry run. */ -export function needsBotToken(mode: Mode, shouldWrite: boolean): boolean { - return mode !== MODES.dryRun && shouldWrite; +export function isDryRun(mode: Mode): boolean { + return mode === MODES.dryRun; } diff --git a/scripts/lib/release-queue/queries.test.ts b/scripts/lib/release-queue/queries.test.ts index 01bf30f73ac46..f39460579ff53 100644 --- a/scripts/lib/release-queue/queries.test.ts +++ b/scripts/lib/release-queue/queries.test.ts @@ -50,7 +50,7 @@ describe('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 mainPullRequest = { number: 18405, versions: [] }; + const stablePullRequest = { number: 18405, versions: [] }; const preMode = (mode: string) => ({ content: Buffer.from(JSON.stringify({ mode, tag: 'beta' })).toString('base64'), }); @@ -63,7 +63,7 @@ describe('getPrereleasePullRequest', () => { }, }); - const pr = await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), mainPullRequest); + const pr = await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), stablePullRequest); assert.deepEqual(pr, { number: 18269, @@ -77,7 +77,7 @@ describe('getPrereleasePullRequest', () => { }); assert.equal( - await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), mainPullRequest), + await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), stablePullRequest), undefined ); }); @@ -88,7 +88,7 @@ describe('getPrereleasePullRequest', () => { await assert.rejects( getPrereleasePullRequest( new Repository(new GitHubClient('token'), FRAMEWORK_REPO), - mainPullRequest + stablePullRequest ), { name: 'GitHubError', @@ -103,7 +103,7 @@ describe('getPrereleasePullRequest', () => { }); await assert.rejects( - getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), mainPullRequest), + getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), stablePullRequest), (error: unknown) => { assert.ok(error instanceof Error); assert.match(error.message, /`\.changeset\/pre\.json` of `next` isn't valid JSON/); @@ -120,7 +120,7 @@ describe('getPrereleasePullRequest', () => { }); await assert.rejects( - getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), mainPullRequest), + getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), stablePullRequest), /`\.changeset\/pre\.json` of `next` has no `mode`/ ); }); @@ -131,8 +131,8 @@ describe('getPrereleasePullRequest', () => { const github = stubGitHub(t, { getResponses }); assert.equal( - await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), mainPullRequest), - mainPullRequest + await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), stablePullRequest), + stablePullRequest ); assert.equal(github.countReads(nextPullRequests), 0); } diff --git a/scripts/lib/release-queue/queries.ts b/scripts/lib/release-queue/queries.ts index da2afe2f2e907..34955089e1794 100644 --- a/scripts/lib/release-queue/queries.ts +++ b/scripts/lib/release-queue/queries.ts @@ -1,8 +1,9 @@ -import { FRAMEWORK_PRERELEASE_BRANCH, MAIN_BRANCH } from './config.ts'; +import { FRAMEWORK_PRERELEASE_BRANCH, STABLE_BRANCH } from './config.ts'; import { GitHubError } from './github-error.ts'; import type { CheckRunResponse, CommitStatusResponse, + CompareResponse, PullRequestResponse, ReviewResponse, RuleResponse, @@ -34,7 +35,14 @@ export interface PullRequest extends DocsPullRequest { isMergeable: boolean | null; } -/** Gets a docs PR for routing, once GitHub knows if it's mergeable. */ +/** + * 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 { @@ -62,7 +70,14 @@ function getPullRequestWithMergeability(repo: Repository, prNumber: number) { }); } -/** Gets the commit a branch points to, or `undefined` if it doesn't exist. */ +/** + * Gets the commit a branch points to. + * + * @param {Repository} repo - The repository of the branch. + * @param {string} branch - The branch name. + * @returns {Promise} The commit SHA, or `undefined` when + * the branch doesn't exist. + */ export async function getBranchSha(repo: Repository, branch: string): Promise { try { const { object } = await repo.get<{ object: { sha: string } }>(`git/ref/heads/${branch}`); @@ -73,32 +88,63 @@ export async function getBranchSha(repo: Repository, branch: string): Promise} The number of commits of `head` missing in `base`. + */ +export async function countCommitsAhead( + repo: Repository, + base: string, + head: string +): Promise { + const { ahead_by } = await compareBranches(repo, base, head); return ahead_by; } -/** Lists the commits of `head` that aren't in `base`, oldest first. */ -export async function listCommitsAhead( +/** + * Lists the titles of the commits of a branch that aren't in another. + * + * @param {Repository} repo - The repository of both branches. + * @param {string} base - The branch to compare with. + * @param {string} head - The branch whose commits are listed. + * @returns {Promise} The first line of the message of each commit + * of `head` missing in `base`, oldest first. + */ +export async function listCommitTitlesAhead( repo: Repository, base: string, head: string ): Promise { - const { commits } = await compare(repo, base, head); - return commits.map(({ sha }) => sha); + const { commits } = await compareBranches(repo, base, head); + return commits.map(({ commit }) => commit.message.split('\n', 1)[0] ?? ''); } -function compare(repo: Repository, base: string, head: string) { - return repo.get<{ ahead_by: number; commits: { sha: string }[] }>(`compare/${base}...${head}`); +function compareBranches(repo: Repository, base: string, head: string) { + return repo.get(`compare/${base}...${head}`); } -/** Comments on a PR. */ +/** + * Comments on a PR. + * + * @param {Repository} repo - The repository of the PR. + * @param {number} prNumber - The number of the PR. + * @param {string} body - The comment, in Markdown. + */ export async function postComment(repo: Repository, prNumber: number, body: string) { await repo.write('POST', `issues/${prNumber}/comments`, { body }); } -/** Removes a label from a PR. A label that is already gone is fine. */ +/** + * Removes a label from a PR. A label that is already gone is fine. + * + * @param {Repository} repo - The repository of the PR. + * @param {number} prNumber - The number of the PR. + * @param {string} label - The label to remove. + */ export async function removeLabel(repo: Repository, prNumber: number, label: string) { try { await repo.write('DELETE', `issues/${prNumber}/labels/${label}`); @@ -107,7 +153,14 @@ export async function removeLabel(repo: Repository, prNumber: number, label: str } } -/** Whether `user` can write to the repository, from their permission on it. */ +/** + * Whether a user can write to the repository, from their permission on it. + * + * @param {Repository} repo - 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. + */ export async function canWrite(repo: Repository, user: string): Promise { try { const { permission } = await repo.get<{ permission: string }>( @@ -123,9 +176,15 @@ export async function canWrite(repo: Repository, user: string): Promise } /** - * Gets what decides whether a PR is ready to merge, with the rules of the - * `main` ruleset: only reviewers with write access count, and only the checks - * required on `main`. + * Gets what decides whether a docs PR is ready to merge, with the rules of + * the ruleset of the stable branch. + * + * Only reviewers with write access count, and only the checks the ruleset + * requires. + * + * @param {Repository} repo - The docs repository. + * @param {PullRequest} pr - The docs PR. + * @returns {Promise} Its reviews, checks, and mergeability. */ export async function getReadiness(repo: Repository, pr: PullRequest): Promise { const [reviews, checkRuns, statuses, required] = await Promise.all([ @@ -162,11 +221,11 @@ async function getWriters(repo: Repository, users: string[]): Promise { - const rules = await repo.getAll(`rules/branches/${MAIN_BRANCH}`); + const rules = await repo.getAll(`rules/branches/${STABLE_BRANCH}`); return rules .filter((rule) => rule.type === 'required_status_checks') .flatMap((rule) => rule.parameters?.required_status_checks ?? []) @@ -174,8 +233,13 @@ async function getRequiredChecks(repo: Repository): Promise { } /** - * Gets the open `[ci] release` PR of a branch of `withastro/astro`: `main`, or - * `next` for its alpha and beta releases. + * Gets the open release PR of a branch of the framework repository. + * + * @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. */ export async function getReleasePullRequest( framework: Repository, @@ -188,13 +252,14 @@ export async function getReleasePullRequest( return { number: pr.number, versions: parseReleaseVersions(pr.body ?? '') }; } -/** Whether `next` in `withastro/astro` is in changesets prerelease mode. */ -async function isNextInPreMode(framework: Repository): Promise { +/** Whether the prerelease branch of the framework is in prerelease mode. */ +async function isPrereleaseBranchInPreMode(framework: Repository): Promise { try { const { content } = await framework.get<{ content: string }>( `contents/${PRE_STATE_PATH}?ref=${FRAMEWORK_PRERELEASE_BRANCH}` ); - // `exit` means the prerelease ends: `next` is then merged into `main`. + // `exit` means the prerelease ends: the branch is then merged into the + // stable branch. return readPreMode(Buffer.from(content, 'base64').toString()) === 'pre'; } catch (error) { if (error instanceof GitHubError && error.status === 404) return false; @@ -202,7 +267,7 @@ async function isNextInPreMode(framework: Repository): Promise { } } -/** Reads the `mode` of the `.changeset/pre.json` of `next`. */ +/** 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); @@ -215,24 +280,36 @@ function isPreState(value: unknown): value is { mode: string } { } /** - * Gets the release PR a prerelease umbrella ships with: the alpha or beta - * release PR of `next` while it's in prerelease mode, then the release PR of - * `main`, which `next` was merged into. + * Gets the release PR a prerelease umbrella ships with. + * + * @param {Repository} framework - The framework repository. + * @param {ReleasePullRequest} [stablePullRequest] - The open release PR of the + * stable branch. + * @returns {Promise} The alpha or beta release + * PR while the prerelease branch is in prerelease mode. Then the release PR + * of the stable branch, which the prerelease branch was merged into. */ export async function getPrereleasePullRequest( framework: Repository, - mainPullRequest: ReleasePullRequest | undefined + stablePullRequest: ReleasePullRequest | undefined ): Promise { - return (await isNextInPreMode(framework)) + return (await isPrereleaseBranchInPreMode(framework)) ? getReleasePullRequest(framework, FRAMEWORK_PRERELEASE_BRANCH) - : mainPullRequest; + : stablePullRequest; } /** - * Whether a PR of `withastro/astro`, such as a release PR, is merged. A PR - * that doesn't exist, for example from an edited marker, isn't. + * Whether a PR of the framework repository, such as a release PR, is merged. + * + * @param {Repository} framework - The framework repository. + * @param {number} prNumber - The number of the PR. + * @returns {Promise} `false` for a PR that doesn't exist, for example + * from an edited marker. */ -export async function isFrameworkPullRequestMerged(framework: Repository, prNumber: number) { +export async function isFrameworkPullRequestMerged( + framework: Repository, + prNumber: number +): Promise { try { const { merged } = await framework.get(`pulls/${prNumber}`); return merged === true; diff --git a/scripts/lib/release-queue/readiness.ts b/scripts/lib/release-queue/readiness.ts index 43e847ddc791a..ead3b9fba309c 100644 --- a/scripts/lib/release-queue/readiness.ts +++ b/scripts/lib/release-queue/readiness.ts @@ -25,11 +25,19 @@ export interface Readiness { isMergeable: boolean | null; } +type ClassifiedReviewers = Pick; +type ClassifiedChecks = Pick; + /** - * Gets the latest review state of each reviewer, from `reviews` in the - * chronological order of the GitHub API. Comments don't replace an earlier - * state. A dismissed review clears it: an earlier review from the same - * reviewer doesn't count again. + * Gets the latest review state of each reviewer. + * + * Comments don't replace an earlier state. A dismissed review clears it: an + * earlier review from the same reviewer doesn't count again. + * + * @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(); @@ -41,13 +49,18 @@ export function getLatestReviewStates(reviews: Review[]): 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 -): Pick { +): ClassifiedReviewers { const reviewersWith = (state: ReviewState) => [...states] .filter(([user, reviewState]) => reviewState === state && writers.has(user)) @@ -62,14 +75,20 @@ const SUCCESSFUL_CONCLUSIONS = new Set(['success', 'neutral', ' /** * 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 {CheckRunResponse[]} checkRuns - The check runs of the commit. + * @param {CommitStatusResponse[]} statuses - The commit statuses. + * @returns {ClassifiedChecks} The required checks in each state. */ export function classifyChecks( required: string[], checkRuns: CheckRunResponse[], statuses: CommitStatusResponse[] -): Pick { +): ClassifiedChecks { const failing = new Set(); const pending = new Set(); const reported = new Set(); diff --git a/scripts/lib/release-queue/refresh.test.ts b/scripts/lib/release-queue/refresh.test.ts index e411eb6611da0..c86ae2282a2a9 100644 --- a/scripts/lib/release-queue/refresh.test.ts +++ b/scripts/lib/release-queue/refresh.test.ts @@ -2,47 +2,43 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; import { FRAMEWORK_REPO } from './config.ts'; import { refreshOpenUmbrellas, refreshUmbrella } from './refresh.ts'; -import { getMainRelease } from './release.ts'; +import { getStableRelease } from './release.ts'; import { pullRequestResponse, REPO, umbrellaResponse } from './testing/fixtures.ts'; import { ACTIONS_TOKEN, BOT_TOKEN, queueOptions } from './testing/queue-options.ts'; import { stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; -import type { OpenUmbrella } from './umbrella-queries.ts'; +import type { UmbrellaPullRequest } from './umbrella-queries.ts'; import { formatUmbrellaBody } from './umbrella-body.ts'; /** The next release from `main`, with release PR `number` open. */ -const releaseWith = (number: number, astroVersion: string) => - getMainRelease({ number, versions: [{ name: 'astro', version: astroVersion }] }); +const releaseWith = (number: number, frameworkVersion: string) => + getStableRelease({ number, versions: [{ name: 'astro', version: frameworkVersion }] }); -const noRelease = getMainRelease(); +const noRelease = getStableRelease(); const included = [{ number: 7, title: 'Document the new option' }]; /** The `main` umbrella, up to date with release PR #18405 (`astro@7.5.0`). */ -const umbrella: OpenUmbrella = { +const umbrella: UmbrellaPullRequest = { number: 42, title: '[ci] release astro@7.5.0 docs', - body: formatUmbrellaBody('main', { releasePullRequest: 18405, included: [] }), + body: formatUmbrellaBody('main', { releasePrNumber: 18405, included: [] }), labels: ['merge-on-release', 'minor-release', 'i18n'], - target: 'main', + base: 'main', branch: 'release-queue/main', }; -/** #7 is on the umbrella branch. #3 had the same base in an earlier release. */ -const history = (target = 'main') => ({ - [`repos/${REPO}/pulls?state=closed&base=release-queue/${target}`]: [ - pullRequestResponse({ number: 3, title: 'Earlier release', merge_commit_sha: 'old-sha' }), - pullRequestResponse({ number: 7, title: 'Document the new option', merge_commit_sha: 'sha-7' }), - ], - [`repos/${REPO}/compare/${target}...release-queue/${target}`]: { +/** The squash commit of #7 is on the umbrella branch. */ +const history = (umbrellaBase = 'main') => ({ + [`repos/${REPO}/compare/${umbrellaBase}...release-queue/${umbrellaBase}`]: { ahead_by: 1, - commits: [{ sha: 'sha-7' }], + commits: [{ commit: { message: 'Document the new option (#7)\n\nDetails.' } }], }, }); /** No merge-block review on umbrella `number`, and no PR on its branch. */ -const reviews = (number: number, target = 'main') => ({ +const reviews = (number: number, umbrellaBase = 'main') => ({ [`repos/${REPO}/pulls/${number}/reviews`]: [], - [`repos/${REPO}/pulls?state=open&base=release-queue/${target}`]: [], + [`repos/${REPO}/pulls?state=open&base=release-queue/${umbrellaBase}`]: [], }); /** `next` in prerelease mode, with its release PR `number` open. */ @@ -76,8 +72,8 @@ const patchOf = (writes: Write[]) => | undefined; const labelWrites = (writes: Write[]) => writes.filter(({ method }) => method !== 'PATCH'); -const umbrellaBody = (releasePullRequest?: number) => - formatUmbrellaBody('main', { releasePullRequest, included }); +const umbrellaBody = (releasePrNumber?: number) => + formatUmbrellaBody('main', { releasePrNumber, included }); describe('refreshUmbrella', () => { describe('with a release PR open', () => { @@ -237,12 +233,12 @@ describe('refreshUmbrella', () => { const github = stubGitHub(t, { getResponses: { ...history('cloudflare-next'), ...prereleaseOf(18269) }, }); - const prerelease: OpenUmbrella = { + const prerelease: UmbrellaPullRequest = { number: 43, title: 'Old title', body: '', labels: [], - target: 'cloudflare-next', + base: 'cloudflare-next', branch: 'release-queue/cloudflare-next', }; @@ -250,7 +246,7 @@ describe('refreshUmbrella', () => { assert.deepEqual(patchOf(github.writes), { title: '[ci] release cloudflare-next docs', - body: formatUmbrellaBody('cloudflare-next', { releasePullRequest: 18269, included }), + body: formatUmbrellaBody('cloudflare-next', { releasePrNumber: 18269, included }), }); assert.deepEqual(labelWrites(github.writes), [ { @@ -279,7 +275,7 @@ describe('refreshOpenUmbrellas', () => { const openPullRequests = { [`repos/${REPO}/pulls?state=open`]: [ umbrellaResponse(), - umbrellaResponse({ number: 43, target: 'cloudflare-next' }), + umbrellaResponse({ number: 43, umbrellaBase: 'cloudflare-next' }), umbrellaResponse({ number: 44, head: { ref: 'release-queue/main', sha: 'fork-sha', repo: { full_name: 'someone/docs' } }, @@ -302,7 +298,7 @@ describe('refreshOpenUmbrellas', () => { ); it('reads a description edited in the browser as up to date', async (t) => { - const body = formatUmbrellaBody('main', { releasePullRequest: 1, included }); + const body = formatUmbrellaBody('main', { releasePrNumber: 1, included }); const github = stubGitHub(t, { getResponses: { ...openPullRequests, @@ -317,7 +313,7 @@ describe('refreshOpenUmbrellas', () => { }, }); - await refreshOpenUmbrellas(queueOptions(github, 'main-only')); + await refreshOpenUmbrellas(queueOptions(github, 'stable-only')); assert.deepEqual(github.writes, []); }); @@ -330,10 +326,10 @@ describe('refreshOpenUmbrellas', () => { assert.deepEqual(github.writes, []); }); - it('refreshes only the main umbrella in main-only mode', async (t) => { + it('refreshes only the main umbrella in stable-only mode', async (t) => { const github = stubGitHub(t, { getResponses: openPullRequests }); - await refreshOpenUmbrellas(queueOptions(github, 'main-only')); + await refreshOpenUmbrellas(queueOptions(github, 'stable-only')); assert.deepEqual(refreshedUmbrellas(github.writesBy(BOT_TOKEN)), [42]); // Without a join, no merge-block review is requested. @@ -357,7 +353,7 @@ describe('refreshOpenUmbrellas', () => { }, }); - await refreshOpenUmbrellas(queueOptions(github, 'main-only')); + await refreshOpenUmbrellas(queueOptions(github, 'stable-only')); assert.deepEqual( github.writesBy(ACTIONS_TOKEN).map(({ method, path }) => `${method} ${path}`), @@ -372,4 +368,19 @@ describe('refreshOpenUmbrellas', () => { 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(queueOptions(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.ts b/scripts/lib/release-queue/refresh.ts index aecf6ebde7601..d76328eca2153 100644 --- a/scripts/lib/release-queue/refresh.ts +++ b/scripts/lib/release-queue/refresh.ts @@ -1,12 +1,17 @@ import { syncMergeBlockReview } from './merge-block.ts'; -import { isLiveFor, type QueueOptions } from './modes.ts'; -import { getPrereleasePullRequest, getReleasePullRequest, removeLabel } from './queries.ts'; -import { getMainRelease, type MainRelease, type ReleasePullRequest } from './release.ts'; -import { FRAMEWORK_MAIN_BRANCH, MAIN_BRANCH } from './config.ts'; -import { getUmbrellaHistory, listOpenUmbrellas, type OpenUmbrella } from './umbrella-queries.ts'; -import type { Repositories, Repository } from './repository.ts'; -import { getReleaseState } from './umbrella-state.ts'; -import { formatUmbrellaBody, selectIncludedPullRequests } from './umbrella-body.ts'; +import { isLiveFor } from './modes.ts'; +import { + getPrereleasePullRequest, + getReleasePullRequest, + listCommitTitlesAhead, + removeLabel, +} from './queries.ts'; +import { getStableRelease, RELEASE_TYPES, type StableRelease } from './release.ts'; +import { FRAMEWORK_STABLE_BRANCH, STABLE_BRANCH } from './config.ts'; +import { listOpenUmbrellas, type UmbrellaPullRequest } from './umbrella-queries.ts'; +import type { QueueOptions, Repositories, Repository } from './repository.ts'; +import { getReleaseState, type ReleaseState } from './umbrella-state.ts'; +import { formatUmbrellaBody, parseIncludedPullRequests } from './umbrella-body.ts'; import { getUmbrellaHeading } from './umbrella-heading.ts'; import { applyToLabels, @@ -16,62 +21,90 @@ import { } from './umbrella-labels.ts'; /** - * Brings an umbrella PR up to date, writing only what changed. Its body - * always lists the included docs PRs. The `main` umbrella takes its title, - * release label and link from the open release PR of `main`, unless it is - * `kept` or `settled`. A prerelease umbrella takes its title and label from - * its branch, and links the release PR of its prerelease. Leaves the `main` - * umbrella as it is before a major: a maintainer moves it to the prerelease - * branch. Returns the umbrella as it is after the refresh. + * Brings an umbrella PR up to date with its release and its included PRs, + * writing only what changed. + * + * @param {Repositories} repositories - The docs repository to write, and the + * framework repository. + * @param {UmbrellaPullRequest} umbrella - The umbrella PR to refresh. + * @param {StableRelease} release - The next release from the stable branch. + * @returns {Promise} The umbrella PR after the refresh. */ export async function refreshUmbrella( { docs, framework }: Repositories, - umbrella: OpenUmbrella, - release: MainRelease -): Promise { + umbrella: UmbrellaPullRequest, + release: StableRelease +): Promise { const state = await getReleaseState(framework, umbrella, release); - const isFollowing = state.status === 'follows'; - if (isFollowing && isBeforeMajor(umbrella, release)) { - console.info( - `Skipped #${umbrella.number}: the next release from ${FRAMEWORK_MAIN_BRANCH} is a major.` - ); + if (state.status === 'follows' && isBeforeMajor(umbrella, release)) { + console.info(`Skipped #${umbrella.number}: the next stable release is a major.`); return umbrella; } - const heading = getUmbrellaHeading(umbrella.target, release); - const linked = isFollowing - ? (await getLinkedReleasePullRequest(framework, umbrella, release))?.number - : state.releasePullRequest; - const { commits, closed } = await getUmbrellaHistory(docs, umbrella); - const textChanges = getTextChanges(umbrella, { - title: isFollowing ? heading.title : umbrella.title, - body: formatUmbrellaBody(umbrella.target, { - releasePullRequest: linked, - included: selectIncludedPullRequests(closed, commits), - }), + const heading = getUmbrellaHeading(umbrella.base, release); + const linkedReleasePrNumber = await getLinkedReleasePrNumber(framework, umbrella, { + state, + release, + }); + const wanted = await getWantedText(docs, umbrella, { + title: state.status === 'follows' ? heading.title : umbrella.title, + linkedReleasePrNumber, }); + const textChanges = getTextChanges(umbrella, wanted); if (Object.keys(textChanges).length > 0) { await docs.write('PATCH', `pulls/${umbrella.number}`, textChanges); } - const labelChanges = isFollowing - ? getLabelChanges(umbrella.labels, heading.releaseLabel) - : getKeptLabelChanges(umbrella.labels); + const labelChanges = + state.status === 'follows' + ? getLabelChanges(umbrella.labels, heading.releaseLabel) + : getKeptLabelChanges(umbrella.labels); await applyLabelChanges(docs, umbrella.number, labelChanges); return { ...umbrella, ...textChanges, labels: applyToLabels(umbrella.labels, labelChanges) }; } /** - * Whether the `main` umbrella waits for a major: the bot leaves it to a - * maintainer. + * 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: OpenUmbrella, release: MainRelease): boolean { - return umbrella.target === MAIN_BRANCH && release.type === 'major'; +function isBeforeMajor(umbrella: UmbrellaPullRequest, release: StableRelease): boolean { + return umbrella.base === STABLE_BRANCH && release.type === RELEASE_TYPES.major; +} + +/** + * Gets the release PR to link in an umbrella: the open one when it follows + * its release, or the one it already links. + */ +async function getLinkedReleasePrNumber( + framework: Repository, + umbrella: UmbrellaPullRequest, + { state, release }: { state: ReleaseState; release: StableRelease } +): Promise { + if (state.status !== 'follows') return state.releasePrNumber; + const releasePullRequest = + umbrella.base === STABLE_BRANCH + ? release.pullRequest + : await getPrereleasePullRequest(framework, release.pullRequest); + return releasePullRequest?.number; +} + +/** Gets the title and body the umbrella PR should have. */ +async function getWantedText( + docs: Repository, + umbrella: UmbrellaPullRequest, + { title, linkedReleasePrNumber }: { title: string; linkedReleasePrNumber: number | undefined } +): Promise<{ title: string; body: string }> { + const commitTitles = await listCommitTitlesAhead(docs, umbrella.base, umbrella.branch); + const body = formatUmbrellaBody(umbrella.base, { + releasePrNumber: linkedReleasePrNumber, + included: parseIncludedPullRequests(commitTitles), + }); + return { title, body }; } /** Gets the title and body of `wanted` that differ from the umbrella's. */ function getTextChanges( - umbrella: OpenUmbrella, + umbrella: UmbrellaPullRequest, wanted: { title: string; body: string } ): { title?: string; body?: string } { return { @@ -88,35 +121,59 @@ async function applyLabelChanges(docs: Repository, umbrellaNumber: number, chang } /** - * Gets the release PR an umbrella that follows its release links: the one of - * `main`, or the one of the prerelease the umbrella ships with. + * Refreshes every open umbrella PR the mode acts on, for example after a + * change to a release PR. + * + * @param {QueueOptions} options - The repositories and the mode of the run. + * @throws {AggregateError} Once every umbrella was tried, when some of them + * couldn't be refreshed. */ -async function getLinkedReleasePullRequest( - framework: Repository, - umbrella: OpenUmbrella, - release: MainRelease -): Promise { - if (umbrella.target === MAIN_BRANCH) return release.pullRequest; - return getPrereleasePullRequest(framework, release.pullRequest); -} - -/** Refreshes every open umbrella PR, for example after a release PR change. */ -export async function refreshOpenUmbrellas({ actions, bot, framework, mode }: QueueOptions) { +export async function refreshOpenUmbrellas(options: QueueOptions) { + const { actions, framework, mode } = options; const [umbrellas, releasePullRequest] = await Promise.all([ listOpenUmbrellas(actions), - getReleasePullRequest(framework, FRAMEWORK_MAIN_BRANCH), + getReleasePullRequest(framework, FRAMEWORK_STABLE_BRANCH), ]); - const release = getMainRelease(releasePullRequest); + const release = getStableRelease(releasePullRequest); const numbers = umbrellas.map(({ number }) => `#${number}`).join(', '); console.info(`Open umbrellas: ${numbers || 'none'}.`); + const liveUmbrellas = umbrellas.filter((umbrella) => isLiveFor(mode, umbrella.base)); for (const umbrella of umbrellas) { - if (!isLiveFor(mode, umbrella.target)) { - console.info(`Skipped #${umbrella.number}: dry run for ${umbrella.target}.`); - continue; + if (!liveUmbrellas.includes(umbrella)) { + console.info(`Skipped #${umbrella.number}: dry run for ${umbrella.base}.`); } + } + 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 refreshAndSyncReview(options, umbrella, release); + 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 and its merge-block review. Catches a failure, so + * the other umbrellas still get refreshed. + */ +async function refreshAndSyncReview( + { actions, bot, framework }: QueueOptions, + umbrella: UmbrellaPullRequest, + release: StableRelease +): Promise { + try { const refreshed = await refreshUmbrella({ docs: bot, framework }, umbrella, release); // Without a join, a dismissed review still covers the included PRs. await syncMergeBlockReview(actions, refreshed, { shouldBlock: false }); + return undefined; + } catch (error) { + return new Error(`Couldn't refresh #${umbrella.number}.`, { cause: error }); } } diff --git a/scripts/lib/release-queue/release.test.ts b/scripts/lib/release-queue/release.test.ts index b4218417ac922..cee50bf29ef8a 100644 --- a/scripts/lib/release-queue/release.test.ts +++ b/scripts/lib/release-queue/release.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { getMainRelease, parseReleaseVersions } from './release.ts'; +import { getStableRelease, parseReleaseVersions } from './release.ts'; const body = `This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. @@ -32,15 +32,15 @@ describe('parseReleaseVersions', () => { }); }); -describe('getMainRelease', () => { +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 = getMainRelease(pullRequest(parseReleaseVersions(body))); - assert.equal(release.astroVersion, '7.4.1'); + const release = getStableRelease(pullRequest(parseReleaseVersions(body))); + assert.equal(release.frameworkVersion, '7.4.1'); }); for (const [version, type] of [ @@ -51,31 +51,34 @@ describe('getMainRelease', () => { ['7.5.0-alpha.0', 'patch'], ] as const) { it(`reads astro@${version} as a ${type} release`, () => { - assert.equal(getMainRelease(pullRequest([{ name: 'astro', version }])).type, type); + assert.equal(getStableRelease(pullRequest([{ name: 'astro', version }])).type, type); }); } it('reads a patch when there is no release PR', () => { - assert.deepEqual(getMainRelease(), { + assert.deepEqual(getStableRelease(), { type: 'patch', - astroVersion: undefined, + frameworkVersion: undefined, pullRequest: undefined, }); }); it('reads a patch when astro is not released', () => { - const release = getMainRelease(pullRequest([{ name: '@astrojs/vercel', version: '11.1.0' }])); + const release = getStableRelease(pullRequest([{ name: '@astrojs/vercel', version: '11.1.0' }])); assert.equal(release.type, 'patch'); - assert.equal(release.astroVersion, undefined); + 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(getMainRelease(minor), { + assert.deepEqual(getStableRelease(minor), { type: 'minor', - astroVersion: '7.5.0', + frameworkVersion: '7.5.0', pullRequest: minor, }); - assert.equal(getMainRelease(pullRequest([{ name: 'astro', version: '8.0.0' }])).type, 'major'); + assert.equal( + getStableRelease(pullRequest([{ name: 'astro', version: '8.0.0' }])).type, + 'major' + ); }); }); diff --git a/scripts/lib/release-queue/release.ts b/scripts/lib/release-queue/release.ts index e798719f5f54e..245b3435c2320 100644 --- a/scripts/lib/release-queue/release.ts +++ b/scripts/lib/release-queue/release.ts @@ -1,35 +1,51 @@ -import { FRAMEWORK_PACKAGE } from './config.ts'; +import { FRAMEWORK_PACKAGE, RELEASE_LABELS } 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+)$/; + +export type ReleaseType = keyof typeof RELEASE_LABELS; + +export const RELEASE_TYPES = { + patch: 'patch', + minor: 'minor', + major: 'major', +} as const satisfies { [Type in ReleaseType]: Type }; export interface PackageVersion { name: string; version: string; } -export type ReleaseType = 'patch' | 'minor' | 'major'; - -/** A `[ci] release` PR of `withastro/astro`. */ +/** A release PR of the framework repository. */ export interface ReleasePullRequest { number: number; versions: PackageVersion[]; } -/** The next release from the `main` branch of `withastro/astro`. */ -export interface MainRelease { +/** The next release from the stable branch of the framework repository. */ +export interface StableRelease { type: ReleaseType; - astroVersion?: string | undefined; + /** The version of the framework package, if the release includes it. */ + frameworkVersion?: string | undefined; /** The open release PR. Without one, the release is a patch. */ pullRequest?: ReleasePullRequest | undefined; } -/** Reads the `## @` headings of a release PR body. */ +/** + * 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(/^## (\S+)@(\d+\.\d+\.\d+\S*)\s*$/gm), ([, name, version]) => ({ + return Array.from(body.matchAll(RELEASE_HEADING), ([, name, version]) => ({ name, version, })); } -/** Finds the version of the package `name` among the released `versions`. */ function findVersion(versions: PackageVersion[], name: string): string | undefined { return versions.find((pkg) => pkg.name === name)?.version; } @@ -40,15 +56,21 @@ function findVersion(versions: PackageVersion[], name: string): string | undefin * PRs are routed. */ function getReleaseType(version: string | undefined): ReleaseType { - const match = version?.match(/^\d+\.(\d+)\.(\d+)$/); - if (!match) return 'patch'; + const match = version?.match(STABLE_VERSION); + if (!match) return RELEASE_TYPES.patch; const [, minor, patch] = match; - if (patch !== '0') return 'patch'; - return minor === '0' ? 'major' : 'minor'; + if (patch !== '0') return RELEASE_TYPES.patch; + return minor === '0' ? RELEASE_TYPES.major : RELEASE_TYPES.minor; } -/** Gets the next release from `main` from its open release PR, if any. */ -export function getMainRelease(pullRequest?: ReleasePullRequest): MainRelease { - const astroVersion = findVersion(pullRequest?.versions ?? [], FRAMEWORK_PACKAGE); - return { type: getReleaseType(astroVersion), astroVersion, pullRequest }; +/** + * 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_PACKAGE); + return { type: getReleaseType(frameworkVersion), frameworkVersion, pullRequest }; } diff --git a/scripts/lib/release-queue/repository.ts b/scripts/lib/release-queue/repository.ts index e1c3309f6f1f5..26b1fcf2f2f3e 100644 --- a/scripts/lib/release-queue/repository.ts +++ b/scripts/lib/release-queue/repository.ts @@ -1,11 +1,26 @@ import type { GitHubClient, WriteMethod } from './github.ts'; +import type { Mode } from './modes.ts'; -/** The docs repository, and `withastro/astro` for its release PRs. */ +/** The docs repository, and the framework repository for its release PRs. */ export interface Repositories { docs: Repository; framework: Repository; } +/** What the bot runs with, whether it handles a PR or refreshes umbrellas. */ +export interface QueueOptions { + /** + * 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; +} + /** * A GitHub repository, read and changed through a client. Its methods are * those of `GitHubClient`, with paths relative to the repository. @@ -15,7 +30,12 @@ export class Repository { readonly owner: string; #client: GitHubClient; - /** Binds `client` to the repository `name`, such as `withastro/docs`. */ + /** + * 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; diff --git a/scripts/lib/release-queue/routing.test.ts b/scripts/lib/release-queue/routing.test.ts index f2cc7af8175e4..964c1578bfba8 100644 --- a/scripts/lib/release-queue/routing.test.ts +++ b/scripts/lib/release-queue/routing.test.ts @@ -3,11 +3,11 @@ import { describe, it } from 'node:test'; import type { Readiness } from './readiness.ts'; import { decideJoin, - getTarget, + getUmbrellaBase, type DecisionContext, type Decision, type DocsPullRequest, - isRetargetedFromMain, + wouldRetargetFromStable, } from './routing.ts'; const pr = (overrides: Partial = {}): DocsPullRequest => ({ @@ -28,12 +28,12 @@ const ready: Readiness = { isMergeable: true, }; -const patch: DecisionContext = { release: { type: 'patch', astroVersion: '7.4.1' } }; -const major: DecisionContext = { release: { type: 'major', astroVersion: '8.0.0' } }; +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, - settledMainUmbrella: { number: 42, releasePullRequest: 18405 }, + settledStableUmbrella: { number: 42, releasePrNumber: 18405 }, }; const refusalsOf = (decision: Decision) => (decision.status === 'refuse' ? decision.refusals : []); @@ -45,7 +45,7 @@ describe('decideJoin', () => { assert.deepEqual(decideJoin(pr({ labels: [label] }), ready, patch), { status: 'join', route: { - target: 'main', + umbrellaBase: 'main', umbrellaBranch: 'release-queue/main', label, shouldRetarget: true, @@ -63,7 +63,7 @@ describe('decideJoin', () => { assert.deepEqual(decision, { status: 'join', route: { - target: 'cloudflare-next', + umbrellaBase: 'cloudflare-next', umbrellaBranch: 'release-queue/cloudflare-next', label: 'major-release', shouldRetarget: true, @@ -75,7 +75,7 @@ describe('decideJoin', () => { assert.deepEqual(decideJoin(pr({ base: 'release-queue/main' }), ready, patch), { status: 'join', route: { - target: 'main', + umbrellaBase: 'main', umbrellaBranch: 'release-queue/main', label: 'patch-release', shouldRetarget: false, @@ -140,7 +140,9 @@ describe('decideJoin', () => { it('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-main', astroVersion: '8.0.0' }]); + assert.deepEqual(refusalsOf(decision), [ + { code: 'major-on-stable', frameworkVersion: '8.0.0' }, + ]); }); it('a PR that is not ready, with every reason', () => { @@ -176,7 +178,7 @@ describe('decideJoin', () => { it('a main PR while the main umbrella is settled', () => { assert.deepEqual(refusalsOf(decideJoin(pr(), ready, settled)), [ - { code: 'settled-umbrella', number: 42, releasePullRequest: 18405 }, + { code: 'settled-umbrella', number: 42, releasePrNumber: 18405 }, ]); }); @@ -201,26 +203,26 @@ describe('decideJoin', () => { }); }); -describe('getTarget', () => { +describe('getUmbrellaBase', () => { it('is the base of a PR against a branch', () => { - assert.equal(getTarget('main'), 'main'); - assert.equal(getTarget('cloudflare-next'), 'cloudflare-next'); + 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(getTarget('release-queue/main'), 'main'); - assert.equal(getTarget('release-queue/cloudflare-next'), 'cloudflare-next'); + assert.equal(getUmbrellaBase('release-queue/main'), 'main'); + assert.equal(getUmbrellaBase('release-queue/cloudflare-next'), 'cloudflare-next'); }); }); -describe('isRetargetedFromMain', () => { +describe('wouldRetargetFromStable', () => { it('applies to an open PR against main', () => { - assert.equal(isRetargetedFromMain(pr()), true); + assert.equal(wouldRetargetFromStable(pr()), true); }); it("doesn't apply to a PR against the umbrella branch, a prerelease, or a closed PR", () => { - assert.equal(isRetargetedFromMain(pr({ base: 'release-queue/main' })), false); - assert.equal(isRetargetedFromMain(pr({ base: 'cloudflare-next' })), false); - assert.equal(isRetargetedFromMain(pr({ state: 'closed' })), false); + 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/routing.ts b/scripts/lib/release-queue/routing.ts index a262c98e267af..e548e25665e5b 100644 --- a/scripts/lib/release-queue/routing.ts +++ b/scripts/lib/release-queue/routing.ts @@ -1,13 +1,13 @@ import { ALL_RELEASE_LABELS, - MAIN_BRANCH, + STABLE_BRANCH, RELEASE_LABELS, - UMBRELLA_PREFIX, + UMBRELLA_BRANCH_PREFIX, type ReleaseLabel, } from './config.ts'; import type { PullRequestState } from './github-types.ts'; import type { Readiness } from './readiness.ts'; -import type { MainRelease } from './release.ts'; +import { RELEASE_TYPES, type StableRelease } from './release.ts'; export interface DocsPullRequest { state: PullRequestState; @@ -18,8 +18,7 @@ export interface DocsPullRequest { } export interface Route { - /** The branch the umbrella PR merges into. */ - target: string; + umbrellaBase: string; umbrellaBranch: string; label: ReleaseLabel; /** Whether the base of the docs PR must change to the umbrella branch. */ @@ -27,19 +26,19 @@ export interface Route { } /** - * The open `main` umbrella once the release PR it ships with is merged, until + * The open stable umbrella once the release PR it ships with is merged, until * the umbrella itself is merged. */ export interface SettledUmbrella { number: number; - /** The merged release PR of `withastro/astro`. */ - releasePullRequest: number; + /** The merged release PR of the framework repository. */ + releasePrNumber: number; } /** What `decideJoin` needs besides the PR. */ export interface DecisionContext { - release: MainRelease; - settledMainUmbrella?: SettledUmbrella | undefined; + release: StableRelease; + settledStableUmbrella?: SettledUmbrella | undefined; } /** The details of each refusal, by code. */ @@ -50,7 +49,7 @@ interface RefusalDetails { 'no-release-label': Record; 'several-release-labels': { labels: ReleaseLabel[] }; mismatch: { base: string; label: ReleaseLabel }; - 'major-on-main': { astroVersion?: string | undefined }; + 'major-on-stable': { frameworkVersion?: string | undefined }; 'settled-umbrella': SettledUmbrella; 'not-approved': Record; 'changes-requested': { reviewers: string[] }; @@ -58,7 +57,7 @@ interface RefusalDetails { 'pending-checks': { checks: string[] }; 'missing-checks': { checks: string[] }; conflict: Record; - // Found when joining (see `JoinRefusal`). + // Found when joining: see `JoinRefusal`. 'umbrella-conflict': { branch: string }; /** The umbrella branch has commits but no open umbrella PR. */ 'stale-umbrella-branch': { branch: string }; @@ -85,29 +84,53 @@ export type Decision = export type RefusedDecision = Extract; -/** Whether `branch` is an umbrella branch, by its reserved prefix. */ +/** 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 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_PREFIX); + return branch.startsWith(UMBRELLA_BRANCH_PREFIX); } /** - * Whether the bot would retarget this open PR from `main`: the only PRs a - * settled `main` umbrella refuses. A PR against the umbrella branch says it - * belongs to that umbrella. The handler reads the settled state only for - * these PRs. + * Whether the bot would change the base of an open docs PR from the stable + * branch to the stable umbrella branch. + * + * These are the only PRs a settled stable umbrella refuses: a PR against the + * umbrella branch says it belongs to that umbrella. + * + * @param {DocsPullRequest} pr - The docs PR. + * @returns {boolean} `true` for an open PR against the stable branch. */ -export function isRetargetedFromMain(pr: DocsPullRequest): boolean { - return pr.state === 'open' && pr.base === MAIN_BRANCH; +export function wouldRetargetFromStable(pr: DocsPullRequest): boolean { + return pr.state === 'open' && pr.base === STABLE_BRANCH; } -/** Gets the branch a docs PR ships to: its base, or its umbrella's base. */ -export function getTarget(base: string): string { - return isUmbrellaBranch(base) ? base.slice(UMBRELLA_PREFIX.length) : base; +/** + * 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; } /** - * Decides whether a docs PR joins an umbrella, and which one. Returns every - * refusal at once, except for a closed PR or an umbrella PR. + * 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, checks, and mergeability. + * @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, @@ -117,46 +140,48 @@ export function decideJoin( if (pr.state !== 'open') return { status: 'refuse', refusals: [{ code: 'closed' }] }; if (isUmbrellaBranch(pr.head)) return { status: 'refuse', refusals: [{ code: 'umbrella' }] }; - const refusals: Refusal[] = []; - if (pr.isDraft) refusals.push({ code: 'draft' }); - - const route = getRoute(pr, context); - if ('code' in route) refusals.push(route); + const routing = getRoute(pr, context); + const refusals: Refusal[] = [ + ...(pr.isDraft ? [{ code: 'draft' } as const] : []), + ...(routing.status === 'refused' ? [routing.refusal] : []), + ...getReadinessRefusals(readiness), + ]; - refusals.push(...getReadinessRefusals(readiness)); - - if ('code' in route) return { status: 'refuse', refusals }; - if (refusals.length > 0) return { status: 'refuse', route, refusals }; - return { status: 'join', route }; + 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, settledMainUmbrella }: DecisionContext -): Route | Refusal { + { release, settledStableUmbrella }: DecisionContext +): Routing { const labels = ALL_RELEASE_LABELS.filter((label) => pr.labels.includes(label)); - if (labels.length === 0) return { code: 'no-release-label' }; - if (labels.length > 1) return { code: 'several-release-labels', labels }; + 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 target = getTarget(pr.base); + 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 other than `main` is one. + // Nothing marks a prerelease branch: any base but the stable branch is one. const isMajor = label === RELEASE_LABELS.major; - const isMainTarget = target === MAIN_BRANCH; - if (isMajor === isMainTarget) return { code: 'mismatch', base: pr.base, label }; - // Every feature on `main` in `withastro/astro` now ships in the major, - // documented on a prerelease branch. - if (isMainTarget && release.type === 'major') { - return { code: 'major-on-main', astroVersion: release.astroVersion }; + const isStableTarget = umbrellaBase === 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 (settledMainUmbrella && isRetargetedFromMain(pr)) { - return { code: 'settled-umbrella', ...settledMainUmbrella }; + if (settledStableUmbrella && wouldRetargetFromStable(pr)) { + return refuseRoute({ code: 'settled-umbrella', ...settledStableUmbrella }); } - return { target, umbrellaBranch: UMBRELLA_PREFIX + target, label, shouldRetarget }; + const umbrellaBranch = `${UMBRELLA_BRANCH_PREFIX}${umbrellaBase}`; + return { status: 'routed', route: { umbrellaBase, umbrellaBranch, label, shouldRetarget } }; } function getReadinessRefusals(readiness: Readiness): Refusal[] { diff --git a/scripts/lib/release-queue/testing/fixtures.ts b/scripts/lib/release-queue/testing/fixtures.ts index 591b1c6980a0f..843eac8fc9a1f 100644 --- a/scripts/lib/release-queue/testing/fixtures.ts +++ b/scripts/lib/release-queue/testing/fixtures.ts @@ -1,42 +1,41 @@ +import type { PullRequestResponse } from '../github-types.ts'; + export const REPO = 'withastro/docs'; -const pullRequest = { +const pullRequest: PullRequestResponse = { number: 7, title: 'Document the new option', html_url: `https://github.com/${REPO}/pull/7`, state: 'open', draft: false, - mergeable: true as boolean | null, - merge_commit_sha: null as string | null, + mergeable: true, base: { ref: 'main' }, - head: { - ref: 'feat/option', - sha: 'head-sha', - repo: { full_name: REPO } as { full_name: string } | null, - }, - labels: [] as { name: string }[], - body: null as string | null, + 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 = {}) { +export function pullRequestResponse( + overrides: Partial = {} +): PullRequestResponse { return { ...pullRequest, ...overrides }; } /** - * A `GET /pulls/` answer for the open umbrella PR into `target`, with - * `merge-on-release`. + * A `GET /pulls/` answer for the open umbrella PR into `umbrellaBase`, + * with `merge-on-release`. */ export function umbrellaResponse({ - target = 'main', + umbrellaBase = 'main', ...overrides -}: { target?: string } & Partial = {}) { +}: { umbrellaBase?: string } & Partial = {}): PullRequestResponse { return pullRequestResponse({ number: 42, - title: `[ci] release ${target} docs`, + title: `[ci] release ${umbrellaBase} docs`, html_url: `https://github.com/${REPO}/pull/42`, - base: { ref: target }, - head: { ref: `release-queue/${target}`, sha: 'umbrella-sha', repo: { full_name: REPO } }, + base: { ref: umbrellaBase }, + head: { ref: `release-queue/${umbrellaBase}`, sha: 'umbrella-sha', repo: { full_name: REPO } }, labels: [{ name: 'merge-on-release' }], ...overrides, }); diff --git a/scripts/lib/release-queue/testing/queue-options.ts b/scripts/lib/release-queue/testing/queue-options.ts index 5b12e7256344a..796f5fe04451d 100644 --- a/scripts/lib/release-queue/testing/queue-options.ts +++ b/scripts/lib/release-queue/testing/queue-options.ts @@ -1,5 +1,6 @@ import { FRAMEWORK_REPO } from '../config.ts'; -import type { Mode, QueueOptions } from '../modes.ts'; +import type { Mode } from '../modes.ts'; +import type { QueueOptions } from '../repository.ts'; import { REPO } from './fixtures.ts'; import type { stubGitHub } from './stub-github.ts'; diff --git a/scripts/lib/release-queue/testing/stub-github.ts b/scripts/lib/release-queue/testing/stub-github.ts index bf36c09530720..de84454bb48fd 100644 --- a/scripts/lib/release-queue/testing/stub-github.ts +++ b/scripts/lib/release-queue/testing/stub-github.ts @@ -1,5 +1,6 @@ import type { TestContext } from 'node:test'; import { GitHubClient } from '../github.ts'; +import { isRecord } from '../json.ts'; import { PrintingGitHubClient } from '../printing-github-client.ts'; import { Repository } from '../repository.ts'; import { REPO } from './fixtures.ts'; @@ -13,6 +14,16 @@ export interface Write { 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; diff --git a/scripts/lib/release-queue/text.ts b/scripts/lib/release-queue/text.ts deleted file mode 100644 index 7c3c6ee4d7b97..0000000000000 --- a/scripts/lib/release-queue/text.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Replaces the `\r\n` line endings GitHub keeps from a description or review - * edited in the browser with `\n`. - */ -export function normalizeLineEndings(text: string): string { - return text.replaceAll('\r\n', '\n'); -} diff --git a/scripts/lib/release-queue/umbrella-body.test.ts b/scripts/lib/release-queue/umbrella-body.test.ts index c4613e390e98e..bfc8b4aff4905 100644 --- a/scripts/lib/release-queue/umbrella-body.test.ts +++ b/scripts/lib/release-queue/umbrella-body.test.ts @@ -3,23 +3,21 @@ import { describe, it } from 'node:test'; import { formatUmbrellaBody, parseReleaseMarker, - selectIncludedPullRequests, + parseIncludedPullRequests, } from './umbrella-body.ts'; -describe('selectIncludedPullRequests', () => { - it('keeps the PRs whose squash commit is on the branch, in commit order', () => { - const included = selectIncludedPullRequests( - [ - { number: 3, title: 'Third', mergeCommitSha: 'c' }, - { number: 1, title: 'First', mergeCommitSha: 'a' }, - { number: 9, title: 'From an earlier release', mergeCommitSha: 'old' }, - { number: 5, title: 'Closed without merging', mergeCommitSha: null }, - ], - ['a', 'main-merge', 'c'] - ); +describe('parseIncludedPullRequests', () => { + it('reads the PR of each squash commit, in commit order', () => { + const included = parseIncludedPullRequests([ + 'Document the new option (#7)', + "Merge branch 'main' into release-queue/main", + 'Fix (a typo) in the guide (#3)', + 'Revert the option (#12) (#15)', + ]); assert.deepEqual(included, [ - { number: 1, title: 'First' }, - { number: 3, title: 'Third' }, + { number: 7, title: 'Document the new option' }, + { number: 3, title: 'Fix (a typo) in the guide' }, + { number: 15, title: 'Revert the option (#12)' }, ]); }); }); @@ -32,14 +30,14 @@ describe('formatUmbrellaBody', () => { it('links the release PR of the main umbrella, and lists the included PRs', () => { assert.equal( - formatUmbrellaBody('main', { releasePullRequest: 18459, included }), + formatUmbrellaBody('main', { releasePrNumber: 18459, included }), [ 'Ships with withastro/astro#18459. This includes:', '', '- Document remote image fetch retries (#14580)', '- Fix a typo (#14581)', '', - 'Managed by the release queue: the bot rewrites this description after each join.', + 'Managed by the release queue: the bot rewrites this description after each join and each manual run.', '', ].join('\n') ); @@ -55,7 +53,7 @@ describe('formatUmbrellaBody', () => { }); it('links the release PR of a prerelease umbrella, with a marker', () => { - const body = formatUmbrellaBody('cloudflare-next', { releasePullRequest: 18269, included }); + const body = formatUmbrellaBody('cloudflare-next', { releasePrNumber: 18269, included }); assert.match(body, /^Ships with withastro\/astro#18269\. This includes:/); assert.equal(parseReleaseMarker(body), 18269); }); @@ -69,7 +67,7 @@ describe('formatUmbrellaBody', () => { it('says when no PR is included yet', () => { assert.match( - formatUmbrellaBody('main', { releasePullRequest: 18459, included: [] }), + formatUmbrellaBody('main', { releasePrNumber: 18459, included: [] }), /^Ships with withastro\/astro#18459\. No docs PR is included yet\.\n\n/ ); }); @@ -77,11 +75,24 @@ describe('formatUmbrellaBody', () => { describe('parseReleaseMarker', () => { it('reads the release PR from the body the bot wrote', () => { - const body = formatUmbrellaBody('main', { releasePullRequest: 18405, included: [] }); + 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); }); + + describe('ignores a marker in the title of an included PR', () => { + const included = [{ number: 7, title: 'Fix ' }]; + + it('when the umbrella links a release PR', () => { + const body = formatUmbrellaBody('main', { releasePrNumber: 18405, included }); + assert.equal(parseReleaseMarker(body), 18405); + }); + + it("when the umbrella doesn't link one", () => { + assert.equal(parseReleaseMarker(formatUmbrellaBody('main', { included })), undefined); + }); + }); }); diff --git a/scripts/lib/release-queue/umbrella-body.ts b/scripts/lib/release-queue/umbrella-body.ts index 6a55606ba4e8f..61b778c44c287 100644 --- a/scripts/lib/release-queue/umbrella-body.ts +++ b/scripts/lib/release-queue/umbrella-body.ts @@ -1,5 +1,8 @@ -import { FRAMEWORK_REPO, MAIN_BRANCH, RELEASE_TITLE_PREFIX } from './config.ts'; -import { formatFrameworkReference } from './github-links.ts'; +import { FRAMEWORK_REPO, STABLE_BRANCH, RELEASE_TITLE_PREFIX } from './config.ts'; +import { formatFrameworkReference } from './github-text.ts'; + +/** The title GitHub gives a squash commit: the PR title, then its number. */ +const SQUASH_TITLE = /^(.*) \(#(\d+)\)$/; /** A docs PR merged into an umbrella branch. */ export interface IncludedPullRequest { @@ -8,51 +11,80 @@ export interface IncludedPullRequest { } /** - * Keeps the PRs whose squash commit is on the umbrella branch, in the order of - * the commits. The branch name comes back each release, so PRs of earlier - * releases have the same base. + * Writes the title of the squash commit of a docs PR, as GitHub does. + * + * @param {IncludedPullRequest} pr - The docs PR to merge. + * @returns {string} The PR title, followed by its number. */ -export function selectIncludedPullRequests( - closed: (IncludedPullRequest & { mergeCommitSha: string | null })[], - umbrellaCommits: string[] -): IncludedPullRequest[] { - const byCommit = new Map(closed.map((pr) => [pr.mergeCommitSha, pr])); - return umbrellaCommits.flatMap((sha) => { - const pr = byCommit.get(sha); - return pr ? [{ number: pr.number, title: pr.title }] : []; +export function formatSquashTitle({ number, title }: IncludedPullRequest): string { + return `${title} (#${number})`; +} + +/** + * Reads the docs PRs an umbrella branch includes, from the titles of its + * commits. + * + * The titles hold the PR numbers, unlike the commit SHAs, which a rebase of + * the branch changes. A commit without a PR number, such as a merge of the + * base, is skipped. + * + * @param {string[]} commitTitles - The titles of the commits of the branch. + * @returns {IncludedPullRequest[]} The included PRs, in the order of the + * commits. + */ +export function parseIncludedPullRequests(commitTitles: string[]): IncludedPullRequest[] { + return commitTitles.flatMap((commitTitle) => { + const match = commitTitle.match(SQUASH_TITLE); + return match ? [{ number: Number(match[2]), title: match[1] }] : []; }); } /** Writes the hidden marker that keeps the release PR an umbrella links. */ -function formatReleaseMarker(releasePullRequest: number | string): string { - return ``; +function formatReleaseMarker(releasePrNumber: number | string): string { + return ``; } // Its characters outside the number match themselves in a regular expression. -const RELEASE_MARKER = new RegExp(formatReleaseMarker('(\\d+)')); +// Anchored to the end of the body, as a PR title above it can hold a marker. +const RELEASE_MARKER = new RegExp(`${formatReleaseMarker('(\\d+)')}\\s*$`); -/** Reads the release PR an umbrella links, from the marker in its body. */ +/** + * 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 the umbrella PR merging into `target`: what it - * ships with, and the docs PRs it includes. A linked release PR is also kept - * in a hidden marker, as the refresh needs it. + * 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, as the + * refresh needs it. + * + * @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 {IncludedPullRequest[]} content.included - The included docs PRs. + * @returns {string} The description, in Markdown. */ export function formatUmbrellaBody( - target: string, + umbrellaBase: string, { - releasePullRequest, + releasePrNumber, included, - }: { releasePullRequest?: number | undefined; included: IncludedPullRequest[] } + }: { releasePrNumber?: number | undefined; included: IncludedPullRequest[] } ): string { - const summary = `${describeShipping(target, releasePullRequest)} ${describeIncluded(included)}`; + const summary = `${describeShipping(umbrellaBase, releasePrNumber)} ${describeIncluded(included)}`; const footer = - 'Managed by the release queue: the bot rewrites this description after each join.'; - const marker = releasePullRequest === undefined ? [] : [formatReleaseMarker(releasePullRequest)]; + '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'); } @@ -62,9 +94,10 @@ function describeIncluded(included: IncludedPullRequest[]): string { return `This includes:\n\n${list}`; } -function describeShipping(target: string, releasePullRequest: number | undefined): string { - if (releasePullRequest !== undefined) - return `Ships with ${formatFrameworkReference(releasePullRequest)}.`; - const release = target === MAIN_BRANCH ? 'release' : 'prerelease'; +function describeShipping(umbrellaBase: string, releasePrNumber: number | undefined): string { + if (releasePrNumber !== undefined) { + return `Ships with ${formatFrameworkReference(releasePrNumber)}.`; + } + const release = umbrellaBase === STABLE_BRANCH ? 'release' : 'prerelease'; return `Ships with the next ${release} of \`${FRAMEWORK_REPO}\`, once its \`${RELEASE_TITLE_PREFIX}\` PR is open.`; } diff --git a/scripts/lib/release-queue/umbrella-heading.test.ts b/scripts/lib/release-queue/umbrella-heading.test.ts index 5a9cb277da624..be566ca9b15be 100644 --- a/scripts/lib/release-queue/umbrella-heading.test.ts +++ b/scripts/lib/release-queue/umbrella-heading.test.ts @@ -1,35 +1,35 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { getMainRelease } from './release.ts'; +import { getStableRelease } from './release.ts'; import { getUmbrellaHeading } from './umbrella-heading.ts'; -const releasePullRequest = (astroVersion?: string) => ({ +const releasePullRequest = (frameworkVersion?: string) => ({ number: 18459, - versions: astroVersion ? [{ name: 'astro', version: astroVersion }] : [], + versions: frameworkVersion ? [{ name: 'astro', version: frameworkVersion }] : [], }); describe('getUmbrellaHeading', () => { it('names the main umbrella after the astro version', () => { - assert.deepEqual(getUmbrellaHeading('main', getMainRelease(releasePullRequest('7.4.1'))), { + assert.deepEqual(getUmbrellaHeading('main', getStableRelease(releasePullRequest('7.4.1'))), { title: '[ci] release astro@7.4.1 docs', releaseLabel: 'patch-release', }); }); it('labels the main umbrella of a minor release', () => { - const heading = getUmbrellaHeading('main', getMainRelease(releasePullRequest('7.5.0'))); + const heading = getUmbrellaHeading('main', getStableRelease(releasePullRequest('7.5.0'))); assert.equal(heading.releaseLabel, 'minor-release'); }); it('falls back to the branch name and a patch when astro is not released', () => { - assert.deepEqual(getUmbrellaHeading('main', getMainRelease(releasePullRequest())), { + assert.deepEqual(getUmbrellaHeading('main', getStableRelease(releasePullRequest())), { title: '[ci] release main docs', releaseLabel: 'patch-release', }); }); it('names a prerelease umbrella after its branch, as a major', () => { - const release = getMainRelease(releasePullRequest('7.4.1')); + const release = getStableRelease(releasePullRequest('7.4.1')); assert.deepEqual(getUmbrellaHeading('cloudflare-next', release), { title: '[ci] release cloudflare-next docs', releaseLabel: 'major-release', diff --git a/scripts/lib/release-queue/umbrella-heading.ts b/scripts/lib/release-queue/umbrella-heading.ts index 4f2fbded49929..95bd2dafe5d4b 100644 --- a/scripts/lib/release-queue/umbrella-heading.ts +++ b/scripts/lib/release-queue/umbrella-heading.ts @@ -1,19 +1,11 @@ import { - FALLBACK_RELEASE_LABEL, FRAMEWORK_PACKAGE, - MAIN_BRANCH, RELEASE_LABELS, RELEASE_TITLE_PREFIX, + STABLE_BRANCH, type ReleaseLabel, } from './config.ts'; -import type { MainRelease } from './release.ts'; - -/** - * An umbrella PR: an open one, or a planned one that a join would create, - * named by its title. An umbrella whose creation was only printed stays - * planned. - */ -export type Umbrella = { status: 'open'; number: number } | { status: 'planned'; title: string }; +import type { StableRelease } from './release.ts'; /** What an umbrella PR takes from the release it ships with. */ export interface UmbrellaHeading { @@ -22,20 +14,38 @@ export interface UmbrellaHeading { } /** - * Gets what the umbrella PR merging into `target` takes from the next release - * from `main`. The `main` umbrella is named after the `astro` version, as - * merged PRs are posted to Discord. On `main`, anything but a minor is a - * patch, including a release without `astro`. A major never gets here: - * `decideJoin` refuses `main` PRs before one. A prerelease umbrella uses its - * branch name, which says what it is for, and is a major. + * Gets the title and release label of the umbrella PR merging into + * `umbrellaBase`. + * + * @param {string} umbrellaBase - The branch the umbrella PR merges into. + * @param {StableRelease} release - The next release from the stable branch. + * @returns {UmbrellaHeading} The heading of the stable umbrella, or of a + * prerelease umbrella for any other base. */ -export function getUmbrellaHeading(target: string, release: MainRelease): UmbrellaHeading { - if (target !== MAIN_BRANCH) { - return { title: `${RELEASE_TITLE_PREFIX} ${target} docs`, releaseLabel: RELEASE_LABELS.major }; - } - const name = release.astroVersion ? `${FRAMEWORK_PACKAGE}@${release.astroVersion}` : target; +export function getUmbrellaHeading(umbrellaBase: string, release: StableRelease): UmbrellaHeading { + return umbrellaBase === STABLE_BRANCH + ? getStableUmbrellaHeading(release) + : getPrereleaseUmbrellaHeading(umbrellaBase); +} + +/** + * Names the stable umbrella after the version of the framework package, as + * merged PRs are posted to Discord. Expects a release that isn't a major. + */ +function getStableUmbrellaHeading(release: StableRelease): UmbrellaHeading { + const name = release.frameworkVersion + ? `${FRAMEWORK_PACKAGE}@${release.frameworkVersion}` + : STABLE_BRANCH; return { title: `${RELEASE_TITLE_PREFIX} ${name} docs`, - releaseLabel: release.type === 'minor' ? RELEASE_LABELS.minor : FALLBACK_RELEASE_LABEL, + releaseLabel: RELEASE_LABELS[release.type], + }; +} + +/** Names a prerelease umbrella after its branch, which says what it is for. */ +function getPrereleaseUmbrellaHeading(umbrellaBase: string): UmbrellaHeading { + return { + title: `${RELEASE_TITLE_PREFIX} ${umbrellaBase} docs`, + releaseLabel: RELEASE_LABELS.major, }; } diff --git a/scripts/lib/release-queue/umbrella-labels.ts b/scripts/lib/release-queue/umbrella-labels.ts index b43ba8dca28f5..edde448a88227 100644 --- a/scripts/lib/release-queue/umbrella-labels.ts +++ b/scripts/lib/release-queue/umbrella-labels.ts @@ -12,8 +12,14 @@ export interface LabelChanges { } /** - * Gets the label changes that give an umbrella PR `merge-on-release` and its - * release label. Other labels, like `i18n`, stay. + * Gets the label changes that give an umbrella PR the label that marks + * umbrella PRs, and its release label. Other labels, such as the translations + * label, 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 the other release labels to + * remove. */ export function getLabelChanges(current: string[], releaseLabel: ReleaseLabel): LabelChanges { const wanted = [MERGE_ON_RELEASE_LABEL, releaseLabel]; @@ -26,8 +32,12 @@ export function getLabelChanges(current: string[], releaseLabel: ReleaseLabel): } /** - * Gets the label changes of an umbrella whose release can't be followed: it - * keeps its release label, and gets the fallback one only if it has none. + * Gets the label changes of an umbrella whose release can't be followed. + * + * @param {string[]} current - The labels of the umbrella PR. + * @returns {LabelChanges} The label that marks umbrella PRs if missing, and + * the fallback release label only when the umbrella has none. Nothing to + * remove. */ export function getKeptLabelChanges(current: string[]): LabelChanges { const hasReleaseLabel = ALL_RELEASE_LABELS.some((label) => current.includes(label)); @@ -40,7 +50,13 @@ export function getKeptLabelChanges(current: string[]): LabelChanges { }; } -/** Gets the labels of an umbrella PR once `changes` are applied. */ +/** + * 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-queries.ts b/scripts/lib/release-queue/umbrella-queries.ts index a26f7e0860ac4..d560ce4da771d 100644 --- a/scripts/lib/release-queue/umbrella-queries.ts +++ b/scripts/lib/release-queue/umbrella-queries.ts @@ -1,29 +1,24 @@ import type { Repository } from './repository.ts'; import type { PullRequestResponse } from './github-types.ts'; -import { listCommitsAhead } from './queries.ts'; -import { getTarget, isUmbrellaBranch } from './routing.ts'; -import { normalizeLineEndings } from './text.ts'; +import { getUmbrellaBase, isUmbrellaBranch } from './routing.ts'; +import { normalizeLineEndings } from './github-text.ts'; -/** An open umbrella PR, as the refresh reads it. */ -export interface OpenUmbrella { +/** An umbrella PR, with what the refresh reads from it. */ +export interface UmbrellaPullRequest { number: number; title: string; body: string; labels: string[]; - /** The branch the umbrella PR merges into. */ - target: string; + base: string; branch: string; } /** - * A PR closed on an umbrella branch, in this release or an earlier one. Only - * a merged PR has a squash commit on the branch. + * An umbrella PR: an open one, or a planned one that a join would create, + * named by its title. An umbrella whose creation was only printed stays + * planned. */ -export interface ClosedPullRequest { - number: number; - title: string; - mergeCommitSha: string | null; -} +export type Umbrella = { status: 'open'; number: number } | { status: 'planned'; title: string }; /** * Whether a PR is an umbrella PR: from an umbrella branch of `repo` into the @@ -33,64 +28,63 @@ function isUmbrellaPullRequest({ base, head }: PullRequestResponse, repo: Reposi return ( isUmbrellaBranch(head.ref) && head.repo?.full_name === repo.name && - getTarget(head.ref) === base.ref + getUmbrellaBase(head.ref) === base.ref ); } -function toOpenUmbrella(data: PullRequestResponse): OpenUmbrella { +function toUmbrellaPullRequest(data: PullRequestResponse): UmbrellaPullRequest { return { number: data.number, title: data.title, body: normalizeLineEndings(data.body ?? ''), labels: data.labels.map((label) => label.name), - target: data.base.ref, + base: data.base.ref, branch: data.head.ref, }; } -/** Gets the umbrella PR `umbrellaNumber`, open or not. */ -export async function getUmbrella(repo: Repository, umbrellaNumber: number) { - return toOpenUmbrella(await repo.get(`pulls/${umbrellaNumber}`)); +/** + * 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 `branch`, if any. */ +/** + * 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 { +): Promise { const pullRequests = await repo.get( `pulls?state=open&head=${repo.owner}:${branch}` ); const umbrella = pullRequests.find((pr) => isUmbrellaPullRequest(pr, repo)); - return umbrella && toOpenUmbrella(umbrella); + return umbrella && toUmbrellaPullRequest(umbrella); } /** - * Lists the open umbrella PRs: PRs from an umbrella branch of `repo` into - * the branch it names. + * Lists the open umbrella PRs: PRs from an umbrella branch of the repository + * into the branch it names. + * + * @param {Repository} repo - The docs repository. + * @returns {Promise} The open umbrella PRs. A PR from a + * fork or into another base isn't one. */ -export async function listOpenUmbrellas(repo: Repository): Promise { +export async function listOpenUmbrellas(repo: Repository): Promise { const pullRequests = await repo.getAll(`pulls?state=open`); - return pullRequests.filter((pr) => isUmbrellaPullRequest(pr, repo)).map(toOpenUmbrella); -} - -/** - * Gets what makes up an umbrella: the commits of its branch, and the PRs ever - * closed on that branch. The closed PRs of the branch build up over the - * releases, about one page of 100 for 100 merged PRs. - */ -export async function getUmbrellaHistory( - repo: Repository, - { target, branch }: OpenUmbrella -): Promise<{ commits: string[]; closed: ClosedPullRequest[] }> { - const [commits, closedResponses] = await Promise.all([ - listCommitsAhead(repo, target, branch), - repo.getAll(`pulls?state=closed&base=${branch}`), - ]); - const closed = closedResponses.map(({ number, title, merge_commit_sha }) => ({ - number, - title, - mergeCommitSha: merge_commit_sha, - })); - return { commits, closed }; + return pullRequests.filter((pr) => isUmbrellaPullRequest(pr, repo)).map(toUmbrellaPullRequest); } diff --git a/scripts/lib/release-queue/umbrella-state.ts b/scripts/lib/release-queue/umbrella-state.ts index 083f768d0bf6c..552f35c33b81a 100644 --- a/scripts/lib/release-queue/umbrella-state.ts +++ b/scripts/lib/release-queue/umbrella-state.ts @@ -1,57 +1,81 @@ import { isFrameworkPullRequestMerged } from './queries.ts'; -import type { MainRelease } from './release.ts'; -import { MAIN_BRANCH, MAIN_UMBRELLA_BRANCH } from './config.ts'; +import type { StableRelease } from './release.ts'; +import { STABLE_BRANCH, STABLE_UMBRELLA_BRANCH } from './config.ts'; import type { SettledUmbrella } from './routing.ts'; import type { Repositories, Repository } from './repository.ts'; -import { findOpenUmbrella, type OpenUmbrella } from './umbrella-queries.ts'; +import { findOpenUmbrella, type UmbrellaPullRequest } from './umbrella-queries.ts'; import { parseReleaseMarker } from './umbrella-body.ts'; /** - * How an umbrella relates to the release PRs of `withastro/astro`: + * 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 = +export type ReleaseState = | { status: 'follows' } - | { status: 'kept'; releasePullRequest?: number | undefined } - | { status: 'settled'; releasePullRequest: number }; + | { status: 'kept'; releasePrNumber?: number | undefined } + | { status: 'settled'; releasePrNumber: number }; /** - * Gets the release state of an umbrella. Prerelease umbrellas always follow: - * their branch has continuous betas. + * Gets how an umbrella relates to the release PRs. + * + * @param {Repository} framework - The framework repository. + * @param {UmbrellaPullRequest} umbrella - The umbrella PR, with the release PR + * its body links. + * @param {StableRelease} release - The next release from the stable branch. + * @returns {Promise} The release state. A prerelease umbrella + * always follows: its branch has continuous betas. */ export async function getReleaseState( framework: Repository, - umbrella: OpenUmbrella, - release: MainRelease + umbrella: UmbrellaPullRequest, + release: StableRelease ): Promise { - if (umbrella.target !== MAIN_BRANCH) return { status: 'follows' }; - const linked = parseReleaseMarker(umbrella.body); - const open = release.pullRequest?.number; - // A release PR closed without merging settles nothing. + if (umbrella.base !== STABLE_BRANCH) return { status: 'follows' }; + const linkedReleasePrNumber = parseReleaseMarker(umbrella.body); + const openReleasePrNumber = release.pullRequest?.number; if ( - linked !== undefined && - linked !== open && - (await isFrameworkPullRequestMerged(framework, linked)) + linkedReleasePrNumber !== undefined && + (await isLinkedReleaseMerged(framework, linkedReleasePrNumber, openReleasePrNumber)) ) { - return { status: 'settled', releasePullRequest: linked }; + return { status: 'settled', releasePrNumber: linkedReleasePrNumber }; } - return open === undefined - ? { status: 'kept', releasePullRequest: linked } + return openReleasePrNumber === undefined + ? { status: 'kept', releasePrNumber: linkedReleasePrNumber } : { status: 'follows' }; } -/** Gets the open `main` umbrella if it's settled, for `decideJoin`. */ -export async function getSettledMainUmbrella( +/** + * 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( + framework: Repository, + linkedReleasePrNumber: number, + openReleasePrNumber: number | undefined +): Promise { + if (linkedReleasePrNumber === openReleasePrNumber) return false; + return isFrameworkPullRequestMerged(framework, linkedReleasePrNumber); +} + +/** + * Gets the open stable umbrella if it's settled. + * + * @param {Repositories} repositories - The docs and framework repositories. + * @param {StableRelease} release - The next release from the stable branch. + * @returns {Promise} The settled umbrella, or + * `undefined` when none is open or it isn't settled. + */ +export async function getSettledStableUmbrella( { docs, framework }: Repositories, - release: MainRelease + release: StableRelease ): Promise { - const umbrella = await findOpenUmbrella(docs, MAIN_UMBRELLA_BRANCH); + const umbrella = await findOpenUmbrella(docs, STABLE_UMBRELLA_BRANCH); if (!umbrella) return undefined; const state = await getReleaseState(framework, umbrella, release); return state.status === 'settled' - ? { number: umbrella.number, releasePullRequest: state.releasePullRequest } + ? { number: umbrella.number, releasePrNumber: state.releasePrNumber } : undefined; } diff --git a/scripts/release-queue.ts b/scripts/release-queue.ts index 73122ff81afe2..86f9fb65262e8 100644 --- a/scripts/release-queue.ts +++ b/scripts/release-queue.ts @@ -1,4 +1,4 @@ -// Routes docs PRs labeled `accepted` to the umbrella PR of their release. +// 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'; From 2e7d8ea87fc4b151cc94e2e5b2669ffe3aac4d8e Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Fri, 23 Oct 2026 14:33:18 +0200 Subject: [PATCH 08/35] Group the release queue modules into folders --- package.json | 2 +- .../handle.test.ts} | 14 +- .../handle.ts} | 27 ++-- .../release-queue/{ => accepted}/join.test.ts | 6 +- .../lib/release-queue/{ => accepted}/join.ts | 8 +- .../{ => accepted}/messages.test.ts | 4 +- .../release-queue/{ => accepted}/messages.ts | 12 +- .../release-queue/accepted/queries.test.ts | 44 ++++++ .../release-queue/{ => accepted}/queries.ts | 140 +----------------- .../{ => accepted}/readiness.test.ts | 2 +- .../release-queue/{ => accepted}/readiness.ts | 2 +- .../{ => accepted}/routing.test.ts | 17 +-- .../release-queue/{ => accepted}/routing.ts | 47 +----- scripts/lib/release-queue/cli.ts | 2 +- scripts/lib/release-queue/github-text.ts | 25 ---- .../{github.test.ts => github/client.test.ts} | 8 +- .../{github.ts => github/client.ts} | 13 +- .../{github-error.ts => github/error.ts} | 0 .../lib/release-queue/{ => github}/json.ts | 0 .../printing-client.ts} | 2 +- .../release-queue/{ => github}/repository.ts | 56 ++++--- .../{github-types.ts => github/types.ts} | 0 scripts/lib/release-queue/main.ts | 11 +- scripts/lib/release-queue/options.ts | 22 +++ .../{ => release}/queries.test.ts | 50 +------ scripts/lib/release-queue/release/queries.ts | 98 ++++++++++++ .../{ => release}/release.test.ts | 0 .../release-queue/{ => release}/release.ts | 13 +- scripts/lib/release-queue/testing/fixtures.ts | 2 +- .../release-queue/testing/queue-options.ts | 2 +- .../lib/release-queue/testing/stub-github.ts | 10 +- .../body.test.ts} | 6 +- .../{umbrella-body.ts => umbrella/body.ts} | 4 +- .../lib/release-queue/umbrella/branch.test.ts | 15 ++ scripts/lib/release-queue/umbrella/branch.ts | 34 +++++ .../heading.test.ts} | 4 +- .../heading.ts} | 4 +- .../labels.test.ts} | 2 +- .../labels.ts} | 2 +- .../{ => umbrella}/merge-block.test.ts | 8 +- .../{ => umbrella}/merge-block.ts | 15 +- .../queries.ts} | 26 +++- .../{ => umbrella}/refresh.test.ts | 14 +- .../release-queue/{ => umbrella}/refresh.ts | 28 ++-- .../{umbrella-state.ts => umbrella/state.ts} | 24 ++- 45 files changed, 425 insertions(+), 400 deletions(-) rename scripts/lib/release-queue/{handle-accepted.test.ts => accepted/handle.test.ts} (97%) rename scripts/lib/release-queue/{handle-accepted.ts => accepted/handle.ts} (90%) rename scripts/lib/release-queue/{ => accepted}/join.test.ts (98%) rename scripts/lib/release-queue/{ => accepted}/join.ts (96%) rename scripts/lib/release-queue/{ => accepted}/messages.test.ts (99%) rename scripts/lib/release-queue/{ => accepted}/messages.ts (97%) create mode 100644 scripts/lib/release-queue/accepted/queries.test.ts rename scripts/lib/release-queue/{ => accepted}/queries.ts (54%) rename scripts/lib/release-queue/{ => accepted}/readiness.test.ts (98%) rename scripts/lib/release-queue/{ => accepted}/readiness.ts (99%) rename scripts/lib/release-queue/{ => accepted}/routing.test.ts (93%) rename scripts/lib/release-queue/{ => accepted}/routing.ts (83%) delete mode 100644 scripts/lib/release-queue/github-text.ts rename scripts/lib/release-queue/{github.test.ts => github/client.test.ts} (95%) rename scripts/lib/release-queue/{github.ts => github/client.ts} (93%) rename scripts/lib/release-queue/{github-error.ts => github/error.ts} (100%) rename scripts/lib/release-queue/{ => github}/json.ts (100%) rename scripts/lib/release-queue/{printing-github-client.ts => github/printing-client.ts} (87%) rename scripts/lib/release-queue/{ => github}/repository.ts (51%) rename scripts/lib/release-queue/{github-types.ts => github/types.ts} (100%) create mode 100644 scripts/lib/release-queue/options.ts rename scripts/lib/release-queue/{ => release}/queries.test.ts (68%) create mode 100644 scripts/lib/release-queue/release/queries.ts rename scripts/lib/release-queue/{ => release}/release.test.ts (100%) rename scripts/lib/release-queue/{ => release}/release.ts (85%) rename scripts/lib/release-queue/{umbrella-body.test.ts => umbrella/body.test.ts} (96%) rename scripts/lib/release-queue/{umbrella-body.ts => umbrella/body.ts} (96%) create mode 100644 scripts/lib/release-queue/umbrella/branch.test.ts create mode 100644 scripts/lib/release-queue/umbrella/branch.ts rename scripts/lib/release-queue/{umbrella-heading.test.ts => umbrella/heading.test.ts} (92%) rename scripts/lib/release-queue/{umbrella-heading.ts => umbrella/heading.ts} (95%) rename scripts/lib/release-queue/{umbrella-labels.test.ts => umbrella/labels.test.ts} (93%) rename scripts/lib/release-queue/{umbrella-labels.ts => umbrella/labels.ts} (98%) rename scripts/lib/release-queue/{ => umbrella}/merge-block.test.ts (95%) rename scripts/lib/release-queue/{ => umbrella}/merge-block.ts (91%) rename scripts/lib/release-queue/{umbrella-queries.ts => umbrella/queries.ts} (75%) rename scripts/lib/release-queue/{ => umbrella}/refresh.test.ts (96%) rename scripts/lib/release-queue/{ => umbrella}/refresh.ts (90%) rename scripts/lib/release-queue/{umbrella-state.ts => umbrella/state.ts} (80%) diff --git a/package.json b/package.json index a7d8160851cb8..80cb83826aae5 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "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", - "test:release-queue": "node --experimental-transform-types --test './scripts/lib/release-queue/*.test.ts'" + "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/handle-accepted.test.ts b/scripts/lib/release-queue/accepted/handle.test.ts similarity index 97% rename from scripts/lib/release-queue/handle-accepted.test.ts rename to scripts/lib/release-queue/accepted/handle.test.ts index 785e4c0f76b64..a8b5be42bb46f 100644 --- a/scripts/lib/release-queue/handle-accepted.test.ts +++ b/scripts/lib/release-queue/accepted/handle.test.ts @@ -1,12 +1,12 @@ 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, queueOptions } from './testing/queue-options.ts'; -import { readBody, stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; -import { formatUmbrellaBody } from './umbrella-body.ts'; +import { GitHubError } from '../github/error.ts'; +import type { Mode } from '../modes.ts'; +import { pullRequestResponse, REPO, umbrellaResponse } from '../testing/fixtures.ts'; +import { ACTIONS_TOKEN, BOT_TOKEN, queueOptions } from '../testing/queue-options.ts'; +import { readBody, stubGitHub, type StubOptions, type Write } from '../testing/stub-github.ts'; +import { formatUmbrellaBody } from '../umbrella/body.ts'; +import { handleAcceptedPullRequest } from './handle.ts'; const paths = { pr: `repos/${REPO}/pulls/7`, diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/accepted/handle.ts similarity index 90% rename from scripts/lib/release-queue/handle-accepted.ts rename to scripts/lib/release-queue/accepted/handle.ts index 02b3b11628248..97caa793de63e 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/accepted/handle.ts @@ -1,6 +1,17 @@ -import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH, STABLE_UMBRELLA_BRANCH } from './config.ts'; +import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH, STABLE_UMBRELLA_BRANCH } from '../config.ts'; +import type { Repository } from '../github/repository.ts'; +import { removeLabel } from '../github/repository.ts'; +import { isLiveFor } from '../modes.ts'; +import type { QueueOptions } from '../options.ts'; +import { getReleasePullRequest } from '../release/queries.ts'; +import { getStableRelease, type StableRelease } from '../release/release.ts'; +import { getUmbrellaBase } from '../umbrella/branch.ts'; +import { getUmbrellaHeading } from '../umbrella/heading.ts'; +import { syncMergeBlockReview } from '../umbrella/merge-block.ts'; +import { findOpenUmbrella, getUmbrella, type Umbrella } from '../umbrella/queries.ts'; +import { refreshUmbrella } from '../umbrella/refresh.ts'; +import { getSettledStableUmbrella } from '../umbrella/state.ts'; import { joinUmbrella } from './join.ts'; -import { syncMergeBlockReview } from './merge-block.ts'; import { formatDryRunJoinComment, formatDryRunRefusalComment, @@ -9,30 +20,20 @@ import { formatUnopenedJoinComment, type MessageContext, } from './messages.ts'; -import { isLiveFor } from './modes.ts'; import { canWrite, getPullRequest, getReadiness, - getReleasePullRequest, postComment, - removeLabel, type PullRequest, } from './queries.ts'; -import { refreshUmbrella } from './refresh.ts'; -import { getStableRelease, type StableRelease } from './release.ts'; -import type { QueueOptions, Repository } from './repository.ts'; import { decideJoin, - getUmbrellaBase, + wouldRetargetFromStable, type Decision, type RefusedDecision, type Route, - wouldRetargetFromStable, } from './routing.ts'; -import { getUmbrellaHeading } from './umbrella-heading.ts'; -import { findOpenUmbrella, getUmbrella, type Umbrella } from './umbrella-queries.ts'; -import { getSettledStableUmbrella } from './umbrella-state.ts'; /** * A docs PR labeled as accepted, with the release it ships with and the diff --git a/scripts/lib/release-queue/join.test.ts b/scripts/lib/release-queue/accepted/join.test.ts similarity index 98% rename from scripts/lib/release-queue/join.test.ts rename to scripts/lib/release-queue/accepted/join.test.ts index 5da9247b95066..afc068e850957 100644 --- a/scripts/lib/release-queue/join.test.ts +++ b/scripts/lib/release-queue/accepted/join.test.ts @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { GitHubError } from './github-error.ts'; +import { GitHubError } from '../github/error.ts'; +import { pullRequestResponse, REPO, umbrellaResponse } from '../testing/fixtures.ts'; +import { stubGitHub, type Write } from '../testing/stub-github.ts'; import { joinUmbrella } from './join.ts'; import type { PullRequest } from './queries.ts'; import type { Route } from './routing.ts'; -import { pullRequestResponse, REPO, umbrellaResponse } from './testing/fixtures.ts'; -import { stubGitHub, type Write } from './testing/stub-github.ts'; const pr: PullRequest = { number: 7, diff --git a/scripts/lib/release-queue/join.ts b/scripts/lib/release-queue/accepted/join.ts similarity index 96% rename from scripts/lib/release-queue/join.ts rename to scripts/lib/release-queue/accepted/join.ts index 734d5dd10608e..60af1f67cc1bd 100644 --- a/scripts/lib/release-queue/join.ts +++ b/scripts/lib/release-queue/accepted/join.ts @@ -1,9 +1,9 @@ -import { GitHubError } from './github-error.ts'; +import { GitHubError } from '../github/error.ts'; +import type { Repository } from '../github/repository.ts'; +import { formatSquashTitle } from '../umbrella/body.ts'; +import { findOpenUmbrella, type Umbrella } from '../umbrella/queries.ts'; import { countCommitsAhead, getBranchSha, getPullRequest, type PullRequest } from './queries.ts'; -import type { Repository } from './repository.ts'; import type { JoinRefusal, Route } from './routing.ts'; -import { formatSquashTitle } from './umbrella-body.ts'; -import { findOpenUmbrella, type Umbrella } from './umbrella-queries.ts'; /** A docs PR to join to the umbrella of `route`. */ export interface Join { diff --git a/scripts/lib/release-queue/messages.test.ts b/scripts/lib/release-queue/accepted/messages.test.ts similarity index 99% rename from scripts/lib/release-queue/messages.test.ts rename to scripts/lib/release-queue/accepted/messages.test.ts index 3e8f50bacebc5..4c433146a7e3a 100644 --- a/scripts/lib/release-queue/messages.test.ts +++ b/scripts/lib/release-queue/accepted/messages.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { GitHubError } from './github-error.ts'; +import { GitHubError } from '../github/error.ts'; import { formatDryRunJoinComment, formatDryRunRefusalComment, - formatUnopenedJoinComment, formatJoinComment, formatRefusalComment, + formatUnopenedJoinComment, type MessageContext, } from './messages.ts'; import type { Refusal, Route } from './routing.ts'; diff --git a/scripts/lib/release-queue/messages.ts b/scripts/lib/release-queue/accepted/messages.ts similarity index 97% rename from scripts/lib/release-queue/messages.ts rename to scripts/lib/release-queue/accepted/messages.ts index 8818ce7143330..5500129c97011 100644 --- a/scripts/lib/release-queue/messages.ts +++ b/scripts/lib/release-queue/accepted/messages.ts @@ -1,16 +1,16 @@ import { ACCEPTED_LABEL, - FRAMEWORK_STABLE_BRANCH, FRAMEWORK_PACKAGE, + FRAMEWORK_STABLE_BRANCH, + RELEASE_LABELS, STABLE_BRANCH, STABLE_UMBRELLA_BRANCH, - RELEASE_LABELS, -} from './config.ts'; -import { GitHubError } from './github-error.ts'; -import { formatFrameworkReference } from './github-text.ts'; +} from '../config.ts'; +import { GitHubError } from '../github/error.ts'; +import { formatFrameworkReference } from '../release/release.ts'; +import type { Umbrella } from '../umbrella/queries.ts'; import type { UnopenedJoin } from './join.ts'; import type { Refusal, RefusalCode, RefusedDecision, Route } from './routing.ts'; -import type { Umbrella } from './umbrella-queries.ts'; export interface MessageContext { /** The umbrella PR the docs PR would join. */ diff --git a/scripts/lib/release-queue/accepted/queries.test.ts b/scripts/lib/release-queue/accepted/queries.test.ts new file mode 100644 index 0000000000000..801a922196667 --- /dev/null +++ b/scripts/lib/release-queue/accepted/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/queries.ts b/scripts/lib/release-queue/accepted/queries.ts similarity index 54% rename from scripts/lib/release-queue/queries.ts rename to scripts/lib/release-queue/accepted/queries.ts index 34955089e1794..626f09b3e1bc0 100644 --- a/scripts/lib/release-queue/queries.ts +++ b/scripts/lib/release-queue/accepted/queries.ts @@ -1,14 +1,13 @@ -import { FRAMEWORK_PRERELEASE_BRANCH, STABLE_BRANCH } from './config.ts'; -import { GitHubError } from './github-error.ts'; +import { STABLE_BRANCH } from '../config.ts'; +import { GitHubError } from '../github/error.ts'; +import { compareBranches, type Repository } from '../github/repository.ts'; import type { CheckRunResponse, CommitStatusResponse, - CompareResponse, PullRequestResponse, ReviewResponse, RuleResponse, -} from './github-types.ts'; -import { isRecord, parseJson } from './json.ts'; +} from '../github/types.ts'; import { classifyChecks, classifyReviewers, @@ -16,15 +15,8 @@ import { type Readiness, type Review, } from './readiness.ts'; -import { parseReleaseVersions, type ReleasePullRequest } from './release.ts'; -import type { Repository } from './repository.ts'; import type { DocsPullRequest } from './routing.ts'; -/** The branch prefix of the release PRs that changesets opens. */ -const RELEASE_BRANCH_PREFIX = 'changeset-release/'; -/** The changesets file that holds the prerelease mode of a branch. */ -const PRE_STATE_PATH = '.changeset/pre.json'; - const MERGEABILITY_ATTEMPTS = 5; export interface PullRequest extends DocsPullRequest { @@ -105,28 +97,6 @@ export async function countCommitsAhead( return ahead_by; } -/** - * Lists the titles of the commits of a branch that aren't in another. - * - * @param {Repository} repo - The repository of both branches. - * @param {string} base - The branch to compare with. - * @param {string} head - The branch whose commits are listed. - * @returns {Promise} The first line of the message of each commit - * of `head` missing in `base`, oldest first. - */ -export async function listCommitTitlesAhead( - repo: Repository, - base: string, - head: string -): Promise { - const { commits } = await compareBranches(repo, base, head); - return commits.map(({ commit }) => commit.message.split('\n', 1)[0] ?? ''); -} - -function compareBranches(repo: Repository, base: string, head: string) { - return repo.get(`compare/${base}...${head}`); -} - /** * Comments on a PR. * @@ -138,21 +108,6 @@ export async function postComment(repo: Repository, prNumber: number, body: stri await repo.write('POST', `issues/${prNumber}/comments`, { body }); } -/** - * Removes a label from a PR. A label that is already gone is fine. - * - * @param {Repository} repo - The repository of the PR. - * @param {number} prNumber - The number of the PR. - * @param {string} label - The label to remove. - */ -export async function removeLabel(repo: Repository, prNumber: number, label: string) { - try { - await repo.write('DELETE', `issues/${prNumber}/labels/${label}`); - } catch (error) { - if (!(error instanceof GitHubError && error.status === 404)) throw error; - } -} - /** * Whether a user can write to the repository, from their permission on it. * @@ -231,90 +186,3 @@ async function getRequiredChecks(repo: Repository): Promise { .flatMap((rule) => rule.parameters?.required_status_checks ?? []) .map((check) => check.context); } - -/** - * Gets the open release PR of a branch of the framework repository. - * - * @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. - */ -export async function getReleasePullRequest( - framework: Repository, - branch: string -): Promise { - const [pr] = await framework.get( - `pulls?state=open&head=${framework.owner}:${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 { - try { - const { content } = await framework.get<{ content: string }>( - `contents/${PRE_STATE_PATH}?ref=${FRAMEWORK_PRERELEASE_BRANCH}` - ); - // `exit` means the prerelease ends: the branch is then merged into the - // stable branch. - return readPreMode(Buffer.from(content, 'base64').toString()) === 'pre'; - } catch (error) { - if (error instanceof GitHubError && error.status === 404) return false; - throw error; - } -} - -/** 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'; -} - -/** - * Gets the release PR a prerelease umbrella ships with. - * - * @param {Repository} framework - The framework repository. - * @param {ReleasePullRequest} [stablePullRequest] - The open release PR of the - * stable branch. - * @returns {Promise} The alpha or beta release - * PR while the prerelease branch is in prerelease mode. Then the release PR - * of the stable branch, which the prerelease branch was merged into. - */ -export async function getPrereleasePullRequest( - framework: Repository, - stablePullRequest: ReleasePullRequest | undefined -): Promise { - return (await isPrereleaseBranchInPreMode(framework)) - ? getReleasePullRequest(framework, FRAMEWORK_PRERELEASE_BRANCH) - : stablePullRequest; -} - -/** - * Whether a PR of the framework repository, such as a release PR, is merged. - * - * @param {Repository} framework - The framework repository. - * @param {number} prNumber - The number of the PR. - * @returns {Promise} `false` for a PR that doesn't exist, for example - * from an edited marker. - */ -export async function isFrameworkPullRequestMerged( - framework: Repository, - prNumber: number -): Promise { - try { - const { merged } = await framework.get(`pulls/${prNumber}`); - return merged === true; - } catch (error) { - if (error instanceof GitHubError && error.status === 404) return false; - throw error; - } -} diff --git a/scripts/lib/release-queue/readiness.test.ts b/scripts/lib/release-queue/accepted/readiness.test.ts similarity index 98% rename from scripts/lib/release-queue/readiness.test.ts rename to scripts/lib/release-queue/accepted/readiness.test.ts index 17b9fd4c18b89..1e5cfb18b2b50 100644 --- a/scripts/lib/release-queue/readiness.test.ts +++ b/scripts/lib/release-queue/accepted/readiness.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { getLatestReviewStates, classifyChecks, classifyReviewers } from './readiness.ts'; +import { classifyChecks, classifyReviewers, getLatestReviewStates } from './readiness.ts'; describe('getLatestReviewStates', () => { it('keeps the latest approval or change request of each reviewer', () => { diff --git a/scripts/lib/release-queue/readiness.ts b/scripts/lib/release-queue/accepted/readiness.ts similarity index 99% rename from scripts/lib/release-queue/readiness.ts rename to scripts/lib/release-queue/accepted/readiness.ts index ead3b9fba309c..520afb07f6868 100644 --- a/scripts/lib/release-queue/readiness.ts +++ b/scripts/lib/release-queue/accepted/readiness.ts @@ -3,7 +3,7 @@ import type { CheckRunResponse, CommitStatusResponse, ReviewResponseState, -} from './github-types.ts'; +} from '../github/types.ts'; export interface Review { user: string; diff --git a/scripts/lib/release-queue/routing.test.ts b/scripts/lib/release-queue/accepted/routing.test.ts similarity index 93% rename from scripts/lib/release-queue/routing.test.ts rename to scripts/lib/release-queue/accepted/routing.test.ts index 964c1578bfba8..8567aa11b7aeb 100644 --- a/scripts/lib/release-queue/routing.test.ts +++ b/scripts/lib/release-queue/accepted/routing.test.ts @@ -3,11 +3,10 @@ import { describe, it } from 'node:test'; import type { Readiness } from './readiness.ts'; import { decideJoin, - getUmbrellaBase, - type DecisionContext, + wouldRetargetFromStable, type Decision, + type DecisionContext, type DocsPullRequest, - wouldRetargetFromStable, } from './routing.ts'; const pr = (overrides: Partial = {}): DocsPullRequest => ({ @@ -203,18 +202,6 @@ describe('decideJoin', () => { }); }); -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'); - }); -}); - describe('wouldRetargetFromStable', () => { it('applies to an open PR against main', () => { assert.equal(wouldRetargetFromStable(pr()), true); diff --git a/scripts/lib/release-queue/routing.ts b/scripts/lib/release-queue/accepted/routing.ts similarity index 83% rename from scripts/lib/release-queue/routing.ts rename to scripts/lib/release-queue/accepted/routing.ts index e548e25665e5b..fa232a86e7b1e 100644 --- a/scripts/lib/release-queue/routing.ts +++ b/scripts/lib/release-queue/accepted/routing.ts @@ -1,13 +1,9 @@ -import { - ALL_RELEASE_LABELS, - STABLE_BRANCH, - RELEASE_LABELS, - UMBRELLA_BRANCH_PREFIX, - type ReleaseLabel, -} from './config.ts'; -import type { PullRequestState } from './github-types.ts'; +import { ALL_RELEASE_LABELS, RELEASE_LABELS, STABLE_BRANCH, type ReleaseLabel } from '../config.ts'; +import type { PullRequestState } from '../github/types.ts'; +import { RELEASE_TYPES, type StableRelease } from '../release/release.ts'; +import { getUmbrellaBase, getUmbrellaBranch, isUmbrellaBranch } from '../umbrella/branch.ts'; +import type { SettledUmbrella } from '../umbrella/state.ts'; import type { Readiness } from './readiness.ts'; -import { RELEASE_TYPES, type StableRelease } from './release.ts'; export interface DocsPullRequest { state: PullRequestState; @@ -25,16 +21,6 @@ export interface Route { shouldRetarget: boolean; } -/** - * The open stable umbrella once the release PR it ships with is merged, until - * the umbrella itself is merged. - */ -export interface SettledUmbrella { - number: number; - /** The merged release PR of the framework repository. */ - releasePrNumber: number; -} - /** What `decideJoin` needs besides the PR. */ export interface DecisionContext { release: StableRelease; @@ -87,16 +73,6 @@ 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 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); -} - /** * Whether the bot would change the base of an open docs PR from the stable * branch to the stable umbrella branch. @@ -111,17 +87,6 @@ export function wouldRetargetFromStable(pr: DocsPullRequest): boolean { return pr.state === 'open' && pr.base === STABLE_BRANCH; } -/** - * 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; -} - /** * Decides whether a docs PR joins an umbrella, and which one. * @@ -180,7 +145,7 @@ function getRoute( return refuseRoute({ code: 'settled-umbrella', ...settledStableUmbrella }); } - const umbrellaBranch = `${UMBRELLA_BRANCH_PREFIX}${umbrellaBase}`; + const umbrellaBranch = getUmbrellaBranch(umbrellaBase); return { status: 'routed', route: { umbrellaBase, umbrellaBranch, label, shouldRetarget } }; } diff --git a/scripts/lib/release-queue/cli.ts b/scripts/lib/release-queue/cli.ts index efab0eca005e0..f214642cbf4a4 100644 --- a/scripts/lib/release-queue/cli.ts +++ b/scripts/lib/release-queue/cli.ts @@ -1,4 +1,4 @@ -import { isRecord, parseJson } from './json.ts'; +import { isRecord, parseJson } from './github/json.ts'; /** A positive integer, written with digits only. */ const PR_NUMBER = /^[1-9]\d*$/; diff --git a/scripts/lib/release-queue/github-text.ts b/scripts/lib/release-queue/github-text.ts deleted file mode 100644 index 389f2303171b3..0000000000000 --- a/scripts/lib/release-queue/github-text.ts +++ /dev/null @@ -1,25 +0,0 @@ -// The text the bot reads from GitHub and writes to it. - -import { FRAMEWORK_REPO } from './config.ts'; - -/** - * Replaces the `\r\n` line endings GitHub keeps from a description or review - * edited in the browser with `\n`. - * - * @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 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/github.test.ts b/scripts/lib/release-queue/github/client.test.ts similarity index 95% rename from scripts/lib/release-queue/github.test.ts rename to scripts/lib/release-queue/github/client.test.ts index 9515fc7be7a4e..f69baccf984f9 100644 --- a/scripts/lib/release-queue/github.test.ts +++ b/scripts/lib/release-queue/github/client.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; -import { GitHubClient } from './github.ts'; -import { GitHubError } from './github-error.ts'; -import { PrintingGitHubClient } from './printing-github-client.ts'; -import { json, stubGitHub } from './testing/stub-github.ts'; +import { json, stubGitHub } from '../testing/stub-github.ts'; +import { GitHubClient } from './client.ts'; +import { GitHubError } from './error.ts'; +import { PrintingGitHubClient } from './printing-client.ts'; /** Replaces `fetch` with one answering `response` to every request. */ function answer(t: TestContext, response: Response) { diff --git a/scripts/lib/release-queue/github.ts b/scripts/lib/release-queue/github/client.ts similarity index 93% rename from scripts/lib/release-queue/github.ts rename to scripts/lib/release-queue/github/client.ts index 97eec84c6ac95..0d11658aa2350 100644 --- a/scripts/lib/release-queue/github.ts +++ b/scripts/lib/release-queue/github/client.ts @@ -1,5 +1,5 @@ import { setTimeout } from 'node:timers/promises'; -import { GitHubError } from './github-error.ts'; +import { GitHubError } from './error.ts'; import { isRecord, parseJson } from './json.ts'; const API_URL = 'https://api.github.com'; @@ -157,3 +157,14 @@ function parseErrorBody(text: string): unknown { function readErrorMessage(error: unknown): string | undefined { return isRecord(error) && typeof error.message === 'string' ? error.message : undefined; } + +/** + * Replaces the `\r\n` line endings GitHub keeps from a description or review + * edited in the browser with `\n`. + * + * @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'); +} diff --git a/scripts/lib/release-queue/github-error.ts b/scripts/lib/release-queue/github/error.ts similarity index 100% rename from scripts/lib/release-queue/github-error.ts rename to scripts/lib/release-queue/github/error.ts diff --git a/scripts/lib/release-queue/json.ts b/scripts/lib/release-queue/github/json.ts similarity index 100% rename from scripts/lib/release-queue/json.ts rename to scripts/lib/release-queue/github/json.ts diff --git a/scripts/lib/release-queue/printing-github-client.ts b/scripts/lib/release-queue/github/printing-client.ts similarity index 87% rename from scripts/lib/release-queue/printing-github-client.ts rename to scripts/lib/release-queue/github/printing-client.ts index 490eb0a5f3593..6d86082ed18d9 100644 --- a/scripts/lib/release-queue/printing-github-client.ts +++ b/scripts/lib/release-queue/github/printing-client.ts @@ -1,4 +1,4 @@ -import { GitHubClient, type WriteMethod } from './github.ts'; +import { GitHubClient, type WriteMethod } from './client.ts'; /** * A GitHub client that prints the requests that change GitHub instead of diff --git a/scripts/lib/release-queue/repository.ts b/scripts/lib/release-queue/github/repository.ts similarity index 51% rename from scripts/lib/release-queue/repository.ts rename to scripts/lib/release-queue/github/repository.ts index 26b1fcf2f2f3e..fe9788297a3b1 100644 --- a/scripts/lib/release-queue/repository.ts +++ b/scripts/lib/release-queue/github/repository.ts @@ -1,25 +1,6 @@ -import type { GitHubClient, WriteMethod } from './github.ts'; -import type { Mode } from './modes.ts'; - -/** The docs repository, and the framework repository for its release PRs. */ -export interface Repositories { - docs: Repository; - framework: Repository; -} - -/** What the bot runs with, whether it handles a PR or refreshes umbrellas. */ -export interface QueueOptions { - /** - * 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; -} +import type { GitHubClient, WriteMethod } from './client.ts'; +import { GitHubError } from './error.ts'; +import type { CompareResponse } from './types.ts'; /** * A GitHub repository, read and changed through a client. Its methods are @@ -62,3 +43,34 @@ export class Repository { return `repos/${this.name}/${path}`; } } + +/** + * Compares two branches of a repository. + * + * @param {Repository} repo - The repository of both branches. + * @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`. + */ +export function compareBranches( + repo: Repository, + base: string, + head: string +): Promise { + return repo.get(`compare/${base}...${head}`); +} + +/** + * Removes a label from a PR. A label that is already gone is fine. + * + * @param {Repository} repo - The repository of the PR. + * @param {number} prNumber - The number of the PR. + * @param {string} label - The label to remove. + */ +export async function removeLabel(repo: Repository, prNumber: number, label: string) { + try { + await repo.write('DELETE', `issues/${prNumber}/labels/${label}`); + } catch (error) { + if (!(error instanceof GitHubError && error.status === 404)) throw error; + } +} diff --git a/scripts/lib/release-queue/github-types.ts b/scripts/lib/release-queue/github/types.ts similarity index 100% rename from scripts/lib/release-queue/github-types.ts rename to scripts/lib/release-queue/github/types.ts diff --git a/scripts/lib/release-queue/main.ts b/scripts/lib/release-queue/main.ts index ad8d755d7cc8a..339617b8f842a 100644 --- a/scripts/lib/release-queue/main.ts +++ b/scripts/lib/release-queue/main.ts @@ -1,12 +1,13 @@ import { readFile } from 'node:fs/promises'; import { chooseAction, parseEvent, type RunEvent } from './cli.ts'; import { DOCS_REPO, FRAMEWORK_REPO } from './config.ts'; -import { GitHubClient } from './github.ts'; -import { handleAcceptedPullRequest } from './handle-accepted.ts'; +import { GitHubClient } from './github/client.ts'; +import { PrintingGitHubClient } from './github/printing-client.ts'; +import { Repository } from './github/repository.ts'; +import { handleAcceptedPullRequest } from './accepted/handle.ts'; import { isDryRun, parseMode } from './modes.ts'; -import { PrintingGitHubClient } from './printing-github-client.ts'; -import { refreshOpenUmbrellas } from './refresh.ts'; -import { Repository, type QueueOptions } from './repository.ts'; +import type { QueueOptions } from './options.ts'; +import { refreshOpenUmbrellas } from './umbrella/refresh.ts'; /** The command-line flags of `scripts/release-queue.ts`. */ export interface Flags { diff --git a/scripts/lib/release-queue/options.ts b/scripts/lib/release-queue/options.ts new file mode 100644 index 0000000000000..478973a42cf4f --- /dev/null +++ b/scripts/lib/release-queue/options.ts @@ -0,0 +1,22 @@ +import type { Repository } from './github/repository.ts'; +import type { Mode } from './modes.ts'; + +/** The docs repository, and the framework repository for its release PRs. */ +export interface Repositories { + docs: Repository; + framework: Repository; +} + +/** What the bot runs with, whether it handles a PR or refreshes umbrellas. */ +export interface QueueOptions { + /** + * 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; +} diff --git a/scripts/lib/release-queue/queries.test.ts b/scripts/lib/release-queue/release/queries.test.ts similarity index 68% rename from scripts/lib/release-queue/queries.test.ts rename to scripts/lib/release-queue/release/queries.test.ts index f39460579ff53..a25d528603833 100644 --- a/scripts/lib/release-queue/queries.test.ts +++ b/scripts/lib/release-queue/release/queries.test.ts @@ -1,50 +1,10 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { FRAMEWORK_REPO } from './config.ts'; -import { GitHubClient } from './github.ts'; -import { getPrereleasePullRequest, getPullRequest } from './queries.ts'; -import { Repository } from './repository.ts'; -import { pullRequestResponse, REPO } from './testing/fixtures.ts'; -import { stubGitHub } from './testing/stub-github.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); - }); -}); +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 { getPrereleasePullRequest } from './queries.ts'; describe('getPrereleasePullRequest', () => { const preJson = 'repos/withastro/astro/contents/.changeset/pre.json?ref=next'; diff --git a/scripts/lib/release-queue/release/queries.ts b/scripts/lib/release-queue/release/queries.ts new file mode 100644 index 0000000000000..537b02d0bd7c1 --- /dev/null +++ b/scripts/lib/release-queue/release/queries.ts @@ -0,0 +1,98 @@ +import { FRAMEWORK_PRERELEASE_BRANCH } from '../config.ts'; +import { GitHubError } from '../github/error.ts'; +import { isRecord, parseJson } from '../github/json.ts'; +import type { Repository } from '../github/repository.ts'; +import type { PullRequestResponse } from '../github/types.ts'; +import { parseReleaseVersions, type ReleasePullRequest } from './release.ts'; + +/** The branch prefix of the release PRs that changesets opens. */ +const RELEASE_BRANCH_PREFIX = 'changeset-release/'; +/** The changesets file that holds the prerelease mode of a branch. */ +const PRE_STATE_PATH = '.changeset/pre.json'; + +/** + * Gets the open release PR of a branch of the framework repository. + * + * @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. + */ +export async function getReleasePullRequest( + framework: Repository, + branch: string +): Promise { + const [pr] = await framework.get( + `pulls?state=open&head=${framework.owner}:${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 { + try { + const { content } = await framework.get<{ content: string }>( + `contents/${PRE_STATE_PATH}?ref=${FRAMEWORK_PRERELEASE_BRANCH}` + ); + // `exit` means the prerelease ends: the branch is then merged into the + // stable branch. + return readPreMode(Buffer.from(content, 'base64').toString()) === 'pre'; + } catch (error) { + if (error instanceof GitHubError && error.status === 404) return false; + throw error; + } +} + +/** 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'; +} + +/** + * Gets the release PR a prerelease umbrella ships with. + * + * @param {Repository} framework - The framework repository. + * @param {ReleasePullRequest} [stablePullRequest] - The open release PR of the + * stable branch. + * @returns {Promise} The alpha or beta release + * PR while the prerelease branch is in prerelease mode. Then the release PR + * of the stable branch, which the prerelease branch was merged into. + */ +export async function getPrereleasePullRequest( + framework: Repository, + stablePullRequest: ReleasePullRequest | undefined +): Promise { + return (await isPrereleaseBranchInPreMode(framework)) + ? getReleasePullRequest(framework, FRAMEWORK_PRERELEASE_BRANCH) + : stablePullRequest; +} + +/** + * Whether a PR of the framework repository, such as a release PR, is merged. + * + * @param {Repository} framework - The framework repository. + * @param {number} prNumber - The number of the PR. + * @returns {Promise} `false` for a PR that doesn't exist, for example + * from an edited marker. + */ +export async function isFrameworkPullRequestMerged( + framework: Repository, + prNumber: number +): Promise { + try { + const { merged } = await framework.get(`pulls/${prNumber}`); + return merged === true; + } catch (error) { + if (error instanceof GitHubError && error.status === 404) return false; + throw error; + } +} diff --git a/scripts/lib/release-queue/release.test.ts b/scripts/lib/release-queue/release/release.test.ts similarity index 100% rename from scripts/lib/release-queue/release.test.ts rename to scripts/lib/release-queue/release/release.test.ts diff --git a/scripts/lib/release-queue/release.ts b/scripts/lib/release-queue/release/release.ts similarity index 85% rename from scripts/lib/release-queue/release.ts rename to scripts/lib/release-queue/release/release.ts index 245b3435c2320..b5182c25eb631 100644 --- a/scripts/lib/release-queue/release.ts +++ b/scripts/lib/release-queue/release/release.ts @@ -1,4 +1,4 @@ -import { FRAMEWORK_PACKAGE, RELEASE_LABELS } from './config.ts'; +import { FRAMEWORK_PACKAGE, FRAMEWORK_REPO, RELEASE_LABELS } from '../config.ts'; /** A `## @` heading of a release PR body. */ const RELEASE_HEADING = /^## (\S+)@(\d+\.\d+\.\d+\S*)\s*$/gm; @@ -74,3 +74,14 @@ export function getStableRelease(pullRequest?: ReleasePullRequest): StableReleas const frameworkVersion = findVersion(pullRequest?.versions ?? [], FRAMEWORK_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/testing/fixtures.ts b/scripts/lib/release-queue/testing/fixtures.ts index 843eac8fc9a1f..6f85bcf8d9981 100644 --- a/scripts/lib/release-queue/testing/fixtures.ts +++ b/scripts/lib/release-queue/testing/fixtures.ts @@ -1,4 +1,4 @@ -import type { PullRequestResponse } from '../github-types.ts'; +import type { PullRequestResponse } from '../github/types.ts'; export const REPO = 'withastro/docs'; diff --git a/scripts/lib/release-queue/testing/queue-options.ts b/scripts/lib/release-queue/testing/queue-options.ts index 796f5fe04451d..932d926063f6e 100644 --- a/scripts/lib/release-queue/testing/queue-options.ts +++ b/scripts/lib/release-queue/testing/queue-options.ts @@ -1,6 +1,6 @@ import { FRAMEWORK_REPO } from '../config.ts'; import type { Mode } from '../modes.ts'; -import type { QueueOptions } from '../repository.ts'; +import type { QueueOptions } from '../options.ts'; import { REPO } from './fixtures.ts'; import type { stubGitHub } from './stub-github.ts'; diff --git a/scripts/lib/release-queue/testing/stub-github.ts b/scripts/lib/release-queue/testing/stub-github.ts index de84454bb48fd..787b0251e7a5a 100644 --- a/scripts/lib/release-queue/testing/stub-github.ts +++ b/scripts/lib/release-queue/testing/stub-github.ts @@ -1,8 +1,8 @@ import type { TestContext } from 'node:test'; -import { GitHubClient } from '../github.ts'; -import { isRecord } from '../json.ts'; -import { PrintingGitHubClient } from '../printing-github-client.ts'; -import { Repository } from '../repository.ts'; +import { GitHubClient } from '../github/client.ts'; +import { isRecord } from '../github/json.ts'; +import { PrintingGitHubClient } from '../github/printing-client.ts'; +import { Repository } from '../github/repository.ts'; import { REPO } from './fixtures.ts'; const API_URL = 'https://api.github.com/'; @@ -37,7 +37,7 @@ 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.test.ts`). + * stub answers one page (paging is tested in `github/client.test.ts`). */ getResponses?: Record; /** JSON answers to writes, by ` `. Others answer with a 204. */ diff --git a/scripts/lib/release-queue/umbrella-body.test.ts b/scripts/lib/release-queue/umbrella/body.test.ts similarity index 96% rename from scripts/lib/release-queue/umbrella-body.test.ts rename to scripts/lib/release-queue/umbrella/body.test.ts index bfc8b4aff4905..1d8c74727b09b 100644 --- a/scripts/lib/release-queue/umbrella-body.test.ts +++ b/scripts/lib/release-queue/umbrella/body.test.ts @@ -1,10 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { - formatUmbrellaBody, - parseReleaseMarker, - parseIncludedPullRequests, -} from './umbrella-body.ts'; +import { formatUmbrellaBody, parseIncludedPullRequests, parseReleaseMarker } from './body.ts'; describe('parseIncludedPullRequests', () => { it('reads the PR of each squash commit, in commit order', () => { diff --git a/scripts/lib/release-queue/umbrella-body.ts b/scripts/lib/release-queue/umbrella/body.ts similarity index 96% rename from scripts/lib/release-queue/umbrella-body.ts rename to scripts/lib/release-queue/umbrella/body.ts index 61b778c44c287..65839d3c4aef7 100644 --- a/scripts/lib/release-queue/umbrella-body.ts +++ b/scripts/lib/release-queue/umbrella/body.ts @@ -1,5 +1,5 @@ -import { FRAMEWORK_REPO, STABLE_BRANCH, RELEASE_TITLE_PREFIX } from './config.ts'; -import { formatFrameworkReference } from './github-text.ts'; +import { FRAMEWORK_REPO, RELEASE_TITLE_PREFIX, STABLE_BRANCH } from '../config.ts'; +import { formatFrameworkReference } from '../release/release.ts'; /** The title GitHub gives a squash commit: the PR title, then its number. */ const SQUASH_TITLE = /^(.*) \(#(\d+)\)$/; 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..8bfefb417df57 --- /dev/null +++ b/scripts/lib/release-queue/umbrella/branch.ts @@ -0,0 +1,34 @@ +// What the reserved prefix of umbrella branches means. + +import { 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 of the umbrella PR merging into a branch. + * + * @param {string} umbrellaBase - The branch the umbrella PR merges into. + * @returns {string} The umbrella branch, with its reserved prefix. + */ +export function getUmbrellaBranch(umbrellaBase: string): string { + return `${UMBRELLA_BRANCH_PREFIX}${umbrellaBase}`; +} diff --git a/scripts/lib/release-queue/umbrella-heading.test.ts b/scripts/lib/release-queue/umbrella/heading.test.ts similarity index 92% rename from scripts/lib/release-queue/umbrella-heading.test.ts rename to scripts/lib/release-queue/umbrella/heading.test.ts index be566ca9b15be..7baff4a795e21 100644 --- a/scripts/lib/release-queue/umbrella-heading.test.ts +++ b/scripts/lib/release-queue/umbrella/heading.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { getStableRelease } from './release.ts'; -import { getUmbrellaHeading } from './umbrella-heading.ts'; +import { getStableRelease } from '../release/release.ts'; +import { getUmbrellaHeading } from './heading.ts'; const releasePullRequest = (frameworkVersion?: string) => ({ number: 18459, diff --git a/scripts/lib/release-queue/umbrella-heading.ts b/scripts/lib/release-queue/umbrella/heading.ts similarity index 95% rename from scripts/lib/release-queue/umbrella-heading.ts rename to scripts/lib/release-queue/umbrella/heading.ts index 95bd2dafe5d4b..fadbbcd78432c 100644 --- a/scripts/lib/release-queue/umbrella-heading.ts +++ b/scripts/lib/release-queue/umbrella/heading.ts @@ -4,8 +4,8 @@ import { RELEASE_TITLE_PREFIX, STABLE_BRANCH, type ReleaseLabel, -} from './config.ts'; -import type { StableRelease } from './release.ts'; +} from '../config.ts'; +import type { StableRelease } from '../release/release.ts'; /** What an umbrella PR takes from the release it ships with. */ export interface UmbrellaHeading { diff --git a/scripts/lib/release-queue/umbrella-labels.test.ts b/scripts/lib/release-queue/umbrella/labels.test.ts similarity index 93% rename from scripts/lib/release-queue/umbrella-labels.test.ts rename to scripts/lib/release-queue/umbrella/labels.test.ts index 1e2b05fe05cc6..22654c4da27e7 100644 --- a/scripts/lib/release-queue/umbrella-labels.test.ts +++ b/scripts/lib/release-queue/umbrella/labels.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { getKeptLabelChanges, getLabelChanges } from './umbrella-labels.ts'; +import { getKeptLabelChanges, getLabelChanges } from './labels.ts'; describe('getLabelChanges', () => { it('adds merge-on-release and the release label', () => { diff --git a/scripts/lib/release-queue/umbrella-labels.ts b/scripts/lib/release-queue/umbrella/labels.ts similarity index 98% rename from scripts/lib/release-queue/umbrella-labels.ts rename to scripts/lib/release-queue/umbrella/labels.ts index edde448a88227..39d87352da1f5 100644 --- a/scripts/lib/release-queue/umbrella-labels.ts +++ b/scripts/lib/release-queue/umbrella/labels.ts @@ -3,7 +3,7 @@ import { FALLBACK_RELEASE_LABEL, MERGE_ON_RELEASE_LABEL, type ReleaseLabel, -} from './config.ts'; +} from '../config.ts'; /** Labels to add to an umbrella PR, and release labels to remove from it. */ export interface LabelChanges { diff --git a/scripts/lib/release-queue/merge-block.test.ts b/scripts/lib/release-queue/umbrella/merge-block.test.ts similarity index 95% rename from scripts/lib/release-queue/merge-block.test.ts rename to scripts/lib/release-queue/umbrella/merge-block.test.ts index 2debfa79b990b..3a6548732b6b9 100644 --- a/scripts/lib/release-queue/merge-block.test.ts +++ b/scripts/lib/release-queue/umbrella/merge-block.test.ts @@ -1,10 +1,10 @@ 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 { REPO } from './testing/fixtures.ts'; -import { readBody, stubGitHub } from './testing/stub-github.ts'; -import { formatUmbrellaBody } from './umbrella-body.ts'; -import type { UmbrellaPullRequest } from './umbrella-queries.ts'; +import type { UmbrellaPullRequest } from './queries.ts'; /** The `main` umbrella #42, linked to release PR #18405. */ const umbrella: UmbrellaPullRequest = { diff --git a/scripts/lib/release-queue/merge-block.ts b/scripts/lib/release-queue/umbrella/merge-block.ts similarity index 91% rename from scripts/lib/release-queue/merge-block.ts rename to scripts/lib/release-queue/umbrella/merge-block.ts index 43bb6e5073721..1385af2a90695 100644 --- a/scripts/lib/release-queue/merge-block.ts +++ b/scripts/lib/release-queue/umbrella/merge-block.ts @@ -2,15 +2,16 @@ import { FRAMEWORK_REPO, LUNARIA_DIRECTIVE, LUNARIA_DIRECTIVE_DOCS, - RELEASE_TITLE_PREFIX, MERGE_BLOCK_REVIEWER_LOGIN, + RELEASE_TITLE_PREFIX, TRANSLATIONS_LABEL, -} from './config.ts'; -import type { ReviewResponse } from './github-types.ts'; -import type { Repository } from './repository.ts'; -import { formatFrameworkReference, normalizeLineEndings } from './github-text.ts'; -import { parseReleaseMarker } from './umbrella-body.ts'; -import type { UmbrellaPullRequest } from './umbrella-queries.ts'; +} from '../config.ts'; +import { normalizeLineEndings } from '../github/client.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'; interface SubmittedReview extends ReviewResponse { id: number; diff --git a/scripts/lib/release-queue/umbrella-queries.ts b/scripts/lib/release-queue/umbrella/queries.ts similarity index 75% rename from scripts/lib/release-queue/umbrella-queries.ts rename to scripts/lib/release-queue/umbrella/queries.ts index d560ce4da771d..afb12a37f5fba 100644 --- a/scripts/lib/release-queue/umbrella-queries.ts +++ b/scripts/lib/release-queue/umbrella/queries.ts @@ -1,7 +1,7 @@ -import type { Repository } from './repository.ts'; -import type { PullRequestResponse } from './github-types.ts'; -import { getUmbrellaBase, isUmbrellaBranch } from './routing.ts'; -import { normalizeLineEndings } from './github-text.ts'; +import { normalizeLineEndings } from '../github/client.ts'; +import { compareBranches, type Repository } from '../github/repository.ts'; +import type { PullRequestResponse } from '../github/types.ts'; +import { getUmbrellaBase, isUmbrellaBranch } from './branch.ts'; /** An umbrella PR, with what the refresh reads from it. */ export interface UmbrellaPullRequest { @@ -88,3 +88,21 @@ export async function listOpenUmbrellas(repo: Repository): Promise(`pulls?state=open`); return pullRequests.filter((pr) => isUmbrellaPullRequest(pr, repo)).map(toUmbrellaPullRequest); } + +/** + * Lists the titles of the commits of a branch that aren't in another. + * + * @param {Repository} repo - The repository of both branches. + * @param {string} base - The branch to compare with. + * @param {string} head - The branch whose commits are listed. + * @returns {Promise} The first line of the message of each commit + * of `head` missing in `base`, oldest first. + */ +export async function listCommitTitlesAhead( + repo: Repository, + base: string, + head: string +): Promise { + const { commits } = await compareBranches(repo, base, head); + return commits.map(({ commit }) => commit.message.split('\n', 1)[0] ?? ''); +} diff --git a/scripts/lib/release-queue/refresh.test.ts b/scripts/lib/release-queue/umbrella/refresh.test.ts similarity index 96% rename from scripts/lib/release-queue/refresh.test.ts rename to scripts/lib/release-queue/umbrella/refresh.test.ts index c86ae2282a2a9..21a2b9cca2a96 100644 --- a/scripts/lib/release-queue/refresh.test.ts +++ b/scripts/lib/release-queue/umbrella/refresh.test.ts @@ -1,13 +1,13 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; -import { FRAMEWORK_REPO } from './config.ts'; +import { FRAMEWORK_REPO } from '../config.ts'; +import { getStableRelease } from '../release/release.ts'; +import { pullRequestResponse, REPO, umbrellaResponse } from '../testing/fixtures.ts'; +import { ACTIONS_TOKEN, BOT_TOKEN, queueOptions } from '../testing/queue-options.ts'; +import { stubGitHub, type StubOptions, type Write } from '../testing/stub-github.ts'; +import { formatUmbrellaBody } from './body.ts'; +import type { UmbrellaPullRequest } from './queries.ts'; import { refreshOpenUmbrellas, refreshUmbrella } from './refresh.ts'; -import { getStableRelease } from './release.ts'; -import { pullRequestResponse, REPO, umbrellaResponse } from './testing/fixtures.ts'; -import { ACTIONS_TOKEN, BOT_TOKEN, queueOptions } from './testing/queue-options.ts'; -import { stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; -import type { UmbrellaPullRequest } from './umbrella-queries.ts'; -import { formatUmbrellaBody } from './umbrella-body.ts'; /** The next release from `main`, with release PR `number` open. */ const releaseWith = (number: number, frameworkVersion: string) => diff --git a/scripts/lib/release-queue/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts similarity index 90% rename from scripts/lib/release-queue/refresh.ts rename to scripts/lib/release-queue/umbrella/refresh.ts index d76328eca2153..945954839c8fb 100644 --- a/scripts/lib/release-queue/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -1,24 +1,20 @@ -import { syncMergeBlockReview } from './merge-block.ts'; -import { isLiveFor } from './modes.ts'; -import { - getPrereleasePullRequest, - getReleasePullRequest, - listCommitTitlesAhead, - removeLabel, -} from './queries.ts'; -import { getStableRelease, RELEASE_TYPES, type StableRelease } from './release.ts'; -import { FRAMEWORK_STABLE_BRANCH, STABLE_BRANCH } from './config.ts'; -import { listOpenUmbrellas, type UmbrellaPullRequest } from './umbrella-queries.ts'; -import type { QueueOptions, Repositories, Repository } from './repository.ts'; -import { getReleaseState, type ReleaseState } from './umbrella-state.ts'; -import { formatUmbrellaBody, parseIncludedPullRequests } from './umbrella-body.ts'; -import { getUmbrellaHeading } from './umbrella-heading.ts'; +import { FRAMEWORK_STABLE_BRANCH, STABLE_BRANCH } from '../config.ts'; +import { removeLabel, type Repository } from '../github/repository.ts'; +import { isLiveFor } from '../modes.ts'; +import type { QueueOptions, Repositories } from '../options.ts'; +import { getPrereleasePullRequest, getReleasePullRequest } from '../release/queries.ts'; +import { getStableRelease, RELEASE_TYPES, type StableRelease } from '../release/release.ts'; +import { formatUmbrellaBody, parseIncludedPullRequests } from './body.ts'; +import { getUmbrellaHeading } from './heading.ts'; import { applyToLabels, getKeptLabelChanges, getLabelChanges, type LabelChanges, -} from './umbrella-labels.ts'; +} from './labels.ts'; +import { syncMergeBlockReview } from './merge-block.ts'; +import { listCommitTitlesAhead, listOpenUmbrellas, type UmbrellaPullRequest } from './queries.ts'; +import { getReleaseState, type ReleaseState } from './state.ts'; /** * Brings an umbrella PR up to date with its release and its included PRs, diff --git a/scripts/lib/release-queue/umbrella-state.ts b/scripts/lib/release-queue/umbrella/state.ts similarity index 80% rename from scripts/lib/release-queue/umbrella-state.ts rename to scripts/lib/release-queue/umbrella/state.ts index 552f35c33b81a..e3560f0390984 100644 --- a/scripts/lib/release-queue/umbrella-state.ts +++ b/scripts/lib/release-queue/umbrella/state.ts @@ -1,10 +1,20 @@ -import { isFrameworkPullRequestMerged } from './queries.ts'; -import type { StableRelease } from './release.ts'; -import { STABLE_BRANCH, STABLE_UMBRELLA_BRANCH } from './config.ts'; -import type { SettledUmbrella } from './routing.ts'; -import type { Repositories, Repository } from './repository.ts'; -import { findOpenUmbrella, type UmbrellaPullRequest } from './umbrella-queries.ts'; -import { parseReleaseMarker } from './umbrella-body.ts'; +import { STABLE_BRANCH, STABLE_UMBRELLA_BRANCH } from '../config.ts'; +import type { Repository } from '../github/repository.ts'; +import type { Repositories } from '../options.ts'; +import { isFrameworkPullRequestMerged } from '../release/queries.ts'; +import type { StableRelease } from '../release/release.ts'; +import { parseReleaseMarker } from './body.ts'; +import { findOpenUmbrella, type UmbrellaPullRequest } from './queries.ts'; + +/** + * The open stable umbrella once the release PR it ships with is merged, until + * the umbrella itself is merged. + */ +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: From c0f26fde1acce5795106c1bd9bcdcc4d7b2bf150 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Fri, 23 Oct 2026 15:37:46 +0200 Subject: [PATCH 09/35] more renaming and reorganization --- .../lib/release-queue/accepted/handle.test.ts | 16 ++--- scripts/lib/release-queue/accepted/handle.ts | 72 +++++++++---------- .../lib/release-queue/accepted/messages.ts | 2 +- scripts/lib/release-queue/cli.ts | 4 +- scripts/lib/release-queue/config.ts | 1 - .../lib/release-queue/github/client.test.ts | 3 +- scripts/lib/release-queue/github/client.ts | 27 ++++--- .../release-queue/github/printing-client.ts | 17 ----- .../lib/release-queue/github/repository.ts | 3 +- scripts/lib/release-queue/main.ts | 15 ++-- .../{options.ts => run-context.ts} | 7 +- .../{queue-options.ts => run-context.ts} | 6 +- .../lib/release-queue/testing/stub-github.ts | 3 +- scripts/lib/release-queue/umbrella/branch.ts | 4 +- .../lib/release-queue/umbrella/merge-block.ts | 25 +++---- scripts/lib/release-queue/umbrella/queries.ts | 51 ++++++++++++- .../release-queue/umbrella/refresh.test.ts | 14 ++-- scripts/lib/release-queue/umbrella/refresh.ts | 22 +++--- scripts/lib/release-queue/umbrella/state.ts | 5 +- 19 files changed, 161 insertions(+), 136 deletions(-) delete mode 100644 scripts/lib/release-queue/github/printing-client.ts rename scripts/lib/release-queue/{options.ts => run-context.ts} (78%) rename scripts/lib/release-queue/testing/{queue-options.ts => run-context.ts} (70%) diff --git a/scripts/lib/release-queue/accepted/handle.test.ts b/scripts/lib/release-queue/accepted/handle.test.ts index a8b5be42bb46f..47e9566d60b63 100644 --- a/scripts/lib/release-queue/accepted/handle.test.ts +++ b/scripts/lib/release-queue/accepted/handle.test.ts @@ -3,7 +3,7 @@ import { describe, it, type TestContext } from 'node:test'; import { GitHubError } from '../github/error.ts'; import type { Mode } from '../modes.ts'; import { pullRequestResponse, REPO, umbrellaResponse } from '../testing/fixtures.ts'; -import { ACTIONS_TOKEN, BOT_TOKEN, queueOptions } from '../testing/queue-options.ts'; +import { ACTIONS_TOKEN, BOT_TOKEN, createRunContext } from '../testing/run-context.ts'; import { readBody, stubGitHub, type StubOptions, type Write } from '../testing/stub-github.ts'; import { formatUmbrellaBody } from '../umbrella/body.ts'; import { handleAcceptedPullRequest } from './handle.ts'; @@ -81,7 +81,7 @@ const stubReadyPullRequest = (t: TestContext, options: StubOptions = {}) => /** 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(queueOptions(github, mode), 7, sender); + await handleAcceptedPullRequest(createRunContext(github, mode), 7, sender); return { actionsWrites: github.writesBy(ACTIONS_TOKEN), botWrites: github.writesBy(BOT_TOKEN) }; } @@ -210,7 +210,7 @@ describe('handleAcceptedPullRequest', () => { it("doesn't read the prerelease line for a PR shipping to main", async (t) => { const github = stubReadyPullRequest(t); - await handleAcceptedPullRequest(queueOptions(github, 'stable-only'), 7, 'maintainer'); + await handleAcceptedPullRequest(createRunContext(github, 'stable-only'), 7, 'maintainer'); assert.ok(!github.calls.some(({ path }) => path.includes('.changeset/pre.json'))); }); @@ -233,7 +233,7 @@ describe('handleAcceptedPullRequest', () => { it(`doesn't read the prerelease line for ${name} of a prerelease PR`, async (t) => { const github = stubReadyPullRequest(t, { getResponses }); - await handleAcceptedPullRequest(queueOptions(github, mode), 7, 'maintainer'); + await handleAcceptedPullRequest(createRunContext(github, mode), 7, 'maintainer'); assert.ok(github.writes.some(({ path }) => path.endsWith('/comments'))); assert.ok(!github.calls.some(({ path }) => path.includes('.changeset/pre.json'))); @@ -324,7 +324,7 @@ describe('handleAcceptedPullRequest', () => { getResponses: { ...settledUmbrella(), [paths.pr]: pr }, }); - await handleAcceptedPullRequest(queueOptions(github, 'dry-run'), 7, 'maintainer'); + await handleAcceptedPullRequest(createRunContext(github, 'dry-run'), 7, 'maintainer'); assert.equal(github.countReads('repos/withastro/astro/pulls/18405'), 0); }); @@ -338,7 +338,7 @@ describe('handleAcceptedPullRequest', () => { }, }); - await handleAcceptedPullRequest(queueOptions(github, 'all'), 7, 'maintainer'); + await handleAcceptedPullRequest(createRunContext(github, 'all'), 7, 'maintainer'); assert.ok(writtenPaths(github.writes).includes(`PUT repos/${REPO}/pulls/7/merge`)); assert.equal(github.countReads('repos/withastro/astro/pulls/18405'), 0); @@ -367,7 +367,7 @@ describe('handleAcceptedPullRequest', () => { }); await assert.rejects( - handleAcceptedPullRequest(queueOptions(github, 'stable-only'), 7, 'maintainer'), + handleAcceptedPullRequest(createRunContext(github, 'stable-only'), 7, 'maintainer'), (error: unknown) => { assert.ok(error instanceof Error); assert.match(error.message, /but the umbrella PR couldn't be opened\.$/); @@ -388,7 +388,7 @@ describe('handleAcceptedPullRequest', () => { const github = stubReadyPullRequest(t, { getResponses: responses }); await assert.rejects( - handleAcceptedPullRequest(queueOptions(github, 'stable-only'), 7, 'maintainer'), + handleAcceptedPullRequest(createRunContext(github, 'stable-only'), 7, 'maintainer'), { name: 'GitHubError', status: 404 } ); assert.match(commentBody(github.writesBy(BOT_TOKEN)), /squash-merged into/); diff --git a/scripts/lib/release-queue/accepted/handle.ts b/scripts/lib/release-queue/accepted/handle.ts index 97caa793de63e..c51a23b779fa9 100644 --- a/scripts/lib/release-queue/accepted/handle.ts +++ b/scripts/lib/release-queue/accepted/handle.ts @@ -1,11 +1,10 @@ -import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH, STABLE_UMBRELLA_BRANCH } from '../config.ts'; -import type { Repository } from '../github/repository.ts'; -import { removeLabel } from '../github/repository.ts'; +import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH } from '../config.ts'; +import { removeLabel, type Repository } from '../github/repository.ts'; import { isLiveFor } from '../modes.ts'; -import type { QueueOptions } from '../options.ts'; import { getReleasePullRequest } from '../release/queries.ts'; import { getStableRelease, type StableRelease } from '../release/release.ts'; -import { getUmbrellaBase } from '../umbrella/branch.ts'; +import type { RunContext } from '../run-context.ts'; +import { getUmbrellaBase, STABLE_UMBRELLA_BRANCH } from '../umbrella/branch.ts'; import { getUmbrellaHeading } from '../umbrella/heading.ts'; import { syncMergeBlockReview } from '../umbrella/merge-block.ts'; import { findOpenUmbrella, getUmbrella, type Umbrella } from '../umbrella/queries.ts'; @@ -51,27 +50,27 @@ type JoinDecision = Extract; * Joins a docs PR labeled as accepted to its umbrella, refuses it, or * comments on it as a dry run. * - * @param {QueueOptions} options - The repositories and the mode of the run. + * @param {RunContext} run - The repositories and the mode of the run. * @param {number} prNumber - The number of the docs PR. * @param {string} [labelSender] - Who added the label. Without it, the write * access check is skipped, as in a local run. */ export async function handleAcceptedPullRequest( - options: QueueOptions, + run: RunContext, prNumber: number, labelSender?: string ) { - if (!(await isSenderAllowed(options.actions, labelSender))) return; + if (!(await isSenderAllowed(run.actions, labelSender))) return; - const accepted = await readAcceptedPullRequest(options, prNumber); + const accepted = await readAcceptedPullRequest(run, prNumber); const { pr, release, decision } = accepted; console.info(JSON.stringify({ pr: pr.url, release, decision }, null, 2)); - if (!isLiveFor(options.mode, getUmbrellaBase(pr.base))) { - await commentDryRun(options, accepted); + if (!isLiveFor(run.mode, getUmbrellaBase(pr.base))) { + await commentDryRun(run, accepted); } else if (decision.status === 'refuse') { - await refuse(options, { ...accepted, decision }); + await refuse(run, { ...accepted, decision }); } else { - await joinAndAnnounce(options, { ...accepted, decision }); + await joinAndAnnounce(run, { ...accepted, decision }); } } @@ -94,7 +93,7 @@ async function isSenderAllowed( * release, and the settled stable umbrella. */ async function readAcceptedPullRequest( - { actions, framework }: QueueOptions, + { actions, framework }: RunContext, prNumber: number ): Promise { const pr = await getPullRequest(actions, prNumber); @@ -110,51 +109,44 @@ async function readAcceptedPullRequest( return { pr, release, decision }; } -async function commentDryRun( - options: QueueOptions, - { pr, release, decision }: AcceptedPullRequest -) { +async function commentDryRun(run: RunContext, { pr, release, decision }: AcceptedPullRequest) { if (decision.status === 'join') { - const umbrella = await findOrPlanUmbrella(options, decision.route, release); - await postComment( - options.actions, - pr.number, - formatDryRunJoinComment(decision.route, umbrella) - ); + const umbrella = await findOrPlanUmbrella(run, decision.route, release); + await postComment(run.actions, pr.number, formatDryRunJoinComment(decision.route, umbrella)); return; } - const context = await getMessageContext(options, decision, release); - await postComment(options.actions, pr.number, formatDryRunRefusalComment(decision, context)); + const context = await getMessageContext(run, decision, release); + await postComment(run.actions, pr.number, formatDryRunRefusalComment(decision, context)); } async function refuse( - options: QueueOptions, + run: RunContext, { pr, release, decision }: AcceptedPullRequest ) { - const context = await getMessageContext(options, decision, release); + const context = await getMessageContext(run, decision, release); // Removed first, so the comment is true once posted. - await removeLabel(options.bot, pr.number, ACCEPTED_LABEL); - await postComment(options.bot, pr.number, formatRefusalComment(decision, context)); + await removeLabel(run.bot, pr.number, ACCEPTED_LABEL); + await postComment(run.bot, pr.number, formatRefusalComment(decision, context)); } /** Joins the PR to its umbrella, then says how it went on the PR. */ -async function joinAndAnnounce(options: QueueOptions, accepted: AcceptedPullRequest) { +async function joinAndAnnounce(run: RunContext, accepted: AcceptedPullRequest) { const { pr, release, decision } = accepted; const { route } = decision; const { title } = getUmbrellaHeading(route.umbrellaBase, release); - const result = await joinUmbrella(options.bot, { pr, route, umbrellaTitle: title }); + const result = await joinUmbrella(run.bot, { pr, route, umbrellaTitle: title }); switch (result.status) { case 'joined': - await announceJoin(options, accepted, result.umbrella); + await announceJoin(run, accepted, result.umbrella); return; case 'refused': { const refused: RefusedDecision = { status: 'refuse', route, refusals: [result.refusal] }; - await refuse(options, { ...accepted, decision: refused }); + await refuse(run, { ...accepted, decision: refused }); return; } case 'unopened': - await postComment(options.bot, pr.number, formatUnopenedJoinComment(route, result)); + await postComment(run.bot, 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.`, @@ -172,7 +164,7 @@ async function joinAndAnnounce(options: QueueOptions, accepted: AcceptedPullRequ * blocks its merge until a maintainer checks it. */ async function announceJoin( - { actions, bot, framework }: QueueOptions, + { actions, bot, framework }: RunContext, { pr, release, decision }: AcceptedPullRequest, umbrella: Umbrella ) { @@ -186,7 +178,7 @@ async function announceJoin( /** Finds the open umbrella PR of `route`, or names the one to create. */ async function findOrPlanUmbrella( - { actions }: QueueOptions, + { actions }: RunContext, route: Route, release: StableRelease ): Promise { @@ -196,13 +188,13 @@ async function findOrPlanUmbrella( } async function getMessageContext( - options: QueueOptions, + run: RunContext, decision: RefusedDecision, release: StableRelease ): Promise { const [umbrella, stableUmbrella] = await Promise.all([ - decision.route && findOrPlanUmbrella(options, decision.route, release), - findStableUmbrellaToMove(options.actions, decision), + decision.route && findOrPlanUmbrella(run, decision.route, release), + findStableUmbrellaToMove(run.actions, decision), ]); return { umbrella, stableUmbrella }; } diff --git a/scripts/lib/release-queue/accepted/messages.ts b/scripts/lib/release-queue/accepted/messages.ts index 5500129c97011..866fb26fcfa77 100644 --- a/scripts/lib/release-queue/accepted/messages.ts +++ b/scripts/lib/release-queue/accepted/messages.ts @@ -4,10 +4,10 @@ import { FRAMEWORK_STABLE_BRANCH, RELEASE_LABELS, STABLE_BRANCH, - STABLE_UMBRELLA_BRANCH, } from '../config.ts'; import { GitHubError } from '../github/error.ts'; import { formatFrameworkReference } from '../release/release.ts'; +import { STABLE_UMBRELLA_BRANCH } from '../umbrella/branch.ts'; import type { Umbrella } from '../umbrella/queries.ts'; import type { UnopenedJoin } from './join.ts'; import type { Refusal, RefusalCode, RefusedDecision, Route } from './routing.ts'; diff --git a/scripts/lib/release-queue/cli.ts b/scripts/lib/release-queue/cli.ts index f214642cbf4a4..20591549a6911 100644 --- a/scripts/lib/release-queue/cli.ts +++ b/scripts/lib/release-queue/cli.ts @@ -9,8 +9,8 @@ export type Action = { type: 'handle'; prNumber: number } | { type: 'refresh' }; * Chooses what a run does, from an explicit signal. * * A `workflow_dispatch` event, or `isRefreshRequested` in a local run, - * refreshes the open - * umbrellas. Otherwise, the run handles `pr`, or the PR of the event. + * refreshes the open umbrellas. Otherwise, the run handles `pr`, or the PR of + * the event. * * @param {object} signals - The flags and the event of the run. * @param {string} [signals.pr] - The value of `--pr`. diff --git a/scripts/lib/release-queue/config.ts b/scripts/lib/release-queue/config.ts index 8174eb98d6ca2..1fe9ce1b835d7 100644 --- a/scripts/lib/release-queue/config.ts +++ b/scripts/lib/release-queue/config.ts @@ -13,7 +13,6 @@ export const RELEASE_TITLE_PREFIX = '[ci] release'; export const STABLE_BRANCH = 'main'; export const UMBRELLA_BRANCH_PREFIX = 'release-queue/'; -export const STABLE_UMBRELLA_BRANCH = `${UMBRELLA_BRANCH_PREFIX}${STABLE_BRANCH}`; /** The label meaning a docs PR ships with the next release. */ export const ACCEPTED_LABEL = 'accepted'; diff --git a/scripts/lib/release-queue/github/client.test.ts b/scripts/lib/release-queue/github/client.test.ts index f69baccf984f9..2e20a659424dd 100644 --- a/scripts/lib/release-queue/github/client.test.ts +++ b/scripts/lib/release-queue/github/client.test.ts @@ -1,9 +1,8 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; import { json, stubGitHub } from '../testing/stub-github.ts'; -import { GitHubClient } from './client.ts'; +import { GitHubClient, PrintingGitHubClient } from './client.ts'; import { GitHubError } from './error.ts'; -import { PrintingGitHubClient } from './printing-client.ts'; /** Replaces `fetch` with one answering `response` to every request. */ function answer(t: TestContext, response: Response) { diff --git a/scripts/lib/release-queue/github/client.ts b/scripts/lib/release-queue/github/client.ts index 0d11658aa2350..11fb70f7b17a5 100644 --- a/scripts/lib/release-queue/github/client.ts +++ b/scripts/lib/release-queue/github/client.ts @@ -131,6 +131,22 @@ export class GitHubClient { } } +/** + * 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: a 422 only says * "Validation Failed" in `message`, and details it in `errors`. @@ -157,14 +173,3 @@ function parseErrorBody(text: string): unknown { function readErrorMessage(error: unknown): string | undefined { return isRecord(error) && typeof error.message === 'string' ? error.message : undefined; } - -/** - * Replaces the `\r\n` line endings GitHub keeps from a description or review - * edited in the browser with `\n`. - * - * @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'); -} diff --git a/scripts/lib/release-queue/github/printing-client.ts b/scripts/lib/release-queue/github/printing-client.ts deleted file mode 100644 index 6d86082ed18d9..0000000000000 --- a/scripts/lib/release-queue/github/printing-client.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { GitHubClient, type WriteMethod } from './client.ts'; - -/** - * 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; - } -} diff --git a/scripts/lib/release-queue/github/repository.ts b/scripts/lib/release-queue/github/repository.ts index fe9788297a3b1..4f5b6928af2f0 100644 --- a/scripts/lib/release-queue/github/repository.ts +++ b/scripts/lib/release-queue/github/repository.ts @@ -50,7 +50,8 @@ export class Repository { * @param {Repository} repo - The repository of both branches. * @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`. + * @returns {Promise} The commits of `head` missing in + * `base`, and their count. */ export function compareBranches( repo: Repository, diff --git a/scripts/lib/release-queue/main.ts b/scripts/lib/release-queue/main.ts index 339617b8f842a..7e736388e5391 100644 --- a/scripts/lib/release-queue/main.ts +++ b/scripts/lib/release-queue/main.ts @@ -1,12 +1,11 @@ import { readFile } from 'node:fs/promises'; +import { handleAcceptedPullRequest } from './accepted/handle.ts'; import { chooseAction, parseEvent, type RunEvent } from './cli.ts'; import { DOCS_REPO, FRAMEWORK_REPO } from './config.ts'; -import { GitHubClient } from './github/client.ts'; -import { PrintingGitHubClient } from './github/printing-client.ts'; +import { GitHubClient, PrintingGitHubClient } from './github/client.ts'; import { Repository } from './github/repository.ts'; -import { handleAcceptedPullRequest } from './accepted/handle.ts'; import { isDryRun, parseMode } from './modes.ts'; -import type { QueueOptions } from './options.ts'; +import type { RunContext } from './run-context.ts'; import { refreshOpenUmbrellas } from './umbrella/refresh.ts'; /** The command-line flags of `scripts/release-queue.ts`. */ @@ -46,7 +45,7 @@ export interface Env { * flags and the event don't say what to do. */ export async function runReleaseQueue({ flags, env }: { flags: Flags; env: Env }) { - const options = createOptions(flags, env); + const run = createRunContext(flags, env); const event = await readEvent(env.GITHUB_EVENT_PATH); const action = chooseAction({ pr: flags.pr, @@ -56,16 +55,16 @@ export async function runReleaseQueue({ flags, env }: { flags: Flags; env: Env } }); if (action.type === 'refresh') { - await refreshOpenUmbrellas(options); + await refreshOpenUmbrellas(run); } else { const labelSender = getLabelSender(flags, env, event); - await handleAcceptedPullRequest(options, action.prNumber, labelSender); + await handleAcceptedPullRequest(run, action.prNumber, labelSender); } } const isGitHubActions = (env: Env) => env.GITHUB_ACTIONS === 'true'; -function createOptions(flags: Flags, env: Env): QueueOptions { +function createRunContext(flags: Flags, env: Env): RunContext { const mode = parseMode(env.MODE); const token = env.GITHUB_TOKEN; if (!token) throw new Error('GITHUB_TOKEN is required.'); diff --git a/scripts/lib/release-queue/options.ts b/scripts/lib/release-queue/run-context.ts similarity index 78% rename from scripts/lib/release-queue/options.ts rename to scripts/lib/release-queue/run-context.ts index 478973a42cf4f..4691b5f19ca74 100644 --- a/scripts/lib/release-queue/options.ts +++ b/scripts/lib/release-queue/run-context.ts @@ -7,8 +7,11 @@ export interface Repositories { framework: Repository; } -/** What the bot runs with, whether it handles a PR or refreshes umbrellas. */ -export interface QueueOptions { +/** + * What a run works with, whether it handles a PR or refreshes umbrellas: the + * repositories it acts through, and the mode. + */ +export interface RunContext { /** * The docs repository through `GITHUB_TOKEN`: reads the PR, posts dry-run * comments, and submits merge-block reviews. diff --git a/scripts/lib/release-queue/testing/queue-options.ts b/scripts/lib/release-queue/testing/run-context.ts similarity index 70% rename from scripts/lib/release-queue/testing/queue-options.ts rename to scripts/lib/release-queue/testing/run-context.ts index 932d926063f6e..e2bf9aa8a655a 100644 --- a/scripts/lib/release-queue/testing/queue-options.ts +++ b/scripts/lib/release-queue/testing/run-context.ts @@ -1,6 +1,6 @@ import { FRAMEWORK_REPO } from '../config.ts'; import type { Mode } from '../modes.ts'; -import type { QueueOptions } from '../options.ts'; +import type { RunContext } from '../run-context.ts'; import { REPO } from './fixtures.ts'; import type { stubGitHub } from './stub-github.ts'; @@ -8,8 +8,8 @@ import type { stubGitHub } from './stub-github.ts'; export const ACTIONS_TOKEN = 'actions-token'; export const BOT_TOKEN = 'bot-token'; -/** Options for both entry points, with a repository per identity. */ -export function queueOptions(github: ReturnType, mode: Mode): QueueOptions { +/** A run context for both entry points, with a repository per identity. */ +export function createRunContext(github: ReturnType, mode: Mode): RunContext { return { actions: github.repository(REPO, { token: ACTIONS_TOKEN }), bot: github.repository(REPO, { token: BOT_TOKEN }), diff --git a/scripts/lib/release-queue/testing/stub-github.ts b/scripts/lib/release-queue/testing/stub-github.ts index 787b0251e7a5a..7330777d961aa 100644 --- a/scripts/lib/release-queue/testing/stub-github.ts +++ b/scripts/lib/release-queue/testing/stub-github.ts @@ -1,7 +1,6 @@ import type { TestContext } from 'node:test'; -import { GitHubClient } from '../github/client.ts'; +import { GitHubClient, PrintingGitHubClient } from '../github/client.ts'; import { isRecord } from '../github/json.ts'; -import { PrintingGitHubClient } from '../github/printing-client.ts'; import { Repository } from '../github/repository.ts'; import { REPO } from './fixtures.ts'; diff --git a/scripts/lib/release-queue/umbrella/branch.ts b/scripts/lib/release-queue/umbrella/branch.ts index 8bfefb417df57..edfcfe09dd2f6 100644 --- a/scripts/lib/release-queue/umbrella/branch.ts +++ b/scripts/lib/release-queue/umbrella/branch.ts @@ -1,6 +1,6 @@ // What the reserved prefix of umbrella branches means. -import { UMBRELLA_BRANCH_PREFIX } from '../config.ts'; +import { STABLE_BRANCH, UMBRELLA_BRANCH_PREFIX } from '../config.ts'; /** * Whether a branch is an umbrella branch, by its reserved prefix. @@ -32,3 +32,5 @@ export function getUmbrellaBase(base: string): string { export function getUmbrellaBranch(umbrellaBase: string): string { return `${UMBRELLA_BRANCH_PREFIX}${umbrellaBase}`; } + +export const STABLE_UMBRELLA_BRANCH = getUmbrellaBranch(STABLE_BRANCH); diff --git a/scripts/lib/release-queue/umbrella/merge-block.ts b/scripts/lib/release-queue/umbrella/merge-block.ts index 1385af2a90695..31f137ee64391 100644 --- a/scripts/lib/release-queue/umbrella/merge-block.ts +++ b/scripts/lib/release-queue/umbrella/merge-block.ts @@ -6,17 +6,15 @@ import { RELEASE_TITLE_PREFIX, TRANSLATIONS_LABEL, } from '../config.ts'; -import { normalizeLineEndings } from '../github/client.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'; - -interface SubmittedReview extends ReviewResponse { - id: number; - body: string; -} +import { + listOpenPullRequestsTargeting, + listUmbrellaReviews, + type SubmittedReview, + type UmbrellaPullRequest, +} from './queries.ts'; /** * Writes the body of the merge-block review: the checklist a maintainer goes @@ -86,16 +84,13 @@ export async function syncMergeBlockReview( { shouldBlock }: { shouldBlock: boolean } ) { const [reviews, openPullRequests] = await Promise.all([ - repo.getAll(`pulls/${umbrella.number}/reviews`), - repo.getAll<{ number: number }>(`pulls?state=open&base=${umbrella.branch}`), + listUmbrellaReviews(repo, umbrella.number), + listOpenPullRequestsTargeting(repo, umbrella.branch), ]); - const body = formatMergeBlockBody( - umbrella, - openPullRequests.map(({ number }) => number) - ); + const body = formatMergeBlockBody(umbrella, openPullRequests); const active = findActiveReview(reviews); if (active) { - if (normalizeLineEndings(active.body) !== body) { + if (active.body !== body) { await repo.write('PUT', `pulls/${umbrella.number}/reviews/${active.id}`, { body }); } } else if (shouldBlock) { diff --git a/scripts/lib/release-queue/umbrella/queries.ts b/scripts/lib/release-queue/umbrella/queries.ts index afb12a37f5fba..9d5033c7c48fb 100644 --- a/scripts/lib/release-queue/umbrella/queries.ts +++ b/scripts/lib/release-queue/umbrella/queries.ts @@ -1,6 +1,5 @@ -import { normalizeLineEndings } from '../github/client.ts'; import { compareBranches, type Repository } from '../github/repository.ts'; -import type { PullRequestResponse } from '../github/types.ts'; +import type { PullRequestResponse, ReviewResponse } from '../github/types.ts'; import { getUmbrellaBase, isUmbrellaBranch } from './branch.ts'; /** An umbrella PR, with what the refresh reads from it. */ @@ -20,6 +19,12 @@ export interface UmbrellaPullRequest { */ export type Umbrella = { status: 'open'; number: number } | { status: 'planned'; title: string }; +/** A review of an umbrella PR, with its body. */ +export interface SubmittedReview extends ReviewResponse { + id: number; + body: string; +} + /** * Whether a PR is an umbrella PR: from an umbrella branch of `repo` into the * branch the name implies. @@ -32,6 +37,17 @@ function isUmbrellaPullRequest({ base, head }: PullRequestResponse, repo: Reposi ); } +/** + * Replaces the `\r\n` line endings GitHub keeps from a description or review + * edited in the browser with `\n`. + * + * @param {string} text - A description or a review body read from GitHub. + * @returns {string} The text with `\n` line endings only. + */ +function normalizeLineEndings(text: string): string { + return text.replaceAll('\r\n', '\n'); +} + function toUmbrellaPullRequest(data: PullRequestResponse): UmbrellaPullRequest { return { number: data.number, @@ -106,3 +122,34 @@ export async function listCommitTitlesAhead( const { commits } = await compareBranches(repo, base, head); return commits.map(({ commit }) => commit.message.split('\n', 1)[0] ?? ''); } + +/** + * 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. + */ +export 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. + */ +export async function listOpenPullRequestsTargeting( + repo: Repository, + branch: string +): Promise { + const pullRequests = await repo.getAll<{ number: number }>(`pulls?state=open&base=${branch}`); + return pullRequests.map(({ number }) => number); +} diff --git a/scripts/lib/release-queue/umbrella/refresh.test.ts b/scripts/lib/release-queue/umbrella/refresh.test.ts index 21a2b9cca2a96..01c97950e3600 100644 --- a/scripts/lib/release-queue/umbrella/refresh.test.ts +++ b/scripts/lib/release-queue/umbrella/refresh.test.ts @@ -3,7 +3,7 @@ import { describe, it, type TestContext } from 'node:test'; import { FRAMEWORK_REPO } from '../config.ts'; import { getStableRelease } from '../release/release.ts'; import { pullRequestResponse, REPO, umbrellaResponse } from '../testing/fixtures.ts'; -import { ACTIONS_TOKEN, BOT_TOKEN, queueOptions } from '../testing/queue-options.ts'; +import { ACTIONS_TOKEN, BOT_TOKEN, createRunContext } from '../testing/run-context.ts'; import { stubGitHub, type StubOptions, type Write } from '../testing/stub-github.ts'; import { formatUmbrellaBody } from './body.ts'; import type { UmbrellaPullRequest } from './queries.ts'; @@ -313,7 +313,7 @@ describe('refreshOpenUmbrellas', () => { }, }); - await refreshOpenUmbrellas(queueOptions(github, 'stable-only')); + await refreshOpenUmbrellas(createRunContext(github, 'stable-only')); assert.deepEqual(github.writes, []); }); @@ -321,7 +321,7 @@ describe('refreshOpenUmbrellas', () => { it("doesn't change anything in a dry run", async (t) => { const github = stubGitHub(t, { getResponses: openPullRequests }); - await refreshOpenUmbrellas(queueOptions(github, 'dry-run')); + await refreshOpenUmbrellas(createRunContext(github, 'dry-run')); assert.deepEqual(github.writes, []); }); @@ -329,7 +329,7 @@ describe('refreshOpenUmbrellas', () => { it('refreshes only the main umbrella in stable-only mode', async (t) => { const github = stubGitHub(t, { getResponses: openPullRequests }); - await refreshOpenUmbrellas(queueOptions(github, 'stable-only')); + await refreshOpenUmbrellas(createRunContext(github, 'stable-only')); assert.deepEqual(refreshedUmbrellas(github.writesBy(BOT_TOKEN)), [42]); // Without a join, no merge-block review is requested. @@ -353,7 +353,7 @@ describe('refreshOpenUmbrellas', () => { }, }); - await refreshOpenUmbrellas(queueOptions(github, 'stable-only')); + await refreshOpenUmbrellas(createRunContext(github, 'stable-only')); assert.deepEqual( github.writesBy(ACTIONS_TOKEN).map(({ method, path }) => `${method} ${path}`), @@ -364,7 +364,7 @@ describe('refreshOpenUmbrellas', () => { it('refreshes every umbrella in all mode, but not a fork or another base', async (t) => { const github = stubGitHub(t, { getResponses: openPullRequests }); - await refreshOpenUmbrellas(queueOptions(github, 'all')); + await refreshOpenUmbrellas(createRunContext(github, 'all')); assert.deepEqual(refreshedUmbrellas(github.writesBy(BOT_TOKEN)), [42, 43]); }); @@ -375,7 +375,7 @@ describe('refreshOpenUmbrellas', () => { writeFailures: { [`PATCH repos/${REPO}/pulls/42`]: { status: 500, message: 'Oops' } }, }); - await assert.rejects(refreshOpenUmbrellas(queueOptions(github, 'all')), (error) => { + await assert.rejects(refreshOpenUmbrellas(createRunContext(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\./); diff --git a/scripts/lib/release-queue/umbrella/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts index 945954839c8fb..b210e5f84cffd 100644 --- a/scripts/lib/release-queue/umbrella/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -1,9 +1,9 @@ import { FRAMEWORK_STABLE_BRANCH, STABLE_BRANCH } from '../config.ts'; import { removeLabel, type Repository } from '../github/repository.ts'; import { isLiveFor } from '../modes.ts'; -import type { QueueOptions, Repositories } from '../options.ts'; import { getPrereleasePullRequest, getReleasePullRequest } from '../release/queries.ts'; import { getStableRelease, RELEASE_TYPES, type StableRelease } from '../release/release.ts'; +import type { Repositories, RunContext } from '../run-context.ts'; import { formatUmbrellaBody, parseIncludedPullRequests } from './body.ts'; import { getUmbrellaHeading } from './heading.ts'; import { @@ -120,12 +120,12 @@ async function applyLabelChanges(docs: Repository, umbrellaNumber: number, chang * Refreshes every open umbrella PR the mode acts on, for example after a * change to a release PR. * - * @param {QueueOptions} options - The repositories and the mode of the run. + * @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(options: QueueOptions) { - const { actions, framework, mode } = options; +export async function refreshOpenUmbrellas(run: RunContext) { + const { actions, framework, mode } = run; const [umbrellas, releasePullRequest] = await Promise.all([ listOpenUmbrellas(actions), getReleasePullRequest(framework, FRAMEWORK_STABLE_BRANCH), @@ -134,17 +134,17 @@ export async function refreshOpenUmbrellas(options: QueueOptions) { const numbers = umbrellas.map(({ number }) => `#${number}`).join(', '); console.info(`Open umbrellas: ${numbers || 'none'}.`); - const liveUmbrellas = umbrellas.filter((umbrella) => isLiveFor(mode, umbrella.base)); - for (const umbrella of umbrellas) { - if (!liveUmbrellas.includes(umbrella)) { - console.info(`Skipped #${umbrella.number}: dry run for ${umbrella.base}.`); - } + const { live: liveUmbrellas = [], skipped = [] } = Object.groupBy(umbrellas, (umbrella) => + isLiveFor(mode, umbrella.base) ? 'live' : 'skipped' + ); + for (const umbrella of skipped) { + console.info(`Skipped #${umbrella.number}: dry run for ${umbrella.base}.`); } 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 refreshAndSyncReview(options, umbrella, release); + const failure = await refreshAndSyncReview(run, umbrella, release); if (failure) failures.push(failure); } if (failures.length > 0) { @@ -160,7 +160,7 @@ export async function refreshOpenUmbrellas(options: QueueOptions) { * the other umbrellas still get refreshed. */ async function refreshAndSyncReview( - { actions, bot, framework }: QueueOptions, + { actions, bot, framework }: RunContext, umbrella: UmbrellaPullRequest, release: StableRelease ): Promise { diff --git a/scripts/lib/release-queue/umbrella/state.ts b/scripts/lib/release-queue/umbrella/state.ts index e3560f0390984..e3313cc440c70 100644 --- a/scripts/lib/release-queue/umbrella/state.ts +++ b/scripts/lib/release-queue/umbrella/state.ts @@ -1,9 +1,10 @@ -import { STABLE_BRANCH, STABLE_UMBRELLA_BRANCH } from '../config.ts'; +import { STABLE_BRANCH } from '../config.ts'; import type { Repository } from '../github/repository.ts'; -import type { Repositories } from '../options.ts'; import { isFrameworkPullRequestMerged } from '../release/queries.ts'; import type { StableRelease } from '../release/release.ts'; +import type { Repositories } from '../run-context.ts'; import { parseReleaseMarker } from './body.ts'; +import { STABLE_UMBRELLA_BRANCH } from './branch.ts'; import { findOpenUmbrella, type UmbrellaPullRequest } from './queries.ts'; /** From 287978ce98b16eb92312aa57e27750658c26ff91 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Fri, 23 Oct 2026 22:30:51 +0200 Subject: [PATCH 10/35] Another cleaning pass - List the umbrella's PRs by number because GitHub already shows their titles - Read only the numbers from the squash commits - Split the umbrella title and release label - Derive the release labels from their types - Extract the long refusal descriptions - Reword some doc comments --- scripts/lib/release-queue/accepted/handle.ts | 10 +-- scripts/lib/release-queue/accepted/join.ts | 13 ++- .../lib/release-queue/accepted/messages.ts | 62 ++++++++++---- .../lib/release-queue/accepted/readiness.ts | 6 +- .../release-queue/accepted/routing.test.ts | 4 +- scripts/lib/release-queue/accepted/routing.ts | 12 ++- scripts/lib/release-queue/cli.ts | 15 ++-- scripts/lib/release-queue/config.ts | 17 ++-- scripts/lib/release-queue/github/client.ts | 11 ++- scripts/lib/release-queue/main.ts | 12 +-- scripts/lib/release-queue/modes.test.ts | 12 +-- scripts/lib/release-queue/modes.ts | 12 +-- scripts/lib/release-queue/release/release.ts | 19 ++--- scripts/lib/release-queue/run-context.ts | 6 +- .../lib/release-queue/umbrella/body.test.ts | 42 ++------- scripts/lib/release-queue/umbrella/body.ts | 52 ++---------- scripts/lib/release-queue/umbrella/branch.ts | 2 +- .../release-queue/umbrella/heading.test.ts | 45 +++++----- scripts/lib/release-queue/umbrella/heading.ts | 63 +++++++------- scripts/lib/release-queue/umbrella/labels.ts | 12 +-- .../lib/release-queue/umbrella/merge-block.ts | 8 +- .../release-queue/umbrella/queries.test.ts | 35 ++++++++ scripts/lib/release-queue/umbrella/queries.ts | 48 ++++++----- .../release-queue/umbrella/refresh.test.ts | 2 +- scripts/lib/release-queue/umbrella/refresh.ts | 85 +++++++++---------- scripts/lib/release-queue/umbrella/state.ts | 10 +-- 26 files changed, 305 insertions(+), 310 deletions(-) create mode 100644 scripts/lib/release-queue/umbrella/queries.test.ts diff --git a/scripts/lib/release-queue/accepted/handle.ts b/scripts/lib/release-queue/accepted/handle.ts index c51a23b779fa9..4beda6f2a7024 100644 --- a/scripts/lib/release-queue/accepted/handle.ts +++ b/scripts/lib/release-queue/accepted/handle.ts @@ -1,11 +1,11 @@ import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH } from '../config.ts'; import { removeLabel, type Repository } from '../github/repository.ts'; -import { isLiveFor } from '../modes.ts'; +import { canActOn } from '../modes.ts'; import { getReleasePullRequest } from '../release/queries.ts'; import { getStableRelease, type StableRelease } from '../release/release.ts'; import type { RunContext } from '../run-context.ts'; import { getUmbrellaBase, STABLE_UMBRELLA_BRANCH } from '../umbrella/branch.ts'; -import { getUmbrellaHeading } from '../umbrella/heading.ts'; +import { getUmbrellaTitle } from '../umbrella/heading.ts'; import { syncMergeBlockReview } from '../umbrella/merge-block.ts'; import { findOpenUmbrella, getUmbrella, type Umbrella } from '../umbrella/queries.ts'; import { refreshUmbrella } from '../umbrella/refresh.ts'; @@ -65,7 +65,7 @@ export async function handleAcceptedPullRequest( const accepted = await readAcceptedPullRequest(run, prNumber); const { pr, release, decision } = accepted; console.info(JSON.stringify({ pr: pr.url, release, decision }, null, 2)); - if (!isLiveFor(run.mode, getUmbrellaBase(pr.base))) { + if (!canActOn(run.mode, getUmbrellaBase(pr.base))) { await commentDryRun(run, accepted); } else if (decision.status === 'refuse') { await refuse(run, { ...accepted, decision }); @@ -133,7 +133,7 @@ async function refuse( async function joinAndAnnounce(run: RunContext, accepted: AcceptedPullRequest) { const { pr, release, decision } = accepted; const { route } = decision; - const { title } = getUmbrellaHeading(route.umbrellaBase, release); + const title = getUmbrellaTitle(route.umbrellaBase, release); const result = await joinUmbrella(run.bot, { pr, route, umbrellaTitle: title }); switch (result.status) { @@ -184,7 +184,7 @@ async function findOrPlanUmbrella( ): Promise { const openUmbrella = await findOpenUmbrella(actions, route.umbrellaBranch); if (openUmbrella) return { status: 'open', number: openUmbrella.number }; - return { status: 'planned', title: getUmbrellaHeading(route.umbrellaBase, release).title }; + return { status: 'planned', title: getUmbrellaTitle(route.umbrellaBase, release) }; } async function getMessageContext( diff --git a/scripts/lib/release-queue/accepted/join.ts b/scripts/lib/release-queue/accepted/join.ts index 60af1f67cc1bd..fbfaf7c314b29 100644 --- a/scripts/lib/release-queue/accepted/join.ts +++ b/scripts/lib/release-queue/accepted/join.ts @@ -1,12 +1,11 @@ import { GitHubError } from '../github/error.ts'; import type { Repository } from '../github/repository.ts'; -import { formatSquashTitle } from '../umbrella/body.ts'; import { findOpenUmbrella, type Umbrella } from '../umbrella/queries.ts'; import { countCommitsAhead, getBranchSha, getPullRequest, type PullRequest } from './queries.ts'; import type { JoinRefusal, Route } from './routing.ts'; /** A docs PR to join to the umbrella of `route`. */ -export interface Join { +interface Join { pr: PullRequest; route: Route; /** The title of the umbrella PR, if the join opens it. */ @@ -17,7 +16,7 @@ export interface Join { * The outcome of a join. `unopened` means the PR was merged into the umbrella * branch, but the umbrella PR couldn't be opened. */ -export type JoinResult = +type JoinResult = | { status: 'joined'; umbrella: Umbrella } | { status: 'refused'; refusal: JoinRefusal } | { status: 'unopened'; error: unknown }; @@ -164,10 +163,10 @@ async function squashMerge( merge_method: 'squash', // Refuses the merge if the PR changed since its checks were read. sha: pr.headSha, - // Set here rather than relying on how the API applies the squash - // title setting of the repository. The included PRs are read from - // this title. - commit_title: formatSquashTitle(pr), + // 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) { diff --git a/scripts/lib/release-queue/accepted/messages.ts b/scripts/lib/release-queue/accepted/messages.ts index 866fb26fcfa77..96979b61b9838 100644 --- a/scripts/lib/release-queue/accepted/messages.ts +++ b/scripts/lib/release-queue/accepted/messages.ts @@ -1,6 +1,6 @@ import { ACCEPTED_LABEL, - FRAMEWORK_PACKAGE, + FRAMEWORK_CORE_PACKAGE, FRAMEWORK_STABLE_BRANCH, RELEASE_LABELS, STABLE_BRANCH, @@ -29,29 +29,55 @@ type DescribeRefusal = ( context: MessageContext ) => string; +const PATCH_LABEL = wrapInCode(RELEASE_LABELS.patch); +const MINOR_LABEL = wrapInCode(RELEASE_LABELS.minor); +const MAJOR_LABEL = wrapInCode(RELEASE_LABELS.major); + +function describeMissingReleaseLabel(): string { + const stableBranch = wrapInCode(STABLE_BRANCH); + return `This PR has no release label. Add ${PATCH_LABEL} or ${MINOR_LABEL} for a PR against ${stableBranch}, or ${MAJOR_LABEL} for a PR against a prerelease branch.`; +} + +function describeMismatch({ base, label }: Refusal<'mismatch'>): string { + const stableBranch = wrapInCode(STABLE_BRANCH); + const currentBase = wrapInCode(base); + if (label === RELEASE_LABELS.major) { + return `${MAJOR_LABEL} PRs target a prerelease branch, but this one targets ${currentBase}. Change the base to the prerelease branch, or the label to ${PATCH_LABEL} or ${MINOR_LABEL}.`; + } + return `${wrapInCode(label)} PRs target ${stableBranch}, but this one targets ${currentBase}. Change the base to ${stableBranch}, or the label to ${MAJOR_LABEL}.`; +} + +function describeMajorOnStable( + { frameworkVersion }: Refusal<'major-on-stable'>, + { stableUmbrella }: MessageContext +): string { + const corePackage = wrapInCode(FRAMEWORK_CORE_PACKAGE); + const stableBranch = wrapInCode(FRAMEWORK_STABLE_BRANCH); + const version = frameworkVersion + ? ` (${wrapInCode(`${FRAMEWORK_CORE_PACKAGE}@${frameworkVersion}`)})` + : ''; + const umbrellaToMove = stableUmbrella + ? ` The open ${wrapInCode(STABLE_BRANCH)} umbrella (#${stableUmbrella.number}) needs to be squashed into the prerelease branch by a maintainer.` + : ''; + return `The next ${corePackage} release from ${stableBranch} 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}.${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': () => - `This PR has no release label. Add ${wrapInCode(RELEASE_LABELS.patch)} or ${wrapInCode(RELEASE_LABELS.minor)} for a PR against ${wrapInCode(STABLE_BRANCH)}, or ${wrapInCode(RELEASE_LABELS.major)} for a PR against a prerelease branch.`, + 'no-release-label': describeMissingReleaseLabel, 'several-release-labels': ({ labels }) => `This PR has several release labels (${joinAsCode(labels)}). Keep only one.`, - mismatch: ({ base, label }) => - label === RELEASE_LABELS.major - ? `${wrapInCode(RELEASE_LABELS.major)} PRs target a prerelease branch, but this one targets ${wrapInCode(base)}. Change the base to the prerelease branch, or the label to ${wrapInCode(RELEASE_LABELS.patch)} or ${wrapInCode(RELEASE_LABELS.minor)}.` - : `${wrapInCode(label)} PRs target ${wrapInCode(STABLE_BRANCH)}, but this one targets ${wrapInCode(base)}. Change the base to ${wrapInCode(STABLE_BRANCH)}, or the label to ${wrapInCode(RELEASE_LABELS.major)}.`, - 'major-on-stable': ({ frameworkVersion }, { stableUmbrella }) => { - const release = frameworkVersion - ? ` (${wrapInCode(`${FRAMEWORK_PACKAGE}@${frameworkVersion}`)})` - : ''; - const umbrella = stableUmbrella - ? ` The open ${wrapInCode(STABLE_BRANCH)} umbrella (#${stableUmbrella.number}) needs to be squashed into the prerelease branch by a maintainer.` - : ''; - return `The next ${wrapInCode(FRAMEWORK_PACKAGE)} release from ${wrapInCode(FRAMEWORK_STABLE_BRANCH)} is a major${release}: docs for it belong on the prerelease branch. Change the base to the prerelease branch and the label to ${wrapInCode(RELEASE_LABELS.major)}.${umbrella}`; - }, - 'settled-umbrella': ({ number, releasePrNumber }) => - `#${number} ships with ${formatFrameworkReference(releasePrNumber)}, which is already merged. Merge #${number} first. If this PR documents that release, change its base to ${wrapInCode(STABLE_UMBRELLA_BRANCH)} instead.`, + 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.`, diff --git a/scripts/lib/release-queue/accepted/readiness.ts b/scripts/lib/release-queue/accepted/readiness.ts index 520afb07f6868..2fe7580bb4037 100644 --- a/scripts/lib/release-queue/accepted/readiness.ts +++ b/scripts/lib/release-queue/accepted/readiness.ts @@ -10,7 +10,7 @@ export interface Review { state: ReviewResponseState; } -export type ReviewState = Extract; +type ReviewState = Extract; export interface Readiness { /** At least one reviewer with write access approved the PR. */ @@ -31,8 +31,8 @@ type ClassifiedChecks = Pick[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. */ diff --git a/scripts/lib/release-queue/accepted/routing.ts b/scripts/lib/release-queue/accepted/routing.ts index fa232a86e7b1e..b101d5542451e 100644 --- a/scripts/lib/release-queue/accepted/routing.ts +++ b/scripts/lib/release-queue/accepted/routing.ts @@ -1,6 +1,12 @@ -import { ALL_RELEASE_LABELS, RELEASE_LABELS, STABLE_BRANCH, type ReleaseLabel } from '../config.ts'; +import { + ALL_RELEASE_LABELS, + RELEASE_LABELS, + RELEASE_TYPES, + STABLE_BRANCH, + type ReleaseLabel, +} from '../config.ts'; import type { PullRequestState } from '../github/types.ts'; -import { RELEASE_TYPES, type StableRelease } from '../release/release.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 { Readiness } from './readiness.ts'; @@ -22,7 +28,7 @@ export interface Route { } /** What `decideJoin` needs besides the PR. */ -export interface DecisionContext { +interface DecisionContext { release: StableRelease; settledStableUmbrella?: SettledUmbrella | undefined; } diff --git a/scripts/lib/release-queue/cli.ts b/scripts/lib/release-queue/cli.ts index 20591549a6911..fb1015b28eec2 100644 --- a/scripts/lib/release-queue/cli.ts +++ b/scripts/lib/release-queue/cli.ts @@ -3,7 +3,7 @@ import { isRecord, parseJson } from './github/json.ts'; /** A positive integer, written with digits only. */ const PR_NUMBER = /^[1-9]\d*$/; -export type Action = { type: 'handle'; prNumber: number } | { type: 'refresh' }; +type Action = { type: 'handle'; prNumber: number } | { type: 'refresh' }; /** * Chooses what a run does, from an explicit signal. @@ -37,11 +37,13 @@ export function chooseAction({ if (shouldRefresh && pr !== undefined) throw new Error('Pass --pr or --refresh, not both.'); if (shouldRefresh) return { type: 'refresh' }; - const value = pr ?? eventPullRequest?.toString(); - if (value === undefined) throw new Error('No PR to handle: pass --pr .'); + 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(value)) throw new Error(`Invalid PR number: ${value}. Pass --pr .`); - return { type: 'handle', prNumber: Number(value) }; + if (!PR_NUMBER.test(rawPrNumber)) { + throw new Error(`Invalid PR number: ${rawPrNumber}. Pass --pr .`); + } + return { type: 'handle', prNumber: Number(rawPrNumber) }; } /** What a run reads from the event of GitHub Actions. */ @@ -55,8 +57,7 @@ export interface RunEvent { * * @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. A `workflow_dispatch` event has - * neither field. + * @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 { diff --git a/scripts/lib/release-queue/config.ts b/scripts/lib/release-queue/config.ts index 1fe9ce1b835d7..65fda647576fb 100644 --- a/scripts/lib/release-queue/config.ts +++ b/scripts/lib/release-queue/config.ts @@ -3,12 +3,9 @@ export const DOCS_REPO = 'withastro/docs'; export const FRAMEWORK_REPO = 'withastro/astro'; -/** The package of the framework repository that names its releases. */ -export const FRAMEWORK_PACKAGE = 'astro'; +export const FRAMEWORK_CORE_PACKAGE = 'astro'; export const FRAMEWORK_STABLE_BRANCH = 'main'; -/** The branch of the framework repository for its alpha and beta releases. */ export const FRAMEWORK_PRERELEASE_BRANCH = 'next'; -/** The title prefix of the framework's release PRs, shared by umbrellas. */ export const RELEASE_TITLE_PREFIX = '[ci] release'; export const STABLE_BRANCH = 'main'; @@ -19,11 +16,15 @@ 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'; +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: 'patch-release', - minor: 'minor-release', - major: 'major-release', -} as const; + 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]; diff --git a/scripts/lib/release-queue/github/client.ts b/scripts/lib/release-queue/github/client.ts index 11fb70f7b17a5..673ae1850ca59 100644 --- a/scripts/lib/release-queue/github/client.ts +++ b/scripts/lib/release-queue/github/client.ts @@ -96,9 +96,8 @@ export class GitHubClient { * @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. `undefined` when GitHub - * answers without a body, or when `PrintingGitHubClient` only prints 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 { @@ -155,9 +154,7 @@ 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((error: unknown) => (typeof error === 'string' ? error : readErrorMessage(error))) - .filter((detail) => detail !== undefined); + const details = errors.map(readErrorMessage).filter((detail) => detail !== undefined); return details.length > 0 ? `${body.message}: ${details.join('; ')}` : body.message; } @@ -170,6 +167,8 @@ function parseErrorBody(text: string): unknown { } } +/** 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/main.ts b/scripts/lib/release-queue/main.ts index 7e736388e5391..27a86d030c5bf 100644 --- a/scripts/lib/release-queue/main.ts +++ b/scripts/lib/release-queue/main.ts @@ -9,7 +9,7 @@ import type { RunContext } from './run-context.ts'; import { refreshOpenUmbrellas } from './umbrella/refresh.ts'; /** The command-line flags of `scripts/release-queue.ts`. */ -export interface Flags { +interface Flags { pr?: string | undefined; refresh: boolean; /** Who added the label, for the write access check. */ @@ -18,14 +18,14 @@ export interface Flags { } /** The environment variables the release queue reads. */ -export interface Env { +interface Env { /** For reads, dry-run comments and merge-block reviews. */ GITHUB_TOKEN?: string | undefined; /** The token of the bot account, for joins, refusals and refreshes. */ BOT_TOKEN?: string | undefined; - /** A dry run by default: see `Mode`. */ + /** The mode of the bot. */ MODE?: string | undefined; - // Set by GitHub Actions. + // The variables below are set by GitHub Actions. GITHUB_ACTIONS?: string | undefined; GITHUB_EVENT_NAME?: string | undefined; GITHUB_EVENT_PATH?: string | undefined; @@ -69,8 +69,8 @@ function createRunContext(flags: Flags, env: Env): RunContext { const token = env.GITHUB_TOKEN; if (!token) throw new Error('GITHUB_TOKEN is required.'); const shouldWrite = isGitHubActions(env) || flags.write; - // Changes go through the bot account: unlike `GITHUB_TOKEN`, its merges into - // an umbrella branch trigger the checks of the umbrella PR. + // 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 in ${mode} mode.`); diff --git a/scripts/lib/release-queue/modes.test.ts b/scripts/lib/release-queue/modes.test.ts index f2d222db984af..93b251056763d 100644 --- a/scripts/lib/release-queue/modes.test.ts +++ b/scripts/lib/release-queue/modes.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { isDryRun, isLiveFor, parseMode } from './modes.ts'; +import { canActOn, isDryRun, parseMode } from './modes.ts'; describe('parseMode', () => { it('defaults to a dry run', () => { @@ -13,15 +13,15 @@ describe('parseMode', () => { }); }); -describe('isLiveFor', () => { +describe('canActOn', () => { it('acts on the stable branch only in stable-only mode', () => { - assert.equal(isLiveFor('stable-only', 'main'), true); - assert.equal(isLiveFor('stable-only', 'cloudflare-next'), false); + 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(isLiveFor('all', 'cloudflare-next'), true); - assert.equal(isLiveFor('dry-run', 'main'), false); + assert.equal(canActOn('all', 'cloudflare-next'), true); + assert.equal(canActOn('dry-run', 'main'), false); }); }); diff --git a/scripts/lib/release-queue/modes.ts b/scripts/lib/release-queue/modes.ts index 70d9846c82675..cc7d20c5e3fe9 100644 --- a/scripts/lib/release-queue/modes.ts +++ b/scripts/lib/release-queue/modes.ts @@ -3,7 +3,7 @@ import { STABLE_BRANCH } from './config.ts'; const MODES = { dryRun: 'dry-run', stableOnly: 'stable-only', all: 'all' } as const; /** - * The modes of the bot, from the most cautious: + * 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. @@ -17,7 +17,7 @@ const ALL_MODES: Mode[] = Object.values(MODES); * Reads the mode of the bot. * * @param {string} [value] - The value of `MODE`. Without one, a dry run. - * @returns {Mode} The mode. + * @returns {Mode} The mode `value` names. * @throws {Error} When `value` isn't a mode. */ export function parseMode(value: string = MODES.dryRun): Mode { @@ -27,14 +27,14 @@ export function parseMode(value: string = MODES.dryRun): Mode { } /** - * Whether the bot changes the PRs shipping to `umbrellaBase`, instead of a dry - * run. + * 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 mode of the bot. * @param {string} umbrellaBase - The branch the umbrella PR merges into. - * @returns {boolean} `true` when the bot joins, refuses, and refreshes there. + * @returns {boolean} `true` when the bot acts on these PRs. */ -export function isLiveFor(mode: Mode, umbrellaBase: string): boolean { +export function canActOn(mode: Mode, umbrellaBase: string): boolean { return mode === MODES.all || (mode === MODES.stableOnly && umbrellaBase === STABLE_BRANCH); } diff --git a/scripts/lib/release-queue/release/release.ts b/scripts/lib/release-queue/release/release.ts index b5182c25eb631..69192f17994c7 100644 --- a/scripts/lib/release-queue/release/release.ts +++ b/scripts/lib/release-queue/release/release.ts @@ -1,19 +1,16 @@ -import { FRAMEWORK_PACKAGE, FRAMEWORK_REPO, RELEASE_LABELS } from '../config.ts'; +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+)$/; -export type ReleaseType = keyof typeof RELEASE_LABELS; - -export const RELEASE_TYPES = { - patch: 'patch', - minor: 'minor', - major: 'major', -} as const satisfies { [Type in ReleaseType]: Type }; - -export interface PackageVersion { +interface PackageVersion { name: string; version: string; } @@ -71,7 +68,7 @@ function getReleaseType(version: string | undefined): ReleaseType { * @returns {StableRelease} The type and version of the release, with its PR. */ export function getStableRelease(pullRequest?: ReleasePullRequest): StableRelease { - const frameworkVersion = findVersion(pullRequest?.versions ?? [], FRAMEWORK_PACKAGE); + const frameworkVersion = findVersion(pullRequest?.versions ?? [], FRAMEWORK_CORE_PACKAGE); return { type: getReleaseType(frameworkVersion), frameworkVersion, pullRequest }; } diff --git a/scripts/lib/release-queue/run-context.ts b/scripts/lib/release-queue/run-context.ts index 4691b5f19ca74..403803fce4a7a 100644 --- a/scripts/lib/release-queue/run-context.ts +++ b/scripts/lib/release-queue/run-context.ts @@ -1,16 +1,12 @@ import type { Repository } from './github/repository.ts'; import type { Mode } from './modes.ts'; -/** The docs repository, and the framework repository for its release PRs. */ export interface Repositories { docs: Repository; framework: Repository; } -/** - * What a run works with, whether it handles a PR or refreshes umbrellas: the - * repositories it acts through, and the mode. - */ +/** The repositories a run acts through, and its mode. */ export interface RunContext { /** * The docs repository through `GITHUB_TOKEN`: reads the PR, posts dry-run diff --git a/scripts/lib/release-queue/umbrella/body.test.ts b/scripts/lib/release-queue/umbrella/body.test.ts index 1d8c74727b09b..8b7ed062d2caa 100644 --- a/scripts/lib/release-queue/umbrella/body.test.ts +++ b/scripts/lib/release-queue/umbrella/body.test.ts @@ -1,28 +1,9 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { formatUmbrellaBody, parseIncludedPullRequests, parseReleaseMarker } from './body.ts'; - -describe('parseIncludedPullRequests', () => { - it('reads the PR of each squash commit, in commit order', () => { - const included = parseIncludedPullRequests([ - 'Document the new option (#7)', - "Merge branch 'main' into release-queue/main", - 'Fix (a typo) in the guide (#3)', - 'Revert the option (#12) (#15)', - ]); - assert.deepEqual(included, [ - { number: 7, title: 'Document the new option' }, - { number: 3, title: 'Fix (a typo) in the guide' }, - { number: 15, title: 'Revert the option (#12)' }, - ]); - }); -}); +import { formatUmbrellaBody, parseReleaseMarker } from './body.ts'; describe('formatUmbrellaBody', () => { - const included = [ - { number: 14580, title: 'Document remote image fetch retries' }, - { number: 14581, title: 'Fix a typo' }, - ]; + const included = [14580, 14581]; it('links the release PR of the main umbrella, and lists the included PRs', () => { assert.equal( @@ -30,8 +11,8 @@ describe('formatUmbrellaBody', () => { [ 'Ships with withastro/astro#18459. This includes:', '', - '- Document remote image fetch retries (#14580)', - '- Fix a typo (#14581)', + '- #14580', + '- #14581', '', 'Managed by the release queue: the bot rewrites this description after each join and each manual run.', '', @@ -79,16 +60,9 @@ describe('parseReleaseMarker', () => { assert.equal(parseReleaseMarker('Release umbrella for `main`.'), undefined); }); - describe('ignores a marker in the title of an included PR', () => { - const included = [{ number: 7, title: 'Fix ' }]; - - it('when the umbrella links a release PR', () => { - const body = formatUmbrellaBody('main', { releasePrNumber: 18405, included }); - assert.equal(parseReleaseMarker(body), 18405); - }); - - it("when the umbrella doesn't link one", () => { - assert.equal(parseReleaseMarker(formatUmbrellaBody('main', { included })), 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 index 65839d3c4aef7..258156d6c93c7 100644 --- a/scripts/lib/release-queue/umbrella/body.ts +++ b/scripts/lib/release-queue/umbrella/body.ts @@ -1,51 +1,13 @@ import { FRAMEWORK_REPO, RELEASE_TITLE_PREFIX, STABLE_BRANCH } from '../config.ts'; import { formatFrameworkReference } from '../release/release.ts'; -/** The title GitHub gives a squash commit: the PR title, then its number. */ -const SQUASH_TITLE = /^(.*) \(#(\d+)\)$/; - -/** A docs PR merged into an umbrella branch. */ -export interface IncludedPullRequest { - number: number; - title: string; -} - -/** - * Writes the title of the squash commit of a docs PR, as GitHub does. - * - * @param {IncludedPullRequest} pr - The docs PR to merge. - * @returns {string} The PR title, followed by its number. - */ -export function formatSquashTitle({ number, title }: IncludedPullRequest): string { - return `${title} (#${number})`; -} - -/** - * Reads the docs PRs an umbrella branch includes, from the titles of its - * commits. - * - * The titles hold the PR numbers, unlike the commit SHAs, which a rebase of - * the branch changes. A commit without a PR number, such as a merge of the - * base, is skipped. - * - * @param {string[]} commitTitles - The titles of the commits of the branch. - * @returns {IncludedPullRequest[]} The included PRs, in the order of the - * commits. - */ -export function parseIncludedPullRequests(commitTitles: string[]): IncludedPullRequest[] { - return commitTitles.flatMap((commitTitle) => { - const match = commitTitle.match(SQUASH_TITLE); - return match ? [{ number: Number(match[2]), title: match[1] }] : []; - }); -} - /** 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 a PR title above it can hold a marker. +// Anchored to the end of the body, as an edit above it could add a marker. const RELEASE_MARKER = new RegExp(`${formatReleaseMarker('(\\d+)')}\\s*$`); /** @@ -71,15 +33,12 @@ export function parseReleaseMarker(body: string): number | undefined { * @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 {IncludedPullRequest[]} content.included - The included docs PRs. + * @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: IncludedPullRequest[] } + { releasePrNumber, included }: { releasePrNumber?: number | undefined; included: number[] } ): string { const summary = `${describeShipping(umbrellaBase, releasePrNumber)} ${describeIncluded(included)}`; const footer = @@ -88,9 +47,10 @@ export function formatUmbrellaBody( return [`${summary}\n\n${footer}`, ...marker].join('\n'); } -function describeIncluded(included: IncludedPullRequest[]): string { +function describeIncluded(included: number[]): string { if (included.length === 0) return 'No docs PR is included yet.'; - const list = included.map(({ number, title }) => `- ${title} (#${number})`).join('\n'); + // 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}`; } diff --git a/scripts/lib/release-queue/umbrella/branch.ts b/scripts/lib/release-queue/umbrella/branch.ts index edfcfe09dd2f6..749f13c421430 100644 --- a/scripts/lib/release-queue/umbrella/branch.ts +++ b/scripts/lib/release-queue/umbrella/branch.ts @@ -24,7 +24,7 @@ export function getUmbrellaBase(base: string): string { } /** - * Gets the umbrella branch of the umbrella PR merging into a branch. + * 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. diff --git a/scripts/lib/release-queue/umbrella/heading.test.ts b/scripts/lib/release-queue/umbrella/heading.test.ts index 7baff4a795e21..a9ebccf8bc341 100644 --- a/scripts/lib/release-queue/umbrella/heading.test.ts +++ b/scripts/lib/release-queue/umbrella/heading.test.ts @@ -1,38 +1,45 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { getStableRelease } from '../release/release.ts'; -import { getUmbrellaHeading } from './heading.ts'; +import { getUmbrellaReleaseLabel, getUmbrellaTitle } from './heading.ts'; const releasePullRequest = (frameworkVersion?: string) => ({ number: 18459, versions: frameworkVersion ? [{ name: 'astro', version: frameworkVersion }] : [], }); -describe('getUmbrellaHeading', () => { +describe('getUmbrellaTitle', () => { it('names the main umbrella after the astro version', () => { - assert.deepEqual(getUmbrellaHeading('main', getStableRelease(releasePullRequest('7.4.1'))), { - title: '[ci] release astro@7.4.1 docs', - releaseLabel: 'patch-release', - }); + 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('labels the main umbrella of a minor release', () => { - const heading = getUmbrellaHeading('main', getStableRelease(releasePullRequest('7.5.0'))); - assert.equal(heading.releaseLabel, 'minor-release'); + 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('falls back to the branch name and a patch when astro is not released', () => { - assert.deepEqual(getUmbrellaHeading('main', getStableRelease(releasePullRequest())), { - title: '[ci] release main docs', - releaseLabel: 'patch-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('names a prerelease umbrella after its branch, as a major', () => { + it('labels a prerelease umbrella as a major', () => { const release = getStableRelease(releasePullRequest('7.4.1')); - assert.deepEqual(getUmbrellaHeading('cloudflare-next', release), { - title: '[ci] release cloudflare-next docs', - releaseLabel: 'major-release', - }); + 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 index fadbbcd78432c..7e3bce162a6e4 100644 --- a/scripts/lib/release-queue/umbrella/heading.ts +++ b/scripts/lib/release-queue/umbrella/heading.ts @@ -1,5 +1,5 @@ import { - FRAMEWORK_PACKAGE, + FRAMEWORK_CORE_PACKAGE, RELEASE_LABELS, RELEASE_TITLE_PREFIX, STABLE_BRANCH, @@ -7,45 +7,42 @@ import { } from '../config.ts'; import type { StableRelease } from '../release/release.ts'; -/** What an umbrella PR takes from the release it ships with. */ -export interface UmbrellaHeading { - title: string; - releaseLabel: ReleaseLabel; -} - /** - * Gets the title and release label of the umbrella PR merging into - * `umbrellaBase`. + * Gets the title of the umbrella PR merging into a branch. + * + * The stable umbrella is named after the version of the core package, as + * merged PRs are posted to Discord. A prerelease umbrella is 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 {UmbrellaHeading} The heading of the stable umbrella, or of a - * prerelease umbrella for any other base. + * @returns {string} The title of the umbrella PR. */ -export function getUmbrellaHeading(umbrellaBase: string, release: StableRelease): UmbrellaHeading { - return umbrellaBase === STABLE_BRANCH - ? getStableUmbrellaHeading(release) - : getPrereleaseUmbrellaHeading(umbrellaBase); +export function getUmbrellaTitle(umbrellaBase: string, release: StableRelease): string { + if (umbrellaBase !== STABLE_BRANCH) return formatUmbrellaTitle(umbrellaBase); + return formatUmbrellaTitle( + release.frameworkVersion + ? `${FRAMEWORK_CORE_PACKAGE}@${release.frameworkVersion}` + : STABLE_BRANCH + ); } -/** - * Names the stable umbrella after the version of the framework package, as - * merged PRs are posted to Discord. Expects a release that isn't a major. - */ -function getStableUmbrellaHeading(release: StableRelease): UmbrellaHeading { - const name = release.frameworkVersion - ? `${FRAMEWORK_PACKAGE}@${release.frameworkVersion}` - : STABLE_BRANCH; - return { - title: `${RELEASE_TITLE_PREFIX} ${name} docs`, - releaseLabel: RELEASE_LABELS[release.type], - }; +function formatUmbrellaTitle(name: string): string { + return `${RELEASE_TITLE_PREFIX} ${name} docs`; } -/** Names a prerelease umbrella after its branch, which says what it is for. */ -function getPrereleaseUmbrellaHeading(umbrellaBase: string): UmbrellaHeading { - return { - title: `${RELEASE_TITLE_PREFIX} ${umbrellaBase} docs`, - releaseLabel: RELEASE_LABELS.major, - }; +/** + * 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 === STABLE_BRANCH ? RELEASE_LABELS[release.type] : RELEASE_LABELS.major; } diff --git a/scripts/lib/release-queue/umbrella/labels.ts b/scripts/lib/release-queue/umbrella/labels.ts index 39d87352da1f5..390070f841ae7 100644 --- a/scripts/lib/release-queue/umbrella/labels.ts +++ b/scripts/lib/release-queue/umbrella/labels.ts @@ -12,9 +12,10 @@ export interface LabelChanges { } /** - * Gets the label changes that give an umbrella PR the label that marks - * umbrella PRs, and its release label. Other labels, such as the translations - * label, stay. + * 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. @@ -35,9 +36,8 @@ export function getLabelChanges(current: string[], releaseLabel: ReleaseLabel): * Gets the label changes of an umbrella whose release can't be followed. * * @param {string[]} current - The labels of the umbrella PR. - * @returns {LabelChanges} The label that marks umbrella PRs if missing, and - * the fallback release label only when the umbrella has none. Nothing to - * remove. + * @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)); diff --git a/scripts/lib/release-queue/umbrella/merge-block.ts b/scripts/lib/release-queue/umbrella/merge-block.ts index 31f137ee64391..17384dfde0b7a 100644 --- a/scripts/lib/release-queue/umbrella/merge-block.ts +++ b/scripts/lib/release-queue/umbrella/merge-block.ts @@ -48,7 +48,10 @@ function describeBranchItem( } /** - * Names the translations label instead of reading it: `pr-labeler.yml` adds it + * 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 { @@ -75,8 +78,7 @@ function findActiveReview(reviews: SubmittedReview[]): SubmittedReview | undefin * @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: after a join, as a dismissed review doesn't cover the PRs that - * joined since. + * is active. */ export async function syncMergeBlockReview( repo: Repository, 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 index 9d5033c7c48fb..836d22150a8a2 100644 --- a/scripts/lib/release-queue/umbrella/queries.ts +++ b/scripts/lib/release-queue/umbrella/queries.ts @@ -2,6 +2,9 @@ import { compareBranches, type Repository } from '../github/repository.ts'; import type { PullRequestResponse, ReviewResponse } 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+)\)$/; + /** An umbrella PR, with what the refresh reads from it. */ export interface UmbrellaPullRequest { number: number; @@ -13,9 +16,8 @@ export interface UmbrellaPullRequest { } /** - * An umbrella PR: an open one, or a planned one that a join would create, - * named by its title. An umbrella whose creation was only printed stays - * planned. + * 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 }; @@ -25,10 +27,7 @@ export interface SubmittedReview extends ReviewResponse { body: string; } -/** - * Whether a PR is an umbrella PR: from an umbrella branch of `repo` into the - * branch the name implies. - */ +/** Whether a PR is an umbrella PR of the repository. */ function isUmbrellaPullRequest({ base, head }: PullRequestResponse, repo: Repository): boolean { return ( isUmbrellaBranch(head.ref) && @@ -93,12 +92,10 @@ export async function findOpenUmbrella( } /** - * Lists the open umbrella PRs: PRs from an umbrella branch of the repository - * into the branch it names. + * Lists the open umbrella PRs. * * @param {Repository} repo - The docs repository. - * @returns {Promise} The open umbrella PRs. A PR from a - * fork or into another base isn't one. + * @returns {Promise} The open umbrella PRs. */ export async function listOpenUmbrellas(repo: Repository): Promise { const pullRequests = await repo.getAll(`pulls?state=open`); @@ -106,21 +103,26 @@ export async function listOpenUmbrellas(repo: Repository): Promise} The first line of the message of each commit - * of `head` missing in `base`, oldest first. + * 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 listCommitTitlesAhead( +export async function listIncludedPullRequests( repo: Repository, - base: string, - head: string -): Promise { - const { commits } = await compareBranches(repo, base, head); - return commits.map(({ commit }) => commit.message.split('\n', 1)[0] ?? ''); + { base, branch }: UmbrellaPullRequest +): Promise { + const { commits } = await compareBranches(repo, 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 index 01c97950e3600..e0834eb8a889a 100644 --- a/scripts/lib/release-queue/umbrella/refresh.test.ts +++ b/scripts/lib/release-queue/umbrella/refresh.test.ts @@ -15,7 +15,7 @@ const releaseWith = (number: number, frameworkVersion: string) => const noRelease = getStableRelease(); -const included = [{ number: 7, title: 'Document the new option' }]; +const included = [7]; /** The `main` umbrella, up to date with release PR #18405 (`astro@7.5.0`). */ const umbrella: UmbrellaPullRequest = { diff --git a/scripts/lib/release-queue/umbrella/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts index b210e5f84cffd..551a58b2eb2ae 100644 --- a/scripts/lib/release-queue/umbrella/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -1,11 +1,11 @@ -import { FRAMEWORK_STABLE_BRANCH, STABLE_BRANCH } from '../config.ts'; +import { FRAMEWORK_STABLE_BRANCH, RELEASE_TYPES, STABLE_BRANCH } from '../config.ts'; import { removeLabel, type Repository } from '../github/repository.ts'; -import { isLiveFor } from '../modes.ts'; +import { canActOn } from '../modes.ts'; import { getPrereleasePullRequest, getReleasePullRequest } from '../release/queries.ts'; -import { getStableRelease, RELEASE_TYPES, type StableRelease } from '../release/release.ts'; +import { getStableRelease, type StableRelease } from '../release/release.ts'; import type { Repositories, RunContext } from '../run-context.ts'; -import { formatUmbrellaBody, parseIncludedPullRequests } from './body.ts'; -import { getUmbrellaHeading } from './heading.ts'; +import { formatUmbrellaBody } from './body.ts'; +import { getUmbrellaReleaseLabel, getUmbrellaTitle } from './heading.ts'; import { applyToLabels, getKeptLabelChanges, @@ -13,8 +13,12 @@ import { type LabelChanges, } from './labels.ts'; import { syncMergeBlockReview } from './merge-block.ts'; -import { listCommitTitlesAhead, listOpenUmbrellas, type UmbrellaPullRequest } from './queries.ts'; -import { getReleaseState, type ReleaseState } from './state.ts'; +import { + listIncludedPullRequests, + listOpenUmbrellas, + type UmbrellaPullRequest, +} from './queries.ts'; +import { getReleaseState } from './state.ts'; /** * Brings an umbrella PR up to date with its release and its included PRs, @@ -31,30 +35,31 @@ export async function refreshUmbrella( umbrella: UmbrellaPullRequest, release: StableRelease ): Promise { - const state = await getReleaseState(framework, umbrella, release); - if (state.status === 'follows' && isBeforeMajor(umbrella, release)) { + const releaseState = await getReleaseState(framework, umbrella, release); + const isFollowing = releaseState.status === 'follows'; + if (isFollowing && isBeforeMajor(umbrella, release)) { console.info(`Skipped #${umbrella.number}: the next stable release is a major.`); return umbrella; } - const heading = getUmbrellaHeading(umbrella.base, release); - const linkedReleasePrNumber = await getLinkedReleasePrNumber(framework, umbrella, { - state, - release, - }); - const wanted = await getWantedText(docs, umbrella, { - title: state.status === 'follows' ? heading.title : umbrella.title, - linkedReleasePrNumber, - }); - const textChanges = getTextChanges(umbrella, wanted); + const linkedReleasePrNumber = isFollowing + ? await getFollowedReleasePrNumber(framework, umbrella, release) + : releaseState.releasePrNumber; + const wantedText = { + title: isFollowing ? getUmbrellaTitle(umbrella.base, release) : umbrella.title, + body: formatUmbrellaBody(umbrella.base, { + releasePrNumber: linkedReleasePrNumber, + included: await listIncludedPullRequests(docs, umbrella), + }), + }; + const textChanges = getTextChanges(umbrella, wantedText); if (Object.keys(textChanges).length > 0) { await docs.write('PATCH', `pulls/${umbrella.number}`, textChanges); } - const labelChanges = - state.status === 'follows' - ? getLabelChanges(umbrella.labels, heading.releaseLabel) - : getKeptLabelChanges(umbrella.labels); + const labelChanges = isFollowing + ? getLabelChanges(umbrella.labels, getUmbrellaReleaseLabel(umbrella.base, release)) + : getKeptLabelChanges(umbrella.labels); await applyLabelChanges(docs, umbrella.number, labelChanges); return { ...umbrella, ...textChanges, labels: applyToLabels(umbrella.labels, labelChanges) }; } @@ -68,15 +73,15 @@ function isBeforeMajor(umbrella: UmbrellaPullRequest, release: StableRelease): b } /** - * Gets the release PR to link in an umbrella: the open one when it follows - * its release, or the one it already links. + * Gets the release PR an umbrella that follows its release links: the open + * release PR of the stable branch, or of the prerelease of a prerelease + * umbrella. */ -async function getLinkedReleasePrNumber( +async function getFollowedReleasePrNumber( framework: Repository, umbrella: UmbrellaPullRequest, - { state, release }: { state: ReleaseState; release: StableRelease } + release: StableRelease ): Promise { - if (state.status !== 'follows') return state.releasePrNumber; const releasePullRequest = umbrella.base === STABLE_BRANCH ? release.pullRequest @@ -84,28 +89,14 @@ async function getLinkedReleasePrNumber( return releasePullRequest?.number; } -/** Gets the title and body the umbrella PR should have. */ -async function getWantedText( - docs: Repository, - umbrella: UmbrellaPullRequest, - { title, linkedReleasePrNumber }: { title: string; linkedReleasePrNumber: number | undefined } -): Promise<{ title: string; body: string }> { - const commitTitles = await listCommitTitlesAhead(docs, umbrella.base, umbrella.branch); - const body = formatUmbrellaBody(umbrella.base, { - releasePrNumber: linkedReleasePrNumber, - included: parseIncludedPullRequests(commitTitles), - }); - return { title, body }; -} - -/** Gets the title and body of `wanted` that differ from the umbrella's. */ +/** Gets the title and body of `wantedText` that differ from the umbrella's. */ function getTextChanges( umbrella: UmbrellaPullRequest, - wanted: { title: string; body: string } + wantedText: { title: string; body: string } ): { title?: string; body?: string } { return { - ...(wanted.title === umbrella.title ? {} : { title: wanted.title }), - ...(wanted.body === umbrella.body ? {} : { body: wanted.body }), + ...(wantedText.title === umbrella.title ? {} : { title: wantedText.title }), + ...(wantedText.body === umbrella.body ? {} : { body: wantedText.body }), }; } @@ -135,7 +126,7 @@ export async function refreshOpenUmbrellas(run: RunContext) { console.info(`Open umbrellas: ${numbers || 'none'}.`); const { live: liveUmbrellas = [], skipped = [] } = Object.groupBy(umbrellas, (umbrella) => - isLiveFor(mode, umbrella.base) ? 'live' : 'skipped' + canActOn(mode, umbrella.base) ? 'live' : 'skipped' ); for (const umbrella of skipped) { console.info(`Skipped #${umbrella.number}: dry run for ${umbrella.base}.`); diff --git a/scripts/lib/release-queue/umbrella/state.ts b/scripts/lib/release-queue/umbrella/state.ts index e3313cc440c70..475a4d1a2a501 100644 --- a/scripts/lib/release-queue/umbrella/state.ts +++ b/scripts/lib/release-queue/umbrella/state.ts @@ -8,8 +8,8 @@ import { STABLE_UMBRELLA_BRANCH } from './branch.ts'; import { findOpenUmbrella, type UmbrellaPullRequest } from './queries.ts'; /** - * The open stable umbrella once the release PR it ships with is merged, until - * the umbrella itself is merged. + * An open stable umbrella whose release PR is already merged. It stays settled + * until a maintainer merges it. */ export interface SettledUmbrella { number: number; @@ -24,7 +24,7 @@ export interface SettledUmbrella { * - `settled`: the release PR it links is merged. It keeps everything until a * maintainer merges it, even when the next release PR opens. */ -export type ReleaseState = +type ReleaseState = | { status: 'follows' } | { status: 'kept'; releasePrNumber?: number | undefined } | { status: 'settled'; releasePrNumber: number }; @@ -36,14 +36,14 @@ export type ReleaseState = * @param {UmbrellaPullRequest} umbrella - The umbrella PR, with the release PR * its body links. * @param {StableRelease} release - The next release from the stable branch. - * @returns {Promise} The release state. A prerelease umbrella - * always follows: its branch has continuous betas. + * @returns {Promise} The release state. */ export async function getReleaseState( framework: Repository, umbrella: UmbrellaPullRequest, release: StableRelease ): Promise { + // A prerelease umbrella always follows: its branch has continuous betas. if (umbrella.base !== STABLE_BRANCH) return { status: 'follows' }; const linkedReleasePrNumber = parseReleaseMarker(umbrella.body); const openReleasePrNumber = release.pullRequest?.number; From dc7402e7b1cdbb1c2ca35bf6896a36d2f9ae37fc Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 18:16:15 +0100 Subject: [PATCH 11/35] more rewording, renaming, and reorganization --- .github/workflows/release-queue.yml | 13 +- scripts/lib/release-queue/accepted/handle.ts | 4 +- scripts/lib/release-queue/accepted/join.ts | 6 +- .../lib/release-queue/accepted/messages.ts | 23 +- scripts/lib/release-queue/accepted/queries.ts | 4 +- .../release-queue/accepted/routing.test.ts | 246 +++++++++--------- scripts/lib/release-queue/accepted/routing.ts | 9 +- scripts/lib/release-queue/cli.ts | 2 +- scripts/lib/release-queue/config.ts | 36 +-- .../lib/release-queue/github/client.test.ts | 30 ++- scripts/lib/release-queue/github/client.ts | 13 +- .../lib/release-queue/{github => }/json.ts | 0 scripts/lib/release-queue/main.test.ts | 2 +- scripts/lib/release-queue/main.ts | 8 +- scripts/lib/release-queue/modes.ts | 16 +- scripts/lib/release-queue/release/queries.ts | 9 +- scripts/lib/release-queue/release/release.ts | 2 +- .../lib/release-queue/testing/stub-github.ts | 2 +- scripts/lib/release-queue/umbrella/body.ts | 4 +- scripts/lib/release-queue/umbrella/branch.ts | 10 +- scripts/lib/release-queue/umbrella/heading.ts | 14 +- .../umbrella/merge-block.test.ts | 4 +- .../lib/release-queue/umbrella/merge-block.ts | 2 +- scripts/lib/release-queue/umbrella/queries.ts | 1 - scripts/lib/release-queue/umbrella/refresh.ts | 6 +- scripts/lib/release-queue/umbrella/state.ts | 4 +- 26 files changed, 236 insertions(+), 234 deletions(-) rename scripts/lib/release-queue/{github => }/json.ts (100%) diff --git a/.github/workflows/release-queue.yml b/.github/workflows/release-queue.yml index 67b04cdda3047..02c34918d5721 100644 --- a/.github/workflows/release-queue.yml +++ b/.github/workflows/release-queue.yml @@ -1,6 +1,6 @@ -# Routes docs PRs labeled as accepted to the umbrella PR of their release. -# Run it manually to refresh the open umbrella PRs, for example after the -# release PR of `withastro/astro` changed. +# 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 @@ -42,7 +42,7 @@ jobs: persist-credentials: false sparse-checkout: scripts - # The script has no dependencies: Node.js is enough, no `pnpm install`. + # The script has no dependencies, only Node.js is required. - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -51,9 +51,8 @@ jobs: - name: Route the PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Writes as the bot account outside a dry run: its merges trigger CI - # on the umbrella PR. Also passed in dry run: going live only changes - # MODE. + # 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/scripts/lib/release-queue/accepted/handle.ts b/scripts/lib/release-queue/accepted/handle.ts index 4beda6f2a7024..89a8b31846b1e 100644 --- a/scripts/lib/release-queue/accepted/handle.ts +++ b/scripts/lib/release-queue/accepted/handle.ts @@ -133,8 +133,8 @@ async function refuse( async function joinAndAnnounce(run: RunContext, accepted: AcceptedPullRequest) { const { pr, release, decision } = accepted; const { route } = decision; - const title = getUmbrellaTitle(route.umbrellaBase, release); - const result = await joinUmbrella(run.bot, { pr, route, umbrellaTitle: title }); + const umbrellaTitle = getUmbrellaTitle(route.umbrellaBase, release); + const result = await joinUmbrella(run.bot, { pr, route, umbrellaTitle }); switch (result.status) { case 'joined': diff --git a/scripts/lib/release-queue/accepted/join.ts b/scripts/lib/release-queue/accepted/join.ts index fbfaf7c314b29..5d3197dd96d09 100644 --- a/scripts/lib/release-queue/accepted/join.ts +++ b/scripts/lib/release-queue/accepted/join.ts @@ -8,7 +8,7 @@ import type { JoinRefusal, Route } from './routing.ts'; interface Join { pr: PullRequest; route: Route; - /** The title of the umbrella PR, if the join opens it. */ + /** The title given to the umbrella PR when the join opens it. */ umbrellaTitle: string; } @@ -26,8 +26,8 @@ export type UnopenedJoin = Extract; /** * The umbrella branch before a join that opens the umbrella PR: missing, left - * over without commits, for example after a failed first merge, or left over - * with commits, which a maintainer has to look at. + * 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' }; diff --git a/scripts/lib/release-queue/accepted/messages.ts b/scripts/lib/release-queue/accepted/messages.ts index 96979b61b9838..e6c596c4aa2ab 100644 --- a/scripts/lib/release-queue/accepted/messages.ts +++ b/scripts/lib/release-queue/accepted/messages.ts @@ -3,7 +3,7 @@ import { FRAMEWORK_CORE_PACKAGE, FRAMEWORK_STABLE_BRANCH, RELEASE_LABELS, - STABLE_BRANCH, + DOCS_STABLE_BRANCH, } from '../config.ts'; import { GitHubError } from '../github/error.ts'; import { formatFrameworkReference } from '../release/release.ts'; @@ -29,22 +29,21 @@ type DescribeRefusal = ( context: MessageContext ) => string; -const PATCH_LABEL = wrapInCode(RELEASE_LABELS.patch); -const MINOR_LABEL = wrapInCode(RELEASE_LABELS.minor); -const MAJOR_LABEL = wrapInCode(RELEASE_LABELS.major); +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 { - const stableBranch = wrapInCode(STABLE_BRANCH); - return `This PR has no release label. Add ${PATCH_LABEL} or ${MINOR_LABEL} for a PR against ${stableBranch}, or ${MAJOR_LABEL} for a PR against a prerelease branch.`; + 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 stableBranch = wrapInCode(STABLE_BRANCH); const currentBase = wrapInCode(base); if (label === RELEASE_LABELS.major) { - return `${MAJOR_LABEL} PRs target a prerelease branch, but this one targets ${currentBase}. Change the base to the prerelease branch, or the label to ${PATCH_LABEL} or ${MINOR_LABEL}.`; + 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 ${stableBranch}, but this one targets ${currentBase}. Change the base to ${stableBranch}, or the label to ${MAJOR_LABEL}.`; + 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( @@ -52,14 +51,14 @@ function describeMajorOnStable( { stableUmbrella }: MessageContext ): string { const corePackage = wrapInCode(FRAMEWORK_CORE_PACKAGE); - const stableBranch = wrapInCode(FRAMEWORK_STABLE_BRANCH); + const frameworkBranch = wrapInCode(FRAMEWORK_STABLE_BRANCH); const version = frameworkVersion ? ` (${wrapInCode(`${FRAMEWORK_CORE_PACKAGE}@${frameworkVersion}`)})` : ''; const umbrellaToMove = stableUmbrella - ? ` The open ${wrapInCode(STABLE_BRANCH)} umbrella (#${stableUmbrella.number}) needs to be squashed into the prerelease branch by a maintainer.` + ? ` 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 ${stableBranch} 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}.${umbrellaToMove}`; + 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 { diff --git a/scripts/lib/release-queue/accepted/queries.ts b/scripts/lib/release-queue/accepted/queries.ts index 626f09b3e1bc0..26eaa6780f748 100644 --- a/scripts/lib/release-queue/accepted/queries.ts +++ b/scripts/lib/release-queue/accepted/queries.ts @@ -1,4 +1,4 @@ -import { STABLE_BRANCH } from '../config.ts'; +import { DOCS_STABLE_BRANCH } from '../config.ts'; import { GitHubError } from '../github/error.ts'; import { compareBranches, type Repository } from '../github/repository.ts'; import type { @@ -180,7 +180,7 @@ async function getWriters(repo: Repository, users: string[]): Promise { - const rules = await repo.getAll(`rules/branches/${STABLE_BRANCH}`); + 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 ?? []) diff --git a/scripts/lib/release-queue/accepted/routing.test.ts b/scripts/lib/release-queue/accepted/routing.test.ts index f85a986c863ad..861e6a58d7ea8 100644 --- a/scripts/lib/release-queue/accepted/routing.test.ts +++ b/scripts/lib/release-queue/accepted/routing.test.ts @@ -40,154 +40,150 @@ const settled: DecisionContext = { const refusalsOf = (decision: Decision) => (decision.status === 'refuse' ? decision.refusals : []); describe('decideJoin', () => { - describe('routes a ready docs PR', () => { - for (const label of ['patch-release', 'minor-release']) { - it(`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('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('to the umbrella it already targets, without retargeting', () => { - assert.deepEqual(decideJoin(pr({ base: 'release-queue/main' }), ready, patch), { + 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: 'patch-release', - shouldRetarget: false, + 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, + }, + }); }); - describe('refuses', () => { - it('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('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('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 closed PR, and nothing else', () => { + const decision = decideJoin(pr({ state: 'closed', isDraft: true }), ready, patch); + assert.deepEqual(decision, { status: 'refuse', refusals: [{ code: 'closed' }] }); + }); - it('a draft PR', () => { - assert.deepEqual(refusalsOf(decideJoin(pr({ isDraft: true }), ready, patch)), [ - { code: 'draft' }, - ]); - }); + 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('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 draft PR', () => { + assert.deepEqual(refusalsOf(decideJoin(pr({ isDraft: true }), ready, patch)), [ + { code: 'draft' }, + ]); + }); - it('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 PR without a release label', () => { + const decision = decideJoin(pr({ labels: ['accepted'] }), ready, patch); + assert.deepEqual(decision, { status: 'refuse', refusals: [{ code: 'no-release-label' }] }); + }); - it('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 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('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 main', () => { + const decision = decideJoin(pr({ labels: ['major-release'] }), ready, patch); + assert.deepEqual(refusalsOf(decision), [ + { code: 'mismatch', base: 'main', label: 'major-release' }, + ]); + }); - it('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 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('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 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('a PR that is not ready, with every reason', () => { - const decision = decideJoin( - pr(), - { - isApproved: false, - changesRequestedBy: ['alice'], - failingChecks: ['links'], - pendingChecks: ['check'], - missingChecks: ['zizmor'], - isMergeable: false, - }, - 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 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('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 PR that is not ready, with every reason', () => { + const decision = decideJoin( + pr(), + { + isApproved: false, + changesRequestedBy: ['alice'], + failingChecks: ['links'], + pendingChecks: ['check'], + missingChecks: ['zizmor'], + isMergeable: false, + }, + 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('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 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('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('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', () => { diff --git a/scripts/lib/release-queue/accepted/routing.ts b/scripts/lib/release-queue/accepted/routing.ts index b101d5542451e..be0ae22aa6c88 100644 --- a/scripts/lib/release-queue/accepted/routing.ts +++ b/scripts/lib/release-queue/accepted/routing.ts @@ -2,7 +2,7 @@ import { ALL_RELEASE_LABELS, RELEASE_LABELS, RELEASE_TYPES, - STABLE_BRANCH, + DOCS_STABLE_BRANCH, type ReleaseLabel, } from '../config.ts'; import type { PullRequestState } from '../github/types.ts'; @@ -83,14 +83,11 @@ type Routing = { status: 'routed'; route: Route } | { status: 'refused'; refusal * Whether the bot would change the base of an open docs PR from the stable * branch to the stable umbrella branch. * - * These are the only PRs a settled stable umbrella refuses: a PR against the - * umbrella branch says it belongs to that umbrella. - * * @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 === STABLE_BRANCH; + return pr.state === 'open' && pr.base === DOCS_STABLE_BRANCH; } /** @@ -140,7 +137,7 @@ function getRoute( // Nothing marks a prerelease branch: any base but the stable branch is one. const isMajor = label === RELEASE_LABELS.major; - const isStableTarget = umbrellaBase === STABLE_BRANCH; + 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. diff --git a/scripts/lib/release-queue/cli.ts b/scripts/lib/release-queue/cli.ts index fb1015b28eec2..ae138d3ded6e2 100644 --- a/scripts/lib/release-queue/cli.ts +++ b/scripts/lib/release-queue/cli.ts @@ -1,4 +1,4 @@ -import { isRecord, parseJson } from './github/json.ts'; +import { isRecord, parseJson } from './json.ts'; /** A positive integer, written with digits only. */ const PR_NUMBER = /^[1-9]\d*$/; diff --git a/scripts/lib/release-queue/config.ts b/scripts/lib/release-queue/config.ts index 65fda647576fb..da4c92ab831e3 100644 --- a/scripts/lib/release-queue/config.ts +++ b/scripts/lib/release-queue/config.ts @@ -1,14 +1,10 @@ // The names the release queue uses: repositories, branches, labels, and the // accounts and directives it relies on. -export const DOCS_REPO = 'withastro/docs'; -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 docs repository. -export const STABLE_BRANCH = 'main'; +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. */ @@ -16,6 +12,24 @@ 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]; @@ -30,12 +44,4 @@ export type ReleaseLabel = (typeof RELEASE_LABELS)[keyof typeof RELEASE_LABELS]; export const ALL_RELEASE_LABELS: ReleaseLabel[] = Object.values(RELEASE_LABELS); -/** The label of a release whose type isn't known yet. */ export const FALLBACK_RELEASE_LABEL: ReleaseLabel = RELEASE_LABELS.patch; - -/** 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'; diff --git a/scripts/lib/release-queue/github/client.test.ts b/scripts/lib/release-queue/github/client.test.ts index 2e20a659424dd..8689409bdb1f8 100644 --- a/scripts/lib/release-queue/github/client.test.ts +++ b/scripts/lib/release-queue/github/client.test.ts @@ -105,20 +105,6 @@ describe('GitHubClient', () => { ); }); - it('prints writes without sending them when asked to', 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); - }); - 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) => { @@ -164,3 +150,19 @@ describe('GitHubClient', () => { }); }); }); + +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 index 673ae1850ca59..56a948bdcc88d 100644 --- a/scripts/lib/release-queue/github/client.ts +++ b/scripts/lib/release-queue/github/client.ts @@ -1,6 +1,6 @@ import { setTimeout } from 'node:timers/promises'; import { GitHubError } from './error.ts'; -import { isRecord, parseJson } from './json.ts'; +import { isRecord, parseJson } from '../json.ts'; const API_URL = 'https://api.github.com'; const API_VERSION = '2022-11-28'; @@ -40,9 +40,12 @@ export class GitHubClient { * @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. */ - get(path: string): Promise { - return this.#request('GET', path); + 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; } /** @@ -104,7 +107,7 @@ export class GitHubClient { return this.#request(method, path, body); } - async #request(method: string, path: string, body?: unknown): Promise { + async #request(method: string, path: string, body?: unknown): Promise { const response = await fetch(`${API_URL}/${path}`, { method, headers: { @@ -117,7 +120,7 @@ export class GitHubClient { }); const request = `${method} ${path}`; - if (response.status === 204) return undefined as T; + 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(); diff --git a/scripts/lib/release-queue/github/json.ts b/scripts/lib/release-queue/json.ts similarity index 100% rename from scripts/lib/release-queue/github/json.ts rename to scripts/lib/release-queue/json.ts diff --git a/scripts/lib/release-queue/main.test.ts b/scripts/lib/release-queue/main.test.ts index b3aaac11d56cc..74f49563b99bb 100644 --- a/scripts/lib/release-queue/main.test.ts +++ b/scripts/lib/release-queue/main.test.ts @@ -79,7 +79,7 @@ describe('runReleaseQueue', () => { flags: { ...noFlags, refresh: true }, env: { ...env, GITHUB_ACTIONS: 'true', MODE: 'all' }, }), - /^Error: BOT_TOKEN is required in all mode\.$/ + /^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 index 27a86d030c5bf..13e426cc49ffe 100644 --- a/scripts/lib/release-queue/main.ts +++ b/scripts/lib/release-queue/main.ts @@ -19,11 +19,11 @@ interface Flags { /** The environment variables the release queue reads. */ interface Env { - /** For reads, dry-run comments and merge-block reviews. */ + /** Used for reads, dry-run comments and merge-block reviews. */ GITHUB_TOKEN?: string | undefined; - /** The token of the bot account, for joins, refusals and refreshes. */ + /** Used for joins, refusals and refreshes. */ BOT_TOKEN?: string | undefined; - /** The mode of the bot. */ + /** The bot mode. */ MODE?: string | undefined; // The variables below are set by GitHub Actions. GITHUB_ACTIONS?: string | undefined; @@ -73,7 +73,7 @@ function createRunContext(flags: Flags, env: Env): RunContext { // trigger the checks of the umbrella PR. const botToken = env.BOT_TOKEN; if (shouldWrite && !isDryRun(mode) && !botToken) { - throw new Error(`BOT_TOKEN is required in ${mode} mode.`); + throw new Error(`BOT_TOKEN is required when MODE is ${mode}.`); } const Client = shouldWrite ? GitHubClient : PrintingGitHubClient; diff --git a/scripts/lib/release-queue/modes.ts b/scripts/lib/release-queue/modes.ts index cc7d20c5e3fe9..012932f937854 100644 --- a/scripts/lib/release-queue/modes.ts +++ b/scripts/lib/release-queue/modes.ts @@ -1,4 +1,4 @@ -import { STABLE_BRANCH } from './config.ts'; +import { DOCS_STABLE_BRANCH } from './config.ts'; const MODES = { dryRun: 'dry-run', stableOnly: 'stable-only', all: 'all' } as const; @@ -14,11 +14,11 @@ export type Mode = (typeof MODES)[keyof typeof MODES]; const ALL_MODES: Mode[] = Object.values(MODES); /** - * Reads the mode of the bot. + * Reads the bot mode. * - * @param {string} [value] - The value of `MODE`. Without one, a dry run. - * @returns {Mode} The mode `value` names. - * @throws {Error} When `value` isn't a 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); @@ -30,18 +30,18 @@ export function parseMode(value: string = MODES.dryRun): 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 mode of the bot. + * @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 === STABLE_BRANCH); + return mode === MODES.all || (mode === MODES.stableOnly && umbrellaBase === DOCS_STABLE_BRANCH); } /** * Whether the bot only comments, without changing PRs. * - * @param {Mode} mode - The mode of the bot. + * @param {Mode} mode - The bot mode. * @returns {boolean} `true` in a dry run. */ export function isDryRun(mode: Mode): boolean { diff --git a/scripts/lib/release-queue/release/queries.ts b/scripts/lib/release-queue/release/queries.ts index 537b02d0bd7c1..5d88c1266fd5b 100644 --- a/scripts/lib/release-queue/release/queries.ts +++ b/scripts/lib/release-queue/release/queries.ts @@ -1,17 +1,17 @@ import { FRAMEWORK_PRERELEASE_BRANCH } from '../config.ts'; import { GitHubError } from '../github/error.ts'; -import { isRecord, parseJson } from '../github/json.ts'; +import { isRecord, parseJson } from '../json.ts'; import type { Repository } from '../github/repository.ts'; import type { PullRequestResponse } from '../github/types.ts'; import { parseReleaseVersions, type ReleasePullRequest } from './release.ts'; -/** The branch prefix of the release PRs that changesets opens. */ +/** 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'; /** - * Gets the open release PR of a branch of the framework repository. + * 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 @@ -81,8 +81,7 @@ export async function getPrereleasePullRequest( * * @param {Repository} framework - The framework repository. * @param {number} prNumber - The number of the PR. - * @returns {Promise} `false` for a PR that doesn't exist, for example - * from an edited marker. + * @returns {Promise} `false` for a PR that doesn't exist. */ export async function isFrameworkPullRequestMerged( framework: Repository, diff --git a/scripts/lib/release-queue/release/release.ts b/scripts/lib/release-queue/release/release.ts index 69192f17994c7..bc958895bfc80 100644 --- a/scripts/lib/release-queue/release/release.ts +++ b/scripts/lib/release-queue/release/release.ts @@ -26,7 +26,7 @@ export interface StableRelease { type: ReleaseType; /** The version of the framework package, if the release includes it. */ frameworkVersion?: string | undefined; - /** The open release PR. Without one, the release is a patch. */ + /** The open release PR, if any. */ pullRequest?: ReleasePullRequest | undefined; } diff --git a/scripts/lib/release-queue/testing/stub-github.ts b/scripts/lib/release-queue/testing/stub-github.ts index 7330777d961aa..4408ac2eac1eb 100644 --- a/scripts/lib/release-queue/testing/stub-github.ts +++ b/scripts/lib/release-queue/testing/stub-github.ts @@ -1,6 +1,6 @@ import type { TestContext } from 'node:test'; import { GitHubClient, PrintingGitHubClient } from '../github/client.ts'; -import { isRecord } from '../github/json.ts'; +import { isRecord } from '../json.ts'; import { Repository } from '../github/repository.ts'; import { REPO } from './fixtures.ts'; diff --git a/scripts/lib/release-queue/umbrella/body.ts b/scripts/lib/release-queue/umbrella/body.ts index 258156d6c93c7..8cc4131be7bec 100644 --- a/scripts/lib/release-queue/umbrella/body.ts +++ b/scripts/lib/release-queue/umbrella/body.ts @@ -1,4 +1,4 @@ -import { FRAMEWORK_REPO, RELEASE_TITLE_PREFIX, STABLE_BRANCH } from '../config.ts'; +import { FRAMEWORK_REPO, RELEASE_TITLE_PREFIX, DOCS_STABLE_BRANCH } from '../config.ts'; import { formatFrameworkReference } from '../release/release.ts'; /** Writes the hidden marker that keeps the release PR an umbrella links. */ @@ -58,6 +58,6 @@ function describeShipping(umbrellaBase: string, releasePrNumber: number | undefi if (releasePrNumber !== undefined) { return `Ships with ${formatFrameworkReference(releasePrNumber)}.`; } - const release = umbrellaBase === STABLE_BRANCH ? 'release' : 'prerelease'; + const release = umbrellaBase === DOCS_STABLE_BRANCH ? 'release' : 'prerelease'; return `Ships with the next ${release} of \`${FRAMEWORK_REPO}\`, once its \`${RELEASE_TITLE_PREFIX}\` PR is open.`; } diff --git a/scripts/lib/release-queue/umbrella/branch.ts b/scripts/lib/release-queue/umbrella/branch.ts index 749f13c421430..ea7ce4a239bb2 100644 --- a/scripts/lib/release-queue/umbrella/branch.ts +++ b/scripts/lib/release-queue/umbrella/branch.ts @@ -1,6 +1,6 @@ // What the reserved prefix of umbrella branches means. -import { STABLE_BRANCH, UMBRELLA_BRANCH_PREFIX } from '../config.ts'; +import { DOCS_STABLE_BRANCH, UMBRELLA_BRANCH_PREFIX } from '../config.ts'; /** * Whether a branch is an umbrella branch, by its reserved prefix. @@ -29,8 +29,10 @@ export function getUmbrellaBase(base: string): string { * @param {string} umbrellaBase - The branch the umbrella PR merges into. * @returns {string} The umbrella branch, with its reserved prefix. */ -export function getUmbrellaBranch(umbrellaBase: string): string { - return `${UMBRELLA_BRANCH_PREFIX}${umbrellaBase}`; +export function getUmbrellaBranch( + umbrellaBase: T +): `${typeof UMBRELLA_BRANCH_PREFIX}${T}` { + return `${UMBRELLA_BRANCH_PREFIX}${umbrellaBase}` as const; } -export const STABLE_UMBRELLA_BRANCH = getUmbrellaBranch(STABLE_BRANCH); +export const STABLE_UMBRELLA_BRANCH = getUmbrellaBranch(DOCS_STABLE_BRANCH); diff --git a/scripts/lib/release-queue/umbrella/heading.ts b/scripts/lib/release-queue/umbrella/heading.ts index 7e3bce162a6e4..3339edc83be58 100644 --- a/scripts/lib/release-queue/umbrella/heading.ts +++ b/scripts/lib/release-queue/umbrella/heading.ts @@ -2,7 +2,7 @@ import { FRAMEWORK_CORE_PACKAGE, RELEASE_LABELS, RELEASE_TITLE_PREFIX, - STABLE_BRANCH, + DOCS_STABLE_BRANCH, type ReleaseLabel, } from '../config.ts'; import type { StableRelease } from '../release/release.ts'; @@ -10,20 +10,20 @@ 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, as - * merged PRs are posted to Discord. A prerelease umbrella is named after its - * 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 !== STABLE_BRANCH) return formatUmbrellaTitle(umbrellaBase); + if (umbrellaBase !== DOCS_STABLE_BRANCH) return formatUmbrellaTitle(umbrellaBase); return formatUmbrellaTitle( release.frameworkVersion ? `${FRAMEWORK_CORE_PACKAGE}@${release.frameworkVersion}` - : STABLE_BRANCH + : DOCS_STABLE_BRANCH ); } @@ -44,5 +44,5 @@ export function getUmbrellaReleaseLabel( umbrellaBase: string, release: StableRelease ): ReleaseLabel { - return umbrellaBase === STABLE_BRANCH ? RELEASE_LABELS[release.type] : RELEASE_LABELS.major; + return umbrellaBase === DOCS_STABLE_BRANCH ? RELEASE_LABELS[release.type] : RELEASE_LABELS.major; } diff --git a/scripts/lib/release-queue/umbrella/merge-block.test.ts b/scripts/lib/release-queue/umbrella/merge-block.test.ts index 3a6548732b6b9..fdf61de72e086 100644 --- a/scripts/lib/release-queue/umbrella/merge-block.test.ts +++ b/scripts/lib/release-queue/umbrella/merge-block.test.ts @@ -23,7 +23,7 @@ const checklist = [ '', '- [ ] withastro/astro#18405 is merged.', '- [ ] No open PR targets `release-queue/main`.', - '- [ ] If the umbrella has the `i18n` label, the commit body has a [Lunaria directive](https://lunaria.dev/guides/tracking/#triggering-changes-per-file), for example `@lunaria-track:src/content/docs/en/**.mdx`. The squash commit body is empty by default: add it in the merge dialog.', + '- [ ] 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'); @@ -77,7 +77,7 @@ describe('syncMergeBlockReview', () => { assert.doesNotMatch(umbrella.labels.join(), /i18n/); assert.match( await submittedChecklist(t, umbrella), - /- \[ \] If the umbrella has the `i18n` label, the commit body has a \[Lunaria directive\]/ + /- \[ \] If the umbrella has the `i18n` label, add a \[Lunaria directive\]/ ); }); }); diff --git a/scripts/lib/release-queue/umbrella/merge-block.ts b/scripts/lib/release-queue/umbrella/merge-block.ts index 17384dfde0b7a..c3088e01d2fce 100644 --- a/scripts/lib/release-queue/umbrella/merge-block.ts +++ b/scripts/lib/release-queue/umbrella/merge-block.ts @@ -55,7 +55,7 @@ function describeBranchItem( * after the merge, with `GITHUB_TOKEN`, which doesn't trigger this workflow. */ function describeLunariaItem(): string { - return `If the umbrella has the \`${TRANSLATIONS_LABEL}\` label, the commit body has a [Lunaria directive](${LUNARIA_DIRECTIVE_DOCS}), for example \`${LUNARIA_DIRECTIVE}\`. The squash commit body is empty by default: add it in the merge dialog.`; + return `If the umbrella has the \`${TRANSLATIONS_LABEL}\` label, add a [Lunaria directive](${LUNARIA_DIRECTIVE_DOCS}) to the commit body in the merge dialog. For example: \`${LUNARIA_DIRECTIVE}\`.`; } /** diff --git a/scripts/lib/release-queue/umbrella/queries.ts b/scripts/lib/release-queue/umbrella/queries.ts index 836d22150a8a2..d704ace2bc21e 100644 --- a/scripts/lib/release-queue/umbrella/queries.ts +++ b/scripts/lib/release-queue/umbrella/queries.ts @@ -5,7 +5,6 @@ import { getUmbrellaBase, isUmbrellaBranch } from './branch.ts'; /** The title GitHub gives a squash commit: the PR title, then its number. */ const SQUASH_TITLE = / \(#(\d+)\)$/; -/** An umbrella PR, with what the refresh reads from it. */ export interface UmbrellaPullRequest { number: number; title: string; diff --git a/scripts/lib/release-queue/umbrella/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts index 551a58b2eb2ae..15e80652ef5c1 100644 --- a/scripts/lib/release-queue/umbrella/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -1,4 +1,4 @@ -import { FRAMEWORK_STABLE_BRANCH, RELEASE_TYPES, STABLE_BRANCH } from '../config.ts'; +import { FRAMEWORK_STABLE_BRANCH, RELEASE_TYPES, DOCS_STABLE_BRANCH } from '../config.ts'; import { removeLabel, type Repository } from '../github/repository.ts'; import { canActOn } from '../modes.ts'; import { getPrereleasePullRequest, getReleasePullRequest } from '../release/queries.ts'; @@ -69,7 +69,7 @@ export async function refreshUmbrella( * maintainer, who moves it to the prerelease branch. */ function isBeforeMajor(umbrella: UmbrellaPullRequest, release: StableRelease): boolean { - return umbrella.base === STABLE_BRANCH && release.type === RELEASE_TYPES.major; + return umbrella.base === DOCS_STABLE_BRANCH && release.type === RELEASE_TYPES.major; } /** @@ -83,7 +83,7 @@ async function getFollowedReleasePrNumber( release: StableRelease ): Promise { const releasePullRequest = - umbrella.base === STABLE_BRANCH + umbrella.base === DOCS_STABLE_BRANCH ? release.pullRequest : await getPrereleasePullRequest(framework, release.pullRequest); return releasePullRequest?.number; diff --git a/scripts/lib/release-queue/umbrella/state.ts b/scripts/lib/release-queue/umbrella/state.ts index 475a4d1a2a501..acd6b3b72d841 100644 --- a/scripts/lib/release-queue/umbrella/state.ts +++ b/scripts/lib/release-queue/umbrella/state.ts @@ -1,4 +1,4 @@ -import { STABLE_BRANCH } from '../config.ts'; +import { DOCS_STABLE_BRANCH } from '../config.ts'; import type { Repository } from '../github/repository.ts'; import { isFrameworkPullRequestMerged } from '../release/queries.ts'; import type { StableRelease } from '../release/release.ts'; @@ -44,7 +44,7 @@ export async function getReleaseState( release: StableRelease ): Promise { // A prerelease umbrella always follows: its branch has continuous betas. - if (umbrella.base !== STABLE_BRANCH) return { status: 'follows' }; + if (umbrella.base !== DOCS_STABLE_BRANCH) return { status: 'follows' }; const linkedReleasePrNumber = parseReleaseMarker(umbrella.body); const openReleasePrNumber = release.pullRequest?.number; if ( From 7553b9547270a1f356169359e94c28bca5f06b8a Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 18:24:50 +0100 Subject: [PATCH 12/35] Make local runs of the release queue print-only Remove the `--write` flag: outside GitHub Actions, the bot now always prints the writes it would make. --- scripts/lib/release-queue/main.test.ts | 22 ++++++++++++++++++++-- scripts/lib/release-queue/main.ts | 10 +++++----- scripts/release-queue.ts | 2 -- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/scripts/lib/release-queue/main.test.ts b/scripts/lib/release-queue/main.test.ts index 74f49563b99bb..ae1e6227c156f 100644 --- a/scripts/lib/release-queue/main.test.ts +++ b/scripts/lib/release-queue/main.test.ts @@ -4,10 +4,10 @@ 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 } from './testing/fixtures.ts'; +import { REPO, umbrellaResponse } from './testing/fixtures.ts'; import { stubGitHub } from './testing/stub-github.ts'; -const noFlags = { refresh: false, write: false }; +const noFlags = { refresh: false }; const env = { GITHUB_TOKEN: 'actions-token', GITHUB_REPOSITORY: REPO }; /** Writes `event` to a temporary file, as GitHub Actions does. */ @@ -73,6 +73,24 @@ describe('runReleaseQueue', () => { 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: { 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({ diff --git a/scripts/lib/release-queue/main.ts b/scripts/lib/release-queue/main.ts index 13e426cc49ffe..1a7c54db0cf8e 100644 --- a/scripts/lib/release-queue/main.ts +++ b/scripts/lib/release-queue/main.ts @@ -14,7 +14,6 @@ interface Flags { refresh: boolean; /** Who added the label, for the write access check. */ sender?: string | undefined; - write: boolean; } /** The environment variables the release queue reads. */ @@ -36,7 +35,7 @@ interface Env { * 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 printed unless `write` is set. + * 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. @@ -45,7 +44,7 @@ interface Env { * flags and the event don't say what to do. */ export async function runReleaseQueue({ flags, env }: { flags: Flags; env: Env }) { - const run = createRunContext(flags, env); + const run = createRunContext(env); const event = await readEvent(env.GITHUB_EVENT_PATH); const action = chooseAction({ pr: flags.pr, @@ -64,11 +63,12 @@ export async function runReleaseQueue({ flags, env }: { flags: Flags; env: Env } const isGitHubActions = (env: Env) => env.GITHUB_ACTIONS === 'true'; -function createRunContext(flags: Flags, env: Env): RunContext { +function createRunContext(env: Env): RunContext { const mode = parseMode(env.MODE); const token = env.GITHUB_TOKEN; if (!token) throw new Error('GITHUB_TOKEN is required.'); - const shouldWrite = isGitHubActions(env) || flags.write; + // 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; diff --git a/scripts/release-queue.ts b/scripts/release-queue.ts index 86f9fb65262e8..5415b9daa7fca 100644 --- a/scripts/release-queue.ts +++ b/scripts/release-queue.ts @@ -14,8 +14,6 @@ const { values: flags } = parseArgs({ refresh: { type: 'boolean', default: false }, // Who added the label, for the write access check. sender: { type: 'string' }, - // Send the writes instead of printing them, in a local run. - write: { type: 'boolean', default: false }, }, }); From ff03c21c864deeb2e1e143d36fee03c00c6e7139 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 18:30:51 +0100 Subject: [PATCH 13/35] Rename the refresh flag to `--preview-refresh` Local runs only print what they would write, `--refresh` was ambiguous. --- scripts/lib/release-queue/cli.test.ts | 4 ++-- scripts/lib/release-queue/cli.ts | 7 ++++--- scripts/lib/release-queue/main.test.ts | 10 +++++----- scripts/lib/release-queue/main.ts | 4 ++-- scripts/release-queue.ts | 8 +++++--- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/scripts/lib/release-queue/cli.test.ts b/scripts/lib/release-queue/cli.test.ts index 24a81bc109d5c..006103c4fe0c6 100644 --- a/scripts/lib/release-queue/cli.test.ts +++ b/scripts/lib/release-queue/cli.test.ts @@ -14,7 +14,7 @@ describe('chooseAction', () => { assert.deepEqual(chooseAction({ pr: '14580' }), { type: 'handle', prNumber: 14580 }); }); - it('refreshes on a workflow_dispatch, or with --refresh', () => { + 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' }); }); @@ -29,7 +29,7 @@ describe('chooseAction', () => { } }); - it('refuses --refresh with --pr', () => { + it('refuses --preview-refresh with --pr', () => { assert.throws(() => chooseAction({ pr: '7', isRefreshRequested: true }), /not both/); }); }); diff --git a/scripts/lib/release-queue/cli.ts b/scripts/lib/release-queue/cli.ts index ae138d3ded6e2..37a017c704e6e 100644 --- a/scripts/lib/release-queue/cli.ts +++ b/scripts/lib/release-queue/cli.ts @@ -14,8 +14,8 @@ type Action = { type: 'handle'; prNumber: number } | { type: 'refresh' }; * * @param {object} signals - The flags and the event of the run. * @param {string} [signals.pr] - The value of `--pr`. - * @param {boolean} [signals.isRefreshRequested] - Whether `--refresh` was - * passed. + * @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. * @returns {Action} The refresh, or the PR to handle. @@ -34,7 +34,8 @@ export function chooseAction({ eventPullRequest?: number | undefined; }): Action { const shouldRefresh = isRefreshRequested || eventName === 'workflow_dispatch'; - if (shouldRefresh && pr !== undefined) throw new Error('Pass --pr or --refresh, not both.'); + if (shouldRefresh && pr !== undefined) + throw new Error('Pass --pr or --preview-refresh, not both.'); if (shouldRefresh) return { type: 'refresh' }; const rawPrNumber = pr ?? eventPullRequest?.toString(); diff --git a/scripts/lib/release-queue/main.test.ts b/scripts/lib/release-queue/main.test.ts index ae1e6227c156f..4b601b355f203 100644 --- a/scripts/lib/release-queue/main.test.ts +++ b/scripts/lib/release-queue/main.test.ts @@ -7,7 +7,7 @@ import { runReleaseQueue } from './main.ts'; import { REPO, umbrellaResponse } from './testing/fixtures.ts'; import { stubGitHub } from './testing/stub-github.ts'; -const noFlags = { refresh: false }; +const noFlags = { 'preview-refresh': false }; const env = { GITHUB_TOKEN: 'actions-token', GITHUB_REPOSITORY: REPO }; /** Writes `event` to a temporary file, as GitHub Actions does. */ @@ -20,7 +20,7 @@ async function writeEvent(event: unknown): Promise { const silence = (t: TestContext) => t.mock.method(console, 'info', () => {}); describe('runReleaseQueue', () => { - it('refreshes the open umbrellas with --refresh', async (t) => { + it('previews the refresh of the open umbrellas with --preview-refresh', async (t) => { silence(t); const github = stubGitHub(t, { getResponses: { @@ -29,7 +29,7 @@ describe('runReleaseQueue', () => { }, }); - await runReleaseQueue({ flags: { ...noFlags, refresh: true }, env }); + await runReleaseQueue({ flags: { 'preview-refresh': true }, env }); assert.equal(github.countReads(`repos/${REPO}/pulls?state=open`), 1); assert.deepEqual(github.writes, []); @@ -85,7 +85,7 @@ describe('runReleaseQueue', () => { }, }); - await runReleaseQueue({ flags: { refresh: true }, env: { ...env, MODE: 'all' } }); + await runReleaseQueue({ flags: { 'preview-refresh': true }, env: { ...env, MODE: 'all' } }); assert.ok(github.calls.length > 0); assert.deepEqual(github.writes, []); @@ -94,7 +94,7 @@ describe('runReleaseQueue', () => { it('requires the bot token to write outside a dry run', async () => { await assert.rejects( runReleaseQueue({ - flags: { ...noFlags, refresh: true }, + 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 index 1a7c54db0cf8e..6c68d43921983 100644 --- a/scripts/lib/release-queue/main.ts +++ b/scripts/lib/release-queue/main.ts @@ -11,7 +11,7 @@ import { refreshOpenUmbrellas } from './umbrella/refresh.ts'; /** The command-line flags of `scripts/release-queue.ts`. */ interface Flags { pr?: string | undefined; - refresh: boolean; + 'preview-refresh': boolean; /** Who added the label, for the write access check. */ sender?: string | undefined; } @@ -48,7 +48,7 @@ export async function runReleaseQueue({ flags, env }: { flags: Flags; env: Env } const event = await readEvent(env.GITHUB_EVENT_PATH); const action = chooseAction({ pr: flags.pr, - isRefreshRequested: flags.refresh, + isRefreshRequested: flags['preview-refresh'], eventName: env.GITHUB_EVENT_NAME, eventPullRequest: event.pullRequest, }); diff --git a/scripts/release-queue.ts b/scripts/release-queue.ts index 5415b9daa7fca..49a39f37c89c7 100644 --- a/scripts/release-queue.ts +++ b/scripts/release-queue.ts @@ -4,14 +4,16 @@ import { parseArgs } from 'node:util'; import { runReleaseQueue } from './lib/release-queue/main.ts'; -// In GitHub Actions, the PR and the sender come from the event, and a +// 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' }, - // Refresh every open umbrella PR, instead of routing `--pr`. - refresh: { type: 'boolean', default: false }, + // 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' }, }, From 54cc9f17a6838a2c59312e5922190db8a01143e4 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 18:42:18 +0100 Subject: [PATCH 14/35] Turn the release queue join into a class The repository, the PR, and the route went through every step of the join. `UmbrellaJoin` takes them once. --- scripts/lib/release-queue/accepted/handle.ts | 5 +- .../lib/release-queue/accepted/join.test.ts | 70 +++- scripts/lib/release-queue/accepted/join.ts | 298 +++++++++--------- 3 files changed, 208 insertions(+), 165 deletions(-) diff --git a/scripts/lib/release-queue/accepted/handle.ts b/scripts/lib/release-queue/accepted/handle.ts index 89a8b31846b1e..9cdfc2ff7d536 100644 --- a/scripts/lib/release-queue/accepted/handle.ts +++ b/scripts/lib/release-queue/accepted/handle.ts @@ -10,7 +10,7 @@ import { syncMergeBlockReview } from '../umbrella/merge-block.ts'; import { findOpenUmbrella, getUmbrella, type Umbrella } from '../umbrella/queries.ts'; import { refreshUmbrella } from '../umbrella/refresh.ts'; import { getSettledStableUmbrella } from '../umbrella/state.ts'; -import { joinUmbrella } from './join.ts'; +import { UmbrellaJoin } from './join.ts'; import { formatDryRunJoinComment, formatDryRunRefusalComment, @@ -134,7 +134,8 @@ async function joinAndAnnounce(run: RunContext, accepted: AcceptedPullRequest { +/** 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 joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); + 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, [ @@ -105,7 +113,11 @@ describe('joinUmbrella', () => { it('joins the open umbrella PR', async (t) => { const github = stubGitHub(t, { getResponses: withUmbrella }); - const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); + 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]); @@ -120,7 +132,11 @@ describe('joinUmbrella', () => { writeResults: createdUmbrella, }); - const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); + 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); @@ -130,7 +146,7 @@ describe('joinUmbrella', () => { const github = stubGitHub(t, { getResponses: withUmbrella }); const umbrellaPr = { ...pr, base: 'release-queue/main' }; - await joinUmbrella(github.repository(), { + await mergeIntoUmbrella(github.repository(), { pr: umbrellaPr, route: { ...route, shouldRetarget: false }, umbrellaTitle, @@ -143,7 +159,7 @@ describe('joinUmbrella', () => { const github = stubGitHub(t, { getResponses: withoutUmbrella, writeResults: createdUmbrella }); const umbrellaPr = { ...pr, base: 'release-queue/main' }; - await joinUmbrella(github.repository(), { + await mergeIntoUmbrella(github.repository(), { pr: umbrellaPr, route: { ...route, shouldRetarget: false }, umbrellaTitle, @@ -162,7 +178,7 @@ describe('joinUmbrella', () => { writeResults: createdUmbrella, }); - await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); + await mergeIntoUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); assert.deepEqual(github.writes, [ { @@ -185,7 +201,11 @@ describe('joinUmbrella', () => { }, }); - const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); + const result = await mergeIntoUmbrella(github.repository(), { + pr: pr, + route: route, + umbrellaTitle, + }); assert.deepEqual(result, { status: 'refused', @@ -200,7 +220,7 @@ describe('joinUmbrella', () => { const github = stubGitHub(t, { getResponses: withoutStable }); await assert.rejects( - joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), + mergeIntoUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), /doesn't exist/ ); assert.deepEqual(github.writes, []); @@ -211,7 +231,11 @@ describe('joinUmbrella', () => { getResponses: { ...withUmbrella, [paths.pr]: prResponse(null) }, }); - const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); + 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]); @@ -222,7 +246,11 @@ describe('joinUmbrella', () => { getResponses: { ...withUmbrella, [paths.pr]: prResponse(false) }, }); - const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); + const result = await mergeIntoUmbrella(github.repository(), { + pr: pr, + route: route, + umbrellaTitle, + }); assert.deepEqual(result, { status: 'refused', @@ -242,7 +270,11 @@ describe('joinUmbrella', () => { }, }); - const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); + const result = await mergeIntoUmbrella(github.repository(), { + pr: pr, + route: route, + umbrellaTitle, + }); assert.deepEqual(result, { status: 'refused', @@ -258,7 +290,7 @@ describe('joinUmbrella', () => { }); await assert.rejects( - joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), + mergeIntoUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), TypeError ); assert.deepEqual(github.writes, [setBase('release-queue/main'), merge, setBase('main')]); @@ -272,7 +304,7 @@ describe('joinUmbrella', () => { }); await assert.rejects( - joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), + mergeIntoUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }), (error: unknown) => { assert.ok(error instanceof AggregateError); assert.deepEqual(error.errors, [restoreError]); @@ -294,7 +326,11 @@ describe('joinUmbrella', () => { }, }); - const result = await joinUmbrella(github.repository(), { pr: pr, route: route, umbrellaTitle }); + const result = await mergeIntoUmbrella(github.repository(), { + pr: pr, + route: route, + umbrellaTitle, + }); assert.equal(result.status, 'unopened'); assert.ok(result.error instanceof GitHubError); @@ -312,7 +348,7 @@ describe('joinUmbrella', () => { t.mock.method(console, 'info', () => {}); const printingClient = github.repository(REPO, { shouldPrintWrites: true }); - const result = await joinUmbrella(printingClient, { pr: pr, route: route, umbrellaTitle }); + const result = await mergeIntoUmbrella(printingClient, { pr: pr, route: route, umbrellaTitle }); assert.deepEqual(result, { status: 'joined', diff --git a/scripts/lib/release-queue/accepted/join.ts b/scripts/lib/release-queue/accepted/join.ts index 5d3197dd96d09..6e147d3e4395d 100644 --- a/scripts/lib/release-queue/accepted/join.ts +++ b/scripts/lib/release-queue/accepted/join.ts @@ -5,7 +5,7 @@ import { countCommitsAhead, getBranchSha, getPullRequest, type PullRequest } fro import type { JoinRefusal, Route } from './routing.ts'; /** A docs PR to join to the umbrella of `route`. */ -interface Join { +interface JoinRequest { pr: PullRequest; route: Route; /** The title given to the umbrella PR when the join opens it. */ @@ -32,167 +32,173 @@ export type UnopenedJoin = Extract; type UmbrellaBranch = { state: 'missing' | 'empty'; umbrellaBaseSha: string } | { state: 'stale' }; /** - * Squash-merges a docs PR into its umbrella branch. - * - * Prepares the branch before changing the PR, then changes the PR base, - * merges, and opens the umbrella PR if none is open: GitHub refuses a PR - * without commits. A refused merge restores the PR base. The refresh sets the - * labels and the body of the umbrella PR. - * - * @param {Repository} repo - The docs repository, written as the bot account. - * @param {Join} join - The PR to merge, its route, and the umbrella title. - * @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. + * 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 async function joinUmbrella( - repo: Repository, - { pr, route, umbrellaTitle }: Join -): Promise { - const openUmbrella = await findOpenUmbrella(repo, route.umbrellaBranch); - const branchRefusal = openUmbrella ? undefined : await prepareUmbrellaBranch(repo, route); - if (branchRefusal) return { status: 'refused', refusal: branchRefusal }; - - const mergeRefusal = route.shouldRetarget - ? await retargetAndMerge(repo, pr, route) - : await squashMerge(repo, pr, route); - if (mergeRefusal) return { status: 'refused', refusal: mergeRefusal }; - - if (!openUmbrella) return createUmbrella(repo, route, umbrellaTitle); - return { status: 'joined', umbrella: { status: 'open', number: openUmbrella.number } }; -} +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; + } -/** - * Starts the umbrella branch of a join that opens the umbrella PR. Refuses - * the join when the branch has commits left over. - */ -async function prepareUmbrellaBranch( - repo: Repository, - route: Route -): Promise { - const branch = await readUmbrellaBranch(repo, route); - if (branch.state === 'stale') { - return { code: 'stale-umbrella-branch', branch: route.umbrellaBranch }; + /** + * Squash-merges the docs PR into its umbrella branch. + * + * Prepares the branch before changing the PR, then changes the PR base, + * merges, and opens the umbrella PR if none is open: GitHub refuses a PR + * without commits. A refused merge restores the PR base. The refresh sets + * the labels and the body of the 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 } }; } - await startUmbrellaBranch(repo, route, branch); - return undefined; -} -async function readUmbrellaBranch( - repo: Repository, - { umbrellaBase, umbrellaBranch }: Route -): Promise { - const umbrellaBaseSha = await getBranchSha(repo, umbrellaBase); - if (!umbrellaBaseSha) { - throw new Error(`The branch ${umbrellaBase} doesn't exist in ${repo.name}.`); + /** + * 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; } - if ((await getBranchSha(repo, umbrellaBranch)) === undefined) { - return { state: 'missing', umbrellaBaseSha }; + + async #readUmbrellaBranch(): Promise { + const { umbrellaBase, umbrellaBranch } = this.#route; + const umbrellaBaseSha = await getBranchSha(this.#repo, umbrellaBase); + if (!umbrellaBaseSha) { + throw new Error(`The branch ${umbrellaBase} doesn't exist in ${this.#repo.name}.`); + } + if ((await getBranchSha(this.#repo, umbrellaBranch)) === undefined) { + return { state: 'missing', umbrellaBaseSha }; + } + const commitsAhead = await countCommitsAhead(this.#repo, umbrellaBase, umbrellaBranch); + return commitsAhead > 0 ? { state: 'stale' } : { state: 'empty', umbrellaBaseSha }; } - const commitsAhead = await countCommitsAhead(repo, umbrellaBase, umbrellaBranch); - return commitsAhead > 0 ? { state: 'stale' } : { state: 'empty', umbrellaBaseSha }; -} -/** Creates the umbrella branch from its base, or resets an empty one. */ -async function startUmbrellaBranch( - repo: Repository, - { umbrellaBranch }: Route, - branch: Extract -) { - if (branch.state === 'missing') { - await repo.write('POST', 'git/refs', { - ref: `refs/heads/${umbrellaBranch}`, + /** 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, }); - return; } - await repo.write('PATCH', `git/refs/heads/${umbrellaBranch}`, { - sha: branch.umbrellaBaseSha, - force: true, - }); -} -function setBase(repo: Repository, prNumber: number, base: string) { - return repo.write('PATCH', `pulls/${prNumber}`, { base }); -} + #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 function retargetAndMerge( - repo: Repository, - pr: PullRequest, - route: Route -): Promise { - await setBase(repo, pr.number, route.umbrellaBranch); - // Not a `try` around both paths: a failed restore after a refusal would - // then restore the base a second time. - const refusal = await squashMerge(repo, pr, route).catch(async (error: unknown) => { - await restoreBase(repo, pr, error); - throw error; - }); - if (refusal) await restoreBase(repo, pr, refusal); - return refusal; -} + /** + * 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. If that fails too, the error keeps both: the - * failure to restore, and `cause`, the reason for restoring. - */ -async function restoreBase(repo: Repository, pr: PullRequest, cause: unknown) { - try { - await setBase(repo, pr.number, pr.base); - } catch (error) { - throw new AggregateError([error], `Couldn't restore the base of #${pr.number}.`, { cause }); + /** + * 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 function squashMerge( - repo: Repository, - pr: PullRequest, - route: Route -): Promise { - // GitHub computes the mergeability again after a base change. - const { isMergeable } = await getPullRequest(repo, pr.number); - if (isMergeable === false) return { code: 'umbrella-conflict', branch: route.umbrellaBranch }; - - try { - await 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 #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 function createUmbrella( - repo: Repository, - { umbrellaBase, umbrellaBranch }: Route, - title: string -): Promise | UnopenedJoin> { - try { - const created = await repo.write<{ number: number }>('POST', 'pulls', { - title, - head: umbrellaBranch, - base: umbrellaBase, - body: `Release umbrella for \`${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 }; + 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 \`${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 }; + } } } From 1cb34ac9b85434f68565a838e311b0b8aa177560 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 19:16:32 +0100 Subject: [PATCH 15/35] Move generic repository operations into the GitHub module --- scripts/lib/release-queue/accepted/handle.ts | 10 +-- scripts/lib/release-queue/accepted/join.ts | 4 +- scripts/lib/release-queue/accepted/queries.ts | 71 +------------------ .../lib/release-queue/github/repository.ts | 68 ++++++++++++++++++ 4 files changed, 73 insertions(+), 80 deletions(-) diff --git a/scripts/lib/release-queue/accepted/handle.ts b/scripts/lib/release-queue/accepted/handle.ts index 9cdfc2ff7d536..145dc31a4970d 100644 --- a/scripts/lib/release-queue/accepted/handle.ts +++ b/scripts/lib/release-queue/accepted/handle.ts @@ -1,5 +1,5 @@ import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH } from '../config.ts'; -import { removeLabel, type Repository } from '../github/repository.ts'; +import { canWrite, postComment, removeLabel, type Repository } from '../github/repository.ts'; import { canActOn } from '../modes.ts'; import { getReleasePullRequest } from '../release/queries.ts'; import { getStableRelease, type StableRelease } from '../release/release.ts'; @@ -19,13 +19,7 @@ import { formatUnopenedJoinComment, type MessageContext, } from './messages.ts'; -import { - canWrite, - getPullRequest, - getReadiness, - postComment, - type PullRequest, -} from './queries.ts'; +import { getPullRequest, getReadiness, type PullRequest } from './queries.ts'; import { decideJoin, wouldRetargetFromStable, diff --git a/scripts/lib/release-queue/accepted/join.ts b/scripts/lib/release-queue/accepted/join.ts index 6e147d3e4395d..bf09941635dec 100644 --- a/scripts/lib/release-queue/accepted/join.ts +++ b/scripts/lib/release-queue/accepted/join.ts @@ -1,7 +1,7 @@ import { GitHubError } from '../github/error.ts'; -import type { Repository } from '../github/repository.ts'; +import { countCommitsAhead, getBranchSha, type Repository } from '../github/repository.ts'; import { findOpenUmbrella, type Umbrella } from '../umbrella/queries.ts'; -import { countCommitsAhead, getBranchSha, getPullRequest, type PullRequest } from './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`. */ diff --git a/scripts/lib/release-queue/accepted/queries.ts b/scripts/lib/release-queue/accepted/queries.ts index 26eaa6780f748..2eb72775f967c 100644 --- a/scripts/lib/release-queue/accepted/queries.ts +++ b/scripts/lib/release-queue/accepted/queries.ts @@ -1,6 +1,5 @@ import { DOCS_STABLE_BRANCH } from '../config.ts'; -import { GitHubError } from '../github/error.ts'; -import { compareBranches, type Repository } from '../github/repository.ts'; +import { canWrite, type Repository } from '../github/repository.ts'; import type { CheckRunResponse, CommitStatusResponse, @@ -62,74 +61,6 @@ function getPullRequestWithMergeability(repo: Repository, prNumber: number) { }); } -/** - * Gets the commit a branch points to. - * - * @param {Repository} repo - The repository of the branch. - * @param {string} branch - The branch name. - * @returns {Promise} The commit SHA, or `undefined` when - * the branch doesn't exist. - */ -export async function getBranchSha(repo: Repository, branch: string): Promise { - try { - const { object } = await repo.get<{ object: { sha: string } }>(`git/ref/heads/${branch}`); - return object.sha; - } catch (error) { - if (error instanceof GitHubError && error.status === 404) return undefined; - throw error; - } -} - -/** - * Counts the commits of a branch that aren't in another. - * - * @param {Repository} repo - The repository of both branches. - * @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`. - */ -export async function countCommitsAhead( - repo: Repository, - base: string, - head: string -): Promise { - const { ahead_by } = await compareBranches(repo, base, head); - return ahead_by; -} - -/** - * Comments on a PR. - * - * @param {Repository} repo - The repository of the PR. - * @param {number} prNumber - The number of the PR. - * @param {string} body - The comment, in Markdown. - */ -export async function postComment(repo: Repository, prNumber: number, body: string) { - await repo.write('POST', `issues/${prNumber}/comments`, { body }); -} - -/** - * Whether a user can write to the repository, from their permission on it. - * - * @param {Repository} repo - 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. - */ -export async function canWrite(repo: Repository, user: string): Promise { - try { - const { permission } = await repo.get<{ permission: string }>( - `collaborators/${user}/permission` - ); - // The legacy `permission` field maps `maintain` to `write`, - // and `triage` to `read`. - return permission === 'admin' || permission === 'write'; - } catch (error) { - if (error instanceof GitHubError && error.status === 404) return false; - throw error; - } -} - /** * Gets what decides whether a docs PR is ready to merge, with the rules of * the ruleset of the stable branch. diff --git a/scripts/lib/release-queue/github/repository.ts b/scripts/lib/release-queue/github/repository.ts index 4f5b6928af2f0..35a58303226bb 100644 --- a/scripts/lib/release-queue/github/repository.ts +++ b/scripts/lib/release-queue/github/repository.ts @@ -75,3 +75,71 @@ export async function removeLabel(repo: Repository, prNumber: number, label: str if (!(error instanceof GitHubError && error.status === 404)) throw error; } } + +/** + * Gets the commit a branch points to. + * + * @param {Repository} repo - The repository of the branch. + * @param {string} branch - The branch name. + * @returns {Promise} The commit SHA, or `undefined` when + * the branch doesn't exist. + */ +export async function getBranchSha(repo: Repository, branch: string): Promise { + try { + const { object } = await repo.get<{ object: { sha: string } }>(`git/ref/heads/${branch}`); + return object.sha; + } catch (error) { + if (error instanceof GitHubError && error.status === 404) return undefined; + throw error; + } +} + +/** + * Counts the commits of a branch that aren't in another. + * + * @param {Repository} repo - The repository of both branches. + * @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`. + */ +export async function countCommitsAhead( + repo: Repository, + base: string, + head: string +): Promise { + const { ahead_by } = await compareBranches(repo, base, head); + return ahead_by; +} + +/** + * Comments on a PR. + * + * @param {Repository} repo - The repository of the PR. + * @param {number} prNumber - The number of the PR. + * @param {string} body - The comment, in Markdown. + */ +export async function postComment(repo: Repository, prNumber: number, body: string) { + await repo.write('POST', `issues/${prNumber}/comments`, { body }); +} + +/** + * Whether a user can write to the repository, from their permission on it. + * + * @param {Repository} repo - 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. + */ +export async function canWrite(repo: Repository, user: string): Promise { + try { + const { permission } = await repo.get<{ permission: string }>( + `collaborators/${user}/permission` + ); + // The legacy `permission` field maps `maintain` to `write`, + // and `triage` to `read`. + return permission === 'admin' || permission === 'write'; + } catch (error) { + if (error instanceof GitHubError && error.status === 404) return false; + throw error; + } +} From b827a9456b551a3df2e7bff3263e83bba3f9f76e Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 19:27:20 +0100 Subject: [PATCH 16/35] Refresh umbrellas and their merge-block review in one step --- scripts/lib/release-queue/accepted/handle.ts | 10 +-- .../release-queue/umbrella/refresh.test.ts | 87 ++++++++++--------- scripts/lib/release-queue/umbrella/refresh.ts | 39 ++++++--- 3 files changed, 78 insertions(+), 58 deletions(-) diff --git a/scripts/lib/release-queue/accepted/handle.ts b/scripts/lib/release-queue/accepted/handle.ts index 145dc31a4970d..36a58104c7fac 100644 --- a/scripts/lib/release-queue/accepted/handle.ts +++ b/scripts/lib/release-queue/accepted/handle.ts @@ -6,7 +6,6 @@ import { getStableRelease, type StableRelease } from '../release/release.ts'; import type { RunContext } from '../run-context.ts'; import { getUmbrellaBase, STABLE_UMBRELLA_BRANCH } from '../umbrella/branch.ts'; import { getUmbrellaTitle } from '../umbrella/heading.ts'; -import { syncMergeBlockReview } from '../umbrella/merge-block.ts'; import { findOpenUmbrella, getUmbrella, type Umbrella } from '../umbrella/queries.ts'; import { refreshUmbrella } from '../umbrella/refresh.ts'; import { getSettledStableUmbrella } from '../umbrella/state.ts'; @@ -159,16 +158,15 @@ async function joinAndAnnounce(run: RunContext, accepted: AcceptedPullRequest, umbrella: Umbrella ) { - await postComment(bot, pr.number, formatJoinComment(decision.route, umbrella)); + await postComment(run.bot, pr.number, formatJoinComment(decision.route, umbrella)); // A planned umbrella was only printed. if (umbrella.status === 'planned') return; - const openUmbrella = await getUmbrella(bot, umbrella.number); - const refreshed = await refreshUmbrella({ docs: bot, framework }, openUmbrella, release); - await syncMergeBlockReview(actions, refreshed, { shouldBlock: true }); + const openUmbrella = await getUmbrella(run.bot, umbrella.number); + await refreshUmbrella(run, openUmbrella, { release, shouldBlock: true }); } /** Finds the open umbrella PR of `route`, or names the one to create. */ diff --git a/scripts/lib/release-queue/umbrella/refresh.test.ts b/scripts/lib/release-queue/umbrella/refresh.test.ts index e0834eb8a889a..cd3b628b04099 100644 --- a/scripts/lib/release-queue/umbrella/refresh.test.ts +++ b/scripts/lib/release-queue/umbrella/refresh.test.ts @@ -1,10 +1,9 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; -import { FRAMEWORK_REPO } from '../config.ts'; -import { getStableRelease } from '../release/release.ts'; +import { getStableRelease, type StableRelease } from '../release/release.ts'; import { pullRequestResponse, REPO, umbrellaResponse } from '../testing/fixtures.ts'; import { ACTIONS_TOKEN, BOT_TOKEN, createRunContext } from '../testing/run-context.ts'; -import { stubGitHub, type StubOptions, type Write } from '../testing/stub-github.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 { refreshOpenUmbrellas, refreshUmbrella } from './refresh.ts'; @@ -51,11 +50,16 @@ const prereleaseOf = (number: number) => ({ ], }); -/** The repositories a refresh reads, from the stub. */ -const repositories = (github: ReturnType) => ({ - docs: github.repository(), - framework: github.repository(FRAMEWORK_REPO), -}); +/** Refreshes an umbrella as a refresh run does: without blocking its merge. */ +const refresh = ( + github: ReturnType, + umbrellaToRefresh: UmbrellaPullRequest, + release: StableRelease +) => + refreshUmbrella(createRunContext(github, 'all'), umbrellaToRefresh, { + release, + shouldBlock: false, + }); /** Answers whether release PR `number` of `withastro/astro` is merged. */ const releaseMerged = (number: number, merged: boolean) => ({ @@ -63,7 +67,7 @@ const releaseMerged = (number: number, merged: boolean) => ({ }); function stub(t: TestContext, getResponses: StubOptions['getResponses'] = {}) { - return stubGitHub(t, { getResponses: { ...history(), ...getResponses } }); + return stubGitHub(t, { getResponses: { ...history(), ...reviews(42), ...getResponses } }); } const patchOf = (writes: Write[]) => @@ -81,7 +85,7 @@ describe('refreshUmbrella', () => { const github = stub(t); const unlinked = { ...umbrella, body: 'Release umbrella for `main`.' }; - await refreshUmbrella(repositories(github), unlinked, releaseWith(18405, '7.5.0')); + await refresh(github, unlinked, releaseWith(18405, '7.5.0')); assert.deepEqual(patchOf(github.writes), { body: umbrellaBody(18405) }); }); @@ -89,7 +93,7 @@ describe('refreshUmbrella', () => { it('refreshes an umbrella that links the open release PR', async (t) => { const github = stub(t); - await refreshUmbrella(repositories(github), umbrella, releaseWith(18405, '7.5.0')); + await refresh(github, umbrella, releaseWith(18405, '7.5.0')); assert.deepEqual(github.writes, [ { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, @@ -100,7 +104,7 @@ describe('refreshUmbrella', () => { it('moves to the open release PR when the linked one is not merged', async (t) => { const github = stub(t, releaseMerged(18405, false)); - await refreshUmbrella(repositories(github), umbrella, releaseWith(18460, '7.6.0')); + await refresh(github, umbrella, releaseWith(18460, '7.6.0')); assert.deepEqual(patchOf(github.writes), { title: '[ci] release astro@7.6.0 docs', @@ -111,7 +115,7 @@ describe('refreshUmbrella', () => { it('keeps a settled umbrella as it is, but for the list', async (t) => { const github = stub(t, releaseMerged(18405, true)); - await refreshUmbrella(repositories(github), umbrella, releaseWith(18460, '7.6.0')); + await refresh(github, umbrella, releaseWith(18460, '7.6.0')); assert.deepEqual(github.writes, [ { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, @@ -126,20 +130,9 @@ describe('refreshUmbrella', () => { labels: ['merge-on-release', 'patch-release'], }; - const refreshed = await refreshUmbrella( - repositories(github), - patchUmbrella, - releaseWith(18405, '7.5.0') - ); + await refresh(github, patchUmbrella, releaseWith(18405, '7.5.0')); assert.equal(patchOf(github.writes)?.title, '[ci] release astro@7.5.0 docs'); - // The merge-block review reads the umbrella as it is after the refresh. - assert.deepEqual(refreshed, { - ...patchUmbrella, - title: '[ci] release astro@7.5.0 docs', - body: umbrellaBody(18405), - labels: ['merge-on-release', 'minor-release'], - }); assert.deepEqual(labelWrites(github.writes), [ { method: 'POST', @@ -154,7 +147,7 @@ describe('refreshUmbrella', () => { // No answer for #18405: GitHub says 404. const github = stub(t); - await refreshUmbrella(repositories(github), umbrella, releaseWith(18460, '7.6.0')); + await refresh(github, umbrella, releaseWith(18460, '7.6.0')); assert.deepEqual(patchOf(github.writes), { title: '[ci] release astro@7.6.0 docs', @@ -165,7 +158,7 @@ describe('refreshUmbrella', () => { it('keeps a settled umbrella when the next release is a major', async (t) => { const github = stub(t, releaseMerged(18405, true)); - await refreshUmbrella(repositories(github), umbrella, releaseWith(18460, '8.0.0')); + await refresh(github, umbrella, releaseWith(18460, '8.0.0')); assert.deepEqual(github.writes, [ { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, @@ -175,14 +168,9 @@ describe('refreshUmbrella', () => { it('leaves the main umbrella as it is before a major', async (t) => { const github = stub(t); - const refreshed = await refreshUmbrella( - repositories(github), - umbrella, - releaseWith(18405, '8.0.0') - ); + await refresh(github, umbrella, releaseWith(18405, '8.0.0')); - assert.deepEqual(github.calls, []); - assert.equal(refreshed, umbrella); + assert.deepEqual(github.writes, []); }); }); @@ -190,7 +178,7 @@ describe('refreshUmbrella', () => { it('keeps the title, label and link, and rewrites the list', async (t) => { const github = stub(t, releaseMerged(18405, true)); - await refreshUmbrella(repositories(github), umbrella, noRelease); + await refresh(github, umbrella, noRelease); assert.deepEqual(github.writes, [ { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, @@ -200,7 +188,7 @@ describe('refreshUmbrella', () => { it('keeps an umbrella whose release PR was closed without merging', async (t) => { const github = stub(t, releaseMerged(18405, false)); - await refreshUmbrella(repositories(github), umbrella, noRelease); + await refresh(github, umbrella, noRelease); assert.deepEqual(github.writes, [ { method: 'PATCH', path: `repos/${REPO}/pulls/42`, body: { body: umbrellaBody(18405) } }, @@ -216,7 +204,7 @@ describe('refreshUmbrella', () => { labels: [], }; - await refreshUmbrella(repositories(github), newUmbrella, noRelease); + await refresh(github, newUmbrella, noRelease); assert.deepEqual(patchOf(github.writes), { body: umbrellaBody() }); assert.deepEqual(labelWrites(github.writes), [ @@ -231,7 +219,11 @@ describe('refreshUmbrella', () => { it('refreshes a prerelease umbrella, which always follows', async (t) => { const github = stubGitHub(t, { - getResponses: { ...history('cloudflare-next'), ...prereleaseOf(18269) }, + getResponses: { + ...history('cloudflare-next'), + ...reviews(43, 'cloudflare-next'), + ...prereleaseOf(18269), + }, }); const prerelease: UmbrellaPullRequest = { number: 43, @@ -242,7 +234,7 @@ describe('refreshUmbrella', () => { branch: 'release-queue/cloudflare-next', }; - await refreshUmbrella(repositories(github), prerelease, releaseWith(18405, '7.5.0')); + await refresh(github, prerelease, releaseWith(18405, '7.5.0')); assert.deepEqual(patchOf(github.writes), { title: '[ci] release cloudflare-next docs', @@ -257,11 +249,26 @@ describe('refreshUmbrella', () => { ]); }); + 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`.' }; + + await refreshUmbrella(createRunContext(github, 'all'), unlinked, { + release: releaseWith(18405, '7.5.0'), + 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 refreshUmbrella(repositories(github), upToDate, releaseWith(18405, '7.5.0')); + 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 index 15e80652ef5c1..21f7114ffd755 100644 --- a/scripts/lib/release-queue/umbrella/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -22,15 +22,31 @@ import { getReleaseState } from './state.ts'; /** * Brings an umbrella PR up to date with its release and its included PRs, - * writing only what changed. + * then its merge-block review. * - * @param {Repositories} repositories - The docs repository to write, and the - * framework repository. + * @param {RunContext} run - The repositories of the run. * @param {UmbrellaPullRequest} umbrella - The umbrella PR to refresh. - * @param {StableRelease} release - The next release from the stable branch. - * @returns {Promise} The umbrella PR after the refresh. + * @param {object} options - What the umbrella follows, and when to block its + * merge. + * @param {StableRelease} options.release - The next release from the stable + * branch. + * @param {boolean} options.shouldBlock - Whether to submit a merge-block + * review when none is active. */ export async function refreshUmbrella( + { actions, bot, framework }: RunContext, + umbrella: UmbrellaPullRequest, + { release, shouldBlock }: { release: StableRelease; shouldBlock: boolean } +) { + const refreshed = await refreshTitleBodyAndLabels({ docs: bot, framework }, umbrella, release); + await syncMergeBlockReview(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 function refreshTitleBodyAndLabels( { docs, framework }: Repositories, umbrella: UmbrellaPullRequest, release: StableRelease @@ -135,7 +151,7 @@ export async function refreshOpenUmbrellas(run: RunContext) { // 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 refreshAndSyncReview(run, umbrella, release); + const failure = await tryRefreshUmbrella(run, umbrella, release); if (failure) failures.push(failure); } if (failures.length > 0) { @@ -147,18 +163,17 @@ export async function refreshOpenUmbrellas(run: RunContext) { } /** - * Refreshes an umbrella PR and its merge-block review. Catches a failure, so - * the other umbrellas still get refreshed. + * Refreshes an umbrella PR without a join. Catches a failure, so the other + * umbrellas still get refreshed. */ -async function refreshAndSyncReview( - { actions, bot, framework }: RunContext, +async function tryRefreshUmbrella( + run: RunContext, umbrella: UmbrellaPullRequest, release: StableRelease ): Promise { try { - const refreshed = await refreshUmbrella({ docs: bot, framework }, umbrella, release); // Without a join, a dismissed review still covers the included PRs. - await syncMergeBlockReview(actions, refreshed, { shouldBlock: false }); + await refreshUmbrella(run, umbrella, { release, shouldBlock: false }); return undefined; } catch (error) { return new Error(`Couldn't refresh #${umbrella.number}.`, { cause: error }); From 104ec353baea0d05263e4247ee2ee77268cc04b1 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 19:34:22 +0100 Subject: [PATCH 17/35] Move the release queue flows next to its entry point --- ...handle.test.ts => handle-accepted.test.ts} | 14 +- .../handle.ts => handle-accepted.ts} | 30 ++-- scripts/lib/release-queue/main.ts | 4 +- .../release-queue/refresh-umbrellas.test.ts | 133 ++++++++++++++++ .../lib/release-queue/refresh-umbrellas.ts | 64 ++++++++ scripts/lib/release-queue/testing/fixtures.ts | 20 +++ .../release-queue/umbrella/refresh.test.ts | 150 ++---------------- scripts/lib/release-queue/umbrella/refresh.ts | 70 +------- 8 files changed, 257 insertions(+), 228 deletions(-) rename scripts/lib/release-queue/{accepted/handle.test.ts => handle-accepted.test.ts} (97%) rename scripts/lib/release-queue/{accepted/handle.ts => handle-accepted.ts} (89%) create mode 100644 scripts/lib/release-queue/refresh-umbrellas.test.ts create mode 100644 scripts/lib/release-queue/refresh-umbrellas.ts diff --git a/scripts/lib/release-queue/accepted/handle.test.ts b/scripts/lib/release-queue/handle-accepted.test.ts similarity index 97% rename from scripts/lib/release-queue/accepted/handle.test.ts rename to scripts/lib/release-queue/handle-accepted.test.ts index 47e9566d60b63..a4539b9e7f138 100644 --- a/scripts/lib/release-queue/accepted/handle.test.ts +++ b/scripts/lib/release-queue/handle-accepted.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; -import { GitHubError } from '../github/error.ts'; -import type { Mode } from '../modes.ts'; -import { pullRequestResponse, REPO, umbrellaResponse } from '../testing/fixtures.ts'; -import { ACTIONS_TOKEN, BOT_TOKEN, createRunContext } from '../testing/run-context.ts'; -import { readBody, stubGitHub, type StubOptions, type Write } from '../testing/stub-github.ts'; -import { formatUmbrellaBody } from '../umbrella/body.ts'; -import { handleAcceptedPullRequest } from './handle.ts'; +import { GitHubError } from './github/error.ts'; +import type { Mode } from './modes.ts'; +import { pullRequestResponse, REPO, umbrellaResponse } from './testing/fixtures.ts'; +import { ACTIONS_TOKEN, BOT_TOKEN, createRunContext } from './testing/run-context.ts'; +import { readBody, stubGitHub, type StubOptions, type Write } from './testing/stub-github.ts'; +import { formatUmbrellaBody } from './umbrella/body.ts'; +import { handleAcceptedPullRequest } from './handle-accepted.ts'; const paths = { pr: `repos/${REPO}/pulls/7`, diff --git a/scripts/lib/release-queue/accepted/handle.ts b/scripts/lib/release-queue/handle-accepted.ts similarity index 89% rename from scripts/lib/release-queue/accepted/handle.ts rename to scripts/lib/release-queue/handle-accepted.ts index 36a58104c7fac..78e0b69bb47f1 100644 --- a/scripts/lib/release-queue/accepted/handle.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -1,15 +1,4 @@ -import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH } from '../config.ts'; -import { canWrite, postComment, removeLabel, type Repository } from '../github/repository.ts'; -import { canActOn } from '../modes.ts'; -import { getReleasePullRequest } from '../release/queries.ts'; -import { getStableRelease, type StableRelease } from '../release/release.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, type Umbrella } from '../umbrella/queries.ts'; -import { refreshUmbrella } from '../umbrella/refresh.ts'; -import { getSettledStableUmbrella } from '../umbrella/state.ts'; -import { UmbrellaJoin } from './join.ts'; +import { UmbrellaJoin } from './accepted/join.ts'; import { formatDryRunJoinComment, formatDryRunRefusalComment, @@ -17,15 +6,26 @@ import { formatRefusalComment, formatUnopenedJoinComment, type MessageContext, -} from './messages.ts'; -import { getPullRequest, getReadiness, type PullRequest } from './queries.ts'; +} from './accepted/messages.ts'; +import { getPullRequest, getReadiness, type PullRequest } from './accepted/queries.ts'; import { decideJoin, wouldRetargetFromStable, type Decision, type RefusedDecision, type Route, -} from './routing.ts'; +} from './accepted/routing.ts'; +import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH } from './config.ts'; +import { canWrite, postComment, removeLabel, type Repository } from './github/repository.ts'; +import { canActOn } from './modes.ts'; +import { getReleasePullRequest } from './release/queries.ts'; +import { getStableRelease, type StableRelease } from './release/release.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, type Umbrella } from './umbrella/queries.ts'; +import { refreshUmbrella } from './umbrella/refresh.ts'; +import { getSettledStableUmbrella } from './umbrella/state.ts'; /** * A docs PR labeled as accepted, with the release it ships with and the diff --git a/scripts/lib/release-queue/main.ts b/scripts/lib/release-queue/main.ts index 6c68d43921983..e89dee2cbaae6 100644 --- a/scripts/lib/release-queue/main.ts +++ b/scripts/lib/release-queue/main.ts @@ -1,12 +1,12 @@ import { readFile } from 'node:fs/promises'; -import { handleAcceptedPullRequest } from './accepted/handle.ts'; import { chooseAction, parseEvent, type RunEvent } from './cli.ts'; import { DOCS_REPO, FRAMEWORK_REPO } from './config.ts'; import { GitHubClient, PrintingGitHubClient } from './github/client.ts'; import { Repository } from './github/repository.ts'; +import { handleAcceptedPullRequest } from './handle-accepted.ts'; import { isDryRun, parseMode } from './modes.ts'; +import { refreshOpenUmbrellas } from './refresh-umbrellas.ts'; import type { RunContext } from './run-context.ts'; -import { refreshOpenUmbrellas } from './umbrella/refresh.ts'; /** The command-line flags of `scripts/release-queue.ts`. */ interface Flags { 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..4be01834aa4c9 --- /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, createRunContext } 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(createRunContext(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(createRunContext(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(createRunContext(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(createRunContext(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(createRunContext(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(createRunContext(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..cbdba76b6eef2 --- /dev/null +++ b/scripts/lib/release-queue/refresh-umbrellas.ts @@ -0,0 +1,64 @@ +import { FRAMEWORK_STABLE_BRANCH } from './config.ts'; +import { canActOn } from './modes.ts'; +import { getReleasePullRequest } from './release/queries.ts'; +import { getStableRelease, type StableRelease } from './release/release.ts'; +import type { RunContext } from './run-context.ts'; +import { listOpenUmbrellas, type UmbrellaPullRequest } from './umbrella/queries.ts'; +import { refreshUmbrella } 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, releasePullRequest] = await Promise.all([ + listOpenUmbrellas(actions), + getReleasePullRequest(framework, FRAMEWORK_STABLE_BRANCH), + ]); + const release = getStableRelease(releasePullRequest); + 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 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(run, umbrella, release); + 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( + run: RunContext, + umbrella: UmbrellaPullRequest, + release: StableRelease +): Promise { + try { + // Without a join, a dismissed review still covers the included PRs. + await refreshUmbrella(run, umbrella, { release, shouldBlock: false }); + return undefined; + } catch (error) { + return new Error(`Couldn't refresh #${umbrella.number}.`, { cause: error }); + } +} diff --git a/scripts/lib/release-queue/testing/fixtures.ts b/scripts/lib/release-queue/testing/fixtures.ts index 6f85bcf8d9981..b4be3048df289 100644 --- a/scripts/lib/release-queue/testing/fixtures.ts +++ b/scripts/lib/release-queue/testing/fixtures.ts @@ -40,3 +40,23 @@ export function umbrellaResponse({ ...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/umbrella/refresh.test.ts b/scripts/lib/release-queue/umbrella/refresh.test.ts index cd3b628b04099..075accd3a44ee 100644 --- a/scripts/lib/release-queue/umbrella/refresh.test.ts +++ b/scripts/lib/release-queue/umbrella/refresh.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import { describe, it, type TestContext } from 'node:test'; import { getStableRelease, type StableRelease } from '../release/release.ts'; -import { pullRequestResponse, REPO, umbrellaResponse } from '../testing/fixtures.ts'; -import { ACTIONS_TOKEN, BOT_TOKEN, createRunContext } from '../testing/run-context.ts'; +import { emptyMergeBlockResponses, REPO, umbrellaCommitResponses } from '../testing/fixtures.ts'; +import { ACTIONS_TOKEN, createRunContext } 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 { refreshOpenUmbrellas, refreshUmbrella } from './refresh.ts'; +import { refreshUmbrella } from './refresh.ts'; /** The next release from `main`, with release PR `number` open. */ const releaseWith = (number: number, frameworkVersion: string) => @@ -26,20 +26,6 @@ const umbrella: UmbrellaPullRequest = { branch: 'release-queue/main', }; -/** The squash commit of #7 is on the umbrella branch. */ -const history = (umbrellaBase = 'main') => ({ - [`repos/${REPO}/compare/${umbrellaBase}...release-queue/${umbrellaBase}`]: { - ahead_by: 1, - commits: [{ commit: { message: 'Document the new option (#7)\n\nDetails.' } }], - }, -}); - -/** No merge-block review on umbrella `number`, and no PR on its branch. */ -const reviews = (number: number, umbrellaBase = 'main') => ({ - [`repos/${REPO}/pulls/${number}/reviews`]: [], - [`repos/${REPO}/pulls?state=open&base=release-queue/${umbrellaBase}`]: [], -}); - /** `next` in prerelease mode, with its release PR `number` open. */ const prereleaseOf = (number: number) => ({ 'repos/withastro/astro/contents/.changeset/pre.json?ref=next': { @@ -67,7 +53,13 @@ const releaseMerged = (number: number, merged: boolean) => ({ }); function stub(t: TestContext, getResponses: StubOptions['getResponses'] = {}) { - return stubGitHub(t, { getResponses: { ...history(), ...reviews(42), ...getResponses } }); + return stubGitHub(t, { + getResponses: { + ...umbrellaCommitResponses(), + ...emptyMergeBlockResponses(42), + ...getResponses, + }, + }); } const patchOf = (writes: Write[]) => @@ -220,8 +212,8 @@ describe('refreshUmbrella', () => { it('refreshes a prerelease umbrella, which always follows', async (t) => { const github = stubGitHub(t, { getResponses: { - ...history('cloudflare-next'), - ...reviews(43, 'cloudflare-next'), + ...umbrellaCommitResponses('cloudflare-next'), + ...emptyMergeBlockResponses(43, 'cloudflare-next'), ...prereleaseOf(18269), }, }); @@ -273,121 +265,3 @@ describe('refreshUmbrella', () => { assert.deepEqual(github.writes, []); }); }); - -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' }, - ], - ...history(), - ...history('cloudflare-next'), - ...reviews(42), - ...reviews(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(createRunContext(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(createRunContext(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(createRunContext(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(createRunContext(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(createRunContext(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(createRunContext(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/umbrella/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts index 21f7114ffd755..3563ff31495d9 100644 --- a/scripts/lib/release-queue/umbrella/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -1,8 +1,7 @@ -import { FRAMEWORK_STABLE_BRANCH, RELEASE_TYPES, DOCS_STABLE_BRANCH } from '../config.ts'; +import { DOCS_STABLE_BRANCH, RELEASE_TYPES } from '../config.ts'; import { removeLabel, type Repository } from '../github/repository.ts'; -import { canActOn } from '../modes.ts'; -import { getPrereleasePullRequest, getReleasePullRequest } from '../release/queries.ts'; -import { getStableRelease, type StableRelease } from '../release/release.ts'; +import { getPrereleasePullRequest } from '../release/queries.ts'; +import type { StableRelease } from '../release/release.ts'; import type { Repositories, RunContext } from '../run-context.ts'; import { formatUmbrellaBody } from './body.ts'; import { getUmbrellaReleaseLabel, getUmbrellaTitle } from './heading.ts'; @@ -13,11 +12,7 @@ import { type LabelChanges, } from './labels.ts'; import { syncMergeBlockReview } from './merge-block.ts'; -import { - listIncludedPullRequests, - listOpenUmbrellas, - type UmbrellaPullRequest, -} from './queries.ts'; +import { listIncludedPullRequests, type UmbrellaPullRequest } from './queries.ts'; import { getReleaseState } from './state.ts'; /** @@ -122,60 +117,3 @@ async function applyLabelChanges(docs: Repository, umbrellaNumber: number, chang } await Promise.all(changes.toRemove.map((label) => removeLabel(docs, umbrellaNumber, label))); } - -/** - * 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, releasePullRequest] = await Promise.all([ - listOpenUmbrellas(actions), - getReleasePullRequest(framework, FRAMEWORK_STABLE_BRANCH), - ]); - const release = getStableRelease(releasePullRequest); - 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 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(run, umbrella, release); - 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( - run: RunContext, - umbrella: UmbrellaPullRequest, - release: StableRelease -): Promise { - try { - // Without a join, a dismissed review still covers the included PRs. - await refreshUmbrella(run, umbrella, { release, shouldBlock: false }); - return undefined; - } catch (error) { - return new Error(`Couldn't refresh #${umbrella.number}.`, { cause: error }); - } -} From b1eba84fc73725ee1fab5f09f996b9590d61a3d7 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 19:38:29 +0100 Subject: [PATCH 18/35] Define the docs PR and umbrella types with their concepts --- scripts/lib/release-queue/accepted/join.ts | 8 +++++++- scripts/lib/release-queue/accepted/messages.ts | 5 ++--- scripts/lib/release-queue/accepted/queries.ts | 11 ++++++++++- scripts/lib/release-queue/accepted/routing.test.ts | 8 ++------ scripts/lib/release-queue/accepted/routing.ts | 12 ++---------- scripts/lib/release-queue/handle-accepted.ts | 4 ++-- scripts/lib/release-queue/umbrella/queries.ts | 6 ------ 7 files changed, 25 insertions(+), 29 deletions(-) diff --git a/scripts/lib/release-queue/accepted/join.ts b/scripts/lib/release-queue/accepted/join.ts index bf09941635dec..d672a917ca886 100644 --- a/scripts/lib/release-queue/accepted/join.ts +++ b/scripts/lib/release-queue/accepted/join.ts @@ -1,6 +1,6 @@ import { GitHubError } from '../github/error.ts'; import { countCommitsAhead, getBranchSha, type Repository } from '../github/repository.ts'; -import { findOpenUmbrella, type Umbrella } from '../umbrella/queries.ts'; +import { findOpenUmbrella } from '../umbrella/queries.ts'; import { getPullRequest, type PullRequest } from './queries.ts'; import type { JoinRefusal, Route } from './routing.ts'; @@ -12,6 +12,12 @@ interface JoinRequest { 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. `unopened` means the PR was merged into the umbrella * branch, but the umbrella PR couldn't be opened. diff --git a/scripts/lib/release-queue/accepted/messages.ts b/scripts/lib/release-queue/accepted/messages.ts index e6c596c4aa2ab..3989ce6e72861 100644 --- a/scripts/lib/release-queue/accepted/messages.ts +++ b/scripts/lib/release-queue/accepted/messages.ts @@ -1,15 +1,14 @@ import { ACCEPTED_LABEL, + DOCS_STABLE_BRANCH, FRAMEWORK_CORE_PACKAGE, FRAMEWORK_STABLE_BRANCH, RELEASE_LABELS, - DOCS_STABLE_BRANCH, } from '../config.ts'; import { GitHubError } from '../github/error.ts'; import { formatFrameworkReference } from '../release/release.ts'; import { STABLE_UMBRELLA_BRANCH } from '../umbrella/branch.ts'; -import type { Umbrella } from '../umbrella/queries.ts'; -import type { UnopenedJoin } from './join.ts'; +import type { Umbrella, UnopenedJoin } from './join.ts'; import type { Refusal, RefusalCode, RefusedDecision, Route } from './routing.ts'; export interface MessageContext { diff --git a/scripts/lib/release-queue/accepted/queries.ts b/scripts/lib/release-queue/accepted/queries.ts index 2eb72775f967c..d7234c7d45876 100644 --- a/scripts/lib/release-queue/accepted/queries.ts +++ b/scripts/lib/release-queue/accepted/queries.ts @@ -4,6 +4,7 @@ import type { CheckRunResponse, CommitStatusResponse, PullRequestResponse, + PullRequestState, ReviewResponse, RuleResponse, } from '../github/types.ts'; @@ -14,10 +15,18 @@ import { type Readiness, type Review, } from './readiness.ts'; -import type { DocsPullRequest } from './routing.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[]; +} + export interface PullRequest extends DocsPullRequest { number: number; title: string; diff --git a/scripts/lib/release-queue/accepted/routing.test.ts b/scripts/lib/release-queue/accepted/routing.test.ts index 861e6a58d7ea8..8d8c0f1352fc0 100644 --- a/scripts/lib/release-queue/accepted/routing.test.ts +++ b/scripts/lib/release-queue/accepted/routing.test.ts @@ -1,12 +1,8 @@ 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, - type DocsPullRequest, -} from './routing.ts'; +import { decideJoin, wouldRetargetFromStable, type Decision } from './routing.ts'; const pr = (overrides: Partial = {}): DocsPullRequest => ({ state: 'open', diff --git a/scripts/lib/release-queue/accepted/routing.ts b/scripts/lib/release-queue/accepted/routing.ts index be0ae22aa6c88..282e91b72d1bd 100644 --- a/scripts/lib/release-queue/accepted/routing.ts +++ b/scripts/lib/release-queue/accepted/routing.ts @@ -1,24 +1,16 @@ import { ALL_RELEASE_LABELS, + DOCS_STABLE_BRANCH, RELEASE_LABELS, RELEASE_TYPES, - DOCS_STABLE_BRANCH, type ReleaseLabel, } from '../config.ts'; -import type { PullRequestState } from '../github/types.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 DocsPullRequest { - state: PullRequestState; - isDraft: boolean; - base: string; - head: string; - labels: string[]; -} - export interface Route { umbrellaBase: string; umbrellaBranch: string; diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts index 78e0b69bb47f1..284ec73edcf66 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -1,4 +1,4 @@ -import { UmbrellaJoin } from './accepted/join.ts'; +import { UmbrellaJoin, type Umbrella } from './accepted/join.ts'; import { formatDryRunJoinComment, formatDryRunRefusalComment, @@ -23,7 +23,7 @@ import { getStableRelease, type StableRelease } from './release/release.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, type Umbrella } from './umbrella/queries.ts'; +import { findOpenUmbrella, getUmbrella } from './umbrella/queries.ts'; import { refreshUmbrella } from './umbrella/refresh.ts'; import { getSettledStableUmbrella } from './umbrella/state.ts'; diff --git a/scripts/lib/release-queue/umbrella/queries.ts b/scripts/lib/release-queue/umbrella/queries.ts index d704ace2bc21e..548486d32bc58 100644 --- a/scripts/lib/release-queue/umbrella/queries.ts +++ b/scripts/lib/release-queue/umbrella/queries.ts @@ -14,12 +14,6 @@ export interface UmbrellaPullRequest { branch: 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 }; - /** A review of an umbrella PR, with its body. */ export interface SubmittedReview extends ReviewResponse { id: number; From 2218a408d9b946f9fad180c0fb821c6052969d00 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 19:42:19 +0100 Subject: [PATCH 19/35] Rename the accepted folder after the join it holds --- scripts/lib/release-queue/handle-accepted.ts | 12 ++++++------ .../{accepted => join}/messages.test.ts | 0 .../lib/release-queue/{accepted => join}/messages.ts | 2 +- .../release-queue/{accepted => join}/queries.test.ts | 0 .../lib/release-queue/{accepted => join}/queries.ts | 0 .../{accepted => join}/readiness.test.ts | 0 .../release-queue/{accepted => join}/readiness.ts | 0 .../release-queue/{accepted => join}/routing.test.ts | 0 .../lib/release-queue/{accepted => join}/routing.ts | 0 .../join.test.ts => join/umbrella-join.test.ts} | 2 +- .../{accepted/join.ts => join/umbrella-join.ts} | 0 11 files changed, 8 insertions(+), 8 deletions(-) rename scripts/lib/release-queue/{accepted => join}/messages.test.ts (100%) rename scripts/lib/release-queue/{accepted => join}/messages.ts (99%) rename scripts/lib/release-queue/{accepted => join}/queries.test.ts (100%) rename scripts/lib/release-queue/{accepted => join}/queries.ts (100%) rename scripts/lib/release-queue/{accepted => join}/readiness.test.ts (100%) rename scripts/lib/release-queue/{accepted => join}/readiness.ts (100%) rename scripts/lib/release-queue/{accepted => join}/routing.test.ts (100%) rename scripts/lib/release-queue/{accepted => join}/routing.ts (100%) rename scripts/lib/release-queue/{accepted/join.test.ts => join/umbrella-join.test.ts} (99%) rename scripts/lib/release-queue/{accepted/join.ts => join/umbrella-join.ts} (100%) diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts index 284ec73edcf66..27d105c5412df 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -1,4 +1,5 @@ -import { UmbrellaJoin, type Umbrella } from './accepted/join.ts'; +import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH } from './config.ts'; +import { canWrite, postComment, removeLabel, type Repository } from './github/repository.ts'; import { formatDryRunJoinComment, formatDryRunRefusalComment, @@ -6,17 +7,16 @@ import { formatRefusalComment, formatUnopenedJoinComment, type MessageContext, -} from './accepted/messages.ts'; -import { getPullRequest, getReadiness, type PullRequest } from './accepted/queries.ts'; +} from './join/messages.ts'; +import { getPullRequest, getReadiness, type PullRequest } from './join/queries.ts'; import { decideJoin, wouldRetargetFromStable, type Decision, type RefusedDecision, type Route, -} from './accepted/routing.ts'; -import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH } from './config.ts'; -import { canWrite, postComment, removeLabel, type Repository } from './github/repository.ts'; +} from './join/routing.ts'; +import { UmbrellaJoin, type Umbrella } from './join/umbrella-join.ts'; import { canActOn } from './modes.ts'; import { getReleasePullRequest } from './release/queries.ts'; import { getStableRelease, type StableRelease } from './release/release.ts'; diff --git a/scripts/lib/release-queue/accepted/messages.test.ts b/scripts/lib/release-queue/join/messages.test.ts similarity index 100% rename from scripts/lib/release-queue/accepted/messages.test.ts rename to scripts/lib/release-queue/join/messages.test.ts diff --git a/scripts/lib/release-queue/accepted/messages.ts b/scripts/lib/release-queue/join/messages.ts similarity index 99% rename from scripts/lib/release-queue/accepted/messages.ts rename to scripts/lib/release-queue/join/messages.ts index 3989ce6e72861..3140acc934442 100644 --- a/scripts/lib/release-queue/accepted/messages.ts +++ b/scripts/lib/release-queue/join/messages.ts @@ -8,7 +8,7 @@ import { 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 './join.ts'; +import type { Umbrella, UnopenedJoin } from './umbrella-join.ts'; import type { Refusal, RefusalCode, RefusedDecision, Route } from './routing.ts'; export interface MessageContext { diff --git a/scripts/lib/release-queue/accepted/queries.test.ts b/scripts/lib/release-queue/join/queries.test.ts similarity index 100% rename from scripts/lib/release-queue/accepted/queries.test.ts rename to scripts/lib/release-queue/join/queries.test.ts diff --git a/scripts/lib/release-queue/accepted/queries.ts b/scripts/lib/release-queue/join/queries.ts similarity index 100% rename from scripts/lib/release-queue/accepted/queries.ts rename to scripts/lib/release-queue/join/queries.ts diff --git a/scripts/lib/release-queue/accepted/readiness.test.ts b/scripts/lib/release-queue/join/readiness.test.ts similarity index 100% rename from scripts/lib/release-queue/accepted/readiness.test.ts rename to scripts/lib/release-queue/join/readiness.test.ts diff --git a/scripts/lib/release-queue/accepted/readiness.ts b/scripts/lib/release-queue/join/readiness.ts similarity index 100% rename from scripts/lib/release-queue/accepted/readiness.ts rename to scripts/lib/release-queue/join/readiness.ts diff --git a/scripts/lib/release-queue/accepted/routing.test.ts b/scripts/lib/release-queue/join/routing.test.ts similarity index 100% rename from scripts/lib/release-queue/accepted/routing.test.ts rename to scripts/lib/release-queue/join/routing.test.ts diff --git a/scripts/lib/release-queue/accepted/routing.ts b/scripts/lib/release-queue/join/routing.ts similarity index 100% rename from scripts/lib/release-queue/accepted/routing.ts rename to scripts/lib/release-queue/join/routing.ts diff --git a/scripts/lib/release-queue/accepted/join.test.ts b/scripts/lib/release-queue/join/umbrella-join.test.ts similarity index 99% rename from scripts/lib/release-queue/accepted/join.test.ts rename to scripts/lib/release-queue/join/umbrella-join.test.ts index c6cc57effa50e..af316b30aa048 100644 --- a/scripts/lib/release-queue/accepted/join.test.ts +++ b/scripts/lib/release-queue/join/umbrella-join.test.ts @@ -3,7 +3,7 @@ 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 './join.ts'; +import { UmbrellaJoin } from './umbrella-join.ts'; import type { PullRequest } from './queries.ts'; import type { Route } from './routing.ts'; diff --git a/scripts/lib/release-queue/accepted/join.ts b/scripts/lib/release-queue/join/umbrella-join.ts similarity index 100% rename from scripts/lib/release-queue/accepted/join.ts rename to scripts/lib/release-queue/join/umbrella-join.ts From 108807092d621c719896a5e4fd88133e0227a487 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 22:12:01 +0100 Subject: [PATCH 20/35] Make generic repository operations methods of Repository --- .../lib/release-queue/github/repository.ts | 178 ++++++++---------- scripts/lib/release-queue/handle-accepted.ts | 16 +- scripts/lib/release-queue/join/queries.ts | 4 +- .../lib/release-queue/join/umbrella-join.ts | 8 +- scripts/lib/release-queue/umbrella/queries.ts | 4 +- scripts/lib/release-queue/umbrella/refresh.ts | 4 +- 6 files changed, 101 insertions(+), 113 deletions(-) diff --git a/scripts/lib/release-queue/github/repository.ts b/scripts/lib/release-queue/github/repository.ts index 35a58303226bb..3ecb5f8432af2 100644 --- a/scripts/lib/release-queue/github/repository.ts +++ b/scripts/lib/release-queue/github/repository.ts @@ -3,8 +3,9 @@ import { GitHubError } from './error.ts'; import type { CompareResponse } from './types.ts'; /** - * A GitHub repository, read and changed through a client. Its methods are - * those of `GitHubClient`, with paths relative to the repository. + * A GitHub repository, read and changed through a client: the requests of + * `GitHubClient` with paths relative to the repository, and the operations + * the bot runs on any repository. */ export class Repository { readonly name: string; @@ -39,107 +40,94 @@ export class Repository { return this.#client.write(method, this.#url(path), body); } - #url(path: string): string { - return `repos/${this.name}/${path}`; + /** + * 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}`); } -} -/** - * Compares two branches of a repository. - * - * @param {Repository} repo - The repository of both branches. - * @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. - */ -export function compareBranches( - repo: Repository, - base: string, - head: string -): Promise { - return repo.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; + } -/** - * Removes a label from a PR. A label that is already gone is fine. - * - * @param {Repository} repo - The repository of the PR. - * @param {number} prNumber - The number of the PR. - * @param {string} label - The label to remove. - */ -export async function removeLabel(repo: Repository, prNumber: number, label: string) { - try { - await repo.write('DELETE', `issues/${prNumber}/labels/${label}`); - } catch (error) { - if (!(error instanceof GitHubError && error.status === 404)) throw error; + /** + * 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 { + try { + const { object } = await this.get<{ object: { sha: string } }>(`git/ref/heads/${branch}`); + return object.sha; + } catch (error) { + if (error instanceof GitHubError && error.status === 404) return undefined; + throw error; + } } -} -/** - * Gets the commit a branch points to. - * - * @param {Repository} repo - The repository of the branch. - * @param {string} branch - The branch name. - * @returns {Promise} The commit SHA, or `undefined` when - * the branch doesn't exist. - */ -export async function getBranchSha(repo: Repository, branch: string): Promise { - try { - const { object } = await repo.get<{ object: { sha: string } }>(`git/ref/heads/${branch}`); - return object.sha; - } catch (error) { - if (error instanceof GitHubError && error.status === 404) return undefined; - throw error; + /** + * 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 }); } -} -/** - * Counts the commits of a branch that aren't in another. - * - * @param {Repository} repo - The repository of both branches. - * @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`. - */ -export async function countCommitsAhead( - repo: Repository, - base: string, - head: string -): Promise { - const { ahead_by } = await compareBranches(repo, base, head); - return ahead_by; -} + /** + * 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 (!(error instanceof GitHubError && error.status === 404)) throw error; + } + } -/** - * Comments on a PR. - * - * @param {Repository} repo - The repository of the PR. - * @param {number} prNumber - The number of the PR. - * @param {string} body - The comment, in Markdown. - */ -export async function postComment(repo: Repository, prNumber: number, body: string) { - await repo.write('POST', `issues/${prNumber}/comments`, { body }); -} + /** + * Whether a user can write to the repository, from their permission on it. + * + * @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 { + try { + const { permission } = await this.get<{ permission: string }>( + `collaborators/${user}/permission` + ); + // The legacy `permission` field maps `maintain` to `write`, + // and `triage` to `read`. + return permission === 'admin' || permission === 'write'; + } catch (error) { + if (error instanceof GitHubError && error.status === 404) return false; + throw error; + } + } -/** - * Whether a user can write to the repository, from their permission on it. - * - * @param {Repository} repo - 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. - */ -export async function canWrite(repo: Repository, user: string): Promise { - try { - const { permission } = await repo.get<{ permission: string }>( - `collaborators/${user}/permission` - ); - // The legacy `permission` field maps `maintain` to `write`, - // and `triage` to `read`. - return permission === 'admin' || permission === 'write'; - } catch (error) { - if (error instanceof GitHubError && error.status === 404) return false; - throw error; + #url(path: string): string { + return `repos/${this.name}/${path}`; } } diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts index 27d105c5412df..9f127a270cfea 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -1,5 +1,5 @@ import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH } from './config.ts'; -import { canWrite, postComment, removeLabel, type Repository } from './github/repository.ts'; +import type { Repository } from './github/repository.ts'; import { formatDryRunJoinComment, formatDryRunRefusalComment, @@ -76,7 +76,7 @@ async function isSenderAllowed( console.info('No label sender: skipping the write access check.'); return true; } - if (await canWrite(repo, labelSender)) return true; + if (await repo.canWrite(labelSender)) return true; console.info(`Ignored: ${labelSender} can't write to ${repo.name}.`); return false; } @@ -105,11 +105,11 @@ async function readAcceptedPullRequest( async function commentDryRun(run: RunContext, { pr, release, decision }: AcceptedPullRequest) { if (decision.status === 'join') { const umbrella = await findOrPlanUmbrella(run, decision.route, release); - await postComment(run.actions, pr.number, formatDryRunJoinComment(decision.route, umbrella)); + await run.actions.postComment(pr.number, formatDryRunJoinComment(decision.route, umbrella)); return; } const context = await getMessageContext(run, decision, release); - await postComment(run.actions, pr.number, formatDryRunRefusalComment(decision, context)); + await run.actions.postComment(pr.number, formatDryRunRefusalComment(decision, context)); } async function refuse( @@ -118,8 +118,8 @@ async function refuse( ) { const context = await getMessageContext(run, decision, release); // Removed first, so the comment is true once posted. - await removeLabel(run.bot, pr.number, ACCEPTED_LABEL); - await postComment(run.bot, pr.number, formatRefusalComment(decision, context)); + await run.bot.removeLabel(pr.number, ACCEPTED_LABEL); + await run.bot.postComment(pr.number, formatRefusalComment(decision, context)); } /** Joins the PR to its umbrella, then says how it went on the PR. */ @@ -140,7 +140,7 @@ async function joinAndAnnounce(run: RunContext, accepted: AcceptedPullRequest, umbrella: Umbrella ) { - await postComment(run.bot, pr.number, formatJoinComment(decision.route, umbrella)); + await run.bot.postComment(pr.number, formatJoinComment(decision.route, umbrella)); // A planned umbrella was only printed. if (umbrella.status === 'planned') return; const openUmbrella = await getUmbrella(run.bot, umbrella.number); diff --git a/scripts/lib/release-queue/join/queries.ts b/scripts/lib/release-queue/join/queries.ts index d7234c7d45876..cf5aabe71fe0e 100644 --- a/scripts/lib/release-queue/join/queries.ts +++ b/scripts/lib/release-queue/join/queries.ts @@ -1,5 +1,5 @@ import { DOCS_STABLE_BRANCH } from '../config.ts'; -import { canWrite, type Repository } from '../github/repository.ts'; +import type { Repository } from '../github/repository.ts'; import type { CheckRunResponse, CommitStatusResponse, @@ -111,7 +111,7 @@ function toReviews(responses: ReviewResponse[]): Review[] { /** 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) => canWrite(repo, user))); + const canEachWrite = await Promise.all(users.map((user) => repo.canWrite(user))); return new Set(users.filter((_, index) => canEachWrite[index])); } diff --git a/scripts/lib/release-queue/join/umbrella-join.ts b/scripts/lib/release-queue/join/umbrella-join.ts index d672a917ca886..cc3ed90bc2a59 100644 --- a/scripts/lib/release-queue/join/umbrella-join.ts +++ b/scripts/lib/release-queue/join/umbrella-join.ts @@ -1,5 +1,5 @@ import { GitHubError } from '../github/error.ts'; -import { countCommitsAhead, getBranchSha, type Repository } from '../github/repository.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'; @@ -103,14 +103,14 @@ export class UmbrellaJoin { async #readUmbrellaBranch(): Promise { const { umbrellaBase, umbrellaBranch } = this.#route; - const umbrellaBaseSha = await getBranchSha(this.#repo, umbrellaBase); + const umbrellaBaseSha = await this.#repo.getBranchSha(umbrellaBase); if (!umbrellaBaseSha) { throw new Error(`The branch ${umbrellaBase} doesn't exist in ${this.#repo.name}.`); } - if ((await getBranchSha(this.#repo, umbrellaBranch)) === undefined) { + if ((await this.#repo.getBranchSha(umbrellaBranch)) === undefined) { return { state: 'missing', umbrellaBaseSha }; } - const commitsAhead = await countCommitsAhead(this.#repo, umbrellaBase, umbrellaBranch); + const commitsAhead = await this.#repo.countCommitsAhead(umbrellaBase, umbrellaBranch); return commitsAhead > 0 ? { state: 'stale' } : { state: 'empty', umbrellaBaseSha }; } diff --git a/scripts/lib/release-queue/umbrella/queries.ts b/scripts/lib/release-queue/umbrella/queries.ts index 548486d32bc58..d9ea0faf65bdd 100644 --- a/scripts/lib/release-queue/umbrella/queries.ts +++ b/scripts/lib/release-queue/umbrella/queries.ts @@ -1,4 +1,4 @@ -import { compareBranches, type Repository } from '../github/repository.ts'; +import type { Repository } from '../github/repository.ts'; import type { PullRequestResponse, ReviewResponse } from '../github/types.ts'; import { getUmbrellaBase, isUmbrellaBranch } from './branch.ts'; @@ -110,7 +110,7 @@ export async function listIncludedPullRequests( repo: Repository, { base, branch }: UmbrellaPullRequest ): Promise { - const { commits } = await compareBranches(repo, base, branch); + const { commits } = await repo.compareBranches(base, branch); return commits.flatMap(({ commit }) => { const [commitTitle = ''] = commit.message.split('\n', 1); const match = commitTitle.match(SQUASH_TITLE); diff --git a/scripts/lib/release-queue/umbrella/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts index 3563ff31495d9..ff7570d573ad4 100644 --- a/scripts/lib/release-queue/umbrella/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -1,5 +1,5 @@ import { DOCS_STABLE_BRANCH, RELEASE_TYPES } from '../config.ts'; -import { removeLabel, type Repository } from '../github/repository.ts'; +import type { Repository } from '../github/repository.ts'; import { getPrereleasePullRequest } from '../release/queries.ts'; import type { StableRelease } from '../release/release.ts'; import type { Repositories, RunContext } from '../run-context.ts'; @@ -115,5 +115,5 @@ async function applyLabelChanges(docs: Repository, umbrellaNumber: number, chang if (changes.toAdd.length > 0) { await docs.write('POST', `issues/${umbrellaNumber}/labels`, { labels: changes.toAdd }); } - await Promise.all(changes.toRemove.map((label) => removeLabel(docs, umbrellaNumber, label))); + await Promise.all(changes.toRemove.map((label) => docs.removeLabel(umbrellaNumber, label))); } From 6a96d345138b798cec971b5f7d6d3285b8718506 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 22:22:59 +0100 Subject: [PATCH 21/35] Handle an accepted PR through a class built once per run --- scripts/lib/release-queue/handle-accepted.ts | 202 +++++++++++-------- 1 file changed, 113 insertions(+), 89 deletions(-) diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts index 9f127a270cfea..36a6c2dccba97 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -31,14 +31,12 @@ import { getSettledStableUmbrella } from './umbrella/state.ts'; * A docs PR labeled as accepted, with the release it ships with and the * decision on it. */ -interface AcceptedPullRequest { +interface DecidedPullRequest { pr: PullRequest; release: StableRelease; - decision: D; + decision: Decision; } -type JoinDecision = Extract; - /** * Joins a docs PR labeled as accepted to its umbrella, refuses it, or * comments on it as a dry run. @@ -55,15 +53,15 @@ export async function handleAcceptedPullRequest( ) { if (!(await isSenderAllowed(run.actions, labelSender))) return; - const accepted = await readAcceptedPullRequest(run, prNumber); - const { pr, release, decision } = accepted; + const { pr, release, decision } = await readAcceptedPullRequest(run, prNumber); console.info(JSON.stringify({ pr: pr.url, release, decision }, null, 2)); + const accepted = new AcceptedPullRequest(run, { pr, release }); if (!canActOn(run.mode, getUmbrellaBase(pr.base))) { - await commentDryRun(run, accepted); + await accepted.commentDryRun(decision); } else if (decision.status === 'refuse') { - await refuse(run, { ...accepted, decision }); + await accepted.refuse(decision); } else { - await joinAndAnnounce(run, { ...accepted, decision }); + await accepted.join(decision.route); } } @@ -88,7 +86,7 @@ async function isSenderAllowed( async function readAcceptedPullRequest( { actions, framework }: RunContext, prNumber: number -): Promise { +): Promise { const pr = await getPullRequest(actions, prNumber); const [readiness, releasePullRequest] = await Promise.all([ getReadiness(actions, pr), @@ -102,94 +100,120 @@ async function readAcceptedPullRequest( return { pr, release, decision }; } -async function commentDryRun(run: RunContext, { pr, release, decision }: AcceptedPullRequest) { - if (decision.status === 'join') { - const umbrella = await findOrPlanUmbrella(run, decision.route, release); - await run.actions.postComment(pr.number, formatDryRunJoinComment(decision.route, umbrella)); - return; - } - const context = await getMessageContext(run, decision, release); - await run.actions.postComment(pr.number, formatDryRunRefusalComment(decision, context)); -} - -async function refuse( - run: RunContext, - { pr, release, decision }: AcceptedPullRequest -) { - const context = await getMessageContext(run, decision, release); - // Removed first, so the comment is true once posted. - await run.bot.removeLabel(pr.number, ACCEPTED_LABEL); - await run.bot.postComment(pr.number, formatRefusalComment(decision, context)); -} +/** + * A docs PR labeled as accepted, and what the bot does with it in a run: a + * dry-run comment, a refusal, or a join. + */ +class AcceptedPullRequest { + #run: RunContext; + #pr: PullRequest; + #release: StableRelease; -/** Joins the PR to its umbrella, then says how it went on the PR. */ -async function joinAndAnnounce(run: RunContext, accepted: AcceptedPullRequest) { - const { pr, release, decision } = accepted; - const { route } = decision; - const umbrellaTitle = getUmbrellaTitle(route.umbrellaBase, release); - const join = new UmbrellaJoin(run.bot, { pr, route, umbrellaTitle }); - const result = await join.mergePullRequest(); + /** + * 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 release it ships with. + * @param {PullRequest} accepted.pr - The docs PR. + * @param {StableRelease} accepted.release - The next release from the + * stable branch. + */ + constructor(run: RunContext, { pr, release }: { pr: PullRequest; release: StableRelease }) { + this.#run = run; + this.#pr = pr; + this.#release = release; + } - switch (result.status) { - case 'joined': - await announceJoin(run, accepted, result.umbrella); - return; - case 'refused': { - const refused: RefusedDecision = { status: 'refuse', route, refusals: [result.refusal] }; - await refuse(run, { ...accepted, decision: refused }); + /** + * 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; } - case 'unopened': - await 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)}`); + 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.#release); + 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 function announceJoin( - run: RunContext, - { pr, release, decision }: AcceptedPullRequest, - umbrella: Umbrella -) { - await run.bot.postComment(pr.number, formatJoinComment(decision.route, umbrella)); - // A planned umbrella was only printed. - if (umbrella.status === 'planned') return; - const openUmbrella = await getUmbrella(run.bot, umbrella.number); - await refreshUmbrella(run, openUmbrella, { release, shouldBlock: true }); -} + /** + * 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); + await refreshUmbrella(this.#run, openUmbrella, { release: this.#release, shouldBlock: true }); + } -/** Finds the open umbrella PR of `route`, or names the one to create. */ -async function findOrPlanUmbrella( - { actions }: RunContext, - route: Route, - release: StableRelease -): Promise { - const openUmbrella = await findOpenUmbrella(actions, route.umbrellaBranch); - if (openUmbrella) return { status: 'open', number: openUmbrella.number }; - return { status: 'planned', title: getUmbrellaTitle(route.umbrellaBase, release) }; -} + /** 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.#release) }; + } -async function getMessageContext( - run: RunContext, - decision: RefusedDecision, - release: StableRelease -): Promise { - const [umbrella, stableUmbrella] = await Promise.all([ - decision.route && findOrPlanUmbrella(run, decision.route, release), - findStableUmbrellaToMove(run.actions, decision), - ]); - return { umbrella, stableUmbrella }; + 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 }; + } } /** From 2457f748a95bdf2112590c9c480c6970c6f0ae82 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 22:28:32 +0100 Subject: [PATCH 22/35] Read optional GitHub resources without catching 404s by hand --- scripts/lib/release-queue/github/error.ts | 10 +++++ .../lib/release-queue/github/repository.ts | 44 ++++++++++--------- scripts/lib/release-queue/release/queries.ts | 28 ++++-------- 3 files changed, 43 insertions(+), 39 deletions(-) diff --git a/scripts/lib/release-queue/github/error.ts b/scripts/lib/release-queue/github/error.ts index e764efe98c1a6..19455298d86d0 100644 --- a/scripts/lib/release-queue/github/error.ts +++ b/scripts/lib/release-queue/github/error.ts @@ -27,3 +27,13 @@ export class GitHubError extends Error { this.githubReason = githubReason; } } + +/** + * Whether an error is GitHub's answer that the resource doesn't exist. + * + * @param {unknown} error - A caught error. + * @returns {boolean} `true` for a 404 answer from the GitHub API. + */ +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 index 3ecb5f8432af2..30fa6bd135737 100644 --- a/scripts/lib/release-queue/github/repository.ts +++ b/scripts/lib/release-queue/github/repository.ts @@ -1,5 +1,5 @@ import type { GitHubClient, WriteMethod } from './client.ts'; -import { GitHubError } from './error.ts'; +import { isNotFound } from './error.ts'; import type { CompareResponse } from './types.ts'; /** @@ -28,6 +28,22 @@ export class Repository { 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` when + * GitHub answers that the resource doesn't exist. + */ + 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); } @@ -73,13 +89,8 @@ export class Repository { * the branch doesn't exist. */ async getBranchSha(branch: string): Promise { - try { - const { object } = await this.get<{ object: { sha: string } }>(`git/ref/heads/${branch}`); - return object.sha; - } catch (error) { - if (error instanceof GitHubError && error.status === 404) return undefined; - throw error; - } + const ref = await this.find<{ object: { sha: string } }>(`git/ref/heads/${branch}`); + return ref?.object.sha; } /** @@ -102,7 +113,7 @@ export class Repository { try { await this.write('DELETE', `issues/${prNumber}/labels/${label}`); } catch (error) { - if (!(error instanceof GitHubError && error.status === 404)) throw error; + if (!isNotFound(error)) throw error; } } @@ -114,17 +125,10 @@ export class Repository { * permissions. `false` for a user who isn't a collaborator. */ async canWrite(user: string): Promise { - try { - const { permission } = await this.get<{ permission: string }>( - `collaborators/${user}/permission` - ); - // The legacy `permission` field maps `maintain` to `write`, - // and `triage` to `read`. - return permission === 'admin' || permission === 'write'; - } catch (error) { - if (error instanceof GitHubError && error.status === 404) return false; - throw error; - } + 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 { diff --git a/scripts/lib/release-queue/release/queries.ts b/scripts/lib/release-queue/release/queries.ts index 5d88c1266fd5b..b2fd36a2b8cd3 100644 --- a/scripts/lib/release-queue/release/queries.ts +++ b/scripts/lib/release-queue/release/queries.ts @@ -1,5 +1,4 @@ import { FRAMEWORK_PRERELEASE_BRANCH } from '../config.ts'; -import { GitHubError } from '../github/error.ts'; import { isRecord, parseJson } from '../json.ts'; import type { Repository } from '../github/repository.ts'; import type { PullRequestResponse } from '../github/types.ts'; @@ -32,17 +31,13 @@ export async function getReleasePullRequest( /** Whether the prerelease branch of the framework is in prerelease mode. */ async function isPrereleaseBranchInPreMode(framework: Repository): Promise { - try { - const { content } = await framework.get<{ content: string }>( - `contents/${PRE_STATE_PATH}?ref=${FRAMEWORK_PRERELEASE_BRANCH}` - ); - // `exit` means the prerelease ends: the branch is then merged into the - // stable branch. - return readPreMode(Buffer.from(content, 'base64').toString()) === 'pre'; - } catch (error) { - if (error instanceof GitHubError && error.status === 404) return false; - throw error; - } + 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. */ @@ -87,11 +82,6 @@ export async function isFrameworkPullRequestMerged( framework: Repository, prNumber: number ): Promise { - try { - const { merged } = await framework.get(`pulls/${prNumber}`); - return merged === true; - } catch (error) { - if (error instanceof GitHubError && error.status === 404) return false; - throw error; - } + const pr = await framework.find(`pulls/${prNumber}`); + return pr?.merged === true; } From 8bae9260c3d2e27aabb4427723389fa1ba8bc240 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 22:57:23 +0100 Subject: [PATCH 23/35] Read the framework releases once per run --- scripts/lib/release-queue/handle-accepted.ts | 45 ++++---- .../lib/release-queue/refresh-umbrellas.ts | 15 +-- .../lib/release-queue/release/queries.test.ts | 29 ++--- scripts/lib/release-queue/release/queries.ts | 105 +++++++++++------- scripts/lib/release-queue/run-context.ts | 5 - .../release-queue/umbrella/refresh.test.ts | 21 ++-- scripts/lib/release-queue/umbrella/refresh.ts | 35 +++--- scripts/lib/release-queue/umbrella/state.ts | 33 +++--- 8 files changed, 153 insertions(+), 135 deletions(-) diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts index 36a6c2dccba97..900d5ef58d552 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -1,4 +1,4 @@ -import { ACCEPTED_LABEL, FRAMEWORK_STABLE_BRANCH } from './config.ts'; +import { ACCEPTED_LABEL } from './config.ts'; import type { Repository } from './github/repository.ts'; import { formatDryRunJoinComment, @@ -18,8 +18,7 @@ import { } from './join/routing.ts'; import { UmbrellaJoin, type Umbrella } from './join/umbrella-join.ts'; import { canActOn } from './modes.ts'; -import { getReleasePullRequest } from './release/queries.ts'; -import { getStableRelease, type StableRelease } from './release/release.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'; @@ -33,7 +32,7 @@ import { getSettledStableUmbrella } from './umbrella/state.ts'; */ interface DecidedPullRequest { pr: PullRequest; - release: StableRelease; + releases: FrameworkReleases; decision: Decision; } @@ -53,9 +52,9 @@ export async function handleAcceptedPullRequest( ) { if (!(await isSenderAllowed(run.actions, labelSender))) return; - const { pr, release, decision } = await readAcceptedPullRequest(run, prNumber); - console.info(JSON.stringify({ pr: pr.url, release, decision }, null, 2)); - const accepted = new AcceptedPullRequest(run, { pr, release }); + 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') { @@ -88,16 +87,15 @@ async function readAcceptedPullRequest( prNumber: number ): Promise { const pr = await getPullRequest(actions, prNumber); - const [readiness, releasePullRequest] = await Promise.all([ + const [readiness, releases] = await Promise.all([ getReadiness(actions, pr), - getReleasePullRequest(framework, FRAMEWORK_STABLE_BRANCH), + readFrameworkReleases(framework), ]); - const release = getStableRelease(releasePullRequest); const settledStableUmbrella = wouldRetargetFromStable(pr) - ? await getSettledStableUmbrella({ docs: actions, framework }, release) + ? await getSettledStableUmbrella(actions, releases) : undefined; - const decision = decideJoin(pr, readiness, { release, settledStableUmbrella }); - return { pr, release, decision }; + const decision = decideJoin(pr, readiness, { release: releases.stable, settledStableUmbrella }); + return { pr, releases, decision }; } /** @@ -107,21 +105,21 @@ async function readAcceptedPullRequest( class AcceptedPullRequest { #run: RunContext; #pr: PullRequest; - #release: StableRelease; + #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 release it ships with. + * @param {object} accepted - The docs PR, and the releases of the framework. * @param {PullRequest} accepted.pr - The docs PR. - * @param {StableRelease} accepted.release - The next release from the - * stable branch. + * @param {FrameworkReleases} accepted.releases - The releases of the + * framework. */ - constructor(run: RunContext, { pr, release }: { pr: PullRequest; release: StableRelease }) { + constructor(run: RunContext, { pr, releases }: { pr: PullRequest; releases: FrameworkReleases }) { this.#run = run; this.#pr = pr; - this.#release = release; + this.#releases = releases; } /** @@ -162,7 +160,7 @@ class AcceptedPullRequest { */ async join(route: Route) { const pr = this.#pr; - const umbrellaTitle = getUmbrellaTitle(route.umbrellaBase, this.#release); + const umbrellaTitle = getUmbrellaTitle(route.umbrellaBase, this.#releases.stable); const join = new UmbrellaJoin(this.#run.bot, { pr, route, umbrellaTitle }); const result = await join.mergePullRequest(); @@ -197,14 +195,17 @@ class AcceptedPullRequest { // A planned umbrella was only printed. if (umbrella.status === 'planned') return; const openUmbrella = await getUmbrella(bot, umbrella.number); - await refreshUmbrella(this.#run, openUmbrella, { release: this.#release, shouldBlock: true }); + await refreshUmbrella(this.#run, openUmbrella, { releases: this.#releases, 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.#release) }; + return { + status: 'planned', + title: getUmbrellaTitle(route.umbrellaBase, this.#releases.stable), + }; } async #getMessageContext(decision: RefusedDecision): Promise { diff --git a/scripts/lib/release-queue/refresh-umbrellas.ts b/scripts/lib/release-queue/refresh-umbrellas.ts index cbdba76b6eef2..c68aeee5873d6 100644 --- a/scripts/lib/release-queue/refresh-umbrellas.ts +++ b/scripts/lib/release-queue/refresh-umbrellas.ts @@ -1,7 +1,5 @@ -import { FRAMEWORK_STABLE_BRANCH } from './config.ts'; import { canActOn } from './modes.ts'; -import { getReleasePullRequest } from './release/queries.ts'; -import { getStableRelease, type StableRelease } from './release/release.ts'; +import { readFrameworkReleases, type FrameworkReleases } from './release/queries.ts'; import type { RunContext } from './run-context.ts'; import { listOpenUmbrellas, type UmbrellaPullRequest } from './umbrella/queries.ts'; import { refreshUmbrella } from './umbrella/refresh.ts'; @@ -16,11 +14,10 @@ import { refreshUmbrella } from './umbrella/refresh.ts'; */ export async function refreshOpenUmbrellas(run: RunContext) { const { actions, framework, mode } = run; - const [umbrellas, releasePullRequest] = await Promise.all([ + const [umbrellas, releases] = await Promise.all([ listOpenUmbrellas(actions), - getReleasePullRequest(framework, FRAMEWORK_STABLE_BRANCH), + readFrameworkReleases(framework), ]); - const release = getStableRelease(releasePullRequest); const numbers = umbrellas.map(({ number }) => `#${number}`).join(', '); console.info(`Open umbrellas: ${numbers || 'none'}.`); @@ -34,7 +31,7 @@ export async function refreshOpenUmbrellas(run: RunContext) { // 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(run, umbrella, release); + const failure = await tryRefreshUmbrella(run, umbrella, releases); if (failure) failures.push(failure); } if (failures.length > 0) { @@ -52,11 +49,11 @@ export async function refreshOpenUmbrellas(run: RunContext) { async function tryRefreshUmbrella( run: RunContext, umbrella: UmbrellaPullRequest, - release: StableRelease + releases: FrameworkReleases ): Promise { try { // Without a join, a dismissed review still covers the included PRs. - await refreshUmbrella(run, umbrella, { release, shouldBlock: false }); + await refreshUmbrella(run, umbrella, { releases, 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 index a25d528603833..ee91fb693c728 100644 --- a/scripts/lib/release-queue/release/queries.test.ts +++ b/scripts/lib/release-queue/release/queries.test.ts @@ -4,13 +4,20 @@ 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 { getPrereleasePullRequest } from './queries.ts'; +import { FrameworkReleases } from './queries.ts'; +import { getStableRelease } from './release.ts'; -describe('getPrereleasePullRequest', () => { +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'), }); @@ -23,7 +30,7 @@ describe('getPrereleasePullRequest', () => { }, }); - const pr = await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), stablePullRequest); + const pr = await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO)); assert.deepEqual(pr, { number: 18269, @@ -36,20 +43,14 @@ describe('getPrereleasePullRequest', () => { getResponses: { [preJson]: preMode('pre'), [nextPullRequests]: [] }, }); - assert.equal( - await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), stablePullRequest), - undefined - ); + 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), - stablePullRequest - ), + getPrereleasePullRequest(new Repository(new GitHubClient('token'), FRAMEWORK_REPO)), { name: 'GitHubError', status: 500, @@ -63,7 +64,7 @@ describe('getPrereleasePullRequest', () => { }); await assert.rejects( - getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), stablePullRequest), + 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/); @@ -80,7 +81,7 @@ describe('getPrereleasePullRequest', () => { }); await assert.rejects( - getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), stablePullRequest), + getPrereleasePullRequest(github.repository(FRAMEWORK_REPO)), /`\.changeset\/pre\.json` of `next` has no `mode`/ ); }); @@ -91,7 +92,7 @@ describe('getPrereleasePullRequest', () => { const github = stubGitHub(t, { getResponses }); assert.equal( - await getPrereleasePullRequest(github.repository(FRAMEWORK_REPO), stablePullRequest), + 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 index b2fd36a2b8cd3..a2d38da567156 100644 --- a/scripts/lib/release-queue/release/queries.ts +++ b/scripts/lib/release-queue/release/queries.ts @@ -1,14 +1,77 @@ -import { FRAMEWORK_PRERELEASE_BRANCH } from '../config.ts'; -import { isRecord, parseJson } from '../json.ts'; +import { FRAMEWORK_PRERELEASE_BRANCH, FRAMEWORK_STABLE_BRANCH } from '../config.ts'; import type { Repository } from '../github/repository.ts'; import type { PullRequestResponse } from '../github/types.ts'; -import { parseReleaseVersions, type ReleasePullRequest } from './release.ts'; +import { isRecord, parseJson } from '../json.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, with + * the next stable release read. + */ +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. Then the + * release PR of the stable branch, which the prerelease branch was merged + * into. + */ + 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. * @@ -18,7 +81,7 @@ const PRE_STATE_PATH = '.changeset/pre.json'; * @returns {Promise} The release PR with its * package versions, or `undefined` when none is open. */ -export async function getReleasePullRequest( +async function getReleasePullRequest( framework: Repository, branch: string ): Promise { @@ -51,37 +114,3 @@ function readPreMode(json: string): string { function isPreState(value: unknown): value is { mode: string } { return isRecord(value) && typeof value.mode === 'string'; } - -/** - * Gets the release PR a prerelease umbrella ships with. - * - * @param {Repository} framework - The framework repository. - * @param {ReleasePullRequest} [stablePullRequest] - The open release PR of the - * stable branch. - * @returns {Promise} The alpha or beta release - * PR while the prerelease branch is in prerelease mode. Then the release PR - * of the stable branch, which the prerelease branch was merged into. - */ -export async function getPrereleasePullRequest( - framework: Repository, - stablePullRequest: ReleasePullRequest | undefined -): Promise { - return (await isPrereleaseBranchInPreMode(framework)) - ? getReleasePullRequest(framework, FRAMEWORK_PRERELEASE_BRANCH) - : stablePullRequest; -} - -/** - * Whether a PR of the framework repository, such as a release PR, is merged. - * - * @param {Repository} framework - The framework repository. - * @param {number} prNumber - The number of the PR. - * @returns {Promise} `false` for a PR that doesn't exist. - */ -export async function isFrameworkPullRequestMerged( - framework: Repository, - prNumber: number -): Promise { - const pr = await framework.find(`pulls/${prNumber}`); - return pr?.merged === true; -} diff --git a/scripts/lib/release-queue/run-context.ts b/scripts/lib/release-queue/run-context.ts index 403803fce4a7a..700411c354c6f 100644 --- a/scripts/lib/release-queue/run-context.ts +++ b/scripts/lib/release-queue/run-context.ts @@ -1,11 +1,6 @@ import type { Repository } from './github/repository.ts'; import type { Mode } from './modes.ts'; -export interface Repositories { - docs: Repository; - framework: Repository; -} - /** The repositories a run acts through, and its mode. */ export interface RunContext { /** diff --git a/scripts/lib/release-queue/umbrella/refresh.test.ts b/scripts/lib/release-queue/umbrella/refresh.test.ts index 075accd3a44ee..eafca2f536da7 100644 --- a/scripts/lib/release-queue/umbrella/refresh.test.ts +++ b/scripts/lib/release-queue/umbrella/refresh.test.ts @@ -1,5 +1,6 @@ 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, createRunContext } from '../testing/run-context.ts'; @@ -37,15 +38,15 @@ const prereleaseOf = (number: number) => ({ }); /** Refreshes an umbrella as a refresh run does: without blocking its merge. */ -const refresh = ( +function refresh( github: ReturnType, umbrellaToRefresh: UmbrellaPullRequest, release: StableRelease -) => - refreshUmbrella(createRunContext(github, 'all'), umbrellaToRefresh, { - release, - shouldBlock: false, - }); +) { + const run = createRunContext(github, 'all'); + const releases = new FrameworkReleases(run.framework, release); + return refreshUmbrella(run, umbrellaToRefresh, { releases, shouldBlock: false }); +} /** Answers whether release PR `number` of `withastro/astro` is merged. */ const releaseMerged = (number: number, merged: boolean) => ({ @@ -245,10 +246,10 @@ describe('refreshUmbrella', () => { const github = stub(t); const unlinked = { ...umbrella, body: 'Release umbrella for `main`.' }; - await refreshUmbrella(createRunContext(github, 'all'), unlinked, { - release: releaseWith(18405, '7.5.0'), - shouldBlock: true, - }); + const run = createRunContext(github, 'all'); + const releases = new FrameworkReleases(run.framework, releaseWith(18405, '7.5.0')); + + await refreshUmbrella(run, unlinked, { releases, shouldBlock: true }); const [review] = github.writesBy(ACTIONS_TOKEN); assert.equal(review?.path, `repos/${REPO}/pulls/42/reviews`); diff --git a/scripts/lib/release-queue/umbrella/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts index ff7570d573ad4..7f1af817d57cd 100644 --- a/scripts/lib/release-queue/umbrella/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -1,8 +1,8 @@ import { DOCS_STABLE_BRANCH, RELEASE_TYPES } from '../config.ts'; import type { Repository } from '../github/repository.ts'; -import { getPrereleasePullRequest } from '../release/queries.ts'; +import type { FrameworkReleases } from '../release/queries.ts'; import type { StableRelease } from '../release/release.ts'; -import type { Repositories, RunContext } from '../run-context.ts'; +import type { RunContext } from '../run-context.ts'; import { formatUmbrellaBody } from './body.ts'; import { getUmbrellaReleaseLabel, getUmbrellaTitle } from './heading.ts'; import { @@ -23,17 +23,17 @@ import { getReleaseState } from './state.ts'; * @param {UmbrellaPullRequest} umbrella - The umbrella PR to refresh. * @param {object} options - What the umbrella follows, and when to block its * merge. - * @param {StableRelease} options.release - The next release from the stable - * branch. + * @param {FrameworkReleases} options.releases - The releases of the + * framework. * @param {boolean} options.shouldBlock - Whether to submit a merge-block * review when none is active. */ export async function refreshUmbrella( - { actions, bot, framework }: RunContext, + { actions, bot }: RunContext, umbrella: UmbrellaPullRequest, - { release, shouldBlock }: { release: StableRelease; shouldBlock: boolean } + { releases, shouldBlock }: { releases: FrameworkReleases; shouldBlock: boolean } ) { - const refreshed = await refreshTitleBodyAndLabels({ docs: bot, framework }, umbrella, release); + const refreshed = await refreshTitleBodyAndLabels(bot, umbrella, releases); await syncMergeBlockReview(actions, refreshed, { shouldBlock }); } @@ -42,22 +42,22 @@ export async function refreshUmbrella( * returns the umbrella as it is after. */ async function refreshTitleBodyAndLabels( - { docs, framework }: Repositories, + docs: Repository, umbrella: UmbrellaPullRequest, - release: StableRelease + releases: FrameworkReleases ): Promise { - const releaseState = await getReleaseState(framework, umbrella, release); + const releaseState = await getReleaseState(umbrella, releases); const isFollowing = releaseState.status === 'follows'; - if (isFollowing && isBeforeMajor(umbrella, release)) { + if (isFollowing && isBeforeMajor(umbrella, releases.stable)) { console.info(`Skipped #${umbrella.number}: the next stable release is a major.`); return umbrella; } const linkedReleasePrNumber = isFollowing - ? await getFollowedReleasePrNumber(framework, umbrella, release) + ? await getFollowedReleasePrNumber(umbrella, releases) : releaseState.releasePrNumber; const wantedText = { - title: isFollowing ? getUmbrellaTitle(umbrella.base, release) : umbrella.title, + title: isFollowing ? getUmbrellaTitle(umbrella.base, releases.stable) : umbrella.title, body: formatUmbrellaBody(umbrella.base, { releasePrNumber: linkedReleasePrNumber, included: await listIncludedPullRequests(docs, umbrella), @@ -69,7 +69,7 @@ async function refreshTitleBodyAndLabels( } const labelChanges = isFollowing - ? getLabelChanges(umbrella.labels, getUmbrellaReleaseLabel(umbrella.base, release)) + ? getLabelChanges(umbrella.labels, getUmbrellaReleaseLabel(umbrella.base, releases.stable)) : getKeptLabelChanges(umbrella.labels); await applyLabelChanges(docs, umbrella.number, labelChanges); return { ...umbrella, ...textChanges, labels: applyToLabels(umbrella.labels, labelChanges) }; @@ -89,14 +89,13 @@ function isBeforeMajor(umbrella: UmbrellaPullRequest, release: StableRelease): b * umbrella. */ async function getFollowedReleasePrNumber( - framework: Repository, umbrella: UmbrellaPullRequest, - release: StableRelease + releases: FrameworkReleases ): Promise { const releasePullRequest = umbrella.base === DOCS_STABLE_BRANCH - ? release.pullRequest - : await getPrereleasePullRequest(framework, release.pullRequest); + ? releases.stable.pullRequest + : await releases.getPrereleasePullRequest(); return releasePullRequest?.number; } diff --git a/scripts/lib/release-queue/umbrella/state.ts b/scripts/lib/release-queue/umbrella/state.ts index acd6b3b72d841..689b8418ec10a 100644 --- a/scripts/lib/release-queue/umbrella/state.ts +++ b/scripts/lib/release-queue/umbrella/state.ts @@ -1,8 +1,6 @@ import { DOCS_STABLE_BRANCH } from '../config.ts'; import type { Repository } from '../github/repository.ts'; -import { isFrameworkPullRequestMerged } from '../release/queries.ts'; -import type { StableRelease } from '../release/release.ts'; -import type { Repositories } from '../run-context.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'; @@ -32,24 +30,22 @@ type ReleaseState = /** * Gets how an umbrella relates to the release PRs. * - * @param {Repository} framework - The framework repository. * @param {UmbrellaPullRequest} umbrella - The umbrella PR, with the release PR * its body links. - * @param {StableRelease} release - The next release from the stable branch. + * @param {FrameworkReleases} releases - The releases of the framework. * @returns {Promise} The release state. */ export async function getReleaseState( - framework: Repository, umbrella: UmbrellaPullRequest, - release: StableRelease + 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 = release.pullRequest?.number; + const openReleasePrNumber = releases.stable.pullRequest?.number; if ( linkedReleasePrNumber !== undefined && - (await isLinkedReleaseMerged(framework, linkedReleasePrNumber, openReleasePrNumber)) + (await isLinkedReleaseMerged(releases, linkedReleasePrNumber)) ) { return { status: 'settled', releasePrNumber: linkedReleasePrNumber }; } @@ -63,29 +59,28 @@ export async function getReleaseState( * merging settles nothing, and the open one isn't merged yet. */ async function isLinkedReleaseMerged( - framework: Repository, - linkedReleasePrNumber: number, - openReleasePrNumber: number | undefined + releases: FrameworkReleases, + linkedReleasePrNumber: number ): Promise { - if (linkedReleasePrNumber === openReleasePrNumber) return false; - return isFrameworkPullRequestMerged(framework, linkedReleasePrNumber); + if (linkedReleasePrNumber === releases.stable.pullRequest?.number) return false; + return releases.isPullRequestMerged(linkedReleasePrNumber); } /** * Gets the open stable umbrella if it's settled. * - * @param {Repositories} repositories - The docs and framework repositories. - * @param {StableRelease} release - The next release from the stable branch. + * @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, framework }: Repositories, - release: StableRelease + docs: Repository, + releases: FrameworkReleases ): Promise { const umbrella = await findOpenUmbrella(docs, STABLE_UMBRELLA_BRANCH); if (!umbrella) return undefined; - const state = await getReleaseState(framework, umbrella, release); + const state = await getReleaseState(umbrella, releases); return state.status === 'settled' ? { number: umbrella.number, releasePrNumber: state.releasePrNumber } : undefined; From 8a7de9a93b18da81ec85b9dff819531c89f7d133 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 23:17:03 +0100 Subject: [PATCH 24/35] List open PRs through the repository, with GitHub's head format --- .../lib/release-queue/github/repository.ts | 23 ++++++++++++++++++- scripts/lib/release-queue/release/queries.ts | 5 ++-- scripts/lib/release-queue/umbrella/queries.ts | 8 +++---- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/scripts/lib/release-queue/github/repository.ts b/scripts/lib/release-queue/github/repository.ts index 30fa6bd135737..1ea150bf02e16 100644 --- a/scripts/lib/release-queue/github/repository.ts +++ b/scripts/lib/release-queue/github/repository.ts @@ -1,6 +1,6 @@ import type { GitHubClient, WriteMethod } from './client.ts'; import { isNotFound } from './error.ts'; -import type { CompareResponse } from './types.ts'; +import type { CompareResponse, PullRequestResponse } from './types.ts'; /** * A GitHub repository, read and changed through a client: the requests of @@ -56,6 +56,27 @@ export class Repository { 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. * diff --git a/scripts/lib/release-queue/release/queries.ts b/scripts/lib/release-queue/release/queries.ts index a2d38da567156..8aed20140c4cf 100644 --- a/scripts/lib/release-queue/release/queries.ts +++ b/scripts/lib/release-queue/release/queries.ts @@ -85,9 +85,8 @@ async function getReleasePullRequest( framework: Repository, branch: string ): Promise { - const [pr] = await framework.get( - `pulls?state=open&head=${framework.owner}:${RELEASE_BRANCH_PREFIX}${branch}` - ); + // 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 ?? '') }; } diff --git a/scripts/lib/release-queue/umbrella/queries.ts b/scripts/lib/release-queue/umbrella/queries.ts index d9ea0faf65bdd..5bba43c45ea20 100644 --- a/scripts/lib/release-queue/umbrella/queries.ts +++ b/scripts/lib/release-queue/umbrella/queries.ts @@ -77,9 +77,7 @@ export async function findOpenUmbrella( repo: Repository, branch: string ): Promise { - const pullRequests = await repo.get( - `pulls?state=open&head=${repo.owner}:${branch}` - ); + const pullRequests = await repo.listOpenPullRequests({ head: branch }); const umbrella = pullRequests.find((pr) => isUmbrellaPullRequest(pr, repo)); return umbrella && toUmbrellaPullRequest(umbrella); } @@ -91,7 +89,7 @@ export async function findOpenUmbrella( * @returns {Promise} The open umbrella PRs. */ export async function listOpenUmbrellas(repo: Repository): Promise { - const pullRequests = await repo.getAll(`pulls?state=open`); + const pullRequests = await repo.listOpenPullRequests(); return pullRequests.filter((pr) => isUmbrellaPullRequest(pr, repo)).map(toUmbrellaPullRequest); } @@ -145,6 +143,6 @@ export async function listOpenPullRequestsTargeting( repo: Repository, branch: string ): Promise { - const pullRequests = await repo.getAll<{ number: number }>(`pulls?state=open&base=${branch}`); + const pullRequests = await repo.listOpenPullRequests({ base: branch }); return pullRequests.map(({ number }) => number); } From 02fee92de1551b2da81127d16f22fa2ff69012fc Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 23:32:22 +0100 Subject: [PATCH 25/35] Build the run context in its own module --- .../lib/release-queue/handle-accepted.test.ts | 18 +++--- scripts/lib/release-queue/main.ts | 42 +------------ .../release-queue/refresh-umbrellas.test.ts | 14 ++--- scripts/lib/release-queue/run-context.ts | 62 ++++++++++++++++++- .../lib/release-queue/testing/run-context.ts | 2 +- .../release-queue/umbrella/refresh.test.ts | 6 +- 6 files changed, 82 insertions(+), 62 deletions(-) diff --git a/scripts/lib/release-queue/handle-accepted.test.ts b/scripts/lib/release-queue/handle-accepted.test.ts index a4539b9e7f138..e129279e08308 100644 --- a/scripts/lib/release-queue/handle-accepted.test.ts +++ b/scripts/lib/release-queue/handle-accepted.test.ts @@ -1,12 +1,12 @@ 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, createRunContext } from './testing/run-context.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'; -import { handleAcceptedPullRequest } from './handle-accepted.ts'; const paths = { pr: `repos/${REPO}/pulls/7`, @@ -81,7 +81,7 @@ const stubReadyPullRequest = (t: TestContext, options: StubOptions = {}) => /** 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(createRunContext(github, mode), 7, sender); + await handleAcceptedPullRequest(stubRunContext(github, mode), 7, sender); return { actionsWrites: github.writesBy(ACTIONS_TOKEN), botWrites: github.writesBy(BOT_TOKEN) }; } @@ -210,7 +210,7 @@ describe('handleAcceptedPullRequest', () => { it("doesn't read the prerelease line for a PR shipping to main", async (t) => { const github = stubReadyPullRequest(t); - await handleAcceptedPullRequest(createRunContext(github, 'stable-only'), 7, 'maintainer'); + await handleAcceptedPullRequest(stubRunContext(github, 'stable-only'), 7, 'maintainer'); assert.ok(!github.calls.some(({ path }) => path.includes('.changeset/pre.json'))); }); @@ -233,7 +233,7 @@ describe('handleAcceptedPullRequest', () => { it(`doesn't read the prerelease line for ${name} of a prerelease PR`, async (t) => { const github = stubReadyPullRequest(t, { getResponses }); - await handleAcceptedPullRequest(createRunContext(github, mode), 7, 'maintainer'); + await handleAcceptedPullRequest(stubRunContext(github, mode), 7, 'maintainer'); assert.ok(github.writes.some(({ path }) => path.endsWith('/comments'))); assert.ok(!github.calls.some(({ path }) => path.includes('.changeset/pre.json'))); @@ -324,7 +324,7 @@ describe('handleAcceptedPullRequest', () => { getResponses: { ...settledUmbrella(), [paths.pr]: pr }, }); - await handleAcceptedPullRequest(createRunContext(github, 'dry-run'), 7, 'maintainer'); + await handleAcceptedPullRequest(stubRunContext(github, 'dry-run'), 7, 'maintainer'); assert.equal(github.countReads('repos/withastro/astro/pulls/18405'), 0); }); @@ -338,7 +338,7 @@ describe('handleAcceptedPullRequest', () => { }, }); - await handleAcceptedPullRequest(createRunContext(github, 'all'), 7, 'maintainer'); + await handleAcceptedPullRequest(stubRunContext(github, 'all'), 7, 'maintainer'); assert.ok(writtenPaths(github.writes).includes(`PUT repos/${REPO}/pulls/7/merge`)); assert.equal(github.countReads('repos/withastro/astro/pulls/18405'), 0); @@ -367,7 +367,7 @@ describe('handleAcceptedPullRequest', () => { }); await assert.rejects( - handleAcceptedPullRequest(createRunContext(github, 'stable-only'), 7, 'maintainer'), + handleAcceptedPullRequest(stubRunContext(github, 'stable-only'), 7, 'maintainer'), (error: unknown) => { assert.ok(error instanceof Error); assert.match(error.message, /but the umbrella PR couldn't be opened\.$/); @@ -388,7 +388,7 @@ describe('handleAcceptedPullRequest', () => { const github = stubReadyPullRequest(t, { getResponses: responses }); await assert.rejects( - handleAcceptedPullRequest(createRunContext(github, 'stable-only'), 7, 'maintainer'), + handleAcceptedPullRequest(stubRunContext(github, 'stable-only'), 7, 'maintainer'), { name: 'GitHubError', status: 404 } ); assert.match(commentBody(github.writesBy(BOT_TOKEN)), /squash-merged into/); diff --git a/scripts/lib/release-queue/main.ts b/scripts/lib/release-queue/main.ts index e89dee2cbaae6..02708cd21115e 100644 --- a/scripts/lib/release-queue/main.ts +++ b/scripts/lib/release-queue/main.ts @@ -1,12 +1,8 @@ import { readFile } from 'node:fs/promises'; import { chooseAction, parseEvent, type RunEvent } from './cli.ts'; -import { DOCS_REPO, FRAMEWORK_REPO } from './config.ts'; -import { GitHubClient, PrintingGitHubClient } from './github/client.ts'; -import { Repository } from './github/repository.ts'; import { handleAcceptedPullRequest } from './handle-accepted.ts'; -import { isDryRun, parseMode } from './modes.ts'; import { refreshOpenUmbrellas } from './refresh-umbrellas.ts'; -import type { RunContext } from './run-context.ts'; +import { createRunContext, isGitHubActions, type RunEnv } from './run-context.ts'; /** The command-line flags of `scripts/release-queue.ts`. */ interface Flags { @@ -17,18 +13,10 @@ interface Flags { } /** The environment variables the release queue reads. */ -interface Env { - /** 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; +interface Env extends RunEnv { // The variables below are set by GitHub Actions. - GITHUB_ACTIONS?: string | undefined; GITHUB_EVENT_NAME?: string | undefined; GITHUB_EVENT_PATH?: string | undefined; - GITHUB_REPOSITORY?: string | undefined; } /** @@ -61,32 +49,6 @@ export async function runReleaseQueue({ flags, env }: { flags: Flags; env: Env } } } -const isGitHubActions = (env: Env) => env.GITHUB_ACTIONS === 'true'; - -function createRunContext(env: Env): 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, - }; -} - async function readEvent(path: string | undefined): Promise { return path ? parseEvent(await readFile(path, 'utf-8'), path) : {}; } diff --git a/scripts/lib/release-queue/refresh-umbrellas.test.ts b/scripts/lib/release-queue/refresh-umbrellas.test.ts index 4be01834aa4c9..f3d9cb483760b 100644 --- a/scripts/lib/release-queue/refresh-umbrellas.test.ts +++ b/scripts/lib/release-queue/refresh-umbrellas.test.ts @@ -8,7 +8,7 @@ import { umbrellaCommitResponses, umbrellaResponse, } from './testing/fixtures.ts'; -import { ACTIONS_TOKEN, BOT_TOKEN, createRunContext } from './testing/run-context.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'; @@ -60,7 +60,7 @@ describe('refreshOpenUmbrellas', () => { }, }); - await refreshOpenUmbrellas(createRunContext(github, 'stable-only')); + await refreshOpenUmbrellas(stubRunContext(github, 'stable-only')); assert.deepEqual(github.writes, []); }); @@ -68,7 +68,7 @@ describe('refreshOpenUmbrellas', () => { it("doesn't change anything in a dry run", async (t) => { const github = stubGitHub(t, { getResponses: openPullRequests }); - await refreshOpenUmbrellas(createRunContext(github, 'dry-run')); + await refreshOpenUmbrellas(stubRunContext(github, 'dry-run')); assert.deepEqual(github.writes, []); }); @@ -76,7 +76,7 @@ describe('refreshOpenUmbrellas', () => { it('refreshes only the main umbrella in stable-only mode', async (t) => { const github = stubGitHub(t, { getResponses: openPullRequests }); - await refreshOpenUmbrellas(createRunContext(github, 'stable-only')); + await refreshOpenUmbrellas(stubRunContext(github, 'stable-only')); assert.deepEqual(refreshedUmbrellas(github.writesBy(BOT_TOKEN)), [42]); // Without a join, no merge-block review is requested. @@ -100,7 +100,7 @@ describe('refreshOpenUmbrellas', () => { }, }); - await refreshOpenUmbrellas(createRunContext(github, 'stable-only')); + await refreshOpenUmbrellas(stubRunContext(github, 'stable-only')); assert.deepEqual( github.writesBy(ACTIONS_TOKEN).map(({ method, path }) => `${method} ${path}`), @@ -111,7 +111,7 @@ describe('refreshOpenUmbrellas', () => { it('refreshes every umbrella in all mode, but not a fork or another base', async (t) => { const github = stubGitHub(t, { getResponses: openPullRequests }); - await refreshOpenUmbrellas(createRunContext(github, 'all')); + await refreshOpenUmbrellas(stubRunContext(github, 'all')); assert.deepEqual(refreshedUmbrellas(github.writesBy(BOT_TOKEN)), [42, 43]); }); @@ -122,7 +122,7 @@ describe('refreshOpenUmbrellas', () => { writeFailures: { [`PATCH repos/${REPO}/pulls/42`]: { status: 500, message: 'Oops' } }, }); - await assert.rejects(refreshOpenUmbrellas(createRunContext(github, 'all')), (error) => { + 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\./); diff --git a/scripts/lib/release-queue/run-context.ts b/scripts/lib/release-queue/run-context.ts index 700411c354c6f..b187160bd1844 100644 --- a/scripts/lib/release-queue/run-context.ts +++ b/scripts/lib/release-queue/run-context.ts @@ -1,5 +1,20 @@ -import type { Repository } from './github/repository.ts'; -import type { Mode } from './modes.ts'; +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 { @@ -14,3 +29,46 @@ export interface RunContext { 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/run-context.ts b/scripts/lib/release-queue/testing/run-context.ts index e2bf9aa8a655a..552966d89b56d 100644 --- a/scripts/lib/release-queue/testing/run-context.ts +++ b/scripts/lib/release-queue/testing/run-context.ts @@ -9,7 +9,7 @@ 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 createRunContext(github: ReturnType, mode: Mode): RunContext { +export function stubRunContext(github: ReturnType, mode: Mode): RunContext { return { actions: github.repository(REPO, { token: ACTIONS_TOKEN }), bot: github.repository(REPO, { token: BOT_TOKEN }), diff --git a/scripts/lib/release-queue/umbrella/refresh.test.ts b/scripts/lib/release-queue/umbrella/refresh.test.ts index eafca2f536da7..69271d162989a 100644 --- a/scripts/lib/release-queue/umbrella/refresh.test.ts +++ b/scripts/lib/release-queue/umbrella/refresh.test.ts @@ -3,7 +3,7 @@ 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, createRunContext } from '../testing/run-context.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'; @@ -43,7 +43,7 @@ function refresh( umbrellaToRefresh: UmbrellaPullRequest, release: StableRelease ) { - const run = createRunContext(github, 'all'); + const run = stubRunContext(github, 'all'); const releases = new FrameworkReleases(run.framework, release); return refreshUmbrella(run, umbrellaToRefresh, { releases, shouldBlock: false }); } @@ -246,7 +246,7 @@ describe('refreshUmbrella', () => { const github = stub(t); const unlinked = { ...umbrella, body: 'Release umbrella for `main`.' }; - const run = createRunContext(github, 'all'); + const run = stubRunContext(github, 'all'); const releases = new FrameworkReleases(run.framework, releaseWith(18405, '7.5.0')); await refreshUmbrella(run, unlinked, { releases, shouldBlock: true }); From 87783cb292defd965b468c4e9baacb10d220134d Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 26 Oct 2026 23:55:52 +0100 Subject: [PATCH 26/35] Keep the merge-block reads with the review, and group the text formats --- scripts/lib/release-queue/cli.ts | 2 +- .../lib/release-queue/{ => formats}/json.ts | 0 scripts/lib/release-queue/formats/markdown.ts | 12 +++++ scripts/lib/release-queue/github/client.ts | 2 +- scripts/lib/release-queue/release/queries.ts | 2 +- .../lib/release-queue/testing/stub-github.ts | 2 +- .../lib/release-queue/umbrella/merge-block.ts | 43 +++++++++++++--- scripts/lib/release-queue/umbrella/queries.ts | 51 +------------------ 8 files changed, 55 insertions(+), 59 deletions(-) rename scripts/lib/release-queue/{ => formats}/json.ts (100%) create mode 100644 scripts/lib/release-queue/formats/markdown.ts diff --git a/scripts/lib/release-queue/cli.ts b/scripts/lib/release-queue/cli.ts index 37a017c704e6e..43025236e1121 100644 --- a/scripts/lib/release-queue/cli.ts +++ b/scripts/lib/release-queue/cli.ts @@ -1,4 +1,4 @@ -import { isRecord, parseJson } from './json.ts'; +import { isRecord, parseJson } from './formats/json.ts'; /** A positive integer, written with digits only. */ const PR_NUMBER = /^[1-9]\d*$/; diff --git a/scripts/lib/release-queue/json.ts b/scripts/lib/release-queue/formats/json.ts similarity index 100% rename from scripts/lib/release-queue/json.ts rename to scripts/lib/release-queue/formats/json.ts diff --git a/scripts/lib/release-queue/formats/markdown.ts b/scripts/lib/release-queue/formats/markdown.ts new file mode 100644 index 0000000000000..812bcad6536f4 --- /dev/null +++ b/scripts/lib/release-queue/formats/markdown.ts @@ -0,0 +1,12 @@ +// The Markdown text of PR descriptions, comments and reviews. + +/** + * Replaces the `\r\n` line endings GitHub keeps from a description or review + * edited in the browser with `\n`. + * + * @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'); +} diff --git a/scripts/lib/release-queue/github/client.ts b/scripts/lib/release-queue/github/client.ts index 56a948bdcc88d..596675cb7a32d 100644 --- a/scripts/lib/release-queue/github/client.ts +++ b/scripts/lib/release-queue/github/client.ts @@ -1,6 +1,6 @@ import { setTimeout } from 'node:timers/promises'; +import { isRecord, parseJson } from '../formats/json.ts'; import { GitHubError } from './error.ts'; -import { isRecord, parseJson } from '../json.ts'; const API_URL = 'https://api.github.com'; const API_VERSION = '2022-11-28'; diff --git a/scripts/lib/release-queue/release/queries.ts b/scripts/lib/release-queue/release/queries.ts index 8aed20140c4cf..06ad105fb4c10 100644 --- a/scripts/lib/release-queue/release/queries.ts +++ b/scripts/lib/release-queue/release/queries.ts @@ -1,7 +1,7 @@ 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 { isRecord, parseJson } from '../json.ts'; import { getStableRelease, parseReleaseVersions, diff --git a/scripts/lib/release-queue/testing/stub-github.ts b/scripts/lib/release-queue/testing/stub-github.ts index 4408ac2eac1eb..c4a32b5b20608 100644 --- a/scripts/lib/release-queue/testing/stub-github.ts +++ b/scripts/lib/release-queue/testing/stub-github.ts @@ -1,6 +1,6 @@ import type { TestContext } from 'node:test'; +import { isRecord } from '../formats/json.ts'; import { GitHubClient, PrintingGitHubClient } from '../github/client.ts'; -import { isRecord } from '../json.ts'; import { Repository } from '../github/repository.ts'; import { REPO } from './fixtures.ts'; diff --git a/scripts/lib/release-queue/umbrella/merge-block.ts b/scripts/lib/release-queue/umbrella/merge-block.ts index c3088e01d2fce..5e567c48b1894 100644 --- a/scripts/lib/release-queue/umbrella/merge-block.ts +++ b/scripts/lib/release-queue/umbrella/merge-block.ts @@ -6,15 +6,18 @@ import { RELEASE_TITLE_PREFIX, TRANSLATIONS_LABEL, } from '../config.ts'; +import { normalizeLineEndings } 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 { - listOpenPullRequestsTargeting, - listUmbrellaReviews, - type SubmittedReview, - type UmbrellaPullRequest, -} from './queries.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 body of the merge-block review: the checklist a maintainer goes @@ -58,6 +61,34 @@ function describeLunariaItem(): string { return `If the umbrella has the \`${TRANSLATIONS_LABEL}\` label, add a [Lunaria directive](${LUNARIA_DIRECTIVE_DOCS}) to the commit body in the merge dialog. For example: \`${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: a dismissal changes its * state. diff --git a/scripts/lib/release-queue/umbrella/queries.ts b/scripts/lib/release-queue/umbrella/queries.ts index 5bba43c45ea20..feabf65827417 100644 --- a/scripts/lib/release-queue/umbrella/queries.ts +++ b/scripts/lib/release-queue/umbrella/queries.ts @@ -1,5 +1,6 @@ +import { normalizeLineEndings } from '../formats/markdown.ts'; import type { Repository } from '../github/repository.ts'; -import type { PullRequestResponse, ReviewResponse } from '../github/types.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. */ @@ -14,12 +15,6 @@ export interface UmbrellaPullRequest { branch: string; } -/** A review of an umbrella PR, with its body. */ -export interface SubmittedReview extends ReviewResponse { - id: number; - body: string; -} - /** Whether a PR is an umbrella PR of the repository. */ function isUmbrellaPullRequest({ base, head }: PullRequestResponse, repo: Repository): boolean { return ( @@ -29,17 +24,6 @@ function isUmbrellaPullRequest({ base, head }: PullRequestResponse, repo: Reposi ); } -/** - * Replaces the `\r\n` line endings GitHub keeps from a description or review - * edited in the browser with `\n`. - * - * @param {string} text - A description or a review body read from GitHub. - * @returns {string} The text with `\n` line endings only. - */ -function normalizeLineEndings(text: string): string { - return text.replaceAll('\r\n', '\n'); -} - function toUmbrellaPullRequest(data: PullRequestResponse): UmbrellaPullRequest { return { number: data.number, @@ -115,34 +99,3 @@ export async function listIncludedPullRequests( return match ? [Number(match[1])] : []; }); } - -/** - * 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. - */ -export 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. - */ -export async function listOpenPullRequestsTargeting( - repo: Repository, - branch: string -): Promise { - const pullRequests = await repo.listOpenPullRequests({ base: branch }); - return pullRequests.map(({ number }) => number); -} From 8ed8d82a3981b21ca5462a017b146bedf5b5c888 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 27 Oct 2026 00:01:10 +0100 Subject: [PATCH 27/35] Write the code spans the bot posts in one place --- scripts/lib/release-queue/formats/markdown.ts | 10 ++++++++++ scripts/lib/release-queue/github/repository.ts | 10 ++++++++++ scripts/lib/release-queue/join/messages.ts | 2 +- scripts/lib/release-queue/join/umbrella-join.ts | 3 ++- scripts/lib/release-queue/umbrella/body.ts | 3 ++- scripts/lib/release-queue/umbrella/merge-block.ts | 10 +++++----- scripts/lib/release-queue/umbrella/refresh.ts | 4 +--- 7 files changed, 31 insertions(+), 11 deletions(-) diff --git a/scripts/lib/release-queue/formats/markdown.ts b/scripts/lib/release-queue/formats/markdown.ts index 812bcad6536f4..72ee46cbaa5c4 100644 --- a/scripts/lib/release-queue/formats/markdown.ts +++ b/scripts/lib/release-queue/formats/markdown.ts @@ -10,3 +10,13 @@ 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/repository.ts b/scripts/lib/release-queue/github/repository.ts index 1ea150bf02e16..210a1d71ea85e 100644 --- a/scripts/lib/release-queue/github/repository.ts +++ b/scripts/lib/release-queue/github/repository.ts @@ -124,6 +124,16 @@ export class Repository { await this.write('POST', `issues/${prNumber}/comments`, { body }); } + /** + * Adds labels to a PR. The labels it already has stay. + * + * @param {number} prNumber - The number of the PR. + * @param {string[]} labels - The labels to add. + */ + async addLabels(prNumber: number, labels: string[]) { + await this.write('POST', `issues/${prNumber}/labels`, { labels }); + } + /** * Removes a label from a PR. A label that is already gone is fine. * diff --git a/scripts/lib/release-queue/join/messages.ts b/scripts/lib/release-queue/join/messages.ts index 3140acc934442..62cdead1f0e3a 100644 --- a/scripts/lib/release-queue/join/messages.ts +++ b/scripts/lib/release-queue/join/messages.ts @@ -5,6 +5,7 @@ import { 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'; @@ -18,7 +19,6 @@ export interface MessageContext { stableUmbrella?: { number: number } | undefined; } -const wrapInCode = (text: string) => `\`${text}\``; 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}.`); diff --git a/scripts/lib/release-queue/join/umbrella-join.ts b/scripts/lib/release-queue/join/umbrella-join.ts index cc3ed90bc2a59..e4e465c3722d0 100644 --- a/scripts/lib/release-queue/join/umbrella-join.ts +++ b/scripts/lib/release-queue/join/umbrella-join.ts @@ -1,3 +1,4 @@ +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'; @@ -196,7 +197,7 @@ export class UmbrellaJoin { title, head: umbrellaBranch, base: umbrellaBase, - body: `Release umbrella for \`${umbrellaBase}\`, managed by the release queue.`, + body: `Release umbrella for ${wrapInCode(umbrellaBase)}, managed by the release queue.`, }); // Without an answer, the request was only printed. const umbrella: Umbrella = created diff --git a/scripts/lib/release-queue/umbrella/body.ts b/scripts/lib/release-queue/umbrella/body.ts index 8cc4131be7bec..cc452174ba2ce 100644 --- a/scripts/lib/release-queue/umbrella/body.ts +++ b/scripts/lib/release-queue/umbrella/body.ts @@ -1,4 +1,5 @@ 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. */ @@ -59,5 +60,5 @@ function describeShipping(umbrellaBase: string, releasePrNumber: number | undefi return `Ships with ${formatFrameworkReference(releasePrNumber)}.`; } const release = umbrellaBase === DOCS_STABLE_BRANCH ? 'release' : 'prerelease'; - return `Ships with the next ${release} of \`${FRAMEWORK_REPO}\`, once its \`${RELEASE_TITLE_PREFIX}\` PR is open.`; + 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/merge-block.ts b/scripts/lib/release-queue/umbrella/merge-block.ts index 5e567c48b1894..90623ad5c8b5e 100644 --- a/scripts/lib/release-queue/umbrella/merge-block.ts +++ b/scripts/lib/release-queue/umbrella/merge-block.ts @@ -6,7 +6,7 @@ import { RELEASE_TITLE_PREFIX, TRANSLATIONS_LABEL, } from '../config.ts'; -import { normalizeLineEndings } from '../formats/markdown.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'; @@ -37,7 +37,7 @@ function formatMergeBlockBody(umbrella: UmbrellaPullRequest, openPullRequests: n function describeReleaseItem(umbrella: UmbrellaPullRequest): string { const releasePrNumber = parseReleaseMarker(umbrella.body); return releasePrNumber === undefined - ? `The \`${RELEASE_TITLE_PREFIX}\` PR of \`${FRAMEWORK_REPO}\` this umbrella ships with is merged.` + ? `The ${wrapInCode(RELEASE_TITLE_PREFIX)} PR of ${wrapInCode(FRAMEWORK_REPO)} this umbrella ships with is merged.` : `${formatFrameworkReference(releasePrNumber)} is merged.`; } @@ -45,9 +45,9 @@ function describeBranchItem( { branch, base }: UmbrellaPullRequest, openPullRequests: number[] ): string { - if (openPullRequests.length === 0) return `No open PR targets \`${branch}\`.`; + if (openPullRequests.length === 0) return `No open PR targets ${wrapInCode(branch)}.`; const list = openPullRequests.map((prNumber) => `#${prNumber}`).join(', '); - return `These PRs still target \`${branch}\`: ${list}. Merge them into the umbrella, or change their base to \`${base}\` and update them after the umbrella is merged.`; + 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.`; } /** @@ -58,7 +58,7 @@ function describeBranchItem( * after the merge, with `GITHUB_TOKEN`, which doesn't trigger this workflow. */ function describeLunariaItem(): string { - return `If the umbrella has the \`${TRANSLATIONS_LABEL}\` label, add a [Lunaria directive](${LUNARIA_DIRECTIVE_DOCS}) to the commit body in the merge dialog. For example: \`${LUNARIA_DIRECTIVE}\`.`; + 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)}.`; } /** diff --git a/scripts/lib/release-queue/umbrella/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts index 7f1af817d57cd..554fc425c3881 100644 --- a/scripts/lib/release-queue/umbrella/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -111,8 +111,6 @@ function getTextChanges( } async function applyLabelChanges(docs: Repository, umbrellaNumber: number, changes: LabelChanges) { - if (changes.toAdd.length > 0) { - await docs.write('POST', `issues/${umbrellaNumber}/labels`, { labels: changes.toAdd }); - } + if (changes.toAdd.length > 0) await docs.addLabels(umbrellaNumber, changes.toAdd); await Promise.all(changes.toRemove.map((label) => docs.removeLabel(umbrellaNumber, label))); } From e32ddcf2c2a1874f5c6a7c2974ff0ddc0cb81a60 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 27 Oct 2026 00:17:30 +0100 Subject: [PATCH 28/35] Classify check runs and commit statuses as one kind of check --- scripts/lib/release-queue/join/queries.ts | 7 +- .../lib/release-queue/join/readiness.test.ts | 86 +++++++++++++------ scripts/lib/release-queue/join/readiness.ts | 64 ++++++++------ 3 files changed, 107 insertions(+), 50 deletions(-) diff --git a/scripts/lib/release-queue/join/queries.ts b/scripts/lib/release-queue/join/queries.ts index cf5aabe71fe0e..7ea7854f4cc97 100644 --- a/scripts/lib/release-queue/join/queries.ts +++ b/scripts/lib/release-queue/join/queries.ts @@ -12,6 +12,8 @@ import { classifyChecks, classifyReviewers, getLatestReviewStates, + readCheckRun, + readCommitStatus, type Readiness, type Review, } from './readiness.ts'; @@ -99,7 +101,10 @@ export async function getReadiness(repo: Repository, pr: PullRequest): Promise { it('keeps the latest approval or change request of each reviewer', () => { @@ -65,24 +71,57 @@ describe('classifyReviewers', () => { }); }); +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', 'lint-ja', 'links', 'check', 'deploy', 'external', 'zizmor']; + const required = ['eslint', 'links', 'check', 'deploy', 'external']; - it('lists unfinished and unsuccessful required check runs and statuses', () => { - const checks = classifyChecks( - required, - [ - { name: 'eslint', status: 'completed', conclusion: 'success' }, - { name: 'lint-ja', status: 'completed', conclusion: 'skipped' }, - { name: 'links', status: 'completed', conclusion: 'failure' }, - { name: 'check', status: 'in_progress', conclusion: null }, - { name: 'zizmor', status: 'completed', conclusion: 'neutral' }, - ], - [ - { context: 'deploy', state: 'pending' }, - { context: 'external', state: 'error' }, - ] - ); + 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'], @@ -94,17 +133,16 @@ describe('classifyChecks', () => { const checks = classifyChecks( ['eslint'], [ - { name: 'eslint', status: 'completed', conclusion: 'success' }, - { name: 'triage', status: 'in_progress', conclusion: null }, - { name: 'Generate Lunaria Overview', status: 'completed', conclusion: 'failure' }, - ], - [] + { 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'], [], []), { + assert.deepEqual(classifyChecks(['eslint'], []), { failingChecks: [], pendingChecks: [], missingChecks: ['eslint'], @@ -112,7 +150,7 @@ describe('classifyChecks', () => { }); it('accepts a commit when no check is required', () => { - assert.deepEqual(classifyChecks([], [], []), { + assert.deepEqual(classifyChecks([], []), { failingChecks: [], pendingChecks: [], missingChecks: [], diff --git a/scripts/lib/release-queue/join/readiness.ts b/scripts/lib/release-queue/join/readiness.ts index 2fe7580bb4037..4df75ac8388f9 100644 --- a/scripts/lib/release-queue/join/readiness.ts +++ b/scripts/lib/release-queue/join/readiness.ts @@ -73,6 +73,35 @@ export function classifyReviewers( 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. * @@ -80,33 +109,18 @@ const SUCCESSFUL_CONCLUSIONS = new Set(['success', 'neutral', ' * runs of a first-time contributor wait for approval. * * @param {string[]} required - The names of the required checks. - * @param {CheckRunResponse[]} checkRuns - The check runs of the commit. - * @param {CommitStatusResponse[]} statuses - The commit statuses. + * @param {Check[]} checks - The checks reported on the commit. * @returns {ClassifiedChecks} The required checks in each state. */ -export function classifyChecks( - required: string[], - checkRuns: CheckRunResponse[], - statuses: CommitStatusResponse[] -): ClassifiedChecks { - const failing = new Set(); - const pending = new Set(); - const reported = new Set(); - - for (const run of checkRuns) { - reported.add(run.name); - if (run.status !== 'completed') pending.add(run.name); - else if (!run.conclusion || !SUCCESSFUL_CONCLUSIONS.has(run.conclusion)) failing.add(run.name); - } - for (const status of statuses) { - reported.add(status.context); - if (status.state === 'pending') pending.add(status.context); - else if (status.state !== 'success') failing.add(status.context); - } - +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((check) => failing.has(check)), - pendingChecks: required.filter((check) => pending.has(check)), - missingChecks: required.filter((check) => !reported.has(check)), + failingChecks: required.filter((name) => failing.has(name)), + pendingChecks: required.filter((name) => pending.has(name)), + missingChecks: required.filter((name) => !reported.has(name)), }; } From da7898297f387852c46c8aebdebcdc3e7b1c8bdf Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 27 Oct 2026 00:19:48 +0100 Subject: [PATCH 29/35] Test that a pending commit status refuses a PR --- .../lib/release-queue/handle-accepted.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/lib/release-queue/handle-accepted.test.ts b/scripts/lib/release-queue/handle-accepted.test.ts index e129279e08308..fafe5de2d72c1 100644 --- a/scripts/lib/release-queue/handle-accepted.test.ts +++ b/scripts/lib/release-queue/handle-accepted.test.ts @@ -254,6 +254,26 @@ describe('handleAcceptedPullRequest', () => { 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(), From 2455d2b5c98267e4f67978c5a5d6645dcf0825ca Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 27 Oct 2026 00:22:10 +0100 Subject: [PATCH 30/35] Read whether a docs PR is mergeable from the PR only --- scripts/lib/release-queue/join/queries.ts | 6 +++--- scripts/lib/release-queue/join/readiness.ts | 2 -- scripts/lib/release-queue/join/routing.test.ts | 7 +++---- scripts/lib/release-queue/join/routing.ts | 4 ++-- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/scripts/lib/release-queue/join/queries.ts b/scripts/lib/release-queue/join/queries.ts index 7ea7854f4cc97..ce8210eb52cf9 100644 --- a/scripts/lib/release-queue/join/queries.ts +++ b/scripts/lib/release-queue/join/queries.ts @@ -27,6 +27,8 @@ export interface DocsPullRequest { base: string; head: string; labels: string[]; + /** `null` while GitHub hasn't computed it yet. */ + isMergeable: boolean | null; } export interface PullRequest extends DocsPullRequest { @@ -34,7 +36,6 @@ export interface PullRequest extends DocsPullRequest { title: string; url: string; headSha: string; - isMergeable: boolean | null; } /** @@ -81,7 +82,7 @@ function getPullRequestWithMergeability(repo: Repository, prNumber: number) { * * @param {Repository} repo - The docs repository. * @param {PullRequest} pr - The docs PR. - * @returns {Promise} Its reviews, checks, and mergeability. + * @returns {Promise} Its reviews and checks. */ export async function getReadiness(repo: Repository, pr: PullRequest): Promise { const [reviews, checkRuns, statuses, required] = await Promise.all([ @@ -105,7 +106,6 @@ export async function getReadiness(repo: Repository, pr: PullRequest): Promise; diff --git a/scripts/lib/release-queue/join/routing.test.ts b/scripts/lib/release-queue/join/routing.test.ts index 8d8c0f1352fc0..10bd93668d512 100644 --- a/scripts/lib/release-queue/join/routing.test.ts +++ b/scripts/lib/release-queue/join/routing.test.ts @@ -10,6 +10,7 @@ const pr = (overrides: Partial = {}): DocsPullRequest => ({ base: 'main', head: 'feat/docs', labels: ['accepted', 'patch-release'], + isMergeable: true, ...overrides, }); @@ -19,7 +20,6 @@ const ready: Readiness = { failingChecks: [], pendingChecks: [], missingChecks: [], - isMergeable: true, }; /** The context `decideJoin` takes, besides the PR and its readiness. */ @@ -141,14 +141,13 @@ describe('decideJoin', () => { it('refuses a PR that is not ready, with every reason', () => { const decision = decideJoin( - pr(), + pr({ isMergeable: false }), { isApproved: false, changesRequestedBy: ['alice'], failingChecks: ['links'], pendingChecks: ['check'], missingChecks: ['zizmor'], - isMergeable: false, }, patch ); @@ -192,7 +191,7 @@ describe('decideJoin', () => { }); it('lets the merge decide while mergeability is unknown', () => { - assert.equal(decideJoin(pr(), { ...ready, isMergeable: null }, patch).status, 'join'); + assert.equal(decideJoin(pr({ isMergeable: null }), ready, patch).status, 'join'); }); }); diff --git a/scripts/lib/release-queue/join/routing.ts b/scripts/lib/release-queue/join/routing.ts index 282e91b72d1bd..0e10fb66ef6e5 100644 --- a/scripts/lib/release-queue/join/routing.ts +++ b/scripts/lib/release-queue/join/routing.ts @@ -86,7 +86,7 @@ export function wouldRetargetFromStable(pr: DocsPullRequest): boolean { * 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, checks, and mergeability. + * @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 @@ -105,6 +105,7 @@ export function decideJoin( ...(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 }; @@ -159,6 +160,5 @@ function getReadinessRefusals(readiness: Readiness): Refusal[] { if (readiness.missingChecks.length > 0) { refusals.push({ code: 'missing-checks', checks: readiness.missingChecks }); } - if (readiness.isMergeable === false) refusals.push({ code: 'conflict' }); return refusals; } From d1a3ba9d8e4dac32a6ef08ff7ea38220254372e7 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 27 Oct 2026 00:24:44 +0100 Subject: [PATCH 31/35] Update the labels of a PR through the repository --- scripts/lib/release-queue/github/repository.ts | 15 +++++++++++---- scripts/lib/release-queue/umbrella/labels.ts | 7 +------ scripts/lib/release-queue/umbrella/refresh.ts | 14 ++------------ 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/scripts/lib/release-queue/github/repository.ts b/scripts/lib/release-queue/github/repository.ts index 210a1d71ea85e..4b27effdafabb 100644 --- a/scripts/lib/release-queue/github/repository.ts +++ b/scripts/lib/release-queue/github/repository.ts @@ -2,6 +2,12 @@ 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 repository, read and changed through a client: the requests of * `GitHubClient` with paths relative to the repository, and the operations @@ -125,13 +131,14 @@ export class Repository { } /** - * Adds labels to a PR. The labels it already has stay. + * Adds and removes labels on a PR. Its other labels stay. * * @param {number} prNumber - The number of the PR. - * @param {string[]} labels - The labels to add. + * @param {LabelChanges} changes - The labels to add and to remove. */ - async addLabels(prNumber: number, labels: string[]) { - await this.write('POST', `issues/${prNumber}/labels`, { labels }); + 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))); } /** diff --git a/scripts/lib/release-queue/umbrella/labels.ts b/scripts/lib/release-queue/umbrella/labels.ts index 390070f841ae7..78a7eaebe3ed2 100644 --- a/scripts/lib/release-queue/umbrella/labels.ts +++ b/scripts/lib/release-queue/umbrella/labels.ts @@ -4,12 +4,7 @@ import { MERGE_ON_RELEASE_LABEL, type ReleaseLabel, } from '../config.ts'; - -/** Labels to add to an umbrella PR, and release labels to remove from it. */ -export interface LabelChanges { - toAdd: string[]; - toRemove: string[]; -} +import type { LabelChanges } from '../github/repository.ts'; /** * Gets the label changes of an umbrella that follows its release. diff --git a/scripts/lib/release-queue/umbrella/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts index 554fc425c3881..f2733cf9c6f84 100644 --- a/scripts/lib/release-queue/umbrella/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -5,12 +5,7 @@ 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, - type LabelChanges, -} from './labels.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'; @@ -71,7 +66,7 @@ async function refreshTitleBodyAndLabels( const labelChanges = isFollowing ? getLabelChanges(umbrella.labels, getUmbrellaReleaseLabel(umbrella.base, releases.stable)) : getKeptLabelChanges(umbrella.labels); - await applyLabelChanges(docs, umbrella.number, labelChanges); + await docs.updateLabels(umbrella.number, labelChanges); return { ...umbrella, ...textChanges, labels: applyToLabels(umbrella.labels, labelChanges) }; } @@ -109,8 +104,3 @@ function getTextChanges( ...(wantedText.body === umbrella.body ? {} : { body: wantedText.body }), }; } - -async function applyLabelChanges(docs: Repository, umbrellaNumber: number, changes: LabelChanges) { - if (changes.toAdd.length > 0) await docs.addLabels(umbrellaNumber, changes.toAdd); - await Promise.all(changes.toRemove.map((label) => docs.removeLabel(umbrellaNumber, label))); -} From 92165a941d9188cf4dd6a1734cf8a05fae00fee7 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 27 Oct 2026 00:29:41 +0100 Subject: [PATCH 32/35] Refresh umbrellas through a refresher built once per run --- scripts/lib/release-queue/handle-accepted.ts | 5 +- .../lib/release-queue/refresh-umbrellas.ts | 14 +- .../release-queue/umbrella/refresh.test.ts | 8 +- scripts/lib/release-queue/umbrella/refresh.ts | 143 +++++++++--------- 4 files changed, 89 insertions(+), 81 deletions(-) diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts index 900d5ef58d552..38447f4c8a622 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -23,7 +23,7 @@ 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 { refreshUmbrella } from './umbrella/refresh.ts'; +import { UmbrellaRefresher } from './umbrella/refresh.ts'; import { getSettledStableUmbrella } from './umbrella/state.ts'; /** @@ -195,7 +195,8 @@ class AcceptedPullRequest { // A planned umbrella was only printed. if (umbrella.status === 'planned') return; const openUmbrella = await getUmbrella(bot, umbrella.number); - await refreshUmbrella(this.#run, openUmbrella, { releases: this.#releases, shouldBlock: true }); + 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. */ diff --git a/scripts/lib/release-queue/refresh-umbrellas.ts b/scripts/lib/release-queue/refresh-umbrellas.ts index c68aeee5873d6..2e4e113342005 100644 --- a/scripts/lib/release-queue/refresh-umbrellas.ts +++ b/scripts/lib/release-queue/refresh-umbrellas.ts @@ -1,8 +1,8 @@ import { canActOn } from './modes.ts'; -import { readFrameworkReleases, type FrameworkReleases } from './release/queries.ts'; +import { readFrameworkReleases } from './release/queries.ts'; import type { RunContext } from './run-context.ts'; import { listOpenUmbrellas, type UmbrellaPullRequest } from './umbrella/queries.ts'; -import { refreshUmbrella } from './umbrella/refresh.ts'; +import { UmbrellaRefresher } from './umbrella/refresh.ts'; /** * Refreshes every open umbrella PR the mode acts on, for example after a @@ -27,11 +27,12 @@ export async function refreshOpenUmbrellas(run: RunContext) { 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(run, umbrella, releases); + const failure = await tryRefreshUmbrella(refresher, umbrella); if (failure) failures.push(failure); } if (failures.length > 0) { @@ -47,13 +48,12 @@ export async function refreshOpenUmbrellas(run: RunContext) { * umbrellas still get refreshed. */ async function tryRefreshUmbrella( - run: RunContext, - umbrella: UmbrellaPullRequest, - releases: FrameworkReleases + refresher: UmbrellaRefresher, + umbrella: UmbrellaPullRequest ): Promise { try { // Without a join, a dismissed review still covers the included PRs. - await refreshUmbrella(run, umbrella, { releases, shouldBlock: false }); + 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/umbrella/refresh.test.ts b/scripts/lib/release-queue/umbrella/refresh.test.ts index 69271d162989a..e8bb0ae5779f7 100644 --- a/scripts/lib/release-queue/umbrella/refresh.test.ts +++ b/scripts/lib/release-queue/umbrella/refresh.test.ts @@ -7,7 +7,7 @@ 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 { refreshUmbrella } from './refresh.ts'; +import { UmbrellaRefresher } from './refresh.ts'; /** The next release from `main`, with release PR `number` open. */ const releaseWith = (number: number, frameworkVersion: string) => @@ -45,7 +45,7 @@ function refresh( ) { const run = stubRunContext(github, 'all'); const releases = new FrameworkReleases(run.framework, release); - return refreshUmbrella(run, umbrellaToRefresh, { releases, shouldBlock: false }); + return new UmbrellaRefresher(run, releases).refresh(umbrellaToRefresh, { shouldBlock: false }); } /** Answers whether release PR `number` of `withastro/astro` is merged. */ @@ -72,7 +72,7 @@ const labelWrites = (writes: Write[]) => writes.filter(({ method }) => method != const umbrellaBody = (releasePrNumber?: number) => formatUmbrellaBody('main', { releasePrNumber, included }); -describe('refreshUmbrella', () => { +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); @@ -249,7 +249,7 @@ describe('refreshUmbrella', () => { const run = stubRunContext(github, 'all'); const releases = new FrameworkReleases(run.framework, releaseWith(18405, '7.5.0')); - await refreshUmbrella(run, unlinked, { releases, shouldBlock: true }); + await new UmbrellaRefresher(run, releases).refresh(unlinked, { shouldBlock: true }); const [review] = github.writesBy(ACTIONS_TOKEN); assert.equal(review?.path, `repos/${REPO}/pulls/42/reviews`); diff --git a/scripts/lib/release-queue/umbrella/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts index f2733cf9c6f84..45eea5686433c 100644 --- a/scripts/lib/release-queue/umbrella/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -1,5 +1,4 @@ import { DOCS_STABLE_BRANCH, RELEASE_TYPES } from '../config.ts'; -import type { Repository } from '../github/repository.ts'; import type { FrameworkReleases } from '../release/queries.ts'; import type { StableRelease } from '../release/release.ts'; import type { RunContext } from '../run-context.ts'; @@ -11,63 +10,87 @@ import { listIncludedPullRequests, type UmbrellaPullRequest } from './queries.ts import { getReleaseState } from './state.ts'; /** - * Brings an umbrella PR up to date with its release and its included PRs, - * then its merge-block review. - * - * @param {RunContext} run - The repositories of the run. - * @param {UmbrellaPullRequest} umbrella - The umbrella PR to refresh. - * @param {object} options - What the umbrella follows, and when to block its - * merge. - * @param {FrameworkReleases} options.releases - The releases of the - * framework. - * @param {boolean} options.shouldBlock - Whether to submit a merge-block - * review when none is active. + * Brings umbrella PRs up to date in a run: their title, body and labels with + * the releases of the framework and their included PRs, then their + * merge-block review. */ -export async function refreshUmbrella( - { actions, bot }: RunContext, - umbrella: UmbrellaPullRequest, - { releases, shouldBlock }: { releases: FrameworkReleases; shouldBlock: boolean } -) { - const refreshed = await refreshTitleBodyAndLabels(bot, umbrella, releases); - await syncMergeBlockReview(actions, refreshed, { shouldBlock }); -} +export class UmbrellaRefresher { + #run: RunContext; + #releases: FrameworkReleases; -/** - * Writes what changed in the title, body and labels of an umbrella PR, and - * returns the umbrella as it is after. - */ -async function refreshTitleBodyAndLabels( - docs: Repository, - umbrella: UmbrellaPullRequest, - releases: FrameworkReleases -): Promise { - const releaseState = await getReleaseState(umbrella, releases); - const isFollowing = releaseState.status === 'follows'; - if (isFollowing && isBeforeMajor(umbrella, releases.stable)) { - console.info(`Skipped #${umbrella.number}: the next stable release is a major.`); - return umbrella; + /** + * 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; } - const linkedReleasePrNumber = isFollowing - ? await getFollowedReleasePrNumber(umbrella, releases) - : releaseState.releasePrNumber; - const wantedText = { - title: isFollowing ? getUmbrellaTitle(umbrella.base, releases.stable) : umbrella.title, - body: formatUmbrellaBody(umbrella.base, { - releasePrNumber: linkedReleasePrNumber, - included: await listIncludedPullRequests(docs, umbrella), - }), - }; - const textChanges = getTextChanges(umbrella, wantedText); - if (Object.keys(textChanges).length > 0) { - await docs.write('PATCH', `pulls/${umbrella.number}`, textChanges); + /** + * 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 docs = this.#run.bot; + const { stable } = this.#releases; + const releaseState = await getReleaseState(umbrella, this.#releases); + const isFollowing = releaseState.status === 'follows'; + if (isFollowing && isBeforeMajor(umbrella, stable)) { + console.info(`Skipped #${umbrella.number}: the next stable release is a major.`); + return umbrella; + } + + const linkedReleasePrNumber = isFollowing + ? await this.#getFollowedReleasePrNumber(umbrella) + : releaseState.releasePrNumber; + const wantedText = { + title: isFollowing ? getUmbrellaTitle(umbrella.base, stable) : umbrella.title, + body: formatUmbrellaBody(umbrella.base, { + releasePrNumber: linkedReleasePrNumber, + included: await listIncludedPullRequests(docs, umbrella), + }), + }; + const textChanges = getTextChanges(umbrella, wantedText); + if (Object.keys(textChanges).length > 0) { + await docs.write('PATCH', `pulls/${umbrella.number}`, textChanges); + } + + const labelChanges = isFollowing + ? getLabelChanges(umbrella.labels, getUmbrellaReleaseLabel(umbrella.base, stable)) + : getKeptLabelChanges(umbrella.labels); + await docs.updateLabels(umbrella.number, labelChanges); + return { ...umbrella, ...textChanges, labels: applyToLabels(umbrella.labels, labelChanges) }; } - const labelChanges = isFollowing - ? getLabelChanges(umbrella.labels, getUmbrellaReleaseLabel(umbrella.base, releases.stable)) - : getKeptLabelChanges(umbrella.labels); - await docs.updateLabels(umbrella.number, labelChanges); - return { ...umbrella, ...textChanges, labels: applyToLabels(umbrella.labels, labelChanges) }; + /** + * Gets the release PR an umbrella that follows its release links: the open + * release PR of the stable branch, or of the prerelease of a prerelease + * umbrella. + */ + async #getFollowedReleasePrNumber(umbrella: UmbrellaPullRequest): Promise { + const releasePullRequest = + umbrella.base === DOCS_STABLE_BRANCH + ? this.#releases.stable.pullRequest + : await this.#releases.getPrereleasePullRequest(); + return releasePullRequest?.number; + } } /** @@ -78,22 +101,6 @@ function isBeforeMajor(umbrella: UmbrellaPullRequest, release: StableRelease): b return umbrella.base === DOCS_STABLE_BRANCH && release.type === RELEASE_TYPES.major; } -/** - * Gets the release PR an umbrella that follows its release links: the open - * release PR of the stable branch, or of the prerelease of a prerelease - * umbrella. - */ -async function getFollowedReleasePrNumber( - umbrella: UmbrellaPullRequest, - releases: FrameworkReleases -): Promise { - const releasePullRequest = - umbrella.base === DOCS_STABLE_BRANCH - ? releases.stable.pullRequest - : await releases.getPrereleasePullRequest(); - return releasePullRequest?.number; -} - /** Gets the title and body of `wantedText` that differ from the umbrella's. */ function getTextChanges( umbrella: UmbrellaPullRequest, From 88dc80b84c796cded4f0b5d4b2cd56c11ba79e98 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 27 Oct 2026 00:32:57 +0100 Subject: [PATCH 33/35] Read the PR and its label sender together from the flags or the event --- scripts/lib/release-queue/cli.test.ts | 29 ++++++++++++--- scripts/lib/release-queue/cli.ts | 28 +++++++++++---- .../lib/release-queue/handle-accepted.test.ts | 35 +++++++++++++++---- scripts/lib/release-queue/handle-accepted.ts | 14 +++++--- scripts/lib/release-queue/main.ts | 18 +++------- 5 files changed, 88 insertions(+), 36 deletions(-) diff --git a/scripts/lib/release-queue/cli.test.ts b/scripts/lib/release-queue/cli.test.ts index 006103c4fe0c6..aba1328efbc95 100644 --- a/scripts/lib/release-queue/cli.test.ts +++ b/scripts/lib/release-queue/cli.test.ts @@ -4,14 +4,35 @@ import { chooseAction, parseEvent } from './cli.ts'; describe('chooseAction', () => { it('handles the PR of the event', () => { - assert.deepEqual(chooseAction({ eventName: 'pull_request_target', eventPullRequest: 7 }), { + 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: 7, + prNumber: 14580, + labelSender: 'maintainer', }); }); - it('handles the PR passed with --pr', () => { - assert.deepEqual(chooseAction({ pr: '14580' }), { type: 'handle', prNumber: 14580 }); + 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', () => { diff --git a/scripts/lib/release-queue/cli.ts b/scripts/lib/release-queue/cli.ts index 43025236e1121..4b51a80be8adb 100644 --- a/scripts/lib/release-queue/cli.ts +++ b/scripts/lib/release-queue/cli.ts @@ -1,37 +1,48 @@ 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'; prNumber: number } | { type: 'refresh' }; +type Action = ({ type: 'handle' } & LabeledPullRequest) | { type: 'refresh' }; /** * Chooses what a run does, from an explicit signal. * * A `workflow_dispatch` event, or `isRefreshRequested` in a local run, * refreshes the open umbrellas. Otherwise, the run handles `pr`, or the PR of - * the event. + * the event, labeled by `sender`, or by the sender of 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. - * @returns {Action} The refresh, or the PR to handle. - * @throws {Error} When both a PR and a refresh are asked for, or when the PR - * number is missing or invalid. + * @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) @@ -44,7 +55,12 @@ export function chooseAction({ if (!PR_NUMBER.test(rawPrNumber)) { throw new Error(`Invalid PR number: ${rawPrNumber}. Pass --pr .`); } - return { type: 'handle', prNumber: Number(rawPrNumber) }; + + 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. */ diff --git a/scripts/lib/release-queue/handle-accepted.test.ts b/scripts/lib/release-queue/handle-accepted.test.ts index fafe5de2d72c1..751e3a3bd7e81 100644 --- a/scripts/lib/release-queue/handle-accepted.test.ts +++ b/scripts/lib/release-queue/handle-accepted.test.ts @@ -81,7 +81,10 @@ const stubReadyPullRequest = (t: TestContext, options: StubOptions = {}) => /** 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), 7, sender); + await handleAcceptedPullRequest(stubRunContext(github, mode), { + prNumber: 7, + labelSender: sender, + }); return { actionsWrites: github.writesBy(ACTIONS_TOKEN), botWrites: github.writesBy(BOT_TOKEN) }; } @@ -210,7 +213,10 @@ describe('handleAcceptedPullRequest', () => { 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'), 7, 'maintainer'); + await handleAcceptedPullRequest(stubRunContext(github, 'stable-only'), { + prNumber: 7, + labelSender: 'maintainer', + }); assert.ok(!github.calls.some(({ path }) => path.includes('.changeset/pre.json'))); }); @@ -233,7 +239,10 @@ describe('handleAcceptedPullRequest', () => { 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), 7, 'maintainer'); + 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'))); @@ -344,7 +353,10 @@ describe('handleAcceptedPullRequest', () => { getResponses: { ...settledUmbrella(), [paths.pr]: pr }, }); - await handleAcceptedPullRequest(stubRunContext(github, 'dry-run'), 7, 'maintainer'); + await handleAcceptedPullRequest(stubRunContext(github, 'dry-run'), { + prNumber: 7, + labelSender: 'maintainer', + }); assert.equal(github.countReads('repos/withastro/astro/pulls/18405'), 0); }); @@ -358,7 +370,10 @@ describe('handleAcceptedPullRequest', () => { }, }); - await handleAcceptedPullRequest(stubRunContext(github, 'all'), 7, 'maintainer'); + 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); @@ -387,7 +402,10 @@ describe('handleAcceptedPullRequest', () => { }); await assert.rejects( - handleAcceptedPullRequest(stubRunContext(github, 'stable-only'), 7, 'maintainer'), + 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\.$/); @@ -408,7 +426,10 @@ describe('handleAcceptedPullRequest', () => { const github = stubReadyPullRequest(t, { getResponses: responses }); await assert.rejects( - handleAcceptedPullRequest(stubRunContext(github, 'stable-only'), 7, 'maintainer'), + handleAcceptedPullRequest(stubRunContext(github, 'stable-only'), { + prNumber: 7, + labelSender: 'maintainer', + }), { name: 'GitHubError', status: 404 } ); assert.match(commentBody(github.writesBy(BOT_TOKEN)), /squash-merged into/); diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts index 38447f4c8a622..7acce14de6df7 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -36,19 +36,23 @@ interface DecidedPullRequest { decision: Decision; } +/** A docs PR labeled as accepted, by its number, 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 {number} prNumber - The number of the docs PR. - * @param {string} [labelSender] - Who added the label. Without it, the write - * access check is skipped, as in a local run. + * @param {LabeledPullRequest} labeled - The docs PR, and who added the label. */ export async function handleAcceptedPullRequest( run: RunContext, - prNumber: number, - labelSender?: string + { prNumber, labelSender }: LabeledPullRequest ) { if (!(await isSenderAllowed(run.actions, labelSender))) return; diff --git a/scripts/lib/release-queue/main.ts b/scripts/lib/release-queue/main.ts index 02708cd21115e..c706261785d5e 100644 --- a/scripts/lib/release-queue/main.ts +++ b/scripts/lib/release-queue/main.ts @@ -36,31 +36,21 @@ export async function runReleaseQueue({ flags, env }: { flags: Flags; env: 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 { - const labelSender = getLabelSender(flags, env, event); - await handleAcceptedPullRequest(run, action.prNumber, labelSender); + await handleAcceptedPullRequest(run, action); } } async function readEvent(path: string | undefined): Promise { return path ? parseEvent(await readFile(path, 'utf-8'), path) : {}; } - -/** - * Gets who added the label. Only a local run can go without one, which skips - * the write access check. - */ -function getLabelSender(flags: Flags, env: Env, event: RunEvent): string | undefined { - const labelSender = flags.sender ?? event.labelSender; - if (labelSender === undefined && isGitHubActions(env)) { - throw new Error('The event has no sender: the write access check needs one.'); - } - return labelSender; -} From d9f210d49f5b62be8ea61af7c489dcf0ce3953eb Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 27 Oct 2026 00:40:34 +0100 Subject: [PATCH 34/35] Split the umbrella refresh into what it shows and what it writes --- scripts/lib/release-queue/umbrella/refresh.ts | 87 ++++++++++++------- 1 file changed, 58 insertions(+), 29 deletions(-) diff --git a/scripts/lib/release-queue/umbrella/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts index 45eea5686433c..6a8a12df03c7a 100644 --- a/scripts/lib/release-queue/umbrella/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -1,4 +1,4 @@ -import { DOCS_STABLE_BRANCH, RELEASE_TYPES } from '../config.ts'; +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'; @@ -9,6 +9,16 @@ 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 umbrella PRs up to date in a run: their title, body and labels with * the releases of the framework and their included PRs, then their @@ -48,48 +58,67 @@ export class UmbrellaRefresher { * returns the umbrella as it is after. */ async #refreshTitleBodyAndLabels(umbrella: UmbrellaPullRequest): Promise { - const docs = this.#run.bot; - const { stable } = this.#releases; const releaseState = await getReleaseState(umbrella, this.#releases); const isFollowing = releaseState.status === 'follows'; - if (isFollowing && isBeforeMajor(umbrella, stable)) { + if (isFollowing && isBeforeMajor(umbrella, this.#releases.stable)) { console.info(`Skipped #${umbrella.number}: the next stable release is a major.`); return umbrella; } - const linkedReleasePrNumber = isFollowing - ? await this.#getFollowedReleasePrNumber(umbrella) - : releaseState.releasePrNumber; - const wantedText = { - title: isFollowing ? getUmbrellaTitle(umbrella.base, stable) : umbrella.title, - body: formatUmbrellaBody(umbrella.base, { - releasePrNumber: linkedReleasePrNumber, - included: await listIncludedPullRequests(docs, 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, }; - const textChanges = getTextChanges(umbrella, wantedText); + } + + /** + * Writes the title and the body of an umbrella PR when they differ from what + * it shows, 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); } - - const labelChanges = isFollowing - ? getLabelChanges(umbrella.labels, getUmbrellaReleaseLabel(umbrella.base, stable)) - : getKeptLabelChanges(umbrella.labels); - await docs.updateLabels(umbrella.number, labelChanges); - return { ...umbrella, ...textChanges, labels: applyToLabels(umbrella.labels, labelChanges) }; + return text; } /** - * Gets the release PR an umbrella that follows its release links: the open - * release PR of the stable branch, or of the prerelease of a prerelease - * umbrella. + * Gives an umbrella PR its release label, or keeps the one it has without + * `releaseLabel`, and returns its labels after. */ - async #getFollowedReleasePrNumber(umbrella: UmbrellaPullRequest): Promise { - const releasePullRequest = - umbrella.base === DOCS_STABLE_BRANCH - ? this.#releases.stable.pullRequest - : await this.#releases.getPrereleasePullRequest(); - return releasePullRequest?.number; + 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); } } From a5d4954197d992c252041f02f6537838c122bb24 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 27 Oct 2026 00:59:08 +0100 Subject: [PATCH 35/35] Shorten the doc comments of the release queue --- scripts/lib/release-queue/cli.ts | 8 +++----- scripts/lib/release-queue/formats/json.ts | 7 ++++--- scripts/lib/release-queue/formats/markdown.ts | 4 ++-- scripts/lib/release-queue/github/client.ts | 9 ++++----- scripts/lib/release-queue/github/error.ts | 4 ++-- .../lib/release-queue/github/repository.ts | 12 ++++------- scripts/lib/release-queue/handle-accepted.ts | 17 ++++------------ scripts/lib/release-queue/join/messages.ts | 2 +- scripts/lib/release-queue/join/queries.ts | 7 ++----- scripts/lib/release-queue/join/routing.ts | 2 +- .../lib/release-queue/join/umbrella-join.ts | 20 ++++++------------- scripts/lib/release-queue/release/queries.ts | 9 ++++----- scripts/lib/release-queue/release/release.ts | 4 ++-- scripts/lib/release-queue/umbrella/body.ts | 4 ++-- scripts/lib/release-queue/umbrella/labels.ts | 3 +-- .../lib/release-queue/umbrella/merge-block.ts | 13 +++--------- scripts/lib/release-queue/umbrella/refresh.ts | 14 +++++-------- 17 files changed, 50 insertions(+), 89 deletions(-) diff --git a/scripts/lib/release-queue/cli.ts b/scripts/lib/release-queue/cli.ts index 4b51a80be8adb..0a10e522dfb2f 100644 --- a/scripts/lib/release-queue/cli.ts +++ b/scripts/lib/release-queue/cli.ts @@ -7,11 +7,9 @@ const PR_NUMBER = /^[1-9]\d*$/; type Action = ({ type: 'handle' } & LabeledPullRequest) | { type: 'refresh' }; /** - * Chooses what a run does, from an explicit signal. - * - * A `workflow_dispatch` event, or `isRefreshRequested` in a local run, - * refreshes the open umbrellas. Otherwise, the run handles `pr`, or the PR of - * the event, labeled by `sender`, or by the sender of the event. + * 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`. diff --git a/scripts/lib/release-queue/formats/json.ts b/scripts/lib/release-queue/formats/json.ts index 1f90e1c4e427d..88d6e14f60a75 100644 --- a/scripts/lib/release-queue/formats/json.ts +++ b/scripts/lib/release-queue/formats/json.ts @@ -1,8 +1,9 @@ /** - * Whether a parsed JSON value is an object, and not an array. + * Whether a value is an object whose fields can be read, and not `null` or an + * array. * - * @param {unknown} value - The parsed value. - * @returns {boolean} `true` for an object whose fields can be read. + * @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); diff --git a/scripts/lib/release-queue/formats/markdown.ts b/scripts/lib/release-queue/formats/markdown.ts index 72ee46cbaa5c4..ee5363978cda1 100644 --- a/scripts/lib/release-queue/formats/markdown.ts +++ b/scripts/lib/release-queue/formats/markdown.ts @@ -1,8 +1,8 @@ // The Markdown text of PR descriptions, comments and reviews. /** - * Replaces the `\r\n` line endings GitHub keeps from a description or review - * edited in the browser with `\n`. + * 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. diff --git a/scripts/lib/release-queue/github/client.ts b/scripts/lib/release-queue/github/client.ts index 596675cb7a32d..50502f09c8767 100644 --- a/scripts/lib/release-queue/github/client.ts +++ b/scripts/lib/release-queue/github/client.ts @@ -13,9 +13,8 @@ export type WriteMethod = 'POST' | 'PATCH' | 'PUT' | 'DELETE'; /** * A minimal GitHub REST client. * - * Callers declare the shape of the answers they read, which isn't checked: - * the API version is pinned, and the queries map each answer to the types the - * bot works with. + * 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; @@ -150,8 +149,8 @@ export class PrintingGitHubClient extends GitHubClient { } /** - * Gets the reason of an error body, with its details: a 422 only says - * "Validation Failed" in `message`, and details it in `errors`. + * 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); diff --git a/scripts/lib/release-queue/github/error.ts b/scripts/lib/release-queue/github/error.ts index 19455298d86d0..0635e16bb3472 100644 --- a/scripts/lib/release-queue/github/error.ts +++ b/scripts/lib/release-queue/github/error.ts @@ -29,10 +29,10 @@ export class GitHubError extends Error { } /** - * Whether an error is GitHub's answer that the resource doesn't exist. + * Whether an error is a 404 answer from the GitHub API. * * @param {unknown} error - A caught error. - * @returns {boolean} `true` for a 404 answer from the GitHub API. + * @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 index 4b27effdafabb..5e0323ffef8d7 100644 --- a/scripts/lib/release-queue/github/repository.ts +++ b/scripts/lib/release-queue/github/repository.ts @@ -8,11 +8,7 @@ export interface LabelChanges { toRemove: string[]; } -/** - * A GitHub repository, read and changed through a client: the requests of - * `GitHubClient` with paths relative to the repository, and the operations - * the bot runs on any repository. - */ +/** A GitHub client bound to a repository, with paths relative to it. */ export class Repository { readonly name: string; readonly owner: string; @@ -38,8 +34,8 @@ export class Repository { * Reads an endpoint whose resource may not exist. * * @param {string} path - The endpoint, relative to the repository. - * @returns {Promise} The JSON answer, or `undefined` when - * GitHub answers that the resource doesn't exist. + * @returns {Promise} The JSON answer, or `undefined` for a + * 404. */ async find(path: string): Promise { try { @@ -156,7 +152,7 @@ export class Repository { } /** - * Whether a user can write to the repository, from their permission on it. + * 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 diff --git a/scripts/lib/release-queue/handle-accepted.ts b/scripts/lib/release-queue/handle-accepted.ts index 7acce14de6df7..7d89075b2a79e 100644 --- a/scripts/lib/release-queue/handle-accepted.ts +++ b/scripts/lib/release-queue/handle-accepted.ts @@ -26,17 +26,14 @@ 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 release it ships with and the - * decision on it. - */ +/** 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, by its number, and who added the label. */ +/** 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. */ @@ -82,10 +79,7 @@ async function isSenderAllowed( return false; } -/** - * Reads a docs PR with what decides its join: its readiness, the next stable - * release, and the settled stable umbrella. - */ +/** Reads a docs PR, and decides its join. */ async function readAcceptedPullRequest( { actions, framework }: RunContext, prNumber: number @@ -102,10 +96,7 @@ async function readAcceptedPullRequest( return { pr, releases, decision }; } -/** - * A docs PR labeled as accepted, and what the bot does with it in a run: a - * dry-run comment, a refusal, or a join. - */ +/** What the bot does with a docs PR labeled as accepted. */ class AcceptedPullRequest { #run: RunContext; #pr: PullRequest; diff --git a/scripts/lib/release-queue/join/messages.ts b/scripts/lib/release-queue/join/messages.ts index 62cdead1f0e3a..8bdb4e5fd92e3 100644 --- a/scripts/lib/release-queue/join/messages.ts +++ b/scripts/lib/release-queue/join/messages.ts @@ -173,7 +173,7 @@ export function formatRefusalComment( } /** - * Writes the comment of a join: the umbrella the PR is now part of. + * 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. diff --git a/scripts/lib/release-queue/join/queries.ts b/scripts/lib/release-queue/join/queries.ts index ce8210eb52cf9..0779bc6eca476 100644 --- a/scripts/lib/release-queue/join/queries.ts +++ b/scripts/lib/release-queue/join/queries.ts @@ -74,11 +74,8 @@ function getPullRequestWithMergeability(repo: Repository, prNumber: number) { } /** - * Gets what decides whether a docs PR is ready to merge, with the rules of - * the ruleset of the stable branch. - * - * Only reviewers with write access count, and only the checks the ruleset - * requires. + * 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. diff --git a/scripts/lib/release-queue/join/routing.ts b/scripts/lib/release-queue/join/routing.ts index 0e10fb66ef6e5..794c4929af069 100644 --- a/scripts/lib/release-queue/join/routing.ts +++ b/scripts/lib/release-queue/join/routing.ts @@ -19,7 +19,7 @@ export interface Route { shouldRetarget: boolean; } -/** What `decideJoin` needs besides the PR. */ +/** What `decideJoin` needs besides the PR and its readiness. */ interface DecisionContext { release: StableRelease; settledStableUmbrella?: SettledUmbrella | undefined; diff --git a/scripts/lib/release-queue/join/umbrella-join.ts b/scripts/lib/release-queue/join/umbrella-join.ts index e4e465c3722d0..f34f1bd9e6420 100644 --- a/scripts/lib/release-queue/join/umbrella-join.ts +++ b/scripts/lib/release-queue/join/umbrella-join.ts @@ -19,10 +19,7 @@ interface JoinRequest { */ export type Umbrella = { status: 'open'; number: number } | { status: 'planned'; title: string }; -/** - * The outcome of a join. `unopened` means the PR was merged into the umbrella - * branch, but the umbrella PR couldn't be opened. - */ +/** The outcome of a join. */ type JoinResult = | { status: 'joined'; umbrella: Umbrella } | { status: 'refused'; refusal: JoinRefusal } @@ -33,7 +30,7 @@ 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 + * 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' }; @@ -63,12 +60,9 @@ export class UmbrellaJoin { } /** - * Squash-merges the docs PR into its umbrella branch. - * - * Prepares the branch before changing the PR, then changes the PR base, - * merges, and opens the umbrella PR if none is open: GitHub refuses a PR - * without commits. A refused merge restores the PR base. The refresh sets - * the labels and the body of the umbrella PR. + * 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. @@ -151,9 +145,7 @@ export class UmbrellaJoin { return refusal; } - /** - * Changes the PR base back. - */ + /** Changes the PR base back. */ async #restoreBase(cause: unknown) { try { await this.#setBase(this.#pr.base); diff --git a/scripts/lib/release-queue/release/queries.ts b/scripts/lib/release-queue/release/queries.ts index 06ad105fb4c10..64a4a427baa0f 100644 --- a/scripts/lib/release-queue/release/queries.ts +++ b/scripts/lib/release-queue/release/queries.ts @@ -18,8 +18,7 @@ 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, with - * the next stable release read. + * @returns {Promise} The releases of the framework. */ export async function readFrameworkReleases(framework: Repository): Promise { const stablePullRequest = await getReleasePullRequest(framework, FRAMEWORK_STABLE_BRANCH); @@ -50,9 +49,9 @@ export class FrameworkReleases { * 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. Then the - * release PR of the stable branch, which the prerelease branch was merged - * into. + * 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)) diff --git a/scripts/lib/release-queue/release/release.ts b/scripts/lib/release-queue/release/release.ts index bc958895bfc80..2239c3b0d1f5e 100644 --- a/scripts/lib/release-queue/release/release.ts +++ b/scripts/lib/release-queue/release/release.ts @@ -49,8 +49,8 @@ function findVersion(versions: PackageVersion[], name: string): string | undefin /** * Gets the type of a stable release from its version. Prereleases and missing - * versions count as patches: only a new stable minor or major changes how docs - * PRs are routed. + * 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); diff --git a/scripts/lib/release-queue/umbrella/body.ts b/scripts/lib/release-queue/umbrella/body.ts index cc452174ba2ce..7181e3d0288b3 100644 --- a/scripts/lib/release-queue/umbrella/body.ts +++ b/scripts/lib/release-queue/umbrella/body.ts @@ -28,8 +28,8 @@ export function parseReleaseMarker(body: string): number | 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, as the - * refresh needs it. + * 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. diff --git a/scripts/lib/release-queue/umbrella/labels.ts b/scripts/lib/release-queue/umbrella/labels.ts index 78a7eaebe3ed2..0b4dd57fca1e6 100644 --- a/scripts/lib/release-queue/umbrella/labels.ts +++ b/scripts/lib/release-queue/umbrella/labels.ts @@ -14,8 +14,7 @@ import type { LabelChanges } from '../github/repository.ts'; * * @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 the other release labels to - * remove. + * @returns {LabelChanges} The labels to add and to remove. */ export function getLabelChanges(current: string[], releaseLabel: ReleaseLabel): LabelChanges { const wanted = [MERGE_ON_RELEASE_LABEL, releaseLabel]; diff --git a/scripts/lib/release-queue/umbrella/merge-block.ts b/scripts/lib/release-queue/umbrella/merge-block.ts index 90623ad5c8b5e..218a524995def 100644 --- a/scripts/lib/release-queue/umbrella/merge-block.ts +++ b/scripts/lib/release-queue/umbrella/merge-block.ts @@ -19,11 +19,7 @@ interface SubmittedReview extends ReviewResponse { body: string; } -/** - * Writes the body of the merge-block review: the checklist a maintainer goes - * through before merging an umbrella PR with `openPullRequests` still on its - * branch, and the dismissal that lifts the block. - */ +/** Writes the checklist to go through before merging the umbrella PR. */ function formatMergeBlockBody(umbrella: UmbrellaPullRequest, openPullRequests: number[]): string { const items = [ describeReleaseItem(umbrella), @@ -90,8 +86,8 @@ async function listOpenPullRequestsTargeting(repo: Repository, branch: string): } /** - * Finds the bot's review that still requests changes: a dismissal changes its - * state. + * Finds the bot's review that still requests changes, as a dismissal changes + * its state. */ function findActiveReview(reviews: SubmittedReview[]): SubmittedReview | undefined { return reviews.findLast( @@ -102,9 +98,6 @@ function findActiveReview(reviews: SubmittedReview[]): SubmittedReview | undefin /** * Keeps the merge-block review of an umbrella PR up to date. * - * Updates the active review when there is one. Otherwise, submits a new one - * when `shouldBlock` is set. - * * @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. diff --git a/scripts/lib/release-queue/umbrella/refresh.ts b/scripts/lib/release-queue/umbrella/refresh.ts index 6a8a12df03c7a..052450f708b28 100644 --- a/scripts/lib/release-queue/umbrella/refresh.ts +++ b/scripts/lib/release-queue/umbrella/refresh.ts @@ -19,11 +19,7 @@ interface ShownRelease { releasePrNumber?: number | undefined; } -/** - * Brings umbrella PRs up to date in a run: their title, body and labels with - * the releases of the framework and their included PRs, then their - * merge-block review. - */ +/** Brings the umbrella PRs of a run up to date. */ export class UmbrellaRefresher { #run: RunContext; #releases: FrameworkReleases; @@ -89,8 +85,8 @@ export class UmbrellaRefresher { } /** - * Writes the title and the body of an umbrella PR when they differ from what - * it shows, and returns them. + * Writes the title and the body of an umbrella PR when they changed, and + * returns them. */ async #writeText( umbrella: UmbrellaPullRequest, @@ -107,8 +103,8 @@ export class UmbrellaRefresher { } /** - * Gives an umbrella PR its release label, or keeps the one it has without - * `releaseLabel`, and returns its labels after. + * Gives an umbrella PR its release label, or keeps its own without one, and + * returns its labels after. */ async #writeLabels( umbrella: UmbrellaPullRequest,