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:
Mason Daugherty 2026-06-08 16:53:16 -04:00 • committed by GitHub
parent ffb9366fc1
commit ab2e0d1da3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 106 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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