diff --git a/agent/prompt.py b/agent/prompt.py index ea4c3e8e..9ca415dd 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -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 `, 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 `, 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 ` 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.** diff --git a/agent/server.py b/agent/server.py index 9887cb3a..8bbe6371 100644 --- a/agent/server.py +++ b/agent/server.py @@ -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, diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index ec4304e4..aea428d3 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -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", diff --git a/agent/tools/open_pull_request.py b/agent/tools/open_pull_request.py new file mode 100644 index 00000000..b57f0ac7 --- /dev/null +++ b/agent/tools/open_pull_request.py @@ -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 ` 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, + ) + ) diff --git a/tests/test_open_pull_request.py b/tests/test_open_pull_request.py new file mode 100644 index 00000000..e0270ecf --- /dev/null +++ b/tests/test_open_pull_request.py @@ -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