Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
114 changes: 114 additions & 0 deletions workspace-server/src/__tests__/services/DriveService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ describe('DriveService', () => {
comments: {
list: jest.fn(),
},
replies: {
create: jest.fn(),
},
};

// Mock the google.drive constructor
Expand Down Expand Up @@ -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!' },
});
Comment thread
matheusrmorgado marked this conversation as resolved.
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' },
});
Comment thread
matheusrmorgado marked this conversation as resolved.
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');
});
});
});
2 changes: 2 additions & 0 deletions workspace-server/src/features/feature-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [
'drive.moveFile',
'drive.trashFile',
'drive.renameFile',
'drive.replyToComment',
'drive.resolveComment',
],
defaultEnabled: true,
},
Expand Down
35 changes: 35 additions & 0 deletions workspace-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand Down
78 changes: 78 additions & 0 deletions workspace-server/src/services/DriveService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
Comment thread
matheusrmorgado marked this conversation as resolved.

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' },
});
Comment thread
matheusrmorgado marked this conversation as resolved.

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,
Expand Down
Loading