Skip to content

Commit e5f8c6b

Browse files
committed
action: Add patch-level checks
Use git cherry-pick to replay the PR branch in order to add patch-level checks. Signed-off-by: Carles Cufi <[email protected]>
1 parent b975c93 commit e5f8c6b

File tree

2 files changed

+191
-58
lines changed

2 files changed

+191
-58
lines changed

action.py

Lines changed: 185 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Dict, List
88
from pathlib import Path
99
import argparse
10+
import functools
1011
import json
1112
import os
1213
import re
@@ -47,12 +48,20 @@
4748

4849
ARGS = None # global arguments, see parse_args()
4950

50-
def stdout(*msg):
51-
# Print a diagnostic message to standard error.
52-
53-
print(f'{PROG}:', *msg)
51+
def stdout(msg):
52+
print(f'{PROG}: {msg}', file=sys.stdout)
5453
sys.stdout.flush()
5554

55+
die_switch = None
56+
57+
def die(s):
58+
print(f'ERROR: {s}', file=sys.stderr)
59+
if die_switch:
60+
# Switch back
61+
stdout('die: switch')
62+
try_switch_back(die_switch)
63+
sys.exit(1)
64+
5665
def gh_pr_split(s):
5766
sl = s.split('/')
5867
if len(sl) != 3:
@@ -72,16 +81,16 @@ def parse_args():
7281
stdout(f'pr: {ARGS.pr} upstream: {ARGS.upstream}')
7382

7483
if ARGS.target == 'none':
75-
sys.exit('--target is required')
84+
die('--target is required')
7685

7786
if ARGS.upstream == 'none':
78-
sys.exit('--upstream is required')
87+
die('--upstream is required')
7988

8089
if ARGS.baserev == 'none' and ARGS.pr == 'none':
81-
sys.exit('--baserev or --pr is required')
90+
die('--baserev or --pr is required')
8291

8392
if ARGS.baserev != 'none' and ARGS.pr != 'none':
84-
sys.exit('--baserev and --pr are mutually exclusive')
93+
die('--baserev and --pr are mutually exclusive')
8594

8695
def ssplit(cmd):
8796
if isinstance(cmd, str):
@@ -105,20 +114,20 @@ def runc(cmd, exit_on_cpe=True, **kwargs):
105114
ret = subprocess.run(ssplit(cmd), **kwargs)
106115
except subprocess.CalledProcessError as e:
107116
if exit_on_cpe:
108-
sys.exit(f'Execution of {cmd} failed with {e.returncode}')
117+
die(f'Execution of {cmd} failed with {e.returncode}')
109118
else:
110119
raise
111120

112121
return ret
113122

114-
def runc_out(cmd, exit_on_cpe=True, **kwargs):
123+
def runc_out(cmd, exit_on_cpe=True, suppress_stderr=False, **kwargs):
115124
# A shorthand for running a simple shell command and getting its output.
116125

117126
cwd = kwargs.get('cwd', os.getcwd())
118127

119-
if ARGS.quiet_subprocesses:
128+
if ARGS.quiet_subprocesses or suppress_stderr:
120129
kwargs['stderr'] = subprocess.DEVNULL
121-
else:
130+
if not ARGS.quiet_subprocesses:
122131
stdout(f'running "{cmd}" in "{cwd}"')
123132

124133
kwargs['check'] = True
@@ -129,82 +138,183 @@ def runc_out(cmd, exit_on_cpe=True, **kwargs):
129138
cp = subprocess.run(ssplit(cmd), **kwargs)
130139
except subprocess.CalledProcessError as e:
131140
if exit_on_cpe:
132-
sys.exit(f'Execution of {cmd} failed with {e.returncode}')
141+
die(f'Execution of {cmd} failed with {e.returncode}')
133142
else:
134143
raise
135144

136145
return cp.stdout.rstrip()
137146

138-
def fetch_upstream(gh, upstream):
139-
pass
147+
@functools.cache
148+
def fetch_branch(repo, branch, target):
149+
ref = f'nrf/ref/{branch}'
150+
runc(f'git -C {target} fetch {repo.clone_url} {branch}:{ref}')
151+
return ref
152+
153+
@functools.cache
154+
def fetch_pr(repo, prn, target):
155+
pr = repo.get_pull(prn)
156+
if pr.is_merged():
157+
die(f'PR #{prn} is merged, please use [nrf fromtree] instead')
158+
if pr.state == 'closed':
159+
die(f'PR #{prn} is closed and not merged')
140160

141-
def fetch_pr(gh, pr):
142-
pass
161+
shas = [c.sha for c in pr.get_commits()]
162+
ref = f'nrf/pull/{prn}'
163+
runc(f'git -C {target} fetch {repo.clone_url} pull/{prn}/head:{ref}')
164+
165+
stdout(f'PR #{prn} ref: {ref}')
166+
stdout(f'PR #{prn} shas: {shas}')
167+
return ref, shas
143168

144169
def merge_base(target, base, head):
145170
mb = runc_out(f'git -C {target} merge-base {base} {head}')
146171
stdout(f'merge base {mb}')
147172
return mb
148173

149-
def check_commit(urepo, target, sha):
150-
stdout(f'Checking commit {sha}')
174+
@functools.cache
175+
def get_commit_msg(target, sha):
151176
cm = runc_out(f'git -C {target} show -s --format=%B {sha}').split('\n')
152-
title = cm[0]
177+
title = cm[0].lstrip().rstrip()
153178
body = '\n'.join(cm[1:])
154-
# stdout(f'{title}')
155-
# stdout(f'{body}')
179+
180+
return title, body
181+
182+
def range_diff(target, s1, s2, stat=False):
183+
# Compare commit ranges (sha^! is a range for sha itself)
184+
stats = ' --stat' if stat else ''
185+
out = runc_out(f'git -C {target} range-diff --no-color{stats} {s1}^! {s2}^!')
186+
return out
187+
188+
def get_commit_diff(target, sha):
189+
# Get the diff of a commit (sha^! is a range for sha itself)
190+
diff = runc_out(f'git -C {target} diff {sha}^!').split('\n')
191+
192+
return diff
193+
194+
def try_switch_back(target):
195+
try:
196+
_ = runc_out(f'git -C {target} switch -', exit_on_cpe=False,
197+
suppress_stderr=True)
198+
except subprocess.CalledProcessError as e:
199+
pass
200+
201+
def check_commit(urepo, ubranch, target, sha, merge):
202+
stdout(f'--- Checking commit {sha}')
203+
title, body = get_commit_msg(target, sha)
156204
m = re.match(r'^(Revert\s+\")?\[nrf (mergeup|fromtree|fromlist|noup)\]\s+',
157205
title)
158206
if not m:
159-
sys_exit(f'{sha}: Title does not contain a sauce tag')
207+
die(f'{sha}: Title does not contain a sauce tag')
160208
revert = m.group(1)
161209
tag = m.group(2)
162210
if not tag:
163-
sys_exit(f'{sha}: Title does not contain a sauce tag')
211+
die(f'{sha}: Title does not contain a sauce tag')
164212

165-
#stdout(tag)
166-
167-
# Skip the rest of checks if the commit is a revert
168213
if revert:
169214
if tag == 'mergeup':
170-
sys.exit('Mergeup cannot be reverted')
171-
stdout(f'Revert commit, skipping additional checks')
172-
return
173-
174-
if tag == 'mergeup':
215+
die('Mergeup cannot be reverted')
216+
regex = r'^This reverts commit \b([a-f0-9]{40})\b\.'
217+
match = re.search(regex, body, re.MULTILINE)
218+
if not match:
219+
die(f'{sha}: revert commit missing reverted SHA')
220+
stdout(f'revert: {match.group(1)}')
221+
# The SHA to replay is the revert commmit's
222+
usha = sha
223+
elif tag == 'mergeup':
175224
# Count the merges in this commit range (sha^! is a range for sha
176225
# itself)
177-
count = runc_out('git rev-list --merges --count {sha}^!')
226+
count = runc_out(f'git -C {target} rev-list --merges --count {sha}^!')
178227
if count != '1':
179-
sys.exit('mergeup used in a non-merge commit')
228+
die('mergeup used in a non-merge commit')
180229
if not re.match(r'^\[nrf mergeup\] Merge upstream up to commit \b([a-f0-9]{40})\b',
181230
title):
182-
sys.exit('{sha}: Invalid mergeup commit title')
231+
die(f'{sha}: Invalid mergeup commit title')
232+
233+
# We cannot replay the mergeup commit
234+
return True
183235
elif tag == 'fromlist':
184-
regex = r'^Upstream PR: (' \
185-
r'https://github\.com/.*/pull/(\d+)|' \
186-
r'http://lists.infradead.org/pipermail/hostap/.*\.html' \
187-
r')'
236+
regex = r'^Upstream PR: https://github\.com/.*/pull/(\d+)'
188237

189238
match = re.search(regex, body, re.MULTILINE)
190239
if not match:
191-
sys_exit(f'{sha}: fromlist commit missing an Upstream PR reference')
240+
die(f'{sha}: fromlist commit missing an Upstream PR reference')
192241

193-
#stdout(f'fromlist: {match.group(1)}')
194-
if urepo:
195-
upr = match.group(2)
196-
stdout(f'fromlist: {upr}')
242+
upr = match.group(1)
243+
stdout(f'fromlist: {upr}')
244+
245+
# Check and Fetch the upstream Pull Request
246+
ref, shas = fetch_pr(urepo, int(upr), target)
247+
248+
# Match a commit
249+
usha = None
250+
for s in shas:
251+
t, b = get_commit_msg(target, s)
252+
# Match the upstream commit title with the downstream one
253+
if t in title:
254+
stdout(f'fromlist: Matched upstream PR commit {s}')
255+
usha = s
256+
break
257+
258+
if not usha:
259+
die(f'{sha}: unable to match any commit from PR {upr}')
260+
197261
elif tag == 'fromtree':
198262
regex = r'^\(cherry picked from commit \b([a-f0-9]{40})\b\)'
199263
match = re.search(regex, body, re.MULTILINE)
200264
if not match:
201-
sys_exit(f'{sha}: fromtree commit missing cherry-pick reference')
265+
die(f'{sha}: fromtree commit missing cherry-pick reference')
202266
#stdout(f'fromtree: {match.group(0)}')
203-
if urepo:
204-
usha = match.group(1)
205-
stdout(f'fromtree: {usha}')
267+
268+
usha = match.group(1)
269+
stdout(f'fromtree: {usha}')
270+
271+
# Fetch the upstream main branch
272+
ref = fetch_branch(urepo, ubranch, target)
273+
274+
# Verify that the commit exists at all in the working tree
275+
_ = runc_out(f'git -C {target} rev-parse --verify {usha}^{{commit}}')
276+
277+
# Verify that the commit is in the required branch
278+
contains = runc_out(f'git -C {target} branch {ref} --contains {usha}')
279+
280+
if not re.match(rf'^.*{ref}.*$', contains):
281+
die(f'Branch {ref} does not contain commit {usha}')
282+
283+
elif tag == 'noup':
284+
stdout('noup')
285+
# The SHA to replay is the noup commmit's
286+
usha = sha
287+
288+
# Skip cherry-picking if a merge has been found
289+
if merge:
290+
stdout(f'merge: skipping cherry-pick of {sha}')
291+
return True
292+
293+
# Cherry-pick the commit into the replay branch
294+
try:
295+
out = runc_out(f'git -C {target} cherry-pick {usha}', exit_on_cpe=False)
296+
except subprocess.CalledProcessError as e:
297+
# Make sure we abort the cherry-pick
298+
try:
299+
_ = runc_out(f'git -C {target} cherry-pick --abort',
300+
exit_on_cpe=False)
301+
except subprocess.CalledProcessError as e:
302+
pass
303+
# Ignore it and exit forcefully
304+
die(f'ERROR: unable to cherry-pick {usha}')
305+
306+
# Execute a diff between the replay branch and the sha to make sure the
307+
# commit has not been modified
308+
diff = runc_out(f'git -C {target} diff {sha}')
309+
310+
if diff:
311+
die('SHA {sha} non-empty diff:\n{diff}')
312+
313+
return False
206314

207315
def main():
316+
global die_switch
317+
208318
parse_args()
209319

210320
token = os.environ.get('GITHUB_TOKEN', None)
@@ -217,38 +327,55 @@ def main():
217327

218328
target = Path(ARGS.target).absolute()
219329
if not target.is_dir():
220-
sys.exit(f'target repo {target} does not exist; check path')
330+
die(f'target repo {target} does not exist; check path')
221331

222332
org_str, repo_str, br_str = gh_pr_split(ARGS.upstream)
223333
urepo = gh.get_repo(f'{org_str}/{repo_str}')
224334

225335
if ARGS.pr != 'none':
226336
org_str, repo_str, pr_str = gh_pr_split(ARGS.pr)
227337
drepo = gh.get_repo(f'{org_str}/{repo_str}')
228-
dpr = drepo.get_pull(int(pr_str))
338+
prn = int(pr_str)
339+
dpr = drepo.get_pull(prn)
229340
baserev = merge_base(target, dpr.base.sha, dpr.head.sha)
230341
headrev = dpr.head.sha
231342
dcommits = [c for c in dpr.get_commits()]
232343
dshas = [c.sha for c in dcommits]
233-
stdout(f'SHAs found in PR: {dshas}')
344+
stdout(f'{len(dshas)} commits found in PR')
234345
else:
235346
baserev = ARGS.baserev
236347
headrev = 'HEAD'
348+
prn = 0
237349

238350
stdout(f'baserev: {baserev}')
239351
stdout(f'headrev: {headrev}')
240352
revs = runc_out(f'git -C {target} rev-list --first-parent {baserev}..{headrev}')
241353
revs = revs.split('\n')
242354
revs.reverse()
243-
stdout(f'revs: {revs}')
244-
245-
rev_str = f"{','.join(revs)},"
246-
355+
stdout(f'{len(revs)} commits found with rev-list')
356+
357+
# Prepare a replay branch
358+
replay = f'nrf/replay/{prn}'
359+
# Create the replay branch
360+
runc(f'git -C {target} branch -f {replay} {baserev}')
361+
# Switch to it
362+
runc(f'git -C {target} switch {replay}')
363+
die_switch = target
364+
365+
merge = False
366+
count = 0
247367
for r in revs:
248-
check_commit(urepo, target, r)
368+
merge = check_commit(urepo, br_str, target, r, merge)
369+
count += 1
370+
stdout(f'- Processed commit {count}')
371+
372+
# Switch back to the previous branch
373+
stdout('main: switch')
374+
die_switch = None
375+
try_switch_back(target)
249376

250-
if dshas and dshas != revs:
251-
sys.exit(f'{dshas} is different from {revs}')
377+
if not merge and (dshas and dshas != revs):
378+
die(f'{dshas} is different from {revs}')
252379

253380
if __name__ == '__main__':
254381
main()

action.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ inputs:
44
target:
55
description: 'path to the target repository'
66
default: 'none'
7+
baserev:
8+
description: 'base revision to use. Will compute a rev-list --first-parent to HEAD'
9+
default: 'none'
710
upstream:
811
description: 'Upstream <org>/<repo>/<branch>'
912
default: 'none'
@@ -22,8 +25,11 @@ runs:
2225
shell: bash
2326
- id: run-python
2427
run: |
28+
git config --global user.email "[email protected]"
29+
git config --global user.name "Your Name"
2530
python3 ${{ github.action_path }}/action.py \
2631
--target "${{ inputs.target }}" \
32+
--baserev "${{ inputs.baserev }}" \
2733
--upstream "${{ inputs.upstream }}" \
2834
--pr "${{ github.repository }}/${{ github.event.pull_request.number }}" \
2935

0 commit comments

Comments
 (0)