Public Access
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
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:
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user