open-swe/tests/test_commit_and_open_pr_workflow_scope.py
langsmith-forge[bot] 15a7268d30
fix: return actionable error when push fails with workflows scope error (#1153)
* fix: return actionable error when git push fails due to workflows scope

- Root cause: push failure with "workflows scope" error returned a generic
  "Git push failed: ..." message, causing the agent to retry 10+ times
- Change: detect "workflows" + "scope" in push output and return a clear
  message instructing the agent to remove .github/workflows/ file changes
- Verified: unit tests cover both the workflow-scope path and the
  non-workflow path to prevent regressions

* fix: detect github workflow permission push failures

* style: ruff-format checkout line in commit_and_open_pr

---------

Co-authored-by: LangSmith Forge <forge-agent@langsmith.ai>
Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
2026-05-01 13:09:54 -07:00

119 lines
5 KiB
Python

"""Tests for workflow-scope push error detection in commit_and_open_pr."""
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
from agent.tools.commit_and_open_pr import commit_and_open_pr
def _make_exec_result(exit_code: int, output: str) -> MagicMock:
result = MagicMock()
result.exit_code = exit_code
result.output = output
return result
WORKFLOW_SCOPE_OUTPUT = (
"To https://github.com/langchain-ai/langchainplus.git\n"
" ! [remote rejected] open-swe/abc -> open-swe/abc "
"(Unable to determine if workflow can be created or updated due to timeout; "
"`workflows` scope may be required.)\n"
"error: failed to push some refs to 'https://github.com/langchain-ai/langchainplus.git'\n"
)
WORKFLOW_PERMISSION_OUTPUT = (
"To https://github.com/langchain-ai/open-swe.git\n"
" ! [remote rejected] open-swe/abc -> open-swe/abc "
"(refusing to allow a GitHub App to create or update workflow "
"`.github/workflows/ci.yml` without `workflows` permission)\n"
"error: failed to push some refs to 'https://github.com/langchain-ai/open-swe.git'\n"
)
def _run_with_push_result(push_result: MagicMock) -> dict:
"""Run commit_and_open_pr with all external calls mocked, using the given push_result."""
config = {
"configurable": {
"thread_id": "test-thread-id",
"repo": {"owner": "langchain-ai", "name": "open-swe"},
},
"metadata": {},
}
sandbox = MagicMock()
sandbox.execute.return_value = _make_exec_result(0, "")
patches = [
patch("agent.tools.commit_and_open_pr.get_config", return_value=config),
patch("agent.tools.commit_and_open_pr.get_sandbox_backend_sync", return_value=sandbox),
patch("agent.tools.commit_and_open_pr.git_has_uncommitted_changes", return_value=True),
patch("agent.tools.commit_and_open_pr.git_fetch_origin"),
patch("agent.tools.commit_and_open_pr.git_has_unpushed_commits", return_value=False),
patch(
"agent.tools.commit_and_open_pr.get_github_app_installation_token",
return_value="token",
),
patch("agent.tools.commit_and_open_pr.get_github_token", return_value="gh-token"),
patch("agent.tools.commit_and_open_pr.resolve_triggering_user_identity", return_value=None),
patch(
"agent.tools.commit_and_open_pr.add_pr_collaboration_note",
side_effect=lambda body, _: body,
),
patch(
"agent.tools.commit_and_open_pr.add_user_coauthor_trailer",
side_effect=lambda msg, _: msg,
),
patch(
"agent.tools.commit_and_open_pr.git_current_branch",
return_value="open-swe/test-thread-id",
),
patch("agent.tools.commit_and_open_pr.git_config_user"),
patch("agent.tools.commit_and_open_pr.git_add_all"),
patch(
"agent.tools.commit_and_open_pr.git_commit",
return_value=_make_exec_result(0, ""),
),
patch("agent.tools.commit_and_open_pr.git_push", return_value=push_result),
patch("agent.tools.commit_and_open_pr.asyncio.run", return_value="token"),
]
with ExitStack() as stack:
for p in patches:
stack.enter_context(p)
return commit_and_open_pr(title="fix: test", body="## Description\ntest")
class TestWorkflowScopePushError:
def test_workflow_scope_error_returns_actionable_message(self):
"""Workflow-scope push failure returns a clear message telling the agent to remove
.github/workflows/ files rather than a generic 'Git push failed:' that causes retries."""
result = _run_with_push_result(_make_exec_result(1, WORKFLOW_SCOPE_OUTPUT))
assert result["success"] is False
assert ".github/workflows/" in result["error"]
assert "Remove any .github/workflows/" in result["error"]
assert result["pr_url"] is None
# Must NOT be the raw git output dump that causes blind agent retries
assert "remote rejected" not in result["error"]
def test_workflow_permission_error_returns_actionable_message(self):
result = _run_with_push_result(_make_exec_result(1, WORKFLOW_PERMISSION_OUTPUT))
assert result["success"] is False
assert ".github/workflows/" in result["error"]
assert "Remove any .github/workflows/" in result["error"]
assert result["pr_url"] is None
assert "PERMANENT_FAILURE" not in result["error"]
def test_non_workflow_push_error_returns_generic_message(self):
"""Non-workflow failures that are not permanent auth errors return the generic message."""
generic_output = (
"To https://github.com/langchain-ai/open-swe.git\n"
" ! [rejected] open-swe/abc -> open-swe/abc (non-fast-forward)\n"
"error: failed to push some refs\n"
)
result = _run_with_push_result(_make_exec_result(1, generic_output))
assert result["success"] is False
assert result["error"].startswith("Git push failed:")
assert ".github/workflows/" not in result["error"]