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