From 789ecb12d2ef7dd34c2570d14bfef4d0e7f0d9c4 Mon Sep 17 00:00:00 2001 From: Doug Hellmann Date: Sat, 29 Aug 2020 17:23:03 -0400 Subject: [PATCH] add `semver-next` command Add a sub-command for computing the next release version number by applying Semantic Versioning rules to the release notes added to a project since the last published release. Add configuration options to control which notes sections trigger updates to each level of the version number. Change-Id: I96be0c81a3947aaa0bf9080b500cf1bc77abe655 Signed-off-by: Doug Hellmann --- doc/source/user/usage.rst | 8 + .../notes/semver-next-63c68cf10ec91f09.yaml | 8 + reno/config.py | 16 ++ reno/main.py | 18 ++ reno/semver.py | 101 +++++++++++ reno/tests/test_semver.py | 169 ++++++++++++++++++ requirements.txt | 1 + 7 files changed, 321 insertions(+) create mode 100644 releasenotes/notes/semver-next-63c68cf10ec91f09.yaml create mode 100644 reno/semver.py create mode 100644 reno/tests/test_semver.py diff --git a/doc/source/user/usage.rst b/doc/source/user/usage.rst index 6dd71fc..c5fabae 100644 --- a/doc/source/user/usage.rst +++ b/doc/source/user/usage.rst @@ -176,6 +176,14 @@ mistakes. The command exits with an error code if there are any mistakes, so it can be used in a build pipeline to force some correctness. +Computing Next Release Version +============================== + +Run ``reno -q semver-next`` to compute the next SemVer_ version number +based on the types of release notes found since the last release. + +.. _SemVer: https://semver.org + .. _configuration: Configuring Reno diff --git a/releasenotes/notes/semver-next-63c68cf10ec91f09.yaml b/releasenotes/notes/semver-next-63c68cf10ec91f09.yaml new file mode 100644 index 0000000..4f1919b --- /dev/null +++ b/releasenotes/notes/semver-next-63c68cf10ec91f09.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Add the ``semver-next`` command to calculate the next release + version based on the available release notes. Three new + configuration options (``semver_major``, ``semver_minor``, and + ``semver_patch``) define the sections that should cause different + types of version increments. See :doc:`/user/usage` for details. diff --git a/reno/config.py b/reno/config.py index 36acad3..eca7ad6 100644 --- a/reno/config.py +++ b/reno/config.py @@ -188,6 +188,22 @@ _OPTIONS = [ name that will be passed to the encoding kwarg for open(), so any codec or alias from stdlib's codec module is valid. """)), + + Opt('semver_major', ['upgrade'], + textwrap.dedent("""\ + The sections that indicate release notes triggering major version + updates for the next release, from X.Y.Z to X+1.0.0. + """)), + Opt('semver_minor', ['features'], + textwrap.dedent("""\ + The sections that indicate release notes triggering minor version + updates for the next release, from X.Y.Z to X.Y+1.0. + """)), + Opt('semver_patch', ['fixes'], + textwrap.dedent("""\ + The sections that indicate release notes triggering patch version + updates for the next release, from X.Y.Z to X.Y.Z+1. + """)), ] diff --git a/reno/main.py b/reno/main.py index cb27712..5fc70b0 100644 --- a/reno/main.py +++ b/reno/main.py @@ -21,6 +21,7 @@ from reno import defaults from reno import linter from reno import lister from reno import report +from reno import semver _query_args = [ (('--version',), @@ -193,6 +194,23 @@ def main(argv=sys.argv[1:]): ) do_linter.set_defaults(func=linter.lint_cmd) + do_semver = subparsers.add_parser( + 'semver-next', + help='calculate next release version based on semver rules', + ) + do_semver.add_argument( + 'reporoot', + default='.', + nargs='?', + help='root of the git repository', + ) + do_semver.add_argument( + '--branch', + default=config.Config.get_default('branch'), + help='the branch to scan, defaults to the current', + ) + do_semver.set_defaults(func=semver.semver_next_cmd) + args = parser.parse_args(argv) # no arguments, print help messaging, then exit with error(1) if not args.command: diff --git a/reno/semver.py b/reno/semver.py new file mode 100644 index 0000000..7573c4a --- /dev/null +++ b/reno/semver.py @@ -0,0 +1,101 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from packaging import version + +from reno import loader + +LOG = logging.getLogger(__name__) + + +def compute_next_version(conf): + "Compute the next semantic version based on the available release notes." + LOG.debug('starting semver-next') + ldr = loader.Loader(conf, ignore_cache=True) + LOG.debug('known versions: %s', ldr.versions) + + # We want to include any notes in the local working directory or + # in any commits that came after the last tag. We should never end + # up with more than 2 entries in to_include. + to_include = [] + for to_consider in ldr.versions: + if to_consider == '*working-copy*': + to_include.append(to_consider) + continue + # This check relies on PEP 440 versioning + parsed = version.Version(to_consider) + if parsed.post: + to_include.append(to_consider) + continue + break + + # If we found no commits then we're sitting on a real tag and + # there is nothing to do to update the version. + if not to_include: + LOG.debug('found no staged notes and no post-release commits') + return ldr.versions[0] + + LOG.debug('including notes from %s', to_include) + + candidate_bases = to_include[:] + if candidate_bases[0] == '*working-copy*': + candidate_bases = candidate_bases[1:] + + if not candidate_bases: + # We have a real tag and some locally modified files. Use the + # real tag as the basis of the next version. + base_version = version.Version(ldr.versions[1]) + else: + base_version = version.Version(candidate_bases[0]) + + LOG.debug('base version %s', base_version) + + inc_minor = False + inc_patch = False + for ver in to_include: + for filename, sha in ldr[ver]: + notes = ldr.parse_note_file(filename, sha) + for section in conf.semver_major: + if notes.get(section, []): + LOG.debug('found breaking change in %r section of %s', + section, filename) + return '{}.0.0'.format(base_version.major + 1) + for section in conf.semver_minor: + if notes.get(section, []): + LOG.debug('found feature in %r section of %s', + section, filename) + inc_minor = True + break + for section in conf.semver_patch: + if notes.get(section, []): + LOG.debug('found bugfix in %r section of %s', + section, filename) + inc_patch = True + break + + major = base_version.major + minor = base_version.minor + patch = base_version.micro + if inc_patch: + patch += 1 + if inc_minor: + minor += 1 + patch = 0 + return '{}.{}.{}'.format(major, minor, patch) + + +def semver_next_cmd(args, conf): + "Calculate next semantic version number" + print(compute_next_version(conf)) + return 0 diff --git a/reno/tests/test_semver.py b/reno/tests/test_semver.py new file mode 100644 index 0000000..afa8b72 --- /dev/null +++ b/reno/tests/test_semver.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import collections +from unittest import mock + +import fixtures +import textwrap + + +from reno import config +from reno import semver +from reno.tests import base + + +class TestSemVer(base.TestCase): + + note_bodies = { + 'none': textwrap.dedent(""" + prelude: > + This should not cause any version update. + """), + 'major': textwrap.dedent(""" + upgrade: + - This should cause a major version update. + """), + 'minor': textwrap.dedent(""" + features: + - This should cause a minor version update. + """), + 'patch': textwrap.dedent(""" + fixes: + - This should cause a patch version update. + """), + } + + def _get_note_body(self, filename, sha): + return self.note_bodies.get(filename, '') + + def _get_dates(self): + return {'1.0.0': 1547874431} + + def setUp(self): + super(TestSemVer, self).setUp() + self.useFixture( + fixtures.MockPatch('reno.scanner.Scanner.get_file_at_commit', + new=self._get_note_body) + ) + self.useFixture( + fixtures.MockPatch('reno.scanner.Scanner.get_version_dates', + new=self._get_dates) + ) + self.c = config.Config('.') + + @mock.patch('reno.scanner.Scanner.get_notes_by_version') + def test_same(self, mock_get_notes): + mock_get_notes.return_value = collections.OrderedDict([ + ('1.1.1', []), + ]) + expected = '1.1.1' + actual = semver.compute_next_version(self.c) + self.assertEqual(expected, actual) + + @mock.patch('reno.scanner.Scanner.get_notes_by_version') + def test_same_with_note(self, mock_get_notes): + mock_get_notes.return_value = collections.OrderedDict([ + ('1.1.1', [('none', 'shaA')]), + ]) + expected = '1.1.1' + actual = semver.compute_next_version(self.c) + self.assertEqual(expected, actual) + + @mock.patch('reno.scanner.Scanner.get_notes_by_version') + def test_major_working_copy(self, mock_get_notes): + mock_get_notes.return_value = collections.OrderedDict([ + ('*working-copy*', [('major', 'shaA')]), + ('1.1.1', []), + ]) + expected = '2.0.0' + actual = semver.compute_next_version(self.c) + self.assertEqual(expected, actual) + + @mock.patch('reno.scanner.Scanner.get_notes_by_version') + def test_major_working_and_post_release(self, mock_get_notes): + mock_get_notes.return_value = collections.OrderedDict([ + ('*working-copy*', [('none', 'shaA')]), + ('1.1.1-1', [('major', 'shaA')]), + ]) + expected = '2.0.0' + actual = semver.compute_next_version(self.c) + self.assertEqual(expected, actual) + + @mock.patch('reno.scanner.Scanner.get_notes_by_version') + def test_major_post_release(self, mock_get_notes): + mock_get_notes.return_value = collections.OrderedDict([ + ('1.1.1-1', [('major', 'shaA')]), + ]) + expected = '2.0.0' + actual = semver.compute_next_version(self.c) + self.assertEqual(expected, actual) + + @mock.patch('reno.scanner.Scanner.get_notes_by_version') + def test_minor_working_copy(self, mock_get_notes): + mock_get_notes.return_value = collections.OrderedDict([ + ('*working-copy*', [('minor', 'shaA')]), + ('1.1.1', []), + ]) + expected = '1.2.0' + actual = semver.compute_next_version(self.c) + self.assertEqual(expected, actual) + + @mock.patch('reno.scanner.Scanner.get_notes_by_version') + def test_minor_working_and_post_release(self, mock_get_notes): + mock_get_notes.return_value = collections.OrderedDict([ + ('*working-copy*', [('none', 'shaA')]), + ('1.1.1-1', [('minor', 'shaA')]), + ]) + expected = '1.2.0' + actual = semver.compute_next_version(self.c) + self.assertEqual(expected, actual) + + @mock.patch('reno.scanner.Scanner.get_notes_by_version') + def test_minor_post_release(self, mock_get_notes): + mock_get_notes.return_value = collections.OrderedDict([ + ('1.1.1-1', [('minor', 'shaA')]), + ]) + expected = '1.2.0' + actual = semver.compute_next_version(self.c) + self.assertEqual(expected, actual) + + @mock.patch('reno.scanner.Scanner.get_notes_by_version') + def test_patch_working_copy(self, mock_get_notes): + mock_get_notes.return_value = collections.OrderedDict([ + ('*working-copy*', [('patch', 'shaA')]), + ('1.1.1', []), + ]) + expected = '1.1.2' + actual = semver.compute_next_version(self.c) + self.assertEqual(expected, actual) + + @mock.patch('reno.scanner.Scanner.get_notes_by_version') + def test_patch_working_and_post_release(self, mock_get_notes): + mock_get_notes.return_value = collections.OrderedDict([ + ('*working-copy*', [('none', 'shaA')]), + ('1.1.1-1', [('patch', 'shaA')]), + ]) + expected = '1.1.2' + actual = semver.compute_next_version(self.c) + self.assertEqual(expected, actual) + + @mock.patch('reno.scanner.Scanner.get_notes_by_version') + def test_patch_post_release(self, mock_get_notes): + mock_get_notes.return_value = collections.OrderedDict([ + ('1.1.1-1', [('patch', 'shaA')]), + ]) + expected = '1.1.2' + actual = semver.compute_next_version(self.c) + self.assertEqual(expected, actual) diff --git a/requirements.txt b/requirements.txt index b97c1c8..ddc0705 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pbr PyYAML>=3.10 dulwich>=0.15.0 # Apache-2.0 +packaging>=20.4