diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 405971c..9d2edcf 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -57,6 +57,12 @@ - ✅ Adding pull request comments (`add_pull_request_comment`) - ✅ Updating pull request comments (`update_pull_request_comment`) +### Version/Milestone-related +- ✅ Retrieving version/milestone lists (`get_version_milestone_list`) +- ✅ Adding versions/milestones (`add_version_milestone`) +- ✅ Updating versions/milestones (`update_version_milestone`) +- ✅ Deleting versions (`delete_version`) + ### Watch-related - ✅ Retrieving watched item lists (`get_watching_list_items`) - ✅ Retrieving watch counts (`get_watching_list_count`) @@ -131,10 +137,6 @@ - ❌ Adding categories (`add_category`) - ❌ Updating categories (`update_category`) - ❌ Deleting categories (`delete_category`) -- ❌ Retrieving version/milestone lists (`get_version_milestone_list`) -- ❌ Adding versions/milestones (`add_version_milestone`) -- ❌ Updating versions/milestones (`update_version_milestone`) -- ❌ Deleting versions (`delete_version`) - ❌ Retrieving custom field lists (`get_custom_field_list`) - ❌ Adding custom fields (`add_custom_field`) - ❌ Updating custom fields (`update_custom_field`) @@ -218,7 +220,6 @@ This allows access to Backlog's main features from Claude, with optimizations fo 2. **Medium-term Goals** - Custom field-related features - - Version/milestone-related features - Webhook-related features - Further performance optimizations diff --git a/src/tools/addVersionMilestone.test.ts b/src/tools/addVersionMilestone.test.ts new file mode 100644 index 0000000..c99e4e8 --- /dev/null +++ b/src/tools/addVersionMilestone.test.ts @@ -0,0 +1,104 @@ +import { addVersionMilestoneTool } from './addVersionMilestone.js'; +import { jest, describe, it, expect } from '@jest/globals'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; + +describe('addVersionMilestoneTool', () => { + const mockBacklog: Partial = { + postVersions: jest.fn<() => Promise>().mockResolvedValue({ + id: 1, + projectId: 100, + name: 'Version 1.0.0', + description: 'Initial release version', + startDate: '2023-01-01T00:00:00Z', + releaseDueDate: '2023-03-31T00:00:00Z', + archived: false, + displayOrder: 1, + }), + }; + + const mockTranslationHelper = createTranslationHelper(); + const tool = addVersionMilestoneTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + it('returns created version milestone as formatted JSON text', async () => { + const result = await tool.handler({ + projectKey: 'TEST', + name: 'Version 1.0.0', + description: 'Initial release version', + startDate: '2023-01-01T00:00:00Z', + releaseDueDate: '2023-03-31T00:00:00Z', + }); + + if (Array.isArray(result)) { + throw new Error('Unexpected array result'); + } + expect(result.name).toEqual('Version 1.0.0'); + expect(result.description).toEqual('Initial release version'); + expect(result.startDate).toEqual('2023-01-01T00:00:00Z'); + expect(result.releaseDueDate).toEqual('2023-03-31T00:00:00Z'); + }); + + it('calls backlog.postVersions with correct params when using projectKey', async () => { + const params = { + projectKey: 'TEST', + name: 'Version 1.0.0', + description: 'Initial release version', + startDate: '2023-01-01T00:00:00Z', + releaseDueDate: '2024-03-31T00:00:00Z', + }; + + await tool.handler(params); + + expect(mockBacklog.postVersions).toHaveBeenCalledWith('TEST', { + name: 'Version 1.0.0', + description: 'Initial release version', + startDate: '2023-01-01T00:00:00Z', + releaseDueDate: '2024-03-31T00:00:00Z', + }); + }); + + it('calls backlog.postVersions with correct params when using projectId', async () => { + const params = { + projectId: 100, + name: 'Version 2.0.0', + description: 'Major release', + startDate: '2023-04-01T00:00:00Z', + releaseDueDate: '2023-06-30T00:00:00Z', + }; + + await tool.handler(params); + + expect(mockBacklog.postVersions).toHaveBeenCalledWith(100, { + name: 'Version 2.0.0', + description: 'Major release', + startDate: '2023-04-01T00:00:00Z', + releaseDueDate: '2023-06-30T00:00:00Z', + }); + }); + + it('calls backlog.postVersions with minimal required params', async () => { + const params = { + projectKey: 'TEST', + name: 'Quick Version', + }; + + await tool.handler(params); + + expect(mockBacklog.postVersions).toHaveBeenCalledWith('TEST', { + name: 'Quick Version', + }); + }); + + it('throws an error if neither projectId nor projectKey is provided', async () => { + const params = { + // projectId and projectKey are missing + name: 'Version without project', + description: 'This should fail', + }; + + await expect(tool.handler(params as any)).rejects.toThrow(Error); + }); +}); diff --git a/src/tools/addVersionMilestone.ts b/src/tools/addVersionMilestone.ts new file mode 100644 index 0000000..3db5e86 --- /dev/null +++ b/src/tools/addVersionMilestone.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, ToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { VersionSchema } from '../types/zod/backlogOutputDefinition.js'; +import { resolveIdOrKey } from '../utils/resolveIdOrKey.js'; + +const addVersionMilestoneSchema = buildToolSchema((t) => ({ + projectId: z + .number() + .optional() + .describe(t('TOOL_ADD_VERSION_MILESTONE_PROJECT_ID', 'Project ID')), + projectKey: z + .string() + .optional() + .describe(t('TOOL_ADD_VERSION_MILESTONE_PROJECT_KEY', 'Project key')), + name: z + .string() + .describe(t('TOOL_ADD_VERSION_MILESTONE_NAME', 'Version name')), + description: z + .string() + .optional() + .describe( + t('TOOL_ADD_VERSION_MILESTONE_DESCRIPTION', 'Version description') + ), + startDate: z + .string() + .optional() + .describe( + t('TOOL_ADD_VERSION_MILESTONE_START_DATE', 'Start date of the version') + ), + releaseDueDate: z + .string() + .optional() + .describe( + t( + 'TOOL_ADD_VERSION_MILESTONE_RELEASE_DUE_DATE', + 'Release due date of the version' + ) + ), +})); + +export const addVersionMilestoneTool = ( + backlog: Backlog, + { t }: TranslationHelper +): ToolDefinition< + ReturnType, + (typeof VersionSchema)['shape'] +> => { + return { + name: 'add_version_milestone', + description: t( + 'TOOL_ADD_VERSION_MILESTONE_DESCRIPTION', + 'Creates a new version milestone' + ), + schema: z.object(addVersionMilestoneSchema(t)), + outputSchema: VersionSchema, + importantFields: [ + 'id', + 'name', + 'description', + 'startDate', + 'releaseDueDate', + ], + handler: async ({ projectId, projectKey, ...params }) => { + const result = resolveIdOrKey( + 'project', + { id: projectId, key: projectKey }, + t + ); + if (!result.ok) { + throw result.error; + } + return backlog.postVersions(result.value, params); + }, + }; +}; diff --git a/src/tools/deleteVersion.test.ts b/src/tools/deleteVersion.test.ts new file mode 100644 index 0000000..f449563 --- /dev/null +++ b/src/tools/deleteVersion.test.ts @@ -0,0 +1,56 @@ +import { deleteVersionTool } from './deleteVersion.js'; +import { jest, describe, it, expect } from '@jest/globals'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; + +describe('deleteVersionTool', () => { + const mockBacklog: Partial = { + deleteVersions: jest.fn<() => Promise>().mockResolvedValue({ + id: 1, + projectId: 100, + name: 'Test Version', + description: '', + startDate: null, + releaseDueDate: null, + archived: false, + displayOrder: 0, + }), + }; + + const mockTranslationHelper = createTranslationHelper(); + const tool = deleteVersionTool(mockBacklog as Backlog, mockTranslationHelper); + + it('returns deleted version information', async () => { + const result = await tool.handler({ + projectKey: 'TEST', + id: 1, + }); + + expect(result).toHaveProperty('id', 1); + expect(result).toHaveProperty('name', 'Test Version'); + }); + + it('calls backlog.deleteVersions with correct params when using project key', async () => { + await tool.handler({ + projectKey: 'TEST', + id: 1, + }); + + expect(mockBacklog.deleteVersions).toHaveBeenCalledWith('TEST', 1); + }); + + it('calls backlog.deleteVersions with correct params when using project ID', async () => { + await tool.handler({ + projectId: 100, + id: 1, + }); + + expect(mockBacklog.deleteVersions).toHaveBeenCalledWith(100, 1); + }); + + it('throws an error if neither projectId nor projectKey is provided', async () => { + const params = { id: 1 }; // No identifier provided + + await expect(tool.handler(params)).rejects.toThrowError(Error); + }); +}); diff --git a/src/tools/deleteVersion.ts b/src/tools/deleteVersion.ts new file mode 100644 index 0000000..40e8b2f --- /dev/null +++ b/src/tools/deleteVersion.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, ToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { VersionSchema } from '../types/zod/backlogOutputDefinition.js'; +import { resolveIdOrKey } from '../utils/resolveIdOrKey.js'; + +const deleteVersionSchema = buildToolSchema((t) => ({ + projectId: z + .number() + .optional() + .describe( + t( + 'TOOL_DELETE_VERSION_PROJECT_ID', + 'The numeric ID of the project (e.g., 12345)' + ) + ), + projectKey: z + .string() + .optional() + .describe( + t( + 'TOOL_DELETE_VERSION_PROJECT_KEY', + "The key of the project (e.g., 'PROJECT')" + ) + ), + id: z + .number() + .describe( + t( + 'TOOL_DELETE_VERSION_ID', + 'The numeric ID of the version to delete (e.g., 67890)' + ) + ), +})); + +export const deleteVersionTool = ( + backlog: Backlog, + { t }: TranslationHelper +): ToolDefinition< + ReturnType, + (typeof VersionSchema)['shape'] +> => { + return { + name: 'delete_version', + description: t( + 'TOOL_DELETE_VERSION_DESCRIPTION', + 'Deletes a version from a project' + ), + schema: z.object(deleteVersionSchema(t)), + outputSchema: VersionSchema, + handler: async ({ projectId, projectKey, id }) => { + const result = resolveIdOrKey( + 'project', + { id: projectId, key: projectKey }, + t + ); + if (!result.ok) { + throw result.error; + } + if (!id) { + throw new Error( + t('TOOL_DELETE_VERSION_MISSING_ID', 'Version ID is required') + ); + } + return backlog.deleteVersions(result.value, id); + }, + }; +}; diff --git a/src/tools/getVersionMilestoneList.test.ts b/src/tools/getVersionMilestoneList.test.ts new file mode 100644 index 0000000..298b966 --- /dev/null +++ b/src/tools/getVersionMilestoneList.test.ts @@ -0,0 +1,82 @@ +import { getVersionMilestoneListTool } from './getVersionMilestoneList.js'; +import { jest, describe, it, expect } from '@jest/globals'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; + +describe('getVersionMilestoneTool', () => { + const mockBacklog: Partial = { + getVersions: jest.fn<() => Promise>().mockResolvedValue([ + { + id: 1, + projectId: 1, + name: 'wait for release', + description: '', + startDate: null, + releaseDueDate: null, + archived: false, + displayOrder: 0, + }, + { + id: 2, + projectId: 1, + name: 'v1.0.0', + description: 'First release', + startDate: '2025-01-01', + releaseDueDate: '2025-03-01', + archived: false, + displayOrder: 1, + }, + { + id: 3, + projectId: 1, + name: 'v1.1.0', + description: 'Minor update', + startDate: '2025-03-01', + releaseDueDate: '2025-05-01', + archived: false, + displayOrder: 2, + }, + ]), + }; + + const mockTranslationHelper = createTranslationHelper(); + const tool = getVersionMilestoneListTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + it('returns versions list as formatted JSON text', async () => { + const result = await tool.handler({ projectId: 123 }); + + if (!Array.isArray(result)) { + throw new Error('Unexpected non array result'); + } + + expect(result).toHaveLength(3); + expect(result[0].name).toContain('wait for release'); + expect(result[1].name).toContain('v1.0.0'); + expect(result[2].name).toContain('v1.1.0'); + }); + + it('calls backlog.getVersions with correct params when using project key', async () => { + await tool.handler({ + projectKey: 'TEST_PROJECT', + }); + + expect(mockBacklog.getVersions).toHaveBeenCalledWith('TEST_PROJECT'); + }); + + it('calls backlog.getVersions with correct params when using project ID', async () => { + await tool.handler({ + projectId: 123, + }); + + expect(mockBacklog.getVersions).toHaveBeenCalledWith(123); + }); + + it('throws an error if neither projectId nor projectKey is provided', async () => { + const params = {}; // No identifier provided + + await expect(tool.handler(params as any)).rejects.toThrow(Error); + }); +}); diff --git a/src/tools/getVersionMilestoneList.ts b/src/tools/getVersionMilestoneList.ts new file mode 100644 index 0000000..70a102b --- /dev/null +++ b/src/tools/getVersionMilestoneList.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, ToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { VersionSchema } from '../types/zod/backlogOutputDefinition.js'; +import { resolveIdOrKey } from '../utils/resolveIdOrKey.js'; + +const getVersionMilestoneListSchema = buildToolSchema((t) => ({ + projectId: z + .number() + .optional() + .describe( + t( + 'TOOL_GET_VERSION_MILESTONE_PROJECT_ID', + 'The numeric ID of the project (e.g., 12345)' + ) + ), + projectKey: z + .string() + .optional() + .describe( + t( + 'TOOL_GET_VERSION_MILESTONE_PROJECT_KEY', + 'The key of the project (e.g., TEST_PROJECT)' + ) + ), +})); + +export const getVersionMilestoneListTool = ( + backlog: Backlog, + { t }: TranslationHelper +): ToolDefinition< + ReturnType, + (typeof VersionSchema)['shape'] +> => { + return { + name: 'get_version_milestone_list', + description: t( + 'TOOL_GET_VERSION_MILESTONE_LIST_DESCRIPTION', + 'Returns list of versions/milestones in the Backlog space' + ), + schema: z.object(getVersionMilestoneListSchema(t)), + outputSchema: VersionSchema, + importantFields: [ + 'id', + 'name', + 'description', + 'startDate', + 'releaseDueDate', + 'archived', + ], + handler: async ({ projectId, projectKey }) => { + const result = resolveIdOrKey( + 'project', + { id: projectId, key: projectKey }, + t + ); + if (!result.ok) { + throw result.error; + } + return backlog.getVersions(result.value); + }, + }; +}; diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 377fc5d..dd3b42e 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -45,6 +45,10 @@ import { updatePullRequestCommentTool } from './updatePullRequestComment.js'; import { getDocumentTool } from './getDocument.js'; import { getDocumentsTool } from './getDocuments.js'; import { getDocumentTreeTool } from './getDocumentTree.js'; +import { getVersionMilestoneListTool } from './getVersionMilestoneList.js'; +import { addVersionMilestoneTool } from './addVersionMilestone.js'; +import { updateVersionMilestoneTool } from './updateVersionMilestone.js'; +import { deleteVersionTool } from './deleteVersion.js'; export const allTools = ( backlog: Backlog, @@ -147,6 +151,17 @@ export const allTools = ( markNotificationAsReadTool(backlog, helper), ], }, + { + name: 'version_milestone', + description: 'Tools for managing version milestones in projects.', + enabled: false, + tools: [ + getVersionMilestoneListTool(backlog, helper), + addVersionMilestoneTool(backlog, helper), + updateVersionMilestoneTool(backlog, helper), + deleteVersionTool(backlog, helper), + ], + }, ], }; }; diff --git a/src/tools/updateVersionMilestone.test.ts b/src/tools/updateVersionMilestone.test.ts new file mode 100644 index 0000000..74475e1 --- /dev/null +++ b/src/tools/updateVersionMilestone.test.ts @@ -0,0 +1,115 @@ +import { updateVersionMilestoneTool } from './updateVersionMilestone.js'; +import { jest, describe, expect, it } from '@jest/globals'; +import type { Backlog } from 'backlog-js'; +import { createTranslationHelper } from '../createTranslationHelper.js'; + +describe('updateVersionMilestoneTool', () => { + const mockBacklog: Partial = { + patchVersions: jest.fn<() => Promise>().mockResolvedValue({ + id: 1, + projectId: 100, + name: 'Updated Version', + description: 'Updated version description', + startDate: '2023-01-01T00:00:00Z', + releaseDueDate: '2023-12-31T00:00:00Z', + archived: false, + }), + }; + + const mockTranslationHelper = createTranslationHelper(); + const tool = updateVersionMilestoneTool( + mockBacklog as Backlog, + mockTranslationHelper + ); + + it('returns updated version milestone', async () => { + const result = await tool.handler({ + projectKey: 'TEST', + projectId: 100, + id: 1, + name: 'Updated Version', + description: 'Updated version description', + startDate: '2023-01-01T00:00:00Z', + releaseDueDate: '2023-12-31T00:00:00Z', + archived: false, + }); + + if (Array.isArray(result)) { + throw new Error('Unexpected array result'); + } + + expect(result.name).toEqual('Updated Version'); + expect(result.description).toEqual('Updated version description'); + expect(result.startDate).toEqual('2023-01-01T00:00:00Z'); + expect(result.releaseDueDate).toEqual('2023-12-31T00:00:00Z'); + expect(result.archived).toBe(false); + }); + + it('calls backlog.patchVersions with correct params when using projectKey', async () => { + const params = { + projectKey: 'TEST', + id: 1, + name: 'Updated Version', + description: 'Updated version description', + startDate: '2023-01-01T00:00:00Z', + releaseDueDate: '2023-12-31T00:00:00Z', + archived: false, + }; + + await tool.handler(params); + + expect(mockBacklog.patchVersions).toHaveBeenCalledWith('TEST', 1, { + name: 'Updated Version', + description: 'Updated version description', + startDate: '2023-01-01T00:00:00Z', + releaseDueDate: '2023-12-31T00:00:00Z', + archived: false, + }); + }); + + it('calls backlog.pathVersions with correct params when using projectId', async () => { + const params = { + projectId: 100, + id: 1, + name: 'Updated Version', + description: 'Updated version description', + startDate: '2023-01-01T00:00:00Z', + releaseDueDate: '2023-12-31T00:00:00Z', + archived: false, + }; + + await tool.handler(params); + + expect(mockBacklog.patchVersions).toHaveBeenCalledWith(100, 1, { + name: 'Updated Version', + description: 'Updated version description', + startDate: '2023-01-01T00:00:00Z', + releaseDueDate: '2023-12-31T00:00:00Z', + archived: false, + }); + }); + + it('throws an error if neither projectId nor projectKey is provided', async () => { + const params = { + // projectId and projectKey are missing + id: 1, + name: 'Version without project', + description: 'This should fail', + }; + + await expect(tool.handler(params as any)).rejects.toThrow(Error); + }); + + it('throws an error if id is not provided', async () => { + const params = { + projectKey: 'TEST', + // id is missing + name: 'Version without ID', + description: 'This should fail', + }; + + await expect(tool.handler(params as any)).rejects.toThrow( + 'Version ID is required' + ); + }); +}); diff --git a/src/tools/updateVersionMilestone.ts b/src/tools/updateVersionMilestone.ts new file mode 100644 index 0000000..e4f6e6a --- /dev/null +++ b/src/tools/updateVersionMilestone.ts @@ -0,0 +1,101 @@ +import { z } from 'zod'; +import { Backlog } from 'backlog-js'; +import { buildToolSchema, ToolDefinition } from '../types/tool.js'; +import { TranslationHelper } from '../createTranslationHelper.js'; +import { VersionSchema } from '../types/zod/backlogOutputDefinition.js'; +import { resolveIdOrKey } from '../utils/resolveIdOrKey.js'; + +const updateVersionMilestoneSchema = buildToolSchema((t) => ({ + projectId: z + .number() + .optional() + .describe( + t( + 'TOOL_UPDATE_VERSION_MILESTONE_PROJECT_ID', + 'The numeric ID of the project (e.g., 12345)' + ) + ), + projectKey: z + .string() + .optional() + .describe( + t( + 'TOOL_UPDATE_VERSION_MILESTONE_PROJECT_KEY', + "The key of the project (e.g., 'PROJECT')" + ) + ), + id: z.number().describe(t('TOOL_UPDATE_VERSION_MILESTONE_ID', 'Version ID')), + name: z + .string() + .describe(t('TOOL_UPDATE_VERSION_MILESTONE_NAME', 'Version name')), + description: z + .string() + .optional() + .describe( + t('TOOL_UPDATE_VERSION_MILESTONE_DESCRIPTION', 'Version description') + ), + startDate: z + .string() + .optional() + .describe(t('TOOL_UPDATE_VERSION_MILESTONE_START_DATE', 'Start date')), + releaseDueDate: z + .string() + .optional() + .describe( + t('TOOL_UPDATE_VERSION_MILESTONE_RELEASE_DUE_DATE', 'Release due date') + ), + archived: z + .boolean() + .optional() + .describe( + t( + 'TOOL_UPDATE_VERSION_MILESTONE_ARCHIVED', + 'Archive status of the version' + ) + ), +})); + +export const updateVersionMilestoneTool = ( + backlog: Backlog, + { t }: TranslationHelper +): ToolDefinition< + ReturnType, + (typeof VersionSchema)['shape'] +> => { + return { + name: 'update_version_milestone', + description: t( + 'TOOL_UPDATE_VERSION_MILESTONE_DESCRIPTION', + 'Updates an existing version milestone' + ), + schema: z.object(updateVersionMilestoneSchema(t)), + outputSchema: VersionSchema, + importantFields: [ + 'id', + 'name', + 'description', + 'startDate', + 'releaseDueDate', + 'archived', + ], + handler: async ({ projectId, projectKey, id, ...params }) => { + const result = resolveIdOrKey( + 'project', + { id: projectId, key: projectKey }, + t + ); + if (!result.ok) { + throw result.error; + } + if (!id) { + throw new Error( + t( + 'TOOL_UPDATE_VERSION_MILESTONE_ID_REQUIRED', + 'Version ID is required' + ) + ); + } + return backlog.patchVersions(result.value, id, params); + }, + }; +};