hgext/releasenotes.py
changeset 32778 91e355a0408b
child 33012 5814db57941c
equal deleted inserted replaced
32777:9dccaff02ad5 32778:91e355a0408b
       
     1 # Copyright 2017-present Gregory Szorc <gregory.szorc@gmail.com>
       
     2 #
       
     3 # This software may be used and distributed according to the terms of the
       
     4 # GNU General Public License version 2 or any later version.
       
     5 
       
     6 """generate release notes from commit messages (EXPERIMENTAL)
       
     7 
       
     8 It is common to maintain files detailing changes in a project between
       
     9 releases. Maintaining these files can be difficult and time consuming.
       
    10 The :hg:`releasenotes` command provided by this extension makes the
       
    11 process simpler by automating it.
       
    12 """
       
    13 
       
    14 from __future__ import absolute_import
       
    15 
       
    16 import errno
       
    17 import re
       
    18 import sys
       
    19 import textwrap
       
    20 
       
    21 from mercurial.i18n import _
       
    22 from mercurial import (
       
    23     error,
       
    24     minirst,
       
    25     registrar,
       
    26     scmutil,
       
    27 )
       
    28 
       
    29 cmdtable = {}
       
    30 command = registrar.command(cmdtable)
       
    31 
       
    32 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
       
    33 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
       
    34 # be specifying the version(s) of Mercurial they are tested with, or
       
    35 # leave the attribute unspecified.
       
    36 testedwith = 'ships-with-hg-core'
       
    37 
       
    38 DEFAULT_SECTIONS = [
       
    39     ('feature', _('New Features')),
       
    40     ('bc', _('Backwards Compatibility Changes')),
       
    41     ('fix', _('Bug Fixes')),
       
    42     ('perf', _('Performance Improvements')),
       
    43     ('api', _('API Changes')),
       
    44 ]
       
    45 
       
    46 RE_DIRECTIVE = re.compile('^\.\. ([a-zA-Z0-9_]+)::\s*([^$]+)?$')
       
    47 
       
    48 BULLET_SECTION = _('Other Changes')
       
    49 
       
    50 class parsedreleasenotes(object):
       
    51     def __init__(self):
       
    52         self.sections = {}
       
    53 
       
    54     def __contains__(self, section):
       
    55         return section in self.sections
       
    56 
       
    57     def __iter__(self):
       
    58         return iter(sorted(self.sections))
       
    59 
       
    60     def addtitleditem(self, section, title, paragraphs):
       
    61         """Add a titled release note entry."""
       
    62         self.sections.setdefault(section, ([], []))
       
    63         self.sections[section][0].append((title, paragraphs))
       
    64 
       
    65     def addnontitleditem(self, section, paragraphs):
       
    66         """Adds a non-titled release note entry.
       
    67 
       
    68         Will be rendered as a bullet point.
       
    69         """
       
    70         self.sections.setdefault(section, ([], []))
       
    71         self.sections[section][1].append(paragraphs)
       
    72 
       
    73     def titledforsection(self, section):
       
    74         """Returns titled entries in a section.
       
    75 
       
    76         Returns a list of (title, paragraphs) tuples describing sub-sections.
       
    77         """
       
    78         return self.sections.get(section, ([], []))[0]
       
    79 
       
    80     def nontitledforsection(self, section):
       
    81         """Returns non-titled, bulleted paragraphs in a section."""
       
    82         return self.sections.get(section, ([], []))[1]
       
    83 
       
    84     def hastitledinsection(self, section, title):
       
    85         return any(t[0] == title for t in self.titledforsection(section))
       
    86 
       
    87     def merge(self, ui, other):
       
    88         """Merge another instance into this one.
       
    89 
       
    90         This is used to combine multiple sources of release notes together.
       
    91         """
       
    92         for section in other:
       
    93             for title, paragraphs in other.titledforsection(section):
       
    94                 if self.hastitledinsection(section, title):
       
    95                     # TODO prompt for resolution if different and running in
       
    96                     # interactive mode.
       
    97                     ui.write(_('%s already exists in %s section; ignoring\n') %
       
    98                              (title, section))
       
    99                     continue
       
   100 
       
   101                 # TODO perform similarity comparison and try to match against
       
   102                 # existing.
       
   103                 self.addtitleditem(section, title, paragraphs)
       
   104 
       
   105             for paragraphs in other.nontitledforsection(section):
       
   106                 if paragraphs in self.nontitledforsection(section):
       
   107                     continue
       
   108 
       
   109                 # TODO perform similarily comparison and try to match against
       
   110                 # existing.
       
   111                 self.addnontitleditem(section, paragraphs)
       
   112 
       
   113 class releasenotessections(object):
       
   114     def __init__(self, ui):
       
   115         # TODO support defining custom sections from config.
       
   116         self._sections = list(DEFAULT_SECTIONS)
       
   117 
       
   118     def __iter__(self):
       
   119         return iter(self._sections)
       
   120 
       
   121     def names(self):
       
   122         return [t[0] for t in self._sections]
       
   123 
       
   124     def sectionfromtitle(self, title):
       
   125         for name, value in self._sections:
       
   126             if value == title:
       
   127                 return name
       
   128 
       
   129         return None
       
   130 
       
   131 def parsenotesfromrevisions(repo, directives, revs):
       
   132     notes = parsedreleasenotes()
       
   133 
       
   134     for rev in revs:
       
   135         ctx = repo[rev]
       
   136 
       
   137         blocks, pruned = minirst.parse(ctx.description(),
       
   138                                        admonitions=directives)
       
   139 
       
   140         for i, block in enumerate(blocks):
       
   141             if block['type'] != 'admonition':
       
   142                 continue
       
   143 
       
   144             directive = block['admonitiontitle']
       
   145             title = block['lines'][0].strip() if block['lines'] else None
       
   146 
       
   147             if i + 1 == len(blocks):
       
   148                 raise error.Abort(_('release notes directive %s lacks content')
       
   149                                   % directive)
       
   150 
       
   151             # Now search ahead and find all paragraphs attached to this
       
   152             # admonition.
       
   153             paragraphs = []
       
   154             for j in range(i + 1, len(blocks)):
       
   155                 pblock = blocks[j]
       
   156 
       
   157                 # Margin blocks may appear between paragraphs. Ignore them.
       
   158                 if pblock['type'] == 'margin':
       
   159                     continue
       
   160 
       
   161                 if pblock['type'] != 'paragraph':
       
   162                     raise error.Abort(_('unexpected block in release notes '
       
   163                                         'directive %s') % directive)
       
   164 
       
   165                 if pblock['indent'] > 0:
       
   166                     paragraphs.append(pblock['lines'])
       
   167                 else:
       
   168                     break
       
   169 
       
   170             # TODO consider using title as paragraph for more concise notes.
       
   171             if not paragraphs:
       
   172                 raise error.Abort(_('could not find content for release note '
       
   173                                     '%s') % directive)
       
   174 
       
   175             if title:
       
   176                 notes.addtitleditem(directive, title, paragraphs)
       
   177             else:
       
   178                 notes.addnontitleditem(directive, paragraphs)
       
   179 
       
   180     return notes
       
   181 
       
   182 def parsereleasenotesfile(sections, text):
       
   183     """Parse text content containing generated release notes."""
       
   184     notes = parsedreleasenotes()
       
   185 
       
   186     blocks = minirst.parse(text)[0]
       
   187 
       
   188     def gatherparagraphs(offset):
       
   189         paragraphs = []
       
   190 
       
   191         for i in range(offset + 1, len(blocks)):
       
   192             block = blocks[i]
       
   193 
       
   194             if block['type'] == 'margin':
       
   195                 continue
       
   196             elif block['type'] == 'section':
       
   197                 break
       
   198             elif block['type'] == 'bullet':
       
   199                 if block['indent'] != 0:
       
   200                     raise error.Abort(_('indented bullet lists not supported'))
       
   201 
       
   202                 lines = [l[1:].strip() for l in block['lines']]
       
   203                 paragraphs.append(lines)
       
   204                 continue
       
   205             elif block['type'] != 'paragraph':
       
   206                 raise error.Abort(_('unexpected block type in release notes: '
       
   207                                     '%s') % block['type'])
       
   208 
       
   209             paragraphs.append(block['lines'])
       
   210 
       
   211         return paragraphs
       
   212 
       
   213     currentsection = None
       
   214     for i, block in enumerate(blocks):
       
   215         if block['type'] != 'section':
       
   216             continue
       
   217 
       
   218         title = block['lines'][0]
       
   219 
       
   220         # TODO the parsing around paragraphs and bullet points needs some
       
   221         # work.
       
   222         if block['underline'] == '=':  # main section
       
   223             name = sections.sectionfromtitle(title)
       
   224             if not name:
       
   225                 raise error.Abort(_('unknown release notes section: %s') %
       
   226                                   title)
       
   227 
       
   228             currentsection = name
       
   229             paragraphs = gatherparagraphs(i)
       
   230             if paragraphs:
       
   231                 notes.addnontitleditem(currentsection, paragraphs)
       
   232 
       
   233         elif block['underline'] == '-':  # sub-section
       
   234             paragraphs = gatherparagraphs(i)
       
   235 
       
   236             if title == BULLET_SECTION:
       
   237                 notes.addnontitleditem(currentsection, paragraphs)
       
   238             else:
       
   239                 notes.addtitleditem(currentsection, title, paragraphs)
       
   240         else:
       
   241             raise error.Abort(_('unsupported section type for %s') % title)
       
   242 
       
   243     return notes
       
   244 
       
   245 def serializenotes(sections, notes):
       
   246     """Serialize release notes from parsed fragments and notes.
       
   247 
       
   248     This function essentially takes the output of ``parsenotesfromrevisions()``
       
   249     and ``parserelnotesfile()`` and produces output combining the 2.
       
   250     """
       
   251     lines = []
       
   252 
       
   253     for sectionname, sectiontitle in sections:
       
   254         if sectionname not in notes:
       
   255             continue
       
   256 
       
   257         lines.append(sectiontitle)
       
   258         lines.append('=' * len(sectiontitle))
       
   259         lines.append('')
       
   260 
       
   261         # First pass to emit sub-sections.
       
   262         for title, paragraphs in notes.titledforsection(sectionname):
       
   263             lines.append(title)
       
   264             lines.append('-' * len(title))
       
   265             lines.append('')
       
   266 
       
   267             wrapper = textwrap.TextWrapper(width=78)
       
   268             for i, para in enumerate(paragraphs):
       
   269                 if i:
       
   270                     lines.append('')
       
   271                 lines.extend(wrapper.wrap(' '.join(para)))
       
   272 
       
   273             lines.append('')
       
   274 
       
   275         # Second pass to emit bullet list items.
       
   276 
       
   277         # If the section has titled and non-titled items, we can't
       
   278         # simply emit the bullet list because it would appear to come
       
   279         # from the last title/section. So, we emit a new sub-section
       
   280         # for the non-titled items.
       
   281         nontitled = notes.nontitledforsection(sectionname)
       
   282         if notes.titledforsection(sectionname) and nontitled:
       
   283             # TODO make configurable.
       
   284             lines.append(BULLET_SECTION)
       
   285             lines.append('-' * len(BULLET_SECTION))
       
   286             lines.append('')
       
   287 
       
   288         for paragraphs in nontitled:
       
   289             wrapper = textwrap.TextWrapper(initial_indent='* ',
       
   290                                            subsequent_indent='  ',
       
   291                                            width=78)
       
   292             lines.extend(wrapper.wrap(' '.join(paragraphs[0])))
       
   293 
       
   294             wrapper = textwrap.TextWrapper(initial_indent='  ',
       
   295                                            subsequent_indent='  ',
       
   296                                            width=78)
       
   297             for para in paragraphs[1:]:
       
   298                 lines.append('')
       
   299                 lines.extend(wrapper.wrap(' '.join(para)))
       
   300 
       
   301             lines.append('')
       
   302 
       
   303     if lines[-1]:
       
   304         lines.append('')
       
   305 
       
   306     return '\n'.join(lines)
       
   307 
       
   308 @command('releasenotes',
       
   309     [('r', 'rev', '', _('revisions to process for release notes'), _('REV'))],
       
   310     _('[-r REV] FILE'))
       
   311 def releasenotes(ui, repo, file_, rev=None):
       
   312     """parse release notes from commit messages into an output file
       
   313 
       
   314     Given an output file and set of revisions, this command will parse commit
       
   315     messages for release notes then add them to the output file.
       
   316 
       
   317     Release notes are defined in commit messages as ReStructuredText
       
   318     directives. These have the form::
       
   319 
       
   320        .. directive:: title
       
   321 
       
   322           content
       
   323 
       
   324     Each ``directive`` maps to an output section in a generated release notes
       
   325     file, which itself is ReStructuredText. For example, the ``.. feature::``
       
   326     directive would map to a ``New Features`` section.
       
   327 
       
   328     Release note directives can be either short-form or long-form. In short-
       
   329     form, ``title`` is omitted and the release note is rendered as a bullet
       
   330     list. In long form, a sub-section with the title ``title`` is added to the
       
   331     section.
       
   332 
       
   333     The ``FILE`` argument controls the output file to write gathered release
       
   334     notes to. The format of the file is::
       
   335 
       
   336        Section 1
       
   337        =========
       
   338 
       
   339        ...
       
   340 
       
   341        Section 2
       
   342        =========
       
   343 
       
   344        ...
       
   345 
       
   346     Only sections with defined release notes are emitted.
       
   347 
       
   348     If a section only has short-form notes, it will consist of bullet list::
       
   349 
       
   350        Section
       
   351        =======
       
   352 
       
   353        * Release note 1
       
   354        * Release note 2
       
   355 
       
   356     If a section has long-form notes, sub-sections will be emitted::
       
   357 
       
   358        Section
       
   359        =======
       
   360 
       
   361        Note 1 Title
       
   362        ------------
       
   363 
       
   364        Description of the first long-form note.
       
   365 
       
   366        Note 2 Title
       
   367        ------------
       
   368 
       
   369        Description of the second long-form note.
       
   370 
       
   371     If the ``FILE`` argument points to an existing file, that file will be
       
   372     parsed for release notes having the format that would be generated by this
       
   373     command. The notes from the processed commit messages will be *merged*
       
   374     into this parsed set.
       
   375 
       
   376     During release notes merging:
       
   377 
       
   378     * Duplicate items are automatically ignored
       
   379     * Items that are different are automatically ignored if the similarity is
       
   380       greater than a threshold.
       
   381 
       
   382     This means that the release notes file can be updated independently from
       
   383     this command and changes should not be lost when running this command on
       
   384     that file. A particular use case for this is to tweak the wording of a
       
   385     release note after it has been added to the release notes file.
       
   386     """
       
   387     sections = releasenotessections(ui)
       
   388 
       
   389     revs = scmutil.revrange(repo, [rev or 'not public()'])
       
   390     incoming = parsenotesfromrevisions(repo, sections.names(), revs)
       
   391 
       
   392     try:
       
   393         with open(file_, 'rb') as fh:
       
   394             notes = parsereleasenotesfile(sections, fh.read())
       
   395     except IOError as e:
       
   396         if e.errno != errno.ENOENT:
       
   397             raise
       
   398 
       
   399         notes = parsedreleasenotes()
       
   400 
       
   401     notes.merge(ui, incoming)
       
   402 
       
   403     with open(file_, 'wb') as fh:
       
   404         fh.write(serializenotes(sections, notes))
       
   405 
       
   406 @command('debugparsereleasenotes', norepo=True)
       
   407 def debugparsereleasenotes(ui, path):
       
   408     """parse release notes and print resulting data structure"""
       
   409     if path == '-':
       
   410         text = sys.stdin.read()
       
   411     else:
       
   412         with open(path, 'rb') as fh:
       
   413             text = fh.read()
       
   414 
       
   415     sections = releasenotessections(ui)
       
   416 
       
   417     notes = parsereleasenotesfile(sections, text)
       
   418 
       
   419     for section in notes:
       
   420         ui.write(_('section: %s\n') % section)
       
   421         for title, paragraphs in notes.titledforsection(section):
       
   422             ui.write(_('  subsection: %s\n') % title)
       
   423             for para in paragraphs:
       
   424                 ui.write(_('    paragraph: %s\n') % ' '.join(para))
       
   425 
       
   426         for paragraphs in notes.nontitledforsection(section):
       
   427             ui.write(_('  bullet point:\n'))
       
   428             for para in paragraphs:
       
   429                 ui.write(_('    paragraph: %s\n') % ' '.join(para))