Add a interactive-cmd to propose library releases
Add a useful new command that augments some of the existing commands and can be used in a interactive manner to create a release for a given project or view what a release could be composed of. Change-Id: Ic0ee4ecc7c5abf0cd65d7cf19c132712a2acf4ae
This commit is contained in:
parent
9c1ac3dace
commit
f8a9d606b2
387
openstack_releases/cmds/interactive_release.py
Normal file
387
openstack_releases/cmds/interactive_release.py
Normal file
@ -0,0 +1,387 @@
|
||||
#
|
||||
# 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.contrib.completers 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 = 'http://docs.openstack.org/releasenotes/%s/%s.html'
|
||||
ANNOUNCE_EMAIL = 'openstack-dev@lists.openstack.org'
|
||||
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'.
|
||||
"""
|
||||
|
||||
|
||||
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:
|
||||
if isinstance(line, six.binary_type):
|
||||
line = line.decode("utf8")
|
||||
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 = NOTES_URL_TPL % (short_project, latest_cycle)
|
||||
notes_link = prompt(
|
||||
"Release notes link: ",
|
||||
validator=NoEmptyValidator(),
|
||||
default=notes_link)
|
||||
if deliverable_info:
|
||||
launchpad_project = deliverable_info['launchpad']
|
||||
announce_email = deliverable_info['send-announcements-to']
|
||||
else:
|
||||
launchpad_project = prompt(
|
||||
"Launchpad project name: ",
|
||||
validator=NoEmptyValidator(),
|
||||
default=short_project)
|
||||
announce_email = prompt(
|
||||
"Announcement email address: ",
|
||||
validator=NoEmptyValidator(),
|
||||
default=ANNOUNCE_EMAIL)
|
||||
include_pypi_link = yes_no_prompt("Include pypi link? ")
|
||||
newest_release = collections.OrderedDict([
|
||||
('launchpad', launchpad_project),
|
||||
('send-announcements-to', announce_email),
|
||||
('include-pypi-link', include_pypi_link),
|
||||
('release-notes', notes_link),
|
||||
('releases', []),
|
||||
])
|
||||
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=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, 'wb') 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)
|
||||
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)
|
||||
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
|
@ -4,3 +4,5 @@ requests>=2.5.2
|
||||
PyYAML>=3.1.0
|
||||
zuul
|
||||
yamlordereddictloader
|
||||
prompt_toolkit
|
||||
tqdm
|
||||
|
@ -26,6 +26,7 @@ console_scripts =
|
||||
list-constraints = openstack_releases.cmds.list_constraints:main
|
||||
new-release = openstack_releases.cmds.new_release:main
|
||||
format-yaml = openstack_releases.cmds.reformat_yaml:main
|
||||
interactive-release = openstack_releases.cmds.interactive_release:main
|
||||
|
||||
[extras]
|
||||
sphinxext =
|
||||
|
Loading…
x
Reference in New Issue
Block a user