Skip to content
This repository was archived by the owner on Jul 8, 2026. It is now read-only.

Commit a8dfd51

Browse files
Merge pull request #160 from pasha-tw/feat/epics-create-comment
feat: add epics-create-comment tool
2 parents 97d788f + dd6aa83 commit a8dfd51

3 files changed

Lines changed: 171 additions & 2 deletions

File tree

src/client/shortcut.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { readFileSync } from "node:fs";
33
import { basename } from "node:path";
44
import type {
55
ShortcutClient as BaseClient,
6+
CreateCommentComment,
67
CreateDoc,
78
CreateEpic,
9+
CreateEpicComment,
810
CreateIteration,
911
CreateLabelParams,
1012
CreateStoryComment,
@@ -26,6 +28,7 @@ import type {
2628
StoryLink,
2729
StorySlim,
2830
Task,
31+
ThreadedComment,
2932
UpdateDoc,
3033
UpdateEpic,
3134
UpdateIteration,
@@ -446,6 +449,35 @@ export class ShortcutClientWrapper {
446449
await this.client.deleteIteration(iterationPublicId);
447450
}
448451

452+
async createEpicComment(
453+
epicPublicId: number,
454+
params: CreateEpicComment,
455+
): Promise<ThreadedComment> {
456+
const response = await this.client.createEpicComment(epicPublicId, params);
457+
const epicComment = response?.data ?? null;
458+
459+
if (!epicComment) throw new Error(`Failed to create the comment: ${response.status}`);
460+
461+
return epicComment;
462+
}
463+
464+
async createEpicCommentComment(
465+
epicPublicId: number,
466+
commentPublicId: number,
467+
params: CreateCommentComment,
468+
): Promise<ThreadedComment> {
469+
const response = await this.client.createEpicCommentComment(
470+
epicPublicId,
471+
commentPublicId,
472+
params,
473+
);
474+
const epicComment = response?.data ?? null;
475+
476+
if (!epicComment) throw new Error(`Failed to create the comment reply: ${response.status}`);
477+
478+
return epicComment;
479+
}
480+
449481
async createEpic(params: CreateEpic): Promise<Epic> {
450482
const response = await this.client.createEpic(params);
451483
const epic = response?.data ?? null;

src/tools/epics.test.ts

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,11 @@ describe("EpicTools", () => {
113113
expect(mockToolRead.mock.calls?.[0]?.[0]).toBe("epics-get-by-id");
114114
expect(mockToolRead.mock.calls?.[1]?.[0]).toBe("epics-search");
115115

116-
expect(mockToolWrite).toHaveBeenCalledTimes(3);
116+
expect(mockToolWrite).toHaveBeenCalledTimes(4);
117117
expect(mockToolWrite.mock.calls?.[0]?.[0]).toBe("epics-create");
118118
expect(mockToolWrite.mock.calls?.[1]?.[0]).toBe("epics-update");
119-
expect(mockToolWrite.mock.calls?.[2]?.[0]).toBe("epics-delete");
119+
expect(mockToolWrite.mock.calls?.[2]?.[0]).toBe("epics-create-comment");
120+
expect(mockToolWrite.mock.calls?.[3]?.[0]).toBe("epics-delete");
120121
});
121122
});
122123

@@ -425,6 +426,102 @@ describe("EpicTools", () => {
425426
});
426427
});
427428

429+
describe("createEpicComment method", () => {
430+
const getEpicMock = mock(async (id: number) => mockEpics.find((epic) => epic.id === id));
431+
const createEpicCommentMock = mock(async (_id: number, _params: { text: string }) => ({
432+
id: 100,
433+
text: "Test comment",
434+
app_url: "https://app.shortcut.com/test/epic/1/comments/100",
435+
}));
436+
437+
beforeEach(() => {
438+
createEpicCommentMock.mockClear();
439+
});
440+
441+
test("should create a comment on an epic", async () => {
442+
const mockClient = createMockClient({
443+
getEpic: getEpicMock,
444+
createEpicComment: createEpicCommentMock,
445+
});
446+
const epicTools = new EpicTools(mockClient);
447+
const result = await epicTools.createEpicComment({
448+
epicPublicId: 1,
449+
text: "Test comment",
450+
});
451+
452+
expect(getTextContent(result)).toBe(
453+
"Created comment on epic 1. Comment URL: https://app.shortcut.com/test/epic/1/comments/100.",
454+
);
455+
expect(createEpicCommentMock).toHaveBeenCalledTimes(1);
456+
expect(createEpicCommentMock.mock.calls?.[0]?.[0]).toBe(1);
457+
expect(createEpicCommentMock.mock.calls?.[0]?.[1]).toMatchObject({
458+
text: "Test comment",
459+
});
460+
});
461+
462+
test("should throw error when epic is not found", async () => {
463+
const mockClient = createMockClient({
464+
getEpic: mock(async () => null),
465+
createEpicComment: createEpicCommentMock,
466+
});
467+
const epicTools = new EpicTools(mockClient);
468+
469+
await expect(() =>
470+
epicTools.createEpicComment({
471+
epicPublicId: 999,
472+
text: "Test comment",
473+
}),
474+
).toThrow("Failed to retrieve Shortcut epic with public ID: 999");
475+
});
476+
477+
test("should throw error when text is empty", async () => {
478+
const mockClient = createMockClient({
479+
getEpic: getEpicMock,
480+
createEpicComment: createEpicCommentMock,
481+
});
482+
const epicTools = new EpicTools(mockClient);
483+
484+
await expect(() =>
485+
epicTools.createEpicComment({
486+
epicPublicId: 1,
487+
text: "",
488+
}),
489+
).toThrow("Epic comment text is required");
490+
});
491+
492+
test("should create a reply to an existing comment", async () => {
493+
const createEpicCommentCommentMock = mock(
494+
async (_epicId: number, _commentId: number, _params: { text: string }) => ({
495+
id: 200,
496+
text: "Reply comment",
497+
app_url: "https://app.shortcut.com/test/epic/1/comments/200",
498+
}),
499+
);
500+
const mockClient = createMockClient({
501+
getEpic: getEpicMock,
502+
createEpicComment: createEpicCommentMock,
503+
createEpicCommentComment: createEpicCommentCommentMock,
504+
});
505+
const epicTools = new EpicTools(mockClient);
506+
const result = await epicTools.createEpicComment({
507+
epicPublicId: 1,
508+
replyToCommentId: 100,
509+
text: "Reply comment",
510+
});
511+
512+
expect(getTextContent(result)).toBe(
513+
"Created comment on epic 1. Comment URL: https://app.shortcut.com/test/epic/1/comments/200.",
514+
);
515+
expect(createEpicCommentMock).not.toHaveBeenCalled();
516+
expect(createEpicCommentCommentMock).toHaveBeenCalledTimes(1);
517+
expect(createEpicCommentCommentMock.mock.calls?.[0]?.[0]).toBe(1);
518+
expect(createEpicCommentCommentMock.mock.calls?.[0]?.[1]).toBe(100);
519+
expect(createEpicCommentCommentMock.mock.calls?.[0]?.[2]).toMatchObject({
520+
text: "Reply comment",
521+
});
522+
});
523+
});
524+
428525
describe("deleteEpic method", () => {
429526
const getEpicMock = mock(async (id: number) => mockEpics.find((epic) => epic.id === id));
430527
const deleteEpicMock = mock(async (_id: number) => undefined);

src/tools/epics.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ export class EpicTools extends BaseTools {
9393
async (params) => await tools.updateEpic(params),
9494
);
9595

96+
server.addToolWithWriteAccess(
97+
"epics-create-comment",
98+
"Add a comment to an epic.",
99+
{
100+
epicPublicId: z.number().positive().describe("Epic ID"),
101+
replyToCommentId: z.number().positive().optional().describe("Comment ID to reply to"),
102+
text: z.string().min(1).describe("Comment text"),
103+
},
104+
async (params) => await tools.createEpicComment(params),
105+
);
106+
96107
server.addToolWithWriteAccess(
97108
"epics-delete",
98109
"Delete an epic (cannot be undone).",
@@ -196,6 +207,35 @@ export class EpicTools extends BaseTools {
196207
return this.toResult(`Updated epic ${epicPublicId}. Epic URL: ${updatedEpic.app_url}`);
197208
}
198209

210+
async createEpicComment({
211+
epicPublicId,
212+
replyToCommentId,
213+
text,
214+
}: {
215+
epicPublicId: number;
216+
replyToCommentId?: number;
217+
text: string;
218+
}): Promise<CallToolResult> {
219+
if (!epicPublicId) throw new Error("Epic public ID is required");
220+
if (!text) throw new Error("Epic comment text is required");
221+
222+
const epic = await this.client.getEpic(epicPublicId);
223+
if (!epic)
224+
throw new Error(`Failed to retrieve Shortcut epic with public ID: ${epicPublicId}`);
225+
226+
const epicComment = replyToCommentId
227+
? await this.client.createEpicCommentComment(epicPublicId, replyToCommentId, {
228+
text,
229+
})
230+
: await this.client.createEpicComment(epicPublicId, {
231+
text,
232+
});
233+
234+
return this.toResult(
235+
`Created comment on epic ${epicPublicId}. Comment URL: ${epicComment.app_url}.`,
236+
);
237+
}
238+
199239
async deleteEpic(epicPublicId: number): Promise<CallToolResult> {
200240
const epic = await this.client.getEpic(epicPublicId);
201241
if (!epic) throw new Error(`Failed to retrieve Shortcut epic with public ID: ${epicPublicId}`);

0 commit comments

Comments
 (0)