342 lines
12 KiB
Python
Executable File
342 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright (c) 2022, Meta
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Query issues in a release branch
|
|
|
|
This script searches for issues referenced via pull-requests in a release
|
|
branch in order to simplify tracking changes such as automated backports,
|
|
manual backports, security fixes, and stability fixes.
|
|
|
|
A formatted report is printed to standard output either in JSON or
|
|
reStructuredText.
|
|
|
|
Since an issue is required for all changes to release branches, merged PRs
|
|
must have at least one instance of the phrase "Fixes #1234" in the body. This
|
|
script will throw an error if a PR has been made without an associated issue.
|
|
|
|
Usage:
|
|
./scripts/release/list_backports.py \
|
|
-t ~/.ghtoken \
|
|
-b v2.7-branch \
|
|
-s 2021-12-15 -e 2022-04-22 \
|
|
-P 45074 -P 45868 -P 44918 -P 41234 -P 41174 \
|
|
-j | jq . | tee /tmp/backports.json
|
|
|
|
GITHUB_TOKEN="<secret>" \
|
|
./scripts/release/list_backports.py \
|
|
-b v3.0-branch \
|
|
-p 43381 \
|
|
-j | jq . | tee /tmp/backports.json
|
|
"""
|
|
|
|
import argparse
|
|
from datetime import datetime, timedelta
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
# Requires PyGithub
|
|
from github import Github
|
|
|
|
|
|
# https://gist.github.com/monkut/e60eea811ef085a6540f
|
|
def valid_date_type(arg_date_str):
|
|
"""custom argparse *date* type for user dates values given from the
|
|
command line"""
|
|
try:
|
|
return datetime.strptime(arg_date_str, "%Y-%m-%d")
|
|
except ValueError:
|
|
msg = "Given Date ({0}) not valid! Expected format, YYYY-MM-DD!".format(arg_date_str)
|
|
raise argparse.ArgumentTypeError(msg)
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('-t', '--token', dest='tokenfile',
|
|
help='File containing GitHub token (alternatively, use GITHUB_TOKEN env variable)', metavar='FILE')
|
|
parser.add_argument('-b', '--base', dest='base',
|
|
help='branch (base) for PRs (e.g. v2.7-branch)', metavar='BRANCH', required=True)
|
|
parser.add_argument('-j', '--json', dest='json', action='store_true',
|
|
help='print output in JSON rather than RST')
|
|
parser.add_argument('-s', '--start', dest='start', help='start date (YYYY-mm-dd)',
|
|
metavar='START_DATE', type=valid_date_type)
|
|
parser.add_argument('-e', '--end', dest='end', help='end date (YYYY-mm-dd)',
|
|
metavar='END_DATE', type=valid_date_type)
|
|
parser.add_argument("-o", "--org", default="zephyrproject-rtos",
|
|
help="Github organisation")
|
|
parser.add_argument('-p', '--include-pull', dest='includes',
|
|
help='include pull request (can be specified multiple times)',
|
|
metavar='PR', type=int, action='append', default=[])
|
|
parser.add_argument('-P', '--exclude-pull', dest='excludes',
|
|
help='exlude pull request (can be specified multiple times, helpful for version bumps and release notes)',
|
|
metavar='PR', type=int, action='append', default=[])
|
|
parser.add_argument("-r", "--repo", default="zephyr",
|
|
help="Github repository")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.includes:
|
|
if getattr(args, 'start'):
|
|
logging.error(
|
|
'the --start argument should not be used with --include-pull')
|
|
return None
|
|
if getattr(args, 'end'):
|
|
logging.error(
|
|
'the --end argument should not be used with --include-pull')
|
|
return None
|
|
else:
|
|
if not getattr(args, 'start'):
|
|
logging.error(
|
|
'if --include-pr PR is not used, --start START_DATE is required')
|
|
return None
|
|
|
|
if not getattr(args, 'end'):
|
|
setattr(args, 'end', datetime.now())
|
|
|
|
if args.end < args.start:
|
|
logging.error(
|
|
f'end date {args.end} is before start date {args.start}')
|
|
return None
|
|
|
|
if args.tokenfile:
|
|
with open(args.tokenfile, 'r') as file:
|
|
token = file.read()
|
|
token = token.strip()
|
|
else:
|
|
if 'GITHUB_TOKEN' not in os.environ:
|
|
raise ValueError('No credentials specified')
|
|
token = os.environ['GITHUB_TOKEN']
|
|
|
|
setattr(args, 'token', token)
|
|
|
|
return args
|
|
|
|
|
|
class Backport(object):
|
|
def __init__(self, repo, base, pulls):
|
|
self._base = base
|
|
self._repo = repo
|
|
self._issues = []
|
|
self._pulls = pulls
|
|
|
|
self._pulls_without_an_issue = []
|
|
self._pulls_with_invalid_issues = {}
|
|
|
|
@staticmethod
|
|
def by_date_range(repo, base, start_date, end_date, excludes):
|
|
"""Create a Backport object with the provided repo,
|
|
base, start datetime object, and end datetime objects, and
|
|
list of excluded PRs"""
|
|
|
|
pulls = []
|
|
|
|
unfiltered_pulls = repo.get_pulls(
|
|
base=base, state='closed')
|
|
for p in unfiltered_pulls:
|
|
if not p.merged:
|
|
# only consider merged backports
|
|
continue
|
|
|
|
if p.closed_at < start_date or p.closed_at >= end_date + timedelta(1):
|
|
# only concerned with PRs within time window
|
|
continue
|
|
|
|
if p.number in excludes:
|
|
# skip PRs that have been explicitly excluded
|
|
continue
|
|
|
|
pulls.append(p)
|
|
|
|
# paginated_list.sort() does not exist
|
|
pulls = sorted(pulls, key=lambda x: x.number)
|
|
|
|
return Backport(repo, base, pulls)
|
|
|
|
@staticmethod
|
|
def by_included_prs(repo, base, includes):
|
|
"""Create a Backport object with the provided repo,
|
|
base, and list of included PRs"""
|
|
|
|
pulls = []
|
|
|
|
for i in includes:
|
|
try:
|
|
p = repo.get_pull(i)
|
|
except Exception:
|
|
p = None
|
|
|
|
if not p:
|
|
logging.error(f'{i} is not a valid pull request')
|
|
return None
|
|
|
|
if p.base.ref != base:
|
|
logging.error(
|
|
f'{i} is not a valid pull request for base {base} ({p.base.label})')
|
|
return None
|
|
|
|
pulls.append(p)
|
|
|
|
# paginated_list.sort() does not exist
|
|
pulls = sorted(pulls, key=lambda x: x.number)
|
|
|
|
return Backport(repo, base, pulls)
|
|
|
|
@staticmethod
|
|
def sanitize_title(title):
|
|
# TODO: sanitize titles such that they are suitable for both JSON and ReStructured Text
|
|
# could also automatically fix titles like "Automated backport of PR #1234"
|
|
return title
|
|
|
|
def print(self):
|
|
for i in self.get_issues():
|
|
title = Backport.sanitize_title(i.title)
|
|
# * :github:`38972` - logging: Cleaning references to tracing in logging
|
|
print(f'* :github:`{i.number}` - {title}')
|
|
|
|
def print_json(self):
|
|
issue_objects = []
|
|
for i in self.get_issues():
|
|
obj = {}
|
|
obj['id'] = i.number
|
|
obj['title'] = Backport.sanitize_title(i.title)
|
|
obj['url'] = f'https://github.com/{self._repo.organization.login}/{self._repo.name}/pull/{i.number}'
|
|
issue_objects.append(obj)
|
|
|
|
print(json.dumps(issue_objects))
|
|
|
|
def get_pulls(self):
|
|
return self._pulls
|
|
|
|
def get_issues(self):
|
|
"""Return GitHub issues fixed in the provided date window"""
|
|
if self._issues:
|
|
return self._issues
|
|
|
|
issue_map = {}
|
|
self._pulls_without_an_issue = []
|
|
self._pulls_with_invalid_issues = {}
|
|
|
|
for p in self._pulls:
|
|
# check for issues in this pr
|
|
issues_for_this_pr = {}
|
|
with io.StringIO(p.body) as buf:
|
|
for line in buf.readlines():
|
|
line = line.strip()
|
|
match = re.search(r"^Fixes[:]?\s*#([1-9][0-9]*).*", line)
|
|
if not match:
|
|
match = re.search(
|
|
rf"^Fixes[:]?\s*https://github\.com/{self._repo.organization.login}/{self._repo.name}/issues/([1-9][0-9]*).*", line)
|
|
if not match:
|
|
continue
|
|
issue_number = int(match[1])
|
|
issue = self._repo.get_issue(issue_number)
|
|
if not issue:
|
|
if not self._pulls_with_invalid_issues[p.number]:
|
|
self._pulls_with_invalid_issues[p.number] = [
|
|
issue_number]
|
|
else:
|
|
self._pulls_with_invalid_issues[p.number].append(
|
|
issue_number)
|
|
logging.error(
|
|
f'https://github.com/{self._repo.organization.login}/{self._repo.name}/pull/{p.number} references invalid issue number {issue_number}')
|
|
continue
|
|
issues_for_this_pr[issue_number] = issue
|
|
|
|
# report prs missing issues later
|
|
if len(issues_for_this_pr) == 0:
|
|
logging.error(
|
|
f'https://github.com/{self._repo.organization.login}/{self._repo.name}/pull/{p.number} does not have an associated issue')
|
|
self._pulls_without_an_issue.append(p)
|
|
continue
|
|
|
|
# FIXME: when we have upgrade to python3.9+, use "issue_map | issues_for_this_pr"
|
|
issue_map = {**issue_map, **issues_for_this_pr}
|
|
|
|
issues = list(issue_map.values())
|
|
|
|
# paginated_list.sort() does not exist
|
|
issues = sorted(issues, key=lambda x: x.number)
|
|
|
|
self._issues = issues
|
|
|
|
return self._issues
|
|
|
|
def get_pulls_without_issues(self):
|
|
if self._pulls_without_an_issue:
|
|
return self._pulls_without_an_issue
|
|
|
|
self.get_issues()
|
|
|
|
return self._pulls_without_an_issue
|
|
|
|
def get_pulls_with_invalid_issues(self):
|
|
if self._pulls_with_invalid_issues:
|
|
return self._pulls_with_invalid_issues
|
|
|
|
self.get_issues()
|
|
|
|
return self._pulls_with_invalid_issues
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
|
|
if not args:
|
|
return os.EX_DATAERR
|
|
|
|
try:
|
|
gh = Github(args.token)
|
|
except Exception:
|
|
logging.error('failed to authenticate with GitHub')
|
|
return os.EX_DATAERR
|
|
|
|
try:
|
|
repo = gh.get_repo(args.org + '/' + args.repo)
|
|
except Exception:
|
|
logging.error('failed to obtain Github repository')
|
|
return os.EX_DATAERR
|
|
|
|
bp = None
|
|
if args.includes:
|
|
bp = Backport.by_included_prs(repo, args.base, set(args.includes))
|
|
else:
|
|
bp = Backport.by_date_range(repo, args.base,
|
|
args.start, args.end, set(args.excludes))
|
|
|
|
if not bp:
|
|
return os.EX_DATAERR
|
|
|
|
pulls_with_invalid_issues = bp.get_pulls_with_invalid_issues()
|
|
if pulls_with_invalid_issues:
|
|
logging.error('The following PRs link to invalid issues:')
|
|
for (p, lst) in pulls_with_invalid_issues:
|
|
logging.error(
|
|
f'\nhttps://github.com/{repo.organization.login}/{repo.name}/pull/{p.number}: {lst}')
|
|
return os.EX_DATAERR
|
|
|
|
pulls_without_issues = bp.get_pulls_without_issues()
|
|
if pulls_without_issues:
|
|
logging.error(
|
|
'Please ensure the body of each PR to a release branch contains "Fixes #1234"')
|
|
logging.error('The following PRs are lacking associated issues:')
|
|
for p in pulls_without_issues:
|
|
logging.error(
|
|
f'https://github.com/{repo.organization.login}/{repo.name}/pull/{p.number}')
|
|
return os.EX_DATAERR
|
|
|
|
if args.json:
|
|
bp.print_json()
|
|
else:
|
|
bp.print()
|
|
|
|
return os.EX_OK
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|