mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13:14 +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(
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue