diff --git a/actions/cherry-picker/action.yml b/actions/cherry-picker/action.yml new file mode 100644 index 0000000000..c947be9efa --- /dev/null +++ b/actions/cherry-picker/action.yml @@ -0,0 +1,42 @@ +name: 'Cherry-picker when comment is created or issue/pr is closed' +description: 'Cherry-picks the commit' +inputs: + triggered-on: + required: true + default: ${{ github.triggered-on }} + pr-number: + required: true + default: ${{ github.pr-number }} + milestone-title: + required: false + default: ${{ github.milestone-title }} + milestoned-issue-number: + required: false + default: ${{ github.milestoned-issue-number }} + is-prod: + required: true + default: ${{ github.is-prod }} +runs: + using: 'composite' + steps: + - name: Install Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Install Dependencies + run: | + pip install -r ${{ github.action_path }}/requirements.txt + shell: bash + - name: Pass Inputs to Shell + run: | + echo "INPUT_TRIGGERED_ON=${{ inputs.triggered-on }}" >> $GITHUB_ENV + echo "INPUT_PR_NUMBER=${{ inputs.pr-number }}" >> $GITHUB_ENV + echo "INPUT_MILESTONE_TITLE=${{ inputs.milestone-title }}" >> $GITHUB_ENV + echo "INPUT_MILESTONED_ISSUE_NUMBER=${{ inputs.milestoned-issue-number }}" >> $GITHUB_ENV + echo "INPUT_IS_PROD=${{ inputs.is-prod }}" >> $GITHUB_ENV + shell: bash + - name: Run python index.py + run: | + chmod +x ${{ github.action_path }}/index.py + python -u ${{ github.action_path }}/index.py + shell: bash diff --git a/actions/cherry-picker/functions.py b/actions/cherry-picker/functions.py new file mode 100644 index 0000000000..000143fd82 --- /dev/null +++ b/actions/cherry-picker/functions.py @@ -0,0 +1,171 @@ +import os, subprocess, requests +from pprint import pprint + +headers = { + 'X-GitHub-Api-Version': '2022-11-28' +} +token = os.environ["GH_TOKEN"] +upstream_url = "https://github.com/bazelbuild/bazel.git" +upstream_repo = upstream_url.replace("https://github.com/", "").replace(".git", "") + +def get_commit_id(pr_number, actor_name, action_event, api_repo_name): + params = {"per_page": 100} + response = requests.get(f'https://api.github.com/repos/{api_repo_name}/issues/{pr_number}/events', headers=headers, params=params) + commit_id = None + for event in response.json(): + if (event["actor"]["login"] in actor_name) and (event["commit_id"] != None) and (commit_id == None) and (event["event"] == action_event): + commit_id = event["commit_id"] + elif (event["actor"]["login"] in actor_name) and (event["commit_id"] != None) and (commit_id != None) and (event["event"] == action_event): + raise Exception(f'PR#{pr_number} has multiple commits made by {actor_name}') + if commit_id == None: raise Exception(f'PR#{pr_number} has NO commit made by {actor_name}') + return commit_id + +def get_reviewers(pr_number, api_repo_name, issues_data): + if "pull_request" not in issues_data: return [] + r = requests.get(f'https://api.github.com/repos/{api_repo_name}/pulls/{pr_number}/reviews', headers=headers) + if len(r.json()) == 0: raise Exception(f"PR#{pr_number} has no approver at all.") + approvers_list = [] + for review in r.json(): + if review["state"] == "APPROVED": approvers_list.append(review["user"]["login"]) + if len(approvers_list) == 0: raise Exception(f"PR#{pr_number} has no approval from the approver(s).") + return approvers_list + +def extract_release_numbers_data(pr_number, api_repo_name): + + def get_milestoned_issues(milestones, pr_number): + results= {} + for milestone in milestones: + params = { + "milestone": milestone["number"] + } + r = requests.get(f'https://api.github.com/repos/{api_repo_name}/issues', headers=headers, params=params) + for issue in r.json(): + if issue["body"] == f'Forked from #{pr_number}' and issue["state"] == "open": + results[milestone["title"]] = issue["number"] + break + return results + + response_milestones = requests.get(f'https://api.github.com/repos/{api_repo_name}/milestones', headers=headers) + all_milestones = list(map(lambda n: {"title": n["title"].split("release blockers")[0].replace(" ", ""), "number": n["number"]}, response_milestones.json())) + milestoned_issues = get_milestoned_issues(all_milestones, pr_number) + return milestoned_issues + +def issue_comment(issue_number, body_content, api_repo_name, is_prod): + if is_prod == True: + subprocess.run(['git', 'remote', 'add', 'upstream', upstream_url]) + subprocess.run(['gh', 'repo', 'set-default', upstream_repo]) + subprocess.run(['gh', 'issue', 'comment', str(issue_number), '--body', body_content]) + subprocess.run(['git', 'remote', 'rm', 'upstream']) + subprocess.run(['gh', 'repo', 'set-default', api_repo_name]) + else: + subprocess.run(['gh', 'issue', 'comment', str(issue_number), '--body', body_content]) + +def cherry_pick(commit_id, release_branch_name, target_branch_name, issue_number, is_first_time, input_data): + gh_cli_repo_name = f"{input_data['user_name']}/bazel" + gh_cli_repo_url = f"git@github.com:{gh_cli_repo_name}.git" + master_branch = input_data["master_branch"] + user_name = input_data["user_name"] + + def clone_and_sync_repo(): + print("Cloning and syncing the repo...") + subprocess.run(['gh', 'repo', 'sync', gh_cli_repo_name, "-b", master_branch]) + subprocess.run(['gh', 'repo', 'sync', gh_cli_repo_name, "-b", release_branch_name]) + subprocess.run(['git', 'clone', f"https://{user_name}:{token}@github.com/{gh_cli_repo_name}.git"]) + subprocess.run(['git', 'config', '--global', 'user.name', user_name]) + subprocess.run(['git', 'config', '--global', 'user.email', input_data["email"]]) + os.chdir("bazel") + subprocess.run(['git', 'remote', 'add', 'origin', gh_cli_repo_url]) + subprocess.run(['git', 'remote', '-v']) + + def checkout_release_number(): + subprocess.run(['git', 'fetch', '--all']) + status_checkout_release = subprocess.run(['git', 'checkout', release_branch_name]) + + # Create the new release branch from the upstream if not exists already. + if status_checkout_release.returncode != 0: + print(f"There is NO branch called {release_branch_name}...") + print(f"Creating the {release_branch_name} from upstream, {upstream_url}") + subprocess.run(['git', 'remote', 'add', 'upstream', upstream_url]) + subprocess.run(['git', 'remote', '-v']) + subprocess.run(['git', 'fetch', 'upstream']) + subprocess.run(['git', 'branch', release_branch_name, f"upstream/{release_branch_name}"]) + release_push_status = subprocess.run(['git', 'push', '--set-upstream', 'origin', release_branch_name]) + if release_push_status.returncode != 0: + raise Exception(f"Could not create and push the branch, {release_branch_name}") + subprocess.run(['git', 'remote', 'rm', 'upstream']) + subprocess.run(['git', 'checkout', release_branch_name]) + + status_checkout_target = subprocess.run(['git', 'checkout', '-b', target_branch_name]) + + # Need to let the user know that there is already a created branch with the same name and bazel-io needs to delete the branch + if status_checkout_target.returncode != 0: + raise Exception(f"Cherry-pick was being attempted. But, it failed due to already existent branch called {target_branch_name}\ncc: @bazelbuild/triage") + + def run_cherrypick(): + print(f"Cherry-picking the commit id {commit_id} in CP branch: {target_branch_name}") + if input_data["is_prod"] == True: + cherrypick_status = subprocess.run(['git', 'cherry-pick', commit_id]) + else: + cherrypick_status = subprocess.run(['git', 'cherry-pick', '-m', '1', commit_id]) + + if cherrypick_status.returncode == 0: + print(f"Successfully Cherry-picked, pushing it to branch: {target_branch_name}") + push_status = subprocess.run(['git', 'push', '--set-upstream', 'origin', target_branch_name]) + if push_status.returncode != 0: + raise Exception(f"Cherry-pick was attempted, but failed to push. Please check if the branch, {target_branch_name}, already exists\ncc: @bazelbuild/triage") + else: + raise Exception("Cherry-pick was attempted but there were merge conflicts. Please resolve manually.\ncc: @bazelbuild/triage") + + if is_first_time == True: + clone_and_sync_repo() + checkout_release_number() + run_cherrypick() + +def create_pr(reviewers, release_number, issue_number, labels, issue_data, release_branch_name, target_branch_name, user_name, api_repo_name, is_prod): + def send_pr_msg(issue_number, head_branch, release_branch): + params = { + "head": head_branch, + "base": release_branch, + "state": "open" + } + r = requests.get(f'https://api.github.com/repos/{upstream_repo}/pulls', headers=headers, params=params).json() + if len(r) == 1: + cherry_picked_pr_number = r[0]["number"] + issue_comment(issue_number, f"Cherry-picked in https://github.com/{upstream_repo}/pull/{cherry_picked_pr_number}", api_repo_name, is_prod) + else: + issue_comment(issue_number, "Failed to send PR msg \ncc: @bazelbuild/triage", api_repo_name, is_prod) + + head_branch = f"{user_name}:{target_branch_name}" + reviewers_str = ",".join(reviewers) + labels_str = ",".join(labels) + pr_title = f"[{release_number}] {issue_data['title']}" + pr_body = issue_data['body'] + status_create_pr = subprocess.run(['gh', 'pr', 'create', "--repo", upstream_repo, "--title", pr_title, "--body", pr_body, "--head", head_branch, "--base", release_branch_name, '--label', labels_str, '--reviewer', reviewers_str]) + if status_create_pr.returncode == 0: + send_pr_msg(issue_number, head_branch, release_branch_name) + else: + subprocess.run(['gh', 'issue', 'comment', str(issue_number), '--body', "PR failed to be created."]) + +def get_labels(pr_number, api_repo_name): + r = requests.get(f'https://api.github.com/repos/{api_repo_name}/issues/{pr_number}/labels', headers=headers) + labels_list = list(filter(lambda label: "area" in label or "team" in label, list(map(lambda x: x["name"], r.json())))) + if "awaiting-review" not in labels_list: labels_list.append("awaiting-review") + return labels_list + +def get_pr_title_body(commit_id, api_repo_name, issue_data): + data = {} + data["title"] = issue_data["title"] + response_commit = requests.get(f"https://api.github.com/repos/{api_repo_name}/commits/{commit_id}") + original_msg = response_commit.json()["commit"]["message"] + pr_body = original_msg[original_msg.index("\n\n") + 2:] if "\n\n" in original_msg else original_msg + commit_str_body = f"Commit https://github.com/{api_repo_name}/commit/{commit_id}" + + if "PiperOrigin-RevId" in pr_body: + piper_index = pr_body.index("PiperOrigin-RevId") + pr_body = pr_body[:piper_index] + f"{commit_str_body}\n\n" + pr_body[piper_index:] + else: + pr_body += f"\n\n{commit_str_body}" + + data["body"] = pr_body + return data + diff --git a/actions/cherry-picker/index.py b/actions/cherry-picker/index.py new file mode 100644 index 0000000000..c8735a5de5 --- /dev/null +++ b/actions/cherry-picker/index.py @@ -0,0 +1,75 @@ +import os, requests +from functions import get_commit_id, get_reviewers, extract_release_numbers_data, cherry_pick, create_pr, get_labels, get_pr_title_body, issue_comment + +triggered_on = os.environ["INPUT_TRIGGERED_ON"] +pr_number = os.environ["INPUT_PR_NUMBER"] if triggered_on == "closed" else os.environ["INPUT_PR_NUMBER"].split("#")[1] +milestone_title = os.environ["INPUT_MILESTONE_TITLE"] +milestoned_issue_number = os.environ["INPUT_MILESTONED_ISSUE_NUMBER"] +is_prod = os.environ["INPUT_IS_PROD"] + +if is_prod == "true": + input_data = { + "is_prod": True, + "api_repo_name": "bazelbuild/bazel", + "master_branch": "master", + "release_branch_name_initials": "release-", + "user_name": "bazel-io", + "action_event": "closed", + "actor_name": { + "copybara-service[bot]" + }, + "email": "bazel-io-bot@google.com" + } + +else: + input_data = { + "is_prod": False, + "api_repo_name": "iancha1992/bazel", + "master_branch": "release_test", + "release_branch_name_initials": "fake-release-", + "user_name": "iancha1992", + "action_event": "merged", + "actor_name": { + "iancha1992", + "Pavank1992", + "chaheein123", + }, + "email": "heec@google.com" + } + +issue_data = requests.get(f"https://api.github.com/repos/{input_data['api_repo_name']}/issues/{pr_number}", headers={'X-GitHub-Api-Version': '2022-11-28'}).json() + +# Check if the PR is closed. +if issue_data["state"] != "closed": raise ValueError(f'The PR #{pr_number} is not closed yet.') + +# Retrieve commit_id. If the PR/issue has no commit or has multiple commits, then raise an error. +commit_id = get_commit_id(pr_number, input_data["actor_name"], input_data["action_event"], input_data["api_repo_name"]) + +# Retrieve approvers(reviewers) of the PR +reviewers = get_reviewers(pr_number, input_data["api_repo_name"], issue_data) + +# Retrieve release_numbers +if triggered_on == "closed": + release_numbers_data = extract_release_numbers_data(pr_number, input_data["api_repo_name"]) +elif triggered_on == "commented": + release_numbers_data = {milestone_title.split(" release blockers")[0]: milestoned_issue_number} + +# Retrieve labels +labels = get_labels(pr_number, input_data["api_repo_name"]) + +# Retrieve issue/PR's title and body +pr_title_body = get_pr_title_body(commit_id, input_data["api_repo_name"], issue_data) + +# Perform cherry-pick and then create a pr if it's successful. +is_first_time = True +for k in release_numbers_data.keys(): + release_number = k + release_branch_name = f"{input_data['release_branch_name_initials']}{release_number}" + target_branch_name = f"cp{pr_number}-{release_number}" + issue_number = release_numbers_data[k] + try: + cherry_pick(commit_id, release_branch_name, target_branch_name, issue_number, is_first_time, input_data) + create_pr(reviewers, release_number, issue_number, labels, pr_title_body, release_branch_name, target_branch_name, input_data["user_name"], input_data["api_repo_name"], input_data["is_prod"]) + except Exception as e: + issue_comment(issue_number, str(e), input_data["api_repo_name"], input_data["is_prod"]) + is_first_time = False diff --git a/actions/cherry-picker/requirements.txt b/actions/cherry-picker/requirements.txt new file mode 100644 index 0000000000..7457217023 --- /dev/null +++ b/actions/cherry-picker/requirements.txt @@ -0,0 +1,3 @@ +requests==2.31.0 +github3.py==4.0.1 +PyGithub==1.58.2 \ No newline at end of file