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