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:
Adam Moussa 2026-07-17 16:22:38 -04:00
parent e542f0d7a6
commit bfb36be948
No known key found for this signature in database
9 changed files with 252 additions and 2 deletions

View file

@ -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`).

View file

@ -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.

View file

@ -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 |

View file

@ -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...",

View file

@ -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,

View file

@ -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

View 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,
)

View file

@ -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,

View 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",
}