refactor: drop regex-based repo extraction from Slack/Linear webhooks

A user mention of "what the default repo is?" was being parsed by the
`repo[: ]([a-zA-Z0-9_.\-/]+)` regex in `extract_repo_from_text`: the
space form matched "repo is" and captured "is" as the repo name, which
combined with the default owner to silently route the run to
`langchain-ai/is`. The user's profile `default_repo` was never reached
because the regex short-circuited first.

The freeform extraction was solving a problem that user-level config
already handles better:

  * Slack thread metadata carries the repo across follow-up mentions.
  * Dashboard profile `default_repo` is per-user and explicit.
  * Linear team/project mapping handles per-team routing.
  * GitHub webhooks know the repo from the payload — no parsing needed.

So `extract_repo_from_text` and its tests are gone. `get_slack_repo_config`
and the Linear webhook now fall through: thread metadata (Slack) → user
profile → env / team-mapping defaults. Users who want a one-off override
update their dashboard profile or post in a thread that's already on
the target repo.

Obsolete tests removed; `test_get_slack_repo_config_uses_thread_metadata_when_set`
added to cover the remaining "thread metadata wins" path.
This commit is contained in:
Johannes du Plessis 2026-05-15 14:02:28 -07:00
parent 6647c3463e
commit e215f1ef8d
4 changed files with 32 additions and 395 deletions

View file

@ -1,42 +0,0 @@
"""Utilities for extracting repository configuration from text."""
from __future__ import annotations
import os
import re
_DEFAULT_REPO_OWNER = os.environ.get("DEFAULT_REPO_OWNER", "langchain-ai")
def extract_repo_from_text(text: str, default_owner: str | None = None) -> dict[str, str] | None:
"""Extract owner/name repo config from text containing repo: syntax or GitHub URLs.
Checks for explicit ``repo:owner/name`` or ``repo owner/name`` first, then
falls back to GitHub URL extraction.
Returns:
A dict with ``owner`` and ``name`` keys, or ``None`` if no repo found.
"""
if default_owner is None:
default_owner = _DEFAULT_REPO_OWNER
owner: str | None = None
name: str | None = None
if "repo:" in text or "repo " in text:
match = re.search(r"repo[: ]([a-zA-Z0-9_.\-/]+)", text)
if match:
value = match.group(1).rstrip("/")
if "/" in value:
owner, name = value.split("/", 1)
else:
owner = default_owner
name = value
if not owner or not name:
github_match = re.search(r"github\.com/([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+)", text)
if github_match:
owner, name = github_match.group(1).split("/", 1)
if owner and name:
return {"owner": owner, "name": name}
return None

View file

@ -59,7 +59,6 @@ from .utils.github_user_email_map import GITHUB_USER_EMAIL_MAP
from .utils.linear import post_linear_trace_comment from .utils.linear import post_linear_trace_comment
from .utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO from .utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO
from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block
from .utils.repo import extract_repo_from_text
from .utils.sandbox import validate_sandbox_startup_config from .utils.sandbox import validate_sandbox_startup_config
from .utils.slack import ( from .utils.slack import (
GitHubPrRef, GitHubPrRef,
@ -494,31 +493,29 @@ async def get_slack_repo_config(
"""Resolve repository configuration for Slack-triggered runs. """Resolve repository configuration for Slack-triggered runs.
Priority: Priority:
1. Explicit ``owner/repo`` mention in the message body. 1. Repo carried over from the existing Slack thread's metadata.
2. Repo carried over from the existing Slack thread's metadata. 2. The triggering user's dashboard ``default_repo`` (if they have a
3. The triggering user's dashboard ``default_repo`` (if they have a
profile and their Slack email maps to a known GitHub login). profile and their Slack email maps to a known GitHub login).
4. ``SLACK_REPO_*`` env defaults. 3. ``SLACK_REPO_*`` env defaults.
""" """
default_owner = SLACK_REPO_OWNER.strip() or DEFAULT_REPO_OWNER default_owner = SLACK_REPO_OWNER.strip() or DEFAULT_REPO_OWNER
default_name = SLACK_REPO_NAME.strip() or DEFAULT_REPO_NAME default_name = SLACK_REPO_NAME.strip() or DEFAULT_REPO_NAME
thread_id = generate_thread_id_from_slack_thread(channel_id, thread_ts) thread_id = generate_thread_id_from_slack_thread(channel_id, thread_ts)
langgraph_client = get_client(url=LANGGRAPH_URL) langgraph_client = get_client(url=LANGGRAPH_URL)
repo_config = extract_repo_from_text(message, default_owner=default_owner) repo_config: dict[str, str] | None = None
if not repo_config: try:
try: thread = await langgraph_client.threads.get(thread_id)
thread = await langgraph_client.threads.get(thread_id) thread_repo_config = _extract_repo_config_from_thread(thread)
thread_repo_config = _extract_repo_config_from_thread(thread) if thread_repo_config:
if thread_repo_config: repo_config = thread_repo_config
repo_config = thread_repo_config except Exception as exc: # noqa: BLE001
except Exception as exc: # noqa: BLE001 if not _is_not_found_error(exc):
if not _is_not_found_error(exc): logger.exception(
logger.exception( "Failed to fetch Slack thread %s for repo resolution",
"Failed to fetch Slack thread %s for repo resolution", thread_id,
thread_id, )
)
if not repo_config and slack_user_id: if not repo_config and slack_user_id:
try: try:
@ -1171,31 +1168,21 @@ async def linear_webhook( # noqa: PLR0911, PLR0912, PLR0915
logger.warning("Failed to fetch full issue details, using webhook data") logger.warning("Failed to fetch full issue details, using webhook data")
full_issue = issue full_issue = issue
repo_config = extract_repo_from_text(comment_body, default_owner=DEFAULT_REPO_OWNER) repo_config: dict[str, str] | None = None
comment_user_email = (data.get("user") or {}).get("email")
if repo_config: try:
logger.debug( profile_repo = await get_profile_default_repo(resolve_login_from_email(comment_user_email))
"Using repo from comment body: %s/%s", except Exception: # noqa: BLE001
repo_config["owner"], logger.exception("Failed to apply dashboard default_repo for Linear user")
repo_config["name"], profile_repo = None
if profile_repo:
logger.info(
"Applying dashboard default_repo for Linear user %s: %s/%s",
comment_user_email,
profile_repo["owner"],
profile_repo["name"],
) )
else: repo_config = profile_repo
comment_user_email = (data.get("user") or {}).get("email")
try:
profile_repo = await get_profile_default_repo(
resolve_login_from_email(comment_user_email)
)
except Exception: # noqa: BLE001
logger.exception("Failed to apply dashboard default_repo for Linear user")
profile_repo = None
if profile_repo:
logger.info(
"Applying dashboard default_repo for Linear user %s: %s/%s",
comment_user_email,
profile_repo["owner"],
profile_repo["name"],
)
repo_config = profile_repo
if not repo_config: if not repo_config:
team = full_issue.get("team", {}) team = full_issue.get("team", {})

View file

@ -1,159 +0,0 @@
"""Tests for agent.utils.repo and Linear webhook repo override behavior."""
import json
from unittest.mock import AsyncMock, patch
import pytest
from agent.utils.repo import extract_repo_from_text
class TestExtractRepoFromText:
def test_repo_colon_with_org(self) -> None:
result = extract_repo_from_text("please use repo:my-org/my-repo")
assert result == {"owner": "my-org", "name": "my-repo"}
def test_repo_space_with_org(self) -> None:
result = extract_repo_from_text("please use repo langchain-ai/langchainjs")
assert result == {"owner": "langchain-ai", "name": "langchainjs"}
def test_repo_colon_name_only_uses_default_owner(self) -> None:
result = extract_repo_from_text("fix bug in repo:langchainplus")
assert result == {"owner": "langchain-ai", "name": "langchainplus"}
def test_repo_space_name_only_uses_default_owner(self) -> None:
result = extract_repo_from_text("fix bug in repo open-swe")
assert result == {"owner": "langchain-ai", "name": "open-swe"}
def test_repo_name_only_custom_default_owner(self) -> None:
result = extract_repo_from_text("repo:my-repo", default_owner="custom-org")
assert result == {"owner": "custom-org", "name": "my-repo"}
def test_github_url(self) -> None:
result = extract_repo_from_text(
"check https://github.com/langchain-ai/langgraph-api please"
)
assert result == {"owner": "langchain-ai", "name": "langgraph-api"}
def test_explicit_repo_beats_github_url(self) -> None:
result = extract_repo_from_text(
"see https://github.com/langchain-ai/langgraph-api but use repo:my-org/my-repo"
)
assert result == {"owner": "my-org", "name": "my-repo"}
def test_no_repo_returns_none(self) -> None:
result = extract_repo_from_text("please fix the bug")
assert result is None
def test_empty_string_returns_none(self) -> None:
result = extract_repo_from_text("")
assert result is None
def test_trailing_slash_stripped(self) -> None:
result = extract_repo_from_text("repo:my-org/my-repo/")
assert result == {"owner": "my-org", "name": "my-repo"}
class TestLinearWebhookRepoOverride:
"""Test that the Linear webhook handler checks comment body for repo config first."""
@pytest.fixture()
def _base_payload(self) -> dict:
return {
"type": "Comment",
"action": "create",
"data": {
"id": "comment-123",
"body": "@openswe please fix this repo:custom-org/custom-repo",
"issue": {
"id": "issue-456",
"title": "Test issue",
},
"user": {"id": "user-1", "name": "Test User", "email": "test@test.com"},
},
}
@pytest.mark.asyncio
async def test_comment_repo_overrides_team_mapping(self, _base_payload: dict) -> None:
from agent.webapp import linear_webhook
with (
patch("agent.webapp.verify_linear_signature", return_value=True),
patch(
"agent.webapp.fetch_linear_issue_details",
new_callable=AsyncMock,
return_value={
"id": "issue-456",
"title": "Test issue",
"identifier": "TEST-1",
"url": "https://linear.app/test/issue/TEST-1",
"team": {"id": "t1", "name": "Some Team", "key": "ST"},
"project": {"id": "p1", "name": "Some Project"},
"comments": {"nodes": []},
},
),
patch("agent.webapp._is_repo_allowed", return_value=True),
patch("agent.webapp.BackgroundTasks"),
):
mock_request = AsyncMock()
mock_request.body.return_value = json.dumps(_base_payload).encode()
mock_request.headers = {"Linear-Signature": "valid"}
bg_tasks = AsyncMock()
result = await linear_webhook(mock_request, bg_tasks)
assert result["status"] == "accepted"
assert "custom-org/custom-repo" in result["message"]
call_args = bg_tasks.add_task.call_args
repo_config = call_args[0][2]
assert repo_config == {"owner": "custom-org", "name": "custom-repo"}
@pytest.mark.asyncio
async def test_falls_back_to_team_mapping_when_no_repo_in_comment(self) -> None:
from agent.webapp import linear_webhook
payload = {
"type": "Comment",
"action": "create",
"data": {
"id": "comment-123",
"body": "@openswe please fix this bug",
"issue": {
"id": "issue-456",
"title": "Test issue",
},
"user": {"id": "user-1", "name": "Test User", "email": "test@test.com"},
},
}
with (
patch("agent.webapp.verify_linear_signature", return_value=True),
patch(
"agent.webapp.fetch_linear_issue_details",
new_callable=AsyncMock,
return_value={
"id": "issue-456",
"title": "Test issue",
"identifier": "TEST-1",
"url": "https://linear.app/test/issue/TEST-1",
"team": {"id": "t1", "name": "Open SWE", "key": "OS"},
"project": None,
"comments": {"nodes": []},
},
),
patch("agent.webapp._is_repo_allowed", return_value=True),
):
mock_request = AsyncMock()
mock_request.body.return_value = json.dumps(payload).encode()
mock_request.headers = {"Linear-Signature": "valid"}
bg_tasks = AsyncMock()
result = await linear_webhook(mock_request, bg_tasks)
assert result["status"] == "accepted"
assert "langchain-ai/open-swe" in result["message"]
call_args = bg_tasks.add_task.call_args
repo_config = call_args[0][2]
assert repo_config == {"owner": "langchain-ai", "name": "open-swe"}

View file

@ -301,46 +301,20 @@ def test_select_slack_context_messages_detects_username_mention() -> None:
assert [item["ts"] for item in selected] == ["1.0", "2.0", "3.0"] assert [item["ts"] for item in selected] == ["1.0", "2.0", "3.0"]
def test_get_slack_repo_config_message_repo_overrides_existing_thread_repo( def test_get_slack_repo_config_uses_thread_metadata_when_set(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
threads_client = _FakeThreadsClient( threads_client = _FakeThreadsClient(
thread={"metadata": {"repo": {"owner": "saved-owner", "name": "saved-repo"}}} thread={"metadata": {"repo": {"owner": "saved-owner", "name": "saved-repo"}}}
) )
posted = False
async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, text: str) -> bool:
nonlocal posted
posted = True
return True
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
monkeypatch.setattr(
webapp, "post_slack_thread_reply", fake_post_slack_thread_reply, raising=False
)
repo = asyncio.run(
webapp.get_slack_repo_config("please use repo:new-owner/new-repo", "C123", "1.234")
)
assert repo == {"owner": "new-owner", "name": "new-repo"}
assert threads_client.requested_thread_id is None
assert not posted
def test_get_slack_repo_config_parses_message_for_new_thread(
monkeypatch: pytest.MonkeyPatch,
) -> None:
threads_client = _FakeThreadsClient(raise_not_found=True)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client)) monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
repo = asyncio.run( repo = asyncio.run(
webapp.get_slack_repo_config("please use repo:new-owner/new-repo", "C123", "1.234") webapp.get_slack_repo_config("please just help with this bug", "C123", "1.234")
) )
assert repo == {"owner": "new-owner", "name": "new-repo"} assert repo == {"owner": "saved-owner", "name": "saved-repo"}
def test_get_slack_repo_config_existing_thread_without_repo_uses_default( def test_get_slack_repo_config_existing_thread_without_repo_uses_default(
@ -360,129 +334,6 @@ def test_get_slack_repo_config_existing_thread_without_repo_uses_default(
) )
def test_get_slack_repo_config_space_syntax_detected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""repo owner/name (space instead of colon) should be detected correctly."""
threads_client = _FakeThreadsClient(raise_not_found=True)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
repo = asyncio.run(
webapp.get_slack_repo_config(
"please fix the bug in repo langchain-ai/langchainjs", "C123", "1.234"
)
)
assert repo == {"owner": "langchain-ai", "name": "langchainjs"}
def test_get_slack_repo_config_github_url_extracted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""GitHub URL in message should be used to detect the repo."""
threads_client = _FakeThreadsClient(raise_not_found=True)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
repo = asyncio.run(
webapp.get_slack_repo_config(
"I found a bug in https://github.com/langchain-ai/langgraph-api please fix it",
"C123",
"1.234",
)
)
assert repo == {"owner": "langchain-ai", "name": "langgraph-api"}
def test_get_slack_repo_config_explicit_repo_beats_github_url(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Explicit repo: syntax takes priority over a GitHub URL also present in the message."""
threads_client = _FakeThreadsClient(raise_not_found=True)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
repo = asyncio.run(
webapp.get_slack_repo_config(
"see https://github.com/langchain-ai/langgraph-api but use repo:my-org/my-repo",
"C123",
"1.234",
)
)
assert repo == {"owner": "my-org", "name": "my-repo"}
def test_get_slack_repo_config_explicit_space_syntax_beats_thread_metadata(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Explicit repo owner/name (space syntax) takes priority over saved thread metadata."""
threads_client = _FakeThreadsClient(
thread={"metadata": {"repo": {"owner": "saved-owner", "name": "saved-repo"}}}
)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
repo = asyncio.run(
webapp.get_slack_repo_config(
"actually use repo langchain-ai/langchainjs today", "C123", "1.234"
)
)
assert repo == {"owner": "langchain-ai", "name": "langchainjs"}
def test_get_slack_repo_config_github_url_beats_thread_metadata(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A GitHub URL in the message takes priority over saved thread metadata."""
threads_client = _FakeThreadsClient(
thread={"metadata": {"repo": {"owner": "saved-owner", "name": "saved-repo"}}}
)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
repo = asyncio.run(
webapp.get_slack_repo_config(
"I found a bug in https://github.com/langchain-ai/langgraph-api",
"C123",
"1.234",
)
)
assert repo == {"owner": "langchain-ai", "name": "langgraph-api"}
def test_get_slack_repo_config_repo_name_only_defaults_org(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""repo:name without org should default owner to langchain-ai."""
threads_client = _FakeThreadsClient(raise_not_found=True)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
repo = asyncio.run(
webapp.get_slack_repo_config("fix bug in repo:langchainplus", "C123", "1.234")
)
assert repo == {"owner": "langchain-ai", "name": "langchainplus"}
def test_get_slack_repo_config_repo_name_only_space_syntax(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""repo name (space syntax, no org) should default owner to langchain-ai."""
threads_client = _FakeThreadsClient(raise_not_found=True)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client))
repo = asyncio.run(webapp.get_slack_repo_config("fix bug in repo open-swe", "C123", "1.234"))
assert repo == {"owner": "langchain-ai", "name": "open-swe"}
def _setup_slack_mention_fakes( def _setup_slack_mention_fakes(
monkeypatch: pytest.MonkeyPatch, captured: dict[str, object] monkeypatch: pytest.MonkeyPatch, captured: dict[str, object]
) -> None: ) -> None: