ceph/src/script/backport-create-issue
Nathan Cutler e4d6312fa2 script/backport-create-issue: add --resolve-parent feature
When --resolve-parent is provided on the command line, the script
will check the status of backport issues for those parent issues
all of whose backport issues already exist. If all the backport
issues are in status "Resolved", the parent issue's status is set
to "Resolved" as well.

Signed-off-by: Nathan Cutler <ncutler@suse.com>
2019-08-27 11:22:46 +02:00

312 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
#
# backport-create-issue
#
# Standalone version of the "backport-create-issue" subcommand of
# "ceph-workbench" by Loic Dachary.
#
# This script scans Redmine (tracker.ceph.com) for issues in "Pending Backport"
# status and creates backport issues for them, based on the contents of the
# "Backport" field while trying to avoid creating duplicate backport issues.
#
# Copyright (C) 2015 <contact@redhat.com>
# Copyright (C) 2018, SUSE LLC
#
# Author: Loic Dachary <loic@dachary.org>
# Author: Nathan Cutler <ncutler@suse.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see http://www.gnu.org/licenses/>
#
import argparse
import logging
import re
import time
from redminelib import Redmine # https://pypi.org/project/python-redmine/
redmine_endpoint = "https://tracker.ceph.com"
project_name = "Ceph"
release_id = 16
delay_seconds = 5
#
# NOTE: release_id is hard-coded because
# http://www.redmine.org/projects/redmine/wiki/Rest_CustomFields
# requires administrative permissions. If and when
# https://www.redmine.org/issues/18875
# is resolved, it could maybe be replaced by the following code:
#
# for field in redmine.custom_field.all():
# if field.name == 'Release':
# release_id = field.id
#
status2status_id = {}
project_id2project = {}
tracker2tracker_id = {}
version2version_id = {}
resolve_parent = None
def usage():
logging.error("Command-line arguments must include either a Redmine key (--key) "
"or a Redmine username and password (via --user and --password). "
"Optionally, one or more issue numbers can be given via positional "
"argument(s). In the absence of positional arguments, the script "
"will loop through all issues in Pending Backport status.")
exit(-1)
def parse_arguments():
parser = argparse.ArgumentParser()
parser.add_argument("issue_numbers", nargs='*', help="Issue number")
parser.add_argument("--key", help="Redmine user key")
parser.add_argument("--user", help="Redmine user")
parser.add_argument("--password", help="Redmine password")
parser.add_argument("--resolve-parent", help="Resolve parent issue if all backports resolved",
action="store_true")
parser.add_argument("--debug", help="Show debug-level messages",
action="store_true")
parser.add_argument("--dry-run", help="Do not write anything to Redmine",
action="store_true")
return parser.parse_args()
def set_logging_level(a):
if a.debug:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO)
return None
def report_dry_run(a):
if a.dry_run:
logging.info("Dry run: nothing will be written to Redmine")
else:
logging.warning("Missing issues will be created in Backport tracker "
"of the relevant Redmine project")
def process_resolve_parent_option(a):
global resolve_parent
resolve_parent = a.resolve_parent
if a.resolve_parent:
logging.warning("Parent issues with all backports resolved will be marked Resolved")
def connect_to_redmine(a):
if a.key:
logging.info("Redmine key was provided; using it")
return Redmine(redmine_endpoint, key=a.key)
elif a.user and a.password:
logging.info("Redmine username and password were provided; using them")
return Redmine(redmine_endpoint, username=a.user, password=a.password)
else:
usage()
def releases():
return ('argonaut', 'bobtail', 'cuttlefish', 'dumpling', 'emperor',
'firefly', 'giant', 'hammer', 'infernalis', 'jewel', 'kraken',
'luminous', 'mimic', 'nautilus')
def populate_status_dict(r):
for status in r.issue_status.all():
status2status_id[status.name] = status.id
logging.debug("Statuses {}".format(status2status_id))
return None
# not used currently, but might be useful
def populate_version_dict(r, p_id):
versions = r.version.filter(project_id=p_id)
for version in versions:
version2version_id[version.name] = version.id
#logging.debug("Versions {}".format(version2version_id))
return None
def populate_tracker_dict(r):
for tracker in r.tracker.all():
tracker2tracker_id[tracker.name] = tracker.id
logging.debug("Trackers {}".format(tracker2tracker_id))
return None
def has_tracker(r, p_id, tracker_name):
for tracker in get_project(r, p_id).trackers:
if tracker['name'] == tracker_name:
return True
return False
def get_project(r, p_id):
if p_id not in project_id2project:
p_obj = r.project.get(p_id, include='trackers')
project_id2project[p_id] = p_obj
return project_id2project[p_id]
def url(issue):
return redmine_endpoint + "/issues/" + str(issue['id'])
def set_backport(issue):
for field in issue['custom_fields']:
if field['name'] == 'Backport' and field['value'] != 0:
issue['backports'] = set(re.findall('\w+', field['value']))
logging.debug("backports for " + str(issue['id']) +
" is " + str(field['value']) + " " +
str(issue['backports']))
return True
return False
def get_release(issue):
for field in issue.custom_fields:
if field['name'] == 'Release':
return field['value']
def update_relations(r, issue, dry_run):
global resolve_parent
relations = r.issue_relation.filter(issue_id=issue['id'])
existing_backports = set()
existing_backports_dict = {}
for relation in relations:
other = r.issue.get(relation['issue_to_id'])
if other['tracker']['name'] != 'Backport':
logging.debug(url(issue) + " ignore relation to " +
url(other) + " because it is not in the Backport " +
"tracker")
continue
if relation['relation_type'] != 'copied_to':
logging.error(url(issue) + " unexpected relation '" +
relation['relation_type'] + "' to " + url(other))
continue
release = get_release(other)
if release in existing_backports:
logging.error(url(issue) + " duplicate " + release +
" backport issue detected")
continue
existing_backports.add(release)
existing_backports_dict[release] = relation['issue_to_id']
logging.debug(url(issue) + " backport to " + release + " is " +
redmine_endpoint + "/issues/" + str(relation['issue_to_id']))
if existing_backports == issue['backports']:
logging.debug(url(issue) + " has all the required backport issues")
if resolve_parent:
maybe_resolve(issue, existing_backports_dict, dry_run)
return None
if existing_backports.issuperset(issue['backports']):
logging.error(url(issue) + " has more backport issues (" +
",".join(sorted(existing_backports)) + ") than expected (" +
",".join(sorted(issue['backports'])) + ")")
return None
backport_tracker_id = tracker2tracker_id['Backport']
for release in issue['backports'] - existing_backports:
if release not in releases():
logging.error(url(issue) + " requires backport to " +
"unknown release " + release)
break
subject = (release + ": " + issue['subject'])[:255]
if dry_run:
logging.info(url(issue) + " add backport to " + release)
continue
other = r.issue.create(project_id=issue['project']['id'],
tracker_id=backport_tracker_id,
subject=subject,
priority='Normal',
target_version=None,
custom_fields=[{
"id": release_id,
"value": release,
}])
logging.debug("Rate-limiting to avoid seeming like a spammer")
time.sleep(delay_seconds)
r.issue_relation.create(issue_id=issue['id'],
issue_to_id=other['id'],
relation_type='copied_to')
logging.info(url(issue) + " added backport to " +
release + " " + url(other))
return None
def maybe_resolve(issue, backports, dry_run):
'''
issue is a parent issue in Pending Backports status, and backports is a dict
like, e.g., { "luminous": 25345, "mimic": 32134 }.
If all the backport issues are Resolved, set the parent issue to Resolved, too.
'''
global delay_seconds
global redmine
global status2status_id
pending_backport_status_id = status2status_id["Pending Backport"]
resolved_status_id = status2status_id["Resolved"]
logging.debug("entering maybe_resolve with parent issue ->{}<- backports ->{}<-"
.format(issue.id, backports))
assert issue.status.id == pending_backport_status_id, \
"Parent Redmine issue ->{}<- has status ->{}<- (expected Pending Backport)".format(issue.id, issue.status)
all_resolved = True
for backport in backports.keys():
tracker_issue_id = backports[backport]
backport_issue = redmine.issue.get(tracker_issue_id)
logging.debug("{} backport is in status {}".format(backport, backport_issue.status.name))
if backport_issue.status.id != resolved_status_id:
all_resolved = False
break
if all_resolved:
logging.debug("Parent ->{}<- all backport issues in status Resolved".format(url(issue)))
note = ("While running with --resolve-parent, the script \"backport-create-issue\" "
"noticed that all backports of this issue are in status \"Resolved\".")
if dry_run:
logging.info("Set status of parent ->{}<- to Resolved".format(url(issue)))
else:
redmine.issue.update(issue.id, status_id=resolved_status_id, notes=note)
logging.info("Parent ->{}<- status changed from Pending Backport to Resolved".format(url(issue)))
logging.debug("Rate-limiting to avoid seeming like a spammer")
time.sleep(delay_seconds)
else:
logging.debug("Some backport issues are still unresolved: leaving parent issue open")
def iterate_over_backports(r, issues, dry_run):
counter = 0
for issue in issues:
counter += 1
logging.debug("{} ({}) {}".format(issue.id, issue.project,
issue.subject))
print('{}\r'.format(issue.id), end='', flush=True)
if not has_tracker(r, issue['project']['id'], 'Backport'):
logging.info("{} skipped because the project {} does not "
"have a Backport tracker".format(url(issue),
issue['project']['name']))
continue
if not set_backport(issue):
logging.error(url(issue) + " no backport field")
continue
if len(issue['backports']) == 0:
logging.error(url(issue) + " the backport field is empty")
update_relations(r, issue, dry_run)
logging.info("Processed {} issues with status Pending Backport"
.format(counter))
return None
if __name__ == '__main__':
args = parse_arguments()
set_logging_level(args)
process_resolve_parent_option(args)
report_dry_run(args)
redmine = connect_to_redmine(args)
project = redmine.project.get(project_name)
ceph_project_id = project.id
logging.debug("Project {} has ID {}".format(project_name, ceph_project_id))
populate_status_dict(redmine)
pending_backport_status_id = status2status_id["Pending Backport"]
logging.debug("Pending Backport status has ID {}"
.format(pending_backport_status_id))
populate_tracker_dict(redmine)
if args.issue_numbers:
issue_list = ','.join(args.issue_numbers)
logging.info("Processing issue list ->{}<-".format(issue_list))
issues = redmine.issue.filter(project_id=ceph_project_id,
issue_id=issue_list,
status_id=pending_backport_status_id)
else:
issues = redmine.issue.filter(project_id=ceph_project_id,
status_id=pending_backport_status_id)
iterate_over_backports(redmine, issues, args.dry_run)