diff --git a/agent/tools/open_pull_request.py b/agent/tools/open_pull_request.py index 2a1193e1..fd1ad05b 100644 --- a/agent/tools/open_pull_request.py +++ b/agent/tools/open_pull_request.py @@ -11,6 +11,7 @@ from langgraph.config import get_config from langgraph_sdk import get_client from ..dashboard.agent_usage import record_agent_pr_usage +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 @@ -168,9 +169,18 @@ async def _record_pr_telemetry( ) -async def _build_source_references() -> str: - """Build a `## References` section linking the run's source (Slack/Linear).""" - configurable = get_config().get("configurable", {}) +def _plan_reference_line(configurable: dict[str, Any]) -> str | None: + thread_id = configurable.get("thread_id") + if not isinstance(thread_id, str): + 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] = [] @@ -191,9 +201,7 @@ async def _build_source_references() -> str: elif identifier: lines.append(f"- Linear ticket: {identifier}") - if not lines: - return "" - return _REFERENCES_HEADING + "\n" + "\n".join(lines) + return lines async def _is_private_repo(client: httpx.AsyncClient, token: str, owner: str, repo: str) -> bool: @@ -205,25 +213,31 @@ async def _is_private_repo(client: httpx.AsyncClient, token: str, owner: str, re return bool(data.get("private")) if isinstance(data, dict) else False -async def _maybe_append_source_references( +async def _maybe_append_references( client: httpx.AsyncClient, token: str, owner: str, repo: str, body: str ) -> str: - """Append source references to the PR body for private repos only. - - Gated to private repos so private Slack thread URLs / Linear identifiers are - never published to a public PR. - """ + """Append run references to the PR body.""" try: if _REFERENCES_HEADING in body: return body - references = await _build_source_references() - if not references: + configurable = get_config().get("configurable", {}) + if not isinstance(configurable, dict): + configurable = {} + lines: list[str] = [] + plan_line = _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 - if not await _is_private_repo(client, token, owner, repo): - return body - return f"{body.rstrip()}\n\n{references}" + return f"{body.rstrip()}\n\n{_REFERENCES_HEADING}\n" + "\n".join(lines) except Exception: - logger.debug("Failed to append source references to PR body", exc_info=True) + logger.debug("Failed to append references to PR body", exc_info=True) return body @@ -245,7 +259,7 @@ async def _open_pull_request( } async with httpx.AsyncClient(timeout=30.0) as client: - body = await _maybe_append_source_references(client, token, owner, repo, body) + 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", diff --git a/tests/test_open_pull_request.py b/tests/test_open_pull_request.py index 52dbfa09..1ccbec57 100644 --- a/tests/test_open_pull_request.py +++ b/tests/test_open_pull_request.py @@ -284,6 +284,50 @@ def test_appends_slack_reference_for_private_repo(monkeypatch: pytest.MonkeyPatc assert "- Slack thread: https://slack.example/p1" in sent_body +def test_appends_plan_reference_from_thread_id(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example") + _set_config(monkeypatch, {"source": "dashboard", "thread_id": "thread-1"}) + _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\n\n## References\n- Plan: https://dashboard.example/agents/thread-1/plan" + ) + assert client.get_calls == [] + + +def test_plan_reference_survives_source_reference_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example") + _set_config( + monkeypatch, + { + "source": "slack", + "thread_id": "thread-1", + "slack_thread": {"channel_id": "C123", "thread_ts": "1700000000.000100"}, + }, + ) + _stub_token(monkeypatch) + + async def fail_permalink(*_args: Any, **_kwargs: Any) -> str: + raise RuntimeError("slack failed") + + monkeypatch.setattr(opr, "get_slack_permalink", fail_permalink) + client = _FakeClient(post=_FakeResponse(201, {"html_url": "u", "number": 1, "user": {}})) + _install_client(monkeypatch, client) + + _open_with_body("body") + + sent_body = client.post_calls[0]["json"]["body"] + assert "- Plan: https://dashboard.example/agents/thread-1/plan" in sent_body + assert client.get_calls == [] + + def test_no_reference_for_public_repo(monkeypatch: pytest.MonkeyPatch) -> None: _set_config( monkeypatch, @@ -308,6 +352,36 @@ def test_no_reference_for_public_repo(monkeypatch: pytest.MonkeyPatch) -> None: assert client.post_calls[0]["json"]["body"] == "original body" +def test_public_repo_appends_plan_but_not_source_reference( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example") + _set_config( + monkeypatch, + { + "source": "slack", + "thread_id": "thread-1", + "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("body") + + sent_body = client.post_calls[0]["json"]["body"] + assert "- Plan: https://dashboard.example/agents/thread-1/plan" in sent_body + assert "Slack thread" not in sent_body + + def test_appends_linear_reference_for_private_repo(monkeypatch: pytest.MonkeyPatch) -> None: _set_config( monkeypatch,