Added the cherry-pick automation (#1728)

The process of the cherry-pick automation is as follows:
1) index.py is triggered with the inputs by action.yml
2) Check if the PR/issue is closed
3) Check if the commit id exists and if it was done by
"copybara-service[bot]". If yes, then retrieve the commit id
4) Check if approved by reviewers and get the GitHub ID's of the
reviewers
5) Get labels
6) Perform cherry-pick
7) Create a PR
8) Issue a comment in the milestoned issue whether or not the
cherry-pick was performed
diff --git a/actions/cherry-picker/action.yml b/actions/cherry-picker/action.yml
new file mode 100644
index 0000000..c947be9
--- /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 0000000..000143f
--- /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 0000000..c8735a5
--- /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 0000000..7457217
--- /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