mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 05:43:14 +00:00
feat: add Linear issue search tool (#1748)
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit 79df6b2ff283afdbd36660be888c130ea964fe3f) Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
This commit is contained in:
parent
e542f0d7a6
commit
bfb36be948
9 changed files with 252 additions and 2 deletions
|
|
@ -88,7 +88,7 @@ There is intentionally no after-agent safety net that opens a PR for the agent.
|
|||
All tools live in `agent/tools/` and are flat-imported via `agent/tools/__init__.py`. The set is intentionally small and curated — see README "Tools — Curated, Not Accumulated".
|
||||
|
||||
Wired into `get_agent`:
|
||||
`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_update_issue`, `jira_comment`, `jira_create_issue`, `jira_get_issue`, `jira_get_issue_comments`, `jira_list_projects`, `jira_update_issue`, `confluence_get_page`, `confluence_create_page`, `confluence_update_page`, `confluence_comment`, `confluence_search`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`.
|
||||
`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_search_issues`, `linear_update_issue`, `jira_comment`, `jira_create_issue`, `jira_get_issue`, `jira_get_issue_comments`, `jira_list_projects`, `jira_update_issue`, `confluence_get_page`, `confluence_create_page`, `confluence_update_page`, `confluence_comment`, `confluence_search`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`.
|
||||
|
||||
Reviewer-only tools (in `agent/reviewer.py`): `add_finding`, `update_finding`, `list_findings`, `publish_review`. The review-style analyzer uses `save_review_style` (exported as `save_review_style_prompt`).
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ There is intentionally no after-agent safety net that opens a PR for the agent.
|
|||
All tools live in `agent/tools/` and are flat-imported via `agent/tools/__init__.py`. The set is intentionally small and curated — see README "Tools — Curated, Not Accumulated".
|
||||
|
||||
Wired into `get_agent`:
|
||||
`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_update_issue`, `jira_comment`, `jira_create_issue`, `jira_get_issue`, `jira_get_issue_comments`, `jira_list_projects`, `jira_update_issue`, `confluence_get_page`, `confluence_create_page`, `confluence_update_page`, `confluence_comment`, `confluence_search`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`.
|
||||
`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_search_issues`, `linear_update_issue`, `jira_comment`, `jira_create_issue`, `jira_get_issue`, `jira_get_issue_comments`, `jira_list_projects`, `jira_update_issue`, `confluence_get_page`, `confluence_create_page`, `confluence_update_page`, `confluence_comment`, `confluence_search`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`.
|
||||
|
||||
Jira uses a service-account REST client (`agent/utils/jira.py`, Basic auth) with ADF↔markdown conversion (`agent/utils/adf.py`); Confluence likewise (`agent/utils/confluence.py`, XHTML storage-format). Both are dark-safe: unset env returns a clean error.
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ Stripe's key insight: *tool curation matters more than tool quantity.* Open SWE
|
|||
| `fetch_url` | Fetch web pages as markdown |
|
||||
| `http_request` | API calls (GET, POST, etc.) |
|
||||
| `linear_comment` | Post updates to Linear tickets |
|
||||
| `linear_search_issues` | Search Linear issues by free text |
|
||||
| `jira_*` | Read/comment/create/update Jira issues |
|
||||
| `confluence_*` | Read/write Confluence pages + comments |
|
||||
| `slack_add_reaction` | React to Slack messages |
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ _TOOL_STATUS: dict[str, str] = {
|
|||
"linear_get_issue": "checking Linear...",
|
||||
"linear_get_issue_comments": "checking Linear...",
|
||||
"linear_list_teams": "checking Linear...",
|
||||
"linear_search_issues": "searching Linear...",
|
||||
"linear_update_issue": "updating Linear...",
|
||||
"linear_delete_issue": "updating Linear...",
|
||||
"add_finding": "recording review findings...",
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ from .tools import (
|
|||
linear_get_issue,
|
||||
linear_get_issue_comments,
|
||||
linear_list_teams,
|
||||
linear_search_issues,
|
||||
linear_update_issue,
|
||||
open_pull_request,
|
||||
report_platform_issue,
|
||||
|
|
@ -1019,6 +1020,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
linear_get_issue,
|
||||
linear_get_issue_comments,
|
||||
linear_list_teams,
|
||||
linear_search_issues,
|
||||
linear_update_issue,
|
||||
jira_comment,
|
||||
jira_create_issue,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ _TOOL_MODULES = {
|
|||
"linear_get_issue": ".linear_get_issue",
|
||||
"linear_get_issue_comments": ".linear_get_issue_comments",
|
||||
"linear_list_teams": ".linear_list_teams",
|
||||
"linear_search_issues": ".linear_search_issues",
|
||||
"linear_update_issue": ".linear_update_issue",
|
||||
"list_findings": ".list_findings",
|
||||
"list_review_findings": ".list_review_findings",
|
||||
|
|
@ -67,6 +68,7 @@ __all__ = [
|
|||
"linear_get_issue",
|
||||
"linear_get_issue_comments",
|
||||
"linear_list_teams",
|
||||
"linear_search_issues",
|
||||
"linear_update_issue",
|
||||
"list_findings",
|
||||
"list_review_findings",
|
||||
|
|
@ -110,6 +112,7 @@ if TYPE_CHECKING:
|
|||
from .linear_get_issue import linear_get_issue
|
||||
from .linear_get_issue_comments import linear_get_issue_comments
|
||||
from .linear_list_teams import linear_list_teams
|
||||
from .linear_search_issues import linear_search_issues
|
||||
from .linear_update_issue import linear_update_issue
|
||||
from .list_findings import list_findings
|
||||
from .list_review_findings import list_review_findings
|
||||
|
|
|
|||
34
agent/tools/linear_search_issues.py
Normal file
34
agent/tools/linear_search_issues.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from typing import Any
|
||||
|
||||
from ..utils.linear import search_issues
|
||||
|
||||
|
||||
async def linear_search_issues(
|
||||
query: str,
|
||||
team_id: str | None = None,
|
||||
limit: int = 10,
|
||||
include_archived: bool = False,
|
||||
include_comments: bool = False,
|
||||
after: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Search Linear issues by title, description, and optionally comments.
|
||||
|
||||
Args:
|
||||
query: Free-text search query.
|
||||
team_id: Optional team UUID used to restrict matches to that team.
|
||||
limit: Maximum results to return, from 1 to 50.
|
||||
include_archived: Whether to include archived issues.
|
||||
include_comments: Whether to search issue comments in addition to issue content.
|
||||
after: Optional pagination cursor from a previous result's page_info.endCursor.
|
||||
|
||||
Returns:
|
||||
Matching issues plus total_count and page_info for pagination.
|
||||
"""
|
||||
return await search_issues(
|
||||
query=query,
|
||||
team_id=team_id,
|
||||
limit=limit,
|
||||
include_archived=include_archived,
|
||||
include_comments=include_comments,
|
||||
after=after,
|
||||
)
|
||||
|
|
@ -132,6 +132,84 @@ async def get_issue(issue_id: str) -> dict[str, Any]:
|
|||
return {"issue": result.get("issue")}
|
||||
|
||||
|
||||
async def search_issues(
|
||||
query: str,
|
||||
team_id: str | None = None,
|
||||
limit: int = 10,
|
||||
include_archived: bool = False,
|
||||
include_comments: bool = False,
|
||||
after: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Search Linear issues by free-text query."""
|
||||
query = query.strip()
|
||||
if not query:
|
||||
return {"error": "Search query must not be empty"}
|
||||
if not 1 <= limit <= 50:
|
||||
return {"error": "Search limit must be between 1 and 50"}
|
||||
|
||||
search_query = """
|
||||
query SearchIssues(
|
||||
$query: String!
|
||||
$filter: IssueFilter
|
||||
$limit: Int!
|
||||
$includeArchived: Boolean
|
||||
$includeComments: Boolean
|
||||
$after: String
|
||||
) {
|
||||
searchIssues(
|
||||
term: $query
|
||||
filter: $filter
|
||||
first: $limit
|
||||
includeArchived: $includeArchived
|
||||
includeComments: $includeComments
|
||||
after: $after
|
||||
) {
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
priority
|
||||
priorityLabel
|
||||
state { id name type }
|
||||
assignee { id name email }
|
||||
team { id name key }
|
||||
project { id name }
|
||||
labels { nodes { id name } }
|
||||
createdAt
|
||||
updatedAt
|
||||
archivedAt
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = await _graphql_request(
|
||||
search_query,
|
||||
{
|
||||
"query": query,
|
||||
"filter": {"team": {"id": {"eq": team_id}}} if team_id else None,
|
||||
"limit": limit,
|
||||
"includeArchived": include_archived,
|
||||
"includeComments": include_comments,
|
||||
"after": after,
|
||||
},
|
||||
)
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
search_results = result.get("searchIssues", {})
|
||||
return {
|
||||
"issues": search_results.get("nodes", []),
|
||||
"total_count": search_results.get("totalCount", 0),
|
||||
"page_info": search_results.get("pageInfo", {}),
|
||||
}
|
||||
|
||||
|
||||
async def create_issue(
|
||||
team_id: str,
|
||||
title: str,
|
||||
|
|
|
|||
131
tests/tools/test_linear_search_issues.py
Normal file
131
tests/tools/test_linear_search_issues.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.utils import linear
|
||||
|
||||
linear_search_tool = importlib.import_module("agent.tools.linear_search_issues")
|
||||
|
||||
|
||||
async def test_search_issues_returns_results_and_pagination(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_graphql_request(
|
||||
query: str, variables: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
captured.update({"query": query, "variables": variables})
|
||||
return {
|
||||
"searchIssues": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "issue-id",
|
||||
"identifier": "DCD-20",
|
||||
"title": "User-message styling improvement",
|
||||
}
|
||||
],
|
||||
"totalCount": 12,
|
||||
"pageInfo": {"hasNextPage": True, "endCursor": "next-page"},
|
||||
}
|
||||
}
|
||||
|
||||
monkeypatch.setattr(linear, "_graphql_request", fake_graphql_request)
|
||||
|
||||
result = await linear.search_issues(
|
||||
" user message styling ",
|
||||
team_id="team-id",
|
||||
limit=5,
|
||||
include_archived=True,
|
||||
include_comments=True,
|
||||
after="current-page",
|
||||
)
|
||||
|
||||
assert "searchIssues" in captured["query"]
|
||||
assert captured["variables"] == {
|
||||
"query": "user message styling",
|
||||
"filter": {"team": {"id": {"eq": "team-id"}}},
|
||||
"limit": 5,
|
||||
"includeArchived": True,
|
||||
"includeComments": True,
|
||||
"after": "current-page",
|
||||
}
|
||||
assert result == {
|
||||
"issues": [
|
||||
{
|
||||
"id": "issue-id",
|
||||
"identifier": "DCD-20",
|
||||
"title": "User-message styling improvement",
|
||||
}
|
||||
],
|
||||
"total_count": 12,
|
||||
"page_info": {"hasNextPage": True, "endCursor": "next-page"},
|
||||
}
|
||||
|
||||
|
||||
async def test_search_issues_rejects_blank_query(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def unexpected_request(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
||||
pytest.fail("GraphQL request should not be made")
|
||||
|
||||
monkeypatch.setattr(linear, "_graphql_request", unexpected_request)
|
||||
|
||||
result = await linear.search_issues(" ")
|
||||
|
||||
assert result == {"error": "Search query must not be empty"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [0, 51])
|
||||
async def test_search_issues_rejects_invalid_limit(
|
||||
monkeypatch: pytest.MonkeyPatch, limit: int
|
||||
) -> None:
|
||||
async def unexpected_request(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
||||
pytest.fail("GraphQL request should not be made")
|
||||
|
||||
monkeypatch.setattr(linear, "_graphql_request", unexpected_request)
|
||||
|
||||
result = await linear.search_issues("styling", limit=limit)
|
||||
|
||||
assert result == {"error": "Search limit must be between 1 and 50"}
|
||||
|
||||
|
||||
async def test_search_issues_propagates_graphql_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_graphql_request(
|
||||
_query: str, _variables: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
return {"error": "rate limited"}
|
||||
|
||||
monkeypatch.setattr(linear, "_graphql_request", fake_graphql_request)
|
||||
|
||||
assert await linear.search_issues("styling") == {"error": "rate limited"}
|
||||
|
||||
|
||||
async def test_linear_search_issues_tool_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_search_issues(**kwargs: Any) -> dict[str, Any]:
|
||||
captured.update(kwargs)
|
||||
return {"issues": []}
|
||||
|
||||
monkeypatch.setattr(linear_search_tool, "search_issues", fake_search_issues)
|
||||
|
||||
result = await linear_search_tool.linear_search_issues(
|
||||
"styling",
|
||||
team_id="team-id",
|
||||
limit=20,
|
||||
include_archived=True,
|
||||
include_comments=True,
|
||||
after="cursor",
|
||||
)
|
||||
|
||||
assert result == {"issues": []}
|
||||
assert captured == {
|
||||
"query": "styling",
|
||||
"team_id": "team-id",
|
||||
"limit": 20,
|
||||
"include_archived": True,
|
||||
"include_comments": True,
|
||||
"after": "cursor",
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue