Skip to content

Commit fe4def7

Browse files
Add GitHub Artifact API View
Adds an API route to fetch GitHub build artifacts through a GitHub app. Signed-off-by: Hassan Abouelela <[email protected]>
1 parent 7511c6d commit fe4def7

File tree

8 files changed

+609
-4
lines changed

8 files changed

+609
-4
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,6 @@ log.*
132132

133133
# Mac/OSX
134134
.DS_Store
135+
136+
# Private keys
137+
*.pem

poetry.lock

Lines changed: 66 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pydis_site/apps/api/github_utils.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Utilities for working with the GitHub API."""
2+
3+
import asyncio
4+
import datetime
5+
import math
6+
7+
import httpx
8+
import jwt
9+
from asgiref.sync import async_to_sync
10+
11+
from pydis_site import settings
12+
13+
MAX_POLLS = 20
14+
"""The maximum number of attempts at fetching a workflow run."""
15+
16+
17+
class ArtifactProcessingError(Exception):
18+
"""Base exception for other errors related to processing a GitHub artifact."""
19+
20+
status: int
21+
22+
23+
class UnauthorizedError(ArtifactProcessingError):
24+
"""The application does not have permission to access the requested repo."""
25+
26+
status = 401
27+
28+
29+
class NotFoundError(ArtifactProcessingError):
30+
"""The requested resource could not be found."""
31+
32+
status = 404
33+
34+
35+
class ActionFailedError(ArtifactProcessingError):
36+
"""The requested workflow did not conclude successfully."""
37+
38+
status = 400
39+
40+
41+
class RunTimeoutError(ArtifactProcessingError):
42+
"""The requested workflow run was not ready in time."""
43+
44+
status = 408
45+
46+
47+
def generate_token() -> str:
48+
"""
49+
Generate a JWT token to access the GitHub API.
50+
51+
The token is valid for roughly 10 minutes after generation, before the API starts
52+
returning 401s.
53+
54+
Refer to:
55+
https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app
56+
"""
57+
now = datetime.datetime.now()
58+
return jwt.encode(
59+
{
60+
"iat": math.floor((now - datetime.timedelta(seconds=60)).timestamp()), # Issued at
61+
"exp": math.floor((now + datetime.timedelta(minutes=9)).timestamp()), # Expires at
62+
"iss": settings.GITHUB_OAUTH_APP_ID,
63+
},
64+
settings.GITHUB_OAUTH_KEY,
65+
algorithm="RS256"
66+
)
67+
68+
69+
async def authorize(owner: str, repo: str) -> httpx.AsyncClient:
70+
"""
71+
Get an access token for the requested repository.
72+
73+
The process is roughly:
74+
- GET app/installations to get a list of all app installations
75+
- POST <app_access_token> to get a token to access the given app
76+
- GET installation/repositories and check if the requested one is part of those
77+
"""
78+
client = httpx.AsyncClient(
79+
base_url=settings.GITHUB_API,
80+
headers={"Authorization": f"bearer {generate_token()}"},
81+
timeout=settings.TIMEOUT_PERIOD,
82+
)
83+
84+
try:
85+
# Get a list of app installations we have access to
86+
apps = await client.get("app/installations")
87+
apps.raise_for_status()
88+
89+
for app in apps.json():
90+
# Look for an installation with the right owner
91+
if app["account"]["login"] != owner:
92+
continue
93+
94+
# Get the repositories of the specified owner
95+
app_token = await client.post(app["access_tokens_url"])
96+
app_token.raise_for_status()
97+
client.headers["Authorization"] = f"bearer {app_token.json()['token']}"
98+
99+
repos = await client.get("installation/repositories")
100+
repos.raise_for_status()
101+
102+
# Search for the request repository
103+
for accessible_repo in repos.json()["repositories"]:
104+
if accessible_repo["name"] == repo:
105+
# We've found the correct repository, and it's accessible with the current auth
106+
return client
107+
108+
raise NotFoundError(
109+
"Could not find the requested repository. Make sure the application can access it."
110+
)
111+
112+
except BaseException as e:
113+
# Close the client if we encountered an unexpected exception
114+
await client.aclose()
115+
raise e
116+
117+
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']}")
130+
131+
else:
132+
# The desired action was found, and it ended successfully
133+
return run["artifacts_url"]
134+
135+
run = await client.get(run["url"])
136+
run.raise_for_status()
137+
run = run.json()
138+
139+
raise RunTimeoutError("The requested workflow was not ready in time.")
140+
141+
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+
"""Get a download URL for a build artifact."""
147+
client = await authorize(owner, repo)
148+
149+
try:
150+
# Get the workflow runs for this repository
151+
runs = await client.get(f"/repos/{owner}/{repo}/actions/runs", params={"per_page": 100})
152+
runs.raise_for_status()
153+
runs = runs.json()
154+
155+
# Filter the runs for the one associated with the given SHA
156+
for run in runs["workflow_runs"]:
157+
if run["name"] == action_name and sha == run["head_sha"]:
158+
break
159+
else:
160+
raise NotFoundError(
161+
"Could not find a run matching the provided settings in the previous hundred runs."
162+
)
163+
164+
# Wait for the workflow to finish
165+
url = await wait_for_run(client, run)
166+
167+
# Filter the artifacts, and return the download URL
168+
artifacts = await client.get(url)
169+
artifacts.raise_for_status()
170+
171+
for artifact in artifacts.json()["artifacts"]:
172+
if artifact["name"] == artifact_name:
173+
data = await client.get(artifact["archive_download_url"])
174+
if data.status_code == 302:
175+
return str(data.next_request.url)
176+
177+
# The following line is left untested since it should in theory be impossible
178+
data.raise_for_status() # pragma: no cover
179+
180+
raise NotFoundError("Could not find an artifact matching the provided name.")
181+
182+
finally:
183+
await client.aclose()

0 commit comments

Comments
 (0)