@@ -0,0 +1,435 @@
+# Copyright 2017-present Gregory Szorc <gregory.szorc@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.
+
+"""generate release notes from commit messages (EXPERIMENTAL)
+
+It is common to maintain files detailing changes in a project between
+releases. Maintaining these files can be difficult and time consuming.
+The :hg:`releasenotes` command provided by this extension makes the
+process simpler by automating it.
+"""
+
+from __future__ import absolute_import
+
+import errno
+import re
+import sys
+import textwrap
+
+from mercurial.i18n import _
+from mercurial import (
+ cmdutil,
+ error,
+ minirst,
+ scmutil,
+)
+
+cmdtable = {}
+command = cmdutil.command(cmdtable)
+
+# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
+# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
+# be specifying the version(s) of Mercurial they are tested with, or
+# leave the attribute unspecified.
+testedwith = 'ships-with-hg-core'
+
+DEFAULT_SECTIONS = [
+ ('feature', _('New Features')),
+ ('bc', _('Backwards Compatibility Changes')),
+ ('fix', _('Bug Fixes')),
+ ('perf', _('Performance Improvements')),
+ ('api', _('API Changes')),
+]
+
+RE_DIRECTIVE = re.compile('^\.\. ([a-zA-Z0-9_]+)::\s*([^$]+)?$')
+
+BULLET_SECTION = _('Other Changes')
+
+class parsedreleasenotes(object):
+ def __init__(self):
+ self.sections = {}
+
+ def __contains__(self, section):
+ return section in self.sections
+
+ def __iter__(self):
+ return iter(sorted(self.sections))
+
+ def addtitleditem(self, section, title, paragraphs):
+ """Add a titled release note entry."""
+ self.sections.setdefault(section, ([], []))
+ self.sections[section][0].append((title, paragraphs))
+
+ def addnontitleditem(self, section, paragraphs):
+ """Adds a non-titled release note entry.
+
+ Will be rendered as a bullet point.
+ """
+ self.sections.setdefault(section, ([], []))
+ self.sections[section][1].append(paragraphs)
+
+ def titledforsection(self, section):
+ """Returns titled entries in a section.
+
+ Returns a list of (title, paragraphs) tuples describing sub-sections.
+ """
+ return self.sections.get(section, ([], []))[0]
+
+ def nontitledforsection(self, section):
+ """Returns non-titled, bulleted paragraphs in a section."""
+ return self.sections.get(section, ([], []))[1]
+
+ def hastitledinsection(self, section, title):
+ return any(t[0] == title for t in self.titledforsection(section))
+
+ def merge(self, ui, other):
+ """Merge another instance into this one.
+
+ This is used to combine multiple sources of release notes together.
+ """
+ for section in other:
+ for title, paragraphs in other.titledforsection(section):
+ if self.hastitledinsection(section, title):
+ # TODO prompt for resolution if different and running in
+ # interactive mode.
+ ui.write('%s already exists in %s section; ignoring\n' %
+ (title, section))
+ continue
+
+ # TODO perform similarity comparison and try to match against
+ # existing.
+ self.addtitleditem(section, title, paragraphs)
+
+ for paragraphs in other.nontitledforsection(section):
+ if paragraphs in self.nontitledforsection(section):
+ continue
+
+ # TODO perform similarily comparison and try to match against
+ # existing.
+ self.addnontitleditem(section, paragraphs)
+
+class releasenotessections(object):
+ def __init__(self, ui):
+ # TODO support defining custom sections from config.
+ self._sections = list(DEFAULT_SECTIONS)
+
+ def __iter__(self):
+ return iter(self._sections)
+
+ def names(self):
+ return [t[0] for t in self._sections]
+
+ def sectionfromtitle(self, title):
+ for name, value in self._sections:
+ if value == title:
+ return name
+
+ return None
+
+def parsenotesfromrevisions(repo, directives, revs):
+ notes = parsedreleasenotes()
+
+ for rev in revs:
+ ctx = repo[rev]
+
+ blocks, pruned = minirst.parse(ctx.description(),
+ admonitions=directives)
+
+ for i, block in enumerate(blocks):
+ if block['type'] != 'admonition':
+ continue
+
+ directive = block['admonitiontitle']
+ title = block['lines'][0].strip() if block['lines'] else None
+
+ if i + 1 == len(blocks):
+ raise error.Abort(_('release notes directive %s lacks content')
+ % directive)
+
+ # Now search ahead and find all paragraphs attached to this
+ # admonition.
+ paragraphs = []
+ for j in range(i + 1, len(blocks)):
+ pblock = blocks[j]
+
+ # Margin blocks may appear between paragraphs. Ignore them.
+ if pblock['type'] == 'margin':
+ continue
+
+ if pblock['type'] != 'paragraph':
+ raise error.Abort(_('unexpected block in release notes '
+ 'directive %s') % directive)
+
+ if pblock['indent'] > 0:
+ paragraphs.append(pblock['lines'])
+ else:
+ break
+
+ # TODO consider using title as paragraph for more concise notes.
+ if not paragraphs:
+ raise error.Abort(_('could not find content for release note '
+ '%s') % directive)
+
+ if title:
+ notes.addtitleditem(directive, title, paragraphs)
+ else:
+ notes.addnontitleditem(directive, paragraphs)
+
+ return notes
+
+
+def parsereleasenotesfile(sections, text):
+ """Parse text content containing generated release notes."""
+ notes = parsedreleasenotes()
+
+ blocks = minirst.parse(text)[0]
+
+ def gatherparagraphs(offset):
+ paragraphs = []
+
+ for i in range(offset + 1, len(blocks)):
+ block = blocks[i]
+
+ if block['type'] == 'margin':
+ continue
+ elif block['type'] == 'section':
+ break
+ elif block['type'] == 'bullet':
+ if block['indent'] != 0:
+ raise error.Abort(_('indented bullet lists not supported'))
+
+ lines = [l[1:].strip() for l in block['lines']]
+ paragraphs.append(lines)
+ continue
+ elif block['type'] != 'paragraph':
+ raise error.Abort(_('unexpected block type in release notes: '
+ '%s') % block['type'])
+
+ paragraphs.append(block['lines'])
+
+ return paragraphs
+
+ currentsection = None
+ for i, block in enumerate(blocks):
+ if block['type'] != 'section':
+ continue
+
+ title = block['lines'][0]
+
+ # TODO the parsing around paragraphs and bullet points needs some
+ # work.
+
+ # Main section.
+ if block['underline'] == '=':
+ name = sections.sectionfromtitle(title)
+ if not name:
+ raise error.Abort(_('unknown release notes section: %s') %
+ title)
+
+ currentsection = name
+ paragraphs = gatherparagraphs(i)
+ if paragraphs:
+ notes.addnontitleditem(currentsection, paragraphs)
+
+ # Sub-section.
+ elif block['underline'] == '-':
+ paragraphs = gatherparagraphs(i)
+
+ if title == BULLET_SECTION:
+ notes.addnontitleditem(currentsection, paragraphs)
+ else:
+ notes.addtitleditem(currentsection, title, paragraphs)
+ else:
+ raise error.Abort(_('unsupported section type for %s') % title)
+
+ return notes
+
+
+def serializenotes(sections, notes):
+ """Serialize release notes from parsed fragments and notes.
+
+ This function essentially takes the output of ``parsenotesfromrevisions()``
+ and ``parserelnotesfile()`` and produces output combining the 2.
+ """
+ lines = []
+
+ for sectionname, sectiontitle in sections:
+ if sectionname not in notes:
+ continue
+
+ lines.append(sectiontitle)
+ lines.append('=' * len(sectiontitle))
+ lines.append('')
+
+ # First pass to emit sub-sections.
+ for title, paragraphs in notes.titledforsection(sectionname):
+ lines.append(title)
+ lines.append('-' * len(title))
+ lines.append('')
+
+ wrapper = textwrap.TextWrapper(width=78)
+ for i, para in enumerate(paragraphs):
+ if i:
+ lines.append('')
+ lines.extend(wrapper.wrap(' '.join(para)))
+
+ lines.append('')
+
+ # Second pass to emit bullet list items.
+
+ # If the section has titled and non-titled items, we can't
+ # simply emit the bullet list because it would appear to come
+ # from the last title/section. So, we emit a new sub-section
+ # for the non-titled items.
+ nontitled = notes.nontitledforsection(sectionname)
+ if notes.titledforsection(sectionname) and nontitled:
+ # TODO make configurable.
+ lines.append(BULLET_SECTION)
+ lines.append('-' * len(BULLET_SECTION))
+ lines.append('')
+
+ for paragraphs in nontitled:
+ wrapper = textwrap.TextWrapper(initial_indent='* ',
+ subsequent_indent=' ',
+ width=78)
+ lines.extend(wrapper.wrap(' '.join(paragraphs[0])))
+
+ wrapper = textwrap.TextWrapper(initial_indent=' ',
+ subsequent_indent=' ',
+ width=78)
+ for para in paragraphs[1:]:
+ lines.append('')
+ lines.extend(wrapper.wrap(' '.join(para)))
+
+ lines.append('')
+
+ if lines[-1]:
+ lines.append('')
+
+ return '\n'.join(lines)
+
+
+@command('releasenotes',
+ [('r', 'rev', '', _('revisions to process for release notes'), _('REV'))],
+ _('[-r REV] FILE'))
+def releasenotes(ui, repo, file_, rev=None):
+ """parse release notes from commit messages into an output file
+
+ Given an output file and set of revisions, this command will parse commit
+ messages for release notes then add them to the output file.
+
+ Release notes are defined in commit messages as ReStructuredText
+ directives. These have the form::
+
+ .. directive:: title
+
+ content
+
+ Each ``directive`` maps to an output section in a generated release notes
+ file, which itself is ReStructuredText. For example, the ``.. feature::``
+ directive would map to a ``New Features`` section.
+
+ Release note directives can be either short-form or long-form. In short-
+ form, ``title`` is omitted and the release note is rendered as a bullet
+ list. In long form, a sub-section with the title ``title`` is added to the
+ section.
+
+ The ``FILE`` argument controls the output file to write gathered release
+ notes to. The format of the file is::
+
+ Section 1
+ =========
+
+ ...
+
+ Section 2
+ =========
+
+ ...
+
+ Only sections with defined release notes are emitted.
+
+ If a section only has short-form notes, it will consist of bullet list::
+
+ Section
+ =======
+
+ * Release note 1
+ * Release note 2
+
+ If a section has long-form notes, sub-sections will be emitted::
+
+ Section
+ =======
+
+ Note 1 Title
+ ------------
+
+ Description of the first long-form note.
+
+ Note 2 Title
+ ------------
+
+ Description of the second long-form note.
+
+ If the ``FILE`` argument points to an existing file, that file will be
+ parsed for release notes having the format that would be generated by this
+ command. The notes from the processed commit messages will be *merged*
+ into this parsed set.
+
+ During release notes merging:
+
+ * Duplicate items are automatically ignored
+ * Items that are different are automatically ignored if the similarity is
+ greater than a threshold.
+
+ This means that the release notes file can be updated independently from
+ this command and changes should not be lost when running this command on
+ that file. A particular use case for this is to tweak the wording of a
+ release note after it has been added to the release notes file.
+ """
+ sections = releasenotessections(ui)
+
+ revs = scmutil.revrange(repo, [rev or 'not public()'])
+ incoming = parsenotesfromrevisions(repo, sections.names(), revs)
+
+ try:
+ with open(file_, 'rb') as fh:
+ notes = parsereleasenotesfile(sections, fh.read())
+ except IOError as e:
+ if e.errno != errno.ENOENT:
+ raise
+
+ notes = parsedreleasenotes()
+
+ notes.merge(ui, incoming)
+
+ with open(file_, 'wb') as fh:
+ fh.write(serializenotes(sections, notes))
+
+@command('debugparsereleasenotes', norepo=True)
+def debugparsereleasenotes(ui, path):
+ """parse release notes and print resulting data structure"""
+ if path == '-':
+ text = sys.stdin.read()
+ else:
+ with open(path, 'rb') as fh:
+ text = fh.read()
+
+ sections = releasenotessections(ui)
+
+ notes = parsereleasenotesfile(sections, text)
+
+ for section in notes:
+ ui.write('section: %s\n' % section)
+ for title, paragraphs in notes.titledforsection(section):
+ ui.write(' subsection: %s\n' % title)
+ for para in paragraphs:
+ ui.write(' paragraph: %s\n' % ' '.join(para))
+
+ for paragraphs in notes.nontitledforsection(section):
+ ui.write(' bullet point:\n')
+ for para in paragraphs:
+ ui.write(' paragraph: %s\n' % ' '.join(para))
@@ -0,0 +1,256 @@
+ $ cat >> $HGRCPATH << EOF
+ > [extensions]
+ > releasenotes=
+ > EOF
+
+ $ hg init simple-repo
+ $ cd simple-repo
+
+A fix with a single line results in a bullet point in the appropriate section
+
+ $ touch fix1
+ $ hg -q commit -A -l - << EOF
+ > single line fix
+ >
+ > .. fix::
+ >
+ > Simple fix with a single line content entry.
+ > EOF
+
+ $ hg releasenotes -r . $TESTTMP/relnotes-single-line
+
+ $ cat $TESTTMP/relnotes-single-line
+ Bug Fixes
+ =========
+
+ * Simple fix with a single line content entry.
+
+A fix with multiple lines is handled correctly
+
+ $ touch fix2
+ $ hg -q commit -A -l - << EOF
+ > multi line fix
+ >
+ > .. fix::
+ >
+ > First line of fix entry.
+ > A line after it without a space.
+ >
+ > A new paragraph in the fix entry. And this is a really long line. It goes on for a while.
+ > And it wraps around to a new paragraph.
+ > EOF
+
+ $ hg releasenotes -r . $TESTTMP/relnotes-multi-line
+ $ cat $TESTTMP/relnotes-multi-line
+ Bug Fixes
+ =========
+
+ * First line of fix entry. A line after it without a space.
+
+ A new paragraph in the fix entry. And this is a really long line. It goes on
+ for a while. And it wraps around to a new paragraph.
+
+A release note with a title results in a sub-section being written
+
+ $ touch fix3
+ $ hg -q commit -A -l - << EOF
+ > fix with title
+ >
+ > .. fix:: Fix Title
+ >
+ > First line of fix with title.
+ >
+ > Another paragraph of fix with title. But this is a paragraph
+ > with multiple lines.
+ > EOF
+
+ $ hg releasenotes -r . $TESTTMP/relnotes-fix-with-title
+ $ cat $TESTTMP/relnotes-fix-with-title
+ Bug Fixes
+ =========
+
+ Fix Title
+ ---------
+
+ First line of fix with title.
+
+ Another paragraph of fix with title. But this is a paragraph with multiple
+ lines.
+
+ $ cd ..
+
+Formatting of multiple bullet points works
+
+ $ hg init multiple-bullets
+ $ cd multiple-bullets
+ $ touch fix1
+ $ hg -q commit -A -l - << EOF
+ > commit 1
+ >
+ > .. fix::
+ >
+ > first fix
+ > EOF
+
+ $ touch fix2
+ $ hg -q commit -A -l - << EOF
+ > commit 2
+ >
+ > .. fix::
+ >
+ > second fix
+ >
+ > Second paragraph of second fix.
+ > EOF
+
+ $ touch fix3
+ $ hg -q commit -A -l - << EOF
+ > commit 3
+ >
+ > .. fix::
+ >
+ > third fix
+ > EOF
+
+ $ hg releasenotes -r 'all()' $TESTTMP/relnotes-multiple-bullets
+ $ cat $TESTTMP/relnotes-multiple-bullets
+ Bug Fixes
+ =========
+
+ * first fix
+
+ * second fix
+
+ Second paragraph of second fix.
+
+ * third fix
+
+ $ cd ..
+
+Formatting of multiple sections works
+
+ $ hg init multiple-sections
+ $ cd multiple-sections
+ $ touch fix1
+ $ hg -q commit -A -l - << EOF
+ > commit 1
+ >
+ > .. fix::
+ >
+ > first fix
+ > EOF
+
+ $ touch feature1
+ $ hg -q commit -A -l - << EOF
+ > commit 2
+ >
+ > .. feature::
+ >
+ > description of the new feature
+ > EOF
+
+ $ touch fix2
+ $ hg -q commit -A -l - << EOF
+ > commit 3
+ >
+ > .. fix::
+ >
+ > second fix
+ > EOF
+
+ $ hg releasenotes -r 'all()' $TESTTMP/relnotes-multiple-sections
+ $ cat $TESTTMP/relnotes-multiple-sections
+ New Features
+ ============
+
+ * description of the new feature
+
+ Bug Fixes
+ =========
+
+ * first fix
+
+ * second fix
+
+ $ cd ..
+
+Section with subsections and bullets
+
+ $ hg init multiple-subsections
+ $ cd multiple-subsections
+
+ $ touch fix1
+ $ hg -q commit -A -l - << EOF
+ > commit 1
+ >
+ > .. fix:: Title of First Fix
+ >
+ > First paragraph of first fix.
+ >
+ > Second paragraph of first fix.
+ > EOF
+
+ $ touch fix2
+ $ hg -q commit -A -l - << EOF
+ > commit 2
+ >
+ > .. fix:: Title of Second Fix
+ >
+ > First paragraph of second fix.
+ >
+ > Second paragraph of second fix.
+ > EOF
+
+ $ hg releasenotes -r 'all()' $TESTTMP/relnotes-multiple-subsections
+ $ cat $TESTTMP/relnotes-multiple-subsections
+ Bug Fixes
+ =========
+
+ Title of First Fix
+ ------------------
+
+ First paragraph of first fix.
+
+ Second paragraph of first fix.
+
+ Title of Second Fix
+ -------------------
+
+ First paragraph of second fix.
+
+ Second paragraph of second fix.
+
+Now add bullet points to sections having sub-sections
+
+ $ touch fix3
+ $ hg -q commit -A -l - << EOF
+ > commit 3
+ >
+ > .. fix::
+ >
+ > Short summary of fix 3
+ > EOF
+
+ $ hg releasenotes -r 'all()' $TESTTMP/relnotes-multiple-subsections-with-bullets
+ $ cat $TESTTMP/relnotes-multiple-subsections-with-bullets
+ Bug Fixes
+ =========
+
+ Title of First Fix
+ ------------------
+
+ First paragraph of first fix.
+
+ Second paragraph of first fix.
+
+ Title of Second Fix
+ -------------------
+
+ First paragraph of second fix.
+
+ Second paragraph of second fix.
+
+ Other Changes
+ -------------
+
+ * Short summary of fix 3
@@ -0,0 +1,112 @@
+ $ cat >> $HGRCPATH << EOF
+ > [extensions]
+ > releasenotes=
+ > EOF
+
+ $ hg init simple-repo
+ $ cd simple-repo
+
+A fix directive from commit message is added to release notes
+
+ $ touch fix1
+ $ hg -q commit -A -l - << EOF
+ > commit 1
+ >
+ > .. fix::
+ >
+ > Fix from commit message.
+ > EOF
+
+ $ cat >> $TESTTMP/single-fix-bullet << EOF
+ > Bug Fixes
+ > =========
+ >
+ > * Fix from release notes.
+ > EOF
+
+ $ hg releasenotes -r . $TESTTMP/single-fix-bullet
+
+ $ cat $TESTTMP/single-fix-bullet
+ Bug Fixes
+ =========
+
+ * Fix from release notes.
+
+ * Fix from commit message.
+
+Processing again will no-op
+TODO this is buggy
+
+ $ hg releasenotes -r . $TESTTMP/single-fix-bullet
+
+ $ cat $TESTTMP/single-fix-bullet
+ Bug Fixes
+ =========
+
+ * Fix from release notes.
+
+ Fix from commit message.
+
+ * Fix from commit message.
+
+ $ cd ..
+
+Sections are unioned
+
+ $ hg init subsections
+ $ cd subsections
+ $ touch fix1
+ $ hg -q commit -A -l - << EOF
+ > Commit 1
+ >
+ > .. feature:: Commit Message Feature
+ >
+ > This describes a feature from a commit message.
+ > EOF
+
+ $ cat >> $TESTTMP/single-feature-section << EOF
+ > New Features
+ > ============
+ >
+ > Notes Feature
+ > -------------
+ >
+ > This describes a feature from a release notes file.
+ > EOF
+
+ $ hg releasenotes -r . $TESTTMP/single-feature-section
+
+ $ cat $TESTTMP/single-feature-section
+ New Features
+ ============
+
+ Notes Feature
+ -------------
+
+ This describes a feature from a release notes file.
+
+ Commit Message Feature
+ ----------------------
+
+ This describes a feature from a commit message.
+
+Doing it again won't add another section
+
+ $ hg releasenotes -r . $TESTTMP/single-feature-section
+ Commit Message Feature already exists in feature section; ignoring
+
+ $ cat $TESTTMP/single-feature-section
+ New Features
+ ============
+
+ Notes Feature
+ -------------
+
+ This describes a feature from a release notes file.
+
+ Commit Message Feature
+ ----------------------
+
+ This describes a feature from a commit message.
+
+ $ cd ..
@@ -0,0 +1,170 @@
+ $ cat >> $HGRCPATH << EOF
+ > [extensions]
+ > releasenotes=
+ > EOF
+
+Bullet point with a single item spanning a single line
+
+ $ hg debugparsereleasenotes - << EOF
+ > New Features
+ > ============
+ >
+ > * Bullet point item with a single line
+ > EOF
+ section: feature
+ bullet point:
+ paragraph: Bullet point item with a single line
+
+Bullet point that spans multiple lines.
+
+ $ hg debugparsereleasenotes - << EOF
+ > New Features
+ > ============
+ >
+ > * Bullet point with a paragraph
+ > that spans multiple lines.
+ > EOF
+ section: feature
+ bullet point:
+ paragraph: Bullet point with a paragraph that spans multiple lines.
+
+ $ hg debugparsereleasenotes - << EOF
+ > New Features
+ > ============
+ >
+ > * Bullet point with a paragraph
+ > that spans multiple lines.
+ >
+ > And has an empty line between lines too.
+ > With a line cuddling that.
+ > EOF
+ section: feature
+ bullet point:
+ paragraph: Bullet point with a paragraph that spans multiple lines.
+ paragraph: And has an empty line between lines too. With a line cuddling that.
+
+
+Multiple bullet points. With some entries being multiple lines.
+
+ $ hg debugparsereleasenotes - << EOF
+ > New Features
+ > ============
+ >
+ > * First bullet point. It has a single line.
+ >
+ > * Second bullet point.
+ > It consists of multiple lines.
+ >
+ > * Third bullet point. It has a single line.
+ > EOF
+ section: feature
+ bullet point:
+ paragraph: First bullet point. It has a single line.
+ paragraph: Second bullet point. It consists of multiple lines.
+ paragraph: Third bullet point. It has a single line.
+
+Bullet point without newline between items
+
+ $ hg debugparsereleasenotes - << EOF
+ > New Features
+ > ============
+ >
+ > * First bullet point
+ > * Second bullet point
+ > And it has multiple lines
+ > * Third bullet point
+ > * Fourth bullet point
+ > EOF
+ section: feature
+ bullet point:
+ paragraph: First bullet point
+ paragraph: Second bullet point And it has multiple lines
+ paragraph: Third bullet point
+ paragraph: Fourth bullet point
+
+Sub-section contents are read
+
+ $ hg debugparsereleasenotes - << EOF
+ > New Features
+ > ============
+ >
+ > First Feature
+ > -------------
+ >
+ > This is the first new feature that was implemented.
+ >
+ > And a second paragraph about it.
+ >
+ > Second Feature
+ > --------------
+ >
+ > This is the second new feature that was implemented.
+ >
+ > Paragraph two.
+ >
+ > Paragraph three.
+ > EOF
+ section: feature
+ subsection: First Feature
+ paragraph: This is the first new feature that was implemented.
+ paragraph: And a second paragraph about it.
+ subsection: Second Feature
+ paragraph: This is the second new feature that was implemented.
+ paragraph: Paragraph two.
+ paragraph: Paragraph three.
+
+Multiple sections are read
+
+ $ hg debugparsereleasenotes - << EOF
+ > New Features
+ > ============
+ >
+ > * Feature 1
+ > * Feature 2
+ >
+ > Bug Fixes
+ > =========
+ >
+ > * Fix 1
+ > * Fix 2
+ > EOF
+ section: feature
+ bullet point:
+ paragraph: Feature 1
+ paragraph: Feature 2
+ section: fix
+ bullet point:
+ paragraph: Fix 1
+ paragraph: Fix 2
+
+Mixed sub-sections and bullet list
+
+ $ hg debugparsereleasenotes - << EOF
+ > New Features
+ > ============
+ >
+ > Feature 1
+ > ---------
+ >
+ > Some words about the first feature.
+ >
+ > Feature 2
+ > ---------
+ >
+ > Some words about the second feature.
+ > That span multiple lines.
+ >
+ > Other Changes
+ > -------------
+ >
+ > * Bullet item 1
+ > * Bullet item 2
+ > EOF
+ section: feature
+ subsection: Feature 1
+ paragraph: Some words about the first feature.
+ subsection: Feature 2
+ paragraph: Some words about the second feature. That span multiple lines.
+ bullet point:
+ paragraph: Bullet item 1
+ paragraph: Bullet item 2