This PR adds a simplified Linear toolkit focused on core functionality: ## What's Included **Tools:** - `get_issue` - Get detailed information about Linear issues - `get_teams` - Get team information and details **Models:** - `DateRange` enum with comprehensive date range support (TODAY, YESTERDAY, THIS_WEEK, LAST_WEEK, THIS_MONTH, LAST_MONTH, THIS_YEAR, LAST_YEAR, LAST_7_DAYS, LAST_30_DAYS) - Timezone-aware datetime handling following Google toolkit patterns ## What's Simplified This toolkit has been streamlined by removing: - Cycles management tools - Projects management tools - Users management tools - Workflows management tools - Corresponding tests and evaluations for removed features ## Quality Assurance - All linting and formatting checks pass - Comprehensive test coverage for included functionality - Follows established patterns from Google toolkit --------- Co-authored-by: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
332 lines
11 KiB
Python
332 lines
11 KiB
Python
from datetime import datetime, timezone
|
|
from typing import Any, cast
|
|
|
|
import dateparser
|
|
from arcade_tdk.errors import ToolExecutionError
|
|
|
|
from arcade_linear.models import DateRange
|
|
|
|
# Error message constants
|
|
INVALID_DATE_FORMAT_ERROR = "Invalid date format for {field}: '{value}'"
|
|
|
|
|
|
def remove_none_values(data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Remove None values from a dictionary"""
|
|
return {k: v for k, v in data.items() if v is not None}
|
|
|
|
|
|
def parse_date_string(date_str: str) -> datetime | None:
|
|
"""Parse a date string into a timezone-aware datetime object
|
|
|
|
First tries to match against DateRange enum for consistent relative dates,
|
|
then falls back to dateparser for flexible parsing.
|
|
"""
|
|
if not date_str:
|
|
return None
|
|
|
|
# Check if it's a relative time expression that matches our DateRange enum
|
|
date_range = DateRange.from_string(date_str)
|
|
if date_range:
|
|
# For relative dates, return the start of the range
|
|
# This maintains backward compatibility with existing usage
|
|
return date_range.to_start_datetime()
|
|
|
|
# Fall back to dateparser for other formats (ISO dates, etc.)
|
|
try:
|
|
parsed_date = dateparser.parse(date_str)
|
|
if parsed_date is None:
|
|
return None
|
|
|
|
# Cast to datetime to satisfy type checker
|
|
parsed_datetime = cast(datetime, parsed_date)
|
|
|
|
# Ensure all datetimes are timezone-aware (UTC)
|
|
if parsed_datetime.tzinfo is None:
|
|
return parsed_datetime.replace(tzinfo=timezone.utc)
|
|
else:
|
|
return parsed_datetime
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def parse_date_range(date_str: str) -> tuple[datetime, datetime] | None:
|
|
"""Parse a date string into a datetime range tuple if it matches a DateRange enum
|
|
|
|
Args:
|
|
date_str: String that might represent a date range
|
|
|
|
Returns:
|
|
Tuple of (start_datetime, end_datetime) or None if not a valid range
|
|
"""
|
|
if not date_str:
|
|
return None
|
|
|
|
date_range = DateRange.from_string(date_str)
|
|
if date_range:
|
|
return date_range.to_datetime_range()
|
|
|
|
return None
|
|
|
|
|
|
def validate_date_format(field_name: str, date_str: str | None) -> None:
|
|
"""Validate date format and raise error if invalid"""
|
|
if not date_str:
|
|
return
|
|
|
|
parsed_date = parse_date_string(date_str)
|
|
if parsed_date is None:
|
|
raise ToolExecutionError(INVALID_DATE_FORMAT_ERROR.format(field=field_name, value=date_str))
|
|
|
|
|
|
def clean_user_data(user_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Clean and format user data"""
|
|
if not user_data:
|
|
return {}
|
|
|
|
return remove_none_values({
|
|
"id": user_data.get("id"),
|
|
"name": user_data.get("name"),
|
|
"email": user_data.get("email"),
|
|
"display_name": user_data.get("displayName"),
|
|
"avatar_url": user_data.get("avatarUrl"),
|
|
})
|
|
|
|
|
|
def clean_team_data(team_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Clean and format team data"""
|
|
if not team_data:
|
|
return {}
|
|
|
|
cleaned = {
|
|
"id": team_data.get("id"),
|
|
"key": team_data.get("key"),
|
|
"name": team_data.get("name"),
|
|
"description": team_data.get("description"),
|
|
"private": team_data.get("private"),
|
|
"archived_at": team_data.get("archivedAt"),
|
|
"created_at": team_data.get("createdAt"),
|
|
"updated_at": team_data.get("updatedAt"),
|
|
"icon": team_data.get("icon"),
|
|
"color": team_data.get("color"),
|
|
}
|
|
|
|
if team_data.get("members") and team_data["members"].get("nodes"):
|
|
cleaned["members"] = [clean_user_data(member) for member in team_data["members"]["nodes"]]
|
|
|
|
return remove_none_values(cleaned)
|
|
|
|
|
|
def clean_state_data(state_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Clean and format workflow state data"""
|
|
if not state_data:
|
|
return {}
|
|
|
|
return remove_none_values({
|
|
"id": state_data.get("id"),
|
|
"name": state_data.get("name"),
|
|
"type": state_data.get("type"),
|
|
"color": state_data.get("color"),
|
|
"position": state_data.get("position"),
|
|
})
|
|
|
|
|
|
def clean_project_data(project_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Clean and format project data"""
|
|
if not project_data:
|
|
return {}
|
|
|
|
return remove_none_values({
|
|
"id": project_data.get("id"),
|
|
"name": project_data.get("name"),
|
|
"description": project_data.get("description"),
|
|
"state": project_data.get("state"),
|
|
"progress": project_data.get("progress"),
|
|
"start_date": project_data.get("startDate"),
|
|
"target_date": project_data.get("targetDate"),
|
|
"url": project_data.get("url"),
|
|
})
|
|
|
|
|
|
def clean_label_data(label_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Clean and format label data"""
|
|
if not label_data:
|
|
return {}
|
|
|
|
return remove_none_values({
|
|
"id": label_data.get("id"),
|
|
"name": label_data.get("name"),
|
|
"color": label_data.get("color"),
|
|
"description": label_data.get("description"),
|
|
})
|
|
|
|
|
|
def clean_relation_data(relation_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Clean and format issue relation data"""
|
|
if not relation_data:
|
|
return {}
|
|
|
|
cleaned = {
|
|
"id": relation_data.get("id"),
|
|
"type": relation_data.get("type"),
|
|
}
|
|
|
|
# Clean related issue data
|
|
if relation_data.get("relatedIssue"):
|
|
cleaned["related_issue"] = {
|
|
"id": relation_data["relatedIssue"].get("id"),
|
|
"identifier": relation_data["relatedIssue"].get("identifier"),
|
|
"title": relation_data["relatedIssue"].get("title"),
|
|
}
|
|
|
|
return remove_none_values(cleaned)
|
|
|
|
|
|
def clean_comment_data(comment_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Clean and format comment data"""
|
|
if not comment_data:
|
|
return {}
|
|
|
|
cleaned = {
|
|
"id": comment_data.get("id"),
|
|
"body": comment_data.get("body"),
|
|
"created_at": comment_data.get("createdAt"),
|
|
"updated_at": comment_data.get("updatedAt"),
|
|
}
|
|
|
|
# Clean user data for comment author
|
|
if comment_data.get("user"):
|
|
cleaned["user"] = clean_user_data(comment_data["user"])
|
|
|
|
return remove_none_values(cleaned)
|
|
|
|
|
|
def clean_attachment_data(attachment_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Clean and format attachment data"""
|
|
if not attachment_data:
|
|
return {}
|
|
|
|
return remove_none_values({
|
|
"id": attachment_data.get("id"),
|
|
"title": attachment_data.get("title"),
|
|
"subtitle": attachment_data.get("subtitle"),
|
|
"url": attachment_data.get("url"),
|
|
"metadata": attachment_data.get("metadata"),
|
|
"created_at": attachment_data.get("createdAt"),
|
|
})
|
|
|
|
|
|
def _clean_issue_relations(issue_data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Clean issue relations data"""
|
|
relations = issue_data.get("relations", {})
|
|
if not relations or not relations.get("nodes"):
|
|
return []
|
|
|
|
cleaned_relations = []
|
|
for relation in relations["nodes"]:
|
|
if relation and relation.get("relatedIssue"):
|
|
cleaned_relations.append({
|
|
"id": relation.get("id"),
|
|
"type": relation.get("type"),
|
|
"related_issue": {
|
|
"id": relation["relatedIssue"].get("id"),
|
|
"identifier": relation["relatedIssue"].get("identifier"),
|
|
"title": relation["relatedIssue"].get("title"),
|
|
},
|
|
})
|
|
return cleaned_relations
|
|
|
|
|
|
def _clean_issue_children(issue_data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Clean issue children data"""
|
|
children = issue_data.get("children", {})
|
|
if not children or not children.get("nodes"):
|
|
return []
|
|
|
|
cleaned_children = []
|
|
for child in children["nodes"]:
|
|
if child:
|
|
cleaned_children.append({
|
|
"id": child.get("id"),
|
|
"identifier": child.get("identifier"),
|
|
"title": child.get("title"),
|
|
"state": clean_state_data(child.get("state", {})),
|
|
})
|
|
return cleaned_children
|
|
|
|
|
|
def _clean_issue_labels(issue_data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Clean issue labels data"""
|
|
labels = issue_data.get("labels", {})
|
|
if not labels or not labels.get("nodes"):
|
|
return []
|
|
|
|
return [clean_label_data(label) for label in labels["nodes"] if label]
|
|
|
|
|
|
def _clean_issue_comments(issue_data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Clean issue comments data"""
|
|
comments = issue_data.get("comments", {})
|
|
if not comments or not comments.get("nodes"):
|
|
return []
|
|
|
|
return [clean_comment_data(comment) for comment in comments["nodes"] if comment]
|
|
|
|
|
|
def _clean_issue_attachments(issue_data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Clean issue attachments data"""
|
|
attachments = issue_data.get("attachments", {})
|
|
if not attachments or not attachments.get("nodes"):
|
|
return []
|
|
|
|
return [clean_attachment_data(attachment) for attachment in attachments["nodes"] if attachment]
|
|
|
|
|
|
def clean_issue_data(issue_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Clean and format issue data for consistent output"""
|
|
if not issue_data:
|
|
return {}
|
|
|
|
# Clean basic issue data
|
|
cleaned_issue = {
|
|
"id": issue_data.get("id"),
|
|
"identifier": issue_data.get("identifier"),
|
|
"title": issue_data.get("title"),
|
|
"description": issue_data.get("description"),
|
|
"priority": issue_data.get("priority"),
|
|
"priority_label": issue_data.get("priorityLabel"),
|
|
"estimate": issue_data.get("estimate"),
|
|
"sort_order": issue_data.get("sortOrder"),
|
|
"created_at": issue_data.get("createdAt"),
|
|
"updated_at": issue_data.get("updatedAt"),
|
|
"completed_at": issue_data.get("completedAt"),
|
|
"canceled_at": issue_data.get("canceledAt"),
|
|
"due_date": issue_data.get("dueDate"),
|
|
"url": issue_data.get("url"),
|
|
"branch_name": issue_data.get("branchName"),
|
|
"creator": clean_user_data(issue_data.get("creator", {})),
|
|
"assignee": clean_user_data(issue_data.get("assignee", {})),
|
|
"state": clean_state_data(issue_data.get("state", {})),
|
|
"team": clean_team_data(issue_data.get("team", {})),
|
|
"project": clean_project_data(issue_data.get("project", {})),
|
|
"parent": clean_issue_data(issue_data.get("parent", {}))
|
|
if issue_data.get("parent")
|
|
else None,
|
|
"labels": _clean_issue_labels(issue_data),
|
|
"comments": _clean_issue_comments(issue_data),
|
|
"attachments": _clean_issue_attachments(issue_data),
|
|
"relations": _clean_issue_relations(issue_data),
|
|
"children": _clean_issue_children(issue_data),
|
|
}
|
|
|
|
return remove_none_values(cleaned_issue)
|
|
|
|
|
|
def add_pagination_info(response: dict[str, Any], page_info: dict[str, Any]) -> dict[str, Any]:
|
|
"""Add pagination information to response"""
|
|
response["pagination"] = {
|
|
"has_next_page": page_info.get("hasNextPage", False),
|
|
"has_previous_page": page_info.get("hasPreviousPage", False),
|
|
"start_cursor": page_info.get("startCursor"),
|
|
"end_cursor": page_info.get("endCursor"),
|
|
}
|
|
return response
|