Skip to content

Commit 22092f7

Browse files
authored
Add a PR backporter (#203)
* Add a PR backporter which we need to backport changes to designated base branches when we merge PRs to target branches.
1 parent 5fcd5ed commit 22092f7

File tree

2 files changed

+277
-0
lines changed

2 files changed

+277
-0
lines changed

.github/workflows/pr_backporter.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Backport PR to another branch
2+
on:
3+
issue_comment:
4+
types: [created]
5+
6+
permissions:
7+
pull-requests: write
8+
contents: write
9+
10+
jobs:
11+
pr_commented:
12+
name: PR comment
13+
if: github.event.issue.pull_request
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions-ecosystem/action-regex-match@v2
17+
id: regex-match
18+
with:
19+
text: ${{ github.event.comment.body }}
20+
regex: '^@logstashmachine backport (main|[x0-9\.]+)$'
21+
- if: ${{ steps.regex-match.outputs.group1 == '' }}
22+
run: exit 1
23+
- name: Fetch logstash-core team member list
24+
uses: tspascoal/get-user-teams-membership@v1
25+
id: checkUserMember
26+
with:
27+
username: ${{ github.actor }}
28+
organization: elastic
29+
team: logstash
30+
GITHUB_TOKEN: ${{ secrets.READ_ORG_SECRET_JSVD }}
31+
- name: Is user not a core team member?
32+
if: ${{ steps.checkUserMember.outputs.isTeamMember == 'false' }}
33+
run: exit 1
34+
- name: checkout repo content
35+
uses: actions/checkout@v2
36+
with:
37+
fetch-depth: 0
38+
ref: 'main'
39+
- run: git config --global user.email "[email protected]"
40+
- run: git config --global user.name "logstashmachine"
41+
- name: setup python
42+
uses: actions/setup-python@v2
43+
with:
44+
python-version: 3.8
45+
- run: |
46+
mkdir ~/.elastic && echo ${{ github.token }} > ~/.elastic/github.token
47+
- run: pip install requests
48+
- name: run backport
49+
run: python devtools/backport ${{ steps.regex-match.outputs.group1 }} ${{ github.event.issue.number }} --remote=origin --yes

devtools/backport

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
#!/usr/bin/env python3
2+
"""Cherry pick and backport a PR"""
3+
from __future__ import print_function
4+
5+
from builtins import input
6+
import sys
7+
import os
8+
import argparse
9+
from os.path import expanduser
10+
import re
11+
from subprocess import check_call, call, check_output
12+
import requests
13+
14+
usage = """
15+
Example usage:
16+
./devtools/backport 7.16 2565 6490604aa0cf7fa61932a90700e6ca988fc8a527
17+
18+
In case of backporting errors, fix them, then run
19+
git cherry-pick --continue
20+
./devtools/backport 7.16 2565 6490604aa0cf7fa61932a90700e6ca988fc8a527 --continue
21+
22+
This script does the following:
23+
* cleanups both from_branch and to_branch (warning: drops local changes)
24+
* creates a temporary branch named something like "branch_2565"
25+
* calls the git cherry-pick command in this branch
26+
* after fixing the merge errors (if needed), pushes the branch to your
27+
remote
28+
* it will attempt to create a PR for you using the GitHub API, but requires
29+
the GitHub token, with the public_repo scope, available in `~/.elastic/github.token`.
30+
Keep in mind this token has to also be authorized to the Elastic organization as
31+
well as to work with SSO.
32+
(see https://help.github.com/en/articles/authorizing-a-personal-access-token-for-use-with-saml-single-sign-on)
33+
34+
Note that you need to take the commit hashes from `git log` on the
35+
from_branch, copying the IDs from Github doesn't work in case we squashed the
36+
PR.
37+
"""
38+
39+
40+
def main():
41+
"""Main"""
42+
parser = argparse.ArgumentParser(
43+
description="Creates a PR for cherry-picking commits",
44+
formatter_class=argparse.RawDescriptionHelpFormatter,
45+
epilog=usage)
46+
parser.add_argument("to_branch",
47+
help="To branch (e.g 7.x)")
48+
parser.add_argument("pr_number",
49+
help="The PR number being merged (e.g. 2345)")
50+
parser.add_argument("commit_hashes", metavar="hash", nargs="*",
51+
help="The commit hashes to cherry pick." +
52+
" You can specify multiple.")
53+
parser.add_argument("--yes", action="store_true",
54+
help="Assume yes. Warning: discards local changes.")
55+
parser.add_argument("--continue", action="store_true",
56+
help="Continue after fixing merging errors.")
57+
parser.add_argument("--from_branch", default="main",
58+
help="From branch")
59+
parser.add_argument("--diff", action="store_true",
60+
help="Display the diff before pushing the PR")
61+
parser.add_argument("--remote", default="",
62+
help="Which remote to push the backport branch to")
63+
args = parser.parse_args()
64+
65+
print(args)
66+
67+
create_pr(parser, args)
68+
69+
70+
def create_pr(parser, args):
71+
info("Checking if GitHub API token is available in `~/.elastic/github.token`")
72+
token = get_github_token()
73+
74+
tmp_branch = "backport_{}_{}".format(args.pr_number, args.to_branch)
75+
76+
if not vars(args)["continue"]:
77+
if not args.yes and input("This will destroy all local changes. " +
78+
"Continue? [y/n]: ") != "y":
79+
return 1
80+
info("Destroying local changes...")
81+
check_call("git reset --hard", shell=True)
82+
check_call("git clean -df", shell=True)
83+
check_call("git fetch", shell=True)
84+
85+
info("Checkout of {} to backport from....".format(args.from_branch))
86+
check_call("git checkout {}".format(args.from_branch), shell=True)
87+
check_call("git pull", shell=True)
88+
89+
info("Checkout of {} to backport to...".format(args.to_branch))
90+
check_call("git checkout {}".format(args.to_branch), shell=True)
91+
check_call("git pull", shell=True)
92+
93+
info("Creating backport branch {}...".format(tmp_branch))
94+
call("git branch -D {} > /dev/null".format(tmp_branch), shell=True)
95+
check_call("git checkout -b {}".format(tmp_branch), shell=True)
96+
97+
if len(args.commit_hashes) == 0:
98+
if token:
99+
session = github_session(token)
100+
base = "https://api.github.com/repos/elastic/logstash-filter-elastic_integration"
101+
original_pr = session.get(base + "/pulls/" + args.pr_number).json()
102+
merge_commit = original_pr['merge_commit_sha']
103+
if not merge_commit:
104+
info("Could not auto resolve merge commit - PR isn't merged yet")
105+
return 1
106+
info("Merge commit detected from PR: {}".format(merge_commit))
107+
commit_hashes = merge_commit
108+
else:
109+
info("GitHub API token not available. " +
110+
"Please manually specify commit hash(es) argument(s)\n")
111+
parser.print_help()
112+
return 1
113+
else:
114+
commit_hashes = "{}".format(" ").join(args.commit_hashes)
115+
116+
info("Cherry-picking {}".format(commit_hashes))
117+
if call("git cherry-pick -x {}".format(commit_hashes), shell=True) != 0:
118+
info("Looks like you have cherry-pick errors.")
119+
info("Fix them, then run: ")
120+
info(" git cherry-pick --continue")
121+
info(" {} --continue".format(" ".join(sys.argv)))
122+
return 1
123+
124+
if len(check_output("git status -s", shell=True).strip()) > 0:
125+
info("Looks like you have uncommitted changes." +
126+
" Please execute first: git cherry-pick --continue")
127+
return 1
128+
129+
if len(check_output("git log HEAD...{}".format(args.to_branch),
130+
shell=True).strip()) == 0:
131+
info("No commit to push")
132+
return 1
133+
134+
if args.diff:
135+
call("git diff {}".format(args.to_branch), shell=True)
136+
if input("Continue? [y/n]: ") != "y":
137+
info("Aborting cherry-pick.")
138+
return 1
139+
140+
info("Ready to push branch.")
141+
142+
remote = args.remote
143+
if not remote:
144+
remote = input("To which remote should I push? (your fork): ")
145+
146+
info("Pushing branch {} to remote {}".format(tmp_branch, remote))
147+
call("git push {} :{} > /dev/null".format(remote, tmp_branch), shell=True)
148+
check_call("git push --set-upstream {} {}".format(remote, tmp_branch), shell=True)
149+
150+
if not token:
151+
info("GitHub API token not available.\n" +
152+
"Manually create a PR by following this URL: \n\t" +
153+
"https://github.com/elastic/logstash-filter-elastic_integration/compare/{}...{}:{}?expand=1"
154+
.format(args.to_branch, remote, tmp_branch))
155+
else:
156+
info("Automatically creating a PR for you...")
157+
158+
session = github_session(token)
159+
base = "https://api.github.com/repos/elastic/logstash-filter-elastic_integration"
160+
original_pr = session.get(base + "/pulls/" + args.pr_number).json()
161+
162+
# get the github username from the remote where we pushed
163+
remote_url = check_output("git remote get-url {}".format(remote), shell=True)
164+
remote_user = re.search("github.com[:/](.+)/logstash", str(remote_url)).group(1)
165+
166+
# create PR
167+
request = session.post(base + "/pulls", json=dict(
168+
title="Backport PR #{} to {}: {}".format(args.pr_number, args.to_branch, original_pr["title"]),
169+
head=remote_user + ":" + tmp_branch,
170+
base=args.to_branch,
171+
body="**Backport PR #{} to {} branch, original message:**\n\n---\n\n{}"
172+
.format(args.pr_number, args.to_branch, original_pr["body"])
173+
))
174+
if request.status_code > 299:
175+
info("Creating PR failed: {}".format(request.json()))
176+
sys.exit(1)
177+
new_pr = request.json()
178+
179+
# add labels
180+
labels = ["backport"]
181+
# get the version (vX.Y.Z) we are backporting to
182+
version = get_version(os.getcwd())
183+
if version:
184+
labels.append(version)
185+
186+
session.post(
187+
base + "/issues/{}/labels".format(new_pr["number"]), json=labels)
188+
189+
"""
190+
if not args.keep_backport_label:
191+
# remove needs backport label from the original PR
192+
session.delete(base + "/issues/{}/labels/needs_backport".format(args.pr_number))
193+
"""
194+
# Set a version label on the original PR
195+
if version:
196+
session.post(
197+
base + "/issues/{}/labels".format(args.pr_number), json=[version])
198+
199+
info("Done. PR created: {}".format(new_pr["html_url"]))
200+
info("Please go and check it and add the review tags")
201+
202+
203+
def get_version(base_dir):
204+
with open(os.path.join(base_dir, "VERSION"), "r") as f:
205+
for line in f:
206+
return "v" + line.strip()
207+
208+
209+
def get_github_token():
210+
try:
211+
token = open(expanduser("~/.elastic/github.token"), "r").read().strip()
212+
except:
213+
token = False
214+
return token
215+
216+
217+
def github_session(token):
218+
session = requests.Session()
219+
session.headers.update({"Authorization": "token " + token})
220+
return session
221+
222+
223+
def info(msg):
224+
print("\nINFO: {}".format(msg))
225+
226+
227+
if __name__ == "__main__":
228+
sys.exit(main())

0 commit comments

Comments
 (0)