ci-time-tracker 0.2.0__py3-none-any.whl
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/__init__.py +3 -0
- ci_time_tracker/__main__.py +12 -0
- ci_time_tracker/analyzer.py +444 -0
- ci_time_tracker/cache.py +113 -0
- ci_time_tracker/cli.py +664 -0
- ci_time_tracker/config_parser.py +484 -0
- ci_time_tracker/detection.py +234 -0
- ci_time_tracker/gates.py +108 -0
- ci_time_tracker/github_api.py +624 -0
- ci_time_tracker/impact.py +361 -0
- ci_time_tracker/log_parser.py +728 -0
- ci_time_tracker/models.py +277 -0
- ci_time_tracker/pricing.py +202 -0
- ci_time_tracker/reporter.py +577 -0
- ci_time_tracker-0.2.0.dist-info/METADATA +561 -0
- ci_time_tracker-0.2.0.dist-info/RECORD +20 -0
- ci_time_tracker-0.2.0.dist-info/WHEEL +5 -0
- ci_time_tracker-0.2.0.dist-info/entry_points.txt +2 -0
- ci_time_tracker-0.2.0.dist-info/licenses/LICENSE +21 -0
- ci_time_tracker-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""How one run compares with its baseline: the pull request view.
|
|
2
|
+
|
|
3
|
+
The history analysis answers "where does our CI time go". A pull request asks
|
|
4
|
+
something narrower: did this change make CI slower or more expensive, and is
|
|
5
|
+
a failure real or a known flaky step? Each step of the run is compared with
|
|
6
|
+
its median over the baseline (recent runs of the same workflow on the default
|
|
7
|
+
branch), and failures are checked against same-commit flaky evidence.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from statistics import median
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ci_time_tracker.analyzer import aggregate_job_metrics, normalize_matrix_name
|
|
16
|
+
from ci_time_tracker.detection import (
|
|
17
|
+
DEFAULT_MIN_INCREASE_SECONDS,
|
|
18
|
+
DEFAULT_REGRESSION_THRESHOLD,
|
|
19
|
+
detect_flaky_by_commit,
|
|
20
|
+
)
|
|
21
|
+
from ci_time_tracker.github_api import COMMENT_MARKER
|
|
22
|
+
from ci_time_tracker.models import BuildLog
|
|
23
|
+
from ci_time_tracker.pricing import PricingTable
|
|
24
|
+
from ci_time_tracker.reporter import format_cost, format_duration
|
|
25
|
+
|
|
26
|
+
# Fewest baseline builds a step needs before its change is judged
|
|
27
|
+
DEFAULT_MIN_BASELINE_BUILDS = 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class StepChange:
|
|
32
|
+
"""A step's duration in this run against its baseline median."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
baseline_median: float
|
|
36
|
+
current: float
|
|
37
|
+
delta_seconds: float
|
|
38
|
+
delta_pct: float | None
|
|
39
|
+
significant: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class FailedStep:
|
|
44
|
+
"""A step that failed in this run, and whether it is known to be flaky."""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
known_flaky: bool
|
|
48
|
+
evidence: dict[str, Any] | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class RunImpact:
|
|
53
|
+
"""Everything the pull request comment says about one run."""
|
|
54
|
+
|
|
55
|
+
run_id: str | None
|
|
56
|
+
run_url: str | None
|
|
57
|
+
workflow_name: str | None
|
|
58
|
+
head_sha: str | None
|
|
59
|
+
baseline_branch: str
|
|
60
|
+
baseline_builds: int
|
|
61
|
+
duration: float | None
|
|
62
|
+
baseline_duration: float | None
|
|
63
|
+
compute_seconds: float
|
|
64
|
+
baseline_compute_seconds: float | None
|
|
65
|
+
cost_usd: float | None
|
|
66
|
+
baseline_cost_usd: float | None
|
|
67
|
+
threshold: float
|
|
68
|
+
min_seconds: float
|
|
69
|
+
min_baseline_builds: int = DEFAULT_MIN_BASELINE_BUILDS
|
|
70
|
+
# Steps with some baseline history, but too little to be judged
|
|
71
|
+
unjudged_steps: int = 0
|
|
72
|
+
# Jobs of this run, and how many ran on a runner with a known rate
|
|
73
|
+
job_count: int = 0
|
|
74
|
+
priced_jobs: int = 0
|
|
75
|
+
changes: list[StepChange] = field(default_factory=list)
|
|
76
|
+
new_steps: list[str] = field(default_factory=list)
|
|
77
|
+
failures: list[FailedStep] = field(default_factory=list)
|
|
78
|
+
recovered_on_rerun: list[str] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict[str, Any]:
|
|
81
|
+
return asdict(self)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _step_values(log: BuildLog, group_matrix: bool) -> dict[str, float]:
|
|
85
|
+
"""One duration per reported step: the median across matrix legs."""
|
|
86
|
+
values: dict[str, list[float]] = defaultdict(list)
|
|
87
|
+
|
|
88
|
+
for step in log.steps:
|
|
89
|
+
if step.duration_seconds is None:
|
|
90
|
+
continue
|
|
91
|
+
name = normalize_matrix_name(step.name) if group_matrix else step.name
|
|
92
|
+
values[name].append(step.duration_seconds)
|
|
93
|
+
|
|
94
|
+
return {name: median(durations) for name, durations in values.items()}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _median_or_none(values: list[float]) -> float | None:
|
|
98
|
+
return median(values) if values else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def compare_run(
|
|
102
|
+
run_logs: list[BuildLog],
|
|
103
|
+
baseline: list[BuildLog],
|
|
104
|
+
baseline_branch: str,
|
|
105
|
+
pricing: PricingTable | None = None,
|
|
106
|
+
group_matrix: bool = False,
|
|
107
|
+
threshold: float = DEFAULT_REGRESSION_THRESHOLD,
|
|
108
|
+
min_seconds: float = DEFAULT_MIN_INCREASE_SECONDS,
|
|
109
|
+
min_baseline_builds: int = DEFAULT_MIN_BASELINE_BUILDS,
|
|
110
|
+
) -> RunImpact:
|
|
111
|
+
"""Compare a run with its baseline.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
run_logs: The run's attempts, oldest first; the last one is judged
|
|
115
|
+
baseline: Recent builds of the same workflow on the baseline branch,
|
|
116
|
+
including earlier attempts of re-runs (they carry flaky evidence)
|
|
117
|
+
baseline_branch: Name of the baseline branch, for the report
|
|
118
|
+
pricing: Runner pricing for cost; defaults to the built-in rates
|
|
119
|
+
group_matrix: Merge the legs of a matrix job
|
|
120
|
+
threshold: Relative change that makes a step change significant
|
|
121
|
+
min_seconds: Absolute change that makes a step change significant
|
|
122
|
+
min_baseline_builds: Builds a step needs in the baseline to be judged
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The comparison
|
|
126
|
+
"""
|
|
127
|
+
pricing = pricing or PricingTable()
|
|
128
|
+
run = run_logs[-1]
|
|
129
|
+
builds = [log for log in baseline if not log.metadata.get("superseded_attempt")]
|
|
130
|
+
|
|
131
|
+
def reported(name: str) -> str:
|
|
132
|
+
return normalize_matrix_name(name) if group_matrix else name
|
|
133
|
+
|
|
134
|
+
# Per step, one value per baseline build
|
|
135
|
+
baseline_values: dict[str, list[float]] = defaultdict(list)
|
|
136
|
+
for log in builds:
|
|
137
|
+
for name, value in _step_values(log, group_matrix).items():
|
|
138
|
+
baseline_values[name].append(value)
|
|
139
|
+
|
|
140
|
+
changes: list[StepChange] = []
|
|
141
|
+
new_steps: list[str] = []
|
|
142
|
+
unjudged = 0
|
|
143
|
+
|
|
144
|
+
for name, current in _step_values(run, group_matrix).items():
|
|
145
|
+
history = baseline_values.get(name)
|
|
146
|
+
if not history:
|
|
147
|
+
new_steps.append(name)
|
|
148
|
+
continue
|
|
149
|
+
if len(history) < min_baseline_builds:
|
|
150
|
+
unjudged += 1
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
base = median(history)
|
|
154
|
+
delta = current - base
|
|
155
|
+
pct = delta / base if base > 0 else None
|
|
156
|
+
significant = abs(delta) >= min_seconds and (pct is None or abs(pct) >= threshold)
|
|
157
|
+
changes.append(StepChange(name, base, current, delta, pct, significant))
|
|
158
|
+
|
|
159
|
+
changes.sort(key=lambda change: abs(change.delta_seconds), reverse=True)
|
|
160
|
+
|
|
161
|
+
# Failures, checked against flaky evidence from the baseline and from
|
|
162
|
+
# this run's own re-runs
|
|
163
|
+
evidence = detect_flaky_by_commit(baseline + run_logs, reported)
|
|
164
|
+
failed_names = list(dict.fromkeys(
|
|
165
|
+
reported(step.name) for step in run.steps if step.status == "failure"
|
|
166
|
+
))
|
|
167
|
+
failures = [FailedStep(name, name in evidence, evidence.get(name)) for name in failed_names]
|
|
168
|
+
|
|
169
|
+
# Steps that failed on an earlier attempt of this run and passed on the last
|
|
170
|
+
passed_now = {reported(step.name) for step in run.steps if step.status == "success"}
|
|
171
|
+
failed_before = {
|
|
172
|
+
reported(step.name)
|
|
173
|
+
for log in run_logs[:-1]
|
|
174
|
+
for step in log.steps
|
|
175
|
+
if step.status == "failure"
|
|
176
|
+
}
|
|
177
|
+
recovered = sorted(failed_before & passed_now)
|
|
178
|
+
|
|
179
|
+
run_jobs = aggregate_job_metrics([run], pricing)
|
|
180
|
+
|
|
181
|
+
def build_cost(log: BuildLog) -> float | None:
|
|
182
|
+
return aggregate_job_metrics([log], pricing).get("estimated_cost_usd")
|
|
183
|
+
|
|
184
|
+
return RunImpact(
|
|
185
|
+
run_id=run.build_id,
|
|
186
|
+
run_url=run.metadata.get("url"),
|
|
187
|
+
workflow_name=run.metadata.get("workflow_name"),
|
|
188
|
+
head_sha=run.head_sha,
|
|
189
|
+
baseline_branch=baseline_branch,
|
|
190
|
+
baseline_builds=len(builds),
|
|
191
|
+
duration=run.total_duration,
|
|
192
|
+
baseline_duration=_median_or_none([log.total_duration for log in builds if log.total_duration is not None]),
|
|
193
|
+
compute_seconds=sum(step.duration_seconds or 0.0 for step in run.steps),
|
|
194
|
+
baseline_compute_seconds=_median_or_none([
|
|
195
|
+
sum(step.duration_seconds or 0.0 for step in log.steps) for log in builds
|
|
196
|
+
]),
|
|
197
|
+
cost_usd=build_cost(run),
|
|
198
|
+
baseline_cost_usd=_median_or_none([cost for cost in map(build_cost, builds) if cost is not None]),
|
|
199
|
+
threshold=threshold,
|
|
200
|
+
min_seconds=min_seconds,
|
|
201
|
+
min_baseline_builds=min_baseline_builds,
|
|
202
|
+
unjudged_steps=unjudged,
|
|
203
|
+
job_count=run_jobs.get("job_count", 0),
|
|
204
|
+
priced_jobs=run_jobs.get("priced_jobs", 0),
|
|
205
|
+
changes=changes,
|
|
206
|
+
new_steps=new_steps,
|
|
207
|
+
failures=failures,
|
|
208
|
+
recovered_on_rerun=recovered,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _signed_duration(delta: float) -> str:
|
|
213
|
+
return f"{'+' if delta >= 0 else '-'}{format_duration(abs(delta))}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _change_cell(current: float | None, base: float | None, significant: bool) -> str:
|
|
217
|
+
"""Change column: arrow when significant, approximately-equal otherwise."""
|
|
218
|
+
if current is None or base is None:
|
|
219
|
+
return "–"
|
|
220
|
+
|
|
221
|
+
delta = current - base
|
|
222
|
+
pct = f" ({'+' if delta >= 0 else ''}{delta / base * 100:.0f}%)" if base > 0 else ""
|
|
223
|
+
marker = ("🔺" if delta > 0 else "🔻") if significant else "≈"
|
|
224
|
+
return f"{marker} {_signed_duration(delta)}{pct}"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _is_significant(current: float | None, base: float | None, threshold: float, min_seconds: float) -> bool:
|
|
228
|
+
if current is None or base is None:
|
|
229
|
+
return False
|
|
230
|
+
delta = current - base
|
|
231
|
+
return abs(delta) >= min_seconds and (base <= 0 or abs(delta) / base >= threshold)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _md_cell(value: Any) -> str:
|
|
235
|
+
return str(value).replace("|", "\\|").replace("\n", " ")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def format_impact_markdown(impact: RunImpact) -> str:
|
|
239
|
+
"""The pull request comment for a run's impact."""
|
|
240
|
+
branch = f"`{impact.baseline_branch}`"
|
|
241
|
+
run_label = f"[run {impact.run_id}]({impact.run_url})" if impact.run_url else f"run {impact.run_id}"
|
|
242
|
+
workflow = f"**{impact.workflow_name}**" if impact.workflow_name else "This workflow"
|
|
243
|
+
commit = f" on `{impact.head_sha[:7]}`" if impact.head_sha else ""
|
|
244
|
+
|
|
245
|
+
lines = [COMMENT_MARKER, "## ⏱️ CI time impact", ""]
|
|
246
|
+
|
|
247
|
+
if not impact.baseline_builds:
|
|
248
|
+
lines += [
|
|
249
|
+
f"{workflow} {run_label}{commit} took **{format_duration(impact.duration)}**.",
|
|
250
|
+
"",
|
|
251
|
+
f"No completed runs of this workflow on {branch} to compare with yet.",
|
|
252
|
+
"",
|
|
253
|
+
]
|
|
254
|
+
else:
|
|
255
|
+
significant = _is_significant(
|
|
256
|
+
impact.duration, impact.baseline_duration, impact.threshold, impact.min_seconds
|
|
257
|
+
)
|
|
258
|
+
headline = f"{workflow} {run_label}{commit} took **{format_duration(impact.duration)}**"
|
|
259
|
+
if impact.duration is not None and impact.baseline_duration is not None:
|
|
260
|
+
change = _change_cell(impact.duration, impact.baseline_duration, significant)
|
|
261
|
+
headline += (
|
|
262
|
+
f": **{change}** compared with the median of the last "
|
|
263
|
+
f"{impact.baseline_builds} runs on {branch}."
|
|
264
|
+
)
|
|
265
|
+
lines += [headline, ""]
|
|
266
|
+
|
|
267
|
+
lines += [
|
|
268
|
+
f"| | {branch} (median) | This run | Change |",
|
|
269
|
+
"|---|---:|---:|---:|",
|
|
270
|
+
f"| Wall clock | {format_duration(impact.baseline_duration)} | {format_duration(impact.duration)} "
|
|
271
|
+
f"| {_change_cell(impact.duration, impact.baseline_duration, significant)} |",
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
compute_significant = _is_significant(
|
|
275
|
+
impact.compute_seconds, impact.baseline_compute_seconds, impact.threshold, impact.min_seconds
|
|
276
|
+
)
|
|
277
|
+
lines.append(
|
|
278
|
+
f"| Compute time | {format_duration(impact.baseline_compute_seconds)} "
|
|
279
|
+
f"| {format_duration(impact.compute_seconds)} "
|
|
280
|
+
f"| {_change_cell(impact.compute_seconds, impact.baseline_compute_seconds, compute_significant)} |"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if impact.cost_usd is not None and impact.baseline_cost_usd is not None:
|
|
284
|
+
cost_delta = impact.cost_usd - impact.baseline_cost_usd
|
|
285
|
+
label = "Estimated cost"
|
|
286
|
+
if impact.job_count and impact.priced_jobs < impact.job_count:
|
|
287
|
+
label += f" ({impact.priced_jobs} of {impact.job_count} jobs priced)"
|
|
288
|
+
lines.append(
|
|
289
|
+
f"| {label} | {format_cost(impact.baseline_cost_usd)} | {format_cost(impact.cost_usd)} "
|
|
290
|
+
f"| {'+' if cost_delta >= 0 else '-'}{format_cost(abs(cost_delta))} |"
|
|
291
|
+
)
|
|
292
|
+
lines.append("")
|
|
293
|
+
|
|
294
|
+
changed = [change for change in impact.changes if change.significant]
|
|
295
|
+
lines += ["### Steps that changed", ""]
|
|
296
|
+
if changed:
|
|
297
|
+
lines += [
|
|
298
|
+
f"| Step | {branch} (median) | This run | Change |",
|
|
299
|
+
"|---|---:|---:|---:|",
|
|
300
|
+
]
|
|
301
|
+
for change in changed:
|
|
302
|
+
lines.append(
|
|
303
|
+
f"| {_md_cell(change.name)} | {format_duration(change.baseline_median)} "
|
|
304
|
+
f"| {format_duration(change.current)} "
|
|
305
|
+
f"| {_change_cell(change.current, change.baseline_median, True)} |"
|
|
306
|
+
)
|
|
307
|
+
elif impact.unjudged_steps and not impact.changes:
|
|
308
|
+
# Saying "nothing changed" would claim steps were checked when none was
|
|
309
|
+
lines.append(
|
|
310
|
+
f"Not enough history on {branch} yet to compare steps: each needs at least "
|
|
311
|
+
f"{impact.min_baseline_builds} baseline runs."
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
lines.append(
|
|
315
|
+
f"No step changed by more than {impact.threshold * 100:.0f}% "
|
|
316
|
+
f"and {format_duration(impact.min_seconds)}."
|
|
317
|
+
)
|
|
318
|
+
if impact.unjudged_steps and impact.changes:
|
|
319
|
+
count = impact.unjudged_steps
|
|
320
|
+
lines += [
|
|
321
|
+
"",
|
|
322
|
+
f"{count} step{'s' if count != 1 else ''} with fewer than {impact.min_baseline_builds} "
|
|
323
|
+
f"runs on {branch} {'were' if count != 1 else 'was'} not compared.",
|
|
324
|
+
]
|
|
325
|
+
lines.append("")
|
|
326
|
+
|
|
327
|
+
if impact.failures:
|
|
328
|
+
lines += ["### Failures", ""]
|
|
329
|
+
for failure in impact.failures:
|
|
330
|
+
if failure.known_flaky and failure.evidence:
|
|
331
|
+
evidence = failure.evidence
|
|
332
|
+
lines.append(
|
|
333
|
+
f"- ❌ `{failure.name}`: ⚠️ known to be flaky (failed and passed on the same "
|
|
334
|
+
f"commit in {evidence.get('flaky_commits', 0)} of "
|
|
335
|
+
f"{evidence.get('observed_commits', 0)} commits). A re-run may pass."
|
|
336
|
+
)
|
|
337
|
+
else:
|
|
338
|
+
lines.append(f"- ❌ `{failure.name}`: not known to be flaky.")
|
|
339
|
+
lines.append("")
|
|
340
|
+
|
|
341
|
+
if impact.recovered_on_rerun:
|
|
342
|
+
lines += ["### Passed on re-run", ""]
|
|
343
|
+
for name in impact.recovered_on_rerun:
|
|
344
|
+
lines.append(
|
|
345
|
+
f"- 🔁 `{name}` failed on an earlier attempt of this run and passed on re-run: likely flaky."
|
|
346
|
+
)
|
|
347
|
+
lines.append("")
|
|
348
|
+
|
|
349
|
+
if impact.new_steps:
|
|
350
|
+
shown = ", ".join(f"`{name}`" for name in impact.new_steps[:10])
|
|
351
|
+
more = f" and {len(impact.new_steps) - 10} more" if len(impact.new_steps) > 10 else ""
|
|
352
|
+
lines += [f"New steps, with no baseline yet: {shown}{more}.", ""]
|
|
353
|
+
|
|
354
|
+
lines.append(
|
|
355
|
+
f"<sub>Baseline: the last {impact.baseline_builds} completed runs of this workflow on "
|
|
356
|
+
f"{branch}. Steps are listed when they changed by at least {impact.threshold * 100:.0f}% "
|
|
357
|
+
f"and {format_duration(impact.min_seconds)}. Cost at GitHub list prices for private repositories. "
|
|
358
|
+
"Generated by ci-time-tracker.</sub>"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
return "\n".join(lines) + "\n"
|