1
1
"""Utilities for working with the GitHub API."""
2
2
3
- import asyncio
4
3
import datetime
5
4
import math
6
5
7
6
import httpx
8
7
import jwt
9
- from asgiref .sync import async_to_sync
10
8
11
9
from pydis_site import settings
12
10
13
- MAX_POLLS = 20
14
- """The maximum number of attempts at fetching a workflow run."""
11
+ MAX_RUN_TIME = datetime .timedelta (minutes = 3 )
12
+ """The maximum time allowed before an action is declared timed out."""
13
+ ISO_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ"
14
+ """The datetime string format GitHub uses."""
15
15
16
16
17
17
class ArtifactProcessingError (Exception ):
@@ -44,6 +44,12 @@ class RunTimeoutError(ArtifactProcessingError):
44
44
status = 408
45
45
46
46
47
+ class RunPendingError (ArtifactProcessingError ):
48
+ """The requested workflow run is still pending, try again later."""
49
+
50
+ status = 202
51
+
52
+
47
53
def generate_token () -> str :
48
54
"""
49
55
Generate a JWT token to access the GitHub API.
@@ -66,7 +72,7 @@ def generate_token() -> str:
66
72
)
67
73
68
74
69
- async def authorize (owner : str , repo : str ) -> httpx .AsyncClient :
75
+ def authorize (owner : str , repo : str ) -> httpx .Client :
70
76
"""
71
77
Get an access token for the requested repository.
72
78
@@ -75,15 +81,15 @@ async def authorize(owner: str, repo: str) -> httpx.AsyncClient:
75
81
- POST <app_access_token> to get a token to access the given app
76
82
- GET installation/repositories and check if the requested one is part of those
77
83
"""
78
- client = httpx .AsyncClient (
84
+ client = httpx .Client (
79
85
base_url = settings .GITHUB_API ,
80
86
headers = {"Authorization" : f"bearer { generate_token ()} " },
81
87
timeout = settings .TIMEOUT_PERIOD ,
82
88
)
83
89
84
90
try :
85
91
# Get a list of app installations we have access to
86
- apps = await client .get ("app/installations" )
92
+ apps = client .get ("app/installations" )
87
93
apps .raise_for_status ()
88
94
89
95
for app in apps .json ():
@@ -92,11 +98,11 @@ async def authorize(owner: str, repo: str) -> httpx.AsyncClient:
92
98
continue
93
99
94
100
# Get the repositories of the specified owner
95
- app_token = await client .post (app ["access_tokens_url" ])
101
+ app_token = client .post (app ["access_tokens_url" ])
96
102
app_token .raise_for_status ()
97
103
client .headers ["Authorization" ] = f"bearer { app_token .json ()['token' ]} "
98
104
99
- repos = await client .get ("installation/repositories" )
105
+ repos = client .get ("installation/repositories" )
100
106
repos .raise_for_status ()
101
107
102
108
# Search for the request repository
@@ -111,44 +117,39 @@ async def authorize(owner: str, repo: str) -> httpx.AsyncClient:
111
117
112
118
except BaseException as e :
113
119
# Close the client if we encountered an unexpected exception
114
- await client .aclose ()
120
+ client .close ()
115
121
raise e
116
122
117
123
118
- async def wait_for_run (client : httpx .AsyncClient , run : dict ) -> str :
119
- """Wait for the provided `run` to finish, and return the URL to its artifacts."""
120
- polls = 0
121
- while polls <= MAX_POLLS :
122
- if run ["status" ] != "completed" :
123
- # The action is still processing, wait a bit longer
124
- polls += 1
125
- await asyncio .sleep (10 )
126
-
127
- elif run ["conclusion" ] != "success" :
128
- # The action failed, or did not run
129
- raise ActionFailedError (f"The requested workflow ended with: { run ['conclusion' ]} " )
124
+ def check_run_status (run : dict ) -> str :
125
+ """Check if the provided run has been completed, otherwise raise an exception."""
126
+ created_at = datetime .datetime .strptime (run ["created_at" ], ISO_FORMAT_STRING )
127
+ run_time = datetime .datetime .now () - created_at
130
128
129
+ if run ["status" ] != "completed" :
130
+ if run_time <= MAX_RUN_TIME :
131
+ raise RunPendingError (
132
+ f"The requested run is still pending. It was created "
133
+ f"{ run_time .seconds // 60 } :{ run_time .seconds % 60 :>02} minutes ago."
134
+ )
131
135
else :
132
- # The desired action was found, and it ended successfully
133
- return run ["artifacts_url" ]
136
+ raise RunTimeoutError ("The requested workflow was not ready in time." )
134
137
135
- run = await client . get ( run [ "url" ])
136
- run . raise_for_status ()
137
- run = run . json ( )
138
+ if run [ "conclusion" ] != "success" :
139
+ # The action failed, or did not run
140
+ raise ActionFailedError ( f"The requested workflow ended with: { run [ 'conclusion' ] } " )
138
141
139
- raise RunTimeoutError ("The requested workflow was not ready in time." )
142
+ # The requested action is ready
143
+ return run ["artifacts_url" ]
140
144
141
145
142
- @async_to_sync
143
- async def get_artifact (
144
- owner : str , repo : str , sha : str , action_name : str , artifact_name : str
145
- ) -> str :
146
+ def get_artifact (owner : str , repo : str , sha : str , action_name : str , artifact_name : str ) -> str :
146
147
"""Get a download URL for a build artifact."""
147
- client = await authorize (owner , repo )
148
+ client = authorize (owner , repo )
148
149
149
150
try :
150
151
# Get the workflow runs for this repository
151
- runs = await client .get (f"/repos/{ owner } /{ repo } /actions/runs" , params = {"per_page" : 100 })
152
+ runs = client .get (f"/repos/{ owner } /{ repo } /actions/runs" , params = {"per_page" : 100 })
152
153
runs .raise_for_status ()
153
154
runs = runs .json ()
154
155
@@ -161,16 +162,16 @@ async def get_artifact(
161
162
"Could not find a run matching the provided settings in the previous hundred runs."
162
163
)
163
164
164
- # Wait for the workflow to finish
165
- url = await wait_for_run ( client , run )
165
+ # Check the workflow status
166
+ url = check_run_status ( run )
166
167
167
168
# Filter the artifacts, and return the download URL
168
- artifacts = await client .get (url )
169
+ artifacts = client .get (url )
169
170
artifacts .raise_for_status ()
170
171
171
172
for artifact in artifacts .json ()["artifacts" ]:
172
173
if artifact ["name" ] == artifact_name :
173
- data = await client .get (artifact ["archive_download_url" ])
174
+ data = client .get (artifact ["archive_download_url" ])
174
175
if data .status_code == 302 :
175
176
return str (data .next_request .url )
176
177
@@ -180,4 +181,4 @@ async def get_artifact(
180
181
raise NotFoundError ("Could not find an artifact matching the provided name." )
181
182
182
183
finally :
183
- await client .aclose ()
184
+ client .close ()
0 commit comments