diff --git a/agent/prompt.py b/agent/prompt.py index 6168162c..fdba01f4 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -312,9 +312,10 @@ When you have completed your implementation, follow these steps in order: **PR Title** (under 70 characters): ``` - : [closes {linear_project_id}-{linear_issue_number}] + : [closes ] ``` - Where type is one of: `fix` (bug fix), `feat` (new feature), `chore` (maintenance), `ci` (CI/CD) + Where type is one of: `fix` (bug fix), `feat` (new feature), `chore` (maintenance), `ci` (CI/CD). + Always append the resolvable ticket number in square brackets at the end of the title (e.g. `fix: handle null session [closes AB-000]`). Resolve the ticket from the Linear-triggered run when present (`{linear_project_id}-{linear_issue_number}`), or from a Linear ticket referenced in the Slack thread / task context. If no ticket number is resolvable, omit the bracketed suffix entirely. **PR Body** (keep under 10 lines total. the more concise the better): ``` @@ -329,6 +330,8 @@ When you have completed your implementation, follow these steps in order: - [ ] ``` + You don't need to add links back to the originating Slack thread or Linear ticket — for private repos, `open_pull_request` appends a `## References` section automatically. + When the target repo is public, don't reference private repos or private PR/issue numbers in the description. **Commit message**: Concise, focusing on the "why" rather than the "what". If not provided, the PR title is used. diff --git a/agent/tools/open_pull_request.py b/agent/tools/open_pull_request.py index 371fdd72..2a1193e1 100644 --- a/agent/tools/open_pull_request.py +++ b/agent/tools/open_pull_request.py @@ -13,11 +13,13 @@ from langgraph_sdk import get_client from ..dashboard.agent_usage import record_agent_pr_usage 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]: @@ -166,6 +168,65 @@ 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", {}) + 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}") + + if not lines: + return "" + return _REFERENCES_HEADING + "\n" + "\n".join(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_source_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. + """ + try: + if _REFERENCES_HEADING in body: + return body + references = await _build_source_references() + if not references: + return body + if not await _is_private_repo(client, token, owner, repo): + return body + return f"{body.rstrip()}\n\n{references}" + except Exception: + logger.debug("Failed to append source references to PR body", exc_info=True) + return body + + async def _open_pull_request( *, owner: str, @@ -183,8 +244,9 @@ async def _open_pull_request( "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: + body = await _maybe_append_source_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), diff --git a/agent/utils/slack.py b/agent/utils/slack.py index 5d3522a9..e260b42b 100644 --- a/agent/utils/slack.py +++ b/agent/utils/slack.py @@ -622,6 +622,39 @@ async def fetch_slack_message_by_ts(channel_id: str, message_ts: str) -> dict[st return None +async def get_slack_permalink(channel_id: str, message_ts: str) -> str | None: + """Return the public permalink for a Slack message, or None if unavailable.""" + if not SLACK_BOT_TOKEN or not channel_id or not message_ts: + return None + + async with httpx.AsyncClient() as http_client: + try: + response = await http_client.get( + f"{SLACK_API_BASE_URL}/chat.getPermalink", + headers=_slack_headers(), + params={"channel": channel_id, "message_ts": message_ts}, + ) + response.raise_for_status() + data = response.json() + if not data.get("ok"): + logger.warning( + "Slack chat.getPermalink failed for channel=%s ts=%s: %s", + channel_id, + message_ts, + data.get("error"), + ) + return None + permalink = data.get("permalink") + return permalink if isinstance(permalink, str) and permalink else None + except httpx.HTTPError: + logger.exception( + "Slack chat.getPermalink request failed for channel=%s ts=%s", + channel_id, + message_ts, + ) + return None + + async def resolve_slack_message_url(url: str) -> dict[str, Any] | None: """Resolve a Slack message URL to its message content. diff --git a/tests/test_open_pull_request.py b/tests/test_open_pull_request.py index 06edb0b5..52dbfa09 100644 --- a/tests/test_open_pull_request.py +++ b/tests/test_open_pull_request.py @@ -48,7 +48,38 @@ class _FakeClient: return self._get -def _install_client(monkeypatch: pytest.MonkeyPatch, client: _FakeClient) -> None: +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) @@ -208,6 +239,129 @@ 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" diff --git a/tests/test_slack_context.py b/tests/test_slack_context.py index 8fb99279..3757e091 100644 --- a/tests/test_slack_context.py +++ b/tests/test_slack_context.py @@ -8,6 +8,7 @@ from agent.utils.slack import ( TRACE_REPLY_TIPS, convert_mentions_to_slack_format, format_slack_messages_for_prompt, + get_slack_permalink, parse_github_pr_url, post_slack_trace_reply, replace_bot_mention_with_username, @@ -958,3 +959,63 @@ def test_process_slack_mention_bot_only_mode_runs_without_user_token( assert "run_create" in captured assert "prompt" not in captured + + +class _FakeResponse: + def __init__(self, payload: dict) -> None: + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return self._payload + + +class _FakeAsyncClient: + def __init__(self, payload: dict) -> None: + self._payload = payload + + async def __aenter__(self) -> "_FakeAsyncClient": + return self + + async def __aexit__(self, *exc: object) -> None: + return None + + async def get(self, url: str, **kwargs: object) -> _FakeResponse: + return _FakeResponse(self._payload) + + +def test_get_slack_permalink_returns_link(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "xoxb-test") + link = "https://workspace.slack.com/archives/C123/p1700000000000100" + monkeypatch.setattr( + slack_utils.httpx, + "AsyncClient", + lambda *a, **k: _FakeAsyncClient({"ok": True, "permalink": link}), + ) + + result = asyncio.run(get_slack_permalink("C123", "1700000000.000100")) + + assert result == link + + +def test_get_slack_permalink_returns_none_on_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "xoxb-test") + monkeypatch.setattr( + slack_utils.httpx, + "AsyncClient", + lambda *a, **k: _FakeAsyncClient({"ok": False, "error": "message_not_found"}), + ) + + result = asyncio.run(get_slack_permalink("C123", "1700000000.000100")) + + assert result is None + + +def test_get_slack_permalink_without_token_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(slack_utils, "SLACK_BOT_TOKEN", "") + + result = asyncio.run(get_slack_permalink("C123", "1700000000.000100")) + + assert result is None