From cd1ad3c3ae4a50f3271ab2564d1f89f8c1e49615 Mon Sep 17 00:00:00 2001 From: Paul Codding Date: Sun, 23 Mar 2025 10:32:59 -0500 Subject: [PATCH 1/4] Added support for searching projects --- actions/linear/CHANGELOG.md | 10 +++ actions/linear/actions.py | 55 +++++++++++- .../linear/devdata/input_search_projects.json | 89 +++++++++++++++++++ actions/linear/models.py | 67 +++++++++++++- actions/linear/package.yaml | 6 +- actions/linear/queries.py | 29 ++++++ 6 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 actions/linear/devdata/input_search_projects.json diff --git a/actions/linear/CHANGELOG.md b/actions/linear/CHANGELOG.md index 87435f83..e0f8ac97 100644 --- a/actions/linear/CHANGELOG.md +++ b/actions/linear/CHANGELOG.md @@ -5,6 +5,16 @@ 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.1.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`) +- Input validation for empty string handling in project filters +- Dependency versions updated + ## [1.0.2] - 2025-03-06 ### Changed 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..fba04eed --- /dev/null +++ b/actions/linear/devdata/input_search_projects.json @@ -0,0 +1,89 @@ +{ + "inputs": [ + { + "inputName": "Search by name", + "inputValue": { + "filter_options": { + "name": "Leverage Existing RPA", + "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..bb5761b4 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 + team: Optional[NameAndId] = None + initiative: Optional[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"), + team=( + NameAndId( + name=data.get("teams", {}).get("nodes", [{}])[0].get("name"), + id=data.get("teams", {}).get("nodes", [{}])[0].get("id"), + ) + if data.get("teams", {}).get("nodes") + else None + ), + initiative=( + NameAndId( + name=data.get("initiatives", {}).get("nodes", [{}])[0].get("name"), + id=data.get("initiatives", {}).get("nodes", [{}])[0].get("id"), + ) + 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 18c7b63d..6acb2818 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.0.2 +version: 1.1.0 # The version of the `package.yaml` format. spec-version: v2 @@ -16,8 +16,8 @@ dependencies: - python-dotenv=1.0.1 - uv=0.4.17 pypi: - - sema4ai-actions=1.3.5 - - pydantic=2.10.4 + - sema4ai-actions=1.3.6 + - pydantic=2.10.6 - requests=2.32.3 packaging: 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 + } + } +} +""" From 5e26a8bdce66d36f9a85ed6eb15edd10d0a9c441 Mon Sep 17 00:00:00 2001 From: Paul Codding Date: Sun, 23 Mar 2025 13:28:18 -0500 Subject: [PATCH 2/4] Updated to support pulling back all teams and initiatives --- actions/linear/README.md | 19 +++++++++++++++ .../linear/devdata/input_search_projects.json | 2 +- actions/linear/models.py | 24 +++++++++---------- 3 files changed, 32 insertions(+), 13 deletions(-) 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/devdata/input_search_projects.json b/actions/linear/devdata/input_search_projects.json index fba04eed..ba9ac470 100644 --- a/actions/linear/devdata/input_search_projects.json +++ b/actions/linear/devdata/input_search_projects.json @@ -4,7 +4,7 @@ "inputName": "Search by name", "inputValue": { "filter_options": { - "name": "Leverage Existing RPA", + "name": "Improved Data Visualization", "team_name": "", "initiative": "", "limit": "", diff --git a/actions/linear/models.py b/actions/linear/models.py index bb5761b4..31fa850d 100644 --- a/actions/linear/models.py +++ b/actions/linear/models.py @@ -173,8 +173,8 @@ class Project(BaseModel): description: Optional[str] = None startDate: Optional[datetime] = None targetDate: Optional[datetime] = None - team: Optional[NameAndId] = None - initiative: Optional[NameAndId] = 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 @@ -194,19 +194,19 @@ def create(cls, data: dict) -> "Project": description=data.get("description"), startDate=data.get("startDate"), targetDate=data.get("targetDate"), - team=( - NameAndId( - name=data.get("teams", {}).get("nodes", [{}])[0].get("name"), - id=data.get("teams", {}).get("nodes", [{}])[0].get("id"), - ) + 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 ), - initiative=( - NameAndId( - name=data.get("initiatives", {}).get("nodes", [{}])[0].get("name"), - id=data.get("initiatives", {}).get("nodes", [{}])[0].get("id"), - ) + 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 ), From 4853d87ce694784b5cb59d500d624dbd76ff118a Mon Sep 17 00:00:00 2001 From: Paul Codding Date: Sun, 23 Mar 2025 21:50:39 -0500 Subject: [PATCH 3/4] Added external-endpoint --- actions/linear/package.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/actions/linear/package.yaml b/actions/linear/package.yaml index 6acb2818..4bfc4b57 100644 --- a/actions/linear/package.yaml +++ b/actions/linear/package.yaml @@ -33,3 +33,11 @@ packaging: - ./.DS_Store/** - ./**/*.pyc - ./**/*.zip + +external-endpoints: + - name: "Linear API" + description: "Accesses to the Linear API" + additional-info-link: "https://developers.linear.app/docs/graphql/working-with-the-graphql-api" + rules: + - host: "api.linear.app" + port: 443 \ No newline at end of file From f685c7af153dbc49b6bc3f71e3d3a324ce7a315d Mon Sep 17 00:00:00 2001 From: Paul Codding Date: Sun, 30 Mar 2025 17:41:46 -0500 Subject: [PATCH 4/4] Moved external-endpoints to the correct location. --- actions/linear/package.yaml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/actions/linear/package.yaml b/actions/linear/package.yaml index 57a3142a..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,12 +40,4 @@ packaging: - ./.venv/** - ./.DS_Store/** - ./**/*.pyc - - ./**/*.zip - -external-endpoints: - - name: "Linear API" - description: "Accesses to the Linear API" - additional-info-link: "https://developers.linear.app/docs/graphql/working-with-the-graphql-api" - rules: - - host: "api.linear.app" - port: 443 \ No newline at end of file + - ./**/*.zip \ No newline at end of file