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,234 @@
1
+ """Evidence-based detection of flaky steps and duration regressions.
2
+
3
+ Flaky: a step that both failed and passed on the same commit is flaky by
4
+ definition, since the code did not change between the two outcomes. A failed
5
+ run that passed when re-run is the clearest case of it. A high failure rate
6
+ alone is not evidence: a step that fails because commits break it is doing
7
+ its job.
8
+
9
+ Regression: a step whose duration jumped and stayed up. The step's history is
10
+ split at the point that best separates "before" from "after", and the jump
11
+ must be large in both relative and absolute terms and still hold for the most
12
+ recent builds, so that a spike that already recovered is not reported.
13
+ """
14
+
15
+ import heapq
16
+ from collections import defaultdict
17
+ from collections.abc import Callable
18
+ from statistics import median
19
+ from typing import Any
20
+
21
+ from ci_time_tracker.models import BuildLog
22
+
23
+ DEFAULT_REGRESSION_THRESHOLD = 0.25
24
+ DEFAULT_MIN_SAMPLES = 5
25
+ DEFAULT_MIN_INCREASE_SECONDS = 10.0
26
+
27
+ _EXAMPLE_COMMITS = 3
28
+ _SHORT_SHA = 7
29
+
30
+ # Seconds of total deviation below which two candidate splits count as tied
31
+ _COST_TOLERANCE = 1e-9
32
+
33
+
34
+ def _short(sha: str) -> str:
35
+ return sha[:_SHORT_SHA]
36
+
37
+
38
+ def detect_flaky_by_commit(
39
+ logs: list[BuildLog],
40
+ group: Callable[[str], str] = lambda name: name,
41
+ ) -> dict[str, dict[str, Any]]:
42
+ """Find steps that both failed and passed on the same commit.
43
+
44
+ Outcomes are compared per (workflow, step, commit), using each step's own
45
+ name, so a matrix leg that only fails on one platform is not mistaken for
46
+ flakiness. `group` maps those names to the names being reported (for
47
+ example merged matrix legs); a reported step is flaky when any of its legs
48
+ is.
49
+
50
+ Args:
51
+ logs: Build logs; those without a head_sha carry no evidence
52
+ group: Maps a step's own name to its reported name
53
+
54
+ Returns:
55
+ Reported step name -> evidence, only for flaky steps:
56
+ flaky_commits: commits on which the step both failed and passed
57
+ observed_commits: commits on which the step ran to an outcome
58
+ rerun_recoveries: failed runs whose step passed on a re-run
59
+ example_commits: a few short SHAs to look at
60
+ """
61
+ outcomes: dict[tuple[str, str, str], set[str]] = defaultdict(set)
62
+ attempts: dict[tuple[str, str, str], list[tuple[int, str]]] = defaultdict(list)
63
+
64
+ for log in logs:
65
+ if not log.head_sha:
66
+ continue
67
+
68
+ workflow = str(log.metadata.get("workflow_name") or "")
69
+
70
+ for step in log.steps:
71
+ if step.status not in ("success", "failure"):
72
+ continue
73
+
74
+ outcomes[(workflow, step.name, log.head_sha)].add(step.status)
75
+ if log.build_id:
76
+ attempts[(workflow, step.name, log.build_id)].append((log.run_attempt, step.status))
77
+
78
+ # Map each step name once, not once per commit
79
+ reported_names: dict[str, str] = {}
80
+
81
+ def reported_name(name: str) -> str:
82
+ if name not in reported_names:
83
+ reported_names[name] = group(name)
84
+ return reported_names[name]
85
+
86
+ observed: dict[str, set[str]] = defaultdict(set)
87
+ flipped: dict[str, set[str]] = defaultdict(set)
88
+
89
+ for (_, name, sha), statuses in outcomes.items():
90
+ reported = reported_name(name)
91
+ observed[reported].add(sha)
92
+ if len(statuses) == 2: # both success and failure
93
+ flipped[reported].add(sha)
94
+
95
+ recoveries: dict[str, int] = defaultdict(int)
96
+
97
+ for (_, name, _), entries in attempts.items():
98
+ # A build attempted once cannot have recovered on a re-run
99
+ if len(entries) < 2:
100
+ continue
101
+ failed_attempts = [attempt for attempt, status in entries if status == "failure"]
102
+ if not failed_attempts:
103
+ continue
104
+ first_failure = min(failed_attempts)
105
+ if any(status == "success" and attempt > first_failure for attempt, status in entries):
106
+ recoveries[reported_name(name)] += 1
107
+
108
+ return {
109
+ reported: {
110
+ "method": "same-commit",
111
+ "flaky_commits": len(shas),
112
+ "observed_commits": len(observed[reported]),
113
+ "rerun_recoveries": recoveries.get(reported, 0),
114
+ "example_commits": [_short(sha) for sha in sorted(shas)[:_EXAMPLE_COMMITS]],
115
+ }
116
+ for reported, shas in flipped.items()
117
+ }
118
+
119
+
120
+ def _prefix_medians_and_costs(values: list[float]) -> list[tuple[float, float]]:
121
+ """Median and total absolute deviation from it, for every prefix of values.
122
+
123
+ Entry i describes values[:i + 1]. Two heaps hold the lower and upper
124
+ halves, so each prefix costs O(log n) rather than a sort and a pass over
125
+ it. The total absolute deviation from the median equals the sum of the
126
+ upper half minus the sum of the lower half, the median itself left out.
127
+ """
128
+ lower: list[float] = [] # max-heap, stored negated
129
+ upper: list[float] = [] # min-heap
130
+ lower_sum = upper_sum = 0.0
131
+ results: list[tuple[float, float]] = []
132
+
133
+ for value in values:
134
+ if not lower or value <= -lower[0]:
135
+ heapq.heappush(lower, -value)
136
+ lower_sum += value
137
+ else:
138
+ heapq.heappush(upper, value)
139
+ upper_sum += value
140
+
141
+ # Keep the halves balanced; lower holds the extra value when odd
142
+ if len(lower) > len(upper) + 1:
143
+ moved = -heapq.heappop(lower)
144
+ lower_sum -= moved
145
+ heapq.heappush(upper, moved)
146
+ upper_sum += moved
147
+ elif len(upper) > len(lower):
148
+ moved = heapq.heappop(upper)
149
+ upper_sum -= moved
150
+ heapq.heappush(lower, -moved)
151
+ lower_sum += moved
152
+
153
+ if len(lower) > len(upper):
154
+ center = -lower[0]
155
+ results.append((center, upper_sum - (lower_sum - center)))
156
+ else:
157
+ center = (-lower[0] + upper[0]) / 2
158
+ results.append((center, upper_sum - lower_sum))
159
+
160
+ return results
161
+
162
+
163
+ def detect_regression(
164
+ samples: list[tuple[float, BuildLog]],
165
+ threshold: float = DEFAULT_REGRESSION_THRESHOLD,
166
+ min_samples: int = DEFAULT_MIN_SAMPLES,
167
+ min_increase_seconds: float = DEFAULT_MIN_INCREASE_SECONDS,
168
+ ) -> dict[str, Any] | None:
169
+ """Detect a lasting increase in a step's duration.
170
+
171
+ Finds the single split that best divides the history into two stable
172
+ segments (least total absolute deviation from each segment's median),
173
+ then reports a regression only when the later segment is slower by at
174
+ least `threshold` and `min_increase_seconds`, and the most recent
175
+ `min_samples` builds are still that slow.
176
+
177
+ Args:
178
+ samples: (duration, build) pairs in chronological order, one per build
179
+ threshold: Minimum relative increase, e.g. 0.25 for +25%
180
+ min_samples: Minimum builds on each side of the split
181
+ min_increase_seconds: Minimum absolute increase, so that 1s -> 2s
182
+ steps are not reported
183
+
184
+ Returns:
185
+ Regression details, or None when there is none or too little history
186
+ """
187
+ durations = [duration for duration, _ in samples]
188
+ count = len(durations)
189
+
190
+ if count < 2 * min_samples:
191
+ return None
192
+
193
+ # prefix[i] describes durations[:i + 1]; suffix[i] describes durations[i:]
194
+ prefix = _prefix_medians_and_costs(durations)
195
+ suffix = _prefix_medians_and_costs(durations[::-1])[::-1]
196
+
197
+ best: tuple[float, int, float, float] | None = None
198
+
199
+ for split in range(min_samples, count - min_samples + 1):
200
+ before_median, before_cost = prefix[split - 1]
201
+ after_median, after_cost = suffix[split]
202
+ cost = before_cost + after_cost
203
+
204
+ # Running sums are not exact, so a tie must not let a later split win
205
+ if best is None or cost < best[0] - _COST_TOLERANCE:
206
+ best = (cost, split, before_median, after_median)
207
+
208
+ if best is None:
209
+ return None
210
+
211
+ _, split, baseline, current = best
212
+ recent = median(durations[-min_samples:])
213
+ required = baseline * (1 + threshold)
214
+
215
+ if current < required or recent < required:
216
+ return None
217
+ if current - baseline < min_increase_seconds or recent - baseline < min_increase_seconds:
218
+ return None
219
+
220
+ started = samples[split][1]
221
+
222
+ return {
223
+ "baseline_median": baseline,
224
+ "current_median": current,
225
+ "increase_seconds": current - baseline,
226
+ "increase_pct": (current - baseline) / baseline if baseline > 0 else None,
227
+ "baseline_builds": split,
228
+ "current_builds": count - split,
229
+ "started_at": {
230
+ "build_id": started.build_id,
231
+ "head_sha": _short(started.head_sha) if started.head_sha else None,
232
+ "timestamp": started.timestamp.isoformat() if started.timestamp else None,
233
+ },
234
+ }
@@ -0,0 +1,108 @@
1
+ """Quality gates: turn findings into a failing exit code for CI.
2
+
3
+ A gate lets the report fail the pipeline it runs in, e.g. when a step became
4
+ flaky or regressed, or when builds got too slow.
5
+ """
6
+
7
+ from collections.abc import Iterable
8
+
9
+ from ci_time_tracker.impact import RunImpact
10
+ from ci_time_tracker.models import AnalysisResult
11
+ from ci_time_tracker.reporter import format_duration
12
+
13
+ # Findings that --fail-on can gate on
14
+ GATE_CHOICES = ("flaky", "regression")
15
+
16
+ # How many step names a failure reason lists before summarizing the rest
17
+ _LISTED_STEPS = 5
18
+
19
+
20
+ def _list_steps(names: list[str]) -> str:
21
+ listed = ", ".join(names[:_LISTED_STEPS])
22
+ hidden = len(names) - _LISTED_STEPS
23
+ return f"{listed} and {hidden} more" if hidden > 0 else listed
24
+
25
+
26
+ def evaluate_gates(
27
+ result: AnalysisResult,
28
+ fail_on: Iterable[str] = (),
29
+ max_duration: float | None = None,
30
+ ) -> list[str]:
31
+ """Return the reasons the gates fail, or an empty list when they pass.
32
+
33
+ Args:
34
+ result: The analysis to judge
35
+ fail_on: Findings that fail the gate: "flaky", "regression"
36
+ max_duration: Fail when the median build duration exceeds this many
37
+ seconds. Not checked when no build durations are known.
38
+
39
+ Returns:
40
+ Human-readable failure reasons
41
+ """
42
+ gates = set(fail_on)
43
+ reasons: list[str] = []
44
+
45
+ if "flaky" in gates and result.flaky_steps:
46
+ count = len(result.flaky_steps)
47
+ reasons.append(f"{count} flaky step{'s' if count != 1 else ''}: {_list_steps(result.flaky_steps)}")
48
+
49
+ if "regression" in gates and result.regressed_steps:
50
+ count = len(result.regressed_steps)
51
+ reasons.append(
52
+ f"{count} regressed step{'s' if count != 1 else ''}: {_list_steps(result.regressed_steps)}"
53
+ )
54
+
55
+ if max_duration is not None:
56
+ median_build = result.metadata.get("build_p50_seconds")
57
+ if median_build is not None and median_build > max_duration:
58
+ reasons.append(
59
+ f"median build duration {format_duration(median_build)} exceeds "
60
+ f"the {format_duration(max_duration)} limit"
61
+ )
62
+
63
+ return reasons
64
+
65
+
66
+ def evaluate_impact_gates(
67
+ impact: RunImpact,
68
+ fail_on: Iterable[str] = (),
69
+ max_duration: float | None = None,
70
+ ) -> list[str]:
71
+ """Return the reasons a single run fails the gates (the pull request view).
72
+
73
+ Args:
74
+ impact: The run compared with its baseline
75
+ fail_on: "regression" fails when a step got significantly slower than
76
+ on the baseline branch; "flaky" when the run hit a step known to be
77
+ flaky, or one that only passed on re-run
78
+ max_duration: Fail when the run took longer than this many seconds
79
+
80
+ Returns:
81
+ Human-readable failure reasons
82
+ """
83
+ gates = set(fail_on)
84
+ reasons: list[str] = []
85
+
86
+ if "regression" in gates:
87
+ slower = [change.name for change in impact.changes if change.significant and change.delta_seconds > 0]
88
+ if slower:
89
+ count = len(slower)
90
+ reasons.append(
91
+ f"{count} step{'s' if count != 1 else ''} slower than on "
92
+ f"{impact.baseline_branch}: {_list_steps(slower)}"
93
+ )
94
+
95
+ if "flaky" in gates:
96
+ flaky = [failure.name for failure in impact.failures if failure.known_flaky]
97
+ flaky += [name for name in impact.recovered_on_rerun if name not in flaky]
98
+ if flaky:
99
+ count = len(flaky)
100
+ reasons.append(f"{count} flaky step{'s' if count != 1 else ''} hit: {_list_steps(flaky)}")
101
+
102
+ if max_duration is not None and impact.duration is not None and impact.duration > max_duration:
103
+ reasons.append(
104
+ f"run duration {format_duration(impact.duration)} exceeds "
105
+ f"the {format_duration(max_duration)} limit"
106
+ )
107
+
108
+ return reasons