394 lines
15 KiB
Python
394 lines
15 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.
|
|
|
|
from __future__ import print_function
|
|
from __future__ import unicode_literals
|
|
|
|
import argparse
|
|
import collections
|
|
import contextlib
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
import six
|
|
|
|
from prompt_toolkit.completion import WordCompleter
|
|
from prompt_toolkit import prompt
|
|
from prompt_toolkit.validation import ValidationError
|
|
from prompt_toolkit.validation import Validator
|
|
|
|
from tqdm import tqdm
|
|
|
|
from openstack_releases import gitutils
|
|
from openstack_releases import yamlutils
|
|
|
|
NOTES_URL_TPL = 'https://docs.openstack.org/releasenotes/%s/%s.html'
|
|
RELEASE_INCREMENTS = {
|
|
'bugfix': (0, 0, 1),
|
|
'feature': (0, 1, 0),
|
|
'major': (1, 0, 0),
|
|
}
|
|
RELEASE_KINDS = tuple(sorted(RELEASE_INCREMENTS))
|
|
OVERVIEW = """
|
|
A interactive command line helper tool that makes it easy create
|
|
releases of openstack projects.
|
|
|
|
Supported features:
|
|
|
|
- Tab completion
|
|
|
|
Notes:
|
|
|
|
- To exit the multi-line highlights text entry field press
|
|
'escape' then 'enter'.
|
|
"""
|
|
|
|
|
|
def to_unicode(blob, encoding='utf8'):
|
|
if isinstance(blob, six.text_type):
|
|
return blob
|
|
elif isinstance(blob, six.binary_type):
|
|
return blob.decode(encoding)
|
|
else:
|
|
raise TypeError("Unable to convert %r to a text type" % blob)
|
|
|
|
|
|
class NoEmptyValidator(Validator):
|
|
def validate(self, document):
|
|
text = document.text.strip()
|
|
if len(text) == 0:
|
|
raise ValidationError(message='Empty input is not allowed')
|
|
|
|
|
|
class SetValidator(Validator):
|
|
def __init__(self, allowed_values, show_possible=False):
|
|
super(SetValidator, self).__init__()
|
|
self.allowed_values = frozenset(allowed_values)
|
|
self.show_possible = show_possible
|
|
|
|
def validate(self, document):
|
|
text = document.text
|
|
if text not in self.allowed_values:
|
|
if self.show_possible:
|
|
raise ValidationError(
|
|
message='This input is not allowed, '
|
|
' please choose from %s' % self.allowed_values)
|
|
else:
|
|
raise ValidationError(
|
|
message='This input is not allowed')
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def tempdir(**kwargs):
|
|
# This seems like it was only added in python 3.2
|
|
# Make it since its useful...
|
|
# See: http://bugs.python.org/file12970/tempdir.patch
|
|
tdir = tempfile.mkdtemp(**kwargs)
|
|
try:
|
|
yield tdir
|
|
finally:
|
|
shutil.rmtree(tdir)
|
|
|
|
|
|
def yes_no_prompt(title, default=True):
|
|
result = prompt(title, completer=WordCompleter(['yes', 'no']),
|
|
validator=SetValidator(['yes', 'no'],
|
|
show_possible=True),
|
|
default="yes" if default else "no")
|
|
return result == 'yes'
|
|
|
|
|
|
def clean_changes(changes):
|
|
for line in changes:
|
|
line = to_unicode(line)
|
|
sha, descr = line.split(" ", 1)
|
|
yield sha, descr
|
|
|
|
|
|
def generate_suggested_next_version(last_release, release_type):
|
|
"""Generates a suggested next version for a given project."""
|
|
if not last_release:
|
|
return None
|
|
last_version = last_release['version'].split('.')
|
|
# Ensure we have at least 3 components...
|
|
while len(last_version) < 3:
|
|
last_version.append(0)
|
|
increment = RELEASE_INCREMENTS[release_type]
|
|
new_version_parts = []
|
|
for cur, inc in zip(last_version, increment):
|
|
new_version_parts.append(str(int(cur) + inc))
|
|
# Ensure that we reset any numbers after the version we
|
|
# incremented, since those should now roll-over to the next
|
|
# version.
|
|
if release_type == 'major':
|
|
for i in range(1, len(new_version_parts)):
|
|
new_version_parts[i] = '0'
|
|
if release_type == 'feature':
|
|
for i in range(2, len(new_version_parts)):
|
|
new_version_parts[i] = '0'
|
|
if release_type == 'bugfix':
|
|
for i in range(3, len(new_version_parts)):
|
|
new_version_parts[i] = '0'
|
|
return '.'.join(new_version_parts)
|
|
|
|
|
|
def maybe_create_release(release_repo_path, deliverable_info,
|
|
last_release, change_lines,
|
|
latest_cycle, project,
|
|
short_project, max_changes_show=100,
|
|
should_prompt=True):
|
|
if last_release:
|
|
print("%s changes to release since %s are:"
|
|
% (len(change_lines), last_release['version']))
|
|
else:
|
|
print("%s changes to release are:" % (len(change_lines)))
|
|
for sha, descr in change_lines[0:max_changes_show]:
|
|
print(" %s %s" % (sha, descr))
|
|
leftover_change_lines = change_lines[max_changes_show:]
|
|
if leftover_change_lines:
|
|
print(" and %s more changes..." % len(leftover_change_lines))
|
|
if not should_prompt:
|
|
return
|
|
create_release = yes_no_prompt('Create a release in %s containing'
|
|
' those changes? ' % latest_cycle)
|
|
if create_release:
|
|
# NOTE(harlowja): use of an ordered-dict here is on purpose, so that
|
|
# the ordering here stays similar to what is already being used.
|
|
newest_release_path = os.path.join(
|
|
release_repo_path, 'deliverables',
|
|
latest_cycle, "%s.yaml" % short_project)
|
|
ok_change = True
|
|
if os.path.exists(newest_release_path):
|
|
with open(newest_release_path, 'rb') as fh:
|
|
newest_release = yamlutils.loads(fh.read())
|
|
ok_change = yes_no_prompt("Alter existing file (reformatting"
|
|
" may lose comments and some existing"
|
|
" yaml indenting/structure)? ")
|
|
else:
|
|
notes_link = to_unicode(
|
|
NOTES_URL_TPL % (short_project, latest_cycle))
|
|
notes_link = prompt(
|
|
"Release notes link: ",
|
|
validator=NoEmptyValidator(),
|
|
default=notes_link)
|
|
if deliverable_info:
|
|
launchpad_project = to_unicode(deliverable_info['launchpad'])
|
|
else:
|
|
launchpad_project = prompt(
|
|
"Launchpad project name: ",
|
|
validator=NoEmptyValidator(),
|
|
default=to_unicode(short_project))
|
|
team = prompt("Project team: ",
|
|
validator=NoEmptyValidator(),
|
|
default=to_unicode(launchpad_project))
|
|
include_pypi_link = yes_no_prompt("Include pypi link? ")
|
|
newest_release = collections.OrderedDict([
|
|
('launchpad', launchpad_project),
|
|
('include-pypi-link', include_pypi_link),
|
|
('release-notes', notes_link),
|
|
('releases', []),
|
|
('team', team),
|
|
])
|
|
possible_hashes = []
|
|
for sha, _descr in change_lines:
|
|
possible_hashes.append(sha)
|
|
release_kind = prompt("Release type: ",
|
|
validator=SetValidator(RELEASE_KINDS),
|
|
completer=WordCompleter(RELEASE_KINDS))
|
|
suggested_version = generate_suggested_next_version(
|
|
last_release, release_kind)
|
|
if not suggested_version:
|
|
suggested_version = ''
|
|
version = prompt("Release version: ",
|
|
validator=NoEmptyValidator(),
|
|
default=to_unicode(suggested_version))
|
|
highlights = prompt("Highlights (esc then enter to"
|
|
" exit): ", multiline=True)
|
|
highlights = highlights.strip()
|
|
release_hash = prompt("Hash to release at: ",
|
|
validator=SetValidator(possible_hashes),
|
|
completer=WordCompleter(possible_hashes),
|
|
default=possible_hashes[0])
|
|
new_release = collections.OrderedDict([
|
|
('version', version),
|
|
('projects', [
|
|
collections.OrderedDict([
|
|
('repo', project),
|
|
('hash', release_hash),
|
|
]),
|
|
]),
|
|
])
|
|
if highlights:
|
|
new_release['highlights'] = highlights
|
|
if not ok_change:
|
|
new_release = yamlutils.dumps(new_release)
|
|
print("You may manually adjust %s and add:" % newest_release_path)
|
|
print(new_release)
|
|
else:
|
|
try:
|
|
newest_release['releases'].append(new_release)
|
|
except KeyError:
|
|
newest_release['releases'] = [new_release]
|
|
newest_release = yamlutils.dumps(newest_release)
|
|
with open(newest_release_path, 'w') as fh:
|
|
fh.write(newest_release)
|
|
|
|
|
|
def find_last_release_path(release_repo_path,
|
|
latest_cycle, cycles,
|
|
project):
|
|
latest_cycle_idx = cycles.index(latest_cycle)
|
|
for a_cycle in reversed(cycles[0:latest_cycle_idx + 1]):
|
|
release_path = os.path.join(release_repo_path, 'deliverables',
|
|
a_cycle, "%s.yaml" % project)
|
|
if os.path.isfile(release_path):
|
|
return a_cycle, release_path
|
|
return (None, None)
|
|
|
|
|
|
def read_projects(path):
|
|
"""Reads a list of openstack projects from a file.
|
|
|
|
Example file::
|
|
|
|
$ cat tools/oslo.txt
|
|
openstack/oslo.i18n
|
|
|
|
"""
|
|
raw_projects = []
|
|
with open(path) as fh:
|
|
for line in fh.read().splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
else:
|
|
raw_projects.append(line)
|
|
return raw_projects
|
|
|
|
|
|
def clone_repos(save_dir, projects):
|
|
"""Clones a bunch of openstack repos."""
|
|
repos = {}
|
|
for project, short_project in tqdm(
|
|
projects, unit='repo', desc='Cloning %s repos' % len(projects)):
|
|
gitutils.clone_repo(save_dir, project)
|
|
repos[project] = os.path.join(save_dir, project)
|
|
return repos
|
|
|
|
|
|
def extract_projects(raw_projects):
|
|
projects = []
|
|
seen_projects = set()
|
|
for project in sorted(raw_projects):
|
|
project_pieces = project.split("/", 1)
|
|
if len(project_pieces) == 1:
|
|
# This handles someone passing in just the base project
|
|
# name instead of the fully qualified project name.
|
|
project_pieces = ['openstack', project]
|
|
project = "openstack/%s" % project
|
|
if project in seen_projects:
|
|
continue
|
|
short_project = project_pieces[-1]
|
|
projects.append((project, short_project))
|
|
seen_projects.add(project)
|
|
return projects
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description=OVERVIEW,
|
|
formatter_class=argparse.RawTextHelpFormatter)
|
|
parser.add_argument("-p", "--projects", metavar="FILE",
|
|
help="File containing projects to analyze")
|
|
parser.add_argument("-r", "--releases", metavar="PATH",
|
|
help="Release repository path (default=%(default)s)",
|
|
default=os.getcwd())
|
|
parser.add_argument("--only-show", action="store_true", default=False,
|
|
help="Only list changes and do not"
|
|
" prompt to propose")
|
|
parser.add_argument('project', nargs='*', help="Project to analyze")
|
|
args = parser.parse_args()
|
|
release_repo_path = args.releases
|
|
release_deliverable_path = os.path.join(release_repo_path, 'deliverables')
|
|
try:
|
|
cycles = sorted([c for c in os.listdir(release_deliverable_path)
|
|
if not c.startswith("_")])
|
|
latest_cycle = cycles[-1]
|
|
except (IndexError, OSError):
|
|
print("Please ensure release deliverables directory '%s' exists and"
|
|
" it contains at least one release"
|
|
" cycle." % (release_deliverable_path),
|
|
file=sys.stderr)
|
|
return 1
|
|
raw_projects = []
|
|
if args.projects:
|
|
try:
|
|
raw_projects.extend(read_projects(args.projects))
|
|
except IOError:
|
|
print("Please ensure projects '%s' file exists"
|
|
" and is readable." % (args.projects), file=sys.stderr)
|
|
return 1
|
|
raw_projects.extend(args.project)
|
|
projects = extract_projects(raw_projects)
|
|
if not projects:
|
|
print("Please provide at least one project.")
|
|
return 1
|
|
with tempdir() as a_temp_dir:
|
|
# Clone fresh copies of all the repos (so we have a good
|
|
# non-altered starting set of repos, in the future we can
|
|
# likely relax this).
|
|
repos = clone_repos(a_temp_dir, projects)
|
|
for project, short_project in projects:
|
|
repo_path = repos[project]
|
|
last_release_cycle, last_release_path = find_last_release_path(
|
|
release_repo_path, latest_cycle, cycles, short_project)
|
|
if last_release_path is None or last_release_cycle is None:
|
|
last_release = None
|
|
deliverable_info = None
|
|
else:
|
|
with open(last_release_path, 'rb') as fh:
|
|
deliverable_info = yamlutils.loads(fh.read())
|
|
try:
|
|
last_release = deliverable_info['releases'][-1]
|
|
except (IndexError, KeyError, TypeError):
|
|
last_release = None
|
|
print("== Analysis of project '%s' ==" % short_project)
|
|
if not last_release:
|
|
print("It has never had a release.")
|
|
cmd = ['git', 'log', '--pretty=oneline']
|
|
output = subprocess.check_output(cmd, cwd=repo_path).decode('utf-8')
|
|
output = output.strip()
|
|
changes = list(clean_changes(output.splitlines()))
|
|
else:
|
|
print("The last release of project %s was:" % short_project)
|
|
print(" Released in: %s" % last_release_cycle)
|
|
print(" Version: %s" % last_release['version'])
|
|
print(" At sha: %s" % last_release['projects'][0]['hash'])
|
|
cmd = ['git', 'log', '--pretty=oneline',
|
|
"%s..HEAD" % last_release['projects'][0]['hash']]
|
|
output = subprocess.check_output(cmd, cwd=repo_path).decode('utf-8')
|
|
output = output.strip()
|
|
changes = list(clean_changes(output.splitlines()))
|
|
if changes:
|
|
maybe_create_release(release_repo_path, deliverable_info,
|
|
last_release, changes,
|
|
latest_cycle, project,
|
|
short_project,
|
|
should_prompt=not args.only_show)
|
|
else:
|
|
print(" No changes.")
|
|
return 0
|