GRM-20: feat: replace inline workflow scripts with tested Python modules
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""Unit tests for scripts/auto_merge.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from scripts.auto_merge import (
|
||||
CONVENTIONAL_RE,
|
||||
GITEA_API,
|
||||
TASK_ID_RE,
|
||||
extract_task_id,
|
||||
main,
|
||||
merge_pr,
|
||||
validate_pr_title,
|
||||
)
|
||||
|
||||
|
||||
class TestRegexes:
|
||||
def test_task_id_re_matches(self) -> None:
|
||||
assert TASK_ID_RE.search("GRM-19-fix-bug")
|
||||
assert TASK_ID_RE.search("feature/GRM-42")
|
||||
|
||||
def test_task_id_re_no_match(self) -> None:
|
||||
assert not TASK_ID_RE.search("feature-no-id")
|
||||
|
||||
def test_conventional_re_matches(self) -> None:
|
||||
assert CONVENTIONAL_RE.match("feat: add feature")
|
||||
assert CONVENTIONAL_RE.match("fix(api): handle timeout")
|
||||
|
||||
def test_conventional_re_rejects(self) -> None:
|
||||
assert not CONVENTIONAL_RE.match("random message")
|
||||
assert not CONVENTIONAL_RE.match("feat:")
|
||||
|
||||
|
||||
class TestExtractTaskId:
|
||||
def test_extracts_from_branch(self) -> None:
|
||||
assert extract_task_id("GRM-19-fix-bug") == "GRM-19"
|
||||
|
||||
def test_extracts_from_feature_branch(self) -> None:
|
||||
assert extract_task_id("feature/GRM-42-add-x") == "GRM-42"
|
||||
|
||||
def test_returns_empty_when_missing(self) -> None:
|
||||
assert extract_task_id("feature-no-id") == ""
|
||||
|
||||
|
||||
class TestValidatePrTitle:
|
||||
def test_valid_title_passes(self) -> None:
|
||||
validate_pr_title("fix: resolve timeout")
|
||||
|
||||
def test_valid_title_with_scope_passes(self) -> None:
|
||||
validate_pr_title("feat(cli): add --url option")
|
||||
|
||||
def test_invalid_title_exits(self) -> None:
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
validate_pr_title("random message")
|
||||
assert exc.value.code == 1
|
||||
|
||||
|
||||
class TestMergePr:
|
||||
@patch("scripts.auto_merge.requests.post")
|
||||
def test_successful_merge(self, mock_post: MagicMock) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_post.return_value = mock_response
|
||||
merge_pr("tok", "owner/repo", "7", "GRM-19: fix: bug")
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
assert kwargs["headers"]["Authorization"] == "token tok"
|
||||
assert kwargs["json"]["Do"] == "squash"
|
||||
assert kwargs["json"]["MergeTitleField"] == "GRM-19: fix: bug"
|
||||
assert GITEA_API in args[0]
|
||||
|
||||
@patch("scripts.auto_merge.requests.post")
|
||||
def test_merge_raises_on_http_error(self, mock_post: MagicMock) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
|
||||
mock_post.return_value = mock_response
|
||||
with pytest.raises(requests.HTTPError):
|
||||
merge_pr("tok", "owner/repo", "7", "title")
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"GITEA_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.merge_pr")
|
||||
def test_successful_flow(self, mock_merge: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
main(["auto_merge.py", "GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7"])
|
||||
mock_merge.assert_called_once_with("tok", "owner/repo", "7", "GRM-19: fix: resolve timeout")
|
||||
captured = capsys.readouterr()
|
||||
assert "squash-merged" in captured.out
|
||||
|
||||
@patch.dict("os.environ", {"GITEA_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main(["auto_merge.py", "branch", "title", "repo", "1"])
|
||||
assert exc.value.code == 1
|
||||
|
||||
@patch.dict("os.environ", {"GITEA_TOKEN": "tok"})
|
||||
def test_missing_task_id_exits(self) -> None:
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main(["auto_merge.py", "feature-no-id", "fix: bug", "repo", "1"])
|
||||
assert exc.value.code == 1
|
||||
|
||||
@patch.dict("os.environ", {"GITEA_TOKEN": "tok"})
|
||||
def test_invalid_pr_title_exits(self) -> None:
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main(["auto_merge.py", "GRM-19-fix", "random title", "repo", "1"])
|
||||
assert exc.value.code == 1
|
||||
|
||||
@patch.dict("os.environ", {"GITEA_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.merge_pr")
|
||||
def test_merge_pr_failure_propagates(self, mock_merge: MagicMock) -> None:
|
||||
mock_merge.side_effect = requests.HTTPError("500")
|
||||
with pytest.raises(requests.HTTPError):
|
||||
main(["auto_merge.py", "GRM-19-fix", "fix: bug", "repo", "1"])
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Unit tests for scripts/post_merge.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from scripts.post_merge import (
|
||||
PROJECT_ID,
|
||||
VIKUNJA_API,
|
||||
build_comment,
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
main,
|
||||
mark_task_done,
|
||||
post_comment,
|
||||
resolve_task_id,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractTaskId:
|
||||
def test_extracts_from_first_line(self) -> None:
|
||||
assert extract_task_id("GRM-19: fix: resolve bug\n\nBody") == "GRM-19"
|
||||
|
||||
def test_returns_empty_when_missing(self) -> None:
|
||||
assert extract_task_id("fix: resolve bug") == ""
|
||||
|
||||
|
||||
class TestExtractConventionalMsg:
|
||||
def test_strips_task_id_prefix(self) -> None:
|
||||
assert extract_conventional_msg("GRM-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("GRM-19", "fix: bug", "abc123")
|
||||
assert "<strong>GRM-19</strong>" in html
|
||||
assert "fix: bug" in html
|
||||
assert "<code>abc123</code>" in html
|
||||
|
||||
|
||||
class TestResolveTaskId:
|
||||
@patch("scripts.post_merge.requests.get")
|
||||
def test_found(self, mock_get: MagicMock) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = [
|
||||
{"id": 42, "project_id": PROJECT_ID, "identifier": "GRM-19"},
|
||||
]
|
||||
mock_get.return_value = mock_response
|
||||
assert resolve_task_id("tok", "GRM-19") == 42
|
||||
mock_get.assert_called_once()
|
||||
|
||||
@patch("scripts.post_merge.requests.get")
|
||||
def test_not_found_exits(self, mock_get: MagicMock) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = []
|
||||
mock_get.return_value = mock_response
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
resolve_task_id("tok", "GRM-99")
|
||||
assert exc.value.code == 1
|
||||
|
||||
@patch("scripts.post_merge.requests.get")
|
||||
def test_wrong_project_filtered(self, mock_get: MagicMock) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = [
|
||||
{"id": 42, "project_id": 999, "identifier": "GRM-19"},
|
||||
]
|
||||
mock_get.return_value = mock_response
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
resolve_task_id("tok", "GRM-19")
|
||||
assert exc.value.code == 1
|
||||
|
||||
@patch("scripts.post_merge.requests.get")
|
||||
def test_http_error_propagates(self, mock_get: MagicMock) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
|
||||
mock_get.return_value = mock_response
|
||||
with pytest.raises(requests.HTTPError):
|
||||
resolve_task_id("tok", "GRM-19")
|
||||
|
||||
|
||||
class TestPostComment:
|
||||
@patch("scripts.post_merge.requests.post")
|
||||
def test_success(self, mock_post: MagicMock) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_post.return_value = mock_response
|
||||
post_comment("tok", 42, "<p>hi</p>")
|
||||
args, kwargs = mock_post.call_args
|
||||
assert VIKUNJA_API in args[0]
|
||||
assert kwargs["json"]["comment"] == "<p>hi</p>"
|
||||
|
||||
@patch("scripts.post_merge.requests.post")
|
||||
def test_http_error_propagates(self, mock_post: MagicMock) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
|
||||
mock_post.return_value = mock_response
|
||||
with pytest.raises(requests.HTTPError):
|
||||
post_comment("tok", 42, "html")
|
||||
|
||||
|
||||
class TestMarkTaskDone:
|
||||
@patch("scripts.post_merge.requests.put")
|
||||
def test_success(self, mock_put: MagicMock) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_put.return_value = mock_response
|
||||
mark_task_done("tok", 42)
|
||||
args, kwargs = mock_put.call_args
|
||||
assert kwargs["json"]["done"] is True
|
||||
|
||||
@patch("scripts.post_merge.requests.put")
|
||||
def test_http_error_propagates(self, mock_put: MagicMock) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
|
||||
mock_put.return_value = mock_response
|
||||
with pytest.raises(requests.HTTPError):
|
||||
mark_task_done("tok", 42)
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.resolve_task_id")
|
||||
@patch("scripts.post_merge.post_comment")
|
||||
@patch("scripts.post_merge.mark_task_done")
|
||||
def test_full_flow(
|
||||
self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
mock_resolve.return_value = 267
|
||||
main(["post_merge.py", "GRM-20: fix: resolve bug\n\nBody", "--commit-sha", "abc123"])
|
||||
mock_resolve.assert_called_once_with("tok", "GRM-20")
|
||||
mock_post.assert_called_once()
|
||||
mock_mark.assert_called_once_with("tok", 267)
|
||||
captured = capsys.readouterr()
|
||||
assert "updated and marked done" in captured.out
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.resolve_task_id")
|
||||
@patch("scripts.post_merge.post_comment")
|
||||
@patch("scripts.post_merge.mark_task_done")
|
||||
def test_no_commit_sha(self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock) -> None:
|
||||
mock_resolve.return_value = 267
|
||||
main(["post_merge.py", "GRM-20: fix: resolve bug"])
|
||||
mock_post.assert_called_once()
|
||||
args, _ = mock_post.call_args
|
||||
assert "unknown" in args[2]
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main(["post_merge.py", "GRM-20: fix: bug"])
|
||||
assert exc.value.code == 1
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_no_task_id_skips(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
main(["post_merge.py", "fix: resolve bug"])
|
||||
captured = capsys.readouterr()
|
||||
assert "skipping Vikunja update" in captured.out
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.resolve_task_id")
|
||||
def test_resolve_failure_propagates(self, mock_resolve: MagicMock) -> None:
|
||||
mock_resolve.side_effect = SystemExit(1)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main(["post_merge.py", "GRM-20: fix: bug"])
|
||||
assert exc.value.code == 1
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.resolve_task_id")
|
||||
@patch("scripts.post_merge.post_comment")
|
||||
def test_post_comment_failure_propagates(self, mock_post: MagicMock, mock_resolve: MagicMock) -> None:
|
||||
mock_resolve.return_value = 267
|
||||
mock_post.side_effect = requests.HTTPError("500")
|
||||
with pytest.raises(requests.HTTPError):
|
||||
main(["post_merge.py", "GRM-20: fix: bug"])
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.resolve_task_id")
|
||||
@patch("scripts.post_merge.post_comment")
|
||||
@patch("scripts.post_merge.mark_task_done")
|
||||
def test_mark_done_failure_propagates(
|
||||
self, mock_mark: MagicMock, mock_post: MagicMock, mock_resolve: MagicMock
|
||||
) -> None:
|
||||
mock_resolve.return_value = 267
|
||||
mock_mark.side_effect = requests.HTTPError("500")
|
||||
with pytest.raises(requests.HTTPError):
|
||||
main(["post_merge.py", "GRM-20: fix: bug"])
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Unit tests for scripts/publish.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from scripts.publish import (
|
||||
GITEA_API,
|
||||
build_package,
|
||||
create_gitea_release,
|
||||
main,
|
||||
publish_to_pypi,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildPackage:
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
||||
build_package()
|
||||
args, _ = mock_run.call_args
|
||||
assert args[0][1] == "-m"
|
||||
assert args[0][2] == "build"
|
||||
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
def test_failure_exits(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="build error")
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
build_package()
|
||||
assert exc.value.code == 1
|
||||
|
||||
|
||||
class TestPublishToPypi:
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
||||
publish_to_pypi("pypi-tok")
|
||||
args, _ = mock_run.call_args
|
||||
assert "twine" in args[0]
|
||||
assert "pypi-tok" in args[0]
|
||||
captured = capsys.readouterr()
|
||||
assert "Published to PyPI" in captured.out
|
||||
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
def test_failure_exits(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="upload failed")
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
publish_to_pypi("pypi-tok")
|
||||
assert exc.value.code == 1
|
||||
|
||||
|
||||
class TestCreateGiteaRelease:
|
||||
@patch("scripts.publish.requests.post")
|
||||
def test_success(self, mock_post: MagicMock) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_post.return_value = mock_response
|
||||
create_gitea_release("tok", "owner/repo", "v1.0.0")
|
||||
args, kwargs = mock_post.call_args
|
||||
assert GITEA_API in args[0]
|
||||
assert kwargs["json"]["tag_name"] == "v1.0.0"
|
||||
assert kwargs["json"]["draft"] is False
|
||||
assert kwargs["json"]["prerelease"] is False
|
||||
|
||||
@patch("scripts.publish.requests.post")
|
||||
def test_http_error_propagates(self, mock_post: MagicMock) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("500")
|
||||
mock_post.return_value = mock_response
|
||||
with pytest.raises(requests.HTTPError):
|
||||
create_gitea_release("tok", "owner/repo", "v1.0.0")
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.publish.create_gitea_release")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
def test_full_flow_with_pypi(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_release: MagicMock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
main(["publish.py", "v1.0.0", "owner/repo"])
|
||||
mock_build.assert_called_once()
|
||||
mock_publish.assert_called_once_with("pypi-tok")
|
||||
mock_release.assert_called_once_with("gitea-tok", "owner/repo", "v1.0.0")
|
||||
captured = capsys.readouterr()
|
||||
assert "Gitea release v1.0.0 created" in captured.out
|
||||
|
||||
@patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok"}, clear=True)
|
||||
@patch("scripts.publish.create_gitea_release")
|
||||
@patch("scripts.publish.build_package")
|
||||
def test_without_pypi(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_release: MagicMock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
main(["publish.py", "v1.0.0", "owner/repo"])
|
||||
mock_build.assert_called_once()
|
||||
mock_release.assert_called_once_with("gitea-tok", "owner/repo", "v1.0.0")
|
||||
captured = capsys.readouterr()
|
||||
assert "PYPI_TOKEN not set" in captured.out
|
||||
|
||||
@patch.dict("os.environ", {"GITEA_TOKEN": ""}, clear=True)
|
||||
def test_missing_gitea_token_exits(self) -> None:
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main(["publish.py", "v1.0.0", "owner/repo"])
|
||||
assert exc.value.code == 1
|
||||
|
||||
@patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.publish.create_gitea_release")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
def test_build_failure_propagates(self, mock_build: MagicMock, *_: MagicMock) -> None:
|
||||
mock_build.side_effect = SystemExit(1)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main(["publish.py", "v1.0.0", "owner/repo"])
|
||||
assert exc.value.code == 1
|
||||
|
||||
@patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.publish.create_gitea_release")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
def test_publish_failure_propagates(self, mock_publish: MagicMock, *_: MagicMock) -> None:
|
||||
mock_publish.side_effect = SystemExit(1)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main(["publish.py", "v1.0.0", "owner/repo"])
|
||||
assert exc.value.code == 1
|
||||
|
||||
@patch.dict("os.environ", {"GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.publish.create_gitea_release")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
def test_release_failure_propagates(self, mock_release: MagicMock, *_: MagicMock) -> None:
|
||||
mock_release.side_effect = requests.HTTPError("500")
|
||||
with pytest.raises(requests.HTTPError):
|
||||
main(["publish.py", "v1.0.0", "owner/repo"])
|
||||
Reference in New Issue
Block a user