avr-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1424 @@
1
+ """Workflow run CLI commands."""
2
+
3
+ from avrea_cli.api_client import ApiClient
4
+ from avrea_cli.click_ext import GhGroup
5
+ from avrea_cli.config import CliConfig
6
+ from avrea_cli.display import DIM_FG
7
+ from avrea_cli.display import format_conclusion_colored
8
+ from avrea_cli.display import format_duration
9
+ from avrea_cli.display import get_console_url
10
+ from avrea_cli.display import hint as _hint
11
+ from avrea_cli.display import hyperlink
12
+ from avrea_cli.display import is_piped
13
+ from avrea_cli.display import job_url
14
+ from avrea_cli.display import notify
15
+ from avrea_cli.display import open_or_print_url
16
+ from avrea_cli.display import page_output
17
+ from avrea_cli.display import print_piped_header
18
+ from avrea_cli.display import print_piped_row
19
+ from avrea_cli.display import repo_url
20
+ from avrea_cli.display import run_url
21
+ from avrea_cli.display import status_indicator
22
+ from avrea_cli.display import terminal_title
23
+ from avrea_cli.display import truncate as _truncate
24
+ from avrea_cli.display import workflow_url
25
+ from avrea_cli.helpers import ensure_authenticated
26
+ from avrea_cli.helpers import ensure_ctx
27
+ from avrea_cli.helpers import ensure_prompts_allowed
28
+ from avrea_cli.helpers import get_org_id
29
+ from avrea_cli.helpers import get_org_slug
30
+ from avrea_cli.helpers import handle_http_error
31
+ from avrea_cli.helpers import parse_since
32
+ from avrea_cli.helpers import validate_cursor
33
+ from avrea_cli.json_output import emit_json
34
+ from avrea_cli.json_output import emit_json_record
35
+ from avrea_cli.json_output import handle_json_meta
36
+ from avrea_cli.json_output import make_schema
37
+ from avrea_cli.json_output import reject_web_with_json
38
+ from avrea_cli.json_output import split_fields
39
+ from avrea_cli.log_display import fetch_all_logs
40
+ from avrea_cli.log_display import follow_logs
41
+ from avrea_cli.log_display import print_failed_step_logs
42
+ from avrea_cli.log_display import print_logs_grouped
43
+ from avrea_cli.output import format_key_value
44
+ from avrea_cli.output import format_relative_timestamp
45
+ from avrea_cli.repo_context import resolve_repos_or_detect
46
+ from datetime import UTC
47
+ from datetime import datetime
48
+ from typing import Any
49
+ import click
50
+ import httpx
51
+ import json
52
+ import sys
53
+ import time
54
+ import webbrowser
55
+
56
+ # Values accepted by --status on run/job commands. Names are agnostic of
57
+ # entity (a job and a run share the same state/conclusion vocabulary on the
58
+ # upstream API), so `avr job list --status failure` resolves through the
59
+ # same partition function as `avr run list --status failure`.
60
+ _PLATFORM_STATES = frozenset({"queued", "in_progress", "completed"})
61
+ _PLATFORM_CONCLUSIONS = frozenset(
62
+ {
63
+ "success",
64
+ "failure",
65
+ "neutral",
66
+ "cancelled",
67
+ "skipped",
68
+ "timed_out",
69
+ "action_required",
70
+ "stale",
71
+ "startup_failure",
72
+ }
73
+ )
74
+
75
+
76
+ _RUN_LIST_FIELDS = make_schema(
77
+ "run_id",
78
+ "platform_run_id",
79
+ "display_title",
80
+ "status",
81
+ "conclusion",
82
+ "head_branch",
83
+ "head_sha",
84
+ "event",
85
+ "run_number",
86
+ "run_attempt",
87
+ "duration_seconds",
88
+ "created_at",
89
+ "updated_at",
90
+ "workflow_id",
91
+ "workflow",
92
+ "repository",
93
+ "triggering_actor",
94
+ )
95
+
96
+ # View is a strict superset — adding to list propagates here. Split back to
97
+ # a hand-maintained mapping if you ever need a list-only entry.
98
+ _RUN_VIEW_FIELDS = {**_RUN_LIST_FIELDS, **make_schema("jobs")}
99
+
100
+
101
+ _STATUS_CHOICES = sorted(_PLATFORM_STATES | _PLATFORM_CONCLUSIONS)
102
+ """Unified --status validator used by run/job list. Use as
103
+ ``type=click.Choice(_STATUS_CHOICES, case_sensitive=False)`` on the
104
+ option — Click handles validation + tab completion + the auto-generated
105
+ error message, leaving ``_split_status_values`` to just partition the
106
+ known-good values."""
107
+
108
+
109
+ def _split_status_values(values: tuple[str, ...]) -> tuple[list[str], list[str]]:
110
+ """Partition unified --status values into (platform_statuses, platform_conclusions).
111
+
112
+ Caller must validate via ``click.Choice(_STATUS_CHOICES)`` — this
113
+ function trusts every value is already a known state or conclusion.
114
+ """
115
+ states: list[str] = []
116
+ conclusions: list[str] = []
117
+ for v in values:
118
+ vl = v.lower()
119
+ if vl in _PLATFORM_STATES:
120
+ states.append(vl)
121
+ else:
122
+ conclusions.append(vl)
123
+ return states, conclusions
124
+
125
+
126
+ @click.group(cls=GhGroup)
127
+ @click.pass_context
128
+ def run(ctx):
129
+ """View and manage GitHub workflow runs."""
130
+ ensure_ctx(ctx)
131
+
132
+
133
+ @run.command("list")
134
+ @click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
135
+ @click.option(
136
+ "--repo",
137
+ "repository_ids",
138
+ multiple=True,
139
+ help="Filter by repository (org/repo or rep-xxx, repeatable). Auto-detected from git remote if omitted.",
140
+ )
141
+ @click.option(
142
+ "--status",
143
+ "status_values",
144
+ type=click.Choice(_STATUS_CHOICES, case_sensitive=False),
145
+ multiple=True,
146
+ help="Filter by state (queued, in_progress, completed) or conclusion (success, failure, ...). Repeatable.",
147
+ )
148
+ @click.option("--branch", "head_branches", multiple=True, help="Filter by head branch (repeatable).")
149
+ @click.option("-w", "--workflow", "workflow_ids", multiple=True, help="Filter by workflow ID (wfl-xxx, repeatable).")
150
+ @click.option("--since", default=None, help="Relative time window: '7d', '24h', etc. Sugar for --created-after.")
151
+ @click.option(
152
+ "--from",
153
+ "--created-after",
154
+ "created_after",
155
+ default=None,
156
+ help="Only runs created after this ISO timestamp.",
157
+ )
158
+ @click.option(
159
+ "--to",
160
+ "--created-before",
161
+ "created_before",
162
+ default=None,
163
+ help="Only runs created before this ISO timestamp.",
164
+ )
165
+ @click.option("-L", "--limit", type=click.IntRange(1, 1000), default=20, show_default=True, help="Max runs to return.")
166
+ @click.option("--cursor", default=None, help="Pagination cursor from a previous response.")
167
+ @click.option(
168
+ "--order",
169
+ type=click.Choice(["created_at.desc", "created_at.asc"]),
170
+ default="created_at.desc",
171
+ show_default=True,
172
+ help="Sort order.",
173
+ )
174
+ @click.option(
175
+ "--json",
176
+ "json_fields",
177
+ default=None,
178
+ help='Output JSON. Pass comma-separated field names, "*" for all fields, or "?" to list available fields.',
179
+ )
180
+ @click.option("-q", "--jq", "jq_expr", default=None, help="Filter --json output through a jq expression.")
181
+ @click.option("--web", is_flag=True, help="Open in browser.")
182
+ @click.pass_context
183
+ def run_list(
184
+ ctx,
185
+ org_id,
186
+ repository_ids,
187
+ status_values,
188
+ head_branches,
189
+ workflow_ids,
190
+ since,
191
+ created_after,
192
+ created_before,
193
+ limit,
194
+ cursor,
195
+ order,
196
+ json_fields,
197
+ jq_expr,
198
+ web: bool,
199
+ ):
200
+ """List workflow runs for an organization.
201
+
202
+ \b
203
+ Examples:
204
+ avr run list
205
+ avr run list --status failure --limit 5
206
+ avr run list --status in_progress --json status,conclusion,head_branch
207
+ avr run list --branch main --status completed
208
+ avr run list --since 24h
209
+ avr run list --json '?' # list available fields
210
+ avr run list --json '*' # all fields
211
+ avr run list --json status,conclusion -q '[.[] | select(.status == "completed")]'
212
+
213
+ \b
214
+ JSON FIELDS
215
+ conclusion, created_at, display_title, duration_seconds, event,
216
+ head_branch, head_sha, platform_run_id, repository, run_attempt, run_id,
217
+ run_number, status, triggering_actor, updated_at, workflow, workflow_id
218
+ """
219
+ reject_web_with_json(json_fields, web)
220
+ if handle_json_meta(json_fields, jq_expr, _RUN_LIST_FIELDS):
221
+ return
222
+ client: ApiClient = ctx.obj["client"]
223
+ config: CliConfig = ctx.obj["config"]
224
+ ensure_authenticated(config)
225
+
226
+ org_id = get_org_id(config, org_id, client=client)
227
+
228
+ if web:
229
+ slug = get_org_slug(client, org_id)
230
+ console_url = get_console_url(config.public_api_url)
231
+ url = f"{console_url}/org/{slug}?view=workflows"
232
+ open_or_print_url(url)
233
+ return
234
+
235
+ repository_ids = tuple(resolve_repos_or_detect(client, config, org_id, repository_ids, soft_detect=True))
236
+
237
+ # --since is sugar for --created-after; reject double-spec rather than
238
+ # silently picking one — accidental combinations are usually mistakes.
239
+ if since and (created_after or created_before):
240
+ raise click.UsageError("--since cannot be combined with --created-after or --created-before")
241
+ if since:
242
+ created_after = parse_since(since).isoformat()
243
+
244
+ if created_after:
245
+ try:
246
+ created_after = datetime.fromisoformat(created_after).isoformat()
247
+ except ValueError as err:
248
+ click.echo("Error: --created-after must be a valid ISO-8601 timestamp", err=True)
249
+ raise click.Abort() from err
250
+ if created_before:
251
+ try:
252
+ created_before = datetime.fromisoformat(created_before).isoformat()
253
+ except ValueError as err:
254
+ click.echo("Error: --created-before must be a valid ISO-8601 timestamp", err=True)
255
+ raise click.Abort() from err
256
+
257
+ platform_statuses, platform_conclusions = _split_status_values(status_values)
258
+ cursor = validate_cursor(cursor)
259
+
260
+ params: dict[str, Any] = {"limit": limit, "order": order, "include": ["workflow"]}
261
+ if repository_ids:
262
+ params["repository_ids"] = list(repository_ids)
263
+ if platform_statuses:
264
+ params["statuses"] = platform_statuses
265
+ if platform_conclusions:
266
+ params["conclusions"] = platform_conclusions
267
+ if head_branches:
268
+ params["head_branches"] = list(head_branches)
269
+ if workflow_ids:
270
+ params["workflow_ids"] = list(workflow_ids)
271
+ if created_after:
272
+ params["created_after"] = created_after
273
+ if created_before:
274
+ params["created_before"] = created_before
275
+ if cursor:
276
+ params["cursor"] = cursor
277
+
278
+ try:
279
+ response = client.public_get(f"/orgs/{org_id}/workflow-runs", params=params)
280
+ runs = response.get("data") or []
281
+ except httpx.HTTPStatusError as exc:
282
+ handle_http_error(exc, "list workflow runs")
283
+
284
+ if json_fields is not None:
285
+ emit_json(list(runs), split_fields(json_fields, _RUN_LIST_FIELDS), _RUN_LIST_FIELDS, jq_expr)
286
+ return
287
+
288
+ if not runs:
289
+ click.echo("No workflow runs found.")
290
+ if since or repository_ids or workflow_ids:
291
+ click.echo(
292
+ click.style(
293
+ " Try: --since 90d, drop --repo/--workflow, or check `avr run list --json '*' --jq '. | length'`.",
294
+ dim=True,
295
+ ),
296
+ err=True,
297
+ )
298
+ return
299
+
300
+ if is_piped():
301
+ # Tab-separated output for cut/awk/grep, with a header row so scripts
302
+ # can index by column name rather than memorizing positional order.
303
+ print_piped_header(
304
+ ["status", "title", "workflow", "branch", "event", "run_id", "duration_seconds", "created_at"]
305
+ )
306
+ for r in runs:
307
+ status = r.get("conclusion") or r.get("status") or "unknown"
308
+ print_piped_row(
309
+ [
310
+ status,
311
+ r.get("display_title", ""),
312
+ (r.get("workflow") or {}).get("name", ""),
313
+ r.get("head_branch") or "",
314
+ r.get("event", ""),
315
+ r.get("run_id", ""),
316
+ r.get("duration_seconds"),
317
+ r.get("created_at", ""),
318
+ ]
319
+ )
320
+ return
321
+
322
+ W = {"title": 50, "wf": 30, "branch": 30, "event": 18, "elapsed": 12, "age": 12, "id": 36}
323
+ s = " "
324
+
325
+ def _hdr(label: str, width: int) -> str:
326
+ return click.style(f"{label:{width}s}", fg=DIM_FG, underline=True)
327
+
328
+ click.echo(
329
+ f" {_hdr('TITLE', W['title'])}{s}{_hdr('WORKFLOW', W['wf'])}{s}{_hdr('BRANCH', W['branch'])}{s}"
330
+ f"{_hdr('EVENT', W['event'])}{s}{_hdr('ID', W['id'])}{s}"
331
+ f"{_hdr('ELAPSED', W['elapsed'])}{s}{_hdr('AGE', W['age'])}"
332
+ )
333
+ # OSC 8 link context — slug lookup is best-effort, one extra API call.
334
+ links_enabled = ctx.obj.get("links_enabled", False)
335
+ console_url = get_console_url(config.public_api_url) if links_enabled else ""
336
+ slug = get_org_slug(client, org_id) if links_enabled else ""
337
+ for r in runs:
338
+ ind = status_indicator(r.get("status", "unknown"), r.get("conclusion"))
339
+ title = f"{_truncate(r.get('display_title', ''), W['title'] - 2):{W['title']}s}"
340
+ wf = f"{_truncate((r.get('workflow') or {}).get('name', ''), W['wf'] - 2):{W['wf']}s}"
341
+ head_branch = r.get("head_branch") or ""
342
+ br = f"{_truncate(head_branch, W['branch'] - 2):{W['branch']}s}"
343
+ event = f"{r.get('event', ''):{W['event']}s}"
344
+ elapsed = f"{format_duration(r.get('duration_seconds')):>{W['elapsed']}s}"
345
+ age = f"{format_relative_timestamp(r.get('created_at')):>{W['age']}s}"
346
+ r_id = r.get("run_id", "")
347
+ repo_full = (r.get("repository") or {}).get("full_name", "")
348
+ # Pad-then-style — f-string width counts ANSI escape bytes,
349
+ # breaking column alignment if applied first.
350
+ title_cell = title
351
+ wf_cell = click.style(wf, fg="magenta")
352
+ br_cell = click.style(br, bold=True)
353
+ r_id_cell = click.style(f"{r_id:{W['id']}s}", fg="cyan")
354
+ if links_enabled and r_id:
355
+ url = run_url(console_url, slug, r_id)
356
+ title_cell = hyperlink(title, url)
357
+ wf_cell = hyperlink(wf_cell, url)
358
+ r_id_cell = hyperlink(r_id_cell, url)
359
+ if links_enabled and head_branch and repo_full and "/" in repo_full:
360
+ br_cell = hyperlink(br_cell, f"https://github.com/{repo_full}/tree/{head_branch}")
361
+ click.echo(
362
+ f"{ind} {title_cell}{s}"
363
+ f"{wf_cell}{s}"
364
+ f"{br_cell}{s}"
365
+ f"{event}{s}{r_id_cell}{s}"
366
+ f"{elapsed}{s}{click.style(age, dim=True)}"
367
+ )
368
+
369
+ next_cursor = response.get("pagination", {}).get("next_cursor")
370
+ if next_cursor:
371
+ click.echo(f"\nMore results available. Next page: --cursor {next_cursor}", err=True)
372
+
373
+
374
+ def _print_run_jobs(
375
+ jobs_list: list[dict[str, Any]],
376
+ *,
377
+ verbose: bool = False,
378
+ console_url: str = "",
379
+ slug: str = "",
380
+ ) -> None:
381
+ """Print job list with status indicators. If verbose, include steps.
382
+
383
+ When ``console_url`` and ``slug`` are non-empty, each job_name is wrapped
384
+ in an OSC 8 hyperlink to the console job page. Caller is responsible for
385
+ only passing them when ``ctx.obj['links_enabled']`` is true.
386
+
387
+ Returns early on empty input so callers can call unconditionally without
388
+ the awkward 'JOBS' header above an empty section."""
389
+ if not jobs_list:
390
+ return
391
+ click.echo()
392
+ click.echo(click.style("JOBS", bold=True, fg="bright_white"))
393
+ click.echo()
394
+ for j in jobs_list:
395
+ j_state = j.get("state", "unknown")
396
+ j_conclusion = j.get("conclusion")
397
+ j_indicator = status_indicator(j_state, j_conclusion)
398
+ j_dur = format_duration(j.get("duration_seconds"))
399
+ if j_state == "in_progress":
400
+ started = j.get("started_at")
401
+ if started:
402
+ try:
403
+ started_dt = datetime.fromisoformat(started.replace("Z", "+00:00"))
404
+ elapsed = (datetime.now(UTC) - started_dt).total_seconds()
405
+ j_dur = f"running {format_duration(elapsed)}"
406
+ except ValueError, TypeError:
407
+ j_dur = "running"
408
+ else:
409
+ j_dur = "running"
410
+ j_name = j.get("job_name", "?")
411
+ j_id = j.get("job_id") or ""
412
+ j_id_cell = click.style(j_id, fg="cyan") if j_id else ""
413
+ if console_url and slug and j_id:
414
+ url = job_url(console_url, slug, j_id)
415
+ j_name = hyperlink(j_name, url)
416
+ j_id_cell = hyperlink(j_id_cell, url)
417
+ suffix = f" {j_id_cell}" if j_id_cell else ""
418
+ click.echo(f" {j_indicator} {j_name} ({j_dur}){suffix}")
419
+
420
+ if verbose:
421
+ for step in j.get("steps") or []:
422
+ s_status = step.get("status", "pending")
423
+ s_conclusion = step.get("conclusion")
424
+ s_indicator = status_indicator(s_status, s_conclusion)
425
+ s_dur = ""
426
+ if step.get("started_at") and step.get("completed_at"):
427
+ try:
428
+ s_started = datetime.fromisoformat(step["started_at"].replace("Z", "+00:00"))
429
+ s_completed = datetime.fromisoformat(step["completed_at"].replace("Z", "+00:00"))
430
+ s_dur = f" ({format_duration((s_completed - s_started).total_seconds())})"
431
+ except ValueError, TypeError:
432
+ pass
433
+ click.echo(f" {s_indicator} {step.get('name', '?')}{s_dur}")
434
+ click.echo("")
435
+
436
+
437
+ @run.command("view")
438
+ @click.argument("run_id", required=False)
439
+ @click.option("--org", "org_id", help="Organization ID.")
440
+ @click.option(
441
+ "--steps",
442
+ "show_steps",
443
+ is_flag=True,
444
+ help="Expand each job to show its individual steps.",
445
+ )
446
+ @click.option("--log", "show_log", is_flag=True, help="Print full logs for all jobs.")
447
+ @click.option("--log-failed", is_flag=True, help="Print logs only for failed steps.")
448
+ @click.option("--job", "job_name_filter", help="Restrict view and logs to jobs whose name contains this string.")
449
+ @click.option(
450
+ "--json",
451
+ "json_fields",
452
+ default=None,
453
+ help='Output JSON. Pass comma-separated field names, "*" for all fields, or "?" to list available fields.',
454
+ )
455
+ @click.option("-q", "--jq", "jq_expr", default=None, help="Filter --json output through a jq expression.")
456
+ @click.option("--web", is_flag=True, help="Open in browser.")
457
+ @click.option(
458
+ "--no-pager",
459
+ is_flag=True,
460
+ help="Print logs directly to stdout instead of paging through `less`. Same as setting AVR_PAGER=''.",
461
+ )
462
+ @click.pass_context
463
+ def run_view(
464
+ ctx,
465
+ run_id: str | None,
466
+ org_id,
467
+ show_steps: bool,
468
+ show_log: bool,
469
+ log_failed: bool,
470
+ job_name_filter: str | None,
471
+ json_fields: str | None,
472
+ jq_expr: str | None,
473
+ web: bool,
474
+ no_pager: bool,
475
+ ):
476
+ """View a workflow run with its jobs.
477
+
478
+ \b
479
+ Without RUN_ID, shows 10 most recent runs.
480
+
481
+ \b
482
+ Examples:
483
+ avr run view
484
+ avr run view run-abc123
485
+ avr run view run-abc123 --steps
486
+ avr run view run-abc123 --log-failed
487
+ avr run view run-abc123 --job Build
488
+ avr run view run-abc123 --json conclusion,jobs
489
+ avr run view run-abc123 --json '*' --jq '.jobs[].job_name'
490
+
491
+ \b
492
+ JSON FIELDS
493
+ conclusion, created_at, display_title, duration_seconds, event,
494
+ head_branch, head_sha, jobs, platform_run_id, repository, run_attempt,
495
+ run_id, run_number, status, triggering_actor, updated_at, workflow,
496
+ workflow_id
497
+ """
498
+ reject_web_with_json(json_fields, web)
499
+ if handle_json_meta(json_fields, jq_expr, _RUN_VIEW_FIELDS):
500
+ return
501
+ client: ApiClient = ctx.obj["client"]
502
+ config: CliConfig = ctx.obj["config"]
503
+ ensure_authenticated(config)
504
+
505
+ org_id = get_org_id(config, org_id, client=client)
506
+
507
+ if not run_id:
508
+ try:
509
+ response = client.public_get(
510
+ f"/orgs/{org_id}/workflow-runs",
511
+ params={"limit": 10, "order": "created_at.desc", "include": ["jobs"]},
512
+ )
513
+ runs = response.get("data") or []
514
+ except httpx.HTTPStatusError as exc:
515
+ handle_http_error(exc, "list workflow runs")
516
+
517
+ if not runs:
518
+ click.echo("No workflow runs found.")
519
+ return
520
+
521
+ for r in runs:
522
+ r_status = r.get("status", "unknown")
523
+ r_conclusion = r.get("conclusion")
524
+ r_indicator = status_indicator(r_status, r_conclusion)
525
+ r_dur = format_duration(r.get("duration_seconds"))
526
+ repo = (r.get("repository") or {}).get("full_name", "")
527
+ title = r.get("display_title", "")
528
+ branch = r.get("head_branch") or ""
529
+ r_id = r.get("run_id", "")
530
+ created = format_relative_timestamp(r.get("created_at"))
531
+ click.echo(f" {r_indicator} {repo} {title} -- {branch} -- {r_dur} -- {created}")
532
+ click.echo(click.style(f" {r_id}", fg=DIM_FG))
533
+
534
+ click.echo("\nTo view a run, try: avr run view <run-id>", err=True)
535
+ return
536
+
537
+ include = ["jobs", "workflow"]
538
+ try:
539
+ response = client.public_get(
540
+ f"/orgs/{org_id}/workflow-runs/{run_id}",
541
+ params={"include": include},
542
+ )
543
+ run_data = response.get("data", response)
544
+ except httpx.HTTPStatusError as exc:
545
+ handle_http_error(exc, "get workflow run")
546
+
547
+ if json_fields is not None:
548
+ emit_json_record(run_data, split_fields(json_fields, _RUN_VIEW_FIELDS), _RUN_VIEW_FIELDS, jq_expr)
549
+ return
550
+
551
+ if web:
552
+ repo = (run_data.get("repository") or {}).get("full_name", "")
553
+ slug = get_org_slug(client, org_id)
554
+ console_url = get_console_url(config.public_api_url)
555
+ avrea_url = f"{console_url}/org/{slug}/runs/{run_id}"
556
+ click.echo(f"Avrea: {avrea_url}")
557
+ platform_run_id = run_data.get("platform_run_id")
558
+ run_attempt = run_data.get("run_attempt")
559
+ if repo and platform_run_id:
560
+ gh_url = f"https://github.com/{repo}/actions/runs/{platform_run_id}"
561
+ if run_attempt and run_attempt > 1:
562
+ gh_url += f"/attempts/{run_attempt}"
563
+ click.echo(f"GitHub: {gh_url}")
564
+ if sys.stdout.isatty():
565
+ webbrowser.open(avrea_url)
566
+ return
567
+
568
+ status = run_data.get("status", "unknown")
569
+ conclusion = run_data.get("conclusion")
570
+ indicator = status_indicator(status, conclusion)
571
+ conclusion_display = f"{indicator} {conclusion}" if conclusion else status
572
+
573
+ repo_data = run_data.get("repository") or {}
574
+ repo = repo_data.get("full_name", "-")
575
+ repo_id = repo_data.get("id")
576
+ head_sha_full = run_data.get("head_sha") or ""
577
+ head_sha = head_sha_full[:8]
578
+ branch = run_data.get("head_branch") or "-"
579
+ actor_data = run_data.get("triggering_actor") or {}
580
+ actor = actor_data.get("login") or actor_data.get("platform_login") or "-"
581
+ wf_data = run_data.get("workflow") or {}
582
+ wf_name = wf_data.get("name", "")
583
+ wf_path = (wf_data.get("path") or "").rsplit("/", 1)[-1] # e.g. ".github/workflows/build.yml" → "build.yml"
584
+ workflow = f"{wf_name} ({wf_path})" if wf_name and wf_path else wf_name
585
+ run_number = run_data.get("run_number", "?")
586
+ run_attempt = run_data.get("run_attempt", 1)
587
+
588
+ # OSC 8 link context. Github repos always have an `org/name` shape;
589
+ # gate platform-specific links on `/` so a future non-github repo
590
+ # skips them gracefully.
591
+ links_enabled = ctx.obj.get("links_enabled", False)
592
+ link_console_url = get_console_url(config.public_api_url) if links_enabled else ""
593
+ link_slug = get_org_slug(client, org_id) if links_enabled else ""
594
+ is_github = repo and "/" in repo
595
+
596
+ title_text = run_data.get("display_title", "-")
597
+ branch_cell = branch
598
+ commit_cell = head_sha or "-"
599
+ workflow_cell = workflow or "-"
600
+ repo_cell = repo
601
+ title_cell = title_text
602
+ if links_enabled and link_console_url and link_slug:
603
+ title_cell = hyperlink(title_text, run_url(link_console_url, link_slug, run_id))
604
+ if wf_data.get("workflow_id") and workflow:
605
+ workflow_cell = hyperlink(workflow_cell, workflow_url(link_console_url, link_slug, wf_data["workflow_id"]))
606
+ if repo_id and repo != "-":
607
+ repo_cell = hyperlink(repo_cell, repo_url(link_console_url, link_slug, repo_id))
608
+ if links_enabled and is_github:
609
+ if branch != "-":
610
+ branch_cell = hyperlink(branch_cell, f"https://github.com/{repo}/tree/{branch}")
611
+ if head_sha_full:
612
+ commit_cell = hyperlink(commit_cell, f"https://github.com/{repo}/commit/{head_sha_full}")
613
+
614
+ click.echo(
615
+ format_key_value(
616
+ {
617
+ "Title": title_cell,
618
+ "Conclusion": conclusion_display,
619
+ "Branch": branch_cell,
620
+ "Commit": commit_cell,
621
+ "Event": run_data.get("event", "-"),
622
+ "Actor": actor,
623
+ "Duration": format_duration(run_data.get("duration_seconds")),
624
+ "Run": f"#{run_number} (attempt {run_attempt})",
625
+ "Workflow": workflow_cell,
626
+ "Repository": repo_cell,
627
+ }
628
+ )
629
+ )
630
+
631
+ run_jobs = run_data.get("jobs") or []
632
+ if job_name_filter:
633
+ needle = job_name_filter.lower()
634
+ matched = [j for j in run_jobs if needle in (j.get("job_name") or "").lower()]
635
+ if not matched:
636
+ click.echo(f"No job matching '{job_name_filter}' in this run.", err=True)
637
+ return
638
+ run_jobs = matched
639
+
640
+ if run_jobs:
641
+ links_enabled = ctx.obj.get("links_enabled", False)
642
+ link_console_url = get_console_url(config.public_api_url) if links_enabled else ""
643
+ link_slug = get_org_slug(client, org_id) if links_enabled else ""
644
+ _print_run_jobs(run_jobs, verbose=show_steps, console_url=link_console_url, slug=link_slug)
645
+
646
+ if show_log or log_failed:
647
+ log_links_enabled = ctx.obj.get("links_enabled", False)
648
+ log_console_url = get_console_url(config.public_api_url) if log_links_enabled else ""
649
+ log_slug = get_org_slug(client, org_id) if log_links_enabled else ""
650
+ # OSC 133 marks omitted here: output goes through `less`, which
651
+ # doesn't strip OSC sequences, so they'd leak as visible bytes.
652
+ log_buf: list[str] = []
653
+ log_emit = log_buf.append
654
+ for j in run_jobs:
655
+ j_id = j.get("job_id")
656
+ j_steps = j.get("steps") or []
657
+ if not j_id:
658
+ continue
659
+ if log_failed and j.get("conclusion") == "success":
660
+ continue
661
+ j_url = job_url(log_console_url, log_slug, j_id) if log_console_url and log_slug else None
662
+ header = click.style(f"=== {j.get('job_name', '?')} ({j_id}) ===", bold=True)
663
+ log_emit(f"\n{'=' * 60}")
664
+ log_emit(hyperlink(header, j_url) if j_url else header)
665
+ if log_failed:
666
+ print_failed_step_logs(client, j_id, j_steps, emit=log_emit, link_url=j_url)
667
+ else:
668
+ entries = fetch_all_logs(client, j_id)
669
+ print_logs_grouped(entries, emit=log_emit, link_url=j_url)
670
+ page_output("\n".join(log_buf), bypass=no_pager)
671
+ elif run_jobs and len(run_jobs) == 1:
672
+ j_id = run_jobs[0].get("job_id", "")
673
+ click.echo(f"\nTo view the job, try: avr job view {j_id}", err=True)
674
+ elif run_jobs:
675
+ click.echo("\nTo view a job, try: avr job view <job-id>", err=True)
676
+
677
+ _print_other_attempts(client, org_id, run_id, run_data)
678
+
679
+ slug = get_org_slug(client, org_id)
680
+ console_url = get_console_url(config.public_api_url)
681
+ _hint(f"View this run on Avrea: {console_url}/org/{slug}/runs/{run_id}")
682
+
683
+
684
+ def _print_other_attempts(client: ApiClient, org_id: str, run_id: str, run_data: dict[str, Any]) -> None:
685
+ """List sibling attempts of the workflow run currently being viewed.
686
+
687
+ Each Avrea ``run_id`` represents one (workflow_run, attempt) pair, so a
688
+ re-run produces a separate row sharing the same ``platform_run_id``. List
689
+ the others so users can navigate to them without leaving the CLI.
690
+ """
691
+ platform_run_id = run_data.get("platform_run_id")
692
+ if not platform_run_id:
693
+ return
694
+ try:
695
+ response = client.public_get(
696
+ f"/orgs/{org_id}/workflow-runs",
697
+ params={"platform_run_id": platform_run_id, "limit": 10, "order": "created_at.desc"},
698
+ )
699
+ except httpx.HTTPStatusError as exc:
700
+ # Don't fail the whole `run view` — siblings are auxiliary. Surface
701
+ # the cause to stderr so users know the section is missing data.
702
+ _hint(f"(could not load other attempts: HTTP {exc.response.status_code})")
703
+ return
704
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
705
+ _hint(f"(could not load other attempts: {type(exc).__name__})")
706
+ return
707
+ siblings = [r for r in response.get("data", []) if r.get("run_id") != run_id]
708
+ if not siblings:
709
+ return
710
+ click.echo()
711
+ click.echo(click.style("OTHER ATTEMPTS", bold=True, fg="bright_white"))
712
+ click.echo()
713
+ for sib in siblings:
714
+ ind = status_indicator(sib.get("status", "unknown"), sib.get("conclusion"))
715
+ attempt_n = sib.get("run_attempt", "?")
716
+ duration = format_duration(sib.get("duration_seconds"))
717
+ sib_id = sib.get("run_id", "")
718
+ click.echo(f" {ind} attempt {attempt_n:<3} {duration:>8s} {click.style(sib_id, fg='cyan')}")
719
+
720
+
721
+ def _emit_ndjson_event(event: dict[str, Any]) -> None:
722
+ """Write a single NDJSON event to stdout. Swallows BrokenPipeError so
723
+ piping into ``head``/``jq`` exits cleanly instead of dumping a stack
724
+ trace."""
725
+ try:
726
+ sys.stdout.write(json.dumps(event, default=str) + "\n")
727
+ sys.stdout.flush()
728
+ except BrokenPipeError:
729
+ # Detach stdout so any later writes/flushes (e.g. atexit) don't try
730
+ # again and re-trigger the same error.
731
+ try:
732
+ sys.stdout.close()
733
+ except BrokenPipeError, OSError:
734
+ pass
735
+ raise SystemExit(0) from None
736
+
737
+
738
+ def _job_event_payload(event: str, run_id: str, job: dict[str, Any], timestamp: str) -> dict[str, Any]:
739
+ return {
740
+ "event": event,
741
+ "timestamp": timestamp,
742
+ "run_id": run_id,
743
+ "avrea_job_id": job.get("job_id"),
744
+ "platform_job_id": job.get("platform_job_id"),
745
+ "job_name": job.get("job_name"),
746
+ "state": job.get("state"),
747
+ "conclusion": job.get("conclusion"),
748
+ }
749
+
750
+
751
+ def watch_run_loop_ndjson(client: ApiClient, org_id: str, run_id: str, *, interval: int, exit_status: bool) -> None:
752
+ """Poll a workflow run and emit NDJSON events on state transitions.
753
+
754
+ One JSON object per line, designed for piping into automation. Three
755
+ event kinds, with intentionally different payloads:
756
+
757
+ - ``job_started`` / ``job_completed`` — emitted via ``_job_event_payload``
758
+ and share the keys ``event``, ``timestamp``, ``run_id``,
759
+ ``avrea_job_id``, ``platform_job_id``, ``job_name``, ``state``,
760
+ ``conclusion``. ``conclusion`` is ``null`` until ``job_completed``.
761
+
762
+ - ``run_completed`` — top-level run summary; carries ``event``,
763
+ ``timestamp``, ``run_id``, ``status``, ``conclusion``,
764
+ ``duration_seconds``. It deliberately omits the per-job fields —
765
+ consumers correlate via ``run_id`` to the preceding ``job_*`` events.
766
+
767
+ The shape is asymmetric on purpose: padding the run-level event with
768
+ null per-job fields would suggest those fields are sometimes populated."""
769
+ last_state: dict[str, str] = {}
770
+ consecutive_failures = 0
771
+ max_consecutive_failures = 5
772
+
773
+ def record_failure(reason: str, msg: str) -> None:
774
+ """Emit a transient_error event and abort with watch_aborted after
775
+ ``max_consecutive_failures`` strikes — bounded retry so a stuck
776
+ consumer doesn't loop forever on a dead network."""
777
+ nonlocal consecutive_failures
778
+ consecutive_failures += 1
779
+ ts = datetime.now(UTC).isoformat().replace("+00:00", "Z")
780
+ _emit_ndjson_event(
781
+ {
782
+ "event": "transient_error",
783
+ "timestamp": ts,
784
+ "run_id": run_id,
785
+ "reason": reason,
786
+ "consecutive_failures": consecutive_failures,
787
+ }
788
+ )
789
+ click.echo(msg, err=True)
790
+ if consecutive_failures >= max_consecutive_failures:
791
+ _emit_ndjson_event({"event": "watch_aborted", "timestamp": ts, "run_id": run_id, "reason": reason})
792
+ sys.exit(1)
793
+
794
+ while True:
795
+ try:
796
+ response = client.public_get(
797
+ f"/orgs/{org_id}/workflow-runs/{run_id}",
798
+ params={"include": ["jobs"]},
799
+ )
800
+ run_data = response.get("data", response)
801
+ consecutive_failures = 0
802
+ except httpx.HTTPStatusError as exc:
803
+ # 4xx is terminal (auth lost, run deleted, etc.) — same as the
804
+ # non-JSON watch loop. Looping forever on a permanent failure
805
+ # would leave the consumer hanging without a useful event stream.
806
+ if exc.response.status_code < 500 and exc.response.status_code != 429:
807
+ handle_http_error(exc, "get workflow run")
808
+ record_failure(
809
+ f"http_{exc.response.status_code}",
810
+ f"Error fetching run (HTTP {exc.response.status_code}); retrying.",
811
+ )
812
+ time.sleep(interval)
813
+ continue
814
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
815
+ record_failure(type(exc).__name__, "Couldn't reach Avrea; retrying.")
816
+ time.sleep(interval)
817
+ continue
818
+
819
+ now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
820
+ for j in run_data.get("jobs") or []:
821
+ j_id = j.get("job_id")
822
+ if not j_id:
823
+ continue
824
+ j_state = j.get("state", "unknown")
825
+ prev = last_state.get(j_id)
826
+ if prev != j_state:
827
+ last_state[j_id] = j_state
828
+ if j_state == "in_progress":
829
+ _emit_ndjson_event(_job_event_payload("job_started", run_id, j, now))
830
+ elif j_state == "completed":
831
+ _emit_ndjson_event(_job_event_payload("job_completed", run_id, j, now))
832
+
833
+ status = run_data.get("status", "unknown")
834
+ if status == "completed":
835
+ _emit_ndjson_event(
836
+ {
837
+ "event": "run_completed",
838
+ "timestamp": now,
839
+ "run_id": run_id,
840
+ "status": status,
841
+ "conclusion": run_data.get("conclusion"),
842
+ "duration_seconds": run_data.get("duration_seconds"),
843
+ }
844
+ )
845
+ if exit_status and run_data.get("conclusion") != "success":
846
+ sys.exit(1)
847
+ return
848
+
849
+ time.sleep(interval)
850
+
851
+
852
+ def _active_job_name(jobs: list[dict[str, Any]]) -> str | None:
853
+ """Pick the most informative job name for the terminal title.
854
+
855
+ Prefers in-progress jobs over queued (the user wants to know what's
856
+ *running*); ties broken by ``started_at`` so the oldest active job
857
+ surfaces first. Returns None when nothing's active or queued — caller
858
+ falls back to a progress fraction or the run state."""
859
+ in_progress = [j for j in jobs if j.get("state") == "in_progress"]
860
+ if in_progress:
861
+ in_progress.sort(key=lambda j: j.get("started_at") or "")
862
+ return in_progress[0].get("job_name")
863
+ queued = [j for j in jobs if j.get("state") == "queued"]
864
+ if queued:
865
+ return queued[0].get("job_name")
866
+ return None
867
+
868
+
869
+ def _watch_title(run_data: dict[str, Any]) -> str:
870
+ """Render a tab-friendly terminal title for the watched run.
871
+
872
+ Format: ``avr ▸ <workflow>: <stage>`` where ``<stage>`` is the active
873
+ job name when something's running, the conclusion on completion, or
874
+ a progress fraction / run state as fallback. ``avr ▸`` prefix marks
875
+ the tab as belonging to this CLI; the workflow name then disambiguates
876
+ multiple concurrent watch tabs."""
877
+ wf_name = ((run_data.get("workflow") or {}).get("name") or "run").strip()
878
+ jobs = run_data.get("jobs") or []
879
+ status = run_data.get("status", "unknown")
880
+ conclusion = run_data.get("conclusion")
881
+ if status == "completed":
882
+ stage = conclusion or "done"
883
+ elif (active := _active_job_name(jobs)) is not None:
884
+ stage = active
885
+ elif jobs:
886
+ completed = sum(1 for j in jobs if j.get("state") == "completed")
887
+ stage = f"{completed}/{len(jobs)} jobs done"
888
+ else:
889
+ stage = status
890
+ return f"avr ▸ {wf_name}: {stage}"
891
+
892
+
893
+ def watch_run_loop(
894
+ client: ApiClient,
895
+ org_id: str,
896
+ run_id: str,
897
+ *,
898
+ interval: int,
899
+ exit_status: bool,
900
+ console_url: str = "",
901
+ slug: str = "",
902
+ ) -> None:
903
+ """Poll a workflow run until it completes, clearing+redrawing each tick.
904
+
905
+ Shared by `avr run watch` and `avr workflow run --watch`. When
906
+ ``console_url`` and ``slug`` are non-empty, JOBS-table job names are
907
+ OSC 8 hyperlinked — clicking opens the job in the console without
908
+ interrupting the redraw loop. The terminal title (OSC 2) is updated
909
+ each tick with progress so users with many tabs can spot the run
910
+ they're watching.
911
+ """
912
+ with terminal_title("avr ▸ run: starting") as term_title:
913
+ try:
914
+ while True:
915
+ try:
916
+ response = client.public_get(
917
+ f"/orgs/{org_id}/workflow-runs/{run_id}",
918
+ # ``include=workflow`` so the title can show the
919
+ # workflow name (``avr ▸ tools: sleep``) rather
920
+ # than the opaque short run id.
921
+ params={"include": ["jobs", "workflow"]},
922
+ )
923
+ run_data = response.get("data", response)
924
+ except httpx.HTTPStatusError as exc:
925
+ if exc.response.status_code < 500 and exc.response.status_code != 429:
926
+ handle_http_error(exc, "get workflow run")
927
+ # Don't clear — keep the last frame so the user can still
928
+ # see job progress while we retry. Status footer on stderr
929
+ # tells them what's going on.
930
+ click.echo(
931
+ click.style(f" [retry — HTTP {exc.response.status_code}]", fg="yellow"),
932
+ err=True,
933
+ )
934
+ term_title.set("avr ▸ run: error")
935
+ time.sleep(interval)
936
+ continue
937
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
938
+ click.echo(
939
+ click.style(f" [retry — couldn't reach Avrea: {type(exc).__name__}]", fg="yellow"),
940
+ err=True,
941
+ )
942
+ term_title.set("avr ▸ run: network error")
943
+ time.sleep(interval)
944
+ continue
945
+
946
+ click.clear()
947
+ now_str = datetime.now(UTC).strftime("%H:%M:%S UTC")
948
+ title = run_data.get("display_title", "")
949
+ run_number = run_data.get("run_number", "?")
950
+ status = run_data.get("status", "unknown")
951
+ conclusion = run_data.get("conclusion")
952
+
953
+ header = f"Watching run #{run_number}: {title} -- {now_str}"
954
+ if console_url and slug:
955
+ header = hyperlink(header, run_url(console_url, slug, run_id))
956
+ click.echo(header + "\n")
957
+
958
+ run_jobs = run_data.get("jobs") or []
959
+ if run_jobs:
960
+ _print_run_jobs(run_jobs, console_url=console_url, slug=slug)
961
+ # Hints for drilling into a job. Static lines (job ids
962
+ # vary across rows), but the verbs are the demo flow.
963
+ click.echo()
964
+ click.echo(
965
+ click.style(
966
+ " → avr job logs --follow <job-id> # tail logs of a running job",
967
+ fg=DIM_FG,
968
+ )
969
+ )
970
+ click.echo(
971
+ click.style(
972
+ " → avr job metrics --watch <job-id> # live CPU/mem/IO gauges",
973
+ fg=DIM_FG,
974
+ )
975
+ )
976
+ click.echo(
977
+ click.style(
978
+ " → avr job ssh <job-id> # ssh into the runner VM",
979
+ fg=DIM_FG,
980
+ )
981
+ )
982
+ else:
983
+ click.echo(" No jobs yet.")
984
+
985
+ # Update the terminal title each tick. ``<workflow>: <active
986
+ # job>`` while running, ``<workflow>: <conclusion>`` on done.
987
+ term_title.set(_watch_title(run_data))
988
+
989
+ if status == "completed":
990
+ dur = format_duration(run_data.get("duration_seconds"))
991
+ conclusion_str = format_conclusion_colored(status, conclusion)
992
+ click.echo(f"\nRun #{run_number} completed: {conclusion_str} ({dur})")
993
+ # Reset title to bare ``avr ▸ <workflow>`` before notify
994
+ # so the conclusion isn't echoed twice (once via Ghostty's
995
+ # source line, once in the notification body).
996
+ wf_name = ((run_data.get("workflow") or {}).get("name") or "run").strip()
997
+ term_title.set(f"avr ▸ {wf_name}")
998
+ notify(f"Run #{run_number} {title}: {conclusion or 'completed'}")
999
+ if exit_status and conclusion != "success":
1000
+ sys.exit(1)
1001
+ return
1002
+
1003
+ time.sleep(interval)
1004
+ except KeyboardInterrupt:
1005
+ click.echo("\nStopped watching.")
1006
+
1007
+
1008
+ @run.command("watch")
1009
+ @click.argument("run_id", required=False)
1010
+ @click.option("--org", "org_id", help="Organization ID.")
1011
+ @click.option(
1012
+ "--repo",
1013
+ "repo_options",
1014
+ multiple=True,
1015
+ help="Scope the auto-select to a repo (org/name or rep-xxx, repeatable). Auto-detected from git remote if omitted.",
1016
+ )
1017
+ @click.option("--exit-status", is_flag=True, help="Exit non-zero if run failed.")
1018
+ @click.option("--interval", type=int, default=3, show_default=True, help="Refresh interval in seconds.")
1019
+ @click.option(
1020
+ "--ndjson",
1021
+ "ndjson_output",
1022
+ is_flag=True,
1023
+ help="Force NDJSON event stream (default when stdout isn't a TTY).",
1024
+ )
1025
+ @click.pass_context
1026
+ def run_watch(
1027
+ ctx,
1028
+ run_id: str | None,
1029
+ org_id,
1030
+ repo_options: tuple[str, ...],
1031
+ exit_status: bool,
1032
+ interval: int,
1033
+ ndjson_output: bool,
1034
+ ):
1035
+ """Watch a workflow run until it completes.
1036
+
1037
+ \b
1038
+ Without RUN_ID, auto-selects the latest in-progress run. Pass --repo
1039
+ (repeatable) to scope the auto-select to specific repositories.
1040
+
1041
+ \b
1042
+ Examples:
1043
+ avr run watch
1044
+ avr run watch --repo acme/web
1045
+ avr run watch --repo a/x --repo b/y
1046
+ avr run watch run-abc123 --exit-status
1047
+ avr run watch run-abc123 --ndjson | jq -c .
1048
+ """
1049
+ client: ApiClient = ctx.obj["client"]
1050
+ config: CliConfig = ctx.obj["config"]
1051
+ ensure_authenticated(config)
1052
+ if interval < 1:
1053
+ click.echo("Error: --interval must be at least 1 second.", err=True)
1054
+ raise click.Abort()
1055
+
1056
+ org_id = get_org_id(config, org_id, client=client)
1057
+
1058
+ if not run_id:
1059
+ click.echo(click.style("Looking for an in-progress run…", dim=True), err=True)
1060
+ params: dict[str, Any] = {
1061
+ "limit": 1,
1062
+ "order": "created_at.desc",
1063
+ "statuses": ["in_progress", "queued"],
1064
+ }
1065
+ # Auto-select must scope to --repo / AVR_REPO / git auto-detect if
1066
+ # any are present, so we don't silently watch an unrelated repo's run.
1067
+ # Only the "no git dir" auto-detect failure is swallowed — explicit
1068
+ # user choices propagate the error.
1069
+ if repo_options or config.repo_override:
1070
+ repo_ids = resolve_repos_or_detect(client, config, org_id, repo_options)
1071
+ else:
1072
+ try:
1073
+ repo_ids = resolve_repos_or_detect(client, config, org_id, ())
1074
+ except click.ClickException:
1075
+ repo_ids = []
1076
+ if repo_ids:
1077
+ params["repository_ids"] = list(repo_ids)
1078
+ try:
1079
+ response = client.public_get(f"/orgs/{org_id}/workflow-runs", params=params)
1080
+ runs = response.get("data") or []
1081
+ except httpx.HTTPStatusError as exc:
1082
+ handle_http_error(exc, "list workflow runs")
1083
+
1084
+ if not runs:
1085
+ scope = " for these repos" if repo_ids else ""
1086
+ click.echo(f"No in-progress workflow runs found{scope}.")
1087
+ return
1088
+ run_id = runs[0]["run_id"]
1089
+ title = runs[0].get("display_title", "")
1090
+ click.echo(f"Auto-selected: {run_id} ({title})", err=True)
1091
+
1092
+ # Default to NDJSON off-TTY so `avr run watch | jq` works without an
1093
+ # explicit flag; the redrawing TUI would otherwise produce unparseable
1094
+ # output to the pipe consumer. Named --ndjson (not --json) because watch
1095
+ # streams events, not records — the asymmetry with --json is intentional.
1096
+ if ndjson_output or not sys.stdout.isatty():
1097
+ watch_run_loop_ndjson(client, org_id, run_id, interval=interval, exit_status=exit_status)
1098
+ else:
1099
+ # TUI pre-check: an already-finished run would flash and exit, leaving
1100
+ # the user wondering whether they actually watched anything. NDJSON
1101
+ # mode is fine emitting a single terminal event so it skips this.
1102
+ try:
1103
+ pre = client.public_get(f"/orgs/{org_id}/workflow-runs/{run_id}")
1104
+ pre_data = pre.get("data", pre)
1105
+ except httpx.HTTPStatusError as exc:
1106
+ handle_http_error(exc, "look up workflow run before watch")
1107
+ if pre_data.get("status") == "completed":
1108
+ conclusion = pre_data.get("conclusion") or "completed"
1109
+ click.echo(
1110
+ click.style(
1111
+ f"Run {run_id} already finished ({conclusion}). Nothing to watch — see `avr run view {run_id}`.",
1112
+ fg="yellow",
1113
+ ),
1114
+ err=True,
1115
+ )
1116
+ if exit_status and conclusion != "success":
1117
+ sys.exit(1)
1118
+ return
1119
+ # Resolve link context once — the watch loop redraws every interval,
1120
+ # but slug/console_url stay constant for the lifetime of the watch.
1121
+ links_enabled = ctx.obj.get("links_enabled", False)
1122
+ link_console_url = get_console_url(config.public_api_url) if links_enabled else ""
1123
+ link_slug = get_org_slug(client, org_id) if links_enabled else ""
1124
+ watch_run_loop(
1125
+ client,
1126
+ org_id,
1127
+ run_id,
1128
+ interval=interval,
1129
+ exit_status=exit_status,
1130
+ console_url=link_console_url,
1131
+ slug=link_slug,
1132
+ )
1133
+
1134
+
1135
+ def _poll_for_new_attempt(
1136
+ client: ApiClient,
1137
+ org_id: str,
1138
+ platform_run_id: int,
1139
+ current_attempt: int,
1140
+ *,
1141
+ timeout: float = 30.0,
1142
+ ) -> dict[str, Any] | None:
1143
+ """Poll the workflow-runs list for a row with run_attempt > current_attempt.
1144
+
1145
+ A re-run produces a brand-new Avrea ``run_id`` (each attempt is its own
1146
+ row), but only after GitHub fires the webhook and Avrea ingests it. This
1147
+ closes the gap between "rerun requested" and "the new attempt is queryable"
1148
+ so users get the new run_id printed inline.
1149
+
1150
+ Transient errors (5xx, network) keep retrying until the deadline; 4xx are
1151
+ terminal — a stale token or revoked access mid-poll otherwise looks like
1152
+ 'GitHub is slow' all the way to the timeout, hiding a real failure."""
1153
+ deadline = time.monotonic() + timeout
1154
+ while time.monotonic() < deadline:
1155
+ try:
1156
+ response = client.public_get(
1157
+ f"/orgs/{org_id}/workflow-runs",
1158
+ params={"platform_run_id": platform_run_id, "limit": 10, "order": "created_at.desc"},
1159
+ )
1160
+ for r in response.get("data", []):
1161
+ if (r.get("run_attempt") or 0) > current_attempt:
1162
+ return r
1163
+ except httpx.HTTPStatusError as exc:
1164
+ if exc.response.status_code < 500 and exc.response.status_code != 429:
1165
+ handle_http_error(exc, "poll for rerun attempt")
1166
+ # 5xx falls through to the sleep+retry below.
1167
+ except httpx.ConnectError, httpx.TimeoutException:
1168
+ # Transient network failures during poll — keep retrying until the
1169
+ # deadline rather than aborting the rerun command.
1170
+ pass
1171
+ time.sleep(1.0)
1172
+ return None
1173
+
1174
+
1175
+ @run.command("rerun")
1176
+ @click.argument("run_id")
1177
+ @click.option("--org", "org_id", help="Organization ID.")
1178
+ @click.option("--failed", is_flag=True, help="Re-run only the failed jobs.")
1179
+ @click.option("-y", "--yes", is_flag=True, help="Skip the confirmation prompt.")
1180
+ @click.pass_context
1181
+ def run_rerun(ctx, run_id: str, org_id, failed: bool, yes: bool):
1182
+ """Re-run a completed workflow run.
1183
+
1184
+ \b
1185
+ Examples:
1186
+ avr run rerun run-abc123
1187
+ avr run rerun run-abc123 --failed
1188
+ avr run rerun run-abc123 --yes
1189
+ """
1190
+ client: ApiClient = ctx.obj["client"]
1191
+ config: CliConfig = ctx.obj["config"]
1192
+ ensure_authenticated(config)
1193
+ org_id = get_org_id(config, org_id, client=client)
1194
+
1195
+ if not yes:
1196
+ scope = "failed jobs only" if failed else "all jobs"
1197
+ ensure_prompts_allowed("run rerun needs confirmation")
1198
+ click.confirm(f"Re-run {run_id} ({scope})?", abort=True)
1199
+
1200
+ # Fetch the run first so we know which platform_run_id and attempt to
1201
+ # diff against — the new attempt's run_id is what users want to navigate to.
1202
+ try:
1203
+ existing = client.public_get(f"/orgs/{org_id}/workflow-runs/{run_id}")
1204
+ existing_data = existing.get("data", existing)
1205
+ platform_run_id = existing_data.get("platform_run_id")
1206
+ current_attempt = existing_data.get("run_attempt", 1)
1207
+ except httpx.HTTPStatusError as exc:
1208
+ handle_http_error(exc, "look up workflow run before rerun")
1209
+
1210
+ try:
1211
+ client.public_post(
1212
+ f"/orgs/{org_id}/workflow-runs/{run_id}/rerun",
1213
+ json={"failed_only": failed},
1214
+ )
1215
+ except httpx.HTTPStatusError as exc:
1216
+ handle_http_error(exc, "rerun workflow run")
1217
+
1218
+ scope = "failed jobs" if failed else "all jobs"
1219
+ click.echo(f"{click.style('✓', fg='green')} Re-run requested for {click.style(run_id, fg='cyan')} ({scope}).")
1220
+
1221
+ if platform_run_id is not None:
1222
+ click.echo(click.style("Waiting for the new attempt to appear...", dim=True), err=True)
1223
+ new_run = _poll_for_new_attempt(client, org_id, int(platform_run_id), int(current_attempt))
1224
+ if new_run:
1225
+ new_id = new_run.get("run_id", "")
1226
+ new_attempt = new_run.get("run_attempt", "?")
1227
+ click.echo(f" Run: {click.style(new_id, fg='cyan')} (attempt {new_attempt})")
1228
+ run_id = new_id # use the new run_id for the console-URL hint below
1229
+ else:
1230
+ click.echo(
1231
+ click.style(
1232
+ "New attempt not yet visible after 30s. Use `avr run list` to find it.",
1233
+ dim=True,
1234
+ ),
1235
+ err=True,
1236
+ )
1237
+
1238
+ slug = get_org_slug(client, org_id)
1239
+ console_url = get_console_url(config.public_api_url)
1240
+ click.echo(f" {console_url}/org/{slug}/runs/{run_id}")
1241
+ click.echo(click.style(f" → avr run watch {run_id}", dim=True), err=True)
1242
+
1243
+
1244
+ # Subset of job-level conclusion values that count as "failed" for
1245
+ # `avr run logs --failed`. `stale` and `startup_failure` are run-level
1246
+ # only and never appear on a job.
1247
+ _FAILED_CONCLUSIONS = frozenset({"failure", "timed_out", "cancelled", "action_required"})
1248
+
1249
+
1250
+ @run.command("logs")
1251
+ @click.argument("run_id")
1252
+ @click.option("--org", "org_id", help="Organization ID.")
1253
+ @click.option("--job", "job_name_filter", help="Restrict to GitHub jobs whose name contains this string.")
1254
+ @click.option("-f", "--follow", is_flag=True, help="Tail logs as they appear (running jobs only).")
1255
+ @click.option("--failed", "failed_only", is_flag=True, help="Show only logs from failed jobs.")
1256
+ @click.option(
1257
+ "--all-levels",
1258
+ "show_all_levels",
1259
+ is_flag=True,
1260
+ help="Include diagnostic-level lines (off by default).",
1261
+ )
1262
+ @click.option(
1263
+ "--no-pager",
1264
+ is_flag=True,
1265
+ help="Print directly to stdout instead of paging through `less`. Same as setting AVR_PAGER=''.",
1266
+ )
1267
+ @click.pass_context
1268
+ def run_logs(
1269
+ ctx,
1270
+ run_id: str,
1271
+ org_id,
1272
+ job_name_filter: str | None,
1273
+ follow: bool,
1274
+ failed_only: bool,
1275
+ show_all_levels: bool,
1276
+ no_pager: bool,
1277
+ ):
1278
+ """Fetch logs for a workflow run's GitHub jobs.
1279
+
1280
+ Long-form alternative to `avr run view --log[-failed]`. Use --follow to
1281
+ tail logs in real time for an in-progress job; pass --job to scope to a
1282
+ specific job when a run has many.
1283
+
1284
+ \b
1285
+ Examples:
1286
+ avr run logs run-abc123
1287
+ avr run logs run-abc123 --failed
1288
+ avr run logs run-abc123 --job test
1289
+ avr run logs run-abc123 --follow
1290
+ """
1291
+ if follow and failed_only:
1292
+ raise click.UsageError("--follow and --failed cannot be combined")
1293
+
1294
+ client: ApiClient = ctx.obj["client"]
1295
+ config: CliConfig = ctx.obj["config"]
1296
+ ensure_authenticated(config)
1297
+ org_id = get_org_id(config, org_id, client=client)
1298
+
1299
+ try:
1300
+ response = client.public_get(
1301
+ f"/orgs/{org_id}/workflow-runs/{run_id}",
1302
+ params={"include": ["jobs"]},
1303
+ )
1304
+ run_data = response.get("data", response)
1305
+ except httpx.HTTPStatusError as exc:
1306
+ handle_http_error(exc, "get workflow run")
1307
+
1308
+ jobs_list: list[dict[str, Any]] = run_data.get("jobs") or []
1309
+ if job_name_filter:
1310
+ needle = job_name_filter.lower()
1311
+ jobs_list = [j for j in jobs_list if needle in (j.get("job_name") or "").lower()]
1312
+ if not jobs_list:
1313
+ click.echo("No matching jobs found.")
1314
+ return
1315
+
1316
+ # Per-job console URL — used for header and per-line timestamps.
1317
+ links_enabled = ctx.obj.get("links_enabled", False)
1318
+ link_console_url = get_console_url(config.public_api_url) if links_enabled else ""
1319
+ link_slug = get_org_slug(client, org_id) if links_enabled else ""
1320
+
1321
+ def _job_link(job_id_: str) -> str | None:
1322
+ if link_console_url and link_slug and job_id_:
1323
+ return job_url(link_console_url, link_slug, job_id_)
1324
+ return None
1325
+
1326
+ if follow:
1327
+ # Prefer a running job; fall back to the first. Note: jobs use
1328
+ # ``state``, steps use ``status``.
1329
+ running = [j for j in jobs_list if j.get("state") in ("in_progress", "queued")]
1330
+ target = running[0] if running else jobs_list[0]
1331
+ job_id = target.get("job_id")
1332
+ if not job_id:
1333
+ raise click.ClickException("Cannot follow: target job has no ID yet.")
1334
+ url = _job_link(job_id)
1335
+ header = f"--- {target.get('job_name', '?')} ({job_id}) ---"
1336
+ click.echo(hyperlink(click.style(header, bold=True), url) if url else click.style(header, bold=True))
1337
+ # Single-job follow → step-level marks (matches `avr job logs --follow`).
1338
+ follow_logs(
1339
+ client,
1340
+ org_id,
1341
+ job_id,
1342
+ show_all_levels=show_all_levels,
1343
+ link_url=url,
1344
+ mark_steps=True,
1345
+ job_name=target.get("job_name"),
1346
+ )
1347
+ return
1348
+
1349
+ if failed_only:
1350
+ jobs_list = [j for j in jobs_list if (j.get("conclusion") or "") in _FAILED_CONCLUSIONS]
1351
+ if not jobs_list:
1352
+ click.echo("No failed jobs in this run.")
1353
+ return
1354
+
1355
+ # Buffer all log output, then page it in one go. Logs can be tens of MB
1356
+ # for long jobs; a pager makes that navigable. piped consumers and
1357
+ # AVR_PAGER='' / PAGER='' callers fall through to direct echo.
1358
+ #
1359
+ # OSC 133 marks intentionally omitted here: `less` doesn't strip OSC
1360
+ # sequences, so they'd leak as visible bytes when paged. Step-level
1361
+ # marks live in `avr job logs --follow` instead, which streams direct
1362
+ # to the terminal.
1363
+ buffer: list[str] = []
1364
+ emit = buffer.append
1365
+ for job in jobs_list:
1366
+ job_id = job.get("job_id")
1367
+ if not job_id:
1368
+ continue
1369
+ steps = job.get("steps") or []
1370
+ url = _job_link(job_id)
1371
+ emit("")
1372
+ header = f"--- {job.get('job_name', '?')} ({job_id}) ---"
1373
+ styled = click.style(header, bold=True)
1374
+ emit(hyperlink(styled, url) if url else styled)
1375
+ if failed_only and steps:
1376
+ print_failed_step_logs(client, job_id, steps, show_all_levels=show_all_levels, emit=emit, link_url=url)
1377
+ else:
1378
+ entries = fetch_all_logs(client, job_id, show_all_levels=show_all_levels)
1379
+ print_logs_grouped(entries, emit=emit, link_url=url)
1380
+ page_output("\n".join(buffer), bypass=no_pager)
1381
+
1382
+
1383
+ @run.command("cancel")
1384
+ @click.argument("run_id")
1385
+ @click.option("--org", "org_id", help="Organization ID.")
1386
+ @click.option("-y", "--yes", is_flag=True, help="Skip the confirmation prompt.")
1387
+ @click.pass_context
1388
+ def run_cancel(ctx, run_id: str, org_id, yes: bool):
1389
+ """Cancel an in-progress or queued workflow run.
1390
+
1391
+ \b
1392
+ Examples:
1393
+ avr run cancel run-abc123
1394
+ avr run cancel run-abc123 --yes
1395
+ """
1396
+ client: ApiClient = ctx.obj["client"]
1397
+ config: CliConfig = ctx.obj["config"]
1398
+ ensure_authenticated(config)
1399
+ org_id = get_org_id(config, org_id, client=client)
1400
+
1401
+ if not yes:
1402
+ ensure_prompts_allowed("run cancel needs confirmation")
1403
+ click.confirm(f"Cancel {run_id}?", abort=True)
1404
+
1405
+ try:
1406
+ result = client.public_post(f"/orgs/{org_id}/workflow-runs/{run_id}/cancel")
1407
+ except httpx.HTTPStatusError as exc:
1408
+ handle_http_error(exc, "cancel workflow run")
1409
+
1410
+ status = result.get("data", {}).get("status", "cancel_requested")
1411
+ styled_run_id = click.style(run_id, fg="cyan")
1412
+ if status == "already_terminal":
1413
+ click.echo(f"{click.style('•', fg='yellow')} Run {styled_run_id} had already finished; nothing to cancel.")
1414
+ else:
1415
+ click.echo(f"{click.style('✓', fg='green')} Cancel requested for {styled_run_id}.")
1416
+
1417
+ slug = get_org_slug(client, org_id)
1418
+ console_url = get_console_url(config.public_api_url)
1419
+ click.echo(f" {console_url}/org/{slug}/runs/{run_id}")
1420
+ if status != "already_terminal":
1421
+ click.echo(
1422
+ click.style(f" → avr run watch {run_id} (verify cancellation)", dim=True),
1423
+ err=True,
1424
+ )