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.
@@ -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