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,577 @@
1
+ """Reporter module for ci-time-tracker.
2
+
3
+ This module provides functionality to generate and format reports from
4
+ analysis results, supporting both human-readable text and JSON outputs.
5
+ """
6
+
7
+ import json
8
+ from datetime import datetime
9
+ from typing import Any
10
+
11
+ from ci_time_tracker.models import (
12
+ AnalysisResult,
13
+ PipelineStep,
14
+ Report,
15
+ StepStatistics,
16
+ )
17
+
18
+
19
+ def generate_report(result: AnalysisResult, title: str | None = None) -> Report:
20
+ """Generate a Report from an AnalysisResult.
21
+
22
+ Args:
23
+ result: The analysis result to convert into a report
24
+ title: Optional custom title for the report
25
+
26
+ Returns:
27
+ Report object ready for formatting and output
28
+ """
29
+ if title is None:
30
+ title = f"CI Time Tracker Report ({result.mode} mode)"
31
+
32
+ # Build summary
33
+ summary: dict[str, Any] = {
34
+ "mode": result.mode,
35
+ **result.metadata,
36
+ }
37
+
38
+ if result.total_estimated_duration is not None:
39
+ summary["total_estimated_duration"] = result.total_estimated_duration
40
+
41
+ # Build steps list
42
+ steps_data: list[dict[str, Any]] = []
43
+
44
+ for step in result.steps:
45
+ if isinstance(step, StepStatistics):
46
+ # Log mode - include statistics
47
+ step_dict = {
48
+ "name": step.name,
49
+ "execution_count": step.execution_count,
50
+ "success_count": step.success_count,
51
+ "failure_count": step.failure_count,
52
+ "failure_rate": step.failure_rate,
53
+ "total_duration": step.total_duration,
54
+ "mean_duration": step.mean_duration,
55
+ "p50": step.p50,
56
+ "p90": step.p90,
57
+ "p95": step.p95,
58
+ "p99": step.p99,
59
+ "is_regression": step.is_regression,
60
+ "is_flaky": step.is_flaky,
61
+ "runner": step.runner,
62
+ "estimated_cost_usd": step.estimated_cost_usd,
63
+ "regression": step.regression,
64
+ "flaky_evidence": step.flaky_evidence,
65
+ }
66
+ elif isinstance(step, PipelineStep):
67
+ # Config mode - include step info
68
+ step_dict = {
69
+ "name": step.name,
70
+ "stage": step.stage,
71
+ "estimated_duration": step.estimated_duration,
72
+ }
73
+ if step.command:
74
+ step_dict["command"] = step.command
75
+ else:
76
+ # Fallback for unknown types
77
+ step_dict = {"name": str(step)}
78
+
79
+ steps_data.append(step_dict)
80
+
81
+ # Order log-mode steps by the time they consume, so the biggest
82
+ # contributors come first in every output format.
83
+ if result.mode == "log":
84
+ steps_data.sort(key=lambda step: step.get("total_duration") or 0.0, reverse=True)
85
+
86
+ # Build issues list
87
+ issues: list[dict[str, Any]] = []
88
+
89
+ for regressed_step in result.regressed_steps:
90
+ # Find the corresponding step to get more details
91
+ step_info = next((s for s in result.steps if s.name == regressed_step), None)
92
+
93
+ issue = {
94
+ "type": "regression",
95
+ "step": regressed_step,
96
+ "severity": "warning",
97
+ }
98
+
99
+ if step_info and isinstance(step_info, StepStatistics) and step_info.regression:
100
+ issue.update(step_info.regression)
101
+
102
+ issues.append(issue)
103
+
104
+ for flaky_step in result.flaky_steps:
105
+ # Find the corresponding step to get more details
106
+ step_info = next((s for s in result.steps if s.name == flaky_step), None)
107
+
108
+ issue = {
109
+ "type": "flaky",
110
+ "step": flaky_step,
111
+ "severity": "error",
112
+ }
113
+
114
+ if step_info and isinstance(step_info, StepStatistics):
115
+ issue["failure_rate"] = step_info.failure_rate
116
+ issue["failure_count"] = step_info.failure_count
117
+ issue["execution_count"] = step_info.execution_count
118
+ if step_info.flaky_evidence:
119
+ issue.update(step_info.flaky_evidence)
120
+
121
+ issues.append(issue)
122
+
123
+ return Report(
124
+ title=title,
125
+ mode=result.mode,
126
+ generated_at=datetime.now(),
127
+ summary=summary,
128
+ steps=steps_data,
129
+ issues=issues,
130
+ )
131
+
132
+
133
+ def format_json(report: Report) -> str:
134
+ """Format a Report as JSON.
135
+
136
+ Args:
137
+ report: The report to format
138
+
139
+ Returns:
140
+ JSON string representation of the report
141
+ """
142
+ return json.dumps(report.to_dict(), indent=2)
143
+
144
+
145
+ # Report width per mode: the log table carries more columns than the config one
146
+ _LOG_WIDTH = 110
147
+ _CONFIG_WIDTH = 80
148
+
149
+ # How many steps the "top time consumers" ranking shows
150
+ _TOP_CONSUMERS = 5
151
+
152
+
153
+ def format_duration(seconds: float | None) -> str:
154
+ """Format a duration in seconds for humans (45.0s, 12m 30s, 3h 05m)."""
155
+ if seconds is None:
156
+ return "N/A"
157
+
158
+ seconds = float(seconds)
159
+ if seconds < 60:
160
+ return f"{seconds:.1f}s"
161
+
162
+ minutes, remainder = divmod(int(round(seconds)), 60)
163
+ if minutes < 60:
164
+ return f"{minutes}m {remainder:02d}s"
165
+
166
+ hours, minutes = divmod(minutes, 60)
167
+ return f"{hours}h {minutes:02d}m"
168
+
169
+
170
+ def format_cost(usd: float | None) -> str:
171
+ """Format a cost in US dollars, or N/A when it could not be estimated."""
172
+ if usd is None:
173
+ return "N/A"
174
+ return f"${usd:,.2f}"
175
+
176
+
177
+ def cost_coverage(summary: dict[str, Any]) -> str | None:
178
+ """Explain a partial cost estimate, or None when every job was priced.
179
+
180
+ Third-party and self-named runners have no known rate, and leaving them
181
+ out silently would present part of the cost as all of it.
182
+ """
183
+ jobs = summary.get("job_count") or 0
184
+ priced = summary.get("priced_jobs") or 0
185
+ if summary.get("estimated_cost_usd") is None or not jobs or priced >= jobs:
186
+ return None
187
+ return (
188
+ f"cost covers only {priced} of {jobs} jobs; the rest ran on runners with "
189
+ "no known rate (see unpriced_runners, or add them with --pricing)"
190
+ )
191
+
192
+
193
+ def _format_summary_value(key: str, value: Any) -> str:
194
+ """Format a summary value according to what the key measures."""
195
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
196
+ if key.endswith(("_seconds", "_duration")):
197
+ return format_duration(value)
198
+ if key.endswith("_usd"):
199
+ return format_cost(value)
200
+
201
+ if isinstance(value, list):
202
+ return ", ".join(str(item) for item in value)
203
+
204
+ return str(value)
205
+
206
+
207
+ def _rank_consumers(steps: list[dict[str, Any]]) -> list[tuple[dict[str, Any], str]]:
208
+ """The steps that consume the most time, each with its share of the total.
209
+
210
+ Percentiles say how long a step takes; this says where the time actually
211
+ goes, which is what a CI budget is spent on.
212
+ """
213
+ consumers = sorted(
214
+ (step for step in steps if step.get("total_duration")),
215
+ key=lambda step: step["total_duration"],
216
+ reverse=True,
217
+ )[:_TOP_CONSUMERS]
218
+
219
+ total = sum(step.get("total_duration") or 0.0 for step in steps)
220
+
221
+ return [
222
+ (step, f"{step['total_duration'] / total * 100:.1f}%" if total else "N/A")
223
+ for step in consumers
224
+ ]
225
+
226
+
227
+ def _format_top_consumers(steps: list[dict[str, Any]], width: int) -> list[str]:
228
+ """Text section ranking the steps that consume the most time."""
229
+ ranked = _rank_consumers(steps)
230
+
231
+ if not ranked:
232
+ return []
233
+
234
+ lines = ["TOP TIME CONSUMERS", "-" * width]
235
+
236
+ for rank, (step, share) in enumerate(ranked, start=1):
237
+ cost = step.get("estimated_cost_usd")
238
+ cost_str = format_cost(cost) if cost is not None else ""
239
+
240
+ lines.append(
241
+ f" {rank}. {step['name'][:44]:<44} {format_duration(step['total_duration']):>9} "
242
+ f"{share:>7} {step.get('execution_count', 0):>5} runs {cost_str:>9}"
243
+ )
244
+
245
+ lines.append("")
246
+ return lines
247
+
248
+
249
+ def _describe_origin(started: dict[str, Any]) -> str:
250
+ """Describe where a regression started: commit, build and date."""
251
+ commit = started.get("head_sha")
252
+ context = [
253
+ f"build {started['build_id']}" if started.get("build_id") else None,
254
+ str(started["timestamp"])[:10] if started.get("timestamp") else None,
255
+ ]
256
+ context_str = ", ".join(part for part in context if part)
257
+
258
+ if commit:
259
+ return f"commit {commit} ({context_str})" if context_str else f"commit {commit}"
260
+ return context_str
261
+
262
+
263
+ def _format_issue_details(issue: dict[str, Any]) -> list[str]:
264
+ """Indented detail lines explaining why an issue was raised."""
265
+ details: list[str] = []
266
+
267
+ if issue["type"] == "regression":
268
+ if "baseline_median" in issue and "current_median" in issue:
269
+ pct = issue.get("increase_pct")
270
+ pct_str = f"+{pct * 100:.0f}%, " if pct is not None else ""
271
+ details.append(
272
+ f" Median {format_duration(issue['baseline_median'])} -> "
273
+ f"{format_duration(issue['current_median'])} "
274
+ f"({pct_str}+{format_duration(issue.get('increase_seconds'))})"
275
+ )
276
+
277
+ origin = _describe_origin(issue.get("started_at") or {})
278
+ if origin:
279
+ details.append(f" Started at {origin}")
280
+
281
+ elif issue["type"] == "flaky":
282
+ if issue.get("method") == "same-commit":
283
+ details.append(
284
+ f" Failed and passed on the same commit in {issue.get('flaky_commits', 0)} "
285
+ f"of {issue.get('observed_commits', 0)} commits"
286
+ )
287
+ if issue.get("rerun_recoveries"):
288
+ details.append(f" Passed on re-run after failing in {issue['rerun_recoveries']} run(s)")
289
+ if issue.get("example_commits"):
290
+ details.append(f" e.g. {', '.join(issue['example_commits'])}")
291
+
292
+ elif "failure_rate" in issue:
293
+ failure_rate = issue["failure_rate"] * 100
294
+ failure_count = issue.get("failure_count", 0)
295
+ execution_count = issue.get("execution_count", 0)
296
+ details.append(
297
+ f" Failure rate: {failure_rate:.1f}% "
298
+ f"({failure_count}/{execution_count} executions)"
299
+ )
300
+ if issue.get("method") == "failure-rate":
301
+ details.append(
302
+ " Based on failure rate only (no commit data), which cannot "
303
+ "tell flakiness from real breakage"
304
+ )
305
+
306
+ return details
307
+
308
+
309
+ def format_text(report: Report, top: int | None = None) -> str:
310
+ """Format a Report as human-readable text.
311
+
312
+ Args:
313
+ report: The report to format
314
+ top: Show only the N steps that consume the most time; None or 0
315
+ shows every step. Percentiles alone make a long table hard to
316
+ read on a matrix build, where each leg is its own step.
317
+
318
+ Returns:
319
+ Formatted text string ready for console output
320
+ """
321
+ lines: list[str] = []
322
+ width = _LOG_WIDTH if report.mode == "log" else _CONFIG_WIDTH
323
+
324
+ # Header
325
+ lines.append("=" * width)
326
+ lines.append(report.title)
327
+ lines.append("=" * width)
328
+ lines.append(f"Generated: {report.generated_at.strftime('%Y-%m-%d %H:%M:%S')}")
329
+ lines.append(f"Mode: {report.mode}")
330
+ lines.append("")
331
+
332
+ # Summary
333
+ lines.append("SUMMARY")
334
+ lines.append("-" * width)
335
+ for key, value in report.summary.items():
336
+ if key == "mode":
337
+ continue # Already displayed
338
+ lines.append(f" {key}: {_format_summary_value(key, value)}")
339
+
340
+ if report.summary.get("estimated_cost_usd") is not None:
341
+ lines.append(
342
+ " note: cost at GitHub's private-repository list prices, each job rounded "
343
+ "up to a whole minute; public repos pay nothing for standard runners"
344
+ )
345
+ coverage = cost_coverage(report.summary)
346
+ if coverage:
347
+ lines.append(f" note: {coverage}")
348
+ lines.append("")
349
+
350
+ # Where the time goes
351
+ if report.mode == "log":
352
+ lines.extend(_format_top_consumers(report.steps, width))
353
+
354
+ # Issues
355
+ if report.issues:
356
+ lines.append("ISSUES")
357
+ lines.append("-" * width)
358
+ for issue in report.issues:
359
+ issue_type = issue["type"].upper()
360
+ step = issue["step"]
361
+ severity = issue.get("severity", "warning").upper()
362
+
363
+ lines.append(f" [{severity}] {issue_type}: {step}")
364
+
365
+ lines.extend(_format_issue_details(issue))
366
+ lines.append("")
367
+
368
+ # Steps
369
+ lines.append("STEPS")
370
+ lines.append("-" * width)
371
+
372
+ if report.mode == "log":
373
+ # Log mode - show statistics
374
+ lines.append(
375
+ f"{'Step Name':<38} {'Runs':>5} {'Total':>9} {'P50':>8} {'P90':>8} "
376
+ f"{'P95':>8} {'P99':>8} {'Cost':>8} {'Flags':<10}"
377
+ )
378
+ lines.append("-" * width)
379
+
380
+ shown = report.steps[:top] if top else report.steps
381
+
382
+ for step in shown:
383
+ name = step["name"][:37] # Truncate long names
384
+ exec_count = step.get("execution_count", 0)
385
+
386
+ total_str = format_duration(step.get("total_duration"))
387
+ p50_str = f"{step['p50']:.2f}s" if step.get("p50") is not None else "N/A"
388
+ p90_str = f"{step['p90']:.2f}s" if step.get("p90") is not None else "N/A"
389
+ p95_str = f"{step['p95']:.2f}s" if step.get("p95") is not None else "N/A"
390
+ p99_str = f"{step['p99']:.2f}s" if step.get("p99") is not None else "N/A"
391
+ cost = step.get("estimated_cost_usd")
392
+ cost_str = format_cost(cost) if cost is not None else "N/A"
393
+
394
+ flags = []
395
+ if step.get("is_regression"):
396
+ flags.append("REGR")
397
+ if step.get("is_flaky"):
398
+ flags.append("FLAKY")
399
+ flags_str = ",".join(flags) if flags else ""
400
+
401
+ lines.append(
402
+ f"{name:<38} {exec_count:>5} {total_str:>9} {p50_str:>8} {p90_str:>8} "
403
+ f"{p95_str:>8} {p99_str:>8} {cost_str:>8} {flags_str:<10}"
404
+ )
405
+
406
+ hidden = len(report.steps) - len(shown)
407
+ if hidden > 0:
408
+ lines.append(f"... and {hidden} more steps (use --top 0 to show all)")
409
+ else:
410
+ # Config mode - show steps and estimates
411
+ lines.append(f"{'Step Name':<40} {'Stage':<20} {'Estimate':<15}")
412
+ lines.append("-" * width)
413
+
414
+ for step in report.steps:
415
+ name = step["name"][:39]
416
+ stage = step.get("stage", "")[:19] if step.get("stage") else ""
417
+ estimate = step.get("estimated_duration")
418
+ estimate_str = f"{estimate:.2f}s" if estimate is not None else "N/A"
419
+
420
+ lines.append(f"{name:<40} {stage:<20} {estimate_str:<15}")
421
+
422
+ lines.append("")
423
+ lines.append("=" * width)
424
+
425
+ return "\n".join(lines)
426
+
427
+
428
+ _ISSUE_LABELS = {
429
+ "regression": "⚠️ Regression",
430
+ "flaky": "❌ Flaky",
431
+ }
432
+
433
+
434
+ def _md_cell(value: Any) -> str:
435
+ """Escape a value so it cannot break a Markdown table row."""
436
+ return str(value).replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
437
+
438
+
439
+ def _md_seconds(value: float | None) -> str:
440
+ return format_duration(value) if value is not None else "–"
441
+
442
+
443
+ def format_markdown(report: Report, top: int | None = None) -> str:
444
+ """Format a Report as GitHub-flavored Markdown.
445
+
446
+ Meant for a run's summary page ($GITHUB_STEP_SUMMARY) and PR comments:
447
+ the headline numbers, then the issues, then where the time goes, with the
448
+ full step table collapsed so the page stays short.
449
+
450
+ Args:
451
+ report: The report to format
452
+ top: Show only the N steps that consume the most time in the step
453
+ table; None or 0 shows every step
454
+
455
+ Returns:
456
+ Markdown text
457
+ """
458
+ summary = report.summary
459
+ lines: list[str] = [f"## {report.title}", ""]
460
+
461
+ facts = []
462
+ if "build_count" in summary:
463
+ builds = summary["build_count"]
464
+ facts.append(f"**{builds}** build{'s' if builds != 1 else ''}")
465
+ if summary.get("build_p50_seconds") is not None:
466
+ facts.append(f"median build **{format_duration(summary['build_p50_seconds'])}**")
467
+ if summary.get("total_execution_seconds") is not None:
468
+ facts.append(f"**{format_duration(summary['total_execution_seconds'])}** of execution")
469
+ if summary.get("total_queue_seconds") is not None:
470
+ facts.append(f"**{format_duration(summary['total_queue_seconds'])}** queued")
471
+ if summary.get("estimated_cost_usd") is not None:
472
+ cost_fact = f"**{format_cost(summary['estimated_cost_usd'])}** estimated cost"
473
+ if cost_coverage(summary):
474
+ cost_fact += f" ({summary.get('priced_jobs', 0)} of {summary.get('job_count', 0)} jobs priced)"
475
+ facts.append(cost_fact)
476
+ if facts:
477
+ lines += [" · ".join(facts), ""]
478
+
479
+ if report.mode == "log":
480
+ if report.issues:
481
+ lines += ["### Issues", ""]
482
+ for issue in report.issues:
483
+ label = _ISSUE_LABELS.get(issue["type"], issue["type"].title())
484
+ lines.append(f"- {label}: `{issue['step']}`")
485
+ for detail in _format_issue_details(issue):
486
+ lines.append(f" - {detail.strip()}")
487
+ lines.append("")
488
+ else:
489
+ lines += ["✅ No flaky steps or regressions found.", ""]
490
+
491
+ ranked = _rank_consumers(report.steps)
492
+ if ranked:
493
+ lines += [
494
+ "### Top time consumers",
495
+ "",
496
+ "| # | Step | Total | Share | Runs | Cost |",
497
+ "|--:|------|------:|------:|-----:|-----:|",
498
+ ]
499
+ for rank, (step, share) in enumerate(ranked, start=1):
500
+ cost = step.get("estimated_cost_usd")
501
+ lines.append(
502
+ f"| {rank} | {_md_cell(step['name'])} | {format_duration(step['total_duration'])} "
503
+ f"| {share} | {step.get('execution_count', 0)} "
504
+ f"| {format_cost(cost) if cost is not None else '–'} |"
505
+ )
506
+ lines.append("")
507
+
508
+ shown = report.steps[:top] if top else report.steps
509
+ lines += [
510
+ f"<details><summary>All steps ({len(report.steps)})</summary>",
511
+ "",
512
+ "| Step | Runs | Total | P50 | P90 | P99 | Cost | Flags |",
513
+ "|------|-----:|------:|----:|----:|----:|-----:|-------|",
514
+ ]
515
+ for step in shown:
516
+ flags = [
517
+ flag for flag, key in (("REGR", "is_regression"), ("FLAKY", "is_flaky")) if step.get(key)
518
+ ]
519
+ cost = step.get("estimated_cost_usd")
520
+ lines.append(
521
+ f"| {_md_cell(step['name'])} | {step.get('execution_count', 0)} "
522
+ f"| {_md_seconds(step.get('total_duration'))} | {_md_seconds(step.get('p50'))} "
523
+ f"| {_md_seconds(step.get('p90'))} | {_md_seconds(step.get('p99'))} "
524
+ f"| {format_cost(cost) if cost is not None else '–'} | {' '.join(flags)} |"
525
+ )
526
+ hidden = len(report.steps) - len(shown)
527
+ if hidden > 0:
528
+ lines += ["", f"…and {hidden} more steps."]
529
+ lines += ["", "</details>", ""]
530
+
531
+ notes = []
532
+ if summary.get("flaky_detection") == "same-commit":
533
+ notes.append("flaky = failed and passed on the same commit")
534
+ elif summary.get("flaky_detection") == "failure-rate":
535
+ notes.append("flaky by failure rate only (no commit data)")
536
+ if "regression_checked_steps" in summary:
537
+ notes.append(
538
+ f"regressions checked on {summary['regression_checked_steps']} steps with "
539
+ f"≥{summary.get('regression_min_builds', '?')} builds"
540
+ )
541
+ if summary.get("estimated_cost_usd") is not None:
542
+ notes.append("cost at GitHub list prices for private repositories")
543
+ if notes:
544
+ lines += [f"<sub>{'; '.join(notes)}.</sub>", ""]
545
+
546
+ else:
547
+ lines += [
548
+ "| Step | Stage | Estimate |",
549
+ "|------|-------|---------:|",
550
+ ]
551
+ for step in report.steps:
552
+ estimate = step.get("estimated_duration")
553
+ lines.append(
554
+ f"| {_md_cell(step['name'])} | {_md_cell(step.get('stage') or '')} "
555
+ f"| {_md_seconds(estimate)} |"
556
+ )
557
+ lines.append("")
558
+
559
+ return "\n".join(lines)
560
+
561
+
562
+ def parse_json_report(json_str: str) -> Report:
563
+ """Parse a JSON string into a Report object.
564
+
565
+ Args:
566
+ json_str: JSON string containing a serialized report
567
+
568
+ Returns:
569
+ Deserialized Report object
570
+
571
+ Raises:
572
+ json.JSONDecodeError: If the JSON is invalid
573
+ KeyError: If required fields are missing
574
+ """
575
+ data = json.loads(json_str)
576
+ return Report.from_dict(data)
577
+