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