mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-10 06:45:28 -05:00
## Summary Implements [SECRT-1880](https://linear.app/autogpt/issue/SECRT-1880) - Improve Linear Search Block ## Changes ### Models (`models.py`) - Added `State` model with `id`, `name`, and `type` fields for workflow state information - Added `state: State | None` field to `Issue` model ### API Client (`_api.py`) - Updated `try_search_issues()` to: - Add `max_results` parameter (default 10, was ~50) to reduce token usage - Add `team_id` parameter for team filtering - Return `createdAt`, `state`, `project`, and `assignee` fields in results - Fixed `try_get_team_by_name()` to return descriptive error message when team not found instead of crashing with `IndexError` ### Block (`issues.py`) - Added `max_results` input parameter (1-100, default 10) - Added `team_name` input parameter for optional team filtering - Added `error` output field for graceful error handling - Added categories (`PRODUCTIVITY`, `ISSUE_TRACKING`) - Updated test fixtures to include new fields ## Breaking Changes | Change | Before | After | Mitigation | |--------|--------|-------|------------| | Default result count | ~50 | 10 | Users can set `max_results` up to 100 if needed | ## Non-Breaking Changes - `state` field added to `Issue` (optional, defaults to `None`) - `max_results` param added (has default value) - `team_name` param added (optional, defaults to `None`) - `error` output added (follows established pattern from GitHub blocks) ## Testing - [x] Format/lint checks pass - [x] Unit test fixtures updated Resolves SECRT-1880 --------- Co-authored-by: Toran Bruce Richards <toran.richards@gmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Toran Bruce Richards <Torantulino@users.noreply.github.com>
369 lines
11 KiB
Python
369 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Dict, Optional, Union
|
|
|
|
from backend.sdk import APIKeyCredentials, OAuth2Credentials, Requests
|
|
|
|
from .models import CreateCommentResponse, CreateIssueResponse, Issue, Project
|
|
|
|
|
|
class LinearAPIException(Exception):
|
|
def __init__(self, message: str, status_code: int):
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
|
|
|
|
class LinearClient:
|
|
"""Client for the Linear API
|
|
|
|
If you're looking for the schema: https://studio.apollographql.com/public/Linear-API/variant/current/schema
|
|
"""
|
|
|
|
API_URL = "https://api.linear.app/graphql"
|
|
|
|
def __init__(
|
|
self,
|
|
credentials: Union[OAuth2Credentials, APIKeyCredentials, None] = None,
|
|
custom_requests: Optional[Requests] = None,
|
|
):
|
|
if custom_requests:
|
|
self._requests = custom_requests
|
|
else:
|
|
headers: Dict[str, str] = {
|
|
"Content-Type": "application/json",
|
|
}
|
|
if credentials:
|
|
headers["Authorization"] = credentials.auth_header()
|
|
|
|
self._requests = Requests(
|
|
extra_headers=headers,
|
|
trusted_origins=["https://api.linear.app"],
|
|
raise_for_status=False,
|
|
)
|
|
|
|
async def _execute_graphql_request(
|
|
self, query: str, variables: dict | None = None
|
|
) -> Any:
|
|
"""
|
|
Executes a GraphQL request against the Linear API and returns the response data.
|
|
|
|
Args:
|
|
query: The GraphQL query string.
|
|
variables (optional): Any GraphQL query variables
|
|
|
|
Returns:
|
|
The parsed JSON response data, or raises a LinearAPIException on error.
|
|
"""
|
|
payload: Dict[str, Any] = {"query": query}
|
|
if variables:
|
|
payload["variables"] = variables
|
|
|
|
response = await self._requests.post(self.API_URL, json=payload)
|
|
|
|
if not response.ok:
|
|
try:
|
|
error_data = response.json()
|
|
error_message = error_data.get("errors", [{}])[0].get("message", "")
|
|
except json.JSONDecodeError:
|
|
error_message = response.text()
|
|
|
|
raise LinearAPIException(
|
|
f"Linear API request failed ({response.status}): {error_message}",
|
|
response.status,
|
|
)
|
|
|
|
response_data = response.json()
|
|
if "errors" in response_data:
|
|
|
|
error_messages = [
|
|
error.get("message", "") for error in response_data["errors"]
|
|
]
|
|
raise LinearAPIException(
|
|
f"Linear API returned errors: {', '.join(error_messages)}",
|
|
response.status,
|
|
)
|
|
|
|
return response_data["data"]
|
|
|
|
async def query(self, query: str, variables: Optional[dict] = None) -> dict:
|
|
"""Executes a GraphQL query.
|
|
|
|
Args:
|
|
query: The GraphQL query string.
|
|
variables: Query variables, if any.
|
|
|
|
Returns:
|
|
The response data.
|
|
"""
|
|
return await self._execute_graphql_request(query, variables)
|
|
|
|
async def mutate(self, mutation: str, variables: Optional[dict] = None) -> dict:
|
|
"""Executes a GraphQL mutation.
|
|
|
|
Args:
|
|
mutation: The GraphQL mutation string.
|
|
variables: Query variables, if any.
|
|
|
|
Returns:
|
|
The response data.
|
|
"""
|
|
return await self._execute_graphql_request(mutation, variables)
|
|
|
|
async def try_create_comment(
|
|
self, issue_id: str, comment: str
|
|
) -> CreateCommentResponse:
|
|
try:
|
|
mutation = """
|
|
mutation CommentCreate($input: CommentCreateInput!) {
|
|
commentCreate(input: $input) {
|
|
success
|
|
comment {
|
|
id
|
|
body
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
variables = {
|
|
"input": {
|
|
"body": comment,
|
|
"issueId": issue_id,
|
|
}
|
|
}
|
|
|
|
added_comment = await self.mutate(mutation, variables)
|
|
# Select the commentCreate field from the mutation response
|
|
return CreateCommentResponse(**added_comment["commentCreate"])
|
|
except LinearAPIException as e:
|
|
raise e
|
|
|
|
async def try_get_team_by_name(self, team_name: str) -> str:
|
|
try:
|
|
query = """
|
|
query GetTeamId($searchTerm: String!) {
|
|
teams(filter: {
|
|
or: [
|
|
{ name: { eqIgnoreCase: $searchTerm } },
|
|
{ key: { eqIgnoreCase: $searchTerm } }
|
|
]
|
|
}) {
|
|
nodes {
|
|
id
|
|
name
|
|
key
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
variables: dict[str, Any] = {
|
|
"searchTerm": team_name,
|
|
}
|
|
|
|
result = await self.query(query, variables)
|
|
nodes = result["teams"]["nodes"]
|
|
|
|
if not nodes:
|
|
raise LinearAPIException(
|
|
f"Team '{team_name}' not found. Check the team name or key and try again.",
|
|
status_code=404,
|
|
)
|
|
|
|
return nodes[0]["id"]
|
|
except LinearAPIException as e:
|
|
raise e
|
|
|
|
async def try_create_issue(
|
|
self,
|
|
team_id: str,
|
|
title: str,
|
|
description: str | None = None,
|
|
priority: int | None = None,
|
|
project_id: str | None = None,
|
|
) -> CreateIssueResponse:
|
|
try:
|
|
mutation = """
|
|
mutation IssueCreate($input: IssueCreateInput!) {
|
|
issueCreate(input: $input) {
|
|
issue {
|
|
title
|
|
description
|
|
id
|
|
identifier
|
|
priority
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
variables: dict[str, Any] = {
|
|
"input": {
|
|
"teamId": team_id,
|
|
"title": title,
|
|
}
|
|
}
|
|
|
|
if project_id:
|
|
variables["input"]["projectId"] = project_id
|
|
|
|
if description:
|
|
variables["input"]["description"] = description
|
|
|
|
if priority:
|
|
variables["input"]["priority"] = priority
|
|
|
|
added_issue = await self.mutate(mutation, variables)
|
|
return CreateIssueResponse(**added_issue["issueCreate"])
|
|
except LinearAPIException as e:
|
|
raise e
|
|
|
|
async def try_search_projects(self, term: str) -> list[Project]:
|
|
try:
|
|
query = """
|
|
query SearchProjects($term: String!, $includeComments: Boolean!) {
|
|
searchProjects(term: $term, includeComments: $includeComments) {
|
|
nodes {
|
|
id
|
|
name
|
|
description
|
|
priority
|
|
progress
|
|
content
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
variables: dict[str, Any] = {
|
|
"term": term,
|
|
"includeComments": True,
|
|
}
|
|
|
|
projects = await self.query(query, variables)
|
|
return [
|
|
Project(**project) for project in projects["searchProjects"]["nodes"]
|
|
]
|
|
except LinearAPIException as e:
|
|
raise e
|
|
|
|
async def try_search_issues(
|
|
self,
|
|
term: str,
|
|
max_results: int = 10,
|
|
team_id: str | None = None,
|
|
) -> list[Issue]:
|
|
try:
|
|
query = """
|
|
query SearchIssues(
|
|
$term: String!,
|
|
$first: Int,
|
|
$teamId: String
|
|
) {
|
|
searchIssues(
|
|
term: $term,
|
|
first: $first,
|
|
teamId: $teamId
|
|
) {
|
|
nodes {
|
|
id
|
|
identifier
|
|
title
|
|
description
|
|
priority
|
|
createdAt
|
|
state {
|
|
id
|
|
name
|
|
type
|
|
}
|
|
project {
|
|
id
|
|
name
|
|
}
|
|
assignee {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
variables: dict[str, Any] = {
|
|
"term": term,
|
|
"first": max_results,
|
|
"teamId": team_id,
|
|
}
|
|
|
|
issues = await self.query(query, variables)
|
|
return [Issue(**issue) for issue in issues["searchIssues"]["nodes"]]
|
|
except LinearAPIException as e:
|
|
raise e
|
|
|
|
async def try_get_issues(
|
|
self, project: str, status: str, is_assigned: bool, include_comments: bool
|
|
) -> list[Issue]:
|
|
try:
|
|
query = """
|
|
query IssuesByProjectStatusAndAssignee(
|
|
$projectName: String!
|
|
$statusName: String!
|
|
$isAssigned: Boolean!
|
|
$includeComments: Boolean! = false
|
|
) {
|
|
issues(
|
|
filter: {
|
|
project: { name: { eq: $projectName } }
|
|
state: { name: { eq: $statusName } }
|
|
assignee: { null: $isAssigned }
|
|
}
|
|
) {
|
|
nodes {
|
|
id
|
|
title
|
|
identifier
|
|
description
|
|
createdAt
|
|
priority
|
|
assignee {
|
|
id
|
|
name
|
|
}
|
|
project {
|
|
id
|
|
name
|
|
}
|
|
state {
|
|
id
|
|
name
|
|
}
|
|
comments @include(if: $includeComments) {
|
|
nodes {
|
|
id
|
|
body
|
|
createdAt
|
|
user {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
variables: dict[str, Any] = {
|
|
"projectName": project,
|
|
"statusName": status,
|
|
"isAssigned": not is_assigned,
|
|
"includeComments": include_comments,
|
|
}
|
|
|
|
issues = await self.query(query, variables)
|
|
return [Issue(**issue) for issue in issues["issues"]["nodes"]]
|
|
except LinearAPIException as e:
|
|
raise e
|