# -*- coding: utf-8 -*- """ Backports changes from PR to release branch. Requires multiple separate runs as part of the implementation. First run should do the following: 1. Merge release branch with a first parent of merge-commit of PR (using 'ours' strategy). (branch: backport/{branch}/{pr}) 2. Create temporary branch over merge-commit to use it for PR creation. (branch: cherrypick/{merge_commit}) 3. Create PR from temporary branch to backport branch (emulating cherry-pick). Second run checks PR from previous run to be merged or at least being mergeable. If it's not merged then try to merge it. Third run creates PR from backport branch (with merged previous PR) to release branch. """ import argparse from enum import Enum import logging import os import subprocess import sys sys.path.append(os.path.dirname(__file__)) from query import Query as RemoteRepo class CherryPick: class Status(Enum): DISCARDED = "discarded" NOT_INITIATED = "not started" FIRST_MERGEABLE = "waiting for 1st stage" FIRST_CONFLICTS = "conflicts on 1st stage" SECOND_MERGEABLE = "waiting for 2nd stage" SECOND_CONFLICTS = "conflicts on 2nd stage" MERGED = "backported" def _run(self, args): out = subprocess.check_output(args).rstrip() logging.debug(out) return out def __init__(self, token, owner, name, team, pr_number, target_branch): self._gh = RemoteRepo(token, owner=owner, name=name, team=team) self._pr = self._gh.get_pull_request(pr_number) self.target_branch = target_branch self.ssh_url = self._gh.ssh_url # TODO: check if pull-request is merged. self.update_pr_branch(self._pr, self.target_branch) def update_pr_branch(self, pr_data, target_branch): """The method is here to avoid unnecessary creation of new objects""" self._pr = pr_data self.target_branch = target_branch self.merge_commit_oid = self._pr["mergeCommit"]["oid"] self.backport_branch = f"backport/{target_branch}/{pr_data['number']}" self.cherrypick_branch = f"cherrypick/{target_branch}/{self.merge_commit_oid}" def getCherryPickPullRequest(self): return self._gh.find_pull_request( base=self.backport_branch, head=self.cherrypick_branch ) def createCherryPickPullRequest(self, repo_path): DESCRIPTION = ( "This pull-request is a first step of an automated backporting.\n" "It contains changes like after calling a local command `git cherry-pick`.\n" "If you intend to continue backporting this changes, then resolve all conflicts if any.\n" "Otherwise, if you do not want to backport them, then just close this pull-request.\n" "\n" "The check results does not matter at this step - you can safely ignore them.\n" "Also this pull-request will be merged automatically as it reaches the mergeable state, but you always can merge it manually.\n" ) # FIXME: replace with something better than os.system() git_prefix = [ "git", "-C", repo_path, "-c", "user.email=robot-clickhouse@yandex-team.ru", "-c", "user.name=robot-clickhouse", ] base_commit_oid = self._pr["mergeCommit"]["parents"]["nodes"][0]["oid"] # Create separate branch for backporting, and make it look like real cherry-pick. self._run(git_prefix + ["checkout", "-f", self.target_branch]) self._run(git_prefix + ["checkout", "-B", self.backport_branch]) self._run(git_prefix + ["merge", "-s", "ours", "--no-edit", base_commit_oid]) # Create secondary branch to allow pull request with cherry-picked commit. self._run( git_prefix + ["branch", "-f", self.cherrypick_branch, self.merge_commit_oid] ) self._run( git_prefix + [ "push", "-f", "origin", "{branch}:{branch}".format(branch=self.backport_branch), ] ) self._run( git_prefix + [ "push", "-f", "origin", "{branch}:{branch}".format(branch=self.cherrypick_branch), ] ) # Create pull-request like a local cherry-pick title = self._pr["title"].replace('"', r"\"") pr = self._gh.create_pull_request( source=self.cherrypick_branch, target=self.backport_branch, title=( f'Cherry pick #{self._pr["number"]} ' f"to {self.target_branch}: " f"{title}" ), description=f'Original pull-request #{self._pr["number"]}\n\n{DESCRIPTION}', ) # FIXME: use `team` to leave a single eligible assignee. self._gh.add_assignee(pr, self._pr["author"]) self._gh.add_assignee(pr, self._pr["mergedBy"]) self._gh.set_label(pr, "do not test") self._gh.set_label(pr, "pr-cherrypick") return pr def mergeCherryPickPullRequest(self, cherrypick_pr): return self._gh.merge_pull_request(cherrypick_pr["id"]) def getBackportPullRequest(self): return self._gh.find_pull_request( base=self.target_branch, head=self.backport_branch ) def createBackportPullRequest(self, cherrypick_pr, repo_path): DESCRIPTION = ( "This pull-request is a last step of an automated backporting.\n" "Treat it as a standard pull-request: look at the checks and resolve conflicts.\n" "Merge it only if you intend to backport changes to the target branch, otherwise just close it.\n" ) git_prefix = [ "git", "-C", repo_path, "-c", "user.email=robot-clickhouse@clickhouse.com", "-c", "user.name=robot-clickhouse", ] title = (self._pr["title"].replace('"', r"\""),) pr_title = f"Backport #{self._pr['number']} to {self.target_branch}: {title}" self._run(git_prefix + ["checkout", "-f", self.backport_branch]) self._run(git_prefix + ["pull", "--ff-only", "origin", self.backport_branch]) self._run( git_prefix + [ "reset", "--soft", self._run( git_prefix + [ "merge-base", "origin/" + self.target_branch, self.backport_branch, ] ), ] ) self._run(git_prefix + ["commit", "-a", "--allow-empty", "-m", pr_title]) self._run( git_prefix + [ "push", "-f", "origin", "{branch}:{branch}".format(branch=self.backport_branch), ] ) pr = self._gh.create_pull_request( source=self.backport_branch, target=self.target_branch, title=pr_title, description=f"Original pull-request #{self._pr['number']}\n" f"Cherry-pick pull-request #{cherrypick_pr['number']}\n\n{DESCRIPTION}", ) # FIXME: use `team` to leave a single eligible assignee. self._gh.add_assignee(pr, self._pr["author"]) self._gh.add_assignee(pr, self._pr["mergedBy"]) self._gh.set_label(pr, "pr-backport") return pr def execute(self, repo_path, dry_run=False): pr1 = self.getCherryPickPullRequest() if not pr1: if not dry_run: pr1 = self.createCherryPickPullRequest(repo_path) logging.debug( "Created PR with cherry-pick of %s to %s: %s", self._pr["number"], self.target_branch, pr1["url"], ) else: return CherryPick.Status.NOT_INITIATED else: logging.debug( "Found PR with cherry-pick of %s to %s: %s", self._pr["number"], self.target_branch, pr1["url"], ) if not pr1["merged"] and pr1["mergeable"] == "MERGEABLE" and not pr1["closed"]: if not dry_run: pr1 = self.mergeCherryPickPullRequest(pr1) logging.debug( "Merged PR with cherry-pick of %s to %s: %s", self._pr["number"], self.target_branch, pr1["url"], ) if not pr1["merged"]: logging.debug( "Waiting for PR with cherry-pick of %s to %s: %s", self._pr["number"], self.target_branch, pr1["url"], ) if pr1["closed"]: return CherryPick.Status.DISCARDED elif pr1["mergeable"] == "CONFLICTING": return CherryPick.Status.FIRST_CONFLICTS else: return CherryPick.Status.FIRST_MERGEABLE pr2 = self.getBackportPullRequest() if not pr2: if not dry_run: pr2 = self.createBackportPullRequest(pr1, repo_path) logging.debug( "Created PR with backport of %s to %s: %s", self._pr["number"], self.target_branch, pr2["url"], ) else: return CherryPick.Status.FIRST_MERGEABLE else: logging.debug( "Found PR with backport of %s to %s: %s", self._pr["number"], self.target_branch, pr2["url"], ) if pr2["merged"]: return CherryPick.Status.MERGED elif pr2["closed"]: return CherryPick.Status.DISCARDED elif pr2["mergeable"] == "CONFLICTING": return CherryPick.Status.SECOND_CONFLICTS else: return CherryPick.Status.SECOND_MERGEABLE if __name__ == "__main__": logging.basicConfig(format="%(message)s", stream=sys.stdout, level=logging.DEBUG) parser = argparse.ArgumentParser() parser.add_argument( "--token", "-t", type=str, required=True, help="token for Github access" ) parser.add_argument("--pr", type=str, required=True, help="PR# to cherry-pick") parser.add_argument( "--branch", "-b", type=str, required=True, help="target branch name for cherry-pick", ) parser.add_argument( "--repo", "-r", type=str, required=True, help="path to full repository", metavar="PATH", ) args = parser.parse_args() cp = CherryPick( args.token, "ClickHouse", "ClickHouse", "core", args.pr, args.branch ) cp.execute(args.repo)