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,444 @@
|
|
|
1
|
+
"""Analyzer module for ci-time-tracker.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to analyze CI/CD pipeline configurations
|
|
4
|
+
and build logs, computing statistics, detecting duration regressions, and identifying
|
|
5
|
+
flaky steps.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from collections import Counter, defaultdict
|
|
10
|
+
from statistics import median
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ci_time_tracker.detection import (
|
|
14
|
+
DEFAULT_MIN_SAMPLES,
|
|
15
|
+
DEFAULT_REGRESSION_THRESHOLD,
|
|
16
|
+
detect_flaky_by_commit,
|
|
17
|
+
detect_regression,
|
|
18
|
+
)
|
|
19
|
+
from ci_time_tracker.models import (
|
|
20
|
+
AnalysisResult,
|
|
21
|
+
BuildLog,
|
|
22
|
+
PipelineConfig,
|
|
23
|
+
PipelineStep,
|
|
24
|
+
StepStatistics,
|
|
25
|
+
)
|
|
26
|
+
from ci_time_tracker.pricing import PricingTable, attributed_cost, job_cost
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compute_percentiles(durations: list[float]) -> dict[str, float | None]:
|
|
30
|
+
"""Compute p50, p90, p95, p99 percentiles for a list of durations.
|
|
31
|
+
|
|
32
|
+
Uses the nearest-rank method for percentile calculation, where pN is
|
|
33
|
+
the smallest value such that at least N% of the data is less than or
|
|
34
|
+
equal to that value.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
durations: List of duration values in seconds. Must be non-empty.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Dictionary with keys 'p50', 'p90', 'p95', 'p99' and their computed values.
|
|
41
|
+
Returns None values if durations list is empty.
|
|
42
|
+
"""
|
|
43
|
+
if not durations:
|
|
44
|
+
return {"p50": None, "p90": None, "p95": None, "p99": None}
|
|
45
|
+
|
|
46
|
+
sorted_durations = sorted(durations)
|
|
47
|
+
n = len(sorted_durations)
|
|
48
|
+
|
|
49
|
+
def percentile(p: float) -> float:
|
|
50
|
+
"""Calculate percentile using nearest-rank method."""
|
|
51
|
+
if n == 1:
|
|
52
|
+
return sorted_durations[0]
|
|
53
|
+
# Calculate rank (1-indexed position)
|
|
54
|
+
rank = (p / 100.0) * n
|
|
55
|
+
# Use ceiling to get the index (convert to 0-indexed)
|
|
56
|
+
index = int(rank)
|
|
57
|
+
if index >= n:
|
|
58
|
+
index = n - 1
|
|
59
|
+
elif index < 1:
|
|
60
|
+
index = 0
|
|
61
|
+
else:
|
|
62
|
+
index = index - 1 if rank == int(rank) else index
|
|
63
|
+
# Clamp to valid range
|
|
64
|
+
index = max(0, min(index, n - 1))
|
|
65
|
+
return sorted_durations[index]
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"p50": percentile(50),
|
|
69
|
+
"p90": percentile(90),
|
|
70
|
+
"p95": percentile(95),
|
|
71
|
+
"p99": percentile(99),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def analyze_config(
|
|
76
|
+
config: PipelineConfig,
|
|
77
|
+
estimates: dict[str, float] | None = None
|
|
78
|
+
) -> AnalysisResult:
|
|
79
|
+
"""Analyze pipeline configuration and return summary.
|
|
80
|
+
|
|
81
|
+
Processes a parsed pipeline configuration to create an analysis result
|
|
82
|
+
containing all steps with their estimated durations (if provided).
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
config: Parsed pipeline configuration
|
|
86
|
+
estimates: Optional dictionary mapping step names to estimated
|
|
87
|
+
durations in seconds
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
AnalysisResult in config mode with steps and total estimated duration
|
|
91
|
+
"""
|
|
92
|
+
estimates = estimates or {}
|
|
93
|
+
|
|
94
|
+
# Associate estimates with steps
|
|
95
|
+
steps_with_estimates: list[PipelineStep] = []
|
|
96
|
+
total_estimated = 0.0
|
|
97
|
+
has_estimates = False
|
|
98
|
+
|
|
99
|
+
for step in config.steps:
|
|
100
|
+
# Look up estimate by step name
|
|
101
|
+
estimated_duration = estimates.get(step.name)
|
|
102
|
+
|
|
103
|
+
# Create new step with estimate if found
|
|
104
|
+
step_with_estimate = PipelineStep(
|
|
105
|
+
name=step.name,
|
|
106
|
+
stage=step.stage,
|
|
107
|
+
command=step.command,
|
|
108
|
+
estimated_duration=estimated_duration,
|
|
109
|
+
)
|
|
110
|
+
steps_with_estimates.append(step_with_estimate)
|
|
111
|
+
|
|
112
|
+
if estimated_duration is not None:
|
|
113
|
+
total_estimated += estimated_duration
|
|
114
|
+
has_estimates = True
|
|
115
|
+
|
|
116
|
+
return AnalysisResult(
|
|
117
|
+
mode="config",
|
|
118
|
+
steps=steps_with_estimates,
|
|
119
|
+
total_estimated_duration=total_estimated if has_estimates else None,
|
|
120
|
+
regressed_steps=[],
|
|
121
|
+
flaky_steps=[],
|
|
122
|
+
metadata={
|
|
123
|
+
"provider": config.provider,
|
|
124
|
+
"stages": config.stages,
|
|
125
|
+
"step_count": len(steps_with_estimates),
|
|
126
|
+
**config.metadata,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Matrix parameters as GitHub appends them to a job name: "build (3.12, windows-latest)"
|
|
132
|
+
_MATRIX_PARAMS_PATTERN = re.compile(r'\s*\([^()]*\)')
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def normalize_matrix_name(name: str) -> str:
|
|
136
|
+
"""Strip matrix parameters from a step's job prefix.
|
|
137
|
+
|
|
138
|
+
GitHub names every leg of a matrix separately, so "Run tests" becomes
|
|
139
|
+
"build (3.12, windows-latest) / Run tests", one step per leg. Grouping
|
|
140
|
+
the legs answers how much a step costs across the whole matrix, at the
|
|
141
|
+
price of mixing runners with different rates.
|
|
142
|
+
|
|
143
|
+
Only the job prefix is stripped, so parentheses in a step's own name
|
|
144
|
+
are left alone.
|
|
145
|
+
"""
|
|
146
|
+
job, separator, step = name.partition(" / ")
|
|
147
|
+
if not separator:
|
|
148
|
+
return _MATRIX_PARAMS_PATTERN.sub("", name).strip()
|
|
149
|
+
|
|
150
|
+
return f"{_MATRIX_PARAMS_PATTERN.sub('', job).strip()}{separator}{step}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _chronological(logs: list[BuildLog]) -> list[BuildLog]:
|
|
154
|
+
"""Order builds oldest first when every build has a comparable timestamp.
|
|
155
|
+
|
|
156
|
+
Otherwise the given order is kept: log files and API results already come
|
|
157
|
+
in a meaningful order, and aware and naive timestamps cannot be compared.
|
|
158
|
+
"""
|
|
159
|
+
timestamps = [log.timestamp for log in logs]
|
|
160
|
+
|
|
161
|
+
if any(timestamp is None for timestamp in timestamps):
|
|
162
|
+
return list(logs)
|
|
163
|
+
if len({timestamp.tzinfo is None for timestamp in timestamps}) > 1:
|
|
164
|
+
return list(logs)
|
|
165
|
+
|
|
166
|
+
return sorted(logs, key=lambda log: log.timestamp)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def analyze_logs(
|
|
170
|
+
logs: list[BuildLog],
|
|
171
|
+
pricing: PricingTable | None = None,
|
|
172
|
+
group_matrix: bool = False,
|
|
173
|
+
regression_threshold: float = DEFAULT_REGRESSION_THRESHOLD,
|
|
174
|
+
min_samples: int = DEFAULT_MIN_SAMPLES,
|
|
175
|
+
) -> AnalysisResult:
|
|
176
|
+
"""Analyze multiple build logs and compute statistics.
|
|
177
|
+
|
|
178
|
+
Aggregates step execution data across builds to compute duration
|
|
179
|
+
percentiles and consumed time, detect duration regressions, identify
|
|
180
|
+
flaky steps, and estimate cost where the runner's rate is known.
|
|
181
|
+
|
|
182
|
+
Flaky detection uses same-commit evidence whenever builds carry a commit
|
|
183
|
+
SHA and falls back to the failure-rate heuristic otherwise; the method
|
|
184
|
+
used is recorded in the metadata.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
logs: List of parsed build logs
|
|
188
|
+
pricing: Optional runner pricing table; defaults to the built-in rates
|
|
189
|
+
group_matrix: Aggregate the legs of a matrix job into one step
|
|
190
|
+
regression_threshold: Minimum relative duration increase to report,
|
|
191
|
+
e.g. 0.25 for +25%
|
|
192
|
+
min_samples: Minimum builds on each side of a regression
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
AnalysisResult in log mode with step statistics and detected issues
|
|
196
|
+
"""
|
|
197
|
+
pricing = pricing or PricingTable()
|
|
198
|
+
|
|
199
|
+
if not logs:
|
|
200
|
+
return AnalysisResult(
|
|
201
|
+
mode="log",
|
|
202
|
+
steps=[],
|
|
203
|
+
total_estimated_duration=None,
|
|
204
|
+
regressed_steps=[],
|
|
205
|
+
flaky_steps=[],
|
|
206
|
+
metadata={"build_count": 0},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
logs = _chronological(logs)
|
|
210
|
+
|
|
211
|
+
def reported_name(name: str) -> str:
|
|
212
|
+
return normalize_matrix_name(name) if group_matrix else name
|
|
213
|
+
|
|
214
|
+
# Aggregate step data by name
|
|
215
|
+
step_data: dict[str, dict] = {}
|
|
216
|
+
|
|
217
|
+
for build_index, log in enumerate(logs):
|
|
218
|
+
for step in log.steps:
|
|
219
|
+
name = reported_name(step.name)
|
|
220
|
+
|
|
221
|
+
if name not in step_data:
|
|
222
|
+
step_data[name] = {
|
|
223
|
+
"durations": [],
|
|
224
|
+
"build_durations": defaultdict(list),
|
|
225
|
+
"success_count": 0,
|
|
226
|
+
"failure_count": 0,
|
|
227
|
+
"execution_count": 0,
|
|
228
|
+
"retry_count": 0,
|
|
229
|
+
"runners": Counter(),
|
|
230
|
+
"cost": 0.0,
|
|
231
|
+
"priced_executions": 0,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
data = step_data[name]
|
|
235
|
+
data["execution_count"] += 1
|
|
236
|
+
|
|
237
|
+
if step.duration_seconds is not None:
|
|
238
|
+
data["durations"].append(step.duration_seconds)
|
|
239
|
+
data["build_durations"][build_index].append(step.duration_seconds)
|
|
240
|
+
|
|
241
|
+
if step.status == "success":
|
|
242
|
+
data["success_count"] += 1
|
|
243
|
+
elif step.status == "failure":
|
|
244
|
+
data["failure_count"] += 1
|
|
245
|
+
|
|
246
|
+
if step.is_retry:
|
|
247
|
+
data["retry_count"] += 1
|
|
248
|
+
|
|
249
|
+
if step.runner:
|
|
250
|
+
data["runners"][step.runner] += 1
|
|
251
|
+
|
|
252
|
+
cost = attributed_cost(step.duration_seconds, pricing.price_for(step.runner))
|
|
253
|
+
if cost is not None:
|
|
254
|
+
data["cost"] += cost
|
|
255
|
+
data["priced_executions"] += 1
|
|
256
|
+
|
|
257
|
+
# Build step statistics
|
|
258
|
+
step_stats: list[StepStatistics] = []
|
|
259
|
+
|
|
260
|
+
for name, data in step_data.items():
|
|
261
|
+
percentiles = compute_percentiles(data["durations"])
|
|
262
|
+
|
|
263
|
+
execution_count = data["execution_count"]
|
|
264
|
+
failure_count = data["failure_count"]
|
|
265
|
+
failure_rate = failure_count / execution_count if execution_count > 0 else 0.0
|
|
266
|
+
|
|
267
|
+
durations = data["durations"]
|
|
268
|
+
total_duration = sum(durations)
|
|
269
|
+
most_common_runner = data["runners"].most_common(1)
|
|
270
|
+
|
|
271
|
+
stats = StepStatistics(
|
|
272
|
+
name=name,
|
|
273
|
+
execution_count=execution_count,
|
|
274
|
+
success_count=data["success_count"],
|
|
275
|
+
failure_count=failure_count,
|
|
276
|
+
durations=durations,
|
|
277
|
+
p50=percentiles["p50"],
|
|
278
|
+
p90=percentiles["p90"],
|
|
279
|
+
p95=percentiles["p95"],
|
|
280
|
+
p99=percentiles["p99"],
|
|
281
|
+
failure_rate=failure_rate,
|
|
282
|
+
total_duration=total_duration,
|
|
283
|
+
mean_duration=total_duration / len(durations) if durations else None,
|
|
284
|
+
runner=most_common_runner[0][0] if most_common_runner else None,
|
|
285
|
+
estimated_cost_usd=data["cost"] if data["priced_executions"] else None,
|
|
286
|
+
)
|
|
287
|
+
step_stats.append(stats)
|
|
288
|
+
|
|
289
|
+
# Flaky steps: same-commit evidence when builds carry a commit, otherwise
|
|
290
|
+
# the failure-rate heuristic, which cannot tell flakiness from breakage
|
|
291
|
+
if any(log.head_sha for log in logs):
|
|
292
|
+
flaky_method = "same-commit"
|
|
293
|
+
flaky_evidence = detect_flaky_by_commit(logs, reported_name)
|
|
294
|
+
else:
|
|
295
|
+
flaky_method = "failure-rate"
|
|
296
|
+
flaky_evidence = {
|
|
297
|
+
name: {"method": "failure-rate"} for name in detect_flaky_steps(step_stats)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
# Regressions, from one sample per build (the median across matrix legs
|
|
301
|
+
# when they are grouped), in chronological order
|
|
302
|
+
regression_checked = 0
|
|
303
|
+
|
|
304
|
+
for stats in step_stats:
|
|
305
|
+
build_durations = step_data[stats.name]["build_durations"]
|
|
306
|
+
samples = [
|
|
307
|
+
(median(values), logs[build_index])
|
|
308
|
+
for build_index, values in sorted(build_durations.items())
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
if len(samples) >= 2 * min_samples:
|
|
312
|
+
regression_checked += 1
|
|
313
|
+
|
|
314
|
+
stats.regression = detect_regression(samples, regression_threshold, min_samples)
|
|
315
|
+
stats.is_regression = stats.regression is not None
|
|
316
|
+
stats.flaky_evidence = flaky_evidence.get(stats.name)
|
|
317
|
+
stats.is_flaky = stats.flaky_evidence is not None
|
|
318
|
+
|
|
319
|
+
metadata: dict[str, Any] = {
|
|
320
|
+
"build_count": len(logs),
|
|
321
|
+
"step_count": len(step_stats),
|
|
322
|
+
"total_execution_seconds": sum(stats.total_duration for stats in step_stats),
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
# How long a build takes end to end; superseded attempts are left out so
|
|
326
|
+
# that a re-run build is not counted twice
|
|
327
|
+
build_durations = [
|
|
328
|
+
log.total_duration
|
|
329
|
+
for log in logs
|
|
330
|
+
if log.total_duration is not None and not log.metadata.get("superseded_attempt")
|
|
331
|
+
]
|
|
332
|
+
if build_durations:
|
|
333
|
+
build_percentiles = compute_percentiles(build_durations)
|
|
334
|
+
metadata["build_p50_seconds"] = build_percentiles["p50"]
|
|
335
|
+
metadata["build_p90_seconds"] = build_percentiles["p90"]
|
|
336
|
+
|
|
337
|
+
if group_matrix:
|
|
338
|
+
metadata["grouping"] = "matrix legs merged"
|
|
339
|
+
|
|
340
|
+
superseded = sum(1 for log in logs if log.metadata.get("superseded_attempt"))
|
|
341
|
+
if superseded:
|
|
342
|
+
metadata["superseded_attempts"] = superseded
|
|
343
|
+
|
|
344
|
+
metadata["flaky_detection"] = flaky_method
|
|
345
|
+
metadata["regression_checked_steps"] = regression_checked
|
|
346
|
+
metadata["regression_min_builds"] = 2 * min_samples
|
|
347
|
+
metadata.update(aggregate_job_metrics(logs, pricing))
|
|
348
|
+
|
|
349
|
+
return AnalysisResult(
|
|
350
|
+
mode="log",
|
|
351
|
+
steps=step_stats,
|
|
352
|
+
total_estimated_duration=None,
|
|
353
|
+
regressed_steps=[stats.name for stats in step_stats if stats.is_regression],
|
|
354
|
+
flaky_steps=[stats.name for stats in step_stats if stats.is_flaky],
|
|
355
|
+
metadata=metadata,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def aggregate_job_metrics(
|
|
360
|
+
logs: list[BuildLog],
|
|
361
|
+
pricing: PricingTable | None = None,
|
|
362
|
+
) -> dict[str, Any]:
|
|
363
|
+
"""Aggregate job-level queue time and billed cost across builds.
|
|
364
|
+
|
|
365
|
+
Only providers that report jobs (the API ingestion) carry this data, so
|
|
366
|
+
text logs yield an empty dict. Cost uses GitHub's per-job rounding, which
|
|
367
|
+
makes it slightly higher than the sum of the steps' attributed costs.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
logs: List of build logs whose metadata may carry a "jobs" list
|
|
371
|
+
pricing: Optional runner pricing table
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Metadata entries for queue time and cost, empty when no jobs are known
|
|
375
|
+
"""
|
|
376
|
+
pricing = pricing or PricingTable()
|
|
377
|
+
|
|
378
|
+
queue_times: list[float] = []
|
|
379
|
+
billed_cost = 0.0
|
|
380
|
+
priced_jobs = 0
|
|
381
|
+
unpriced_runners: set[str] = set()
|
|
382
|
+
job_count = 0
|
|
383
|
+
|
|
384
|
+
for log in logs:
|
|
385
|
+
for job in log.metadata.get("jobs") or []:
|
|
386
|
+
job_count += 1
|
|
387
|
+
|
|
388
|
+
queued = job.get("queued_seconds")
|
|
389
|
+
if queued is not None and queued >= 0:
|
|
390
|
+
queue_times.append(float(queued))
|
|
391
|
+
|
|
392
|
+
price = pricing.price_for(job.get("runner"))
|
|
393
|
+
if price is None:
|
|
394
|
+
if job.get("runner"):
|
|
395
|
+
unpriced_runners.add(str(job["runner"]))
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
cost = job_cost(job.get("duration_seconds"), price)
|
|
399
|
+
if cost is not None:
|
|
400
|
+
billed_cost += cost
|
|
401
|
+
priced_jobs += 1
|
|
402
|
+
|
|
403
|
+
if not job_count:
|
|
404
|
+
return {}
|
|
405
|
+
|
|
406
|
+
metrics: dict[str, Any] = {"job_count": job_count}
|
|
407
|
+
|
|
408
|
+
if queue_times:
|
|
409
|
+
percentiles = compute_percentiles(queue_times)
|
|
410
|
+
metrics["total_queue_seconds"] = sum(queue_times)
|
|
411
|
+
metrics["queue_p50_seconds"] = percentiles["p50"]
|
|
412
|
+
metrics["queue_p90_seconds"] = percentiles["p90"]
|
|
413
|
+
|
|
414
|
+
if priced_jobs:
|
|
415
|
+
metrics["estimated_cost_usd"] = round(billed_cost, 4)
|
|
416
|
+
metrics["priced_jobs"] = priced_jobs
|
|
417
|
+
metrics["pricing_source"] = pricing.source
|
|
418
|
+
|
|
419
|
+
if unpriced_runners:
|
|
420
|
+
metrics["unpriced_runners"] = sorted(unpriced_runners)
|
|
421
|
+
|
|
422
|
+
return metrics
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def detect_flaky_steps(stats: list[StepStatistics]) -> list[str]:
|
|
426
|
+
"""Identify steps with failure rate between 5% and 95%.
|
|
427
|
+
|
|
428
|
+
A step is flagged as flaky if its failure rate is strictly between
|
|
429
|
+
5% and 95% (exclusive), indicating intermittent failures.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
stats: List of step statistics with failure counts
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
List of step names that are flagged as flaky
|
|
436
|
+
"""
|
|
437
|
+
flaky_steps: list[str] = []
|
|
438
|
+
|
|
439
|
+
for step in stats:
|
|
440
|
+
# Flaky if failure rate is strictly between 5% and 95%
|
|
441
|
+
if 0.05 < step.failure_rate < 0.95:
|
|
442
|
+
flaky_steps.append(step.name)
|
|
443
|
+
|
|
444
|
+
return flaky_steps
|
ci_time_tracker/cache.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Local cache for CI provider API responses.
|
|
2
|
+
|
|
3
|
+
Listing workflow runs is cheap, but every run costs an extra request for its
|
|
4
|
+
jobs. Those payloads no longer change once a run has completed, so they are
|
|
5
|
+
stored in a small SQLite database between invocations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sqlite3
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
_SCHEMA = """
|
|
16
|
+
CREATE TABLE IF NOT EXISTS run_jobs (
|
|
17
|
+
repo TEXT NOT NULL,
|
|
18
|
+
run_id INTEGER NOT NULL,
|
|
19
|
+
run_attempt INTEGER NOT NULL,
|
|
20
|
+
fetched_at TEXT NOT NULL,
|
|
21
|
+
payload TEXT NOT NULL,
|
|
22
|
+
PRIMARY KEY (repo, run_id, run_attempt)
|
|
23
|
+
);
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_cache_path() -> Path:
|
|
28
|
+
"""Return the cache database location for this platform.
|
|
29
|
+
|
|
30
|
+
Overridable with the CI_TIME_TRACKER_CACHE environment variable.
|
|
31
|
+
"""
|
|
32
|
+
override = os.environ.get("CI_TIME_TRACKER_CACHE")
|
|
33
|
+
if override:
|
|
34
|
+
return Path(override)
|
|
35
|
+
|
|
36
|
+
if os.name == "nt":
|
|
37
|
+
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
|
38
|
+
else:
|
|
39
|
+
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
|
|
40
|
+
|
|
41
|
+
return base / "ci-time-tracker" / "runs.db"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RunCache:
|
|
45
|
+
"""Stores the jobs payload of completed runs, keyed by run and attempt."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, path: str | Path | None = None):
|
|
48
|
+
self.path = Path(path) if path is not None else default_cache_path()
|
|
49
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
self._connection = sqlite3.connect(str(self.path))
|
|
51
|
+
self._connection.executescript(_SCHEMA)
|
|
52
|
+
self._connection.commit()
|
|
53
|
+
|
|
54
|
+
def get_jobs(
|
|
55
|
+
self,
|
|
56
|
+
repo: str,
|
|
57
|
+
run_id: int | str,
|
|
58
|
+
run_attempt: int = 1,
|
|
59
|
+
) -> list[dict[str, Any]] | None:
|
|
60
|
+
"""Return the cached jobs of a run, or None when not cached."""
|
|
61
|
+
row = self._connection.execute(
|
|
62
|
+
"SELECT payload FROM run_jobs WHERE repo = ? AND run_id = ? AND run_attempt = ?",
|
|
63
|
+
(repo, int(run_id), int(run_attempt)),
|
|
64
|
+
).fetchone()
|
|
65
|
+
|
|
66
|
+
if row is None:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
return json.loads(row[0])
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
# A corrupted row should not break the run; refetch instead.
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def store_jobs(
|
|
76
|
+
self,
|
|
77
|
+
repo: str,
|
|
78
|
+
run_id: int | str,
|
|
79
|
+
run_attempt: int,
|
|
80
|
+
jobs: list[dict[str, Any]],
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Cache the jobs payload of a completed run."""
|
|
83
|
+
self._connection.execute(
|
|
84
|
+
"INSERT OR REPLACE INTO run_jobs "
|
|
85
|
+
"(repo, run_id, run_attempt, fetched_at, payload) VALUES (?, ?, ?, ?, ?)",
|
|
86
|
+
(
|
|
87
|
+
repo,
|
|
88
|
+
int(run_id),
|
|
89
|
+
int(run_attempt),
|
|
90
|
+
datetime.now(timezone.utc).isoformat(),
|
|
91
|
+
json.dumps(jobs),
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
self._connection.commit()
|
|
95
|
+
|
|
96
|
+
def count(self, repo: str | None = None) -> int:
|
|
97
|
+
"""Number of cached runs, optionally limited to one repository."""
|
|
98
|
+
if repo is None:
|
|
99
|
+
query, params = "SELECT COUNT(*) FROM run_jobs", ()
|
|
100
|
+
else:
|
|
101
|
+
query, params = "SELECT COUNT(*) FROM run_jobs WHERE repo = ?", (repo,)
|
|
102
|
+
|
|
103
|
+
return int(self._connection.execute(query, params).fetchone()[0])
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
"""Close the underlying database connection."""
|
|
107
|
+
self._connection.close()
|
|
108
|
+
|
|
109
|
+
def __enter__(self) -> "RunCache":
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
113
|
+
self.close()
|