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