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,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
|
+
}
|
ci_time_tracker/gates.py
ADDED
|
@@ -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
|