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

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

## Project Overview

This is `@makeplane/plane-node-sdk` — a TypeScript SDK for the Plane API. It uses axios for HTTP, targets Node.js >=20, and is managed with pnpm@10.20.0.

## Common Commands

```bash
pnpm install # Install dependencies
pnpm build # Compile TS + bundle type definitions
pnpm dev # Watch mode for development
pnpm test # Run all tests (Jest)
pnpm test:unit # Unit tests only
pnpm test:e2e # E2E tests only
pnpm test -- --testPathPattern=tests/unit/project # Run a single test file
pnpm test:coverage # Run with coverage report
pnpm check:lint # Lint check (ESLint)
pnpm fix:lint # Auto-fix lint issues
pnpm check:format # Format check (Prettier, 120 char width)
pnpm fix:format # Auto-format
```

## Testing

Tests live in `tests/unit/` and `tests/e2e/`. Tests require a `.env.test` file (copy from `env.example`) with real workspace/project IDs. Tests run sequentially (`maxWorkers: 1`) to avoid API rate limits. Jest uses `tsconfig.jest.json` via ts-jest.

## Architecture

**Entry point**: `src/index.ts` re-exports everything. The main consumer-facing class is `PlaneClient` (`src/client/plane-client.ts`), which instantiates all API resources with shared `Configuration`.

**BaseResource pattern** (`src/api/BaseResource.ts`): Abstract base class providing HTTP methods (get, post, patch, put, httpDelete) via axios. All API resource classes extend it. Handles both `apiKey` (X-Api-Key header) and `accessToken` (Bearer token) auth. Includes optional request/response logging with sensitive data sanitization.

**API resources** (`src/api/`): Each resource class extends BaseResource. Some have sub-resources as separate classes composed by the parent:
- `WorkItems/` → Comments, Attachments, Activities, Relations, WorkLogs
- `Customers/` → Properties, Requests
- `Teamspaces/` → Members, Projects
- `Initiatives/` → Labels, Projects, Epics
- `AgentRuns/` → Activities
- `WorkItemProperties/` → Options, Values

**Models** (`src/models/`): TypeScript interfaces for each entity with separate Create/Update DTOs. Uses `Pick`, `Omit`, and `Partial` for DTO derivation. Notable: `WorkItem` uses a generic expandable fields pattern (`WorkItem<E extends WorkItemExpandableFieldName = never>`).

**Errors** (`src/errors/`): `PlaneError` (base) → `HttpError` (HTTP-specific with status code and response data).

**OAuth**: Standalone `OAuthClient` (`src/client/oauth-client.ts`) handles authorization flows, token exchange, and refresh separately from the main SDK auth.

## Conventions

- All API endpoint URLs must end with `/`
- Standard resource methods: `list`, `create`, `retrieve`, `update`, `del`
- Never use "Issue" in names — always use "Work Item"
- File naming: kebab-case for files, PascalCase for classes, camelCase for methods
- Avoid `any` types; use proper typing or `unknown` with type guards
- Build produces `dist/` with compiled JS, declarations, source maps, and a bundled `types.bundle.d.ts`
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module.exports = {
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
testTimeout: 30000, // 30 seconds timeout for API tests
testTimeout: 60000, // 60 seconds timeout for API tests
verbose: true,
// Allow tests to run in parallel but with some control
maxWorkers: 1, // Run tests sequentially to avoid API rate limits
Expand Down
12 changes: 12 additions & 0 deletions src/api/WorkItems/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
WorkItemExpandableFieldName,
WorkItemBase,
WorkItemSearch,
AdvancedSearchWorkItem,
AdvancedSearchResult,
} from "../../models/WorkItem";
import { PaginatedResponse } from "../../models/common";
import { Links } from "../Links";
Expand Down Expand Up @@ -136,4 +138,14 @@ export class WorkItems extends BaseResource {
project: projectId,
});
}

/**
* Perform advanced search on work items with filters.
*
* Supports text-based search via `query` and/or structured filters
* using recursive AND/OR groups.
*/
async advancedSearch(workspaceSlug: string, data: AdvancedSearchWorkItem): Promise<AdvancedSearchResult[]> {
return this.post<AdvancedSearchResult[]>(`/workspaces/${workspaceSlug}/work-items/advanced-search/`, data);
}
}
36 changes: 36 additions & 0 deletions src/models/WorkItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,39 @@ export interface WorkItemSearchItem {
project_id: string; // Project ID
workspace__slug: string; // Workspace slug
}

/**
* Filter condition for advanced search.
* Either a leaf condition (e.g. { state_id: "..." }) or a group with "and"/"or" keys.
*/
export type AdvancedSearchFilter = {
and?: AdvancedSearchFilter[];
or?: AdvancedSearchFilter[];
[key: string]: unknown;
};

/**
* Request body for advanced work item search.
*/
export interface AdvancedSearchWorkItem {
query?: string;
filters?: AdvancedSearchFilter;
limit?: number;
}

/**
* Result item from advanced work item search.
*/
export interface AdvancedSearchResult {
id: string;
name: string;
sequence_id: number;
project_identifier: string;
project_id: string;
workspace_id: string;
type_id?: string | null;
state_id?: string | null;
priority?: string | null;
target_date?: string | null;
start_date?: string | null;
}
49 changes: 49 additions & 0 deletions tests/e2e/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,55 @@ describe("End to End Project Test", () => {
expect(workItems.results.length).toBeGreaterThan(0);
});

it("should advanced search work items with query", async () => {
// Wait for search index to update after work item creation
await wait(5);

const results = await client.workItems.advancedSearch(e2eConfig.workspaceSlug, {
query: workItem1.name,
limit: 10,
});

expect(Array.isArray(results)).toBe(true);
expect(results.length).toBeGreaterThan(0);

const found = results.find((r) => r.id === workItem1.id);
expect(found).toBeDefined();
expect(found!.name).toBe(workItem1.name);
expect(found!.sequence_id).toBeDefined();
expect(found!.project_id).toBe(project.id);
expect(found!.workspace_id).toBeDefined();
});

it("should advanced search work items with nested filters", async () => {
const states = await client.states.list(e2eConfig.workspaceSlug, project.id);
const stateId = states.results[0]?.id;

const results = await client.workItems.advancedSearch(e2eConfig.workspaceSlug, {
filters: {
and: [
...(stateId ? [{ state_id: stateId }] : []),
{
or: [
{ priority: "none" },
{ priority: "high" },
],
},
],
},
limit: 10,
});

expect(Array.isArray(results)).toBe(true);
for (const item of results) {
expect(item.id).toBeDefined();
expect(item.name).toBeDefined();
expect(item.sequence_id).toBeDefined();
expect(item.project_id).toBeDefined();
expect(item.workspace_id).toBeDefined();
}
});

it("should create work item relations", async () => {
await client.workItems.relations.create(e2eConfig.workspaceSlug, project.id, workItem1.id, {
relation_type: "relates_to",
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/work-items/work-items.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,66 @@ describe(!!(config.workspaceSlug && config.projectId && config.userId), "Work It
const foundWorkItem = searchedWorkItemsResponse.issues.find((wi) => wi.id === workItem.id);
expect(foundWorkItem).toBeDefined();
});

it("should advanced search work items with query only", async () => {
const results = await client.workItems.advancedSearch(workspaceSlug, {
query: workItem.name,
limit: 10,
});

expect(Array.isArray(results)).toBe(true);
for (const item of results) {
expect(item.id).toBeDefined();
expect(item.name).toBeDefined();
expect(item.sequence_id).toBeDefined();
expect(item.project_id).toBeDefined();
expect(item.workspace_id).toBeDefined();
}
});

it("should advanced search work items with filters", async () => {
const results = await client.workItems.advancedSearch(workspaceSlug, {
filters: {
and: [
{ priority: workItem.priority },
],
},
limit: 10,
});

expect(Array.isArray(results)).toBe(true);
for (const item of results) {
expect(item.id).toBeDefined();
expect(item.name).toBeDefined();
}
});

it("should advanced search work items with nested AND/OR filters", async () => {
const states = await client.states.list(workspaceSlug, projectId);
const stateId = states.results[0]?.id;

const results = await client.workItems.advancedSearch(workspaceSlug, {
filters: {
and: [
...(stateId ? [{ state_id: stateId }] : []),
{
or: [
{ priority: "high" },
{ priority: "urgent" },
],
},
],
},
limit: 10,
});

expect(Array.isArray(results)).toBe(true);
for (const item of results) {
expect(item.id).toBeDefined();
expect(item.name).toBeDefined();
expect(item.sequence_id).toBeDefined();
expect(item.project_id).toBeDefined();
expect(item.workspace_id).toBeDefined();
}
});
});
Loading