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
63 changes: 62 additions & 1 deletion .github/workflows/build-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,27 @@ on:
- check-tests

jobs:
check-secrets:
runs-on: ubuntu-latest
outputs:
has_token: ${{ steps.check.outputs.has_token }}
steps:
- name: Check whether PLANE_EE_CHECKOUT_TOKEN is configured
# `secrets` isn't reliably available in a job-level `if:` on every runner
# context, so export the check as a step output here instead and gate the
# v2-golden-drift job on that output.
id: check
run: echo "has_token=${{ secrets.PLANE_EE_CHECKOUT_TOKEN != '' }}" >> "$GITHUB_OUTPUT"

build-lint:
runs-on: ubuntu-latest
defaults:
run:
working-directory: plane-node-sdk
steps:
- uses: actions/checkout@v4
with:
path: plane-node-sdk

- name: Set up Node.js
uses: actions/setup-node@v4
Expand All @@ -36,9 +53,53 @@ jobs:
- name: Run build
run: pnpm run build

- name: Run tests
- name: Run unit tests
run: pnpm run test:unit

- name: Run e2e tests
run: pnpm run test:e2e
env:
PLANE_API_KEY: ${{ secrets.PLANE_API_KEY }}
PLANE_BASE_URL: ${{ secrets.PLANE_BASE_URL }}
TEST_WORKSPACE_SLUG: ${{ vars.TEST_WORKSPACE_SLUG }}

v2-golden-drift:
needs: check-secrets
if: ${{ needs.check-secrets.outputs.has_token == 'true' }}
runs-on: ubuntu-latest
# Runs inside plane-node-sdk/ with plane-ee as a sibling dir: the generator records
# its golden path in the output header, so the relative spelling must match the
# committed one (`../plane-ee/apps/api/plane/api_v2/core/schema/openapi`).
defaults:
run:
working-directory: plane-node-sdk
steps:
- uses: actions/checkout@v4
with:
path: plane-node-sdk

- name: Set up Node.js
uses: actions/setup-node@v4

- name: Enable corepack
run: corepack enable pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Checkout plane-ee (api_v2 OpenAPI golden)
# PLANE_EE_CHECKOUT_TOKEN: fine-grained PAT with read access to makeplane/plane-ee.
# Without it this job is skipped (visibly), not failed.
uses: actions/checkout@v4
with:
repository: makeplane/plane-ee
ref: preview
token: ${{ secrets.PLANE_EE_CHECKOUT_TOKEN }}
sparse-checkout: apps/api/plane/api_v2/core/schema/openapi
sparse-checkout-cone-mode: false
path: plane-ee

- name: Check generated v2 constants against the api_v2 golden
run: |
pnpm codegen:v2 ../plane-ee/apps/api/plane/api_v2/core/schema/openapi
git diff --exit-code src/api/v2/generated/constants.ts
18 changes: 18 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,24 @@
"node": true
}
},
{
"files": ["scripts/**"],
"rules": {
"no-console": "off"
}
},
// Deliberately narrow: covers e2e cleanup logging only (a test-support helper
// that warns on a best-effort cleanup failure is doing the right thing). Does
// NOT extend to tests/** generally -- pre-existing `console` warnings elsewhere
// under tests/ (e.g. stray console.log calls in v1 test files) are intentionally
// left visible; a broader tests/** override would silence ~70 of them and turn
// the lint count from a tripwire into a number nobody can trust again.
{
"files": ["tests/e2e/v2/support/**"],
"rules": {
"no-console": "off"
}
},
{
"files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
"rules": {
Expand Down
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ Tests live in `tests/unit/` and `tests/e2e/`. Tests require a `.env.test` file (
- `AgentRuns/` → Activities
- `WorkItemProperties/` → Options, Values

**API v2** (`src/api/v2/`): the v2 surface, reached as `client.v2`. The bound/chained
form is the only public shape — `client.v2.workspace(slug)` returns a `Workspace`
locator (`src/api/v2/Workspace.ts`), `.project(key)` off of that returns a `Project`
locator (`src/api/v2/Project.ts`); both do zero I/O. `Wiki.ts` is `Workspace.wiki`'s
container (`.pages`/`.collections`). `kernel/` holds the shared machinery (transport,
pagination, generic `V2Resource`, bulk helpers); `generated/constants.ts` is produced
by `pnpm codegen:v2` from the api_v2 OpenAPI golden and must never be hand-edited.
Resources are thin declarations over `V2Resource`, constructed with a `scope`
(`{ slug }` / `{ slug, project_id }`) a locator already bound — `V2Resource`'s
`urlFor` resolves path placeholders from `{...scope, ...pathParams}`, explicit
`pathParams` winning. No public v2 method takes `workspaceSlug`/`project` — the
locator supplies both; leaf ids (`workItemId`, `releaseId`, ...) stay as the first
positional argument. Models live in `src/models/v2/`; read models mark every
field except `id` optional, because `?fields=` and collection deferral can omit any
of them. The wiki page model is `Page` (`src/models/v2/Page.ts`) — files that also
need the pagination envelope `Page<T>` (`models/v2/common.ts`) import it under a
local `WikiPage` alias to keep both in scope.

**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).
Expand Down
138 changes: 138 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,144 @@ const project = await client.projects.create("workspace-slug", {
});
```

## API v2

`client.v2` reaches the v2 surface. The v1 resources on the client are unchanged.

The bound/chained form is the **only** public shape: bind a workspace, then (for
project-scoped resources) a project — both locators do zero I/O, so building the
chain never makes a request on its own.

```ts
import { PlaneClient, v2 } from "@makeplane/plane-node-sdk";

const client = new PlaneClient({ baseUrl: "https://api.plane.so", apiKey: "..." });

// Bind once. `project` accepts a project id or its key (e.g. "ENG").
const ws = client.v2.workspace("acme");
const eng = ws.project("ENG");

// States resolve by name; every method below takes only the arguments the
// workspace/project scope doesn't already supply.
const todo = await eng.states.findByName("Todo");

// Ask for only the fields you need — list/retrieve narrow the return type to match.
// An inline array literal needs no `as const`.
const page = await eng.states.list({ fields: ["id", "name"] });
for (const state of page.data) {
console.log(state.id, state.name); // typed — only id/name exist on this row
}

// iterate() follows pagination automatically, but always returns the full row type
for await (const state of eng.states.iterate()) {
console.log(state.id, state.name);
}

await eng.states.create({ name: "In Review", color: "#4ECDC4" });

// Batches report per row; partial success is the default. `raiseForFailures` and the
// other bulk helpers live on the `v2` namespace, not the package root.
const result = await eng.states.bulkCreate([{ name: "QA", color: "#fff" }]);
v2.raiseForFailures(result);

// The 6 operations that aren't workspace-scoped at all stay on the top-level namespace.
await client.v2.users.me();
```

No v2 method takes `workspaceSlug`/`project` — the locator supplies both. Leaf ids
(`workItemId`, `releaseId`, `collectionId`, …) stay as the first positional argument:

```ts
const item = await eng.workItems.create({ name: "Fix login bug", state: "Todo", labels: ["bug"] });
await eng.workItems.comments.list(item.id);
await ws.workItems.retrieveByIdentifier("ENG-12"); // by human key, no project needed
await ws.releases.comments.list(releaseId);
```

A field list built at runtime (not a literal) must be typed `StateField[]` /
`LabelField[]` — plain `string[]` is **not** assignable to `readonly StateField[]` and
fails with a long overload-mismatch error:

```ts
import { PlaneClient, v2 } from "@makeplane/plane-node-sdk";

const client = new PlaneClient({ baseUrl: "https://api.plane.so", apiKey: "..." });
const eng = client.v2.workspace("acme").project("ENG");

const wanted: v2.StateField[] = includeColor ? ["id", "name", "color"] : ["id", "name"];
const page = await eng.states.list({ fields: wanted }); // still narrows
```

`"all"` is a legal field value meaning "every field" and correctly yields the full row
type. Sparse responses mean every read field except `id` is optional — check for
`undefined` rather than assuming a field is present.

**Pagination**: `Page<T>` is a union of the offset envelope (`total_count`, `next`) and
the cursor envelope (`has_more`, `next_cursor`) — which one a given `list()` returns
depends on the request's `paginate` param. Narrow it with `v2.isCursorPage` /
`v2.isOffsetPage` before reading an envelope-specific field; reading one without
narrowing first is a compile error, since it may not exist on the other half of the
union:

```ts
import { PlaneClient, v2 } from "@makeplane/plane-node-sdk";

const client = new PlaneClient({ baseUrl: "https://api.plane.so", apiKey: "..." });
const eng = client.v2.workspace("acme").project("ENG");

const page = await eng.states.list();
if (v2.isOffsetPage(page)) {
console.log(page.total_count); // only reachable once narrowed
} else if (v2.isCursorPage(page)) {
console.log(page.next_cursor);
}
```

`order_by` is validated the same way `fields` is, against a generated
`StateOrderBy`/`LabelOrderBy` union — an unsupported value throws client-side rather
than reaching the server.

**Wiki**: `ws.wiki.pages` is every global page in the workspace (not one project —
that's `eng.pages`); `ws.wiki.collections` is wiki collections. A page write's
`collection_id` can be omitted for a public page (it lands in the workspace's default
"General" collection); a private page needs an explicit `collection_id` of a
collection the caller owns.

```ts
const ws = client.v2.workspace("acme");
await ws.wiki.pages.create({ name: "Handbook" }); // public page -> default collection
const handbook = await ws.wiki.collections.findByName("Engineering handbook");
await ws.wiki.pages.create({ name: "Runbook", collection_id: handbook.id });
await ws.wiki.collections.default(); // the default collection, resolved via `is_default`
```

**Errors**: `PlaneApiError` — an RFC 9457 problem detail with `.status`, `.type`,
`.code`, `.detail`, `.errors` — plus `NoMatchFoundError` and
`MultipleMatchesFoundError` from `findByName`. A request that never reaches a server
at all (connection refused, DNS failure, timeout, ...) raises `PlaneNetworkError`
instead, carrying the underlying error's message and `.cause`.

**Bulk writes** (`bulkCreate` / `bulkUpdate` / `bulkDelete`) always answer HTTP 200,
even when some rows fail — partial success is the default. Call
`v2.raiseForFailures(result)` to throw, carrying the first failure's `errors`. The cap
is 50 items per call (`v2.BULK_MAX_ITEMS`); an empty batch is rejected client-side (not
a silent no-op).

**Types**: v2 types are reachable through the `v2` and `v2models` namespaces (e.g.
`v2models.State`, `v2.StateField`), and the most common ones are also aliased at the
package root: `V2Label`, `V2State`, `V2Page`, `V2Cycle`, `V2Module`, `V2Milestone`,
`V2ListStatesParams`, `V2ListLabelsParams`. Use those — v2's bare `Label`/`State`/
`Page`/`Cycle`/`Module`/`Milestone` names collide with v1's in the bundled type
definitions, so `import { Label } from "@makeplane/plane-node-sdk"` resolves to
**v1's** shape, not v2's. (`V2Page` is the wiki page model; the pagination envelope
`Page<T>` is aliased separately as `V2PageEnvelope`.)

**Generated data**: `v2.FIELDS` and `v2.ORDER_BY` are the full operation-id -> allowed-
values maps `encodeFields`/`encodeOrderBy` validate against (e.g. `v2.FIELDS["states_list"]`
lists every field `states.list` accepts); `v2.OPENAPI_VERSION` is the api_v2 golden
version the SDK was generated from. All three, plus `v2.BULK_MAX_ITEMS`, are exported
from the `v2` namespace so a caller can enumerate valid values rather than guessing.

## Features

- ✅ TypeScript support with full type safety
Expand Down
7 changes: 6 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ module.exports = {
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts", "!src/**/*.spec.ts"],
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov", "html"],
testTimeout: 60000, // 60 seconds timeout for API tests
// 180s, not 60s: tests/e2e/v2/support/client.ts's rate-limit retry budget needs
// headroom to actually absorb this dev server's observed 429 Retry-After (56-59s)
// instead of always losing the race against Jest's own timeout — see that file's
// own comment. Harmless for every non-live test: they finish in milliseconds
// regardless of the ceiling.
testTimeout: 180000,
verbose: true,
// Allow tests to run in parallel but with some control
maxWorkers: 1, // Run tests sequentially to avoid API rate limits
Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@makeplane/plane-node-sdk",
"version": "0.2.13",
"version": "0.3.0",
"description": "Node SDK for Plane",
"author": "Plane <engineering@plane.so>",
"repository": {
Expand All @@ -15,7 +15,7 @@
],
"scripts": {
"build:types-bundle": "dts-bundle-generator --no-check -o dist/types.bundle.d.ts dist/index.d.ts",
"build": "tsc && pnpm run build:types-bundle",
"build": "tsc && pnpm run build:types-bundle && node scripts/check-types-bundle.mjs",
"dev": "tsc --watch",
"test": "jest",
"test:unit": "jest --testPathPattern=tests/unit",
Expand All @@ -27,7 +27,9 @@
"check:lint": "oxlint",
"fix:lint": "oxlint --fix",
"check:format": "oxfmt --check",
"fix:format": "oxfmt"
"fix:format": "oxfmt",
"codegen:v2": "node scripts/generate-v2-constants.mjs",
"check:types-bundle": "node scripts/check-types-bundle.mjs"
},
"keywords": [
"plane",
Expand All @@ -47,6 +49,7 @@
"dotenv": "^17.4.2",
"dts-bundle-generator": "^9.5.1",
"jest": "^29.0.0",
"nock": "^14.0.17",
"oxfmt": "^0.42.0",
"oxlint": "^1.57.0",
"ts-node": "^10.9.0",
Expand Down
Loading
Loading