open-swe/agent/tools/open_pull_request.py

369 lines
13 KiB
Python
Raw Normal View History

"""Open a GitHub pull request attributed to the triggering user."""
from __future__ import annotations
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}",
}
refactor: durable interrupt dispatch + completion webhook (#1621) * wip(rebuild): core reliability spine - remove PR-babysitting (ci_autofix + ci_monitor graph + webhook wiring) - dispatch core: agent/dispatch.py with multitask_strategy=interrupt + durability=sync + completion webhook; reroute all webhook + plan triggers; drop the racy in-process lock + is_thread_active busy-check - completion webhook: agent/completion.py + /webhooks/run-complete loopback route for failure/timeout replies (idempotent) Co-authored-by: open-swe[bot] * feat(rebuild): async tools, reconcile, shared http timeouts, assembly tuning Parallel batch on top of the reliability spine: - async-ify all 24 tools (drop asyncio.run; requests->httpx); re-implement the http_request/fetch_url SSRF + DNS-rebinding defense httpx-natively and harden the IP check to 'not is_global' (+ IPv4-mapped unwrap) - reconcile.py: stale pending-run sweep (threads.search -> per-thread runs.list -> cancel_many), wired into the scheduler graph via task='reconcile' - shared DEFAULT_HTTP_TIMEOUT (agent/utils/http.py) on every bare httpx.AsyncClient() across utils/dashboard/webapp/middleware - run budget: MODEL_CALL_RECURSION_LIMIT 5000->250 - fix stale OpenAI->Anthropic fallback id (claude-opus-4-5 -> 4-8) - drop redundant custom repair middleware (deepagents auto-adds PatchToolCalls) - confirm tool-result eviction + summarization auto-wired via backend - slim system prompt ~8% (full harness-profile rewrite deferred) Co-authored-by: open-swe[bot] * feat(rebuild): harness-profile prompt + split webhooks out of webapp - prompt.py: own the system prompt via a registered harness profile (OPEN_SWE_SHARED_BASE, kept neutral so the read-only reviewer/analyzer that share it stay safe), registered across all 4 providers; per-thread values stay in construct_system_prompt. Assembled main-agent prompt ~6.8k -> ~3.1k tokens (~55% smaller); de-duped PR/commit/suite/force-push guidance; dropped ALL-CAPS markers. - webapp.py 3325 -> 1890 LOC: moved 14 per-source handlers into agent/webhooks/{linear,slack,github}.py; webapp re-exports them for the routes + tests; moved handlers reach shared helpers via the webapp namespace to preserve the test suite's monkeypatch targets. Full suite: 1168 passing, lint clean. Co-authored-by: open-swe[bot] * Restore MODEL_CALL_RECURSION_LIMIT to 5000 for long-running tasks Reverts the 250 cap from the run-budget change — long-running tasks legitimately need many model calls. The notify_step_limit_reached safety net still fires if a run does hit the cap, so runs end with a signal either way. Co-authored-by: open-swe[bot] * fix: address PR review (auth, SSRF, interrupted status, redirect headers) - completion.py: drop `interrupted` from failure statuses — with multitask_strategy=interrupt a follow-up ends the prior run as interrupted, which is healthy, not a failure to report. [open-swe] - /webhooks/run-complete: shared-secret auth — dispatch appends ?token= when RUN_COMPLETE_WEBHOOK_SECRET is set; route verifies via hmac.compare_digest. [corridor-security] - SSRF: extract the URL validator to agent/utils/url_safety.py and apply it before server-side image fetches in multimodal.fetch_image_block. [corridor-security] - http_request: preserve caller headers/extensions across redirect hops instead of dropping them on the first hop. [open-swe] Co-authored-by: open-swe[bot] * chore: remove REBUILD_PLAN.md (planning doc, not needed in the repo) Co-authored-by: open-swe[bot] * fix: fail closed on run-complete webhook auth when secret unset Corridor follow-up: verify_run_complete_token returns False (not True) when RUN_COMPLETE_WEBHOOK_SECRET is unset, so the public route is never unauthenticated. Logs a startup warning when the secret is absent, and dispatch skips registering the webhook when there's no secret (no rejected callbacks). Co-authored-by: open-swe[bot] --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-26 13:48:38 -07:00
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}.
"""
refactor: durable interrupt dispatch + completion webhook (#1621) * wip(rebuild): core reliability spine - remove PR-babysitting (ci_autofix + ci_monitor graph + webhook wiring) - dispatch core: agent/dispatch.py with multitask_strategy=interrupt + durability=sync + completion webhook; reroute all webhook + plan triggers; drop the racy in-process lock + is_thread_active busy-check - completion webhook: agent/completion.py + /webhooks/run-complete loopback route for failure/timeout replies (idempotent) Co-authored-by: open-swe[bot] * feat(rebuild): async tools, reconcile, shared http timeouts, assembly tuning Parallel batch on top of the reliability spine: - async-ify all 24 tools (drop asyncio.run; requests->httpx); re-implement the http_request/fetch_url SSRF + DNS-rebinding defense httpx-natively and harden the IP check to 'not is_global' (+ IPv4-mapped unwrap) - reconcile.py: stale pending-run sweep (threads.search -> per-thread runs.list -> cancel_many), wired into the scheduler graph via task='reconcile' - shared DEFAULT_HTTP_TIMEOUT (agent/utils/http.py) on every bare httpx.AsyncClient() across utils/dashboard/webapp/middleware - run budget: MODEL_CALL_RECURSION_LIMIT 5000->250 - fix stale OpenAI->Anthropic fallback id (claude-opus-4-5 -> 4-8) - drop redundant custom repair middleware (deepagents auto-adds PatchToolCalls) - confirm tool-result eviction + summarization auto-wired via backend - slim system prompt ~8% (full harness-profile rewrite deferred) Co-authored-by: open-swe[bot] * feat(rebuild): harness-profile prompt + split webhooks out of webapp - prompt.py: own the system prompt via a registered harness profile (OPEN_SWE_SHARED_BASE, kept neutral so the read-only reviewer/analyzer that share it stay safe), registered across all 4 providers; per-thread values stay in construct_system_prompt. Assembled main-agent prompt ~6.8k -> ~3.1k tokens (~55% smaller); de-duped PR/commit/suite/force-push guidance; dropped ALL-CAPS markers. - webapp.py 3325 -> 1890 LOC: moved 14 per-source handlers into agent/webhooks/{linear,slack,github}.py; webapp re-exports them for the routes + tests; moved handlers reach shared helpers via the webapp namespace to preserve the test suite's monkeypatch targets. Full suite: 1168 passing, lint clean. Co-authored-by: open-swe[bot] * Restore MODEL_CALL_RECURSION_LIMIT to 5000 for long-running tasks Reverts the 250 cap from the run-budget change — long-running tasks legitimately need many model calls. The notify_step_limit_reached safety net still fires if a run does hit the cap, so runs end with a signal either way. Co-authored-by: open-swe[bot] * fix: address PR review (auth, SSRF, interrupted status, redirect headers) - completion.py: drop `interrupted` from failure statuses — with multitask_strategy=interrupt a follow-up ends the prior run as interrupted, which is healthy, not a failure to report. [open-swe] - /webhooks/run-complete: shared-secret auth — dispatch appends ?token= when RUN_COMPLETE_WEBHOOK_SECRET is set; route verifies via hmac.compare_digest. [corridor-security] - SSRF: extract the URL validator to agent/utils/url_safety.py and apply it before server-side image fetches in multimodal.fetch_image_block. [corridor-security] - http_request: preserve caller headers/extensions across redirect hops instead of dropping them on the first hop. [open-swe] Co-authored-by: open-swe[bot] * chore: remove REBUILD_PLAN.md (planning doc, not needed in the repo) Co-authored-by: open-swe[bot] * fix: fail closed on run-complete webhook auth when secret unset Corridor follow-up: verify_run_complete_token returns False (not True) when RUN_COMPLETE_WEBHOOK_SECRET is unset, so the public route is never unauthenticated. Logs a startup warning when the secret is absent, and dispatch skips registering the webhook when there's no secret (no rejected callbacks). Co-authored-by: open-swe[bot] --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-26 13:48:38 -07:00
return await _open_pull_request(
owner=owner,
repo=repo,
head=head,
base=base,
title=title,
body=body,
draft=draft,
)