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