ci-time-tracker 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ci_time_tracker/__init__.py +3 -0
- ci_time_tracker/__main__.py +12 -0
- ci_time_tracker/analyzer.py +444 -0
- ci_time_tracker/cache.py +113 -0
- ci_time_tracker/cli.py +664 -0
- ci_time_tracker/config_parser.py +484 -0
- ci_time_tracker/detection.py +234 -0
- ci_time_tracker/gates.py +108 -0
- ci_time_tracker/github_api.py +624 -0
- ci_time_tracker/impact.py +361 -0
- ci_time_tracker/log_parser.py +728 -0
- ci_time_tracker/models.py +277 -0
- ci_time_tracker/pricing.py +202 -0
- ci_time_tracker/reporter.py +577 -0
- ci_time_tracker-0.2.0.dist-info/METADATA +561 -0
- ci_time_tracker-0.2.0.dist-info/RECORD +20 -0
- ci_time_tracker-0.2.0.dist-info/WHEEL +5 -0
- ci_time_tracker-0.2.0.dist-info/entry_points.txt +2 -0
- ci_time_tracker-0.2.0.dist-info/licenses/LICENSE +21 -0
- ci_time_tracker-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""GitHub Actions API ingestion for ci-time-tracker.
|
|
2
|
+
|
|
3
|
+
Fetches workflow runs and their jobs from the GitHub REST API and maps them to
|
|
4
|
+
the BuildLog/StepExecution models. Timing comes from the API's own timestamps,
|
|
5
|
+
so analysis does not depend on parsing runner log text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from collections.abc import Callable, Iterator
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
from ci_time_tracker.models import BuildLog, StepExecution
|
|
20
|
+
|
|
21
|
+
DEFAULT_API_URL = "https://api.github.com"
|
|
22
|
+
DEFAULT_RUN_LIMIT = 50
|
|
23
|
+
|
|
24
|
+
# Each run needs its own jobs request, and those dominate the wall clock, so
|
|
25
|
+
# they are fetched concurrently. Kept modest to stay clear of GitHub's
|
|
26
|
+
# secondary rate limits.
|
|
27
|
+
DEFAULT_WORKERS = 8
|
|
28
|
+
|
|
29
|
+
_PER_PAGE = 100
|
|
30
|
+
_USER_AGENT = "ci-time-tracker"
|
|
31
|
+
_API_VERSION = "2022-11-28"
|
|
32
|
+
_TIMEOUT_SECONDS = 30
|
|
33
|
+
|
|
34
|
+
# GitHub conclusions mapped to StepExecution statuses. A cancelled or skipped
|
|
35
|
+
# step never really ran, so it counts as neither success nor failure.
|
|
36
|
+
_CONCLUSION_STATUS: dict[str, Literal["success", "failure", "skipped"]] = {
|
|
37
|
+
"success": "success",
|
|
38
|
+
"failure": "failure",
|
|
39
|
+
"timed_out": "failure",
|
|
40
|
+
"startup_failure": "failure",
|
|
41
|
+
"action_required": "failure",
|
|
42
|
+
"cancelled": "skipped",
|
|
43
|
+
"skipped": "skipped",
|
|
44
|
+
"neutral": "skipped",
|
|
45
|
+
"stale": "skipped",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# A transport takes (url, headers) and returns (parsed JSON body, response headers).
|
|
49
|
+
# Injectable so tests can run against recorded payloads instead of the network.
|
|
50
|
+
Transport = Callable[[str, dict[str, str]], tuple[Any, dict[str, str]]]
|
|
51
|
+
|
|
52
|
+
# A sender takes (method, url, headers, JSON body) and returns the parsed JSON
|
|
53
|
+
# response. Used for the few writes (PR comments); injectable like Transport.
|
|
54
|
+
Sender = Callable[[str, str, dict[str, str], dict[str, Any] | None], Any]
|
|
55
|
+
|
|
56
|
+
# Marks the comment ci-time-tracker owns on a pull request, so a later run
|
|
57
|
+
# updates it instead of adding another
|
|
58
|
+
COMMENT_MARKER = "<!-- ci-time-tracker:pr-impact -->"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GitHubAPIError(Exception):
|
|
62
|
+
"""Raised when the GitHub API cannot be queried."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, message: str, status: int | None = None):
|
|
65
|
+
self.message = message
|
|
66
|
+
self.status = status
|
|
67
|
+
super().__init__(message)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_token(explicit: str | None = None) -> str | None:
|
|
71
|
+
"""Return the GitHub token to authenticate with.
|
|
72
|
+
|
|
73
|
+
Prefers an explicitly passed token, then GITHUB_TOKEN, then GH_TOKEN.
|
|
74
|
+
Requests without a token still work for public repositories, but GitHub
|
|
75
|
+
allows only 60 of them per hour.
|
|
76
|
+
"""
|
|
77
|
+
return explicit or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _urllib_transport(url: str, headers: dict[str, str]) -> tuple[Any, dict[str, str]]:
|
|
81
|
+
"""Default transport: a plain GET against the GitHub REST API."""
|
|
82
|
+
request = urllib.request.Request(url, headers=headers)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
with urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS) as response:
|
|
86
|
+
body = response.read().decode("utf-8")
|
|
87
|
+
return json.loads(body), dict(response.headers)
|
|
88
|
+
except urllib.error.HTTPError as error:
|
|
89
|
+
raise _http_error(error, url) from error
|
|
90
|
+
except urllib.error.URLError as error:
|
|
91
|
+
raise GitHubAPIError(f"Could not reach {url}: {error.reason}") from error
|
|
92
|
+
except json.JSONDecodeError as error:
|
|
93
|
+
raise GitHubAPIError(f"GitHub returned a non-JSON response for {url}") from error
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _urllib_send(
|
|
97
|
+
method: str,
|
|
98
|
+
url: str,
|
|
99
|
+
headers: dict[str, str],
|
|
100
|
+
body: dict[str, Any] | None,
|
|
101
|
+
) -> Any:
|
|
102
|
+
"""Default sender: a JSON write request against the GitHub REST API."""
|
|
103
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
104
|
+
request = urllib.request.Request(
|
|
105
|
+
url,
|
|
106
|
+
data=data,
|
|
107
|
+
method=method,
|
|
108
|
+
headers={**headers, "Content-Type": "application/json"},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
with urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS) as response:
|
|
113
|
+
raw = response.read().decode("utf-8")
|
|
114
|
+
return json.loads(raw) if raw else {}
|
|
115
|
+
except urllib.error.HTTPError as error:
|
|
116
|
+
raise _http_error(error, url) from error
|
|
117
|
+
except urllib.error.URLError as error:
|
|
118
|
+
raise GitHubAPIError(f"Could not reach {url}: {error.reason}") from error
|
|
119
|
+
except json.JSONDecodeError as error:
|
|
120
|
+
raise GitHubAPIError(f"GitHub returned a non-JSON response for {url}") from error
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _http_error(error: urllib.error.HTTPError, url: str) -> GitHubAPIError:
|
|
124
|
+
"""Turn an HTTPError into a GitHubAPIError with actionable wording."""
|
|
125
|
+
remaining = error.headers.get("X-RateLimit-Remaining") if error.headers else None
|
|
126
|
+
|
|
127
|
+
if error.code == 403 and remaining == "0":
|
|
128
|
+
return GitHubAPIError(
|
|
129
|
+
"GitHub API rate limit exceeded. Set GITHUB_TOKEN to raise the limit "
|
|
130
|
+
"from 60 to 5000 requests per hour.",
|
|
131
|
+
status=error.code,
|
|
132
|
+
)
|
|
133
|
+
if error.code in (401, 403):
|
|
134
|
+
return GitHubAPIError(
|
|
135
|
+
f"GitHub denied access to {url} (HTTP {error.code}). Check that "
|
|
136
|
+
"GITHUB_TOKEN is set and has the 'actions:read' scope (and "
|
|
137
|
+
"'pull-requests: write' to post comments).",
|
|
138
|
+
status=error.code,
|
|
139
|
+
)
|
|
140
|
+
if error.code == 404:
|
|
141
|
+
return GitHubAPIError(
|
|
142
|
+
f"Not found: {url}. Check the repository and workflow names, and "
|
|
143
|
+
"that the token can see a private repository.",
|
|
144
|
+
status=error.code,
|
|
145
|
+
)
|
|
146
|
+
return GitHubAPIError(f"GitHub API request failed (HTTP {error.code}): {url}", status=error.code)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class GitHubClient:
|
|
151
|
+
"""Minimal client for the GitHub Actions API.
|
|
152
|
+
|
|
153
|
+
Reads runs and jobs; the only write is the pull request comment.
|
|
154
|
+
|
|
155
|
+
Attributes:
|
|
156
|
+
repo: Repository in "owner/name" form
|
|
157
|
+
token: Optional API token; without one only public data is reachable
|
|
158
|
+
api_url: API root, overridable for GitHub Enterprise
|
|
159
|
+
transport: Injectable read function (see Transport)
|
|
160
|
+
sender: Injectable write function (see Sender)
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
repo: str
|
|
164
|
+
token: str | None = None
|
|
165
|
+
api_url: str = DEFAULT_API_URL
|
|
166
|
+
transport: Transport = _urllib_transport
|
|
167
|
+
sender: Sender = _urllib_send
|
|
168
|
+
|
|
169
|
+
def __post_init__(self) -> None:
|
|
170
|
+
if self.repo.count("/") != 1 or not all(part.strip() for part in self.repo.split("/")):
|
|
171
|
+
raise GitHubAPIError(
|
|
172
|
+
f"Invalid repository '{self.repo}'. Expected the form 'owner/name'."
|
|
173
|
+
)
|
|
174
|
+
self.api_url = self.api_url.rstrip("/")
|
|
175
|
+
|
|
176
|
+
def _headers(self) -> dict[str, str]:
|
|
177
|
+
headers = {
|
|
178
|
+
"Accept": "application/vnd.github+json",
|
|
179
|
+
"User-Agent": _USER_AGENT,
|
|
180
|
+
"X-GitHub-Api-Version": _API_VERSION,
|
|
181
|
+
}
|
|
182
|
+
if self.token:
|
|
183
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
184
|
+
return headers
|
|
185
|
+
|
|
186
|
+
def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
187
|
+
query = {key: value for key, value in (params or {}).items() if value is not None}
|
|
188
|
+
url = f"{self.api_url}{path}"
|
|
189
|
+
if query:
|
|
190
|
+
url = f"{url}?{urllib.parse.urlencode(query)}"
|
|
191
|
+
|
|
192
|
+
payload, _ = self.transport(url, self._headers())
|
|
193
|
+
return payload
|
|
194
|
+
|
|
195
|
+
def _send(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
|
|
196
|
+
return self.sender(method, f"{self.api_url}{path}", self._headers(), body)
|
|
197
|
+
|
|
198
|
+
def fetch_run(self, run_id: int | str) -> dict[str, Any]:
|
|
199
|
+
"""Return one workflow run's payload (its latest attempt)."""
|
|
200
|
+
return self._get(f"/repos/{self.repo}/actions/runs/{run_id}")
|
|
201
|
+
|
|
202
|
+
def fetch_default_branch(self) -> str:
|
|
203
|
+
"""Return the repository's default branch."""
|
|
204
|
+
return self._get(f"/repos/{self.repo}")["default_branch"]
|
|
205
|
+
|
|
206
|
+
def find_pull_request(self, sha: str) -> int | None:
|
|
207
|
+
"""Return the number of the open pull request whose head is `sha`.
|
|
208
|
+
|
|
209
|
+
The workflow_run event leaves its pull request list empty for pull
|
|
210
|
+
requests from forks, so the commit is looked up instead.
|
|
211
|
+
"""
|
|
212
|
+
pulls = self._get(f"/repos/{self.repo}/commits/{sha}/pulls")
|
|
213
|
+
if not isinstance(pulls, list):
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
candidates = [pull for pull in pulls if pull.get("state") == "open"] or pulls
|
|
217
|
+
for pull in candidates:
|
|
218
|
+
if (pull.get("head") or {}).get("sha") == sha:
|
|
219
|
+
return pull["number"]
|
|
220
|
+
return candidates[0]["number"] if candidates else None
|
|
221
|
+
|
|
222
|
+
def upsert_comment(self, number: int, body: str, marker: str = COMMENT_MARKER) -> tuple[str, str | None]:
|
|
223
|
+
"""Create or update the pull request comment carrying `marker`.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
("created" or "updated", the comment's URL)
|
|
227
|
+
"""
|
|
228
|
+
page = 1
|
|
229
|
+
while True:
|
|
230
|
+
comments = self._get(
|
|
231
|
+
f"/repos/{self.repo}/issues/{number}/comments",
|
|
232
|
+
{"per_page": _PER_PAGE, "page": page},
|
|
233
|
+
)
|
|
234
|
+
comments = comments if isinstance(comments, list) else []
|
|
235
|
+
|
|
236
|
+
for comment in comments:
|
|
237
|
+
if marker in (comment.get("body") or ""):
|
|
238
|
+
updated = self._send(
|
|
239
|
+
"PATCH", f"/repos/{self.repo}/issues/comments/{comment['id']}", {"body": body}
|
|
240
|
+
)
|
|
241
|
+
return "updated", (updated or {}).get("html_url")
|
|
242
|
+
|
|
243
|
+
if len(comments) < _PER_PAGE:
|
|
244
|
+
break
|
|
245
|
+
page += 1
|
|
246
|
+
|
|
247
|
+
created = self._send("POST", f"/repos/{self.repo}/issues/{number}/comments", {"body": body})
|
|
248
|
+
return "created", (created or {}).get("html_url")
|
|
249
|
+
|
|
250
|
+
def iter_workflow_runs(
|
|
251
|
+
self,
|
|
252
|
+
workflow: str | None = None,
|
|
253
|
+
branch: str | None = None,
|
|
254
|
+
limit: int = DEFAULT_RUN_LIMIT,
|
|
255
|
+
status: str | None = "completed",
|
|
256
|
+
) -> Iterator[dict[str, Any]]:
|
|
257
|
+
"""Yield up to `limit` workflow runs, newest first.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
workflow: Workflow file name (e.g. "ci.yml") or numeric id. When
|
|
261
|
+
omitted, runs of every workflow in the repository are returned.
|
|
262
|
+
branch: Restrict to runs of a single branch
|
|
263
|
+
limit: Maximum number of runs to yield
|
|
264
|
+
status: Run status filter; "completed" skips in-flight runs whose
|
|
265
|
+
timings are not final yet
|
|
266
|
+
"""
|
|
267
|
+
if limit <= 0:
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
if workflow:
|
|
271
|
+
path = f"/repos/{self.repo}/actions/workflows/{urllib.parse.quote(workflow)}/runs"
|
|
272
|
+
else:
|
|
273
|
+
path = f"/repos/{self.repo}/actions/runs"
|
|
274
|
+
|
|
275
|
+
yielded = 0
|
|
276
|
+
page = 1
|
|
277
|
+
|
|
278
|
+
while yielded < limit:
|
|
279
|
+
per_page = min(_PER_PAGE, limit - yielded)
|
|
280
|
+
payload = self._get(
|
|
281
|
+
path,
|
|
282
|
+
{
|
|
283
|
+
"per_page": per_page,
|
|
284
|
+
"page": page,
|
|
285
|
+
"status": status,
|
|
286
|
+
"branch": branch,
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
runs = payload.get("workflow_runs", []) if isinstance(payload, dict) else []
|
|
290
|
+
if not runs:
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
for run in runs:
|
|
294
|
+
yield run
|
|
295
|
+
yielded += 1
|
|
296
|
+
if yielded >= limit:
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
# A short page is the last one; asking for the next would be wasted
|
|
300
|
+
if len(runs) < per_page:
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
page += 1
|
|
304
|
+
|
|
305
|
+
def fetch_run_jobs(self, run_id: int | str, run_attempt: int | None = None) -> list[dict[str, Any]]:
|
|
306
|
+
"""Return every job of a workflow run attempt, following pagination.
|
|
307
|
+
|
|
308
|
+
Without an attempt, GitHub returns the jobs of the latest attempt, so
|
|
309
|
+
an earlier attempt of a re-run must always be asked for explicitly.
|
|
310
|
+
"""
|
|
311
|
+
if run_attempt:
|
|
312
|
+
path = f"/repos/{self.repo}/actions/runs/{run_id}/attempts/{run_attempt}/jobs"
|
|
313
|
+
else:
|
|
314
|
+
path = f"/repos/{self.repo}/actions/runs/{run_id}/jobs"
|
|
315
|
+
|
|
316
|
+
jobs: list[dict[str, Any]] = []
|
|
317
|
+
page = 1
|
|
318
|
+
|
|
319
|
+
while True:
|
|
320
|
+
payload = self._get(path, {"per_page": _PER_PAGE, "page": page})
|
|
321
|
+
batch = payload.get("jobs", []) if isinstance(payload, dict) else []
|
|
322
|
+
jobs.extend(batch)
|
|
323
|
+
|
|
324
|
+
if len(batch) < _PER_PAGE:
|
|
325
|
+
return jobs
|
|
326
|
+
page += 1
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def parse_api_timestamp(value: str | None) -> datetime | None:
|
|
330
|
+
"""Parse an ISO 8601 timestamp as returned by the GitHub API."""
|
|
331
|
+
if not value:
|
|
332
|
+
return None
|
|
333
|
+
try:
|
|
334
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
335
|
+
except ValueError:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _elapsed_seconds(start: datetime | None, end: datetime | None) -> float | None:
|
|
340
|
+
"""Seconds between two timestamps, or None when either is missing."""
|
|
341
|
+
if start is None or end is None:
|
|
342
|
+
return None
|
|
343
|
+
return (end - start).total_seconds()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _status_from_conclusion(conclusion: str | None) -> Literal["success", "failure", "skipped"]:
|
|
347
|
+
"""Map a GitHub conclusion to a step status."""
|
|
348
|
+
if conclusion is None:
|
|
349
|
+
return "skipped"
|
|
350
|
+
return _CONCLUSION_STATUS.get(conclusion, "skipped")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _runner_label(job: dict[str, Any]) -> str | None:
|
|
354
|
+
"""Best available description of the machine a job ran on."""
|
|
355
|
+
labels = job.get("labels") or []
|
|
356
|
+
if labels:
|
|
357
|
+
return ", ".join(str(label) for label in labels)
|
|
358
|
+
return job.get("runner_name")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def steps_from_job(job: dict[str, Any], run_attempt: int = 1) -> list[StepExecution]:
|
|
362
|
+
"""Map a job's steps to StepExecution records.
|
|
363
|
+
|
|
364
|
+
Step names are qualified with the job name ("job / step"), the way the
|
|
365
|
+
GitHub UI shows them, so that identically named steps in different jobs
|
|
366
|
+
are not aggregated together.
|
|
367
|
+
"""
|
|
368
|
+
job_name = job.get("name") or "unnamed job"
|
|
369
|
+
runner = _runner_label(job)
|
|
370
|
+
executions: list[StepExecution] = []
|
|
371
|
+
|
|
372
|
+
for step in job.get("steps") or []:
|
|
373
|
+
step_name = step.get("name") or f"step {step.get('number', len(executions) + 1)}"
|
|
374
|
+
start_time = parse_api_timestamp(step.get("started_at"))
|
|
375
|
+
end_time = parse_api_timestamp(step.get("completed_at"))
|
|
376
|
+
|
|
377
|
+
executions.append(StepExecution(
|
|
378
|
+
name=f"{job_name} / {step_name}",
|
|
379
|
+
start_time=start_time,
|
|
380
|
+
end_time=end_time,
|
|
381
|
+
duration_seconds=_elapsed_seconds(start_time, end_time),
|
|
382
|
+
status=_status_from_conclusion(step.get("conclusion")),
|
|
383
|
+
is_retry=run_attempt > 1,
|
|
384
|
+
job_name=job_name,
|
|
385
|
+
runner=runner,
|
|
386
|
+
))
|
|
387
|
+
|
|
388
|
+
return executions
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def build_log_from_run(
|
|
392
|
+
run: dict[str, Any],
|
|
393
|
+
jobs: list[dict[str, Any]],
|
|
394
|
+
superseded: bool = False,
|
|
395
|
+
) -> BuildLog:
|
|
396
|
+
"""Map a workflow run and its jobs to a BuildLog.
|
|
397
|
+
|
|
398
|
+
Job-level data that has no place on a step (queue time, runner, result)
|
|
399
|
+
is kept in `metadata["jobs"]` for later analysis.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
run: Run payload; for an earlier attempt of a re-run, the run-level
|
|
403
|
+
timestamps describe the latest attempt and must be left out
|
|
404
|
+
jobs: The jobs of that attempt
|
|
405
|
+
superseded: Whether this is an earlier attempt of a re-run
|
|
406
|
+
"""
|
|
407
|
+
run_attempt = int(run.get("run_attempt") or 1)
|
|
408
|
+
|
|
409
|
+
job_starts = [ts for ts in (parse_api_timestamp(job.get("started_at")) for job in jobs) if ts]
|
|
410
|
+
job_ends = [ts for ts in (parse_api_timestamp(job.get("completed_at")) for job in jobs) if ts]
|
|
411
|
+
|
|
412
|
+
started_at = (
|
|
413
|
+
parse_api_timestamp(run.get("run_started_at"))
|
|
414
|
+
or (min(job_starts) if job_starts else None)
|
|
415
|
+
or parse_api_timestamp(run.get("created_at"))
|
|
416
|
+
)
|
|
417
|
+
finished_at = parse_api_timestamp(run.get("updated_at")) or (max(job_ends) if job_ends else None)
|
|
418
|
+
|
|
419
|
+
steps: list[StepExecution] = []
|
|
420
|
+
jobs_metadata: list[dict[str, Any]] = []
|
|
421
|
+
|
|
422
|
+
for job in jobs:
|
|
423
|
+
steps.extend(steps_from_job(job, run_attempt))
|
|
424
|
+
|
|
425
|
+
job_created = parse_api_timestamp(job.get("created_at"))
|
|
426
|
+
job_started = parse_api_timestamp(job.get("started_at"))
|
|
427
|
+
job_completed = parse_api_timestamp(job.get("completed_at"))
|
|
428
|
+
|
|
429
|
+
jobs_metadata.append({
|
|
430
|
+
"name": job.get("name"),
|
|
431
|
+
"conclusion": job.get("conclusion"),
|
|
432
|
+
"runner": _runner_label(job),
|
|
433
|
+
"queued_seconds": _elapsed_seconds(job_created, job_started),
|
|
434
|
+
"duration_seconds": _elapsed_seconds(job_started, job_completed),
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
return BuildLog(
|
|
438
|
+
build_id=str(run["id"]) if run.get("id") is not None else None,
|
|
439
|
+
steps=steps,
|
|
440
|
+
total_duration=_elapsed_seconds(started_at, finished_at),
|
|
441
|
+
timestamp=started_at,
|
|
442
|
+
head_sha=run.get("head_sha"),
|
|
443
|
+
run_attempt=run_attempt,
|
|
444
|
+
conclusion=run.get("conclusion"),
|
|
445
|
+
metadata={
|
|
446
|
+
"provider": "github",
|
|
447
|
+
"workflow_name": run.get("name"),
|
|
448
|
+
"branch": run.get("head_branch"),
|
|
449
|
+
"event": run.get("event"),
|
|
450
|
+
"url": run.get("html_url"),
|
|
451
|
+
"jobs": jobs_metadata,
|
|
452
|
+
"superseded_attempt": superseded,
|
|
453
|
+
},
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _earlier_attempt(run: dict[str, Any], attempt: int) -> dict[str, Any]:
|
|
458
|
+
"""Run payload for an earlier attempt of a re-run.
|
|
459
|
+
|
|
460
|
+
The runs listing only describes the latest attempt, so its timestamps and
|
|
461
|
+
conclusion are dropped; build_log_from_run derives them from the jobs.
|
|
462
|
+
An earlier attempt is finished by definition.
|
|
463
|
+
"""
|
|
464
|
+
return {
|
|
465
|
+
**run,
|
|
466
|
+
"run_attempt": attempt,
|
|
467
|
+
"run_started_at": None,
|
|
468
|
+
"updated_at": None,
|
|
469
|
+
"conclusion": None,
|
|
470
|
+
"status": "completed",
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def fetch_build_logs(
|
|
475
|
+
repo: str,
|
|
476
|
+
workflow: str | None = None,
|
|
477
|
+
branch: str | None = None,
|
|
478
|
+
limit: int = DEFAULT_RUN_LIMIT,
|
|
479
|
+
token: str | None = None,
|
|
480
|
+
cache: Any | None = None,
|
|
481
|
+
api_url: str = DEFAULT_API_URL,
|
|
482
|
+
transport: Transport = _urllib_transport,
|
|
483
|
+
workers: int = DEFAULT_WORKERS,
|
|
484
|
+
include_attempts: bool = True,
|
|
485
|
+
) -> list[BuildLog]:
|
|
486
|
+
"""Fetch recent workflow runs and return them as BuildLogs.
|
|
487
|
+
|
|
488
|
+
Listing runs is one request per 100 runs, but each run's jobs cost a
|
|
489
|
+
request of their own. Those dominate the wall clock, so cached runs are
|
|
490
|
+
served from `cache` (see cache.RunCache) and the rest are fetched
|
|
491
|
+
concurrently.
|
|
492
|
+
|
|
493
|
+
The runs listing only shows the latest attempt of a re-run. The earlier
|
|
494
|
+
attempts are fetched too, as BuildLogs of their own: a step that failed
|
|
495
|
+
and then passed on a re-run is the strongest evidence of flakiness.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
repo: Repository in "owner/name" form
|
|
499
|
+
workflow: Optional workflow file name (e.g. "ci.yml")
|
|
500
|
+
branch: Optional branch filter
|
|
501
|
+
limit: Maximum number of runs to fetch
|
|
502
|
+
token: API token; falls back to GITHUB_TOKEN / GH_TOKEN
|
|
503
|
+
cache: Optional RunCache for jobs payloads
|
|
504
|
+
api_url: API root, overridable for GitHub Enterprise
|
|
505
|
+
transport: Injectable request function
|
|
506
|
+
workers: Number of concurrent jobs requests
|
|
507
|
+
include_attempts: Also fetch the earlier attempts of re-run runs;
|
|
508
|
+
they do not count towards `limit`
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
BuildLogs ordered oldest first, so trends read left to right; the
|
|
512
|
+
attempts of a re-run come in attempt order
|
|
513
|
+
|
|
514
|
+
Raises:
|
|
515
|
+
GitHubAPIError: If the API cannot be queried
|
|
516
|
+
"""
|
|
517
|
+
client = GitHubClient(
|
|
518
|
+
repo=repo,
|
|
519
|
+
token=resolve_token(token),
|
|
520
|
+
api_url=api_url,
|
|
521
|
+
transport=transport,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
# One entry per (run, attempt), newest first: (payload, attempt, superseded)
|
|
525
|
+
attempts: list[tuple[dict[str, Any], int, bool]] = []
|
|
526
|
+
|
|
527
|
+
for run in client.iter_workflow_runs(workflow=workflow, branch=branch, limit=limit):
|
|
528
|
+
if run.get("id") is None:
|
|
529
|
+
continue
|
|
530
|
+
|
|
531
|
+
latest = int(run.get("run_attempt") or 1)
|
|
532
|
+
attempts.append((run, latest, False))
|
|
533
|
+
|
|
534
|
+
if include_attempts:
|
|
535
|
+
for attempt in range(latest - 1, 0, -1):
|
|
536
|
+
attempts.append((_earlier_attempt(run, attempt), attempt, True))
|
|
537
|
+
|
|
538
|
+
# Cache reads and writes stay on this thread: a sqlite connection belongs
|
|
539
|
+
# to the thread that created it.
|
|
540
|
+
jobs_by_attempt: dict[tuple[int, int], list[dict[str, Any]]] = {}
|
|
541
|
+
missing: list[tuple[dict[str, Any], int]] = []
|
|
542
|
+
|
|
543
|
+
for run, attempt, _ in attempts:
|
|
544
|
+
cached = cache.get_jobs(repo, run["id"], attempt) if cache else None
|
|
545
|
+
if cached is None:
|
|
546
|
+
missing.append((run, attempt))
|
|
547
|
+
else:
|
|
548
|
+
jobs_by_attempt[(run["id"], attempt)] = cached
|
|
549
|
+
|
|
550
|
+
if missing:
|
|
551
|
+
def fetch(entry: tuple[dict[str, Any], int]) -> tuple[dict[str, Any], int, list[dict[str, Any]]]:
|
|
552
|
+
run, attempt = entry
|
|
553
|
+
return run, attempt, client.fetch_run_jobs(run["id"], attempt)
|
|
554
|
+
|
|
555
|
+
with ThreadPoolExecutor(max_workers=max(1, min(workers, len(missing)))) as pool:
|
|
556
|
+
for run, attempt, jobs in pool.map(fetch, missing):
|
|
557
|
+
jobs_by_attempt[(run["id"], attempt)] = jobs
|
|
558
|
+
# Only completed runs are worth caching: an in-flight run's
|
|
559
|
+
# timings still change.
|
|
560
|
+
if cache is not None and run.get("status") == "completed":
|
|
561
|
+
cache.store_jobs(repo, run["id"], attempt, jobs)
|
|
562
|
+
|
|
563
|
+
logs = [
|
|
564
|
+
build_log_from_run(run, jobs_by_attempt.get((run["id"], attempt), []), superseded)
|
|
565
|
+
for run, attempt, superseded in attempts
|
|
566
|
+
]
|
|
567
|
+
logs.reverse()
|
|
568
|
+
return logs
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def fetch_impact_inputs(
|
|
572
|
+
repo: str,
|
|
573
|
+
run_id: int | str,
|
|
574
|
+
baseline_branch: str | None = None,
|
|
575
|
+
baseline_runs: int = 20,
|
|
576
|
+
token: str | None = None,
|
|
577
|
+
cache: Any | None = None,
|
|
578
|
+
api_url: str = DEFAULT_API_URL,
|
|
579
|
+
transport: Transport = _urllib_transport,
|
|
580
|
+
) -> tuple[dict[str, Any], list[BuildLog], list[BuildLog], str]:
|
|
581
|
+
"""Fetch one run and the baseline to compare it with.
|
|
582
|
+
|
|
583
|
+
The baseline is the latest completed runs of the same workflow on
|
|
584
|
+
`baseline_branch` (the repository's default branch when omitted), the run
|
|
585
|
+
itself excluded.
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
(run payload, the run's attempts oldest first, baseline logs, the
|
|
589
|
+
baseline branch)
|
|
590
|
+
|
|
591
|
+
Raises:
|
|
592
|
+
GitHubAPIError: If the API cannot be queried
|
|
593
|
+
"""
|
|
594
|
+
client = GitHubClient(repo=repo, token=resolve_token(token), api_url=api_url, transport=transport)
|
|
595
|
+
|
|
596
|
+
run = client.fetch_run(run_id)
|
|
597
|
+
branch = baseline_branch or client.fetch_default_branch()
|
|
598
|
+
|
|
599
|
+
latest = int(run.get("run_attempt") or 1)
|
|
600
|
+
run_logs = [
|
|
601
|
+
build_log_from_run(
|
|
602
|
+
run if attempt == latest else _earlier_attempt(run, attempt),
|
|
603
|
+
client.fetch_run_jobs(run["id"], attempt),
|
|
604
|
+
superseded=attempt != latest,
|
|
605
|
+
)
|
|
606
|
+
for attempt in range(1, latest + 1)
|
|
607
|
+
]
|
|
608
|
+
|
|
609
|
+
baseline = [
|
|
610
|
+
log
|
|
611
|
+
for log in fetch_build_logs(
|
|
612
|
+
repo,
|
|
613
|
+
workflow=str(run["workflow_id"]) if run.get("workflow_id") else None,
|
|
614
|
+
branch=branch,
|
|
615
|
+
limit=baseline_runs,
|
|
616
|
+
token=token,
|
|
617
|
+
cache=cache,
|
|
618
|
+
api_url=api_url,
|
|
619
|
+
transport=transport,
|
|
620
|
+
)
|
|
621
|
+
if log.build_id != str(run["id"])
|
|
622
|
+
]
|
|
623
|
+
|
|
624
|
+
return run, run_logs, baseline, branch
|