diff --git a/extensions/README-complete.md b/extensions/README-complete.md new file mode 100644 index 0000000..78a9221 --- /dev/null +++ b/extensions/README-complete.md @@ -0,0 +1,141 @@ +# GitLab MCP Extensions + +This directory contains extensions for the GitLab MCP server that add critical missing functionality from the original implementation. + +## Overview + +The GitLab MCP Extensions add approximately 30+ new tools for advanced project management capabilities that are not available in the original GitLab MCP server. + +## Extension Categories + +### 1. Issue Boards Management +- `list_boards` - List issue boards in a project +- `get_board` - Get details of a specific issue board +- `create_board` - Create a new issue board +- `update_board` - Update an existing issue board +- `delete_board` - Delete an issue board +- `list_board_lists` - List all lists in an issue board +- `create_board_list` - Create a new list in an issue board +- `update_board_list` - Update a list in an issue board +- `delete_board_list` - Delete a list from an issue board +- `get_board_list_issues` - Get issues in a specific board list + +### 2. Time Tracking Management +- `add_time_spent` - Add time spent on an issue +- `get_time_tracking` - Get time tracking summary for an issue +- `update_time_estimate` - Update time estimate for an issue +- `list_time_entries` - List time tracking entries for an issue +- `delete_time_entry` - Delete a time tracking entry + +### 3. Releases Management +- `list_releases` - List releases in a project +- `get_release` - Get details of a specific release +- `create_release` - Create a new release +- `update_release` - Update an existing release +- `delete_release` - Delete a release +- `create_release_asset` - Create a release asset link +- `update_release_asset` - Update a release asset link +- `delete_release_asset` - Delete a release asset link + +### 4. Bulk Operations +- `bulk_update_issues` - Update multiple issues simultaneously +- `bulk_close_issues` - Close multiple issues with optional comment +- `bulk_assign_issues` - Assign or unassign multiple issues to users +- `bulk_label_issues` - Add, remove, or replace labels on multiple issues +- `bulk_update_merge_requests` - Update multiple merge requests simultaneously +- `bulk_export_data` - Export project data in bulk (issues, MRs, milestones) + +### 5. Analytics and Reporting +- `get_project_analytics` - Get comprehensive project analytics and metrics +- `get_issue_analytics` - Get issue-specific analytics including velocity and cycle time +- `get_team_performance` - Get team and individual performance metrics +- `get_milestone_analytics` - Get milestone progress and burndown analytics +- `generate_custom_report` - Generate custom reports with specified filters and grouping + +### 6. Webhooks Management +- `list_webhooks` - List project webhooks +- `get_webhook` - Get details of a specific webhook +- `create_webhook` - Create a new project webhook +- `update_webhook` - Update an existing webhook +- `delete_webhook` - Delete a project webhook +- `test_webhook` - Test a webhook by triggering a test event + +## File Structure + +``` +extensions/ +├── README.md # Main documentation (see README-complete.md for full version) +├── README-complete.md # Complete documentation file +├── index.ts # Main export file +├── schemas.ts # Zod schema definitions for all extensions +├── tools.ts # Tool definitions for MCP server +├── handlers.ts # Handler implementations (to be implemented) +├── types.ts # TypeScript type definitions +``` + +## Integration + +The extensions are designed to integrate seamlessly with the existing GitLab MCP server without disrupting current functionality. They follow the same patterns and conventions as the original implementation. + +## Development Status + +- ✅ **Setup Complete**: Extension development environment and structure +- ⏳ **In Progress**: Handler implementations (subsequent tasks) +- ⏳ **Pending**: Integration with main server +- ⏳ **Pending**: Testing and validation + +## Requirements Covered + +This extension implementation addresses all requirements from the GitLab MCP Extensions specification: + +- **Requirement 1**: Issue Boards Management ✅ +- **Requirement 2**: Issue Board Lists Management ✅ +- **Requirement 3**: Time Tracking Management ✅ +- **Requirement 4**: Releases Management ✅ +- **Requirement 5**: Bulk Operations ✅ +- **Requirement 6**: Advanced Analytics and Reporting ✅ +- **Requirement 7**: Workflow Automation (Future) ⏳ +- **Requirement 8**: Webhooks and Integration Management ✅ + +## Architecture + +The extensions follow a modular architecture: + +1. **Schemas** (`schemas.ts`) - Zod validation schemas for all API inputs/outputs +2. **Tools** (`tools.ts`) - MCP tool definitions with JSON schemas +3. **Handlers** (`handlers.ts`) - Implementation logic for each tool +4. **Types** (`types.ts`) - TypeScript type definitions +5. **Index** (`index.ts`) - Main export aggregator + +## Usage + +The extensions will be automatically integrated with the main GitLab MCP server once the handlers are implemented. Each tool can be called through the MCP protocol with the appropriate parameters as defined in the schemas. + +## Next Steps + +1. Implement handler functions for each tool category +2. Integrate extensions with main GitLab MCP server +3. Add comprehensive error handling +4. Implement testing suite +5. Add workflow automation capabilities + +## Contributing + +When implementing handlers, follow these guidelines: + +1. Use the existing `ExtensionHandlerContext` interface +2. Follow the same error handling patterns as the main server +3. Validate inputs using the provided Zod schemas +4. Return responses in the standard MCP format +5. Add appropriate logging for debugging + +## API Coverage + +The extensions provide comprehensive coverage of GitLab's REST API endpoints that were missing from the original implementation: + +- **Boards API**: `/projects/:id/boards/*` +- **Time Tracking API**: `/projects/:id/issues/:issue_iid/time_stats` +- **Releases API**: `/projects/:id/releases/*` +- **Bulk Operations**: Multiple API endpoints with batch processing +- **Analytics**: Custom analytics aggregation from multiple endpoints +- **Webhooks API**: `/projects/:id/hooks/*` \ No newline at end of file diff --git a/extensions/README.md b/extensions/README.md new file mode 100644 index 0000000..663bbfa --- /dev/null +++ b/extensions/README.md @@ -0,0 +1,107 @@ +# GitLab MCP Extensions + +This directory contains extensions for the GitLab MCP server that add critical missing functionality from the original implementation. + +> **📖 For complete documentation, see [README-complete.md](./README-complete.md)** + +## Overview + +The GitLab MCP Extensions add approximately 30+ new tools for advanced project management capabilities that are not available in the original GitLab MCP server. + +## Extension Categories + +### 1. Issue Boards Management +- `list_boards` - List issue boards in a project +- `get_board` - Get details of a specific issue board + +- `update_bo +oard +- `ard +- `create_b +- `update_board_list` - Update a list in an issue board +- `delete_board_list` - Delete a list from an issue board +- `get_board_list_issues` - Get issues in a specific board list + +### 2. Time Tracking Management +- `add_time_spent` - Add time ssue +- `n issue +ue +- `list_time_entries` +g entry + +### 3. Releases Management +- `list_releases` - List releases in a pro +- `get_release` - Get details of a spe +- `create_release` - Create aase +- `update_release` - Updatse + +- `create_release_ank +- `update_release_asset` - Update ank + link + +ns +- `bulk_update_issues` - Update multiple issues simultaneously +nt +- `bulk_assign_issue +e issues +- `bulk_update_merge_requests` - ly +- `bulk_export_data` - Export project) + +### 5. Analytics and Reporting +- `get_project_analytics` - Get comprehensive proj +time +- `get_team_perfocs + +- `generate_custom_report` - Generate custom reports with specified + +### 6. t +- `list_webhooks` - List project webhooks +- `get_webhook` - Get details of a specific wook +- `create_webhook` - Create a new project webhook +- `ook +ebhook +- `test_webhoo + +## File Structure + +``` +extensions/ +├── README.md # This file +├── index.ts # Main export file +├── schemas.ts # Zod schema definitions ns +├── tools.ts # Tool definitions for MCP se +├── handlers.ts # Handler implementated) +├── types.ts # TypeScript type de +``` + +## Integration + +The extensions are designed to integrate seamless + +## Development Status + + +- ⏳ **In Progress**: Hasks) +- ⏳ **Pending**: Integration with main server +- ⏳ **Pending**: Testing and validation + +## Requirements Covered + +This extension implementation addresses all req: + +- **Requirement 1**: Issue Boards Management ✅ + +- **Requirement 3**: Time Tra ✅ +- **Requirement 4**: Releases Management ✅ +- **Requirement 5**: Bulk Operations ✅ +- **Requirement 6**: Advanced Analytics and Reporting ✅ +- **Requirement 7**: Workflow Automation (Future) ⏳ +- **Requirement 8**: Webhooks and Integration Manageent ✅ + +s + +1. Implement handler functions for each tool category +2. Integrate extensions with main GitLab MCP +3. Add comprehensive error handling +4. Implement testing suite +5. Add workflow automation capabilities \ No newline at end of file diff --git a/extensions/handlers.ts b/extensions/handlers.ts new file mode 100644 index 0000000..53a02d4 --- /dev/null +++ b/extensions/handlers.ts @@ -0,0 +1,137 @@ +// Extension handlers for GitLab MCP Extensions +// This file imports and re-exports all handlers from the organized structure + +// Import all handlers from the new organized structure +import * as handlers from './handlers/index.js'; + +// Re-export the context interface for backward compatibility +export interface ExtensionHandlerContext { + GITLAB_API_URL: string; + DEFAULT_FETCH_CONFIG: any; + getEffectiveProjectId: (projectId: string) => string; + handleGitLabError: (response: any) => Promise; + logger: any; + fetch: any; +} + +// Re-export all handlers for backward compatibility +export const { + // Board handlers + handleListBoards, + handleGetBoard, + handleCreateBoard, + handleUpdateBoard, + handleDeleteBoard, + handleListBoardLists, + handleCreateBoardList, + handleUpdateBoardList, + handleDeleteBoardList, + handleGetBoardListIssues, + + // Time tracking handlers + handleAddTimeSpent, + handleGetTimeTracking, + handleListTimeEntries, + handleDeleteTimeEntry, + handleAddTimeEstimate, + handleAddToTimeEstimate, + handleUpdateTimeEstimate, + handleResetTimeEstimate, + handleGetTimeEstimate, + handleCompareTimeEstimate, + handleBulkEstimateIssues, + + // Release handlers (placeholders for now) + handleListReleases, + handleGetRelease, + handleCreateRelease, + handleUpdateRelease, + handleDeleteRelease, + handleCreateReleaseAsset, + handleUpdateReleaseAsset, + handleDeleteReleaseAsset, + + // Bulk operation handlers (placeholders for now) + handleBulkUpdateIssues, + handleBulkCloseIssues, + handleBulkAssignIssues, + handleBulkLabelIssues, + handleBulkUpdateMergeRequests, + handleBulkExportData, + + // Analytics handlers (placeholders for now) + handleGetProjectAnalytics, + handleGetIssueAnalytics, + handleGetTeamPerformance, + handleGetMilestoneAnalytics, + handleGenerateCustomReport, + + // Webhook handlers (placeholders for now) + handleListWebhooks, + handleGetWebhook, + handleCreateWebhook, + handleUpdateWebhook, + handleDeleteWebhook, + handleTestWebhook +} = handlers; + +// Export the handler mapping for tools.ts +export const extensionHandlers = { + // Issue Boards + list_boards: handleListBoards, + get_board: handleGetBoard, + create_board: handleCreateBoard, + update_board: handleUpdateBoard, + delete_board: handleDeleteBoard, + list_board_lists: handleListBoardLists, + create_board_list: handleCreateBoardList, + update_board_list: handleUpdateBoardList, + delete_board_list: handleDeleteBoardList, + get_board_list_issues: handleGetBoardListIssues, + + // Time Tracking + add_time_spent: handleAddTimeSpent, + get_time_tracking: handleGetTimeTracking, + add_time_estimate: handleAddTimeEstimate, + add_to_time_estimate: handleAddToTimeEstimate, + update_time_estimate: handleUpdateTimeEstimate, + reset_time_estimate: handleResetTimeEstimate, + get_time_estimate: handleGetTimeEstimate, + compare_time_estimate: handleCompareTimeEstimate, + bulk_estimate_issues: handleBulkEstimateIssues, + list_time_entries: handleListTimeEntries, + delete_time_entry: handleDeleteTimeEntry, + + // Releases + list_releases: handleListReleases, + get_release: handleGetRelease, + create_release: handleCreateRelease, + update_release: handleUpdateRelease, + delete_release: handleDeleteRelease, + create_release_asset: handleCreateReleaseAsset, + update_release_asset: handleUpdateReleaseAsset, + delete_release_asset: handleDeleteReleaseAsset, + + // Bulk Operations + bulk_update_issues: handleBulkUpdateIssues, + bulk_close_issues: handleBulkCloseIssues, + bulk_assign_issues: handleBulkAssignIssues, + bulk_label_issues: handleBulkLabelIssues, + bulk_update_merge_requests: handleBulkUpdateMergeRequests, + bulk_export_data: handleBulkExportData, + + // Analytics + get_project_analytics: handleGetProjectAnalytics, + get_issue_analytics: handleGetIssueAnalytics, + get_team_performance: handleGetTeamPerformance, + get_milestone_analytics: handleGetMilestoneAnalytics, + generate_custom_report: handleGenerateCustomReport, + + // Webhooks + list_webhooks: handleListWebhooks, + get_webhook: handleGetWebhook, + create_webhook: handleCreateWebhook, + update_webhook: handleUpdateWebhook, + delete_webhook: handleDeleteWebhook, + test_webhook: handleTestWebhook, +}; \ No newline at end of file diff --git a/extensions/handlers/analytics/analytics-handlers.ts b/extensions/handlers/analytics/analytics-handlers.ts new file mode 100644 index 0000000..c36cc12 --- /dev/null +++ b/extensions/handlers/analytics/analytics-handlers.ts @@ -0,0 +1,24 @@ +// Analytics operation handlers +// This file will be populated in task 6 + +import { ExtensionHandlerContext } from '../types.js'; + +export async function handleGetProjectAnalytics(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 6"); +} + +export async function handleGetIssueAnalytics(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 6"); +} + +export async function handleGetTeamPerformance(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 6"); +} + +export async function handleGetMilestoneAnalytics(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 6"); +} + +export async function handleGenerateCustomReport(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 6"); +} \ No newline at end of file diff --git a/extensions/handlers/analytics/index.ts b/extensions/handlers/analytics/index.ts new file mode 100644 index 0000000..527d157 --- /dev/null +++ b/extensions/handlers/analytics/index.ts @@ -0,0 +1,3 @@ +// Export all analytics handlers + +export * from './analytics-handlers.js'; \ No newline at end of file diff --git a/extensions/handlers/boards/board-handlers.ts b/extensions/handlers/boards/board-handlers.ts new file mode 100644 index 0000000..52b2a45 --- /dev/null +++ b/extensions/handlers/boards/board-handlers.ts @@ -0,0 +1,115 @@ +// Board CRUD operation handlers + +import { ExtensionHandlerContext } from '../types.js'; +import { formatSuccessResponse, formatArrayParam, encodeProjectId, createRequestBody } from '../utils.js'; + +export async function handleListBoards(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, page = 1, per_page = 20 } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Listing boards for project ${effectiveProjectId}`); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/boards?page=${page}&per_page=${per_page}`, + context.DEFAULT_FETCH_CONFIG + ); + + await context.handleGitLabError(response); + const boards = await response.json(); + + return formatSuccessResponse(boards); +} + +export async function handleGetBoard(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, board_id } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Getting board ${board_id} for project ${effectiveProjectId}`); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/boards/${board_id}`, + context.DEFAULT_FETCH_CONFIG + ); + + await context.handleGitLabError(response); + const board = await response.json(); + + return formatSuccessResponse(board); +} + +export async function handleCreateBoard(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, name, assignee_id, milestone_id, labels, weight } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Creating board "${name}" for project ${effectiveProjectId}`); + + const body = createRequestBody({ + name, + assignee_id, + milestone_id, + labels: formatArrayParam(labels), + weight + }); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/boards`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "POST", + body, + } + ); + + await context.handleGitLabError(response); + const board = await response.json(); + + return formatSuccessResponse(board); +} + +export async function handleUpdateBoard(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, board_id, name, assignee_id, milestone_id, labels, weight } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Updating board ${board_id} for project ${effectiveProjectId}`); + + const body = createRequestBody({ + name, + assignee_id, + milestone_id, + labels: formatArrayParam(labels), + weight + }); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/boards/${board_id}`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "PUT", + body, + } + ); + + await context.handleGitLabError(response); + const board = await response.json(); + + return formatSuccessResponse(board); +} + +export async function handleDeleteBoard(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, board_id } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Deleting board ${board_id} for project ${effectiveProjectId}`); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/boards/${board_id}`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "DELETE", + } + ); + + await context.handleGitLabError(response); + + return formatSuccessResponse(`Board ${board_id} deleted successfully`); +} \ No newline at end of file diff --git a/extensions/handlers/boards/board-list-handlers.ts b/extensions/handlers/boards/board-list-handlers.ts new file mode 100644 index 0000000..349e4ec --- /dev/null +++ b/extensions/handlers/boards/board-list-handlers.ts @@ -0,0 +1,110 @@ +// Board list operation handlers + +import { ExtensionHandlerContext } from '../types.js'; +import { formatSuccessResponse, encodeProjectId, createRequestBody } from '../utils.js'; + +export async function handleListBoardLists(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, board_id } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Listing board lists for board ${board_id} in project ${effectiveProjectId}`); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/boards/${board_id}/lists`, + context.DEFAULT_FETCH_CONFIG + ); + + await context.handleGitLabError(response); + const lists = await response.json(); + + return formatSuccessResponse(lists); +} + +export async function handleCreateBoardList(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, board_id, label_id, assignee_id, milestone_id } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Creating board list for board ${board_id} in project ${effectiveProjectId}`); + + const body = createRequestBody({ + label_id, + assignee_id, + milestone_id + }); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/boards/${board_id}/lists`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "POST", + body, + } + ); + + await context.handleGitLabError(response); + const list = await response.json(); + + return formatSuccessResponse(list); +} + +export async function handleUpdateBoardList(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, board_id, list_id, position, collapsed } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Updating board list ${list_id} for board ${board_id} in project ${effectiveProjectId}`); + + const body = createRequestBody({ + position, + collapsed + }); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/boards/${board_id}/lists/${list_id}`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "PUT", + body, + } + ); + + await context.handleGitLabError(response); + const list = await response.json(); + + return formatSuccessResponse(list); +} + +export async function handleDeleteBoardList(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, board_id, list_id } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Deleting board list ${list_id} for board ${board_id} in project ${effectiveProjectId}`); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/boards/${board_id}/lists/${list_id}`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "DELETE", + } + ); + + await context.handleGitLabError(response); + + return formatSuccessResponse(`Board list ${list_id} deleted successfully`); +} + +export async function handleGetBoardListIssues(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, board_id, list_id, page = 1, per_page = 20 } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Getting issues for board list ${list_id} in board ${board_id} for project ${effectiveProjectId}`); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/boards/${board_id}/lists/${list_id}/issues?page=${page}&per_page=${per_page}`, + context.DEFAULT_FETCH_CONFIG + ); + + await context.handleGitLabError(response); + const issues = await response.json(); + + return formatSuccessResponse(issues); +} \ No newline at end of file diff --git a/extensions/handlers/boards/index.ts b/extensions/handlers/boards/index.ts new file mode 100644 index 0000000..7e1327e --- /dev/null +++ b/extensions/handlers/boards/index.ts @@ -0,0 +1,4 @@ +// Export all board-related handlers + +export * from './board-handlers.js'; +export * from './board-list-handlers.js'; \ No newline at end of file diff --git a/extensions/handlers/bulk-operations/bulk-handlers.ts b/extensions/handlers/bulk-operations/bulk-handlers.ts new file mode 100644 index 0000000..658617a --- /dev/null +++ b/extensions/handlers/bulk-operations/bulk-handlers.ts @@ -0,0 +1,28 @@ +// Bulk operation handlers +// This file will be populated in task 5 + +import { ExtensionHandlerContext } from '../types.js'; + +export async function handleBulkUpdateIssues(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 5"); +} + +export async function handleBulkCloseIssues(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 5"); +} + +export async function handleBulkAssignIssues(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 5"); +} + +export async function handleBulkLabelIssues(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 5"); +} + +export async function handleBulkUpdateMergeRequests(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 5"); +} + +export async function handleBulkExportData(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 5"); +} \ No newline at end of file diff --git a/extensions/handlers/bulk-operations/index.ts b/extensions/handlers/bulk-operations/index.ts new file mode 100644 index 0000000..a6e3e17 --- /dev/null +++ b/extensions/handlers/bulk-operations/index.ts @@ -0,0 +1,3 @@ +// Export all bulk operation handlers + +export * from './bulk-handlers.js'; \ No newline at end of file diff --git a/extensions/handlers/index.ts b/extensions/handlers/index.ts new file mode 100644 index 0000000..5177080 --- /dev/null +++ b/extensions/handlers/index.ts @@ -0,0 +1,13 @@ +// Main export file for all handlers + +// Export types and utilities +export * from './types.js'; +export * from './utils.js'; + +// Export all handler modules +export * from './boards/index.js'; +export * from './time-tracking/index.js'; +export * from './releases/index.js'; +export * from './bulk-operations/index.js'; +export * from './analytics/index.js'; +export * from './webhooks/index.js'; \ No newline at end of file diff --git a/extensions/handlers/releases/asset-handlers.ts b/extensions/handlers/releases/asset-handlers.ts new file mode 100644 index 0000000..aecd4f5 --- /dev/null +++ b/extensions/handlers/releases/asset-handlers.ts @@ -0,0 +1,16 @@ +// Release asset operation handlers +// This file will be populated in task 4.2 + +import { ExtensionHandlerContext } from '../types.js'; + +export async function handleCreateReleaseAsset(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 4.2"); +} + +export async function handleUpdateReleaseAsset(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 4.2"); +} + +export async function handleDeleteReleaseAsset(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 4.2"); +} \ No newline at end of file diff --git a/extensions/handlers/releases/index.ts b/extensions/handlers/releases/index.ts new file mode 100644 index 0000000..84a183d --- /dev/null +++ b/extensions/handlers/releases/index.ts @@ -0,0 +1,4 @@ +// Export all release-related handlers + +export * from './release-handlers.js'; +export * from './asset-handlers.js'; \ No newline at end of file diff --git a/extensions/handlers/releases/release-handlers.ts b/extensions/handlers/releases/release-handlers.ts new file mode 100644 index 0000000..188b5d5 --- /dev/null +++ b/extensions/handlers/releases/release-handlers.ts @@ -0,0 +1,24 @@ +// Release CRUD operation handlers +// This file will be populated in task 4.1 + +import { ExtensionHandlerContext } from '../types.js'; + +export async function handleListReleases(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 4.1"); +} + +export async function handleGetRelease(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 4.1"); +} + +export async function handleCreateRelease(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 4.1"); +} + +export async function handleUpdateRelease(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 4.1"); +} + +export async function handleDeleteRelease(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 4.1"); +} \ No newline at end of file diff --git a/extensions/handlers/time-tracking/index.ts b/extensions/handlers/time-tracking/index.ts new file mode 100644 index 0000000..9433329 --- /dev/null +++ b/extensions/handlers/time-tracking/index.ts @@ -0,0 +1,4 @@ +// Export all time tracking handlers + +export * from './time-spent-handlers.js'; +export * from './time-estimate-handlers.js'; \ No newline at end of file diff --git a/extensions/handlers/time-tracking/time-estimate-handlers.ts b/extensions/handlers/time-tracking/time-estimate-handlers.ts new file mode 100644 index 0000000..c0579c8 --- /dev/null +++ b/extensions/handlers/time-tracking/time-estimate-handlers.ts @@ -0,0 +1,265 @@ +// Time estimate operation handlers + +import { ExtensionHandlerContext } from '../types.js'; +import { + formatSuccessResponse, + encodeProjectId, + createRequestBody, + parseDurationToSeconds, + formatSecondsToHumanDuration, + calculateAccuracyPercentage, + calculateVariancePercentage, + getTimeTrackingStatus, + batchProcess, + createBulkOperationSummary +} from '../utils.js'; + +export async function handleAddTimeEstimate(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, issue_iid, duration } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Adding time estimate for issue ${issue_iid} in project ${effectiveProjectId}`); + + const body = createRequestBody({ duration }); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/time_estimate`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "POST", + body, + } + ); + + await context.handleGitLabError(response); + const estimate = await response.json(); + + return formatSuccessResponse(estimate); +} + +export async function handleAddToTimeEstimate(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, issue_iid, duration } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Adding time estimate to issue ${issue_iid} in project ${effectiveProjectId}`); + + // First get current estimate + const currentResponse = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/time_stats`, + context.DEFAULT_FETCH_CONFIG + ); + + await context.handleGitLabError(currentResponse); + const currentStats = await currentResponse.json(); + + // Parse durations and add them + const currentEstimate = currentStats.time_estimate || 0; + const additionalSeconds = parseDurationToSeconds(duration); + const newTotalSeconds = currentEstimate + additionalSeconds; + const newDuration = formatSecondsToHumanDuration(newTotalSeconds); + + // Update with new total + const body = createRequestBody({ duration: newDuration }); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/time_estimate`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "POST", + body, + } + ); + + await context.handleGitLabError(response); + const result = await response.json(); + + return formatSuccessResponse({ + ...result, + previous_estimate: currentStats.human_time_estimate, + added_estimate: duration, + new_total_estimate: result.human_time_estimate + }); +} + +export async function handleUpdateTimeEstimate(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, issue_iid, duration } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Updating time estimate for issue ${issue_iid} in project ${effectiveProjectId}`); + + const body = createRequestBody({ duration }); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/time_estimate`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "POST", + body, + } + ); + + await context.handleGitLabError(response); + const estimate = await response.json(); + + return formatSuccessResponse(estimate); +} + +export async function handleResetTimeEstimate(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, issue_iid } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Resetting time estimate for issue ${issue_iid} in project ${effectiveProjectId}`); + + const body = createRequestBody({ duration: "0" }); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/reset_time_estimate`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "POST", + body, + } + ); + + await context.handleGitLabError(response); + const result = await response.json(); + + return formatSuccessResponse(result); +} + +export async function handleGetTimeEstimate(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, issue_iid } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Getting time estimate for issue ${issue_iid} in project ${effectiveProjectId}`); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/time_stats`, + context.DEFAULT_FETCH_CONFIG + ); + + await context.handleGitLabError(response); + const timeStats = await response.json(); + + // Return only estimate-related data + const estimateData = { + time_estimate: timeStats.time_estimate, + human_time_estimate: timeStats.human_time_estimate, + }; + + return formatSuccessResponse(estimateData); +} + +export async function handleCompareTimeEstimate(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, issue_iid, include_breakdown = false } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Comparing time estimate vs actual for issue ${issue_iid} in project ${effectiveProjectId}`); + + // Get time stats + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/time_stats`, + context.DEFAULT_FETCH_CONFIG + ); + + await context.handleGitLabError(response); + const timeStats = await response.json(); + + const estimated = timeStats.time_estimate || 0; + const actual = timeStats.total_time_spent || 0; + const difference = actual - estimated; + const accuracyPercentage = calculateAccuracyPercentage(estimated, actual); + + let breakdown = {}; + if (include_breakdown) { + // Get time entries for detailed breakdown + const entriesResponse = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/resource_time_events`, + context.DEFAULT_FETCH_CONFIG + ); + + if (entriesResponse.ok) { + const entries = await entriesResponse.json(); + breakdown = { + total_entries: entries.length, + entries: entries.map((entry: any) => ({ + duration: entry.duration, + spent_at: entry.spent_at, + user: entry.user?.name || 'Unknown', + note: entry.note + })) + }; + } + } + + const comparison = { + issue_iid: issue_iid, + estimated_seconds: estimated, + actual_seconds: actual, + difference_seconds: difference, + estimated_human: timeStats.human_time_estimate, + actual_human: timeStats.human_total_time_spent, + difference_human: formatSecondsToHumanDuration(Math.abs(difference)), + status: getTimeTrackingStatus(estimated, actual), + accuracy_percentage: accuracyPercentage, + variance_percentage: calculateVariancePercentage(estimated, actual), + ...(include_breakdown && { breakdown }) + }; + + return formatSuccessResponse(comparison); +} + +export async function handleBulkEstimateIssues(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, issue_iids, duration, action } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Bulk ${action} time estimates for ${issue_iids.length} issues in project ${effectiveProjectId}`); + + const processor = async (issue_iid: string) => { + let finalDuration = duration; + + if (action === 'add') { + // Get current estimate first + const currentResponse = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/time_stats`, + context.DEFAULT_FETCH_CONFIG + ); + + if (currentResponse.ok) { + const currentStats = await currentResponse.json(); + const currentEstimate = currentStats.time_estimate || 0; + const additionalSeconds = parseDurationToSeconds(duration); + const newTotalSeconds = currentEstimate + additionalSeconds; + finalDuration = formatSecondsToHumanDuration(newTotalSeconds); + } + } + + const body = createRequestBody({ duration: finalDuration }); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/time_estimate`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "POST", + body, + } + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + return { + issue_iid, + status: 'success', + new_estimate: result.human_time_estimate, + action: action + }; + }; + + const results = await batchProcess(issue_iids, processor); + const summary = createBulkOperationSummary(results, action, { duration }); + + return formatSuccessResponse(summary); +} \ No newline at end of file diff --git a/extensions/handlers/time-tracking/time-spent-handlers.ts b/extensions/handlers/time-tracking/time-spent-handlers.ts new file mode 100644 index 0000000..8fad6e4 --- /dev/null +++ b/extensions/handlers/time-tracking/time-spent-handlers.ts @@ -0,0 +1,84 @@ +// Time spent operation handlers + +import { ExtensionHandlerContext } from '../types.js'; +import { formatSuccessResponse, encodeProjectId, createRequestBody } from '../utils.js'; + +export async function handleAddTimeSpent(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, issue_iid, duration, summary, spent_at } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Adding time spent to issue ${issue_iid} in project ${effectiveProjectId}`); + + const body = createRequestBody({ + duration, + summary, + spent_at + }); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/add_spent_time`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "POST", + body, + } + ); + + await context.handleGitLabError(response); + const timeEntry = await response.json(); + + return formatSuccessResponse(timeEntry); +} + +export async function handleGetTimeTracking(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, issue_iid } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Getting time tracking for issue ${issue_iid} in project ${effectiveProjectId}`); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/time_stats`, + context.DEFAULT_FETCH_CONFIG + ); + + await context.handleGitLabError(response); + const timeStats = await response.json(); + + return formatSuccessResponse(timeStats); +} + +export async function handleListTimeEntries(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, issue_iid, page = 1, per_page = 20 } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Listing time entries for issue ${issue_iid} in project ${effectiveProjectId}`); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/resource_time_events?page=${page}&per_page=${per_page}`, + context.DEFAULT_FETCH_CONFIG + ); + + await context.handleGitLabError(response); + const timeEntries = await response.json(); + + return formatSuccessResponse(timeEntries); +} + +export async function handleDeleteTimeEntry(args: any, context: ExtensionHandlerContext): Promise { + const { project_id, issue_iid, time_event_id } = args; + const effectiveProjectId = context.getEffectiveProjectId(project_id); + + context.logger.info(`Deleting time entry ${time_event_id} for issue ${issue_iid} in project ${effectiveProjectId}`); + + const response = await context.fetch( + `${context.GITLAB_API_URL}/projects/${encodeProjectId(effectiveProjectId)}/issues/${issue_iid}/resource_time_events/${time_event_id}`, + { + ...context.DEFAULT_FETCH_CONFIG, + method: "DELETE", + } + ); + + await context.handleGitLabError(response); + + return formatSuccessResponse(`Time entry ${time_event_id} deleted successfully`); +} \ No newline at end of file diff --git a/extensions/handlers/types.ts b/extensions/handlers/types.ts new file mode 100644 index 0000000..385a0bf --- /dev/null +++ b/extensions/handlers/types.ts @@ -0,0 +1,116 @@ +// Shared types and interfaces for GitLab MCP Extension handlers + +export interface ExtensionHandlerContext { + GITLAB_API_URL: string; + DEFAULT_FETCH_CONFIG: any; + getEffectiveProjectId: (projectId: string) => string; + handleGitLabError: (response: any) => Promise; + logger: any; + fetch: any; +} + +export interface HandlerResponse { + content: Array<{ + type: string; + text: string; + }>; +} + +// Common response types +export interface SuccessResponse extends HandlerResponse {} +export interface ErrorResponse extends HandlerResponse {} + +// Common parameter types +export interface PaginationParams { + page?: number; + per_page?: number; +} + +export interface ProjectParams { + project_id: string; +} + +// Board-related types +export interface BoardParams extends ProjectParams { + board_id?: string; + name?: string; + assignee_id?: number; + milestone_id?: number; + labels?: string[]; + weight?: number; +} + +export interface BoardListParams extends ProjectParams { + board_id: string; + list_id?: string; + label_id?: number; + assignee_id?: number; + milestone_id?: number; + position?: number; + collapsed?: boolean; +} + +// Time tracking types +export interface TimeTrackingParams extends ProjectParams { + issue_iid: string; + duration?: string; + summary?: string; + spent_at?: string; + time_event_id?: string; +} + +export interface BulkEstimateParams extends ProjectParams { + issue_iids: string[]; + duration: string; + action: 'set' | 'add'; +} + +// Release types +export interface ReleaseParams extends ProjectParams { + tag_name?: string; + name?: string; + description?: string; + ref?: string; + milestones?: string[]; + released_at?: string; +} + +// Webhook types +export interface WebhookParams extends ProjectParams { + webhook_id?: string; + url?: string; + push_events?: boolean; + issues_events?: boolean; + merge_requests_events?: boolean; + tag_push_events?: boolean; + note_events?: boolean; + job_events?: boolean; + pipeline_events?: boolean; + wiki_page_events?: boolean; + deployment_events?: boolean; + releases_events?: boolean; + subgroup_events?: boolean; + enable_ssl_verification?: boolean; + token?: string; + push_events_branch_filter?: string; +} + +// Analytics types +export interface AnalyticsParams extends ProjectParams { + from_date?: string; + to_date?: string; + milestone_id?: number; + assignee_id?: number; + author_id?: number; + label_name?: string[]; +} + +// Bulk operations types +export interface BulkOperationParams extends ProjectParams { + issue_iids?: string[]; + merge_request_iids?: string[]; + assignee_id?: number; + milestone_id?: number; + labels?: string[]; + state_event?: 'close' | 'reopen'; +} \ No newline at end of file diff --git a/extensions/handlers/utils.ts b/extensions/handlers/utils.ts new file mode 100644 index 0000000..04c36e7 --- /dev/null +++ b/extensions/handlers/utils.ts @@ -0,0 +1,219 @@ +// Shared utility functions for GitLab MCP Extension handlers + +import { HandlerResponse, SuccessResponse, ErrorResponse } from './types.js'; + +/** + * Parse duration string (e.g., "2h 30m", "1d 4h") to seconds + */ +export function parseDurationToSeconds(duration: string): number { + const regex = /(\d+)([dhm])/g; + let totalSeconds = 0; + let match; + + while ((match = regex.exec(duration)) !== null) { + const value = parseInt(match[1]); + const unit = match[2]; + + switch (unit) { + case 'd': totalSeconds += value * 24 * 60 * 60; break; + case 'h': totalSeconds += value * 60 * 60; break; + case 'm': totalSeconds += value * 60; break; + } + } + + return totalSeconds; +} + +/** + * Format seconds to human-readable duration (e.g., "2h 30m", "1d 4h") + */ +export function formatSecondsToHumanDuration(seconds: number): string { + if (seconds === 0) return '0m'; + + const days = Math.floor(seconds / (24 * 60 * 60)); + const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60)); + const minutes = Math.floor((seconds % (60 * 60)) / 60); + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + + return parts.join(' ') || '0m'; +} + +/** + * Format successful response with data + */ +export function formatSuccessResponse(data: any): SuccessResponse { + return { + content: [ + { + type: "text", + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +/** + * Format error response with message + */ +export function formatErrorResponse(message: string): ErrorResponse { + return { + content: [ + { + type: "text", + text: JSON.stringify({ error: message }, null, 2), + }, + ], + }; +} + +/** + * Format simple text response + */ +export function formatTextResponse(text: string): HandlerResponse { + return { + content: [ + { + type: "text", + text: text, + }, + ], + }; +} + +/** + * Build query string from parameters + */ +export function buildQueryString(params: Record): string { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + value.forEach(v => searchParams.append(key, String(v))); + } else { + searchParams.append(key, String(value)); + } + } + }); + + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ''; +} + +/** + * Safely encode URI component for GitLab API paths + */ +export function encodeProjectId(projectId: string): string { + return encodeURIComponent(projectId); +} + +/** + * Create request body for API calls + */ +export function createRequestBody(data: Record): string { + // Filter out undefined values + const cleanData = Object.entries(data).reduce((acc, [key, value]) => { + if (value !== undefined) { + acc[key] = value; + } + return acc; + }, {} as Record); + + return JSON.stringify(cleanData); +} + +/** + * Handle array parameters (like labels) for API requests + */ +export function formatArrayParam(value: string[] | undefined): string | undefined { + return value && value.length > 0 ? value.join(',') : undefined; +} + +/** + * Calculate accuracy percentage for time estimates + */ +export function calculateAccuracyPercentage(estimated: number, actual: number): number { + if (estimated === 0) return 0; + return Math.round((estimated / actual) * 100); +} + +/** + * Calculate variance percentage for time estimates + */ +export function calculateVariancePercentage(estimated: number, actual: number): number { + if (estimated === 0) return 0; + const difference = Math.abs(actual - estimated); + return Math.round((difference / estimated) * 100); +} + +/** + * Determine time tracking status based on estimate vs actual + */ +export function getTimeTrackingStatus(estimated: number, actual: number): 'exact' | 'over_estimate' | 'under_estimate' { + const difference = actual - estimated; + if (difference === 0) return 'exact'; + return difference > 0 ? 'over_estimate' : 'under_estimate'; +} + +/** + * Batch process items with error handling + */ +export async function batchProcess( + items: T[], + processor: (item: T) => Promise, + options: { concurrency?: number } = {} +): Promise> { + const { concurrency = 5 } = options; + const results: Array<{ item: T; result?: R; error?: string }> = []; + + // Process items in batches to avoid overwhelming the API + for (let i = 0; i < items.length; i += concurrency) { + const batch = items.slice(i, i + concurrency); + const batchPromises = batch.map(async (item) => { + try { + const result = await processor(item); + return { item, result }; + } catch (error) { + return { + item, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + const batchResults = await Promise.all(batchPromises); + results.push(...batchResults); + } + + return results; +} + +/** + * Create summary for bulk operations + */ +export function createBulkOperationSummary( + results: Array<{ item: T; result?: any; error?: string }>, + operation: string, + additionalData?: Record +): any { + const successful = results.filter(r => !r.error).length; + const failed = results.filter(r => r.error).length; + + return { + total_items: results.length, + successful, + failed, + operation, + results: results.map(r => ({ + item: r.item, + status: r.error ? 'error' : 'success', + ...(r.result && { result: r.result }), + ...(r.error && { error: r.error }) + })), + ...additionalData + }; +} \ No newline at end of file diff --git a/extensions/handlers/webhooks/index.ts b/extensions/handlers/webhooks/index.ts new file mode 100644 index 0000000..3945376 --- /dev/null +++ b/extensions/handlers/webhooks/index.ts @@ -0,0 +1,3 @@ +// Export all webhook handlers + +export * from './webhook-handlers.js'; \ No newline at end of file diff --git a/extensions/handlers/webhooks/webhook-handlers.ts b/extensions/handlers/webhooks/webhook-handlers.ts new file mode 100644 index 0000000..40fe9a5 --- /dev/null +++ b/extensions/handlers/webhooks/webhook-handlers.ts @@ -0,0 +1,28 @@ +// Webhook operation handlers +// This file will be populated in task 7 + +import { ExtensionHandlerContext } from '../types.js'; + +export async function handleListWebhooks(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 7"); +} + +export async function handleGetWebhook(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 7"); +} + +export async function handleCreateWebhook(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 7"); +} + +export async function handleUpdateWebhook(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 7"); +} + +export async function handleDeleteWebhook(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 7"); +} + +export async function handleTestWebhook(args: any, context: ExtensionHandlerContext): Promise { + throw new Error("Handler will be implemented in task 7"); +} \ No newline at end of file diff --git a/extensions/index.ts b/extensions/index.ts new file mode 100644 index 0000000..3f02997 --- /dev/null +++ b/extensions/index.ts @@ -0,0 +1,24 @@ +// GitLab MCP Extensions - Main Export File +// This file exports all extension functionality for integration with the main GitLab MCP server + +export * from "./schemas.js"; +export * from "./tools.js"; +export * from "./handlers.js"; + +// Re-export commonly used items for convenience +export { + allExtensionTools, + extensionToolNames, + readOnlyExtensionTools, + boardTools, + timeTrackingTools, + releasesTools, + bulkOperationsTools, + analyticsTools, + webhooksTools, +} from "./tools.js"; + +export { + extensionHandlers, + type ExtensionHandlerContext, +} from "./handlers.js"; \ No newline at end of file diff --git a/extensions/schemas.ts b/extensions/schemas.ts new file mode 100644 index 0000000..560cdfe --- /dev/null +++ b/extensions/schemas.ts @@ -0,0 +1,450 @@ +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { + GitLabUserSchema, + GitLabLabelSchema, + GitLabMilestoneSchema, + PaginationOptionsSchema +} from "../schemas.js"; + +// Base schemas for extension operations +const ProjectParamsSchema = z.object({ + project_id: z.coerce.string().describe("Project ID or URL-encoded path to project"), +}); + +// ============================================================================ +// ISSUE BOARDS MANAGEMENT SCHEMAS +// ============================================================================ + +export const GitLabBoardSchema = z.object({ + id: z.number(), + name: z.string(), + project: z.object({ + id: z.number(), + name: z.string(), + path: z.string(), + }), + milestone: z.object({ + id: z.number(), + title: z.string(), + }).nullable(), + assignee: GitLabUserSchema.nullable(), + labels: z.array(GitLabLabelSchema), + weight: z.number().nullable(), + lists: z.array(z.object({ + id: z.number(), + label: GitLabLabelSchema.nullable(), + position: z.number(), + list_type: z.enum(['backlog', 'closed', 'label', 'milestone', 'assignee']), + })), +}); + +export const CreateBoardSchema = ProjectParamsSchema.extend({ + name: z.string().describe("The name of the board"), + assignee_id: z.number().optional().describe("The assignee the board should be scoped to"), + milestone_id: z.number().optional().describe("The milestone the board should be scoped to"), + labels: z.array(z.string()).optional().describe("Array of label names the board should be scoped to"), + weight: z.number().optional().describe("The weight range the board should be scoped to"), +}); + +export const ListBoardsSchema = ProjectParamsSchema.merge(PaginationOptionsSchema); + +export const GetBoardSchema = ProjectParamsSchema.extend({ + board_id: z.coerce.string().describe("The ID of the board"), +}); + +export const UpdateBoardSchema = GetBoardSchema.extend({ + name: z.string().optional().describe("The new name of the board"), + assignee_id: z.number().optional().describe("The assignee the board should be scoped to"), + milestone_id: z.number().optional().describe("The milestone the board should be scoped to"), + labels: z.array(z.string()).optional().describe("Array of label names the board should be scoped to"), + weight: z.number().optional().describe("The weight range the board should be scoped to"), +}); + +export const DeleteBoardSchema = GetBoardSchema; + +// Board Lists Schemas +export const CreateBoardListSchema = GetBoardSchema.extend({ + label_id: z.number().optional().describe("The ID of the label"), + assignee_id: z.number().optional().describe("The ID of the assignee"), + milestone_id: z.number().optional().describe("The ID of the milestone"), +}); + +export const ListBoardListsSchema = GetBoardSchema; + +export const UpdateBoardListSchema = GetBoardSchema.extend({ + list_id: z.coerce.string().describe("The ID of the list"), + position: z.number().optional().describe("The position of the list"), + collapsed: z.boolean().optional().describe("Whether the list is collapsed"), +}); + +export const DeleteBoardListSchema = GetBoardSchema.extend({ + list_id: z.coerce.string().describe("The ID of the list"), +}); + +export const GetBoardListIssuesSchema = GetBoardSchema.extend({ + list_id: z.coerce.string().describe("The ID of the list"), +}).merge(PaginationOptionsSchema); + +// ============================================================================ +// TIME TRACKING MANAGEMENT SCHEMAS +// ============================================================================ + +export const GitLabTimeStatsSchema = z.object({ + time_estimate: z.number(), + total_time_spent: z.number(), + human_time_estimate: z.string().nullable(), + human_total_time_spent: z.string().nullable(), +}); + +export const GitLabTimeEntrySchema = z.object({ + id: z.number(), + user: GitLabUserSchema, + created_at: z.string(), + updated_at: z.string(), + spent_at: z.string(), + duration: z.number(), + summary: z.string(), +}); + +export const AddTimeSpentSchema = z.object({ + project_id: z.coerce.string().describe("The ID or path of the project"), + issue_iid: z.coerce.string().describe("The internal ID of the issue"), + duration: z.string().describe("Time spent (e.g., '1h 30m', '2h', '45m')"), + summary: z.string().optional().describe("Summary of work done"), + spent_at: z.string().optional().describe("Date when time was spent (YYYY-MM-DD)"), +}); + +export const GetTimeTrackingSchema = z.object({ + project_id: z.coerce.string().describe("The ID or path of the project"), + issue_iid: z.coerce.string().describe("The internal ID of the issue"), +}); + +export const AddTimeEstimateSchema = z.object({ + project_id: z.coerce.string().describe("The ID or path of the project"), + issue_iid: z.coerce.string().describe("The internal ID of the issue"), + duration: z.string().describe("Time estimate (e.g., '2h', '1d 4h', '30m')"), +}); + +export const UpdateTimeEstimateSchema = z.object({ + project_id: z.coerce.string().describe("The ID or path of the project"), + issue_iid: z.coerce.string().describe("The internal ID of the issue"), + duration: z.string().describe("Time estimate (e.g., '2h', '1d 4h', '30m')"), +}); + +export const ResetTimeEstimateSchema = z.object({ + project_id: z.coerce.string().describe("The ID or path of the project"), + issue_iid: z.coerce.string().describe("The internal ID of the issue"), +}); + +export const GetTimeEstimateSchema = z.object({ + project_id: z.coerce.string().describe("The ID or path of the project"), + issue_iid: z.coerce.string().describe("The internal ID of the issue"), +}); + +export const AddToTimeEstimateSchema = z.object({ + project_id: z.coerce.string().describe("The ID or path of the project"), + issue_iid: z.coerce.string().describe("The internal ID of the issue"), + duration: z.string().describe("Time estimate to add (e.g., '2h', '1d 4h', '30m')"), +}); + +export const CompareTimeEstimateSchema = z.object({ + project_id: z.coerce.string().describe("The ID or path of the project"), + issue_iid: z.coerce.string().describe("The internal ID of the issue"), + include_breakdown: z.boolean().optional().describe("Include detailed breakdown of time entries"), +}); + +export const BulkEstimateIssuesSchema = z.object({ + project_id: z.coerce.string().describe("The ID or path of the project"), + issue_iids: z.array(z.number()).describe("Array of issue internal IDs"), + duration: z.string().describe("Time estimate to apply to all issues"), + action: z.enum(["set", "add"]).describe("Whether to set or add to existing estimates"), +}); + +export const ListTimeEntriesSchema = GetTimeTrackingSchema.merge(PaginationOptionsSchema); + +export const DeleteTimeEntrySchema = z.object({ + project_id: z.coerce.string().describe("The ID or path of the project"), + issue_iid: z.coerce.string().describe("The internal ID of the issue"), + time_entry_id: z.coerce.string().describe("The ID of the time entry"), +}); + +// ============================================================================ +// RELEASES MANAGEMENT SCHEMAS +// ============================================================================ + +export const GitLabReleaseSchema = z.object({ + tag_name: z.string(), + name: z.string(), + description: z.string(), + description_html: z.string(), + created_at: z.string(), + released_at: z.string(), + author: GitLabUserSchema, + commit: z.object({ + id: z.string(), + short_id: z.string(), + title: z.string(), + author_name: z.string(), + author_email: z.string(), + authored_date: z.string(), + committer_name: z.string(), + committer_email: z.string(), + committed_date: z.string(), + message: z.string(), + }), + milestones: z.array(GitLabMilestoneSchema), + commit_path: z.string(), + tag_path: z.string(), + assets: z.object({ + count: z.number(), + sources: z.array(z.object({ + format: z.string(), + url: z.string(), + })), + links: z.array(z.object({ + id: z.number(), + name: z.string(), + url: z.string(), + external: z.boolean(), + link_type: z.string(), + })), + }), + evidences: z.array(z.object({ + sha: z.string(), + filepath: z.string(), + collected_at: z.string(), + })), +}); + +export const ListReleasesSchema = ProjectParamsSchema.extend({ + order_by: z.enum(['created_at', 'released_at']).optional().describe("Order releases by created_at or released_at"), + sort: z.enum(['asc', 'desc']).optional().describe("Sort order"), + include_html_description: z.boolean().optional().describe("Include HTML description"), +}).merge(PaginationOptionsSchema); + +export const GetReleaseSchema = ProjectParamsSchema.extend({ + tag_name: z.string().describe("The Git tag the release is associated with"), + include_html_description: z.boolean().optional().describe("Include HTML description"), +}); + +export const CreateReleaseSchema = ProjectParamsSchema.extend({ + name: z.string().describe("The release name"), + tag_name: z.string().describe("The tag where the release is created from"), + description: z.string().describe("The description of the release"), + ref: z.string().optional().describe("If tag_name doesn't exist, the release is created from ref"), + milestones: z.array(z.string()).optional().describe("Array of milestone titles to associate with the release"), + assets: z.object({ + links: z.array(z.object({ + name: z.string(), + url: z.string(), + filepath: z.string().optional(), + link_type: z.enum(['runbook', 'package', 'image', 'other']).optional(), + })).optional(), + }).optional().describe("Assets associated with the release"), + released_at: z.string().optional().describe("Date and time for the release (ISO 8601 format)"), +}); + +export const UpdateReleaseSchema = GetReleaseSchema.extend({ + name: z.string().optional().describe("The release name"), + description: z.string().optional().describe("The description of the release"), + milestones: z.array(z.string()).optional().describe("Array of milestone titles to associate with the release"), + released_at: z.string().optional().describe("Date and time for the release (ISO 8601 format)"), +}); + +export const DeleteReleaseSchema = GetReleaseSchema; + +// Release Assets Schemas +export const CreateReleaseAssetSchema = GetReleaseSchema.extend({ + name: z.string().describe("The name of the asset"), + url: z.string().describe("The URL of the asset"), + filepath: z.string().optional().describe("The filepath of the asset"), + link_type: z.enum(['runbook', 'package', 'image', 'other']).optional().describe("The type of the asset"), +}); + +export const UpdateReleaseAssetSchema = GetReleaseSchema.extend({ + link_id: z.coerce.string().describe("The ID of the asset link"), + name: z.string().optional().describe("The name of the asset"), + url: z.string().optional().describe("The URL of the asset"), + filepath: z.string().optional().describe("The filepath of the asset"), + link_type: z.enum(['runbook', 'package', 'image', 'other']).optional().describe("The type of the asset"), +}); + +export const DeleteReleaseAssetSchema = GetReleaseSchema.extend({ + link_id: z.coerce.string().describe("The ID of the asset link"), +}); + +// ============================================================================ +// BULK OPERATIONS SCHEMAS +// ============================================================================ + +export const BulkUpdateIssuesSchema = ProjectParamsSchema.extend({ + issue_iids: z.array(z.number()).describe("Array of issue internal IDs"), + assignee_ids: z.array(z.number()).optional().describe("Array of user IDs to assign"), + milestone_id: z.number().optional().describe("Milestone ID to assign"), + labels: z.array(z.string()).optional().describe("Array of label names to add"), + remove_labels: z.array(z.string()).optional().describe("Array of label names to remove"), + state_event: z.enum(['close', 'reopen']).optional().describe("State change to apply"), + discussion_locked: z.boolean().optional().describe("Lock or unlock discussions"), +}); + +export const BulkCloseIssuesSchema = ProjectParamsSchema.extend({ + issue_iids: z.array(z.number()).describe("Array of issue internal IDs"), + comment: z.string().optional().describe("Comment to add when closing issues"), +}); + +export const BulkAssignIssuesSchema = ProjectParamsSchema.extend({ + issue_iids: z.array(z.number()).describe("Array of issue internal IDs"), + assignee_ids: z.array(z.number()).describe("Array of user IDs to assign"), + action: z.enum(['assign', 'unassign']).describe("Whether to assign or unassign users"), +}); + +export const BulkLabelIssuesSchema = ProjectParamsSchema.extend({ + issue_iids: z.array(z.number()).describe("Array of issue internal IDs"), + labels: z.array(z.string()).describe("Array of label names"), + action: z.enum(['add', 'remove', 'replace']).describe("Whether to add, remove, or replace labels"), +}); + +export const BulkUpdateMergeRequestsSchema = ProjectParamsSchema.extend({ + merge_request_iids: z.array(z.number()).describe("Array of merge request internal IDs"), + assignee_ids: z.array(z.number()).optional().describe("Array of user IDs to assign"), + reviewer_ids: z.array(z.number()).optional().describe("Array of user IDs to assign as reviewers"), + milestone_id: z.number().optional().describe("Milestone ID to assign"), + labels: z.array(z.string()).optional().describe("Array of label names to add"), + remove_labels: z.array(z.string()).optional().describe("Array of label names to remove"), + state_event: z.enum(['close', 'reopen']).optional().describe("State change to apply"), +}); + +export const BulkExportSchema = ProjectParamsSchema.extend({ + export_type: z.enum(['issues', 'merge_requests', 'milestones']).describe("Type of data to export"), + format: z.enum(['json', 'csv']).describe("Export format"), + filters: z.object({ + state: z.enum(['opened', 'closed', 'all']).optional(), + labels: z.array(z.string()).optional(), + milestone: z.string().optional(), + assignee_id: z.number().optional(), + author_id: z.number().optional(), + created_after: z.string().optional(), + created_before: z.string().optional(), + }).optional().describe("Filters to apply to the export"), +}); + +// ============================================================================ +// ANALYTICS AND REPORTING SCHEMAS +// ============================================================================ + +export const ProjectAnalyticsSchema = ProjectParamsSchema.extend({ + from: z.string().describe("Start date for analytics (YYYY-MM-DD)"), + to: z.string().describe("End date for analytics (YYYY-MM-DD)"), + milestone_id: z.number().optional().describe("Filter by milestone"), + labels: z.array(z.string()).optional().describe("Filter by labels"), +}); + +export const IssueAnalyticsSchema = ProjectAnalyticsSchema.extend({ + group_by: z.enum(['day', 'week', 'month']).optional().describe("Group results by time period"), + include_closed: z.boolean().optional().describe("Include closed issues in analytics"), +}); + +export const TeamPerformanceSchema = ProjectAnalyticsSchema.extend({ + user_ids: z.array(z.number()).optional().describe("Filter by specific users"), + include_time_tracking: z.boolean().optional().describe("Include time tracking data"), +}); + +export const MilestoneAnalyticsSchema = ProjectParamsSchema.extend({ + milestone_id: z.coerce.string().describe("The ID of the milestone"), + include_burndown: z.boolean().optional().describe("Include burndown chart data"), +}); + +export const CustomReportSchema = ProjectParamsSchema.extend({ + report_type: z.enum(['issues', 'merge_requests', 'time_tracking', 'milestones']).describe("Type of report"), + from: z.string().describe("Start date for report (YYYY-MM-DD)"), + to: z.string().describe("End date for report (YYYY-MM-DD)"), + filters: z.object({ + state: z.enum(['opened', 'closed', 'all']).optional(), + labels: z.array(z.string()).optional(), + milestone: z.string().optional(), + assignee_id: z.number().optional(), + author_id: z.number().optional(), + }).optional().describe("Filters to apply to the report"), + format: z.enum(['json', 'csv']).describe("Report format"), + group_by: z.enum(['day', 'week', 'month', 'user', 'label', 'milestone']).optional().describe("Group results by"), +}); + +// ============================================================================ +// WEBHOOKS MANAGEMENT SCHEMAS +// ============================================================================ + +export const GitLabWebhookSchema = z.object({ + id: z.number(), + url: z.string(), + project_id: z.number(), + push_events: z.boolean(), + push_events_branch_filter: z.string(), + issues_events: z.boolean(), + confidential_issues_events: z.boolean(), + merge_requests_events: z.boolean(), + tag_push_events: z.boolean(), + note_events: z.boolean(), + confidential_note_events: z.boolean(), + job_events: z.boolean(), + pipeline_events: z.boolean(), + wiki_page_events: z.boolean(), + deployment_events: z.boolean(), + releases_events: z.boolean(), + subgroup_events: z.boolean(), + enable_ssl_verification: z.boolean(), + created_at: z.string(), + custom_webhook_template: z.string().nullable(), +}); + +export const CreateWebhookSchema = ProjectParamsSchema.extend({ + url: z.string().describe("The URL to which the hook will be triggered"), + push_events: z.boolean().optional().describe("Trigger hook on push events"), + issues_events: z.boolean().optional().describe("Trigger hook on issue events"), + confidential_issues_events: z.boolean().optional().describe("Trigger hook on confidential issue events"), + merge_requests_events: z.boolean().optional().describe("Trigger hook on merge request events"), + tag_push_events: z.boolean().optional().describe("Trigger hook on tag push events"), + note_events: z.boolean().optional().describe("Trigger hook on note events"), + confidential_note_events: z.boolean().optional().describe("Trigger hook on confidential note events"), + job_events: z.boolean().optional().describe("Trigger hook on job events"), + pipeline_events: z.boolean().optional().describe("Trigger hook on pipeline events"), + wiki_page_events: z.boolean().optional().describe("Trigger hook on wiki page events"), + deployment_events: z.boolean().optional().describe("Trigger hook on deployment events"), + releases_events: z.boolean().optional().describe("Trigger hook on release events"), + subgroup_events: z.boolean().optional().describe("Trigger hook on subgroup events"), + push_events_branch_filter: z.string().optional().describe("Branch filter for push events"), + enable_ssl_verification: z.boolean().optional().describe("Enable SSL verification"), + token: z.string().optional().describe("Secret token for webhook authentication"), + custom_webhook_template: z.string().optional().describe("Custom webhook template"), +}); + +export const ListWebhooksSchema = ProjectParamsSchema.merge(PaginationOptionsSchema); + +export const GetWebhookSchema = ProjectParamsSchema.extend({ + hook_id: z.coerce.string().describe("The ID of the webhook"), +}); + +export const UpdateWebhookSchema = GetWebhookSchema.extend({ + url: z.string().optional().describe("The URL to which the hook will be triggered"), + push_events: z.boolean().optional().describe("Trigger hook on push events"), + issues_events: z.boolean().optional().describe("Trigger hook on issue events"), + confidential_issues_events: z.boolean().optional().describe("Trigger hook on confidential issue events"), + merge_requests_events: z.boolean().optional().describe("Trigger hook on merge request events"), + tag_push_events: z.boolean().optional().describe("Trigger hook on tag push events"), + note_events: z.boolean().optional().describe("Trigger hook on note events"), + confidential_note_events: z.boolean().optional().describe("Trigger hook on confidential note events"), + job_events: z.boolean().optional().describe("Trigger hook on job events"), + pipeline_events: z.boolean().optional().describe("Trigger hook on pipeline events"), + wiki_page_events: z.boolean().optional().describe("Trigger hook on wiki page events"), + deployment_events: z.boolean().optional().describe("Trigger hook on deployment events"), + releases_events: z.boolean().optional().describe("Trigger hook on release events"), + subgroup_events: z.boolean().optional().describe("Trigger hook on subgroup events"), + push_events_branch_filter: z.string().optional().describe("Branch filter for push events"), + enable_ssl_verification: z.boolean().optional().describe("Enable SSL verification"), + token: z.string().optional().describe("Secret token for webhook authentication"), + custom_webhook_template: z.string().optional().describe("Custom webhook template"), +}); + +export const DeleteWebhookSchema = GetWebhookSchema; + +export const TestWebhookSchema = GetWebhookSchema; \ No newline at end of file diff --git a/extensions/tools.ts b/extensions/tools.ts new file mode 100644 index 0000000..818b550 --- /dev/null +++ b/extensions/tools.ts @@ -0,0 +1,370 @@ +import { zodToJsonSchema } from "zod-to-json-schema"; +import { + // Issue Boards schemas + ListBoardsSchema, + GetBoardSchema, + CreateBoardSchema, + UpdateBoardSchema, + DeleteBoardSchema, + ListBoardListsSchema, + CreateBoardListSchema, + UpdateBoardListSchema, + DeleteBoardListSchema, + GetBoardListIssuesSchema, + + // Time Tracking schemas + AddTimeSpentSchema, + GetTimeTrackingSchema, + UpdateTimeEstimateSchema, + ResetTimeEstimateSchema, + GetTimeEstimateSchema, + AddTimeEstimateSchema, + AddToTimeEstimateSchema, + CompareTimeEstimateSchema, + BulkEstimateIssuesSchema, + ListTimeEntriesSchema, + DeleteTimeEntrySchema, + + // Releases schemas + ListReleasesSchema, + GetReleaseSchema, + CreateReleaseSchema, + UpdateReleaseSchema, + DeleteReleaseSchema, + CreateReleaseAssetSchema, + UpdateReleaseAssetSchema, + DeleteReleaseAssetSchema, + + // Bulk Operations schemas + BulkUpdateIssuesSchema, + BulkCloseIssuesSchema, + BulkAssignIssuesSchema, + BulkLabelIssuesSchema, + BulkUpdateMergeRequestsSchema, + BulkExportSchema, + + // Analytics schemas + ProjectAnalyticsSchema, + IssueAnalyticsSchema, + TeamPerformanceSchema, + MilestoneAnalyticsSchema, + CustomReportSchema, + + // Webhooks schemas + ListWebhooksSchema, + GetWebhookSchema, + CreateWebhookSchema, + UpdateWebhookSchema, + DeleteWebhookSchema, + TestWebhookSchema, +} from "./schemas.js"; + +// ============================================================================ +// ISSUE BOARDS TOOLS +// ============================================================================ + +export const boardTools = [ + { + name: "list_boards", + description: "List issue boards in a GitLab project", + inputSchema: zodToJsonSchema(ListBoardsSchema), + }, + { + name: "get_board", + description: "Get details of a specific issue board", + inputSchema: zodToJsonSchema(GetBoardSchema), + }, + { + name: "create_board", + description: "Create a new issue board in a GitLab project", + inputSchema: zodToJsonSchema(CreateBoardSchema), + }, + { + name: "update_board", + description: "Update an existing issue board", + inputSchema: zodToJsonSchema(UpdateBoardSchema), + }, + { + name: "delete_board", + description: "Delete an issue board from a GitLab project", + inputSchema: zodToJsonSchema(DeleteBoardSchema), + }, + { + name: "list_board_lists", + description: "List all lists in an issue board", + inputSchema: zodToJsonSchema(ListBoardListsSchema), + }, + { + name: "create_board_list", + description: "Create a new list in an issue board", + inputSchema: zodToJsonSchema(CreateBoardListSchema), + }, + { + name: "update_board_list", + description: "Update a list in an issue board", + inputSchema: zodToJsonSchema(UpdateBoardListSchema), + }, + { + name: "delete_board_list", + description: "Delete a list from an issue board", + inputSchema: zodToJsonSchema(DeleteBoardListSchema), + }, + { + name: "get_board_list_issues", + description: "Get issues in a specific board list", + inputSchema: zodToJsonSchema(GetBoardListIssuesSchema), + }, +]; + +// ============================================================================ +// TIME TRACKING TOOLS +// ============================================================================ + +export const timeTrackingTools = [ + { + name: "add_time_spent", + description: "Add time spent on an issue", + inputSchema: zodToJsonSchema(AddTimeSpentSchema), + }, + { + name: "get_time_tracking", + description: "Get time tracking summary for an issue", + inputSchema: zodToJsonSchema(GetTimeTrackingSchema), + }, + { + name: "add_time_estimate", + description: "Add initial time estimate for an issue", + inputSchema: zodToJsonSchema(AddTimeEstimateSchema), + }, + { + name: "update_time_estimate", + description: "Update existing time estimate for an issue", + inputSchema: zodToJsonSchema(UpdateTimeEstimateSchema), + }, + { + name: "reset_time_estimate", + description: "Reset time estimate for an issue to zero", + inputSchema: zodToJsonSchema(ResetTimeEstimateSchema), + }, + { + name: "get_time_estimate", + description: "Get only the time estimate for an issue", + inputSchema: zodToJsonSchema(GetTimeEstimateSchema), + }, + { + name: "add_to_time_estimate", + description: "Add additional time to existing estimate for an issue", + inputSchema: zodToJsonSchema(AddToTimeEstimateSchema), + }, + { + name: "compare_time_estimate", + description: "Compare time estimate vs actual time spent with analysis", + inputSchema: zodToJsonSchema(CompareTimeEstimateSchema), + }, + { + name: "bulk_estimate_issues", + description: "Set or add time estimates to multiple issues", + inputSchema: zodToJsonSchema(BulkEstimateIssuesSchema), + }, + { + name: "list_time_entries", + description: "List time tracking entries for an issue", + inputSchema: zodToJsonSchema(ListTimeEntriesSchema), + }, + { + name: "delete_time_entry", + description: "Delete a time tracking entry", + inputSchema: zodToJsonSchema(DeleteTimeEntrySchema), + }, +]; + +// ============================================================================ +// RELEASES TOOLS +// ============================================================================ + +export const releasesTools = [ + { + name: "list_releases", + description: "List releases in a GitLab project", + inputSchema: zodToJsonSchema(ListReleasesSchema), + }, + { + name: "get_release", + description: "Get details of a specific release", + inputSchema: zodToJsonSchema(GetReleaseSchema), + }, + { + name: "create_release", + description: "Create a new release in a GitLab project", + inputSchema: zodToJsonSchema(CreateReleaseSchema), + }, + { + name: "update_release", + description: "Update an existing release", + inputSchema: zodToJsonSchema(UpdateReleaseSchema), + }, + { + name: "delete_release", + description: "Delete a release from a GitLab project", + inputSchema: zodToJsonSchema(DeleteReleaseSchema), + }, + { + name: "create_release_asset", + description: "Create a release asset link", + inputSchema: zodToJsonSchema(CreateReleaseAssetSchema), + }, + { + name: "update_release_asset", + description: "Update a release asset link", + inputSchema: zodToJsonSchema(UpdateReleaseAssetSchema), + }, + { + name: "delete_release_asset", + description: "Delete a release asset link", + inputSchema: zodToJsonSchema(DeleteReleaseAssetSchema), + }, +]; + +// ============================================================================ +// BULK OPERATIONS TOOLS +// ============================================================================ + +export const bulkOperationsTools = [ + { + name: "bulk_update_issues", + description: "Update multiple issues simultaneously", + inputSchema: zodToJsonSchema(BulkUpdateIssuesSchema), + }, + { + name: "bulk_close_issues", + description: "Close multiple issues with optional comment", + inputSchema: zodToJsonSchema(BulkCloseIssuesSchema), + }, + { + name: "bulk_assign_issues", + description: "Assign or unassign multiple issues to users", + inputSchema: zodToJsonSchema(BulkAssignIssuesSchema), + }, + { + name: "bulk_label_issues", + description: "Add, remove, or replace labels on multiple issues", + inputSchema: zodToJsonSchema(BulkLabelIssuesSchema), + }, + { + name: "bulk_update_merge_requests", + description: "Update multiple merge requests simultaneously", + inputSchema: zodToJsonSchema(BulkUpdateMergeRequestsSchema), + }, + { + name: "bulk_export_data", + description: "Export project data in bulk (issues, MRs, milestones)", + inputSchema: zodToJsonSchema(BulkExportSchema), + }, +]; + +// ============================================================================ +// ANALYTICS TOOLS +// ============================================================================ + +export const analyticsTools = [ + { + name: "get_project_analytics", + description: "Get comprehensive project analytics and metrics", + inputSchema: zodToJsonSchema(ProjectAnalyticsSchema), + }, + { + name: "get_issue_analytics", + description: "Get issue-specific analytics including velocity and cycle time", + inputSchema: zodToJsonSchema(IssueAnalyticsSchema), + }, + { + name: "get_team_performance", + description: "Get team and individual performance metrics", + inputSchema: zodToJsonSchema(TeamPerformanceSchema), + }, + { + name: "get_milestone_analytics", + description: "Get milestone progress and burndown analytics", + inputSchema: zodToJsonSchema(MilestoneAnalyticsSchema), + }, + { + name: "generate_custom_report", + description: "Generate custom reports with specified filters and grouping", + inputSchema: zodToJsonSchema(CustomReportSchema), + }, +]; + +// ============================================================================ +// WEBHOOKS TOOLS +// ============================================================================ + +export const webhooksTools = [ + { + name: "list_webhooks", + description: "List project webhooks", + inputSchema: zodToJsonSchema(ListWebhooksSchema), + }, + { + name: "get_webhook", + description: "Get details of a specific webhook", + inputSchema: zodToJsonSchema(GetWebhookSchema), + }, + { + name: "create_webhook", + description: "Create a new project webhook", + inputSchema: zodToJsonSchema(CreateWebhookSchema), + }, + { + name: "update_webhook", + description: "Update an existing webhook", + inputSchema: zodToJsonSchema(UpdateWebhookSchema), + }, + { + name: "delete_webhook", + description: "Delete a project webhook", + inputSchema: zodToJsonSchema(DeleteWebhookSchema), + }, + { + name: "test_webhook", + description: "Test a webhook by triggering a test event", + inputSchema: zodToJsonSchema(TestWebhookSchema), + }, +]; + +// ============================================================================ +// ALL EXTENSION TOOLS +// ============================================================================ + +export const allExtensionTools = [ + ...boardTools, + ...timeTrackingTools, + ...releasesTools, + ...bulkOperationsTools, + ...analyticsTools, + ...webhooksTools, +]; + +// Extension tool names for filtering +export const extensionToolNames = allExtensionTools.map(tool => tool.name); + +// Read-only extension tools +export const readOnlyExtensionTools = [ + "list_boards", + "get_board", + "list_board_lists", + "get_board_list_issues", + "get_time_tracking", + "get_time_estimate", + "compare_time_estimate", + "list_time_entries", + "list_releases", + "get_release", + "get_project_analytics", + "get_issue_analytics", + "get_team_performance", + "get_milestone_analytics", + "generate_custom_report", + "bulk_export_data", + "list_webhooks", + "get_webhook", +]; \ No newline at end of file diff --git a/extensions/types.ts b/extensions/types.ts new file mode 100644 index 0000000..57284ae --- /dev/null +++ b/extensions/types.ts @@ -0,0 +1,319 @@ +// TypeScript type definitions for GitLab MCP Extensions + +import { z } from "zod"; +import { + GitLabBoardSchema, + GitLabTimeStatsSchema, + GitLabTimeEntrySchema, + GitLabReleaseSchema, + GitLabWebhookSchema, +} from "./schemas.js"; + +// ============================================================================ +// ISSUE BOARDS TYPES +// ============================================================================ + +export type GitLabBoard = z.infer; + +export interface GitLabBoardList { + id: number; + label: { + id: number; + name: string; + color: string; + description: string | null; + } | null; + position: number; + list_type: 'backlog' | 'closed' | 'label' | 'milestone' | 'assignee'; + collapsed?: boolean; +} + +export interface GitLabBoardCreateOptions { + name: string; + assignee_id?: number; + milestone_id?: number; + labels?: string[]; + weight?: number; +} + +export interface GitLabBoardUpdateOptions { + name?: string; + assignee_id?: number; + milestone_id?: number; + labels?: string[]; + weight?: number; +} + +// ============================================================================ +// TIME TRACKING TYPES +// ============================================================================ + +export type GitLabTimeStats = z.infer; +export type GitLabTimeEntry = z.infer; + +export interface TimeTrackingOptions { + duration: string; + summary?: string; + spent_at?: string; +} + +export interface TimeEstimateOptions { + duration: string; +} + +// ============================================================================ +// RELEASES TYPES +// ============================================================================ + +export type GitLabRelease = z.infer; + +export interface GitLabReleaseAsset { + id: number; + name: string; + url: string; + external: boolean; + link_type: 'runbook' | 'package' | 'image' | 'other'; + filepath?: string; +} + +export interface GitLabReleaseCreateOptions { + name: string; + tag_name: string; + description: string; + ref?: string; + milestones?: string[]; + assets?: { + links?: Array<{ + name: string; + url: string; + filepath?: string; + link_type?: 'runbook' | 'package' | 'image' | 'other'; + }>; + }; + released_at?: string; +} + +export interface GitLabReleaseUpdateOptions { + name?: string; + description?: string; + milestones?: string[]; + released_at?: string; +} + +// ============================================================================ +// BULK OPERATIONS TYPES +// ============================================================================ + +export interface BulkUpdateIssuesOptions { + issue_iids: number[]; + assignee_ids?: number[]; + milestone_id?: number; + labels?: string[]; + remove_labels?: string[]; + state_event?: 'close' | 'reopen'; + discussion_locked?: boolean; +} + +export interface BulkCloseIssuesOptions { + issue_iids: number[]; + comment?: string; +} + +export interface BulkAssignIssuesOptions { + issue_iids: number[]; + assignee_ids: number[]; + action: 'assign' | 'unassign'; +} + +export interface BulkLabelIssuesOptions { + issue_iids: number[]; + labels: string[]; + action: 'add' | 'remove' | 'replace'; +} + +export interface BulkUpdateMergeRequestsOptions { + merge_request_iids: number[]; + assignee_ids?: number[]; + reviewer_ids?: number[]; + milestone_id?: number; + labels?: string[]; + remove_labels?: string[]; + state_event?: 'close' | 'reopen'; +} + +export interface BulkExportOptions { + export_type: 'issues' | 'merge_requests' | 'milestones'; + format: 'json' | 'csv'; + filters?: { + state?: 'opened' | 'closed' | 'all'; + labels?: string[]; + milestone?: string; + assignee_id?: number; + author_id?: number; + created_after?: string; + created_before?: string; + }; +} + +// ============================================================================ +// ANALYTICS TYPES +// ============================================================================ + +export interface ProjectAnalyticsOptions { + from: string; + to: string; + milestone_id?: number; + labels?: string[]; +} + +export interface IssueAnalyticsOptions extends ProjectAnalyticsOptions { + group_by?: 'day' | 'week' | 'month'; + include_closed?: boolean; +} + +export interface TeamPerformanceOptions extends ProjectAnalyticsOptions { + user_ids?: number[]; + include_time_tracking?: boolean; +} + +export interface MilestoneAnalyticsOptions { + milestone_id: string; + include_burndown?: boolean; +} + +export interface CustomReportOptions { + report_type: 'issues' | 'merge_requests' | 'time_tracking' | 'milestones'; + from: string; + to: string; + filters?: { + state?: 'opened' | 'closed' | 'all'; + labels?: string[]; + milestone?: string; + assignee_id?: number; + author_id?: number; + }; + format: 'json' | 'csv'; + group_by?: 'day' | 'week' | 'month' | 'user' | 'label' | 'milestone'; +} + +export interface AnalyticsResult { + metrics: Record; + data: any[]; + summary: { + total_count: number; + date_range: { + from: string; + to: string; + }; + filters_applied: Record; + }; +} + +// ============================================================================ +// WEBHOOKS TYPES +// ============================================================================ + +export type GitLabWebhook = z.infer; + +export interface GitLabWebhookCreateOptions { + url: string; + push_events?: boolean; + issues_events?: boolean; + confidential_issues_events?: boolean; + merge_requests_events?: boolean; + tag_push_events?: boolean; + note_events?: boolean; + confidential_note_events?: boolean; + job_events?: boolean; + pipeline_events?: boolean; + wiki_page_events?: boolean; + deployment_events?: boolean; + releases_events?: boolean; + subgroup_events?: boolean; + push_events_branch_filter?: string; + enable_ssl_verification?: boolean; + token?: string; + custom_webhook_template?: string; +} + +export interface GitLabWebhookUpdateOptions { + url?: string; + push_events?: boolean; + issues_events?: boolean; + confidential_issues_events?: boolean; + merge_requests_events?: boolean; + tag_push_events?: boolean; + note_events?: boolean; + confidential_note_events?: boolean; + job_events?: boolean; + pipeline_events?: boolean; + wiki_page_events?: boolean; + deployment_events?: boolean; + releases_events?: boolean; + subgroup_events?: boolean; + push_events_branch_filter?: string; + enable_ssl_verification?: boolean; + token?: string; + custom_webhook_template?: string; +} + +// ============================================================================ +// COMMON TYPES +// ============================================================================ + +export interface PaginationOptions { + page?: number; + per_page?: number; +} + +export interface PaginatedResponse { + data: T[]; + pagination: { + page: number; + per_page: number; + total_pages: number; + total_count: number; + }; +} + +export interface ExtensionError { + type: string; + message: string; + context?: any; + status_code?: number; +} + +// ============================================================================ +// WORKFLOW AUTOMATION TYPES (Future Implementation) +// ============================================================================ + +export interface AutomationRule { + id: string; + name: string; + description: string; + enabled: boolean; + trigger: { + event_type: string; + conditions: Record; + }; + actions: Array<{ + type: string; + parameters: Record; + }>; + created_at: string; + updated_at: string; +} + +export interface AutomationRuleCreateOptions { + name: string; + description?: string; + enabled?: boolean; + trigger: { + event_type: string; + conditions: Record; + }; + actions: Array<{ + type: string; + parameters: Record; + }>; +} \ No newline at end of file diff --git a/index.ts b/index.ts index 370e29d..c571d94 100644 --- a/index.ts +++ b/index.ts @@ -191,6 +191,14 @@ import { VerifyNamespaceSchema } from "./schemas.js"; +// Import GitLab MCP Extensions +import { + allExtensionTools, + extensionHandlers, + readOnlyExtensionTools, + type ExtensionHandlerContext +} from "./extensions/index.js"; + import { randomUUID } from "crypto"; import { pino } from "pino"; @@ -816,6 +824,8 @@ const allTools = [ description: "Download an uploaded file from a GitLab project by secret and filename", inputSchema: zodToJsonSchema(DownloadAttachmentSchema), }, + // Add GitLab MCP Extensions + ...allExtensionTools, ]; // Define which tools are read-only @@ -863,6 +873,8 @@ const readOnlyTools = [ "list_group_iterations", "get_group_iteration", "download_attachment", + // Add read-only extension tools + ...readOnlyExtensionTools, ]; // Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI @@ -5177,6 +5189,19 @@ server.setRequestHandler(CallToolRequestSchema, async request => { } default: + // Check if it's an extension tool + if (extensionHandlers[request.params.name as keyof typeof extensionHandlers]) { + const handler = extensionHandlers[request.params.name as keyof typeof extensionHandlers]; + const context: ExtensionHandlerContext = { + GITLAB_API_URL, + DEFAULT_FETCH_CONFIG, + getEffectiveProjectId, + handleGitLabError, + logger, + fetch, + }; + return await handler(request.params.arguments, context); + } throw new Error(`Unknown tool: ${request.params.name}`); } } catch (error) { @@ -5386,6 +5411,13 @@ async function runServer() { } } +/** + * Create and return the server instance for testing + */ +export function createServer() { + return server; +} + // 下記の2行を追記 runServer().catch(error => { logger.error("Fatal error in main():", error); diff --git a/test-time-tracking.js b/test-time-tracking.js new file mode 100644 index 0000000..3545736 --- /dev/null +++ b/test-time-tracking.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +// Test script for Time Tracking functionality +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Set environment variables +process.env.GITLAB_PERSONAL_ACCESS_TOKEN = 'glpat-Kz5k0wGIn-H7ZGyd5Ea9Om86MQp1OjZoaGIK.01.100lj5xfz'; +process.env.GITLAB_API_URL = 'https://gitlab.com/api/v4'; +process.env.LOG_LEVEL = 'info'; + +console.log('🧪 Testing GitLab MCP Time Tracking Extensions...\n'); + +// Test 1: List available tools +console.log('📋 Test 1: Listing available tools...'); +const listToolsMessage = { + jsonrpc: "2.0", + id: 1, + method: "tools/list" +}; + +const serverPath = join(__dirname, 'build', 'index.js'); +const server = spawn('node', [serverPath], { + stdio: ['pipe', 'pipe', 'pipe'], + env: process.env +}); + +let output = ''; +let errorOutput = ''; + +server.stdout.on('data', (data) => { + output += data.toString(); +}); + +server.stderr.on('data', (data) => { + errorOutput += data.toString(); +}); + +server.on('close', (code) => { + console.log(`Server exited with code ${code}`); + + if (errorOutput) { + console.log('📝 Server logs:'); + console.log(errorOutput); + } + + if (output) { + try { + const response = JSON.parse(output); + if (response.result && response.result.tools) { + const timeTrackingTools = response.result.tools.filter(tool => + tool.name.includes('time') || + ['add_time_spent', 'get_time_tracking', 'update_time_estimate', 'list_time_entries', 'delete_time_entry'].includes(tool.name) + ); + + console.log(`✅ Found ${timeTrackingTools.length} Time Tracking tools:`); + timeTrackingTools.forEach(tool => { + console.log(` - ${tool.name}: ${tool.description}`); + }); + } else { + console.log('❌ No tools found in response'); + } + } catch (e) { + console.log('❌ Failed to parse server response:', e.message); + console.log('Raw output:', output); + } + } else { + console.log('❌ No output received from server'); + } +}); + +// Send the request +server.stdin.write(JSON.stringify(listToolsMessage) + '\n'); +server.stdin.end(); + +// Timeout after 10 seconds +setTimeout(() => { + console.log('⏰ Test timeout - killing server'); + server.kill(); +}, 10000); \ No newline at end of file