ci-time-tracker 0.3.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (42) hide show
  1. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/PKG-INFO +43 -13
  2. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/README.md +42 -12
  3. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/__init__.py +1 -1
  4. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/cache.py +27 -1
  5. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/cli.py +85 -33
  6. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/detection.py +25 -4
  7. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/github_api.py +65 -22
  8. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/html_report.py +8 -2
  9. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/PKG-INFO +43 -13
  10. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/SOURCES.txt +2 -1
  11. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/pyproject.toml +1 -1
  12. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_cli.py +11 -0
  13. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_detection.py +29 -1
  14. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_field_test_3.py +12 -0
  15. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_github_api.py +66 -14
  16. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_html_report.py +14 -0
  17. ci_time_tracker-0.3.1/tests/test_workflow_cache.py +115 -0
  18. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/LICENSE +0 -0
  19. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/__main__.py +0 -0
  20. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/analyzer.py +0 -0
  21. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/config_parser.py +0 -0
  22. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/gates.py +0 -0
  23. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/impact.py +0 -0
  24. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/log_parser.py +0 -0
  25. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/models.py +0 -0
  26. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/pricing.py +0 -0
  27. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/recommendations.py +0 -0
  28. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/reporter.py +0 -0
  29. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/dependency_links.txt +0 -0
  30. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/entry_points.txt +0 -0
  31. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/requires.txt +0 -0
  32. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/top_level.txt +0 -0
  33. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/setup.cfg +0 -0
  34. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_analyzer.py +0 -0
  35. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_config_parser.py +0 -0
  36. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_gates_markdown.py +0 -0
  37. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_impact.py +0 -0
  38. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_log_parser.py +0 -0
  39. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_metrics.py +0 -0
  40. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_packaging.py +0 -0
  41. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_recommendations.py +0 -0
  42. {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_reporter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ci-time-tracker
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Find where CI time and money go: cost, flaky steps and regressions from GitHub Actions, and a PR comment action
5
5
  Author: ci-time-tracker contributors
6
6
  License-Expression: MIT
@@ -36,22 +36,44 @@ Dynamic: license-file
36
36
 
37
37
  Find where your CI time and money go. ci-time-tracker reads your GitHub Actions
38
38
  history and reports the steps that cost the most, the ones that are flaky, and
39
- the ones that got slower, with the commit where it started. Its GitHub Action
40
- comments on each pull request with how it changes CI time and cost.
39
+ the ones that got slower, with the commit where it started. It also recommends
40
+ changes to your workflows, backed by what your runs actually did. Its GitHub
41
+ Action comments on each pull request with how it changes CI time and cost.
42
+
43
+ ```bash
44
+ pip install ci-time-tracker
45
+ ci-time-tracker --github owner/repo --last 300 --format html --output report.html
46
+ ```
47
+
48
+ [![Report for microsoft/TypeScript](docs/example-report.png)](https://0xwhisp.github.io/ci-time-tracker/example-report.html)
49
+
50
+ **[See the full example report](https://0xwhisp.github.io/ci-time-tracker/example-report.html)**
51
+ for the last 300 runs of microsoft/TypeScript: three flaky steps, and
52
+ recommendations with up to 135 hours of runner time at stake, such as running
53
+ the Windows legs of three jobs only after merging, since in 87 builds they
54
+ never failed when Linux passed.
55
+
56
+ Set `GITHUB_TOKEN` first (with the GitHub CLI: `export GITHUB_TOKEN=$(gh auth token)`):
57
+ each run costs a request, and without a token GitHub allows 60 an hour.
41
58
 
42
59
  ## Features
43
60
 
44
- - **Config Analysis**: Parse GitHub Actions, GitLab CI, and CircleCI configurations to extract pipeline structure
45
- - **Log Analysis**: Parse build logs to compute actual step durations and statistics
61
+ - **History from the API**: Reads workflow runs, jobs and steps straight from
62
+ the GitHub Actions API, cached locally so later runs take seconds
46
63
  - **Flaky Detection**: Flag steps that both failed and passed on the same commit,
47
64
  including runs that only passed on a re-run
48
- - **Regression Detection**: Flag steps whose duration jumped and stayed up, and
49
- name the commit where it started
50
- - **Cost and Time**: Rank steps by the time they consume, estimate cost per runner,
51
- and separate queue time from execution time
65
+ - **Regression Detection**: Flag steps whose duration jumped and stayed up, well
66
+ beyond their usual variation, and name the commit where it started
67
+ - **Recommendations**: Dependency caching, cancelling superseded pull request
68
+ runs, and matrix legs that never catch anything, each with the time at stake
69
+ - **Cost and Time**: Rank steps by the time they consume, estimate cost per runner
70
+ at GitHub's list prices, and separate queue time from execution time
52
71
  - **Pull Request Comments**: A GitHub Action that tells each pull request how it
53
72
  changes CI time and cost, and whether its failures are in known flaky steps
54
- - **Multiple Output Formats**: Human-readable text or JSON for dashboard integration
73
+ - **Output Formats**: A self-contained HTML report, Markdown for
74
+ `$GITHUB_STEP_SUMMARY`, text, or JSON for dashboards; `--fail-on` gates
75
+ - **Config and Log Analysis**: Also parses GitHub Actions, GitLab CI and
76
+ CircleCI configurations and build logs, for providers without API support yet
55
77
 
56
78
  ## Installation
57
79
 
@@ -376,7 +398,7 @@ jobs:
376
398
  if: github.event.workflow_run.event == 'pull_request'
377
399
  runs-on: ubuntu-latest
378
400
  steps:
379
- - uses: 0xWhisp/ci-time-tracker@v0.3.0
401
+ - uses: 0xWhisp/ci-time-tracker@v0.3.1
380
402
  ```
381
403
 
382
404
  `workflow_run` runs the copy of the workflow on the default branch with the
@@ -558,7 +580,11 @@ A step has **regressed** when its duration jumped and stayed up:
558
580
  2. The later segment's median must be at least **25%** higher
559
581
  (`--regression-threshold`) and at least **10 seconds** higher, so `1s -> 2s`
560
582
  steps are not reported.
561
- 3. The most recent 5 builds must still be that slow, so a spike that already
583
+ 3. The jump must be at least **three times** the step's usual variation (the
584
+ median absolute deviation of the noisier segment). With hundreds of steps
585
+ checked, the best split of a merely noisy one would otherwise clear the
586
+ thresholds above by chance.
587
+ 4. The most recent 5 builds must still be that slow, so a spike that already
562
588
  recovered is not reported.
563
589
 
564
590
  The report names the commit and build where the regression started. A step
@@ -566,6 +592,10 @@ needs at least **10 builds** to be judged; `regression_checked_steps` says how
566
592
  many had enough history. With `--group-matrix`, the legs of a build are
567
593
  combined into one sample (their median).
568
594
 
595
+ Runs that never executed (awaiting a maintainer's approval, skipped by the
596
+ workflow's conditions, or stale) have no timings and are left out; they do not
597
+ count towards `--last`.
598
+
569
599
  When the analyzed runs span several branches, regressions are looked for on
570
600
  the repository's default branch only (`regression_branch`). Pull request
571
601
  branches run different code with different caches; mixed into one history
@@ -621,7 +651,7 @@ Then, for each release:
621
651
  1. Bump `version` in `pyproject.toml` and `__version__` in
622
652
  `ci_time_tracker/__init__.py` (a test checks they agree), and add the
623
653
  release to [CHANGELOG.md](CHANGELOG.md).
624
- 2. Tag and push: `git tag v0.3.0 && git push origin v0.3.0`.
654
+ 2. Tag and push: `git tag v0.3.1 && git push origin v0.3.1`.
625
655
  3. Create the GitHub release from the tag, with the changelog entry as its
626
656
  notes, and tick "Publish this Action to the GitHub Marketplace".
627
657
 
@@ -5,22 +5,44 @@
5
5
 
6
6
  Find where your CI time and money go. ci-time-tracker reads your GitHub Actions
7
7
  history and reports the steps that cost the most, the ones that are flaky, and
8
- the ones that got slower, with the commit where it started. Its GitHub Action
9
- comments on each pull request with how it changes CI time and cost.
8
+ the ones that got slower, with the commit where it started. It also recommends
9
+ changes to your workflows, backed by what your runs actually did. Its GitHub
10
+ Action comments on each pull request with how it changes CI time and cost.
11
+
12
+ ```bash
13
+ pip install ci-time-tracker
14
+ ci-time-tracker --github owner/repo --last 300 --format html --output report.html
15
+ ```
16
+
17
+ [![Report for microsoft/TypeScript](docs/example-report.png)](https://0xwhisp.github.io/ci-time-tracker/example-report.html)
18
+
19
+ **[See the full example report](https://0xwhisp.github.io/ci-time-tracker/example-report.html)**
20
+ for the last 300 runs of microsoft/TypeScript: three flaky steps, and
21
+ recommendations with up to 135 hours of runner time at stake, such as running
22
+ the Windows legs of three jobs only after merging, since in 87 builds they
23
+ never failed when Linux passed.
24
+
25
+ Set `GITHUB_TOKEN` first (with the GitHub CLI: `export GITHUB_TOKEN=$(gh auth token)`):
26
+ each run costs a request, and without a token GitHub allows 60 an hour.
10
27
 
11
28
  ## Features
12
29
 
13
- - **Config Analysis**: Parse GitHub Actions, GitLab CI, and CircleCI configurations to extract pipeline structure
14
- - **Log Analysis**: Parse build logs to compute actual step durations and statistics
30
+ - **History from the API**: Reads workflow runs, jobs and steps straight from
31
+ the GitHub Actions API, cached locally so later runs take seconds
15
32
  - **Flaky Detection**: Flag steps that both failed and passed on the same commit,
16
33
  including runs that only passed on a re-run
17
- - **Regression Detection**: Flag steps whose duration jumped and stayed up, and
18
- name the commit where it started
19
- - **Cost and Time**: Rank steps by the time they consume, estimate cost per runner,
20
- and separate queue time from execution time
34
+ - **Regression Detection**: Flag steps whose duration jumped and stayed up, well
35
+ beyond their usual variation, and name the commit where it started
36
+ - **Recommendations**: Dependency caching, cancelling superseded pull request
37
+ runs, and matrix legs that never catch anything, each with the time at stake
38
+ - **Cost and Time**: Rank steps by the time they consume, estimate cost per runner
39
+ at GitHub's list prices, and separate queue time from execution time
21
40
  - **Pull Request Comments**: A GitHub Action that tells each pull request how it
22
41
  changes CI time and cost, and whether its failures are in known flaky steps
23
- - **Multiple Output Formats**: Human-readable text or JSON for dashboard integration
42
+ - **Output Formats**: A self-contained HTML report, Markdown for
43
+ `$GITHUB_STEP_SUMMARY`, text, or JSON for dashboards; `--fail-on` gates
44
+ - **Config and Log Analysis**: Also parses GitHub Actions, GitLab CI and
45
+ CircleCI configurations and build logs, for providers without API support yet
24
46
 
25
47
  ## Installation
26
48
 
@@ -345,7 +367,7 @@ jobs:
345
367
  if: github.event.workflow_run.event == 'pull_request'
346
368
  runs-on: ubuntu-latest
347
369
  steps:
348
- - uses: 0xWhisp/ci-time-tracker@v0.3.0
370
+ - uses: 0xWhisp/ci-time-tracker@v0.3.1
349
371
  ```
350
372
 
351
373
  `workflow_run` runs the copy of the workflow on the default branch with the
@@ -527,7 +549,11 @@ A step has **regressed** when its duration jumped and stayed up:
527
549
  2. The later segment's median must be at least **25%** higher
528
550
  (`--regression-threshold`) and at least **10 seconds** higher, so `1s -> 2s`
529
551
  steps are not reported.
530
- 3. The most recent 5 builds must still be that slow, so a spike that already
552
+ 3. The jump must be at least **three times** the step's usual variation (the
553
+ median absolute deviation of the noisier segment). With hundreds of steps
554
+ checked, the best split of a merely noisy one would otherwise clear the
555
+ thresholds above by chance.
556
+ 4. The most recent 5 builds must still be that slow, so a spike that already
531
557
  recovered is not reported.
532
558
 
533
559
  The report names the commit and build where the regression started. A step
@@ -535,6 +561,10 @@ needs at least **10 builds** to be judged; `regression_checked_steps` says how
535
561
  many had enough history. With `--group-matrix`, the legs of a build are
536
562
  combined into one sample (their median).
537
563
 
564
+ Runs that never executed (awaiting a maintainer's approval, skipped by the
565
+ workflow's conditions, or stale) have no timings and are left out; they do not
566
+ count towards `--last`.
567
+
538
568
  When the analyzed runs span several branches, regressions are looked for on
539
569
  the repository's default branch only (`regression_branch`). Pull request
540
570
  branches run different code with different caches; mixed into one history
@@ -590,7 +620,7 @@ Then, for each release:
590
620
  1. Bump `version` in `pyproject.toml` and `__version__` in
591
621
  `ci_time_tracker/__init__.py` (a test checks they agree), and add the
592
622
  release to [CHANGELOG.md](CHANGELOG.md).
593
- 2. Tag and push: `git tag v0.3.0 && git push origin v0.3.0`.
623
+ 2. Tag and push: `git tag v0.3.1 && git push origin v0.3.1`.
594
624
  3. Create the GitHub release from the tag, with the changelog entry as its
595
625
  notes, and tick "Publish this Action to the GitHub Marketplace".
596
626
 
@@ -1,3 +1,3 @@
1
1
  """ci-time-tracker: A CLI tool for analyzing CI/CD pipeline configurations and build logs."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -2,7 +2,8 @@
2
2
 
3
3
  Listing workflow runs is cheap, but every run costs an extra request for its
4
4
  jobs. Those payloads no longer change once a run has completed, so they are
5
- stored in a small SQLite database between invocations.
5
+ stored in a small SQLite database between invocations. Workflow files are
6
+ stored too, keyed by the commit they were read at, which pins their content.
6
7
  """
7
8
 
8
9
  import json
@@ -21,6 +22,14 @@ CREATE TABLE IF NOT EXISTS run_jobs (
21
22
  payload TEXT NOT NULL,
22
23
  PRIMARY KEY (repo, run_id, run_attempt)
23
24
  );
25
+ CREATE TABLE IF NOT EXISTS workflow_files (
26
+ repo TEXT NOT NULL,
27
+ path TEXT NOT NULL,
28
+ ref TEXT NOT NULL,
29
+ fetched_at TEXT NOT NULL,
30
+ content TEXT NOT NULL,
31
+ PRIMARY KEY (repo, path, ref)
32
+ );
24
33
  """
25
34
 
26
35
 
@@ -93,6 +102,23 @@ class RunCache:
93
102
  )
94
103
  self._connection.commit()
95
104
 
105
+ def get_workflow_file(self, repo: str, path: str, ref: str) -> str | None:
106
+ """Return a workflow file as read at commit `ref`, or None when not cached."""
107
+ row = self._connection.execute(
108
+ "SELECT content FROM workflow_files WHERE repo = ? AND path = ? AND ref = ?",
109
+ (repo, path, ref),
110
+ ).fetchone()
111
+ return None if row is None else row[0]
112
+
113
+ def store_workflow_file(self, repo: str, path: str, ref: str, content: str) -> None:
114
+ """Cache a workflow file as read at commit `ref`."""
115
+ self._connection.execute(
116
+ "INSERT OR REPLACE INTO workflow_files "
117
+ "(repo, path, ref, fetched_at, content) VALUES (?, ?, ?, ?, ?)",
118
+ (repo, path, ref, datetime.now(timezone.utc).isoformat(), content),
119
+ )
120
+ self._connection.commit()
121
+
96
122
  def count(self, repo: str | None = None) -> int:
97
123
  """Number of cached runs, optionally limited to one repository."""
98
124
  if repo is None:
@@ -7,11 +7,12 @@ parsing, file I/O, and orchestration of parsing, analysis, and reporting.
7
7
  import argparse
8
8
  import json
9
9
  import sys
10
- from concurrent.futures import ThreadPoolExecutor
10
+ from concurrent.futures import Future, ThreadPoolExecutor
11
11
  from dataclasses import replace
12
12
  from pathlib import Path
13
13
  from typing import Any
14
14
 
15
+ from ci_time_tracker import __version__
15
16
  from ci_time_tracker.analyzer import analyze_config, analyze_logs
16
17
  from ci_time_tracker.cache import RunCache
17
18
  from ci_time_tracker.config_parser import parse_config
@@ -86,6 +87,7 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
86
87
  description="Analyze CI/CD pipeline configurations and build logs for performance bottlenecks and flaky steps.",
87
88
  epilog="Example: ci-time-tracker --config .github/workflows/ci.yml --format text",
88
89
  )
90
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
89
91
 
90
92
  # Mode selection (mutually exclusive)
91
93
  mode_group = parser.add_mutually_exclusive_group(required=True)
@@ -362,19 +364,29 @@ def load_estimates(path: str) -> dict[str, float]:
362
364
  return estimates
363
365
 
364
366
 
365
- def _default_branch_if_mixed(repo: str, logs: list[Any]) -> str | None:
367
+ def _default_branch(repo: str) -> str | None:
368
+ """The repository's default branch, or None when it cannot be read."""
369
+ try:
370
+ return GitHubClient(repo=repo, token=resolve_token()).fetch_default_branch()
371
+ except (GitHubAPIError, KeyError, TypeError):
372
+ return None
373
+
374
+
375
+ def _default_branch_if_mixed(
376
+ repo: str,
377
+ logs: list[Any],
378
+ lookup: Future[str | None] | None = None,
379
+ ) -> str | None:
366
380
  """The repository's default branch, when the runs span several branches.
367
381
 
368
382
  Regressions are looked for on that branch only; with a single branch
369
- there is nothing to choose and no request is made.
383
+ there is nothing to choose. `lookup` is a request already under way;
384
+ without one, a request is made only when needed.
370
385
  """
371
386
  branches = {log.metadata.get("branch") for log in logs if log.metadata.get("branch")}
372
387
  if len(branches) < 2:
373
388
  return None
374
- try:
375
- return GitHubClient(repo=repo, token=resolve_token()).fetch_default_branch()
376
- except (GitHubAPIError, KeyError, TypeError):
377
- return None
389
+ return lookup.result() if lookup is not None else _default_branch(repo)
378
390
 
379
391
 
380
392
  def _visibility_pricing(logs: list[Any], pricing: Any) -> tuple[PricingTable, str | None]:
@@ -400,38 +412,69 @@ def _visibility_pricing(logs: list[Any], pricing: Any) -> tuple[PricingTable, st
400
412
  _MAX_WORKFLOW_FILES = 20
401
413
 
402
414
 
403
- def _fetch_workflows(repo: str, logs: list[Any]) -> dict[str, dict[str, Any]]:
415
+ def _workflow_refs(repo: str, logs: list[Any]) -> dict[str, str | None]:
416
+ """Map each workflow file behind the runs to the commit to read it at.
417
+
418
+ That is the newest run's commit from the repository itself (a fork's
419
+ commit may not be readable through it). None reads the default branch.
420
+ """
421
+ def rank(log: Any) -> tuple[bool, float]:
422
+ own = log.metadata.get("head_repository") in (None, repo)
423
+ return bool(own and log.head_sha), log.timestamp.timestamp() if log.timestamp else float("-inf")
424
+
425
+ newest: dict[str, Any] = {}
426
+ for log in logs:
427
+ path = str(log.metadata.get("workflow_path") or "")
428
+ if path.startswith(".github/workflows/") and (path not in newest or rank(log) > rank(newest[path])):
429
+ newest[path] = log
430
+
431
+ return {
432
+ path: newest[path].head_sha if rank(newest[path])[0] else None
433
+ for path in sorted(newest)[:_MAX_WORKFLOW_FILES]
434
+ }
435
+
436
+
437
+ def _fetch_workflows(repo: str, logs: list[Any], cache: Any = None) -> dict[str, dict[str, Any]]:
404
438
  """Fetch and parse the workflow files behind the analyzed runs.
405
439
 
406
440
  Recommendations that need a workflow's configuration skip the ones that
407
441
  cannot be read, so a failure here only narrows what is recommended.
442
+ Files read at a commit are cached, since that pins their content.
408
443
  """
409
- paths = sorted({
410
- log.metadata["workflow_path"]
411
- for log in logs
412
- if str(log.metadata.get("workflow_path") or "").startswith(".github/workflows/")
413
- })[:_MAX_WORKFLOW_FILES]
414
-
415
- if not paths:
416
- return {}
417
-
418
- client = GitHubClient(repo=repo, token=resolve_token())
444
+ refs = _workflow_refs(repo, logs)
445
+ texts: dict[str, str | None] = {}
446
+ if cache is not None:
447
+ for path, ref in refs.items():
448
+ if ref:
449
+ cached = cache.get_workflow_file(repo, path, ref)
450
+ if cached is not None:
451
+ texts[path] = cached
452
+
453
+ missing = [path for path in refs if path not in texts]
454
+ if missing:
455
+ client = GitHubClient(repo=repo, token=resolve_token())
456
+
457
+ def fetch(path: str) -> tuple[str, str | None]:
458
+ try:
459
+ return path, client.fetch_workflow_file(path, ref=refs[path])
460
+ except GitHubAPIError as e:
461
+ print(f"Warning: could not read {path} ({e}); skipping its recommendations", file=sys.stderr)
462
+ return path, None
419
463
 
420
- def fetch(path: str) -> tuple[str, str | None]:
421
- try:
422
- return path, client.fetch_workflow_file(path)
423
- except GitHubAPIError as e:
424
- print(f"Warning: could not read {path} ({e}); skipping its recommendations", file=sys.stderr)
425
- return path, None
464
+ # Fetched concurrently: one after the other they added over a second
465
+ # to an analysis whose runs were all cached
466
+ with ThreadPoolExecutor(max_workers=min(DEFAULT_WORKERS, len(missing))) as pool:
467
+ for path, text in pool.map(fetch, missing):
468
+ texts[path] = text
469
+ if text is not None and cache is not None and refs[path]:
470
+ cache.store_workflow_file(repo, path, refs[path], text)
426
471
 
427
- # Fetched concurrently: these requests are not cached, and one after the
428
- # other they added over a second to a cached analysis
429
472
  workflows: dict[str, dict[str, Any]] = {}
430
- with ThreadPoolExecutor(max_workers=min(DEFAULT_WORKERS, len(paths))) as pool:
431
- for path, text in pool.map(fetch, paths):
432
- parsed = parse_workflow(text) if text else None
433
- if parsed is not None:
434
- workflows[path] = parsed
473
+ for path in refs:
474
+ text = texts.get(path)
475
+ parsed = parse_workflow(text) if text else None
476
+ if parsed is not None:
477
+ workflows[path] = parsed
435
478
 
436
479
  return workflows
437
480
 
@@ -672,6 +715,14 @@ def main() -> int:
672
715
  except Exception as e:
673
716
  print(f"Warning: run cache unavailable ({e})", file=sys.stderr)
674
717
 
718
+ # Runs of every branch almost always need the default branch to
719
+ # look for regressions on, so it is asked for while listing
720
+ lookup = None
721
+ if args.branch is None:
722
+ lookups = ThreadPoolExecutor(max_workers=1)
723
+ lookup = lookups.submit(_default_branch, args.github)
724
+ lookups.shutdown(wait=False)
725
+
675
726
  skipped: list[str] = []
676
727
  try:
677
728
  logs = fetch_build_logs(
@@ -682,6 +733,7 @@ def main() -> int:
682
733
  cache=cache,
683
734
  skipped=skipped,
684
735
  )
736
+ workflows = _fetch_workflows(args.github, logs, cache) if logs else {}
685
737
  except GitHubAPIError as e:
686
738
  print(f"Error: {e}", file=sys.stderr)
687
739
  return 3
@@ -700,7 +752,7 @@ def main() -> int:
700
752
  pricing,
701
753
  args.group_matrix,
702
754
  regression_threshold=args.regression_threshold,
703
- regression_branch=_default_branch_if_mixed(args.github, logs),
755
+ regression_branch=_default_branch_if_mixed(args.github, logs, lookup),
704
756
  )
705
757
  result.metadata["repository"] = args.github
706
758
  if visibility:
@@ -713,7 +765,7 @@ def main() -> int:
713
765
  file=sys.stderr,
714
766
  )
715
767
  result.recommendations = [
716
- item.to_dict() for item in recommend(logs, _fetch_workflows(args.github, logs), pricing)
768
+ item.to_dict() for item in recommend(logs, workflows, pricing)
717
769
  ]
718
770
 
719
771
  else:
@@ -8,8 +8,9 @@ its job.
8
8
 
9
9
  Regression: a step whose duration jumped and stayed up. The step's history is
10
10
  split at the point that best separates "before" from "after", and the jump
11
- must be large in both relative and absolute terms and still hold for the most
12
- recent builds, so that a spike that already recovered is not reported.
11
+ must be large in both relative and absolute terms, large against the step's
12
+ own variation, and still hold for the most recent builds, so that a spike that
13
+ already recovered is not reported.
13
14
  """
14
15
 
15
16
  import heapq
@@ -24,6 +25,12 @@ DEFAULT_REGRESSION_THRESHOLD = 0.25
24
25
  DEFAULT_MIN_SAMPLES = 5
25
26
  DEFAULT_MIN_INCREASE_SECONDS = 10.0
26
27
 
28
+ # The jump must be this many times the noisier segment's median absolute
29
+ # deviation. Checking hundreds of steps, the best split of a merely noisy one
30
+ # often clears the relative and absolute thresholds: in field tests such
31
+ # splits stayed under 3 times the noise, a real regression went well above.
32
+ DEFAULT_MIN_SHIFT_SPREADS = 3.0
33
+
27
34
  _EXAMPLE_COMMITS = 3
28
35
  _SHORT_SHA = 7
29
36
 
@@ -160,19 +167,26 @@ def _prefix_medians_and_costs(values: list[float]) -> list[tuple[float, float]]:
160
167
  return results
161
168
 
162
169
 
170
+ def _median_absolute_deviation(values: list[float]) -> float:
171
+ center = median(values)
172
+ return median(abs(value - center) for value in values)
173
+
174
+
163
175
  def detect_regression(
164
176
  samples: list[tuple[float, BuildLog]],
165
177
  threshold: float = DEFAULT_REGRESSION_THRESHOLD,
166
178
  min_samples: int = DEFAULT_MIN_SAMPLES,
167
179
  min_increase_seconds: float = DEFAULT_MIN_INCREASE_SECONDS,
180
+ min_shift_spreads: float = DEFAULT_MIN_SHIFT_SPREADS,
168
181
  ) -> dict[str, Any] | None:
169
182
  """Detect a lasting increase in a step's duration.
170
183
 
171
184
  Finds the single split that best divides the history into two stable
172
185
  segments (least total absolute deviation from each segment's median),
173
186
  then reports a regression only when the later segment is slower by at
174
- least `threshold` and `min_increase_seconds`, and the most recent
175
- `min_samples` builds are still that slow.
187
+ least `threshold`, `min_increase_seconds` and `min_shift_spreads` times
188
+ the noisier segment's variation, and the most recent `min_samples` builds
189
+ are still that slow.
176
190
 
177
191
  Args:
178
192
  samples: (duration, build) pairs in chronological order, one per build
@@ -180,6 +194,9 @@ def detect_regression(
180
194
  min_samples: Minimum builds on each side of the split
181
195
  min_increase_seconds: Minimum absolute increase, so that 1s -> 2s
182
196
  steps are not reported
197
+ min_shift_spreads: Minimum increase in units of the noisier
198
+ segment's median absolute deviation, so that the best split of a
199
+ noisy step is not reported
183
200
 
184
201
  Returns:
185
202
  Regression details, or None when there is none or too little history
@@ -217,6 +234,10 @@ def detect_regression(
217
234
  if current - baseline < min_increase_seconds or recent - baseline < min_increase_seconds:
218
235
  return None
219
236
 
237
+ spread = max(_median_absolute_deviation(durations[:split]), _median_absolute_deviation(durations[split:]))
238
+ if current - baseline < min_shift_spreads * spread:
239
+ return None
240
+
220
241
  started = samples[split][1]
221
242
 
222
243
  return {
@@ -7,6 +7,7 @@ so analysis does not depend on parsing runner log text.
7
7
 
8
8
  import base64
9
9
  import json
10
+ import math
10
11
  import os
11
12
  import time
12
13
  import urllib.error
@@ -30,6 +31,19 @@ DEFAULT_WORKERS = 8
30
31
 
31
32
  _PER_PAGE = 100
32
33
 
34
+ # GitHub takes longer to build a bigger page of runs, so a limit is split into
35
+ # smaller pages, one per worker, down to this size
36
+ _MIN_RUNS_PAGE = 25
37
+
38
+ # Runs that never ran are skipped and made up from later pages, but the
39
+ # listing stops after this many times the pages the limit needed
40
+ _MAX_LISTING_FACTOR = 3
41
+
42
+ # Run conclusions for runs that never executed: waiting for a maintainer's
43
+ # approval (pull requests from forks), skipped by the workflow's conditions,
44
+ # or superseded while queued. They have no timings to analyze.
45
+ _NOT_RUN_CONCLUSIONS = frozenset({"action_required", "skipped", "stale"})
46
+
33
47
  # A run with many jobs, each with many steps, can make GitHub time out
34
48
  # building a 100-job page (502/504); such runs are paged in smaller pieces
35
49
  _SMALL_JOBS_PAGE = 30
@@ -320,6 +334,7 @@ class GitHubClient:
320
334
  branch: str | None = None,
321
335
  limit: int = DEFAULT_RUN_LIMIT,
322
336
  status: str | None = "completed",
337
+ include: Callable[[dict[str, Any]], bool] | None = None,
323
338
  ) -> Iterator[dict[str, Any]]:
324
339
  """Yield up to `limit` workflow runs, newest first.
325
340
 
@@ -330,6 +345,8 @@ class GitHubClient:
330
345
  limit: Maximum number of runs to yield
331
346
  status: Run status filter; "completed" skips in-flight runs whose
332
347
  timings are not final yet
348
+ include: Optional filter; runs it rejects are not yielded and do
349
+ not count towards `limit`, so later pages are read instead
333
350
  """
334
351
  if limit <= 0:
335
352
  return
@@ -343,9 +360,15 @@ class GitHubClient:
343
360
  # together: listing is the slowest part of a cached analysis, at a few
344
361
  # seconds a page. The pull requests attached to each run are left out
345
362
  # of the payload; nothing reads them (a run's pull request is looked up
346
- # by its commit).
347
- per_page = min(_PER_PAGE, limit)
348
- pages = -(-limit // per_page)
363
+ # by its commit). Eight pages of 25 come back in about half the time
364
+ # of two pages of 100.
365
+ # With a filter, a margin is listed up front: a second round of pages
366
+ # for the runs it dropped would add a few seconds.
367
+ target = limit + limit // 4 if include is not None else limit
368
+ per_page = min(_PER_PAGE, max(_MIN_RUNS_PAGE, -(-target // DEFAULT_WORKERS)), target)
369
+ pages = -(-target // per_page)
370
+ # Filtered out runs are made up from later pages, within reason
371
+ last_page = pages * _MAX_LISTING_FACTOR
349
372
 
350
373
  def fetch(page: int) -> list[dict[str, Any]]:
351
374
  payload = self._get(
@@ -360,27 +383,39 @@ class GitHubClient:
360
383
  )
361
384
  return payload.get("workflow_runs", []) if isinstance(payload, dict) else []
362
385
 
363
- with ThreadPoolExecutor(max_workers=min(DEFAULT_WORKERS, pages)) as pool:
364
- batches = list(pool.map(fetch, range(1, pages + 1)))
365
-
366
386
  seen: set[Any] = set()
367
- yielded = 0
368
-
369
- for runs in batches:
370
- for run in runs:
371
- # A run created while the pages were fetched shifts the others
372
- # by one, which can show a run on two pages
373
- if run.get("id") in seen:
374
- continue
375
- seen.add(run.get("id"))
376
- yield run
377
- yielded += 1
378
- if yielded >= limit:
387
+ yielded = listed = 0
388
+ first = 1
389
+
390
+ while first <= last_page:
391
+ with ThreadPoolExecutor(max_workers=min(DEFAULT_WORKERS, pages)) as pool:
392
+ batches = list(pool.map(fetch, range(first, first + pages)))
393
+
394
+ for runs in batches:
395
+ for run in runs:
396
+ # A run created while the pages were fetched shifts the
397
+ # others by one, which can show a run on two pages
398
+ if run.get("id") in seen:
399
+ continue
400
+ seen.add(run.get("id"))
401
+ listed += 1
402
+ if include is not None and not include(run):
403
+ continue
404
+ yield run
405
+ yielded += 1
406
+ if yielded >= limit:
407
+ return
408
+
409
+ # A short page is the last one; later pages can only be empty
410
+ if len(runs) < per_page:
379
411
  return
380
412
 
381
- # A short page is the last one; later pages can only be empty
382
- if len(runs) < per_page:
383
- return
413
+ # Enough further pages for the runs still missing, at the share
414
+ # of listed runs the filter has kept so far
415
+ first += pages
416
+ kept = max(yielded / listed, 0.1)
417
+ needed = math.ceil((limit - yielded) / (per_page * kept))
418
+ pages = max(1, min(DEFAULT_WORKERS, last_page - first + 1, needed))
384
419
 
385
420
  def fetch_run_jobs(self, run_id: int | str, run_attempt: int | None = None) -> list[dict[str, Any]]:
386
421
  """Return every job of a workflow run attempt, following pagination.
@@ -567,6 +602,11 @@ def _earlier_attempt(run: dict[str, Any], attempt: int) -> dict[str, Any]:
567
602
  }
568
603
 
569
604
 
605
+ def _ran(run: dict[str, Any]) -> bool:
606
+ """Whether a run executed at all, so that it has timings to analyze."""
607
+ return run.get("conclusion") not in _NOT_RUN_CONCLUSIONS
608
+
609
+
570
610
  def fetch_build_logs(
571
611
  repo: str,
572
612
  workflow: str | None = None,
@@ -587,6 +627,9 @@ def fetch_build_logs(
587
627
  served from `cache` (see cache.RunCache) and the rest are fetched
588
628
  concurrently.
589
629
 
630
+ Runs that never executed (awaiting approval, skipped, stale) are left
631
+ out and do not count towards `limit`.
632
+
590
633
  The runs listing only shows the latest attempt of a re-run. The earlier
591
634
  attempts are fetched too, as BuildLogs of their own: a step that failed
592
635
  and then passed on a re-run is the strongest evidence of flakiness.
@@ -623,7 +666,7 @@ def fetch_build_logs(
623
666
  # One entry per (run, attempt), newest first: (payload, attempt, superseded)
624
667
  attempts: list[tuple[dict[str, Any], int, bool]] = []
625
668
 
626
- for run in client.iter_workflow_runs(workflow=workflow, branch=branch, limit=limit):
669
+ for run in client.iter_workflow_runs(workflow=workflow, branch=branch, limit=limit, include=_ran):
627
670
  if run.get("id") is None:
628
671
  continue
629
672
 
@@ -8,6 +8,7 @@ pull requests) is escaped.
8
8
 
9
9
  import html
10
10
  import math
11
+ from collections import Counter
11
12
  from statistics import median
12
13
  from typing import Any
13
14
 
@@ -139,8 +140,13 @@ def _summary_cards(report: Report) -> str:
139
140
  note = "at GitHub list prices"
140
141
  cards.append(_card("Estimated cost", format_cost(summary["estimated_cost_usd"]), note))
141
142
 
142
- issues = len(report.issues)
143
- cards.append(_card("Issues", str(issues), "regressions and flaky steps"))
143
+ kinds = Counter(issue.get("type") for issue in report.issues)
144
+ parts = [
145
+ f"{count} {label if count == 1 else label + 's'}"
146
+ for label, count in (("regression", kinds["regression"]), ("flaky step", kinds["flaky"]))
147
+ if count
148
+ ]
149
+ cards.append(_card("Issues", str(len(report.issues)), " · ".join(parts) or "no regressions or flaky steps"))
144
150
 
145
151
  return f'<div class="cards">{"".join(cards)}</div>'
146
152
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ci-time-tracker
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Find where CI time and money go: cost, flaky steps and regressions from GitHub Actions, and a PR comment action
5
5
  Author: ci-time-tracker contributors
6
6
  License-Expression: MIT
@@ -36,22 +36,44 @@ Dynamic: license-file
36
36
 
37
37
  Find where your CI time and money go. ci-time-tracker reads your GitHub Actions
38
38
  history and reports the steps that cost the most, the ones that are flaky, and
39
- the ones that got slower, with the commit where it started. Its GitHub Action
40
- comments on each pull request with how it changes CI time and cost.
39
+ the ones that got slower, with the commit where it started. It also recommends
40
+ changes to your workflows, backed by what your runs actually did. Its GitHub
41
+ Action comments on each pull request with how it changes CI time and cost.
42
+
43
+ ```bash
44
+ pip install ci-time-tracker
45
+ ci-time-tracker --github owner/repo --last 300 --format html --output report.html
46
+ ```
47
+
48
+ [![Report for microsoft/TypeScript](docs/example-report.png)](https://0xwhisp.github.io/ci-time-tracker/example-report.html)
49
+
50
+ **[See the full example report](https://0xwhisp.github.io/ci-time-tracker/example-report.html)**
51
+ for the last 300 runs of microsoft/TypeScript: three flaky steps, and
52
+ recommendations with up to 135 hours of runner time at stake, such as running
53
+ the Windows legs of three jobs only after merging, since in 87 builds they
54
+ never failed when Linux passed.
55
+
56
+ Set `GITHUB_TOKEN` first (with the GitHub CLI: `export GITHUB_TOKEN=$(gh auth token)`):
57
+ each run costs a request, and without a token GitHub allows 60 an hour.
41
58
 
42
59
  ## Features
43
60
 
44
- - **Config Analysis**: Parse GitHub Actions, GitLab CI, and CircleCI configurations to extract pipeline structure
45
- - **Log Analysis**: Parse build logs to compute actual step durations and statistics
61
+ - **History from the API**: Reads workflow runs, jobs and steps straight from
62
+ the GitHub Actions API, cached locally so later runs take seconds
46
63
  - **Flaky Detection**: Flag steps that both failed and passed on the same commit,
47
64
  including runs that only passed on a re-run
48
- - **Regression Detection**: Flag steps whose duration jumped and stayed up, and
49
- name the commit where it started
50
- - **Cost and Time**: Rank steps by the time they consume, estimate cost per runner,
51
- and separate queue time from execution time
65
+ - **Regression Detection**: Flag steps whose duration jumped and stayed up, well
66
+ beyond their usual variation, and name the commit where it started
67
+ - **Recommendations**: Dependency caching, cancelling superseded pull request
68
+ runs, and matrix legs that never catch anything, each with the time at stake
69
+ - **Cost and Time**: Rank steps by the time they consume, estimate cost per runner
70
+ at GitHub's list prices, and separate queue time from execution time
52
71
  - **Pull Request Comments**: A GitHub Action that tells each pull request how it
53
72
  changes CI time and cost, and whether its failures are in known flaky steps
54
- - **Multiple Output Formats**: Human-readable text or JSON for dashboard integration
73
+ - **Output Formats**: A self-contained HTML report, Markdown for
74
+ `$GITHUB_STEP_SUMMARY`, text, or JSON for dashboards; `--fail-on` gates
75
+ - **Config and Log Analysis**: Also parses GitHub Actions, GitLab CI and
76
+ CircleCI configurations and build logs, for providers without API support yet
55
77
 
56
78
  ## Installation
57
79
 
@@ -376,7 +398,7 @@ jobs:
376
398
  if: github.event.workflow_run.event == 'pull_request'
377
399
  runs-on: ubuntu-latest
378
400
  steps:
379
- - uses: 0xWhisp/ci-time-tracker@v0.3.0
401
+ - uses: 0xWhisp/ci-time-tracker@v0.3.1
380
402
  ```
381
403
 
382
404
  `workflow_run` runs the copy of the workflow on the default branch with the
@@ -558,7 +580,11 @@ A step has **regressed** when its duration jumped and stayed up:
558
580
  2. The later segment's median must be at least **25%** higher
559
581
  (`--regression-threshold`) and at least **10 seconds** higher, so `1s -> 2s`
560
582
  steps are not reported.
561
- 3. The most recent 5 builds must still be that slow, so a spike that already
583
+ 3. The jump must be at least **three times** the step's usual variation (the
584
+ median absolute deviation of the noisier segment). With hundreds of steps
585
+ checked, the best split of a merely noisy one would otherwise clear the
586
+ thresholds above by chance.
587
+ 4. The most recent 5 builds must still be that slow, so a spike that already
562
588
  recovered is not reported.
563
589
 
564
590
  The report names the commit and build where the regression started. A step
@@ -566,6 +592,10 @@ needs at least **10 builds** to be judged; `regression_checked_steps` says how
566
592
  many had enough history. With `--group-matrix`, the legs of a build are
567
593
  combined into one sample (their median).
568
594
 
595
+ Runs that never executed (awaiting a maintainer's approval, skipped by the
596
+ workflow's conditions, or stale) have no timings and are left out; they do not
597
+ count towards `--last`.
598
+
569
599
  When the analyzed runs span several branches, regressions are looked for on
570
600
  the repository's default branch only (`regression_branch`). Pull request
571
601
  branches run different code with different caches; mixed into one history
@@ -621,7 +651,7 @@ Then, for each release:
621
651
  1. Bump `version` in `pyproject.toml` and `__version__` in
622
652
  `ci_time_tracker/__init__.py` (a test checks they agree), and add the
623
653
  release to [CHANGELOG.md](CHANGELOG.md).
624
- 2. Tag and push: `git tag v0.3.0 && git push origin v0.3.0`.
654
+ 2. Tag and push: `git tag v0.3.1 && git push origin v0.3.1`.
625
655
  3. Create the GitHub release from the tag, with the changelog entry as its
626
656
  notes, and tick "Publish this Action to the GitHub Marketplace".
627
657
 
@@ -36,4 +36,5 @@ tests/test_log_parser.py
36
36
  tests/test_metrics.py
37
37
  tests/test_packaging.py
38
38
  tests/test_recommendations.py
39
- tests/test_reporter.py
39
+ tests/test_reporter.py
40
+ tests/test_workflow_cache.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ci-time-tracker"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Find where CI time and money go: cost, flaky steps and regressions from GitHub Actions, and a PR comment action"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -574,3 +574,14 @@ structure: true
574
574
  finally:
575
575
  Path(config_path).unlink()
576
576
 
577
+
578
+
579
+ def test_version(monkeypatch, capsys):
580
+ from ci_time_tracker import __version__
581
+
582
+ monkeypatch.setattr("sys.argv", ["ci-time-tracker", "--version"])
583
+ with pytest.raises(SystemExit) as exit_info:
584
+ main()
585
+
586
+ assert exit_info.value.code == 0
587
+ assert capsys.readouterr().out.strip() == f"ci-time-tracker {__version__}"
@@ -300,7 +300,8 @@ class TestFastChangePoint:
300
300
  durations = [float(value) for value in values]
301
301
 
302
302
  expected = reference_regression(durations)
303
- result = detect_regression(samples(durations))
303
+ # The oracle covers the search; the noise rule has its own tests
304
+ result = detect_regression(samples(durations), min_shift_spreads=0)
304
305
 
305
306
  if expected is None:
306
307
  assert result is None
@@ -381,3 +382,30 @@ class TestRegressionThresholdOption:
381
382
  def test_invalid_values_are_rejected(self, value):
382
383
  with pytest.raises(SystemExit):
383
384
  parse_args(["--logs", "x", "--regression-threshold", value])
385
+
386
+
387
+ class TestRegressionAgainstNoise:
388
+ """Seen on pydantic: 16 regressions in one day, all the best split of a noisy step."""
389
+
390
+ def test_the_best_split_of_a_noisy_step_is_not_reported(self):
391
+ # pydantic's "Test ubuntu-latest / 3.12 / Run pytest": medians 121 -> 166,
392
+ # yet both halves spread as widely as the jump between them
393
+ durations = [121.0, 95.0, 160.0, 118.0, 140.0, 166.0, 120.0, 190.0, 171.0, 150.0]
394
+
395
+ assert detect_regression(samples(durations), min_shift_spreads=0) is not None
396
+ assert detect_regression(samples(durations)) is None
397
+
398
+ def test_a_clean_jump_is_still_reported(self):
399
+ durations = [100.0, 104.0, 97.0, 101.0, 99.0, 103.0, 160.0, 158.0, 163.0, 161.0, 157.0, 162.0]
400
+
401
+ result = detect_regression(samples(durations))
402
+
403
+ assert result is not None
404
+ assert result["baseline_builds"] == 6
405
+
406
+ def test_a_noisy_step_with_a_large_jump_is_still_reported(self):
407
+ # TypeScript's 623s -> 787s step: noisy, but the jump is four times the noise
408
+ durations = [600.0, 640.0, 623.0, 610.0, 650.0, 615.0, 630.0,
409
+ 787.0, 760.0, 800.0, 790.0, 770.0, 810.0, 785.0]
410
+
411
+ assert detect_regression(samples(durations)) is not None
@@ -2,6 +2,7 @@
2
2
  spring-boot, django, rubocop): regressions on one branch, and matrix-leg
3
3
  recommendations grouped per workflow and platform."""
4
4
 
5
+ from concurrent.futures import Future
5
6
  from datetime import datetime, timedelta, timezone
6
7
 
7
8
  from ci_time_tracker.analyzer import analyze_logs
@@ -120,6 +121,17 @@ class TestDefaultBranchLookup:
120
121
 
121
122
  assert _default_branch_if_mixed("acme/web", [build(1, 1.0, "main"), build(2, 1.0, "fix")]) is None
122
123
 
124
+ def test_a_lookup_under_way_is_used(self, monkeypatch):
125
+ FakeClient.calls = 0
126
+ monkeypatch.setattr("ci_time_tracker.cli.GitHubClient", FakeClient)
127
+ lookup = Future()
128
+ lookup.set_result("trunk")
129
+
130
+ mixed = [build(1, 1.0, "main"), build(2, 1.0, "fix")]
131
+ assert _default_branch_if_mixed("acme/web", mixed, lookup) == "trunk"
132
+ assert _default_branch_if_mixed("acme/web", mixed[:1], lookup) is None
133
+ assert FakeClient.calls == 0
134
+
123
135
  def test_client_reads_the_default_branch(self):
124
136
  client = GitHubClient(repo="acme/web", transport=lambda url, headers: ({"default_branch": "trunk"}, {}))
125
137
 
@@ -251,34 +251,83 @@ class TestClientRequests:
251
251
 
252
252
  def test_pagination_walks_pages_until_limit(self):
253
253
  """A limit above one page keeps paging."""
254
- first_page = [{**RUN, "id": 1000 + index} for index in range(100)]
255
- # "&page=1&", not "page=1", which "per_page=100" also contains
254
+ first_page = [{**RUN, "id": 1000 + index} for index in range(25)]
255
+ # "&page=1&", not "page=1", which "per_page=..." could also contain
256
256
  transport = FakeTransport([
257
257
  ("/jobs", JOBS),
258
258
  ("&page=1&", {"workflow_runs": first_page}),
259
259
  ("&page=2&", {"workflow_runs": [{**RUN, "id": 2000}]}),
260
260
  ])
261
261
 
262
- logs = fetch_build_logs("acme/web", limit=101, transport=transport)
262
+ logs = fetch_build_logs("acme/web", limit=26, transport=transport)
263
263
 
264
- assert len(logs) == 101
265
- assert len({log.build_id for log in logs}) == 101
264
+ assert len(logs) == 26
265
+ assert len({log.build_id for log in logs}) == 26
266
266
  assert transport.calls_matching("&page=2&")
267
267
 
268
268
  def test_pages_are_requested_together_and_deduplicated(self):
269
269
  """A run created while listing shifts it onto two pages: it counts once."""
270
- first_page = [{**RUN, "id": 1000 + index} for index in range(100)]
271
- second_page = [{**RUN, "id": 1099}] + [{**RUN, "id": 2000 + index} for index in range(99)]
270
+ first_page = [{**RUN, "id": 1000 + index} for index in range(25)]
271
+ second_page = [{**RUN, "id": 1024}] + [{**RUN, "id": 2000 + index} for index in range(24)]
272
272
  transport = FakeTransport([
273
273
  ("/jobs", JOBS),
274
274
  ("&page=1&", {"workflow_runs": first_page}),
275
275
  ("&page=2&", {"workflow_runs": second_page}),
276
+ ("&page=3&", {"workflow_runs": [{**RUN, "id": 3000}]}),
276
277
  ])
277
278
 
278
- logs = fetch_build_logs("acme/web", limit=200, transport=transport)
279
+ logs = fetch_build_logs("acme/web", limit=50, transport=transport)
280
+
281
+ # The duplicate is made up from the next page
282
+ assert len(logs) == 50
283
+ assert len({log.build_id for log in logs}) == 50
284
+
285
+ @pytest.mark.parametrize(("limit", "per_page", "pages"), [
286
+ (10, 10, 1),
287
+ (50, 25, 2),
288
+ (200, 25, 8),
289
+ (400, 50, 8),
290
+ (1000, 100, 10),
291
+ ])
292
+ def test_a_limit_is_split_into_a_page_per_worker(self, limit, per_page, pages):
293
+ """Smaller pages come back faster, so the workers share the limit."""
294
+ transport = FakeTransport([("/actions/runs?", {"workflow_runs": []})])
295
+ list(GitHubClient(repo="acme/web", transport=transport).iter_workflow_runs(limit=limit))
296
+
297
+ calls = transport.calls_matching("/actions/runs?")
298
+ assert len(calls) == pages
299
+ assert all(f"per_page={per_page}&" in url for url in calls)
300
+
301
+ def test_runs_that_never_ran_are_left_out(self):
302
+ """Awaiting approval, skipped or stale: no timings, and no place in the limit."""
303
+ never_ran = [{**RUN, "id": 100 + i, "conclusion": c} for i, c in enumerate(["action_required", "skipped", "stale"])]
304
+ ran = [{**RUN, "id": 200 + i} for i in range(22)]
305
+ transport = FakeTransport([
306
+ ("/jobs", JOBS),
307
+ ("&page=1&", {"workflow_runs": never_ran + ran}),
308
+ ("&page=2&", {"workflow_runs": [{**RUN, "id": 300 + i} for i in range(25)]}),
309
+ ])
310
+
311
+ logs = fetch_build_logs("acme/web", limit=25, transport=transport)
312
+
313
+ assert len(logs) == 25
314
+ assert not {log.build_id for log in logs} & {"100", "101", "102"}
315
+ assert not transport.calls_matching("/runs/100/")
316
+
317
+ def test_cancelled_runs_are_kept(self):
318
+ transport = FakeTransport(default_routes(runs=[{**RUN, "conclusion": "cancelled"}]))
319
+
320
+ assert len(fetch_build_logs("acme/web", limit=1, transport=transport)) == 1
279
321
 
280
- assert len(logs) == 199
281
- assert len({log.build_id for log in logs}) == 199
322
+ def test_a_listing_of_runs_that_never_ran_stops(self):
323
+ """A workflow that is always skipped must not page through its whole history."""
324
+ transport = FakeTransport([
325
+ ("/actions/runs?", {"workflow_runs": [{**RUN, "id": 100 + i, "conclusion": "skipped"} for i in range(25)]}),
326
+ ])
327
+
328
+ assert fetch_build_logs("acme/web", limit=50, transport=transport) == []
329
+ # Three pages hold the limit and its margin; at most three times that are read
330
+ assert len(transport.calls_matching("/actions/runs?")) == 9
282
331
 
283
332
  def test_pull_requests_are_left_out_of_the_listing(self):
284
333
  transport = FakeTransport(default_routes())
@@ -287,14 +336,17 @@ class TestClientRequests:
287
336
  assert "exclude_pull_requests=true" in transport.calls[0]
288
337
 
289
338
  def test_short_page_is_the_last_one(self):
290
- """Fewer runs than asked for means there is no next page to request."""
339
+ """Fewer runs than a page holds means the later pages are not read."""
291
340
  runs = [{**RUN, "id": 100 + index} for index in range(3)]
292
- transport = FakeTransport(default_routes(runs=runs))
341
+ transport = FakeTransport([
342
+ ("/jobs", JOBS),
343
+ ("&page=1&", {"workflow_runs": runs}),
344
+ ("&page=", {"workflow_runs": [{**RUN, "id": 999}]}),
345
+ ])
293
346
 
294
347
  logs = fetch_build_logs("acme/web", limit=50, transport=transport)
295
348
 
296
- assert len(logs) == 3
297
- assert len(transport.calls_matching("/actions/runs?")) == 1
349
+ assert sorted(log.build_id for log in logs) == ["100", "101", "102"]
298
350
 
299
351
  def test_empty_page_stops_pagination(self):
300
352
  transport = FakeTransport([
@@ -82,6 +82,20 @@ class TestSummaryCards:
82
82
  assert "public repository: standard runners are free" in html
83
83
 
84
84
 
85
+ def test_issues_card_counts_each_kind(self):
86
+ # A regression on main, and a step that failed then passed on one commit
87
+ logs = [build(i, 100.0 if i < 6 else 200.0) for i in range(12)]
88
+ flaky = build(12, 200.0, steps=[StepExecution(name="build / Run tests", duration_seconds=200.0, status="failure")])
89
+ flaky.head_sha = logs[-1].head_sha
90
+
91
+ html = page(logs + [flaky])
92
+
93
+ assert "1 regression · 1 flaky step" in html
94
+
95
+ def test_issues_card_without_issues(self):
96
+ assert "no regressions or flaky steps" in page([build(i, 600.0) for i in range(6)])
97
+
98
+
85
99
  class TestBuildDurationCharts:
86
100
  def test_one_chart_per_workflow(self):
87
101
  """A 10-second workflow next to a 10-minute one would only zigzag."""
@@ -0,0 +1,115 @@
1
+ """Workflow files are cached by the commit they were read at, so an analysis
2
+ whose runs are all cached makes no request for them either."""
3
+
4
+ import base64
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ from ci_time_tracker.cache import RunCache
8
+ from ci_time_tracker.cli import _fetch_workflows, _workflow_refs
9
+ from ci_time_tracker.github_api import GitHubClient
10
+ from ci_time_tracker.models import BuildLog
11
+
12
+ START = datetime(2026, 10, 1, 10, 0, tzinfo=timezone.utc)
13
+ PATH = ".github/workflows/ci.yml"
14
+ YAML = "jobs:\n test:\n steps:\n - run: pip install .\n"
15
+
16
+
17
+ def log(index, sha="", path=PATH, head_repository="acme/web"):
18
+ return BuildLog(
19
+ build_id=str(index),
20
+ head_sha=sha or f"{index:040x}",
21
+ timestamp=START + timedelta(hours=index),
22
+ metadata={"workflow_path": path, "head_repository": head_repository},
23
+ )
24
+
25
+
26
+ class Recorder:
27
+ """Stands in for GitHubClient(repo, token) and records the URLs asked for."""
28
+
29
+ urls: list[str] = []
30
+
31
+ def __new__(cls, repo, token=None):
32
+ def transport(url, headers):
33
+ Recorder.urls.append(url)
34
+ return {"encoding": "base64", "content": base64.b64encode(YAML.encode()).decode()}, {}
35
+
36
+ return GitHubClient(repo=repo, transport=transport)
37
+
38
+
39
+ class TestCacheTable:
40
+ def test_a_file_is_cached_per_commit(self, tmp_path):
41
+ with RunCache(tmp_path / "runs.db") as cache:
42
+ cache.store_workflow_file("acme/web", PATH, "abc", YAML)
43
+
44
+ assert cache.get_workflow_file("acme/web", PATH, "abc") == YAML
45
+ assert cache.get_workflow_file("acme/web", PATH, "def") is None
46
+ assert cache.get_workflow_file("acme/api", PATH, "abc") is None
47
+
48
+ def test_an_existing_database_gains_the_table(self, tmp_path):
49
+ RunCache(tmp_path / "runs.db").close()
50
+
51
+ with RunCache(tmp_path / "runs.db") as cache:
52
+ cache.store_workflow_file("acme/web", PATH, "abc", YAML)
53
+ assert cache.get_workflow_file("acme/web", PATH, "abc") == YAML
54
+
55
+
56
+ class TestWorkflowRefs:
57
+ def test_the_newest_run_picks_the_commit(self):
58
+ refs = _workflow_refs("acme/web", [log(1, "a" * 40), log(3, "c" * 40), log(2, "b" * 40)])
59
+
60
+ assert refs == {PATH: "c" * 40}
61
+
62
+ def test_a_fork_commit_is_not_used(self):
63
+ refs = _workflow_refs("acme/web", [log(1, "a" * 40), log(2, "f" * 40, head_repository="fork/web")])
64
+
65
+ assert refs == {PATH: "a" * 40}
66
+
67
+ def test_only_fork_commits_read_the_default_branch(self):
68
+ assert _workflow_refs("acme/web", [log(1, head_repository="fork/web")]) == {PATH: None}
69
+
70
+ def test_runs_without_a_workflow_file_are_ignored(self):
71
+ assert _workflow_refs("acme/web", [log(1, path="dynamic/dependabot/x")]) == {}
72
+
73
+
74
+ class TestFetchWorkflows:
75
+ def test_a_second_analysis_makes_no_request(self, tmp_path, monkeypatch):
76
+ monkeypatch.setattr("ci_time_tracker.cli.GitHubClient", Recorder)
77
+ Recorder.urls = []
78
+ logs = [log(1, "a" * 40)]
79
+
80
+ with RunCache(tmp_path / "runs.db") as cache:
81
+ first = _fetch_workflows("acme/web", logs, cache)
82
+ second = _fetch_workflows("acme/web", logs, cache)
83
+
84
+ assert first == second
85
+ assert PATH in first
86
+ assert len(Recorder.urls) == 1
87
+ assert "ref=" + "a" * 40 in Recorder.urls[0]
88
+
89
+ def test_a_new_commit_is_fetched_again(self, tmp_path, monkeypatch):
90
+ monkeypatch.setattr("ci_time_tracker.cli.GitHubClient", Recorder)
91
+ Recorder.urls = []
92
+
93
+ with RunCache(tmp_path / "runs.db") as cache:
94
+ _fetch_workflows("acme/web", [log(1, "a" * 40)], cache)
95
+ _fetch_workflows("acme/web", [log(1, "a" * 40), log(2, "b" * 40)], cache)
96
+
97
+ assert len(Recorder.urls) == 2
98
+
99
+ def test_the_default_branch_is_never_cached(self, tmp_path, monkeypatch):
100
+ monkeypatch.setattr("ci_time_tracker.cli.GitHubClient", Recorder)
101
+ Recorder.urls = []
102
+ logs = [log(1, head_repository="fork/web")]
103
+
104
+ with RunCache(tmp_path / "runs.db") as cache:
105
+ _fetch_workflows("acme/web", logs, cache)
106
+ _fetch_workflows("acme/web", logs, cache)
107
+
108
+ assert len(Recorder.urls) == 2
109
+ assert "ref=" not in Recorder.urls[0]
110
+
111
+ def test_without_a_cache_files_are_still_read(self, monkeypatch):
112
+ monkeypatch.setattr("ci_time_tracker.cli.GitHubClient", Recorder)
113
+ Recorder.urls = []
114
+
115
+ assert PATH in _fetch_workflows("acme/web", [log(1, "a" * 40)])
File without changes