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
ci_time_tracker/cli.py
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
"""CLI module for ci-time-tracker.
|
|
2
|
+
|
|
3
|
+
This module provides command-line interface functionality including argument
|
|
4
|
+
parsing, file I/O, and orchestration of parsing, analysis, and reporting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ci_time_tracker.analyzer import analyze_config, analyze_logs
|
|
14
|
+
from ci_time_tracker.cache import RunCache
|
|
15
|
+
from ci_time_tracker.config_parser import parse_config
|
|
16
|
+
from ci_time_tracker.detection import DEFAULT_REGRESSION_THRESHOLD
|
|
17
|
+
from ci_time_tracker.gates import GATE_CHOICES, evaluate_gates, evaluate_impact_gates
|
|
18
|
+
from ci_time_tracker.github_api import (
|
|
19
|
+
DEFAULT_RUN_LIMIT,
|
|
20
|
+
GitHubAPIError,
|
|
21
|
+
GitHubClient,
|
|
22
|
+
fetch_build_logs,
|
|
23
|
+
fetch_impact_inputs,
|
|
24
|
+
resolve_token,
|
|
25
|
+
)
|
|
26
|
+
from ci_time_tracker.impact import compare_run, format_impact_markdown
|
|
27
|
+
from ci_time_tracker.log_parser import parse_log
|
|
28
|
+
from ci_time_tracker.pricing import load_pricing
|
|
29
|
+
from ci_time_tracker.reporter import (
|
|
30
|
+
format_json,
|
|
31
|
+
format_markdown,
|
|
32
|
+
format_text,
|
|
33
|
+
generate_report,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _positive_percentage(value: str) -> float:
|
|
38
|
+
"""argparse type: a percentage above zero, returned as a fraction."""
|
|
39
|
+
try:
|
|
40
|
+
number = float(value)
|
|
41
|
+
except ValueError:
|
|
42
|
+
raise argparse.ArgumentTypeError(f"'{value}' is not a number") from None
|
|
43
|
+
if number <= 0:
|
|
44
|
+
raise argparse.ArgumentTypeError("must be greater than 0")
|
|
45
|
+
return number / 100.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _gate_list(value: str) -> set[str]:
|
|
49
|
+
"""argparse type: a comma-separated list of findings to fail on."""
|
|
50
|
+
gates = {item.strip().lower() for item in value.split(",") if item.strip()}
|
|
51
|
+
unknown = gates - set(GATE_CHOICES)
|
|
52
|
+
if not gates or unknown:
|
|
53
|
+
raise argparse.ArgumentTypeError(
|
|
54
|
+
f"expected a comma-separated list of: {', '.join(GATE_CHOICES)}"
|
|
55
|
+
)
|
|
56
|
+
return gates
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _positive_seconds(value: str) -> float:
|
|
60
|
+
"""argparse type: a duration in seconds above zero."""
|
|
61
|
+
try:
|
|
62
|
+
number = float(value)
|
|
63
|
+
except ValueError:
|
|
64
|
+
raise argparse.ArgumentTypeError(f"'{value}' is not a number") from None
|
|
65
|
+
if number <= 0:
|
|
66
|
+
raise argparse.ArgumentTypeError("must be greater than 0")
|
|
67
|
+
return number
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_args(args: list[str] | None = None) -> argparse.Namespace:
|
|
71
|
+
"""Parse command-line arguments.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
args: Optional list of argument strings (for testing). If None, uses sys.argv.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Parsed arguments namespace
|
|
78
|
+
"""
|
|
79
|
+
parser = argparse.ArgumentParser(
|
|
80
|
+
prog="ci-time-tracker",
|
|
81
|
+
description="Analyze CI/CD pipeline configurations and build logs for performance bottlenecks and flaky steps.",
|
|
82
|
+
epilog="Example: ci-time-tracker --config .github/workflows/ci.yml --format text",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Mode selection (mutually exclusive)
|
|
86
|
+
mode_group = parser.add_mutually_exclusive_group(required=True)
|
|
87
|
+
mode_group.add_argument(
|
|
88
|
+
"--config",
|
|
89
|
+
type=str,
|
|
90
|
+
metavar="PATH",
|
|
91
|
+
help="Path to CI config file (GitHub Actions, GitLab CI, CircleCI). Use '-' for stdin.",
|
|
92
|
+
)
|
|
93
|
+
mode_group.add_argument(
|
|
94
|
+
"--logs",
|
|
95
|
+
type=str,
|
|
96
|
+
metavar="PATH",
|
|
97
|
+
help="Path to build log file or directory containing multiple logs. Use '-' for stdin.",
|
|
98
|
+
)
|
|
99
|
+
mode_group.add_argument(
|
|
100
|
+
"--github",
|
|
101
|
+
type=str,
|
|
102
|
+
metavar="OWNER/REPO",
|
|
103
|
+
help="Analyze recent workflow runs fetched from the GitHub Actions API. "
|
|
104
|
+
"Set GITHUB_TOKEN to reach private repositories and raise the rate limit.",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# GitHub mode options
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--workflow",
|
|
110
|
+
type=str,
|
|
111
|
+
metavar="FILE",
|
|
112
|
+
help="Workflow file name to analyze (e.g. ci.yml). Only used with --github.",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--branch",
|
|
117
|
+
type=str,
|
|
118
|
+
metavar="NAME",
|
|
119
|
+
help="Only analyze runs of this branch. Only used with --github.",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"--last",
|
|
124
|
+
type=int,
|
|
125
|
+
default=DEFAULT_RUN_LIMIT,
|
|
126
|
+
metavar="N",
|
|
127
|
+
help=f"Number of recent runs to analyze (default: {DEFAULT_RUN_LIMIT}). Only used with --github.",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"--no-cache",
|
|
132
|
+
action="store_true",
|
|
133
|
+
help="Ignore the local run cache and refetch every run. Only used with --github.",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
"--cache-path",
|
|
138
|
+
type=str,
|
|
139
|
+
metavar="PATH",
|
|
140
|
+
help="Location of the local run cache database. Only used with --github.",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Pull request mode: one run against its baseline
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"--run-id",
|
|
146
|
+
type=int,
|
|
147
|
+
metavar="ID",
|
|
148
|
+
help="Compare one workflow run with recent runs of the same workflow on the "
|
|
149
|
+
"baseline branch, instead of analyzing history. Requires --github.",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
parser.add_argument(
|
|
153
|
+
"--baseline-branch",
|
|
154
|
+
type=str,
|
|
155
|
+
metavar="NAME",
|
|
156
|
+
help="Branch to compare --run-id with (default: the repository's default branch).",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
parser.add_argument(
|
|
160
|
+
"--baseline-runs",
|
|
161
|
+
type=int,
|
|
162
|
+
default=20,
|
|
163
|
+
metavar="N",
|
|
164
|
+
help="Number of baseline runs to compare --run-id with (default: 20).",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--comment",
|
|
169
|
+
action="store_true",
|
|
170
|
+
help="Post the --run-id comparison as a comment on the run's pull request, "
|
|
171
|
+
"updating the previous one. Needs a token with pull-requests: write.",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
parser.add_argument(
|
|
175
|
+
"--pr",
|
|
176
|
+
type=int,
|
|
177
|
+
metavar="NUMBER",
|
|
178
|
+
help="Pull request to comment on (default: found from the run's commit).",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
parser.add_argument(
|
|
182
|
+
"--pricing",
|
|
183
|
+
type=str,
|
|
184
|
+
metavar="PATH",
|
|
185
|
+
help="Path to a JSON file mapping runner labels to USD per minute, "
|
|
186
|
+
"overriding the built-in rates used for cost estimates.",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
parser.add_argument(
|
|
190
|
+
"--top",
|
|
191
|
+
type=int,
|
|
192
|
+
default=25,
|
|
193
|
+
metavar="N",
|
|
194
|
+
help="In text output, show only the N steps that consume the most time "
|
|
195
|
+
"(default: 25). Use 0 to show every step.",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
parser.add_argument(
|
|
199
|
+
"--group-matrix",
|
|
200
|
+
action="store_true",
|
|
201
|
+
help="Merge the legs of a matrix job, so 'build (3.12, ubuntu) / Run tests' "
|
|
202
|
+
"and its siblings count as one step.",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
parser.add_argument(
|
|
206
|
+
"--regression-threshold",
|
|
207
|
+
type=_positive_percentage,
|
|
208
|
+
default=DEFAULT_REGRESSION_THRESHOLD,
|
|
209
|
+
metavar="PCT",
|
|
210
|
+
help="Report a step as regressed when its median duration rose by at least "
|
|
211
|
+
f"PCT percent (default: {DEFAULT_REGRESSION_THRESHOLD * 100:.0f}).",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Optional arguments
|
|
215
|
+
parser.add_argument(
|
|
216
|
+
"--format",
|
|
217
|
+
type=str,
|
|
218
|
+
choices=["json", "text", "markdown"],
|
|
219
|
+
default="text",
|
|
220
|
+
help="Output format (default: text). Markdown suits $GITHUB_STEP_SUMMARY "
|
|
221
|
+
"and PR comments.",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
parser.add_argument(
|
|
225
|
+
"--fail-on",
|
|
226
|
+
type=_gate_list,
|
|
227
|
+
default=set(),
|
|
228
|
+
metavar="FINDINGS",
|
|
229
|
+
help="Exit with code 4 when any of these findings is reported: "
|
|
230
|
+
f"a comma-separated list of {', '.join(GATE_CHOICES)}.",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
parser.add_argument(
|
|
234
|
+
"--max-duration",
|
|
235
|
+
type=_positive_seconds,
|
|
236
|
+
metavar="SECONDS",
|
|
237
|
+
help="Exit with code 4 when the median build duration exceeds SECONDS.",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
parser.add_argument(
|
|
241
|
+
"--output",
|
|
242
|
+
type=str,
|
|
243
|
+
metavar="PATH",
|
|
244
|
+
help="Output file path. If not specified, writes to stdout.",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
parser.add_argument(
|
|
248
|
+
"--estimates",
|
|
249
|
+
type=str,
|
|
250
|
+
metavar="PATH",
|
|
251
|
+
help="Path to JSON file mapping step names to estimated durations (in seconds). Only used with --config mode.",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
parsed = parser.parse_args(args)
|
|
255
|
+
|
|
256
|
+
if parsed.run_id is not None and not parsed.github:
|
|
257
|
+
parser.error("--run-id requires --github")
|
|
258
|
+
if (parsed.comment or parsed.pr is not None) and parsed.run_id is None:
|
|
259
|
+
parser.error("--comment and --pr require --run-id")
|
|
260
|
+
|
|
261
|
+
return parsed
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def read_input(path: str) -> str:
|
|
265
|
+
"""Read input from file or stdin.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
path: File path or '-' for stdin
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
File contents as string
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
FileNotFoundError: If file doesn't exist
|
|
275
|
+
IOError: If file cannot be read
|
|
276
|
+
"""
|
|
277
|
+
if path == "-":
|
|
278
|
+
return sys.stdin.read()
|
|
279
|
+
|
|
280
|
+
file_path = Path(path)
|
|
281
|
+
if not file_path.exists():
|
|
282
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
283
|
+
|
|
284
|
+
if not file_path.is_file():
|
|
285
|
+
raise OSError(f"Path is not a file: {path}")
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
return file_path.read_text(encoding="utf-8")
|
|
289
|
+
except Exception as e:
|
|
290
|
+
raise OSError(f"Error reading file {path}: {e}") from e
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def write_output(content: str, path: str | None) -> None:
|
|
294
|
+
"""Write output to file or stdout.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
content: Content to write
|
|
298
|
+
path: Output file path or None for stdout
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
IOError: If file cannot be written
|
|
302
|
+
"""
|
|
303
|
+
if path is None:
|
|
304
|
+
print(content)
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
output_path = Path(path)
|
|
308
|
+
try:
|
|
309
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
310
|
+
output_path.write_text(content, encoding="utf-8")
|
|
311
|
+
except Exception as e:
|
|
312
|
+
raise OSError(f"Error writing to file {path}: {e}") from e
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def load_estimates(path: str) -> dict[str, float]:
|
|
316
|
+
"""Load estimates from JSON file.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
path: Path to JSON file containing step name to duration mapping
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Dictionary mapping step names to estimated durations
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
FileNotFoundError: If file doesn't exist
|
|
326
|
+
json.JSONDecodeError: If file contains invalid JSON
|
|
327
|
+
ValueError: If JSON structure is invalid
|
|
328
|
+
"""
|
|
329
|
+
content = read_input(path)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
data = json.loads(content)
|
|
333
|
+
except json.JSONDecodeError as e:
|
|
334
|
+
raise json.JSONDecodeError(
|
|
335
|
+
f"Invalid JSON in estimates file: {e.msg}",
|
|
336
|
+
e.doc,
|
|
337
|
+
e.pos
|
|
338
|
+
) from e
|
|
339
|
+
|
|
340
|
+
if not isinstance(data, dict):
|
|
341
|
+
raise ValueError("Estimates file must contain a JSON object (dictionary)")
|
|
342
|
+
|
|
343
|
+
# Validate structure
|
|
344
|
+
estimates: dict[str, float] = {}
|
|
345
|
+
for key, value in data.items():
|
|
346
|
+
if not isinstance(key, str):
|
|
347
|
+
raise ValueError(f"Estimate keys must be strings, got: {type(key)}")
|
|
348
|
+
|
|
349
|
+
if not isinstance(value, (int, float)):
|
|
350
|
+
raise ValueError(f"Estimate values must be numbers, got: {type(value)} for key '{key}'")
|
|
351
|
+
|
|
352
|
+
if value < 0:
|
|
353
|
+
raise ValueError(f"Estimate values must be non-negative, got: {value} for key '{key}'")
|
|
354
|
+
|
|
355
|
+
estimates[key] = float(value)
|
|
356
|
+
|
|
357
|
+
return estimates
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _no_runs_message(args: argparse.Namespace) -> str:
|
|
361
|
+
"""Explain an empty result, pointing at the default branch when it differs.
|
|
362
|
+
|
|
363
|
+
Repositories differ on main versus master, and an empty answer for the
|
|
364
|
+
wrong branch is otherwise indistinguishable from a repository without CI.
|
|
365
|
+
"""
|
|
366
|
+
message = "No workflow runs found to analyze"
|
|
367
|
+
if not args.branch:
|
|
368
|
+
return message
|
|
369
|
+
|
|
370
|
+
message = f"No completed workflow runs found on branch '{args.branch}'"
|
|
371
|
+
try:
|
|
372
|
+
default = GitHubClient(repo=args.github, token=resolve_token()).fetch_default_branch()
|
|
373
|
+
except GitHubAPIError:
|
|
374
|
+
return message
|
|
375
|
+
|
|
376
|
+
if default and default != args.branch:
|
|
377
|
+
message += f". The repository's default branch is '{default}'"
|
|
378
|
+
return message
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _run_impact(args: argparse.Namespace, pricing: Any) -> int:
|
|
382
|
+
"""Compare one run with its baseline, and optionally comment on its PR.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Exit code
|
|
386
|
+
"""
|
|
387
|
+
cache = None
|
|
388
|
+
if not args.no_cache:
|
|
389
|
+
try:
|
|
390
|
+
cache = RunCache(args.cache_path)
|
|
391
|
+
except Exception as e:
|
|
392
|
+
print(f"Warning: run cache unavailable ({e})", file=sys.stderr)
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
run, run_logs, baseline, branch = fetch_impact_inputs(
|
|
396
|
+
repo=args.github,
|
|
397
|
+
run_id=args.run_id,
|
|
398
|
+
baseline_branch=args.baseline_branch,
|
|
399
|
+
baseline_runs=args.baseline_runs,
|
|
400
|
+
cache=cache,
|
|
401
|
+
)
|
|
402
|
+
except GitHubAPIError as e:
|
|
403
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
404
|
+
return 3
|
|
405
|
+
finally:
|
|
406
|
+
if cache is not None:
|
|
407
|
+
cache.close()
|
|
408
|
+
|
|
409
|
+
impact = compare_run(
|
|
410
|
+
run_logs,
|
|
411
|
+
baseline,
|
|
412
|
+
branch,
|
|
413
|
+
pricing=pricing,
|
|
414
|
+
group_matrix=args.group_matrix,
|
|
415
|
+
threshold=args.regression_threshold,
|
|
416
|
+
)
|
|
417
|
+
comment = format_impact_markdown(impact)
|
|
418
|
+
|
|
419
|
+
# The comparison is Markdown in both text and markdown formats
|
|
420
|
+
output = json.dumps(impact.to_dict(), indent=2, default=str) if args.format == "json" else comment
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
write_output(output, args.output)
|
|
424
|
+
except OSError as e:
|
|
425
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
426
|
+
return 1
|
|
427
|
+
|
|
428
|
+
if args.comment:
|
|
429
|
+
client = GitHubClient(repo=args.github, token=resolve_token())
|
|
430
|
+
try:
|
|
431
|
+
number = args.pr or client.find_pull_request(run.get("head_sha") or "")
|
|
432
|
+
if number is None:
|
|
433
|
+
print("Warning: no open pull request found for this run; not commenting", file=sys.stderr)
|
|
434
|
+
else:
|
|
435
|
+
action, url = client.upsert_comment(number, comment)
|
|
436
|
+
print(f"Comment {action} on pull request #{number}: {url}", file=sys.stderr)
|
|
437
|
+
except GitHubAPIError as e:
|
|
438
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
439
|
+
return 3
|
|
440
|
+
|
|
441
|
+
reasons = evaluate_impact_gates(impact, args.fail_on, args.max_duration)
|
|
442
|
+
if reasons:
|
|
443
|
+
print("Gate failed:", file=sys.stderr)
|
|
444
|
+
for reason in reasons:
|
|
445
|
+
print(f" - {reason}", file=sys.stderr)
|
|
446
|
+
return 4
|
|
447
|
+
|
|
448
|
+
return 0
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _use_utf8_output() -> None:
|
|
452
|
+
"""Write UTF-8 to stdout and stderr whatever the platform default is.
|
|
453
|
+
|
|
454
|
+
On Windows, output sent to a pipe or a file (such as
|
|
455
|
+
$GITHUB_STEP_SUMMARY) uses the ANSI code page, which cannot encode emoji
|
|
456
|
+
or many step names and would crash the run halfway through the report.
|
|
457
|
+
"""
|
|
458
|
+
for stream in (sys.stdout, sys.stderr):
|
|
459
|
+
try:
|
|
460
|
+
stream.reconfigure(encoding="utf-8")
|
|
461
|
+
except (AttributeError, ValueError):
|
|
462
|
+
# Not a reconfigurable text stream (e.g. replaced in tests)
|
|
463
|
+
pass
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def main() -> int:
|
|
467
|
+
"""Main entry point for the CLI.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
Exit code (0 for success, non-zero for error)
|
|
471
|
+
"""
|
|
472
|
+
_use_utf8_output()
|
|
473
|
+
|
|
474
|
+
try:
|
|
475
|
+
args = parse_args()
|
|
476
|
+
|
|
477
|
+
# Runner pricing for cost estimates (log and GitHub modes)
|
|
478
|
+
pricing = None
|
|
479
|
+
if args.pricing:
|
|
480
|
+
try:
|
|
481
|
+
pricing = load_pricing(args.pricing)
|
|
482
|
+
except OSError as e:
|
|
483
|
+
print(f"Error reading pricing file: {e}", file=sys.stderr)
|
|
484
|
+
return 1
|
|
485
|
+
except ValueError as e:
|
|
486
|
+
print(f"Error parsing pricing file: {e}", file=sys.stderr)
|
|
487
|
+
return 2
|
|
488
|
+
|
|
489
|
+
# Determine mode
|
|
490
|
+
if args.config:
|
|
491
|
+
# Config analysis mode
|
|
492
|
+
try:
|
|
493
|
+
config_content = read_input(args.config)
|
|
494
|
+
except FileNotFoundError as e:
|
|
495
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
496
|
+
return 1
|
|
497
|
+
except OSError as e:
|
|
498
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
499
|
+
return 1
|
|
500
|
+
|
|
501
|
+
# Parse config
|
|
502
|
+
try:
|
|
503
|
+
config = parse_config(config_content)
|
|
504
|
+
except ValueError as e:
|
|
505
|
+
print(f"Error parsing config: {e}", file=sys.stderr)
|
|
506
|
+
return 2
|
|
507
|
+
except Exception as e:
|
|
508
|
+
print(f"Unexpected error parsing config: {e}", file=sys.stderr)
|
|
509
|
+
return 2
|
|
510
|
+
|
|
511
|
+
# Load estimates if provided
|
|
512
|
+
estimates = None
|
|
513
|
+
if args.estimates:
|
|
514
|
+
try:
|
|
515
|
+
estimates = load_estimates(args.estimates)
|
|
516
|
+
except (OSError, FileNotFoundError) as e:
|
|
517
|
+
print(f"Error loading estimates: {e}", file=sys.stderr)
|
|
518
|
+
return 1
|
|
519
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
520
|
+
print(f"Error parsing estimates: {e}", file=sys.stderr)
|
|
521
|
+
return 2
|
|
522
|
+
|
|
523
|
+
# Analyze
|
|
524
|
+
result = analyze_config(config, estimates)
|
|
525
|
+
|
|
526
|
+
elif args.logs:
|
|
527
|
+
# Log analysis mode
|
|
528
|
+
log_path = Path(args.logs) if args.logs != "-" else None
|
|
529
|
+
|
|
530
|
+
# Determine if we have a directory or single file
|
|
531
|
+
logs_to_parse = []
|
|
532
|
+
|
|
533
|
+
if log_path and log_path.is_dir():
|
|
534
|
+
# Parse all files in directory
|
|
535
|
+
for file_path in sorted(log_path.rglob("*")):
|
|
536
|
+
if file_path.is_file():
|
|
537
|
+
try:
|
|
538
|
+
log_content = file_path.read_text(encoding="utf-8")
|
|
539
|
+
logs_to_parse.append(log_content)
|
|
540
|
+
except Exception as e:
|
|
541
|
+
print(f"Warning: Could not read {file_path}: {e}", file=sys.stderr)
|
|
542
|
+
else:
|
|
543
|
+
# Single file or stdin
|
|
544
|
+
try:
|
|
545
|
+
log_content = read_input(args.logs)
|
|
546
|
+
logs_to_parse.append(log_content)
|
|
547
|
+
except (OSError, FileNotFoundError) as e:
|
|
548
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
549
|
+
return 1
|
|
550
|
+
|
|
551
|
+
if not logs_to_parse:
|
|
552
|
+
print("Error: No log files found to analyze", file=sys.stderr)
|
|
553
|
+
return 1
|
|
554
|
+
|
|
555
|
+
# Parse logs
|
|
556
|
+
parsed_logs = []
|
|
557
|
+
for i, log_content in enumerate(logs_to_parse):
|
|
558
|
+
try:
|
|
559
|
+
log = parse_log(log_content)
|
|
560
|
+
parsed_logs.append(log)
|
|
561
|
+
except ValueError as e:
|
|
562
|
+
print(f"Warning: Error parsing log {i + 1}: {e}", file=sys.stderr)
|
|
563
|
+
except Exception as e:
|
|
564
|
+
print(f"Warning: Unexpected error parsing log {i + 1}: {e}", file=sys.stderr)
|
|
565
|
+
|
|
566
|
+
if not parsed_logs:
|
|
567
|
+
print("Error: No logs could be successfully parsed", file=sys.stderr)
|
|
568
|
+
return 2
|
|
569
|
+
|
|
570
|
+
# Analyze
|
|
571
|
+
result = analyze_logs(
|
|
572
|
+
parsed_logs,
|
|
573
|
+
pricing,
|
|
574
|
+
args.group_matrix,
|
|
575
|
+
regression_threshold=args.regression_threshold,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
elif args.github:
|
|
579
|
+
if args.run_id is not None:
|
|
580
|
+
return _run_impact(args, pricing)
|
|
581
|
+
|
|
582
|
+
# GitHub Actions API mode
|
|
583
|
+
cache = None
|
|
584
|
+
if not args.no_cache:
|
|
585
|
+
try:
|
|
586
|
+
cache = RunCache(args.cache_path)
|
|
587
|
+
except Exception as e:
|
|
588
|
+
print(f"Warning: run cache unavailable ({e})", file=sys.stderr)
|
|
589
|
+
|
|
590
|
+
try:
|
|
591
|
+
logs = fetch_build_logs(
|
|
592
|
+
repo=args.github,
|
|
593
|
+
workflow=args.workflow,
|
|
594
|
+
branch=args.branch,
|
|
595
|
+
limit=args.last,
|
|
596
|
+
cache=cache,
|
|
597
|
+
)
|
|
598
|
+
except GitHubAPIError as e:
|
|
599
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
600
|
+
return 3
|
|
601
|
+
finally:
|
|
602
|
+
if cache is not None:
|
|
603
|
+
cache.close()
|
|
604
|
+
|
|
605
|
+
if not logs:
|
|
606
|
+
print(f"Error: {_no_runs_message(args)}", file=sys.stderr)
|
|
607
|
+
return 1
|
|
608
|
+
|
|
609
|
+
# Analyze
|
|
610
|
+
result = analyze_logs(
|
|
611
|
+
logs,
|
|
612
|
+
pricing,
|
|
613
|
+
args.group_matrix,
|
|
614
|
+
regression_threshold=args.regression_threshold,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
else:
|
|
618
|
+
# Should never reach here due to argparse
|
|
619
|
+
print("Error: Must specify --config, --logs or --github", file=sys.stderr)
|
|
620
|
+
return 1
|
|
621
|
+
|
|
622
|
+
# Generate report
|
|
623
|
+
report = generate_report(result)
|
|
624
|
+
|
|
625
|
+
# Format output
|
|
626
|
+
if args.format == "json":
|
|
627
|
+
output = format_json(report)
|
|
628
|
+
elif args.format == "markdown":
|
|
629
|
+
output = format_markdown(report, args.top)
|
|
630
|
+
else:
|
|
631
|
+
output = format_text(report, args.top)
|
|
632
|
+
|
|
633
|
+
# Write output
|
|
634
|
+
try:
|
|
635
|
+
write_output(output, args.output)
|
|
636
|
+
except OSError as e:
|
|
637
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
638
|
+
return 1
|
|
639
|
+
|
|
640
|
+
# Quality gates run after the report is written, so a failing
|
|
641
|
+
# pipeline still shows why it failed
|
|
642
|
+
if args.max_duration is not None and "build_p50_seconds" not in result.metadata:
|
|
643
|
+
print("Warning: --max-duration not checked: no build durations known", file=sys.stderr)
|
|
644
|
+
|
|
645
|
+
reasons = evaluate_gates(result, args.fail_on, args.max_duration)
|
|
646
|
+
if reasons:
|
|
647
|
+
print("Gate failed:", file=sys.stderr)
|
|
648
|
+
for reason in reasons:
|
|
649
|
+
print(f" - {reason}", file=sys.stderr)
|
|
650
|
+
return 4
|
|
651
|
+
|
|
652
|
+
return 0
|
|
653
|
+
|
|
654
|
+
except KeyboardInterrupt:
|
|
655
|
+
print("\nInterrupted by user", file=sys.stderr)
|
|
656
|
+
return 130
|
|
657
|
+
except Exception as e:
|
|
658
|
+
print(f"Unexpected error: {e}", file=sys.stderr)
|
|
659
|
+
return 1
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
if __name__ == "__main__":
|
|
663
|
+
sys.exit(main())
|
|
664
|
+
|