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,146 @@
|
|
|
1
|
+
"""Log search 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.helpers import ensure_authenticated
|
|
7
|
+
from avrea_cli.helpers import ensure_ctx
|
|
8
|
+
from avrea_cli.helpers import get_org_id
|
|
9
|
+
from avrea_cli.helpers import handle_http_error
|
|
10
|
+
from avrea_cli.json_output import emit_json
|
|
11
|
+
from avrea_cli.json_output import handle_json_meta
|
|
12
|
+
from avrea_cli.json_output import json_options
|
|
13
|
+
from avrea_cli.json_output import make_schema
|
|
14
|
+
from avrea_cli.json_output import split_fields
|
|
15
|
+
from avrea_cli.repo_context import resolve_repo_or_detect
|
|
16
|
+
import click
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group(cls=GhGroup)
|
|
21
|
+
@click.pass_context
|
|
22
|
+
def log(ctx):
|
|
23
|
+
"""Search across runner execution logs."""
|
|
24
|
+
ensure_ctx(ctx)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_LOG_SEARCH_FIELDS = make_schema(
|
|
28
|
+
"id",
|
|
29
|
+
"vm_id",
|
|
30
|
+
"line_number",
|
|
31
|
+
"content",
|
|
32
|
+
"stream",
|
|
33
|
+
"level",
|
|
34
|
+
"timestamp",
|
|
35
|
+
"score",
|
|
36
|
+
"repository_id",
|
|
37
|
+
"group_name",
|
|
38
|
+
"step_name",
|
|
39
|
+
"step_record_id",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@log.command("search")
|
|
44
|
+
@click.option("--repo", "repo_id", help="Repository (org/repo or rep-xxx). Auto-detected from git remote if omitted.")
|
|
45
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified.")
|
|
46
|
+
@click.option("--query", help="Full-text search query")
|
|
47
|
+
@click.option(
|
|
48
|
+
"--stream",
|
|
49
|
+
type=click.Choice(["stdout", "stderr"], case_sensitive=False),
|
|
50
|
+
help="Filter by output stream",
|
|
51
|
+
)
|
|
52
|
+
@click.option(
|
|
53
|
+
"--level",
|
|
54
|
+
type=click.Choice(["debug", "info", "warning", "error"], case_sensitive=False),
|
|
55
|
+
help="Filter by log level",
|
|
56
|
+
)
|
|
57
|
+
@click.option("--vm-id", help="Filter by execution/VM ID")
|
|
58
|
+
@click.option("-L", "--limit", type=int, default=100, show_default=True, help="Maximum results to return")
|
|
59
|
+
@json_options
|
|
60
|
+
@click.pass_context
|
|
61
|
+
def log_search(
|
|
62
|
+
ctx,
|
|
63
|
+
repo_id: str,
|
|
64
|
+
org_id: str | None,
|
|
65
|
+
query: str | None,
|
|
66
|
+
stream: str | None,
|
|
67
|
+
level: str | None,
|
|
68
|
+
vm_id: str | None,
|
|
69
|
+
limit: int,
|
|
70
|
+
json_fields: str | None,
|
|
71
|
+
jq_expr: str | None,
|
|
72
|
+
):
|
|
73
|
+
"""Search logs for a repository.
|
|
74
|
+
|
|
75
|
+
Performs full-text search when --query is provided, otherwise returns
|
|
76
|
+
logs sorted by line number. Results are filtered to logs from repositories
|
|
77
|
+
you have access to.
|
|
78
|
+
|
|
79
|
+
\b
|
|
80
|
+
Examples:
|
|
81
|
+
avr log search --repo acme/web --query "error"
|
|
82
|
+
avr log search --repo rep-abc123 --level error --limit 50
|
|
83
|
+
avr log search --repo rep-abc123 --vm-id vm-xyz --stream stderr
|
|
84
|
+
avr log search --repo rep-abc123 --query "OOM" --json content,timestamp,vm_id
|
|
85
|
+
|
|
86
|
+
\b
|
|
87
|
+
JSON FIELDS
|
|
88
|
+
content, group_name, id, level, line_number, repository_id, score,
|
|
89
|
+
step_name, step_record_id, stream, timestamp, vm_id
|
|
90
|
+
"""
|
|
91
|
+
if handle_json_meta(json_fields, jq_expr, _LOG_SEARCH_FIELDS):
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
client: ApiClient = ctx.obj["client"]
|
|
95
|
+
config: CliConfig = ctx.obj["config"]
|
|
96
|
+
ensure_authenticated(config)
|
|
97
|
+
|
|
98
|
+
org_id = get_org_id(config, org_id, client=client)
|
|
99
|
+
repo_id = resolve_repo_or_detect(client, config, org_id, repo_id, required=True)
|
|
100
|
+
|
|
101
|
+
payload: dict = {
|
|
102
|
+
"repository_ids": [repo_id],
|
|
103
|
+
"limit": limit,
|
|
104
|
+
}
|
|
105
|
+
if query:
|
|
106
|
+
payload["query"] = query
|
|
107
|
+
if stream:
|
|
108
|
+
payload["stream"] = stream.lower()
|
|
109
|
+
if level:
|
|
110
|
+
payload["level"] = level.lower()
|
|
111
|
+
if vm_id:
|
|
112
|
+
payload["vm_id"] = vm_id
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
result = client.public_post("/logs/search", json=payload)
|
|
116
|
+
except httpx.HTTPStatusError as exc:
|
|
117
|
+
handle_http_error(exc, "search logs")
|
|
118
|
+
|
|
119
|
+
results = result.get("results", [])
|
|
120
|
+
|
|
121
|
+
if json_fields is not None:
|
|
122
|
+
emit_json(results, split_fields(json_fields, _LOG_SEARCH_FIELDS), _LOG_SEARCH_FIELDS, jq_expr)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
if not results:
|
|
126
|
+
click.echo("No logs found.")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
total = result.get("total_estimated", len(results))
|
|
130
|
+
has_more = result.get("has_more", False)
|
|
131
|
+
click.echo(f"Found {total} log entries{' (more available)' if has_more else ''}\n")
|
|
132
|
+
|
|
133
|
+
for entry in results:
|
|
134
|
+
ts = entry.get("timestamp", "")[:19]
|
|
135
|
+
level_str = entry.get("level", "info").upper()[:5].ljust(5)
|
|
136
|
+
stream_str = entry.get("stream", "stdout")[:6].ljust(6)
|
|
137
|
+
line_no = entry.get("line_number", 0)
|
|
138
|
+
content = entry.get("content", "")
|
|
139
|
+
|
|
140
|
+
level_colors = {"ERROR": "red", "WARNI": "yellow", "DEBUG": "cyan"}
|
|
141
|
+
level_color = level_colors.get(level_str, None)
|
|
142
|
+
if level_color:
|
|
143
|
+
level_str = click.style(level_str, fg=level_color)
|
|
144
|
+
|
|
145
|
+
prefix = f"[{ts}] {level_str} {stream_str} L{line_no:>6}"
|
|
146
|
+
click.echo(f"{prefix}: {content}")
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Organization CLI commands with install and email-domain subgroups."""
|
|
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.helpers import ensure_authenticated
|
|
7
|
+
from avrea_cli.helpers import ensure_ctx
|
|
8
|
+
from avrea_cli.helpers import ensure_prompts_allowed
|
|
9
|
+
from avrea_cli.helpers import get_org_id
|
|
10
|
+
from avrea_cli.helpers import handle_http_error
|
|
11
|
+
from avrea_cli.json_output import emit_json
|
|
12
|
+
from avrea_cli.json_output import emit_json_record
|
|
13
|
+
from avrea_cli.json_output import handle_json_meta
|
|
14
|
+
from avrea_cli.json_output import json_options
|
|
15
|
+
from avrea_cli.json_output import make_schema
|
|
16
|
+
from avrea_cli.json_output import split_fields
|
|
17
|
+
from avrea_cli.output import format_key_value
|
|
18
|
+
from avrea_cli.output import format_timestamp
|
|
19
|
+
from avrea_cli.output import output_list
|
|
20
|
+
import click
|
|
21
|
+
import httpx
|
|
22
|
+
import time
|
|
23
|
+
import webbrowser
|
|
24
|
+
|
|
25
|
+
_ORG_FIELDS = make_schema("organization_id", "name", "slug", "role")
|
|
26
|
+
_MEMBER_FIELDS = make_schema("user_id", "name", "role", "joined_at")
|
|
27
|
+
_EMAIL_DOMAIN_FIELDS = make_schema("organization_email_domain_id", "domain", "created_at")
|
|
28
|
+
_INSTALL_FIELDS = make_schema(
|
|
29
|
+
"installation_id",
|
|
30
|
+
"platform_installation_id",
|
|
31
|
+
"target_name",
|
|
32
|
+
"organization_name",
|
|
33
|
+
"organization_slug",
|
|
34
|
+
"state",
|
|
35
|
+
"created_at",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.group(cls=GhGroup)
|
|
40
|
+
@click.pass_context
|
|
41
|
+
def org(ctx):
|
|
42
|
+
"""Manage organizations and installations."""
|
|
43
|
+
ensure_ctx(ctx)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Top-level org commands
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@org.command("list")
|
|
52
|
+
@json_options
|
|
53
|
+
@click.pass_context
|
|
54
|
+
def org_list(ctx, json_fields, jq_expr):
|
|
55
|
+
"""List organizations you belong to.
|
|
56
|
+
|
|
57
|
+
\b
|
|
58
|
+
Examples:
|
|
59
|
+
avr org list
|
|
60
|
+
avr org list --json slug,role
|
|
61
|
+
avr org list --json '*' -q '.[] | select(.role == "admin")'
|
|
62
|
+
|
|
63
|
+
\b
|
|
64
|
+
JSON FIELDS
|
|
65
|
+
name, organization_id, role, slug
|
|
66
|
+
"""
|
|
67
|
+
if handle_json_meta(json_fields, jq_expr, _ORG_FIELDS):
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
client: ApiClient = ctx.obj["client"]
|
|
71
|
+
config: CliConfig = ctx.obj["config"]
|
|
72
|
+
ensure_authenticated(config)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
response = client.public_get("/users/me/organizations")
|
|
76
|
+
organizations = response["data"]
|
|
77
|
+
except httpx.HTTPStatusError as exc:
|
|
78
|
+
handle_http_error(exc, "fetch organizations")
|
|
79
|
+
|
|
80
|
+
if json_fields is not None:
|
|
81
|
+
emit_json(organizations, split_fields(json_fields, _ORG_FIELDS), _ORG_FIELDS, jq_expr)
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
default_org = config.default_org
|
|
85
|
+
for o in organizations:
|
|
86
|
+
o["default"] = "yes" if o["organization_id"] == default_org else ""
|
|
87
|
+
|
|
88
|
+
output_list(
|
|
89
|
+
organizations,
|
|
90
|
+
columns=["organization_id", "name", "slug", "role", "default"],
|
|
91
|
+
column_labels=["ID", "Name", "Slug", "Role", "Default"],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@org.command("create")
|
|
96
|
+
@click.argument("name")
|
|
97
|
+
@json_options
|
|
98
|
+
@click.pass_context
|
|
99
|
+
def org_create(ctx, name: str, json_fields, jq_expr):
|
|
100
|
+
"""Create a new organization.
|
|
101
|
+
|
|
102
|
+
\b
|
|
103
|
+
JSON FIELDS
|
|
104
|
+
name, organization_id, role, slug
|
|
105
|
+
"""
|
|
106
|
+
if handle_json_meta(json_fields, jq_expr, _ORG_FIELDS):
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
client: ApiClient = ctx.obj["client"]
|
|
110
|
+
config: CliConfig = ctx.obj["config"]
|
|
111
|
+
ensure_authenticated(config)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
result = client.public_post("/users/me/organizations", json={"name": name})
|
|
115
|
+
except httpx.HTTPStatusError as exc:
|
|
116
|
+
handle_http_error(exc, "create organization")
|
|
117
|
+
|
|
118
|
+
if json_fields is not None:
|
|
119
|
+
emit_json_record(result, split_fields(json_fields, _ORG_FIELDS), _ORG_FIELDS, jq_expr)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
click.echo(
|
|
123
|
+
format_key_value(
|
|
124
|
+
{
|
|
125
|
+
"Organization ID": result["organization_id"],
|
|
126
|
+
"Name": result["name"],
|
|
127
|
+
"Slug": result["slug"],
|
|
128
|
+
"Role": result["role"],
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@org.command("members")
|
|
135
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
136
|
+
@json_options
|
|
137
|
+
@click.pass_context
|
|
138
|
+
def org_members(ctx, org_id, json_fields, jq_expr):
|
|
139
|
+
"""List organization members (admin only).
|
|
140
|
+
|
|
141
|
+
\b
|
|
142
|
+
Examples:
|
|
143
|
+
avr org members
|
|
144
|
+
avr org members --org org-abc123
|
|
145
|
+
avr org members --json name,role
|
|
146
|
+
|
|
147
|
+
\b
|
|
148
|
+
JSON FIELDS
|
|
149
|
+
joined_at, name, role, user_id
|
|
150
|
+
"""
|
|
151
|
+
if handle_json_meta(json_fields, jq_expr, _MEMBER_FIELDS):
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
client: ApiClient = ctx.obj["client"]
|
|
155
|
+
config: CliConfig = ctx.obj["config"]
|
|
156
|
+
ensure_authenticated(config)
|
|
157
|
+
|
|
158
|
+
org_id = get_org_id(config, org_id, client=client)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
response = client.public_get(f"/orgs/{org_id}/members")
|
|
162
|
+
members = response["data"]
|
|
163
|
+
except httpx.HTTPStatusError as exc:
|
|
164
|
+
handle_http_error(exc, "list members")
|
|
165
|
+
|
|
166
|
+
if json_fields is not None:
|
|
167
|
+
emit_json(members, split_fields(json_fields, _MEMBER_FIELDS), _MEMBER_FIELDS, jq_expr)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
for m in members:
|
|
171
|
+
m["joined_display"] = format_timestamp(m.get("joined_at"))
|
|
172
|
+
|
|
173
|
+
output_list(
|
|
174
|
+
members,
|
|
175
|
+
columns=["user_id", "name", "role", "joined_display"],
|
|
176
|
+
column_labels=["User ID", "Name", "Role", "Joined"],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# email-domain subgroup
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@org.group("email-domain", cls=GhGroup)
|
|
186
|
+
@click.pass_context
|
|
187
|
+
def email_domain(ctx):
|
|
188
|
+
"""Manage email domains for automatic org membership."""
|
|
189
|
+
ensure_ctx(ctx)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@email_domain.command("list")
|
|
193
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
194
|
+
@json_options
|
|
195
|
+
@click.pass_context
|
|
196
|
+
def email_domain_list(ctx, org_id, json_fields, jq_expr):
|
|
197
|
+
"""List email domains for automatic org membership (admin only).
|
|
198
|
+
|
|
199
|
+
\b
|
|
200
|
+
JSON FIELDS
|
|
201
|
+
created_at, domain, organization_email_domain_id
|
|
202
|
+
"""
|
|
203
|
+
if handle_json_meta(json_fields, jq_expr, _EMAIL_DOMAIN_FIELDS):
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
client: ApiClient = ctx.obj["client"]
|
|
207
|
+
config: CliConfig = ctx.obj["config"]
|
|
208
|
+
ensure_authenticated(config)
|
|
209
|
+
|
|
210
|
+
org_id = get_org_id(config, org_id, client=client)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
response = client.public_get(f"/orgs/{org_id}/email-domains")
|
|
214
|
+
domains = response["data"]
|
|
215
|
+
except httpx.HTTPStatusError as exc:
|
|
216
|
+
handle_http_error(exc, "list email domains")
|
|
217
|
+
|
|
218
|
+
if json_fields is not None:
|
|
219
|
+
emit_json(domains, split_fields(json_fields, _EMAIL_DOMAIN_FIELDS), _EMAIL_DOMAIN_FIELDS, jq_expr)
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
for d in domains:
|
|
223
|
+
d["created_display"] = format_timestamp(d.get("created_at"))
|
|
224
|
+
|
|
225
|
+
output_list(
|
|
226
|
+
domains,
|
|
227
|
+
columns=["domain", "organization_email_domain_id", "created_display"],
|
|
228
|
+
column_labels=["Domain", "ID", "Created"],
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@email_domain.command("set")
|
|
233
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
234
|
+
@click.option("--yes", "-y", "confirmed", is_flag=True, help="Skip confirmation prompt.")
|
|
235
|
+
@click.argument("domains", nargs=-1, required=True)
|
|
236
|
+
@click.pass_context
|
|
237
|
+
def email_domain_set(ctx, org_id, confirmed, domains):
|
|
238
|
+
"""Set email domains for automatic org membership (admin only).
|
|
239
|
+
|
|
240
|
+
Replaces all existing domains — a typo wipes the org's auto-membership
|
|
241
|
+
policy. Confirms before applying; pass --yes to skip the prompt (required
|
|
242
|
+
when stdout isn't a TTY, e.g. in CI).
|
|
243
|
+
|
|
244
|
+
\b
|
|
245
|
+
Examples:
|
|
246
|
+
avr org email-domain set example.com
|
|
247
|
+
avr org email-domain set example.com corp.example.com --yes
|
|
248
|
+
"""
|
|
249
|
+
client: ApiClient = ctx.obj["client"]
|
|
250
|
+
config: CliConfig = ctx.obj["config"]
|
|
251
|
+
ensure_authenticated(config)
|
|
252
|
+
|
|
253
|
+
org_id = get_org_id(config, org_id, client=client)
|
|
254
|
+
|
|
255
|
+
if not confirmed:
|
|
256
|
+
ensure_prompts_allowed("email-domain set replaces ALL existing domains")
|
|
257
|
+
domain_list = ", ".join(domains)
|
|
258
|
+
click.confirm(
|
|
259
|
+
f"Replace ALL email domains for {org_id} with [{domain_list}]?",
|
|
260
|
+
abort=True,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
response = client.public_post(f"/orgs/{org_id}/email-domains", json={"domains": list(domains)})
|
|
265
|
+
result = response["data"]
|
|
266
|
+
except httpx.HTTPStatusError as exc:
|
|
267
|
+
handle_http_error(exc, "set email domains")
|
|
268
|
+
|
|
269
|
+
click.echo(f"Set {len(result)} email domain(s) for organization {org_id}:")
|
|
270
|
+
for d in result:
|
|
271
|
+
click.echo(f" {d['domain']}")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# install subgroup
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@org.group("install", cls=GhGroup)
|
|
280
|
+
@click.pass_context
|
|
281
|
+
def install(ctx):
|
|
282
|
+
"""Manage GitHub App installations."""
|
|
283
|
+
ensure_ctx(ctx)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@install.command("list")
|
|
287
|
+
@json_options
|
|
288
|
+
@click.pass_context
|
|
289
|
+
def install_list(ctx, json_fields, jq_expr):
|
|
290
|
+
"""List accessible installations across all your organizations.
|
|
291
|
+
|
|
292
|
+
\b
|
|
293
|
+
JSON FIELDS
|
|
294
|
+
created_at, platform_installation_id, installation_id, organization_name,
|
|
295
|
+
organization_slug, state, target_name
|
|
296
|
+
"""
|
|
297
|
+
if handle_json_meta(json_fields, jq_expr, _INSTALL_FIELDS):
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
client: ApiClient = ctx.obj["client"]
|
|
301
|
+
config: CliConfig = ctx.obj["config"]
|
|
302
|
+
ensure_authenticated(config)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
response = client.public_get("/users/me/installations")
|
|
306
|
+
installations = response["data"]
|
|
307
|
+
except httpx.HTTPStatusError as exc:
|
|
308
|
+
handle_http_error(exc, "fetch installations")
|
|
309
|
+
|
|
310
|
+
if json_fields is not None:
|
|
311
|
+
emit_json(installations, split_fields(json_fields, _INSTALL_FIELDS), _INSTALL_FIELDS, jq_expr)
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
for inst in installations:
|
|
315
|
+
inst["org_name"] = inst.get("organization_name") or inst.get("organization_slug") or "-"
|
|
316
|
+
inst["created_display"] = format_timestamp(inst.get("created_at"))
|
|
317
|
+
|
|
318
|
+
output_list(
|
|
319
|
+
installations,
|
|
320
|
+
columns=["installation_id", "platform_installation_id", "target_name", "org_name", "state", "created_display"],
|
|
321
|
+
column_labels=["Installation ID", "GitHub ID", "Target", "Organization", "State", "Created"],
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@install.command("add")
|
|
326
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
327
|
+
@click.option("--no-browser", is_flag=True, help="Do not open browser automatically.")
|
|
328
|
+
@click.option("--wait-seconds", type=int, default=120, show_default=True, help="Seconds to wait for detection.")
|
|
329
|
+
@click.pass_context
|
|
330
|
+
def install_add(ctx, org_id: str | None, no_browser: bool, wait_seconds: int):
|
|
331
|
+
"""Start the GitHub App installation flow."""
|
|
332
|
+
client: ApiClient = ctx.obj["client"]
|
|
333
|
+
config: CliConfig = ctx.obj["config"]
|
|
334
|
+
ensure_authenticated(config)
|
|
335
|
+
|
|
336
|
+
org_id = get_org_id(config, org_id, client=client)
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
response = client.public_get("/users/me/installations")
|
|
340
|
+
before = response["data"]
|
|
341
|
+
except httpx.HTTPStatusError as exc:
|
|
342
|
+
handle_http_error(exc, "fetch installations")
|
|
343
|
+
|
|
344
|
+
install_url = f"{config.public_api_url}/oauth/github/install?organization_id={org_id}"
|
|
345
|
+
|
|
346
|
+
click.echo(f"GitHub installation URL: {install_url}")
|
|
347
|
+
if not no_browser:
|
|
348
|
+
if webbrowser.open(install_url):
|
|
349
|
+
click.echo("Opened browser for GitHub installation flow.")
|
|
350
|
+
else:
|
|
351
|
+
click.echo("Unable to open browser automatically. Please open the URL manually.", err=True)
|
|
352
|
+
|
|
353
|
+
if wait_seconds <= 0:
|
|
354
|
+
click.echo("Skipping wait for installation detection. Re-run 'avr org install list' to verify.")
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
click.echo("Waiting for installation to appear...")
|
|
358
|
+
seen_ids = {inst["platform_installation_id"] for inst in before}
|
|
359
|
+
deadline = time.time() + wait_seconds
|
|
360
|
+
while time.time() < deadline:
|
|
361
|
+
time.sleep(5)
|
|
362
|
+
try:
|
|
363
|
+
response = client.public_get("/users/me/installations")
|
|
364
|
+
current = response["data"]
|
|
365
|
+
except httpx.HTTPStatusError as exc:
|
|
366
|
+
handle_http_error(exc, "poll installations")
|
|
367
|
+
current_ids = {inst["platform_installation_id"] for inst in current}
|
|
368
|
+
added = current_ids - seen_ids
|
|
369
|
+
if added:
|
|
370
|
+
click.echo(f"Detected new installation(s): {', '.join(str(gh_id) for gh_id in sorted(added))}")
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
click.echo(
|
|
374
|
+
"Installation not detected yet. Complete the GitHub flow and rerun 'avr org install list' to verify.",
|
|
375
|
+
err=True,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@install.command("remove")
|
|
380
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
381
|
+
@click.option("--installation-id", required=True, help="Installation ID to remove (ins-xxx format)")
|
|
382
|
+
@click.option("--yes", "-y", "confirmed", is_flag=True, help="Skip confirmation prompt.")
|
|
383
|
+
@click.pass_context
|
|
384
|
+
def install_remove(ctx, org_id: str | None, installation_id: str, confirmed: bool):
|
|
385
|
+
"""Remove/suspend a GitHub installation.
|
|
386
|
+
|
|
387
|
+
Confirms before suspending; pass --yes to skip the prompt (required when
|
|
388
|
+
stdout isn't a TTY, e.g. in CI).
|
|
389
|
+
"""
|
|
390
|
+
client: ApiClient = ctx.obj["client"]
|
|
391
|
+
config: CliConfig = ctx.obj["config"]
|
|
392
|
+
ensure_authenticated(config)
|
|
393
|
+
|
|
394
|
+
org_id = get_org_id(config, org_id, client=client)
|
|
395
|
+
|
|
396
|
+
if not confirmed:
|
|
397
|
+
ensure_prompts_allowed("installation remove suspends the GitHub App for this org")
|
|
398
|
+
click.confirm(f"Suspend installation {installation_id} for {org_id}?", abort=True)
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
client.public_delete(f"/orgs/{org_id}/installations/{installation_id}")
|
|
402
|
+
click.echo(f"Installation {installation_id} suspended.")
|
|
403
|
+
except httpx.HTTPStatusError as exc:
|
|
404
|
+
handle_http_error(exc, "remove installation")
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Repository 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 get_console_url
|
|
8
|
+
from avrea_cli.display import hyperlink
|
|
9
|
+
from avrea_cli.display import is_piped
|
|
10
|
+
from avrea_cli.display import print_piped_header
|
|
11
|
+
from avrea_cli.display import print_piped_row
|
|
12
|
+
from avrea_cli.display import repo_url
|
|
13
|
+
from avrea_cli.display import truncate
|
|
14
|
+
from avrea_cli.helpers import ensure_authenticated
|
|
15
|
+
from avrea_cli.helpers import ensure_ctx
|
|
16
|
+
from avrea_cli.helpers import get_org_id
|
|
17
|
+
from avrea_cli.helpers import get_org_slug
|
|
18
|
+
from avrea_cli.helpers import handle_http_error
|
|
19
|
+
from avrea_cli.json_output import emit_json
|
|
20
|
+
from avrea_cli.json_output import handle_json_meta
|
|
21
|
+
from avrea_cli.json_output import json_options
|
|
22
|
+
from avrea_cli.json_output import make_schema
|
|
23
|
+
from avrea_cli.json_output import split_fields
|
|
24
|
+
from typing import Any
|
|
25
|
+
import click
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
_REPO_LIST_FIELDS = make_schema("repository_id", "full_name", "platform", "platform_repository_id")
|
|
29
|
+
|
|
30
|
+
# Sized so common values fit without truncation. 50 chars covers typical
|
|
31
|
+
# "org/name" lengths with headroom. Platform IDs are GitHub repo numeric
|
|
32
|
+
# IDs (10 digits today; 12 leaves room for growth).
|
|
33
|
+
_REPO_TABLE_W = {"name": 50, "id": 36, "platform": 10, "platform_id": 12}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _hdr_cell(label: str, width: int) -> str:
|
|
37
|
+
return click.style(f"{label:{width}s}", fg=DIM_FG, underline=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _print_repos_table(repos: list[dict[str, Any]], *, console_url: str = "", slug: str = "") -> None:
|
|
41
|
+
"""Sectioned-table renderer matching `avr job list` / `avr run list`.
|
|
42
|
+
|
|
43
|
+
When ``console_url`` and ``slug`` are non-empty, the repository name and
|
|
44
|
+
id cells are wrapped in OSC 8 hyperlinks to the console activity feed
|
|
45
|
+
filtered to that repo. Caller passes them only when
|
|
46
|
+
``ctx.obj['links_enabled']`` is true.
|
|
47
|
+
|
|
48
|
+
Switches to tab-separated output (header row + data rows, no color, no
|
|
49
|
+
truncation) when stdout isn't a TTY — the standard scriptability
|
|
50
|
+
convention."""
|
|
51
|
+
if is_piped():
|
|
52
|
+
print_piped_header(["repository", "repository_id", "platform", "platform_repository_id"])
|
|
53
|
+
for r in repos:
|
|
54
|
+
print_piped_row(
|
|
55
|
+
[
|
|
56
|
+
r.get("full_name", ""),
|
|
57
|
+
r.get("repository_id", ""),
|
|
58
|
+
r.get("platform", ""),
|
|
59
|
+
r.get("platform_repository_id", ""),
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
w = _REPO_TABLE_W
|
|
65
|
+
s = " "
|
|
66
|
+
click.echo(
|
|
67
|
+
f" {_hdr_cell('REPOSITORY', w['name'])}{s}"
|
|
68
|
+
f"{_hdr_cell('ID', w['id'])}{s}"
|
|
69
|
+
f"{_hdr_cell('PLATFORM', w['platform'])}{s}"
|
|
70
|
+
f"{_hdr_cell('PLATFORM ID', w['platform_id'])}"
|
|
71
|
+
)
|
|
72
|
+
for r in repos:
|
|
73
|
+
name = f"{truncate(r.get('full_name', ''), w['name'] - 2):{w['name']}s}"
|
|
74
|
+
repo_id = f"{r.get('repository_id', ''):{w['id']}s}"
|
|
75
|
+
platform = f"{r.get('platform', ''):{w['platform']}s}"
|
|
76
|
+
platform_id = str(r.get("platform_repository_id") or "")
|
|
77
|
+
# Pad-then-style for the bold name cell, OSC 8 wrap last so click's
|
|
78
|
+
# padding doesn't count escape bytes. Repo_id sits inside the table
|
|
79
|
+
# so it also needs its width preserved before linking.
|
|
80
|
+
name_cell = click.style(name, bold=True)
|
|
81
|
+
repo_id_cell = click.style(repo_id, fg="cyan")
|
|
82
|
+
platform_id_cell = click.style(platform_id, dim=True)
|
|
83
|
+
if console_url and slug and r.get("repository_id"):
|
|
84
|
+
url = repo_url(console_url, slug, r["repository_id"])
|
|
85
|
+
name_cell = hyperlink(name_cell, url)
|
|
86
|
+
repo_id_cell = hyperlink(repo_id_cell, url)
|
|
87
|
+
if console_url and slug and r.get("platform") == "github" and r.get("full_name") and platform_id:
|
|
88
|
+
platform_id_cell = hyperlink(platform_id_cell, f"https://github.com/{r['full_name']}")
|
|
89
|
+
click.echo(f" {name_cell}{s}{repo_id_cell}{s}{click.style(platform, dim=True)}{s}{platform_id_cell}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@click.group(cls=GhGroup)
|
|
93
|
+
@click.pass_context
|
|
94
|
+
def repo(ctx):
|
|
95
|
+
"""List repositories connected to Avrea."""
|
|
96
|
+
ensure_ctx(ctx)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@repo.command("list")
|
|
100
|
+
@click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
|
|
101
|
+
@click.option(
|
|
102
|
+
"-L",
|
|
103
|
+
"--limit",
|
|
104
|
+
type=click.IntRange(1, 1000),
|
|
105
|
+
default=100,
|
|
106
|
+
show_default=True,
|
|
107
|
+
help="Max repositories to return.",
|
|
108
|
+
)
|
|
109
|
+
@json_options
|
|
110
|
+
@click.pass_context
|
|
111
|
+
def repo_list(ctx, org_id, limit, json_fields, jq_expr):
|
|
112
|
+
"""List repositories you can access in an organization.
|
|
113
|
+
|
|
114
|
+
\b
|
|
115
|
+
Examples:
|
|
116
|
+
avr repo list
|
|
117
|
+
avr repo list --json full_name,repository_id
|
|
118
|
+
avr repo list --json '*' -q '.[].full_name'
|
|
119
|
+
|
|
120
|
+
\b
|
|
121
|
+
JSON FIELDS
|
|
122
|
+
full_name, platform, platform_repository_id, repository_id
|
|
123
|
+
"""
|
|
124
|
+
if handle_json_meta(json_fields, jq_expr, _REPO_LIST_FIELDS):
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
client: ApiClient = ctx.obj["client"]
|
|
128
|
+
config: CliConfig = ctx.obj["config"]
|
|
129
|
+
ensure_authenticated(config)
|
|
130
|
+
|
|
131
|
+
org_id = get_org_id(config, org_id, client=client)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
response = client.public_get(f"/orgs/{org_id}/repos", params={"limit": limit})
|
|
135
|
+
data = response.get("data") or []
|
|
136
|
+
except httpx.HTTPStatusError as exc:
|
|
137
|
+
handle_http_error(exc, "list repositories")
|
|
138
|
+
|
|
139
|
+
if json_fields is not None:
|
|
140
|
+
emit_json(data, split_fields(json_fields, _REPO_LIST_FIELDS), _REPO_LIST_FIELDS, jq_expr)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
if not data:
|
|
144
|
+
click.echo("No repositories found.")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
# Skip slug lookup when piped — `_print_repos_table` writes plain TSV
|
|
148
|
+
# without OSC 8 wrapping in that mode, so the API call would be wasted.
|
|
149
|
+
links_enabled = ctx.obj.get("links_enabled", False) and not is_piped()
|
|
150
|
+
link_console_url = get_console_url(config.public_api_url) if links_enabled else ""
|
|
151
|
+
link_slug = get_org_slug(client, org_id) if links_enabled else ""
|
|
152
|
+
_print_repos_table(data, console_url=link_console_url, slug=link_slug)
|