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.
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/PKG-INFO +43 -13
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/README.md +42 -12
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/__init__.py +1 -1
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/cache.py +27 -1
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/cli.py +85 -33
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/detection.py +25 -4
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/github_api.py +65 -22
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/html_report.py +8 -2
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/PKG-INFO +43 -13
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/SOURCES.txt +2 -1
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/pyproject.toml +1 -1
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_cli.py +11 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_detection.py +29 -1
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_field_test_3.py +12 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_github_api.py +66 -14
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_html_report.py +14 -0
- ci_time_tracker-0.3.1/tests/test_workflow_cache.py +115 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/LICENSE +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/__main__.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/analyzer.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/config_parser.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/gates.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/impact.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/log_parser.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/models.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/pricing.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/recommendations.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker/reporter.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/dependency_links.txt +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/entry_points.txt +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/requires.txt +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/top_level.txt +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/setup.cfg +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_analyzer.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_config_parser.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_gates_markdown.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_impact.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_log_parser.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_metrics.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_packaging.py +0 -0
- {ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/tests/test_recommendations.py +0 -0
- {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.
|
|
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.
|
|
40
|
-
|
|
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
|
+
[](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
|
-
- **
|
|
45
|
-
|
|
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,
|
|
49
|
-
name the commit where it started
|
|
50
|
-
- **
|
|
51
|
-
and
|
|
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
|
-
- **
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
9
|
-
|
|
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
|
+
[](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
|
-
- **
|
|
14
|
-
|
|
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,
|
|
18
|
-
name the commit where it started
|
|
19
|
-
- **
|
|
20
|
-
and
|
|
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
|
-
- **
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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,
|
|
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
|
|
12
|
-
recent builds, so that a spike that
|
|
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
|
|
175
|
-
`min_samples` builds
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
#
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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.
|
|
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.
|
|
40
|
-
|
|
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
|
+
[](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
|
-
- **
|
|
45
|
-
|
|
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,
|
|
49
|
-
name the commit where it started
|
|
50
|
-
- **
|
|
51
|
-
and
|
|
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
|
-
- **
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ci-time-tracker"
|
|
7
|
-
version = "0.3.
|
|
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
|
-
|
|
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(
|
|
255
|
-
# "&page=1&", not "page=1", which "per_page
|
|
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=
|
|
262
|
+
logs = fetch_build_logs("acme/web", limit=26, transport=transport)
|
|
263
263
|
|
|
264
|
-
assert len(logs) ==
|
|
265
|
-
assert len({log.build_id for log in logs}) ==
|
|
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(
|
|
271
|
-
second_page = [{**RUN, "id":
|
|
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=
|
|
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
|
-
|
|
281
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ci_time_tracker-0.3.0 → ci_time_tracker-0.3.1}/ci_time_tracker.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|