77import click
88import coloredlogs
99import yaml
10- from github import Github , Workflow , UnknownObjectException
10+ from github import Github , UnknownObjectException
1111from github .Organization import Organization
1212from github .PaginatedList import PaginatedList
1313
1414from gha_cli .scanner import Org , print_orgs_as_csvs
1515
16- coloredlogs .install (level = ' INFO' )
16+ coloredlogs .install (level = " INFO" )
1717logger = logging .getLogger ()
1818
19- ActionVersion = namedtuple (' ActionVersion' , [' name' , ' current' , ' latest' ])
19+ ActionVersion = namedtuple (" ActionVersion" , [" name" , " current" , " latest" ])
2020
2121FLAG_COMPARE_EXACT_VERSION = False
2222
2323
2424def compare_versions (v1 : str , v2 : str ) -> int :
25- """Compare two versions, return 1 if v1 > v2, 0 if v1 == v2, -1 if v1 < v2
26- """
27- if v1 .startswith ('v' ):
25+ """Compare two versions, return 1 if v1 > v2, 0 if v1 == v2, -1 if v1 < v2"""
26+ if v1 .startswith ("v" ):
2827 v1 = v1 [1 :]
29- if v2 .startswith ('v' ):
28+ if v2 .startswith ("v" ):
3029 v2 = v2 [1 :]
31- v1 = v1 .split ('.' )
32- v2 = v2 .split ('.' )
30+ v1 = v1 .split ("." )
31+ v2 = v2 .split ("." )
3332 try :
3433 compare_count = max (len (v1 ), len (v2 )) if FLAG_COMPARE_EXACT_VERSION else 1
3534 for i in range (compare_count ):
@@ -40,7 +39,7 @@ def compare_versions(v1: str, v2: str) -> int:
4039 if v1_i < v2_i :
4140 return - 1
4241 except ValueError :
43- logging .warning (f' Could not compare versions { v1 } and { v2 } ' )
42+ logging .warning (f" Could not compare versions { v1 } and { v2 } " )
4443 return 0
4544
4645
@@ -53,47 +52,45 @@ def __init__(self, github_token: str):
5352
5453 @staticmethod
5554 def is_local_repo (repo_name : str ) -> bool :
56- return os .path .exists (repo_name ) and os .path .exists (os .path .join (repo_name , ' .git' ))
55+ return os .path .exists (repo_name ) and os .path .exists (os .path .join (repo_name , " .git" ))
5756
5857 @staticmethod
5958 def list_full_paths (path : str ) -> set [str ]:
6059 if not os .path .exists (path ):
6160 return set ()
62- return {os .path .join (path , file )
63- for file in os .listdir (path )
64- if file .endswith (('.yml' , '.yaml' ))}
61+ return {os .path .join (path , file ) for file in os .listdir (path ) if file .endswith ((".yml" , ".yaml" ))}
6562
6663 def get_workflow_actions (self , repo_name : str , workflow_path : str ) -> Set [str ]:
6764 workflow_content = self ._get_workflow_file_content (repo_name , workflow_path )
6865 workflow = yaml .load (workflow_content , Loader = yaml .CLoader )
6966 res = set ()
70- for job in workflow .get (' jobs' , dict ()).values ():
71- for step in job .get (' steps' , list ()):
72- if ' uses' in step :
73- res .add (step [' uses' ])
67+ for job in workflow .get (" jobs" , dict ()).values ():
68+ for step in job .get (" steps" , list ()):
69+ if " uses" in step :
70+ res .add (step [" uses" ])
7471 return res
7572
7673 def check_for_updates (self , action_name : str ) -> Optional [str ]:
7774 """Check whether an action has an update, and return the latest version if it does syntax for uses:
7875 https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_iduses
7976 """
80- if '@' not in action_name :
77+ if "@" not in action_name :
8178 return None
82- repo_name , current_version = action_name .split ('@' )
83- logging .debug (f' Checking for updates for { action_name } : Getting repo { repo_name } ' )
79+ repo_name , current_version = action_name .split ("@" )
80+ logging .debug (f" Checking for updates for { action_name } : Getting repo { repo_name } " )
8481 if repo_name in self .actions_latest_release :
8582 latest_release = self .actions_latest_release [repo_name ]
8683 logging .debug (f"Found in cache { repo_name } : { latest_release } " )
8784 return latest_release if compare_versions (latest_release , current_version ) else None
8885 repo = self .client .get_repo (repo_name )
89- logging .debug (f' Getting latest release for repository: { repo_name } ' )
86+ logging .debug (f" Getting latest release for repository: { repo_name } " )
9087 try :
9188 latest_release = repo .get_latest_release ()
9289 if compare_versions (latest_release .tag_name , current_version ):
9390 self .actions_latest_release [repo_name ] = latest_release .tag_name
9491 return latest_release .tag_name
9592 except UnknownObjectException :
96- logging .warning (f' No releases found for repository: { repo_name } ' )
93+ logging .warning (f" No releases found for repository: { repo_name } " )
9794 return None
9895
9996 def get_repo_actions_latest (self , repo_name : str ) -> Dict [str , List [ActionVersion ]]:
@@ -103,9 +100,9 @@ def get_repo_actions_latest(self, repo_name: str) -> Dict[str, List[ActionVersio
103100 res [path ] = list ()
104101 actions = self .get_workflow_actions (repo_name , path )
105102 for action in actions :
106- if '@' not in action :
103+ if "@" not in action :
107104 continue
108- action_name , curr_version = action .split ('@' )
105+ action_name , curr_version = action .split ("@" )
109106 if action not in self .actions_latest_release :
110107 latest = self .check_for_updates (action )
111108 self .actions_latest_release [action ] = latest
@@ -121,33 +118,34 @@ def get_repo_workflow_names(self, repo_name: str) -> Dict[str, str]:
121118 try :
122119 content = self ._get_workflow_file_content (repo_name , path )
123120 yaml_content = yaml .load (content , Loader = yaml .CLoader )
124- res [path ] = yaml_content .get (' name' , path )
121+ res [path ] = yaml_content .get (" name" , path )
125122 except FileNotFoundError as ex :
126123 logging .warning (ex )
127124 return res
128125
129126 def update_actions (
130- self , repo_name : str , workflow_path : str ,
131- updates : List [ActionVersion ],
132- commit_msg : str ,
127+ self ,
128+ repo_name : str ,
129+ workflow_path : str ,
130+ updates : List [ActionVersion ],
131+ commit_msg : str ,
133132 ) -> None :
134133 workflow_content = self ._get_workflow_file_content (repo_name , workflow_path )
135134 if isinstance (workflow_content , bytes ):
136135 workflow_content = workflow_content .decode ()
137136 for update in updates :
138137 if update .latest is None :
139138 continue
140- current_action = f' { update .name } @{ update .current } '
141- latest_action = f' { update .name } @{ update .latest } '
139+ current_action = f" { update .name } @{ update .current } "
140+ latest_action = f" { update .name } @{ update .latest } "
142141 workflow_content = workflow_content .replace (current_action , latest_action )
143142 self ._update_workflow_content (repo_name , workflow_path , workflow_content , commit_msg )
144143
145- def _update_workflow_content (
146- self , repo_name : str , workflow_path : str , workflow_content : str , commit_msg : str ):
144+ def _update_workflow_content (self , repo_name : str , workflow_path : str , workflow_content : str , commit_msg : str ):
147145 if self .is_local_repo (repo_name ):
148- with open (workflow_path , 'w' ) as f :
146+ with open (workflow_path , "w" ) as f :
149147 f .write (workflow_content )
150- click .secho (f' Updated workflow in { workflow_path } ' , fg = ' cyan' )
148+ click .secho (f" Updated workflow in { workflow_path } " , fg = " cyan" )
151149 return
152150
153151 # remote
@@ -159,24 +157,21 @@ def _update_workflow_content(
159157 workflow_content ,
160158 current_content .sha ,
161159 )
162- click .secho (f' Committed changes to workflow in { repo_name } :{ workflow_path } ' , fg = ' cyan' )
160+ click .secho (f" Committed changes to workflow in { repo_name } :{ workflow_path } " , fg = " cyan" )
163161 return res
164162
165163 def _get_github_workflow_filenames (self , repo_name : str ) -> Set [str ]:
166164 if repo_name in self ._wf_cache :
167165 return set (self ._wf_cache [repo_name ].keys ())
168166 # local
169167 if self .is_local_repo (repo_name ):
170- return self .list_full_paths (os .path .join (repo_name , ' .github' , ' workflows' ))
171- if repo_name .startswith ('.' ):
172- click .secho (f' { repo_name } is not a local repo and does not start with owner/repo' , fg = ' red' , err = True )
173- raise ValueError (f' { repo_name } is not a local repo and does not start with owner/repo' )
168+ return self .list_full_paths (os .path .join (repo_name , " .github" , " workflows" ))
169+ if repo_name .startswith ("." ):
170+ click .secho (f" { repo_name } is not a local repo and does not start with owner/repo" , fg = " red" , err = True )
171+ raise ValueError (f" { repo_name } is not a local repo and does not start with owner/repo" )
174172 # Remote
175173 repo = self .client .get_repo (repo_name )
176- self ._wf_cache [repo_name ] = {
177- wf .path : wf
178- for wf in repo .get_workflows ()
179- if wf .path .startswith ('.github/' )}
174+ self ._wf_cache [repo_name ] = {wf .path : wf for wf in repo .get_workflows () if wf .path .startswith (".github/" )}
180175 return set (self ._wf_cache [repo_name ].keys ())
181176
182177 def _get_workflow_file_content (self , repo_name : str , workflow_path : str ) -> Union [str , bytes ]:
@@ -185,20 +180,24 @@ def _get_workflow_file_content(self, repo_name: str, workflow_path: str) -> Unio
185180 if self .is_local_repo (repo_name ):
186181 if not os .path .exists (workflow_path ):
187182 click .echo (
188- f'f{ workflow_path } not found in workflows for repository { repo_name } , '
189- f'possible values: { workflow_paths } ' , err = True )
183+ f"f{ workflow_path } not found in workflows for repository { repo_name } , "
184+ f"possible values: { workflow_paths } " ,
185+ err = True ,
186+ )
190187 with open (workflow_path ) as f :
191188 return f .read ()
192189
193190 if workflow_path not in workflow_paths :
194191 click .echo (
195- f'f{ workflow_path } not found in workflows for repository { repo_name } , '
196- f'possible values: { workflow_paths } ' , err = True )
192+ f"f{ workflow_path } not found in workflows for repository { repo_name } , "
193+ f"possible values: { workflow_paths } " ,
194+ err = True ,
195+ )
197196 repo = self .client .get_repo (repo_name )
198197 try :
199198 workflow_content = repo .get_contents (workflow_path )
200199 except UnknownObjectException :
201- raise FileNotFoundError (f' Workflow not found in repository: { repo_name } , path: { workflow_path } ' )
200+ raise FileNotFoundError (f" Workflow not found in repository: { repo_name } , path: { workflow_path } " )
202201 return workflow_content .decoded_content
203202
204203
@@ -209,95 +208,114 @@ def _get_workflow_file_content(self, repo_name: str, workflow_path: str) -> Unio
209208
210209
211210@click .group (invoke_without_command = True )
212- @click .option ('-v' , '--verbose' , count = True ,
213- help = "Increase verbosity, can be used multiple times to increase verbosity" )
214211@click .option (
215- '--repo' , default = '.' , show_default = True , type = str ,
216- help = 'Repository to analyze, can be a local directory or a {OWNER}/{REPO} format' , )
212+ "-v" , "--verbose" , count = True , help = "Increase verbosity, can be used multiple times to increase verbosity"
213+ )
217214@click .option (
218- '--github-token' , default = os .getenv ('GITHUB_TOKEN' ), type = str , show_default = False ,
219- help = 'GitHub token to use, by default will use GITHUB_TOKEN environment variable' )
215+ "--repo" ,
216+ default = "." ,
217+ show_default = True ,
218+ type = str ,
219+ help = "Repository to analyze, can be a local directory or a {OWNER}/{REPO} format" ,
220+ )
220221@click .option (
221- '--compare-exact-versions' , is_flag = True , default = False ,
222- help = "Compare versions using all semantic and not only major versions, e.g., v1 will be upgraded to v1.2.3" , )
222+ "--github-token" ,
223+ default = os .getenv ("GITHUB_TOKEN" ),
224+ type = str ,
225+ show_default = False ,
226+ help = "GitHub token to use, by default will use GITHUB_TOKEN environment variable" ,
227+ )
228+ @click .option (
229+ "--compare-exact-versions" ,
230+ is_flag = True ,
231+ default = False ,
232+ help = "Compare versions using all semantic and not only major versions, e.g., v1 will be upgraded to v1.2.3" ,
233+ )
223234@click .pass_context
224235def cli (ctx , verbose : int , repo : str , github_token : Optional [str ], compare_exact_versions : bool ):
225236 if verbose == 1 :
226- coloredlogs .install (level = ' INFO' )
237+ coloredlogs .install (level = " INFO" )
227238 if verbose > 1 :
228- coloredlogs .install (level = ' DEBUG' )
239+ coloredlogs .install (level = " DEBUG" )
229240 ctx .ensure_object (dict )
230241 global FLAG_COMPARE_EXACT_VERSION
231242 FLAG_COMPARE_EXACT_VERSION = compare_exact_versions
232243 if not github_token :
233- click .secho (GITHUB_ACTION_NOT_PROVIDED_MSG , fg = ' yellow' , err = True )
234- ctx .obj ['gh' ] = GithubActionsTools (github_token )
235- ctx .obj [' repo' ] = repo
244+ click .secho (GITHUB_ACTION_NOT_PROVIDED_MSG , fg = " yellow" , err = True )
245+ ctx .obj ["gh" ] = GithubActionsTools (github_token )
246+ ctx .obj [" repo" ] = repo
236247 if not ctx .invoked_subcommand :
237248 ctx .invoke (update_actions )
238249
239250
240- @cli .command (help = ' Show actions required updates in repository workflows' )
251+ @cli .command (help = " Show actions required updates in repository workflows" )
241252@click .option (
242- '-u' , '--update' , is_flag = True , default = False ,
243- help = 'Update actions in workflows (For remote repos: make changes and commit, for local repos: update files' , )
253+ "-u" ,
254+ "--update" ,
255+ is_flag = True ,
256+ default = False ,
257+ help = "Update actions in workflows (For remote repos: make changes and commit, for local repos: update files" ,
258+ )
244259@click .option (
245- '-commit-msg' ,
246- default = 'chore(ci):update actions' , type = str , show_default = True ,
247- help = 'Commit msg, only relevant when remote repo' )
260+ "-commit-msg" ,
261+ default = "chore(ci):update actions" ,
262+ type = str ,
263+ show_default = True ,
264+ help = "Commit msg, only relevant when remote repo" ,
265+ )
248266@click .pass_context
249267def update_actions (ctx , update : bool , commit_msg : str ):
250- gh , repo = ctx .obj ['gh' ], ctx .obj [' repo' ]
251- workflow_names = ( gh .get_repo_workflow_names (repo ) )
268+ gh , repo = ctx .obj ["gh" ], ctx .obj [" repo" ]
269+ workflow_names = gh .get_repo_workflow_names (repo )
252270 workflow_action_versions = gh .get_repo_actions_latest (repo )
253271 max_action_name_length , max_version_length = 0 , 0
254272 for workflow_path , actions in workflow_action_versions .items ():
255273 for action in workflow_action_versions [workflow_path ]:
256274 max_action_name_length = max (max_action_name_length , len (action .name ))
257275 max_version_length = max (max_version_length , len (action .current ))
258276 for workflow_path , workflow_name in workflow_names .items ():
259- click .secho (f' { workflow_path } ({ click .style (workflow_name , fg = " bright_cyan" )} ):' , fg = ' bright_blue' )
277+ click .secho (f" { workflow_path } ({ click .style (workflow_name , fg = ' bright_cyan' )} ):" , fg = " bright_blue" )
260278 for action in workflow_action_versions [workflow_path ]:
261- s = f' \t { action .name :<{max_action_name_length + 5 }} { action .current :>{max_version_length + 2 }} '
279+ s = f" \t { action .name :<{max_action_name_length + 5 }} { action .current :>{max_version_length + 2 }} "
262280 if action .latest :
263- old_version = action .current .split ('.' )
264- new_version = action .latest .split ('.' )
265- color = ' red' if new_version [0 ] != old_version [0 ] else ' cyan'
266- s += ' ==> ' + click .style (f' { action .latest } ' , fg = color )
281+ old_version = action .current .split ("." )
282+ new_version = action .latest .split ("." )
283+ color = " red" if new_version [0 ] != old_version [0 ] else " cyan"
284+ s += " ==> " + click .style (f" { action .latest } " , fg = color )
267285 click .echo (s )
268286 if not update :
269287 return
270288 for workflow in workflow_action_versions :
271289 gh .update_actions (repo , workflow , workflow_action_versions [workflow ], commit_msg )
272290
273291
274- @cli .command (help = ' List actions in a workflow' )
275- @click .argument (' workflow' )
292+ @cli .command (help = " List actions in a workflow" )
293+ @click .argument (" workflow" )
276294@click .pass_context
277295def list_actions (ctx , workflow : str ):
278- actions = ctx .obj ['gh' ].get_workflow_actions (ctx .obj [' repo' ], workflow )
296+ actions = ctx .obj ["gh" ].get_workflow_actions (ctx .obj [" repo" ], workflow )
279297 for action in actions :
280298 click .echo (action )
281299
282300
283- @cli .command (help = ' List workflows in repository' )
301+ @cli .command (help = " List workflows in repository" )
284302@click .pass_context
285303def list_workflows (ctx ):
286- workflow_paths = ( ctx .obj ['gh' ].get_repo_workflow_names (ctx .obj [' repo' ]) )
304+ workflow_paths = ctx .obj ["gh" ].get_repo_workflow_names (ctx .obj [" repo" ] )
287305 for path , name in workflow_paths .items ():
288- click .echo (f' { path } - { name } ' )
306+ click .echo (f" { path } - { name } " )
289307
290308
291- @cli .command (help = ' Analyze organizations' )
292- @click .option ('-x' , ' --exclude' , multiple = True , default = [], help = ' Exclude orgs' )
309+ @cli .command (help = " Analyze organizations" )
310+ @click .option ("-x" , " --exclude" , multiple = True , default = [], help = " Exclude orgs" )
293311@click .pass_context
294312def analyze_orgs (ctx , exclude : Set [str ] = None ):
295- gh_client : Github = ctx .obj ['gh' ].client
313+ gh_client : Github = ctx .obj ["gh" ].client
296314 exclude = exclude or {}
297315 exclude = set (exclude )
298316 current_user = gh_client .get_user ()
299317 gh_orgs : PaginatedList [Organization ] = current_user .get_orgs ()
300- logging .info (f' Analyzing { gh_orgs .totalCount } organizations' )
318+ logging .info (f" Analyzing { gh_orgs .totalCount } organizations" )
301319 orgs : List [Org ] = []
302320 for gh_org in gh_orgs :
303321 if gh_org .login in exclude :
@@ -307,5 +325,5 @@ def analyze_orgs(ctx, exclude: Set[str] = None):
307325 print_orgs_as_csvs (orgs )
308326
309327
310- if __name__ == ' __main__' :
328+ if __name__ == " __main__" :
311329 cli (obj = {})
0 commit comments