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