|
|
#!/usr/bin/env python
|
|
|
"""
|
|
|
Backport pull requests to a particular branch.
|
|
|
|
|
|
Usage: backport_pr.py [org/repository] branch [PR] [PR2]
|
|
|
|
|
|
e.g.:
|
|
|
|
|
|
python tools/backport_pr.py 0.13.1 123 155
|
|
|
|
|
|
to backport PRs #123 and #155 onto branch 0.13.1
|
|
|
|
|
|
or
|
|
|
|
|
|
python tools/backport_pr.py 2.1
|
|
|
|
|
|
to see what PRs are marked for backport with milestone=2.1 that have yet to be applied
|
|
|
to branch 2.x
|
|
|
|
|
|
or
|
|
|
|
|
|
python tools/backport_pr.py jupyter/notebook 0.13.1 123 155
|
|
|
|
|
|
to backport PRs #123 and #155 of the `jupyter/notebook` repo onto branch 0.13.1
|
|
|
of that repo.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
import os
|
|
|
import re
|
|
|
import sys
|
|
|
|
|
|
from subprocess import Popen, PIPE, check_call, check_output
|
|
|
try:
|
|
|
from urllib.request import urlopen
|
|
|
except:
|
|
|
from urllib import urlopen
|
|
|
|
|
|
from gh_api import (
|
|
|
get_issues_list,
|
|
|
get_pull_request,
|
|
|
get_pull_request_files,
|
|
|
is_pull_request,
|
|
|
get_milestone_id,
|
|
|
)
|
|
|
|
|
|
def find_rejects(root='.'):
|
|
|
for dirname, dirs, files in os.walk(root):
|
|
|
for fname in files:
|
|
|
if fname.endswith('.rej'):
|
|
|
yield os.path.join(dirname, fname)
|
|
|
|
|
|
def get_current_branch():
|
|
|
branches = check_output(['git', 'branch'])
|
|
|
for branch in branches.splitlines():
|
|
|
if branch.startswith(b'*'):
|
|
|
return branch[1:].strip().decode('utf-8')
|
|
|
|
|
|
def backport_pr(branch, num, project='ipython/ipython'):
|
|
|
current_branch = get_current_branch()
|
|
|
if branch != current_branch:
|
|
|
check_call(['git', 'checkout', branch])
|
|
|
check_call(['git', 'pull'])
|
|
|
pr = get_pull_request(project, num, auth=True)
|
|
|
files = get_pull_request_files(project, num, auth=True)
|
|
|
patch_url = pr['patch_url']
|
|
|
title = pr['title']
|
|
|
description = pr['body']
|
|
|
fname = "PR%i.patch" % num
|
|
|
if os.path.exists(fname):
|
|
|
print("using patch from {fname}".format(**locals()))
|
|
|
with open(fname, 'rb') as f:
|
|
|
patch = f.read()
|
|
|
else:
|
|
|
req = urlopen(patch_url)
|
|
|
patch = req.read()
|
|
|
|
|
|
lines = description.splitlines()
|
|
|
if len(lines) > 5:
|
|
|
lines = lines[:5] + ['...']
|
|
|
description = '\n'.join(lines)
|
|
|
|
|
|
msg = "Backport PR #%i: %s" % (num, title) + '\n\n' + description
|
|
|
check = Popen(['git', 'apply', '--check', '--verbose'], stdin=PIPE)
|
|
|
a,b = check.communicate(patch)
|
|
|
|
|
|
if check.returncode:
|
|
|
print("patch did not apply, saving to {fname}".format(**locals()))
|
|
|
print("edit {fname} until `cat {fname} | git apply --check` succeeds".format(**locals()))
|
|
|
print("then run tools/backport_pr.py {num} again".format(**locals()))
|
|
|
if not os.path.exists(fname):
|
|
|
with open(fname, 'wb') as f:
|
|
|
f.write(patch)
|
|
|
return 1
|
|
|
|
|
|
p = Popen(['git', 'apply'], stdin=PIPE)
|
|
|
a,b = p.communicate(patch)
|
|
|
|
|
|
filenames = [ f['filename'] for f in files ]
|
|
|
|
|
|
check_call(['git', 'add'] + filenames)
|
|
|
|
|
|
check_call(['git', 'commit', '-m', msg])
|
|
|
|
|
|
print("PR #%i applied, with msg:" % num)
|
|
|
print()
|
|
|
print(msg)
|
|
|
print()
|
|
|
|
|
|
if branch != current_branch:
|
|
|
check_call(['git', 'checkout', current_branch])
|
|
|
|
|
|
return 0
|
|
|
|
|
|
backport_re = re.compile(r"(?:[Bb]ackport|[Mm]erge).*#(\d+)")
|
|
|
|
|
|
def already_backported(branch, since_tag=None):
|
|
|
"""return set of PRs that have been backported already"""
|
|
|
if since_tag is None:
|
|
|
since_tag = check_output(['git','describe', branch, '--abbrev=0']).decode('utf8').strip()
|
|
|
cmd = ['git', 'log', '%s..%s' % (since_tag, branch), '--oneline']
|
|
|
lines = check_output(cmd).decode('utf8')
|
|
|
return set(int(num) for num in backport_re.findall(lines))
|
|
|
|
|
|
def should_backport(labels=None, milestone=None, project='ipython/ipython'):
|
|
|
"""return set of PRs marked for backport"""
|
|
|
if labels is None and milestone is None:
|
|
|
raise ValueError("Specify one of labels or milestone.")
|
|
|
elif labels is not None and milestone is not None:
|
|
|
raise ValueError("Specify only one of labels or milestone.")
|
|
|
if labels is not None:
|
|
|
issues = get_issues_list(project,
|
|
|
labels=labels,
|
|
|
state='closed',
|
|
|
auth=True,
|
|
|
)
|
|
|
else:
|
|
|
milestone_id = get_milestone_id(project, milestone,
|
|
|
auth=True)
|
|
|
issues = get_issues_list(project,
|
|
|
milestone=milestone_id,
|
|
|
state='closed',
|
|
|
auth=True,
|
|
|
)
|
|
|
|
|
|
should_backport = set()
|
|
|
for issue in issues:
|
|
|
if not is_pull_request(issue):
|
|
|
continue
|
|
|
pr = get_pull_request(project, issue['number'],
|
|
|
auth=True)
|
|
|
if not pr['merged']:
|
|
|
print ("Marked PR closed without merge: %i" % pr['number'])
|
|
|
continue
|
|
|
if pr['base']['ref'] != 'master':
|
|
|
continue
|
|
|
should_backport.add(pr['number'])
|
|
|
return should_backport
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
project = 'ipython/ipython'
|
|
|
|
|
|
print("DEPRECATE: backport_pr.py is deprecated and it is now recommended"
|
|
|
"to install `ghpro` from PyPI.", file=sys.stderr)
|
|
|
|
|
|
args = list(sys.argv)
|
|
|
if len(args) >= 2:
|
|
|
if '/' in args[1]:
|
|
|
project = args[1]
|
|
|
del args[1]
|
|
|
|
|
|
if len(args) < 2:
|
|
|
print(__doc__)
|
|
|
sys.exit(1)
|
|
|
|
|
|
if len(args) < 3:
|
|
|
milestone = args[1]
|
|
|
branch = milestone.split('.')[0] + '.x'
|
|
|
already = already_backported(branch)
|
|
|
should = should_backport(milestone=milestone, project=project)
|
|
|
print ("The following PRs should be backported:")
|
|
|
for pr in sorted(should.difference(already)):
|
|
|
print (pr)
|
|
|
sys.exit(0)
|
|
|
|
|
|
for prno in map(int, args[2:]):
|
|
|
print("Backporting PR #%i" % prno)
|
|
|
rc = backport_pr(args[1], prno, project=project)
|
|
|
if rc:
|
|
|
print("Backporting PR #%i failed" % prno)
|
|
|
sys.exit(rc)
|
|
|
|