1- #!/bin/python3
1+ #!/usr/ bin/env python3
22
33import subprocess
4+ import sys
45import os
56import argparse
67import yaml
78import asyncio
9+ from typing import List , Dict , Any , Optional
810
911
1012REPO_ROOT = os .path .dirname (os .path .dirname (__file__ ))
@@ -21,23 +23,71 @@ class col:
2123 UNDERLINE = "\033 [4m"
2224
2325
24- def color (the_color , text ) :
26+ def color (the_color : str , text : str ) -> str :
2527 return col .BOLD + the_color + str (text ) + col .RESET
2628
2729
28- def cprint (the_color , text ):
29- print (color (the_color , text ))
30+ def cprint (the_color : str , text : str ) -> None :
31+ if hasattr (sys .stdout , "isatty" ) and sys .stdout .isatty ():
32+ print (color (the_color , text ))
33+ else :
34+ print (text )
35+
36+
37+ def git (args : List [str ]) -> List [str ]:
38+ p = subprocess .run (
39+ ["git" ] + args ,
40+ cwd = REPO_ROOT ,
41+ stdout = subprocess .PIPE ,
42+ stderr = subprocess .PIPE ,
43+ check = True ,
44+ )
45+ lines = p .stdout .decode ().strip ().split ("\n " )
46+ return [line .strip () for line in lines ]
47+
48+
49+ def find_changed_files (merge_base : str = "origin/master" ) -> List [str ]:
50+ untracked = []
51+
52+ for line in git (["status" , "--porcelain" ]):
53+ # Untracked files start with ??, so grab all of those
54+ if line .startswith ("?? " ):
55+ untracked .append (line .replace ("?? " , "" ))
56+
57+ # Modified, unstaged
58+ modified = git (["diff" , "--name-only" ])
3059
60+ # Modified, staged
61+ cached = git (["diff" , "--cached" , "--name-only" ])
3162
32- async def run_step (step , job_name ):
63+ # Committed
64+ diff_with_origin = git (["diff" , "--name-only" , "--merge-base" , merge_base , "HEAD" ])
65+
66+ # De-duplicate
67+ all_files = set (untracked + cached + modified + diff_with_origin )
68+ return [x .strip () for x in all_files if x .strip () != "" ]
69+
70+
71+ async def run_step (step : Dict [str , Any ], job_name : str , files : Optional [List [str ]]) -> bool :
3372 env = os .environ .copy ()
3473 env ["GITHUB_WORKSPACE" ] = "/tmp"
74+ if files is None :
75+ env ["LOCAL_FILES" ] = ""
76+ else :
77+ env ["LOCAL_FILES" ] = " " .join (files )
3578 script = step ["run" ]
3679
80+ PASS = "\U00002705 "
81+ FAIL = "\U0000274C "
3782 # We don't need to print the commands for local running
3883 # TODO: Either lint that GHA scripts only use 'set -eux' or make this more
3984 # resilient
4085 script = script .replace ("set -eux" , "set -eu" )
86+ name = f'{ job_name } : { step ["name" ]} '
87+
88+ def header (passed : bool ) -> None :
89+ icon = PASS if passed else FAIL
90+ cprint (col .BLUE , f"{ icon } { name } " )
4191
4292 try :
4393 proc = await asyncio .create_subprocess_shell (
@@ -49,27 +99,31 @@ async def run_step(step, job_name):
4999 stderr = subprocess .PIPE ,
50100 )
51101
52- stdout , stderr = await proc .communicate ()
53- cprint (col .BLUE , f'{ job_name } : { step ["name" ]} ' )
102+ stdout_bytes , stderr_bytes = await proc .communicate ()
103+
104+ header (passed = proc .returncode == 0 )
54105 except Exception as e :
55- cprint ( col . BLUE , f' { job_name } : { step [ "name" ] } ' )
106+ header ( passed = False )
56107 print (e )
108+ return False
57109
58- stdout = stdout .decode ().strip ()
59- stderr = stderr .decode ().strip ()
110+ stdout = stdout_bytes .decode ().strip ()
111+ stderr = stderr_bytes .decode ().strip ()
60112
61113 if stderr != "" :
62114 print (stderr )
63115 if stdout != "" :
64116 print (stdout )
65117
118+ return proc .returncode == 0
66119
67- async def run_steps (steps , job_name ):
68- coros = [run_step (step , job_name ) for step in steps ]
120+
121+ async def run_steps (steps : List [Dict [str , Any ]], job_name : str , files : Optional [List [str ]]) -> None :
122+ coros = [run_step (step , job_name , files ) for step in steps ]
69123 await asyncio .gather (* coros )
70124
71125
72- def grab_specific_steps (steps_to_grab , job ) :
126+ def grab_specific_steps (steps_to_grab : List [ str ] , job : Dict [ str , Any ]) -> List [ Dict [ str , Any ]] :
73127 relevant_steps = []
74128 for step in steps_to_grab :
75129 for actual_step in job ["steps" ]:
@@ -83,7 +137,7 @@ def grab_specific_steps(steps_to_grab, job):
83137 return relevant_steps
84138
85139
86- def grab_all_steps_after (last_step , job ) :
140+ def grab_all_steps_after (last_step : str , job : Dict [ str , Any ]) -> List [ Dict [ str , Any ]] :
87141 relevant_steps = []
88142
89143 found = False
@@ -96,18 +150,29 @@ def grab_all_steps_after(last_step, job):
96150 return relevant_steps
97151
98152
99- def main ():
153+ def main () -> None :
100154 parser = argparse .ArgumentParser (
101155 description = "Pull shell scripts out of GitHub actions and run them"
102156 )
103157 parser .add_argument ("--file" , help = "YAML file with actions" , required = True )
158+ parser .add_argument ("--file-filter" , help = "only pass through files with this extension" , default = '' )
159+ parser .add_argument ("--changed-only" , help = "only run on changed files" , action = 'store_true' , default = False )
104160 parser .add_argument ("--job" , help = "job name" , required = True )
105161 parser .add_argument ("--step" , action = "append" , help = "steps to run (in order)" )
106162 parser .add_argument (
107163 "--all-steps-after" , help = "include every step after this one (non inclusive)"
108164 )
109165 args = parser .parse_args ()
110166
167+ changed_files = None
168+ if args .changed_only :
169+ changed_files = []
170+ for f in find_changed_files ():
171+ for file_filter in args .file_filter :
172+ if f .endswith (file_filter ):
173+ changed_files .append (f )
174+ break
175+
111176 if args .step is None and args .all_steps_after is None :
112177 raise RuntimeError ("1+ --steps or --all-steps-after must be provided" )
113178
@@ -129,8 +194,7 @@ def main():
129194 else :
130195 relevant_steps = grab_all_steps_after (args .all_steps_after , job )
131196
132- # pprint.pprint(relevant_steps)
133- asyncio .run (run_steps (relevant_steps , args .job ))
197+ asyncio .run (run_steps (relevant_steps , args .job , changed_files )) # type: ignore[attr-defined]
134198
135199
136200if __name__ == "__main__" :
0 commit comments