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] <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>
This commit is contained in:
Johannes du Plessis 2026-06-11 15:52:40 -07:00 • committed by GitHub
parent ce3af2d9b2
commit 194e9cd60e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 317 additions and 4 deletions

View file

@ -312,9 +312,10 @@ When you have completed your implementation, follow these steps in order:
**PR Title** (under 70 characters):
```
<type>: <concise description> [closes {linear_project_id}-{linear_issue_number}]
<type>: <concise description> [closes <TICKET>]
```
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:
- [ ] <new/novel verification steps only — NOT "run existing tests" or "verify existing behavior">
```
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.

View file

@ -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),

View file

@ -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.

View file

@ -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"

View file

@ -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