Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
9 changes: 9 additions & 0 deletions actions/linear/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions actions/linear/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Actions available:
- create an issue
- search for issues
- add comment to an issue
- search for projects

## Prompts

Expand Down Expand Up @@ -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.
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": "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'"
}
}
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
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):
Expand Down
8 changes: 4 additions & 4 deletions actions/linear/package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@ 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

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:
Expand All @@ -40,4 +40,4 @@ packaging:
- ./.venv/**
- ./.DS_Store/**
- ./**/*.pyc
- ./**/*.zip
- ./**/*.zip
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
}
}
}
"""