Skip to content

Commit 8ab501c

Browse files
Merge pull request #742 from python-discord/github-artifacts
Add GitHub Artifacts API Route
2 parents 3ec8972 + 460ccff commit 8ab501c

File tree

10 files changed

+684
-107
lines changed

10 files changed

+684
-107
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: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Utilities for working with the GitHub API."""
2+
import dataclasses
3+
import datetime
4+
import math
5+
import typing
6+
7+
import httpx
8+
import jwt
9+
10+
from pydis_site import settings
11+
12+
MAX_RUN_TIME = datetime.timedelta(minutes=3)
13+
"""The maximum time allowed before an action is declared timed out."""
14+
ISO_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ"
15+
"""The datetime string format GitHub uses."""
16+
17+
18+
class ArtifactProcessingError(Exception):
19+
"""Base exception for other errors related to processing a GitHub artifact."""
20+
21+
status: int
22+
23+
24+
class UnauthorizedError(ArtifactProcessingError):
25+
"""The application does not have permission to access the requested repo."""
26+
27+
status = 401
28+
29+
30+
class NotFoundError(ArtifactProcessingError):
31+
"""The requested resource could not be found."""
32+
33+
status = 404
34+
35+
36+
class ActionFailedError(ArtifactProcessingError):
37+
"""The requested workflow did not conclude successfully."""
38+
39+
status = 400
40+
41+
42+
class RunTimeoutError(ArtifactProcessingError):
43+
"""The requested workflow run was not ready in time."""
44+
45+
status = 408
46+
47+
48+
class RunPendingError(ArtifactProcessingError):
49+
"""The requested workflow run is still pending, try again later."""
50+
51+
status = 202
52+
53+
54+
@dataclasses.dataclass(frozen=True)
55+
class WorkflowRun:
56+
"""
57+
A workflow run from the GitHub API.
58+
59+
https://docs.github.com/en/rest/actions/workflow-runs#get-a-workflow-run
60+
"""
61+
62+
name: str
63+
head_sha: str
64+
created_at: str
65+
status: str
66+
conclusion: str
67+
artifacts_url: str
68+
69+
@classmethod
70+
def from_raw(cls, data: dict[str, typing.Any]):
71+
"""Create an instance using the raw data from the API, discarding unused fields."""
72+
return cls(**{
73+
key.name: data[key.name] for key in dataclasses.fields(cls)
74+
})
75+
76+
77+
def generate_token() -> str:
78+
"""
79+
Generate a JWT token to access the GitHub API.
80+
81+
The token is valid for roughly 10 minutes after generation, before the API starts
82+
returning 401s.
83+
84+
Refer to:
85+
https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app
86+
"""
87+
now = datetime.datetime.now()
88+
return jwt.encode(
89+
{
90+
"iat": math.floor((now - datetime.timedelta(seconds=60)).timestamp()), # Issued at
91+
"exp": math.floor((now + datetime.timedelta(minutes=9)).timestamp()), # Expires at
92+
"iss": settings.GITHUB_APP_ID,
93+
},
94+
settings.GITHUB_APP_KEY,
95+
algorithm="RS256"
96+
)
97+
98+
99+
def authorize(owner: str, repo: str) -> httpx.Client:
100+
"""
101+
Get an access token for the requested repository.
102+
103+
The process is roughly:
104+
- GET app/installations to get a list of all app installations
105+
- POST <app_access_token> to get a token to access the given app
106+
- GET installation/repositories and check if the requested one is part of those
107+
"""
108+
client = httpx.Client(
109+
base_url=settings.GITHUB_API,
110+
headers={"Authorization": f"bearer {generate_token()}"},
111+
timeout=settings.TIMEOUT_PERIOD,
112+
)
113+
114+
try:
115+
# Get a list of app installations we have access to
116+
apps = client.get("app/installations")
117+
apps.raise_for_status()
118+
119+
for app in apps.json():
120+
# Look for an installation with the right owner
121+
if app["account"]["login"] != owner:
122+
continue
123+
124+
# Get the repositories of the specified owner
125+
app_token = client.post(app["access_tokens_url"])
126+
app_token.raise_for_status()
127+
client.headers["Authorization"] = f"bearer {app_token.json()['token']}"
128+
129+
repos = client.get("installation/repositories")
130+
repos.raise_for_status()
131+
132+
# Search for the request repository
133+
for accessible_repo in repos.json()["repositories"]:
134+
if accessible_repo["name"] == repo:
135+
# We've found the correct repository, and it's accessible with the current auth
136+
return client
137+
138+
raise NotFoundError(
139+
"Could not find the requested repository. Make sure the application can access it."
140+
)
141+
142+
except BaseException as e:
143+
# Close the client if we encountered an unexpected exception
144+
client.close()
145+
raise e
146+
147+
148+
def check_run_status(run: WorkflowRun) -> str:
149+
"""Check if the provided run has been completed, otherwise raise an exception."""
150+
created_at = datetime.datetime.strptime(run.created_at, ISO_FORMAT_STRING)
151+
run_time = datetime.datetime.utcnow() - created_at
152+
153+
if run.status != "completed":
154+
if run_time <= MAX_RUN_TIME:
155+
raise RunPendingError(
156+
f"The requested run is still pending. It was created "
157+
f"{run_time.seconds // 60}:{run_time.seconds % 60 :>02} minutes ago."
158+
)
159+
else:
160+
raise RunTimeoutError("The requested workflow was not ready in time.")
161+
162+
if run.conclusion != "success":
163+
# The action failed, or did not run
164+
raise ActionFailedError(f"The requested workflow ended with: {run.conclusion}")
165+
166+
# The requested action is ready
167+
return run.artifacts_url
168+
169+
170+
def get_artifact(owner: str, repo: str, sha: str, action_name: str, artifact_name: str) -> str:
171+
"""Get a download URL for a build artifact."""
172+
client = authorize(owner, repo)
173+
174+
try:
175+
# Get the workflow runs for this repository
176+
runs = client.get(f"/repos/{owner}/{repo}/actions/runs", params={"per_page": 100})
177+
runs.raise_for_status()
178+
runs = runs.json()
179+
180+
# Filter the runs for the one associated with the given SHA
181+
for run in runs["workflow_runs"]:
182+
run = WorkflowRun.from_raw(run)
183+
if run.name == action_name and sha == run.head_sha:
184+
break
185+
else:
186+
raise NotFoundError(
187+
"Could not find a run matching the provided settings in the previous hundred runs."
188+
)
189+
190+
# Check the workflow status
191+
url = check_run_status(run)
192+
193+
# Filter the artifacts, and return the download URL
194+
artifacts = client.get(url)
195+
artifacts.raise_for_status()
196+
197+
for artifact in artifacts.json()["artifacts"]:
198+
if artifact["name"] == artifact_name:
199+
data = client.get(artifact["archive_download_url"])
200+
if data.status_code == 302:
201+
return str(data.next_request.url)
202+
203+
# The following line is left untested since it should in theory be impossible
204+
data.raise_for_status() # pragma: no cover
205+
206+
raise NotFoundError("Could not find an artifact matching the provided name.")
207+
208+
finally:
209+
client.close()

0 commit comments

Comments
 (0)