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,277 @@
1
+ """Data models for ci-time-tracker.
2
+
3
+ This module contains all dataclasses used across the application for
4
+ representing pipeline configurations, build logs, statistics, and reports.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from typing import Any, Literal
10
+
11
+
12
+ @dataclass
13
+ class PipelineStep:
14
+ """A discrete unit of work within a CI/CD pipeline.
15
+
16
+ Attributes:
17
+ name: The step identifier/name
18
+ stage: Optional stage grouping for the step
19
+ command: Optional command or script executed by the step
20
+ estimated_duration: Optional estimated runtime in seconds
21
+ """
22
+ name: str
23
+ stage: str | None = None
24
+ command: str | None = None
25
+ estimated_duration: float | None = None
26
+
27
+ def validate(self) -> list[str]:
28
+ """Validate the step data and return list of validation errors."""
29
+ errors = []
30
+ if not self.name or not self.name.strip():
31
+ errors.append("Step name cannot be empty")
32
+ if self.estimated_duration is not None and self.estimated_duration < 0:
33
+ errors.append("Estimated duration cannot be negative")
34
+ return errors
35
+
36
+
37
+ @dataclass
38
+ class PipelineConfig:
39
+ """Parsed CI/CD pipeline configuration.
40
+
41
+ Attributes:
42
+ provider: CI provider name (github, gitlab, circleci)
43
+ steps: List of pipeline steps
44
+ stages: List of stage names in order
45
+ metadata: Additional provider-specific metadata
46
+ """
47
+ provider: str
48
+ steps: list[PipelineStep] = field(default_factory=list)
49
+ stages: list[str] = field(default_factory=list)
50
+ metadata: dict[str, Any] = field(default_factory=dict)
51
+
52
+ def validate(self) -> list[str]:
53
+ """Validate the config and return list of validation errors."""
54
+ errors = []
55
+ if not self.provider or not self.provider.strip():
56
+ errors.append("Provider cannot be empty")
57
+ for step in self.steps:
58
+ step_errors = step.validate()
59
+ errors.extend(step_errors)
60
+ return errors
61
+
62
+
63
+ @dataclass
64
+ class StepExecution:
65
+ """A single execution of a pipeline step from a build log.
66
+
67
+ Attributes:
68
+ name: The step identifier/name
69
+ start_time: When the step started executing
70
+ end_time: When the step finished executing
71
+ duration_seconds: Computed or extracted duration in seconds
72
+ status: Execution result (success, failure, skipped)
73
+ is_retry: Whether this execution was a retry attempt
74
+ job_name: Name of the job this step ran in, when known
75
+ runner: Label or name of the machine that ran the step, when known
76
+ """
77
+ name: str
78
+ start_time: datetime | None = None
79
+ end_time: datetime | None = None
80
+ duration_seconds: float | None = None
81
+ status: Literal["success", "failure", "skipped"] = "success"
82
+ is_retry: bool = False
83
+ job_name: str | None = None
84
+ runner: str | None = None
85
+
86
+ def validate(self) -> list[str]:
87
+ """Validate the step execution and return list of validation errors."""
88
+ errors = []
89
+ if not self.name or not self.name.strip():
90
+ errors.append("Step name cannot be empty")
91
+ if self.duration_seconds is not None and self.duration_seconds < 0:
92
+ errors.append("Duration cannot be negative")
93
+ if self.start_time and self.end_time and self.start_time > self.end_time:
94
+ errors.append("Start time cannot be after end time")
95
+ return errors
96
+
97
+
98
+ @dataclass
99
+ class BuildLog:
100
+ """Parsed build log containing step executions.
101
+
102
+ Attributes:
103
+ build_id: Optional identifier for the build
104
+ steps: List of step executions from the log
105
+ total_duration: Total build duration in seconds
106
+ timestamp: When the build occurred
107
+ head_sha: Commit the build ran against, when known
108
+ run_attempt: Attempt number of the build (1 unless it was re-run)
109
+ conclusion: Overall build result as reported by the provider
110
+ metadata: Additional provider-specific data (jobs, queue times, ...)
111
+ """
112
+ build_id: str | None = None
113
+ steps: list[StepExecution] = field(default_factory=list)
114
+ total_duration: float | None = None
115
+ timestamp: datetime | None = None
116
+ head_sha: str | None = None
117
+ run_attempt: int = 1
118
+ conclusion: str | None = None
119
+ metadata: dict[str, Any] = field(default_factory=dict)
120
+
121
+ def validate(self) -> list[str]:
122
+ """Validate the build log and return list of validation errors."""
123
+ errors = []
124
+ if self.total_duration is not None and self.total_duration < 0:
125
+ errors.append("Total duration cannot be negative")
126
+ for step in self.steps:
127
+ step_errors = step.validate()
128
+ errors.extend(step_errors)
129
+ return errors
130
+
131
+
132
+ @dataclass
133
+ class StepStatistics:
134
+ """Aggregated statistics for a pipeline step across multiple builds.
135
+
136
+ Attributes:
137
+ name: The step identifier/name
138
+ execution_count: Total number of executions
139
+ success_count: Number of successful executions
140
+ failure_count: Number of failed executions
141
+ durations: List of recorded durations in seconds
142
+ p50: 50th percentile duration
143
+ p90: 90th percentile duration
144
+ p95: 95th percentile duration
145
+ p99: 99th percentile duration
146
+ is_regression: Whether the step's duration regressed
147
+ is_flaky: Whether the step is flagged as flaky
148
+ failure_rate: Ratio of failures to total executions
149
+ total_duration: Sum of all recorded durations, i.e. the time this step
150
+ has consumed across every build
151
+ mean_duration: Average recorded duration
152
+ runner: Runner the step ran on, when known
153
+ estimated_cost_usd: Cost attributed to this step, when the runner's
154
+ rate is known
155
+ regression: Details of the duration regression, when there is one
156
+ flaky_evidence: Why the step was flagged as flaky, when it was
157
+ """
158
+ name: str
159
+ execution_count: int = 0
160
+ success_count: int = 0
161
+ failure_count: int = 0
162
+ durations: list[float] = field(default_factory=list)
163
+ p50: float | None = None
164
+ p90: float | None = None
165
+ p95: float | None = None
166
+ p99: float | None = None
167
+ is_regression: bool = False
168
+ is_flaky: bool = False
169
+ failure_rate: float = 0.0
170
+ total_duration: float = 0.0
171
+ mean_duration: float | None = None
172
+ runner: str | None = None
173
+ estimated_cost_usd: float | None = None
174
+ regression: dict[str, Any] | None = None
175
+ flaky_evidence: dict[str, Any] | None = None
176
+
177
+ def validate(self) -> list[str]:
178
+ """Validate the statistics and return list of validation errors."""
179
+ errors = []
180
+ if not self.name or not self.name.strip():
181
+ errors.append("Step name cannot be empty")
182
+ if self.execution_count < 0:
183
+ errors.append("Execution count cannot be negative")
184
+ if self.success_count < 0:
185
+ errors.append("Success count cannot be negative")
186
+ if self.failure_count < 0:
187
+ errors.append("Failure count cannot be negative")
188
+ if self.success_count + self.failure_count > self.execution_count:
189
+ errors.append("Success + failure count cannot exceed execution count")
190
+ if not (0.0 <= self.failure_rate <= 1.0):
191
+ errors.append("Failure rate must be between 0 and 1")
192
+ for d in self.durations:
193
+ if d < 0:
194
+ errors.append("Duration values cannot be negative")
195
+ break
196
+ return errors
197
+
198
+
199
+ @dataclass
200
+ class AnalysisResult:
201
+ """Result of analyzing pipeline config or build logs.
202
+
203
+ Attributes:
204
+ mode: Analysis mode (config or log)
205
+ steps: List of step statistics (log mode) or pipeline steps (config mode)
206
+ total_estimated_duration: Sum of estimated durations for config mode
207
+ regressed_steps: Names of steps whose duration regressed
208
+ flaky_steps: Names of steps flagged as flaky
209
+ metadata: Additional analysis metadata
210
+ """
211
+ mode: Literal["config", "log"]
212
+ steps: list[StepStatistics] | list[PipelineStep] = field(default_factory=list)
213
+ total_estimated_duration: float | None = None
214
+ regressed_steps: list[str] = field(default_factory=list)
215
+ flaky_steps: list[str] = field(default_factory=list)
216
+ metadata: dict[str, Any] = field(default_factory=dict)
217
+
218
+ def validate(self) -> list[str]:
219
+ """Validate the analysis result and return list of validation errors."""
220
+ errors = []
221
+ if self.mode not in ("config", "log"):
222
+ errors.append("Mode must be 'config' or 'log'")
223
+ if self.total_estimated_duration is not None and self.total_estimated_duration < 0:
224
+ errors.append("Total estimated duration cannot be negative")
225
+ return errors
226
+
227
+
228
+ @dataclass
229
+ class Report:
230
+ """Final report generated from analysis results.
231
+
232
+ Attributes:
233
+ title: Report title
234
+ mode: Analysis mode that produced this report
235
+ generated_at: Timestamp when report was generated
236
+ summary: Summary statistics and counts
237
+ steps: List of step data dictionaries
238
+ issues: List of detected issues (regressions, flaky steps)
239
+ """
240
+ title: str
241
+ mode: str
242
+ generated_at: datetime
243
+ summary: dict[str, Any] = field(default_factory=dict)
244
+ steps: list[dict[str, Any]] = field(default_factory=list)
245
+ issues: list[dict[str, Any]] = field(default_factory=list)
246
+
247
+ def validate(self) -> list[str]:
248
+ """Validate the report and return list of validation errors."""
249
+ errors = []
250
+ if not self.title or not self.title.strip():
251
+ errors.append("Report title cannot be empty")
252
+ if not self.mode or not self.mode.strip():
253
+ errors.append("Report mode cannot be empty")
254
+ return errors
255
+
256
+ def to_dict(self) -> dict[str, Any]:
257
+ """Convert report to dictionary for JSON serialization."""
258
+ return {
259
+ "title": self.title,
260
+ "mode": self.mode,
261
+ "generated_at": self.generated_at.isoformat(),
262
+ "summary": self.summary,
263
+ "steps": self.steps,
264
+ "issues": self.issues,
265
+ }
266
+
267
+ @classmethod
268
+ def from_dict(cls, data: dict[str, Any]) -> "Report":
269
+ """Create Report from dictionary (JSON deserialization)."""
270
+ return cls(
271
+ title=data["title"],
272
+ mode=data["mode"],
273
+ generated_at=datetime.fromisoformat(data["generated_at"]),
274
+ summary=data.get("summary", {}),
275
+ steps=data.get("steps", []),
276
+ issues=data.get("issues", []),
277
+ )
@@ -0,0 +1,202 @@
1
+ """Runner pricing for cost estimates.
2
+
3
+ The defaults are GitHub's list prices for private repositories, as published at
4
+ PRICES_SOURCE_URL on PRICES_AS_OF. Public repositories pay nothing for
5
+ standard GitHub-hosted runners, and self-hosted runners are free. Prices
6
+ change, so they can be overridden with a JSON file (see load_pricing).
7
+
8
+ Each job's cost is its duration rounded up to a whole minute. GitHub has
9
+ billed that way; its current documentation does not state the rounding.
10
+ """
11
+
12
+ import json
13
+ import math
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ PRICES_AS_OF = "2026-10"
20
+ PRICES_SOURCE_URL = "https://docs.github.com/en/billing/reference/actions-runner-pricing"
21
+
22
+ # USD per minute for the labels that are not versioned (versioned labels such
23
+ # as ubuntu-24.04 or macos-15 are matched by _LABEL_FAMILIES below)
24
+ DEFAULT_PRICES_USD_PER_MINUTE: dict[str, float] = {
25
+ "ubuntu-latest": 0.006,
26
+ "ubuntu-slim": 0.002,
27
+ "windows-latest": 0.010,
28
+ "macos-latest": 0.062,
29
+ "macos-latest-large": 0.077,
30
+ "macos-latest-xlarge": 0.102,
31
+ }
32
+
33
+ # GitHub-hosted label families, most specific first, so that labels GitHub
34
+ # adds later (ubuntu-26.04, macos-26-intel, ...) are priced without an update
35
+ _LABEL_FAMILIES: list[tuple[re.Pattern[str], float]] = [
36
+ (re.compile(r"^ubuntu-(latest|\d+\.\d+)-arm$"), 0.005), # Linux 2-core arm64
37
+ (re.compile(r"^ubuntu-(latest|\d+\.\d+)$"), 0.006), # Linux 2-core x64
38
+ (re.compile(r"^windows-(latest|\d+)-arm$"), 0.010), # Windows 2-core arm64
39
+ (re.compile(r"^windows-(latest|\d{4})$"), 0.010), # Windows 2-core x64
40
+ (re.compile(r"^macos-(latest|\d+)-xlarge$"), 0.102), # macOS 5-core M2 Pro
41
+ (re.compile(r"^macos-(latest|\d+)-large$"), 0.077), # macOS 12-core Intel
42
+ (re.compile(r"^macos-(latest|\d+)(-intel)?$"), 0.062), # macOS 3/4-core
43
+ ]
44
+
45
+ # Larger runners are named freely (e.g. "ubuntu-24.04-8-cores"), so they are
46
+ # priced from the platform, architecture and core count in the label. The
47
+ # published rates do not scale linearly with cores, hence the tables; a core
48
+ # count that is not published stays unpriced rather than extrapolated.
49
+ _LARGER_RUNNER_PRICES: dict[tuple[str, str], dict[int, float]] = {
50
+ ("linux", "x64"): {2: 0.006, 4: 0.012, 8: 0.022, 16: 0.042, 32: 0.082, 64: 0.162, 96: 0.252},
51
+ ("windows", "x64"): {4: 0.022, 8: 0.042, 16: 0.082, 32: 0.162, 64: 0.322, 96: 0.552},
52
+ ("linux", "arm64"): {2: 0.005, 4: 0.008, 8: 0.014, 16: 0.026, 32: 0.050, 64: 0.098},
53
+ ("windows", "arm64"): {2: 0.008, 4: 0.014, 8: 0.026, 16: 0.050, 32: 0.098, 64: 0.194},
54
+ }
55
+
56
+ _CORES_PATTERN = re.compile(r"(\d+)[-_ ]?cores?\b", re.IGNORECASE)
57
+
58
+ # A self-hosted runner costs the user nothing on GitHub's bill.
59
+ SELF_HOSTED_LABELS = frozenset({"self-hosted", "selfhosted"})
60
+
61
+
62
+ @dataclass
63
+ class PricingTable:
64
+ """Maps runner labels to a USD-per-minute rate.
65
+
66
+ Attributes:
67
+ prices: Runner label (lowercased) to USD per minute; checked before
68
+ the label families and larger-runner tables, so a file can
69
+ override any of them
70
+ source: Where the rates came from, for the report
71
+ """
72
+
73
+ prices: dict[str, float] = field(
74
+ default_factory=lambda: dict(DEFAULT_PRICES_USD_PER_MINUTE)
75
+ )
76
+ source: str = f"GitHub list prices as of {PRICES_AS_OF}"
77
+ # Resolved rates by runner string: an analysis asks once per step
78
+ # execution, for a handful of distinct runners
79
+ _resolved: dict[str | None, float | None] = field(
80
+ default_factory=dict, init=False, repr=False, compare=False
81
+ )
82
+
83
+ def price_for(self, runner: str | None) -> float | None:
84
+ """Return the USD-per-minute rate for a runner, or None if unknown.
85
+
86
+ `runner` may be a comma-separated label list, as GitHub reports it.
87
+ Unknown runners return None rather than 0, so that an incomplete
88
+ estimate is never presented as a complete one.
89
+ """
90
+ if runner not in self._resolved:
91
+ self._resolved[runner] = self._resolve(runner)
92
+ return self._resolved[runner]
93
+
94
+ def _resolve(self, runner: str | None) -> float | None:
95
+ """Look up a runner's rate, without caching."""
96
+ if not runner:
97
+ return None
98
+
99
+ labels = [label.strip().lower() for label in runner.split(",") if label.strip()]
100
+ if not labels:
101
+ return None
102
+
103
+ # A self-hosted job is not billed, whatever else it is labelled with
104
+ if any(label in SELF_HOSTED_LABELS for label in labels):
105
+ return 0.0
106
+
107
+ for label in labels:
108
+ if label in self.prices:
109
+ return self.prices[label]
110
+
111
+ for label in labels:
112
+ for pattern, price in _LABEL_FAMILIES:
113
+ if pattern.match(label):
114
+ return price
115
+
116
+ for label in labels:
117
+ derived = _price_from_core_count(label)
118
+ if derived is not None:
119
+ return derived
120
+
121
+ return None
122
+
123
+
124
+ def _price_from_core_count(label: str) -> float | None:
125
+ """Price a larger runner from the platform and core count in its label."""
126
+ cores_match = _CORES_PATTERN.search(label)
127
+ if not cores_match:
128
+ return None
129
+
130
+ if "windows" in label:
131
+ platform = "windows"
132
+ elif "ubuntu" in label or "linux" in label:
133
+ platform = "linux"
134
+ else:
135
+ return None
136
+
137
+ architecture = "arm64" if "arm" in label else "x64"
138
+ return _LARGER_RUNNER_PRICES[(platform, architecture)].get(int(cores_match.group(1)))
139
+
140
+
141
+ def load_pricing(path: str | Path) -> PricingTable:
142
+ """Load a runner pricing table from a JSON file.
143
+
144
+ The file maps runner labels to USD per minute:
145
+
146
+ {"ubuntu-latest": 0.006, "my-big-runner": 0.032}
147
+
148
+ Args:
149
+ path: Path to the JSON file
150
+
151
+ Returns:
152
+ PricingTable with the file's rates taking precedence over the defaults
153
+
154
+ Raises:
155
+ ValueError: If the file is not a JSON object of non-negative numbers
156
+ OSError: If the file cannot be read
157
+ """
158
+ content = Path(path).read_text(encoding="utf-8")
159
+
160
+ try:
161
+ data: Any = json.loads(content)
162
+ except json.JSONDecodeError as error:
163
+ raise ValueError(f"Invalid JSON in pricing file: {error.msg}") from error
164
+
165
+ if not isinstance(data, dict):
166
+ raise ValueError("Pricing file must contain a JSON object of label -> USD per minute")
167
+
168
+ prices = dict(DEFAULT_PRICES_USD_PER_MINUTE)
169
+
170
+ for label, price in data.items():
171
+ if isinstance(price, bool) or not isinstance(price, (int, float)):
172
+ raise ValueError(f"Price for '{label}' must be a number, got {type(price).__name__}")
173
+ if price < 0:
174
+ raise ValueError(f"Price for '{label}' cannot be negative")
175
+ prices[str(label).strip().lower()] = float(price)
176
+
177
+ return PricingTable(prices=prices, source=str(path))
178
+
179
+
180
+ def job_cost(duration_seconds: float | None, price_per_minute: float | None) -> float | None:
181
+ """Cost of one job, its duration rounded up to a whole minute.
182
+
183
+ Returns None when either the duration or the rate is unknown.
184
+ """
185
+ if duration_seconds is None or price_per_minute is None:
186
+ return None
187
+ if duration_seconds <= 0:
188
+ return 0.0
189
+
190
+ return math.ceil(duration_seconds / 60.0) * price_per_minute
191
+
192
+
193
+ def attributed_cost(duration_seconds: float | None, price_per_minute: float | None) -> float | None:
194
+ """Cost attributed to a step, without per-job rounding.
195
+
196
+ Used to rank steps by spend. The sum over steps is therefore slightly
197
+ below the job total, which rounds each job up to a whole minute.
198
+ """
199
+ if duration_seconds is None or price_per_minute is None:
200
+ return None
201
+
202
+ return (duration_seconds / 60.0) * price_per_minute