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,728 @@
|
|
|
1
|
+
"""Log parser module for ci-time-tracker.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to parse CI/CD build logs in various formats
|
|
4
|
+
(text and JSON) and extract step timing information.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from ci_time_tracker.models import BuildLog, StepExecution
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LogParseError(Exception):
|
|
16
|
+
"""Exception raised when log parsing fails."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, position: int | None = None):
|
|
19
|
+
self.message = message
|
|
20
|
+
self.position = position
|
|
21
|
+
super().__init__(self._format_message())
|
|
22
|
+
|
|
23
|
+
def _format_message(self) -> str:
|
|
24
|
+
if self.position is not None:
|
|
25
|
+
return f"{self.message} (position: {self.position})"
|
|
26
|
+
return self.message
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def detect_format(content: str) -> Literal["text", "json"]:
|
|
30
|
+
"""Auto-detect the format of log content.
|
|
31
|
+
|
|
32
|
+
Attempts to determine if the content is JSON or plain text by checking
|
|
33
|
+
if it starts with JSON structural characters after stripping whitespace.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
content: The raw log content to analyze
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
"json" if content appears to be JSON, "text" otherwise
|
|
40
|
+
"""
|
|
41
|
+
stripped = content.strip()
|
|
42
|
+
|
|
43
|
+
if not stripped:
|
|
44
|
+
return "text"
|
|
45
|
+
|
|
46
|
+
# JSON logs typically start with { or [
|
|
47
|
+
if stripped.startswith('{') or stripped.startswith('['):
|
|
48
|
+
# Verify it's actually valid JSON
|
|
49
|
+
try:
|
|
50
|
+
json.loads(stripped)
|
|
51
|
+
return "json"
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
return "text"
|
|
54
|
+
|
|
55
|
+
return "text"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_log(content: str, format: Literal["text", "json"] | None = None) -> BuildLog:
|
|
59
|
+
"""Parse build log content and return structured data.
|
|
60
|
+
|
|
61
|
+
Main entry point for log parsing. Automatically detects format if not
|
|
62
|
+
specified, then delegates to the appropriate parser.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
content: The raw log content to parse
|
|
66
|
+
format: Optional format hint ("text" or "json"). If None, auto-detects.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
BuildLog containing extracted step executions and timing data
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
LogParseError: If the log cannot be parsed
|
|
73
|
+
"""
|
|
74
|
+
if format is None:
|
|
75
|
+
format = detect_format(content)
|
|
76
|
+
|
|
77
|
+
if format == "json":
|
|
78
|
+
return parse_json_log(content)
|
|
79
|
+
else:
|
|
80
|
+
return parse_text_log(content)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_text_log(content: str) -> BuildLog:
|
|
84
|
+
"""Parse plain text log with timestamp patterns.
|
|
85
|
+
|
|
86
|
+
Extracts step timing information from plain text CI logs by matching
|
|
87
|
+
common timestamp and step marker patterns. Supports GitHub Actions,
|
|
88
|
+
GitLab CI, and CircleCI log formats.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
content: Plain text log content
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
BuildLog with extracted step executions
|
|
95
|
+
"""
|
|
96
|
+
steps: list[StepExecution] = []
|
|
97
|
+
build_id: str | None = None
|
|
98
|
+
build_timestamp: datetime | None = None
|
|
99
|
+
total_duration: float | None = None
|
|
100
|
+
|
|
101
|
+
# Try different CI provider patterns
|
|
102
|
+
github_steps = _parse_github_actions_log(content)
|
|
103
|
+
if github_steps:
|
|
104
|
+
steps = github_steps
|
|
105
|
+
else:
|
|
106
|
+
gitlab_steps = _parse_gitlab_log(content)
|
|
107
|
+
if gitlab_steps:
|
|
108
|
+
steps = gitlab_steps
|
|
109
|
+
else:
|
|
110
|
+
circleci_steps = _parse_circleci_log(content)
|
|
111
|
+
if circleci_steps:
|
|
112
|
+
steps = circleci_steps
|
|
113
|
+
else:
|
|
114
|
+
# Fall back to generic timestamp parsing
|
|
115
|
+
steps = _parse_generic_log(content)
|
|
116
|
+
|
|
117
|
+
# Extract build ID if present
|
|
118
|
+
build_id_match = re.search(r'(?:build|run|job)[_\s#:]*(\d+)', content, re.IGNORECASE)
|
|
119
|
+
if build_id_match:
|
|
120
|
+
build_id = build_id_match.group(1)
|
|
121
|
+
|
|
122
|
+
# Extract build timestamp from first timestamp found
|
|
123
|
+
if steps and steps[0].start_time:
|
|
124
|
+
build_timestamp = steps[0].start_time
|
|
125
|
+
|
|
126
|
+
# Calculate total duration if we have step data
|
|
127
|
+
if steps:
|
|
128
|
+
durations = [s.duration_seconds for s in steps if s.duration_seconds is not None]
|
|
129
|
+
if durations:
|
|
130
|
+
total_duration = sum(durations)
|
|
131
|
+
|
|
132
|
+
return BuildLog(
|
|
133
|
+
build_id=build_id,
|
|
134
|
+
steps=steps,
|
|
135
|
+
total_duration=total_duration,
|
|
136
|
+
timestamp=build_timestamp
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# Timestamp patterns for various formats
|
|
141
|
+
_TIMESTAMP_PATTERNS = [
|
|
142
|
+
# ISO 8601: 2024-01-15T10:30:45Z or 2024-01-15T10:30:45.123Z
|
|
143
|
+
(r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?)', '%Y-%m-%dT%H:%M:%S'),
|
|
144
|
+
# Common log format: 2024-01-15 10:30:45
|
|
145
|
+
(r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})', '%Y-%m-%d %H:%M:%S'),
|
|
146
|
+
# GitHub Actions format: 2024-01-15T10:30:45.1234567Z
|
|
147
|
+
(r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\.\d+Z?', '%Y-%m-%dT%H:%M:%S'),
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_timestamp(text: str) -> datetime | None:
|
|
152
|
+
"""Extract and parse timestamp from text."""
|
|
153
|
+
for pattern, fmt in _TIMESTAMP_PATTERNS:
|
|
154
|
+
match = re.search(pattern, text)
|
|
155
|
+
if match:
|
|
156
|
+
try:
|
|
157
|
+
ts_str = match.group(1).replace('Z', '').strip()
|
|
158
|
+
return datetime.strptime(ts_str, fmt)
|
|
159
|
+
except ValueError:
|
|
160
|
+
continue
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_GHA_TIMESTAMP = r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?)'
|
|
165
|
+
|
|
166
|
+
# "##[group]<title>", optionally prefixed by the line timestamp
|
|
167
|
+
_GHA_GROUP_PATTERN = re.compile(
|
|
168
|
+
rf'^(?:{_GHA_TIMESTAMP}\s+)?##\[group\](.*?)[ \t\r]*$',
|
|
169
|
+
re.MULTILINE
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Lines the runner emits once the job's own steps have finished
|
|
173
|
+
_GHA_JOB_TEARDOWN_PATTERN = re.compile(
|
|
174
|
+
rf'^(?:{_GHA_TIMESTAMP}\s+)?(?:Post job cleanup\.|Cleaning up orphan processes)',
|
|
175
|
+
re.MULTILINE
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
_GHA_LINE_TIMESTAMP_PATTERN = re.compile(rf'^{_GHA_TIMESTAMP}\s', re.MULTILINE)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_gha_timestamp(value: str | None) -> datetime | None:
|
|
182
|
+
"""Parse a GitHub Actions timestamp, keeping sub-second precision."""
|
|
183
|
+
if not value:
|
|
184
|
+
return None
|
|
185
|
+
match = re.match(r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?', value)
|
|
186
|
+
if not match:
|
|
187
|
+
return None
|
|
188
|
+
timestamp = datetime.strptime(match.group(1), '%Y-%m-%dT%H:%M:%S')
|
|
189
|
+
if match.group(2):
|
|
190
|
+
# Runner logs use 7 fractional digits; datetime supports 6
|
|
191
|
+
timestamp += timedelta(microseconds=int(match.group(2)[:6].ljust(6, '0')))
|
|
192
|
+
return timestamp
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _parse_github_actions_log(content: str) -> list[StepExecution]:
|
|
196
|
+
"""Parse GitHub Actions log format.
|
|
197
|
+
|
|
198
|
+
Job logs prefix every line with a timestamp and open each step with a
|
|
199
|
+
``##[group]Run <name>`` header:
|
|
200
|
+
|
|
201
|
+
2024-01-15T10:30:45.1234567Z ##[group]Run npm test
|
|
202
|
+
2024-01-15T10:30:45.1234567Z npm test
|
|
203
|
+
2024-01-15T10:30:45.1234567Z ##[endgroup]
|
|
204
|
+
2024-01-15T10:32:10.0000000Z ...step output...
|
|
205
|
+
|
|
206
|
+
``##[endgroup]`` only closes the header block (the step's inputs), so a
|
|
207
|
+
step runs until the next step header or the job teardown, and the last
|
|
208
|
+
step until its final timestamped line. Groups without ``Run`` are nested
|
|
209
|
+
sections of the current step; if a log has no ``Run`` headers at all,
|
|
210
|
+
every group is treated as a step.
|
|
211
|
+
"""
|
|
212
|
+
steps: list[StepExecution] = []
|
|
213
|
+
|
|
214
|
+
groups = list(_GHA_GROUP_PATTERN.finditer(content))
|
|
215
|
+
headers = [m for m in groups if m.group(2).startswith('Run ')] or groups
|
|
216
|
+
boundaries = sorted(
|
|
217
|
+
headers + list(_GHA_JOB_TEARDOWN_PATTERN.finditer(content)),
|
|
218
|
+
key=lambda m: m.start()
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
for header in headers:
|
|
222
|
+
step_name = header.group(2)
|
|
223
|
+
if step_name.startswith('Run '):
|
|
224
|
+
step_name = step_name[len('Run '):]
|
|
225
|
+
step_name = step_name.strip()
|
|
226
|
+
|
|
227
|
+
# The step ends where the next step (or the job teardown) begins
|
|
228
|
+
boundary = next((b for b in boundaries if b.start() > header.start()), None)
|
|
229
|
+
step_content = content[header.end():boundary.start() if boundary else len(content)]
|
|
230
|
+
|
|
231
|
+
start_time = _parse_gha_timestamp(header.group(1))
|
|
232
|
+
end_time = _parse_gha_timestamp(boundary.group(1)) if boundary else None
|
|
233
|
+
if end_time is None:
|
|
234
|
+
line_timestamps = _GHA_LINE_TIMESTAMP_PATTERN.findall(step_content)
|
|
235
|
+
if line_timestamps:
|
|
236
|
+
end_time = _parse_gha_timestamp(line_timestamps[-1])
|
|
237
|
+
|
|
238
|
+
duration = None
|
|
239
|
+
if start_time and end_time:
|
|
240
|
+
duration = (end_time - start_time).total_seconds()
|
|
241
|
+
|
|
242
|
+
status: Literal["success", "failure", "skipped"] = "success"
|
|
243
|
+
exit_code_match = re.search(r'Process completed with exit code (\d+)', step_content)
|
|
244
|
+
if '##[error]' in step_content:
|
|
245
|
+
status = "failure"
|
|
246
|
+
elif exit_code_match and exit_code_match.group(1) != '0':
|
|
247
|
+
status = "failure"
|
|
248
|
+
|
|
249
|
+
# Check for retry indicator
|
|
250
|
+
is_retry = bool(re.search(r'retry|attempt\s*[2-9]', step_name, re.IGNORECASE))
|
|
251
|
+
|
|
252
|
+
steps.append(StepExecution(
|
|
253
|
+
name=step_name,
|
|
254
|
+
start_time=start_time,
|
|
255
|
+
end_time=end_time,
|
|
256
|
+
duration_seconds=duration,
|
|
257
|
+
status=status,
|
|
258
|
+
is_retry=is_retry
|
|
259
|
+
))
|
|
260
|
+
|
|
261
|
+
return steps
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _parse_gitlab_log(content: str) -> list[StepExecution]:
|
|
265
|
+
"""Parse GitLab CI log format.
|
|
266
|
+
|
|
267
|
+
GitLab logs have patterns like:
|
|
268
|
+
section_start:1234567890:step_name
|
|
269
|
+
section_end:1234567890:step_name
|
|
270
|
+
Or ANSI-colored output with timestamps.
|
|
271
|
+
"""
|
|
272
|
+
steps: list[StepExecution] = []
|
|
273
|
+
|
|
274
|
+
# Pattern for GitLab section markers. Names allow letters, digits, '_',
|
|
275
|
+
# '.' and '-'; options like "[collapsed=true]" and ANSI codes follow them.
|
|
276
|
+
section_start_pattern = re.compile(
|
|
277
|
+
r'section_start:(\d+):([\w.-]+)',
|
|
278
|
+
re.MULTILINE
|
|
279
|
+
)
|
|
280
|
+
section_end_pattern = re.compile(
|
|
281
|
+
r'section_end:(\d+):([\w.-]+)',
|
|
282
|
+
re.MULTILINE
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Failure evidence inside a section, and the runner's job-level verdict
|
|
286
|
+
step_failure_pattern = re.compile(r'exit code [1-9]|ERROR: Job failed')
|
|
287
|
+
job_failed_pattern = re.compile(r'ERROR: Job failed')
|
|
288
|
+
|
|
289
|
+
# Also try to match "Running with gitlab-runner" style logs
|
|
290
|
+
job_pattern = re.compile(
|
|
291
|
+
r'(?:Executing|Running)\s+"([^"]+)"',
|
|
292
|
+
re.MULTILINE
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
starts = {m.group(2): m for m in section_start_pattern.finditer(content)}
|
|
296
|
+
ends = {m.group(2): m for m in section_end_pattern.finditer(content)}
|
|
297
|
+
|
|
298
|
+
if starts:
|
|
299
|
+
for name, start_match in starts.items():
|
|
300
|
+
start_ts = int(start_match.group(1))
|
|
301
|
+
start_time = datetime.fromtimestamp(start_ts)
|
|
302
|
+
end_time = None
|
|
303
|
+
duration = None
|
|
304
|
+
section_end = len(content)
|
|
305
|
+
|
|
306
|
+
end_match = ends.get(name)
|
|
307
|
+
if end_match and end_match.start() > start_match.start():
|
|
308
|
+
end_ts = int(end_match.group(1))
|
|
309
|
+
end_time = datetime.fromtimestamp(end_ts)
|
|
310
|
+
duration = float(end_ts - start_ts)
|
|
311
|
+
section_end = end_match.start()
|
|
312
|
+
|
|
313
|
+
# Check for failure within the section's own output
|
|
314
|
+
status: Literal["success", "failure", "skipped"] = "success"
|
|
315
|
+
if step_failure_pattern.search(content[start_match.end():section_end]):
|
|
316
|
+
status = "failure"
|
|
317
|
+
|
|
318
|
+
is_retry = bool(re.search(r'retry|attempt\s*[2-9]', name, re.IGNORECASE))
|
|
319
|
+
|
|
320
|
+
steps.append(StepExecution(
|
|
321
|
+
name=name,
|
|
322
|
+
start_time=start_time,
|
|
323
|
+
end_time=end_time,
|
|
324
|
+
duration_seconds=duration,
|
|
325
|
+
status=status,
|
|
326
|
+
is_retry=is_retry
|
|
327
|
+
))
|
|
328
|
+
|
|
329
|
+
# The runner reports a failed job after all sections have closed;
|
|
330
|
+
# attribute it to the job script when no section showed the failure.
|
|
331
|
+
if job_failed_pattern.search(content) and not any(s.status == "failure" for s in steps):
|
|
332
|
+
culprit = next((s for s in steps if s.name == "step_script"), steps[-1])
|
|
333
|
+
culprit.status = "failure"
|
|
334
|
+
else:
|
|
335
|
+
# Try job pattern
|
|
336
|
+
for match in job_pattern.finditer(content):
|
|
337
|
+
steps.append(StepExecution(
|
|
338
|
+
name=match.group(1),
|
|
339
|
+
status="success",
|
|
340
|
+
is_retry=False
|
|
341
|
+
))
|
|
342
|
+
|
|
343
|
+
return steps
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _parse_circleci_log(content: str) -> list[StepExecution]:
|
|
347
|
+
"""Parse CircleCI log format.
|
|
348
|
+
|
|
349
|
+
CircleCI logs have patterns like:
|
|
350
|
+
#!/bin/bash -eo pipefail
|
|
351
|
+
step: Step Name
|
|
352
|
+
Or timing output like:
|
|
353
|
+
real 0m5.123s
|
|
354
|
+
"""
|
|
355
|
+
steps: list[StepExecution] = []
|
|
356
|
+
|
|
357
|
+
# Pattern for CircleCI step markers
|
|
358
|
+
step_pattern = re.compile(
|
|
359
|
+
r'(?:^|\n)\s*(?:step:|Running step:)\s*(.+?)(?:\s*$)',
|
|
360
|
+
re.MULTILINE | re.IGNORECASE
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Pattern for timing output
|
|
364
|
+
timing_pattern = re.compile(
|
|
365
|
+
r'real\s+(\d+)m([\d.]+)s',
|
|
366
|
+
re.MULTILINE
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
step_matches = list(step_pattern.finditer(content))
|
|
370
|
+
|
|
371
|
+
if step_matches:
|
|
372
|
+
timing_matches = list(timing_pattern.finditer(content))
|
|
373
|
+
|
|
374
|
+
for i, match in enumerate(step_matches):
|
|
375
|
+
step_name = match.group(1).strip()
|
|
376
|
+
duration = None
|
|
377
|
+
|
|
378
|
+
# Try to find corresponding timing
|
|
379
|
+
if i < len(timing_matches):
|
|
380
|
+
minutes = int(timing_matches[i].group(1))
|
|
381
|
+
seconds = float(timing_matches[i].group(2))
|
|
382
|
+
duration = minutes * 60 + seconds
|
|
383
|
+
|
|
384
|
+
# Check for failure
|
|
385
|
+
status: Literal["success", "failure", "skipped"] = "success"
|
|
386
|
+
step_end = step_matches[i + 1].start() if i + 1 < len(step_matches) else len(content)
|
|
387
|
+
step_content = content[match.end():step_end]
|
|
388
|
+
|
|
389
|
+
if re.search(r'(?:failed|error|exit code [1-9]|Exited with code [1-9])', step_content, re.IGNORECASE):
|
|
390
|
+
status = "failure"
|
|
391
|
+
|
|
392
|
+
is_retry = bool(re.search(r'retry|attempt\s*[2-9]', step_name, re.IGNORECASE))
|
|
393
|
+
|
|
394
|
+
steps.append(StepExecution(
|
|
395
|
+
name=step_name,
|
|
396
|
+
duration_seconds=duration,
|
|
397
|
+
status=status,
|
|
398
|
+
is_retry=is_retry
|
|
399
|
+
))
|
|
400
|
+
|
|
401
|
+
return steps
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _parse_generic_log(content: str) -> list[StepExecution]:
|
|
405
|
+
"""Parse generic log format with timestamps.
|
|
406
|
+
|
|
407
|
+
Attempts to extract step information from logs that don't match
|
|
408
|
+
specific CI provider formats.
|
|
409
|
+
"""
|
|
410
|
+
steps: list[StepExecution] = []
|
|
411
|
+
|
|
412
|
+
# Generic patterns for step markers
|
|
413
|
+
patterns = [
|
|
414
|
+
# "Step: name" or "Step 1: name" - match after timestamp if present
|
|
415
|
+
re.compile(r'(?:^|\n)(?:[\d\-T:.\sZ]+)?\s*[Ss]tep(?:\s*\d*)?[:\s]+(.+?)(?:\s*$)', re.MULTILINE),
|
|
416
|
+
# "Running: name" or "Executing: name"
|
|
417
|
+
re.compile(r'(?:^|\n)(?:[\d\-T:.\sZ]+)?\s*(?:running|executing)[:\s]+(.+?)(?:\s*$)', re.IGNORECASE | re.MULTILINE),
|
|
418
|
+
# "[timestamp] Starting: name"
|
|
419
|
+
re.compile(r'(?:^|\n)(?:[\d\-T:.\sZ]+)?\s*(?:starting|begin)[:\s]+(.+?)(?:\s*$)', re.IGNORECASE | re.MULTILINE),
|
|
420
|
+
]
|
|
421
|
+
|
|
422
|
+
for pattern in patterns:
|
|
423
|
+
matches = list(pattern.finditer(content))
|
|
424
|
+
if matches:
|
|
425
|
+
for match in matches:
|
|
426
|
+
step_name = match.group(1).strip()
|
|
427
|
+
if step_name and len(step_name) < 200: # Sanity check
|
|
428
|
+
# Try to extract timestamp near this match
|
|
429
|
+
context_start = max(0, match.start() - 50)
|
|
430
|
+
context = content[context_start:match.end()]
|
|
431
|
+
start_time = _parse_timestamp(context)
|
|
432
|
+
|
|
433
|
+
steps.append(StepExecution(
|
|
434
|
+
name=step_name,
|
|
435
|
+
start_time=start_time,
|
|
436
|
+
status="success",
|
|
437
|
+
is_retry=bool(re.search(r'retry|attempt\s*[2-9]', step_name, re.IGNORECASE))
|
|
438
|
+
))
|
|
439
|
+
break # Use first pattern that matches
|
|
440
|
+
|
|
441
|
+
return steps
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def parse_json_log(content: str) -> BuildLog:
|
|
445
|
+
"""Parse JSON-formatted log.
|
|
446
|
+
|
|
447
|
+
Extracts timing data from structured JSON log fields. Supports various
|
|
448
|
+
JSON log formats including:
|
|
449
|
+
- Array of step objects with name, start_time, end_time, status
|
|
450
|
+
- Object with steps/jobs array
|
|
451
|
+
- GitHub Actions JSON format
|
|
452
|
+
- GitLab CI JSON format
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
content: JSON log content
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
BuildLog with extracted step executions
|
|
459
|
+
|
|
460
|
+
Raises:
|
|
461
|
+
LogParseError: If JSON is invalid or missing required fields
|
|
462
|
+
"""
|
|
463
|
+
try:
|
|
464
|
+
data = json.loads(content)
|
|
465
|
+
except json.JSONDecodeError as e:
|
|
466
|
+
raise LogParseError(f"Invalid JSON syntax: {e.msg}", e.pos) from e
|
|
467
|
+
|
|
468
|
+
steps: list[StepExecution] = []
|
|
469
|
+
build_id: str | None = None
|
|
470
|
+
build_timestamp: datetime | None = None
|
|
471
|
+
total_duration: float | None = None
|
|
472
|
+
|
|
473
|
+
# Handle different JSON structures
|
|
474
|
+
if isinstance(data, list):
|
|
475
|
+
# Array of step objects
|
|
476
|
+
steps = _parse_json_steps_array(data)
|
|
477
|
+
elif isinstance(data, dict):
|
|
478
|
+
# Extract build metadata
|
|
479
|
+
build_id = _extract_build_id(data)
|
|
480
|
+
build_timestamp = _extract_timestamp(data)
|
|
481
|
+
total_duration = _extract_total_duration(data)
|
|
482
|
+
|
|
483
|
+
# Find steps in various locations
|
|
484
|
+
steps = _extract_steps_from_dict(data)
|
|
485
|
+
|
|
486
|
+
# Calculate total duration if not provided
|
|
487
|
+
if total_duration is None and steps:
|
|
488
|
+
durations = [s.duration_seconds for s in steps if s.duration_seconds is not None]
|
|
489
|
+
if durations:
|
|
490
|
+
total_duration = sum(durations)
|
|
491
|
+
|
|
492
|
+
# Extract build timestamp from first step if not found
|
|
493
|
+
if build_timestamp is None and steps and steps[0].start_time:
|
|
494
|
+
build_timestamp = steps[0].start_time
|
|
495
|
+
|
|
496
|
+
return BuildLog(
|
|
497
|
+
build_id=build_id,
|
|
498
|
+
steps=steps,
|
|
499
|
+
total_duration=total_duration,
|
|
500
|
+
timestamp=build_timestamp
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _parse_json_steps_array(data: list) -> list[StepExecution]:
|
|
505
|
+
"""Parse an array of step objects."""
|
|
506
|
+
steps: list[StepExecution] = []
|
|
507
|
+
|
|
508
|
+
for item in data:
|
|
509
|
+
if isinstance(item, dict):
|
|
510
|
+
step = _parse_json_step(item)
|
|
511
|
+
if step:
|
|
512
|
+
steps.append(step)
|
|
513
|
+
|
|
514
|
+
return steps
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _first_present(data: dict, *keys: str) -> Any:
|
|
518
|
+
"""Return the value of the first key holding a non-null, non-empty value.
|
|
519
|
+
|
|
520
|
+
Unlike chaining ``data.get(a) or data.get(b)``, this keeps falsy but
|
|
521
|
+
meaningful values such as ``0``.
|
|
522
|
+
"""
|
|
523
|
+
for key in keys:
|
|
524
|
+
value = data.get(key)
|
|
525
|
+
if value is not None and value != "":
|
|
526
|
+
return value
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _as_number(value: Any) -> float | None:
|
|
531
|
+
"""Convert a JSON value to a float, or None if it is not numeric."""
|
|
532
|
+
if isinstance(value, bool):
|
|
533
|
+
return None
|
|
534
|
+
try:
|
|
535
|
+
return float(value)
|
|
536
|
+
except (ValueError, TypeError):
|
|
537
|
+
return None
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _parse_json_step(data: dict) -> StepExecution | None:
|
|
541
|
+
"""Parse a single step object from JSON."""
|
|
542
|
+
# Try various field names for step name
|
|
543
|
+
name = _first_present(
|
|
544
|
+
data, "name", "step_name", "stepName", "step", "job", "job_name", "jobName"
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
if name is None:
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
# Parse timestamps
|
|
551
|
+
start_time = _parse_json_timestamp(_first_present(
|
|
552
|
+
data, "start_time", "startTime", "started_at", "startedAt", "start"
|
|
553
|
+
))
|
|
554
|
+
|
|
555
|
+
end_time = _parse_json_timestamp(_first_present(
|
|
556
|
+
data, "end_time", "endTime", "finished_at", "finishedAt", "end",
|
|
557
|
+
"completed_at", "completedAt"
|
|
558
|
+
))
|
|
559
|
+
|
|
560
|
+
# Parse duration
|
|
561
|
+
duration = _parse_json_duration(data)
|
|
562
|
+
|
|
563
|
+
# Calculate duration from timestamps if not provided
|
|
564
|
+
if duration is None and start_time and end_time:
|
|
565
|
+
duration = (end_time - start_time).total_seconds()
|
|
566
|
+
|
|
567
|
+
# Parse status
|
|
568
|
+
status = _parse_json_status(data)
|
|
569
|
+
|
|
570
|
+
# Check for retry
|
|
571
|
+
attempt = _as_number(data.get("attempt"))
|
|
572
|
+
retry_count = _as_number(data.get("retry_count"))
|
|
573
|
+
is_retry = (
|
|
574
|
+
any(bool(data.get(key)) for key in ("is_retry", "isRetry", "retry")) or
|
|
575
|
+
(attempt is not None and attempt > 1) or
|
|
576
|
+
(retry_count is not None and retry_count > 0)
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
return StepExecution(
|
|
580
|
+
name=str(name),
|
|
581
|
+
start_time=start_time,
|
|
582
|
+
end_time=end_time,
|
|
583
|
+
duration_seconds=duration,
|
|
584
|
+
status=status,
|
|
585
|
+
is_retry=is_retry
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _parse_json_timestamp(value: str | int | float | None) -> datetime | None:
|
|
590
|
+
"""Parse a timestamp from various JSON formats."""
|
|
591
|
+
if value is None:
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
if isinstance(value, (int, float)):
|
|
595
|
+
# Unix timestamp
|
|
596
|
+
try:
|
|
597
|
+
return datetime.fromtimestamp(value)
|
|
598
|
+
except (ValueError, OSError):
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
if isinstance(value, str):
|
|
602
|
+
# Try ISO format first
|
|
603
|
+
try:
|
|
604
|
+
# Handle Z suffix
|
|
605
|
+
clean_value = value.replace('Z', '+00:00')
|
|
606
|
+
return datetime.fromisoformat(clean_value)
|
|
607
|
+
except ValueError:
|
|
608
|
+
pass
|
|
609
|
+
|
|
610
|
+
# Try common formats
|
|
611
|
+
formats = [
|
|
612
|
+
'%Y-%m-%dT%H:%M:%S',
|
|
613
|
+
'%Y-%m-%dT%H:%M:%S.%f',
|
|
614
|
+
'%Y-%m-%d %H:%M:%S',
|
|
615
|
+
'%Y-%m-%d %H:%M:%S.%f',
|
|
616
|
+
]
|
|
617
|
+
for fmt in formats:
|
|
618
|
+
try:
|
|
619
|
+
return datetime.strptime(value.split('+')[0].split('Z')[0], fmt)
|
|
620
|
+
except ValueError:
|
|
621
|
+
continue
|
|
622
|
+
|
|
623
|
+
return None
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
# Duration field names, in lookup order, with the divisor that converts them to seconds
|
|
627
|
+
_DURATION_FIELDS = (
|
|
628
|
+
("duration", 1.0),
|
|
629
|
+
("duration_seconds", 1.0),
|
|
630
|
+
("durationSeconds", 1.0),
|
|
631
|
+
("duration_ms", 1000.0),
|
|
632
|
+
("durationMs", 1000.0),
|
|
633
|
+
("elapsed", 1.0),
|
|
634
|
+
("elapsed_time", 1.0),
|
|
635
|
+
("elapsedTime", 1.0),
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _parse_json_duration(data: dict) -> float | None:
|
|
640
|
+
"""Extract duration in seconds from JSON step data."""
|
|
641
|
+
for key, divisor in _DURATION_FIELDS:
|
|
642
|
+
if _first_present(data, key) is None:
|
|
643
|
+
continue
|
|
644
|
+
duration = _as_number(data[key])
|
|
645
|
+
return duration / divisor if duration is not None else None
|
|
646
|
+
|
|
647
|
+
return None
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _parse_json_status(data: dict) -> Literal["success", "failure", "skipped"]:
|
|
651
|
+
"""Extract status from JSON step data."""
|
|
652
|
+
status_value = _first_present(data, "status", "conclusion", "result", "state")
|
|
653
|
+
|
|
654
|
+
if status_value is None:
|
|
655
|
+
return "success"
|
|
656
|
+
|
|
657
|
+
status_str = str(status_value).lower()
|
|
658
|
+
|
|
659
|
+
if status_str in ("success", "passed", "completed", "ok", "0"):
|
|
660
|
+
return "success"
|
|
661
|
+
elif status_str in ("failure", "failed", "error", "errored", "1"):
|
|
662
|
+
return "failure"
|
|
663
|
+
elif status_str in ("skipped", "skip", "cancelled", "canceled", "pending", "neutral"):
|
|
664
|
+
return "skipped"
|
|
665
|
+
|
|
666
|
+
return "success"
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _extract_build_id(data: dict) -> str | None:
|
|
670
|
+
"""Extract build ID from JSON data."""
|
|
671
|
+
build_id = _first_present(
|
|
672
|
+
data, "build_id", "buildId", "id", "run_id", "runId", "job_id", "jobId"
|
|
673
|
+
)
|
|
674
|
+
return str(build_id) if build_id is not None else None
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _extract_timestamp(data: dict) -> datetime | None:
|
|
678
|
+
"""Extract build timestamp from JSON data."""
|
|
679
|
+
timestamp = _first_present(
|
|
680
|
+
data, "timestamp", "created_at", "createdAt", "started_at", "startedAt"
|
|
681
|
+
)
|
|
682
|
+
return _parse_json_timestamp(timestamp)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _extract_total_duration(data: dict) -> float | None:
|
|
686
|
+
"""Extract total duration from JSON data."""
|
|
687
|
+
duration = _first_present(data, "total_duration", "totalDuration", "duration", "elapsed")
|
|
688
|
+
return _as_number(duration) if duration is not None else None
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _extract_steps_from_dict(data: dict) -> list[StepExecution]:
|
|
692
|
+
"""Extract steps from various JSON object structures."""
|
|
693
|
+
steps: list[StepExecution] = []
|
|
694
|
+
|
|
695
|
+
# Try common field names for steps array
|
|
696
|
+
steps_data = (
|
|
697
|
+
data.get("steps") or
|
|
698
|
+
data.get("jobs") or
|
|
699
|
+
data.get("stages") or
|
|
700
|
+
data.get("tasks") or
|
|
701
|
+
data.get("workflow_steps") or
|
|
702
|
+
data.get("workflowSteps")
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
if isinstance(steps_data, list):
|
|
706
|
+
steps = _parse_json_steps_array(steps_data)
|
|
707
|
+
elif isinstance(steps_data, dict):
|
|
708
|
+
# Steps might be a dict with step names as keys
|
|
709
|
+
for name, step_data in steps_data.items():
|
|
710
|
+
if isinstance(step_data, dict):
|
|
711
|
+
step_data_with_name = {**step_data, "name": name}
|
|
712
|
+
step = _parse_json_step(step_data_with_name)
|
|
713
|
+
if step:
|
|
714
|
+
steps.append(step)
|
|
715
|
+
|
|
716
|
+
# If no steps found, check for nested job structures (GitHub Actions style)
|
|
717
|
+
if not steps and "jobs" in data and isinstance(data["jobs"], dict):
|
|
718
|
+
for job_data in data["jobs"].values():
|
|
719
|
+
if isinstance(job_data, dict):
|
|
720
|
+
job_steps = job_data.get("steps", [])
|
|
721
|
+
if isinstance(job_steps, list):
|
|
722
|
+
for step_data in job_steps:
|
|
723
|
+
if isinstance(step_data, dict):
|
|
724
|
+
step = _parse_json_step(step_data)
|
|
725
|
+
if step:
|
|
726
|
+
steps.append(step)
|
|
727
|
+
|
|
728
|
+
return steps
|