@hgarcianareia/ai-pr-review-bitbucket 1.0.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/package.json +47 -0
- package/src/bitbucket-adapter.js +660 -0
- package/src/cli.js +49 -0
- package/src/index.js +21 -0
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hgarcianareia/ai-pr-review-bitbucket",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Bitbucket Cloud adapter for AI-powered PR reviews",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./adapter": "./src/bitbucket-adapter.js"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"ai-pr-review-bitbucket": "./src/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src/**/*.js"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"review": "node src/cli.js",
|
|
19
|
+
"test": "echo 'No tests configured yet'"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@hgarcianareia/ai-pr-review-core": "^1.0.0"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20.0.0"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/hgarcianareia/GitHubPRReviewer.git",
|
|
30
|
+
"directory": "packages/bitbucket"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"ai",
|
|
37
|
+
"code-review",
|
|
38
|
+
"pull-request",
|
|
39
|
+
"bitbucket",
|
|
40
|
+
"bitbucket-cloud",
|
|
41
|
+
"bitbucket-pipelines",
|
|
42
|
+
"claude",
|
|
43
|
+
"anthropic"
|
|
44
|
+
],
|
|
45
|
+
"author": "hgarcianareia",
|
|
46
|
+
"license": "MIT"
|
|
47
|
+
}
|
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bitbucket Cloud Platform Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the PlatformAdapter interface for Bitbucket Cloud.
|
|
5
|
+
* Uses the Bitbucket REST API 2.0 for all operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import {
|
|
12
|
+
PlatformAdapter,
|
|
13
|
+
AI_REVIEW_MARKER,
|
|
14
|
+
parsePRNumber,
|
|
15
|
+
validateRepoOwner,
|
|
16
|
+
validateRepoName,
|
|
17
|
+
validateGitSha,
|
|
18
|
+
sanitizeBranchName
|
|
19
|
+
} from '@hgarcianareia/ai-pr-review-core';
|
|
20
|
+
|
|
21
|
+
// Bitbucket API configuration
|
|
22
|
+
const BITBUCKET_API_BASE = 'https://api.bitbucket.org/2.0';
|
|
23
|
+
|
|
24
|
+
// Rate limiting configuration
|
|
25
|
+
const RATE_LIMIT = {
|
|
26
|
+
maxRetries: 3,
|
|
27
|
+
initialDelayMs: 1000,
|
|
28
|
+
maxDelayMs: 30000
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Git command timeout configuration (in milliseconds)
|
|
32
|
+
const GIT_TIMEOUT = {
|
|
33
|
+
local: 30000, // 30s for local operations
|
|
34
|
+
network: 120000 // 120s for network operations
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Sleep for specified milliseconds
|
|
39
|
+
*/
|
|
40
|
+
function sleep(ms) {
|
|
41
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Exponential backoff retry wrapper
|
|
46
|
+
*/
|
|
47
|
+
async function withRetry(fn, operation) {
|
|
48
|
+
let lastError;
|
|
49
|
+
let delay = RATE_LIMIT.initialDelayMs;
|
|
50
|
+
|
|
51
|
+
for (let attempt = 1; attempt <= RATE_LIMIT.maxRetries; attempt++) {
|
|
52
|
+
try {
|
|
53
|
+
return await fn();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
lastError = error;
|
|
56
|
+
|
|
57
|
+
if (error.status === 429 || error.message?.includes('rate')) {
|
|
58
|
+
console.log(`[WARN] Rate limited on ${operation}, attempt ${attempt}/${RATE_LIMIT.maxRetries}`);
|
|
59
|
+
await sleep(delay);
|
|
60
|
+
delay = Math.min(delay * 2, RATE_LIMIT.maxDelayMs);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
throw lastError;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Safely validate an environment variable with user-friendly error message
|
|
73
|
+
*/
|
|
74
|
+
function safeValidateEnv(name, validator, defaultValue = undefined) {
|
|
75
|
+
try {
|
|
76
|
+
return validator(process.env[name]);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (defaultValue !== undefined) {
|
|
79
|
+
return defaultValue;
|
|
80
|
+
}
|
|
81
|
+
console.error('='.repeat(60));
|
|
82
|
+
console.error(`[FATAL] ${name} Validation Failed`);
|
|
83
|
+
console.error('='.repeat(60));
|
|
84
|
+
console.error(` ${error.message}`);
|
|
85
|
+
console.error('');
|
|
86
|
+
console.error('Please check your Bitbucket Pipelines configuration.');
|
|
87
|
+
console.error('='.repeat(60));
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Bitbucket Cloud Platform Adapter Implementation
|
|
94
|
+
*/
|
|
95
|
+
export class BitbucketAdapter extends PlatformAdapter {
|
|
96
|
+
/**
|
|
97
|
+
* @param {Object} context - Platform context
|
|
98
|
+
* @param {string} token - Bitbucket access token
|
|
99
|
+
*/
|
|
100
|
+
constructor(context, token) {
|
|
101
|
+
super(context);
|
|
102
|
+
this.token = token;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Factory method to create a BitbucketAdapter instance
|
|
107
|
+
* @returns {Promise<BitbucketAdapter>}
|
|
108
|
+
*/
|
|
109
|
+
static async create() {
|
|
110
|
+
// Validate required token
|
|
111
|
+
const token = process.env.BITBUCKET_TOKEN;
|
|
112
|
+
if (!token) {
|
|
113
|
+
console.error('='.repeat(60));
|
|
114
|
+
console.error('[FATAL] BITBUCKET_TOKEN is required');
|
|
115
|
+
console.error('='.repeat(60));
|
|
116
|
+
console.error(' Please add BITBUCKET_TOKEN to your repository variables.');
|
|
117
|
+
console.error(' This should be an App Password with repository and PR permissions.');
|
|
118
|
+
console.error('='.repeat(60));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check if this is a manual dispatch
|
|
123
|
+
const isManualDispatch = process.env.AI_REVIEW_TRIGGER === 'manual';
|
|
124
|
+
|
|
125
|
+
// Build context from environment variables
|
|
126
|
+
const context = {
|
|
127
|
+
owner: safeValidateEnv('BITBUCKET_WORKSPACE', validateRepoOwner),
|
|
128
|
+
repo: safeValidateEnv('BITBUCKET_REPO_SLUG', validateRepoName),
|
|
129
|
+
prNumber: safeValidateEnv('BITBUCKET_PR_ID', parsePRNumber),
|
|
130
|
+
prTitle: '',
|
|
131
|
+
prBody: '',
|
|
132
|
+
prAuthor: '',
|
|
133
|
+
baseSha: null,
|
|
134
|
+
headSha: safeValidateEnv('BITBUCKET_COMMIT', (v) => validateGitSha(v, 'BITBUCKET_COMMIT')),
|
|
135
|
+
eventName: process.env.AI_REVIEW_TRIGGER || 'opened',
|
|
136
|
+
isManualTrigger: isManualDispatch
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const adapter = new BitbucketAdapter(context, token);
|
|
140
|
+
|
|
141
|
+
// Load PR metadata from API
|
|
142
|
+
await adapter._loadPRMetadata();
|
|
143
|
+
|
|
144
|
+
return adapter;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Make an authenticated request to the Bitbucket API
|
|
149
|
+
* @private
|
|
150
|
+
*/
|
|
151
|
+
async _fetchBitbucket(endpoint, options = {}) {
|
|
152
|
+
const url = endpoint.startsWith('http') ? endpoint : `${BITBUCKET_API_BASE}${endpoint}`;
|
|
153
|
+
|
|
154
|
+
const response = await fetch(url, {
|
|
155
|
+
...options,
|
|
156
|
+
headers: {
|
|
157
|
+
'Authorization': `Bearer ${this.token}`,
|
|
158
|
+
'Content-Type': 'application/json',
|
|
159
|
+
'Accept': 'application/json',
|
|
160
|
+
...options.headers
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (!response.ok) {
|
|
165
|
+
const error = new Error(`Bitbucket API error: ${response.status} ${response.statusText}`);
|
|
166
|
+
error.status = response.status;
|
|
167
|
+
try {
|
|
168
|
+
error.body = await response.json();
|
|
169
|
+
} catch (e) {
|
|
170
|
+
// Ignore JSON parse errors
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Handle empty responses
|
|
176
|
+
const contentType = response.headers.get('content-type');
|
|
177
|
+
if (contentType && contentType.includes('application/json')) {
|
|
178
|
+
return response.json();
|
|
179
|
+
}
|
|
180
|
+
return response.text();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Load PR metadata from Bitbucket API
|
|
185
|
+
* @private
|
|
186
|
+
*/
|
|
187
|
+
async _loadPRMetadata() {
|
|
188
|
+
try {
|
|
189
|
+
const pr = await this._fetchBitbucket(
|
|
190
|
+
`/repositories/${this.context.owner}/${this.context.repo}/pullrequests/${this.context.prNumber}`
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
this.context.prTitle = pr.title || '';
|
|
194
|
+
this.context.prBody = pr.description || '';
|
|
195
|
+
this.context.prAuthor = pr.author?.display_name || pr.author?.nickname || '';
|
|
196
|
+
this.context.baseSha = pr.destination?.commit?.hash || null;
|
|
197
|
+
|
|
198
|
+
// Update headSha if available from API
|
|
199
|
+
if (pr.source?.commit?.hash) {
|
|
200
|
+
this.context.headSha = pr.source.commit.hash;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.log('[INFO] Loaded PR metadata from Bitbucket API');
|
|
204
|
+
} catch (error) {
|
|
205
|
+
console.error('[ERROR] Failed to load PR metadata:', error.message);
|
|
206
|
+
throw new Error('Failed to load PR metadata from Bitbucket API');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Validate baseSha after loading metadata
|
|
210
|
+
if (!this.context.baseSha) {
|
|
211
|
+
console.error('='.repeat(60));
|
|
212
|
+
console.error('[FATAL] BASE_SHA Validation Failed');
|
|
213
|
+
console.error('='.repeat(60));
|
|
214
|
+
console.error(' Could not determine base commit SHA from PR metadata');
|
|
215
|
+
console.error('='.repeat(60));
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// =========================================================================
|
|
221
|
+
// Interface Implementation
|
|
222
|
+
// =========================================================================
|
|
223
|
+
|
|
224
|
+
getPlatformType() {
|
|
225
|
+
return 'bitbucket';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
getCapabilities() {
|
|
229
|
+
return {
|
|
230
|
+
supportsReactions: false, // Limited reaction support in Bitbucket
|
|
231
|
+
supportsReviewStates: true, // Supports APPROVE and REQUEST_CHANGES
|
|
232
|
+
supportsAutoFixPR: true,
|
|
233
|
+
supportsCaching: true
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// =========================================================================
|
|
238
|
+
// PR Data Access
|
|
239
|
+
// =========================================================================
|
|
240
|
+
|
|
241
|
+
async getDiff() {
|
|
242
|
+
// First try to read from file (prepared by pipeline script)
|
|
243
|
+
try {
|
|
244
|
+
const diffContent = await fs.readFile('pr_diff.txt', 'utf-8');
|
|
245
|
+
return diffContent;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
// Fall back to API
|
|
248
|
+
console.log('[INFO] Fetching diff from Bitbucket API...');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const diff = await this._fetchBitbucket(
|
|
253
|
+
`/repositories/${this.context.owner}/${this.context.repo}/pullrequests/${this.context.prNumber}/diff`,
|
|
254
|
+
{ headers: { 'Accept': 'text/plain' } }
|
|
255
|
+
);
|
|
256
|
+
return diff;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
console.error('[ERROR] Failed to get diff:', error.message);
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async getChangedFiles() {
|
|
264
|
+
// First try to read from file (prepared by pipeline script)
|
|
265
|
+
try {
|
|
266
|
+
const filesContent = await fs.readFile('changed_files.txt', 'utf-8');
|
|
267
|
+
return filesContent.split('\n').filter(f => f.trim());
|
|
268
|
+
} catch (error) {
|
|
269
|
+
// Fall back to API
|
|
270
|
+
console.log('[INFO] Fetching changed files from Bitbucket API...');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const diffstat = await this._fetchBitbucket(
|
|
275
|
+
`/repositories/${this.context.owner}/${this.context.repo}/pullrequests/${this.context.prNumber}/diffstat`
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const files = [];
|
|
279
|
+
for (const entry of diffstat.values || []) {
|
|
280
|
+
// Use new path if available, otherwise old path
|
|
281
|
+
const filePath = entry.new?.path || entry.old?.path;
|
|
282
|
+
if (filePath) {
|
|
283
|
+
files.push(filePath);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return files;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.error('[ERROR] Failed to get changed files:', error.message);
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async getExistingComments() {
|
|
295
|
+
// First try to read from file (prepared by pipeline script)
|
|
296
|
+
try {
|
|
297
|
+
const commentsJson = await fs.readFile('pr_comments.json', 'utf-8');
|
|
298
|
+
const comments = JSON.parse(commentsJson);
|
|
299
|
+
|
|
300
|
+
// Filter for AI review comments
|
|
301
|
+
return comments.filter(c =>
|
|
302
|
+
c.content?.raw?.includes(AI_REVIEW_MARKER) ||
|
|
303
|
+
c.content?.raw?.includes('AI Code Review')
|
|
304
|
+
).map(c => ({
|
|
305
|
+
id: c.id,
|
|
306
|
+
path: c.inline?.path,
|
|
307
|
+
line: c.inline?.to,
|
|
308
|
+
body: c.content?.raw
|
|
309
|
+
}));
|
|
310
|
+
} catch (error) {
|
|
311
|
+
// Fall back to API
|
|
312
|
+
console.log('[INFO] Fetching existing comments from Bitbucket API...');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const response = await this._fetchBitbucket(
|
|
317
|
+
`/repositories/${this.context.owner}/${this.context.repo}/pullrequests/${this.context.prNumber}/comments`
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const comments = response.values || [];
|
|
321
|
+
|
|
322
|
+
// Filter for AI review comments
|
|
323
|
+
return comments.filter(c =>
|
|
324
|
+
c.content?.raw?.includes(AI_REVIEW_MARKER) ||
|
|
325
|
+
c.content?.raw?.includes('AI Code Review')
|
|
326
|
+
).map(c => ({
|
|
327
|
+
id: c.id,
|
|
328
|
+
path: c.inline?.path,
|
|
329
|
+
line: c.inline?.to,
|
|
330
|
+
body: c.content?.raw
|
|
331
|
+
}));
|
|
332
|
+
} catch (error) {
|
|
333
|
+
console.log('[WARN] Could not load existing comments:', error.message);
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async getExistingReviews() {
|
|
339
|
+
// Bitbucket doesn't have a separate reviews concept like GitHub
|
|
340
|
+
// Return empty array - we'll use comments for threading
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async getFileContent(filePath) {
|
|
345
|
+
try {
|
|
346
|
+
const fullPath = path.join(process.cwd(), filePath);
|
|
347
|
+
return await fs.readFile(fullPath, 'utf-8');
|
|
348
|
+
} catch (error) {
|
|
349
|
+
// Try fetching from API if local file not found
|
|
350
|
+
try {
|
|
351
|
+
const content = await this._fetchBitbucket(
|
|
352
|
+
`/repositories/${this.context.owner}/${this.context.repo}/src/${this.context.headSha}/${encodeURIComponent(filePath)}`,
|
|
353
|
+
{ headers: { 'Accept': 'text/plain' } }
|
|
354
|
+
);
|
|
355
|
+
return content;
|
|
356
|
+
} catch (apiError) {
|
|
357
|
+
console.log(`[WARN] Could not read file ${filePath}:`, error.message);
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// =========================================================================
|
|
364
|
+
// PR Interaction
|
|
365
|
+
// =========================================================================
|
|
366
|
+
|
|
367
|
+
async postReview(body, comments = [], event = 'COMMENT') {
|
|
368
|
+
try {
|
|
369
|
+
// Add marker to the body for identification
|
|
370
|
+
const markedBody = `${AI_REVIEW_MARKER}\n${body}`;
|
|
371
|
+
|
|
372
|
+
// Post main summary comment
|
|
373
|
+
await withRetry(
|
|
374
|
+
() => this._fetchBitbucket(
|
|
375
|
+
`/repositories/${this.context.owner}/${this.context.repo}/pullrequests/${this.context.prNumber}/comments`,
|
|
376
|
+
{
|
|
377
|
+
method: 'POST',
|
|
378
|
+
body: JSON.stringify({
|
|
379
|
+
content: { raw: markedBody }
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
),
|
|
383
|
+
'postSummaryComment'
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
console.log('[INFO] Posted review summary comment');
|
|
387
|
+
|
|
388
|
+
// Post inline comments
|
|
389
|
+
for (const comment of comments) {
|
|
390
|
+
try {
|
|
391
|
+
await withRetry(
|
|
392
|
+
() => this._fetchBitbucket(
|
|
393
|
+
`/repositories/${this.context.owner}/${this.context.repo}/pullrequests/${this.context.prNumber}/comments`,
|
|
394
|
+
{
|
|
395
|
+
method: 'POST',
|
|
396
|
+
body: JSON.stringify({
|
|
397
|
+
content: { raw: `${AI_REVIEW_MARKER}\n${comment.body}` },
|
|
398
|
+
inline: {
|
|
399
|
+
to: comment.position, // Line number in new file
|
|
400
|
+
path: comment.path
|
|
401
|
+
}
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
),
|
|
405
|
+
'postInlineComment'
|
|
406
|
+
);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
console.log(`[WARN] Failed to post inline comment on ${comment.path}:${comment.position}:`, error.message);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
console.log(`[INFO] Posted ${comments.length} inline comments`);
|
|
413
|
+
|
|
414
|
+
// Handle review state
|
|
415
|
+
if (event === 'APPROVE') {
|
|
416
|
+
try {
|
|
417
|
+
await withRetry(
|
|
418
|
+
() => this._fetchBitbucket(
|
|
419
|
+
`/repositories/${this.context.owner}/${this.context.repo}/pullrequests/${this.context.prNumber}/approve`,
|
|
420
|
+
{ method: 'POST' }
|
|
421
|
+
),
|
|
422
|
+
'approvePR'
|
|
423
|
+
);
|
|
424
|
+
console.log('[INFO] Approved PR');
|
|
425
|
+
} catch (error) {
|
|
426
|
+
console.log('[WARN] Could not approve PR:', error.message);
|
|
427
|
+
}
|
|
428
|
+
} else if (event === 'REQUEST_CHANGES') {
|
|
429
|
+
try {
|
|
430
|
+
await withRetry(
|
|
431
|
+
() => this._fetchBitbucket(
|
|
432
|
+
`/repositories/${this.context.owner}/${this.context.repo}/pullrequests/${this.context.prNumber}/request-changes`,
|
|
433
|
+
{ method: 'POST' }
|
|
434
|
+
),
|
|
435
|
+
'requestChangesPR'
|
|
436
|
+
);
|
|
437
|
+
console.log('[INFO] Requested changes on PR');
|
|
438
|
+
} catch (error) {
|
|
439
|
+
console.log('[WARN] Could not request changes on PR:', error.message);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
} catch (error) {
|
|
444
|
+
console.error('[ERROR] Failed to post review:', error.message);
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
calculateCommentPosition(file, targetLine) {
|
|
450
|
+
// Bitbucket uses line numbers directly, not diff positions
|
|
451
|
+
// Validate the line exists in an added or context section
|
|
452
|
+
for (const hunk of file.hunks || []) {
|
|
453
|
+
for (const change of hunk.changes || []) {
|
|
454
|
+
if ((change.type === 'add' || change.type === 'context') &&
|
|
455
|
+
change.newLine === targetLine) {
|
|
456
|
+
return targetLine;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// If exact line not found, find closest added line within 5 lines
|
|
462
|
+
let closestLine = null;
|
|
463
|
+
let closestDistance = Infinity;
|
|
464
|
+
|
|
465
|
+
for (const hunk of file.hunks || []) {
|
|
466
|
+
for (const change of hunk.changes || []) {
|
|
467
|
+
if (change.type === 'add' && change.newLine) {
|
|
468
|
+
const distance = Math.abs(change.newLine - targetLine);
|
|
469
|
+
if (distance < closestDistance && distance <= 5) {
|
|
470
|
+
closestDistance = distance;
|
|
471
|
+
closestLine = change.newLine;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return closestLine;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// =========================================================================
|
|
481
|
+
// Feature: Feedback Loop (Reactions)
|
|
482
|
+
// =========================================================================
|
|
483
|
+
|
|
484
|
+
async getCommentReactions(commentId) {
|
|
485
|
+
// Bitbucket has limited reaction support - return null
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async getReviewReactions(reviewId) {
|
|
490
|
+
// Bitbucket doesn't have review reactions - return null
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// =========================================================================
|
|
495
|
+
// Feature: Auto-fix PRs
|
|
496
|
+
// =========================================================================
|
|
497
|
+
|
|
498
|
+
async createAutoFixPR(branchName, fixes, prTitle, prBody) {
|
|
499
|
+
if (fixes.length === 0) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const sanitizedBranch = sanitizeBranchName(branchName);
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
// Stash any current changes
|
|
507
|
+
execSync('git stash', {
|
|
508
|
+
encoding: 'utf-8',
|
|
509
|
+
stdio: 'pipe',
|
|
510
|
+
timeout: GIT_TIMEOUT.local
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Create and checkout new branch
|
|
514
|
+
execSync(`git checkout -b ${sanitizedBranch}`, {
|
|
515
|
+
encoding: 'utf-8',
|
|
516
|
+
stdio: 'pipe',
|
|
517
|
+
timeout: GIT_TIMEOUT.local
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Group fixes by file
|
|
521
|
+
const fixesByFile = new Map();
|
|
522
|
+
for (const fix of fixes) {
|
|
523
|
+
if (!fixesByFile.has(fix.file)) {
|
|
524
|
+
fixesByFile.set(fix.file, []);
|
|
525
|
+
}
|
|
526
|
+
fixesByFile.get(fix.file).push(fix);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Apply fixes to each file
|
|
530
|
+
for (const [filePath, fileFixes] of fixesByFile) {
|
|
531
|
+
try {
|
|
532
|
+
const fullPath = path.join(process.cwd(), filePath);
|
|
533
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
534
|
+
const lines = content.split('\n');
|
|
535
|
+
|
|
536
|
+
// Sort fixes by line number descending to apply from bottom up
|
|
537
|
+
const sortedFixes = [...fileFixes].sort((a, b) => b.line - a.line);
|
|
538
|
+
|
|
539
|
+
for (const fix of sortedFixes) {
|
|
540
|
+
if (fix.line > 0 && fix.line <= lines.length) {
|
|
541
|
+
lines[fix.line - 1] = fix.suggested;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
await fs.writeFile(fullPath, lines.join('\n'), 'utf-8');
|
|
546
|
+
} catch (error) {
|
|
547
|
+
console.log(`[WARN] Failed to apply fixes to ${filePath}:`, error.message);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Commit changes
|
|
552
|
+
execSync('git add -A', {
|
|
553
|
+
encoding: 'utf-8',
|
|
554
|
+
stdio: 'pipe',
|
|
555
|
+
timeout: GIT_TIMEOUT.local
|
|
556
|
+
});
|
|
557
|
+
execSync(`git commit -m "${prTitle}"`, {
|
|
558
|
+
encoding: 'utf-8',
|
|
559
|
+
stdio: 'pipe',
|
|
560
|
+
timeout: GIT_TIMEOUT.local
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Push branch
|
|
564
|
+
execSync(`git push origin ${sanitizedBranch}`, {
|
|
565
|
+
encoding: 'utf-8',
|
|
566
|
+
stdio: 'pipe',
|
|
567
|
+
timeout: GIT_TIMEOUT.network
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// Get the base branch (the PR's source branch)
|
|
571
|
+
const prInfo = await this.getPRInfo(this.context.prNumber);
|
|
572
|
+
const baseBranch = prInfo.source?.branch?.name;
|
|
573
|
+
|
|
574
|
+
if (!baseBranch) {
|
|
575
|
+
throw new Error('Could not determine source branch for auto-fix PR');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Create PR via Bitbucket API
|
|
579
|
+
const newPR = await this._fetchBitbucket(
|
|
580
|
+
`/repositories/${this.context.owner}/${this.context.repo}/pullrequests`,
|
|
581
|
+
{
|
|
582
|
+
method: 'POST',
|
|
583
|
+
body: JSON.stringify({
|
|
584
|
+
title: prTitle,
|
|
585
|
+
description: prBody,
|
|
586
|
+
source: {
|
|
587
|
+
branch: { name: sanitizedBranch }
|
|
588
|
+
},
|
|
589
|
+
destination: {
|
|
590
|
+
branch: { name: baseBranch }
|
|
591
|
+
},
|
|
592
|
+
close_source_branch: true
|
|
593
|
+
})
|
|
594
|
+
}
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
// Checkout back to original branch
|
|
598
|
+
execSync('git checkout -', {
|
|
599
|
+
encoding: 'utf-8',
|
|
600
|
+
stdio: 'pipe',
|
|
601
|
+
timeout: GIT_TIMEOUT.local
|
|
602
|
+
});
|
|
603
|
+
execSync('git stash pop || true', {
|
|
604
|
+
encoding: 'utf-8',
|
|
605
|
+
stdio: 'pipe',
|
|
606
|
+
timeout: GIT_TIMEOUT.local
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
console.log(`[INFO] Created auto-fix PR #${newPR.id}`);
|
|
610
|
+
|
|
611
|
+
return {
|
|
612
|
+
prNumber: newPR.id,
|
|
613
|
+
prUrl: newPR.links?.html?.href || `https://bitbucket.org/${this.context.owner}/${this.context.repo}/pull-requests/${newPR.id}`,
|
|
614
|
+
branch: sanitizedBranch,
|
|
615
|
+
fixCount: fixes.length
|
|
616
|
+
};
|
|
617
|
+
} catch (error) {
|
|
618
|
+
console.error('[ERROR] Failed to create auto-fix PR:', error.message);
|
|
619
|
+
|
|
620
|
+
// Try to restore original state
|
|
621
|
+
try {
|
|
622
|
+
execSync('git checkout -', {
|
|
623
|
+
encoding: 'utf-8',
|
|
624
|
+
stdio: 'pipe',
|
|
625
|
+
timeout: GIT_TIMEOUT.local
|
|
626
|
+
});
|
|
627
|
+
execSync('git stash pop || true', {
|
|
628
|
+
encoding: 'utf-8',
|
|
629
|
+
stdio: 'pipe',
|
|
630
|
+
timeout: GIT_TIMEOUT.local
|
|
631
|
+
});
|
|
632
|
+
} catch (e) {
|
|
633
|
+
// Ignore cleanup errors
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async getPRInfo(prNumber) {
|
|
641
|
+
const response = await this._fetchBitbucket(
|
|
642
|
+
`/repositories/${this.context.owner}/${this.context.repo}/pullrequests/${prNumber}`
|
|
643
|
+
);
|
|
644
|
+
return response;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// =========================================================================
|
|
648
|
+
// Metrics & Summary
|
|
649
|
+
// =========================================================================
|
|
650
|
+
|
|
651
|
+
async writeMetricsSummary(summary) {
|
|
652
|
+
// Bitbucket Pipelines doesn't have a native step summary feature like GitHub Actions
|
|
653
|
+
// Log to console instead
|
|
654
|
+
console.log('\n' + '='.repeat(60));
|
|
655
|
+
console.log('AI Review Metrics Summary');
|
|
656
|
+
console.log('='.repeat(60));
|
|
657
|
+
console.log(summary);
|
|
658
|
+
console.log('='.repeat(60) + '\n');
|
|
659
|
+
}
|
|
660
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for Bitbucket AI PR Review
|
|
4
|
+
*
|
|
5
|
+
* This script is the main entry point when running the review
|
|
6
|
+
* from Bitbucket Pipelines or the command line.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ReviewEngine } from '@hgarcianareia/ai-pr-review-core';
|
|
10
|
+
import { BitbucketAdapter } from './bitbucket-adapter.js';
|
|
11
|
+
|
|
12
|
+
async function main() {
|
|
13
|
+
// Validate required API key
|
|
14
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
15
|
+
console.error('='.repeat(60));
|
|
16
|
+
console.error('[FATAL] ANTHROPIC_API_KEY is required');
|
|
17
|
+
console.error('='.repeat(60));
|
|
18
|
+
console.error(' Please add ANTHROPIC_API_KEY to your repository variables.');
|
|
19
|
+
console.error('='.repeat(60));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// Create the Bitbucket adapter
|
|
25
|
+
const adapter = await BitbucketAdapter.create();
|
|
26
|
+
|
|
27
|
+
// Create and run the review engine
|
|
28
|
+
const engine = new ReviewEngine({
|
|
29
|
+
platformAdapter: adapter,
|
|
30
|
+
anthropicApiKey: process.env.ANTHROPIC_API_KEY
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const result = await engine.run();
|
|
34
|
+
|
|
35
|
+
if (result.skipped) {
|
|
36
|
+
console.log(`[INFO] Review skipped: ${result.reason}`);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log('[INFO] Review completed successfully');
|
|
41
|
+
process.exit(0);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('[FATAL] Review failed:', error.message);
|
|
44
|
+
console.error(error.stack);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
main();
|
package/src/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hgarcianareia/ai-pr-review-bitbucket
|
|
3
|
+
*
|
|
4
|
+
* Bitbucket Cloud adapter for AI-powered PR reviews.
|
|
5
|
+
* Use this package with @hgarcianareia/ai-pr-review-core to review
|
|
6
|
+
* Pull Requests in Bitbucket Pipelines.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { BitbucketAdapter } from '@hgarcianareia/ai-pr-review-bitbucket';
|
|
10
|
+
* import { ReviewEngine } from '@hgarcianareia/ai-pr-review-core';
|
|
11
|
+
*
|
|
12
|
+
* const adapter = await BitbucketAdapter.create();
|
|
13
|
+
* const engine = new ReviewEngine({
|
|
14
|
+
* platformAdapter: adapter,
|
|
15
|
+
* anthropicApiKey: process.env.ANTHROPIC_API_KEY
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* await engine.run();
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export { BitbucketAdapter } from './bitbucket-adapter.js';
|