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