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()