diff options
Diffstat (limited to 'python/wpt/exporter/step.py')
-rw-r--r-- | python/wpt/exporter/step.py | 313 |
1 files changed, 313 insertions, 0 deletions
diff --git a/python/wpt/exporter/step.py b/python/wpt/exporter/step.py new file mode 100644 index 00000000000..9781353ce15 --- /dev/null +++ b/python/wpt/exporter/step.py @@ -0,0 +1,313 @@ +# Copyright 2023 The Servo Project Developers. See the COPYRIGHT +# file at the top-level directory of this distribution. +# +# Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or +# http://www.apache.org/licenses/LICENSE-2.0> or the MIT license +# <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your +# option. This file may not be copied, modified, or distributed +# except according to those terms. + +# pylint: disable=broad-except +# pylint: disable=dangerous-default-value +# pylint: disable=fixme +# pylint: disable=missing-docstring + +# This allows using types that are defined later in the file. +from __future__ import annotations + +import logging +import os +import textwrap + +from typing import TYPE_CHECKING, Generic, Optional, TypeVar + +from .common import COULD_NOT_APPLY_CHANGES_DOWNSTREAM_COMMENT +from .common import COULD_NOT_APPLY_CHANGES_UPSTREAM_COMMENT +from .common import COULD_NOT_MERGE_CHANGES_DOWNSTREAM_COMMENT +from .common import COULD_NOT_MERGE_CHANGES_UPSTREAM_COMMENT +from .common import UPSTREAMABLE_PATH +from .common import wpt_branch_name_from_servo_pr_number +from .github import GithubBranch, GithubRepository, PullRequest + +if TYPE_CHECKING: + from . import SyncRun, WPTSync + +PATCH_FILE_NAME = "tmp.patch" + + +class Step: + def __init__(self, name): + self.name = name + + def provides(self) -> Optional[AsyncValue]: + return None + + def run(self, _: SyncRun): + return + + +T = TypeVar('T') + + +class AsyncValue(Generic[T]): + def __init__(self, value: Optional[T] = None): + self._value = value + + def resolve(self, value: T): + self._value = value + + def value(self) -> T: + assert self._value is not None + return self._value + + def has_value(self): + return self._value is not None + + +class CreateOrUpdateBranchForPRStep(Step): + def __init__(self, pull_data: dict, pull_request: PullRequest): + Step.__init__(self, "CreateOrUpdateBranchForPRStep") + self.pull_data = pull_data + self.pull_request = pull_request + self.branch: AsyncValue[GithubBranch] = AsyncValue() + + def provides(self): + return self.branch + + def run(self, run: SyncRun): + try: + commits = self._get_upstreamable_commits_from_local_servo_repo( + run.sync) + branch_name = self._create_or_update_branch_for_pr(run, commits) + branch = run.sync.downstream_wpt.get_branch(branch_name) + + self.branch.resolve(branch) + self.name += f":{len(commits)}:{branch}" + except Exception as exception: + logging.info("Could not apply changes to upstream WPT repository.") + logging.info(exception, exc_info=True) + + run.steps = [] + run.add_step(CommentStep( + self.pull_request, COULD_NOT_APPLY_CHANGES_DOWNSTREAM_COMMENT + )) + if run.upstream_pr.has_value(): + run.add_step(CommentStep( + run.upstream_pr.value(), COULD_NOT_APPLY_CHANGES_UPSTREAM_COMMENT + )) + + def _get_upstreamable_commits_from_local_servo_repo(self, sync: WPTSync): + local_servo_repo = sync.local_servo_repo + number_of_commits = self.pull_data["commits"] + pr_head = self.pull_data["head"]["sha"] + commit_shas = local_servo_repo.run( + "log", "--pretty=%H", pr_head, f"-{number_of_commits}" + ).splitlines() + + filtered_commits = [] + for sha in commit_shas: + # Specifying the path here does a few things. First, it excludes any + # changes that do not touch WPT files at all. Secondly, when a file is + # moved in or out of the WPT directory the filename which is outside the + # directory becomes /dev/null and the change becomes an addition or + # deletion. This makes the patch usable on the WPT repository itself. + # TODO: If we could cleverly parse and manipulate the full commit diff + # we could avoid cloning the servo repository altogether and only + # have to fetch the commit diffs from GitHub. + # NB: The output of git show might include binary files or non-UTF8 text, + # so store the content of the diff as a `bytes`. + diff = local_servo_repo.run_without_encoding( + "show", "--binary", "--format=%b", sha, "--", UPSTREAMABLE_PATH + ) + + # Retrieve the diff of any changes to files that are relevant + if diff: + # Create an object that contains everything necessary to transplant this + # commit to another repository. + filtered_commits += [ + { + "author": local_servo_repo.run( + "show", "-s", "--pretty=%an <%ae>", sha + ), + "message": local_servo_repo.run( + "show", "-s", "--pretty=%B", sha + ), + "diff": diff, + } + ] + return filtered_commits + + def _apply_filtered_servo_commit_to_wpt(self, run: SyncRun, commit: dict): + patch_path = os.path.join(run.sync.wpt_path, PATCH_FILE_NAME) + strip_count = UPSTREAMABLE_PATH.count("/") + 1 + + try: + with open(patch_path, "wb") as file: + file.write(commit["diff"]) + run.sync.local_wpt_repo.run( + "apply", PATCH_FILE_NAME, "-p", str(strip_count) + ) + finally: + # Ensure the patch file is not added with the other changes. + os.remove(patch_path) + + run.sync.local_wpt_repo.run("add", "--all") + run.sync.local_wpt_repo.run( + "commit", "--message", commit["message"], "--author", commit["author"] + ) + + def _create_or_update_branch_for_pr( + self, run: SyncRun, commits: list[dict], pre_commit_callback=None + ): + branch_name = wpt_branch_name_from_servo_pr_number( + self.pull_data["number"]) + try: + # Create a new branch with a unique name that is consistent between + # updates of the same PR. + run.sync.local_wpt_repo.run("checkout", "-b", branch_name) + + for commit in commits: + self._apply_filtered_servo_commit_to_wpt(run, commit) + + if pre_commit_callback: + pre_commit_callback() + + # Push the branch upstream (forcing to overwrite any existing changes). + if not run.sync.suppress_force_push: + + # In order to push to our downstream branch we need to ensure that + # the local repository isn't a shallow clone. Shallow clones are + # commonly created by GitHub actions. + run.sync.local_wpt_repo.run("fetch", "--unshallow", "origin") + + user = run.sync.github_username + token = run.sync.github_api_token + repo = run.sync.downstream_wpt_repo + remote_url = f"https://{user}:{token}@github.com/{repo}.git" + run.sync.local_wpt_repo.run( + "push", "-f", remote_url, branch_name) + + return branch_name + finally: + try: + run.sync.local_wpt_repo.run("checkout", "master") + run.sync.local_wpt_repo.run("branch", "-D", branch_name) + except Exception: + pass + + +class RemoveBranchForPRStep(Step): + def __init__(self, pull_request): + Step.__init__(self, "RemoveBranchForPRStep") + self.branch_name = wpt_branch_name_from_servo_pr_number( + pull_request["number"]) + + def run(self, run: SyncRun): + self.name += f":{run.sync.downstream_wpt.get_branch(self.branch_name)}" + logging.info(" -> Removing branch used for upstream PR") + if not run.sync.suppress_force_push: + user = run.sync.github_username + token = run.sync.github_api_token + repo = run.sync.downstream_wpt_repo + remote_url = f"https://{user}:{token}@github.com/{repo}.git" + run.sync.local_wpt_repo.run("push", remote_url, "--delete", + self.branch_name) + + +class ChangePRStep(Step): + def __init__( + self, + pull_request: PullRequest, + state: str, + title: Optional[str] = None, + body: Optional[str] = None, + ): + name = f"ChangePRStep:{pull_request}:{state}" + if title: + name += f":{title}" + + Step.__init__(self, name) + self.pull_request = pull_request + self.state = state + self.title = title + self.body = body + + def run(self, run: SyncRun): + body = self.body + if body: + body = run.prepare_body_text(body) + self.name += ( + f':{textwrap.shorten(body, width=20, placeholder="...")}[{len(body)}]' + ) + + self.pull_request.change(state=self.state, title=self.title, body=body) + + +class MergePRStep(Step): + def __init__(self, pull_request: PullRequest, labels_to_remove: list[str] = []): + Step.__init__(self, f"MergePRStep:{pull_request}") + self.pull_request = pull_request + self.labels_to_remove = labels_to_remove + + def run(self, run: SyncRun): + for label in self.labels_to_remove: + self.pull_request.remove_label(label) + try: + self.pull_request.merge() + except Exception as exception: + logging.warning("Could not merge PR (%s).", self.pull_request) + logging.warning(exception, exc_info=True) + + run.steps = [] + run.add_step(CommentStep( + self.pull_request, COULD_NOT_MERGE_CHANGES_UPSTREAM_COMMENT + )) + run.add_step(CommentStep( + run.servo_pr, COULD_NOT_MERGE_CHANGES_DOWNSTREAM_COMMENT + )) + self.pull_request.add_labels(["stale-servo-export"]) + + +class OpenPRStep(Step): + def __init__( + self, + source_branch: AsyncValue[GithubBranch], + target_repo: GithubRepository, + title: str, + body: str, + labels: list[str], + ): + Step.__init__(self, "OpenPRStep") + self.title = title + self.body = body + self.source_branch = source_branch + self.target_repo = target_repo + self.new_pr: AsyncValue[PullRequest] = AsyncValue() + self.labels = labels + + def provides(self): + return self.new_pr + + def run(self, run: SyncRun): + pull_request = self.target_repo.open_pull_request( + self.source_branch.value(), self.title, run.prepare_body_text(self.body) + ) + + if self.labels: + pull_request.add_labels(self.labels) + + self.new_pr.resolve(pull_request) + + self.name += f":{self.source_branch.value()}→{self.new_pr.value()}" + + +class CommentStep(Step): + def __init__(self, pull_request: PullRequest, comment_template: str): + Step.__init__(self, "CommentStep") + self.pull_request = pull_request + self.comment_template = comment_template + + def run(self, run: SyncRun): + comment = run.make_comment(self.comment_template) + self.name += f":{self.pull_request}:{comment}" + self.pull_request.leave_comment(comment) |