ci-time-tracker 0.2.0__tar.gz → 0.3.0__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 (41) hide show
  1. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/PKG-INFO +77 -6
  2. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/README.md +76 -5
  3. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/__init__.py +1 -1
  4. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/analyzer.py +40 -0
  5. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/cli.py +107 -3
  6. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/github_api.py +139 -26
  7. ci_time_tracker-0.3.0/ci_time_tracker/html_report.py +477 -0
  8. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/impact.py +9 -1
  9. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/models.py +12 -0
  10. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/pricing.py +36 -12
  11. ci_time_tracker-0.3.0/ci_time_tracker/recommendations.py +469 -0
  12. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/reporter.py +66 -6
  13. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker.egg-info/PKG-INFO +77 -6
  14. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker.egg-info/SOURCES.txt +5 -0
  15. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/pyproject.toml +1 -1
  16. ci_time_tracker-0.3.0/tests/test_field_test_3.py +174 -0
  17. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_github_api.py +26 -3
  18. ci_time_tracker-0.3.0/tests/test_html_report.py +218 -0
  19. ci_time_tracker-0.3.0/tests/test_recommendations.py +576 -0
  20. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/LICENSE +0 -0
  21. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/__main__.py +0 -0
  22. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/cache.py +0 -0
  23. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/config_parser.py +0 -0
  24. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/detection.py +0 -0
  25. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/gates.py +0 -0
  26. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/log_parser.py +0 -0
  27. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker.egg-info/dependency_links.txt +0 -0
  28. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker.egg-info/entry_points.txt +0 -0
  29. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker.egg-info/requires.txt +0 -0
  30. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker.egg-info/top_level.txt +0 -0
  31. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/setup.cfg +0 -0
  32. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_analyzer.py +0 -0
  33. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_cli.py +0 -0
  34. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_config_parser.py +0 -0
  35. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_detection.py +0 -0
  36. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_gates_markdown.py +0 -0
  37. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_impact.py +0 -0
  38. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_log_parser.py +0 -0
  39. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_metrics.py +0 -0
  40. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_packaging.py +0 -0
  41. {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/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.2.0
3
+ Version: 0.3.0
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
@@ -32,6 +32,7 @@ Dynamic: license-file
32
32
  # ci-time-tracker
33
33
 
34
34
  [![CI](https://github.com/0xWhisp/ci-time-tracker/actions/workflows/ci.yml/badge.svg)](https://github.com/0xWhisp/ci-time-tracker/actions/workflows/ci.yml)
35
+ [![PyPI](https://img.shields.io/pypi/v/ci-time-tracker)](https://pypi.org/project/ci-time-tracker/)
35
36
 
36
37
  Find where your CI time and money go. ci-time-tracker reads your GitHub Actions
37
38
  history and reports the steps that cost the most, the ones that are flaky, and
@@ -54,7 +55,13 @@ comments on each pull request with how it changes CI time and cost.
54
55
 
55
56
  ## Installation
56
57
 
57
- Requires Python 3.10+. Not yet published on PyPI; install from source:
58
+ Requires Python 3.10+.
59
+
60
+ ```bash
61
+ pip install ci-time-tracker
62
+ ```
63
+
64
+ Or from source:
58
65
 
59
66
  ```bash
60
67
  git clone https://github.com/0xWhisp/ci-time-tracker.git
@@ -296,7 +303,7 @@ consumers, and the full step table collapsed.
296
303
  ```yaml
297
304
  - name: CI time report
298
305
  run: |
299
- pip install git+https://github.com/0xWhisp/ci-time-tracker.git
306
+ pip install ci-time-tracker
300
307
  ci-time-tracker --github ${{ github.repository }} --workflow ci.yml --last 100 --group-matrix --format markdown >> "$GITHUB_STEP_SUMMARY"
301
308
  env:
302
309
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -369,7 +376,7 @@ jobs:
369
376
  if: github.event.workflow_run.event == 'pull_request'
370
377
  runs-on: ubuntu-latest
371
378
  steps:
372
- - uses: 0xWhisp/ci-time-tracker@main # pin a release tag once published
379
+ - uses: 0xWhisp/ci-time-tracker@v0.3.0
373
380
  ```
374
381
 
375
382
  `workflow_run` runs the copy of the workflow on the default branch with the
@@ -395,6 +402,31 @@ ci-time-tracker --github acme/web --run-id 39102310449 --group-matrix
395
402
  ci-time-tracker --github acme/web --run-id 39102310449 --comment # post it
396
403
  ```
397
404
 
405
+ ### HTML report
406
+
407
+ `--format html` writes a single self-contained page: summary cards, build
408
+ duration over time for each of the busiest workflows (failures marked, a
409
+ rolling median, outliers kept from flattening the chart), issues with their
410
+ evidence, recommendations, and where the time goes. It has no scripts and loads
411
+ nothing from elsewhere, so it opens offline and can be attached to an issue or
412
+ kept as a CI artifact. It follows the viewer's light or dark mode.
413
+
414
+ ```bash
415
+ ci-time-tracker --github acme/web --group-matrix --format html --output ci-report.html
416
+ ```
417
+
418
+ ```yaml
419
+ - run: ci-time-tracker --github ${{ github.repository }} --format html --output ci-report.html
420
+ env:
421
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
422
+ - uses: actions/upload-artifact@v4
423
+ with:
424
+ name: ci-report
425
+ path: ci-report.html
426
+ ```
427
+
428
+ The JSON output carries the same per-build data under `timeline`.
429
+
398
430
  ### Exit codes
399
431
 
400
432
  | Code | Meaning |
@@ -405,13 +437,47 @@ ci-time-tracker --github acme/web --run-id 39102310449 --comment # post it
405
437
  | 3 | GitHub API error (rate limit, permissions, not found) |
406
438
  | 4 | A quality gate failed |
407
439
 
440
+ ## Recommendations
441
+
442
+ With `--github`, ci-time-tracker also reads the workflow files behind the runs
443
+ and suggests changes where the configuration shows a cause and the history
444
+ shows it matters. Neither is enough alone: a slow install step may already be
445
+ cached, and a job without a cache may install in two seconds.
446
+
447
+ | Recommendation | When |
448
+ |---|---|
449
+ | **Cache dependencies** | A job installs dependencies (pip, uv, poetry, npm, pnpm, yarn, bundler, composer, go) without any cache, and the install takes a median of 20s or more |
450
+ | **Cancel superseded pull request runs** | Pull request runs kept running after a newer push to the same branch started another run, and the workflow has no `concurrency` with `cancel-in-progress` |
451
+ | **Run expensive matrix legs less often** | Over at least 10 builds, the Windows or macOS legs of a job never failed when its Linux legs passed |
452
+
453
+ Each one comes with its evidence, a concrete change, and what is at stake: the
454
+ time (and cost) the affected steps or jobs used over the analyzed runs. That is
455
+ an upper bound on the saving, not a promise of it. Findings with less than
456
+ 10 minutes at stake are left out, so the list stays worth reading.
457
+
458
+ For example, on pytest-dev/pytest:
459
+
460
+ ```
461
+ RECOMMENDATIONS
462
+ 1. Run the Windows legs of `build` less often (.github/workflows/test.yml)
463
+ In 79 builds, the Windows legs never failed when the Linux legs passed. They used 68h 08m, 40% of the job's compute.
464
+ Suggestion: Run the Windows legs only on pushes to the default branch or on a schedule, and keep Linux on pull requests. A platform-specific failure then shows up after merging rather than before.
465
+ At stake: 68h 08m
466
+ ```
467
+
468
+ Whether a job could start earlier (a `needs` that only orders jobs) is not
469
+ suggested: the configuration cannot tell an ordering from a real dependency on
470
+ the other job's results.
471
+
408
472
  ## Cost Estimates
409
473
 
410
474
  Costs are estimated from the runner each job ran on, using GitHub's
411
475
  [list prices](https://docs.github.com/en/billing/reference/actions-runner-pricing)
412
476
  for **private repositories** as of October 2026, with each job rounded up to a
413
477
  whole minute. Public repositories pay nothing for standard GitHub-hosted
414
- runners; larger runners are billed for public repositories too.
478
+ runners; larger runners are billed for public repositories too. When the
479
+ analyzed repository is public (the runs say so), standard runners are priced
480
+ at `$0`, so the estimate counts only what is actually billed.
415
481
 
416
482
  | Runner | USD per minute |
417
483
  |---|---:|
@@ -500,6 +566,11 @@ needs at least **10 builds** to be judged; `regression_checked_steps` says how
500
566
  many had enough history. With `--group-matrix`, the legs of a build are
501
567
  combined into one sample (their median).
502
568
 
569
+ When the analyzed runs span several branches, regressions are looked for on
570
+ the repository's default branch only (`regression_branch`). Pull request
571
+ branches run different code with different caches; mixed into one history
572
+ they look like changes that never happened.
573
+
503
574
  ## Supported CI Providers
504
575
 
505
576
  | Provider | Config Format | Log Format |
@@ -550,7 +621,7 @@ Then, for each release:
550
621
  1. Bump `version` in `pyproject.toml` and `__version__` in
551
622
  `ci_time_tracker/__init__.py` (a test checks they agree), and add the
552
623
  release to [CHANGELOG.md](CHANGELOG.md).
553
- 2. Tag and push: `git tag v0.2.0 && git push origin v0.2.0`.
624
+ 2. Tag and push: `git tag v0.3.0 && git push origin v0.3.0`.
554
625
  3. Create the GitHub release from the tag, with the changelog entry as its
555
626
  notes, and tick "Publish this Action to the GitHub Marketplace".
556
627
 
@@ -1,6 +1,7 @@
1
1
  # ci-time-tracker
2
2
 
3
3
  [![CI](https://github.com/0xWhisp/ci-time-tracker/actions/workflows/ci.yml/badge.svg)](https://github.com/0xWhisp/ci-time-tracker/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/ci-time-tracker)](https://pypi.org/project/ci-time-tracker/)
4
5
 
5
6
  Find where your CI time and money go. ci-time-tracker reads your GitHub Actions
6
7
  history and reports the steps that cost the most, the ones that are flaky, and
@@ -23,7 +24,13 @@ comments on each pull request with how it changes CI time and cost.
23
24
 
24
25
  ## Installation
25
26
 
26
- Requires Python 3.10+. Not yet published on PyPI; install from source:
27
+ Requires Python 3.10+.
28
+
29
+ ```bash
30
+ pip install ci-time-tracker
31
+ ```
32
+
33
+ Or from source:
27
34
 
28
35
  ```bash
29
36
  git clone https://github.com/0xWhisp/ci-time-tracker.git
@@ -265,7 +272,7 @@ consumers, and the full step table collapsed.
265
272
  ```yaml
266
273
  - name: CI time report
267
274
  run: |
268
- pip install git+https://github.com/0xWhisp/ci-time-tracker.git
275
+ pip install ci-time-tracker
269
276
  ci-time-tracker --github ${{ github.repository }} --workflow ci.yml --last 100 --group-matrix --format markdown >> "$GITHUB_STEP_SUMMARY"
270
277
  env:
271
278
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -338,7 +345,7 @@ jobs:
338
345
  if: github.event.workflow_run.event == 'pull_request'
339
346
  runs-on: ubuntu-latest
340
347
  steps:
341
- - uses: 0xWhisp/ci-time-tracker@main # pin a release tag once published
348
+ - uses: 0xWhisp/ci-time-tracker@v0.3.0
342
349
  ```
343
350
 
344
351
  `workflow_run` runs the copy of the workflow on the default branch with the
@@ -364,6 +371,31 @@ ci-time-tracker --github acme/web --run-id 39102310449 --group-matrix
364
371
  ci-time-tracker --github acme/web --run-id 39102310449 --comment # post it
365
372
  ```
366
373
 
374
+ ### HTML report
375
+
376
+ `--format html` writes a single self-contained page: summary cards, build
377
+ duration over time for each of the busiest workflows (failures marked, a
378
+ rolling median, outliers kept from flattening the chart), issues with their
379
+ evidence, recommendations, and where the time goes. It has no scripts and loads
380
+ nothing from elsewhere, so it opens offline and can be attached to an issue or
381
+ kept as a CI artifact. It follows the viewer's light or dark mode.
382
+
383
+ ```bash
384
+ ci-time-tracker --github acme/web --group-matrix --format html --output ci-report.html
385
+ ```
386
+
387
+ ```yaml
388
+ - run: ci-time-tracker --github ${{ github.repository }} --format html --output ci-report.html
389
+ env:
390
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
391
+ - uses: actions/upload-artifact@v4
392
+ with:
393
+ name: ci-report
394
+ path: ci-report.html
395
+ ```
396
+
397
+ The JSON output carries the same per-build data under `timeline`.
398
+
367
399
  ### Exit codes
368
400
 
369
401
  | Code | Meaning |
@@ -374,13 +406,47 @@ ci-time-tracker --github acme/web --run-id 39102310449 --comment # post it
374
406
  | 3 | GitHub API error (rate limit, permissions, not found) |
375
407
  | 4 | A quality gate failed |
376
408
 
409
+ ## Recommendations
410
+
411
+ With `--github`, ci-time-tracker also reads the workflow files behind the runs
412
+ and suggests changes where the configuration shows a cause and the history
413
+ shows it matters. Neither is enough alone: a slow install step may already be
414
+ cached, and a job without a cache may install in two seconds.
415
+
416
+ | Recommendation | When |
417
+ |---|---|
418
+ | **Cache dependencies** | A job installs dependencies (pip, uv, poetry, npm, pnpm, yarn, bundler, composer, go) without any cache, and the install takes a median of 20s or more |
419
+ | **Cancel superseded pull request runs** | Pull request runs kept running after a newer push to the same branch started another run, and the workflow has no `concurrency` with `cancel-in-progress` |
420
+ | **Run expensive matrix legs less often** | Over at least 10 builds, the Windows or macOS legs of a job never failed when its Linux legs passed |
421
+
422
+ Each one comes with its evidence, a concrete change, and what is at stake: the
423
+ time (and cost) the affected steps or jobs used over the analyzed runs. That is
424
+ an upper bound on the saving, not a promise of it. Findings with less than
425
+ 10 minutes at stake are left out, so the list stays worth reading.
426
+
427
+ For example, on pytest-dev/pytest:
428
+
429
+ ```
430
+ RECOMMENDATIONS
431
+ 1. Run the Windows legs of `build` less often (.github/workflows/test.yml)
432
+ In 79 builds, the Windows legs never failed when the Linux legs passed. They used 68h 08m, 40% of the job's compute.
433
+ Suggestion: Run the Windows legs only on pushes to the default branch or on a schedule, and keep Linux on pull requests. A platform-specific failure then shows up after merging rather than before.
434
+ At stake: 68h 08m
435
+ ```
436
+
437
+ Whether a job could start earlier (a `needs` that only orders jobs) is not
438
+ suggested: the configuration cannot tell an ordering from a real dependency on
439
+ the other job's results.
440
+
377
441
  ## Cost Estimates
378
442
 
379
443
  Costs are estimated from the runner each job ran on, using GitHub's
380
444
  [list prices](https://docs.github.com/en/billing/reference/actions-runner-pricing)
381
445
  for **private repositories** as of October 2026, with each job rounded up to a
382
446
  whole minute. Public repositories pay nothing for standard GitHub-hosted
383
- runners; larger runners are billed for public repositories too.
447
+ runners; larger runners are billed for public repositories too. When the
448
+ analyzed repository is public (the runs say so), standard runners are priced
449
+ at `$0`, so the estimate counts only what is actually billed.
384
450
 
385
451
  | Runner | USD per minute |
386
452
  |---|---:|
@@ -469,6 +535,11 @@ needs at least **10 builds** to be judged; `regression_checked_steps` says how
469
535
  many had enough history. With `--group-matrix`, the legs of a build are
470
536
  combined into one sample (their median).
471
537
 
538
+ When the analyzed runs span several branches, regressions are looked for on
539
+ the repository's default branch only (`regression_branch`). Pull request
540
+ branches run different code with different caches; mixed into one history
541
+ they look like changes that never happened.
542
+
472
543
  ## Supported CI Providers
473
544
 
474
545
  | Provider | Config Format | Log Format |
@@ -519,7 +590,7 @@ Then, for each release:
519
590
  1. Bump `version` in `pyproject.toml` and `__version__` in
520
591
  `ci_time_tracker/__init__.py` (a test checks they agree), and add the
521
592
  release to [CHANGELOG.md](CHANGELOG.md).
522
- 2. Tag and push: `git tag v0.2.0 && git push origin v0.2.0`.
593
+ 2. Tag and push: `git tag v0.3.0 && git push origin v0.3.0`.
523
594
  3. Create the GitHub release from the tag, with the changelog entry as its
524
595
  notes, and tick "Publish this Action to the GitHub Marketplace".
525
596
 
@@ -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.2.0"
3
+ __version__ = "0.3.0"
@@ -172,6 +172,7 @@ def analyze_logs(
172
172
  group_matrix: bool = False,
173
173
  regression_threshold: float = DEFAULT_REGRESSION_THRESHOLD,
174
174
  min_samples: int = DEFAULT_MIN_SAMPLES,
175
+ regression_branch: str | None = None,
175
176
  ) -> AnalysisResult:
176
177
  """Analyze multiple build logs and compute statistics.
177
178
 
@@ -190,6 +191,9 @@ def analyze_logs(
190
191
  regression_threshold: Minimum relative duration increase to report,
191
192
  e.g. 0.25 for +25%
192
193
  min_samples: Minimum builds on each side of a regression
194
+ regression_branch: When builds span several branches, the one
195
+ regressions are looked for on (the default branch); defaults to
196
+ the branch with the most builds
193
197
 
194
198
  Returns:
195
199
  AnalysisResult in log mode with step statistics and detected issues
@@ -297,6 +301,17 @@ def analyze_logs(
297
301
  name: {"method": "failure-rate"} for name in detect_flaky_steps(step_stats)
298
302
  }
299
303
 
304
+ # Regressions are looked for on one branch only. Pull request branches
305
+ # run different code with different caches, and mixed into one series
306
+ # they read as changes that never happened (seen on rubocop, whose
307
+ # 26 master builds were mixed with 74 from pull requests).
308
+ branches = Counter(log.metadata.get("branch") for log in logs if log.metadata.get("branch"))
309
+ if len(branches) > 1:
310
+ if regression_branch not in branches:
311
+ regression_branch = branches.most_common(1)[0][0]
312
+ else:
313
+ regression_branch = None # one branch or none known: all builds compare
314
+
300
315
  # Regressions, from one sample per build (the median across matrix legs
301
316
  # when they are grouped), in chronological order
302
317
  regression_checked = 0
@@ -306,6 +321,7 @@ def analyze_logs(
306
321
  samples = [
307
322
  (median(values), logs[build_index])
308
323
  for build_index, values in sorted(build_durations.items())
324
+ if regression_branch is None or logs[build_index].metadata.get("branch") == regression_branch
309
325
  ]
310
326
 
311
327
  if len(samples) >= 2 * min_samples:
@@ -344,18 +360,42 @@ def analyze_logs(
344
360
  metadata["flaky_detection"] = flaky_method
345
361
  metadata["regression_checked_steps"] = regression_checked
346
362
  metadata["regression_min_builds"] = 2 * min_samples
363
+ if regression_branch:
364
+ metadata["regression_branch"] = regression_branch
347
365
  metadata.update(aggregate_job_metrics(logs, pricing))
348
366
 
349
367
  return AnalysisResult(
350
368
  mode="log",
351
369
  steps=step_stats,
352
370
  total_estimated_duration=None,
371
+ timeline=build_timeline(logs),
353
372
  regressed_steps=[stats.name for stats in step_stats if stats.is_regression],
354
373
  flaky_steps=[stats.name for stats in step_stats if stats.is_flaky],
355
374
  metadata=metadata,
356
375
  )
357
376
 
358
377
 
378
+ def build_timeline(logs: list[BuildLog]) -> list[dict[str, Any]]:
379
+ """One entry per build, in the given order, for trend charts.
380
+
381
+ Earlier attempts of re-runs are left out: they are part of the same build.
382
+ """
383
+ return [
384
+ {
385
+ "build_id": log.build_id,
386
+ "timestamp": log.timestamp.isoformat() if log.timestamp else None,
387
+ "duration": log.total_duration,
388
+ "conclusion": log.conclusion,
389
+ "head_sha": log.head_sha[:7] if log.head_sha else None,
390
+ "workflow": log.metadata.get("workflow_name"),
391
+ "branch": log.metadata.get("branch"),
392
+ "url": log.metadata.get("url"),
393
+ }
394
+ for log in logs
395
+ if not log.metadata.get("superseded_attempt")
396
+ ]
397
+
398
+
359
399
  def aggregate_job_metrics(
360
400
  logs: list[BuildLog],
361
401
  pricing: PricingTable | None = None,
@@ -7,6 +7,8 @@ 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
11
+ from dataclasses import replace
10
12
  from pathlib import Path
11
13
  from typing import Any
12
14
 
@@ -17,15 +19,18 @@ from ci_time_tracker.detection import DEFAULT_REGRESSION_THRESHOLD
17
19
  from ci_time_tracker.gates import GATE_CHOICES, evaluate_gates, evaluate_impact_gates
18
20
  from ci_time_tracker.github_api import (
19
21
  DEFAULT_RUN_LIMIT,
22
+ DEFAULT_WORKERS,
20
23
  GitHubAPIError,
21
24
  GitHubClient,
22
25
  fetch_build_logs,
23
26
  fetch_impact_inputs,
24
27
  resolve_token,
25
28
  )
29
+ from ci_time_tracker.html_report import format_html
26
30
  from ci_time_tracker.impact import compare_run, format_impact_markdown
27
31
  from ci_time_tracker.log_parser import parse_log
28
- from ci_time_tracker.pricing import load_pricing
32
+ from ci_time_tracker.pricing import PricingTable, load_pricing
33
+ from ci_time_tracker.recommendations import parse_workflow, recommend
29
34
  from ci_time_tracker.reporter import (
30
35
  format_json,
31
36
  format_markdown,
@@ -215,10 +220,10 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
215
220
  parser.add_argument(
216
221
  "--format",
217
222
  type=str,
218
- choices=["json", "text", "markdown"],
223
+ choices=["json", "text", "markdown", "html"],
219
224
  default="text",
220
225
  help="Output format (default: text). Markdown suits $GITHUB_STEP_SUMMARY "
221
- "and PR comments.",
226
+ "and PR comments; html is a self-contained page with charts.",
222
227
  )
223
228
 
224
229
  parser.add_argument(
@@ -357,6 +362,80 @@ def load_estimates(path: str) -> dict[str, float]:
357
362
  return estimates
358
363
 
359
364
 
365
+ def _default_branch_if_mixed(repo: str, logs: list[Any]) -> str | None:
366
+ """The repository's default branch, when the runs span several branches.
367
+
368
+ Regressions are looked for on that branch only; with a single branch
369
+ there is nothing to choose and no request is made.
370
+ """
371
+ branches = {log.metadata.get("branch") for log in logs if log.metadata.get("branch")}
372
+ if len(branches) < 2:
373
+ return None
374
+ try:
375
+ return GitHubClient(repo=repo, token=resolve_token()).fetch_default_branch()
376
+ except (GitHubAPIError, KeyError, TypeError):
377
+ return None
378
+
379
+
380
+ def _visibility_pricing(logs: list[Any], pricing: Any) -> tuple[PricingTable, str | None]:
381
+ """Pricing for the repository's visibility, read from its runs.
382
+
383
+ Public repositories pay nothing for standard GitHub-hosted runners. When
384
+ the runs do not say, list prices are kept and no visibility is claimed.
385
+ """
386
+ pricing = pricing or PricingTable()
387
+ private = next(
388
+ (log.metadata["repository_private"] for log in logs if log.metadata.get("repository_private") is not None),
389
+ None,
390
+ )
391
+
392
+ if private is False:
393
+ return replace(pricing, public_repository=True), "public"
394
+ if private is True:
395
+ return pricing, "private"
396
+ return pricing, None
397
+
398
+
399
+ # Most distinct workflow files fetched to cross with the history
400
+ _MAX_WORKFLOW_FILES = 20
401
+
402
+
403
+ def _fetch_workflows(repo: str, logs: list[Any]) -> dict[str, dict[str, Any]]:
404
+ """Fetch and parse the workflow files behind the analyzed runs.
405
+
406
+ Recommendations that need a workflow's configuration skip the ones that
407
+ cannot be read, so a failure here only narrows what is recommended.
408
+ """
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())
419
+
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
426
+
427
+ # Fetched concurrently: these requests are not cached, and one after the
428
+ # other they added over a second to a cached analysis
429
+ 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
435
+
436
+ return workflows
437
+
438
+
360
439
  def _no_runs_message(args: argparse.Namespace) -> str:
361
440
  """Explain an empty result, pointing at the default branch when it differs.
362
441
 
@@ -406,6 +485,7 @@ def _run_impact(args: argparse.Namespace, pricing: Any) -> int:
406
485
  if cache is not None:
407
486
  cache.close()
408
487
 
488
+ pricing, _ = _visibility_pricing(run_logs, pricing)
409
489
  impact = compare_run(
410
490
  run_logs,
411
491
  baseline,
@@ -511,6 +591,11 @@ def main() -> int:
511
591
  # Load estimates if provided
512
592
  estimates = None
513
593
  if args.estimates:
594
+ print(
595
+ "Warning: --estimates is deprecated and will be removed: use --github "
596
+ "for durations measured from real runs",
597
+ file=sys.stderr,
598
+ )
514
599
  try:
515
600
  estimates = load_estimates(args.estimates)
516
601
  except (OSError, FileNotFoundError) as e:
@@ -587,6 +672,7 @@ def main() -> int:
587
672
  except Exception as e:
588
673
  print(f"Warning: run cache unavailable ({e})", file=sys.stderr)
589
674
 
675
+ skipped: list[str] = []
590
676
  try:
591
677
  logs = fetch_build_logs(
592
678
  repo=args.github,
@@ -594,6 +680,7 @@ def main() -> int:
594
680
  branch=args.branch,
595
681
  limit=args.last,
596
682
  cache=cache,
683
+ skipped=skipped,
597
684
  )
598
685
  except GitHubAPIError as e:
599
686
  print(f"Error: {e}", file=sys.stderr)
@@ -607,12 +694,27 @@ def main() -> int:
607
694
  return 1
608
695
 
609
696
  # Analyze
697
+ pricing, visibility = _visibility_pricing(logs, pricing)
610
698
  result = analyze_logs(
611
699
  logs,
612
700
  pricing,
613
701
  args.group_matrix,
614
702
  regression_threshold=args.regression_threshold,
703
+ regression_branch=_default_branch_if_mixed(args.github, logs),
615
704
  )
705
+ result.metadata["repository"] = args.github
706
+ if visibility:
707
+ result.metadata["repository_visibility"] = visibility
708
+ if skipped:
709
+ result.metadata["skipped_runs"] = len(skipped)
710
+ print(
711
+ f"Warning: left out {len(skipped)} run(s) GitHub failed to return: "
712
+ f"{', '.join(skipped[:5])}",
713
+ file=sys.stderr,
714
+ )
715
+ result.recommendations = [
716
+ item.to_dict() for item in recommend(logs, _fetch_workflows(args.github, logs), pricing)
717
+ ]
616
718
 
617
719
  else:
618
720
  # Should never reach here due to argparse
@@ -627,6 +729,8 @@ def main() -> int:
627
729
  output = format_json(report)
628
730
  elif args.format == "markdown":
629
731
  output = format_markdown(report, args.top)
732
+ elif args.format == "html":
733
+ output = format_html(report, args.top)
630
734
  else:
631
735
  output = format_text(report, args.top)
632
736