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