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:
Johannes du Plessis 2026-06-02 16:01:54 -07:00 • committed by GitHub
parent 0a7fae60cd
commit 04c346176d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 388 additions and 6 deletions

View file

@ -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.**

View file

@ -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,

View file

@ -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",

View 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,
)
)

View 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