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