mcp-server-bitbucket 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,1102 @@
1
+ /**
2
+ * Bitbucket API client for MCP server.
3
+ *
4
+ * Provides all Bitbucket API operations needed by the MCP tools:
5
+ * - Repositories: get, create, delete, list, update
6
+ * - Pull Requests: create, get, list, merge, approve, decline, comments, diff
7
+ * - Pipelines: trigger, get, list, logs, stop
8
+ * - Branches: list, get
9
+ * - Commits: list, get, compare, statuses
10
+ * - Deployments: environments, deployment history
11
+ * - Webhooks: list, create, get, delete
12
+ * - Tags: list, create, delete
13
+ * - Branch Restrictions: list, create, delete
14
+ * - Source: file content, directory listing
15
+ * - Permissions: user and group permissions
16
+ */
17
+
18
+ import axios, { AxiosInstance, AxiosError } from 'axios';
19
+ import { getSettings } from './settings.js';
20
+ import { ensureUuidBraces, sleep } from './utils.js';
21
+ import type {
22
+ BitbucketRepository,
23
+ BitbucketBranch,
24
+ BitbucketCommit,
25
+ BitbucketPullRequest,
26
+ BitbucketPipeline,
27
+ BitbucketPipelineStep,
28
+ BitbucketPipelineVariable,
29
+ BitbucketEnvironment,
30
+ BitbucketDeployment,
31
+ BitbucketWebhook,
32
+ BitbucketTag,
33
+ BitbucketBranchRestriction,
34
+ BitbucketComment,
35
+ BitbucketCommitStatus,
36
+ BitbucketProject,
37
+ DirectoryEntry,
38
+ UserPermission,
39
+ GroupPermission,
40
+ PaginatedResponse,
41
+ TriggerPipelineOptions,
42
+ PipelineTriggerVariable,
43
+ } from './types.js';
44
+
45
+ /**
46
+ * Error class for Bitbucket API errors
47
+ */
48
+ export class BitbucketError extends Error {
49
+ constructor(
50
+ message: string,
51
+ public statusCode?: number,
52
+ public method?: string,
53
+ public path?: string
54
+ ) {
55
+ super(message);
56
+ this.name = 'BitbucketError';
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Bitbucket API client with connection pooling and retry logic
62
+ */
63
+ export class BitbucketClient {
64
+ private static readonly BASE_URL = 'https://api.bitbucket.org/2.0';
65
+ private static readonly INITIAL_BACKOFF = 1000; // ms
66
+
67
+ public readonly workspace: string;
68
+ private readonly client: AxiosInstance;
69
+ private readonly maxRetries: number;
70
+
71
+ constructor() {
72
+ const settings = getSettings();
73
+
74
+ this.workspace = settings.bitbucketWorkspace;
75
+ this.maxRetries = settings.maxRetries;
76
+
77
+ this.client = axios.create({
78
+ baseURL: BitbucketClient.BASE_URL,
79
+ timeout: settings.apiTimeout * 1000,
80
+ auth: {
81
+ username: settings.bitbucketEmail,
82
+ password: settings.bitbucketApiToken,
83
+ },
84
+ headers: {
85
+ 'Content-Type': 'application/json',
86
+ },
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Build repository endpoint path
92
+ */
93
+ private repoPath(repoSlug: string, ...parts: string[]): string {
94
+ const base = `repositories/${this.workspace}/${repoSlug}`;
95
+ return parts.length > 0 ? `${base}/${parts.join('/')}` : base;
96
+ }
97
+
98
+ /**
99
+ * Make an API request with retry logic for rate limiting
100
+ */
101
+ private async request<T>(
102
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
103
+ path: string,
104
+ data?: unknown,
105
+ params?: Record<string, unknown>
106
+ ): Promise<T | null> {
107
+ let backoff = BitbucketClient.INITIAL_BACKOFF;
108
+
109
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
110
+ try {
111
+ const response = await this.client.request<T>({
112
+ method,
113
+ url: path,
114
+ data,
115
+ params,
116
+ });
117
+
118
+ return response.data;
119
+ } catch (error) {
120
+ if (axios.isAxiosError(error)) {
121
+ const axiosError = error as AxiosError;
122
+
123
+ // Handle 404 as null (not found)
124
+ if (axiosError.response?.status === 404) {
125
+ return null;
126
+ }
127
+
128
+ // Handle rate limiting (429)
129
+ if (axiosError.response?.status === 429) {
130
+ if (attempt < this.maxRetries) {
131
+ const retryAfter = axiosError.response.headers['retry-after'];
132
+ const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : backoff;
133
+ await sleep(waitTime);
134
+ backoff *= 2;
135
+ continue;
136
+ }
137
+ throw new BitbucketError(
138
+ `Rate limited after ${this.maxRetries} retries`,
139
+ 429,
140
+ method,
141
+ path
142
+ );
143
+ }
144
+
145
+ // Other errors
146
+ const statusCode = axiosError.response?.status;
147
+ const errorText = JSON.stringify(axiosError.response?.data || axiosError.message).substring(0, 500);
148
+ throw new BitbucketError(
149
+ `API error ${statusCode}: ${errorText}`,
150
+ statusCode,
151
+ method,
152
+ path
153
+ );
154
+ }
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ throw new BitbucketError(`Unexpected error in request`, undefined, method, path);
160
+ }
161
+
162
+ /**
163
+ * Make a request that returns plain text
164
+ */
165
+ private async requestText(path: string): Promise<string | null> {
166
+ let backoff = BitbucketClient.INITIAL_BACKOFF;
167
+
168
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
169
+ try {
170
+ const response = await this.client.get(path, {
171
+ responseType: 'text',
172
+ });
173
+ return response.data;
174
+ } catch (error) {
175
+ if (axios.isAxiosError(error)) {
176
+ if (error.response?.status === 404) {
177
+ return null;
178
+ }
179
+ if (error.response?.status === 429) {
180
+ if (attempt < this.maxRetries) {
181
+ const retryAfter = error.response.headers['retry-after'];
182
+ const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : backoff;
183
+ await sleep(waitTime);
184
+ backoff *= 2;
185
+ continue;
186
+ }
187
+ }
188
+ throw new BitbucketError(`Request failed: ${error.response?.status}`);
189
+ }
190
+ throw error;
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+
196
+ /**
197
+ * Helper for paginated list endpoints
198
+ */
199
+ private async paginatedList<T>(
200
+ endpoint: string,
201
+ options: { limit?: number; maxPage?: number } & Record<string, unknown> = {}
202
+ ): Promise<T[]> {
203
+ const { limit = 50, maxPage = 100, ...extraParams } = options;
204
+ const params: Record<string, unknown> = {
205
+ pagelen: Math.min(limit, maxPage),
206
+ ...extraParams,
207
+ };
208
+
209
+ // Filter out undefined values
210
+ Object.keys(params).forEach(key => {
211
+ if (params[key] === undefined) {
212
+ delete params[key];
213
+ }
214
+ });
215
+
216
+ const result = await this.request<PaginatedResponse<T>>('GET', endpoint, undefined, params);
217
+ return result?.values || [];
218
+ }
219
+
220
+ // ==================== REPOSITORIES ====================
221
+
222
+ async getRepository(repoSlug: string): Promise<BitbucketRepository | null> {
223
+ return this.request('GET', this.repoPath(repoSlug));
224
+ }
225
+
226
+ async createRepository(
227
+ repoSlug: string,
228
+ options: {
229
+ projectKey?: string;
230
+ isPrivate?: boolean;
231
+ description?: string;
232
+ } = {}
233
+ ): Promise<BitbucketRepository> {
234
+ const payload: Record<string, unknown> = {
235
+ scm: 'git',
236
+ is_private: options.isPrivate ?? true,
237
+ };
238
+ if (options.projectKey) {
239
+ payload.project = { key: options.projectKey };
240
+ }
241
+ if (options.description) {
242
+ payload.description = options.description;
243
+ }
244
+
245
+ const result = await this.request<BitbucketRepository>('POST', this.repoPath(repoSlug), payload);
246
+ if (!result) {
247
+ throw new BitbucketError(`Failed to create repository: ${repoSlug}`);
248
+ }
249
+ return result;
250
+ }
251
+
252
+ async deleteRepository(repoSlug: string): Promise<void> {
253
+ await this.request('DELETE', this.repoPath(repoSlug));
254
+ }
255
+
256
+ async listRepositories(
257
+ options: {
258
+ projectKey?: string;
259
+ query?: string;
260
+ limit?: number;
261
+ } = {}
262
+ ): Promise<BitbucketRepository[]> {
263
+ const params: Record<string, unknown> = {
264
+ pagelen: Math.min(options.limit || 50, 100),
265
+ };
266
+
267
+ const qParts: string[] = [];
268
+ if (options.projectKey) {
269
+ qParts.push(`project.key="${options.projectKey}"`);
270
+ }
271
+ if (options.query) {
272
+ qParts.push(options.query);
273
+ }
274
+ if (qParts.length > 0) {
275
+ params.q = qParts.join(' AND ');
276
+ }
277
+
278
+ const result = await this.request<PaginatedResponse<BitbucketRepository>>(
279
+ 'GET',
280
+ `repositories/${this.workspace}`,
281
+ undefined,
282
+ params
283
+ );
284
+ return result?.values || [];
285
+ }
286
+
287
+ async updateRepository(
288
+ repoSlug: string,
289
+ options: {
290
+ projectKey?: string;
291
+ isPrivate?: boolean;
292
+ description?: string;
293
+ name?: string;
294
+ }
295
+ ): Promise<BitbucketRepository> {
296
+ const payload: Record<string, unknown> = {};
297
+ if (options.projectKey !== undefined) {
298
+ payload.project = { key: options.projectKey };
299
+ }
300
+ if (options.isPrivate !== undefined) {
301
+ payload.is_private = options.isPrivate;
302
+ }
303
+ if (options.description !== undefined) {
304
+ payload.description = options.description;
305
+ }
306
+ if (options.name !== undefined) {
307
+ payload.name = options.name;
308
+ }
309
+
310
+ if (Object.keys(payload).length === 0) {
311
+ throw new BitbucketError('No fields to update');
312
+ }
313
+
314
+ const result = await this.request<BitbucketRepository>('PUT', this.repoPath(repoSlug), payload);
315
+ if (!result) {
316
+ throw new BitbucketError(`Failed to update repository: ${repoSlug}`);
317
+ }
318
+ return result;
319
+ }
320
+
321
+ // ==================== PULL REQUESTS ====================
322
+
323
+ async createPullRequest(
324
+ repoSlug: string,
325
+ options: {
326
+ title: string;
327
+ sourceBranch: string;
328
+ destinationBranch?: string;
329
+ description?: string;
330
+ closeSourceBranch?: boolean;
331
+ reviewers?: string[];
332
+ }
333
+ ): Promise<BitbucketPullRequest> {
334
+ const payload: Record<string, unknown> = {
335
+ title: options.title,
336
+ source: { branch: { name: options.sourceBranch } },
337
+ destination: { branch: { name: options.destinationBranch || 'main' } },
338
+ close_source_branch: options.closeSourceBranch ?? true,
339
+ };
340
+ if (options.description) {
341
+ payload.description = options.description;
342
+ }
343
+ if (options.reviewers && options.reviewers.length > 0) {
344
+ payload.reviewers = options.reviewers.map(r =>
345
+ r.startsWith('{') ? { uuid: r } : { account_id: r }
346
+ );
347
+ }
348
+
349
+ const result = await this.request<BitbucketPullRequest>(
350
+ 'POST',
351
+ this.repoPath(repoSlug, 'pullrequests'),
352
+ payload
353
+ );
354
+ if (!result) {
355
+ throw new BitbucketError(`Failed to create PR: ${options.sourceBranch} -> ${options.destinationBranch || 'main'}`);
356
+ }
357
+ return result;
358
+ }
359
+
360
+ async getPullRequest(repoSlug: string, prId: number): Promise<BitbucketPullRequest | null> {
361
+ return this.request('GET', this.repoPath(repoSlug, 'pullrequests', String(prId)));
362
+ }
363
+
364
+ async listPullRequests(
365
+ repoSlug: string,
366
+ options: { state?: string; limit?: number } = {}
367
+ ): Promise<BitbucketPullRequest[]> {
368
+ return this.paginatedList(this.repoPath(repoSlug, 'pullrequests'), {
369
+ limit: options.limit || 50,
370
+ maxPage: 50,
371
+ state: options.state || 'OPEN',
372
+ });
373
+ }
374
+
375
+ async mergePullRequest(
376
+ repoSlug: string,
377
+ prId: number,
378
+ options: {
379
+ mergeStrategy?: string;
380
+ closeSourceBranch?: boolean;
381
+ message?: string;
382
+ } = {}
383
+ ): Promise<BitbucketPullRequest> {
384
+ const payload: Record<string, unknown> = {
385
+ type: options.mergeStrategy || 'merge_commit',
386
+ close_source_branch: options.closeSourceBranch ?? true,
387
+ };
388
+ if (options.message) {
389
+ payload.message = options.message;
390
+ }
391
+
392
+ const result = await this.request<BitbucketPullRequest>(
393
+ 'POST',
394
+ this.repoPath(repoSlug, 'pullrequests', String(prId), 'merge'),
395
+ payload
396
+ );
397
+ if (!result) {
398
+ throw new BitbucketError(`Failed to merge PR #${prId}`);
399
+ }
400
+ return result;
401
+ }
402
+
403
+ async listPrComments(
404
+ repoSlug: string,
405
+ prId: number,
406
+ options: { limit?: number } = {}
407
+ ): Promise<BitbucketComment[]> {
408
+ return this.paginatedList(
409
+ this.repoPath(repoSlug, 'pullrequests', String(prId), 'comments'),
410
+ { limit: options.limit || 50 }
411
+ );
412
+ }
413
+
414
+ async addPrComment(
415
+ repoSlug: string,
416
+ prId: number,
417
+ content: string,
418
+ inline?: { path: string; to: number }
419
+ ): Promise<BitbucketComment> {
420
+ const payload: Record<string, unknown> = {
421
+ content: { raw: content },
422
+ };
423
+ if (inline) {
424
+ payload.inline = inline;
425
+ }
426
+
427
+ const result = await this.request<BitbucketComment>(
428
+ 'POST',
429
+ this.repoPath(repoSlug, 'pullrequests', String(prId), 'comments'),
430
+ payload
431
+ );
432
+ if (!result) {
433
+ throw new BitbucketError(`Failed to add comment to PR #${prId}`);
434
+ }
435
+ return result;
436
+ }
437
+
438
+ async approvePr(repoSlug: string, prId: number): Promise<Record<string, unknown>> {
439
+ const result = await this.request<Record<string, unknown>>(
440
+ 'POST',
441
+ this.repoPath(repoSlug, 'pullrequests', String(prId), 'approve')
442
+ );
443
+ if (!result) {
444
+ throw new BitbucketError(`Failed to approve PR #${prId}`);
445
+ }
446
+ return result;
447
+ }
448
+
449
+ async unapprovePr(repoSlug: string, prId: number): Promise<void> {
450
+ await this.request('DELETE', this.repoPath(repoSlug, 'pullrequests', String(prId), 'approve'));
451
+ }
452
+
453
+ async requestChangesPr(repoSlug: string, prId: number): Promise<Record<string, unknown>> {
454
+ const result = await this.request<Record<string, unknown>>(
455
+ 'POST',
456
+ this.repoPath(repoSlug, 'pullrequests', String(prId), 'request-changes')
457
+ );
458
+ if (!result) {
459
+ throw new BitbucketError(`Failed to request changes on PR #${prId}`);
460
+ }
461
+ return result;
462
+ }
463
+
464
+ async declinePr(repoSlug: string, prId: number): Promise<BitbucketPullRequest> {
465
+ const result = await this.request<BitbucketPullRequest>(
466
+ 'POST',
467
+ this.repoPath(repoSlug, 'pullrequests', String(prId), 'decline')
468
+ );
469
+ if (!result) {
470
+ throw new BitbucketError(`Failed to decline PR #${prId}`);
471
+ }
472
+ return result;
473
+ }
474
+
475
+ async getPrDiff(repoSlug: string, prId: number): Promise<string> {
476
+ return (await this.requestText(this.repoPath(repoSlug, 'pullrequests', String(prId), 'diff'))) || '';
477
+ }
478
+
479
+ // ==================== PIPELINES ====================
480
+
481
+ /**
482
+ * Build the pipeline target object based on options.
483
+ * Supports branch triggers, commit triggers, and custom pipelines.
484
+ */
485
+ private buildPipelineTarget(options: TriggerPipelineOptions): Record<string, unknown> {
486
+ // Validate mutual exclusivity of branch and commit
487
+ if (options.branch && options.commit) {
488
+ throw new BitbucketError('Cannot specify both branch and commit - they are mutually exclusive');
489
+ }
490
+
491
+ // Build target based on trigger type
492
+ if (options.commit) {
493
+ // Commit-based trigger
494
+ const target: Record<string, unknown> = {
495
+ type: 'pipeline_commit_target',
496
+ commit: { hash: options.commit },
497
+ };
498
+ // Add selector for custom pipeline if specified
499
+ if (options.customPipeline) {
500
+ target.selector = {
501
+ type: 'custom',
502
+ pattern: options.customPipeline,
503
+ };
504
+ }
505
+ return target;
506
+ }
507
+
508
+ // Branch-based trigger (default)
509
+ const target: Record<string, unknown> = {
510
+ type: 'pipeline_ref_target',
511
+ ref_type: 'branch',
512
+ ref_name: options.branch || 'main',
513
+ };
514
+ // Add selector for custom pipeline if specified
515
+ if (options.customPipeline) {
516
+ target.selector = {
517
+ type: 'custom',
518
+ pattern: options.customPipeline,
519
+ };
520
+ }
521
+ return target;
522
+ }
523
+
524
+ /**
525
+ * Normalize pipeline variables to the array format expected by the API.
526
+ * Supports both array format (with secured flag) and simple object format.
527
+ */
528
+ private normalizePipelineVariables(
529
+ variables?: PipelineTriggerVariable[] | Record<string, string>
530
+ ): { key: string; value: string; secured?: boolean }[] | undefined {
531
+ if (!variables) {
532
+ return undefined;
533
+ }
534
+
535
+ // If already an array, return as-is (with secured flag preserved)
536
+ if (Array.isArray(variables)) {
537
+ return variables.map(v => ({
538
+ key: v.key,
539
+ value: v.value,
540
+ ...(v.secured !== undefined && { secured: v.secured }),
541
+ }));
542
+ }
543
+
544
+ // Convert object format to array format (without secured flag)
545
+ return Object.entries(variables).map(([key, value]) => ({
546
+ key,
547
+ value,
548
+ }));
549
+ }
550
+
551
+ async triggerPipeline(
552
+ repoSlug: string,
553
+ options: TriggerPipelineOptions = {}
554
+ ): Promise<BitbucketPipeline> {
555
+ const payload: Record<string, unknown> = {
556
+ target: this.buildPipelineTarget(options),
557
+ };
558
+
559
+ const normalizedVariables = this.normalizePipelineVariables(options.variables);
560
+ if (normalizedVariables && normalizedVariables.length > 0) {
561
+ payload.variables = normalizedVariables;
562
+ }
563
+
564
+ const result = await this.request<BitbucketPipeline>(
565
+ 'POST',
566
+ `${this.repoPath(repoSlug, 'pipelines')}/`,
567
+ payload
568
+ );
569
+
570
+ const targetDesc = options.commit
571
+ ? `commit ${options.commit}`
572
+ : (options.branch || 'main');
573
+ const pipelineDesc = options.customPipeline
574
+ ? `custom:${options.customPipeline}`
575
+ : 'default';
576
+
577
+ if (!result) {
578
+ throw new BitbucketError(`Failed to trigger ${pipelineDesc} pipeline on ${targetDesc}`);
579
+ }
580
+ return result;
581
+ }
582
+
583
+ async getPipeline(repoSlug: string, pipelineUuid: string): Promise<BitbucketPipeline | null> {
584
+ return this.request('GET', this.repoPath(repoSlug, 'pipelines', ensureUuidBraces(pipelineUuid)));
585
+ }
586
+
587
+ async listPipelines(
588
+ repoSlug: string,
589
+ options: { limit?: number } = {}
590
+ ): Promise<BitbucketPipeline[]> {
591
+ return this.paginatedList(`${this.repoPath(repoSlug, 'pipelines')}/`, {
592
+ limit: options.limit || 10,
593
+ sort: '-created_on',
594
+ });
595
+ }
596
+
597
+ async getPipelineSteps(
598
+ repoSlug: string,
599
+ pipelineUuid: string
600
+ ): Promise<BitbucketPipelineStep[]> {
601
+ return this.paginatedList(
602
+ `${this.repoPath(repoSlug, 'pipelines', ensureUuidBraces(pipelineUuid), 'steps')}/`
603
+ );
604
+ }
605
+
606
+ async getPipelineLogs(
607
+ repoSlug: string,
608
+ pipelineUuid: string,
609
+ stepUuid: string
610
+ ): Promise<string> {
611
+ const path = this.repoPath(
612
+ repoSlug,
613
+ 'pipelines',
614
+ ensureUuidBraces(pipelineUuid),
615
+ 'steps',
616
+ ensureUuidBraces(stepUuid),
617
+ 'log'
618
+ );
619
+ return (await this.requestText(path)) || '';
620
+ }
621
+
622
+ async stopPipeline(repoSlug: string, pipelineUuid: string): Promise<BitbucketPipeline> {
623
+ await this.request(
624
+ 'POST',
625
+ this.repoPath(repoSlug, 'pipelines', ensureUuidBraces(pipelineUuid), 'stopPipeline')
626
+ );
627
+ const result = await this.getPipeline(repoSlug, pipelineUuid);
628
+ return result || { uuid: pipelineUuid, state: { name: 'STOPPED' } };
629
+ }
630
+
631
+ // ==================== PIPELINE VARIABLES ====================
632
+
633
+ async listPipelineVariables(
634
+ repoSlug: string,
635
+ options: { limit?: number } = {}
636
+ ): Promise<BitbucketPipelineVariable[]> {
637
+ return this.paginatedList(
638
+ this.repoPath(repoSlug, 'pipelines_config', 'variables'),
639
+ { limit: options.limit || 50 }
640
+ );
641
+ }
642
+
643
+ async getPipelineVariable(
644
+ repoSlug: string,
645
+ variableUuid: string
646
+ ): Promise<BitbucketPipelineVariable | null> {
647
+ return this.request(
648
+ 'GET',
649
+ this.repoPath(repoSlug, 'pipelines_config', 'variables', ensureUuidBraces(variableUuid))
650
+ );
651
+ }
652
+
653
+ async createPipelineVariable(
654
+ repoSlug: string,
655
+ key: string,
656
+ value: string,
657
+ secured: boolean = false
658
+ ): Promise<BitbucketPipelineVariable> {
659
+ const result = await this.request<BitbucketPipelineVariable>(
660
+ 'POST',
661
+ `${this.repoPath(repoSlug, 'pipelines_config', 'variables')}/`,
662
+ { key, value, secured }
663
+ );
664
+ if (!result) {
665
+ throw new BitbucketError('Failed to create pipeline variable');
666
+ }
667
+ return result;
668
+ }
669
+
670
+ async updatePipelineVariable(
671
+ repoSlug: string,
672
+ variableUuid: string,
673
+ value: string
674
+ ): Promise<BitbucketPipelineVariable> {
675
+ const result = await this.request<BitbucketPipelineVariable>(
676
+ 'PUT',
677
+ this.repoPath(repoSlug, 'pipelines_config', 'variables', ensureUuidBraces(variableUuid)),
678
+ { value }
679
+ );
680
+ if (!result) {
681
+ throw new BitbucketError('Failed to update pipeline variable');
682
+ }
683
+ return result;
684
+ }
685
+
686
+ async deletePipelineVariable(repoSlug: string, variableUuid: string): Promise<void> {
687
+ await this.request(
688
+ 'DELETE',
689
+ this.repoPath(repoSlug, 'pipelines_config', 'variables', ensureUuidBraces(variableUuid))
690
+ );
691
+ }
692
+
693
+ // ==================== BRANCHES ====================
694
+
695
+ async listBranches(
696
+ repoSlug: string,
697
+ options: { limit?: number } = {}
698
+ ): Promise<BitbucketBranch[]> {
699
+ return this.paginatedList(this.repoPath(repoSlug, 'refs', 'branches'), {
700
+ limit: options.limit || 50,
701
+ });
702
+ }
703
+
704
+ async getBranch(repoSlug: string, branchName: string): Promise<BitbucketBranch | null> {
705
+ return this.request('GET', this.repoPath(repoSlug, 'refs', 'branches', branchName));
706
+ }
707
+
708
+ // ==================== COMMITS ====================
709
+
710
+ async listCommits(
711
+ repoSlug: string,
712
+ options: { branch?: string; path?: string; limit?: number } = {}
713
+ ): Promise<BitbucketCommit[]> {
714
+ return this.paginatedList(this.repoPath(repoSlug, 'commits'), {
715
+ limit: options.limit || 20,
716
+ include: options.branch,
717
+ path: options.path,
718
+ });
719
+ }
720
+
721
+ async getCommit(repoSlug: string, commit: string): Promise<BitbucketCommit | null> {
722
+ return this.request('GET', this.repoPath(repoSlug, 'commit', commit));
723
+ }
724
+
725
+ async compareCommits(
726
+ repoSlug: string,
727
+ base: string,
728
+ head: string
729
+ ): Promise<Record<string, unknown> | null> {
730
+ return this.request('GET', this.repoPath(repoSlug, 'diffstat', `${base}..${head}`));
731
+ }
732
+
733
+ async getCommitStatuses(
734
+ repoSlug: string,
735
+ commit: string,
736
+ options: { limit?: number } = {}
737
+ ): Promise<BitbucketCommitStatus[]> {
738
+ return this.paginatedList(this.repoPath(repoSlug, 'commit', commit, 'statuses'), {
739
+ limit: options.limit || 20,
740
+ });
741
+ }
742
+
743
+ async createCommitStatus(
744
+ repoSlug: string,
745
+ commit: string,
746
+ options: {
747
+ state: string;
748
+ key: string;
749
+ url?: string;
750
+ name?: string;
751
+ description?: string;
752
+ }
753
+ ): Promise<BitbucketCommitStatus> {
754
+ const payload: Record<string, unknown> = {
755
+ state: options.state,
756
+ key: options.key,
757
+ };
758
+ if (options.url) payload.url = options.url;
759
+ if (options.name) payload.name = options.name;
760
+ if (options.description) payload.description = options.description;
761
+
762
+ const result = await this.request<BitbucketCommitStatus>(
763
+ 'POST',
764
+ this.repoPath(repoSlug, 'commit', commit, 'statuses', 'build'),
765
+ payload
766
+ );
767
+ if (!result) {
768
+ throw new BitbucketError(`Failed to create status for commit ${commit}`);
769
+ }
770
+ return result;
771
+ }
772
+
773
+ // ==================== PROJECTS ====================
774
+
775
+ async listProjects(options: { limit?: number } = {}): Promise<BitbucketProject[]> {
776
+ return this.paginatedList(`workspaces/${this.workspace}/projects`, {
777
+ limit: options.limit || 50,
778
+ });
779
+ }
780
+
781
+ async getProject(projectKey: string): Promise<BitbucketProject | null> {
782
+ return this.request('GET', `workspaces/${this.workspace}/projects/${projectKey}`);
783
+ }
784
+
785
+ // ==================== DEPLOYMENTS ====================
786
+
787
+ async listEnvironments(
788
+ repoSlug: string,
789
+ options: { limit?: number } = {}
790
+ ): Promise<BitbucketEnvironment[]> {
791
+ return this.paginatedList(this.repoPath(repoSlug, 'environments'), {
792
+ limit: options.limit || 20,
793
+ });
794
+ }
795
+
796
+ async getEnvironment(
797
+ repoSlug: string,
798
+ environmentUuid: string
799
+ ): Promise<BitbucketEnvironment | null> {
800
+ return this.request(
801
+ 'GET',
802
+ this.repoPath(repoSlug, 'environments', ensureUuidBraces(environmentUuid))
803
+ );
804
+ }
805
+
806
+ async listDeploymentHistory(
807
+ repoSlug: string,
808
+ environmentUuid: string,
809
+ options: { limit?: number } = {}
810
+ ): Promise<BitbucketDeployment[]> {
811
+ return this.paginatedList(this.repoPath(repoSlug, 'deployments'), {
812
+ limit: options.limit || 20,
813
+ environment: ensureUuidBraces(environmentUuid),
814
+ sort: '-state.started_on',
815
+ });
816
+ }
817
+
818
+ // ==================== WEBHOOKS ====================
819
+
820
+ async listWebhooks(
821
+ repoSlug: string,
822
+ options: { limit?: number } = {}
823
+ ): Promise<BitbucketWebhook[]> {
824
+ return this.paginatedList(this.repoPath(repoSlug, 'hooks'), {
825
+ limit: options.limit || 50,
826
+ });
827
+ }
828
+
829
+ async createWebhook(
830
+ repoSlug: string,
831
+ options: {
832
+ url: string;
833
+ events: string[];
834
+ description?: string;
835
+ active?: boolean;
836
+ }
837
+ ): Promise<BitbucketWebhook> {
838
+ const payload: Record<string, unknown> = {
839
+ url: options.url,
840
+ events: options.events,
841
+ active: options.active ?? true,
842
+ };
843
+ if (options.description) {
844
+ payload.description = options.description;
845
+ }
846
+
847
+ const result = await this.request<BitbucketWebhook>(
848
+ 'POST',
849
+ this.repoPath(repoSlug, 'hooks'),
850
+ payload
851
+ );
852
+ if (!result) {
853
+ throw new BitbucketError('Failed to create webhook');
854
+ }
855
+ return result;
856
+ }
857
+
858
+ async getWebhook(repoSlug: string, webhookUid: string): Promise<BitbucketWebhook | null> {
859
+ return this.request('GET', this.repoPath(repoSlug, 'hooks', ensureUuidBraces(webhookUid)));
860
+ }
861
+
862
+ async deleteWebhook(repoSlug: string, webhookUid: string): Promise<void> {
863
+ await this.request('DELETE', this.repoPath(repoSlug, 'hooks', ensureUuidBraces(webhookUid)));
864
+ }
865
+
866
+ // ==================== TAGS ====================
867
+
868
+ async listTags(repoSlug: string, options: { limit?: number } = {}): Promise<BitbucketTag[]> {
869
+ return this.paginatedList(this.repoPath(repoSlug, 'refs', 'tags'), {
870
+ limit: options.limit || 50,
871
+ sort: '-target.date',
872
+ });
873
+ }
874
+
875
+ async createTag(
876
+ repoSlug: string,
877
+ name: string,
878
+ target: string,
879
+ message?: string
880
+ ): Promise<BitbucketTag> {
881
+ const payload: Record<string, unknown> = {
882
+ name,
883
+ target: { hash: target },
884
+ };
885
+ if (message) {
886
+ payload.message = message;
887
+ }
888
+
889
+ const result = await this.request<BitbucketTag>(
890
+ 'POST',
891
+ this.repoPath(repoSlug, 'refs', 'tags'),
892
+ payload
893
+ );
894
+ if (!result) {
895
+ throw new BitbucketError(`Failed to create tag ${name}`);
896
+ }
897
+ return result;
898
+ }
899
+
900
+ async deleteTag(repoSlug: string, tagName: string): Promise<void> {
901
+ await this.request('DELETE', this.repoPath(repoSlug, 'refs', 'tags', tagName));
902
+ }
903
+
904
+ // ==================== BRANCH RESTRICTIONS ====================
905
+
906
+ async listBranchRestrictions(
907
+ repoSlug: string,
908
+ options: { limit?: number } = {}
909
+ ): Promise<BitbucketBranchRestriction[]> {
910
+ return this.paginatedList(this.repoPath(repoSlug, 'branch-restrictions'), {
911
+ limit: options.limit || 50,
912
+ });
913
+ }
914
+
915
+ async createBranchRestriction(
916
+ repoSlug: string,
917
+ options: {
918
+ kind: string;
919
+ pattern?: string;
920
+ branchMatchKind?: string;
921
+ branchType?: string;
922
+ value?: number;
923
+ }
924
+ ): Promise<BitbucketBranchRestriction> {
925
+ const payload: Record<string, unknown> = {
926
+ kind: options.kind,
927
+ branch_match_kind: options.branchMatchKind || 'glob',
928
+ };
929
+ if (options.branchMatchKind === 'glob' && options.pattern) {
930
+ payload.pattern = options.pattern;
931
+ }
932
+ if (options.branchMatchKind === 'branching_model' && options.branchType) {
933
+ payload.branch_type = options.branchType;
934
+ }
935
+ if (options.value !== undefined) {
936
+ payload.value = options.value;
937
+ }
938
+
939
+ const result = await this.request<BitbucketBranchRestriction>(
940
+ 'POST',
941
+ this.repoPath(repoSlug, 'branch-restrictions'),
942
+ payload
943
+ );
944
+ if (!result) {
945
+ throw new BitbucketError(`Failed to create branch restriction ${options.kind}`);
946
+ }
947
+ return result;
948
+ }
949
+
950
+ async deleteBranchRestriction(repoSlug: string, restrictionId: number): Promise<void> {
951
+ await this.request(
952
+ 'DELETE',
953
+ this.repoPath(repoSlug, 'branch-restrictions', String(restrictionId))
954
+ );
955
+ }
956
+
957
+ // ==================== SOURCE ====================
958
+
959
+ async getFileContent(
960
+ repoSlug: string,
961
+ path: string,
962
+ ref: string = 'main'
963
+ ): Promise<string | null> {
964
+ return this.requestText(this.repoPath(repoSlug, 'src', ref, path));
965
+ }
966
+
967
+ async listDirectory(
968
+ repoSlug: string,
969
+ path: string = '',
970
+ options: { ref?: string; limit?: number } = {}
971
+ ): Promise<DirectoryEntry[]> {
972
+ const endpoint = path
973
+ ? this.repoPath(repoSlug, 'src', options.ref || 'main', path)
974
+ : this.repoPath(repoSlug, 'src', options.ref || 'main');
975
+ return this.paginatedList(endpoint, { limit: options.limit || 100 });
976
+ }
977
+
978
+ // ==================== PERMISSIONS ====================
979
+
980
+ async listUserPermissions(
981
+ repoSlug: string,
982
+ options: { limit?: number } = {}
983
+ ): Promise<UserPermission[]> {
984
+ return this.paginatedList(this.repoPath(repoSlug, 'permissions-config', 'users'), {
985
+ limit: options.limit || 50,
986
+ });
987
+ }
988
+
989
+ async getUserPermission(
990
+ repoSlug: string,
991
+ selectedUser: string
992
+ ): Promise<UserPermission | null> {
993
+ return this.request(
994
+ 'GET',
995
+ this.repoPath(repoSlug, 'permissions-config', 'users', selectedUser)
996
+ );
997
+ }
998
+
999
+ async updateUserPermission(
1000
+ repoSlug: string,
1001
+ selectedUser: string,
1002
+ permission: string
1003
+ ): Promise<UserPermission> {
1004
+ const result = await this.request<UserPermission>(
1005
+ 'PUT',
1006
+ this.repoPath(repoSlug, 'permissions-config', 'users', selectedUser),
1007
+ { permission }
1008
+ );
1009
+ if (!result) {
1010
+ throw new BitbucketError(`Failed to update permission for user ${selectedUser}`);
1011
+ }
1012
+ return result;
1013
+ }
1014
+
1015
+ async deleteUserPermission(repoSlug: string, selectedUser: string): Promise<void> {
1016
+ await this.request(
1017
+ 'DELETE',
1018
+ this.repoPath(repoSlug, 'permissions-config', 'users', selectedUser)
1019
+ );
1020
+ }
1021
+
1022
+ async listGroupPermissions(
1023
+ repoSlug: string,
1024
+ options: { limit?: number } = {}
1025
+ ): Promise<GroupPermission[]> {
1026
+ return this.paginatedList(this.repoPath(repoSlug, 'permissions-config', 'groups'), {
1027
+ limit: options.limit || 50,
1028
+ });
1029
+ }
1030
+
1031
+ async getGroupPermission(
1032
+ repoSlug: string,
1033
+ groupSlug: string
1034
+ ): Promise<GroupPermission | null> {
1035
+ return this.request(
1036
+ 'GET',
1037
+ this.repoPath(repoSlug, 'permissions-config', 'groups', groupSlug)
1038
+ );
1039
+ }
1040
+
1041
+ async updateGroupPermission(
1042
+ repoSlug: string,
1043
+ groupSlug: string,
1044
+ permission: string
1045
+ ): Promise<GroupPermission> {
1046
+ const result = await this.request<GroupPermission>(
1047
+ 'PUT',
1048
+ this.repoPath(repoSlug, 'permissions-config', 'groups', groupSlug),
1049
+ { permission }
1050
+ );
1051
+ if (!result) {
1052
+ throw new BitbucketError(`Failed to update permission for group ${groupSlug}`);
1053
+ }
1054
+ return result;
1055
+ }
1056
+
1057
+ async deleteGroupPermission(repoSlug: string, groupSlug: string): Promise<void> {
1058
+ await this.request(
1059
+ 'DELETE',
1060
+ this.repoPath(repoSlug, 'permissions-config', 'groups', groupSlug)
1061
+ );
1062
+ }
1063
+
1064
+ // ==================== UTILITIES ====================
1065
+
1066
+ extractPrUrl(pr: BitbucketPullRequest): string {
1067
+ return pr.links?.html?.href || '';
1068
+ }
1069
+
1070
+ extractCloneUrls(repo: BitbucketRepository): Record<string, string> {
1071
+ const urls: Record<string, string> = {};
1072
+ for (const link of repo.links?.clone || []) {
1073
+ const name = (link.name || '').toLowerCase();
1074
+ if (name === 'https' || name === 'ssh') {
1075
+ urls[name] = link.href || '';
1076
+ }
1077
+ }
1078
+ urls.html = repo.links?.html?.href || '';
1079
+ return urls;
1080
+ }
1081
+ }
1082
+
1083
+ // Singleton instance
1084
+ let clientInstance: BitbucketClient | null = null;
1085
+
1086
+ /**
1087
+ * Get or create the BitbucketClient singleton
1088
+ */
1089
+ export function getClient(): BitbucketClient {
1090
+ if (!clientInstance) {
1091
+ clientInstance = new BitbucketClient();
1092
+ }
1093
+ return clientInstance;
1094
+ }
1095
+
1096
+ /**
1097
+ * Reset the client singleton (useful for testing)
1098
+ */
1099
+ export function resetClient(): void {
1100
+ clientInstance = null;
1101
+ }
1102
+