This is page 4 of 6. Use http://codebase.md/aashari/mcp-server-atlassian-bitbucket?lines=true&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 -------------------------------------------------------------------------------- /src/services/vendor.atlassian.repositories.service.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import atlassianRepositoriesService from './vendor.atlassian.repositories.service.js'; 2 | import { getAtlassianCredentials } from '../utils/transport.util.js'; 3 | import { config } from '../utils/config.util.js'; 4 | import { McpError } from '../utils/error.util.js'; 5 | import atlassianWorkspacesService from './vendor.atlassian.workspaces.service.js'; 6 | import { Repository } from './vendor.atlassian.repositories.types.js'; 7 | import { Logger } from '../utils/logger.util.js'; 8 | 9 | // Instantiate logger for the test file 10 | const logger = Logger.forContext( 11 | 'services/vendor.atlassian.repositories.service.test.ts', 12 | ); 13 | 14 | describe('Vendor Atlassian Repositories Service', () => { 15 | // Load configuration and check for credentials before all tests 16 | beforeAll(() => { 17 | config.load(); // Ensure config is loaded 18 | const credentials = getAtlassianCredentials(); 19 | if (!credentials) { 20 | console.warn( 21 | 'Skipping Atlassian Repositories Service tests: No credentials available', 22 | ); 23 | } 24 | }); 25 | 26 | // Helper function to skip tests when credentials are missing 27 | const skipIfNoCredentials = () => !getAtlassianCredentials(); 28 | 29 | // Helper to get a valid workspace slug for testing 30 | async function getFirstWorkspaceSlug(): Promise<string | null> { 31 | if (skipIfNoCredentials()) return null; 32 | 33 | try { 34 | const listResult = await atlassianWorkspacesService.list({ 35 | pagelen: 1, 36 | }); 37 | return listResult.values.length > 0 38 | ? listResult.values[0].workspace.slug 39 | : null; 40 | } catch (error) { 41 | console.warn( 42 | 'Could not fetch workspace list for repository tests:', 43 | error, 44 | ); 45 | return null; 46 | } 47 | } 48 | 49 | describe('list', () => { 50 | it('should return a list of repositories for a valid workspace', async () => { 51 | if (skipIfNoCredentials()) return; 52 | 53 | const workspaceSlug = await getFirstWorkspaceSlug(); 54 | if (!workspaceSlug) { 55 | console.warn('Skipping test: No workspace slug found.'); 56 | return; 57 | } 58 | 59 | const result = await atlassianRepositoriesService.list({ 60 | workspace: workspaceSlug, 61 | }); 62 | logger.debug('List repositories result:', result); 63 | 64 | // Verify the response structure based on RepositoriesResponse 65 | expect(result).toHaveProperty('values'); 66 | expect(Array.isArray(result.values)).toBe(true); 67 | expect(result).toHaveProperty('pagelen'); // Bitbucket uses pagelen 68 | expect(result).toHaveProperty('page'); 69 | expect(result).toHaveProperty('size'); 70 | 71 | if (result.values.length > 0) { 72 | // Verify the structure of the first repository in the list 73 | verifyRepositoryStructure(result.values[0]); 74 | } 75 | }, 30000); // Increased timeout 76 | 77 | it('should support pagination with pagelen and page', async () => { 78 | if (skipIfNoCredentials()) return; 79 | 80 | const workspaceSlug = await getFirstWorkspaceSlug(); 81 | if (!workspaceSlug) { 82 | console.warn('Skipping test: No workspace slug found.'); 83 | return; 84 | } 85 | 86 | // Get first page with limited results 87 | const result = await atlassianRepositoriesService.list({ 88 | workspace: workspaceSlug, 89 | pagelen: 1, 90 | }); 91 | 92 | expect(result).toHaveProperty('pagelen'); 93 | // Allow pagelen to be greater than requested if API enforces minimum 94 | expect(result.pagelen).toBeGreaterThanOrEqual(1); 95 | expect(result.values.length).toBeLessThanOrEqual(result.pagelen); 96 | 97 | // If there are more items than the page size, expect pagination links 98 | if (result.size > result.pagelen) { 99 | expect(result).toHaveProperty('next'); 100 | 101 | // Test requesting page 2 if available 102 | // Extract page parameter from next link if available 103 | if (result.next) { 104 | const nextPageUrl = new URL(result.next); 105 | const pageParam = nextPageUrl.searchParams.get('page'); 106 | 107 | if (pageParam) { 108 | const page2 = parseInt(pageParam, 10); 109 | const page2Result = 110 | await atlassianRepositoriesService.list({ 111 | workspace: workspaceSlug, 112 | pagelen: 1, 113 | page: page2, 114 | }); 115 | 116 | expect(page2Result).toHaveProperty('page', page2); 117 | 118 | // If both pages have values, verify they're different repositories 119 | if ( 120 | result.values.length > 0 && 121 | page2Result.values.length > 0 122 | ) { 123 | expect(result.values[0].uuid).not.toBe( 124 | page2Result.values[0].uuid, 125 | ); 126 | } 127 | } 128 | } 129 | } 130 | }, 30000); 131 | 132 | it('should support filtering with q parameter', async () => { 133 | if (skipIfNoCredentials()) return; 134 | 135 | const workspaceSlug = await getFirstWorkspaceSlug(); 136 | if (!workspaceSlug) { 137 | console.warn('Skipping test: No workspace slug found.'); 138 | return; 139 | } 140 | 141 | // First get all repositories to find a potential query term 142 | const allRepos = await atlassianRepositoriesService.list({ 143 | workspace: workspaceSlug, 144 | }); 145 | 146 | // Skip if no repositories available 147 | if (allRepos.values.length === 0) { 148 | console.warn( 149 | 'Skipping query filtering test: No repositories available', 150 | ); 151 | return; 152 | } 153 | 154 | // Use the first repo's name as a query term 155 | const firstRepo = allRepos.values[0]; 156 | // Take just the first word or first few characters to make filter less restrictive 157 | const queryTerm = firstRepo.name.split(' ')[0]; 158 | 159 | // Test the query filter 160 | try { 161 | const result = await atlassianRepositoriesService.list({ 162 | workspace: workspaceSlug, 163 | q: `name~"${queryTerm}"`, 164 | }); 165 | 166 | // Verify basic response structure 167 | expect(result).toHaveProperty('values'); 168 | 169 | // All returned repos should contain the query term in their name 170 | if (result.values.length > 0) { 171 | const nameMatches = result.values.some((repo) => 172 | repo.name 173 | .toLowerCase() 174 | .includes(queryTerm.toLowerCase()), 175 | ); 176 | expect(nameMatches).toBe(true); 177 | } 178 | } catch (error) { 179 | // If filtering isn't fully supported, we just log it 180 | console.warn( 181 | 'Query filtering test encountered an error:', 182 | error instanceof Error ? error.message : String(error), 183 | ); 184 | } 185 | }, 30000); 186 | 187 | it('should support sorting with sort parameter', async () => { 188 | if (skipIfNoCredentials()) return; 189 | 190 | const workspaceSlug = await getFirstWorkspaceSlug(); 191 | if (!workspaceSlug) { 192 | console.warn('Skipping test: No workspace slug found.'); 193 | return; 194 | } 195 | 196 | // Skip this test if fewer than 2 repositories (can't verify sort order) 197 | const checkResult = await atlassianRepositoriesService.list({ 198 | workspace: workspaceSlug, 199 | pagelen: 2, 200 | }); 201 | 202 | if (checkResult.values.length < 2) { 203 | console.warn( 204 | 'Skipping sort test: Need at least 2 repositories to verify sort order', 205 | ); 206 | return; 207 | } 208 | 209 | // Test sorting by name ascending 210 | const resultAsc = await atlassianRepositoriesService.list({ 211 | workspace: workspaceSlug, 212 | sort: 'name', 213 | pagelen: 2, 214 | }); 215 | 216 | // Test sorting by name descending 217 | const resultDesc = await atlassianRepositoriesService.list({ 218 | workspace: workspaceSlug, 219 | sort: '-name', 220 | pagelen: 2, 221 | }); 222 | 223 | // Verify basic response structure 224 | expect(resultAsc).toHaveProperty('values'); 225 | expect(resultDesc).toHaveProperty('values'); 226 | 227 | // Ensure both responses have at least 2 items to compare 228 | if (resultAsc.values.length >= 2 && resultDesc.values.length >= 2) { 229 | // For ascending order, first item should come before second alphabetically 230 | const ascNameComparison = 231 | resultAsc.values[0].name.localeCompare( 232 | resultAsc.values[1].name, 233 | ); 234 | // For descending order, first item should come after second alphabetically 235 | const descNameComparison = 236 | resultDesc.values[0].name.localeCompare( 237 | resultDesc.values[1].name, 238 | ); 239 | 240 | // Ascending should be ≤ 0 (first before or equal to second) 241 | expect(ascNameComparison).toBeLessThanOrEqual(0); 242 | // Descending should be ≥ 0 (first after or equal to second) 243 | expect(descNameComparison).toBeGreaterThanOrEqual(0); 244 | } 245 | }, 30000); 246 | 247 | it('should throw an error for an invalid workspace', async () => { 248 | if (skipIfNoCredentials()) return; 249 | 250 | const invalidWorkspace = 251 | 'this-workspace-definitely-does-not-exist-12345'; 252 | 253 | // Expect the service call to reject with an McpError (likely 404) 254 | await expect( 255 | atlassianRepositoriesService.list({ 256 | workspace: invalidWorkspace, 257 | }), 258 | ).rejects.toThrow(); 259 | 260 | // Check for specific error properties 261 | try { 262 | await atlassianRepositoriesService.list({ 263 | workspace: invalidWorkspace, 264 | }); 265 | } catch (e) { 266 | expect(e).toBeInstanceOf(McpError); 267 | expect((e as McpError).statusCode).toBe(404); // Expecting Not Found 268 | } 269 | }, 30000); 270 | }); 271 | 272 | describe('get', () => { 273 | // Helper to get a valid repo for testing 'get' 274 | async function getFirstRepositoryInfo(): Promise<{ 275 | workspace: string; 276 | repoSlug: string; 277 | } | null> { 278 | if (skipIfNoCredentials()) return null; 279 | 280 | const workspaceSlug = await getFirstWorkspaceSlug(); 281 | if (!workspaceSlug) return null; 282 | 283 | try { 284 | const listResult = await atlassianRepositoriesService.list({ 285 | workspace: workspaceSlug, 286 | pagelen: 1, 287 | }); 288 | 289 | if (listResult.values.length === 0) return null; 290 | 291 | const fullName = listResult.values[0].full_name; 292 | // full_name is in format "workspace/repo_slug" 293 | const [workspace, repoSlug] = fullName.split('/'); 294 | 295 | return { workspace, repoSlug }; 296 | } catch (error) { 297 | console.warn( 298 | "Could not fetch repository list for 'get' test setup:", 299 | error, 300 | ); 301 | return null; 302 | } 303 | } 304 | 305 | it('should return details for a valid workspace and repo_slug', async () => { 306 | const repoInfo = await getFirstRepositoryInfo(); 307 | if (!repoInfo) { 308 | console.warn('Skipping get test: No repository found.'); 309 | return; 310 | } 311 | 312 | const result = await atlassianRepositoriesService.get({ 313 | workspace: repoInfo.workspace, 314 | repo_slug: repoInfo.repoSlug, 315 | }); 316 | 317 | // Verify the response structure based on RepositoryDetailed 318 | expect(result).toHaveProperty('uuid'); 319 | expect(result).toHaveProperty( 320 | 'full_name', 321 | `${repoInfo.workspace}/${repoInfo.repoSlug}`, 322 | ); 323 | expect(result).toHaveProperty('name'); 324 | expect(result).toHaveProperty('type', 'repository'); 325 | expect(result).toHaveProperty('is_private'); 326 | expect(result).toHaveProperty('links'); 327 | expect(result.links).toHaveProperty('html'); 328 | expect(result).toHaveProperty('owner'); 329 | expect(result.owner).toHaveProperty('type'); 330 | }, 30000); 331 | 332 | it('should throw an McpError for a non-existent repo_slug', async () => { 333 | const workspaceSlug = await getFirstWorkspaceSlug(); 334 | if (!workspaceSlug) { 335 | console.warn('Skipping test: No workspace slug found.'); 336 | return; 337 | } 338 | 339 | const invalidRepoSlug = 'this-repo-definitely-does-not-exist-12345'; 340 | 341 | // Expect the service call to reject with an McpError (likely 404) 342 | await expect( 343 | atlassianRepositoriesService.get({ 344 | workspace: workspaceSlug, 345 | repo_slug: invalidRepoSlug, 346 | }), 347 | ).rejects.toThrow(McpError); 348 | 349 | // Check for specific error properties 350 | try { 351 | await atlassianRepositoriesService.get({ 352 | workspace: workspaceSlug, 353 | repo_slug: invalidRepoSlug, 354 | }); 355 | } catch (e) { 356 | expect(e).toBeInstanceOf(McpError); 357 | expect((e as McpError).statusCode).toBe(404); // Expecting Not Found 358 | } 359 | }, 30000); 360 | 361 | it('should throw an McpError for a non-existent workspace', async () => { 362 | if (skipIfNoCredentials()) return; 363 | 364 | const invalidWorkspace = 365 | 'this-workspace-definitely-does-not-exist-12345'; 366 | const invalidRepoSlug = 'some-repo'; 367 | 368 | // Expect the service call to reject with an McpError (likely 404) 369 | await expect( 370 | atlassianRepositoriesService.get({ 371 | workspace: invalidWorkspace, 372 | repo_slug: invalidRepoSlug, 373 | }), 374 | ).rejects.toThrow(McpError); 375 | 376 | // Check for specific error properties 377 | try { 378 | await atlassianRepositoriesService.get({ 379 | workspace: invalidWorkspace, 380 | repo_slug: invalidRepoSlug, 381 | }); 382 | } catch (e) { 383 | expect(e).toBeInstanceOf(McpError); 384 | expect((e as McpError).statusCode).toBe(404); // Expecting Not Found 385 | } 386 | }, 30000); 387 | }); 388 | }); 389 | 390 | // Helper function to verify the Repository structure 391 | function verifyRepositoryStructure(repo: Repository) { 392 | expect(repo).toHaveProperty('uuid'); 393 | expect(repo).toHaveProperty('name'); 394 | expect(repo).toHaveProperty('full_name'); 395 | expect(repo).toHaveProperty('is_private'); 396 | expect(repo).toHaveProperty('links'); 397 | expect(repo).toHaveProperty('owner'); 398 | expect(repo).toHaveProperty('type', 'repository'); 399 | } 400 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | "lib": ["ES2020"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "libReplacement": true, /* Enable lib replacement. */ 18 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 19 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 20 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 21 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 22 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 23 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 24 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 25 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 26 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 27 | 28 | /* Modules */ 29 | "module": "NodeNext", /* Specify what module code is generated. */ 30 | // "rootDir": "./", /* Specify the root folder within your source files. */ 31 | "moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */ 32 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 33 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 34 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 35 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 36 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 37 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 38 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 39 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 40 | // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ 41 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 42 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 43 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 44 | // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ 45 | "resolveJsonModule": true, /* Enable importing .json files. */ 46 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 47 | // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */ 48 | 49 | /* JavaScript Support */ 50 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 51 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 52 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 53 | 54 | /* Emit */ 55 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 56 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 57 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 58 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 59 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 62 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 63 | // "removeComments": true, /* Disable emitting comments. */ 64 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 65 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 66 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 67 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 68 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 69 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 70 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 71 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 72 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 73 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 74 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 75 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 76 | 77 | /* Interop Constraints */ 78 | "isolatedModules": true, 79 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 80 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ 81 | // "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */ 82 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 83 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 84 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 85 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 86 | 87 | /* Type Checking */ 88 | "strict": true, /* Enable all strict type-checking options. */ 89 | "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 90 | "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 91 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 92 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 93 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 94 | // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ 95 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 96 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 97 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 98 | "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 99 | "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 100 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 101 | "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 102 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 103 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 104 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 105 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 106 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 107 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 108 | 109 | /* Completeness */ 110 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 111 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 112 | }, 113 | "include": ["src/**/*"] 114 | } 115 | ``` -------------------------------------------------------------------------------- /src/tools/atlassian.repositories.tool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import { Logger } from '../utils/logger.util.js'; 3 | import { formatErrorForMcpTool } from '../utils/error.util.js'; 4 | import { 5 | ListRepositoriesToolArgs, 6 | type ListRepositoriesToolArgsType, 7 | GetRepositoryToolArgs, 8 | type GetRepositoryToolArgsType, 9 | GetCommitHistoryToolArgs, 10 | type GetCommitHistoryToolArgsType, 11 | CreateBranchToolArgsSchema, 12 | type CreateBranchToolArgsType, 13 | CloneRepositoryToolArgs, 14 | type CloneRepositoryToolArgsType, 15 | GetFileContentToolArgs, 16 | type GetFileContentToolArgsType, 17 | ListBranchesToolArgs, 18 | type ListBranchesToolArgsType, 19 | } from './atlassian.repositories.types.js'; 20 | 21 | // Import directly from specialized controllers 22 | import { handleRepositoriesList } from '../controllers/atlassian.repositories.list.controller.js'; 23 | import { handleRepositoryDetails } from '../controllers/atlassian.repositories.details.controller.js'; 24 | import { handleCommitHistory } from '../controllers/atlassian.repositories.commit.controller.js'; 25 | import { 26 | handleCreateBranch, 27 | handleListBranches, 28 | } from '../controllers/atlassian.repositories.branch.controller.js'; 29 | import { 30 | handleCloneRepository, 31 | handleGetFileContent, 32 | } from '../controllers/atlassian.repositories.content.controller.js'; 33 | 34 | // Create a contextualized logger for this file 35 | const toolLogger = Logger.forContext('tools/atlassian.repositories.tool.ts'); 36 | 37 | // Log tool initialization 38 | toolLogger.debug('Bitbucket repositories tool initialized'); 39 | 40 | /** 41 | * MCP Tool: List Bitbucket Repositories 42 | * 43 | * Lists Bitbucket repositories within a workspace with optional filtering. 44 | * Returns a formatted markdown response with repository details. 45 | * 46 | * @param args - Tool arguments for filtering repositories 47 | * @returns MCP response with formatted repositories list 48 | * @throws Will return error message if repository listing fails 49 | */ 50 | async function listRepositories(args: Record<string, unknown>) { 51 | const methodLogger = Logger.forContext( 52 | 'tools/atlassian.repositories.tool.ts', 53 | 'listRepositories', 54 | ); 55 | methodLogger.debug('Listing Bitbucket repositories with filters:', args); 56 | 57 | try { 58 | // Pass args directly to controller without any logic 59 | const result = await handleRepositoriesList( 60 | args as ListRepositoriesToolArgsType, 61 | ); 62 | 63 | methodLogger.debug( 64 | 'Successfully retrieved repositories from controller', 65 | ); 66 | 67 | return { 68 | content: [ 69 | { 70 | type: 'text' as const, 71 | text: result.content, 72 | }, 73 | ], 74 | }; 75 | } catch (error) { 76 | methodLogger.error('Failed to list repositories', error); 77 | return formatErrorForMcpTool(error); 78 | } 79 | } 80 | 81 | /** 82 | * MCP Tool: Get Bitbucket Repository Details 83 | * 84 | * Retrieves detailed information about a specific Bitbucket repository. 85 | * Returns a formatted markdown response with repository metadata. 86 | * 87 | * @param args - Tool arguments containing the workspace and repository slug 88 | * @returns MCP response with formatted repository details 89 | * @throws Will return error message if repository retrieval fails 90 | */ 91 | async function getRepository(args: Record<string, unknown>) { 92 | const methodLogger = Logger.forContext( 93 | 'tools/atlassian.repositories.tool.ts', 94 | 'getRepository', 95 | ); 96 | methodLogger.debug('Getting repository details:', args); 97 | 98 | try { 99 | // Pass args directly to controller 100 | const result = await handleRepositoryDetails( 101 | args as GetRepositoryToolArgsType, 102 | ); 103 | 104 | methodLogger.debug( 105 | 'Successfully retrieved repository details from controller', 106 | ); 107 | 108 | return { 109 | content: [ 110 | { 111 | type: 'text' as const, 112 | text: result.content, 113 | }, 114 | ], 115 | }; 116 | } catch (error) { 117 | methodLogger.error('Failed to get repository details', error); 118 | return formatErrorForMcpTool(error); 119 | } 120 | } 121 | 122 | /** 123 | * MCP Tool: Get Bitbucket Commit History 124 | * 125 | * Retrieves the commit history for a specific repository. 126 | * 127 | * @param args Tool arguments including workspace/repo slugs and optional filters. 128 | * @returns MCP response with formatted commit history. 129 | * @throws Will return error message if history retrieval fails. 130 | */ 131 | async function handleGetCommitHistory(args: Record<string, unknown>) { 132 | const methodLogger = Logger.forContext( 133 | 'tools/atlassian.repositories.tool.ts', 134 | 'handleGetCommitHistory', 135 | ); 136 | methodLogger.debug('Getting commit history with args:', args); 137 | 138 | try { 139 | // Pass args directly to controller 140 | const result = await handleCommitHistory( 141 | args as GetCommitHistoryToolArgsType, 142 | ); 143 | 144 | methodLogger.debug( 145 | 'Successfully retrieved commit history from controller', 146 | ); 147 | 148 | return { 149 | content: [{ type: 'text' as const, text: result.content }], 150 | }; 151 | } catch (error) { 152 | methodLogger.error('Failed to get commit history', error); 153 | return formatErrorForMcpTool(error); 154 | } 155 | } 156 | 157 | /** 158 | * Handler for adding a new branch. 159 | */ 160 | async function handleAddBranch(args: Record<string, unknown>) { 161 | const methodLogger = Logger.forContext( 162 | 'tools/atlassian.repositories.tool.ts', 163 | 'handleAddBranch', 164 | ); 165 | try { 166 | methodLogger.debug('Creating new branch:', args); 167 | 168 | // Pass args directly to controller 169 | const result = await handleCreateBranch( 170 | args as CreateBranchToolArgsType, 171 | ); 172 | 173 | methodLogger.debug('Successfully created branch via controller'); 174 | 175 | return { 176 | content: [{ type: 'text' as const, text: result.content }], 177 | }; 178 | } catch (error) { 179 | methodLogger.error('Failed to create branch', error); 180 | return formatErrorForMcpTool(error); 181 | } 182 | } 183 | 184 | /** 185 | * Handler for cloning a repository. 186 | */ 187 | async function handleRepoClone(args: Record<string, unknown>) { 188 | const methodLogger = Logger.forContext( 189 | 'tools/atlassian.repositories.tool.ts', 190 | 'handleRepoClone', 191 | ); 192 | try { 193 | methodLogger.debug('Cloning repository:', args); 194 | 195 | // Pass args directly to controller 196 | const result = await handleCloneRepository( 197 | args as CloneRepositoryToolArgsType, 198 | ); 199 | 200 | methodLogger.debug('Successfully cloned repository via controller'); 201 | 202 | return { 203 | content: [{ type: 'text' as const, text: result.content }], 204 | }; 205 | } catch (error) { 206 | methodLogger.error('Failed to clone repository', error); 207 | return formatErrorForMcpTool(error); 208 | } 209 | } 210 | 211 | /** 212 | * Handler for getting file content. 213 | */ 214 | async function getFileContent(args: Record<string, unknown>) { 215 | const methodLogger = toolLogger.forMethod('getFileContent'); 216 | try { 217 | methodLogger.debug('Getting file content:', args); 218 | 219 | // Map tool args to controller args 220 | const typedArgs = args as GetFileContentToolArgsType; 221 | const result = await handleGetFileContent({ 222 | workspaceSlug: typedArgs.workspaceSlug, 223 | repoSlug: typedArgs.repoSlug, 224 | path: typedArgs.filePath, 225 | ref: typedArgs.revision, 226 | }); 227 | 228 | methodLogger.debug( 229 | 'Successfully retrieved file content via controller', 230 | ); 231 | 232 | return { 233 | content: [{ type: 'text' as const, text: result.content }], 234 | }; 235 | } catch (error) { 236 | methodLogger.error('Failed to get file content', error); 237 | return formatErrorForMcpTool(error); 238 | } 239 | } 240 | 241 | /** 242 | * MCP Tool: List Branches in a Bitbucket Repository 243 | * 244 | * Lists branches within a specific repository with optional filtering. 245 | * Returns a formatted markdown response with branch details. 246 | * 247 | * @param args - Tool arguments for identifying the repository and filtering branches 248 | * @returns MCP response with formatted branches list 249 | * @throws Will return error message if branch listing fails 250 | */ 251 | async function listBranches(args: Record<string, unknown>) { 252 | const methodLogger = Logger.forContext( 253 | 'tools/atlassian.repositories.tool.ts', 254 | 'listBranches', 255 | ); 256 | methodLogger.debug('Listing branches with filters:', args); 257 | 258 | try { 259 | // Pass args directly to controller 260 | const result = await handleListBranches( 261 | args as ListBranchesToolArgsType, 262 | ); 263 | 264 | methodLogger.debug('Successfully retrieved branches from controller'); 265 | 266 | return { 267 | content: [ 268 | { 269 | type: 'text' as const, 270 | text: result.content, 271 | }, 272 | ], 273 | }; 274 | } catch (error) { 275 | methodLogger.error('Failed to list branches', error); 276 | return formatErrorForMcpTool(error); 277 | } 278 | } 279 | 280 | /** 281 | * Register all Bitbucket repository tools with the MCP server. 282 | */ 283 | function registerTools(server: McpServer) { 284 | const registerLogger = Logger.forContext( 285 | 'tools/atlassian.repositories.tool.ts', 286 | 'registerTools', 287 | ); 288 | registerLogger.debug('Registering Repository tools...'); 289 | 290 | // Register the list repositories tool 291 | server.tool( 292 | 'bb_ls_repos', 293 | `Lists repositories within a workspace. If \`workspaceSlug\` is not provided, uses your default workspace (either configured via BITBUCKET_DEFAULT_WORKSPACE or the first workspace in your account). Filters repositories by the user\`s \`role\`, project key \`projectKey\`, or a \`query\` string (searches name/description). Supports sorting via \`sort\` and pagination via \`limit\` and \`cursor\`. Pagination details are included at the end of the text content. Returns a formatted Markdown list with comprehensive details. Requires Bitbucket credentials.`, 294 | ListRepositoriesToolArgs.shape, 295 | listRepositories, 296 | ); 297 | 298 | // Register the get repository details tool 299 | server.tool( 300 | 'bb_get_repo', 301 | `Retrieves detailed information for a specific repository identified by \`workspaceSlug\` and \`repoSlug\`. Returns comprehensive repository details as formatted Markdown, including owner, main branch, comment/task counts, recent pull requests, and relevant links. Requires Bitbucket credentials.`, 302 | GetRepositoryToolArgs.shape, 303 | getRepository, 304 | ); 305 | 306 | // Register the get commit history tool 307 | server.tool( 308 | 'bb_get_commit_history', 309 | `Retrieves the commit history for a repository identified by \`workspaceSlug\` and \`repoSlug\`. Supports pagination via \`limit\` (number of commits per page) and \`cursor\` (which acts as the page number for this endpoint). Optionally filters history starting from a specific branch, tag, or commit hash using \`revision\`, or shows only commits affecting a specific file using \`path\`. Returns the commit history as formatted Markdown, including commit hash, author, date, and message. Pagination details are included at the end of the text content. Requires Bitbucket credentials to be configured.`, 310 | GetCommitHistoryToolArgs.shape, 311 | handleGetCommitHistory, 312 | ); 313 | 314 | // Add the new branch tool 315 | server.tool( 316 | 'bb_add_branch', 317 | `Creates a new branch in a specified Bitbucket repository. Requires the workspace slug (\`workspaceSlug\`), repository slug (\`repoSlug\`), the desired new branch name (\`newBranchName\`), and the source branch or commit hash (\`sourceBranchOrCommit\`) to branch from. Requires repository write permissions. Returns a success message.`, 318 | CreateBranchToolArgsSchema.shape, 319 | handleAddBranch, 320 | ); 321 | 322 | // Register the clone repository tool 323 | server.tool( 324 | 'bb_clone_repo', 325 | `Clones a Bitbucket repository to your local filesystem using SSH (preferred) or HTTPS. Requires Bitbucket credentials and proper SSH key setup for optimal usage. 326 | 327 | **Parameters:** 328 | - \`workspaceSlug\`: The Bitbucket workspace containing the repository (optional - will use default if not provided) 329 | - \`repoSlug\`: The repository name to clone (required) 330 | - \`targetPath\`: Parent directory where repository will be cloned (required) 331 | 332 | **Path Handling:** 333 | - Absolute paths are strongly recommended (e.g., "/home/user/projects" or "C:\\Users\\name\\projects") 334 | - Relative paths (e.g., "./my-repos" or "../downloads") will be resolved relative to the server's working directory, which may not be what you expect 335 | - The repository will be cloned into a subdirectory at \`targetPath/repoSlug\` 336 | - Make sure you have write permissions to the target directory 337 | 338 | **SSH Requirements:** 339 | - SSH keys must be properly configured for Bitbucket 340 | - SSH agent should be running with your keys added 341 | - Will automatically fall back to HTTPS if SSH is unavailable 342 | 343 | **Example Usage:** 344 | \`\`\` 345 | // Clone a repository to a specific absolute path 346 | bb_clone_repo({repoSlug: "my-project", targetPath: "/home/user/projects"}) 347 | 348 | // Specify the workspace and use a relative path (less reliable) 349 | bb_clone_repo({workspaceSlug: "my-team", repoSlug: "api-service", targetPath: "./downloads"}) 350 | \`\`\` 351 | 352 | **Returns:** Success message with clone details or an error message with troubleshooting steps.`, 353 | CloneRepositoryToolArgs.shape, 354 | handleRepoClone, 355 | ); 356 | 357 | // Register the get file content tool 358 | server.tool( 359 | 'bb_get_file', 360 | `Retrieves the content of a file from a Bitbucket repository identified by \`workspaceSlug\` and \`repoSlug\`. Specify the file to retrieve using the \`filePath\` parameter. Optionally, you can specify a \`revision\` (branch name, tag, or commit hash) to retrieve the file from - if omitted, the repository's default branch is used. Returns the raw content of the file as text. Requires Bitbucket credentials.`, 361 | GetFileContentToolArgs.shape, 362 | getFileContent, 363 | ); 364 | 365 | // Register the list branches tool 366 | server.tool( 367 | 'bb_list_branches', 368 | `Lists branches in a repository identified by \`workspaceSlug\` and \`repoSlug\`. Filters branches by an optional text \`query\` and supports custom \`sort\` order. Provides pagination via \`limit\` and \`cursor\`. Pagination details are included at the end of the text content. Returns branch details as Markdown with each branch's name, latest commit, and default merge strategy. Requires Bitbucket credentials.`, 369 | ListBranchesToolArgs.shape, 370 | listBranches, 371 | ); 372 | 373 | registerLogger.debug('Successfully registered Repository tools'); 374 | } 375 | 376 | export default { registerTools }; 377 | ``` -------------------------------------------------------------------------------- /src/cli/atlassian.repositories.cli.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { CliTestUtil } from '../utils/cli.test.util.js'; 2 | import { getAtlassianCredentials } from '../utils/transport.util.js'; 3 | import { config } from '../utils/config.util.js'; 4 | 5 | describe('Atlassian Repositories CLI Commands', () => { 6 | // Load configuration and check for credentials before all tests 7 | beforeAll(() => { 8 | // Load configuration from all sources 9 | config.load(); 10 | 11 | // Log warning if credentials aren't available 12 | const credentials = getAtlassianCredentials(); 13 | if (!credentials) { 14 | console.warn( 15 | 'Skipping Atlassian Repositories CLI tests: No credentials available', 16 | ); 17 | } 18 | }); 19 | 20 | // Helper function to skip tests when credentials are missing 21 | const skipIfNoCredentials = () => { 22 | const credentials = getAtlassianCredentials(); 23 | if (!credentials) { 24 | return true; 25 | } 26 | return false; 27 | }; 28 | 29 | // Helper to get a valid workspace slug for testing 30 | async function getWorkspaceSlug(): Promise<string | null> { 31 | // First, get a list of workspaces 32 | const workspacesResult = await CliTestUtil.runCommand([ 33 | 'ls-workspaces', 34 | ]); 35 | 36 | // Skip if no workspaces are available 37 | if ( 38 | workspacesResult.stdout.includes('No Bitbucket workspaces found.') 39 | ) { 40 | return null; // Skip silently for this helper function 41 | } 42 | 43 | // Extract a workspace slug from the output 44 | const slugMatch = workspacesResult.stdout.match( 45 | /\*\*Slug\*\*:\s+([^\n]+)/, 46 | ); 47 | if (!slugMatch || !slugMatch[1]) { 48 | return null; // Skip silently for this helper function 49 | } 50 | 51 | return slugMatch[1].trim(); 52 | } 53 | 54 | describe('ls-repos command', () => { 55 | // Test listing repositories for a workspace 56 | it('should list repositories in a workspace', async () => { 57 | if (skipIfNoCredentials()) { 58 | return; 59 | } 60 | 61 | // Get a valid workspace 62 | const workspaceSlug = await getWorkspaceSlug(); 63 | if (!workspaceSlug) { 64 | return; // Skip if no valid workspace found 65 | } 66 | 67 | // Run the CLI command 68 | const result = await CliTestUtil.runCommand([ 69 | 'ls-repos', 70 | '--workspace-slug', 71 | workspaceSlug, 72 | ]); 73 | 74 | // Check command exit code 75 | expect(result.exitCode).toBe(0); 76 | 77 | // Verify the output format if there are repositories 78 | if (!result.stdout.includes('No repositories found')) { 79 | // Validate expected Markdown structure 80 | CliTestUtil.validateOutputContains(result.stdout, [ 81 | '# Bitbucket Repositories', 82 | '**Name**', 83 | '**Full Name**', 84 | '**Owner**', 85 | ]); 86 | 87 | // Validate Markdown formatting 88 | CliTestUtil.validateMarkdownOutput(result.stdout); 89 | } 90 | }, 30000); // Increased timeout for API call 91 | 92 | // Test with pagination 93 | it('should support pagination with --limit flag', async () => { 94 | if (skipIfNoCredentials()) { 95 | return; 96 | } 97 | 98 | // Get a valid workspace 99 | const workspaceSlug = await getWorkspaceSlug(); 100 | if (!workspaceSlug) { 101 | return; // Skip if no valid workspace found 102 | } 103 | 104 | // Run the CLI command with limit 105 | const result = await CliTestUtil.runCommand([ 106 | 'ls-repos', 107 | '--workspace-slug', 108 | workspaceSlug, 109 | '--limit', 110 | '1', 111 | ]); 112 | 113 | // Check command exit code 114 | expect(result.exitCode).toBe(0); 115 | 116 | // If there are multiple repositories, pagination section should be present 117 | if ( 118 | !result.stdout.includes('No repositories found') && 119 | result.stdout.includes('items remaining') 120 | ) { 121 | CliTestUtil.validateOutputContains(result.stdout, [ 122 | 'Pagination', 123 | 'Next cursor:', 124 | ]); 125 | } 126 | }, 30000); // Increased timeout for API call 127 | 128 | // Test with query filtering 129 | it('should support filtering with --query parameter', async () => { 130 | if (skipIfNoCredentials()) { 131 | return; 132 | } 133 | 134 | // Get a valid workspace 135 | const workspaceSlug = await getWorkspaceSlug(); 136 | if (!workspaceSlug) { 137 | return; // Skip if no valid workspace found 138 | } 139 | 140 | // Use a common term that might be in repository names 141 | const query = 'api'; 142 | 143 | // Run the CLI command with query 144 | const result = await CliTestUtil.runCommand([ 145 | 'ls-repos', 146 | '--workspace-slug', 147 | workspaceSlug, 148 | '--query', 149 | query, 150 | ]); 151 | 152 | // Check command exit code 153 | expect(result.exitCode).toBe(0); 154 | 155 | // Output might contain filtered results or no matches, both are valid 156 | if (result.stdout.includes('No repositories found')) { 157 | // Valid case - no repositories match the query 158 | CliTestUtil.validateOutputContains(result.stdout, [ 159 | 'No repositories found', 160 | ]); 161 | } else { 162 | // Valid case - some repositories match, check formatting 163 | CliTestUtil.validateMarkdownOutput(result.stdout); 164 | } 165 | }, 30000); // Increased timeout for API call 166 | 167 | // Test with role filtering (if supported by the API) 168 | it('should support filtering by --role', async () => { 169 | if (skipIfNoCredentials()) { 170 | return; 171 | } 172 | 173 | // Get a valid workspace 174 | const workspaceSlug = await getWorkspaceSlug(); 175 | if (!workspaceSlug) { 176 | return; // Skip if no valid workspace found 177 | } 178 | 179 | // Test one role - we pick 'contributor' as it's most likely to have results 180 | const result = await CliTestUtil.runCommand([ 181 | 'ls-repos', 182 | '--workspace-slug', 183 | workspaceSlug, 184 | '--role', 185 | 'contributor', 186 | ]); 187 | 188 | // Check command exit code 189 | expect(result.exitCode).toBe(0); 190 | 191 | // Output might contain filtered results or no matches, both are valid 192 | if (result.stdout.includes('No repositories found')) { 193 | // Valid case - no repositories match the role filter 194 | CliTestUtil.validateOutputContains(result.stdout, [ 195 | 'No repositories found', 196 | ]); 197 | } else { 198 | // Valid case - some repositories match the role, check formatting 199 | CliTestUtil.validateMarkdownOutput(result.stdout); 200 | } 201 | }, 30000); // Increased timeout for API call 202 | 203 | // Test with sort parameter 204 | it('should support sorting with --sort parameter', async () => { 205 | if (skipIfNoCredentials()) { 206 | return; 207 | } 208 | 209 | // Get a valid workspace 210 | const workspaceSlug = await getWorkspaceSlug(); 211 | if (!workspaceSlug) { 212 | return; // Skip if no valid workspace found 213 | } 214 | 215 | // Test sorting by name (alphabetical) 216 | const result = await CliTestUtil.runCommand([ 217 | 'ls-repos', 218 | '--workspace-slug', 219 | workspaceSlug, 220 | '--sort', 221 | 'name', 222 | ]); 223 | 224 | // Check command exit code 225 | expect(result.exitCode).toBe(0); 226 | 227 | // Sorting doesn't affect whether items are returned 228 | if (!result.stdout.includes('No repositories found')) { 229 | // Validate Markdown formatting 230 | CliTestUtil.validateMarkdownOutput(result.stdout); 231 | } 232 | }, 30000); // Increased timeout for API call 233 | 234 | // Test without workspace parameter (now optional) 235 | it('should use default workspace when workspace is not provided', async () => { 236 | if (skipIfNoCredentials()) { 237 | return; 238 | } 239 | 240 | // Run command without workspace parameter 241 | const result = await CliTestUtil.runCommand(['ls-repos']); 242 | 243 | // Should succeed with exit code 0 (using default workspace) 244 | expect(result.exitCode).toBe(0); 245 | 246 | // Output should contain either repositories or "No repositories found" 247 | const hasRepos = !result.stdout.includes('No repositories found'); 248 | 249 | if (hasRepos) { 250 | // Validate expected Markdown structure if repos are found 251 | CliTestUtil.validateOutputContains(result.stdout, [ 252 | '# Bitbucket Repositories', 253 | ]); 254 | } else { 255 | // No repositories were found but command should still succeed 256 | CliTestUtil.validateOutputContains(result.stdout, [ 257 | 'No repositories found', 258 | ]); 259 | } 260 | }, 15000); 261 | 262 | // Test with invalid parameter value 263 | it('should handle invalid limit values properly', async () => { 264 | if (skipIfNoCredentials()) { 265 | return; 266 | } 267 | 268 | // Get a valid workspace 269 | const workspaceSlug = await getWorkspaceSlug(); 270 | if (!workspaceSlug) { 271 | return; // Skip if no valid workspace found 272 | } 273 | 274 | // Run with non-numeric limit 275 | const result = await CliTestUtil.runCommand([ 276 | 'ls-repos', 277 | '--workspace-slug', 278 | workspaceSlug, 279 | '--limit', 280 | 'invalid', 281 | ]); 282 | 283 | // This might either return an error (non-zero exit code) or handle it gracefully (zero exit code) 284 | // Both behaviors are acceptable, we just need to check that the command completes 285 | if (result.exitCode !== 0) { 286 | expect(result.stderr).toContain('error'); 287 | } else { 288 | // Command completed without error, the implementation should handle it gracefully 289 | expect(result.exitCode).toBe(0); 290 | } 291 | }, 30000); 292 | }); 293 | 294 | describe('get-repo command', () => { 295 | // Helper to get a valid repository for testing 296 | async function getRepositorySlug( 297 | workspaceSlug: string, 298 | ): Promise<string | null> { 299 | // Get repositories for this workspace 300 | const reposResult = await CliTestUtil.runCommand([ 301 | 'ls-repos', 302 | '--workspace-slug', 303 | workspaceSlug, 304 | ]); 305 | 306 | // Skip if no repositories are available 307 | if (reposResult.stdout.includes('No repositories found')) { 308 | return null; // Skip silently for this helper function 309 | } 310 | 311 | // Extract a repository slug from the output 312 | const repoMatch = reposResult.stdout.match( 313 | /\*\*Name\*\*:\s+([^\n]+)/, 314 | ); 315 | if (!repoMatch || !repoMatch[1]) { 316 | console.warn( 317 | 'Skipping test: Could not extract repository slug', 318 | ); 319 | return null; 320 | } 321 | 322 | return repoMatch[1].trim(); 323 | } 324 | 325 | // Test to fetch a specific repository 326 | it('should retrieve repository details', async () => { 327 | if (skipIfNoCredentials()) { 328 | return; 329 | } 330 | 331 | // Get valid workspace and repository slugs 332 | const workspaceSlug = await getWorkspaceSlug(); 333 | if (!workspaceSlug) { 334 | return; // Skip if no valid workspace found 335 | } 336 | 337 | const repoSlug = await getRepositorySlug(workspaceSlug); 338 | if (!repoSlug) { 339 | return; // Skip if no valid repository found 340 | } 341 | 342 | // Run the get-repo command 343 | const result = await CliTestUtil.runCommand([ 344 | 'get-repo', 345 | '--workspace-slug', 346 | workspaceSlug, 347 | '--repo-slug', 348 | repoSlug, 349 | ]); 350 | 351 | // Instead of expecting a success, check if the command ran 352 | // If access is unavailable, just note it and skip the test validation 353 | if (result.exitCode !== 0) { 354 | console.warn( 355 | 'Skipping test validation: Could not retrieve repository details', 356 | ); 357 | return; 358 | } 359 | 360 | // Verify the output structure and content 361 | CliTestUtil.validateOutputContains(result.stdout, [ 362 | `# Repository: ${repoSlug}`, 363 | '## Basic Information', 364 | '**Name**', 365 | '**Full Name**', 366 | '**UUID**', 367 | '## Owner', 368 | '## Links', 369 | ]); 370 | 371 | // Validate Markdown formatting 372 | CliTestUtil.validateMarkdownOutput(result.stdout); 373 | }, 30000); // Increased timeout for API calls 374 | 375 | // Test with missing workspace parameter 376 | it('should use default workspace when workspace is not provided', async () => { 377 | if (skipIfNoCredentials()) { 378 | return; 379 | } 380 | 381 | // Run command without the workspace parameter 382 | const result = await CliTestUtil.runCommand([ 383 | 'get-repo', 384 | '--repo-slug', 385 | 'some-repo', 386 | ]); 387 | 388 | // Now that workspace is optional, we should get a different error 389 | // (repository not found), but not a missing parameter error 390 | expect(result.exitCode).not.toBe(0); 391 | 392 | // Should NOT indicate missing required option for workspace 393 | expect(result.stderr).not.toContain('workspace-slug'); 394 | }, 15000); 395 | 396 | // Test with missing repository parameter 397 | it('should fail when repository is not provided', async () => { 398 | if (skipIfNoCredentials()) { 399 | return; 400 | } 401 | 402 | // Get a valid workspace 403 | const workspaceSlug = await getWorkspaceSlug(); 404 | if (!workspaceSlug) { 405 | return; // Skip if no valid workspace found 406 | } 407 | 408 | // Run command without the repository parameter 409 | const result = await CliTestUtil.runCommand([ 410 | 'get-repo', 411 | '--workspace-slug', 412 | workspaceSlug, 413 | ]); 414 | 415 | // Should fail with non-zero exit code 416 | expect(result.exitCode).not.toBe(0); 417 | 418 | // Should indicate missing required option 419 | expect(result.stderr).toContain('required option'); 420 | }, 15000); 421 | 422 | // Test with invalid repository slug 423 | it('should handle invalid repository slugs gracefully', async () => { 424 | if (skipIfNoCredentials()) { 425 | return; 426 | } 427 | 428 | // Get a valid workspace 429 | const workspaceSlug = await getWorkspaceSlug(); 430 | if (!workspaceSlug) { 431 | return; // Skip if no valid workspace found 432 | } 433 | 434 | // Use a deliberately invalid repository slug 435 | const invalidSlug = 'invalid-repository-slug-that-does-not-exist'; 436 | 437 | // Run command with invalid repository slug 438 | const result = await CliTestUtil.runCommand([ 439 | 'get-repo', 440 | '--workspace-slug', 441 | workspaceSlug, 442 | '--repo-slug', 443 | invalidSlug, 444 | ]); 445 | 446 | // Should fail with non-zero exit code 447 | expect(result.exitCode).not.toBe(0); 448 | 449 | // Should contain error information 450 | expect(result.stderr).toContain('error'); 451 | }, 30000); 452 | 453 | // Test with invalid workspace slug but valid repository format 454 | it('should handle invalid workspace slugs gracefully', async () => { 455 | if (skipIfNoCredentials()) { 456 | return; 457 | } 458 | 459 | // Use deliberately invalid workspace and repository slugs 460 | const invalidWorkspace = 'invalid-workspace-that-does-not-exist'; 461 | const someRepo = 'some-repo'; 462 | 463 | // Run command with invalid workspace slug 464 | const result = await CliTestUtil.runCommand([ 465 | 'get-repo', 466 | '--workspace-slug', 467 | invalidWorkspace, 468 | '--repo-slug', 469 | someRepo, 470 | ]); 471 | 472 | // Should fail with non-zero exit code 473 | expect(result.exitCode).not.toBe(0); 474 | 475 | // Should contain error information 476 | expect(result.stderr).toContain('error'); 477 | }, 30000); 478 | }); 479 | }); 480 | ``` -------------------------------------------------------------------------------- /src/utils/formatter.util.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Standardized formatting utilities for consistent output across all CLI and Tool interfaces. 3 | * These functions should be used by all formatters to ensure consistent formatting. 4 | */ 5 | 6 | import { Logger } from './logger.util.js'; // Ensure logger is imported 7 | import { ResponsePagination } from '../types/common.types.js'; 8 | 9 | // const formatterLogger = Logger.forContext('utils/formatter.util.ts'); // Define logger instance - Removed as unused 10 | 11 | /** 12 | * Format a date in a standardized way: YYYY-MM-DD HH:MM:SS UTC 13 | * @param dateString - ISO date string or Date object 14 | * @returns Formatted date string 15 | */ 16 | export function formatDate(dateString?: string | Date): string { 17 | if (!dateString) { 18 | return 'Not available'; 19 | } 20 | 21 | try { 22 | const date = 23 | typeof dateString === 'string' ? new Date(dateString) : dateString; 24 | 25 | // Format: YYYY-MM-DD HH:MM:SS UTC 26 | return date 27 | .toISOString() 28 | .replace('T', ' ') 29 | .replace(/\.\d+Z$/, ' UTC'); 30 | } catch { 31 | return 'Invalid date'; 32 | } 33 | } 34 | 35 | /** 36 | * Format a URL as a markdown link 37 | * @param url - URL to format 38 | * @param title - Link title 39 | * @returns Formatted markdown link 40 | */ 41 | export function formatUrl(url?: string, title?: string): string { 42 | if (!url) { 43 | return 'Not available'; 44 | } 45 | 46 | const linkTitle = title || url; 47 | return `[${linkTitle}](${url})`; 48 | } 49 | 50 | /** 51 | * Format pagination information in a standardized way for CLI output. 52 | * Includes separator, item counts, availability message, next page instructions, and timestamp. 53 | * @param pagination - The ResponsePagination object containing pagination details. 54 | * @returns Formatted pagination footer string for CLI. 55 | */ 56 | export function formatPagination(pagination: ResponsePagination): string { 57 | const methodLogger = Logger.forContext( 58 | 'utils/formatter.util.ts', 59 | 'formatPagination', 60 | ); 61 | const parts: string[] = [formatSeparator()]; // Start with separator 62 | 63 | const { count = 0, hasMore, nextCursor, total, page } = pagination; 64 | 65 | // Showing count and potentially total 66 | if (total !== undefined && total >= 0) { 67 | parts.push(`*Showing ${count} of ${total} total items.*`); 68 | } else if (count >= 0) { 69 | parts.push(`*Showing ${count} item${count !== 1 ? 's' : ''}.*`); 70 | } 71 | 72 | // More results availability 73 | if (hasMore) { 74 | parts.push('More results are available.'); 75 | } 76 | 77 | // Include the actual cursor value for programmatic use 78 | if (hasMore && nextCursor) { 79 | parts.push(`*Next cursor: \`${nextCursor}\`*`); 80 | // Assuming nextCursor holds the next page number for Bitbucket 81 | parts.push(`*Use --page ${nextCursor} to view more.*`); 82 | } else if (hasMore && page !== undefined) { 83 | // Fallback if nextCursor wasn't parsed but page exists 84 | const nextPage = page + 1; 85 | parts.push(`*Next cursor: \`${nextPage}\`*`); 86 | parts.push(`*Use --page ${nextPage} to view more.*`); 87 | } 88 | 89 | // Add standard timestamp 90 | parts.push(`*Information retrieved at: ${formatDate(new Date())}*`); 91 | 92 | const result = parts.join('\n').trim(); // Join with newline 93 | methodLogger.debug(`Formatted pagination footer: ${result}`); 94 | return result; 95 | } 96 | 97 | /** 98 | * Format a heading with consistent style 99 | * @param text - Heading text 100 | * @param level - Heading level (1-6) 101 | * @returns Formatted heading 102 | */ 103 | export function formatHeading(text: string, level: number = 1): string { 104 | const validLevel = Math.min(Math.max(level, 1), 6); 105 | const prefix = '#'.repeat(validLevel); 106 | return `${prefix} ${text}`; 107 | } 108 | 109 | /** 110 | * Format a list of key-value pairs as a bullet list 111 | * @param items - Object with key-value pairs 112 | * @param keyFormatter - Optional function to format keys 113 | * @returns Formatted bullet list 114 | */ 115 | export function formatBulletList( 116 | items: Record<string, unknown>, 117 | keyFormatter?: (key: string) => string, 118 | ): string { 119 | const lines: string[] = []; 120 | 121 | for (const [key, value] of Object.entries(items)) { 122 | if (value === undefined || value === null) { 123 | continue; 124 | } 125 | 126 | const formattedKey = keyFormatter ? keyFormatter(key) : key; 127 | const formattedValue = formatValue(value); 128 | lines.push(`- **${formattedKey}**: ${formattedValue}`); 129 | } 130 | 131 | return lines.join('\n'); 132 | } 133 | 134 | /** 135 | * Format a value based on its type 136 | * @param value - Value to format 137 | * @returns Formatted value 138 | */ 139 | function formatValue(value: unknown): string { 140 | if (value === undefined || value === null) { 141 | return 'Not available'; 142 | } 143 | 144 | if (value instanceof Date) { 145 | return formatDate(value); 146 | } 147 | 148 | // Handle URL objects with url and title properties 149 | if (typeof value === 'object' && value !== null && 'url' in value) { 150 | const urlObj = value as { url: string; title?: string }; 151 | if (typeof urlObj.url === 'string') { 152 | return formatUrl(urlObj.url, urlObj.title); 153 | } 154 | } 155 | 156 | if (typeof value === 'string') { 157 | // Check if it's a URL 158 | if (value.startsWith('http://') || value.startsWith('https://')) { 159 | return formatUrl(value); 160 | } 161 | 162 | // Check if it might be a date 163 | if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) { 164 | return formatDate(value); 165 | } 166 | 167 | return value; 168 | } 169 | 170 | if (typeof value === 'boolean') { 171 | return value ? 'Yes' : 'No'; 172 | } 173 | 174 | return String(value); 175 | } 176 | 177 | /** 178 | * Format a separator line 179 | * @returns Separator line 180 | */ 181 | export function formatSeparator(): string { 182 | return '---'; 183 | } 184 | 185 | /** 186 | * Format a numbered list of items 187 | * @param items - Array of items to format 188 | * @param formatter - Function to format each item 189 | * @returns Formatted numbered list 190 | */ 191 | export function formatNumberedList<T>( 192 | items: T[], 193 | formatter: (item: T, index: number) => string, 194 | ): string { 195 | if (items.length === 0) { 196 | return 'No items.'; 197 | } 198 | 199 | return items.map((item, index) => formatter(item, index)).join('\n\n'); 200 | } 201 | 202 | /** 203 | * Format a raw diff output for display 204 | * 205 | * Parses and formats a raw unified diff string into a Markdown 206 | * formatted display with proper code block syntax highlighting. 207 | * 208 | * @param {string} rawDiff - The raw diff content from the API 209 | * @param {number} maxFiles - Maximum number of files to display in detail (optional, default: 5) 210 | * @param {number} maxLinesPerFile - Maximum number of lines to display per file (optional, default: 100) 211 | * @returns {string} Markdown formatted diff content 212 | */ 213 | export function formatDiff( 214 | rawDiff: string, 215 | maxFiles: number = 5, 216 | maxLinesPerFile: number = 100, 217 | ): string { 218 | if (!rawDiff || rawDiff.trim() === '') { 219 | return '*No changes found in this pull request.*'; 220 | } 221 | 222 | const lines = rawDiff.split('\n'); 223 | const formattedLines: string[] = []; 224 | let currentFile = ''; 225 | let fileCount = 0; 226 | let inFile = false; 227 | let truncated = false; 228 | let lineCount = 0; 229 | 230 | for (const line of lines) { 231 | // New file is marked by a line starting with "diff --git" 232 | if (line.startsWith('diff --git')) { 233 | if (inFile) { 234 | // Close previous file code block 235 | formattedLines.push('```'); 236 | formattedLines.push(''); 237 | } 238 | 239 | // Only process up to maxFiles 240 | fileCount++; 241 | if (fileCount > maxFiles) { 242 | truncated = true; 243 | break; 244 | } 245 | 246 | // Extract filename 247 | const filePath = line.match(/diff --git a\/(.*) b\/(.*)/); 248 | currentFile = filePath ? filePath[1] : 'unknown file'; 249 | formattedLines.push(`### ${currentFile}`); 250 | formattedLines.push(''); 251 | formattedLines.push('```diff'); 252 | inFile = true; 253 | lineCount = 0; 254 | } else if (inFile) { 255 | lineCount++; 256 | 257 | // Truncate files that are too long 258 | if (lineCount > maxLinesPerFile) { 259 | formattedLines.push( 260 | '// ... more lines omitted for brevity ...', 261 | ); 262 | formattedLines.push('```'); 263 | formattedLines.push(''); 264 | inFile = false; 265 | continue; 266 | } 267 | 268 | // Format diff lines with appropriate highlighting 269 | if (line.startsWith('+')) { 270 | formattedLines.push(line); 271 | } else if (line.startsWith('-')) { 272 | formattedLines.push(line); 273 | } else if (line.startsWith('@@')) { 274 | // Change section header 275 | formattedLines.push(line); 276 | } else { 277 | // Context line 278 | formattedLines.push(line); 279 | } 280 | } 281 | } 282 | 283 | // Close the last code block if necessary 284 | if (inFile) { 285 | formattedLines.push('```'); 286 | } 287 | 288 | // Add truncation notice if we limited the output 289 | if (truncated) { 290 | formattedLines.push(''); 291 | formattedLines.push( 292 | `*Output truncated. Only showing the first ${maxFiles} files.*`, 293 | ); 294 | } 295 | 296 | return formattedLines.join('\n'); 297 | } 298 | 299 | /** 300 | * Optimizes markdown content to address Bitbucket Cloud's rendering quirks 301 | * 302 | * IMPORTANT: This function does NOT convert between formats (unlike Jira's ADF conversion). 303 | * Bitbucket Cloud API natively accepts and returns markdown format. This function specifically 304 | * addresses documented rendering issues in Bitbucket's markdown renderer by applying targeted 305 | * formatting adjustments for better display in the Bitbucket UI. 306 | * 307 | * Known Bitbucket rendering issues this function fixes: 308 | * - List spacing and indentation (prevents items from concatenating on a single line) 309 | * - Code block formatting (addresses BCLOUD-20503 and similar bugs) 310 | * - Nested list indentation (ensures proper hierarchy display) 311 | * - Inline code formatting (adds proper spacing around backticks) 312 | * - Diff syntax preservation (maintains +/- at line starts) 313 | * - Excessive line break normalization 314 | * - Heading spacing consistency 315 | * 316 | * Use this function for both: 317 | * - Content received FROM the Bitbucket API (to properly display in CLI/tools) 318 | * - Content being sent TO the Bitbucket API (to ensure proper rendering in Bitbucket UI) 319 | * 320 | * @param {string} markdown - The original markdown content 321 | * @returns {string} Optimized markdown with workarounds for Bitbucket rendering issues 322 | */ 323 | export function optimizeBitbucketMarkdown(markdown: string): string { 324 | const methodLogger = Logger.forContext( 325 | 'utils/formatter.util.ts', 326 | 'optimizeBitbucketMarkdown', 327 | ); 328 | 329 | if (!markdown || markdown.trim() === '') { 330 | return markdown; 331 | } 332 | 333 | methodLogger.debug('Optimizing markdown for Bitbucket rendering'); 334 | 335 | // First, let's extract code blocks to protect them from other transformations 336 | const codeBlocks: string[] = []; 337 | let optimized = markdown.replace( 338 | /```(\w*)\n([\s\S]*?)```/g, 339 | (_match, language, code) => { 340 | // Store the code block and replace with a placeholder 341 | const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`; 342 | codeBlocks.push(`\n\n\`\`\`${language}\n${code}\n\`\`\`\n\n`); 343 | return placeholder; 344 | }, 345 | ); 346 | 347 | // Fix numbered lists with proper spacing 348 | // Match numbered lists (1. Item) and ensure proper spacing between items 349 | optimized = optimized.replace( 350 | /^(\d+\.)\s+(.*?)$/gm, 351 | (_match, number, content) => { 352 | // Keep the list item and ensure it ends with double line breaks if it doesn't already 353 | return `${number} ${content.trim()}\n\n`; 354 | }, 355 | ); 356 | 357 | // Fix bullet lists with proper spacing 358 | optimized = optimized.replace( 359 | /^(\s*)[-*]\s+(.*?)$/gm, 360 | (_match, indent, content) => { 361 | // Ensure proper indentation and spacing for bullet lists 362 | return `${indent}- ${content.trim()}\n\n`; 363 | }, 364 | ); 365 | 366 | // Ensure nested lists have proper indentation 367 | // Matches lines that are part of nested lists and ensures proper indentation 368 | // REMOVED: This step added excessive leading spaces causing Bitbucket to treat lists as code blocks 369 | // optimized = optimized.replace( 370 | // /^(\s+)[-*]\s+(.*?)$/gm, 371 | // (_match, indent, content) => { 372 | // // For nested items, ensure proper indentation (4 spaces per level) 373 | // const indentLevel = Math.ceil(indent.length / 2); 374 | // const properIndent = ' '.repeat(indentLevel); 375 | // return `${properIndent}- ${content.trim()}\n\n`; 376 | // }, 377 | // ); 378 | 379 | // Fix inline code formatting - ensure it has spaces around it for rendering 380 | optimized = optimized.replace(/`([^`]+)`/g, (_match, code) => { 381 | // Ensure inline code is properly formatted with spaces before and after 382 | // but avoid adding spaces within diff lines (+ or - prefixed) 383 | const trimmedCode = code.trim(); 384 | const firstChar = trimmedCode.charAt(0); 385 | 386 | // Don't add spaces if it's part of a diff line 387 | if (firstChar === '+' || firstChar === '-') { 388 | return `\`${trimmedCode}\``; 389 | } 390 | 391 | return ` \`${trimmedCode}\` `; 392 | }); 393 | 394 | // Ensure diff lines are properly preserved 395 | // This helps with preserving + and - prefixes in diff code blocks 396 | optimized = optimized.replace( 397 | /^([+-])(.*?)$/gm, 398 | (_match, prefix, content) => { 399 | return `${prefix}${content}`; 400 | }, 401 | ); 402 | 403 | // Remove excessive line breaks (more than 2 consecutive) 404 | optimized = optimized.replace(/\n{3,}/g, '\n\n'); 405 | 406 | // Restore code blocks 407 | codeBlocks.forEach((codeBlock, index) => { 408 | optimized = optimized.replace(`__CODE_BLOCK_${index}__`, codeBlock); 409 | }); 410 | 411 | // Fix double formatting issues (heading + bold) which Bitbucket renders incorrectly 412 | // Remove bold formatting from headings as headings are already emphasized 413 | optimized = optimized.replace( 414 | /^(#{1,6})\s+\*\*(.*?)\*\*\s*$/gm, 415 | (_match, hashes, content) => { 416 | return `\n${hashes} ${content.trim()}\n\n`; 417 | }, 418 | ); 419 | 420 | // Fix bold text within headings (alternative pattern) 421 | optimized = optimized.replace( 422 | /^(#{1,6})\s+(.*?)\*\*(.*?)\*\*(.*?)$/gm, 423 | (_match, hashes, before, boldText, after) => { 424 | // Combine text without bold formatting since heading already provides emphasis 425 | const cleanContent = (before + boldText + after).trim(); 426 | return `\n${hashes} ${cleanContent}\n\n`; 427 | }, 428 | ); 429 | 430 | // Ensure headings have proper spacing (for headings without bold issues) 431 | optimized = optimized.replace( 432 | /^(#{1,6})\s+(.*?)$/gm, 433 | (_match, hashes, content) => { 434 | // Skip if already processed by bold removal above 435 | if (content.includes('**')) { 436 | return _match; // Leave as-is, will be handled by bold removal patterns 437 | } 438 | return `\n${hashes} ${content.trim()}\n\n`; 439 | }, 440 | ); 441 | 442 | // Ensure the content ends with a single line break 443 | optimized = optimized.trim() + '\n'; 444 | 445 | methodLogger.debug('Markdown optimization complete'); 446 | return optimized; 447 | } 448 | ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.pullrequests.formatter.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | PullRequest, 3 | PullRequestsResponse, 4 | DiffstatResponse, 5 | PullRequestComment, 6 | } from '../services/vendor.atlassian.pullrequests.types.js'; 7 | import { 8 | formatHeading, 9 | formatBulletList, 10 | formatUrl, 11 | formatSeparator, 12 | formatNumberedList, 13 | formatDiff, 14 | formatDate, 15 | optimizeBitbucketMarkdown, 16 | } from '../utils/formatter.util.js'; 17 | 18 | // Define the extended type here as well for clarity 19 | interface PullRequestCommentWithSnippet extends PullRequestComment { 20 | codeSnippet?: string; 21 | } 22 | 23 | /** 24 | * Format a list of pull requests for display 25 | * @param pullRequestsData - Raw pull requests data from the API 26 | * @returns Formatted string with pull requests information in markdown format 27 | */ 28 | export function formatPullRequestsList( 29 | pullRequestsData: PullRequestsResponse, 30 | ): string { 31 | const pullRequests = pullRequestsData.values || []; 32 | 33 | if (pullRequests.length === 0) { 34 | return 'No pull requests found matching your criteria.'; 35 | } 36 | 37 | const lines: string[] = [formatHeading('Bitbucket Pull Requests', 1), '']; 38 | 39 | // Format each pull request with its details 40 | const formattedList = formatNumberedList(pullRequests, (pr, _index) => { 41 | const itemLines: string[] = []; 42 | itemLines.push(formatHeading(`#${pr.id}: ${pr.title}`, 2)); 43 | 44 | // Prepare the description (truncated if too long) 45 | let description = 'No description provided'; 46 | if (pr.summary?.raw && pr.summary.raw.trim() !== '') { 47 | description = pr.summary.raw; 48 | } else if ( 49 | pr.summary?.markup && 50 | pr.summary.markup.trim() !== '' && 51 | pr.summary.markup !== 'markdown' 52 | ) { 53 | description = pr.summary.markup; 54 | } 55 | 56 | if (description.length > 150) { 57 | description = description.substring(0, 150) + '...'; 58 | } 59 | 60 | // Basic information 61 | const properties: Record<string, unknown> = { 62 | ID: pr.id, 63 | State: pr.state, 64 | Author: pr.author?.display_name || pr.author?.nickname || 'Unknown', 65 | Created: formatDate(pr.created_on), 66 | Updated: formatDate(pr.updated_on), 67 | 'Source Branch': pr.source?.branch?.name || 'Unknown', 68 | 'Destination Branch': pr.destination?.branch?.name || 'Unknown', 69 | Description: description, 70 | URL: pr.links?.html?.href 71 | ? formatUrl(pr.links.html.href, `PR #${pr.id}`) 72 | : 'N/A', 73 | }; 74 | 75 | // Format as a bullet list 76 | itemLines.push(formatBulletList(properties, (key) => key)); 77 | 78 | return itemLines.join('\n'); 79 | }); 80 | 81 | lines.push(formattedList); 82 | 83 | // Add standard footer with timestamp 84 | lines.push('\n\n' + formatSeparator()); 85 | lines.push(`*Information retrieved at: ${formatDate(new Date())}*`); 86 | 87 | return lines.join('\n'); 88 | } 89 | 90 | /** 91 | * Format detailed pull request information for display 92 | * @param pullRequest - Raw pull request data from the API 93 | * @param diffstat - Optional diffstat data from the API 94 | * @param rawDiff - Optional raw diff content from the API 95 | * @param comments - Optional comments data from the API 96 | * @returns Formatted string with pull request details in markdown format 97 | */ 98 | export function formatPullRequestDetails( 99 | pullRequest: PullRequest, 100 | diffstat?: DiffstatResponse | null, 101 | rawDiff?: string | null, 102 | comments?: PullRequestCommentWithSnippet[] | null, 103 | ): string { 104 | const lines: string[] = [ 105 | formatHeading( 106 | `Pull Request #${pullRequest.id}: ${pullRequest.title}`, 107 | 1, 108 | ), 109 | '', 110 | formatHeading('Basic Information', 2), 111 | ]; 112 | 113 | // Format basic information as a bullet list 114 | const basicProperties: Record<string, unknown> = { 115 | State: pullRequest.state, 116 | Repository: pullRequest.destination.repository.full_name, 117 | Source: pullRequest.source.branch.name, 118 | Destination: pullRequest.destination.branch.name, 119 | Author: pullRequest.author?.display_name, 120 | Created: formatDate(pullRequest.created_on), 121 | Updated: formatDate(pullRequest.updated_on), 122 | 'Comment Count': pullRequest.comment_count ?? 0, 123 | 'Task Count': pullRequest.task_count ?? 0, 124 | }; 125 | 126 | lines.push(formatBulletList(basicProperties, (key) => key)); 127 | 128 | // Reviewers 129 | if (pullRequest.reviewers && pullRequest.reviewers.length > 0) { 130 | lines.push(''); 131 | lines.push(formatHeading('Reviewers', 2)); 132 | const reviewerLines: string[] = []; 133 | pullRequest.reviewers.forEach((reviewer) => { 134 | reviewerLines.push(`- ${reviewer.display_name}`); 135 | }); 136 | lines.push(reviewerLines.join('\n')); 137 | } 138 | 139 | // Summary or rendered content for description if available 140 | if (pullRequest.summary?.raw) { 141 | lines.push(''); 142 | lines.push(formatHeading('Description', 2)); 143 | // Optimize the markdown content for better rendering 144 | lines.push(optimizeBitbucketMarkdown(pullRequest.summary.raw)); 145 | } else if (pullRequest.rendered?.description?.raw) { 146 | lines.push(''); 147 | lines.push(formatHeading('Description', 2)); 148 | // Optimize the markdown content for better rendering 149 | lines.push( 150 | optimizeBitbucketMarkdown(pullRequest.rendered.description.raw), 151 | ); 152 | } 153 | 154 | // File Changes Summary from Diffstat 155 | if (diffstat && diffstat.values && diffstat.values.length > 0) { 156 | lines.push(''); 157 | lines.push(formatHeading('File Changes', 2)); 158 | 159 | // Calculate summary statistics 160 | const totalFiles = diffstat.values.length; 161 | let totalAdditions = 0; 162 | let totalDeletions = 0; 163 | 164 | diffstat.values.forEach((file) => { 165 | if (file.lines_added) totalAdditions += file.lines_added; 166 | if (file.lines_removed) totalDeletions += file.lines_removed; 167 | }); 168 | 169 | // Add summary line 170 | lines.push( 171 | `${totalFiles} file${totalFiles !== 1 ? 's' : ''} changed with ${totalAdditions} insertion${totalAdditions !== 1 ? 's' : ''} and ${totalDeletions} deletion${totalDeletions !== 1 ? 's' : ''}`, 172 | ); 173 | 174 | // Add file list (limited to 10 files for brevity) 175 | const maxFilesToShow = 10; 176 | if (totalFiles > 0) { 177 | lines.push(''); 178 | diffstat.values.slice(0, maxFilesToShow).forEach((file) => { 179 | const changes = []; 180 | if (file.lines_added) changes.push(`+${file.lines_added}`); 181 | if (file.lines_removed) changes.push(`-${file.lines_removed}`); 182 | const changeStr = 183 | changes.length > 0 ? ` (${changes.join(', ')})` : ''; 184 | lines.push( 185 | `- \`${file.old?.path || file.new?.path}\`${changeStr}`, 186 | ); 187 | }); 188 | 189 | if (totalFiles > maxFilesToShow) { 190 | lines.push( 191 | `- ... and ${totalFiles - maxFilesToShow} more files`, 192 | ); 193 | } 194 | } 195 | } 196 | 197 | // Detailed Diff Content 198 | if (rawDiff) { 199 | lines.push(''); 200 | lines.push(formatHeading('Code Changes (Full Diff)', 2)); 201 | lines.push(formatDiff(rawDiff)); 202 | } 203 | 204 | // Comments Section (when included) 205 | if (comments && comments.length > 0) { 206 | lines.push(''); 207 | lines.push(formatHeading('Comments', 2)); 208 | 209 | // Group comments by parent (to handle threads) 210 | const topLevelComments: PullRequestCommentWithSnippet[] = []; 211 | const childComments: { 212 | [parentId: number]: PullRequestCommentWithSnippet[]; 213 | } = {}; 214 | 215 | // First pass: organize comments by parent 216 | comments.forEach((comment) => { 217 | if (comment.parent) { 218 | const parentId = comment.parent.id; 219 | if (!childComments[parentId]) { 220 | childComments[parentId] = []; 221 | } 222 | childComments[parentId].push(comment); 223 | } else { 224 | topLevelComments.push(comment); 225 | } 226 | }); 227 | 228 | // Format each top-level comment and its replies (limit to 5 comments for conciseness) 229 | const maxCommentsToShow = 5; 230 | const commentsToShow = topLevelComments.slice(0, maxCommentsToShow); 231 | 232 | commentsToShow.forEach((comment, index) => { 233 | formatComment(comment, lines); 234 | 235 | // Add replies if any exist (limit to 3 replies per comment for conciseness) 236 | const replies = childComments[comment.id] || []; 237 | if (replies.length > 0) { 238 | lines.push(''); 239 | lines.push('**Replies:**'); 240 | 241 | const maxRepliesToShow = 3; 242 | const repliesToShow = replies.slice(0, maxRepliesToShow); 243 | 244 | repliesToShow.forEach((reply) => { 245 | lines.push(''); 246 | lines.push( 247 | `> **${reply.user.display_name || 'Unknown User'}** (${formatDate(reply.created_on)})`, 248 | ); 249 | // Optimize the markdown content for replies as well 250 | const optimizedReplyContent = optimizeBitbucketMarkdown( 251 | reply.content.raw, 252 | ); 253 | lines.push( 254 | `> ${optimizedReplyContent.replace(/\n/g, '\n> ')}`, 255 | ); 256 | }); 257 | 258 | // Show message if more replies were omitted 259 | if (replies.length > maxRepliesToShow) { 260 | lines.push(''); 261 | lines.push( 262 | `> *...and ${replies.length - maxRepliesToShow} more replies*`, 263 | ); 264 | } 265 | } 266 | 267 | if (index < commentsToShow.length - 1) { 268 | lines.push(''); 269 | lines.push(formatSeparator()); 270 | } 271 | }); 272 | 273 | // Show message if more comments were omitted 274 | if (topLevelComments.length > maxCommentsToShow) { 275 | lines.push(''); 276 | lines.push( 277 | `*...and ${topLevelComments.length - maxCommentsToShow} more comments*`, 278 | ); 279 | } 280 | 281 | // Add link to view all comments if available 282 | if (pullRequest.links?.comments?.href) { 283 | lines.push(''); 284 | lines.push( 285 | `[View all comments in browser](${pullRequest.links.comments.href})`, 286 | ); 287 | } 288 | } else if (comments && comments.length === 0) { 289 | lines.push(''); 290 | lines.push(formatHeading('Comments', 2)); 291 | lines.push('*No comments found on this pull request.*'); 292 | } 293 | 294 | // Links 295 | lines.push(''); 296 | lines.push(formatHeading('Links', 2)); 297 | 298 | const links: string[] = []; 299 | 300 | if (pullRequest.links.html?.href) { 301 | links.push( 302 | `- ${formatUrl(pullRequest.links.html.href, 'View in Browser')}`, 303 | ); 304 | } 305 | if (pullRequest.links.commits?.href) { 306 | links.push(`- ${formatUrl(pullRequest.links.commits.href, 'Commits')}`); 307 | } 308 | if (pullRequest.links.comments?.href) { 309 | links.push( 310 | `- ${formatUrl(pullRequest.links.comments.href, 'Comments')}`, 311 | ); 312 | } 313 | if (pullRequest.links.diff?.href) { 314 | links.push(`- ${formatUrl(pullRequest.links.diff.href, 'Diff')}`); 315 | } 316 | 317 | lines.push(links.join('\n')); 318 | 319 | // Add standard footer with timestamp 320 | lines.push('\n\n' + formatSeparator()); 321 | lines.push(`*Information retrieved at: ${formatDate(new Date())}*`); 322 | 323 | return lines.join('\n'); 324 | } 325 | 326 | /** 327 | * Format pull request comments for display, including code snippets for inline comments. 328 | * @param comments - Array of comment objects, potentially enhanced with code snippets. 329 | * @param prId - The ID of the pull request to include in the title. 330 | * @returns Formatted string with pull request comments in markdown format. 331 | */ 332 | export function formatPullRequestComments( 333 | comments: PullRequestCommentWithSnippet[], // Accept the array of enhanced comments directly 334 | prId: string, 335 | ): string { 336 | const lines: string[] = []; 337 | 338 | lines.push(formatHeading(`Comments on Pull Request #${prId}`, 1)); 339 | lines.push(''); 340 | 341 | if (!comments || comments.length === 0) { 342 | lines.push('*No comments found on this pull request.*'); 343 | lines.push('\n\n' + formatSeparator()); 344 | lines.push(`*Information retrieved at: ${formatDate(new Date())}*`); 345 | return lines.join('\n'); 346 | } 347 | 348 | // Group comments by parent (to handle threads) 349 | const topLevelComments: PullRequestCommentWithSnippet[] = []; 350 | const childComments: { 351 | [parentId: number]: PullRequestCommentWithSnippet[]; // Use enhanced type here too 352 | } = {}; 353 | 354 | // First pass: organize comments by parent 355 | comments.forEach((comment) => { 356 | if (comment.parent) { 357 | const parentId = comment.parent.id; 358 | if (!childComments[parentId]) { 359 | childComments[parentId] = []; 360 | } 361 | childComments[parentId].push(comment); 362 | } else { 363 | topLevelComments.push(comment); 364 | } 365 | }); 366 | 367 | // Format each top-level comment and its replies 368 | topLevelComments.forEach((comment, index) => { 369 | formatComment(comment, lines); // Pass the enhanced comment object 370 | 371 | // Add replies if any exist 372 | const replies = childComments[comment.id] || []; 373 | if (replies.length > 0) { 374 | lines.push(''); 375 | lines.push('**Replies:**'); 376 | 377 | replies.forEach((reply) => { 378 | lines.push(''); 379 | lines.push( 380 | `> **${reply.user.display_name || 'Unknown User'}** (${formatDate(reply.created_on)})`, 381 | ); 382 | // Optimize the markdown content for replies as well 383 | const optimizedReplyContent = optimizeBitbucketMarkdown( 384 | reply.content.raw, 385 | ); 386 | lines.push(`> ${optimizedReplyContent.replace(/\n/g, '\n> ')}`); 387 | }); 388 | } 389 | 390 | if (index < topLevelComments.length - 1) { 391 | lines.push(''); 392 | lines.push(formatSeparator()); 393 | } 394 | }); 395 | 396 | lines.push('\n\n' + formatSeparator()); 397 | lines.push(`*Information retrieved at: ${formatDate(new Date())}*`); 398 | 399 | return lines.join('\n'); 400 | } 401 | 402 | /** 403 | * Helper function to format a single comment, including code snippet if available. 404 | * @param comment - The comment object (potentially with codeSnippet). 405 | * @param lines - Array of string lines to append to. 406 | */ 407 | function formatComment( 408 | comment: PullRequestCommentWithSnippet, // Use the enhanced type 409 | lines: string[], 410 | ): void { 411 | const author = comment.user.display_name || 'Unknown User'; 412 | const headerText = comment.deleted 413 | ? `[DELETED] Comment by ${author}` 414 | : `Comment by ${author}`; 415 | 416 | lines.push(formatHeading(headerText, 3)); 417 | lines.push(`*Posted on ${formatDate(comment.created_on)}*`); 418 | 419 | if (comment.updated_on && comment.updated_on !== comment.created_on) { 420 | lines.push(`*Updated on ${formatDate(comment.updated_on)}*`); 421 | } 422 | 423 | // If it's an inline comment, show file, line info, and snippet 424 | if (comment.inline) { 425 | const fileInfo = `File: \`${comment.inline.path}\``; 426 | let lineInfo = ''; 427 | 428 | if ( 429 | comment.inline.from !== undefined && 430 | comment.inline.to !== undefined 431 | ) { 432 | lineInfo = `(changed line ${comment.inline.from} -> ${comment.inline.to})`; // Slightly clearer wording 433 | } else if (comment.inline.to !== undefined) { 434 | lineInfo = `(line ${comment.inline.to})`; 435 | } 436 | 437 | lines.push(`**Inline Comment: ${fileInfo}** ${lineInfo}`); 438 | 439 | // Add the code snippet if it exists 440 | if (comment.codeSnippet) { 441 | lines.push(''); 442 | lines.push('```diff'); // Use diff language for syntax highlighting 443 | lines.push(comment.codeSnippet); 444 | lines.push('```'); 445 | } else if (comment.links?.code?.href) { 446 | // Fallback link if snippet fetch failed or wasn't applicable 447 | lines.push( 448 | `[View code context in browser](${comment.links.code.href})`, 449 | ); 450 | } 451 | } 452 | 453 | lines.push(''); 454 | // Show specific message for deleted comments, otherwise show optimized raw content 455 | lines.push( 456 | comment.deleted 457 | ? '*This comment has been deleted.*' 458 | : optimizeBitbucketMarkdown(comment.content.raw) || 459 | '*No content provided.*', 460 | ); 461 | 462 | // Add link to view the comment itself in browser if available 463 | if (comment.links?.html?.href) { 464 | lines.push(''); 465 | lines.push( 466 | `[View full comment thread in browser](${comment.links.html.href})`, 467 | ); // Clarify link purpose 468 | } 469 | } 470 | ``` -------------------------------------------------------------------------------- /src/cli/atlassian.repositories.cli.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Command } from 'commander'; 2 | import { Logger } from '../utils/logger.util.js'; 3 | import { handleCliError } from '../utils/error.util.js'; 4 | // Import directly from specialized controllers 5 | import { handleRepositoriesList } from '../controllers/atlassian.repositories.list.controller.js'; 6 | import { handleRepositoryDetails } from '../controllers/atlassian.repositories.details.controller.js'; 7 | import { handleCommitHistory } from '../controllers/atlassian.repositories.commit.controller.js'; 8 | import { 9 | handleCreateBranch, 10 | handleListBranches, 11 | } from '../controllers/atlassian.repositories.branch.controller.js'; 12 | import { 13 | handleCloneRepository, 14 | handleGetFileContent, 15 | } from '../controllers/atlassian.repositories.content.controller.js'; 16 | 17 | /** 18 | * CLI module for managing Bitbucket repositories. 19 | * Provides commands for listing repositories and retrieving repository details. 20 | * All commands require valid Atlassian credentials. 21 | */ 22 | 23 | // Create a contextualized logger for this file 24 | const cliLogger = Logger.forContext('cli/atlassian.repositories.cli.ts'); 25 | 26 | // Log CLI initialization 27 | cliLogger.debug('Bitbucket repositories CLI module initialized'); 28 | 29 | /** 30 | * Register Bitbucket repositories CLI commands with the Commander program 31 | * 32 | * @param program - The Commander program instance to register commands with 33 | * @throws Error if command registration fails 34 | */ 35 | function register(program: Command): void { 36 | const methodLogger = Logger.forContext( 37 | 'cli/atlassian.repositories.cli.ts', 38 | 'register', 39 | ); 40 | methodLogger.debug('Registering Bitbucket Repositories CLI commands...'); 41 | 42 | registerListRepositoriesCommand(program); 43 | registerGetRepositoryCommand(program); 44 | registerGetCommitHistoryCommand(program); 45 | registerAddBranchCommand(program); 46 | registerCloneRepositoryCommand(program); 47 | registerGetFileCommand(program); 48 | registerListBranchesCommand(program); 49 | 50 | methodLogger.debug('CLI commands registered successfully'); 51 | } 52 | 53 | /** 54 | * Register the command for listing Bitbucket repositories in a workspace 55 | * 56 | * @param program - The Commander program instance 57 | */ 58 | function registerListRepositoriesCommand(program: Command): void { 59 | program 60 | .command('ls-repos') 61 | .description( 62 | 'List repositories in a Bitbucket workspace, with filtering and pagination.', 63 | ) 64 | .option( 65 | '-w, --workspace-slug <slug>', 66 | 'Workspace slug containing the repositories. If not provided, uses your default workspace (configured via BITBUCKET_DEFAULT_WORKSPACE or first workspace in your account). Example: "myteam"', 67 | ) 68 | .option( 69 | '-q, --query <string>', 70 | 'Filter repositories by this query string. Searches repository name and description.', 71 | ) 72 | .option( 73 | '-p, --project-key <key>', 74 | 'Filter repositories belonging to the specified project key. Example: "PROJ"', 75 | ) 76 | .option( 77 | '-r, --role <string>', 78 | 'Filter repositories where the authenticated user has the specified role or higher. Valid roles: `owner`, `admin`, `contributor`, `member`. Note: `member` typically includes all accessible repositories.', 79 | ) 80 | .option( 81 | '-s, --sort <string>', 82 | 'Sort repositories by this field. Examples: "name", "-updated_on" (default), "size".', 83 | ) 84 | .option( 85 | '-l, --limit <number>', 86 | 'Maximum number of items to return (1-100). Defaults to 25 if omitted.', 87 | ) 88 | .option( 89 | '-c, --cursor <string>', 90 | 'Pagination cursor for retrieving the next set of results.', 91 | ) 92 | .action(async (options) => { 93 | const actionLogger = cliLogger.forMethod('ls-repos'); 94 | try { 95 | actionLogger.debug('CLI ls-repos called', options); 96 | 97 | // Map CLI options to controller options - keep only type conversions 98 | const controllerOptions = { 99 | workspaceSlug: options.workspaceSlug, 100 | query: options.query, 101 | projectKey: options.projectKey, 102 | role: options.role, 103 | sort: options.sort, 104 | limit: options.limit 105 | ? parseInt(options.limit, 10) 106 | : undefined, 107 | cursor: options.cursor, 108 | }; 109 | 110 | // Call controller directly 111 | const result = await handleRepositoriesList(controllerOptions); 112 | 113 | // Output result content 114 | console.log(result.content); 115 | } catch (error) { 116 | handleCliError(error); 117 | } 118 | }); 119 | } 120 | 121 | /** 122 | * Register the command for retrieving a specific Bitbucket repository 123 | * @param program - The Commander program instance 124 | */ 125 | function registerGetRepositoryCommand(program: Command): void { 126 | program 127 | .command('get-repo') 128 | .description( 129 | 'Get detailed information about a specific Bitbucket repository.', 130 | ) 131 | .option( 132 | '-w, --workspace-slug <slug>', 133 | 'Workspace slug containing the repository. If not provided, uses your default workspace (either configured via BITBUCKET_DEFAULT_WORKSPACE or first workspace in your account). Example: "myteam"', 134 | ) 135 | .requiredOption( 136 | '-r, --repo-slug <slug>', 137 | 'Repository slug to retrieve. Must be a valid repository in the workspace. Example: "project-api"', 138 | ) 139 | .action(async (options) => { 140 | const actionLogger = Logger.forContext( 141 | 'cli/atlassian.repositories.cli.ts', 142 | 'get-repo', 143 | ); 144 | try { 145 | actionLogger.debug( 146 | `Fetching repository: ${options.workspaceSlug}/${options.repoSlug}`, 147 | ); 148 | 149 | const result = await handleRepositoryDetails({ 150 | workspaceSlug: options.workspaceSlug, 151 | repoSlug: options.repoSlug, 152 | }); 153 | 154 | console.log(result.content); 155 | } catch (error) { 156 | actionLogger.error('Operation failed:', error); 157 | handleCliError(error); 158 | } 159 | }); 160 | } 161 | 162 | /** 163 | * Register the command for retrieving commit history from a repository 164 | * @param program - The Commander program instance 165 | */ 166 | function registerGetCommitHistoryCommand(program: Command): void { 167 | program 168 | .command('get-commit-history') 169 | .description('Get commit history for a Bitbucket repository.') 170 | .option( 171 | '-w, --workspace-slug <slug>', 172 | 'Workspace slug containing the repository. If not provided, uses your default workspace. Example: "myteam"', 173 | ) 174 | .requiredOption( 175 | '-r, --repo-slug <slug>', 176 | 'Repository slug to get commit history from. Example: "project-api"', 177 | ) 178 | .option( 179 | '-v, --revision <branch-or-tag>', 180 | 'Filter commits by a specific branch, tag, or commit hash.', 181 | ) 182 | .option( 183 | '--path <file-path>', 184 | 'Filter commits to those that affect this specific file path.', 185 | ) 186 | .option( 187 | '-l, --limit <number>', 188 | 'Maximum number of commits to return (1-100). Defaults to 25 if omitted.', 189 | ) 190 | .option( 191 | '-c, --cursor <string>', 192 | 'Pagination cursor for retrieving the next set of results.', 193 | ) 194 | .action(async (options) => { 195 | const actionLogger = Logger.forContext( 196 | 'cli/atlassian.repositories.cli.ts', 197 | 'get-commit-history', 198 | ); 199 | try { 200 | actionLogger.debug('Processing command options:', options); 201 | 202 | // Map CLI options to controller params - keep only type conversions 203 | const requestOptions = { 204 | workspaceSlug: options.workspaceSlug, 205 | repoSlug: options.repoSlug, 206 | revision: options.revision, 207 | path: options.path, 208 | limit: options.limit 209 | ? parseInt(options.limit, 10) 210 | : undefined, 211 | cursor: options.cursor, 212 | }; 213 | 214 | actionLogger.debug( 215 | 'Fetching commit history with options:', 216 | requestOptions, 217 | ); 218 | const result = await handleCommitHistory(requestOptions); 219 | actionLogger.debug('Successfully retrieved commit history'); 220 | 221 | console.log(result.content); 222 | } catch (error) { 223 | actionLogger.error('Operation failed:', error); 224 | handleCliError(error); 225 | } 226 | }); 227 | } 228 | 229 | /** 230 | * Register the command for adding a branch to a repository 231 | * @param program - The Commander program instance 232 | */ 233 | function registerAddBranchCommand(program: Command): void { 234 | program 235 | .command('add-branch') 236 | .description('Add a new branch in a Bitbucket repository.') 237 | .requiredOption( 238 | '-w, --workspace-slug <slug>', 239 | 'Workspace slug containing the repository.', 240 | ) 241 | .requiredOption( 242 | '-r, --repo-slug <slug>', 243 | 'Repository slug where the branch will be created.', 244 | ) 245 | .requiredOption( 246 | '-n, --new-branch-name <n>', 247 | 'The name for the new branch.', 248 | ) 249 | .requiredOption( 250 | '-s, --source-branch-or-commit <target>', 251 | 'The name of the existing branch or a full commit hash to branch from.', 252 | ) 253 | .action(async (options) => { 254 | const actionLogger = Logger.forContext( 255 | 'cli/atlassian.repositories.cli.ts', 256 | 'add-branch', 257 | ); 258 | try { 259 | actionLogger.debug('Processing command options:', options); 260 | 261 | // Map CLI options to controller params 262 | const requestOptions = { 263 | workspaceSlug: options.workspaceSlug, 264 | repoSlug: options.repoSlug, 265 | newBranchName: options.newBranchName, 266 | sourceBranchOrCommit: options.sourceBranchOrCommit, 267 | }; 268 | 269 | actionLogger.debug( 270 | 'Creating branch with options:', 271 | requestOptions, 272 | ); 273 | const result = await handleCreateBranch(requestOptions); 274 | actionLogger.debug('Successfully created branch'); 275 | 276 | console.log(result.content); 277 | } catch (error) { 278 | actionLogger.error('Operation failed:', error); 279 | handleCliError(error); 280 | } 281 | }); 282 | } 283 | 284 | /** 285 | * Register the command for cloning a Bitbucket repository. 286 | * 287 | * @param program - The Commander program instance 288 | */ 289 | function registerCloneRepositoryCommand(program: Command): void { 290 | program 291 | .command('clone') 292 | .description( 293 | 'Clone a Bitbucket repository to your local filesystem using SSH (preferred) or HTTPS. ' + 294 | 'The repository will be cloned into a subdirectory at targetPath/repoSlug. ' + 295 | 'Requires Bitbucket credentials and proper SSH key setup for optimal usage.', 296 | ) 297 | .requiredOption( 298 | '-w, --workspace-slug <slug>', 299 | 'Workspace slug containing the repository. Example: "myteam"', 300 | ) 301 | .requiredOption( 302 | '-r, --repo-slug <slug>', 303 | 'Repository slug to clone. Example: "project-api"', 304 | ) 305 | .requiredOption( 306 | '-t, --target-path <path>', 307 | 'Directory path where the repository will be cloned. Absolute paths are strongly recommended. Example: "/home/user/projects"', 308 | ) 309 | .action(async (options) => { 310 | const actionLogger = Logger.forContext( 311 | 'cli/atlassian.repositories.cli.ts', 312 | 'clone', 313 | ); 314 | try { 315 | actionLogger.debug( 316 | 'Processing clone command options:', 317 | options, 318 | ); 319 | 320 | // Map CLI options to controller params (already correct case) 321 | const controllerOptions = { 322 | workspaceSlug: options.workspaceSlug, 323 | repoSlug: options.repoSlug, 324 | targetPath: options.targetPath, 325 | }; 326 | 327 | actionLogger.debug( 328 | 'Initiating repository clone with options:', 329 | controllerOptions, 330 | ); 331 | const result = await handleCloneRepository(controllerOptions); 332 | actionLogger.info('Clone operation initiated successfully.'); 333 | 334 | console.log(result.content); 335 | } catch (error) { 336 | actionLogger.error('Clone operation failed:', error); 337 | handleCliError(error); 338 | } 339 | }); 340 | } 341 | 342 | /** 343 | * Register the command for getting a file from a Bitbucket repository 344 | * 345 | * @param program - The Commander program instance 346 | */ 347 | function registerGetFileCommand(program: Command): void { 348 | program 349 | .command('get-file') 350 | .description('Get the content of a file from a Bitbucket repository.') 351 | .requiredOption( 352 | '-w, --workspace-slug <slug>', 353 | 'Workspace slug containing the repository. Must be a valid workspace slug from your Bitbucket account. Example: "myteam"', 354 | ) 355 | .requiredOption( 356 | '-r, --repo-slug <slug>', 357 | 'Repository slug to get the file from. Must be a valid repository slug in the specified workspace. Example: "project-api"', 358 | ) 359 | .requiredOption( 360 | '-f, --file-path <path>', 361 | 'Path to the file in the repository. Example: "README.md" or "src/main.js"', 362 | ) 363 | .option( 364 | '-v, --revision <branch-tag-or-commit>', 365 | 'Branch name, tag, or commit hash to retrieve the file from. If omitted, the default branch is used.', 366 | ) 367 | .action(async (options) => { 368 | const actionLogger = Logger.forContext( 369 | 'cli/atlassian.repositories.cli.ts', 370 | 'get-file', 371 | ); 372 | try { 373 | actionLogger.debug( 374 | `Fetching file: ${options.workspaceSlug}/${options.repoSlug}/${options.filePath}`, 375 | options.revision ? { revision: options.revision } : {}, 376 | ); 377 | 378 | const result = await handleGetFileContent({ 379 | workspaceSlug: options.workspaceSlug, 380 | repoSlug: options.repoSlug, 381 | path: options.filePath, 382 | ref: options.revision, 383 | }); 384 | 385 | console.log(result.content); 386 | } catch (error) { 387 | actionLogger.error('Operation failed:', error); 388 | handleCliError(error); 389 | } 390 | }); 391 | } 392 | 393 | /** 394 | * Register the command for listing branches in a repository 395 | * @param program - The Commander program instance 396 | */ 397 | function registerListBranchesCommand(program: Command): void { 398 | program 399 | .command('list-branches') 400 | .description('List branches in a Bitbucket repository.') 401 | .option( 402 | '-w, --workspace-slug <slug>', 403 | 'Workspace slug containing the repository. If not provided, uses your default workspace. Example: "myteam"', 404 | ) 405 | .requiredOption( 406 | '-r, --repo-slug <slug>', 407 | 'Repository slug to list branches from. Example: "project-api"', 408 | ) 409 | .option( 410 | '-q, --query <string>', 411 | 'Filter branches by name or other properties (text search).', 412 | ) 413 | .option( 414 | '-s, --sort <string>', 415 | 'Sort branches by this field. Examples: "name" (default), "-name", "target.date".', 416 | ) 417 | .option( 418 | '-l, --limit <number>', 419 | 'Maximum number of branches to return (1-100). Defaults to 25 if omitted.', 420 | ) 421 | .option( 422 | '-c, --cursor <string>', 423 | 'Pagination cursor for retrieving the next set of results.', 424 | ) 425 | .action(async (options) => { 426 | const actionLogger = Logger.forContext( 427 | 'cli/atlassian.repositories.cli.ts', 428 | 'list-branches', 429 | ); 430 | try { 431 | actionLogger.debug('Processing command options:', options); 432 | 433 | // Map CLI options to controller params - keep only type conversions 434 | const params = { 435 | workspaceSlug: options.workspaceSlug, 436 | repoSlug: options.repoSlug, 437 | query: options.query, 438 | sort: options.sort, 439 | limit: options.limit 440 | ? parseInt(options.limit, 10) 441 | : undefined, 442 | cursor: options.cursor, 443 | }; 444 | 445 | actionLogger.debug( 446 | 'Fetching branches with parameters:', 447 | params, 448 | ); 449 | const result = await handleListBranches(params); 450 | actionLogger.debug('Successfully retrieved branches'); 451 | 452 | console.log(result.content); 453 | } catch (error) { 454 | actionLogger.error('Operation failed:', error); 455 | handleCliError(error); 456 | } 457 | }); 458 | } 459 | 460 | export default { register }; 461 | ``` -------------------------------------------------------------------------------- /src/controllers/atlassian.repositories.controller.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { handleRepositoriesList } from './atlassian.repositories.list.controller.js'; 2 | import { handleRepositoryDetails } from './atlassian.repositories.details.controller.js'; 3 | import { getAtlassianCredentials } from '../utils/transport.util.js'; 4 | import { config } from '../utils/config.util.js'; 5 | import { McpError } from '../utils/error.util.js'; 6 | import atlassianWorkspacesController from './atlassian.workspaces.controller.js'; 7 | 8 | describe('Atlassian Repositories Controller', () => { 9 | // Load configuration and check for credentials before all tests 10 | beforeAll(() => { 11 | config.load(); // Ensure config is loaded 12 | const credentials = getAtlassianCredentials(); 13 | if (!credentials) { 14 | console.warn( 15 | 'Skipping Atlassian Repositories Controller tests: No credentials available', 16 | ); 17 | } 18 | }); 19 | 20 | // Helper function to skip tests when credentials are missing 21 | const skipIfNoCredentials = () => !getAtlassianCredentials(); 22 | 23 | describe('list', () => { 24 | // Helper to get a valid workspace slug for testing 25 | async function getFirstWorkspaceSlugForController(): Promise< 26 | string | null 27 | > { 28 | if (skipIfNoCredentials()) return null; 29 | 30 | try { 31 | const listResult = await atlassianWorkspacesController.list({ 32 | limit: 1, 33 | }); 34 | 35 | if (listResult.content === 'No Bitbucket workspaces found.') 36 | return null; 37 | 38 | // Extract slug from Markdown content 39 | const slugMatch = listResult.content.match( 40 | /\*\*Slug\*\*:\s+([^\s\n]+)/, 41 | ); 42 | return slugMatch ? slugMatch[1] : null; 43 | } catch (error) { 44 | console.warn( 45 | "Could not fetch workspace list for controller 'list' test setup:", 46 | error, 47 | ); 48 | return null; 49 | } 50 | } 51 | 52 | it('should return a formatted list of repositories in Markdown', async () => { 53 | if (skipIfNoCredentials()) return; 54 | 55 | const workspaceSlug = await getFirstWorkspaceSlugForController(); 56 | if (!workspaceSlug) { 57 | console.warn('Skipping test: No workspace slug found.'); 58 | return; 59 | } 60 | 61 | const result = await handleRepositoriesList({ 62 | workspaceSlug, 63 | }); 64 | 65 | // Verify the response structure 66 | expect(result).toHaveProperty('content'); 67 | expect(typeof result.content).toBe('string'); 68 | 69 | // Basic Markdown content checks 70 | if (result.content !== 'No repositories found in this workspace.') { 71 | expect(result.content).toMatch(/^# Bitbucket Repositories/m); 72 | expect(result.content).toContain('**Name**'); 73 | expect(result.content).toContain('**Full Name**'); 74 | expect(result.content).toContain('**Updated**'); 75 | } 76 | 77 | // Check for pagination information in the content string 78 | expect(result.content).toMatch( 79 | /---[\s\S]*\*Showing \d+ (of \d+ total items|\S+ items?)[\s\S]*\*/, 80 | ); 81 | }, 30000); 82 | 83 | it('should handle pagination options (limit/cursor)', async () => { 84 | if (skipIfNoCredentials()) return; 85 | 86 | const workspaceSlug = await getFirstWorkspaceSlugForController(); 87 | if (!workspaceSlug) { 88 | console.warn('Skipping test: No workspace slug found.'); 89 | return; 90 | } 91 | 92 | // Fetch first page with limit 1 93 | const result1 = await handleRepositoriesList({ 94 | workspaceSlug, 95 | limit: 1, 96 | }); 97 | 98 | // Extract pagination info from content instead of accessing pagination object 99 | const countMatch = result1.content.match( 100 | /\*Showing (\d+) items?\.\*/, 101 | ); 102 | const count = countMatch ? parseInt(countMatch[1], 10) : 0; 103 | expect(count).toBeLessThanOrEqual(1); 104 | 105 | // Extract cursor from content 106 | const cursorMatch = result1.content.match( 107 | /\*Next cursor: `([^`]+)`\*/, 108 | ); 109 | const nextCursor = cursorMatch ? cursorMatch[1] : null; 110 | 111 | // Check if pagination indicates more results 112 | const hasMoreResults = result1.content.includes( 113 | 'More results are available.', 114 | ); 115 | 116 | // If there's a next page, fetch it 117 | if (hasMoreResults && nextCursor) { 118 | const result2 = await handleRepositoriesList({ 119 | workspaceSlug, 120 | limit: 1, 121 | cursor: nextCursor, 122 | }); 123 | expect(result2.content).toMatch( 124 | /---[\s\S]*\*Showing \d+ (of \d+ total items|\S+ items?)[\s\S]*\*/, 125 | ); 126 | 127 | // Ensure content is different (or handle case where only 1 repo exists) 128 | if ( 129 | result1.content !== 130 | 'No repositories found in this workspace.' && 131 | result2.content !== 132 | 'No repositories found in this workspace.' && 133 | count > 0 && 134 | count > 0 135 | ) { 136 | // Only compare if we actually have multiple repositories 137 | expect(result1.content).not.toEqual(result2.content); 138 | } 139 | } else { 140 | console.warn( 141 | 'Skipping cursor part of pagination test: Only one page of repositories found.', 142 | ); 143 | } 144 | }, 30000); 145 | 146 | it('should handle filtering options (query)', async () => { 147 | if (skipIfNoCredentials()) return; 148 | 149 | const workspaceSlug = await getFirstWorkspaceSlugForController(); 150 | if (!workspaceSlug) { 151 | console.warn('Skipping test: No workspace slug found.'); 152 | return; 153 | } 154 | 155 | // First get all repositories to find a valid query term 156 | const allResult = await handleRepositoriesList({ 157 | workspaceSlug, 158 | }); 159 | 160 | if ( 161 | allResult.content === 'No repositories found in this workspace.' 162 | ) { 163 | console.warn('Skipping filtering test: No repositories found.'); 164 | return; 165 | } 166 | 167 | // Extract a repository name from the first result to use as a query 168 | const repoNameMatch = allResult.content.match( 169 | /\*\*Name\*\*:\s+([^\n]+)/, 170 | ); 171 | if (!repoNameMatch || !repoNameMatch[1]) { 172 | console.warn( 173 | 'Skipping filtering test: Could not extract repository name.', 174 | ); 175 | return; 176 | } 177 | 178 | // Use part of the repo name as a query term 179 | const queryTerm = repoNameMatch[1].trim().split(' ')[0]; 180 | 181 | // Query with the extracted term 182 | const filteredResult = await handleRepositoriesList({ 183 | workspaceSlug, 184 | query: queryTerm, 185 | }); 186 | 187 | // The result should be a valid response 188 | expect(filteredResult).toHaveProperty('content'); 189 | expect(typeof filteredResult.content).toBe('string'); 190 | 191 | // We can't guarantee matches (query might not match anything), but response should be valid 192 | if ( 193 | filteredResult.content !== 194 | 'No repositories found in this workspace.' 195 | ) { 196 | expect(filteredResult.content).toMatch( 197 | /^# Bitbucket Repositories/m, 198 | ); 199 | } 200 | }, 30000); 201 | 202 | it('should handle sorting options', async () => { 203 | if (skipIfNoCredentials()) return; 204 | 205 | const workspaceSlug = await getFirstWorkspaceSlugForController(); 206 | if (!workspaceSlug) { 207 | console.warn('Skipping test: No workspace slug found.'); 208 | return; 209 | } 210 | 211 | // Request with explicit sort by name 212 | const sortedResult = await handleRepositoriesList({ 213 | workspaceSlug, 214 | sort: 'name', 215 | }); 216 | 217 | // The result should be a valid response 218 | expect(sortedResult).toHaveProperty('content'); 219 | expect(typeof sortedResult.content).toBe('string'); 220 | 221 | // We can't verify the exact sort order in the Markdown output easily, 222 | // but we can verify the response is valid 223 | if ( 224 | sortedResult.content !== 225 | 'No repositories found in this workspace.' 226 | ) { 227 | expect(sortedResult.content).toMatch( 228 | /^# Bitbucket Repositories/m, 229 | ); 230 | } 231 | }, 30000); 232 | 233 | it('should handle role filtering if supported', async () => { 234 | if (skipIfNoCredentials()) return; 235 | 236 | const workspaceSlug = await getFirstWorkspaceSlugForController(); 237 | if (!workspaceSlug) { 238 | console.warn('Skipping test: No workspace slug found.'); 239 | return; 240 | } 241 | 242 | // Try filtering by role 243 | try { 244 | const filteredResult = await handleRepositoriesList({ 245 | workspaceSlug, 246 | role: 'owner', // Most likely role to have some results 247 | }); 248 | 249 | // The result should be a valid response 250 | expect(filteredResult).toHaveProperty('content'); 251 | expect(typeof filteredResult.content).toBe('string'); 252 | 253 | // We can't guarantee matches, but response should be valid 254 | if ( 255 | filteredResult.content !== 256 | 'No repositories found in this workspace.' 257 | ) { 258 | expect(filteredResult.content).toMatch( 259 | /^# Bitbucket Repositories/m, 260 | ); 261 | } 262 | } catch (error) { 263 | // If role filtering isn't supported, log and continue 264 | console.warn( 265 | 'Role filtering test encountered an error:', 266 | error, 267 | ); 268 | } 269 | }, 30000); 270 | 271 | it('should handle empty result scenario', async () => { 272 | if (skipIfNoCredentials()) return; 273 | 274 | const workspaceSlug = await getFirstWorkspaceSlugForController(); 275 | if (!workspaceSlug) { 276 | console.warn('Skipping test: No workspace slug found.'); 277 | return; 278 | } 279 | 280 | // Use an extremely unlikely query to get empty results 281 | const noMatchQuery = 'thisstringwillnotmatchanyrepository12345xyz'; 282 | 283 | const emptyResult = await handleRepositoriesList({ 284 | workspaceSlug, 285 | query: noMatchQuery, 286 | }); 287 | 288 | // Should return a specific "no results" message 289 | expect(emptyResult.content).toContain( 290 | 'No repositories found matching your criteria.', 291 | ); 292 | }, 30000); 293 | 294 | it('should throw an McpError for an invalid workspace slug', async () => { 295 | if (skipIfNoCredentials()) return; 296 | 297 | const invalidWorkspaceSlug = 298 | 'this-workspace-definitely-does-not-exist-12345'; 299 | 300 | // Expect the controller call to reject with an McpError 301 | await expect( 302 | handleRepositoriesList({ 303 | workspaceSlug: invalidWorkspaceSlug, 304 | }), 305 | ).rejects.toThrow(McpError); 306 | 307 | // Check the status code via the error handler's behavior 308 | try { 309 | await handleRepositoriesList({ 310 | workspaceSlug: invalidWorkspaceSlug, 311 | }); 312 | } catch (e) { 313 | expect(e).toBeInstanceOf(McpError); 314 | expect((e as McpError).statusCode).toBe(404); // Expecting Not Found 315 | expect((e as McpError).message).toContain('not found'); 316 | } 317 | }, 30000); 318 | }); 319 | 320 | describe('get', () => { 321 | // Helper to get valid repo identifiers for testing 322 | async function getRepositoryIdentifier(): Promise<{ 323 | workspaceSlug: string; 324 | repoSlug: string; 325 | } | null> { 326 | if (skipIfNoCredentials()) return null; 327 | 328 | try { 329 | const listWorkspacesResult = 330 | await atlassianWorkspacesController.list({ 331 | limit: 1, 332 | }); 333 | 334 | if ( 335 | listWorkspacesResult.content === 336 | 'No Bitbucket workspaces found.' 337 | ) { 338 | return null; 339 | } 340 | 341 | // Extract workspace slug 342 | const workspaceMatch = listWorkspacesResult.content.match( 343 | /\*\*Slug\*\*:\s+([^\s\n]+)/, 344 | ); 345 | const workspaceSlug = workspaceMatch ? workspaceMatch[1] : null; 346 | 347 | if (!workspaceSlug) return null; 348 | 349 | // Get a repository from this workspace 350 | const listReposResult = await handleRepositoriesList({ 351 | workspaceSlug, 352 | limit: 1, 353 | }); 354 | 355 | if ( 356 | listReposResult.content === 357 | 'No repositories found in this workspace.' 358 | ) { 359 | return null; 360 | } 361 | 362 | // Extract repo slug - this may need adjustment based on actual Markdown format 363 | const repoSlugMatch = listReposResult.content.match( 364 | /\*\*Slug\*\*:\s+([^\s\n]+)/, 365 | ); 366 | const repoSlug = repoSlugMatch ? repoSlugMatch[1] : null; 367 | 368 | if (!repoSlug) return null; 369 | 370 | return { workspaceSlug, repoSlug }; 371 | } catch (error) { 372 | console.warn( 373 | 'Could not fetch repository identifier for test:', 374 | error, 375 | ); 376 | return null; 377 | } 378 | } 379 | 380 | it('should return formatted repository details in Markdown', async () => { 381 | if (skipIfNoCredentials()) return; 382 | 383 | const repoIdentifier = await getRepositoryIdentifier(); 384 | if (!repoIdentifier) { 385 | console.warn('Skipping test: No repository identifier found.'); 386 | return; 387 | } 388 | 389 | const result = await handleRepositoryDetails(repoIdentifier); 390 | 391 | // Verify the response structure 392 | expect(result).toHaveProperty('content'); 393 | expect(typeof result.content).toBe('string'); 394 | 395 | // Basic Markdown content checks 396 | expect(result.content).toMatch(/^# Repository:/m); 397 | expect(result.content).toContain('## Basic Information'); 398 | expect(result.content).toContain('## Links'); 399 | 400 | // Should contain the recent pull requests section (even if there are no PRs, 401 | // the section heading should be present, and there might be a "no pull requests found" message) 402 | expect(result.content).toContain('## Recent Pull Requests'); 403 | 404 | // The URL to view all PRs should be present 405 | expect(result.content).toContain( 406 | 'View all pull requests in Bitbucket', 407 | ); 408 | }, 30000); 409 | 410 | it('should throw an McpError for a non-existent repository slug', async () => { 411 | if (skipIfNoCredentials()) return; 412 | 413 | // First get a valid workspace slug 414 | const listWorkspacesResult = 415 | await atlassianWorkspacesController.list({ 416 | limit: 1, 417 | }); 418 | 419 | if ( 420 | listWorkspacesResult.content === 421 | 'No Bitbucket workspaces found.' 422 | ) { 423 | console.warn('Skipping test: No workspaces available.'); 424 | return; 425 | } 426 | 427 | // Extract workspace slug 428 | const workspaceMatch = listWorkspacesResult.content.match( 429 | /\*\*Slug\*\*:\s+([^\s\n]+)/, 430 | ); 431 | const workspaceSlug = workspaceMatch ? workspaceMatch[1] : null; 432 | 433 | if (!workspaceSlug) { 434 | console.warn( 435 | 'Skipping test: Could not extract workspace slug.', 436 | ); 437 | return; 438 | } 439 | 440 | const invalidRepoSlug = 'this-repo-definitely-does-not-exist-12345'; 441 | 442 | // Expect the controller call to reject with an McpError 443 | await expect( 444 | handleRepositoryDetails({ 445 | workspaceSlug, 446 | repoSlug: invalidRepoSlug, 447 | }), 448 | ).rejects.toThrow(McpError); 449 | 450 | // Check the status code via the error handler's behavior 451 | try { 452 | await handleRepositoryDetails({ 453 | workspaceSlug, 454 | repoSlug: invalidRepoSlug, 455 | }); 456 | } catch (e) { 457 | expect(e).toBeInstanceOf(McpError); 458 | expect((e as McpError).statusCode).toBe(404); // Expecting Not Found 459 | expect((e as McpError).message).toContain('not found'); 460 | } 461 | }, 30000); 462 | 463 | it('should throw an McpError for a non-existent workspace slug', async () => { 464 | if (skipIfNoCredentials()) return; 465 | 466 | const invalidWorkspaceSlug = 467 | 'this-workspace-definitely-does-not-exist-12345'; 468 | const someRepoSlug = 'some-repo'; 469 | 470 | // Expect the controller call to reject with an McpError 471 | await expect( 472 | handleRepositoryDetails({ 473 | workspaceSlug: invalidWorkspaceSlug, 474 | repoSlug: someRepoSlug, 475 | }), 476 | ).rejects.toThrow(McpError); 477 | 478 | // Check the status code via the error handler's behavior 479 | try { 480 | await handleRepositoryDetails({ 481 | workspaceSlug: invalidWorkspaceSlug, 482 | repoSlug: someRepoSlug, 483 | }); 484 | } catch (e) { 485 | expect(e).toBeInstanceOf(McpError); 486 | expect((e as McpError).statusCode).toBe(404); // Expecting Not Found 487 | expect((e as McpError).message).toContain('not found'); 488 | } 489 | }, 30000); 490 | }); 491 | }); 492 | ``` -------------------------------------------------------------------------------- /src/services/vendor.atlassian.pullrequests.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import atlassianPullRequestsService from './vendor.atlassian.pullrequests.service.js'; 2 | import atlassianWorkspacesService from './vendor.atlassian.workspaces.service.js'; 3 | import atlassianRepositoriesService from './vendor.atlassian.repositories.service.js'; 4 | import { getAtlassianCredentials } from '../utils/transport.util.js'; 5 | import { config } from '../utils/config.util.js'; 6 | 7 | describe('Vendor Atlassian Pull Requests Service', () => { 8 | // Variables to store valid test data 9 | let validWorkspace: string | null = null; 10 | let validRepo: string | null = null; 11 | let validPrId: string | null = null; 12 | 13 | // Load configuration and skip all tests if Atlassian credentials are not available 14 | beforeAll(async () => { 15 | // Load configuration from all sources 16 | config.load(); 17 | 18 | const credentials = getAtlassianCredentials(); 19 | if (!credentials) { 20 | console.warn( 21 | 'Skipping Atlassian Pull Requests tests: No credentials available', 22 | ); 23 | return; 24 | } 25 | 26 | // Try to find a valid workspace, repository, and PR for tests 27 | try { 28 | // Get available workspaces 29 | const workspaces = await atlassianWorkspacesService.list(); 30 | if (workspaces.values.length > 0) { 31 | validWorkspace = workspaces.values[0].workspace.slug; 32 | 33 | // Find repositories in this workspace 34 | const repositories = await atlassianRepositoriesService.list({ 35 | workspace: validWorkspace, 36 | }); 37 | 38 | if (repositories && repositories.values.length > 0) { 39 | validRepo = repositories.values[0].name.toLowerCase(); 40 | 41 | // Try to find a PR in this repository 42 | try { 43 | const pullRequests = 44 | await atlassianPullRequestsService.list({ 45 | workspace: validWorkspace, 46 | repo_slug: validRepo, 47 | pagelen: 1, 48 | }); 49 | 50 | if (pullRequests.values.length > 0) { 51 | validPrId = String(pullRequests.values[0].id); 52 | console.log( 53 | `Found valid PR for testing: ${validWorkspace}/${validRepo}/${validPrId}`, 54 | ); 55 | } 56 | } catch (error) { 57 | console.warn('Could not find a valid PR for testing'); 58 | } 59 | } 60 | } 61 | } catch (error) { 62 | console.warn('Error setting up test data:', error); 63 | } 64 | }, 30000); // <--- Increased timeout for beforeAll hook 65 | 66 | describe('listPullRequests', () => { 67 | it('should return a list of pull requests', async () => { 68 | // Check if credentials are available 69 | const credentials = getAtlassianCredentials(); 70 | if (!credentials) { 71 | return; // Skip this test if no credentials 72 | } 73 | 74 | // First get available workspaces 75 | const workspaces = await atlassianWorkspacesService.list(); 76 | 77 | // Skip if no workspaces are available 78 | if (workspaces.values.length === 0) { 79 | console.warn('Skipping test: No workspaces available'); 80 | return; 81 | } 82 | 83 | // Get the first workspace 84 | const workspace = workspaces.values[0].workspace.slug; 85 | console.log(`Using workspace: ${workspace}`); 86 | 87 | // Find repositories in this workspace 88 | let repositories; 89 | try { 90 | repositories = await atlassianRepositoriesService.list({ 91 | workspace, 92 | }); 93 | } catch (error) { 94 | console.warn( 95 | `Error fetching repositories for workspace ${workspace}: ${error instanceof Error ? error.message : String(error)}`, 96 | ); 97 | return; // Skip this test if repositories can't be fetched 98 | } 99 | 100 | // Skip if no repositories are available 101 | if (!repositories || repositories.values.length === 0) { 102 | console.warn( 103 | `Skipping test: No repositories found in workspace ${workspace}`, 104 | ); 105 | return; 106 | } 107 | 108 | // Get the first repository 109 | const repo_slug = repositories.values[0].name.toLowerCase(); 110 | console.log(`Using repository: ${workspace}/${repo_slug}`); 111 | 112 | try { 113 | // Call the function with the real API 114 | const result = await atlassianPullRequestsService.list({ 115 | workspace, 116 | repo_slug, 117 | }); 118 | 119 | // Verify the response structure 120 | expect(result).toHaveProperty('values'); 121 | expect(Array.isArray(result.values)).toBe(true); 122 | expect(result).toHaveProperty('pagelen'); 123 | expect(result).toHaveProperty('page'); 124 | expect(result).toHaveProperty('size'); 125 | 126 | // If pull requests are returned, verify their structure 127 | if (result.values.length > 0) { 128 | const pullRequest = result.values[0]; 129 | expect(pullRequest).toHaveProperty('type', 'pullrequest'); 130 | expect(pullRequest).toHaveProperty('id'); 131 | expect(pullRequest).toHaveProperty('title'); 132 | expect(pullRequest).toHaveProperty('state'); 133 | expect(pullRequest).toHaveProperty('author'); 134 | expect(pullRequest).toHaveProperty('source'); 135 | expect(pullRequest).toHaveProperty('destination'); 136 | expect(pullRequest).toHaveProperty('links'); 137 | } else { 138 | console.log( 139 | `Repository ${workspace}/${repo_slug} doesn't have any pull requests`, 140 | ); 141 | } 142 | } catch (error) { 143 | // Allow test to pass if repository doesn't exist or has no PRs 144 | if ( 145 | error instanceof Error && 146 | (error.message.includes('Not Found') || 147 | error.message.includes('No such repository')) 148 | ) { 149 | console.warn( 150 | `Repository ${workspace}/${repo_slug} not found or no access to pull requests. Skipping test.`, 151 | ); 152 | return; 153 | } 154 | throw error; // Re-throw if it's some other error 155 | } 156 | }, 15000); // Increase timeout for API call 157 | 158 | it('should support pagination', async () => { 159 | // Check if credentials are available 160 | const credentials = getAtlassianCredentials(); 161 | if (!credentials) { 162 | return; // Skip this test if no credentials 163 | } 164 | 165 | // First get available workspaces 166 | const workspaces = await atlassianWorkspacesService.list(); 167 | 168 | // Skip if no workspaces are available 169 | if (workspaces.values.length === 0) { 170 | console.warn('Skipping test: No workspaces available'); 171 | return; 172 | } 173 | 174 | // Get the first workspace 175 | const workspace = workspaces.values[0].workspace.slug; 176 | 177 | // Find repositories in this workspace 178 | let repositories; 179 | try { 180 | repositories = await atlassianRepositoriesService.list({ 181 | workspace, 182 | }); 183 | } catch (error) { 184 | console.warn( 185 | `Error fetching repositories for workspace ${workspace}: ${error instanceof Error ? error.message : String(error)}`, 186 | ); 187 | return; // Skip this test if repositories can't be fetched 188 | } 189 | 190 | // Skip if no repositories are available 191 | if (!repositories || repositories.values.length === 0) { 192 | console.warn( 193 | `Skipping test: No repositories found in workspace ${workspace}`, 194 | ); 195 | return; 196 | } 197 | 198 | // Get the first repository 199 | const repo_slug = repositories.values[0].name.toLowerCase(); 200 | 201 | try { 202 | // Call the function with the real API and limit results 203 | const result = await atlassianPullRequestsService.list({ 204 | workspace, 205 | repo_slug, 206 | pagelen: 2, 207 | }); 208 | 209 | // Verify the pagination parameters 210 | expect(result).toHaveProperty('pagelen', 2); 211 | expect(result.values.length).toBeLessThanOrEqual(2); 212 | console.log( 213 | `Found ${result.values.length} pull requests with pagination`, 214 | ); 215 | } catch (error) { 216 | // Allow test to pass if repository doesn't exist or has no PRs 217 | if ( 218 | error instanceof Error && 219 | (error.message.includes('Not Found') || 220 | error.message.includes('No such repository')) 221 | ) { 222 | console.warn( 223 | `Repository ${workspace}/${repo_slug} not found or no access to pull requests. Skipping test.`, 224 | ); 225 | return; 226 | } 227 | throw error; // Re-throw if it's some other error 228 | } 229 | }, 30000); // Increase timeout for API call 230 | 231 | it('should filter by state', async () => { 232 | // Check if credentials are available 233 | const credentials = getAtlassianCredentials(); 234 | if (!credentials) { 235 | return; // Skip this test if no credentials 236 | } 237 | 238 | // First get available workspaces 239 | const workspaces = await atlassianWorkspacesService.list(); 240 | 241 | // Skip if no workspaces are available 242 | if (workspaces.values.length === 0) { 243 | console.warn('Skipping test: No workspaces available'); 244 | return; 245 | } 246 | 247 | // Get the first workspace 248 | const workspace = workspaces.values[0].workspace.slug; 249 | 250 | // Find repositories in this workspace 251 | let repositories; 252 | try { 253 | repositories = await atlassianRepositoriesService.list({ 254 | workspace, 255 | }); 256 | } catch (error) { 257 | console.warn( 258 | `Error fetching repositories for workspace ${workspace}: ${error instanceof Error ? error.message : String(error)}`, 259 | ); 260 | return; // Skip this test if repositories can't be fetched 261 | } 262 | 263 | // Skip if no repositories are available 264 | if (!repositories || repositories.values.length === 0) { 265 | console.warn( 266 | `Skipping test: No repositories found in workspace ${workspace}`, 267 | ); 268 | return; 269 | } 270 | 271 | // Get the first repository 272 | const repo_slug = repositories.values[0].name.toLowerCase(); 273 | 274 | try { 275 | // Call the function with the real API and filter by state 276 | const result = await atlassianPullRequestsService.list({ 277 | workspace, 278 | repo_slug, 279 | state: ['OPEN', 'MERGED'], 280 | }); 281 | 282 | // Verify the states are as expected 283 | expect(result).toHaveProperty('values'); 284 | 285 | // If pull requests are returned, verify they have the correct state 286 | if (result.values.length > 0) { 287 | result.values.forEach((pr) => { 288 | expect(['OPEN', 'MERGED']).toContain(pr.state); 289 | }); 290 | console.log( 291 | `Found ${result.values.length} pull requests with states OPEN or MERGED`, 292 | ); 293 | } else { 294 | console.log( 295 | `No pull requests found with states OPEN or MERGED`, 296 | ); 297 | } 298 | } catch (error) { 299 | // Allow test to pass if repository doesn't exist or has no PRs 300 | if ( 301 | error instanceof Error && 302 | (error.message.includes('Not Found') || 303 | error.message.includes('No such repository')) 304 | ) { 305 | console.warn( 306 | `Repository ${workspace}/${repo_slug} not found or no access to pull requests. Skipping test.`, 307 | ); 308 | return; 309 | } 310 | throw error; // Re-throw if it's some other error 311 | } 312 | }, 30000); // Increase timeout for API call 313 | }); 314 | 315 | describe('getPullRequest', () => { 316 | it('should return details for a valid pull request ID', async () => { 317 | // Check if credentials are available 318 | const credentials = getAtlassianCredentials(); 319 | if (!credentials) { 320 | return; // Skip this test if no credentials 321 | } 322 | 323 | // First get available workspaces 324 | const workspaces = await atlassianWorkspacesService.list(); 325 | 326 | // Skip if no workspaces are available 327 | if (workspaces.values.length === 0) { 328 | console.warn('Skipping test: No workspaces available'); 329 | return; 330 | } 331 | 332 | // Get the first workspace 333 | const workspace = workspaces.values[0].workspace.slug; 334 | 335 | // Find repositories in this workspace 336 | let repositories; 337 | try { 338 | repositories = await atlassianRepositoriesService.list({ 339 | workspace, 340 | }); 341 | } catch (error) { 342 | console.warn( 343 | `Error fetching repositories for workspace ${workspace}: ${error instanceof Error ? error.message : String(error)}`, 344 | ); 345 | return; // Skip this test if repositories can't be fetched 346 | } 347 | 348 | // Skip if no repositories are available 349 | if (!repositories || repositories.values.length === 0) { 350 | console.warn( 351 | `Skipping test: No repositories found in workspace ${workspace}`, 352 | ); 353 | return; 354 | } 355 | 356 | // Get the first repository 357 | const repo_slug = repositories.values[0].name.toLowerCase(); 358 | 359 | try { 360 | // First, check if we can get a list of PRs to find a valid ID 361 | const prs = await atlassianPullRequestsService.list({ 362 | workspace, 363 | repo_slug, 364 | }); 365 | 366 | // Skip if no pull requests are available 367 | if (!prs.values.length) { 368 | console.warn( 369 | `Skipping test: No pull requests found in repository ${workspace}/${repo_slug}`, 370 | ); 371 | return; 372 | } 373 | 374 | // Use the first PR's ID 375 | const prId = prs.values[0].id; 376 | console.log(`Testing pull request ID: ${prId}`); 377 | 378 | // Get the specific pull request 379 | const result = await atlassianPullRequestsService.get({ 380 | workspace, 381 | repo_slug, 382 | pull_request_id: prId, 383 | }); 384 | 385 | // Verify the response contains expected fields 386 | expect(result).toHaveProperty('id', prId); 387 | expect(result).toHaveProperty('type', 'pullrequest'); 388 | expect(result).toHaveProperty('title'); 389 | expect(result).toHaveProperty('state'); 390 | expect(result).toHaveProperty('author'); 391 | expect(result).toHaveProperty('source'); 392 | expect(result).toHaveProperty('destination'); 393 | expect(result).toHaveProperty('links'); 394 | } catch (error) { 395 | // Allow test to pass if repository or PR doesn't exist 396 | if ( 397 | error instanceof Error && 398 | (error.message.includes('Not Found') || 399 | error.message.includes('No such repository') || 400 | error.message.includes('Pull request not found')) 401 | ) { 402 | console.warn( 403 | `Repository ${workspace}/${repo_slug} or its pull requests not found. Skipping test.`, 404 | ); 405 | return; 406 | } 407 | throw error; // Re-throw if it's some other error 408 | } 409 | }, 15000); // Increase timeout for API call 410 | 411 | it('should handle invalid pull request IDs', async () => { 412 | // Check if credentials are available 413 | const credentials = getAtlassianCredentials(); 414 | if (!credentials) { 415 | return; // Skip this test if no credentials 416 | } 417 | 418 | // First get available workspaces 419 | const workspaces = await atlassianWorkspacesService.list(); 420 | 421 | // Skip if no workspaces are available 422 | if (workspaces.values.length === 0) { 423 | console.warn('Skipping test: No workspaces available'); 424 | return; 425 | } 426 | 427 | // Get the first workspace 428 | const workspace = workspaces.values[0].workspace.slug; 429 | 430 | // Find repositories in this workspace 431 | let repositories; 432 | try { 433 | repositories = await atlassianRepositoriesService.list({ 434 | workspace, 435 | }); 436 | } catch (error) { 437 | console.warn( 438 | `Error fetching repositories for workspace ${workspace}: ${error instanceof Error ? error.message : String(error)}`, 439 | ); 440 | return; // Skip this test if repositories can't be fetched 441 | } 442 | 443 | // Skip if no repositories are available 444 | if (!repositories || repositories.values.length === 0) { 445 | console.warn( 446 | `Skipping test: No repositories found in workspace ${workspace}`, 447 | ); 448 | return; 449 | } 450 | 451 | // Get the first repository 452 | const repo_slug = repositories.values[0].name.toLowerCase(); 453 | 454 | try { 455 | // Use an invalid pull request ID (very large number unlikely to exist) 456 | const invalidId = 999999; 457 | console.log(`Testing invalid pull request ID: ${invalidId}`); 458 | 459 | // Call the function with the real API and expect it to throw 460 | await expect( 461 | atlassianPullRequestsService.get({ 462 | workspace, 463 | repo_slug, 464 | pull_request_id: invalidId, 465 | }), 466 | ).rejects.toThrow(); 467 | } catch (error) { 468 | // If repo doesn't exist, just skip the test 469 | if ( 470 | error instanceof Error && 471 | (error.message.includes('Not Found') || 472 | error.message.includes('No such repository')) 473 | ) { 474 | console.warn( 475 | `Repository ${workspace}/${repo_slug} not found. Skipping test.`, 476 | ); 477 | return; 478 | } 479 | // Otherwise, we should have caught the expected rejection 480 | } 481 | }, 15000); // Increase timeout for API call 482 | }); 483 | 484 | // Note: addComment test suite has been removed to avoid creating comments on real PRs during tests 485 | }); 486 | ``` -------------------------------------------------------------------------------- /src/utils/error-handler.util.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { createApiError } from './error.util.js'; 2 | import { Logger } from './logger.util.js'; 3 | import { getDeepOriginalError } from './error.util.js'; 4 | import { McpError } from './error.util.js'; 5 | 6 | /** 7 | * Standard error codes for consistent handling 8 | */ 9 | export enum ErrorCode { 10 | NOT_FOUND = 'NOT_FOUND', 11 | INVALID_CURSOR = 'INVALID_CURSOR', 12 | ACCESS_DENIED = 'ACCESS_DENIED', 13 | VALIDATION_ERROR = 'VALIDATION_ERROR', 14 | UNEXPECTED_ERROR = 'UNEXPECTED_ERROR', 15 | NETWORK_ERROR = 'NETWORK_ERROR', 16 | RATE_LIMIT_ERROR = 'RATE_LIMIT_ERROR', 17 | PRIVATE_IP_ERROR = 'PRIVATE_IP_ERROR', 18 | RESERVED_RANGE_ERROR = 'RESERVED_RANGE_ERROR', 19 | } 20 | 21 | /** 22 | * Context information for error handling 23 | */ 24 | export interface ErrorContext { 25 | /** 26 | * Source of the error (e.g., file path and function) 27 | */ 28 | source?: string; 29 | 30 | /** 31 | * Type of entity being processed (e.g., 'Repository', 'PullRequest') 32 | */ 33 | entityType?: string; 34 | 35 | /** 36 | * Identifier of the entity being processed 37 | */ 38 | entityId?: string | Record<string, string>; 39 | 40 | /** 41 | * Operation being performed (e.g., 'listing', 'creating') 42 | */ 43 | operation?: string; 44 | 45 | /** 46 | * Additional information for debugging 47 | */ 48 | additionalInfo?: Record<string, unknown>; 49 | } 50 | 51 | /** 52 | * Helper function to create a consistent error context object 53 | * @param entityType Type of entity being processed 54 | * @param operation Operation being performed 55 | * @param source Source of the error (typically file path and function) 56 | * @param entityId Optional identifier of the entity 57 | * @param additionalInfo Optional additional information for debugging 58 | * @returns A formatted ErrorContext object 59 | */ 60 | export function buildErrorContext( 61 | entityType: string, 62 | operation: string, 63 | source: string, 64 | entityId?: string | Record<string, string>, 65 | additionalInfo?: Record<string, unknown>, 66 | ): ErrorContext { 67 | return { 68 | entityType, 69 | operation, 70 | source, 71 | ...(entityId && { entityId }), 72 | ...(additionalInfo && { additionalInfo }), 73 | }; 74 | } 75 | 76 | /** 77 | * Detect specific error types from raw errors 78 | * @param error The error to analyze 79 | * @param context Context information for better error detection 80 | * @returns Object containing the error code and status code 81 | */ 82 | export function detectErrorType( 83 | error: unknown, 84 | context: ErrorContext = {}, 85 | ): { code: ErrorCode; statusCode: number } { 86 | const methodLogger = Logger.forContext( 87 | 'utils/error-handler.util.ts', 88 | 'detectErrorType', 89 | ); 90 | methodLogger.debug(`Detecting error type`, { error, context }); 91 | 92 | const errorMessage = error instanceof Error ? error.message : String(error); 93 | const statusCode = 94 | error instanceof Error && 'statusCode' in error 95 | ? (error as { statusCode: number }).statusCode 96 | : undefined; 97 | 98 | // PR ID validation error detection 99 | if ( 100 | errorMessage.includes('Invalid pull request ID') || 101 | errorMessage.includes('Pull request ID must be a positive integer') 102 | ) { 103 | return { code: ErrorCode.VALIDATION_ERROR, statusCode: 400 }; 104 | } 105 | 106 | // Network error detection 107 | if ( 108 | errorMessage.includes('network error') || 109 | errorMessage.includes('fetch failed') || 110 | errorMessage.includes('ECONNREFUSED') || 111 | errorMessage.includes('ENOTFOUND') || 112 | errorMessage.includes('Failed to fetch') || 113 | errorMessage.includes('Network request failed') 114 | ) { 115 | return { code: ErrorCode.NETWORK_ERROR, statusCode: 500 }; 116 | } 117 | 118 | // Network error detection in originalError 119 | if ( 120 | error instanceof Error && 121 | 'originalError' in error && 122 | error.originalError 123 | ) { 124 | // Check for TypeError in originalError (common for network issues) 125 | if (error.originalError instanceof TypeError) { 126 | return { code: ErrorCode.NETWORK_ERROR, statusCode: 500 }; 127 | } 128 | 129 | // Check for network error messages in originalError 130 | if ( 131 | error.originalError instanceof Error && 132 | (error.originalError.message.includes('fetch') || 133 | error.originalError.message.includes('network') || 134 | error.originalError.message.includes('ECON')) 135 | ) { 136 | return { code: ErrorCode.NETWORK_ERROR, statusCode: 500 }; 137 | } 138 | } 139 | 140 | // Rate limiting detection 141 | if ( 142 | errorMessage.includes('rate limit') || 143 | errorMessage.includes('too many requests') || 144 | statusCode === 429 145 | ) { 146 | return { code: ErrorCode.RATE_LIMIT_ERROR, statusCode: 429 }; 147 | } 148 | 149 | // Bitbucket-specific error detection 150 | if ( 151 | error instanceof Error && 152 | 'originalError' in error && 153 | error.originalError 154 | ) { 155 | const originalError = getDeepOriginalError(error.originalError); 156 | 157 | if (originalError && typeof originalError === 'object') { 158 | const oe = originalError as Record<string, unknown>; 159 | 160 | // Check for Bitbucket API error structure 161 | if (oe.error && typeof oe.error === 'object') { 162 | const bbError = oe.error as Record<string, unknown>; 163 | const errorMsg = String(bbError.message || '').toLowerCase(); 164 | const errorDetail = bbError.detail 165 | ? String(bbError.detail).toLowerCase() 166 | : ''; 167 | 168 | methodLogger.debug('Found Bitbucket error structure', { 169 | message: errorMsg, 170 | detail: errorDetail, 171 | }); 172 | 173 | // Repository not found / Does not exist errors 174 | if ( 175 | errorMsg.includes('repository not found') || 176 | errorMsg.includes('does not exist') || 177 | errorMsg.includes('no such resource') || 178 | errorMsg.includes('not found') 179 | ) { 180 | return { code: ErrorCode.NOT_FOUND, statusCode: 404 }; 181 | } 182 | 183 | // Access and permission errors 184 | if ( 185 | errorMsg.includes('access') || 186 | errorMsg.includes('permission') || 187 | errorMsg.includes('credentials') || 188 | errorMsg.includes('unauthorized') || 189 | errorMsg.includes('forbidden') || 190 | errorMsg.includes('authentication') 191 | ) { 192 | return { code: ErrorCode.ACCESS_DENIED, statusCode: 403 }; 193 | } 194 | 195 | // Validation errors 196 | if ( 197 | errorMsg.includes('invalid') || 198 | (errorMsg.includes('parameter') && 199 | errorMsg.includes('error')) || 200 | errorMsg.includes('input') || 201 | errorMsg.includes('validation') || 202 | errorMsg.includes('required field') || 203 | errorMsg.includes('bad request') 204 | ) { 205 | return { 206 | code: ErrorCode.VALIDATION_ERROR, 207 | statusCode: 400, 208 | }; 209 | } 210 | 211 | // Rate limiting errors 212 | if ( 213 | errorMsg.includes('rate limit') || 214 | errorMsg.includes('too many requests') || 215 | errorMsg.includes('throttled') 216 | ) { 217 | return { 218 | code: ErrorCode.RATE_LIMIT_ERROR, 219 | statusCode: 429, 220 | }; 221 | } 222 | } 223 | 224 | // Check for alternate Bitbucket error structure: {"type": "error", ...} 225 | if (oe.type === 'error') { 226 | methodLogger.debug('Found Bitbucket type:error structure', oe); 227 | 228 | // Check for status code if available in the error object 229 | if (typeof oe.status === 'number') { 230 | if (oe.status === 404) { 231 | return { code: ErrorCode.NOT_FOUND, statusCode: 404 }; 232 | } 233 | if (oe.status === 403 || oe.status === 401) { 234 | return { 235 | code: ErrorCode.ACCESS_DENIED, 236 | statusCode: oe.status, 237 | }; 238 | } 239 | if (oe.status === 400) { 240 | return { 241 | code: ErrorCode.VALIDATION_ERROR, 242 | statusCode: 400, 243 | }; 244 | } 245 | if (oe.status === 429) { 246 | return { 247 | code: ErrorCode.RATE_LIMIT_ERROR, 248 | statusCode: 429, 249 | }; 250 | } 251 | } 252 | } 253 | 254 | // Check for Bitbucket error structure: {"errors": [{...}]} 255 | if (Array.isArray(oe.errors) && oe.errors.length > 0) { 256 | const firstError = oe.errors[0] as Record<string, unknown>; 257 | methodLogger.debug( 258 | 'Found Bitbucket errors array structure', 259 | firstError, 260 | ); 261 | 262 | if (typeof firstError.status === 'number') { 263 | if (firstError.status === 404) { 264 | return { code: ErrorCode.NOT_FOUND, statusCode: 404 }; 265 | } 266 | if ( 267 | firstError.status === 403 || 268 | firstError.status === 401 269 | ) { 270 | return { 271 | code: ErrorCode.ACCESS_DENIED, 272 | statusCode: firstError.status, 273 | }; 274 | } 275 | if (firstError.status === 400) { 276 | return { 277 | code: ErrorCode.VALIDATION_ERROR, 278 | statusCode: 400, 279 | }; 280 | } 281 | if (firstError.status === 429) { 282 | return { 283 | code: ErrorCode.RATE_LIMIT_ERROR, 284 | statusCode: 429, 285 | }; 286 | } 287 | } 288 | 289 | // Look for error messages in the title or message fields 290 | if (firstError.title || firstError.message) { 291 | const errorText = String( 292 | firstError.title || firstError.message, 293 | ).toLowerCase(); 294 | if (errorText.includes('not found')) { 295 | return { code: ErrorCode.NOT_FOUND, statusCode: 404 }; 296 | } 297 | if ( 298 | errorText.includes('access') || 299 | errorText.includes('permission') 300 | ) { 301 | return { 302 | code: ErrorCode.ACCESS_DENIED, 303 | statusCode: 403, 304 | }; 305 | } 306 | if ( 307 | errorText.includes('invalid') || 308 | errorText.includes('required') 309 | ) { 310 | return { 311 | code: ErrorCode.VALIDATION_ERROR, 312 | statusCode: 400, 313 | }; 314 | } 315 | if ( 316 | errorText.includes('rate limit') || 317 | errorText.includes('too many requests') 318 | ) { 319 | return { 320 | code: ErrorCode.RATE_LIMIT_ERROR, 321 | statusCode: 429, 322 | }; 323 | } 324 | } 325 | } 326 | } 327 | } 328 | 329 | // Not Found detection 330 | if ( 331 | errorMessage.includes('not found') || 332 | errorMessage.includes('does not exist') || 333 | statusCode === 404 334 | ) { 335 | return { code: ErrorCode.NOT_FOUND, statusCode: 404 }; 336 | } 337 | 338 | // Access Denied detection 339 | if ( 340 | errorMessage.includes('access') || 341 | errorMessage.includes('permission') || 342 | errorMessage.includes('authorize') || 343 | errorMessage.includes('authentication') || 344 | statusCode === 401 || 345 | statusCode === 403 346 | ) { 347 | return { code: ErrorCode.ACCESS_DENIED, statusCode: statusCode || 403 }; 348 | } 349 | 350 | // Invalid Cursor detection 351 | if ( 352 | (errorMessage.includes('cursor') || 353 | errorMessage.includes('startAt') || 354 | errorMessage.includes('page')) && 355 | (errorMessage.includes('invalid') || errorMessage.includes('not valid')) 356 | ) { 357 | return { code: ErrorCode.INVALID_CURSOR, statusCode: 400 }; 358 | } 359 | 360 | // Validation Error detection 361 | if ( 362 | errorMessage.includes('validation') || 363 | errorMessage.includes('invalid') || 364 | errorMessage.includes('required') || 365 | statusCode === 400 || 366 | statusCode === 422 367 | ) { 368 | return { 369 | code: ErrorCode.VALIDATION_ERROR, 370 | statusCode: statusCode || 400, 371 | }; 372 | } 373 | 374 | // Default to unexpected error 375 | return { 376 | code: ErrorCode.UNEXPECTED_ERROR, 377 | statusCode: statusCode || 500, 378 | }; 379 | } 380 | 381 | /** 382 | * Create user-friendly error messages based on error type and context 383 | * @param code The error code 384 | * @param context Context information for better error messages 385 | * @param originalMessage The original error message 386 | * @returns User-friendly error message 387 | */ 388 | export function createUserFriendlyErrorMessage( 389 | code: ErrorCode, 390 | context: ErrorContext = {}, 391 | originalMessage?: string, 392 | ): string { 393 | const methodLogger = Logger.forContext( 394 | 'utils/error-handler.util.ts', 395 | 'createUserFriendlyErrorMessage', 396 | ); 397 | const { entityType, entityId, operation } = context; 398 | 399 | // Format entity ID for display 400 | let entityIdStr = ''; 401 | if (entityId) { 402 | if (typeof entityId === 'string') { 403 | entityIdStr = entityId; 404 | } else { 405 | // Handle object entityId (like ProjectIdentifier) 406 | entityIdStr = Object.values(entityId).join('/'); 407 | } 408 | } 409 | 410 | // Determine entity display name 411 | const entity = entityType 412 | ? `${entityType}${entityIdStr ? ` ${entityIdStr}` : ''}` 413 | : 'Resource'; 414 | 415 | let message = ''; 416 | 417 | switch (code) { 418 | case ErrorCode.NOT_FOUND: 419 | message = `${entity} not found${entityIdStr ? `: ${entityIdStr}` : ''}. Verify the ID is correct and that you have access to this ${entityType?.toLowerCase() || 'resource'}.`; 420 | 421 | // Bitbucket-specific guidance 422 | if ( 423 | entityType === 'Repository' || 424 | entityType === 'PullRequest' || 425 | entityType === 'Branch' 426 | ) { 427 | message += ` Make sure the workspace and ${entityType.toLowerCase()} names are spelled correctly and that you have permission to access it.`; 428 | } 429 | break; 430 | 431 | case ErrorCode.ACCESS_DENIED: 432 | message = `Access denied for ${entity.toLowerCase()}${entityIdStr ? ` ${entityIdStr}` : ''}. Verify your credentials and permissions.`; 433 | 434 | // Bitbucket-specific guidance 435 | message += ` Ensure your Bitbucket API token/app password has sufficient privileges and hasn't expired. If using a workspace/repository name, check that it's spelled correctly.`; 436 | break; 437 | 438 | case ErrorCode.INVALID_CURSOR: 439 | message = `Invalid pagination cursor. Use the exact cursor string returned from previous results.`; 440 | 441 | // Bitbucket-specific guidance 442 | message += ` Bitbucket pagination typically uses page numbers. Check that the page number is valid and within range.`; 443 | break; 444 | 445 | case ErrorCode.VALIDATION_ERROR: 446 | message = 447 | originalMessage || 448 | `Invalid data provided for ${operation || 'operation'} ${entity.toLowerCase()}.`; 449 | 450 | // The originalMessage already includes error details for VALIDATION_ERROR 451 | break; 452 | 453 | case ErrorCode.NETWORK_ERROR: 454 | message = `Network error while ${operation || 'connecting to'} the Bitbucket API. Please check your internet connection and try again.`; 455 | break; 456 | 457 | case ErrorCode.RATE_LIMIT_ERROR: 458 | message = `Bitbucket API rate limit exceeded. Please wait a moment and try again, or reduce the frequency of requests.`; 459 | 460 | // Bitbucket-specific guidance 461 | message += ` Bitbucket's API has rate limits per IP address and additional limits for authenticated users.`; 462 | break; 463 | 464 | default: 465 | message = `An unexpected error occurred while ${operation || 'processing'} ${entity.toLowerCase()}.`; 466 | } 467 | 468 | // Include original message details if available and appropriate 469 | if ( 470 | originalMessage && 471 | code !== ErrorCode.NOT_FOUND && 472 | code !== ErrorCode.ACCESS_DENIED 473 | ) { 474 | message += ` Error details: ${originalMessage}`; 475 | } 476 | 477 | methodLogger.debug(`Created user-friendly message: ${message}`, { 478 | code, 479 | context, 480 | }); 481 | return message; 482 | } 483 | 484 | /** 485 | * Handle controller errors consistently 486 | * @param error The error to handle 487 | * @param context Context information for better error messages 488 | * @returns Never returns, always throws an error 489 | */ 490 | export function handleControllerError( 491 | error: unknown, 492 | context: ErrorContext = {}, 493 | ): never { 494 | const methodLogger = Logger.forContext( 495 | 'utils/error-handler.util.ts', 496 | 'handleControllerError', 497 | ); 498 | 499 | // Extract error details 500 | const errorMessage = error instanceof Error ? error.message : String(error); 501 | const statusCode = 502 | error instanceof Error && 'statusCode' in error 503 | ? (error as { statusCode: number }).statusCode 504 | : undefined; 505 | 506 | // Detect error type using utility 507 | const { code, statusCode: detectedStatus } = detectErrorType( 508 | error, 509 | context, 510 | ); 511 | 512 | // Combine detected status with explicit status 513 | const finalStatusCode = statusCode || detectedStatus; 514 | 515 | // Format entity information for logging 516 | const { entityType, entityId, operation } = context; 517 | const entity = entityType || 'resource'; 518 | const entityIdStr = entityId 519 | ? typeof entityId === 'string' 520 | ? entityId 521 | : JSON.stringify(entityId) 522 | : ''; 523 | const actionStr = operation || 'processing'; 524 | 525 | // Log detailed error information 526 | methodLogger.error( 527 | `Error ${actionStr} ${entity}${ 528 | entityIdStr ? `: ${entityIdStr}` : '' 529 | }: ${errorMessage}`, 530 | error, 531 | ); 532 | 533 | // Create user-friendly error message for the response 534 | const message = 535 | code === ErrorCode.VALIDATION_ERROR 536 | ? errorMessage 537 | : createUserFriendlyErrorMessage(code, context, errorMessage); 538 | 539 | // Throw an appropriate API error with the user-friendly message 540 | throw createApiError(message, finalStatusCode, error); 541 | } 542 | 543 | /** 544 | * Handles errors from CLI commands 545 | * Logs the error and exits the process with appropriate exit code 546 | * 547 | * @param error The error to handle 548 | */ 549 | export function handleCliError(error: unknown): never { 550 | const logger = Logger.forContext( 551 | 'utils/error-handler.util.ts', 552 | 'handleCliError', 553 | ); 554 | 555 | logger.error('CLI error:', error); 556 | 557 | // Process different error types 558 | if (error instanceof McpError) { 559 | // Format user-friendly error message for MCP errors 560 | console.error(`Error: ${error.message}`); 561 | 562 | // Use specific exit codes based on error type 563 | switch (error.errorType) { 564 | case 'AUTHENTICATION_REQUIRED': 565 | process.exit(2); 566 | break; // Not strictly needed after process.exit but added for clarity 567 | case 'NOT_FOUND': 568 | process.exit(3); 569 | break; 570 | case 'VALIDATION_ERROR': 571 | process.exit(4); 572 | break; 573 | case 'RATE_LIMIT_EXCEEDED': 574 | process.exit(5); 575 | break; 576 | case 'API_ERROR': 577 | process.exit(6); 578 | break; 579 | default: 580 | process.exit(1); 581 | break; 582 | } 583 | } else if (error instanceof Error) { 584 | // Standard Error objects 585 | console.error(`Error: ${error.message}`); 586 | process.exit(1); 587 | } else { 588 | // Unknown error types 589 | console.error(`Unknown error occurred: ${String(error)}`); 590 | process.exit(1); 591 | } 592 | } 593 | ```