Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions actions/linear/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 54 additions & 1 deletion actions/linear/actions.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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)
89 changes: 89 additions & 0 deletions actions/linear/devdata/input_search_projects.json
Original file line number Diff line number Diff line change
@@ -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'"
}
}
67 changes: 66 additions & 1 deletion actions/linear/models.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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):
Expand Down
6 changes: 3 additions & 3 deletions actions/linear/package.yaml
Comment thread
pcodding marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions actions/linear/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
"""