comparison hgext/releasenotes.py @ 32778:91e355a0408b

releasenotes: command to manage release notes files Per discussion on the mailing list, we want better release notes for Mercurial. This patch introduces an extension that provides a command for producing release notes files. Functionality is implemented as an extension because it could be useful outside of the Mercurial project and because there is some code (like rst parsing) that already exists in Mercurial and it doesn't make sense to reinvent the wheel. The general idea with the extension is that changeset authors declare release notes in commit messages using rst directives. Periodically (such as at publishing or release time), a project maintainer runs `hg releasenotes` to extract release notes fragments from commit messages and format them to an auto-generated release notes file. More details are explained inline in docstrings. There are several things that need addressed before this is ready for prime time: * Moar tests * Interactive merge mode * Implement similarity detection for individual notes items * Support customizing section names/titles * Parsing improvements for bullet lists and paragraphs * Document which rst primitives can be parsed * Retain arbitrary content (e.g. header section/paragraphs) from existing release notes file * Better error messages (line numbers, hints, etc)
author Gregory Szorc <gregory.szorc@gmail.com>
date Fri, 02 Jun 2017 23:33:30 +0200
parents
children 5814db57941c
comparison
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))