This is page 1 of 4. Use http://codebase.md/aashari/mcp-server-atlassian-bitbucket?page={x} to view the full context. # Directory Structure ``` ├── .env.example ├── .github │ ├── dependabot.yml │ └── workflows │ ├── ci-dependabot-auto-merge.yml │ ├── ci-dependency-check.yml │ └── ci-semantic-release.yml ├── .gitignore ├── .gitkeep ├── .npmignore ├── .npmrc ├── .prettierrc ├── .releaserc.json ├── .trigger-ci ├── CHANGELOG.md ├── eslint.config.mjs ├── jest.setup.js ├── package-lock.json ├── package.json ├── README.md ├── scripts │ ├── ensure-executable.js │ ├── package.json │ └── update-version.js ├── src │ ├── cli │ │ ├── atlassian.diff.cli.ts │ │ ├── atlassian.pullrequests.cli.test.ts │ │ ├── atlassian.pullrequests.cli.ts │ │ ├── atlassian.repositories.cli.test.ts │ │ ├── atlassian.repositories.cli.ts │ │ ├── atlassian.search.cli.test.ts │ │ ├── atlassian.search.cli.ts │ │ ├── atlassian.workspaces.cli.test.ts │ │ ├── atlassian.workspaces.cli.ts │ │ └── index.ts │ ├── controllers │ │ ├── atlassian.diff.controller.ts │ │ ├── atlassian.diff.formatter.ts │ │ ├── atlassian.pullrequests.approve.controller.ts │ │ ├── atlassian.pullrequests.base.controller.ts │ │ ├── atlassian.pullrequests.comments.controller.ts │ │ ├── atlassian.pullrequests.controller.test.ts │ │ ├── atlassian.pullrequests.controller.ts │ │ ├── atlassian.pullrequests.create.controller.ts │ │ ├── atlassian.pullrequests.formatter.ts │ │ ├── atlassian.pullrequests.get.controller.ts │ │ ├── atlassian.pullrequests.list.controller.ts │ │ ├── atlassian.pullrequests.reject.controller.ts │ │ ├── atlassian.pullrequests.update.controller.ts │ │ ├── atlassian.repositories.branch.controller.ts │ │ ├── atlassian.repositories.commit.controller.ts │ │ ├── atlassian.repositories.content.controller.ts │ │ ├── atlassian.repositories.controller.test.ts │ │ ├── atlassian.repositories.details.controller.ts │ │ ├── atlassian.repositories.formatter.ts │ │ ├── atlassian.repositories.list.controller.ts │ │ ├── atlassian.search.code.controller.ts │ │ ├── atlassian.search.content.controller.ts │ │ ├── atlassian.search.controller.test.ts │ │ ├── atlassian.search.controller.ts │ │ ├── atlassian.search.formatter.ts │ │ ├── atlassian.search.pullrequests.controller.ts │ │ ├── atlassian.search.repositories.controller.ts │ │ ├── atlassian.workspaces.controller.test.ts │ │ ├── atlassian.workspaces.controller.ts │ │ └── atlassian.workspaces.formatter.ts │ ├── index.ts │ ├── services │ │ ├── vendor.atlassian.pullrequests.service.ts │ │ ├── vendor.atlassian.pullrequests.test.ts │ │ ├── vendor.atlassian.pullrequests.types.ts │ │ ├── vendor.atlassian.repositories.diff.service.ts │ │ ├── vendor.atlassian.repositories.diff.types.ts │ │ ├── vendor.atlassian.repositories.service.test.ts │ │ ├── vendor.atlassian.repositories.service.ts │ │ ├── vendor.atlassian.repositories.types.ts │ │ ├── vendor.atlassian.search.service.ts │ │ ├── vendor.atlassian.search.types.ts │ │ ├── vendor.atlassian.workspaces.service.ts │ │ ├── vendor.atlassian.workspaces.test.ts │ │ └── vendor.atlassian.workspaces.types.ts │ ├── tools │ │ ├── atlassian.diff.tool.ts │ │ ├── atlassian.diff.types.ts │ │ ├── atlassian.pullrequests.tool.ts │ │ ├── atlassian.pullrequests.types.test.ts │ │ ├── atlassian.pullrequests.types.ts │ │ ├── atlassian.repositories.tool.ts │ │ ├── atlassian.repositories.types.ts │ │ ├── atlassian.search.tool.ts │ │ ├── atlassian.search.types.ts │ │ ├── atlassian.workspaces.tool.ts │ │ └── atlassian.workspaces.types.ts │ ├── types │ │ └── common.types.ts │ └── utils │ ├── adf.util.test.ts │ ├── adf.util.ts │ ├── atlassian.util.ts │ ├── bitbucket-error-detection.test.ts │ ├── cli.test.util.ts │ ├── config.util.test.ts │ ├── config.util.ts │ ├── constants.util.ts │ ├── defaults.util.ts │ ├── diff.util.ts │ ├── error-handler.util.test.ts │ ├── error-handler.util.ts │ ├── error.util.test.ts │ ├── error.util.ts │ ├── formatter.util.ts │ ├── logger.util.ts │ ├── markdown.util.test.ts │ ├── markdown.util.ts │ ├── pagination.util.ts │ ├── path.util.test.ts │ ├── path.util.ts │ ├── query.util.ts │ ├── shell.util.ts │ ├── transport.util.test.ts │ ├── transport.util.ts │ └── workspace.util.ts ├── STYLE_GUIDE.md └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitkeep: -------------------------------------------------------------------------------- ``` ``` -------------------------------------------------------------------------------- /.trigger-ci: -------------------------------------------------------------------------------- ``` # CI/CD trigger Thu Sep 18 00:41:08 WIB 2025 ``` -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- ``` { "singleQuote": true, "semi": true, "useTabs": true, "tabWidth": 4, "printWidth": 80, "trailingComma": "all" } ``` -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- ``` # This file is for local development only # The CI/CD workflow will create its own .npmrc files # For npm registry registry=https://registry.npmjs.org/ # GitHub Packages configuration removed ``` -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- ``` # Source code src/ *.ts !*.d.ts # Tests *.test.ts *.test.js __tests__/ coverage/ jest.config.js # Development files .github/ .git/ .gitignore .eslintrc .eslintrc.js .eslintignore .prettierrc .prettierrc.js tsconfig.json *.tsbuildinfo # Editor directories .idea/ .vscode/ *.swp *.swo # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # CI/CD .travis.yml # Runtime data .env .env.* ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependency directories node_modules/ .npm # TypeScript output dist/ build/ *.tsbuildinfo # Coverage directories coverage/ .nyc_output/ # Environment variables .env .env.local .env.*.local # Log files logs/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* # IDE files .idea/ .vscode/ *.sublime-project *.sublime-workspace .project .classpath .settings/ .DS_Store # Temp directories .tmp/ temp/ # Backup files *.bak # Editor directories and files *.suo *.ntvs* *.njsproj *.sln *.sw? # macOS .DS_Store # Misc .yarn-integrity ``` -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- ``` # Enable debug logging DEBUG=false # Atlassian Configuration - Method 1 (Standard Atlassian - recommended) # Use this for general Atlassian services (works with Bitbucket, Jira, Confluence) ATLASSIAN_SITE_NAME=your-instance [email protected] ATLASSIAN_API_TOKEN= # Atlassian Configuration - Method 2 (Bitbucket-specific alternative) # Use this if you prefer Bitbucket username + app password authentication # ATLASSIAN_BITBUCKET_USERNAME=your-bitbucket-username # ATLASSIAN_BITBUCKET_APP_PASSWORD=your-app-password # Optional: Default workspace for commands # BITBUCKET_DEFAULT_WORKSPACE=your-main-workspace-slug ``` -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- ```json { "branches": ["main"], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", "@semantic-release/changelog", [ "@semantic-release/exec", { "prepareCmd": "node scripts/update-version.js ${nextRelease.version} && npm run build && chmod +x dist/index.js" } ], [ "@semantic-release/npm", { "npmPublish": true, "pkgRoot": "." } ], [ "@semantic-release/git", { "assets": [ "package.json", "CHANGELOG.md", "src/index.ts", "src/cli/index.ts" ], "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" } ], "@semantic-release/github" ] } ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Connect AI to Your Bitbucket Repositories Transform how you work with Bitbucket by connecting Claude, Cursor AI, and other AI assistants directly to your repositories, pull requests, and code. Get instant insights, automate code reviews, and streamline your development workflow. [](https://www.npmjs.com/package/@aashari/mcp-server-atlassian-bitbucket) ## What You Can Do ✅ **Ask AI about your code**: "What's the latest commit in my main repository?" ✅ **Get PR insights**: "Show me all open pull requests that need review" ✅ **Search your codebase**: "Find all JavaScript files that use the authentication function" ✅ **Review code changes**: "Compare the differences between my feature branch and main" ✅ **Manage pull requests**: "Create a PR for my new-feature branch" ✅ **Automate workflows**: "Add a comment to PR #123 with the test results" ## Perfect For - **Developers** who want AI assistance with code reviews and repository management - **Team Leads** needing quick insights into project status and pull request activity - **DevOps Engineers** automating repository workflows and branch management - **Anyone** who wants to interact with Bitbucket using natural language ## Quick Start Get up and running in 2 minutes: ### 1. Get Your Bitbucket Credentials > ⚠️ **IMPORTANT**: Bitbucket App Passwords are being deprecated and will be removed by **June 2026**. We recommend using **Scoped API Tokens** for new setups. #### Option A: Scoped API Token (Recommended - Future-Proof) **Bitbucket is deprecating app passwords**. Use the new scoped API tokens instead: 1. Go to [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens) 2. Click **"Create API token with scopes"** 3. Select **"Bitbucket"** as the product 4. Choose the appropriate scopes: - **For read-only access**: `repository`, `workspace` - **For full functionality**: `repository`, `workspace`, `pullrequest` 5. Copy the generated token (starts with `ATATT`) 6. Use with your Atlassian email as the username #### Option B: App Password (Legacy - Will be deprecated) Generate a Bitbucket App Password (legacy method): 1. Go to [Bitbucket App Passwords](https://bitbucket.org/account/settings/app-passwords/) 2. Click "Create app password" 3. Give it a name like "AI Assistant" 4. Select these permissions: - **Workspaces**: Read - **Repositories**: Read (and Write if you want AI to create PRs/comments) - **Pull Requests**: Read (and Write for PR management) ### 2. Try It Instantly ```bash # Set your credentials (choose one method) # Method 1: Scoped API Token (recommended - future-proof) export ATLASSIAN_USER_EMAIL="[email protected]" export ATLASSIAN_API_TOKEN="your_scoped_api_token" # Token starting with ATATT # OR Method 2: Legacy App Password (will be deprecated June 2026) export ATLASSIAN_BITBUCKET_USERNAME="your_username" export ATLASSIAN_BITBUCKET_APP_PASSWORD="your_app_password" # List your workspaces npx -y @aashari/mcp-server-atlassian-bitbucket ls-workspaces # List repositories in your workspace npx -y @aashari/mcp-server-atlassian-bitbucket ls-repos --workspace-slug your-workspace # Get details about a specific repository npx -y @aashari/mcp-server-atlassian-bitbucket get-repo --workspace-slug your-workspace --repo-slug your-repo ``` ## Connect to AI Assistants ### For Claude Desktop Users Add this to your Claude configuration file (`~/.claude/claude_desktop_config.json`): **Option 1: Scoped API Token (recommended - future-proof)** ```json { "mcpServers": { "bitbucket": { "command": "npx", "args": ["-y", "@aashari/mcp-server-atlassian-bitbucket"], "env": { "ATLASSIAN_USER_EMAIL": "[email protected]", "ATLASSIAN_API_TOKEN": "your_scoped_api_token" } } } } ``` **Option 2: Legacy App Password (will be deprecated June 2026)** ```json { "mcpServers": { "bitbucket": { "command": "npx", "args": ["-y", "@aashari/mcp-server-atlassian-bitbucket"], "env": { "ATLASSIAN_BITBUCKET_USERNAME": "your_username", "ATLASSIAN_BITBUCKET_APP_PASSWORD": "your_app_password" } } } } ``` Restart Claude Desktop, and you'll see "🔗 bitbucket" in the status bar. ### For Other AI Assistants Most AI assistants support MCP. Install the server globally: ```bash npm install -g @aashari/mcp-server-atlassian-bitbucket ``` Then configure your AI assistant to use the MCP server with STDIO transport. ### Alternative: Configuration File Create `~/.mcp/configs.json` for system-wide configuration: **Option 1: Scoped API Token (recommended - future-proof)** ```json { "bitbucket": { "environments": { "ATLASSIAN_USER_EMAIL": "[email protected]", "ATLASSIAN_API_TOKEN": "your_scoped_api_token", "BITBUCKET_DEFAULT_WORKSPACE": "your_main_workspace" } } } ``` **Option 2: Legacy App Password (will be deprecated June 2026)** ```json { "bitbucket": { "environments": { "ATLASSIAN_BITBUCKET_USERNAME": "your_username", "ATLASSIAN_BITBUCKET_APP_PASSWORD": "your_app_password", "BITBUCKET_DEFAULT_WORKSPACE": "your_main_workspace" } } } ``` **Alternative config keys:** The system also accepts `"atlassian-bitbucket"`, `"@aashari/mcp-server-atlassian-bitbucket"`, or `"mcp-server-atlassian-bitbucket"` instead of `"bitbucket"`. ## Real-World Examples ### 🔍 Explore Your Repositories Ask your AI assistant: - *"List all repositories in my main workspace"* - *"Show me details about the backend-api repository"* - *"What's the commit history for the feature-auth branch?"* - *"Get the content of src/config.js from the main branch"* ### 📋 Manage Pull Requests Ask your AI assistant: - *"Show me all open pull requests that need review"* - *"Get details about pull request #42 including the code changes"* - *"Create a pull request from feature-login to main branch"* - *"Add a comment to PR #15 saying the tests passed"* - *"Approve pull request #33"* ### 🔧 Work with Branches and Code Ask your AI assistant: - *"Compare my feature branch with the main branch"* - *"Create a new branch called hotfix-login from the main branch"* - *"List all branches in the user-service repository"* - *"Show me the differences between commits abc123 and def456"* ### 🔎 Search and Discovery Ask your AI assistant: - *"Search for JavaScript files that contain 'authentication'"* - *"Find all pull requests related to the login feature"* - *"Search for repositories in the mobile project"* - *"Show me code files that use the React framework"* ## Troubleshooting ### "Authentication failed" or "403 Forbidden" 1. **Choose the right authentication method**: - **Standard Atlassian method**: Use your Atlassian account email + API token (works with any Atlassian service) - **Bitbucket-specific method**: Use your Bitbucket username + App password (Bitbucket only) 2. **For Bitbucket App Passwords** (if using Option 2): - Go to [Bitbucket App Passwords](https://bitbucket.org/account/settings/app-passwords/) - Make sure your app password has the right permissions (Workspaces: Read, Repositories: Read, Pull Requests: Read) 3. **For Scoped API Tokens** (recommended): - Go to [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens) - Make sure your token is still active and has the right scopes - Update your `~/.mcp/configs.json` file to use the new scoped API token format: ```json { "@aashari/mcp-server-atlassian-bitbucket": { "environments": { "ATLASSIAN_USER_EMAIL": "[email protected]", "ATLASSIAN_API_TOKEN": "ATATT3xFfGF0..." } } } ``` 4. **Verify your credentials**: ```bash # Test your credentials work npx -y @aashari/mcp-server-atlassian-bitbucket ls-workspaces ``` ### "Workspace not found" or "Repository not found" 1. **Check your workspace slug**: ```bash # List your workspaces to see the correct slugs npx -y @aashari/mcp-server-atlassian-bitbucket ls-workspaces ``` 2. **Use the exact slug from Bitbucket URL**: - If your repo URL is `https://bitbucket.org/myteam/my-repo` - Workspace slug is `myteam` - Repository slug is `my-repo` ### "No default workspace configured" Set a default workspace to avoid specifying it every time: ```bash export BITBUCKET_DEFAULT_WORKSPACE="your-main-workspace-slug" ``` ### Claude Desktop Integration Issues 1. **Restart Claude Desktop** after updating the config file 2. **Check the status bar** for the "🔗 bitbucket" indicator 3. **Verify config file location**: - macOS: `~/.claude/claude_desktop_config.json` - Windows: `%APPDATA%\Claude\claude_desktop_config.json` ### Getting Help If you're still having issues: 1. Run a simple test command to verify everything works 2. Check the [GitHub Issues](https://github.com/aashari/mcp-server-atlassian-bitbucket/issues) for similar problems 3. Create a new issue with your error message and setup details ## Frequently Asked Questions ### What permissions do I need? **For Scoped API Tokens** (recommended): - Your regular Atlassian account with access to Bitbucket - Scoped API token created at [id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens) - Required scopes: `repository`, `workspace` (add `pullrequest` for PR management) **For Bitbucket App Passwords** (legacy - being deprecated): - For **read-only access** (viewing repos, PRs, commits): - Workspaces: Read - Repositories: Read - Pull Requests: Read - For **full functionality** (creating PRs, commenting): - Add "Write" permissions for Repositories and Pull Requests ### Can I use this with private repositories? Yes! This works with both public and private repositories. You just need the appropriate permissions through your Bitbucket App Password. ### Do I need to specify workspace every time? No! Set `BITBUCKET_DEFAULT_WORKSPACE` in your environment or config file, and it will be used automatically when you don't specify one. ### What AI assistants does this work with? Any AI assistant that supports the Model Context Protocol (MCP): - Claude Desktop (most popular) - Cursor AI - Continue.dev - Many others ### Is my data secure? Yes! This tool: - Runs entirely on your local machine - Uses your own Bitbucket credentials - Never sends your data to third parties - Only accesses what you give it permission to access ### Can I use this for multiple Bitbucket accounts? Currently, each installation supports one set of credentials. For multiple accounts, you'd need separate configurations. ## Support Need help? Here's how to get assistance: 1. **Check the troubleshooting section above** - most common issues are covered there 2. **Visit our GitHub repository** for documentation and examples: [github.com/aashari/mcp-server-atlassian-bitbucket](https://github.com/aashari/mcp-server-atlassian-bitbucket) 3. **Report issues** at [GitHub Issues](https://github.com/aashari/mcp-server-atlassian-bitbucket/issues) 4. **Start a discussion** for feature requests or general questions --- *Made with ❤️ for developers who want to bring AI into their Bitbucket workflow.* ``` -------------------------------------------------------------------------------- /scripts/package.json: -------------------------------------------------------------------------------- ```json { "type": "module" } ``` -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- ```yaml version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 10 versioning-strategy: auto labels: - "dependencies" commit-message: prefix: "chore" include: "scope" allow: - dependency-type: "direct" ignore: - dependency-name: "*" update-types: ["version-update:semver-patch"] - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 5 labels: - "dependencies" - "github-actions" ``` -------------------------------------------------------------------------------- /src/utils/atlassian.util.ts: -------------------------------------------------------------------------------- ```typescript /** * Types of content that can be searched in Bitbucket */ export enum ContentType { WIKI = 'wiki', ISSUE = 'issue', PULLREQUEST = 'pullrequest', COMMIT = 'commit', BRANCH = 'branch', TAG = 'tag', } /** * Get the display name for a content type */ export function getContentTypeDisplay(type: ContentType): string { switch (type) { case ContentType.WIKI: return 'Wiki'; case ContentType.ISSUE: return 'Issue'; case ContentType.PULLREQUEST: return 'Pull Request'; case ContentType.COMMIT: return 'Commit'; case ContentType.BRANCH: return 'Branch'; case ContentType.TAG: return 'Tag'; default: return type; } } ``` -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- ```javascript // Jest setup file to suppress console warnings during tests // This improves test output readability while maintaining error visibility const originalConsoleWarn = console.warn; const originalConsoleInfo = console.info; const originalConsoleDebug = console.debug; beforeAll(() => { // Suppress console.warn, console.info, and console.debug during tests // while keeping console.error for actual issues console.warn = jest.fn(); console.info = jest.fn(); console.debug = jest.fn(); }); afterAll(() => { // Restore original console methods console.warn = originalConsoleWarn; console.info = originalConsoleInfo; console.debug = originalConsoleDebug; }); ``` -------------------------------------------------------------------------------- /.github/workflows/ci-dependency-check.yml: -------------------------------------------------------------------------------- ```yaml name: CI - Dependency Check on: schedule: - cron: '0 5 * * 1' # Run at 5 AM UTC every Monday workflow_dispatch: # Allow manual triggering jobs: validate: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v5 with: node-version: '22' cache: 'npm' - name: Install dependencies run: npm ci - name: Run npm audit run: npm audit - name: Check for outdated dependencies run: npm outdated || true - name: Run tests run: npm test - name: Run linting run: npm run lint - name: Build project run: npm run build ``` -------------------------------------------------------------------------------- /src/utils/defaults.util.ts: -------------------------------------------------------------------------------- ```typescript /** * Default values for pagination across the application. * These values should be used consistently throughout the codebase. */ /** * Default page size for all list operations. * This value determines how many items are returned in a single page by default. */ export const DEFAULT_PAGE_SIZE = 25; /** * Apply default values to options object. * This utility ensures that default values are consistently applied. * * @param options Options object that may have some values undefined * @param defaults Default values to apply when options values are undefined * @returns Options object with default values applied * * @example * const options = applyDefaults({ limit: 10 }, { limit: DEFAULT_PAGE_SIZE, includeBranches: true }); * // Result: { limit: 10, includeBranches: true } */ export function applyDefaults<T extends object>( options: Partial<T>, defaults: Partial<T>, ): T { return { ...defaults, ...Object.fromEntries( Object.entries(options).filter(([_, value]) => value !== undefined), ), } as T; } ``` -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- ``` import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; import prettierPlugin from 'eslint-plugin-prettier'; import eslintConfigPrettier from 'eslint-config-prettier'; export default tseslint.config( { ignores: ['node_modules/**', 'dist/**', 'examples/**'], }, eslint.configs.recommended, ...tseslint.configs.recommended, { plugins: { prettier: prettierPlugin, }, rules: { 'prettier/prettier': 'error', indent: ['error', 'tab', { SwitchCase: 1 }], '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_' }, ], }, languageOptions: { parserOptions: { ecmaVersion: 'latest', sourceType: 'module', }, globals: { node: 'readonly', jest: 'readonly', }, }, }, // Special rules for test files { files: ['**/*.test.ts'], rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-require-imports': 'off', '@typescript-eslint/no-unsafe-function-type': 'off', '@typescript-eslint/no-unused-vars': 'off', }, }, eslintConfigPrettier, ); ``` -------------------------------------------------------------------------------- /.github/workflows/ci-dependabot-auto-merge.yml: -------------------------------------------------------------------------------- ```yaml name: CI - Dependabot Auto-merge on: pull_request: branches: [main] permissions: contents: write pull-requests: write checks: read jobs: auto-merge-dependabot: runs-on: ubuntu-latest if: github.actor == 'dependabot[bot]' steps: - name: Checkout code uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v5 with: node-version: '22' cache: 'npm' - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Run linting run: npm run lint - name: Auto-approve PR uses: hmarr/auto-approve-action@v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Enable auto-merge if: success() run: gh pr merge --auto --merge "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` -------------------------------------------------------------------------------- /src/tools/atlassian.workspaces.types.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; /** * Base pagination arguments for all tools */ const PaginationArgs = { limit: z .number() .int() .positive() .max(100) .optional() .describe( 'Maximum number of items to return (1-100). Controls the response size. Defaults to 25 if omitted.', ), cursor: z .string() .optional() .describe( 'Pagination cursor for retrieving the next set of results. Obtained from previous response when more results are available.', ), }; /** * Schema for list-workspaces tool arguments */ export const ListWorkspacesToolArgs = z.object({ /** * Maximum number of workspaces to return and pagination */ ...PaginationArgs, }); export type ListWorkspacesToolArgsType = z.infer<typeof ListWorkspacesToolArgs>; /** * Schema for get-workspace tool arguments */ export const GetWorkspaceToolArgs = z.object({ /** * Workspace slug to retrieve */ workspaceSlug: z .string() .min(1, 'Workspace slug is required') .describe( 'Workspace slug to retrieve detailed information for. Must be a valid workspace slug from your Bitbucket account. Example: "myteam"', ), }); export type GetWorkspaceToolArgsType = z.infer<typeof GetWorkspaceToolArgs>; ``` -------------------------------------------------------------------------------- /src/services/vendor.atlassian.search.types.ts: -------------------------------------------------------------------------------- ```typescript import { ContentType } from '../utils/atlassian.util.js'; /** * Content search parameters */ export interface ContentSearchParams { /** Workspace slug to search in */ workspaceSlug: string; /** Query string to search for */ query: string; /** Maximum number of results to return (default: 25) */ limit?: number; /** Page number for pagination (default: 1) */ page?: number; /** Repository slug to search in (optional) */ repoSlug?: string; /** Type of content to search for (optional) */ contentType?: ContentType; } /** * Generic content search result item */ export interface ContentSearchResultItem { // Most Bitbucket content items will have these fields type?: string; title?: string; name?: string; summary?: string; description?: string; content?: string; created_on?: string; updated_on?: string; links?: { self?: { href: string }; html?: { href: string }; [key: string]: unknown; }; // Allow additional properties as Bitbucket returns different fields per content type [key: string]: unknown; } /** * Content search response */ export interface ContentSearchResponse { size: number; page: number; pagelen: number; values: ContentSearchResultItem[]; next?: string; previous?: string; } ``` -------------------------------------------------------------------------------- /src/utils/constants.util.ts: -------------------------------------------------------------------------------- ```typescript /** * Application constants * * This file contains constants used throughout the application. * Centralizing these values makes them easier to maintain and update. */ /** * Current application version * This should match the version in package.json */ export const VERSION = '1.23.6'; /** * Package name with scope * Used for initialization and identification */ export const PACKAGE_NAME = '@aashari/mcp-server-atlassian-bitbucket'; /** * CLI command name * Used for binary name and CLI help text */ export const CLI_NAME = 'mcp-atlassian-bitbucket'; /** * Network timeout constants (in milliseconds) */ export const NETWORK_TIMEOUTS = { /** Default timeout for API requests (30 seconds) */ DEFAULT_REQUEST_TIMEOUT: 30 * 1000, /** Timeout for large file operations like diffs (60 seconds) */ LARGE_REQUEST_TIMEOUT: 60 * 1000, /** Timeout for search operations (45 seconds) */ SEARCH_REQUEST_TIMEOUT: 45 * 1000, } as const; /** * Data limits to prevent excessive resource consumption (CWE-770) */ export const DATA_LIMITS = { /** Maximum response size in bytes (10MB) */ MAX_RESPONSE_SIZE: 10 * 1024 * 1024, /** Maximum items per page for paginated requests */ MAX_PAGE_SIZE: 100, /** Default page size when not specified */ DEFAULT_PAGE_SIZE: 50, } as const; ``` -------------------------------------------------------------------------------- /scripts/ensure-executable.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; // Use dynamic import meta for ESM compatibility const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); const entryPoint = path.join(rootDir, 'dist', 'index.js'); try { if (fs.existsSync(entryPoint)) { // Ensure the file is executable (cross-platform) const currentMode = fs.statSync(entryPoint).mode; // Check if executable bits are set (user, group, or other) // Mode constants differ slightly across platforms, checking broadly const isExecutable = currentMode & fs.constants.S_IXUSR || currentMode & fs.constants.S_IXGRP || currentMode & fs.constants.S_IXOTH; if (!isExecutable) { // Set permissions to 755 (rwxr-xr-x) if not executable fs.chmodSync(entryPoint, 0o755); console.log( `Made ${path.relative(rootDir, entryPoint)} executable`, ); } else { // console.log(`${path.relative(rootDir, entryPoint)} is already executable`); } } else { // console.warn(`${path.relative(rootDir, entryPoint)} not found, skipping chmod`); } } catch (err) { // console.warn(`Failed to set executable permissions: ${err.message}`); // We use '|| true' in package.json, so no need to exit here } ``` -------------------------------------------------------------------------------- /src/services/vendor.atlassian.repositories.diff.types.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; /** * Parameters for retrieving diffstat between two refs (branches, tags, or commit hashes) */ export const GetDiffstatParamsSchema = z.object({ workspace: z.string().min(1, 'Workspace is required'), repo_slug: z.string().min(1, 'Repository slug is required'), /** e.g., "main..feature" or "hashA..hashB" */ spec: z.string().min(1, 'Diff spec is required'), pagelen: z.number().int().positive().optional(), cursor: z.number().int().positive().optional(), // Bitbucket page-based cursor topic: z.boolean().optional(), }); export type GetDiffstatParams = z.infer<typeof GetDiffstatParamsSchema>; export const GetRawDiffParamsSchema = z.object({ workspace: z.string().min(1), repo_slug: z.string().min(1), spec: z.string().min(1), }); export type GetRawDiffParams = z.infer<typeof GetRawDiffParamsSchema>; /** * Schema for a single file change entry in diffstat */ export const DiffstatFileChangeSchema = z.object({ status: z.string(), old: z .object({ path: z.string(), type: z.string().optional(), }) .nullable() .optional(), new: z .object({ path: z.string(), type: z.string().optional(), }) .nullable() .optional(), lines_added: z.number().optional(), lines_removed: z.number().optional(), }); /** * Schema for diffstat API response (paginated) */ export const DiffstatResponseSchema = z.object({ pagelen: z.number().optional(), values: z.array(DiffstatFileChangeSchema), page: z.number().optional(), size: z.number().optional(), next: z.string().optional(), previous: z.string().optional(), }); export type DiffstatResponse = z.infer<typeof DiffstatResponseSchema>; ``` -------------------------------------------------------------------------------- /src/utils/query.util.ts: -------------------------------------------------------------------------------- ```typescript /** * Utilities for formatting and handling query parameters for the Bitbucket API. * These functions help convert user-friendly query strings into the format expected by Bitbucket's REST API. */ /** * Format a simple text query into Bitbucket's query syntax * Bitbucket API expects query parameters in a specific format for the 'q' parameter * * @param query - The search query string * @param field - Optional field to search in, defaults to 'name' * @returns Formatted query string for Bitbucket API * * @example * // Simple text search (returns: name ~ "vue3") * formatBitbucketQuery("vue3") * * @example * // Already formatted query (returns unchanged: name = "repository") * formatBitbucketQuery("name = \"repository\"") * * @example * // With specific field (returns: description ~ "API") * formatBitbucketQuery("API", "description") */ export function formatBitbucketQuery( query: string, field: string = 'name', ): string { // If the query is empty, return it as is if (!query || query.trim() === '') { return query; } // Regular expression to check if the query already contains operators // like ~, =, !=, >, <, etc., which would indicate it's already formatted const operatorPattern = /[~=!<>]/; // If the query already contains operators, assume it's properly formatted if (operatorPattern.test(query)) { return query; } // If query is quoted, assume it's an exact match if (query.startsWith('"') && query.endsWith('"')) { return `${field} ~ ${query}`; } // Format simple text as a field search with fuzzy match // Wrap in double quotes to handle spaces and special characters return `${field} ~ "${query}"`; } ``` -------------------------------------------------------------------------------- /src/types/common.types.ts: -------------------------------------------------------------------------------- ```typescript /** * Common type definitions shared across controllers. * These types provide a standard interface for controller interactions. * Centralized here to ensure consistency across the codebase. */ /** * Common pagination information for API responses. * This is used for providing consistent pagination details internally. * Its formatted representation will be included directly in the content string. */ export interface ResponsePagination { /** * Cursor for the next page of results, if available. * This should be passed to subsequent requests to retrieve the next page. */ nextCursor?: string; /** * Whether more results are available beyond the current page. * When true, clients should use the nextCursor to retrieve more results. */ hasMore: boolean; /** * The number of items in the current result set. * This helps clients track how many items they've received. */ count?: number; /** * The total number of items available across all pages, if known. * Note: Not all APIs provide this. Check the specific API/tool documentation. */ total?: number; /** * Page number for page-based pagination. */ page?: number; /** * Page size for page-based pagination. */ size?: number; } /** * Common response structure for controller operations. * All controller methods should return this structure. */ export interface ControllerResponse { /** * Formatted content to be displayed to the user. * Contains a comprehensive Markdown-formatted string that includes all information: * - Primary content (e.g., list items, details) * - Any metadata (previously in metadata field) * - Pagination information (previously in pagination field) */ content: string; } ``` -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- ```typescript import { Command } from 'commander'; import { Logger } from '../utils/logger.util.js'; import { VERSION, CLI_NAME } from '../utils/constants.util.js'; // Import Bitbucket-specific CLI modules import atlassianWorkspacesCli from './atlassian.workspaces.cli.js'; import atlassianRepositoriesCli from './atlassian.repositories.cli.js'; import atlassianPullRequestsCli from './atlassian.pullrequests.cli.js'; import atlassianSearchCommands from './atlassian.search.cli.js'; import diffCli from './atlassian.diff.cli.js'; // Package description const DESCRIPTION = 'A Model Context Protocol (MCP) server for Atlassian Bitbucket integration'; // Create a contextualized logger for this file const cliLogger = Logger.forContext('cli/index.ts'); // Log CLI initialization cliLogger.debug('Bitbucket CLI module initialized'); export async function runCli(args: string[]) { const methodLogger = Logger.forContext('cli/index.ts', 'runCli'); const program = new Command(); program.name(CLI_NAME).description(DESCRIPTION).version(VERSION); // Register CLI commands atlassianWorkspacesCli.register(program); cliLogger.debug('Workspace commands registered'); atlassianRepositoriesCli.register(program); cliLogger.debug('Repository commands registered'); atlassianPullRequestsCli.register(program); cliLogger.debug('Pull Request commands registered'); atlassianSearchCommands.register(program); cliLogger.debug('Search commands registered'); diffCli.register(program); cliLogger.debug('Diff commands registered'); // Handle unknown commands program.on('command:*', (operands) => { methodLogger.error(`Unknown command: ${operands[0]}`); console.log(''); program.help(); process.exit(1); }); // Parse arguments; default to help if no command provided await program.parseAsync(args.length ? args : ['--help'], { from: 'user' }); } ``` -------------------------------------------------------------------------------- /src/utils/shell.util.ts: -------------------------------------------------------------------------------- ```typescript import { promisify } from 'util'; import { exec as callbackExec } from 'child_process'; import { Logger } from './logger.util.js'; const exec = promisify(callbackExec); const utilLogger = Logger.forContext('utils/shell.util.ts'); /** * Executes a shell command. * * @param command The command string to execute. * @param operationDesc A brief description of the operation for logging purposes. * @returns A promise that resolves with the stdout of the command. * @throws An error if the command execution fails, including stderr. */ export async function executeShellCommand( command: string, operationDesc: string, ): Promise<string> { const methodLogger = utilLogger.forMethod('executeShellCommand'); methodLogger.debug(`Attempting to ${operationDesc}: ${command}`); try { const { stdout, stderr } = await exec(command); if (stderr) { methodLogger.warn(`Stderr from ${operationDesc}: ${stderr}`); // Depending on the command, stderr might not always indicate a failure, // but for git clone, it usually does if stdout is empty. // If stdout is also present, it might be a warning. } methodLogger.info( `Successfully executed ${operationDesc}. Stdout: ${stdout}`, ); return stdout || `Successfully ${operationDesc}.`; // Return stdout or a generic success message } catch (error: unknown) { methodLogger.error(`Failed to ${operationDesc}: ${command}`, error); let errorMessage = 'Unknown error during shell command execution.'; if (error instanceof Error) { // Node's child_process.ExecException often has stdout and stderr properties const execError = error as Error & { stdout?: string; stderr?: string; }; errorMessage = execError.stderr || execError.stdout || execError.message; } else if (typeof error === 'string') { errorMessage = error; } // Ensure a default message if somehow it's still undefined (though unlikely with above checks) if (!errorMessage && error) { errorMessage = String(error); } throw new Error(`Failed to ${operationDesc}: ${errorMessage}`); } } ``` -------------------------------------------------------------------------------- /src/utils/path.util.ts: -------------------------------------------------------------------------------- ```typescript import * as path from 'path'; import { Logger } from './logger.util.js'; const logger = Logger.forContext('utils/path.util.ts'); /** * Safely converts a path or path segments to a string, handling various input types. * Useful for ensuring consistent path string representations across different platforms. * * @param pathInput - The path or path segments to convert to a string * @returns The path as a normalized string */ export function pathToString(pathInput: string | string[] | unknown): string { if (Array.isArray(pathInput)) { return path.join(...pathInput); } else if (typeof pathInput === 'string') { return pathInput; } else if (pathInput instanceof URL) { return pathInput.pathname; } else if ( pathInput && typeof pathInput === 'object' && 'toString' in pathInput ) { return String(pathInput); } logger.warn(`Unable to convert path input to string: ${typeof pathInput}`); return ''; // Return empty string for null/undefined } /** * Determines if a given path is within the user's home directory * which is generally considered a safe location for MCP operations. * * @param inputPath - Path to check * @returns True if the path is within the user's home directory */ export function isPathInHome(inputPath: string): boolean { const homePath = process.env.HOME || process.env.USERPROFILE || ''; if (!homePath) { logger.warn('Could not determine user home directory'); return false; } const resolvedPath = path.resolve(inputPath); return resolvedPath.startsWith(homePath); } /** * Gets a user-friendly display version of a path for use in messages. * For paths within the home directory, can replace with ~ for brevity. * * @param inputPath - Path to format * @param useHomeTilde - Whether to replace home directory with ~ symbol * @returns Formatted path string */ export function formatDisplayPath( inputPath: string, useHomeTilde = true, ): string { const homePath = process.env.HOME || process.env.USERPROFILE || ''; if ( useHomeTilde && homePath && path.resolve(inputPath).startsWith(homePath) ) { return path.resolve(inputPath).replace(homePath, '~'); } return path.resolve(inputPath); } ``` -------------------------------------------------------------------------------- /src/utils/markdown.util.test.ts: -------------------------------------------------------------------------------- ```typescript import { htmlToMarkdown } from './markdown.util.js'; describe('Markdown Utility', () => { describe('htmlToMarkdown', () => { it('should convert basic HTML to Markdown', () => { const html = '<h1>Hello World</h1><p>This is a <strong>test</strong>.</p>'; const expected = '# Hello World\n\nThis is a **test**.'; expect(htmlToMarkdown(html)).toBe(expected); }); it('should handle empty input', () => { expect(htmlToMarkdown('')).toBe(''); expect(htmlToMarkdown(' ')).toBe(''); }); it('should convert links correctly', () => { const html = '<p>Check out <a href="https://example.com">this link</a>.</p>'; const expected = 'Check out [this link](https://example.com).'; expect(htmlToMarkdown(html)).toBe(expected); }); it('should convert lists correctly', () => { const html = '<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>'; const expected = '- Item 1\n- Item 2\n- Item 3'; expect(htmlToMarkdown(html)).toBe(expected); }); it('should convert tables correctly', () => { const html = ` <table> <thead> <tr> <th>Header 1</th> <th>Header 2</th> </tr> </thead> <tbody> <tr> <td>Cell 1</td> <td>Cell 2</td> </tr> <tr> <td>Cell 3</td> <td>Cell 4</td> </tr> </tbody> </table> `; const expected = '| Header 1 | Header 2 |\n| --- | --- |\n| Cell 1 | Cell 2 |\n| Cell 3 | Cell 4 |'; // Normalize whitespace for comparison const normalizedResult = htmlToMarkdown(html) .replace(/\s+/g, ' ') .trim(); const normalizedExpected = expected.replace(/\s+/g, ' ').trim(); expect(normalizedResult).toBe(normalizedExpected); }); it('should handle strikethrough text', () => { const html = '<p>This is <del>deleted</del> text.</p>'; const expected = 'This is ~~deleted~~ text.'; expect(htmlToMarkdown(html)).toBe(expected); }); }); }); ``` -------------------------------------------------------------------------------- /src/cli/atlassian.search.cli.ts: -------------------------------------------------------------------------------- ```typescript import { Command } from 'commander'; import { Logger } from '../utils/logger.util.js'; import atlassianSearchController from '../controllers/atlassian.search.controller.js'; import { handleCliError } from '../utils/error-handler.util.js'; import { getDefaultWorkspace } from '../utils/workspace.util.js'; // Set up a logger for this module const logger = Logger.forContext('cli/atlassian.search.cli.ts'); /** * Register the search commands with the CLI * @param program The commander program to register commands with */ function register(program: Command) { program .command('search') .description('Search Bitbucket for content matching a query') .requiredOption('-q, --query <query>', 'Search query') .option('-w, --workspace <workspace>', 'Workspace slug') .option('-r, --repo <repo>', 'Repository slug (required for PR search)') .option( '-t, --type <type>', 'Search type (code, content, repositories, pullrequests)', 'code', ) .option( '-c, --content-type <contentType>', 'Content type for content search (e.g., wiki, issue)', ) .option( '-l, --language <language>', 'Filter code search by programming language', ) .option( '-e, --extension <extension>', 'Filter code search by file extension', ) .option('--limit <limit>', 'Maximum number of results to return', '20') .option('--cursor <cursor>', 'Pagination cursor') .action(async (options) => { const methodLogger = logger.forMethod('search'); try { methodLogger.debug('CLI search command called with:', options); // Handle workspace let workspace = options.workspace; if (!workspace) { workspace = await getDefaultWorkspace(); if (!workspace) { console.error( 'Error: No workspace provided and no default workspace configured', ); process.exit(1); } methodLogger.debug(`Using default workspace: ${workspace}`); } // Prepare controller options const controllerOptions = { workspace, repo: options.repo, query: options.query, type: options.type, contentType: options.contentType, language: options.language, extension: options.extension, limit: options.limit ? parseInt(options.limit, 10) : undefined, cursor: options.cursor, }; // Call the controller const result = await atlassianSearchController.search(controllerOptions); // Output the result console.log(result.content); } catch (error) { handleCliError(error); } }); } export default { register }; ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.search.pullrequests.controller.ts: -------------------------------------------------------------------------------- ```typescript import { Logger } from '../utils/logger.util.js'; import { ControllerResponse } from '../types/common.types.js'; import { DEFAULT_PAGE_SIZE } from '../utils/defaults.util.js'; import atlassianPullRequestsService from '../services/vendor.atlassian.pullrequests.service.js'; import { extractPaginationInfo, PaginationType, } from '../utils/pagination.util.js'; import { formatPagination } from '../utils/formatter.util.js'; import { formatPullRequestsList } from './atlassian.pullrequests.formatter.js'; import { ListPullRequestsParams } from '../services/vendor.atlassian.pullrequests.types.js'; /** * Handle search for pull requests (uses PR API with query filter) */ export async function handlePullRequestSearch( workspaceSlug: string, repoSlug?: string, query?: string, limit: number = DEFAULT_PAGE_SIZE, cursor?: string, ): Promise<ControllerResponse> { const methodLogger = Logger.forContext( 'controllers/atlassian.search.pullrequests.controller.ts', 'handlePullRequestSearch', ); methodLogger.debug('Performing pull request search'); if (!query) { return { content: 'Please provide a search query for pull request search.', }; } try { // Format query for the Bitbucket API - specifically target title/description const formattedQuery = `(title ~ "${query}" OR description ~ "${query}")`; // Create the parameters for the PR service const params: ListPullRequestsParams = { workspace: workspaceSlug, repo_slug: repoSlug!, // Can safely use non-null assertion now that schema validation ensures it's present q: formattedQuery, pagelen: limit, page: cursor ? parseInt(cursor, 10) : undefined, sort: '-updated_on', }; methodLogger.debug('Using PR search params:', params); const prData = await atlassianPullRequestsService.list(params); methodLogger.debug( `Search complete, found ${prData.values.length} matches`, ); // Extract pagination information const pagination = extractPaginationInfo(prData, PaginationType.PAGE); // Format the search results const formattedPrs = formatPullRequestsList(prData); let finalContent = `# Pull Request Search Results\n\n${formattedPrs}`; // Add pagination information if available if ( pagination && (pagination.hasMore || pagination.count !== undefined) ) { const paginationString = formatPagination(pagination); finalContent += '\n\n' + paginationString; } return { content: finalContent, }; } catch (error) { methodLogger.error('Error performing pull request search:', error); throw error; } } ``` -------------------------------------------------------------------------------- /src/utils/cli.test.util.ts: -------------------------------------------------------------------------------- ```typescript import { spawn } from 'child_process'; import { join } from 'path'; /** * Utility for testing CLI commands with real execution */ export class CliTestUtil { /** * Executes a CLI command and returns the result * * @param args - CLI arguments to pass to the command * @param options - Test options * @returns Promise with stdout, stderr, and exit code */ static async runCommand( args: string[], options: { timeoutMs?: number; env?: Record<string, string>; } = {}, ): Promise<{ stdout: string; stderr: string; exitCode: number; }> { // Default timeout of 30 seconds const timeoutMs = options.timeoutMs || 30000; // CLI execution path - points to the built CLI script const cliPath = join(process.cwd(), 'dist', 'index.js'); return new Promise((resolve, reject) => { // Set up timeout handler const timeoutId = setTimeout(() => { child.kill(); reject(new Error(`CLI command timed out after ${timeoutMs}ms`)); }, timeoutMs); // Capture stdout and stderr let stdout = ''; let stderr = ''; // Spawn the process with given arguments const child = spawn('node', [cliPath, ...args], { env: { ...process.env, ...options.env, }, }); // Collect stdout data child.stdout.on('data', (data) => { stdout += data.toString(); }); // Collect stderr data child.stderr.on('data', (data) => { stderr += data.toString(); }); // Handle process completion child.on('close', (exitCode) => { clearTimeout(timeoutId); resolve({ stdout, stderr, exitCode: exitCode ?? 0, }); }); // Handle process errors child.on('error', (err) => { clearTimeout(timeoutId); reject(err); }); }); } /** * Validates that stdout contains expected strings/patterns */ static validateOutputContains( output: string, expectedPatterns: (string | RegExp)[], ): void { for (const pattern of expectedPatterns) { if (typeof pattern === 'string') { expect(output).toContain(pattern); } else { expect(output).toMatch(pattern); } } } /** * Validates Markdown output format */ static validateMarkdownOutput(output: string): void { // Check for Markdown heading expect(output).toMatch(/^#\s.+/m); // Check for markdown formatting elements like bold text, lists, etc. const markdownElements = [ /\*\*.+\*\*/, // Bold text /-\s.+/, // List items /\|.+\|.+\|/, // Table rows /\[.+\]\(.+\)/, // Links ]; expect(markdownElements.some((pattern) => pattern.test(output))).toBe( true, ); } } ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.search.content.controller.ts: -------------------------------------------------------------------------------- ```typescript import { Logger } from '../utils/logger.util.js'; import { ControllerResponse } from '../types/common.types.js'; import { DEFAULT_PAGE_SIZE } from '../utils/defaults.util.js'; import { extractPaginationInfo, PaginationType, } from '../utils/pagination.util.js'; import { formatPagination } from '../utils/formatter.util.js'; import { formatContentSearchResults } from './atlassian.search.formatter.js'; import { ContentType } from '../utils/atlassian.util.js'; import { ContentSearchParams } from '../services/vendor.atlassian.search.types.js'; import atlassianSearchService from '../services/vendor.atlassian.search.service.js'; /** * Handle search for content (PRs, Issues, Wiki, etc.) */ export async function handleContentSearch( workspaceSlug: string, repoSlug?: string, query?: string, limit: number = DEFAULT_PAGE_SIZE, cursor?: string, contentType?: ContentType, ): Promise<ControllerResponse> { const methodLogger = Logger.forContext( 'controllers/atlassian.search.content.controller.ts', 'handleContentSearch', ); methodLogger.debug('Performing content search'); if (!query) { return { content: 'Please provide a search query for content search.', }; } try { const params: ContentSearchParams = { workspaceSlug, query, limit, page: cursor ? parseInt(cursor, 10) : 1, }; // Add optional parameters if provided if (repoSlug) { params.repoSlug = repoSlug; } if (contentType) { params.contentType = contentType; } methodLogger.debug('Content search params:', params); const searchResult = await atlassianSearchService.searchContent(params); methodLogger.debug( `Content search complete, found ${searchResult.size} matches`, ); // Extract pagination information const pagination = extractPaginationInfo( { ...searchResult, // For content search, the Bitbucket API returns values and size differently // We need to map it to a format that extractPaginationInfo can understand page: params.page, pagelen: limit, }, PaginationType.PAGE, ); // Format the search results const formattedResults = formatContentSearchResults( searchResult, contentType, ); // Add pagination information if available let finalContent = formattedResults; if ( pagination && (pagination.hasMore || pagination.count !== undefined) ) { const paginationString = formatPagination(pagination); finalContent += '\n\n' + paginationString; } return { content: finalContent, }; } catch (searchError) { methodLogger.error('Error performing content search:', searchError); throw searchError; } } ``` -------------------------------------------------------------------------------- /src/utils/markdown.util.ts: -------------------------------------------------------------------------------- ```typescript /** * Markdown utility functions for converting HTML to Markdown * Uses Turndown library for HTML to Markdown conversion * * @see https://github.com/mixmark-io/turndown */ import TurndownService from 'turndown'; import { Logger } from './logger.util.js'; // Create a file-level logger for the module const markdownLogger = Logger.forContext('utils/markdown.util.ts'); // DOM type definitions interface HTMLElement { nodeName: string; parentNode?: Node; childNodes: NodeListOf<Node>; } interface Node { tagName?: string; childNodes: NodeListOf<Node>; parentNode?: Node; } interface NodeListOf<T> extends Array<T> { length: number; } // Create a singleton instance of TurndownService with default options const turndownService = new TurndownService({ headingStyle: 'atx', // Use # style headings bulletListMarker: '-', // Use - for bullet lists codeBlockStyle: 'fenced', // Use ``` for code blocks emDelimiter: '_', // Use _ for emphasis strongDelimiter: '**', // Use ** for strong linkStyle: 'inlined', // Use [text](url) for links linkReferenceStyle: 'full', // Use [text][id] + [id]: url for reference links }); // Add custom rule for strikethrough turndownService.addRule('strikethrough', { filter: (node: HTMLElement) => { return ( node.nodeName.toLowerCase() === 'del' || node.nodeName.toLowerCase() === 's' || node.nodeName.toLowerCase() === 'strike' ); }, replacement: (content: string): string => `~~${content}~~`, }); // Add custom rule for tables to improve table formatting turndownService.addRule('tableCell', { filter: ['th', 'td'], replacement: (content: string, _node: TurndownService.Node): string => { return ` ${content} |`; }, }); turndownService.addRule('tableRow', { filter: 'tr', replacement: (content: string, node: TurndownService.Node): string => { let output = `|${content}\n`; // If this is the first row in a table head, add the header separator row if ( node.parentNode && 'tagName' in node.parentNode && node.parentNode.tagName === 'THEAD' ) { const cellCount = node.childNodes.length; output += '|' + ' --- |'.repeat(cellCount) + '\n'; } return output; }, }); /** * Convert HTML content to Markdown * * @param html - The HTML content to convert * @returns The converted Markdown content */ export function htmlToMarkdown(html: string): string { if (!html || html.trim() === '') { return ''; } try { const markdown = turndownService.turndown(html); return markdown; } catch (error) { markdownLogger.error('Error converting HTML to Markdown:', error); // Return the original HTML if conversion fails return html; } } ``` -------------------------------------------------------------------------------- /.github/workflows/ci-semantic-release.yml: -------------------------------------------------------------------------------- ```yaml name: CI - Semantic Release # This workflow is triggered on every push to the main branch # It analyzes commits and automatically releases a new version when needed on: push: branches: [main] jobs: release: name: Semantic Release runs-on: ubuntu-latest # Permissions needed for creating releases, updating issues, and publishing packages permissions: contents: write # Needed to create releases and tags issues: write # Needed to comment on issues pull-requests: write # Needed to comment on pull requests # packages permission removed as we're not using GitHub Packages steps: # Step 1: Check out the full Git history for proper versioning - name: Checkout uses: actions/checkout@v5 with: fetch-depth: 0 # Fetches all history for all branches and tags # Step 2: Setup Node.js environment - name: Setup Node.js uses: actions/setup-node@v5 with: node-version: 22 # Using Node.js 22 cache: 'npm' # Enable npm caching # Step 3: Install dependencies with clean install - name: Install dependencies run: npm ci # Clean install preserving package-lock.json # Step 4: Build the package - name: Build run: npm run build # Compiles TypeScript to JavaScript # Step 5: Ensure executable permissions - name: Set executable permissions run: chmod +x dist/index.js # Step 6: Run tests to ensure quality - name: Verify tests run: npm test # Runs Jest tests # Step 7: Configure Git identity for releases - name: Configure Git User run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" # Step 8: Run semantic-release to analyze commits and publish to npm - name: Semantic Release id: semantic env: # Tokens needed for GitHub and npm authentication GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # For creating releases and commenting NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # For publishing to npm run: | echo "Running semantic-release for version bump and npm publishing" npx semantic-release # Note: GitHub Packages publishing has been removed ``` -------------------------------------------------------------------------------- /src/tools/atlassian.pullrequests.types.test.ts: -------------------------------------------------------------------------------- ```typescript import { CreatePullRequestCommentToolArgs } from './atlassian.pullrequests.types'; describe('Atlassian Pull Requests Tool Types', () => { describe('CreatePullRequestCommentToolArgs Schema', () => { it('should accept valid parentId parameter for comment replies', () => { const validArgs = { repoSlug: 'test-repo', prId: '123', content: 'This is a reply to another comment', parentId: '456', }; const result = CreatePullRequestCommentToolArgs.safeParse(validArgs); expect(result.success).toBe(true); if (result.success) { expect(result.data.parentId).toBe('456'); expect(result.data.repoSlug).toBe('test-repo'); expect(result.data.prId).toBe('123'); expect(result.data.content).toBe( 'This is a reply to another comment', ); } }); it('should work without parentId parameter for top-level comments', () => { const validArgs = { repoSlug: 'test-repo', prId: '123', content: 'This is a top-level comment', }; const result = CreatePullRequestCommentToolArgs.safeParse(validArgs); expect(result.success).toBe(true); if (result.success) { expect(result.data.parentId).toBeUndefined(); expect(result.data.repoSlug).toBe('test-repo'); expect(result.data.prId).toBe('123'); expect(result.data.content).toBe('This is a top-level comment'); } }); it('should accept both parentId and inline parameters together', () => { const validArgs = { repoSlug: 'test-repo', prId: '123', content: 'Reply with inline comment', parentId: '456', inline: { path: 'src/main.ts', line: 42, }, }; const result = CreatePullRequestCommentToolArgs.safeParse(validArgs); expect(result.success).toBe(true); if (result.success) { expect(result.data.parentId).toBe('456'); expect(result.data.inline?.path).toBe('src/main.ts'); expect(result.data.inline?.line).toBe(42); } }); it('should require required fields even with parentId', () => { const invalidArgs = { parentId: '456', // parentId alone is not enough }; const result = CreatePullRequestCommentToolArgs.safeParse(invalidArgs); expect(result.success).toBe(false); }); it('should accept optional workspaceSlug with parentId', () => { const validArgs = { workspaceSlug: 'my-workspace', repoSlug: 'test-repo', prId: '123', content: 'Reply comment', parentId: '456', }; const result = CreatePullRequestCommentToolArgs.safeParse(validArgs); expect(result.success).toBe(true); if (result.success) { expect(result.data.workspaceSlug).toBe('my-workspace'); expect(result.data.parentId).toBe('456'); } }); }); }); ``` -------------------------------------------------------------------------------- /src/utils/workspace.util.ts: -------------------------------------------------------------------------------- ```typescript import { Logger } from './logger.util.js'; import { config } from './config.util.js'; import atlassianWorkspacesService from '../services/vendor.atlassian.workspaces.service.js'; import { WorkspaceMembership } from '../services/vendor.atlassian.workspaces.types.js'; const workspaceLogger = Logger.forContext('utils/workspace.util.ts'); /** * Cache for workspace data to avoid repeated API calls */ let cachedDefaultWorkspace: string | null = null; let cachedWorkspaces: WorkspaceMembership[] | null = null; /** * Get the default workspace slug * * This function follows this priority: * 1. Use cached value if available * 2. Check BITBUCKET_DEFAULT_WORKSPACE environment variable * 3. Fetch from API and use the first workspace in the list * * @returns {Promise<string|null>} The default workspace slug or null if not available */ export async function getDefaultWorkspace(): Promise<string | null> { const methodLogger = workspaceLogger.forMethod('getDefaultWorkspace'); // Step 1: Return cached value if available if (cachedDefaultWorkspace) { methodLogger.debug( `Using cached default workspace: ${cachedDefaultWorkspace}`, ); return cachedDefaultWorkspace; } // Step 2: Check environment variable const envWorkspace = config.get('BITBUCKET_DEFAULT_WORKSPACE'); if (envWorkspace) { methodLogger.debug( `Using default workspace from environment: ${envWorkspace}`, ); cachedDefaultWorkspace = envWorkspace; return envWorkspace; } // Step 3: Fetch from API methodLogger.debug('No default workspace configured, fetching from API...'); try { const workspaces = await getWorkspaces(); if (workspaces.length > 0) { const defaultWorkspace = workspaces[0].workspace.slug; methodLogger.debug( `Using first workspace from API as default: ${defaultWorkspace}`, ); cachedDefaultWorkspace = defaultWorkspace; return defaultWorkspace; } else { methodLogger.warn('No workspaces found in the account'); return null; } } catch (error) { methodLogger.error('Failed to fetch default workspace', error); return null; } } /** * Get list of workspaces from API or cache * * @returns {Promise<WorkspaceMembership[]>} Array of workspace membership objects */ export async function getWorkspaces(): Promise<WorkspaceMembership[]> { const methodLogger = workspaceLogger.forMethod('getWorkspaces'); if (cachedWorkspaces) { methodLogger.debug( `Using ${cachedWorkspaces.length} cached workspaces`, ); return cachedWorkspaces; } try { const result = await atlassianWorkspacesService.list({ pagelen: 10, // Limit to first 10 workspaces }); if (result.values) { cachedWorkspaces = result.values; methodLogger.debug(`Cached ${result.values.length} workspaces`); return result.values; } else { return []; } } catch (error) { methodLogger.error('Failed to fetch workspaces list', error); return []; } } ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.pullrequests.get.controller.ts: -------------------------------------------------------------------------------- ```typescript import { ControllerResponse } from '../types/common.types.js'; import { GetPullRequestParams } from '../services/vendor.atlassian.pullrequests.types.js'; import { GetPullRequestToolArgsType } from '../tools/atlassian.pullrequests.types.js'; import { atlassianPullRequestsService, Logger, handleControllerError, formatPullRequestDetails, applyDefaults, getDefaultWorkspace, } from './atlassian.pullrequests.base.controller.js'; /** * Get detailed information about a specific Bitbucket pull request * @param options - Options including workspace slug, repo slug, and pull request ID * @returns Promise with formatted pull request details as Markdown content */ async function get( options: GetPullRequestToolArgsType, ): Promise<ControllerResponse> { const methodLogger = Logger.forContext( 'controllers/atlassian.pullrequests.get.controller.ts', 'get', ); try { // Apply default values if needed const mergedOptions = applyDefaults<GetPullRequestToolArgsType>( options, {}, // No defaults required for this operation ); // Handle optional workspaceSlug - get default if not provided if (!mergedOptions.workspaceSlug) { methodLogger.debug( 'No workspace provided, fetching default workspace', ); const defaultWorkspace = await getDefaultWorkspace(); if (!defaultWorkspace) { throw new Error( 'Could not determine a default workspace. Please provide a workspaceSlug.', ); } mergedOptions.workspaceSlug = defaultWorkspace; methodLogger.debug( `Using default workspace: ${mergedOptions.workspaceSlug}`, ); } const { workspaceSlug, repoSlug, prId } = mergedOptions; // Validate required parameters if (!workspaceSlug || !repoSlug || !prId) { throw new Error( 'Workspace slug, repository slug, and pull request ID are required', ); } methodLogger.debug( `Getting pull request details for ${workspaceSlug}/${repoSlug}/${prId}`, ); // Map controller options to service parameters const serviceParams: GetPullRequestParams = { workspace: workspaceSlug, repo_slug: repoSlug, pull_request_id: parseInt(prId, 10), }; // Get PR details from the service const pullRequestData = await atlassianPullRequestsService.get(serviceParams); methodLogger.debug('Retrieved pull request details', { id: pullRequestData.id, title: pullRequestData.title, state: pullRequestData.state, }); // Format the pull request details using the formatter const formattedContent = formatPullRequestDetails(pullRequestData); return { content: formattedContent, }; } catch (error) { // Use the standardized error handler throw handleControllerError(error, { entityType: 'Pull Request', operation: 'retrieving details', source: 'controllers/atlassian.pullrequests.get.controller.ts@get', additionalInfo: { options }, }); } } // Export the controller functions export default { get }; ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.search.repositories.controller.ts: -------------------------------------------------------------------------------- ```typescript import { Logger } from '../utils/logger.util.js'; import { ControllerResponse } from '../types/common.types.js'; import { DEFAULT_PAGE_SIZE } from '../utils/defaults.util.js'; import { extractPaginationInfo, PaginationType, } from '../utils/pagination.util.js'; import { formatPagination } from '../utils/formatter.util.js'; import { formatRepositoriesList } from './atlassian.repositories.formatter.js'; import { RepositoriesResponse } from '../services/vendor.atlassian.repositories.types.js'; import { fetchAtlassian, getAtlassianCredentials, } from '../utils/transport.util.js'; /** * Handle search for repositories (limited functionality in the API) */ export async function handleRepositorySearch( workspaceSlug: string, _repoSlug?: string, // Renamed to indicate it's intentionally unused query?: string, limit: number = DEFAULT_PAGE_SIZE, cursor?: string, ): Promise<ControllerResponse> { const methodLogger = Logger.forContext( 'controllers/atlassian.search.repositories.controller.ts', 'handleRepositorySearch', ); methodLogger.debug('Performing repository search'); if (!query) { return { content: 'Please provide a search query for repository search.', }; } try { const credentials = getAtlassianCredentials(); if (!credentials) { throw new Error( 'Atlassian credentials are required for this operation', ); } // Build query params const queryParams = new URLSearchParams(); // Format the query - Bitbucket's repository API allows filtering by name/description const formattedQuery = `(name ~ "${query}" OR description ~ "${query}")`; queryParams.set('q', formattedQuery); // Add pagination parameters queryParams.set('pagelen', limit.toString()); if (cursor) { queryParams.set('page', cursor); } // Sort by most recently updated queryParams.set('sort', '-updated_on'); // Use the repositories endpoint to search const path = `/2.0/repositories/${workspaceSlug}?${queryParams.toString()}`; methodLogger.debug(`Sending repository search request: ${path}`); const searchData = await fetchAtlassian<RepositoriesResponse>( credentials, path, ); methodLogger.debug( `Search complete, found ${searchData.values?.length || 0} matches`, ); // Extract pagination information const pagination = extractPaginationInfo( searchData, PaginationType.PAGE, ); // Format the search results const formattedRepos = formatRepositoriesList(searchData); let finalContent = `# Repository Search Results\n\n${formattedRepos}`; // Add pagination information if available if ( pagination && (pagination.hasMore || pagination.count !== undefined) ) { const paginationString = formatPagination(pagination); finalContent += '\n\n' + paginationString; } return { content: finalContent, }; } catch (searchError) { methodLogger.error('Error performing repository search:', searchError); throw searchError; } } ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.pullrequests.approve.controller.ts: -------------------------------------------------------------------------------- ```typescript import { ControllerResponse } from '../types/common.types.js'; import { ApprovePullRequestParams } from '../services/vendor.atlassian.pullrequests.types.js'; import { ApprovePullRequestToolArgsType } from '../tools/atlassian.pullrequests.types.js'; import { atlassianPullRequestsService, Logger, handleControllerError, applyDefaults, getDefaultWorkspace, } from './atlassian.pullrequests.base.controller.js'; /** * Approve a pull request in Bitbucket * @param options - Options including workspace slug, repo slug, and pull request ID * @returns Promise with formatted approval confirmation as Markdown content */ async function approve( options: ApprovePullRequestToolArgsType, ): Promise<ControllerResponse> { const methodLogger = Logger.forContext( 'controllers/atlassian.pullrequests.approve.controller.ts', 'approve', ); try { // Apply defaults if needed (none for this operation) const mergedOptions = applyDefaults<ApprovePullRequestToolArgsType>( options, {}, ); // Handle optional workspaceSlug - get default if not provided if (!mergedOptions.workspaceSlug) { methodLogger.debug( 'No workspace provided, fetching default workspace', ); const defaultWorkspace = await getDefaultWorkspace(); if (!defaultWorkspace) { throw new Error( 'Could not determine a default workspace. Please provide a workspaceSlug.', ); } mergedOptions.workspaceSlug = defaultWorkspace; methodLogger.debug( `Using default workspace: ${mergedOptions.workspaceSlug}`, ); } methodLogger.debug( `Approving pull request ${mergedOptions.pullRequestId} in ${mergedOptions.workspaceSlug}/${mergedOptions.repoSlug}`, ); // Prepare service parameters const serviceParams: ApprovePullRequestParams = { workspace: mergedOptions.workspaceSlug, repo_slug: mergedOptions.repoSlug, pull_request_id: mergedOptions.pullRequestId, }; // Call service to approve the pull request const participant = await atlassianPullRequestsService.approve(serviceParams); methodLogger.debug( `Successfully approved pull request ${mergedOptions.pullRequestId}`, ); // Format the response const content = `# Pull Request Approved ✅ **Pull Request ID:** ${mergedOptions.pullRequestId} **Repository:** \`${mergedOptions.workspaceSlug}/${mergedOptions.repoSlug}\` **Approved by:** ${participant.user.display_name || participant.user.nickname || 'Unknown User'} **Status:** ${participant.state} **Participated on:** ${new Date(participant.participated_on).toLocaleString()} The pull request has been successfully approved and is now ready for merge (pending any other required approvals or checks).`; return { content: content, }; } catch (error) { throw handleControllerError(error, { entityType: 'Pull Request', operation: 'approving', source: 'controllers/atlassian.pullrequests.approve.controller.ts@approve', additionalInfo: { options }, }); } } export default { approve }; ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.pullrequests.reject.controller.ts: -------------------------------------------------------------------------------- ```typescript import { ControllerResponse } from '../types/common.types.js'; import { RejectPullRequestParams } from '../services/vendor.atlassian.pullrequests.types.js'; import { RejectPullRequestToolArgsType } from '../tools/atlassian.pullrequests.types.js'; import { atlassianPullRequestsService, Logger, handleControllerError, applyDefaults, getDefaultWorkspace, } from './atlassian.pullrequests.base.controller.js'; /** * Request changes on a pull request in Bitbucket * @param options - Options including workspace slug, repo slug, and pull request ID * @returns Promise with formatted rejection confirmation as Markdown content */ async function reject( options: RejectPullRequestToolArgsType, ): Promise<ControllerResponse> { const methodLogger = Logger.forContext( 'controllers/atlassian.pullrequests.reject.controller.ts', 'reject', ); try { // Apply defaults if needed (none for this operation) const mergedOptions = applyDefaults<RejectPullRequestToolArgsType>( options, {}, ); // Handle optional workspaceSlug - get default if not provided if (!mergedOptions.workspaceSlug) { methodLogger.debug( 'No workspace provided, fetching default workspace', ); const defaultWorkspace = await getDefaultWorkspace(); if (!defaultWorkspace) { throw new Error( 'Could not determine a default workspace. Please provide a workspaceSlug.', ); } mergedOptions.workspaceSlug = defaultWorkspace; methodLogger.debug( `Using default workspace: ${mergedOptions.workspaceSlug}`, ); } methodLogger.debug( `Requesting changes on pull request ${mergedOptions.pullRequestId} in ${mergedOptions.workspaceSlug}/${mergedOptions.repoSlug}`, ); // Prepare service parameters const serviceParams: RejectPullRequestParams = { workspace: mergedOptions.workspaceSlug, repo_slug: mergedOptions.repoSlug, pull_request_id: mergedOptions.pullRequestId, }; // Call service to request changes on the pull request const participant = await atlassianPullRequestsService.reject(serviceParams); methodLogger.debug( `Successfully requested changes on pull request ${mergedOptions.pullRequestId}`, ); // Format the response const content = `# Changes Requested 🔄 **Pull Request ID:** ${mergedOptions.pullRequestId} **Repository:** \`${mergedOptions.workspaceSlug}/${mergedOptions.repoSlug}\` **Requested by:** ${participant.user.display_name || participant.user.nickname || 'Unknown User'} **Status:** ${participant.state} **Participated on:** ${new Date(participant.participated_on).toLocaleString()} Changes have been requested on this pull request. The author should address the feedback before the pull request can be merged.`; return { content: content, }; } catch (error) { throw handleControllerError(error, { entityType: 'Pull Request', operation: 'requesting changes on', source: 'controllers/atlassian.pullrequests.reject.controller.ts@reject', additionalInfo: { options }, }); } } export default { reject }; ``` -------------------------------------------------------------------------------- /src/utils/path.util.test.ts: -------------------------------------------------------------------------------- ```typescript import * as path from 'path'; import { pathToString, isPathInHome, formatDisplayPath } from './path.util.js'; describe('Path Utilities', () => { describe('pathToString', () => { it('should convert string paths correctly', () => { expect(pathToString('/test/path')).toBe('/test/path'); }); it('should join array paths correctly', () => { expect(pathToString(['/test', 'path'])).toBe( path.join('/test', 'path'), ); }); it('should handle URL objects', () => { expect(pathToString(new URL('file:///test/path'))).toBe( '/test/path', ); }); it('should handle objects with toString', () => { expect(pathToString({ toString: () => '/test/path' })).toBe( '/test/path', ); }); it('should convert null or undefined to empty string', () => { expect(pathToString(null)).toBe(''); expect(pathToString(undefined)).toBe(''); }); }); describe('isPathInHome', () => { const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; beforeEach(() => { // Set a mock home directory for testing process.env.HOME = '/mock/home'; process.env.USERPROFILE = '/mock/home'; }); afterEach(() => { // Restore the original environment variables process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; }); it('should return true for paths in home directory', () => { expect(isPathInHome('/mock/home/projects')).toBe(true); }); it('should return false for paths outside home directory', () => { expect(isPathInHome('/tmp/projects')).toBe(false); }); it('should resolve relative paths correctly', () => { const cwd = process.cwd(); if (cwd.startsWith('/mock/home')) { expect(isPathInHome('./projects')).toBe(true); } else { expect(isPathInHome('./projects')).toBe(false); } }); }); describe('formatDisplayPath', () => { const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; beforeEach(() => { // Set a mock home directory for testing process.env.HOME = '/mock/home'; process.env.USERPROFILE = '/mock/home'; }); afterEach(() => { // Restore the original environment variables process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; }); it('should replace home directory with tilde when requested', () => { expect(formatDisplayPath('/mock/home/projects', true)).toBe( '~/projects', ); }); it('should not replace home directory when not requested', () => { expect(formatDisplayPath('/mock/home/projects', false)).toBe( '/mock/home/projects', ); }); it('should not modify paths outside of home directory', () => { expect(formatDisplayPath('/tmp/projects', true)).toBe( '/tmp/projects', ); }); it('should resolve relative paths', () => { // This will resolve to the absolute path based on current working directory const expected = path.resolve('./projects'); expect(formatDisplayPath('./projects')).toBe(expected); }); }); }); ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.repositories.details.controller.ts: -------------------------------------------------------------------------------- ```typescript import atlassianRepositoriesService from '../services/vendor.atlassian.repositories.service.js'; import atlassianPullRequestsService from '../services/vendor.atlassian.pullrequests.service.js'; import { Logger } from '../utils/logger.util.js'; import { handleControllerError } from '../utils/error-handler.util.js'; import { ControllerResponse } from '../types/common.types.js'; import { GetRepositoryToolArgsType } from '../tools/atlassian.repositories.types.js'; import { formatRepositoryDetails } from './atlassian.repositories.formatter.js'; import { getDefaultWorkspace } from '../utils/workspace.util.js'; // Logger instance for this module const logger = Logger.forContext( 'controllers/atlassian.repositories.details.controller.ts', ); /** * Get details of a specific repository * * @param params - Parameters containing workspaceSlug and repoSlug * @returns Promise with formatted repository details content */ export async function handleRepositoryDetails( params: GetRepositoryToolArgsType, ): Promise<ControllerResponse> { const methodLogger = logger.forMethod('handleRepositoryDetails'); try { methodLogger.debug('Getting repository details', params); // Handle optional workspaceSlug if (!params.workspaceSlug) { methodLogger.debug( 'No workspace provided, fetching default workspace', ); const defaultWorkspace = await getDefaultWorkspace(); if (!defaultWorkspace) { throw new Error( 'No default workspace found. Please provide a workspace slug.', ); } params.workspaceSlug = defaultWorkspace; methodLogger.debug(`Using default workspace: ${defaultWorkspace}`); } // Call the service to get repository details const repoData = await atlassianRepositoriesService.get({ workspace: params.workspaceSlug, repo_slug: params.repoSlug, }); // Fetch recent pull requests for this repository (most recently updated, limit to 5) let pullRequestsData = null; try { methodLogger.debug( 'Fetching recent pull requests for the repository', ); pullRequestsData = await atlassianPullRequestsService.list({ workspace: params.workspaceSlug, repo_slug: params.repoSlug, state: 'OPEN', // Focus on open PRs sort: '-updated_on', // Sort by most recently updated pagelen: 5, // Limit to 5 to keep the response concise }); methodLogger.debug( `Retrieved ${pullRequestsData.values?.length || 0} recent pull requests`, ); } catch (error) { // Log the error but continue - this is an enhancement, not critical methodLogger.warn( 'Failed to fetch recent pull requests, continuing without them', error, ); // Do not fail the entire operation if pull requests cannot be fetched } // Format the repository data with optional pull requests const content = formatRepositoryDetails(repoData, pullRequestsData); return { content }; } catch (error) { throw handleControllerError(error, { entityType: 'Repository', operation: 'get', source: 'controllers/atlassian.repositories.details.controller.ts@handleRepositoryDetails', additionalInfo: params, }); } } ``` -------------------------------------------------------------------------------- /src/cli/atlassian.search.cli.test.ts: -------------------------------------------------------------------------------- ```typescript import { CliTestUtil } from '../utils/cli.test.util'; import { getAtlassianCredentials } from '../utils/transport.util'; describe('Atlassian Search CLI Commands', () => { beforeAll(() => { // Check if credentials are available const credentials = getAtlassianCredentials(); if (!credentials) { console.warn( 'WARNING: No Atlassian credentials available. Live API tests will be skipped.', ); } }); /** * Helper function to skip tests if Atlassian credentials are not available */ const skipIfNoCredentials = () => { const credentials = getAtlassianCredentials(); if (!credentials) { return true; } return false; }; describe('search command', () => { it('should search repositories and return success exit code', async () => { if (skipIfNoCredentials()) { return; // Skip silently - no credentials available } const { stdout, exitCode } = await CliTestUtil.runCommand([ 'search', '--query', 'test', ]); expect(exitCode).toBe(0); CliTestUtil.validateMarkdownOutput(stdout); CliTestUtil.validateOutputContains(stdout, ['## Search Results']); }, 60000); it('should support searching with query parameter', async () => { if (skipIfNoCredentials()) { return; // Skip silently - no credentials available } const { stdout, exitCode } = await CliTestUtil.runCommand([ 'search', '--query', 'api', ]); expect(exitCode).toBe(0); CliTestUtil.validateMarkdownOutput(stdout); CliTestUtil.validateOutputContains(stdout, ['## Search Results']); }, 60000); it('should support pagination with limit flag', async () => { if (skipIfNoCredentials()) { return; // Skip silently - no credentials available } const { stdout, exitCode } = await CliTestUtil.runCommand([ 'search', '--query', 'test', '--limit', '2', ]); expect(exitCode).toBe(0); CliTestUtil.validateMarkdownOutput(stdout); // Check for pagination markers CliTestUtil.validateOutputContains(stdout, [ /Showing \d+ results/, /Next page:|No more results/, ]); }, 60000); it('should require the query parameter', async () => { const { stderr, exitCode } = await CliTestUtil.runCommand([ 'search', ]); expect(exitCode).not.toBe(0); expect(stderr).toMatch( /required option|missing required|specify a query/i, ); }, 30000); it('should handle invalid limit value gracefully', async () => { if (skipIfNoCredentials()) { return; // Skip silently - no credentials available } const { stdout, exitCode } = await CliTestUtil.runCommand([ 'search', '--query', 'test', '--limit', 'not-a-number', ]); expect(exitCode).not.toBe(0); CliTestUtil.validateOutputContains(stdout, [ /Error|Invalid|Failed/i, ]); }, 60000); it('should handle help flag correctly', async () => { const { stdout, exitCode } = await CliTestUtil.runCommand([ 'search', '--help', ]); expect(exitCode).toBe(0); expect(stdout).toMatch(/Usage|Options|Description/i); expect(stdout).toContain('search'); }, 15000); }); }); ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.pullrequests.update.controller.ts: -------------------------------------------------------------------------------- ```typescript import { ControllerResponse } from '../types/common.types.js'; import { UpdatePullRequestParams } from '../services/vendor.atlassian.pullrequests.types.js'; import { UpdatePullRequestToolArgsType } from '../tools/atlassian.pullrequests.types.js'; import { atlassianPullRequestsService, Logger, handleControllerError, formatPullRequestDetails, applyDefaults, optimizeBitbucketMarkdown, getDefaultWorkspace, } from './atlassian.pullrequests.base.controller.js'; /** * Update an existing pull request in Bitbucket * @param options - Options including workspace slug, repo slug, pull request ID, title, and description * @returns Promise with formatted updated pull request details as Markdown content */ async function update( options: UpdatePullRequestToolArgsType, ): Promise<ControllerResponse> { const methodLogger = Logger.forContext( 'controllers/atlassian.pullrequests.update.controller.ts', 'update', ); try { // Apply defaults if needed (none for this operation) const mergedOptions = applyDefaults<UpdatePullRequestToolArgsType>( options, {}, ); // Handle optional workspaceSlug - get default if not provided if (!mergedOptions.workspaceSlug) { methodLogger.debug( 'No workspace provided, fetching default workspace', ); const defaultWorkspace = await getDefaultWorkspace(); if (!defaultWorkspace) { throw new Error( 'Could not determine a default workspace. Please provide a workspaceSlug.', ); } mergedOptions.workspaceSlug = defaultWorkspace; methodLogger.debug( `Using default workspace: ${mergedOptions.workspaceSlug}`, ); } // Validate that at least one field to update is provided if (!mergedOptions.title && !mergedOptions.description) { throw new Error( 'At least one field to update (title or description) must be provided', ); } methodLogger.debug( `Updating pull request ${mergedOptions.pullRequestId} in ${mergedOptions.workspaceSlug}/${mergedOptions.repoSlug}`, ); // Prepare service parameters const serviceParams: UpdatePullRequestParams = { workspace: mergedOptions.workspaceSlug, repo_slug: mergedOptions.repoSlug, pull_request_id: mergedOptions.pullRequestId, }; // Add optional fields if provided if (mergedOptions.title !== undefined) { serviceParams.title = mergedOptions.title; } if (mergedOptions.description !== undefined) { serviceParams.description = optimizeBitbucketMarkdown( mergedOptions.description, ); } // Call service to update the pull request const pullRequest = await atlassianPullRequestsService.update(serviceParams); methodLogger.debug( `Successfully updated pull request ${pullRequest.id}`, ); // Format the response const content = await formatPullRequestDetails(pullRequest); return { content: `## Pull Request Updated Successfully\n\n${content}`, }; } catch (error) { throw handleControllerError(error, { entityType: 'Pull Request', operation: 'updating', source: 'controllers/atlassian.pullrequests.update.controller.ts@update', additionalInfo: { options }, }); } } export default { update }; ``` -------------------------------------------------------------------------------- /src/services/vendor.atlassian.workspaces.types.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; /** * Types for Atlassian Bitbucket Workspaces API */ /** * Workspace type (basic object) */ export const WorkspaceTypeSchema = z.literal('workspace'); export type WorkspaceType = z.infer<typeof WorkspaceTypeSchema>; /** * Workspace user object */ export const WorkspaceUserSchema = z.object({ type: z.literal('user'), uuid: z.string(), nickname: z.string(), display_name: z.string(), }); /** * Workspace permission type */ export const WorkspacePermissionSchema = z.enum([ 'owner', 'collaborator', 'member', ]); /** * Workspace links object */ const LinkSchema = z.object({ href: z.string(), name: z.string().optional(), }); export const WorkspaceLinksSchema = z.object({ avatar: LinkSchema.optional(), html: LinkSchema.optional(), members: LinkSchema.optional(), owners: LinkSchema.optional(), projects: LinkSchema.optional(), repositories: LinkSchema.optional(), snippets: LinkSchema.optional(), self: LinkSchema.optional(), }); export type WorkspaceLinks = z.infer<typeof WorkspaceLinksSchema>; /** * Workspace forking mode */ export const WorkspaceForkingModeSchema = z.enum([ 'allow_forks', 'no_public_forks', 'no_forks', ]); export type WorkspaceForkingMode = z.infer<typeof WorkspaceForkingModeSchema>; /** * Workspace object returned from the API */ export const WorkspaceSchema: z.ZodType<{ type: WorkspaceType; uuid: string; name: string; slug: string; is_private?: boolean; is_privacy_enforced?: boolean; forking_mode?: WorkspaceForkingMode; created_on?: string; updated_on?: string; links: WorkspaceLinks; }> = z.object({ type: WorkspaceTypeSchema, uuid: z.string(), name: z.string(), slug: z.string(), is_private: z.boolean().optional(), is_privacy_enforced: z.boolean().optional(), forking_mode: WorkspaceForkingModeSchema.optional(), created_on: z.string().optional(), updated_on: z.string().optional(), links: WorkspaceLinksSchema, }); /** * Workspace membership object */ export const WorkspaceMembershipSchema = z.object({ type: z.literal('workspace_membership'), permission: WorkspacePermissionSchema, last_accessed: z.string().optional(), added_on: z.string().optional(), user: WorkspaceUserSchema, workspace: WorkspaceSchema, }); export type WorkspaceMembership = z.infer<typeof WorkspaceMembershipSchema>; /** * Extended workspace object with optional fields * @remarks Currently identical to Workspace, but allows for future extension */ export const WorkspaceDetailedSchema = WorkspaceSchema; export type WorkspaceDetailed = z.infer<typeof WorkspaceDetailedSchema>; /** * Parameters for listing workspaces */ export const ListWorkspacesParamsSchema = z.object({ q: z.string().optional(), page: z.number().optional(), pagelen: z.number().optional(), }); export type ListWorkspacesParams = z.infer<typeof ListWorkspacesParamsSchema>; /** * API response for user permissions on workspaces */ export const WorkspacePermissionsResponseSchema = z.object({ pagelen: z.number(), page: z.number(), size: z.number(), next: z.string().optional(), previous: z.string().optional(), values: z.array(WorkspaceMembershipSchema), }); export type WorkspacePermissionsResponse = z.infer< typeof WorkspacePermissionsResponseSchema >; ``` -------------------------------------------------------------------------------- /src/utils/diff.util.ts: -------------------------------------------------------------------------------- ```typescript import { Logger } from './logger.util.js'; const utilLogger = Logger.forContext('utils/diff.util.ts'); /** * Extracts a code snippet from raw unified diff content around a specific line number. * * @param diffContent - The raw unified diff content (string). * @param targetLineNumber - The line number (in the "new" file) to center the snippet around. * @param contextLines - The number of lines to include before and after the target line. * @returns The extracted code snippet as a string, or an empty string if extraction fails. */ export function extractDiffSnippet( diffContent: string, targetLineNumber: number, contextLines = 2, ): string { const methodLogger = utilLogger.forMethod('extractDiffSnippet'); methodLogger.debug( `Attempting to extract snippet around line ${targetLineNumber}`, ); const lines = diffContent.split('\n'); const snippetLines: string[] = []; let currentNewLineNumber = 0; let hunkHeaderFound = false; for (const line of lines) { if (line.startsWith('@@')) { // Found a hunk header, parse the starting line number of the new file const match = line.match(/\+([0-9]+)/); // Matches the part like "+1,10" or "+5" if (match && match[1]) { currentNewLineNumber = parseInt(match[1], 10) - 1; // -1 because we increment before checking hunkHeaderFound = true; methodLogger.debug( `Found hunk starting at new line number: ${currentNewLineNumber + 1}`, ); } else { methodLogger.warn('Could not parse hunk header:', line); hunkHeaderFound = false; // Reset if header is unparseable } continue; // Skip the hunk header line itself } if (!hunkHeaderFound) { continue; // Skip lines before the first valid hunk header } // Track line numbers only for lines added or unchanged in the new file if (line.startsWith('+') || line.startsWith(' ')) { currentNewLineNumber++; // Check if the current line is within the desired context range if ( currentNewLineNumber >= targetLineNumber - contextLines && currentNewLineNumber <= targetLineNumber + contextLines ) { // Prepend line numbers for context, marking the target line const prefix = currentNewLineNumber === targetLineNumber ? '>' : ' '; // Add the line, removing the diff marker (+ or space) snippetLines.push( `${prefix} ${currentNewLineNumber.toString().padStart(4)}: ${line.substring(1)}`, ); } } else if (line.startsWith('-')) { // Lines only in the old file don't increment the new file line number // but can be included for context if they fall within the range calculation *based on previous new lines* // This is complex logic, for now, we only show '+' and ' ' lines for simplicity. // Future enhancement: Show '-' lines that are adjacent to the target context. } // Optimization: if we've passed the target context range, stop processing if (currentNewLineNumber > targetLineNumber + contextLines) { methodLogger.debug( `Passed target context range (current: ${currentNewLineNumber}, target: ${targetLineNumber}). Stopping search.`, ); break; } } if (snippetLines.length === 0) { methodLogger.warn( `Could not find or extract snippet for line ${targetLineNumber}`, ); return ''; // Return empty if no relevant lines found } methodLogger.debug( `Successfully extracted snippet with ${snippetLines.length} lines.`, ); return snippetLines.join('\n'); } ``` -------------------------------------------------------------------------------- /src/cli/atlassian.workspaces.cli.ts: -------------------------------------------------------------------------------- ```typescript import { Command } from 'commander'; import { Logger } from '../utils/logger.util.js'; import { handleCliError } from '../utils/error.util.js'; import atlassianWorkspacesController from '../controllers/atlassian.workspaces.controller.js'; /** * CLI module for managing Bitbucket workspaces. * Provides commands for listing workspaces and retrieving workspace details. * All commands require valid Atlassian credentials. */ // Create a contextualized logger for this file const cliLogger = Logger.forContext('cli/atlassian.workspaces.cli.ts'); // Log CLI initialization cliLogger.debug('Bitbucket workspaces CLI module initialized'); /** * Register Bitbucket workspaces CLI commands with the Commander program * * @param program - The Commander program instance to register commands with * @throws Error if command registration fails */ function register(program: Command): void { const methodLogger = Logger.forContext( 'cli/atlassian.workspaces.cli.ts', 'register', ); methodLogger.debug('Registering Bitbucket Workspaces CLI commands...'); registerListWorkspacesCommand(program); registerGetWorkspaceCommand(program); methodLogger.debug('CLI commands registered successfully'); } /** * Register the command for listing Bitbucket workspaces * * @param program - The Commander program instance */ function registerListWorkspacesCommand(program: Command): void { program .command('ls-workspaces') .description('List workspaces in your Bitbucket account.') .option( '-l, --limit <number>', 'Maximum number of workspaces to retrieve (1-100). Default: 25.', ) .option( '-c, --cursor <string>', 'Pagination cursor for retrieving the next set of results.', ) .action(async (options) => { const actionLogger = cliLogger.forMethod('ls-workspaces'); try { actionLogger.debug('Processing command options:', options); // Map CLI options to controller params - keep only type conversions const controllerOptions = { limit: options.limit ? parseInt(options.limit, 10) : undefined, cursor: options.cursor, }; // Call controller directly const result = await atlassianWorkspacesController.list(controllerOptions); console.log(result.content); } catch (error) { actionLogger.error('Operation failed:', error); handleCliError(error); } }); } /** * Register the command for retrieving a specific Bitbucket workspace * * @param program - The Commander program instance */ function registerGetWorkspaceCommand(program: Command): void { program .command('get-workspace') .description( 'Get detailed information about a specific Bitbucket workspace.', ) .requiredOption( '-w, --workspace-slug <slug>', 'Workspace slug to retrieve. Must be a valid workspace slug from your Bitbucket account. Example: "myteam"', ) .action(async (options) => { const actionLogger = Logger.forContext( 'cli/atlassian.workspaces.cli.ts', 'get-workspace', ); try { actionLogger.debug( `Fetching workspace: ${options.workspaceSlug}`, ); // Call controller directly with passed options const result = await atlassianWorkspacesController.get({ workspaceSlug: options.workspaceSlug, }); console.log(result.content); } catch (error) { actionLogger.error('Operation failed:', error); handleCliError(error); } }); } export default { register }; ``` -------------------------------------------------------------------------------- /src/tools/atlassian.diff.tool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { Logger } from '../utils/logger.util.js'; import { formatErrorForMcpTool } from '../utils/error.util.js'; import diffController from '../controllers/atlassian.diff.controller.js'; import { BranchDiffArgsSchema, CommitDiffArgsSchema, type BranchDiffArgsType, type CommitDiffArgsType, } from './atlassian.diff.types.js'; // Create a contextualized logger for this file const toolLogger = Logger.forContext('tools/atlassian.diff.tool.ts'); // Log tool initialization toolLogger.debug('Bitbucket diff tool initialized'); /** * Handles branch diff requests * @param args - Arguments for the branch diff operation * @returns MCP tool response */ async function branchDiff(args: Record<string, unknown>) { const methodLogger = toolLogger.forMethod('branchDiff'); try { methodLogger.debug('Processing branch diff tool request', args); // Pass args directly to controller without any business logic const result = await diffController.branchDiff( args as BranchDiffArgsType, ); methodLogger.debug( 'Successfully retrieved branch diff from controller', ); return { content: [ { type: 'text' as const, text: result.content, }, ], }; } catch (error) { methodLogger.error('Failed to retrieve branch diff', error); return formatErrorForMcpTool(error); } } /** * Handles commit diff requests * @param args - Arguments for the commit diff operation * @returns MCP tool response */ async function commitDiff(args: Record<string, unknown>) { const methodLogger = toolLogger.forMethod('commitDiff'); try { methodLogger.debug('Processing commit diff tool request', args); // Pass args directly to controller without any business logic const result = await diffController.commitDiff( args as CommitDiffArgsType, ); methodLogger.debug( 'Successfully retrieved commit diff from controller', ); return { content: [ { type: 'text' as const, text: result.content, }, ], }; } catch (error) { methodLogger.error('Failed to retrieve commit diff', error); return formatErrorForMcpTool(error); } } /** * Register all Bitbucket diff tools with the MCP server. */ function registerTools(server: McpServer) { const registerLogger = Logger.forContext( 'tools/atlassian.diff.tool.ts', 'registerTools', ); registerLogger.debug('Registering Diff tools...'); // Register the branch diff tool server.tool( 'bb_diff_branches', `Shows changes between branches in a repository identified by \`workspaceSlug\` and \`repoSlug\`. Compares changes in \`sourceBranch\` relative to \`destinationBranch\`. Limits the number of files to show with \`limit\`. Returns the diff as formatted Markdown showing file changes, additions, and deletions. Requires Bitbucket credentials to be configured.`, BranchDiffArgsSchema.shape, branchDiff, ); // Register the commit diff tool server.tool( 'bb_diff_commits', `Shows changes between commits in a repository identified by \`workspaceSlug\` and \`repoSlug\`. Requires \`sinceCommit\` and \`untilCommit\` to identify the specific commits to compare. Returns the diff as formatted Markdown showing file changes, additions, and deletions between the commits. Requires Bitbucket credentials to be configured.`, CommitDiffArgsSchema.shape, commitDiff, ); registerLogger.debug('Successfully registered Diff tools'); } export default { registerTools }; ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.repositories.commit.controller.ts: -------------------------------------------------------------------------------- ```typescript import atlassianRepositoriesService from '../services/vendor.atlassian.repositories.service.js'; import { Logger } from '../utils/logger.util.js'; import { handleControllerError } from '../utils/error-handler.util.js'; import { DEFAULT_PAGE_SIZE, applyDefaults } from '../utils/defaults.util.js'; import { extractPaginationInfo, PaginationType, } from '../utils/pagination.util.js'; import { formatPagination } from '../utils/formatter.util.js'; import { ControllerResponse } from '../types/common.types.js'; import { GetCommitHistoryToolArgsType } from '../tools/atlassian.repositories.types.js'; import { formatCommitHistory } from './atlassian.repositories.formatter.js'; import { ListCommitsParams } from '../services/vendor.atlassian.repositories.types.js'; import { getDefaultWorkspace } from '../utils/workspace.util.js'; // Logger instance for this module const logger = Logger.forContext( 'controllers/atlassian.repositories.commit.controller.ts', ); /** * Get commit history for a repository * * @param options - Options containing repository identifiers and filters * @returns Promise with formatted commit history content and pagination info */ export async function handleCommitHistory( options: GetCommitHistoryToolArgsType, ): Promise<ControllerResponse> { const methodLogger = logger.forMethod('handleCommitHistory'); try { methodLogger.debug('Getting commit history', options); // Apply defaults const defaults = { limit: DEFAULT_PAGE_SIZE, }; const params = applyDefaults( options, defaults, ) as GetCommitHistoryToolArgsType & { limit: number; }; // Handle optional workspaceSlug if (!params.workspaceSlug) { methodLogger.debug( 'No workspace provided, fetching default workspace', ); const defaultWorkspace = await getDefaultWorkspace(); if (!defaultWorkspace) { throw new Error( 'No default workspace found. Please provide a workspace slug.', ); } params.workspaceSlug = defaultWorkspace; methodLogger.debug(`Using default workspace: ${defaultWorkspace}`); } const serviceParams: ListCommitsParams = { workspace: params.workspaceSlug, repo_slug: params.repoSlug, include: params.revision, path: params.path, pagelen: params.limit, page: params.cursor ? parseInt(params.cursor, 10) : undefined, }; methodLogger.debug('Fetching commits with params:', serviceParams); const commitsData = await atlassianRepositoriesService.listCommits(serviceParams); methodLogger.debug( `Retrieved ${commitsData.values?.length || 0} commits`, ); // Extract pagination info before formatting const pagination = extractPaginationInfo( commitsData, PaginationType.PAGE, ); const formattedHistory = formatCommitHistory(commitsData, { revision: params.revision, path: params.path, }); // Create the final content by combining the formatted commit history with pagination information let finalContent = formattedHistory; // Add pagination information if available if ( pagination && (pagination.hasMore || pagination.count !== undefined) ) { const paginationString = formatPagination(pagination); finalContent += '\n\n' + paginationString; } return { content: finalContent, }; } catch (error) { throw handleControllerError(error, { entityType: 'Commit History', operation: 'retrieving', source: 'controllers/atlassian.repositories.commit.controller.ts@handleCommitHistory', additionalInfo: { options }, }); } } ``` -------------------------------------------------------------------------------- /src/tools/atlassian.search.tool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { Logger } from '../utils/logger.util.js'; import { SearchToolArgsSchema, type SearchToolArgsType, } from './atlassian.search.types.js'; import atlassianSearchController from '../controllers/atlassian.search.controller.js'; import { formatErrorForMcpTool } from '../utils/error.util.js'; import { getDefaultWorkspace } from '../utils/workspace.util.js'; // Set up logger const logger = Logger.forContext('tools/atlassian.search.tool.ts'); /** * Handle search command in MCP */ async function handleSearch(args: Record<string, unknown>) { // Create a method-scoped logger const methodLogger = logger.forMethod('handleSearch'); try { methodLogger.debug('Search tool called with args:', args); // Handle workspace similar to CLI implementation let workspace = args.workspaceSlug; if (!workspace) { const defaultWorkspace = await getDefaultWorkspace(); if (!defaultWorkspace) { return { content: [ { type: 'text' as const, text: 'Error: No workspace provided and no default workspace configured', }, ], }; } workspace = defaultWorkspace; methodLogger.debug(`Using default workspace: ${workspace}`); } // Pass args to controller with workspace added const searchArgs: SearchToolArgsType = { workspaceSlug: workspace as string, repoSlug: args.repoSlug as string | undefined, query: args.query as string, scope: (args.scope as | 'code' | 'content' | 'repositories' | 'pullrequests') || 'code', contentType: args.contentType as string | undefined, language: args.language as string | undefined, extension: args.extension as string | undefined, limit: args.limit as number | undefined, cursor: args.cursor as string | undefined, }; // Call the controller with proper parameter mapping const controllerOptions = { workspace: searchArgs.workspaceSlug, repo: searchArgs.repoSlug, query: searchArgs.query, type: searchArgs.scope, contentType: searchArgs.contentType, language: searchArgs.language, extension: searchArgs.extension, limit: searchArgs.limit, cursor: searchArgs.cursor, }; const result = await atlassianSearchController.search( controllerOptions as Parameters< typeof atlassianSearchController.search >[0], ); // Return the result content in MCP format return { content: [{ type: 'text' as const, text: result.content }], }; } catch (error) { // Log the error methodLogger.error('Search tool failed:', error); // Format the error for MCP response return formatErrorForMcpTool(error); } } /** * Register the search tools with the MCP server */ function registerTools(server: McpServer) { // Register the search tool using the schema shape server.tool( 'bb_search', 'Searches Bitbucket for content matching the provided query. Use this tool to find repositories, code, pull requests, or other content in Bitbucket. Specify `scope` to narrow your search ("code", "repositories", "pullrequests", or "content"). Filter code searches by `language` or `extension`. Filter content searches by `contentType`. Only searches within the specified `workspaceSlug` and optionally within a specific `repoSlug`. Supports pagination via `limit` and `cursor`. Requires Atlassian Bitbucket credentials configured. Returns search results as Markdown.', SearchToolArgsSchema.shape, handleSearch, ); logger.debug('Successfully registered Bitbucket search tools'); } export default { registerTools }; ``` -------------------------------------------------------------------------------- /src/tools/atlassian.diff.types.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; /** * Schema for the branch diff tool arguments */ export const BranchDiffArgsSchema = z.object({ /** * Workspace slug containing the repository */ workspaceSlug: z .string() .optional() .describe( 'Workspace slug containing the repository. If not provided, the system will use your default workspace (either configured via BITBUCKET_DEFAULT_WORKSPACE or the first workspace in your account). Example: "myteam"', ), /** * Repository slug containing the branches */ repoSlug: z .string() .min(1, 'Repository slug is required') .describe( 'Repository slug containing the branches. Must be a valid repository slug in the specified workspace. Example: "project-api"', ), /** * Source branch (feature branch) */ sourceBranch: z .string() .min(1, 'Source branch is required') .describe( 'Source branch for comparison. IMPORTANT NOTE: The output displays as "destinationBranch → sourceBranch", and parameter naming can be counterintuitive. For full code diffs, try both parameter orders if initial results show only summary. Example: "feature/login-redesign"', ), /** * Destination branch (target branch like main/master) */ destinationBranch: z .string() .optional() .describe( 'Destination branch for comparison. IMPORTANT NOTE: The output displays as "destinationBranch → sourceBranch", and parameter naming can be counterintuitive. For full code diffs, try both parameter orders if initial results show only summary. If not specified, defaults to "main". Example: "develop"', ), /** * Include full diff in the output */ includeFullDiff: z .boolean() .optional() .describe( 'Whether to include the full code diff in the output. Defaults to true for rich output.', ), /** * Maximum number of files to return per page */ limit: z .number() .int() .positive() .optional() .describe('Maximum number of changed files to return in results'), /** * Pagination cursor for retrieving additional results */ cursor: z .number() .int() .positive() .optional() .describe('Pagination cursor for retrieving additional results'), }); export type BranchDiffArgsType = z.infer<typeof BranchDiffArgsSchema>; /** * Schema for the commit diff tool arguments */ export const CommitDiffArgsSchema = z.object({ workspaceSlug: z .string() .optional() .describe( 'Workspace slug containing the repository. If not provided, the system will use your default workspace.', ), repoSlug: z .string() .min(1) .describe('Repository slug to compare commits in'), sinceCommit: z .string() .min(1) .describe( 'Base commit hash or reference. IMPORTANT NOTE: For proper results with code changes, this should be the NEWER commit (chronologically later). If you see "No changes detected", try reversing commit order.', ), untilCommit: z .string() .min(1) .describe( 'Target commit hash or reference. IMPORTANT NOTE: For proper results with code changes, this should be the OLDER commit (chronologically earlier). If you see "No changes detected", try reversing commit order.', ), includeFullDiff: z .boolean() .optional() .describe( 'Whether to include the full code diff in the response (default: false)', ), limit: z .number() .int() .positive() .optional() .describe('Maximum number of changed files to return in results'), cursor: z .number() .int() .positive() .optional() .describe('Pagination cursor for retrieving additional results'), }); export type CommitDiffArgsType = z.infer<typeof CommitDiffArgsSchema>; ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.pullrequests.create.controller.ts: -------------------------------------------------------------------------------- ```typescript import { ControllerResponse } from '../types/common.types.js'; import { CreatePullRequestParams } from '../services/vendor.atlassian.pullrequests.types.js'; import { CreatePullRequestToolArgsType } from '../tools/atlassian.pullrequests.types.js'; import { atlassianPullRequestsService, Logger, handleControllerError, formatPullRequestDetails, applyDefaults, optimizeBitbucketMarkdown, getDefaultWorkspace, } from './atlassian.pullrequests.base.controller.js'; /** * Create a new pull request in Bitbucket * @param options - Options including workspace slug, repo slug, source branch, target branch, title, etc. * @returns Promise with formatted pull request details as Markdown content */ async function add( options: CreatePullRequestToolArgsType, ): Promise<ControllerResponse> { const methodLogger = Logger.forContext( 'controllers/atlassian.pullrequests.create.controller.ts', 'add', ); try { // Apply defaults if needed (none for this operation) const mergedOptions = applyDefaults<CreatePullRequestToolArgsType>( options, {}, ); // Handle optional workspaceSlug - get default if not provided if (!mergedOptions.workspaceSlug) { methodLogger.debug( 'No workspace provided, fetching default workspace', ); const defaultWorkspace = await getDefaultWorkspace(); if (!defaultWorkspace) { throw new Error( 'Could not determine a default workspace. Please provide a workspaceSlug.', ); } mergedOptions.workspaceSlug = defaultWorkspace; methodLogger.debug( `Using default workspace: ${mergedOptions.workspaceSlug}`, ); } const { workspaceSlug, repoSlug, title, sourceBranch, destinationBranch, description, closeSourceBranch, } = mergedOptions; // Validate required parameters if ( !workspaceSlug || !repoSlug || !title || !sourceBranch || !destinationBranch ) { throw new Error( 'Workspace slug, repository slug, title, source branch, and destination branch are required', ); } methodLogger.debug( `Creating PR in ${workspaceSlug}/${repoSlug} from ${sourceBranch} to ${destinationBranch}`, { title, descriptionLength: description?.length, closeSourceBranch, }, ); // Process description - optimize Markdown if provided const optimizedDescription = description ? optimizeBitbucketMarkdown(description) : undefined; // Map controller options to service parameters const serviceParams: CreatePullRequestParams = { workspace: workspaceSlug, repo_slug: repoSlug, title, source: { branch: { name: sourceBranch, }, }, destination: { branch: { name: destinationBranch, }, }, description: optimizedDescription, close_source_branch: closeSourceBranch, }; // Create the pull request through the service const pullRequestResult = await atlassianPullRequestsService.create(serviceParams); methodLogger.debug('Pull request created successfully', { id: pullRequestResult.id, title: pullRequestResult.title, sourceBranch, destinationBranch, }); // Format the pull request details using the formatter const formattedContent = formatPullRequestDetails(pullRequestResult); // Return formatted content with success message return { content: `## Pull Request Created Successfully\n\n${formattedContent}`, }; } catch (error) { // Use the standardized error handler throw handleControllerError(error, { entityType: 'Pull Request', operation: 'creating', source: 'controllers/atlassian.pullrequests.create.controller.ts@add', additionalInfo: { options }, }); } } // Export the controller functions export default { add }; ``` -------------------------------------------------------------------------------- /STYLE_GUIDE.md: -------------------------------------------------------------------------------- ```markdown # MCP Server Style Guide Based on the patterns observed and best practices, I recommend adopting the following consistent style guide across all your MCP servers: | Element | Convention | Rationale / Examples | | :------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ | | **CLI Commands** | `verb-noun` in `kebab-case`. Use the shortest unambiguous verb (`ls`, `get`, `create`, `add`, `exec`, `search`). | `ls-repos`, `get-pr`, `create-comment`, `exec-command` | | **CLI Options** | `--kebab-case`. Be specific (e.g., `--workspace-slug`, not just `--slug`). | `--project-key-or-id`, `--source-branch` | | **MCP Tool Names** | `<namespace>_<verb>_<noun>` in `snake_case`. Use a concise 2-4 char namespace. Avoid noun repetition. | `bb_ls_repos` (Bitbucket list repos), `conf_get_page` (Confluence get page), `aws_exec_command` (AWS execute command). Avoid `ip_ip_get_details`. | | **MCP Arguments** | `camelCase`. Suffix identifiers consistently (e.g., `Id`, `Key`, `Slug`). Avoid abbreviations unless universal. | `workspaceSlug`, `pullRequestId`, `sourceBranch`, `pageId`. | | **Boolean Args** | Use verb prefixes for clarity (`includeXxx`, `launchBrowser`). Avoid bare adjectives (`--https`). | `includeExtendedData: boolean`, `launchBrowser: boolean` | | **Array Args** | Use plural names (`spaceIds`, `labels`, `statuses`). | `spaceIds: string[]`, `labels: string[]` | | **Descriptions** | **Start with an imperative verb.** Keep the first sentence concise (≤120 chars). Add 1-2 sentences detail. Mention pre-requisites/notes last. | `List available Confluence spaces. Filters by type, status, or query. Returns formatted list including ID, key, name.` | | **Arg Descriptions** | Start lowercase, explain purpose clearly. Mention defaults or constraints. | `numeric ID of the page to retrieve (e.g., "456789"). Required.` | | **ID/Key Naming** | Use consistent suffixes like `Id`, `Key`, `Slug`, `KeyOrId` where appropriate. | `pageId`, `projectKeyOrId`, `workspaceSlug` | Adopting this guide will make the tools more predictable and easier for both humans and AI agents to understand and use correctly. ``` -------------------------------------------------------------------------------- /src/tools/atlassian.workspaces.tool.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { Logger } from '../utils/logger.util.js'; import { formatErrorForMcpTool } from '../utils/error.util.js'; import { ListWorkspacesToolArgs, type ListWorkspacesToolArgsType, type GetWorkspaceToolArgsType, GetWorkspaceToolArgs, } from './atlassian.workspaces.types.js'; import atlassianWorkspacesController from '../controllers/atlassian.workspaces.controller.js'; // Create a contextualized logger for this file const toolLogger = Logger.forContext('tools/atlassian.workspaces.tool.ts'); // Log tool initialization toolLogger.debug('Bitbucket workspaces tool initialized'); /** * MCP Tool: List Bitbucket Workspaces * * Lists Bitbucket workspaces available to the authenticated user with optional filtering. * Returns a formatted markdown response with workspace details. * * @param args - Tool arguments for filtering workspaces * @returns MCP response with formatted workspaces list * @throws Will return error message if workspace listing fails */ async function listWorkspaces(args: Record<string, unknown>) { const methodLogger = Logger.forContext( 'tools/atlassian.workspaces.tool.ts', 'listWorkspaces', ); methodLogger.debug('Listing Bitbucket workspaces with filters:', args); try { // Pass args directly to controller without any logic const result = await atlassianWorkspacesController.list( args as ListWorkspacesToolArgsType, ); methodLogger.debug('Successfully retrieved workspaces from controller'); return { content: [ { type: 'text' as const, text: result.content, }, ], }; } catch (error) { methodLogger.error('Failed to list workspaces', error); return formatErrorForMcpTool(error); } } /** * MCP Tool: Get Bitbucket Workspace Details * * Retrieves detailed information about a specific Bitbucket workspace. * Returns a formatted markdown response with workspace metadata. * * @param args - Tool arguments containing the workspace slug * @returns MCP response with formatted workspace details * @throws Will return error message if workspace retrieval fails */ async function getWorkspace(args: Record<string, unknown>) { const methodLogger = Logger.forContext( 'tools/atlassian.workspaces.tool.ts', 'getWorkspace', ); methodLogger.debug('Getting workspace details:', args); try { // Pass args directly to controller without any logic const result = await atlassianWorkspacesController.get( args as GetWorkspaceToolArgsType, ); methodLogger.debug( 'Successfully retrieved workspace details from controller', ); return { content: [ { type: 'text' as const, text: result.content, }, ], }; } catch (error) { methodLogger.error('Failed to get workspace details', error); return formatErrorForMcpTool(error); } } /** * Register all Bitbucket workspace tools with the MCP server. */ function registerTools(server: McpServer) { const registerLogger = Logger.forContext( 'tools/atlassian.workspaces.tool.ts', 'registerTools', ); registerLogger.debug('Registering Workspace tools...'); // Register the list workspaces tool server.tool( 'bb_ls_workspaces', `Lists workspaces within your Bitbucket account. Returns a formatted Markdown list showing workspace slugs, names, and membership role. Requires Bitbucket credentials to be configured.`, ListWorkspacesToolArgs.shape, listWorkspaces, ); // Register the get workspace details tool server.tool( 'bb_get_workspace', `Retrieves detailed information for a workspace identified by \`workspaceSlug\`. Returns comprehensive workspace details as formatted Markdown, including membership, projects, and key metadata. Requires Bitbucket credentials to be configured.`, GetWorkspaceToolArgs.shape, getWorkspace, ); registerLogger.debug('Successfully registered Workspace tools'); } export default { registerTools }; ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.search.controller.ts: -------------------------------------------------------------------------------- ```typescript import { Logger } from '../utils/logger.util.js'; import { ContentType } from '../utils/atlassian.util.js'; import { handleCodeSearch } from './atlassian.search.code.controller.js'; import { handleContentSearch } from './atlassian.search.content.controller.js'; import { handleRepositorySearch } from './atlassian.search.repositories.controller.js'; import { handlePullRequestSearch } from './atlassian.search.pullrequests.controller.js'; import { DEFAULT_PAGE_SIZE, applyDefaults } from '../utils/defaults.util.js'; import { ControllerResponse } from '../types/common.types.js'; import { handleControllerError } from '../utils/error-handler.util.js'; // Logger instance for this module const logger = Logger.forContext('controllers/atlassian.search.controller.ts'); /** * Search interface options */ export interface SearchOptions { /** The workspace to search in */ workspace?: string; /** The repository to search in (optional) */ repo?: string; /** The search query */ query?: string; /** The type of search to perform */ type?: string; /** The content type to filter by (for content search) */ contentType?: string; /** The language to filter by (for code search) */ language?: string; /** File extension to filter by (for code search) */ extension?: string; /** Maximum number of results to return */ limit?: number; /** Pagination cursor */ cursor?: string; } /** * Perform a search across various Bitbucket data types * * @param options Search options * @returns Formatted search results */ async function search( options: SearchOptions = {}, ): Promise<ControllerResponse> { const methodLogger = logger.forMethod('search'); try { // Apply default values const defaults: Partial<SearchOptions> = { type: 'code', workspace: '', limit: DEFAULT_PAGE_SIZE, }; const params = applyDefaults<SearchOptions>(options, defaults); methodLogger.debug('Search options (with defaults):', params); // Validate parameters if (!params.workspace) { methodLogger.warn('No workspace provided for search'); return { content: 'Error: Please provide a workspace to search in.', }; } // Convert content type string to enum if provided (outside the switch statement) let contentTypeEnum: ContentType | undefined = undefined; if (params.contentType) { contentTypeEnum = params.contentType.toLowerCase() as ContentType; } // Dispatch to the appropriate search function based on type switch (params.type?.toLowerCase()) { case 'code': return await handleCodeSearch( params.workspace, params.repo, params.query, params.limit, params.cursor, params.language, params.extension, ); case 'content': return await handleContentSearch( params.workspace, params.repo, params.query, params.limit, params.cursor, contentTypeEnum, ); case 'repos': case 'repositories': return await handleRepositorySearch( params.workspace, params.repo, params.query, params.limit, params.cursor, ); case 'prs': case 'pullrequests': if (!params.repo) { return { content: 'Error: Repository is required for pull request search.', }; } return await handlePullRequestSearch( params.workspace, params.repo, params.query, params.limit, params.cursor, ); default: methodLogger.warn(`Unknown search type: ${params.type}`); return { content: `Error: Unknown search type "${params.type}". Supported types are: code, content, repositories, pullrequests.`, }; } } catch (error) { // Pass the error to the handler with context throw handleControllerError(error, { entityType: 'Search', operation: 'search', source: 'controllers/atlassian.search.controller.ts@search', additionalInfo: options as Record<string, unknown>, }); } } export default { search }; ``` -------------------------------------------------------------------------------- /src/utils/config.util.test.ts: -------------------------------------------------------------------------------- ```typescript import { ErrorType, McpError, createApiError, createAuthMissingError, createAuthInvalidError, createUnexpectedError, ensureMcpError, formatErrorForMcpTool, formatErrorForMcpResource, } from './error.util.js'; describe('Error Utility', () => { describe('McpError', () => { it('should create an error with the correct properties', () => { const error = new McpError('Test error', ErrorType.API_ERROR, 404); expect(error).toBeInstanceOf(Error); expect(error).toBeInstanceOf(McpError); expect(error.message).toBe('Test error'); expect(error.type).toBe(ErrorType.API_ERROR); expect(error.statusCode).toBe(404); expect(error.name).toBe('McpError'); }); }); describe('Error Factory Functions', () => { it('should create auth missing error', () => { const error = createAuthMissingError(); expect(error).toBeInstanceOf(McpError); expect(error.type).toBe(ErrorType.AUTH_MISSING); expect(error.message).toBe( 'Authentication credentials are missing', ); }); it('should create auth invalid error', () => { const error = createAuthInvalidError('Invalid token'); expect(error).toBeInstanceOf(McpError); expect(error.type).toBe(ErrorType.AUTH_INVALID); expect(error.statusCode).toBe(401); expect(error.message).toBe('Invalid token'); }); it('should create API error', () => { const originalError = new Error('Original error'); const error = createApiError('API failed', 500, originalError); expect(error).toBeInstanceOf(McpError); expect(error.type).toBe(ErrorType.API_ERROR); expect(error.statusCode).toBe(500); expect(error.message).toBe('API failed'); expect(error.originalError).toBe(originalError); }); it('should create unexpected error', () => { const error = createUnexpectedError(); expect(error).toBeInstanceOf(McpError); expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR); expect(error.message).toBe('An unexpected error occurred'); }); }); describe('ensureMcpError', () => { it('should return the same error if it is already an McpError', () => { const originalError = createApiError('Original error'); const error = ensureMcpError(originalError); expect(error).toBe(originalError); }); it('should wrap a standard Error', () => { const originalError = new Error('Standard error'); const error = ensureMcpError(originalError); expect(error).toBeInstanceOf(McpError); expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR); expect(error.message).toBe('Standard error'); expect(error.originalError).toBe(originalError); }); it('should handle non-Error objects', () => { const error = ensureMcpError('String error'); expect(error).toBeInstanceOf(McpError); expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR); expect(error.message).toBe('String error'); }); }); describe('formatErrorForMcpTool', () => { it('should format an error for MCP tool response', () => { const error = createApiError('API error'); const response = formatErrorForMcpTool(error); expect(response).toHaveProperty('content'); expect(response.content).toHaveLength(1); expect(response.content[0]).toHaveProperty('type', 'text'); expect(response.content[0]).toHaveProperty( 'text', 'Error: API error', ); }); }); describe('formatErrorForMcpResource', () => { it('should format an error for MCP resource response', () => { const error = createApiError('API error'); const response = formatErrorForMcpResource(error, 'test://uri'); expect(response).toHaveProperty('contents'); expect(response.contents).toHaveLength(1); expect(response.contents[0]).toHaveProperty('uri', 'test://uri'); expect(response.contents[0]).toHaveProperty( 'text', 'Error: API error', ); expect(response.contents[0]).toHaveProperty( 'mimeType', 'text/plain', ); expect(response.contents[0]).toHaveProperty( 'description', 'Error: API_ERROR', ); }); }); }); ``` -------------------------------------------------------------------------------- /src/tools/atlassian.search.types.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; /** * Pagination arguments * Used for pagination of search results */ const PaginationArgs = z.object({ /** * Maximum number of items to return (1-100). * Use this to control the response size. * Useful for pagination or when you only need a few results. */ limit: z .number() .min(1) .max(100) .optional() .describe( 'Maximum number of results to return (1-100). Use this to control the response size. Useful for pagination or when you only need a few results.', ), /** * Pagination cursor for retrieving the next set of results. * For repositories and pull requests, this is a cursor string. * For code search, this is a page number. * Use this to navigate through large result sets. */ cursor: z .string() .optional() .describe( 'Pagination cursor for retrieving the next set of results. For repositories and pull requests, this is a cursor string. For code search, this is a page number. Use this to navigate through large result sets.', ), }); /** * Bitbucket search tool arguments schema base */ export const SearchToolArgsBase = z .object({ /** * Workspace slug to search in. Example: "myteam" * This maps to the CLI's "--workspace" parameter. */ workspaceSlug: z .string() .optional() .describe( 'Workspace slug to search in. If not provided, the system will use your default workspace. Example: "myteam". Equivalent to --workspace in CLI.', ), /** * Optional: Repository slug to limit search scope. Required for `pullrequests` scope. Example: "project-api" * This maps to the CLI's "--repo" parameter. */ repoSlug: z .string() .optional() .describe( 'Optional: Repository slug to limit search scope. Required for `pullrequests` scope. Example: "project-api". Equivalent to --repo in CLI.', ), /** * Search query text. Required. Will match against content based on the selected search scope. * This maps to the CLI's "--query" parameter. */ query: z .string() .min(1) .describe( 'Search query text. Required. Will match against content based on the selected search scope. Equivalent to --query in CLI.', ), /** * Search scope: "code", "content", "repositories", "pullrequests". Default: "code" * This maps to the CLI's "--type" parameter. */ scope: z .enum(['code', 'content', 'repositories', 'pullrequests']) .optional() .default('code') .describe( 'Search scope: "code", "content", "repositories", "pullrequests". Default: "code". Equivalent to --type in CLI.', ), /** * Content type for content search (e.g., "wiki", "issue") * This maps to the CLI's "--content-type" parameter. */ contentType: z .string() .optional() .describe( 'Content type for content search (e.g., "wiki", "issue"). Equivalent to --content-type in CLI.', ), /** * Filter code search by language. * This maps to the CLI's "--language" parameter. */ language: z .string() .optional() .describe( 'Filter code search by language. Equivalent to --language in CLI.', ), /** * Filter code search by file extension. * This maps to the CLI's "--extension" parameter. */ extension: z .string() .optional() .describe( 'Filter code search by file extension. Equivalent to --extension in CLI.', ), }) .merge(PaginationArgs); /** * Bitbucket search tool arguments schema with validation */ export const SearchToolArgs = SearchToolArgsBase.superRefine((data, ctx) => { // Make repoSlug required when scope is 'pullrequests' if (data.scope === 'pullrequests' && !data.repoSlug) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'repoSlug is required when scope is "pullrequests"', path: ['repoSlug'], }); } }); // Export both the schema and its shape for use with the MCP server export const SearchToolArgsSchema = SearchToolArgsBase; export type SearchToolArgsType = z.infer<typeof SearchToolArgs>; ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.pullrequests.list.controller.ts: -------------------------------------------------------------------------------- ```typescript import { ControllerResponse } from '../types/common.types.js'; import { ListPullRequestsParams } from '../services/vendor.atlassian.pullrequests.types.js'; import { ListPullRequestsToolArgsType } from '../tools/atlassian.pullrequests.types.js'; import { atlassianPullRequestsService, Logger, handleControllerError, extractPaginationInfo, PaginationType, formatPagination, formatPullRequestsList, DEFAULT_PAGE_SIZE, applyDefaults, getDefaultWorkspace, } from './atlassian.pullrequests.base.controller.js'; /** * List Bitbucket pull requests with optional filtering options * @param options - Options for listing pull requests including workspace slug and repo slug * @returns Promise with formatted pull requests list content and pagination information */ async function list( options: ListPullRequestsToolArgsType, ): Promise<ControllerResponse> { const methodLogger = Logger.forContext( 'controllers/atlassian.pullrequests.list.controller.ts', 'list', ); try { // Create defaults object with proper typing const defaults: Partial<ListPullRequestsToolArgsType> = { limit: DEFAULT_PAGE_SIZE, }; // Apply defaults const mergedOptions = applyDefaults<ListPullRequestsToolArgsType>( options, defaults, ); // Handle optional workspaceSlug - get default if not provided if (!mergedOptions.workspaceSlug) { methodLogger.debug( 'No workspace provided, fetching default workspace', ); const defaultWorkspace = await getDefaultWorkspace(); if (!defaultWorkspace) { throw new Error( 'Could not determine a default workspace. Please provide a workspaceSlug.', ); } mergedOptions.workspaceSlug = defaultWorkspace; methodLogger.debug( `Using default workspace: ${mergedOptions.workspaceSlug}`, ); } const { workspaceSlug, repoSlug } = mergedOptions; if (!workspaceSlug || !repoSlug) { throw new Error('Workspace slug and repository slug are required'); } methodLogger.debug( `Listing pull requests for ${workspaceSlug}/${repoSlug}...`, mergedOptions, ); // Format the query for Bitbucket API if provided - specifically target title/description const formattedQuery = mergedOptions.query ? `(title ~ "${mergedOptions.query}" OR description ~ "${mergedOptions.query}")` // Construct specific query for PRs : undefined; // Map controller options to service parameters const serviceParams: ListPullRequestsParams = { workspace: workspaceSlug, repo_slug: repoSlug, pagelen: mergedOptions.limit, page: mergedOptions.cursor ? parseInt(mergedOptions.cursor, 10) : undefined, state: mergedOptions.state, sort: '-updated_on', // Sort by most recently updated first ...(formattedQuery && { q: formattedQuery }), }; methodLogger.debug('Using service parameters:', serviceParams); const pullRequestsData = await atlassianPullRequestsService.list(serviceParams); methodLogger.debug( `Retrieved ${pullRequestsData.values?.length || 0} pull requests`, ); // Extract pagination information using the utility const pagination = extractPaginationInfo( pullRequestsData, PaginationType.PAGE, ); // Format the pull requests data for display using the formatter const formattedPullRequests = formatPullRequestsList(pullRequestsData); // Create the final content by combining the formatted pull requests with pagination information let finalContent = formattedPullRequests; // Add pagination information if available if ( pagination && (pagination.hasMore || pagination.count !== undefined) ) { const paginationString = formatPagination(pagination); finalContent += '\n\n' + paginationString; } return { content: finalContent, }; } catch (error) { // Use the standardized error handler throw handleControllerError(error, { entityType: 'Pull Requests', operation: 'listing', source: 'controllers/atlassian.pullrequests.list.controller.ts@list', additionalInfo: { options }, }); } } // Export the controller functions export default { list }; ``` -------------------------------------------------------------------------------- /src/services/vendor.atlassian.repositories.diff.service.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { Logger } from '../utils/logger.util.js'; import { NETWORK_TIMEOUTS } from '../utils/constants.util.js'; import { fetchAtlassian, getAtlassianCredentials, } from '../utils/transport.util.js'; import { createApiError, createAuthMissingError, McpError, } from '../utils/error.util.js'; import { GetDiffstatParamsSchema, DiffstatResponseSchema, GetRawDiffParamsSchema, type GetDiffstatParams, type DiffstatResponse, type GetRawDiffParams, } from './vendor.atlassian.repositories.diff.types.js'; /** * Base API path for Bitbucket REST API v2 */ const API_PATH = '/2.0'; const serviceLogger = Logger.forContext( 'services/vendor.atlassian.repositories.diff.service.ts', ); serviceLogger.debug('Bitbucket diff service initialised'); /** * Retrieve diffstat (per–file summary) between two refs (branches, tags or commits). * Follows Bitbucket Cloud endpoint: * GET /2.0/repositories/{workspace}/{repo_slug}/diffstat/{spec} */ export async function getDiffstat( params: GetDiffstatParams, ): Promise<DiffstatResponse> { const methodLogger = serviceLogger.forMethod('getDiffstat'); methodLogger.debug('Fetching diffstat with params', params); // Validate params try { GetDiffstatParamsSchema.parse(params); } catch (err) { if (err instanceof z.ZodError) { throw createApiError( `Invalid parameters: ${err.issues.map((e) => e.message).join(', ')}`, 400, err, ); } throw err; } const credentials = getAtlassianCredentials(); if (!credentials) { throw createAuthMissingError('Atlassian credentials are required'); } const query = new URLSearchParams(); if (params.pagelen) query.set('pagelen', String(params.pagelen)); if (params.cursor) query.set('page', String(params.cursor)); if (params.topic !== undefined) query.set('topic', String(params.topic)); const queryString = query.toString() ? `?${query.toString()}` : ''; const encodedSpec = encodeURIComponent(params.spec); const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/diffstat/${encodedSpec}${queryString}`; methodLogger.debug(`Requesting: ${path}`); try { const rawData = await fetchAtlassian(credentials, path); try { const validated = DiffstatResponseSchema.parse(rawData); return validated; } catch (error) { if (error instanceof z.ZodError) { methodLogger.error( 'Bitbucket API response validation failed:', error.format(), ); throw createApiError( `Invalid response format from Bitbucket API for diffstat: ${error.message}`, 500, error, ); } throw error; // Re-throw any other errors } } catch (error) { if (error instanceof McpError) throw error; throw createApiError( `Failed to fetch diffstat: ${error instanceof Error ? error.message : String(error)}`, 500, error, ); } } /** * Retrieve raw unified diff between two refs. * Endpoint: /diff/{spec} */ export async function getRawDiff(params: GetRawDiffParams): Promise<string> { const methodLogger = serviceLogger.forMethod('getRawDiff'); methodLogger.debug('Fetching raw diff', params); try { GetRawDiffParamsSchema.parse(params); } catch (err) { if (err instanceof z.ZodError) { throw createApiError( `Invalid parameters: ${err.issues.map((e) => e.message).join(', ')}`, 400, err, ); } throw err; } const credentials = getAtlassianCredentials(); if (!credentials) { throw createAuthMissingError('Atlassian credentials are required'); } const encodedSpec = encodeURIComponent(params.spec); const path = `${API_PATH}/repositories/${params.workspace}/${params.repo_slug}/diff/${encodedSpec}`; methodLogger.debug(`Requesting: ${path}`); try { // fetchAtlassian will return string for text/plain const diffText = await fetchAtlassian<string>(credentials, path, { timeout: NETWORK_TIMEOUTS.LARGE_REQUEST_TIMEOUT, }); return diffText; } catch (error) { if (error instanceof McpError) throw error; throw createApiError( `Failed to fetch raw diff: ${error instanceof Error ? error.message : String(error)}`, 500, error, ); } } ``` -------------------------------------------------------------------------------- /src/utils/adf.util.test.ts: -------------------------------------------------------------------------------- ```typescript import { adfToMarkdown } from './adf.util.js'; describe('ADF Utility', () => { describe('adfToMarkdown', () => { it('should handle empty or undefined input', () => { expect(adfToMarkdown(null)).toBe(''); expect(adfToMarkdown(undefined)).toBe(''); expect(adfToMarkdown('')).toBe(''); }); it('should handle non-ADF string input', () => { expect(adfToMarkdown('plain text')).toBe('plain text'); }); it('should convert basic paragraph', () => { const adf = { type: 'doc', version: 1, content: [ { type: 'paragraph', content: [ { type: 'text', text: 'This is a paragraph', }, ], }, ], }; expect(adfToMarkdown(adf)).toBe('This is a paragraph'); }); it('should convert multiple paragraphs', () => { const adf = { type: 'doc', version: 1, content: [ { type: 'paragraph', content: [ { type: 'text', text: 'First paragraph', }, ], }, { type: 'paragraph', content: [ { type: 'text', text: 'Second paragraph', }, ], }, ], }; expect(adfToMarkdown(adf)).toBe( 'First paragraph\n\nSecond paragraph', ); }); it('should convert headings', () => { const adf = { type: 'doc', version: 1, content: [ { type: 'heading', attrs: { level: 1 }, content: [ { type: 'text', text: 'Heading 1', }, ], }, { type: 'heading', attrs: { level: 2 }, content: [ { type: 'text', text: 'Heading 2', }, ], }, ], }; expect(adfToMarkdown(adf)).toBe('# Heading 1\n\n## Heading 2'); }); it('should convert text with marks', () => { const adf = { type: 'doc', version: 1, content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Bold', marks: [{ type: 'strong' }], }, { type: 'text', text: ' and ', }, { type: 'text', text: 'italic', marks: [{ type: 'em' }], }, { type: 'text', text: ' and ', }, { type: 'text', text: 'code', marks: [{ type: 'code' }], }, ], }, ], }; expect(adfToMarkdown(adf)).toBe('**Bold** and *italic* and `code`'); }); it('should convert bullet lists', () => { const adf = { type: 'doc', version: 1, content: [ { type: 'bulletList', content: [ { type: 'listItem', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Item 1', }, ], }, ], }, { type: 'listItem', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Item 2', }, ], }, ], }, ], }, ], }; expect(adfToMarkdown(adf)).toBe('- Item 1\n- Item 2'); }); it('should convert code blocks', () => { const adf = { type: 'doc', version: 1, content: [ { type: 'codeBlock', attrs: { language: 'javascript' }, content: [ { type: 'text', text: 'const x = 1;', }, ], }, ], }; expect(adfToMarkdown(adf)).toBe('```javascript\nconst x = 1;\n```'); }); it('should convert links', () => { const adf = { type: 'doc', version: 1, content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Visit', }, { type: 'text', text: ' Atlassian', marks: [ { type: 'link', attrs: { href: 'https://atlassian.com', }, }, ], }, ], }, ], }; expect(adfToMarkdown(adf)).toBe( 'Visit[ Atlassian](https://atlassian.com)', ); }); }); }); ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.pullrequests.base.controller.ts: -------------------------------------------------------------------------------- ```typescript import atlassianPullRequestsService from '../services/vendor.atlassian.pullrequests.service.js'; import { Logger } from '../utils/logger.util.js'; import { handleControllerError } from '../utils/error-handler.util.js'; import { extractPaginationInfo, PaginationType, } from '../utils/pagination.util.js'; import { formatPagination } from '../utils/formatter.util.js'; import { formatPullRequestsList, formatPullRequestDetails, formatPullRequestComments, } from './atlassian.pullrequests.formatter.js'; import { PullRequestComment, PullRequestCommentsResponse, } from '../services/vendor.atlassian.pullrequests.types.js'; import { DEFAULT_PAGE_SIZE, applyDefaults } from '../utils/defaults.util.js'; import { extractDiffSnippet } from '../utils/diff.util.js'; import { optimizeBitbucketMarkdown } from '../utils/formatter.util.js'; import { getDefaultWorkspace } from '../utils/workspace.util.js'; /** * Base controller for managing Bitbucket pull requests. * Contains shared utilities and types used by the specific PR controller files. * * NOTE ON MARKDOWN HANDLING: * Unlike Jira (which uses ADF) or Confluence (which uses a mix of formats), * Bitbucket Cloud API natively accepts Markdown for text content in both directions: * - When sending data TO the API (comments, PR descriptions) * - When receiving data FROM the API (PR descriptions, comments) * * The API expects content in the format: { content: { raw: "markdown-text" } } * * We use optimizeBitbucketMarkdown() to address specific rendering quirks in * Bitbucket's markdown renderer but it does NOT perform format conversion. * See formatter.util.ts for details on the specific issues it addresses. */ // Define an extended type for internal use within the controller/formatter // to include the code snippet. export interface PullRequestCommentWithSnippet extends PullRequestComment { codeSnippet?: string; } // Define a service-specific type for listing comments export type ListCommentsParams = { workspace: string; repo_slug: string; pull_request_id: number; pagelen?: number; page?: number; }; // Define a service-specific type for creating comments export type CreateCommentParams = { workspace: string; repo_slug: string; pull_request_id: number; content: { raw: string; }; inline?: { path: string; to?: number; }; parent?: { id: number; }; }; // Helper function to enhance comments with code snippets export async function enhanceCommentsWithSnippets( commentsData: PullRequestCommentsResponse, controllerMethodName: string, // To contextualize logs ): Promise<PullRequestCommentWithSnippet[]> { const methodLogger = Logger.forContext( `controllers/atlassian.pullrequests.base.controller.ts`, controllerMethodName, // Use provided method name for logger context ); const commentsWithSnippets: PullRequestCommentWithSnippet[] = []; if (!commentsData.values || commentsData.values.length === 0) { return []; } for (const comment of commentsData.values) { let snippet = undefined; if ( comment.inline && comment.links?.code?.href && comment.inline.to !== undefined ) { try { methodLogger.debug( `Fetching diff for inline comment ${comment.id} from ${comment.links.code.href}`, ); const diffContent = await atlassianPullRequestsService.getDiffForUrl( comment.links.code.href, ); snippet = extractDiffSnippet(diffContent, comment.inline.to); methodLogger.debug( `Extracted snippet for comment ${comment.id} (length: ${snippet?.length})`, ); } catch (snippetError) { methodLogger.warn( `Failed to fetch or parse snippet for comment ${comment.id}:`, snippetError, ); // Continue without snippet if fetching/parsing fails } } commentsWithSnippets.push({ ...comment, codeSnippet: snippet }); } return commentsWithSnippets; } export { atlassianPullRequestsService, Logger, handleControllerError, extractPaginationInfo, PaginationType, formatPagination, formatPullRequestsList, formatPullRequestDetails, formatPullRequestComments, DEFAULT_PAGE_SIZE, applyDefaults, optimizeBitbucketMarkdown, getDefaultWorkspace, }; ``` -------------------------------------------------------------------------------- /src/utils/bitbucket-error-detection.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, expect, test } from '@jest/globals'; import { detectErrorType, ErrorCode } from './error-handler.util.js'; import { createApiError } from './error.util.js'; describe('Bitbucket Error Detection', () => { describe('Classic Bitbucket error structure: { error: { message, detail } }', () => { test('detects not found errors', () => { // Create a mock Bitbucket error structure const bitbucketError = { error: { message: 'Repository not found', detail: 'The repository does not exist or you do not have access', }, }; const mcpError = createApiError('API Error', 404, bitbucketError); const result = detectErrorType(mcpError); expect(result).toEqual({ code: ErrorCode.NOT_FOUND, statusCode: 404, }); }); test('detects access denied errors', () => { const bitbucketError = { error: { message: 'Access denied to this repository', detail: 'You need admin permissions to perform this action', }, }; const mcpError = createApiError('API Error', 403, bitbucketError); const result = detectErrorType(mcpError); expect(result).toEqual({ code: ErrorCode.ACCESS_DENIED, statusCode: 403, }); }); test('detects validation errors', () => { const bitbucketError = { error: { message: 'Invalid parameter: repository name', detail: 'Repository name can only contain alphanumeric characters', }, }; const mcpError = createApiError('API Error', 400, bitbucketError); const result = detectErrorType(mcpError); expect(result).toEqual({ code: ErrorCode.VALIDATION_ERROR, statusCode: 400, }); }); test('detects rate limit errors', () => { const bitbucketError = { error: { message: 'Too many requests', detail: 'Rate limit exceeded. Try again later.', }, }; const mcpError = createApiError('API Error', 429, bitbucketError); const result = detectErrorType(mcpError); expect(result).toEqual({ code: ErrorCode.RATE_LIMIT_ERROR, statusCode: 429, }); }); }); describe('Alternate Bitbucket error structure: { type: "error", ... }', () => { test('detects not found errors', () => { const altBitbucketError = { type: 'error', status: 404, message: 'Resource not found', }; const mcpError = createApiError( 'API Error', 404, altBitbucketError, ); const result = detectErrorType(mcpError); expect(result).toEqual({ code: ErrorCode.NOT_FOUND, statusCode: 404, }); }); test('detects access denied errors', () => { const altBitbucketError = { type: 'error', status: 403, message: 'Forbidden', }; const mcpError = createApiError( 'API Error', 403, altBitbucketError, ); const result = detectErrorType(mcpError); expect(result).toEqual({ code: ErrorCode.ACCESS_DENIED, statusCode: 403, }); }); }); describe('Bitbucket errors array structure: { errors: [{ ... }] }', () => { test('detects errors from array structure', () => { const arrayBitbucketError = { errors: [ { status: 400, code: 'INVALID_REQUEST_PARAMETER', title: 'Invalid parameter value', message: 'The parameter is not valid', }, ], }; const mcpError = createApiError( 'API Error', 400, arrayBitbucketError, ); const result = detectErrorType(mcpError); expect(result).toEqual({ code: ErrorCode.VALIDATION_ERROR, statusCode: 400, }); }); }); describe('Network errors in Bitbucket context', () => { test('detects network errors from TypeError', () => { const networkError = new TypeError('Failed to fetch'); const mcpError = createApiError('Network Error', 500, networkError); const result = detectErrorType(mcpError); expect(result).toEqual({ code: ErrorCode.NETWORK_ERROR, statusCode: 500, }); }); test('detects other common network error messages', () => { const errorMessages = [ 'network error occurred', 'ECONNREFUSED', 'ENOTFOUND', 'Network request failed', 'Failed to fetch', ]; errorMessages.forEach((msg) => { const error = new Error(msg); const result = detectErrorType(error); expect(result).toEqual({ code: ErrorCode.NETWORK_ERROR, statusCode: 500, }); }); }); }); }); ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.workspaces.formatter.ts: -------------------------------------------------------------------------------- ```typescript import { WorkspaceDetailed, WorkspacePermissionsResponse, WorkspaceMembership, } from '../services/vendor.atlassian.workspaces.types.js'; import { formatUrl, formatHeading, formatBulletList, formatSeparator, formatNumberedList, formatDate, } from '../utils/formatter.util.js'; /** * Format a list of workspaces for display * @param workspacesData - Raw workspaces data from the API * @returns Formatted string with workspaces information in markdown format */ export function formatWorkspacesList( workspacesData: WorkspacePermissionsResponse, ): string { const workspaces = workspacesData.values || []; if (workspaces.length === 0) { return 'No workspaces found matching your criteria.'; } const lines: string[] = [formatHeading('Bitbucket Workspaces', 1), '']; // Format each workspace with its details const formattedList = formatNumberedList( workspaces, (membership, index) => { const workspace = membership.workspace; const itemLines: string[] = []; itemLines.push(formatHeading(workspace.name, 2)); // Basic information const properties: Record<string, unknown> = { UUID: workspace.uuid, Slug: workspace.slug, 'Permission Level': membership.permission || 'Unknown', 'Last Accessed': membership.last_accessed ? formatDate(new Date(membership.last_accessed)) : 'N/A', 'Added On': membership.added_on ? formatDate(new Date(membership.added_on)) : 'N/A', 'Web URL': workspace.links?.html?.href ? formatUrl(workspace.links.html.href, workspace.slug) : formatUrl( `https://bitbucket.org/${workspace.slug}/`, workspace.slug, ), User: membership.user?.display_name || membership.user?.nickname || 'Unknown', }; // Format as a bullet list itemLines.push(formatBulletList(properties, (key) => key)); // Add separator between workspaces except for the last one if (index < workspaces.length - 1) { itemLines.push(''); itemLines.push(formatSeparator()); } return itemLines.join('\n'); }, ); lines.push(formattedList); // Add standard footer with timestamp lines.push('\n\n' + formatSeparator()); lines.push(`*Information retrieved at: ${formatDate(new Date())}*`); return lines.join('\n'); } /** * Format detailed workspace information for display * @param workspace - Raw workspace data from the API * @param membership - Optional membership information for the workspace * @returns Formatted string with workspace details in markdown format */ export function formatWorkspaceDetails( workspace: WorkspaceDetailed, membership?: WorkspaceMembership, ): string { const lines: string[] = [ formatHeading(`Workspace: ${workspace.name}`, 1), '', formatHeading('Basic Information', 2), ]; // Format basic information as a bullet list const basicProperties: Record<string, unknown> = { UUID: workspace.uuid, Slug: workspace.slug, Type: workspace.type || 'Not specified', 'Created On': workspace.created_on ? formatDate(workspace.created_on) : 'N/A', }; lines.push(formatBulletList(basicProperties, (key) => key)); // Add membership information if available if (membership) { lines.push(''); lines.push(formatHeading('Your Membership', 2)); const membershipProperties: Record<string, unknown> = { Permission: membership.permission, 'Last Accessed': membership.last_accessed ? formatDate(membership.last_accessed) : 'N/A', 'Added On': membership.added_on ? formatDate(membership.added_on) : 'N/A', }; lines.push(formatBulletList(membershipProperties, (key) => key)); } // Add links lines.push(''); lines.push(formatHeading('Links', 2)); const links: string[] = []; if (workspace.links.html?.href) { links.push( `- ${formatUrl(workspace.links.html.href, 'View in Browser')}`, ); } if (workspace.links.repositories?.href) { links.push( `- ${formatUrl(workspace.links.repositories.href, 'Repositories')}`, ); } if (workspace.links.projects?.href) { links.push(`- ${formatUrl(workspace.links.projects.href, 'Projects')}`); } if (workspace.links.snippets?.href) { links.push(`- ${formatUrl(workspace.links.snippets.href, 'Snippets')}`); } lines.push(links.join('\n')); // Add standard footer with timestamp lines.push('\n\n' + formatSeparator()); lines.push(`*Information retrieved at: ${formatDate(new Date())}*`); return lines.join('\n'); } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "@aashari/mcp-server-atlassian-bitbucket", "version": "1.45.0", "description": "Node.js/TypeScript MCP server for Atlassian Bitbucket. Enables AI systems (LLMs) to interact with workspaces, repositories, and pull requests via tools (list, get, comment, search). Connects AI directly to version control workflows through the standard MCP interface.", "main": "dist/index.js", "types": "dist/index.d.ts", "type": "commonjs", "repository": { "type": "git", "url": "https://github.com/aashari/mcp-server-atlassian-bitbucket.git" }, "bin": { "mcp-atlassian-bitbucket": "./dist/index.js" }, "scripts": { "build": "tsc", "prepare": "npm run build && node scripts/ensure-executable.js", "postinstall": "node scripts/ensure-executable.js", "test": "jest", "test:coverage": "jest --coverage", "test:cli": "jest src/cli/.*\\.cli\\.test\\.ts --runInBand --testTimeout=60000", "lint": "eslint src --ext .ts --config eslint.config.mjs", "format": "prettier --write 'src/**/*.ts' 'scripts/**/*.js'", "publish:npm": "npm publish", "update:check": "npx npm-check-updates", "update:deps": "npx npm-check-updates -u && npm install --legacy-peer-deps", "update:version": "node scripts/update-version.js", "mcp:stdio": "TRANSPORT_MODE=stdio npm run build && node dist/index.js", "mcp:http": "TRANSPORT_MODE=http npm run build && node dist/index.js", "mcp:inspect": "TRANSPORT_MODE=http npm run build && (node dist/index.js &) && sleep 2 && npx @modelcontextprotocol/inspector http://localhost:3000/mcp", "dev:stdio": "npm run build && npx @modelcontextprotocol/inspector -e TRANSPORT_MODE=stdio -e DEBUG=true node dist/index.js", "dev:http": "DEBUG=true TRANSPORT_MODE=http npm run build && node dist/index.js", "dev:server": "DEBUG=true npm run build && npx @modelcontextprotocol/inspector -e DEBUG=true node dist/index.js", "dev:cli": "DEBUG=true npm run build && DEBUG=true node dist/index.js", "start:server": "npm run build && npx @modelcontextprotocol/inspector node dist/index.js", "start:cli": "npm run build && node dist/index.js" }, "keywords": [ "mcp", "typescript", "claude", "anthropic", "ai", "atlassian", "bitbucket", "repository", "version-control", "pull-request", "server", "model-context-protocol", "tools", "resources", "tooling", "ai-integration", "mcp-server", "llm", "ai-connector", "external-tools", "cli", "mcp-inspector" ], "author": "", "license": "ISC", "devDependencies": { "@eslint/js": "^9.35.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^11.0.5", "@semantic-release/npm": "^12.0.2", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jest": "^30.0.0", "@types/node": "^24.3.1", "@types/turndown": "^5.0.5", "@typescript-eslint/eslint-plugin": "^8.43.0", "@typescript-eslint/parser": "^8.43.0", "eslint": "^9.35.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-filenames": "^1.3.2", "eslint-plugin-prettier": "^5.5.4", "jest": "^30.1.3", "nodemon": "^3.1.10", "npm-check-updates": "^18.1.0", "prettier": "^3.6.2", "semantic-release": "^24.2.7", "ts-jest": "^29.4.1", "ts-node": "^10.9.2", "typescript": "^5.9.2", "typescript-eslint": "^8.43.0" }, "publishConfig": { "registry": "https://registry.npmjs.org/", "access": "public" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.17.5", "commander": "^14.0.0", "cors": "^2.8.5", "dotenv": "^17.2.2", "express": "^5.1.0", "turndown": "^7.2.1", "zod": "^3.25.76" }, "directories": { "example": "examples" }, "jest": { "preset": "ts-jest", "testEnvironment": "node", "setupFilesAfterEnv": [ "<rootDir>/jest.setup.js" ], "testMatch": [ "**/src/**/*.test.ts" ], "collectCoverageFrom": [ "src/**/*.ts", "!src/**/*.test.ts", "!src/**/*.spec.ts" ], "coveragePathIgnorePatterns": [ "/node_modules/", "/dist/", "/coverage/" ], "coverageReporters": [ "text", "lcov", "json-summary" ], "transform": { "^.+\\.tsx?$": [ "ts-jest", { "useESM": true } ] }, "moduleNameMapper": { "(.*)\\.(js|jsx)$": "$1" }, "extensionsToTreatAsEsm": [ ".ts" ], "moduleFileExtensions": [ "ts", "tsx", "js", "jsx", "json", "node" ] }, "engines": { "node": ">=18.0.0" } } ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.pullrequests.controller.ts: -------------------------------------------------------------------------------- ```typescript import { ControllerResponse } from '../types/common.types.js'; import { ListPullRequestsToolArgsType, GetPullRequestToolArgsType, ListPullRequestCommentsToolArgsType, CreatePullRequestCommentToolArgsType, CreatePullRequestToolArgsType, UpdatePullRequestToolArgsType, ApprovePullRequestToolArgsType, RejectPullRequestToolArgsType, } from '../tools/atlassian.pullrequests.types.js'; import listController from './atlassian.pullrequests.list.controller.js'; import getController from './atlassian.pullrequests.get.controller.js'; import commentsController from './atlassian.pullrequests.comments.controller.js'; import createController from './atlassian.pullrequests.create.controller.js'; import updateController from './atlassian.pullrequests.update.controller.js'; import approveController from './atlassian.pullrequests.approve.controller.js'; import rejectController from './atlassian.pullrequests.reject.controller.js'; /** * Controller for managing Bitbucket pull requests. * Provides functionality for listing, retrieving, and creating pull requests and comments. * * NOTE ON MARKDOWN HANDLING: * Unlike Jira (which uses ADF) or Confluence (which uses a mix of formats), * Bitbucket Cloud API natively accepts Markdown for text content in both directions: * - When sending data TO the API (comments, PR descriptions) * - When receiving data FROM the API (PR descriptions, comments) * * The API expects content in the format: { content: { raw: "markdown-text" } } * * We use optimizeBitbucketMarkdown() to address specific rendering quirks in * Bitbucket's markdown renderer but it does NOT perform format conversion. * See formatter.util.ts for details on the specific issues it addresses. */ /** * List Bitbucket pull requests with optional filtering options * @param options - Options for listing pull requests including workspace slug and repo slug * @returns Promise with formatted pull requests list content and pagination information */ async function list( options: ListPullRequestsToolArgsType, ): Promise<ControllerResponse> { return listController.list(options); } /** * Get detailed information about a specific Bitbucket pull request * @param options - Options including workspace slug, repo slug, and pull request ID * @returns Promise with formatted pull request details as Markdown content */ async function get( options: GetPullRequestToolArgsType, ): Promise<ControllerResponse> { return getController.get(options); } /** * List comments on a Bitbucket pull request * @param options - Options including workspace slug, repo slug, and pull request ID * @returns Promise with formatted pull request comments as Markdown content */ async function listComments( options: ListPullRequestCommentsToolArgsType, ): Promise<ControllerResponse> { return commentsController.listComments(options); } /** * Add a comment to a Bitbucket pull request * @param options - Options including workspace slug, repo slug, PR ID, and comment content * @returns Promise with a success message as content */ async function addComment( options: CreatePullRequestCommentToolArgsType, ): Promise<ControllerResponse> { return commentsController.addComment(options); } /** * Create a new pull request in Bitbucket * @param options - Options including workspace slug, repo slug, source branch, target branch, title, etc. * @returns Promise with formatted pull request details as Markdown content */ async function add( options: CreatePullRequestToolArgsType, ): Promise<ControllerResponse> { return createController.add(options); } /** * Update an existing pull request in Bitbucket * @param options - Options including workspace slug, repo slug, pull request ID, title, and description * @returns Promise with formatted updated pull request details as Markdown content */ async function update( options: UpdatePullRequestToolArgsType, ): Promise<ControllerResponse> { return updateController.update(options); } /** * Approve a pull request in Bitbucket * @param options - Options including workspace slug, repo slug, and pull request ID * @returns Promise with formatted approval confirmation as Markdown content */ async function approve( options: ApprovePullRequestToolArgsType, ): Promise<ControllerResponse> { return approveController.approve(options); } /** * Request changes on a pull request in Bitbucket * @param options - Options including workspace slug, repo slug, and pull request ID * @returns Promise with formatted rejection confirmation as Markdown content */ async function reject( options: RejectPullRequestToolArgsType, ): Promise<ControllerResponse> { return rejectController.reject(options); } // Export the controller functions export default { list, get, listComments, addComment, add, update, approve, reject, }; ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.workspaces.controller.ts: -------------------------------------------------------------------------------- ```typescript import atlassianWorkspacesService from '../services/vendor.atlassian.workspaces.service.js'; import { Logger } from '../utils/logger.util.js'; import { handleControllerError } from '../utils/error-handler.util.js'; import { extractPaginationInfo, PaginationType, } from '../utils/pagination.util.js'; import { ControllerResponse } from '../types/common.types.js'; import { ListWorkspacesToolArgsType, GetWorkspaceToolArgsType, } from '../tools/atlassian.workspaces.types.js'; import { formatWorkspacesList, formatWorkspaceDetails, } from './atlassian.workspaces.formatter.js'; import { ListWorkspacesParams } from '../services/vendor.atlassian.workspaces.types.js'; import { DEFAULT_PAGE_SIZE, applyDefaults } from '../utils/defaults.util.js'; import { formatPagination } from '../utils/formatter.util.js'; // Create a contextualized logger for this file const controllerLogger = Logger.forContext( 'controllers/atlassian.workspaces.controller.ts', ); // Log controller initialization controllerLogger.debug('Bitbucket workspaces controller initialized'); /** * Controller for managing Bitbucket workspaces. * Provides functionality for listing workspaces and retrieving workspace details. */ /** * List Bitbucket workspaces with optional filtering * @param options - Options for listing workspaces * @param options.limit - Maximum number of workspaces to return * @param options.cursor - Pagination cursor for retrieving the next set of results * @returns Promise with formatted workspace list content including pagination information */ async function list( options: ListWorkspacesToolArgsType, ): Promise<ControllerResponse> { const methodLogger = Logger.forContext( 'controllers/atlassian.workspaces.controller.ts', 'list', ); methodLogger.debug('Listing Bitbucket workspaces...', options); try { // Create defaults object with proper typing const defaults: Partial<ListWorkspacesToolArgsType> = { limit: DEFAULT_PAGE_SIZE, }; // Apply defaults const mergedOptions = applyDefaults<ListWorkspacesToolArgsType>( options, defaults, ); // Map controller filters to service params const serviceParams: ListWorkspacesParams = { pagelen: mergedOptions.limit, // Default page length page: mergedOptions.cursor ? parseInt(mergedOptions.cursor, 10) : undefined, // Use cursor value for page // NOTE: Sort parameter is not included as the Bitbucket API's /2.0/user/permissions/workspaces // endpoint does not support sorting on any field }; methodLogger.debug('Using filters:', serviceParams); const workspacesData = await atlassianWorkspacesService.list(serviceParams); methodLogger.debug( `Retrieved ${workspacesData.values?.length || 0} workspaces`, ); // Extract pagination information using the utility const pagination = extractPaginationInfo( workspacesData, PaginationType.PAGE, ); // Format the workspaces data for display using the formatter const formattedWorkspaces = formatWorkspacesList(workspacesData); // Create the final content by combining the formatted workspaces with pagination information let finalContent = formattedWorkspaces; // Add pagination information if available if ( pagination && (pagination.hasMore || pagination.count !== undefined) ) { const paginationString = formatPagination(pagination); finalContent += '\n\n' + paginationString; } return { content: finalContent, }; } catch (error) { // Use the standardized error handler throw handleControllerError(error, { entityType: 'Workspaces', operation: 'listing', source: 'controllers/atlassian.workspaces.controller.ts@list', additionalInfo: { options }, }); } } /** * Get details of a specific Bitbucket workspace * @param identifier - Object containing the workspace slug * @param identifier.workspaceSlug - The slug of the workspace to retrieve * @returns Promise with formatted workspace details content * @throws Error if workspace retrieval fails */ async function get( identifier: GetWorkspaceToolArgsType, ): Promise<ControllerResponse> { const { workspaceSlug } = identifier; const methodLogger = Logger.forContext( 'controllers/atlassian.workspaces.controller.ts', 'get', ); methodLogger.debug( `Getting Bitbucket workspace with slug: ${workspaceSlug}...`, ); try { const workspaceData = await atlassianWorkspacesService.get(workspaceSlug); methodLogger.debug(`Retrieved workspace: ${workspaceData.slug}`); // Since membership info isn't directly available, we'll use the workspace data only methodLogger.debug( 'Membership info not available, using workspace data only', ); // Format the workspace data for display using the formatter const formattedWorkspace = formatWorkspaceDetails( workspaceData, undefined, // Pass undefined instead of membership data ); return { content: formattedWorkspace, }; } catch (error) { // Use the standardized error handler throw handleControllerError(error, { entityType: 'Workspace', operation: 'retrieving', source: 'controllers/atlassian.workspaces.controller.ts@get', additionalInfo: { identifier }, }); } } export default { list, get }; ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.workspaces.controller.test.ts: -------------------------------------------------------------------------------- ```typescript import atlassianWorkspacesController from './atlassian.workspaces.controller.js'; import { getAtlassianCredentials } from '../utils/transport.util.js'; import { config } from '../utils/config.util.js'; import { McpError } from '../utils/error.util.js'; describe('Atlassian Workspaces Controller', () => { // Load configuration and check for credentials before all tests beforeAll(() => { config.load(); // Ensure config is loaded const credentials = getAtlassianCredentials(); if (!credentials) { console.warn( 'Skipping Atlassian Workspaces Controller tests: No credentials available', ); } }); // Helper function to skip tests when credentials are missing const skipIfNoCredentials = () => !getAtlassianCredentials(); describe('list', () => { it('should return a formatted list of workspaces in Markdown', async () => { if (skipIfNoCredentials()) return; const result = await atlassianWorkspacesController.list({}); // Verify the response structure expect(result).toHaveProperty('content'); expect(typeof result.content).toBe('string'); // Basic Markdown content checks if (result.content !== 'No Bitbucket workspaces found.') { expect(result.content).toMatch(/^# Bitbucket Workspaces/m); expect(result.content).toContain('**UUID**'); expect(result.content).toContain('**Slug**'); expect(result.content).toContain('**Permission Level**'); // Check for pagination information in the content string expect(result.content).toMatch( /---\s*[\s\S]*\*Showing \d+ (of \d+ total items|\S+ items?)[\s\S]*\*/, ); } }, 30000); // Increased timeout it('should handle pagination options (limit/cursor)', async () => { if (skipIfNoCredentials()) return; // Fetch first page const result1 = await atlassianWorkspacesController.list({ limit: 1, }); // Extract pagination info from content const countMatch1 = result1.content.match( /\*Showing (\d+) items?\.\*/, ); const count1 = countMatch1 ? parseInt(countMatch1[1], 10) : 0; expect(count1).toBeLessThanOrEqual(1); // Extract cursor from content const cursorMatch1 = result1.content.match( /\*Next cursor: `(\d+)`\*/, ); const nextCursor = cursorMatch1 ? cursorMatch1[1] : null; // Check if pagination indicates more results const hasMoreResults = result1.content.includes( 'More results are available.', ); // If there's a next page, fetch it if (hasMoreResults && nextCursor) { const result2 = await atlassianWorkspacesController.list({ limit: 1, cursor: nextCursor, }); // Ensure content is different (or handle case where only 1 item exists) if ( result1.content !== 'No Bitbucket workspaces found.' && result2.content !== 'No Bitbucket workspaces found.' ) { // Only compare if we actually have multiple workspaces expect(result1.content).not.toEqual(result2.content); } } else { console.warn( 'Skipping cursor part of pagination test: Only one page of workspaces found.', ); } }, 30000); }); describe('get', () => { // Helper to get a valid slug for testing 'get' async function getFirstWorkspaceSlugForController(): Promise< string | null > { if (skipIfNoCredentials()) return null; try { const listResult = await atlassianWorkspacesController.list({ limit: 1, }); if (listResult.content === 'No Bitbucket workspaces found.') return null; // Extract slug from Markdown content const slugMatch = listResult.content.match( /\*\*Slug\*\*:\s+([^\s\n]+)/, ); return slugMatch ? slugMatch[1] : null; } catch (error) { console.warn( "Could not fetch workspace list for controller 'get' test setup:", error, ); return null; } } it('should return formatted details for a valid workspace slug in Markdown', async () => { const workspaceSlug = await getFirstWorkspaceSlugForController(); if (!workspaceSlug) { console.warn( 'Skipping controller get test: No workspace slug found.', ); return; } const result = await atlassianWorkspacesController.get({ workspaceSlug, }); // Verify the ControllerResponse structure expect(result).toHaveProperty('content'); expect(typeof result.content).toBe('string'); // Verify Markdown content expect(result.content).toMatch(/^# Workspace:/m); expect(result.content).toContain(`**Slug**: ${workspaceSlug}`); expect(result.content).toContain('## Basic Information'); expect(result.content).toContain('## Links'); }, 30000); it('should throw McpError for an invalid workspace slug', async () => { if (skipIfNoCredentials()) return; const invalidSlug = 'this-slug-definitely-does-not-exist-12345'; // Expect the controller call to reject with an McpError await expect( atlassianWorkspacesController.get({ workspaceSlug: invalidSlug, }), ).rejects.toThrow(McpError); // Optionally check the status code via the error handler's behavior try { await atlassianWorkspacesController.get({ workspaceSlug: invalidSlug, }); } catch (e) { expect(e).toBeInstanceOf(McpError); // The controller error handler wraps the service error expect((e as McpError).statusCode).toBe(404); // Expecting Not Found expect((e as McpError).message).toContain('not found'); } }, 30000); }); }); ``` -------------------------------------------------------------------------------- /src/cli/atlassian.workspaces.cli.test.ts: -------------------------------------------------------------------------------- ```typescript import { CliTestUtil } from '../utils/cli.test.util.js'; import { getAtlassianCredentials } from '../utils/transport.util.js'; import { config } from '../utils/config.util.js'; describe('Atlassian Workspaces CLI Commands', () => { // Load configuration and check for credentials before all tests beforeAll(() => { // Load configuration from all sources config.load(); // Log warning if credentials aren't available const credentials = getAtlassianCredentials(); if (!credentials) { console.warn( 'Skipping Atlassian Workspaces CLI tests: No credentials available', ); } }); // Helper function to skip tests when credentials are missing const skipIfNoCredentials = () => { const credentials = getAtlassianCredentials(); if (!credentials) { return true; } return false; }; describe('ls-workspaces command', () => { // Test default behavior (list all workspaces) it('should list available workspaces', async () => { if (skipIfNoCredentials()) { return; } // Run the CLI command const result = await CliTestUtil.runCommand(['ls-workspaces']); // Check command exit code expect(result.exitCode).toBe(0); // Verify the output format if (!result.stdout.includes('No Bitbucket workspaces found.')) { // Validate expected Markdown structure - Fixed to match actual output CliTestUtil.validateOutputContains(result.stdout, [ '# Bitbucket Workspaces', '**UUID**', '**Slug**', '**Permission Level**', ]); // Validate Markdown formatting CliTestUtil.validateMarkdownOutput(result.stdout); } }, 30000); // Increased timeout for API call // Test with pagination it('should support pagination with --limit flag', async () => { if (skipIfNoCredentials()) { return; } // Run the CLI command with limit const result = await CliTestUtil.runCommand([ 'ls-workspaces', '--limit', '1', ]); // Check command exit code expect(result.exitCode).toBe(0); // If there are multiple workspaces, pagination section should be present if ( !result.stdout.includes('No Bitbucket workspaces found.') && result.stdout.includes('items remaining') ) { CliTestUtil.validateOutputContains(result.stdout, [ 'Pagination', 'Next cursor:', ]); } }, 30000); // Increased timeout for API call // Test with invalid parameters - Fixed to use a truly invalid input it('should handle invalid parameters properly', async () => { if (skipIfNoCredentials()) { return; } // Run the CLI command with a non-existent parameter const result = await CliTestUtil.runCommand([ 'ls-workspaces', '--non-existent-parameter', 'value', ]); // Should fail with non-zero exit code expect(result.exitCode).not.toBe(0); // Should output error message expect(result.stderr).toContain('unknown option'); }, 30000); }); describe('get-workspace command', () => { // Test to fetch a specific workspace it('should retrieve a specific workspace by slug', async () => { if (skipIfNoCredentials()) { return; } // First, get a list of workspaces to find a valid slug const listResult = await CliTestUtil.runCommand(['ls-workspaces']); // Skip if no workspaces are available if (listResult.stdout.includes('No Bitbucket workspaces found.')) { console.warn('Skipping test: No workspaces available'); return; } // Extract a workspace slug from the output const slugMatch = listResult.stdout.match( /\*\*Slug\*\*:\s+([^\n]+)/, ); if (!slugMatch || !slugMatch[1]) { console.warn('Skipping test: Could not extract workspace slug'); return; } const workspaceSlug = slugMatch[1].trim(); // Run the get-workspace command with the extracted slug const getResult = await CliTestUtil.runCommand([ 'get-workspace', '--workspace-slug', workspaceSlug, ]); // Check command exit code expect(getResult.exitCode).toBe(0); // Verify the output structure and content CliTestUtil.validateOutputContains(getResult.stdout, [ `# Workspace: `, `**Slug**: ${workspaceSlug}`, 'Basic Information', 'Links', ]); // Validate Markdown formatting CliTestUtil.validateMarkdownOutput(getResult.stdout); }, 30000); // Increased timeout for API calls // Test with missing required parameter it('should fail when workspace slug is not provided', async () => { if (skipIfNoCredentials()) { return; } // Run command without required parameter const result = await CliTestUtil.runCommand(['get-workspace']); // Should fail with non-zero exit code expect(result.exitCode).not.toBe(0); // Should indicate missing required option expect(result.stderr).toContain('required option'); }, 15000); // Test with invalid workspace slug it('should handle invalid workspace slugs gracefully', async () => { if (skipIfNoCredentials()) { return; } // Use a deliberately invalid workspace slug const invalidSlug = 'invalid-workspace-slug-that-does-not-exist'; // Run command with invalid slug const result = await CliTestUtil.runCommand([ 'get-workspace', '--workspace-slug', invalidSlug, ]); // Should fail with non-zero exit code expect(result.exitCode).not.toBe(0); // Should contain error information expect(result.stderr).toContain('error'); }, 30000); }); }); ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.search.code.controller.ts: -------------------------------------------------------------------------------- ```typescript import { Logger } from '../utils/logger.util.js'; import { ControllerResponse } from '../types/common.types.js'; import { DEFAULT_PAGE_SIZE } from '../utils/defaults.util.js'; import atlassianSearchService from '../services/vendor.atlassian.search.service.js'; import { extractPaginationInfo, PaginationType, } from '../utils/pagination.util.js'; import { formatPagination } from '../utils/formatter.util.js'; import { formatCodeSearchResults } from './atlassian.search.formatter.js'; /** * Handle search for code content (uses Bitbucket's Code Search API) */ export async function handleCodeSearch( workspaceSlug: string, repoSlug?: string, query?: string, limit: number = DEFAULT_PAGE_SIZE, cursor?: string, language?: string, extension?: string, ): Promise<ControllerResponse> { const methodLogger = Logger.forContext( 'controllers/atlassian.search.code.controller.ts', 'handleCodeSearch', ); methodLogger.debug('Performing code search'); if (!query) { return { content: 'Please provide a search query for code search.', }; } try { // Convert cursor to page number if provided let page = 1; if (cursor) { const parsedPage = parseInt(cursor, 10); if (!isNaN(parsedPage)) { page = parsedPage; } else { methodLogger.warn('Invalid page cursor:', cursor); } } // Use the search service const searchResponse = await atlassianSearchService.searchCode({ workspaceSlug: workspaceSlug, searchQuery: query, repoSlug: repoSlug, page: page, pageLen: limit, language: language, extension: extension, }); methodLogger.debug( `Search complete, found ${searchResponse.size} matches`, ); // Post-filter by language if specified and Bitbucket API returned mixed results let filteredValues = searchResponse.values || []; let originalSize = searchResponse.size; if (language && filteredValues.length > 0) { // Language extension mapping for post-filtering const languageExtMap: Record<string, string[]> = { hcl: ['.tf', '.tfvars', '.hcl'], terraform: ['.tf', '.tfvars', '.hcl'], java: ['.java', '.class', '.jar'], javascript: ['.js', '.jsx', '.mjs'], typescript: ['.ts', '.tsx'], python: ['.py', '.pyw', '.pyc'], ruby: ['.rb', '.rake'], go: ['.go'], rust: ['.rs'], c: ['.c', '.h'], cpp: ['.cpp', '.cc', '.cxx', '.h', '.hpp'], csharp: ['.cs'], php: ['.php'], html: ['.html', '.htm'], css: ['.css'], shell: ['.sh', '.bash', '.zsh'], sql: ['.sql'], yaml: ['.yml', '.yaml'], json: ['.json'], xml: ['.xml'], markdown: ['.md', '.markdown'], }; // Normalize the language name to lowercase const normalizedLang = language.toLowerCase(); const extensions = languageExtMap[normalizedLang] || []; // Only apply post-filtering if we have extension mappings for this language if (extensions.length > 0) { const beforeFilterCount = filteredValues.length; // Filter results to only include files with the expected extensions filteredValues = filteredValues.filter((result) => { const filePath = result.file.path.toLowerCase(); return extensions.some((ext) => filePath.endsWith(ext)); }); const afterFilterCount = filteredValues.length; if (afterFilterCount !== beforeFilterCount) { methodLogger.debug( `Post-filtered code search results by language=${language}: ${afterFilterCount} of ${beforeFilterCount} matched extensions ${extensions.join(', ')}`, ); // Adjust the size estimate originalSize = searchResponse.size; const filterRatio = afterFilterCount / beforeFilterCount; searchResponse.size = Math.max( afterFilterCount, Math.ceil(searchResponse.size * filterRatio), ); methodLogger.debug( `Adjusted size from ${originalSize} to ${searchResponse.size} based on filtering`, ); } } } // Extract pagination information const transformedResponse = { pagelen: limit, page: page, size: searchResponse.size, values: filteredValues, next: 'available', // Fallback to 'available' since searchResponse doesn't have a next property }; const pagination = extractPaginationInfo( transformedResponse, PaginationType.PAGE, ); // Format the code search results let formattedCode = formatCodeSearchResults({ ...searchResponse, values: filteredValues, }); // Add note about language filtering if applied if (language) { // Make it clear that language filtering is a best-effort by the API and we've improved it const languageNote = `> **Note:** Language filtering for '${language}' combines Bitbucket API filtering with client-side filtering for more accurate results. Due to limitations in the Bitbucket API, some files in other languages might still appear in search results, and filtering is based on file extensions rather than content analysis. This is a known limitation of the Bitbucket API that this tool attempts to mitigate through additional filtering.`; formattedCode = `${languageNote}\n\n${formattedCode}`; } // Add pagination information if available let finalContent = formattedCode; if ( pagination && (pagination.hasMore || pagination.count !== undefined) ) { const paginationString = formatPagination(pagination); finalContent += '\n\n' + paginationString; } return { content: finalContent, }; } catch (searchError) { methodLogger.error('Error performing code search:', searchError); throw searchError; } } ``` -------------------------------------------------------------------------------- /scripts/update-version.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node /** * Script to update version numbers across the project * Usage: node scripts/update-version.js [version] [options] * Options: * --dry-run Show what changes would be made without applying them * --verbose Show detailed logging information * * If no version is provided, it will use the version from package.json */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; // Get the directory name of the current module const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, '..'); // Parse command line arguments const args = process.argv.slice(2); const options = { dryRun: args.includes('--dry-run'), verbose: args.includes('--verbose'), }; // Get the version (first non-flag argument) let newVersion = args.find((arg) => !arg.startsWith('--')); // Log helper function const log = (message, verbose = false) => { if (!verbose || options.verbose) { console.log(message); } }; // File paths that may contain version information const versionFiles = [ { path: path.join(rootDir, 'package.json'), pattern: /"version": "([^"]*)"/, replacement: (match, currentVersion) => match.replace(currentVersion, newVersion), }, { path: path.join(rootDir, 'src', 'utils', 'constants.util.ts'), pattern: /export const VERSION = ['"]([^'"]*)['"]/, replacement: (match, currentVersion) => match.replace(currentVersion, newVersion), }, // Also update the compiled JavaScript files if they exist { path: path.join(rootDir, 'dist', 'utils', 'constants.util.js'), pattern: /exports.VERSION = ['"]([^'"]*)['"]/, replacement: (match, currentVersion) => match.replace(currentVersion, newVersion), optional: true, // Mark this file as optional }, // Additional files can be added here with their patterns and replacement logic ]; /** * Read the version from package.json * @returns {string} The version from package.json */ function getPackageVersion() { try { const packageJsonPath = path.join(rootDir, 'package.json'); log(`Reading version from ${packageJsonPath}`, true); const packageJson = JSON.parse( fs.readFileSync(packageJsonPath, 'utf8'), ); if (!packageJson.version) { throw new Error('No version field found in package.json'); } return packageJson.version; } catch (error) { console.error(`Error reading package.json: ${error.message}`); process.exit(1); } } /** * Validate the semantic version format * @param {string} version - The version to validate * @returns {boolean} True if valid, throws error if invalid */ function validateVersion(version) { // More comprehensive semver regex const semverRegex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; if (!semverRegex.test(version)) { throw new Error( `Invalid version format: ${version}\nPlease use semantic versioning format (e.g., 1.2.3, 1.2.3-beta.1, etc.)`, ); } return true; } /** * Update version in a specific file * @param {Object} fileConfig - Configuration for the file to update */ function updateFileVersion(fileConfig) { const { path: filePath, pattern, replacement, optional = false, } = fileConfig; try { log(`Checking ${filePath}...`, true); if (!fs.existsSync(filePath)) { if (optional) { log(`Optional file not found (skipping): ${filePath}`, true); return; } console.warn(`Warning: File not found: ${filePath}`); return; } // Read file content const fileContent = fs.readFileSync(filePath, 'utf8'); const match = fileContent.match(pattern); if (!match) { console.warn(`Warning: Version pattern not found in ${filePath}`); return; } const currentVersion = match[1]; if (currentVersion === newVersion) { log( `Version in ${path.basename(filePath)} is already ${newVersion}`, true, ); return; } // Create new content with the updated version const updatedContent = fileContent.replace(pattern, replacement); // Write the changes or log them in dry run mode if (options.dryRun) { log( `Would update version in ${filePath} from ${currentVersion} to ${newVersion}`, ); } else { // Create a backup of the original file fs.writeFileSync(`${filePath}.bak`, fileContent); log(`Backup created: ${filePath}.bak`, true); // Write the updated content fs.writeFileSync(filePath, updatedContent); log( `Updated version in ${path.basename(filePath)} from ${currentVersion} to ${newVersion}`, ); } } catch (error) { if (optional) { log(`Error with optional file ${filePath}: ${error.message}`, true); return; } console.error(`Error updating ${filePath}: ${error.message}`); process.exit(1); } } // Main execution try { // If no version specified, get from package.json if (!newVersion) { newVersion = getPackageVersion(); log( `No version specified, using version from package.json: ${newVersion}`, ); } // Validate the version format validateVersion(newVersion); // Update all configured files for (const fileConfig of versionFiles) { updateFileVersion(fileConfig); } if (options.dryRun) { log(`\nDry run completed. No files were modified.`); } else { log(`\nVersion successfully updated to ${newVersion}`); } } catch (error) { console.error(`\nVersion update failed: ${error.message}`); process.exit(1); } ``` -------------------------------------------------------------------------------- /src/services/vendor.atlassian.workspaces.test.ts: -------------------------------------------------------------------------------- ```typescript import atlassianWorkspacesService from './vendor.atlassian.workspaces.service.js'; import { getAtlassianCredentials } from '../utils/transport.util.js'; import { config } from '../utils/config.util.js'; import { McpError } from '../utils/error.util.js'; describe('Vendor Atlassian Workspaces Service', () => { // Load configuration and check for credentials before all tests beforeAll(() => { config.load(); // Ensure config is loaded const credentials = getAtlassianCredentials(); if (!credentials) { console.warn( 'Skipping Atlassian Workspaces Service tests: No credentials available', ); } }); // Helper function to skip tests when credentials are missing const skipIfNoCredentials = () => !getAtlassianCredentials(); describe('list', () => { it('should return a list of workspaces (permissions)', async () => { if (skipIfNoCredentials()) return; const result = await atlassianWorkspacesService.list(); // Verify the response structure based on WorkspacePermissionsResponse expect(result).toHaveProperty('values'); expect(Array.isArray(result.values)).toBe(true); expect(result).toHaveProperty('pagelen'); // Bitbucket uses pagelen expect(result).toHaveProperty('page'); expect(result).toHaveProperty('size'); if (result.values.length > 0) { const membership = result.values[0]; expect(membership).toHaveProperty( 'type', 'workspace_membership', ); expect(membership).toHaveProperty('permission'); expect(membership).toHaveProperty('user'); expect(membership).toHaveProperty('workspace'); expect(membership.workspace).toHaveProperty('slug'); expect(membership.workspace).toHaveProperty('uuid'); } }, 30000); // Increased timeout it('should support pagination with pagelen', async () => { if (skipIfNoCredentials()) return; const result = await atlassianWorkspacesService.list({ pagelen: 1, }); expect(result).toHaveProperty('pagelen'); // Allow pagelen to be greater than requested if API enforces minimum expect(result.pagelen).toBeGreaterThanOrEqual(1); expect(result.values.length).toBeLessThanOrEqual(result.pagelen); // Items should not exceed pagelen if (result.size > result.pagelen) { // If there are more items than the page size, expect pagination links expect(result).toHaveProperty('next'); } }, 30000); it('should handle query filtering if supported by the API', async () => { if (skipIfNoCredentials()) return; // First get all workspaces to find a potential query term const allWorkspaces = await atlassianWorkspacesService.list(); // Skip if no workspaces available if (allWorkspaces.values.length === 0) { console.warn( 'Skipping query filtering test: No workspaces available', ); return; } // Try to search using a workspace name - note that this might not work if // the API doesn't fully support 'q' parameter for this endpoint // This test basically checks that the request doesn't fail const firstWorkspace = allWorkspaces.values[0].workspace; try { const result = await atlassianWorkspacesService.list({ q: `workspace.name="${firstWorkspace.name}"`, }); // We're mostly testing that this request completes without error expect(result).toHaveProperty('values'); // The result might be empty if filtering isn't supported, // so we don't assert on the number of results returned } catch (error) { // If filtering isn't supported, the API might return an error // This is acceptable, so we just log it console.warn( 'Query filtering test encountered an error:', error instanceof Error ? error.message : String(error), ); } }, 30000); }); describe('get', () => { // Helper to get a valid slug for testing 'get' async function getFirstWorkspaceSlug(): Promise<string | null> { if (skipIfNoCredentials()) return null; try { const listResult = await atlassianWorkspacesService.list({ pagelen: 1, }); return listResult.values.length > 0 ? listResult.values[0].workspace.slug : null; } catch (error) { console.warn( "Could not fetch workspace list for 'get' test setup:", error, ); return null; } } it('should return details for a valid workspace slug', async () => { const workspaceSlug = await getFirstWorkspaceSlug(); if (!workspaceSlug) { console.warn('Skipping get test: No workspace slug found.'); return; } const result = await atlassianWorkspacesService.get(workspaceSlug); // Verify the response structure based on WorkspaceDetailed expect(result).toHaveProperty('uuid'); expect(result).toHaveProperty('slug', workspaceSlug); expect(result).toHaveProperty('name'); expect(result).toHaveProperty('type', 'workspace'); expect(result).toHaveProperty('links'); expect(result.links).toHaveProperty('html'); }, 30000); it('should throw an McpError for an invalid workspace slug', async () => { if (skipIfNoCredentials()) return; const invalidSlug = 'this-slug-definitely-does-not-exist-12345'; // Expect the service call to reject with an McpError (likely 404) await expect( atlassianWorkspacesService.get(invalidSlug), ).rejects.toThrow(McpError); // Optionally check the status code if needed try { await atlassianWorkspacesService.get(invalidSlug); } catch (e) { expect(e).toBeInstanceOf(McpError); expect((e as McpError).statusCode).toBe(404); // Expecting Not Found } }, 30000); }); }); ```