Public Access
358 lines
15 KiB
Python
358 lines
15 KiB
Python
"""Tests for devx.tools.rebase, devx.tools.pr_rebase, and detect_pr_number."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from click.testing import CliRunner
|
|
|
|
from devx.tools.pr_rebase import main as pr_rebase_main
|
|
from devx.tools.rebase import main as rebase_main
|
|
|
|
_FULL_ENV = {
|
|
"CI_GITEA_TOKEN": "tok",
|
|
"DEVX_REPO_OWNER": "owner",
|
|
"DEVX_REPO_NAME": "repo",
|
|
}
|
|
|
|
|
|
class TestRunGitHelper:
|
|
"""Tests for the _run_git helper function."""
|
|
|
|
@patch("devx.tools.rebase.subprocess.run")
|
|
def test_run_git_with_check(self, mock_run: MagicMock) -> None:
|
|
"""_run_git passes check=True by default."""
|
|
from devx.tools.rebase import _run_git
|
|
|
|
mock_run.return_value = MagicMock(stdout="ok\n", returncode=0)
|
|
result = _run_git(["status"])
|
|
mock_run.assert_called_once_with(
|
|
["git", "status"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
assert result.stdout == "ok\n"
|
|
|
|
@patch("devx.tools.rebase.subprocess.run")
|
|
def test_run_git_without_check(self, mock_run: MagicMock) -> None:
|
|
"""_run_git passes check=False when specified."""
|
|
from devx.tools.rebase import _run_git
|
|
|
|
mock_run.return_value = MagicMock(stdout="", stderr="err", returncode=1)
|
|
result = _run_git(["rebase", "origin/master"], check=False)
|
|
mock_run.assert_called_once_with(
|
|
["git", "rebase", "origin/master"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
assert result.returncode == 1
|
|
|
|
|
|
class TestDetectPrNumber:
|
|
"""Tests for the detect_pr_number helper in _shared."""
|
|
|
|
@patch("devx.tools._shared.subprocess.run")
|
|
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
|
@patch("devx.api_clients.GiteaClient")
|
|
def test_detect_pr_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
|
|
"""detect_pr_number returns PR number when branch has an open PR."""
|
|
from devx.tools._shared import detect_pr_number
|
|
|
|
mock_run.return_value = MagicMock(stdout="feature-branch\n", returncode=0)
|
|
mock_client = MagicMock()
|
|
mock_client.list_prs.return_value = [
|
|
{"number": 42, "head": {"ref": "feature-branch"}},
|
|
{"number": 99, "head": {"ref": "other-branch"}},
|
|
]
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
result = detect_pr_number()
|
|
assert result == 42
|
|
|
|
@patch("devx.tools._shared.subprocess.run")
|
|
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
|
@patch("devx.api_clients.GiteaClient")
|
|
def test_detect_pr_not_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
|
|
"""detect_pr_number returns None when no open PR matches branch."""
|
|
from devx.tools._shared import detect_pr_number
|
|
|
|
mock_run.return_value = MagicMock(stdout="no-pr-branch\n", returncode=0)
|
|
mock_client = MagicMock()
|
|
mock_client.list_prs.return_value = [
|
|
{"number": 42, "head": {"ref": "other-branch"}},
|
|
]
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
result = detect_pr_number()
|
|
assert result is None
|
|
|
|
@patch("devx.tools._shared.subprocess.run")
|
|
def test_detect_pr_detached_head(self, mock_run: MagicMock) -> None:
|
|
"""detect_pr_number returns None on detached HEAD."""
|
|
from devx.tools._shared import detect_pr_number
|
|
|
|
mock_run.return_value = MagicMock(stdout="HEAD\n", returncode=0)
|
|
result = detect_pr_number()
|
|
assert result is None
|
|
|
|
@patch("devx.tools._shared.subprocess.run")
|
|
def test_detect_pr_git_failure(self, mock_run: MagicMock) -> None:
|
|
"""detect_pr_number returns None when git command fails."""
|
|
from devx.tools._shared import detect_pr_number
|
|
|
|
mock_run.return_value = MagicMock(stdout="", stderr="error", returncode=1)
|
|
result = detect_pr_number()
|
|
assert result is None
|
|
|
|
@patch("devx.tools._shared.subprocess.run")
|
|
@patch.dict("os.environ", {}, clear=True)
|
|
def test_detect_pr_no_token(self, mock_run: MagicMock) -> None:
|
|
"""detect_pr_number returns None when CI_GITEA_TOKEN is not set."""
|
|
from devx.tools._shared import detect_pr_number
|
|
|
|
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
|
|
result = detect_pr_number()
|
|
assert result is None
|
|
|
|
@patch("devx.tools._shared.subprocess.run")
|
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True)
|
|
@patch("devx.api_clients.GiteaClient")
|
|
def test_detect_pr_github_repo_fallback(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
|
|
"""detect_pr_number uses GITHUB_REPOSITORY as fallback for owner/repo."""
|
|
from devx.tools._shared import detect_pr_number
|
|
|
|
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
|
|
mock_client = MagicMock()
|
|
mock_client.list_prs.return_value = [{"number": 7, "head": {"ref": "feature"}}]
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
result = detect_pr_number()
|
|
assert result == 7
|
|
|
|
@patch("devx.tools._shared.subprocess.run")
|
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "invalid-no-slash"}, clear=True)
|
|
def test_detect_pr_github_repo_no_slash(self, mock_run: MagicMock) -> None:
|
|
"""GITHUB_REPOSITORY without slash is ignored, returns None."""
|
|
from devx.tools._shared import detect_pr_number
|
|
|
|
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
|
|
result = detect_pr_number()
|
|
assert result is None
|
|
|
|
@patch("devx.tools._shared.subprocess.run")
|
|
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
|
@patch("devx.api_clients.GiteaClient")
|
|
def test_detect_pr_api_error_returns_none(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
|
|
"""detect_pr_number returns None when API call fails (best-effort)."""
|
|
from devx.api_clients import APIError
|
|
from devx.tools._shared import detect_pr_number
|
|
|
|
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
|
|
mock_client = MagicMock()
|
|
mock_client.list_prs.side_effect = APIError(401, "Unauthorized")
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
result = detect_pr_number()
|
|
assert result is None
|
|
|
|
|
|
class TestRebaseTool:
|
|
"""Tests for the local rebase tool (devx.tools.rebase)."""
|
|
|
|
@patch("devx.tools.rebase._run_git")
|
|
def test_rebase_already_up_to_date(self, mock_run_git: MagicMock) -> None:
|
|
"""When branch is up-to-date, no rebase or push happens."""
|
|
mock_run_git.side_effect = [
|
|
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
|
MagicMock(stdout="", returncode=0), # fetch
|
|
MagicMock(stdout="0\n", returncode=0), # rev-list --count
|
|
]
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(rebase_main, [])
|
|
assert result.exit_code == 0
|
|
assert "already up-to-date" in result.output.lower()
|
|
|
|
@patch("devx.tools.rebase._run_git")
|
|
def test_rebase_behind_master_success(self, mock_run_git: MagicMock) -> None:
|
|
"""When behind master, rebase and force-push."""
|
|
mock_run_git.side_effect = [
|
|
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
|
MagicMock(stdout="", returncode=0), # fetch
|
|
MagicMock(stdout="2\n", returncode=0), # rev-list --count (behind by 2)
|
|
MagicMock(stdout="", stderr="", returncode=0), # rebase
|
|
MagicMock(stdout="", stderr="", returncode=0), # push
|
|
]
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(rebase_main, [])
|
|
assert result.exit_code == 0
|
|
assert "2 commit(s) behind" in result.output
|
|
assert "rebase successful" in result.output.lower()
|
|
assert "pushed" in result.output.lower()
|
|
|
|
@patch("devx.tools.rebase._run_git")
|
|
def test_rebase_no_push_flag(self, mock_run_git: MagicMock) -> None:
|
|
"""With --no-push, rebase happens but no push."""
|
|
mock_run_git.side_effect = [
|
|
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
|
MagicMock(stdout="", returncode=0), # fetch
|
|
MagicMock(stdout="1\n", returncode=0), # rev-list --count
|
|
MagicMock(stdout="", stderr="", returncode=0), # rebase
|
|
]
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(rebase_main, ["--no-push"])
|
|
assert result.exit_code == 0
|
|
assert "rebase successful" in result.output.lower()
|
|
# Only 4 git calls (no push)
|
|
assert mock_run_git.call_count == 4
|
|
|
|
@patch("devx.tools.rebase._run_git")
|
|
def test_rebase_detached_head_fails(self, mock_run_git: MagicMock) -> None:
|
|
"""Detached HEAD should fail immediately."""
|
|
mock_run_git.return_value = MagicMock(stdout="HEAD\n", returncode=0)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(rebase_main, [])
|
|
assert result.exit_code != 0
|
|
assert "detached" in result.output.lower()
|
|
|
|
@patch("devx.tools.rebase._run_git")
|
|
def test_rebase_branch_detection_failure(self, mock_run_git: MagicMock) -> None:
|
|
"""Git rev-parse failure should exit with error."""
|
|
mock_run_git.return_value = MagicMock(stdout="", stderr="fatal: not a repo", returncode=1)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(rebase_main, [])
|
|
assert result.exit_code != 0
|
|
assert "could not detect" in result.output.lower()
|
|
|
|
@patch("devx.tools.rebase._run_git")
|
|
def test_rebase_conflict_fails(self, mock_run_git: MagicMock) -> None:
|
|
"""Rebase conflict should exit with error."""
|
|
mock_run_git.side_effect = [
|
|
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
|
MagicMock(stdout="", returncode=0), # fetch
|
|
MagicMock(stdout="1\n", returncode=0), # rev-list --count
|
|
MagicMock(stdout="", stderr="CONFLICT", returncode=1), # rebase fails
|
|
]
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(rebase_main, [])
|
|
assert result.exit_code != 0
|
|
assert "rebase failed" in result.output.lower()
|
|
|
|
@patch("devx.tools.rebase._run_git")
|
|
def test_rebase_fetch_failure(self, mock_run_git: MagicMock) -> None:
|
|
"""Fetch failure should exit with error."""
|
|
mock_run_git.side_effect = [
|
|
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
|
MagicMock(stdout="", stderr="network error", returncode=1), # fetch fails
|
|
]
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(rebase_main, [])
|
|
assert result.exit_code != 0
|
|
assert "fetch failed" in result.output.lower()
|
|
|
|
@patch("devx.tools.rebase._run_git")
|
|
def test_rebase_push_failure(self, mock_run_git: MagicMock) -> None:
|
|
"""Force-push rejection should exit with error."""
|
|
mock_run_git.side_effect = [
|
|
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
|
|
MagicMock(stdout="", returncode=0), # fetch
|
|
MagicMock(stdout="1\n", returncode=0), # rev-list --count
|
|
MagicMock(stdout="", stderr="", returncode=0), # rebase
|
|
MagicMock(stdout="", stderr="rejected", returncode=1), # push fails
|
|
]
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(rebase_main, [])
|
|
assert result.exit_code != 0
|
|
assert "force-push failed" in result.output.lower()
|
|
|
|
|
|
class TestPrRebaseTool:
|
|
"""Tests for the server-side PR rebase tool (devx.tools.pr_rebase)."""
|
|
|
|
@patch("devx.tools._shared.detect_pr_number")
|
|
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
|
@patch("devx.tools.pr_rebase.GiteaClient")
|
|
def test_pr_rebase_success(self, mock_client_cls: MagicMock, mock_detect: MagicMock) -> None:
|
|
"""Successful API rebase prints confirmation."""
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
|
assert result.exit_code == 0
|
|
assert "rebased successfully" in result.output.lower()
|
|
mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")
|
|
|
|
@patch("devx.tools._shared.detect_pr_number")
|
|
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
|
@patch("devx.tools.pr_rebase.GiteaClient")
|
|
def test_pr_rebase_api_error(self, mock_client_cls: MagicMock, mock_detect: MagicMock) -> None:
|
|
"""API error during rebase exits with error."""
|
|
from devx.api_clients import APIError
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.update_pr_branch.side_effect = APIError(409, "Conflict")
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
|
assert result.exit_code != 0
|
|
assert "rebase failed" in result.output.lower()
|
|
|
|
@patch("devx.tools._shared.detect_pr_number")
|
|
@patch("devx.tools.pr_rebase.load_dotenv")
|
|
@patch.dict("os.environ", {}, clear=True)
|
|
def test_pr_rebase_no_token(self, _mock_load: MagicMock, mock_detect: MagicMock) -> None:
|
|
"""Missing CI_GITEA_TOKEN should fail."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
|
assert result.exit_code != 0
|
|
assert "CI_GITEA_TOKEN" in result.output
|
|
|
|
@patch.dict("os.environ", _FULL_ENV, clear=True)
|
|
@patch("devx.tools.pr_rebase.detect_pr_number", return_value=None)
|
|
def test_pr_rebase_no_pr_detected(self, _mock_detect: MagicMock) -> None:
|
|
"""When PR number can't be auto-detected, fail with instructions."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(pr_rebase_main, [])
|
|
assert result.exit_code != 0
|
|
assert "could not detect" in result.output.lower()
|
|
|
|
@patch("devx.tools._shared.detect_pr_number")
|
|
@patch("devx.tools.pr_rebase.load_dotenv")
|
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
|
@patch("devx.tools.pr_rebase.GiteaClient")
|
|
def test_pr_rebase_no_repo_env(
|
|
self, _mock_client: MagicMock, _mock_load: MagicMock, mock_detect: MagicMock
|
|
) -> None:
|
|
"""Missing repo env vars should fail."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
|
assert result.exit_code != 0
|
|
assert "DEVX_REPO_OWNER" in result.output
|
|
|
|
@patch("devx.tools._shared.detect_pr_number")
|
|
@patch("devx.tools.pr_rebase.load_dotenv")
|
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True)
|
|
@patch("devx.tools.pr_rebase.GiteaClient")
|
|
def test_pr_rebase_github_repo_fallback(
|
|
self, mock_client_cls: MagicMock, _mock_load: MagicMock, mock_detect: MagicMock
|
|
) -> None:
|
|
"""GITHUB_REPOSITORY env var is used as fallback for owner/repo."""
|
|
mock_client = MagicMock()
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
|
|
assert result.exit_code == 0
|
|
mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")
|