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