diff --git a/agent/tools/linear_search_issues.py b/agent/tools/linear_search_issues.py index bb223cf7..f1259f6b 100644 --- a/agent/tools/linear_search_issues.py +++ b/agent/tools/linear_search_issues.py @@ -4,21 +4,23 @@ from ..utils.linear import search_issues async def linear_search_issues( - query: str, + query: str | None = None, team_id: str | None = None, + filters: dict[str, Any] | 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. + """Search Linear issues by text, structured filters, or both. Args: - query: Free-text search query. + query: Optional free-text query over issue content. team_id: Optional team UUID used to restrict matches to that team. + filters: Optional Linear IssueFilter object for labels, state, project, assignee, and more. 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. + include_comments: Whether free-text search includes issue comments. after: Optional pagination cursor from a previous result's page_info.endCursor. Returns: @@ -27,6 +29,7 @@ async def linear_search_issues( return await search_issues( query=query, team_id=team_id, + filters=filters, limit=limit, include_archived=include_archived, include_comments=include_comments, diff --git a/agent/utils/linear.py b/agent/utils/linear.py index 5fce95b5..c6279725 100644 --- a/agent/utils/linear.py +++ b/agent/utils/linear.py @@ -133,76 +133,110 @@ async def get_issue(issue_id: str) -> dict[str, Any]: async def search_issues( - query: str, + query: str | None = None, team_id: str | None = None, + filters: dict[str, Any] | 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"} + """Search Linear issues by text, structured filters, or both.""" + query = (query or "").strip() + issue_filter = dict(filters or {}) + if team_id: + team_filter = {"team": {"id": {"eq": team_id}}} + issue_filter = {"and": [issue_filter, team_filter]} if issue_filter else team_filter + if not query and not issue_filter: + return {"error": "Search query or filters must be provided"} 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 - } + connection_fields = """ + 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, - { + if query: + graphql_query = f""" + 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 + ) {{ + {connection_fields} + }} + }} + """ + variables = { "query": query, - "filter": {"team": {"id": {"eq": team_id}}} if team_id else None, + "filter": issue_filter or None, "limit": limit, "includeArchived": include_archived, "includeComments": include_comments, "after": after, - }, - ) + } + connection_name = "searchIssues" + else: + graphql_query = f""" + query FilterIssues( + $filter: IssueFilter! + $limit: Int! + $includeArchived: Boolean + $after: String + ) {{ + issues( + filter: $filter + first: $limit + includeArchived: $includeArchived + after: $after + ) {{ + {connection_fields} + }} + }} + """ + variables = { + "filter": issue_filter, + "limit": limit, + "includeArchived": include_archived, + "after": after, + } + connection_name = "issues" + + result = await _graphql_request(graphql_query, variables) if "error" in result: return result - search_results = result.get("searchIssues", {}) + search_results = result.get(connection_name, {}) return { "issues": search_results.get("nodes", []), "total_count": search_results.get("totalCount", 0), diff --git a/tests/tools/test_linear_search_issues.py b/tests/tools/test_linear_search_issues.py index 233a80bb..eabc67ea 100644 --- a/tests/tools/test_linear_search_issues.py +++ b/tests/tools/test_linear_search_issues.py @@ -66,7 +66,59 @@ async def test_search_issues_returns_results_and_pagination( } -async def test_search_issues_rejects_blank_query(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_search_issues_filters_without_text(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 { + "issues": { + "nodes": [{"id": "issue-id", "identifier": "DCD-21", "title": "Fix filters"}], + "totalCount": 1, + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + + monkeypatch.setattr(linear, "_graphql_request", fake_graphql_request) + filters = {"labels": {"some": {"name": {"eq": "open-swe"}}}} + + result = await linear.search_issues(filters=filters, limit=1) + + assert "issues(" in captured["query"] + assert "searchIssues" not in captured["query"] + assert captured["variables"] == { + "filter": filters, + "limit": 1, + "includeArchived": False, + "after": None, + } + assert result["issues"][0]["identifier"] == "DCD-21" + + +async def test_search_issues_combines_filters_with_team(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["variables"] = variables + return {"searchIssues": {"nodes": [], "totalCount": 0, "pageInfo": {}}} + + monkeypatch.setattr(linear, "_graphql_request", fake_graphql_request) + filters = {"state": {"name": {"eq": "Todo"}}} + + await linear.search_issues("fix", team_id="team-id", filters=filters) + + assert captured["variables"]["filter"] == { + "and": [filters, {"team": {"id": {"eq": "team-id"}}}] + } + + +async def test_search_issues_rejects_missing_query_and_filters( + monkeypatch: pytest.MonkeyPatch, +) -> None: async def unexpected_request(*_args: Any, **_kwargs: Any) -> dict[str, Any]: pytest.fail("GraphQL request should not be made") @@ -74,7 +126,7 @@ async def test_search_issues_rejects_blank_query(monkeypatch: pytest.MonkeyPatch result = await linear.search_issues(" ") - assert result == {"error": "Search query must not be empty"} + assert result == {"error": "Search query or filters must be provided"} @pytest.mark.parametrize("limit", [0, 51]) @@ -114,6 +166,7 @@ async def test_linear_search_issues_tool_delegates(monkeypatch: pytest.MonkeyPat result = await linear_search_tool.linear_search_issues( "styling", team_id="team-id", + filters={"priority": {"eq": 1}}, limit=20, include_archived=True, include_comments=True, @@ -124,6 +177,7 @@ async def test_linear_search_issues_tool_delegates(monkeypatch: pytest.MonkeyPat assert captured == { "query": "styling", "team_id": "team-id", + "filters": {"priority": {"eq": 1}}, "limit": 20, "include_archived": True, "include_comments": True,