##// END OF EJS Templates
releasenotes: update docstrings with information on additional flags...
Rishabh Madan -
r34342:01e8ab4b default
parent child Browse files
Show More
@@ -1,610 +1,616 b''
1 # Copyright 2017-present Gregory Szorc <gregory.szorc@gmail.com>
1 # Copyright 2017-present Gregory Szorc <gregory.szorc@gmail.com>
2 #
2 #
3 # This software may be used and distributed according to the terms of the
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.
4 # GNU General Public License version 2 or any later version.
5
5
6 """generate release notes from commit messages (EXPERIMENTAL)
6 """generate release notes from commit messages (EXPERIMENTAL)
7
7
8 It is common to maintain files detailing changes in a project between
8 It is common to maintain files detailing changes in a project between
9 releases. Maintaining these files can be difficult and time consuming.
9 releases. Maintaining these files can be difficult and time consuming.
10 The :hg:`releasenotes` command provided by this extension makes the
10 The :hg:`releasenotes` command provided by this extension makes the
11 process simpler by automating it.
11 process simpler by automating it.
12 """
12 """
13
13
14 from __future__ import absolute_import
14 from __future__ import absolute_import
15
15
16 import difflib
16 import difflib
17 import errno
17 import errno
18 import re
18 import re
19 import sys
19 import sys
20 import textwrap
20 import textwrap
21
21
22 from mercurial.i18n import _
22 from mercurial.i18n import _
23 from mercurial import (
23 from mercurial import (
24 config,
24 config,
25 error,
25 error,
26 minirst,
26 minirst,
27 registrar,
27 registrar,
28 scmutil,
28 scmutil,
29 util,
29 util,
30 )
30 )
31
31
32 cmdtable = {}
32 cmdtable = {}
33 command = registrar.command(cmdtable)
33 command = registrar.command(cmdtable)
34
34
35 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
35 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
36 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
36 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
37 # be specifying the version(s) of Mercurial they are tested with, or
37 # be specifying the version(s) of Mercurial they are tested with, or
38 # leave the attribute unspecified.
38 # leave the attribute unspecified.
39 testedwith = 'ships-with-hg-core'
39 testedwith = 'ships-with-hg-core'
40
40
41 DEFAULT_SECTIONS = [
41 DEFAULT_SECTIONS = [
42 ('feature', _('New Features')),
42 ('feature', _('New Features')),
43 ('bc', _('Backwards Compatibility Changes')),
43 ('bc', _('Backwards Compatibility Changes')),
44 ('fix', _('Bug Fixes')),
44 ('fix', _('Bug Fixes')),
45 ('perf', _('Performance Improvements')),
45 ('perf', _('Performance Improvements')),
46 ('api', _('API Changes')),
46 ('api', _('API Changes')),
47 ]
47 ]
48
48
49 RE_DIRECTIVE = re.compile('^\.\. ([a-zA-Z0-9_]+)::\s*([^$]+)?$')
49 RE_DIRECTIVE = re.compile('^\.\. ([a-zA-Z0-9_]+)::\s*([^$]+)?$')
50 RE_ISSUE = r'\bissue ?[0-9]{4,6}(?![0-9])\b'
50 RE_ISSUE = r'\bissue ?[0-9]{4,6}(?![0-9])\b'
51
51
52 BULLET_SECTION = _('Other Changes')
52 BULLET_SECTION = _('Other Changes')
53
53
54 class parsedreleasenotes(object):
54 class parsedreleasenotes(object):
55 def __init__(self):
55 def __init__(self):
56 self.sections = {}
56 self.sections = {}
57
57
58 def __contains__(self, section):
58 def __contains__(self, section):
59 return section in self.sections
59 return section in self.sections
60
60
61 def __iter__(self):
61 def __iter__(self):
62 return iter(sorted(self.sections))
62 return iter(sorted(self.sections))
63
63
64 def addtitleditem(self, section, title, paragraphs):
64 def addtitleditem(self, section, title, paragraphs):
65 """Add a titled release note entry."""
65 """Add a titled release note entry."""
66 self.sections.setdefault(section, ([], []))
66 self.sections.setdefault(section, ([], []))
67 self.sections[section][0].append((title, paragraphs))
67 self.sections[section][0].append((title, paragraphs))
68
68
69 def addnontitleditem(self, section, paragraphs):
69 def addnontitleditem(self, section, paragraphs):
70 """Adds a non-titled release note entry.
70 """Adds a non-titled release note entry.
71
71
72 Will be rendered as a bullet point.
72 Will be rendered as a bullet point.
73 """
73 """
74 self.sections.setdefault(section, ([], []))
74 self.sections.setdefault(section, ([], []))
75 self.sections[section][1].append(paragraphs)
75 self.sections[section][1].append(paragraphs)
76
76
77 def titledforsection(self, section):
77 def titledforsection(self, section):
78 """Returns titled entries in a section.
78 """Returns titled entries in a section.
79
79
80 Returns a list of (title, paragraphs) tuples describing sub-sections.
80 Returns a list of (title, paragraphs) tuples describing sub-sections.
81 """
81 """
82 return self.sections.get(section, ([], []))[0]
82 return self.sections.get(section, ([], []))[0]
83
83
84 def nontitledforsection(self, section):
84 def nontitledforsection(self, section):
85 """Returns non-titled, bulleted paragraphs in a section."""
85 """Returns non-titled, bulleted paragraphs in a section."""
86 return self.sections.get(section, ([], []))[1]
86 return self.sections.get(section, ([], []))[1]
87
87
88 def hastitledinsection(self, section, title):
88 def hastitledinsection(self, section, title):
89 return any(t[0] == title for t in self.titledforsection(section))
89 return any(t[0] == title for t in self.titledforsection(section))
90
90
91 def merge(self, ui, other):
91 def merge(self, ui, other):
92 """Merge another instance into this one.
92 """Merge another instance into this one.
93
93
94 This is used to combine multiple sources of release notes together.
94 This is used to combine multiple sources of release notes together.
95 """
95 """
96 for section in other:
96 for section in other:
97 existingnotes = converttitled(self.titledforsection(section)) + \
97 existingnotes = converttitled(self.titledforsection(section)) + \
98 convertnontitled(self.nontitledforsection(section))
98 convertnontitled(self.nontitledforsection(section))
99 for title, paragraphs in other.titledforsection(section):
99 for title, paragraphs in other.titledforsection(section):
100 if self.hastitledinsection(section, title):
100 if self.hastitledinsection(section, title):
101 # TODO prompt for resolution if different and running in
101 # TODO prompt for resolution if different and running in
102 # interactive mode.
102 # interactive mode.
103 ui.write(_('%s already exists in %s section; ignoring\n') %
103 ui.write(_('%s already exists in %s section; ignoring\n') %
104 (title, section))
104 (title, section))
105 continue
105 continue
106
106
107 incoming_str = converttitled([(title, paragraphs)])[0]
107 incoming_str = converttitled([(title, paragraphs)])[0]
108 if section == 'fix':
108 if section == 'fix':
109 issue = getissuenum(incoming_str)
109 issue = getissuenum(incoming_str)
110 if issue:
110 if issue:
111 if findissue(ui, existingnotes, issue):
111 if findissue(ui, existingnotes, issue):
112 continue
112 continue
113
113
114 if similar(ui, existingnotes, incoming_str):
114 if similar(ui, existingnotes, incoming_str):
115 continue
115 continue
116
116
117 self.addtitleditem(section, title, paragraphs)
117 self.addtitleditem(section, title, paragraphs)
118
118
119 for paragraphs in other.nontitledforsection(section):
119 for paragraphs in other.nontitledforsection(section):
120 if paragraphs in self.nontitledforsection(section):
120 if paragraphs in self.nontitledforsection(section):
121 continue
121 continue
122
122
123 incoming_str = convertnontitled([paragraphs])[0]
123 incoming_str = convertnontitled([paragraphs])[0]
124 if section == 'fix':
124 if section == 'fix':
125 issue = getissuenum(incoming_str)
125 issue = getissuenum(incoming_str)
126 if issue:
126 if issue:
127 if findissue(ui, existingnotes, issue):
127 if findissue(ui, existingnotes, issue):
128 continue
128 continue
129
129
130 if similar(ui, existingnotes, incoming_str):
130 if similar(ui, existingnotes, incoming_str):
131 continue
131 continue
132
132
133 self.addnontitleditem(section, paragraphs)
133 self.addnontitleditem(section, paragraphs)
134
134
135 class releasenotessections(object):
135 class releasenotessections(object):
136 def __init__(self, ui, repo=None):
136 def __init__(self, ui, repo=None):
137 if repo:
137 if repo:
138 sections = util.sortdict(DEFAULT_SECTIONS)
138 sections = util.sortdict(DEFAULT_SECTIONS)
139 custom_sections = getcustomadmonitions(repo)
139 custom_sections = getcustomadmonitions(repo)
140 if custom_sections:
140 if custom_sections:
141 sections.update(custom_sections)
141 sections.update(custom_sections)
142 self._sections = list(sections.iteritems())
142 self._sections = list(sections.iteritems())
143 else:
143 else:
144 self._sections = list(DEFAULT_SECTIONS)
144 self._sections = list(DEFAULT_SECTIONS)
145
145
146 def __iter__(self):
146 def __iter__(self):
147 return iter(self._sections)
147 return iter(self._sections)
148
148
149 def names(self):
149 def names(self):
150 return [t[0] for t in self._sections]
150 return [t[0] for t in self._sections]
151
151
152 def sectionfromtitle(self, title):
152 def sectionfromtitle(self, title):
153 for name, value in self._sections:
153 for name, value in self._sections:
154 if value == title:
154 if value == title:
155 return name
155 return name
156
156
157 return None
157 return None
158
158
159 def converttitled(titledparagraphs):
159 def converttitled(titledparagraphs):
160 """
160 """
161 Convert titled paragraphs to strings
161 Convert titled paragraphs to strings
162 """
162 """
163 string_list = []
163 string_list = []
164 for title, paragraphs in titledparagraphs:
164 for title, paragraphs in titledparagraphs:
165 lines = []
165 lines = []
166 for para in paragraphs:
166 for para in paragraphs:
167 lines.extend(para)
167 lines.extend(para)
168 string_list.append(' '.join(lines))
168 string_list.append(' '.join(lines))
169 return string_list
169 return string_list
170
170
171 def convertnontitled(nontitledparagraphs):
171 def convertnontitled(nontitledparagraphs):
172 """
172 """
173 Convert non-titled bullets to strings
173 Convert non-titled bullets to strings
174 """
174 """
175 string_list = []
175 string_list = []
176 for paragraphs in nontitledparagraphs:
176 for paragraphs in nontitledparagraphs:
177 lines = []
177 lines = []
178 for para in paragraphs:
178 for para in paragraphs:
179 lines.extend(para)
179 lines.extend(para)
180 string_list.append(' '.join(lines))
180 string_list.append(' '.join(lines))
181 return string_list
181 return string_list
182
182
183 def getissuenum(incoming_str):
183 def getissuenum(incoming_str):
184 """
184 """
185 Returns issue number from the incoming string if it exists
185 Returns issue number from the incoming string if it exists
186 """
186 """
187 issue = re.search(RE_ISSUE, incoming_str, re.IGNORECASE)
187 issue = re.search(RE_ISSUE, incoming_str, re.IGNORECASE)
188 if issue:
188 if issue:
189 issue = issue.group()
189 issue = issue.group()
190 return issue
190 return issue
191
191
192 def findissue(ui, existing, issue):
192 def findissue(ui, existing, issue):
193 """
193 """
194 Returns true if issue number already exists in notes.
194 Returns true if issue number already exists in notes.
195 """
195 """
196 if any(issue in s for s in existing):
196 if any(issue in s for s in existing):
197 ui.write(_('"%s" already exists in notes; ignoring\n') % issue)
197 ui.write(_('"%s" already exists in notes; ignoring\n') % issue)
198 return True
198 return True
199 else:
199 else:
200 return False
200 return False
201
201
202 def similar(ui, existing, incoming_str):
202 def similar(ui, existing, incoming_str):
203 """
203 """
204 Returns true if similar note found in existing notes.
204 Returns true if similar note found in existing notes.
205 """
205 """
206 if len(incoming_str.split()) > 10:
206 if len(incoming_str.split()) > 10:
207 merge = similaritycheck(incoming_str, existing)
207 merge = similaritycheck(incoming_str, existing)
208 if not merge:
208 if not merge:
209 ui.write(_('"%s" already exists in notes file; ignoring\n')
209 ui.write(_('"%s" already exists in notes file; ignoring\n')
210 % incoming_str)
210 % incoming_str)
211 return True
211 return True
212 else:
212 else:
213 return False
213 return False
214 else:
214 else:
215 return False
215 return False
216
216
217 def similaritycheck(incoming_str, existingnotes):
217 def similaritycheck(incoming_str, existingnotes):
218 """
218 """
219 Returns true when note fragment can be merged to existing notes.
219 Returns true when note fragment can be merged to existing notes.
220 """
220 """
221 import fuzzywuzzy.fuzz as fuzz
221 import fuzzywuzzy.fuzz as fuzz
222 merge = True
222 merge = True
223 for bullet in existingnotes:
223 for bullet in existingnotes:
224 score = fuzz.token_set_ratio(incoming_str, bullet)
224 score = fuzz.token_set_ratio(incoming_str, bullet)
225 if score > 75:
225 if score > 75:
226 merge = False
226 merge = False
227 break
227 break
228 return merge
228 return merge
229
229
230 def getcustomadmonitions(repo):
230 def getcustomadmonitions(repo):
231 ctx = repo['.']
231 ctx = repo['.']
232 p = config.config()
232 p = config.config()
233
233
234 def read(f, sections=None, remap=None):
234 def read(f, sections=None, remap=None):
235 if f in ctx:
235 if f in ctx:
236 data = ctx[f].data()
236 data = ctx[f].data()
237 p.parse(f, data, sections, remap, read)
237 p.parse(f, data, sections, remap, read)
238 else:
238 else:
239 raise error.Abort(_(".hgreleasenotes file \'%s\' not found") %
239 raise error.Abort(_(".hgreleasenotes file \'%s\' not found") %
240 repo.pathto(f))
240 repo.pathto(f))
241
241
242 if '.hgreleasenotes' in ctx:
242 if '.hgreleasenotes' in ctx:
243 read('.hgreleasenotes')
243 read('.hgreleasenotes')
244 return p['sections']
244 return p['sections']
245
245
246 def checkadmonitions(ui, repo, directives, revs):
246 def checkadmonitions(ui, repo, directives, revs):
247 """
247 """
248 Checks the commit messages for admonitions and their validity.
248 Checks the commit messages for admonitions and their validity.
249
249
250 .. abcd::
250 .. abcd::
251
251
252 First paragraph under this admonition
252 First paragraph under this admonition
253
253
254 For this commit message, using `hg releasenotes -r . --check`
254 For this commit message, using `hg releasenotes -r . --check`
255 returns: Invalid admonition 'abcd' present in changeset 3ea92981e103
255 returns: Invalid admonition 'abcd' present in changeset 3ea92981e103
256
256
257 As admonition 'abcd' is neither present in default nor custom admonitions
257 As admonition 'abcd' is neither present in default nor custom admonitions
258 """
258 """
259 for rev in revs:
259 for rev in revs:
260 ctx = repo[rev]
260 ctx = repo[rev]
261 admonition = re.search(RE_DIRECTIVE, ctx.description())
261 admonition = re.search(RE_DIRECTIVE, ctx.description())
262 if admonition:
262 if admonition:
263 if admonition.group(1) in directives:
263 if admonition.group(1) in directives:
264 continue
264 continue
265 else:
265 else:
266 ui.write(_("Invalid admonition '%s' present in changeset %s"
266 ui.write(_("Invalid admonition '%s' present in changeset %s"
267 "\n") % (admonition.group(1), ctx.hex()[:12]))
267 "\n") % (admonition.group(1), ctx.hex()[:12]))
268 sim = lambda x: difflib.SequenceMatcher(None,
268 sim = lambda x: difflib.SequenceMatcher(None,
269 admonition.group(1), x).ratio()
269 admonition.group(1), x).ratio()
270
270
271 similar = [s for s in directives if sim(s) > 0.6]
271 similar = [s for s in directives if sim(s) > 0.6]
272 if len(similar) == 1:
272 if len(similar) == 1:
273 ui.write(_("(did you mean %s?)\n") % similar[0])
273 ui.write(_("(did you mean %s?)\n") % similar[0])
274 elif similar:
274 elif similar:
275 ss = ", ".join(sorted(similar))
275 ss = ", ".join(sorted(similar))
276 ui.write(_("(did you mean one of %s?)\n") % ss)
276 ui.write(_("(did you mean one of %s?)\n") % ss)
277
277
278 def _getadmonitionlist(ui, sections):
278 def _getadmonitionlist(ui, sections):
279 for section in sections:
279 for section in sections:
280 ui.write("%s: %s\n" % (section[0], section[1]))
280 ui.write("%s: %s\n" % (section[0], section[1]))
281
281
282 def parsenotesfromrevisions(repo, directives, revs):
282 def parsenotesfromrevisions(repo, directives, revs):
283 notes = parsedreleasenotes()
283 notes = parsedreleasenotes()
284
284
285 for rev in revs:
285 for rev in revs:
286 ctx = repo[rev]
286 ctx = repo[rev]
287
287
288 blocks, pruned = minirst.parse(ctx.description(),
288 blocks, pruned = minirst.parse(ctx.description(),
289 admonitions=directives)
289 admonitions=directives)
290
290
291 for i, block in enumerate(blocks):
291 for i, block in enumerate(blocks):
292 if block['type'] != 'admonition':
292 if block['type'] != 'admonition':
293 continue
293 continue
294
294
295 directive = block['admonitiontitle']
295 directive = block['admonitiontitle']
296 title = block['lines'][0].strip() if block['lines'] else None
296 title = block['lines'][0].strip() if block['lines'] else None
297
297
298 if i + 1 == len(blocks):
298 if i + 1 == len(blocks):
299 raise error.Abort(_('release notes directive %s lacks content')
299 raise error.Abort(_('release notes directive %s lacks content')
300 % directive)
300 % directive)
301
301
302 # Now search ahead and find all paragraphs attached to this
302 # Now search ahead and find all paragraphs attached to this
303 # admonition.
303 # admonition.
304 paragraphs = []
304 paragraphs = []
305 for j in range(i + 1, len(blocks)):
305 for j in range(i + 1, len(blocks)):
306 pblock = blocks[j]
306 pblock = blocks[j]
307
307
308 # Margin blocks may appear between paragraphs. Ignore them.
308 # Margin blocks may appear between paragraphs. Ignore them.
309 if pblock['type'] == 'margin':
309 if pblock['type'] == 'margin':
310 continue
310 continue
311
311
312 if pblock['type'] != 'paragraph':
312 if pblock['type'] != 'paragraph':
313 raise error.Abort(_('unexpected block in release notes '
313 raise error.Abort(_('unexpected block in release notes '
314 'directive %s') % directive)
314 'directive %s') % directive)
315
315
316 if pblock['indent'] > 0:
316 if pblock['indent'] > 0:
317 paragraphs.append(pblock['lines'])
317 paragraphs.append(pblock['lines'])
318 else:
318 else:
319 break
319 break
320
320
321 # TODO consider using title as paragraph for more concise notes.
321 # TODO consider using title as paragraph for more concise notes.
322 if not paragraphs:
322 if not paragraphs:
323 raise error.Abort(_('could not find content for release note '
323 raise error.Abort(_('could not find content for release note '
324 '%s') % directive)
324 '%s') % directive)
325
325
326 if title:
326 if title:
327 notes.addtitleditem(directive, title, paragraphs)
327 notes.addtitleditem(directive, title, paragraphs)
328 else:
328 else:
329 notes.addnontitleditem(directive, paragraphs)
329 notes.addnontitleditem(directive, paragraphs)
330
330
331 return notes
331 return notes
332
332
333 def parsereleasenotesfile(sections, text):
333 def parsereleasenotesfile(sections, text):
334 """Parse text content containing generated release notes."""
334 """Parse text content containing generated release notes."""
335 notes = parsedreleasenotes()
335 notes = parsedreleasenotes()
336
336
337 blocks = minirst.parse(text)[0]
337 blocks = minirst.parse(text)[0]
338
338
339 def gatherparagraphsbullets(offset, title=False):
339 def gatherparagraphsbullets(offset, title=False):
340 notefragment = []
340 notefragment = []
341
341
342 for i in range(offset + 1, len(blocks)):
342 for i in range(offset + 1, len(blocks)):
343 block = blocks[i]
343 block = blocks[i]
344
344
345 if block['type'] == 'margin':
345 if block['type'] == 'margin':
346 continue
346 continue
347 elif block['type'] == 'section':
347 elif block['type'] == 'section':
348 break
348 break
349 elif block['type'] == 'bullet':
349 elif block['type'] == 'bullet':
350 if block['indent'] != 0:
350 if block['indent'] != 0:
351 raise error.Abort(_('indented bullet lists not supported'))
351 raise error.Abort(_('indented bullet lists not supported'))
352 if title:
352 if title:
353 lines = [l[1:].strip() for l in block['lines']]
353 lines = [l[1:].strip() for l in block['lines']]
354 notefragment.append(lines)
354 notefragment.append(lines)
355 continue
355 continue
356 else:
356 else:
357 lines = [[l[1:].strip() for l in block['lines']]]
357 lines = [[l[1:].strip() for l in block['lines']]]
358
358
359 for block in blocks[i + 1:]:
359 for block in blocks[i + 1:]:
360 if block['type'] in ('bullet', 'section'):
360 if block['type'] in ('bullet', 'section'):
361 break
361 break
362 if block['type'] == 'paragraph':
362 if block['type'] == 'paragraph':
363 lines.append(block['lines'])
363 lines.append(block['lines'])
364 notefragment.append(lines)
364 notefragment.append(lines)
365 continue
365 continue
366 elif block['type'] != 'paragraph':
366 elif block['type'] != 'paragraph':
367 raise error.Abort(_('unexpected block type in release notes: '
367 raise error.Abort(_('unexpected block type in release notes: '
368 '%s') % block['type'])
368 '%s') % block['type'])
369 if title:
369 if title:
370 notefragment.append(block['lines'])
370 notefragment.append(block['lines'])
371
371
372 return notefragment
372 return notefragment
373
373
374 currentsection = None
374 currentsection = None
375 for i, block in enumerate(blocks):
375 for i, block in enumerate(blocks):
376 if block['type'] != 'section':
376 if block['type'] != 'section':
377 continue
377 continue
378
378
379 title = block['lines'][0]
379 title = block['lines'][0]
380
380
381 # TODO the parsing around paragraphs and bullet points needs some
381 # TODO the parsing around paragraphs and bullet points needs some
382 # work.
382 # work.
383 if block['underline'] == '=': # main section
383 if block['underline'] == '=': # main section
384 name = sections.sectionfromtitle(title)
384 name = sections.sectionfromtitle(title)
385 if not name:
385 if not name:
386 raise error.Abort(_('unknown release notes section: %s') %
386 raise error.Abort(_('unknown release notes section: %s') %
387 title)
387 title)
388
388
389 currentsection = name
389 currentsection = name
390 bullet_points = gatherparagraphsbullets(i)
390 bullet_points = gatherparagraphsbullets(i)
391 if bullet_points:
391 if bullet_points:
392 for para in bullet_points:
392 for para in bullet_points:
393 notes.addnontitleditem(currentsection, para)
393 notes.addnontitleditem(currentsection, para)
394
394
395 elif block['underline'] == '-': # sub-section
395 elif block['underline'] == '-': # sub-section
396 if title == BULLET_SECTION:
396 if title == BULLET_SECTION:
397 bullet_points = gatherparagraphsbullets(i)
397 bullet_points = gatherparagraphsbullets(i)
398 for para in bullet_points:
398 for para in bullet_points:
399 notes.addnontitleditem(currentsection, para)
399 notes.addnontitleditem(currentsection, para)
400 else:
400 else:
401 paragraphs = gatherparagraphsbullets(i, True)
401 paragraphs = gatherparagraphsbullets(i, True)
402 notes.addtitleditem(currentsection, title, paragraphs)
402 notes.addtitleditem(currentsection, title, paragraphs)
403 else:
403 else:
404 raise error.Abort(_('unsupported section type for %s') % title)
404 raise error.Abort(_('unsupported section type for %s') % title)
405
405
406 return notes
406 return notes
407
407
408 def serializenotes(sections, notes):
408 def serializenotes(sections, notes):
409 """Serialize release notes from parsed fragments and notes.
409 """Serialize release notes from parsed fragments and notes.
410
410
411 This function essentially takes the output of ``parsenotesfromrevisions()``
411 This function essentially takes the output of ``parsenotesfromrevisions()``
412 and ``parserelnotesfile()`` and produces output combining the 2.
412 and ``parserelnotesfile()`` and produces output combining the 2.
413 """
413 """
414 lines = []
414 lines = []
415
415
416 for sectionname, sectiontitle in sections:
416 for sectionname, sectiontitle in sections:
417 if sectionname not in notes:
417 if sectionname not in notes:
418 continue
418 continue
419
419
420 lines.append(sectiontitle)
420 lines.append(sectiontitle)
421 lines.append('=' * len(sectiontitle))
421 lines.append('=' * len(sectiontitle))
422 lines.append('')
422 lines.append('')
423
423
424 # First pass to emit sub-sections.
424 # First pass to emit sub-sections.
425 for title, paragraphs in notes.titledforsection(sectionname):
425 for title, paragraphs in notes.titledforsection(sectionname):
426 lines.append(title)
426 lines.append(title)
427 lines.append('-' * len(title))
427 lines.append('-' * len(title))
428 lines.append('')
428 lines.append('')
429
429
430 wrapper = textwrap.TextWrapper(width=78)
430 wrapper = textwrap.TextWrapper(width=78)
431 for i, para in enumerate(paragraphs):
431 for i, para in enumerate(paragraphs):
432 if i:
432 if i:
433 lines.append('')
433 lines.append('')
434 lines.extend(wrapper.wrap(' '.join(para)))
434 lines.extend(wrapper.wrap(' '.join(para)))
435
435
436 lines.append('')
436 lines.append('')
437
437
438 # Second pass to emit bullet list items.
438 # Second pass to emit bullet list items.
439
439
440 # If the section has titled and non-titled items, we can't
440 # If the section has titled and non-titled items, we can't
441 # simply emit the bullet list because it would appear to come
441 # simply emit the bullet list because it would appear to come
442 # from the last title/section. So, we emit a new sub-section
442 # from the last title/section. So, we emit a new sub-section
443 # for the non-titled items.
443 # for the non-titled items.
444 nontitled = notes.nontitledforsection(sectionname)
444 nontitled = notes.nontitledforsection(sectionname)
445 if notes.titledforsection(sectionname) and nontitled:
445 if notes.titledforsection(sectionname) and nontitled:
446 # TODO make configurable.
446 # TODO make configurable.
447 lines.append(BULLET_SECTION)
447 lines.append(BULLET_SECTION)
448 lines.append('-' * len(BULLET_SECTION))
448 lines.append('-' * len(BULLET_SECTION))
449 lines.append('')
449 lines.append('')
450
450
451 for paragraphs in nontitled:
451 for paragraphs in nontitled:
452 wrapper = textwrap.TextWrapper(initial_indent='* ',
452 wrapper = textwrap.TextWrapper(initial_indent='* ',
453 subsequent_indent=' ',
453 subsequent_indent=' ',
454 width=78)
454 width=78)
455 lines.extend(wrapper.wrap(' '.join(paragraphs[0])))
455 lines.extend(wrapper.wrap(' '.join(paragraphs[0])))
456
456
457 wrapper = textwrap.TextWrapper(initial_indent=' ',
457 wrapper = textwrap.TextWrapper(initial_indent=' ',
458 subsequent_indent=' ',
458 subsequent_indent=' ',
459 width=78)
459 width=78)
460 for para in paragraphs[1:]:
460 for para in paragraphs[1:]:
461 lines.append('')
461 lines.append('')
462 lines.extend(wrapper.wrap(' '.join(para)))
462 lines.extend(wrapper.wrap(' '.join(para)))
463
463
464 lines.append('')
464 lines.append('')
465
465
466 if lines and lines[-1]:
466 if lines and lines[-1]:
467 lines.append('')
467 lines.append('')
468
468
469 return '\n'.join(lines)
469 return '\n'.join(lines)
470
470
471 @command('releasenotes',
471 @command('releasenotes',
472 [('r', 'rev', '', _('revisions to process for release notes'), _('REV')),
472 [('r', 'rev', '', _('revisions to process for release notes'), _('REV')),
473 ('c', 'check', False, _('checks for validity of admonitions (if any)'),
473 ('c', 'check', False, _('checks for validity of admonitions (if any)'),
474 _('REV')),
474 _('REV')),
475 ('l', 'list', False, _('list the available admonitions with their title'),
475 ('l', 'list', False, _('list the available admonitions with their title'),
476 None)],
476 None)],
477 _('hg releasenotes [-r REV] [-c] FILE'))
477 _('hg releasenotes [-r REV] [-c] FILE'))
478 def releasenotes(ui, repo, file_=None, **opts):
478 def releasenotes(ui, repo, file_=None, **opts):
479 """parse release notes from commit messages into an output file
479 """parse release notes from commit messages into an output file
480
480
481 Given an output file and set of revisions, this command will parse commit
481 Given an output file and set of revisions, this command will parse commit
482 messages for release notes then add them to the output file.
482 messages for release notes then add them to the output file.
483
483
484 Release notes are defined in commit messages as ReStructuredText
484 Release notes are defined in commit messages as ReStructuredText
485 directives. These have the form::
485 directives. These have the form::
486
486
487 .. directive:: title
487 .. directive:: title
488
488
489 content
489 content
490
490
491 Each ``directive`` maps to an output section in a generated release notes
491 Each ``directive`` maps to an output section in a generated release notes
492 file, which itself is ReStructuredText. For example, the ``.. feature::``
492 file, which itself is ReStructuredText. For example, the ``.. feature::``
493 directive would map to a ``New Features`` section.
493 directive would map to a ``New Features`` section.
494
494
495 Release note directives can be either short-form or long-form. In short-
495 Release note directives can be either short-form or long-form. In short-
496 form, ``title`` is omitted and the release note is rendered as a bullet
496 form, ``title`` is omitted and the release note is rendered as a bullet
497 list. In long form, a sub-section with the title ``title`` is added to the
497 list. In long form, a sub-section with the title ``title`` is added to the
498 section.
498 section.
499
499
500 The ``FILE`` argument controls the output file to write gathered release
500 The ``FILE`` argument controls the output file to write gathered release
501 notes to. The format of the file is::
501 notes to. The format of the file is::
502
502
503 Section 1
503 Section 1
504 =========
504 =========
505
505
506 ...
506 ...
507
507
508 Section 2
508 Section 2
509 =========
509 =========
510
510
511 ...
511 ...
512
512
513 Only sections with defined release notes are emitted.
513 Only sections with defined release notes are emitted.
514
514
515 If a section only has short-form notes, it will consist of bullet list::
515 If a section only has short-form notes, it will consist of bullet list::
516
516
517 Section
517 Section
518 =======
518 =======
519
519
520 * Release note 1
520 * Release note 1
521 * Release note 2
521 * Release note 2
522
522
523 If a section has long-form notes, sub-sections will be emitted::
523 If a section has long-form notes, sub-sections will be emitted::
524
524
525 Section
525 Section
526 =======
526 =======
527
527
528 Note 1 Title
528 Note 1 Title
529 ------------
529 ------------
530
530
531 Description of the first long-form note.
531 Description of the first long-form note.
532
532
533 Note 2 Title
533 Note 2 Title
534 ------------
534 ------------
535
535
536 Description of the second long-form note.
536 Description of the second long-form note.
537
537
538 If the ``FILE`` argument points to an existing file, that file will be
538 If the ``FILE`` argument points to an existing file, that file will be
539 parsed for release notes having the format that would be generated by this
539 parsed for release notes having the format that would be generated by this
540 command. The notes from the processed commit messages will be *merged*
540 command. The notes from the processed commit messages will be *merged*
541 into this parsed set.
541 into this parsed set.
542
542
543 During release notes merging:
543 During release notes merging:
544
544
545 * Duplicate items are automatically ignored
545 * Duplicate items are automatically ignored
546 * Items that are different are automatically ignored if the similarity is
546 * Items that are different are automatically ignored if the similarity is
547 greater than a threshold.
547 greater than a threshold.
548
548
549 This means that the release notes file can be updated independently from
549 This means that the release notes file can be updated independently from
550 this command and changes should not be lost when running this command on
550 this command and changes should not be lost when running this command on
551 that file. A particular use case for this is to tweak the wording of a
551 that file. A particular use case for this is to tweak the wording of a
552 release note after it has been added to the release notes file.
552 release note after it has been added to the release notes file.
553
554 The -c/--check option checks the commit message for invalid admonitions.
555
556 The -l/--list option, presents the user with a list of existing available
557 admonitions along with their title. This also includes the custom
558 admonitions (if any).
553 """
559 """
554 sections = releasenotessections(ui, repo)
560 sections = releasenotessections(ui, repo)
555
561
556 listflag = opts.get('list')
562 listflag = opts.get('list')
557
563
558 if listflag and opts.get('rev'):
564 if listflag and opts.get('rev'):
559 raise error.Abort(_('cannot use both \'--list\' and \'--rev\''))
565 raise error.Abort(_('cannot use both \'--list\' and \'--rev\''))
560 if listflag and opts.get('check'):
566 if listflag and opts.get('check'):
561 raise error.Abort(_('cannot use both \'--list\' and \'--check\''))
567 raise error.Abort(_('cannot use both \'--list\' and \'--check\''))
562
568
563 if listflag:
569 if listflag:
564 return _getadmonitionlist(ui, sections)
570 return _getadmonitionlist(ui, sections)
565
571
566 rev = opts.get('rev')
572 rev = opts.get('rev')
567 revs = scmutil.revrange(repo, [rev or 'not public()'])
573 revs = scmutil.revrange(repo, [rev or 'not public()'])
568 if opts.get('check'):
574 if opts.get('check'):
569 return checkadmonitions(ui, repo, sections.names(), revs)
575 return checkadmonitions(ui, repo, sections.names(), revs)
570
576
571 incoming = parsenotesfromrevisions(repo, sections.names(), revs)
577 incoming = parsenotesfromrevisions(repo, sections.names(), revs)
572
578
573 try:
579 try:
574 with open(file_, 'rb') as fh:
580 with open(file_, 'rb') as fh:
575 notes = parsereleasenotesfile(sections, fh.read())
581 notes = parsereleasenotesfile(sections, fh.read())
576 except IOError as e:
582 except IOError as e:
577 if e.errno != errno.ENOENT:
583 if e.errno != errno.ENOENT:
578 raise
584 raise
579
585
580 notes = parsedreleasenotes()
586 notes = parsedreleasenotes()
581
587
582 notes.merge(ui, incoming)
588 notes.merge(ui, incoming)
583
589
584 with open(file_, 'wb') as fh:
590 with open(file_, 'wb') as fh:
585 fh.write(serializenotes(sections, notes))
591 fh.write(serializenotes(sections, notes))
586
592
587 @command('debugparsereleasenotes', norepo=True)
593 @command('debugparsereleasenotes', norepo=True)
588 def debugparsereleasenotes(ui, path, repo=None):
594 def debugparsereleasenotes(ui, path, repo=None):
589 """parse release notes and print resulting data structure"""
595 """parse release notes and print resulting data structure"""
590 if path == '-':
596 if path == '-':
591 text = sys.stdin.read()
597 text = sys.stdin.read()
592 else:
598 else:
593 with open(path, 'rb') as fh:
599 with open(path, 'rb') as fh:
594 text = fh.read()
600 text = fh.read()
595
601
596 sections = releasenotessections(ui, repo)
602 sections = releasenotessections(ui, repo)
597
603
598 notes = parsereleasenotesfile(sections, text)
604 notes = parsereleasenotesfile(sections, text)
599
605
600 for section in notes:
606 for section in notes:
601 ui.write(_('section: %s\n') % section)
607 ui.write(_('section: %s\n') % section)
602 for title, paragraphs in notes.titledforsection(section):
608 for title, paragraphs in notes.titledforsection(section):
603 ui.write(_(' subsection: %s\n') % title)
609 ui.write(_(' subsection: %s\n') % title)
604 for para in paragraphs:
610 for para in paragraphs:
605 ui.write(_(' paragraph: %s\n') % ' '.join(para))
611 ui.write(_(' paragraph: %s\n') % ' '.join(para))
606
612
607 for paragraphs in notes.nontitledforsection(section):
613 for paragraphs in notes.nontitledforsection(section):
608 ui.write(_(' bullet point:\n'))
614 ui.write(_(' bullet point:\n'))
609 for para in paragraphs:
615 for para in paragraphs:
610 ui.write(_(' paragraph: %s\n') % ' '.join(para))
616 ui.write(_(' paragraph: %s\n') % ' '.join(para))
General Comments 0
You need to be logged in to leave comments. Login now