bitbucket-gemini-action 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.
@@ -0,0 +1,157 @@
1
+ # Full-featured Bitbucket Pipeline for Gemini Code Review
2
+ # Includes webhook triggers for @gemini mentions
3
+
4
+ image: node:20
5
+
6
+ definitions:
7
+ caches:
8
+ bun: ~/.bun
9
+
10
+ scripts:
11
+ install-bun: &install-bun |
12
+ curl -fsSL https://bun.sh/install | bash
13
+ export BUN_INSTALL="$HOME/.bun"
14
+ export PATH="$BUN_INSTALL/bin:$PATH"
15
+
16
+ steps:
17
+ - step: &setup-gemini-action
18
+ name: Setup Gemini Action
19
+ caches:
20
+ - bun
21
+ - node
22
+ artifacts:
23
+ - .gemini-action/**
24
+ script:
25
+ - *install-bun
26
+ - |
27
+ if [ ! -d ".gemini-action" ]; then
28
+ git clone --depth 1 https://github.com/your-org/bitbucket-gemini-action.git .gemini-action
29
+ fi
30
+ - cd .gemini-action && bun install
31
+
32
+ - step: &run-gemini-prepare
33
+ name: Prepare Review
34
+ caches:
35
+ - bun
36
+ script:
37
+ - *install-bun
38
+ - bun run .gemini-action/src/entrypoints/prepare.ts
39
+ artifacts:
40
+ - .gemini-action-output.json
41
+
42
+ - step: &run-gemini-execute
43
+ name: Execute Review
44
+ caches:
45
+ - bun
46
+ script:
47
+ - *install-bun
48
+ - |
49
+ if [ -f ".gemini-action-output.json" ]; then
50
+ SHOULD_CONTINUE=$(cat .gemini-action-output.json | jq -r '.containsTrigger')
51
+ if [ "$SHOULD_CONTINUE" = "true" ]; then
52
+ bun run .gemini-action/src/entrypoints/execute.ts
53
+ else
54
+ echo "Skipping: No trigger detected"
55
+ fi
56
+ else
57
+ echo "Skipping: No prepare output found"
58
+ fi
59
+
60
+ pipelines:
61
+ # Automatic review on all PRs
62
+ pull-requests:
63
+ '**':
64
+ - step: *setup-gemini-action
65
+ - step:
66
+ <<: *run-gemini-prepare
67
+ name: Check for @gemini mentions
68
+ - step:
69
+ <<: *run-gemini-execute
70
+ name: Run Gemini Review
71
+
72
+ # Webhook-triggered review (for comment events)
73
+ custom:
74
+ # Triggered by webhook when @gemini is mentioned
75
+ on-mention:
76
+ - variables:
77
+ - name: WEBHOOK_PAYLOAD
78
+ - name: TRIGGER_EVENT
79
+ default: "pullrequest:comment_created"
80
+ - step: *setup-gemini-action
81
+ - step: *run-gemini-prepare
82
+ - step: *run-gemini-execute
83
+
84
+ # Manual full review
85
+ full-review:
86
+ - variables:
87
+ - name: PR_ID
88
+ description: "Pull Request ID to review"
89
+ - name: REVIEW_TYPE
90
+ default: "comprehensive"
91
+ allowed-values:
92
+ - "quick"
93
+ - "comprehensive"
94
+ - "security"
95
+ - step: *setup-gemini-action
96
+ - step:
97
+ name: Run Full Review
98
+ caches:
99
+ - bun
100
+ script:
101
+ - *install-bun
102
+ - |
103
+ case "$REVIEW_TYPE" in
104
+ "quick")
105
+ PROMPT="Quick review: Check for obvious bugs and issues"
106
+ ;;
107
+ "security")
108
+ PROMPT="Security-focused review: Check for vulnerabilities, injection risks, auth issues"
109
+ ;;
110
+ *)
111
+ PROMPT="Comprehensive review: bugs, security, performance, code quality, best practices"
112
+ ;;
113
+ esac
114
+ export PROMPT
115
+ export MODE="agent"
116
+ export BITBUCKET_PR_ID="$PR_ID"
117
+ - bun run .gemini-action/src/entrypoints/prepare.ts
118
+ - bun run .gemini-action/src/entrypoints/execute.ts
119
+
120
+ # Scheduled review of open PRs
121
+ scheduled-review:
122
+ - step:
123
+ name: Review Stale PRs
124
+ script:
125
+ - *install-bun
126
+ - |
127
+ # Get list of open PRs older than 3 days
128
+ STALE_PRS=$(curl -s -u "$BITBUCKET_USERNAME:$BITBUCKET_APP_PASSWORD" \
129
+ "https://api.bitbucket.org/2.0/repositories/$BITBUCKET_WORKSPACE/$BITBUCKET_REPO_SLUG/pullrequests?state=OPEN" \
130
+ | jq -r '.values[] | select(.created_on < (now - 259200 | todate)) | .id')
131
+
132
+ for PR_ID in $STALE_PRS; do
133
+ echo "Reviewing stale PR #$PR_ID"
134
+ export BITBUCKET_PR_ID="$PR_ID"
135
+ export MODE="agent"
136
+ export PROMPT="This PR has been open for a while. Please review and suggest how to move it forward."
137
+ bun run .gemini-action/src/entrypoints/prepare.ts
138
+ bun run .gemini-action/src/entrypoints/execute.ts
139
+ done
140
+
141
+ # Branch-specific configurations
142
+ branches:
143
+ main:
144
+ - step:
145
+ name: Security Review
146
+ script:
147
+ - *install-bun
148
+ - |
149
+ export MODE="agent"
150
+ export PROMPT="Security review for main branch merge"
151
+ - bun run .gemini-action/src/entrypoints/prepare.ts
152
+ - bun run .gemini-action/src/entrypoints/execute.ts
153
+
154
+ # Schedule daily review of stale PRs
155
+ schedules:
156
+ - cron: "0 9 * * 1-5" # 9 AM weekdays
157
+ pipeline: custom/scheduled-review
@@ -0,0 +1,22 @@
1
+ # Minimal Bitbucket Pipeline for Gemini Code Review
2
+ # Copy this to your repository's bitbucket-pipelines.yml
3
+
4
+ image: node:20
5
+
6
+ pipelines:
7
+ pull-requests:
8
+ '**':
9
+ - step:
10
+ name: AI Code Review
11
+ script:
12
+ # Install Bun
13
+ - curl -fsSL https://bun.sh/install | bash
14
+ - export BUN_INSTALL="$HOME/.bun"
15
+ - export PATH="$BUN_INSTALL/bin:$PATH"
16
+
17
+ # Install and run gemini action
18
+ - npx bitbucket-gemini-action
19
+
20
+ # Required repository variables (Settings > Repository settings > Repository variables):
21
+ # - GEMINI_API_KEY: Your Google Gemini API key
22
+ # - BITBUCKET_ACCESS_TOKEN: Token with PR read/write permissions
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "bitbucket-gemini-action",
3
+ "version": "1.0.0",
4
+ "description": "Bitbucket Pipeline action for AI-powered code review using Google Gemini",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "build": "bun build src/entrypoints/prepare.ts --outdir=dist --target=node",
9
+ "test": "bun test",
10
+ "format": "prettier --write .",
11
+ "format:check": "prettier --check .",
12
+ "typecheck": "tsc --noEmit",
13
+ "dev": "bun run src/entrypoints/prepare.ts"
14
+ },
15
+ "dependencies": {
16
+ "@google/generative-ai": "^0.21.0",
17
+ "@modelcontextprotocol/sdk": "^1.11.0",
18
+ "node-fetch": "^3.3.2",
19
+ "zod": "^3.24.4"
20
+ },
21
+ "devDependencies": {
22
+ "@types/bun": "^1.1.14",
23
+ "@types/node": "^22.10.2",
24
+ "prettier": "^3.4.2",
25
+ "typescript": "^5.7.2"
26
+ },
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "author": "",
31
+ "license": "MIT",
32
+ "packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447"
33
+ }
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Bitbucket Cloud API Client
3
+ * REST API v2.0 implementation
4
+ */
5
+
6
+ import type {
7
+ BitbucketPullRequest,
8
+ BitbucketComment,
9
+ BitbucketDiffStat,
10
+ BitbucketCommit,
11
+ BitbucketBranch,
12
+ BitbucketIssue,
13
+ BitbucketRepository,
14
+ PaginatedResponse,
15
+ } from "../types.js";
16
+
17
+ const BITBUCKET_API_BASE = "https://api.bitbucket.org/2.0";
18
+
19
+ export interface BitbucketClientOptions {
20
+ username?: string;
21
+ appPassword?: string;
22
+ accessToken?: string;
23
+ baseUrl?: string;
24
+ }
25
+
26
+ export class BitbucketClient {
27
+ private baseUrl: string;
28
+ private authHeader: string;
29
+
30
+ constructor(options: BitbucketClientOptions) {
31
+ this.baseUrl = options.baseUrl || BITBUCKET_API_BASE;
32
+
33
+ if (options.accessToken) {
34
+ this.authHeader = `Bearer ${options.accessToken}`;
35
+ } else if (options.username && options.appPassword) {
36
+ const credentials = Buffer.from(
37
+ `${options.username}:${options.appPassword}`
38
+ ).toString("base64");
39
+ this.authHeader = `Basic ${credentials}`;
40
+ } else {
41
+ throw new Error(
42
+ "BitbucketClient requires either accessToken or username/appPassword"
43
+ );
44
+ }
45
+ }
46
+
47
+ private async request<T>(
48
+ endpoint: string,
49
+ options: RequestInit = {}
50
+ ): Promise<T> {
51
+ const url = endpoint.startsWith("http")
52
+ ? endpoint
53
+ : `${this.baseUrl}${endpoint}`;
54
+
55
+ const response = await fetch(url, {
56
+ ...options,
57
+ headers: {
58
+ Authorization: this.authHeader,
59
+ "Content-Type": "application/json",
60
+ Accept: "application/json",
61
+ ...options.headers,
62
+ },
63
+ });
64
+
65
+ if (!response.ok) {
66
+ const errorBody = await response.text();
67
+ throw new BitbucketApiError(
68
+ `Bitbucket API error: ${response.status} ${response.statusText}`,
69
+ response.status,
70
+ errorBody
71
+ );
72
+ }
73
+
74
+ if (response.status === 204) {
75
+ return {} as T;
76
+ }
77
+
78
+ return response.json() as Promise<T>;
79
+ }
80
+
81
+ private async paginatedRequest<T>(
82
+ endpoint: string,
83
+ maxPages = 10
84
+ ): Promise<T[]> {
85
+ const results: T[] = [];
86
+ let nextUrl: string | undefined = endpoint;
87
+ let pageCount = 0;
88
+
89
+ while (nextUrl && pageCount < maxPages) {
90
+ const page: PaginatedResponse<T> = await this.request<PaginatedResponse<T>>(nextUrl);
91
+ results.push(...page.values);
92
+ nextUrl = page.next;
93
+ pageCount++;
94
+ }
95
+
96
+ return results;
97
+ }
98
+
99
+ // Repository operations
100
+ async getRepository(
101
+ workspace: string,
102
+ repoSlug: string
103
+ ): Promise<BitbucketRepository> {
104
+ return this.request<BitbucketRepository>(
105
+ `/repositories/${workspace}/${repoSlug}`
106
+ );
107
+ }
108
+
109
+ // Pull Request operations
110
+ async getPullRequest(
111
+ workspace: string,
112
+ repoSlug: string,
113
+ prId: number
114
+ ): Promise<BitbucketPullRequest> {
115
+ return this.request<BitbucketPullRequest>(
116
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}`
117
+ );
118
+ }
119
+
120
+ async getPullRequestDiff(
121
+ workspace: string,
122
+ repoSlug: string,
123
+ prId: number
124
+ ): Promise<string> {
125
+ const url = `${this.baseUrl}/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/diff`;
126
+ const response = await fetch(url, {
127
+ headers: {
128
+ Authorization: this.authHeader,
129
+ Accept: "text/plain",
130
+ },
131
+ });
132
+
133
+ if (!response.ok) {
134
+ throw new BitbucketApiError(
135
+ `Failed to get PR diff: ${response.status}`,
136
+ response.status
137
+ );
138
+ }
139
+
140
+ return response.text();
141
+ }
142
+
143
+ async getPullRequestDiffStat(
144
+ workspace: string,
145
+ repoSlug: string,
146
+ prId: number
147
+ ): Promise<BitbucketDiffStat[]> {
148
+ return this.paginatedRequest<BitbucketDiffStat>(
149
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/diffstat`
150
+ );
151
+ }
152
+
153
+ async getPullRequestComments(
154
+ workspace: string,
155
+ repoSlug: string,
156
+ prId: number
157
+ ): Promise<BitbucketComment[]> {
158
+ return this.paginatedRequest<BitbucketComment>(
159
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments`
160
+ );
161
+ }
162
+
163
+ async getPullRequestCommits(
164
+ workspace: string,
165
+ repoSlug: string,
166
+ prId: number
167
+ ): Promise<BitbucketCommit[]> {
168
+ return this.paginatedRequest<BitbucketCommit>(
169
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/commits`
170
+ );
171
+ }
172
+
173
+ async createPullRequestComment(
174
+ workspace: string,
175
+ repoSlug: string,
176
+ prId: number,
177
+ content: string,
178
+ inline?: { path: string; line: number }
179
+ ): Promise<BitbucketComment> {
180
+ const body: Record<string, unknown> = {
181
+ content: {
182
+ raw: content,
183
+ },
184
+ };
185
+
186
+ if (inline) {
187
+ body.inline = {
188
+ to: inline.line,
189
+ path: inline.path,
190
+ };
191
+ }
192
+
193
+ return this.request<BitbucketComment>(
194
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments`,
195
+ {
196
+ method: "POST",
197
+ body: JSON.stringify(body),
198
+ }
199
+ );
200
+ }
201
+
202
+ async updatePullRequestComment(
203
+ workspace: string,
204
+ repoSlug: string,
205
+ prId: number,
206
+ commentId: number,
207
+ content: string
208
+ ): Promise<BitbucketComment> {
209
+ return this.request<BitbucketComment>(
210
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}`,
211
+ {
212
+ method: "PUT",
213
+ body: JSON.stringify({
214
+ content: {
215
+ raw: content,
216
+ },
217
+ }),
218
+ }
219
+ );
220
+ }
221
+
222
+ async deletePullRequestComment(
223
+ workspace: string,
224
+ repoSlug: string,
225
+ prId: number,
226
+ commentId: number
227
+ ): Promise<void> {
228
+ await this.request<void>(
229
+ `/repositories/${workspace}/${repoSlug}/pullrequests/${prId}/comments/${commentId}`,
230
+ {
231
+ method: "DELETE",
232
+ }
233
+ );
234
+ }
235
+
236
+ // Branch operations
237
+ async getBranch(
238
+ workspace: string,
239
+ repoSlug: string,
240
+ branchName: string
241
+ ): Promise<BitbucketBranch> {
242
+ return this.request<BitbucketBranch>(
243
+ `/repositories/${workspace}/${repoSlug}/refs/branches/${encodeURIComponent(branchName)}`
244
+ );
245
+ }
246
+
247
+ async createBranch(
248
+ workspace: string,
249
+ repoSlug: string,
250
+ branchName: string,
251
+ targetCommit: string
252
+ ): Promise<BitbucketBranch> {
253
+ return this.request<BitbucketBranch>(
254
+ `/repositories/${workspace}/${repoSlug}/refs/branches`,
255
+ {
256
+ method: "POST",
257
+ body: JSON.stringify({
258
+ name: branchName,
259
+ target: {
260
+ hash: targetCommit,
261
+ },
262
+ }),
263
+ }
264
+ );
265
+ }
266
+
267
+ async deleteBranch(
268
+ workspace: string,
269
+ repoSlug: string,
270
+ branchName: string
271
+ ): Promise<void> {
272
+ await this.request<void>(
273
+ `/repositories/${workspace}/${repoSlug}/refs/branches/${encodeURIComponent(branchName)}`,
274
+ {
275
+ method: "DELETE",
276
+ }
277
+ );
278
+ }
279
+
280
+ // Commit operations
281
+ async getCommit(
282
+ workspace: string,
283
+ repoSlug: string,
284
+ commitHash: string
285
+ ): Promise<BitbucketCommit> {
286
+ return this.request<BitbucketCommit>(
287
+ `/repositories/${workspace}/${repoSlug}/commit/${commitHash}`
288
+ );
289
+ }
290
+
291
+ async getFileContent(
292
+ workspace: string,
293
+ repoSlug: string,
294
+ commitHash: string,
295
+ filePath: string
296
+ ): Promise<string> {
297
+ const url = `${this.baseUrl}/repositories/${workspace}/${repoSlug}/src/${commitHash}/${encodeURIComponent(filePath)}`;
298
+ const response = await fetch(url, {
299
+ headers: {
300
+ Authorization: this.authHeader,
301
+ },
302
+ });
303
+
304
+ if (!response.ok) {
305
+ throw new BitbucketApiError(
306
+ `Failed to get file content: ${response.status}`,
307
+ response.status
308
+ );
309
+ }
310
+
311
+ return response.text();
312
+ }
313
+
314
+ // Issue operations (if issue tracker is enabled)
315
+ async getIssue(
316
+ workspace: string,
317
+ repoSlug: string,
318
+ issueId: number
319
+ ): Promise<BitbucketIssue> {
320
+ return this.request<BitbucketIssue>(
321
+ `/repositories/${workspace}/${repoSlug}/issues/${issueId}`
322
+ );
323
+ }
324
+
325
+ async getIssueComments(
326
+ workspace: string,
327
+ repoSlug: string,
328
+ issueId: number
329
+ ): Promise<BitbucketComment[]> {
330
+ return this.paginatedRequest<BitbucketComment>(
331
+ `/repositories/${workspace}/${repoSlug}/issues/${issueId}/comments`
332
+ );
333
+ }
334
+
335
+ async createIssueComment(
336
+ workspace: string,
337
+ repoSlug: string,
338
+ issueId: number,
339
+ content: string
340
+ ): Promise<BitbucketComment> {
341
+ return this.request<BitbucketComment>(
342
+ `/repositories/${workspace}/${repoSlug}/issues/${issueId}/comments`,
343
+ {
344
+ method: "POST",
345
+ body: JSON.stringify({
346
+ content: {
347
+ raw: content,
348
+ },
349
+ }),
350
+ }
351
+ );
352
+ }
353
+
354
+ // User operations
355
+ async getCurrentUser(): Promise<{
356
+ uuid: string;
357
+ display_name: string;
358
+ account_id: string;
359
+ }> {
360
+ return this.request("/user");
361
+ }
362
+
363
+ async checkUserPermissions(
364
+ workspace: string,
365
+ repoSlug: string,
366
+ userId: string
367
+ ): Promise<{ permission: "read" | "write" | "admin" }> {
368
+ const response = await this.request<{
369
+ values: Array<{ permission: "read" | "write" | "admin" }>;
370
+ }>(
371
+ `/repositories/${workspace}/${repoSlug}/permissions-config/users/${userId}`
372
+ );
373
+
374
+ return { permission: response.values[0]?.permission || "read" };
375
+ }
376
+ }
377
+
378
+ export class BitbucketApiError extends Error {
379
+ constructor(
380
+ message: string,
381
+ public statusCode: number,
382
+ public responseBody?: string
383
+ ) {
384
+ super(message);
385
+ this.name = "BitbucketApiError";
386
+ }
387
+ }
388
+
389
+ // Factory function for creating client from environment variables
390
+ export function createBitbucketClientFromEnv(): BitbucketClient {
391
+ const accessToken = process.env.BITBUCKET_ACCESS_TOKEN;
392
+ const username = process.env.BITBUCKET_USERNAME;
393
+ const appPassword = process.env.BITBUCKET_APP_PASSWORD;
394
+
395
+ if (accessToken) {
396
+ return new BitbucketClient({ accessToken });
397
+ }
398
+
399
+ if (username && appPassword) {
400
+ return new BitbucketClient({ username, appPassword });
401
+ }
402
+
403
+ throw new Error(
404
+ "Missing Bitbucket credentials. Set BITBUCKET_ACCESS_TOKEN or BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD"
405
+ );
406
+ }