diff --git a/agent/webapp.py b/agent/webapp.py index 55ace137..c7909c6a 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -108,6 +108,7 @@ from .utils.slack import ( fetch_slack_thread_messages, format_slack_messages_for_prompt, get_slack_channel_description, + get_slack_channel_info, get_slack_user_info, get_slack_user_names, post_slack_thread_reply, @@ -172,6 +173,10 @@ DEFAULT_REPO_OWNER = os.environ.get("DEFAULT_REPO_OWNER", "langchain-ai") DEFAULT_REPO_NAME = os.environ.get("DEFAULT_REPO_NAME", "") SLACK_REPO_OWNER = os.environ.get("SLACK_REPO_OWNER", "") or DEFAULT_REPO_OWNER SLACK_REPO_NAME = os.environ.get("SLACK_REPO_NAME", "") or DEFAULT_REPO_NAME +DOCS_PLZ_SLACK_CHANNEL_NAME = "docs-plz" +DOCS_PLZ_SLACK_GATE_REPLY = ( + "Please don't use Open SWE here, instead ask the Fleet docs-plz agent to implement the docs" +) LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get( "LANGGRAPH_URL_PROD", "http://localhost:2024" @@ -419,6 +424,22 @@ def _run_id_for_logging(run: Any) -> str: return run_id if isinstance(run_id, str) and run_id else "" +async def _is_docs_plz_slack_channel(channel_id: str) -> bool: + """Check whether a Slack channel is the docs-plz handoff channel.""" + try: + channel = await get_slack_channel_info(channel_id) + except Exception: # noqa: BLE001 + logger.exception("Failed to resolve Slack channel info for docs-plz gate") + return False + if not isinstance(channel, dict): + return False + candidate_names = (channel.get("name"), channel.get("name_normalized")) + return any( + isinstance(name, str) and name.strip().lower() == DOCS_PLZ_SLACK_CHANNEL_NAME + for name in candidate_names + ) + + def _is_repo_allowed(repo_config: dict[str, str]) -> bool: """Check if the repo is in the allowlist. @@ -1588,6 +1609,15 @@ async def slack_webhook(request: Request, background_tasks: BackgroundTasks) -> if bot_user_id and user_id == bot_user_id: return {"status": "ignored", "reason": "Event from this bot user"} + if await _is_docs_plz_slack_channel(channel_id): + background_tasks.add_task( + post_slack_thread_reply, + channel_id, + thread_ts, + DOCS_PLZ_SLACK_GATE_REPLY, + ) + return {"status": "accepted", "message": "Slack mention gated for docs-plz"} + event_data = { "channel_id": channel_id, "thread_ts": thread_ts, diff --git a/tests/test_github_issue_webhook.py b/tests/test_github_issue_webhook.py index 043c010c..eee94131 100644 --- a/tests/test_github_issue_webhook.py +++ b/tests/test_github_issue_webhook.py @@ -521,6 +521,81 @@ def test_github_webhook_ignores_review_requested(monkeypatch) -> None: } +def test_is_docs_plz_slack_channel_matches_name(monkeypatch) -> None: + async def fake_get_slack_channel_info(channel_id: str) -> dict[str, object]: + assert channel_id == "C_DOCS" + return {"name": "docs-plz"} + + monkeypatch.setattr(webapp, "get_slack_channel_info", fake_get_slack_channel_info) + + assert asyncio.run(webapp._is_docs_plz_slack_channel("C_DOCS")) is True + + +def test_is_docs_plz_slack_channel_matches_normalized_name(monkeypatch) -> None: + async def fake_get_slack_channel_info(channel_id: str) -> dict[str, object]: + assert channel_id == "C_DOCS" + return {"name": "Docs Plz", "name_normalized": "docs-plz"} + + monkeypatch.setattr(webapp, "get_slack_channel_info", fake_get_slack_channel_info) + + assert asyncio.run(webapp._is_docs_plz_slack_channel("C_DOCS")) is True + + +def test_slack_webhook_gates_docs_plz_channel(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_is_docs_plz_slack_channel(channel_id: str) -> bool: + captured["checked_channel_id"] = channel_id + return True + + async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, text: str) -> bool: + captured["reply"] = {"channel_id": channel_id, "thread_ts": thread_ts, "text": text} + return True + + async def fail_get_slack_repo_config( + channel_id: str, thread_ts: str, slack_user_id: str | None = None + ) -> dict[str, str]: + raise AssertionError("docs-plz gate should skip repo resolution") + + async def fail_process_slack_mention( + event_data: dict[str, object], repo_config: dict[str, str] + ) -> None: + raise AssertionError("docs-plz gate should not start the agent") + + monkeypatch.setattr(webapp, "SLACK_SIGNING_SECRET", _TEST_SLACK_SECRET) + monkeypatch.setattr(webapp, "SLACK_BOT_USER_ID", "UBOT") + monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe") + monkeypatch.setattr(slack_utils.time, "time", lambda: 1700000000) + monkeypatch.setattr(webapp, "_is_docs_plz_slack_channel", fake_is_docs_plz_slack_channel) + monkeypatch.setattr(webapp, "post_slack_thread_reply", fake_post_slack_thread_reply) + monkeypatch.setattr(webapp, "get_slack_repo_config", fail_get_slack_repo_config) + monkeypatch.setattr(webapp, "process_slack_mention", fail_process_slack_mention) + + client = TestClient(webapp.app) + response = _post_slack_webhook( + client, + { + "type": "event_callback", + "event": { + "type": "app_mention", + "channel": "C_DOCS", + "ts": "1700000000.000100", + "user": "U123", + "text": "<@UBOT> please update docs", + }, + }, + ) + + assert response.status_code == 200 + assert response.json() == {"status": "accepted", "message": "Slack mention gated for docs-plz"} + assert captured["checked_channel_id"] == "C_DOCS" + assert captured["reply"] == { + "channel_id": "C_DOCS", + "thread_ts": "1700000000.000100", + "text": webapp.DOCS_PLZ_SLACK_GATE_REPLY, + } + + def test_slack_webhook_routes_review_command_to_agent(monkeypatch) -> None: captured: dict[str, object] = {}