diff --git a/docs/index.md b/docs/index.md index 7d7b622..b660134 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,6 +49,9 @@ The extension provides the following tools: - `drive.downloadFile`: Downloads a file from Google Drive to a local path. - `drive.trashFile`: Moves a file or folder to the trash in Google Drive. - `drive.renameFile`: Renames a file or folder in Google Drive. +- `drive.replyToComment`: Posts a reply to an existing comment on a Drive file. +- `drive.resolveComment`: Marks a comment on a Drive file as resolved, + optionally with a closing reply. ### Google Calendar diff --git a/workspace-server/src/__tests__/services/DriveService.test.ts b/workspace-server/src/__tests__/services/DriveService.test.ts index 6703e68..6602cca 100644 --- a/workspace-server/src/__tests__/services/DriveService.test.ts +++ b/workspace-server/src/__tests__/services/DriveService.test.ts @@ -76,6 +76,9 @@ describe('DriveService', () => { comments: { list: jest.fn(), }, + replies: { + create: jest.fn(), + }, }; // Mock the google.drive constructor @@ -1439,4 +1442,115 @@ describe('DriveService', () => { ); }); }); + + describe('replyToComment', () => { + it('should post a reply via replies.create', async () => { + const mockReply = { + id: 'reply1', + content: 'Thanks, fixed!', + author: { displayName: 'Test User', emailAddress: 'test@example.com' }, + createdTime: '2025-01-01T00:00:00Z', + }; + mockDriveAPI.replies.create.mockResolvedValue({ data: mockReply }); + + const result = await driveService.replyToComment({ + fileId: 'test-doc-id', + commentId: 'comment1', + content: 'Thanks, fixed!', + }); + + expect(mockDriveAPI.replies.create).toHaveBeenCalledWith({ + fileId: 'test-doc-id', + commentId: 'comment1', + fields: 'id, content, author(displayName, emailAddress), createdTime', + requestBody: { content: 'Thanks, fixed!' }, + }); + const reply = JSON.parse(result.content[0].text); + expect(reply).toEqual(mockReply); + }); + + it('should extract the file ID from a full URL', async () => { + mockDriveAPI.replies.create.mockResolvedValue({ data: { id: 'r' } }); + + await driveService.replyToComment({ + fileId: 'https://docs.google.com/document/d/url-doc-id/edit', + commentId: 'comment1', + content: 'reply', + }); + + expect(mockDriveAPI.replies.create).toHaveBeenCalledWith( + expect.objectContaining({ fileId: 'url-doc-id' }), + ); + }); + + it('should handle errors gracefully', async () => { + mockDriveAPI.replies.create.mockRejectedValue(new Error('Reply Error')); + + const result = await driveService.replyToComment({ + fileId: 'test-doc-id', + commentId: 'comment1', + content: 'reply', + }); + + expect(result.content[0].text).toContain('Reply Error'); + }); + }); + + describe('resolveComment', () => { + it('should resolve a comment via replies.create with action resolve', async () => { + const mockReply = { + id: 'reply2', + action: 'resolve', + content: '', + author: { displayName: 'Test User', emailAddress: 'test@example.com' }, + createdTime: '2025-01-01T00:00:00Z', + }; + mockDriveAPI.replies.create.mockResolvedValue({ data: mockReply }); + + const result = await driveService.resolveComment({ + fileId: 'test-doc-id', + commentId: 'comment1', + }); + + expect(mockDriveAPI.replies.create).toHaveBeenCalledWith({ + fileId: 'test-doc-id', + commentId: 'comment1', + fields: + 'id, action, content, author(displayName, emailAddress), createdTime', + requestBody: { action: 'resolve' }, + }); + const reply = JSON.parse(result.content[0].text); + expect(reply.action).toBe('resolve'); + }); + + it('should include an optional closing message', async () => { + mockDriveAPI.replies.create.mockResolvedValue({ data: { id: 'r' } }); + + await driveService.resolveComment({ + fileId: 'test-doc-id', + commentId: 'comment1', + content: 'Done in the latest revision.', + }); + + expect(mockDriveAPI.replies.create).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: { + action: 'resolve', + content: 'Done in the latest revision.', + }, + }), + ); + }); + + it('should handle errors gracefully', async () => { + mockDriveAPI.replies.create.mockRejectedValue(new Error('Resolve Error')); + + const result = await driveService.resolveComment({ + fileId: 'test-doc-id', + commentId: 'comment1', + }); + + expect(result.content[0].text).toContain('Resolve Error'); + }); + }); }); diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index dc9eef5..f709ec6 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -99,6 +99,8 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ 'drive.moveFile', 'drive.trashFile', 'drive.renameFile', + 'drive.replyToComment', + 'drive.resolveComment', ], defaultEnabled: true, }, diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index e7a8077..39ef38c 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -1031,6 +1031,41 @@ async function main() { driveService.trashFile, ); + registerTool( + 'drive.replyToComment', + { + description: + 'Posts a reply to an existing comment on a Google Drive file (Docs, Sheets, Slides, etc.). Use drive.getComments to find comment IDs.', + inputSchema: { + fileId: z + .string() + .describe('The ID or URL of the file the comment belongs to.'), + commentId: z.string().describe('The ID of the comment to reply to.'), + content: z.string().min(1).describe('The reply text.'), + }, + }, + driveService.replyToComment, + ); + + registerTool( + 'drive.resolveComment', + { + description: + 'Marks a comment on a Google Drive file as resolved, optionally with a closing reply. Use drive.getComments to find comment IDs.', + inputSchema: { + fileId: z + .string() + .describe('The ID or URL of the file the comment belongs to.'), + commentId: z.string().describe('The ID of the comment to resolve.'), + content: z + .string() + .optional() + .describe('Optional closing message posted with the resolution.'), + }, + }, + driveService.resolveComment, + ); + registerTool( 'drive.renameFile', { diff --git a/workspace-server/src/services/DriveService.ts b/workspace-server/src/services/DriveService.ts index 8561b61..e3b14ee 100644 --- a/workspace-server/src/services/DriveService.ts +++ b/workspace-server/src/services/DriveService.ts @@ -452,6 +452,84 @@ export class DriveService { } }; + public replyToComment = async ({ + fileId, + commentId, + content, + }: { + fileId: string; + commentId: string; + content: string; + }) => { + logToFile( + `[DriveService] Replying to comment ${commentId} on file: ${fileId}`, + ); + try { + const drive = await this.getDriveClient(); + const id = extractDocumentId(fileId); + // Note: replies.create does not take supportsAllDrives (that parameter + // belongs to the files/drives endpoints); comment threads on Shared + // Drive files work through this endpoint as-is. + const res = await drive.replies.create({ + fileId: id, + commentId, + fields: 'id, content, author(displayName, emailAddress), createdTime', + requestBody: { content }, + }); + + logToFile(`[DriveService] Created reply ${res.data.id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(res.data, null, 2), + }, + ], + }; + } catch (error) { + return this.handleError('drive.replyToComment', error); + } + }; + + public resolveComment = async ({ + fileId, + commentId, + content, + }: { + fileId: string; + commentId: string; + content?: string; + }) => { + logToFile( + `[DriveService] Resolving comment ${commentId} on file: ${fileId}`, + ); + try { + const drive = await this.getDriveClient(); + const id = extractDocumentId(fileId); + const res = await drive.replies.create({ + fileId: id, + commentId, + fields: + 'id, action, content, author(displayName, emailAddress), createdTime', + requestBody: content + ? { action: 'resolve', content } + : { action: 'resolve' }, + }); + + logToFile(`[DriveService] Resolved comment ${commentId}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(res.data, null, 2), + }, + ], + }; + } catch (error) { + return this.handleError('drive.resolveComment', error); + } + }; + public moveFile = async ({ fileId, folderId,