mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 13:53:15 +00:00
* chore: include ripgrep in sandbox image (#1728) Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit 129ddcf9a2fe8b3710535eb10098f80c98b47690) * feat: include Cargo in sandbox image (#1729) Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit 1ea03a4330cc9faac65157f3fb94842d074e0231) * fix: prefer LangSmith tools for trace links (#1751) Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit c69459adcafad4a59028232514cecb3bdaf0dfc0) * fix: add trace link to error banner (#1750) Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit 5cb2e2bb3582d69241b386bb0852c6f6b40b2dbb) * fix: link issue PRs and prompt repo conventions (#1704) Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit 22e024cb1ca080e233eb2cc164767446173a7870) * chore: reconcile triage ledger --------- Co-authored-by: Johannes du Plessis <johannes@langchain.dev> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev> Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com> Co-authored-by: Palash Shah <35114859+Palashio@users.noreply.github.com>
805 lines
28 KiB
Python
805 lines
28 KiB
Python
"""Open a GitHub pull request attributed to the triggering user."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
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", "linear", "dashboard")
|
|
_REFERENCES_HEADING = "## References"
|
|
_ACCESS_FAILURE_CODE = "github_app_access_missing_or_repo_not_found"
|
|
_BRANCH_FAILURE_CODE = "github_pr_branch_not_visible"
|
|
_PREFLIGHT_FAILURE_CODE = "github_pr_preflight_failed"
|
|
|
|
|
|
async def _resolve_pr_author_token() -> tuple[str | None, str]:
|
|
"""Return ``(token, kind)`` for opening the PR.
|
|
|
|
DEFAULT: open the PR as the GitHub App bot ``seahaven-openswe[bot]`` (the
|
|
installation token) for every source, so PRs are attributed to the app —
|
|
matching GitHub-issue runs and making the self-review 422 impossible by
|
|
construction. OPT-IN: when the triggering user's profile has
|
|
``author_prs_as_user: true``, open Slack/Linear/dashboard PRs as that user
|
|
(their per-user OAuth token, resolved by login from the dashboard OAuth
|
|
store).
|
|
|
|
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():
|
|
login = github_login.strip()
|
|
from ..dashboard.agent_overrides import load_profile, profile_author_prs_as_user
|
|
|
|
if profile_author_prs_as_user(await load_profile(login)):
|
|
from ..dashboard.profiles import get_valid_access_token
|
|
|
|
user_token = await get_valid_access_token(login)
|
|
if user_token:
|
|
return user_token, "user"
|
|
logger.info(
|
|
"author_prs_as_user set but no valid user token for %s; opening PR as the app bot",
|
|
login,
|
|
)
|
|
|
|
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",
|
|
}
|
|
|
|
|
|
def _github_message(resp: httpx.Response) -> str:
|
|
try:
|
|
data = resp.json()
|
|
except Exception:
|
|
return resp.text.strip() or f"HTTP {resp.status_code}"
|
|
if isinstance(data, dict):
|
|
message = data.get("message")
|
|
if isinstance(message, str) and message.strip():
|
|
return message.strip()
|
|
return resp.text.strip() or f"HTTP {resp.status_code}"
|
|
|
|
|
|
def _configurable() -> dict[str, Any]:
|
|
try:
|
|
config = get_config()
|
|
except Exception:
|
|
return {}
|
|
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
|
return dict(configurable) if isinstance(configurable, dict) else {}
|
|
|
|
|
|
def _head_branch_for_repo(owner: str, head: str) -> str | None:
|
|
if ":" not in head:
|
|
return head
|
|
head_owner, branch = head.split(":", 1)
|
|
if head_owner == owner and branch:
|
|
return branch
|
|
return None
|
|
|
|
|
|
def _failure_payload(
|
|
*,
|
|
code: str,
|
|
owner: str,
|
|
repo: str,
|
|
head: str,
|
|
base: str,
|
|
token_kind: str,
|
|
http_status: int | None,
|
|
reason: str,
|
|
likely_cause: str,
|
|
suggested_action: str,
|
|
branch_pushed: bool | None,
|
|
failed_step: str,
|
|
repo_visible: bool | None = None,
|
|
base_branch_visible: bool | None = None,
|
|
head_branch_visible: bool | None = None,
|
|
) -> dict[str, Any]:
|
|
error = (
|
|
"Failed to open an attributed PR with open_pull_request. "
|
|
f"Reason: {reason}. Likely cause: {likely_cause}. "
|
|
f"Branch pushed: {owner}/{repo}:{head} "
|
|
f"({'unknown' if branch_pushed is None else 'yes' if branch_pushed else 'no'}). "
|
|
"PR created: no. "
|
|
f"Action: {suggested_action}"
|
|
)
|
|
payload: dict[str, Any] = {
|
|
"success": False,
|
|
"error": error,
|
|
"code": code,
|
|
"recoverable_by_agent": False,
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"head": head,
|
|
"base": base,
|
|
"token_kind": token_kind,
|
|
"http_status": http_status,
|
|
"branch_pushed": branch_pushed,
|
|
"pr_created": False,
|
|
"failed_step": failed_step,
|
|
"likely_cause": likely_cause,
|
|
"suggested_action": suggested_action,
|
|
}
|
|
if repo_visible is not None:
|
|
payload["repo_visible"] = repo_visible
|
|
if base_branch_visible is not None:
|
|
payload["base_branch_visible"] = base_branch_visible
|
|
if head_branch_visible is not None:
|
|
payload["head_branch_visible"] = head_branch_visible
|
|
_record_open_pr_failure_telemetry(payload)
|
|
return payload
|
|
|
|
|
|
def _record_open_pr_failure_telemetry(payload: dict[str, Any]) -> None:
|
|
configurable = _configurable()
|
|
logger.warning(
|
|
"open_pull_request_failed code=%s owner=%s repo=%s head=%s base=%s "
|
|
"http_status=%s token_kind=%s branch_pushed=%s thread_id=%s source=%s",
|
|
payload.get("code"),
|
|
payload.get("owner"),
|
|
payload.get("repo"),
|
|
payload.get("head"),
|
|
payload.get("base"),
|
|
payload.get("http_status"),
|
|
payload.get("token_kind"),
|
|
payload.get("branch_pushed"),
|
|
configurable.get("thread_id"),
|
|
configurable.get("source"),
|
|
extra={
|
|
"open_pull_request_failure": {
|
|
"code": payload.get("code"),
|
|
"owner": payload.get("owner"),
|
|
"repo": payload.get("repo"),
|
|
"head": payload.get("head"),
|
|
"base": payload.get("base"),
|
|
"http_status": payload.get("http_status"),
|
|
"token_kind": payload.get("token_kind"),
|
|
"branch_pushed": payload.get("branch_pushed"),
|
|
"pr_created": payload.get("pr_created"),
|
|
"failed_step": payload.get("failed_step"),
|
|
"thread_id": configurable.get("thread_id"),
|
|
"source": configurable.get("source"),
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
def _access_failure_payload(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
head: str,
|
|
base: str,
|
|
token_kind: str,
|
|
http_status: int | None,
|
|
reason: str,
|
|
branch_pushed: bool | None,
|
|
failed_step: str,
|
|
repo_visible: bool | None = None,
|
|
base_branch_visible: bool | None = None,
|
|
head_branch_visible: bool | None = None,
|
|
) -> dict[str, Any]:
|
|
return _failure_payload(
|
|
code=_ACCESS_FAILURE_CODE,
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=token_kind,
|
|
http_status=http_status,
|
|
reason=reason,
|
|
likely_cause=(
|
|
"the Open SWE GitHub App or PR author token is not installed on, granted access "
|
|
"to, or able to see this repository or one of the PR branches"
|
|
),
|
|
suggested_action=(
|
|
"install or grant the Open SWE GitHub App and the triggering user's GitHub "
|
|
"authorization access to this repository, verify the base/head branches exist, "
|
|
"then ask Open SWE to retry opening the PR"
|
|
),
|
|
branch_pushed=branch_pushed,
|
|
failed_step=failed_step,
|
|
repo_visible=repo_visible,
|
|
base_branch_visible=base_branch_visible,
|
|
head_branch_visible=head_branch_visible,
|
|
)
|
|
|
|
|
|
def _branch_failure_payload(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
head: str,
|
|
base: str,
|
|
token_kind: str,
|
|
http_status: int,
|
|
branch: str,
|
|
branch_role: str,
|
|
) -> dict[str, Any]:
|
|
branch_pushed = False if branch_role == "head" else None
|
|
return _failure_payload(
|
|
code=_BRANCH_FAILURE_CODE,
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=token_kind,
|
|
http_status=http_status,
|
|
reason=f"GitHub could not see the {branch_role} branch `{branch}` before PR creation",
|
|
likely_cause=(
|
|
f"the {branch_role} branch does not exist on `{owner}/{repo}` or is not visible "
|
|
"to the PR author token"
|
|
),
|
|
suggested_action=(
|
|
f"push or restore the {branch_role} branch `{branch}`, ensure the Open SWE "
|
|
"GitHub App/token can see it, then ask Open SWE to retry opening the PR"
|
|
),
|
|
branch_pushed=branch_pushed,
|
|
failed_step=f"preflight_{branch_role}_branch",
|
|
repo_visible=True,
|
|
base_branch_visible=False if branch_role == "base" else True,
|
|
head_branch_visible=False if branch_role == "head" else None,
|
|
)
|
|
|
|
|
|
async def _github_get(client: httpx.AsyncClient, token: str, path: str) -> httpx.Response:
|
|
return await client.get(f"{GITHUB_API}{path}", headers=_auth_headers(token))
|
|
|
|
|
|
async def _preflight_pr_access(
|
|
*,
|
|
client: httpx.AsyncClient,
|
|
token: str,
|
|
token_kind: str,
|
|
owner: str,
|
|
repo: str,
|
|
head: str,
|
|
base: str,
|
|
) -> dict[str, Any] | None:
|
|
"""Diagnose *why* a PR creation POST failed — not an authoritative gate.
|
|
|
|
This runs only after the POST returns a non-201/non-422 status so it
|
|
doesn't add latency on the happy path and avoids false positives from
|
|
read-after-write inconsistency on just-pushed head branches.
|
|
"""
|
|
repo_resp = await _github_get(client, token, f"/repos/{owner}/{repo}")
|
|
if repo_resp.status_code in {403, 404}:
|
|
return _access_failure_payload(
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=token_kind,
|
|
http_status=repo_resp.status_code,
|
|
reason=f"GitHub returned {repo_resp.status_code} while checking repository access",
|
|
branch_pushed=None,
|
|
failed_step="preflight_repo",
|
|
repo_visible=False,
|
|
)
|
|
if repo_resp.status_code != 200:
|
|
return _failure_payload(
|
|
code=_PREFLIGHT_FAILURE_CODE,
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=token_kind,
|
|
http_status=repo_resp.status_code,
|
|
reason=(
|
|
f"GitHub returned {repo_resp.status_code} while checking repository access: "
|
|
f"{_github_message(repo_resp)}"
|
|
),
|
|
likely_cause="GitHub repository access preflight failed before PR creation",
|
|
suggested_action="check GitHub availability and repository access, then retry",
|
|
branch_pushed=None,
|
|
failed_step="preflight_repo",
|
|
repo_visible=None,
|
|
)
|
|
|
|
base_resp = await _github_get(
|
|
client, token, f"/repos/{owner}/{repo}/branches/{quote(base, safe='')}"
|
|
)
|
|
if base_resp.status_code == 404:
|
|
return _branch_failure_payload(
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=token_kind,
|
|
http_status=base_resp.status_code,
|
|
branch=base,
|
|
branch_role="base",
|
|
)
|
|
if base_resp.status_code in {401, 403}:
|
|
return _access_failure_payload(
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=token_kind,
|
|
http_status=base_resp.status_code,
|
|
reason=f"GitHub returned {base_resp.status_code} while checking base branch access",
|
|
branch_pushed=None,
|
|
failed_step="preflight_base_branch",
|
|
repo_visible=True,
|
|
base_branch_visible=False,
|
|
)
|
|
if base_resp.status_code != 200:
|
|
return _failure_payload(
|
|
code=_PREFLIGHT_FAILURE_CODE,
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=token_kind,
|
|
http_status=base_resp.status_code,
|
|
reason=(
|
|
f"GitHub returned {base_resp.status_code} while checking base branch access: "
|
|
f"{_github_message(base_resp)}"
|
|
),
|
|
likely_cause="GitHub branch access preflight failed before PR creation",
|
|
suggested_action="check GitHub availability and branch access, then retry",
|
|
branch_pushed=None,
|
|
failed_step="preflight_base_branch",
|
|
repo_visible=True,
|
|
base_branch_visible=None,
|
|
)
|
|
|
|
head_branch = _head_branch_for_repo(owner, head)
|
|
if head_branch is None:
|
|
return None
|
|
head_resp = await _github_get(
|
|
client, token, f"/repos/{owner}/{repo}/branches/{quote(head_branch, safe='')}"
|
|
)
|
|
if head_resp.status_code == 404:
|
|
return _branch_failure_payload(
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=token_kind,
|
|
http_status=head_resp.status_code,
|
|
branch=head_branch,
|
|
branch_role="head",
|
|
)
|
|
if head_resp.status_code in {401, 403}:
|
|
return _access_failure_payload(
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=token_kind,
|
|
http_status=head_resp.status_code,
|
|
reason=f"GitHub returned {head_resp.status_code} while checking head branch access",
|
|
branch_pushed=False,
|
|
failed_step="preflight_head_branch",
|
|
repo_visible=True,
|
|
base_branch_visible=True,
|
|
head_branch_visible=False,
|
|
)
|
|
if head_resp.status_code != 200:
|
|
return _failure_payload(
|
|
code=_PREFLIGHT_FAILURE_CODE,
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=token_kind,
|
|
http_status=head_resp.status_code,
|
|
reason=(
|
|
f"GitHub returned {head_resp.status_code} while checking head branch access: "
|
|
f"{_github_message(head_resp)}"
|
|
),
|
|
likely_cause="GitHub branch access preflight failed before PR creation",
|
|
suggested_action="check GitHub availability and branch access, then retry",
|
|
branch_pushed=None,
|
|
failed_step="preflight_head_branch",
|
|
repo_visible=True,
|
|
base_branch_visible=True,
|
|
head_branch_visible=None,
|
|
)
|
|
return None
|
|
|
|
|
|
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}")
|
|
elif source in ("github", "github_issue"):
|
|
github_issue = configurable.get("github_issue") or {}
|
|
url = github_issue.get("url")
|
|
number = github_issue.get("number")
|
|
if url:
|
|
label = f"#{number}" if number else url
|
|
lines.append(f"- GitHub issue: [{label}]({url})")
|
|
elif number:
|
|
lines.append(f"- GitHub issue: #{number}")
|
|
|
|
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 _failure_payload(
|
|
code="no_github_token",
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=kind,
|
|
http_status=None,
|
|
reason="No GitHub token was available to open the pull request",
|
|
likely_cause="the triggering user is not authorized and no GitHub App token is available",
|
|
suggested_action="connect GitHub authorization or install/grant the Open SWE GitHub App, then retry",
|
|
branch_pushed=None,
|
|
failed_step="resolve_pr_author_token",
|
|
)
|
|
|
|
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,
|
|
}
|
|
|
|
# POST failed — run preflight diagnostics to understand why, so the
|
|
# agent gets an actionable diagnosis instead of a raw HTTP status.
|
|
# Preflight runs *after* the POST so a just-pushed branch that is
|
|
# momentarily invisible to GitHub's ref endpoints does not cause a
|
|
# false-positive preflight failure on what would have been a successful
|
|
# PR creation.
|
|
diagnostic = await _preflight_pr_access(
|
|
client=client,
|
|
token=token,
|
|
token_kind=kind,
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
)
|
|
if diagnostic is not None:
|
|
return diagnostic
|
|
|
|
# Preflight found nothing — surface the raw POST failure.
|
|
if resp.status_code == 404:
|
|
return _access_failure_payload(
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=kind,
|
|
http_status=resp.status_code,
|
|
reason="GitHub returned 404 while creating the pull request",
|
|
branch_pushed=True,
|
|
failed_step="create_pull_request",
|
|
repo_visible=True,
|
|
base_branch_visible=True,
|
|
head_branch_visible=True
|
|
if _head_branch_for_repo(owner, head) is not None
|
|
else None,
|
|
)
|
|
|
|
return _failure_payload(
|
|
code="github_pr_create_failed",
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
token_kind=kind,
|
|
http_status=resp.status_code,
|
|
reason=f"GitHub returned {resp.status_code} while creating the pull request: {_github_message(resp)}",
|
|
likely_cause="GitHub rejected the pull request creation request",
|
|
suggested_action="inspect the GitHub error, correct the branch or repository state, then retry",
|
|
branch_pushed=True,
|
|
failed_step="create_pull_request",
|
|
repo_visible=True,
|
|
base_branch_visible=True,
|
|
head_branch_visible=True if _head_branch_for_repo(owner, head) is not None else None,
|
|
)
|
|
|
|
|
|
async 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, "code": str,
|
|
"recoverable_by_agent": False, "pr_created": False, ...}.
|
|
"""
|
|
return await _open_pull_request(
|
|
owner=owner,
|
|
repo=repo,
|
|
head=head,
|
|
base=base,
|
|
title=title,
|
|
body=body,
|
|
draft=draft,
|
|
)
|