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