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/__init__.py +3 -0
- ado_pipeline/api.py +281 -0
- ado_pipeline/cli.py +1402 -0
- ado_pipeline/config.py +225 -0
- ado_pipeline/favorites.py +109 -0
- ado_pipeline/pipelines.py +154 -0
- ado_pipeline/plan.py +164 -0
- adop_cli-0.1.3.dist-info/METADATA +429 -0
- adop_cli-0.1.3.dist-info/RECORD +13 -0
- adop_cli-0.1.3.dist-info/WHEEL +5 -0
- adop_cli-0.1.3.dist-info/entry_points.txt +2 -0
- adop_cli-0.1.3.dist-info/licenses/LICENSE +21 -0
- adop_cli-0.1.3.dist-info/top_level.txt +1 -0
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()
|