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