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