GRM-33: feat: add mandatory PR review step to workflow
This commit is contained in:
+113
-17
@@ -10,6 +10,8 @@ from click.testing import CliRunner
|
||||
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.auto_merge import (
|
||||
PR_TITLE_RE,
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
has_ready_to_merge_label,
|
||||
main,
|
||||
@@ -26,6 +28,10 @@ def _status(context: str, status: str, updated_at: str = "2026-01-01T00:00:00Z")
|
||||
return {"context": context, "status": status, "updated_at": updated_at}
|
||||
|
||||
|
||||
def _commit(message: str) -> dict[str, dict[str, str]]:
|
||||
return {"commit": {"message": message}}
|
||||
|
||||
|
||||
class TestRegexes:
|
||||
def test_task_id_re_matches(self) -> None:
|
||||
assert TASK_ID_RE.search("GRM-19-fix-bug")
|
||||
@@ -42,6 +48,15 @@ class TestRegexes:
|
||||
assert not CONVENTIONAL_RE.match("random message")
|
||||
assert not CONVENTIONAL_RE.match("feat:")
|
||||
|
||||
def test_pr_title_re_matches(self) -> None:
|
||||
assert PR_TITLE_RE.match("GRM-19: Some task title")
|
||||
assert PR_TITLE_RE.match("GRM-42: double space title")
|
||||
|
||||
def test_pr_title_re_rejects(self) -> None:
|
||||
assert not PR_TITLE_RE.match("fix: resolve timeout")
|
||||
assert not PR_TITLE_RE.match("GRM-19:No space after colon")
|
||||
assert not PR_TITLE_RE.match("random message")
|
||||
|
||||
|
||||
class TestExtractTaskId:
|
||||
def test_extracts_from_branch(self) -> None:
|
||||
@@ -56,15 +71,58 @@ class TestExtractTaskId:
|
||||
|
||||
class TestValidatePrTitle:
|
||||
def test_valid_title_passes(self) -> None:
|
||||
validate_pr_title("fix: resolve timeout")
|
||||
validate_pr_title("GRM-19: Some task title", "GRM-19")
|
||||
|
||||
def test_valid_title_with_scope_passes(self) -> None:
|
||||
validate_pr_title("feat(cli): add --url option")
|
||||
validate_pr_title("GRM-42: Add --url option", "GRM-42")
|
||||
|
||||
def test_invalid_title_raises(self) -> None:
|
||||
def test_invalid_format_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
validate_pr_title("random message")
|
||||
assert "conventional" in str(exc.value)
|
||||
validate_pr_title("random message", "GRM-19")
|
||||
assert "GRM-N" in str(exc.value)
|
||||
|
||||
def test_invalid_format_conventional_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
validate_pr_title("fix: resolve timeout", "GRM-19")
|
||||
assert "GRM-N" in str(exc.value)
|
||||
|
||||
def test_task_id_mismatch_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
validate_pr_title("GRM-42: Some task title", "GRM-19")
|
||||
assert "mismatch" in str(exc.value)
|
||||
|
||||
|
||||
class TestExtractConventionalMsg:
|
||||
def test_returns_newest_conventional(self) -> None:
|
||||
"""Iterates in reverse — picks the newest conventional commit."""
|
||||
commits = [
|
||||
_commit("fix: resolve timeout"),
|
||||
_commit("random message"),
|
||||
_commit("feat: add thing"),
|
||||
]
|
||||
assert extract_conventional_msg(commits) == "feat: add thing"
|
||||
|
||||
def test_falls_back_to_newest_commit(self) -> None:
|
||||
commits = [
|
||||
_commit("another random"),
|
||||
_commit("random message"),
|
||||
]
|
||||
assert extract_conventional_msg(commits) == "random message"
|
||||
|
||||
def test_uses_first_line_only(self) -> None:
|
||||
commits = [_commit("fix: resolve timeout\n\nBody text here")]
|
||||
assert extract_conventional_msg(commits) == "fix: resolve timeout"
|
||||
|
||||
def test_empty_commits_returns_empty(self) -> None:
|
||||
assert extract_conventional_msg([]) == ""
|
||||
|
||||
def test_commit_with_scope(self) -> None:
|
||||
commits = [_commit("feat(api): new endpoint")]
|
||||
assert extract_conventional_msg(commits) == "feat(api): new endpoint"
|
||||
|
||||
def test_missing_commit_key(self) -> None:
|
||||
commits = [{}] # type: ignore[list-item]
|
||||
assert extract_conventional_msg(commits) == ""
|
||||
|
||||
|
||||
class TestHasReadyToMergeLabel:
|
||||
@@ -161,6 +219,10 @@ def _mock_ci_passing() -> list[dict[str, str]]:
|
||||
return [_status("CI / quality (pull_request)", CI_SUCCESS)]
|
||||
|
||||
|
||||
def _mock_commits() -> list[dict[str, dict[str, str]]]:
|
||||
return [_commit("fix: resolve timeout")]
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@@ -169,14 +231,15 @@ class TestMain:
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7", "ready-to-merge"],
|
||||
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7", "ready-to-merge"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "squash-merged" in result.output
|
||||
mock_client.merge_pr.assert_called_once_with("7", "GRM-19: fix: resolve timeout")
|
||||
mock_client.merge_pr.assert_called_once_with("7", "GRM-19 fix: resolve timeout")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@@ -186,15 +249,16 @@ class TestMain:
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7"],
|
||||
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "squash-merged" in result.output
|
||||
mock_client.merge_pr.assert_called_once_with("7", "GRM-19: fix: resolve timeout")
|
||||
mock_client.merge_pr.assert_called_once_with("7", "GRM-19 fix: resolve timeout")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@@ -206,7 +270,7 @@ class TestMain:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7", "bug"],
|
||||
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7", "bug"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "skipping" in result.output
|
||||
@@ -220,11 +284,12 @@ class TestMain:
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7", ""],
|
||||
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7", ""],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "squash-merged" in result.output
|
||||
@@ -243,7 +308,7 @@ class TestMain:
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["feature-no-id", "fix: bug", "owner/repo", "1"])
|
||||
result = runner.invoke(main, ["feature-no-id", "GRM-19: bug", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "task ID" in result.output
|
||||
|
||||
@@ -256,7 +321,35 @@ class TestMain:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "random title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "conventional" in result.output
|
||||
assert "GRM-N" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_pr_title_task_id_mismatch_exits(self, mock_client_cls: MagicMock) -> None:
|
||||
"""PR title has a different task ID than the branch."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-42: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "mismatch" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_empty_commits_exits(self, mock_client_cls: MagicMock) -> None:
|
||||
"""PR has no commits — cannot extract conventional message."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = []
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "conventional commit" in result.output
|
||||
mock_client.merge_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@@ -265,10 +358,11 @@ class TestMain:
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "HTTP" in result.output
|
||||
|
||||
@@ -279,10 +373,11 @@ class TestMain:
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
|
||||
|
||||
@@ -298,7 +393,7 @@ class TestMain:
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "CI checks did not pass" in result.output
|
||||
mock_client.merge_pr.assert_not_called()
|
||||
@@ -310,8 +405,9 @@ class TestMain:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = {"head": {}}
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 0
|
||||
assert "squash-merged" in result.output
|
||||
|
||||
Reference in New Issue
Block a user