Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[machine-image] add backport automation #541

Merged
merged 1 commit into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions .github/scripts/auto-backport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#!/usr/bin/env python3

import argparse
import os
import re
import tempfile
import logging

from github import Github, GithubException
from git import Repo, GitCommandError

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')


def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument('--repo', type=str, required=True, help='Github repository name')
parser.add_argument('--base-branch', type=str, default='refs/heads/next', help='Base branch')
parser.add_argument('--commits', default=None, type=str, help='Range of promoted commits.')
parser.add_argument('--pull-request', type=int, help='Pull request number to be backported')
return parser.parse_args()


def create_pull_request(repo, new_branch_name, base_branch_name, pr, backport_pr_title, commits, is_draft=False):
pr_body = f'{pr.body}\n\n'
for commit in commits:
pr_body += f'- (cherry picked from commit {commit})\n\n'
pr_body += f'Parent PR: #{pr.number}'
if is_draft:
new_branch_name = f'{pr.user.login}:{new_branch_name}'
try:
backport_pr = repo.create_pull(
title=backport_pr_title,
body=pr_body,
head=new_branch_name,
base=base_branch_name,
draft=is_draft
)
logging.info(f"Pull request created: {backport_pr.html_url}")
backport_pr.add_to_assignees(pr.user)
logging.info(f"Assigned PR to original author: {pr.user}")
return backport_pr
except GithubException as e:
if 'A pull request already exists' in str(e):
logging.warning(f'A pull request already exists for {pr.user}:{new_branch_name}')
else:
logging.error(f'Failed to create PR: {e}')


def get_pr_commits(repo, pr, stable_branch, start_commit=None):
commits = []
if pr.merged:
merge_commit = repo.get_commit(pr.merge_commit_sha)
if len(merge_commit.parents) > 1: # Check if this merge commit include multiple commits
commits.append(pr.merge_commit_sha)
else:
if start_commit:
promoted_commits = repo.compare(start_commit, stable_branch).commits
else:
promoted_commits = repo.get_commits(sha=stable_branch)
for commit in pr.get_commits():
for promoted_commit in promoted_commits:
commit_title = commit.commit.message.splitlines()[0]
# In Scylla-pkg and scylla-dtest for example, we don't create a merge commit for a PR with multiple commits,
# according to the GitHub API, the last commit will be the merge commit which is not what we need when backporting (we need all the commits).
# So here, we are validating the correct SHA for each commit so we can cherry-pick
if promoted_commit.commit.message.startswith(commit_title):
commits.append(promoted_commit.sha)

elif pr.state == 'closed':
events = pr.get_issue_events()
for event in events:
if event.event == 'closed':
commits.append(event.commit_id)
return commits


def backport(repo, pr, version, commits, backport_base_branch):
with (tempfile.TemporaryDirectory() as local_repo_path):
try:
new_branch_name = f'backport/{pr.number}/to-{version}'
backport_pr_title = f'[Backport {version}] {pr.title}'
repo_local = Repo.clone_from(f'https://github.com/{repo.full_name}.git', local_repo_path, branch=backport_base_branch)
repo_local.git.checkout(b=new_branch_name)
fork_repo = pr.user.get_repo(repo.full_name.split('/')[1])
repo_local.create_remote('fork', fork_repo.clone_url)
remote = 'origin'
is_draft = False
for commit in commits:
try:
repo_local.git.cherry_pick(commit, '-m1', '-x')
except GitCommandError as e:
logging.warning(f'Cherry-pick conflict on commit {commit}: {e}')
remote = 'fork'
is_draft = True
repo_local.git.add(A=True)
repo_local.git.cherry_pick('--continue')
repo_local.git.push(remote, new_branch_name, force=True)
create_pull_request(repo, new_branch_name, backport_base_branch, pr, backport_pr_title, commits,
is_draft=is_draft)
except GitCommandError as e:
logging.warning(f"GitCommandError: {e}")


def get_prs_from_commits(repo, commits):
for sha1 in commits:
commit = repo.get_commit(sha1)
for parent in commit.parents:
prs = repo.get_pulls(state="closed", head=parent.sha)
if prs:
yield prs[0]
break


def main():
args = parse_args()
base_branch = args.base_branch.split('/')[2]
promoted_label = 'promoted-to-master'
repo_name = args.repo
if args.repo in ('scylladb/scylla', 'scylladb/scylla-enterprise'):
stable_branch = base_branch
backport_branch = 'branch-'
if args.repo == 'scylladb/scylla-enterprise':
promoted_label = 'promoted-to-enterprise'
else:
backport_branch = f'{base_branch}-'
if base_branch == 'next':
stable_branch = 'master'
else:
stable_branch = base_branch.replace('next', 'branch')

github_token = os.getenv("BACKPORT_GITHUB_TOKEN")
backport_label_pattern = re.compile(r'backport/\d+\.\d+$')

g = Github(github_token)
repo = g.get_repo(repo_name)
closed_prs = []
start_commit = None

if args.commits:
start_commit, end_commit = args.commits.split('..')
commits = repo.compare(start_commit, end_commit).commits
prs = get_prs_from_commits(repo, commits)
closed_prs = list(prs)
if args.pull_request:
start_commit = args.commits
pr = repo.get_pull(args.pull_request)
closed_prs = [pr]

for pr in closed_prs:
labels = [label.name for label in pr.labels]
backport_labels = [label for label in labels if backport_label_pattern.match(label)]
if promoted_label not in labels:
continue
if not backport_labels:
continue
commits = get_pr_commits(repo, pr, stable_branch, start_commit)
logging.info(f"Found PR #{pr.number} with commit {commits} and the following labels: {backport_labels}")
for backport_label in backport_labels:
version = backport_label.replace('backport/', '')
backport_base_branch = backport_label.replace('backport/', backport_branch)
backport(repo, pr, version, commits, backport_base_branch)


if __name__ == "__main__":
main()
79 changes: 79 additions & 0 deletions .github/scripts/search_commits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env python3

import re
import requests
from github import Github
import argparse
import sys
import os

try:
github_token = os.environ["GITHUB_TOKEN"]
except KeyError:
print("Please set the 'GITHUB_TOKEN' environment variable")
sys.exit(1)


def parser():
parser = argparse.ArgumentParser()
parser.add_argument('--repository', type=str, default='scylladb/scylla-pkg', help='Github repository name')
parser.add_argument('--commits', type=str, required=True, help='Range of promoted commits.')
parser.add_argument('--label', type=str, default='promoted-to-master', help='Label to use')
parser.add_argument('--ref', type=str, required=True, help='PR target branch')
return parser.parse_args()


def main():
args = parser()
g = Github(github_token)
repo = g.get_repo(args.repository, lazy=False)
start_commit, end_commit = args.commits.split('..')
commits = repo.compare(start_commit, end_commit).commits
processed_prs = set()
for commit in commits:
search_url = f'https://api.github.com/search/issues'
query = f"repo:{args.repository} is:pr is:merged sha:{commit.sha}"
params = {
"q": query,
}
headers = {
"Authorization": f"token {github_token}",
"Accept": "application/vnd.github.v3+json"
}
response = requests.get(search_url, headers=headers, params=params)
prs = response.json().get("items", [])
for pr in prs:
match = re.findall(r'Parent PR: #(\d+)', pr["body"])
if match:
pr_number = int(match[0])
if pr_number in processed_prs:
continue
ref = re.search(r'-(\d+\.\d+)', args.ref)
label_to_add = f'backport/{ref.group(1)}-done'
label_to_remove = f'backport/{ref.group(1)}'
remove_label_url = f'https://api.github.com/repos/{args.repository}/issues/{pr_number}/labels/{label_to_remove}'
del_data = {
"labels": [f'{label_to_remove}']
}
response = requests.delete(remove_label_url, headers=headers, json=del_data)
if response.ok:
print(f'Label {label_to_remove} removed successfully')
else:
print(f'Label {label_to_remove} cant be removed')
else:
pr_number = pr["number"]
label_to_add = args.label
data = {
"labels": [f'{label_to_add}']
}
add_label_url = f'https://api.github.com/repos/{args.repository}/issues/{pr_number}/labels'
response = requests.post(add_label_url, headers=headers, json=data)
if response.ok:
print(f"Label added successfully to {add_label_url}")
else:
print(f"No label was added to {add_label_url}")
processed_prs.add(pr_number)


if __name__ == "__main__":
main()
49 changes: 49 additions & 0 deletions .github/workflows/add-label-when-promoted.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Check if commits are promoted

on:
push:
branches:
- master
- next-*.*
pull_request_target:
types: [labeled]
branches: [master, next]

env:
DEFAULT_BRANCH: ${{ github.repository == 'scylladb/scylla-enterprise' && 'enterprise' || 'master' }}

jobs:
check-commit:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Dump GitHub context
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- name: Checkout repository
uses: actions/checkout@v4
with:
repository: ${{ github.repository }}
ref: ${{ env.DEFAULT_BRANCH }}
token: ${{ secrets.AUTO_BACKPORT_TOKEN }}
fetch-depth: 0 # Fetch all history for all tags and branches
- name: Install dependencies
run: sudo apt-get install -y python3-github python3-git
- name: Run python script
if: github.event_name == 'push'
env:
GITHUB_TOKEN: ${{ secrets.AUTO_BACKPORT_TOKEN }}
run: python .github/scripts/search_commits.py --commits ${{ github.event.before }}..${{ github.sha }} --repository ${{ github.repository }} --ref ${{ github.ref }}
- name: Run auto-backport.py whe promotion completed
if: github.event_name == 'push'
env:
GITHUB_TOKEN: ${{ secrets.AUTO_BACKPORT_TOKEN }}
run: python .github/scripts/auto-backport.py --repo ${{ github.repository }} --base-branch ${{ github.ref }} --commits ${{ github.event.before }}..${{ github.sha }}
- name: Run auto-backport.py when label was added
if: github.event_name == 'pull_request_target' && startsWith(github.event.label.name, 'backport/') && (github.event.pull_request.state == 'closed' && github.event.pull_request.merged == true)
env:
GITHUB_TOKEN: ${{ secrets.AUTO_BACKPORT_TOKEN }}
run: python .github/scripts/auto-backport.py --repo ${{ github.repository }} --base-branch ${{ github.ref }} --pull-request ${{ github.event.pull_request.number }} --commits ${{ github.sha }}
Loading