11#!/usr/bin/env python3
2- import argparse
32import logging
43import os
5- from typing import Optional , List , Set , Tuple , Dict
4+ from typing import Optional , List , Set , Tuple , Dict , Union
65
6+ import click
77import yaml
88from github import Github , Workflow
99
1212logger = logging .getLogger ()
1313
1414
15- def parse_args () -> argparse .Namespace :
16- parser = argparse .ArgumentParser ()
17- parser .add_argument ('repo' , help = 'Repository to analyze' )
18- parser .add_argument (
19- '--github-token' , dest = 'github_token' , default = None ,
20- help = 'GitHub token to use, by default will use GITHUB_TOKEN environment variable' )
21- subcommands = parser .add_subparsers (dest = 'command' , required = True )
22- list_wfs_cmd_parser = subcommands .add_parser ('list-workflows' , help = 'List github workflows' )
23- list_wfs_cmd_parser .add_argument ('--all' , action = 'store_true' , dest = 'external_workflows' ,
24- help = 'Show external workflows as well' )
25- list_actions_cmd_parser = subcommands .add_parser ('list-actions' , help = 'List actions in a workflow' )
26- list_actions_cmd_parser .add_argument ('workflow_path' , help = 'Workflow path' )
27- update_gha_cmd_parser = subcommands .add_parser ('update' , help = 'Update actions in github workflows' )
28- update_gha_cmd_parser .add_argument ("--dry-run" , action = 'store_true' , dest = 'dryrun' , help = 'List updates only' )
29- return parser .parse_args ()
30-
31-
3215class GithubActionsTools (object ):
3316 workflows : dict [str , dict [str , Workflow ]] = dict () # repo_name -> [path -> workflow]
34- actions : dict [str , str ] = dict () # action_name -> latest_release_tag
17+ actions_latest_release : dict [str , str ] = dict () # action_name@current_release -> latest_release_tag
3518
3619 def __init__ (self , github_token : Optional [str ]):
3720 github_token = github_token or os .getenv ('GITHUB_TOKEN' )
3821 if github_token is None :
3922 raise ValueError ('GITHUB_TOKEN must be set' )
4023 self .client = Github (login_or_token = github_token )
4124
42- def get_github_workflows (self , repo_name : str , external_workflows : bool = False ) -> List [Workflow ]:
43- if repo_name in self .workflows :
44- return list (self .workflows [repo_name ].values ())
45- repo = self .client .get_repo (repo_name )
46- workflows = list (repo .get_workflows ())
47- if not external_workflows :
48- workflows = list (filter (lambda item : item .path .startswith ('.github/' ), workflows ))
49- self .workflows [repo_name ] = {wf .path : wf for wf in workflows }
50- return workflows
25+ def is_local_repo (self , repo_name : str ) -> bool :
26+ return os .path .exists (repo_name )
5127
52- def get_workflow_actions (self , repo_name : str , workflow_path : str ) -> Set [str ]:
28+ @staticmethod
29+ def list_full_paths (path : str ) -> set [str ]:
30+ return {os .path .join (path , file )
31+ for file in os .listdir (path )
32+ if file .endswith (('.yml' , '.yaml' ))}
5333
54- self .get_github_workflows (repo_name )
55- if workflow_path not in self .workflows [repo_name ]:
56- raise ValueError (f'f{ workflow_path } not found in workflows for repository { repo_name } , '
57- f'possible values: { self .workflows [repo_name ].keys ()} ' )
34+ def get_github_workflows (self , repo_name : str ) -> Set [str ]:
35+ if repo_name in self .workflows :
36+ return set (self .workflows [repo_name ].keys ())
37+ # local
38+ if self .is_local_repo (repo_name ):
39+ return self .list_full_paths (os .path .join (repo_name , '.github' , 'workflows' ))
40+ # Remote
41+ repo = self .client .get_repo (repo_name )
42+ self .workflows [repo_name ] = {
43+ wf .path : wf
44+ for wf in repo .get_workflows ()
45+ if wf .path .startswith ('.github/' )}
46+ return set (self .workflows [repo_name ].keys ())
47+
48+ def _get_workflow_content (self , repo_name : str , workflow_path : str ) -> Union [str , bytes ]:
49+ workflow_paths = self .get_github_workflows (repo_name )
50+
51+ if self .is_local_repo (repo_name ):
52+ if not os .path .exists (workflow_path ):
53+ click .echo (
54+ f'f{ workflow_path } not found in workflows for repository { repo_name } , '
55+ f'possible values: { workflow_paths } ' , err = True )
56+ with open (workflow_path ) as f :
57+ return f .read ()
58+
59+ if workflow_path not in workflow_paths :
60+ click .echo (
61+ f'f{ workflow_path } not found in workflows for repository { repo_name } , '
62+ f'possible values: { workflow_paths } ' , err = True )
5863 repo = self .client .get_repo (repo_name )
5964 workflow_content = repo .get_contents (workflow_path )
60- workflow = yaml .load (workflow_content .decoded_content , Loader = yaml .CLoader )
65+ return workflow_content .decoded_content
66+
67+ def get_workflow_actions (self , repo_name : str , workflow_path : str ) -> Set [str ]:
68+ workflow_content = self ._get_workflow_content (repo_name , workflow_path )
69+ workflow = yaml .load (workflow_content , Loader = yaml .CLoader )
6170 res = set ()
6271 for job in workflow .get ('jobs' , dict ()).values ():
6372 for step in job .get ('steps' , list ()):
@@ -78,44 +87,104 @@ def check_for_updates(self, action_name: str) -> Optional[str]:
7887 return latest_release .tag_name if latest_release .tag_name != current_version else None
7988
8089 def get_repo_actions_latest (self , repo_name : str ) -> Dict [str , List [Tuple [str , str , Optional [str ]]]]:
81- workflows = self .get_github_workflows (repo_name )
90+ workflow_paths = self .get_github_workflows (repo_name )
8291 res = dict ()
83- for workflow in workflows :
84- if not workflow .path .startswith ('.github' ):
85- continue
86- res [workflow .path ] = list ()
87- actions = self .get_workflow_actions (repo_name , workflow .path )
92+ for path in workflow_paths :
93+ res [path ] = list ()
94+ actions = self .get_workflow_actions (repo_name , path )
8895 for action in actions :
8996 if '@' not in action :
9097 continue
9198 action_name , curr_version = action .split ('@' )
92- latest = self .check_for_updates (action )
93- res [workflow .path ].append ((action_name , curr_version , latest ))
99+ if action not in self .actions_latest_release :
100+ latest = self .check_for_updates (action )
101+ self .actions_latest_release [action ] = latest
102+ else :
103+ latest = self .actions_latest_release [action ]
104+ res [path ].append ((action_name , curr_version , latest ))
105+ return res
106+
107+ def update_actions (
108+ self , repo_name : str , workflow_path : str ,
109+ updates : List [Tuple [str , str , Optional [str ]]],
110+ commit_msg : str ,
111+ ) -> None :
112+ workflow_content = self ._get_workflow_content (repo_name , workflow_path )
113+ if isinstance (workflow_content , bytes ):
114+ workflow_content = workflow_content .decode ()
115+ for update in updates :
116+ if update [2 ] is None :
117+ continue
118+ current_action = f'{ update [0 ]} @{ update [1 ]} '
119+ latest_action = f'{ update [0 ]} @{ update [2 ]} '
120+ workflow_content = workflow_content .replace (current_action , latest_action )
121+ self ._update_workflow_content (repo_name , workflow_path , workflow_content , commit_msg )
122+
123+ def _update_workflow_content (
124+ self , repo_name : str , workflow_path : str , workflow_content : str , commit_msg : str ):
125+ if self .is_local_repo (repo_name ):
126+ with open (workflow_path , 'w' ) as f :
127+ f .write (workflow_content )
128+ return
129+ # remote
130+ repo = self .client .get_repo (repo_name )
131+ current_content = repo .get_contents (workflow_path )
132+ res = repo .update_file (
133+ workflow_path ,
134+ commit_msg ,
135+ workflow_content ,
136+ current_content .sha ,
137+ )
94138 return res
95139
96140
97- def run ():
98- args = parse_args ()
99- gh = GithubActionsTools (args .github_token )
100- gh .check_for_updates ('actions/checkout@v2' )
101- if args .command == 'list-workflows' :
102- workflows = gh .get_github_workflows (args .repo )
103- for workflow in workflows :
104- print (f'{ workflow .path } :{ workflow .name } ' )
105- elif args .command == 'list-actions' :
106- actions = gh .get_workflow_actions (args .repo , args .workflow_path )
107- for action in actions :
108- print (action )
109- elif args .command == 'update' :
110- action_versions = gh .get_repo_actions_latest (args .repo )
111- for wf in action_versions :
112- print (f'{ wf } :' )
113- for action in action_versions [wf ]:
114- s = f' { action [0 ]} @ { action [1 ]} '
115- if action [2 ]:
116- s += f' \t ==> { action [2 ]} '
117- print (s )
141+ @click .group ()
142+ @click .option ('-repo' , default = '.' , help = 'Repository to analyze' )
143+ @click .option ('--github-token' , default = os .getenv ('GITHUB_TOKEN' ),
144+ help = 'GitHub token to use, by default will use GITHUB_TOKEN environment variable' )
145+ @click .pass_context
146+ def cli (ctx , repo : str , github_token : str ):
147+ ctx .obj ['gh' ] = GithubActionsTools (github_token )
148+ ctx .obj ['repo' ] = repo
149+
150+
151+ @cli .command (help = 'List actions in a workflow' )
152+ @click .option ('--dry-run' , default = False , help = 'Do not update, list only' )
153+ @click .option ('-commit-msg' , default = 'Update github-actions' ,
154+ help = 'Commit msg, only relevant when remote repo' )
155+ @click .pass_context
156+ def update_actions (ctx , dry_run : bool , commit_msg : str ):
157+ gh , repo = ctx .obj ['gh' ], ctx .obj ['repo' ]
158+ action_versions = gh .get_repo_actions_latest (repo )
159+ for wf in action_versions :
160+ click .echo (f'{ wf } :' )
161+ for action in action_versions [wf ]:
162+ s = f' { action [0 ]} @ { action [1 ]} '
163+ if action [2 ]:
164+ s += f' \t ==> { action [2 ]} '
165+ click .echo (s )
166+ if dry_run :
167+ return
168+ for wf in action_versions :
169+ gh .update_actions (repo , wf , action_versions [wf ], commit_msg )
170+
171+
172+ @cli .command (help = 'List actions in a workflow' )
173+ @click .argument ('workflow' )
174+ @click .pass_context
175+ def list_actions (ctx , workflow : str ):
176+ actions = ctx .obj ['gh' ].get_workflow_actions (ctx .obj ['repo' ], workflow )
177+ for action in actions :
178+ click .echo (action )
179+
180+
181+ @cli .command (help = 'List workflows in repository' )
182+ @click .pass_context
183+ def list_workflows (ctx ):
184+ workflow_paths = ctx .obj ['gh' ].get_github_workflows (ctx .obj ['repo' ])
185+ for path in workflow_paths :
186+ click .echo (f'{ path } ' )
118187
119188
120189if __name__ == '__main__' :
121- run ( )
190+ cli ( obj = {} )
0 commit comments