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.
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/PKG-INFO +77 -6
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/README.md +76 -5
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/__init__.py +1 -1
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/analyzer.py +40 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/cli.py +107 -3
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/github_api.py +139 -26
- ci_time_tracker-0.3.0/ci_time_tracker/html_report.py +477 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/impact.py +9 -1
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/models.py +12 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/pricing.py +36 -12
- ci_time_tracker-0.3.0/ci_time_tracker/recommendations.py +469 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/reporter.py +66 -6
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker.egg-info/PKG-INFO +77 -6
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker.egg-info/SOURCES.txt +5 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/pyproject.toml +1 -1
- ci_time_tracker-0.3.0/tests/test_field_test_3.py +174 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_github_api.py +26 -3
- ci_time_tracker-0.3.0/tests/test_html_report.py +218 -0
- ci_time_tracker-0.3.0/tests/test_recommendations.py +576 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/LICENSE +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/__main__.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/cache.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/config_parser.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/detection.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/gates.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker/log_parser.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker.egg-info/dependency_links.txt +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker.egg-info/entry_points.txt +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker.egg-info/requires.txt +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/ci_time_tracker.egg-info/top_level.txt +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/setup.cfg +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_analyzer.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_cli.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_config_parser.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_detection.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_gates_markdown.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_impact.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_log_parser.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_metrics.py +0 -0
- {ci_time_tracker-0.2.0 → ci_time_tracker-0.3.0}/tests/test_packaging.py +0 -0
- {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.
|
|
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
|
[](https://github.com/0xWhisp/ci-time-tracker/actions/workflows/ci.yml)
|
|
35
|
+
[](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+.
|
|
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
|
|
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@
|
|
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.
|
|
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
|
[](https://github.com/0xWhisp/ci-time-tracker/actions/workflows/ci.yml)
|
|
4
|
+
[](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+.
|
|
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
|
|
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@
|
|
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.
|
|
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
|
|
|
@@ -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
|
|