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
99 changes: 99 additions & 0 deletions src/api/Milestones.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { BaseResource } from "./BaseResource";
import { Configuration } from "../Configuration";
import { PaginatedResponse } from "../models/common";

import { Milestone, CreateMilestoneRequest, UpdateMilestoneRequest, MilestoneWorkItem } from "../models/Milestone";

/**
* Milestones API resource
* Handles all milestone related operations
*/
export class Milestones extends BaseResource {
constructor(config: Configuration) {
super(config);
}

/**
* Create a new milestone
*/
async create(workspaceSlug: string, projectId: string, createMilestone: CreateMilestoneRequest): Promise<Milestone> {
return this.post<Milestone>(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/`, createMilestone);
}

/**
* Retrieve a milestone by ID
*/
async retrieve(workspaceSlug: string, projectId: string, milestoneId: string): Promise<Milestone> {
return this.get<Milestone>(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/`);
}

/**
* Update a milestone
*/
async update(
workspaceSlug: string,
projectId: string,
milestoneId: string,
updateMilestone: UpdateMilestoneRequest
): Promise<Milestone> {
return this.patch<Milestone>(
`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/`,
updateMilestone
);
}

/**
* Delete a milestone
*/
async delete(workspaceSlug: string, projectId: string, milestoneId: string): Promise<void> {
return this.httpDelete(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/`);
}
Comment on lines +48 to +50
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Rename delete to del to match the standard resource method pattern.

Per coding guidelines, standard resource methods should follow the pattern: list, create, retrieve, update, del. As per coding guidelines, "Standard resource methods should follow the pattern: list, create, retrieve, update, del".

♻️ Proposed fix
-  async delete(workspaceSlug: string, projectId: string, milestoneId: string): Promise<void> {
+  async del(workspaceSlug: string, projectId: string, milestoneId: string): Promise<void> {
     return this.httpDelete(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/`);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async delete(workspaceSlug: string, projectId: string, milestoneId: string): Promise<void> {
return this.httpDelete(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/`);
}
async del(workspaceSlug: string, projectId: string, milestoneId: string): Promise<void> {
return this.httpDelete(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/`);
}
🤖 Prompt for AI Agents
In `@src/api/Milestones.ts` around lines 48 - 50, The method currently named
delete in the Milestones class should be renamed to del to match the standard
resource method pattern; locate the async method delete(workspaceSlug,
projectId, milestoneId) (and any places referencing it) and rename the function
to async del(workspaceSlug: string, projectId: string, milestoneId: string):
Promise<void>, keeping the same implementation (return this.httpDelete(...));
also update any internal or external callers/tests/imports that reference
Milestones.delete to use Milestones.del to avoid breakage.


/**
* List milestones with optional filtering
*/
async list(workspaceSlug: string, projectId: string, params?: any): Promise<PaginatedResponse<Milestone>> {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Replace any with proper typing for query params.

params?: any on Lines 55 and 92 violates the guideline to avoid any types. Use Record<string, unknown> or a dedicated query-params interface.

♻️ Proposed fix
-  async list(workspaceSlug: string, projectId: string, params?: any): Promise<PaginatedResponse<Milestone>> {
+  async list(workspaceSlug: string, projectId: string, params?: Record<string, unknown>): Promise<PaginatedResponse<Milestone>> {
-    params?: any
+    params?: Record<string, unknown>

As per coding guidelines, "Avoid any types; use proper typing or unknown with type guards".

Also applies to: 92-92

🤖 Prompt for AI Agents
In `@src/api/Milestones.ts` at line 55, Replace the use of the `any` type for
query params in the Milestones API methods by defining and using a proper type
(e.g., a dedicated interface like MilestoneQueryParams or at minimum
Record<string, unknown>) and update the method signatures where `params?: any`
appears (notably in the `list` method and the other occurrence around line 92);
ensure any downstream usage of `params` is typed or guarded (use typed
properties or type-narrowing) so callers and callers' code compile without
`any`.

return this.get<PaginatedResponse<Milestone>>(
`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/`,
params
);
}

/**
* Add work items to a milestone
*/
async addWorkItems(workspaceSlug: string, projectId: string, milestoneId: string, issueIds: string[]): Promise<void> {
return this.post<void>(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/work-items/`, {
issues: issueIds,
});
Comment on lines +65 to +68
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/api/Milestones.ts | head -90

Repository: makeplane/plane-node-sdk

Length of output: 3413


🏁 Script executed:

# Check if "issues" is a backend API contract by looking at other API usages and responses
rg -n "issues" --type=ts src/api/ -B 2 -A 2 | head -100

Repository: makeplane/plane-node-sdk

Length of output: 5317


🏁 Script executed:

# Check if there are any type definitions or models that define the payload structure
fd -e ts -e json . src/models src/types 2>/dev/null | head -20

Repository: makeplane/plane-node-sdk

Length of output: 515


🏁 Script executed:

# Check Modules.ts to see the full pattern of how payload is structured
sed -n '75,90p' src/api/Modules.ts

Repository: makeplane/plane-node-sdk

Length of output: 456


🏁 Script executed:

# Check Cycles.ts to see the same pattern
sed -n '100,110p' src/api/Cycles.ts

Repository: makeplane/plane-node-sdk

Length of output: 346


🏁 Script executed:

# Search for any backend response or test fixtures that might show the expected payload structure
rg -n "work_items|work-items" --type=ts src/ | head -20

Repository: makeplane/plane-node-sdk

Length of output: 2863


Rename "issue" parameter to "work item" terminology; keep backend API payload contract.

The parameter issueIds violates the coding guideline (never use "Issue" in names). Rename it to workItemIds to align with the rest of the codebase.

However, the payload key issues must remain unchanged—it is the backend API contract. Other similar methods in the codebase (Modules.ts, Cycles.ts) already follow this pattern: parameter uses workItemIds, but the payload key stays as issues.

♻️ Proposed fix
-  async addWorkItems(workspaceSlug: string, projectId: string, milestoneId: string, issueIds: string[]): Promise<void> {
+  async addWorkItems(workspaceSlug: string, projectId: string, milestoneId: string, workItemIds: string[]): Promise<void> {
     return this.post<void>(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/work-items/`, {
-      issues: issueIds,
+      issues: workItemIds,
     });
   }
   async removeWorkItems(
     workspaceSlug: string,
     projectId: string,
     milestoneId: string,
-    issueIds: string[]
+    workItemIds: string[]
   ): Promise<void> {
     return this.httpDelete(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/work-items/`, {
-      issues: issueIds,
+      issues: workItemIds,
     });
   }

Also applies to: 74-82

🤖 Prompt for AI Agents
In `@src/api/Milestones.ts` around lines 65 - 68, Rename the parameter issueIds to
workItemIds in the addWorkItems method signature (async
addWorkItems(workspaceSlug, projectId, milestoneId, workItemIds): Promise<void>)
and pass workItemIds as the value for the existing payload key "issues" when
calling this.post so the backend contract stays unchanged; also apply the same
rename-to-workItemIds-but-keep-payload-key="issues" pattern to the other
method(s) in this file around the 74-82 region to match Modules.ts/Cycles.ts
conventions.

}

/**
* Remove work items from a milestone
*/
async removeWorkItems(
workspaceSlug: string,
projectId: string,
milestoneId: string,
issueIds: string[]
): Promise<void> {
return this.httpDelete(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/work-items/`, {
issues: issueIds,
});
}

/**
* List work items in a milestone
*/
async listWorkItems(
workspaceSlug: string,
projectId: string,
milestoneId: string,
params?: any
): Promise<PaginatedResponse<MilestoneWorkItem>> {
return this.get<PaginatedResponse<MilestoneWorkItem>>(
`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/work-items/`,
params
);
}
}
3 changes: 3 additions & 0 deletions src/client/plane-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Intake } from "../api/Intake";
import { Stickies } from "../api/Stickies";
import { Teamspaces } from "../api/Teamspaces";
import { Initiatives } from "../api/Initiatives";
import { Milestones } from "../api/Milestones";
import { AgentRuns } from "../api/AgentRuns";

/**
Expand All @@ -44,6 +45,7 @@ export class PlaneClient {
public intake: Intake;
public stickies: Stickies;
public teamspaces: Teamspaces;
public milestones: Milestones;
public initiatives: Initiatives;
public agentRuns: AgentRuns;

Expand Down Expand Up @@ -77,6 +79,7 @@ export class PlaneClient {
this.intake = new Intake(this.config);
this.stickies = new Stickies(this.config);
this.teamspaces = new Teamspaces(this.config);
this.milestones = new Milestones(this.config);
this.initiatives = new Initiatives(this.config);
this.agentRuns = new AgentRuns(this.config);
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export { Epics } from "./api/Epics";
export { Intake } from "./api/Intake";
export { Stickies } from "./api/Stickies";
export { Teamspaces } from "./api/Teamspaces";
export { Milestones } from "./api/Milestones";
export { Initiatives } from "./api/Initiatives";
export { AgentRuns } from "./api/AgentRuns";

Expand Down
42 changes: 42 additions & 0 deletions src/models/Milestone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { BaseModel } from "./common";

export interface Milestone extends BaseModel {
title: string;
// YYYY-MM-DD format
target_date?: string;
project: string;
workspace: string;
}

export interface MilestoneLite {
id?: string;
title: string;
// YYYY-MM-DD format
target_date?: string;
external_source?: string;
external_id?: string;
created_at?: string;
updated_at?: string;
}

export interface CreateMilestoneRequest {
title: string;
// YYYY-MM-DD format
target_date?: string;
external_source?: string;
external_id?: string;
}

export interface UpdateMilestoneRequest {
title?: string;
// YYYY-MM-DD format
target_date?: string;
external_source?: string;
external_id?: string;
}

export interface MilestoneWorkItem {
id?: string;
issue?: string;
milestone?: string;
}
1 change: 1 addition & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from "./InitiativeLabel";
export * from "./Intake";
export * from "./Label";
export * from "./Link";
export * from "./Milestone";
export * from "./Module";
export * from "./OAuth";
export * from "./Page";
Expand Down
125 changes: 125 additions & 0 deletions tests/unit/milestone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { PlaneClient } from "../../src/client/plane-client";
import { Milestone, UpdateMilestoneRequest, MilestoneWorkItem } from "../../src/models";
import { config } from "./constants";
import { createTestClient } from "../helpers/test-utils";
import { describeIf as describe } from "../helpers/conditional-tests";

describe(!!(config.workspaceSlug && config.projectId && config.workItemId), "Milestone API Tests", () => {
let client: PlaneClient;
let workspaceSlug: string;
let projectId: string;
let workItemId: string;
let milestone: Milestone;

beforeAll(async () => {
client = createTestClient();
workspaceSlug = config.workspaceSlug;
projectId = config.projectId;
workItemId = config.workItemId;
});

afterAll(async () => {
// Clean up created resources
if (milestone?.id) {
try {
await client.milestones.delete(workspaceSlug, projectId, milestone.id);
} catch (error) {
console.warn("Failed to delete milestone:", error);
}
}
});

it("should create a milestone", async () => {
milestone = await client.milestones.create(workspaceSlug, projectId, {
title: "Test Milestone",
});

expect(milestone).toBeDefined();
expect(milestone.id).toBeDefined();
expect(milestone.title).toBe("Test Milestone");
});

it("should retrieve a milestone", async () => {
const retrievedMilestone = await client.milestones.retrieve(workspaceSlug, projectId, milestone.id);

expect(retrievedMilestone).toBeDefined();
expect(retrievedMilestone.id).toBe(milestone.id);
expect(retrievedMilestone.title).toBe(milestone.title);
});

it("should update a milestone", async () => {
const updateData: UpdateMilestoneRequest = {
title: "Updated Test Milestone",
target_date: "2026-12-31",
};

const updatedMilestone = await client.milestones.update(
workspaceSlug,
projectId,
milestone.id,
updateData
);

expect(updatedMilestone).toBeDefined();
expect(updatedMilestone.id).toBe(milestone.id);
expect(updatedMilestone.title).toBe("Updated Test Milestone");
expect(updatedMilestone.target_date).toBe("2026-12-31");
});

it("should list milestones", async () => {
const milestones = await client.milestones.list(workspaceSlug, projectId);

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

const foundMilestone = milestones.results.find((m) => m.id === milestone.id);
expect(foundMilestone).toBeDefined();
expect(foundMilestone?.title).toBe("Updated Test Milestone");
});

it("should add work items to milestone", async () => {
await client.milestones.addWorkItems(workspaceSlug, projectId, milestone.id, [workItemId]);

const workItems = await client.milestones.listWorkItems(workspaceSlug, projectId, milestone.id);

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

it("should list work items in milestone", async () => {
const workItems = await client.milestones.listWorkItems(workspaceSlug, projectId, milestone.id);

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

it("should remove work items from milestone", async () => {
await client.milestones.removeWorkItems(workspaceSlug, projectId, milestone.id, [workItemId]);

const workItems = await client.milestones.listWorkItems(workspaceSlug, projectId, milestone.id);

expect(workItems).toBeDefined();
expect(Array.isArray(workItems.results)).toBe(true);

const foundWorkItem = workItems.results.find((item: MilestoneWorkItem) => item.issue === workItemId);
expect(foundWorkItem).toBeUndefined();
});

it("should delete a milestone", async () => {
await client.milestones.delete(workspaceSlug, projectId, milestone.id);

// Verify it's deleted by trying to retrieve it
try {
await client.milestones.retrieve(workspaceSlug, projectId, milestone.id);
fail("Expected an error when retrieving a deleted milestone");
} catch (error) {
expect(error).toBeDefined();
}

// Prevent afterAll from trying to delete again
milestone = undefined as any;
});
Comment on lines +111 to +124
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

fail() is not a reliable Jest global — use throw new Error() instead.

fail() is not defined in newer Jest versions (27+) and can cause ReferenceError at runtime, which would be caught by the surrounding catch block and make the test pass incorrectly even when the retrieve succeeds.

🐛 Proposed fix
     try {
       await client.milestones.retrieve(workspaceSlug, projectId, milestone.id);
-      fail("Expected an error when retrieving a deleted milestone");
+      throw new Error("Expected an error when retrieving a deleted milestone");
     } catch (error) {
-      expect(error).toBeDefined();
+      expect(error).toBeDefined();
+      // Ensure we didn't catch our own thrown error
+      expect((error as Error).message).not.toBe("Expected an error when retrieving a deleted milestone");
     }
🧰 Tools
🪛 ESLint

[error] 111-111: 'it' is not defined.

(no-undef)


[error] 117-117: 'fail' is not defined.

(no-undef)


[error] 119-119: 'expect' is not defined.

(no-undef)

🤖 Prompt for AI Agents
In `@tests/unit/milestone.test.ts` around lines 111 - 124, The test uses the
unreliable global fail() which can be undefined in recent Jest versions; update
the "should delete a milestone" test to throw an explicit error when retrieval
unexpectedly succeeds (e.g., replace fail("Expected an error when retrieving a
deleted milestone") with throw new Error("Expected an error when retrieving a
deleted milestone")) or alternatively assert via
expect(client.milestones.retrieve(...)).rejects, ensuring the error path for
client.milestones.retrieve(workspaceSlug, projectId, milestone.id) is correctly
detected and keep the cleanup line milestone = undefined as is.

});
Loading