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