diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c0b5d..090b71d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2025-01-26 + +### Added +- **Enhanced `get_pull_request_diff` with filtering capabilities**: + - Added `include_patterns` parameter to filter diff by file patterns (whitelist) + - Added `exclude_patterns` parameter to exclude files from diff (blacklist) + - Added `file_path` parameter to get diff for a specific file only + - Patterns support standard glob syntax (e.g., `*.js`, `src/**/*.res`, `node_modules/**`) + - Response includes filtering metadata showing total files, included/excluded counts, and excluded file list +- Added `minimatch` dependency for glob pattern matching +- Created `DiffParser` utility class for parsing and filtering unified diff format + +### Changed +- Modified `get_pull_request_diff` tool to support optional filtering without breaking existing usage +- Updated tool definition and type guards to include new optional parameters +- Enhanced documentation with comprehensive examples of filtering usage + ## [0.6.1] - 2025-01-26 ### Added diff --git a/README.md b/README.md index e85bdfc..6ba0ebd 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,10 @@ This tool is particularly useful for: ### Get Pull Request Diff +Get the diff/changes for a pull request with optional filtering capabilities: + ```typescript +// Get full diff (default behavior) { "tool": "get_pull_request_diff", "arguments": { @@ -431,6 +434,75 @@ This tool is particularly useful for: "context_lines": 5 // Optional (default: 3) } } + +// Exclude specific file types +{ + "tool": "get_pull_request_diff", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "exclude_patterns": ["*.lock", "*.svg", "node_modules/**", "*.min.js"] + } +} + +// Include only specific file types +{ + "tool": "get_pull_request_diff", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "include_patterns": ["*.res", "*.resi", "src/**/*.js"] + } +} + +// Get diff for a specific file only +{ + "tool": "get_pull_request_diff", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "file_path": "src/components/Button.res" + } +} + +// Combine filters +{ + "tool": "get_pull_request_diff", + "arguments": { + "workspace": "PROJ", + "repository": "my-repo", + "pull_request_id": 123, + "include_patterns": ["src/**/*"], + "exclude_patterns": ["*.test.js", "*.spec.js"] + } +} +``` + +**Filtering Options:** +- `include_patterns`: Array of glob patterns to include (whitelist) +- `exclude_patterns`: Array of glob patterns to exclude (blacklist) +- `file_path`: Get diff for a specific file only +- Patterns support standard glob syntax (e.g., `*.js`, `src/**/*.res`, `!test/**`) + +**Response includes filtering metadata:** +```json +{ + "message": "Pull request diff retrieved successfully", + "pull_request_id": 123, + "diff": "..filtered diff content..", + "filter_metadata": { + "total_files": 15, + "included_files": 12, + "excluded_files": 3, + "excluded_file_list": ["package-lock.json", "logo.svg", "yarn.lock"], + "filters_applied": { + "exclude_patterns": ["*.lock", "*.svg"] + } + } +} ``` ### Approve Pull Request diff --git a/package-lock.json b/package-lock.json index 06b718d..66faeeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,28 @@ { - "name": "bitbucket-mcp-server", - "version": "1.0.0", + "name": "@nexus2520/bitbucket-mcp-server", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "bitbucket-mcp-server", - "version": "1.0.0", - "license": "ISC", + "name": "@nexus2520/bitbucket-mcp-server", + "version": "0.7.0", + "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", - "axios": "^1.9.0" + "axios": "^1.9.0", + "minimatch": "^9.0.3" + }, + "bin": { + "bitbucket-mcp-server": "build/index.js" }, "devDependencies": { + "@types/minimatch": "^5.1.2", "@types/node": "^22.15.29", "typescript": "^5.8.3" + }, + "engines": { + "node": ">=16.0.0" } }, "node_modules/@modelcontextprotocol/sdk": { @@ -38,6 +46,12 @@ "node": ">=18" } }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true + }, "node_modules/@types/node": { "version": "22.15.29", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.29.tgz", @@ -89,6 +103,11 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, "node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -108,6 +127,14 @@ "node": ">=18" } }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -684,6 +711,20 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index 27d35b4..17c105e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nexus2520/bitbucket-mcp-server", - "version": "0.6.1", + "version": "0.7.0", "description": "MCP server for Bitbucket API integration - supports both Cloud and Server", "type": "module", "main": "./build/index.js", @@ -44,9 +44,11 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", - "axios": "^1.9.0" + "axios": "^1.9.0", + "minimatch": "^9.0.3" }, "devDependencies": { + "@types/minimatch": "^5.1.2", "@types/node": "^22.15.29", "typescript": "^5.8.3" } diff --git a/src/handlers/review-handlers.ts b/src/handlers/review-handlers.ts index 68a1ad3..eae3b49 100644 --- a/src/handlers/review-handlers.ts +++ b/src/handlers/review-handlers.ts @@ -5,6 +5,7 @@ import { isApprovePullRequestArgs, isRequestChangesArgs } from '../types/guards.js'; +import { DiffParser } from '../utils/diff-parser.js'; export class ReviewHandlers { constructor( @@ -20,7 +21,15 @@ export class ReviewHandlers { ); } - const { workspace, repository, pull_request_id, context_lines = 3 } = args; + const { + workspace, + repository, + pull_request_id, + context_lines = 3, + include_patterns, + exclude_patterns, + file_path + } = args; try { let apiPath: string; @@ -39,17 +48,76 @@ export class ReviewHandlers { // For diff, we want the raw text response config.headers = { 'Accept': 'text/plain' }; - const diff = await this.apiClient.makeRequest('get', apiPath, undefined, config); + const rawDiff = await this.apiClient.makeRequest('get', apiPath, undefined, config); + + // Check if filtering is needed + const needsFiltering = file_path || include_patterns || exclude_patterns; + + if (!needsFiltering) { + // Return raw diff without filtering + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + message: 'Pull request diff retrieved successfully', + pull_request_id, + diff: rawDiff + }, null, 2), + }, + ], + }; + } + + // Apply filtering + const diffParser = new DiffParser(); + const sections = diffParser.parseDiffIntoSections(rawDiff); + + const filterOptions = { + includePatterns: include_patterns, + excludePatterns: exclude_patterns, + filePath: file_path + }; + + const filteredResult = diffParser.filterSections(sections, filterOptions); + const filteredDiff = diffParser.reconstructDiff(filteredResult.sections); + + // Build response with filtering metadata + const response: any = { + message: 'Pull request diff retrieved successfully', + pull_request_id, + diff: filteredDiff + }; + + // Add filter metadata + if (filteredResult.metadata.excludedFiles > 0 || file_path || include_patterns || exclude_patterns) { + response.filter_metadata = { + total_files: filteredResult.metadata.totalFiles, + included_files: filteredResult.metadata.includedFiles, + excluded_files: filteredResult.metadata.excludedFiles + }; + + if (filteredResult.metadata.excludedFileList.length > 0) { + response.filter_metadata.excluded_file_list = filteredResult.metadata.excludedFileList; + } + + response.filter_metadata.filters_applied = {}; + if (file_path) { + response.filter_metadata.filters_applied.file_path = file_path; + } + if (include_patterns) { + response.filter_metadata.filters_applied.include_patterns = include_patterns; + } + if (exclude_patterns) { + response.filter_metadata.filters_applied.exclude_patterns = exclude_patterns; + } + } return { content: [ { type: 'text', - text: JSON.stringify({ - message: 'Pull request diff retrieved successfully', - pull_request_id, - diff: diff - }, null, 2), + text: JSON.stringify(response, null, 2), }, ], }; diff --git a/src/index.ts b/src/index.ts index ca997bc..ea076f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,7 +40,7 @@ class BitbucketMCPServer { this.server = new Server( { name: 'bitbucket-mcp-server', - version: '0.6.1', + version: '0.7.0', }, { capabilities: { diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts index 1ea756b..a39c29a 100644 --- a/src/tools/definitions.ts +++ b/src/tools/definitions.ts @@ -274,7 +274,7 @@ export const toolDefinitions = [ }, { name: 'get_pull_request_diff', - description: 'Get the diff/changes for a pull request', + description: 'Get the diff/changes for a pull request with optional filtering', inputSchema: { type: 'object', properties: { @@ -294,6 +294,20 @@ export const toolDefinitions = [ type: 'number', description: 'Number of context lines around changes (optional, default: 3)', }, + include_patterns: { + type: 'array', + items: { type: 'string' }, + description: 'Array of glob patterns to include (e.g., ["*.res", "src/**/*.js"]) (optional)', + }, + exclude_patterns: { + type: 'array', + items: { type: 'string' }, + description: 'Array of glob patterns to exclude (e.g., ["*.lock", "*.svg"]) (optional)', + }, + file_path: { + type: 'string', + description: 'Specific file path to get diff for (e.g., "src/index.ts") (optional)', + }, }, required: ['workspace', 'repository', 'pull_request_id'], }, diff --git a/src/types/guards.ts b/src/types/guards.ts index 4f059e3..0b31fc5 100644 --- a/src/types/guards.ts +++ b/src/types/guards.ts @@ -152,13 +152,19 @@ export const isGetPullRequestDiffArgs = ( repository: string; pull_request_id: number; context_lines?: number; + include_patterns?: string[]; + exclude_patterns?: string[]; + file_path?: string; } => typeof args === 'object' && args !== null && typeof args.workspace === 'string' && typeof args.repository === 'string' && typeof args.pull_request_id === 'number' && - (args.context_lines === undefined || typeof args.context_lines === 'number'); + (args.context_lines === undefined || typeof args.context_lines === 'number') && + (args.include_patterns === undefined || (Array.isArray(args.include_patterns) && args.include_patterns.every((p: any) => typeof p === 'string'))) && + (args.exclude_patterns === undefined || (Array.isArray(args.exclude_patterns) && args.exclude_patterns.every((p: any) => typeof p === 'string'))) && + (args.file_path === undefined || typeof args.file_path === 'string'); export const isApprovePullRequestArgs = ( args: any diff --git a/src/utils/diff-parser.ts b/src/utils/diff-parser.ts new file mode 100644 index 0000000..2e10c45 --- /dev/null +++ b/src/utils/diff-parser.ts @@ -0,0 +1,208 @@ +import { minimatch } from 'minimatch'; + +export interface DiffSection { + filePath: string; + oldPath?: string; // For renamed files + content: string; + isNew: boolean; + isDeleted: boolean; + isRenamed: boolean; + isBinary: boolean; +} + +export interface FilterOptions { + includePatterns?: string[]; + excludePatterns?: string[]; + filePath?: string; +} + +export interface FilteredResult { + sections: DiffSection[]; + metadata: { + totalFiles: number; + includedFiles: number; + excludedFiles: number; + excludedFileList: string[]; + }; +} + +export class DiffParser { + /** + * Parse a unified diff into file sections + */ + parseDiffIntoSections(diff: string): DiffSection[] { + const sections: DiffSection[] = []; + + // Split by file boundaries - handle both formats + const fileChunks = diff.split(/(?=^diff --git)/gm).filter(chunk => chunk.trim()); + + for (const chunk of fileChunks) { + const section = this.parseFileSection(chunk); + if (section) { + sections.push(section); + } + } + + return sections; + } + + /** + * Parse a single file section from the diff + */ + private parseFileSection(chunk: string): DiffSection | null { + const lines = chunk.split('\n'); + if (lines.length === 0) return null; + + // Extract file paths from the diff header + let filePath = ''; + let oldPath: string | undefined; + let isNew = false; + let isDeleted = false; + let isRenamed = false; + let isBinary = false; + + // Look for diff --git line - handle both standard and Bitbucket Server formats + const gitDiffMatch = lines[0].match(/^diff --git (?:a\/|src:\/\/)(.+?) (?:b\/|dst:\/\/)(.+?)$/); + if (gitDiffMatch) { + const [, aPath, bPath] = gitDiffMatch; + filePath = bPath; + + // Check subsequent lines for file status + for (let i = 1; i < Math.min(lines.length, 10); i++) { + const line = lines[i]; + + if (line.startsWith('new file mode')) { + isNew = true; + } else if (line.startsWith('deleted file mode')) { + isDeleted = true; + filePath = aPath; // Use the original path for deleted files + } else if (line.startsWith('rename from')) { + isRenamed = true; + oldPath = line.replace('rename from ', ''); + } else if (line.includes('Binary files') && line.includes('differ')) { + isBinary = true; + } else if (line.startsWith('--- ')) { + // Alternative way to detect new/deleted + if (line.includes('/dev/null')) { + isNew = true; + } + } else if (line.startsWith('+++ ')) { + if (line.includes('/dev/null')) { + isDeleted = true; + } + // Extract path from +++ line if needed - handle both formats + const match = line.match(/^\+\+\+ (?:b\/|dst:\/\/)(.+)$/); + if (match && !filePath) { + filePath = match[1]; + } + } + } + } + + // Fallback: try to extract from --- and +++ lines + if (!filePath) { + for (const line of lines) { + if (line.startsWith('+++ ')) { + const match = line.match(/^\+\+\+ (?:b\/|dst:\/\/)(.+)$/); + if (match) { + filePath = match[1]; + break; + } + } else if (line.startsWith('--- ')) { + const match = line.match(/^--- (?:a\/|src:\/\/)(.+)$/); + if (match) { + filePath = match[1]; + } + } + } + } + + if (!filePath) return null; + + return { + filePath, + oldPath, + content: chunk, + isNew, + isDeleted, + isRenamed, + isBinary + }; + } + + /** + * Apply filters to diff sections + */ + filterSections(sections: DiffSection[], options: FilterOptions): FilteredResult { + const excludedFileList: string[] = []; + let filteredSections = sections; + + // If specific file path is requested, only keep that file + if (options.filePath) { + filteredSections = sections.filter(section => + section.filePath === options.filePath || + section.oldPath === options.filePath + ); + + // Track excluded files + sections.forEach(section => { + if (section.filePath !== options.filePath && + section.oldPath !== options.filePath) { + excludedFileList.push(section.filePath); + } + }); + } else { + // Apply exclude patterns first (blacklist) + if (options.excludePatterns && options.excludePatterns.length > 0) { + filteredSections = filteredSections.filter(section => { + const shouldExclude = options.excludePatterns!.some(pattern => + minimatch(section.filePath, pattern, { matchBase: true }) + ); + + if (shouldExclude) { + excludedFileList.push(section.filePath); + return false; + } + return true; + }); + } + + // Apply include patterns if specified (whitelist) + if (options.includePatterns && options.includePatterns.length > 0) { + filteredSections = filteredSections.filter(section => { + const shouldInclude = options.includePatterns!.some(pattern => + minimatch(section.filePath, pattern, { matchBase: true }) + ); + + if (!shouldInclude) { + excludedFileList.push(section.filePath); + return false; + } + return true; + }); + } + } + + return { + sections: filteredSections, + metadata: { + totalFiles: sections.length, + includedFiles: filteredSections.length, + excludedFiles: sections.length - filteredSections.length, + excludedFileList + } + }; + } + + /** + * Reconstruct a unified diff from filtered sections + */ + reconstructDiff(sections: DiffSection[]): string { + if (sections.length === 0) { + return ''; + } + + // Join all sections with proper spacing + return sections.map(section => section.content).join('\n'); + } +}