#
tokens: 45455/50000 10/114 files (page 4/6)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 4/6FirstPrevNextLast