Mercurial > hg
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)) |