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