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,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