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,3 @@
1
+ """ci-time-tracker: A CLI tool for analyzing CI/CD pipeline configurations and build logs."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,12 @@
1
+ """Module entry point for ci-time-tracker.
2
+
3
+ Enables execution via: python -m ci_time_tracker
4
+ """
5
+
6
+ import sys
7
+
8
+ from ci_time_tracker.cli import main
9
+
10
+ if __name__ == "__main__":
11
+ sys.exit(main())
12
+
@@ -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
@@ -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()