diff --git a/.changelog/.create_dir b/.changelog/.create_dir new file mode 100644 index 0000000..e69de29 diff --git a/.changelog/acc2596fb367494db070e6c06abf705a.md b/.changelog/acc2596fb367494db070e6c06abf705a.md new file mode 100644 index 0000000..2f3525b --- /dev/null +++ b/.changelog/acc2596fb367494db070e6c06abf705a.md @@ -0,0 +1,4 @@ +--- +type: none +--- +Adding changelog management infra and doc \ No newline at end of file diff --git a/.git_hooks_pre-commit b/.git_hooks_pre-commit index 7938eee..de3ba67 100755 --- a/.git_hooks_pre-commit +++ b/.git_hooks_pre-commit @@ -10,3 +10,4 @@ ROOT=$(dirname "$GIT") "$ROOT/script/lint" "$ROOT/script/format" --check --quiet || (echo "Formatting check failed, run ./script/format" && exit 1) "$ROOT/script/coverage" +"$ROOT/script/changelog" check diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..18c9285 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,24 @@ +name: OctoDNS Changelog +on: + pull_request: + workflow_dispatch: + +jobs: + changelog: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fetch main + run: git fetch origin main --depth 1 + - name: Setup python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Changelog Check + run: | + ./script/changelog check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6fed5bd..e26e8a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,11 @@ This project uses the [GitHub Flow](https://guides.github.com/introduction/flow/ 0. Create a new branch: `git checkout -b my-branch-name` 0. Make your change, add tests, and make sure the tests still pass 0. Make sure that `./script/lint` passes without any warnings +0. Run `./script/format` to make sure your changes follow Python's preferred + coding style +0. Run `./script/changelog create ...` to add a changelog entry to your PR 0. Make sure that coverage is at :100:% `./script/coverage` and open `htmlcov/index.html` - * You can open PRs for :eyes: & discussion prior to this + * You can open a draft PR for :eyes: & discussion prior to this 0. Push to your fork and submit a pull request We will handle updating the version, tagging the release, and releasing the gem. Please don't bump the version or otherwise attempt to take on these administrative internal tasks as part of your pull request. diff --git a/script/changelog b/script/changelog index c270fb9..0c7dd42 100755 --- a/script/changelog +++ b/script/changelog @@ -1,7 +1,308 @@ -#!/bin/bash +#!/usr/bin/env python -set -e +from argparse import ArgumentParser, RawTextHelpFormatter +from datetime import datetime +from importlib import import_module +from io import StringIO +from json import loads +from os import getcwd, listdir, makedirs, remove +from os.path import basename, isdir, join +from subprocess import PIPE, run +from sys import argv, exit, path, stderr +from uuid import uuid4 -VERSION=v$(grep __version__ octodns/__init__.py | sed -e "s/^[^']*'//" -e "s/'$//") -echo $VERSION -git log --pretty="%h - %cr - %s (%an)" "${VERSION}..HEAD" +from yaml import safe_load_all + + +def create(argv): + prog = basename(argv.pop(0)) + parser = ArgumentParser( + prog=f'{prog} create', + description='Creates a new changelog entry.', + add_help=True, + formatter_class=RawTextHelpFormatter, + ) + + parser.add_argument( + '-t', + '--type', + choices=('none', 'patch', 'minor', 'major'), + required=True, + help='''The scope of the change. + +* patch - This is a bug fix +* minor - This adds new functionality or makes changes in a fully backwards + compatible way +* major - This includes substantial new functionality and/or changes that break + compatibility and may require careful migration +* none - This change does not need to be mentioned in the changelog + +See https://semver.org/ for more info''', + ) + parser.add_argument( + 'md', + metavar='change-description-markdown', + nargs='+', + help='''A short description of the changes in this PR, suitable as an entry in +CHANGELOG.md. Should be a single line. Can include simple markdown formatting +and links.''', + ) + + args = parser.parse_args(argv) + + if not isdir('.changelog'): + makedirs('.changelog') + filepath = join('.changelog', f'{uuid4().hex}.md') + with open(filepath, 'w') as fh: + fh.write('---\ntype: ') + fh.write(args.type) + fh.write('\n---\n') + fh.write(' '.join(args.md)) + + print( + f'Created {filepath}, it can be further edited and should be committed to your branch.' + ) + + +def check(argv): + if isdir('.changelog'): + result = run( + ['git', 'diff', '--name-only', 'origin/main', '.changelog/'], + check=False, + stdout=PIPE, + ) + entries = { + l + for l in result.stdout.decode('utf-8').split() + if l.endswith('.md') + } + if not result.returncode and entries: + exit(0) + + print( + 'PR is missing required changelog file, run ./script/changelog create', + file=stderr, + ) + exit(1) + + +def _get_current_version(module_name): + cwd = getcwd() + path.append(cwd) + module = import_module(module_name) + return tuple(int(v) for v in module.__version__.split('.', 2)) + + +class _ChangeMeta: + _pr_cache = None + + @classmethod + def get(cls, filepath): + if cls._pr_cache is None: + result = run( + [ + 'gh', + 'pr', + 'list', + '--base', + 'main', + '--state', + 'merged', + '--limit=50', + '--json', + 'files,mergedAt,number', + ], + check=True, + stdout=PIPE, + ) + cls._pr_cache = {} + for pr in loads(result.stdout): + for file in pr['files']: + path = file['path'] + if path.startswith('.changelog'): + cls._pr_cache[path] = ( + pr['number'], + datetime.fromisoformat(pr['mergedAt']).replace( + tzinfo=None + ), + ) + + try: + return cls._pr_cache[filepath] + except KeyError: + return None, datetime(year=1970, month=1, day=1) + + +def _get_changelogs(): + ret = [] + dirname = '.changelog' + for filename in listdir(dirname): + if not filename.endswith('.md'): + continue + filepath = join(dirname, filename) + with open(filepath) as fh: + data, md = safe_load_all(fh) + pr, time = _ChangeMeta.get(filepath) + if not pr: + continue + ret.append( + { + 'filepath': filepath, + 'md': md, + 'pr': pr, + 'time': time, + 'type': data.get('type', '').lower(), + } + ) + + ordering = {'major': 0, 'minor': 1, 'patch': 2, 'none': 3, '': 3} + ret.sort(key=lambda c: (ordering[c['type']], c['time'])) + return ret + + +def _get_new_version(current_version, changelogs): + try: + bump_type = changelogs[0]['type'] + except IndexError: + return None + new_version = list(current_version) + if bump_type == 'major': + new_version[0] += 1 + new_version[1] = 0 + new_version[2] = 0 + elif bump_type == 'minor': + new_version[1] += 1 + new_version[2] = 0 + else: + new_version[2] += 1 + return tuple(new_version) + + +def _format_version(version): + return '.'.join(str(v) for v in version) + + +def bump(argv): + prog = basename(argv.pop(0)) + parser = ArgumentParser( + prog=f'{prog} bump', + description='Builds a changelog update and calculates a new version number.', + add_help=True, + ) + + parser.add_argument( + '--make-changes', + action='store_true', + help='Write changelog update and bump version number', + ) + + args = parser.parse_args(argv) + + buf = StringIO() + + cwd = getcwd() + module_name = basename(cwd).replace('-', '_') + + buf.write('## ') + current_version = _get_current_version(module_name) + changelogs = _get_changelogs() + new_version = _get_new_version(current_version, changelogs) + if not new_version: + print('No changelog entries found that would bump, nothing to do') + exit(1) + new_version = _format_version(new_version) + buf.write(new_version) + buf.write(' - ') + buf.write(datetime.now().strftime('%Y-%m-%d')) + buf.write(' - ') + buf.write(' '.join(argv[1:])) + buf.write('\n') + + current_type = None + for changelog in changelogs: + md = changelog['md'] + if not md: + continue + + _type = changelog['type'] + if _type != current_type: + buf.write('\n') + buf.write(_type.capitalize()) + buf.write(':\n') + current_type = _type + buf.write('* ') + buf.write(md) + + pr = changelog['pr'] + if pr: + pr = str(pr) + buf.write(' [#') + buf.write(pr) + buf.write('](https://github.com/octodns/') + buf.write(module_name) + buf.write('/pull/') + buf.write(pr) + buf.write(')') + + buf.write('\n') + + buf.write('\n') + + if not args.make_changes: + print(f'New version number {new_version}\n') + print(buf.getvalue()) + exit(0) + + with open('CHANGELOG.md') as fh: + existing = fh.read() + + with open('CHANGELOG.md', 'w') as fh: + fh.write(buf.getvalue()) + fh.write(existing) + + with open(f'{module_name}/__init__.py') as fh: + existing = fh.read() + + current_version = _format_version(current_version) + with open(f'{module_name}/__init__.py', 'w') as fh: + fh.write(existing.replace(current_version, new_version)) + + for changelog in changelogs: + remove(changelog['filepath']) + + +cmds = {'create': create, 'check': check, 'bump': bump} + + +def general_usage(msg=None): + global cmds + + exe = basename(argv[0]) + cmds = ','.join(sorted(cmds.keys())) + print(f'usage: {exe} {{{cmds}}} ...') + if msg: + print(msg) + else: + print( + ''' +Creates and checks or changelog entries, located in the .changelog directory. +Additionally supports updating CHANGELOG.md and bumping the package version +based on one or more entries in that directory. +''' + ) + + +try: + cmd = cmds[argv[1].lower()] + argv.pop(1) +except IndexError: + general_usage('missing command') + exit(1) +except KeyError: + if argv[1] in ('-h', '--help', 'help'): + general_usage() + exit(0) + general_usage(f'unknown command "{argv[1]}"') + exit(1) + +cmd(argv)