open-swe/agent/tools/open_pull_request.py

170 lines
5.7 KiB
Python
Raw Normal View History

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