From 9093dd507379aa4ed062e01b731af5fc22bb8758 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Wed, 4 Feb 2026 11:50:20 +0100 Subject: [PATCH] chore: create PR description from changeset --- .github/pull_request_template.md | 4 +- .github/workflows/copy-changesets-to-pr.yml | 54 ++++ pnpm-lock.yaml | 17 +- .../__tests__/copy-changesets-to-pr.test.ts | 246 ++++++++++++++++++ .../copy-changesets-to-pr.ts | 215 +++++++++++++++ tools/package.json | 2 + 6 files changed, 528 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/copy-changesets-to-pr.yml create mode 100644 tools/github-workflow-helpers/__tests__/copy-changesets-to-pr.test.ts create mode 100644 tools/github-workflow-helpers/copy-changesets-to-pr.ts diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c4d9e9d10e..8ef937f9d8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,7 +4,7 @@ _Describe your change..._ --- - @@ -22,4 +22,4 @@ The following selections do not need to be completed if this PR only contains ch +END_CHECKLIST --> diff --git a/.github/workflows/copy-changesets-to-pr.yml b/.github/workflows/copy-changesets-to-pr.yml new file mode 100644 index 0000000000..061cfd4a38 --- /dev/null +++ b/.github/workflows/copy-changesets-to-pr.yml @@ -0,0 +1,54 @@ +name: Copy Changesets to PR + +on: + pull_request: + types: [opened] + paths: + - ".changeset/*.md" + +jobs: + copy-changesets: + # Don't run on the Version Packages PR + if: github.head_ref != 'changeset-release/main' + runs-on: ubuntu-slim + + permissions: + pull-requests: write + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Get added changeset files + id: changesets + uses: dorny/paths-filter@v3 + with: + list-files: json + filters: | + added: + - added: '.changeset/*.md' + - '!.changeset/README.md' + + - name: Setup Node.js + if: steps.changesets.outputs.added == 'true' + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + if: steps.changesets.outputs.added == 'true' + uses: pnpm/action-setup@v4 + with: + version: 9.12.0 + + - name: Install Dependencies + if: steps.changesets.outputs.added == 'true' + run: pnpm i -F tools --frozen-lockfile + + - name: Copy changesets to PR description + if: steps.changesets.outputs.added == 'true' + run: node -r esbuild-register tools/github-workflow-helpers/copy-changesets-to-pr.ts + env: + INPUT_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ADDED_CHANGESETS: ${{ steps.changesets.outputs.added_files }} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 455a852334..619c8b464f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4372,6 +4372,12 @@ importers: '@actions/github': specifier: ^6.0.0 version: 6.0.1 + '@changesets/parse': + specifier: ^0.4.1 + version: 0.4.1 + '@changesets/types': + specifier: ^6.1.0 + version: 6.1.0 '@cloudflare/eslint-config-shared': specifier: workspace:* version: link:../packages/eslint-config-shared @@ -4882,9 +4888,6 @@ packages: '@changesets/types@4.1.0': resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} - '@changesets/types@6.0.0': - resolution: {integrity: sha512-b1UkfNulgKoWfqyHtzKS5fOZYSJO+77adgL7DLRDr+/7jhChN+QcHnbjiQVOz/U+Ts3PGNySq7diAItzDgugfQ==} - '@changesets/types@6.1.0': resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} @@ -10414,12 +10417,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -15325,7 +15328,7 @@ snapshots: '@changesets/changelog-github@0.5.0(encoding@0.1.13)': dependencies: '@changesets/get-github-info': 0.6.0(encoding@0.1.13) - '@changesets/types': 6.0.0 + '@changesets/types': 6.1.0 dotenv: 8.6.0 transitivePeerDependencies: - encoding @@ -15443,8 +15446,6 @@ snapshots: '@changesets/types@4.1.0': {} - '@changesets/types@6.0.0': {} - '@changesets/types@6.1.0': {} '@changesets/write@0.4.0': diff --git a/tools/github-workflow-helpers/__tests__/copy-changesets-to-pr.test.ts b/tools/github-workflow-helpers/__tests__/copy-changesets-to-pr.test.ts new file mode 100644 index 0000000000..01d5cee44b --- /dev/null +++ b/tools/github-workflow-helpers/__tests__/copy-changesets-to-pr.test.ts @@ -0,0 +1,246 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + formatChangesets, + insertChangesets, + isDescriptionEmpty, +} from "../copy-changesets-to-pr"; +import type { Changeset } from "@changesets/types"; + +// Read the actual PR template to keep tests in sync +const FULL_TEMPLATE = fs.readFileSync( + path.join(__dirname, "../../../.github/pull_request_template.md"), + "utf-8" +); + +describe("isDescriptionEmpty()", () => { + it("should return true for empty body", () => { + expect(isDescriptionEmpty("")).toBe(true); + }); + + it("should return true for body with only whitespace", () => { + expect(isDescriptionEmpty(" \n\n ")).toBe(true); + }); + + it("should return true for template with only Fixes line", () => { + expect(isDescriptionEmpty("Fixes #123")).toBe(true); + }); + + it("should return true for full template with checklist", () => { + expect(isDescriptionEmpty(FULL_TEMPLATE)).toBe(true); + }); + + it("should return true for template with placeholder and separator", () => { + const body = `Fixes #123 + +_Describe your change..._ + +--- +`; + expect(isDescriptionEmpty(body)).toBe(true); + }); + + it("should return false when description has custom content (2+ lines)", () => { + const body = `Fixes #123 + +This is my custom description. +It has multiple lines. + +--- +`; + expect(isDescriptionEmpty(body)).toBe(false); + }); + + it("should return false when user added description to template", () => { + const body = `Fixes #456 + +This PR adds a new feature that does something useful. +It also fixes a bug. + +--- + + + +- Tests + - [x] Tests included/updated + +END_CHECKLIST --> +`; + expect(isDescriptionEmpty(body)).toBe(false); + }); + + it("should handle body without checklist markers", () => { + const body = `Fixes #123 + +_Describe your change..._ + +--- +`; + expect(isDescriptionEmpty(body)).toBe(true); + }); + + it("should handle body without checklist markers but with content", () => { + const body = `Fixes #123 + +This is a real description. +With multiple lines. +`; + expect(isDescriptionEmpty(body)).toBe(false); + }); +}); + +describe("insertChangesets()", () => { + const changesetContent = `**Packages:** \`wrangler\` (patch) + +Fix a bug in the dev command`; + + it("should replace placeholder when present", () => { + const body = `Fixes #123 + +_Describe your change..._ + +--- +`; + const result = insertChangesets(body, changesetContent); + expect(result).toBe(`Fixes #123 + +**Packages:** \`wrangler\` (patch) + +Fix a bug in the dev command + +--- +`); + expect(result).not.toContain("_Describe your change..._"); + }); + + it("should prepend when placeholder is not present", () => { + const body = `Fixes #123 + +--- +`; + const result = insertChangesets(body, changesetContent); + expect(result).toBe(`**Packages:** \`wrangler\` (patch) + +Fix a bug in the dev command + +Fixes #123 + +--- +`); + }); + + it("should prepend to empty body", () => { + const result = insertChangesets("", changesetContent); + expect(result).toBe(`**Packages:** \`wrangler\` (patch) + +Fix a bug in the dev command + +`); + }); + + it("should replace placeholder in full template", () => { + const result = insertChangesets(FULL_TEMPLATE, changesetContent); + expect(result).toContain("**Packages:** `wrangler` (patch)"); + expect(result).toContain("Fix a bug in the dev command"); + expect(result).not.toContain("_Describe your change..._"); + // Should preserve the checklist + expect(result).toContain(""); + }); +}); + +describe("formatChangesets()", () => { + it("should format a single changeset", () => { + const changesets: Changeset[] = [ + { + summary: "Fix a bug in the dev command", + releases: [{ name: "wrangler", type: "patch" }], + }, + ]; + const result = formatChangesets(changesets); + expect(result).toMatchInlineSnapshot(` + "#### Changeset 1 + **Packages:** \`wrangler\` (patch) + + Fix a bug in the dev command" + `); + }); + + it("should format multiple changesets with numbered headers", () => { + const changesets: Changeset[] = [ + { + summary: "Fix a bug", + releases: [{ name: "wrangler", type: "patch" }], + }, + { + summary: "Add a new feature", + releases: [{ name: "miniflare", type: "minor" }], + }, + ]; + const result = formatChangesets(changesets); + expect(result).toMatchInlineSnapshot(` + "#### Changeset 1 + **Packages:** \`wrangler\` (patch) + + Fix a bug + + #### Changeset 2 + **Packages:** \`miniflare\` (minor) + + Add a new feature" + `); + }); + + it("should list multiple packages in one changeset", () => { + const changesets: Changeset[] = [ + { + summary: "Shared update across packages", + releases: [ + { name: "wrangler", type: "minor" }, + { name: "miniflare", type: "minor" }, + { name: "@cloudflare/vitest-pool-workers", type: "patch" }, + ], + }, + ]; + const result = formatChangesets(changesets); + expect(result).toMatchInlineSnapshot(` + "#### Changeset 1 + **Packages:** \`wrangler\` (minor), \`miniflare\` (minor), \`@cloudflare/vitest-pool-workers\` (patch) + + Shared update across packages" + `); + }); + + it("should handle changeset with multi-line summary", () => { + const changesets: Changeset[] = [ + { + summary: `Add new CLI command for database export + +You can now export your D1 database to a local SQL file: + +\`\`\`bash +wrangler d1 export my-database --output backup.sql +\`\`\` + +This is useful for creating backups.`, + releases: [{ name: "wrangler", type: "minor" }], + }, + ]; + const result = formatChangesets(changesets); + expect(result).toContain("#### Changeset 1"); + expect(result).toContain("`wrangler` (minor)"); + expect(result).toContain("Add new CLI command for database export"); + expect(result).toContain("```bash"); + expect(result).toContain( + "wrangler d1 export my-database --output backup.sql" + ); + }); + + it("should handle empty changesets array", () => { + const result = formatChangesets([]); + expect(result).toBe(""); + }); +}); diff --git a/tools/github-workflow-helpers/copy-changesets-to-pr.ts b/tools/github-workflow-helpers/copy-changesets-to-pr.ts new file mode 100644 index 0000000000..fbd97e6883 --- /dev/null +++ b/tools/github-workflow-helpers/copy-changesets-to-pr.ts @@ -0,0 +1,215 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as core from "@actions/core"; +import { context, getOctokit } from "@actions/github"; +import parseChangesetFile from "@changesets/parse"; +import dedent from "ts-dedent"; +import type { Changeset } from "@changesets/types"; +import type { PullRequestOpenedEvent } from "@octokit/webhooks-types"; + +const START_CHECKLIST_MARKER = ""; +const DESCRIBE_PLACEHOLDER = "_Describe your change..._"; + +if (require.main === module) { + run().catch((error) => { + core.setFailed(error); + }); +} + +async function run() { + core.info(dedent` + Copy Changesets to PR + ===================== + `); + + if (!isPullRequestEvent(context.payload)) { + core.info("Not a pull request event, skipping."); + return; + } + + const { pull_request: pr } = context.payload; + const body = pr.body ?? ""; + + // Check if the description is empty (only contains template content) + if (!isDescriptionEmpty(body)) { + core.info("PR description has custom content, skipping."); + return; + } + + // Get added changesets from environment variable (set by dorny/paths-filter with list-files: json) + // eslint-disable-next-line turbo/no-undeclared-env-vars + const addedChangesetsEnv = process.env.ADDED_CHANGESETS ?? "[]"; + let addedFiles: string[]; + try { + addedFiles = JSON.parse(addedChangesetsEnv) as string[]; + } catch { + core.warning( + `Failed to parse ADDED_CHANGESETS as JSON: ${addedChangesetsEnv}` + ); + return; + } + + if (addedFiles.length === 0) { + core.info("No added changesets found, skipping."); + return; + } + + core.info( + `Found ${addedFiles.length} added changeset(s): ${addedFiles.join(", ")}` + ); + + // Read and parse the added changesets + const changesets = readChangesets(addedFiles); + + if (changesets.length === 0) { + core.info("No valid changesets found after parsing, skipping."); + return; + } + + // Build the new description + const changesetContent = formatChangesets(changesets); + const newBody = insertChangesets(body, changesetContent); + + // Update the PR + const token = core.getInput("github_token", { required: true }); + const octokit = getOctokit(token); + + await octokit.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + body: newBody, + }); + + core.info("Successfully updated PR description with changesets."); +} + +/** + * Check if the description section is empty. + * + * To determine this, we: + * 1. Strip the checklist (content between START_CHECKLIST and END_CHECKLIST markers) + * 2. Strip the "_Describe your change..._" placeholder + * 3. Strip "---" separators + * 4. Strip empty lines + * + * If only 1 or fewer non-empty lines remain, the description is considered empty. + */ +function isDescriptionEmpty(body: string): boolean { + let content = body; + + // Remove checklist section (between markers, inclusive) + const startIdx = content.indexOf(START_CHECKLIST_MARKER); + const endIdx = content.indexOf(END_CHECKLIST_MARKER); + + if (startIdx !== -1 && endIdx !== -1) { + content = + content.slice(0, startIdx) + + content.slice(endIdx + END_CHECKLIST_MARKER.length); + } + + // Process line by line + const lines = content.split("\n"); + const meaningfulLines = lines.filter((line) => { + const trimmed = line.trim(); + + // Skip empty lines + if (trimmed === "") { + return false; + } + + // Skip the placeholder text + if (trimmed === DESCRIBE_PLACEHOLDER) { + return false; + } + + // Skip separator lines + if (trimmed === "---") { + return false; + } + + return true; + }); + + // Consider empty if 1 or fewer meaningful lines remain + // (the "Fixes #..." line is expected to remain) + return meaningfulLines.length <= 1; +} + +/** + * Insert changeset content into the PR body. + * If the placeholder is present, replace it. Otherwise, prepend to the body. + */ +function insertChangesets(body: string, changesetContent: string): string { + if (body.includes(DESCRIBE_PLACEHOLDER)) { + return body.replace(DESCRIBE_PLACEHOLDER, changesetContent); + } + // Prepend changesets when placeholder is not present + return `${changesetContent}\n\n${body}`; +} + +/** + * Read and parse changeset files from the given paths using @changesets/parse + */ +function readChangesets(filePaths: string[]): Changeset[] { + const changesets: Changeset[] = []; + + for (const filePath of filePaths) { + const fullPath = path.join(process.cwd(), filePath); + + if (!fs.existsSync(fullPath)) { + core.warning(`Changeset file not found: ${fullPath}`); + continue; + } + + const content = fs.readFileSync(fullPath, "utf-8"); + try { + const parsed = parseChangesetFile(content); + if (parsed.releases.length > 0) { + changesets.push(parsed); + } + } catch (e) { + core.warning(`Failed to parse changeset ${filePath}: ${e}`); + } + } + + return changesets; +} + +/** + * Format changesets for display in the PR description + */ +function formatChangesets(changesets: Changeset[]): string { + const parts = changesets.map((cs, idx) => { + const packageInfo = cs.releases + .map((r) => `\`${r.name}\` (${r.type})`) + .join(", "); + + return dedent` + #### Changeset ${idx + 1} + **Packages:** ${packageInfo} + + ${cs.summary} + `; + }); + + return parts.join("\n\n"); +} + +/** + * Type guard to check if the payload is a PullRequestOpenedEvent + */ +function isPullRequestEvent( + payload: object +): payload is PullRequestOpenedEvent { + return ( + payload && + "action" in payload && + payload.action === "opened" && + "pull_request" in payload && + !!payload.pull_request + ); +} + +export { isDescriptionEmpty, insertChangesets, formatChangesets }; diff --git a/tools/package.json b/tools/package.json index 1c87ec716e..f4faf483d0 100644 --- a/tools/package.json +++ b/tools/package.json @@ -11,6 +11,8 @@ "devDependencies": { "@actions/core": "^1.10.1", "@actions/github": "^6.0.0", + "@changesets/parse": "^0.4.1", + "@changesets/types": "^6.1.0", "@cloudflare/eslint-config-shared": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", "@octokit/webhooks-types": "^7.6.1",