diff --git a/bin/happy-release-notes.cjs b/bin/happy-release-notes.cjs index f6e8b16..398b737 100755 --- a/bin/happy-release-notes.cjs +++ b/bin/happy-release-notes.cjs @@ -22,7 +22,7 @@ function getArguments() { from: null, to: null, versionHeader: false, - author: null + authorUsername: null }; for (const arg of process.argv) { @@ -32,6 +32,8 @@ function getArguments() { args.to = arg.split('=')[1]; } else if (arg.startsWith('--author=')) { args.author = arg.split('=')[1]; + } else if (arg.startsWith('--authorUsername=')) { + args.authorUsername = arg.split('=')[1]; } else if (arg.startsWith('--versionHeader')) { args.versionHeader = true; } @@ -56,7 +58,8 @@ async function main() { fromVersion: args.from ? args.from : null, toVersion: args.to ? args.to : null, versionHeader: args.versionHeader, - author: args.author + author: args.author, + authorUsername: args.authorUsername }); console.log(releaseNotes); diff --git a/src/ConventionalCommitReleaseNotes.ts b/src/ConventionalCommitReleaseNotes.ts index 5adbb5d..ece7b58 100644 --- a/src/ConventionalCommitReleaseNotes.ts +++ b/src/ConventionalCommitReleaseNotes.ts @@ -39,6 +39,7 @@ export default class ConventionalCommitReleaseNotes { * @param [options.toVersion] To version. * @param [options.versionHeader] "true" to show version header. * @param [options.author] "githubUsername" or "nameAndEmail". + * @param [options.authorUsername] Use this username for author when "author" is not specified or if it was not possible to retrieve the Github username based on email. * @returns Release notes. */ public static async getReleaseNotes(options?: { @@ -46,6 +47,7 @@ export default class ConventionalCommitReleaseNotes { toVersion?: string; versionHeader?: boolean; author?: 'githubUsername' | 'nameAndEmail'; + authorUsername?: string; }): Promise { const releases = await this.getReleases(options); let output = ''; @@ -61,18 +63,28 @@ export default class ConventionalCommitReleaseNotes { const message = change.message.endsWith('.') ? change.message.slice(0, -1) : change.message; - const author = - options?.author === 'githubUsername' && change.author.githubUsername - ? change.author.githubUsername - : options?.author === 'nameAndEmail' - ? `${change.author.name} (${change.author.email})` - : null; + let author = ''; + + switch (options?.author) { + case 'githubUsername': + author = + change.author.githubUsername || options?.authorUsername + ? `@${change.author.githubUsername || options?.authorUsername}` + : null; + break; + case 'nameAndEmail': + author = `${change.author.name} (${change.author.email})`; + break; + default: + author = options?.authorUsername ? `@${options?.authorUsername}` : null; + break; + } let userAndTask = ''; if (author && change.taskId) { - userAndTask = ` - By **@${author}** in task ${change.taskId}`; + userAndTask = ` - By **${author}** in task ${change.taskId}`; } else if (author) { - userAndTask = ` - By **@${author}**`; + userAndTask = ` - By **${author}**`; } else if (change.taskId) { userAndTask = ` - In task ${change.taskId}`; } @@ -295,7 +307,8 @@ export default class ConventionalCommitReleaseNotes { const [message, userName, userEmail] = row.trim().split('|'); if (message) { commits.push({ - message, + // Remove @ from message to avoid Github to link to a user. + message: message.replace('@', ''), author: { name: userName, email: userEmail, diff --git a/test/ConventionalCommitReleaseNotes.test.ts b/test/ConventionalCommitReleaseNotes.test.ts index fece651..4ebd2d5 100644 --- a/test/ConventionalCommitReleaseNotes.test.ts +++ b/test/ConventionalCommitReleaseNotes.test.ts @@ -48,10 +48,17 @@ describe('ConventionalCommitReleaseNotes', () => { versionHeader: true }); expect(result.replace(/\s/g, '')).toBe( - `#v2.1.0-rc###:bomb:BreakingChanges-Breaking - In task TASK-123###:art:Features-Addnewfeature - In task TASK-123-Addanotherfeature - In task TASK-123###:construction_worker_man:Patchfixes-Fixbug - In task TASK-123 `.replace( - /\s/g, - '' - ) + `#v2.1.0-rc + + ### :bomb: Breaking Changes + - Breaking - In task TASK-123 + + ### :art: Features + - Add new feature - In task TASK-123 + - Add another feature - In task TASK-123 + + ### :construction_worker_man: Patch fixes + - Fix bug - In task TASK-123`.replace(/\s/g, '') ); }); @@ -93,10 +100,17 @@ describe('ConventionalCommitReleaseNotes', () => { versionHeader: true }); expect(result.replace(/\s/g, '')).toBe( - `# v2.1.0-rc\n\n### :bomb: Breaking Changes\n - Breaking - In task TASK-123\n\n### :art: Features\n - Add new feature - In task TASK-123\n - Add another feature - In task TASK-123\n\n### :construction_worker_man: Patch fixes\n - Fix bug - In task TASK-123`.replace( - /\s/g, - '' - ) + `#v2.1.0-rc + + ### :bomb: Breaking Changes + - Breaking - In task TASK-123 + + ### :art: Features + - Add new feature - In task TASK-123 + - Add another feature - In task TASK-123 + + ### :construction_worker_man: Patch fixes + - Fix bug - In task TASK-123`.replace(/\s/g, '') ); }); @@ -144,10 +158,25 @@ describe('ConventionalCommitReleaseNotes', () => { fromVersion: 'v1.0.0' }); expect(result.replace(/\s/g, '')).toBe( - `#v2.1.0-rc###:bomb:BreakingChanges-Breaking - In task TASK-123###:construction_worker_man:Patchfixes-Fixbug - In task TASK-123#v2.0.0###:art:Features-Addanotherfeature - In task TASK-123#v1.1.0###:art:Features-Addnewfeature - In task TASK-123`.replace( - /\s/g, - '' - ) + `#v2.1.0-rc + + ### :bomb: Breaking Changes + - Breaking - In task TASK-123 + + ### :construction_worker_man: Patch fixes + - Fix bug - In task TASK-123 + + + #v2.0.0 + + ### :art: Features + - Add another feature - In task TASK-123 + + + #v1.1.0 + + ### :art: Features + - Add new feature - In task TASK-123`.replace(/\s/g, '') ); }); @@ -190,10 +219,15 @@ describe('ConventionalCommitReleaseNotes', () => { toVersion: 'v1.1.0' }); expect(result.replace(/\s/g, '')).toBe( - `###:bomb:BreakingChanges-Breaking - In task TASK-123###:art:Features-Addnewfeature - In task TASK-123-Addanotherfeature - In task TASK-123###:construction_worker_man:Patchfixes-Fixbug - In task TASK-123 `.replace( - /\s/g, - '' - ) + `### :bomb: Breaking Changes + - Breaking - In task TASK-123 + + ### :art: Features + - Add new feature - In task TASK-123 + - Add another feature - In task TASK-123 + + ### :construction_worker_man: Patch fixes + - Fix bug - In task TASK-123`.replace(/\s/g, '') ); }); @@ -239,10 +273,18 @@ describe('ConventionalCommitReleaseNotes', () => { toVersion: 'v1.1.0' }); expect(result.replace(/\s/g, '')).toBe( - `###:bomb:BreakingChanges-Breaking-IntaskTASK-123###:art:Features-Addnewfeature-IntaskTASK-123-Addanotherfeature-IntaskTASK-123###:construction_worker_man:Patchfixes-Fixbug-IntaskTASK-123-TASK-123Non-conventional1-TASK-123:Non-conventional2-Non-conventional3-IntaskTASK-123`.replace( - /\s/g, - '' - ) + `### :bomb: Breaking Changes + - Breaking - In task TASK-123 + + ### :art: Features + - Add new feature - In task TASK-123 + - Add another feature - In task TASK-123 + + ### :construction_worker_man: Patch fixes + - Fix bug - In task TASK-123 + - TASK-123 Non-conventional 1 + - TASK-123: Non-conventional 2 + - Non-conventional 3 - In task TASK-123`.replace(/\s/g, '') ); }); @@ -290,17 +332,17 @@ describe('ConventionalCommitReleaseNotes', () => { }); expect(result.replace(/\s/g, '')).toBe( `### :bomb: Breaking Changes - - Breaking - By **@Firstname Lastname (example@example.se)** in task #123 + - Breaking - By **Firstname Lastname (example@example.se)** in task #123 ### :art: Features - - Add new feature - By **@Firstname Lastname (example@example.se)** in task #123 - - Add another feature - By **@Firstname Lastname (example@example.se)** in task #123 + - Add new feature - By **Firstname Lastname (example@example.se)** in task #123 + - Add another feature - By **Firstname Lastname (example@example.se)** in task #123 ### :construction_worker_man: Patch fixes - - Fix bug - By **@Firstname Lastname (example@example.se)** in task #123 - - Non-conventional 1 - By **@Firstname Lastname (example@example.se)** in task #123 - - : Non-conventional 2 - By **@Firstname Lastname (example@example.se)** in task #123 - - Non-conventional 3 - By **@Firstname Lastname (example@example.se)** in task #123`.replace( + - Fix bug - By **Firstname Lastname (example@example.se)** in task #123 + - Non-conventional 1 - By **Firstname Lastname (example@example.se)** in task #123 + - : Non-conventional 2 - By **Firstname Lastname (example@example.se)** in task #123 + - Non-conventional 3 - By **Firstname Lastname (example@example.se)** in task #123`.replace( /\s/g, '' ) @@ -380,7 +422,167 @@ describe('ConventionalCommitReleaseNotes', () => { author: 'githubUsername' }); expect(result.replace(/\s/g, '')).toBe( - `###:bomb:BreakingChanges-Breaking-By**@testGithubUsername**intask#123###:art:Features-Addnewfeature-By**@testGithubUsername**intask#123-Addanotherfeature-By**@testGithubUsername**intask#123###:construction_worker_man:Patchfixes-Fixbug-By**@testGithubUsername**intask#123-Non-conventional1-By**@testGithubUsername**intask#123-:Non-conventional2-By**@testGithubUsername**intask#123-Non-conventional3-By**@testGithubUsername**intask#123`.replace( + `### :bomb: Breaking Changes + - Breaking - By **@testGithubUsername** in task #123 + + ### :art: Features + - Add new feature - By **@testGithubUsername** in task #123 + - Add another feature - By **@testGithubUsername** in task #123 + + ### :construction_worker_man: Patch fixes + - Fix bug - By **@testGithubUsername** in task #123 + - Non-conventional 1 - By **@testGithubUsername** in task #123 + - : Non-conventional 2 - By **@testGithubUsername** in task #123 + - Non-conventional 3 - By **@testGithubUsername** in task #123`.replace(/\s/g, '') + ); + }); + + it('Should support author by Github username and fallback username.', async () => { + const gitTags = ['v2.1.0-rc', 'v2.0.0', 'v2.0.0-rc', 'v1.1.0', 'v1.1.1-rc', 'v1.0.0']; + const gitCommits = [ + 'Merge branch with branch|Firstname Lastname|example@example.se', + 'chore: [#123] Update dependencies.|Firstname Lastname|example@example.se', + 'feat: [#123] Add new feature.|Firstname Lastname|example@example.se', + 'feat: [#123] Add another feature.|Firstname Lastname|example@example.se', + 'Merge branch with branch|Firstname Lastname|example@example.se', + 'fix: [#123] Fix bug.|Firstname Lastname|example@example.se', + '#123 Non-conventional 1|Firstname Lastname|example@example.se', + '#123: Non-conventional 2|Firstname Lastname|example@example.se', + '#123@minor: Non-conventional 3|Firstname Lastname|example@example.se', + 'chore: [#123] Update dependencies.|Firstname Lastname|example@example.se', + 'BREAKING CHANGE: [#123] Breaking.|Firstname2 Lastname2|example2@example.se' + ]; + + vi.spyOn(ChildProcess, 'exec').mockImplementation( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + (command: string, callback: (error: Error | null, stdout: string) => void): void => { + switch (command) { + case 'git --no-pager log v1.0.0..v1.1.0 --pretty=format:"%s|%cn|%ce"': + callback(null, gitCommits.join('\n')); + break; + case 'git --no-pager tag -l --sort -version:refname': + callback(null, gitTags.join('\n')); + break; + case 'git fetch --all --tags': + callback(null, ''); + break; + default: + callback(new Error(`Command failed: ${command}`), ''); + break; + } + } + ); + + vi.spyOn(HTTPS, 'get').mockImplementation((url, _options, callback) => { + if (!callback) { + throw new Error('Callback is undefined'); + } + callback({ + on: (event, callback) => { + if (event === 'data') { + callback( + JSON.stringify( + url.toString().includes('example@example.se') + ? { + items: [ + { + login: 'testGithubUsername' + } + ] + } + : {} + ) + ); + } else if (event === 'end') { + callback(); + } + } + }); + return {}; + }); + + const result = await ConventionalCommitReleaseNotes.getReleaseNotes({ + fromVersion: 'v1.0.0', + toVersion: 'v1.1.0', + author: 'githubUsername', + authorUsername: 'fallbackUsername' + }); + expect(result.replace(/\s/g, '')).toBe( + `### :bomb: Breaking Changes + - Breaking - By **@fallbackUsername** in task #123 + + ### :art: Features + - Add new feature - By **@testGithubUsername** in task #123 + - Add another feature - By **@testGithubUsername** in task #123 + + ### :construction_worker_man: Patch fixes + - Fix bug - By **@testGithubUsername** in task #123 + - Non-conventional 1 - By **@testGithubUsername** in task #123 + - : Non-conventional 2 - By **@testGithubUsername** in task #123 + - Minor: Non-conventional 3 - By **@testGithubUsername** in task #123`.replace( + /\s/g, + '' + ) + ); + }); + + it('Should support author by fallback username.', async () => { + const gitTags = ['v2.1.0-rc', 'v2.0.0', 'v2.0.0-rc', 'v1.1.0', 'v1.1.1-rc', 'v1.0.0']; + const gitCommits = [ + 'Merge branch with branch|Firstname Lastname|example@example.se', + 'chore: [#123] Update dependencies.|Firstname Lastname|example@example.se', + 'feat: [#123] Add new feature.|Firstname Lastname|example@example.se', + 'feat: [#123] Add another feature.|Firstname Lastname|example@example.se', + 'Merge branch with branch|Firstname Lastname|example@example.se', + 'fix: [#123] Fix bug.|Firstname Lastname|example@example.se', + '#123 Non-conventional 1|Firstname Lastname|example@example.se', + '#123: Non-conventional 2|Firstname Lastname|example@example.se', + '#123@minor: Non-conventional 3|Firstname Lastname|example@example.se', + 'chore: [#123] Update dependencies.|Firstname Lastname|example@example.se', + 'BREAKING CHANGE: [#123] Breaking.|Firstname2 Lastname2|example2@example.se' + ]; + + vi.spyOn(ChildProcess, 'exec').mockImplementation( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + (command: string, callback: (error: Error | null, stdout: string) => void): void => { + switch (command) { + case 'git --no-pager log v1.0.0..v1.1.0 --pretty=format:"%s|%cn|%ce"': + callback(null, gitCommits.join('\n')); + break; + case 'git --no-pager tag -l --sort -version:refname': + callback(null, gitTags.join('\n')); + break; + case 'git fetch --all --tags': + callback(null, ''); + break; + default: + callback(new Error(`Command failed: ${command}`), ''); + break; + } + } + ); + + const result = await ConventionalCommitReleaseNotes.getReleaseNotes({ + fromVersion: 'v1.0.0', + toVersion: 'v1.1.0', + authorUsername: 'fallbackUsername' + }); + + expect(result.replace(/\s/g, '')).toBe( + `### :bomb: Breaking Changes + - Breaking - By **@fallbackUsername** in task #123 + + ### :art: Features + - Add new feature - By **@fallbackUsername** in task #123 + - Add another feature - By **@fallbackUsername** in task #123 + + ### :construction_worker_man: Patch fixes + - Fix bug - By **@fallbackUsername** in task #123 + - Non-conventional 1 - By **@fallbackUsername** in task #123 + - : Non-conventional 2 - By **@fallbackUsername** in task #123 + - Minor: Non-conventional 3 - By **@fallbackUsername** in task #123`.replace( /\s/g, '' )