├── .npmrc ├── .gitignore ├── .prettierignore ├── tsconfig.json ├── source ├── tsconfig.json ├── tsconfig.sources.json ├── tsconfig.unit-tests.json ├── lib │ ├── get-github-repo.ts │ ├── valid-labels.ts │ ├── get-github-repo.test.ts │ ├── version-number.ts │ ├── split.ts │ ├── find-remote-alias.ts │ ├── find-remote-alias.test.ts │ ├── split.test.ts │ ├── get-pull-request-label.ts │ ├── version-number.test.ts │ ├── cli-run-options.ts │ ├── ensure-clean-local-git-state.ts │ ├── ensure-clean-local-git-state.test.ts │ ├── get-pull-request-label.test.ts │ ├── get-merged-pull-requests.ts │ ├── cli.ts │ ├── git-command-runner.ts │ ├── create-changelog.ts │ ├── cli-run-options.test.ts │ ├── get-merged-pull-requests.test.ts │ ├── create-changelog.test.ts │ ├── git-command-runner.test.ts │ └── cli.test.ts └── bin │ └── pr-log.ts ├── .editorconfig ├── ava.config.js ├── prettier.config.js ├── .c8rc.json ├── renovate.json ├── tsconfig.base.json ├── .github └── workflows │ ├── check-labels.yml │ └── ci.yml ├── LICENSE ├── eslint.config.js ├── package.json ├── README.md └── CHANGELOG.md /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact = true 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | target 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.md 2 | target/ 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./source" }] 4 | } 5 | -------------------------------------------------------------------------------- /source/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "tsconfig.sources.json" }, { "path": "tsconfig.unit-tests.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,nix,ts,yml,yaml,md}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 4 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | const avaConfig = { 2 | files: ['./source/**/*.test.ts'], 3 | typescript: { 4 | rewritePaths: { 5 | 'source/': 'target/build/source/' 6 | }, 7 | compile: false 8 | } 9 | }; 10 | 11 | export default avaConfig; 12 | -------------------------------------------------------------------------------- /source/tsconfig.sources.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "../target/buildcache/sources.tsbuildinfo", 5 | "rootDir": "../" 6 | }, 7 | "include": ["./**/*.ts"], 8 | "exclude": ["./**/*.test.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | const prettierConfig = { 2 | printWidth: 120, 3 | tabWidth: 4, 4 | useTabs: false, 5 | semi: true, 6 | singleQuote: true, 7 | trailingComma: 'none', 8 | bracketSpacing: true, 9 | arrowParens: 'always' 10 | }; 11 | 12 | export default prettierConfig; 13 | -------------------------------------------------------------------------------- /source/tsconfig.unit-tests.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "../target/buildcache/unit-tests.tsbuildinfo", 5 | "rootDir": "../" 6 | }, 7 | "references": [{ "path": "./tsconfig.sources.json" }], 8 | "include": ["./**/*.test.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /source/lib/get-github-repo.ts: -------------------------------------------------------------------------------- 1 | import parseGithubUrl from 'parse-github-repo-url'; 2 | 3 | export function getGithubRepo(githubUrl: string): string { 4 | const result = parseGithubUrl(githubUrl); 5 | 6 | if (result === false) { 7 | throw new Error(`Invalid GitHub URI ${githubUrl}`); 8 | } 9 | 10 | return `${result[0]}/${result[1]}`; 11 | } 12 | -------------------------------------------------------------------------------- /source/lib/valid-labels.ts: -------------------------------------------------------------------------------- 1 | export const defaultValidLabels: ReadonlyMap = new Map([ 2 | ['breaking', 'Breaking Changes'], 3 | ['bug', 'Bug Fixes'], 4 | ['feature', 'Features'], 5 | ['enhancement', 'Enhancements'], 6 | ['documentation', 'Documentation'], 7 | ['upgrade', 'Dependency Upgrades'], 8 | ['refactor', 'Code Refactoring'], 9 | ['build', 'Build-Related'] 10 | ]); 11 | -------------------------------------------------------------------------------- /.c8rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "statements": 100, 4 | "branches": 100, 5 | "functions": 100, 6 | "lines": 100, 7 | "instrument": true, 8 | "check-coverage": true, 9 | "extension": [".js"], 10 | "src": "target/build/source", 11 | "reporter": ["lcov", "text-summary", "cobertura"], 12 | "reportDir": "./target/coverage", 13 | "tempDirectory": "./target/c8_output", 14 | "exclude": ["target/build/source/**/*.test.js", "target/build/source/bin/**/*"] 15 | } 16 | -------------------------------------------------------------------------------- /source/lib/get-github-repo.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getGithubRepo } from './get-github-repo.js'; 3 | 4 | test('extracts the repo path of a github URL', (t) => { 5 | t.is(getGithubRepo('git://github.com/foo/bar.git#master'), 'foo/bar'); 6 | }); 7 | 8 | test('throws if the given URL is not a github URL', (t) => { 9 | t.throws( 10 | () => { 11 | return getGithubRepo('git://foo.com/bar.git'); 12 | }, 13 | { message: 'Invalid GitHub URI git://foo.com/bar.git' } 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "commitMessagePrefix": "⬆️ ", 4 | "labels": ["renovate", "upgrade"], 5 | "rebaseStalePrs": true, 6 | "dependencyDashboard": false, 7 | "lockFileMaintenance": { 8 | "enabled": true, 9 | "automerge": true 10 | }, 11 | "packageRules": [ 12 | { 13 | "matchPackagePatterns": ["^@enormora/eslint-config"], 14 | "groupName": "@enormora/eslint-config" 15 | }, 16 | { 17 | "depTypeList": ["dependencies", "devDependencies"], 18 | "updateTypes": ["minor", "patch"], 19 | "automerge": true, 20 | "automergeType": "branch" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "node16", 5 | "moduleResolution": "node16", 6 | "resolveJsonModule": true, 7 | "strict": true, 8 | "sourceMap": true, 9 | "allowJs": false, 10 | "allowSyntheticDefaultImports": true, 11 | "composite": true, 12 | "declaration": true, 13 | "declarationMap": true, 14 | "esModuleInterop": true, 15 | "exactOptionalPropertyTypes": true, 16 | "importHelpers": true, 17 | "isolatedModules": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "noImplicitReturns": true, 20 | "noImplicitOverride": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noUncheckedIndexedAccess": true, 24 | "verbatimModuleSyntax": true, 25 | "outDir": "./target/build" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/check-labels.yml: -------------------------------------------------------------------------------- 1 | name: Check pull request labels 2 | 3 | on: 4 | pull_request: 5 | types: [opened, labeled, unlabeled] 6 | 7 | jobs: 8 | check-labels: 9 | runs-on: ubuntu-latest 10 | if: > 11 | contains(github.event.pull_request.labels.*.name, 'breaking') == false && 12 | contains(github.event.pull_request.labels.*.name, 'bug') == false && 13 | contains(github.event.pull_request.labels.*.name, 'feature') == false && 14 | contains(github.event.pull_request.labels.*.name, 'enhancement') == false && 15 | contains(github.event.pull_request.labels.*.name, 'documentation') == false && 16 | contains(github.event.pull_request.labels.*.name, 'upgrade') == false && 17 | contains(github.event.pull_request.labels.*.name, 'refactor') == false && 18 | contains(github.event.pull_request.labels.*.name, 'build') == false 19 | steps: 20 | - run: echo "None of the required pull request labels are set" && exit 1 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2023 Mathias Schreck 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /source/lib/version-number.ts: -------------------------------------------------------------------------------- 1 | import semver from 'semver'; 2 | import type { Just, Nothing } from 'true-myth/maybe'; 3 | import Result from 'true-myth/result'; 4 | import Unit from 'true-myth/unit'; 5 | 6 | type ValidateVersionNumberOptionsUnreleased = { 7 | readonly unreleased: true; 8 | readonly versionNumber: Nothing; 9 | }; 10 | 11 | type ValidateVersionNumberOptionsReleased = { 12 | readonly unreleased: false; 13 | readonly versionNumber: Just; 14 | }; 15 | 16 | export type ValidateVersionNumberOptions = 17 | | ValidateVersionNumberOptionsReleased 18 | | ValidateVersionNumberOptionsUnreleased; 19 | 20 | export function validateVersionNumber(options: ValidateVersionNumberOptions): Result { 21 | if (options.unreleased) { 22 | return Result.ok(Unit); 23 | } 24 | 25 | const versionNumber = options.versionNumber.value; 26 | 27 | if (versionNumber.length === 0) { 28 | return Result.err(new TypeError('version-number not specified')); 29 | } 30 | 31 | if (semver.valid(versionNumber) === null) { 32 | return Result.err(new Error('version-number is invalid')); 33 | } 34 | 35 | return Result.ok(Unit); 36 | } 37 | -------------------------------------------------------------------------------- /source/lib/split.ts: -------------------------------------------------------------------------------- 1 | type NonEmptyArray = readonly [T, ...(readonly T[])]; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-unused-vars -- only with inference it is possible to define a non empty string type, but we don’t need the inferred type 4 | type NonEmptyString = T extends `${infer _Character}${string}` ? T : never; 5 | 6 | type SplitReturnValue = T extends NonEmptyString ? NonEmptyArray : readonly string[]; 7 | 8 | export function splitByString( 9 | value: string, 10 | separator: Separator 11 | ): SplitReturnValue { 12 | return value.split(separator) as unknown as SplitReturnValue; 13 | } 14 | 15 | function isEmptyRegExp(value: Readonly): boolean { 16 | const matches = ''.split(value); 17 | return matches.length === 0; 18 | } 19 | 20 | export function splitByPattern(value: string, separator: Readonly): NonEmptyArray { 21 | if (isEmptyRegExp(separator)) { 22 | throw new Error('The given regex pattern was empty and can’t be used to split a string value'); 23 | } 24 | 25 | return value.split(separator) as unknown as NonEmptyArray; 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous integration 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | name: Node v20 9 | steps: 10 | - uses: actions/checkout@v5 11 | - name: Use Node.js 12 | uses: actions/setup-node@v6 13 | with: 14 | node-version: 20 15 | - name: Install dependencies 16 | run: npm clean-install 17 | - name: Static code analysis 18 | run: npm run lint 19 | - name: Compile Typescript 20 | run: npm run compile 21 | - name: Run tests 22 | run: npm test 23 | - name: Coveralls 24 | uses: coverallsapp/github-action@v2 25 | with: 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | path-to-lcov: ./target/coverage/lcov.info 28 | parallel: true 29 | 30 | coverage: 31 | needs: build 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Coveralls Finished 35 | uses: coverallsapp/github-action@v2 36 | with: 37 | github-token: ${{ secrets.GITHUB_TOKEN }} 38 | parallel-finished: true 39 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { baseConfig } from '@enormora/eslint-config-base'; 2 | import { nodeConfig } from '@enormora/eslint-config-node'; 3 | import { avaConfig } from '@enormora/eslint-config-ava'; 4 | import { typescriptConfig } from '@enormora/eslint-config-typescript'; 5 | 6 | export default [ 7 | { 8 | ignores: ['target/**'] 9 | }, 10 | baseConfig, 11 | nodeConfig, 12 | { 13 | ...typescriptConfig, 14 | files: ['**/*.ts'], 15 | rules: { 16 | ...typescriptConfig.rules, 17 | 'functional/prefer-immutable-types': [ 18 | 'error', 19 | { 20 | ...typescriptConfig.rules['functional/prefer-immutable-types'][1], 21 | ignoreTypePattern: ['Result'] 22 | } 23 | ] 24 | } 25 | }, 26 | { 27 | ...avaConfig, 28 | files: ['**/*.test.ts'] 29 | }, 30 | { 31 | rules: { 32 | 'max-lines-per-function': ['error', { max: 50, skipBlankLines: true, skipComments: true }] 33 | } 34 | }, 35 | { 36 | files: ['eslint.config.js', 'ava.config.js', 'prettier.config.js'], 37 | rules: { 38 | 'import/no-default-export': 'off' 39 | } 40 | }, 41 | { 42 | files: ['source/bin/pr-log.ts'], 43 | rules: { 44 | 'no-console': 'off', 45 | 'node/no-process-env': 'off', 46 | 'import/max-dependencies': 'off' 47 | } 48 | } 49 | ]; 50 | -------------------------------------------------------------------------------- /source/lib/find-remote-alias.ts: -------------------------------------------------------------------------------- 1 | import parseGitUrl from 'git-url-parse'; 2 | import { isUndefined } from '@sindresorhus/is'; 3 | import type { GitCommandRunner } from './git-command-runner.js'; 4 | 5 | function isSameGitUrl(gitUrlA: string, gitUrlB: string): boolean { 6 | const parsedUrlA = parseGitUrl(gitUrlA); 7 | const parsedUrlB = parseGitUrl(gitUrlB); 8 | const pathA = parsedUrlA.pathname.replace(/\.git$/, ''); 9 | const pathB = parsedUrlB.pathname.replace(/\.git$/, ''); 10 | 11 | return parsedUrlA.resource === parsedUrlB.resource && pathA === pathB; 12 | } 13 | 14 | function getGitUrl(githubRepo: string): string { 15 | return `git://github.com/${githubRepo}.git`; 16 | } 17 | 18 | export type FindRemoteAliasDependencies = { 19 | readonly gitCommandRunner: GitCommandRunner; 20 | }; 21 | 22 | export type FindRemoteAlias = (githubRepo: string) => Promise; 23 | 24 | export function findRemoteAliasFactory(dependencies: FindRemoteAliasDependencies): FindRemoteAlias { 25 | const { gitCommandRunner } = dependencies; 26 | 27 | return async function findRemoteAlias(githubRepo: string) { 28 | const gitRemote = getGitUrl(githubRepo); 29 | 30 | const remotes = await gitCommandRunner.getRemoteAliases(); 31 | const matchedRemote = remotes.find((remote) => { 32 | return isSameGitUrl(gitRemote, remote.url); 33 | }); 34 | 35 | if (isUndefined(matchedRemote)) { 36 | throw new TypeError(`This local git repository doesn’t have a remote pointing to ${gitRemote}`); 37 | } 38 | 39 | return matchedRemote.alias; 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /source/lib/find-remote-alias.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { fake } from 'sinon'; 3 | import { findRemoteAliasFactory, type FindRemoteAliasDependencies, type FindRemoteAlias } from './find-remote-alias.js'; 4 | import type { RemoteAlias } from './git-command-runner.js'; 5 | 6 | const githubRepo = 'foo/bar'; 7 | 8 | function factory(result: readonly RemoteAlias[] = []): FindRemoteAlias { 9 | const getRemoteAliases = fake.resolves(result); 10 | const dependencies = { gitCommandRunner: { getRemoteAliases } } as unknown as FindRemoteAliasDependencies; 11 | 12 | return findRemoteAliasFactory(dependencies); 13 | } 14 | 15 | test('rejects if no alias is found', async (t) => { 16 | const expectedGitRemote = `git://github.com/${githubRepo}.git`; 17 | const expectedErrorMessage = `This local git repository doesn’t have a remote pointing to ${expectedGitRemote}`; 18 | const findRemoteAlias = factory(); 19 | 20 | await t.throwsAsync(findRemoteAlias(githubRepo), { message: expectedErrorMessage }); 21 | }); 22 | 23 | test('resolves with the correct remote alias', async (t) => { 24 | const gitRemotes = [ 25 | { alias: 'origin', url: 'git://github.com/fork/bar' }, 26 | { alias: 'upstream', url: 'git://github.com/foo/bar' } 27 | ]; 28 | const findRemoteAlias = factory(gitRemotes); 29 | 30 | t.is(await findRemoteAlias(githubRepo), 'upstream'); 31 | }); 32 | 33 | test('works with different forms of the same URL', async (t) => { 34 | const gitRemotes = [{ alias: 'origin', url: 'git+ssh://git@github.com/foo/bar ' }]; 35 | const findRemoteAlias = factory(gitRemotes); 36 | 37 | t.is(await findRemoteAlias(githubRepo), 'origin'); 38 | }); 39 | -------------------------------------------------------------------------------- /source/lib/split.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { splitByString, splitByPattern } from './split.js'; 3 | 4 | test('splitByString() splits the given string by an empty string separator', (t) => { 5 | const result = splitByString('foo', ''); 6 | t.deepEqual(result, ['f', 'o', 'o']); 7 | }); 8 | 9 | test('splitByString() splits the given string by an non empty string separator', (t) => { 10 | const result = splitByString('foo bar', ' '); 11 | t.deepEqual(result, ['foo', 'bar']); 12 | }); 13 | 14 | test('splitByString() splits an empty string by an empty string separator', (t) => { 15 | const result = splitByString('', ''); 16 | t.deepEqual(result, []); 17 | }); 18 | 19 | test('splitByString() splits an empty string by an non empty string separator', (t) => { 20 | const result = splitByString('', ' '); 21 | t.deepEqual(result, ['']); 22 | }); 23 | 24 | test('splitByPattern() splits the given string by the given regex pattern', (t) => { 25 | const result = splitByPattern('foo bar', / /); 26 | t.deepEqual(result, ['foo', 'bar']); 27 | }); 28 | 29 | test('splitByPattern() splits an empty string by the given regex pattern', (t) => { 30 | const result = splitByPattern('', / /); 31 | t.deepEqual(result, ['']); 32 | }); 33 | 34 | test('splitByPattern() throws when an empty pattern as literal is given', (t) => { 35 | t.throws( 36 | () => { 37 | splitByPattern('foo', /(?:)/); 38 | }, 39 | { message: 'The given regex pattern was empty and can’t be used to split a string value' } 40 | ); 41 | }); 42 | 43 | test('splitByPattern() throws when an empty pattern is given', (t) => { 44 | t.throws( 45 | () => { 46 | // eslint-disable-next-line prefer-regex-literals -- we want to test if the empty regex is detected correctly using the constructor 47 | splitByPattern('foo', new RegExp('')); 48 | }, 49 | { message: 'The given regex pattern was empty and can’t be used to split a string value' } 50 | ); 51 | }); 52 | -------------------------------------------------------------------------------- /source/lib/get-pull-request-label.ts: -------------------------------------------------------------------------------- 1 | import type { Octokit } from '@octokit/rest'; 2 | import { splitByString } from './split.js'; 3 | 4 | type Dependencies = { 5 | readonly githubClient: Octokit; 6 | }; 7 | 8 | export type GetPullRequestLabel = typeof getPullRequestLabel; 9 | 10 | function determineRepoDetails(githubRepo: string): Readonly<[owner: string, repo: string]> { 11 | const [owner, repo] = splitByString(githubRepo, '/'); 12 | 13 | if (repo === undefined) { 14 | throw new TypeError('Could not find a repository'); 15 | } 16 | 17 | return [owner, repo]; 18 | } 19 | 20 | type Label = Readonly>['data'][number]>; 21 | 22 | async function fetchLabels( 23 | githubClient: Readonly, 24 | githubRepo: string, 25 | pullRequestId: number 26 | ): Promise { 27 | const [owner, repo] = determineRepoDetails(githubRepo); 28 | const params = { owner, repo, issue_number: pullRequestId }; 29 | const { data: labels } = await githubClient.issues.listLabelsOnIssue(params); 30 | 31 | return labels; 32 | } 33 | 34 | export async function getPullRequestLabel( 35 | githubRepo: string, 36 | validLabels: ReadonlyMap, 37 | pullRequestId: number, 38 | dependencies: Dependencies 39 | ): Promise { 40 | const { githubClient } = dependencies; 41 | const validLabelNames = Array.from(validLabels.keys()); 42 | 43 | const labels = await fetchLabels(githubClient, githubRepo, pullRequestId); 44 | 45 | const listOfLabels = validLabelNames.join(', '); 46 | const filteredLabels = labels.filter((label) => { 47 | return validLabelNames.includes(label.name); 48 | }); 49 | const [firstLabel] = filteredLabels; 50 | 51 | if (filteredLabels.length > 1) { 52 | throw new Error(`Pull Request #${pullRequestId} has multiple labels of ${listOfLabels}`); 53 | } else if (firstLabel === undefined) { 54 | throw new TypeError(`Pull Request #${pullRequestId} has no label of ${listOfLabels}`); 55 | } 56 | 57 | return firstLabel.name; 58 | } 59 | -------------------------------------------------------------------------------- /source/lib/version-number.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { Factory, type DeepPartial } from 'fishery'; 3 | import { Maybe, Result, Unit } from 'true-myth'; 4 | import type { Just } from 'true-myth/maybe'; 5 | import { validateVersionNumber, type ValidateVersionNumberOptions } from './version-number.js'; 6 | 7 | const validateVersionNumberOptionsFactory = Factory.define(() => { 8 | return { 9 | unreleased: false, 10 | versionNumber: Maybe.just('1.2.3') as Just 11 | }; 12 | }); 13 | 14 | const testValidateVersionNumberMacro = test.macro( 15 | (t, optionsOverrides: DeepPartial, expected: Result) => { 16 | const options = validateVersionNumberOptionsFactory.build(optionsOverrides); 17 | 18 | const actual = validateVersionNumber(options); 19 | 20 | t.deepEqual(actual, expected); 21 | } 22 | ); 23 | 24 | test( 25 | 'validateVersionNumber() returns a Result Ok when version is unreleased', 26 | testValidateVersionNumberMacro, 27 | { 28 | unreleased: true, 29 | versionNumber: Maybe.nothing() 30 | }, 31 | Result.ok(Unit) 32 | ); 33 | 34 | test( 35 | 'validateVersionNumber() returns a Result Err when version number is an empty string', 36 | testValidateVersionNumberMacro, 37 | { 38 | unreleased: false, 39 | versionNumber: Maybe.just('') as Just 40 | }, 41 | Result.err(new TypeError('version-number not specified')) 42 | ); 43 | 44 | test( 45 | 'validateVersionNumber() returns a Result Err when version number is not valid', 46 | testValidateVersionNumberMacro, 47 | { 48 | unreleased: false, 49 | versionNumber: Maybe.just('foo.bar') as Just 50 | }, 51 | Result.err(new Error('version-number is invalid')) 52 | ); 53 | 54 | test( 55 | 'validateVersionNumber() returns a Result Ok when version number is valid', 56 | testValidateVersionNumberMacro, 57 | { 58 | unreleased: false, 59 | versionNumber: Maybe.just('1.2.3') as Just 60 | }, 61 | Result.ok(Unit) 62 | ); 63 | -------------------------------------------------------------------------------- /source/lib/cli-run-options.ts: -------------------------------------------------------------------------------- 1 | import Maybe, { type Just, type Nothing } from 'true-myth/maybe'; 2 | import Result from 'true-myth/result'; 3 | import { isString } from '@sindresorhus/is'; 4 | import { InvalidArgumentError } from 'commander'; 5 | 6 | type CliRunOptionsUnreleased = { 7 | readonly unreleased: true; 8 | readonly versionNumber: Nothing; 9 | readonly changelogPath: string; 10 | readonly sloppy: boolean; 11 | readonly stdout: boolean; 12 | }; 13 | 14 | type CliRunOptionsReleased = { 15 | readonly unreleased: false; 16 | readonly versionNumber: Just; 17 | readonly changelogPath: string; 18 | readonly sloppy: boolean; 19 | readonly stdout: boolean; 20 | }; 21 | 22 | export type CliRunOptions = CliRunOptionsReleased | CliRunOptionsUnreleased; 23 | 24 | export type CreateCliRunOptions = { 25 | readonly versionNumber: string | undefined; 26 | readonly commandOptions: Record; 27 | readonly changelogPath: string; 28 | }; 29 | 30 | export function createCliRunOptions(options: CreateCliRunOptions): Result { 31 | const { versionNumber, commandOptions, changelogPath } = options; 32 | const unreleased = commandOptions.unreleased === true; 33 | 34 | const commonRunOptions = { 35 | sloppy: commandOptions.sloppy === true, 36 | changelogPath, 37 | stdout: commandOptions.stdout === true, 38 | unreleased 39 | }; 40 | 41 | if (unreleased) { 42 | if (isString(versionNumber)) { 43 | return Result.err( 44 | new InvalidArgumentError('A version number is not allowed when --unreleased was provided') 45 | ); 46 | } 47 | 48 | return Result.ok({ 49 | ...commonRunOptions, 50 | unreleased: true, 51 | versionNumber: Maybe.nothing() 52 | }); 53 | } 54 | 55 | return Maybe.of(versionNumber).match>({ 56 | Just(value) { 57 | return Result.ok({ 58 | ...commonRunOptions, 59 | unreleased: false, 60 | versionNumber: Maybe.just(value) as Just 61 | }); 62 | }, 63 | Nothing() { 64 | return Result.err(new InvalidArgumentError('Version number is missing')); 65 | } 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /source/lib/ensure-clean-local-git-state.ts: -------------------------------------------------------------------------------- 1 | import { oneLine } from 'common-tags'; 2 | import type { FindRemoteAlias } from './find-remote-alias.js'; 3 | import type { GitCommandRunner } from './git-command-runner.js'; 4 | 5 | type EnsureCleanLocalGitStateOptions = { 6 | readonly defaultBranch: string; 7 | }; 8 | 9 | export type EnsureCleanLocalGitStateDependencies = { 10 | readonly gitCommandRunner: GitCommandRunner; 11 | readonly findRemoteAlias: FindRemoteAlias; 12 | }; 13 | 14 | export type EnsureCleanLocalGitState = (githubRepo: string) => Promise; 15 | 16 | export function ensureCleanLocalGitStateFactory( 17 | dependencies: EnsureCleanLocalGitStateDependencies, 18 | options: EnsureCleanLocalGitStateOptions 19 | ): EnsureCleanLocalGitState { 20 | const { gitCommandRunner, findRemoteAlias } = dependencies; 21 | 22 | async function ensureCleanLocalCopy(): Promise { 23 | const status = await gitCommandRunner.getShortStatus(); 24 | if (status !== '') { 25 | throw new Error('Local copy is not clean'); 26 | } 27 | } 28 | 29 | async function ensureDefaultBranch(): Promise { 30 | const branchName = await gitCommandRunner.getCurrentBranchName(); 31 | if (branchName !== options.defaultBranch) { 32 | throw new Error(`Not on ${options.defaultBranch} branch`); 33 | } 34 | } 35 | 36 | async function ensureLocalIsEqualToRemote(remoteAlias: string): Promise { 37 | const remoteBranch = `${remoteAlias}/${options.defaultBranch}`; 38 | 39 | const commits = await gitCommandRunner.getSymmetricDifferencesBetweenBranches( 40 | options.defaultBranch, 41 | remoteBranch 42 | ); 43 | let commitsAhead = 0; 44 | let commitsBehind = 0; 45 | 46 | commits.forEach((commit: string) => { 47 | if (commit.startsWith('>')) { 48 | commitsBehind += 1; 49 | } else { 50 | commitsAhead += 1; 51 | } 52 | }); 53 | 54 | if (commitsAhead > 0 || commitsBehind > 0) { 55 | const errorMessage = oneLine`Local git ${options.defaultBranch} branch is ${commitsAhead} commits ahead 56 | and ${commitsBehind} commits behind of ${remoteBranch}`; 57 | 58 | throw new Error(errorMessage); 59 | } 60 | } 61 | 62 | return async function ensureCleanLocalGitState(githubRepo: string): Promise { 63 | await ensureCleanLocalCopy(); 64 | await ensureDefaultBranch(); 65 | 66 | const remoteAlias = await findRemoteAlias(githubRepo); 67 | 68 | await gitCommandRunner.fetchRemote(remoteAlias); 69 | await ensureLocalIsEqualToRemote(remoteAlias); 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pr-log", 3 | "version": "6.1.1", 4 | "type": "module", 5 | "description": "Changelog generator based on GitHub Pull Requests", 6 | "bin": "target/build/source/bin/pr-log.js", 7 | "files": [ 8 | "target/build/source/", 9 | "!target/build/source/**/*.test.js", 10 | "!target/build/source/**/*.d.ts", 11 | "!target/build/source/**/*.map", 12 | "LICENSE", 13 | "README.md" 14 | ], 15 | "scripts": { 16 | "compile": "tsc --build", 17 | "prettier": "prettier '**/*.(yml|json|yaml|md)'", 18 | "eslint": "eslint . --max-warnings 0", 19 | "lint": "npm run eslint && npm run prettier -- --check", 20 | "lint:fix": "npm run eslint -- --fix && npm run prettier -- --write", 21 | "test": "c8 npm run test:unit", 22 | "pretest:unit": "npm run compile", 23 | "test:unit": "ava" 24 | }, 25 | "author": "Mathias Schreck ", 26 | "license": "MIT", 27 | "dependencies": { 28 | "@octokit/rest": "22.0.1", 29 | "@sindresorhus/is": "7.1.1", 30 | "commander": "14.0.2", 31 | "common-tags": "1.8.2", 32 | "date-fns": "2.30.0", 33 | "execa": "9.6.1", 34 | "git-url-parse": "16.0.1", 35 | "loglevel": "1.9.2", 36 | "parse-github-repo-url": "1.4.1", 37 | "prepend-file": "2.0.1", 38 | "semver": "7.7.3", 39 | "true-myth": "9.3.1" 40 | }, 41 | "devDependencies": { 42 | "@ava/typescript": "6.0.0", 43 | "@enormora/eslint-config-ava": "0.0.12", 44 | "@enormora/eslint-config-base": "0.0.11", 45 | "@enormora/eslint-config-node": "0.0.10", 46 | "@enormora/eslint-config-typescript": "0.0.11", 47 | "@types/common-tags": "1.8.4", 48 | "@types/git-url-parse": "9.0.3", 49 | "@types/node": "22.19.3", 50 | "@types/parse-github-repo-url": "1.4.2", 51 | "@types/semver": "7.7.1", 52 | "@types/sinon": "17.0.4", 53 | "ava": "6.4.1", 54 | "c8": "10.1.3", 55 | "eslint": "8.57.1", 56 | "fishery": "2.4.0", 57 | "prettier": "3.3.3", 58 | "sinon": "21.0.0", 59 | "typescript": "5.9.3" 60 | }, 61 | "repository": { 62 | "type": "git", 63 | "url": "https://github.com/lo1tuma/pr-log.git" 64 | }, 65 | "keywords": [ 66 | "pr-log", 67 | "changelog", 68 | "changelog.md", 69 | "github", 70 | "history", 71 | "history.md" 72 | ], 73 | "contributors": [ 74 | "Alexander Schmidt ", 75 | "Christian Rackerseder " 76 | ], 77 | "engines": { 78 | "node": "^22.0.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /source/lib/ensure-clean-local-git-state.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { fake, type SinonSpy } from 'sinon'; 3 | import { 4 | ensureCleanLocalGitStateFactory, 5 | type EnsureCleanLocalGitState, 6 | type EnsureCleanLocalGitStateDependencies 7 | } from './ensure-clean-local-git-state.js'; 8 | 9 | const githubRepo = 'foo/bar'; 10 | 11 | type Overrides = { 12 | readonly getShortStatus?: SinonSpy; 13 | readonly getCurrentBranchName?: SinonSpy; 14 | readonly getSymmetricDifferencesBetweenBranches?: SinonSpy; 15 | readonly fetchRemote?: SinonSpy; 16 | readonly findRemoteAlias?: SinonSpy; 17 | }; 18 | 19 | function factory(overrides: Overrides = {}): EnsureCleanLocalGitState { 20 | const { 21 | getShortStatus = fake.resolves(''), 22 | getCurrentBranchName = fake.resolves('master'), 23 | getSymmetricDifferencesBetweenBranches = fake.resolves([]), 24 | fetchRemote = fake.resolves(undefined), 25 | findRemoteAlias = fake.resolves('origin') 26 | } = overrides; 27 | const fakeDependencies = { 28 | gitCommandRunner: { 29 | getShortStatus, 30 | getCurrentBranchName, 31 | getSymmetricDifferencesBetweenBranches, 32 | fetchRemote 33 | }, 34 | findRemoteAlias 35 | } as unknown as EnsureCleanLocalGitStateDependencies; 36 | 37 | return ensureCleanLocalGitStateFactory(fakeDependencies, { defaultBranch: 'master' }); 38 | } 39 | 40 | test('rejects if git status is not empty', async (t) => { 41 | const ensureCleanLocalGitState = factory({ getShortStatus: fake.resolves('M foobar') }); 42 | 43 | await t.throwsAsync(ensureCleanLocalGitState(githubRepo), { message: 'Local copy is not clean' }); 44 | }); 45 | 46 | test('rejects if current branch is not default branch', async (t) => { 47 | const ensureCleanLocalGitState = factory({ getCurrentBranchName: fake.resolves('feature-foo') }); 48 | 49 | await t.throwsAsync(ensureCleanLocalGitState(githubRepo), { message: 'Not on master branch' }); 50 | }); 51 | 52 | test('rejects if the local branch is ahead of the remote', async (t) => { 53 | const ensureCleanLocalGitState = factory({ 54 | getSymmetricDifferencesBetweenBranches: fake.resolves([' { 62 | const ensureCleanLocalGitState = factory({ 63 | getSymmetricDifferencesBetweenBranches: fake.resolves(['>commit-sha1']) 64 | }); 65 | const expectedMessage = 'Local git master branch is 0 commits ahead and 1 commits behind of origin/master'; 66 | 67 | await t.throwsAsync(ensureCleanLocalGitState(githubRepo), { message: expectedMessage }); 68 | }); 69 | 70 | test('fetches the remote repository', async (t) => { 71 | const fetchRemote = fake.resolves(undefined); 72 | const ensureCleanLocalGitState = factory({ fetchRemote }); 73 | 74 | await ensureCleanLocalGitState(githubRepo); 75 | 76 | t.is(fetchRemote.callCount, 1); 77 | t.deepEqual(fetchRemote.firstCall.args, ['origin']); 78 | }); 79 | 80 | test('fulfills if the local git state is clean', async (t) => { 81 | const ensureCleanLocalGitState = factory(); 82 | 83 | await ensureCleanLocalGitState(githubRepo); 84 | 85 | t.pass(); 86 | }); 87 | -------------------------------------------------------------------------------- /source/lib/get-pull-request-label.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { fake, type SinonSpy } from 'sinon'; 3 | import { oneLine } from 'common-tags'; 4 | import type { Octokit } from '@octokit/rest'; 5 | import { getPullRequestLabel } from './get-pull-request-label.js'; 6 | import { defaultValidLabels } from './valid-labels.js'; 7 | 8 | type Overrides = { 9 | readonly listLabelsOnIssue?: SinonSpy; 10 | }; 11 | 12 | function createGithubClient(overrides: Overrides = {}): Readonly { 13 | const { listLabelsOnIssue = fake.resolves({ data: [] }) } = overrides; 14 | 15 | return { 16 | issues: { listLabelsOnIssue } 17 | } as unknown as Octokit; 18 | } 19 | 20 | const anyRepo = 'any/repo'; 21 | const anyPullRequestId = 123; 22 | 23 | test('throws when the given repo doesn’t have a "/"', async (t) => { 24 | const githubClient = createGithubClient(); 25 | 26 | await t.throwsAsync(getPullRequestLabel('foo', defaultValidLabels, anyPullRequestId, { githubClient }), { 27 | message: 'Could not find a repository' 28 | }); 29 | }); 30 | 31 | test('requests the labels for the correct repo and pull request', async (t) => { 32 | const listLabelsOnIssue = fake.resolves({ data: [{ name: 'bug' }] }); 33 | const githubClient = createGithubClient({ listLabelsOnIssue }); 34 | 35 | await getPullRequestLabel(anyRepo, defaultValidLabels, anyPullRequestId, { githubClient }); 36 | 37 | t.is(listLabelsOnIssue.callCount, 1); 38 | t.deepEqual(listLabelsOnIssue.firstCall.args, [{ owner: 'any', repo: 'repo', issue_number: 123 }]); 39 | }); 40 | 41 | test('fulfills with the correct label name', async (t) => { 42 | const listLabelsOnIssue = fake.resolves({ data: [{ name: 'bug' }] }); 43 | const githubClient = createGithubClient({ listLabelsOnIssue }); 44 | 45 | const expectedLabelName = 'bug'; 46 | 47 | t.is(await getPullRequestLabel(anyRepo, defaultValidLabels, anyPullRequestId, { githubClient }), expectedLabelName); 48 | }); 49 | 50 | test('uses custom labels when provided', async (t) => { 51 | const listLabelsOnIssue = fake.resolves({ data: [{ name: 'addons' }] }); 52 | const githubClient = createGithubClient({ listLabelsOnIssue }); 53 | 54 | const expectedLabelName = 'addons'; 55 | const customValidLabels = new Map([['addons', 'Addons']]); 56 | 57 | t.is(await getPullRequestLabel(anyRepo, customValidLabels, anyPullRequestId, { githubClient }), expectedLabelName); 58 | }); 59 | 60 | test('rejects if the pull request doesn’t have one valid label', async (t) => { 61 | const githubClient = createGithubClient(); 62 | 63 | const expectedErrorMessage = oneLine`Pull Request #123 has no label of breaking, bug, 64 | feature, enhancement, documentation, upgrade, refactor, build`; 65 | 66 | await t.throwsAsync(getPullRequestLabel(anyRepo, defaultValidLabels, anyPullRequestId, { githubClient }), { 67 | message: expectedErrorMessage 68 | }); 69 | }); 70 | 71 | test('rejects if the pull request has more than one valid label', async (t) => { 72 | const listLabelsOnIssue = fake.resolves({ data: [{ name: 'bug' }, { name: 'documentation' }] }); 73 | const githubClient = createGithubClient({ listLabelsOnIssue }); 74 | const expectedErrorMessage = oneLine`Pull Request #123 has multiple labels of breaking, 75 | bug, feature, enhancement, documentation, upgrade, refactor, build`; 76 | 77 | await t.throwsAsync(getPullRequestLabel(anyRepo, defaultValidLabels, anyPullRequestId, { githubClient }), { 78 | message: expectedErrorMessage 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /source/lib/get-merged-pull-requests.ts: -------------------------------------------------------------------------------- 1 | import semver from 'semver'; 2 | import type { Octokit } from '@octokit/rest'; 3 | import { isUndefined } from '@sindresorhus/is'; 4 | import type { GetPullRequestLabel } from './get-pull-request-label.js'; 5 | import type { GitCommandRunner } from './git-command-runner.js'; 6 | 7 | export type PullRequest = { 8 | readonly id: number; 9 | readonly title: string; 10 | }; 11 | 12 | export type PullRequestWithLabel = PullRequest & { 13 | readonly label: string; 14 | }; 15 | 16 | export type GetMergedPullRequestsDependencies = { 17 | readonly gitCommandRunner: GitCommandRunner; 18 | readonly getPullRequestLabel: GetPullRequestLabel; 19 | readonly githubClient: Octokit; 20 | }; 21 | 22 | export type GetMergedPullRequests = ( 23 | repo: string, 24 | validLabels: ReadonlyMap 25 | ) => Promise; 26 | 27 | export function getMergedPullRequestsFactory(dependencies: GetMergedPullRequestsDependencies): GetMergedPullRequests { 28 | const { gitCommandRunner, getPullRequestLabel } = dependencies; 29 | 30 | async function getLatestVersionTag(): Promise { 31 | const tags = await gitCommandRunner.listTags(); 32 | const versionTags = tags.filter((tag: string) => { 33 | return semver.valid(tag) !== null && semver.prerelease(tag) === null; 34 | }); 35 | const orderedVersionTags = versionTags.sort(semver.compare); 36 | const latestTag = orderedVersionTags.at(-1); 37 | 38 | if (isUndefined(latestTag)) { 39 | throw new TypeError('Failed to determine latest version number git tag'); 40 | } 41 | 42 | return latestTag; 43 | } 44 | 45 | async function getPullRequests(fromTag: string): Promise { 46 | const mergeCommits = await gitCommandRunner.getMergeCommitLogs(fromTag); 47 | 48 | return mergeCommits.map((log) => { 49 | const matches = /^Merge pull request #(?\d+) from .*?$/u.exec(log.subject); 50 | if (isUndefined(matches?.groups?.id)) { 51 | throw new TypeError('Failed to extract pull request id from merge commit log'); 52 | } 53 | 54 | if (isUndefined(log.body)) { 55 | throw new TypeError('Failed to extract pull request title from merge commit log'); 56 | } 57 | 58 | return { id: Number.parseInt(matches.groups.id, 10), title: log.body }; 59 | }); 60 | } 61 | 62 | async function extendWithLabel( 63 | githubRepo: string, 64 | validLabels: ReadonlyMap, 65 | pullRequests: readonly PullRequest[] 66 | ): Promise { 67 | const promises = pullRequests.map(async (pullRequest): Promise => { 68 | const label = await getPullRequestLabel(githubRepo, validLabels, pullRequest.id, dependencies); 69 | 70 | return { 71 | id: pullRequest.id, 72 | title: pullRequest.title, 73 | label 74 | }; 75 | }); 76 | 77 | return Promise.all(promises); 78 | } 79 | 80 | return async function getMergedPullRequests(githubRepo: string, validLabels: ReadonlyMap) { 81 | const latestVersionTag = await getLatestVersionTag(); 82 | const pullRequests = await getPullRequests(latestVersionTag); 83 | const pullRequestsWithLabels = await extendWithLabel(githubRepo, validLabels, pullRequests); 84 | 85 | return pullRequestsWithLabels; 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /source/lib/cli.ts: -------------------------------------------------------------------------------- 1 | import type _prependFile from 'prepend-file'; 2 | import type { Logger } from 'loglevel'; 3 | import { isPlainObject, isString } from '@sindresorhus/is'; 4 | import type { CliRunOptions } from './cli-run-options.js'; 5 | import type { CreateChangelog } from './create-changelog.js'; 6 | import type { GetMergedPullRequests } from './get-merged-pull-requests.js'; 7 | import type { EnsureCleanLocalGitState } from './ensure-clean-local-git-state.js'; 8 | import { validateVersionNumber } from './version-number.js'; 9 | import { getGithubRepo } from './get-github-repo.js'; 10 | import { defaultValidLabels } from './valid-labels.js'; 11 | 12 | function stripTrailingEmptyLine(text: string): string { 13 | if (text.endsWith('\n\n')) { 14 | return text.slice(0, -1); 15 | } 16 | 17 | return text; 18 | } 19 | 20 | function getValidLabels(packageInfo: Record): ReadonlyMap { 21 | const prLogConfig = packageInfo['pr-log']; 22 | if (isPlainObject(prLogConfig) && Array.isArray(prLogConfig.validLabels)) { 23 | return new Map(prLogConfig.validLabels); 24 | } 25 | 26 | return defaultValidLabels; 27 | } 28 | 29 | export type CliRunnerDependencies = { 30 | readonly ensureCleanLocalGitState: EnsureCleanLocalGitState; 31 | readonly getMergedPullRequests: GetMergedPullRequests; 32 | readonly createChangelog: CreateChangelog; 33 | readonly packageInfo: Record; 34 | readonly prependFile: typeof _prependFile; 35 | readonly logger: Logger; 36 | }; 37 | 38 | export type CliRunner = { 39 | run(options: CliRunOptions): Promise; 40 | }; 41 | 42 | export function createCliRunner(dependencies: CliRunnerDependencies): CliRunner { 43 | const { ensureCleanLocalGitState, getMergedPullRequests, createChangelog, packageInfo, prependFile, logger } = 44 | dependencies; 45 | 46 | async function generateChangelog( 47 | options: CliRunOptions, 48 | githubRepo: string, 49 | validLabels: ReadonlyMap 50 | ): Promise { 51 | if (!options.sloppy) { 52 | await ensureCleanLocalGitState(githubRepo); 53 | } 54 | 55 | const mergedPullRequests = await getMergedPullRequests(githubRepo, validLabels); 56 | const commonChangelogOptions = { validLabels, mergedPullRequests, githubRepo }; 57 | const changelogOptions = options.unreleased 58 | ? ({ ...commonChangelogOptions, unreleased: true, versionNumber: options.versionNumber } as const) 59 | : ({ ...commonChangelogOptions, unreleased: false, versionNumber: options.versionNumber } as const); 60 | const changelog = createChangelog(changelogOptions); 61 | 62 | return stripTrailingEmptyLine(changelog); 63 | } 64 | 65 | async function writeChangelog(changelog: string, options: CliRunOptions): Promise { 66 | const trimmedChangelog = changelog.trim(); 67 | 68 | if (options.stdout) { 69 | logger.log(trimmedChangelog); 70 | } else { 71 | await prependFile(options.changelogPath, `${trimmedChangelog}\n\n`); 72 | } 73 | } 74 | 75 | return { 76 | async run(options: CliRunOptions) { 77 | const { repository } = packageInfo; 78 | if (!isPlainObject(repository)) { 79 | throw new Error('Repository information missing in package.json'); 80 | } 81 | if (!isString(repository.url)) { 82 | throw new TypeError('Repository url is not a string in package.json'); 83 | } 84 | const githubRepo = getGithubRepo(repository.url); 85 | const validLabels = getValidLabels(packageInfo); 86 | 87 | validateVersionNumber(options).unwrapOrElse((error) => { 88 | throw error; 89 | }); 90 | 91 | const changelog = await generateChangelog(options, githubRepo, validLabels); 92 | 93 | await writeChangelog(changelog, options); 94 | } 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /source/lib/git-command-runner.ts: -------------------------------------------------------------------------------- 1 | import type { execaCommand } from 'execa'; 2 | import { oneLine } from 'common-tags'; 3 | import { splitByString, splitByPattern } from './split.js'; 4 | 5 | export type RemoteAlias = { 6 | readonly alias: string; 7 | readonly url: string; 8 | }; 9 | 10 | type MergeCommitLogEntry = { 11 | readonly subject: string; 12 | readonly body: string | undefined; 13 | }; 14 | 15 | export type GitCommandRunner = { 16 | getShortStatus(): Promise; 17 | getCurrentBranchName(): Promise; 18 | fetchRemote(remoteAlias: string): Promise; 19 | getSymmetricDifferencesBetweenBranches(branchA: string, branchB: string): Promise; 20 | getRemoteAliases(): Promise; 21 | listTags(): Promise; 22 | getMergeCommitLogs(from: string): Promise; 23 | }; 24 | 25 | export type GitCommandRunnerDependencies = { 26 | readonly execute: typeof execaCommand; 27 | }; 28 | 29 | function trim(value: string): string { 30 | return value.trim(); 31 | } 32 | 33 | function isNonEmptyString(value: string): boolean { 34 | return value.length > 0; 35 | } 36 | 37 | function splitLines(value: string, lineSeparator = '\n'): readonly string[] { 38 | return splitByString(value, lineSeparator).map(trim).filter(isNonEmptyString); 39 | } 40 | 41 | const lineSeparator = '##$$@@$$##'; 42 | const fieldSeparator = '__||__'; 43 | 44 | function createParsableGitLogFormat(): string { 45 | const subjectPlaceholder = '%s'; 46 | const bodyPlaceholder = '%b'; 47 | const fields = [subjectPlaceholder, bodyPlaceholder]; 48 | 49 | return `${fields.join(fieldSeparator)}${lineSeparator}`; 50 | } 51 | 52 | export function createGitCommandRunner(dependencies: GitCommandRunnerDependencies): GitCommandRunner { 53 | const { execute } = dependencies; 54 | 55 | return { 56 | async getShortStatus() { 57 | const result = await execute('git status --short'); 58 | return result.stdout.trim(); 59 | }, 60 | 61 | async getCurrentBranchName() { 62 | const result = await execute('git rev-parse --abbrev-ref HEAD'); 63 | return result.stdout.trim(); 64 | }, 65 | 66 | async fetchRemote(remoteAlias) { 67 | await execute(`git fetch ${remoteAlias}`); 68 | }, 69 | 70 | async getSymmetricDifferencesBetweenBranches(branchA, branchB) { 71 | const result = await execute(`git rev-list --left-right ${branchA}...${branchB}`); 72 | return splitLines(result.stdout); 73 | }, 74 | 75 | async getRemoteAliases() { 76 | const result = await execute('git remote -v'); 77 | 78 | return splitLines(result.stdout).map((line: string) => { 79 | const remoteLineTokens = splitByPattern(line, /\s/); 80 | const [alias, url] = remoteLineTokens; 81 | 82 | if (url === undefined) { 83 | throw new TypeError('Failed to determine git remote alias'); 84 | } 85 | 86 | return { alias, url }; 87 | }); 88 | }, 89 | 90 | async listTags() { 91 | const result = await execute('git tag --list'); 92 | return splitLines(result.stdout); 93 | }, 94 | 95 | async getMergeCommitLogs(from) { 96 | const result = await execute(oneLine`git log --first-parent --no-color 97 | --pretty=format:${createParsableGitLogFormat()} --merges ${from}..HEAD`); 98 | 99 | const logs = splitLines(result.stdout, lineSeparator); 100 | return logs.map((log) => { 101 | const parts = splitByString(log, fieldSeparator); 102 | const [subject, body] = parts; 103 | 104 | return { subject, body: body === '' ? undefined : body }; 105 | }); 106 | } 107 | }; 108 | } 109 | -------------------------------------------------------------------------------- /source/lib/create-changelog.ts: -------------------------------------------------------------------------------- 1 | import { format as formatDate } from 'date-fns'; 2 | import { isPlainObject, isArray, isString } from '@sindresorhus/is'; 3 | import enLocale from 'date-fns/locale/en-US/index.js'; 4 | import type { Just, Nothing } from 'true-myth/maybe'; 5 | import type { PullRequest, PullRequestWithLabel } from './get-merged-pull-requests.js'; 6 | 7 | function formatLinkToPullRequest(pullRequestId: number, repo: string): string { 8 | return `[#${pullRequestId}](https://github.com/${repo}/pull/${pullRequestId})`; 9 | } 10 | 11 | function formatPullRequest(pullRequest: PullRequest, repo: string): string { 12 | return `* ${pullRequest.title} (${formatLinkToPullRequest(pullRequest.id, repo)})\n`; 13 | } 14 | 15 | function formatListOfPullRequests(pullRequests: readonly PullRequest[], repo: string): string { 16 | return pullRequests 17 | .map((pr) => { 18 | return formatPullRequest(pr, repo); 19 | }) 20 | .join(''); 21 | } 22 | 23 | function formatSection(displayLabel: string, pullRequests: readonly PullRequest[], repo: string): string { 24 | return `### ${displayLabel}\n\n${formatListOfPullRequests(pullRequests, repo)}\n`; 25 | } 26 | 27 | export type CreateChangelog = (options: ChangelogOptions) => string; 28 | 29 | type PackageInfo = Record; 30 | 31 | function getConfigValueFromPackageInfo(packageInfo: PackageInfo, fieldName: string, fallback: string): string { 32 | const prLogConfig = packageInfo['pr-log']; 33 | 34 | if (isPlainObject(prLogConfig)) { 35 | const field = prLogConfig[fieldName]; 36 | if (isString(field)) { 37 | return field; 38 | } 39 | } 40 | 41 | return fallback; 42 | } 43 | 44 | function groupByLabel(pullRequests: readonly PullRequestWithLabel[]): Record { 45 | return pullRequests.reduce((groupedObject: Record, pullRequest) => { 46 | const { label } = pullRequest; 47 | const group = groupedObject[label]; 48 | 49 | if (isArray(group)) { 50 | return { 51 | ...groupedObject, 52 | [label]: [...group, pullRequest] 53 | }; 54 | } 55 | 56 | return { 57 | ...groupedObject, 58 | [label]: [pullRequest] 59 | }; 60 | }, {}); 61 | } 62 | 63 | type Dependencies = { 64 | readonly packageInfo: PackageInfo; 65 | getCurrentDate(): Readonly; 66 | }; 67 | 68 | type ChangelogOptionsUnreleased = { 69 | readonly unreleased: true; 70 | readonly versionNumber: Nothing; 71 | readonly validLabels: ReadonlyMap; 72 | readonly mergedPullRequests: readonly PullRequestWithLabel[]; 73 | readonly githubRepo: string; 74 | }; 75 | 76 | type ChangelogOptionsReleased = { 77 | readonly unreleased: false; 78 | readonly versionNumber: Just; 79 | readonly validLabels: ReadonlyMap; 80 | readonly mergedPullRequests: readonly PullRequestWithLabel[]; 81 | readonly githubRepo: string; 82 | }; 83 | 84 | export type ChangelogOptions = ChangelogOptionsReleased | ChangelogOptionsUnreleased; 85 | 86 | const defaultDateFormat = 'MMMM d, yyyy'; 87 | 88 | export function createChangelogFactory(dependencies: Dependencies): CreateChangelog { 89 | const { getCurrentDate, packageInfo } = dependencies; 90 | const dateFormat = getConfigValueFromPackageInfo(packageInfo, 'dateFormat', defaultDateFormat); 91 | 92 | function createChangelogTitle(options: ChangelogOptions): string { 93 | const { unreleased } = options; 94 | 95 | if (unreleased) { 96 | return ''; 97 | } 98 | 99 | const date = formatDate(getCurrentDate(), dateFormat, { locale: enLocale }); 100 | const title = `## ${options.versionNumber.value} (${date})`; 101 | 102 | return `${title}\n\n`; 103 | } 104 | 105 | return function createChangelog(options) { 106 | const { validLabels, mergedPullRequests, githubRepo } = options; 107 | const groupedPullRequests = groupByLabel(mergedPullRequests); 108 | 109 | let changelog = createChangelogTitle(options); 110 | 111 | for (const [label, displayLabel] of validLabels) { 112 | const pullRequests = groupedPullRequests[label]; 113 | 114 | if (isArray(pullRequests)) { 115 | changelog += formatSection(displayLabel, pullRequests, githubRepo); 116 | } 117 | } 118 | 119 | return changelog; 120 | }; 121 | } 122 | -------------------------------------------------------------------------------- /source/bin/pr-log.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import path from 'node:path'; 4 | import fs from 'node:fs/promises'; 5 | import { createCommand } from 'commander'; 6 | import { Octokit } from '@octokit/rest'; 7 | import prependFile from 'prepend-file'; 8 | import { execaCommand } from 'execa'; 9 | import loglevel from 'loglevel'; 10 | import { isString } from '@sindresorhus/is'; 11 | import { createCliRunOptions } from '../lib/cli-run-options.js'; 12 | import { createCliRunner, type CliRunnerDependencies } from '../lib/cli.js'; 13 | import { ensureCleanLocalGitStateFactory } from '../lib/ensure-clean-local-git-state.js'; 14 | import { getMergedPullRequestsFactory } from '../lib/get-merged-pull-requests.js'; 15 | import { createChangelogFactory } from '../lib/create-changelog.js'; 16 | import { findRemoteAliasFactory } from '../lib/find-remote-alias.js'; 17 | import { getPullRequestLabel } from '../lib/get-pull-request-label.js'; 18 | import { createGitCommandRunner } from '../lib/git-command-runner.js'; 19 | 20 | loglevel.enableAll(); 21 | 22 | async function readJson(filePath: string): Promise> { 23 | const fileContent = await fs.readFile(filePath, { encoding: 'utf8' }); 24 | return JSON.parse(fileContent) as Record; 25 | } 26 | 27 | const prLogPackageJsonURL = new URL('../../../../package.json', import.meta.url); 28 | const config = (await readJson(prLogPackageJsonURL.pathname)) as Record; 29 | 30 | const { GH_TOKEN } = process.env; 31 | const githubClient = new Octokit({ auth: GH_TOKEN }); 32 | 33 | let isTracingEnabled = false; 34 | 35 | const changelogPath = path.join(process.cwd(), 'CHANGELOG.md'); 36 | const gitCommandRunner = createGitCommandRunner({ execute: execaCommand }); 37 | const findRemoteAlias = findRemoteAliasFactory({ gitCommandRunner }); 38 | const getMergedPullRequests = getMergedPullRequestsFactory({ 39 | githubClient, 40 | gitCommandRunner, 41 | getPullRequestLabel 42 | }); 43 | const getCurrentDate = (): Readonly => { 44 | return new Date(); 45 | }; 46 | 47 | const program = createCommand(config.name ?? ''); 48 | program 49 | .storeOptionsAsProperties(false) 50 | .description(config.description ?? '') 51 | .version(config.version ?? '') 52 | .argument('[version-number]', 'Desired version number. Must not be provided when --unreleased was specified') 53 | .option('--sloppy', 'skip ensuring clean local git state', false) 54 | .option('--trace', 'show stack traces for any error', false) 55 | .option('--default-branch ', 'set custom default branch', 'main') 56 | .option('--stdout', 'output the changelog to stdout instead of writing to CHANGELOG.md', false) 57 | .option('--unreleased', 'include section for unreleased changes', false) 58 | .action(async (versionNumber: string | undefined, options: Record) => { 59 | isTracingEnabled = options.trace === true; 60 | 61 | const runOptionsResult = createCliRunOptions({ versionNumber, changelogPath, commandOptions: options }); 62 | 63 | await runOptionsResult.match({ 64 | async Ok(runOptions) { 65 | const defaultBranch = options.defaultBranch as string; 66 | if (isString(GH_TOKEN)) { 67 | await githubClient.auth(); 68 | } 69 | const packageInfo = await readJson(path.join(process.cwd(), 'package.json')); 70 | const dependencies: CliRunnerDependencies = { 71 | prependFile, 72 | packageInfo, 73 | logger: loglevel, 74 | ensureCleanLocalGitState: ensureCleanLocalGitStateFactory( 75 | { gitCommandRunner, findRemoteAlias }, 76 | { defaultBranch } 77 | ), 78 | getMergedPullRequests, 79 | createChangelog: createChangelogFactory({ getCurrentDate, packageInfo }) 80 | }; 81 | const cliRunner = createCliRunner(dependencies); 82 | await cliRunner.run(runOptions); 83 | }, 84 | Err(error) { 85 | throw error; 86 | } 87 | }); 88 | }); 89 | 90 | function crash(error: Readonly): void { 91 | let message: string | undefined = `Error: ${error.message}`; 92 | 93 | if (isTracingEnabled) { 94 | message = error.stack; 95 | } 96 | 97 | console.error(message); 98 | process.exitCode = 1; 99 | } 100 | 101 | program.parseAsync(process.argv).catch(crash); 102 | -------------------------------------------------------------------------------- /source/lib/cli-run-options.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { Factory, type DeepPartial } from 'fishery'; 3 | import Result from 'true-myth/result'; 4 | import { InvalidArgumentError } from 'commander'; 5 | import Maybe, { type Just } from 'true-myth/maybe'; 6 | import { createCliRunOptions, type CliRunOptions, type CreateCliRunOptions } from './cli-run-options.js'; 7 | 8 | const createCliRunOptionsFactory = Factory.define(() => { 9 | return { 10 | versionNumber: undefined, 11 | commandOptions: {}, 12 | changelogPath: '' 13 | }; 14 | }); 15 | 16 | const testCreateCliRunOptionsMacro = test.macro( 17 | (t, optionsOverrides: DeepPartial, expected: Result) => { 18 | const options = createCliRunOptionsFactory.build(optionsOverrides); 19 | 20 | const actual = createCliRunOptions(options); 21 | 22 | t.deepEqual(actual, expected); 23 | } 24 | ); 25 | 26 | test( 27 | 'createCliRunOptions() returns a Result Error when "unreleased" command option exists and a version number was provided', 28 | testCreateCliRunOptionsMacro, 29 | { 30 | commandOptions: { 31 | unreleased: true 32 | }, 33 | versionNumber: '1.2.3' 34 | }, 35 | Result.err( 36 | new InvalidArgumentError('A version number is not allowed when --unreleased was provided') 37 | ) 38 | ); 39 | 40 | test( 41 | 'createCliRunOptions() returns a Result Ok when "unreleased" command option exists and no version number was provided', 42 | testCreateCliRunOptionsMacro, 43 | { 44 | commandOptions: { 45 | unreleased: true 46 | }, 47 | versionNumber: undefined 48 | }, 49 | Result.ok({ 50 | unreleased: true, 51 | versionNumber: Maybe.nothing(), 52 | sloppy: false, 53 | changelogPath: '', 54 | stdout: false 55 | }) 56 | ); 57 | 58 | test( 59 | 'createCliRunOptions() returns a Result Error when "unreleased" command option does not exists and no version number was provided', 60 | testCreateCliRunOptionsMacro, 61 | { 62 | commandOptions: { 63 | unreleased: undefined 64 | }, 65 | versionNumber: undefined 66 | }, 67 | Result.err(new InvalidArgumentError('Version number is missing')) 68 | ); 69 | 70 | test( 71 | 'createCliRunOptions() returns a Result Error when "unreleased" command option is false and no version number was provided', 72 | testCreateCliRunOptionsMacro, 73 | { 74 | commandOptions: { 75 | unreleased: false 76 | }, 77 | versionNumber: undefined 78 | }, 79 | Result.err(new InvalidArgumentError('Version number is missing')) 80 | ); 81 | 82 | test( 83 | 'createCliRunOptions() returns a Result Ok when "unreleased" command option is false and a version number was provided', 84 | testCreateCliRunOptionsMacro, 85 | { 86 | commandOptions: { 87 | unreleased: false 88 | }, 89 | versionNumber: '1.2.3' 90 | }, 91 | Result.ok({ 92 | unreleased: false, 93 | versionNumber: Maybe.just('1.2.3') as Just, 94 | sloppy: false, 95 | changelogPath: '', 96 | stdout: false 97 | }) 98 | ); 99 | 100 | test( 101 | 'createCliRunOptions() returns a Result Ok and sets sloppy to true when command option sloppy was also set to true', 102 | testCreateCliRunOptionsMacro, 103 | { 104 | commandOptions: { 105 | unreleased: false, 106 | sloppy: true 107 | }, 108 | versionNumber: '1.2.3' 109 | }, 110 | Result.ok({ 111 | unreleased: false, 112 | versionNumber: Maybe.just('1.2.3') as Just, 113 | sloppy: true, 114 | changelogPath: '', 115 | stdout: false 116 | }) 117 | ); 118 | 119 | test( 120 | 'createCliRunOptions() returns a Result Ok and sets stdout to true when command option stdout was also set to true', 121 | testCreateCliRunOptionsMacro, 122 | { 123 | commandOptions: { 124 | unreleased: false, 125 | stdout: true 126 | }, 127 | versionNumber: '1.2.3' 128 | }, 129 | Result.ok({ 130 | unreleased: false, 131 | versionNumber: Maybe.just('1.2.3') as Just, 132 | sloppy: false, 133 | changelogPath: '', 134 | stdout: true 135 | }) 136 | ); 137 | -------------------------------------------------------------------------------- /source/lib/get-merged-pull-requests.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { fake, type SinonSpy } from 'sinon'; 3 | import { defaultValidLabels } from './valid-labels.js'; 4 | import { 5 | getMergedPullRequestsFactory, 6 | type GetMergedPullRequests, 7 | type GetMergedPullRequestsDependencies 8 | } from './get-merged-pull-requests.js'; 9 | 10 | const anyRepo = 'any/repo'; 11 | const latestVersion = '1.2.3'; 12 | 13 | type Overrides = { 14 | readonly listTags?: SinonSpy; 15 | readonly getMergeCommitLogs?: SinonSpy; 16 | readonly getPullRequestLabel?: SinonSpy; 17 | }; 18 | 19 | function factory(overrides: Overrides = {}): GetMergedPullRequests { 20 | const { 21 | listTags = fake.resolves([latestVersion]), 22 | getMergeCommitLogs = fake.resolves([]), 23 | getPullRequestLabel = fake.resolves('bug') 24 | } = overrides; 25 | 26 | const dependencies = { 27 | getPullRequestLabel, 28 | gitCommandRunner: { listTags, getMergeCommitLogs } 29 | } as unknown as GetMergedPullRequestsDependencies; 30 | 31 | return getMergedPullRequestsFactory(dependencies); 32 | } 33 | 34 | test('throws when there is no tag at all', async (t) => { 35 | const listTags = fake.resolves([]); 36 | const getMergedPullRequests = factory({ listTags }); 37 | 38 | await t.throwsAsync(getMergedPullRequests(anyRepo, defaultValidLabels), { 39 | message: 'Failed to determine latest version number git tag' 40 | }); 41 | }); 42 | 43 | test('throws when there are only non-semver tags', async (t) => { 44 | const listTags = fake.resolves(['foo', 'bar']); 45 | const getMergedPullRequests = factory({ listTags }); 46 | 47 | await t.throwsAsync(getMergedPullRequests(anyRepo, defaultValidLabels), { 48 | message: 'Failed to determine latest version number git tag' 49 | }); 50 | }); 51 | 52 | test('ignores non-semver tag', async (t) => { 53 | const listTags = fake.resolves(['0.0.1', 'foo', '0.0.2', '0.0.0.0.1']); 54 | const getMergeCommitLogs = fake.resolves([]); 55 | const getMergedPullRequests = factory({ listTags, getMergeCommitLogs }); 56 | 57 | await getMergedPullRequests(anyRepo, defaultValidLabels); 58 | 59 | t.is(getMergeCommitLogs.callCount, 1); 60 | t.deepEqual(getMergeCommitLogs.firstCall.args, ['0.0.2']); 61 | }); 62 | 63 | test('always uses the highest version', async (t) => { 64 | const listTags = fake.resolves(['1.0.0', '0.0.0', '0.7.5', '2.0.0', '0.2.5', '0.5.0']); 65 | const getMergeCommitLogs = fake.resolves([]); 66 | const getMergedPullRequests = factory({ listTags, getMergeCommitLogs }); 67 | 68 | await getMergedPullRequests(anyRepo, defaultValidLabels); 69 | 70 | t.is(getMergeCommitLogs.callCount, 1); 71 | t.deepEqual(getMergeCommitLogs.firstCall.args, ['2.0.0']); 72 | }); 73 | 74 | test('ignores prerelease versions', async (t) => { 75 | const listTags = fake.resolves(['1.0.0', '0.0.0', '0.7.5', '2.0.0', '0.2.5', '3.0.0-alpha.1']); 76 | const getMergeCommitLogs = fake.resolves([]); 77 | const getMergedPullRequests = factory({ listTags, getMergeCommitLogs }); 78 | 79 | await getMergedPullRequests(anyRepo, defaultValidLabels); 80 | 81 | t.is(getMergeCommitLogs.callCount, 1); 82 | t.deepEqual(getMergeCommitLogs.firstCall.args, ['2.0.0']); 83 | }); 84 | 85 | test('throws when the pull request cannot be extracted from the commit message', async (t) => { 86 | const getMergeCommitLogs = fake.resolves([ 87 | { 88 | subject: 'Merge pull request foo from branch', 89 | body: 'pr-1 message' 90 | } 91 | ]); 92 | const getMergedPullRequests = factory({ getMergeCommitLogs }); 93 | 94 | await t.throwsAsync(getMergedPullRequests(anyRepo, defaultValidLabels), { 95 | message: 'Failed to extract pull request id from merge commit log' 96 | }); 97 | }); 98 | 99 | test('throws when the the commit log doesn’t have a body', async (t) => { 100 | const getMergeCommitLogs = fake.resolves([ 101 | { 102 | subject: 'Merge pull request #1 from branch', 103 | body: undefined 104 | } 105 | ]); 106 | const getMergedPullRequests = factory({ getMergeCommitLogs }); 107 | 108 | await t.throwsAsync(getMergedPullRequests(anyRepo, defaultValidLabels), { 109 | message: 'Failed to extract pull request title from merge commit log' 110 | }); 111 | }); 112 | 113 | test('extracts id, title and label for merged pull requests', async (t) => { 114 | const firstExpectedPullRequest = { id: 1, title: 'pr-1 message', label: 'bug' }; 115 | const secondExpectedPullRequest = { id: 2, title: 'pr-2 message', label: 'bug' }; 116 | const getPullRequestLabel = fake.resolves('bug'); 117 | const getMergeCommitLogs = fake.resolves([ 118 | { 119 | subject: 'Merge pull request #1 from branch', 120 | body: 'pr-1 message' 121 | }, 122 | { subject: 'Merge pull request #2 from other', body: 'pr-2 message' } 123 | ]); 124 | const getMergedPullRequests = factory({ getMergeCommitLogs, getPullRequestLabel }); 125 | 126 | const pullRequests = await getMergedPullRequests(anyRepo, defaultValidLabels); 127 | 128 | t.like(getPullRequestLabel.args, [ 129 | ['any/repo', defaultValidLabels, firstExpectedPullRequest.id], 130 | ['any/repo', defaultValidLabels, secondExpectedPullRequest.id] 131 | ]); 132 | 133 | t.deepEqual(pullRequests, [firstExpectedPullRequest, secondExpectedPullRequest]); 134 | }); 135 | -------------------------------------------------------------------------------- /source/lib/create-changelog.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { Factory } from 'fishery'; 3 | import Maybe, { type Just } from 'true-myth/maybe'; 4 | import { createChangelogFactory, type ChangelogOptions } from './create-changelog.js'; 5 | import { defaultValidLabels } from './valid-labels.js'; 6 | 7 | const changelogOptionsFactory = Factory.define(() => { 8 | return { 9 | unreleased: false, 10 | versionNumber: Maybe.just('1.0.0') as Just, 11 | validLabels: defaultValidLabels, 12 | mergedPullRequests: [], 13 | githubRepo: '' 14 | }; 15 | }); 16 | 17 | test('contains no title when version was not released', (t) => { 18 | const createChangelog = createChangelogFactory({ 19 | getCurrentDate: () => { 20 | return new Date(0); 21 | }, 22 | packageInfo: {} 23 | }); 24 | const options = changelogOptionsFactory.build({ 25 | unreleased: true, 26 | versionNumber: Maybe.nothing() 27 | }); 28 | 29 | const changelog = createChangelog(options); 30 | const expected = ''; 31 | 32 | t.is(changelog, expected); 33 | }); 34 | 35 | test('contains a title with the version number and the formatted date when version was released', (t) => { 36 | const createChangelog = createChangelogFactory({ 37 | getCurrentDate: () => { 38 | return new Date(0); 39 | }, 40 | packageInfo: {} 41 | }); 42 | const options = changelogOptionsFactory.build(); 43 | const changelog = createChangelog(options); 44 | const expectedTitle = '## 1.0.0 (January 1, 1970)'; 45 | 46 | t.true(changelog.includes(expectedTitle)); 47 | }); 48 | 49 | test('format the date with a custom date format when version was released', (t) => { 50 | const packageInfo = { 'pr-log': { dateFormat: 'dd.MM.yyyy' } }; 51 | const createChangelog = createChangelogFactory({ 52 | getCurrentDate: () => { 53 | return new Date(0); 54 | }, 55 | packageInfo 56 | }); 57 | const options = changelogOptionsFactory.build(); 58 | const changelog = createChangelog(options); 59 | const expectedTitle = '## 1.0.0 (01.01.1970)'; 60 | 61 | t.true(changelog.includes(expectedTitle)); 62 | }); 63 | 64 | test('creates a formatted changelog when version was released', (t) => { 65 | const createChangelog = createChangelogFactory({ 66 | getCurrentDate: () => { 67 | return new Date(0); 68 | }, 69 | packageInfo: {} 70 | }); 71 | const mergedPullRequests = [ 72 | { 73 | id: 1, 74 | title: 'Fixed bug foo', 75 | label: 'bug' 76 | }, 77 | { 78 | id: 2, 79 | title: 'Fixed bug bar', 80 | label: 'bug' 81 | }, 82 | { 83 | id: 3, 84 | title: 'Fix spelling error', 85 | label: 'documentation' 86 | } 87 | ] as const; 88 | 89 | const expectedChangelog = [ 90 | '### Bug Fixes', 91 | '', 92 | '* Fixed bug foo ([#1](https://github.com/any/repo/pull/1))', 93 | '* Fixed bug bar ([#2](https://github.com/any/repo/pull/2))', 94 | '', 95 | '### Documentation', 96 | '', 97 | '* Fix spelling error ([#3](https://github.com/any/repo/pull/3))', 98 | '' 99 | ].join('\n'); 100 | 101 | const options = changelogOptionsFactory.build({ 102 | mergedPullRequests, 103 | githubRepo: 'any/repo' 104 | }); 105 | const changelog = createChangelog(options); 106 | 107 | t.true(changelog.includes(expectedChangelog)); 108 | }); 109 | 110 | test('uses custom labels when provided and version was released', (t) => { 111 | const createChangelog = createChangelogFactory({ 112 | getCurrentDate: () => { 113 | return new Date(0); 114 | }, 115 | packageInfo: {} 116 | }); 117 | const customValidLabels = new Map([ 118 | ['core', 'Core Features'], 119 | ['addons', 'Addons'] 120 | ]); 121 | const mergedPullRequests = [ 122 | { 123 | id: 1, 124 | title: 'Fixed bug foo', 125 | label: 'core' 126 | }, 127 | { 128 | id: 2, 129 | title: 'Fixed bug bar', 130 | label: 'addons' 131 | }, 132 | { 133 | id: 3, 134 | title: 'Fix spelling error', 135 | label: 'core' 136 | } 137 | ] as const; 138 | 139 | const expectedChangelog = [ 140 | '### Core Features', 141 | '', 142 | '* Fixed bug foo ([#1](https://github.com/any/repo/pull/1))', 143 | '* Fix spelling error ([#3](https://github.com/any/repo/pull/3))', 144 | '', 145 | '### Addons', 146 | '', 147 | '* Fixed bug bar ([#2](https://github.com/any/repo/pull/2))', 148 | '' 149 | ].join('\n'); 150 | 151 | const options = changelogOptionsFactory.build({ 152 | validLabels: customValidLabels, 153 | mergedPullRequests, 154 | githubRepo: 'any/repo' 155 | }); 156 | const changelog = createChangelog(options); 157 | 158 | t.true(changelog.includes(expectedChangelog)); 159 | }); 160 | 161 | test('uses the same order for the changelog sections as in validLabels when version was released', (t) => { 162 | const createChangelog = createChangelogFactory({ 163 | getCurrentDate: () => { 164 | return new Date(0); 165 | }, 166 | packageInfo: {} 167 | }); 168 | const customValidLabels = new Map([ 169 | ['first', 'First Section'], 170 | ['second', 'Second Section'] 171 | ]); 172 | const mergedPullRequests = [ 173 | { 174 | id: 1, 175 | title: 'Fixed bug foo', 176 | label: 'second' 177 | }, 178 | { 179 | id: 2, 180 | title: 'Fixed bug bar', 181 | label: 'second' 182 | }, 183 | { 184 | id: 3, 185 | title: 'Fix spelling error', 186 | label: 'first' 187 | } 188 | ] as const; 189 | 190 | const expectedChangelog = [ 191 | '### First Section', 192 | '', 193 | '* Fix spelling error ([#3](https://github.com/any/repo/pull/3))', 194 | '', 195 | '### Second Section', 196 | '', 197 | '* Fixed bug foo ([#1](https://github.com/any/repo/pull/1))', 198 | '* Fixed bug bar ([#2](https://github.com/any/repo/pull/2))', 199 | '' 200 | ].join('\n'); 201 | 202 | const options = changelogOptionsFactory.build({ 203 | validLabels: customValidLabels, 204 | mergedPullRequests, 205 | githubRepo: 'any/repo' 206 | }); 207 | const changelog = createChangelog(options); 208 | 209 | t.true(changelog.includes(expectedChangelog)); 210 | }); 211 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![NPM Version](https://img.shields.io/npm/v/pr-log.svg?style=flat)](https://www.npmjs.org/package/pr-log) 2 | [![GitHub Actions status](https://github.com/lo1tuma/pr-log/workflows/CI/badge.svg)](https://github.com/lo1tuma/pr-log/actions) 3 | [![Coverage Status](https://img.shields.io/coveralls/lo1tuma/pr-log/main.svg?style=flat)](https://coveralls.io/r/lo1tuma/pr-log) 4 | 5 | --- 6 | 7 | # pr-log 8 | 9 | > Changelog generator based on GitHub Pull Requests 10 | 11 | The main features: 12 | 13 | - Writes in a `CHANGELOG.md` from merged GitHub pull requests since the last tag (as long as [--stdout](#options) is not provided). This works by 14 | - first getting a list of all tags 15 | - than removing all tags that are not compatible to [semver versioning](http://semver.org/) 16 | - sort the tags 17 | - getting the git log from the last tag until now 18 | - If no `CHANGELOG.md` existed, it will create the file else it will write prepending to it 19 | - Friendly CLI 20 | - Get usage by running `pr-log --help` 21 | - Error messages that help correcting usage mistakes. E.g. 22 | - Missing first command line argument: `Error: version-number not specified` 23 | - Local branch is outdated compared to the remote branch: `Error: Local git main branch is 0 commits ahead and 2 commits behind of origin/main` 24 | - The current working directory is not clean (e.g. contains files that are modified): `Error: Local copy is not clean` 25 | - Well tested 26 | 27 | ## Install 28 | 29 | Simply run this to install `pr-log`: 30 | 31 | ``` 32 | npm install pr-log 33 | ``` 34 | 35 | ## Setup and configuration 36 | 37 | You have to follow these steps to use `pr-log` without problems. 38 | 39 | ### GitHub 40 | 41 | The following categories are defined by default: 42 | 43 | | GitHub label | Human friendly name | Description | 44 | | --------------: | :------------------ | ------------------------------------------------------------------- | 45 | | `breaking` | Breaking Changes | Backwards-incompatible changes | 46 | | `bug` | Bug Fixes | Changes that only fix a bug | 47 | | `feature` | Features | New features | 48 | | `enhancement` | Enhancements | Non-breaking improvements of existing features | 49 | | `documentation` | Documentation | Changes to documentation and/or README | 50 | | `upgrade` | Dependency Upgrades | Any kind of dependency updates | 51 | | `refactor` | Code Refactoring | Changes that don’t affect the behavior but improve the code quality | 52 | | `build` | Build-Related | Changes related to the build process and/or CI/CD pipeline | 53 | 54 | However, you can also create a custom mapping by adding a `pr-log.validLabels` section to your `package.json`. 55 | `validLabels` must be specified as an array of key, value pairs. The same order will be used to format the changelog sections. 56 | For example: 57 | 58 | ```json 59 | { 60 | "pr-log": { 61 | "validLabels": [ 62 | ["core", "Core features"], 63 | ["addon", "Addons"] 64 | ] 65 | } 66 | } 67 | ``` 68 | 69 | To use `pr-log` your GitHub project needs some small configuration: 70 | 71 | - Create the labels mentioned above (you can create GitHub labels from `Issues -> Labels -> New Label`) 72 | - Set the correct label on your pull requests - you need to set exactly one label, multiple labels or one that is not recognized will throw an error 73 | - Use correct semver versioning for your tags (e.g. `2.4.7`) 74 | 75 | ### Project 76 | 77 | As `pr-log` reads repository information from your project you have to add the `repository` information in your `package.json` 78 | 79 | ```json 80 | { 81 | "repository": { 82 | "type": "git", 83 | "url": "https://github.com//.git" 84 | } 85 | } 86 | ``` 87 | 88 | ### Changelog formatting 89 | 90 | #### Custom date format 91 | 92 | If you want to use a custom date format you can configure `pr-log.dateFormat` in your `package.json`. For example: 93 | 94 | ```json 95 | { 96 | "pr-log": { "dateFormat": "dd.MM.yyyy" } 97 | } 98 | ``` 99 | 100 | Please refer to the [`dates-fn` documentation](https://date-fns.org/docs/format) for details about the format expressions. 101 | 102 | ## Usage 103 | 104 | To create or update your changelog run 105 | 106 | `pr-log [options] ` where `version-number` is the name of this release 107 | 108 | Example: 109 | 110 | Given the following setup: 111 | 112 | - In GitHub a tag named `2.0.0` exists that is behind `main` 113 | - A pull request (#13) was created since the last tag that has the label `breaking` 114 | - A pull request (#22) was created since the last tag that has the label `documentation` 115 | 116 | `pr-log 2.0.0` creates a changelog with the following example content: 117 | 118 | ```markdown 119 | ## 2.0.0 (January 20, 2015) 120 | 121 | ### Breaking Changes 122 | 123 | - Use new (backwards incompatible) version of module XYZ (#13) 124 | 125 | ### Documentation 126 | 127 | - Fix some spelling mistakes in documentation. (#22) 128 | ``` 129 | 130 | ### Options 131 | 132 | #### --sloppy 133 | 134 | The `--sloppy` option defaults to false. When set, it allows `pr-log` to generate a changelog even when you are not on the default branch. This should not be used in production! 135 | 136 | #### --trace 137 | 138 | When enabled this option outputs the stacktrace of an error additionally to the error message to `stderr`. 139 | 140 | #### --stdout 141 | 142 | This option disables writing the changelog into the file `CHANGELOG.md`. Instead it prints the changelog to `stdout`. 143 | 144 | ### Correct usage makes a clean and complete changelog 145 | 146 | If you want your changelog to be complete and clean you have to follow these rules: 147 | 148 | 1. Don't commit directly to `main` - if you do, your changes will not be covered in the changelog (this might be ok but you should know this implication) 149 | 2. Use pull requests for your features that you want to be in your changelog 150 | 3. Use the correct categories for your pull request: If you introduce a new feature that will be a breaking change, give it the according label `breaking` (which will later result in this feature being listed under the `Breaking Changes` point in your changelog) 151 | 152 | ## Github Authentication 153 | 154 | If you need to authenticate `pr-log`, e.g. to access a private repo, you can set the `GH_TOKEN` environment variable. Generate a token value in your [Github settings](https://github.com/settings/tokens). 155 | 156 | `GH_TOKEN=xxxxxxxxx pr-log [options] ` 157 | 158 | ## Reason for this project 159 | 160 | Many projects have problems with their changelogs. Most of them try one of the following ways 161 | 162 | - manually write change logs: This is error-prone and the log will not be consistent 163 | - generating it from commit messages: As there are often far more commits than useful messages for the changelog, this will hide important features because there are too many to read everything 164 | 165 | Other challenges for good changelogs: 166 | 167 | - Different categories (e.g. breaking changes) 168 | - Only include changes starting from a certain tag 169 | 170 | ### More complete example `CHANGELOG.md` 171 | 172 | After working for some time with the tool and having e.g. two releases, the file content could look like this: 173 | 174 | ```markdown 175 | ## 2.0.0 (January 20, 2015) 176 | 177 | ### Breaking Changes 178 | 179 | - Use new (backwards incompatible) version of module XYZ (#13) 180 | 181 | ### Features 182 | 183 | - Add fancy feature (#2) 184 | - 185 | 186 | ### Documentation 187 | 188 | - Fix some spelling mistakes in documentation. (#22) 189 | 190 | ## 1.1.0 (November 3, 2014) 191 | ``` 192 | -------------------------------------------------------------------------------- /source/lib/git-command-runner.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { fake, type SinonSpy } from 'sinon'; 3 | import { 4 | createGitCommandRunner, 5 | type GitCommandRunner, 6 | type GitCommandRunnerDependencies 7 | } from './git-command-runner.js'; 8 | 9 | type Overrides = { 10 | readonly execute?: SinonSpy; 11 | }; 12 | 13 | function gitCommandRunnerFactory(overrides: Overrides = {}): GitCommandRunner { 14 | const { execute = fake.resolves({ stdout: '' }) } = overrides; 15 | const fakeDependencies = { execute } as unknown as GitCommandRunnerDependencies; 16 | 17 | return createGitCommandRunner(fakeDependencies); 18 | } 19 | 20 | test('getShortStatus() executes "git status" with correct options', async (t) => { 21 | const execute = fake.resolves({ stdout: '' }); 22 | const runner = gitCommandRunnerFactory({ execute }); 23 | 24 | await runner.getShortStatus(); 25 | 26 | t.is(execute.callCount, 1); 27 | t.deepEqual(execute.firstCall.args, ['git status --short']); 28 | }); 29 | 30 | test('getShortStatus() returns the command output without leading or trailing whitespace', async (t) => { 31 | const execute = fake.resolves({ stdout: ' foo \n' }); 32 | const runner = gitCommandRunnerFactory({ execute }); 33 | 34 | const result = await runner.getShortStatus(); 35 | 36 | t.is(result, 'foo'); 37 | }); 38 | 39 | test('getCurrentBranchName() executes "git rev-parse" with correct options', async (t) => { 40 | const execute = fake.resolves({ stdout: '' }); 41 | const runner = gitCommandRunnerFactory({ execute }); 42 | 43 | await runner.getCurrentBranchName(); 44 | 45 | t.is(execute.callCount, 1); 46 | t.deepEqual(execute.firstCall.args, ['git rev-parse --abbrev-ref HEAD']); 47 | }); 48 | 49 | test('getCurrentBranchName() returns the command output without leading or trailing whitespace', async (t) => { 50 | const execute = fake.resolves({ stdout: ' foo \n' }); 51 | const runner = gitCommandRunnerFactory({ execute }); 52 | 53 | const result = await runner.getCurrentBranchName(); 54 | 55 | t.is(result, 'foo'); 56 | }); 57 | 58 | test('fetchRemote() executes "git fetch" with the given remote', async (t) => { 59 | const execute = fake.resolves({ stdout: '' }); 60 | const runner = gitCommandRunnerFactory({ execute }); 61 | 62 | await runner.fetchRemote('foo'); 63 | 64 | t.is(execute.callCount, 1); 65 | t.deepEqual(execute.firstCall.args, ['git fetch foo']); 66 | }); 67 | 68 | test('getSymmetricDifferencesBetweenBranches() executes "git rev-list" with correct options', async (t) => { 69 | const execute = fake.resolves({ stdout: '' }); 70 | const runner = gitCommandRunnerFactory({ execute }); 71 | 72 | await runner.getSymmetricDifferencesBetweenBranches('a', 'b'); 73 | 74 | t.is(execute.callCount, 1); 75 | t.deepEqual(execute.firstCall.args, ['git rev-list --left-right a...b']); 76 | }); 77 | 78 | test('getSymmetricDifferencesBetweenBranches() returns the command output splitted as individual lines', async (t) => { 79 | const execute = fake.resolves({ stdout: ' a \nb\nc \n \n\n' }); 80 | const runner = gitCommandRunnerFactory({ execute }); 81 | 82 | const result = await runner.getSymmetricDifferencesBetweenBranches('a', 'b'); 83 | 84 | t.deepEqual(result, ['a', 'b', 'c']); 85 | }); 86 | 87 | test('getRemoteAliases() executes "git remote -v"', async (t) => { 88 | const execute = fake.resolves({ stdout: '' }); 89 | const runner = gitCommandRunnerFactory({ execute }); 90 | 91 | await runner.getRemoteAliases(); 92 | 93 | t.is(execute.callCount, 1); 94 | t.deepEqual(execute.firstCall.args, ['git remote -v']); 95 | }); 96 | 97 | test('getRemoteAliases() returns the parsed command output', async (t) => { 98 | const execute = fake.resolves({ 99 | stdout: 'foo git@example.com/repo/a.git (fetch)\nfoo\tgit@example.com/repo/a.git (push)\n\n' 100 | }); 101 | const runner = gitCommandRunnerFactory({ execute }); 102 | 103 | const result = await runner.getRemoteAliases(); 104 | 105 | t.deepEqual(result, [ 106 | { alias: 'foo', url: 'git@example.com/repo/a.git' }, 107 | { alias: 'foo', url: 'git@example.com/repo/a.git' } 108 | ]); 109 | }); 110 | 111 | test('getRemoteAliases() throws when the url of an remote entry cannot be determined', async (t) => { 112 | const execute = fake.resolves({ stdout: 'foo git@example.com/repo/a.git (fetch)\nfoo' }); 113 | const runner = gitCommandRunnerFactory({ execute }); 114 | 115 | await t.throwsAsync(runner.getRemoteAliases(), { message: 'Failed to determine git remote alias' }); 116 | }); 117 | 118 | test('listTags() executes "git tag" with correct options', async (t) => { 119 | const execute = fake.resolves({ stdout: '' }); 120 | const runner = gitCommandRunnerFactory({ execute }); 121 | 122 | await runner.listTags(); 123 | 124 | t.is(execute.callCount, 1); 125 | t.deepEqual(execute.firstCall.args, ['git tag --list']); 126 | }); 127 | 128 | test('listTags() returns the command output splitted as individual lines', async (t) => { 129 | const execute = fake.resolves({ stdout: ' a \nb\nc \n \n\n' }); 130 | const runner = gitCommandRunnerFactory({ execute }); 131 | 132 | const result = await runner.listTags(); 133 | 134 | t.deepEqual(result, ['a', 'b', 'c']); 135 | }); 136 | 137 | test('getMergeCommitLogs() executes "git log" with the correct options', async (t) => { 138 | const execute = fake.resolves({ stdout: '' }); 139 | const runner = gitCommandRunnerFactory({ execute }); 140 | 141 | await runner.getMergeCommitLogs('foo'); 142 | 143 | t.is(execute.callCount, 1); 144 | t.deepEqual(execute.firstCall.args, [ 145 | 'git log --first-parent --no-color --pretty=format:%s__||__%b##$$@@$$## --merges foo..HEAD' 146 | ]); 147 | }); 148 | 149 | test('getMergeCommitLogs() returns the parsed command output', async (t) => { 150 | const execute = fake.resolves({ stdout: 'foo__||__bar##$$@@$$##\nbaz__||__qux##$$@@$$##\n\n' }); 151 | const runner = gitCommandRunnerFactory({ execute }); 152 | 153 | const result = await runner.getMergeCommitLogs(''); 154 | 155 | t.deepEqual(result, [ 156 | { subject: 'foo', body: 'bar' }, 157 | { subject: 'baz', body: 'qux' } 158 | ]); 159 | }); 160 | 161 | test('getMergeCommitLogs() parses multi-line message bodies correctly', async (t) => { 162 | const execute = fake.resolves({ stdout: 'foo__||__bar\nbaz\nqux##$$@@$$##\nbaz__||__qux##$$@@$$##\n\n' }); 163 | const runner = gitCommandRunnerFactory({ execute }); 164 | 165 | const result = await runner.getMergeCommitLogs(''); 166 | 167 | t.deepEqual(result, [ 168 | { subject: 'foo', body: 'bar\nbaz\nqux' }, 169 | { subject: 'baz', body: 'qux' } 170 | ]); 171 | }); 172 | 173 | test('getMergeCommitLogs() parses multi-line bodies correctly when it doesn’t end with a line break', async (t) => { 174 | const execute = fake.resolves({ stdout: 'foo__||__bar\nbaz\nqux##$$@@$$##\nbaz__||__qux##$$@@$$##' }); 175 | const runner = gitCommandRunnerFactory({ execute }); 176 | 177 | const result = await runner.getMergeCommitLogs(''); 178 | 179 | t.deepEqual(result, [ 180 | { subject: 'foo', body: 'bar\nbaz\nqux' }, 181 | { subject: 'baz', body: 'qux' } 182 | ]); 183 | }); 184 | 185 | test('getMergeCommitLogs() falls back to undefined when the body couldn’t be extracted', async (t) => { 186 | const execute = fake.resolves({ stdout: 'foo##$$@@$$##\n\n\n' }); 187 | const runner = gitCommandRunnerFactory({ execute }); 188 | 189 | const result = await runner.getMergeCommitLogs(''); 190 | 191 | t.deepEqual(result, [{ subject: 'foo', body: undefined }]); 192 | }); 193 | 194 | test('getMergeCommitLogs() falls back to undefined when the body is an empty string', async (t) => { 195 | const execute = fake.resolves({ stdout: 'foo__||__##$$@@$$##\n\n\n' }); 196 | const runner = gitCommandRunnerFactory({ execute }); 197 | 198 | const result = await runner.getMergeCommitLogs(''); 199 | 200 | t.deepEqual(result, [{ subject: 'foo', body: undefined }]); 201 | }); 202 | -------------------------------------------------------------------------------- /source/lib/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { stub } from 'sinon'; 2 | import test from 'ava'; 3 | import type _prependFile from 'prepend-file'; 4 | import type { Logger } from 'loglevel'; 5 | import { Factory, type DeepPartial } from 'fishery'; 6 | import Maybe, { type Just } from 'true-myth/maybe'; 7 | import { createCliRunner, type CliRunner, type CliRunnerDependencies } from './cli.js'; 8 | import type { CliRunOptions } from './cli-run-options.js'; 9 | import { defaultValidLabels } from './valid-labels.js'; 10 | 11 | const cliRunOptionsFactory = Factory.define(() => { 12 | return { 13 | unreleased: false, 14 | versionNumber: Maybe.just('1.2.3') as Just, 15 | changelogPath: '/foo/CHANGELOG.md', 16 | sloppy: false, 17 | stdout: false 18 | }; 19 | }); 20 | 21 | function createCli(dependencies: Partial = {}): CliRunner { 22 | const { 23 | ensureCleanLocalGitState = stub().resolves(), 24 | getMergedPullRequests = stub().resolves([]), 25 | createChangelog = stub().returns(''), 26 | prependFile = stub().resolves() as unknown as typeof _prependFile, 27 | packageInfo = { repository: { url: 'https://github.com/foo/bar.git' } }, 28 | logger = { log: stub() } as unknown as Logger 29 | } = dependencies; 30 | 31 | return createCliRunner({ 32 | ensureCleanLocalGitState, 33 | getMergedPullRequests, 34 | createChangelog, 35 | prependFile, 36 | packageInfo, 37 | logger 38 | }); 39 | } 40 | 41 | type TestThrowsTestCase = { 42 | readonly cliRunOptionsOverrides: DeepPartial; 43 | readonly dependenciesOverrides: Partial; 44 | readonly expectedErrorMessage: string; 45 | }; 46 | 47 | const testThrowsMacro = test.macro(async (t, testCase: TestThrowsTestCase) => { 48 | const { cliRunOptionsOverrides, dependenciesOverrides, expectedErrorMessage } = testCase; 49 | const cli = createCli(dependenciesOverrides); 50 | const options = cliRunOptionsFactory.build(cliRunOptionsOverrides); 51 | 52 | await t.throwsAsync(cli.run(options), { message: expectedErrorMessage }); 53 | }); 54 | 55 | test('throws if the version was released but no version number was specified', testThrowsMacro, { 56 | cliRunOptionsOverrides: { 57 | unreleased: false, 58 | versionNumber: Maybe.just('') as Just 59 | }, 60 | dependenciesOverrides: {}, 61 | expectedErrorMessage: 'version-number not specified' 62 | }); 63 | 64 | test('throws if the version was released and an invalid version number was specified', testThrowsMacro, { 65 | cliRunOptionsOverrides: { 66 | unreleased: false, 67 | versionNumber: Maybe.just('a.b.c') as Just 68 | }, 69 | dependenciesOverrides: {}, 70 | expectedErrorMessage: 'version-number is invalid' 71 | }); 72 | 73 | test('throws when the repository entry in the given packageInfo is missing', testThrowsMacro, { 74 | cliRunOptionsOverrides: {}, 75 | dependenciesOverrides: { 76 | packageInfo: {} 77 | }, 78 | expectedErrorMessage: 'Repository information missing in package.json' 79 | }); 80 | 81 | test('throws when the repository entry in the given packageInfo is not an object', testThrowsMacro, { 82 | cliRunOptionsOverrides: {}, 83 | dependenciesOverrides: { 84 | packageInfo: { repository: 'foo' } 85 | }, 86 | expectedErrorMessage: 'Repository information missing in package.json' 87 | }); 88 | 89 | test('throws when the repository.url entry in the given packageInfo is missing', testThrowsMacro, { 90 | cliRunOptionsOverrides: {}, 91 | dependenciesOverrides: { 92 | packageInfo: { repository: {} } 93 | }, 94 | expectedErrorMessage: 'Repository url is not a string in package.json' 95 | }); 96 | 97 | test('throws when the repository.url entry in the given packageInfo is not a string', testThrowsMacro, { 98 | cliRunOptionsOverrides: {}, 99 | dependenciesOverrides: { 100 | packageInfo: { repository: { url: 42 } } 101 | }, 102 | expectedErrorMessage: 'Repository url is not a string in package.json' 103 | }); 104 | 105 | test('throws if the repository is dirty', testThrowsMacro, { 106 | cliRunOptionsOverrides: {}, 107 | dependenciesOverrides: { 108 | ensureCleanLocalGitState: stub().rejects(new Error('Local copy is not clean')) 109 | }, 110 | expectedErrorMessage: 'Local copy is not clean' 111 | }); 112 | 113 | test('does not throw if the repository is dirty', async (t) => { 114 | const ensureCleanLocalGitState = stub().rejects(new Error('Local copy is not clean')); 115 | const createChangelog = stub().returns('sloppy changelog'); 116 | const prependFile = stub().resolves(); 117 | const cli = createCli({ 118 | prependFile: prependFile as unknown as typeof _prependFile, 119 | ensureCleanLocalGitState, 120 | createChangelog 121 | }); 122 | const options = cliRunOptionsFactory.build({ 123 | sloppy: true 124 | }); 125 | 126 | await cli.run(options); 127 | 128 | t.is(prependFile.callCount, 1); 129 | t.deepEqual(prependFile.firstCall.args, ['/foo/CHANGELOG.md', 'sloppy changelog\n\n']); 130 | }); 131 | 132 | test('uses custom labels if they are provided in package.json', async (t) => { 133 | const packageInfo = { 134 | repository: { url: 'https://github.com/foo/bar.git' }, 135 | 'pr-log': { 136 | validLabels: [ 137 | ['foo', 'Foo'], 138 | ['bar', 'Bar'] 139 | ] 140 | } 141 | }; 142 | const expectedLabels = new Map([ 143 | ['foo', 'Foo'], 144 | ['bar', 'Bar'] 145 | ]); 146 | const createChangelog = stub().returns('generated changelog'); 147 | const getMergedPullRequests = stub().resolves(); 148 | const cli = createCli({ packageInfo, createChangelog, getMergedPullRequests }); 149 | 150 | await cli.run(cliRunOptionsFactory.build()); 151 | 152 | t.is(getMergedPullRequests.callCount, 1); 153 | t.deepEqual(getMergedPullRequests.firstCall.args, ['foo/bar', expectedLabels]); 154 | 155 | t.is(createChangelog.callCount, 1); 156 | t.deepEqual(createChangelog.firstCall.args[0], { 157 | validLabels: expectedLabels, 158 | mergedPullRequests: undefined, 159 | githubRepo: 'foo/bar', 160 | unreleased: false, 161 | versionNumber: Maybe.just('1.2.3') 162 | }); 163 | }); 164 | 165 | test('calls ensureCleanLocalGitState with correct parameters', async (t) => { 166 | const ensureCleanLocalGitState = stub().resolves(); 167 | 168 | const cli = createCli({ ensureCleanLocalGitState }); 169 | const options = cliRunOptionsFactory.build(); 170 | 171 | const expectedGithubRepo = 'foo/bar'; 172 | 173 | await cli.run(options); 174 | 175 | t.is(ensureCleanLocalGitState.callCount, 1); 176 | t.deepEqual(ensureCleanLocalGitState.firstCall.args, [expectedGithubRepo]); 177 | }); 178 | 179 | test('calls getMergedPullRequests with the correct repo', async (t) => { 180 | const getMergedPullRequests = stub().resolves(); 181 | 182 | const cli = createCli({ getMergedPullRequests }); 183 | const options = cliRunOptionsFactory.build(); 184 | 185 | const expectedGithubRepo = 'foo/bar'; 186 | 187 | await cli.run(options); 188 | 189 | t.is(getMergedPullRequests.callCount, 1); 190 | t.is(getMergedPullRequests.firstCall.args[0], expectedGithubRepo); 191 | }); 192 | 193 | test('reports the generated changelog to stdout and not to a file when stdout is set to true', async (t) => { 194 | const createChangelog = stub().returns('generated changelog'); 195 | const prependFile = stub().resolves(); 196 | const log = stub(); 197 | 198 | const cli = createCli({ 199 | createChangelog, 200 | prependFile: prependFile as unknown as typeof _prependFile, 201 | logger: { log } as unknown as Logger 202 | }); 203 | 204 | await cli.run( 205 | cliRunOptionsFactory.build({ 206 | stdout: true 207 | }) 208 | ); 209 | 210 | t.is(createChangelog.callCount, 1); 211 | t.deepEqual(createChangelog.firstCall.args[0], { 212 | validLabels: defaultValidLabels, 213 | mergedPullRequests: [], 214 | githubRepo: 'foo/bar', 215 | unreleased: false, 216 | versionNumber: Maybe.just('1.2.3') 217 | }); 218 | 219 | t.is(prependFile.callCount, 0); 220 | 221 | t.is(log.callCount, 1); 222 | t.deepEqual(log.firstCall.args, ['generated changelog']); 223 | }); 224 | 225 | test('reports the generated changelog to a file when stdout is set to false', async (t) => { 226 | const createChangelog = stub().returns('generated changelog'); 227 | const prependFile = stub().resolves(); 228 | 229 | const cli = createCli({ 230 | createChangelog, 231 | prependFile: prependFile as unknown as typeof _prependFile 232 | }); 233 | const options = cliRunOptionsFactory.build({ 234 | stdout: false 235 | }); 236 | 237 | await cli.run(options); 238 | 239 | t.is(createChangelog.callCount, 1); 240 | t.deepEqual(createChangelog.firstCall.args[0], { 241 | validLabels: defaultValidLabels, 242 | mergedPullRequests: [], 243 | githubRepo: 'foo/bar', 244 | unreleased: false, 245 | versionNumber: Maybe.just('1.2.3') 246 | }); 247 | 248 | t.is(prependFile.callCount, 1); 249 | t.deepEqual(prependFile.firstCall.args, ['/foo/CHANGELOG.md', 'generated changelog\n\n']); 250 | }); 251 | 252 | test('reports the generated unreleased changelog to a file when stdout is set to false', async (t) => { 253 | const createChangelog = stub().returns('generated changelog'); 254 | const prependFile = stub().resolves(); 255 | 256 | const cli = createCli({ 257 | createChangelog, 258 | prependFile: prependFile as unknown as typeof _prependFile 259 | }); 260 | const options = cliRunOptionsFactory.build({ 261 | unreleased: true, 262 | stdout: false 263 | }); 264 | 265 | await cli.run(options); 266 | 267 | t.is(createChangelog.callCount, 1); 268 | t.deepEqual(createChangelog.firstCall.args[0], { 269 | validLabels: defaultValidLabels, 270 | mergedPullRequests: [], 271 | githubRepo: 'foo/bar', 272 | unreleased: true, 273 | versionNumber: Maybe.just('1.2.3') 274 | }); 275 | 276 | t.is(prependFile.callCount, 1); 277 | t.deepEqual(prependFile.firstCall.args, ['/foo/CHANGELOG.md', 'generated changelog\n\n']); 278 | }); 279 | 280 | test('strips trailing empty lines from the generated changelog', async (t) => { 281 | const createChangelog = stub().returns('generated\nchangelog\nwith\n\na\nlot\n\nof\nempty\nlines\n\n\n\n\n'); 282 | const prependFile = stub().resolves(); 283 | 284 | const cli = createCli({ createChangelog, prependFile: prependFile as unknown as typeof _prependFile }); 285 | const options = cliRunOptionsFactory.build(); 286 | 287 | await cli.run(options); 288 | 289 | t.deepEqual(prependFile.firstCall.args, [ 290 | '/foo/CHANGELOG.md', 291 | 'generated\nchangelog\nwith\n\na\nlot\n\nof\nempty\nlines\n\n' 292 | ]); 293 | }); 294 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 6.1.1 (March 6, 2024) 2 | 3 | ### Bug Fixes 4 | 5 | * Revert "⬆️ Update @enormora/eslint-config" ([#365](https://github.com/lo1tuma/pr-log/pull/365)) 6 | * Move loglevel from development to runtime dependencies ([#363](https://github.com/lo1tuma/pr-log/pull/363)) 7 | 8 | ### Dependency Upgrades 9 | 10 | * ⬆️ Update @enormora/eslint-config ([#358](https://github.com/lo1tuma/pr-log/pull/358)) 11 | * ⬆️ Lock file maintenance ([#364](https://github.com/lo1tuma/pr-log/pull/364)) 12 | * ⬆️ Lock file maintenance ([#361](https://github.com/lo1tuma/pr-log/pull/361)) 13 | * ⬆️ Lock file maintenance ([#360](https://github.com/lo1tuma/pr-log/pull/360)) 14 | * ⬆️ Update dependency commander to v12 ([#355](https://github.com/lo1tuma/pr-log/pull/355)) 15 | * ⬆️ Lock file maintenance ([#359](https://github.com/lo1tuma/pr-log/pull/359)) 16 | * ⬆️ Lock file maintenance ([#357](https://github.com/lo1tuma/pr-log/pull/357)) 17 | * Move @enormora dependencies to development dependencies ([#356](https://github.com/lo1tuma/pr-log/pull/356)) 18 | 19 | ## 6.1.0 (February 4, 2024) 20 | 21 | ### Bug Fixes 22 | 23 | * Remove "Unreleased" title when version was not released ([#351](https://github.com/lo1tuma/pr-log/pull/351)) 24 | 25 | ### Features 26 | 27 | * Introduce --unreleased CLI option ([#347](https://github.com/lo1tuma/pr-log/pull/347)) 28 | * Introduce "--stdout" option to print the changelog ([#340](https://github.com/lo1tuma/pr-log/pull/340)) 29 | 30 | ### Documentation 31 | 32 | * Document --stdout option ([#341](https://github.com/lo1tuma/pr-log/pull/341)) 33 | 34 | ### Dependency Upgrades 35 | 36 | * ⬆️ Lock file maintenance ([#354](https://github.com/lo1tuma/pr-log/pull/354)) 37 | * ⬆️ Lock file maintenance ([#353](https://github.com/lo1tuma/pr-log/pull/353)) 38 | * ⬆️ Lock file maintenance ([#352](https://github.com/lo1tuma/pr-log/pull/352)) 39 | * ⬆️ Update dependency git-url-parse to v14 ([#349](https://github.com/lo1tuma/pr-log/pull/349)) 40 | * ⬆️ Update dependency c8 to v9 ([#350](https://github.com/lo1tuma/pr-log/pull/350)) 41 | * ⬆️ Lock file maintenance ([#348](https://github.com/lo1tuma/pr-log/pull/348)) 42 | * ⬆️ Lock file maintenance ([#346](https://github.com/lo1tuma/pr-log/pull/346)) 43 | * ⬆️ Lock file maintenance ([#344](https://github.com/lo1tuma/pr-log/pull/344)) 44 | * Disable Renovates dependency dashboard ([#342](https://github.com/lo1tuma/pr-log/pull/342)) 45 | * ⬆️ Update dependency @types/sinon to v17 ([#331](https://github.com/lo1tuma/pr-log/pull/331)) 46 | * ⬆️ Update dependency ava to v6 ([#338](https://github.com/lo1tuma/pr-log/pull/338)) 47 | * ⬆️ Lock file maintenance ([#339](https://github.com/lo1tuma/pr-log/pull/339)) 48 | * ⬆️ Lock file maintenance ([#337](https://github.com/lo1tuma/pr-log/pull/337)) 49 | * ⬆️ Lock file maintenance ([#336](https://github.com/lo1tuma/pr-log/pull/336)) 50 | * ⬆️ Lock file maintenance ([#335](https://github.com/lo1tuma/pr-log/pull/335)) 51 | * ⬆️ Update dependency @types/node to v20.9.0 ([#333](https://github.com/lo1tuma/pr-log/pull/333)) 52 | * ⬆️ Lock file maintenance ([#334](https://github.com/lo1tuma/pr-log/pull/334)) 53 | * ⬆️ Lock file maintenance ([#332](https://github.com/lo1tuma/pr-log/pull/332)) 54 | * ⬆️ Lock file maintenance ([#330](https://github.com/lo1tuma/pr-log/pull/330)) 55 | 56 | ### Build-Related 57 | 58 | * Check pull request labels ([#343](https://github.com/lo1tuma/pr-log/pull/343)) 59 | 60 | ## 6.0.0 (October 24, 2023) 61 | 62 | ### Breaking Changes 63 | 64 | * Add support for custom default branch and change default to `main` ([#326](https://github.com/lo1tuma/pr-log/pull/326)) 65 | 66 | ### Bug Fixes 67 | 68 | * Ignore merge commits with indirect parent ([#328](https://github.com/lo1tuma/pr-log/pull/328)) 69 | 70 | ### Dependency Upgrades 71 | 72 | * ⬆️ Update actions/setup-node action to v4 ([#327](https://github.com/lo1tuma/pr-log/pull/327)) 73 | * ⬆️ Update dependency sinon to v17 ([#324](https://github.com/lo1tuma/pr-log/pull/324)) 74 | * ⬆️ Lock file maintenance ([#325](https://github.com/lo1tuma/pr-log/pull/325)) 75 | * ⬆️ Lock file maintenance ([#322](https://github.com/lo1tuma/pr-log/pull/322)) 76 | * ⬆️ Update dependency @types/semver to v7.5.3 ([#312](https://github.com/lo1tuma/pr-log/pull/312)) 77 | * ⬆️ Update dependency @types/sinon to v10.0.17 ([#313](https://github.com/lo1tuma/pr-log/pull/313)) 78 | * ⬆️ Update typescript-eslint monorepo to v6.7.3 ([#314](https://github.com/lo1tuma/pr-log/pull/314)) 79 | * ⬆️ Update dependency @octokit/rest to v20.0.2 ([#296](https://github.com/lo1tuma/pr-log/pull/296)) 80 | * ⬆️ Update dependency @types/node to v20.7.0 ([#315](https://github.com/lo1tuma/pr-log/pull/315)) 81 | * ⬆️ Update dependency @types/node to v20.6.5 ([#311](https://github.com/lo1tuma/pr-log/pull/311)) 82 | 83 | ### Code Refactoring 84 | 85 | * Force exact dependency installation ([#309](https://github.com/lo1tuma/pr-log/pull/309)) 86 | 87 | ### Build-Related 88 | 89 | * Automatically set upgrade label for renovate PRs ([#329](https://github.com/lo1tuma/pr-log/pull/329)) 90 | * Enable verbatim module syntax in TypeScript compiler settings ([#323](https://github.com/lo1tuma/pr-log/pull/323)) 91 | * Group @enormora/eslint-config dependencies ([#321](https://github.com/lo1tuma/pr-log/pull/321)) 92 | * Migrate to ESLint flat config ([#317](https://github.com/lo1tuma/pr-log/pull/317)) 93 | * Enable automatic dependency updates for minor and patch versions ([#316](https://github.com/lo1tuma/pr-log/pull/316)) 94 | * Specify versions of GitHub actions ([#310](https://github.com/lo1tuma/pr-log/pull/310)) 95 | 96 | ## 5.0.0 (September 25, 2023) 97 | 98 | ### Breaking Changes 99 | 100 | * Rewrite codebase to TypeScript (drop nodejs support for versions < 20) ([#298](https://github.com/lo1tuma/pr-log/pull/298)) 101 | * Drop support for node < 18 ([#275](https://github.com/lo1tuma/pr-log/pull/275)) 102 | * Replace moment.js by date-fns ([#239](https://github.com/lo1tuma/pr-log/pull/239)) 103 | * Drop support for node v10 ([#238](https://github.com/lo1tuma/pr-log/pull/238)) 104 | 105 | ### Bug Fixes 106 | 107 | * Ensure there is always an empty line between the existing content and the new content in CHANGELOG.md ([#308](https://github.com/lo1tuma/pr-log/pull/308)) 108 | * Fix parsing of git commit log messages ([#307](https://github.com/lo1tuma/pr-log/pull/307)) 109 | * Fix token authentication ([#306](https://github.com/lo1tuma/pr-log/pull/306)) 110 | * Remove quotes from git log format ([#305](https://github.com/lo1tuma/pr-log/pull/305)) 111 | 112 | ### Documentation 113 | 114 | * Remove david-dm badge from README.md ([#277](https://github.com/lo1tuma/pr-log/pull/277)) 115 | 116 | ### Dependency Upgrades 117 | 118 | * ⬆️ Update dependency @types/node to v20.6.5 ([#301](https://github.com/lo1tuma/pr-log/pull/301)) 119 | * ⬆️ Update dependency eslint to v8.50.0 ([#283](https://github.com/lo1tuma/pr-log/pull/283)) 120 | * Update all dependencies ([#273](https://github.com/lo1tuma/pr-log/pull/273)) 121 | * ⬆️ Update dependency ava to v3 ([#222](https://github.com/lo1tuma/pr-log/pull/222)) 122 | * ⬆️ Update dependency nyc to v15 ([#220](https://github.com/lo1tuma/pr-log/pull/220)) 123 | * ⬆️ Update dependency babel-plugin-istanbul to v6 ([#219](https://github.com/lo1tuma/pr-log/pull/219)) 124 | * ⬆️ Update dependency git-promise to v1 ([#228](https://github.com/lo1tuma/pr-log/pull/228)) 125 | * ⬆️ Update dependency sinon to v9 ([#230](https://github.com/lo1tuma/pr-log/pull/230)) 126 | * ⬆️ Update dependency semver to v7 ([#216](https://github.com/lo1tuma/pr-log/pull/216)) 127 | * ⬆️ Update dependency commander to v6 ([#234](https://github.com/lo1tuma/pr-log/pull/234)) 128 | * ⬆️ Update dependency moment to v2.29.0 ([#229](https://github.com/lo1tuma/pr-log/pull/229)) 129 | * ⬆️ Update dependency ramda to v0.27.1 ([#224](https://github.com/lo1tuma/pr-log/pull/224)) 130 | * ⬆️ Update dependency eslint-plugin-ava to v11 ([#237](https://github.com/lo1tuma/pr-log/pull/237)) 131 | * ⬆️ Update dependency eslint to v7 ([#231](https://github.com/lo1tuma/pr-log/pull/231)) 132 | * ⬆️ Update dependency @octokit/rest to v18 ([#232](https://github.com/lo1tuma/pr-log/pull/232)) 133 | * ⬆️ Update dependency git-url-parse to v11.2.0 ([#233](https://github.com/lo1tuma/pr-log/pull/233)) 134 | * ⬆️ Update babel monorepo ([#214](https://github.com/lo1tuma/pr-log/pull/214)) 135 | 136 | ### Code Refactoring 137 | 138 | * Refactoring: introduce GitCommandRunner ([#300](https://github.com/lo1tuma/pr-log/pull/300)) 139 | * Use execaCommand instead of template string tag ([#299](https://github.com/lo1tuma/pr-log/pull/299)) 140 | * ⬆️ Pin dependencies ([#242](https://github.com/lo1tuma/pr-log/pull/242)) 141 | * Use eslint-config-joyn ([#241](https://github.com/lo1tuma/pr-log/pull/241)) 142 | * ⬆️ Pin dependency date-fns to 2.16.1 ([#240](https://github.com/lo1tuma/pr-log/pull/240)) 143 | 144 | ### Build-Related 145 | 146 | * ⬆️ Update actions/setup-node action to v3 ([#281](https://github.com/lo1tuma/pr-log/pull/281)) 147 | * Add node v18 to CI environments ([#274](https://github.com/lo1tuma/pr-log/pull/274)) 148 | * Use github actions instead of travis ci ([#236](https://github.com/lo1tuma/pr-log/pull/236)) 149 | 150 | ## 4.0.0 (December 7, 2019) 151 | 152 | ### Breaking Changes 153 | 154 | - Drop support for nodejs 6 and 8 ([#208](https://github.com/lo1tuma/pr-log/pull/208)) 155 | 156 | ### Dependency Upgrades 157 | 158 | - ⬆️ Update dependency babel-plugin-istanbul to v5.2.0 ([#193](https://github.com/lo1tuma/pr-log/pull/193)) 159 | - ⬆️ Update dependency ava to v2 ([#201](https://github.com/lo1tuma/pr-log/pull/201)) 160 | - ⬆️ Update dependency sinon to v7.5.0 ([#190](https://github.com/lo1tuma/pr-log/pull/190)) 161 | - ⬆️ Update dependency moment to v2.24.0 ([#191](https://github.com/lo1tuma/pr-log/pull/191)) 162 | - ⬆️ Update dependency coveralls to v3.0.9 ([#194](https://github.com/lo1tuma/pr-log/pull/194)) 163 | - ⬆️ Update dependency @octokit/rest to v16.35.0 ([#187](https://github.com/lo1tuma/pr-log/pull/187)) 164 | - ⬆️ Update dependency eslint to v6 ([#203](https://github.com/lo1tuma/pr-log/pull/203)) 165 | - ⬆️ Update dependency eslint-plugin-ava to v9 ([#206](https://github.com/lo1tuma/pr-log/pull/206)) 166 | - Update to babel 7 ([#209](https://github.com/lo1tuma/pr-log/pull/209)) 167 | - ⬆️ Update dependency semver to v6 ([#197](https://github.com/lo1tuma/pr-log/pull/197)) 168 | - ⬆️ Update dependency nyc to v14.1.1 ([#200](https://github.com/lo1tuma/pr-log/pull/200)) 169 | - ⬆️ Update dependency commander to v4 ([#207](https://github.com/lo1tuma/pr-log/pull/207)) 170 | - ⬆️ Update dependency nyc to v14 ([#199](https://github.com/lo1tuma/pr-log/pull/199)) 171 | - ⬆️ Update dependency eslint-plugin-ava to v6 ([#195](https://github.com/lo1tuma/pr-log/pull/195)) 172 | 173 | ### Code Refactoring 174 | 175 | - Fix deprecation warnings from octokit ([#213](https://github.com/lo1tuma/pr-log/pull/213)) 176 | - Refactor ESLint config/setup ([#211](https://github.com/lo1tuma/pr-log/pull/211)) 177 | - Use builtin promisify instead of separate package ([#212](https://github.com/lo1tuma/pr-log/pull/212)) 178 | 179 | ### Build-Related 180 | 181 | - Add .editorconfig ([#210](https://github.com/lo1tuma/pr-log/pull/210)) 182 | 183 | ## 3.1.0 (January 8, 2019) 184 | 185 | ### Bug Fixes 186 | 187 | - Fix octokit usage ([#186](https://github.com/lo1tuma/pr-log/pull/186)) 188 | - Fix incorrect git URL in test case ([#161](https://github.com/lo1tuma/pr-log/pull/161)) 189 | 190 | ### Features 191 | 192 | - Support github token-based authentication ([#179](https://github.com/lo1tuma/pr-log/pull/179)) 193 | 194 | ### Documentation 195 | 196 | - Remove greenkeeper badge ([#160](https://github.com/lo1tuma/pr-log/pull/160)) 197 | 198 | ### Dependency Upgrades 199 | 200 | - ⬆️ Update dependency git-url-parse to v11 ([#176](https://github.com/lo1tuma/pr-log/pull/176)) 201 | - ⬆️ Update dependency eslint to v5.12.0 ([#181](https://github.com/lo1tuma/pr-log/pull/181)) 202 | - ⬆️ Update dependency sinon to v7.2.2 ([#177](https://github.com/lo1tuma/pr-log/pull/177)) 203 | - ⬆️ Update dependency ramda to v0.26.1 ([#182](https://github.com/lo1tuma/pr-log/pull/182)) 204 | - ⬆️ Update dependency @octokit/rest to v16 ([#183](https://github.com/lo1tuma/pr-log/pull/183)) 205 | - ⬆️ Update dependency ava to v1 ([#185](https://github.com/lo1tuma/pr-log/pull/185)) 206 | - ⬆️ Update dependency moment to v2.23.0 ([#184](https://github.com/lo1tuma/pr-log/pull/184)) 207 | - ⬆️ Update dependency eslint-plugin-ava to v5 ([#174](https://github.com/lo1tuma/pr-log/pull/174)) 208 | - ⬆️ Update dependency sinon to v7 ([#171](https://github.com/lo1tuma/pr-log/pull/171)) 209 | - ⬆️ Update dependency eslint to v5 ([#173](https://github.com/lo1tuma/pr-log/pull/173)) 210 | - ⬆️ Update dependency git-url-parse to v10 ([#170](https://github.com/lo1tuma/pr-log/pull/170)) 211 | - ⬆️ Update dependency nyc to v13 ([#175](https://github.com/lo1tuma/pr-log/pull/175)) 212 | - ⬆️ Update dependency babel-plugin-istanbul to v5 ([#172](https://github.com/lo1tuma/pr-log/pull/172)) 213 | - ⬆️ Update dependency sinon to v4.5.0 ([#169](https://github.com/lo1tuma/pr-log/pull/169)) 214 | - ⬆️ Update dependency moment to v2.22.2 ([#168](https://github.com/lo1tuma/pr-log/pull/168)) 215 | - ⬆️ Update dependency coveralls to v3.0.2 ([#165](https://github.com/lo1tuma/pr-log/pull/165)) 216 | - ⬆️ Update dependency eslint-config-holidaycheck to v0.13.1 ([#166](https://github.com/lo1tuma/pr-log/pull/166)) 217 | - ⬆️ Update dependency git-url-parse to v8.3.1 ([#167](https://github.com/lo1tuma/pr-log/pull/167)) 218 | - ⬆️ Update dependency commander to v2.19.0 ([#164](https://github.com/lo1tuma/pr-log/pull/164)) 219 | - Update sinon to the latest version 🚀 ([#147](https://github.com/lo1tuma/pr-log/pull/147)) 220 | - Update sinon to the latest version 🚀 ([#146](https://github.com/lo1tuma/pr-log/pull/146)) 221 | 222 | ### Code Refactoring 223 | 224 | - ⬆️ Pin dependencies ([#163](https://github.com/lo1tuma/pr-log/pull/163)) 225 | - Remove bluebird dependency ([#145](https://github.com/lo1tuma/pr-log/pull/145)) 226 | 227 | ### Build-Related 228 | 229 | - Configure Renovate ([#162](https://github.com/lo1tuma/pr-log/pull/162)) 230 | - Update to node 10 in .travis.yml ([#158](https://github.com/lo1tuma/pr-log/pull/158)) 231 | 232 | ## 3.0.0 (March 9, 2018) 233 | 234 | ### Breaking Changes 235 | 236 | - Make validLabels an array of pairs to define order of changelog sections ([#144](https://github.com/lo1tuma/pr-log/pull/144)) 237 | - Don’t write stacktraces to stderr per default ([#141](https://github.com/lo1tuma/pr-log/pull/141)) 238 | - Make references to pull requests a link ([#142](https://github.com/lo1tuma/pr-log/pull/142)) 239 | - Remove support for nodejs 4 and 7 ([#125](https://github.com/lo1tuma/pr-log/pull/125)) 240 | 241 | ### Enhancements 242 | 243 | - Add support for custom date format configuration ([#143](https://github.com/lo1tuma/pr-log/pull/143)) 244 | - Validate CLI argument to be a valid semver version number ([#133](https://github.com/lo1tuma/pr-log/pull/133)) 245 | - Add refactor label ([#132](https://github.com/lo1tuma/pr-log/pull/132)) 246 | 247 | ### Documentation 248 | 249 | - Small README.md improvements ([#140](https://github.com/lo1tuma/pr-log/pull/140)) 250 | 251 | ### Dependency Upgrades 252 | 253 | - Update commander to the latest version 🚀 ([#137](https://github.com/lo1tuma/pr-log/pull/137)) 254 | - Update @octokit/rest to the latest version 🚀 ([#135](https://github.com/lo1tuma/pr-log/pull/135)) 255 | - Update mocha to the latest version 🚀 ([#128](https://github.com/lo1tuma/pr-log/pull/128)) 256 | 257 | ### Code Refactoring 258 | 259 | - Use ava instead of mocha/chai ([#138](https://github.com/lo1tuma/pr-log/pull/138)) 260 | - Remove proxyquire dependency ([#134](https://github.com/lo1tuma/pr-log/pull/134)) 261 | - Use octokit instead of restling ([#131](https://github.com/lo1tuma/pr-log/pull/131)) 262 | - Use async/await instead of bluebird ([#130](https://github.com/lo1tuma/pr-log/pull/130)) 263 | 264 | ## 2.1.0 (March 3, 2018) 265 | 266 | ### Dependency Upgrades 267 | 268 | - Update chai to version 4.1.2 (#124) 269 | - Update git-url-parse to version 8.1.0 (#123) 270 | - chore(package): update coveralls to version 3.0.0 (#122) 271 | - Update mocha to version 5.0.1 (#121) 272 | - Update sinon to version 4.4.2 (#120) 273 | - Update babel-register to the latest version 🚀 (#104) 274 | - Update babel-cli to the latest version 🚀 (#105) 275 | - Update parse-github-repo-url to the latest version 🚀 (#106) 276 | - Update bluebird to the latest version 🚀 (#111) 277 | - Update ramda to the latest version 🚀 (#112) 278 | - fix(package): update moment to version 2.20.1 (#119) 279 | - fix(package): update commander to version 2.14.1 (#118) 280 | - chore(package): update eslint to version 4.7.0 (#109) 281 | - Update sinon to the latest version 🚀 (#101) 282 | - Update sinon to the latest version 🚀 (#88) 283 | - Update eslint and eslint-config-holidaycheck to the latest version 🚀 (#95) 284 | - Update eslint-plugin-mocha to the latest version 🚀 (#92) 285 | - Update chai-as-promised to the latest version 🚀 (#97) 286 | - Update commander to the latest version 🚀 (#96) 287 | - Update chai-as-promised to the latest version 🚀 (#90) 288 | - Update commander to the latest version 🚀 (#93) 289 | - Update git-url-parse to the latest version 🚀 (#91) 290 | - Update sinon-chai to the latest version 🚀 (#89) 291 | - Update nyc to the latest version 🚀 (#85) 292 | - Update ramda to the latest version 🚀 (#86) 293 | - Update dependencies to enable Greenkeeper 🌴 (#82) 294 | - Update eslint (#81) 295 | 296 | ### Bug Fixes 297 | 298 | - Reduce cyclomatic complexity to fix build (#117) 299 | 300 | ### Build-Related 301 | 302 | - Use files whitelist instead of .npmignore (#100) 303 | - Switch to babel-preset-env (#99) 304 | - Add node 8 test environment (#98) 305 | - Move to nyc for code coverage (#80) 306 | 307 | ## 2.0.0 (May 23, 2017) 308 | 309 | ### Breaking Changes 310 | 311 | - Drop nodejs 0.x and 5.x support (#79) 312 | - Skip prerelease tags and upgrade semver (#76) 313 | 314 | ### Features 315 | 316 | - Allow the user to configure PR label to group mapping (#78) 317 | - Added --sloppy option (#75) 318 | 319 | ### Enhancements 320 | 321 | - Handle PRs that don't match expected merge format (#77) 322 | 323 | ## 1.6.0 (August 25, 2016) 324 | 325 | ### Bug Fixes 326 | 327 | - Support parentheses in PR titles (#74) 328 | 329 | ## 1.5.0 (June 4, 2016) 330 | 331 | ### Bug Fixes 332 | 333 | - Fix stripping trailing empty line (#70) 334 | 335 | ### Dependency Upgrades 336 | 337 | - Update eslint-config-holidaycheck to version 0.9.0 🚀 (#69) 338 | - Update git-url-parse to version 6.0.3 🚀 (#65) 339 | - Update eslint-plugin-mocha to version 3.0.0 🚀 (#68) 340 | - Update bluebird to version 3.4.0 🚀 (#59) 341 | - Update babel-preset-es2015 to version 6.9.0 🚀 (#58) 342 | - Update sinon to version 1.17.4 🚀 (#45) 343 | - Update babel-cli to version 6.9.0 🚀 (#57) 344 | - Update babel-register to version 6.9.0 🚀 (#60) 345 | - Update proxyquire to version 1.7.9 🚀 (#52) 346 | - Update mocha to version 2.5.3 🚀 (#64) 347 | - Update eslint to version 2.11.1 🚀 (#67) 348 | - Update git-url-parse to version 6.0.2 🚀 (#43) 349 | - Update babel-cli to version 6.7.7 🚀 (#42) 350 | - Update eslint to version 2.8.0 🚀 (#39) 351 | - Update parse-github-repo-url to version 1.3.0 🚀 (#40) 352 | - Update moment to version 2.13.0 🚀 (#41) 353 | - Update eslint-plugin-mocha to version 2.2.0 🚀 (#38) 354 | - Update eslint-config-holidaycheck to version 0.7.0 🚀 (#36) 355 | - Update parse-github-repo-url to version 1.2.0 🚀 (#37) 356 | - Update bluebird to version 3.3.5 🚀 (#35) 357 | - Update ramda to version 0.21.0 🚀 (#33) 358 | - Update eslint-plugin-mocha to version 2.1.0 🚀 (#34) 359 | - Update babel-cli to version 6.7.5 🚀 (#32) 360 | - Update eslint to version 2.7.0 🚀 (#31) 361 | - Update eslint to version 2.6.0 🚀 (#29) 362 | - Update eslint-config-holidaycheck to version 0.6.0 🚀 (#30) 363 | - Update ramda to version 0.20.1 🚀 (#28) 364 | - Update ramda to version 0.20.0 🚀 (#24) 365 | - Update eslint to version 2.5.3 🚀 (#26) 366 | - Update coveralls to version 2.11.9 🚀 (#21) 367 | - Update chai-as-promised to version 5.3.0 🚀 (#20) 368 | - Update eslint to version 2.4.0 🚀 (#15) 369 | - Update bluebird to version 3.3.4 🚀 (#14) 370 | - Update moment to version 2.12.0 🚀 (#13) 371 | 372 | ### Build-Related 373 | 374 | - Convert to es2015 (#18) 375 | 376 | ## 1.4.0 (March 5, 2016) 377 | 378 | ### Dependency Upgrades 379 | 380 | - Update all dependencies 🌴 (#11) 381 | - Update to ESLint 2 and use eslint-config-holidaycheck (#12) 382 | 383 | ### Enhancements 384 | 385 | - Replace lodash by ramda (#10) 386 | 387 | ### Bug Fixes 388 | 389 | - Fix long computation time (#9) 390 | 391 | ## 1.3.0 (July 1, 2015) 392 | 393 | ### Enhancements 394 | 395 | - Replace superagent-promise with restling (#8) 396 | 397 | ### Bug Fixes 398 | 399 | - Avoid extra empty line (#7) 400 | 401 | ## 1.2.0 (June 21, 2015) 402 | 403 | ### Dependency Upgrades 404 | 405 | - Update dependencies (#6) 406 | 407 | ## 1.1.0 (March 8, 2015) 408 | 409 | ### Bug Fixes 410 | 411 | - Fix crash with mulitline commit message body (#4) 412 | 413 | ### Dependency Upgrades 414 | 415 | - Update eslint (#5) 416 | - Update dependencies (#3) 417 | 418 | ## 1.0.0 (January 22, 2015) 419 | 420 | Initial release 421 | --------------------------------------------------------------------------------