mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 19:32:12 +00:00
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:
parent
e2954f743e
commit
023d9b2a62
3 changed files with 148 additions and 57 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue