-
Notifications
You must be signed in to change notification settings - Fork 135
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
922e89f
commit 8e5a455
Showing
4 changed files
with
291 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
requests==2.31.0 | ||
github3.py==4.0.1 | ||
PyGithub==1.58.2 |