diff --git a/cloudsmith_cli/cli/commands/push.py b/cloudsmith_cli/cli/commands/push.py index 1dbe1b30..96059807 100644 --- a/cloudsmith_cli/cli/commands/push.py +++ b/cloudsmith_cli/cli/commands/push.py @@ -2,6 +2,7 @@ # pylint: disable=too-many-lines +import json import math import os import shlex @@ -694,7 +695,7 @@ def display_status(current): # When using stderr for logs, avoid an interactive progress bar and just poll for status. while True: res = get_package_status(owner, repo, slug) - ok, failed, _, _, _, _ = res + ok, failed, _, status_str, stage_str, reason = res if ok or failed: break @@ -811,6 +812,34 @@ def display_status(current): attempts=attempts, ) else: + if use_stderr: + # In JSON output mode the human-readable text above went to + # stderr, so stdout still needs a machine-readable envelope. + # Mirror the shape produced by ``handle_api_exceptions`` so a + # sync failure is parseable the same way as an API failure. + error_data = { + "detail": reason or "Package failed to synchronise.", + "help": { + "context": context_msg, + "hint": None, + }, + "meta": { + "status": status_str, + "stage": stage_str or "Unknown", + "seconds": seconds, + }, + } + + metadata_context = getattr(opts, "push_metadata_info", None) + if metadata_context is not None: + error_data["metadata_attachment"] = metadata_context + + click.echo( + json.dumps( + error_data, indent=4 if opts.output == "pretty_json" else None + ) + ) + ctx.exit(1) diff --git a/cloudsmith_cli/cli/tests/test_push.py b/cloudsmith_cli/cli/tests/test_push.py index 0c6670df..1b6c3fe6 100644 --- a/cloudsmith_cli/cli/tests/test_push.py +++ b/cloudsmith_cli/cli/tests/test_push.py @@ -16,6 +16,7 @@ resolve_push_metadata_options, upload_files_and_create_package, validate_metadata_payload, + wait_for_package_sync, ) from ..metadata_common import ResolvedMetadata @@ -1466,3 +1467,54 @@ def test_options_metadata_failure_mode_none_is_noop(): opts = Options() opts.metadata_failure_mode = None assert opts.metadata_failure_mode is None + + +def test_wait_for_package_sync_json_mode_prints_status_reason_on_failure(capsys): + """Failed sync under JSON output must print the server's status_reason. + + The JSON poll path used to discard status_reason, then crash with + UnboundLocalError at ``if reason:`` instead of showing the message. + """ + ctx = click.Context(click.Command("push")) + opts = SimpleNamespace(output="json") + status_reason = ( + "A package with name 'eng-13978-repro' already exists. " + "This package should be deleted." + ) + failed_status = ( + False, + True, + 100, + "Failed", + "Parsing Package Metadata", + status_reason, + ) + + with patch( + "cloudsmith_cli.cli.commands.push.get_package_status", + return_value=failed_status, + ): + with pytest.raises(click.exceptions.Exit) as exc_info: + wait_for_package_sync( + ctx=ctx, + opts=opts, + owner="bart-demo-org-terraform", + repo="eng-13978-cli-repro", + slug="eng-13978-repro-100-alpha4tgz", + wait_interval=1.0, + skip_errors=False, + attempts=1, + ) + + assert exc_info.value.exit_code == 1 + captured = capsys.readouterr() + assert "Package failed to synchronise" in captured.err + assert "Parsing Package Metadata" in captured.err + assert "Reason given:" in captured.err + assert status_reason in captured.err + + # JSON mode must still leave a machine-readable envelope on stdout. + envelope = json.loads(captured.out) + assert envelope["detail"] == status_reason + assert envelope["meta"]["stage"] == "Parsing Package Metadata" + assert envelope["meta"]["status"] == "Failed"