mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +00:00
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] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
6fc82d9edd
commit
46d7ed9d43
5 changed files with 279 additions and 74 deletions
|
|
@ -264,6 +264,29 @@ To fully remove a trigger's code, delete the corresponding endpoint from `agent/
|
||||||
- **Linear**: `linear_webhook()` and `process_linear_issue()`
|
- **Linear**: `linear_webhook()` and `process_linear_issue()`
|
||||||
- **Slack**: `slack_webhook()` and `process_slack_mention()`
|
- **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
|
### 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:
|
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
|
### 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
|
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`.
|
||||||
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`.
|
|
||||||
|
|
||||||
### Adding a new trigger
|
### Adding a new trigger
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
### Slack (optional)
|
||||||
|
|
||||||
**Create a Slack App:**
|
**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_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`)
|
- `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`:
|
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.
|
||||||
|
|
||||||
```bash
|
|
||||||
SLACK_REPO_OWNER="my-org" # Default GitHub org
|
|
||||||
SLACK_REPO_NAME="my-repo" # Default GitHub repo
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Environment variables
|
## 6. Environment variables
|
||||||
|
|
||||||
|
|
@ -331,6 +328,11 @@ GITHUB_OAUTH_PROVIDER_ID="" # The provider ID from steps 3a / 4b
|
||||||
# Leave empty to allow all orgs.
|
# Leave empty to allow all orgs.
|
||||||
ALLOWED_GITHUB_ORGS="" # e.g. "my-org,my-other-org"
|
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 (if using Linear trigger) ===
|
||||||
LINEAR_API_KEY="" # From step 5
|
LINEAR_API_KEY="" # From step 5
|
||||||
LINEAR_WEBHOOK_SECRET="" # 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_USER_ID=""
|
||||||
SLACK_BOT_USERNAME=""
|
SLACK_BOT_USERNAME=""
|
||||||
SLACK_SIGNING_SECRET=""
|
SLACK_SIGNING_SECRET=""
|
||||||
SLACK_REPO_OWNER="" # Default org for Slack-triggered tasks
|
|
||||||
SLACK_REPO_NAME="" # Default repo for Slack-triggered tasks
|
|
||||||
|
|
||||||
# === Sandbox (optional) ===
|
# === Sandbox (optional) ===
|
||||||
DEFAULT_SANDBOX_TEMPLATE_NAME="" # Custom sandbox template name (default: deepagents-cli)
|
DEFAULT_SANDBOX_TEMPLATE_NAME="" # Custom sandbox template name (default: deepagents-cli)
|
||||||
|
|
|
||||||
42
agent/utils/repo.py
Normal file
42
agent/utils/repo.py
Normal file
|
|
@ -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
|
||||||
100
agent/webapp.py
100
agent/webapp.py
|
|
@ -5,7 +5,6 @@ import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
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 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.slack import (
|
from .utils.slack import (
|
||||||
add_slack_reaction,
|
add_slack_reaction,
|
||||||
fetch_slack_thread_messages,
|
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_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "")
|
||||||
SLACK_BOT_USER_ID = os.environ.get("SLACK_BOT_USER_ID", "")
|
SLACK_BOT_USER_ID = os.environ.get("SLACK_BOT_USER_ID", "")
|
||||||
SLACK_BOT_USERNAME = os.environ.get("SLACK_BOT_USERNAME", "")
|
SLACK_BOT_USERNAME = os.environ.get("SLACK_BOT_USERNAME", "")
|
||||||
SLACK_REPO_OWNER = os.environ.get("SLACK_REPO_OWNER", "langchain-ai")
|
DEFAULT_REPO_OWNER = os.environ.get("DEFAULT_REPO_OWNER", "langchain-ai")
|
||||||
SLACK_REPO_NAME = os.environ.get("SLACK_REPO_NAME", "open-swe")
|
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 = os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
||||||
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
||||||
|
|
@ -96,20 +98,11 @@ _GITHUB_BOT_MESSAGE_PREFIXES = (
|
||||||
def get_repo_config_from_team_mapping(
|
def get_repo_config_from_team_mapping(
|
||||||
team_identifier: str, project_name: str = ""
|
team_identifier: str, project_name: str = ""
|
||||||
) -> dict[str, 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:
|
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]
|
config = LINEAR_TEAM_TO_REPO[team_identifier]
|
||||||
|
|
||||||
|
|
@ -124,7 +117,7 @@ def get_repo_config_from_team_mapping(
|
||||||
if "default" in config:
|
if "default" in config:
|
||||||
return config["default"]
|
return config["default"]
|
||||||
|
|
||||||
return {"owner": "langchain-ai", "name": "langchainplus"}
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
async def react_to_linear_comment(comment_id: str, emoji: str = "👀") -> bool:
|
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]:
|
async def get_slack_repo_config(message: str, channel_id: str, thread_ts: str) -> dict[str, str]:
|
||||||
"""Resolve repository configuration for Slack-triggered runs."""
|
"""Resolve repository configuration for Slack-triggered runs."""
|
||||||
default_owner = SLACK_REPO_OWNER.strip() or "langchain-ai"
|
default_owner = SLACK_REPO_OWNER.strip() or DEFAULT_REPO_OWNER
|
||||||
default_name = SLACK_REPO_NAME.strip() or "langchainplus"
|
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)
|
||||||
|
|
||||||
owner: str | None = None
|
repo_config = extract_repo_from_text(message, default_owner=default_owner)
|
||||||
name: str | None = None
|
|
||||||
|
|
||||||
if "repo:" in message or "repo " in message:
|
if not repo_config:
|
||||||
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:
|
|
||||||
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:
|
||||||
owner = thread_repo_config["owner"]
|
repo_config = thread_repo_config
|
||||||
name = thread_repo_config["name"]
|
|
||||||
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(
|
||||||
|
|
@ -383,15 +359,14 @@ async def get_slack_repo_config(message: str, channel_id: str, thread_ts: str) -
|
||||||
thread_id,
|
thread_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not owner or not name:
|
if not repo_config:
|
||||||
owner = default_owner
|
repo_config = {"owner": default_owner, "name": default_name}
|
||||||
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):
|
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)
|
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:
|
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")
|
logger.warning("Failed to fetch full issue details, using webhook data")
|
||||||
full_issue = issue
|
full_issue = issue
|
||||||
|
|
||||||
team = full_issue.get("team", {})
|
repo_config = extract_repo_from_text(comment_body, default_owner=DEFAULT_REPO_OWNER)
|
||||||
team_name = team.get("name", "") if team else ""
|
|
||||||
project = full_issue.get("project")
|
|
||||||
project_name = project.get("name", "") if project else ""
|
|
||||||
|
|
||||||
team_identifier = team_name.strip() if team_name else ""
|
if repo_config:
|
||||||
project_key = project_name.strip() if project_name else ""
|
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(
|
repo_config = get_repo_config_from_team_mapping(team_identifier, project_key)
|
||||||
"Team/project lookup result",
|
|
||||||
extra={
|
logger.debug(
|
||||||
"team_name": team_identifier,
|
"Team/project lookup result",
|
||||||
"project_name": project_key,
|
extra={
|
||||||
"repo_config": repo_config,
|
"team_name": team_identifier,
|
||||||
},
|
"project_name": project_key,
|
||||||
)
|
"repo_config": repo_config,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if not _is_repo_org_allowed(repo_config):
|
if not _is_repo_org_allowed(repo_config):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
|
||||||
159
tests/test_repo_extraction.py
Normal file
159
tests/test_repo_extraction.py
Normal file
|
|
@ -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"}
|
||||||
Loading…
Add table
Reference in a new issue