[PATCH RFC] contrib: script to generate release notes from commit messages

Gregory Szorc gregory.szorc at gmail.com
Fri Feb 3 07:56:29 UTC 2017


# HG changeset patch
# User Gregory Szorc <gregory.szorc at gmail.com>
# Date 1486108530 28800
#      Thu Feb 02 23:55:30 2017 -0800
# Node ID 5fe78521b9cb553b9a7c6bd4d96576a35b8d3517
# Parent  abf029200e198878a4576a87e095bd8d77d9cea9
contrib: script to generate release notes from commit messages

Per discussion on the mailing list, we want better release notes.

This patch introduces a script for producing better release notes.

The script looks for "<version>.rst" files in a "relnotes" directory.
These files contain formatted release notes which are comprised of
bullet points and sub-sections in various categories.

The script reads commit messages of non-public changesets and
extracts release notes within. These release notes are added to
the latest release notes file found in the "relnotes" directory.

The general idea is that patch authors declare release notes in
commit messages. When a patch is queued (or published), its commit
message will be parsed and the appropriate release notes file will be
updated as appropriate.

This patch is incomplete. Known deficiencies and open issues include:

* Parsing of multiple line notes does not work
* Parsing and formatting of sub-sections is not implemented
* Are sub-sections needed (I think they are because major features
  deserve more than a bullet point)
* The syntax for declaring release notes should likely be bikeshedded
* Bikeshed the sections, their titles, and their identifiers.
* Need moar tests
* Figure out how/when the script is executed. Perhaps a merge driver?

diff --git a/contrib/generate-release-notes b/contrib/generate-release-notes
new file mode 100755
--- /dev/null
+++ b/contrib/generate-release-notes
@@ -0,0 +1,270 @@
+#!/usr/bin/env python
+# generate-release-notes -- populate release notes from commit messages
+#
+# Copyright 2017 Gregory Szorc <gregory.szorc at gmail.com>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+
+"""
+Release notes files live in ``relnotes/<version>.rst``.
+
+Release notes files have sections defined by ``SECTIONS`` below.
+
+If commit messages have lines beginning with ``.. <section>::``, the content
+that follows is populated in that section of the release notes.
+
+The ``feature`` section is special in that it can have a title. e.g.
+``.. feature:: this is a new feature``. This will get formatted as its
+own sub-section in the release notes.
+
+"""
+
+from __future__ import absolute_import, print_function
+
+import os
+import subprocess
+import textwrap
+import uuid
+
+# For testing.
+if 'FAKESRCDIR' in os.environ:
+    SRCDIR = os.environ['FAKESRCDIR']
+else:
+    SRCDIR = os.path.normpath(os.path.join(os.path.dirname(__file__), '..'))
+
+# (header, internal, has sub-sections)
+SECTIONS = [
+    ('New Features', 'features', True,),
+    ('Backwards Compatibility Changes', 'bc', False),
+    ('Fixes', 'fix', False),
+    ('Performance Improvements', 'perf', False),
+    ('API Changes', 'api', False),
+]
+
+def relnotespath():
+    """Obtain the path to the current relnotes file.
+
+    The current relnotes file is the highest versioned file in the relnotes
+    directory.
+    """
+    files = os.listdir(os.path.join(SRCDIR, 'relnotes'))
+    assert all(f.endswith('.rst') for f in files)
+    versions = [map(int, f[:-4].split('.')) for f in files]
+
+    latest = '.'.join(map(str, list(sorted(versions))[-1]))
+
+    return os.path.join(SRCDIR, 'relnotes', '%s.rst' % latest)
+
+def unpublishedcommitmessages():
+    """Obtain commit messages for unpublished commits."""
+
+    env = dict(os.environ)
+    env['HGMODULEPOLICY'] = 'py'
+
+    sep = str(uuid.uuid4())
+
+    args = [
+        'hg',
+        'log',
+        '-r', '::. and not public()',
+        '-T', '{desc}%s' % sep
+    ]
+
+    p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+                         cwd=SRCDIR, env=env)
+    out, err = p.communicate()
+    res = p.wait()
+    if res:
+        raise Exception('error running `hg log`')
+
+    return out.split(sep)[:-1]
+
+def parsenotes(path):
+    notes = {}
+
+    for header, section, hassub in SECTIONS:
+        notes[section] = []
+
+    with open(path, 'rb') as fh:
+        lines = [l.rstrip() for l in fh]
+
+    i = 0
+    section = None
+    subsection = None
+    itemlines = []
+    while i < len(lines):
+        line = lines[i]
+
+        if not line:
+            # Flush existing entry.
+            if itemlines:
+                notes[section].append(itemlines)
+                itemlines = []
+            i += 1
+            continue
+
+        # Section header.
+        if i + 1 < len(lines) and lines[i + 1].startswith('='):
+            section = None
+            subsection = None
+            for header, sec, hassub in SECTIONS:
+                if line == header:
+                    section = sec
+
+            if not section:
+                raise Exception('Unknown section in %s: %s' % (path, line))
+
+            i += 2
+            continue
+
+        # Sub-section header.
+        if i + 1 < len(lines) and lines[i + 1].startswith('-'):
+            subsection = line
+
+        assert section
+
+        # New item.
+        if line.startswith('*'):
+            # Flush existing entry.
+            if itemlines:
+                if subsection:
+                    notes[section].append((subsection, itemlines))
+                else:
+                    notes[section].append(itemlines)
+
+                itemlines = []
+
+            assert line.startswith('* ')
+            itemlines.append(line[2:])
+            i += 1
+            continue
+
+        # Item continuation.
+        if line.startswith('  '):
+            itemlines.append(line[2:])
+            i += 1
+            continue
+
+        raise Exception('unexpected content in release notes file: %s' % line)
+
+    return notes
+
+def serializenotes(notes):
+    lines = []
+
+    for header, section, hassub in SECTIONS:
+        if not notes[section]:
+            continue
+
+        lines.append(header)
+        lines.append('=' * len(header))
+        lines.append('')
+
+        for itemlines in notes[section]:
+            paralines = []
+            initial = [True]
+
+            def flush():
+                if not paralines:
+                    return
+
+                if initial[0]:
+                    indent = '* '
+                    initial[0] = False
+                else:
+                    lines.append('')
+                    indent = '  '
+
+                wrapper = textwrap.TextWrapper(initial_indent=indent,
+                                               subsequent_indent='  ',
+                                               width=78)
+                lines.extend(wrapper.wrap(' '.join(paralines)))
+                paralines[:] = []
+
+
+            for line in itemlines:
+                if not line:
+                    flush()
+                    continue
+
+                paralines.append(line)
+
+            flush()
+
+        lines.append('')
+
+    return '\n'.join(lines)
+
+def updaterelnotes():
+    path = relnotespath()
+    notes = parsenotes(path)
+    messages = unpublishedcommitmessages()
+
+    print('updating %s with content from %d changesets' % (path, len(messages)))
+
+    for message in messages:
+        lines = message.splitlines()
+
+        state = {
+            'header': None,
+            'section': None,
+            'lines': [],
+        }
+
+        def flush():
+            if not state['section']:
+                return
+
+            lines = state['lines']
+            while not lines[0]:
+                lines = lines[1:]
+            while not lines[-1]:
+                lines = lines[:-1]
+
+            # We've already seen this release note. Ignore it.
+            if lines not in notes[state['section']]:
+                notes[state['section']].append(lines)
+
+            state['header'] = None
+            state['section'] = None
+            state['lines'] = []
+
+        i = 0
+        while i < len(lines):
+            line = lines[i]
+            issection = False
+            for header, section, hassub in SECTIONS:
+                if hassub:
+                    if line.startswith('.. %s:: ' % section):
+                        flush()
+                        section = line[3:-2]
+                        state['section'] = section
+                        state['header'] = line[len(section) + 5:]
+                        issection = True
+                        break
+                elif line == '.. %s::' % section:
+                    flush()
+                    state['section'] = line[3:-2]
+                    state['header'] = None
+                    issection = True
+                    break
+
+            # Found a new section header.
+            if issection:
+                i += 1
+                continue
+
+            # Add this content to active section.
+            if state['section']:
+                state['lines'].append(line)
+
+            i += 1
+
+        flush()
+
+    with open(path, 'wb') as fh:
+        fh.write(serializenotes(notes))
+
+if __name__ == '__main__':
+    updaterelnotes()
diff --git a/tests/test-generate-release-notes.t b/tests/test-generate-release-notes.t
new file mode 100644
--- /dev/null
+++ b/tests/test-generate-release-notes.t
@@ -0,0 +1,145 @@
+Create a fake repo with a relnotes directory and commits
+
+  $ hg init repo0
+  $ export FAKESRCDIR=$TESTTMP/repo0
+  $ cd repo0
+  $ mkdir relnotes
+  $ touch relnotes/4.1.rst
+  $ hg commit -A -m 'add relnotes 4.1'
+  adding relnotes/4.1.rst
+
+4.1.rst should be used for relnotes if only available file
+
+  $ $TESTDIR/../contrib/generate-release-notes
+  updating $TESTTMP/repo0/relnotes/4.1.rst with content from 1 changesets
+
+4.1.1.rst is used over 4.1.rst
+
+  $ touch relnotes/4.1.1.rst
+  $ hg commit -A -m 'add relnotes 4.1.1'
+  adding relnotes/4.1.1.rst
+  $ $TESTDIR/../contrib/generate-release-notes
+  updating $TESTTMP/repo0/relnotes/4.1.1.rst with content from 2 changesets
+
+4.2 is used over 4.1.1
+
+  $ touch relnotes/4.2.rst
+  $ hg commit -A -m 'add relnotes 4.2'
+  adding relnotes/4.2.rst
+  $ $TESTDIR/../contrib/generate-release-notes
+  updating $TESTTMP/repo0/relnotes/4.2.rst with content from 3 changesets
+
+A fix with a single line is documented with .. fix::
+
+  $ touch fix1
+  $ hg commit -A -l - << EOF
+  > summary line
+  > 
+  > .. fix::
+  > 
+  > this is a simple fix with a single line
+  > EOF
+  adding fix1
+
+  $ $TESTDIR/../contrib/generate-release-notes
+  updating $TESTTMP/repo0/relnotes/4.2.rst with content from 4 changesets
+
+  $ cat relnotes/4.2.rst
+  Fixes
+  =====
+  
+  * this is a simple fix with a single line
+
+Another fix is appended properly
+
+  $ touch fix2
+  $ hg commit -A -l - << EOF
+  > summary line
+  > 
+  > .. fix::
+  > 
+  > this is another simple fix
+  > EOF
+  adding fix2
+
+  $ $TESTDIR/../contrib/generate-release-notes
+  updating $TESTTMP/repo0/relnotes/4.2.rst with content from 5 changesets
+
+  $ cat relnotes/4.2.rst
+  Fixes
+  =====
+  
+  * this is a simple fix with a single line
+  * this is another simple fix
+
+An entry with a long line is line wrapped
+
+  $ touch fix3
+  $ hg commit -A -l - << EOF
+  > summary line
+  > .. fix::
+  > 
+  > This is another fix. It has a long line that should wrap when written to the release notes file.
+  > EOF
+  adding fix3
+  $ $TESTDIR/../contrib/generate-release-notes
+  updating $TESTTMP/repo0/relnotes/4.2.rst with content from 6 changesets
+
+  $ cat relnotes/4.2.rst
+  Fixes
+  =====
+  
+  * this is a simple fix with a single line
+  * this is another simple fix
+  * This is another fix. It has a long line that should wrap when written to the
+    release notes file.
+
+An entry with multiple lines is recorded properly
+
+  $ touch fix4
+  $ hg commit -A -l - << EOF
+  > summary line
+  > 
+  > .. fix::
+  > 
+  > This is the first line of a fix.
+  > And the second line.
+  > 
+  > And the line after a blank line.
+  > EOF
+  adding fix4
+
+  $ $TESTDIR/../contrib/generate-release-notes
+  updating $TESTTMP/repo0/relnotes/4.2.rst with content from 7 changesets
+
+  $ cat relnotes/4.2.rst
+  Fixes
+  =====
+  
+  * this is a simple fix with a single line
+  * this is another simple fix
+  * This is another fix. It has a long line that should wrap when written to the
+    release notes file.
+  * This is the first line of a fix. And the second line.
+  
+    And the line after a blank line.
+
+Generating again will preserve original content
+TODO fix the parser
+
+  $ $TESTDIR/../contrib/generate-release-notes
+  updating $TESTTMP/repo0/relnotes/4.2.rst with content from 7 changesets
+  $ cat relnotes/4.2.rst
+  Fixes
+  =====
+  
+  * this is a simple fix with a single line
+  * this is another simple fix
+  * This is another fix. It has a long line that should wrap when written to the
+    release notes file.
+  * This is the first line of a fix. And the second line.
+  * This is another fix. It has a long line that should wrap when written to the
+    release notes file.
+  * This is the first line of a fix. And the second line.
+  
+    And the line after a blank line.


More information about the Mercurial-devel mailing list