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 mistakes, so it can be used in a build pipeline to force some
correctness. 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: .. _configuration:
Configuring Reno 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 name that will be passed to the encoding kwarg for open(), so any
codec or alias from stdlib's codec module is valid. 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 linter
from reno import lister from reno import lister
from reno import report from reno import report
from reno import semver
_query_args = [ _query_args = [
(('--version',), (('--version',),
@ -193,6 +194,23 @@ def main(argv=sys.argv[1:]):
) )
do_linter.set_defaults(func=linter.lint_cmd) 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) args = parser.parse_args(argv)
# no arguments, print help messaging, then exit with error(1) # no arguments, print help messaging, then exit with error(1)
if not args.command: 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 pbr
PyYAML>=3.10 PyYAML>=3.10
dulwich>=0.15.0 # Apache-2.0 dulwich>=0.15.0 # Apache-2.0
packaging>=20.4