hgext/releasenotes.py
changeset 43076 2372284d9457
parent 41759 aaad36b88298
child 43077 687b865b95ad
equal deleted inserted replaced
43075:57875cf423c9 43076:2372284d9457
    26     pycompat,
    26     pycompat,
    27     registrar,
    27     registrar,
    28     scmutil,
    28     scmutil,
    29     util,
    29     util,
    30 )
    30 )
    31 from mercurial.utils import (
    31 from mercurial.utils import stringutil
    32     stringutil,
       
    33 )
       
    34 
    32 
    35 cmdtable = {}
    33 cmdtable = {}
    36 command = registrar.command(cmdtable)
    34 command = registrar.command(cmdtable)
    37 
    35 
    38 try:
    36 try:
    39     import fuzzywuzzy.fuzz as fuzz
    37     import fuzzywuzzy.fuzz as fuzz
       
    38 
    40     fuzz.token_set_ratio
    39     fuzz.token_set_ratio
    41 except ImportError:
    40 except ImportError:
    42     fuzz = None
    41     fuzz = None
    43 
    42 
    44 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
    43 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
    58 RE_DIRECTIVE = re.compile(br'^\.\. ([a-zA-Z0-9_]+)::\s*([^$]+)?$')
    57 RE_DIRECTIVE = re.compile(br'^\.\. ([a-zA-Z0-9_]+)::\s*([^$]+)?$')
    59 RE_ISSUE = br'\bissue ?[0-9]{4,6}(?![0-9])\b'
    58 RE_ISSUE = br'\bissue ?[0-9]{4,6}(?![0-9])\b'
    60 
    59 
    61 BULLET_SECTION = _('Other Changes')
    60 BULLET_SECTION = _('Other Changes')
    62 
    61 
       
    62 
    63 class parsedreleasenotes(object):
    63 class parsedreleasenotes(object):
    64     def __init__(self):
    64     def __init__(self):
    65         self.sections = {}
    65         self.sections = {}
    66 
    66 
    67     def __contains__(self, section):
    67     def __contains__(self, section):
   101         """Merge another instance into this one.
   101         """Merge another instance into this one.
   102 
   102 
   103         This is used to combine multiple sources of release notes together.
   103         This is used to combine multiple sources of release notes together.
   104         """
   104         """
   105         if not fuzz:
   105         if not fuzz:
   106             ui.warn(_("module 'fuzzywuzzy' not found, merging of similar "
   106             ui.warn(
   107                       "releasenotes is disabled\n"))
   107                 _(
       
   108                     "module 'fuzzywuzzy' not found, merging of similar "
       
   109                     "releasenotes is disabled\n"
       
   110                 )
       
   111             )
   108 
   112 
   109         for section in other:
   113         for section in other:
   110             existingnotes = (
   114             existingnotes = converttitled(
   111                 converttitled(self.titledforsection(section)) +
   115                 self.titledforsection(section)
   112                 convertnontitled(self.nontitledforsection(section)))
   116             ) + convertnontitled(self.nontitledforsection(section))
   113             for title, paragraphs in other.titledforsection(section):
   117             for title, paragraphs in other.titledforsection(section):
   114                 if self.hastitledinsection(section, title):
   118                 if self.hastitledinsection(section, title):
   115                     # TODO prompt for resolution if different and running in
   119                     # TODO prompt for resolution if different and running in
   116                     # interactive mode.
   120                     # interactive mode.
   117                     ui.write(_('%s already exists in %s section; ignoring\n') %
   121                     ui.write(
   118                              (title, section))
   122                         _('%s already exists in %s section; ignoring\n')
       
   123                         % (title, section)
       
   124                     )
   119                     continue
   125                     continue
   120 
   126 
   121                 incoming_str = converttitled([(title, paragraphs)])[0]
   127                 incoming_str = converttitled([(title, paragraphs)])[0]
   122                 if section == 'fix':
   128                 if section == 'fix':
   123                     issue = getissuenum(incoming_str)
   129                     issue = getissuenum(incoming_str)
   143 
   149 
   144                 if similar(ui, existingnotes, incoming_str):
   150                 if similar(ui, existingnotes, incoming_str):
   145                     continue
   151                     continue
   146 
   152 
   147                 self.addnontitleditem(section, paragraphs)
   153                 self.addnontitleditem(section, paragraphs)
       
   154 
   148 
   155 
   149 class releasenotessections(object):
   156 class releasenotessections(object):
   150     def __init__(self, ui, repo=None):
   157     def __init__(self, ui, repo=None):
   151         if repo:
   158         if repo:
   152             sections = util.sortdict(DEFAULT_SECTIONS)
   159             sections = util.sortdict(DEFAULT_SECTIONS)
   168             if value == title:
   175             if value == title:
   169                 return name
   176                 return name
   170 
   177 
   171         return None
   178         return None
   172 
   179 
       
   180 
   173 def converttitled(titledparagraphs):
   181 def converttitled(titledparagraphs):
   174     """
   182     """
   175     Convert titled paragraphs to strings
   183     Convert titled paragraphs to strings
   176     """
   184     """
   177     string_list = []
   185     string_list = []
   180         for para in paragraphs:
   188         for para in paragraphs:
   181             lines.extend(para)
   189             lines.extend(para)
   182         string_list.append(' '.join(lines))
   190         string_list.append(' '.join(lines))
   183     return string_list
   191     return string_list
   184 
   192 
       
   193 
   185 def convertnontitled(nontitledparagraphs):
   194 def convertnontitled(nontitledparagraphs):
   186     """
   195     """
   187     Convert non-titled bullets to strings
   196     Convert non-titled bullets to strings
   188     """
   197     """
   189     string_list = []
   198     string_list = []
   192         for para in paragraphs:
   201         for para in paragraphs:
   193             lines.extend(para)
   202             lines.extend(para)
   194         string_list.append(' '.join(lines))
   203         string_list.append(' '.join(lines))
   195     return string_list
   204     return string_list
   196 
   205 
       
   206 
   197 def getissuenum(incoming_str):
   207 def getissuenum(incoming_str):
   198     """
   208     """
   199     Returns issue number from the incoming string if it exists
   209     Returns issue number from the incoming string if it exists
   200     """
   210     """
   201     issue = re.search(RE_ISSUE, incoming_str, re.IGNORECASE)
   211     issue = re.search(RE_ISSUE, incoming_str, re.IGNORECASE)
   202     if issue:
   212     if issue:
   203         issue = issue.group()
   213         issue = issue.group()
   204     return issue
   214     return issue
       
   215 
   205 
   216 
   206 def findissue(ui, existing, issue):
   217 def findissue(ui, existing, issue):
   207     """
   218     """
   208     Returns true if issue number already exists in notes.
   219     Returns true if issue number already exists in notes.
   209     """
   220     """
   211         ui.write(_('"%s" already exists in notes; ignoring\n') % issue)
   222         ui.write(_('"%s" already exists in notes; ignoring\n') % issue)
   212         return True
   223         return True
   213     else:
   224     else:
   214         return False
   225         return False
   215 
   226 
       
   227 
   216 def similar(ui, existing, incoming_str):
   228 def similar(ui, existing, incoming_str):
   217     """
   229     """
   218     Returns true if similar note found in existing notes.
   230     Returns true if similar note found in existing notes.
   219     """
   231     """
   220     if len(incoming_str.split()) > 10:
   232     if len(incoming_str.split()) > 10:
   221         merge = similaritycheck(incoming_str, existing)
   233         merge = similaritycheck(incoming_str, existing)
   222         if not merge:
   234         if not merge:
   223             ui.write(_('"%s" already exists in notes file; ignoring\n')
   235             ui.write(
   224                      % incoming_str)
   236                 _('"%s" already exists in notes file; ignoring\n')
       
   237                 % incoming_str
       
   238             )
   225             return True
   239             return True
   226         else:
   240         else:
   227             return False
   241             return False
   228     else:
   242     else:
   229         return False
   243         return False
       
   244 
   230 
   245 
   231 def similaritycheck(incoming_str, existingnotes):
   246 def similaritycheck(incoming_str, existingnotes):
   232     """
   247     """
   233     Returns false when note fragment can be merged to existing notes.
   248     Returns false when note fragment can be merged to existing notes.
   234     """
   249     """
   242         if score > 75:
   257         if score > 75:
   243             merge = False
   258             merge = False
   244             break
   259             break
   245     return merge
   260     return merge
   246 
   261 
       
   262 
   247 def getcustomadmonitions(repo):
   263 def getcustomadmonitions(repo):
   248     ctx = repo['.']
   264     ctx = repo['.']
   249     p = config.config()
   265     p = config.config()
   250 
   266 
   251     def read(f, sections=None, remap=None):
   267     def read(f, sections=None, remap=None):
   252         if f in ctx:
   268         if f in ctx:
   253             data = ctx[f].data()
   269             data = ctx[f].data()
   254             p.parse(f, data, sections, remap, read)
   270             p.parse(f, data, sections, remap, read)
   255         else:
   271         else:
   256             raise error.Abort(_(".hgreleasenotes file \'%s\' not found") %
   272             raise error.Abort(
   257                               repo.pathto(f))
   273                 _(".hgreleasenotes file \'%s\' not found") % repo.pathto(f)
       
   274             )
   258 
   275 
   259     if '.hgreleasenotes' in ctx:
   276     if '.hgreleasenotes' in ctx:
   260         read('.hgreleasenotes')
   277         read('.hgreleasenotes')
   261     return p['sections']
   278     return p['sections']
       
   279 
   262 
   280 
   263 def checkadmonitions(ui, repo, directives, revs):
   281 def checkadmonitions(ui, repo, directives, revs):
   264     """
   282     """
   265     Checks the commit messages for admonitions and their validity.
   283     Checks the commit messages for admonitions and their validity.
   266 
   284 
   278         admonition = re.search(RE_DIRECTIVE, ctx.description())
   296         admonition = re.search(RE_DIRECTIVE, ctx.description())
   279         if admonition:
   297         if admonition:
   280             if admonition.group(1) in directives:
   298             if admonition.group(1) in directives:
   281                 continue
   299                 continue
   282             else:
   300             else:
   283                 ui.write(_("Invalid admonition '%s' present in changeset %s"
   301                 ui.write(
   284                            "\n") % (admonition.group(1), ctx.hex()[:12]))
   302                     _("Invalid admonition '%s' present in changeset %s" "\n")
   285                 sim = lambda x: difflib.SequenceMatcher(None,
   303                     % (admonition.group(1), ctx.hex()[:12])
   286                     admonition.group(1), x).ratio()
   304                 )
       
   305                 sim = lambda x: difflib.SequenceMatcher(
       
   306                     None, admonition.group(1), x
       
   307                 ).ratio()
   287 
   308 
   288                 similar = [s for s in directives if sim(s) > 0.6]
   309                 similar = [s for s in directives if sim(s) > 0.6]
   289                 if len(similar) == 1:
   310                 if len(similar) == 1:
   290                     ui.write(_("(did you mean %s?)\n") % similar[0])
   311                     ui.write(_("(did you mean %s?)\n") % similar[0])
   291                 elif similar:
   312                 elif similar:
   292                     ss = ", ".join(sorted(similar))
   313                     ss = ", ".join(sorted(similar))
   293                     ui.write(_("(did you mean one of %s?)\n") % ss)
   314                     ui.write(_("(did you mean one of %s?)\n") % ss)
   294 
   315 
       
   316 
   295 def _getadmonitionlist(ui, sections):
   317 def _getadmonitionlist(ui, sections):
   296     for section in sections:
   318     for section in sections:
   297         ui.write("%s: %s\n" % (section[0], section[1]))
   319         ui.write("%s: %s\n" % (section[0], section[1]))
   298 
   320 
       
   321 
   299 def parsenotesfromrevisions(repo, directives, revs):
   322 def parsenotesfromrevisions(repo, directives, revs):
   300     notes = parsedreleasenotes()
   323     notes = parsedreleasenotes()
   301 
   324 
   302     for rev in revs:
   325     for rev in revs:
   303         ctx = repo[rev]
   326         ctx = repo[rev]
   304 
   327 
   305         blocks, pruned = minirst.parse(ctx.description(),
   328         blocks, pruned = minirst.parse(
   306                                        admonitions=directives)
   329             ctx.description(), admonitions=directives
       
   330         )
   307 
   331 
   308         for i, block in enumerate(blocks):
   332         for i, block in enumerate(blocks):
   309             if block['type'] != 'admonition':
   333             if block['type'] != 'admonition':
   310                 continue
   334                 continue
   311 
   335 
   312             directive = block['admonitiontitle']
   336             directive = block['admonitiontitle']
   313             title = block['lines'][0].strip() if block['lines'] else None
   337             title = block['lines'][0].strip() if block['lines'] else None
   314 
   338 
   315             if i + 1 == len(blocks):
   339             if i + 1 == len(blocks):
   316                 raise error.Abort(_('changeset %s: release notes directive %s '
   340                 raise error.Abort(
   317                         'lacks content') % (ctx, directive))
   341                     _(
       
   342                         'changeset %s: release notes directive %s '
       
   343                         'lacks content'
       
   344                     )
       
   345                     % (ctx, directive)
       
   346                 )
   318 
   347 
   319             # Now search ahead and find all paragraphs attached to this
   348             # Now search ahead and find all paragraphs attached to this
   320             # admonition.
   349             # admonition.
   321             paragraphs = []
   350             paragraphs = []
   322             for j in range(i + 1, len(blocks)):
   351             for j in range(i + 1, len(blocks)):
   328 
   357 
   329                 if pblock['type'] == 'admonition':
   358                 if pblock['type'] == 'admonition':
   330                     break
   359                     break
   331 
   360 
   332                 if pblock['type'] != 'paragraph':
   361                 if pblock['type'] != 'paragraph':
   333                     repo.ui.warn(_('changeset %s: unexpected block in release '
   362                     repo.ui.warn(
   334                         'notes directive %s\n') % (ctx, directive))
   363                         _(
       
   364                             'changeset %s: unexpected block in release '
       
   365                             'notes directive %s\n'
       
   366                         )
       
   367                         % (ctx, directive)
       
   368                     )
   335 
   369 
   336                 if pblock['indent'] > 0:
   370                 if pblock['indent'] > 0:
   337                     paragraphs.append(pblock['lines'])
   371                     paragraphs.append(pblock['lines'])
   338                 else:
   372                 else:
   339                     break
   373                     break
   340 
   374 
   341             # TODO consider using title as paragraph for more concise notes.
   375             # TODO consider using title as paragraph for more concise notes.
   342             if not paragraphs:
   376             if not paragraphs:
   343                 repo.ui.warn(_("error parsing releasenotes for revision: "
   377                 repo.ui.warn(
   344                                "'%s'\n") % node.hex(ctx.node()))
   378                     _("error parsing releasenotes for revision: " "'%s'\n")
       
   379                     % node.hex(ctx.node())
       
   380                 )
   345             if title:
   381             if title:
   346                 notes.addtitleditem(directive, title, paragraphs)
   382                 notes.addtitleditem(directive, title, paragraphs)
   347             else:
   383             else:
   348                 notes.addnontitleditem(directive, paragraphs)
   384                 notes.addnontitleditem(directive, paragraphs)
   349 
   385 
   350     return notes
   386     return notes
       
   387 
   351 
   388 
   352 def parsereleasenotesfile(sections, text):
   389 def parsereleasenotesfile(sections, text):
   353     """Parse text content containing generated release notes."""
   390     """Parse text content containing generated release notes."""
   354     notes = parsedreleasenotes()
   391     notes = parsedreleasenotes()
   355 
   392 
   373                     notefragment.append(lines)
   410                     notefragment.append(lines)
   374                     continue
   411                     continue
   375                 else:
   412                 else:
   376                     lines = [[l[1:].strip() for l in block['lines']]]
   413                     lines = [[l[1:].strip() for l in block['lines']]]
   377 
   414 
   378                     for block in blocks[i + 1:]:
   415                     for block in blocks[i + 1 :]:
   379                         if block['type'] in ('bullet', 'section'):
   416                         if block['type'] in ('bullet', 'section'):
   380                             break
   417                             break
   381                         if block['type'] == 'paragraph':
   418                         if block['type'] == 'paragraph':
   382                             lines.append(block['lines'])
   419                             lines.append(block['lines'])
   383                     notefragment.append(lines)
   420                     notefragment.append(lines)
   384                     continue
   421                     continue
   385             elif block['type'] != 'paragraph':
   422             elif block['type'] != 'paragraph':
   386                 raise error.Abort(_('unexpected block type in release notes: '
   423                 raise error.Abort(
   387                                     '%s') % block['type'])
   424                     _('unexpected block type in release notes: ' '%s')
       
   425                     % block['type']
       
   426                 )
   388             if title:
   427             if title:
   389                 notefragment.append(block['lines'])
   428                 notefragment.append(block['lines'])
   390 
   429 
   391         return notefragment
   430         return notefragment
   392 
   431 
   400         # TODO the parsing around paragraphs and bullet points needs some
   439         # TODO the parsing around paragraphs and bullet points needs some
   401         # work.
   440         # work.
   402         if block['underline'] == '=':  # main section
   441         if block['underline'] == '=':  # main section
   403             name = sections.sectionfromtitle(title)
   442             name = sections.sectionfromtitle(title)
   404             if not name:
   443             if not name:
   405                 raise error.Abort(_('unknown release notes section: %s') %
   444                 raise error.Abort(
   406                                   title)
   445                     _('unknown release notes section: %s') % title
       
   446                 )
   407 
   447 
   408             currentsection = name
   448             currentsection = name
   409             bullet_points = gatherparagraphsbullets(i)
   449             bullet_points = gatherparagraphsbullets(i)
   410             if bullet_points:
   450             if bullet_points:
   411                 for para in bullet_points:
   451                 for para in bullet_points:
   422         else:
   462         else:
   423             raise error.Abort(_('unsupported section type for %s') % title)
   463             raise error.Abort(_('unsupported section type for %s') % title)
   424 
   464 
   425     return notes
   465     return notes
   426 
   466 
       
   467 
   427 def serializenotes(sections, notes):
   468 def serializenotes(sections, notes):
   428     """Serialize release notes from parsed fragments and notes.
   469     """Serialize release notes from parsed fragments and notes.
   429 
   470 
   430     This function essentially takes the output of ``parsenotesfromrevisions()``
   471     This function essentially takes the output of ``parsenotesfromrevisions()``
   431     and ``parserelnotesfile()`` and produces output combining the 2.
   472     and ``parserelnotesfile()`` and produces output combining the 2.
   447             lines.append('')
   488             lines.append('')
   448 
   489 
   449             for i, para in enumerate(paragraphs):
   490             for i, para in enumerate(paragraphs):
   450                 if i:
   491                 if i:
   451                     lines.append('')
   492                     lines.append('')
   452                 lines.extend(stringutil.wrap(' '.join(para),
   493                 lines.extend(
   453                                              width=78).splitlines())
   494                     stringutil.wrap(' '.join(para), width=78).splitlines()
       
   495                 )
   454 
   496 
   455             lines.append('')
   497             lines.append('')
   456 
   498 
   457         # Second pass to emit bullet list items.
   499         # Second pass to emit bullet list items.
   458 
   500 
   466             lines.append(BULLET_SECTION)
   508             lines.append(BULLET_SECTION)
   467             lines.append('-' * len(BULLET_SECTION))
   509             lines.append('-' * len(BULLET_SECTION))
   468             lines.append('')
   510             lines.append('')
   469 
   511 
   470         for paragraphs in nontitled:
   512         for paragraphs in nontitled:
   471             lines.extend(stringutil.wrap(' '.join(paragraphs[0]),
   513             lines.extend(
   472                                          width=78,
   514                 stringutil.wrap(
   473                                          initindent='* ',
   515                     ' '.join(paragraphs[0]),
   474                                          hangindent='  ').splitlines())
   516                     width=78,
       
   517                     initindent='* ',
       
   518                     hangindent='  ',
       
   519                 ).splitlines()
       
   520             )
   475 
   521 
   476             for para in paragraphs[1:]:
   522             for para in paragraphs[1:]:
   477                 lines.append('')
   523                 lines.append('')
   478                 lines.extend(stringutil.wrap(' '.join(para),
   524                 lines.extend(
   479                                              width=78,
   525                     stringutil.wrap(
   480                                              initindent='  ',
   526                         ' '.join(para),
   481                                              hangindent='  ').splitlines())
   527                         width=78,
       
   528                         initindent='  ',
       
   529                         hangindent='  ',
       
   530                     ).splitlines()
       
   531                 )
   482 
   532 
   483             lines.append('')
   533             lines.append('')
   484 
   534 
   485     if lines and lines[-1]:
   535     if lines and lines[-1]:
   486         lines.append('')
   536         lines.append('')
   487 
   537 
   488     return '\n'.join(lines)
   538     return '\n'.join(lines)
   489 
   539 
   490 @command('releasenotes',
   540 
   491     [('r', 'rev', '', _('revisions to process for release notes'), _('REV')),
   541 @command(
   492     ('c', 'check', False, _('checks for validity of admonitions (if any)'),
   542     'releasenotes',
   493         _('REV')),
   543     [
   494     ('l', 'list', False, _('list the available admonitions with their title'),
   544         ('r', 'rev', '', _('revisions to process for release notes'), _('REV')),
   495         None)],
   545         (
       
   546             'c',
       
   547             'check',
       
   548             False,
       
   549             _('checks for validity of admonitions (if any)'),
       
   550             _('REV'),
       
   551         ),
       
   552         (
       
   553             'l',
       
   554             'list',
       
   555             False,
       
   556             _('list the available admonitions with their title'),
       
   557             None,
       
   558         ),
       
   559     ],
   496     _('hg releasenotes [-r REV] [-c] FILE'),
   560     _('hg releasenotes [-r REV] [-c] FILE'),
   497     helpcategory=command.CATEGORY_CHANGE_NAVIGATION)
   561     helpcategory=command.CATEGORY_CHANGE_NAVIGATION,
       
   562 )
   498 def releasenotes(ui, repo, file_=None, **opts):
   563 def releasenotes(ui, repo, file_=None, **opts):
   499     """parse release notes from commit messages into an output file
   564     """parse release notes from commit messages into an output file
   500 
   565 
   501     Given an output file and set of revisions, this command will parse commit
   566     Given an output file and set of revisions, this command will parse commit
   502     messages for release notes then add them to the output file.
   567     messages for release notes then add them to the output file.
   613 
   678 
   614     notes.merge(ui, incoming)
   679     notes.merge(ui, incoming)
   615 
   680 
   616     with open(file_, 'wb') as fh:
   681     with open(file_, 'wb') as fh:
   617         fh.write(serializenotes(sections, notes))
   682         fh.write(serializenotes(sections, notes))
       
   683 
   618 
   684 
   619 @command('debugparsereleasenotes', norepo=True)
   685 @command('debugparsereleasenotes', norepo=True)
   620 def debugparsereleasenotes(ui, path, repo=None):
   686 def debugparsereleasenotes(ui, path, repo=None):
   621     """parse release notes and print resulting data structure"""
   687     """parse release notes and print resulting data structure"""
   622     if path == '-':
   688     if path == '-':