diff --git a/README.md b/README.md index 74db00a..f7228a5 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,10 @@ Tools for managing issues, their comments, and related items like priorities, ca - `get_resolutions`: Returns list of issue resolutions. - `get_watching_list_items`: Returns list of watching items for a user. - `get_watching_list_count`: Returns count of watching items for a user. +- `add_watching`: Adds a new watch to an issue. +- `update_watching`: Updates an existing watch note. +- `delete_watching`: Deletes a watch from an issue. +- `mark_watching_as_read`: Marks a watch as read. - `get_version_milestone_list`: Returns list of version milestones for a project. - `add_version_milestone`: Creates a new version milestone for a project. - `update_version_milestone`: Updates an existing version milestone. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 9d2edcf..5585d28 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -66,6 +66,10 @@ ### Watch-related - ✅ Retrieving watched item lists (`get_watching_list_items`) - ✅ Retrieving watch counts (`get_watching_list_count`) +- ✅ Adding watches (`add_watching`) +- ✅ Updating watches (`update_watching`) +- ✅ Deleting watches (`delete_watching`) +- ✅ Marking watches as read (`mark_watching_as_read`) ### Infrastructure - ✅ MCP server implementation @@ -78,10 +82,6 @@ ### Watch-related - ❌ Retrieving watches (`get_watching`) -- ❌ Adding watches (`add_watching`) -- ❌ Updating watches (`update_watching`) -- ❌ Deleting watches (`delete_watching`) -- ❌ Marking watches as read (`mark_watching_as_read`) ### Attachment-related - ❌ Uploading attachments (`post_attachment_file`) diff --git a/src/tools/addWatching.test.ts b/src/tools/addWatching.test.ts new file mode 100644 index 0000000..94482c0 --- /dev/null +++ b/src/tools/addWatching.test.ts @@ -0,0 +1,63 @@ +import { addWatchingTool } from './addWatching.js'; +import { jest, describe, it, expect } from '@jest/globals'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; + +describe('addWatchingTool', () => { + const mockBacklog: Partial = { + postWatchingListItem: jest.fn<() => Promise>().mockResolvedValue({ + id: 1, + resourceAlreadyRead: false, + note: 'Watching this issue', + type: 'issue', + issue: { + id: 1000, + projectId: 100, + issueKey: 'TEST-1', + summary: 'Test issue', + }, + created: '2023-01-01T00:00:00Z', + updated: '2023-01-01T00:00:00Z', + }), + }; + + const mockTranslationHelper = createTranslationHelper(); + const tool = addWatchingTool(mockBacklog as Backlog, mockTranslationHelper); + + it('returns created watching item as formatted JSON text', async () => { + const result = await tool.handler({ + issueIdOrKey: 'TEST-1', + note: 'Watching this issue', + }); + + if (Array.isArray(result)) { + throw new Error('Unexpected array result'); + } + expect(result).toHaveProperty('id'); + expect(result.note).toBe('Watching this issue'); + }); + + it('calls backlog.postWatchingListItem with correct params when using issue key', async () => { + await tool.handler({ + issueIdOrKey: 'TEST-1', + note: 'Watching this issue', + }); + + expect(mockBacklog.postWatchingListItem).toHaveBeenCalledWith({ + issueIdOrKey: 'TEST-1', + note: 'Watching this issue', + }); + }); + + it('calls backlog.postWatchingListItem with correct params when using issue ID', async () => { + await tool.handler({ + issueIdOrKey: 1, + note: 'Important issue', + }); + + expect(mockBacklog.postWatchingListItem).toHaveBeenCalledWith({ + issueIdOrKey: 1, + note: 'Important issue', + }); + }); +}); diff --git a/src/tools/addWatching.ts b/src/tools/addWatching.ts new file mode 100644 index 0000000..888c0e1 --- /dev/null +++ b/src/tools/addWatching.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, ToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { WatchingListItemSchema } from '../types/zod/backlogOutputDefinition.js'; + +const addWatchingSchema = buildToolSchema((t) => ({ + issueIdOrKey: z + .union([z.number(), z.string()]) + .describe( + t( + 'TOOL_ADD_WATCHING_ISSUE_ID_OR_KEY', + 'Issue ID or issue key (e.g., 1234 or "PROJECT-123")' + ) + ), + note: z + .string() + .describe(t('TOOL_ADD_WATCHING_NOTE', 'Optional note for the watch')) + .optional() + .default(''), +})); + +export const addWatchingTool = ( + backlog: Backlog, + { t }: TranslationHelper +): ToolDefinition< + ReturnType, + (typeof WatchingListItemSchema)['shape'] +> => { + return { + name: 'add_watching', + description: t( + 'TOOL_ADD_WATCHING_DESCRIPTION', + 'Adds a new watch to an issue' + ), + schema: z.object(addWatchingSchema(t)), + outputSchema: WatchingListItemSchema, + handler: async ({ issueIdOrKey, note }) => + backlog.postWatchingListItem({ + issueIdOrKey, + note, + }), + }; +}; diff --git a/src/tools/deleteWatching.test.ts b/src/tools/deleteWatching.test.ts new file mode 100644 index 0000000..179020f --- /dev/null +++ b/src/tools/deleteWatching.test.ts @@ -0,0 +1,53 @@ +import { deleteWatchingTool } from './deleteWatching.js'; +import { jest, describe, it, expect } from '@jest/globals'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; + +describe('deleteWatchingTool', () => { + const mockBacklog: Partial = { + deletehWatchingListItem: jest.fn<() => Promise>().mockResolvedValue({ + id: 1, + resourceAlreadyRead: false, + note: 'Deleted watch', + type: 'issue', + issue: { + id: 1000, + projectId: 100, + issueKey: 'TEST-1', + summary: 'Test issue', + }, + created: '2023-01-01T00:00:00Z', + updated: '2023-01-01T00:00:00Z', + }), + }; + + const mockTranslationHelper = createTranslationHelper(); + const tool = deleteWatchingTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + it('deletes watching', async () => { + const result = await tool.handler({ + watchId: 1, + }); + + expect(result).toHaveProperty('id', 1); + }); + + it('calls backlog.deletehWatchingListItem with correct params', async () => { + await tool.handler({ + watchId: 123, + }); + + expect(mockBacklog.deletehWatchingListItem).toHaveBeenCalledWith(123); + }); + + it('handles deletion of different watch IDs', async () => { + await tool.handler({ + watchId: 456, + }); + + expect(mockBacklog.deletehWatchingListItem).toHaveBeenCalledWith(456); + }); +}); diff --git a/src/tools/deleteWatching.ts b/src/tools/deleteWatching.ts new file mode 100644 index 0000000..9239ae9 --- /dev/null +++ b/src/tools/deleteWatching.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, ToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { WatchingListItemSchema } from '../types/zod/backlogOutputDefinition.js'; + +const deleteWatchingSchema = buildToolSchema((t) => ({ + watchId: z + .number() + .describe(t('TOOL_DELETE_WATCHING_WATCH_ID', 'Watch ID to delete')), +})); + +export const deleteWatchingTool = ( + backlog: Backlog, + { t }: TranslationHelper +): ToolDefinition< + ReturnType, + (typeof WatchingListItemSchema)['shape'] +> => { + return { + name: 'delete_watching', + description: t( + 'TOOL_DELETE_WATCHING_DESCRIPTION', + 'Deletes a watch from an issue' + ), + schema: z.object(deleteWatchingSchema(t)), + outputSchema: WatchingListItemSchema, + handler: async ({ watchId }) => backlog.deletehWatchingListItem(watchId), + }; +}; diff --git a/src/tools/markWatchingAsRead.test.ts b/src/tools/markWatchingAsRead.test.ts new file mode 100644 index 0000000..c84681b --- /dev/null +++ b/src/tools/markWatchingAsRead.test.ts @@ -0,0 +1,37 @@ +import { markWatchingAsReadTool } from './markWatchingAsRead.js'; +import { jest, describe, it, expect } from '@jest/globals'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; + +describe('markWatchingAsReadTool', () => { + const mockBacklog: Partial = { + resetWatchingListItemAsRead: jest + .fn<() => Promise>() + .mockResolvedValue(undefined), + }; + + const mockTranslationHelper = createTranslationHelper(); + const tool = markWatchingAsReadTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + it('returns success message as formatted JSON text', async () => { + const result = await tool.handler({ + watchId: 123, + }); + + if (Array.isArray(result)) { + throw new Error('Unexpected array result'); + } + expect(result.success).toBe(true); + }); + + it('calls backlog.resetWatchingListItemAsRead with correct params', async () => { + await tool.handler({ + watchId: 123, + }); + + expect(mockBacklog.resetWatchingListItemAsRead).toHaveBeenCalledWith(123); + }); +}); diff --git a/src/tools/markWatchingAsRead.ts b/src/tools/markWatchingAsRead.ts new file mode 100644 index 0000000..8d09c4b --- /dev/null +++ b/src/tools/markWatchingAsRead.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, ToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; + +const markWatchingAsReadSchema = buildToolSchema((t) => ({ + watchId: z + .number() + .describe( + t('TOOL_MARK_WATCHING_AS_READ_WATCH_ID', 'Watch ID to mark as read') + ), +})); + +export const MarkWatchingAsReadResultSchema = z.object({ + success: z.boolean(), + message: z.string(), +}); + +export const markWatchingAsReadTool = ( + backlog: Backlog, + { t }: TranslationHelper +): ToolDefinition< + ReturnType, + (typeof MarkWatchingAsReadResultSchema)['shape'] +> => { + return { + name: 'mark_watching_as_read', + description: t( + 'TOOL_MARK_WATCHING_AS_READ_DESCRIPTION', + 'Mark a watch as read' + ), + schema: z.object(markWatchingAsReadSchema(t)), + outputSchema: MarkWatchingAsReadResultSchema, + handler: async ({ watchId }) => { + await backlog.resetWatchingListItemAsRead(watchId); + return { + success: true, + message: `Watch ${watchId} marked as read`, + }; + }, + }; +}; diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 68d055f..d27666b 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -33,6 +33,10 @@ import { getSpaceTool } from './getSpace.js'; import { getUsersTool } from './getUsers.js'; import { getWatchingListCountTool } from './getWatchingListCount.js'; import { getWatchingListItemsTool } from './getWatchingListItems.js'; +import { addWatchingTool } from './addWatching.js'; +import { updateWatchingTool } from './updateWatching.js'; +import { deleteWatchingTool } from './deleteWatching.js'; +import { markWatchingAsReadTool } from './markWatchingAsRead.js'; import { getWikiTool } from './getWiki.js'; import { getWikiPagesTool } from './getWikiPages.js'; import { getWikisCountTool } from './getWikisCount.js'; @@ -100,6 +104,10 @@ export const allTools = ( getResolutionsTool(backlog, helper), getWatchingListItemsTool(backlog, helper), getWatchingListCountTool(backlog, helper), + addWatchingTool(backlog, helper), + updateWatchingTool(backlog, helper), + deleteWatchingTool(backlog, helper), + markWatchingAsReadTool(backlog, helper), getVersionMilestoneListTool(backlog, helper), addVersionMilestoneTool(backlog, helper), updateVersionMilestoneTool(backlog, helper), diff --git a/src/tools/updateWatching.test.ts b/src/tools/updateWatching.test.ts new file mode 100644 index 0000000..109ea11 --- /dev/null +++ b/src/tools/updateWatching.test.ts @@ -0,0 +1,55 @@ +import { updateWatchingTool } from './updateWatching.js'; +import { jest, describe, it, expect } from '@jest/globals'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; + +describe('updateWatchingTool', () => { + const mockBacklog: Partial = { + patchWatchingListItem: jest.fn<() => Promise>().mockResolvedValue({ + id: 1, + resourceAlreadyRead: false, + note: 'Updated note', + type: 'issue', + issue: { + id: 1000, + projectId: 100, + issueKey: 'TEST-1', + summary: 'Test issue', + }, + created: '2023-01-01T00:00:00Z', + updated: '2023-01-02T00:00:00Z', + }), + }; + + const mockTranslationHelper = createTranslationHelper(); + const tool = updateWatchingTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + it('returns updated watching item', async () => { + const result = await tool.handler({ + watchId: 1, + note: 'Updated note', + }); + + if (Array.isArray(result)) { + throw new Error('Unexpected array result'); + } + + expect(result).toHaveProperty('id', 1); + expect(result.note).toBe('Updated note'); + }); + + it('calls backlog.patchWatchingListItem with correct params', async () => { + await tool.handler({ + watchId: 1, + note: 'Updated note', + }); + + expect(mockBacklog.patchWatchingListItem).toHaveBeenCalledWith( + 1, + 'Updated note' + ); + }); +}); diff --git a/src/tools/updateWatching.ts b/src/tools/updateWatching.ts new file mode 100644 index 0000000..e74ba80 --- /dev/null +++ b/src/tools/updateWatching.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, ToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { WatchingListItemSchema } from '../types/zod/backlogOutputDefinition.js'; + +const updateWatchingSchema = buildToolSchema((t) => ({ + watchId: z.number().describe(t('TOOL_UPDATE_WATCHING_WATCH_ID', 'Watch ID')), + note: z + .string() + .describe(t('TOOL_UPDATE_WATCHING_NOTE', 'Updated note for the watch')), +})); + +export const updateWatchingTool = ( + backlog: Backlog, + { t }: TranslationHelper +): ToolDefinition< + ReturnType, + (typeof WatchingListItemSchema)['shape'] +> => { + return { + name: 'update_watching', + description: t( + 'TOOL_UPDATE_WATCHING_DESCRIPTION', + 'Updates an existing watch note' + ), + schema: z.object(updateWatchingSchema(t)), + outputSchema: WatchingListItemSchema, + handler: async ({ watchId, note }) => + backlog.patchWatchingListItem(watchId, note), + }; +};