releasenotes.py
720 lines
| 21.7 KiB
| text/x-python
|
PythonLexer
/ hgext / releasenotes.py
Gregory Szorc
|
r32778 | # Copyright 2017-present Gregory Szorc <gregory.szorc@gmail.com> | ||
# | ||||
# This software may be used and distributed according to the terms of the | ||||
# GNU General Public License version 2 or any later version. | ||||
"""generate release notes from commit messages (EXPERIMENTAL) | ||||
It is common to maintain files detailing changes in a project between | ||||
releases. Maintaining these files can be difficult and time consuming. | ||||
The :hg:`releasenotes` command provided by this extension makes the | ||||
process simpler by automating it. | ||||
""" | ||||
Rishabh Madan
|
r33883 | import difflib | ||
Gregory Szorc
|
r32778 | import re | ||
from mercurial.i18n import _ | ||||
Gregory Szorc
|
r43355 | from mercurial.pycompat import open | ||
Joerg Sonnenberger
|
r46729 | from mercurial.node import hex | ||
Gregory Szorc
|
r32778 | from mercurial import ( | ||
Martin von Zweigbergk
|
r44350 | cmdutil, | ||
Rishabh Madan
|
r33572 | config, | ||
Gregory Szorc
|
r32778 | error, | ||
Martin von Zweigbergk
|
r48928 | logcmdutil, | ||
Gregory Szorc
|
r32778 | minirst, | ||
registrar, | ||||
Rishabh Madan
|
r33572 | util, | ||
Gregory Szorc
|
r32778 | ) | ||
Manuel Jacob
|
r45598 | from mercurial.utils import ( | ||
procutil, | ||||
stringutil, | ||||
) | ||||
Gregory Szorc
|
r32778 | |||
cmdtable = {} | ||||
command = registrar.command(cmdtable) | ||||
Pulkit Goyal
|
r34813 | try: | ||
r48655 | # Silence a warning about python-Levenshtein. | |||
# | ||||
Matt Harbison
|
r50746 | # We don't need the performance that much and it gets annoying in tests. | ||
r48655 | import warnings | |||
Augie Fackler
|
r43346 | |||
r48655 | with warnings.catch_warnings(): | |||
warnings.filterwarnings( | ||||
action="ignore", | ||||
message=".*python-Levenshtein.*", | ||||
category=UserWarning, | ||||
module="fuzzywuzzy.fuzz", | ||||
) | ||||
Matt Harbison
|
r50744 | import fuzzywuzzy.fuzz as fuzz # pytype: disable=import-error | ||
r48655 | ||||
fuzz.token_set_ratio | ||||
Pulkit Goyal
|
r34813 | except ImportError: | ||
fuzz = None | ||||
Gregory Szorc
|
r32778 | # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for | ||
# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should | ||||
# be specifying the version(s) of Mercurial they are tested with, or | ||||
# leave the attribute unspecified. | ||||
Augie Fackler
|
r43347 | testedwith = b'ships-with-hg-core' | ||
Gregory Szorc
|
r32778 | |||
DEFAULT_SECTIONS = [ | ||||
Augie Fackler
|
r43347 | (b'feature', _(b'New Features')), | ||
(b'bc', _(b'Backwards Compatibility Changes')), | ||||
(b'fix', _(b'Bug Fixes')), | ||||
(b'perf', _(b'Performance Improvements')), | ||||
(b'api', _(b'API Changes')), | ||||
Gregory Szorc
|
r32778 | ] | ||
Craig Ozancin
|
r50413 | RE_DIRECTIVE = re.compile(br'^\.\. ([a-zA-Z0-9_]+)::\s*([^$]+)?$', re.MULTILINE) | ||
Augie Fackler
|
r40271 | RE_ISSUE = br'\bissue ?[0-9]{4,6}(?![0-9])\b' | ||
Gregory Szorc
|
r32778 | |||
Augie Fackler
|
r43347 | BULLET_SECTION = _(b'Other Changes') | ||
Gregory Szorc
|
r32778 | |||
Augie Fackler
|
r43346 | |||
Gregory Szorc
|
r49801 | class parsedreleasenotes: | ||
Gregory Szorc
|
r32778 | def __init__(self): | ||
self.sections = {} | ||||
def __contains__(self, section): | ||||
return section in self.sections | ||||
def __iter__(self): | ||||
return iter(sorted(self.sections)) | ||||
def addtitleditem(self, section, title, paragraphs): | ||||
"""Add a titled release note entry.""" | ||||
self.sections.setdefault(section, ([], [])) | ||||
self.sections[section][0].append((title, paragraphs)) | ||||
def addnontitleditem(self, section, paragraphs): | ||||
"""Adds a non-titled release note entry. | ||||
Will be rendered as a bullet point. | ||||
""" | ||||
self.sections.setdefault(section, ([], [])) | ||||
self.sections[section][1].append(paragraphs) | ||||
def titledforsection(self, section): | ||||
"""Returns titled entries in a section. | ||||
Returns a list of (title, paragraphs) tuples describing sub-sections. | ||||
""" | ||||
return self.sections.get(section, ([], []))[0] | ||||
def nontitledforsection(self, section): | ||||
"""Returns non-titled, bulleted paragraphs in a section.""" | ||||
return self.sections.get(section, ([], []))[1] | ||||
def hastitledinsection(self, section, title): | ||||
return any(t[0] == title for t in self.titledforsection(section)) | ||||
def merge(self, ui, other): | ||||
"""Merge another instance into this one. | ||||
This is used to combine multiple sources of release notes together. | ||||
""" | ||||
Pulkit Goyal
|
r34814 | if not fuzz: | ||
Augie Fackler
|
r43346 | ui.warn( | ||
_( | ||||
Augie Fackler
|
r43347 | b"module 'fuzzywuzzy' not found, merging of similar " | ||
b"releasenotes is disabled\n" | ||||
Augie Fackler
|
r43346 | ) | ||
) | ||||
Pulkit Goyal
|
r34814 | |||
Gregory Szorc
|
r32778 | for section in other: | ||
Augie Fackler
|
r43346 | existingnotes = converttitled( | ||
self.titledforsection(section) | ||||
) + convertnontitled(self.nontitledforsection(section)) | ||||
Gregory Szorc
|
r32778 | for title, paragraphs in other.titledforsection(section): | ||
if self.hastitledinsection(section, title): | ||||
# TODO prompt for resolution if different and running in | ||||
# interactive mode. | ||||
Augie Fackler
|
r43346 | ui.write( | ||
Augie Fackler
|
r43347 | _(b'%s already exists in %s section; ignoring\n') | ||
Augie Fackler
|
r43346 | % (title, section) | ||
) | ||||
Gregory Szorc
|
r32778 | continue | ||
Rishabh Madan
|
r33661 | incoming_str = converttitled([(title, paragraphs)])[0] | ||
Augie Fackler
|
r43347 | if section == b'fix': | ||
Rishabh Madan
|
r33661 | issue = getissuenum(incoming_str) | ||
if issue: | ||||
if findissue(ui, existingnotes, issue): | ||||
continue | ||||
if similar(ui, existingnotes, incoming_str): | ||||
continue | ||||
Gregory Szorc
|
r32778 | self.addtitleditem(section, title, paragraphs) | ||
for paragraphs in other.nontitledforsection(section): | ||||
if paragraphs in self.nontitledforsection(section): | ||||
continue | ||||
Rishabh Madan
|
r33661 | incoming_str = convertnontitled([paragraphs])[0] | ||
Augie Fackler
|
r43347 | if section == b'fix': | ||
Rishabh Madan
|
r33661 | issue = getissuenum(incoming_str) | ||
if issue: | ||||
if findissue(ui, existingnotes, issue): | ||||
continue | ||||
if similar(ui, existingnotes, incoming_str): | ||||
continue | ||||
Gregory Szorc
|
r32778 | self.addnontitleditem(section, paragraphs) | ||
Augie Fackler
|
r43346 | |||
Gregory Szorc
|
r49801 | class releasenotessections: | ||
Rishabh Madan
|
r33572 | def __init__(self, ui, repo=None): | ||
if repo: | ||||
sections = util.sortdict(DEFAULT_SECTIONS) | ||||
custom_sections = getcustomadmonitions(repo) | ||||
if custom_sections: | ||||
sections.update(custom_sections) | ||||
Gregory Szorc
|
r49768 | self._sections = list(sections.items()) | ||
Rishabh Madan
|
r33572 | else: | ||
self._sections = list(DEFAULT_SECTIONS) | ||||
Gregory Szorc
|
r32778 | |||
def __iter__(self): | ||||
return iter(self._sections) | ||||
def names(self): | ||||
return [t[0] for t in self._sections] | ||||
def sectionfromtitle(self, title): | ||||
for name, value in self._sections: | ||||
if value == title: | ||||
return name | ||||
return None | ||||
Augie Fackler
|
r43346 | |||
Rishabh Madan
|
r33661 | def converttitled(titledparagraphs): | ||
""" | ||||
Convert titled paragraphs to strings | ||||
""" | ||||
string_list = [] | ||||
for title, paragraphs in titledparagraphs: | ||||
lines = [] | ||||
for para in paragraphs: | ||||
lines.extend(para) | ||||
Augie Fackler
|
r43347 | string_list.append(b' '.join(lines)) | ||
Rishabh Madan
|
r33661 | return string_list | ||
Augie Fackler
|
r43346 | |||
Rishabh Madan
|
r33661 | def convertnontitled(nontitledparagraphs): | ||
""" | ||||
Convert non-titled bullets to strings | ||||
""" | ||||
string_list = [] | ||||
for paragraphs in nontitledparagraphs: | ||||
lines = [] | ||||
for para in paragraphs: | ||||
lines.extend(para) | ||||
Augie Fackler
|
r43347 | string_list.append(b' '.join(lines)) | ||
Rishabh Madan
|
r33661 | return string_list | ||
Augie Fackler
|
r43346 | |||
Rishabh Madan
|
r33661 | def getissuenum(incoming_str): | ||
""" | ||||
Returns issue number from the incoming string if it exists | ||||
""" | ||||
issue = re.search(RE_ISSUE, incoming_str, re.IGNORECASE) | ||||
if issue: | ||||
issue = issue.group() | ||||
return issue | ||||
Augie Fackler
|
r43346 | |||
Rishabh Madan
|
r33661 | def findissue(ui, existing, issue): | ||
""" | ||||
Returns true if issue number already exists in notes. | ||||
""" | ||||
if any(issue in s for s in existing): | ||||
Augie Fackler
|
r43347 | ui.write(_(b'"%s" already exists in notes; ignoring\n') % issue) | ||
Rishabh Madan
|
r33661 | return True | ||
else: | ||||
return False | ||||
Augie Fackler
|
r43346 | |||
Rishabh Madan
|
r33661 | def similar(ui, existing, incoming_str): | ||
""" | ||||
Returns true if similar note found in existing notes. | ||||
""" | ||||
if len(incoming_str.split()) > 10: | ||||
merge = similaritycheck(incoming_str, existing) | ||||
if not merge: | ||||
Augie Fackler
|
r43346 | ui.write( | ||
Augie Fackler
|
r43347 | _(b'"%s" already exists in notes file; ignoring\n') | ||
Augie Fackler
|
r43346 | % incoming_str | ||
) | ||||
Rishabh Madan
|
r33661 | return True | ||
else: | ||||
return False | ||||
else: | ||||
return False | ||||
Augie Fackler
|
r43346 | |||
Rishabh Madan
|
r33661 | def similaritycheck(incoming_str, existingnotes): | ||
""" | ||||
Pulkit Goyal
|
r34781 | Returns false when note fragment can be merged to existing notes. | ||
Rishabh Madan
|
r33661 | """ | ||
Pulkit Goyal
|
r34813 | # fuzzywuzzy not present | ||
if not fuzz: | ||||
Pulkit Goyal
|
r34811 | return True | ||
Rishabh Madan
|
r33661 | merge = True | ||
for bullet in existingnotes: | ||||
score = fuzz.token_set_ratio(incoming_str, bullet) | ||||
if score > 75: | ||||
merge = False | ||||
break | ||||
return merge | ||||
Augie Fackler
|
r43346 | |||
Rishabh Madan
|
r33572 | def getcustomadmonitions(repo): | ||
Augie Fackler
|
r43347 | ctx = repo[b'.'] | ||
Rishabh Madan
|
r33572 | p = config.config() | ||
def read(f, sections=None, remap=None): | ||||
if f in ctx: | ||||
data = ctx[f].data() | ||||
p.parse(f, data, sections, remap, read) | ||||
else: | ||||
Augie Fackler
|
r43346 | raise error.Abort( | ||
Augie Fackler
|
r43347 | _(b".hgreleasenotes file \'%s\' not found") % repo.pathto(f) | ||
Augie Fackler
|
r43346 | ) | ||
Rishabh Madan
|
r33572 | |||
Augie Fackler
|
r43347 | if b'.hgreleasenotes' in ctx: | ||
read(b'.hgreleasenotes') | ||||
r47384 | return p.items(b'sections') | |||
Rishabh Madan
|
r33572 | |||
Augie Fackler
|
r43346 | |||
Rishabh Madan
|
r33883 | def checkadmonitions(ui, repo, directives, revs): | ||
""" | ||||
Checks the commit messages for admonitions and their validity. | ||||
.. abcd:: | ||||
First paragraph under this admonition | ||||
For this commit message, using `hg releasenotes -r . --check` | ||||
returns: Invalid admonition 'abcd' present in changeset 3ea92981e103 | ||||
As admonition 'abcd' is neither present in default nor custom admonitions | ||||
""" | ||||
for rev in revs: | ||||
ctx = repo[rev] | ||||
admonition = re.search(RE_DIRECTIVE, ctx.description()) | ||||
if admonition: | ||||
if admonition.group(1) in directives: | ||||
continue | ||||
else: | ||||
Augie Fackler
|
r43346 | ui.write( | ||
Martin von Zweigbergk
|
r43387 | _(b"Invalid admonition '%s' present in changeset %s\n") | ||
Augie Fackler
|
r43346 | % (admonition.group(1), ctx.hex()[:12]) | ||
) | ||||
sim = lambda x: difflib.SequenceMatcher( | ||||
None, admonition.group(1), x | ||||
).ratio() | ||||
Rishabh Madan
|
r33883 | |||
similar = [s for s in directives if sim(s) > 0.6] | ||||
if len(similar) == 1: | ||||
Augie Fackler
|
r43347 | ui.write(_(b"(did you mean %s?)\n") % similar[0]) | ||
Rishabh Madan
|
r33883 | elif similar: | ||
Augie Fackler
|
r43347 | ss = b", ".join(sorted(similar)) | ||
ui.write(_(b"(did you mean one of %s?)\n") % ss) | ||||
Rishabh Madan
|
r33883 | |||
Augie Fackler
|
r43346 | |||
Rishabh Madan
|
r33942 | def _getadmonitionlist(ui, sections): | ||
for section in sections: | ||||
Augie Fackler
|
r43347 | ui.write(b"%s: %s\n" % (section[0], section[1])) | ||
Rishabh Madan
|
r33942 | |||
Augie Fackler
|
r43346 | |||
Gregory Szorc
|
r32778 | def parsenotesfromrevisions(repo, directives, revs): | ||
notes = parsedreleasenotes() | ||||
for rev in revs: | ||||
ctx = repo[rev] | ||||
Augie Fackler
|
r43346 | blocks, pruned = minirst.parse( | ||
ctx.description(), admonitions=directives | ||||
) | ||||
Gregory Szorc
|
r32778 | |||
for i, block in enumerate(blocks): | ||||
Augie Fackler
|
r43347 | if block[b'type'] != b'admonition': | ||
Gregory Szorc
|
r32778 | continue | ||
Augie Fackler
|
r43347 | directive = block[b'admonitiontitle'] | ||
title = block[b'lines'][0].strip() if block[b'lines'] else None | ||||
Gregory Szorc
|
r32778 | |||
if i + 1 == len(blocks): | ||||
Augie Fackler
|
r43346 | raise error.Abort( | ||
_( | ||||
Augie Fackler
|
r43347 | b'changeset %s: release notes directive %s ' | ||
b'lacks content' | ||||
Augie Fackler
|
r43346 | ) | ||
% (ctx, directive) | ||||
) | ||||
Gregory Szorc
|
r32778 | |||
# Now search ahead and find all paragraphs attached to this | ||||
# admonition. | ||||
paragraphs = [] | ||||
for j in range(i + 1, len(blocks)): | ||||
pblock = blocks[j] | ||||
# Margin blocks may appear between paragraphs. Ignore them. | ||||
Augie Fackler
|
r43347 | if pblock[b'type'] == b'margin': | ||
Gregory Szorc
|
r32778 | continue | ||
Augie Fackler
|
r43347 | if pblock[b'type'] == b'admonition': | ||
Rishabh Madan
|
r36788 | break | ||
Augie Fackler
|
r43347 | if pblock[b'type'] != b'paragraph': | ||
Augie Fackler
|
r43346 | repo.ui.warn( | ||
_( | ||||
Augie Fackler
|
r43347 | b'changeset %s: unexpected block in release ' | ||
b'notes directive %s\n' | ||||
Augie Fackler
|
r43346 | ) | ||
% (ctx, directive) | ||||
) | ||||
Gregory Szorc
|
r32778 | |||
Augie Fackler
|
r43347 | if pblock[b'indent'] > 0: | ||
paragraphs.append(pblock[b'lines']) | ||||
Gregory Szorc
|
r32778 | else: | ||
break | ||||
# TODO consider using title as paragraph for more concise notes. | ||||
if not paragraphs: | ||||
Augie Fackler
|
r43346 | repo.ui.warn( | ||
Martin von Zweigbergk
|
r43387 | _(b"error parsing releasenotes for revision: '%s'\n") | ||
Joerg Sonnenberger
|
r46729 | % hex(ctx.node()) | ||
Augie Fackler
|
r43346 | ) | ||
Gregory Szorc
|
r32778 | if title: | ||
notes.addtitleditem(directive, title, paragraphs) | ||||
else: | ||||
notes.addnontitleditem(directive, paragraphs) | ||||
return notes | ||||
Augie Fackler
|
r43346 | |||
Gregory Szorc
|
r32778 | def parsereleasenotesfile(sections, text): | ||
"""Parse text content containing generated release notes.""" | ||||
notes = parsedreleasenotes() | ||||
blocks = minirst.parse(text)[0] | ||||
Rishabh Madan
|
r33012 | def gatherparagraphsbullets(offset, title=False): | ||
notefragment = [] | ||||
Gregory Szorc
|
r32778 | |||
for i in range(offset + 1, len(blocks)): | ||||
block = blocks[i] | ||||
Augie Fackler
|
r43347 | if block[b'type'] == b'margin': | ||
Gregory Szorc
|
r32778 | continue | ||
Augie Fackler
|
r43347 | elif block[b'type'] == b'section': | ||
Gregory Szorc
|
r32778 | break | ||
Augie Fackler
|
r43347 | elif block[b'type'] == b'bullet': | ||
if block[b'indent'] != 0: | ||||
raise error.Abort(_(b'indented bullet lists not supported')) | ||||
Rishabh Madan
|
r33012 | if title: | ||
Augie Fackler
|
r43347 | lines = [l[1:].strip() for l in block[b'lines']] | ||
Rishabh Madan
|
r33012 | notefragment.append(lines) | ||
continue | ||||
else: | ||||
Augie Fackler
|
r43347 | lines = [[l[1:].strip() for l in block[b'lines']]] | ||
Gregory Szorc
|
r32778 | |||
Augie Fackler
|
r43346 | for block in blocks[i + 1 :]: | ||
Augie Fackler
|
r43347 | if block[b'type'] in (b'bullet', b'section'): | ||
Rishabh Madan
|
r33012 | break | ||
Augie Fackler
|
r43347 | if block[b'type'] == b'paragraph': | ||
lines.append(block[b'lines']) | ||||
Rishabh Madan
|
r33012 | notefragment.append(lines) | ||
continue | ||||
Augie Fackler
|
r43347 | elif block[b'type'] != b'paragraph': | ||
Augie Fackler
|
r43346 | raise error.Abort( | ||
Martin von Zweigbergk
|
r43387 | _(b'unexpected block type in release notes: %s') | ||
Augie Fackler
|
r43347 | % block[b'type'] | ||
Augie Fackler
|
r43346 | ) | ||
Rishabh Madan
|
r33012 | if title: | ||
Augie Fackler
|
r43347 | notefragment.append(block[b'lines']) | ||
Gregory Szorc
|
r32778 | |||
Rishabh Madan
|
r33012 | return notefragment | ||
Gregory Szorc
|
r32778 | |||
currentsection = None | ||||
for i, block in enumerate(blocks): | ||||
Augie Fackler
|
r43347 | if block[b'type'] != b'section': | ||
Gregory Szorc
|
r32778 | continue | ||
Augie Fackler
|
r43347 | title = block[b'lines'][0] | ||
Gregory Szorc
|
r32778 | |||
# TODO the parsing around paragraphs and bullet points needs some | ||||
# work. | ||||
Augie Fackler
|
r43347 | if block[b'underline'] == b'=': # main section | ||
Gregory Szorc
|
r32778 | name = sections.sectionfromtitle(title) | ||
if not name: | ||||
Augie Fackler
|
r43346 | raise error.Abort( | ||
Augie Fackler
|
r43347 | _(b'unknown release notes section: %s') % title | ||
Augie Fackler
|
r43346 | ) | ||
Gregory Szorc
|
r32778 | |||
currentsection = name | ||||
Rishabh Madan
|
r33012 | bullet_points = gatherparagraphsbullets(i) | ||
if bullet_points: | ||||
for para in bullet_points: | ||||
notes.addnontitleditem(currentsection, para) | ||||
Gregory Szorc
|
r32778 | |||
Augie Fackler
|
r43347 | elif block[b'underline'] == b'-': # sub-section | ||
Gregory Szorc
|
r32778 | if title == BULLET_SECTION: | ||
Rishabh Madan
|
r33012 | bullet_points = gatherparagraphsbullets(i) | ||
for para in bullet_points: | ||||
notes.addnontitleditem(currentsection, para) | ||||
Gregory Szorc
|
r32778 | else: | ||
Rishabh Madan
|
r33012 | paragraphs = gatherparagraphsbullets(i, True) | ||
Gregory Szorc
|
r32778 | notes.addtitleditem(currentsection, title, paragraphs) | ||
else: | ||||
Augie Fackler
|
r43347 | raise error.Abort(_(b'unsupported section type for %s') % title) | ||
Gregory Szorc
|
r32778 | |||
return notes | ||||
Augie Fackler
|
r43346 | |||
Gregory Szorc
|
r32778 | def serializenotes(sections, notes): | ||
"""Serialize release notes from parsed fragments and notes. | ||||
This function essentially takes the output of ``parsenotesfromrevisions()`` | ||||
and ``parserelnotesfile()`` and produces output combining the 2. | ||||
""" | ||||
lines = [] | ||||
for sectionname, sectiontitle in sections: | ||||
if sectionname not in notes: | ||||
continue | ||||
lines.append(sectiontitle) | ||||
Augie Fackler
|
r43347 | lines.append(b'=' * len(sectiontitle)) | ||
lines.append(b'') | ||||
Gregory Szorc
|
r32778 | |||
# First pass to emit sub-sections. | ||||
for title, paragraphs in notes.titledforsection(sectionname): | ||||
lines.append(title) | ||||
Augie Fackler
|
r43347 | lines.append(b'-' * len(title)) | ||
lines.append(b'') | ||||
Gregory Szorc
|
r32778 | |||
for i, para in enumerate(paragraphs): | ||||
if i: | ||||
Augie Fackler
|
r43347 | lines.append(b'') | ||
Augie Fackler
|
r43346 | lines.extend( | ||
Augie Fackler
|
r43347 | stringutil.wrap(b' '.join(para), width=78).splitlines() | ||
Augie Fackler
|
r43346 | ) | ||
Gregory Szorc
|
r32778 | |||
Augie Fackler
|
r43347 | lines.append(b'') | ||
Gregory Szorc
|
r32778 | |||
# Second pass to emit bullet list items. | ||||
# If the section has titled and non-titled items, we can't | ||||
# simply emit the bullet list because it would appear to come | ||||
# from the last title/section. So, we emit a new sub-section | ||||
# for the non-titled items. | ||||
nontitled = notes.nontitledforsection(sectionname) | ||||
if notes.titledforsection(sectionname) and nontitled: | ||||
# TODO make configurable. | ||||
lines.append(BULLET_SECTION) | ||||
Augie Fackler
|
r43347 | lines.append(b'-' * len(BULLET_SECTION)) | ||
lines.append(b'') | ||||
Gregory Szorc
|
r32778 | |||
for paragraphs in nontitled: | ||||
Augie Fackler
|
r43346 | lines.extend( | ||
stringutil.wrap( | ||||
Augie Fackler
|
r43347 | b' '.join(paragraphs[0]), | ||
Augie Fackler
|
r43346 | width=78, | ||
Augie Fackler
|
r43347 | initindent=b'* ', | ||
hangindent=b' ', | ||||
Augie Fackler
|
r43346 | ).splitlines() | ||
) | ||||
Gregory Szorc
|
r32778 | |||
for para in paragraphs[1:]: | ||||
Augie Fackler
|
r43347 | lines.append(b'') | ||
Augie Fackler
|
r43346 | lines.extend( | ||
stringutil.wrap( | ||||
Augie Fackler
|
r43347 | b' '.join(para), | ||
Augie Fackler
|
r43346 | width=78, | ||
Augie Fackler
|
r43347 | initindent=b' ', | ||
hangindent=b' ', | ||||
Augie Fackler
|
r43346 | ).splitlines() | ||
) | ||||
Gregory Szorc
|
r32778 | |||
Augie Fackler
|
r43347 | lines.append(b'') | ||
Gregory Szorc
|
r32778 | |||
Rishabh Madan
|
r33781 | if lines and lines[-1]: | ||
Augie Fackler
|
r43347 | lines.append(b'') | ||
Gregory Szorc
|
r32778 | |||
Augie Fackler
|
r43347 | return b'\n'.join(lines) | ||
Gregory Szorc
|
r32778 | |||
Augie Fackler
|
r43346 | |||
@command( | ||||
Augie Fackler
|
r43347 | b'releasenotes', | ||
Augie Fackler
|
r43346 | [ | ||
( | ||||
Augie Fackler
|
r43347 | b'r', | ||
b'rev', | ||||
b'', | ||||
_(b'revisions to process for release notes'), | ||||
_(b'REV'), | ||||
Augie Fackler
|
r43346 | ), | ||
( | ||||
Augie Fackler
|
r43347 | b'c', | ||
b'check', | ||||
Augie Fackler
|
r43346 | False, | ||
Augie Fackler
|
r43347 | _(b'checks for validity of admonitions (if any)'), | ||
_(b'REV'), | ||||
), | ||||
( | ||||
b'l', | ||||
b'list', | ||||
False, | ||||
_(b'list the available admonitions with their title'), | ||||
Augie Fackler
|
r43346 | None, | ||
), | ||||
], | ||||
Augie Fackler
|
r43347 | _(b'hg releasenotes [-r REV] [-c] FILE'), | ||
Augie Fackler
|
r43346 | helpcategory=command.CATEGORY_CHANGE_NAVIGATION, | ||
) | ||||
Rishabh Madan
|
r33883 | def releasenotes(ui, repo, file_=None, **opts): | ||
Gregory Szorc
|
r32778 | """parse release notes from commit messages into an output file | ||
Given an output file and set of revisions, this command will parse commit | ||||
messages for release notes then add them to the output file. | ||||
Release notes are defined in commit messages as ReStructuredText | ||||
directives. These have the form:: | ||||
.. directive:: title | ||||
content | ||||
Each ``directive`` maps to an output section in a generated release notes | ||||
file, which itself is ReStructuredText. For example, the ``.. feature::`` | ||||
directive would map to a ``New Features`` section. | ||||
Release note directives can be either short-form or long-form. In short- | ||||
form, ``title`` is omitted and the release note is rendered as a bullet | ||||
list. In long form, a sub-section with the title ``title`` is added to the | ||||
section. | ||||
The ``FILE`` argument controls the output file to write gathered release | ||||
notes to. The format of the file is:: | ||||
Section 1 | ||||
========= | ||||
... | ||||
Section 2 | ||||
========= | ||||
... | ||||
Only sections with defined release notes are emitted. | ||||
If a section only has short-form notes, it will consist of bullet list:: | ||||
Section | ||||
======= | ||||
* Release note 1 | ||||
* Release note 2 | ||||
If a section has long-form notes, sub-sections will be emitted:: | ||||
Section | ||||
======= | ||||
Note 1 Title | ||||
------------ | ||||
Description of the first long-form note. | ||||
Note 2 Title | ||||
------------ | ||||
Description of the second long-form note. | ||||
If the ``FILE`` argument points to an existing file, that file will be | ||||
parsed for release notes having the format that would be generated by this | ||||
command. The notes from the processed commit messages will be *merged* | ||||
into this parsed set. | ||||
During release notes merging: | ||||
* Duplicate items are automatically ignored | ||||
* Items that are different are automatically ignored if the similarity is | ||||
greater than a threshold. | ||||
This means that the release notes file can be updated independently from | ||||
this command and changes should not be lost when running this command on | ||||
that file. A particular use case for this is to tweak the wording of a | ||||
release note after it has been added to the release notes file. | ||||
Rishabh Madan
|
r34342 | |||
The -c/--check option checks the commit message for invalid admonitions. | ||||
The -l/--list option, presents the user with a list of existing available | ||||
admonitions along with their title. This also includes the custom | ||||
admonitions (if any). | ||||
Gregory Szorc
|
r32778 | """ | ||
Pulkit Goyal
|
r35004 | |||
Rishabh Madan
|
r33572 | sections = releasenotessections(ui, repo) | ||
Rishabh Madan
|
r34341 | |||
Matt Harbison
|
r51776 | cmdutil.check_incompatible_arguments(opts, 'list', ['rev', 'check']) | ||
Rishabh Madan
|
r34341 | |||
Matt Harbison
|
r51776 | if opts.get('list'): | ||
Rishabh Madan
|
r33942 | return _getadmonitionlist(ui, sections) | ||
Matt Harbison
|
r51776 | rev = opts.get('rev') | ||
Martin von Zweigbergk
|
r48928 | revs = logcmdutil.revrange(repo, [rev or b'not public()']) | ||
Matt Harbison
|
r51776 | if opts.get('check'): | ||
Rishabh Madan
|
r33883 | return checkadmonitions(ui, repo, sections.names(), revs) | ||
Gregory Szorc
|
r32778 | incoming = parsenotesfromrevisions(repo, sections.names(), revs) | ||
Rishabh Madan
|
r34405 | if file_ is None: | ||
Augie Fackler
|
r43347 | ui.pager(b'releasenotes') | ||
Rishabh Madan
|
r34405 | return ui.write(serializenotes(sections, incoming)) | ||
Gregory Szorc
|
r32778 | try: | ||
Augie Fackler
|
r43347 | with open(file_, b'rb') as fh: | ||
Gregory Szorc
|
r32778 | notes = parsereleasenotesfile(sections, fh.read()) | ||
Manuel Jacob
|
r50201 | except FileNotFoundError: | ||
Gregory Szorc
|
r32778 | notes = parsedreleasenotes() | ||
notes.merge(ui, incoming) | ||||
Augie Fackler
|
r43347 | with open(file_, b'wb') as fh: | ||
Gregory Szorc
|
r32778 | fh.write(serializenotes(sections, notes)) | ||
Augie Fackler
|
r43346 | |||
Augie Fackler
|
r43347 | @command(b'debugparsereleasenotes', norepo=True) | ||
Rishabh Madan
|
r33572 | def debugparsereleasenotes(ui, path, repo=None): | ||
Gregory Szorc
|
r32778 | """parse release notes and print resulting data structure""" | ||
Augie Fackler
|
r43347 | if path == b'-': | ||
Manuel Jacob
|
r45598 | text = procutil.stdin.read() | ||
Gregory Szorc
|
r32778 | else: | ||
Augie Fackler
|
r43347 | with open(path, b'rb') as fh: | ||
Gregory Szorc
|
r32778 | text = fh.read() | ||
Rishabh Madan
|
r33572 | sections = releasenotessections(ui, repo) | ||
Gregory Szorc
|
r32778 | |||
notes = parsereleasenotesfile(sections, text) | ||||
for section in notes: | ||||
Augie Fackler
|
r43347 | ui.write(_(b'section: %s\n') % section) | ||
Gregory Szorc
|
r32778 | for title, paragraphs in notes.titledforsection(section): | ||
Augie Fackler
|
r43347 | ui.write(_(b' subsection: %s\n') % title) | ||
Gregory Szorc
|
r32778 | for para in paragraphs: | ||
Augie Fackler
|
r43347 | ui.write(_(b' paragraph: %s\n') % b' '.join(para)) | ||
Gregory Szorc
|
r32778 | |||
for paragraphs in notes.nontitledforsection(section): | ||||
Augie Fackler
|
r43347 | ui.write(_(b' bullet point:\n')) | ||
Gregory Szorc
|
r32778 | for para in paragraphs: | ||
Augie Fackler
|
r43347 | ui.write(_(b' paragraph: %s\n') % b' '.join(para)) | ||