diff --git a/CHANGELOG.md b/CHANGELOG.md index 22186c5..2c8e240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Look for `CHANGELOG entry:` in the PR description, and look for `no-changelog` label, then apply this to the generated CHANGELOG, plus properly categorize by Conventional Commit type, including our custom ConventionalCommitType.RELEASE (#247) + ## [5.0.2] ### Fixed diff --git a/package.json b/package.json index 086403c..bc60966 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test:watch": "jest --watch" }, "dependencies": { + "@octokit/rest": "^20.0.0", "diff": "^5.0.0", "execa": "^5.1.1", "semver": "^7.3.5", @@ -56,6 +57,7 @@ "@types/cross-spawn": "^6.0.2", "@types/diff": "^5.0.0", "@types/jest": "^26.0.23", + "@types/node": "^22.18.0", "@types/semver": "^7.3.6", "@types/yargs": "^16.0.1", "@typescript-eslint/eslint-plugin": "^5.42.1", diff --git a/src/changelog.ts b/src/changelog.ts index 3352f74..dd8aaf6 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -107,12 +107,14 @@ type ChangelogChanges = Record & { * @param category - The title of the changelog category. * @param changes - The changes included in this category. * @param repoUrl - The URL of the repository. + * @param useShortPrLink - Whether to use short PR links in the changelog entries. * @returns The stringified category section. */ function stringifyCategory( category: ChangeCategory, changes: Change[], repoUrl: string, + useShortPrLink = false, ) { const categoryHeader = `### ${category}`; if (changes.length === 0) { @@ -122,7 +124,11 @@ function stringifyCategory( .map(({ description, prNumbers }) => { const [firstLine, ...otherLines] = description.split('\n'); const stringifiedPrLinks = prNumbers - .map((prNumber) => `[#${prNumber}](${repoUrl}/pull/${prNumber})`) + .map((prNumber) => + useShortPrLink + ? `#${prNumber}` + : `[#${prNumber}](${repoUrl}/pull/${prNumber})`, + ) .join(', '); const parenthesizedPrLinks = stringifiedPrLinks.length > 0 ? ` (${stringifiedPrLinks})` : ''; @@ -140,6 +146,7 @@ function stringifyCategory( * @param version - The release version. * @param categories - The categories of changes included in this release. * @param repoUrl - The URL of the repository. + * @param useShortPrLink - Whether to use short PR links in the changelog entries. * @param options - Additional release options. * @param options.date - The date of the release. * @param options.status - The status of the release (e.g., "DEPRECATED"). @@ -149,6 +156,7 @@ function stringifyRelease( version: Version | typeof unreleased, categories: ReleaseChanges, repoUrl: string, + useShortPrLink = false, { date, status }: Partial = {}, ) { const releaseHeader = `## [${version}]${date ? ` - ${date}` : ''}${ @@ -158,7 +166,7 @@ function stringifyRelease( .filter((category) => categories[category]) .map((category) => { const changes = categories[category] ?? []; - return stringifyCategory(category, changes, repoUrl); + return stringifyCategory(category, changes, repoUrl, useShortPrLink); }) .join('\n\n'); if (categorizedChanges === '') { @@ -173,21 +181,27 @@ function stringifyRelease( * @param releases - The releases to stringify. * @param changes - The set of changes to include, organized by release. * @param repoUrl - The URL of the repository. + * @param useShortPrLink - Whether to use short PR links in the changelog entries. * @returns The stringified set of release sections. */ function stringifyReleases( releases: ReleaseMetadata[], changes: ChangelogChanges, repoUrl: string, + useShortPrLink = false, ) { const stringifiedUnreleased = stringifyRelease( unreleased, changes[unreleased], repoUrl, + useShortPrLink, ); const stringifiedReleases = releases.map(({ version, date, status }) => { const categories = changes[version]; - return stringifyRelease(version, categories, repoUrl, { date, status }); + return stringifyRelease(version, categories, repoUrl, useShortPrLink, { + date, + status, + }); }); return [stringifiedUnreleased, ...stringifiedReleases].join('\n\n'); @@ -586,15 +600,22 @@ export default class Changelog { * Throws an error if no such release exists. * * @param version - The version of the release to stringify. + * @param useShortPrLink - Whether to use short PR links in the changelog entries. * @returns The stringified release, as it appears in the changelog. */ - getStringifiedRelease(version: Version) { + getStringifiedRelease(version: Version, useShortPrLink = false) { const release = this.getRelease(version); if (!release) { throw new Error(`Specified release version does not exist: '${version}'`); } const releaseChanges = this.getReleaseChanges(version); - return stringifyRelease(version, releaseChanges, this.#repoUrl, release); + return stringifyRelease( + version, + releaseChanges, + this.#repoUrl, + useShortPrLink, + release, + ); } /** @@ -619,13 +640,14 @@ export default class Changelog { /** * The stringified changelog, formatted according to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). * + * @param useShortPrLink - Whether to use short PR links in the changelog entries. * @returns The stringified changelog. */ - async toString(): Promise { + async toString(useShortPrLink = false): Promise { const changelog = `${changelogTitle} ${changelogDescription} -${stringifyReleases(this.#releases, this.#changes, this.#repoUrl)} +${stringifyReleases(this.#releases, this.#changes, this.#repoUrl, useShortPrLink)} ${stringifyLinkReferenceDefinitions( this.#repoUrl, diff --git a/src/cli.ts b/src/cli.ts index a765e24..209627a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -94,6 +94,14 @@ type UpdateOptions = { * The package rename properties, used in case of package is renamed */ packageRename?: PackageRename; + /** + * Whether to use `CHANGELOG entry:` from the commit body and the no-changelog label + */ + useChangelogEntry: boolean; + /** + * Whether to use short PR links in the changelog entries + */ + useShortPrLink: boolean; }; /** @@ -107,9 +115,12 @@ type UpdateOptions = { * @param options.projectRootDirectory - The root project directory. * @param options.tagPrefix - The prefix used in tags before the version number. * @param options.formatter - A custom Markdown formatter to use. - * @param options.packageRename - The package rename properties. + * @param options.packageRename - The package rename properties. Optional. + * Only needed when retrieving a changelog for a renamed package + * (e.g., utils -> @metamask/utils). * @param options.autoCategorize - Whether to categorize commits automatically based on their messages. - * An optional, which is required only in case of package renamed. + * @param options.useChangelogEntry - Whether to read `CHANGELOG entry:` from the commit body and the no-changelog label. + * @param options.useShortPrLink - Whether to use short PR links in the changelog entries. */ async function update({ changelogPath, @@ -121,6 +132,8 @@ async function update({ formatter, packageRename, autoCategorize, + useChangelogEntry, + useShortPrLink, }: UpdateOptions) { const changelogContent = await readChangelog(changelogPath); @@ -134,6 +147,8 @@ async function update({ formatter, packageRename, autoCategorize, + useChangelogEntry, + useShortPrLink, }); if (newChangelogContent) { @@ -331,6 +346,17 @@ async function main() { description: `Expect the changelog to be formatted with Prettier.`, type: 'boolean', }) + .option('useChangelogEntry', { + default: false, + description: + 'Read `CHANGELOG entry:` from the commit body and the no-changelog label', + type: 'boolean', + }) + .option('useShortPrLink', { + default: false, + description: 'Use short PR links in the changelog entries', + type: 'boolean', + }) .epilog(updateEpilog), ) .command( @@ -388,6 +414,8 @@ async function main() { tagPrefixBeforePackageRename, autoCategorize, prLinks, + useChangelogEntry, + useShortPrLink, } = argv; let { currentVersion } = argv; @@ -517,6 +545,8 @@ async function main() { formatter, packageRename, autoCategorize, + useChangelogEntry: Boolean(useChangelogEntry), + useShortPrLink: Boolean(useShortPrLink), }); } else if (command === 'validate') { let packageRename: PackageRename | undefined; diff --git a/src/constants.ts b/src/constants.ts index cc8f23e..adddb77 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,15 +7,23 @@ export type Version = string; * A [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) type. */ export enum ConventionalCommitType { - /** - * fix: a commit of the type fix patches a bug in your codebase - */ - Fix = 'fix', - /** - * a commit of the type feat introduces a new feature to the codebase - */ - Feat = 'feat', + FEAT = 'feat', // A new feature + FIX = 'fix', // A bug fix + DOCS = 'docs', // Documentation only changes + STYLE = 'style', // Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) + REFACTOR = 'refactor', // A code change that neither fixes a bug nor adds a feature + PERF = 'perf', // A code change that improves performance + TEST = 'test', // Adding missing tests or correcting existing tests + BUILD = 'build', // Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) + CI = 'ci', // Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) + CHORE = 'chore', // Other changes that don't modify src or test files (use this sparingly) + REVERT = 'revert', // Reverts a previous commit + + // Custom types for MetaMask + BUMP = 'bump', // A version bump to dependencies + RELEASE = 'release', // A release commit, made on a release branch or to0 support the release process } + /** * Change categories. * @@ -58,6 +66,11 @@ export enum ChangeCategory { * For any changes that have yet to be categorized. */ Uncategorized = 'Uncategorized', + + /** + * For changes that should be excluded from the changelog. + */ + Excluded = 'Excluded', } /** @@ -78,3 +91,18 @@ export const orderedChangeCategories: ChangeCategory[] = [ * The header for the section of the changelog listing unreleased changes. */ export const unreleased = 'Unreleased'; + +/** + * Lowercase keywords that indicate a commit should be excluded from the changelog. + */ +export const keywordsToIndicateExcluded: string[] = [ + 'Bump main version to', + 'changelog', + 'cherry-pick', + 'cp-', + 'e2e', + 'flaky test', + 'INFRA-', + 'merge', + 'New Crowdin translations', +].map((word) => word.toLowerCase()); diff --git a/src/get-new-changes.ts b/src/get-new-changes.ts index 5ac0182..33dd6f6 100644 --- a/src/get-new-changes.ts +++ b/src/get-new-changes.ts @@ -1,13 +1,29 @@ +/* eslint-disable node/no-process-env */ + +import { Octokit } from '@octokit/rest'; import { strict as assert } from 'assert'; -import execa from 'execa'; + +import { ConventionalCommitType } from './constants'; +import { getOwnerAndRepoFromUrl } from './repo'; +import { runCommand, runCommandAndSplit } from './run-command'; + +let github: Octokit; export type AddNewCommitsOptions = { mostRecentTag: string | null; repoUrl: string; loggedPrNumbers: string[]; projectRootDirectory?: string; + useChangelogEntry: boolean; + useShortPrLink: boolean; }; +// Get array of all ConventionalCommitType values +const conventionalCommitTypes = Object.values(ConventionalCommitType); + +// Create a regex pattern that matches any of the ConventionalCommitTypes +const typesWithPipe = conventionalCommitTypes.join('|'); + /** * Get all commit hashes included in the given commit range. * @@ -23,45 +39,120 @@ async function getCommitHashesInRange( if (rootDirectory) { revListArgs.push(rootDirectory); } - return await runCommand('git', revListArgs); + return await runCommandAndSplit('git', revListArgs); +} + +/** + * Remove outer backticks if present in the given message. + * + * @param message - The changelog entry message. + * @returns The message without outer backticks. + */ +function removeOuterBackticksIfPresent(message: string) { + return message.replace(/^`(.*)`$/u, '$1'); +} + +/** + * Remove Conventional Commit prefix if it exists in the given message. + * + * @param message - The changelog entry message. + * @returns The message without Conventional Commit prefix. + */ +function removeConventionalCommitPrefixIfPresent(message: string) { + const regex = new RegExp(`^(${typesWithPipe})(\\([^)]*\\))?:\\s*`, 'iu'); + return message.replace(regex, ''); } /** * Get commit details for each given commit hash. * * @param commitHashes - The list of commit hashes. + * @param repoUrl - The repository URL. + * @param useChangelogEntry - Whether to use `CHANGELOG entry:` from the commit body and the no-changelog label. * @returns Commit details for each commit, including description and PR number (if present). */ -async function getCommits(commitHashes: string[]) { - const commits: { prNumber?: string; description: string }[] = []; +async function getCommits( + commitHashes: string[], + repoUrl: string, + useChangelogEntry: boolean, +) { + // Only initialize Octokit if we need to fetch PR labels + if (useChangelogEntry) { + initOctoKit(); + } + + const commits: { prNumber?: string; subject: string; description: string }[] = + []; for (const commitHash of commitHashes) { - const [subject] = await runCommand('git', [ + const subject = await runCommand('git', [ 'show', '-s', '--format=%s', commitHash, ]); + assert.ok( Boolean(subject), `"git show" returned empty subject for commit "${commitHash}".`, ); - let matchResults = subject.match(/\(#(\d+)\)/u); + const subjectMatch = subject.match(/\(#(\d+)\)/u); + let prNumber: string | undefined; let description = subject; - if (matchResults) { + if (subjectMatch) { // Squash & Merge: the commit subject is parsed as ` (#)` - prNumber = matchResults[1]; - description = subject.match(/^(.+)\s\(#\d+\)/u)?.[1] ?? ''; + prNumber = subjectMatch[1]; + + if (useChangelogEntry) { + const body = await runCommand('git', [ + 'show', + '-s', + '--format=%b', + commitHash, + ]); + + const changelogMatch = body.match(/\nCHANGELOG entry:\s(\S.+?)\n\n/su); + + if (changelogMatch) { + const changelogEntry = changelogMatch[1].replace('\n', ' '); + + description = changelogEntry; // This may be string 'null' to indicate no description + + if (description !== 'null') { + // Remove outer backticks if present. Example: `feat: new feature description` -> feat: new feature description + description = removeOuterBackticksIfPresent(description); + + // Remove Conventional Commit prefix if present. Example: feat: new feature description -> new feature description + description = removeConventionalCommitPrefixIfPresent(description); + + // Make description coming from `CHANGELOG entry:` start with an uppercase letter + description = + description.charAt(0).toUpperCase() + description.slice(1); + } + } else { + description = subject.match(/^(.+)\s\(#\d+\)/u)?.[1] ?? ''; + } + + if (description !== 'null') { + const prLabels = await getPrLabels(repoUrl, prNumber); + + if (prLabels.includes('no-changelog')) { + description = 'null'; // Has the no-changelog label, use string 'null' to indicate no description + } + } + } else { + description = subject.match(/^(.+)\s\(#\d+\)/u)?.[1] ?? ''; + } } else { // Merge: the PR ID is parsed from the git subject (which is of the form `Merge pull request // # from `, and the description is assumed to be the first line of the body. // If no body is found, the description is set to the commit subject - matchResults = subject.match(/#(\d+)\sfrom/u); - if (matchResults) { - prNumber = matchResults[1]; - const [firstLineOfBody] = await runCommand('git', [ + const mergeMatch = subject.match(/#(\d+)\sfrom/u); + if (mergeMatch) { + prNumber = mergeMatch[1]; + const [firstLineOfBody] = await runCommandAndSplit('git', [ 'show', '-s', '--format=%b', @@ -73,8 +164,12 @@ async function getCommits(commitHashes: string[]) { // Otherwise: // Normal commits: The commit subject is the description, and the PR ID is omitted. - commits.push({ prNumber, description }); + // String 'null' is used to indicate no description + if (description !== 'null') { + commits.push({ prNumber, subject, description }); + } } + return commits; } @@ -89,6 +184,8 @@ async function getCommits(commitHashes: string[]) { * filter results from various git commands. This path is assumed to be either * absolute, or relative to the current directory. Defaults to the root of the * current git repository. + * @param options.useChangelogEntry - Whether to use `CHANGELOG entry:` from the commit body and the no-changelog label. + * @param options.useShortPrLink - Whether to use short PR links in the changelog entries. * @returns A list of new change entries to add to the changelog, based on commits made since the last release. */ export async function getNewChangeEntries({ @@ -96,6 +193,8 @@ export async function getNewChangeEntries({ repoUrl, loggedPrNumbers, projectRootDirectory, + useChangelogEntry, + useShortPrLink, }: AddNewCommitsOptions) { const commitRange = mostRecentTag === null ? 'HEAD' : `${mostRecentTag}..HEAD`; @@ -103,32 +202,86 @@ export async function getNewChangeEntries({ commitRange, projectRootDirectory, ); - const commits = await getCommits(commitsHashesSinceLastRelease); + const commits = await getCommits( + commitsHashesSinceLastRelease, + repoUrl, + useChangelogEntry, + ); const newCommits = commits.filter( ({ prNumber }) => !prNumber || !loggedPrNumbers.includes(prNumber), ); - return newCommits.map(({ prNumber, description }) => { + return newCommits.map(({ prNumber, subject, description }) => { + // Handle the edge case where the PR description includes multiple changelog entries with this format: + // CHANGELOG entry: Added support to Solana tokens with multiplier (#509) + // CHANGELOG entry: Fix a bug that was causing to show spam Solana transactions in the activity list (#515) + // CHANGELOG entry: Fixed an issue that was causing to show an empty symbol instead of UNKNOWN in activity list for Solana tokens with no metadata (#517) + // This is not a supposed to happen, but we've seen engineers doing it already. + // Example PR on metamask-extension repo: (#35695) + let newDescription = description?.replace(/CHANGELOG entry: /gu, ''); + if (prNumber) { - const suffix = `([#${prNumber}](${repoUrl}/pull/${prNumber}))`; - return `${description} ${suffix}`; + const suffix = useShortPrLink + ? `(#${prNumber})` + : `([#${prNumber}](${repoUrl}/pull/${prNumber}))`; + + if (newDescription) { + const lines = newDescription.split('\n'); + lines[0] = `${lines[0]} ${suffix}`; // Append suffix to the first line (next lines are considered part of the description and ignored by the parsing logic) + newDescription = lines.join('\n'); + } else { + newDescription = suffix; + } } - return description; + + return { description: newDescription, subject }; }); } /** - * Executes a shell command in a child process and returns what it wrote to - * stdout, or rejects if the process exited with an error. + * Initialize the Octokit GitHub client with authentication token. + */ +function initOctoKit() { + if (!process.env.GITHUB_TOKEN) { + throw new Error('GITHUB_TOKEN environment variable is not set'); + } + + github = new Octokit({ auth: process.env.GITHUB_TOKEN }); +} + +/** + * Fetch labels for a pull request. * - * @param command - The command to run, e.g. "git". - * @param args - The arguments to the command. - * @returns An array of the non-empty lines returned by the command. + * @param repoUrl - The repository URL. + * @param prNumber - The pull request number. + * @returns A list of label names for the PR (empty array if not found or invalid). */ -async function runCommand(command: string, args: string[]): Promise { - return (await execa(command, [...args])).stdout - .trim() - .split('\n') - .filter((line) => line !== ''); +async function getPrLabels( + repoUrl: string, + prNumber: string, +): Promise { + if (!prNumber) { + return []; + } + + if (!github) { + initOctoKit(); + } + + const { owner, repo } = getOwnerAndRepoFromUrl(repoUrl); + + const { data: pullRequest } = await github.rest.pulls.get({ + owner, + repo, + // eslint-disable-next-line @typescript-eslint/naming-convention + pull_number: Number(prNumber), + }); + + if (pullRequest) { + const labels = pullRequest.labels.map((label: any) => label.name); + return labels; + } + + return []; } diff --git a/src/parse-changelog.test.ts b/src/parse-changelog.test.ts index 789ed52..0410404 100644 --- a/src/parse-changelog.test.ts +++ b/src/parse-changelog.test.ts @@ -4,6 +4,49 @@ import { parseChangelog } from './parse-changelog'; const outdent = _outdent({ trimTrailingNewline: false }); +const repoUrl = + 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository'; + +const COMMON_HEADER = outdent`# Changelog + All notable changes to this project will be documented in this file. + + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), + and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + ## [Unreleased] +`; + +const COMMON_REFERENCE_LINKS_1 = outdent`[Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/compare/v0.0.2...v1.0.0 +`; +const COMMON_REFERENCE_LINKS_3 = outdent`[Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/compare/v0.0.2...v1.0.0 + [0.0.2]: ${repoUrl}/compare/v0.0.1...v0.0.2 + [0.0.1]: ${repoUrl}/releases/tag/v0.0.1 +`; +const COMMON_REFERENCE_LINKS_4 = outdent`[Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/compare/v0.0.3...v1.0.0 + [0.0.3]: ${repoUrl}/compare/v0.0.2...v0.0.3 + [0.0.2]: ${repoUrl}/compare/v0.0.1...v0.0.2 + [0.0.1]: ${repoUrl}/releases/tag/v0.0.1 +`; + +/** + * Creates a changelog string with the given content, header, and reference links. + * + * @param header - The changelog header (default: COMMON_HEADER). + * @param content - The changelog content to include. + * @param reference - The reference links (default: COMMON_REFERENCE_LINKS). + * @returns The complete changelog string. + */ +function createChangelog( + header: string, + content: string, + reference: string, +): string { + return `${header}\n${content}\n${reference}`; +} + describe('parseChangelog', () => { it('should parse empty changelog', () => { const changelog = parseChangelog({ @@ -37,10 +80,9 @@ describe('parseChangelog', () => { ## [Unreleased] - [Unreleased]:https://github.com/ExampleUsernameOrOrganization/ExampleRepository/ + [Unreleased]:${repoUrl}/ `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleases()).toStrictEqual([]); @@ -57,10 +99,9 @@ describe('parseChangelog', () => { ## [Unreleased] - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/ + [Unreleased]: ${repoUrl}/ `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleases()).toStrictEqual([]); @@ -74,10 +115,9 @@ describe('parseChangelog', () => { ## [Unreleased] - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/ + [Unreleased]: ${repoUrl}/ `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleases()).toStrictEqual([]); @@ -107,13 +147,12 @@ describe('parseChangelog', () => { ### Changed - Something - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 - [0.0.2]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.1...v0.0.2 - [0.0.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v0.0.1 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/compare/v0.0.2...v1.0.0 + [0.0.2]: ${repoUrl}/compare/v0.0.1...v0.0.2 + [0.0.1]: ${repoUrl}/releases/tag/v0.0.1 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleases()).toStrictEqual([ @@ -189,10 +228,9 @@ describe('parseChangelog', () => { ### Changed - Something - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleases()).toStrictEqual([ @@ -253,13 +291,12 @@ describe('parseChangelog', () => { ### Changed - Something - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0-rc.1...HEAD - [1.0.0-rc.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2-beta.1...v1.0.0-rc.1 - [0.0.2-beta.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.1-alpha.1...v0.0.2-beta.1 - [0.0.1-alpha.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v0.0.1-alpha.1 + [Unreleased]: ${repoUrl}/compare/v1.0.0-rc.1...HEAD + [1.0.0-rc.1]: ${repoUrl}/compare/v0.0.2-beta.1...v1.0.0-rc.1 + [0.0.2-beta.1]: ${repoUrl}/compare/v0.0.1-alpha.1...v0.0.2-beta.1 + [0.0.1-alpha.1]: ${repoUrl}/releases/tag/v0.0.1-alpha.1 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleases()).toStrictEqual([ @@ -292,13 +329,12 @@ describe('parseChangelog', () => { ### Changed - Something - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 - [0.0.2]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.1...v0.0.2 - [0.0.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v0.0.1 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/compare/v0.0.2...v1.0.0 + [0.0.2]: ${repoUrl}/compare/v0.0.1...v0.0.2 + [0.0.1]: ${repoUrl}/releases/tag/v0.0.1 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleases()).toStrictEqual([ @@ -324,11 +360,10 @@ describe('parseChangelog', () => { - Something else Further explanation of changes - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ @@ -357,11 +392,10 @@ describe('parseChangelog', () => { - Something else - Further explanation of changes - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ @@ -391,11 +425,10 @@ describe('parseChangelog', () => { - Further explanation of changes - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ @@ -427,11 +460,10 @@ describe('parseChangelog', () => { ### Fixed - Not including newline between change categories as part of change entry - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ @@ -471,11 +503,10 @@ describe('parseChangelog', () => { ### Changed - Initial release - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ @@ -500,11 +531,10 @@ describe('parseChangelog', () => { ## [1.0.0] - 2020-01-01 ### Changed - Something else - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleases()).toStrictEqual([ @@ -530,13 +560,12 @@ describe('parseChangelog', () => { The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/ + [Unreleased]: ${repoUrl}/ `; expect(() => parseChangelog({ changelogContent: brokenChangelog, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }), ).toThrow('Failed to find Unreleased header'); }); @@ -554,8 +583,7 @@ describe('parseChangelog', () => { expect(() => parseChangelog({ changelogContent: brokenChangelog, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }), ).toThrow('Failed to find Unreleased link reference definition'); }); @@ -574,14 +602,13 @@ describe('parseChangelog', () => { ### Changed - Something else - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `; expect(() => parseChangelog({ changelogContent: brokenChangelog, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }), ).toThrow(`Unrecognized line: '## 1.0.0 - 2020-01-01'`); }); @@ -600,14 +627,13 @@ describe('parseChangelog', () => { ### Changed - Something else - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `; expect(() => parseChangelog({ changelogContent: brokenChangelog, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }), ).toThrow(`Malformed release header: '## [1.0.0 - 2020-01-01'`); }); @@ -626,14 +652,13 @@ describe('parseChangelog', () => { ### Changed - Something else - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.2.3.4...HEAD - [1.2.3.4]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.2.3.4 + [Unreleased]: ${repoUrl}/compare/v1.2.3.4...HEAD + [1.2.3.4]: ${repoUrl}/releases/tag/v1.2.3.4 `; expect(() => parseChangelog({ changelogContent: brokenChangelog, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }), ).toThrow(`Invalid SemVer version in release header: '## [1.2.3.4]`); }); @@ -652,14 +677,13 @@ describe('parseChangelog', () => { ### Changed - Something else - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `; expect(() => parseChangelog({ changelogContent: brokenChangelog, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }), ).toThrow(`Unrecognized line: '# [1.0.0] - 2020-01-01'`); }); @@ -677,14 +701,13 @@ describe('parseChangelog', () => { ## [1.0.0] - 2020-01-01 - Something else - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `; expect(() => parseChangelog({ changelogContent: brokenChangelog, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }), ).toThrow(`Category missing for change: '- Something else'`); }); @@ -703,14 +726,13 @@ describe('parseChangelog', () => { #### Changed - Something else - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `; expect(() => parseChangelog({ changelogContent: brokenChangelog, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }), ).toThrow(`Unrecognized line: '#### Changed'`); }); @@ -729,14 +751,13 @@ describe('parseChangelog', () => { ### Ch-Ch-Ch-Ch-Changes - Something else - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `; expect(() => parseChangelog({ changelogContent: brokenChangelog, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }), ).toThrow(`Malformed category header: '### Ch-Ch-Ch-Ch-Changes'`); }); @@ -755,14 +776,13 @@ describe('parseChangelog', () => { ### Invalid - Something else - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `; expect(() => parseChangelog({ changelogContent: brokenChangelog, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }), ).toThrow(`Invalid change category: 'Invalid'`); }); @@ -781,14 +801,13 @@ describe('parseChangelog', () => { ### Changed * Something else - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `; expect(() => parseChangelog({ changelogContent: brokenChangelog, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }), ).toThrow(`Unrecognized line: '* Something else'`); }); @@ -807,14 +826,13 @@ describe('parseChangelog', () => { ### Changed * Very very very very very very very very very very very very very very very very very very very very long line - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v1.0.0 + [Unreleased]: ${repoUrl}/compare/v1.0.0...HEAD + [1.0.0]: ${repoUrl}/releases/tag/v1.0.0 `; expect(() => parseChangelog({ changelogContent: brokenChangelog, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }), ).toThrow( `Unrecognized line: '* Very very very very very very very very very very very very very very very ver...'`, @@ -844,13 +862,12 @@ describe('parseChangelog', () => { ### Changed - Something - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/@metamask/test@1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/test@0.0.2...@metamask/test@1.0.0 - [0.0.2]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/test@0.0.1...test@0.0.2 - [0.0.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/test@0.0.1 + [Unreleased]: ${repoUrl}/compare/@metamask/test@1.0.0...HEAD + [1.0.0]: ${repoUrl}/compare/test@0.0.2...@metamask/test@1.0.0 + [0.0.2]: ${repoUrl}/compare/test@0.0.1...test@0.0.2 + [0.0.1]: ${repoUrl}/releases/tag/test@0.0.1 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + repoUrl, }); expect(changelog.getReleases()).toStrictEqual([ @@ -903,21 +920,14 @@ describe('parseChangelog', () => { expect(changelog.getUnreleasedChanges()).toStrictEqual({}); }); - describe('when shouldExtractPrLinks is true', () => { + describe('when shouldExtractPrLinks is true and changelog entry matches long pattern', () => { it('should parse changelog with pull request links after changelog entries', () => { - const changelog = parseChangelog({ - changelogContent: outdent` - # Changelog - All notable changes to this project will be documented in this file. - - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - - ## [Unreleased] - + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` ## [1.0.0] ### Changed - - Change something else ([#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100), [#200](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/200)) + - Change something else ([#100](${repoUrl}/pull/100), [#200](${repoUrl}/pull/200)) ## [0.0.2] ### Fixed @@ -925,15 +935,14 @@ describe('parseChangelog', () => { ## [0.0.1] ### Added - - Initial release ([#456](anything goes here actually)) - - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 - [0.0.2]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.1...v0.0.2 - [0.0.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v0.0.1 + - Initial release ([#456](a PR link to the right repo is needed here, otherwise it will be ignored)) `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + COMMON_REFERENCE_LINKS_3, + ); + + const changelog = parseChangelog({ + changelogContent, + repoUrl, shouldExtractPrLinks: true, }); @@ -948,34 +957,29 @@ describe('parseChangelog', () => { expect(changelog.getReleaseChanges('0.0.1')).toStrictEqual({ Added: [ { - description: 'Initial release', - prNumbers: ['456'], + description: + 'Initial release ([#456](a PR link to the right repo is needed here, otherwise it will be ignored))', + prNumbers: [], }, ], }); }); it('should parse changelog with pull request links at end of first line of multi-line change description', () => { - const changelog = parseChangelog({ - changelogContent: outdent` - # Changelog - All notable changes to this project will be documented in this file. - - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - - ## [Unreleased] - + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` ## [1.0.0] ### Changed - - Change something ([#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100), [#200](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/200)) + - Change something ([#100](${repoUrl}/pull/100), [#200](${repoUrl}/pull/200)) This is a cool change, you will really like it. - - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + COMMON_REFERENCE_LINKS_1, + ); + + const changelog = parseChangelog({ + changelogContent, + repoUrl, shouldExtractPrLinks: true, }); @@ -991,26 +995,19 @@ describe('parseChangelog', () => { }); it('should parse changelog with pull request links at end of first line of change description with sub-bullets', () => { - const changelog = parseChangelog({ - changelogContent: outdent` - # Changelog - All notable changes to this project will be documented in this file. - - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - - ## [Unreleased] - + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` ## [1.0.0] ### Changed - - Change something ([#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100), [#200](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/200)) + - Change something ([#100](${repoUrl}/pull/100), [#200](${repoUrl}/pull/200)) - This is a cool change, you will really like it. - - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + COMMON_REFERENCE_LINKS_1, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, shouldExtractPrLinks: true, }); @@ -1026,34 +1023,26 @@ describe('parseChangelog', () => { }); it('should preserve links within sub-bullets', () => { - const changelog = parseChangelog({ - changelogContent: outdent` - # Changelog - All notable changes to this project will be documented in this file. - - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - - ## [Unreleased] - + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` ## [1.0.0] ### Changed - Change something - - This is a cool change, you will really like it. ([#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100)) - - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 + - This is a cool change, you will really like it. ([#100](${repoUrl}/pull/100)) `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + COMMON_REFERENCE_LINKS_1, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, shouldExtractPrLinks: true, }); expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ Changed: [ { - description: - 'Change something\n - This is a cool change, you will really like it. ([#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100))', + description: `Change something\n - This is a cool change, you will really like it. ([#100](${repoUrl}/pull/100))`, prNumbers: [], }, ], @@ -1061,25 +1050,18 @@ describe('parseChangelog', () => { }); it('should parse changelog with pull request links somewhere within entry, not just at end', () => { - const changelog = parseChangelog({ - changelogContent: outdent` - # Changelog - All notable changes to this project will be documented in this file. - - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - - ## [Unreleased] - + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` ## [1.0.0] ### Changed - - Change something ([#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100), [#200](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/200)). And something else. - - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 + - Change something ([#100](${repoUrl}/pull/100), [#200](${repoUrl}/pull/200)). And something else. `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + COMMON_REFERENCE_LINKS_1, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, shouldExtractPrLinks: true, }); @@ -1094,25 +1076,70 @@ describe('parseChangelog', () => { }); it('should combine multiple pull request lists', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` + ## [1.0.0] + ### Changed + - Change something ([#100](${repoUrl}/pull/100), [#200](${repoUrl}/pull/200)) ([#300](${repoUrl}/pull/300)) + `, + COMMON_REFERENCE_LINKS_1, + ); const changelog = parseChangelog({ - changelogContent: outdent` - # Changelog - All notable changes to this project will be documented in this file. - - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + changelogContent, + repoUrl, + shouldExtractPrLinks: true, + }); - ## [Unreleased] + expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ + Changed: [ + { + description: 'Change something', + prNumbers: ['100', '200', '300'], + }, + ], + }); + }); + it('should de-duplicate pull request links in same list', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` ## [1.0.0] ### Changed - - Change something ([#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100), [#200](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/200)) ([#300](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/300)) + - Change something ([#100](${repoUrl}/pull/100), [#100](${repoUrl}/pull/100)) + `, + COMMON_REFERENCE_LINKS_1, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, + shouldExtractPrLinks: true, + }); + + expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ + Changed: [ + { + description: 'Change something', + prNumbers: ['100'], + }, + ], + }); + }); - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 + it('should de-duplicate pull request links in separate lists', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` + ## [1.0.0] + ### Changed + - Change something ([#100](${repoUrl}/pull/100)) ([#100](${repoUrl}/pull/100)) `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + COMMON_REFERENCE_LINKS_1, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, shouldExtractPrLinks: true, }); @@ -1120,65 +1147,271 @@ describe('parseChangelog', () => { Changed: [ { description: 'Change something', - prNumbers: ['100', '200', '300'], + prNumbers: ['100'], }, ], }); }); - it('should de-duplicate pull request links in same list', () => { + it('should preserve non-pull request links or malformed link syntax after changelog entries as part of the entry text itself', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` + ## [1.0.0] + ### Changed + - Change something else ([123](${repoUrl})) + + ## [0.0.3] + ### Deprecated + - Deprecate whatever([#123](${repoUrl})) + + ## [0.0.2] + ### Fixed + - Fix something + + ## [0.0.1] + ### Added + - Initial release ([#789](https://example.com) + `, + COMMON_REFERENCE_LINKS_4, + ); const changelog = parseChangelog({ - changelogContent: outdent` - # Changelog - All notable changes to this project will be documented in this file. + changelogContent, + repoUrl, + shouldExtractPrLinks: true, + }); + + expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ + Changed: [ + { + // Missing '#' + description: `Change something else ([123](${repoUrl}))`, + prNumbers: [], + }, + ], + }); + expect(changelog.getReleaseChanges('0.0.3')).toStrictEqual({ + Deprecated: [ + { + // Missing space before link + description: `Deprecate whatever([#123](${repoUrl}))`, + prNumbers: [], + }, + ], + }); + expect(changelog.getReleaseChanges('0.0.2')).toStrictEqual({ + Fixed: [ + { + // Missing link + description: 'Fix something', + prNumbers: [], + }, + ], + }); + expect(changelog.getReleaseChanges('0.0.1')).toStrictEqual({ + Added: [ + { + // Incorrect URL + description: 'Initial release ([#789](https://example.com)', + prNumbers: [], + }, + ], + }); + }); + }); - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + describe('when shouldExtractPrLinks is true and changelog entry matches short pattern', () => { + it('should parse changelog with pull request links after changelog entries', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` + ## [1.0.0] + ### Changed + - Change something else (#100), (#200) + + ## [0.0.2] + ### Fixed + - Fix something + + ## [0.0.1] + ### Added + - Initial release (#456) + `, + COMMON_REFERENCE_LINKS_3, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, + shouldExtractPrLinks: true, + }); - ## [Unreleased] + expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ + Changed: [ + { + description: 'Change something else', + prNumbers: ['100', '200'], + }, + ], + }); + expect(changelog.getReleaseChanges('0.0.1')).toStrictEqual({ + Added: [ + { + description: 'Initial release', + prNumbers: ['456'], + }, + ], + }); + }); + it('should parse changelog with pull request links at end of first line of multi-line change description', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` ## [1.0.0] ### Changed - - Change something ([#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100), [#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100)) + - Change something (#100), (#200) + This is a cool change, you will really like it. + `, + COMMON_REFERENCE_LINKS_1, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, + shouldExtractPrLinks: true, + }); + + expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ + Changed: [ + { + description: + 'Change something\nThis is a cool change, you will really like it.', + prNumbers: ['100', '200'], + }, + ], + }); + }); - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 + it('should parse changelog with pull request links at end of first line of change description with sub-bullets', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` + ## [1.0.0] + ### Changed + - Change something (#100), (#200) + - This is a cool change, you will really like it. `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + COMMON_REFERENCE_LINKS_1, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, shouldExtractPrLinks: true, }); expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ Changed: [ { - description: 'Change something', - prNumbers: ['100'], + description: + 'Change something\n - This is a cool change, you will really like it.', + prNumbers: ['100', '200'], }, ], }); }); - it('should de-duplicate pull request links in separate lists', () => { + it('should preserve links within sub-bullets', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` + ## [1.0.0] + ### Changed + - Change something + - This is a cool change, you will really like it. (#100) + `, + COMMON_REFERENCE_LINKS_1, + ); const changelog = parseChangelog({ - changelogContent: outdent` - # Changelog - All notable changes to this project will be documented in this file. + changelogContent, + repoUrl, + shouldExtractPrLinks: true, + }); - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ + Changed: [ + { + description: + 'Change something\n - This is a cool change, you will really like it. (#100)', + prNumbers: [], + }, + ], + }); + }); - ## [Unreleased] + it('should parse changelog with pull request links somewhere within entry, not just at end', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` + ## [1.0.0] + ### Changed + - Change something (#100), (#200). And something else. + `, + COMMON_REFERENCE_LINKS_1, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, + shouldExtractPrLinks: true, + }); + + expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ + Changed: [ + { + description: 'Change something. And something else.', + prNumbers: ['100', '200'], + }, + ], + }); + }); + it('should combine multiple pull request lists', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` ## [1.0.0] ### Changed - - Change something ([#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100)) ([#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100)) + - Change something (#100), (#200) (#300) + `, + COMMON_REFERENCE_LINKS_1, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, + shouldExtractPrLinks: true, + }); - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 + expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ + Changed: [ + { + description: 'Change something', + prNumbers: ['100', '200', '300'], + }, + ], + }); + }); + + it('should de-duplicate pull request links in same list', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` + ## [1.0.0] + ### Changed + - Change something (#100), (#100) `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + COMMON_REFERENCE_LINKS_1, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, shouldExtractPrLinks: true, }); @@ -1192,24 +1425,43 @@ describe('parseChangelog', () => { }); }); - it('should preserve non-pull request links or malformed link syntax after changelog entries as part of the entry text itself', () => { + it('should de-duplicate pull request links in separate lists', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` + ## [1.0.0] + ### Changed + - Change something (#100) (#100) + `, + COMMON_REFERENCE_LINKS_1, + ); const changelog = parseChangelog({ - changelogContent: outdent` - # Changelog - All notable changes to this project will be documented in this file. - - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + changelogContent, + repoUrl, + shouldExtractPrLinks: true, + }); - ## [Unreleased] + expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ + Changed: [ + { + description: 'Change something', + prNumbers: ['100'], + }, + ], + }); + }); + it('should preserve non-pull request links or malformed link syntax after changelog entries as part of the entry text itself', () => { + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` ## [1.0.0] ### Changed - - Change something else ([123](https://github.com/ExampleUsernameOrOrganization/ExampleRepository)) + - Change something else (123) ## [0.0.3] ### Deprecated - - Deprecate whatever([#123](https://github.com/ExampleUsernameOrOrganization/ExampleRepository)) + - Deprecate whatever(#123) ## [0.0.2] ### Fixed @@ -1217,16 +1469,13 @@ describe('parseChangelog', () => { ## [0.0.1] ### Added - - Initial release ([#789](https://example.com) - - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.3...v1.0.0 - [0.0.3]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v0.0.3 - [0.0.2]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.1...v0.0.2 - [0.0.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v0.0.1 + - Initial release (#789 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + COMMON_REFERENCE_LINKS_4, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, shouldExtractPrLinks: true, }); @@ -1234,8 +1483,7 @@ describe('parseChangelog', () => { Changed: [ { // Missing '#' - description: - 'Change something else ([123](https://github.com/ExampleUsernameOrOrganization/ExampleRepository))', + description: 'Change something else (123)', prNumbers: [], }, ], @@ -1244,8 +1492,7 @@ describe('parseChangelog', () => { Deprecated: [ { // Missing space before link - description: - 'Deprecate whatever([#123](https://github.com/ExampleUsernameOrOrganization/ExampleRepository))', + description: 'Deprecate whatever(#123)', prNumbers: [], }, ], @@ -1263,7 +1510,7 @@ describe('parseChangelog', () => { Added: [ { // Incorrect URL - description: 'Initial release ([#789](https://example.com)', + description: 'Initial release (#789', prNumbers: [], }, ], @@ -1273,19 +1520,12 @@ describe('parseChangelog', () => { describe('when shouldExtractPrLinks is false', () => { it('should not parse pull request links after changelog entries specially', () => { - const changelog = parseChangelog({ - changelogContent: outdent` - # Changelog - All notable changes to this project will be documented in this file. - - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - - ## [Unreleased] - + const changelogContent = createChangelog( + COMMON_HEADER, + outdent` ## [1.0.0] ### Changed - - Change something else ([#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100), [#200](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/200)) + - Change something else ([#100](${repoUrl}/pull/100), [#200](${repoUrl}/pull/200)) ## [0.0.2] ### Fixed @@ -1294,22 +1534,19 @@ describe('parseChangelog', () => { ## [0.0.1] ### Added - Initial release ([#456](anything goes here actually)) - - [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v1.0.0...HEAD - [1.0.0]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.2...v1.0.0 - [0.0.2]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/compare/v0.0.1...v0.0.2 - [0.0.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v0.0.1 `, - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + COMMON_REFERENCE_LINKS_3, + ); + const changelog = parseChangelog({ + changelogContent, + repoUrl, shouldExtractPrLinks: false, }); expect(changelog.getReleaseChanges('1.0.0')).toStrictEqual({ Changed: [ { - description: - 'Change something else ([#100](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/100), [#200](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/200))', + description: `Change something else ([#100](${repoUrl}/pull/100), [#200](${repoUrl}/pull/200))`, prNumbers: [], }, ], diff --git a/src/parse-changelog.ts b/src/parse-changelog.ts index d037672..f434707 100644 --- a/src/parse-changelog.ts +++ b/src/parse-changelog.ts @@ -24,6 +24,17 @@ function isValidChangeCategory(category: string): category is ChangeCategory { return ChangeCategory[category as ChangeCategory] !== undefined; } +/** + * Returns the repository name from a GitHub repository URL. + * + * @param repoUrl - The URL for the GitHub repository. + * @returns The repository name, or null if it could not be determined. + */ +function extractRepoName(repoUrl: string): string { + const match = repoUrl?.match(/github\.com\/[^/]+\/([^/]+)/u); // Match and capture the repo name + return match ? match[1] : ''; +} + /** * Constructs a Changelog instance that represents the given changelog, which * is parsed for release and change information. @@ -61,6 +72,7 @@ export function parseChangelog({ formatter, packageRename, }); + const repoName = extractRepoName(repoUrl); const unreleasedHeaderIndex = changelogLines.indexOf(`## [${unreleased}]`); if (unreleasedHeaderIndex === -1) { @@ -115,7 +127,7 @@ export function parseChangelog({ } const { description, prNumbers } = shouldExtractPrLinks - ? extractPrLinks(currentChangeEntry) + ? extractPrLinks(currentChangeEntry, repoName) : { description: currentChangeEntry, prNumbers: [], @@ -207,60 +219,80 @@ export function parseChangelog({ * changelog). * * @param changeEntry - The text of the change entry. + * @param repoName - The name of the GitHub repository. * @returns The list of pull request numbers referenced by the change entry, and * the change entry without the links to those pull requests. */ -function extractPrLinks(changeEntry: string): { +function extractPrLinks( + changeEntry: string, + repoName: string, +): { description: string; prNumbers: string[]; } { - const [firstLine, ...otherLines] = changeEntry.split('\n'); - const parentheticalMatches = firstLine.matchAll(/[ ]\((\[.+?\]\(.+?\))\)/gu); + const lines = changeEntry.split('\n'); const prNumbers: string[] = []; - let startIndex = Infinity; - let endIndex = 0; - for (const parentheticalMatch of parentheticalMatches) { - const { index } = parentheticalMatch; - const wholeMatch = parentheticalMatch[0]; - const parts = wholeMatch.split(/,[ ]?/u); + // Only process the first line for PR link extraction + let firstLine = lines[0]; - for (const part of parts) { - const regexp = /\[#(\d+)\]\([^()]+\)/u; - const match = part.match(regexp); - if (match !== null) { - const prNumber = match[1]; - prNumbers.push(prNumber); - } - } + // We only extract PR links from the right repo, because it happens that some + // changelog entries include links to PRs from other repos like packages that were bumped. + // We don't want to accidentally extract those. - if (index === undefined) { - throw new Error('Could not find index. This should not happen.'); - } + // eslint-disable-next-line prefer-regex-literals + const longGroupMatchPattern = new RegExp( + // Example of long match group: " ([#123](...), [#456](...))" + `\\s+\\(\\s*(\\[#\\d+\\]\\([^)]*${repoName}[^)]*\\)\\s*,?\\s*)+\\)`, + 'gu', + ); - if (index < startIndex) { - startIndex = index; - } + // eslint-disable-next-line prefer-regex-literals + const longMatchPattern = new RegExp( + // Example of long match: "[#123](...)" + `\\[#(\\d+)\\]\\([^)]*${repoName}[^)]*\\)`, + 'gu', + ); - if (index + wholeMatch.length > endIndex) { - endIndex = index + wholeMatch.length; + // Match and extract all long PR links like ([#123](...)) separated by commas + const longGroupMatches = [...firstLine.matchAll(longGroupMatchPattern)]; + for (const longGroupMatch of longGroupMatches) { + const group = longGroupMatch[0]; + const longMatches = [...group.matchAll(longMatchPattern)]; + for (const match of longMatches) { + prNumbers.push(match[1]); } } - const uniquePrNumbers = [...new Set(prNumbers)]; + // Remove valid long PR links (grouped in parentheses, possibly comma-separated) + firstLine = firstLine.replace(longGroupMatchPattern, ''); + + // Example of short match group: " (#123), (#123)" + const shortGroupMatchPattern = /\s+(\(#\d+\)\s*,?\s*)+/gu; - if (prNumbers.length > 0) { - const firstLineWithoutPrLinks = - firstLine.slice(0, startIndex) + firstLine.slice(endIndex); + // Example of short match: "(#123)" + const shortMatchPattern = /\(#(\d+)\)/gu; - return { - description: [firstLineWithoutPrLinks, ...otherLines].join('\n'), - prNumbers: uniquePrNumbers, - }; + // Match and extract all short PR links like (#123) + const shortGroupMatches = [...firstLine.matchAll(shortGroupMatchPattern)]; + for (const shortGroupMatch of shortGroupMatches) { + const group = shortGroupMatch[0]; + const shortMatches = [...group.matchAll(shortMatchPattern)]; + for (const match of shortMatches) { + prNumbers.push(match[1]); + } } + // Remove valid short PR links + firstLine = firstLine.replace(shortGroupMatchPattern, ''); + + // Prepare the cleaned description + const cleanedLines = [firstLine.trim(), ...lines.slice(1)]; + const cleanedDescription = cleanedLines.join('\n'); + + // Return unique PR numbers and the cleaned description return { - description: changeEntry, - prNumbers: [], + description: cleanedDescription.trim(), + prNumbers: [...new Set(prNumbers)], }; } diff --git a/src/repo.ts b/src/repo.ts index 90c84aa..d0b67cd 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -42,3 +42,27 @@ export function getRepositoryUrl(): string | null { return null; } + +/** + * Extract the owner and repository name from a GitHub repository URL. + * + * Supports HTTPS and SSH GitHub URLs and removes any trailing .git; throws if parsing fails. + * + * @param repoUrl - The full GitHub repository URL (e.g., https://github.com/owner/repo or git@github.com:owner/repo). + * @returns An object containing the owner and repo name. + * @throws If the URL cannot be parsed. + */ +export function getOwnerAndRepoFromUrl(repoUrl: string): { + owner: string; + repo: string; +} { + const match = repoUrl.match( + /github\.com[:/](?[^/]+)\/(?[^/]+)$/iu, + ); + + if (!match?.groups) { + throw new Error(`Cannot parse owner/repo from repoUrl: ${repoUrl}`); + } + + return { owner: match.groups.owner, repo: match.groups.repo }; +} diff --git a/src/run-command.ts b/src/run-command.ts new file mode 100644 index 0000000..cfda331 --- /dev/null +++ b/src/run-command.ts @@ -0,0 +1,35 @@ +import execa from 'execa'; + +/** + * Executes a shell command in a child process and returns what it wrote to + * stdout, or rejects if the process exited with an error. + * + * @param command - The command to run, e.g. "git". + * @param args - The arguments to the command. + * @returns An array of the non-empty lines returned by the command. + */ +export async function runCommand( + command: string, + args: string[], +): Promise { + return (await execa(command, [...args])).stdout; +} + +/** + * Executes a shell command in a child process and returns what it wrote to + * stdout, or rejects if the process exited with an error. + * Trims, splits the output by newlines, and filters out empty lines. + * + * @param command - The command to run, e.g. "git". + * @param args - The arguments to the command. + * @returns An array of the non-empty lines returned by the command. + */ +export async function runCommandAndSplit( + command: string, + args: string[], +): Promise { + return (await execa(command, [...args])).stdout + .trim() + .split('\n') + .filter((line) => line !== ''); +} diff --git a/src/update-changelog.test.ts b/src/update-changelog.test.ts index ad65e18..6a9e275 100644 --- a/src/update-changelog.test.ts +++ b/src/update-changelog.test.ts @@ -14,22 +14,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Unreleased]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/ `; +const getNewChangeEntriesMockData = [ + { + description: 'Fixed a critical bug (#123)', + subject: 'fix: Fixed a critical bug (#123)', + }, + { + description: 'New cool feature (#124)', + subject: 'feat: New cool feature (#124)', + }, + { + description: 'Release thingy (#124)', + subject: 'release: Release thingy (#124)', + }, +]; + +const changelogData = { + changelogContent: emptyChangelog, + currentVersion: '1.0.0', + repoUrl: 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', + isReleaseCandidate: true, + autoCategorize: true, + useChangelogEntry: false, + useShortPrLink: true, +}; + describe('updateChangelog', () => { it('should contain conventional support mappings categorization when autoCategorize is true', async () => { - // Set up the spy and mock the implementation if needed jest .spyOn(ChangeLogUtils, 'getNewChangeEntries') - .mockResolvedValue([ - 'fix: Fixed a critical bug', - 'feat: Added new feature [PR#123](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/123)', - ]); + .mockResolvedValue(getNewChangeEntriesMockData); const result = await ChangeLogManager.updateChangelog({ - changelogContent: emptyChangelog, - currentVersion: '1.0.0', - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', - isReleaseCandidate: true, + ...changelogData, autoCategorize: true, }); @@ -39,20 +56,12 @@ describe('updateChangelog', () => { }); it('should not contain conventional support mappings categorization when autoCategorize is false', async () => { - // Set up the spy and mock the implementation if needed jest .spyOn(ChangeLogUtils, 'getNewChangeEntries') - .mockResolvedValue([ - 'fix: Fixed a critical bug', - 'feat: Added new feature [PR#123](https://github.com/ExampleUsernameOrOrganization/ExampleRepository/pull/123)', - ]); + .mockResolvedValue(getNewChangeEntriesMockData); const result = await ChangeLogManager.updateChangelog({ - changelogContent: emptyChangelog, - currentVersion: '1.0.0', - repoUrl: - 'https://github.com/ExampleUsernameOrOrganization/ExampleRepository', - isReleaseCandidate: true, + ...changelogData, autoCategorize: false, }); @@ -60,6 +69,20 @@ describe('updateChangelog', () => { expect(result).not.toContain('### Fixed'); expect(result).not.toContain('### Added'); }); + + it('should support useChangelogEntry=true', async () => { + jest + .spyOn(ChangeLogUtils, 'getNewChangeEntries') + .mockResolvedValue(getNewChangeEntriesMockData); + + const result = await ChangeLogManager.updateChangelog({ + ...changelogData, + useChangelogEntry: true, + }); + + expect(result).toContain('### Added\n- New cool feature (#124)'); + expect(result).toContain('### Fixed\n- Fixed a critical bug (#123)'); + }); }); describe('getCategory', () => { diff --git a/src/update-changelog.ts b/src/update-changelog.ts index 9c44426..3000d72 100644 --- a/src/update-changelog.ts +++ b/src/update-changelog.ts @@ -1,10 +1,14 @@ -import execa from 'execa'; - import type Changelog from './changelog'; import { Formatter, getKnownPropertyNames } from './changelog'; -import { ChangeCategory, Version, ConventionalCommitType } from './constants'; +import { + ChangeCategory, + ConventionalCommitType, + Version, + keywordsToIndicateExcluded, +} from './constants'; import { getNewChangeEntries } from './get-new-changes'; import { parseChangelog } from './parse-changelog'; +import { runCommandAndSplit } from './run-command'; import { PackageRename } from './shared-types'; /** @@ -21,7 +25,7 @@ async function getMostRecentTag({ tagPrefixes: [string, ...string[]]; }) { // Ensure we have all tags on remote - await runCommand('git', ['fetch', '--tags']); + await runCommandAndSplit('git', ['fetch', '--tags']); let mostRecentTagCommitHash: string | null = null; for (const tagPrefix of tagPrefixes) { @@ -31,7 +35,7 @@ async function getMostRecentTag({ '--max-count=1', '--date-order', ]; - const results = await runCommand('git', revListArgs); + const results = await runCommandAndSplit('git', revListArgs); if (results.length) { mostRecentTagCommitHash = results[0]; break; @@ -41,7 +45,7 @@ async function getMostRecentTag({ if (mostRecentTagCommitHash === null) { return null; } - const [mostRecentTag] = await runCommand('git', [ + const [mostRecentTag] = await runCommandAndSplit('git', [ 'describe', '--tags', mostRecentTagCommitHash, @@ -89,6 +93,14 @@ export type UpdateChangelogOptions = { * The package rename properties, used in case of package is renamed */ packageRename?: PackageRename; + /** + * Whether to use `CHANGELOG entry:` from the commit body and the no-changelog label + */ + useChangelogEntry: boolean; + /** + * Whether to use short PR links in the changelog entries. + */ + useShortPrLink: boolean; }; /** @@ -115,6 +127,8 @@ export type UpdateChangelogOptions = { * An optional, which is required only in case of package renamed. * @param options.autoCategorize - A flag indicating whether changes should be auto-categorized * based on commit message prefixes. + * @param options.useChangelogEntry - Whether to use `CHANGELOG entry:` from the commit body and the no-changelog label. + * @param options.useShortPrLink - Whether to use short PR links in the changelog. * @returns The updated changelog text. */ export async function updateChangelog({ @@ -127,6 +141,8 @@ export async function updateChangelog({ formatter = undefined, packageRename, autoCategorize, + useChangelogEntry, + useShortPrLink, }: UpdateChangelogOptions): Promise { const changelog = parseChangelog({ changelogContent, @@ -134,6 +150,7 @@ export async function updateChangelog({ tagPrefix: tagPrefixes[0], formatter, packageRename, + shouldExtractPrLinks: true, // By setting this to true, we ensure we don't re-add a PR to the changelog if it was already added in previous releases }); const mostRecentTag = await getMostRecentTag({ @@ -173,40 +190,29 @@ export async function updateChangelog({ repoUrl, loggedPrNumbers: getAllLoggedPrNumbers(changelog), projectRootDirectory, + useChangelogEntry, + useShortPrLink, }); - for (const description of newChangeEntries.reverse()) { + for (const entry of newChangeEntries.reverse()) { const category = autoCategorize - ? getCategory(description) + ? getCategory(entry.subject) : ChangeCategory.Uncategorized; - changelog.addChange({ - version: isReleaseCandidate ? currentVersion : undefined, - category, - description, - }); + if (category !== ChangeCategory.Excluded) { + changelog.addChange({ + version: isReleaseCandidate ? currentVersion : undefined, + category, + description: entry.description, + }); + } } - const newChangelogContent = await changelog.toString(); + const newChangelogContent = await changelog.toString(useShortPrLink); const isChangelogUpdated = changelogContent !== newChangelogContent; return isChangelogUpdated ? newChangelogContent : undefined; } -/** - * Executes a shell command in a child process and returns what it wrote to - * stdout, or rejects if the process exited with an error. - * - * @param command - The command to run, e.g. "git". - * @param args - The arguments to the command. - * @returns An array of the non-empty lines returned by the command. - */ -async function runCommand(command: string, args: string[]): Promise { - return (await execa(command, [...args])).stdout - .trim() - .split('\n') - .filter((line) => line !== ''); -} - /** * Determine the category of a change based on the commit message prefix. * @@ -214,16 +220,39 @@ async function runCommand(command: string, args: string[]): Promise { * @returns The category of the change. */ export function getCategory(description: string): ChangeCategory { - const conventionalCommitPattern = /^(feat|fix)(?:\([^)]+\))?\s*:\s*/u; + // Check whether the commit description includes exclusion keywords + if (checkIfDescriptionIndicatesExcluded(description)) { + return ChangeCategory.Excluded; + } + + // Get array of all ConventionalCommitType values + const conventionalCommitTypes = Object.values(ConventionalCommitType); + + // Create a regex pattern that matches any of the ConventionalCommitTypes + const typesWithPipe = conventionalCommitTypes.join('|'); + const conventionalCommitPattern = new RegExp( + `^(${typesWithPipe})\\s*(\\([^)]*\\))?:.*$`, + 'ui', + ); + const match = description.match(conventionalCommitPattern); if (match) { - const prefix = match.length > 1 ? match[1] : undefined; + const prefix = match[1]?.toLowerCase(); // Always use lowercase for consistency switch (prefix) { - case ConventionalCommitType.Feat: + case ConventionalCommitType.FEAT: return ChangeCategory.Added; - case ConventionalCommitType.Fix: + case ConventionalCommitType.FIX: return ChangeCategory.Fixed; + // Begin categories that should be excluded from the changelog + case ConventionalCommitType.STYLE: + case ConventionalCommitType.REFACTOR: + case ConventionalCommitType.TEST: + case ConventionalCommitType.BUILD: + case ConventionalCommitType.CI: + case ConventionalCommitType.RELEASE: + return ChangeCategory.Excluded; + // End categories that should be excluded from the changelog default: return ChangeCategory.Uncategorized; } @@ -231,3 +260,15 @@ export function getCategory(description: string): ChangeCategory { // Return 'Uncategorized' if no colon is found or prefix doesn't match return ChangeCategory.Uncategorized; } + +/** + * Check whether the commit description includes exclusion keywords. + * + * @param description - The raw or processed commit description. + * @returns True if the description contains any exclusion keywords; otherwise false. + */ +function checkIfDescriptionIndicatesExcluded(description: string): boolean { + const _description = description.toLowerCase(); + + return keywordsToIndicateExcluded.some((word) => _description.includes(word)); +} diff --git a/src/validate-changelog.test.ts b/src/validate-changelog.test.ts index d2bf680..2a731d5 100644 --- a/src/validate-changelog.test.ts +++ b/src/validate-changelog.test.ts @@ -806,6 +806,8 @@ describe('validateChangelog', () => { [0.0.1]: https://github.com/ExampleUsernameOrOrganization/ExampleRepository/releases/tag/v0.0.1 `; + // The links to other repos are not recognized as PR links, which is why in this example + // the error is about 'missing PR link', not invalid one. await expect( validateChangelog({ changelogContent, @@ -815,7 +817,9 @@ describe('validateChangelog', () => { isReleaseCandidate: false, ensureValidPrLinksPresent: true, }), - ).rejects.toThrow('Changelog is not well-formatted'); + ).rejects.toThrow( + `Pull request link(s) missing for change: 'Fix something ([#123](https://github.com/foo/bar/pull/123))' (in 0.0.2)`, + ); }); }); diff --git a/yarn.lock b/yarn.lock index dbaa301..3abb588 100644 --- a/yarn.lock +++ b/yarn.lock @@ -842,10 +842,12 @@ __metadata: "@metamask/eslint-config-jest": ^11.1.0 "@metamask/eslint-config-nodejs": ^11.1.0 "@metamask/eslint-config-typescript": ^11.1.0 + "@octokit/rest": ^20.0.0 "@ts-bridge/cli": ^0.6.3 "@types/cross-spawn": ^6.0.2 "@types/diff": ^5.0.0 "@types/jest": ^26.0.23 + "@types/node": ^22.18.0 "@types/semver": ^7.3.6 "@types/yargs": ^16.0.1 "@typescript-eslint/eslint-plugin": ^5.42.1 @@ -1000,6 +1002,131 @@ __metadata: languageName: node linkType: hard +"@octokit/auth-token@npm:^4.0.0": + version: 4.0.0 + resolution: "@octokit/auth-token@npm:4.0.0" + checksum: d78f4dc48b214d374aeb39caec4fdbf5c1e4fd8b9fcb18f630b1fe2cbd5a880fca05445f32b4561f41262cb551746aeb0b49e89c95c6dd99299706684d0cae2f + languageName: node + linkType: hard + +"@octokit/core@npm:^5.0.2": + version: 5.2.2 + resolution: "@octokit/core@npm:5.2.2" + dependencies: + "@octokit/auth-token": ^4.0.0 + "@octokit/graphql": ^7.1.0 + "@octokit/request": ^8.4.1 + "@octokit/request-error": ^5.1.1 + "@octokit/types": ^13.0.0 + before-after-hook: ^2.2.0 + universal-user-agent: ^6.0.0 + checksum: d4303d808c6b8eca32ce03381db5f6230440c1c6cfd9d73376ed583973094abd8ca56d9a64d490e6b0045f827a8f913b619bd90eae99c2cba682487720dc8002 + languageName: node + linkType: hard + +"@octokit/endpoint@npm:^9.0.6": + version: 9.0.6 + resolution: "@octokit/endpoint@npm:9.0.6" + dependencies: + "@octokit/types": ^13.1.0 + universal-user-agent: ^6.0.0 + checksum: f853c08f0777a8cc7c3d2509835d478e11a76d722f807d4f2ad7c0e64bf4dd159536409f466b367a907886aa3b78574d3d09ed95ac462c769e4fccaaad81e72a + languageName: node + linkType: hard + +"@octokit/graphql@npm:^7.1.0": + version: 7.1.1 + resolution: "@octokit/graphql@npm:7.1.1" + dependencies: + "@octokit/request": ^8.4.1 + "@octokit/types": ^13.0.0 + universal-user-agent: ^6.0.0 + checksum: afb60d5dda6d365334480540610d67b0c5f8e3977dd895fe504ce988f8b7183f29f3b16b88d895a701a739cf29d157d49f8f9fbc71b6c57eb4fc9bd97e099f55 + languageName: node + linkType: hard + +"@octokit/openapi-types@npm:^24.2.0": + version: 24.2.0 + resolution: "@octokit/openapi-types@npm:24.2.0" + checksum: 3c2d2f4cafd21c8a1e6a6fe6b56df6a3c09bc52ab6f829c151f9397694d028aa183ae856f08e006ee7ecaa7bd7eb413a903fbc0ffa6403e7b284ddcda20b1294 + languageName: node + linkType: hard + +"@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2": + version: 11.4.4-cjs.2 + resolution: "@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2" + dependencies: + "@octokit/types": ^13.7.0 + peerDependencies: + "@octokit/core": 5 + checksum: e6d1f4da255d08c24188b5df1436f22680e7fe2608d3af5d2f08a98f40d565bd3df0c58d306f05caae923247fffe861ec12d5f1273a882333fcdb34255e6c8b0 + languageName: node + linkType: hard + +"@octokit/plugin-request-log@npm:^4.0.0": + version: 4.0.1 + resolution: "@octokit/plugin-request-log@npm:4.0.1" + peerDependencies: + "@octokit/core": 5 + checksum: fd8c0a201490cba00084689a0d1d54fc7b5ab5b6bdb7e447056b947b1754f78526e9685400eab10d3522bfa7b5bc49c555f41ec412c788610b96500b168f3789 + languageName: node + linkType: hard + +"@octokit/plugin-rest-endpoint-methods@npm:13.3.2-cjs.1": + version: 13.3.2-cjs.1 + resolution: "@octokit/plugin-rest-endpoint-methods@npm:13.3.2-cjs.1" + dependencies: + "@octokit/types": ^13.8.0 + peerDependencies: + "@octokit/core": ^5 + checksum: de38a7fe33aa41ecfa62dd8546d9b603cf43b1a6cf3a31e8c1950684e1cf0f9dc7ccbcff8ef570e825729f3800f42e6ae33447c836dfa12259391ced421df64f + languageName: node + linkType: hard + +"@octokit/request-error@npm:^5.1.1": + version: 5.1.1 + resolution: "@octokit/request-error@npm:5.1.1" + dependencies: + "@octokit/types": ^13.1.0 + deprecation: ^2.0.0 + once: ^1.4.0 + checksum: 17d0b3f59c2a8a285715bfe6a85168d9c417aa7a0ff553b9be4198a3bc8bb00384a3530221a448eb19f8f07ea9fc48d264869624f5f84fa63a948a7af8cddc8c + languageName: node + linkType: hard + +"@octokit/request@npm:^8.4.1": + version: 8.4.1 + resolution: "@octokit/request@npm:8.4.1" + dependencies: + "@octokit/endpoint": ^9.0.6 + "@octokit/request-error": ^5.1.1 + "@octokit/types": ^13.1.0 + universal-user-agent: ^6.0.0 + checksum: 0ba76728583543baeef9fda98690bc86c57e0a3ccac8c189d2b7d144d248c89167eb37a071ed8fead8f4da0a1c55c4dd98a8fc598769c263b95179fb200959de + languageName: node + linkType: hard + +"@octokit/rest@npm:^20.0.0": + version: 20.1.2 + resolution: "@octokit/rest@npm:20.1.2" + dependencies: + "@octokit/core": ^5.0.2 + "@octokit/plugin-paginate-rest": 11.4.4-cjs.2 + "@octokit/plugin-request-log": ^4.0.0 + "@octokit/plugin-rest-endpoint-methods": 13.3.2-cjs.1 + checksum: 72309dd393f3424f0c4213d045332c1c1a00893bea4db9b54d6add7316d9a9b461932de3afe3c866bff52cc084c79e98f644dabd386cda95068690cc9ae97456 + languageName: node + linkType: hard + +"@octokit/types@npm:^13.0.0, @octokit/types@npm:^13.1.0, @octokit/types@npm:^13.7.0, @octokit/types@npm:^13.8.0": + version: 13.10.0 + resolution: "@octokit/types@npm:13.10.0" + dependencies: + "@octokit/openapi-types": ^24.2.0 + checksum: fca3764548d5872535b9025c3b5fe6373fe588b287cb5b5259364796c1931bbe5e9ab8a86a5274ce43bb2b3e43b730067c3b86b6b1ade12a98cd59b2e8b3610d + languageName: node + linkType: hard + "@pkgr/core@npm:^0.1.0": version: 0.1.1 resolution: "@pkgr/core@npm:0.1.1" @@ -1199,6 +1326,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.18.0": + version: 22.18.0 + resolution: "@types/node@npm:22.18.0" + dependencies: + undici-types: ~6.21.0 + checksum: a110b66f079ea882be1e300e72978cd3a5e7be8217b362b72152e09f64087731a235a0557fca72d621912a8ba1347d9ba49468c35755dd2581edb7f3f6e016e2 + languageName: node + linkType: hard + "@types/semver@npm:^7.3.12, @types/semver@npm:^7.3.6": version: 7.3.13 resolution: "@types/semver@npm:7.3.13" @@ -1735,6 +1871,13 @@ __metadata: languageName: node linkType: hard +"before-after-hook@npm:^2.2.0": + version: 2.2.3 + resolution: "before-after-hook@npm:2.2.3" + checksum: a1a2430976d9bdab4cd89cb50d27fa86b19e2b41812bf1315923b0cba03371ebca99449809226425dd3bcef20e010db61abdaff549278e111d6480034bebae87 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -2231,6 +2374,13 @@ __metadata: languageName: node linkType: hard +"deprecation@npm:^2.0.0": + version: 2.3.1 + resolution: "deprecation@npm:2.3.1" + checksum: f56a05e182c2c195071385455956b0c4106fe14e36245b00c689ceef8e8ab639235176a96977ba7c74afb173317fac2e0ec6ec7a1c6d1e6eaa401c586c714132 + languageName: node + linkType: hard + "detect-indent@npm:^7.0.1": version: 7.0.1 resolution: "detect-indent@npm:7.0.1" @@ -4897,7 +5047,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0": +"once@npm:^1.3.0, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -6124,6 +6274,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.21.0": + version: 6.21.0 + resolution: "undici-types@npm:6.21.0" + checksum: 46331c7d6016bf85b3e8f20c159d62f5ae471aba1eb3dc52fff35a0259d58dcc7d592d4cc4f00c5f9243fa738a11cfa48bd20203040d4a9e6bc25e807fab7ab3 + languageName: node + linkType: hard + "unicode-emoji-modifier-base@npm:^1.0.0": version: 1.0.0 resolution: "unicode-emoji-modifier-base@npm:1.0.0" @@ -6149,6 +6306,13 @@ __metadata: languageName: node linkType: hard +"universal-user-agent@npm:^6.0.0": + version: 6.0.1 + resolution: "universal-user-agent@npm:6.0.1" + checksum: fdc8e1ae48a05decfc7ded09b62071f571c7fe0bd793d700704c80cea316101d4eac15cc27ed2bb64f4ce166d2684777c3198b9ab16034f547abea0d3aa1c93c + languageName: node + linkType: hard + "update-browserslist-db@npm:^1.1.0": version: 1.1.0 resolution: "update-browserslist-db@npm:1.1.0"