feat: move github workflows to gh cli (#1238)

* feat: move github workflows to gh cli

Use LangSmith proxy auth to support gh-driven GitHub workflows while removing custom GitHub wrapper tools.

* docker ignore + snapshot and docker image updates

* updated image and instructions

* removing open_pr if needed after agent call
This commit is contained in:
Johannes du Plessis 2026-05-04 18:03:53 -07:00 • committed by GitHub
parent 13f5d8a1c9
commit 96774f20ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 224 additions and 4672 deletions

14
.dockerignore Normal file
View file

@ -0,0 +1,14 @@
.git
.env
.env.*
*.pem
*.key
*.crt
credentials.json
node_modules
__pycache__
.pytest_cache
.ruff_cache
.venv
.DS_Store
logs.txt

View file

@ -39,7 +39,7 @@ make format # ruff format + ruff check --fix
3. No sandbox at all → create one, set `__creating__` sentinel, then real id.
4. Metadata has an id but no cache → reconnect; fall back to recreate on failure.
For `SANDBOX_TYPE=langsmith` (default), every sandbox creation/refresh also calls `_configure_github_proxy` with a fresh GitHub App installation token (`get_github_app_installation_token`). Other providers (modal, daytona, runloop, local) skip the proxy step. Provider is selected via `SANDBOX_TYPE` env var; factory is `agent/utils/sandbox.py:create_sandbox`.
For `SANDBOX_TYPE=langsmith` (default), every sandbox creation/refresh also calls `_configure_github_proxy` with a fresh GitHub App installation token (`get_github_app_installation_token`). The proxy injects Basic auth for `github.com` git traffic and Bearer auth for `api.github.com` so sandbox commands can use `GH_TOKEN=dummy gh ...` without storing real tokens in the sandbox. Other providers (modal, daytona, runloop, local) skip the proxy step. Provider is selected via `SANDBOX_TYPE` env var; factory is `agent/utils/sandbox.py:create_sandbox`.
### Middleware stack (order matters)
@ -48,7 +48,9 @@ Configured in `get_agent`, runs around every model call:
1. `ToolErrorMiddleware` — catches tool exceptions.
2. `check_message_queue_before_model` — pulls Linear comments / Slack messages that arrived mid-run from the thread queue and injects them as user messages before the next LLM call. This is what makes "message the agent while it's working" work.
3. `ensure_no_empty_msg` — guards against empty assistant messages that some providers reject.
4. `open_pr_if_needed` — **after-agent safety net**: if the agent finishes without committing, this commits and opens a draft PR. Don't remove or reorder lightly — much of the system relies on a PR existing at end-of-run.
4. `notify_step_limit_reached` — after-agent hook that posts a Slack reply when the agent hits the step limit, so the user gets a clear signal instead of silence.
There is intentionally no after-agent safety net that opens a PR for the agent. The agent itself is responsible for committing, pushing, opening/updating the draft PR, and replying in the source channel — all via `GH_TOKEN=dummy gh` and `slack_thread_reply` / `linear_comment`.
### Tools

View file

@ -39,7 +39,7 @@ make format # ruff format + ruff check --fix
3. No sandbox at all → create one, set `__creating__` sentinel, then real id.
4. Metadata has an id but no cache → reconnect; fall back to recreate on failure.
For `SANDBOX_TYPE=langsmith` (default), every sandbox creation/refresh also calls `_configure_github_proxy` with a fresh GitHub App installation token (`get_github_app_installation_token`). Other providers (modal, daytona, runloop, local) skip the proxy step. Provider is selected via `SANDBOX_TYPE` env var; factory is `agent/utils/sandbox.py:create_sandbox`.
For `SANDBOX_TYPE=langsmith` (default), every sandbox creation/refresh also calls `_configure_github_proxy` with a fresh GitHub App installation token (`get_github_app_installation_token`). The proxy injects Basic auth for `github.com` git traffic and Bearer auth for `api.github.com` so sandbox commands can use `GH_TOKEN=dummy gh ...` without storing real tokens in the sandbox. Other providers (modal, daytona, runloop, local) skip the proxy step. Provider is selected via `SANDBOX_TYPE` env var; factory is `agent/utils/sandbox.py:create_sandbox`.
### Middleware stack (order matters)
@ -48,7 +48,9 @@ Configured in `get_agent`, runs around every model call:
1. `ToolErrorMiddleware` — catches tool exceptions.
2. `check_message_queue_before_model` — pulls Linear comments / Slack messages that arrived mid-run from the thread queue and injects them as user messages before the next LLM call. This is what makes "message the agent while it's working" work.
3. `ensure_no_empty_msg` — guards against empty assistant messages that some providers reject.
4. `open_pr_if_needed` — **after-agent safety net**: if the agent finishes without committing, this commits and opens a draft PR. Don't remove or reorder lightly — much of the system relies on a PR existing at end-of-run.
4. `notify_step_limit_reached` — after-agent hook that posts a Slack reply when the agent hits the step limit, so the user gets a clear signal instead of silence.
There is intentionally no after-agent safety net that opens a PR for the agent. The agent itself is responsible for committing, pushing, opening/updating the draft PR, and replying in the source channel — all via `GH_TOKEN=dummy gh` and `slack_thread_reply` / `linear_comment`.
### Tools

View file

@ -12,13 +12,13 @@ if model_id == DEFAULT_LLM_MODEL_ID:
return create_deep_agent(
model=make_model(model_id, **model_kwargs),
system_prompt=construct_system_prompt(...),
tools=[http_request, fetch_url, list_repos, get_branch_name, commit_and_open_pr, linear_comment, slack_thread_reply],
tools=[http_request, fetch_url, linear_comment, slack_thread_reply],
backend=sandbox_backend,
middleware=[
ToolErrorMiddleware(),
check_message_queue_before_model,
ensure_no_empty_msg,
open_pr_if_needed,
notify_step_limit_reached,
],
)
```
@ -40,7 +40,14 @@ DEFAULT_SANDBOX_VCPUS="4" # Optional, d
DEFAULT_SANDBOX_MEM_BYTES="16106127360" # Optional, default 15 GiB
```
This is useful for pre-installing languages, frameworks, or internal tools that your repos depend on — reducing setup time per agent run.
This is useful for pre-installing languages, frameworks, or internal tools that your repos depend on — reducing setup time per agent run. The default snapshot includes the GitHub CLI; agents invoke it as `GH_TOKEN=dummy gh <command>` and rely on the LangSmith proxy for the real credentials.
For LangSmith sandboxes, Open SWE configures two GitHub proxy rules whenever a sandbox is created or reattached to a run:
- `github.com` / `*.github.com` receive Basic auth for git-over-HTTPS operations.
- `api.github.com` receives Bearer auth for `gh` and REST API operations.
The proxy token is minted at runtime from the GitHub App installation credentials. Do not store GitHub access tokens as deployment environment variables.
### Using a different sandbox provider
@ -185,11 +192,10 @@ async def get_agent(config: RunnableConfig) -> Pregel:
## 3. Tools
Open SWE ships with five custom tools on top of the built-in Deep Agents tools (file operations, shell execution, subagents, todos):
Open SWE ships with a small set of custom tools on top of the built-in Deep Agents tools (file operations, shell execution, subagents, todos). GitHub operations are handled by `GH_TOKEN=dummy gh` inside the sandbox.
| Tool | File | Purpose |
|---|---|---|
| `commit_and_open_pr` | `agent/tools/commit_and_open_pr.py` | Git commit + GitHub draft PR |
| `fetch_url` | `agent/tools/fetch_url.py` | Fetch web pages as markdown |
| `http_request` | `agent/tools/http_request.py` | HTTP API calls |
| `linear_comment` | `agent/tools/linear_comment.py` | Post comments on Linear tickets |
@ -223,13 +229,13 @@ def datadog_search(query: str, time_range: str = "1h") -> dict[str, Any]:
Then register it in `agent/server.py`:
```python
from .tools import commit_and_open_pr, fetch_url, http_request, linear_comment, slack_thread_reply
from .tools import fetch_url, http_request, linear_comment, slack_thread_reply
from .tools.datadog_search import datadog_search
return create_deep_agent(
...
tools=[
http_request, fetch_url, commit_and_open_pr,
http_request, fetch_url,
linear_comment, slack_thread_reply,
datadog_search, # new tool
],
@ -241,14 +247,14 @@ The agent will automatically see the tool's name, docstring, and parameter types
### Removing tools
If you only use Linear (not Slack), remove `slack_thread_reply` from the tools list and vice versa. If you don't need web fetching, remove `fetch_url`. The only tool that's essential to the core workflow is `commit_and_open_pr`.
If you only use Linear (not Slack), remove `slack_thread_reply` from the tools list and vice versa. If you don't need web fetching, remove `fetch_url`.
### Conditional tools
You can vary the toolset based on the trigger source:
```python
base_tools = [http_request, fetch_url, commit_and_open_pr]
base_tools = [http_request, fetch_url]
source = config["configurable"].get("source")
if source == "linear":
@ -441,14 +447,16 @@ Drop an `AGENTS.md` file in the root of any repository to add repo-specific inst
## 6. Middleware
Middleware hooks run around the agent loop. Open SWE includes four:
Middleware hooks run around the agent loop. Open SWE includes:
| Middleware | Type | Purpose |
|---|---|---|
| `ToolErrorMiddleware` | Tool error handler | Catches and formats tool errors |
| `check_message_queue_before_model` | Before model | Injects follow-up messages that arrived mid-run |
| `ensure_no_empty_msg` | Before model | Prevents empty messages from reaching the model |
| `open_pr_if_needed` | After agent | Safety net — opens a PR if the agent didn't |
| `notify_step_limit_reached` | After agent | Posts a Slack reply when the agent hits the model-call limit |
There is intentionally no after-agent middleware that opens a PR for the agent. The agent is responsible for committing, pushing, opening/updating the draft PR, and replying in the source channel. If you want a deterministic backstop for your fork, add an `@after_agent` hook here.
Add custom middleware by appending to the middleware list in `get_agent()`. See the [LangChain middleware docs](https://python.langchain.com/docs/concepts/agents/#middleware) for the `@before_model` and `@after_agent` decorators.
@ -472,7 +480,7 @@ middleware=[
ToolErrorMiddleware(),
check_message_queue_before_model,
ensure_no_empty_msg,
open_pr_if_needed,
notify_step_limit_reached,
run_ci_check, # new middleware
],
```

View file

@ -4,6 +4,7 @@ ARG DOCKER_CLI_VERSION=5:29.1.5-1~debian.13~trixie
ARG NODEJS_VERSION=22.22.0-1nodesource1
ARG UV_VERSION=0.9.26
ARG YARN_VERSION=4.12.0
ARG GH_VERSION=2.83.1
ENV DEBIAN_FRONTEND=noninteractive
@ -30,6 +31,18 @@ RUN install -m 0755 -d /etc/apt/keyrings \
&& apt-get install -y "docker-ce-cli=${DOCKER_CLI_VERSION}" \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "${arch}" in \
amd64) gh_arch="amd64" ;; \
arm64) gh_arch="arm64" ;; \
*) echo "unsupported architecture: ${arch}" >&2; exit 1 ;; \
esac; \
curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${gh_arch}.deb" -o /tmp/gh.deb; \
apt-get update; \
apt-get install -y /tmp/gh.deb; \
rm -rf /tmp/gh.deb /var/lib/apt/lists/*
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "${arch}" in \
@ -70,4 +83,5 @@ RUN echo "=== Installed versions ===" \
&& yarn --version \
&& go version \
&& docker --version \
&& git --version
&& git --version \
&& gh --version

View file

@ -135,7 +135,26 @@ To set up per-user OAuth:
LangSmith sandboxes provide the isolated execution environment for each agent run. Open SWE boots each sandbox from a pre-built **snapshot** — you build the snapshot once (from a Docker image) and then reference it by UUID.
Build a snapshot in the LangSmith UI (Sandboxes → Snapshots → New), or via the SDK:
(Optional) Build and Push a custom Docker Image to Docker hub
First build and push the sandbox Docker image to a registry LangSmith can pull from. On Apple Silicon, force `linux/amd64`
```bash
docker buildx build \
--platform linux/amd64 \
-t <your-docker-hub>/<name-of-your-image> \
--push .
```
For a multi-arch tag that also runs locally on Apple Silicon:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t <your-docker-hub>/<name-of-your-image> \
--push .
```
Then build a snapshot in the LangSmith UI (Sandboxes → Snapshots → New), or via the SDK:
```python
from langsmith.sandbox import SandboxClient
@ -143,12 +162,20 @@ from langsmith.sandbox import SandboxClient
client = SandboxClient(api_key="<your key>")
snapshot = client.create_snapshot(
name="open-swe",
docker_image="bracelangchain/deepagents-sandbox:v1", # built from ./Dockerfile
docker_image="johanneslangchain/open-swe-sandbox:gh-cli-amd64", # built from ./Dockerfile
fs_capacity_bytes=32 * 1024**3,
)
print(snapshot.id)
```
You can also use the helper script:
```bash
uv run python scripts/create_sandbox_snapshot.py \
--name open-swe-gh-cli-amd64 \
--image johanneslangchain/open-swe-sandbox:gh-cli-amd64
```
Then set the resulting UUID in your environment:
```bash
@ -161,7 +188,7 @@ DEFAULT_SANDBOX_VCPUS="4"
DEFAULT_SANDBOX_MEM_BYTES="16106127360"
```
`DEFAULT_SANDBOX_SNAPSHOT_ID` is required when `SANDBOX_TYPE=langsmith`. The server validates this at startup and refuses to boot if it's missing.
`DEFAULT_SANDBOX_SNAPSHOT_ID` is required when `SANDBOX_TYPE=langsmith`. The server validates this at startup and refuses to boot if it's missing. The snapshot should include the GitHub CLI from the project Dockerfile; Open SWE authenticates `git` and `gh` through the LangSmith sandbox proxy using runtime-minted GitHub App installation tokens, not deployment-stored GitHub access tokens.
## 5. Set up triggers

View file

@ -43,7 +43,7 @@ Rather than forking an existing agent or building from scratch, Open SWE **compo
create_deep_agent(
model="openai:gpt-5.5",
system_prompt=construct_system_prompt(...),
tools=[http_request, fetch_url, list_repos, get_branch_name, commit_and_open_pr, linear_comment, slack_thread_reply],
tools=[http_request, fetch_url, linear_comment, slack_thread_reply],
backend=sandbox_backend,
middleware=[ToolErrorMiddleware(), check_message_queue_before_model, ...],
)
@ -70,11 +70,10 @@ Stripe's key insight: *tool curation matters more than tool quantity.* Open SWE
| `execute` | Shell commands in the sandbox |
| `fetch_url` | Fetch web pages as markdown |
| `http_request` | API calls (GET, POST, etc.) |
| `commit_and_open_pr` | Git commit + open a GitHub draft PR |
| `linear_comment` | Post updates to Linear tickets |
| `slack_thread_reply` | Reply in Slack threads |
Plus the built-in Deep Agents tools: `read_file`, `write_file`, `edit_file`, `ls`, `glob`, `grep`, `write_todos`, and `task` (subagent spawning).
GitHub operations are performed with `GH_TOKEN=dummy gh` inside the sandbox, backed by the LangSmith proxy. Plus the built-in Deep Agents tools: `read_file`, `write_file`, `edit_file`, `ls`, `glob`, `grep`, `write_todos`, and `task` (subagent spawning).
### 4. Context Engineering — AGENTS.md + Source Context
@ -92,7 +91,7 @@ Open SWE's orchestration has two layers:
**Middleware:** Deterministic middleware hooks run around the agent loop:
- **`check_message_queue_before_model`** — Injects follow-up messages (Linear comments or Slack messages that arrive mid-run) before the next model call. You can message the agent while it's working and it'll pick up your input at its next step.
- **`open_pr_if_needed`** — After-agent safety net that commits and opens a PR if the agent didn't do it itself. This is a lightweight version of Stripe's deterministic nodes — ensuring critical steps happen regardless of LLM behavior.
- **`notify_step_limit_reached`** — After-agent hook that posts a Slack reply when the agent hits the model-call limit, so users get a clear signal instead of silence.
- **`ToolErrorMiddleware`** — Catches and handles tool errors gracefully.
### 6. Invocation — Slack, Linear, and GitHub
@ -105,10 +104,9 @@ All three companies in the article converge on **Slack as the primary invocation
Each invocation creates a deterministic thread ID, so follow-up messages on the same issue or thread route to the same running agent.
### 7. Validation — Prompt-Driven + Safety Nets
The agent is instructed to run linters, formatters, and tests before committing. The `open_pr_if_needed` middleware acts as a backstop — if the agent finishes without opening a PR, the middleware handles it automatically.
### 7. Validation — Prompt-Driven
The agent is instructed to run linters, formatters, and tests before committing, and is responsible end-to-end for committing, pushing, opening/updating the draft PR, and replying in the source channel.
This is an area where you can extend Open SWE for your org: add deterministic CI checks, visual verification, or review gates as additional middleware. See the [Customization Guide](CUSTOMIZATION.md#6-middleware) for how.
---
@ -123,7 +121,7 @@ This is an area where you can extend Open SWE for your org: add deterministic CI
| **Context** | AGENTS.md + issue/thread | Rule files + pre-hydration | OpenCode built-in | Linear-first + MCPs |
| **Orchestration** | Subagents + middleware | Blueprints (deterministic + agentic) | Sessions + child sessions | Three modes |
| **Invocation** | Slack, Linear, GitHub | Slack + embedded buttons | Slack + web + Chrome extension | Slack-native |
| **Validation** | Prompt-driven + PR safety net | 3-layer (local + CI + 1 retry) | Visual DOM verification | Agent councils + auto-merge |
| **Validation** | Prompt-driven | 3-layer (local + CI + 1 retry) | Visual DOM verification | Agent councils + auto-merge |
---

View file

@ -41,8 +41,36 @@ def _get_sandbox_snapshot_config() -> tuple[str | None, int, int, int]:
return snapshot_id, fs_capacity_bytes, vcpus, mem_bytes
def _github_proxy_rules(github_token: str) -> list[dict[str, Any]]:
basic_auth = base64.b64encode(f"x-access-token:{github_token}".encode()).decode()
return [
{
"name": "github-api",
"match_hosts": ["api.github.com"],
"headers": [
{
"name": "Authorization",
"type": "opaque",
"value": f"Bearer {github_token}",
}
],
},
{
"name": "github",
"match_hosts": ["github.com", "*.github.com"],
"headers": [
{
"name": "Authorization",
"type": "opaque",
"value": f"Basic {basic_auth}",
}
],
},
]
def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
"""Configure sandbox proxy to inject GitHub auth for all github.com requests.
"""Configure sandbox proxy to inject GitHub auth for GitHub traffic.
Uses the LangSmith proxy-config API to set up header injection so that
git operations (clone, pull, push) authenticate via the proxy rather than
@ -58,24 +86,7 @@ def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
return
langsmith_endpoint = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}"
basic_auth = base64.b64encode(f"x-access-token:{github_token}".encode()).decode()
payload = {
"proxy_config": {
"rules": [
{
"name": "github",
"match_hosts": ["github.com", "*.github.com"],
"headers": [
{
"name": "Authorization",
"type": "opaque",
"value": f"Basic {basic_auth}",
}
],
}
]
}
}
payload = {"proxy_config": {"rules": _github_proxy_rules(github_token)}}
with httpx.Client() as client:
response = client.patch(
url,

View file

@ -1,7 +1,6 @@
from .check_message_queue import check_message_queue_before_model
from .ensure_no_empty_msg import ensure_no_empty_msg
from .notify_step_limit import notify_step_limit_reached
from .open_pr import open_pr_if_needed
from .sanitize_tool_inputs import SanitizeToolInputsMiddleware
from .tool_error_handler import ToolErrorMiddleware
@ -11,5 +10,4 @@ __all__ = [
"check_message_queue_before_model",
"ensure_no_empty_msg",
"notify_step_limit_reached",
"open_pr_if_needed",
]

View file

@ -16,19 +16,11 @@ def get_every_message_since_last_human(state: AgentState) -> list[AnyMessage]:
return messages[last_human_idx + 1 :]
def check_if_model_already_called_commit_and_open_pr(messages: list[AnyMessage]) -> bool:
for msg in messages:
if msg.type == "tool" and msg.name == "commit_and_open_pr":
return True
return False
def check_if_model_messaged_user(messages: list[AnyMessage]) -> bool:
for msg in messages:
if msg.type == "tool" and msg.name in [
"slack_thread_reply",
"linear_comment",
"github_comment",
]:
return True
return False
@ -58,9 +50,7 @@ def ensure_no_empty_msg(state: AgentState, runtime: Runtime) -> dict[str, Any] |
if check_if_no_op(messages_since_last_human):
return None
if check_if_model_already_called_commit_and_open_pr(
messages_since_last_human
) and check_if_model_messaged_user(messages_since_last_human):
if check_if_model_messaged_user(messages_since_last_human):
return None
tc_id = str(uuid4())
@ -75,16 +65,11 @@ def ensure_no_empty_msg(state: AgentState, runtime: Runtime) -> dict[str, Any] |
return {"messages": [last_msg, no_op_tool_msg]}
if has_contents and not has_tool_calls:
# See if the model already called open_pr or it sent a slack/linear message
# First, get every message since the last human message
messages_since_last_human = get_every_message_since_last_human(state)
# If it opened a PR, we don't need to do anything
if (
check_if_model_already_called_commit_and_open_pr(messages_since_last_human)
or check_if_model_messaged_user(messages_since_last_human)
or check_if_confirming_completion(messages_since_last_human)
):
if check_if_model_messaged_user(
messages_since_last_human
) or check_if_confirming_completion(messages_since_last_human):
return None
tc_id = str(uuid4())

View file

@ -1,192 +0,0 @@
"""After-agent middleware that creates a GitHub PR if needed.
Runs once after the agent finishes as a safety net. If the agent called
``commit_and_open_pr`` and it already succeeded, this is a no-op. Otherwise it
commits any remaining changes, pushes to a feature branch, and opens a GitHub PR.
"""
from __future__ import annotations
import asyncio
import json as _json
import logging
from typing import Any
from langchain.agents.middleware import AgentState, after_agent
from langgraph.config import get_config
from langgraph.runtime import Runtime
from ..utils.authorship import (
OPEN_SWE_BOT_EMAIL,
OPEN_SWE_BOT_NAME,
add_pr_collaboration_note,
add_user_coauthor_trailer,
resolve_triggering_user_identity,
)
from ..utils.github import (
create_github_pr,
get_github_default_branch,
git_add_all,
git_checkout_branch,
git_checkout_existing_branch,
git_commit,
git_config_user,
git_current_branch,
git_fetch_origin,
git_has_uncommitted_changes,
git_has_unpushed_commits,
git_push,
is_permanent_github_push_failure,
)
from ..utils.github_app import get_github_app_installation_token
from ..utils.github_token import get_github_token
from ..utils.sandbox_paths import aresolve_repo_dir
from ..utils.sandbox_state import get_sandbox_backend
logger = logging.getLogger(__name__)
def _extract_pr_params_from_messages(messages: list) -> dict[str, Any] | None:
"""Extract commit_and_open_pr tool result payload."""
for msg in reversed(messages):
if isinstance(msg, dict):
content = msg.get("content", "")
name = msg.get("name", "")
else:
content = getattr(msg, "content", "")
name = getattr(msg, "name", "")
if name == "commit_and_open_pr" and content:
try:
parsed = _json.loads(content) if isinstance(content, str) else content
if isinstance(parsed, dict):
return parsed
except (ValueError, TypeError):
pass
return None
@after_agent
async def open_pr_if_needed(
state: AgentState,
runtime: Runtime,
) -> dict[str, Any] | None:
"""Middleware that commits/pushes changes after agent runs if `commit_and_open_pr` tool didn't."""
logger.info("After-agent middleware started")
try:
config = get_config()
configurable = config.get("configurable", {})
thread_id = configurable.get("thread_id")
logger.debug("Middleware running for thread %s", thread_id)
messages = state.get("messages", [])
pr_payload = _extract_pr_params_from_messages(messages)
if not pr_payload:
logger.info("No commit_and_open_pr tool call found, skipping PR creation")
return None
if pr_payload.get("success"):
return None
error = pr_payload.get("error")
if pr_payload.get("fatal") is True or (isinstance(error, str) and "Do not retry" in error):
logger.info("Skipping PR safety net after fatal commit_and_open_pr failure")
return None
if isinstance(error, str) and is_permanent_github_push_failure(error):
logger.info("Skipping PR safety net after permanent push failure")
return None
pr_title = pr_payload.get("title", "feat: Open SWE PR")
pr_body = pr_payload.get("body", "Automated PR created by Open SWE agent.")
commit_message = pr_payload.get("commit_message", pr_title)
github_token = get_github_token(config)
user_identity = await asyncio.to_thread(
resolve_triggering_user_identity, config, github_token
)
pr_body = add_pr_collaboration_note(pr_body, user_identity)
commit_message = add_user_coauthor_trailer(commit_message, user_identity)
if not thread_id:
raise ValueError("No thread_id found in config")
repo_config = configurable.get("repo", {})
repo_owner = repo_config.get("owner")
repo_name = repo_config.get("name")
sandbox_backend = await get_sandbox_backend(thread_id)
if not sandbox_backend or not repo_name:
return None
repo_dir = await aresolve_repo_dir(sandbox_backend, repo_name)
has_uncommitted_changes = await asyncio.to_thread(
git_has_uncommitted_changes, sandbox_backend, repo_dir
)
await asyncio.to_thread(git_fetch_origin, sandbox_backend, repo_dir)
has_unpushed_commits = await asyncio.to_thread(
git_has_unpushed_commits, sandbox_backend, repo_dir
)
has_changes = has_uncommitted_changes or has_unpushed_commits
if not has_changes:
logger.info("No changes detected, skipping PR creation")
return None
installation_token = await get_github_app_installation_token()
if not installation_token:
logger.error("Failed to get GitHub App installation token for thread %s", thread_id)
return None
logger.info("Changes detected, preparing PR for thread %s", thread_id)
metadata = config.get("metadata", {})
branch_name = metadata.get("branch_name")
current_branch = await asyncio.to_thread(git_current_branch, sandbox_backend, repo_dir)
target_branch = branch_name if branch_name else f"open-swe/{thread_id}"
if current_branch != target_branch:
if branch_name:
# Existing branch — plain checkout, do not create or reset
await asyncio.to_thread(
git_checkout_existing_branch, sandbox_backend, repo_dir, target_branch
)
else:
await asyncio.to_thread(
git_checkout_branch, sandbox_backend, repo_dir, target_branch
)
await asyncio.to_thread(
git_config_user,
sandbox_backend,
repo_dir,
OPEN_SWE_BOT_NAME,
OPEN_SWE_BOT_EMAIL,
)
await asyncio.to_thread(git_add_all, sandbox_backend, repo_dir)
await asyncio.to_thread(git_commit, sandbox_backend, repo_dir, commit_message)
await asyncio.to_thread(git_push, sandbox_backend, repo_dir, target_branch)
base_branch = await get_github_default_branch(repo_owner, repo_name, installation_token)
logger.info("Using base branch: %s", base_branch)
await create_github_pr(
repo_owner=repo_owner,
repo_name=repo_name,
github_token=github_token or installation_token,
title=pr_title,
head_branch=target_branch,
base_branch=base_branch,
body=pr_body,
installation_token=installation_token,
)
logger.info("After-agent middleware completed successfully")
except Exception:
logger.exception("Error in after-agent middleware")
return None

View file

@ -44,6 +44,8 @@ All code execution and file operations happen in this sandbox environment.
**Important:**
- Use `{working_dir}` as your working directory for all operations
- The `gh` CLI is installed and authenticated by a sandbox proxy. Always invoke it as `GH_TOKEN=dummy gh <command>` so the CLI passes its local auth check while the proxy injects the real runtime token.
- Direct GitHub API calls from the sandbox are also authenticated by the proxy; do not ask the user for a GitHub token.
- The `execute` tool enforces a 5-minute timeout by default (300 seconds)
- If a command times out and needs longer, rerun it by explicitly passing `timeout=<seconds>` to the `execute` tool (e.g. `timeout=600` for 10 minutes)
@ -67,13 +69,13 @@ REPO_SETUP_SECTION = """---
### Repository Setup
Before starting any task, you must set up the repository in your sandbox. Follow these steps in order:
Before starting any task that requires code changes, set up the repository in your sandbox. Follow these steps in order:
1. **Find the repo** — Call `list_repos(organization_name="<org>")` to list repositories for a GitHub organization, or `list_repos(organization_name="<username>", is_organization=False)` for a personal user account. Match the repo to your task context (e.g. the Linear team/project or issue description). If you are unsure which repo to use, ask the user for confirmation before proceeding.
1. **Identify the repo** — Use task context to determine the repository. If you need to inspect GitHub, use `GH_TOKEN=dummy gh repo list`, `GH_TOKEN=dummy gh search repos`, or `GH_TOKEN=dummy gh search code`.
2. **Clone the repo** — Clone it into `{working_dir}`.
2. **Clone the repo** — Run `cd {working_dir} && GH_TOKEN=dummy gh repo clone <owner>/<repo>`.
3. **Get your branch** — Always call the `get_branch_name` tool to get the branch name for this thread.
3. **Choose your branch** — Use a thread-stable branch name such as `open-swe/<short-task-slug>`. If a branch already exists for this thread/task, fetch and check it out instead of creating a new one.
4. **Checkout your branch** — Always fetch and checkout your branch before making any changes.
@ -101,7 +103,7 @@ TASK_EXECUTION_SECTION = """---
If you make changes, communicate updates in the source channel:
- Use `linear_comment` for Linear-triggered tasks.
- Use `slack_thread_reply` for Slack-triggered tasks.
- Use `github_comment` for GitHub-triggered tasks.
- For GitHub-triggered tasks, use `GH_TOKEN=dummy gh issue comment` or `GH_TOKEN=dummy gh pr comment` only after confirming the target issue or pull request.
- If the task was not triggered from a known source (no Slack thread, no Linear ticket, no GitHub issue), skip the notification step.
For tasks that require code changes, follow this order:
@ -109,27 +111,21 @@ For tasks that require code changes, follow this order:
1. **Understand** — Read the issue/task carefully. Explore relevant files before making any changes.
2. **Implement** — Make focused, minimal changes. Do not modify code outside the scope of the task. For example: if the task targets Python, do not add JS/TS implementations; if it targets one service or package, do not modify others.
3. **Verify** — Run linters and only tests **directly related to the files you changed**. Do NOT run the full test suite — CI handles that. If no related tests exist, skip this step.
4. **Submit** — Call `commit_and_open_pr` to push changes to the existing PR branch.
5. **Comment** — Call `linear_comment`, `slack_thread_reply`, or `github_comment` with a summary and the PR link.
4. **Submit** — Commit, push, and open or update a draft pull request with `GH_TOKEN=dummy gh`.
5. **Comment** — Call `linear_comment` or `slack_thread_reply` for Linear/Slack. For GitHub-triggered tasks, comment with `GH_TOKEN=dummy gh`.
**Strict requirement:** You must call `commit_and_open_pr` before posting any completion message for a code change task. Only claim "PR updated/opened" if `commit_and_open_pr` returns `success` and a PR link. If it returns "No changes detected" or any error, you must state that explicitly and do not claim an update.
**Strict requirement:** Never claim "PR updated/opened" unless `gh` returned success and you have the PR URL from command output or `GH_TOKEN=dummy gh pr view --json url --jq .url`. If push or PR creation fails, state that explicitly.
For questions or status checks (no code changes needed):
1. **Answer** — Gather the information needed to respond.
2. **Comment** — Call `linear_comment`, `slack_thread_reply`, or `github_comment` with your answer. Never leave a question unanswered."""
2. **Comment** — Call `linear_comment` or `slack_thread_reply` for Linear/Slack. For GitHub-triggered tasks, use `GH_TOKEN=dummy gh issue comment` or `GH_TOKEN=dummy gh pr comment`. Never leave a question unanswered."""
TOOL_USAGE_SECTION = """---
### Tool Usage
#### `list_repos`
Lists GitHub repositories for a given organization or user via the GitHub API. Pass `organization_name` to specify which org or user to query. Set `is_organization=False` for personal user accounts (defaults to True). Call this first to find the right repo for your task.
#### `get_branch_name`
Returns the git branch name for this thread. Always call this tool to get the correct branch before making any changes.
#### `execute`
Run shell commands in the sandbox. Pass `timeout=<seconds>` for long-running commands (default: 300s).
@ -138,16 +134,10 @@ Fetches a URL and converts HTML to markdown. Use for web pages. Synthesize the c
#### `http_request`
Make HTTP requests (GET, POST, PUT, DELETE, etc.) to APIs. Use this for API calls with custom headers, methods, params, or request bodies — not for fetching web pages.
Do not use this tool to create or update the pull request for completed code changes. Use `commit_and_open_pr` for that workflow so commits are pushed and GitHub authentication is handled correctly. For other PR-related actions, use the dedicated GitHub PR tools when available.
#### `commit_and_open_pr`
Commits all changes, pushes to a branch, and opens a **draft** GitHub PR. If a PR already exists for the branch, it is reused and its existing title and description are preserved. Use this for submitting code changes, not for editing existing PR metadata.
#### `edit_pull_request`
Edits the title and/or body of an existing GitHub Pull Request. Use this only when you deliberately need to change PR metadata after creation: pass `title` only to retitle, `body` only to rewrite the description, or both when both should change. Do not call this as part of the normal submit flow unless the current PR title or description is stale or misleading. Requires `pr_number` and at least one of `title` or `body`.
Do not use this tool for GitHub API calls. Use `GH_TOKEN=dummy gh` in the sandbox for GitHub operations.
#### `linear_comment`
Posts a comment to a Linear ticket given a `ticket_id`. Call this **after** `commit_and_open_pr` to notify stakeholders that the work is done and include the PR link. You can tag Linear users with `@username` (their Linear display name). Example: "I've completed the implementation and opened a PR: <pr_url>. Hey @username, let me know if you have any feedback!".
Posts a comment to a Linear ticket given a `ticket_id`. Call this after opening/updating the pull request to notify stakeholders and include the PR link. You can tag Linear users with `@username` (their Linear display name).
#### `slack_thread_reply`
Posts a message to the active Slack thread. Use this for clarifying questions, status updates, and final summaries when the task was triggered from Slack.
@ -157,18 +147,15 @@ Format messages using Slack's mrkdwn format, NOT standard Markdown.
Do NOT use **bold**, [link](url), or other standard Markdown syntax.
To mention/tag a user, use `<@USER_ID>` (e.g. `<@U06KD8BFY95>`). You can find user IDs in the conversation context next to display names (e.g. `@Name(U06KD8BFY95)`).
#### `github_comment`
Posts a comment to a GitHub issue or pull request. Provide the `issue_number` explicitly. Use this when the task was triggered from GitHub — to reply with updates, answers, or a summary after completing work.
#### `get_pr_review_comments`
Fetches all review comments on a GitHub pull request (thread comments, inline review comments, and review submissions), sorted chronologically. Requires `pr_number`. Optionally accepts `repo_owner` and `repo_name` if different from the configured repo. Use this whenever you need to read PR feedback — do NOT ask users to paste comments."""
#### GitHub via `gh`
Use `GH_TOKEN=dummy gh <command>` for GitHub operations: repository discovery, cloning, issues, pull requests, reviews, comments, labels, check status, and workflow operations. For local working-tree state, use `git` directly. Never pass a real GitHub token to `gh`."""
TOOL_BEST_PRACTICES_SECTION = """---
### Tool Usage Best Practices
- **Search:** Use `execute` to run search commands (`grep`, `find`, etc.) in the sandbox.
- **Search:** Use `execute` to run search commands (`rg`, `git grep`, etc.) in the sandbox.
- **Dependencies:** Use the correct package manager; skip if installation fails.
- **History:** Use `git log` and `git blame` via `execute` for additional context when needed.
- **Parallel Tool Calling:** Call multiple tools at once when they don't depend on each other.
@ -207,7 +194,7 @@ CORE_BEHAVIOR_SECTION = """---
- **Persistence:** Keep working until the current task is completely resolved. Only terminate when you are certain the task is complete.
- **Accuracy:** Never guess or make up information. Always use tools to gather accurate data about files and codebase structure.
- **Autonomy:** Never ask the user for permission mid-task. Run linters, fix errors, and call `commit_and_open_pr` without waiting for confirmation."""
- **Autonomy:** Never ask the user for permission mid-task. Run linters, fix errors, push commits, and open/update the draft PR without waiting for confirmation."""
DEPENDENCY_SECTION = """---
@ -283,8 +270,8 @@ When you have completed your implementation, follow these steps in order:
2. **Review your changes**: Review the diff to ensure correctness. Verify no regressions or unintended modifications.
3. **Submit via `commit_and_open_pr` tool**: Call this tool as the final step.
If a PR already exists for the branch, `commit_and_open_pr` preserves the existing PR title and description. Do not use repeated `commit_and_open_pr` calls to edit existing PR metadata; use `edit_pull_request` only when the current title or description is stale or misleading for the overall PR contents.
3. **Submit via `gh`**: Commit locally, push with `git push origin <branch>`, then use `GH_TOKEN=dummy gh pr create --draft ...` or `GH_TOKEN=dummy gh pr edit ...`.
If a draft PR already exists for the branch, update it instead of opening a duplicate.
**PR Title** (under 70 characters):
```
@ -307,20 +294,20 @@ When you have completed your implementation, follow these steps in order:
**Commit message**: Concise, focusing on the "why" rather than the "what". If not provided, the PR title is used.
**IMPORTANT: Never ask the user for permission or confirmation before calling `commit_and_open_pr`. Do not say "if you want, I can proceed" or "shall I open the PR?". When your implementation is done and checks pass, call the tool immediately and autonomously.**
**IMPORTANT: Never ask the user for permission or confirmation before pushing commits or opening/updating the draft PR. Do not say "if you want, I can proceed" or "shall I open the PR?". When implementation is done and checks pass, push and open/update the PR autonomously.**
**IMPORTANT: Even if you made commits directly via `git commit` or `git revert` in the sandbox, you MUST still call `commit_and_open_pr` to push those commits to GitHub. Never report the work as done without pushing.**
**IMPORTANT: If you made commits directly via `git commit` or `git revert` in the sandbox, you MUST push those commits to GitHub. Never report the work as done without pushing.**
**IMPORTANT: Never claim a PR was created or updated unless `commit_and_open_pr` returned `success` and a PR link. If it returns "No changes detected" or any error, report that instead.**
**IMPORTANT: Never claim a PR was created or updated unless `gh` returned success and you have the PR URL from command output or `GH_TOKEN=dummy gh pr view --json url --jq .url`. If there are no changes or any command fails, report that explicitly.**
**IMPORTANT: If `commit_and_open_pr` returns `"fatal": true` or an error message containing "Do not retry", stop immediately — do NOT call `commit_and_open_pr` again. These are infrastructure failures that cannot be fixed by retrying the same tool. Report the failure and end the task.**
**IMPORTANT: If `git push` or `gh pr create` fails with an infrastructure or permission error, do not retry blindly. Report the failure and end the task.**
**IMPORTANT: If `commit_and_open_pr` returns an error containing "403", "Permission denied", or "PERMANENT_FAILURE", this is a permanent authorization failure — the token does not have write access to the repository. Do NOT retry. Report the error to the user immediately and stop.**
**IMPORTANT: If `git push` or `gh` returns "403", "Permission denied", or another permanent authorization failure, do not retry. Report the error to the user immediately and stop.**
4. **Notify the source** immediately after `commit_and_open_pr` succeeds. Include a brief summary and the PR link:
4. **Notify the source** immediately after PR creation/update succeeds. Include a brief summary and the PR link:
- Linear-triggered: use `linear_comment` with an `@mention` of the user who triggered the task
- Slack-triggered: use `slack_thread_reply`
- GitHub-triggered: use `github_comment`
- GitHub-triggered: use `GH_TOKEN=dummy gh issue comment` or `GH_TOKEN=dummy gh pr comment`
- If the task was not triggered from a known source channel (no Slack thread, no Linear ticket, no GitHub issue context), skip the notification step.
Example:
@ -332,7 +319,7 @@ When you have completed your implementation, follow these steps in order:
- <change 2>
```
Always call `commit_and_open_pr` followed by the appropriate reply tool once implementation is complete and code quality checks pass."""
Always push, open/update the draft PR with `gh`, and notify the appropriate source once implementation is complete and code quality checks pass."""
SYSTEM_PROMPT_TEMPLATE = (

View file

@ -33,20 +33,10 @@ from .middleware import (
check_message_queue_before_model,
ensure_no_empty_msg,
notify_step_limit_reached,
open_pr_if_needed,
)
from .prompt import construct_system_prompt
from .tools import (
commit_and_open_pr,
create_pr_review,
dismiss_pr_review,
edit_pull_request,
fetch_url,
get_branch_name,
get_pr_check_runs,
get_pr_review,
get_pr_review_comments,
github_comment,
http_request,
linear_comment,
linear_create_issue,
@ -55,14 +45,8 @@ from .tools import (
linear_get_issue_comments,
linear_list_teams,
linear_update_issue,
list_pr_review_comments,
list_pr_reviews,
list_repos,
rerun_failed_workflow_runs,
slack_read_thread_messages,
slack_thread_reply,
submit_pr_review,
update_pr_review,
web_search,
)
from .utils.auth import resolve_github_token
@ -301,10 +285,6 @@ async def get_agent(config: RunnableConfig) -> Pregel:
http_request,
fetch_url,
web_search,
list_repos,
get_branch_name,
commit_and_open_pr,
edit_pull_request,
linear_comment,
linear_create_issue,
linear_delete_issue,
@ -314,17 +294,6 @@ async def get_agent(config: RunnableConfig) -> Pregel:
linear_update_issue,
slack_read_thread_messages,
slack_thread_reply,
github_comment,
get_pr_review_comments,
list_pr_reviews,
get_pr_review,
create_pr_review,
update_pr_review,
dismiss_pr_review,
submit_pr_review,
list_pr_review_comments,
get_pr_check_runs,
rerun_failed_workflow_runs,
],
backend=sandbox_backend,
middleware=[
@ -333,8 +302,6 @@ async def get_agent(config: RunnableConfig) -> Pregel:
ToolErrorMiddleware(),
check_message_queue_before_model,
ensure_no_empty_msg,
# after_agent hooks run in reverse list order; notify after the PR safety net.
notify_step_limit_reached,
open_pr_if_needed,
],
).with_config(config)

View file

@ -1,19 +1,4 @@
from .commit_and_open_pr import commit_and_open_pr
from .edit_pull_request import edit_pull_request
from .fetch_url import fetch_url
from .get_branch_name import get_branch_name
from .get_pr_review_comments import get_pr_review_comments
from .github_ci import get_pr_check_runs, rerun_failed_workflow_runs
from .github_comment import github_comment
from .github_review import (
create_pr_review,
dismiss_pr_review,
get_pr_review,
list_pr_review_comments,
list_pr_reviews,
submit_pr_review,
update_pr_review,
)
from .http_request import http_request
from .linear_comment import linear_comment
from .linear_create_issue import linear_create_issue
@ -22,22 +7,12 @@ from .linear_get_issue import linear_get_issue
from .linear_get_issue_comments import linear_get_issue_comments
from .linear_list_teams import linear_list_teams
from .linear_update_issue import linear_update_issue
from .list_repos import list_repos
from .slack_read_thread_messages import slack_read_thread_messages
from .slack_thread_reply import slack_thread_reply
from .web_search import web_search
__all__ = [
"commit_and_open_pr",
"create_pr_review",
"dismiss_pr_review",
"edit_pull_request",
"fetch_url",
"get_branch_name",
"get_pr_check_runs",
"get_pr_review",
"get_pr_review_comments",
"github_comment",
"http_request",
"linear_comment",
"linear_create_issue",
@ -46,13 +21,7 @@ __all__ = [
"linear_get_issue_comments",
"linear_list_teams",
"linear_update_issue",
"list_pr_review_comments",
"list_pr_reviews",
"list_repos",
"rerun_failed_workflow_runs",
"slack_read_thread_messages",
"slack_thread_reply",
"submit_pr_review",
"update_pr_review",
"web_search",
]

View file

@ -1,299 +0,0 @@
import asyncio
import logging
from typing import Any
from langgraph.config import get_config
from ..utils.authorship import (
OPEN_SWE_BOT_EMAIL,
OPEN_SWE_BOT_NAME,
add_pr_collaboration_note,
add_user_coauthor_trailer,
resolve_triggering_user_identity,
)
from ..utils.github import (
create_github_pr,
get_github_default_branch,
git_add_all,
git_checkout_branch,
git_checkout_existing_branch,
git_commit,
git_config_user,
git_current_branch,
git_fetch_origin,
git_has_uncommitted_changes,
git_has_unpushed_commits,
git_push,
is_permanent_github_push_failure,
)
from ..utils.github_app import get_github_app_installation_token
from ..utils.github_token import get_github_token
from ..utils.sandbox_paths import resolve_repo_dir
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,
commit_message: str | None = None,
) -> dict[str, Any]:
"""Commit all current changes and open a GitHub Pull Request.
You MUST call this tool when you have completed your work and want to
submit your changes for review. This is the final step in your workflow.
If a PR already exists for the branch, this tool reuses it and preserves
the existing PR title and description. To intentionally edit existing PR
metadata, use `edit_pull_request` after this succeeds.
Before calling this tool, ensure you have:
1. Reviewed your changes for correctness
2. Run `make format` and `make lint` if a Makefile exists in the repo root
## Title Format (REQUIRED — keep under 70 characters)
The PR title MUST follow this exact format:
<type>: <short lowercase description> [closes <PROJECT_ID>-<ISSUE_NUMBER>]
The description MUST be entirely lowercase (no capital letters).
Where <type> is one of:
- fix: for bug fixes
- feat: for new features
- chore: for maintenance tasks (deps, configs, cleanup)
- ci: for CI/CD changes
The [closes ...] suffix links and auto-closes the Linear ticket.
Use the linear_project_id and linear_issue_number from your context.
Examples:
- "fix: resolve null pointer in user auth [closes AA-123]"
- "feat: add dark mode toggle to settings [closes ENG-456]"
- "chore: upgrade dependencies to latest versions [closes OPS-789]"
## Body Format (REQUIRED)
The PR body MUST follow this exact template:
## Description
<1-3 sentences explaining WHY this PR is needed and the approach taken.
DO NOT list files changed or enumerate code
changes — that information is already in the commit history.>
## Release Note
<One-line summary for the changelog, written for self-hosted customers.
Write "none" for changes that don't need a changelog entry
(internal, CI, tests, refactors, etc.)>
## Test Plan
- [ ] <new test case or manual verification step ONLY for new behavior>
IMPORTANT RULES for the body:
- NEVER add a "Changes:" or "Files changed:" section — it's redundant with git commits
- Test Plan must ONLY include new/novel verification steps, NOT "run existing tests"
or "verify existing functionality is unaffected" — those are always implied
If it's a UI change you may say something along the lines of "Test in preview deployment"
- Keep the entire body concise (aim for under 10 lines total)
Example body:
## Description
Fixes the null pointer exception when a user without a profile authenticates.
The root cause was a missing null check in `getProfile`.
Resolves AA-123
## Release Note
Fixed authentication failure for users without profiles.
## Test Plan
- [ ] Verify login works for users without profiles
## Commit Message
The commit message should be concise (1-2 sentences) and focus on the "why"
rather than the "what". Summarize the nature of the changes: new feature,
bug fix, refactoring, etc. If not provided, the PR title is used.
Args:
title: PR title following the format above (e.g. "fix: resolve auth bug [closes AA-123]")
body: PR description following the template above with ## Description,
## Release Note, and ## Test Plan
commit_message: Optional git commit message. If not provided, the PR title is used.
Returns:
Dictionary containing:
- success: Whether the operation completed successfully
- error: Error string if something failed, otherwise None
- pr_url: URL of the created PR if successful, otherwise None
- pr_existing: Whether a PR already existed for this branch
"""
try:
config = get_config()
configurable = config.get("configurable", {})
thread_id = configurable.get("thread_id")
if not thread_id:
return {
"success": False,
"error": "Missing thread_id in config",
"pr_url": None,
}
repo_config = configurable.get("repo", {})
repo_owner = repo_config.get("owner")
repo_name = repo_config.get("name")
if not repo_owner or not repo_name:
return {
"success": False,
"error": "Missing repo owner/name in config",
"pr_url": None,
}
sandbox_backend = get_sandbox_backend_sync(thread_id)
if not sandbox_backend:
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()
user_identity = resolve_triggering_user_identity(config, github_token)
pr_body = add_pr_collaboration_note(body, user_identity)
has_uncommitted_changes = git_has_uncommitted_changes(sandbox_backend, repo_dir)
git_fetch_origin(sandbox_backend, repo_dir)
has_unpushed_commits = git_has_unpushed_commits(sandbox_backend, repo_dir)
if not (has_uncommitted_changes or has_unpushed_commits):
return {"success": False, "error": "No changes detected", "pr_url": None}
metadata = config.get("metadata", {})
branch_name = metadata.get("branch_name")
current_branch = git_current_branch(sandbox_backend, repo_dir)
target_branch = branch_name if branch_name else f"open-swe/{thread_id}"
if current_branch != target_branch:
if branch_name:
# Existing branch — plain checkout, do not create or reset
result = git_checkout_existing_branch(sandbox_backend, repo_dir, target_branch)
if result.exit_code != 0:
return {
"success": False,
"error": f"Failed to checkout branch {target_branch}: {result.output.strip()}. Do not retry this tool — the git environment needs manual inspection.",
"pr_url": None,
"fatal": True,
}
else:
ok, git_err = git_checkout_branch(sandbox_backend, repo_dir, target_branch)
if not ok:
return {
"success": False,
"error": f"Failed to checkout branch {target_branch}: {git_err}. Do not retry this tool — the git environment needs manual inspection.",
"pr_url": None,
"fatal": True,
}
git_config_user(
sandbox_backend,
repo_dir,
OPEN_SWE_BOT_NAME,
OPEN_SWE_BOT_EMAIL,
)
git_add_all(sandbox_backend, repo_dir)
commit_msg = add_user_coauthor_trailer(commit_message or title, user_identity)
if has_uncommitted_changes:
commit_result = git_commit(sandbox_backend, repo_dir, commit_msg)
if commit_result.exit_code != 0:
return {
"success": False,
"error": f"Git commit failed: {commit_result.output.strip()}",
"pr_url": None,
}
installation_token = asyncio.run(get_github_app_installation_token())
if not installation_token:
return {
"success": False,
"error": "Failed to get GitHub App installation token",
"pr_url": None,
}
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,
"error": (
f"PERMANENT_FAILURE: do not retry. Git push was rejected with a 403 "
f"permission denied error — the token does not have write access to this "
f"repository. Report this to the user and stop. Details: {push_output}"
),
"pr_url": None,
}
return {
"success": False,
"error": f"Git push failed: {push_output}",
"pr_url": None,
}
base_branch = asyncio.run(
get_github_default_branch(repo_owner, repo_name, installation_token)
)
pr_url, _pr_number, pr_existing = asyncio.run(
create_github_pr(
repo_owner=repo_owner,
repo_name=repo_name,
github_token=github_token or installation_token,
title=title,
head_branch=target_branch,
base_branch=base_branch,
body=pr_body,
installation_token=installation_token,
)
)
if not pr_url:
return {
"success": False,
"error": "Failed to create GitHub PR. Do not retry this tool — if the push succeeded, the PR may need to be opened manually.",
"pr_url": None,
"pr_existing": False,
"fatal": True,
}
return {
"success": True,
"error": None,
"pr_url": pr_url,
"pr_existing": pr_existing,
}
except Exception as e:
logger.exception("commit_and_open_pr failed")
return {"success": False, "error": f"{type(e).__name__}: {e}", "pr_url": None}

View file

@ -1,88 +0,0 @@
import asyncio
import logging
from typing import Any
from langgraph.config import get_config
from ..utils.github import edit_github_pr
from ..utils.github_app import get_github_app_installation_token
from ..utils.github_token import get_github_token
logger = logging.getLogger(__name__)
def edit_pull_request(
pr_number: int,
title: str | None = None,
body: str | None = None,
) -> dict[str, Any]:
"""Edit the title and/or body of an existing GitHub Pull Request.
Use this tool only when you intentionally need to update a PR's title or
description after it has been created. Normal code submission should use
`commit_and_open_pr`, which preserves existing PR metadata when reusing a PR.
At least one of `title` or `body` must be provided.
Args:
pr_number: The pull request number to edit.
title: New title for the PR. If not provided, the title is left unchanged.
body: New body/description for the PR. If not provided, the body is left unchanged.
Returns:
Dictionary containing:
- success: Whether the operation completed successfully
- error: Error string if something failed, otherwise None
- pr_url: URL of the updated PR if successful, otherwise None
"""
try:
config = get_config()
configurable = config.get("configurable", {})
repo_config = configurable.get("repo", {})
repo_owner = repo_config.get("owner")
repo_name = repo_config.get("name")
if not repo_owner or not repo_name:
return {
"success": False,
"error": "Missing repo owner/name in config",
"pr_url": None,
}
if not pr_number:
return {"success": False, "error": "Missing pr_number argument", "pr_url": None}
if title is None and body is None:
return {
"success": False,
"error": "At least one of title or body must be provided",
"pr_url": None,
}
github_token = get_github_token(config)
if not github_token:
github_token = asyncio.run(get_github_app_installation_token())
if not github_token:
return {"success": False, "error": "Missing GitHub token", "pr_url": None}
pr_url, _pr_number = asyncio.run(
edit_github_pr(
repo_owner=repo_owner,
repo_name=repo_name,
github_token=github_token,
pr_number=pr_number,
title=title,
body=body,
)
)
if not pr_url:
return {
"success": False,
"error": f"Failed to update PR #{pr_number}",
"pr_url": None,
}
return {"success": True, "error": None, "pr_url": pr_url}
except Exception as e:
logger.exception("edit_pull_request failed")
return {"success": False, "error": f"{type(e).__name__}: {e}", "pr_url": None}

View file

@ -1,18 +0,0 @@
from typing import Any
from langgraph.config import get_config
def get_branch_name() -> dict[str, Any]:
"""Return the git branch name for this thread.
Returns the branch_name from thread metadata if set,
otherwise falls back to open-swe/{thread_id}.
"""
config = get_config()
metadata = config.get("metadata", {})
branch_name = metadata.get("branch_name")
if not branch_name:
thread_id = config.get("configurable", {}).get("thread_id", "unknown")
branch_name = f"open-swe/{thread_id}"
return {"branch_name": branch_name}

View file

@ -1,96 +0,0 @@
import asyncio
from typing import Any
from langgraph.config import get_config
from ..utils.github_app import get_github_app_installation_token
from ..utils.github_comments import _fetch_paginated
def get_pr_review_comments(
pr_number: int,
repo_owner: str | None = None,
repo_name: str | None = None,
) -> dict[str, Any]:
"""Fetch all review comments for a GitHub pull request.
Returns thread comments, inline review comments, and review submissions
sorted chronologically.
"""
config = get_config()
configurable = config.get("configurable", {})
repo_config = configurable.get("repo", {})
owner = repo_owner or repo_config.get("owner", "")
repo = repo_name or repo_config.get("name", "")
if not owner or not repo:
return {
"success": False,
"error": "No repo config found — provide repo_owner/repo_name or set repo in config",
}
token = asyncio.run(get_github_app_installation_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
base = f"https://api.github.com/repos/{owner}/{repo}"
async def _fetch_all() -> list[dict[str, Any]]:
import httpx
async with httpx.AsyncClient() as http_client:
pr_comments, review_comments, reviews = await asyncio.gather(
_fetch_paginated(http_client, f"{base}/issues/{pr_number}/comments", headers),
_fetch_paginated(http_client, f"{base}/pulls/{pr_number}/comments", headers),
_fetch_paginated(http_client, f"{base}/pulls/{pr_number}/reviews", headers),
)
all_comments: list[dict[str, Any]] = []
for c in pr_comments:
all_comments.append(
{
"body": c.get("body", ""),
"author": c.get("user", {}).get("login", "unknown"),
"created_at": c.get("created_at", ""),
"type": "pr_comment",
"comment_id": c.get("id"),
}
)
for c in review_comments:
all_comments.append(
{
"body": c.get("body", ""),
"author": c.get("user", {}).get("login", "unknown"),
"created_at": c.get("created_at", ""),
"type": "review_comment",
"comment_id": c.get("id"),
"path": c.get("path", ""),
"line": c.get("line") or c.get("original_line"),
}
)
for r in reviews:
body = r.get("body", "")
if not body:
continue
all_comments.append(
{
"body": body,
"author": r.get("user", {}).get("login", "unknown"),
"created_at": r.get("submitted_at", ""),
"type": "review",
"comment_id": r.get("id"),
}
)
all_comments.sort(key=lambda c: c.get("created_at", ""))
return all_comments
comments = asyncio.run(_fetch_all())
return {"success": True, "pr_number": pr_number, "total": len(comments), "comments": comments}

View file

@ -1,252 +0,0 @@
import asyncio
from typing import Any
import httpx
from langgraph.config import get_config
from ..utils.github_app import get_github_app_installation_token
GITHUB_API_BASE = "https://api.github.com"
PER_PAGE = 100
HTTP_TIMEOUT_SECONDS = 30.0
FAILED_CONCLUSIONS = ("failure", "timed_out", "cancelled", "action_required")
RERUNNABLE_CONCLUSIONS = ("failure", "timed_out", "cancelled")
def _get_repo_config() -> dict[str, str]:
config = get_config()
return config.get("configurable", {}).get("repo", {})
def _github_headers(token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async def _get_token() -> str | None:
return await get_github_app_installation_token()
def _repo_url(repo_config: dict[str, str]) -> str:
owner = repo_config.get("owner", "")
name = repo_config.get("name", "")
return f"{GITHUB_API_BASE}/repos/{owner}/{name}"
def _http_client() -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=httpx.Timeout(HTTP_TIMEOUT_SECONDS))
async def _fetch_paginated_items(
client: httpx.AsyncClient,
url: str,
headers: dict[str, str],
item_key: str,
params: dict[str, str] | None = None,
) -> tuple[list[dict[str, Any]] | None, int | None, str | None]:
items: list[dict[str, Any]] = []
total_count: int | None = None
page = 1
while True:
# Reserved pagination params take precedence over caller-supplied params
# so the end-of-pagination check below stays consistent with PER_PAGE.
page_params = dict(params) if params else {}
page_params["per_page"] = str(PER_PAGE)
page_params["page"] = str(page)
response = await client.get(url, headers=headers, params=page_params)
if response.status_code != 200:
return None, None, f"GitHub API returned {response.status_code}: {response.text}"
data = response.json()
if total_count is None and isinstance(data.get("total_count"), int):
total_count = data["total_count"]
page_items = data.get(item_key, [])
if not isinstance(page_items, list):
return None, None, f"GitHub API response missing {item_key} list"
items.extend(page_items)
if len(page_items) < PER_PAGE:
return items, total_count, None
page += 1
def get_pr_check_runs(pull_number: int) -> dict[str, Any]:
"""Get CI check run status for a pull request.
Returns all check runs for the PR's latest commit with their status and conclusion.
Use this to check if CI is passing before declaring a PR ready for review.
Note: this returns all check runs (GitHub Actions plus any third-party CI like
CircleCI, Vercel, etc.). The companion `rerun_failed_workflow_runs` tool only
retries GitHub Actions workflow runs.
Args:
pull_number: The PR number to get check runs for.
Returns:
Dictionary with success status and check run summary per check.
"""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
async def _fetch() -> dict[str, Any]:
async with _http_client() as client:
# Step 1: get the PR's head commit SHA
pr_url = f"{_repo_url(repo_config)}/pulls/{pull_number}"
pr_response = await client.get(pr_url, headers=_github_headers(token))
if pr_response.status_code != 200:
return {
"success": False,
"error": f"GitHub API returned {pr_response.status_code} fetching PR: {pr_response.text}",
}
head_sha = pr_response.json().get("head", {}).get("sha")
if not head_sha:
return {"success": False, "error": "Could not determine head SHA for PR"}
# Step 2: get check runs for that SHA
check_runs_url = f"{_repo_url(repo_config)}/commits/{head_sha}/check-runs"
check_runs, total_count, error = await _fetch_paginated_items(
client,
check_runs_url,
_github_headers(token),
"check_runs",
)
if error or check_runs is None:
return {
"success": False,
"error": f"Error fetching check runs: {error}",
}
summary = [
{
"id": run.get("id"),
"name": run.get("name"),
"status": run.get("status"),
"conclusion": run.get("conclusion"),
"html_url": run.get("html_url"),
}
for run in check_runs
]
has_check_runs = bool(check_runs)
all_passed = all(
run.get("conclusion") == "success"
for run in check_runs
if run.get("status") == "completed"
)
any_failed = any(run.get("conclusion") in FAILED_CONCLUSIONS for run in check_runs)
any_pending = any(run.get("status") != "completed" for run in check_runs)
return {
"success": True,
"head_sha": head_sha,
"total_count": total_count if total_count is not None else len(check_runs),
"check_runs": summary,
"all_passed": has_check_runs and all_passed and not any_pending,
"any_failed": any_failed,
"any_pending": any_pending,
}
return asyncio.run(_fetch())
def rerun_failed_workflow_runs(pull_number: int) -> dict[str, Any]:
"""Rerun failed jobs for failed/timed-out/cancelled GitHub Actions workflow runs.
Use this to retry flaky CI failures without human intervention. Only operates on
GitHub Actions workflow runs — third-party CI checks (CircleCI, Vercel, etc.)
surfaced by `get_pr_check_runs` are not affected. Skips runs with conclusion
`action_required`, since those need manual approval (e.g. environment protection)
rather than a rerun.
Args:
pull_number: The PR number whose failed CI runs should be rerun.
Returns:
Dictionary with success status and details of which runs were rerun.
"""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
async def _rerun() -> dict[str, Any]:
async with _http_client() as client:
# Step 1: get the PR's head commit SHA
pr_url = f"{_repo_url(repo_config)}/pulls/{pull_number}"
pr_response = await client.get(pr_url, headers=_github_headers(token))
if pr_response.status_code != 200:
return {
"success": False,
"error": f"GitHub API returned {pr_response.status_code} fetching PR: {pr_response.text}",
}
head_sha = pr_response.json().get("head", {}).get("sha")
if not head_sha:
return {"success": False, "error": "Could not determine head SHA for PR"}
# Step 2: get workflow runs for that SHA
runs_url = f"{_repo_url(repo_config)}/actions/runs"
workflow_runs, _, error = await _fetch_paginated_items(
client,
runs_url,
_github_headers(token),
"workflow_runs",
params={"head_sha": head_sha},
)
if error or workflow_runs is None:
return {
"success": False,
"error": f"Error fetching workflow runs: {error}",
}
failed_run_ids = [
run["id"]
for run in workflow_runs
if run.get("conclusion") in RERUNNABLE_CONCLUSIONS
]
if not failed_run_ids:
return {
"success": True,
"message": "No failed workflow runs found for the PR's latest commit",
"head_sha": head_sha,
"rerun_run_ids": [],
}
# Step 3: rerun failed jobs concurrently for all failed workflow runs
async def _rerun_one(run_id: int) -> dict[str, Any]:
rerun_url = f"{_repo_url(repo_config)}/actions/runs/{run_id}/rerun-failed-jobs"
rerun_response = await client.post(rerun_url, headers=_github_headers(token))
return {
"run_id": run_id,
"status_code": rerun_response.status_code,
"success": rerun_response.status_code in (200, 201, 204),
}
rerun_results = await asyncio.gather(*(_rerun_one(rid) for rid in failed_run_ids))
all_rerun_succeeded = all(r["success"] for r in rerun_results)
return {
"success": all_rerun_succeeded,
"head_sha": head_sha,
"rerun_run_ids": failed_run_ids,
"rerun_results": list(rerun_results),
}
return asyncio.run(_rerun())

View file

@ -1,28 +0,0 @@
import asyncio
from typing import Any
from langgraph.config import get_config
from ..utils.github_app import get_github_app_installation_token
from ..utils.github_comments import post_github_comment
def github_comment(message: str, issue_number: int) -> dict[str, Any]:
"""Post a comment to a GitHub issue or pull request."""
config = get_config()
configurable = config.get("configurable", {})
repo_config = configurable.get("repo", {})
if not issue_number:
return {"success": False, "error": "Missing issue_number argument"}
if not repo_config:
return {"success": False, "error": "No repo config found in config"}
if not message.strip():
return {"success": False, "error": "Message cannot be empty"}
token = asyncio.run(get_github_app_installation_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
success = asyncio.run(post_github_comment(repo_config, issue_number, message, token=token))
return {"success": success}

View file

@ -1,312 +0,0 @@
import asyncio
from typing import Any
import httpx
from langgraph.config import get_config
from ..utils.github_app import get_github_app_installation_token
GITHUB_API_BASE = "https://api.github.com"
def _get_repo_config() -> dict[str, str]:
config = get_config()
return config.get("configurable", {}).get("repo", {})
def _github_headers(token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async def _get_token() -> str | None:
return await get_github_app_installation_token()
def _repo_url(repo_config: dict[str, str]) -> str:
owner = repo_config.get("owner", "")
name = repo_config.get("name", "")
return f"{GITHUB_API_BASE}/repos/{owner}/{name}"
def list_pr_reviews(pull_number: int) -> dict[str, Any]:
"""List all reviews on a pull request."""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews"
async def _fetch() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=_github_headers(token))
if response.status_code != 200:
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "reviews": response.json()}
return asyncio.run(_fetch())
def get_pr_review(pull_number: int, review_id: int) -> dict[str, Any]:
"""Get a specific review on a pull request by review ID."""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews/{review_id}"
async def _fetch() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=_github_headers(token))
if response.status_code != 200:
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "review": response.json()}
return asyncio.run(_fetch())
def create_pr_review(
pull_number: int,
body: str | None = None,
event: str = "COMMENT",
comments: list[dict[str, Any]] | None = None,
commit_id: str | None = None,
) -> dict[str, Any]:
"""Create a review on a pull request.
Args:
pull_number: The PR number to review.
body: The review body text (required for REQUEST_CHANGES, optional for COMMENT).
event: The review action - one of REQUEST_CHANGES or COMMENT. APPROVE is not allowed.
comments: Optional list of review comments. Each comment dict should have:
- path (str): The relative file path to comment on.
- body (str): The comment text.
- line (int, optional): The line number in the diff to comment on.
- side (str, optional): Which side of the diff to comment on (LEFT or RIGHT).
- start_line (int, optional): For multi-line comments, the start line.
- start_side (str, optional): For multi-line comments, the start side.
commit_id: Optional SHA of the commit to review. Defaults to the latest commit.
Returns:
Dictionary with success status and the created review data.
"""
if event.upper() == "APPROVE":
return {
"success": False,
"error": "APPROVE is not allowed. Use COMMENT or REQUEST_CHANGES.",
}
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews"
payload: dict[str, Any] = {"event": event}
if body is not None:
payload["body"] = body
if comments:
payload["comments"] = comments
if commit_id:
payload["commit_id"] = commit_id
async def _create() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=_github_headers(token), json=payload)
if response.status_code not in (200, 201):
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "review": response.json()}
return asyncio.run(_create())
def update_pr_review(
pull_number: int,
review_id: int,
body: str,
) -> dict[str, Any]:
"""Update the body of an existing review on a pull request.
Args:
pull_number: The PR number.
review_id: The ID of the review to update.
body: The new review body text.
Returns:
Dictionary with success status and the updated review data.
"""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews/{review_id}"
async def _update() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.put(url, headers=_github_headers(token), json={"body": body})
if response.status_code != 200:
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "review": response.json()}
return asyncio.run(_update())
def dismiss_pr_review(
pull_number: int,
review_id: int,
message: str,
) -> dict[str, Any]:
"""Dismiss a review on a pull request.
Args:
pull_number: The PR number.
review_id: The ID of the review to dismiss.
message: A message explaining why the review is being dismissed.
Returns:
Dictionary with success status and the dismissed review data.
"""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews/{review_id}/dismissals"
async def _dismiss() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.put(
url, headers=_github_headers(token), json={"message": message}
)
if response.status_code != 200:
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "review": response.json()}
return asyncio.run(_dismiss())
def submit_pr_review(
pull_number: int,
review_id: int,
body: str | None = None,
event: str = "COMMENT",
) -> dict[str, Any]:
"""Submit a pending review on a pull request.
Use this if a review was created without an event (pending state) and needs to be submitted.
Args:
pull_number: The PR number.
review_id: The ID of the pending review to submit.
body: Optional body text for the review submission.
event: The review action - one of REQUEST_CHANGES or COMMENT. APPROVE is not allowed.
Returns:
Dictionary with success status and the submitted review data.
"""
if event.upper() == "APPROVE":
return {
"success": False,
"error": "APPROVE is not allowed. Use COMMENT or REQUEST_CHANGES.",
}
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews/{review_id}/events"
payload: dict[str, Any] = {"event": event}
if body is not None:
payload["body"] = body
async def _submit() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=_github_headers(token), json=payload)
if response.status_code not in (200, 201):
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "review": response.json()}
return asyncio.run(_submit())
def list_pr_review_comments(
pull_number: int,
review_id: int | None = None,
) -> dict[str, Any]:
"""List comments on a pull request review.
Args:
pull_number: The PR number.
review_id: If provided, list comments for a specific review.
If not provided, list all review comments on the PR.
Returns:
Dictionary with success status and the list of review comments.
"""
repo_config = _get_repo_config()
if not repo_config:
return {"success": False, "error": "No repo config found"}
token = asyncio.run(_get_token())
if not token:
return {"success": False, "error": "Failed to get GitHub App installation token"}
if review_id is not None:
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/reviews/{review_id}/comments"
else:
url = f"{_repo_url(repo_config)}/pulls/{pull_number}/comments"
async def _fetch() -> dict[str, Any]:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=_github_headers(token))
if response.status_code != 200:
return {
"success": False,
"error": f"GitHub API returned {response.status_code}: {response.text}",
}
return {"success": True, "comments": response.json()}
return asyncio.run(_fetch())

View file

@ -107,10 +107,8 @@ def http_request(
) -> dict[str, Any]:
"""Make HTTP requests to APIs and web services.
Do not use this tool to create or update the pull request for completed code
changes. Use `commit_and_open_pr` for that workflow so commits are pushed and
GitHub authentication is handled correctly. For other PR-related actions, use
the dedicated GitHub PR tools when available.
Do not use this tool for GitHub API calls. Use `GH_TOKEN=dummy gh` in the
sandbox so GitHub authentication is handled by the sandbox proxy.
Args:
url: Target URL

View file

@ -10,7 +10,7 @@ def linear_comment(comment_body: str, ticket_id: str) -> dict[str, Any]:
Use this tool to communicate progress and completion to stakeholders on Linear.
**When to use:**
- After calling `commit_and_open_pr`, post a comment on the Linear ticket to let
- After opening/updating a draft PR, post a comment on the Linear ticket to let
stakeholders know the task is complete and include the PR link. For example:
"I've completed the implementation and opened a PR: <pr_url>"
- When answering a question or sharing an update (no code changes needed).

View file

@ -1,59 +0,0 @@
import logging
from typing import Any
import httpx
from ..utils.github_app import get_github_app_installation_token
logger = logging.getLogger(__name__)
async def list_repos(
organization_name: str,
is_organization: bool = True,
page: int = 1,
per_page: int = 100,
sort: str = "updated",
name_filter: str | None = None,
) -> dict[str, Any]:
"""List GitHub repositories for an organization or user via the GitHub API.
Uses /orgs/{name}/repos for organizations and /users/{name}/repos for
personal user accounts, based on the is_organization flag.
Args:
organization_name: The GitHub organization or username to list repos for.
is_organization: If True, uses the /orgs/ endpoint. If False, uses the
/users/ endpoint for personal accounts. Default: True.
page: Page number to fetch (default: 1).
per_page: Number of repos per page, max 100 (default: 100).
sort: Sort field — "updated", "created", "pushed", or "full_name" (default: "updated").
name_filter: Optional substring to filter repo names by (case-insensitive).
If unsure which repo to use, ask the user for confirmation.
"""
try:
headers = {"Accept": "application/vnd.github+json"}
token = await get_github_app_installation_token()
if token:
headers["Authorization"] = f"Bearer {token}"
path_prefix = "orgs" if is_organization else "users"
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.github.com/{path_prefix}/{organization_name}/repos",
headers=headers,
params={"per_page": min(per_page, 100), "sort": sort, "page": page},
timeout=10,
)
if response.status_code == 200:
repos: list[str] = [r["name"] for r in response.json()]
if name_filter:
repos = [r for r in repos if name_filter.lower() in r.lower()]
has_next = 'rel="next"' in response.headers.get("link", "")
result: dict[str, Any] = {"repos": repos, "page": page, "has_next_page": has_next}
return result
return {"error": f"GitHub API returned status {response.status_code}"}
except Exception:
logger.warning("Failed to fetch repos for %s", organization_name)
return {"error": f"Failed to fetch repos for {organization_name}"}

View file

@ -1,508 +0,0 @@
"""GitHub API and git utilities."""
from __future__ import annotations
import logging
import shlex
import httpx
from deepagents.backends.protocol import ExecuteResponse, SandboxBackendProtocol
logger = logging.getLogger(__name__)
# HTTP status codes
HTTP_CREATED = 201
HTTP_UNPROCESSABLE_ENTITY = 422
def is_permanent_github_push_failure(output: str) -> bool:
"""Return whether git push output indicates a permanent auth failure."""
normalized_output = output.lower()
return (
"permanent_failure" in normalized_output
or "403" in normalized_output
or "permission" in normalized_output
or "denied" in normalized_output
)
def _run_git(
sandbox_backend: SandboxBackendProtocol, repo_dir: str, command: str
) -> ExecuteResponse:
"""Run a git command in the sandbox repo directory."""
safe_repo_dir = shlex.quote(repo_dir)
return sandbox_backend.execute(f"cd {safe_repo_dir} && {command}")
def git_has_uncommitted_changes(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> bool:
"""Check whether the repo has uncommitted changes."""
result = _run_git(sandbox_backend, repo_dir, "git status --porcelain")
return result.exit_code == 0 and bool(result.output.strip())
def git_fetch_origin(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> ExecuteResponse:
"""Fetch latest from origin (best-effort)."""
return _run_git(sandbox_backend, repo_dir, "git fetch origin 2>/dev/null || true")
def git_has_unpushed_commits(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> bool:
"""Check whether there are commits not pushed to upstream."""
git_log_cmd = (
"git log --oneline @{upstream}..HEAD 2>/dev/null "
"|| git log --oneline origin/HEAD..HEAD 2>/dev/null || echo ''"
)
result = _run_git(sandbox_backend, repo_dir, git_log_cmd)
return result.exit_code == 0 and bool(result.output.strip())
def git_current_branch(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> str:
"""Get the current git branch name."""
result = _run_git(sandbox_backend, repo_dir, "git rev-parse --abbrev-ref HEAD")
return result.output.strip() if result.exit_code == 0 else ""
def git_checkout_branch(
sandbox_backend: SandboxBackendProtocol, repo_dir: str, branch: str
) -> tuple[bool, str]:
"""Checkout branch, creating it if needed. Returns (success, error_output)."""
safe_branch = shlex.quote(branch)
checkout_result = _run_git(sandbox_backend, repo_dir, f"git checkout -B {safe_branch}")
if checkout_result.exit_code == 0:
return True, ""
fallback_create = _run_git(sandbox_backend, repo_dir, f"git checkout -b {safe_branch}")
if fallback_create.exit_code == 0:
return True, ""
fallback = _run_git(sandbox_backend, repo_dir, f"git checkout {safe_branch}")
if fallback.exit_code == 0:
return True, ""
return False, fallback.output.strip() or checkout_result.output.strip()
def git_checkout_existing_branch(
sandbox_backend: SandboxBackendProtocol, repo_dir: str, branch: str
) -> ExecuteResponse:
"""Checkout an existing branch without creating or resetting it."""
safe_branch = shlex.quote(branch)
return _run_git(sandbox_backend, repo_dir, f"git checkout {safe_branch}")
def git_config_user(
sandbox_backend: SandboxBackendProtocol,
repo_dir: str,
name: str,
email: str,
) -> None:
"""Configure git user name and email."""
safe_name = shlex.quote(name)
safe_email = shlex.quote(email)
_run_git(sandbox_backend, repo_dir, f"git config user.name {safe_name}")
_run_git(sandbox_backend, repo_dir, f"git config user.email {safe_email}")
def git_add_all(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> ExecuteResponse:
"""Stage all changes."""
return _run_git(sandbox_backend, repo_dir, "git add -A")
def git_commit(
sandbox_backend: SandboxBackendProtocol, repo_dir: str, message: str
) -> ExecuteResponse:
"""Commit staged changes with the given message."""
safe_message = shlex.quote(message)
return _run_git(sandbox_backend, repo_dir, f"git commit -m {safe_message}")
def git_get_remote_url(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> str | None:
"""Get the origin remote URL."""
result = _run_git(sandbox_backend, repo_dir, "git remote get-url origin")
if result.exit_code != 0:
return None
return result.output.strip()
def git_push(
sandbox_backend: SandboxBackendProtocol,
repo_dir: str,
branch: str,
) -> ExecuteResponse:
"""Push the branch to origin.
Authentication is handled by the sandbox proxy (configured at sandbox creation
time via the LangSmith proxy-config API), so no token is needed here.
"""
safe_branch = shlex.quote(branch)
return _run_git(sandbox_backend, repo_dir, f"git push origin {safe_branch}")
async def create_github_pr(
repo_owner: str,
repo_name: str,
github_token: str,
title: str,
head_branch: str,
base_branch: str,
body: str,
installation_token: str | None = None,
) -> tuple[str | None, int | None, bool]:
"""Create a draft GitHub pull request via the API.
When *github_token* differs from *installation_token* (e.g. a user
OAuth token), the function first attempts to create the PR with the
user token so the user becomes the PR author. If that fails it
retries with the installation token. The ``OpenSWE`` label is
always added using the installation token.
Args:
repo_owner: Repository owner (e.g., "langchain-ai")
repo_name: Repository name (e.g., "deepagents")
github_token: GitHub access token (user token preferred)
title: PR title
head_branch: Source branch name
base_branch: Target branch name
body: PR description
installation_token: GitHub App installation token used for labeling and as a fallback
for PR creation. Falls back to github_token when not provided.
Returns:
Tuple of (pr_url, pr_number, pr_existing) if successful, (None, None, False) otherwise
"""
tokens_to_try = [github_token]
if installation_token and installation_token != github_token:
tokens_to_try.append(installation_token)
label_tok = installation_token or github_token
pr_payload = {
"title": title,
"head": head_branch,
"base": base_branch,
"body": body,
"draft": True,
}
logger.info(
"Creating PR: head=%s, base=%s, repo=%s/%s",
head_branch,
base_branch,
repo_owner,
repo_name,
)
async with httpx.AsyncClient() as http_client:
for token in tokens_to_try:
try:
pr_response = await http_client.post(
f"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json=pr_payload,
)
pr_data = pr_response.json()
if pr_response.status_code == HTTP_CREATED:
pr_url = pr_data.get("html_url")
pr_number = pr_data.get("number")
await _add_label(
http_client,
repo_owner,
repo_name,
label_tok,
pr_number,
)
logger.info("PR created successfully: %s", pr_url)
return pr_url, pr_number, False
if pr_response.status_code == HTTP_UNPROCESSABLE_ENTITY:
logger.error("GitHub API validation error (422): %s", pr_data.get("message"))
existing = await _find_existing_pr(
http_client=http_client,
repo_owner=repo_owner,
repo_name=repo_name,
github_token=token,
head_branch=head_branch,
)
pr_url, pr_number = existing
if pr_url:
logger.info("Using existing PR for head branch: %s", pr_url)
updated = await _update_github_pr(
http_client=http_client,
repo_owner=repo_owner,
repo_name=repo_name,
github_token=token,
pr_number=pr_number,
)
if not updated:
if token != tokens_to_try[-1]:
logger.info("Retrying existing PR update with installation token")
continue
return None, None, False
await _add_label(
http_client,
repo_owner,
repo_name,
label_tok,
pr_number,
)
return pr_url, pr_number, True
else:
logger.debug(
"Could not find existing PR with current token, will retry"
if token != tokens_to_try[-1]
else "Could not find existing PR"
)
else:
logger.error(
"GitHub API error (%s): %s",
pr_response.status_code,
pr_data.get("message"),
)
if "errors" in pr_data:
logger.error("GitHub API errors detail: %s", pr_data.get("errors"))
# If this was the user token, fall through to retry with installation token
if token != tokens_to_try[-1]:
logger.info("Retrying PR creation with installation token")
continue
return None, None, False
except httpx.HTTPError:
logger.exception("Failed to create PR via GitHub API")
if token != tokens_to_try[-1]:
logger.info("Retrying PR creation with installation token")
continue
try:
existing_pr_url, existing_pr_number = await _find_existing_pr(
http_client=http_client,
repo_owner=repo_owner,
repo_name=repo_name,
github_token=token,
head_branch=head_branch,
)
if existing_pr_url:
await _add_label(
http_client,
repo_owner,
repo_name,
label_tok,
existing_pr_number,
)
logger.info("Found existing PR after HTTP error: %s", existing_pr_url)
return existing_pr_url, existing_pr_number, True
except Exception:
logger.exception("Failed to find existing PR after HTTP error")
return None, None, False
return None, None, False
_OPENSWE_LABEL = "OpenSWE"
async def _add_label(
http_client: httpx.AsyncClient,
repo_owner: str,
repo_name: str,
github_token: str,
pr_number: int | None,
) -> None:
"""Add the 'OpenSWE' label to a PR without failing PR creation on errors."""
if not pr_number:
return
try:
response = await http_client.post(
f"https://api.github.com/repos/{repo_owner}/{repo_name}/issues/{pr_number}/labels",
headers={
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json={"labels": [_OPENSWE_LABEL]},
)
if response.is_success:
logger.info("Added '%s' label to PR #%s", _OPENSWE_LABEL, pr_number)
else:
logger.warning(
"Failed to add label to PR #%s (%s)",
pr_number,
response.status_code,
)
except httpx.HTTPError:
logger.warning("Failed to add label to PR #%s", pr_number, exc_info=True)
async def _find_existing_pr(
http_client: httpx.AsyncClient,
repo_owner: str,
repo_name: str,
github_token: str,
head_branch: str,
) -> tuple[str | None, int | None]:
"""Find an existing PR for the given head branch."""
headers = {
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
head_ref = f"{repo_owner}:{head_branch}"
for state in ("open", "all"):
response = await http_client.get(
f"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls",
headers=headers,
params={"head": head_ref, "state": state, "per_page": 1},
)
if response.status_code != 200: # noqa: PLR2004
continue
data = response.json()
if not data:
continue
pr = data[0]
return pr.get("html_url"), pr.get("number")
return None, None
HTTP_OK = 200
async def edit_github_pr(
repo_owner: str,
repo_name: str,
github_token: str,
pr_number: int,
title: str | None = None,
body: str | None = None,
) -> tuple[str | None, int | None]:
"""Update an existing GitHub pull request title and/or body."""
pr_payload: dict[str, str] = {}
if title is not None:
pr_payload["title"] = title
if body is not None:
pr_payload["body"] = body
if not pr_payload:
logger.warning("edit_github_pr called with no fields to update")
return None, None
async with httpx.AsyncClient() as http_client:
try:
response = await http_client.patch(
f"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls/{pr_number}",
headers={
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json=pr_payload,
)
pr_data = response.json()
if response.status_code == HTTP_OK:
pr_url = pr_data.get("html_url")
logger.info("PR #%d updated successfully: %s", pr_number, pr_url)
return pr_url, pr_data.get("number")
logger.error(
"GitHub API error (%s): %s",
response.status_code,
pr_data.get("message"),
)
return None, None
except httpx.HTTPError:
logger.exception("Failed to update PR #%d via GitHub API", pr_number)
return None, None
async def _update_github_pr(
http_client: httpx.AsyncClient,
repo_owner: str,
repo_name: str,
github_token: str,
pr_number: int | None,
title: str | None = None,
body: str | None = None,
) -> bool:
"""Update an existing PR via PATCH."""
if pr_number is None:
logger.warning("Cannot update PR: pr_number is None")
return False
payload: dict[str, str] = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if not payload:
logger.info("No existing PR fields to update for PR #%s", pr_number)
return True
headers = {
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
try:
response = await http_client.patch(
f"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls/{pr_number}",
headers=headers,
json=payload,
)
except httpx.HTTPError:
logger.warning("Failed to update PR #%s", pr_number, exc_info=True)
return False
if response.status_code == 200: # noqa: PLR2004
logger.info("Updated existing PR #%s", pr_number)
return True
logger.warning(
"Failed to update PR #%s (%s): %s",
pr_number,
response.status_code,
response.json().get("message"),
)
return False
async def get_github_default_branch(
repo_owner: str,
repo_name: str,
github_token: str,
) -> str:
"""Get the default branch of a GitHub repository via the API.
Args:
repo_owner: Repository owner (e.g., "langchain-ai")
repo_name: Repository name (e.g., "deepagents")
github_token: GitHub access token
Returns:
The default branch name (e.g., "main" or "master")
"""
try:
async with httpx.AsyncClient() as http_client:
response = await http_client.get(
f"https://api.github.com/repos/{repo_owner}/{repo_name}",
headers={
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
if response.status_code == 200: # noqa: PLR2004
repo_data = response.json()
default_branch = repo_data.get("default_branch", "main")
logger.debug("Got default branch from GitHub API: %s", default_branch)
return default_branch
logger.warning(
"Failed to get repo info from GitHub API (%s), falling back to 'main'",
response.status_code,
)
return "main"
except httpx.HTTPError:
logger.exception("Failed to get default branch from GitHub API, falling back to 'main'")
return "main"

View file

@ -412,11 +412,11 @@ def build_pr_prompt(
f"## Comments:\n{comments_text}\n\n"
"If code changes are needed:\n"
"1. Make the changes in the sandbox\n"
"2. Call `commit_and_open_pr` to push them to GitHub — this is REQUIRED, do NOT skip it\n"
"3. Call `github_comment` with the PR number to post a summary on GitHub\n\n"
"2. Push them and open/update a draft PR with `GH_TOKEN=dummy gh` — this is REQUIRED, do NOT skip it\n"
"3. Use `GH_TOKEN=dummy gh pr comment` to post a summary on GitHub\n\n"
"If no code changes are needed:\n"
"1. Call `github_comment` with the PR number to explain your answer — this is REQUIRED, never end silently\n\n"
"**You MUST always call `github_comment` before finishing — whether or not changes were made.**"
"1. Use `GH_TOKEN=dummy gh pr comment` to explain your answer — this is REQUIRED, never end silently\n\n"
"**You MUST always comment on GitHub before finishing — whether or not changes were made.**"
)

View file

@ -1145,7 +1145,8 @@ def build_github_issue_prompt(
f"## Description:\n{formatted_body}\n"
f"{comments_text}\n\n"
"Please analyze this issue and implement the necessary changes. "
"When you need to communicate on GitHub, use `github_comment` with the issue number."
"When you need to communicate on GitHub, use `GH_TOKEN=dummy gh issue comment` "
"with the issue number."
)

View file

@ -5,14 +5,14 @@ import os
from langsmith.sandbox import SandboxClient
DEFAULT_IMAGE = "bracelangchain/deepagents-sandbox:v1"
DEFAULT_IMAGE = "johanneslangchain/open-swe-sandbox:gh-cli-amd64"
DEFAULT_FS_CAPACITY = 32 * 1024**3 # 32 GiB
def main() -> None:
parser = argparse.ArgumentParser(description="Create a LangSmith sandbox snapshot")
parser.add_argument(
"--name", default="open-swe-new", help="Snapshot name (default: open-swe-new)"
"--name", default="open-swe-gh-amd64", help="Snapshot name (default: open-swe-gh-amd64)"
)
parser.add_argument(
"--image", default=DEFAULT_IMAGE, help=f"Docker image (default: {DEFAULT_IMAGE})"

View file

@ -1,321 +0,0 @@
"""Export unique PR URLs from commit_and_open_pr tool messages in LangGraph threads."""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from langchain_core.messages import BaseMessage, convert_to_messages
from langgraph_sdk import get_client
from langgraph_sdk.client import LangGraphClient
logger = logging.getLogger(__name__)
DEFAULT_OUTPUT_PATH = "pr_urls.json"
DEFAULT_PAGE_SIZE = 100
DEFAULT_CONCURRENCY = 20
DEFAULT_DAYS_BACK = 9
def _load_dotenv_if_available() -> None:
try:
from dotenv import load_dotenv
except ImportError:
return
load_dotenv()
def get_langgraph_url(explicit_url: str | None = None) -> str:
if explicit_url:
return explicit_url
return os.environ.get("LANGGRAPH_URL") or os.environ.get(
"LANGGRAPH_URL_PROD", "http://localhost:2024"
)
def extract_pr_urls_from_messages(messages: list[BaseMessage]) -> list[str]:
pr_urls: list[str] = []
for message in messages:
if getattr(message, "type", None) != "tool":
continue
if getattr(message, "name", None) != "commit_and_open_pr":
continue
content = getattr(message, "content", None)
payload: dict[str, Any] | None = None
if isinstance(content, str):
try:
parsed_content = json.loads(content)
except (TypeError, ValueError):
continue
if isinstance(parsed_content, dict):
payload = parsed_content
elif isinstance(content, dict):
payload = content
if not payload:
continue
pr_url = payload.get("pr_url")
if isinstance(pr_url, str) and pr_url:
pr_urls.append(pr_url)
return pr_urls
def extract_pr_urls_from_state_values(state_values: Any) -> list[str]:
if not isinstance(state_values, dict):
return []
raw_messages = state_values.get("messages")
if not isinstance(raw_messages, list):
return []
try:
messages = convert_to_messages(raw_messages)
except Exception: # noqa: BLE001
logger.exception("Failed to deserialize messages from thread state")
raise ValueError("Failed to deserialize messages from thread state") from None
return extract_pr_urls_from_messages(messages)
def _get_thread_id(thread: Any) -> str | None:
if isinstance(thread, dict):
thread_id = thread.get("thread_id")
else:
thread_id = getattr(thread, "thread_id", None)
return thread_id if isinstance(thread_id, str) and thread_id else None
def _coerce_datetime(value: Any) -> datetime | None:
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
if isinstance(value, str):
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
return None
def _get_thread_created_at(thread: Any) -> datetime | None:
if isinstance(thread, dict):
created_at = thread.get("created_at")
else:
created_at = getattr(thread, "created_at", None)
return _coerce_datetime(created_at)
def _split_recent_threads(threads: list[Any], cutoff: datetime) -> tuple[list[Any], bool]:
recent_threads: list[Any] = []
for thread in threads:
created_at = _get_thread_created_at(thread)
if created_at is None:
logger.warning(
"Skipping thread %s because created_at is missing or invalid",
_get_thread_id(thread) or "<unknown>",
)
continue
if created_at >= cutoff:
recent_threads.append(thread)
continue
return recent_threads, True
return recent_threads, False
def _iter_offset_batches(
total_threads: int, page_size: int, batch_size: int
) -> Iterator[list[int]]:
offsets = range(0, total_threads, page_size)
batch: list[int] = []
for offset in offsets:
batch.append(offset)
if len(batch) == batch_size:
yield batch
batch = []
if batch:
yield batch
async def _fetch_thread_page(
client: LangGraphClient,
*,
offset: int,
page_size: int,
) -> tuple[int, list[Any]]:
threads = await client.threads.search(
limit=page_size,
offset=offset,
sort_by="created_at",
sort_order="desc",
)
return offset, threads
async def _fetch_pr_urls_for_thread(
client: LangGraphClient,
thread_id: str,
semaphore: asyncio.Semaphore,
) -> list[str]:
async with semaphore:
try:
state = await client.threads.get_state(thread_id)
except Exception: # noqa: BLE001
logger.exception("Failed to fetch state for thread %s", thread_id)
return []
return extract_pr_urls_from_state_values(state.get("values"))
async def export_pr_urls(
*,
langgraph_url: str,
output_path: Path,
page_size: int = DEFAULT_PAGE_SIZE,
concurrency: int = DEFAULT_CONCURRENCY,
days_back: int = DEFAULT_DAYS_BACK,
) -> list[str]:
if page_size < 1:
raise ValueError("page_size must be greater than 0")
if concurrency < 1:
raise ValueError("concurrency must be greater than 0")
if days_back < 1:
raise ValueError("days_back must be greater than 0")
api_key = os.environ.get("LANGGRAPH_API_KEY")
client = get_client(url=langgraph_url, api_key=api_key)
try:
total_threads = await client.threads.count()
cutoff = datetime.now(UTC) - timedelta(days=days_back)
logger.info(
"Scanning threads from %s created on or after %s",
langgraph_url,
cutoff.isoformat(),
)
state_semaphore = asyncio.Semaphore(concurrency)
unique_pr_urls: set[str] = set()
recent_threads_count = 0
for offset_batch in _iter_offset_batches(total_threads, page_size, concurrency):
page_results = await asyncio.gather(
*[
_fetch_thread_page(client, offset=offset, page_size=page_size)
for offset in offset_batch
]
)
thread_ids: list[str] = []
saw_older_thread = False
for _offset, threads in sorted(page_results, key=lambda result: result[0]):
if not threads:
continue
recent_threads, saw_older_thread = _split_recent_threads(threads, cutoff)
recent_threads_count += len(recent_threads)
for thread in recent_threads:
thread_id = _get_thread_id(thread)
if thread_id:
thread_ids.append(thread_id)
if saw_older_thread:
break
for pr_urls in await asyncio.gather(
*[
_fetch_pr_urls_for_thread(client, thread_id, state_semaphore)
for thread_id in thread_ids
]
):
unique_pr_urls.update(pr_urls)
logger.info("Processed %d recent thread(s)", recent_threads_count)
if saw_older_thread:
break
sorted_pr_urls = sorted(unique_pr_urls)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(f"{json.dumps(sorted_pr_urls, indent=2)}\n", encoding="utf-8")
logger.info("Total threads in deployment: %d", total_threads)
logger.info("Threads in last %d days: %d", days_back, recent_threads_count)
logger.info("Wrote %d unique PR URL(s) to %s", len(sorted_pr_urls), output_path)
return sorted_pr_urls
finally:
await client.aclose()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Export unique PR URLs from commit_and_open_pr tool messages."
)
parser.add_argument(
"--output",
default=DEFAULT_OUTPUT_PATH,
help=f"Path to the output JSON file. Defaults to {DEFAULT_OUTPUT_PATH!r}.",
)
parser.add_argument(
"--langgraph-url",
default=None,
help="LangGraph deployment URL. Defaults to LANGGRAPH_URL or LANGGRAPH_URL_PROD.",
)
parser.add_argument(
"--page-size",
type=int,
default=DEFAULT_PAGE_SIZE,
help=f"Threads to fetch per page. Defaults to {DEFAULT_PAGE_SIZE}.",
)
parser.add_argument(
"--concurrency",
type=int,
default=DEFAULT_CONCURRENCY,
help=f"Concurrent LangGraph page/state requests per batch. Defaults to {DEFAULT_CONCURRENCY}.",
)
parser.add_argument(
"--days-back",
type=int,
default=DEFAULT_DAYS_BACK,
help=f"Only include threads created in the last N days. Defaults to {DEFAULT_DAYS_BACK}.",
)
return parser.parse_args()
def main() -> None:
_load_dotenv_if_available()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
args = parse_args()
asyncio.run(
export_pr_urls(
langgraph_url=get_langgraph_url(args.langgraph_url),
output_path=Path(args.output),
page_size=args.page_size,
concurrency=args.concurrency,
days_back=args.days_back,
)
)
if __name__ == "__main__":
main()

View file

@ -1,128 +0,0 @@
"""Tests that commit_and_open_pr returns a PERMANENT_FAILURE message on 403 push errors."""
from unittest.mock import AsyncMock, MagicMock, patch
from deepagents.backends.protocol import ExecuteResponse
def _make_push_result(exit_code: int, output: str) -> ExecuteResponse:
return ExecuteResponse(output=output, exit_code=exit_code, truncated=False)
def _make_config(thread_id: str = "test-thread") -> dict:
return {
"configurable": {
"thread_id": thread_id,
"repo": {"owner": "langchain-ai", "name": "docs"},
},
"metadata": {},
}
@patch("agent.tools.commit_and_open_pr.get_config")
@patch("agent.tools.commit_and_open_pr.get_sandbox_backend_sync")
@patch("agent.tools.commit_and_open_pr.resolve_repo_dir", return_value="/repo/docs")
@patch("agent.tools.commit_and_open_pr.git_has_uncommitted_changes", return_value=False)
@patch("agent.tools.commit_and_open_pr.git_fetch_origin")
@patch("agent.tools.commit_and_open_pr.git_has_unpushed_commits", return_value=True)
@patch("agent.tools.commit_and_open_pr.git_current_branch", return_value="open-swe/test-thread")
@patch("agent.tools.commit_and_open_pr.git_checkout_branch", return_value=True)
@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.get_github_token", return_value="ghp_token")
@patch(
"agent.tools.commit_and_open_pr.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="ghs_token",
)
@patch("agent.tools.commit_and_open_pr.git_push")
def test_403_push_returns_permanent_failure(
mock_git_push,
mock_get_installation_token,
mock_get_token,
mock_git_add_all,
mock_git_config_user,
mock_git_checkout_branch,
mock_git_current_branch,
mock_git_has_unpushed,
mock_git_fetch_origin,
mock_git_has_uncommitted,
mock_resolve_repo_dir,
mock_get_sandbox,
mock_get_config,
) -> None:
from agent.tools.commit_and_open_pr import commit_and_open_pr
mock_get_config.return_value = _make_config()
mock_get_sandbox.return_value = MagicMock()
mock_git_push.return_value = _make_push_result(
exit_code=128,
output=(
"remote: Permission to langchain-ai/docs.git denied to hinthornw.\n"
"fatal: unable to access 'https://github.com/langchain-ai/docs/': "
"The requested URL returned error: 403"
),
)
result = commit_and_open_pr(
title="fix: something", body="## Description\nfoo\n\n## Test Plan\n- [ ] check"
)
assert result["success"] is False
assert result["pr_url"] is None
error = result["error"]
assert "PERMANENT_FAILURE" in error
assert "do not retry" in error
assert "403" in error
@patch("agent.tools.commit_and_open_pr.get_config")
@patch("agent.tools.commit_and_open_pr.get_sandbox_backend_sync")
@patch("agent.tools.commit_and_open_pr.resolve_repo_dir", return_value="/repo/docs")
@patch("agent.tools.commit_and_open_pr.git_has_uncommitted_changes", return_value=False)
@patch("agent.tools.commit_and_open_pr.git_fetch_origin")
@patch("agent.tools.commit_and_open_pr.git_has_unpushed_commits", return_value=True)
@patch("agent.tools.commit_and_open_pr.git_current_branch", return_value="open-swe/test-thread")
@patch("agent.tools.commit_and_open_pr.git_checkout_branch", return_value=True)
@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.get_github_token", return_value="ghp_token")
@patch(
"agent.tools.commit_and_open_pr.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="ghs_token",
)
@patch("agent.tools.commit_and_open_pr.git_push")
def test_non_403_push_failure_returns_regular_error(
mock_git_push,
mock_get_installation_token,
mock_get_token,
mock_git_add_all,
mock_git_config_user,
mock_git_checkout_branch,
mock_git_current_branch,
mock_git_has_unpushed,
mock_git_fetch_origin,
mock_git_has_uncommitted,
mock_resolve_repo_dir,
mock_get_sandbox,
mock_get_config,
) -> None:
from agent.tools.commit_and_open_pr import commit_and_open_pr
mock_get_config.return_value = _make_config()
mock_get_sandbox.return_value = MagicMock()
mock_git_push.return_value = _make_push_result(
exit_code=1,
output="error: failed to push some refs to 'origin'",
)
result = commit_and_open_pr(
title="fix: something", body="## Description\nfoo\n\n## Test Plan\n- [ ] check"
)
assert result["success"] is False
assert result["pr_url"] is None
error = result["error"]
assert "PERMANENT_FAILURE" not in error
assert error.startswith("Git push failed:")

View file

@ -1,119 +0,0 @@
"""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"]

View file

@ -1,195 +0,0 @@
from __future__ import annotations
import asyncio
import importlib
from typing import Any
from unittest.mock import AsyncMock
import pytest
from agent.tools import edit_pull_request as edit_pull_request_tool
from agent.utils import github
edit_pull_request_module = importlib.import_module("agent.tools.edit_pull_request")
class _FakeResponse:
def __init__(self, status_code: int, payload: dict[str, Any]) -> None:
self.status_code = status_code
self._payload = payload
def json(self) -> dict[str, Any]:
return self._payload
class _FakeAsyncClient:
def __init__(
self,
responses: list[_FakeResponse],
calls: list[tuple[str, str, dict[str, str], dict[str, str] | None]],
) -> None:
self._responses = responses
self._calls = calls
async def __aenter__(self) -> _FakeAsyncClient:
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
async def patch(
self,
url: str,
*,
headers: dict[str, str],
json: dict[str, str] | None = None,
) -> _FakeResponse:
self._calls.append(("PATCH", url, headers, json))
return self._responses.pop(0)
def _config() -> dict[str, Any]:
return {"configurable": {"repo": {"owner": "owner", "name": "repo"}}, "metadata": {}}
def test_edit_pull_request_requires_repo_config(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(edit_pull_request_module, "get_config", lambda: {"configurable": {}})
result = edit_pull_request_tool(pr_number=12, title="new title")
assert result == {
"success": False,
"error": "Missing repo owner/name in config",
"pr_url": None,
}
def test_edit_pull_request_requires_update_field(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(edit_pull_request_module, "get_config", _config)
result = edit_pull_request_tool(pr_number=12)
assert result == {
"success": False,
"error": "At least one of title or body must be provided",
"pr_url": None,
}
def test_edit_pull_request_prefers_user_token(monkeypatch: pytest.MonkeyPatch) -> None:
edit_mock = AsyncMock(return_value=("https://github.com/owner/repo/pull/12", 12))
app_token_mock = AsyncMock(return_value="app-token")
monkeypatch.setattr(edit_pull_request_module, "get_config", _config)
monkeypatch.setattr(edit_pull_request_module, "get_github_token", lambda config: "user-token")
monkeypatch.setattr(
edit_pull_request_module,
"get_github_app_installation_token",
app_token_mock,
)
monkeypatch.setattr(edit_pull_request_module, "edit_github_pr", edit_mock)
result = edit_pull_request_tool(pr_number=12, title="new title")
assert result == {
"success": True,
"error": None,
"pr_url": "https://github.com/owner/repo/pull/12",
}
app_token_mock.assert_not_called()
edit_mock.assert_awaited_once_with(
repo_owner="owner",
repo_name="repo",
github_token="user-token",
pr_number=12,
title="new title",
body=None,
)
def test_edit_pull_request_falls_back_to_app_token(monkeypatch: pytest.MonkeyPatch) -> None:
edit_mock = AsyncMock(return_value=("https://github.com/owner/repo/pull/12", 12))
monkeypatch.setattr(edit_pull_request_module, "get_config", _config)
monkeypatch.setattr(edit_pull_request_module, "get_github_token", lambda config: None)
monkeypatch.setattr(
edit_pull_request_module,
"get_github_app_installation_token",
AsyncMock(return_value="app-token"),
)
monkeypatch.setattr(edit_pull_request_module, "edit_github_pr", edit_mock)
result = edit_pull_request_tool(pr_number=12, body="new body")
assert result["success"] is True
edit_mock.assert_awaited_once_with(
repo_owner="owner",
repo_name="repo",
github_token="app-token",
pr_number=12,
title=None,
body="new body",
)
def test_edit_pull_request_fails_without_any_token(monkeypatch: pytest.MonkeyPatch) -> None:
edit_mock = AsyncMock()
monkeypatch.setattr(edit_pull_request_module, "get_config", _config)
monkeypatch.setattr(edit_pull_request_module, "get_github_token", lambda config: None)
monkeypatch.setattr(
edit_pull_request_module,
"get_github_app_installation_token",
AsyncMock(return_value=None),
)
monkeypatch.setattr(edit_pull_request_module, "edit_github_pr", edit_mock)
result = edit_pull_request_tool(pr_number=12, title="new title")
assert result == {"success": False, "error": "Missing GitHub token", "pr_url": None}
edit_mock.assert_not_called()
def test_edit_github_pr_sends_partial_patch_payload(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, str, dict[str, str], dict[str, str] | None]] = []
responses = [_FakeResponse(200, {"html_url": "https://github.com/o/r/pull/12", "number": 12})]
monkeypatch.setattr(github.httpx, "AsyncClient", lambda: _FakeAsyncClient(responses, calls))
result = asyncio.run(
github.edit_github_pr(
repo_owner="o",
repo_name="r",
github_token="token",
pr_number=12,
title="new title",
)
)
assert result == ("https://github.com/o/r/pull/12", 12)
assert calls == [
(
"PATCH",
"https://api.github.com/repos/o/r/pulls/12",
{
"Authorization": "Bearer token",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
{"title": "new title"},
)
]
def test_edit_github_pr_returns_none_on_api_failure(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, str, dict[str, str], dict[str, str] | None]] = []
responses = [_FakeResponse(404, {"message": "Not Found"})]
monkeypatch.setattr(github.httpx, "AsyncClient", lambda: _FakeAsyncClient(responses, calls))
result = asyncio.run(
github.edit_github_pr(
repo_owner="o",
repo_name="r",
github_token="token",
pr_number=12,
body="new body",
)
)
assert result == (None, None)

View file

@ -4,7 +4,6 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from agent.middleware.ensure_no_empty_msg import (
check_if_confirming_completion,
check_if_model_already_called_commit_and_open_pr,
check_if_model_messaged_user,
ensure_no_empty_msg,
get_every_message_since_last_human,
@ -71,35 +70,6 @@ class TestGetEveryMessageSinceLastHuman:
assert result[2].content == "ai 2"
class TestCheckIfModelAlreadyCalledCommitAndOpenPr:
def test_returns_true_when_commit_and_open_pr_called(self) -> None:
messages = [
AIMessage(content="opening pr"),
ToolMessage(content="PR opened", tool_call_id="123", name="commit_and_open_pr"),
]
assert check_if_model_already_called_commit_and_open_pr(messages) is True
def test_returns_false_when_not_called(self) -> None:
messages = [
AIMessage(content="doing something"),
ToolMessage(content="done", tool_call_id="123", name="bash"),
]
assert check_if_model_already_called_commit_and_open_pr(messages) is False
def test_returns_false_for_empty_list(self) -> None:
assert check_if_model_already_called_commit_and_open_pr([]) is False
def test_ignores_non_tool_messages(self) -> None:
messages = [
AIMessage(content="commit_and_open_pr"),
HumanMessage(content="commit_and_open_pr"),
]
assert check_if_model_already_called_commit_and_open_pr(messages) is False
class TestCheckIfModelMessagedUser:
def test_returns_true_for_slack_thread_reply(self) -> None:
messages = [
@ -115,13 +85,6 @@ class TestCheckIfModelMessagedUser:
assert check_if_model_messaged_user(messages) is True
def test_returns_true_for_github_comment(self) -> None:
messages = [
ToolMessage(content="commented", tool_call_id="123", name="github_comment"),
]
assert check_if_model_messaged_user(messages) is True
def test_returns_false_for_other_tools(self) -> None:
messages = [
ToolMessage(content="result", tool_call_id="123", name="bash"),
@ -163,74 +126,11 @@ class TestCheckIfConfirmingCompletion:
assert check_if_confirming_completion(messages) is True
class TestEnsureNoEmptyMsgCommitAndNotify:
"""Tests the branch: commit_and_open_pr was called AND user was messaged -> return None."""
class TestEnsureNoEmptyMsgNotify:
def _make_runtime(self) -> MagicMock:
return MagicMock()
def test_returns_none_when_pr_opened_and_user_messaged(self) -> None:
empty_ai = AIMessage(content="")
state = {
"messages": [
HumanMessage(content="fix the bug"),
ToolMessage(content="PR opened", tool_call_id="1", name="commit_and_open_pr"),
ToolMessage(content="message sent", tool_call_id="2", name="slack_thread_reply"),
empty_ai,
]
}
result = ensure_no_empty_msg.after_model(state, self._make_runtime())
assert result is None
def test_returns_none_with_linear_comment_instead_of_slack(self) -> None:
empty_ai = AIMessage(content="")
state = {
"messages": [
HumanMessage(content="fix the bug"),
ToolMessage(content="PR opened", tool_call_id="1", name="commit_and_open_pr"),
ToolMessage(content="commented", tool_call_id="2", name="linear_comment"),
empty_ai,
]
}
result = ensure_no_empty_msg.after_model(state, self._make_runtime())
assert result is None
def test_returns_none_with_github_comment_instead_of_slack(self) -> None:
empty_ai = AIMessage(content="")
state = {
"messages": [
HumanMessage(content="fix the bug"),
ToolMessage(content="PR opened", tool_call_id="1", name="commit_and_open_pr"),
ToolMessage(content="commented", tool_call_id="2", name="github_comment"),
empty_ai,
]
}
result = ensure_no_empty_msg.after_model(state, self._make_runtime())
assert result is None
def test_injects_no_op_when_only_pr_opened_but_user_not_messaged(self) -> None:
empty_ai = AIMessage(content="")
state = {
"messages": [
HumanMessage(content="fix the bug"),
ToolMessage(content="PR opened", tool_call_id="1", name="commit_and_open_pr"),
empty_ai,
]
}
result = ensure_no_empty_msg.after_model(state, self._make_runtime())
assert result is not None
assert len(result["messages"]) == 2
assert result["messages"][0].tool_calls[0]["name"] == "no_op"
def test_injects_no_op_when_only_user_messaged_but_no_pr(self) -> None:
def test_returns_none_when_user_messaged(self) -> None:
empty_ai = AIMessage(content="")
state = {
"messages": [
@ -242,6 +142,48 @@ class TestEnsureNoEmptyMsgCommitAndNotify:
result = ensure_no_empty_msg.after_model(state, self._make_runtime())
assert result is None
def test_returns_none_with_linear_comment(self) -> None:
empty_ai = AIMessage(content="")
state = {
"messages": [
HumanMessage(content="fix the bug"),
ToolMessage(content="commented", tool_call_id="1", name="linear_comment"),
empty_ai,
]
}
result = ensure_no_empty_msg.after_model(state, self._make_runtime())
assert result is None
def test_injects_no_op_when_user_not_messaged(self) -> None:
empty_ai = AIMessage(content="")
state = {
"messages": [
HumanMessage(content="fix the bug"),
ToolMessage(content="result", tool_call_id="1", name="bash"),
empty_ai,
]
}
result = ensure_no_empty_msg.after_model(state, self._make_runtime())
assert result is not None
assert len(result["messages"]) == 2
assert result["messages"][0].tool_calls[0]["name"] == "no_op"
def test_returns_none_when_only_user_messaged(self) -> None:
empty_ai = AIMessage(content="")
state = {
"messages": [
HumanMessage(content="fix the bug"),
ToolMessage(content="message sent", tool_call_id="1", name="slack_thread_reply"),
empty_ai,
]
}
result = ensure_no_empty_msg.after_model(state, self._make_runtime())
assert result is None

View file

@ -1,289 +0,0 @@
"""Tests for get_pr_review_comments tool."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent.tools.get_pr_review_comments import get_pr_review_comments
@pytest.fixture()
def repo_config() -> dict[str, str]:
return {"owner": "langchain-ai", "name": "open-swe"}
@pytest.fixture()
def mock_langgraph_config(repo_config: dict[str, str]) -> MagicMock:
config = MagicMock()
config.get.return_value = {"repo": repo_config}
return config
def _make_pr_comment(
body: str = "LGTM",
login: str = "reviewer",
created_at: str = "2026-03-01T10:00:00Z",
comment_id: int = 1,
) -> dict[str, Any]:
return {
"body": body,
"user": {"login": login},
"created_at": created_at,
"id": comment_id,
}
def _make_review_comment(
body: str = "Nit: rename this",
login: str = "reviewer",
created_at: str = "2026-03-01T11:00:00Z",
comment_id: int = 2,
path: str = "src/foo.py",
line: int = 42,
) -> dict[str, Any]:
return {
"body": body,
"user": {"login": login},
"created_at": created_at,
"id": comment_id,
"path": path,
"line": line,
}
def _make_review(
body: str = "Looks good, minor nit",
login: str = "reviewer",
submitted_at: str = "2026-03-01T12:00:00Z",
review_id: int = 3,
) -> dict[str, Any]:
return {
"body": body,
"user": {"login": login},
"submitted_at": submitted_at,
"id": review_id,
}
class TestGetPrReviewComments:
"""Tests for the get_pr_review_comments tool."""
def test_returns_formatted_comments_from_all_three_sources(
self, mock_langgraph_config: MagicMock, repo_config: dict[str, str]
) -> None:
pr_comments = [_make_pr_comment(body="Thread comment", comment_id=10)]
review_comments = [_make_review_comment(body="Inline comment", comment_id=20)]
reviews = [_make_review(body="Review body", review_id=30)]
with (
patch(
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
),
patch(
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="ghs_token123",
),
patch(
"agent.tools.get_pr_review_comments._fetch_paginated",
new_callable=AsyncMock,
side_effect=[pr_comments, review_comments, reviews],
),
):
result = get_pr_review_comments(pr_number=42)
assert result["success"] is True
assert "comments" in result
assert len(result["comments"]) == 3
bodies = [c["body"] for c in result["comments"]]
assert "Thread comment" in bodies
assert "Inline comment" in bodies
assert "Review body" in bodies
def test_inline_review_comments_include_path_and_line(
self, mock_langgraph_config: MagicMock
) -> None:
review_comments = [_make_review_comment(path="agent/tools/foo.py", line=17)]
with (
patch(
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
),
patch(
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="ghs_token123",
),
patch(
"agent.tools.get_pr_review_comments._fetch_paginated",
new_callable=AsyncMock,
side_effect=[[], review_comments, []],
),
):
result = get_pr_review_comments(pr_number=42)
assert result["success"] is True
assert len(result["comments"]) == 1
comment = result["comments"][0]
assert comment["path"] == "agent/tools/foo.py"
assert comment["line"] == 17
assert comment["type"] == "review_comment"
def test_uses_repo_config_from_get_config_when_no_owner_name_provided(
self, mock_langgraph_config: MagicMock
) -> None:
with (
patch(
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
) as mock_cfg,
patch(
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="ghs_token123",
),
patch(
"agent.tools.get_pr_review_comments._fetch_paginated",
new_callable=AsyncMock,
side_effect=[[], [], []],
) as mock_fetch,
):
get_pr_review_comments(pr_number=5)
mock_cfg.assert_called_once()
# Verify the URLs passed to _fetch_paginated use the configured owner/repo
called_urls = [call.args[1] for call in mock_fetch.call_args_list]
assert all("langchain-ai/open-swe" in url for url in called_urls)
def test_accepts_explicit_repo_owner_and_name(self) -> None:
config = MagicMock()
config.get.return_value = {"repo": {"owner": "other-org", "name": "other-repo"}}
with (
patch("agent.tools.get_pr_review_comments.get_config", return_value=config),
patch(
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="ghs_token123",
),
patch(
"agent.tools.get_pr_review_comments._fetch_paginated",
new_callable=AsyncMock,
side_effect=[[], [], []],
) as mock_fetch,
):
result = get_pr_review_comments(
pr_number=7, repo_owner="explicit-org", repo_name="explicit-repo"
)
assert result["success"] is True
called_urls = [call.args[1] for call in mock_fetch.call_args_list]
assert all("explicit-org/explicit-repo" in url for url in called_urls)
def test_handles_auth_failure_gracefully(self, mock_langgraph_config: MagicMock) -> None:
with (
patch(
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
),
patch(
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
new_callable=AsyncMock,
return_value=None,
),
):
result = get_pr_review_comments(pr_number=42)
assert result["success"] is False
assert "token" in result["error"].lower()
def test_handles_missing_repo_config(self) -> None:
config = MagicMock()
config.get.return_value = {} # no "repo" key
with patch("agent.tools.get_pr_review_comments.get_config", return_value=config):
result = get_pr_review_comments(pr_number=42)
assert result["success"] is False
assert "repo" in result["error"].lower()
def test_skips_reviews_with_empty_body(self, mock_langgraph_config: MagicMock) -> None:
reviews = [
_make_review(body="", review_id=1),
_make_review(body="Approved", review_id=2),
]
with (
patch(
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
),
patch(
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="ghs_token123",
),
patch(
"agent.tools.get_pr_review_comments._fetch_paginated",
new_callable=AsyncMock,
side_effect=[[], [], reviews],
),
):
result = get_pr_review_comments(pr_number=42)
assert result["success"] is True
assert len(result["comments"]) == 1
assert result["comments"][0]["body"] == "Approved"
def test_comments_sorted_chronologically(self, mock_langgraph_config: MagicMock) -> None:
pr_comments = [
_make_pr_comment(body="Third", created_at="2026-03-01T13:00:00Z", comment_id=3)
]
review_comments = [
_make_review_comment(body="First", created_at="2026-03-01T10:00:00Z", comment_id=1)
]
reviews = [_make_review(body="Second", submitted_at="2026-03-01T12:00:00Z", review_id=2)]
with (
patch(
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
),
patch(
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="ghs_token123",
),
patch(
"agent.tools.get_pr_review_comments._fetch_paginated",
new_callable=AsyncMock,
side_effect=[pr_comments, review_comments, reviews],
),
):
result = get_pr_review_comments(pr_number=42)
assert result["success"] is True
bodies = [c["body"] for c in result["comments"]]
assert bodies == ["First", "Second", "Third"]
def test_returns_total_count(self, mock_langgraph_config: MagicMock) -> None:
pr_comments = [_make_pr_comment(comment_id=1), _make_pr_comment(comment_id=2)]
with (
patch(
"agent.tools.get_pr_review_comments.get_config", return_value=mock_langgraph_config
),
patch(
"agent.tools.get_pr_review_comments.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="ghs_token123",
),
patch(
"agent.tools.get_pr_review_comments._fetch_paginated",
new_callable=AsyncMock,
side_effect=[pr_comments, [], []],
),
):
result = get_pr_review_comments(pr_number=42)
assert result["success"] is True
assert result["total"] == 2

View file

@ -1,422 +0,0 @@
"""Tests for the github_ci tools."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import agent.tools.github_ci as github_ci
def _make_response(status_code: int, json_data: Any) -> MagicMock:
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = json_data
resp.text = str(json_data)
return resp
def _check_run(
run_id: int, conclusion: str = "success", status: str = "completed"
) -> dict[str, Any]:
return {
"id": run_id,
"name": f"job-{run_id}",
"status": status,
"conclusion": conclusion,
"html_url": f"https://github.com/checks/{run_id}",
}
# ---------------------------------------------------------------------------
# get_pr_check_runs
# ---------------------------------------------------------------------------
def test_get_pr_check_runs_no_repo_config(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(github_ci, "_get_repo_config", lambda: {})
result = github_ci.get_pr_check_runs(42)
assert result == {"success": False, "error": "No repo config found"}
def test_get_pr_check_runs_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"}
)
monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value=None))
result = github_ci.get_pr_check_runs(42)
assert result == {"success": False, "error": "Failed to get GitHub App installation token"}
def test_get_pr_check_runs_success(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"}
)
monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token"))
pr_data = {"head": {"sha": "abc123"}}
check_runs_data = {
"total_count": 2,
"check_runs": [
{
"id": 1,
"name": "test-job",
"status": "completed",
"conclusion": "success",
"html_url": "https://github.com/checks/1",
},
{
"id": 2,
"name": "lint-job",
"status": "completed",
"conclusion": "success",
"html_url": "https://github.com/checks/2",
},
],
}
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=[
_make_response(200, pr_data),
_make_response(200, check_runs_data),
]
)
mock_async_context = MagicMock()
mock_async_context.__aenter__ = AsyncMock(return_value=mock_client)
mock_async_context.__aexit__ = AsyncMock(return_value=False)
with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context):
result = github_ci.get_pr_check_runs(42)
assert result["success"] is True
assert result["head_sha"] == "abc123"
assert result["total_count"] == 2
assert len(result["check_runs"]) == 2
assert result["all_passed"] is True
assert result["any_failed"] is False
assert result["any_pending"] is False
def test_get_pr_check_runs_with_failure(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"}
)
monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token"))
pr_data = {"head": {"sha": "def456"}}
check_runs_data = {
"total_count": 2,
"check_runs": [
{
"id": 10,
"name": "test-job",
"status": "completed",
"conclusion": "failure",
"html_url": "https://github.com/checks/10",
},
{
"id": 11,
"name": "lint-job",
"status": "in_progress",
"conclusion": None,
"html_url": "https://github.com/checks/11",
},
],
}
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=[
_make_response(200, pr_data),
_make_response(200, check_runs_data),
]
)
mock_async_context = MagicMock()
mock_async_context.__aenter__ = AsyncMock(return_value=mock_client)
mock_async_context.__aexit__ = AsyncMock(return_value=False)
with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context):
result = github_ci.get_pr_check_runs(42)
assert result["success"] is True
assert result["any_failed"] is True
assert result["any_pending"] is True
assert result["all_passed"] is False
def test_get_pr_check_runs_empty_checks_not_all_passed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"}
)
monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token"))
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=[
_make_response(200, {"head": {"sha": "empty123"}}),
_make_response(200, {"total_count": 0, "check_runs": []}),
]
)
mock_async_context = MagicMock()
mock_async_context.__aenter__ = AsyncMock(return_value=mock_client)
mock_async_context.__aexit__ = AsyncMock(return_value=False)
with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context):
result = github_ci.get_pr_check_runs(42)
assert result["success"] is True
assert result["total_count"] == 0
assert result["check_runs"] == []
assert result["all_passed"] is False
assert result["any_failed"] is False
assert result["any_pending"] is False
def test_get_pr_check_runs_paginates_before_summarizing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"}
)
monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token"))
first_page_runs = [_check_run(run_id) for run_id in range(1, 101)]
second_page_runs = [_check_run(101, conclusion="failure")]
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=[
_make_response(200, {"head": {"sha": "paged123"}}),
_make_response(200, {"total_count": 101, "check_runs": first_page_runs}),
_make_response(200, {"total_count": 101, "check_runs": second_page_runs}),
]
)
mock_async_context = MagicMock()
mock_async_context.__aenter__ = AsyncMock(return_value=mock_client)
mock_async_context.__aexit__ = AsyncMock(return_value=False)
with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context):
result = github_ci.get_pr_check_runs(42)
assert result["success"] is True
assert result["total_count"] == 101
assert len(result["check_runs"]) == 101
assert result["any_failed"] is True
assert result["all_passed"] is False
def test_get_pr_check_runs_pr_fetch_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"}
)
monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token"))
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=_make_response(404, {"message": "Not Found"}))
mock_async_context = MagicMock()
mock_async_context.__aenter__ = AsyncMock(return_value=mock_client)
mock_async_context.__aexit__ = AsyncMock(return_value=False)
with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context):
result = github_ci.get_pr_check_runs(42)
assert result["success"] is False
assert "404" in result["error"]
# ---------------------------------------------------------------------------
# rerun_failed_workflow_runs
# ---------------------------------------------------------------------------
def test_rerun_failed_workflow_runs_no_repo_config(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(github_ci, "_get_repo_config", lambda: {})
result = github_ci.rerun_failed_workflow_runs(42)
assert result == {"success": False, "error": "No repo config found"}
def test_rerun_failed_workflow_runs_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"}
)
monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value=None))
result = github_ci.rerun_failed_workflow_runs(42)
assert result == {"success": False, "error": "Failed to get GitHub App installation token"}
def test_rerun_failed_workflow_runs_no_failures(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"}
)
monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token"))
pr_data = {"head": {"sha": "abc999"}}
workflow_runs_data = {
"workflow_runs": [
{"id": 100, "conclusion": "success"},
]
}
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=[
_make_response(200, pr_data),
_make_response(200, workflow_runs_data),
]
)
mock_async_context = MagicMock()
mock_async_context.__aenter__ = AsyncMock(return_value=mock_client)
mock_async_context.__aexit__ = AsyncMock(return_value=False)
with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context):
result = github_ci.rerun_failed_workflow_runs(42)
assert result["success"] is True
assert result["rerun_run_ids"] == []
assert "No failed workflow runs" in result["message"]
def test_rerun_failed_workflow_runs_with_failures(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"}
)
monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token"))
pr_data = {"head": {"sha": "bbb111"}}
workflow_runs_data = {
"workflow_runs": [
{"id": 200, "conclusion": "failure"},
{"id": 201, "conclusion": "timed_out"},
]
}
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=[
_make_response(200, pr_data),
_make_response(200, workflow_runs_data),
]
)
mock_client.post = AsyncMock(return_value=_make_response(201, {}))
mock_async_context = MagicMock()
mock_async_context.__aenter__ = AsyncMock(return_value=mock_client)
mock_async_context.__aexit__ = AsyncMock(return_value=False)
with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context):
result = github_ci.rerun_failed_workflow_runs(42)
assert result["success"] is True
assert set(result["rerun_run_ids"]) == {200, 201}
assert len(result["rerun_results"]) == 2
assert all(r["success"] for r in result["rerun_results"])
def test_rerun_failed_workflow_runs_paginates_workflow_runs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"}
)
monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token"))
first_page_runs = [{"id": run_id, "conclusion": "success"} for run_id in range(1, 101)]
second_page_runs = [{"id": 999, "conclusion": "failure"}]
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=[
_make_response(200, {"head": {"sha": "paged-rerun"}}),
_make_response(200, {"workflow_runs": first_page_runs}),
_make_response(200, {"workflow_runs": second_page_runs}),
]
)
mock_client.post = AsyncMock(return_value=_make_response(201, {}))
mock_async_context = MagicMock()
mock_async_context.__aenter__ = AsyncMock(return_value=mock_client)
mock_async_context.__aexit__ = AsyncMock(return_value=False)
with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context):
result = github_ci.rerun_failed_workflow_runs(42)
assert result["success"] is True
assert result["rerun_run_ids"] == [999]
assert len(result["rerun_results"]) == 1
def test_get_pr_check_runs_paginated_fetch_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""Surface a non-200 response that occurs on a later page of pagination."""
monkeypatch.setattr(
github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"}
)
monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token"))
first_page_runs = [_check_run(run_id) for run_id in range(1, 101)]
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=[
_make_response(200, {"head": {"sha": "paged-error"}}),
_make_response(200, {"total_count": 200, "check_runs": first_page_runs}),
_make_response(500, {"message": "server error"}),
]
)
mock_async_context = MagicMock()
mock_async_context.__aenter__ = AsyncMock(return_value=mock_client)
mock_async_context.__aexit__ = AsyncMock(return_value=False)
with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context):
result = github_ci.get_pr_check_runs(42)
assert result["success"] is False
assert "500" in result["error"]
def test_rerun_failed_workflow_runs_skips_action_required(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`action_required` runs need manual approval and must not be rerun."""
monkeypatch.setattr(
github_ci, "_get_repo_config", lambda: {"owner": "langchain-ai", "name": "open-swe"}
)
monkeypatch.setattr(github_ci, "_get_token", AsyncMock(return_value="test-token"))
pr_data = {"head": {"sha": "ccc222"}}
workflow_runs_data = {
"workflow_runs": [
{"id": 300, "conclusion": "failure"},
{"id": 301, "conclusion": "action_required"},
{"id": 302, "conclusion": "success"},
]
}
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=[
_make_response(200, pr_data),
_make_response(200, workflow_runs_data),
]
)
mock_client.post = AsyncMock(return_value=_make_response(201, {}))
mock_async_context = MagicMock()
mock_async_context.__aenter__ = AsyncMock(return_value=mock_client)
mock_async_context.__aexit__ = AsyncMock(return_value=False)
with patch("agent.tools.github_ci.httpx.AsyncClient", return_value=mock_async_context):
result = github_ci.rerun_failed_workflow_runs(42)
assert result["success"] is True
assert result["rerun_run_ids"] == [300]
assert mock_client.post.await_count == 1

View file

@ -1,148 +0,0 @@
"""Unit tests for create_github_pr HTTP error fallback behavior."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from agent.utils.github import create_github_pr
@pytest.mark.asyncio
async def test_create_github_pr_existing_pr_preserves_description():
"""Existing PRs should be reused without overwriting their title or body."""
existing_pr_url = "https://github.com/owner/repo/pull/42"
existing_pr_number = 42
create_response = MagicMock()
create_response.status_code = 422
create_response.json.return_value = {"message": "Validation Failed"}
mock_client = MagicMock()
mock_client.post = AsyncMock(return_value=create_response)
mock_client.patch = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("httpx.AsyncClient", return_value=mock_client),
patch(
"agent.utils.github._find_existing_pr",
AsyncMock(return_value=(existing_pr_url, existing_pr_number)),
),
patch("agent.utils.github._add_label", AsyncMock()),
):
pr_url, pr_number, pr_existing = await create_github_pr(
repo_owner="owner",
repo_name="repo",
github_token="token",
title="Test PR",
head_branch="feature/test",
base_branch="main",
body="new generated body",
)
assert pr_url == existing_pr_url
assert pr_number == existing_pr_number
assert pr_existing is True
mock_client.patch.assert_not_awaited()
@pytest.mark.asyncio
async def test_create_github_pr_http_error_falls_back_to_existing_pr():
"""When httpx.HTTPError is raised during PR creation, the function should
fall back to _find_existing_pr and return the existing PR if one is found.
Guards against regression where transient network errors after a successful
GitHub PR creation would cause a false 'Failed to create GitHub PR' result.
"""
existing_pr_url = "https://github.com/owner/repo/pull/42"
existing_pr_number = 42
mock_post = AsyncMock(side_effect=httpx.HTTPError("connection reset"))
mock_find = AsyncMock(return_value=(existing_pr_url, existing_pr_number))
mock_client = MagicMock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("httpx.AsyncClient", return_value=mock_client),
patch("agent.utils.github._find_existing_pr", mock_find),
):
pr_url, pr_number, pr_existing = await create_github_pr(
repo_owner="owner",
repo_name="repo",
github_token="token",
title="Test PR",
head_branch="feature/test",
base_branch="main",
body="PR body",
)
assert pr_url == existing_pr_url
assert pr_number == existing_pr_number
assert pr_existing is True
mock_find.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_github_pr_http_error_returns_none_when_no_existing_pr():
"""When httpx.HTTPError is raised and no existing PR is found, return (None, None, False)."""
mock_post = AsyncMock(side_effect=httpx.HTTPError("connection reset"))
mock_find = AsyncMock(return_value=(None, None))
mock_client = MagicMock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("httpx.AsyncClient", return_value=mock_client),
patch("agent.utils.github._find_existing_pr", mock_find),
):
pr_url, pr_number, pr_existing = await create_github_pr(
repo_owner="owner",
repo_name="repo",
github_token="token",
title="Test PR",
head_branch="feature/test",
base_branch="main",
body="PR body",
)
assert pr_url is None
assert pr_number is None
assert pr_existing is False
@pytest.mark.asyncio
async def test_create_github_pr_http_error_returns_none_when_find_also_fails():
"""When httpx.HTTPError is raised and _find_existing_pr also raises, return (None, None, False)."""
mock_post = AsyncMock(side_effect=httpx.HTTPError("connection reset"))
mock_find = AsyncMock(side_effect=Exception("secondary failure"))
mock_client = MagicMock()
mock_client.post = mock_post
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("httpx.AsyncClient", return_value=mock_client),
patch("agent.utils.github._find_existing_pr", mock_find),
):
pr_url, pr_number, pr_existing = await create_github_pr(
repo_owner="owner",
repo_name="repo",
github_token="token",
title="Test PR",
head_branch="feature/test",
base_branch="main",
body="PR body",
)
assert pr_url is None
assert pr_number is None
assert pr_existing is False

View file

@ -55,7 +55,7 @@ def test_build_github_issue_prompt_includes_issue_context() -> None:
assert "Fix the flaky test" in prompt
assert "The test is failing intermittently." in prompt
assert "Please take a look" in prompt
assert "github_comment" in prompt
assert "GH_TOKEN=dummy gh issue comment" in prompt
def test_build_github_issue_followup_prompt_only_includes_comment() -> None:

View file

@ -1,433 +0,0 @@
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from agent.utils import github
class _FakeResponse:
def __init__(self, status_code: int, payload: Any) -> None:
self.status_code = status_code
self._payload = payload
@property
def is_success(self) -> bool:
return 200 <= self.status_code < 300
def json(self) -> Any:
return self._payload
class _FakeAsyncClient:
def __init__(
self, responses: list[_FakeResponse], calls: list[tuple[str, str, dict | None]]
) -> None:
self._responses = responses
self._calls = calls
async def __aenter__(self) -> _FakeAsyncClient:
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
async def post(
self, url: str, *, headers: dict[str, str], json: dict | None = None
) -> _FakeResponse:
self._calls.append(("POST", url, json))
return self._responses.pop(0)
async def get(
self, url: str, *, headers: dict[str, str], params: dict[str, str | int]
) -> _FakeResponse:
self._calls.append(("GET", url, params))
return self._responses.pop(0)
async def patch(
self, url: str, *, headers: dict[str, str], json: dict | None = None
) -> _FakeResponse:
self._calls.append(("PATCH", url, json))
return self._responses.pop(0)
class _RaiseOnLabelPostClient(_FakeAsyncClient):
"""Raises on the second POST (the label call) to simulate network failure."""
async def post(
self, url: str, *, headers: dict[str, str], json: dict | None = None
) -> _FakeResponse:
self._calls.append(("POST", url, json))
if len(self._calls) == 2:
request = github.httpx.Request("POST", url)
raise github.httpx.ConnectError("boom", request=request)
return self._responses.pop(0)
class _AlwaysRaisePostClient(_FakeAsyncClient):
"""Raises on every POST to simulate network failure."""
async def post(
self, url: str, *, headers: dict[str, str], json: dict | None = None
) -> _FakeResponse:
self._calls.append(("POST", url, json))
request = github.httpx.Request("POST", url)
raise github.httpx.ConnectError("boom", request=request)
# -- _add_label tests --
def test_add_label_success(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, str, dict | None]] = []
responses = [_FakeResponse(200, [{"name": "OpenSWE"}])]
client = _FakeAsyncClient(responses, calls)
asyncio.run(github._add_label(client, "o", "r", "token", 12))
assert calls == [
("POST", "https://api.github.com/repos/o/r/issues/12/labels", {"labels": ["OpenSWE"]}),
]
def test_add_label_skips_when_no_pr_number() -> None:
"""Should return immediately without making any API calls."""
async def _run() -> None:
# Pass a mock that would fail if called
await github._add_label(None, "o", "r", "token", None) # type: ignore[arg-type]
asyncio.run(_run())
def test_add_label_does_not_raise_on_api_failure(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, str, dict | None]] = []
responses = [_FakeResponse(403, {"message": "Resource not accessible by integration"})]
client = _FakeAsyncClient(responses, calls)
# Should not raise
asyncio.run(github._add_label(client, "o", "r", "token", 12))
assert len(calls) == 1
def test_add_label_does_not_raise_on_http_error() -> None:
calls: list[tuple[str, str, dict | None]] = []
responses: list[_FakeResponse] = []
client = _AlwaysRaisePostClient(responses, calls)
# The POST will raise — should not propagate
asyncio.run(github._add_label(client, "o", "r", "token", 5))
assert len(calls) == 1
# -- create_github_pr with label tests --
def test_create_pr_adds_label_on_new_pr(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, str, dict | None]] = []
responses = [
_FakeResponse(201, {"html_url": "https://github.com/o/r/pull/12", "number": 12}),
_FakeResponse(200, [{"name": "OpenSWE"}]),
]
monkeypatch.setattr(github.httpx, "AsyncClient", lambda: _FakeAsyncClient(responses, calls))
result = asyncio.run(
github.create_github_pr(
repo_owner="o",
repo_name="r",
github_token="user-token",
title="feat: test",
head_branch="feature",
base_branch="main",
body="body",
installation_token="install-token",
)
)
assert result == ("https://github.com/o/r/pull/12", 12, False)
# First call: create PR with user token, second call: add label with install token
assert calls[0] == (
"POST",
"https://api.github.com/repos/o/r/pulls",
{"title": "feat: test", "head": "feature", "base": "main", "body": "body", "draft": True},
)
assert calls[1] == (
"POST",
"https://api.github.com/repos/o/r/issues/12/labels",
{"labels": ["OpenSWE"]},
)
def test_create_pr_adds_label_on_existing_pr(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, str, dict | None]] = []
responses = [
_FakeResponse(422, {"message": "A pull request already exists"}),
_FakeResponse(200, [{"html_url": "https://github.com/o/r/pull/7", "number": 7}]),
_FakeResponse(200, [{"name": "OpenSWE"}]),
]
monkeypatch.setattr(github.httpx, "AsyncClient", lambda: _FakeAsyncClient(responses, calls))
result = asyncio.run(
github.create_github_pr(
repo_owner="o",
repo_name="r",
github_token="user-token",
title="feat: test",
head_branch="feature",
base_branch="main",
body="body",
installation_token="install-token",
)
)
assert result == ("https://github.com/o/r/pull/7", 7, True)
assert calls[2] == (
"POST",
"https://api.github.com/repos/o/r/issues/7/labels",
{"labels": ["OpenSWE"]},
)
assert [call[0] for call in calls] == ["POST", "GET", "POST"]
def test_create_pr_returns_existing_pr_when_existing_pr_label_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[str, str, dict | None]] = []
responses = [
_FakeResponse(422, {"message": "A pull request already exists"}),
_FakeResponse(200, [{"html_url": "https://github.com/o/r/pull/7", "number": 7}]),
_FakeResponse(403, {"message": "Resource not accessible by integration"}),
]
monkeypatch.setattr(github.httpx, "AsyncClient", lambda: _FakeAsyncClient(responses, calls))
result = asyncio.run(
github.create_github_pr(
repo_owner="o",
repo_name="r",
github_token="token",
title="feat: test",
head_branch="feature",
base_branch="main",
body="body",
)
)
assert result == ("https://github.com/o/r/pull/7", 7, True)
assert calls == [
(
"POST",
"https://api.github.com/repos/o/r/pulls",
{
"title": "feat: test",
"head": "feature",
"base": "main",
"body": "body",
"draft": True,
},
),
(
"GET",
"https://api.github.com/repos/o/r/pulls",
{"head": "o:feature", "state": "open", "per_page": 1},
),
(
"POST",
"https://api.github.com/repos/o/r/issues/7/labels",
{"labels": ["OpenSWE"]},
),
]
def test_create_pr_preserves_existing_pr_metadata_without_token_retry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[str, str, dict | None]] = []
responses = [
_FakeResponse(422, {"message": "A pull request already exists"}),
_FakeResponse(200, [{"html_url": "https://github.com/o/r/pull/7", "number": 7}]),
_FakeResponse(200, [{"name": "OpenSWE"}]),
]
monkeypatch.setattr(github.httpx, "AsyncClient", lambda: _FakeAsyncClient(responses, calls))
result = asyncio.run(
github.create_github_pr(
repo_owner="o",
repo_name="r",
github_token="user-token",
title="feat: test",
head_branch="feature",
base_branch="main",
body="body",
installation_token="install-token",
)
)
assert result == ("https://github.com/o/r/pull/7", 7, True)
assert [call[0] for call in calls] == ["POST", "GET", "POST"]
def test_create_pr_succeeds_when_label_fails(monkeypatch: pytest.MonkeyPatch) -> None:
"""PR creation should succeed even if labeling fails."""
calls: list[tuple[str, str, dict | None]] = []
responses = [
_FakeResponse(201, {"html_url": "https://github.com/o/r/pull/12", "number": 12}),
]
monkeypatch.setattr(
github.httpx, "AsyncClient", lambda: _RaiseOnLabelPostClient(responses, calls)
)
result = asyncio.run(
github.create_github_pr(
repo_owner="o",
repo_name="r",
github_token="token",
title="feat: test",
head_branch="feature",
base_branch="main",
body="body",
)
)
assert result == ("https://github.com/o/r/pull/12", 12, False)
def test_create_pr_uses_github_token_for_label_when_no_installation_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When installation_token is not provided, github_token is used for labeling."""
calls: list[tuple[str, str, dict | None]] = []
responses = [
_FakeResponse(201, {"html_url": "https://github.com/o/r/pull/12", "number": 12}),
_FakeResponse(200, [{"name": "OpenSWE"}]),
]
monkeypatch.setattr(github.httpx, "AsyncClient", lambda: _FakeAsyncClient(responses, calls))
result = asyncio.run(
github.create_github_pr(
repo_owner="o",
repo_name="r",
github_token="token",
title="feat: test",
head_branch="feature",
base_branch="main",
body="body",
)
)
assert result == ("https://github.com/o/r/pull/12", 12, False)
# Both calls made — label uses the same token
assert len(calls) == 2
def test_create_pr_falls_back_to_installation_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When user token fails, retries with installation token."""
calls: list[tuple[str, str, dict | None]] = []
responses = [
# First attempt with user token → 403
_FakeResponse(403, {"message": "Resource not accessible by integration"}),
# Second attempt with installation token → 201
_FakeResponse(201, {"html_url": "https://github.com/o/r/pull/12", "number": 12}),
# Label
_FakeResponse(200, [{"name": "OpenSWE"}]),
]
monkeypatch.setattr(github.httpx, "AsyncClient", lambda: _FakeAsyncClient(responses, calls))
result = asyncio.run(
github.create_github_pr(
repo_owner="o",
repo_name="r",
github_token="user-token",
title="feat: test",
head_branch="feature",
base_branch="main",
body="body",
installation_token="install-token",
)
)
assert result == ("https://github.com/o/r/pull/12", 12, False)
# 3 calls: failed PR create, successful PR create, label
assert len(calls) == 3
def test_create_pr_falls_back_on_http_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When user token raises HTTPError, retries with installation token."""
calls: list[tuple[str, str, dict | None]] = []
responses = [
# Installation token succeeds
_FakeResponse(201, {"html_url": "https://github.com/o/r/pull/12", "number": 12}),
# Label
_FakeResponse(200, [{"name": "OpenSWE"}]),
]
class _RaiseFirstPostClient(_FakeAsyncClient):
"""Raises on the first POST only (user token), then delegates to normal behavior."""
_first = True
async def post(
self, url: str, *, headers: dict[str, str], json: dict | None = None
) -> _FakeResponse:
self._calls.append(("POST", url, json))
if self._first:
self._first = False
request = github.httpx.Request("POST", url)
raise github.httpx.ConnectError("boom", request=request)
return self._responses.pop(0)
monkeypatch.setattr(
github.httpx, "AsyncClient", lambda: _RaiseFirstPostClient(responses, calls)
)
result = asyncio.run(
github.create_github_pr(
repo_owner="o",
repo_name="r",
github_token="user-token",
title="feat: test",
head_branch="feature",
base_branch="main",
body="body",
installation_token="install-token",
)
)
assert result == ("https://github.com/o/r/pull/12", 12, False)
# 3 calls: failed POST (user token), successful POST (install token), label POST
assert len(calls) == 3
def test_create_pr_no_fallback_when_tokens_are_same(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When github_token == installation_token, no retry happens."""
calls: list[tuple[str, str, dict | None]] = []
responses = [
_FakeResponse(403, {"message": "Resource not accessible by integration"}),
]
monkeypatch.setattr(github.httpx, "AsyncClient", lambda: _FakeAsyncClient(responses, calls))
result = asyncio.run(
github.create_github_pr(
repo_owner="o",
repo_name="r",
github_token="same-token",
title="feat: test",
head_branch="feature",
base_branch="main",
body="body",
installation_token="same-token",
)
)
assert result == (None, None, False)
# Only 1 call — no retry since tokens are identical
assert len(calls) == 1

View file

@ -1,40 +0,0 @@
"""Tests for github_review tool guards."""
from unittest.mock import patch
from agent.tools.github_review import create_pr_review, submit_pr_review
class TestApproveBlocked:
"""APPROVE event must be rejected in both create and submit."""
def test_create_pr_review_blocks_approve(self):
result = create_pr_review(pull_number=1, body="lgtm", event="APPROVE")
assert result["success"] is False
assert "APPROVE is not allowed" in result["error"]
def test_submit_pr_review_blocks_approve(self):
result = submit_pr_review(pull_number=1, review_id=1, event="APPROVE")
assert result["success"] is False
assert "APPROVE is not allowed" in result["error"]
def test_create_pr_review_blocks_approve_lowercase(self):
result = create_pr_review(pull_number=1, body="lgtm", event="approve")
assert result["success"] is False
assert "APPROVE is not allowed" in result["error"]
def test_submit_pr_review_blocks_approve_mixed_case(self):
result = submit_pr_review(pull_number=1, review_id=1, event="Approve")
assert result["success"] is False
assert "APPROVE is not allowed" in result["error"]
@patch("agent.tools.github_review._get_repo_config", return_value=None)
def test_create_pr_review_allows_comment(self, _mock):
result = create_pr_review(pull_number=1, body="looks good", event="COMMENT")
# Will fail with "No repo config found" but NOT with the approve error
assert "APPROVE is not allowed" not in result.get("error", "")
@patch("agent.tools.github_review._get_repo_config", return_value=None)
def test_create_pr_review_allows_request_changes(self, _mock):
result = create_pr_review(pull_number=1, body="fix this", event="REQUEST_CHANGES")
assert "APPROVE is not allowed" not in result.get("error", "")

View file

@ -1,48 +0,0 @@
from __future__ import annotations
import shlex
from types import SimpleNamespace
from agent.utils import github
class FakeSandboxBackend:
def __init__(self) -> None:
self.commands: list[str] = []
self.writes: list[tuple[str, str]] = []
def execute(self, command: str) -> SimpleNamespace:
self.commands.append(command)
return SimpleNamespace(exit_code=0, output="")
def write(self, path: str, content: str) -> None:
self.writes.append((path, content))
def test_git_checkout_existing_branch_quotes_repo_dir_and_branch() -> None:
sandbox = FakeSandboxBackend()
repo_dir = "/tmp/repo; curl attacker"
branch = "main; curl attacker"
github.git_checkout_existing_branch(sandbox, repo_dir, branch)
assert sandbox.commands == [f"cd {shlex.quote(repo_dir)} && git checkout {shlex.quote(branch)}"]
def test_git_checkout_branch_returns_true_on_success() -> None:
sandbox = FakeSandboxBackend()
ok, err = github.git_checkout_branch(sandbox, "/tmp/repo", "my-branch")
assert ok is True
assert err == ""
def test_git_checkout_branch_returns_false_with_error_output_on_failure() -> None:
class FailingSandbox(FakeSandboxBackend):
def execute(self, command: str) -> SimpleNamespace:
self.commands.append(command)
return SimpleNamespace(exit_code=1, output="error: pathspec did not match")
sandbox = FailingSandbox()
ok, err = github.git_checkout_branch(sandbox, "/tmp/repo", "my-branch")
assert ok is False
assert "pathspec did not match" in err

View file

@ -1,384 +0,0 @@
"""Tests for the open_pr_if_needed after-agent middleware.
Verifies that the safety net middleware correctly fires (or skips) based on
the success value from commit_and_open_pr tool results.
"""
import json
from contextlib import ExitStack
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from agent.middleware.open_pr import _extract_pr_params_from_messages, open_pr_if_needed
class TestExtractPrParamsFromMessages:
"""Tests for the helper that parses commit_and_open_pr tool results."""
def test_returns_none_when_no_commit_and_open_pr_message(self) -> None:
messages = [
HumanMessage(content="fix the bug"),
AIMessage(content="sure"),
ToolMessage(content="done", tool_call_id="1", name="bash"),
]
assert _extract_pr_params_from_messages(messages) is None
def test_returns_none_for_empty_messages(self) -> None:
assert _extract_pr_params_from_messages([]) is None
def test_returns_payload_from_commit_and_open_pr_success(self) -> None:
payload = {"success": True, "error": None, "pr_url": "https://github.com/org/repo/pull/42"}
messages = [
ToolMessage(
content=json.dumps(payload),
tool_call_id="1",
name="commit_and_open_pr",
)
]
result = _extract_pr_params_from_messages(messages)
assert result == payload
def test_returns_payload_from_commit_and_open_pr_failure(self) -> None:
payload = {"success": False, "error": "Git push failed: non-fast-forward", "pr_url": None}
messages = [
ToolMessage(
content=json.dumps(payload),
tool_call_id="1",
name="commit_and_open_pr",
)
]
result = _extract_pr_params_from_messages(messages)
assert result == payload
def test_returns_last_commit_and_open_pr_message_when_multiple(self) -> None:
first_payload = {"success": False, "error": "Git push failed", "pr_url": None}
second_payload = {"success": True, "error": None, "pr_url": "https://github.com/pr/2"}
messages = [
ToolMessage(
content=json.dumps(first_payload),
tool_call_id="1",
name="commit_and_open_pr",
),
ToolMessage(
content=json.dumps(second_payload),
tool_call_id="2",
name="commit_and_open_pr",
),
]
result = _extract_pr_params_from_messages(messages)
# reversed() returns the last one first
assert result == second_payload
def test_ignores_other_tool_names(self) -> None:
messages = [
ToolMessage(content='{"success": true}', tool_call_id="1", name="bash"),
ToolMessage(content='{"success": true}', tool_call_id="2", name="git_push"),
]
assert _extract_pr_params_from_messages(messages) is None
class TestOpenPrIfNeededMiddleware:
"""Tests for the open_pr_if_needed after-agent safety net middleware.
The middleware should:
- Return None (skip) when commit_and_open_pr succeeded (success=True)
- Proceed (attempt to create PR) when commit_and_open_pr failed (success=False)
- Return None (skip) when no commit_and_open_pr call is found in messages
"""
def _make_runtime(self) -> MagicMock:
return MagicMock()
def _make_state(self, messages: list) -> dict:
return {"messages": messages}
def _patch_auth_flow(self) -> ExitStack:
stack = ExitStack()
stack.enter_context(
patch("agent.middleware.open_pr.get_github_token", return_value="token")
)
stack.enter_context(
patch("agent.middleware.open_pr.resolve_triggering_user_identity", return_value=None)
)
stack.enter_context(
patch(
"agent.middleware.open_pr.get_github_app_installation_token",
new_callable=AsyncMock,
return_value="installation-token",
)
)
return stack
def test_skips_when_commit_and_open_pr_succeeded(self) -> None:
"""When success=True, the tool handled everything — middleware should be a no-op."""
payload = {"success": True, "error": None, "pr_url": "https://github.com/org/repo/pull/42"}
state = self._make_state(
[
HumanMessage(content="fix the bug"),
ToolMessage(
content=json.dumps(payload),
tool_call_id="1",
name="commit_and_open_pr",
),
]
)
with patch(
"agent.middleware.open_pr.get_config",
return_value={"configurable": {"thread_id": "thread-success"}},
):
result = open_pr_if_needed.after_agent(state, self._make_runtime())
assert result is None
@pytest.mark.asyncio
async def test_skips_when_commit_and_open_pr_failed_permanently(self) -> None:
payload = {
"success": False,
"error": (
"PERMANENT_FAILURE: do not retry. Git push was rejected with a 403 "
"permission denied error."
),
"pr_url": None,
}
state = self._make_state(
[
ToolMessage(
content=json.dumps(payload),
tool_call_id="1",
name="commit_and_open_pr",
)
]
)
with (
patch(
"agent.middleware.open_pr.get_config",
return_value={
"configurable": {
"thread_id": "thread-permanent",
"repo": {"owner": "org", "name": "repo"},
}
},
),
patch(
"agent.middleware.open_pr.get_sandbox_backend", new_callable=AsyncMock
) as mock_sandbox,
):
await open_pr_if_needed.aafter_agent(state, self._make_runtime())
mock_sandbox.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_commit_and_open_pr_failed_fatally(self) -> None:
payload = {
"success": False,
"error": (
"Failed to create GitHub PR. Do not retry this tool — if the push succeeded, "
"the PR may need to be opened manually."
),
"pr_url": None,
"fatal": True,
}
state = self._make_state(
[
ToolMessage(
content=json.dumps(payload),
tool_call_id="1",
name="commit_and_open_pr",
)
]
)
with (
patch(
"agent.middleware.open_pr.get_config",
return_value={
"configurable": {
"thread_id": "thread-fatal",
"repo": {"owner": "org", "name": "repo"},
}
},
),
patch(
"agent.middleware.open_pr.get_sandbox_backend", new_callable=AsyncMock
) as mock_sandbox,
):
await open_pr_if_needed.aafter_agent(state, self._make_runtime())
mock_sandbox.assert_not_called()
@pytest.mark.asyncio
async def test_proceeds_when_commit_and_open_pr_failed_git_push(self) -> None:
"""When success=False due to git push failure, safety net should attempt PR creation."""
payload = {
"success": False,
"error": "Git push failed: Updates were rejected because the remote contains work",
"pr_url": None,
}
state = self._make_state(
[
HumanMessage(content="fix the bug"),
ToolMessage(
content=json.dumps(payload),
tool_call_id="1",
name="commit_and_open_pr",
),
]
)
with patch(
"agent.middleware.open_pr.get_config",
return_value={
"configurable": {
"thread_id": "thread-push-fail",
"repo": {"owner": "org", "name": "repo"},
}
},
):
with patch(
"agent.middleware.open_pr.get_sandbox_backend",
new_callable=AsyncMock,
return_value=MagicMock(),
) as mock_sandbox:
with patch(
"agent.middleware.open_pr.git_has_uncommitted_changes",
return_value=True,
):
with patch(
"agent.middleware.open_pr.git_fetch_origin",
return_value=None,
):
with patch(
"agent.middleware.open_pr.git_has_unpushed_commits",
return_value=False,
):
# Middleware should NOT short-circuit; it reaches sandbox logic
# We verify get_sandbox_backend was called (safety net fired)
with self._patch_auth_flow():
await open_pr_if_needed.aafter_agent(state, self._make_runtime())
# The safety net fired: get_sandbox_backend was called
mock_sandbox.assert_called_once_with("thread-push-fail")
@pytest.mark.asyncio
async def test_proceeds_when_commit_and_open_pr_failed_pr_creation(self) -> None:
"""When success=False due to PR creation failure, safety net should attempt PR creation."""
payload = {
"success": False,
"error": "Failed to create GitHub PR",
"pr_url": None,
}
state = self._make_state(
[
HumanMessage(content="fix the bug"),
ToolMessage(
content=json.dumps(payload),
tool_call_id="1",
name="commit_and_open_pr",
),
]
)
with patch(
"agent.middleware.open_pr.get_config",
return_value={
"configurable": {
"thread_id": "thread-pr-fail",
"repo": {"owner": "org", "name": "repo"},
}
},
):
with patch(
"agent.middleware.open_pr.get_sandbox_backend",
new_callable=AsyncMock,
return_value=MagicMock(),
) as mock_sandbox:
with patch(
"agent.middleware.open_pr.git_has_uncommitted_changes",
return_value=False,
):
with patch(
"agent.middleware.open_pr.git_fetch_origin",
return_value=None,
):
with patch(
"agent.middleware.open_pr.git_has_unpushed_commits",
return_value=True,
):
with self._patch_auth_flow():
await open_pr_if_needed.aafter_agent(state, self._make_runtime())
# The safety net fired: get_sandbox_backend was called
mock_sandbox.assert_called_once_with("thread-pr-fail")
def test_skips_when_no_commit_and_open_pr_call_found(self) -> None:
"""When no commit_and_open_pr call exists in messages, middleware returns None."""
state = self._make_state(
[
HumanMessage(content="fix the bug"),
ToolMessage(content="edited file", tool_call_id="1", name="bash"),
AIMessage(content="done"),
]
)
with patch(
"agent.middleware.open_pr.get_config",
return_value={"configurable": {"thread_id": "thread-no-call"}},
):
result = open_pr_if_needed.after_agent(state, self._make_runtime())
assert result is None
@pytest.mark.asyncio
async def test_does_not_skip_when_success_is_false_not_missing(self) -> None:
"""Regression: key-existence check `'success' in payload` was always True.
This test confirms the fix: checking the VALUE via `.get('success')` means
a payload with success=False will NOT trigger the early return.
"""
payload = {
"success": False,
"error": "Git push failed: remote contains work",
"pr_url": None,
}
state = self._make_state(
[
ToolMessage(
content=json.dumps(payload),
tool_call_id="1",
name="commit_and_open_pr",
)
]
)
reached_sandbox_call = False
async def fake_get_sandbox(thread_id: str):
nonlocal reached_sandbox_call
reached_sandbox_call = True
return None # return None so middleware bails out early after this point
with patch(
"agent.middleware.open_pr.get_config",
return_value={
"configurable": {
"thread_id": "thread-regression",
"repo": {"owner": "org", "name": "repo"},
}
},
):
with patch(
"agent.middleware.open_pr.get_sandbox_backend", side_effect=fake_get_sandbox
):
with self._patch_auth_flow():
await open_pr_if_needed.aafter_agent(state, self._make_runtime())
# If the old buggy `"success" in pr_payload` check was used, the middleware
# would have returned None before reaching get_sandbox_backend.
assert reached_sandbox_call, (
"Safety net middleware returned early due to key-existence check bug; "
"fix should use pr_payload.get('success') to check the VALUE"
)

View file

@ -36,16 +36,24 @@ class TestConfigureGithubProxy:
call_kwargs = mock_client.patch.call_args
payload = call_kwargs.kwargs["json"]
# Verify proxy_config structure
assert "proxy_config" in payload
rules = payload["proxy_config"]["rules"]
assert len(rules) == 1
assert len(rules) == 2
rule = rules[0]
assert rule["name"] == "github"
assert rule["match_hosts"] == ["github.com", "*.github.com"]
api_rule = rules[0]
assert api_rule["name"] == "github-api"
assert api_rule["match_hosts"] == ["api.github.com"]
api_headers = api_rule["headers"]
assert len(api_headers) == 1
assert api_headers[0]["name"] == "Authorization"
assert api_headers[0]["type"] == "opaque"
assert api_headers[0]["value"] == f"Bearer {token}"
headers = rule["headers"]
web_rule = rules[1]
assert web_rule["name"] == "github"
assert web_rule["match_hosts"] == ["github.com", "*.github.com"]
headers = web_rule["headers"]
assert len(headers) == 1
assert headers[0]["name"] == "Authorization"
assert headers[0]["type"] == "opaque"