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.
- avr_cli-0.1.0.dist-info/METADATA +298 -0
- avr_cli-0.1.0.dist-info/RECORD +42 -0
- avr_cli-0.1.0.dist-info/WHEEL +5 -0
- avr_cli-0.1.0.dist-info/entry_points.txt +2 -0
- avr_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- avr_cli-0.1.0.dist-info/top_level.txt +1 -0
- avrea_cli/__init__.py +9 -0
- avrea_cli/_build_info.py +2 -0
- avrea_cli/api_client.py +111 -0
- avrea_cli/assets/logo_mark.svg +25 -0
- avrea_cli/audit.py +153 -0
- avrea_cli/auth.py +457 -0
- avrea_cli/billing.py +614 -0
- avrea_cli/click_ext.py +261 -0
- avrea_cli/commands/__init__.py +1 -0
- avrea_cli/commands/auth_cmd.py +296 -0
- avrea_cli/commands/cache.py +312 -0
- avrea_cli/commands/config_cmd.py +180 -0
- avrea_cli/commands/health.py +76 -0
- avrea_cli/commands/internal.py +18 -0
- avrea_cli/commands/job.py +959 -0
- avrea_cli/commands/log.py +146 -0
- avrea_cli/commands/org.py +404 -0
- avrea_cli/commands/repo.py +152 -0
- avrea_cli/commands/run.py +1424 -0
- avrea_cli/commands/settings.py +271 -0
- avrea_cli/commands/status.py +405 -0
- avrea_cli/commands/workflow.py +883 -0
- avrea_cli/config.py +49 -0
- avrea_cli/display.py +431 -0
- avrea_cli/docs_gen.py +859 -0
- avrea_cli/firewall.py +342 -0
- avrea_cli/helpers.py +218 -0
- avrea_cli/json_output.py +169 -0
- avrea_cli/log_display.py +509 -0
- avrea_cli/main.py +210 -0
- avrea_cli/metrics_display.py +347 -0
- avrea_cli/output.py +101 -0
- avrea_cli/paths.py +45 -0
- avrea_cli/repo_context.py +473 -0
- avrea_cli/transport.py +71 -0
- avrea_cli/version.py +64 -0
|
@@ -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
|
+
)
|