From 14d9df104bc6a181d5be143ddd0f91a177c3fc7f Mon Sep 17 00:00:00 2001 From: Matheus Morgado Date: Fri, 12 Jun 2026 15:22:35 -0300 Subject: [PATCH 1/2] feat(drive): add drive.replyToComment and drive.resolveComment tools Implements the two write-side Drive comment tools proposed in #343, complementing the existing read-only drive.getComments: - drive.replyToComment(fileId, commentId, content): posts a reply via replies.create - drive.resolveComment(fileId, commentId, content?): marks the comment resolved via replies.create with action: 'resolve', optionally with a closing message Both live in the drive.write feature group and reuse its existing 'drive' OAuth scope, so the consent surface is unchanged. Includes Jest coverage (request shapes, URL-to-ID extraction, error paths) and docs/index.md entries. --- docs/index.md | 3 + .../__tests__/services/DriveService.test.ts | 114 ++++++++++++++++++ .../src/features/feature-config.ts | 2 + workspace-server/src/index.ts | 35 ++++++ workspace-server/src/services/DriveService.ts | 73 +++++++++++ 5 files changed, 227 insertions(+) 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..245294d 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', content: '' }, + }); + 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..8314e62 100644 --- a/workspace-server/src/services/DriveService.ts +++ b/workspace-server/src/services/DriveService.ts @@ -452,6 +452,79 @@ 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); + 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: { action: 'resolve', content: content ?? '' }, + }); + + 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, From 98ac5d8f2d71e229380e87f93b6b1d43c76c99ce Mon Sep 17 00:00:00 2001 From: Matheus Morgado Date: Fri, 12 Jun 2026 15:58:04 -0300 Subject: [PATCH 2/2] fix(review): omit empty resolve content; document why supportsAllDrives does not apply Addresses gemini-code-assist review: resolveComment now omits the content field entirely when no closing message is provided instead of sending an empty string. The supportsAllDrives suggestion does not apply here: replies.create takes no such parameter (it belongs to the files/drives endpoints, confirmed by the Drive v3 reference and the googleapis typings, which reject it at compile time); a code comment records that. --- .../src/__tests__/services/DriveService.test.ts | 2 +- workspace-server/src/services/DriveService.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/workspace-server/src/__tests__/services/DriveService.test.ts b/workspace-server/src/__tests__/services/DriveService.test.ts index 245294d..6602cca 100644 --- a/workspace-server/src/__tests__/services/DriveService.test.ts +++ b/workspace-server/src/__tests__/services/DriveService.test.ts @@ -1517,7 +1517,7 @@ describe('DriveService', () => { commentId: 'comment1', fields: 'id, action, content, author(displayName, emailAddress), createdTime', - requestBody: { action: 'resolve', content: '' }, + requestBody: { action: 'resolve' }, }); const reply = JSON.parse(result.content[0].text); expect(reply.action).toBe('resolve'); diff --git a/workspace-server/src/services/DriveService.ts b/workspace-server/src/services/DriveService.ts index 8314e62..e3b14ee 100644 --- a/workspace-server/src/services/DriveService.ts +++ b/workspace-server/src/services/DriveService.ts @@ -467,6 +467,9 @@ export class DriveService { 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, @@ -508,7 +511,9 @@ export class DriveService { commentId, fields: 'id, action, content, author(displayName, emailAddress), createdTime', - requestBody: { action: 'resolve', content: content ?? '' }, + requestBody: content + ? { action: 'resolve', content } + : { action: 'resolve' }, }); logToFile(`[DriveService] Resolved comment ${commentId}`);