deepdebug-local-agent 0.3.1 → 0.3.2

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.
@@ -0,0 +1,723 @@
1
+ import { BaseGitProvider } from './base-git-provider.js';
2
+ import { run } from '../utils/exec-utils.js';
3
+
4
+ /**
5
+ * BitbucketProvider
6
+ *
7
+ * Implementação do Git provider para Bitbucket Cloud.
8
+ *
9
+ * Bitbucket API 2.0: https://developer.atlassian.com/cloud/bitbucket/rest/intro/
10
+ *
11
+ * Authentication:
12
+ * - App Password: Basic auth with username:app_password
13
+ * - OAuth 2.0: Bearer token
14
+ * - Repository Access Token: Bearer token (scoped to repo)
15
+ *
16
+ * Key differences from GitHub:
17
+ * - API base: https://api.bitbucket.org/2.0
18
+ * - Uses "workspace/repo_slug" instead of "owner/repo"
19
+ * - Pull Requests are at /pullrequests (not /pulls)
20
+ * - Branch creation is via refs/branches endpoint
21
+ * - Paginated responses use { values: [], next: url }
22
+ * - File content at /src endpoint (not /contents)
23
+ */
24
+ export class BitbucketProvider extends BaseGitProvider {
25
+
26
+ constructor(config = {}) {
27
+ super(config);
28
+ this.clientId = config.clientId || process.env.BITBUCKET_CLIENT_ID;
29
+ this.clientSecret = config.clientSecret || process.env.BITBUCKET_CLIENT_SECRET;
30
+ // Bitbucket App Password auth requires username
31
+ this.username = config.username || process.env.BITBUCKET_USERNAME;
32
+ // Auth mode: 'app_password' (Basic) or 'oauth' (Bearer)
33
+ this.authMode = config.authMode || 'app_password';
34
+ }
35
+
36
+ // ==========================================
37
+ // IDENTIFICATION
38
+ // ==========================================
39
+
40
+ getId() {
41
+ return 'bitbucket';
42
+ }
43
+
44
+ getName() {
45
+ return 'Bitbucket';
46
+ }
47
+
48
+ getApiBaseUrl() {
49
+ return 'https://api.bitbucket.org/2.0';
50
+ }
51
+
52
+ // ==========================================
53
+ // AUTHENTICATION
54
+ // ==========================================
55
+
56
+ getAuthorizationUrl(redirectUri, state) {
57
+ const params = new URLSearchParams({
58
+ client_id: this.clientId,
59
+ redirect_uri: redirectUri,
60
+ response_type: 'code',
61
+ state
62
+ });
63
+
64
+ return `https://bitbucket.org/site/oauth2/authorize?${params}`;
65
+ }
66
+
67
+ async exchangeCodeForToken(code, redirectUri) {
68
+ const response = await fetch('https://bitbucket.org/site/oauth2/access_token', {
69
+ method: 'POST',
70
+ headers: {
71
+ 'Content-Type': 'application/x-www-form-urlencoded',
72
+ 'Authorization': 'Basic ' + Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')
73
+ },
74
+ body: new URLSearchParams({
75
+ grant_type: 'authorization_code',
76
+ code,
77
+ redirect_uri: redirectUri
78
+ }).toString()
79
+ });
80
+
81
+ const data = await response.json();
82
+
83
+ if (data.error) {
84
+ throw new Error(data.error_description || data.error);
85
+ }
86
+
87
+ this.accessToken = data.access_token;
88
+ this.authMode = 'oauth';
89
+
90
+ return {
91
+ accessToken: data.access_token,
92
+ tokenType: data.token_type,
93
+ refreshToken: data.refresh_token,
94
+ expiresIn: data.expires_in
95
+ };
96
+ }
97
+
98
+ async validateToken() {
99
+ try {
100
+ await this.getCurrentUser();
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ // ==========================================
108
+ // API HELPERS
109
+ // ==========================================
110
+
111
+ /**
112
+ * Build authorization headers based on auth mode.
113
+ *
114
+ * App Password: Basic base64(username:app_password)
115
+ * OAuth/Token: Bearer <token>
116
+ */
117
+ _getAuthHeaders() {
118
+ if (this.authMode === 'app_password' && this.username) {
119
+ const credentials = Buffer.from(`${this.username}:${this.accessToken}`).toString('base64');
120
+ return { 'Authorization': `Basic ${credentials}` };
121
+ }
122
+ return { 'Authorization': `Bearer ${this.accessToken}` };
123
+ }
124
+
125
+ async apiRequest(endpoint, options = {}) {
126
+ const url = endpoint.startsWith('http')
127
+ ? endpoint
128
+ : `${this.getApiBaseUrl()}${endpoint}`;
129
+
130
+ const response = await fetch(url, {
131
+ ...options,
132
+ headers: {
133
+ ...this._getAuthHeaders(),
134
+ 'Accept': 'application/json',
135
+ 'Content-Type': 'application/json',
136
+ ...options.headers
137
+ }
138
+ });
139
+
140
+ if (!response.ok) {
141
+ const error = await response.json().catch(() => ({}));
142
+ const msg = error.error?.message || error.error || `Bitbucket API error: ${response.status}`;
143
+ throw new Error(msg);
144
+ }
145
+
146
+ // Some endpoints (DELETE) return 204 No Content
147
+ if (response.status === 204) {
148
+ return {};
149
+ }
150
+
151
+ return response.json();
152
+ }
153
+
154
+ // ==========================================
155
+ // USER
156
+ // ==========================================
157
+
158
+ async getCurrentUser() {
159
+ const user = await this.apiRequest('/user');
160
+
161
+ return {
162
+ id: user.uuid,
163
+ username: user.username,
164
+ email: user.email || null,
165
+ name: user.display_name,
166
+ avatarUrl: user.links?.avatar?.href || null
167
+ };
168
+ }
169
+
170
+ // ==========================================
171
+ // REPOSITORIES
172
+ // ==========================================
173
+
174
+ async listRepositories(options = {}) {
175
+ const { page = 1, perPage = 25, sort = '-updated_on' } = options;
176
+
177
+ // Bitbucket uses workspace-based listing
178
+ const user = await this.getCurrentUser();
179
+ const data = await this.apiRequest(
180
+ `/repositories/${user.username}?page=${page}&pagelen=${perPage}&sort=${sort}`
181
+ );
182
+
183
+ return (data.values || []).map(repo => ({
184
+ id: repo.uuid,
185
+ name: repo.slug,
186
+ fullName: repo.full_name,
187
+ description: repo.description,
188
+ defaultBranch: repo.mainbranch?.name || 'main',
189
+ private: repo.is_private,
190
+ cloneUrl: this._extractCloneUrl(repo.links?.clone, 'https'),
191
+ htmlUrl: repo.links?.html?.href
192
+ }));
193
+ }
194
+
195
+ async getRepository(workspace, repoSlug) {
196
+ const data = await this.apiRequest(`/repositories/${workspace}/${repoSlug}`);
197
+
198
+ return {
199
+ id: data.uuid,
200
+ name: data.slug,
201
+ fullName: data.full_name,
202
+ description: data.description,
203
+ defaultBranch: data.mainbranch?.name || 'main',
204
+ private: data.is_private,
205
+ cloneUrl: this._extractCloneUrl(data.links?.clone, 'https'),
206
+ htmlUrl: data.links?.html?.href
207
+ };
208
+ }
209
+
210
+ async cloneRepository(workspace, repoSlug, destPath, options = {}) {
211
+ const { branch, depth = 1 } = options;
212
+
213
+ const cloneUrl = this.getCloneUrl(workspace, repoSlug, true);
214
+
215
+ // Insert credentials in URL for authentication
216
+ let authedUrl;
217
+ if (this.authMode === 'app_password' && this.username) {
218
+ authedUrl = cloneUrl.replace(
219
+ 'https://',
220
+ `https://${this.username}:${this.accessToken}@`
221
+ );
222
+ } else {
223
+ authedUrl = cloneUrl.replace(
224
+ 'https://',
225
+ `https://x-token-auth:${this.accessToken}@`
226
+ );
227
+ }
228
+
229
+ const args = ['clone'];
230
+
231
+ if (depth) {
232
+ args.push('--depth', String(depth));
233
+ }
234
+
235
+ if (branch) {
236
+ args.push('--branch', branch);
237
+ }
238
+
239
+ args.push(authedUrl, destPath);
240
+
241
+ const result = await run('git', args);
242
+
243
+ return {
244
+ success: result.code === 0,
245
+ path: destPath,
246
+ output: result.stdout + result.stderr
247
+ };
248
+ }
249
+
250
+ // ==========================================
251
+ // BRANCHES
252
+ // ==========================================
253
+
254
+ async listBranches(workspace, repoSlug) {
255
+ const data = await this.apiRequest(`/repositories/${workspace}/${repoSlug}/refs/branches`);
256
+
257
+ return (data.values || []).map(branch => ({
258
+ name: branch.name,
259
+ sha: branch.target?.hash,
260
+ protected: branch.name === branch.target?.repository?.mainbranch?.name
261
+ }));
262
+ }
263
+
264
+ async createBranch(workspace, repoSlug, branchName, fromRef) {
265
+ const data = await this.apiRequest(`/repositories/${workspace}/${repoSlug}/refs/branches`, {
266
+ method: 'POST',
267
+ body: JSON.stringify({
268
+ name: branchName,
269
+ target: {
270
+ hash: fromRef
271
+ }
272
+ })
273
+ });
274
+
275
+ return {
276
+ name: data.name,
277
+ sha: data.target?.hash,
278
+ protected: false
279
+ };
280
+ }
281
+
282
+ async getBranch(workspace, repoSlug, branch) {
283
+ const data = await this.apiRequest(`/repositories/${workspace}/${repoSlug}/refs/branches/${branch}`);
284
+
285
+ return {
286
+ name: data.name,
287
+ sha: data.target?.hash,
288
+ protected: false
289
+ };
290
+ }
291
+
292
+ // ==========================================
293
+ // COMMITS
294
+ // ==========================================
295
+
296
+ async listCommits(workspace, repoSlug, branch, options = {}) {
297
+ const { page = 1, perPage = 30 } = options;
298
+
299
+ const data = await this.apiRequest(
300
+ `/repositories/${workspace}/${repoSlug}/commits/${branch}?page=${page}&pagelen=${perPage}`
301
+ );
302
+
303
+ return (data.values || []).map(commit => ({
304
+ sha: commit.hash,
305
+ message: commit.message,
306
+ authorName: commit.author?.raw?.split('<')[0]?.trim() || commit.author?.user?.display_name || 'Unknown',
307
+ authorEmail: commit.author?.raw?.match(/<(.+?)>/)?.[1] || '',
308
+ date: new Date(commit.date)
309
+ }));
310
+ }
311
+
312
+ async createCommit(workspace, repoSlug, branch, message, changes) {
313
+ // Bitbucket uses the /src endpoint with multipart form data for commits
314
+ // For simplicity, we use the API to push individual file changes
315
+ const formData = new FormData();
316
+
317
+ formData.append('message', message);
318
+ formData.append('branch', branch);
319
+
320
+ for (const change of changes) {
321
+ if (change.action === 'delete') {
322
+ formData.append('files', change.path);
323
+ } else {
324
+ formData.append(change.path, new Blob([change.content], { type: 'text/plain' }));
325
+ }
326
+ }
327
+
328
+ const url = `${this.getApiBaseUrl()}/repositories/${workspace}/${repoSlug}/src`;
329
+
330
+ const response = await fetch(url, {
331
+ method: 'POST',
332
+ headers: this._getAuthHeaders(),
333
+ body: formData
334
+ });
335
+
336
+ if (!response.ok) {
337
+ const error = await response.json().catch(() => ({}));
338
+ throw new Error(error.error?.message || `Bitbucket commit failed: ${response.status}`);
339
+ }
340
+
341
+ // Bitbucket returns 201 with no body on success, get latest commit
342
+ const commits = await this.listCommits(workspace, repoSlug, branch, { perPage: 1 });
343
+ const latestCommit = commits[0] || {};
344
+
345
+ return {
346
+ sha: latestCommit.sha || 'unknown',
347
+ message: message,
348
+ authorName: latestCommit.authorName || '',
349
+ authorEmail: latestCommit.authorEmail || '',
350
+ date: latestCommit.date || new Date()
351
+ };
352
+ }
353
+
354
+ // ==========================================
355
+ // FILES
356
+ // ==========================================
357
+
358
+ async getFileContent(workspace, repoSlug, filePath, ref = 'main') {
359
+ // Bitbucket /src endpoint returns raw file content
360
+ const url = `${this.getApiBaseUrl()}/repositories/${workspace}/${repoSlug}/src/${ref}/${filePath}`;
361
+
362
+ const response = await fetch(url, {
363
+ headers: this._getAuthHeaders()
364
+ });
365
+
366
+ if (!response.ok) {
367
+ throw new Error(`File not found: ${filePath} (${response.status})`);
368
+ }
369
+
370
+ const content = await response.text();
371
+
372
+ return {
373
+ path: filePath,
374
+ content: content,
375
+ sha: null, // Bitbucket doesn't return SHA for file content
376
+ encoding: 'utf-8'
377
+ };
378
+ }
379
+
380
+ async createOrUpdateFile(workspace, repoSlug, filePath, content, message, branch, sha = null) {
381
+ // Bitbucket uses /src endpoint with form data
382
+ const formData = new FormData();
383
+ formData.append('message', message);
384
+ formData.append('branch', branch);
385
+ formData.append(filePath, new Blob([content], { type: 'text/plain' }));
386
+
387
+ const url = `${this.getApiBaseUrl()}/repositories/${workspace}/${repoSlug}/src`;
388
+
389
+ const response = await fetch(url, {
390
+ method: 'POST',
391
+ headers: this._getAuthHeaders(),
392
+ body: formData
393
+ });
394
+
395
+ if (!response.ok) {
396
+ const error = await response.json().catch(() => ({}));
397
+ throw new Error(error.error?.message || `Failed to update file: ${response.status}`);
398
+ }
399
+
400
+ return {
401
+ path: filePath,
402
+ sha: null,
403
+ commit: { message }
404
+ };
405
+ }
406
+
407
+ // ==========================================
408
+ // PULL REQUESTS
409
+ // ==========================================
410
+
411
+ async listPullRequests(workspace, repoSlug, options = {}) {
412
+ const { state = 'OPEN', page = 1, perPage = 25 } = options;
413
+
414
+ const data = await this.apiRequest(
415
+ `/repositories/${workspace}/${repoSlug}/pullrequests?state=${state}&page=${page}&pagelen=${perPage}`
416
+ );
417
+
418
+ return (data.values || []).map(pr => ({
419
+ number: pr.id,
420
+ title: pr.title,
421
+ body: pr.description,
422
+ state: pr.state,
423
+ head: pr.source?.branch?.name,
424
+ base: pr.destination?.branch?.name,
425
+ htmlUrl: pr.links?.html?.href
426
+ }));
427
+ }
428
+
429
+ async createPullRequest(workspace, repoSlug, pr) {
430
+ const data = await this.apiRequest(`/repositories/${workspace}/${repoSlug}/pullrequests`, {
431
+ method: 'POST',
432
+ body: JSON.stringify({
433
+ title: pr.title,
434
+ description: pr.body || '',
435
+ source: {
436
+ branch: { name: pr.head }
437
+ },
438
+ destination: {
439
+ branch: { name: pr.base }
440
+ },
441
+ close_source_branch: true
442
+ })
443
+ });
444
+
445
+ return {
446
+ number: data.id,
447
+ title: data.title,
448
+ body: data.description,
449
+ state: data.state,
450
+ head: data.source?.branch?.name,
451
+ base: data.destination?.branch?.name,
452
+ htmlUrl: data.links?.html?.href
453
+ };
454
+ }
455
+
456
+ async getPullRequest(workspace, repoSlug, prId) {
457
+ const data = await this.apiRequest(`/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`);
458
+
459
+ return {
460
+ number: data.id,
461
+ title: data.title,
462
+ body: data.description,
463
+ state: data.state,
464
+ head: data.source?.branch?.name,
465
+ base: data.destination?.branch?.name,
466
+ htmlUrl: data.links?.html?.href
467
+ };
468
+ }
469
+
470
+ // ==========================================
471
+ // PULL REQUEST COMMENTS / REVIEWS
472
+ // ==========================================
473
+
474
+ /**
475
+ * List all comments on a pull request.
476
+ * Bitbucket has a single comments endpoint for both general and inline comments.
477
+ */
478
+ async listPRComments(workspace, repoSlug, prId, options = {}) {
479
+ const { page = 1, perPage = 50 } = options;
480
+
481
+ const data = await this.apiRequest(
482
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments?page=${page}&pagelen=${perPage}`
483
+ );
484
+
485
+ return (data.values || []).map(c => this._mapBitbucketComment(c));
486
+ }
487
+
488
+ /**
489
+ * List only inline (code review) comments on a PR.
490
+ * Filters comments that have inline.path set.
491
+ */
492
+ async listPRReviewComments(workspace, repoSlug, prId, options = {}) {
493
+ const allComments = await this.listPRComments(workspace, repoSlug, prId, options);
494
+ return allComments.filter(c => c.type === 'review' && c.path);
495
+ }
496
+
497
+ /**
498
+ * Get a specific comment by ID
499
+ */
500
+ async getPRComment(workspace, repoSlug, prId, commentId) {
501
+ const c = await this.apiRequest(
502
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}`
503
+ );
504
+ return this._mapBitbucketComment(c);
505
+ }
506
+
507
+ /**
508
+ * Add a general comment to a PR
509
+ */
510
+ async addPRComment(workspace, repoSlug, prId, body) {
511
+ const data = await this.apiRequest(
512
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments`, {
513
+ method: 'POST',
514
+ body: JSON.stringify({
515
+ content: { raw: body }
516
+ })
517
+ }
518
+ );
519
+
520
+ return this._mapBitbucketComment(data);
521
+ }
522
+
523
+ /**
524
+ * Add an inline review comment on a specific line of code.
525
+ *
526
+ * @param {object} comment - { body, path, line, side, commitId }
527
+ * - path: file path relative to repo root
528
+ * - line: line number (Bitbucket uses 'to' for new side, 'from' for old side)
529
+ */
530
+ async addPRReviewComment(workspace, repoSlug, prId, comment) {
531
+ const requestBody = {
532
+ content: { raw: comment.body },
533
+ inline: {
534
+ path: comment.path
535
+ }
536
+ };
537
+
538
+ // Bitbucket uses 'to' for right/new side, 'from' for left/old side
539
+ if (comment.side === 'LEFT') {
540
+ requestBody.inline.from = comment.line;
541
+ } else {
542
+ requestBody.inline.to = comment.line;
543
+ }
544
+
545
+ const data = await this.apiRequest(
546
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments`, {
547
+ method: 'POST',
548
+ body: JSON.stringify(requestBody)
549
+ }
550
+ );
551
+
552
+ return this._mapBitbucketComment(data);
553
+ }
554
+
555
+ /**
556
+ * Reply to an existing comment.
557
+ * Bitbucket uses parent.id to create threaded replies.
558
+ */
559
+ async replyToPRComment(workspace, repoSlug, prId, commentId, body) {
560
+ const data = await this.apiRequest(
561
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments`, {
562
+ method: 'POST',
563
+ body: JSON.stringify({
564
+ content: { raw: body },
565
+ parent: { id: Number(commentId) }
566
+ })
567
+ }
568
+ );
569
+
570
+ return this._mapBitbucketComment(data);
571
+ }
572
+
573
+ /**
574
+ * Resolve a comment in Bitbucket.
575
+ * Bitbucket supports resolution natively via PUT with resolved=true.
576
+ */
577
+ async resolvePRComment(workspace, repoSlug, prId, commentId) {
578
+ await this.apiRequest(
579
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}/resolve`, {
580
+ method: 'POST'
581
+ }
582
+ ).catch(async () => {
583
+ // Fallback: some Bitbucket versions use PUT with resolution object
584
+ await this.apiRequest(
585
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}`, {
586
+ method: 'PUT',
587
+ body: JSON.stringify({
588
+ resolution: {
589
+ type: 'pullrequest_comment_resolution'
590
+ }
591
+ })
592
+ }
593
+ );
594
+ });
595
+
596
+ return { resolved: true };
597
+ }
598
+
599
+ /**
600
+ * Unresolve a comment in Bitbucket.
601
+ */
602
+ async unresolvePRComment(workspace, repoSlug, prId, commentId) {
603
+ await this.apiRequest(
604
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}/resolve`, {
605
+ method: 'DELETE'
606
+ }
607
+ ).catch(async () => {
608
+ // Fallback: PUT without resolution
609
+ await this.apiRequest(
610
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}`, {
611
+ method: 'PUT',
612
+ body: JSON.stringify({
613
+ resolution: null
614
+ })
615
+ }
616
+ );
617
+ });
618
+
619
+ return { resolved: false };
620
+ }
621
+
622
+ /**
623
+ * List only unresolved comments on a PR.
624
+ * Bitbucket API supports filtering by resolution status.
625
+ */
626
+ async listUnresolvedPRComments(workspace, repoSlug, prId) {
627
+ // Fetch all and filter - Bitbucket doesn't have a direct filter param
628
+ const allComments = await this.listPRComments(workspace, repoSlug, prId, { perPage: 100 });
629
+ return allComments.filter(c => c.resolved === false);
630
+ }
631
+
632
+ /**
633
+ * Map a Bitbucket comment response to our standard format
634
+ */
635
+ _mapBitbucketComment(c) {
636
+ const isInline = c.inline && c.inline.path;
637
+ return {
638
+ id: c.id,
639
+ body: c.content?.raw || c.content?.markup || '',
640
+ path: isInline ? c.inline.path : null,
641
+ line: isInline ? (c.inline.to || c.inline.from) : null,
642
+ side: isInline ? (c.inline.to ? 'RIGHT' : 'LEFT') : null,
643
+ commitId: null,
644
+ authorUsername: c.user?.username || c.user?.nickname,
645
+ authorName: c.user?.display_name,
646
+ createdAt: c.created_on,
647
+ updatedAt: c.updated_on,
648
+ htmlUrl: c.links?.html?.href || null,
649
+ resolved: c.resolution !== undefined && c.resolution !== null,
650
+ threadId: c.parent?.id || null,
651
+ inReplyToId: c.parent?.id || null,
652
+ type: isInline ? 'review' : 'general'
653
+ };
654
+ }
655
+
656
+ // ==========================================
657
+ // WEBHOOKS
658
+ // ==========================================
659
+
660
+ async createWebhook(workspace, repoSlug, webhook) {
661
+ const data = await this.apiRequest(`/repositories/${workspace}/${repoSlug}/hooks`, {
662
+ method: 'POST',
663
+ body: JSON.stringify({
664
+ description: webhook.description || 'InspTech AI Webhook',
665
+ url: webhook.url,
666
+ active: true,
667
+ events: webhook.events || ['repo:push', 'pullrequest:created', 'pullrequest:updated'],
668
+ secret: webhook.secret
669
+ })
670
+ });
671
+
672
+ return {
673
+ id: data.uuid,
674
+ url: data.url,
675
+ events: data.events,
676
+ active: data.active
677
+ };
678
+ }
679
+
680
+ // ==========================================
681
+ // HELPER METHODS
682
+ // ==========================================
683
+
684
+ getCloneUrl(workspace, repoSlug, useHttps = true) {
685
+ if (useHttps) {
686
+ return `https://bitbucket.org/${workspace}/${repoSlug}.git`;
687
+ }
688
+ return `git@bitbucket.org:${workspace}/${repoSlug}.git`;
689
+ }
690
+
691
+ parseRepositoryUrl(url) {
692
+ // Supports:
693
+ // - https://bitbucket.org/workspace/repo
694
+ // - https://bitbucket.org/workspace/repo.git
695
+ // - git@bitbucket.org:workspace/repo.git
696
+ // - https://username@bitbucket.org/workspace/repo.git
697
+
698
+ let match = url.match(/bitbucket\.org[/:]([\w-]+)\/([\w.-]+?)(?:\.git)?$/);
699
+
700
+ if (match) {
701
+ return {
702
+ owner: match[1], // workspace in Bitbucket terminology
703
+ repo: match[2] // repo_slug
704
+ };
705
+ }
706
+
707
+ throw new Error(`Invalid Bitbucket repository URL: ${url}`);
708
+ }
709
+
710
+ /**
711
+ * Extract clone URL from Bitbucket's links.clone array
712
+ * @param {Array} cloneLinks - Array of { name, href } objects
713
+ * @param {string} type - 'https' or 'ssh'
714
+ * @returns {string}
715
+ */
716
+ _extractCloneUrl(cloneLinks, type = 'https') {
717
+ if (!cloneLinks || !Array.isArray(cloneLinks)) return '';
718
+ const link = cloneLinks.find(l => l.name === type);
719
+ return link?.href || '';
720
+ }
721
+ }
722
+
723
+ export default BitbucketProvider;