Skip to content

Commit 7676609

Browse files
committed
wip
1 parent 99d539b commit 7676609

File tree

3 files changed

+226
-39
lines changed

3 files changed

+226
-39
lines changed

.github/scripts/auto-backport.py

+166
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import logging
5+
import os
6+
import re
7+
import sys
8+
import tempfile
9+
10+
from git import GitCommandError, Repo
11+
from github import Github, GithubException
12+
13+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
14+
try:
15+
github_token = os.environ["GITHUB_TOKEN"]
16+
except KeyError:
17+
print("Please set the 'GITHUB_TOKEN' environment variable")
18+
sys.exit(1)
19+
20+
21+
def is_pull_request():
22+
return "--pull-request" in sys.argv[1:]
23+
24+
25+
def parse_args():
26+
parser = argparse.ArgumentParser()
27+
parser.add_argument("--repo", type=str, required=True, help="Github repository name")
28+
parser.add_argument("--base-branch", type=str, default="refs/heads/master", help="Base branch")
29+
parser.add_argument("--commits", default=None, type=str, help="Range of promoted commits.")
30+
parser.add_argument("--pull-request", type=int, help="Pull request number to be backported")
31+
parser.add_argument("--head-commit", type=str, required=is_pull_request(), help="The HEAD of target branch after the pull request specified by --pull-request is merged")
32+
return parser.parse_args()
33+
34+
35+
def create_pull_request(repo, new_branch_name, base_branch_name, pr, backport_pr_title, commits, is_draft=False): # noqa: PLR0913
36+
enterprise_release_pattern = re.compile("branch-\d{4}.\d+$")
37+
pr_body = f"{pr.body}\n\n"
38+
for commit in commits:
39+
pr_body += f"- (cherry picked from commit {commit})\n\n"
40+
pr_body += f"Parent PR: #{pr.number}"
41+
if is_draft:
42+
new_branch_name = f"{pr.user.login}:{new_branch_name}"
43+
try:
44+
backport_pr = repo.create_pull(title=backport_pr_title, body=pr_body, head=new_branch_name, base=base_branch_name, draft=is_draft)
45+
logging.info(f"Pull request created: {backport_pr.html_url}")
46+
backport_pr.add_to_assignees(pr.user)
47+
if is_draft:
48+
backport_pr.add_to_labels("conflicts")
49+
if enterprise_release_pattern.match(base_branch_name):
50+
backport_pr.add_to_labels("enterprise")
51+
logging.info(f"Assigned PR to original author: {pr.user}")
52+
return backport_pr
53+
except GithubException as e:
54+
if "A pull request already exists" in str(e):
55+
logging.warning(f"A pull request already exists for {pr.user}:{new_branch_name}")
56+
else:
57+
logging.error(f"Failed to create PR: {e}")
58+
59+
60+
def get_pr_commits(repo, pr, stable_branch, start_commit=None):
61+
commits = []
62+
if pr.merged:
63+
merge_commit = repo.get_commit(pr.merge_commit_sha)
64+
if len(merge_commit.parents) > 1: # Check if this merge commit includes multiple commits
65+
commits.append(pr.merge_commit_sha)
66+
else:
67+
if start_commit:
68+
promoted_commits = repo.compare(start_commit, stable_branch).commits
69+
else:
70+
promoted_commits = repo.get_commits(sha=stable_branch)
71+
for commit in pr.get_commits():
72+
for promoted_commit in promoted_commits:
73+
commit_title = commit.commit.message.splitlines()[0]
74+
# In Scylla-pkg and scylla-dtest, for example,
75+
# we don't create a merge commit for a PR with multiple commits,
76+
# according to the GitHub API, the last commit will be the merge commit,
77+
# which is not what we need when backporting (we need all the commits).
78+
# So here, we are validating the correct SHA for each commit so we can cherry-pick
79+
if promoted_commit.commit.message.startswith(commit_title):
80+
commits.append(promoted_commit.sha)
81+
82+
elif pr.state == "closed":
83+
events = pr.get_issue_events()
84+
for event in events:
85+
if event.event == "closed":
86+
commits.append(event.commit_id)
87+
return commits
88+
89+
90+
def backport(repo, pr, version, commits, backport_base_branch, user): # noqa: PLR0913
91+
with tempfile.TemporaryDirectory() as local_repo_path:
92+
try:
93+
new_branch_name = f"backport/{pr.number}/to-{version}"
94+
backport_pr_title = f"[Backport {version}] {pr.title}"
95+
repo_local = Repo.clone_from(f"https://{user.login}:{github_token}@github.com/{repo.full_name}.git", local_repo_path, branch=backport_base_branch)
96+
repo_local.git.checkout(b=new_branch_name)
97+
fork_repo = pr.user.get_repo(repo.full_name.split("/")[1])
98+
fork_repo_url = f"https://{user.login}:{github_token}@github.com/{fork_repo.full_name}.git"
99+
repo_local.create_remote("fork", fork_repo_url)
100+
remote = "origin"
101+
is_draft = False
102+
for commit in commits:
103+
try:
104+
repo_local.git.cherry_pick(commit, "-m1", "-x")
105+
except GitCommandError as e:
106+
logging.warning(f"Cherry-pick conflict on commit {commit}: {e}")
107+
remote = "fork"
108+
is_draft = True
109+
repo_local.git.add(A=True)
110+
repo_local.git.cherry_pick("--continue")
111+
repo_local.git.push(remote, new_branch_name, force=True)
112+
create_pull_request(repo, new_branch_name, backport_base_branch, pr, backport_pr_title, commits, is_draft=is_draft)
113+
except GitCommandError as e:
114+
logging.warning(f"GitCommandError: {e}")
115+
116+
117+
def main():
118+
args = parse_args()
119+
base_branch = args.base_branch.split("/")[2]
120+
promoted_label = "promoted-to-master"
121+
repo_name = args.repo
122+
backport_branch = "branch-"
123+
stable_branch = base_branch
124+
125+
backport_label_pattern = re.compile(r"backport/\d+\.\d+$")
126+
backport_manager_label_pattern = re.compile(r"backport/manager-\d+\.\d+$")
127+
backport_perf_label_pattern = re.compile(r"backport/")
128+
129+
g = Github(github_token)
130+
repo = g.get_repo(repo_name)
131+
user = g.get_user()
132+
closed_prs = []
133+
start_commit = None
134+
135+
if args.commits:
136+
start_commit, end_commit = args.commits.split("..")
137+
commits = repo.compare(start_commit, end_commit).commits
138+
for commit in commits:
139+
for pr in commit.get_pulls():
140+
closed_prs.append(pr)
141+
if args.pull_request:
142+
start_commit = args.head_commit
143+
pr = repo.get_pull(args.pull_request)
144+
closed_prs = [pr]
145+
146+
for pr in closed_prs:
147+
labels = [label.name for label in pr.labels]
148+
backport_labels = [label for label in labels if backport_label_pattern.match(label) or backport_manager_label_pattern.match(label)]
149+
if promoted_label not in labels:
150+
print(f"no {promoted_label} label: {pr.number}")
151+
continue
152+
if not backport_labels:
153+
print(f"no backport label: {pr.number}")
154+
continue
155+
commits = get_pr_commits(repo, pr, stable_branch, start_commit)
156+
logging.info(f"Found PR #{pr.number} with commit {commits} and the following labels: {backport_labels}")
157+
for backport_label in backport_labels:
158+
version = backport_label.replace("backport/", "")
159+
backport_base_branch = backport_label.replace("backport/", backport_branch)
160+
if "manager" in version:
161+
backport_base_branch = version
162+
backport(repo, pr, version, commits, backport_base_branch, user)
163+
164+
165+
if __name__ == "__main__":
166+
main()

.github/search_commits.py

+28-37
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
#!/usr/bin/env python3
22

3-
import sys
4-
import os
53
import argparse
4+
import os
65
import re
7-
import requests
6+
import sys
87

8+
import requests
99
from github import Github
1010

1111
try:
@@ -15,61 +15,52 @@
1515
sys.exit(1)
1616

1717

18-
def get_parser():
18+
def parser():
1919
parser = argparse.ArgumentParser()
20-
parser.add_argument('--repository', type=str, default='scylladb/scylla-pkg', help='Github repository name')
21-
parser.add_argument('--commit_before_merge', type=str, required=True,
22-
help='Git commit ID to start labeling from newest commit.')
23-
parser.add_argument('--commit_after_merge', type=str, required=True,
24-
help='Git commit ID to end labeling at (oldest commit, exclusive).')
25-
parser.add_argument('--label', type=str, default='promoted-to-master', help='Label to use')
26-
parser.add_argument('--ref', type=str, required=True, help='PR target branch')
20+
parser.add_argument("--repository", type=str, default="scylladb/scylla-pkg", help="Github repository name")
21+
parser.add_argument("--commits", type=str, required=True, help="Range of promoted commits.")
22+
parser.add_argument("--label", type=str, default="promoted-to-master", help="Label to use")
23+
parser.add_argument("--ref", type=str, required=True, help="PR target branch")
2724
return parser.parse_args()
2825

2926

30-
def main(): # pylint: disable=too-many-locals # noqa: PLR0914
31-
args = get_parser()
32-
github = Github(github_token)
33-
repo = github.get_repo(args.repository, lazy=False)
34-
commits = repo.compare(head=args.commit_after_merge, base=args.commit_before_merge)
27+
def main():
28+
args = parser()
29+
g = Github(github_token)
30+
repo = g.get_repo(args.repository, lazy=False)
31+
start_commit, end_commit = args.commits.split("..")
32+
commits = repo.compare(start_commit, end_commit).commits
3533
processed_prs = set()
36-
for commit in commits.commits:
37-
search_url = 'https://api.github.com/search/issues'
34+
for commit in commits:
35+
search_url = f"https://api.github.com/search/issues"
3836
query = f"repo:{args.repository} is:pr is:merged sha:{commit.sha}"
3937
params = {
4038
"q": query,
4139
}
42-
headers = {
43-
"Authorization": f"token {github_token}",
44-
"Accept": "application/vnd.github.v3+json"
45-
}
40+
headers = {"Authorization": f"token {github_token}", "Accept": "application/vnd.github.v3+json"}
4641
response = requests.get(search_url, headers=headers, params=params)
4742
prs = response.json().get("items", [])
48-
for pr in prs: # pylint: disable=invalid-name
49-
match = re.findall(r'Parent PR: #(\d+)', pr["body"])
43+
for pr in prs:
44+
match = re.findall(r"Parent PR: #(\d+)", pr["body"])
5045
if match:
5146
pr_number = int(match[0])
5247
if pr_number in processed_prs:
5348
continue
54-
ref = re.search(r'-(\d+\.\d+|perf-v(\d+))', args.ref)
55-
label_to_add = f'backport/{ref.group(1)}-done'
56-
label_to_remove = f'backport/{ref.group(1)}'
57-
remove_label_url = f'https://api.github.com/repos/{args.repository}/issues/{pr_number}/labels/{label_to_remove}'
58-
del_data = {
59-
"labels": [f'{label_to_remove}']
60-
}
49+
ref = re.search(r"-(\d+\.\d+)", args.ref)
50+
label_to_add = f"backport/{ref.group(1)}-done"
51+
label_to_remove = f"backport/{ref.group(1)}"
52+
remove_label_url = f"https://api.github.com/repos/{args.repository}/issues/{pr_number}/labels/{label_to_remove}"
53+
del_data = {"labels": [f"{label_to_remove}"]}
6154
response = requests.delete(remove_label_url, headers=headers, json=del_data)
6255
if response.ok:
63-
print(f'Label {label_to_remove} removed successfully')
56+
print(f"Label {label_to_remove} removed successfully")
6457
else:
65-
print(f'Label {label_to_remove} cant be removed')
58+
print(f"Label {label_to_remove} cant be removed")
6659
else:
6760
pr_number = pr["number"]
6861
label_to_add = args.label
69-
data = {
70-
"labels": [f'{label_to_add}']
71-
}
72-
add_label_url = f'https://api.github.com/repos/{args.repository}/issues/{pr_number}/labels'
62+
data = {"labels": [f"{label_to_add}"]}
63+
add_label_url = f"https://api.github.com/repos/{args.repository}/issues/{pr_number}/labels"
7364
response = requests.post(add_label_url, headers=headers, json=data)
7465
if response.ok:
7566
print(f"Label added successfully to {add_label_url}")

.github/workflows/add-label-when-promoted.yaml

+32-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ on:
77
- branch-*.*
88
- branch-perf-v*
99
- manager-*.*
10+
pull_request_target:
11+
types: [ labeled ]
12+
branches: [ master, next ]
1013

1114
env:
1215
DEFAULT_BRANCH: 'master'
@@ -29,9 +32,36 @@ jobs:
2932
ref: ${{ env.DEFAULT_BRANCH }}
3033
token: ${{ secrets.AUTO_BACKPORT_TOKEN }}
3134
fetch-depth: 0 # Fetch all history for all tags and branches
35+
- name: Set up Git identity
36+
run: |
37+
git config --global user.name "GitHub Action"
38+
git config --global user.email "[email protected]"
39+
git config --global merge.conflictstyle diff3
3240
- name: Install dependencies
33-
run: sudo apt-get install -y python3-github
41+
run: sudo apt-get install -y python3-github python3-git
3442
- name: Run python script
43+
if: github.event_name == 'push'
3544
env:
3645
GITHUB_TOKEN: ${{ secrets.AUTO_BACKPORT_TOKEN }}
37-
run: python .github/search_commits.py --commit_before_merge ${{ github.event.before }} --commit_after_merge ${{ github.event.after }} --repository ${{ github.repository }} --ref ${{ github.ref }}
46+
run: python .github/scripts/search_commits.py --commits ${{ github.event.before }}..${{ github.sha }} --repository ${{ github.repository }} --ref ${{ github.ref }}
47+
- name: Run auto-backport.py when promotion completed
48+
if: github.event_name == 'push'
49+
env:
50+
GITHUB_TOKEN: ${{ secrets.AUTO_BACKPORT_TOKEN }}
51+
run: python .github/scripts/auto-backport.py --repo ${{ github.repository }} --base-branch ${{ github.ref }} --commits ${{ github.event.before }}..${{ github.sha }}
52+
- name: Check if label starts with 'backport/' and contains digits
53+
id: check_label
54+
run: |
55+
label_name="${{ github.event.label.name }}"
56+
if [[ "$label_name" =~ ^backport/[0-9]+\.[0-9]+$ ]]; then
57+
echo "Label matches backport/X.X pattern."
58+
echo "backport_label=true" >> $GITHUB_OUTPUT
59+
else
60+
echo "Label does not match the required pattern."
61+
echo "backport_label=false" >> $GITHUB_OUTPUT
62+
fi
63+
- name: Run auto-backport.py when label was added
64+
if: github.event_name == 'pull_request_target' && steps.check_label.outputs.backport_label == 'true' && (github.event.pull_request.state == 'closed' && github.event.pull_request.merged == true)
65+
env:
66+
GITHUB_TOKEN: ${{ secrets.AUTO_BACKPORT_TOKEN }}
67+
run: python .github/scripts/auto-backport.py --repo ${{ github.repository }} --base-branch ${{ github.ref }} --pull-request ${{ github.event.pull_request.number }} --head-commit ${{ github.event.pull_request.base.sha }}

0 commit comments

Comments
 (0)