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