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.
- package/.claude/settings.local.json +8 -0
- package/.prettierrc +8 -0
- package/CLAUDE.md +150 -0
- package/README.md +375 -0
- package/bitbucket-pipelines.yml +95 -0
- package/bun.lock +227 -0
- package/dist/prepare.js +7111 -0
- package/examples/bitbucket-pipelines-full.yml +157 -0
- package/examples/bitbucket-pipelines-minimal.yml +22 -0
- package/package.json +33 -0
- package/src/bitbucket/api/client.ts +406 -0
- package/src/bitbucket/context.ts +196 -0
- package/src/bitbucket/data/fetcher.ts +195 -0
- package/src/bitbucket/data/formatter.ts +221 -0
- package/src/bitbucket/operations/comments.ts +236 -0
- package/src/bitbucket/types.ts +262 -0
- package/src/bitbucket/validation/permissions.ts +154 -0
- package/src/bitbucket/validation/trigger.ts +175 -0
- package/src/entrypoints/execute.ts +349 -0
- package/src/entrypoints/prepare.ts +216 -0
- package/src/gemini/client.ts +263 -0
- package/src/gemini/presets.ts +2130 -0
- package/src/gemini/prompts.ts +331 -0
- package/src/gemini/tools.ts +226 -0
- package/src/index.ts +71 -0
- package/src/modes/agent/index.ts +119 -0
- package/src/modes/registry.ts +118 -0
- package/src/modes/tag/index.ts +172 -0
- package/src/modes/types.ts +95 -0
- package/src/utils/env.ts +190 -0
- package/src/utils/retry.ts +149 -0
- package/tsconfig.json +24 -0
|
@@ -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
|
+
}
|