mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 18:33:15 +00:00
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:
parent
ce3af2d9b2
commit
194e9cd60e
5 changed files with 317 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue