Files
devx/tests/unit/test_rebase.py
T
emil ddfbdec956
Post-merge / detect-and-configure (push) Successful in 12s
Post-merge / release-and-maintain (push) Successful in 1m0s
DEVX-136: feat: add fix_pr_title module and update_pr API method
2026-07-13 23:55:11 +00:00

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")