mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-01 03:53:14 +00:00
feat: open PRs as the triggering user via dedicated tool (#1378)
Add an open_pull_request tool that creates a new PR via the GitHub REST API using the triggering user's OAuth token (resolved by login from the dashboard store), so the PR creator is the user rather than open-swe[bot]. Falls back to the GitHub App installation token for GitHub-triggered runs, unmapped users, and bot-token-only deployments. The user token never enters the sandbox: clone/push/comments still go through the bot proxy via gh. The agent is steered to use the tool only for OPENING a new PR; updates (body edits, mark ready) and pasted/existing PRs continue to use gh pr edit. Existing-PR (422) returns the open PR's URL so re-runs don't create duplicates.
This commit is contained in:
parent
0a7fae60cd
commit
04c346176d
5 changed files with 388 additions and 6 deletions
|
|
@ -140,10 +140,10 @@ 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** — Commit and push your branch. Open or update a draft pull request with `GH_TOKEN=dummy gh` when the user asks for a PR, when a PR is necessary to deliver or review the changes, or when the Always Create PRs dashboard setting is enabled.
|
||||
4. **Submit** — Commit and push your branch. To OPEN a new draft pull request, call the `open_pull_request` tool (NOT `gh pr create`) so the PR is attributed to the triggering user. To UPDATE an existing PR (body, mark ready, etc.), use `GH_TOKEN=dummy gh pr edit`. Do this when the user asks for a PR, when a PR is necessary to deliver or review the changes, or when the Always Create PRs dashboard setting is enabled.
|
||||
5. **Comment** — Call `linear_comment` or `slack_thread_reply` for Linear/Slack. For GitHub-triggered tasks, comment with `GH_TOKEN=dummy gh`.
|
||||
|
||||
**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.
|
||||
**Strict requirement:** Never claim "PR updated/opened" unless the operation returned success and you have the PR URL — from `open_pull_request`'s returned `url`, from `gh` command output, or from `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):
|
||||
|
||||
|
|
@ -305,8 +305,9 @@ 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 `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 ...` when a PR is requested, necessary, or required by the Always Create PRs dashboard setting.
|
||||
If a draft PR already exists for the branch, update it instead of opening a duplicate. For follow-up changes, add a new commit on top of the existing branch history.
|
||||
3. **Submit**: Commit locally, push with `git push origin <branch>`, then open or update the PR when a PR is requested, necessary, or required by the Always Create PRs dashboard setting.
|
||||
- **Open a new PR** with the `open_pull_request` tool (pass `owner`, `repo`, `head` = your branch, `base`, `title`, `body`). This attributes the PR to the triggering user. Push the branch BEFORE calling it.
|
||||
- **Update an existing PR** (edit the body, mark ready for review, etc.) with `GH_TOKEN=dummy gh pr edit`. If a PR already exists for the branch (including one the user pasted in), do NOT open a duplicate — `open_pull_request` returns the existing PR's URL, so switch to `gh pr edit`. For follow-up changes, add a new commit on top of the existing branch history.
|
||||
|
||||
**PR Title** (under 70 characters):
|
||||
```
|
||||
|
|
@ -335,11 +336,11 @@ When you have completed your implementation, follow these steps in order:
|
|||
|
||||
**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 `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: Never claim a PR was created or updated unless the operation returned success and you have the PR URL — from `open_pull_request`'s returned `url`, from `gh` command output, or from `GH_TOKEN=dummy gh pr view --json url --jq .url`. If there are no changes or any command fails, report that explicitly.**
|
||||
|
||||
**IMPORTANT: Never force-push.** Never run `git push --force` or `git push --force-with-lease`, and never amend or rebase commits that are already on the remote branch — reviewers rely on inter-commit diffs. Add follow-up work as new commits. If a normal push is rejected because the remote branch has new commits, run `git pull --rebase origin <branch>` and push again; if that conflicts, report it and stop.
|
||||
|
||||
**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 `git push`, `open_pull_request`, or `gh pr edit` fails with an infrastructure or permission error, do not retry blindly. Report the failure and end the task.**
|
||||
|
||||
**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.**
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ from .tools import (
|
|||
linear_get_issue_comments,
|
||||
linear_list_teams,
|
||||
linear_update_issue,
|
||||
open_pull_request,
|
||||
request_pr_review,
|
||||
slack_read_thread_messages,
|
||||
slack_thread_reply,
|
||||
|
|
@ -512,6 +513,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
linear_get_issue_comments,
|
||||
linear_list_teams,
|
||||
linear_update_issue,
|
||||
open_pull_request,
|
||||
request_pr_review,
|
||||
slack_read_thread_messages,
|
||||
slack_thread_reply,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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_findings import list_findings
|
||||
from .open_pull_request import open_pull_request
|
||||
from .publish_review import publish_review
|
||||
from .reply_to_finding_thread import reply_to_finding_thread
|
||||
from .request_pr_review import request_pr_review
|
||||
|
|
@ -30,6 +31,7 @@ __all__ = [
|
|||
"linear_list_teams",
|
||||
"linear_update_issue",
|
||||
"list_findings",
|
||||
"open_pull_request",
|
||||
"publish_review",
|
||||
"request_pr_review",
|
||||
"reply_to_finding_thread",
|
||||
|
|
|
|||
169
agent/tools/open_pull_request.py
Normal file
169
agent/tools/open_pull_request.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""Open a GitHub pull request attributed to the triggering user."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from langgraph.config import get_config
|
||||
|
||||
from ..utils.github_app import get_github_app_installation_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GITHUB_API = "https://api.github.com"
|
||||
_USER_TOKEN_SOURCES = ("slack", "dashboard")
|
||||
|
||||
|
||||
async def _resolve_pr_author_token() -> tuple[str | None, str]:
|
||||
"""Return ``(token, kind)`` for opening the PR.
|
||||
|
||||
Prefers the triggering user's OAuth token (so the PR is created *as them*)
|
||||
for Slack/dashboard runs with a mapped GitHub login, resolving it by login
|
||||
from the dashboard OAuth store. Falls back to the GitHub App installation
|
||||
token (creator = open-swe[bot]) for GitHub-triggered runs, unmapped users,
|
||||
or bot-token-only deployments — preserving today's behavior.
|
||||
|
||||
The token is resolved by login rather than read from the shared thread
|
||||
metadata: Slack thread ids are shared across a conversation, so a cached
|
||||
token could belong to a prior triggering user.
|
||||
"""
|
||||
configurable = get_config().get("configurable", {})
|
||||
source = configurable.get("source")
|
||||
github_login = configurable.get("github_login")
|
||||
|
||||
if source in _USER_TOKEN_SOURCES and isinstance(github_login, str) and github_login.strip():
|
||||
from ..dashboard.profiles import get_valid_access_token
|
||||
|
||||
user_token = await get_valid_access_token(github_login.strip())
|
||||
if user_token:
|
||||
return user_token, "user"
|
||||
logger.info("No valid user token for %s; opening PR as open-swe[bot]", github_login.strip())
|
||||
|
||||
return await get_github_app_installation_token(), "bot"
|
||||
|
||||
|
||||
def _auth_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 _find_existing_pr(
|
||||
client: httpx.AsyncClient, token: str, owner: str, repo: str, head: str
|
||||
) -> dict[str, Any] | None:
|
||||
resp = await client.get(
|
||||
f"{GITHUB_API}/repos/{owner}/{repo}/pulls",
|
||||
headers=_auth_headers(token),
|
||||
params={"head": f"{owner}:{head}", "state": "open"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
items = resp.json()
|
||||
return items[0] if isinstance(items, list) and items else None
|
||||
|
||||
|
||||
async def _open_pull_request(
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str,
|
||||
draft: bool,
|
||||
) -> dict[str, Any]:
|
||||
token, kind = await _resolve_pr_author_token()
|
||||
if not token:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "No GitHub token available to open the pull request.",
|
||||
}
|
||||
|
||||
payload = {"title": title, "head": head, "base": base, "body": body, "draft": draft}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{GITHUB_API}/repos/{owner}/{repo}/pulls",
|
||||
headers=_auth_headers(token),
|
||||
json=payload,
|
||||
)
|
||||
if resp.status_code == 201:
|
||||
pr = resp.json()
|
||||
return {
|
||||
"success": True,
|
||||
"created": True,
|
||||
"url": pr.get("html_url"),
|
||||
"number": pr.get("number"),
|
||||
"author": (pr.get("user") or {}).get("login"),
|
||||
"token_kind": kind,
|
||||
}
|
||||
|
||||
# A PR for this head branch may already exist — return it so the agent
|
||||
# switches to `gh pr edit` for updates instead of erroring out.
|
||||
if resp.status_code == 422: # noqa: PLR2004
|
||||
existing = await _find_existing_pr(client, token, owner, repo, head)
|
||||
if existing is not None:
|
||||
return {
|
||||
"success": True,
|
||||
"created": False,
|
||||
"url": existing.get("html_url"),
|
||||
"number": existing.get("number"),
|
||||
"author": (existing.get("user") or {}).get("login"),
|
||||
"token_kind": kind,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"GitHub returned {resp.status_code}: {resp.text}",
|
||||
}
|
||||
|
||||
|
||||
def open_pull_request(
|
||||
owner: str,
|
||||
repo: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str,
|
||||
draft: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Open a draft GitHub pull request attributed to the triggering user.
|
||||
|
||||
Use this to OPEN a NEW pull request (instead of `gh pr create`) so the PR is
|
||||
created as the person who triggered the run rather than open-swe[bot]. Push
|
||||
your branch with `git push origin <branch>` BEFORE calling this.
|
||||
|
||||
For everything else — updating an existing PR, marking it ready for review,
|
||||
commenting, reading status — keep using `GH_TOKEN=dummy gh`. If a PR already
|
||||
exists for the branch, this returns that PR's URL without creating a
|
||||
duplicate; switch to `gh pr edit` for updates.
|
||||
|
||||
Args:
|
||||
owner: Repository owner/org (e.g. "langchain-ai").
|
||||
repo: Repository name (e.g. "open-swe").
|
||||
head: The branch with your changes (already pushed to origin).
|
||||
base: The branch you want to merge into (e.g. "main").
|
||||
title: PR title.
|
||||
body: PR description (Markdown).
|
||||
draft: Open as a draft PR. Defaults to True.
|
||||
|
||||
Returns:
|
||||
On success: {"success": True, "created": bool, "url": str, "number": int,
|
||||
"author": str}. ``created`` is False when an open PR already existed.
|
||||
On failure: {"success": False, "error": str}.
|
||||
"""
|
||||
return asyncio.run(
|
||||
_open_pull_request(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
title=title,
|
||||
body=body,
|
||||
draft=draft,
|
||||
)
|
||||
)
|
||||
208
tests/test_open_pull_request.py
Normal file
208
tests/test_open_pull_request.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import agent.tools.open_pull_request # noqa: F401
|
||||
|
||||
opr = sys.modules["agent.tools.open_pull_request"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, payload: Any = None, text: str = "") -> None:
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *, post: _FakeResponse, get: _FakeResponse | None = None) -> None:
|
||||
self._post = post
|
||||
self._get = get
|
||||
self.post_calls: list[dict[str, Any]] = []
|
||||
self.get_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def __aenter__(self) -> _FakeClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: object) -> None:
|
||||
return None
|
||||
|
||||
async def post(
|
||||
self, url: str, *, headers: dict[str, str], json: dict[str, Any]
|
||||
) -> _FakeResponse:
|
||||
self.post_calls.append({"url": url, "headers": headers, "json": json})
|
||||
return self._post
|
||||
|
||||
async def get(
|
||||
self, url: str, *, headers: dict[str, str], params: dict[str, str]
|
||||
) -> _FakeResponse:
|
||||
self.get_calls.append({"url": url, "headers": headers, "params": params})
|
||||
assert self._get is not None
|
||||
return self._get
|
||||
|
||||
|
||||
def _install_client(monkeypatch: pytest.MonkeyPatch, client: _FakeClient) -> None:
|
||||
monkeypatch.setattr(opr.httpx, "AsyncClient", lambda **_kwargs: client)
|
||||
|
||||
|
||||
def _set_config(monkeypatch: pytest.MonkeyPatch, configurable: dict[str, Any]) -> None:
|
||||
monkeypatch.setattr(opr, "get_config", lambda: {"configurable": configurable})
|
||||
|
||||
|
||||
def _open() -> dict[str, Any]:
|
||||
return asyncio.run(
|
||||
opr._open_pull_request(
|
||||
owner="langchain-ai",
|
||||
repo="open-swe",
|
||||
head="open-swe/feature",
|
||||
base="main",
|
||||
title="feat: x",
|
||||
body="body",
|
||||
draft=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_uses_user_token_for_slack_with_login(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"})
|
||||
|
||||
from agent.dashboard import profiles
|
||||
|
||||
async def fake_user_token(login: str, **_kw: Any) -> str | None:
|
||||
assert login == "johannes117"
|
||||
return "user-tok"
|
||||
|
||||
monkeypatch.setattr(profiles, "get_valid_access_token", fake_user_token)
|
||||
|
||||
async def fail_bot() -> str | None:
|
||||
raise AssertionError("bot token should not be used when a user token exists")
|
||||
|
||||
monkeypatch.setattr(opr, "get_github_app_installation_token", fail_bot)
|
||||
|
||||
client = _FakeClient(
|
||||
post=_FakeResponse(
|
||||
201,
|
||||
{"html_url": "https://x/pull/1", "number": 1, "user": {"login": "johannes117"}},
|
||||
)
|
||||
)
|
||||
_install_client(monkeypatch, client)
|
||||
|
||||
result = _open()
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["created"] is True
|
||||
assert result["url"] == "https://x/pull/1"
|
||||
assert result["author"] == "johannes117"
|
||||
assert result["token_kind"] == "user"
|
||||
assert client.post_calls[0]["headers"]["Authorization"] == "Bearer user-tok"
|
||||
assert client.post_calls[0]["json"] == {
|
||||
"title": "feat: x",
|
||||
"head": "open-swe/feature",
|
||||
"base": "main",
|
||||
"body": "body",
|
||||
"draft": True,
|
||||
}
|
||||
|
||||
|
||||
def test_falls_back_to_bot_for_github_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch, {"source": "github", "github_login": "johannes117"})
|
||||
|
||||
from agent.dashboard import profiles
|
||||
|
||||
async def fail_user_token(login: str, **_kw: Any) -> str | None:
|
||||
raise AssertionError("user token should not be resolved for github source")
|
||||
|
||||
monkeypatch.setattr(profiles, "get_valid_access_token", fail_user_token)
|
||||
|
||||
async def fake_bot() -> str | None:
|
||||
return "bot-tok"
|
||||
|
||||
monkeypatch.setattr(opr, "get_github_app_installation_token", fake_bot)
|
||||
|
||||
client = _FakeClient(
|
||||
post=_FakeResponse(
|
||||
201, {"html_url": "https://x/pull/2", "number": 2, "user": {"login": "open-swe[bot]"}}
|
||||
)
|
||||
)
|
||||
_install_client(monkeypatch, client)
|
||||
|
||||
result = _open()
|
||||
|
||||
assert result["token_kind"] == "bot"
|
||||
assert client.post_calls[0]["headers"]["Authorization"] == "Bearer bot-tok"
|
||||
|
||||
|
||||
def test_falls_back_to_bot_when_user_token_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"})
|
||||
|
||||
from agent.dashboard import profiles
|
||||
|
||||
async def no_user_token(login: str, **_kw: Any) -> str | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(profiles, "get_valid_access_token", no_user_token)
|
||||
|
||||
async def fake_bot() -> str | None:
|
||||
return "bot-tok"
|
||||
|
||||
monkeypatch.setattr(opr, "get_github_app_installation_token", fake_bot)
|
||||
|
||||
client = _FakeClient(post=_FakeResponse(201, {"html_url": "u", "number": 3, "user": {}}))
|
||||
_install_client(monkeypatch, client)
|
||||
|
||||
assert _open()["token_kind"] == "bot"
|
||||
|
||||
|
||||
def test_returns_existing_pr_on_422(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"})
|
||||
|
||||
from agent.dashboard import profiles
|
||||
|
||||
monkeypatch.setattr(profiles, "get_valid_access_token", lambda *_a, **_k: _coro("user-tok"))
|
||||
monkeypatch.setattr(opr, "get_github_app_installation_token", lambda: _coro("bot"))
|
||||
|
||||
client = _FakeClient(
|
||||
post=_FakeResponse(422, text="A pull request already exists"),
|
||||
get=_FakeResponse(
|
||||
200, [{"html_url": "https://x/pull/9", "number": 9, "user": {"login": "johannes117"}}]
|
||||
),
|
||||
)
|
||||
_install_client(monkeypatch, client)
|
||||
|
||||
result = _open()
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["created"] is False
|
||||
assert result["number"] == 9
|
||||
assert client.get_calls[0]["params"] == {
|
||||
"head": "langchain-ai:open-swe/feature",
|
||||
"state": "open",
|
||||
}
|
||||
|
||||
|
||||
def test_error_surfaced_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"})
|
||||
|
||||
from agent.dashboard import profiles
|
||||
|
||||
monkeypatch.setattr(profiles, "get_valid_access_token", lambda *_a, **_k: _coro("user-tok"))
|
||||
monkeypatch.setattr(opr, "get_github_app_installation_token", lambda: _coro("bot"))
|
||||
|
||||
client = _FakeClient(post=_FakeResponse(403, text="Resource not accessible"))
|
||||
_install_client(monkeypatch, client)
|
||||
|
||||
result = _open()
|
||||
|
||||
assert result["success"] is False
|
||||
assert "403" in result["error"]
|
||||
|
||||
|
||||
async def _coro(value: Any) -> Any:
|
||||
return value
|
||||
Loading…
Add table
Reference in a new issue