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,175 @@
1
+ /**
2
+ * Trigger Validation
3
+ * Validates if a trigger should activate the action
4
+ */
5
+
6
+ import type { ParsedBitbucketContext, BitbucketComment } from "../types.js";
7
+
8
+ export interface TriggerValidationResult {
9
+ shouldTrigger: boolean;
10
+ reason: string;
11
+ triggerType?: "mention" | "label" | "assignee" | "automation" | "manual";
12
+ userMessage?: string;
13
+ }
14
+
15
+ export interface TriggerOptions {
16
+ triggerPhrase: string;
17
+ labelTrigger?: string;
18
+ assigneeTrigger?: string;
19
+ prompt?: string;
20
+ botAccountId?: string;
21
+ }
22
+
23
+ /**
24
+ * Validate if the context should trigger the action
25
+ */
26
+ export function validateTrigger(
27
+ context: ParsedBitbucketContext,
28
+ options: TriggerOptions
29
+ ): TriggerValidationResult {
30
+ // Explicit prompt always triggers (agent mode)
31
+ if (options.prompt) {
32
+ return {
33
+ shouldTrigger: true,
34
+ reason: "Explicit prompt provided",
35
+ triggerType: "automation",
36
+ userMessage: options.prompt,
37
+ };
38
+ }
39
+
40
+ // Check for manual/schedule triggers
41
+ if (
42
+ context.eventType === "manual" ||
43
+ context.eventType === "schedule"
44
+ ) {
45
+ return {
46
+ shouldTrigger: true,
47
+ reason: `Triggered by ${context.eventType} event`,
48
+ triggerType: "manual",
49
+ };
50
+ }
51
+
52
+ // Check comment-based triggers
53
+ if (
54
+ context.eventType === "pullrequest:comment_created" ||
55
+ context.eventType === "pullrequest:comment_updated"
56
+ ) {
57
+ return validateCommentTrigger(context.comment, options);
58
+ }
59
+
60
+ // No trigger matched
61
+ return {
62
+ shouldTrigger: false,
63
+ reason: `Event type ${context.eventType} does not match any trigger`,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Validate comment-based trigger
69
+ */
70
+ function validateCommentTrigger(
71
+ comment: BitbucketComment | undefined,
72
+ options: TriggerOptions
73
+ ): TriggerValidationResult {
74
+ if (!comment) {
75
+ return {
76
+ shouldTrigger: false,
77
+ reason: "No comment in context",
78
+ };
79
+ }
80
+
81
+ // Don't respond to own comments (prevent infinite loops)
82
+ if (options.botAccountId && comment.user.account_id === options.botAccountId) {
83
+ return {
84
+ shouldTrigger: false,
85
+ reason: "Comment is from the bot itself",
86
+ };
87
+ }
88
+
89
+ // Check for trigger phrase
90
+ const content = comment.content.raw;
91
+ const triggerPhrase = options.triggerPhrase.toLowerCase();
92
+
93
+ if (!content.toLowerCase().includes(triggerPhrase)) {
94
+ return {
95
+ shouldTrigger: false,
96
+ reason: `Comment does not contain trigger phrase: ${options.triggerPhrase}`,
97
+ };
98
+ }
99
+
100
+ // Extract user message (remove trigger phrase)
101
+ const userMessage = extractUserMessage(content, options.triggerPhrase);
102
+
103
+ return {
104
+ shouldTrigger: true,
105
+ reason: "Trigger phrase found in comment",
106
+ triggerType: "mention",
107
+ userMessage,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Extract user message from comment content
113
+ */
114
+ function extractUserMessage(content: string, triggerPhrase: string): string {
115
+ // Remove trigger phrase (case insensitive)
116
+ const regex = new RegExp(escapeRegex(triggerPhrase), "gi");
117
+ const cleaned = content.replace(regex, "").trim();
118
+
119
+ // Remove leading/trailing whitespace and newlines
120
+ return cleaned.replace(/^\s+|\s+$/g, "");
121
+ }
122
+
123
+ /**
124
+ * Escape special regex characters
125
+ */
126
+ function escapeRegex(str: string): string {
127
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
128
+ }
129
+
130
+ /**
131
+ * Check if comment was modified after trigger time
132
+ */
133
+ export function isCommentModifiedAfterTrigger(
134
+ comment: BitbucketComment,
135
+ triggerTimestamp: string
136
+ ): boolean {
137
+ const triggerTime = new Date(triggerTimestamp).getTime();
138
+ const updatedTime = new Date(comment.updated_on).getTime();
139
+ const createdTime = new Date(comment.created_on).getTime();
140
+
141
+ // If updated after created and after trigger, it was modified
142
+ return updatedTime > createdTime && updatedTime > triggerTime;
143
+ }
144
+
145
+ /**
146
+ * Validate comment content for safety
147
+ */
148
+ export function validateCommentContent(content: string): {
149
+ safe: boolean;
150
+ warnings: string[];
151
+ } {
152
+ const warnings: string[] = [];
153
+
154
+ // Check for potential injection attempts
155
+ const suspiciousPatterns = [
156
+ /```system/i,
157
+ /\[INST\]/i,
158
+ /<\|im_start\|>/i,
159
+ /<\|im_end\|>/i,
160
+ /You are now/i,
161
+ /Ignore previous/i,
162
+ /Forget all/i,
163
+ ];
164
+
165
+ for (const pattern of suspiciousPatterns) {
166
+ if (pattern.test(content)) {
167
+ warnings.push(`Suspicious pattern detected: ${pattern.toString()}`);
168
+ }
169
+ }
170
+
171
+ return {
172
+ safe: warnings.length === 0,
173
+ warnings,
174
+ };
175
+ }
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Execute Entrypoint
3
+ * Phase 2: Run Gemini and process results
4
+ */
5
+
6
+ import { createGeminiClientFromEnv } from "../gemini/client.js";
7
+ import { createBitbucketClientFromEnv } from "../bitbucket/api/client.js";
8
+ import { parseBitbucketContext } from "../bitbucket/context.js";
9
+ import { fetchPRData } from "../bitbucket/data/fetcher.js";
10
+ import { formatPRContext } from "../bitbucket/data/formatter.js";
11
+ import {
12
+ buildPRReviewPrompt,
13
+ buildTagModePrompt,
14
+ buildAgentModePrompt,
15
+ buildPRReviewPromptWithPresets,
16
+ buildTagModePromptWithPresets,
17
+ buildAgentModePromptWithPresets,
18
+ buildSystemPromptWithPresets,
19
+ } from "../gemini/prompts.js";
20
+ import { getToolsForMode } from "../gemini/tools.js";
21
+ import {
22
+ updateTrackingComment,
23
+ createInlineComment,
24
+ createPRComment,
25
+ } from "../bitbucket/operations/comments.js";
26
+ import { getReviewPresets, getCustomPrompt } from "../utils/env.js";
27
+ import { withRetry, GEMINI_RETRY_OPTIONS } from "../utils/retry.js";
28
+ import type { GeminiToolCall } from "../gemini/client.js";
29
+
30
+ interface ExecuteOptions {
31
+ mode: "tag" | "agent";
32
+ prompt: string;
33
+ systemPrompt: string;
34
+ trackingCommentId?: number;
35
+ userMessage?: string;
36
+ mentionAuthor?: string;
37
+ }
38
+
39
+ interface ExecuteResult {
40
+ success: boolean;
41
+ response?: string;
42
+ toolCalls?: GeminiToolCall[];
43
+ inlineCommentsCreated: number;
44
+ error?: string;
45
+ }
46
+
47
+ /**
48
+ * Main execute function
49
+ */
50
+ export async function execute(options?: ExecuteOptions): Promise<ExecuteResult> {
51
+ console.log("🚀 Starting Bitbucket Gemini Action - Execute Phase");
52
+
53
+ try {
54
+ // Load options from prepare phase if not provided
55
+ const opts = options || loadPrepareOutput();
56
+ console.log(`📋 Mode: ${opts.mode}`);
57
+
58
+ // Create clients
59
+ const geminiClient = createGeminiClientFromEnv();
60
+ const bitbucketClient = createBitbucketClientFromEnv();
61
+
62
+ // Parse context
63
+ const context = parseBitbucketContext();
64
+
65
+ // Update tracking comment to show progress
66
+ if (opts.trackingCommentId && context.entityNumber) {
67
+ await updateTrackingComment(
68
+ bitbucketClient,
69
+ context.workspace,
70
+ context.repoSlug,
71
+ context.entityNumber,
72
+ opts.trackingCommentId,
73
+ {
74
+ status: "in_progress",
75
+ message: "Analyzing code changes...",
76
+ }
77
+ );
78
+ }
79
+
80
+ // Fetch PR data
81
+ let prContext;
82
+ if (context.isPR && context.entityNumber) {
83
+ console.log("📥 Fetching PR data...");
84
+ const prData = await fetchPRData(bitbucketClient, context);
85
+ prContext = formatPRContext(prData);
86
+ }
87
+
88
+ // Get review presets and custom prompt
89
+ const presetKeys = getReviewPresets();
90
+ const customPrompt = getCustomPrompt();
91
+
92
+ if (presetKeys.length > 0) {
93
+ console.log(`📋 Review Presets: ${presetKeys.join(", ")}`);
94
+ }
95
+ if (customPrompt) {
96
+ console.log(`📋 Custom Prompt: ${customPrompt.substring(0, 50)}...`);
97
+ }
98
+
99
+ // Build prompt based on mode (with presets if configured)
100
+ let fullPrompt: string;
101
+ if (opts.mode === "tag" && prContext) {
102
+ fullPrompt = presetKeys.length > 0 || customPrompt
103
+ ? buildTagModePromptWithPresets(
104
+ prContext,
105
+ opts.userMessage || opts.prompt,
106
+ opts.mentionAuthor || "user",
107
+ presetKeys,
108
+ customPrompt
109
+ )
110
+ : buildTagModePrompt(
111
+ prContext,
112
+ opts.userMessage || opts.prompt,
113
+ opts.mentionAuthor || "user"
114
+ );
115
+ } else if (opts.mode === "agent" && prContext) {
116
+ fullPrompt = presetKeys.length > 0 || customPrompt
117
+ ? buildAgentModePromptWithPresets(prContext, opts.prompt, presetKeys, customPrompt)
118
+ : buildAgentModePrompt(prContext, opts.prompt);
119
+ } else if (prContext) {
120
+ fullPrompt = presetKeys.length > 0 || customPrompt
121
+ ? buildPRReviewPromptWithPresets(prContext, presetKeys, customPrompt)
122
+ : buildPRReviewPrompt(prContext);
123
+ } else {
124
+ fullPrompt = opts.prompt;
125
+ }
126
+
127
+ // Apply presets to system prompt if configured
128
+ const systemPrompt = presetKeys.length > 0 || customPrompt
129
+ ? buildSystemPromptWithPresets(opts.systemPrompt, presetKeys, customPrompt)
130
+ : opts.systemPrompt;
131
+
132
+ // Get tools for mode
133
+ const tools = getToolsForMode(opts.mode === "agent" ? "full" : "lightweight");
134
+
135
+ console.log("🤖 Calling Gemini API...");
136
+
137
+ // Call Gemini with retry
138
+ const response = await withRetry(
139
+ () =>
140
+ geminiClient.generateWithTools(fullPrompt, tools, systemPrompt),
141
+ GEMINI_RETRY_OPTIONS
142
+ );
143
+
144
+ console.log(`✅ Gemini response received (${response.finishReason})`);
145
+
146
+ // Process tool calls
147
+ let inlineCommentsCreated = 0;
148
+ if (response.toolCalls && context.entityNumber) {
149
+ inlineCommentsCreated = await processToolCalls(
150
+ bitbucketClient,
151
+ context.workspace,
152
+ context.repoSlug,
153
+ context.entityNumber,
154
+ response.toolCalls,
155
+ opts.trackingCommentId
156
+ );
157
+ }
158
+
159
+ // If no tool calls but we have a text response, post it as a comment
160
+ if (
161
+ !response.toolCalls?.length &&
162
+ response.text &&
163
+ context.entityNumber
164
+ ) {
165
+ await createPRComment(
166
+ bitbucketClient,
167
+ context.workspace,
168
+ context.repoSlug,
169
+ context.entityNumber,
170
+ response.text
171
+ );
172
+ }
173
+
174
+ // Update tracking comment with final status
175
+ if (opts.trackingCommentId && context.entityNumber) {
176
+ await updateTrackingComment(
177
+ bitbucketClient,
178
+ context.workspace,
179
+ context.repoSlug,
180
+ context.entityNumber,
181
+ opts.trackingCommentId,
182
+ {
183
+ status: "completed",
184
+ message: "Review completed successfully.",
185
+ summary: response.text.substring(0, 500),
186
+ inlineCommentsCount: inlineCommentsCreated,
187
+ }
188
+ );
189
+ }
190
+
191
+ console.log("✅ Execute phase completed successfully");
192
+
193
+ return {
194
+ success: true,
195
+ response: response.text,
196
+ toolCalls: response.toolCalls,
197
+ inlineCommentsCreated,
198
+ };
199
+ } catch (error) {
200
+ const message = error instanceof Error ? error.message : "Unknown error";
201
+ console.error(`❌ Execute phase failed: ${message}`);
202
+
203
+ // Try to update tracking comment with error
204
+ try {
205
+ const context = parseBitbucketContext();
206
+ const bitbucketClient = createBitbucketClientFromEnv();
207
+ const opts = options || loadPrepareOutput();
208
+
209
+ if (opts.trackingCommentId && context.entityNumber) {
210
+ await updateTrackingComment(
211
+ bitbucketClient,
212
+ context.workspace,
213
+ context.repoSlug,
214
+ context.entityNumber,
215
+ opts.trackingCommentId,
216
+ {
217
+ status: "failed",
218
+ error: message,
219
+ }
220
+ );
221
+ }
222
+ } catch {
223
+ // Ignore errors updating tracking comment
224
+ }
225
+
226
+ return {
227
+ success: false,
228
+ inlineCommentsCreated: 0,
229
+ error: message,
230
+ };
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Process Gemini tool calls
236
+ */
237
+ async function processToolCalls(
238
+ client: ReturnType<typeof createBitbucketClientFromEnv>,
239
+ workspace: string,
240
+ repoSlug: string,
241
+ prId: number,
242
+ toolCalls: GeminiToolCall[],
243
+ trackingCommentId?: number
244
+ ): Promise<number> {
245
+ let inlineCommentsCreated = 0;
246
+
247
+ for (const toolCall of toolCalls) {
248
+ console.log(`🔧 Processing tool call: ${toolCall.name}`);
249
+
250
+ try {
251
+ switch (toolCall.name) {
252
+ case "create_inline_comment": {
253
+ const args = toolCall.args as {
254
+ path: string;
255
+ line: number;
256
+ content: string;
257
+ };
258
+ await createInlineComment(
259
+ client,
260
+ workspace,
261
+ repoSlug,
262
+ prId,
263
+ args.path,
264
+ args.line,
265
+ args.content
266
+ );
267
+ inlineCommentsCreated++;
268
+ break;
269
+ }
270
+
271
+ case "create_pr_comment": {
272
+ const args = toolCall.args as { content: string };
273
+ await createPRComment(client, workspace, repoSlug, prId, args.content);
274
+ break;
275
+ }
276
+
277
+ case "update_tracking_comment": {
278
+ if (trackingCommentId) {
279
+ const args = toolCall.args as {
280
+ content: string;
281
+ status: "in_progress" | "completed" | "failed";
282
+ };
283
+ await updateTrackingComment(
284
+ client,
285
+ workspace,
286
+ repoSlug,
287
+ prId,
288
+ trackingCommentId,
289
+ {
290
+ status: args.status,
291
+ message: args.content,
292
+ }
293
+ );
294
+ }
295
+ break;
296
+ }
297
+
298
+ default:
299
+ console.warn(`Unknown tool call: ${toolCall.name}`);
300
+ }
301
+ } catch (error) {
302
+ console.error(
303
+ `Error processing tool call ${toolCall.name}:`,
304
+ error instanceof Error ? error.message : error
305
+ );
306
+ }
307
+ }
308
+
309
+ return inlineCommentsCreated;
310
+ }
311
+
312
+ /**
313
+ * Load output from prepare phase
314
+ */
315
+ function loadPrepareOutput(): ExecuteOptions {
316
+ const fs = require("fs");
317
+ const outputDir = process.env.BITBUCKET_CLONE_DIR || ".";
318
+ const outputPath = `${outputDir}/.gemini-action-output.json`;
319
+
320
+ if (!fs.existsSync(outputPath)) {
321
+ throw new Error(
322
+ "Prepare phase output not found. Run prepare phase first."
323
+ );
324
+ }
325
+
326
+ const data = JSON.parse(fs.readFileSync(outputPath, "utf-8"));
327
+
328
+ return {
329
+ mode: data.mode || "tag",
330
+ prompt: data.prompt || "",
331
+ systemPrompt: data.systemPrompt || "",
332
+ trackingCommentId: data.trackingCommentId,
333
+ };
334
+ }
335
+
336
+ // Run if executed directly
337
+ if (import.meta.main) {
338
+ execute()
339
+ .then((result) => {
340
+ if (!result.success) {
341
+ process.exit(1);
342
+ }
343
+ console.log(`📊 Inline comments created: ${result.inlineCommentsCreated}`);
344
+ })
345
+ .catch((error) => {
346
+ console.error("Fatal error:", error);
347
+ process.exit(1);
348
+ });
349
+ }