mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 04:33:12 +00:00
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:
parent
13f5d8a1c9
commit
96774f20ae
42 changed files with 224 additions and 4672 deletions
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
```
|
||||
|
|
|
|||
16
Dockerfile
16
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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}"}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.**"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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:")
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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", "")
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue