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

View file

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

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]:
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,