avr-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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)