fix: tea CLI login failure handling, error messages, release retry
CI / validate (pull_request) Successful in 52s
CI / auto-merge (pull_request) Successful in 15s

configure_tea_login silently ignored tea login add/default failures,
causing cryptic "no available login" errors in subsequent tea commands.
Now raises TeaCLIError with stdout/stderr on failure.

TeaCLI._run now includes stdout in error messages (tea writes some
errors like "no available login" to stdout, not stderr).

publish.py retries Gitea release creation up to 3 times with exponential
backoff (2s, 4s) on transient failures. "Already exists" errors are
treated as success (idempotent, no retry).

Root cause: CI run #2822 — post-merge/release-and-maintain failed
creating Gitea release v0.38.1 because tea login add silently failed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
emil
2026-07-16 16:24:38 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent cdf3408a35
commit 2698145a36
5 changed files with 220 additions and 18 deletions
+58 -3
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/gitea_cli.py."""
"""Unit tests for devx/gitea_cli.py."""
from __future__ import annotations
@@ -77,6 +77,25 @@ class TestTeaCLIRun:
with pytest.raises(TeaCLIError, match="auth error"):
cli._run(["labels", "list"])
def test_run_failure_includes_stdout(self) -> None:
"""tea writes some errors to stdout (e.g. 'no available login')."""
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=1, stdout="no available login", stderr="")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(TeaCLIError, match="no available login"):
cli._run(["releases", "create"])
def test_run_failure_includes_both_stdout_and_stderr(self) -> None:
"""When both stdout and stderr have content, both are included."""
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=1, stdout="partial error", stderr="auth error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(TeaCLIError, match="partial error"):
cli._run(["labels", "list"])
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(TeaCLIError, match="auth error"):
cli._run(["labels", "list"])
def test_run_tea_not_found_raises_tea_error(self) -> None:
cli = TeaCLI(tea_bin="tea")
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
@@ -380,9 +399,11 @@ class TestConfigureTeaLogin:
def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
"""configure_tea_login adds login when not already configured."""
mock_list = MagicMock(returncode=0, stdout="")
mock_subprocess.return_value = mock_list
mock_add = MagicMock(returncode=0, stdout="Login successful", stderr="")
mock_default = MagicMock(returncode=0, stdout="", stderr="")
mock_subprocess.side_effect = [mock_list, mock_add, mock_default]
configure_tea_login()
assert mock_subprocess.call_count >= 2 # login list + login add + login default
assert mock_subprocess.call_count == 3 # login list + login add + login default
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@@ -393,3 +414,37 @@ class TestConfigureTeaLogin:
mock_subprocess.return_value = mock_list
configure_tea_login()
assert mock_subprocess.call_count == 1 # only login list, no add
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.gitea_cli.subprocess.run")
def test_raises_on_login_add_failure(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
"""configure_tea_login raises TeaCLIError if tea login add fails."""
mock_list = MagicMock(returncode=0, stdout="")
mock_add = MagicMock(returncode=1, stdout="", stderr="invalid token")
mock_subprocess.side_effect = [mock_list, mock_add]
with pytest.raises(TeaCLIError, match="login add failed"):
configure_tea_login()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.gitea_cli.subprocess.run")
def test_raises_on_login_default_failure(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
"""configure_tea_login raises TeaCLIError if tea login default fails."""
mock_list = MagicMock(returncode=0, stdout="")
mock_add = MagicMock(returncode=0, stdout="Login successful", stderr="")
mock_default = MagicMock(returncode=1, stdout="", stderr="login not found")
mock_subprocess.side_effect = [mock_list, mock_add, mock_default]
with pytest.raises(TeaCLIError, match="login default failed"):
configure_tea_login()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.gitea_cli.subprocess.run")
def test_login_add_failure_includes_stdout(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
"""Error message includes stdout when tea writes errors there."""
mock_list = MagicMock(returncode=0, stdout="")
mock_add = MagicMock(returncode=1, stdout="Error: invalid username", stderr="")
mock_subprocess.side_effect = [mock_list, mock_add]
with pytest.raises(TeaCLIError, match="invalid username"):
configure_tea_login()
+82 -2
View File
@@ -387,8 +387,10 @@ class TestMain:
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
@patch("time.sleep")
def test_release_failure_raises_click(
self,
mock_sleep: MagicMock,
mock_build: MagicMock,
mock_publish: MagicMock,
mock_tea_cls: MagicMock,
@@ -397,6 +399,7 @@ class TestMain:
mock_tag: MagicMock,
mock_login: MagicMock,
) -> None:
"""Release creation failure after retries raises ClickException."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.side_effect = TeaCLIError("server error")
@@ -405,6 +408,8 @@ class TestMain:
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "Release creation failed" in result.output
# Retried 3 times (stop_after_attempt(3))
assert mock_tea.create_release.call_count == 3
@patch("devx.ci.publish.subprocess.run")
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
@@ -496,8 +501,10 @@ class TestMain:
@patch("devx.ci.publish.publish_to_gitea_registry")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
@patch("time.sleep")
def test_create_release_already_exists_is_idempotent(
self,
mock_sleep: MagicMock,
mock_build: MagicMock,
mock_publish: MagicMock,
mock_gitea_pub: MagicMock,
@@ -507,7 +514,7 @@ class TestMain:
mock_tag: MagicMock,
mock_login: MagicMock,
) -> None:
"""If create_release fails with 'already exists', treat as success."""
"""If create_release fails with 'already exists', treat as success (no retry)."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag")
@@ -516,6 +523,8 @@ class TestMain:
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "already exists" in result.output
# "already exists" is caught immediately — no retry
assert mock_tea.create_release.call_count == 1
@patch("devx.ci.publish.subprocess.run")
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
@@ -526,8 +535,10 @@ class TestMain:
@patch("devx.ci.publish.publish_to_gitea_registry")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
@patch("time.sleep")
def test_create_release_other_error_raises(
self,
mock_sleep: MagicMock,
mock_build: MagicMock,
mock_publish: MagicMock,
mock_gitea_pub: MagicMock,
@@ -537,7 +548,7 @@ class TestMain:
mock_tag: MagicMock,
mock_login: MagicMock,
) -> None:
"""If create_release fails with a non-'already exists' error, raise."""
"""If create_release fails with a non-'already exists' error, raise after retries."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea.create_release.side_effect = TeaCLIError("network error")
@@ -546,6 +557,75 @@ class TestMain:
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code != 0
assert "Release creation failed" in result.output
# Retried 3 times before giving up
assert mock_tea.create_release.call_count == 3
class TestReleaseRetry:
"""Tests for retry logic on transient release creation failures."""
@patch("devx.ci.publish.subprocess.run")
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
@patch("devx.gitea_cli.configure_tea_login")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.build_package")
@patch("time.sleep")
def test_transient_failure_retried_and_succeeds(
self,
mock_sleep: MagicMock,
mock_build: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
mock_run: MagicMock,
mock_tag: MagicMock,
mock_login: MagicMock,
) -> None:
"""Transient failure on first attempt succeeds on retry."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.side_effect = [
TeaCLIError("connection timeout"),
None, # second attempt succeeds
]
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
assert result.exit_code == 0
assert "Gitea release v1.0.0 created" in result.output
assert mock_tea.create_release.call_count == 2
mock_sleep.assert_called() # slept between attempts
@patch("devx.ci.publish.subprocess.run")
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
@patch("devx.gitea_cli.configure_tea_login")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.build_package")
@patch("time.sleep")
def test_all_retries_exhausted_raises(
self,
mock_sleep: MagicMock,
mock_build: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
mock_run: MagicMock,
mock_tag: MagicMock,
mock_login: MagicMock,
) -> None:
"""All 3 retry attempts fail — raises ClickException."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.side_effect = TeaCLIError("503 service unavailable")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
assert result.exit_code == 1
assert "Release creation failed" in result.output
assert mock_tea.create_release.call_count == 3
assert mock_sleep.call_count == 2 # slept between 3 attempts (2 sleeps)
class TestFromTag: