From 194e9cd60eebb26f0f42708392e36336f1cc5bb0 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Thu, 11 Jun 2026 15:52:40 -0700 Subject: [PATCH] feat: link Slack thread/Linear ticket in PRs and append ticket to title (#1504) * 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] * fix: use [closes ] format in PR title suffix Co-authored-by: open-swe[bot] * 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] --------- Co-authored-by: open-swe[bot] --- agent/prompt.py | 7 +- agent/tools/open_pull_request.py | 64 ++++++++++++- agent/utils/slack.py | 33 +++++++ tests/test_open_pull_request.py | 156 ++++++++++++++++++++++++++++++- tests/test_slack_context.py | 61 ++++++++++++ 5 files changed, 317 insertions(+), 4 deletions(-) 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