diff --git a/src/api/Milestones.ts b/src/api/Milestones.ts new file mode 100644 index 0000000..af2fcc2 --- /dev/null +++ b/src/api/Milestones.ts @@ -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 { + return this.post(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/`, createMilestone); + } + + /** + * Retrieve a milestone by ID + */ + async retrieve(workspaceSlug: string, projectId: string, milestoneId: string): Promise { + return this.get(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/`); + } + + /** + * Update a milestone + */ + async update( + workspaceSlug: string, + projectId: string, + milestoneId: string, + updateMilestone: UpdateMilestoneRequest + ): Promise { + return this.patch( + `/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/`, + updateMilestone + ); + } + + /** + * Delete a milestone + */ + async delete(workspaceSlug: string, projectId: string, milestoneId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/`); + } + + /** + * List milestones with optional filtering + */ + async list(workspaceSlug: string, projectId: string, params?: any): Promise> { + return this.get>( + `/workspaces/${workspaceSlug}/projects/${projectId}/milestones/`, + params + ); + } + + /** + * Add work items to a milestone + */ + async addWorkItems(workspaceSlug: string, projectId: string, milestoneId: string, issueIds: string[]): Promise { + return this.post(`/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/work-items/`, { + issues: issueIds, + }); + } + + /** + * Remove work items from a milestone + */ + async removeWorkItems( + workspaceSlug: string, + projectId: string, + milestoneId: string, + issueIds: string[] + ): Promise { + 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> { + return this.get>( + `/workspaces/${workspaceSlug}/projects/${projectId}/milestones/${milestoneId}/work-items/`, + params + ); + } +} diff --git a/src/client/plane-client.ts b/src/client/plane-client.ts index 3ab76e6..8245222 100644 --- a/src/client/plane-client.ts +++ b/src/client/plane-client.ts @@ -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"; /** @@ -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; @@ -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); } diff --git a/src/index.ts b/src/index.ts index 151bd76..4b17201 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/models/Milestone.ts b/src/models/Milestone.ts new file mode 100644 index 0000000..bbeded9 --- /dev/null +++ b/src/models/Milestone.ts @@ -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; +} diff --git a/src/models/index.ts b/src/models/index.ts index 9abbc19..ce61a01 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -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"; diff --git a/tests/unit/milestone.test.ts b/tests/unit/milestone.test.ts new file mode 100644 index 0000000..b944b34 --- /dev/null +++ b/tests/unit/milestone.test.ts @@ -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; + }); +});