tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

upstream.py (15237B)


      1 # This Source Code Form is subject to the terms of the Mozilla Public
      2 # License, v. 2.0. If a copy of the MPL was not distributed with this
      3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
      4 
      5 import os
      6 import re
      7 import subprocess
      8 import sys
      9 import tempfile
     10 
     11 from six.moves.urllib import parse as urlparse
     12 from wptrunner.update.base import Step, StepRunner, exit_clean, exit_unclean
     13 from wptrunner.update.tree import get_unique_name
     14 
     15 from .github import GitHub
     16 from .tree import Commit, GitTree, Patch
     17 
     18 
     19 def rewrite_patch(patch, strip_dir):
     20    """Take a Patch and convert to a different repository by stripping a prefix from the
     21    file paths. Also rewrite the message to remove the bug number and reviewer, but add
     22    a bugzilla link in the summary.
     23 
     24    :param patch: the Patch to convert
     25    :param strip_dir: the path prefix to remove
     26    """
     27 
     28    if not strip_dir.startswith("/"):
     29        strip_dir = "/%s" % strip_dir
     30 
     31    new_diff = []
     32    line_starts = [
     33        ("diff ", True),
     34        ("+++ ", True),
     35        ("--- ", True),
     36        ("rename from ", False),
     37        ("rename to ", False),
     38    ]
     39    for line in patch.diff.split("\n"):
     40        for start, leading_slash in line_starts:
     41            strip = strip_dir if leading_slash else strip_dir[1:]
     42            if line.startswith(start):
     43                new_diff.append(line.replace(strip, "").encode("utf8"))
     44                break
     45        else:
     46            new_diff.append(line)
     47 
     48    new_diff = "\n".join(new_diff)
     49 
     50    assert new_diff != patch
     51 
     52    return Patch(patch.author, patch.email, rewrite_message(patch), new_diff)
     53 
     54 
     55 def rewrite_message(patch):
     56    if patch.message.bug is not None:
     57        return "\n".join([
     58            patch.message.summary,
     59            patch.message.body,
     60            "",
     61            "Upstreamed from https://bugzilla.mozilla.org/show_bug.cgi?id=%s [ci skip]"
     62            % patch.message.bug,  # noqa E501
     63        ])
     64 
     65    return "\n".join([
     66        patch.message.full_summary,
     67        "%s\n[ci skip]\n" % patch.message.body,
     68    ])
     69 
     70 
     71 class SyncToUpstream(Step):
     72    """Sync local changes to upstream"""
     73 
     74    def create(self, state):
     75        if not state.kwargs["upstream"]:
     76            return
     77 
     78        if not isinstance(state.local_tree, GitTree):
     79            self.logger.error("Cannot sync with upstream from a non-Git checkout.")
     80            return exit_clean
     81 
     82        try:
     83            import requests  # noqa F401
     84        except ImportError:
     85            self.logger.error(
     86                "Upstream sync requires the requests module to be installed"
     87            )
     88            return exit_clean
     89 
     90        if not state.sync_tree:
     91            os.makedirs(state.sync["path"])
     92            state.sync_tree = GitTree(root=state.sync["path"])
     93 
     94        kwargs = state.kwargs
     95        with state.push([
     96            "local_tree",
     97            "sync_tree",
     98            "tests_path",
     99            "metadata_path",
    100            "sync",
    101        ]):
    102            state.token = kwargs["token"]
    103            runner = SyncToUpstreamRunner(self.logger, state)
    104            runner.run()
    105 
    106 
    107 class GetLastSyncData(Step):
    108    """Find the gecko commit at which we last performed a sync with upstream and the upstream
    109    commit that was synced."""
    110 
    111    provides = ["sync_data_path", "last_sync_commit", "old_upstream_rev"]
    112 
    113    def create(self, state):
    114        self.logger.info("Looking for last sync commit")
    115        state.sync_data_path = os.path.join(state.metadata_path, "mozilla-sync")
    116        items = {}
    117        with open(state.sync_data_path) as f:
    118            for line in f.readlines():
    119                key, value = [item.strip() for item in line.split(":", 1)]
    120                items[key] = value
    121 
    122        state.last_sync_commit = Commit(
    123            state.local_tree, state.local_tree.rev_from_hg(items["local"])
    124        )
    125        state.old_upstream_rev = items["upstream"]
    126 
    127        if not state.local_tree.contains_commit(state.last_sync_commit):
    128            self.logger.error(
    129                "Could not find last sync commit %s" % state.last_sync_commit.sha1
    130            )
    131            return exit_clean
    132 
    133        self.logger.info(
    134            "Last sync to web-platform-tests happened in %s"
    135            % state.last_sync_commit.sha1
    136        )
    137 
    138 
    139 class CheckoutBranch(Step):
    140    """Create a branch in the sync tree pointing at the last upstream sync commit
    141    and check it out"""
    142 
    143    provides = ["branch"]
    144 
    145    def create(self, state):
    146        self.logger.info("Updating sync tree from %s" % state.sync["remote_url"])
    147        state.branch = state.sync_tree.unique_branch_name(
    148            "outbound_update_%s" % state.old_upstream_rev
    149        )
    150        state.sync_tree.update(
    151            state.sync["remote_url"], state.sync["branch"], state.branch
    152        )
    153        state.sync_tree.checkout(state.old_upstream_rev, state.branch, force=True)
    154 
    155 
    156 class GetBaseCommit(Step):
    157    """Find the latest upstream commit on the branch that we are syncing with"""
    158 
    159    provides = ["base_commit"]
    160 
    161    def create(self, state):
    162        state.base_commit = state.sync_tree.get_remote_sha1(
    163            state.sync["remote_url"], state.sync["branch"]
    164        )
    165        self.logger.debug("New base commit is %s" % state.base_commit.sha1)
    166 
    167 
    168 class LoadCommits(Step):
    169    """Get a list of commits in the gecko tree that need to be upstreamed"""
    170 
    171    provides = ["source_commits", "has_backouts"]
    172 
    173    def create(self, state):
    174        state.source_commits = state.local_tree.log(
    175            state.last_sync_commit, state.tests_path
    176        )
    177 
    178        update_regexp = re.compile(
    179            r"Bug \d+ - Update web-platform-tests to revision [0-9a-f]{40}"
    180        )
    181 
    182        state.has_backouts = False
    183 
    184        for i, commit in enumerate(state.source_commits[:]):
    185            if update_regexp.match(commit.message.text):
    186                # This is a previous update commit so ignore it
    187                state.source_commits.remove(commit)
    188                continue
    189 
    190            elif commit.message.backouts:
    191                # TODO: Add support for collapsing backouts
    192                state.has_backouts = True
    193 
    194            elif not commit.message.bug:
    195                self.logger.error(
    196                    "Commit %i (%s) doesn't have an associated bug number."
    197                    % (i + 1, commit.sha1)
    198                )
    199                return exit_unclean
    200 
    201        self.logger.debug("Source commits: %s" % state.source_commits)
    202 
    203 
    204 class SelectCommits(Step):
    205    """Provide a UI to select which commits to upstream"""
    206 
    207    def create(self, state):
    208        while True:
    209            commits = state.source_commits[:]
    210            for i, commit in enumerate(commits):
    211                print(f"{i}:\t{commit.message.summary}")
    212 
    213            remove = input(
    214                "Provide a space-separated list of any commits numbers "
    215                "to remove from the list to upstream:\n"
    216            ).strip()
    217            remove_idx = set()
    218            for item in remove.split(" "):
    219                try:
    220                    item = int(item)
    221                except ValueError:
    222                    continue
    223                if item < 0 or item >= len(commits):
    224                    continue
    225                remove_idx.add(item)
    226 
    227            keep_commits = [
    228                (i, cmt) for i, cmt in enumerate(commits) if i not in remove_idx
    229            ]
    230            # TODO: consider printed removed commits
    231            print("Selected the following commits to keep:")
    232            for i, commit in keep_commits:
    233                print(f"{i}:\t{commit.message.summary}")
    234            confirm = input("Keep the above commits? y/n\n").strip().lower()
    235 
    236            if confirm == "y":
    237                state.source_commits = [item[1] for item in keep_commits]
    238                break
    239 
    240 
    241 class MovePatches(Step):
    242    """Convert gecko commits into patches against upstream and commit these to the sync tree."""
    243 
    244    provides = ["commits_loaded"]
    245 
    246    def create(self, state):
    247        if not hasattr(state, "commits_loaded"):
    248            state.commits_loaded = 0
    249 
    250        strip_path = os.path.relpath(state.tests_path, state.local_tree.root)
    251        self.logger.debug("Stripping patch %s" % strip_path)
    252 
    253        if not hasattr(state, "patch"):
    254            state.patch = None
    255 
    256        for commit in state.source_commits[state.commits_loaded :]:
    257            i = state.commits_loaded + 1
    258            self.logger.info("Moving commit %i: %s" % (i, commit.message.full_summary))
    259            stripped_patch = None
    260            if state.patch:
    261                filename, stripped_patch = state.patch
    262                if not os.path.exists(filename):
    263                    stripped_patch = None
    264                else:
    265                    with open(filename) as f:
    266                        stripped_patch.diff = f.read()
    267            state.patch = None
    268            if not stripped_patch:
    269                patch = commit.export_patch(state.tests_path)
    270                stripped_patch = rewrite_patch(patch, strip_path)
    271            if not stripped_patch.diff:
    272                self.logger.info("Skipping empty patch")
    273                state.commits_loaded = i
    274                continue
    275            try:
    276                state.sync_tree.import_patch(stripped_patch)
    277            except Exception:
    278                with tempfile.NamedTemporaryFile(delete=False, suffix=".diff") as f:
    279                    f.write(stripped_patch.diff)
    280                    print(
    281                        f"""Patch failed to apply. Diff saved in {f.name}
    282 Fix this file so it applies and run with --continue"""
    283                    )
    284                    state.patch = (f.name, stripped_patch)
    285                    print(state.patch)
    286                sys.exit(1)
    287            state.commits_loaded = i
    288        input("Check for differences with upstream")
    289 
    290 
    291 class RebaseCommits(Step):
    292    """Rebase commits from the current branch on top of the upstream destination branch.
    293 
    294    This step is particularly likely to fail if the rebase generates merge conflicts.
    295    In that case the conflicts can be fixed up locally and the sync process restarted
    296    with --continue.
    297    """
    298 
    299    def create(self, state):
    300        self.logger.info("Rebasing local commits")
    301        continue_rebase = False
    302        # Check if there's a rebase in progress
    303        if os.path.exists(
    304            os.path.join(state.sync_tree.root, ".git", "rebase-merge")
    305        ) or os.path.exists(os.path.join(state.sync_tree.root, ".git", "rebase-apply")):
    306            continue_rebase = True
    307 
    308        try:
    309            state.sync_tree.rebase(state.base_commit, continue_rebase=continue_rebase)
    310        except subprocess.CalledProcessError:
    311            self.logger.info(
    312                "Rebase failed, fix merge and run %s again with --continue"
    313                % sys.argv[0]
    314            )
    315            raise
    316        self.logger.info("Rebase successful")
    317 
    318 
    319 class CheckRebase(Step):
    320    """Check if there are any commits remaining after rebase"""
    321 
    322    provides = ["rebased_commits"]
    323 
    324    def create(self, state):
    325        state.rebased_commits = state.sync_tree.log(state.base_commit)
    326        if not state.rebased_commits:
    327            self.logger.info("Nothing to upstream, exiting")
    328            return exit_clean
    329 
    330 
    331 class MergeUpstream(Step):
    332    """Run steps to push local commits as seperate PRs and merge upstream."""
    333 
    334    provides = ["merge_index", "gh_repo"]
    335 
    336    def create(self, state):
    337        gh = GitHub(state.token)
    338        if "merge_index" not in state:
    339            state.merge_index = 0
    340 
    341        org, name = urlparse.urlsplit(state.sync["remote_url"]).path[1:].split("/")
    342        if name.endswith(".git"):
    343            name = name[:-4]
    344        state.gh_repo = gh.repo(org, name)
    345        for commit in state.rebased_commits[state.merge_index :]:
    346            with state.push(["gh_repo", "sync_tree"]):
    347                state.commit = commit
    348                pr_merger = PRMergeRunner(self.logger, state)
    349                rv = pr_merger.run()
    350                if rv is not None:
    351                    return rv
    352            state.merge_index += 1
    353 
    354 
    355 class UpdateLastSyncData(Step):
    356    """Update the gecko commit at which we last performed a sync with upstream."""
    357 
    358    provides = []
    359 
    360    def create(self, state):
    361        self.logger.info("Updating last sync commit")
    362        data = {
    363            "local": state.local_tree.rev_to_hg(state.local_tree.rev),
    364            "upstream": state.sync_tree.rev,
    365        }
    366        with open(state.sync_data_path, "w") as f:
    367            for key, value in data.iteritems():
    368                f.write("%s: %s\n" % (key, value))
    369        # This gets added to the patch later on
    370 
    371 
    372 class MergeLocalBranch(Step):
    373    """Create a local branch pointing at the commit to upstream"""
    374 
    375    provides = ["local_branch"]
    376 
    377    def create(self, state):
    378        branch_prefix = "sync_%s" % state.commit.sha1
    379        local_branch = state.sync_tree.unique_branch_name(branch_prefix)
    380 
    381        state.sync_tree.create_branch(local_branch, state.commit)
    382        state.local_branch = local_branch
    383 
    384 
    385 class MergeRemoteBranch(Step):
    386    """Get an unused remote branch name to use for the PR"""
    387 
    388    provides = ["remote_branch"]
    389 
    390    def create(self, state):
    391        remote_branch = "sync_%s" % state.commit.sha1
    392        branches = [
    393            ref[len("refs/heads/") :]
    394            for sha1, ref in state.sync_tree.list_remote(state.gh_repo.url)
    395            if ref.startswith("refs/heads")
    396        ]
    397        state.remote_branch = get_unique_name(branches, remote_branch)
    398 
    399 
    400 class PushUpstream(Step):
    401    """Push local branch to remote"""
    402 
    403    def create(self, state):
    404        self.logger.info("Pushing commit upstream")
    405        state.sync_tree.push(state.gh_repo.url, state.local_branch, state.remote_branch)
    406 
    407 
    408 class CreatePR(Step):
    409    """Create a PR for the remote branch"""
    410 
    411    provides = ["pr"]
    412 
    413    def create(self, state):
    414        self.logger.info("Creating a PR")
    415        commit = state.commit
    416        state.pr = state.gh_repo.create_pr(
    417            commit.message.full_summary,
    418            state.remote_branch,
    419            "master",
    420            commit.message.body if commit.message.body else "",
    421        )
    422 
    423 
    424 class PRAddComment(Step):
    425    """Add an issue comment indicating that the code has been reviewed already"""
    426 
    427    def create(self, state):
    428        state.pr.issue.add_comment("Code reviewed upstream.")
    429 
    430 
    431 class MergePR(Step):
    432    """Merge the PR"""
    433 
    434    def create(self, state):
    435        self.logger.info("Merging PR")
    436        state.pr.merge()
    437 
    438 
    439 class PRDeleteBranch(Step):
    440    """Delete the remote branch"""
    441 
    442    def create(self, state):
    443        self.logger.info("Deleting remote branch")
    444        state.sync_tree.push(state.gh_repo.url, "", state.remote_branch)
    445 
    446 
    447 class SyncToUpstreamRunner(StepRunner):
    448    """Runner for syncing local changes to upstream"""
    449 
    450    steps = [
    451        GetLastSyncData,
    452        CheckoutBranch,
    453        GetBaseCommit,
    454        LoadCommits,
    455        SelectCommits,
    456        MovePatches,
    457        RebaseCommits,
    458        CheckRebase,
    459        MergeUpstream,
    460        UpdateLastSyncData,
    461    ]
    462 
    463 
    464 class PRMergeRunner(StepRunner):
    465    """(Sub)Runner for creating and merging a PR"""
    466 
    467    steps = [
    468        MergeLocalBranch,
    469        MergeRemoteBranch,
    470        PushUpstream,
    471        CreatePR,
    472        PRAddComment,
    473        MergePR,
    474        PRDeleteBranch,
    475    ]