diff --git a/packages/changesets-changelog/CHANGELOG.md b/packages/changesets-changelog/CHANGELOG.md index fe47b1e..6d74180 100644 --- a/packages/changesets-changelog/CHANGELOG.md +++ b/packages/changesets-changelog/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add local pull request resolution from Git commit subjects ([#84](https://github.com/MetaMask/release-tools/pull/84)) + [Unreleased]: https://github.com/MetaMask/release-tools/ diff --git a/packages/changesets-changelog/package.json b/packages/changesets-changelog/package.json index 8511c15..4ce13c5 100644 --- a/packages/changesets-changelog/package.json +++ b/packages/changesets-changelog/package.json @@ -45,8 +45,13 @@ "test:verbose": "vitest --reporter=verbose", "test:watch": "vitest --watch" }, + "dependencies": { + "@changesets/types": "^7.0.0", + "execa": "^8.0.1" + }, "devDependencies": { "@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/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.ts b/packages/changesets-changelog/src/index.ts deleted file mode 100644 index 408cad9..0000000 --- a/packages/changesets-changelog/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { generateGreeting } from './greetings.js'; 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/tsconfig.build.json b/packages/changesets-changelog/tsconfig.build.json index 662c0b2..6fa9af6 100644 --- a/packages/changesets-changelog/tsconfig.build.json +++ b/packages/changesets-changelog/tsconfig.build.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.packages.build.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "types": ["node"] }, "references": [], "include": ["./src"] diff --git a/packages/changesets-changelog/tsconfig.json b/packages/changesets-changelog/tsconfig.json index 4568e25..ba29d25 100644 --- a/packages/changesets-changelog/tsconfig.json +++ b/packages/changesets-changelog/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "types": ["node"] + }, "references": [], "include": ["./src"] } diff --git a/yarn.lock b/yarn.lock index 16fd18c..6d4dfa0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1252,9 +1252,12 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/changesets-changelog@workspace:packages/changesets-changelog" dependencies: + "@changesets/types": "npm:^7.0.0" "@metamask/auto-changelog": "npm:^6.1.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"