Skip to content

Commit 3ff7bb8

Browse files
feat: add advanced search endpoint for work items
Add POST /work-items/advanced-search/ supporting text queries and recursive AND/OR filter groups, with unit and e2e tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 94a13d9 commit 3ff7bb8

File tree

6 files changed

+215
-2
lines changed

6 files changed

+215
-2
lines changed

CLAUDE.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
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.
8+
9+
## Common Commands
10+
11+
```bash
12+
pnpm install # Install dependencies
13+
pnpm build # Compile TS + bundle type definitions
14+
pnpm dev # Watch mode for development
15+
pnpm test # Run all tests (Jest)
16+
pnpm test:unit # Unit tests only
17+
pnpm test:e2e # E2E tests only
18+
pnpm test -- --testPathPattern=tests/unit/project # Run a single test file
19+
pnpm test:coverage # Run with coverage report
20+
pnpm check:lint # Lint check (ESLint)
21+
pnpm fix:lint # Auto-fix lint issues
22+
pnpm check:format # Format check (Prettier, 120 char width)
23+
pnpm fix:format # Auto-format
24+
```
25+
26+
## Testing
27+
28+
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.
29+
30+
## Architecture
31+
32+
**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`.
33+
34+
**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.
35+
36+
**API resources** (`src/api/`): Each resource class extends BaseResource. Some have sub-resources as separate classes composed by the parent:
37+
- `WorkItems/` → Comments, Attachments, Activities, Relations, WorkLogs
38+
- `Customers/` → Properties, Requests
39+
- `Teamspaces/` → Members, Projects
40+
- `Initiatives/` → Labels, Projects, Epics
41+
- `AgentRuns/` → Activities
42+
- `WorkItemProperties/` → Options, Values
43+
44+
**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>`).
45+
46+
**Errors** (`src/errors/`): `PlaneError` (base) → `HttpError` (HTTP-specific with status code and response data).
47+
48+
**OAuth**: Standalone `OAuthClient` (`src/client/oauth-client.ts`) handles authorization flows, token exchange, and refresh separately from the main SDK auth.
49+
50+
## Conventions
51+
52+
- All API endpoint URLs must end with `/`
53+
- Standard resource methods: `list`, `create`, `retrieve`, `update`, `del`
54+
- Never use "Issue" in names — always use "Work Item"
55+
- File naming: kebab-case for files, PascalCase for classes, camelCase for methods
56+
- Avoid `any` types; use proper typing or `unknown` with type guards
57+
- Build produces `dist/` with compiled JS, declarations, source maps, and a bundled `types.bundle.d.ts`

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@makeplane/plane-node-sdk",
3-
"version": "0.2.6",
3+
"version": "0.2.7",
44
"description": "Node SDK for Plane",
55
"author": "Plane <engineering@plane.so>",
66
"repository": {

src/api/WorkItems/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
WorkItemExpandableFieldName,
99
WorkItemBase,
1010
WorkItemSearch,
11+
AdvancedSearchWorkItem,
12+
AdvancedSearchResult,
1113
} from "../../models/WorkItem";
1214
import { PaginatedResponse } from "../../models/common";
1315
import { Links } from "../Links";
@@ -136,4 +138,14 @@ export class WorkItems extends BaseResource {
136138
project: projectId,
137139
});
138140
}
141+
142+
/**
143+
* Perform advanced search on work items with filters.
144+
*
145+
* Supports text-based search via `query` and/or structured filters
146+
* using recursive AND/OR groups.
147+
*/
148+
async advancedSearch(workspaceSlug: string, data: AdvancedSearchWorkItem): Promise<AdvancedSearchResult[]> {
149+
return this.post<AdvancedSearchResult[]>(`/workspaces/${workspaceSlug}/work-items/advanced-search/`, data);
150+
}
139151
}

src/models/WorkItem.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,39 @@ export interface WorkItemSearchItem {
122122
project_id: string; // Project ID
123123
workspace__slug: string; // Workspace slug
124124
}
125+
126+
/**
127+
* Filter condition for advanced search.
128+
* Either a leaf condition (e.g. { state_id: "..." }) or a group with "and"/"or" keys.
129+
*/
130+
export type AdvancedSearchFilter = {
131+
and?: AdvancedSearchFilter[];
132+
or?: AdvancedSearchFilter[];
133+
[key: string]: unknown;
134+
};
135+
136+
/**
137+
* Request body for advanced work item search.
138+
*/
139+
export interface AdvancedSearchWorkItem {
140+
query?: string;
141+
filters?: AdvancedSearchFilter;
142+
limit?: number;
143+
}
144+
145+
/**
146+
* Result item from advanced work item search.
147+
*/
148+
export interface AdvancedSearchResult {
149+
id: string;
150+
name: string;
151+
sequence_id: number;
152+
project_identifier: string;
153+
project_id: string;
154+
workspace_id: string;
155+
type_id?: string | null;
156+
state_id?: string | null;
157+
priority?: string | null;
158+
target_date?: string | null;
159+
start_date?: string | null;
160+
}

tests/e2e/project.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,52 @@ describe("End to End Project Test", () => {
9797
expect(workItems.results.length).toBeGreaterThan(0);
9898
});
9999

100+
it("should advanced search work items with query", async () => {
101+
const results = await client.workItems.advancedSearch(e2eConfig.workspaceSlug, {
102+
query: workItem1.name,
103+
limit: 10,
104+
});
105+
106+
expect(Array.isArray(results)).toBe(true);
107+
expect(results.length).toBeGreaterThan(0);
108+
109+
const found = results.find((r) => r.id === workItem1.id);
110+
expect(found).toBeDefined();
111+
expect(found!.name).toBe(workItem1.name);
112+
expect(found!.sequence_id).toBeDefined();
113+
expect(found!.project_id).toBe(project.id);
114+
expect(found!.workspace_id).toBeDefined();
115+
});
116+
117+
it("should advanced search work items with nested filters", async () => {
118+
const states = await client.states.list(e2eConfig.workspaceSlug, project.id);
119+
const stateId = states.results[0]?.id;
120+
121+
const results = await client.workItems.advancedSearch(e2eConfig.workspaceSlug, {
122+
filters: {
123+
and: [
124+
...(stateId ? [{ state_id: stateId }] : []),
125+
{
126+
or: [
127+
{ priority: "none" },
128+
{ priority: "high" },
129+
],
130+
},
131+
],
132+
},
133+
limit: 10,
134+
});
135+
136+
expect(Array.isArray(results)).toBe(true);
137+
for (const item of results) {
138+
expect(item.id).toBeDefined();
139+
expect(item.name).toBeDefined();
140+
expect(item.sequence_id).toBeDefined();
141+
expect(item.project_id).toBeDefined();
142+
expect(item.workspace_id).toBeDefined();
143+
}
144+
});
145+
100146
it("should create work item relations", async () => {
101147
await client.workItems.relations.create(e2eConfig.workspaceSlug, project.id, workItem1.id, {
102148
relation_type: "relates_to",

tests/unit/work-items/work-items.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { PlaneClient } from "../../../src/client/plane-client";
2-
import { WorkItem } from "../../../src/models/WorkItem";
2+
import { WorkItem, AdvancedSearchResult } from "../../../src/models/WorkItem";
33
import { config } from "../constants";
44
import { createTestClient, randomizeName } from "../../helpers/test-utils";
55
import { describeIf as describe } from "../../helpers/conditional-tests";
@@ -103,4 +103,66 @@ describe(!!(config.workspaceSlug && config.projectId && config.userId), "Work It
103103
const foundWorkItem = searchedWorkItemsResponse.issues.find((wi) => wi.id === workItem.id);
104104
expect(foundWorkItem).toBeDefined();
105105
});
106+
107+
it("should advanced search work items with query only", async () => {
108+
const results = await client.workItems.advancedSearch(workspaceSlug, {
109+
query: workItem.name,
110+
limit: 10,
111+
});
112+
113+
expect(Array.isArray(results)).toBe(true);
114+
for (const item of results) {
115+
expect(item.id).toBeDefined();
116+
expect(item.name).toBeDefined();
117+
expect(item.sequence_id).toBeDefined();
118+
expect(item.project_id).toBeDefined();
119+
expect(item.workspace_id).toBeDefined();
120+
}
121+
});
122+
123+
it("should advanced search work items with filters", async () => {
124+
const results = await client.workItems.advancedSearch(workspaceSlug, {
125+
filters: {
126+
and: [
127+
{ priority: workItem.priority },
128+
],
129+
},
130+
limit: 10,
131+
});
132+
133+
expect(Array.isArray(results)).toBe(true);
134+
for (const item of results) {
135+
expect(item.id).toBeDefined();
136+
expect(item.name).toBeDefined();
137+
}
138+
});
139+
140+
it("should advanced search work items with nested AND/OR filters", async () => {
141+
const states = await client.states.list(workspaceSlug, projectId);
142+
const stateId = states.results[0]?.id;
143+
144+
const results = await client.workItems.advancedSearch(workspaceSlug, {
145+
filters: {
146+
and: [
147+
...(stateId ? [{ state_id: stateId }] : []),
148+
{
149+
or: [
150+
{ priority: "high" },
151+
{ priority: "urgent" },
152+
],
153+
},
154+
],
155+
},
156+
limit: 10,
157+
});
158+
159+
expect(Array.isArray(results)).toBe(true);
160+
for (const item of results) {
161+
expect(item.id).toBeDefined();
162+
expect(item.name).toBeDefined();
163+
expect(item.sequence_id).toBeDefined();
164+
expect(item.project_id).toBeDefined();
165+
expect(item.workspace_id).toBeDefined();
166+
}
167+
});
106168
});

0 commit comments

Comments
 (0)