From bfb36be948d6d451a29e8e96f006f99d79dd84b5 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 17 Jul 2026 16:22:38 -0400 Subject: [PATCH] feat: add Linear issue search tool (#1748) Co-authored-by: open-swe[bot] (cherry picked from commit 79df6b2ff283afdbd36660be888c130ea964fe3f) Co-authored-by: Johannes du Plessis --- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 1 + agent/middleware/refresh_slack_status.py | 1 + agent/server.py | 2 + agent/tools/__init__.py | 3 + agent/tools/linear_search_issues.py | 34 ++++++ agent/utils/linear.py | 78 ++++++++++++++ tests/tools/test_linear_search_issues.py | 131 +++++++++++++++++++++++ 9 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 agent/tools/linear_search_issues.py create mode 100644 tests/tools/test_linear_search_issues.py diff --git a/AGENTS.md b/AGENTS.md index a022b123..1e16e876 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). diff --git a/CLAUDE.md b/CLAUDE.md index d5d541fa..6afe44f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 8232d10c..9332a968 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/agent/middleware/refresh_slack_status.py b/agent/middleware/refresh_slack_status.py index cfcf1dd0..fce7332b 100644 --- a/agent/middleware/refresh_slack_status.py +++ b/agent/middleware/refresh_slack_status.py @@ -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...", diff --git a/agent/server.py b/agent/server.py index 665bfc67..4db8aeb7 100644 --- a/agent/server.py +++ b/agent/server.py @@ -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, diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index 544c8d95..f408aed8 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -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 diff --git a/agent/tools/linear_search_issues.py b/agent/tools/linear_search_issues.py new file mode 100644 index 00000000..bb223cf7 --- /dev/null +++ b/agent/tools/linear_search_issues.py @@ -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, + ) diff --git a/agent/utils/linear.py b/agent/utils/linear.py index 6e45fec8..5fce95b5 100644 --- a/agent/utils/linear.py +++ b/agent/utils/linear.py @@ -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, diff --git a/tests/tools/test_linear_search_issues.py b/tests/tools/test_linear_search_issues.py new file mode 100644 index 00000000..233a80bb --- /dev/null +++ b/tests/tools/test_linear_search_issues.py @@ -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", + }