diff --git a/CUSTOMIZATION.md b/CUSTOMIZATION.md index 87e60390..628a3e20 100644 --- a/CUSTOMIZATION.md +++ b/CUSTOMIZATION.md @@ -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 diff --git a/agent/utils/slack.py b/agent/utils/slack.py index f53354f8..5d3522a9 100644 --- a/agent/utils/slack.py +++ b/agent/utils/slack.py @@ -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}) diff --git a/agent/webapp.py b/agent/webapp.py index d181f599..f0e90995 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -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) diff --git a/tests/test_repo_extraction.py b/tests/test_repo_extraction.py index 4ea57bd2..dc47b5f6 100644 --- a/tests/test_repo_extraction.py +++ b/tests/test_repo_extraction.py @@ -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."""