deepdebug-local-agent 0.3.1 → 0.3.3
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/.dockerignore +1 -0
- package/bin/install.js +282 -0
- package/package.json +17 -6
- package/src/git/base-git-provider.js +169 -18
- package/src/git/bitbucket-provider.js +723 -0
- package/src/git/git-provider-registry.js +12 -11
- package/src/git/github-provider.js +299 -6
- package/src/server.js +587 -42
|
@@ -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;
|