Coverage for pyTooling/Tracing/CI/GitHub.py: 94%

181 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-03 23:02 +0000

1# ==================================================================================================================== # 

2# _____ _ _ _____ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _|_ _| __ __ _ ___(_)_ __ __ _ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | || '__/ _` |/ __| | '_ \ / _` | # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| || | | (_| | (__| | | | | (_| | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_||_| \__,_|\___|_|_| |_|\__, | # 

7# |_| |___/ |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32Read the timing of a GitHub Actions workflow run into a software execution trace. 

33 

34A workflow run becomes a :class:`~pyTooling.Tracing.Trace`, and each job a :class:`~pyTooling.Tracing.Span` with one 

35sub-span per step. The time a job waited for a runner is a separate timespan in front of the job. Jobs of a called 

36(reusable) workflow - which GitHub names ``Caller / Job`` - are grouped below a timespan named like the calling job. 

37 

38.. code-block:: python 

39 

40 from os import getenv 

41 from pyTooling.Tracing.CI.GitHub import WorkflowRunReader 

42 

43 reader = WorkflowRunReader("pyTooling/Actions", token=getenv("GITHUB_TOKEN")) 

44 trace = reader.ReadRun(34937615362) 

45 print("\\n".join(trace.Format())) 

46 

47.. hint:: 

48 

49 See :ref:`high-level help <TRACING/CI>` for explanations and usage examples. 

50""" 

51from datetime import datetime 

52from typing import Any, ClassVar, Iterable, Optional as Nullable, Self, Union 

53 

54from pyTooling.CI import Base, JobGroup, JSONObject, Matrix 

55from pyTooling.CI.GitHub import Conclusion, GitHubError, Job, MatrixJob, Pipeline, Step 

56from pyTooling.Common import getFullyQualifiedName 

57from pyTooling.Decorators import export, readonly 

58from pyTooling.GenericPath.URL import URL 

59from pyTooling.MetaClasses import ExtendedType 

60from pyTooling.REST import RESTClient, RESTError 

61from pyTooling.Tracing import Span, Trace 

62from pyTooling.Tracing.CI import Result 

63from pyTooling.Tracing.CI import JobSpan as CIJobSpan, MatrixSpan as CIMatrixSpan 

64from pyTooling.Tracing.CI import PipelineTrace as CIPipelineTrace, QueuedSpan as CIQueuedSpan 

65from pyTooling.Tracing.CI import StepSpan as CIStepSpan, WorkflowSpan as CIWorkflowSpan 

66 

67 

68__all__ = ["GITHUB_API_URL"] 

69 

70GITHUB_API_URL = URL.Parse("https://api.github.com") 

71"""Base URL of the GitHub REST API.""" 

72 

73_GITHUB_HEADERS = { 

74 "Accept": "application/vnd.github+json", 

75 "X-GitHub-Api-Version": "2022-11-28" 

76} 

77"""Headers every request to the GitHub REST API carries.""" 

78 

79 

80@export 

81class GitHub(metaclass=ExtendedType, slots=True): 

82 """ 

83 Attribute keys naming what only GitHub Actions reports, beside the keys of 

84 :class:`~pyTooling.Tracing.CI.OTLP`. 

85 

86 The nesting mirrors the key itself, as it does there: :attr:`GitHub.Runner.Labels` spells 

87 ``'github.runner.labels'``. 

88 """ 

89 

90 Conclusion: ClassVar[str] = "github.conclusion" #: GitHub's own conclusion, next to the result it maps to. 

91 Event: ClassVar[str] = "github.event" #: The event that started the run, e.g. ``'push'``. 

92 

93 class Run(metaclass=ExtendedType, slots=True): 

94 """Attribute keys naming a workflow run.""" 

95 

96 Attempt: ClassVar[str] = "github.run.attempt" #: Which attempt of the run this is. 

97 Number: ClassVar[str] = "github.run.number" #: The run's number within its workflow. 

98 

99 class Workflow(metaclass=ExtendedType, slots=True): 

100 """Attribute keys naming the workflow a run belongs to.""" 

101 

102 Path: ClassVar[str] = "github.workflow.path" #: The workflow's YAML file in the repository. 

103 

104 class Matrix(metaclass=ExtendedType, slots=True): 

105 """Attribute keys naming a matrix.""" 

106 

107 Dimensions: ClassVar[str] = "github.matrix.dimensions" #: One instance's values, e.g. ``['ubuntu-26.04', '3.14']``. 

108 

109 class Runner(metaclass=ExtendedType, slots=True): 

110 """Attribute keys naming the runner a job ran on.""" 

111 

112 Labels: ClassVar[str] = "github.runner.labels" #: The labels the job asked for, e.g. ``['ubuntu-26.04']``. 

113 Group: ClassVar[str] = "github.runner.group" #: The runner group the runner belongs to. 

114 

115 class Step(metaclass=ExtendedType, slots=True): 

116 """Attribute keys naming a step of a job.""" 

117 

118 Number: ClassVar[str] = "github.step.number" #: The step's position in its job, counted from one. 

119 

120 

121@export 

122class GitHubTimespanMixin(metaclass=ExtendedType, mixin=True): 

123 """ 

124 Mixin-class for a timespan built from :mod:`pyTooling.CI.GitHub`'s model of a workflow run. 

125 

126 It holds what every flavour needs to read that model: the result an element's outcome is, and the span a group's 

127 contents have. Everything else the model answers itself - the order its elements are in, and the times of a job, which 

128 are wider than the ones GitHub reports because a step may run outside the job containing it. 

129 """ 

130 

131 @staticmethod 

132 def _Result(element: Base) -> Nullable[Result]: 

133 """ 

134 Return the CI/CD result of an element of the model. 

135 

136 The result is the member of the same value as the element's :attr:`~pyTooling.CI.Base.Outcome`. A 

137 GitHub conclusion is mapped onto an outcome by 

138 :meth:`Conclusion.ToOutcome <pyTooling.CI.GitHub.Conclusion.ToOutcome>`. 

139 

140 :param element: The workflow run, job or step. 

141 :returns: The CI/CD result, or ``None`` while the element hasn't ended. 

142 """ 

143 if element.Outcome is None: 

144 return None 

145 

146 return Result(element.Outcome.value) 

147 

148 @staticmethod 

149 def _NotBefore(end: Nullable[datetime], begin: datetime) -> Nullable[datetime]: 

150 """ 

151 Clamp an end time so it doesn't precede a begin time. 

152 

153 :param end: Optional, the end time. Default: ``None``. 

154 :param begin: The begin time. 

155 :returns: The end time, but not before the begin time. 

156 """ 

157 return None if end is None else max(end, begin) 

158 

159 @classmethod 

160 def _Timespan( 

161 cls, 

162 created: Nullable[datetime], 

163 started: Nullable[datetime], 

164 completed: Nullable[datetime] 

165 ) -> tuple[Nullable[datetime], Nullable[datetime]]: 

166 """ 

167 Return the timespan the model's three times describe. 

168 

169 The model spans what a group holds; this is where the span becomes a timespan: it begins when the first 

170 element was queued, or started if it was never queued, and it doesn't end before it begins. 

171 

172 :param created: When the first element was created, or ``None`` if none reports a time. 

173 :param started: When the first element started, or ``None`` if none has started. 

174 :param completed: When the last element completed, or ``None`` while one hasn't. 

175 :returns: When the timespan begins and ends, each ``None`` if there is nothing to place on a 

176 timeline respectively while it is still running. 

177 """ 

178 begin = created if created is not None else started 

179 if begin is None: 

180 return None, None 

181 

182 return begin, cls._NotBefore(completed, begin) 

183 

184 @classmethod 

185 def _AddContents(cls, group: JobGroup, parent: Span) -> None: 

186 """ 

187 Add the jobs, matrices and called workflows of a group to a timespan, ordered by the time they were queued. 

188 

189 :param group: The group - a workflow run, a called workflow or a matrix. 

190 :param parent: The timespan the group's contents are added to. 

191 """ 

192 for item in group.IterateElements(): 

193 if isinstance(item, Job): 

194 JobSpan.FromJob(item, parent) 

195 elif isinstance(item, Matrix): 

196 MatrixSpan.FromGroup(item, parent) 

197 else: 

198 WorkflowSpan.FromGroup(item, parent) 

199 

200 

201@export 

202class StepSpan(CIStepSpan, GitHubTimespanMixin): 

203 """The timespan of a step of a GitHub Actions job.""" 

204 

205 @classmethod 

206 def FromStep(cls, step: Step, parent: Span) -> Nullable[Self]: 

207 """ 

208 Build the timespan of a step, below the timespan of its job. 

209 

210 A step that never started has no timespan - there is nothing to place on a timeline. 

211 

212 :param step: The step. 

213 :param parent: The timespan of the job containing the step. 

214 :returns: The step's timespan, or ``None`` if the step never started. 

215 """ 

216 if step.StartedAt is None: 

217 return None 

218 

219 return cls( 

220 step.Name, 

221 step.StartedAt, 

222 cls._NotBefore(step.CompletedAt, step.StartedAt), 

223 parent=parent, 

224 result=cls._Result(step), 

225 attributes={ 

226 GitHub.Step.Number: step.Number, 

227 GitHub.Conclusion: None if step.Conclusion is None else step.Conclusion.value 

228 } 

229 ) 

230 

231 

232@export 

233class QueuedSpan(CIQueuedSpan, GitHubTimespanMixin): 

234 """The timespan a GitHub Actions job waited for a runner, in front of the job's own timespan.""" 

235 

236 @classmethod 

237 def FromJob(cls, job: Job, created: datetime, started: Nullable[datetime], parent: Span) -> Self: 

238 """ 

239 Build the timespan a job waited for a runner. 

240 

241 :param job: The job that waited. 

242 :param created: When the job was created, which is when it started waiting. 

243 :param started: Optional, when the job started, or ``None`` while it is still waiting. 

244 :param parent: The timespan of the trace, workflow or matrix containing the job. 

245 :returns: The waiting timespan. 

246 """ 

247 return cls( 

248 f"{job!s} (queued)", 

249 created, 

250 cls._NotBefore(started, created), 

251 parent=parent, 

252 taskName=job.QualifiedName, 

253 attributes={GitHub.Runner.Labels: list(job.Labels)} 

254 ) 

255 

256 

257@export 

258class JobSpan(CIJobSpan, GitHubTimespanMixin): 

259 """The timespan of a GitHub Actions job, from the moment it started on a runner until it completed.""" 

260 

261 @classmethod 

262 def FromJob(cls, job: Job, parent: Span) -> Nullable[Self]: 

263 """ 

264 Build the timespans of a job: the time it waited for a runner, the job itself, and one per step. 

265 

266 A step may be reported as starting before, or completing after, the job containing it, so the job's timespan is 

267 widened to hold its steps - :mod:`pyTooling.Tracing` requires a timespan to lie within its parent. 

268 

269 :param job: The job. 

270 :param parent: The timespan of the trace, workflow or matrix containing the job. 

271 :returns: The job's timespan, or ``None`` if the job hasn't started and therefore only waited. 

272 """ 

273 created, started, completed = job.CreatedAt, job.StartedAt, job.CompletedAt 

274 

275 # a matrix instance carries its dimension values, so two of them are distinguishable 

276 displayName = str(job) 

277 if job.Conclusion is Conclusion.Skipped: 

278 # A skipped job neither waited for a runner nor ran on one. 

279 begin = started if started is not None else created 

280 end = None if begin is None else cls._NotBefore(completed if completed is not None else begin, begin) 

281 else: 

282 if created is not None and (started is None or created < started): 

283 QueuedSpan.FromJob(job, created, started, parent) 

284 

285 if started is None: 

286 return None 

287 

288 begin = started 

289 end = cls._NotBefore(completed, started) 

290 

291 attributes = { 

292 GitHub.Conclusion: None if job.Conclusion is None else job.Conclusion.value, 

293 GitHub.Runner.Group: job.RunnerGroupName, 

294 GitHub.Runner.Labels: list(job.Labels) 

295 } 

296 if isinstance(job, MatrixJob): 

297 attributes[GitHub.Matrix.Dimensions] = list(job.Dimensions.values()) 

298 

299 jobSpan = cls( 

300 displayName, 

301 begin, 

302 end, 

303 parent=parent, 

304 taskName=job.QualifiedName, 

305 runID=None if job.ID is None else str(job.ID), 

306 runURL=None if job.URL is None else str(job.URL), 

307 result=cls._Result(job), 

308 workerName=job.RunnerName, 

309 attributes=attributes 

310 ) 

311 

312 for step in job.Steps: 

313 StepSpan.FromStep(step, jobSpan) 

314 

315 return jobSpan 

316 

317 

318@export 

319class GitHubGroupMixin(metaclass=ExtendedType, mixin=True): 

320 """ 

321 Mixin-class for a timespan holding other timespans - a called workflow or a matrix. 

322 

323 Both are built the same way and differ only in what they are, which their class says. Its 

324 :meth:`~GitHubTimespanMixin._Timespan` and :meth:`~GitHubTimespanMixin._AddContents` come from 

325 :class:`GitHubTimespanMixin`. 

326 """ 

327 

328 @classmethod 

329 def FromGroup(cls, group: JobGroup, parent: Span) -> Self: 

330 """ 

331 Build the timespan of a group and everything below it. 

332 

333 :param group: The called workflow or the matrix. 

334 :param parent: The timespan containing the group. 

335 :returns: The group's timespan. 

336 """ 

337 begin, end = cls._Timespan(group.CreatedAt, group.StartedAt, group.CompletedAt) 

338 span = cls(str(group), begin, end, parent=parent) 

339 cls._AddContents(group, span) 

340 

341 return span 

342 

343 

344@export 

345class WorkflowSpan(CIWorkflowSpan, GitHubTimespanMixin, GitHubGroupMixin): 

346 """The jobs of a called workflow, grouped into one timespan.""" 

347 

348 

349@export 

350class MatrixSpan(CIMatrixSpan, GitHubTimespanMixin, GitHubGroupMixin): 

351 """The instances a matrix produced, grouped into one timespan.""" 

352 

353 

354@export 

355class WorkflowRunTrace(CIPipelineTrace, GitHubTimespanMixin): 

356 """A GitHub Actions workflow run as a trace.""" 

357 

358 @classmethod 

359 def FromJSON(cls, run: JSONObject, jobs: Nullable[Iterable[JSONObject]] = None) -> Self: 

360 """ 

361 Build the trace of a workflow run from the payloads the GitHub REST API answers with. 

362 

363 The payloads are read into a :class:`~pyTooling.CI.GitHub.Pipeline` first, so the tree - a called workflow, a 

364 matrix, the jobs and their steps - is reconstructed by the model rather than here, and :meth:`FromPipeline` 

365 turns it into timespans. 

366 

367 :param run: The workflow run, as returned by ``GET /repos/{owner}/{repo}/actions/runs/{run_id}``. 

368 :param jobs: Optional, the run's jobs, as listed by ``GET .../actions/runs/{run_id}/jobs``. 

369 :returns: The workflow run as a trace. 

370 :raises TypeError: If parameter 'run' is not of type :class:`dict`. 

371 :raises GitHubError: If a mandatory field is missing, or a field holds a value GitHub doesn't document. 

372 """ 

373 return cls.FromPipeline(Pipeline.FromJSON(run, jobs)) 

374 

375 @classmethod 

376 def FromPipeline(cls, pipeline: Pipeline) -> Self: 

377 """ 

378 Build the trace of a workflow run, as :mod:`pyTooling.CI.GitHub` models it. 

379 

380 * The run becomes the trace, widened where a job was queued earlier or completed later than the run reports. 

381 * A called workflow and a matrix become a timespan holding the jobs below them. 

382 * A job becomes a timespan from its start to its completion, preceded by ``<job> (queued)`` from its creation to 

383 its start, if it waited. A skipped job has no waiting timespan, and a job that hasn't started yet only a running 

384 waiting timespan. 

385 * A step that started becomes a sub-span of its job. 

386 * Every timespan is classified by :attr:`CI.Span.Kind <pyTooling.Tracing.CI.CI>` and carries the OpenTelemetry CI/CD 

387 attributes, GitHub's conclusion (:data:`CONCLUSION`), and for jobs the runner's labels and group. 

388 

389 :param pipeline: The workflow run. 

390 :returns: The workflow run as a trace. 

391 :raises TypeError: If parameter 'pipeline' is not of type :class:`~pyTooling.CI.GitHub.Pipeline`. 

392 """ 

393 if not isinstance(pipeline, Pipeline): 393 ↛ 394line 393 didn't jump to line 394 because the condition on line 393 was never true

394 ex = TypeError("Parameter 'pipeline' is not of type 'Pipeline'.") 

395 ex.add_note(f"Got type '{getFullyQualifiedName(pipeline)}'.") 

396 raise ex 

397 

398 beginTime = pipeline.StartedAt if pipeline.StartedAt is not None else pipeline.CreatedAt 

399 endTime = cls._NotBefore(pipeline.CompletedAt, beginTime) if beginTime is not None else pipeline.CompletedAt 

400 

401 # a job may be queued before the run reports itself started, and may complete after the run's last update 

402 jobsBegin, jobsEnd = cls._Timespan( 

403 pipeline.ContentsCreatedAt, pipeline.ContentsStartedAt, pipeline.ContentsCompletedAt 

404 ) 

405 if jobsBegin is not None: 

406 beginTime = jobsBegin if beginTime is None else min(beginTime, jobsBegin) 

407 

408 if endTime is not None and jobsEnd is not None: 

409 endTime = max(endTime, jobsEnd) 

410 

411 trace = cls( 

412 pipeline.Name, 

413 beginTime, 

414 endTime, 

415 runID=None if pipeline.ID is None else str(pipeline.ID), 

416 runURL=None if pipeline.URL is None else str(pipeline.URL), 

417 result=cls._Result(pipeline), 

418 reference=pipeline.GitReference, 

419 revision=pipeline.SHA, 

420 attributes={ 

421 GitHub.Conclusion: None if pipeline.Conclusion is None else pipeline.Conclusion.value, 

422 GitHub.Run.Attempt: pipeline.RunAttempt, 

423 GitHub.Run.Number: pipeline.RunNumber, 

424 GitHub.Workflow.Path: pipeline.Path, 

425 GitHub.Event: None if pipeline.Event is None else pipeline.Event.value 

426 } 

427 ) 

428 cls._AddContents(pipeline, trace) 

429 

430 return trace 

431 

432 

433@export 

434class WorkflowRunReader(RESTClient): 

435 """ 

436 Reads workflow runs of a GitHub repository through the GitHub REST API and converts them into traces. 

437 

438 A token is needed for a private repository, and it raises the rate limit for a public one - inside a workflow, 

439 ``GITHUB_TOKEN`` with the ``actions: read`` permission suffices. 

440 

441 The requests themselves - the retries, the pagination and the token's boundary - are 

442 :class:`~pyTooling.REST.RESTClient`'s. 

443 """ 

444 _repository: str #: Repository as ``owner/name``. 

445 

446 def __init__( 

447 self, 

448 repository: str, 

449 token: Nullable[str] = None, 

450 *, 

451 apiURL: Union[str, URL] = GITHUB_API_URL, 

452 timeout: float = 30.0, 

453 retries: int = 3, 

454 retryDelay: float = 2.0 

455 ) -> None: 

456 """ 

457 Initializes a reader for the workflow runs of a repository. 

458 

459 :param repository: The repository as ``owner/name``. 

460 :param token: Optional, token authorizing the requests. Default: anonymous requests. 

461 :param apiURL: Optional, base URL of the GitHub REST API, e.g. of a GitHub Enterprise Server. 

462 Default: :data:`GITHUB_API_URL`. 

463 :param timeout: Optional, timeout of a single request in seconds. Default: ``30.0``. 

464 :param retries: Optional, how often a transiently failing request is tried again. ``0`` tries once. 

465 Default: ``3``. 

466 :param retryDelay: Optional, pause in seconds before a request is tried again the first time. The pause doubles 

467 with every further attempt. Default: ``2.0``. 

468 :raises ValueError: If parameter 'repository' is ``None``. 

469 :raises TypeError: If parameter 'repository' is not of type :class:`str`. 

470 :raises ValueError: If parameter 'repository' isn't of the form ``owner/name``. 

471 :raises TypeError: If a parameter of :class:`~pyTooling.REST.RESTClient` has the wrong type. 

472 :raises ValueError: If a parameter of :class:`~pyTooling.REST.RESTClient` has an invalid value. 

473 """ 

474 super().__init__(apiURL, token, headers=_GITHUB_HEADERS, timeout=timeout, retries=retries, retryDelay=retryDelay) 

475 

476 if repository is None: 

477 raise ValueError("Parameter 'repository' is None.") 

478 elif not isinstance(repository, str): 478 ↛ 479line 478 didn't jump to line 479 because the condition on line 478 was never true

479 ex = TypeError("Parameter 'repository' is not of type 'str'.") 

480 ex.add_note(f"Got type '{getFullyQualifiedName(repository)}'.") 

481 raise ex 

482 elif len(parts := repository.split("/")) != 2 or "" in parts: 

483 ex = ValueError("Parameter 'repository' isn't of the form 'owner/name'.") 

484 ex.add_note(f"Got value '{repository}'.") 

485 raise ex 

486 

487 self._repository = repository 

488 

489 @readonly 

490 def Repository(self) -> str: 

491 """ 

492 Read-only property to access the repository (:attr:`_repository`). 

493 

494 :returns: The repository as ``owner/name``. 

495 """ 

496 return self._repository 

497 

498 def ReadRun(self, runID: int, attempt: Nullable[int] = None) -> Trace: 

499 """ 

500 Read a workflow run and all its jobs, and convert them into a trace. 

501 

502 :param runID: The workflow run's identifier. 

503 :param attempt: Optional, the run attempt to read. Default: the latest attempt. 

504 :returns: The workflow run as a trace (see :meth:`WorkflowRunTrace.FromJSON`). 

505 :raises ValueError: If parameter 'runID' is ``None``. 

506 :raises TypeError: If parameter 'runID' is not of type :class:`int`. 

507 :raises ValueError: If parameter 'runID' isn't positive. 

508 :raises TypeError: If parameter 'attempt' is not of type :class:`int`. 

509 :raises ValueError: If parameter 'attempt' isn't positive. 

510 :raises RESTError: If a request fails, or GitHub's answer isn't a JSON object. 

511 :raises GitHubError: If GitHub's answer lacks a mandatory field. 

512 """ 

513 if runID is None: 

514 raise ValueError("Parameter 'runID' is None.") 

515 elif isinstance(runID, bool) or not isinstance(runID, int): 

516 ex = TypeError("Parameter 'runID' is not of type 'int'.") 

517 ex.add_note(f"Got type '{getFullyQualifiedName(runID)}'.") 

518 raise ex 

519 elif runID <= 0: 

520 ex = ValueError("Parameter 'runID' isn't positive.") 

521 ex.add_note(f"Got value '{runID}'.") 

522 raise ex 

523 

524 if attempt is not None: 

525 if isinstance(attempt, bool) or not isinstance(attempt, int): 525 ↛ 526line 525 didn't jump to line 526 because the condition on line 525 was never true

526 ex = TypeError("Parameter 'attempt' is not of type 'int'.") 

527 ex.add_note(f"Got type '{getFullyQualifiedName(attempt)}'.") 

528 raise ex 

529 elif attempt <= 0: 

530 ex = ValueError("Parameter 'attempt' isn't positive.") 

531 ex.add_note(f"Got value '{attempt}'.") 

532 raise ex 

533 

534 runPath = f"repos/{self._repository}/actions/runs/{runID}" 

535 if attempt is None: 

536 run, _ = self.GetJSONObject(runPath) 

537 jobsPath = f"{runPath}/jobs?filter=latest&per_page=100" 

538 else: 

539 run, _ = self.GetJSONObject(f"{runPath}/attempts/{attempt}") 

540 jobsPath = f"{runPath}/attempts/{attempt}/jobs?per_page=100" 

541 

542 jobs: list[JSONObject] = [] 

543 nextPath: Nullable[str] = jobsPath 

544 while nextPath is not None: 

545 page, nextPath = self.GetJSONObject(nextPath) 

546 if not isinstance(pageJobs := page.get("jobs", None), list): 

547 raise GitHubError(f"Field 'jobs' is missing in the answer of '{jobsPath}'.") 

548 jobs.extend(pageJobs) 

549 

550 return WorkflowRunTrace.FromJSON(run, jobs) 

551 

552 def _AddErrorNotes(self, error: RESTError, status: int) -> None: 

553 """ 

554 Add a note saying what a failing status means for the GitHub REST API. 

555 

556 :param error: The error the note is added to. 

557 :param status: The HTTP status the request failed with. 

558 """ 

559 if status in (401, 403, 404): 559 ↛ exitline 559 didn't return from function '_AddErrorNotes' because the condition on line 559 was always true

560 error.add_note("Check the repository's name, and that the token may read the repository's actions.")