DEVX-91: feat: add pr_status, pr_logs, pr_label tools
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / vikunja (push) Successful in 13s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 30s
Build Images / detect-type (push) Successful in 48s
Post-merge / badges (push) Successful in 41s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 4m41s
Build Images / cleanup (push) Successful in 2m23s

This commit was merged in pull request #143.
This commit is contained in:
2026-06-27 23:38:16 +00:00
parent 3c421dd1ad
commit 81dc30ecff
14 changed files with 1456 additions and 11 deletions
+83
View File
@@ -797,5 +797,88 @@ class TestIsRetryable:
err = _mock_http_error(404, "not found")
assert _is_retryable(err) is False
class TestGiteaClientPrLabels:
def test_add_pr_label(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({}))
client.add_pr_label(42, ["ready-to-merge"])
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/issues/42/labels",
timeout=DEFAULT_TIMEOUT,
json={"labels": ["ready-to-merge"]},
)
def test_add_pr_label_multiple(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({}))
client.add_pr_label(42, ["ready-to-merge", "reviewed"])
call_kwargs = client._session.request.call_args.kwargs
assert call_kwargs["json"]["labels"] == ["ready-to-merge", "reviewed"]
def test_get_pr_label_names(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "bug"}, {"name": "ready-to-merge"}]))
result = client.get_pr_label_names(42)
assert result == ["bug", "ready-to-merge"]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/issues/42/labels",
timeout=DEFAULT_TIMEOUT,
)
class TestGiteaClientActions:
def test_list_action_runs(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"workflow_runs": [{"id": 1, "status": "completed"}], "total_count": 1})
)
result = client.list_action_runs(branch="feature-branch", limit=1)
assert result["total_count"] == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/actions/runs",
timeout=DEFAULT_TIMEOUT,
params={"branch": "feature-branch", "limit": 1},
)
def test_get_action_run_jobs(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"jobs": [{"id": 100, "name": "quality", "conclusion": "failure"}]})
)
result = client.get_action_run_jobs(1410)
assert len(result) == 1
assert result[0]["name"] == "quality"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/actions/runs/1410/jobs",
timeout=DEFAULT_TIMEOUT,
)
def test_get_action_run_jobs_empty(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({}))
result = client.get_action_run_jobs(1410)
assert result == []
def test_get_action_job_logs(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
mock_resp = MagicMock()
mock_resp.text = "log line 1\nlog line 2"
mock_resp.raise_for_status = MagicMock()
client._session.request = MagicMock(return_value=mock_resp)
result = client.get_action_job_logs(10026)
assert "log line 1" in result
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/actions/jobs/10026/logs",
timeout=DEFAULT_TIMEOUT,
)
class TestIsRetryableGeneric:
def test_generic_exception_is_not_retryable(self) -> None:
assert _is_retryable(ValueError("oops")) is False
+12
View File
@@ -29,10 +29,22 @@ class TestGetRepoName:
def test_from_env(self) -> None:
assert get_repo_name() == "infra"
@patch("devx.tools.create_pr.REPO_NAME", "devx")
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
def test_env_overrides_pyproject(self) -> None:
assert get_repo_name() == "infra"
@patch("devx.tools.create_pr.REPO_NAME", "devx")
@patch.dict("os.environ", {}, clear=True)
def test_from_pyproject(self) -> None:
assert get_repo_name() == "devx"
@patch("devx.tools.create_pr.REPO_NAME", "")
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
def test_from_github(self) -> None:
assert get_repo_name() == "infra"
@patch("devx.tools.create_pr.REPO_NAME", "")
@patch.dict("os.environ", {}, clear=True)
def test_missing_raises(self) -> None:
with pytest.raises(click.ClickException, match="Repository name"):
+84
View File
@@ -0,0 +1,84 @@
"""Unit tests for devx.tools.pr_label."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.tools.pr_label import cli
class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
@patch("devx.tools.pr_label.REPO_OWNER", "")
def test_no_owner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
assert result.exit_code != 0
assert "owner" in result.output.lower()
@patch("devx.tools.pr_label.GiteaClient")
def test_adds_new_label(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr_label_names.return_value = []
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
assert result.exit_code == 0
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
assert "Added label" in result.output
@patch("devx.tools.pr_label.GiteaClient")
def test_skips_existing_label(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr_label_names.return_value = ["ready-to-merge"]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
assert result.exit_code == 0
client.add_pr_label.assert_not_called()
assert "already" in result.output
@patch("devx.tools.pr_label.GiteaClient")
def test_mixed_new_and_existing(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr_label_names.return_value = ["reviewed"]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge", "--label", "reviewed"])
assert result.exit_code == 0
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
assert "Added label" in result.output
assert "already" in result.output
@patch("devx.tools.pr_label.GiteaClient")
@patch("devx.tools.pr_status.subprocess.run")
def test_auto_detect_pr(
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
client = mock_client_cls.return_value
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
client.get_pr_label_names.return_value = []
runner = CliRunner()
result = runner.invoke(cli, ["--label", "ready-to-merge"])
assert result.exit_code == 0
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
+312
View File
@@ -0,0 +1,312 @@
"""Unit tests for devx.tools.pr_logs."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.api_clients import APIError, GiteaClient
from devx.tools.pr_logs import (
_find_failed_jobs,
_find_job_by_name,
_find_latest_run_by_sha,
_get_pr_sha,
_print_failed_steps,
_print_job_summary,
_print_logs,
cli,
)
class TestGetPrSha:
def test_returns_sha(self) -> None:
client = MagicMock(spec=GiteaClient)
client.get_pr.return_value = {"head": {"sha": "abc123"}}
assert _get_pr_sha(client, 42) == "abc123"
def test_returns_empty_when_missing(self) -> None:
client = MagicMock(spec=GiteaClient)
client.get_pr.return_value = {"head": {}}
assert _get_pr_sha(client, 42) == ""
class TestFindLatestRunBySha:
def test_returns_matching_run(self) -> None:
client = MagicMock(spec=GiteaClient)
client.list_action_runs.return_value = {
"workflow_runs": [
{"id": 2, "head_sha": "def456"},
{"id": 1, "head_sha": "abc123def"},
],
}
result = _find_latest_run_by_sha(client, "abc123")
assert result is not None
assert result["id"] == 1
def test_returns_none_when_no_match(self) -> None:
client = MagicMock(spec=GiteaClient)
client.list_action_runs.return_value = {
"workflow_runs": [{"id": 1, "head_sha": "def456"}],
}
result = _find_latest_run_by_sha(client, "abc123")
assert result is None
def test_returns_none_when_empty(self) -> None:
client = MagicMock(spec=GiteaClient)
client.list_action_runs.return_value = {"workflow_runs": []}
result = _find_latest_run_by_sha(client, "abc123")
assert result is None
class TestFindFailedJobs:
def test_returns_failed(self) -> None:
jobs = [
{"id": 1, "name": "quality", "conclusion": "failure"},
{"id": 2, "name": "lint", "conclusion": "success"},
]
result = _find_failed_jobs(jobs)
assert len(result) == 1
assert result[0]["name"] == "quality"
def test_empty_when_none_failed(self) -> None:
jobs = [{"id": 1, "name": "quality", "conclusion": "success"}]
assert _find_failed_jobs(jobs) == []
class TestFindJobByName:
def test_case_insensitive_partial(self) -> None:
jobs = [{"id": 1, "name": "CI / quality (pull_request)"}]
result = _find_job_by_name(jobs, "QUALITY")
assert result is not None
assert result["id"] == 1
def test_returns_none_when_not_found(self) -> None:
jobs = [{"id": 1, "name": "quality"}]
assert _find_job_by_name(jobs, "molecule") is None
class TestPrintJobSummary:
def test_prints_all_jobs(self, capsys: pytest.CaptureFixture) -> None:
jobs = [
{"id": 1, "name": "quality", "conclusion": "failure", "status": "completed"},
{"id": 2, "name": "lint", "conclusion": "success", "status": "completed"},
]
_print_job_summary(jobs)
out = capsys.readouterr().out
assert "[FAIL]" in out
assert "[OK]" in out
assert "quality" in out
assert "lint" in out
class TestPrintFailedSteps:
def test_prints_failed_steps(self, capsys: pytest.CaptureFixture) -> None:
job = {
"steps": [
{"name": "checkout", "number": 1, "conclusion": "success"},
{"name": "Unit tests", "number": 3, "conclusion": "failure"},
]
}
result = _print_failed_steps(job)
assert result == [3]
out = capsys.readouterr().out
assert "FAILED step #3" in out
assert "Unit tests" in out
def test_no_failed_steps(self, capsys: pytest.CaptureFixture) -> None:
job = {"steps": [{"name": "checkout", "number": 1, "conclusion": "success"}]}
result = _print_failed_steps(job)
assert result == []
def test_no_steps_key(self, capsys: pytest.CaptureFixture) -> None:
result = _print_failed_steps({})
assert result == []
class TestPrintLogs:
def test_prints_all_lines(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_action_job_logs.return_value = "line 1\nline 2\nline 3"
_print_logs(client, 100, tail=0)
out = capsys.readouterr().out
assert "line 1" in out
assert "line 3" in out
def test_tail_truncates(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_action_job_logs.return_value = "\n".join(f"line {i}" for i in range(100))
_print_logs(client, 100, tail=10)
out = capsys.readouterr().out
assert "line 99" in out
assert "line 0" not in out
assert "showing last 10" in out
def test_api_error_handled(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_action_job_logs.side_effect = APIError(404, "not found")
_print_logs(client, 100, tail=0)
out = capsys.readouterr().out
assert "Could not fetch logs" in out
class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
@patch("devx.tools.pr_logs.REPO_OWNER", "")
def test_no_owner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "owner" in result.output.lower()
@patch("devx.tools.pr_logs.GiteaClient")
@patch("devx.tools.pr_status.subprocess.run")
def test_auto_detect_pr(
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
client = mock_client_cls.return_value
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {"workflow_runs": []}
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "Fetching logs for PR #42" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_no_runs_found(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {"workflow_runs": []}
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "No workflow runs" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_no_jobs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
}
client.get_action_run_jobs.return_value = []
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code == 0
assert "No jobs" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_no_failed_jobs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
}
client.get_action_run_jobs.return_value = [
{"id": 100, "name": "quality", "conclusion": "success", "status": "completed", "steps": []}
]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code == 0
assert "No failed jobs" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_failed_job_logs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
}
client.get_action_run_jobs.return_value = [
{
"id": 100,
"name": "quality",
"conclusion": "failure",
"status": "completed",
"steps": [
{"name": "checkout", "number": 1, "conclusion": "success"},
{"name": "Unit tests", "number": 3, "conclusion": "failure"},
],
}
]
client.get_action_job_logs.return_value = "error: test failed"
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--tail", "0"])
assert result.exit_code == 0
assert "FAILED step #3" in result.output
assert "error: test failed" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_specific_job(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
}
client.get_action_run_jobs.return_value = [
{"id": 100, "name": "quality", "conclusion": "success", "status": "completed", "steps": []},
{"id": 101, "name": "lint", "conclusion": "success", "status": "completed", "steps": []},
]
client.get_action_job_logs.return_value = "lint output here"
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--job", "lint", "--tail", "0"])
assert result.exit_code == 0
assert "lint output here" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_job_not_found(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.list_action_runs.return_value = {
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
}
client.get_action_run_jobs.return_value = [
{"id": 100, "name": "quality", "conclusion": "success", "status": "completed", "steps": []}
]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--job", "nonexistent"])
assert result.exit_code != 0
assert "No job matching" in result.output
@patch("devx.tools.pr_logs.GiteaClient")
def test_no_sha_raises(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {}}
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "SHA" in result.output
+279
View File
@@ -0,0 +1,279 @@
"""Unit tests for devx.tools.pr_status."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.api_clients import GiteaClient
from devx.tools.pr_status import (
_get_pr_sha,
_get_symbol,
cli,
print_status,
wait_for_completion,
)
class TestGetSymbol:
def test_success(self) -> None:
assert _get_symbol("success") == "[OK]"
def test_failure(self) -> None:
assert _get_symbol("failure") == "[FAIL]"
def test_pending(self) -> None:
assert _get_symbol("pending") == "[..]"
def test_unknown(self) -> None:
assert _get_symbol("weird") == "[weird]"
class TestGetPrSha:
def test_returns_head_sha(self) -> None:
client = MagicMock(spec=GiteaClient)
client.get_pr.return_value = {"head": {"sha": "abc123"}}
assert _get_pr_sha(client, 42) == "abc123"
def test_returns_empty_when_missing(self) -> None:
client = MagicMock(spec=GiteaClient)
client.get_pr.return_value = {"head": {}}
assert _get_pr_sha(client, 42) == ""
class TestPrintStatus:
def test_no_statuses(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.return_value = []
result = print_status(client, "abc123")
assert result == "none"
def test_all_success(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "success"},
{"context": "CI / lint", "status": "success"},
]
result = print_status(client, "abc123")
assert result == "success"
def test_has_failure(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "success"},
{"context": "CI / lint", "status": "failure"},
]
result = print_status(client, "abc123")
assert result == "failure"
def test_pending(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "pending"},
]
result = print_status(client, "abc123")
assert result == "pending"
def test_skipped_still_success(self, capsys: pytest.CaptureFixture) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "success"},
{"context": "CI / molecule", "status": "skipped"},
]
result = print_status(client, "abc123")
assert result == "success"
class TestWaitForCompletion:
@patch("devx.tools.pr_status.time.sleep")
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
def test_success_after_pending(
self, mock_time: MagicMock, mock_sleep: MagicMock, capsys: pytest.CaptureFixture
) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.side_effect = [
[{"context": "CI / quality", "status": "pending"}],
[{"context": "CI / quality", "status": "success"}],
]
result = wait_for_completion(client, "abc", timeout=600, interval=1)
assert result == "success"
@patch("devx.tools.pr_status.time.sleep")
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
def test_failure_after_pending(self, mock_time: MagicMock, mock_sleep: MagicMock) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.side_effect = [
[{"context": "CI / quality", "status": "pending"}],
[{"context": "CI / quality", "status": "failure"}],
]
result = wait_for_completion(client, "abc", timeout=600, interval=1)
assert result == "failure"
@patch("devx.tools.pr_status.time.sleep")
@patch("devx.tools.pr_status.time.time", side_effect=[0, 700])
def test_timeout(self, mock_time: MagicMock, mock_sleep: MagicMock) -> None:
client = MagicMock(spec=GiteaClient)
client.get_commit_status.return_value = [{"context": "CI / quality", "status": "pending"}]
result = wait_for_completion(client, "abc", timeout=600, interval=1)
assert result == "pending"
class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
@patch("devx.tools.pr_status.REPO_OWNER", "")
@patch("devx.tools.pr_status.get_repo_name", side_effect=Exception("should not reach"))
def test_no_owner_raises(self, mock_repo_name: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "owner" in result.output.lower()
@patch("devx.tools.pr_status.GiteaClient")
def test_check_pr_status(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "success"},
]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code == 0
assert "[OK]" in result.output
@patch("devx.tools.pr_status.GiteaClient")
def test_check_sha_directly(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "success"},
]
runner = CliRunner()
result = runner.invoke(cli, ["--sha", "abc123"])
assert result.exit_code == 0
assert "[OK]" in result.output
@patch("devx.tools.pr_status.GiteaClient")
def test_failure_raises_exception(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.get_commit_status.return_value = [
{"context": "CI / quality", "status": "failure"},
]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "failed" in result.output.lower()
@patch("devx.tools.pr_status.GiteaClient")
@patch("devx.tools.pr_status.subprocess.run")
def test_auto_detect_branch(
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
client = mock_client_cls.return_value
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.get_commit_status.return_value = [{"context": "CI / quality", "status": "success"}]
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "PR #42" in result.output
@patch("devx.tools.pr_status.GiteaClient")
@patch("devx.tools.pr_status.subprocess.run")
def test_auto_detect_no_pr_found(
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
client = mock_client_cls.return_value
client.list_prs.return_value = []
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "No open PR" in result.output
@patch("devx.tools.pr_status.GiteaClient")
@patch("devx.tools.pr_status.subprocess.run")
def test_auto_detect_branch_error(
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
mock_subprocess.return_value = MagicMock(returncode=1, stderr="git error\n")
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "Could not detect" in result.output
@patch("devx.tools.pr_status.GiteaClient")
def test_no_sha_raises(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {}}
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"])
assert result.exit_code != 0
assert "SHA" in result.output
@patch("devx.tools.pr_status.time.sleep")
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
@patch("devx.tools.pr_status.GiteaClient")
def test_wait_success(
self, mock_client_cls: MagicMock, mock_time: MagicMock, mock_sleep: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.get_commit_status.side_effect = [
[{"context": "CI / quality", "status": "pending"}],
[{"context": "CI / quality", "status": "success"}],
]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--wait", "--timeout", "600", "--interval", "1"])
assert result.exit_code == 0
assert "[OK]" in result.output
@patch("devx.tools.pr_status.time.sleep")
@patch("devx.tools.pr_status.time.time", side_effect=[0, 700])
@patch("devx.tools.pr_status.GiteaClient")
def test_wait_timeout(
self, mock_client_cls: MagicMock, mock_time: MagicMock, mock_sleep: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
client = mock_client_cls.return_value
client.get_pr.return_value = {"head": {"sha": "abc123"}}
client.get_commit_status.return_value = [{"context": "CI / quality", "status": "pending"}]
runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--wait", "--timeout", "600", "--interval", "1"])
assert result.exit_code != 0
assert "timeout" in result.output.lower()