1c92542f42
For list-changes and the announcement emails we want to ignore any local cache file created when a package was built and scan the git repository again. The cache file is meant to be used by the consumer of the package being released, but we have the git repository so we don't need it and ignoring it will ensure we have the correct notes in any announcements. Change-Id: I047f7e455a1d238d12cf94a60cd0d44f44406a3d Signed-off-by: Doug Hellmann <doug@doughellmann.com>
413 lines
13 KiB
Python
413 lines
13 KiB
Python
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
"""Generates a standard set of release notes for a repository."""
|
|
|
|
import glob
|
|
import logging
|
|
import os
|
|
import random
|
|
import subprocess
|
|
|
|
import jinja2
|
|
import parawrap
|
|
from reno import config as reno_config
|
|
from reno import formatter
|
|
from reno import loader
|
|
|
|
from openstack_releases import rst2txt
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
EMOTIONS = [
|
|
'are amped to',
|
|
'are chuffed to',
|
|
'contentedly',
|
|
'are delighted to',
|
|
'eagerly',
|
|
'are ecstatic to',
|
|
'enthusiastically',
|
|
'are excited to',
|
|
'exuberantly',
|
|
'are glad to',
|
|
'are gleeful to',
|
|
'are happy to',
|
|
'high-spiritedly',
|
|
'are jazzed to',
|
|
'joyfully',
|
|
'jubilantly',
|
|
'are overjoyed to',
|
|
'are pleased to',
|
|
'are psyched to',
|
|
'are pumped to',
|
|
'are satisfied to',
|
|
'are stoked to',
|
|
'are thrilled to',
|
|
'are tickled pink to',
|
|
]
|
|
|
|
# The email headers for generating a message to go right into sendmail
|
|
# or msmtp.
|
|
EMAIL_HEADER_TPL = """
|
|
{%- if email %}
|
|
From: {{email_from}}
|
|
To: {{email_to}}
|
|
Reply-To: {{email_reply_to}}
|
|
Subject: {{email_tags}} {{project}} {{end_rev}}{% if series %} ({{series}}){% endif %}
|
|
{% endif %}
|
|
"""
|
|
|
|
PYPI_URL_TPL = 'https://pypi.org/project/%s'
|
|
|
|
# This will be replaced with template values and then wrapped using parawrap
|
|
# to correctly wrap at paragraph boundaries...
|
|
|
|
HEADER_RELEASE_TPL = """
|
|
We {{ emotion }} announce the release of:
|
|
|
|
{% if description %}
|
|
{{ project }} {{ end_rev }}: {{ description }}
|
|
{% else %}
|
|
{{ project }} {{ end_rev }}
|
|
{% endif %}
|
|
|
|
{% if first_release -%}
|
|
This is the first release of {{project}}.
|
|
{%- endif %}
|
|
{% if series -%}
|
|
This release is part of the {{series}} {% if stable_series %}stable {% endif %}release series.
|
|
{%- endif %}
|
|
{% if source_url %}
|
|
|
|
The source is available from:
|
|
|
|
{{ source_url }}
|
|
{% endif %}
|
|
|
|
Download the package from:
|
|
|
|
{% if pypi_url %}
|
|
{{ pypi_url }}
|
|
{% else %}
|
|
https://tarballs.openstack.org/{{publishing_dir_name}}/
|
|
{% endif %}
|
|
{% if bug_url %}
|
|
|
|
Please report issues through launchpad:
|
|
|
|
{{ bug_url }}
|
|
{% endif %}
|
|
|
|
For more details, please see below.
|
|
"""
|
|
|
|
# This will just be replaced with template values (no wrapping applied).
|
|
CHANGE_RELEASE_TPL = """{% if reno_notes %}{{ reno_notes }}{% endif %}
|
|
{% if changes %}{{ change_header }}{% if skip_requirement_merges %}
|
|
|
|
NOTE: Skipping requirement commits...
|
|
{%- endif %}
|
|
|
|
{% for change in changes -%}
|
|
{{ change }}
|
|
{% endfor %}
|
|
{%- endif %}
|
|
{% if diff_stats %}
|
|
{% if not first_release -%}
|
|
Diffstat (except docs and test files)
|
|
-------------------------------------
|
|
|
|
{% for change in diff_stats -%}
|
|
{{ change }}
|
|
{% endfor %}
|
|
{%- endif %}
|
|
{% if requirement_changes %}
|
|
Requirements updates
|
|
--------------------
|
|
|
|
{% for change in requirement_changes -%}
|
|
{{ change }}
|
|
{% endfor %}
|
|
{%- endif %}
|
|
{% endif %}
|
|
"""
|
|
|
|
CHANGES_ONLY_TPL = """{{ change_header }}
|
|
{% for change in changes -%}
|
|
{{ change }}
|
|
{% endfor %}
|
|
"""
|
|
|
|
RELEASE_CANDIDATE_TPL = """
|
|
Hello everyone,
|
|
|
|
A new release candidate for {{project}} for the end of the {{series|capitalize}}
|
|
cycle is available! You can find the source code tarball at:
|
|
|
|
https://tarballs.openstack.org/{{publishing_dir_name}}/
|
|
|
|
Unless release-critical issues are found that warrant a release
|
|
candidate respin, this candidate will be formally released as the
|
|
final {{series|capitalize}} release. You are therefore strongly
|
|
encouraged to test and validate this tarball!
|
|
|
|
Alternatively, you can directly test the stable/{{series|lower}} release
|
|
branch at:
|
|
|
|
http://git.openstack.org/cgit/openstack/{{project}}/log/?h=stable/{{series|lower}}
|
|
|
|
Release notes for {{project}} can be found at:
|
|
|
|
http://docs.openstack.org/releasenotes/{{project}}/
|
|
|
|
{% if bug_url -%}
|
|
If you find an issue that could be considered release-critical, please
|
|
file it at:
|
|
|
|
{{bug_url}}
|
|
|
|
and tag it *{{series|lower}}-rc-potential* to bring it to the {{project}}
|
|
release crew's attention.
|
|
{%- endif %}
|
|
"""
|
|
|
|
|
|
def parse_readme(repo_path):
|
|
sections = {
|
|
'bug_url': '',
|
|
'source_url': '',
|
|
}
|
|
readme_formats = ['rst', 'md']
|
|
for k in readme_formats:
|
|
readme_path = os.path.join(repo_path, 'README.%s' % k)
|
|
try:
|
|
f = open(readme_path, 'r', encoding='utf-8')
|
|
f.close()
|
|
break
|
|
except IOError:
|
|
continue
|
|
else:
|
|
LOG.warning("No README file found in %s\n"
|
|
% repo_path)
|
|
return sections
|
|
|
|
with open(readme_path, 'r', encoding='utf-8') as fh:
|
|
for line in fh:
|
|
for (name, key_name) in [("Bugs:", "bug_url"),
|
|
("Source:", 'source_url')]:
|
|
pieces = line.split(name, 1)
|
|
if len(pieces) == 2:
|
|
candidate = pieces[1].strip()
|
|
if 'http' in candidate:
|
|
sections[key_name] = candidate
|
|
for (k, v) in sections.items():
|
|
if not v:
|
|
what = k.replace("_", " ")
|
|
LOG.warning("No %s found in '%s'\n"
|
|
% (what, readme_path))
|
|
return sections
|
|
|
|
|
|
def expand_template(contents, params):
|
|
if not params:
|
|
params = {}
|
|
tpl = jinja2.Template(source=contents, undefined=jinja2.StrictUndefined)
|
|
return tpl.render(**params)
|
|
|
|
|
|
def run_cmd(cmd, cwd=None, encoding='utf-8'):
|
|
stdout = subprocess.check_output(cmd, cwd=cwd)
|
|
return stdout.decode(encoding)
|
|
|
|
|
|
def is_skippable_commit(skip_requirement_merges, line):
|
|
return (skip_requirement_merges and
|
|
line.lower().endswith('updated from global requirements'))
|
|
|
|
|
|
def generate_release_notes(repo, repo_path,
|
|
start_revision, end_revision,
|
|
show_dates, skip_requirement_merges,
|
|
is_stable, series,
|
|
email, email_from,
|
|
email_reply_to, email_tags,
|
|
include_pypi_link,
|
|
changes_only,
|
|
first_release,
|
|
repo_name, description,
|
|
publishing_dir_name,
|
|
):
|
|
"""Return the text of the release notes.
|
|
|
|
:param repo: The name of the repo.
|
|
:param repo_path: Path to the repo repository on disk.
|
|
:param start_revision: First reference for finding change log.
|
|
:param end_revision: Final reference for finding change log.
|
|
:param show_dates: Boolean indicating whether or not to show dates
|
|
in the output.
|
|
:param skip_requirement_merges: Boolean indicating whether to
|
|
skip merge commits for requirements changes.
|
|
:param is_stable: Boolean indicating whether this is a stable
|
|
series or not.
|
|
:param series: String holding the name of the series.
|
|
:param email: Boolean indicating whether the output format should
|
|
be an email message.
|
|
:param email_from: String containing the sender email address.
|
|
:param email_reply_to: String containing the email reply-to address.
|
|
:param email_tags: String containing the email header topic tags to add.
|
|
:param include_pypi_link: Boolean indicating whether or not to
|
|
include an automatically generated link to the PyPI package
|
|
page.
|
|
:param changes_only: Boolean indicating whether to limit output to
|
|
the list of changes, without any extra data.
|
|
:param first_release: Boolean indicating whether this is the first
|
|
release of the project
|
|
:param repo_name: Name of the repo
|
|
:param description: Description of the repo
|
|
:param publishing_dir_name: The directory on publishings.openstack.org
|
|
containing the package.
|
|
|
|
"""
|
|
# Determine if this is a release candidate or not.
|
|
is_release_candidate = 'rc' in end_revision
|
|
|
|
# Do not mention the series in independent model since there is none
|
|
if series == 'independent':
|
|
series = ''
|
|
|
|
if not email_from:
|
|
raise RuntimeError('No email-from specified')
|
|
|
|
# Get the commits that are in the desired range...
|
|
git_range = "%s..%s" % (start_revision, end_revision)
|
|
if show_dates:
|
|
format = "--format=%h %ci %s"
|
|
else:
|
|
format = "--oneline"
|
|
cmd = ["git", "log", "--no-color", format, "--no-merges", git_range]
|
|
stdout = run_cmd(cmd, cwd=repo_path)
|
|
changes = []
|
|
for commit_line in stdout.splitlines():
|
|
commit_line = commit_line.strip()
|
|
if not commit_line or is_skippable_commit(skip_requirement_merges,
|
|
commit_line):
|
|
continue
|
|
else:
|
|
changes.append(commit_line)
|
|
|
|
# Filter out any requirement file changes...
|
|
requirement_changes = []
|
|
requirement_files = list(glob.glob(os.path.join(repo_path,
|
|
'*requirements*.txt')))
|
|
if requirement_files:
|
|
cmd = ['git', 'diff', '-U0', '--no-color', git_range]
|
|
cmd.extend(requirement_files)
|
|
stdout = run_cmd(cmd, cwd=repo_path)
|
|
requirement_changes = [line.strip()
|
|
for line in stdout.splitlines() if line.strip()]
|
|
|
|
# Get statistics about the range given...
|
|
cmd = ['git', 'diff', '--stat', '--no-color', git_range]
|
|
stdout = run_cmd(cmd, cwd=repo_path)
|
|
diff_stats = []
|
|
for line in stdout.splitlines():
|
|
line = line.strip()
|
|
if not line or line.find("tests") != -1 or line.startswith("doc"):
|
|
continue
|
|
diff_stats.append(line)
|
|
|
|
# Extract + valdiate needed sections from readme...
|
|
readme_sections = parse_readme(repo_path)
|
|
change_header = ["Changes in %s %s" % (repo_name, git_range)]
|
|
change_header.append("-" * len(change_header[0]))
|
|
|
|
# Look for reno notes for this version.
|
|
if not changes_only:
|
|
cfg = reno_config.Config(
|
|
reporoot=repo_path,
|
|
)
|
|
branch = None
|
|
if is_stable and series:
|
|
branch = 'origin/stable/%s' % series
|
|
cfg.override(branch=branch)
|
|
ldr = loader.Loader(conf=cfg, ignore_cache=True)
|
|
if end_revision in ldr.versions:
|
|
rst_notes = formatter.format_report(
|
|
loader=ldr,
|
|
config=cfg,
|
|
versions_to_include=[end_revision],
|
|
)
|
|
reno_notes = rst2txt.convert(rst_notes).decode('utf-8')
|
|
else:
|
|
LOG.warning(
|
|
('Did not find revision %r in list of versions '
|
|
'with release notes %r, skipping reno'),
|
|
end_revision, ldr.versions,
|
|
)
|
|
reno_notes = ''
|
|
else:
|
|
reno_notes = ''
|
|
|
|
# The recipient for announcements should always be the
|
|
# release-announce@lists.openstack.org ML (except for
|
|
# release-test and release candidates)
|
|
email_to = 'release-announce@lists.openstack.org'
|
|
if repo == 'openstack-release-test':
|
|
email_to = 'release-job-failures@lists.openstack.org'
|
|
elif is_release_candidate:
|
|
email_to = 'openstack-dev@lists.openstack.org'
|
|
|
|
params = dict(readme_sections)
|
|
params.update({
|
|
'project': repo_name,
|
|
'description': description,
|
|
'end_rev': end_revision,
|
|
'range': git_range,
|
|
'lib': repo_path,
|
|
'skip_requirement_merges': skip_requirement_merges,
|
|
'changes': changes,
|
|
'requirement_changes': requirement_changes,
|
|
'diff_stats': diff_stats,
|
|
'change_header': "\n".join(change_header),
|
|
'emotion': random.choice(EMOTIONS),
|
|
'stable_series': is_stable,
|
|
'series': series,
|
|
'email': email,
|
|
'email_from': email_from,
|
|
'email_to': email_to,
|
|
'email_reply_to': email_reply_to,
|
|
'email_tags': email_tags,
|
|
'reno_notes': reno_notes,
|
|
'first_release': first_release,
|
|
'publishing_dir_name': publishing_dir_name,
|
|
})
|
|
if include_pypi_link:
|
|
params['pypi_url'] = PYPI_URL_TPL % repo_name
|
|
else:
|
|
params['pypi_url'] = None
|
|
|
|
response = []
|
|
if changes_only:
|
|
response.append(expand_template(CHANGES_ONLY_TPL, params))
|
|
else:
|
|
if email:
|
|
email_header = expand_template(EMAIL_HEADER_TPL.strip(), params)
|
|
response.append(email_header.lstrip())
|
|
if is_release_candidate:
|
|
response.append(expand_template(RELEASE_CANDIDATE_TPL, params))
|
|
else:
|
|
header = expand_template(HEADER_RELEASE_TPL.strip(), params)
|
|
response.append(parawrap.fill(header))
|
|
response.append(expand_template(CHANGE_RELEASE_TPL, params))
|
|
return '\n'.join(response)
|