avr-cli 0.4.0__tar.gz → 0.4.1__tar.gz

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.
Files changed (96) hide show
  1. {avr_cli-0.4.0 → avr_cli-0.4.1}/PKG-INFO +1 -1
  2. {avr_cli-0.4.0 → avr_cli-0.4.1}/avr_cli.egg-info/PKG-INFO +1 -1
  3. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/_build_info.py +1 -1
  4. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/sbom.py +111 -26
  5. {avr_cli-0.4.0 → avr_cli-0.4.1}/pyproject.toml +1 -1
  6. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_sbom.py +192 -5
  7. {avr_cli-0.4.0 → avr_cli-0.4.1}/LICENSE +0 -0
  8. {avr_cli-0.4.0 → avr_cli-0.4.1}/README.md +0 -0
  9. {avr_cli-0.4.0 → avr_cli-0.4.1}/avr_cli.egg-info/SOURCES.txt +0 -0
  10. {avr_cli-0.4.0 → avr_cli-0.4.1}/avr_cli.egg-info/dependency_links.txt +0 -0
  11. {avr_cli-0.4.0 → avr_cli-0.4.1}/avr_cli.egg-info/entry_points.txt +0 -0
  12. {avr_cli-0.4.0 → avr_cli-0.4.1}/avr_cli.egg-info/requires.txt +0 -0
  13. {avr_cli-0.4.0 → avr_cli-0.4.1}/avr_cli.egg-info/top_level.txt +0 -0
  14. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/__init__.py +0 -0
  15. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/api_client.py +0 -0
  16. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/assets/logo_mark.svg +0 -0
  17. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/audit.py +0 -0
  18. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/auth.py +0 -0
  19. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/billing.py +0 -0
  20. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/click_ext.py +0 -0
  21. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/__init__.py +0 -0
  22. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/auth_cmd.py +0 -0
  23. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/cache.py +0 -0
  24. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/config_cmd.py +0 -0
  25. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/health.py +0 -0
  26. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/internal.py +0 -0
  27. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/job.py +0 -0
  28. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/log.py +0 -0
  29. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/org.py +0 -0
  30. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/pr.py +0 -0
  31. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/repo.py +0 -0
  32. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/run.py +0 -0
  33. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/settings.py +0 -0
  34. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/status.py +0 -0
  35. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/commands/workflow.py +0 -0
  36. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/config.py +0 -0
  37. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/display.py +0 -0
  38. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/docs_gen.py +0 -0
  39. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/firewall.py +0 -0
  40. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/helpers.py +0 -0
  41. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/json_output.py +0 -0
  42. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/log_display.py +0 -0
  43. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/main.py +0 -0
  44. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/metrics_display.py +0 -0
  45. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/output.py +0 -0
  46. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/paths.py +0 -0
  47. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/repo_context.py +0 -0
  48. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/run_diagnostics.py +0 -0
  49. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/run_diagnostics_display.py +0 -0
  50. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/run_refs.py +0 -0
  51. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/transport.py +0 -0
  52. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/version.py +0 -0
  53. {avr_cli-0.4.0 → avr_cli-0.4.1}/avrea_cli/vm.py +0 -0
  54. {avr_cli-0.4.0 → avr_cli-0.4.1}/setup.cfg +0 -0
  55. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_api_client.py +0 -0
  56. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_auth.py +0 -0
  57. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_auth_login.py +0 -0
  58. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_auth_sso.py +0 -0
  59. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_cache.py +0 -0
  60. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_config.py +0 -0
  61. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_config_cmd.py +0 -0
  62. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_display.py +0 -0
  63. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_docs_gen.py +0 -0
  64. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_env_conventions.py +0 -0
  65. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_firewall.py +0 -0
  66. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_global_flags.py +0 -0
  67. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_helpers.py +0 -0
  68. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_job_list.py +0 -0
  69. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_job_metrics_cmd.py +0 -0
  70. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_job_view_logs.py +0 -0
  71. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_json_output.py +0 -0
  72. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_lazy_registry.py +0 -0
  73. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_log_display.py +0 -0
  74. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_metrics_display.py +0 -0
  75. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_org_email_domain.py +0 -0
  76. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_org_saml.py +0 -0
  77. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_output.py +0 -0
  78. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_piped_output.py +0 -0
  79. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_pr_list.py +0 -0
  80. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_public_mirrors.py +0 -0
  81. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_repo_context.py +0 -0
  82. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_repo_mirror.py +0 -0
  83. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_run_attempts.py +0 -0
  84. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_run_command_refs.py +0 -0
  85. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_run_diagnose.py +0 -0
  86. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_run_list_flags.py +0 -0
  87. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_run_logs.py +0 -0
  88. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_run_refs.py +0 -0
  89. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_run_view.py +0 -0
  90. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_settings.py +0 -0
  91. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_transport.py +0 -0
  92. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_vm.py +0 -0
  93. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_web_flag.py +0 -0
  94. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_workflow_actions.py +0 -0
  95. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_workflow_run.py +0 -0
  96. {avr_cli-0.4.0 → avr_cli-0.4.1}/tests/test_workflow_view.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: avr-cli
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Avrea command-line client
5
5
  Author-email: Avrea <support@avrea.com>
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: avr-cli
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Avrea command-line client
5
5
  Author-email: Avrea <support@avrea.com>
6
6
  License-Expression: Apache-2.0
@@ -1,2 +1,2 @@
1
1
  # Auto-generated by .github/workflows/release.yml; do not edit.
2
- GIT_COMMIT = "5d74471e85fe9ce7a40209ef3daebaad435c38d8"
2
+ GIT_COMMIT = "de175ab9edd3880dab9a4eb3614fff64c9015397"
@@ -30,6 +30,8 @@ import shlex
30
30
  import time
31
31
 
32
32
  _COMMIT_SHA_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
33
+ _COMMIT_PREFIX_RE = re.compile(r"^[0-9a-f]{7,64}$")
34
+ _HISTORY_PAGE_LIMIT = 1000
33
35
 
34
36
  _ARTIFACT_FILENAMES = {
35
37
  "cyclonedx": "bom.cyclonedx.json",
@@ -55,8 +57,8 @@ def _validate_commit(_ctx: click.Context, _param: click.Parameter, value: str |
55
57
  if value is None:
56
58
  return None
57
59
  sha = value.strip().lower()
58
- if not _COMMIT_SHA_RE.match(sha):
59
- raise click.BadParameter("must be a full 40 or 64 character hex commit SHA.")
60
+ if not _COMMIT_PREFIX_RE.match(sha):
61
+ raise click.BadParameter("must be a hex commit SHA or a prefix of at least 7 characters.")
60
62
  return sha
61
63
 
62
64
 
@@ -70,14 +72,56 @@ def _strip_ref(_ctx: click.Context, _param: click.Parameter, value: str | None)
70
72
  return ref
71
73
 
72
74
 
75
+ _commit_argument = click.argument("commit", required=False, callback=_validate_commit)
73
76
  _commit_option = click.option(
74
77
  "--commit",
75
- "commit_sha",
78
+ "commit_option",
76
79
  callback=_validate_commit,
77
- help="Full commit SHA of a recorded SBOM. Defaults to the latest SBOM.",
80
+ help="Same as the COMMIT argument.",
78
81
  )
79
82
 
80
83
 
84
+ def _requested_commit(commit: str | None, commit_option: str | None) -> str | None:
85
+ if commit and commit_option and commit != commit_option:
86
+ raise click.UsageError("Pass the commit either as the COMMIT argument or with --commit, not both.")
87
+ return commit or commit_option
88
+
89
+
90
+ def _resolve_commit(client: ApiClient, org_id: str, repo_id: str, commit: str) -> str:
91
+ """The full SHA for ``commit``, a full SHA or a prefix of one.
92
+
93
+ The API addresses SBOMs by full SHA only, so a prefix is matched against the
94
+ recorded history. Matching stops at a second hit, which already makes the
95
+ prefix ambiguous."""
96
+ if _COMMIT_SHA_RE.match(commit):
97
+ return commit
98
+ matches: list[str] = []
99
+ params: dict[str, Any] = {"limit": _HISTORY_PAGE_LIMIT}
100
+ while len(matches) < 2:
101
+ try:
102
+ response = client.public_get(_sbom_path(org_id, repo_id), params=params)
103
+ except httpx.HTTPStatusError as exc:
104
+ handle_http_error(exc, "list SBOMs")
105
+ for row in response.get("data", []):
106
+ sha = row.get("commit_sha") or ""
107
+ if sha.startswith(commit) and sha not in matches:
108
+ matches.append(sha)
109
+ next_cursor = (response.get("pagination") or {}).get("next_cursor")
110
+ if not next_cursor:
111
+ break
112
+ params = {"limit": _HISTORY_PAGE_LIMIT, "cursor": next_cursor}
113
+
114
+ if not matches:
115
+ raise click.ClickException(
116
+ f"No recorded SBOM matches commit {commit}. Run `avr sbom list` to see the recorded commits."
117
+ )
118
+ if len(matches) > 1:
119
+ raise click.ClickException(
120
+ f"Commit {commit} is ambiguous; it matches {matches[0]} and {matches[1]}. Use a longer prefix."
121
+ )
122
+ return matches[0]
123
+
124
+
81
125
  def _resolve_scope(ctx: click.Context, org_id: str | None, repo_id: str | None) -> tuple[ApiClient, str, str]:
82
126
  client: ApiClient = ctx.obj["client"]
83
127
  config: CliConfig = ctx.obj["config"]
@@ -239,18 +283,23 @@ _SBOM_VIEW_FIELDS = make_schema(
239
283
 
240
284
 
241
285
  @sbom.command("view")
286
+ @_commit_argument
242
287
  @_repo_option
243
288
  @_org_option
244
289
  @_commit_option
245
290
  @json_options
246
291
  @click.pass_context
247
- def sbom_view(ctx, repo_id, org_id, commit_sha, json_fields, jq_expr):
292
+ def sbom_view(ctx, commit, repo_id, org_id, commit_option, json_fields, jq_expr):
248
293
  """Show an SBOM's summary: dependency counts, licences, and artifacts.
249
294
 
295
+ COMMIT is the commit of a recorded SBOM, as a full SHA or a unique prefix
296
+ of at least 7 characters such as the one `avr sbom list` shows. Defaults to
297
+ the latest SBOM.
298
+
250
299
  \b
251
300
  Examples:
252
301
  avr sbom view --repo acme/api
253
- avr sbom view --repo acme/api --commit 3f2c...e91a
302
+ avr sbom view 3f2c9a1b7d04 --repo acme/api
254
303
  avr sbom view --repo acme/api --json summary --jq .summary.licenses
255
304
 
256
305
  \b
@@ -260,7 +309,10 @@ def sbom_view(ctx, repo_id, org_id, commit_sha, json_fields, jq_expr):
260
309
  """
261
310
  if handle_json_meta(json_fields, jq_expr, _SBOM_VIEW_FIELDS):
262
311
  return
312
+ commit_sha = _requested_commit(commit, commit_option)
263
313
  client, org_id, repo_id = _resolve_scope(ctx, org_id, repo_id)
314
+ if commit_sha is not None:
315
+ commit_sha = _resolve_commit(client, org_id, repo_id, commit_sha)
264
316
  snapshot = _fetch_snapshot(client, org_id, repo_id, commit_sha)
265
317
 
266
318
  if json_fields is not None:
@@ -276,12 +328,11 @@ def sbom_view(ctx, repo_id, org_id, commit_sha, json_fields, jq_expr):
276
328
  "avr",
277
329
  "sbom",
278
330
  "download",
331
+ snapshot.get("commit_sha", ""),
279
332
  "--org",
280
333
  org_id,
281
334
  "--repo",
282
335
  repo_id,
283
- "--commit",
284
- snapshot.get("commit_sha", ""),
285
336
  "--format",
286
337
  "inventory",
287
338
  ]
@@ -342,6 +393,7 @@ def sbom_view(ctx, repo_id, org_id, commit_sha, json_fields, jq_expr):
342
393
 
343
394
 
344
395
  @sbom.command("download")
396
+ @_commit_argument
345
397
  @_repo_option
346
398
  @_org_option
347
399
  @_commit_option
@@ -360,18 +412,25 @@ def sbom_view(ctx, repo_id, org_id, commit_sha, json_fields, jq_expr):
360
412
  help='Output file path, or "-" for stdout. Defaults to the artifact filename in the current directory.',
361
413
  )
362
414
  @click.pass_context
363
- def sbom_download(ctx, repo_id, org_id, commit_sha, artifact_format, out_path):
415
+ def sbom_download(ctx, commit, repo_id, org_id, commit_option, artifact_format, out_path):
364
416
  """Download an SBOM artifact (CycloneDX, SPDX, or dependency inventory).
365
417
 
418
+ COMMIT is the commit of a recorded SBOM, as a full SHA or a unique prefix
419
+ of at least 7 characters such as the one `avr sbom list` shows. Defaults to
420
+ the latest SBOM.
421
+
366
422
  \b
367
423
  Examples:
368
424
  avr sbom download --repo acme/api
369
425
  avr sbom download --repo acme/api --format spdx --out api.spdx.json
370
- avr sbom download --repo acme/api --commit 3f2c...e91a --out - | jq .components
426
+ avr sbom download 3f2c9a1b7d04 --repo acme/api --out - | jq .components
371
427
  """
428
+ commit_sha = _requested_commit(commit, commit_option)
372
429
  client, org_id, repo_id = _resolve_scope(ctx, org_id, repo_id)
373
430
  if commit_sha is None:
374
431
  commit_sha = _fetch_snapshot(client, org_id, repo_id, None)["commit_sha"]
432
+ else:
433
+ commit_sha = _resolve_commit(client, org_id, repo_id, commit_sha)
375
434
 
376
435
  filename = _ARTIFACT_FILENAMES[artifact_format]
377
436
  try:
@@ -508,14 +567,10 @@ class _TaskTracker:
508
567
 
509
568
 
510
569
  @sbom.command("generate")
570
+ @click.argument("ref", required=False, callback=_strip_ref)
511
571
  @_repo_option
512
572
  @_org_option
513
- @click.option(
514
- "--ref",
515
- default=None,
516
- callback=_strip_ref,
517
- help="Branch, tag, or commit SHA to analyse. Defaults to the default-branch tip.",
518
- )
573
+ @click.option("--ref", "ref_option", default=None, callback=_strip_ref, help="Same as the REF argument.")
519
574
  @click.option("--wait", is_flag=True, default=False, help="Wait until generation finishes before returning.")
520
575
  @click.option(
521
576
  "--wait-timeout",
@@ -526,17 +581,22 @@ class _TaskTracker:
526
581
  )
527
582
  @json_options
528
583
  @click.pass_context
529
- def sbom_generate(ctx, repo_id, org_id, ref, wait, wait_timeout, json_fields, jq_expr):
584
+ def sbom_generate(ctx, ref, repo_id, org_id, ref_option, wait, wait_timeout, json_fields, jq_expr):
530
585
  """Start SBOM generation for a repository.
531
586
 
587
+ REF is the branch, tag, or commit SHA to analyse; an abbreviated SHA such
588
+ as the one `avr sbom list` shows works too. Defaults to the default-branch
589
+ tip. Without --wait, commit_sha is the full SHA of the commit the run
590
+ analyses when REF is a full SHA or one the API expanded.
591
+
532
592
  One analysis runs per repository at a time. A request for the same ref as
533
593
  the analysis already running joins it; a request for a different ref is
534
594
  rejected (HTTP 409) until that analysis finishes. Once an SBOM has been
535
595
  recorded for the repository, a new run is refused (HTTP 429) for a
536
596
  cooldown period, and the error says how many seconds remain.
537
597
 
538
- With --wait and --ref set to a full commit SHA, success means a new SBOM
539
- for that commit is downloadable, whatever the analysis task's own outcome
598
+ With --wait and a REF that names a commit, success means a new SBOM for
599
+ that commit is downloadable, whatever the analysis task's own outcome
540
600
  (task_status); commit_sha and recorded_at identify it. A failed analysis
541
601
  can still deliver its SBOM later, so such a wait runs to --wait-timeout
542
602
  before reporting failure. For a branch, tag, or the default branch the API
@@ -547,8 +607,8 @@ def sbom_generate(ctx, repo_id, org_id, ref, wait, wait_timeout, json_fields, jq
547
607
  \b
548
608
  Examples:
549
609
  avr sbom generate --repo acme/api
550
- avr sbom generate --repo acme/api --ref v1.4.0
551
- avr sbom generate --repo acme/api --ref "$(git rev-parse HEAD)" --wait
610
+ avr sbom generate v1.4.0 --repo acme/api
611
+ avr sbom generate 3f2c9a1b7d04 --repo acme/api --wait
552
612
 
553
613
  \b
554
614
  JSON FIELDS
@@ -556,6 +616,9 @@ def sbom_generate(ctx, repo_id, org_id, ref, wait, wait_timeout, json_fields, jq
556
616
  """
557
617
  if handle_json_meta(json_fields, jq_expr, _SBOM_GENERATE_FIELDS):
558
618
  return
619
+ if ref and ref_option and ref != ref_option:
620
+ raise click.UsageError("Pass the ref either as the REF argument or with --ref, not both.")
621
+ ref = ref or ref_option
559
622
  output = _JsonOutput(
560
623
  fields=split_fields(json_fields, _SBOM_GENERATE_FIELDS) if json_fields is not None else [],
561
624
  jq_expr=jq_expr,
@@ -585,13 +648,20 @@ def sbom_generate(ctx, repo_id, org_id, ref, wait, wait_timeout, json_fields, jq
585
648
  "recorded_at": None,
586
649
  }
587
650
 
651
+ expanded_sha = response.get("commit_sha")
652
+ expanded = commit_sha is None and isinstance(expanded_sha, str) and _COMMIT_SHA_RE.match(expanded_sha)
653
+ if expanded:
654
+ commit_sha = expanded_sha
655
+
588
656
  if not wait:
657
+ result["commit_sha"] = commit_sha
589
658
  if output.emit(result):
590
659
  return
660
+ target = f" for {commit_sha[:12]}" if commit_sha else ""
591
661
  if result["status"] == "already_running":
592
- click.echo(f"SBOM generation already running (task {task_id}); joined it.")
662
+ click.echo(f"SBOM generation already running{target} (task {task_id}); joined it.")
593
663
  else:
594
- click.echo(f"SBOM generation started (task {task_id}).")
664
+ click.echo(f"SBOM generation started{target} (task {task_id}).")
595
665
  click.echo("Generation takes a few minutes; run `avr sbom view` afterwards to see the result.", err=True)
596
666
  return
597
667
 
@@ -603,8 +673,13 @@ def sbom_generate(ctx, repo_id, org_id, ref, wait, wait_timeout, json_fields, jq
603
673
  _wait_for_task_outcome(waiter)
604
674
  return
605
675
  snapshot_path = f"{_sbom_path(org_id, repo_id)}/commits/{commit_sha}"
676
+ if expanded:
677
+ # The API expanded an abbreviated SHA, so the baseline is read only now,
678
+ # after the run has started; retry it like the wait's own reads.
679
+ baseline = _snapshot_after_request(waiter, snapshot_path, commit_sha)
606
680
  baseline_recorded_at = baseline.get("recorded_at") if baseline else None
607
- _wait_for_commit_delivery(waiter, snapshot_path, commit_sha, baseline_recorded_at)
681
+ view_command = shlex.join(["avr", "sbom", "view", commit_sha, "--org", org_id, "--repo", repo_id])
682
+ _wait_for_commit_delivery(waiter, snapshot_path, commit_sha, baseline_recorded_at, view_command)
608
683
 
609
684
 
610
685
  @dataclass(frozen=True)
@@ -647,6 +722,16 @@ class _Wait:
647
722
  raise SystemExit(1)
648
723
 
649
724
 
725
+ def _snapshot_after_request(waiter: _Wait, snapshot_path: str, commit_sha: str) -> dict[str, Any] | None:
726
+ """The commit's current snapshot, or None; transient errors retry until the deadline."""
727
+ while True:
728
+ try:
729
+ return waiter.poller.get(snapshot_path, action="check for the commit's SBOM", missing_ok=True)
730
+ except _TransientPollError as exc:
731
+ if not waiter.poller.sleep(exc.backoff):
732
+ waiter.time_out(f"the SBOM for {commit_sha[:12]} could not be checked")
733
+
734
+
650
735
  def _wait_for_task_outcome(waiter: _Wait) -> None:
651
736
  """Report the analysis task's own outcome; no SBOM can be attributed to it."""
652
737
  tracker = waiter.tracker
@@ -690,7 +775,7 @@ def _echo_task_outcome(tracker: _TaskTracker) -> None:
690
775
 
691
776
 
692
777
  def _wait_for_commit_delivery(
693
- waiter: _Wait, snapshot_path: str, commit_sha: str, baseline_recorded_at: str | None
778
+ waiter: _Wait, snapshot_path: str, commit_sha: str, baseline_recorded_at: str | None, view_command: str
694
779
  ) -> None:
695
780
  """Wait for the commit's SBOM to be recorded after the baseline.
696
781
 
@@ -731,7 +816,7 @@ def _wait_for_commit_delivery(
731
816
  "producing the SBOM.",
732
817
  err=True,
733
818
  )
734
- click.echo(f"SBOM generated for {short_sha}. Run `avr sbom view --commit {commit_sha}` to see it.")
819
+ click.echo(f"SBOM generated for {short_sha}. Run `{view_command}` to see it.")
735
820
 
736
821
 
737
822
  def _echo_undelivered(tracker: _TaskTracker, short_sha: str, wait_timeout: int) -> None:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "avr-cli"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "Avrea command-line client"
9
9
  authors = [{ name = "Avrea", email = "support@avrea.com" }]
10
10
  readme = "README.md"
@@ -67,6 +67,7 @@ HISTORY = {
67
67
  ],
68
68
  "pagination": {"next_cursor": "Y3Vyc29y"},
69
69
  }
70
+ HISTORY_LAST_PAGE = {**HISTORY, "pagination": {"next_cursor": None}}
70
71
 
71
72
 
72
73
  def _http_error(status: int, detail: str = "", headers: dict[str, str] | None = None) -> httpx.HTTPStatusError:
@@ -201,12 +202,37 @@ class TestSbomView:
201
202
  assert result.exit_code == 0, result.output
202
203
  assert calls.log[-1][1] == f"{BASE}/commits/{SHA}"
203
204
 
204
- def test_rejects_short_commit(self, runner, calls):
205
- result = runner.invoke(cli, ["sbom", "view", "--repo", "rep-123", "--commit", "abc123"])
205
+ def test_commit_as_argument(self, runner, calls):
206
+ calls.route("GET", f"{BASE}/commits/{SHA}", SNAPSHOT)
207
+ result = runner.invoke(cli, ["sbom", "view", SHA, "--repo", "rep-123"])
208
+ assert result.exit_code == 0, result.output
209
+ assert calls.log[-1][1] == f"{BASE}/commits/{SHA}"
210
+
211
+ def test_short_commit_resolves_through_history(self, runner, calls):
212
+ calls.route("GET", BASE, HISTORY_LAST_PAGE)
213
+ calls.route("GET", f"{BASE}/commits/{SHA}", SNAPSHOT)
214
+ result = runner.invoke(cli, ["sbom", "view", SHA[:12], "--repo", "rep-123"])
215
+ assert result.exit_code == 0, result.output
216
+ assert calls.log[-1][1] == f"{BASE}/commits/{SHA}"
217
+
218
+ @pytest.mark.parametrize("commit", ["abc123", "xyz1234567", "a" * 65, " "])
219
+ def test_rejects_invalid_commit(self, runner, calls, commit):
220
+ result = runner.invoke(cli, ["sbom", "view", "--repo", "rep-123", "--commit", commit])
221
+ assert result.exit_code == 2
222
+ assert "at least 7" in result.output
223
+ assert calls.log == []
224
+
225
+ def test_rejects_conflicting_commit_argument_and_option(self, runner, calls):
226
+ result = runner.invoke(cli, ["sbom", "view", SHA, "--repo", "rep-123", "--commit", OTHER_SHA])
206
227
  assert result.exit_code == 2
207
- assert "40 or 64" in result.output
228
+ assert "--commit" in result.output
208
229
  assert calls.log == []
209
230
 
231
+ def test_accepts_same_commit_as_argument_and_option(self, runner, calls):
232
+ calls.route("GET", f"{BASE}/commits/{SHA}", SNAPSHOT)
233
+ result = runner.invoke(cli, ["sbom", "view", SHA, "--repo", "rep-123", "--commit", SHA.upper()])
234
+ assert result.exit_code == 0, result.output
235
+
210
236
  def test_not_found_hints_generate(self, runner, calls):
211
237
  calls.route("GET", f"{BASE}/latest", _http_error(404, "analysis result not found"))
212
238
  result = runner.invoke(cli, ["sbom", "view", "--repo", "rep-123"])
@@ -251,14 +277,14 @@ class TestSbomView:
251
277
  omitted = {"omitted": "oversized", "serialized_bytes": 143362}
252
278
  calls.route("GET", f"{BASE}/commits/{SHA}", {**SNAPSHOT, "summary": omitted})
253
279
  result = runner.invoke(cli, ["sbom", "view", "--repo", "rep-123", "--commit", SHA])
254
- expected = f"avr sbom download --org org-default --repo rep-123 --commit {SHA} --format inventory"
280
+ expected = f"avr sbom download {SHA} --org org-default --repo rep-123 --format inventory"
255
281
  assert expected in result.output
256
282
 
257
283
  def test_copyleft_overflow_hint_pins_the_viewed_snapshot(self, runner, calls):
258
284
  summary = {**SUMMARY, "copyleft": ["a"], "copyleft_omitted": 3}
259
285
  calls.route("GET", f"{BASE}/latest", {**SNAPSHOT, "summary": summary})
260
286
  result = runner.invoke(cli, ["sbom", "view", "--repo", "rep-123"])
261
- expected = f"avr sbom download --org org-default --repo rep-123 --commit {SHA} --format inventory"
287
+ expected = f"avr sbom download {SHA} --org org-default --repo rep-123 --format inventory"
262
288
  assert expected in result.output
263
289
 
264
290
  def test_inventory_hint_quotes_values_for_the_shell(self, runner, calls):
@@ -353,6 +379,72 @@ class TestSbomDownload:
353
379
  assert "no artifact" in result.output
354
380
  assert not (tmp_path / "x").exists()
355
381
 
382
+ def test_short_commit_argument_downloads_the_resolved_commit(self, runner, calls):
383
+ calls.route("GET", BASE, HISTORY_LAST_PAGE)
384
+ calls.route("GET_BYTES", f"{BASE}/commits/{SHA}/artifacts/bom.cyclonedx.json", b'{"ok":true}')
385
+ result = runner.invoke(cli, ["sbom", "download", SHA[:12], "--repo", "rep-123", "--out", "-"])
386
+ assert result.exit_code == 0, result.output
387
+ assert result.stdout == '{"ok":true}'
388
+ assert calls.log[-1] == ("GET_BYTES", f"{BASE}/commits/{SHA}/artifacts/bom.cyclonedx.json", None)
389
+
390
+
391
+ AMBIGUOUS_SHA = "a" * 12 + "f" * 28
392
+
393
+
394
+ def _history_page(*shas: str, next_cursor: str | None = None) -> dict[str, Any]:
395
+ return {"data": [{"commit_sha": sha} for sha in shas], "pagination": {"next_cursor": next_cursor}}
396
+
397
+
398
+ class TestSbomCommitPrefix:
399
+ def test_full_commit_skips_history(self, runner, calls):
400
+ calls.route("GET_BYTES", f"{BASE}/commits/{SHA}/artifacts/bom.cyclonedx.json", b"{}")
401
+ result = runner.invoke(cli, ["sbom", "download", SHA, "--repo", "rep-123", "--out", "-"])
402
+ assert result.exit_code == 0, result.output
403
+ assert [c[0] for c in calls.log] == ["GET_BYTES"]
404
+
405
+ def test_prefix_is_case_insensitive(self, runner, calls):
406
+ calls.route("GET", BASE, HISTORY_LAST_PAGE)
407
+ calls.route("GET_BYTES", f"{BASE}/commits/{SHA}/artifacts/bom.cyclonedx.json", b"{}")
408
+ result = runner.invoke(cli, ["sbom", "download", SHA[:7].upper(), "--repo", "rep-123", "--out", "-"])
409
+ assert result.exit_code == 0, result.output
410
+
411
+ def test_resolution_pages_through_history(self, runner, calls):
412
+ calls.route("GET", BASE, [_history_page(OTHER_SHA, next_cursor="page2"), _history_page(SHA)])
413
+ calls.route("GET_BYTES", f"{BASE}/commits/{SHA}/artifacts/bom.cyclonedx.json", b"{}")
414
+ result = runner.invoke(cli, ["sbom", "download", SHA[:7], "--repo", "rep-123", "--out", "-"])
415
+ assert result.exit_code == 0, result.output
416
+ history_params = [c[2] for c in calls.log if c[:2] == ("GET", BASE)]
417
+ assert history_params == [{"limit": 1000}, {"limit": 1000, "cursor": "page2"}]
418
+
419
+ def test_unknown_prefix_names_the_list_command(self, runner, calls):
420
+ calls.route("GET", BASE, _history_page(OTHER_SHA))
421
+ result = runner.invoke(cli, ["sbom", "download", SHA[:7], "--repo", "rep-123"])
422
+ assert result.exit_code == 1
423
+ assert SHA[:7] in result.output
424
+ assert "avr sbom list" in result.output
425
+ assert [c[0] for c in calls.log] == ["GET"]
426
+
427
+ def test_ambiguous_prefix_lists_candidates(self, runner, calls):
428
+ calls.route("GET", BASE, [_history_page(SHA, next_cursor="page2"), _history_page(AMBIGUOUS_SHA)])
429
+ result = runner.invoke(cli, ["sbom", "view", SHA[:10], "--repo", "rep-123"])
430
+ assert result.exit_code == 1
431
+ assert "ambiguous" in result.output
432
+ assert SHA in result.output
433
+ assert AMBIGUOUS_SHA in result.output
434
+
435
+ def test_longer_prefix_disambiguates(self, runner, calls):
436
+ calls.route("GET", BASE, _history_page(SHA, AMBIGUOUS_SHA))
437
+ calls.route("GET", f"{BASE}/commits/{SHA}", SNAPSHOT)
438
+ result = runner.invoke(cli, ["sbom", "view", SHA[:13], "--repo", "rep-123"])
439
+ assert result.exit_code == 0, result.output
440
+ assert calls.log[-1][1] == f"{BASE}/commits/{SHA}"
441
+
442
+ def test_history_error_is_reported(self, runner, calls):
443
+ calls.route("GET", BASE, _http_error(403, "forbidden"))
444
+ result = runner.invoke(cli, ["sbom", "view", SHA[:7], "--repo", "rep-123"])
445
+ assert result.exit_code == 1
446
+ assert "forbidden" in result.output
447
+
356
448
 
357
449
  STATE_PATH = "/orgs/org-default/repos/rep-123/ai-task/state"
358
450
  LATEST = f"{BASE}/latest"
@@ -587,6 +679,7 @@ class TestSbomGenerateWaitForCommit:
587
679
  result = runner.invoke(cli, ["sbom", "generate", "--repo", "rep-123", "--ref", NEW_SHA, "--wait"])
588
680
  assert result.exit_code == 0, result.output
589
681
  assert f"SBOM generated for {NEW_SHA[:12]}" in result.output
682
+ assert f"avr sbom view {NEW_SHA} --org org-default --repo rep-123" in result.output
590
683
 
591
684
  def test_unrelated_snapshot_arrival_does_not_satisfy_wait(self, runner, calls):
592
685
  calls.route("GET", COMMIT_PATH, PRIOR_FOR_COMMIT)
@@ -725,6 +818,100 @@ class TestSbomGenerateWaitForCommit:
725
818
  assert calls.log == []
726
819
 
727
820
 
821
+ SHORT_NEW_SHA = NEW_SHA[:12]
822
+
823
+
824
+ class TestSbomGenerateRefArgument:
825
+ def test_usage_shows_ref_as_optional(self, runner):
826
+ result = runner.invoke(cli, ["sbom", "generate", "--help"])
827
+ assert "generate [OPTIONS] [REF]" in result.output
828
+
829
+ def test_ref_as_argument(self, runner, calls):
830
+ calls.route("POST", f"{BASE}/generate", {"ai_task_id": "task-1", "status": "generating"})
831
+ result = runner.invoke(cli, ["sbom", "generate", "v1.2.0", "--repo", "rep-123"])
832
+ assert result.exit_code == 0, result.output
833
+ assert calls.log[-1][2] == {"scope": "sbom", "ref": "v1.2.0"}
834
+
835
+ def test_rejects_conflicting_ref_argument_and_option(self, runner, calls):
836
+ result = runner.invoke(cli, ["sbom", "generate", "v1.2.0", "--repo", "rep-123", "--ref", "main"])
837
+ assert result.exit_code == 2
838
+ assert "REF argument or with --ref, not both" in result.output
839
+ assert calls.log == []
840
+
841
+ def test_short_sha_is_sent_for_the_api_to_expand(self, runner, calls):
842
+ calls.route("POST", f"{BASE}/generate", {"ai_task_id": "task-1", "status": "generating"})
843
+ result = runner.invoke(cli, ["sbom", "generate", SHORT_NEW_SHA, "--repo", "rep-123"])
844
+ assert result.exit_code == 0, result.output
845
+ assert calls.log == [("POST", f"{BASE}/generate", {"scope": "sbom", "ref": SHORT_NEW_SHA})]
846
+
847
+ def test_wait_follows_the_commit_the_api_expanded(self, runner, calls):
848
+ calls.route("POST", f"{BASE}/generate", {"ai_task_id": "task-1", "status": "generating", "commit_sha": NEW_SHA})
849
+ calls.route("GET", COMMIT_PATH, [_http_error(404, "analysis result not found"), NEW_SNAPSHOT])
850
+ calls.route("GET", STATE_PATH, {"ai_task_id": "task-1", "status": "RUNNING"})
851
+ result = runner.invoke(
852
+ cli, ["sbom", "generate", SHORT_NEW_SHA, "--repo", "rep-123", "--wait", "--json", "status,commit_sha"]
853
+ )
854
+ assert result.exit_code == 0, result.output
855
+ assert json.loads(result.stdout) == {"status": "completed", "commit_sha": NEW_SHA}
856
+ assert [c[1] for c in calls.log[:2]] == [f"{BASE}/generate", COMMIT_PATH]
857
+
858
+ def test_wait_for_an_expanded_commit_needs_a_new_delivery(self, runner, calls):
859
+ calls.route("POST", f"{BASE}/generate", {"ai_task_id": "task-1", "status": "generating", "commit_sha": NEW_SHA})
860
+ calls.route("GET", COMMIT_PATH, [PRIOR_FOR_COMMIT, PRIOR_FOR_COMMIT, NEW_SNAPSHOT])
861
+ calls.route("GET", STATE_PATH, {"ai_task_id": "task-1", "status": "RUNNING"})
862
+ result = runner.invoke(
863
+ cli, ["sbom", "generate", SHORT_NEW_SHA, "--repo", "rep-123", "--wait", "--json", "recorded_at"]
864
+ )
865
+ assert result.exit_code == 0, result.output
866
+ assert json.loads(result.stdout) == {"recorded_at": NEW_SNAPSHOT["recorded_at"]}
867
+
868
+ @pytest.mark.parametrize("expanded", [{}, {"commit_sha": None}, {"commit_sha": SHORT_NEW_SHA}])
869
+ def test_wait_follows_the_task_without_an_expanded_commit(self, runner, calls, expanded):
870
+ calls.route("POST", f"{BASE}/generate", {"ai_task_id": "task-1", "status": "generating", **expanded})
871
+ calls.route("GET", STATE_PATH, {"ai_task_id": "task-1", "status": "COMPLETED"})
872
+ result = runner.invoke(
873
+ cli, ["sbom", "generate", SHORT_NEW_SHA, "--repo", "rep-123", "--wait", "--json", "status,commit_sha"]
874
+ )
875
+ assert result.exit_code == 0, result.output
876
+ assert json.loads(result.stdout) == {"status": "completed", "commit_sha": None}
877
+ assert all("/commits/" not in c[1] for c in calls.log)
878
+
879
+ @pytest.mark.parametrize("failure", [_http_error(503), httpx.ConnectError("connection reset")])
880
+ def test_baseline_after_the_request_retries_transient_errors(self, runner, calls, failure):
881
+ calls.route("POST", f"{BASE}/generate", {"ai_task_id": "task-1", "status": "generating", "commit_sha": NEW_SHA})
882
+ calls.route("GET", COMMIT_PATH, [failure, PRIOR_FOR_COMMIT, PRIOR_FOR_COMMIT, NEW_SNAPSHOT])
883
+ calls.route("GET", STATE_PATH, {"ai_task_id": "task-1", "status": "RUNNING"})
884
+ result = runner.invoke(
885
+ cli, ["sbom", "generate", SHORT_NEW_SHA, "--repo", "rep-123", "--wait", "--json", "recorded_at"]
886
+ )
887
+ assert result.exit_code == 0, result.output
888
+ assert json.loads(result.stdout) == {"recorded_at": NEW_SNAPSHOT["recorded_at"]}
889
+
890
+ def test_baseline_after_the_request_that_never_succeeds_times_out(self, runner, calls):
891
+ calls.route("POST", f"{BASE}/generate", {"ai_task_id": "task-1", "status": "generating", "commit_sha": NEW_SHA})
892
+ calls.route("GET", COMMIT_PATH, _http_error(503))
893
+ args = ["sbom", "generate", SHORT_NEW_SHA, "--repo", "rep-123", "--wait", "--wait-timeout", "30"]
894
+ result = runner.invoke(cli, args)
895
+ assert result.exit_code == 1
896
+ assert f"the SBOM for {SHORT_NEW_SHA} could not be checked" in result.output
897
+ assert calls.now >= 30
898
+ assert all(c[1] != STATE_PATH for c in calls.log)
899
+
900
+ @pytest.mark.parametrize(("ref", "response"), [(SHORT_NEW_SHA, {"commit_sha": NEW_SHA}), (NEW_SHA, {})])
901
+ def test_reports_the_pinned_commit_without_wait(self, runner, calls, ref, response):
902
+ calls.route("POST", f"{BASE}/generate", {"ai_task_id": "task-1", "status": "generating", **response})
903
+ json_result = runner.invoke(cli, ["sbom", "generate", ref, "--repo", "rep-123", "--json", "commit_sha"])
904
+ human_result = runner.invoke(cli, ["sbom", "generate", ref, "--repo", "rep-123"])
905
+ assert json.loads(json_result.stdout) == {"commit_sha": NEW_SHA}
906
+ assert f"started for {NEW_SHA[:12]} (task task-1)" in human_result.output
907
+
908
+ @pytest.mark.parametrize("response", [{}, {"commit_sha": SHORT_NEW_SHA}])
909
+ def test_reports_no_commit_without_a_valid_expansion(self, runner, calls, response):
910
+ calls.route("POST", f"{BASE}/generate", {"ai_task_id": "task-1", "status": "generating", **response})
911
+ result = runner.invoke(cli, ["sbom", "generate", SHORT_NEW_SHA, "--repo", "rep-123", "--json", "commit_sha"])
912
+ assert json.loads(result.stdout) == {"commit_sha": None}
913
+
914
+
728
915
  RUNNING = {"ai_task_id": "task-1", "status": "RUNNING"}
729
916
  COMPLETED = {"ai_task_id": "task-1", "status": "COMPLETED"}
730
917
  UNPINNED_WAIT = ["sbom", "generate", "--repo", "rep-123", "--wait"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes