Skip to content
Merged
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
98 changes: 98 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Plane Python SDK (`plane-sdk` on PyPI, v0.2.4) — a synchronous, type-annotated Python client for the Plane API. Built on `requests` + `pydantic` v2, targeting Python 3.10+.

## Common Commands

```bash
# Install for development
pip install -e .
pip install -r requirements.txt

# Run all unit tests (requires env vars, see below)
pytest tests/unit/

# Run a specific test file or test
pytest tests/unit/test_projects.py
pytest tests/unit/test_projects.py::TestProjectsAPICRUD::test_create_project

# Integration/script tests (excluded by default via addopts)
pytest tests/scripts/ --override-ini="addopts="

# Formatting & linting
black plane tests
ruff check plane tests
ruff check --fix plane tests

# Type checking
mypy plane
```

### Required Environment Variables for Tests

Tests make real HTTP requests (no mocking). Set these before running:

- `PLANE_BASE_URL` — API base URL
- `PLANE_API_KEY` or `PLANE_ACCESS_TOKEN` — authentication (exactly one)
- `WORKSPACE_SLUG` — test workspace
- `AGENT_SLUG` — (optional) needed only for agent run tests

## Architecture

### Client → Resource → Model pattern

`PlaneClient` is the single entry point. It holds a `Configuration` and exposes resource objects as attributes:

```
PlaneClient
├── .projects → Projects(BaseResource)
├── .work_items → WorkItems(BaseResource)
│ ├── .comments
│ ├── .attachments
│ ├── .links
│ └── ...sub-resources
├── .cycles → Cycles(BaseResource)
└── ...15+ resources
```

### Key directories

- `plane/api/` — Resource classes. Every resource extends `BaseResource` which handles HTTP methods, auth headers, URL building (`/api/v1/...`), retry via `urllib3.Retry`, and response parsing.
- `plane/models/` — Pydantic v2 models. Three kinds per resource:
- **Response models** (e.g. `Project`): `extra="allow"` for forward compatibility with new API fields.
- **Request DTOs** (e.g. `CreateProject`, `UpdateProject`): `extra="ignore"` to be strict about inputs.
- **Query param models** (e.g. `PaginatedQueryParams`): `extra="ignore"`.
- `plane/client/` — `PlaneClient` (API key / access token auth) and `OAuthClient` (OAuth 2.0 flows).
- `plane/errors/` — `PlaneError` → `HttpError`, `ConfigurationError`.
- `plane/config.py` — `Configuration` and `RetryConfig` dataclasses.

### Sub-resource pattern

Resources with children (work_items, customers, initiatives, teamspaces) instantiate sub-resource objects in `__init__`:

```python
class WorkItems(BaseResource):
def __init__(self, config):
super().__init__(config, "/workspaces/")
self.comments = WorkItemComments(config)
self.attachments = WorkItemAttachments(config)
```

### URL convention

All API endpoints end with a trailing `/`. URLs are built as `{base_path}/api/v1{resource_base_path}/{endpoint}/`.

## Coding Conventions

- Line length: 100 (Black + Ruff)
- Use `X | None` not `Optional[X]`; use `list[str]` not `List[str]` (Python 3.10+ builtins)
- Import abstract types from `collections.abc` (e.g. `Mapping`, `Iterable`)
- Ruff rules: E, F, I (isort), UP (pyupgrade), B (bugbear)
- Never use "Issue" in endpoint or parameter names — always use "Work Item"
- Auth is mutually exclusive: `api_key` XOR `access_token` (raises `ConfigurationError` if both/neither)
- Resource methods accept Pydantic DTOs, serialize with `model_dump(exclude_none=True)`, and validate responses with `Model.model_validate()`
- All resources follow CRUD verbs: `create`, `retrieve`, `update`, `delete`, `list`
45 changes: 45 additions & 0 deletions plane/api/work_items/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from __future__ import annotations

from typing import Any

from ...models.query_params import RetrieveQueryParams, WorkItemQueryParams
from ...models.work_items import (
AdvancedSearchResult,
AdvancedSearchWorkItem,
CreateWorkItem,
PaginatedWorkItemResponse,
UpdateWorkItem,
Expand Down Expand Up @@ -189,3 +193,44 @@ def search(
search_params.update(params.model_dump(exclude_none=True))
response = self._get(f"{workspace_slug}/work-items/search", params=search_params)
return WorkItemSearch.model_validate(response)

def advanced_search(
self,
workspace_slug: str,
data: AdvancedSearchWorkItem,
) -> list[AdvancedSearchResult]:
"""Perform advanced search on work items with filters.

Supports text-based search via ``query`` and/or structured filters
using recursive AND/OR groups.

Args:
workspace_slug: The workspace slug identifier
data: Advanced search request with query, filters, and limit

Example::

from plane.models.work_items import AdvancedSearchWorkItem

results = client.work_items.advanced_search(
"my-workspace",
AdvancedSearchWorkItem(
query="new",
filters={
"and": [
{"state_id": "state-uuid"},
{"or": [
{"priority": "high"},
{"state_id": "other-state-uuid"},
]},
]
},
limit=100,
),
)
"""
response = self._post(
f"{workspace_slug}/work-items/advanced-search",
data.model_dump(exclude_none=True),
)
return [AdvancedSearchResult.model_validate(item) for item in response]
50 changes: 50 additions & 0 deletions plane/models/work_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,56 @@ class WorkItemSearch(BaseModel):
issues: list[WorkItemSearchItem]


class AdvancedSearchWorkItem(BaseModel):
"""Request model for advanced work item search with filters.

Filters support recursive AND/OR groups. Each filter condition is a
single key-value dict (e.g. ``{"state_id": "..."}``). Groups are nested
using ``"and"`` / ``"or"`` keys whose values are lists of conditions or
sub-groups.

Example::

AdvancedSearchWorkItem(
query="new",
filters={
"and": [
{"state_id": "abc-123"},
{"or": [
{"priority": "high"},
{"state_id": "def-456"},
]},
]
},
limit=100,
)
"""

model_config = ConfigDict(extra="ignore", populate_by_name=True)

query: str | None = None
filters: dict[str, Any] | None = None
limit: int | None = None


class AdvancedSearchResult(BaseModel):
"""Advanced search result item."""

model_config = ConfigDict(extra="allow", populate_by_name=True)

id: str
name: str
sequence_id: int
project_identifier: str
project_id: str
workspace_id: str
type_id: str | None = None
state_id: str | None = None
priority: str | None = None
target_date: str | None = None
start_date: str | None = None


class WorkItemActivity(BaseModel):
"""Work item activity model."""

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "plane-sdk"
version = "0.2.4"
version = "0.2.5"
description = "Python SDK for Plane API"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
52 changes: 51 additions & 1 deletion tests/unit/test_work_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from plane.client import PlaneClient
from plane.models.projects import Project
from plane.models.query_params import PaginatedQueryParams
from plane.models.work_items import CreateWorkItem, UpdateWorkItem
from plane.models.work_items import AdvancedSearchWorkItem, CreateWorkItem, UpdateWorkItem


class TestWorkItemsAPI:
Expand Down Expand Up @@ -38,6 +38,56 @@ def test_search_work_items(self, client: PlaneClient, workspace_slug: str) -> No
assert hasattr(response, "issues")
assert isinstance(response.issues, list)

def test_advanced_search_work_items(
self, client: PlaneClient, workspace_slug: str
) -> None:
"""Test advanced search with query only."""
data = AdvancedSearchWorkItem(query="test", limit=10)
results = client.work_items.advanced_search(workspace_slug, data)
assert isinstance(results, list)
for item in results:
assert item.id is not None
assert item.name is not None
assert item.sequence_id is not None
assert item.project_id is not None
assert item.workspace_id is not None

def test_advanced_search_with_filters(
self, client: PlaneClient, workspace_slug: str
) -> None:
"""Test advanced search with filters."""
data = AdvancedSearchWorkItem(
filters={
"and": [
{"priority": "high"},
]
},
limit=10,
)
results = client.work_items.advanced_search(workspace_slug, data)
assert isinstance(results, list)
for item in results:
assert item.id is not None
assert item.priority == "high"

def test_advanced_search_with_nested_filters(
self, client: PlaneClient, workspace_slug: str
) -> None:
"""Test advanced search with nested AND/OR filters."""
data = AdvancedSearchWorkItem(
filters={
"or": [
{"priority": "high"},
{"priority": "urgent"},
]
},
limit=10,
)
results = client.work_items.advanced_search(workspace_slug, data)
assert isinstance(results, list)
for item in results:
assert item.priority in ("high", "urgent")


class TestWorkItemsAPICRUD:
"""Test WorkItems API CRUD operations."""
Expand Down