mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 17:23:15 +00:00
Plan links in PR descriptions were always built from the thread id, so runs that never produced a plan linked to an empty plan-review page. Now the plan content store is consulted first; the link is only added when a plan with non-empty markdown actually exists. A transient store failure degrades gracefully (no link) rather than blocking PR creation. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
371 lines
13 KiB
Python
371 lines
13 KiB
Python
"""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 langgraph_sdk import get_client
|
|
|
|
from ..dashboard.agent_usage import record_agent_pr_usage
|
|
from ..dashboard.plan_store import get_plan_content
|
|
from ..utils.dashboard_links import dashboard_plan_url
|
|
from ..utils.github_app import get_github_app_installation_token
|
|
from ..utils.github_comments import derive_pr_state
|
|
from ..utils.slack import get_slack_permalink
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
GITHUB_API = "https://api.github.com"
|
|
_USER_TOKEN_SOURCES = ("slack", "dashboard")
|
|
_REFERENCES_HEADING = "## References"
|
|
|
|
|
|
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 _fetch_pr_details(
|
|
client: httpx.AsyncClient, token: str, owner: str, repo: str, pr_number: int
|
|
) -> dict[str, Any]:
|
|
resp = await client.get(
|
|
f"{GITHUB_API}/repos/{owner}/{repo}/pulls/{pr_number}",
|
|
headers=_auth_headers(token),
|
|
)
|
|
if resp.status_code != 200:
|
|
logger.debug(
|
|
"GitHub returned %s fetching PR stats for %s/%s#%s: %s",
|
|
resp.status_code,
|
|
owner,
|
|
repo,
|
|
pr_number,
|
|
resp.text,
|
|
)
|
|
return {}
|
|
data = resp.json()
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
async def _record_pr_telemetry(
|
|
*,
|
|
client: httpx.AsyncClient,
|
|
token: str,
|
|
owner: str,
|
|
repo: str,
|
|
head: str,
|
|
base: str,
|
|
pr: dict[str, Any],
|
|
) -> None:
|
|
pr_number = pr.get("number")
|
|
if not isinstance(pr_number, int):
|
|
return
|
|
try:
|
|
details = await _fetch_pr_details(client, token, owner, repo, pr_number)
|
|
config = get_config()
|
|
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
|
thread_id = configurable.get("thread_id")
|
|
github_login = configurable.get("github_login")
|
|
user_email = configurable.get("user_email")
|
|
if not isinstance(github_login, str) or not github_login.strip():
|
|
from ..dashboard.user_mappings import login_for_email
|
|
|
|
github_login = (
|
|
await login_for_email(user_email if isinstance(user_email, str) else None) or ""
|
|
)
|
|
pr_url = details.get("html_url") or pr.get("html_url")
|
|
merged = bool(details.get("merged"))
|
|
is_draft = bool(details.get("draft", pr.get("draft")))
|
|
state = details.get("state") if isinstance(details.get("state"), str) else "open"
|
|
additions = details.get("additions") if isinstance(details.get("additions"), int) else 0
|
|
deletions = details.get("deletions") if isinstance(details.get("deletions"), int) else 0
|
|
changed_files = (
|
|
details.get("changed_files") if isinstance(details.get("changed_files"), int) else 0
|
|
)
|
|
await record_agent_pr_usage(
|
|
thread_id=thread_id if isinstance(thread_id, str) else None,
|
|
github_login=github_login,
|
|
user_email=user_email if isinstance(user_email, str) else None,
|
|
owner=owner,
|
|
repo=repo,
|
|
pr_number=pr_number,
|
|
pr_url=pr_url if isinstance(pr_url, str) else None,
|
|
head=head,
|
|
base=base,
|
|
additions=additions,
|
|
deletions=deletions,
|
|
changed_files=changed_files,
|
|
state=state,
|
|
merged=merged,
|
|
)
|
|
if isinstance(thread_id, str) and thread_id:
|
|
await get_client().threads.update(
|
|
thread_id=thread_id,
|
|
metadata={
|
|
"agent_kind": "agent",
|
|
"pr_url": pr_url if isinstance(pr_url, str) else "",
|
|
"pr_number": pr_number,
|
|
"pr_state": derive_pr_state(state=state, merged=merged, draft=is_draft),
|
|
"pr_title": details.get("title") or pr.get("title"),
|
|
"branch_name": head,
|
|
"base_branch": base,
|
|
"diff_stats": {
|
|
"files": changed_files,
|
|
"additions": additions,
|
|
"deletions": deletions,
|
|
},
|
|
},
|
|
)
|
|
except Exception:
|
|
logger.debug(
|
|
"Failed to record PR usage for %s/%s#%s", owner, repo, pr_number, exc_info=True
|
|
)
|
|
|
|
|
|
async def _plan_reference_line(configurable: dict[str, Any]) -> str | None:
|
|
thread_id = configurable.get("thread_id")
|
|
if not isinstance(thread_id, str):
|
|
return None
|
|
try:
|
|
plan = await get_plan_content(thread_id)
|
|
except Exception:
|
|
logger.debug("Failed to look up plan content for %s", thread_id, exc_info=True)
|
|
return None
|
|
if not plan or not str(plan.get("markdown", "")).strip():
|
|
return None
|
|
plan_url = dashboard_plan_url(thread_id)
|
|
if not plan_url:
|
|
return None
|
|
return f"- Plan: {plan_url}"
|
|
|
|
|
|
async def _build_source_reference_lines(configurable: dict[str, Any]) -> list[str]:
|
|
"""Build source reference lines for the run."""
|
|
source = configurable.get("source")
|
|
lines: list[str] = []
|
|
|
|
if source == "slack":
|
|
slack_thread = configurable.get("slack_thread") or {}
|
|
channel_id = slack_thread.get("channel_id")
|
|
thread_ts = slack_thread.get("thread_ts")
|
|
if channel_id and thread_ts:
|
|
permalink = await get_slack_permalink(channel_id, thread_ts)
|
|
if permalink:
|
|
lines.append(f"- Slack thread: {permalink}")
|
|
elif source == "linear":
|
|
linear_issue = configurable.get("linear_issue") or {}
|
|
url = linear_issue.get("url")
|
|
identifier = linear_issue.get("identifier")
|
|
if url:
|
|
lines.append(f"- Linear ticket: [{identifier or url}]({url})")
|
|
elif identifier:
|
|
lines.append(f"- Linear ticket: {identifier}")
|
|
|
|
return lines
|
|
|
|
|
|
async def _is_private_repo(client: httpx.AsyncClient, token: str, owner: str, repo: str) -> bool:
|
|
"""Return True only when GitHub confirms the repo is private."""
|
|
resp = await client.get(f"{GITHUB_API}/repos/{owner}/{repo}", headers=_auth_headers(token))
|
|
if resp.status_code != 200: # noqa: PLR2004
|
|
return False
|
|
data = resp.json()
|
|
return bool(data.get("private")) if isinstance(data, dict) else False
|
|
|
|
|
|
async def _maybe_append_references(
|
|
client: httpx.AsyncClient, token: str, owner: str, repo: str, body: str
|
|
) -> str:
|
|
"""Append run references to the PR body."""
|
|
try:
|
|
if _REFERENCES_HEADING in body:
|
|
return body
|
|
configurable = get_config().get("configurable", {})
|
|
if not isinstance(configurable, dict):
|
|
configurable = {}
|
|
lines: list[str] = []
|
|
plan_line = await _plan_reference_line(configurable)
|
|
if plan_line:
|
|
lines.append(plan_line)
|
|
try:
|
|
source_lines = await _build_source_reference_lines(configurable)
|
|
if source_lines and await _is_private_repo(client, token, owner, repo):
|
|
lines.extend(source_lines)
|
|
except Exception:
|
|
logger.debug("Failed to append source references to PR body", exc_info=True)
|
|
if not lines:
|
|
return body
|
|
return f"{body.rstrip()}\n\n{_REFERENCES_HEADING}\n" + "\n".join(lines)
|
|
except Exception:
|
|
logger.debug("Failed to append references to PR body", exc_info=True)
|
|
return body
|
|
|
|
|
|
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.",
|
|
}
|
|
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
body = await _maybe_append_references(client, token, owner, repo, body)
|
|
payload = {"title": title, "head": head, "base": base, "body": body, "draft": draft}
|
|
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()
|
|
if isinstance(pr, dict):
|
|
await _record_pr_telemetry(
|
|
client=client,
|
|
token=token,
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
pr=pr,
|
|
)
|
|
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:
|
|
await _record_pr_telemetry(
|
|
client=client,
|
|
token=token,
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
pr=existing,
|
|
)
|
|
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,
|
|
)
|
|
)
|