feat(diff): add filtering capabilities to get_pull_request_diff

- Add include_patterns, exclude_patterns, and file_path parameters to get_pull_request_diff
- Implement a diff parser to filter the diff based on the provided patterns
- Add minimatch dependency for glob pattern matching
- Update documentation and changelog
This commit is contained in:
pdogra1299 2025-06-26 20:17:33 +05:30
parent 793355bdaa
commit d649a116fe
9 changed files with 446 additions and 18 deletions

View file

@ -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/), 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). 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 ## [0.6.1] - 2025-01-26
### Added ### Added

View file

@ -421,7 +421,10 @@ This tool is particularly useful for:
### Get Pull Request Diff ### Get Pull Request Diff
Get the diff/changes for a pull request with optional filtering capabilities:
```typescript ```typescript
// Get full diff (default behavior)
{ {
"tool": "get_pull_request_diff", "tool": "get_pull_request_diff",
"arguments": { "arguments": {
@ -431,6 +434,75 @@ This tool is particularly useful for:
"context_lines": 5 // Optional (default: 3) "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 ### Approve Pull Request

53
package-lock.json generated
View file

@ -1,20 +1,28 @@
{ {
"name": "bitbucket-mcp-server", "name": "@nexus2520/bitbucket-mcp-server",
"version": "1.0.0", "version": "0.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bitbucket-mcp-server", "name": "@nexus2520/bitbucket-mcp-server",
"version": "1.0.0", "version": "0.7.0",
"license": "ISC", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1", "@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": { "devDependencies": {
"@types/minimatch": "^5.1.2",
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"typescript": "^5.8.3" "typescript": "^5.8.3"
},
"engines": {
"node": ">=16.0.0"
} }
}, },
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
@ -38,6 +46,12 @@
"node": ">=18" "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": { "node_modules/@types/node": {
"version": "22.15.29", "version": "22.15.29",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.29.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.29.tgz",
@ -89,6 +103,11 @@
"proxy-from-env": "^1.1.0" "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": { "node_modules/body-parser": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
@ -108,6 +127,14 @@
"node": ">=18" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -684,6 +711,20 @@
"node": ">= 0.6" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "@nexus2520/bitbucket-mcp-server", "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", "description": "MCP server for Bitbucket API integration - supports both Cloud and Server",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
@ -44,9 +44,11 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.12.1",
"axios": "^1.9.0" "axios": "^1.9.0",
"minimatch": "^9.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/minimatch": "^5.1.2",
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }

View file

@ -5,6 +5,7 @@ import {
isApprovePullRequestArgs, isApprovePullRequestArgs,
isRequestChangesArgs isRequestChangesArgs
} from '../types/guards.js'; } from '../types/guards.js';
import { DiffParser } from '../utils/diff-parser.js';
export class ReviewHandlers { export class ReviewHandlers {
constructor( 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 { try {
let apiPath: string; let apiPath: string;
@ -39,8 +48,13 @@ export class ReviewHandlers {
// For diff, we want the raw text response // For diff, we want the raw text response
config.headers = { 'Accept': 'text/plain' }; config.headers = { 'Accept': 'text/plain' };
const diff = await this.apiClient.makeRequest<string>('get', apiPath, undefined, config); const rawDiff = await this.apiClient.makeRequest<string>('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 { return {
content: [ content: [
{ {
@ -48,11 +62,65 @@ export class ReviewHandlers {
text: JSON.stringify({ text: JSON.stringify({
message: 'Pull request diff retrieved successfully', message: 'Pull request diff retrieved successfully',
pull_request_id, pull_request_id,
diff: diff diff: rawDiff
}, null, 2), }, 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(response, null, 2),
},
],
};
} catch (error) { } catch (error) {
return this.apiClient.handleApiError(error, `getting diff for pull request ${pull_request_id} in ${workspace}/${repository}`); return this.apiClient.handleApiError(error, `getting diff for pull request ${pull_request_id} in ${workspace}/${repository}`);
} }

View file

@ -40,7 +40,7 @@ class BitbucketMCPServer {
this.server = new Server( this.server = new Server(
{ {
name: 'bitbucket-mcp-server', name: 'bitbucket-mcp-server',
version: '0.6.1', version: '0.7.0',
}, },
{ {
capabilities: { capabilities: {

View file

@ -274,7 +274,7 @@ export const toolDefinitions = [
}, },
{ {
name: 'get_pull_request_diff', 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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@ -294,6 +294,20 @@ export const toolDefinitions = [
type: 'number', type: 'number',
description: 'Number of context lines around changes (optional, default: 3)', 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'], required: ['workspace', 'repository', 'pull_request_id'],
}, },

View file

@ -152,13 +152,19 @@ export const isGetPullRequestDiffArgs = (
repository: string; repository: string;
pull_request_id: number; pull_request_id: number;
context_lines?: number; context_lines?: number;
include_patterns?: string[];
exclude_patterns?: string[];
file_path?: string;
} => } =>
typeof args === 'object' && typeof args === 'object' &&
args !== null && args !== null &&
typeof args.workspace === 'string' && typeof args.workspace === 'string' &&
typeof args.repository === 'string' && typeof args.repository === 'string' &&
typeof args.pull_request_id === 'number' && 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 = ( export const isApprovePullRequestArgs = (
args: any args: any

208
src/utils/diff-parser.ts Normal file
View file

@ -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');
}
}