| # 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 |