diff -Nru python-reno-1.3.0/babel.cfg python-reno-2.5.0/babel.cfg --- python-reno-1.3.0/babel.cfg 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/babel.cfg 1970-01-01 00:00:00.000000000 +0000 @@ -1,2 +0,0 @@ -[python: **.py] - diff -Nru python-reno-1.3.0/CONTRIBUTING.rst python-reno-2.5.0/CONTRIBUTING.rst --- python-reno-1.3.0/CONTRIBUTING.rst 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/CONTRIBUTING.rst 2017-07-19 09:44:08.000000000 +0000 @@ -1,17 +1,14 @@ If you would like to contribute to the development of OpenStack, you must follow the steps in this page: - - http://docs.openstack.org/infra/manual/developers.html +https://docs.openstack.org/infra/manual/developers.html If you already have a good understanding of how the system works and your OpenStack accounts are set up, you can skip to the development workflow section of this documentation to learn how changes to OpenStack should be submitted for review via the Gerrit tool: - - http://docs.openstack.org/infra/manual/developers.html#development-workflow +https://docs.openstack.org/infra/manual/developers.html#development-workflow Pull requests submitted through GitHub will be ignored. Bugs should be filed on Launchpad, not GitHub: - - https://bugs.launchpad.net/reno +https://bugs.launchpad.net/reno diff -Nru python-reno-1.3.0/debian/changelog python-reno-2.5.0/debian/changelog --- python-reno-1.3.0/debian/changelog 2017-07-28 23:05:05.000000000 +0000 +++ python-reno-2.5.0/debian/changelog 2017-11-05 21:21:02.000000000 +0000 @@ -1,8 +1,20 @@ -python-reno (1.3.0-6build1) artful; urgency=medium +python-reno (2.5.0-1) unstable; urgency=medium - * No-change rebuild against python3.6 + [ Ondřej Nový ] + * Bumped debhelper compat version to 10 - -- Jeremy Bicha Fri, 28 Jul 2017 19:05:05 -0400 + [ Thomas Goirand ] + * Fixed VCS URLS. + * debian/copyright format using https. + * Running wrap-and-sort -bast. + * Updating maintainer field. + * Standards-Version is now 4.1.1. + * Fixed python3 shebang to use python3, not python3.x. + * New upstream release. + * Fixed (build-)depends for this release. + * Using pkgos-dh_auto_test and blacklisting test_build_cache_db(). + + -- Thomas Goirand Sun, 05 Nov 2017 21:21:02 +0000 python-reno (1.3.0-6) unstable; urgency=medium diff -Nru python-reno-1.3.0/debian/compat python-reno-2.5.0/debian/compat --- python-reno-1.3.0/debian/compat 2016-10-13 11:59:52.000000000 +0000 +++ python-reno-2.5.0/debian/compat 2017-11-05 21:21:02.000000000 +0000 @@ -1 +1 @@ -9 +10 diff -Nru python-reno-1.3.0/debian/control python-reno-2.5.0/debian/control --- python-reno-1.3.0/debian/control 2016-10-13 11:59:52.000000000 +0000 +++ python-reno-2.5.0/debian/control 2017-11-05 21:21:02.000000000 +0000 @@ -1,79 +1,89 @@ Source: python-reno Section: python Priority: optional -Maintainer: PKG OpenStack -Uploaders: Ivan Udovichenko , - Thomas Goirand , -Build-Depends: debhelper (>= 9), - dh-python, - openstack-pkg-tools (>= 52~), - gnupg, - python-all, - python-pbr (>= 1.4), - python-setuptools, - python-sphinx, - python3-all, - python3-pbr (>= 1.4), - python3-setuptools, -Build-Depends-Indep: git, - python-babel, - python-coverage, - python-hacking, - python-mock (>= 1.3), - python-oslosphinx (>= 2.5.0), - python-oslotest (>= 1:1.10.0), - python-testscenarios, - python-testtools (>= 1.4.0), - python-yaml, - python3-babel, - python3-coverage (>= 3.6), - python3-mock (>= 1.3), - python3-oslotest (>= 1:1.10.0), - python3-subunit, - python3-testscenarios, - python3-testtools (>= 1.4.0), - subunit, - testrepository, -Standards-Version: 3.9.6 -Vcs-Browser: https://git.openstack.org/cgit/openstack/deb-python-reno?h=debian%2Fnewton -Vcs-Git: https://git.openstack.org/openstack/deb-python-reno -b debian/newton +Maintainer: Debian OpenStack +Uploaders: + Ivan Udovichenko , + Thomas Goirand , +Build-Depends: + debhelper (>= 10), + dh-python, + gnupg, + openstack-pkg-tools, + python-all, + python-pbr, + python-setuptools, + python-sphinx, + python3-all, + python3-pbr, + python3-setuptools, +Build-Depends-Indep: + git, + python-babel, + python-coverage, + python-dulwich, + python-hacking, + python-mock, + python-openstackdocstheme (>= 1.11.0), + python-testscenarios, + python-testtools, + python-yaml, + python3-babel, + python3-coverage, + python3-dulwich, + python3-mock, + python3-testscenarios, + python3-testtools, + python3-yaml, + subunit, + testrepository, +Standards-Version: 4.1.1 +Vcs-Browser: https://anonscm.debian.org/cgit/openstack/libs/python-reno.git +Vcs-Git: https://anonscm.debian.org/git/openstack/libs/python-reno.git Homepage: http://www.openstack.org/ Package: python-reno Architecture: all -Depends: git, - python-pbr (>= 1.4), - python-yaml, - ${misc:Depends}, - ${python:Depends}, -Suggests: python-reno-doc, +Depends: + git, + python-dulwich, + python-pbr (>= 1.4), + python-yaml, + ${misc:Depends}, + ${python:Depends}, +Suggests: + python-reno-doc, Description: RElease NOtes manager - Python 2.x Reno is a release notes manager for storing release notes in a git repository and then building documentation from them. . This package contains the Python 2.x module. -Package: python3-reno +Package: python-reno-doc +Section: doc Architecture: all -Depends: git, - python3-pbr (>= 1.4), - python3-yaml, - ${misc:Depends}, - ${python3:Depends}, -Suggests: python-reno-doc, -Description: RElease NOtes manager - Python 3.x +Depends: + ${misc:Depends}, + ${sphinxdoc:Depends}, +Description: RElease NOtes manager - doc Reno is a release notes manager for storing release notes in a git repository and then building documentation from them. . - This package contains the Python 3.x module. + This package contains the documentation. -Package: python-reno-doc -Section: doc +Package: python3-reno Architecture: all -Depends: ${misc:Depends}, - ${sphinxdoc:Depends}, -Description: RElease NOtes manager - doc +Depends: + git, + python3-dulwich, + python3-pbr (>= 1.4), + python3-yaml, + ${misc:Depends}, + ${python3:Depends}, +Suggests: + python-reno-doc, +Description: RElease NOtes manager - Python 3.x Reno is a release notes manager for storing release notes in a git repository and then building documentation from them. . - This package contains the documentation. + This package contains the Python 3.x module. diff -Nru python-reno-1.3.0/debian/copyright python-reno-2.5.0/debian/copyright --- python-reno-1.3.0/debian/copyright 2016-10-13 11:59:52.000000000 +0000 +++ python-reno-2.5.0/debian/copyright 2017-11-05 21:21:02.000000000 +0000 @@ -1,4 +1,4 @@ -Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: reno Source: http://www.openstack.org/ diff -Nru python-reno-1.3.0/debian/gbp.conf python-reno-2.5.0/debian/gbp.conf --- python-reno-1.3.0/debian/gbp.conf 2016-10-13 11:59:52.000000000 +0000 +++ python-reno-2.5.0/debian/gbp.conf 1970-01-01 00:00:00.000000000 +0000 @@ -1,9 +0,0 @@ -[DEFAULT] -upstream-branch = master -debian-branch = debian/newton -upstream-tag = %(version)s -compression = xz - -[buildpackage] -export-dir = ../build-area/ - diff -Nru python-reno-1.3.0/debian/patches/series python-reno-2.5.0/debian/patches/series --- python-reno-1.3.0/debian/patches/series 2016-10-13 11:59:52.000000000 +0000 +++ python-reno-2.5.0/debian/patches/series 1970-01-01 00:00:00.000000000 +0000 @@ -1 +0,0 @@ -use_less_entropy_in_unit_tests.patch diff -Nru python-reno-1.3.0/debian/patches/use_less_entropy_in_unit_tests.patch python-reno-2.5.0/debian/patches/use_less_entropy_in_unit_tests.patch --- python-reno-1.3.0/debian/patches/use_less_entropy_in_unit_tests.patch 2016-10-13 11:59:52.000000000 +0000 +++ python-reno-2.5.0/debian/patches/use_less_entropy_in_unit_tests.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,55 +0,0 @@ -Description: use less entropy in unit tests - Fix the logic for dealing with entropy in the unit tests so we consume - less. -Author: Doug Hellmann -Date: Sun, 28 Feb 2016 10:39:21 -0500 -Change-Id: I1faebfd5de0b9ae150bc2298df5d797fbbf83c07 -Signed-off-by: Doug Hellmann -Origin: upstream, https://review.openstack.org/#/c/285812 -Last-Update: 2016-03-01 - -diff --git a/reno/tests/test_scanner.py b/reno/tests/test_scanner.py -index 7e5e3ef..4e6d0bb 100644 ---- a/reno/tests/test_scanner.py -+++ b/reno/tests/test_scanner.py -@@ -65,7 +65,7 @@ class GPGKeyFixture(fixtures.Fixture): - gnupg_version_re = re.compile('^gpg\s.*\s([\d+])\.([\d+])\.([\d+])') - gnupg_version = utils.check_output(['gpg', '--version'], - cwd=tempdir.path) -- for line in gnupg_version[0].split('\n'): -+ for line in gnupg_version.split('\n'): - gnupg_version = gnupg_version_re.match(line) - if gnupg_version: - gnupg_version = (int(gnupg_version.group(1)), -@@ -98,17 +98,17 @@ class GPGKeyFixture(fixtures.Fixture): - # Note that --quick-random (--debug-quick-random in GnuPG 2.x) - # does not have a corresponding preferences file setting and - # must be passed explicitly on the command line instead -- # if gnupg_version[0] == 1: -- # gnupg_random = '--quick-random' -- # elif gnupg_version[0] >= 2: -- # gnupg_random = '--debug-quick-random' -- # else: -- # gnupg_random = '' -- subprocess.check_call( -- ['gpg', '--gen-key', '--batch', -- # gnupg_random, -- config_file], -- cwd=tempdir.path) -+ if gnupg_version[0] == 1: -+ gnupg_random = '--quick-random' -+ elif gnupg_version[0] >= 2: -+ gnupg_random = '--debug-quick-random' -+ else: -+ gnupg_random = '' -+ cmd = ['gpg', '--gen-key', '--batch'] -+ if gnupg_random: -+ cmd.append(gnupg_random) -+ cmd.append(config_file) -+ subprocess.check_call(cmd, cwd=tempdir.path) - - - class Base(base.TestCase): --- -1.9.1 - diff -Nru python-reno-1.3.0/debian/rules python-reno-2.5.0/debian/rules --- python-reno-1.3.0/debian/rules 2016-10-13 11:59:52.000000000 +0000 +++ python-reno-2.5.0/debian/rules 2017-11-05 21:21:02.000000000 +0000 @@ -9,20 +9,12 @@ override_dh_auto_install: pkgos-dh_auto_install +override_dh_python3: + dh_python3 --shebang=/usr/bin/python3 + override_dh_auto_test: ifeq (,$(findstring nocheck, $(DEB_BUILD_OPTIONS))) - @echo "===> Running tests" - set -e ; set -x ; for i in 2.7 $(PYTHON3S) ; do \ - PYMAJOR=`echo $$i | cut -d'.' -f1` ; \ - echo "===> Testing with python$$i (python$$PYMAJOR)" ; \ - rm -rf .testrepository ; \ - testr-python$$PYMAJOR init ; \ - TEMP_REZ=`mktemp -t` ; \ - PYTHONPATH=$(CURDIR) PYTHON=python$$i testr-python$$PYMAJOR run --subunit | tee $$TEMP_REZ | subunit2pyunit ; \ - cat $$TEMP_REZ | subunit-filter -s --no-passthrough | subunit-stats ; \ - rm -f $$TEMP_REZ ; \ - testr-python$$PYMAJOR slowest ; \ - done + pkgos-dh_auto_test 'reno\.tests(?!.*test_cache\.TestCache\.test_build_cache_db.*)' endif override_dh_sphinxdoc: diff -Nru python-reno-1.3.0/doc/source/conf.py python-reno-2.5.0/doc/source/conf.py --- python-reno-1.3.0/doc/source/conf.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/doc/source/conf.py 2017-07-19 09:44:08.000000000 +0000 @@ -15,6 +15,16 @@ import os import sys +# oslosphinx uses reno and reno uses oslosphinx. Make oslosphinx for +# reno optional to break the build cycle +try: + import openstackdocstheme +except: + has_theme = False +else: + has_theme = True + + sys.path.insert(0, os.path.abspath('../..')) # -- General configuration ---------------------------------------------------- @@ -22,11 +32,20 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.autodoc', - #'sphinx.ext.intersphinx', - 'oslosphinx', + # 'sphinx.ext.intersphinx', 'reno.sphinxext', ] +if has_theme: + extensions.append('openstackdocstheme') + html_theme = 'openstackdocs' + +# openstackdocstheme options +repository_name = 'openstack/reno' +bug_project = 'reno' +bug_tag = '' +html_last_updated_fmt = '%Y-%m-%d %H:%M' + # autodoc generation is a bit aggressive and a nuisance when doing heavy # text edit cycles. # execute "export SPHINX_DEBUG=1" in your terminal to disable @@ -51,6 +70,9 @@ # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' +# Do not warn about non-local image URI +suppress_warnings = ['image.nonlocal_uri'] + # -- Options for HTML output -------------------------------------------------- # The theme to use for HTML and HTML Help pages. Major themes that come with @@ -73,4 +95,4 @@ ] # Example configuration for intersphinx: refer to the Python standard library. -#intersphinx_mapping = {'http://docs.python.org/': None} +# intersphinx_mapping = {'http://docs.python.org/': None} diff -Nru python-reno-1.3.0/doc/source/contributing.rst python-reno-2.5.0/doc/source/contributing.rst --- python-reno-1.3.0/doc/source/contributing.rst 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/doc/source/contributing.rst 1970-01-01 00:00:00.000000000 +0000 @@ -1,4 +0,0 @@ -============ -Contributing -============ -.. include:: ../../CONTRIBUTING.rst diff -Nru python-reno-1.3.0/doc/source/contributor/index.rst python-reno-2.5.0/doc/source/contributor/index.rst --- python-reno-1.3.0/doc/source/contributor/index.rst 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/doc/source/contributor/index.rst 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,5 @@ +============ +Contributing +============ + +.. include:: ../../../CONTRIBUTING.rst diff -Nru python-reno-1.3.0/doc/source/design.rst python-reno-2.5.0/doc/source/design.rst --- python-reno-1.3.0/doc/source/design.rst 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/doc/source/design.rst 1970-01-01 00:00:00.000000000 +0000 @@ -1,52 +0,0 @@ -===================================== - Design Constraints and Requirements -===================================== - -Managing release notes for a complex project over a long period of -time with many releases can be time consuming and error prone. Reno -helps automate the hard parts by devising a way to store the notes -inside the git repository where they can be tagged as part of the -release. - -We had several design inputs: - -* Release notes should be part of the git history, so as fixes in - master are back-ported to older branches the notes can go with the - code change. -* Release notes may need to change over time, as typos are found, - logical errors or confusing language needs to be fixed, or as more - information becomes available (CVE numbers, etc.). -* Release notes should be peer-reviewed, as with other documentation - and code changes. -* Notes are mutable in that a clone today vs a clone tomorrow might - have different release notes about the same change. -* Notes are immutable in that for a given git hash/tag the release - notes will be the same. Tagging a commit will change the version - description but that is all. -* We want to avoid merge issues when shepherding in a lot of - release-note-worthy changes, which we expect to happen on stable - branches always, and at release times on master branches. -* We want writing a release note to be straight-forward. -* We do not want release notes to be custom ordered within a release, - but we do want the ordering to be predictable and consistent. -* We must be able to entirely remove a release note. -* We must not make things progressively slow down to a crawl over - years of usage. -* Release note authors shouldn't need to know any special values for - naming their notes files (i.e., no change id or SHA value that has - special meaning). -* It would be nice if it was somewhat easy to identify the file - containing a release note on a particular topic. -* Release notes should be grouped by type in the output document. - - 1. New features - 2. Known issues - 3. Upgrade notes - 4. Security fixes - 5. Bugs fixes - 6. Other - -We want to eventually provide the ability to create a release notes -file for a given release and add it to the source distribution for the -project. As a first step, we are going to settle for publishing -release notes in the documentation for a project. diff -Nru python-reno-1.3.0/doc/source/examples.rst python-reno-2.5.0/doc/source/examples.rst --- python-reno-1.3.0/doc/source/examples.rst 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/doc/source/examples.rst 1970-01-01 00:00:00.000000000 +0000 @@ -1,2 +0,0 @@ -.. release-notes:: Examples - :relnotessubdir: examples diff -Nru python-reno-1.3.0/doc/source/history.rst python-reno-2.5.0/doc/source/history.rst --- python-reno-1.3.0/doc/source/history.rst 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/doc/source/history.rst 1970-01-01 00:00:00.000000000 +0000 @@ -1 +0,0 @@ -.. release-notes:: Release Notes diff -Nru python-reno-1.3.0/doc/source/index.rst python-reno-2.5.0/doc/source/index.rst --- python-reno-1.3.0/doc/source/index.rst 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/doc/source/index.rst 2017-07-19 09:44:08.000000000 +0000 @@ -1,28 +1,12 @@ -.. reno documentation master file, created by - sphinx-quickstart on Tue Jul 9 22:26:36 2013. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. +.. include:: ../../README.rst -Welcome to reno's documentation! -======================================================== - -Contents: +Contents +======== .. toctree:: :maxdepth: 2 - design - installation - usage - sphinxext - contributing - history - examples - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - + user/index + install/index + contributor/index + releasenotes/index diff -Nru python-reno-1.3.0/doc/source/install/index.rst python-reno-2.5.0/doc/source/install/index.rst --- python-reno-1.3.0/doc/source/install/index.rst 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/doc/source/install/index.rst 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,15 @@ +============ +Installation +============ + +At the command line:: + + $ pip install reno + +Sphinx Extension +================ + +To use the Sphinx extension built into reno, install the ``[sphinx]`` +extra dependencies:: + + $ pip install 'reno[sphinx]' diff -Nru python-reno-1.3.0/doc/source/installation.rst python-reno-2.5.0/doc/source/installation.rst --- python-reno-1.3.0/doc/source/installation.rst 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/doc/source/installation.rst 1970-01-01 00:00:00.000000000 +0000 @@ -1,15 +0,0 @@ -============ -Installation -============ - -At the command line:: - - $ pip install reno - -Sphinx Extension -================ - -To use the Sphinx extension built into reno, install the ``[sphinx]`` -extra dependencies:: - - $ pip install 'reno[sphinx]' diff -Nru python-reno-1.3.0/doc/source/readme.rst python-reno-2.5.0/doc/source/readme.rst --- python-reno-1.3.0/doc/source/readme.rst 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/doc/source/readme.rst 1970-01-01 00:00:00.000000000 +0000 @@ -1 +0,0 @@ -.. include:: ../../README.rst diff -Nru python-reno-1.3.0/doc/source/releasenotes/index.rst python-reno-2.5.0/doc/source/releasenotes/index.rst --- python-reno-1.3.0/doc/source/releasenotes/index.rst 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/doc/source/releasenotes/index.rst 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,12 @@ +=============== + Release Notes +=============== + +.. release-notes:: Unreleased + +.. release-notes:: Mainline + :branch: origin/master + +.. release-notes:: Newton Series + :branch: origin/stable/newton + :earliest-version: 1.9.0 diff -Nru python-reno-1.3.0/doc/source/sphinxext.rst python-reno-2.5.0/doc/source/sphinxext.rst --- python-reno-1.3.0/doc/source/sphinxext.rst 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/doc/source/sphinxext.rst 1970-01-01 00:00:00.000000000 +0000 @@ -1,76 +0,0 @@ -================== - Sphinx Extension -================== - -In addition to the command line tool, reno includes a Sphinx extension -for incorporating release notes for a project in its documentation -automatically. - -Enable the extension by adding ``'reno.sphinxext'`` to the -``extensions`` list in the Sphinx project ``conf.py`` file. - -.. rst:directive:: release-notes - - The ``release-notes`` directive accepts the same inputs as the - ``report`` subcommand, and inserts the report inline into the - current document where Sphinx then processes it to create HTML, - PDF, or other output formats. - - If the directive has a body, it is used to create a title entry - with ``=`` over and under lines (the typical heading style for the - top-level heading in a document). - - Options: - - *branch* - - The name of the branch to scan. Defaults to the current branch. - - *reporoot* - - The path to the repository root directory. Defaults to the - directory where ``sphinx-build`` is being run. - - *relnotessubdir* - - The path under ``reporoot`` where the release notes are. Defaults - to ``releasenotes``. - - *notesdir* - - The path under ``relnotessubdir`` where the release notes - are. Defaults to ``notes``. - - *version* - - A comma separated list of versions to include in the notes. The - default is to include all versions found on ``branch``. - -Examples -======== - -The release notes for the "current" branch, with "Release Notes" as a -title. - -:: - - .. release-notes:: Release Notes - -The release notes for the "stable/liberty" branch, with a separate -title. - -:: - - ======================= - Liberty Release Notes - ======================= - - .. release-notes:: - :branch: stable/liberty - -The release notes for version "1.0.0". - -:: - - .. release-notes:: 1.0.0 Release Notes - :version: 1.0.0 diff -Nru python-reno-1.3.0/doc/source/usage.rst python-reno-2.5.0/doc/source/usage.rst --- python-reno-1.3.0/doc/source/usage.rst 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/doc/source/usage.rst 1970-01-01 00:00:00.000000000 +0000 @@ -1,140 +0,0 @@ -======== - Usage -======== - -Creating New Release Notes -========================== - -The ``reno`` command line tool is used to create a new release note -file in the correct format and with a unique name. The ``new`` -subcommand combines a random suffix with a "slug" value to make the -new file with a unique name that is easy to identify again later. - -:: - - $ reno new slug-goes-here - Created new notes file in releasenotes/notes/slug-goes-here-95915aaedd3c48d8.yaml - -Within OpenStack projects, ``reno`` is often run via tox instead of -being installed globally. For example - -:: - - $ tox -e venv -- reno new slug-goes-here - venv develop-inst-nodeps: /mnt/projects/release-notes-generation/reno - venv runtests: commands[0] | reno new slug-goes-here - Created new notes file in releasenotes/notes/slug-goes-here-95915aaedd3c48d8.yaml - venv: commands succeeded - congratulations :) - $ git status - Untracked files: - (use "git add ..." to include in what will be committed) - - releasenotes/notes/slug-goes-here-95915aaedd3c48d8.yaml - - -By default the new note is created under ``./releasenotes/notes``. Use -the ``--rel-notes-dir`` to change the parent directory (the ``notes`` -subdirectory is always appended). - -Editing a Release Note -====================== - -The note file is a YAML file with several sections. All of the text is -interpreted as having reStructuredText formatting. - -prelude - - General comments about the release. The prelude from all notes in a - section are combined, in note order, to produce a single prelude - introducing that release. - -features - - A list of new major features in the release. - -issues - - A list of known issues in the release. For example, if a new driver - is experimental or known to not work in some cases, it should be - mentioned here. - -upgrade - - A list of upgrade notes in the release. For example, if a database - schema alteration is needed. - -critical - - A list of *fixed* critical bugs. - -security - - A list of *fixed* security issues. - -fixes - - A list of other *fixed* bugs. - -other - - Other notes that are important but do not fall into any of the given - categories. - -Any sections that would be blank should be left out of the note file -entirely. - -:: - - --- - prelude: > - Replace this text with content to appear at the - top of the section for this release. - features: - - List new features here, or remove this section. - issues: - - List known issues here, or remove this section. - upgrade: - - List upgrade notes here, or remove this section. - critical: - - Add critical notes here, or remove this section. - security: - - Add security notes here, or remove this section. - fixes: - - Add normal bug fixes here, or remove this section. - other: - - Add other notes here, or remove this section. - -Formatting ----------- - -Release notes may include embedded reStructuredText, including simple -inline markup like emphasis and pre-formatted text as well as complex -body structures such as nested lists and tables. To use these -formatting features, the note must be escaped from the YAML parser. - -The default template sets up the ``prelude`` section to use ``>`` so -that line breaks in the text are removed. This escaping mechanism is -not needed for the bullet items in the other sections of the template. - -To escape the text of any section and *retain* the newlines, prefix -the value with ``|``. For example: - -.. include:: ../../examples/notes/add-complex-example-6b5927c246456896.yaml - :literal: - -See :doc:`examples` for the rendered version of the note. - -Generating a Report -=================== - -Run ``reno report `` to generate a report -containing the release notes. The ``--branch`` argument can be used to -generate a report for a specific branch (the default is the branch -that is checked out). To limit the report to a subset of the available -versions on the branch, use the ``--version`` option (it can be -repeated). - -Notes are output in the order they are found by ``git log`` looking -over the history of the branch. This is deterministic, but not -necessarily predictable or mutable. diff -Nru python-reno-1.3.0/doc/source/user/design.rst python-reno-2.5.0/doc/source/user/design.rst --- python-reno-1.3.0/doc/source/user/design.rst 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/doc/source/user/design.rst 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,52 @@ +===================================== + Design Constraints and Requirements +===================================== + +Managing release notes for a complex project over a long period of +time with many releases can be time consuming and error prone. Reno +helps automate the hard parts by devising a way to store the notes +inside the git repository where they can be tagged as part of the +release. + +We had several design inputs: + +* Release notes should be part of the git history, so as fixes in + master are back-ported to older branches the notes can go with the + code change. +* Release notes may need to change over time, as typos are found, + logical errors or confusing language needs to be fixed, or as more + information becomes available (CVE numbers, etc.). +* Release notes should be peer-reviewed, as with other documentation + and code changes. +* Notes are mutable in that a clone today vs a clone tomorrow might + have different release notes about the same change. +* Notes are immutable in that for a given git hash/tag the release + notes will be the same. Tagging a commit will change the version + description but that is all. +* We want to avoid merge issues when shepherding in a lot of + release-note-worthy changes, which we expect to happen on stable + branches always, and at release times on master branches. +* We want writing a release note to be straight-forward. +* We do not want release notes to be custom ordered within a release, + but we do want the ordering to be predictable and consistent. +* We must be able to entirely remove a release note. +* We must not make things progressively slow down to a crawl over + years of usage. +* Release note authors shouldn't need to know any special values for + naming their notes files (i.e., no change id or SHA value that has + special meaning). +* It would be nice if it was somewhat easy to identify the file + containing a release note on a particular topic. +* Release notes should be grouped by type in the output document. + + 1. New features + 2. Known issues + 3. Upgrade notes + 4. Security fixes + 5. Bugs fixes + 6. Other + +We want to eventually provide the ability to create a release notes +file for a given release and add it to the source distribution for the +project. As a first step, we are going to settle for publishing +release notes in the documentation for a project. diff -Nru python-reno-1.3.0/doc/source/user/examples.rst python-reno-2.5.0/doc/source/user/examples.rst --- python-reno-1.3.0/doc/source/user/examples.rst 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/doc/source/user/examples.rst 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,17 @@ +========== + Examples +========== + +Input file +========== + +.. literalinclude:: ../../../examples/notes/add-complex-example-6b5927c246456896.yaml + :caption: examples/notes/add-complex-example-6b5927c246456896.yaml + :language: yaml + +Rendered +======== + +.. release-notes:: + :relnotessubdir: examples + :earliest-version: 1.0.0 diff -Nru python-reno-1.3.0/doc/source/user/index.rst python-reno-2.5.0/doc/source/user/index.rst --- python-reno-1.3.0/doc/source/user/index.rst 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/doc/source/user/index.rst 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,11 @@ +================= + reno User Guide +================= + +.. toctree:: + :maxdepth: 2 + + design + usage + sphinxext + examples diff -Nru python-reno-1.3.0/doc/source/user/sphinxext.rst python-reno-2.5.0/doc/source/user/sphinxext.rst --- python-reno-1.3.0/doc/source/user/sphinxext.rst 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/doc/source/user/sphinxext.rst 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,88 @@ +================== + Sphinx Extension +================== + +In addition to the command line tool, reno includes a Sphinx extension +for incorporating release notes for a project in its documentation +automatically. + +Enable the extension by adding ``'reno.sphinxext'`` to the +``extensions`` list in the Sphinx project ``conf.py`` file. + +.. rst:directive:: release-notes + + The ``release-notes`` directive accepts the same inputs as the + ``report`` subcommand, and inserts the report inline into the + current document where Sphinx then processes it to create HTML, + PDF, or other output formats. + + If the directive has a body, it is used to create a title entry + with ``=`` over and under lines (the typical heading style for the + top-level heading in a document). + + Options: + + *branch* + The name of the branch to scan. Defaults to the current branch. + + *reporoot* + The path to the repository root directory. Defaults to the + directory where ``sphinx-build`` is being run. + + *relnotessubdir* + The path under ``reporoot`` where the release notes are. Defaults + to ``releasenotes``. + + *notesdir* + The path under ``relnotessubdir`` where the release notes + are. Defaults to ``notes``. + + *version* + A comma separated list of versions to include in the notes. The + default is to include all versions found on ``branch``. + + *collapse-pre-releases* + A flag indicating that notes attached to pre-release versions + should be incorporated into the notes for the final release, + after the final release is tagged. + + *earliest-version* + A string containing the version number of the earliest version to + be included. For example, when scanning a branch, this is + typically set to the version used to create the branch to limit + the output to only versions on that branch. + + *ignore-notes* + A string containing a comma-delimited list of filenames or UIDs + for notes that should be ignored by the scanner. It is most + useful to set this when a note is edited on the wrong branch, + making it appear to be part of a release that it is not. + +Examples +======== + +The release notes for the "current" branch, with "Release Notes" as a +title. + +.. code-block:: rest + + .. release-notes:: Release Notes + +The release notes for the "stable/liberty" branch, with a separate +title. + +.. code-block:: rest + + ======================= + Liberty Release Notes + ======================= + + .. release-notes:: + :branch: stable/liberty + +The release notes for version "1.0.0". + +.. code-block:: rest + + .. release-notes:: 1.0.0 Release Notes + :version: 1.0.0 diff -Nru python-reno-1.3.0/doc/source/user/usage.rst python-reno-2.5.0/doc/source/user/usage.rst --- python-reno-1.3.0/doc/source/user/usage.rst 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/doc/source/user/usage.rst 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,325 @@ +======== + Usage +======== + +Creating New Release Notes +========================== + +The ``reno`` command line tool is used to create a new release note +file in the correct format and with a unique name. The ``new`` +subcommand combines a random suffix with a "slug" value to create +the file with a unique name that is easy to identify again later. + +:: + + $ reno new slug-goes-here + Created new notes file in releasenotes/notes/slug-goes-here-95915aaedd3c48d8.yaml + +Within OpenStack projects, ``reno`` is often run via tox instead of +being installed globally. For example + +:: + + $ tox -e venv -- reno new slug-goes-here + venv develop-inst-nodeps: /mnt/projects/release-notes-generation/reno + venv runtests: commands[0] | reno new slug-goes-here + Created new notes file in releasenotes/notes/slug-goes-here-95915aaedd3c48d8.yaml + venv: commands succeeded + congratulations :) + $ git status + Untracked files: + (use "git add ..." to include in what will be committed) + + releasenotes/notes/slug-goes-here-95915aaedd3c48d8.yaml + +The ``--edit`` option opens the new note in a text editor. + +:: + + $ reno new slug-goes-here --edit + ... Opens the editor set in the EDITOR environment variable, editing the new file ... + Created new notes file in releasenotes/notes/slug-goes-here-95915aaedd3c48d8.yaml + + +By default, the new note is created under ``./releasenotes/notes``. +The ``--rel-notes-dir`` command-line flag changes the parent directory +(the ``notes`` subdirectory is always appended). It's also possible to +set a custom template to create notes (see `Configuring Reno`_ ). + +Editing a Release Note +====================== + +The note file is a YAML file with several sections. All of the text is +interpreted as having `reStructuredText`_ formatting. The permitted +sections are configurable (see below) but default to the following +list: + +prelude + General comments about the release. Prelude sections from all notes in a + release are combined, in note order, to produce a single prelude + introducing that release. This section is always included, regardless + of what sections are configured. + +features + A list of new major features in the release. + +issues + A list of known issues in the release. For example, if a new driver + is experimental or known to not work in some cases, it should be + mentioned here. + +upgrade + A list of upgrade notes in the release. For example, if a database + schema alteration is needed. + +deprecations + A list of features, APIs, configuration options to be deprecated in the + release. Deprecations should not be used for something that is removed in the + release, use upgrade section instead. Deprecation should allow time for users + to make necessary changes for the removal to happen in a future release. + +critical + A list of *fixed* critical bugs. + +security + A list of *fixed* security issues. + +fixes + A list of other *fixed* bugs. + +other + Other notes that are important but do not fall into any of the given + categories. + +Any sections that would be blank should be left out of the note file +entirely. + +.. code-block:: yaml + + --- + prelude: > + Replace this text with content to appear at the + top of the section for this release. + features: + - List new features here, or remove this section. + issues: + - List known issues here, or remove this section. + upgrade: + - List upgrade notes here, or remove this section. + deprecations: + - List deprecation notes here, or remove this section + critical: + - Add critical notes here, or remove this section. + security: + - Add security notes here, or remove this section. + fixes: + - Add normal bug fixes here, or remove this section. + other: + - Add other notes here, or remove this section. + +Note File Syntax +---------------- + +Release notes may include embedded `reStructuredText`_, including simple +inline markup like emphasis and pre-formatted text as well as complex +body structures such as nested lists and tables. To use these +formatting features, the note must be escaped from the YAML parser. + +The default template sets up the ``prelude`` section to use ``>`` so +that line breaks in the text are removed. This escaping mechanism is +not needed for the bullet items in the other sections of the template. + +To escape the text of any section and *retain* the newlines, prefix +the value with ``|``. For example: + +.. literalinclude:: ../../../examples/notes/add-complex-example-6b5927c246456896.yaml + :language: yaml + +See :doc:`examples` for the rendered version of the note. + +.. _reStructuredText: http://www.sphinx-doc.org/en/stable/rest.html + +Generating a Report +=================== + +Run ``reno report `` to generate a report +containing the release notes. The ``--branch`` argument can be used to +generate a report for a specific branch (the default is the branch +that is checked out). To limit the report to a subset of the available +versions on the branch, use the ``--version`` option (it can be +repeated). + +Notes are output in the order they are found when scanning the git +history of the branch using topological ordering. This is +deterministic, but not necessarily predictable or mutable. + +Checking Notes +============== + +Run ``reno lint `` to test the existing +release notes files against some rules for catching common +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. + +Configuring Reno +================ + +Reno looks for an optional config file, either ``config.yaml`` in the release +notes directory or ``reno.yaml`` in the root directory. If the values in the +configuration file do not apply to the command being run, they are ignored. For +example, some reno commands take inputs controlling the branch, earliest +revision, and other common parameters that control which notes are included in +the output. Because they are commonly set options, a configuration file may be +the most convenient way to manage the values consistently. + +.. code-block:: yaml + + --- + branch: master + earliest_version: 12.0.0 + collapse_pre_releases: false + stop_at_branch_base: true + sections: + # The prelude section is implicitly included. + - [features, New Features] + - [issues, Known Issues] + - [upgrade, Upgrade Notes] + - [api, API Changes] + - [security, Security Issues] + - [fixes, Bug Fixes] + # Change prelude_section_name to 'release_summary' from default value + # 'prelude'. + prelude_section_name: release_summary + template: | + + ... + +Many of the settings in the configuration file can be overridden by +using command-line switches. For example: + +- ``--branch`` +- ``--earliest-version`` +- ``--collapse-pre-releases``/``--no-collapse-pre-releases`` +- ``--ignore-cache`` +- ``--stop-at-branch-base``/``--no-stop-at-branch-base`` + +The following options are configurable: + +`notesdir` + The notes subdirectory within the `relnotesdir` where the notes live. + + Defaults to ``notes``. + +`collapse_pre_releases` + Should pre-release versions be merged into the final release of the same + number (`1.0.0.0a1` notes appear under `1.0.0`). + + Defaults to ``True``. + +`stop_at_branch_base` + Should the scanner stop at the base of a branch (True) or go ahead and scan + the entire history (False)? + + Defaults to ``True``. + +`branch` + The git branch to scan. If a stable branch is specified but does not exist, + reno attempts to automatically convert that to an "end-of-life" tag. For + example, ``origin/stable/liberty`` would be converted to ``liberty-eol``. + + Defaults to the "current" branch checked out. + +`earliest_version` + The earliest version to be included. This is usually the lowest version + number, and is meant to be the oldest version. If unset, all versions will be + scanned. + + Defaults to ``None``. + +`template` + The template used by reno new to create a note. + +`release_tag_re` + The regex pattern used to match the repo tags representing a valid release + version. The pattern is compiled with the verbose and unicode flags enabled. + + Defaults to ``((?:[\d.ab]|rc)+)``. + +`pre_release_tag_re` + The regex pattern used to check if a valid release version tag is also a + valid pre-release version. The pattern is compiled with the verbose and + unicode flags enabled. The pattern must define a group called `pre_release` + that matches the pre-release part of the tag and any separator, e.g for + pre-release version `12.0.0.0rc1` the default RE pattern will identify + `.0rc1` as the value of the group 'pre_release'. + + Defaults to ``(?P\.\d+(?:[ab]|rc)+\d*)$``. + +`branch_name_re` + The pattern for names for branches that are relevant when scanning history to + determine where to stop, to find the "base" of a branch. Other branches are + ignored. + + Defaults to ``stable/.+``. + +`sections` + The identifiers and names of permitted sections in the release notes, in the + order in which the final report will be generated. A prelude section will + always be automatically inserted before the first element of this list. + +`prelude_section_name` + The name of the prelude section in the note template. Note that the + value for this must be a single word, but can have underscores. The + value is displayed in titlecase in the report after replacing + underscores with spaces. + + Defaults to ``prelude`` + +`ignore_null_merges` + OpenStack used to use null-merges to bring final release tags from + stable branches back into the master branch. This confuses the + regular traversal because it makes that stable branch appear to be + part of master and/or the later stable branch. This option allows us + to ignore those. + + When this option is set to True, any merge commits with no changes + and in which the second or later parent is tagged are considered + "null-merges" that bring the tag information into the current branch + but nothing else. + + Defaults to ``True``. + +`ignore_notes` + A list of filenames or UIDs for notes that should be ignored by the + reno scanner. It is most useful to set this when a note is edited on + the wrong branch, making it appear to be part of a release that it + is not. + + .. warning:: + + Setting the option in the main configuration file makes it apply + to all branches. To ignore a note in the HTML build, use the + ``ignore-notes`` parameter to the ``release-notes`` sphinx + directive. + +Debugging +========= + +The true location of formatting errors in release notes may be masked +because of the way release notes are included into sphinx documents. +To generate the release notes manually, so that they can be put into a +sphinx document directly for debugging, use the ``report`` command. + +.. code-block:: console + + $ reno report . + +Within OpenStack +================ + +The OpenStack project maintains separate instructions for configuring +the CI jobs and other project-specific settings used for reno. Refer +to the `Managing Release Notes +`__ +section of the Project Team Guide for details. diff -Nru python-reno-1.3.0/examples/notes/add-complex-example-6b5927c246456896.yaml python-reno-2.5.0/examples/notes/add-complex-example-6b5927c246456896.yaml --- python-reno-1.3.0/examples/notes/add-complex-example-6b5927c246456896.yaml 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/examples/notes/add-complex-example-6b5927c246456896.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -14,14 +14,23 @@ | with | so the reStructuredText | parser will retain | the line breaks. +features: + This note is a simple string, and does not retain its + formatting when it is rendered in HTML. rst markup here + may break the YAML parser, since the string is not escaped. +fixes: + - Use YAML lists to add multiple items to the same section. + - Another fix could be listed here. other: - | - This bullet item includes a paragraph and a nested list. + This bullet item includes a paragraph and a nested list, + which works because the content of the YAML list item + is an escaped string block with reStructuredText formatting. * list item 1 * list item 2 - :: + .. code-block:: text This example is also rendered correctly on multiple lines diff -Nru python-reno-1.3.0/.gitignore python-reno-2.5.0/.gitignore --- python-reno-1.3.0/.gitignore 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/.gitignore 2017-07-19 09:44:08.000000000 +0000 @@ -23,7 +23,7 @@ pip-log.txt # Unit test / coverage reports -.coverage +.coverage* .tox nosetests.xml .testrepository @@ -52,3 +52,5 @@ *~ .*.swp .*sw? +/cover/ +/releasenotes/notes/reno.cache diff -Nru python-reno-1.3.0/HACKING.rst python-reno-2.5.0/HACKING.rst --- python-reno-1.3.0/HACKING.rst 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/HACKING.rst 2017-07-19 09:44:08.000000000 +0000 @@ -1,4 +1,4 @@ reno Style Commandments =============================================== -Read the OpenStack Style Commandments http://docs.openstack.org/developer/hacking/ +Read the OpenStack Style Commandments https://docs.openstack.org/developer/hacking/ diff -Nru python-reno-1.3.0/openstack-common.conf python-reno-2.5.0/openstack-common.conf --- python-reno-1.3.0/openstack-common.conf 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/openstack-common.conf 1970-01-01 00:00:00.000000000 +0000 @@ -1,6 +0,0 @@ -[DEFAULT] - -# The list of modules to copy from oslo-incubator.git - -# The base module to hold the copy of openstack.common -base=reno diff -Nru python-reno-1.3.0/README.rst python-reno-2.5.0/README.rst --- python-reno-1.3.0/README.rst 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/README.rst 2017-07-19 09:44:08.000000000 +0000 @@ -1,19 +1,58 @@ -=============================== - reno -- Release Notes Manager -=============================== +========================================= + reno: A New Way to Manage Release Notes +========================================= -Reno is a release notes manager for storing release notes in a git -repository and then building documentation from them. +Reno is a release notes manager designed with high throughput in mind, +supporting fast distributed development teams without introducing +additional development processes. Our goal is to encourage detailed +and accurate release notes for every release. + +Reno uses git to store its data, along side the code being +described. This means release notes can be written when the code +changes are fresh, so no details are forgotten. It also means that +release notes can go through the same review process used for managing +code and other documentation changes. + +Reno stores each release note in a separate file to enable a large +number of developers to work on multiple patches simultaneously, all +targeting the same branch, without worrying about merge +conflicts. This cuts down on the need to rebase or otherwise manually +resolve conflicts, and keeps a development team moving quickly. + +Reno also supports multiple branches, allowing release notes to be +back-ported from master to maintenance branches together with the +code for bug fixes. + +Reno organizes notes into logical groups based on whether they +describe new features, bug fixes, known issues, or other topics of +interest to the user. Contributors categorize individual notes as they +are added, and reno combines them before publishing. + +Notes can be styled using reStructuredText directives, and reno's +Sphinx integration makes it easy to incorporate release notes into +automated documentation builds. + +Notes are automatically associated with the release version based on +the git tags applied to the repository, so it is not necessary to +track changes manually using a bug tracker or other tool, or to worry +that an important change will be missed when the release notes are +written by hand all at one time, just before a release. + +Modifications to notes are incorporated when the notes are shown in +their original location in the history. This feature makes it possible +to correct typos or otherwise fix a published release note after a +release is made, but have the new note content associated with the +original version number. Notes also can be deleted, eliminating them +from future documentation builds. Project Meta-data ================= -* Free software: Apache license -* Documentation: http://docs.openstack.org/developer/reno -* Source: http://git.openstack.org/cgit/openstack/reno -* Bugs: http://bugs.launchpad.net/reno +.. .. image:: https://governance.openstack.org/badges/reno.svg + :target: https://governance.openstack.org/reference/tags/index.html -Features -======== - -* TODO +* Free software: Apache license +* Documentation: https://docs.openstack.org/reno/latest/ +* Source: https://git.openstack.org/cgit/openstack/reno +* Bugs: https://bugs.launchpad.net/reno +* IRC: #openstack-release on freenode diff -Nru python-reno-1.3.0/releasenotes/notes/add-config-file-e77084792c1dc695.yaml python-reno-2.5.0/releasenotes/notes/add-config-file-e77084792c1dc695.yaml --- python-reno-1.3.0/releasenotes/notes/add-config-file-e77084792c1dc695.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/add-config-file-e77084792c1dc695.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,8 @@ +--- +features: + - | + Reno now supports having a ``config.yaml`` file in your release notes + directory. It will search for file in the directory specified by + ``--rel-notes-dir`` and parse it. It will apply whatever options are + valid for that particular command. If an option is not relevant to a + particular sub-command, it will not attempt to apply them. diff -Nru python-reno-1.3.0/releasenotes/notes/add-earliest-version-6f3d634770e855d0.yaml python-reno-2.5.0/releasenotes/notes/add-earliest-version-6f3d634770e855d0.yaml --- python-reno-1.3.0/releasenotes/notes/add-earliest-version-6f3d634770e855d0.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/add-earliest-version-6f3d634770e855d0.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,6 @@ +--- +features: + - Add the ability to limit queries by stopping at an "earliest + version". This is intended to be used when scanning a branch, for + example, to stop at a point when the branch was created and not + include all of the history from the parent branch. \ No newline at end of file diff -Nru python-reno-1.3.0/releasenotes/notes/add-linter-ce0a861ade64baf2.yaml python-reno-2.5.0/releasenotes/notes/add-linter-ce0a861ade64baf2.yaml --- python-reno-1.3.0/releasenotes/notes/add-linter-ce0a861ade64baf2.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/add-linter-ce0a861ade64baf2.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,5 @@ +--- +features: + - | + Add a ``lint`` command for checking the contents and names of the + release notes files against some basic validation rules. \ No newline at end of file diff -Nru python-reno-1.3.0/releasenotes/notes/allow-short-branch-names-61a35be55f04cea4.yaml python-reno-2.5.0/releasenotes/notes/allow-short-branch-names-61a35be55f04cea4.yaml --- python-reno-1.3.0/releasenotes/notes/allow-short-branch-names-61a35be55f04cea4.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/allow-short-branch-names-61a35be55f04cea4.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,8 @@ +--- +fixes: + - | + Fix a problem with branch references so that it is now possible to + use a local tracking branch name when the branch only exists on + the 'origin' remote. For example, this allows references to + 'stable/ocata' when there is no local branch with that name but + there is an 'origin/stable/ocata' branch. \ No newline at end of file diff -Nru python-reno-1.3.0/releasenotes/notes/avoid-clashing-uids-e84ffe8132ce996d.yaml python-reno-2.5.0/releasenotes/notes/avoid-clashing-uids-e84ffe8132ce996d.yaml --- python-reno-1.3.0/releasenotes/notes/avoid-clashing-uids-e84ffe8132ce996d.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/avoid-clashing-uids-e84ffe8132ce996d.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,8 @@ +--- +fixes: + - | + Fix a problem caused by failing to process multiple files with the + same UID portion of the filename. Ignore existing cases as long as + there is a corrective patch to remove them. Prevent new cases from + being introduced. See https://bugs.launchpad.net/reno/+bug/1688042 + for details. \ No newline at end of file diff -Nru python-reno-1.3.0/releasenotes/notes/branches-eol-bcafc2a007a1eb9f.yaml python-reno-2.5.0/releasenotes/notes/branches-eol-bcafc2a007a1eb9f.yaml --- python-reno-1.3.0/releasenotes/notes/branches-eol-bcafc2a007a1eb9f.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/branches-eol-bcafc2a007a1eb9f.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,12 @@ +--- +features: + - | + Explicitly allow reno to scan starting from a tag by specifying the + tag where a branch name would otherwise be used. + - | + Add logic to allow reno to detect a branch that has been marked as + end-of-life using the OpenStack community's process of tagging the + HEAD of a stable/foo branch foo-eol before deleting the + branch. This means that references to "stable/foo" are translated + to "foo-eol" when the branch does not exist, and that Sphinx + directives do not need to be manually updated. \ No newline at end of file diff -Nru python-reno-1.3.0/releasenotes/notes/bug-1537451-f44591da125ba09d.yaml python-reno-2.5.0/releasenotes/notes/bug-1537451-f44591da125ba09d.yaml --- python-reno-1.3.0/releasenotes/notes/bug-1537451-f44591da125ba09d.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/bug-1537451-f44591da125ba09d.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,6 @@ +--- +fixes: + - Resolves `a bug `__ + with properly detecting pre-release versions in the existing + history of a repository that resulted in some release notes not + appearing in the report output. diff -Nru python-reno-1.3.0/releasenotes/notes/collapse-pre-releases-0b24e0bab46d7cf1.yaml python-reno-2.5.0/releasenotes/notes/collapse-pre-releases-0b24e0bab46d7cf1.yaml --- python-reno-1.3.0/releasenotes/notes/collapse-pre-releases-0b24e0bab46d7cf1.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/collapse-pre-releases-0b24e0bab46d7cf1.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,4 @@ +--- +features: + - Add a flag to collapse pre-release notes into their final release, + if the final release tag is present. \ No newline at end of file diff -Nru python-reno-1.3.0/releasenotes/notes/config-option-branch-name-re-8ecfe93195b8824e.yaml python-reno-2.5.0/releasenotes/notes/config-option-branch-name-re-8ecfe93195b8824e.yaml --- python-reno-1.3.0/releasenotes/notes/config-option-branch-name-re-8ecfe93195b8824e.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/config-option-branch-name-re-8ecfe93195b8824e.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,15 @@ +--- +features: + - | + Add a configuration option ``branch_name_re`` to hold a regular expression + for choosing "interesting" branches when trying to automatically detect + how far back the scanner should look. The default is ``stable/.+``, which + works for the OpenStack practice of creating branches named after the + stable series of releases. +fixes: + - | + Fixes the logic for determining how far back in history to look when + scanning a given branch. Reno now looks for the base of the "previous" + branch, as determined by looking at branches matching ``branch_name_re`` + in lexical order. This may not work if branches are created using + version numbers as their names. diff -Nru python-reno-1.3.0/releasenotes/notes/config-option-sections-9c68b070698e984a.yaml python-reno-2.5.0/releasenotes/notes/config-option-sections-9c68b070698e984a.yaml --- python-reno-1.3.0/releasenotes/notes/config-option-sections-9c68b070698e984a.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/config-option-sections-9c68b070698e984a.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,7 @@ +--- +features: + - | + Add a configuration option ``sections`` to hold the list of + permitted section identifiers and corresponding display names. + This also determines the order in which sections are collated. + diff -Nru python-reno-1.3.0/releasenotes/notes/custom-tag-versions-d02028b6d35db967.yaml python-reno-2.5.0/releasenotes/notes/custom-tag-versions-d02028b6d35db967.yaml --- python-reno-1.3.0/releasenotes/notes/custom-tag-versions-d02028b6d35db967.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/custom-tag-versions-d02028b6d35db967.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,15 @@ +--- +features: + - | + Add the ability to specify regular expressions to a define a + customised versioning scheme for release tags and pre-release tags. + + By default this change supports the current versioning scheme used by + OpenStack. + + To customise, update the config.yaml file with the appropriate values. + For example, for tags with versions like ``v1.0.0`` and pre-release + versions like ``v1.0.0rc1`` the following could be added to config.yaml:: + + release_tag_re: 'v\d\.\d\.\d(rc\d+)?' + pre_release_tag_re: '(?Prc\d+$)' diff -Nru python-reno-1.3.0/releasenotes/notes/default-repository-root-cli-85d23034bef81619.yaml python-reno-2.5.0/releasenotes/notes/default-repository-root-cli-85d23034bef81619.yaml --- python-reno-1.3.0/releasenotes/notes/default-repository-root-cli-85d23034bef81619.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/default-repository-root-cli-85d23034bef81619.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,5 @@ +--- +features: + - Set the default value of the reporoot argument + for all command line programs to "." and make + it an optional parameter. \ No newline at end of file diff -Nru python-reno-1.3.0/releasenotes/notes/dulwich-rewrite-3a5377162d97402b.yaml python-reno-2.5.0/releasenotes/notes/dulwich-rewrite-3a5377162d97402b.yaml --- python-reno-1.3.0/releasenotes/notes/dulwich-rewrite-3a5377162d97402b.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/dulwich-rewrite-3a5377162d97402b.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,5 @@ +--- +prelude: > + This release includes a significant rewrite of the internal logic of + reno to access git data through the dulwich library instead of the + git command line porcelain. diff -Nru python-reno-1.3.0/releasenotes/notes/fix-branch-base-detection-95300805f26a0c15.yaml python-reno-2.5.0/releasenotes/notes/fix-branch-base-detection-95300805f26a0c15.yaml --- python-reno-1.3.0/releasenotes/notes/fix-branch-base-detection-95300805f26a0c15.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/fix-branch-base-detection-95300805f26a0c15.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,8 @@ +--- +fixes: + - | + Fix a problem with the way reno automatically detects the initial + version in a branch that prevented it from including all of the + notes associated with a release, especially if the branch was + created at a pre-release version number. + `Bug #1652092 `__ diff -Nru python-reno-1.3.0/releasenotes/notes/fix-delete-handling-55232c50b647aa57.yaml python-reno-2.5.0/releasenotes/notes/fix-delete-handling-55232c50b647aa57.yaml --- python-reno-1.3.0/releasenotes/notes/fix-delete-handling-55232c50b647aa57.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/fix-delete-handling-55232c50b647aa57.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,6 @@ +--- +fixes: + - | + Correct a problem with handling deleted release notes that + triggered a TypeError with a message like "Can't mix strings and + bytes in path components" \ No newline at end of file diff -Nru python-reno-1.3.0/releasenotes/notes/fix-sphinxext-scanner-0aa012ada66db773.yaml python-reno-2.5.0/releasenotes/notes/fix-sphinxext-scanner-0aa012ada66db773.yaml --- python-reno-1.3.0/releasenotes/notes/fix-sphinxext-scanner-0aa012ada66db773.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/fix-sphinxext-scanner-0aa012ada66db773.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,8 @@ +--- +fixes: + - | + Fixes a problem with the sphinx extension that caused it to + produce an error if it had a list of versions to include that were + not within the set that seemed to be on the branch because of the + branch-base detection logic. Now if a list of versions is + included, the scan always includes the full history. \ No newline at end of file diff -Nru python-reno-1.3.0/releasenotes/notes/flexible-formatting-31c8de2599d3637d.yaml python-reno-2.5.0/releasenotes/notes/flexible-formatting-31c8de2599d3637d.yaml --- python-reno-1.3.0/releasenotes/notes/flexible-formatting-31c8de2599d3637d.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/flexible-formatting-31c8de2599d3637d.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,5 @@ +--- +features: + Release notes entries may now be made up of single strings. This + simplifies formatting for smaller notes, and eliminates a class of + errors associated with escaping reStructuredText inside YAML lists. diff -Nru python-reno-1.3.0/releasenotes/notes/ignore-notes-option-9d0bde540fbcdf22.yaml python-reno-2.5.0/releasenotes/notes/ignore-notes-option-9d0bde540fbcdf22.yaml --- python-reno-1.3.0/releasenotes/notes/ignore-notes-option-9d0bde540fbcdf22.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/ignore-notes-option-9d0bde540fbcdf22.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,8 @@ +--- +features: + - | + Add a new configuration option ``ignore_notes``. Setting the value + to a list of filenames or UIDs for notes causes the reno scanner + to ignore them. It is most useful to set this when a note is + edited on the wrong branch, making it appear to be part of a + release that it is not. diff -Nru python-reno-1.3.0/releasenotes/notes/ignore-null-merges-56b7a8ed9b20859e.yaml python-reno-2.5.0/releasenotes/notes/ignore-null-merges-56b7a8ed9b20859e.yaml --- python-reno-1.3.0/releasenotes/notes/ignore-null-merges-56b7a8ed9b20859e.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/ignore-null-merges-56b7a8ed9b20859e.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,18 @@ +--- +features: + - | + By default, reno now ignores "null" merge commits that bring in + tags from other threads. The new configuration option + ``ignore_null_merges`` controls this behavior. Setting the flag to + False restores the previous behavior in which the null-merge + commits were traversed like any other merge commit. +upgrade: + - | + The new configuration option ``ignore_null_merges`` causes the + scanner to ignore merge commits with no changes when one of the + parents being merged in has a release tag on it. +fixes: + - | + This release fixes a problem with the scanner that may have caused + it to stop scanning a branch prematurely when the tag from another + branch had been merged into the history. diff -Nru python-reno-1.3.0/releasenotes/notes/include-working-copy-d0aed2e77bb095e6.yaml python-reno-2.5.0/releasenotes/notes/include-working-copy-d0aed2e77bb095e6.yaml --- python-reno-1.3.0/releasenotes/notes/include-working-copy-d0aed2e77bb095e6.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/include-working-copy-d0aed2e77bb095e6.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,7 @@ +--- +features: + - | + Include the local working copy when scanning the history of the + current branch. Notes files must at least be staged to indicate + that they will eventually be part of the history, but subsequent + changes to the file do not need to also be staged to be seen. diff -Nru python-reno-1.3.0/releasenotes/notes/log-levels-and-sphinx-161-6efe0d291718a657.yaml python-reno-2.5.0/releasenotes/notes/log-levels-and-sphinx-161-6efe0d291718a657.yaml --- python-reno-1.3.0/releasenotes/notes/log-levels-and-sphinx-161-6efe0d291718a657.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/log-levels-and-sphinx-161-6efe0d291718a657.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,8 @@ +--- +fixes: + - | + Sphinx 1.6.1 now interprets error and warning log messages as + reasons to abort the build when strict mode is enabled. This + release changes the log level for some calls that weren't really + errors to begin with to avoid having Sphinx abort the build + unnecessarily. \ No newline at end of file diff -Nru python-reno-1.3.0/releasenotes/notes/no-show-source-option-ee02766b26fe53be.yaml python-reno-2.5.0/releasenotes/notes/no-show-source-option-ee02766b26fe53be.yaml --- python-reno-1.3.0/releasenotes/notes/no-show-source-option-ee02766b26fe53be.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/no-show-source-option-ee02766b26fe53be.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,8 @@ +--- +features: + - | + Add a ``--no-show-source`` option to the report command to skip + including the note reference file names and SHA information + in comments in the output. This restores the previous format of + the output for cases where it is meant to be read by people directly, + not just converted to HTML. diff -Nru python-reno-1.3.0/releasenotes/notes/null-merge-infinite-loop-670367094ad83e19.yaml python-reno-2.5.0/releasenotes/notes/null-merge-infinite-loop-670367094ad83e19.yaml --- python-reno-1.3.0/releasenotes/notes/null-merge-infinite-loop-670367094ad83e19.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/null-merge-infinite-loop-670367094ad83e19.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,5 @@ +--- +fixes: + - | + Remove an infinite loop in the traversal algorithm caused by some + null-merge skip situations. diff -Nru python-reno-1.3.0/releasenotes/notes/optional-oslosphinx-55843a7f80a14e58.yaml python-reno-2.5.0/releasenotes/notes/optional-oslosphinx-55843a7f80a14e58.yaml --- python-reno-1.3.0/releasenotes/notes/optional-oslosphinx-55843a7f80a14e58.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/optional-oslosphinx-55843a7f80a14e58.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,5 @@ +--- +other: + - The oslosphinx dependency for building documentation + is now optional. This breaks a build cycle between + oslosphinx and reno. diff -Nru python-reno-1.3.0/releasenotes/notes/repodir-config-file-b6b8edc2975964fc.yaml python-reno-2.5.0/releasenotes/notes/repodir-config-file-b6b8edc2975964fc.yaml --- python-reno-1.3.0/releasenotes/notes/repodir-config-file-b6b8edc2975964fc.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/repodir-config-file-b6b8edc2975964fc.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,7 @@ +--- +features: + - | + reno will now scan for a ``reno.yaml`` file in the root repo directory if a + ``config.yaml`` file does not exist in the releasenotes directory. This + allows users to do away with the unnecessary ``notes`` subdirectory in the + releasenotes directory. diff -Nru python-reno-1.3.0/releasenotes/notes/report-title-option-f0875bfdbc54dd7b.yaml python-reno-2.5.0/releasenotes/notes/report-title-option-f0875bfdbc54dd7b.yaml --- python-reno-1.3.0/releasenotes/notes/report-title-option-f0875bfdbc54dd7b.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/report-title-option-f0875bfdbc54dd7b.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,4 @@ +--- +features: + - | + Add a ``--title`` option to the report command. diff -Nru python-reno-1.3.0/releasenotes/notes/show-less-unreleased-802781a1a3bf110e.yaml python-reno-2.5.0/releasenotes/notes/show-less-unreleased-802781a1a3bf110e.yaml --- python-reno-1.3.0/releasenotes/notes/show-less-unreleased-802781a1a3bf110e.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/show-less-unreleased-802781a1a3bf110e.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,8 @@ +--- +features: + - | + The scanner for the "current" branch (usually ``master``) now stops + when it encounters the base of an earlier branch matching the + ``branch_name_re`` config option. This results in less history + appearing on the unreleased pages, while still actually showing + the current series and any unreleased notes. diff -Nru python-reno-1.3.0/releasenotes/notes/show-note-filename-in-report-a1118c917588b58d.yaml python-reno-2.5.0/releasenotes/notes/show-note-filename-in-report-a1118c917588b58d.yaml --- python-reno-1.3.0/releasenotes/notes/show-note-filename-in-report-a1118c917588b58d.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/show-note-filename-in-report-a1118c917588b58d.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,7 @@ +--- +features: + - | + The report output now includes debugging details with the filename + and sha for the version of the content used to indicate where the + content is from to assist with debugging formatting or content + issues. diff -Nru python-reno-1.3.0/releasenotes/notes/stop-scanning-branch-e5a8937c248acc99.yaml python-reno-2.5.0/releasenotes/notes/stop-scanning-branch-e5a8937c248acc99.yaml --- python-reno-1.3.0/releasenotes/notes/stop-scanning-branch-e5a8937c248acc99.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/stop-scanning-branch-e5a8937c248acc99.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,7 @@ +--- +features: + - Automatically stop scanning branches at the point where they + diverge from master. This avoids having release notes from older + versions, that appear on master before the branch, from showing up + in the versions from the branch. This logic is only applied to + branches created from master. diff -Nru python-reno-1.3.0/releasenotes/notes/stop-scanning-branch-option-6a0156b183814d7f.yaml python-reno-2.5.0/releasenotes/notes/stop-scanning-branch-option-6a0156b183814d7f.yaml --- python-reno-1.3.0/releasenotes/notes/stop-scanning-branch-option-6a0156b183814d7f.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/stop-scanning-branch-option-6a0156b183814d7f.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,9 @@ +--- +features: + - Add a new configuration option, stop_at_branch_base, to control + whether or not the scanner stops looking for changes at the point + where a branch diverges from master. The default is True, meaning + that the scanner does stop. A false value means that versions that + appear on master from a point earlier than when the branch was + created will be included when scanning the branch for release + notes. diff -Nru python-reno-1.3.0/releasenotes/notes/support-custom-template-0534a2199cfec44c.yaml python-reno-2.5.0/releasenotes/notes/support-custom-template-0534a2199cfec44c.yaml --- python-reno-1.3.0/releasenotes/notes/support-custom-template-0534a2199cfec44c.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/support-custom-template-0534a2199cfec44c.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,6 @@ +--- +features: + - | + Reno now supports to set through ``template`` attribute in + ``config.yaml`` a custom template which will be used by reno new + to create notes. diff -Nru python-reno-1.3.0/releasenotes/notes/support-edit-ec5c01ad6144815a.yaml python-reno-2.5.0/releasenotes/notes/support-edit-ec5c01ad6144815a.yaml --- python-reno-1.3.0/releasenotes/notes/support-edit-ec5c01ad6144815a.yaml 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/releasenotes/notes/support-edit-ec5c01ad6144815a.yaml 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,5 @@ +--- +features: + - | + Reno now enables with reno new ``--edit`` to create a note and edit it with + your editor (defined with EDITOR environment variable). diff -Nru python-reno-1.3.0/reno/cache.py python-reno-2.5.0/reno/cache.py --- python-reno-1.3.0/reno/cache.py 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/reno/cache.py 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,107 @@ +# 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 os +import sys + +import yaml + +from reno import loader +from reno import scanner + + +def build_cache_db(conf, versions_to_include): + s = scanner.Scanner(conf) + notes = s.get_notes_by_version() + + # Default to including all versions returned by the scanner. + if not versions_to_include: + versions_to_include = list(notes.keys()) + + # Build a cache data structure including the file contents as well + # as the basic data returned by the scanner. + file_contents = {} + for version in versions_to_include: + for filename, sha in notes[version]: + body = s.get_file_at_commit(filename, sha) + # We want to save the contents of the file, which is YAML, + # inside another YAML file. That looks terribly ugly with + # all of the escapes needed to format it properly as + # embedded YAML, so parse the input and convert it to a + # data structure that can be serialized cleanly. + y = yaml.safe_load(body) + file_contents[filename] = y + + cache = { + 'notes': [ + {'version': k, 'files': v} + for k, v in notes.items() + ], + 'file-contents': file_contents, + } + return cache + + +def write_cache_db(conf, versions_to_include, + outfilename=None): + """Create a cache database file for the release notes data. + + Build the cache database from scanning the project history and + write it to a file within the project. + + By default, the data is written to the same file the scanner will + try to read when it cannot look at the git history. If outfilename + is given and is '-' the data is written to stdout + instead. Otherwise, if outfilename is given, the data overwrites + the named file. + + Return the name of the file created, if any. + + """ + if outfilename == '-': + stream = sys.stdout + close_stream = False + elif outfilename: + stream = open(outfilename, 'w') + close_stream = True + else: + outfilename = loader.get_cache_filename(conf) + if not os.path.exists(os.path.dirname(outfilename)): + os.makedirs(os.path.dirname(outfilename)) + stream = open(outfilename, 'w') + close_stream = True + try: + cache = build_cache_db( + conf, + versions_to_include=versions_to_include, + ) + yaml.safe_dump( + cache, + stream, + allow_unicode=True, + explicit_start=True, + encoding='utf-8', + ) + finally: + if close_stream: + stream.close() + return outfilename + + +def cache_cmd(args, conf): + "Generates a release notes cache" + write_cache_db( + conf=conf, + versions_to_include=args.version, + outfilename=args.output, + ) + return diff -Nru python-reno-1.3.0/reno/config.py python-reno-2.5.0/reno/config.py --- python-reno-1.3.0/reno/config.py 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/reno/config.py 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,251 @@ +# 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 +import os.path + +import yaml + +from reno import defaults + +LOG = logging.getLogger(__name__) + + +class Config(object): + + _OPTS = { + # The notes subdirectory within the relnotesdir where the + # notes live. + 'notesdir': defaults.NOTES_SUBDIR, + + # Should pre-release versions be merged into the final release + # of the same number (1.0.0.0a1 notes appear under 1.0.0). + 'collapse_pre_releases': True, + + # Should the scanner stop at the base of a branch (True) or go + # ahead and scan the entire history (False)? + 'stop_at_branch_base': True, + + # The git branch to scan. Defaults to the "current" branch + # checked out. + 'branch': None, + + # The earliest version to be included. This is usually the + # lowest version number, and is meant to be the oldest + # version. + 'earliest_version': None, + + # The template used by reno new to create a note. + 'template': defaults.TEMPLATE.format(defaults.PRELUDE_SECTION_NAME), + + # The RE pattern used to match the repo tags representing a valid + # release version. The pattern is compiled with the verbose and unicode + # flags enabled. + 'release_tag_re': ''' + ((?:[\d.ab]|rc)+) # digits, a, b, and rc cover regular and + # pre-releases + ''', + + # The RE pattern used to check if a valid release version tag is also a + # valid pre-release version. The pattern is compiled with the verbose + # and unicode flags enabled. The pattern must define a group called + # 'pre_release' that matches the pre-release part of the tag and any + # separator, e.g for pre-release version '12.0.0.0rc1' the default RE + # pattern will identify '.0rc1' as the value of the group + # 'pre_release'. + 'pre_release_tag_re': ''' + (?P\.\d+(?:[ab]|rc)+\d*)$ + ''', + + # The pattern for names for branches that are relevant when + # scanning history to determine where to stop, to find the + # "base" of a branch. Other branches are ignored. + 'branch_name_re': 'stable/.+', + + # The identifiers and names of permitted sections in the + # release notes, in the order in which the final report will + # be generated. A prelude section will always be automatically + # inserted before the first element of this list. + 'sections': [ + ['features', 'New Features'], + ['issues', 'Known Issues'], + ['upgrade', 'Upgrade Notes'], + ['deprecations', 'Deprecation Notes'], + ['critical', 'Critical Issues'], + ['security', 'Security Issues'], + ['fixes', 'Bug Fixes'], + ['other', 'Other Notes'], + ], + + # The name of the prelude section in the note template. This + # allows users to rename the section to, for example, + # 'release_summary' or 'project_wide_general_announcements', + # which is displayed in titlecase in the report after + # replacing underscores with spaces. + 'prelude_section_name': defaults.PRELUDE_SECTION_NAME, + + # When this option is set to True, any merge commits with no + # changes and in which the second or later parent is tagged + # are considered "null-merges" that bring the tag information + # into the current branch but nothing else. + # + # OpenStack used to use null-merges to bring final release + # tags from stable branches back into the master branch. This + # confuses the regular traversal because it makes that stable + # branch appear to be part of master and/or the later stable + # branch. This option allows us to ignore those. + 'ignore_null_merges': True, + + # Note files to be ignored. It's useful to be able to ignore a + # file if it is edited on the wrong branch. Notes should be + # specified by their filename or UID. Setting the value in the + # configuration file makes it apply to all branches. + 'ignore_notes': [], + } + + @classmethod + def get_default(cls, opt): + "Return the default for an option." + try: + return cls._OPTS[opt] + except KeyError: + raise ValueError('unknown option name %r' % (opt,)) + + def __init__(self, reporoot, relnotesdir=None): + """Instantiate a Config object + + :param str reporoot: + The root directory of the repository. + :param str relnotesdir: + The directory containing release notes. Defaults to + 'releasenotes'. + """ + self.reporoot = reporoot + if relnotesdir is None: + relnotesdir = defaults.RELEASE_NOTES_SUBDIR + self.relnotesdir = relnotesdir + # Initialize attributes from the defaults. + self.override(**self._OPTS) + + self._contents = {} + self._load_file() + + def _load_file(self): + filenames = [ + os.path.join(self.reporoot, self.relnotesdir, 'config.yaml'), + os.path.join(self.reporoot, 'reno.yaml')] + + for filename in filenames: + if os.path.isfile(filename): + break + else: + LOG.info('no configuration file in: %s', ', '.join(filenames)) + return + + try: + with open(filename, 'r') as fd: + self._contents = yaml.safe_load(fd) + except IOError as err: + LOG.warning('did not load config file %s: %s', filename, err) + else: + self.override(**self._contents) + + def _rename_prelude_section(self, **kwargs): + key = 'prelude_section_name' + if key in kwargs and kwargs[key] != self._OPTS[key]: + new_prelude_name = kwargs[key] + + self.template = defaults.TEMPLATE.format(new_prelude_name) + + def override(self, **kwds): + """Set the values of the named configuration options. + + Take the values of the keyword arguments as the current value + of the same option, regardless of whether a value is already + present. + + """ + # Replace prelude section name if it has been changed. + self._rename_prelude_section(**kwds) + + for n, v in kwds.items(): + if n not in self._OPTS: + LOG.warning('ignoring unknown configuration value %r = %r', + n, v) + else: + setattr(self, n, v) + + def override_from_parsed_args(self, parsed_args): + """Set the values of the configuration options from parsed CLI args. + + This method assumes that the DEST values for the command line + arguments are named the same as the configuration options. + + """ + arg_values = { + o: getattr(parsed_args, o) + for o in self._OPTS.keys() + if hasattr(parsed_args, o) + } + self.override(**arg_values) + + @property + def reporoot(self): + return self._reporoot + + # Ensure that the 'reporoot' value always only ends in one '/'. + @reporoot.setter + def reporoot(self, value): + self._reporoot = value.rstrip('/') + '/' + + @property + def notespath(self): + """The path in the repo where notes are kept. + + .. important:: + + This does not take ``reporoot`` into account. You need to add this + manually if required. + """ + return os.path.join(self.relnotesdir, self.notesdir) + + @property + def options(self): + """Get all configuration options as a dict. + + Returns the actual configuration options after overrides. + """ + options = {o: getattr(self, o) for o in self._OPTS} + return options + +# def parse_config_into(parsed_arguments): + +# """Parse the user config onto the namespace arguments. + +# :param parsed_arguments: +# The result of calling :meth:`argparse.ArgumentParser.parse_args`. +# :type parsed_arguments: +# argparse.Namespace +# """ +# config_path = get_config_path(parsed_arguments.relnotesdir) +# config_values = read_config(config_path) + +# for key in config_values.keys(): +# try: +# getattr(parsed_arguments, key) +# except AttributeError: +# LOG.info('Option "%s" does not apply to this particular command.' +# '. Ignoring...', key) +# continue +# setattr(parsed_arguments, key, config_values[key]) + +# parsed_arguments._config = config_values diff -Nru python-reno-1.3.0/reno/create.py python-reno-2.5.0/reno/create.py --- python-reno-1.3.0/reno/create.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/create.py 2017-07-19 09:44:08.000000000 +0000 @@ -13,83 +13,11 @@ from __future__ import print_function import os +import subprocess from reno import utils -_TEMPLATE = """\ ---- -prelude: > - Replace this text with content to appear at the - top of the section for this release. All of the - prelude content is merged together and then rendered - separately from the items listed in other parts of - the file, so the text needs to be worded so that - both the prelude and the other items make sense - when read independently. This may mean repeating - some details. Not every release note - requires a prelude. Usually only notes describing - major features or adding release theme details should - have a prelude. -features: - - List new features here, or remove this section. - All of the list items in this section are combined - when the release notes are rendered, so the text - needs to be worded so that it does not depend on any - information only available in another section, such - as the prelude. This may mean repeating some details. -issues: - - List known issues here, or remove this section. - All of the list items in this section are combined - when the release notes are rendered, so the text - needs to be worded so that it does not depend on any - information only available in another section, such - as the prelude. This may mean repeating some details. -upgrade: - - List upgrade notes here, or remove this section. - All of the list items in this section are combined - when the release notes are rendered, so the text - needs to be worded so that it does not depend on any - information only available in another section, such - as the prelude. This may mean repeating some details. -deprecations: - - List deprecations notes here, or remove this section. - All of the list items in this section are combined - when the release notes are rendered, so the text - needs to be worded so that it does not depend on any - information only available in another section, such - as the prelude. This may mean repeating some details. -critical: - - Add critical notes here, or remove this section. - All of the list items in this section are combined - when the release notes are rendered, so the text - needs to be worded so that it does not depend on any - information only available in another section, such - as the prelude. This may mean repeating some details. -security: - - Add security notes here, or remove this section. - All of the list items in this section are combined - when the release notes are rendered, so the text - needs to be worded so that it does not depend on any - information only available in another section, such - as the prelude. This may mean repeating some details. -fixes: - - Add normal bug fixes here, or remove this section. - All of the list items in this section are combined - when the release notes are rendered, so the text - needs to be worded so that it does not depend on any - information only available in another section, such - as the prelude. This may mean repeating some details. -other: - - Add other notes here, or remove this section. - All of the list items in this section are combined - when the release notes are rendered, so the text - needs to be worded so that it does not depend on any - information only available in another section, such - as the prelude. This may mean repeating some details. -""" - - def _pick_note_file_name(notesdir, slug): "Pick a unique name in notesdir." for i in range(50): @@ -104,17 +32,23 @@ ) -def _make_note_file(filename): +def _make_note_file(filename, template): notesdir = os.path.dirname(filename) if not os.path.exists(notesdir): os.makedirs(notesdir) with open(filename, 'w') as f: - f.write(_TEMPLATE) + f.write(template) + + +def _edit_file(filename): + if 'EDITOR' not in os.environ: + return False + subprocess.call([os.environ['EDITOR'], filename]) + return True -def create_cmd(args): +def create_cmd(args, conf): "Create a new release note file from the template." - notesdir = utils.get_notes_dir(args) # NOTE(dhellmann): There is a short race window where we might try # to pick a name that does not exist, then overwrite the file if # it is created before we try to write it. This isn't a problem @@ -122,7 +56,10 @@ # their local git tree, and so there should not be any concurrency # concern. slug = args.slug.replace(' ', '-') - filename = _pick_note_file_name(notesdir, slug) - _make_note_file(filename) + filename = _pick_note_file_name(conf.notespath, slug) + _make_note_file(filename, conf.template) + if args.edit and not _edit_file(filename): + print('Was unable to edit the new note. EDITOR environment variable ' + 'is missing!') print('Created new notes file in %s' % filename) return diff -Nru python-reno-1.3.0/reno/defaults.py python-reno-2.5.0/reno/defaults.py --- python-reno-1.3.0/reno/defaults.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/defaults.py 2017-07-19 09:44:08.000000000 +0000 @@ -12,3 +12,72 @@ RELEASE_NOTES_SUBDIR = 'releasenotes' NOTES_SUBDIR = 'notes' +PRELUDE_SECTION_NAME = 'prelude' +# This is a format string, so it needs to be formatted wherever it is used. +TEMPLATE = """\ +--- +{0}: > + Replace this text with content to appear at the top of the section for this + release. All of the prelude content is merged together and then rendered + separately from the items listed in other parts of the file, so the text + needs to be worded so that both the prelude and the other items make sense + when read independently. This may mean repeating some details. Not every + release note requires a prelude. Usually only notes describing major + features or adding release theme details should have a prelude. +features: + - | + List new features here, or remove this section. All of the list items in + this section are combined when the release notes are rendered, so the text + needs to be worded so that it does not depend on any information only + available in another section, such as the prelude. This may mean repeating + some details. +issues: + - | + List known issues here, or remove this section. All of the list items in + this section are combined when the release notes are rendered, so the text + needs to be worded so that it does not depend on any information only + available in another section, such as the prelude. This may mean repeating + some details. +upgrade: + - | + List upgrade notes here, or remove this section. All of the list items in + this section are combined when the release notes are rendered, so the text + needs to be worded so that it does not depend on any information only + available in another section, such as the prelude. This may mean repeating + some details. +deprecations: + - | + List deprecations notes here, or remove this section. All of the list + items in this section are combined when the release notes are rendered, so + the text needs to be worded so that it does not depend on any information + only available in another section, such as the prelude. This may mean + repeating some details. +critical: + - | + Add critical notes here, or remove this section. All of the list items in + this section are combined when the release notes are rendered, so the text + needs to be worded so that it does not depend on any information only + available in another section, such as the prelude. This may mean repeating + some details. +security: + - | + Add security notes here, or remove this section. All of the list items in + this section are combined when the release notes are rendered, so the text + needs to be worded so that it does not depend on any information only + available in another section, such as the prelude. This may mean repeating + some details. +fixes: + - | + Add normal bug fixes here, or remove this section. All of the list items + in this section are combined when the release notes are rendered, so the + text needs to be worded so that it does not depend on any information only + available in another section, such as the prelude. This may mean repeating + some details. +other: + - | + Add other notes here, or remove this section. All of the list items in + this section are combined when the release notes are rendered, so the text + needs to be worded so that it does not depend on any information only + available in another section, such as the prelude. This may mean repeating + some details. +""" diff -Nru python-reno-1.3.0/reno/formatter.py python-reno-2.5.0/reno/formatter.py --- python-reno-1.3.0/reno/formatter.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/formatter.py 2017-07-19 09:44:08.000000000 +0000 @@ -12,22 +12,6 @@ from __future__ import print_function -from reno import scanner - -import yaml - - -_SECTION_ORDER = [ - ('features', 'New Features'), - ('issues', 'Known Issues'), - ('upgrade', 'Upgrade Notes'), - ('deprecations', 'Deprecation Notes'), - ('critical', 'Critical Issues'), - ('security', 'Security Issues'), - ('fixes', 'Bug Fixes'), - ('other', 'Other Notes'), -] - def _indent_for_list(text, prefix=' '): """Indent some text to make it work as a list entry. @@ -41,7 +25,8 @@ ]) + '\n' -def format_report(reporoot, scanner_output, versions_to_include, title=None): +def format_report(loader, config, versions_to_include, title=None, + show_source=True): report = [] if title: report.append('=' * len(title)) @@ -52,14 +37,9 @@ # Read all of the notes files. file_contents = {} for version in versions_to_include: - for filename, sha in scanner_output[version]: - body = scanner.get_file_at_commit( - reporoot, - filename, - sha, - ) - y = yaml.safe_load(body) - file_contents[filename] = y + for filename, sha in loader[version]: + body = loader.parse_note_file(filename, sha) + file_contents[filename] = body for version in versions_to_include: report.append(version) @@ -67,23 +47,36 @@ report.append('') # Add the preludes. - notefiles = scanner_output[version] - for n, sha in notefiles: - if 'prelude' in file_contents[n]: - report.append(file_contents[n]['prelude']) - report.append('') + notefiles = loader[version] + prelude_name = config.prelude_section_name + notefiles_with_prelude = [(n, sha) for n, sha in notefiles + if prelude_name in file_contents[n]] + if notefiles_with_prelude: + report.append(prelude_name.replace('_', ' ').title()) + report.append('-' * len(prelude_name)) + report.append('') + + for n, sha in notefiles_with_prelude: + if show_source: + report.append('.. %s @ %s\n' % (n, sha)) + report.append(file_contents[n][prelude_name]) + report.append('') - for section_name, section_title in _SECTION_ORDER: + # Add other sections. + for section_name, section_title in config.sections: notes = [ - n + (n, fn, sha) for fn, sha in notefiles + if file_contents[fn].get(section_name) for n in file_contents[fn].get(section_name, []) ] if notes: report.append(section_title) report.append('-' * len(section_title)) report.append('') - for n in notes: + for n, fn, sha in notes: + if show_source: + report.append('.. %s @ %s\n' % (fn, sha)) report.append('- %s' % _indent_for_list(n)) report.append('') diff -Nru python-reno-1.3.0/reno/__init__.py python-reno-2.5.0/reno/__init__.py --- python-reno-1.3.0/reno/__init__.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/__init__.py 2017-07-19 09:44:08.000000000 +0000 @@ -12,8 +12,14 @@ # License for the specific language governing permissions and limitations # under the License. +import logging + import pbr.version __version__ = pbr.version.VersionInfo( 'reno').version_string() + +# Configure a null logger so that if reno is used as a library by an +# application that does not configure logging there are no warnings. +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff -Nru python-reno-1.3.0/reno/linter.py python-reno-2.5.0/reno/linter.py --- python-reno-1.3.0/reno/linter.py 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/reno/linter.py 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,54 @@ +# 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. + +from __future__ import print_function + +import glob +import logging +import os.path + +from reno import loader +from reno import scanner + +LOG = logging.getLogger(__name__) + + +def lint_cmd(args, conf): + "Check some common mistakes" + LOG.debug('starting lint') + notesdir = os.path.join(conf.reporoot, conf.notespath) + notes = glob.glob(os.path.join(notesdir, '*.yaml')) + + error = 0 + load = loader.Loader(conf, ignore_cache=True) + allowed_section_names = [conf.prelude_section_name] + \ + [s[0] for s in conf.sections] + + uids = {} + for f in notes: + LOG.debug('examining %s', f) + uid = scanner._get_unique_id(f) + uids.setdefault(uid, []).append(f) + + content = load.parse_note_file(f, None) + for section_name in content.keys(): + if section_name not in allowed_section_names: + LOG.warning('unrecognized section name %s in %s', + section_name, f) + error = 1 + + for uid, names in sorted(uids.items()): + if len(names) > 1: + LOG.warning('UID collision: %s', names) + error = 1 + + return error diff -Nru python-reno-1.3.0/reno/lister.py python-reno-2.5.0/reno/lister.py --- python-reno-1.3.0/reno/lister.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/lister.py 2017-07-19 09:44:08.000000000 +0000 @@ -14,24 +14,22 @@ import logging -from reno import scanner -from reno import utils +from reno import loader LOG = logging.getLogger(__name__) -def list_cmd(args): +def list_cmd(args, conf): "List notes files based on query arguments" LOG.debug('starting list') - reporoot = args.reporoot.rstrip('/') + '/' - notesdir = utils.get_notes_dir(args) - notes = scanner.get_notes_by_version(reporoot, notesdir, args.branch) + reporoot = conf.reporoot + ldr = loader.Loader(conf) if args.version: versions = args.version else: - versions = notes.keys() + versions = ldr.versions for version in versions: - notefiles = notes[version] + notefiles = ldr[version] print(version) for n, sha in notefiles: if n.startswith(reporoot): diff -Nru python-reno-1.3.0/reno/loader.py python-reno-2.5.0/reno/loader.py --- python-reno-1.3.0/reno/loader.py 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/reno/loader.py 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,140 @@ +# 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 +import os.path + +import six +import yaml + +from reno import scanner + +LOG = logging.getLogger(__name__) + + +def get_cache_filename(conf): + return os.path.normpath(os.path.join( + conf.reporoot, conf.notespath, 'reno.cache')) + + +class Loader(object): + "Load the release notes for a given repository." + + def __init__(self, conf, + ignore_cache=False): + """Initialize a Loader. + + The versions are presented in reverse chronological order. + + Notes files are associated with the earliest version for which + they were available, regardless of whether they changed later. + + :param conf: Parsed configuration from file + :type conf: reno.config.Config + :param ignore_cache: Do not load a cache file if it is present. + :type ignore_cache: bool + """ + self._config = conf + self._ignore_cache = ignore_cache + + self._reporoot = conf.reporoot + self._notespath = conf.notespath + self._branch = conf.branch + self._collapse_pre_releases = conf.collapse_pre_releases + self._earliest_version = conf.earliest_version + + self._cache = None + self._scanner = None + self._scanner_output = None + self._cache_filename = get_cache_filename(conf) + + self._load_data() + + def _load_data(self): + cache_file_exists = os.path.exists(self._cache_filename) + + if self._ignore_cache and cache_file_exists: + LOG.debug('ignoring cache file %s', self._cache_filename) + + if (not self._ignore_cache) and cache_file_exists: + with open(self._cache_filename, 'r') as f: + self._cache = yaml.safe_load(f.read()) + # Save the cached scanner output to the same attribute + # it would be in if we had loaded it "live". This + # simplifies some of the logic in the other methods. + self._scanner_output = { + n['version']: n['files'] + for n in self._cache['notes'] + } + else: + self._scanner = scanner.Scanner(self._config) + self._scanner_output = self._scanner.get_notes_by_version() + + @property + def versions(self): + "A list of all of the versions found." + return list(self._scanner_output.keys()) + + def __getitem__(self, version): + "Return data about the files that should go into a given version." + return self._scanner_output[version] + + def parse_note_file(self, filename, sha): + """Return the data structure encoded in the note file. + + Emit warnings for content that does not look valid in some + way, but return it anyway for backwards-compatibility. + + """ + if self._cache: + content = self._cache['file-contents'][filename] + else: + body = self._scanner.get_file_at_commit(filename, sha) + content = yaml.safe_load(body) + + cleaned_content = {} + + for section_name, section_content in content.items(): + if section_name == self._config.prelude_section_name: + if not isinstance(section_content, six.string_types): + LOG.warning( + ('The %s section of %s ' + 'does not parse as a single string. ' + 'Is the YAML input escaped properly?') % + (self._config.prelude_section_name, filename), + ) + else: + if isinstance(section_content, six.string_types): + # A single string is OK, but wrap it with a list + # so the rest of the code can treat the data model + # consistently. + section_content = [section_content] + elif not isinstance(section_content, list): + LOG.warning( + ('The %s section of %s ' + 'does not parse as a string or list of strings. ' + 'Is the YAML input escaped properly?') % ( + section_name, filename), + ) + else: + for item in section_content: + if not isinstance(item, six.string_types): + LOG.warning( + ('The item %r in the %s section of %s ' + 'parses as a %s instead of a string. ' + 'Is the YAML input escaped properly?' + ) % (item, section_name, + filename, type(item)), + ) + cleaned_content[section_name] = section_content + + return cleaned_content diff -Nru python-reno-1.3.0/reno/main.py python-reno-2.5.0/reno/main.py --- python-reno-1.3.0/reno/main.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/main.py 2017-07-19 09:44:08.000000000 +0000 @@ -14,11 +14,54 @@ import logging import sys +from reno import cache +from reno import config from reno import create from reno import defaults +from reno import linter from reno import lister from reno import report +_query_args = [ + (('--version',), + dict(default=[], + action='append', + help='the version(s) to include, defaults to all')), + (('--branch',), + dict(default=config.Config.get_default('branch'), + help='the branch to scan, defaults to the current')), + (('--collapse-pre-releases',), + dict(action='store_true', + default=config.Config.get_default('collapse_pre_releases'), + help='combine pre-releases with their final release')), + (('--no-collapse-pre-releases',), + dict(action='store_false', + dest='collapse_pre_releases', + help='show pre-releases separately')), + (('--earliest-version',), + dict(default=None, + help='stop when this version is reached in the history')), + (('--ignore-cache',), + dict(default=False, + action='store_true', + help='if there is a cache file present, do not use it')), + (('--stop-at-branch-base',), + dict(action='store_true', + default=True, + dest='stop_at_branch_base', + help='stop scanning when the branch meets master')), + (('--no-stop-at-branch-base',), + dict(action='store_false', + dest='stop_at_branch_base', + help='do not stop scanning when the branch meets master')), +] + + +def _build_query_arg_group(parser): + group = parser.add_argument_group('query') + for args, kwds in _query_args: + group.add_argument(*args, **kwds) + def main(argv=sys.argv[1:]): parser = argparse.ArgumentParser() @@ -52,30 +95,33 @@ help='create a new note', ) do_new.add_argument( + '--edit', + action='store_true', + help='Edit note after its creation (require EDITOR env variable)', + ) + do_new.add_argument( 'slug', help='descriptive title of note (keep it short)', ) + do_new.add_argument( + 'reporoot', + default='.', + nargs='?', + help='root of the git repository', + ) do_new.set_defaults(func=create.create_cmd) do_list = subparsers.add_parser( 'list', help='list notes files based on query arguments', ) + _build_query_arg_group(do_list) do_list.add_argument( 'reporoot', + default='.', + nargs='?', help='root of the git repository', ) - do_list.add_argument( - '--version', - default=[], - action='append', - help='the version(s) to include, defaults to all', - ) - do_list.add_argument( - '--branch', - default=None, - help='the branch to scan, defaults to the current', - ) do_list.set_defaults(func=lister.list_cmd) do_report = subparsers.add_parser( @@ -84,6 +130,8 @@ ) do_report.add_argument( 'reporoot', + default='.', + nargs='?', help='root of the git repository', ) do_report.add_argument( @@ -92,23 +140,59 @@ help='output filename, defaults to stdout', ) do_report.add_argument( - '--branch', - default=None, - help='the branch to scan, defaults to the current', + '--no-show-source', + dest='show_source', + default=True, + action='store_false', + help='do not show the source for notes', ) do_report.add_argument( - '--version', - default=[], - action='append', - help='the version(s) to include, defaults to all', + '--title', + default='Release Notes', + help='set the main title of the generated report', ) + _build_query_arg_group(do_report) do_report.set_defaults(func=report.report_cmd) - args = parser.parse_args() + do_cache = subparsers.add_parser( + 'cache', + help='generate release notes cache', + ) + do_cache.add_argument( + 'reporoot', + default='.', + nargs='?', + help='root of the git repository', + ) + do_cache.add_argument( + '--output', '-o', + default=None, + help=('output filename, ' + 'defaults to the cache file within the notesdir, ' + 'use "-" for stdout'), + ) + _build_query_arg_group(do_cache) + do_cache.set_defaults(func=cache.cache_cmd) + + do_linter = subparsers.add_parser( + 'lint', + help='check some common mistakes', + ) + do_linter.add_argument( + 'reporoot', + default='.', + nargs='?', + help='root of the git repository', + ) + do_linter.set_defaults(func=linter.lint_cmd) + + args = parser.parse_args(argv) + conf = config.Config(args.reporoot, args.relnotesdir) + conf.override_from_parsed_args(args) logging.basicConfig( level=args.verbosity, format='%(message)s', ) - return args.func(args) + return args.func(args, conf) diff -Nru python-reno-1.3.0/reno/report.py python-reno-2.5.0/reno/report.py --- python-reno-1.3.0/reno/report.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/report.py 2017-07-19 09:44:08.000000000 +0000 @@ -13,24 +13,22 @@ from __future__ import print_function from reno import formatter -from reno import scanner -from reno import utils +from reno import loader -def report_cmd(args): +def report_cmd(args, conf): "Generates a release notes report" - reporoot = args.reporoot.rstrip('/') + '/' - notesdir = utils.get_notes_dir(args) - notes = scanner.get_notes_by_version(reporoot, notesdir, args.branch) + ldr = loader.Loader(conf) if args.version: versions = args.version else: - versions = notes.keys() + versions = ldr.versions text = formatter.format_report( - reporoot, - notes, + ldr, + conf, versions, - title='Release Notes', + title=args.title, + show_source=args.show_source, ) if args.output: with open(args.output, 'w') as f: diff -Nru python-reno-1.3.0/reno/scanner.py python-reno-2.5.0/reno/scanner.py --- python-reno-1.3.0/reno/scanner.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/scanner.py 2017-07-19 09:44:08.000000000 +0000 @@ -17,57 +17,28 @@ import logging import os.path import re -import subprocess import sys -from reno import utils +from dulwich import diff_tree +from dulwich import index as d_index +from dulwich import objects +from dulwich import porcelain +from dulwich import repo -_TAG_PAT = re.compile('tag: ([\d\.]+)') LOG = logging.getLogger(__name__) -def _get_current_version(reporoot, branch=None): - """Return the current version of the repository. - - If the repo appears to contain a python project, use setup.py to - get the version so pbr (if used) can do its thing. Otherwise, use - git describe. - - """ - cmd = ['git', 'describe', '--tags'] - if branch is not None: - cmd.append(branch) - try: - result = utils.check_output(cmd, cwd=reporoot).strip() - if '-' in result: - # Descriptions that come after a commit look like - # 2.0.0-1-abcde, and we want to remove the SHA value from - # the end since we only care about the version number - # itself, but we need to recognize that the change is - # unreleased so keep the -1 part. - result, dash, ignore = result.rpartition('-') - except subprocess.CalledProcessError: - # This probably means there are no tags. - result = '0.0.0' +def _parse_version(v): + parts = v.split('.') + ['0', '0', '0'] + result = [] + for p in parts[:3]: + try: + result.append(int(p)) + except ValueError: + result.append(p) return result -def get_file_at_commit(reporoot, filename, sha): - "Return the contents of the file if it exists at the commit, or None." - try: - return utils.check_output( - ['git', 'show', '%s:%s' % (sha, filename)], - cwd=reporoot, - ) - except subprocess.CalledProcessError: - return None - - -def _file_exists_at_commit(reporoot, filename, sha): - "Return true if the file exists at the given commit." - return bool(get_file_at_commit(reporoot, filename, sha)) - - def _get_unique_id(filename): base = os.path.basename(filename) root, ext = os.path.splitext(base) @@ -79,216 +50,1147 @@ return uniqueid -# The git log output from _get_tags_on_branch() looks like this sample -# from the openstack/nova repository for git 1.9.1: -# -# git log --simplify-by-decoration --pretty="%d" -# (HEAD, origin/master, origin/HEAD, gerrit/master, master) -# (apu/master) -# (tag: 13.0.0.0b1) -# (tag: 12.0.0.0rc3, tag: 12.0.0) -# -# And this for git 1.7.1 (RHEL): -# -# $ git log --simplify-by-decoration --pretty="%d" -# (HEAD, origin/master, origin/HEAD, master) -# (tag: 13.0.0.0b1) -# (tag: 12.0.0.0rc3, tag: 12.0.0) -# (tag: 12.0.0.0rc2) -# (tag: 2015.1.0rc3, tag: 2015.1.0) -# ... -# (tag: folsom-2) -# (tag: folsom-1) -# (essex-1) -# (diablo-2) -# (diablo-1) -# (2011.2) -# -# The difference in the tags with "tag:" and without appears to be -# caused by some being annotated and others not. -# -# So we might have multiple tags on a given commit, and we might have -# lines that have no tags or are completely blank, and we might have -# "tag:" or not. This pattern is used to find the tag entries on each -# line, ignoring tags that don't look like version numbers. -TAG_RE = re.compile('(?:[(]|tag: )([\d.ab]+)[,)]') - - -def _get_version_tags_on_branch(reporoot, branch): - """Return tags from the branch, in date order. - - Need to get the list of tags in the right order, because the topo - search breaks the date ordering. Use git to ask for the tags in - order, rather than trying to sort them, because many repositories - have "non-standard" tags or have renumbered projects (from - date-based to SemVer), for which sorting would require complex - logic. +def _note_file(name): + """Return bool indicating if the filename looks like a note file. + + This is used to filter the files in changes based on the notes + directory we were given. We cannot do this in the walker directly + because it means we end up skipping some of the tags if the + commits being tagged don't include any release note files. """ - tags = [] - tag_cmd = [ - 'git', 'log', - '--simplify-by-decoration', - '--pretty="%d"', - ] - if branch: - tag_cmd.append(branch) - LOG.debug('running %s' % ' '.join(tag_cmd)) - tag_results = utils.check_output(tag_cmd, cwd=reporoot) - LOG.debug(tag_results) - for line in tag_results.splitlines(): - LOG.debug('line %r' % line) - for match in TAG_RE.findall(line): - tags.append(match) - return tags + if not name: + return False + if fnmatch.fnmatch(name, '*.yaml'): + return True + else: + LOG.info('found and ignored extra file %s', name) + return False + + +def _changes_in_subdir(repo, walk_entry, subdir): + """Iterator producing changes of interest to reno. + + The default changes() method of a WalkEntry computes all of the + changes in the entire repo at that point. We only care about + changes in a subdirectory, so this reimplements + WalkeEntry.changes() with that filter in place. + + The alternative, passing paths to the TreeWalker, does not work + because we need all of the commits in sequence so we can tell when + the tag changes. We have to look at every commit to see if it + either has a tag, a note file, or both. + + NOTE(dhellmann): The TreeChange entries returned as a result of + the manipulation done by this function have the subdir prefix + stripped. + """ + commit = walk_entry.commit + store = repo.object_store -def get_notes_by_version(reporoot, notesdir, branch=None): - """Return an OrderedDict mapping versions to lists of notes files. + parents = walk_entry._get_parents(commit) - The versions are presented in reverse chronological order. + if not parents: + changes_func = diff_tree.tree_changes + parent_subtree = None + elif len(parents) == 1: + changes_func = diff_tree.tree_changes + parent_tree = repo[repo[parents[0]].tree] + parent_subtree = repo._get_subtree(parent_tree, subdir) + if parent_subtree: + parent_subtree = parent_subtree.sha().hexdigest().encode('ascii') + else: + changes_func = diff_tree.tree_changes_for_merge + parent_subtree = [ + repo._get_subtree(repo[repo[p].tree], subdir) + for p in parents + ] + parent_subtree = [ + p.sha().hexdigest().encode('ascii') + for p in parent_subtree + if p + ] + subdir_tree = repo._get_subtree(repo[commit.tree], subdir) + if subdir_tree: + commit_subtree = subdir_tree.sha().hexdigest().encode('ascii') + else: + commit_subtree = None + if parent_subtree == commit_subtree: + return [] + return changes_func(store, parent_subtree, commit_subtree) + + +class _ChangeAggregator(object): + """Collapse a series of changes based on uniqueness for file uids. + + The list of TreeChange instances describe changes between the old + and new repository trees. The change has a type, and new and old + paths and shas. + + Simple add, delete, and change operations are handled directly. + + There is a rename type, but detection of renamed files is + incomplete so we handle that ourselves based on the UID value + built into the filenames (under the assumption that if someone + changes that part of the filename they want it treated as a + different file for some reason). If we see both an add and a + delete for a given UID treat that as a rename. - Notes files are associated with the earliest version for which - they were available, regardless of whether they changed later. - """ + The SHA values returned are for the commit, rather than the blob + values in the TreeChange objects. - LOG.debug('scanning %s/%s (branch=%s)' % (reporoot, notesdir, branch)) + The path values in the change entries are encoded, so we return + the decoded values to make consuming them easier. - # Determine all of the tags known on the branch, in their date - # order. We scan the commit history in topological order to ensure - # we have the commits in the right version, so we might encounter - # the tags in a different order during that phase. - versions_by_date = _get_version_tags_on_branch(reporoot, branch) - LOG.debug('versions by date %r' % (versions_by_date,)) - versions = [] - earliest_seen = collections.OrderedDict() - - # Determine the current version, which might be an unreleased or - # dev version if there are unreleased commits at the head of the - # branch in question. Since the version may not already be known, - # make sure it is in the list of versions by date. And since it is - # the most recent version, go ahead and insert it at the front of - # the list. - current_version = _get_current_version(reporoot, branch) - LOG.debug('current repository version: %s' % current_version) - if current_version not in versions_by_date: - LOG.debug('adding %s to versions by date' % current_version) - versions_by_date.insert(0, current_version) - - # Remember the most current filename for each id, to allow for - # renames. - last_name_by_id = {} - - # FIXME(dhellmann): This might need to be more line-oriented for - # longer histories. - log_cmd = [ - 'git', 'log', - '--topo-order', # force traversal order rather than date order - '--pretty=%x00%H %d', # output contents in parsable format - '--name-only' # only include the names of the files in the patch - ] - if branch is not None: - log_cmd.append(branch) - LOG.debug('running %s' % ' '.join(log_cmd)) - history_results = utils.check_output(log_cmd, cwd=reporoot) - history = history_results.split('\x00') - current_version = current_version - for h in history: - h = h.strip() - if not h: - continue - # print(h) - - hlines = h.splitlines() - - # The first line of the block will include the SHA and may - # include tags, the other lines are filenames. - sha = hlines[0].split(' ')[0] - tags = _TAG_PAT.findall(hlines[0]) - # Filter the files based on the notes directory we were - # given. We cannot do this in the git log command directly - # because it means we end up skipping some of the tags if the - # commits being tagged don't include any release note - # files. Even if this list ends up empty, we continue doing - # the other processing so that we record all of the known - # versions. - filenames = [ - f - for f in hlines[2:] - if fnmatch.fnmatch(f, notesdir + '/*.yaml') - ] + """ - # If there are no tags in this block, assume the most recently - # seen version. - if not tags: - tags = [current_version] + _rename_op = set([diff_tree.CHANGE_ADD, diff_tree.CHANGE_DELETE]) + _modify_op = set([diff_tree.CHANGE_MODIFY]) + _delete_op = set([diff_tree.CHANGE_DELETE]) + _add_op = set([diff_tree.CHANGE_ADD]) + + def __init__(self): + # Track UIDs that had a duplication issue but have been + # deleted so we know not to throw an error for them. + self._deleted_bad_uids = set() + + def aggregate_changes(self, walk_entry, changes): + sha = walk_entry.commit.id + by_uid = collections.defaultdict(list) + for ec in changes: + if not isinstance(ec, list): + ec = [ec] + else: + ec = ec + for c in ec: + LOG.debug('change %r', c) + if c.type == diff_tree.CHANGE_ADD: + path = c.new.path.decode('utf-8') if c.new.path else None + if _note_file(path): + uid = _get_unique_id(path) + by_uid[uid].append((c.type, path, sha)) + else: + LOG.debug('ignoring') + elif c.type == diff_tree.CHANGE_DELETE: + path = c.old.path.decode('utf-8') if c.old.path else None + if _note_file(path): + uid = _get_unique_id(path) + by_uid[uid].append((c.type, path, sha)) + else: + LOG.debug('ignoring') + elif c.type == diff_tree.CHANGE_MODIFY: + path = c.new.path.decode('utf-8') if c.new.path else None + if _note_file(path): + uid = _get_unique_id(path) + by_uid[uid].append((c.type, path, sha)) + else: + LOG.debug('ignoring') + else: + raise ValueError('unhandled change type: {!r}'.format(c)) + + results = [] + for uid, changes in sorted(by_uid.items()): + if len(changes) == 1: + results.append((uid,) + changes[0]) + else: + types = set(c[0] for c in changes) + if types == self._rename_op: + # A rename, combine the data from the add and + # delete entries. + added = [ + c for c in changes if c[0] == diff_tree.CHANGE_ADD + ][0] + deled = [ + c for c in changes if c[0] == diff_tree.CHANGE_DELETE + ][0] + results.append( + (uid, diff_tree.CHANGE_RENAME, deled[1]) + added[1:] + ) + elif types == self._modify_op: + # Merge commit with modifications to the same files in + # different commits. + for c in changes: + results.append((uid, diff_tree.CHANGE_MODIFY, + c[1], sha)) + elif types == self._delete_op: + # There were multiple files in one commit using the + # same UID but different slugs. Treat them as + # different files and allow them to be deleted. + results.extend( + (uid, diff_tree.CHANGE_DELETE, c[1], sha) + for c in changes + ) + self._deleted_bad_uids.add(uid) + elif types == self._add_op: + # There were multiple files in one commit using the + # same UID but different slugs. Warn the user about + # this case and then ignore the files. We allow delete + # (see above) to ensure they can be cleaned up. + msg = ('%s: found several files in one commit (%s)' + ' with the same UID: %s' % + (uid, sha, [c[1] for c in changes])) + if uid not in self._deleted_bad_uids: + raise ValueError(msg) + else: + LOG.info(msg) + else: + raise ValueError('Unrecognized changes: {!r}'.format( + changes)) + return results + + +class _ChangeTracker(object): + + def __init__(self): + # Track the versions we have seen and the earliest version for + # which we have seen a given note's unique id. + self.versions = [] + self.earliest_seen = collections.OrderedDict() + # Remember the most current filename for each id, to allow for + # renames. + self.last_name_by_id = {} + # Remember uniqueids that have had files deleted. + self.uniqueids_deleted = set() + + def _common(self, uniqueid, sha, version): + if version not in self.versions: + self.versions.append(version) + # Update the "earliest" version where a UID appears + # every time we see it, because we are scanning the + # history in reverse order so "early" items come + # later. + if uniqueid in self.earliest_seen: + LOG.debug('%s: resetting earliest reference from %s to %s for %s', + uniqueid, self.earliest_seen[uniqueid], version, sha) else: - current_version = tags[0] - LOG.debug('%s has tags, updating current version to %s' % - (sha, current_version)) - - # Remember each version we have seen. - if current_version not in versions: - LOG.debug('%s is a new version' % current_version) - versions.append(current_version) - - LOG.debug('%s contains files %s' % (sha, filenames)) - - # Remember the files seen, using their UUID suffix as a unique id. - for f in filenames: - # Updated as older tags are found, handling edits to release - # notes. - LOG.debug('setting earliest reference to %s to %s' % - (f, tags[0])) - uniqueid = _get_unique_id(f) - earliest_seen[uniqueid] = tags[0] - if uniqueid in last_name_by_id: - # We already have a filename for this id from a - # new commit, so use that one in case the name has - # changed. - LOG.debug('%s was seen before' % f) - continue - if _file_exists_at_commit(reporoot, f, sha): - # Remember this filename as the most recent version of - # the unique id we have seen, in case the name - # changed from an older commit. - last_name_by_id[uniqueid] = (f, sha) - LOG.debug('remembering %s as filename for %s' % (f, uniqueid)) - - # Invert earliest_seen to make a list of notes files for each - # version. - files_and_tags = collections.OrderedDict() - for v in versions: - files_and_tags[v] = [] - # Produce a list of the actual files present in the repository. If - # a note is removed, this step should let us ignore it. - for uniqueid, version in earliest_seen.items(): + LOG.debug('%s: setting earliest reference to %s for %s', + uniqueid, version, sha) + self.earliest_seen[uniqueid] = version + + def add(self, filename, sha, version): + uniqueid = _get_unique_id(filename) + self._common(uniqueid, sha, version) + LOG.info('%s: adding %s from %s', + uniqueid, filename, version) + + # If we have recorded that a UID was deleted, that + # means that was the last change made to the file and + # we can ignore it. + if uniqueid in self.uniqueids_deleted: + LOG.debug( + '%s: has already been deleted, ignoring this change', + uniqueid, + ) + return + + # A note is being added in this commit. If we have + # not seen it before, it was added here and never + # changed. + if uniqueid not in self.last_name_by_id: + self.last_name_by_id[uniqueid] = (filename, sha) + LOG.info( + '%s: new %s in commit %s', + uniqueid, filename, sha, + ) + else: + LOG.debug( + '%s: add for file we have already seen', + uniqueid, + ) + + def rename(self, filename, sha, version): + uniqueid = _get_unique_id(filename) + self._common(uniqueid, sha, version) + + # If we have recorded that a UID was deleted, that + # means that was the last change made to the file and + # we can ignore it. + if uniqueid in self.uniqueids_deleted: + LOG.debug( + '%s: has already been deleted, ignoring this change', + uniqueid, + ) + return + + # The file is being renamed. We may have seen it + # before, if there were subsequent modifications, + # so only store the name information if it is not + # there already. + if uniqueid not in self.last_name_by_id: + self.last_name_by_id[uniqueid] = (filename, sha) + LOG.info( + '%s: update to %s in commit %s', + uniqueid, filename, sha, + ) + else: + LOG.debug( + '%s: renamed file already known with the new name', + uniqueid, + ) + + def modify(self, filename, sha, version): + uniqueid = _get_unique_id(filename) + self._common(uniqueid, sha, version) + + # If we have recorded that a UID was deleted, that + # means that was the last change made to the file and + # we can ignore it. + if uniqueid in self.uniqueids_deleted: + LOG.debug( + '%s: has already been deleted, ignoring this change', + uniqueid, + ) + return + + # An existing file is being modified. We may have + # seen it before, if there were subsequent + # modifications, so only store the name + # information if it is not there already. + if uniqueid not in self.last_name_by_id: + self.last_name_by_id[uniqueid] = (filename, sha) + LOG.info( + '%s: update to %s in commit %s', + uniqueid, filename, sha, + ) + else: + LOG.debug( + '%s: modified file already known', + uniqueid, + ) + + def delete(self, filename, sha, version): + uniqueid = _get_unique_id(filename) + self._common(uniqueid, sha, version) + # This file is being deleted without a rename. If + # we have already seen the UID before, that means + # that after the file was deleted another file + # with the same UID was added back. In that case + # we do not want to treat it as deleted. + # + # Never store deleted files in last_name_by_id so + # we can safely use all of those entries to build + # the history data. + if uniqueid not in self.last_name_by_id: + self.uniqueids_deleted.add(uniqueid) + LOG.info( + '%s: note deleted in %s', + uniqueid, sha, + ) + else: + LOG.debug( + '%s: delete for file re-added after the delete', + uniqueid, + ) + + +class RenoRepo(repo.Repo): + + # Populated by _load_tags(). + _all_tags = None + _shas_to_tags = None + + def _get_commit_from_tag(self, tag, tag_sha): + """Return the commit referenced by the tag and when it was tagged.""" + tag_obj = self[tag_sha] + + if isinstance(tag_obj, objects.Tag): + # A signed tag has its own SHA, but the tag refers to + # the commit and that's the SHA we'll see when we scan + # commits on a branch. + git_obj = tag_obj + while True: + # Tags can point to other tags, in such cases follow the chain + # of tags until there are no more. + child_obj = self[git_obj.object[1]] + if isinstance(child_obj, objects.Tag): + git_obj = child_obj + else: + break + + tagged_sha = git_obj.object[1] + date = tag_obj.tag_time + elif isinstance(tag_obj, objects.Commit): + # Unsigned tags refer directly to commits. This seems + # to especially happen when the tag definition moves + # to the packed-refs list instead of being represented + # by its own file. + tagged_sha = tag_obj.id + date = tag_obj.commit_time + else: + raise ValueError( + ('Unrecognized tag object {!r} with ' + 'tag {} and SHA {!r}: {}').format( + tag_obj, tag, tag_sha, type(tag_obj)) + ) + return tagged_sha, date + + def _load_tags(self): + self._all_tags = { + k.partition(b'/tags/')[-1].decode('utf-8'): v + for k, v in self.get_refs().items() + if k.startswith(b'refs/tags/') + } + self._shas_to_tags = {} + for tag, tag_sha in self._all_tags.items(): + tagged_sha, date = self._get_commit_from_tag(tag, tag_sha) + self._shas_to_tags.setdefault(tagged_sha, []).append((tag, date)) + + def get_tags_on_commit(self, sha): + "Return the tag(s) on a commit, in application order." + if self._all_tags is None: + self._load_tags() + tags_and_dates = self._shas_to_tags.get(sha, []) + tags_and_dates.sort(key=lambda x: x[1]) + return [t[0] for t in tags_and_dates] + + def _get_subtree(self, tree, path): + "Given a tree SHA and a path, return the SHA of the subtree." + try: + mode, tree_sha = tree.lookup_path(self.get_object, + path.encode('utf-8')) + except KeyError: + # Some part of the path wasn't found, so the subtree is + # not present. Return the sentinel value. + return None + else: + tree = self[tree_sha] + return tree + + def get_file_at_commit(self, filename, sha): + """Return the contents of the file. + + If sha is None, return the working copy of the file. If the + file cannot be read from the working dir, return None. + + If the sha is not None and the file exists at the commit, + return the data from the stored blob. If the file does not + exist at the commit, return None. + + """ + if sha is None: + # Get the copy from the working directory. + try: + with open(os.path.join(self.path, filename), 'r') as f: + return f.read() + except IOError: + return None + # Get the tree associated with the commit identified by the + # input SHA, then look through the items in the tree to find + # the one with the path matching the filename. Take the + # associated SHA from the tree and get the file contents from + # the repository. + if hasattr(sha, 'encode'): + sha = sha.encode('ascii') + commit = self[sha] + tree = self[commit.tree] try: - base, sha = last_name_by_id[uniqueid] - files_and_tags[version].append((base, sha)) + mode, blob_sha = tree.lookup_path(self.get_object, + filename.encode('utf-8')) except KeyError: - # Unable to find the file again, skip it to avoid breaking - # the build. - msg = ('[reno] unable to find file associated ' - 'with unique id %r, skipping') % uniqueid - LOG.debug(msg) - print(msg, file=sys.stderr) - - # Only return the parts of files_and_tags that actually have - # filenames associated with the versions. - trimmed = collections.OrderedDict() - for ov in versions_by_date: - if not files_and_tags.get(ov): - continue - # Sort the notes associated with the version so they are in a - # deterministic order, to avoid having the same data result in - # different output depending on random factors. Earlier - # versions of the scanner assumed the notes were recorded in - # chronological order based on the commit date, but with the - # change to use topological sorting that is no longer - # necessarily true. We want the notes to always show up in the - # same order, but it doesn't really matter what order that is, - # so just sort based on the unique id. - trimmed[ov] = sorted(files_and_tags[ov]) + # Some part of the filename wasn't found, so the file is + # not present. Return the sentinel value. + return None + else: + blob = self[blob_sha] + return blob.data + + +class Scanner(object): + + def __init__(self, conf): + self.conf = conf + self.reporoot = self.conf.reporoot + self._repo = RenoRepo(self.reporoot) + self.release_tag_re = re.compile( + self.conf.release_tag_re, + flags=re.VERBOSE | re.UNICODE, + ) + self.pre_release_tag_re = re.compile( + self.conf.pre_release_tag_re, + flags=re.VERBOSE | re.UNICODE, + ) + self.branch_name_re = re.compile( + self.conf.branch_name_re, + flags=re.VERBOSE | re.UNICODE, + ) + self._ignore_uids = set( + _get_unique_id(fn) + for fn in self.conf.ignore_notes + ) + + def _get_ref(self, name): + if name: + candidates = [ + 'refs/heads/' + name, + 'refs/remotes/' + name, + 'refs/tags/' + name, + # If a stable branch was removed, look for its EOL tag. + 'refs/tags/' + (name.rpartition('/')[-1] + '-eol'), + # If someone is using the "short" name for a branch + # without a local tracking branch, look to see if the + # name exists on the 'origin' remote. + 'refs/remotes/origin/' + name, + ] + for ref in candidates: + key = ref.encode('utf-8') + if key in self._repo.refs: + sha = self._repo.refs[key] + o = self._repo[sha] + if isinstance(o, objects.Tag): + # Branches point directly to commits, but + # signed tags point to the signature and we + # need to dereference it to get to the commit. + sha = o.object[1] + return sha + # If we end up here we didn't find any of the candidates. + raise ValueError('Unknown reference {!r}'.format(name)) + return self._repo.refs[b'HEAD'] + + def _get_walker_for_branch(self, branch): + branch_head = self._get_ref(branch) + return self._repo.get_walker(branch_head) + + def _get_valid_tags_on_commit(self, sha): + return [tag for tag in self._repo.get_tags_on_commit(sha) + if self.release_tag_re.match(tag)] + + def _get_tags_on_branch(self, branch): + "Return a list of tag names on the given branch." + results = [] + for c in self._get_walker_for_branch(branch): + # shas_to_tags has encoded versions of the shas + # but the commit object gives us a decoded version + sha = c.commit.sha().hexdigest().encode('ascii') + tags = self._get_valid_tags_on_commit(sha) + results.extend(tags) + return results + + def _get_current_version(self, branch=None): + "Return the current version of the repository, like git describe." + # This is similar to _get_tags_on_branch() except that it + # counts up to where the tag appears and it returns when it + # finds the first tagged commit (there is no need to scan the + # rest of the branch). + commit = self._repo[self._get_ref(branch)] + count = 0 + while commit: + # shas_to_tags has encoded versions of the shas + # but the commit object gives us a decoded version + sha = commit.sha().hexdigest().encode('ascii') + tags = self._get_valid_tags_on_commit(sha) + if tags: + if count: + val = '{}-{}'.format(tags[-1], count) + else: + val = tags[-1] + return val + if commit.parents: + # Only traverse the first parent of each node. + commit = self._repo[commit.parents[0]] + count += 1 + else: + commit = None + return '0.0.0' + + def _strip_pre_release(self, tag): + """Return tag with pre-release identifier removed if present.""" + pre_release_match = self.pre_release_tag_re.search(tag) + if pre_release_match: + try: + start = pre_release_match.start('pre_release') + end = pre_release_match.end('pre_release') + except IndexError: + raise ValueError( + ("The pre-release tag regular expression, {!r}, is missing" + " a group named 'pre_release'.").format( + self.pre_release_tag_re.pattern + ) + ) + else: + stripped_tag = tag[:start] + tag[end:] + else: + stripped_tag = tag + + return stripped_tag - return trimmed + def _get_branch_base(self, branch): + "Return the tag at base of the branch." + # Based on + # http://stackoverflow.com/questions/1527234/finding-a-branch-point-with-git + # git rev-list $(git rev-list --first-parent \ + # ^origin/stable/newton master | tail -n1)^^! + # + # Build the set of all commits that appear on the master + # branch, then scan the commits that appear on the specified + # branch until we find something that is on both. + master_commits = set( + c.commit.sha().hexdigest() + for c in self._get_walker_for_branch('master') + ) + for c in self._get_walker_for_branch(branch): + if c.commit.sha().hexdigest() in master_commits: + # We got to this commit via the branch, but it is also + # on master, so this is the base. + tags = self._get_valid_tags_on_commit( + c.commit.sha().hexdigest().encode('ascii')) + if tags: + return tags[-1] + else: + # Naughty, naughty, branching without tagging. + LOG.info( + ('There is no tag on commit %s at the base of %s. ' + 'Branch scan short-cutting is disabled.'), + c.commit.sha().hexdigest(), branch) + return None + return None + + def _topo_traversal(self, branch): + """Generator that yields the branch entries in topological order. + + The topo ordering in dulwich does not match the git command line + output, so we have our own that follows the branch being merged + into the mainline before following the mainline. This ensures that + tags on the mainline appear in the right place relative to the + merge points, regardless of the commit date on the entry. + + # * d1239b6 (HEAD -> master) Merge branch 'new-branch' + # |\ + # | * 9478612 (new-branch) one commit on branch + # * | 303e21d second commit on master + # * | 0ba5186 first commit on master + # |/ + # * a7f573d original commit on master + + """ + head = self._get_ref(branch) + + # Map SHA values to Entry objects, because we will be traversing + # commits not entries. + all = {} + + children = {} + + # Populate all and children structures by traversing the + # entire graph once. It doesn't matter what order we do this + # the first time, since we're just recording the relationships + # of the nodes. + for e in self._repo.get_walker(head): + all[e.commit.id] = e + for p in e.commit.parents: + children.setdefault(p, set()).add(e.commit.id) + + # Track what we have already emitted. + emitted = set() + + # Use a deque as a stack with the nodes left to process. This + # lets us avoid recursion, since we have no idea how deep some + # branches might be. + todo = collections.deque() + todo.appendleft(head) + + ignore_null_merges = self.conf.ignore_null_merges + if ignore_null_merges: + LOG.debug('ignoring null-merge commits') + + while todo: + sha = todo.popleft() + entry = all[sha] + null_merge = False + + # OpenStack used to use null-merges to bring final release + # tags from stable branches back into the master + # branch. This confuses the regular traversal because it + # makes that stable branch appear to be part of master + # and/or the later stable branch. When we hit one of those + # tags, skip it and take the first parent. + if ignore_null_merges and len(entry.commit.parents) > 1: + # Look for tags on the 2nd and later parents. The + # first parent is part of the branch we were + # originally trying to traverse, and any tags on it + # need to be kept. + for p in entry.commit.parents[1:]: + t = self._get_valid_tags_on_commit(p) + # If we have a tag being merged in, we need to + # include a check to verify that this is actually + # a null-merge (there are no changes). + if t and not entry.changes(): + LOG.debug( + 'treating %s as a null-merge because ' + 'parent %s has tag(s) %s', + sha, p, t, + ) + null_merge = True + break + if null_merge: + # Make it look like the parent entries that we're + # going to skip have been emitted so the + # bookkeeping for children works properly and we + # can continue past the merge. + emitted.update(set(entry.commit.parents[1:])) + # Make it look like the current entry was emitted + # so the bookkeeping for children works properly + # and we can continue past the merge. + emitted.add(sha) + # Now set up the first parent so it is processed + # later, as long as we haven't already processed + # it. + first_parent = entry.commit.parents[0] + if (first_parent not in todo and + first_parent not in emitted): + todo.appendleft(first_parent) + continue + + # If a node has multiple children, it is the start point + # for a branch that was merged back into the rest of the + # tree. We will have already processed the merge commit + # and are traversing either the branch that was merged in + # or the base into which it was merged. We want to stop + # traversing the branch that was merged in at the point + # where the branch was created, because we are trying to + # linearize the history. At that point, we go back to the + # merge node and take the other parent node, which should + # lead us back to the origin of the branch through the + # mainline. + unprocessed_children = [ + c + for c in children.get(sha, set()) + if c not in emitted + ] + + if not unprocessed_children: + # All children have been processed. Remember that we have + # processed this node and then emit the entry. + emitted.add(sha) + yield entry + + # Now put the parents on the stack from left to right + # so they are processed right to left. If the node is + # already on the stack, leave it to be processed in + # the original order where it was added. + # + # NOTE(dhellmann): It's not clear if this is the right + # solution, or if we should re-stack and then ignore + # duplicate emissions at the top of this + # loop. Checking if the item is already on the todo + # stack isn't very expensive, since we don't expect it + # to grow very large, but it's not clear the output + # will be produced in the right order. + for p in entry.commit.parents: + if p not in todo and p not in emitted: + todo.appendleft(p) + + else: + # Has unprocessed children. Do not emit, and do not + # restack, since when we get to the other child they will + # stack it. + pass + + def get_file_at_commit(self, filename, sha): + "Return the contents of the file if it exists at the commit, or None." + return self._repo.get_file_at_commit(filename, sha) + + def _file_exists_at_commit(self, filename, sha): + "Return true if the file exists at the given commit." + return bool(self.get_file_at_commit(filename, sha)) + + def _get_series_branches(self): + "Get branches matching the branch_name_re config option." + refs = self._repo.get_refs() + LOG.debug('refs %s', list(refs.keys())) + branch_names = set() + for r in refs.keys(): + name = None + r = r.decode('utf-8') + if r.startswith('refs/remotes/origin/'): + name = r[20:] + elif r.startswith('refs/heads/'): + name = r[11:] + if name and self.branch_name_re.search(name): + branch_names.add(name) + return list(sorted(branch_names)) + + def _get_earlier_branch(self, branch): + "Return the name of the branch created before the given branch." + # FIXME(dhellmann): Assumes branches come in order based on + # name. That may not be true for projects that branch based on + # version numbers instead of names. + if branch.startswith('origin/'): + branch = branch[7:] + LOG.debug('looking for the branch before %s', branch) + branch_names = self._get_series_branches() + if branch not in branch_names: + LOG.debug('Could not find branch %r among %s', + branch, branch_names) + return None + LOG.debug('found branches %s', branch_names) + current = branch_names.index(branch) + if current == 0: + # This is the first branch. + LOG.debug('%s appears to be the first branch', branch) + return None + previous = branch_names[current - 1] + LOG.debug('found earlier branch %s', previous) + return previous + + def _find_scan_stop_point(self, earliest_version, versions_by_date, + collapse_pre_releases, branch): + """Return the version to use to stop the scan. + + Use the list of versions_by_date to get the tag with a + different version created *before* the branch to ensure that + we include notes that go with that version that *is* in the + branch. + + :param earliest_version: Version string of the earliest + version to be included in the output. + :param versions_by_date: List of version strings in reverse + chronological order. + :param collapse_pre_releases: Boolean indicating whether we are + collapsing pre-releases or not. If false, the next tag + is used, regardless of its version. + :param branch: The name of the branch we are scanning. + + """ + if not earliest_version: + return None + earliest_parts = _parse_version(earliest_version) + try: + idx = versions_by_date.index(earliest_version) + 1 + except ValueError: + # The version we were given is not present, use a full + # scan. + return None + # We need to look for the previous branch's root. + if branch and branch != 'master': + previous_branch = self._get_earlier_branch(branch) + if not previous_branch: + # This was the first branch, so scan the whole + # history. + return None + previous_base = self._get_branch_base(previous_branch) + return previous_base + is_pre_release = bool(self.pre_release_tag_re.search(earliest_version)) + if is_pre_release and not collapse_pre_releases: + # We just take the next tag. + return versions_by_date[idx] + # We need to look for a different version. + for candidate in versions_by_date[idx:]: + parts = _parse_version(candidate) + if parts != earliest_parts: + # The candidate is a different version, use it. + return candidate + return None + + def get_notes_by_version(self): + """Return an OrderedDict mapping versions to lists of notes files. + + The versions are presented in reverse chronological order. + + Notes files are associated with the earliest version for which + they were available, regardless of whether they changed later. + + :param reporoot: Path to the root of the git repository. + :type reporoot: str + """ + + reporoot = self.reporoot + notesdir = self.conf.notespath + branch = self.conf.branch + earliest_version = self.conf.earliest_version + collapse_pre_releases = self.conf.collapse_pre_releases + stop_at_branch_base = self.conf.stop_at_branch_base + + LOG.info('scanning %s/%s (branch=%s earliest_version=%s)', + reporoot.rstrip('/'), notesdir.lstrip('/'), + branch or '*current*', earliest_version) + + # Determine the current version, which might be an unreleased or + # dev version if there are unreleased commits at the head of the + # branch in question. + current_version = self._get_current_version(branch) + LOG.debug('current repository version: %s' % current_version) + + # Determine all of the tags known on the branch, in their date + # order. We scan the commit history in topological order to ensure + # we have the commits in the right version, so we might encounter + # the tags in a different order during that phase. + versions_by_date = self._get_tags_on_branch(branch) + LOG.debug('versions by date %r' % (versions_by_date,)) + if earliest_version and earliest_version not in versions_by_date: + raise ValueError( + 'earliest-version set to unknown revision {!r}'.format( + earliest_version)) + + # If the user has told us where to stop, use that as the + # default. + scan_stop_tag = self._find_scan_stop_point( + earliest_version, versions_by_date, + collapse_pre_releases, branch) + + # If the user has not told us where to stop, try to work it + # out for ourselves. + if not branch and not earliest_version and stop_at_branch_base: + # On the current branch, stop at the point where the most + # recent branch was created, if we can find one. + LOG.debug('working on current branch without earliest_version') + branches = self._get_series_branches() + if branches: + for earlier_branch in reversed(branches): + LOG.debug('checking if current branch is later than %s', + earlier_branch) + scan_stop_tag = self._get_branch_base(earlier_branch) + if scan_stop_tag in versions_by_date: + LOG.info( + 'looking at %s at base of %s to ' + 'stop scanning the current branch', + scan_stop_tag, earlier_branch + ) + break + else: + LOG.info('unable to find the previous branch base') + scan_stop_tag = None + if scan_stop_tag: + # If there is a tag on this branch after the point + # where the earlier branch was created, then use that + # tag as the earliest version to show in the current + # "series". If there is no such tag, then go all the + # way to the base of that earlier branch. + try: + idx = versions_by_date.index(scan_stop_tag) + 1 + earliest_version = versions_by_date[idx] + except ValueError: + # The scan_stop_tag is not in versions_by_date. + earliest_version = None + except IndexError: + # The idx is not in versions_by_date. + earliest_version = scan_stop_tag + elif branch and stop_at_branch_base and not earliest_version: + # If branch is set and is not "master", + # then we want to stop at the version before the tag at the + # base of the branch, which involves a bit of searching. + LOG.debug('determining earliest_version from branch') + branch_base = self._get_branch_base(branch) + scan_stop_tag = self._find_scan_stop_point( + branch_base, versions_by_date, + collapse_pre_releases, branch) + if not scan_stop_tag: + earliest_version = branch_base + else: + idx = versions_by_date.index(scan_stop_tag) + earliest_version = versions_by_date[idx - 1] + if earliest_version and collapse_pre_releases: + if self.pre_release_tag_re.search(earliest_version): + # The earliest version won't actually be the pre-release + # that might have been tagged when the branch was created, + # but the final version. Strip the pre-release portion of + # the version number. + earliest_version = self._strip_pre_release( + earliest_version + ) + if earliest_version: + LOG.info('earliest version to include is %s', earliest_version) + else: + LOG.info('including entire branch history') + if scan_stop_tag: + LOG.info('stopping scan at %s', scan_stop_tag) + + # Since the version may not already be known, make sure it is + # in the list of versions by date. And since it is the most + # recent version, go ahead and insert it at the front of the + # list. + if current_version not in versions_by_date: + versions_by_date.insert(0, current_version) + versions_by_date.insert(0, '*working-copy*') + + # Track the versions we have seen and the earliest version for + # which we have seen a given note's unique id. + tracker = _ChangeTracker() + + # Process the local index, if we are scanning the current + # branch. + if not branch: + prefix = notesdir.rstrip('/') + '/' + index = self._repo.open_index() + + # Pretend anything known to the repo and changed but not + # staged is part of the fake version '*working-copy*'. + LOG.debug('scanning unstaged changes') + for fname in d_index.get_unstaged_changes(index, self.reporoot): + fname = fname.decode('utf-8') + LOG.debug('found unstaged file %s', fname) + if fname.startswith(prefix) and _note_file(fname): + fullpath = os.path.join(self.reporoot, fname) + if os.path.exists(fullpath): + LOG.debug('found file %s', fullpath) + tracker.add(fname, None, '*working-copy*') + else: + LOG.debug('deleted file %s', fullpath) + tracker.delete(fname, None, '*working-copy*') + + # Pretend anything in the index is part of the fake + # version "*working-copy*". + LOG.debug('scanning staged schanges') + changes = porcelain.get_tree_changes(self._repo) + for fname in changes['add']: + fname = fname.decode('utf-8') + if fname.startswith(prefix) and _note_file(fname): + tracker.add(fname, None, '*working-copy*') + for fname in changes['modify']: + fname = fname.decode('utf-8') + if fname.startswith(prefix) and _note_file(fname): + tracker.modify(fname, None, '*working-copy*') + for fname in changes['delete']: + fname = fname.decode('utf-8') + if fname.startswith(prefix) and _note_file(fname): + tracker.delete(fname, None, '*working-copy*') + + aggregator = _ChangeAggregator() + + # Process the git commit history. + for counter, entry in enumerate(self._topo_traversal(branch), 1): + + sha = entry.commit.id + tags_on_commit = self._get_valid_tags_on_commit(sha) + + LOG.debug('%06d %s %s', counter, sha, tags_on_commit) + + # If there are no tags in this block, assume the most recently + # seen version. + tags = tags_on_commit + if not tags: + tags = [current_version] + else: + current_version = tags_on_commit[-1] + LOG.info('%06d %s updating current version to %s', + counter, sha, current_version) + + # Look for changes to notes files in this commit. The + # change has only the basename of the path file, so we + # need to prefix that with the notesdir before giving it + # to the tracker. + changes = _changes_in_subdir(self._repo, entry, notesdir) + for change in aggregator.aggregate_changes(entry, changes): + uniqueid = change[0] + + if uniqueid in self._ignore_uids: + LOG.info('ignoring %s based on configuration setting', + uniqueid) + continue + + c_type = change[1] + + if c_type == diff_tree.CHANGE_ADD: + path, blob_sha = change[-2:] + fullpath = os.path.join(notesdir, path) + tracker.add(fullpath, sha, current_version) + + elif c_type == diff_tree.CHANGE_DELETE: + path, blob_sha = change[-2:] + fullpath = os.path.join(notesdir, path) + tracker.delete(fullpath, sha, current_version) + + elif c_type == diff_tree.CHANGE_RENAME: + path, blob_sha = change[-2:] + fullpath = os.path.join(notesdir, path) + tracker.rename(fullpath, sha, current_version) + + elif c_type == diff_tree.CHANGE_MODIFY: + path, blob_sha = change[-2:] + fullpath = os.path.join(notesdir, path) + tracker.modify(fullpath, sha, current_version) + + else: + raise ValueError( + 'unknown change instructions {!r}'.format(change) + ) + + if scan_stop_tag and scan_stop_tag in tags: + LOG.info( + ('reached end of branch after %d commits at %s ' + 'with tags %s'), + counter, sha, tags) + break + + # Invert earliest_seen to make a list of notes files for each + # version. + files_and_tags = collections.OrderedDict() + for v in tracker.versions: + files_and_tags[v] = [] + # Produce a list of the actual files present in the repository. If + # a note is removed, this step should let us ignore it. + for uniqueid, version in tracker.earliest_seen.items(): + try: + base, sha = tracker.last_name_by_id[uniqueid] + LOG.debug('%s: sorting %s into version %s', + uniqueid, base, version) + files_and_tags[version].append((base, sha)) + except KeyError: + # Unable to find the file again, skip it to avoid breaking + # the build. + msg = ('unable to find release notes file associated ' + 'with unique id %r, skipping') % uniqueid + LOG.debug(msg) + print(msg, file=sys.stderr) + + # Combine pre-releases into the final release, if we are told to + # and the final release exists. + if collapse_pre_releases: + LOG.debug('collapsing pre-release versions into final releases') + collapsing = files_and_tags + files_and_tags = collections.OrderedDict() + for ov in versions_by_date: + if ov not in collapsing: + # We don't need to collapse this one because there are + # no notes attached to it. + continue + pre_release_match = self.pre_release_tag_re.search(ov) + LOG.debug('checking %r', ov) + if pre_release_match: + # Remove the trailing pre-release part of the version + # from the string. + canonical_ver = self._strip_pre_release(ov) + if canonical_ver not in versions_by_date: + # This canonical version was never tagged, so we + # do not want to collapse the pre-releases. Reset + # to the original version. + canonical_ver = ov + else: + LOG.debug('combining into %r', canonical_ver) + else: + canonical_ver = ov + if canonical_ver not in files_and_tags: + files_and_tags[canonical_ver] = [] + files_and_tags[canonical_ver].extend(collapsing[ov]) + + LOG.debug('files_and_tags %s', + {k: len(v) for k, v in files_and_tags.items()}) + # Only return the parts of files_and_tags that actually have + # filenames associated with the versions. + LOG.debug('trimming') + trimmed = collections.OrderedDict() + for ov in versions_by_date: + if not files_and_tags.get(ov): + continue + LOG.debug('keeping %s', ov) + # Sort the notes associated with the version so they are in a + # deterministic order, to avoid having the same data result in + # different output depending on random factors. Earlier + # versions of the scanner assumed the notes were recorded in + # chronological order based on the commit date, but with the + # change to use topological sorting that is no longer + # necessarily true. We want the notes to always show up in the + # same order, but it doesn't really matter what order that is, + # so just sort based on the unique id. + trimmed[ov] = sorted(files_and_tags[ov]) + # If we have been told to stop at a version, we can do that + # now. + if earliest_version and ov == earliest_version: + LOG.debug('stopping trimming at %s', earliest_version) + break + + LOG.debug( + 'found %d versions and %d files', + len(trimmed.keys()), sum(len(ov) for ov in trimmed.values()), + ) + return trimmed diff -Nru python-reno-1.3.0/reno/sphinxext.py python-reno-2.5.0/reno/sphinxext.py --- python-reno-1.3.0/reno/sphinxext.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/sphinxext.py 2017-07-19 09:44:08.000000000 +0000 @@ -10,18 +10,21 @@ # License for the specific language governing permissions and limitations # under the License. +import logging import os.path -from reno import defaults -from reno import formatter -from reno import scanner - from docutils import nodes from docutils.parsers import rst from docutils.parsers.rst import directives -from docutils.statemachine import ViewList +from docutils import statemachine from sphinx.util.nodes import nested_parse_with_titles +from dulwich import repo +from reno import config +from reno import defaults +from reno import formatter +from reno import loader + class ReleaseNotesDirective(rst.Directive): @@ -33,6 +36,10 @@ 'relnotessubdir': directives.unchanged, 'notesdir': directives.unchanged, 'version': directives.unchanged, + 'collapse-pre-releases': directives.flag, + 'earliest-version': directives.unchanged, + 'stop-at-branch-base': directives.flag, + 'ignore-notes': directives.unchanged, } def run(self): @@ -46,31 +53,58 @@ branch = self.options.get('branch') reporoot_opt = self.options.get('reporoot', '.') reporoot = os.path.abspath(reporoot_opt) + # When building on RTD.org the root directory may not be + # the current directory, so look for it. + reporoot = repo.Repo.discover(reporoot).path relnotessubdir = self.options.get('relnotessubdir', defaults.RELEASE_NOTES_SUBDIR) - notessubdir = self.options.get('notesdir', defaults.NOTES_SUBDIR) + ignore_notes = [ + name.strip() + for name in self.options.get('ignore-notes', '').split(',') + ] + conf = config.Config(reporoot, relnotessubdir) + opt_overrides = {} + if 'notesdir' in self.options: + opt_overrides['notesdir'] = self.options.get('notesdir') version_opt = self.options.get('version') + # FIXME(dhellmann): Force these flags True for now and figure + # out how Sphinx passes a "false" flag later. + # 'collapse-pre-releases' in self.options + opt_overrides['collapse_pre_releases'] = True + # Only stop at the branch base if we have not been told + # explicitly which versions to include. + opt_overrides['stop_at_branch_base'] = (version_opt is None) + if 'earliest-version' in self.options: + opt_overrides['earliest_version'] = self.options.get( + 'earliest-version') + if branch: + opt_overrides['branch'] = branch + if ignore_notes: + opt_overrides['ignore_notes'] = ignore_notes + conf.override(**opt_overrides) - notesdir = os.path.join(relnotessubdir, notessubdir) + notesdir = os.path.join(relnotessubdir, conf.notesdir) info('scanning %s for %s release notes' % - (os.path.join(reporoot, notesdir), branch or 'current branch')) + (os.path.join(conf.reporoot, notesdir), + branch or 'current branch')) - notes = scanner.get_notes_by_version(reporoot, notesdir, branch) + ldr = loader.Loader(conf) if version_opt is not None: versions = [ v.strip() for v in version_opt.split(',') ] else: - versions = notes.keys() + versions = ldr.versions + info('got versions %s' % (versions,)) text = formatter.format_report( - reporoot, - notes, + ldr, + conf, versions, title=title, ) - source_name = '<' + __name__ + '>' - result = ViewList() + source_name = '<%s %s>' % (__name__, branch or 'current branch') + result = statemachine.ViewList() for line in text.splitlines(): result.append(line, source_name) @@ -82,3 +116,8 @@ def setup(app): app.add_directive('release-notes', ReleaseNotesDirective) + + logging.basicConfig( + level=logging.INFO, + format='[%(name)s] %(message)s', + ) diff -Nru python-reno-1.3.0/reno/tests/base.py python-reno-2.5.0/reno/tests/base.py --- python-reno-1.3.0/reno/tests/base.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/tests/base.py 2017-07-19 09:44:08.000000000 +0000 @@ -15,9 +15,19 @@ # License for the specific language governing permissions and limitations # under the License. -from oslotest import base +import fixtures +import testtools -class TestCase(base.BaseTestCase): +class TestCase(testtools.TestCase): """Test case base class for all unit tests.""" + + def setUp(self): + super(TestCase, self).setUp() + self._stdout_fixture = fixtures.StringStream('stdout') + self.stdout = self.useFixture(self._stdout_fixture).stream + self.useFixture(fixtures.MonkeyPatch('sys.stdout', self.stdout)) + self._stderr_fixture = fixtures.StringStream('stderr') + self.stderr = self.useFixture(self._stderr_fixture).stream + self.useFixture(fixtures.MonkeyPatch('sys.stderr', self.stderr)) diff -Nru python-reno-1.3.0/reno/tests/test_cache.py python-reno-2.5.0/reno/tests/test_cache.py --- python-reno-1.3.0/reno/tests/test_cache.py 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/reno/tests/test_cache.py 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,86 @@ +# -*- 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 fixtures +import textwrap + +import mock + +from reno import cache +from reno import config +from reno.tests import base + + +class TestCache(base.TestCase): + + scanner_output = { + '0.0.0': [('note1', 'shaA')], + '1.0.0': [('note2', 'shaB'), ('note3', 'shaC')], + } + + note_bodies = { + 'note1': textwrap.dedent(""" + prelude: > + This is the prelude. + """), + 'note2': textwrap.dedent(""" + issues: + - This is the first issue. + - This is the second issue. + """), + 'note3': textwrap.dedent(""" + features: + - We added a feature! + """) + } + + def _get_note_body(self, filename, sha): + return self.note_bodies.get(filename, '') + + def setUp(self): + super(TestCache, self).setUp() + self.useFixture( + fixtures.MockPatch('reno.scanner.Scanner.get_file_at_commit', + new=self._get_note_body) + ) + self.c = config.Config('.') + + def test_build_cache_db(self): + with mock.patch('reno.scanner.Scanner.get_notes_by_version') as gnbv: + gnbv.return_value = self.scanner_output + db = cache.build_cache_db( + self.c, + versions_to_include=[], + ) + expected = { + 'notes': [ + {'version': k, 'files': v} + for k, v in self.scanner_output.items() + ], + 'file-contents': { + 'note1': { + 'prelude': 'This is the prelude.\n', + }, + 'note2': { + 'issues': [ + 'This is the first issue.', + 'This is the second issue.', + ], + }, + 'note3': { + 'features': ['We added a feature!'], + }, + }, + } + self.assertEqual(expected, db) diff -Nru python-reno-1.3.0/reno/tests/test_config.py python-reno-2.5.0/reno/tests/test_config.py --- python-reno-1.3.0/reno/tests/test_config.py 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/reno/tests/test_config.py 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,174 @@ +# -*- 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 argparse +import os + +import fixtures + +from reno import config +from reno import defaults +from reno import main +from reno.tests import base + +import mock + + +class TestConfig(base.TestCase): + EXAMPLE_CONFIG = """ +collapse_pre_releases: false +""" + + def setUp(self): + super(TestConfig, self).setUp() + # Temporary directory to store our config + self.tempdir = self.useFixture(fixtures.TempDir()) + + def test_defaults(self): + c = config.Config(self.tempdir.path) + actual = c.options + self.assertEqual(config.Config._OPTS, actual) + + def test_override(self): + c = config.Config(self.tempdir.path) + c.override( + collapse_pre_releases=False, + ) + actual = c.options + expected = {} + expected.update(config.Config._OPTS) + expected['collapse_pre_releases'] = False + self.assertEqual(expected, actual) + + def test_override_multiple(self): + c = config.Config(self.tempdir.path) + c.override( + notesdir='value1', + ) + c.override( + notesdir='value2', + ) + actual = c.options + expected = {} + expected.update(config.Config._OPTS) + expected['notesdir'] = 'value2' + self.assertEqual(expected, actual) + + def test_load_file_not_present(self): + with mock.patch.object(config.LOG, 'info') as logger: + config.Config(self.tempdir.path) + self.assertEqual(1, logger.call_count) + + def _test_load_file(self, config_path): + with open(config_path, 'w') as fd: + fd.write(self.EXAMPLE_CONFIG) + self.addCleanup(os.unlink, config_path) + c = config.Config(self.tempdir.path) + self.assertEqual(False, c.collapse_pre_releases) + + def test_load_file_in_releasenotesdir(self): + rn_path = self.tempdir.join('releasenotes') + os.mkdir(rn_path) + config_path = self.tempdir.join('releasenotes/config.yaml') + self._test_load_file(config_path) + + def test_load_file_in_repodir(self): + config_path = self.tempdir.join('reno.yaml') + self._test_load_file(config_path) + + def test_get_default(self): + d = config.Config.get_default('notesdir') + self.assertEqual('notes', d) + + def test_get_default_unknown(self): + self.assertRaises( + ValueError, + config.Config.get_default, + 'unknownopt', + ) + + def _run_override_from_parsed_args(self, argv): + parser = argparse.ArgumentParser() + main._build_query_arg_group(parser) + args = parser.parse_args(argv) + c = config.Config(self.tempdir.path) + c.override_from_parsed_args(args) + return c + + def test_override_from_parsed_args_empty(self): + c = self._run_override_from_parsed_args([]) + actual = { + o: getattr(c, o) + for o in config.Config._OPTS.keys() + } + self.assertEqual(config.Config._OPTS, actual) + + def test_override_from_parsed_args(self): + c = self._run_override_from_parsed_args([ + '--no-collapse-pre-releases', + ]) + actual = c.options + expected = {} + expected.update(config.Config._OPTS) + expected['collapse_pre_releases'] = False + self.assertEqual(expected, actual) + + def test_override_from_parsed_args_ignore_non_options(self): + parser = argparse.ArgumentParser() + main._build_query_arg_group(parser) + parser.add_argument('not_a_config_option') + args = parser.parse_args(['value']) + c = config.Config(self.tempdir.path) + c.override_from_parsed_args(args) + self.assertFalse(hasattr(c, 'not_a_config_option')) + + +class TestConfigProperties(base.TestCase): + + def setUp(self): + super(TestConfigProperties, self).setUp() + # Temporary directory to store our config + self.tempdir = self.useFixture(fixtures.TempDir()) + self.c = config.Config('releasenotes') + + def test_reporoot(self): + self.c.reporoot = 'blah//' + self.assertEqual('blah/', self.c.reporoot) + self.c.reporoot = 'blah' + self.assertEqual('blah/', self.c.reporoot) + + def test_notespath(self): + self.assertEqual('releasenotes/notes', self.c.notespath) + self.c.override(notesdir='thenotes') + self.assertEqual('releasenotes/thenotes', self.c.notespath) + + def test_template(self): + template = defaults.TEMPLATE.format(defaults.PRELUDE_SECTION_NAME) + self.assertEqual(template, self.c.template) + self.c.override(template='i-am-a-template') + self.assertEqual('i-am-a-template', self.c.template) + + def test_prelude_override(self): + template = defaults.TEMPLATE.format(defaults.PRELUDE_SECTION_NAME) + self.assertEqual(template, self.c.template) + self.c.override(prelude_section_name='fake_prelude_name') + expected_template = defaults.TEMPLATE.format('fake_prelude_name') + self.assertEqual(expected_template, self.c.template) + + def test_prelude_and_template_override(self): + template = defaults.TEMPLATE.format(defaults.PRELUDE_SECTION_NAME) + self.assertEqual(template, self.c.template) + self.c.override(prelude_section_name='fake_prelude_name', + template='i-am-a-template') + self.assertEqual('fake_prelude_name', self.c.prelude_section_name) + self.assertEqual('i-am-a-template', self.c.template) diff -Nru python-reno-1.3.0/reno/tests/test_create.py python-reno-2.5.0/reno/tests/test_create.py --- python-reno-1.3.0/reno/tests/test_create.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/tests/test_create.py 2017-07-19 09:44:08.000000000 +0000 @@ -12,11 +12,12 @@ # License for the specific language governing permissions and limitations # under the License. +import fixtures +import mock + from reno import create from reno.tests import base -import mock - class TestPickFileName(base.TestCase): @@ -29,3 +30,36 @@ 'somepath', 'someslug', ) + + @mock.patch('os.path.exists') + def test_random_enough(self, exists): + exists.return_value = False + result = create._pick_note_file_name('somepath', 'someslug') + self.assertIn('somepath', result) + self.assertIn('someslug', result) + + +class TestCreate(base.TestCase): + + def setUp(self): + super(TestCreate, self).setUp() + self.tmpdir = self.useFixture(fixtures.TempDir()).path + + def test_create_from_template(self): + filename = create._pick_note_file_name(self.tmpdir, 'theslug') + create._make_note_file(filename, 'i-am-a-template') + with open(filename, 'r') as f: + body = f.read() + self.assertEqual('i-am-a-template', body) + + def test_edit(self): + self.useFixture(fixtures.EnvironmentVariable('EDITOR', 'myeditor')) + with mock.patch('subprocess.call') as call_mock: + self.assertTrue(create._edit_file('somepath')) + call_mock.assert_called_once_with(['myeditor', 'somepath']) + + def test_edit_without_editor_env_var(self): + self.useFixture(fixtures.EnvironmentVariable('EDITOR')) + with mock.patch('subprocess.call') as call_mock: + self.assertFalse(create._edit_file('somepath')) + call_mock.assert_not_called() diff -Nru python-reno-1.3.0/reno/tests/test_formatter.py python-reno-2.5.0/reno/tests/test_formatter.py --- python-reno-1.3.0/reno/tests/test_formatter.py 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/reno/tests/test_formatter.py 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,158 @@ +# -*- 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 mock + +from reno import config +from reno import formatter +from reno import loader +from reno.tests import base + + +class TestFormatterBase(base.TestCase): + + scanner_output = { + '0.0.0': [('note1', 'shaA')], + '1.0.0': [('note2', 'shaB'), ('note3', 'shaC')], + } + + versions = ['0.0.0', '1.0.0'] + + def _get_note_body(self, reporoot, filename, sha): + return self.note_bodies.get(filename, '') + + def setUp(self): + super(TestFormatterBase, self).setUp() + + def _load(ldr): + ldr._scanner_output = self.scanner_output + ldr._cache = { + 'file-contents': self.note_bodies + } + + self.c = config.Config('reporoot') + + with mock.patch('reno.loader.Loader._load_data', _load): + self.ldr = loader.Loader( + self.c, + ignore_cache=False, + ) + + +class TestFormatter(TestFormatterBase): + + note_bodies = { + 'note1': { + 'prelude': 'This is the prelude.', + }, + 'note2': { + 'issues': [ + 'This is the first issue.', + 'This is the second issue.', + ], + }, + 'note3': { + 'features': [ + 'We added a feature!', + ], + 'upgrade': None, + }, + } + + def test_with_title(self): + result = formatter.format_report( + loader=self.ldr, + config=self.c, + versions_to_include=self.versions, + title='This is the title', + ) + self.assertIn('This is the title', result) + + def test_versions(self): + result = formatter.format_report( + loader=self.ldr, + config=self.c, + versions_to_include=self.versions, + title='This is the title', + ) + self.assertIn('0.0.0\n=====', result) + self.assertIn('1.0.0\n=====', result) + + def test_without_title(self): + result = formatter.format_report( + loader=self.ldr, + config=self.c, + versions_to_include=self.versions, + title=None, + ) + self.assertNotIn('This is the title', result) + + def test_default_section_order(self): + result = formatter.format_report( + loader=self.ldr, + config=self.c, + versions_to_include=self.versions, + title=None, + ) + prelude_pos = result.index('This is the prelude.') + issues_pos = result.index('This is the first issue.') + features_pos = result.index('We added a feature!') + expected = [prelude_pos, features_pos, issues_pos] + actual = list(sorted([prelude_pos, features_pos, issues_pos])) + self.assertEqual(expected, actual) + + +class TestFormatterCustomSections(TestFormatterBase): + note_bodies = { + 'note1': { + 'prelude': 'This is the prelude.', + }, + 'note2': { + 'features': [ + 'This is the first feature.', + ], + 'api': [ + 'This is the API change for the first feature.', + ], + }, + 'note3': { + 'api': [ + 'This is the API change for the second feature.', + ], + 'features': [ + 'This is the second feature.', + ], + }, + } + + def setUp(self): + super(TestFormatterCustomSections, self).setUp() + self.c.override(sections=[ + ['api', 'API Changes'], + ['features', 'New Features'], + ]) + + def test_custom_section_order(self): + result = formatter.format_report( + loader=self.ldr, + config=self.c, + versions_to_include=self.versions, + title=None, + ) + prelude_pos = result.index('This is the prelude.') + api_pos = result.index('API Changes') + features_pos = result.index('New Features') + expected = [prelude_pos, api_pos, features_pos] + actual = list(sorted([prelude_pos, features_pos, api_pos])) + self.assertEqual(expected, actual) diff -Nru python-reno-1.3.0/reno/tests/test_loader.py python-reno-2.5.0/reno/tests/test_loader.py --- python-reno-1.3.0/reno/tests/test_loader.py 1970-01-01 00:00:00.000000000 +0000 +++ python-reno-2.5.0/reno/tests/test_loader.py 2017-07-19 09:44:08.000000000 +0000 @@ -0,0 +1,90 @@ +# -*- 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 logging +import textwrap + +import fixtures +import mock +import six +import yaml + +from reno import config +from reno import loader +from reno.tests import base + + +class TestValidate(base.TestCase): + + scanner_output = { + '0.0.0': [('note', 'shaA')], + } + + versions = ['0.0.0'] + + def setUp(self): + super(TestValidate, self).setUp() + self.logger = self.useFixture( + fixtures.FakeLogger( + format='%(message)s', + level=logging.WARNING, + ) + ) + self.c = config.Config('reporoot') + + def _make_loader(self, note_bodies): + def _load(ldr): + ldr._scanner_output = self.scanner_output + ldr._cache = { + 'file-contents': {'note1': note_bodies}, + } + + with mock.patch('reno.loader.Loader._load_data', _load): + return loader.Loader( + self.c, + ignore_cache=False, + ) + + def test_prelude_list(self): + note_bodies = yaml.safe_load(textwrap.dedent(''' + prelude: + - This is the first comment. + - This is a second. + ''')) + self.assertIsInstance(note_bodies['prelude'], list) + ldr = self._make_loader(note_bodies) + ldr.parse_note_file('note1', None) + self.assertIn('prelude', self.logger.output) + + def test_non_prelude_single_string_converted_to_list(self): + note_bodies = yaml.safe_load(textwrap.dedent(''' + issues: | + This is a single string. + ''')) + print(type(note_bodies['issues'])) + self.assertIsInstance(note_bodies['issues'], six.string_types) + ldr = self._make_loader(note_bodies) + parse_results = ldr.parse_note_file('note1', None) + self.assertIsInstance(parse_results['issues'], list) + + def test_note_with_colon_as_dict(self): + note_bodies = yaml.safe_load(textwrap.dedent(''' + issues: + - This is the first issue. + - dict: This is parsed as a dictionary. + ''')) + self.assertIsInstance(note_bodies['issues'][-1], dict) + ldr = self._make_loader(note_bodies) + ldr.parse_note_file('note1', None) + self.assertIn('dict', self.logger.output) diff -Nru python-reno-1.3.0/reno/tests/test_scanner.py python-reno-2.5.0/reno/tests/test_scanner.py --- python-reno-1.3.0/reno/tests/test_scanner.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/tests/test_scanner.py 2017-07-19 09:44:08.000000000 +0000 @@ -12,22 +12,28 @@ # License for the specific language governing permissions and limitations # under the License. +from __future__ import unicode_literals + import itertools import logging import os.path import re import subprocess -import textwrap +import time +import unittest + +from dulwich import diff_tree +from dulwich import objects +import fixtures +import mock +from testtools.content import text_content +from reno import config from reno import create from reno import scanner from reno.tests import base from reno import utils -import fixtures -import mock -from testtools.content import text_content - _SETUP_TEMPLATE = """ import setuptools @@ -65,7 +71,7 @@ gnupg_version_re = re.compile('^gpg\s.*\s([\d+])\.([\d+])\.([\d+])') gnupg_version = utils.check_output(['gpg', '--version'], cwd=tempdir.path) - for line in gnupg_version[0].split('\n'): + for line in gnupg_version.split('\n'): gnupg_version = gnupg_version_re.match(line) if gnupg_version: gnupg_version = (int(gnupg_version.group(1)), @@ -98,45 +104,70 @@ # Note that --quick-random (--debug-quick-random in GnuPG 2.x) # does not have a corresponding preferences file setting and # must be passed explicitly on the command line instead - # if gnupg_version[0] == 1: - # gnupg_random = '--quick-random' - # elif gnupg_version[0] >= 2: - # gnupg_random = '--debug-quick-random' - # else: - # gnupg_random = '' + if gnupg_version[0] == 1: + gnupg_random = '--quick-random' + elif gnupg_version[0] >= 2: + gnupg_random = '--debug-quick-random' + else: + gnupg_random = '' + cmd = ['gpg', '--gen-key', '--batch'] + if gnupg_random: + cmd.append(gnupg_random) + cmd.append(config_file) subprocess.check_call( - ['gpg', '--gen-key', '--batch', - # gnupg_random, - config_file], - cwd=tempdir.path) + cmd, + cwd=tempdir.path, + # Direct stderr to its own pipe, from which we don't read, + # to quiet the commands. + stderr=subprocess.PIPE, + ) -class Base(base.TestCase): +class GitRepoFixture(fixtures.Fixture): - def _run_git(self, *args): - return utils.check_output( + logger = logging.getLogger('git') + + def __init__(self, reporoot): + self.reporoot = reporoot + super(GitRepoFixture, self).__init__() + + def setUp(self): + super(GitRepoFixture, self).setUp() + self.useFixture(GPGKeyFixture()) + os.makedirs(self.reporoot) + self.git('init', '.') + self.git('config', '--local', 'user.email', 'example@example.com') + self.git('config', '--local', 'user.name', 'reno developer') + self.git('config', '--local', 'user.signingkey', + 'example@example.com') + + def git(self, *args): + self.logger.debug('$ git %s', ' '.join(args)) + output = utils.check_output( ['git'] + list(args), cwd=self.reporoot, ) + self.logger.debug(output) + return output - def _git_setup(self): - os.makedirs(self.reporoot) - self._run_git('init', '.') - self._run_git('config', '--local', 'user.email', 'example@example.com') - self._run_git('config', '--local', 'user.name', 'reno developer') - self._run_git('config', '--local', 'user.signingkey', - 'example@example.com') - - def _git_commit(self, message='commit message'): - self._run_git('add', '.') - self._run_git('commit', '-m', message) + def commit(self, message='commit message'): + self.git('add', '.') + self.git('commit', '-m', message) + self.git('show', '--pretty=format:%H') + time.sleep(0.1) # force a delay between commits - def _add_other_file(self, name): + def add_file(self, name): with open(os.path.join(self.reporoot, name), 'w') as f: f.write('adding %s\n' % name) - self._git_commit('add %s' % name) + self.commit('add %s' % name) + + +class Base(base.TestCase): + + logger = logging.getLogger('test') - def _add_notes_file(self, slug='slug', commit=True, legacy=False): + def _add_notes_file(self, slug='slug', commit=True, legacy=False, + contents='i-am-also-a-template'): n = self.get_note_num() if legacy: basename = '%016x-%s.yaml' % (n, slug) @@ -144,8 +175,8 @@ basename = '%s-%016x.yaml' % (slug, n) filename = os.path.join(self.reporoot, 'releasenotes', 'notes', basename) - create._make_note_file(filename) - self._git_commit('add %s' % basename) + create._make_note_file(filename, contents) + self.repo.commit('add %s' % basename) return os.path.join('releasenotes', 'notes', basename) def _make_python_package(self): @@ -160,11 +191,11 @@ init = os.path.join(pkgdir, '__init__.py') with open(init, 'w') as f: f.write("Test package") - self._git_commit('add test package') + self.repo.commit('add test package') def setUp(self): super(Base, self).setUp() - self.logger = self.useFixture( + self.fake_logger = self.useFixture( fixtures.FakeLogger( format='%(levelname)8s %(name)s %(message)s', level=logging.DEBUG, @@ -175,15 +206,11 @@ # directory to permit using git config --global without stepping on # developer configuration. self.useFixture(fixtures.TempHomeDir()) - self.useFixture(GPGKeyFixture()) self.useFixture(fixtures.NestedTempfile()) self.temp_dir = self.useFixture(fixtures.TempDir()).path self.reporoot = os.path.join(self.temp_dir, 'reporoot') - self.notesdir = os.path.join(self.reporoot, - 'releasenotes', - 'notes', - ) - self._git_setup() + self.repo = self.useFixture(GitRepoFixture(self.reporoot)) + self.c = config.Config(self.reporoot) self._counter = itertools.count(1) self.get_note_num = lambda: next(self._counter) @@ -192,10 +219,8 @@ def test_non_python_no_tags(self): filename = self._add_notes_file() - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -208,10 +233,8 @@ def test_python_no_tags(self): self._make_python_package() filename = self._add_notes_file() - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -223,12 +246,10 @@ def test_note_before_tag(self): filename = self._add_notes_file() - self._add_other_file('not-a-release-note.txt') - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.repo.add_file('not-a-release-note.txt') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -240,11 +261,9 @@ def test_note_commit_tagged(self): filename = self._add_notes_file() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -256,12 +275,10 @@ def test_note_commit_after_tag(self): self._make_python_package() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') filename = self._add_notes_file() - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -273,13 +290,11 @@ def test_other_commit_after_tag(self): filename = self._add_notes_file() - self._add_other_file('ignore-1.txt') - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') - self._add_other_file('ignore-2.txt') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.repo.add_file('ignore-1.txt') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.add_file('ignore-2.txt') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -291,13 +306,11 @@ def test_multiple_notes_after_tag(self): self._make_python_package() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') f1 = self._add_notes_file() f2 = self._add_notes_file() - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -311,11 +324,9 @@ self._make_python_package() f1 = self._add_notes_file(commit=False) f2 = self._add_notes_file() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -327,14 +338,12 @@ def test_multiple_tags(self): self._make_python_package() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') f1 = self._add_notes_file() - self._run_git('tag', '-s', '-m', 'first tag', '2.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '2.0.0') f2 = self._add_notes_file() - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -348,16 +357,14 @@ def test_rename_file(self): self._make_python_package() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') f1 = self._add_notes_file('slug1') - self._run_git('tag', '-s', '-m', 'first tag', '2.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '2.0.0') f2 = f1.replace('slug1', 'slug2') - self._run_git('mv', f1, f2) - self._git_commit('rename note file') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.repo.git('mv', f1, f2) + self.repo.commit('rename note file') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -370,16 +377,14 @@ def test_rename_file_sort_earlier(self): self._make_python_package() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') f1 = self._add_notes_file('slug1') - self._run_git('tag', '-s', '-m', 'first tag', '2.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '2.0.0') f2 = f1.replace('slug1', 'slug0') - self._run_git('mv', f1, f2) - self._git_commit('rename note file') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.repo.git('mv', f1, f2) + self.repo.commit('rename note file') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -392,16 +397,14 @@ def test_edit_file(self): self._make_python_package() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') f1 = self._add_notes_file() - self._run_git('tag', '-s', '-m', 'first tag', '2.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '2.0.0') with open(os.path.join(self.reporoot, f1), 'w') as f: f.write('---\npreamble: new contents for file') - self._git_commit('edit note file') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.repo.commit('edit note file') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -414,16 +417,14 @@ def test_legacy_file(self): self._make_python_package() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') f1 = self._add_notes_file('slug1', legacy=True) - self._run_git('tag', '-s', '-m', 'first tag', '2.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '2.0.0') f2 = f1.replace('slug1', 'slug2') - self._run_git('mv', f1, f2) - self._git_commit('rename note file') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.repo.git('mv', f1, f2) + self.repo.commit('rename note file') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -436,19 +437,17 @@ def test_rename_legacy_file_to_new(self): self._make_python_package() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') f1 = self._add_notes_file('slug1', legacy=True) - self._run_git('tag', '-s', '-m', 'first tag', '2.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '2.0.0') # Rename the file with the new convention of placing the UUID # after the slug instead of before. f2 = f1.replace('0000000000000001-slug1', 'slug1-0000000000000001') - self._run_git('mv', f1, f2) - self._git_commit('rename note file') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - ) + self.repo.git('mv', f1, f2) + self.repo.commit('rename note file') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() @@ -459,380 +458,1752 @@ results, ) + def test_limit_by_earliest_version(self): + self._make_python_package() + self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + f2 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'middle tag', '2.0.0') + f3 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'last tag', '3.0.0') + self.c.override( + earliest_version='2.0.0', + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + {'2.0.0': [f2], + '3.0.0': [f3], + }, + results, + ) -class MergeCommitTest(Base): - - def test_1(self): - # Create changes on master and in the branch - # in order so the history is "normal" - n1 = self._add_notes_file() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') - self._run_git('checkout', '-b', 'test_merge_commit') - n2 = self._add_notes_file() - self._run_git('checkout', 'master') - self._add_other_file('ignore-1.txt') - self._run_git('merge', '--no-ff', 'test_merge_commit') - self._add_other_file('ignore-2.txt') - self._run_git('tag', '-s', '-m', 'second tag', '2.0.0') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', + def test_delete_file(self): + self._make_python_package() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + f1 = self._add_notes_file('slug1') + f2 = self._add_notes_file('slug2') + self.repo.git('rm', f1) + self.repo.commit('remove note file') + self.repo.git('tag', '-s', '-m', 'first tag', '2.0.0') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + {'2.0.0': [f2], + }, + results, ) + + def test_rename_then_delete_file(self): + self._make_python_package() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + f1 = self._add_notes_file('slug1') + f2 = f1.replace('slug1', 'slug2') + self.repo.git('mv', f1, f2) + self.repo.git('status') + self.repo.commit('rename note file') + self.repo.git('rm', f2) + self.repo.commit('remove note file') + f3 = self._add_notes_file('slug3') + self.repo.git('tag', '-s', '-m', 'first tag', '2.0.0') + log_results = self.repo.git('log', '--topo-order', + '--pretty=%H %d', + '--name-only') + self.addDetail('git log', text_content(log_results)) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() } self.assertEqual( - {'1.0.0': [n1], - '2.0.0': [n2]}, + {'2.0.0': [f3], + }, results, ) + + def test_staged_file(self): + # Prove that we can get a file we have staged. + # Start with a standard commit and tag + self._make_python_package() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + # Now stage a release note + n = self.get_note_num() + basename = 'staged-note-%016x.yaml' % n + filename = os.path.join(self.reporoot, 'releasenotes', 'notes', + basename) + create._make_note_file(filename, 'staged note') + self.repo.git('add', filename) + status_results = self.repo.git('status') + self.addDetail('git status', text_content(status_results)) + # Now run the scanner + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + self.assertEqual( + {'*working-copy*': [ + (os.path.join('releasenotes', 'notes', basename), + None)], + }, + raw_results, + ) + + @unittest.skip('dulwich does not know how to identify new files') + def test_added_tagged_not_staged(self): + # Prove that we can get a file we have created but not staged. + # Start with a standard commit and tag + self._make_python_package() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + # Now create a note without staging it + n = self.get_note_num() + basename = 'staged-note-%016x.yaml' % n + filename = os.path.join(self.reporoot, 'releasenotes', 'notes', + basename) + create._make_note_file(filename, 'staged note') + status_results = self.repo.git('status') + self.addDetail('git status', text_content(status_results)) + # Now run the scanner + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + # Take the staged version of the file, but associate it with + # tagged version 1.0.0 because the file was added before that + # version. self.assertEqual( - ['2.0.0', '1.0.0'], - list(raw_results.keys()), + {'1.0.0': [(os.path.join('releasenotes', 'notes', basename), + None)], + }, + raw_results, ) - def test_2(self): - # Create changes on the branch before the tag into which it is - # actually merged. - self._add_other_file('ignore-0.txt') - self._run_git('checkout', '-b', 'test_merge_commit') - n1 = self._add_notes_file() - self._run_git('checkout', 'master') - n2 = self._add_notes_file() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') - self._add_other_file('ignore-1.txt') - self._run_git('merge', '--no-ff', 'test_merge_commit') - self._add_other_file('ignore-2.txt') - self._run_git('tag', '-s', '-m', 'second tag', '2.0.0') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', + def test_modified_tagged_not_staged(self): + # Prove that we can get a file we have changed but not staged. + # Start with a standard commit and tag + self._make_python_package() + f1 = self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + # Now modify the note + fullpath = os.path.join(self.repo.reporoot, f1) + with open(fullpath, 'w') as f: + f.write('modified first note') + status_results = self.repo.git('status') + self.addDetail('git status', text_content(status_results)) + # Now run the scanner + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + # Take the staged version of the file, but associate it with + # tagged version 1.0.0 because the file was added before that + # version. + self.assertEqual( + {'1.0.0': [(f1, None)], + }, + raw_results, + ) + + def test_stop_on_master_with_other_branch(self): + self._make_python_package() + self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'middle tag', '2.0.0') + f3 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'last tag', '3.0.0') + self.repo.git('branch', 'stable/a') + f4 = self._add_notes_file() + self.c.override( + earliest_version=None, ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() } self.assertEqual( - {'1.0.0': [n2], - '2.0.0': [n1]}, + {'3.0.0-1': [f4], + '3.0.0': [f3], + }, results, ) + + def test_stop_on_master_without_limits_or_branches(self): + self._make_python_package() + f1 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + f2 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'middle tag', '2.0.0') + f3 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'last tag', '3.0.0') + f4 = self._add_notes_file() + self.c.override( + earliest_version=None, + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } self.assertEqual( - ['2.0.0', '1.0.0'], - list(raw_results.keys()), + {'3.0.0-1': [f4], + '3.0.0': [f3], + '2.0.0': [f2], + '1.0.0': [f1], + }, + results, ) - def test_3(self): - # Create changes on the branch before the tag into which it is - # actually merged, with another tag in between the time of the - # commit and the time of the merge. This should reflect the - # order of events described in bug #1522153. - self._add_other_file('ignore-0.txt') - self._run_git('checkout', '-b', 'test_merge_commit') - n1 = self._add_notes_file() - self._run_git('checkout', 'master') - n2 = self._add_notes_file() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') - self._add_other_file('ignore-1.txt') - self._run_git('tag', '-s', '-m', 'second tag', '1.1.0') - self._run_git('merge', '--no-ff', 'test_merge_commit') - self._add_other_file('ignore-2.txt') - self._run_git('tag', '-s', '-m', 'third tag', '2.0.0') - self._add_other_file('ignore-3.txt') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', + +class IgnoreTest(Base): + + def test_by_fullname(self): + self._make_python_package() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + f1 = self._add_notes_file() + f2 = self._add_notes_file() + self.c.override( + ignore_notes=[f1], ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() } - # Since the 1.1.0 tag has no notes files, it does not appear - # in the output. It's only there to trigger the bug as it was - # originally reported. self.assertEqual( - {'1.0.0': [n2], - '2.0.0': [n1]}, + {'1.0.0-2': [f2]}, results, ) + + def test_by_basename(self): + self._make_python_package() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + f1 = self._add_notes_file() + f2 = self._add_notes_file() + self.c.override( + ignore_notes=[os.path.basename(f1)], + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } self.assertEqual( - ['2.0.0', '1.0.0'], - list(raw_results.keys()), + {'1.0.0-2': [f2]}, + results, ) - def test_4(self): - # Create changes on the branch before the tag into which it is - # actually merged, with another tag in between the time of the - # commit and the time of the merge. This should reflect the - # order of events described in bug #1522153. - self._add_other_file('ignore-0.txt') - self._run_git('checkout', '-b', 'test_merge_commit') - n1 = self._add_notes_file() - self._run_git('checkout', 'master') - n2 = self._add_notes_file() - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') - self._add_other_file('ignore-1.txt') - n3 = self._add_notes_file() - self._run_git('tag', '-s', '-m', 'second tag', '1.1.0') - self._run_git('merge', '--no-ff', 'test_merge_commit') - self._add_other_file('ignore-2.txt') - self._run_git('tag', '-s', '-m', 'third tag', '2.0.0') - self._add_other_file('ignore-3.txt') - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', + def test_by_uid(self): + self._make_python_package() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + f1 = self._add_notes_file() + f2 = self._add_notes_file() + self.c.override( + ignore_notes=[scanner._get_unique_id(f1)], ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() } self.assertEqual( - {'1.0.0': [n2], - '1.1.0': [n3], - '2.0.0': [n1]}, + {'1.0.0-2': [f2]}, results, ) + + def test_by_multiples(self): + self._make_python_package() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + f1 = self._add_notes_file() + f2 = self._add_notes_file() + self.c.override( + ignore_notes=[ + scanner._get_unique_id(f1), + scanner._get_unique_id(f2), + ], + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } self.assertEqual( - ['2.0.0', '1.1.0', '1.0.0'], - list(raw_results.keys()), + {}, + results, ) -class UniqueIdTest(Base): +class FileContentsTest(Base): - def test_legacy(self): - uid = scanner._get_unique_id( - 'releasenotes/notes/0000000000000001-slug1.yaml' + def test_basic_file(self): + # Prove that we can get a file we have committed. + f1 = self._add_notes_file(contents='well-known-contents') + r = scanner.RenoRepo(self.reporoot) + contents = r.get_file_at_commit(f1, 'HEAD') + self.assertEqual( + b'well-known-contents', + contents, ) - self.assertEqual('0000000000000001', uid) - def test_modern(self): - uid = scanner._get_unique_id( - 'releasenotes/notes/slug1-0000000000000001.yaml' + def test_no_such_file(self): + # Returns None when the file does not exist at all. + # (we have to commit something, otherwise there is no HEAD) + self._add_notes_file(contents='well-known-contents') + r = scanner.RenoRepo(self.reporoot) + contents = r.get_file_at_commit('no-such-dir/no-such-file', 'HEAD') + self.assertEqual( + None, + contents, ) - self.assertEqual('0000000000000001', uid) + def test_edit_file_and_commit(self): + # Prove that we can edit a file and see the changes. + f1 = self._add_notes_file(contents='initial-contents') + with open(os.path.join(self.reporoot, f1), 'w') as f: + f.write('new contents for file') + self.repo.commit('edit note file') + r = scanner.RenoRepo(self.reporoot) + contents = r.get_file_at_commit(f1, 'HEAD') + self.assertEqual( + b'new contents for file', + contents, + ) -class BranchTest(Base): + def test_earlier_version_of_edited_file(self): + # Prove that we are not always just returning the most current + # version of a file. + f1 = self._add_notes_file(contents='initial-contents') + with open(os.path.join(self.reporoot, f1), 'w') as f: + f.write('new contents for file') + self.repo.commit('edit note file') + self.scanner = scanner.Scanner(self.c) + r = scanner.RenoRepo(self.reporoot) + head = r.head() + parent = r.get_parents(head)[0] + parent = parent.decode('ascii') + contents = r.get_file_at_commit(f1, parent) + self.assertEqual( + b'initial-contents', + contents, + ) + + def test_edit_file_without_commit(self): + # Prove we are not picking up the contents from the local + # filesystem outside of the git history. + f1 = self._add_notes_file(contents='initial-contents') + with open(os.path.join(self.reporoot, f1), 'w') as f: + f.write('new contents for file') + r = scanner.RenoRepo(self.reporoot) + contents = r.get_file_at_commit(f1, 'HEAD') + self.assertEqual( + b'initial-contents', + contents, + ) - def setUp(self): - super(BranchTest, self).setUp() + def test_staged_file(self): + # Prove we are not picking up the contents from the local + # filesystem outside of the git history. + f1 = self._add_notes_file(contents='initial-contents') + with open(os.path.join(self.reporoot, f1), 'w') as f: + f.write('new contents for file') + r = scanner.RenoRepo(self.reporoot) + contents = r.get_file_at_commit(f1, None) + self.assertEqual( + 'new contents for file', + contents, + ) + + +class PreReleaseTest(Base): + + def test_alpha(self): self._make_python_package() - self.f1 = self._add_notes_file('slug1') - self._run_git('tag', '-s', '-m', 'first tag', '1.0.0') - self.f2 = self._add_notes_file('slug2') - self._run_git('tag', '-s', '-m', 'first tag', '2.0.0') - self._add_notes_file('slug3') - self._run_git('tag', '-s', '-m', 'first tag', '3.0.0') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0.0a1') + f1 = self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0.0a2') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + {'1.0.0.0a2': [f1], + }, + results, + ) - def test_files_current_branch(self): - self._run_git('checkout', '2.0.0') - self._run_git('checkout', '-b', 'stable/2') - f21 = self._add_notes_file('slug21') - log_text = self._run_git('log') - self.addDetail('git log', text_content(log_text)) - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', + def test_beta(self): + self._make_python_package() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0.0b1') + f1 = self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0.0b2') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + {'1.0.0.0b2': [f1], + }, + results, ) + + def test_release_candidate(self): + self._make_python_package() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0.0rc1') + f1 = self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0.0rc2') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() } self.assertEqual( - { - '1.0.0': [self.f1], - '2.0.0': [self.f2], - '2.0.0-1': [f21], - }, + {'1.0.0.0rc2': [f1], + }, results, ) - def test_files_stable_from_master(self): - self._run_git('checkout', '2.0.0') - self._run_git('checkout', '-b', 'stable/2') - f21 = self._add_notes_file('slug21') - self._run_git('checkout', 'master') - log_text = self._run_git('log', '--pretty=%x00%H %d', '--name-only', - 'stable/2') - self.addDetail('git log', text_content(log_text)) - raw_results = scanner.get_notes_by_version( - self.reporoot, - 'releasenotes/notes', - 'stable/2', + def test_collapse(self): + files = [] + self._make_python_package() + files.append(self._add_notes_file('slug1')) + self.repo.git('tag', '-s', '-m', 'alpha tag', '1.0.0.0a1') + files.append(self._add_notes_file('slug2')) + self.repo.git('tag', '-s', '-m', 'beta tag', '1.0.0.0b1') + files.append(self._add_notes_file('slug3')) + self.repo.git('tag', '-s', '-m', 'release candidate tag', '1.0.0.0rc1') + files.append(self._add_notes_file('slug4')) + self.repo.git('tag', '-s', '-m', 'full release tag', '1.0.0') + self.c.override( + collapse_pre_releases=True, ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() results = { k: [f for (f, n) in v] for (k, v) in raw_results.items() } self.assertEqual( - { - '1.0.0': [self.f1], - '2.0.0': [self.f2], - '2.0.0-1': [f21], - }, + {'1.0.0': files, + }, results, ) + def test_collapse_without_full_release(self): + self._make_python_package() + f1 = self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'alpha tag', '1.0.0.0a1') + f2 = self._add_notes_file('slug2') + self.repo.git('tag', '-s', '-m', 'beta tag', '1.0.0.0b1') + f3 = self._add_notes_file('slug3') + self.repo.git('tag', '-s', '-m', 'release candidate tag', '1.0.0.0rc1') + self.c.override( + collapse_pre_releases=True, + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + {'1.0.0.0a1': [f1], + '1.0.0.0b1': [f2], + '1.0.0.0rc1': [f3], + }, + results, + ) -class GetTagsParseTest(base.TestCase): - - EXPECTED = [ - '2.0.0', - '1.8.1', - '1.8.0', - '1.7.1', - '1.7.0', - '1.6.0', - '1.5.0', - '1.4.0', - '1.3.0', - '1.2.0', - '1.1.0', - '1.0.0', - '0.11.2', - '0.11.1', - '0.11.0', - '0.10.1', - '0.10.0', - '0.9.0', - '0.8.0', - '0.7.1', - '0.7.0', - '0.6.0', - '0.5.1', - '0.5.0', - '0.4.2', - '0.4.1', - '0.4.0', - '0.3.2', - '0.3.1', - '0.3.0', - '0.2.5', - '0.2.4', - '0.2.3', - '0.2.2', - '0.2.1', - '0.2.0', - '0.1.3', - '0.1.2', - '0.1.1', - '0.1.0', - ] - - def test_keystoneclient_ubuntu_1_9_1(self): - # git 1.9.1 as it produces output on ubuntu for python-keystoneclient - # git log --simplify-by-decoration --pretty="%d" - tag_list_output = textwrap.dedent(""" - (HEAD, origin/master, origin/HEAD, gerrit/master, master) - (apu/master) - (tag: 2.0.0) - (tag: 1.8.1) - (tag: 1.8.0) - (tag: 1.7.1) - (tag: 1.7.0) - (tag: 1.6.0) - (tag: 1.5.0) - (tag: 1.4.0) - (uncap-requirements) - (tag: 1.3.0) - (tag: 1.2.0) - (tag: 1.1.0) - (tag: 1.0.0) - (tag: 0.11.2) - (tag: 0.11.1) - (tag: 0.11.0) - (tag: 0.10.1) - (tag: 0.10.0) - (tag: 0.9.0) - (tag: 0.8.0) - (tag: 0.7.1) - (tag: 0.7.0) - (tag: 0.6.0) - (tag: 0.5.1) - (tag: 0.5.0) - (tag: 0.4.2) - (tag: 0.4.1) - (tag: 0.4.0) - (tag: 0.3.2) - (tag: 0.3.1) - (tag: 0.3.0) - (tag: 0.2.5) - (tag: 0.2.4) - (tag: 0.2.3) - (tag: 0.2.2) - (tag: 0.2.1) - (tag: 0.2.0) - - (origin/feature/keystone-v3, gerrit/feature/keystone-v3) - (tag: 0.1.3) - (tag: 0.1.2) - (tag: 0.1.1) - (tag: 0.1.0) - (tag: folsom-1) - (tag: essex-rc1) - (tag: essex-4) - (tag: essex-3) - """) - with mock.patch('reno.utils.check_output') as co: - co.return_value = tag_list_output - actual = scanner._get_version_tags_on_branch('reporoot', - branch=None) - self.assertEqual(self.EXPECTED, actual) - - def test_keystoneclient_rhel_1_7_1(self): - # git 1.7.1 as it produces output on RHEL 6 for python-keystoneclient - # git log --simplify-by-decoration --pretty="%d" - tag_list_output = textwrap.dedent(""" - (HEAD, origin/master, origin/HEAD, master) - (tag: 2.0.0) - (tag: 1.8.1) - (tag: 1.8.0) - (tag: 1.7.1) - (tag: 1.7.0) - (tag: 1.6.0) - (tag: 1.5.0) - (tag: 1.4.0) - (tag: 1.3.0) - (tag: 1.2.0) - (tag: 1.1.0) - (tag: 1.0.0) - (tag: 0.11.2) - (tag: 0.11.1) - (tag: 0.11.0) - (tag: 0.10.1) - (tag: 0.10.0) - (tag: 0.9.0) - (tag: 0.8.0) - (tag: 0.7.1) - (tag: 0.7.0) - (tag: 0.6.0) - (tag: 0.5.1) - (tag: 0.5.0) - (tag: 0.4.2) - (tag: 0.4.1) - (tag: 0.4.0) - (tag: 0.3.2) - (tag: 0.3.1) - (tag: 0.3.0) - (tag: 0.2.5) - (tag: 0.2.4) - (tag: 0.2.3) - (tag: 0.2.2) - (tag: 0.2.1) - (tag: 0.2.0) - (tag: 0.1.3) - (0.1.2) - (tag: 0.1.1) - (0.1.0) - (tag: folsom-1) - (tag: essex-rc1) - (essex-4) - (essex-3) - """) - with mock.patch('reno.utils.check_output') as co: - co.return_value = tag_list_output - actual = scanner._get_version_tags_on_branch('reporoot', - branch=None) - self.assertEqual(self.EXPECTED, actual) + def test_collapse_without_notes(self): + self._make_python_package() + self.repo.git('tag', '-s', '-m', 'earlier tag', '0.1.0') + f1 = self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'alpha tag', '1.0.0.0a1') + f2 = self._add_notes_file('slug2') + self.repo.git('tag', '-s', '-m', 'beta tag', '1.0.0.0b1') + f3 = self._add_notes_file('slug3') + self.repo.git('tag', '-s', '-m', 'release candidate tag', '1.0.0.0rc1') + self.c.override( + collapse_pre_releases=True, + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + {'1.0.0.0a1': [f1], + '1.0.0.0b1': [f2], + '1.0.0.0rc1': [f3], + }, + results, + ) + + +class MergeCommitTest(Base): + + def test_1(self): + # Create changes on master and in the branch + # in order so the history is "normal" + n1 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.git('checkout', '-b', 'test_merge_commit') + n2 = self._add_notes_file() + self.repo.git('checkout', 'master') + self.repo.add_file('ignore-1.txt') + # Merge the branch into master. + self.repo.git('merge', '--no-ff', 'test_merge_commit') + time.sleep(0.1) # force a delay between commits + self.repo.add_file('ignore-2.txt') + self.repo.git('tag', '-s', '-m', 'second tag', '2.0.0') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + {'1.0.0': [n1], + '2.0.0': [n2]}, + results, + ) + self.assertEqual( + ['2.0.0', '1.0.0'], + list(raw_results.keys()), + ) + + def test_2(self): + # Create changes on the branch before the tag into which it is + # actually merged. + self.repo.add_file('ignore-0.txt') + self.repo.git('checkout', '-b', 'test_merge_commit') + n1 = self._add_notes_file() + self.repo.git('checkout', 'master') + n2 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.add_file('ignore-1.txt') + # Merge the branch into master. + self.repo.git('merge', '--no-ff', 'test_merge_commit') + time.sleep(0.1) # force a delay between commits + self.repo.git('show') + self.repo.add_file('ignore-2.txt') + self.repo.git('tag', '-s', '-m', 'second tag', '2.0.0') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + {'1.0.0': [n2], + '2.0.0': [n1]}, + results, + ) + self.assertEqual( + ['2.0.0', '1.0.0'], + list(raw_results.keys()), + ) + + def test_3(self): + # Create changes on the branch before the tag into which it is + # actually merged, with another tag in between the time of the + # commit and the time of the merge. This should reflect the + # order of events described in bug #1522153. + self.repo.add_file('ignore-0.txt') + self.repo.git('checkout', '-b', 'test_merge_commit') + n1 = self._add_notes_file() + self.repo.git('checkout', 'master') + n2 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.add_file('ignore-1.txt') + self.repo.git('tag', '-s', '-m', 'second tag', '1.1.0') + self.repo.git('merge', '--no-ff', 'test_merge_commit') + time.sleep(0.1) # force a delay between commits + self.repo.add_file('ignore-2.txt') + self.repo.git('tag', '-s', '-m', 'third tag', '2.0.0') + self.repo.add_file('ignore-3.txt') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + # Since the 1.1.0 tag has no notes files, it does not appear + # in the output. It's only there to trigger the bug as it was + # originally reported. + self.assertEqual( + {'1.0.0': [n2], + '2.0.0': [n1]}, + results, + ) + self.assertEqual( + ['2.0.0', '1.0.0'], + list(raw_results.keys()), + ) + + def test_4(self): + # Create changes on the branch before the tag into which it is + # actually merged, with another tag in between the time of the + # commit and the time of the merge. This should reflect the + # order of events described in bug #1522153. + self.repo.add_file('ignore-0.txt') + self.repo.git('checkout', '-b', 'test_merge_commit') + n1 = self._add_notes_file() + self.repo.git('checkout', 'master') + n2 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.add_file('ignore-1.txt') + n3 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'second tag', '1.1.0') + self.repo.git('merge', '--no-ff', 'test_merge_commit') + time.sleep(0.1) # force a delay between commits + self.repo.add_file('ignore-2.txt') + self.repo.git('tag', '-s', '-m', 'third tag', '2.0.0') + self.repo.add_file('ignore-3.txt') + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + {'1.0.0': [n2], + '1.1.0': [n3], + '2.0.0': [n1]}, + results, + ) + self.assertEqual( + ['2.0.0', '1.1.0', '1.0.0'], + list(raw_results.keys()), + ) + + +class NullMergeTest(Base): + + def setUp(self): + super(NullMergeTest, self).setUp() + self.repo.add_file('ignore-0.txt') + self.n1 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + + # Create a branch, add a note, and tag it. + self.repo.git('checkout', '-b', 'test_ignore_null_merge') + self.n2 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'second tag', '2.0.0') + + # Move back to master and advance it. + self.repo.git('checkout', 'master') + self.repo.add_file('ignore-1.txt') + self.n3 = self._add_notes_file() + + # Merge only the tag from the first branch back into master. + self.repo.git( + 'merge', '--no-ff', '--strategy', 'ours', '2.0.0', + ) + + # Add another note file. + self.n4 = self._add_notes_file() + self.repo.git('tag', '-s', '-m', 'third tag', '3.0.0') + + self.repo.git('log', '--decorate', '--oneline', '--graph', '--all') + # The results should look like: + # + # * afea344 (HEAD -> master, tag: 3.0.0) add slug-0000000000000004.yaml + # * 7bb295c Merge tag '2.0.0' + # |\ + # | * 260c80b (tag: 2.0.0, test_ignore_null_merge) add slug-0000000000000002.yaml # noqa + # * | 5981ae3 add slug-0000000000000003.yaml + # * | 00f9376 add ignore-1.txt + # |/ + # * d24faf9 (tag: 1.0.0) add slug-0000000000000001.yaml + # * 6c221cd add ignore-0.txt + + def test_ignore(self): + # The scanner should skip over the null-merge and include the + # notes that come before the version being merged in, up to + # the base of the previous branch. + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + {'1.0.0': [self.n1], + '3.0.0': [self.n3, self.n4]}, + results, + ) + + def test_follow(self): + # The scanner should not skip over the null-merge. The output + # should include the 2.0.0 tag that was merged in, as well as + # the earlier 1.0.0 version. + self.c.override( + ignore_null_merges=False, + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + {'1.0.0': [self.n1], + '2.0.0': [self.n2, self.n3], + '3.0.0': [self.n4]}, + results, + ) + + +class UniqueIdTest(Base): + + def test_legacy(self): + uid = scanner._get_unique_id( + 'releasenotes/notes/0000000000000001-slug1.yaml' + ) + self.assertEqual('0000000000000001', uid) + + def test_modern(self): + uid = scanner._get_unique_id( + 'releasenotes/notes/slug1-0000000000000001.yaml' + ) + self.assertEqual('0000000000000001', uid) + + +class BranchBaseTest(Base): + + def setUp(self): + super(BranchBaseTest, self).setUp() + self._make_python_package() + self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self._add_notes_file('slug2') + self.repo.git('tag', '-s', '-m', 'first tag', '2.0.0') + self._add_notes_file('slug3') + self.repo.git('tag', '-s', '-m', 'first tag', '3.0.0') + self.repo.git('checkout', '2.0.0') + self.repo.git('branch', 'not-master') + self.repo.git('checkout', 'master') + self.scanner = scanner.Scanner(self.c) + + def test_current_branch_no_extra_commits(self): + # checkout the branch and then ask for its base + self.repo.git('checkout', 'not-master') + self.assertEqual( + '2.0.0', + self.scanner._get_branch_base('not-master'), + ) + + def test_current_branch_extra_commit(self): + # checkout the branch and then ask for its base + self.repo.git('checkout', 'not-master') + self._add_notes_file('slug4') + self.assertEqual( + '2.0.0', + self.scanner._get_branch_base('not-master'), + ) + + def test_alternate_branch_no_extra_commits(self): + # checkout master and then ask for the alternate branch base + self.repo.git('checkout', 'master') + self.assertEqual( + '2.0.0', + self.scanner._get_branch_base('not-master'), + ) + + def test_alternate_branch_extra_commit(self): + # checkout master and then ask for the alternate branch base + self.repo.git('checkout', 'not-master') + self._add_notes_file('slug4') + self.repo.git('checkout', 'master') + self.assertEqual( + '2.0.0', + self.scanner._get_branch_base('not-master'), + ) + + def test_no_tag_at_base(self): + # remove the tag at the branch point + self.repo.git('tag', '-d', '2.0.0') + self._add_notes_file('slug4') + self.repo.git('checkout', 'master') + self.assertIsNone( + self.scanner._get_branch_base('not-master') + ) + + +class BranchTest(Base): + + def setUp(self): + super(BranchTest, self).setUp() + self._make_python_package() + self.f1 = self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self.f2 = self._add_notes_file('slug2') + self.repo.git('tag', '-s', '-m', 'first tag', '2.0.0') + self._add_notes_file('slug3') + self.repo.git('tag', '-s', '-m', 'first tag', '3.0.0') + + def test_files_current_branch(self): + self.repo.git('checkout', '2.0.0') + self.repo.git('checkout', '-b', 'stable/2') + f21 = self._add_notes_file('slug21') + log_text = self.repo.git('log', '--decorate') + self.addDetail('git log', text_content(log_text)) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + { + '2.0.0-1': [f21], + '2.0.0': [self.f2], + }, + results, + ) + + def test_files_stable_from_master(self): + self.repo.git('checkout', '2.0.0') + self.repo.git('checkout', '-b', 'stable/2') + f21 = self._add_notes_file('slug21') + self.repo.git('checkout', 'master') + log_text = self.repo.git('log', '--pretty=%x00%H %d', '--name-only', + 'stable/2') + self.addDetail('git log', text_content(log_text)) + self.c.override( + branch='stable/2', + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + { + '2.0.0': [self.f2], + '2.0.0-1': [f21], + }, + results, + ) + + def test_files_stable_from_master_no_stop_base(self): + self.repo.git('checkout', '2.0.0') + self.repo.git('checkout', '-b', 'stable/2') + f21 = self._add_notes_file('slug21') + self.repo.git('checkout', 'master') + log_text = self.repo.git('log', '--pretty=%x00%H %d', '--name-only', + 'stable/2') + self.addDetail('git log', text_content(log_text)) + self.c.override( + branch='stable/2', + ) + self.c.override( + stop_at_branch_base=False, + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + { + '1.0.0': [self.f1], + '2.0.0': [self.f2], + '2.0.0-1': [f21], + }, + results, + ) + + def test_pre_release_branch_no_collapse(self): + f4 = self._add_notes_file('slug4') + self.repo.git('tag', '-s', '-m', 'pre-release', '4.0.0.0rc1') + # Add a commit on master after the tag + self._add_notes_file('slug5') + # Move back to the tag and create the branch + self.repo.git('checkout', '4.0.0.0rc1') + self.repo.git('checkout', '-b', 'stable/4') + # Create a commit on the branch + f41 = self._add_notes_file('slug41') + log_text = self.repo.git( + 'log', '--pretty=%x00%H %d', '--name-only', '--graph', + '--all', '--decorate', + ) + self.addDetail('git log', text_content(log_text)) + rev_list = self.repo.git('rev-list', '--first-parent', + '^stable/4', 'master') + self.addDetail('rev-list', text_content(rev_list)) + self.c.override( + branch='stable/4', + collapse_pre_releases=False, + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + { + '4.0.0.0rc1': [f4], + '4.0.0.0rc1-1': [f41], + }, + results, + ) + + def test_pre_release_branch_collapse(self): + f4 = self._add_notes_file('slug4') + self.repo.git('tag', '-s', '-m', 'pre-release', '4.0.0.0rc1') + # Add a commit on master after the tag + self._add_notes_file('slug5') + # Move back to the tag and create the branch + self.repo.git('checkout', '4.0.0.0rc1') + self.repo.git('checkout', '-b', 'stable/4') + # Create a commit on the branch + f41 = self._add_notes_file('slug41') + self.repo.git('tag', '-s', '-m', 'release', '4.0.0') + log_text = self.repo.git( + 'log', '--pretty=%x00%H %d', '--name-only', '--graph', + '--all', '--decorate', + ) + self.addDetail('git log', text_content(log_text)) + rev_list = self.repo.git('rev-list', '--first-parent', + '^stable/4', 'master') + self.addDetail('rev-list', text_content(rev_list)) + self.c.override( + branch='stable/4', + collapse_pre_releases=True, + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + { + '4.0.0': [f4, f41], + }, + results, + ) + + def test_pre_release_note_before_branch(self): + f4 = self._add_notes_file('slug4') + self.repo.git('tag', '-s', '-m', 'beta', '4.0.0.0b1') + self.repo.add_file('not-a-release-note.txt') + self.repo.git('tag', '-s', '-m', 'pre-release', '4.0.0.0rc1') + # Add a commit on master after the tag + self._add_notes_file('slug5') + # Move back to the tag and create the branch + self.repo.git('checkout', '4.0.0.0rc1') + self.repo.git('checkout', '-b', 'stable/4') + # Create a commit on the branch + f41 = self._add_notes_file('slug41') + self.repo.git('tag', '-s', '-m', 'release', '4.0.0') + log_text = self.repo.git( + 'log', '--pretty=%x00%H %d', '--name-only', '--graph', + '--all', '--decorate', + ) + self.addDetail('git log', text_content(log_text)) + rev_list = self.repo.git('rev-list', '--first-parent', + '^stable/4', 'master') + self.addDetail('rev-list', text_content(rev_list)) + self.c.override( + branch='stable/4', + collapse_pre_releases=True, + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + { + '4.0.0': [f4, f41], + }, + results, + ) + + def test_full_release_branch(self): + f4 = self._add_notes_file('slug4') + self.repo.git('tag', '-s', '-m', 'release', '4.0.0') + # Add a commit on master after the tag + self._add_notes_file('slug5') + # Move back to the tag and create the branch + self.repo.git('checkout', '4.0.0') + self.repo.git('checkout', '-b', 'stable/4') + # Create a commit on the branch + f41 = self._add_notes_file('slug41') + log_text = self.repo.git( + 'log', '--pretty=%x00%H %d', '--name-only', '--graph', + '--all', '--decorate', + ) + self.addDetail('git log', text_content(log_text)) + rev_list = self.repo.git('rev-list', '--first-parent', + '^stable/4', 'master') + self.addDetail('rev-list', text_content(rev_list)) + self.c.override( + branch='stable/4', + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + { + '4.0.0': [f4], + '4.0.0-1': [f41], + }, + results, + ) + + def test_branch_tip_of_master(self): + # We have branched from master, but not added any commits to + # master. + f4 = self._add_notes_file('slug4') + self.repo.git('tag', '-s', '-m', 'release', '4.0.0') + self.repo.git('checkout', '-b', 'stable/4') + # Create a commit on the branch + f41 = self._add_notes_file('slug41') + f42 = self._add_notes_file('slug42') + log_text = self.repo.git( + 'log', '--pretty=%x00%H %d', '--name-only', '--graph', + '--all', '--decorate', + ) + self.addDetail('git log', text_content(log_text)) + rev_list = self.repo.git('rev-list', '--first-parent', + '^stable/4', 'master') + self.addDetail('rev-list', text_content(rev_list)) + self.c.override( + branch='stable/4', + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + { + '4.0.0': [f4], + '4.0.0-2': [f41, f42], + }, + results, + ) + + def test_branch_no_more_commits(self): + # We have branched from master, but not added any commits to + # our branch or to master. + f4 = self._add_notes_file('slug4') + self.repo.git('tag', '-s', '-m', 'release', '4.0.0') + self.repo.git('checkout', '-b', 'stable/4') + # Create a commit on the branch + log_text = self.repo.git( + 'log', '--pretty=%x00%H %d', '--name-only', '--graph', + '--all', '--decorate', + ) + self.addDetail('git log', text_content(log_text)) + rev_list = self.repo.git('rev-list', '--first-parent', + '^stable/4', 'master') + self.addDetail('rev-list', text_content(rev_list)) + self.c.override( + branch='stable/4', + ) + self.scanner = scanner.Scanner(self.c) + raw_results = self.scanner.get_notes_by_version() + results = { + k: [f for (f, n) in v] + for (k, v) in raw_results.items() + } + self.assertEqual( + { + '4.0.0': [f4], + }, + results, + ) + + def test_remote_branches(self): + self.repo.git('checkout', '2.0.0') + self.repo.git('checkout', '-b', 'stable/2') + self.repo.git('checkout', 'master') + scanner1 = scanner.Scanner(self.c) + head1 = scanner1._get_ref('stable/2') + self.assertIsNotNone(head1) + print('head1', head1) + # Create a second repository by cloning the first. + print(utils.check_output( + ['git', 'clone', self.reporoot, 'reporoot2'], + cwd=self.temp_dir, + )) + reporoot2 = os.path.join(self.temp_dir, 'reporoot2') + print(utils.check_output( + ['git', 'remote', 'update'], + cwd=reporoot2, + )) + print(utils.check_output( + ['git', 'remote', '-v'], + cwd=reporoot2, + )) + print(utils.check_output( + ['find', '.git/refs'], + cwd=reporoot2, + )) + print(utils.check_output( + ['git', 'branch', '-a'], + cwd=reporoot2, + )) + c2 = config.Config(reporoot2) + scanner2 = scanner.Scanner(c2) + head2 = scanner2._get_ref('origin/stable/2') + self.assertIsNotNone(head2) + self.assertEqual(head1, head2) + + def test_remote_branch_without_prefix(self): + self.repo.git('checkout', '2.0.0') + self.repo.git('checkout', '-b', 'stable/2') + self.repo.git('checkout', 'master') + scanner1 = scanner.Scanner(self.c) + head1 = scanner1._get_ref('stable/2') + self.assertIsNotNone(head1) + print('head1', head1) + # Create a second repository by cloning the first. + print(utils.check_output( + ['git', 'clone', self.reporoot, 'reporoot2'], + cwd=self.temp_dir, + )) + reporoot2 = os.path.join(self.temp_dir, 'reporoot2') + print(utils.check_output( + ['git', 'remote', 'update'], + cwd=reporoot2, + )) + print(utils.check_output( + ['git', 'remote', '-v'], + cwd=reporoot2, + )) + print(utils.check_output( + ['find', '.git/refs'], + cwd=reporoot2, + )) + print(utils.check_output( + ['git', 'branch', '-a'], + cwd=reporoot2, + )) + c2 = config.Config(reporoot2) + scanner2 = scanner.Scanner(c2) + head2 = scanner2._get_ref('stable/2') + self.assertIsNotNone(head2) + self.assertEqual(head1, head2) + + +class ScanStopPointPrereleaseVersionsTest(Base): + + def setUp(self): + super(ScanStopPointPrereleaseVersionsTest, self).setUp() + self.scanner = scanner.Scanner(self.c) + self._make_python_package() + self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'first series', '1.0.0.0rc1') + self.repo.git('checkout', '-b', 'stable/a') + self._add_notes_file('slug2') + self._add_notes_file('slug3') + self.repo.git('tag', '-s', '-m', 'second tag', '1.0.0') + self.repo.git('checkout', 'master') + self._add_notes_file('slug4') + self._add_notes_file('slug5') + self.repo.git('tag', '-s', '-m', 'second series', '2.0.0.0b3') + self._add_notes_file('slug6') + self._add_notes_file('slug7') + self.repo.git('tag', '-s', '-m', 'second tag', '2.0.0.0rc1') + self.repo.git('checkout', '-b', 'stable/b') + self._add_notes_file('slug8') + self._add_notes_file('slug9') + self.repo.git('tag', '-s', '-m', 'third tag', '2.0.0') + self.repo.git('checkout', 'master') + + def test_beta_collapse(self): + self.assertEqual( + '1.0.0.0rc1', + self.scanner._find_scan_stop_point( + '2.0.0.0b3', ['2.0.0.0b3', '1.0.0.0rc1'], + True, 'master'), + ) + + def test_rc_collapse_master(self): + self.assertEqual( + '1.0.0.0rc1', + self.scanner._find_scan_stop_point( + '2.0.0.0rc1', ['2.0.0.0rc1', '2.0.0.0b3', '1.0.0.0rc1'], + True, 'master'), + ) + + def test_rc_collapse_branch(self): + self.assertEqual( + '1.0.0.0rc1', + self.scanner._find_scan_stop_point( + '2.0.0.0rc1', ['2.0.0.0rc1', '2.0.0.0b3', '1.0.0.0rc1'], + True, 'stable/b'), + ) + + def test_rc_no_collapse(self): + self.assertEqual( + '2.0.0.0b3', + self.scanner._find_scan_stop_point( + '2.0.0.0rc1', ['2.0.0.0rc1', '2.0.0.0b3', '1.0.0.0rc1'], + False, 'master'), + ) + + def test_stable_branch_with_collapse(self): + self.assertEqual( + '1.0.0.0rc1', + self.scanner._find_scan_stop_point( + '2.0.0', ['2.0.0', '2.0.0.0rc1', '2.0.0.0b3', '1.0.0.0rc1'], + True, 'stable/b'), + ) + + # def test_nova_newton(self): + # self.assertEqual( + # '13.0.0.0rc3', + # self.scanner._find_scan_stop_point( + # '14.0.0', + # [u'14.0.3', u'14.0.2', u'14.0.1', u'14.0.0.0rc2', + # u'14.0.0', u'14.0.0.0rc1', u'14.0.0.0b3', u'14.0.0.0b2', + # u'14.0.0.0b1', u'13.0.0.0rc3', u'13.0.0', u'13.0.0.0rc2', + # u'13.0.0.0rc1', u'13.0.0.0b3', u'13.0.0.0b2', u'13.0.0.0b1', + # u'12.0.0.0rc3', u'12.0.0', u'12.0.0.0rc2', u'12.0.0.0rc1', + # u'12.0.0.0b3', u'12.0.0.0b2', u'12.0.0.0b1', u'12.0.0a0', + # u'2015.1.0rc3', u'2015.1.0', u'2015.1.0rc2', u'2015.1.0rc1', + # u'2015.1.0b3', u'2015.1.0b2', u'2015.1.0b1', u'2014.2.rc2', + # u'2014.2', u'2014.2.rc1', u'2014.2.b3', u'2014.2.b2', + # u'2014.2.b1', u'2014.1.rc1', u'2014.1.b3', u'2014.1.b2', + # u'2014.1.b1', u'2013.2.rc1', u'2013.2.b3', u'2013.1.rc1', + # u'folsom-2', u'folsom-1', u'essex-1', u'diablo-2', + # u'diablo-1', u'2011.2', u'2011.2rc1', u'2011.2gamma1', + # u'2011.1rc1', u'0.9.0'], + # True), + # ) + + +class ScanStopPointRegularVersionsTest(Base): + + def setUp(self): + super(ScanStopPointRegularVersionsTest, self).setUp() + self.scanner = scanner.Scanner(self.c) + self._make_python_package() + self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'first series', '1.0.0') + self.repo.git('checkout', '-b', 'stable/a') + self._add_notes_file('slug2') + self._add_notes_file('slug3') + self.repo.git('tag', '-s', '-m', 'second tag', '1.0.1') + self.repo.git('checkout', 'master') + self._add_notes_file('slug4') + self._add_notes_file('slug5') + self.repo.git('tag', '-s', '-m', 'second series', '2.0.0') + self._add_notes_file('slug6') + self._add_notes_file('slug7') + self.repo.git('tag', '-s', '-m', 'second tag', '2.0.1') + self.repo.git('checkout', '-b', 'stable/b') + self._add_notes_file('slug8') + self._add_notes_file('slug9') + self.repo.git('tag', '-s', '-m', 'third tag', '2.0.2') + self.repo.git('checkout', 'master') + + def test_invalid_earliest_version(self): + self.assertIsNone( + self.scanner._find_scan_stop_point( + 'not.a.numeric.version', [], True, 'stable/b'), + ) + + def test_none(self): + self.assertIsNone( + self.scanner._find_scan_stop_point( + None, [], True, 'stable/b'), + ) + + def test_unknown_version(self): + self.assertIsNone( + self.scanner._find_scan_stop_point( + '2.0.2', [], True, 'stable/b'), + ) + + def test_only_version(self): + self.assertIsNone( + self.scanner._find_scan_stop_point( + '2.0.2', ['1.0.0'], True, 'stable/b'), + ) + + def test_find_prior_branch(self): + self.assertEqual( + '1.0.0', + self.scanner._find_scan_stop_point( + '2.0.2', ['2.0.2', '2.0.1', '2.0.0', '1.0.0'], + True, 'stable/b'), + ) + + +class GetRefTest(Base): + + def setUp(self): + super(GetRefTest, self).setUp() + self._make_python_package() + self.f1 = self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self.repo.git('branch', 'stable/foo') + self.repo.git('tag', 'bar-eol') + + def test_signed_tag(self): + self.scanner = scanner.Scanner(self.c) + ref = self.scanner._get_ref('1.0.0') + expected = self.scanner._repo.head() + self.assertEqual(expected, ref) + + def test_unsigned_tag(self): + self.scanner = scanner.Scanner(self.c) + ref = self.scanner._get_ref('bar-eol') + expected = self.scanner._repo.head() + self.assertEqual(expected, ref) + + def test_eol_tag_from_branch(self): + self.scanner = scanner.Scanner(self.c) + ref = self.scanner._get_ref('stable/bar') + expected = self.scanner._repo.head() + self.assertEqual(expected, ref) + + def test_head(self): + self.scanner = scanner.Scanner(self.c) + ref = self.scanner._get_ref(None) + expected = self.scanner._repo.head() + self.assertEqual(expected, ref) + + +class TagsTest(Base): + + def setUp(self): + super(TagsTest, self).setUp() + self._make_python_package() + self.f1 = self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self.f2 = self._add_notes_file('slug2') + self.repo.git('tag', '-s', '-m', 'first tag', '2.0.0') + self._add_notes_file('slug3') + self.repo.git('tag', '-s', '-m', 'first tag', '3.0.0') + + def test_master(self): + self.scanner = scanner.Scanner(self.c) + results = self.scanner._get_tags_on_branch(None) + self.assertEqual( + ['3.0.0', '2.0.0', '1.0.0'], + results, + ) + + def test_get_ref(self): + self.scanner = scanner.Scanner(self.c) + ref = self.scanner._get_ref('3.0.0') + expected = self.scanner._repo.head() + self.assertEqual(expected, ref) + + def test_not_master(self): + self.repo.git('checkout', '2.0.0') + self.repo.git('checkout', '-b', 'not-master') + self._add_notes_file('slug4') + self.repo.git('tag', '-s', '-m', 'not on master', '2.0.1') + self.repo.git('checkout', 'master') + self.scanner = scanner.Scanner(self.c) + results = self.scanner._get_tags_on_branch('not-master') + self.assertEqual( + ['2.0.1', '2.0.0', '1.0.0'], + results, + ) + + def test_unsigned(self): + self._add_notes_file('slug4') + self.repo.git('tag', '-m', 'first tag', '4.0.0') + self.scanner = scanner.Scanner(self.c) + results = self.scanner._get_tags_on_branch(None) + self.assertEqual( + ['4.0.0', '3.0.0', '2.0.0', '1.0.0'], + results, + ) + + def test_tagged_tag_annotated(self): + time.sleep(1) + self.repo.git('tag', '-s', '-m', 'fourth tag', '4.0.0', '3.0.0') + self.scanner = scanner.Scanner(self.c) + results = self.scanner._get_tags_on_branch(None) + self.assertEqual( + ['3.0.0', '4.0.0', '2.0.0', '1.0.0'], + results, + ) + + def test_tagged_tag_lightweight(self): + time.sleep(1) + self.repo.git('tag', '-m', 'fourth tag', '4.0.0', '3.0.0') + self.scanner = scanner.Scanner(self.c) + results = self.scanner._get_tags_on_branch(None) + self.assertEqual( + ['3.0.0', '4.0.0', '2.0.0', '1.0.0'], + results, + ) + + +class VersionTest(Base): + + def setUp(self): + super(VersionTest, self).setUp() + self._make_python_package() + self.f1 = self._add_notes_file('slug1') + self.repo.git('tag', '-s', '-m', 'first tag', '1.0.0') + self.f2 = self._add_notes_file('slug2') + self.repo.git('tag', '-s', '-m', 'second tag', '2.0.0') + self._add_notes_file('slug3') + self.repo.git('tag', '-s', '-m', 'third tag', '3.0.0') + + def test_tagged_head(self): + self.scanner = scanner.Scanner(self.c) + results = self.scanner._get_current_version(None) + self.assertEqual( + '3.0.0', + results, + ) + + def test_head_after_tag(self): + self._add_notes_file('slug4') + self.scanner = scanner.Scanner(self.c) + results = self.scanner._get_current_version(None) + self.assertEqual( + '3.0.0-1', + results, + ) + + def test_multiple_tags(self): + # The timestamp resolution appears to be 1 second, so sleep to + # ensure distinct timestamps for the 2 tags. In practice it is + # unlikely that anything could apply 2 signed tags within a + # single second (certainly not a person). + time.sleep(1) + self.repo.git('tag', '-s', '-m', 'fourth tag', '4.0.0') + self.scanner = scanner.Scanner(self.c) + results = self.scanner._get_current_version(None) + self.assertEqual( + '4.0.0', + results, + ) + + +class AggregateChangesTest(Base): + + def setUp(self): + super(AggregateChangesTest, self).setUp() + self.aggregator = scanner._ChangeAggregator() + + def test_ignore(self): + entry = mock.Mock() + n = self.get_note_num() + name = 'prefix/add-%016x' % n # no .yaml extension + entry.commit.id = 'commit-id' + changes = [ + diff_tree.TreeChange( + type=diff_tree.CHANGE_ADD, + old=objects.TreeEntry(path=None, mode=None, sha=None), + new=objects.TreeEntry( + path=name.encode('utf-8'), + mode='0222', + sha='not-a-hash', + ) + ) + ] + results = self.aggregator.aggregate_changes(entry, changes) + self.assertEqual( + [], + results, + ) + + def test_add(self): + entry = mock.Mock() + n = self.get_note_num() + name = 'prefix/add-%016x.yaml' % n + entry.commit.id = 'commit-id' + changes = [ + diff_tree.TreeChange( + type=diff_tree.CHANGE_ADD, + old=objects.TreeEntry(path=None, mode=None, sha=None), + new=objects.TreeEntry( + path=name.encode('utf-8'), + mode='0222', + sha='not-a-hash', + ) + ) + ] + results = list(self.aggregator.aggregate_changes(entry, changes)) + self.assertEqual( + [('%016x' % n, 'add', name, 'commit-id')], + results, + ) + + def test_add_multiple_after_delete(self): + # Adding multiple files in one commit using the same UID but + # different slug after we have seen a delete for the same UID + # causes the files to be ignored. + entry = mock.Mock() + n = self.get_note_num() + uid = '%016x' % n + changes = [] + for i in range(2): + name = 'prefix/add%d-%s.yaml' % (i, uid) + entry.commit.id = 'commit-id' + changes.append( + diff_tree.TreeChange( + type=diff_tree.CHANGE_ADD, + old=objects.TreeEntry(path=None, mode=None, sha=None), + new=objects.TreeEntry( + path=name.encode('utf-8'), + mode='0222', + sha='not-a-hash', + ) + ) + ) + # Set up the aggregator as though it had already seen a delete + # operation. Since the scan happens in reverse chronological + # order, the delete would have happened after the add, and we + # can ignore the files because the error has been corrected in + # a later patch. + self.aggregator._deleted_bad_uids.add(uid) + results = list(self.aggregator.aggregate_changes(entry, changes)) + self.assertEqual([], results) + + def test_add_multiple_without_delete(self): + # Adding multiple files in one commit using the same UID but + # different slug without a delete operation causes an + # exception. + entry = mock.Mock() + n = self.get_note_num() + uid = '%016x' % n + changes = [] + for i in range(2): + name = 'prefix/add%d-%s.yaml' % (i, uid) + entry.commit.id = 'commit-id' + changes.append( + diff_tree.TreeChange( + type=diff_tree.CHANGE_ADD, + old=objects.TreeEntry(path=None, mode=None, sha=None), + new=objects.TreeEntry( + path=name.encode('utf-8'), + mode='0222', + sha='not-a-hash', + ) + ) + ) + + # aggregate_changes() is a generator, so we have to wrap it in + # list() to process the data, so we need a little temporary + # function to do that and pass to assertRaises(). + def get_results(): + return list(self.aggregator.aggregate_changes(entry, changes)) + + self.assertRaises( + ValueError, + get_results, + ) + + def test_delete(self): + entry = mock.Mock() + n = self.get_note_num() + name = 'prefix/delete-%016x.yaml' % n + entry.commit.id = 'commit-id' + changes = [ + diff_tree.TreeChange( + type=diff_tree.CHANGE_DELETE, + old=objects.TreeEntry( + path=name.encode('utf-8'), + mode='0222', + sha='not-a-hash', + ), + new=objects.TreeEntry(path=None, mode=None, sha=None) + ) + ] + results = list(self.aggregator.aggregate_changes(entry, changes)) + self.assertEqual( + [('%016x' % n, 'delete', name, entry.commit.id)], + results, + ) + + def test_delete_multiple(self): + # Delete multiple files in one commit using the same UID but + # different slug. + entry = mock.Mock() + n = self.get_note_num() + changes = [] + expected = [] + for i in range(2): + name = 'prefix/delete%d-%016x.yaml' % (i, n) + entry.commit.id = 'commit-id' + changes.append( + diff_tree.TreeChange( + type=diff_tree.CHANGE_DELETE, + old=objects.TreeEntry( + path=name.encode('utf-8'), + mode='0222', + sha='not-a-hash', + ), + new=objects.TreeEntry(path=None, mode=None, sha=None), + ) + ) + expected.append(('%016x' % n, 'delete', name, 'commit-id')) + results = list(self.aggregator.aggregate_changes(entry, changes)) + self.assertEqual(expected, results) + + def test_change(self): + entry = mock.Mock() + n = self.get_note_num() + name = 'prefix/change-%016x.yaml' % n + entry.commit.id = 'commit-id' + changes = [ + diff_tree.TreeChange( + type=diff_tree.CHANGE_MODIFY, + old=objects.TreeEntry( + path=name.encode('utf-8'), + mode='0222', + sha='old-sha', + ), + new=objects.TreeEntry( + path=name.encode('utf-8'), + mode='0222', + sha='new-sha', + ), + ) + ] + results = list(self.aggregator.aggregate_changes(entry, changes)) + self.assertEqual( + [('%016x' % n, 'modify', name, 'commit-id')], + results, + ) + + def test_add_then_delete(self): + entry = mock.Mock() + n = self.get_note_num() + new_name = 'prefix/new-%016x.yaml' % n + old_name = 'prefix/old-%016x.yaml' % n + entry.commit.id = 'commit-id' + changes = [ + diff_tree.TreeChange( + type=diff_tree.CHANGE_ADD, + old=objects.TreeEntry(path=None, mode=None, sha=None), + new=objects.TreeEntry( + path=new_name.encode('utf-8'), + mode='0222', + sha='new-hash', + ) + ), + diff_tree.TreeChange( + type=diff_tree.CHANGE_DELETE, + old=objects.TreeEntry( + path=old_name.encode('utf-8'), + mode='0222', + sha='old-hash', + ), + new=objects.TreeEntry(path=None, mode=None, sha=None) + ) + ] + results = list(self.aggregator.aggregate_changes(entry, changes)) + self.assertEqual( + [('%016x' % n, 'rename', old_name, new_name, 'commit-id')], + results, + ) + + def test_delete_then_add(self): + entry = mock.Mock() + n = self.get_note_num() + new_name = 'prefix/new-%016x.yaml' % n + old_name = 'prefix/old-%016x.yaml' % n + entry.commit.id = 'commit-id' + changes = [ + diff_tree.TreeChange( + type=diff_tree.CHANGE_DELETE, + old=objects.TreeEntry( + path=old_name.encode('utf-8'), + mode='0222', + sha='old-hash', + ), + new=objects.TreeEntry(path=None, mode=None, sha=None) + ), + diff_tree.TreeChange( + type=diff_tree.CHANGE_ADD, + old=objects.TreeEntry(path=None, mode=None, sha=None), + new=objects.TreeEntry( + path=new_name.encode('utf-8'), + mode='0222', + sha='new-hash', + ) + ), + ] + results = list(self.aggregator.aggregate_changes(entry, changes)) + self.assertEqual( + [('%016x' % n, 'rename', old_name, new_name, 'commit-id')], + results, + ) + + def test_tree_changes(self): + # Under some conditions when dulwich sees merge commits, + # changes() returns a list with nested lists. See commit + # cc11da6dcfb1dbaa015e9804b6a23f7872380c1b in this repo for an + # example. + entry = mock.Mock() + n = self.get_note_num() + # The files modified by the commit are actually + # reno/scanner.py, but the fake names are used in this test to + # comply with the rest of the configuration for the scanner. + old_name = 'prefix/old-%016x.yaml' % n + entry.commit.id = 'commit-id' + changes = [[ + diff_tree.TreeChange( + type='modify', + old=diff_tree.TreeEntry( + path=old_name.encode('utf-8'), + mode=33188, + sha=b'8247dfdd116fd0e3cc4ba32328e4a3eafd227de6', + ), + new=diff_tree.TreeEntry( + path=old_name.encode('utf-8'), + mode=33188, + sha=b'611f3663f54afb1f018a6a8680b6488da50ac340', + ), + ), + diff_tree.TreeChange( + type='modify', + old=diff_tree.TreeEntry( + path=old_name.encode('utf-8'), + mode=33188, + sha=b'ecb7788066eefa9dc8f110b56360efe7b1140b84', + ), + new=diff_tree.TreeEntry( + path=old_name.encode('utf-8'), + mode=33188, + sha=b'611f3663f54afb1f018a6a8680b6488da50ac340', + ), + ), + ]] + results = list(self.aggregator.aggregate_changes(entry, changes)) + self.assertEqual( + [('%016x' % n, 'modify', old_name, 'commit-id'), + ('%016x' % n, 'modify', old_name, 'commit-id')], + results, + ) diff -Nru python-reno-1.3.0/reno/tests/test_utils.py python-reno-2.5.0/reno/tests/test_utils.py --- python-reno-1.3.0/reno/tests/test_utils.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/tests/test_utils.py 2017-07-19 09:44:08.000000000 +0000 @@ -12,12 +12,12 @@ # License for the specific language governing permissions and limitations # under the License. -from reno.tests import base -from reno import utils - import mock import six +from reno.tests import base +from reno import utils + class TestGetRandomString(base.TestCase): diff -Nru python-reno-1.3.0/reno/utils.py python-reno-2.5.0/reno/utils.py --- python-reno-1.3.0/reno/utils.py 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/reno/utils.py 2017-07-19 09:44:08.000000000 +0000 @@ -11,16 +11,13 @@ # under the License. import binascii +import logging +import os import os.path import random import subprocess -from reno import defaults - - -def get_notes_dir(args): - """Return the path to the release notes directory.""" - return os.path.join(args.relnotesdir, defaults.NOTES_SUBDIR) +LOG = logging.getLogger(__name__) def get_random_string(nbytes=8): @@ -41,5 +38,17 @@ def check_output(*args, **kwds): """Unicode-aware wrapper for subprocess.check_output""" - raw = subprocess.check_output(*args, **kwds) - return raw.decode('utf-8') + process = subprocess.Popen(stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + *args, **kwds) + output, errors = process.communicate() + retcode = process.poll() + if errors: + LOG.debug('ran: %s', ' '.join(*args)) + LOG.debug('returned: %s', retcode) + LOG.debug('error output: %s', errors.rstrip()) + LOG.debug('regular output: %s', output.rstrip()) + if retcode: + LOG.debug('raising error') + raise subprocess.CalledProcessError(retcode, args, output=output) + return output.decode('utf-8') diff -Nru python-reno-1.3.0/requirements.txt python-reno-2.5.0/requirements.txt --- python-reno-1.3.0/requirements.txt 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/requirements.txt 2017-07-19 09:44:08.000000000 +0000 @@ -2,6 +2,7 @@ # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. -pbr<2.0,>=1.4 -Babel>=1.3 +pbr>=1.4 PyYAML>=3.1.0 +six>=1.9.0 +dulwich>=0.15.0 # Apache-2.0 diff -Nru python-reno-1.3.0/setup.cfg python-reno-2.5.0/setup.cfg --- python-reno-1.3.0/setup.cfg 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/setup.cfg 2017-07-19 09:44:08.000000000 +0000 @@ -5,7 +5,7 @@ README.rst author = OpenStack author-email = openstack-dev@lists.openstack.org -home-page = http://www.openstack.org/ +home-page = https://docs.openstack.org/reno/latest/ classifier = Environment :: OpenStack Intended Audience :: Information Technology @@ -16,8 +16,7 @@ Programming Language :: Python :: 2 Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 - Programming Language :: Python :: 3.3 - Programming Language :: Python :: 3.4 + Programming Language :: Python :: 3.5 [files] packages = @@ -29,12 +28,14 @@ [extras] sphinx = - sphinx>=1.1.2,!=1.2.0,!=1.3b1,<1.3 + sphinx>=1.5.1,!=1.6.1 # BSD + docutils>=0.11 # OSI-Approved Open Source, Public Domain [build_sphinx] source-dir = doc/source build-dir = doc/build all_files = 1 +warning-is-error = 1 [upload_sphinx] upload-dir = doc/build/html @@ -47,8 +48,3 @@ domain = reno output_dir = reno/locale input_file = reno/locale/reno.pot - -[extract_messages] -keywords = _ gettext ngettext l_ lazy_gettext -mapping_file = babel.cfg -output_file = reno/locale/reno.pot diff -Nru python-reno-1.3.0/test-requirements.txt python-reno-2.5.0/test-requirements.txt --- python-reno-1.3.0/test-requirements.txt 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/test-requirements.txt 2017-07-19 09:44:08.000000000 +0000 @@ -2,15 +2,13 @@ # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. -hacking<0.11,>=0.10.0 +hacking>=0.12.0,!=0.13.0,<0.14 # Apache-2.0 mock>=1.2 coverage>=3.6 -discover python-subunit>=0.0.18 -oslosphinx>=2.5.0 # Apache-2.0 -oslotest>=1.10.0 # Apache-2.0 +openstackdocstheme>=1.11.0 # Apache-2.0 testrepository>=0.0.18 testscenarios>=0.4 testtools>=1.4.0 diff -Nru python-reno-1.3.0/tox.ini python-reno-2.5.0/tox.ini --- python-reno-1.3.0/tox.ini 2016-01-08 14:11:23.000000000 +0000 +++ python-reno-2.5.0/tox.ini 2017-07-19 09:44:08.000000000 +0000 @@ -1,6 +1,6 @@ [tox] minversion = 1.6 -envlist = py34,py27,pep8 +envlist = py35,py27,pep8 skipsdist = True [testenv] @@ -11,10 +11,14 @@ deps = -r{toxinidir}/test-requirements.txt .[sphinx] -commands = python setup.py test --slowest --testr-args='{posargs}' +commands = + python setup.py test --slowest --coverage --coverage-package-name=reno --testr-args='{posargs}' + coverage report --show-missing [testenv:pep8] -commands = flake8 +commands = + flake8 + reno -q lint [testenv:venv] commands = {posargs} @@ -34,4 +38,4 @@ show-source = True ignore = E123,E125 builtins = _ -exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build +exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build