22import logging
33import os
44from collections import namedtuple
5- from datetime import datetime
6- from typing import Optional , List , Set , Dict , Union , Any , Tuple
5+ from datetime import datetime , timezone
6+ from typing import Any , Dict , List , Optional , Set , Tuple , Union
77
88import click
99import coloredlogs
1010import yaml
11- from github import Github , UnknownObjectException , GitRelease
11+ from github import Github , GitRelease , UnknownObjectException
1212from github .Organization import Organization
1313from github .PaginatedList import PaginatedList
1414from github .Repository import Repository
2323
2424def _is_sha (current_version : str ) -> bool :
2525 """Check if the current version is a SHA (40 characters long)"""
26- return len (current_version ) == 40 and all (c in "0123456789abcdef" for c in current_version .lower ())
26+ return len (current_version ) == 40 and all (
27+ c in "0123456789abcdef" for c in current_version .lower ()
28+ )
2729
2830
2931class GithubActionsTools (object ):
30- _wf_cache : dict [str , dict [str , Any ]] = dict () # repo_name -> [path -> workflow/yaml]
31- __actions_latest_release : dict [str , Tuple [str , datetime ]] = dict () # action_name@current_release -> latest_release_tag
32+ _wf_cache : dict [str , dict [str , Any ]] = (
33+ dict ()
34+ ) # repo_name -> [path -> workflow/yaml]
35+ __actions_latest_release : dict [str , Tuple [str , datetime ]] = (
36+ dict ()
37+ ) # action_name@current_release -> latest_release_tag
3238
3339 def __init__ (self , github_token : str , update_major_version_only : bool = False ):
3440 self .client = Github (login_or_token = github_token )
@@ -73,7 +79,7 @@ def _compare_versions(self, orig_v1: str, orig_v2: str) -> int:
7379
7480 def get_action_latest_release (self , uses_tag_value : str ) -> Optional [str ]:
7581 """Check whether an action has an update, and return the latest version if it does syntax for uses:
76- https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_iduses
82+ https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_iduses
7783 """
7884 if "@" not in uses_tag_value :
7985 return None
@@ -82,12 +88,24 @@ def get_action_latest_release(self, uses_tag_value: str) -> Optional[str]:
8288 latest_release = self .__actions_latest_release [action_name ]
8389 logging .debug (f"Found in cache { action_name } : { latest_release } " )
8490 if _is_sha (current_version ):
85- logging .debug (f"Current version for { action_name } is a SHA: { current_version } , checking whether latest release is newer" )
86- if latest_release [1 ] > datetime .now ():
91+ logging .debug (
92+ f"Current version for { action_name } is a SHA: { current_version } , checking whether latest release is newer"
93+ )
94+ now = datetime .now (timezone .utc )
95+ release_time = latest_release [1 ]
96+ if release_time .tzinfo is None :
97+ release_time = release_time .replace (tzinfo = timezone .utc )
98+ if release_time > now :
8799 return latest_release [0 ]
88- return latest_release [0 ] if self ._compare_versions (latest_release [0 ], current_version ) > 0 else None
100+ return (
101+ latest_release [0 ]
102+ if self ._compare_versions (latest_release [0 ], current_version ) > 0
103+ else None
104+ )
89105
90- logging .debug (f"Checking for updates for { action_name } @{ current_version } : Getting repo { action_name } " )
106+ logging .debug (
107+ f"Checking for updates for { action_name } @{ current_version } : Getting repo { action_name } "
108+ )
91109 try :
92110 repo : Repository = self ._get_repo (action_name )
93111 except ValueError as e :
@@ -97,36 +115,54 @@ def get_action_latest_release(self, uses_tag_value: str) -> Optional[str]:
97115 try :
98116 latest_release = repo .get_latest_release ()
99117 if latest_release is None :
100- logging .warning (f"No latest release found for repository: { action_name } " )
118+ logging .warning (
119+ f"No latest release found for repository: { action_name } "
120+ )
101121 return None
102122 except UnknownObjectException :
103123 logging .warning (f"No releases found for repository: { action_name } " )
104124
105125 if _is_sha (current_version ):
106126 logging .debug (
107- f"Current version for { action_name } is a SHA: { current_version } , checking whether latest release is newer" )
127+ f"Current version for { action_name } is a SHA: { current_version } , checking whether latest release is newer"
128+ )
108129 current_version_commit = repo .get_commit (current_version )
109- if latest_release .last_modified_datetime > current_version_commit .last_modified_datetime :
110- self .__actions_latest_release [action_name ] = self ._fix_version (latest_release .tag_name ), latest_release .last_modified_datetime
130+ if (
131+ latest_release .last_modified_datetime
132+ > current_version_commit .last_modified_datetime
133+ ):
134+ self .__actions_latest_release [action_name ] = (
135+ self ._fix_version (latest_release .tag_name ),
136+ latest_release .last_modified_datetime ,
137+ )
111138 return latest_release .tag_name
112139 if self ._compare_versions (latest_release .tag_name , current_version ) > 0 :
113- self .__actions_latest_release [action_name ] = self ._fix_version (latest_release .tag_name ), latest_release .last_modified_datetime
140+ self .__actions_latest_release [action_name ] = (
141+ self ._fix_version (latest_release .tag_name ),
142+ latest_release .last_modified_datetime ,
143+ )
114144 return latest_release .tag_name
115145 return None
116146
117147 @staticmethod
118148 def is_local_repo (repo_name : str ) -> bool :
119- return os .path .exists (repo_name ) and os .path .exists (os .path .join (repo_name , ".git" ))
149+ return os .path .exists (repo_name ) and os .path .exists (
150+ os .path .join (repo_name , ".git" )
151+ )
120152
121153 @staticmethod
122154 def list_full_paths (path : str ) -> set [str ]:
123155 if not os .path .exists (path ):
124156 return set ()
125- return {os .path .join (path , file ) for file in os .listdir (path ) if file .endswith ((".yml" , ".yaml" ))}
157+ return {
158+ os .path .join (path , file )
159+ for file in os .listdir (path )
160+ if file .endswith ((".yml" , ".yaml" ))
161+ }
126162
127163 def get_workflow_action_names (self , repo_name : str , workflow_path : str ) -> Set [str ]:
128164 workflow_content = self ._get_workflow_file_content (repo_name , workflow_path )
129- workflow = yaml .load (workflow_content , Loader = yaml .CLoader )
165+ workflow = yaml .safe_load (workflow_content , Loader = yaml .CLoader )
130166 res = set ()
131167 for job in workflow .get ("jobs" , dict ()).values ():
132168 for step in job .get ("steps" , list ()):
@@ -136,27 +172,33 @@ def get_workflow_action_names(self, repo_name: str, workflow_path: str) -> Set[s
136172
137173 def get_repo_actions_latest (self , repo_name : str ) -> Dict [str , List [ActionVersion ]]:
138174 workflow_paths = self ._get_github_workflow_filenames (repo_name )
139- res :Dict [str , List [ActionVersion ]] = dict ()
140- actions_per_path :Dict [str ,Set [str ]]= dict () # actions without version, e.g., actions/checkout
175+ res : Dict [str , List [ActionVersion ]] = dict ()
176+ actions_per_path : Dict [str , Set [str ]] = (
177+ dict ()
178+ ) # actions without version, e.g., actions/checkout
141179 for path in workflow_paths :
142180 res [path ] = list ()
143181 actions = self .get_workflow_action_names (repo_name , path )
144182 for action in actions :
145- actions_per_path .setdefault (path ,set ()).add (action )
183+ actions_per_path .setdefault (path , set ()).add (action )
146184 all_actions_no_version = set ()
147185 for path , actions in actions_per_path .items ():
148186 for action in actions :
149187 if "@" not in action :
150188 continue
151189 all_actions_no_version .add (action .split ("@" )[0 ])
152- logging .info (f"Found { len (all_actions_no_version )} actions in workflows: { ", " .join (all_actions_no_version )} " )
190+ logging .info (
191+ f"Found { len (all_actions_no_version )} actions in workflows: { ', ' .join (all_actions_no_version )} "
192+ )
153193 for path , actions in actions_per_path .items ():
154194 for action in actions :
155195 if "@" not in action :
156196 continue
157197 action_name , curr_version = action .split ("@" )
158198 latest_version = self .get_action_latest_release (action )
159- res [path ].append (ActionVersion (action_name , curr_version , latest_version ))
199+ res [path ].append (
200+ ActionVersion (action_name , curr_version , latest_version )
201+ )
160202 return res
161203
162204 def get_repo_workflow_names (self , repo_name : str ) -> Dict [str , str ]:
@@ -165,18 +207,18 @@ def get_repo_workflow_names(self, repo_name: str) -> Dict[str, str]:
165207 for path in workflow_paths :
166208 try :
167209 content = self ._get_workflow_file_content (repo_name , path )
168- yaml_content = yaml .load (content , Loader = yaml .CLoader )
210+ yaml_content = yaml .safe_load (content , Loader = yaml .CLoader )
169211 res [path ] = yaml_content .get ("name" , path )
170212 except FileNotFoundError as ex :
171213 logging .warning (ex )
172214 return res
173215
174216 def update_actions (
175- self ,
176- repo_name : str ,
177- workflow_path : str ,
178- updates : List [ActionVersion ],
179- commit_msg : str ,
217+ self ,
218+ repo_name : str ,
219+ workflow_path : str ,
220+ updates : List [ActionVersion ],
221+ commit_msg : str ,
180222 ) -> None :
181223 workflow_content = self ._get_workflow_file_content (repo_name , workflow_path )
182224 if isinstance (workflow_content , bytes ):
@@ -187,9 +229,13 @@ def update_actions(
187229 current_action = f"{ update .name } @{ update .current } "
188230 latest_action = f"{ update .name } @{ update .latest } "
189231 workflow_content = workflow_content .replace (current_action , latest_action )
190- self ._update_workflow_content (repo_name , workflow_path , workflow_content , commit_msg )
232+ self ._update_workflow_content (
233+ repo_name , workflow_path , workflow_content , commit_msg
234+ )
191235
192- def _update_workflow_content (self , repo_name : str , workflow_path : str , workflow_content : str , commit_msg : str ):
236+ def _update_workflow_content (
237+ self , repo_name : str , workflow_path : str , workflow_content : str , commit_msg : str
238+ ):
193239 if self .is_local_repo (repo_name ):
194240 with open (workflow_path , "w" ) as f :
195241 f .write (workflow_content )
@@ -205,7 +251,9 @@ def _update_workflow_content(self, repo_name: str, workflow_path: str, workflow_
205251 workflow_content ,
206252 current_content .sha ,
207253 )
208- click .secho (f"Committed changes to workflow in { repo_name } :{ workflow_path } " , fg = "cyan" )
254+ click .secho (
255+ f"Committed changes to workflow in { repo_name } :{ workflow_path } " , fg = "cyan"
256+ )
209257 return res
210258
211259 def _get_github_workflow_filenames (self , repo_name : str ) -> Set [str ]:
@@ -215,14 +263,24 @@ def _get_github_workflow_filenames(self, repo_name: str) -> Set[str]:
215263 if self .is_local_repo (repo_name ):
216264 return self .list_full_paths (os .path .join (repo_name , ".github" , "workflows" ))
217265 if repo_name .startswith ("." ):
218- click .secho (f"{ repo_name } is not a local repo and does not start with owner/repo" , fg = "red" , err = True )
219- raise ValueError (f"{ repo_name } is not a local repo and does not start with owner/repo" )
266+ click .secho (
267+ f"{ repo_name } is not a local repo and does not start with owner/repo" ,
268+ fg = "red" ,
269+ err = True ,
270+ )
271+ raise ValueError (
272+ f"{ repo_name } is not a local repo and does not start with owner/repo"
273+ )
220274 # Remote
221275 repo : Repository = self ._get_repo (repo_name )
222- self ._wf_cache [repo_name ] = {wf .path : wf for wf in repo .get_workflows () if wf .path .startswith (".github/" )}
276+ self ._wf_cache [repo_name ] = {
277+ wf .path : wf for wf in repo .get_workflows () if wf .path .startswith (".github/" )
278+ }
223279 return set (self ._wf_cache [repo_name ].keys ())
224280
225- def _get_workflow_file_content (self , repo_name : str , workflow_path : str ) -> Union [str , bytes ]:
281+ def _get_workflow_file_content (
282+ self , repo_name : str , workflow_path : str
283+ ) -> Union [str , bytes ]:
226284 workflow_paths = self ._get_github_workflow_filenames (repo_name )
227285
228286 if self .is_local_repo (repo_name ):
@@ -245,7 +303,9 @@ def _get_workflow_file_content(self, repo_name: str, workflow_path: str) -> Unio
245303 repo : Repository = self ._get_repo (repo_name )
246304 workflow_content = repo .get_contents (workflow_path )
247305 except UnknownObjectException :
248- raise FileNotFoundError (f"Workflow not found in repository: { repo_name } , path: { workflow_path } " )
306+ raise FileNotFoundError (
307+ f"Workflow not found in repository: { repo_name } , path: { workflow_path } "
308+ )
249309 return workflow_content .decoded_content
250310
251311
@@ -257,7 +317,10 @@ def _get_workflow_file_content(self, repo_name: str, workflow_path: str) -> Unio
257317
258318@click .group (invoke_without_command = True )
259319@click .option (
260- "-v" , "--verbose" , count = True , help = "Increase verbosity, can be used multiple times to increase verbosity"
320+ "-v" ,
321+ "--verbose" ,
322+ count = True ,
323+ help = "Increase verbosity, can be used multiple times to increase verbosity" ,
261324)
262325@click .option (
263326 "--repo" ,
@@ -288,7 +351,9 @@ def cli(ctx, verbose: int, repo: str, github_token: Optional[str], major_only: b
288351 coloredlogs .install (level = "DEBUG" )
289352 ctx .ensure_object (dict )
290353 repo_name = os .getcwd () if repo == "." else repo
291- click .secho (f"GitHub Actions CLI, scanning repo in { repo_name } " , fg = "green" , bold = True )
354+ click .secho (
355+ f"GitHub Actions CLI, scanning repo in { repo_name } " , fg = "green" , bold = True
356+ )
292357 if not github_token :
293358 click .secho (GITHUB_ACTION_NOT_PROVIDED_MSG , fg = "yellow" , err = True )
294359 ctx .obj ["gh" ] = GithubActionsTools (github_token , major_only )
@@ -316,15 +381,20 @@ def cli(ctx, verbose: int, repo: str, github_token: Optional[str], major_only: b
316381def update_actions (ctx , update : bool , commit_msg : str ) -> None :
317382 gh , repo_name = ctx .obj ["gh" ], ctx .obj ["repo" ]
318383 workflow_names = gh .get_repo_workflow_names (repo_name )
319- logging .info (f"Found { len (workflow_names )} workflows in { repo_name } : { ', ' .join (list (workflow_names .keys ()))} " )
384+ logging .info (
385+ f"Found { len (workflow_names )} workflows in { repo_name } : { ', ' .join (list (workflow_names .keys ()))} "
386+ )
320387 workflow_action_versions = gh .get_repo_actions_latest (repo_name )
321388 max_action_name_length , max_version_length = 0 , 0
322389 for workflow_path , actions in workflow_action_versions .items ():
323390 for action in workflow_action_versions [workflow_path ]:
324391 max_action_name_length = max (max_action_name_length , len (action .name ))
325392 max_version_length = max (max_version_length , len (action .current ))
326393 for workflow_path , workflow_name in workflow_names .items ():
327- click .secho (f"{ workflow_path } ({ click .style (workflow_name , fg = 'bright_cyan' )} ):" , fg = "bright_blue" )
394+ click .secho (
395+ f"{ workflow_path } ({ click .style (workflow_name , fg = 'bright_cyan' )} ):" ,
396+ fg = "bright_blue" ,
397+ )
328398 for action in workflow_action_versions [workflow_path ]:
329399 s = f"\t { action .name :<{max_action_name_length + 5 }} { action .current :>{max_version_length + 2 }} "
330400 if action .latest :
@@ -336,7 +406,9 @@ def update_actions(ctx, update: bool, commit_msg: str) -> None:
336406 if not update :
337407 return
338408 for workflow in workflow_action_versions :
339- gh .update_actions (repo_name , workflow , workflow_action_versions [workflow ], commit_msg )
409+ gh .update_actions (
410+ repo_name , workflow , workflow_action_versions [workflow ], commit_msg
411+ )
340412
341413
342414@cli .command (help = "List actions in a workflow" )
0 commit comments