Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion pr_split/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,11 @@ def split(
if not is_worktree_clean():
console.print(f"[red]{ErrorMsg.DIRTY_WORKTREE()}[/red]")
raise typer.Exit(1)
fork_info = _resolve_fork_ref(dev_branch)
try:
fork_info = _resolve_fork_ref(dev_branch)
except PRSplitError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
if not fork_info:
console.print(f"[red]{ErrorMsg.BRANCH_NOT_FOUND(branch=dev_branch)}[/red]")
raise typer.Exit(1)
Expand Down
6 changes: 5 additions & 1 deletion pr_split/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ class ErrorMsg(StrEnum):
BRANCH_CREATE_FAILED = "Failed to create branch '{branch}': {detail}"
PR_CREATE_FAILED = "Failed to create PR for group '{group}': {detail}"
MERGE_FAILED = "Merge of '{source}' into '{target}' failed: {detail}"
PR_NOT_FOUND = "PR #{number} not found or is not from a fork"
PR_NOT_FOUND = "PR #{number} not found"
PR_RESPONSE_INVALID = "Unexpected response from GitHub for PR #{number}: {detail}"
PR_NOT_FROM_FORK = (
"PR #{number} is not from a fork; pass its head branch name instead of the PR number"
)
PR_FETCH_FAILED = "Failed to fetch fork branch for PR #{number}: {detail}"
FORK_FETCH_FAILED = "Failed to fetch {user}:{branch}: {detail}"
HUNK_TOO_LARGE = "Hunk {file}[{index}] has ~{tokens} estimated tokens, exceeds budget {budget}"
Expand Down
16 changes: 12 additions & 4 deletions pr_split/git_ops/prs.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,24 @@ def fetch_fork_pr(pr_number: int) -> ForkPRInfo:
except GitOperationError as exc:
raise GitOperationError(ErrorMsg.PR_NOT_FOUND(number=pr_number)) from exc

pr_data: dict[str, object] = json.loads(raw)
head = pr_data["head"]
base = pr_data["base"]
try:
pr_data = json.loads(raw)
head = pr_data["head"]
base = pr_data["base"]
except (json.JSONDecodeError, KeyError, TypeError) as exc:
raise GitOperationError(
ErrorMsg.PR_RESPONSE_INVALID(number=pr_number, detail=str(exc))
) from exc

if not isinstance(head, dict) or not isinstance(base, dict):
raise GitOperationError(ErrorMsg.PR_NOT_FOUND(number=pr_number))

head_repo = head.get("repo")
if not isinstance(head_repo, dict) or not head_repo.get("fork"):
if not isinstance(head_repo, dict):
# head.repo is null when the fork was deleted
raise GitOperationError(ErrorMsg.PR_NOT_FOUND(number=pr_number))
if not head_repo.get("fork"):
raise GitOperationError(ErrorMsg.PR_NOT_FROM_FORK(number=pr_number))

clone_url = str(head_repo["clone_url"])
head_ref = str(head["ref"])
Expand Down
17 changes: 17 additions & 0 deletions tests/test_cli_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,23 @@ def test_split_fork_ref_dirty_worktree(
result = runner.invoke(app, ["split", "#42", "--dry-run"])
assert result.exit_code != 0

@patch("pr_split.cli._resolve_fork_ref", side_effect=GitOperationError("PR #999 not found"))
@patch("pr_split.cli.is_worktree_clean", return_value=True)
@patch("pr_split.cli.check_gh_auth", return_value=True)
@patch("pr_split.cli.branch_exists", return_value=False)
def test_split_fork_ref_error_is_reported_not_raised(
self,
mock_be: MagicMock,
mock_auth: MagicMock,
mock_clean: MagicMock,
mock_resolve: MagicMock,
) -> None:
result = runner.invoke(app, ["split", "#999", "--dry-run"])
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "PR #999 not found" in result.output
assert "Traceback" not in result.output

@patch("pr_split.cli._resolve_fork_ref", return_value=None)
@patch("pr_split.cli.is_worktree_clean", return_value=True)
@patch("pr_split.cli.check_gh_auth", return_value=True)
Expand Down
32 changes: 32 additions & 0 deletions tests/test_git_prs_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,35 @@ def test_git_fetch_failure(self, mock_gh: MagicMock, mock_git: MagicMock) -> Non

with pytest.raises(GitOperationError, match="Failed to fetch"):
fetch_fork_branch("user", "branch")


class TestFetchForkPrNotFromFork:
@patch("pr_split.git_ops.prs._run_gh")
def test_same_repo_pr_gets_specific_message(self, mock_gh: MagicMock) -> None:
pr_data = {
"head": {
"ref": "feature",
"repo": {
"fork": False,
"clone_url": "https://github.com/org/repo.git",
"full_name": "org/repo",
},
},
"base": {"ref": "main"},
}
mock_gh.return_value = json.dumps(pr_data)
with pytest.raises(GitOperationError, match="PR #42 is not from a fork"):
fetch_fork_pr(42)


class TestFetchForkPrMalformedResponse:
@pytest.mark.parametrize(
"raw", ["not json", "[]", '{"head": {}}'], ids=["text", "list", "no-base"]
)
@patch("pr_split.git_ops.prs._run_gh")
def test_malformed_response_is_a_git_operation_error(
self, mock_gh: MagicMock, raw: str
) -> None:
mock_gh.return_value = raw
with pytest.raises(GitOperationError, match="Unexpected response from GitHub for PR #42"):
fetch_fork_pr(42)
Loading