diff --git a/CHANGELOG.md b/CHANGELOG.md index cd01917..fb79f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Latest +### BREAKING + +- group changelog entries by type c5f11dab + ### Features - add --root option b813606b diff --git a/src/utils/changelog.js b/src/utils/changelog.js new file mode 100644 index 0000000..fd11d14 --- /dev/null +++ b/src/utils/changelog.js @@ -0,0 +1,81 @@ +import { readFile } from 'fs' +import { getCommitDetails } from './git' + +export const generateReleased = previousVersion => + new Promise((resolve, reject) => + readFile('CHANGELOG.md', 'utf8', (err, data) => { + if (err) return reject(err) + + let isLatest = false + const released = data + .split('\n') + .filter(line => { + if (line === '## Latest') { + isLatest = true + + return false + } + + if (isLatest && line === `## ${previousVersion}`) { + isLatest = false + + return true + } + + return !isLatest + }) + .join('\n') + + if (isLatest) { + return reject(new Error('Previous release not found in CHANGELOG')) + } + + return resolve(released) + }) + ) + +export const generateChangelog = (version, groups) => { + const changelog = groups.map(group => { + const release = getCommitDetails(group.release) + const title = version || 'Latest' + let groupChangelog = `## ${release ? release.message : title}\n\n` + + const entries = Object.entries(group) + + entries.sort().forEach(([title, commits]) => { + if (title === 'release') return + + switch (title) { + case 'breaking': + groupChangelog += '### BREAKING\n\n' + break + case 'feat': + groupChangelog += '### Features\n\n' + break + case 'fix': + groupChangelog += '### Fixes\n\n' + break + default: + groupChangelog += '### Misc\n\n' + break + } + + return commits.forEach((commit, index) => { + const { message, hash } = getCommitDetails(commit) + + const isLastLine = index + 1 === commits.length + const space = isLastLine ? '\n\n' : '\n' + const line = generateLine({ message, hash }) + space + + return (groupChangelog += line) + }) + }) + + return groupChangelog + }) + + return changelog.join('') +} + +export const generateLine = ({ message, hash }) => + `- ${message} ${hash.slice(0, 8)}` diff --git a/src/utils/changelog.test.js b/src/utils/changelog.test.js new file mode 100644 index 0000000..30a552f --- /dev/null +++ b/src/utils/changelog.test.js @@ -0,0 +1,122 @@ +import { readFile } from 'fs' +import { generateChangelog, generateLine, generateReleased } from './changelog' + +jest.mock('fs', () => ({ + readFile: jest.fn() +})) + +describe('changelog', () => { + beforeEach(() => jest.resetAllMocks()) + + describe('generateReleased', () => { + it('should reject if receives an error', async () => { + const error = 'error' + readFile.mockImplementation((_, __, cb) => cb(error)) + + expect(generateReleased()).rejects.toMatch(error) + }) + + it('should reject if there is no released but tag provided', async () => { + const error = 'Previous release not found in CHANGELOG' + const mockedInput = + '## Latest\n- feat: include changelog in the releases 2da21c56\n- test: add utils tests 217b25d0' + readFile.mockImplementation((_, __, cb) => cb(null, mockedInput)) + + expect(generateReleased('2.2.2')).rejects.toThrow(error) + }) + + it('should generate released', async () => { + const mockedInput = + '## Latest\n- feat: include changelog in the releases 2da21c56\n- test: add utils tests 217b25d0\n## 2.2.2\n- feat: add feature 2da21c56' + const mockedOutput = '## 2.2.2\n- feat: add feature 2da21c56' + readFile.mockImplementation((_, __, cb) => cb(null, mockedInput)) + const released = await generateReleased('2.2.2') + + expect(released).toBe(mockedOutput) + }) + }) + + describe('generateChangelog', () => { + it('should generate changelog', () => { + const mockedInput = [ + { + breaking: [ + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 feat!: add new api', + '2ea04355c1e81c5088eeabc6e242fb1ade978524 feat!: deprecate function' + ], + feat: [ + 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file' + ], + misc: [ + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec' + ] + }, + { + release: + 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0', + fix: [ + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions' + ], + feat: [ + '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' + ], + misc: [ + '4e02179cae1234d7083036024080a3f25fcb52c2 chore: update dependencies' + ] + } + ] + const mockedOutput = + '## Latest\n\n### BREAKING\n\n- add new api b2f59019\n- deprecate function 2ea04355\n\n### Features\n\n- add option to write to local CHANGELOG file aa805ce7\n\n### Misc\n\n- extract line generating logic to function and promisify exec bffc2f9e\n\n## 0.1.0\n\n### Features\n\n- add execute release feature 4e02179c\n\n### Fixes\n\n- support other conventions b2f59019\n\n### Misc\n\n- update dependencies 4e02179c\n\n' + + expect(generateChangelog(null, mockedInput)).toBe(mockedOutput) + }) + + it('should generate changelog with version', () => { + const mockedInput = [ + { + breaking: [ + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 feat!: add new api', + '2ea04355c1e81c5088eeabc6e242fb1ade978524 feat!: deprecate function' + ], + feat: [ + 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file' + ], + misc: [ + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec' + ] + }, + { + release: + 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0', + fix: [ + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions' + ], + feat: [ + '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' + ], + misc: [ + '4e02179cae1234d7083036024080a3f25fcb52c2 chore: update dependencies' + ] + } + ] + const mockedOutput = + '## 2.0.0\n\n### BREAKING\n\n- add new api b2f59019\n- deprecate function 2ea04355\n\n### Features\n\n- add option to write to local CHANGELOG file aa805ce7\n\n### Misc\n\n- extract line generating logic to function and promisify exec bffc2f9e\n\n## 0.1.0\n\n### Features\n\n- add execute release feature 4e02179c\n\n### Fixes\n\n- support other conventions b2f59019\n\n### Misc\n\n- update dependencies 4e02179c\n\n' + + expect(generateChangelog('2.0.0', mockedInput)).toBe(mockedOutput) + }) + }) + + describe('generateLine', () => { + it('should generate line', () => { + const mockedInput = { + message: 'generate changelog', + hash: 'b2f5901922505efbfb6dd684252e8df0cdffeeb2' + } + const mockedOutput = '- generate changelog b2f59019' + + const line = generateLine(mockedInput) + + expect(line).toEqual(mockedOutput) + }) + }) +}) diff --git a/src/utils/git.js b/src/utils/git.js new file mode 100644 index 0000000..61ba0e2 --- /dev/null +++ b/src/utils/git.js @@ -0,0 +1,42 @@ +import { execAsync } from './misc' + +export const getCommits = async tag => { + const query = tag + ? `git log --format="%H %s" ${tag}..` + : 'git log --format="%H %s"' + const commits = await execAsync(query) + + return commits.split('\n').filter(commit => commit) +} + +export const getLatestTag = async () => { + const latestTag = await execAsync('git tag | tail -n 1') + + return latestTag ? latestTag.replace('\n', '') : null +} + +export const getCommitDetails = commit => { + if (!commit) return null + + const { + groups: { hash, title } + } = commit.match(/(?.{40}) (?.*)/) + + const commitDetails = title.match( + /(?<type>[\w ]*)(?:\((?<scope>[\w ]*)\))?(?<breaking>!)?: (?<message>.*)/ + ) + + if (!commitDetails) return title + + const { + groups: { type, scope, message, breaking } + } = commitDetails + + return { hash, title, type, scope, message, breaking } +} + +export const commitRelease = async version => { + await execAsync('git add CHANGELOG.md package.json package-lock.json') + await execAsync(`git commit -m 'chore(release): ${version}'`) + await execAsync(`git tag ${version}`) +} diff --git a/src/utils/git.test.js b/src/utils/git.test.js new file mode 100644 index 0000000..d4556aa --- /dev/null +++ b/src/utils/git.test.js @@ -0,0 +1,144 @@ +import { exec } from 'child_process' +import { + commitRelease, + getCommitDetails, + getCommits, + getLatestTag +} from './git' + +jest.mock('child_process', () => ({ + exec: jest.fn() +})) + +describe('git', () => { + beforeEach(() => jest.resetAllMocks()) + + describe('getLatestTag', () => { + it('should return null if there is no tag', async () => { + exec.mockImplementation((_, cb) => cb(null)) + const latestTag = await getLatestTag() + + expect(latestTag).toBe(null) + expect(exec).toBeCalledTimes(1) + expect(exec).toBeCalledWith('git tag | tail -n 1', expect.any(Function)) + }) + + it('should return latest tag', async () => { + exec.mockImplementation((_, cb) => cb(null, '2.2.2')) + const latestTag = await getLatestTag() + + expect(latestTag).toBe('2.2.2') + expect(exec).toBeCalledTimes(1) + expect(exec).toBeCalledWith('git tag | tail -n 1', expect.any(Function)) + }) + }) + + describe('getCommits', () => { + it('should reject if receives an error', async () => { + const error = 'error' + exec.mockImplementation((_, cb) => cb(error)) + + expect(getCommits()).rejects.toMatch(error) + }) + + it('should return commits', async () => { + const mockedInput = + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec\naa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file\nf2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0\nb2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions\n4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' + const mockedOutput = [ + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', + 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', + 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0', + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', + '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' + ] + + exec.mockImplementation((_, cb) => cb(null, mockedInput)) + const commits = await getCommits() + + expect(exec).toBeCalledTimes(1) + expect(exec).toBeCalledWith( + 'git log --format="%H %s"', + expect.any(Function) + ) + expect(commits).toEqual(mockedOutput) + }) + + it('should return commits since tag', async () => { + const mockedInput = + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec\naa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file\nf2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0\nb2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions\n4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' + const mockedOutput = [ + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', + 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', + 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0', + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', + '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' + ] + + exec.mockImplementation((_, cb) => cb(null, mockedInput)) + const commits = await getCommits('2.2.2') + + expect(exec).toBeCalledTimes(1) + expect(exec).toBeCalledWith( + 'git log --format="%H %s" 2.2.2..', + expect.any(Function) + ) + expect(commits).toEqual(mockedOutput) + }) + }) + + describe('getCommitDetails', () => { + it("should return null if commit doesn't exist", () => { + const mockedInput = null + const mockedOutput = null + + const commitDetails = getCommitDetails(mockedInput) + + expect(commitDetails).toEqual(mockedOutput) + }) + + it('should return commit details', () => { + const mockedInput = + 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0' + const mockedOutput = { + hash: 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb', + scope: 'release', + title: 'chore(release): 0.1.0', + message: '0.1.0', + type: 'chore' + } + + const commitDetails = getCommitDetails(mockedInput) + + expect(commitDetails).toEqual(mockedOutput) + }) + + it('should return title for non conventional commits', () => { + const mockedInput = + 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb Add some old feature' + const mockedOutput = 'Add some old feature' + + const commitDetails = getCommitDetails(mockedInput) + + expect(commitDetails).toEqual(mockedOutput) + }) + }) + + describe('commitRelease', () => { + it('should commit release', async () => { + exec.mockImplementation((_, cb) => cb(null)) + const version = '2.2.2' + await commitRelease(version) + + expect(exec).toBeCalledTimes(3) + expect(exec).toBeCalledWith( + 'git add CHANGELOG.md package.json package-lock.json', + expect.any(Function) + ) + expect(exec).toBeCalledWith( + `git commit -m 'chore(release): ${version}'`, + expect.any(Function) + ) + expect(exec).toBeCalledWith(`git tag ${version}`, expect.any(Function)) + }) + }) +}) diff --git a/src/utils/index.js b/src/utils/index.js index f1129e9..69d03af 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,170 +1,3 @@ -import { exec } from 'child_process' -import { readFile } from 'fs' - -const execAsync = command => - new Promise((resolve, reject) => - exec(command, (err, res) => { - if (err) return reject(err) - resolve(res) - }) - ) - -export const commitRelease = async version => { - await execAsync('git add CHANGELOG.md package.json package-lock.json') - await execAsync(`git commit -m 'chore(release): ${version}'`) - await execAsync(`git tag ${version}`) -} - -export const getCommits = async tag => { - const query = tag - ? `git log --format="%H %s" ${tag}..` - : 'git log --format="%H %s"' - const commits = await execAsync(query) - - return commits.split('\n').filter(commit => commit) -} - -export const generateReleased = previousVersion => - new Promise((resolve, reject) => - readFile('CHANGELOG.md', 'utf8', (err, data) => { - if (err) return reject(err) - - let isLatest = false - const released = data - .split('\n') - .filter(line => { - if (line === '## Latest') { - isLatest = true - - return false - } - - if (isLatest && line === `## ${previousVersion}`) { - isLatest = false - - return true - } - - return !isLatest - }) - .join('\n') - - if (isLatest) { - return reject(new Error('Previous release not found in CHANGELOG')) - } - - return resolve(released) - }) - ) - -export const getLatestTag = async () => { - const latestTag = await execAsync('git tag | tail -n 1') - - return latestTag ? latestTag.replace('\n', '') : null -} - -export const getCommitDetails = commit => { - if (!commit) return null - - const { - groups: { hash, title } - } = commit.match(/(?<hash>.{40}) (?<title>.*)/) - - const commitDetails = title.match( - /(?<type>[\w ]*)(?:\((?<scope>[\w ]*)\))?(?<breaking>!)?: (?<message>.*)/ - ) - - if (!commitDetails) return title - - const { - groups: { type, scope, message, breaking } - } = commitDetails - - return { hash, title, type, scope, message, breaking } -} - -export const groupCommits = commits => - commits.reduce( - (grouped, commit) => { - const group = grouped[grouped.length - 1] - const rest = grouped.slice(0, -1) - const commitDetails = getCommitDetails(commit) - const normalizedScope = - commitDetails.scope && commitDetails.scope.toLowerCase() - - if (normalizedScope === 'changelog') return [...grouped] - if (normalizedScope === 'release') { - return [...grouped, { release: commit }] - } - - if (commitDetails.breaking) { - const existing = group.breaking ? group.breaking : [] - return [...rest, { ...group, breaking: [...existing, commit] }] - } - - switch (commitDetails.type) { - case 'feat': { - const existing = group.feat ? group.feat : [] - return [...rest, { ...group, feat: [...existing, commit] }] - } - case 'fix': { - const existing = group.fix ? group.fix : [] - return [...rest, { ...group, fix: [...existing, commit] }] - } - default: { - const existing = group.misc ? group.misc : [] - return [...rest, { ...group, misc: [...existing, commit] }] - } - } - }, - [{}] - ) - -export const generateChangelog = (version, groups) => { - const changelog = groups.map(group => { - const release = getCommitDetails(group.release) - const title = version || 'Latest' - let groupChangelog = `## ${release ? release.message : title}\n\n` - - const entries = Object.entries(group) - - entries.sort().forEach(([title, commits]) => { - if (title === 'release') return - - switch (title) { - case 'breaking': - groupChangelog += '### BREAKING\n\n' - break - case 'feat': - groupChangelog += '### Features\n\n' - break - case 'fix': - groupChangelog += '### Fixes\n\n' - break - default: - groupChangelog += '### Misc\n\n' - break - } - - return commits.forEach((commit, index) => { - const { message, hash } = getCommitDetails(commit) - - const isLastLine = index + 1 === commits.length - const space = isLastLine ? '\n\n' : '\n' - const line = generateLine({ message, hash }) + space - - return (groupChangelog += line) - }) - }) - - return groupChangelog - }) - - return changelog.join('') -} - -export const generateLine = ({ message, hash }) => - `- ${message} ${hash.slice(0, 8)}` - -export const updateVersion = version => - execAsync(`npm version ${version} --no-git-tag-version`) +export * from './changelog' +export * from './git' +export * from './misc' diff --git a/src/utils/index.test.js b/src/utils/index.test.js deleted file mode 100644 index a93fc02..0000000 --- a/src/utils/index.test.js +++ /dev/null @@ -1,383 +0,0 @@ -import { exec } from 'child_process' -import { readFile } from 'fs' -import { - commitRelease, - generateChangelog, - generateLine, - generateReleased, - getCommitDetails, - getCommits, - getLatestTag, - groupCommits, - updateVersion -} from './index' - -jest.mock('child_process', () => ({ - exec: jest.fn() -})) -jest.mock('fs', () => ({ - readFile: jest.fn() -})) - -describe('utils', () => { - beforeEach(() => jest.resetAllMocks()) - - describe('commitRelease', () => { - it('should commit release', async () => { - exec.mockImplementation((_, cb) => cb(null)) - const version = '2.2.2' - await commitRelease(version) - - expect(exec).toBeCalledTimes(3) - expect(exec).toBeCalledWith( - 'git add CHANGELOG.md package.json package-lock.json', - expect.any(Function) - ) - expect(exec).toBeCalledWith( - `git commit -m 'chore(release): ${version}'`, - expect.any(Function) - ) - expect(exec).toBeCalledWith(`git tag ${version}`, expect.any(Function)) - }) - }) - - describe('getLatestTag', () => { - it('should return null if there is no tag', async () => { - exec.mockImplementation((_, cb) => cb(null)) - const latestTag = await getLatestTag() - - expect(latestTag).toBe(null) - expect(exec).toBeCalledTimes(1) - expect(exec).toBeCalledWith('git tag | tail -n 1', expect.any(Function)) - }) - - it('should return latest tag', async () => { - exec.mockImplementation((_, cb) => cb(null, '2.2.2')) - const latestTag = await getLatestTag() - - expect(latestTag).toBe('2.2.2') - expect(exec).toBeCalledTimes(1) - expect(exec).toBeCalledWith('git tag | tail -n 1', expect.any(Function)) - }) - }) - - describe('getCommits', () => { - it('should reject if receives an error', async () => { - const error = 'error' - exec.mockImplementation((_, cb) => cb(error)) - - expect(getCommits()).rejects.toMatch(error) - }) - - it('should return commits', async () => { - const mockedInput = - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec\naa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file\nf2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0\nb2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions\n4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' - const mockedOutput = [ - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', - 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', - 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0', - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', - '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' - ] - - exec.mockImplementation((_, cb) => cb(null, mockedInput)) - const commits = await getCommits() - - expect(exec).toBeCalledTimes(1) - expect(exec).toBeCalledWith( - 'git log --format="%H %s"', - expect.any(Function) - ) - expect(commits).toEqual(mockedOutput) - }) - - it('should return commits since tag', async () => { - const mockedInput = - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec\naa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file\nf2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0\nb2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions\n4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' - const mockedOutput = [ - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', - 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', - 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0', - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', - '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' - ] - - exec.mockImplementation((_, cb) => cb(null, mockedInput)) - const commits = await getCommits('2.2.2') - - expect(exec).toBeCalledTimes(1) - expect(exec).toBeCalledWith( - 'git log --format="%H %s" 2.2.2..', - expect.any(Function) - ) - expect(commits).toEqual(mockedOutput) - }) - }) - - describe('getCommitDetails', () => { - it("should return null if commit doesn't exist", () => { - const mockedInput = null - const mockedOutput = null - - const commitDetails = getCommitDetails(mockedInput) - - expect(commitDetails).toEqual(mockedOutput) - }) - - it('should return commit details', () => { - const mockedInput = - 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0' - const mockedOutput = { - hash: 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb', - scope: 'release', - title: 'chore(release): 0.1.0', - message: '0.1.0', - type: 'chore' - } - - const commitDetails = getCommitDetails(mockedInput) - - expect(commitDetails).toEqual(mockedOutput) - }) - - it('should return title for non conventional commits', () => { - const mockedInput = - 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb Add some old feature' - const mockedOutput = 'Add some old feature' - - const commitDetails = getCommitDetails(mockedInput) - - expect(commitDetails).toEqual(mockedOutput) - }) - }) - - describe('generateReleased', () => { - it('should reject if receives an error', async () => { - const error = 'error' - readFile.mockImplementation((_, __, cb) => cb(error)) - - expect(generateReleased()).rejects.toMatch(error) - }) - - it('should reject if there is no released but tag provided', async () => { - const error = 'Previous release not found in CHANGELOG' - const mockedInput = - '## Latest\n- feat: include changelog in the releases 2da21c56\n- test: add utils tests 217b25d0' - readFile.mockImplementation((_, __, cb) => cb(null, mockedInput)) - - expect(generateReleased('2.2.2')).rejects.toThrow(error) - }) - - it('should generate released', async () => { - const mockedInput = - '## Latest\n- feat: include changelog in the releases 2da21c56\n- test: add utils tests 217b25d0\n## 2.2.2\n- feat: add feature 2da21c56' - const mockedOutput = '## 2.2.2\n- feat: add feature 2da21c56' - readFile.mockImplementation((_, __, cb) => cb(null, mockedInput)) - const released = await generateReleased('2.2.2') - - expect(released).toBe(mockedOutput) - }) - }) - - describe('groupCommits', () => { - it('should group commits with no releases', async () => { - const mockedInput = [ - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', - 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 fix: a bug', - '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature', - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 tests: add core tests', - '2ea04355c1e81c5088eeabc6e242fb1ade978524 chore: update dependencies' - ] - const mockedOutput = [ - { - fix: [ - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 fix: a bug' - ], - feat: [ - 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', - '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' - ], - misc: [ - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 tests: add core tests', - '2ea04355c1e81c5088eeabc6e242fb1ade978524 chore: update dependencies' - ] - } - ] - const grouped = await groupCommits(mockedInput) - - expect(grouped).toEqual(mockedOutput) - }) - - it('should group commits', async () => { - const mockedInput = [ - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 chore!: generate changelog', - '2ea04355c1e81c5088eeabc6e242fb1ade978524 chore!: version releases', - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', - 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', - 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0', - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', - '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' - ] - const mockedOutput = [ - { - breaking: [ - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 chore!: generate changelog', - '2ea04355c1e81c5088eeabc6e242fb1ade978524 chore!: version releases' - ], - feat: [ - 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file' - ], - misc: [ - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec' - ] - }, - { - release: - 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0', - fix: [ - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions' - ], - feat: [ - '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' - ] - } - ] - const grouped = await groupCommits(mockedInput) - - expect(grouped).toEqual(mockedOutput) - }) - - it('should skip changelog scope', async () => { - const mockedInput = [ - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', - 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 fix: a bug', - '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature', - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 tests: add core tests', - '2ea04355c1e81c5088eeabc6e242fb1ade978524 chore(changelog): update CHANGELOG' - ] - const mockedOutput = [ - { - fix: [ - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 fix: a bug' - ], - feat: [ - 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', - '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' - ], - misc: [ - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 tests: add core tests' - ] - } - ] - const grouped = await groupCommits(mockedInput) - - expect(grouped).toEqual(mockedOutput) - }) - }) - - describe('generateChangelog', () => { - it('should generate changelog', () => { - const mockedInput = [ - { - breaking: [ - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 feat!: add new api', - '2ea04355c1e81c5088eeabc6e242fb1ade978524 feat!: deprecate function' - ], - feat: [ - 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file' - ], - misc: [ - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec' - ] - }, - { - release: - 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0', - fix: [ - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions' - ], - feat: [ - '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' - ], - misc: [ - '4e02179cae1234d7083036024080a3f25fcb52c2 chore: update dependencies' - ] - } - ] - const mockedOutput = - '## Latest\n\n### BREAKING\n\n- add new api b2f59019\n- deprecate function 2ea04355\n\n### Features\n\n- add option to write to local CHANGELOG file aa805ce7\n\n### Misc\n\n- extract line generating logic to function and promisify exec bffc2f9e\n\n## 0.1.0\n\n### Features\n\n- add execute release feature 4e02179c\n\n### Fixes\n\n- support other conventions b2f59019\n\n### Misc\n\n- update dependencies 4e02179c\n\n' - - expect(generateChangelog(null, mockedInput)).toBe(mockedOutput) - }) - - it('should generate changelog with version', () => { - const mockedInput = [ - { - breaking: [ - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 feat!: add new api', - '2ea04355c1e81c5088eeabc6e242fb1ade978524 feat!: deprecate function' - ], - feat: [ - 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file' - ], - misc: [ - 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec' - ] - }, - { - release: - 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0', - fix: [ - 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions' - ], - feat: [ - '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' - ], - misc: [ - '4e02179cae1234d7083036024080a3f25fcb52c2 chore: update dependencies' - ] - } - ] - const mockedOutput = - '## 2.0.0\n\n### BREAKING\n\n- add new api b2f59019\n- deprecate function 2ea04355\n\n### Features\n\n- add option to write to local CHANGELOG file aa805ce7\n\n### Misc\n\n- extract line generating logic to function and promisify exec bffc2f9e\n\n## 0.1.0\n\n### Features\n\n- add execute release feature 4e02179c\n\n### Fixes\n\n- support other conventions b2f59019\n\n### Misc\n\n- update dependencies 4e02179c\n\n' - - expect(generateChangelog('2.0.0', mockedInput)).toBe(mockedOutput) - }) - }) - - describe('generateLine', () => { - it('should generate line', () => { - const mockedInput = { - message: 'generate changelog', - hash: 'b2f5901922505efbfb6dd684252e8df0cdffeeb2' - } - const mockedOutput = '- generate changelog b2f59019' - - const line = generateLine(mockedInput) - - expect(line).toEqual(mockedOutput) - }) - }) - - describe('updateVersion', () => { - it('should update version', async () => { - exec.mockImplementation((_, cb) => cb(null)) - const version = '3.3.3' - await updateVersion(version) - - expect(exec).toBeCalledTimes(1) - expect(exec).toBeCalledWith( - `npm version ${version} --no-git-tag-version`, - expect.any(Function) - ) - }) - }) -}) diff --git a/src/utils/misc.js b/src/utils/misc.js new file mode 100644 index 0000000..a7e60eb --- /dev/null +++ b/src/utils/misc.js @@ -0,0 +1,50 @@ +import { exec } from 'child_process' +import { getCommitDetails } from './git' + +export const execAsync = command => + new Promise((resolve, reject) => + exec(command, (err, res) => { + if (err) return reject(err) + resolve(res) + }) + ) + +export const groupCommits = commits => + commits.reduce( + (grouped, commit) => { + const group = grouped[grouped.length - 1] + const rest = grouped.slice(0, -1) + const commitDetails = getCommitDetails(commit) + const normalizedScope = + commitDetails.scope && commitDetails.scope.toLowerCase() + + if (normalizedScope === 'changelog') return [...grouped] + if (normalizedScope === 'release') { + return [...grouped, { release: commit }] + } + + if (commitDetails.breaking) { + const existing = group.breaking ? group.breaking : [] + return [...rest, { ...group, breaking: [...existing, commit] }] + } + + switch (commitDetails.type) { + case 'feat': { + const existing = group.feat ? group.feat : [] + return [...rest, { ...group, feat: [...existing, commit] }] + } + case 'fix': { + const existing = group.fix ? group.fix : [] + return [...rest, { ...group, fix: [...existing, commit] }] + } + default: { + const existing = group.misc ? group.misc : [] + return [...rest, { ...group, misc: [...existing, commit] }] + } + } + }, + [{}] + ) + +export const updateVersion = version => + execAsync(`npm version ${version} --no-git-tag-version`) diff --git a/src/utils/misc.test.js b/src/utils/misc.test.js new file mode 100644 index 0000000..af3c0fb --- /dev/null +++ b/src/utils/misc.test.js @@ -0,0 +1,128 @@ +import { exec } from 'child_process' +import { groupCommits, updateVersion } from './misc' + +jest.mock('child_process', () => ({ + exec: jest.fn() +})) + +describe('misc', () => { + beforeEach(() => jest.resetAllMocks()) + + describe('groupCommits', () => { + it('should group commits with no releases', async () => { + const mockedInput = [ + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', + 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 fix: a bug', + '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature', + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 tests: add core tests', + '2ea04355c1e81c5088eeabc6e242fb1ade978524 chore: update dependencies' + ] + const mockedOutput = [ + { + fix: [ + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 fix: a bug' + ], + feat: [ + 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', + '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' + ], + misc: [ + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 tests: add core tests', + '2ea04355c1e81c5088eeabc6e242fb1ade978524 chore: update dependencies' + ] + } + ] + const grouped = await groupCommits(mockedInput) + + expect(grouped).toEqual(mockedOutput) + }) + + it('should group commits', async () => { + const mockedInput = [ + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 chore!: generate changelog', + '2ea04355c1e81c5088eeabc6e242fb1ade978524 chore!: version releases', + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', + 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', + 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0', + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', + '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' + ] + const mockedOutput = [ + { + breaking: [ + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 chore!: generate changelog', + '2ea04355c1e81c5088eeabc6e242fb1ade978524 chore!: version releases' + ], + feat: [ + 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file' + ], + misc: [ + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec' + ] + }, + { + release: + 'f2191200bf7b6e5eec3d61fcef9eb756e0129cfb chore(release): 0.1.0', + fix: [ + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions' + ], + feat: [ + '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' + ] + } + ] + const grouped = await groupCommits(mockedInput) + + expect(grouped).toEqual(mockedOutput) + }) + + it('should skip changelog scope', async () => { + const mockedInput = [ + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', + 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 fix: a bug', + '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature', + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 tests: add core tests', + '2ea04355c1e81c5088eeabc6e242fb1ade978524 chore(changelog): update CHANGELOG' + ] + const mockedOutput = [ + { + fix: [ + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 fix: support other conventions', + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 fix: a bug' + ], + feat: [ + 'aa805ce71ee103965ce3db46d4f6ed2658efd08d feat: add option to write to local CHANGELOG file', + '4e02179cae1234d7083036024080a3f25fcb52c2 feat: add execute release feature' + ], + misc: [ + 'bffc2f9e8da1c7ac133689bc9cd14494f3be08e3 refactor: extract line generating logic to function and promisify exec', + 'b2f5901922505efbfb6dd684252e8df0cdffeeb2 tests: add core tests' + ] + } + ] + const grouped = await groupCommits(mockedInput) + + expect(grouped).toEqual(mockedOutput) + }) + }) + + describe('updateVersion', () => { + it('should update version', async () => { + exec.mockImplementation((_, cb) => cb(null)) + const version = '3.3.3' + await updateVersion(version) + + expect(exec).toBeCalledTimes(1) + expect(exec).toBeCalledWith( + `npm version ${version} --no-git-tag-version`, + expect.any(Function) + ) + }) + }) +})