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 ]