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.
@@ -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"