diff --git a/README.md b/README.md index 4e42faf..921ff24 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ linkStyle default opacity:0.5 changesets_changelog(["@metamask/changesets-changelog"]); changesets_utils(["@metamask/changesets-utils"]); create_release_branch(["@metamask/create-release-branch"]); + changesets_changelog --> changesets_utils; + changesets_changelog --> apply_release_plan; ``` diff --git a/packages/changesets-changelog/CHANGELOG.md b/packages/changesets-changelog/CHANGELOG.md index fe47b1e..95685e0 100644 --- a/packages/changesets-changelog/CHANGELOG.md +++ b/packages/changesets-changelog/CHANGELOG.md @@ -7,4 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add the Changesets changelog module that renders "Keep a Changelog" release entries ([#83](https://github.com/MetaMask/release-tools/pull/83)) + - Exports `getReleaseLine`, `getDependencyReleaseLine`, `categorizeReleaseLine`, `categorizeDependencyReleaseLine`, `getVersionHeader`, and `categories`. + - Renders one section per category, in the order `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`, and keeps sub-bullets and continuation lines. + - Prefixes entries of a `major` release with `**BREAKING:**`, and puts dependency bumps into `Changed`. + - Resolves pull request links from the local commit subject with `git show`, so it needs no GitHub token and makes no network call. Rendering fails if no pull request number is found. + [Unreleased]: https://github.com/MetaMask/release-tools/ diff --git a/packages/changesets-changelog/README.md b/packages/changesets-changelog/README.md index 8e49566..c02ba7f 100644 --- a/packages/changesets-changelog/README.md +++ b/packages/changesets-changelog/README.md @@ -2,6 +2,8 @@ Changesets changelog module for MetaMask +This is the changelog module that [Changesets](https://github.com/changesets/changesets) calls to render release entries. It writes ["Keep a Changelog"](https://keepachangelog.com/) sections instead of the default `### Minor Changes` sections, in the format MetaMask changelogs use. + ## Installation `yarn add @metamask/changesets-changelog` @@ -10,6 +12,54 @@ or `npm install @metamask/changesets-changelog` +## Usage + +Point the `changelog` field of your Changesets config at this package, and pass the GitHub repository that the pull request links refer to: + +```json +{ + "changelog": ["@metamask/changesets-changelog", { "repo": "MetaMask/core" }] +} +``` + +This module requires [`@metamask/apply-release-plan`](../apply-release-plan), which renders the categorized sections. The stock `@changesets/cli` ignores the categories and the version header. + +### Entries + +Each changeset holds one entry: a category heading and one top-level bullet. For example: + +```markdown +--- +'@metamask/base-controller': minor +--- + +## Added + +- Add `getNetworkClient` for looking up a client by chain ID + - The client is cached per chain. +``` + +That changeset renders as: + +```markdown +## [1.1.0] + +### Added + +- Add `getNetworkClient` for looking up a client by chain ID ([#1234](https://github.com/MetaMask/core/pull/1234)) + - The client is cached per chain. +``` + +The supported categories are `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security`. Sections appear in that order. Sub-bullets and continuation lines are kept below the entry. + +Entries of a `major` release get a `**BREAKING:**` prefix. Dependency bumps go into `Changed`. + +### Pull request links + +The pull request number comes from the commit that added the changeset file. The module reads the commit subject with `git show`, so it needs no GitHub token and makes no network call. It supports squash subjects such as `Add a feature (#1234)` and merge subjects such as `Merge pull request #1234 from owner/branch`. + +Rendering fails if the pull request number cannot be found, so that no entry is published without a link. + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/release-tools#readme). diff --git a/packages/changesets-changelog/package.json b/packages/changesets-changelog/package.json index 8511c15..10b9070 100644 --- a/packages/changesets-changelog/package.json +++ b/packages/changesets-changelog/package.json @@ -45,8 +45,16 @@ "test:verbose": "vitest --reporter=verbose", "test:watch": "vitest --watch" }, + "dependencies": { + "@changesets/types": "^7.0.0", + "@metamask/changesets-utils": "^0.0.0", + "execa": "^8.0.1" + }, "devDependencies": { + "@manypkg/get-packages": "^3.1.0", + "@metamask/apply-release-plan": "^0.0.0", "@metamask/auto-changelog": "^6.1.0", + "@types/node": "^22.13.14", "@typescript/native": "npm:typescript@^7.0.2", "@vitest/coverage-istanbul": "^5.0.1", "rimraf": "^5.0.5", diff --git a/packages/changesets-changelog/src/categories.test.ts b/packages/changesets-changelog/src/categories.test.ts new file mode 100644 index 0000000..1058748 --- /dev/null +++ b/packages/changesets-changelog/src/categories.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { categories } from './categories.js'; + +describe('categories', () => { + it('lists the Keep a Changelog categories in render order', () => { + expect(categories).toStrictEqual([ + 'Added', + 'Changed', + 'Deprecated', + 'Removed', + 'Fixed', + 'Security', + ]); + }); +}); diff --git a/packages/changesets-changelog/src/categories.ts b/packages/changesets-changelog/src/categories.ts new file mode 100644 index 0000000..7b854b5 --- /dev/null +++ b/packages/changesets-changelog/src/categories.ts @@ -0,0 +1,13 @@ +/** + * The changelog sections this module can produce, in the order they are + * rendered. These are the "Keep a Changelog" categories, which are also the + * categories `parseChangesetSummary` accepts in a changeset heading. + */ +export const categories = [ + 'Added', + 'Changed', + 'Deprecated', + 'Removed', + 'Fixed', + 'Security', +] as const; diff --git a/packages/changesets-changelog/src/get-dependency-release-line.test.ts b/packages/changesets-changelog/src/get-dependency-release-line.test.ts new file mode 100644 index 0000000..6a3207d --- /dev/null +++ b/packages/changesets-changelog/src/get-dependency-release-line.test.ts @@ -0,0 +1,95 @@ +import type { ModCompWithPackage } from '@changesets/types'; +import { describe, expect, it } from 'vitest'; + +import { + categorizeDependencyReleaseLine, + getDependencyReleaseLine, +} from './get-dependency-release-line.js'; + +/** + * Builds an updated dependency. + * + * @param overrides - The fields to override. + * @returns The dependency. + */ +function buildDependency( + overrides: Partial = {}, +): ModCompWithPackage { + return { + name: '@metamask/base-controller', + type: 'minor', + oldVersion: '1.2.0', + newVersion: '1.3.0', + changesets: [], + packageJson: { name: '@metamask/base-controller', version: '1.2.0' }, + dir: '/packages/base-controller', + ...overrides, + } as ModCompWithPackage; +} + +describe('getDependencyReleaseLine', () => { + it('returns an empty string when no dependency changed', async () => { + expect( + await getDependencyReleaseLine([], [], { repo: 'MetaMask/core' }), + ).toBe(''); + }); + + it('renders one caret-ranged line per dependency', async () => { + expect( + await getDependencyReleaseLine( + [], + [ + buildDependency(), + buildDependency({ + name: '@metamask/utils', + oldVersion: '^9.0.0', + newVersion: '^10.0.0', + }), + ], + { repo: 'MetaMask/core' }, + ), + ).toBe( + [ + '- Bump `@metamask/base-controller` from `^1.2.0` to `^1.3.0`', + '- Bump `@metamask/utils` from `^9.0.0` to `^10.0.0`', + ].join('\n'), + ); + }); + + it('throws when the options have no repo', async () => { + await expect( + getDependencyReleaseLine([], [buildDependency()], null), + ).rejects.toThrow('Please provide a repo to this changelog generator'); + }); + + it.each([ + { oldVersion: undefined, newVersion: '1.3.0' }, + { oldVersion: '1.2.0', newVersion: undefined }, + ])('throws when a version is missing', async (versions) => { + await expect( + getDependencyReleaseLine([], [buildDependency(versions)], { + repo: 'MetaMask/core', + }), + ).rejects.toThrow( + 'Dependency @metamask/base-controller must have old and new versions', + ); + }); +}); + +describe('categorizeDependencyReleaseLine', () => { + it('assigns every line to Changed', async () => { + const first = '- Bump `pkg-a` from `^1.0.0` to `^1.1.0`'; + const second = '- Bump `pkg-b` from `^2.0.0` to `^2.1.0`'; + + expect( + await categorizeDependencyReleaseLine(`${first}\n${second}`), + ).toStrictEqual([ + { category: 'Changed', line: first }, + { category: 'Changed', line: second }, + ]); + }); + + it('drops empty lines', async () => { + expect(await categorizeDependencyReleaseLine('')).toStrictEqual([]); + }); +}); diff --git a/packages/changesets-changelog/src/get-dependency-release-line.ts b/packages/changesets-changelog/src/get-dependency-release-line.ts new file mode 100644 index 0000000..8e99d6c --- /dev/null +++ b/packages/changesets-changelog/src/get-dependency-release-line.ts @@ -0,0 +1,57 @@ +import type { + ModCompWithPackage, + NewChangesetWithCommit, +} from '@changesets/types'; + +import { getRepo } from './options.js'; +import type { CategorizedReleaseLine, ChangelogOptions } from './types.js'; + +/** + * Renders dependency version changes as changelog lines. + * + * @param _changesets - The changesets in the release. + * @param dependenciesUpdated - The dependencies updated by the release. + * @param changelogOpts - The changelog options. Must contain `repo`. + * @returns One line per updated dependency. + */ +export async function getDependencyReleaseLine( + _changesets: NewChangesetWithCommit[], + dependenciesUpdated: ModCompWithPackage[], + changelogOpts: ChangelogOptions, +): Promise { + getRepo(changelogOpts); + + return dependenciesUpdated + .map((dependency) => { + if (!dependency.oldVersion || !dependency.newVersion) { + throw new Error( + `Dependency ${dependency.name} must have old and new versions`, + ); + } + + const oldVersion = dependency.oldVersion.startsWith('^') + ? dependency.oldVersion + : `^${dependency.oldVersion}`; + const newVersion = dependency.newVersion.startsWith('^') + ? dependency.newVersion + : `^${dependency.newVersion}`; + + return `- Bump \`${dependency.name}\` from \`${oldVersion}\` to \`${newVersion}\``; + }) + .join('\n'); +} + +/** + * Assigns dependency lines to the `Changed` category. + * + * @param line - The lines rendered by `getDependencyReleaseLine`. + * @returns The non-empty lines and their category. + */ +export async function categorizeDependencyReleaseLine( + line: string, +): Promise { + return line + .split('\n') + .filter((item) => item.trim() !== '') + .map((item) => ({ category: 'Changed', line: item })); +} diff --git a/packages/changesets-changelog/src/get-release-line.test.ts b/packages/changesets-changelog/src/get-release-line.test.ts new file mode 100644 index 0000000..ba44d87 --- /dev/null +++ b/packages/changesets-changelog/src/get-release-line.test.ts @@ -0,0 +1,115 @@ +import type { NewChangesetWithCommit } from '@changesets/types'; +import { describe, expect, it, vi } from 'vitest'; + +import { categorizeReleaseLine, getReleaseLine } from './get-release-line.js'; +import * as resolvePullRequest from './resolve-pull-request.js'; + +vi.mock('./resolve-pull-request.js', async () => ({ + ...(await vi.importActual( + './resolve-pull-request.js', + )), + getPullRequestLink: vi.fn(), +})); + +const getPullRequestLink = vi.mocked(resolvePullRequest.getPullRequestLink); +const pullRequestLink = '[#1234](https://github.com/MetaMask/core/pull/1234)'; + +/** + * Builds a changeset with the given summary. + * + * @param summary - The changeset summary. + * @returns The changeset. + */ +function buildChangeset(summary: string): NewChangesetWithCommit { + return { + id: 'quick-lions-devour', + summary, + releases: [{ name: 'pkg-a', type: 'major' }], + commit: 'abc1234', + }; +} + +describe('getReleaseLine', () => { + it('marks a major entry as breaking', async () => { + getPullRequestLink.mockResolvedValue(pullRequestLink); + + expect( + await getReleaseLine( + buildChangeset('## Added\n\n- Add a feature'), + 'major', + { repo: 'MetaMask/core' }, + ), + ).toBe(`- **BREAKING:** Add a feature (${pullRequestLink})`); + }); + + it.each(['minor', 'patch'] as const)( + 'does not mark a %s entry as breaking', + async (type) => { + getPullRequestLink.mockResolvedValue(pullRequestLink); + + expect( + await getReleaseLine( + buildChangeset('## Changed\n\n- Change a feature'), + type, + { repo: 'MetaMask/core' }, + ), + ).toBe(`- Change a feature (${pullRequestLink})`); + }, + ); + + it('keeps continuation lines and sub-bullets below the entry', async () => { + getPullRequestLink.mockResolvedValue(pullRequestLink); + + expect( + await getReleaseLine( + buildChangeset( + '## Changed\n\n- Rename `getClient`\nto `getNetworkClient`\n - The old method is no longer available', + ), + 'patch', + { repo: 'MetaMask/core' }, + ), + ).toBe( + [ + `- Rename \`getClient\` (${pullRequestLink})`, + ' to `getNetworkClient`', + ' - The old method is no longer available', + ].join('\n'), + ); + }); + + it('throws when the options have no repo', async () => { + await expect( + getReleaseLine( + buildChangeset('## Added\n\n- Add a feature'), + 'patch', + null, + ), + ).rejects.toThrow('Please provide a repo to this changelog generator'); + }); + + it('throws the grammar error when the summary is invalid', async () => { + await expect( + getReleaseLine( + buildChangeset('## Added\n\n- Add one\n- Add two'), + 'patch', + { repo: 'MetaMask/core' }, + ), + ).rejects.toMatchObject({ + rule: 'multiple-entries', + line: 4, + }); + }); +}); + +describe('categorizeReleaseLine', () => { + it('assigns the line to the category of its changeset', async () => { + const line = `- Add a feature (${pullRequestLink})`; + + expect( + await categorizeReleaseLine( + line, + buildChangeset('## Added\n\n- Add a feature'), + ), + ).toStrictEqual([{ category: 'Added', line }]); + }); +}); diff --git a/packages/changesets-changelog/src/get-release-line.ts b/packages/changesets-changelog/src/get-release-line.ts new file mode 100644 index 0000000..a7c7bd8 --- /dev/null +++ b/packages/changesets-changelog/src/get-release-line.ts @@ -0,0 +1,53 @@ +import type { NewChangesetWithCommit, VersionType } from '@changesets/types'; +import { parseChangesetSummary } from '@metamask/changesets-utils'; + +import { getRepo } from './options.js'; +import { getPullRequestLink } from './resolve-pull-request.js'; +import type { CategorizedReleaseLine, ChangelogOptions } from './types.js'; + +/** + * Renders the changelog line for a changeset. + * + * The line holds the entry description, a pull request link, and a + * `**BREAKING:**` prefix for major releases. Continuation lines and + * sub-bullets of the description are kept and indented below the entry. + * + * @param changeset - The changeset to render. + * @param type - The bump type of the release. + * @param changelogOpts - The changelog options. Must contain `repo`. + * @returns The rendered changelog line. + * @throws If the options have no `repo`, if the summary does not follow the + * changeset grammar, or if the pull request cannot be resolved. + */ +export async function getReleaseLine( + changeset: NewChangesetWithCommit, + type: VersionType, + changelogOpts: ChangelogOptions, +): Promise { + const repo = getRepo(changelogOpts); + const { description } = parseChangesetSummary(changeset.summary); + const pullRequestLink = await getPullRequestLink(changeset, repo); + + const [firstLine, ...rest] = description.split('\n'); + const entry = `- ${type === 'major' ? '**BREAKING:** ' : ''}${firstLine} (${pullRequestLink})`; + const continuation = rest.map((line) => + /^\s/u.test(line) ? line : ` ${line}`, + ); + + return [entry, ...continuation].join('\n'); +} + +/** + * Assigns a changelog line to the category declared by its changeset. + * + * @param line - The line rendered by `getReleaseLine`. + * @param changeset - The changeset the line was rendered from. + * @returns The line and the section it belongs to. + */ +export async function categorizeReleaseLine( + line: string, + changeset: NewChangesetWithCommit, +): Promise { + const { category } = parseChangesetSummary(changeset.summary); + return [{ category, line }]; +} diff --git a/packages/changesets-changelog/src/get-version-header.test.ts b/packages/changesets-changelog/src/get-version-header.test.ts new file mode 100644 index 0000000..e7c4b71 --- /dev/null +++ b/packages/changesets-changelog/src/get-version-header.test.ts @@ -0,0 +1,20 @@ +import type { ModCompWithPackage } from '@changesets/types'; +import { describe, expect, it } from 'vitest'; + +import { getVersionHeader } from './get-version-header.js'; + +describe('getVersionHeader', () => { + it('renders a bracketed Keep a Changelog heading', () => { + const release = { + name: 'pkg-a', + type: 'minor', + oldVersion: '1.2.2', + newVersion: '1.2.3', + changesets: [], + packageJson: { name: 'pkg-a', version: '1.2.2' }, + dir: '/packages/pkg-a', + } as ModCompWithPackage; + + expect(getVersionHeader(release)).toBe('## [1.2.3]'); + }); +}); diff --git a/packages/changesets-changelog/src/get-version-header.ts b/packages/changesets-changelog/src/get-version-header.ts new file mode 100644 index 0000000..10de186 --- /dev/null +++ b/packages/changesets-changelog/src/get-version-header.ts @@ -0,0 +1,11 @@ +import type { ModCompWithPackage } from '@changesets/types'; + +/** + * Renders a Keep a Changelog version heading. + * + * @param release - The release being rendered. + * @returns The bracketed version heading. + */ +export function getVersionHeader(release: ModCompWithPackage): string { + return `## [${release.newVersion}]`; +} diff --git a/packages/changesets-changelog/src/greetings.test.ts b/packages/changesets-changelog/src/greetings.test.ts deleted file mode 100644 index f0501e5..0000000 --- a/packages/changesets-changelog/src/greetings.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { generateGreeting } from './index.js'; - -describe('greetings', () => { - describe('generateGreeting', () => { - it('greets', () => { - const name = 'Huey'; - const result = generateGreeting(name); - expect(result).toBe('Hello, Huey!'); - }); - }); -}); diff --git a/packages/changesets-changelog/src/greetings.ts b/packages/changesets-changelog/src/greetings.ts deleted file mode 100644 index 9ad38af..0000000 --- a/packages/changesets-changelog/src/greetings.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export function generateGreeting(name: string): string { - return `Hello, ${name}!`; -} diff --git a/packages/changesets-changelog/src/index.integration.test.ts b/packages/changesets-changelog/src/index.integration.test.ts new file mode 100644 index 0000000..da8bcca --- /dev/null +++ b/packages/changesets-changelog/src/index.integration.test.ts @@ -0,0 +1,120 @@ +import type { + Config, + NewChangesetWithCommit, + ReleasePlan, +} from '@changesets/types'; +import { getPackages } from '@manypkg/get-packages'; +import { applyReleasePlan } from '@metamask/apply-release-plan'; +import { parseChangelog } from '@metamask/auto-changelog'; +import { execa } from 'execa'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('execa', async () => ({ + ...(await vi.importActual('execa')), + execa: vi.fn(), +})); + +const mockedExeca = vi.mocked(execa); +const changelogModulePath = path.resolve(import.meta.dirname, 'index.ts'); + +async function createFixture(files: Record): Promise { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'changesets-changelog-'), + ); + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(directory, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); + } + return directory; +} + +const config: Config = { + changelog: [changelogModulePath, { repo: 'MetaMask/core' }], + commit: false, + fixed: [], + linked: [], + access: 'restricted', + changedFilePatterns: ['**'], + baseBranch: 'main', + updateInternalDependencies: 'patch', + ignore: [], + format: false, + privatePackages: { version: true, tag: false }, + snapshot: { + useCalculatedVersion: false, + prereleaseTemplate: null, + }, + ___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH: { + onlyUpdatePeerDependentsWhenOutOfRange: false, + updateInternalDependents: 'out-of-range', + }, +}; + +describe('changesets-changelog integration', () => { + it('renders a categorized changelog block through applyReleasePlan', async () => { + mockedExeca.mockResolvedValue({ + stdout: 'Add a feature (#1234)', + } as never); + + const fixture = { + 'package.json': JSON.stringify({ name: 'pkg-a', version: '1.0.0' }), + 'CHANGELOG.md': `# Changelog + +## [Unreleased] + +## [1.0.0] + +### Added + +- Initial release + +[Unreleased]: https://github.com/MetaMask/core/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/MetaMask/core/releases/tag/v1.0.0 +`, + }; + const tempDir = await createFixture(fixture); + const packages = await getPackages(tempDir); + // `applyReleasePlan` keeps this commit, because the fixture directory has + // no Git history to resolve the changeset file from. + const changeset: NewChangesetWithCommit = { + id: 'quick-lions-devour', + summary: '## Added\n\n- Add a feature\n - Configure it safely', + releases: [{ name: 'pkg-a', type: 'minor' }], + commit: 'abc1234', + }; + const releasePlan: ReleasePlan = { + changesets: [changeset], + releases: [ + { + name: 'pkg-a', + type: 'minor', + oldVersion: '1.0.0', + newVersion: '1.1.0', + changesets: ['quick-lions-devour'], + }, + ], + preState: undefined, + }; + + await applyReleasePlan(releasePlan, packages, config); + + const changelog = await fs.readFile( + path.join(tempDir, 'CHANGELOG.md'), + 'utf8', + ); + const expectedEntry = + '- Add a feature ([#1234](https://github.com/MetaMask/core/pull/1234))\n - Configure it safely'; + expect(changelog).toContain(`## [1.1.0]\n\n### Added\n\n${expectedEntry}`); + // A round trip through `auto-changelog` must keep the entry intact, + // including its sub-bullet. + const roundTripped = await parseChangelog({ + changelogContent: changelog, + repoUrl: 'https://github.com/MetaMask/core', + }).toString(); + expect(roundTripped).toContain(expectedEntry); + }); +}); diff --git a/packages/changesets-changelog/src/index.ts b/packages/changesets-changelog/src/index.ts index 408cad9..7079d00 100644 --- a/packages/changesets-changelog/src/index.ts +++ b/packages/changesets-changelog/src/index.ts @@ -1 +1,8 @@ -export { generateGreeting } from './greetings.js'; +export { categories } from './categories.js'; +export { + categorizeDependencyReleaseLine, + getDependencyReleaseLine, +} from './get-dependency-release-line.js'; +export { categorizeReleaseLine, getReleaseLine } from './get-release-line.js'; +export { getVersionHeader } from './get-version-header.js'; +export type { CategorizedReleaseLine } from './types.js'; diff --git a/packages/changesets-changelog/src/options.ts b/packages/changesets-changelog/src/options.ts new file mode 100644 index 0000000..5778df3 --- /dev/null +++ b/packages/changesets-changelog/src/options.ts @@ -0,0 +1,20 @@ +import type { ChangelogOptions } from './types.js'; + +/** + * Reads the GitHub repository from the changelog options. + * + * @param changelogOpts - The changelog options. + * @returns The repository in `owner/repo` form. + * @throws If the options have no non-empty `repo` string. + */ +export function getRepo(changelogOpts: ChangelogOptions): string { + const repo = changelogOpts?.repo; + + if (typeof repo !== 'string' || repo.trim() === '') { + throw new Error( + 'Please provide a repo to this changelog generator like this:\n"changelog": ["@metamask/changesets-changelog", { "repo": "org/repo" }]', + ); + } + + return repo; +} diff --git a/packages/changesets-changelog/src/resolve-pull-request.test.ts b/packages/changesets-changelog/src/resolve-pull-request.test.ts new file mode 100644 index 0000000..53365b3 --- /dev/null +++ b/packages/changesets-changelog/src/resolve-pull-request.test.ts @@ -0,0 +1,85 @@ +import type { NewChangesetWithCommit } from '@changesets/types'; +import { execa } from 'execa'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + extractPullRequestNumber, + getPullRequestLink, +} from './resolve-pull-request.js'; + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +const mockedExeca = vi.mocked(execa); + +const changeset = { + id: 'quick-lions-devour', + summary: '## Added\n\n- Add a feature', + releases: [{ name: 'pkg-a', type: 'minor' }], + commit: 'abc1234', +} as NewChangesetWithCommit; + +describe('extractPullRequestNumber', () => { + it('extracts a number from a squash-merge subject', () => { + expect(extractPullRequestNumber('Add a feature (#1234)')).toBe('1234'); + }); + + it('extracts a number from a merge-commit subject', () => { + expect( + extractPullRequestNumber('Merge pull request #1234 from owner/feature'), + ).toBe('1234'); + }); + + it('throws when a subject has no pull request number', () => { + expect(() => extractPullRequestNumber('Add a feature')).toThrow( + 'does not contain a pull request number', + ); + }); +}); + +describe('getPullRequestLink', () => { + beforeEach(() => { + mockedExeca.mockReset(); + }); + + it('reads the commit subject and returns a pull request link', async () => { + mockedExeca.mockResolvedValue({ stdout: 'Add a feature (#1234)' } as never); + + expect(await getPullRequestLink(changeset, 'MetaMask/core')).toBe( + '[#1234](https://github.com/MetaMask/core/pull/1234)', + ); + expect(mockedExeca).toHaveBeenCalledWith('git', [ + 'show', + '-s', + '--format=%s', + 'abc1234', + ]); + }); + + it('throws when the changeset has no commit', async () => { + const withoutCommit = { ...changeset }; + delete withoutCommit.commit; + await expect( + getPullRequestLink(withoutCommit, 'MetaMask/core'), + ).rejects.toThrow('quick-lions-devour has no commit'); + }); + + it('throws when git cannot read the commit', async () => { + mockedExeca.mockRejectedValue(new Error('unknown revision')); + await expect( + getPullRequestLink(changeset, 'MetaMask/core'), + ).rejects.toThrow( + 'Could not read commit abc1234 for changeset quick-lions-devour', + ); + }); + + it('throws when git returns an empty subject', async () => { + mockedExeca.mockResolvedValue({ stdout: ' ' } as never); + await expect( + getPullRequestLink(changeset, 'MetaMask/core'), + ).rejects.toThrow( + 'Could not read commit abc1234 for changeset quick-lions-devour', + ); + }); +}); diff --git a/packages/changesets-changelog/src/resolve-pull-request.ts b/packages/changesets-changelog/src/resolve-pull-request.ts new file mode 100644 index 0000000..5f6506c --- /dev/null +++ b/packages/changesets-changelog/src/resolve-pull-request.ts @@ -0,0 +1,65 @@ +import type { NewChangesetWithCommit } from '@changesets/types'; +import { execa } from 'execa'; + +const SQUASH_MERGE_PR_PATTERN = /\(#(\d+)\)$/u; +const MERGE_COMMIT_PR_PATTERN = /Merge pull request #(\d+) from/iu; + +/** + * Extracts a pull request number from a Git commit subject. + * + * @param subject - The Git commit subject. + * @returns The pull request number. + */ +export function extractPullRequestNumber(subject: string): string { + const match = + subject.match(SQUASH_MERGE_PR_PATTERN) ?? + subject.match(MERGE_COMMIT_PR_PATTERN); + + if (!match) { + throw new Error( + `Commit subject does not contain a pull request number: ${subject}`, + ); + } + + return match[1]; +} + +/** + * Resolves the pull request link for a changeset using its local Git commit. + * + * @param changeset - The changeset whose commit should be inspected. + * @param repo - The GitHub repository in `owner/repo` form. + * @returns The Markdown pull request link. + */ +export async function getPullRequestLink( + changeset: NewChangesetWithCommit, + repo: string, +): Promise { + if (!changeset.commit) { + throw new Error(`Changeset ${changeset.id} has no commit`); + } + + let subject: string; + try { + ({ stdout: subject } = await execa('git', [ + 'show', + '-s', + '--format=%s', + changeset.commit, + ])); + } catch (error) { + throw new Error( + `Could not read commit ${changeset.commit} for changeset ${changeset.id}`, + { cause: error }, + ); + } + + if (!subject.trim()) { + throw new Error( + `Could not read commit ${changeset.commit} for changeset ${changeset.id}: empty subject`, + ); + } + + const pullRequestNumber = extractPullRequestNumber(subject.trim()); + return `[#${pullRequestNumber}](https://github.com/${repo}/pull/${pullRequestNumber})`; +} diff --git a/packages/changesets-changelog/src/types.ts b/packages/changesets-changelog/src/types.ts new file mode 100644 index 0000000..9432127 --- /dev/null +++ b/packages/changesets-changelog/src/types.ts @@ -0,0 +1,23 @@ +/** + * The options passed to the changelog module through the `changelog` field of + * the Changesets config. + */ +export type ChangelogOptions = null | Record; + +/** + * A single changelog line together with the section it belongs to. + * + * This mirrors the type `@metamask/apply-release-plan` expects from a + * categorized changelog module. It is declared here so that this package does + * not depend on the package that loads it. + */ +export type CategorizedReleaseLine = { + /** + * The title of the section the line belongs to, e.g. `Added`. + */ + category: string; + /** + * The changelog line itself. + */ + line: string; +}; diff --git a/packages/changesets-changelog/tsconfig.build.json b/packages/changesets-changelog/tsconfig.build.json index 662c0b2..296f7a5 100644 --- a/packages/changesets-changelog/tsconfig.build.json +++ b/packages/changesets-changelog/tsconfig.build.json @@ -2,8 +2,9 @@ "extends": "../../tsconfig.packages.build.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "types": ["node"] }, - "references": [], + "references": [{ "path": "../changesets-utils/tsconfig.build.json" }], "include": ["./src"] } diff --git a/packages/changesets-changelog/tsconfig.json b/packages/changesets-changelog/tsconfig.json index 4568e25..4dafca3 100644 --- a/packages/changesets-changelog/tsconfig.json +++ b/packages/changesets-changelog/tsconfig.json @@ -1,5 +1,11 @@ { "extends": "../../tsconfig.packages.json", - "references": [], + "compilerOptions": { + "types": ["node"] + }, + "references": [ + { "path": "../apply-release-plan/tsconfig.json" }, + { "path": "../changesets-utils/tsconfig.json" } + ], "include": ["./src"] } diff --git a/packages/changesets-changelog/tsconfig.lint.json b/packages/changesets-changelog/tsconfig.lint.json index fb65dcf..edfc867 100644 --- a/packages/changesets-changelog/tsconfig.lint.json +++ b/packages/changesets-changelog/tsconfig.lint.json @@ -4,5 +4,8 @@ "outDir": "./.tsc-lint-cache", "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" }, - "references": [] + "references": [ + { "path": "../apply-release-plan/tsconfig.lint.json" }, + { "path": "../changesets-utils/tsconfig.lint.json" } + ] } diff --git a/yarn.lock b/yarn.lock index 16fd18c..183cd65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1196,7 +1196,7 @@ __metadata: languageName: node linkType: hard -"@metamask/apply-release-plan@workspace:packages/apply-release-plan": +"@metamask/apply-release-plan@npm:^0.0.0, @metamask/apply-release-plan@workspace:packages/apply-release-plan": version: 0.0.0-use.local resolution: "@metamask/apply-release-plan@workspace:packages/apply-release-plan" dependencies: @@ -1252,9 +1252,15 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/changesets-changelog@workspace:packages/changesets-changelog" dependencies: + "@changesets/types": "npm:^7.0.0" + "@manypkg/get-packages": "npm:^3.1.0" + "@metamask/apply-release-plan": "npm:^0.0.0" "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/changesets-utils": "npm:^0.0.0" + "@types/node": "npm:^22.13.14" "@typescript/native": "npm:typescript@^7.0.2" "@vitest/coverage-istanbul": "npm:^5.0.1" + execa: "npm:^8.0.1" rimraf: "npm:^5.0.5" typedoc: "npm:^0.25.13" typescript: "npm:@typescript/typescript6@^6.0.2" @@ -1263,7 +1269,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/changesets-utils@workspace:packages/changesets-utils": +"@metamask/changesets-utils@npm:^0.0.0, @metamask/changesets-utils@workspace:packages/changesets-utils": version: 0.0.0-use.local resolution: "@metamask/changesets-utils@workspace:packages/changesets-utils" dependencies: