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,216 @@
1
+ /**
2
+ * Prepare Entrypoint
3
+ * Phase 1: Initialize context, validate triggers, prepare for execution
4
+ */
5
+
6
+ import { parseBitbucketContext } from "../bitbucket/context.js";
7
+ import { createBitbucketClientFromEnv } from "../bitbucket/api/client.js";
8
+ import { detectMode } from "../modes/registry.js";
9
+ import { validateTrigger } from "../bitbucket/validation/trigger.js";
10
+ import { validateActor } from "../bitbucket/validation/permissions.js";
11
+ import { fetchPRData } from "../bitbucket/data/fetcher.js";
12
+ import { formatPRContext } from "../bitbucket/data/formatter.js";
13
+ import { getEnvConfig, getPipelineUrl, getEnvBool } from "../utils/env.js";
14
+ import type { ParsedBitbucketContext } from "../bitbucket/types.js";
15
+ import type { ModeName } from "../modes/types.js";
16
+
17
+ export interface PrepareResult {
18
+ success: boolean;
19
+ shouldContinue: boolean;
20
+ context?: ParsedBitbucketContext;
21
+ prompt?: string;
22
+ systemPrompt?: string;
23
+ trackingCommentId?: number;
24
+ prContext?: ReturnType<typeof formatPRContext>;
25
+ error?: string;
26
+ }
27
+
28
+ /**
29
+ * Main prepare function
30
+ */
31
+ export async function prepare(): Promise<PrepareResult> {
32
+ console.log("🚀 Starting Bitbucket Gemini Action - Prepare Phase");
33
+
34
+ try {
35
+ // Load configuration
36
+ const config = getEnvConfig();
37
+ console.log(`📋 Workspace: ${config.BITBUCKET_WORKSPACE}`);
38
+ console.log(`📋 Repository: ${config.BITBUCKET_REPO_SLUG}`);
39
+ console.log(`📋 Trigger Phrase: ${config.TRIGGER_PHRASE}`);
40
+
41
+ // Parse Bitbucket context
42
+ const context = parseBitbucketContext();
43
+ console.log(`📋 Event Type: ${context.eventType}`);
44
+ console.log(`📋 Actor: ${context.actor.display_name}`);
45
+
46
+ if (context.entityNumber) {
47
+ console.log(`📋 PR Number: ${context.entityNumber}`);
48
+ }
49
+
50
+ // Create Bitbucket client
51
+ const client = createBitbucketClientFromEnv();
52
+
53
+ // Validate trigger
54
+ const triggerResult = validateTrigger(context, {
55
+ triggerPhrase: config.TRIGGER_PHRASE,
56
+ prompt: config.PROMPT,
57
+ botAccountId: config.BOT_ACCOUNT_ID,
58
+ });
59
+
60
+ if (!triggerResult.shouldTrigger) {
61
+ console.log(`⏭️ Skipping: ${triggerResult.reason}`);
62
+ return {
63
+ success: true,
64
+ shouldContinue: false,
65
+ error: triggerResult.reason,
66
+ };
67
+ }
68
+
69
+ console.log(`✅ Trigger validated: ${triggerResult.reason}`);
70
+
71
+ // Validate actor permissions
72
+ const actorResult = await validateActor(
73
+ client,
74
+ context.workspace,
75
+ context.repoSlug,
76
+ context.actor,
77
+ {
78
+ requireWritePermission: true,
79
+ allowBots: false,
80
+ }
81
+ );
82
+
83
+ if (!actorResult.valid) {
84
+ console.log(`⏭️ Skipping: ${actorResult.reason}`);
85
+ return {
86
+ success: true,
87
+ shouldContinue: false,
88
+ error: actorResult.reason,
89
+ };
90
+ }
91
+
92
+ console.log("✅ Actor validation passed");
93
+
94
+ // Detect mode
95
+ const { mode, reason } = detectMode(context, {
96
+ triggerPhrase: config.TRIGGER_PHRASE,
97
+ prompt: config.PROMPT,
98
+ mode: config.MODE as ModeName | undefined,
99
+ });
100
+
101
+ console.log(`📋 Mode: ${mode.name} (${reason})`);
102
+
103
+ // Prepare mode context
104
+ const createTrackingComment = getEnvBool("CREATE_TRACKING_COMMENT", true);
105
+ const modeResult = await mode.prepare({
106
+ context,
107
+ client,
108
+ triggerPhrase: config.TRIGGER_PHRASE,
109
+ prompt: config.PROMPT,
110
+ createTrackingComment,
111
+ });
112
+
113
+ if (!modeResult.success) {
114
+ console.error(`❌ Mode preparation failed: ${modeResult.error}`);
115
+ return {
116
+ success: false,
117
+ shouldContinue: false,
118
+ context,
119
+ error: modeResult.error,
120
+ };
121
+ }
122
+
123
+ // Fetch PR data if in PR context
124
+ let prContext: ReturnType<typeof formatPRContext> | undefined;
125
+ if (context.isPR && context.entityNumber) {
126
+ console.log("📥 Fetching PR data...");
127
+ const prData = await fetchPRData(client, context, {
128
+ includeComments: true,
129
+ includeDiff: true,
130
+ includeCommits: true,
131
+ filterCommentsAfter: context.triggerTimestamp,
132
+ });
133
+ prContext = formatPRContext(prData);
134
+ console.log(`📥 Fetched ${prContext.files.length} changed files`);
135
+ }
136
+
137
+ console.log("✅ Prepare phase completed successfully");
138
+
139
+ // Output results for next phase
140
+ outputResults({
141
+ containsTrigger: true,
142
+ mode: mode.name,
143
+ trackingCommentId: modeResult.trackingCommentId,
144
+ prompt: triggerResult.userMessage || config.PROMPT || "",
145
+ systemPrompt: modeResult.modeContext.systemPrompt,
146
+ tools: modeResult.modeContext.tools,
147
+ pipelineUrl: getPipelineUrl(),
148
+ });
149
+
150
+ return {
151
+ success: true,
152
+ shouldContinue: true,
153
+ context,
154
+ prompt: triggerResult.userMessage || config.PROMPT,
155
+ systemPrompt: modeResult.modeContext.systemPrompt,
156
+ trackingCommentId: modeResult.trackingCommentId,
157
+ prContext,
158
+ };
159
+ } catch (error) {
160
+ const message = error instanceof Error ? error.message : "Unknown error";
161
+ console.error(`❌ Prepare phase failed: ${message}`);
162
+ return {
163
+ success: false,
164
+ shouldContinue: false,
165
+ error: message,
166
+ };
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Output results for pipeline consumption
172
+ */
173
+ function outputResults(results: {
174
+ containsTrigger: boolean;
175
+ mode: string;
176
+ trackingCommentId?: number;
177
+ prompt: string;
178
+ systemPrompt: string;
179
+ tools: string[];
180
+ pipelineUrl?: string;
181
+ }) {
182
+ // Write to files for pipeline use
183
+ const fs = require("fs");
184
+ const outputDir = process.env.BITBUCKET_CLONE_DIR || ".";
185
+
186
+ const outputPath = `${outputDir}/.gemini-action-output.json`;
187
+ fs.writeFileSync(outputPath, JSON.stringify(results, null, 2));
188
+ console.log(`📝 Output written to ${outputPath}`);
189
+
190
+ // Also set environment variables for immediate use
191
+ console.log(`::set-output name=contains_trigger::${results.containsTrigger}`);
192
+ console.log(`::set-output name=mode::${results.mode}`);
193
+ if (results.trackingCommentId) {
194
+ console.log(
195
+ `::set-output name=tracking_comment_id::${results.trackingCommentId}`
196
+ );
197
+ }
198
+ }
199
+
200
+ // Run if executed directly
201
+ if (import.meta.main) {
202
+ prepare()
203
+ .then((result) => {
204
+ if (!result.success) {
205
+ process.exit(1);
206
+ }
207
+ if (!result.shouldContinue) {
208
+ console.log("ℹ️ No action needed");
209
+ process.exit(0);
210
+ }
211
+ })
212
+ .catch((error) => {
213
+ console.error("Fatal error:", error);
214
+ process.exit(1);
215
+ });
216
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Google Gemini API Client
3
+ * Wrapper for @google/generative-ai SDK
4
+ */
5
+
6
+ import {
7
+ GoogleGenerativeAI,
8
+ GenerativeModel,
9
+ GenerationConfig,
10
+ Content,
11
+ Part,
12
+ FunctionDeclaration,
13
+ Tool,
14
+ FunctionCallingMode,
15
+ type GenerateContentResult,
16
+ } from "@google/generative-ai";
17
+
18
+ export interface GeminiClientOptions {
19
+ apiKey: string;
20
+ model?: string;
21
+ generationConfig?: GenerationConfig;
22
+ }
23
+
24
+ export interface GeminiMessage {
25
+ role: "user" | "model";
26
+ parts: Part[];
27
+ }
28
+
29
+ export interface GeminiToolCall {
30
+ name: string;
31
+ args: Record<string, unknown>;
32
+ }
33
+
34
+ export interface GeminiResponse {
35
+ text: string;
36
+ toolCalls?: GeminiToolCall[];
37
+ finishReason: string;
38
+ usage?: {
39
+ promptTokenCount: number;
40
+ candidatesTokenCount: number;
41
+ totalTokenCount: number;
42
+ };
43
+ }
44
+
45
+ const DEFAULT_MODEL = "gemini-2.0-flash";
46
+
47
+ const DEFAULT_GENERATION_CONFIG: GenerationConfig = {
48
+ temperature: 0.7,
49
+ topP: 0.95,
50
+ topK: 40,
51
+ maxOutputTokens: 8192,
52
+ };
53
+
54
+ export class GeminiClient {
55
+ private genAI: GoogleGenerativeAI;
56
+ private model: GenerativeModel;
57
+ private modelName: string;
58
+ private generationConfig: GenerationConfig;
59
+
60
+ constructor(options: GeminiClientOptions) {
61
+ this.genAI = new GoogleGenerativeAI(options.apiKey);
62
+ this.modelName = options.model || DEFAULT_MODEL;
63
+ this.generationConfig = {
64
+ ...DEFAULT_GENERATION_CONFIG,
65
+ ...options.generationConfig,
66
+ };
67
+ this.model = this.genAI.getGenerativeModel({
68
+ model: this.modelName,
69
+ generationConfig: this.generationConfig,
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Generate content with a simple prompt
75
+ */
76
+ async generateContent(prompt: string): Promise<GeminiResponse> {
77
+ const result = await this.model.generateContent(prompt);
78
+ return this.parseResponse(result);
79
+ }
80
+
81
+ /**
82
+ * Generate content with chat history
83
+ */
84
+ async generateWithHistory(
85
+ history: GeminiMessage[],
86
+ prompt: string
87
+ ): Promise<GeminiResponse> {
88
+ const chat = this.model.startChat({
89
+ history: history.map((msg) => ({
90
+ role: msg.role,
91
+ parts: msg.parts,
92
+ })),
93
+ generationConfig: this.generationConfig,
94
+ });
95
+
96
+ const result = await chat.sendMessage(prompt);
97
+ return this.parseResponse(result);
98
+ }
99
+
100
+ /**
101
+ * Generate content with system instruction
102
+ */
103
+ async generateWithSystemPrompt(
104
+ systemPrompt: string,
105
+ userPrompt: string
106
+ ): Promise<GeminiResponse> {
107
+ const modelWithSystem = this.genAI.getGenerativeModel({
108
+ model: this.modelName,
109
+ systemInstruction: systemPrompt,
110
+ generationConfig: this.generationConfig,
111
+ });
112
+
113
+ const result = await modelWithSystem.generateContent(userPrompt);
114
+ return this.parseResponse(result);
115
+ }
116
+
117
+ /**
118
+ * Generate content with function calling (tools)
119
+ */
120
+ async generateWithTools(
121
+ prompt: string,
122
+ tools: FunctionDeclaration[],
123
+ systemPrompt?: string
124
+ ): Promise<GeminiResponse> {
125
+ const toolConfig: Tool[] = [
126
+ {
127
+ functionDeclarations: tools,
128
+ },
129
+ ];
130
+
131
+ const modelWithTools = this.genAI.getGenerativeModel({
132
+ model: this.modelName,
133
+ systemInstruction: systemPrompt,
134
+ generationConfig: this.generationConfig,
135
+ tools: toolConfig,
136
+ toolConfig: {
137
+ functionCallingConfig: {
138
+ mode: FunctionCallingMode.AUTO,
139
+ },
140
+ },
141
+ });
142
+
143
+ const result = await modelWithTools.generateContent(prompt);
144
+ return this.parseResponse(result);
145
+ }
146
+
147
+ /**
148
+ * Start a chat session for multi-turn conversation
149
+ */
150
+ startChat(options?: {
151
+ history?: Content[];
152
+ systemInstruction?: string;
153
+ tools?: FunctionDeclaration[];
154
+ }) {
155
+ const modelConfig: {
156
+ model: string;
157
+ generationConfig: GenerationConfig;
158
+ systemInstruction?: string;
159
+ tools?: Tool[];
160
+ } = {
161
+ model: this.modelName,
162
+ generationConfig: this.generationConfig,
163
+ };
164
+
165
+ if (options?.systemInstruction) {
166
+ modelConfig.systemInstruction = options.systemInstruction;
167
+ }
168
+
169
+ if (options?.tools) {
170
+ modelConfig.tools = [
171
+ {
172
+ functionDeclarations: options.tools,
173
+ },
174
+ ];
175
+ }
176
+
177
+ const model = this.genAI.getGenerativeModel(modelConfig);
178
+
179
+ return model.startChat({
180
+ history: options?.history || [],
181
+ generationConfig: this.generationConfig,
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Count tokens for a given content
187
+ */
188
+ async countTokens(content: string | Part[]): Promise<number> {
189
+ const result = await this.model.countTokens(content);
190
+ return result.totalTokens;
191
+ }
192
+
193
+ private parseResponse(result: GenerateContentResult): GeminiResponse {
194
+ const response = result.response;
195
+ const candidate = response.candidates?.[0];
196
+
197
+ if (!candidate) {
198
+ throw new GeminiApiError("No response candidates returned", "NO_CANDIDATES");
199
+ }
200
+
201
+ const text =
202
+ candidate.content?.parts
203
+ ?.filter((part): part is { text: string } => "text" in part)
204
+ .map((part) => part.text)
205
+ .join("") || "";
206
+
207
+ // Extract function calls if present
208
+ const toolCalls = candidate.content?.parts
209
+ ?.filter(
210
+ (part): part is { functionCall: { name: string; args: Record<string, unknown> } } =>
211
+ "functionCall" in part
212
+ )
213
+ .map((part) => ({
214
+ name: part.functionCall.name,
215
+ args: part.functionCall.args,
216
+ }));
217
+
218
+ return {
219
+ text,
220
+ toolCalls: toolCalls?.length ? toolCalls : undefined,
221
+ finishReason: candidate.finishReason || "UNKNOWN",
222
+ usage: response.usageMetadata
223
+ ? {
224
+ promptTokenCount: response.usageMetadata.promptTokenCount || 0,
225
+ candidatesTokenCount:
226
+ response.usageMetadata.candidatesTokenCount || 0,
227
+ totalTokenCount: response.usageMetadata.totalTokenCount || 0,
228
+ }
229
+ : undefined,
230
+ };
231
+ }
232
+ }
233
+
234
+ export class GeminiApiError extends Error {
235
+ constructor(
236
+ message: string,
237
+ public code: string,
238
+ public details?: unknown
239
+ ) {
240
+ super(message);
241
+ this.name = "GeminiApiError";
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Create Gemini client from environment variables
247
+ */
248
+ export function createGeminiClientFromEnv(): GeminiClient {
249
+ const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
250
+
251
+ if (!apiKey) {
252
+ throw new Error(
253
+ "Missing Gemini API key. Set GEMINI_API_KEY or GOOGLE_API_KEY environment variable"
254
+ );
255
+ }
256
+
257
+ const model = process.env.GEMINI_MODEL || DEFAULT_MODEL;
258
+
259
+ return new GeminiClient({
260
+ apiKey,
261
+ model,
262
+ });
263
+ }