Files
devx/tests/unit/test_create_pr.py
T
emil 44c906a5e6
Post-merge / detect-type (push) Successful in 6s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 1m0s
Post-merge / vikunja (push) Successful in 14s
Post-merge / sync-wiki (push) Successful in 59s
Post-merge / badges (push) Successful in 1m12s
DEVX-60: feat: add create-task, create-pr, pre-push-check tools and devx.mak fragment
2026-06-26 14:29:47 +00:00

194 lines
8.3 KiB
Python

"""Unit tests for devx.tools.create_pr."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.tools.create_pr import (
cli,
create_pr,
extract_task_id,
find_existing_pr,
get_repo_name,
get_vikunja_task_title,
)
class TestExtractTaskId:
def test_valid(self) -> None:
assert extract_task_id("DEVX-42-fix") == "DEVX-42"
def test_invalid(self) -> None:
assert extract_task_id("feature") == ""
class TestGetRepoName:
@patch.dict("os.environ", {"DEVX_REPO_NAME": "infra"})
def test_from_env(self) -> None:
assert get_repo_name() == "infra"
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
def test_from_github(self) -> None:
assert get_repo_name() == "infra"
@patch.dict("os.environ", {}, clear=True)
def test_missing_raises(self) -> None:
with pytest.raises(click.ClickException, match="Repository name"):
get_repo_name()
class TestGetVikunjaTaskTitle:
@patch("devx.tools.create_pr.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42", "title": "Add feature"}]
mock_client_cls.return_value = mock_client
assert get_vikunja_task_title("DEVX-42") == "Add feature"
@patch.dict("os.environ", {}, clear=True)
def test_no_token(self) -> None:
with pytest.raises(click.ClickException, match="VIKUNJA_TOKEN"):
get_vikunja_task_title("DEVX-42")
@patch("devx.tools.create_pr.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_not_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
get_vikunja_task_title("DEVX-42")
@patch("devx.tools.create_pr.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
from devx.config import DEFAULT_PER_PAGE
mock_client = MagicMock()
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
page2 = [{"identifier": "OTHER-99"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
get_vikunja_task_title("DEVX-42")
class TestFindExistingPr:
def test_found(self) -> None:
client = MagicMock()
client.list_prs.return_value = [{"head": {"ref": "DEVX-42-fix"}, "number": 10}]
result = find_existing_pr(client, "DEVX-42-fix")
assert result is not None
assert result["number"] == 10
def test_not_found(self) -> None:
client = MagicMock()
client.list_prs.return_value = [{"head": {"ref": "other"}, "number": 10}]
result = find_existing_pr(client, "DEVX-42-fix")
assert result is None
class TestCreatePr:
@patch("devx.tools.create_pr.GiteaClient")
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
@patch("devx.tools.create_pr.find_existing_pr", return_value=None)
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"}
mock_gitea.return_value = mock_client
result = create_pr("DEVX-42-fix", "master", "body", "owner", "repo")
assert result["number"] == 15
mock_client.create_pr.assert_called_once_with(
title="DEVX-42: Add feature",
head="DEVX-42-fix",
base="master",
body="body",
)
@patch("devx.tools.create_pr.GiteaClient")
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
@patch("devx.tools.create_pr.find_existing_pr")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
def test_existing_pr_idempotent(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
mock_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"}
mock_client = MagicMock()
mock_gitea.return_value = mock_client
result = create_pr("DEVX-42-fix", "master", "", "owner", "repo")
assert result["number"] == 10
mock_client.create_pr.assert_not_called()
@patch.dict("os.environ", {}, clear=True)
def test_no_repo_token(self) -> None:
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
create_pr("DEVX-42-fix", "master", "", "owner", "repo")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
def test_no_task_id_in_branch(self) -> None:
with pytest.raises(click.ClickException, match="does not contain a task ID"):
create_pr("feature-branch", "master", "", "owner", "repo")
class TestCli:
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.subprocess.run")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_auto_detect_branch(self, mock_repo: MagicMock, mock_run: MagicMock, mock_create: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="DEVX-42-fix\n", returncode=0)
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "owner", "repo")
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_explicit_branch(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
assert result.exit_code == 0
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_body_from_stdin(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--body", "-"], input="PR body text")
assert result.exit_code == 0
mock_create.assert_called_once()
assert mock_create.call_args.args[2] == "PR body text"
@patch("devx.tools.create_pr.REPO_OWNER", "")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_missing_owner(self, mock_repo: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix"])
assert result.exit_code != 0
assert "owner" in result.output.lower()
@patch("devx.tools.create_pr.create_pr")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_explicit_owner(self, mock_repo: MagicMock, mock_create: MagicMock) -> None:
mock_create.return_value = {"number": 1}
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "DEVX-42-fix", "--owner", "custom"])
assert result.exit_code == 0
mock_create.assert_called_once_with("DEVX-42-fix", "master", "", "custom", "repo")
@patch("devx.tools.create_pr.subprocess.run")
@patch("devx.tools.create_pr.REPO_OWNER", "owner")
@patch("devx.tools.create_pr.get_repo_name", return_value="repo")
def test_git_detect_failure(self, mock_repo: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="", stderr="fatal: not a git repository", returncode=128)
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "Could not detect" in result.output