Skip to content

Commit 7fef7c5

Browse files
Fix: Comparison can be timezone aware
Why do we need this change? ======================= Depending on the setup and return from github, the date/time can be timezone aware. This leads to an error TypeError: can't compare offset-naive and offset-aware datetimes that prevents updating repo's appropriately. What effects does this change have? ======================= * Linting changes * Ensure that comparison will work for timezone and non timezone aware datetime returned from github * switch to safe_load for yaml * fix unescaped logging line that was breaking various linters
1 parent c0dd752 commit 7fef7c5

File tree

1 file changed

+115
-43
lines changed

1 file changed

+115
-43
lines changed

gha_cli/cli.py

Lines changed: 115 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
import logging
33
import os
44
from 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

88
import click
99
import coloredlogs
1010
import yaml
11-
from github import Github, UnknownObjectException, GitRelease
11+
from github import Github, GitRelease, UnknownObjectException
1212
from github.Organization import Organization
1313
from github.PaginatedList import PaginatedList
1414
from github.Repository import Repository
@@ -23,12 +23,18 @@
2323

2424
def _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

2931
class 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
316381
def 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

Comments
 (0)