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
56 changes: 33 additions & 23 deletions src/forge/workflow/nodes/workspace_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,38 +232,46 @@ async def setup_workspace(state: WorkflowState) -> WorkflowState:
git.clone()
logger.info(f"Clone completed successfully for {current_repo}")

# Detect the upstream default branch (main, master, etc.)
# Detect the upstream default branch and establish the durable fork
# before implementation starts. Every implementation/review handoff
# pushes to this fork, so continuing without it would guarantee a
# later persistence failure.
default_branch = "main"
fork_owner = state.get("fork_owner", "")
fork_repo_name = state.get("fork_repo", "")
if current_repo and "/" in current_repo:
owner, repo_name = current_repo.split("/", 1)
github = GitHubClient()
try:
repo_data = await github.get_repository(owner, repo_name)
default_branch = repo_data.get("default_branch", "main")
logger.info(f"Detected default branch for {current_repo}: {default_branch}")
except Exception as exc:
logger.warning(f"Could not detect default branch for {current_repo}: {exc}")
try:
repo_data = await github.get_repository(owner, repo_name)
default_branch = repo_data.get("default_branch", "main")
logger.info(f"Detected default branch for {current_repo}: {default_branch}")
except Exception as exc:
logger.warning(f"Could not detect default branch for {current_repo}: {exc}")

if not fork_owner or not fork_repo_name:
fork_data = await github.get_or_create_fork(owner, repo_name)
fork_owner = fork_data["owner"]["login"]
fork_repo_name = fork_data["name"]

await github.sync_fork_with_upstream(
fork_owner,
fork_repo_name,
branch=default_branch,
)
finally:
await github.close()

# Set up feature branch.
# If the workflow already created a PR (fork_owner/fork_repo in state),
# the branch lives on the fork. Add the fork remote, check whether the
# branch exists there, and check it out so we don't lose history.
fork_owner = state.get("fork_owner", "")
fork_repo_name = state.get("fork_repo", "")

if fork_owner and fork_repo_name:
git.add_fork_remote(fork_owner, fork_repo_name)
branch_exists_on_fork = git.remote_branch_exists(workspace.branch_name, remote="fork")
if branch_exists_on_fork:
logger.info(
f"Branch '{workspace.branch_name}' exists on fork "
f"{fork_owner}/{fork_repo_name} — checking it out"
)
git.checkout_branch(workspace.branch_name, remote="fork")
else:
git.create_branch(default_branch)
git.add_fork_remote(fork_owner, fork_repo_name)
branch_exists_on_fork = git.remote_branch_exists(workspace.branch_name, remote="fork")
if branch_exists_on_fork:
logger.info(
f"Branch '{workspace.branch_name}' exists on fork "
f"{fork_owner}/{fork_repo_name} — checking it out"
)
git.checkout_branch(workspace.branch_name, remote="fork")
else:
git.create_branch(default_branch)

Expand Down Expand Up @@ -303,6 +311,8 @@ async def setup_workspace(state: WorkflowState) -> WorkflowState:
**state,
"workspace_path": str(workspace.path),
"current_repo": current_repo,
"fork_owner": fork_owner,
"fork_repo": fork_repo_name,
"context": context,
"current_node": "implementation",
"last_error": None,
Expand Down
85 changes: 85 additions & 0 deletions tests/unit/workflow/nodes/test_workspace_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,20 @@ def create_mock_guardrails_loader():
return mock


@pytest.fixture(autouse=True)
def mock_workspace_github():
"""Keep workspace tests isolated from GitHub fork operations."""
github = MagicMock()
github.get_repository = AsyncMock(return_value={"default_branch": "main"})
github.get_or_create_fork = AsyncMock(
return_value={"owner": {"login": "fork-owner"}, "name": "test-repo"}
)
github.sync_fork_with_upstream = AsyncMock(return_value=True)
github.close = AsyncMock()
with patch("forge.workflow.nodes.workspace_setup.GitHubClient", return_value=github):
yield github


class TestWorkspaceSetupStatusComment:
"""Test cases for workspace setup posting status comments."""

Expand Down Expand Up @@ -278,6 +292,77 @@ async def test_workspace_setup_continues_on_jira_failure(self, caplog):
assert result["workspace_path"] == str(Path("/tmp/test-workspace"))
mock_jira.close.assert_called_once()

@pytest.mark.asyncio
async def test_workspace_setup_fails_when_fork_cannot_be_created(
self, mock_workspace_github
):
"""Implementation must not start without its durable backup remote."""
mock_workspace_github.get_or_create_fork.side_effect = RuntimeError(
"fork creation denied"
)
mock_jira = create_mock_jira_client()
mock_manager, _ = create_mock_workspace_manager()
mock_git = create_mock_git_operations()

state = create_initial_feature_state(
ticket_key="TEST-FORK-FAIL",
current_repo="owner/test-repo",
)

with (
patch("forge.workflow.nodes.workspace_setup.JiraClient", return_value=mock_jira),
patch(
"forge.workflow.nodes.workspace_setup.get_workspace_manager",
return_value=mock_manager,
),
patch("forge.workflow.nodes.workspace_setup.GitOperations", return_value=mock_git),
):
result = await setup_workspace(state)

assert result["current_node"] == "setup_workspace"
assert result["last_error"] == "fork creation denied"
mock_git.add_fork_remote.assert_not_called()
mock_git.create_branch.assert_not_called()


class TestWorkspaceSetupForkBootstrap:
"""Tests for creating and checkpointing the implementation backup fork."""

@pytest.mark.asyncio
async def test_creates_fork_remote_before_implementation(
self, mock_workspace_github
):
mock_jira = create_mock_jira_client()
mock_manager, _ = create_mock_workspace_manager()
mock_git = create_mock_git_operations()
mock_guardrails_loader = create_mock_guardrails_loader()
state = create_initial_feature_state(
ticket_key="TEST-FORK",
current_repo="upstream/repo",
)

with (
patch("forge.workflow.nodes.workspace_setup.JiraClient", return_value=mock_jira),
patch(
"forge.workflow.nodes.workspace_setup.get_workspace_manager",
return_value=mock_manager,
),
patch("forge.workflow.nodes.workspace_setup.GitOperations", return_value=mock_git),
patch("forge.workflow.nodes.workspace_setup.GuardrailsLoader", mock_guardrails_loader),
):
result = await setup_workspace(state)

mock_workspace_github.get_or_create_fork.assert_awaited_once_with(
"upstream", "repo"
)
mock_workspace_github.sync_fork_with_upstream.assert_awaited_once_with(
"fork-owner", "test-repo", branch="main"
)
mock_git.add_fork_remote.assert_called_once_with("fork-owner", "test-repo")
assert result["fork_owner"] == "fork-owner"
assert result["fork_repo"] == "test-repo"
assert result["current_node"] == "implementation"


class TestPrepareWorkspaceRecovery:
"""Tests for prepare_workspace workspace sync/recreation behavior."""
Expand Down
Loading