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,196 @@
1
+ /**
2
+ * Bitbucket Pipeline Context Parser
3
+ * Parses Bitbucket Pipeline environment variables and webhook payloads
4
+ */
5
+
6
+ import type {
7
+ ParsedBitbucketContext,
8
+ BitbucketEventType,
9
+ BitbucketWebhookPayload,
10
+ BitbucketUser,
11
+ } from "./types.js";
12
+
13
+ // Bitbucket Pipeline environment variables
14
+ interface PipelineEnvVars {
15
+ BITBUCKET_WORKSPACE: string;
16
+ BITBUCKET_REPO_SLUG: string;
17
+ BITBUCKET_REPO_FULL_NAME: string;
18
+ BITBUCKET_COMMIT: string;
19
+ BITBUCKET_BRANCH?: string;
20
+ BITBUCKET_TAG?: string;
21
+ BITBUCKET_PR_ID?: string;
22
+ BITBUCKET_PR_DESTINATION_BRANCH?: string;
23
+ BITBUCKET_BUILD_NUMBER: string;
24
+ BITBUCKET_PIPELINE_UUID: string;
25
+ BITBUCKET_STEP_UUID: string;
26
+ BITBUCKET_CLONE_DIR: string;
27
+
28
+ // Custom variables set by trigger
29
+ TRIGGER_EVENT?: string;
30
+ TRIGGER_COMMENT_ID?: string;
31
+ TRIGGER_ACTOR?: string;
32
+ TRIGGER_TIMESTAMP?: string;
33
+ WEBHOOK_PAYLOAD?: string;
34
+ }
35
+
36
+ export function parseBitbucketContext(): ParsedBitbucketContext {
37
+ const env = process.env as unknown as PipelineEnvVars;
38
+
39
+ // Validate required environment variables
40
+ if (!env.BITBUCKET_WORKSPACE) {
41
+ throw new Error("BITBUCKET_WORKSPACE environment variable is required");
42
+ }
43
+ if (!env.BITBUCKET_REPO_SLUG) {
44
+ throw new Error("BITBUCKET_REPO_SLUG environment variable is required");
45
+ }
46
+
47
+ const workspace = env.BITBUCKET_WORKSPACE;
48
+ const repoSlug = env.BITBUCKET_REPO_SLUG;
49
+ const fullName =
50
+ env.BITBUCKET_REPO_FULL_NAME || `${workspace}/${repoSlug}`;
51
+
52
+ // Parse webhook payload if available
53
+ let webhookPayload: BitbucketWebhookPayload | undefined;
54
+ if (env.WEBHOOK_PAYLOAD) {
55
+ try {
56
+ webhookPayload = JSON.parse(env.WEBHOOK_PAYLOAD);
57
+ } catch {
58
+ console.warn("Failed to parse WEBHOOK_PAYLOAD");
59
+ }
60
+ }
61
+
62
+ // Determine event type
63
+ const eventType = determineEventType(env, webhookPayload);
64
+
65
+ // Determine if this is a PR context
66
+ const isPR = !!env.BITBUCKET_PR_ID || eventType.startsWith("pullrequest:");
67
+ const entityNumber = env.BITBUCKET_PR_ID
68
+ ? parseInt(env.BITBUCKET_PR_ID, 10)
69
+ : undefined;
70
+
71
+ // Get actor information
72
+ const actor = getActor(env, webhookPayload);
73
+
74
+ return {
75
+ eventType,
76
+ workspace,
77
+ repoSlug,
78
+ repository: {
79
+ workspace,
80
+ slug: repoSlug,
81
+ fullName,
82
+ },
83
+ actor,
84
+ pullRequest: webhookPayload?.pullrequest,
85
+ comment: webhookPayload?.comment,
86
+ issue: webhookPayload?.issue,
87
+ isPR,
88
+ entityNumber,
89
+ triggerTimestamp: env.TRIGGER_TIMESTAMP || new Date().toISOString(),
90
+ };
91
+ }
92
+
93
+ function determineEventType(
94
+ env: PipelineEnvVars,
95
+ payload?: BitbucketWebhookPayload
96
+ ): BitbucketEventType {
97
+ // Check explicit trigger event
98
+ if (env.TRIGGER_EVENT) {
99
+ return env.TRIGGER_EVENT as BitbucketEventType;
100
+ }
101
+
102
+ // Infer from pipeline context
103
+ if (env.BITBUCKET_PR_ID) {
104
+ if (payload?.comment) {
105
+ return "pullrequest:comment_created";
106
+ }
107
+ return "pullrequest:updated";
108
+ }
109
+
110
+ if (env.BITBUCKET_BRANCH) {
111
+ return "repo:push";
112
+ }
113
+
114
+ return "manual";
115
+ }
116
+
117
+ function getActor(
118
+ env: PipelineEnvVars,
119
+ payload?: BitbucketWebhookPayload
120
+ ): BitbucketUser {
121
+ // Use webhook payload actor if available
122
+ if (payload?.actor) {
123
+ return payload.actor;
124
+ }
125
+
126
+ // Try to parse from TRIGGER_ACTOR
127
+ if (env.TRIGGER_ACTOR) {
128
+ try {
129
+ return JSON.parse(env.TRIGGER_ACTOR);
130
+ } catch {
131
+ // Fall through to default
132
+ }
133
+ }
134
+
135
+ // Default actor (pipeline bot)
136
+ return {
137
+ uuid: "{pipeline-bot}",
138
+ account_id: "pipeline-bot",
139
+ display_name: "Pipeline Bot",
140
+ nickname: "pipeline-bot",
141
+ type: "user",
142
+ links: {
143
+ self: { href: "" },
144
+ html: { href: "" },
145
+ avatar: { href: "" },
146
+ },
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Check if the context is from a PR-related event
152
+ */
153
+ export function isPullRequestContext(
154
+ context: ParsedBitbucketContext
155
+ ): boolean {
156
+ return context.isPR && context.entityNumber !== undefined;
157
+ }
158
+
159
+ /**
160
+ * Check if the context is from a comment event
161
+ */
162
+ export function isCommentContext(context: ParsedBitbucketContext): boolean {
163
+ return (
164
+ context.eventType === "pullrequest:comment_created" ||
165
+ context.eventType === "pullrequest:comment_updated" ||
166
+ context.eventType === "issue:comment_created"
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Check if the context is from a manual or scheduled trigger
172
+ */
173
+ export function isAutomationContext(context: ParsedBitbucketContext): boolean {
174
+ return context.eventType === "manual" || context.eventType === "schedule";
175
+ }
176
+
177
+ /**
178
+ * Get the default branch for the repository
179
+ */
180
+ export function getDefaultBranch(): string {
181
+ return process.env.BITBUCKET_PR_DESTINATION_BRANCH || "main";
182
+ }
183
+
184
+ /**
185
+ * Get the current commit hash
186
+ */
187
+ export function getCurrentCommit(): string {
188
+ return process.env.BITBUCKET_COMMIT || "";
189
+ }
190
+
191
+ /**
192
+ * Get the current branch name
193
+ */
194
+ export function getCurrentBranch(): string | undefined {
195
+ return process.env.BITBUCKET_BRANCH;
196
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Bitbucket Data Fetcher
3
+ * Fetches PR/issue data from Bitbucket API
4
+ */
5
+
6
+ import type { BitbucketClient } from "../api/client.js";
7
+ import type {
8
+ BitbucketPullRequest,
9
+ BitbucketComment,
10
+ BitbucketDiffStat,
11
+ BitbucketCommit,
12
+ ParsedBitbucketContext,
13
+ } from "../types.js";
14
+
15
+ export interface FetchedPRData {
16
+ pullRequest: BitbucketPullRequest;
17
+ comments: BitbucketComment[];
18
+ diffStats: BitbucketDiffStat[];
19
+ commits: BitbucketCommit[];
20
+ diff: string;
21
+ }
22
+
23
+ export interface FetchOptions {
24
+ includeComments?: boolean;
25
+ includeDiff?: boolean;
26
+ includeCommits?: boolean;
27
+ filterCommentsAfter?: string; // ISO timestamp
28
+ }
29
+
30
+ const DEFAULT_FETCH_OPTIONS: FetchOptions = {
31
+ includeComments: true,
32
+ includeDiff: true,
33
+ includeCommits: true,
34
+ };
35
+
36
+ /**
37
+ * Fetch all PR data needed for code review
38
+ */
39
+ export async function fetchPRData(
40
+ client: BitbucketClient,
41
+ context: ParsedBitbucketContext,
42
+ options: FetchOptions = DEFAULT_FETCH_OPTIONS
43
+ ): Promise<FetchedPRData> {
44
+ const { workspace, repoSlug, entityNumber } = context;
45
+
46
+ if (!entityNumber) {
47
+ throw new Error("PR ID is required to fetch PR data");
48
+ }
49
+
50
+ // Fetch PR details first
51
+ const pullRequest = await client.getPullRequest(
52
+ workspace,
53
+ repoSlug,
54
+ entityNumber
55
+ );
56
+
57
+ // Fetch additional data in parallel
58
+ const [comments, diffStats, commits, diff] = await Promise.all([
59
+ options.includeComments
60
+ ? fetchFilteredComments(
61
+ client,
62
+ workspace,
63
+ repoSlug,
64
+ entityNumber,
65
+ options.filterCommentsAfter
66
+ )
67
+ : Promise.resolve([]),
68
+ client.getPullRequestDiffStat(workspace, repoSlug, entityNumber),
69
+ options.includeCommits
70
+ ? client.getPullRequestCommits(workspace, repoSlug, entityNumber)
71
+ : Promise.resolve([]),
72
+ options.includeDiff
73
+ ? client.getPullRequestDiff(workspace, repoSlug, entityNumber)
74
+ : Promise.resolve(""),
75
+ ]);
76
+
77
+ return {
78
+ pullRequest,
79
+ comments,
80
+ diffStats,
81
+ commits,
82
+ diff,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Fetch comments with optional timestamp filtering
88
+ * Filters out comments created or updated after the trigger time to prevent
89
+ * processing comments that were added during the review
90
+ */
91
+ async function fetchFilteredComments(
92
+ client: BitbucketClient,
93
+ workspace: string,
94
+ repoSlug: string,
95
+ prId: number,
96
+ filterAfter?: string
97
+ ): Promise<BitbucketComment[]> {
98
+ const comments = await client.getPullRequestComments(
99
+ workspace,
100
+ repoSlug,
101
+ prId
102
+ );
103
+
104
+ if (!filterAfter) {
105
+ return comments;
106
+ }
107
+
108
+ const filterTimestamp = new Date(filterAfter).getTime();
109
+
110
+ return comments.filter((comment) => {
111
+ // Filter out comments updated after the trigger time
112
+ const updatedAt = new Date(comment.updated_on).getTime();
113
+ return updatedAt <= filterTimestamp;
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Check if a comment body is safe to process
119
+ * Ensures the comment hasn't been modified since the trigger
120
+ */
121
+ export function isCommentSafeToProcess(
122
+ comment: BitbucketComment,
123
+ triggerTimestamp: string
124
+ ): boolean {
125
+ const triggerTime = new Date(triggerTimestamp).getTime();
126
+ const updatedTime = new Date(comment.updated_on).getTime();
127
+
128
+ // Comment is safe if it wasn't updated after the trigger
129
+ return updatedTime <= triggerTime;
130
+ }
131
+
132
+ /**
133
+ * Get file content from a specific commit
134
+ */
135
+ export async function getFileContent(
136
+ client: BitbucketClient,
137
+ workspace: string,
138
+ repoSlug: string,
139
+ commitHash: string,
140
+ filePath: string
141
+ ): Promise<string | null> {
142
+ try {
143
+ return await client.getFileContent(
144
+ workspace,
145
+ repoSlug,
146
+ commitHash,
147
+ filePath
148
+ );
149
+ } catch (error) {
150
+ // File might not exist at this commit
151
+ console.warn(`Failed to get file content for ${filePath}: ${error}`);
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Get the files changed in a PR
158
+ */
159
+ export function getChangedFiles(
160
+ diffStats: BitbucketDiffStat[]
161
+ ): Array<{
162
+ path: string;
163
+ status: string;
164
+ additions: number;
165
+ deletions: number;
166
+ }> {
167
+ return diffStats.map((stat) => ({
168
+ path: stat.new?.path || stat.old?.path || "",
169
+ status: stat.status,
170
+ additions: stat.lines_added,
171
+ deletions: stat.lines_removed,
172
+ }));
173
+ }
174
+
175
+ /**
176
+ * Extract trigger comment from comments list
177
+ */
178
+ export function findTriggerComment(
179
+ comments: BitbucketComment[],
180
+ triggerPhrase: string,
181
+ triggerTimestamp: string
182
+ ): BitbucketComment | undefined {
183
+ const triggerTime = new Date(triggerTimestamp).getTime();
184
+
185
+ return comments.find((comment) => {
186
+ const createdTime = new Date(comment.created_on).getTime();
187
+ const containsTrigger = comment.content.raw
188
+ .toLowerCase()
189
+ .includes(triggerPhrase.toLowerCase());
190
+
191
+ // Match if contains trigger and was created around the trigger time
192
+ // (within 1 minute to account for timing differences)
193
+ return containsTrigger && Math.abs(createdTime - triggerTime) < 60000;
194
+ });
195
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Bitbucket Data Formatter
3
+ * Formats PR data for Gemini prompts
4
+ */
5
+
6
+ import type {
7
+ BitbucketPullRequest,
8
+ BitbucketComment,
9
+ BitbucketDiffStat,
10
+ BitbucketCommit,
11
+ } from "../types.js";
12
+ import type { FetchedPRData } from "./fetcher.js";
13
+ import type { PRContext } from "../../gemini/prompts.js";
14
+
15
+ /**
16
+ * Convert fetched PR data to PRContext for prompt generation
17
+ */
18
+ export function formatPRContext(data: FetchedPRData): PRContext {
19
+ return {
20
+ title: data.pullRequest.title,
21
+ description: data.pullRequest.description || "",
22
+ author: data.pullRequest.author.display_name,
23
+ sourceBranch: data.pullRequest.source.branch.name,
24
+ targetBranch: data.pullRequest.destination.branch.name,
25
+ files: formatChangedFiles(data.diffStats),
26
+ diff: data.diff,
27
+ comments: formatComments(data.comments),
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Format changed files for display
33
+ */
34
+ export function formatChangedFiles(
35
+ diffStats: BitbucketDiffStat[]
36
+ ): PRContext["files"] {
37
+ return diffStats.map((stat) => ({
38
+ path: stat.new?.path || stat.old?.path || "unknown",
39
+ status: stat.status,
40
+ additions: stat.lines_added,
41
+ deletions: stat.lines_removed,
42
+ }));
43
+ }
44
+
45
+ /**
46
+ * Format comments for inclusion in prompt
47
+ */
48
+ export function formatComments(
49
+ comments: BitbucketComment[]
50
+ ): PRContext["comments"] {
51
+ return comments
52
+ .filter((c) => !c.deleted)
53
+ .map((comment) => ({
54
+ author: comment.user.display_name,
55
+ content: sanitizeContent(comment.content.raw),
56
+ path: comment.inline?.path,
57
+ line: comment.inline?.to || undefined,
58
+ createdAt: formatTimestamp(comment.created_on),
59
+ }));
60
+ }
61
+
62
+ /**
63
+ * Format commits for display
64
+ */
65
+ export function formatCommits(
66
+ commits: BitbucketCommit[]
67
+ ): Array<{
68
+ hash: string;
69
+ message: string;
70
+ author: string;
71
+ date: string;
72
+ }> {
73
+ return commits.map((commit) => ({
74
+ hash: commit.hash.substring(0, 7),
75
+ message: commit.message.split("\n")[0], // First line only
76
+ author: commit.author.user?.display_name || commit.author.raw,
77
+ date: formatTimestamp(commit.date),
78
+ }));
79
+ }
80
+
81
+ /**
82
+ * Format PR details as markdown
83
+ */
84
+ export function formatPRAsMarkdown(pr: BitbucketPullRequest): string {
85
+ const reviewers = pr.reviewers
86
+ .map((r) => r.display_name)
87
+ .join(", ");
88
+ const participants = pr.participants
89
+ .filter((p) => p.approved)
90
+ .map((p) => `✓ ${p.user.display_name}`)
91
+ .join(", ");
92
+
93
+ return `## Pull Request #${pr.id}
94
+
95
+ **Title**: ${pr.title}
96
+ **Author**: ${pr.author.display_name}
97
+ **State**: ${pr.state}
98
+ **Branch**: \`${pr.source.branch.name}\` → \`${pr.destination.branch.name}\`
99
+
100
+ ### Description
101
+ ${pr.description || "_No description provided_"}
102
+
103
+ ${reviewers ? `**Reviewers**: ${reviewers}` : ""}
104
+ ${participants ? `**Approvals**: ${participants}` : ""}
105
+
106
+ **Created**: ${formatTimestamp(pr.created_on)}
107
+ **Updated**: ${formatTimestamp(pr.updated_on)}
108
+ `;
109
+ }
110
+
111
+ /**
112
+ * Format diff stats as summary
113
+ */
114
+ export function formatDiffStatsSummary(diffStats: BitbucketDiffStat[]): string {
115
+ const totalFiles = diffStats.length;
116
+ const totalAdditions = diffStats.reduce((sum, s) => sum + s.lines_added, 0);
117
+ const totalDeletions = diffStats.reduce((sum, s) => sum + s.lines_removed, 0);
118
+
119
+ const added = diffStats.filter((s) => s.status === "added").length;
120
+ const modified = diffStats.filter((s) => s.status === "modified").length;
121
+ const removed = diffStats.filter((s) => s.status === "removed").length;
122
+ const renamed = diffStats.filter((s) => s.status === "renamed").length;
123
+
124
+ return `**Files Changed**: ${totalFiles} (${added} added, ${modified} modified, ${removed} removed${renamed ? `, ${renamed} renamed` : ""})
125
+ **Lines**: +${totalAdditions} / -${totalDeletions}`;
126
+ }
127
+
128
+ /**
129
+ * Format a single file diff for focused review
130
+ */
131
+ export function formatFileDiff(
132
+ filePath: string,
133
+ fullDiff: string
134
+ ): string | null {
135
+ const lines = fullDiff.split("\n");
136
+ const fileLines: string[] = [];
137
+ let inFile = false;
138
+ let foundFile = false;
139
+
140
+ for (const line of lines) {
141
+ if (line.startsWith("diff --git")) {
142
+ if (inFile) break; // End of current file
143
+ inFile = line.includes(filePath);
144
+ if (inFile) foundFile = true;
145
+ }
146
+ if (inFile) {
147
+ fileLines.push(line);
148
+ }
149
+ }
150
+
151
+ return foundFile ? fileLines.join("\n") : null;
152
+ }
153
+
154
+ /**
155
+ * Sanitize content to prevent prompt injection
156
+ */
157
+ export function sanitizeContent(content: string): string {
158
+ // Remove potential prompt injection attempts
159
+ return content
160
+ .replace(/```system/gi, "```text") // Prevent system prompt injection
161
+ .replace(/\[INST\]/gi, "[inst]") // Llama-style
162
+ .replace(/<\|im_start\|>/gi, "") // ChatML style
163
+ .replace(/<\|im_end\|>/gi, "")
164
+ .trim();
165
+ }
166
+
167
+ /**
168
+ * Format ISO timestamp to readable format
169
+ */
170
+ export function formatTimestamp(isoTimestamp: string): string {
171
+ const date = new Date(isoTimestamp);
172
+ return date.toISOString().replace("T", " ").substring(0, 19) + " UTC";
173
+ }
174
+
175
+ /**
176
+ * Truncate diff if too long
177
+ */
178
+ export function truncateDiff(diff: string, maxLength: number = 50000): string {
179
+ if (diff.length <= maxLength) {
180
+ return diff;
181
+ }
182
+
183
+ const truncated = diff.substring(0, maxLength);
184
+ const lastNewline = truncated.lastIndexOf("\n");
185
+
186
+ return (
187
+ truncated.substring(0, lastNewline) +
188
+ "\n\n... (diff truncated due to length)"
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Group inline comments by file
194
+ */
195
+ export function groupCommentsByFile(
196
+ comments: BitbucketComment[]
197
+ ): Map<string, BitbucketComment[]> {
198
+ const grouped = new Map<string, BitbucketComment[]>();
199
+
200
+ for (const comment of comments) {
201
+ if (comment.inline?.path) {
202
+ const existing = grouped.get(comment.inline.path) || [];
203
+ existing.push(comment);
204
+ grouped.set(comment.inline.path, existing);
205
+ }
206
+ }
207
+
208
+ // Sort comments within each file by line number
209
+ for (const [path, fileComments] of grouped) {
210
+ grouped.set(
211
+ path,
212
+ fileComments.sort((a, b) => {
213
+ const lineA = a.inline?.to || 0;
214
+ const lineB = b.inline?.to || 0;
215
+ return lineA - lineB;
216
+ })
217
+ );
218
+ }
219
+
220
+ return grouped;
221
+ }