Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/changesets-changelog/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
5 changes: 5 additions & 0 deletions packages/changesets-changelog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 0 additions & 13 deletions packages/changesets-changelog/src/greetings.test.ts

This file was deleted.

9 changes: 0 additions & 9 deletions packages/changesets-changelog/src/greetings.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/changesets-changelog/src/index.ts

This file was deleted.

85 changes: 85 additions & 0 deletions packages/changesets-changelog/src/resolve-pull-request.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
65 changes: 65 additions & 0 deletions packages/changesets-changelog/src/resolve-pull-request.ts
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to export these at index.ts ?

export { extractPullRequestNumber, getPullRequestLink } from './resolve-pull-request.js';

@cryptodev-2s cryptodev-2s Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All right it looks like from PR description this will come up in a future PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No these will only be used internally. this is the changesets plugin so I would keep the exports to only the plugin required functions (they'll come after)

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<string> {
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})`;
}
3 changes: 2 additions & 1 deletion packages/changesets-changelog/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"extends": "../../tsconfig.packages.build.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
"rootDir": "./src",
"types": ["node"]
},
"references": [],
"include": ["./src"]
Expand Down
3 changes: 3 additions & 0 deletions packages/changesets-changelog/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"extends": "../../tsconfig.packages.json",
"compilerOptions": {
"types": ["node"]
},
"references": [],
"include": ["./src"]
}
3 changes: 3 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading