feat: add structured Linear issue filters (#1775)

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit b5e529252c3012818289eca1b1cdeb8014721310)
This commit is contained in:
Johannes du Plessis 2026-07-17 09:49:31 -07:00 • committed by Adam Moussa
parent e2954f743e
commit 023d9b2a62
No known key found for this signature in database
3 changed files with 148 additions and 57 deletions

View file

@ -4,21 +4,23 @@ from ..utils.linear import search_issues
async def linear_search_issues( async def linear_search_issues(
query: str, query: str | None = None,
team_id: str | None = None, team_id: str | None = None,
filters: dict[str, Any] | None = None,
limit: int = 10, limit: int = 10,
include_archived: bool = False, include_archived: bool = False,
include_comments: bool = False, include_comments: bool = False,
after: str | None = None, after: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Search Linear issues by title, description, and optionally comments. """Search Linear issues by text, structured filters, or both.
Args: 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. 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. limit: Maximum results to return, from 1 to 50.
include_archived: Whether to include archived issues. 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. after: Optional pagination cursor from a previous result's page_info.endCursor.
Returns: Returns:
@ -27,6 +29,7 @@ async def linear_search_issues(
return await search_issues( return await search_issues(
query=query, query=query,
team_id=team_id, team_id=team_id,
filters=filters,
limit=limit, limit=limit,
include_archived=include_archived, include_archived=include_archived,
include_comments=include_comments, include_comments=include_comments,

View file

@ -133,76 +133,110 @@ async def get_issue(issue_id: str) -> dict[str, Any]:
async def search_issues( async def search_issues(
query: str, query: str | None = None,
team_id: str | None = None, team_id: str | None = None,
filters: dict[str, Any] | None = None,
limit: int = 10, limit: int = 10,
include_archived: bool = False, include_archived: bool = False,
include_comments: bool = False, include_comments: bool = False,
after: str | None = None, after: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Search Linear issues by free-text query.""" """Search Linear issues by text, structured filters, or both."""
query = query.strip() query = (query or "").strip()
if not query: issue_filter = dict(filters or {})
return {"error": "Search query must not be empty"} 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: if not 1 <= limit <= 50:
return {"error": "Search limit must be between 1 and 50"} return {"error": "Search limit must be between 1 and 50"}
search_query = """ connection_fields = """
query SearchIssues( totalCount
$query: String! pageInfo {
$filter: IssueFilter hasNextPage
$limit: Int! endCursor
$includeArchived: Boolean }
$includeComments: Boolean nodes {
$after: String id
) { identifier
searchIssues( title
term: $query priority
filter: $filter priorityLabel
first: $limit state { id name type }
includeArchived: $includeArchived assignee { id name email }
includeComments: $includeComments team { id name key }
after: $after project { id name }
) { labels { nodes { id name } }
totalCount createdAt
pageInfo { updatedAt
hasNextPage archivedAt
endCursor url
}
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( if query:
search_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, "query": query,
"filter": {"team": {"id": {"eq": team_id}}} if team_id else None, "filter": issue_filter or None,
"limit": limit, "limit": limit,
"includeArchived": include_archived, "includeArchived": include_archived,
"includeComments": include_comments, "includeComments": include_comments,
"after": after, "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: if "error" in result:
return result return result
search_results = result.get("searchIssues", {}) search_results = result.get(connection_name, {})
return { return {
"issues": search_results.get("nodes", []), "issues": search_results.get("nodes", []),
"total_count": search_results.get("totalCount", 0), "total_count": search_results.get("totalCount", 0),

View file

@ -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]: async def unexpected_request(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
pytest.fail("GraphQL request should not be made") 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(" ") 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]) @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( result = await linear_search_tool.linear_search_issues(
"styling", "styling",
team_id="team-id", team_id="team-id",
filters={"priority": {"eq": 1}},
limit=20, limit=20,
include_archived=True, include_archived=True,
include_comments=True, include_comments=True,
@ -124,6 +177,7 @@ async def test_linear_search_issues_tool_delegates(monkeypatch: pytest.MonkeyPat
assert captured == { assert captured == {
"query": "styling", "query": "styling",
"team_id": "team-id", "team_id": "team-id",
"filters": {"priority": {"eq": 1}},
"limit": 20, "limit": 20,
"include_archived": True, "include_archived": True,
"include_comments": True, "include_comments": True,