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