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 <doug@doughellmann.com>
This commit is contained in:
Doug Hellmann 2020-08-29 17:23:03 -04:00
parent 05d52d38b4
commit 789ecb12d2
No known key found for this signature in database
GPG Key ID: 3B6D06A0C428437A
7 changed files with 321 additions and 0 deletions

View File

@ -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

View File

@ -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.

View File

@ -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.
""")),
]

View File

@ -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:

101
reno/semver.py Normal file
View File

@ -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

169
reno/tests/test_semver.py Normal file
View File

@ -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)

View File

@ -5,3 +5,4 @@
pbr
PyYAML>=3.10
dulwich>=0.15.0 # Apache-2.0
packaging>=20.4