mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 10:23:14 +00:00
* feat: link Slack thread/Linear ticket in PRs and append ticket to title When opening PRs, include any referenced Slack thread or Linear ticket in the description and append the resolvable ticket number to the title. Adds a Slack permalink to the webhook prompt so the agent has a ready-to-link thread URL. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: use [closes <TICKET>] format in PR title suffix Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * feat: deterministically append source refs to private-repo PRs only Move the Slack/Linear source-reference linking out of the prompt and into open_pull_request, gated to private repos so private Slack thread URLs and Linear identifiers are never published to a public PR. Reverts the prompt instruction and webhook permalink injection in favor of this server-side append. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
378 lines
12 KiB
Python
378 lines
12 KiB
Python
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
|
|
|
|
|
|
class _RoutingClient:
|
|
"""Fake httpx client that routes GETs by URL substring."""
|
|
|
|
def __init__(self, *, post: _FakeResponse, get_routes: dict[str, _FakeResponse]) -> None:
|
|
self._post = post
|
|
self._get_routes = get_routes
|
|
self.post_calls: list[dict[str, Any]] = []
|
|
self.get_calls: list[dict[str, Any]] = []
|
|
|
|
async def __aenter__(self) -> _RoutingClient:
|
|
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] | None = None
|
|
) -> _FakeResponse:
|
|
self.get_calls.append({"url": url, "headers": headers, "params": params})
|
|
for needle, resp in self._get_routes.items():
|
|
if needle in url:
|
|
return resp
|
|
raise AssertionError(f"unexpected GET {url}")
|
|
|
|
|
|
def _install_client(monkeypatch: pytest.MonkeyPatch, client: _FakeClient | _RoutingClient) -> 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
|
|
|
|
|
|
def _open_with_body(body: str) -> 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 _stub_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(opr, "_resolve_pr_author_token", lambda: _coro(("tok", "user")))
|
|
|
|
|
|
def test_appends_slack_reference_for_private_repo(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_set_config(
|
|
monkeypatch,
|
|
{
|
|
"source": "slack",
|
|
"slack_thread": {"channel_id": "C123", "thread_ts": "1700000000.000100"},
|
|
},
|
|
)
|
|
_stub_token(monkeypatch)
|
|
monkeypatch.setattr(
|
|
opr, "get_slack_permalink", lambda *_a, **_k: _coro("https://slack.example/p1")
|
|
)
|
|
|
|
client = _RoutingClient(
|
|
post=_FakeResponse(201, {"html_url": "u", "number": 1, "user": {}}),
|
|
get_routes={"/repos/langchain-ai/open-swe": _FakeResponse(200, {"private": True})},
|
|
)
|
|
_install_client(monkeypatch, client)
|
|
|
|
_open_with_body("original body")
|
|
|
|
sent_body = client.post_calls[0]["json"]["body"]
|
|
assert sent_body.startswith("original body")
|
|
assert "## References" in sent_body
|
|
assert "- Slack thread: https://slack.example/p1" in sent_body
|
|
|
|
|
|
def test_no_reference_for_public_repo(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_set_config(
|
|
monkeypatch,
|
|
{
|
|
"source": "slack",
|
|
"slack_thread": {"channel_id": "C123", "thread_ts": "1700000000.000100"},
|
|
},
|
|
)
|
|
_stub_token(monkeypatch)
|
|
monkeypatch.setattr(
|
|
opr, "get_slack_permalink", lambda *_a, **_k: _coro("https://slack.example/p1")
|
|
)
|
|
|
|
client = _RoutingClient(
|
|
post=_FakeResponse(201, {"html_url": "u", "number": 1, "user": {}}),
|
|
get_routes={"/repos/langchain-ai/open-swe": _FakeResponse(200, {"private": False})},
|
|
)
|
|
_install_client(monkeypatch, client)
|
|
|
|
_open_with_body("original body")
|
|
|
|
assert client.post_calls[0]["json"]["body"] == "original body"
|
|
|
|
|
|
def test_appends_linear_reference_for_private_repo(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_set_config(
|
|
monkeypatch,
|
|
{
|
|
"source": "linear",
|
|
"linear_issue": {"url": "https://linear.app/x/AB-12", "identifier": "AB-12"},
|
|
},
|
|
)
|
|
_stub_token(monkeypatch)
|
|
|
|
client = _RoutingClient(
|
|
post=_FakeResponse(201, {"html_url": "u", "number": 1, "user": {}}),
|
|
get_routes={"/repos/langchain-ai/open-swe": _FakeResponse(200, {"private": True})},
|
|
)
|
|
_install_client(monkeypatch, client)
|
|
|
|
_open_with_body("body")
|
|
|
|
sent_body = client.post_calls[0]["json"]["body"]
|
|
assert "- Linear ticket: [AB-12](https://linear.app/x/AB-12)" in sent_body
|
|
|
|
|
|
def test_skips_append_when_no_source_context(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_set_config(monkeypatch, {"source": "slack"})
|
|
_stub_token(monkeypatch)
|
|
|
|
client = _FakeClient(post=_FakeResponse(201, {"html_url": "u", "number": 1, "user": {}}))
|
|
_install_client(monkeypatch, client)
|
|
|
|
_open_with_body("body")
|
|
|
|
assert client.post_calls[0]["json"]["body"] == "body"
|
|
assert client.get_calls == []
|
|
|
|
|
|
def test_does_not_duplicate_existing_references(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_set_config(
|
|
monkeypatch,
|
|
{
|
|
"source": "slack",
|
|
"slack_thread": {"channel_id": "C123", "thread_ts": "1700000000.000100"},
|
|
},
|
|
)
|
|
_stub_token(monkeypatch)
|
|
|
|
client = _FakeClient(post=_FakeResponse(201, {"html_url": "u", "number": 1, "user": {}}))
|
|
_install_client(monkeypatch, client)
|
|
|
|
_open_with_body("body\n\n## References\n- existing")
|
|
|
|
assert client.post_calls[0]["json"]["body"] == "body\n\n## References\n- existing"
|
|
assert client.get_calls == []
|
|
|
|
|
|
def test_derive_pr_state_prefers_merged() -> None:
|
|
assert opr.derive_pr_state(state="closed", merged=True, draft=True) == "merged"
|
|
|
|
|
|
def test_derive_pr_state_closed_over_draft() -> None:
|
|
assert opr.derive_pr_state(state="closed", merged=False, draft=True) == "closed"
|
|
|
|
|
|
def test_derive_pr_state_draft() -> None:
|
|
assert opr.derive_pr_state(state="open", merged=False, draft=True) == "draft"
|
|
|
|
|
|
def test_derive_pr_state_open() -> None:
|
|
assert opr.derive_pr_state(state="open", merged=False, draft=False) == "open"
|