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