diff --git a/actions/linear/CHANGELOG.md b/actions/linear/CHANGELOG.md index 521d9d41..33d6a6e6 100644 --- a/actions/linear/CHANGELOG.md +++ b/actions/linear/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/) and this project adheres to [Semantic Versioning](https://semver.org/). +## [1.2.0] - 2025-03-07 + +### Added + +- New `search_projects` action to search Linear projects +- Support for filtering projects by name, team, and initiative +- New models for project filtering and responses (`ProjectFilterOptions`, `Project`, `ProjectList`) +- Dependency versions updated + ## [1.1.0] - 2025-03-24 ### Added diff --git a/actions/linear/README.md b/actions/linear/README.md index 58329be1..0065ed6d 100644 --- a/actions/linear/README.md +++ b/actions/linear/README.md @@ -7,6 +7,7 @@ Actions available: - create an issue - search for issues - add comment to an issue +- search for projects ## Prompts @@ -36,6 +37,24 @@ For DevTools team create an issue "add more features to Linear action package" f > URL: View Issue in Linear > Let me know if there's anything else you need! +``` +Show me all projects for DevTools team that were updated recently +``` + +> Searching for projects... +> +> 1. Action Package Gallery +> Team: DevTools +> Updated: 2 days ago +> Description: Collection of reusable action packages +> +> 2. Linear Integration +> Team: DevTools +> Updated: 5 days ago +> Description: Linear API integration package +> +> Showing 2 most recently updated projects for DevTools team. + ## Authentication Supports at the moment personal API keys, which can be acquired from https://linear.app/sema4ai/settings/api. diff --git a/actions/linear/actions.py b/actions/linear/actions.py index 87e8b65d..95ca333d 100644 --- a/actions/linear/actions.py +++ b/actions/linear/actions.py @@ -1,11 +1,12 @@ import json -from models import FilterOptions, Issue, IssueList +from models import FilterOptions, Issue, IssueList, ProjectFilterOptions, Project, ProjectList from queries import ( query_add_comment, query_create_issue, query_get_issues, query_search_issues, + query_search_projects, ) from sema4ai.actions import Response, Secret, action from support import ( @@ -116,3 +117,55 @@ def add_comment(issue_id: str, body: str, api_key: Secret) -> Response[str]: return Response( result=f"Comment added - link {comment_response['commentCreate']['comment']['url']}" ) + + +@action +def search_projects( + filter_options: ProjectFilterOptions, + api_key: Secret, +) -> Response[ProjectList]: + """ + Search projects from Linear. + + The values for "ordering" can be "createdAt" or "updatedAt". + Returns by default 50 projects matching the filter options. + + Args: + api_key: The API key to use to authenticate with the Linear API. + filter_options: The filter options to use to search for projects. + + Returns: + List of projects matching the filter criteria. + """ + filter_dict = {} + if filter_options.name: + filter_dict["name"] = {"contains": filter_options.name} + if filter_options.initiative: + filter_dict["initiatives"] = {"some": {"name": {"contains": filter_options.initiative}}} + + query_variables = { + "first": filter_options.limit if filter_options.limit else 50, + "orderBy": filter_options.ordering.value if filter_options.ordering else "updatedAt", + } + if filter_dict: + query_variables["filter"] = filter_dict + + search_response = _make_graphql_request(query_search_projects, query_variables, api_key) + projects = search_response["projects"]["nodes"] + + # Filter by team name after fetching if team_name is specified + if filter_options.team_name: + projects = [ + p for p in projects + if any( + team["name"].lower() == filter_options.team_name.lower() + for team in p.get("teams", {}).get("nodes", []) + ) + ] + + project_list = ProjectList(nodes=[]) + for project_data in projects: + project = Project.create(project_data) + project_list.nodes.append(project) + + return Response(result=project_list) diff --git a/actions/linear/devdata/input_search_projects.json b/actions/linear/devdata/input_search_projects.json new file mode 100644 index 00000000..ba9ac470 --- /dev/null +++ b/actions/linear/devdata/input_search_projects.json @@ -0,0 +1,89 @@ +{ + "inputs": [ + { + "inputName": "Search by name", + "inputValue": { + "filter_options": { + "name": "Improved Data Visualization", + "team_name": "", + "initiative": "", + "limit": "", + "ordering": "" + }, + "api_key": "" + } + }, + { + "inputName": "Search by team", + "inputValue": { + "filter_options": { + "name": "", + "team_name": "Work Room", + "initiative": "", + "limit": "", + "ordering": "" + }, + "api_key": "" + } + }, + { + "inputName": "Search by initiative", + "inputValue": { + "filter_options": { + "name": "", + "team_name": "", + "initiative": "Sai", + "limit": "", + "ordering": "" + }, + "api_key": "" + } + }, + { + "inputName": "Search with multiple filters", + "inputValue": { + "filter_options": { + "name": "", + "team_name": "Studio", + "initiative": "Sai", + "limit": "10", + "ordering": "createdAt" + }, + "api_key": "" + } + }, + { + "inputName": "Get recent projects", + "inputValue": { + "filter_options": { + "name": "", + "team_name": "", + "initiative": "", + "limit": "5", + "ordering": "updatedAt" + }, + "api_key": "" + } + } + ], + "metadata": { + "actionName": "search_projects", + "actionRelativePath": "actions.py", + "schemaDescription": [ + "filter_options.name: string", + "filter_options.team_name: string", + "filter_options.initiative: string", + "filter_options.limit: string", + "filter_options.ordering: string" + ], + "managedParamsSchemaDescription": { + "api_key": { + "type": "Secret", + "description": "The API key to use to authenticate with the Linear API." + } + }, + "inputFileVersion": "v3", + "kind": "action", + "actionSignature": "action/args: 'filter_options: ProjectFilterOptions, api_key: Secret'" + } +} \ No newline at end of file diff --git a/actions/linear/models.py b/actions/linear/models.py index 71f88e50..31fa850d 100644 --- a/actions/linear/models.py +++ b/actions/linear/models.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from typing import Annotated, List, Optional @@ -143,12 +143,77 @@ class TeamList(BaseModel): nodes: List[Team] +class ProjectFilterOptions(BaseModel): + name: Optional[str] = None + team_name: Optional[str] = None + initiative: Optional[str] = None + limit: Optional[int] = Field(default=50) + ordering: Optional[OrderType] = Field(default=OrderType.UPDATED_AT) + + @model_validator(mode='before') + @classmethod + def validate_empty_strings(cls, data: dict) -> dict: + """Convert empty strings to None for optional fields""" + if isinstance(data, dict): + for field in ['name', 'team_name', 'initiative']: + if field in data and data[field] == '': + data[field] = None + # Handle limit field + if 'limit' in data and (data['limit'] == '' or data['limit'] is None): + data['limit'] = 50 + # Handle ordering field + if 'ordering' in data and (data['ordering'] == '' or data['ordering'] is None): + data['ordering'] = OrderType.UPDATED_AT + return data + + class Project(BaseModel): id: str name: str description: Optional[str] = None startDate: Optional[datetime] = None targetDate: Optional[datetime] = None + teams: Optional[List[NameAndId]] = None + initiatives: Optional[List[NameAndId]] = None + url: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + + @classmethod + def create(cls, data: dict) -> "Project": + """Create a Project instance from Linear API data + + Args: + data: Dictionary containing project data from Linear API + Returns: + New Project instance with populated fields + """ + return cls( + id=data.get("id"), + name=data.get("name"), + description=data.get("description"), + startDate=data.get("startDate"), + targetDate=data.get("targetDate"), + teams=( + [ + NameAndId(name=team.get("name", ""), id=team.get("id", "")) + for team in data.get("teams", {}).get("nodes", []) + ] + if data.get("teams", {}).get("nodes") + else None + ), + initiatives=( + [ + NameAndId(name=init.get("name", ""), id=init.get("id", "")) + for init in data.get("initiatives", {}).get("nodes", []) + ] + if data.get("initiatives", {}).get("nodes") + else None + ), + url=data.get("url"), + created_at=data.get("createdAt"), + updated_at=data.get("updatedAt"), + ) class ProjectList(BaseModel): diff --git a/actions/linear/package.yaml b/actions/linear/package.yaml index 614e91f3..315a1e16 100644 --- a/actions/linear/package.yaml +++ b/actions/linear/package.yaml @@ -5,7 +5,7 @@ name: Linear description: Linear actions for handling issues # Package version number, recommend using semver.org -version: 1.1.0 +version: 1.1.1 # The version of the `package.yaml` format. spec-version: v2 @@ -13,11 +13,11 @@ spec-version: v2 dependencies: conda-forge: - python=3.10.16 - - python-dotenv=1.0.1 + - python-dotenv=1.1.0 - uv=0.4.17 pypi: - sema4ai-actions=1.3.6 - - pydantic=2.10.4 + - pydantic=2.11.1 - requests=2.32.3 external-endpoints: @@ -40,4 +40,4 @@ packaging: - ./.venv/** - ./.DS_Store/** - ./**/*.pyc - - ./**/*.zip + - ./**/*.zip \ No newline at end of file diff --git a/actions/linear/queries.py b/actions/linear/queries.py index b1a63ee2..de1dae58 100644 --- a/actions/linear/queries.py +++ b/actions/linear/queries.py @@ -201,3 +201,32 @@ } } """ + +query_search_projects = """ +query SearchProjects($filter: ProjectFilter, $orderBy: PaginationOrderBy, $first: Int = 50) { + projects(filter: $filter, orderBy: $orderBy, first: $first) { + nodes { + id + name + description + startDate + targetDate + teams { + nodes { + id + name + } + } + initiatives { + nodes { + id + name + } + } + url + createdAt + updatedAt + } + } +} +"""