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:
parent
05d52d38b4
commit
789ecb12d2
@ -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
|
||||
|
8
releasenotes/notes/semver-next-63c68cf10ec91f09.yaml
Normal file
8
releasenotes/notes/semver-next-63c68cf10ec91f09.yaml
Normal 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.
|
@ -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.
|
||||
""")),
|
||||
]
|
||||
|
||||
|
||||
|
18
reno/main.py
18
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:
|
||||
|
101
reno/semver.py
Normal file
101
reno/semver.py
Normal 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
169
reno/tests/test_semver.py
Normal 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)
|
@ -5,3 +5,4 @@
|
||||
pbr
|
||||
PyYAML>=3.10
|
||||
dulwich>=0.15.0 # Apache-2.0
|
||||
packaging>=20.4
|
||||
|
Loading…
Reference in New Issue
Block a user