Public Access
377 lines
17 KiB
Python
377 lines
17 KiB
Python
"""Unit tests for scripts/ci/post_merge.py."""
|
|
|
|
import http
|
|
import subprocess
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import click
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from devx.ci.post_merge import (
|
|
_get_git_commit_message,
|
|
_get_git_commit_sha,
|
|
build_comment,
|
|
extract_conventional_msg,
|
|
extract_task_id,
|
|
main,
|
|
resolve_task_id,
|
|
)
|
|
from devx.exceptions import APIError
|
|
|
|
|
|
class TestExtractTaskId:
|
|
def test_extracts_from_first_line(self) -> None:
|
|
assert extract_task_id("DEVX-19: fix: resolve bug\n\nBody") == "DEVX-19"
|
|
|
|
def test_returns_empty_when_missing(self) -> None:
|
|
assert extract_task_id("fix: resolve bug") == ""
|
|
|
|
|
|
class TestExtractConventionalMsg:
|
|
def test_strips_colon_prefix(self) -> None:
|
|
"""Legacy format: DEVX-N: <message>"""
|
|
assert extract_conventional_msg("DEVX-19: fix: resolve bug") == "fix: resolve bug"
|
|
|
|
def test_strips_space_prefix(self) -> None:
|
|
"""Current format: DEVX-N <message>"""
|
|
assert extract_conventional_msg("DEVX-19 fix: resolve bug") == "fix: resolve bug"
|
|
|
|
def test_returns_unchanged_without_prefix(self) -> None:
|
|
assert extract_conventional_msg("fix: resolve bug") == "fix: resolve bug"
|
|
|
|
|
|
class TestBuildComment:
|
|
def test_html_format(self) -> None:
|
|
html = build_comment("DEVX-19", "fix: bug", "abc123")
|
|
assert "<strong>DEVX-19</strong>" in html
|
|
assert "fix: bug" in html
|
|
assert "<code>abc123</code>" in html
|
|
|
|
|
|
class TestResolveTaskId:
|
|
def test_found(self) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.list_project_tasks.return_value = [
|
|
{"id": 42, "identifier": "DEVX-19"},
|
|
]
|
|
assert resolve_task_id(mock_client, "DEVX-19") == 42
|
|
mock_client.list_project_tasks.assert_called_once()
|
|
|
|
def test_not_found_raises(self) -> None:
|
|
"""Missing Vikunja task is a fatal error — every PR must have a task."""
|
|
mock_client = MagicMock()
|
|
mock_client.list_project_tasks.return_value = []
|
|
with pytest.raises(click.ClickException, match="Could not find"):
|
|
resolve_task_id(mock_client, "DEVX-99")
|
|
|
|
def test_found_on_second_page(self) -> None:
|
|
"""Task is on page 2 when project has more than 50 tasks."""
|
|
mock_client = MagicMock()
|
|
page1 = [{"id": i, "identifier": f"DEVX-{i}"} for i in range(50)]
|
|
page2 = [{"id": 100, "identifier": "DEVX-99"}]
|
|
mock_client.list_project_tasks.side_effect = [page1, page2]
|
|
assert resolve_task_id(mock_client, "DEVX-99") == 100
|
|
assert mock_client.list_project_tasks.call_count == 2
|
|
|
|
def test_stops_when_page_is_partial(self) -> None:
|
|
"""Stops paginating when a page has fewer than DEFAULT_PER_PAGE results."""
|
|
mock_client = MagicMock()
|
|
page1 = [{"id": i, "identifier": f"DEVX-{i}"} for i in range(10)]
|
|
mock_client.list_project_tasks.return_value = page1
|
|
with pytest.raises(click.ClickException, match="Could not find"):
|
|
resolve_task_id(mock_client, "DEVX-99")
|
|
assert mock_client.list_project_tasks.call_count == 1
|
|
|
|
def test_http_error_propagates(self) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.list_project_tasks.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
|
with pytest.raises(APIError):
|
|
resolve_task_id(mock_client, "DEVX-19")
|
|
|
|
|
|
class TestMain:
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
@patch("devx.ci.post_merge.VikunjaClient")
|
|
def test_full_flow(
|
|
self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
|
) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.list_project_tasks.return_value = [
|
|
{"id": 267, "identifier": "DEVX-20"},
|
|
]
|
|
mock_client_cls.return_value = mock_client
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main,
|
|
["DEVX-20: fix: resolve bug\n\nBody", "--commit-sha", "abc123"],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "updated and marked done" in result.output
|
|
mock_client.post_comment.assert_called_once()
|
|
mock_client.update_task.assert_called_once_with(267, done=True)
|
|
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
@patch("devx.ci.post_merge.VikunjaClient")
|
|
def test_no_commit_sha(
|
|
self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
|
) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.list_project_tasks.return_value = [
|
|
{"id": 267, "identifier": "DEVX-20"},
|
|
]
|
|
mock_client_cls.return_value = mock_client
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["DEVX-20: fix: resolve bug"])
|
|
assert result.exit_code == 0
|
|
mock_client.post_comment.assert_called_once()
|
|
args, _ = mock_client.post_comment.call_args
|
|
assert "unknown" in args[1]
|
|
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True)
|
|
def test_missing_token_exits(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock) -> None:
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
|
assert result.exit_code == 1
|
|
assert "VIKUNJA_TOKEN" in result.output
|
|
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
def test_no_task_id_non_release_fails(
|
|
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
|
) -> None:
|
|
"""Non-release commits without DEVX-N prefix should fail."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["fix: resolve bug"])
|
|
assert result.exit_code != 0
|
|
assert "No task ID" in result.output
|
|
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
def test_release_commit_without_task_id_skips(
|
|
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
|
) -> None:
|
|
"""Release commits without DEVX-N prefix should skip gracefully."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["release: v0.3.2"])
|
|
assert result.exit_code == 0
|
|
assert "skipping" in result.output
|
|
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
def test_revert_commit_skips(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock) -> None:
|
|
"""Revert commits without DEVX-N prefix should skip gracefully."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["revert: remove v0.6.0 release"])
|
|
assert result.exit_code == 0
|
|
assert "Infrastructure commit" in result.output
|
|
assert "skipping" in result.output
|
|
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
def test_merge_commit_skips(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock) -> None:
|
|
"""Merge commits without DEVX-N prefix should skip gracefully."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["Merge pull request #42"])
|
|
assert result.exit_code == 0
|
|
assert "Infrastructure commit" in result.output
|
|
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
@patch("devx.ci.post_merge.VikunjaClient")
|
|
def test_resolve_failure_fails(
|
|
self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
|
) -> None:
|
|
"""Missing Vikunja task is a fatal error — every PR must have a task."""
|
|
mock_client = MagicMock()
|
|
mock_client.list_project_tasks.return_value = []
|
|
mock_client_cls.return_value = mock_client
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
|
assert result.exit_code != 0
|
|
assert "Could not find" in result.output
|
|
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
@patch("devx.ci.post_merge.VikunjaClient")
|
|
def test_post_comment_failure_fails(
|
|
self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
|
) -> None:
|
|
"""Vikunja API errors should fail — the task was not updated."""
|
|
mock_client = MagicMock()
|
|
mock_client.list_project_tasks.return_value = [
|
|
{"id": 267, "identifier": "DEVX-20"},
|
|
]
|
|
mock_client.post_comment.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
|
mock_client_cls.return_value = mock_client
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
|
assert result.exit_code != 0
|
|
assert "Vikunja API error" in result.output
|
|
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
@patch("devx.ci.post_merge.VikunjaClient")
|
|
def test_mark_done_failure_fails(
|
|
self, mock_client_cls: MagicMock, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock
|
|
) -> None:
|
|
"""Vikunja API errors should fail — the task was not updated."""
|
|
mock_client = MagicMock()
|
|
mock_client.list_project_tasks.return_value = [
|
|
{"id": 267, "identifier": "DEVX-20"},
|
|
]
|
|
mock_client.post_comment.return_value = None
|
|
mock_client.update_task.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
|
mock_client_cls.return_value = mock_client
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["DEVX-20: fix: bug"])
|
|
assert result.exit_code != 0
|
|
assert "Vikunja API error" in result.output
|
|
|
|
|
|
class TestGetGitCommitMessage:
|
|
def test_success(self) -> None:
|
|
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="DEVX-20: fix: bug\n", stderr="")
|
|
with patch("subprocess.run", return_value=mock_result):
|
|
assert _get_git_commit_message() == "DEVX-20: fix: bug"
|
|
|
|
def test_failure(self) -> None:
|
|
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
|
with patch("subprocess.run", return_value=mock_result):
|
|
with pytest.raises(click.ClickException, match="git log failed"):
|
|
_get_git_commit_message()
|
|
|
|
|
|
class TestGetGitCommitSha:
|
|
def test_success(self) -> None:
|
|
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="abc123\n", stderr="")
|
|
with patch("subprocess.run", return_value=mock_result):
|
|
assert _get_git_commit_sha() == "abc123"
|
|
|
|
def test_failure(self) -> None:
|
|
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
|
with patch("subprocess.run", return_value=mock_result):
|
|
with pytest.raises(click.ClickException, match="git rev-parse failed"):
|
|
_get_git_commit_sha()
|
|
|
|
|
|
class TestFromGit:
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
@patch("devx.ci.post_merge.VikunjaClient")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="DEVX-20: fix: bug")
|
|
def test_from_git(
|
|
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock, mock_subproc: MagicMock
|
|
) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.list_project_tasks.return_value = [
|
|
{"id": 267, "identifier": "DEVX-20"},
|
|
]
|
|
mock_client_cls.return_value = mock_client
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--from-git"])
|
|
assert result.exit_code == 0
|
|
assert "updated and marked done" in result.output
|
|
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
@patch("devx.ci.post_merge.VikunjaClient")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="DEVX-20: fix: bug")
|
|
def test_from_git_with_explicit_sha(
|
|
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock, mock_subproc: MagicMock
|
|
) -> None:
|
|
mock_client = MagicMock()
|
|
mock_client.list_project_tasks.return_value = [
|
|
{"id": 267, "identifier": "DEVX-20"},
|
|
]
|
|
mock_client_cls.return_value = mock_client
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--from-git", "--commit-sha", "explicit_sha"])
|
|
assert result.exit_code == 0
|
|
|
|
@patch("devx.ci.post_merge.subprocess.run")
|
|
@patch("devx.ci.post_merge._get_git_commit_message", return_value="msg")
|
|
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="sha")
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
def test_no_msg_and_no_from_git(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_subproc: MagicMock) -> None:
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, [])
|
|
assert result.exit_code != 0
|
|
assert "commit_msg" in result.output
|
|
|
|
|
|
class TestGitSha:
|
|
"""Tests for the --git-sha option (race condition fix)."""
|
|
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
@patch("devx.ci.post_merge.VikunjaClient")
|
|
def test_git_sha_reads_commit_from_specific_sha(self, mock_client_cls: MagicMock) -> None:
|
|
"""--git-sha reads commit message from a specific SHA, not HEAD."""
|
|
mock_client = MagicMock()
|
|
mock_client.list_project_tasks.return_value = [
|
|
{"id": 267, "identifier": "DEVX-20"},
|
|
]
|
|
mock_client_cls.return_value = mock_client
|
|
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="DEVX-20: fix: bug\n", stderr="")
|
|
with patch("subprocess.run", return_value=mock_result):
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--git-sha", "abc123"])
|
|
assert result.exit_code == 0
|
|
assert "updated and marked done" in result.output
|
|
mock_client.post_comment.assert_called_once()
|
|
# Verify the SHA was passed to the comment
|
|
args, _ = mock_client.post_comment.call_args
|
|
assert "abc123" in args[1]
|
|
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
def test_git_sha_failure_raises(self) -> None:
|
|
"""--git-sha with invalid SHA should raise."""
|
|
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="bad sha")
|
|
with patch("subprocess.run", return_value=mock_result):
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--git-sha", "badsha"])
|
|
assert result.exit_code != 0
|
|
assert "git log failed" in result.output
|
|
|
|
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
@patch("devx.ci.post_merge.VikunjaClient")
|
|
def test_git_sha_with_explicit_commit_sha(self, mock_client_cls: MagicMock) -> None:
|
|
"""--git-sha with --commit-sha uses the explicit SHA for the comment."""
|
|
mock_client = MagicMock()
|
|
mock_client.list_project_tasks.return_value = [
|
|
{"id": 267, "identifier": "DEVX-20"},
|
|
]
|
|
mock_client_cls.return_value = mock_client
|
|
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="DEVX-20: fix: bug\n", stderr="")
|
|
with patch("subprocess.run", return_value=mock_result):
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["--git-sha", "abc123", "--commit-sha", "explicit_sha"])
|
|
assert result.exit_code == 0
|
|
args, _ = mock_client.post_comment.call_args
|
|
assert "explicit_sha" in args[1]
|