Skip to content

Commit b65ef7f

Browse files
committed
migrate to click
1 parent 39807af commit b65ef7f

4 files changed

Lines changed: 156 additions & 135 deletions

File tree

.github/workflows/publish.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ jobs:
1616
deploy:
1717
runs-on: ubuntu-latest
1818
steps:
19-
- uses: actions/checkout@v3
19+
- uses: actions/checkout@v3.5.3
2020
- name: Set up Python
21-
uses: actions/setup-python@v4
21+
uses: actions/setup-python@v4.7.0
2222
with:
2323
python-version: '3.11'
2424
cache-dependency-path: requirements.txt
@@ -29,7 +29,7 @@ jobs:
2929
- name: Build package
3030
run: python -m build
3131
- name: Publish package
32-
uses: pypa/gh-action-pypi-publish@release/v1
32+
uses: pypa/gh-action-pypi-publish@v1.8.8
3333
with:
3434
user: __token__
3535
password: ${{ secrets.PYPI_API_TOKEN }}

gha_cli/cli.py

Lines changed: 134 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
#!/usr/bin/env python3
2-
import argparse
32
import logging
43
import os
5-
from typing import Optional, List, Set, Tuple, Dict
4+
from typing import Optional, List, Set, Tuple, Dict, Union
65

6+
import click
77
import yaml
88
from github import Github, Workflow
99

@@ -12,52 +12,61 @@
1212
logger = 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-
3215
class 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

120189
if __name__ == '__main__':
121-
run()
190+
cli(obj={})

0 commit comments

Comments
 (0)