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