From 46d7ed9d4307a4f8d72ff4d7624f0458361fca3d Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Fri, 20 Mar 2026 13:34:00 -0700 Subject: [PATCH] feat: extract repo parsing into shared util, add linear comment repo override (#1103) * feat: extract repo parsing into shared util and add linear comment repo override Moves the repo extraction regex logic (repo:, repo , GitHub URL) into agent/utils/repo.py so it can be reused. Updates the Linear webhook handler to check the comment body for a custom repo first, falling back to the team/project mapping when none is specified. * chore: document repo extraction util and default org configuration * feat: add generic DEFAULT_REPO_OWNER/DEFAULT_REPO_NAME env vars replacing Slack-only defaults * chore: remove deprecated SLACK_REPO_OWNER/NAME env vars from docs --------- Co-authored-by: open-swe[bot] --- CUSTOMIZATION.md | 34 ++++++-- INSTALLATION.md | 18 ++-- agent/utils/repo.py | 42 +++++++++ agent/webapp.py | 100 +++++++++------------ tests/test_repo_extraction.py | 159 ++++++++++++++++++++++++++++++++++ 5 files changed, 279 insertions(+), 74 deletions(-) create mode 100644 agent/utils/repo.py create mode 100644 tests/test_repo_extraction.py diff --git a/CUSTOMIZATION.md b/CUSTOMIZATION.md index 924de38a..74d507de 100644 --- a/CUSTOMIZATION.md +++ b/CUSTOMIZATION.md @@ -264,6 +264,29 @@ To fully remove a trigger's code, delete the corresponding endpoint from `agent/ - **Linear**: `linear_webhook()` and `process_linear_issue()` - **Slack**: `slack_webhook()` and `process_slack_mention()` +### Default repository + +Set the default GitHub org and repo used across all triggers (Slack, Linear, GitHub) when no repo is specified: + +```bash +DEFAULT_REPO_OWNER="my-org" # Default GitHub org (used everywhere) +DEFAULT_REPO_NAME="my-repo" # Default GitHub repo (used everywhere) +``` + +These are used as the fallback when: +- A Slack message doesn't specify a repo (and no thread metadata exists) +- A Linear issue's team/project isn't in the `LINEAR_TEAM_TO_REPO` mapping +- A user writes `repo:name` without an org prefix — the org defaults to `DEFAULT_REPO_OWNER` + +### Repository extraction from messages + +Both Slack and Linear support specifying a target repo directly in the message or comment text. The shared utility `extract_repo_from_text()` in `agent/utils/repo.py` handles parsing these formats: + +- `repo:owner/name` — explicit org and repo +- `repo owner/name` — space syntax (same result) +- `repo:name` — repo name only; the org defaults to `DEFAULT_REPO_OWNER` +- `https://github.com/owner/name` — GitHub URL + ### Customizing Linear routing The `LINEAR_TEAM_TO_REPO` dict in `agent/utils/linear_team_repo_map.py` maps Linear teams and projects to GitHub repos: @@ -280,16 +303,13 @@ LINEAR_TEAM_TO_REPO = { } ``` +Users can also override the team/project mapping on a per-comment basis by including `repo:owner/name` in their `@openswe` comment. This takes priority over the mapping — the mapping is used as a fallback when no repo is specified in the comment. If the team/project isn't found in the mapping either, `DEFAULT_REPO_OWNER`/`DEFAULT_REPO_NAME` is used. + ### Customizing Slack routing -Slack uses env vars for default routing: +Slack uses `DEFAULT_REPO_OWNER` and `DEFAULT_REPO_NAME` as the fallback when no repo is specified in a message. -```bash -SLACK_REPO_OWNER="my-org" -SLACK_REPO_NAME="my-repo" -``` - -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 the value of `SLACK_REPO_OWNER`. +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`. ### Adding a new trigger diff --git a/INSTALLATION.md b/INSTALLATION.md index 41272e9c..3013949e 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -204,6 +204,8 @@ LINEAR_TEAM_TO_REPO = { } ``` +Users can also override the team/project mapping per-comment by including `repo:owner/name` (or a GitHub URL) in their `@openswe` comment. The mapping is used as a fallback when no repo is specified in the comment text. + ### Slack (optional) **Create a Slack App:** @@ -285,14 +287,9 @@ LINEAR_TEAM_TO_REPO = { - `SLACK_BOT_USER_ID`: the bot's user ID (find it in Slack by clicking the bot's profile) - `SLACK_BOT_USERNAME`: the bot's display name (e.g. `open-swe`) -**Configure default repo:** +**Default repo:** -Slack messages are routed to a default repo unless the user specifies one with `repo:owner/name`: - -```bash -SLACK_REPO_OWNER="my-org" # Default GitHub org -SLACK_REPO_NAME="my-repo" # Default GitHub repo -``` +Slack messages are routed to the default repo (`DEFAULT_REPO_OWNER`/`DEFAULT_REPO_NAME` — see step 6) unless the user specifies one with `repo:owner/name` in their message. ## 6. Environment variables @@ -331,6 +328,11 @@ GITHUB_OAUTH_PROVIDER_ID="" # The provider ID from steps 3a / 4b # Leave empty to allow all orgs. ALLOWED_GITHUB_ORGS="" # e.g. "my-org,my-other-org" +# === Default Repository === +# Used across all triggers when no repo is specified. +DEFAULT_REPO_OWNER="" # Default GitHub org (e.g. "my-org") +DEFAULT_REPO_NAME="" # Default GitHub repo (e.g. "my-repo") + # === Linear (if using Linear trigger) === LINEAR_API_KEY="" # From step 5 LINEAR_WEBHOOK_SECRET="" # From step 5 @@ -340,8 +342,6 @@ SLACK_BOT_TOKEN="" # From step 5 SLACK_BOT_USER_ID="" SLACK_BOT_USERNAME="" SLACK_SIGNING_SECRET="" -SLACK_REPO_OWNER="" # Default org for Slack-triggered tasks -SLACK_REPO_NAME="" # Default repo for Slack-triggered tasks # === Sandbox (optional) === DEFAULT_SANDBOX_TEMPLATE_NAME="" # Custom sandbox template name (default: deepagents-cli) diff --git a/agent/utils/repo.py b/agent/utils/repo.py new file mode 100644 index 00000000..01951937 --- /dev/null +++ b/agent/utils/repo.py @@ -0,0 +1,42 @@ +"""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 diff --git a/agent/webapp.py b/agent/webapp.py index a44d86b9..c8a7f958 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -5,7 +5,6 @@ import hmac import json import logging import os -import re import uuid from typing import Any @@ -39,6 +38,7 @@ from .utils.github_user_email_map import GITHUB_USER_EMAIL_MAP from .utils.linear import post_linear_trace_comment 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.repo import extract_repo_from_text from .utils.slack import ( add_slack_reaction, fetch_slack_thread_messages, @@ -61,8 +61,10 @@ GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "") SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "") SLACK_BOT_USER_ID = os.environ.get("SLACK_BOT_USER_ID", "") SLACK_BOT_USERNAME = os.environ.get("SLACK_BOT_USERNAME", "") -SLACK_REPO_OWNER = os.environ.get("SLACK_REPO_OWNER", "langchain-ai") -SLACK_REPO_NAME = os.environ.get("SLACK_REPO_NAME", "open-swe") +DEFAULT_REPO_OWNER = os.environ.get("DEFAULT_REPO_OWNER", "langchain-ai") +DEFAULT_REPO_NAME = os.environ.get("DEFAULT_REPO_NAME", "langchainplus") +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 LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get( "LANGGRAPH_URL_PROD", "http://localhost:2024" @@ -96,20 +98,11 @@ _GITHUB_BOT_MESSAGE_PREFIXES = ( def get_repo_config_from_team_mapping( team_identifier: str, project_name: str = "" ) -> dict[str, str]: - """ - Look up repository configuration from LINEAR_TEAM_TO_REPO mapping. + """Look up repository configuration from LINEAR_TEAM_TO_REPO mapping.""" + fallback = {"owner": DEFAULT_REPO_OWNER, "name": DEFAULT_REPO_NAME} - Supports both legacy flat mapping (team -> repo) and new nested mapping (team -> project -> repo). - - Args: - team_identifier: Team name or ID to look up (e.g., "LangChain OSS") - project_name: Name of the project (e.g., "deepagents") - - Returns: - Repository config dict with 'owner' and 'name' keys. Defaults to langchainplus if not found. - """ if not team_identifier or team_identifier not in LINEAR_TEAM_TO_REPO: - return {"owner": "langchain-ai", "name": "langchainplus"} + return fallback config = LINEAR_TEAM_TO_REPO[team_identifier] @@ -124,7 +117,7 @@ def get_repo_config_from_team_mapping( if "default" in config: return config["default"] - return {"owner": "langchain-ai", "name": "langchainplus"} + return fallback async def react_to_linear_comment(comment_id: str, emoji: str = "👀") -> bool: @@ -346,36 +339,19 @@ async def check_if_using_repo_msg_sent( async def get_slack_repo_config(message: str, channel_id: str, thread_ts: str) -> dict[str, str]: """Resolve repository configuration for Slack-triggered runs.""" - default_owner = SLACK_REPO_OWNER.strip() or "langchain-ai" - default_name = SLACK_REPO_NAME.strip() or "langchainplus" + default_owner = SLACK_REPO_OWNER.strip() or DEFAULT_REPO_OWNER + default_name = SLACK_REPO_NAME.strip() or DEFAULT_REPO_NAME thread_id = generate_thread_id_from_slack_thread(channel_id, thread_ts) langgraph_client = get_client(url=LANGGRAPH_URL) - owner: str | None = None - name: str | None = None + repo_config = extract_repo_from_text(message, default_owner=default_owner) - if "repo:" in message or "repo " in message: - match = re.search(r"repo[: ]([a-zA-Z0-9_.\-/]+)", message) - 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_.-]+)", message) - if github_match: - owner, name = github_match.group(1).split("/", 1) - - if not owner or not name: + if not repo_config: try: thread = await langgraph_client.threads.get(thread_id) thread_repo_config = _extract_repo_config_from_thread(thread) if thread_repo_config: - owner = thread_repo_config["owner"] - name = thread_repo_config["name"] + repo_config = thread_repo_config except Exception as exc: # noqa: BLE001 if not _is_not_found_error(exc): logger.exception( @@ -383,15 +359,14 @@ async def get_slack_repo_config(message: str, channel_id: str, thread_ts: str) - thread_id, ) - if not owner or not name: - owner = default_owner - name = default_name + if not repo_config: + repo_config = {"owner": default_owner, "name": default_name} - using_repo_str = f"Using repository: `{owner}/{name}`" + using_repo_str = f"Using repository: `{repo_config['owner']}/{repo_config['name']}`" if not await check_if_using_repo_msg_sent(channel_id, thread_ts, using_repo_str): await post_slack_thread_reply(channel_id, thread_ts, using_repo_str) - return {"owner": owner, "name": name} + return repo_config async def is_thread_active(thread_id: str) -> bool: @@ -931,24 +906,33 @@ async def linear_webhook( # noqa: PLR0911, PLR0912, PLR0915 logger.warning("Failed to fetch full issue details, using webhook data") full_issue = issue - team = full_issue.get("team", {}) - team_name = team.get("name", "") if team else "" - project = full_issue.get("project") - project_name = project.get("name", "") if project else "" + repo_config = extract_repo_from_text(comment_body, default_owner=DEFAULT_REPO_OWNER) - team_identifier = team_name.strip() if team_name else "" - project_key = project_name.strip() if project_name else "" + if repo_config: + logger.debug( + "Using repo from comment body: %s/%s", + repo_config["owner"], + repo_config["name"], + ) + else: + team = full_issue.get("team", {}) + team_name = team.get("name", "") if team else "" + project = full_issue.get("project") + project_name = project.get("name", "") if project else "" - repo_config = get_repo_config_from_team_mapping(team_identifier, project_key) + team_identifier = team_name.strip() if team_name else "" + project_key = project_name.strip() if project_name else "" - logger.debug( - "Team/project lookup result", - extra={ - "team_name": team_identifier, - "project_name": project_key, - "repo_config": repo_config, - }, - ) + repo_config = get_repo_config_from_team_mapping(team_identifier, project_key) + + logger.debug( + "Team/project lookup result", + extra={ + "team_name": team_identifier, + "project_name": project_key, + "repo_config": repo_config, + }, + ) if not _is_repo_org_allowed(repo_config): logger.warning( diff --git a/tests/test_repo_extraction.py b/tests/test_repo_extraction.py new file mode 100644 index 00000000..e663803d --- /dev/null +++ b/tests/test_repo_extraction.py @@ -0,0 +1,159 @@ +"""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_org_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_org_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"}