fix(git): create_pr auto-creates a missing PR base branch on origin

open_pr -> GitService.create_pr posted "base": parent straight to GitHub, so
when the parent (an ancestor task's integration branch) was never pushed — a PM
paused before its first push, or the workspace was wiped — GitHub 422'd "base
field invalid" and stranded every child PR. The base-existence fallback added
in 3d9dd298 lived only in create_pull_request, which open_pr never calls.

Add _ensure_base_on_remote and call it in create_pr: if the base branch is
absent on origin, create it off the default branch's tip (preserving the
integration hierarchy) instead of failing; fall back to the default branch only
if that create push itself fails. Covered by 3 new tests.
This commit is contained in:
Renn F
2026-06-15 23:34:43 +02:00
parent a4d84992bd
commit 3d8c0e1c54
2 changed files with 141 additions and 0 deletions
+76
View File
@@ -294,6 +294,8 @@ async def test_create_pr_returns_pr_dict() -> None:
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_record_pr_atomically", AsyncMock())
# parent == default → _ensure_base_on_remote short-circuits (no git call)
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
fake_resp = MagicMock()
fake_resp.is_success = True
@@ -321,6 +323,80 @@ async def test_create_pr_raises_when_branch_not_found() -> None:
await svc.create_pr("missing/branch", parent="master", is_root_pr=False)
# ---------------------------------------------------------------------------
# _ensure_base_on_remote: create the PR base branch if it's missing on origin
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ensure_base_creates_missing_base_off_default() -> None:
"""Missing base branch is created on origin off the default branch tip."""
svc = _service()
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
calls: list[list[str]] = []
async def fake_run_git(_ws: Path, args: list[str], **_: object) -> MagicMock:
calls.append(args)
if args[0] == "ls-remote":
return MagicMock(stdout="", returncode=0, stderr="") # base absent
return MagicMock(stdout="", returncode=0, stderr="")
_bind(svc, "_run_git", fake_run_git)
out = await svc._ensure_base_on_remote(
Path("/tmp/ws"), "feature/frontend/abc--def", "roboco", "tok"
)
assert out == "feature/frontend/abc--def"
assert any(
a[0] == "push" and a[-1] == "origin/master:refs/heads/feature/frontend/abc--def"
for a in calls
), calls
@pytest.mark.asyncio
async def test_ensure_base_passthrough_when_present() -> None:
"""An existing base branch is returned unchanged with no push."""
svc = _service()
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
pushed = False
async def fake_run_git(_ws: Path, args: list[str], **_: object) -> MagicMock:
nonlocal pushed
if args[0] == "push":
pushed = True
if args[0] == "ls-remote":
return MagicMock(
stdout="sha\trefs/heads/feature/x", returncode=0, stderr=""
)
return MagicMock(stdout="", returncode=0, stderr="")
_bind(svc, "_run_git", fake_run_git)
out = await svc._ensure_base_on_remote(
Path("/tmp/ws"), "feature/x", "roboco", "tok"
)
assert out == "feature/x"
assert pushed is False
@pytest.mark.asyncio
async def test_ensure_base_falls_back_to_default_when_create_fails() -> None:
"""If the create push fails, retarget to the default branch (never 422)."""
svc = _service()
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
async def fake_run_git(_ws: Path, args: list[str], **_: object) -> MagicMock:
if args[0] == "ls-remote":
return MagicMock(stdout="", returncode=0, stderr="")
if args[0] == "push":
return MagicMock(stdout="", returncode=1, stderr="denied")
return MagicMock(stdout="", returncode=0, stderr="")
_bind(svc, "_run_git", fake_run_git)
out = await svc._ensure_base_on_remote(
Path("/tmp/ws"), "feature/x", "roboco", "tok"
)
assert out == "master"
# ---------------------------------------------------------------------------
# pr_merge: returns merge commit dict
# ---------------------------------------------------------------------------