blob: a64363b16fead73d3fc028248ad1d96d6e97b81c [file] [log] [blame]
# Copyright (C) 2021-2022 Apple Inc. All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import re
import subprocess
import sys
import time
from .canonicalize import Canonicalize
from .command import Command
from .branch import Branch
from .pull_request import PullRequest
from argparse import Namespace
from webkitbugspy import Tracker
from webkitcorepy import arguments, run, string_utils, Terminal
from webkitscmpy import Commit, local, log, remote
class Land(Command):
name = 'land'
help = 'If on a pull-request or commit-queue branch, rebase the ' \
'current branch onto the target production branch and push.'
OOPS_RE = re.compile(r'\(O+P+S!*\)')
REVIEWED_BY_RE = re.compile('Reviewed by (?P<approver>.+)')
GIT_SVN_COMMITTED_RE = re.compile(r'Committed r(?P<revision>\d+)')
REMOTE = 'origin'
MIRROR_TIMEOUT = 60
@classmethod
def revert_branch(cls, repository, remote, branch):
if run(
[repository.executable(), 'branch', '-f', branch, 'remotes/{}/{}'.format(remote, branch)],
cwd=repository.root_path,
).returncode:
return False
return True
@classmethod
def parser(cls, parser, loggers=None):
PullRequest.parser(parser, loggers=loggers)
parser.add_argument(
'--no-force-review', '--force-review', '--no-review',
dest='review', default=True,
help='Check if the change has been approved or blocked by reviewers',
action=arguments.NoAction,
)
parser.add_argument(
'--no-oops', '--no-allow-oops', '--allow-oops',
dest='oops', default=False,
help="Allow (OOPS!) in commit messages",
action=arguments.NoAction,
)
parser.add_argument(
'--safe', '--unsafe',
dest='safe', default=None,
help='Land change via safe (or unsafe) merge queue, if available.',
action=arguments.NoAction,
)
@classmethod
def merge_queue(cls, args, repository, branch_point, merge_labels=None):
log.info('Detected merging automation, using that instead of local git tooling')
merge_type = {
True: 'safe',
False: 'unsafe',
}.get(args.safe, sorted(merge_labels.keys())[0])
merge_label = merge_labels.get(merge_type)
if not merge_label:
sys.stderr.write("No {} merge-queue available for this repository\n".format(merge_type))
return 1
def callback(pr):
pr_issue = pr._metadata.get('issue')
if not pr_issue:
sys.stderr.write("Cannot set any labels on '{}' because the service doesn't support labels\n".format(pr))
return 1
labels = pr_issue.labels
if PullRequest.BLOCKED_LABEL in labels and merge_type == 'unsafe':
log.info("Removing '{}' from PR {}...".format(cls.BLOCKED_LABEL, existing_pr.number))
labels.remove(PullRequest.BLOCKED_LABEL)
log.info("Adding '{}' to '{}'".format(merge_label, pr))
labels.append(merge_label)
if pr_issue.set_labels(labels):
print("Added '{}' to '{}', change is in the queue to be landed".format(merge_label, pr))
return 0
sys.stderr.write("Failed to add '{}' to '{}', change is not landing\n".format(merge_label, pr))
if pr.url:
sys.stderr.write("See if you can add the label manually on '{}'".format(pr.url))
return 1
return PullRequest.create_pull_request(
repository, args, branch_point,
callback=callback,
unblock=True if merge_type == 'unsafe' else False,
)
@classmethod
def main(cls, args, repository, identifier_template=None, canonical_svn=False, **kwargs):
if not repository:
sys.stderr.write('No repository provided\n')
return 1
if not repository.path:
sys.stderr.write("Cannot 'land' change in remote repository\n")
return 1
if not isinstance(repository, local.Git):
sys.stderr.write("'land' only supported by local git repositories\n")
return 1
if canonical_svn and not repository.is_svn:
sys.stderr.write("Cannot 'land' on a canonical SVN repository that is not configured as git-svn\n")
return 1
if not PullRequest.check_pull_request_args(repository, args):
return 1
modified_files = [] if args.will_add is False else repository.modified()
if args.will_add:
modified_files = list(set(modified_files).union(set(repository.modified(staged=False))))
if not Branch.editable(repository.branch, repository=repository) and not modified_files:
sys.stderr.write("Can only 'land' editable branches\n")
return 1
branch_point = PullRequest.pull_request_branch_point(repository, args, **kwargs)
if not branch_point:
return 1
source_branch = repository.branch
if not Branch.editable(source_branch, repository=repository):
sys.stderr.write("Can only 'land' editable branches\n")
return 1
result = PullRequest.create_commit(args, repository, **kwargs)
if result:
return result
commits = list(repository.commits(begin=dict(hash=branch_point.hash), end=dict(branch=source_branch)))
if not commits:
sys.stderr.write('Failed to find commits to land\n')
return 1
if args.squash or (args.squash is None and len(commits) > 1):
result = Squash.squash_commit(args, repository, branch_point, **kwargs)
if result:
return result
commits = list(repository.commits(begin=dict(hash=branch_point.hash), end=dict(branch=source_branch)))
rmt = repository.remote()
if rmt and isinstance(rmt, remote.GitHub):
merge_labels = dict()
for name in rmt.tracker.labels.keys():
if name in PullRequest.MERGE_LABELS:
merge_labels['safe'] = name
if name in PullRequest.UNSAFE_MERGE_LABELS:
merge_labels['unsafe'] = name
if merge_labels:
return cls.merge_queue(args, repository, branch_point, merge_labels=merge_labels)
if args.safe is not None:
sys.stderr.write("No merge-queue available for this repository\n")
return 1
pull_request = None
if rmt and rmt.pull_requests:
candidates = list(rmt.pull_requests.find(opened=True, head=source_branch))
if len(candidates) == 1:
pull_request = candidates[0]
elif candidates:
sys.stderr.write("Multiple pull-request match '{}'\n".format(source_branch))
if pull_request and args.review:
if pull_request.blockers:
sys.stderr.write("{} {} blocking landing '{}'\n".format(
string_utils.join([p.name for p in pull_request.blockers]),
'are' if len(pull_request.blockers) > 1 else 'is',
pull_request,
))
return 1
need_review = False
if pull_request.approvers:
review_lines = [cls.REVIEWED_BY_RE.search(commit.message) for commit in commits]
need_review = any([cls.OOPS_RE.search(match.group('approver')) for match in review_lines if match])
if need_review and (args.defaults or Terminal.choose("Set '{}' as your reviewer{}?".format(
string_utils.join([p.name for p in pull_request.approvers]),
's' if len(pull_request.approvers) > 1 else '',
), default='Yes') == 'Yes'):
log.info("Setting {} as reviewer{}".format(
string_utils.join([p.name for p in pull_request.approvers]),
's' if len(pull_request.approvers) > 1 else '',
))
if run([
repository.executable(), 'filter-branch', '-f',
'--env-filter', "GIT_AUTHOR_DATE='{date}';GIT_COMMITTER_DATE='{date}'".format(
date='{} -{}'.format(int(time.time()), repository.gmtoffset())
), '--msg-filter', 'sed "s/NOBODY (OO*PP*S!*)/{}/g"'.format(string_utils.join([p.name for p in pull_request.approvers])),
'{}...{}'.format(source_branch, branch_point.hash),
], cwd=repository.root_path, env={'FILTER_BRANCH_SQUELCH_WARNING': '1'}, capture_output=True).returncode:
sys.stderr.write('Failed to set reviewers\n')
return 1
commits = list(repository.commits(begin=dict(hash=branch_point.hash), end=dict(branch=source_branch)))
if not commits:
sys.stderr.write('Failed to find commits after setting reviewers\n')
return 1
elif not pull_request:
sys.stderr.write("Failed to find pull-request associated with '{}'\n".format(source_branch))
if not args.oops and any([cls.OOPS_RE.search(commit.message) for commit in commits if commit.message]):
sys.stderr.write("Found '(OOPS!)' message in commit messages, please resolve before committing\n")
return 1
if not args.oops:
for line in repository.diff_lines(branch_point.hash, source_branch):
if cls.OOPS_RE.search(line):
sys.stderr.write("Found '(OOPS!)' in commit diff, please resolve before committing\n")
return 1
issue = None
for line in commits[0].message.split() if commits[0] and commits[0].message else []:
issue = Tracker.from_string(line)
if issue:
break
target = pull_request.base if pull_request else branch_point.branch
log.info("Rebasing '{}' from '{}' to '{}'...".format(source_branch, branch_point.branch, target))
if repository.fetch(branch=target, remote=cls.REMOTE):
sys.stderr.write("Failed to fetch '{}' from '{}'\n".format(target, cls.REMOTE))
return 1
if repository.rebase(target=target, base=branch_point.branch, head=source_branch):
sys.stderr.write("Failed to rebase '{}' onto '{}', please resolve conflicts\n".format(source_branch, target))
return 1
log.info("Rebased '{}' from '{}' to '{}'!".format(source_branch, branch_point.branch, target))
if run([repository.executable(), 'branch', '-f', target, source_branch], cwd=repository.root_path).returncode:
sys.stderr.write("Failed to move '{}' ref\n".format(target))
return 1 if cls.revert_branch(repository, cls.REMOTE, target) else -1
if identifier_template:
repository.checkout(target)
if Canonicalize.main(Namespace(
identifier=True, remote=cls.REMOTE, number=len(commits),
), repository, identifier_template=identifier_template):
sys.stderr.write("Failed to embed identifiers to '{}'\n".format(target))
return 1 if cls.revert_branch(repository, cls.REMOTE, target) else -1
if run([repository.executable(), 'branch', '-f', source_branch, target], cwd=repository.root_path).returncode:
sys.stderr.write("Failed to move '{}' ref to the canonicalized head of '{}'\n".format(source, target))
cls.revert_branch(repository, cls.REMOTE, target)
return -1
# Need to compute the remote source
remote_target = 'fork' if isinstance(rmt, remote.GitHub) else 'origin'
if canonical_svn:
if run([repository.executable(), 'svn', 'fetch'], cwd=repository.root_path).returncode:
sys.stderr.write("Failed to update subversion refs\n".format(target))
return 1 if cls.revert_branch(repository, cls.REMOTE, target) else -1
dcommit = run(
[repository.executable(), 'svn', 'dcommit'],
cwd=repository.root_path,
stdout=subprocess.PIPE,
encoding='utf-8',
)
if dcommit.returncode:
sys.stderr.write(dcommit.stdout)
sys.stderr.write(dcommit.stderr)
sys.stderr.write("Failed to commit '{}' to Subversion remote\n".format(target))
return 1 if cls.revert_branch(repository, cls.REMOTE, target) else -1
revisions = []
for line in dcommit.stdout.splitlines():
match = cls.GIT_SVN_COMMITTED_RE.match(line)
if not match:
continue
revisions.append(int(match.group('revision')))
if not revisions:
sys.stderr.write(dcommit.stdout)
sys.stderr.write(dcommit.stderr)
sys.stderr.write("Failed to find revision in '{}' when committing to Subversion remote\n".format(target))
return 1 if cls.revert_branch(repository, cls.REMOTE, target) else -1
run([repository.executable(), 'reset', 'HEAD~{}'.format(len(commits)), '--hard'], cwd=repository.root_path)
# Verify the mirror processed our change
started = time.time()
latest = repository.find('HEAD', include_log=True, include_identifier=False)
while latest.revision < revisions[-1]:
if time.time() - started > cls.MIRROR_TIMEOUT:
sys.stderr.write("Timed out waiting for the git-svn mirror, '{}' landed but not closed\n".format(pull_request or source_branch))
return 1
log.info(' Verifying mirror processesed change')
time.sleep(5)
run([repository.executable(), 'pull'], cwd=repository.root_path)
latest = repository.find('HEAD', include_log=True, include_identifier=False)
if repository.cache and target in repository.cache._last_populated:
del repository.cache._last_populated[target]
commits = []
for revision in revisions:
commits.append(repository.commit(revision=revision, include_log=True))
commit = commits[-1]
if pull_request:
run([repository.executable(), 'branch', '-f', source_branch, target], cwd=repository.root_path)
run([repository.executable(), 'push', '-f', remote_target, source_branch], cwd=repository.root_path)
rmt.pull_requests.update(
pull_request=pull_request,
title=PullRequest.title_for(commits),
commits=commits,
base=branch_point.branch,
head=source_branch,
)
else:
if pull_request:
log.info("Updating '{}' to match landing commits...".format(pull_request))
commits = list(repository.commits(begin=dict(argument='{}~{}'.format(source_branch, len(commits))), end=dict(branch=source_branch)))
run([repository.executable(), 'push', '-f', remote_target, source_branch], cwd=repository.root_path)
rmt.pull_requests.update(
pull_request=pull_request,
title=PullRequest.title_for(commits),
commits=commits,
base=branch_point.branch,
head=source_branch,
)
if run([repository.executable(), 'push', cls.REMOTE, target], cwd=repository.root_path).returncode:
sys.stderr.write("Failed to push '{}' to '{}'\n".format(target, cls.REMOTE))
return 1 if cls.revert_branch(repository, cls.REMOTE, target) else -1
repository.checkout(target)
commit = repository.commit(branch=target, include_log=False)
if identifier_template and commit.identifier:
land_message = 'Landed {} ({})!'.format(identifier_template.format(commit).split(': ')[-1], commit.hash[:Commit.HASH_LABEL_SIZE])
else:
land_message = 'Landed {}!'.format(commit.hash[:Commit.HASH_LABEL_SIZE])
print(land_message)
if pull_request:
pull_request.comment(land_message)
if issue:
if canonical_svn and commit.revision:
land_message = land_message.replace(commit.hash[:Commit.HASH_LABEL_SIZE], 'r{}'.format(commit.revision))
issue.close(why=land_message)
if args.defaults or Terminal.choose("Delete branch '{}'?".format(source_branch), default='Yes') == 'Yes':
regex = re.compile(r'^{}-(?P<count>\d+)$'.format(source_branch))
for to_delete in repository.branches_for(remote=remote_target):
if to_delete == source_branch or regex.match(to_delete) and remote_target == 'fork':
run([repository.executable(), 'branch', '-D', to_delete], cwd=repository.root_path)
run([repository.executable(), 'push', remote_target, '--delete', to_delete], cwd=repository.root_path)
return 0