mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +00:00
feat: resolve Slack repo from channel topic/purpose (#1453)
Add conversations.info fetch so a repo:owner/name (or GitHub URL) token in a Slack channel's topic/purpose pins the channel to a repo, slotting in just below thread metadata in get_slack_repo_config. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
ffb9366fc1
commit
ab2e0d1da3
4 changed files with 106 additions and 4 deletions
|
|
@ -327,9 +327,17 @@ Users can also override the team/project mapping on a per-comment basis by inclu
|
|||
|
||||
### Customizing Slack routing
|
||||
|
||||
Slack uses `DEFAULT_REPO_OWNER` and `DEFAULT_REPO_NAME` as the fallback when no repo is specified in a message.
|
||||
Slack repo resolution (`get_slack_repo_config` in `agent/webapp.py`) checks, in order:
|
||||
|
||||
Users can override per-message with `repo:owner/name` syntax in their Slack message. A shorthand `repo:name` (without the org) is also supported — the org defaults to `DEFAULT_REPO_OWNER`.
|
||||
1. Repo carried over from the existing Slack thread's metadata.
|
||||
2. A `repo:owner/name` (or GitHub URL) token in the channel's **topic or purpose** (its "description"). This lets a channel be pinned to a repo without anyone repeating it per-message.
|
||||
3. The triggering user's dashboard `default_repo`.
|
||||
4. The team default repo.
|
||||
5. `SLACK_REPO_OWNER`/`SLACK_REPO_NAME`, falling back to `DEFAULT_REPO_OWNER`/`DEFAULT_REPO_NAME`.
|
||||
|
||||
Users can still override per-message with `repo:owner/name` syntax in their Slack message (this is read from the message text by the agent). A shorthand `repo:name` (without the org) is also supported — the org defaults to `DEFAULT_REPO_OWNER`.
|
||||
|
||||
Reading the channel topic/purpose requires the bot's Slack token to have the `channels:read` (and `groups:read` for private channels) scope so `conversations.info` succeeds.
|
||||
|
||||
### Adding a new trigger
|
||||
|
||||
|
|
|
|||
|
|
@ -438,6 +438,51 @@ async def get_slack_user_info(user_id: str) -> dict[str, Any] | None:
|
|||
return None
|
||||
|
||||
|
||||
async def get_slack_channel_info(channel_id: str) -> dict[str, Any] | None:
|
||||
"""Get Slack channel details (including topic/purpose) by channel ID."""
|
||||
if not SLACK_BOT_TOKEN:
|
||||
return None
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
try:
|
||||
response = await http_client.get(
|
||||
f"{SLACK_API_BASE_URL}/conversations.info",
|
||||
headers=_slack_headers(),
|
||||
params={"channel": channel_id},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not data.get("ok"):
|
||||
logger.warning("Slack conversations.info failed: %s", data.get("error"))
|
||||
return None
|
||||
channel = data.get("channel")
|
||||
if isinstance(channel, dict):
|
||||
return channel
|
||||
except httpx.HTTPError:
|
||||
logger.exception("Slack conversations.info request failed")
|
||||
return None
|
||||
|
||||
|
||||
def extract_channel_description_text(channel: dict[str, Any] | None) -> str:
|
||||
"""Combine a Slack channel's topic and purpose text into one string."""
|
||||
if not isinstance(channel, dict):
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for key in ("topic", "purpose"):
|
||||
section = channel.get(key)
|
||||
if isinstance(section, dict):
|
||||
value = section.get("value")
|
||||
if isinstance(value, str) and value.strip():
|
||||
parts.append(value.strip())
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def get_slack_channel_description(channel_id: str) -> str:
|
||||
"""Fetch a Slack channel's combined topic + purpose text."""
|
||||
channel = await get_slack_channel_info(channel_id)
|
||||
return extract_channel_description_text(channel)
|
||||
|
||||
|
||||
async def get_slack_user_names(user_ids: list[str]) -> dict[str, str]:
|
||||
"""Get display names for a set of Slack user IDs."""
|
||||
unique_ids = sorted({user_id for user_id in user_ids if isinstance(user_id, str) and user_id})
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ from .utils.slack import (
|
|||
GitHubPrRef,
|
||||
fetch_slack_thread_messages,
|
||||
format_slack_messages_for_prompt,
|
||||
get_slack_channel_description,
|
||||
get_slack_user_info,
|
||||
get_slack_user_names,
|
||||
post_slack_thread_reply,
|
||||
|
|
@ -573,9 +574,11 @@ async def get_slack_repo_config(
|
|||
|
||||
Priority:
|
||||
1. Repo carried over from the existing Slack thread's metadata.
|
||||
2. The triggering user's dashboard ``default_repo`` (if they have a
|
||||
2. A ``repo:owner/name`` token in the channel's topic/purpose.
|
||||
3. The triggering user's dashboard ``default_repo`` (if they have a
|
||||
profile and their Slack email maps to a known GitHub login).
|
||||
3. ``SLACK_REPO_*`` env defaults.
|
||||
4. Team default repo.
|
||||
5. ``SLACK_REPO_*`` env defaults.
|
||||
"""
|
||||
default_owner = SLACK_REPO_OWNER.strip() or DEFAULT_REPO_OWNER
|
||||
default_name = SLACK_REPO_NAME.strip() or DEFAULT_REPO_NAME
|
||||
|
|
@ -596,6 +599,24 @@ async def get_slack_repo_config(
|
|||
thread_id,
|
||||
)
|
||||
|
||||
if not repo_config:
|
||||
try:
|
||||
channel_description = await get_slack_channel_description(channel_id)
|
||||
if channel_description:
|
||||
channel_repo_config = extract_repo_from_text(
|
||||
channel_description, default_owner=default_owner
|
||||
)
|
||||
if channel_repo_config:
|
||||
logger.info(
|
||||
"Applying repo from Slack channel %s description: %s/%s",
|
||||
channel_id,
|
||||
channel_repo_config["owner"],
|
||||
channel_repo_config["name"],
|
||||
)
|
||||
repo_config = channel_repo_config
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Failed to resolve repo from Slack channel description")
|
||||
|
||||
if not repo_config and slack_user_id:
|
||||
try:
|
||||
slack_user = await get_slack_user_info(slack_user_id)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch
|
|||
import pytest
|
||||
|
||||
from agent.utils.repo import extract_repo_from_text
|
||||
from agent.utils.slack import extract_channel_description_text
|
||||
|
||||
|
||||
class TestExtractRepoFromText:
|
||||
|
|
@ -54,6 +55,33 @@ class TestExtractRepoFromText:
|
|||
assert result == {"owner": "my-org", "name": "my-repo"}
|
||||
|
||||
|
||||
class TestExtractChannelDescriptionText:
|
||||
def test_combines_topic_and_purpose(self) -> None:
|
||||
channel = {
|
||||
"topic": {"value": "repo:my-org/my-repo"},
|
||||
"purpose": {"value": "Team channel"},
|
||||
}
|
||||
assert extract_channel_description_text(channel) == "repo:my-org/my-repo\nTeam channel"
|
||||
|
||||
def test_handles_missing_sections(self) -> None:
|
||||
assert extract_channel_description_text({"topic": {"value": "hi"}}) == "hi"
|
||||
|
||||
def test_empty_for_none(self) -> None:
|
||||
assert extract_channel_description_text(None) == ""
|
||||
|
||||
def test_empty_for_blank_values(self) -> None:
|
||||
channel = {"topic": {"value": " "}, "purpose": {"value": ""}}
|
||||
assert extract_channel_description_text(channel) == ""
|
||||
|
||||
def test_repo_token_extractable_from_description(self) -> None:
|
||||
channel = {"topic": {"value": "Use repo:langchain-ai/open-swe here"}, "purpose": {}}
|
||||
description = extract_channel_description_text(channel)
|
||||
assert extract_repo_from_text(description) == {
|
||||
"owner": "langchain-ai",
|
||||
"name": "open-swe",
|
||||
}
|
||||
|
||||
|
||||
class TestLinearWebhookRepoOverride:
|
||||
"""Test that the Linear webhook handler checks comment body for repo config first."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue