mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-02 15:33:16 +00:00
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>
This commit is contained in:
parent
b25222f1f9
commit
15a7268d30
2 changed files with 149 additions and 2 deletions
|
|
@ -34,6 +34,16 @@ from ..utils.sandbox_state import get_sandbox_backend_sync
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_workflow_scope_push_failure(output: str) -> bool:
|
||||
normalized_output = output.lower()
|
||||
return "workflows" in normalized_output and (
|
||||
"scope" in normalized_output
|
||||
or "permission" in normalized_output
|
||||
or "workflow can be created or updated" in normalized_output
|
||||
or "create or update workflow" in normalized_output
|
||||
)
|
||||
|
||||
|
||||
def commit_and_open_pr(
|
||||
title: str,
|
||||
body: str,
|
||||
|
|
@ -124,7 +134,11 @@ def commit_and_open_pr(
|
|||
thread_id = configurable.get("thread_id")
|
||||
|
||||
if not thread_id:
|
||||
return {"success": False, "error": "Missing thread_id in config", "pr_url": None}
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Missing thread_id in config",
|
||||
"pr_url": None,
|
||||
}
|
||||
|
||||
repo_config = configurable.get("repo", {})
|
||||
repo_owner = repo_config.get("owner")
|
||||
|
|
@ -138,7 +152,11 @@ def commit_and_open_pr(
|
|||
|
||||
sandbox_backend = get_sandbox_backend_sync(thread_id)
|
||||
if not sandbox_backend:
|
||||
return {"success": False, "error": "No sandbox found for thread", "pr_url": None}
|
||||
return {
|
||||
"success": False,
|
||||
"error": "No sandbox found for thread",
|
||||
"pr_url": None,
|
||||
}
|
||||
|
||||
repo_dir = resolve_repo_dir(sandbox_backend, repo_name)
|
||||
github_token = get_github_token()
|
||||
|
|
@ -202,6 +220,16 @@ def commit_and_open_pr(
|
|||
push_result = git_push(sandbox_backend, repo_dir, target_branch)
|
||||
if push_result.exit_code != 0:
|
||||
push_output = push_result.output.strip()
|
||||
if _is_workflow_scope_push_failure(push_output):
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"Git push failed: the branch contains changes to .github/workflows/ files "
|
||||
"that require the 'workflows' GitHub token scope, which is not available. "
|
||||
"Remove any .github/workflows/ file changes from your commit and try again."
|
||||
),
|
||||
"pr_url": None,
|
||||
}
|
||||
if is_permanent_github_push_failure(push_output):
|
||||
return {
|
||||
"success": False,
|
||||
|
|
|
|||
119
tests/test_commit_and_open_pr_workflow_scope.py
Normal file
119
tests/test_commit_and_open_pr_workflow_scope.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""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"]
|
||||
Loading…
Add table
Reference in a new issue