Skip to content

Commit cad6ea6

Browse files
committed
[ci] Simplify BP workflow and script
also by stopping it early if the comment that triggers the BP is made by a non-member of the root-project organisation.
1 parent a9f0e95 commit cad6ea6

2 files changed

Lines changed: 178 additions & 111 deletions

File tree

.github/workflows/root-pr-backport.yml

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,26 +31,26 @@ jobs:
3131
issues: write
3232
pull-requests: write
3333
if: >-
34+
(github.event.comment.author_association == 'MEMBER') &&
3435
(github.repository == 'root-project/root') &&
3536
!startswith(github.event.comment.body, '<!--IGNORE-->') &&
3637
contains(github.event.comment.body, '/backport to')
3738
steps:
38-
- name: Checkout
39+
- name: Checkout the official ROOT repo
3940
uses: actions/checkout@v5
4041
with:
41-
repository: root-project/root
42-
# GitHub stores the token used for checkout and uses it for pushes
43-
# too, but we want to use a different token for pushing, so we need
44-
# to disable persist-credentials here.
45-
persist-credentials: false
46-
path: "./root"
47-
fetch-depth: 1
42+
path: root_official
43+
44+
- name: Checkout the bot ROOT repo
45+
uses: actions/checkout@v5
46+
with:
47+
repository: root-project-bot/root
48+
token: ${{ secrets.ROOT_BOT_GITHUB_TOKEN }}
49+
path: root_bot
4850

4951
- name: Backport Commits
5052
run: " cd root && .github/workflows/utilities/backport_pr.py
5153
--comment \"$COMMENT_BODY\"
5254
--pull ${{ github.event.issue.number }}
5355
--requestor ${{ github.event.comment.user.login }}
54-
--pr-token ${{ secrets.PR_CREDENTIALS_TOKEN }}
55-
--push-token ${{ secrets.BOT_PUSH_CREDENTIALS_TOKEN }}
5656
"

.github/workflows/utilities/backport_pr.py

Lines changed: 168 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,17 @@
88
import shutil
99
import sys
1010

11-
PUBLIC_BOT_ROOT_REPO = f'https:/github.com/root-project-bot/root.git'
11+
BOT_REPO_DIR_NAME = "root_bot"
12+
# This is needed for adding the remote branch to apply the patch deriving from the PR
1213
OFFICIAL_ROOT_REPO = 'https://github.com/root-project/root'
14+
OFFICIAL_REPO_DIR_NAME = "root_official"
1315

1416
def validateTargetBranches(brs):
17+
'''
18+
Verify the branches are floating point numbers
19+
20+
:param brs: The branches as passed to the tool
21+
'''
1522
for br in brs:
1623
try:
1724
brf = float(br)
@@ -24,8 +31,6 @@ def parse_args():
2431
parser.add_argument('--to', action='append', type=str, help='The target branches')
2532
parser.add_argument('--comment', type=str, help='Comment that contains the info, e.g "/backport to 6.36, 6.42"')
2633
parser.add_argument('--requestor', type=str, help='The requestor of the backport"')
27-
parser.add_argument('--push-token', type=str, help='The secret to push')
28-
parser.add_argument('--pr-token', type=str, help='The secret to open a PR')
2934

3035
args = parser.parse_args()
3136

@@ -55,9 +60,17 @@ def parse_args():
5560

5661
validateTargetBranches(targetBranches)
5762

58-
return (args.push_token, args.pr_token, args.requestor, pullN, targetBranches)
63+
return (args.requestor, pullN, targetBranches)
5964

6065
def printFirstMessage(requestor, pullNumber, targetBranches):
66+
'''
67+
Prints a message useful for debugging, declaring what the script will do
68+
for what branches
69+
70+
:param requestor: The user who would like to prepare a backport
71+
:param pullNumber: The number of the PR to backport
72+
:param targetBranches: The branches onto which the backport has to be prepared
73+
'''
6174
nBranches = len(targetBranches)
6275
targetBranchesStr = ''
6376
plural = ''
@@ -70,132 +83,186 @@ def printFirstMessage(requestor, pullNumber, targetBranches):
7083
requestorInfo = f' requested by {requestor}' if requestor else ''
7184
printInfo(f'Preparing to backport PR #{pullNumber} to branch{plural} {targetBranchesStr} {requestorInfo}')
7285

86+
def execCommandOfficialRepo(command):
87+
'''
88+
Execute a shell command in the official ROOT repo
89+
90+
:param command: The command to execute
91+
'''
92+
return execCommand(command, OFFICIAL_REPO_DIR_NAME)
93+
94+
def execCommandBotRepo(command):
95+
'''
96+
Execute a shell command in the bot ROOT repo
97+
98+
:param command: The command to execute
99+
'''
100+
return execCommand(command, BOT_REPO_DIR_NAME)
101+
73102
def shortBranchToRealBranch(shortBranchName):
103+
'''
104+
Translate branches of the form X.YZ to the names of the branches in the root repository.
105+
For example, 6.40 will become v6-40-00-patches
106+
107+
:param shortBranchName: The branch name in the form X.YZ, e.g. 6.38
108+
'''
74109
major, minor = shortBranchName.split('.')
75110
return f'v{major}-{minor}-00-patches'
76111

77-
def getPRTitle(pullNumber):
78-
out = execCommand(f'gh pr view {pullNumber} --repo root-project/root')
79-
titleLine = out.split('\n')[0]
80-
_, title = titleLine.split('\t')
81-
return title
82-
83-
def createWorkdir(workdir):
84-
if os.path.exists(workdir):
85-
shutil.rmtree(workdir)
86-
os.mkdir(workdir)
87-
88-
def fetchPatch(pullNumber):
89-
outname = f'../{pullNumber}.patch'
90-
out = execCommand(f'gh pr diff {pullNumber} --patch > {outname}')
91-
return os.path.abspath(outname)
92-
93-
def parseJson(jsonStr, level1, level2):
112+
class OfficialROOTRepoPR:
94113
'''
95-
A json of the type
96-
{
97-
"labels": [
98-
{
99-
"id": "MDU6TGFiZWwyMzM2MDQ5MDQw",
100-
"name": "affects:master",
101-
"description": "",
102-
"color": "93e0ea"
103-
},
104-
{
105-
"id": "LA_kwDOAKfCqc8AAAACEWlFOQ",
106-
"name" ...
107-
is returned.
114+
A class that represents a PR to the Official ROOT repository
108115
'''
109-
json_object = json.loads(jsonStr)
110-
return ','.join([ l[level2] for l in json_object[level1] ])
116+
def __init__(self, pullNumber):
117+
self.pullNumber = pullNumber
118+
PRJsonStr = execCommandOfficialRepo(f'gh pr view {pullNumber} --json labels,assignees,title')
119+
self.PRJsonObject = json.loads(PRJsonStr)
111120

112-
def getPRLabels(pullNumber):
113-
jsonStr = execCommand(f'gh pr view {pullNumber} --json labels')
114-
labels = parseJson(jsonStr, 'labels', 'name')
115-
labels = f'pr:backport,{labels}'
116-
printInfo(f'The label(s) of PR {pullNumber} is(are) {labels}')
117-
return labels
121+
def fetchPatch(self):
122+
'''
123+
Obtain the patch a PR to the root repo by its number.
124+
A file is created called <#PR>.patch
125+
126+
:param pullNumber: The number of the PR to the ROOT repo
127+
'''
128+
outname = os.path.join(os.getcwd(), f'{self.pullNumber}.patch')
129+
out = execCommandOfficialRepo(f'gh pr diff {self.pullNumber} --patch > {outname}')
130+
return outname
118131

119-
def getPRAssignees(pullNumber):
120-
jsonStr = execCommand(f'gh pr view {pullNumber} --json assignees')
121-
assignees = parseJson(jsonStr, 'assignees', 'login')
122-
printInfo(f'The assignee(s) of PR {pullNumber} is(are) {assignees}')
123-
return assignees
132+
def _parseJson(self, level1Label, level2Label=''):
133+
'''
134+
Obtain information from a json of the type returned by the GitHub interface as specified
135+
by the level 1 and 2 labels.
136+
The result is a comma separated list of strings.
124137
125-
def authenticate(token):
126-
execCommand(f'gh auth login --with-token', theInput=token)
138+
A typical json returned has the following structure:
139+
```
140+
{
141+
"labels": [
142+
{
143+
"id": "MDU6TGFiZWwyMzM2MDQ5MDQw",
144+
"name": "affects:master",
145+
"description": "",
146+
"color": "93e0ea"
147+
},
148+
{
149+
"id": "LA_kwDOAKfCqc8AAAACEWlFOQ",
150+
"name" ...
151+
```
127152
128-
def principal():
129-
push_token, pr_token, requestor, pullNumber, targetBranches = parse_args()
130-
printFirstMessage(requestor, pullNumber, targetBranches)
153+
:param jsonStr: The json as string
154+
:param level1Label: The label of the first level of the json
155+
:param level2Label: The label of the second level of the json
156+
'''
157+
if level2Label != '':
158+
return ','.join([ l[level2Label] for l in self.PRJsonObject[level1Label] ])
159+
else:
160+
return self.PRJsonObject[level1Label]
131161

132-
BOT_ROOT_REPO = f'https://x-access-token:{push_token}@github.com/root-project-bot/root.git'
133-
134-
# Get some information about the PR
135-
authenticate(push_token)
136-
assignees = getPRAssignees(pullNumber)
137-
labels = getPRLabels(pullNumber)
138-
patchName = fetchPatch(pullNumber)
139-
prTitle = getPRTitle(pullNumber)
162+
def getPRTitle(self):
163+
'''
164+
Obtain the name of a PR to the official ROOT repo by its number.
165+
'''
166+
return self._parseJson('title')
140167

141-
# We start the automated procedure, assuming to be in a clean root repo
142-
os.chdir('../')
143-
workdir = f'./workdir_{pullNumber}'
168+
def getPRLabels(self):
169+
'''
170+
Get the labels of a PR to the ROOT official repository as a comma separated list.
171+
172+
:param pullNumber: The number of the PR to the ROOT repo
173+
'''
174+
labels = self._parseJson('labels', 'name')
175+
labels = f'pr:backport,{labels}'
176+
printInfo(f'The label(s) of PR {self.pullNumber} is(are) {labels}')
177+
return labels
178+
179+
def getPRAssignees(self):
180+
'''
181+
Get the assignees of a PR to the ROOT official repository as a comma separated list.
182+
183+
:param pullNumber: The number of the PR to the ROOT repo
184+
'''
185+
assignees = self._parseJson('assignees', 'login')
186+
printInfo(f'The assignee(s) of PR {self.pullNumber} is(are) {assignees}')
187+
return assignees
188+
189+
def postCommentAfterBP(self, bpPRUrlBranch):
190+
'''
191+
Post a clear message summarising what backports have been created
192+
193+
:param pullNumber: The number of the PR to the ROOT repo
194+
:param bpPRUrlBranch: The list of backport PRs numbers and branch names
195+
'''
196+
# We now prepare a clear message to post on the PR for which backports have been created...
197+
prComment = 'This PR has been backported to'
198+
if len(bpPRUrlBranch) == 1:
199+
prUrl, brName = bpPRUrlBranch[0]
200+
prComment += f' branch {brName}: {prUrl}'
201+
else:
202+
for prUrl, brName in bpPRUrlBranch:
203+
prComment += f'\n - Branch {brName}:#{prUrl}'
204+
# ...and post it
205+
execCommandOfficialRepo(f'gh pr comment {self.pullNumber} --body "{prComment}"')
206+
return 0
207+
208+
def principal():
144209

145-
createWorkdir(workdir)
146-
os.chdir(workdir)
210+
# We first obtain the information from the parser
211+
requestor, pullNumber, targetBranches = parse_args()
147212

148-
cloneCmd = 'git clone --depth 1'
213+
# We declare what we are about to do, for clarity and debugging purposes
214+
printFirstMessage(requestor, pullNumber, targetBranches)
149215

150-
execCommand(cmd=f'{cloneCmd} {BOT_ROOT_REPO}', replace=push_token)
216+
# We get some information about the PR
217+
thePR = OfficialROOTRepoPR(pullNumber)
218+
assignees = thePR.getPRAssignees()
219+
labels = thePR.getPRLabels()
220+
patchName = thePR.fetchPatch()
221+
prTitle = thePR.getPRTitle()
151222

152-
os.chdir('root')
153-
execCommand(f'git remote add root_upstream {OFFICIAL_ROOT_REPO}')
154-
155223
requestorInfo = f', requested by @{requestor}' if requestor else ''
156224
bpPRUrlBranch = []
157225

158226
labelSwitch = '' if labels == '' else f'--label "{labels}"'
159227
assigneesSwitch = '' if assignees == '' else f'--assignee "{assignees}"'
160228

161-
execCommand(f'git config user.email "{requestor}@no-reply.github.com"')
162-
execCommand(f'git config user.name "{requestor}"')
229+
execCommandBotRepo(f'git config user.email "{requestor}@no-reply.github.com"')
230+
execCommandBotRepo(f'git config user.name "{requestor}"')
231+
232+
# Before looping on the target branches to prepare the backport PRs, we
233+
# need to add the ROOT repo as remote to the bot repo.
234+
if 'root_upstream' in execCommandBotRepo('git remote'):
235+
execCommandBotRepo(f'git remote remove root_upstream')
236+
execCommandBotRepo(f'git remote add root_upstream {OFFICIAL_ROOT_REPO}')
163237

164238
# Now we loop on the target branches to create one PR for each of them
165239
for targetBranch in targetBranches:
166-
printInfo(f'--------- Backporting PR {pullNumber} to branch {targetBranch}')
167-
realTargetBranch = shortBranchToRealBranch(targetBranch)
168-
bpBranchName = f'BP_{targetBranch}_pull_{pullNumber}'
169-
execCommand(f'git fetch root_upstream {realTargetBranch}')
170-
execCommand(f'git checkout {realTargetBranch}')
171-
execCommand(f'git checkout -b {bpBranchName}')
172-
execCommand(f'git apply --check {patchName}')
173-
execCommand(f'git am --keep-cr --signoff < {patchName}')
174-
execCommand(f'git push --set-upstream origin {bpBranchName}')
175-
authenticate(pr_token)
176-
prUrl = execCommand('gh pr create --repo root-project/root ' \
177-
f'--base {realTargetBranch} '\
178-
f'--head root-project-bot:{bpBranchName} ' \
179-
f'--title "[{targetBranch}] {prTitle}" '\
180-
f'--body "Backport of #{pullNumber}{requestorInfo}" '\
181-
f'{labelSwitch} {assigneesSwitch} ' \
182-
'-d')
183-
bpPRUrlBranch.append((prUrl,targetBranch))
240+
printInfo(f'--------- Backporting PR {pullNumber} to branch {targetBranch}')
241+
realTargetBranch = shortBranchToRealBranch(targetBranch)
242+
bpBranchName = f'BP_{targetBranch}_pull_{pullNumber}'
243+
execCommandBotRepo(f'git fetch root_upstream {realTargetBranch}')
244+
execCommandBotRepo(f'git checkout {realTargetBranch}')
245+
if bpBranchName in execCommandBotRepo('git branch'):
246+
execCommandBotRepo(f'git branch -D {bpBranchName}')
247+
if bpBranchName in execCommandBotRepo('git ls-remote origin'):
248+
execCommandBotRepo(f'git push -d origin {bpBranchName}')
249+
execCommandBotRepo(f'git checkout -b {bpBranchName}')
250+
execCommandBotRepo(f'git apply --check {patchName}')
251+
execCommandBotRepo(f'git am --keep-cr --signoff < {patchName}')
252+
execCommandBotRepo(f'git push --set-upstream origin {bpBranchName}')
253+
prUrl = execCommandBotRepo('gh pr create --repo root-project/root ' \
254+
f'--base {realTargetBranch} '\
255+
f'--head root-project-bot:{bpBranchName} ' \
256+
f'--title "[{targetBranch}] {prTitle}" '\
257+
f'--body "Backport of #{pullNumber}{requestorInfo}" '\
258+
f'{labelSwitch} {assigneesSwitch} ' \
259+
'-d || echo \'\'')
260+
bpPRUrlBranch.append((prUrl,targetBranch))
184261

185262
if bpPRUrlBranch == []:
186263
Exception('No backport succeeded!')
187264

188-
prComment = 'This PR has been backported to'
189-
if len(bpPRUrlBranch) == 1:
190-
prUrl, brName = bpPRUrlBranch[0]
191-
prComment += f' branch {brName}: {prUrl}'
192-
else:
193-
for prUrl, brName in bpPRUrlBranch:
194-
prComment += f'\n - Branch {brName}:#{prUrl}'
195-
196-
authenticate(pr_token)
197-
os.chdir('../../root') # we go back to the original root repo dir
198-
execCommand(f'gh pr comment {pullNumber} --body "{prComment}"')
265+
return thePR.postCommentAfterBP(bpPRUrlBranch)
199266

200267
if __name__ == "__main__":
201268
sys.exit(principal())

0 commit comments

Comments
 (0)