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,119 @@
1
+ /**
2
+ * Agent Mode Implementation
3
+ * Automated execution with explicit prompt
4
+ */
5
+
6
+ import type {
7
+ Mode,
8
+ ModeOptions,
9
+ ModeResult,
10
+ TriggerCheckResult,
11
+ } from "../types.js";
12
+ import type { ParsedBitbucketContext } from "../../bitbucket/types.js";
13
+ import { AGENT_MODE_SYSTEM_PROMPT } from "../../gemini/prompts.js";
14
+ import { codeReviewTools } from "../../gemini/tools.js";
15
+
16
+ export class AgentMode implements Mode {
17
+ name = "agent" as const;
18
+ description = "Automated execution with explicit prompt from pipeline";
19
+
20
+ shouldTrigger(
21
+ _context: ParsedBitbucketContext,
22
+ options: { triggerPhrase: string; prompt?: string }
23
+ ): TriggerCheckResult {
24
+ // Agent mode triggers when explicit prompt is provided
25
+ if (!options.prompt) {
26
+ return {
27
+ shouldTrigger: false,
28
+ reason: "No explicit prompt provided",
29
+ };
30
+ }
31
+
32
+ return {
33
+ shouldTrigger: true,
34
+ userMessage: options.prompt,
35
+ };
36
+ }
37
+
38
+ async prepare(options: ModeOptions): Promise<ModeResult> {
39
+ const { context, client, prompt } = options;
40
+
41
+ try {
42
+ if (!prompt) {
43
+ return {
44
+ success: false,
45
+ modeContext: {
46
+ prompt: "",
47
+ systemPrompt: "",
48
+ tools: [],
49
+ },
50
+ error: "Agent mode requires an explicit prompt",
51
+ };
52
+ }
53
+
54
+ // Optionally create tracking comment for visibility
55
+ let trackingCommentId: number | undefined;
56
+ if (options.createTrackingComment && context.entityNumber) {
57
+ const trackingComment = await client.createPullRequestComment(
58
+ context.workspace,
59
+ context.repoSlug,
60
+ context.entityNumber,
61
+ this.getInitialTrackingCommentContent(prompt)
62
+ );
63
+ trackingCommentId = trackingComment.id;
64
+ }
65
+
66
+ return {
67
+ success: true,
68
+ modeContext: {
69
+ prompt,
70
+ systemPrompt: this.getSystemPrompt(context),
71
+ tools: this.getAllowedTools(),
72
+ trackingCommentId,
73
+ },
74
+ trackingCommentId,
75
+ };
76
+ } catch (error) {
77
+ return {
78
+ success: false,
79
+ modeContext: {
80
+ prompt: "",
81
+ systemPrompt: "",
82
+ tools: [],
83
+ },
84
+ error: error instanceof Error ? error.message : "Unknown error",
85
+ };
86
+ }
87
+ }
88
+
89
+ getSystemPrompt(_context: ParsedBitbucketContext): string {
90
+ return AGENT_MODE_SYSTEM_PROMPT;
91
+ }
92
+
93
+ getAllowedTools(): string[] {
94
+ return codeReviewTools.map((t) => t.name);
95
+ }
96
+
97
+ shouldCreateTrackingComment(): boolean {
98
+ // Agent mode doesn't require tracking comment by default
99
+ return false;
100
+ }
101
+
102
+ private getInitialTrackingCommentContent(prompt: string): string {
103
+ // Truncate long prompts for display
104
+ const displayPrompt =
105
+ prompt.length > 200 ? `${prompt.substring(0, 200)}...` : prompt;
106
+
107
+ return `## 🤖 Gemini Agent Task
108
+
109
+ ⏳ **Status**: Executing automated task...
110
+
111
+ **Task**:
112
+ > ${displayPrompt}
113
+
114
+ ---
115
+ _This comment will be updated with the results._`;
116
+ }
117
+ }
118
+
119
+ export const agentMode = new AgentMode();
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Mode Registry
3
+ * Central registry for all execution modes
4
+ */
5
+
6
+ import type { Mode, ModeName, ModeRegistry } from "./types.js";
7
+ import type { ParsedBitbucketContext } from "../bitbucket/types.js";
8
+ import { tagMode } from "./tag/index.js";
9
+ import { agentMode } from "./agent/index.js";
10
+
11
+ /**
12
+ * Registry of all available modes
13
+ */
14
+ export const modeRegistry: ModeRegistry = {
15
+ tag: tagMode,
16
+ agent: agentMode,
17
+ };
18
+
19
+ /**
20
+ * Get a mode by name
21
+ */
22
+ export function getMode(name: ModeName): Mode {
23
+ const mode = modeRegistry[name];
24
+ if (!mode) {
25
+ throw new Error(`Unknown mode: ${name}`);
26
+ }
27
+ return mode;
28
+ }
29
+
30
+ /**
31
+ * Auto-detect the appropriate mode based on context
32
+ */
33
+ export function detectMode(
34
+ context: ParsedBitbucketContext,
35
+ options: {
36
+ triggerPhrase: string;
37
+ prompt?: string;
38
+ mode?: ModeName;
39
+ }
40
+ ): { mode: Mode; reason: string } {
41
+ // If mode is explicitly specified, use it
42
+ if (options.mode) {
43
+ return {
44
+ mode: getMode(options.mode),
45
+ reason: `Explicitly specified mode: ${options.mode}`,
46
+ };
47
+ }
48
+
49
+ // If explicit prompt is provided, use agent mode
50
+ if (options.prompt) {
51
+ return {
52
+ mode: agentMode,
53
+ reason: "Explicit prompt provided",
54
+ };
55
+ }
56
+
57
+ // Check if tag mode should trigger
58
+ const tagTrigger = tagMode.shouldTrigger(context, {
59
+ triggerPhrase: options.triggerPhrase,
60
+ });
61
+
62
+ if (tagTrigger.shouldTrigger) {
63
+ return {
64
+ mode: tagMode,
65
+ reason: "Trigger phrase detected in comment",
66
+ };
67
+ }
68
+
69
+ // Default to agent mode for automation events
70
+ if (
71
+ context.eventType === "manual" ||
72
+ context.eventType === "schedule"
73
+ ) {
74
+ return {
75
+ mode: agentMode,
76
+ reason: "Automation event (manual/schedule)",
77
+ };
78
+ }
79
+
80
+ // No mode triggered
81
+ return {
82
+ mode: tagMode,
83
+ reason: "Default mode (no specific trigger)",
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Check if any mode should trigger for the given context
89
+ */
90
+ export function shouldAnyModeTrigger(
91
+ context: ParsedBitbucketContext,
92
+ options: {
93
+ triggerPhrase: string;
94
+ prompt?: string;
95
+ }
96
+ ): boolean {
97
+ // Explicit prompt always triggers agent mode
98
+ if (options.prompt) {
99
+ return true;
100
+ }
101
+
102
+ // Check tag mode
103
+ const tagTrigger = tagMode.shouldTrigger(context, {
104
+ triggerPhrase: options.triggerPhrase,
105
+ });
106
+
107
+ return tagTrigger.shouldTrigger;
108
+ }
109
+
110
+ /**
111
+ * List all available modes
112
+ */
113
+ export function listModes(): Array<{ name: ModeName; description: string }> {
114
+ return Object.values(modeRegistry).map((mode) => ({
115
+ name: mode.name,
116
+ description: mode.description,
117
+ }));
118
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Tag Mode Implementation
3
+ * Responds to @gemini mentions in PR comments
4
+ */
5
+
6
+ import type {
7
+ Mode,
8
+ ModeOptions,
9
+ ModeResult,
10
+ TriggerCheckResult,
11
+ } from "../types.js";
12
+ import type { ParsedBitbucketContext } from "../../bitbucket/types.js";
13
+ import { TAG_MODE_SYSTEM_PROMPT } from "../../gemini/prompts.js";
14
+ import { codeReviewTools } from "../../gemini/tools.js";
15
+
16
+ export class TagMode implements Mode {
17
+ name = "tag" as const;
18
+ description = "Responds to @gemini mentions in PR comments";
19
+
20
+ shouldTrigger(
21
+ context: ParsedBitbucketContext,
22
+ options: { triggerPhrase: string; prompt?: string }
23
+ ): TriggerCheckResult {
24
+ // Don't trigger if explicit prompt is provided (that's agent mode)
25
+ if (options.prompt) {
26
+ return {
27
+ shouldTrigger: false,
28
+ reason: "Explicit prompt provided, use agent mode",
29
+ };
30
+ }
31
+
32
+ // Check for comment events
33
+ if (
34
+ context.eventType !== "pullrequest:comment_created" &&
35
+ context.eventType !== "pullrequest:comment_updated"
36
+ ) {
37
+ return {
38
+ shouldTrigger: false,
39
+ reason: `Event type ${context.eventType} is not a comment event`,
40
+ };
41
+ }
42
+
43
+ // Check for trigger phrase in comment
44
+ const comment = context.comment;
45
+ if (!comment) {
46
+ return {
47
+ shouldTrigger: false,
48
+ reason: "No comment in context",
49
+ };
50
+ }
51
+
52
+ const triggerPhrase = options.triggerPhrase.toLowerCase();
53
+ const commentContent = comment.content.raw.toLowerCase();
54
+
55
+ if (!commentContent.includes(triggerPhrase)) {
56
+ return {
57
+ shouldTrigger: false,
58
+ reason: `Comment does not contain trigger phrase: ${options.triggerPhrase}`,
59
+ };
60
+ }
61
+
62
+ // Don't respond to our own comments (prevent infinite loops)
63
+ if (this.isOwnComment(comment.user.account_id)) {
64
+ return {
65
+ shouldTrigger: false,
66
+ reason: "Comment is from the bot itself",
67
+ };
68
+ }
69
+
70
+ // Extract user message (remove trigger phrase)
71
+ const userMessage = this.extractUserMessage(
72
+ comment.content.raw,
73
+ options.triggerPhrase
74
+ );
75
+
76
+ return {
77
+ shouldTrigger: true,
78
+ userMessage,
79
+ mentionAuthor: comment.user.display_name,
80
+ };
81
+ }
82
+
83
+ async prepare(options: ModeOptions): Promise<ModeResult> {
84
+ const { context, client, triggerPhrase } = options;
85
+
86
+ try {
87
+ // Verify trigger
88
+ const triggerCheck = this.shouldTrigger(context, { triggerPhrase });
89
+ if (!triggerCheck.shouldTrigger) {
90
+ return {
91
+ success: false,
92
+ modeContext: {
93
+ prompt: "",
94
+ systemPrompt: "",
95
+ tools: [],
96
+ },
97
+ error: triggerCheck.reason,
98
+ };
99
+ }
100
+
101
+ // Create tracking comment if enabled
102
+ let trackingCommentId: number | undefined;
103
+ if (options.createTrackingComment && context.entityNumber) {
104
+ const trackingComment = await client.createPullRequestComment(
105
+ context.workspace,
106
+ context.repoSlug,
107
+ context.entityNumber,
108
+ this.getInitialTrackingCommentContent()
109
+ );
110
+ trackingCommentId = trackingComment.id;
111
+ }
112
+
113
+ return {
114
+ success: true,
115
+ modeContext: {
116
+ prompt: triggerCheck.userMessage || "",
117
+ systemPrompt: this.getSystemPrompt(context),
118
+ tools: this.getAllowedTools(),
119
+ trackingCommentId,
120
+ },
121
+ trackingCommentId,
122
+ };
123
+ } catch (error) {
124
+ return {
125
+ success: false,
126
+ modeContext: {
127
+ prompt: "",
128
+ systemPrompt: "",
129
+ tools: [],
130
+ },
131
+ error: error instanceof Error ? error.message : "Unknown error",
132
+ };
133
+ }
134
+ }
135
+
136
+ getSystemPrompt(_context: ParsedBitbucketContext): string {
137
+ return TAG_MODE_SYSTEM_PROMPT;
138
+ }
139
+
140
+ getAllowedTools(): string[] {
141
+ return codeReviewTools.map((t) => t.name);
142
+ }
143
+
144
+ shouldCreateTrackingComment(): boolean {
145
+ return true;
146
+ }
147
+
148
+ private extractUserMessage(
149
+ commentContent: string,
150
+ triggerPhrase: string
151
+ ): string {
152
+ // Remove trigger phrase and clean up
153
+ const regex = new RegExp(triggerPhrase, "gi");
154
+ return commentContent.replace(regex, "").trim();
155
+ }
156
+
157
+ private isOwnComment(accountId: string): boolean {
158
+ const botAccountId = process.env.BOT_ACCOUNT_ID;
159
+ return botAccountId ? accountId === botAccountId : false;
160
+ }
161
+
162
+ private getInitialTrackingCommentContent(): string {
163
+ return `## 🤖 Gemini Code Review
164
+
165
+ ⏳ **Status**: Processing your request...
166
+
167
+ ---
168
+ _This comment will be updated with the results._`;
169
+ }
170
+ }
171
+
172
+ export const tagMode = new TagMode();
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Mode System Types
3
+ * Defines the interface for execution modes (tag, agent)
4
+ */
5
+
6
+ import type { ParsedBitbucketContext } from "../bitbucket/types.js";
7
+ import type { BitbucketClient } from "../bitbucket/api/client.js";
8
+
9
+ export type ModeName = "tag" | "agent";
10
+
11
+ export interface ModeContext {
12
+ prompt: string;
13
+ systemPrompt: string;
14
+ tools: string[];
15
+ trackingCommentId?: number;
16
+ branchName?: string;
17
+ }
18
+
19
+ export interface ModeOptions {
20
+ context: ParsedBitbucketContext;
21
+ client: BitbucketClient;
22
+ triggerPhrase: string;
23
+ prompt?: string;
24
+ branchPrefix?: string;
25
+ createTrackingComment?: boolean;
26
+ }
27
+
28
+ export interface ModeResult {
29
+ success: boolean;
30
+ modeContext: ModeContext;
31
+ trackingCommentId?: number;
32
+ branchName?: string;
33
+ error?: string;
34
+ }
35
+
36
+ export interface TriggerCheckResult {
37
+ shouldTrigger: boolean;
38
+ reason?: string;
39
+ userMessage?: string;
40
+ mentionAuthor?: string;
41
+ }
42
+
43
+ /**
44
+ * Mode interface - all modes must implement this
45
+ */
46
+ export interface Mode {
47
+ /**
48
+ * Mode identifier
49
+ */
50
+ name: ModeName;
51
+
52
+ /**
53
+ * Human-readable description
54
+ */
55
+ description: string;
56
+
57
+ /**
58
+ * Check if this mode should be triggered for the given context
59
+ */
60
+ shouldTrigger(
61
+ context: ParsedBitbucketContext,
62
+ options: {
63
+ triggerPhrase: string;
64
+ prompt?: string;
65
+ }
66
+ ): TriggerCheckResult;
67
+
68
+ /**
69
+ * Prepare the mode context (create comments, branches, etc.)
70
+ */
71
+ prepare(options: ModeOptions): Promise<ModeResult>;
72
+
73
+ /**
74
+ * Get the system prompt for this mode
75
+ */
76
+ getSystemPrompt(context: ParsedBitbucketContext): string;
77
+
78
+ /**
79
+ * Get allowed tools for this mode
80
+ */
81
+ getAllowedTools(): string[];
82
+
83
+ /**
84
+ * Check if tracking comment should be created
85
+ */
86
+ shouldCreateTrackingComment(): boolean;
87
+ }
88
+
89
+ /**
90
+ * Mode registry type
91
+ */
92
+ export interface ModeRegistry {
93
+ tag: Mode;
94
+ agent: Mode;
95
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Environment Variable Utilities
3
+ * Handles environment configuration and validation
4
+ */
5
+
6
+ import { z } from "zod";
7
+
8
+ /**
9
+ * Required environment variables schema
10
+ */
11
+ const requiredEnvSchema = z.object({
12
+ // Bitbucket Pipeline variables
13
+ BITBUCKET_WORKSPACE: z.string().min(1),
14
+ BITBUCKET_REPO_SLUG: z.string().min(1),
15
+ BITBUCKET_COMMIT: z.string().min(1),
16
+
17
+ // Gemini API
18
+ GEMINI_API_KEY: z.string().min(1).optional(),
19
+ GOOGLE_API_KEY: z.string().min(1).optional(),
20
+ });
21
+
22
+ /**
23
+ * Optional environment variables schema
24
+ */
25
+ const optionalEnvSchema = z.object({
26
+ // Bitbucket credentials
27
+ BITBUCKET_ACCESS_TOKEN: z.string().optional(),
28
+ BITBUCKET_USERNAME: z.string().optional(),
29
+ BITBUCKET_APP_PASSWORD: z.string().optional(),
30
+
31
+ // Pipeline context
32
+ BITBUCKET_PR_ID: z.string().optional(),
33
+ BITBUCKET_BRANCH: z.string().optional(),
34
+ BITBUCKET_BUILD_NUMBER: z.string().optional(),
35
+ BITBUCKET_PIPELINE_UUID: z.string().optional(),
36
+ BITBUCKET_STEP_UUID: z.string().optional(),
37
+
38
+ // Configuration
39
+ TRIGGER_PHRASE: z.string().default("@gemini"),
40
+ GEMINI_MODEL: z.string().default("gemini-2.0-flash"),
41
+ MODE: z.enum(["tag", "agent"]).optional(),
42
+ PROMPT: z.string().optional(),
43
+ CREATE_TRACKING_COMMENT: z.string().default("true"),
44
+ BOT_ACCOUNT_ID: z.string().optional(),
45
+
46
+ // Review presets (comma-separated list)
47
+ // e.g., "junior,nextjs,security" or "senior,architecture,typescript"
48
+ REVIEW_PRESETS: z.string().optional(),
49
+ // Custom additional prompt to append to presets
50
+ CUSTOM_PROMPT: z.string().optional(),
51
+
52
+ // Webhook data
53
+ TRIGGER_EVENT: z.string().optional(),
54
+ TRIGGER_TIMESTAMP: z.string().optional(),
55
+ WEBHOOK_PAYLOAD: z.string().optional(),
56
+ });
57
+
58
+ export type RequiredEnv = z.infer<typeof requiredEnvSchema>;
59
+ export type OptionalEnv = z.infer<typeof optionalEnvSchema>;
60
+ export type FullEnv = RequiredEnv & OptionalEnv;
61
+
62
+ /**
63
+ * Validate required environment variables
64
+ */
65
+ export function validateRequiredEnv(): RequiredEnv {
66
+ const result = requiredEnvSchema.safeParse(process.env);
67
+
68
+ if (!result.success) {
69
+ const missing = result.error.errors.map((e) => e.path.join(".")).join(", ");
70
+ throw new Error(`Missing required environment variables: ${missing}`);
71
+ }
72
+
73
+ // Ensure at least one Gemini API key is set
74
+ if (!result.data.GEMINI_API_KEY && !result.data.GOOGLE_API_KEY) {
75
+ throw new Error(
76
+ "Missing Gemini API key. Set GEMINI_API_KEY or GOOGLE_API_KEY"
77
+ );
78
+ }
79
+
80
+ return result.data;
81
+ }
82
+
83
+ /**
84
+ * Get all environment configuration
85
+ */
86
+ export function getEnvConfig(): FullEnv {
87
+ const required = validateRequiredEnv();
88
+ const optional = optionalEnvSchema.parse(process.env);
89
+
90
+ return {
91
+ ...required,
92
+ ...optional,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Get Gemini API key
98
+ */
99
+ export function getGeminiApiKey(): string {
100
+ const key = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
101
+ if (!key) {
102
+ throw new Error(
103
+ "Missing Gemini API key. Set GEMINI_API_KEY or GOOGLE_API_KEY"
104
+ );
105
+ }
106
+ return key;
107
+ }
108
+
109
+ /**
110
+ * Get Bitbucket credentials
111
+ */
112
+ export function getBitbucketCredentials(): {
113
+ type: "token" | "basic";
114
+ accessToken?: string;
115
+ username?: string;
116
+ appPassword?: string;
117
+ } {
118
+ const accessToken = process.env.BITBUCKET_ACCESS_TOKEN;
119
+ const username = process.env.BITBUCKET_USERNAME;
120
+ const appPassword = process.env.BITBUCKET_APP_PASSWORD;
121
+
122
+ if (accessToken) {
123
+ return { type: "token", accessToken };
124
+ }
125
+
126
+ if (username && appPassword) {
127
+ return { type: "basic", username, appPassword };
128
+ }
129
+
130
+ throw new Error(
131
+ "Missing Bitbucket credentials. Set BITBUCKET_ACCESS_TOKEN or BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD"
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Get pipeline URL
137
+ */
138
+ export function getPipelineUrl(): string | undefined {
139
+ const workspace = process.env.BITBUCKET_WORKSPACE;
140
+ const repoSlug = process.env.BITBUCKET_REPO_SLUG;
141
+ const pipelineUuid = process.env.BITBUCKET_PIPELINE_UUID;
142
+
143
+ if (workspace && repoSlug && pipelineUuid) {
144
+ return `https://bitbucket.org/${workspace}/${repoSlug}/pipelines/results/${pipelineUuid}`;
145
+ }
146
+
147
+ return undefined;
148
+ }
149
+
150
+ /**
151
+ * Check if running in CI environment
152
+ */
153
+ export function isCI(): boolean {
154
+ return (
155
+ !!process.env.BITBUCKET_PIPELINE_UUID || process.env.CI === "true"
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Get boolean from env variable
161
+ */
162
+ export function getEnvBool(key: string, defaultValue: boolean = false): boolean {
163
+ const value = process.env[key];
164
+ if (value === undefined) {
165
+ return defaultValue;
166
+ }
167
+ return value.toLowerCase() === "true" || value === "1";
168
+ }
169
+
170
+ /**
171
+ * Parse review presets from environment variable
172
+ * @returns Array of preset keys
173
+ */
174
+ export function getReviewPresets(): string[] {
175
+ const presets = process.env.REVIEW_PRESETS;
176
+ if (!presets) {
177
+ return [];
178
+ }
179
+ return presets
180
+ .split(",")
181
+ .map((p) => p.trim().toLowerCase())
182
+ .filter((p) => p.length > 0);
183
+ }
184
+
185
+ /**
186
+ * Get custom prompt from environment variable
187
+ */
188
+ export function getCustomPrompt(): string | undefined {
189
+ return process.env.CUSTOM_PROMPT;
190
+ }