adop-cli 0.1.3__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.
ado_pipeline/cli.py ADDED
@@ -0,0 +1,1402 @@
1
+ """CLI entry point for Azure DevOps Pipeline Trigger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import time
7
+ import webbrowser
8
+ from typing import Any
9
+
10
+ import click
11
+ from rich.console import Console, Group
12
+ from rich.live import Live
13
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
14
+ from rich.table import Table
15
+
16
+ from . import __version__
17
+ from .api import AzureDevOpsClient, AzureDevOpsError, PipelineRun
18
+ from .config import (
19
+ CONFIG_FILE,
20
+ PIPELINES_FILE,
21
+ Config,
22
+ PipelineConfig,
23
+ PipelineParamConfig,
24
+ PipelinesConfig,
25
+ init_config,
26
+ )
27
+ from .favorites import Favorite, FavoritesStore
28
+ from .pipelines import PipelineNotFoundError, get_all_aliases, get_pipeline, list_pipelines
29
+ from .plan import ExecutionPlan, create_plan
30
+
31
+ console = Console()
32
+
33
+
34
+ def _complete_pipeline_alias(
35
+ ctx: click.Context,
36
+ param: click.Parameter,
37
+ incomplete: str,
38
+ ) -> list[click.shell_completion.CompletionItem]:
39
+ """Shell completion for pipeline aliases."""
40
+ return [
41
+ click.shell_completion.CompletionItem(alias)
42
+ for alias in get_all_aliases()
43
+ if alias.startswith(incomplete)
44
+ ]
45
+
46
+
47
+ def _complete_branch(
48
+ ctx: click.Context,
49
+ param: click.Parameter,
50
+ incomplete: str,
51
+ ) -> list[click.shell_completion.CompletionItem]:
52
+ """Shell completion for git branches."""
53
+ try:
54
+ # Get local branches
55
+ result = subprocess.run(
56
+ ["git", "branch", "--format=%(refname:short)"],
57
+ capture_output=True,
58
+ text=True,
59
+ timeout=5,
60
+ )
61
+ if result.returncode != 0:
62
+ return []
63
+
64
+ # Filter empty strings from output
65
+ branches = [b for b in result.stdout.strip().split("\n") if b]
66
+
67
+ # Also get remote branches (without origin/ prefix)
68
+ result_remote = subprocess.run(
69
+ ["git", "branch", "-r", "--format=%(refname:short)"],
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=5,
73
+ )
74
+ if result_remote.returncode == 0:
75
+ for branch in result_remote.stdout.strip().split("\n"):
76
+ # Remove origin/ prefix, skip empty strings
77
+ if branch and branch.startswith("origin/") and branch != "origin/HEAD":
78
+ branches.append(branch[7:])
79
+
80
+ # Deduplicate and filter
81
+ branches = list(set(branches))
82
+ return [
83
+ click.shell_completion.CompletionItem(b)
84
+ for b in sorted(branches)
85
+ if b and b.startswith(incomplete)
86
+ ]
87
+ except Exception:
88
+ return []
89
+
90
+
91
+ def _get_client() -> AzureDevOpsClient:
92
+ """Get configured API client or exit with error."""
93
+ config = Config.load()
94
+ if not config.is_configured():
95
+ missing = config.get_missing_fields()
96
+ console.print(f"[red]Error:[/red] Configuration incomplete. Missing: {', '.join(missing)}")
97
+ console.print("Run 'ado-pipeline config init' to set up.")
98
+ raise SystemExit(1)
99
+ return AzureDevOpsClient(config)
100
+
101
+
102
+ def _format_state(state: str, result: str = "") -> str:
103
+ """Format state/result with colors."""
104
+ state_formats = {
105
+ "inProgress": "[cyan]in progress[/cyan]",
106
+ "notStarted": "[dim]queued[/dim]",
107
+ "canceling": "[yellow]canceling[/yellow]",
108
+ }
109
+
110
+ if state != "completed":
111
+ return state_formats.get(state, f"[dim]{state}[/dim]")
112
+
113
+ result_formats = {
114
+ "succeeded": "[green]succeeded[/green]",
115
+ "failed": "[red]failed[/red]",
116
+ "canceled": "[yellow]canceled[/yellow]",
117
+ }
118
+ return result_formats.get(result, f"[dim]{result or state}[/dim]")
119
+
120
+
121
+ def _format_stage_state(state: str, result: str = "") -> str:
122
+ """Format stage state with colors."""
123
+ state_icons = {
124
+ "inProgress": "[cyan]\u25cf[/cyan]",
125
+ "pending": "[dim]\u25cb[/dim]",
126
+ }
127
+
128
+ if state != "completed":
129
+ return state_icons.get(state, "[dim]?[/dim]")
130
+
131
+ result_icons = {
132
+ "succeeded": "[green]\u2713[/green]",
133
+ "failed": "[red]\u2717[/red]",
134
+ "canceled": "[yellow]-[/yellow]",
135
+ "skipped": "[yellow]-[/yellow]",
136
+ }
137
+ return result_icons.get(result, "[dim]\u2713[/dim]")
138
+
139
+
140
+ def _watch_build(client: AzureDevOpsClient, run: PipelineRun) -> PipelineRun:
141
+ """Watch a build until completion with progress bar."""
142
+ console.print()
143
+ console.print("[bold]Watching build progress...[/bold]")
144
+ console.print("[dim]Press Ctrl+C to stop watching (build will continue)[/dim]")
145
+ console.print()
146
+
147
+ try:
148
+ with Live(console=console, refresh_per_second=1) as live:
149
+ while True:
150
+ status = client.get_run_status(run.pipeline_id, run.run_id)
151
+
152
+ # Get timeline for stages
153
+ timeline = client.get_build_timeline(run.run_id)
154
+ stages = [
155
+ r for r in timeline
156
+ if r.get("type") == "Stage" and r.get("name") != "__default"
157
+ ]
158
+
159
+ # Build info table
160
+ info_table = Table(show_header=False, box=None)
161
+ info_table.add_column("Key", style="bold")
162
+ info_table.add_column("Value")
163
+ info_table.add_row("Run ID:", str(status.run_id))
164
+ info_table.add_row("Status:", _format_state(status.state, status.result))
165
+
166
+ # Build stages table if we have stages
167
+ if stages:
168
+ completed = sum(
169
+ 1 for s in stages
170
+ if s.get("state") == "completed"
171
+ )
172
+ total = len(stages)
173
+
174
+ stages_table = Table(show_header=False, box=None, padding=(0, 1))
175
+ stages_table.add_column("Icon", width=2)
176
+ stages_table.add_column("Stage")
177
+
178
+ for stage in stages:
179
+ icon = _format_stage_state(
180
+ stage.get("state", ""),
181
+ stage.get("result", ""),
182
+ )
183
+ name = stage.get("name", "Unknown")
184
+ stages_table.add_row(icon, name)
185
+
186
+ # Progress bar
187
+ progress = Progress(
188
+ SpinnerColumn() if not status.is_completed else TextColumn(""),
189
+ TextColumn("[bold]{task.description}"),
190
+ BarColumn(bar_width=30),
191
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
192
+ console=console,
193
+ transient=True,
194
+ )
195
+ task = progress.add_task(
196
+ "Progress",
197
+ total=total,
198
+ completed=completed,
199
+ )
200
+
201
+ live.update(Group(info_table, "", stages_table, "", progress))
202
+ else:
203
+ live.update(info_table)
204
+
205
+ if status.is_completed:
206
+ return status
207
+
208
+ time.sleep(5)
209
+
210
+ except KeyboardInterrupt:
211
+ console.print("\n[yellow]Stopped watching. Build continues in background.[/yellow]")
212
+ return client.get_run_status(run.pipeline_id, run.run_id)
213
+
214
+
215
+ def _build_pipeline_kwargs(
216
+ deploy: bool | None,
217
+ output_format: str | None,
218
+ release_notes: str | None,
219
+ environment: str | None,
220
+ fail_if_no_changes: bool | None,
221
+ fail_on_push_error: bool | None,
222
+ ) -> dict:
223
+ """Build kwargs dict from CLI options, excluding None values."""
224
+ option_mapping = {
225
+ "deploy": deploy,
226
+ "outputFormat": output_format,
227
+ "releaseNotes": release_notes,
228
+ "environment": environment,
229
+ "failIfNoChanges": fail_if_no_changes,
230
+ "failOnPushError": fail_on_push_error,
231
+ }
232
+ return {k: v for k, v in option_mapping.items() if v is not None}
233
+
234
+
235
+ def _show_pipeline_not_found(error: PipelineNotFoundError) -> None:
236
+ """Display pipeline not found error with available pipelines."""
237
+ console.print(f"[red]Error:[/red] {error}")
238
+ console.print("\nAvailable pipelines:")
239
+ for p in list_pipelines():
240
+ console.print(f" - {p.alias}")
241
+
242
+
243
+ def _require_config() -> Config:
244
+ """Load and validate config, exit if not configured."""
245
+ config = Config.load()
246
+ if not config.is_configured():
247
+ console.print("[red]Error:[/red] PAT not configured.")
248
+ console.print("Run 'ado-pipeline config init' to set up.")
249
+ raise SystemExit(1)
250
+ return config
251
+
252
+
253
+ def _trigger_and_show_result(
254
+ client: AzureDevOpsClient,
255
+ execution_plan: ExecutionPlan,
256
+ watch: bool,
257
+ ) -> None:
258
+ """Trigger pipeline and display results, optionally watching progress."""
259
+ run = client.trigger_pipeline(execution_plan)
260
+
261
+ console.print()
262
+ console.print("[green bold]Pipeline triggered successfully![/green bold]")
263
+ console.print()
264
+ console.print(f" Run ID: {run.run_id}")
265
+ console.print(f" State: {run.state}")
266
+ if run.web_url:
267
+ console.print(f" URL: {run.web_url}")
268
+ console.print()
269
+
270
+ if not watch:
271
+ return
272
+
273
+ final_status = _watch_build(client, run)
274
+ console.print()
275
+
276
+ if final_status.result == "succeeded":
277
+ console.print("[green bold]Build completed successfully![/green bold]")
278
+ elif final_status.result == "failed":
279
+ console.print("[red bold]Build failed![/red bold]")
280
+ raise SystemExit(1)
281
+ elif final_status.result == "canceled":
282
+ console.print("[yellow]Build was canceled.[/yellow]")
283
+ raise SystemExit(1)
284
+
285
+
286
+ @click.group()
287
+ @click.version_option(version=__version__)
288
+ def main() -> None:
289
+ """Azure DevOps Pipeline Trigger CLI.
290
+
291
+ Trigger Azure DevOps pipelines from the command line.
292
+ """
293
+ pass
294
+
295
+
296
+ @main.command()
297
+ @click.argument("pipeline_alias", shell_complete=_complete_pipeline_alias)
298
+ @click.option(
299
+ "--branch", "-b",
300
+ shell_complete=_complete_branch,
301
+ help="Branch to build. Defaults to current git branch.",
302
+ )
303
+ @click.option(
304
+ "--deploy/--no-deploy",
305
+ default=None,
306
+ help="Deploy to distribution platform.",
307
+ )
308
+ @click.option(
309
+ "--output-format", "-o",
310
+ type=click.Choice(["apk", "aab"]),
311
+ help="Android output format (apk or aab).",
312
+ )
313
+ @click.option(
314
+ "--release-notes", "-r",
315
+ default=None,
316
+ help="Release notes for deployment.",
317
+ )
318
+ @click.option(
319
+ "--environment", "-e",
320
+ type=click.Choice(["dev", "rel", "prod"]),
321
+ help="Environment for iOS Simulator build.",
322
+ )
323
+ @click.option(
324
+ "--fail-if-no-changes/--no-fail-if-no-changes",
325
+ default=None,
326
+ help="Fail if no golden changes (goldens pipeline).",
327
+ )
328
+ @click.option(
329
+ "--fail-on-push-error/--no-fail-on-push-error",
330
+ default=None,
331
+ help="Fail if git push fails (goldens pipeline).",
332
+ )
333
+ def plan(
334
+ pipeline_alias: str,
335
+ branch: str | None,
336
+ deploy: bool | None,
337
+ output_format: str | None,
338
+ release_notes: str | None,
339
+ environment: str | None,
340
+ fail_if_no_changes: bool | None,
341
+ fail_on_push_error: bool | None,
342
+ ) -> None:
343
+ """Generate an execution plan for a pipeline (dry-run).
344
+
345
+ PIPELINE_ALIAS is the short name of the pipeline (e.g., android-dev, ios-prod).
346
+
347
+ Examples:
348
+
349
+ ado-pipeline plan android-dev
350
+
351
+ ado-pipeline plan android-dev --branch feature/my-feature --deploy
352
+
353
+ ado-pipeline plan ios-sim --environment rel
354
+ """
355
+ try:
356
+ pipeline = get_pipeline(pipeline_alias)
357
+ except PipelineNotFoundError as e:
358
+ _show_pipeline_not_found(e)
359
+ raise SystemExit(1)
360
+
361
+ kwargs = _build_pipeline_kwargs(
362
+ deploy, output_format, release_notes,
363
+ environment, fail_if_no_changes, fail_on_push_error,
364
+ )
365
+
366
+ try:
367
+ config = Config.load()
368
+ execution_plan = create_plan(
369
+ pipeline=pipeline,
370
+ branch=branch,
371
+ config=config,
372
+ **kwargs,
373
+ )
374
+ execution_plan.display(console)
375
+ except Exception as e:
376
+ console.print(f"[red]Error:[/red] {e}")
377
+ raise SystemExit(1)
378
+
379
+
380
+ @main.command()
381
+ @click.argument("pipeline_alias", shell_complete=_complete_pipeline_alias)
382
+ @click.option("--branch", "-b", shell_complete=_complete_branch, help="Branch to build.")
383
+ @click.option("--deploy/--no-deploy", default=None)
384
+ @click.option("--output-format", "-o", type=click.Choice(["apk", "aab"]))
385
+ @click.option("--release-notes", "-r", default=None)
386
+ @click.option("--environment", "-e", type=click.Choice(["dev", "rel", "prod"]))
387
+ @click.option("--fail-if-no-changes/--no-fail-if-no-changes", default=None)
388
+ @click.option("--fail-on-push-error/--no-fail-on-push-error", default=None)
389
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
390
+ @click.option("--watch", "-w", is_flag=True, help="Watch build progress until completion.")
391
+ def apply(
392
+ pipeline_alias: str,
393
+ branch: str | None,
394
+ deploy: bool | None,
395
+ output_format: str | None,
396
+ release_notes: str | None,
397
+ environment: str | None,
398
+ fail_if_no_changes: bool | None,
399
+ fail_on_push_error: bool | None,
400
+ yes: bool,
401
+ watch: bool,
402
+ ) -> None:
403
+ """Trigger a pipeline run.
404
+
405
+ PIPELINE_ALIAS is the short name of the pipeline (e.g., android-dev, ios-prod).
406
+
407
+ Examples:
408
+
409
+ ado-pipeline apply android-dev
410
+
411
+ ado-pipeline apply android-dev --branch feature/my-feature --deploy
412
+
413
+ ado-pipeline apply android-dev -y -w # Skip confirmation, watch progress
414
+ """
415
+ try:
416
+ pipeline = get_pipeline(pipeline_alias)
417
+ except PipelineNotFoundError as e:
418
+ _show_pipeline_not_found(e)
419
+ raise SystemExit(1)
420
+
421
+ kwargs = _build_pipeline_kwargs(
422
+ deploy, output_format, release_notes,
423
+ environment, fail_if_no_changes, fail_on_push_error,
424
+ )
425
+
426
+ config = _require_config()
427
+
428
+ try:
429
+ execution_plan = create_plan(
430
+ pipeline=pipeline,
431
+ branch=branch,
432
+ config=config,
433
+ **kwargs,
434
+ )
435
+ except Exception as e:
436
+ console.print(f"[red]Error:[/red] {e}")
437
+ raise SystemExit(1)
438
+
439
+ execution_plan.display(console)
440
+
441
+ if not yes and not click.confirm("Do you want to trigger this pipeline?"):
442
+ console.print("[yellow]Aborted.[/yellow]")
443
+ raise SystemExit(0)
444
+
445
+ console.print()
446
+ console.print("[bold]Triggering pipeline...[/bold]")
447
+
448
+ try:
449
+ client = AzureDevOpsClient(config)
450
+ _trigger_and_show_result(client, execution_plan, watch)
451
+ except AzureDevOpsError as e:
452
+ console.print(f"[red]Error:[/red] {e}")
453
+ raise SystemExit(1)
454
+
455
+
456
+ @main.command("list")
457
+ def list_cmd() -> None:
458
+ """List all available pipeline aliases."""
459
+ table = Table(title="Available Pipeline Aliases")
460
+ table.add_column("Alias", style="cyan")
461
+ table.add_column("Pipeline Name", style="green")
462
+ table.add_column("Parameters", style="dim")
463
+ table.add_column("Description")
464
+
465
+ for pipeline in list_pipelines():
466
+ params = ", ".join(p.name for p in pipeline.parameters) or "-"
467
+ table.add_row(
468
+ pipeline.alias,
469
+ pipeline.name,
470
+ params,
471
+ pipeline.description,
472
+ )
473
+
474
+ console.print(table)
475
+
476
+
477
+ @main.command("list-remote")
478
+ def list_remote_cmd() -> None:
479
+ """List all pipelines from Azure DevOps."""
480
+ try:
481
+ client = AzureDevOpsClient(_require_config())
482
+ pipelines = client.list_pipelines()
483
+
484
+ table = Table(title="Azure DevOps Pipelines")
485
+ table.add_column("ID", style="dim")
486
+ table.add_column("Name", style="cyan")
487
+ table.add_column("Folder", style="dim")
488
+
489
+ for p in sorted(pipelines, key=lambda x: x.get("name", "")):
490
+ table.add_row(
491
+ str(p.get("id", "")),
492
+ p.get("name", ""),
493
+ p.get("folder", "\\"),
494
+ )
495
+
496
+ console.print(table)
497
+ console.print(f"\n[dim]Total: {len(pipelines)} pipelines[/dim]")
498
+
499
+ except AzureDevOpsError as e:
500
+ console.print(f"[red]Error:[/red] {e}")
501
+ raise SystemExit(1)
502
+
503
+
504
+ @main.command()
505
+ @click.option("--top", "-n", default=10, help="Number of builds to show.")
506
+ @click.option(
507
+ "--pipeline", "-p", "pipeline_alias",
508
+ shell_complete=_complete_pipeline_alias,
509
+ help="Filter by pipeline alias.",
510
+ )
511
+ @click.option("--mine", "-m", is_flag=True, help="Show only my builds.")
512
+ def status(top: int, pipeline_alias: str | None, mine: bool) -> None:
513
+ """Show recent build status.
514
+
515
+ Examples:
516
+
517
+ ado-pipeline status
518
+
519
+ ado-pipeline status -n 20
520
+
521
+ ado-pipeline status -p android-dev
522
+
523
+ ado-pipeline status --mine
524
+ """
525
+ try:
526
+ client = _get_client()
527
+
528
+ pipeline_id = None
529
+ if pipeline_alias:
530
+ try:
531
+ pipeline = get_pipeline(pipeline_alias)
532
+ pipeline_id = client.get_pipeline_id(pipeline.name)
533
+ except PipelineNotFoundError as e:
534
+ _show_pipeline_not_found(e)
535
+ raise SystemExit(1)
536
+
537
+ requested_for = None
538
+ if mine:
539
+ try:
540
+ requested_for = client.get_current_user()
541
+ if not requested_for:
542
+ console.print("[yellow]Warning:[/yellow] Could not determine current user.")
543
+ except AzureDevOpsError:
544
+ console.print("[yellow]Warning:[/yellow] Could not determine current user.")
545
+ requested_for = None
546
+
547
+ runs = client.list_runs(
548
+ pipeline_id=pipeline_id,
549
+ top=top,
550
+ requested_for=requested_for,
551
+ )
552
+
553
+ if not runs:
554
+ console.print("[dim]No builds found.[/dim]")
555
+ return
556
+
557
+ table = Table(title="Recent Builds" + (" (mine)" if mine else ""))
558
+ table.add_column("ID", style="dim")
559
+ table.add_column("Name", style="cyan")
560
+ table.add_column("Status")
561
+ table.add_column("Requested By", style="dim")
562
+
563
+ for run in runs:
564
+ table.add_row(
565
+ str(run.run_id),
566
+ run.name,
567
+ _format_state(run.state, run.result),
568
+ run.requested_by if run.requested_by else "-",
569
+ )
570
+
571
+ console.print(table)
572
+
573
+ except AzureDevOpsError as e:
574
+ console.print(f"[red]Error:[/red] {e}")
575
+ raise SystemExit(1)
576
+
577
+
578
+ @main.command()
579
+ @click.argument("build_id", type=int)
580
+ @click.option("--follow", "-f", is_flag=True, help="Follow log output (stream).")
581
+ def logs(build_id: int, follow: bool) -> None:
582
+ """Show build logs.
583
+
584
+ BUILD_ID is the numeric build ID from 'ado-pipeline status'.
585
+
586
+ Examples:
587
+
588
+ ado-pipeline logs 12345
589
+
590
+ ado-pipeline logs 12345 -f # Follow/stream logs
591
+ """
592
+ try:
593
+ client = _get_client()
594
+
595
+ if follow:
596
+ _stream_logs(client, build_id)
597
+ else:
598
+ _show_logs(client, build_id)
599
+
600
+ except AzureDevOpsError as e:
601
+ console.print(f"[red]Error:[/red] {e}")
602
+ raise SystemExit(1)
603
+
604
+
605
+ def _show_logs(client: AzureDevOpsClient, build_id: int) -> None:
606
+ """Show all logs for a build."""
607
+ log_list = client.get_build_logs(build_id)
608
+
609
+ if not log_list:
610
+ console.print("[dim]No logs available yet.[/dim]")
611
+ return
612
+
613
+ # Get the last (most recent/relevant) log
614
+ last_log = log_list[-1]
615
+ log_content = client.get_log_content(build_id, last_log["id"])
616
+
617
+ console.print(f"[bold]Build {build_id} - Log {last_log['id']}[/bold]")
618
+ console.print("[dim]" + "-" * 60 + "[/dim]")
619
+ console.print(log_content)
620
+
621
+
622
+ def _stream_logs(client: AzureDevOpsClient, build_id: int) -> None:
623
+ """Stream logs for a running build."""
624
+ console.print(f"[bold]Streaming logs for build {build_id}...[/bold]")
625
+ console.print("[dim]Press Ctrl+C to stop[/dim]")
626
+ console.print()
627
+
628
+ # Track line count per log ID to handle multiple logs correctly
629
+ log_line_counts: dict[int, int] = {}
630
+
631
+ try:
632
+ while True:
633
+ log_list = client.get_build_logs(build_id)
634
+
635
+ for log in log_list:
636
+ log_id = log["id"]
637
+ content = client.get_log_content(build_id, log_id)
638
+ lines = content.strip().split("\n") if content.strip() else []
639
+
640
+ # Get last seen line count for this specific log
641
+ last_count = log_line_counts.get(log_id, 0)
642
+
643
+ # Print only new lines for this log
644
+ for line in lines[last_count:]:
645
+ console.print(line)
646
+
647
+ log_line_counts[log_id] = len(lines)
648
+
649
+ # Check if build is complete
650
+ runs = client.list_runs(top=50)
651
+ build_run = next((r for r in runs if r.run_id == build_id), None)
652
+ if build_run and build_run.is_completed:
653
+ console.print()
654
+ console.print(f"[bold]Build {_format_state(build_run.state, build_run.result)}[/bold]")
655
+ break
656
+
657
+ time.sleep(3)
658
+
659
+ except KeyboardInterrupt:
660
+ console.print("\n[yellow]Stopped streaming.[/yellow]")
661
+
662
+
663
+ @main.command()
664
+ @click.argument("build_id", type=int)
665
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
666
+ def cancel(build_id: int, yes: bool) -> None:
667
+ """Cancel a running build.
668
+
669
+ BUILD_ID is the numeric build ID from 'ado-pipeline status'.
670
+
671
+ Examples:
672
+
673
+ ado-pipeline cancel 12345
674
+
675
+ ado-pipeline cancel 12345 -y # Skip confirmation
676
+ """
677
+ try:
678
+ client = _get_client()
679
+
680
+ if not yes:
681
+ if not click.confirm(f"Cancel build {build_id}?"):
682
+ console.print("[yellow]Aborted.[/yellow]")
683
+ raise SystemExit(0)
684
+
685
+ client.cancel_build(build_id)
686
+ console.print(f"[green]Build {build_id} cancellation requested.[/green]")
687
+
688
+ except AzureDevOpsError as e:
689
+ console.print(f"[red]Error:[/red] {e}")
690
+ raise SystemExit(1)
691
+
692
+
693
+ def _parse_duration(start: str | None, finish: str | None) -> str:
694
+ """Parse start/finish times and return duration string."""
695
+ if not start or not finish:
696
+ return "-"
697
+ try:
698
+ from datetime import datetime
699
+
700
+ # Parse ISO format timestamps
701
+ start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
702
+ finish_dt = datetime.fromisoformat(finish.replace("Z", "+00:00"))
703
+ duration = finish_dt - start_dt
704
+ minutes, seconds = divmod(int(duration.total_seconds()), 60)
705
+ hours, minutes = divmod(minutes, 60)
706
+ if hours > 0:
707
+ return f"{hours}h {minutes}m {seconds}s"
708
+ elif minutes > 0:
709
+ return f"{minutes}m {seconds}s"
710
+ return f"{seconds}s"
711
+ except Exception:
712
+ return "-"
713
+
714
+
715
+ @main.command("diff")
716
+ @click.argument("build_id_1", type=int)
717
+ @click.argument("build_id_2", type=int)
718
+ def diff_cmd(build_id_1: int, build_id_2: int) -> None:
719
+ """Compare two builds.
720
+
721
+ BUILD_ID_1 and BUILD_ID_2 are numeric build IDs from 'ado-pipeline status'.
722
+
723
+ Examples:
724
+
725
+ ado-pipeline diff 12345 12346
726
+ """
727
+ try:
728
+ client = _get_client()
729
+
730
+ build1 = client.get_build(build_id_1)
731
+ build2 = client.get_build(build_id_2)
732
+
733
+ console.print()
734
+ console.print(f"[bold]Comparing builds {build_id_1} vs {build_id_2}[/bold]")
735
+ console.print()
736
+
737
+ table = Table(show_header=True, box=None)
738
+ table.add_column("Property", style="bold")
739
+ table.add_column(f"Build {build_id_1}", style="cyan")
740
+ table.add_column(f"Build {build_id_2}", style="green")
741
+
742
+ # Pipeline
743
+ pipeline1 = build1.get("definition", {}).get("name", "-")
744
+ pipeline2 = build2.get("definition", {}).get("name", "-")
745
+ diff_style = "" if pipeline1 == pipeline2 else "[yellow]"
746
+ table.add_row(
747
+ "Pipeline",
748
+ f"{diff_style}{pipeline1}[/]" if diff_style else pipeline1,
749
+ f"{diff_style}{pipeline2}[/]" if diff_style else pipeline2,
750
+ )
751
+
752
+ # Branch
753
+ branch1 = build1.get("sourceBranch", "-").replace("refs/heads/", "")
754
+ branch2 = build2.get("sourceBranch", "-").replace("refs/heads/", "")
755
+ diff_style = "" if branch1 == branch2 else "[yellow]"
756
+ table.add_row(
757
+ "Branch",
758
+ f"{diff_style}{branch1}[/]" if diff_style else branch1,
759
+ f"{diff_style}{branch2}[/]" if diff_style else branch2,
760
+ )
761
+
762
+ # Result
763
+ result1 = build1.get("result", build1.get("status", "-"))
764
+ result2 = build2.get("result", build2.get("status", "-"))
765
+ table.add_row(
766
+ "Result",
767
+ _format_state(build1.get("status", ""), result1),
768
+ _format_state(build2.get("status", ""), result2),
769
+ )
770
+
771
+ # Duration
772
+ duration1 = _parse_duration(
773
+ build1.get("startTime"),
774
+ build1.get("finishTime"),
775
+ )
776
+ duration2 = _parse_duration(
777
+ build2.get("startTime"),
778
+ build2.get("finishTime"),
779
+ )
780
+ table.add_row("Duration", duration1, duration2)
781
+
782
+ # Requested by
783
+ user1 = build1.get("requestedFor", {}).get("displayName", "-")
784
+ user2 = build2.get("requestedFor", {}).get("displayName", "-")
785
+ table.add_row("Requested by", user1, user2)
786
+
787
+ # Template parameters (if available)
788
+ params1 = build1.get("templateParameters", {})
789
+ params2 = build2.get("templateParameters", {})
790
+ all_params = set(params1.keys()) | set(params2.keys())
791
+
792
+ if all_params:
793
+ table.add_row("", "", "") # Spacer
794
+ table.add_row("[bold]Parameters[/bold]", "", "")
795
+ for param in sorted(all_params):
796
+ val1 = str(params1.get(param, "-"))
797
+ val2 = str(params2.get(param, "-"))
798
+ diff_style = "" if val1 == val2 else "[yellow]"
799
+ table.add_row(
800
+ f" {param}",
801
+ f"{diff_style}{val1}[/]" if diff_style else val1,
802
+ f"{diff_style}{val2}[/]" if diff_style else val2,
803
+ )
804
+
805
+ console.print(table)
806
+
807
+ except AzureDevOpsError as e:
808
+ console.print(f"[red]Error:[/red] {e}")
809
+ raise SystemExit(1)
810
+
811
+
812
+ @main.command("open")
813
+ @click.argument("build_id", type=int)
814
+ def open_cmd(build_id: int) -> None:
815
+ """Open a build in the browser.
816
+
817
+ BUILD_ID is the numeric build ID from 'ado-pipeline status'.
818
+
819
+ Examples:
820
+
821
+ ado-pipeline open 12345
822
+ """
823
+ try:
824
+ client = _get_client()
825
+ build_data = client.get_build(build_id)
826
+ web_url = build_data.get("_links", {}).get("web", {}).get("href")
827
+
828
+ if not web_url:
829
+ console.print(f"[red]Error:[/red] No URL available for build {build_id}.")
830
+ raise SystemExit(1)
831
+
832
+ console.print(f"Opening build {build_id} in browser...")
833
+ webbrowser.open(web_url)
834
+
835
+ except AzureDevOpsError as e:
836
+ console.print(f"[red]Error:[/red] {e}")
837
+ raise SystemExit(1)
838
+
839
+
840
+ @main.group()
841
+ def config() -> None:
842
+ """Manage CLI configuration."""
843
+ pass
844
+
845
+
846
+ @config.command("init")
847
+ @click.option(
848
+ "--organization", "-o",
849
+ prompt="Azure DevOps Organization",
850
+ help="Your Azure DevOps organization name.",
851
+ )
852
+ @click.option(
853
+ "--project", "-p",
854
+ prompt="Azure DevOps Project",
855
+ help="Your Azure DevOps project name.",
856
+ )
857
+ @click.option(
858
+ "--pat",
859
+ prompt="Personal Access Token",
860
+ hide_input=True,
861
+ help="Your Azure DevOps PAT with pipeline read/execute permissions.",
862
+ )
863
+ def config_init(organization: str, project: str, pat: str) -> None:
864
+ """Initialize configuration with your Azure DevOps credentials."""
865
+ cfg = init_config(
866
+ organization=organization,
867
+ project=project,
868
+ pat=pat,
869
+ )
870
+ console.print()
871
+ console.print("[green]Configuration saved![/green]")
872
+ console.print(f" File: {CONFIG_FILE}")
873
+ console.print(f" Organization: {cfg.organization}")
874
+ console.print(f" Project: {cfg.project}")
875
+ console.print(f" PAT: {'*' * 8}...{'*' * 4}")
876
+ console.print()
877
+ console.print("[dim]Next steps:[/dim]")
878
+ console.print(" 1. Add pipelines: ado-pipeline pipeline import")
879
+ console.print(" 2. Or manually: ado-pipeline pipeline add <alias> <name>")
880
+
881
+
882
+ @config.command("show")
883
+ def config_show() -> None:
884
+ """Show current configuration."""
885
+ cfg = Config.load()
886
+
887
+ if not cfg.is_configured():
888
+ missing = cfg.get_missing_fields()
889
+ console.print("[yellow]Configuration incomplete.[/yellow]")
890
+ console.print(f"Missing: {', '.join(missing)}")
891
+ console.print("Run 'ado-pipeline config init' to set up.")
892
+ return
893
+
894
+ console.print(f"[bold]Config file:[/bold] {CONFIG_FILE}")
895
+ console.print(f" Organization: {cfg.organization}")
896
+ console.print(f" Project: {cfg.project}")
897
+ if cfg.repository:
898
+ console.print(f" Repository: {cfg.repository}")
899
+ console.print(f" PAT: {'*' * 8}...{'*' * 4} (configured)")
900
+
901
+ # Show pipeline count
902
+ pipelines_cfg = PipelinesConfig.load()
903
+ console.print()
904
+ console.print(f"[bold]Pipelines file:[/bold] {PIPELINES_FILE}")
905
+ console.print(f" Configured pipelines: {len(pipelines_cfg.pipelines)}")
906
+
907
+
908
+ @main.group()
909
+ def pipeline() -> None:
910
+ """Manage pipeline configurations."""
911
+ pass
912
+
913
+
914
+ @pipeline.command("add")
915
+ @click.argument("alias")
916
+ @click.argument("pipeline_name")
917
+ @click.option("--description", "-d", default="", help="Pipeline description.")
918
+ def pipeline_add(alias: str, pipeline_name: str, description: str) -> None:
919
+ """Add a pipeline alias.
920
+
921
+ ALIAS is the short name you'll use (e.g., 'android-dev').
922
+ PIPELINE_NAME is the actual Azure DevOps pipeline name.
923
+
924
+ Examples:
925
+
926
+ ado-pipeline pipeline add android-dev Build_Android_Dev
927
+
928
+ ado-pipeline pipeline add ios-prod Build_iOS_Prod -d "iOS Production build"
929
+ """
930
+ pipelines_cfg = PipelinesConfig.load()
931
+ pipeline_cfg = PipelineConfig(
932
+ alias=alias,
933
+ name=pipeline_name,
934
+ description=description,
935
+ )
936
+ pipelines_cfg.add(pipeline_cfg)
937
+ console.print(f"[green]Pipeline '{alias}' added.[/green]")
938
+ console.print(f" Name: {pipeline_name}")
939
+ if description:
940
+ console.print(f" Description: {description}")
941
+
942
+
943
+ @pipeline.command("remove")
944
+ @click.argument("alias")
945
+ def pipeline_remove(alias: str) -> None:
946
+ """Remove a pipeline alias.
947
+
948
+ Examples:
949
+
950
+ ado-pipeline pipeline remove android-dev
951
+ """
952
+ pipelines_cfg = PipelinesConfig.load()
953
+ if not pipelines_cfg.remove(alias):
954
+ console.print(f"[red]Error:[/red] Pipeline '{alias}' not found.")
955
+ raise SystemExit(1)
956
+ console.print(f"[green]Pipeline '{alias}' removed.[/green]")
957
+
958
+
959
+ @pipeline.command("list")
960
+ def pipeline_list() -> None:
961
+ """List configured pipeline aliases."""
962
+ pipelines = list_pipelines()
963
+
964
+ if not pipelines:
965
+ console.print("[dim]No pipelines configured.[/dim]")
966
+ console.print("Run 'ado-pipeline pipeline import' to import from Azure DevOps.")
967
+ console.print("Or 'ado-pipeline pipeline add <alias> <name>' to add manually.")
968
+ return
969
+
970
+ table = Table(title="Configured Pipelines")
971
+ table.add_column("Alias", style="cyan")
972
+ table.add_column("Pipeline Name", style="green")
973
+ table.add_column("Description", style="dim")
974
+
975
+ for p in pipelines:
976
+ table.add_row(p.alias, p.name, p.description or "-")
977
+
978
+ console.print(table)
979
+
980
+
981
+ def _parse_yaml_parameters(yaml_content: str) -> list[dict[str, Any]]:
982
+ """Parse parameters section from Azure Pipeline YAML content."""
983
+ import re
984
+
985
+ params_match = re.search(
986
+ r'^parameters:\s*\n((?:[ \t]*-.*\n?|\s+\w+:.*\n?)+)',
987
+ yaml_content,
988
+ re.MULTILINE,
989
+ )
990
+ if not params_match:
991
+ return []
992
+
993
+ params_section = params_match.group(1)
994
+ param_pattern = re.compile(
995
+ r'-\s*name:\s*["\']?(\w+)["\']?\s*\n'
996
+ r'(?:\s*displayName:.*\n)?'
997
+ r'(?:\s*type:\s*(\w+)\s*\n)?'
998
+ r'(?:\s*default:\s*([^\n]*)\n)?'
999
+ r'(?:\s*values:\s*\n((?:\s*-[^\n]*\n)*))?',
1000
+ re.MULTILINE,
1001
+ )
1002
+
1003
+ params = []
1004
+ for match in param_pattern.finditer(params_section):
1005
+ name = match.group(1)
1006
+ param_type = match.group(2) or "string"
1007
+ default = (match.group(3) or "").strip().strip("'\"")
1008
+ values_raw = match.group(4)
1009
+ values = []
1010
+ if values_raw:
1011
+ values = [
1012
+ v.strip().lstrip("- ").strip("'\"")
1013
+ for v in values_raw.strip().split("\n")
1014
+ if v.strip()
1015
+ ]
1016
+ params.append({
1017
+ "name": name,
1018
+ "type": param_type,
1019
+ "default": default,
1020
+ "values": values,
1021
+ })
1022
+
1023
+ return params
1024
+
1025
+
1026
+ def _display_yaml_params_table(params: list[dict[str, Any]], title: str) -> None:
1027
+ """Display YAML parameters in a formatted table."""
1028
+ table = Table(title=title)
1029
+ table.add_column("Name", style="cyan")
1030
+ table.add_column("Type", style="yellow")
1031
+ table.add_column("Default", style="green")
1032
+ table.add_column("Values", style="dim")
1033
+
1034
+ for p in params:
1035
+ values_str = ", ".join(p["values"]) if p["values"] else "-"
1036
+ table.add_row(p["name"], p["type"], p["default"] or "-", values_str)
1037
+
1038
+ console.print(table)
1039
+
1040
+
1041
+ @pipeline.command("params")
1042
+ @click.argument("alias_or_name")
1043
+ def pipeline_params(alias_or_name: str) -> None:
1044
+ """Show parameters for a pipeline from Azure DevOps.
1045
+
1046
+ ALIAS_OR_NAME can be a configured alias or a pipeline name.
1047
+
1048
+ Examples:
1049
+
1050
+ ado-pipeline pipeline params android-dev
1051
+
1052
+ ado-pipeline pipeline params "Build_Android_Dev"
1053
+ """
1054
+ import os.path
1055
+ import re
1056
+
1057
+ try:
1058
+ client = _get_client()
1059
+
1060
+ # Resolve alias to pipeline name
1061
+ pipeline_name = alias_or_name
1062
+ for p in list_pipelines():
1063
+ if p.alias == alias_or_name:
1064
+ pipeline_name = p.name
1065
+ break
1066
+
1067
+ console.print(f"[bold]Fetching parameters for:[/bold] {pipeline_name}")
1068
+ console.print()
1069
+
1070
+ definition = client.get_build_definition(pipeline_name)
1071
+
1072
+ # Extract queue-time variables
1073
+ variables = definition.get("variables", {})
1074
+ queue_time_vars = [
1075
+ {"name": name, "default": info.get("value", ""), "type": "variable"}
1076
+ for name, info in variables.items()
1077
+ if info.get("allowOverride", False)
1078
+ ]
1079
+
1080
+ if queue_time_vars:
1081
+ table = Table(title="Queue-time Variables")
1082
+ table.add_column("Name", style="cyan")
1083
+ table.add_column("Default", style="green")
1084
+ table.add_column("Type", style="dim")
1085
+ for var in queue_time_vars:
1086
+ table.add_row(var["name"], var["default"] or "-", var["type"])
1087
+ console.print(table)
1088
+ else:
1089
+ console.print("[dim]No queue-time variables found.[/dim]")
1090
+
1091
+ yaml_file = definition.get("process", {}).get("yamlFilename", "")
1092
+ if not yaml_file:
1093
+ return
1094
+
1095
+ console.print()
1096
+ console.print(f"[bold]YAML file:[/bold] {yaml_file}")
1097
+
1098
+ repo_name = definition.get("repository", {}).get("name", "")
1099
+ if not repo_name:
1100
+ return
1101
+
1102
+ try:
1103
+ yaml_content = client.get_file_content(repo_name, yaml_file)
1104
+ except Exception as e:
1105
+ console.print(f"[dim]Could not fetch YAML content: {e}[/dim]")
1106
+ return
1107
+
1108
+ if not yaml_content:
1109
+ return
1110
+
1111
+ yaml_params = _parse_yaml_parameters(yaml_content)
1112
+ if yaml_params:
1113
+ console.print()
1114
+ _display_yaml_params_table(yaml_params, "YAML Template Parameters")
1115
+ return
1116
+
1117
+ # Check if pipeline extends a template
1118
+ uses_template = "extends:" in yaml_content or "template:" in yaml_content
1119
+ if not uses_template:
1120
+ console.print("[dim]No 'parameters:' section found in YAML.[/dim]")
1121
+ return
1122
+
1123
+ console.print("[yellow]Pipeline uses templates.[/yellow]")
1124
+
1125
+ template_match = re.search(r'template:\s*([^\s\n]+)', yaml_content)
1126
+ if not template_match:
1127
+ return
1128
+
1129
+ template_path = template_match.group(1).strip("'\"")
1130
+ console.print(f"[dim]Template: {template_path}[/dim]")
1131
+
1132
+ yaml_dir = os.path.dirname(yaml_file)
1133
+ full_template_path = os.path.normpath(os.path.join(yaml_dir, template_path))
1134
+
1135
+ try:
1136
+ template_content = client.get_file_content(repo_name, full_template_path)
1137
+ if template_content:
1138
+ template_params = _parse_yaml_parameters(template_content)
1139
+ if template_params:
1140
+ console.print()
1141
+ _display_yaml_params_table(template_params, "Template Parameters")
1142
+ except Exception as e:
1143
+ console.print(f"[dim]Could not fetch template: {e}[/dim]")
1144
+
1145
+ except Exception as e:
1146
+ console.print(f"[red]Error:[/red] {e}")
1147
+ raise SystemExit(1)
1148
+
1149
+
1150
+ @pipeline.command("import")
1151
+ @click.option("--all", "-a", "import_all", is_flag=True, help="Import all pipelines without prompting.")
1152
+ def pipeline_import(import_all: bool) -> None:
1153
+ """Import pipelines from Azure DevOps.
1154
+
1155
+ Fetches available pipelines and lets you select which to add as aliases.
1156
+
1157
+ Examples:
1158
+
1159
+ ado-pipeline pipeline import
1160
+
1161
+ ado-pipeline pipeline import --all
1162
+ """
1163
+ try:
1164
+ client = _get_client()
1165
+ remote_pipelines = client.list_pipelines()
1166
+
1167
+ if not remote_pipelines:
1168
+ console.print("[yellow]No pipelines found in Azure DevOps.[/yellow]")
1169
+ return
1170
+
1171
+ console.print(f"[bold]Found {len(remote_pipelines)} pipelines in Azure DevOps:[/bold]")
1172
+ console.print()
1173
+
1174
+ pipelines_cfg = PipelinesConfig.load()
1175
+ existing_names = {p.name for p in pipelines_cfg.list_all()}
1176
+
1177
+ added = 0
1178
+ for p in sorted(remote_pipelines, key=lambda x: x.get("name", "")):
1179
+ name = p.get("name", "")
1180
+ if not name:
1181
+ continue
1182
+
1183
+ # Skip if already configured
1184
+ if name in existing_names:
1185
+ console.print(f" [dim]- {name} (already configured)[/dim]")
1186
+ continue
1187
+
1188
+ # Generate a suggested alias
1189
+ alias = name.lower().replace("_", "-").replace(" ", "-")
1190
+
1191
+ if import_all:
1192
+ # Auto-import
1193
+ pipeline_cfg = PipelineConfig(alias=alias, name=name)
1194
+ pipelines_cfg.add(pipeline_cfg)
1195
+ console.print(f" [green]+[/green] {name} -> {alias}")
1196
+ added += 1
1197
+ else:
1198
+ # Prompt for each
1199
+ if click.confirm(f" Add '{name}' as '{alias}'?", default=True):
1200
+ custom_alias = click.prompt(" Alias", default=alias)
1201
+ pipeline_cfg = PipelineConfig(alias=custom_alias, name=name)
1202
+ pipelines_cfg.add(pipeline_cfg)
1203
+ console.print(f" [green]Added![/green]")
1204
+ added += 1
1205
+
1206
+ console.print()
1207
+ if added > 0:
1208
+ console.print(f"[green]Imported {added} pipeline(s).[/green]")
1209
+ else:
1210
+ console.print("[dim]No new pipelines imported.[/dim]")
1211
+
1212
+ except AzureDevOpsError as e:
1213
+ console.print(f"[red]Error:[/red] {e}")
1214
+ raise SystemExit(1)
1215
+
1216
+
1217
+ @main.group()
1218
+ def fav() -> None:
1219
+ """Manage favorite pipeline configurations."""
1220
+ pass
1221
+
1222
+
1223
+ @fav.command("add")
1224
+ @click.argument("name")
1225
+ @click.argument("pipeline_alias", shell_complete=_complete_pipeline_alias)
1226
+ @click.option("--branch", "-b", shell_complete=_complete_branch, help="Branch to build.")
1227
+ @click.option("--deploy/--no-deploy", default=None)
1228
+ @click.option("--output-format", "-o", type=click.Choice(["apk", "aab"]))
1229
+ @click.option("--release-notes", "-r", default=None)
1230
+ @click.option("--environment", "-e", type=click.Choice(["dev", "rel", "prod"]))
1231
+ @click.option("--fail-if-no-changes/--no-fail-if-no-changes", default=None)
1232
+ @click.option("--fail-on-push-error/--no-fail-on-push-error", default=None)
1233
+ def fav_add(
1234
+ name: str,
1235
+ pipeline_alias: str,
1236
+ branch: str | None,
1237
+ deploy: bool | None,
1238
+ output_format: str | None,
1239
+ release_notes: str | None,
1240
+ environment: str | None,
1241
+ fail_if_no_changes: bool | None,
1242
+ fail_on_push_error: bool | None,
1243
+ ) -> None:
1244
+ """Save a favorite pipeline configuration.
1245
+
1246
+ NAME is the shortcut name for this favorite.
1247
+
1248
+ Examples:
1249
+
1250
+ ado-pipeline fav add my-android android-dev --deploy
1251
+
1252
+ ado-pipeline fav add quick-ios ios-dev --branch main
1253
+ """
1254
+ try:
1255
+ get_pipeline(pipeline_alias)
1256
+ except PipelineNotFoundError as e:
1257
+ _show_pipeline_not_found(e)
1258
+ raise SystemExit(1)
1259
+
1260
+ favorite = Favorite(
1261
+ name=name,
1262
+ pipeline_alias=pipeline_alias,
1263
+ branch=branch,
1264
+ deploy=deploy,
1265
+ output_format=output_format,
1266
+ release_notes=release_notes,
1267
+ environment=environment,
1268
+ fail_if_no_changes=fail_if_no_changes,
1269
+ fail_on_push_error=fail_on_push_error,
1270
+ )
1271
+
1272
+ store = FavoritesStore.load()
1273
+ store.add(favorite)
1274
+ console.print(f"[green]Favorite '{name}' saved.[/green]")
1275
+
1276
+
1277
+ @fav.command("list")
1278
+ def fav_list() -> None:
1279
+ """List all saved favorites."""
1280
+ store = FavoritesStore.load()
1281
+ favorites = store.list_all()
1282
+
1283
+ if not favorites:
1284
+ console.print("[dim]No favorites saved.[/dim]")
1285
+ console.print("Use 'ado-pipeline fav add' to save a favorite.")
1286
+ return
1287
+
1288
+ table = Table(title="Saved Favorites")
1289
+ table.add_column("Name", style="cyan")
1290
+ table.add_column("Pipeline", style="green")
1291
+ table.add_column("Branch", style="dim")
1292
+ table.add_column("Options", style="dim")
1293
+
1294
+ for fav in favorites:
1295
+ options = []
1296
+ if fav.deploy:
1297
+ options.append("deploy")
1298
+ if fav.output_format:
1299
+ options.append(f"format={fav.output_format}")
1300
+ if fav.environment:
1301
+ options.append(f"env={fav.environment}")
1302
+
1303
+ table.add_row(
1304
+ fav.name,
1305
+ fav.pipeline_alias,
1306
+ fav.branch or "(current)",
1307
+ ", ".join(options) or "-",
1308
+ )
1309
+
1310
+ console.print(table)
1311
+
1312
+
1313
+ @fav.command("run")
1314
+ @click.argument("name")
1315
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
1316
+ @click.option("--watch", "-w", is_flag=True, help="Watch build progress.")
1317
+ def fav_run(name: str, yes: bool, watch: bool) -> None:
1318
+ """Run a saved favorite.
1319
+
1320
+ NAME is the shortcut name of the favorite.
1321
+
1322
+ Examples:
1323
+
1324
+ ado-pipeline fav run my-android
1325
+
1326
+ ado-pipeline fav run my-android -y -w
1327
+ """
1328
+ store = FavoritesStore.load()
1329
+ favorite = store.get(name)
1330
+
1331
+ if not favorite:
1332
+ console.print(f"[red]Error:[/red] Favorite '{name}' not found.")
1333
+ console.print("Use 'ado-pipeline fav list' to see saved favorites.")
1334
+ raise SystemExit(1)
1335
+
1336
+ try:
1337
+ pipeline = get_pipeline(favorite.pipeline_alias)
1338
+ except PipelineNotFoundError as e:
1339
+ _show_pipeline_not_found(e)
1340
+ raise SystemExit(1)
1341
+
1342
+ kwargs = _build_pipeline_kwargs(
1343
+ favorite.deploy,
1344
+ favorite.output_format,
1345
+ favorite.release_notes,
1346
+ favorite.environment,
1347
+ favorite.fail_if_no_changes,
1348
+ favorite.fail_on_push_error,
1349
+ )
1350
+
1351
+ config = _require_config()
1352
+
1353
+ try:
1354
+ execution_plan = create_plan(
1355
+ pipeline=pipeline,
1356
+ branch=favorite.branch,
1357
+ config=config,
1358
+ **kwargs,
1359
+ )
1360
+ except Exception as e:
1361
+ console.print(f"[red]Error:[/red] {e}")
1362
+ raise SystemExit(1)
1363
+
1364
+ execution_plan.display(console)
1365
+
1366
+ if not yes and not click.confirm("Do you want to trigger this pipeline?"):
1367
+ console.print("[yellow]Aborted.[/yellow]")
1368
+ raise SystemExit(0)
1369
+
1370
+ console.print()
1371
+ console.print("[bold]Triggering pipeline...[/bold]")
1372
+
1373
+ try:
1374
+ client = AzureDevOpsClient(config)
1375
+ _trigger_and_show_result(client, execution_plan, watch)
1376
+ except AzureDevOpsError as e:
1377
+ console.print(f"[red]Error:[/red] {e}")
1378
+ raise SystemExit(1)
1379
+
1380
+
1381
+ @fav.command("remove")
1382
+ @click.argument("name")
1383
+ def fav_remove(name: str) -> None:
1384
+ """Remove a saved favorite.
1385
+
1386
+ NAME is the shortcut name of the favorite.
1387
+
1388
+ Examples:
1389
+
1390
+ ado-pipeline fav remove my-android
1391
+ """
1392
+ store = FavoritesStore.load()
1393
+
1394
+ if not store.remove(name):
1395
+ console.print(f"[red]Error:[/red] Favorite '{name}' not found.")
1396
+ raise SystemExit(1)
1397
+
1398
+ console.print(f"[green]Favorite '{name}' removed.[/green]")
1399
+
1400
+
1401
+ if __name__ == "__main__":
1402
+ main()