-
Notifications
You must be signed in to change notification settings - Fork 94
Expand file tree
/
Copy pathgitlabCodeReview.ts
More file actions
233 lines (201 loc) · 9.31 KB
/
gitlabCodeReview.ts
File metadata and controls
233 lines (201 loc) · 9.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
import {
type CommitDiffSchema,
type DiscussionSchema,
type ExpandedMergeRequestSchema,
Gitlab as GitlabApi,
type MergeRequestDiffSchema,
type MergeRequestDiscussionNotePositionOptions,
type ProjectSchema,
} from '@gitbeaker/rest';
import { appContext } from '#app/applicationContext';
import { func, funcClass } from '#functionSchema/functionDecorators';
import { GitLab, type GitLabConfig } from '#functions/scm/gitlab';
import { logger } from '#o11y/logger';
import { span } from '#o11y/trace';
import type { CodeReviewConfig } from '#shared/codeReview/codeReview.model';
import { addCodeWithLineNumbers, generateReviewTaskFingerprint, reviewDiff, shouldApplyCodeReview } from '#swe/codeReview/codeReviewCommon';
import type { CodeReviewFingerprintCache, CodeReviewTask } from '#swe/codeReview/codeReviewTaskModel';
import { functionConfig } from '#user/userContext';
import { settleAllWithInput } from '#utils/async-utils';
import { envVar } from '#utils/env-var';
import { cacheRetry } from '../../cache/cacheRetry';
import type { SourceControlManagement } from './sourceControlManagement';
// Note that the type returned from getProjects is mapped to GitProject
export type GitLabProject = Pick<
ProjectSchema,
| 'id'
| 'name'
| 'description'
| 'path_with_namespace'
| 'http_url_to_repo'
| 'default_branch'
| 'archived'
// | "shared_with_groups"
| 'visibility'
| 'owner'
| 'ci_config_path'
>;
@funcClass(__filename)
export class GitLabCodeReview {
// @ts-ignore
_gitlab: GitlabApi;
_config: GitLabConfig | undefined;
_gitlabSCM: SourceControlManagement | undefined;
private config(): GitLabConfig {
if (!this._config) {
const config = functionConfig(GitLab);
if (!config.token && !envVar('GITLAB_TOKEN')) logger.error('No GitLab token configured on the user or environment');
this._config = {
host: config.host || envVar('GITLAB_HOST'),
token: config.token || envVar('GITLAB_TOKEN'),
topLevelGroups: (config.topLevelGroups || envVar('GITLAB_GROUPS')).split(',').map((group: string) => group.trim()),
};
}
return this._config;
}
// @ts-ignore
private api(): GitlabApi {
this._gitlab ??= new GitlabApi({
host: `https://${this.config().host}`,
token: this.config().token,
});
return this._gitlab;
}
private gitlabSCM(): SourceControlManagement {
this._gitlabSCM ??= new GitLab();
return this._gitlabSCM;
}
@cacheRetry()
@span()
async getDiffs(gitlabProjectId: string | number, mergeRequestIId: number): Promise<MergeRequestDiffSchema[]> {
const diffs = await this.api().MergeRequests.allDiffs(gitlabProjectId, mergeRequestIId);
// TODO handle paging of results
return diffs;
}
@span()
async createMergeRequestReviewTasks(gitlabProjectId: string | number, mergeRequestIId: number): Promise<CodeReviewTask[]> {
const mergeRequest: ExpandedMergeRequestSchema = await this.api().MergeRequests.show(gitlabProjectId, mergeRequestIId);
const diffs: MergeRequestDiffSchema[] = await this.getDiffs(gitlabProjectId, mergeRequestIId);
const codeReviewService = appContext().codeReviewService;
const codeReviewConfigs: CodeReviewConfig[] = (await codeReviewService.listCodeReviewConfigs()).filter((config) => config.enabled);
const existingComments: DiscussionSchema[] = await this.api().MergeRequestDiscussions.all(gitlabProjectId, mergeRequestIId);
const project = await this.gitlabSCM().getProject(gitlabProjectId);
const projectPath = project.fullPath;
// Load the hashes of the diffs we've already reviewed
const reviewCache: CodeReviewFingerprintCache = await codeReviewService.getMergeRequestReviewCache(gitlabProjectId, mergeRequestIId);
logger.info(
`Checking for review tasks for MR "${mergeRequest.title}" in project "${projectPath}" (${mergeRequest.web_url}) with ${codeReviewConfigs.length} configs`,
);
const codeReviewTasks: CodeReviewTask[] = [];
// Pre-filter and check cache
for (const diff of diffs) {
if (diff.deleted_file || !diff.diff || diff.diff.trim() === '') continue;
for (const config of codeReviewConfigs) {
// Check if the code review config rules apply for this diff
if (!shouldApplyCodeReview(config, projectPath, diff.new_path, diff.diff)) continue;
const task = this.createCodeReviewTask(config, diff);
if (reviewCache.fingerprints.has(task.fingerprint)) {
logger.info(`Already reviewed ${config.title} in ${diff.new_path} for ${diff.diff.split('\n').slice(0, 1).join(' ')}`);
continue;
}
codeReviewTasks.push(task);
}
}
logger.info(`Found ${codeReviewTasks.length} review tasks needing LLM analysis.`);
return codeReviewTasks;
}
@span()
async processMergeRequestCodeReviewTasks(gitlabProjectId: string | number, mergeRequestIId: number, codeReviewTasks: CodeReviewTask[]): Promise<void> {
const mergeRequest: ExpandedMergeRequestSchema = await this.api().MergeRequests.show(gitlabProjectId, mergeRequestIId);
const codeReviewService = appContext().codeReviewService;
const project = await this.gitlabSCM().getProject(gitlabProjectId);
const projectPath = project.fullPath;
// Load the hashes of the diffs we've already reviewed
const reviewCache: CodeReviewFingerprintCache = await codeReviewService.getMergeRequestReviewCache(gitlabProjectId, mergeRequestIId);
logger.info(`Reviewing MR "${mergeRequest.title}" in project "${projectPath}" (${mergeRequest.web_url}) with ${codeReviewTasks.length} review tasks`);
if (!codeReviewTasks.length) return;
// Perform LLM Reviews
const settled = await settleAllWithInput(codeReviewTasks, reviewDiff);
const codeReviewResults = settled.fulfilled;
for (const rejected of settled.rejected) {
logger.warn(`Error executing review ${rejected.input.config.title}. Error: ${rejected.reason.message || rejected.reason}`);
}
// Post review comments
for (const reviewResult of codeReviewResults) {
reviewCache.fingerprints.add(reviewResult.task.fingerprint);
if (!reviewResult.comments || !reviewResult.comments.length) continue;
logger.info(reviewResult.task.codeWithLineNums);
for (const comment of reviewResult.comments) {
logger.info({ comment }, `Adding review comment for "${reviewResult.task.config.title}" in ${reviewResult.task.filePath} [comment]`);
// Prepare comment position data
if (!mergeRequest.diff_refs?.base_sha || !mergeRequest.diff_refs?.head_sha || !mergeRequest.diff_refs?.start_sha) {
logger.warn({ mrId: mergeRequest.id }, 'Cannot create comment position, missing diff_refs on merge request.');
continue;
}
const position: MergeRequestDiscussionNotePositionOptions = {
baseSha: mergeRequest.diff_refs.base_sha,
headSha: mergeRequest.diff_refs.head_sha,
startSha: mergeRequest.diff_refs.start_sha,
oldPath: reviewResult.task.oldPath ?? '',
newPath: reviewResult.task.filePath,
positionType: 'text',
newLine: comment.lineNumber > 0 ? comment.lineNumber.toString() : '',
};
Object.keys(position).forEach((key) => position[key] === undefined && delete position[key]);
const positionOptions = position.newLine ? { position } : undefined;
logger.info(positionOptions);
try {
const discussion = await this.api().MergeRequestDiscussions.create(gitlabProjectId, mergeRequestIId, comment.comment, positionOptions);
logger.info(discussion);
} catch (e) {
const message = e.cause?.description || e.message;
logger.warn(
{ error: e, comment: comment.comment, lineNumber: comment.lineNumber, positionOptions, errorKey: 'GitLab create code review discussion' },
`Error creating code review comment: ${message}`,
);
}
}
}
await codeReviewService.updateMergeRequestReviewCache(gitlabProjectId, mergeRequestIId, reviewCache);
}
createCodeReviewTask(config: CodeReviewConfig, mrDiff: MergeRequestDiffSchema): CodeReviewTask {
const { codeWithLineNums, code } = addCodeWithLineNumbers(mrDiff.diff, mrDiff.new_path);
const fingerprint = generateReviewTaskFingerprint(mrDiff.new_path, config.id, code);
return {
config,
filePath: mrDiff.new_path,
oldPath: mrDiff.old_path,
// diff: mrDiff,
codeWithLineNums,
fingerprint,
code,
};
}
/**
* Gets the logs for a CI/CD job
* @param projectIdOrProjectPath full path or numeric id
* @param jobId the job id
*/
@func()
async getJobLogs(projectIdOrProjectPath: string | number, jobId: string): Promise<string> {
if (!projectIdOrProjectPath) throw new Error('Parameter "projectPath" must be truthy');
if (!jobId) throw new Error('Parameter "jobId" must be truthy');
const project = await this.api().Projects.show(projectIdOrProjectPath);
const job = await this.api().Jobs.show(project.id, jobId);
return await this.api().Jobs.showLog(project.id, job.id);
}
/**
* Returns the Git diff for the commit in the git repository that the job is running the pipeline on.
* @param projectPath full project path or numeric id
* @param jobId the job id
*/
@func()
async getJobCommitDiff(projectPath: string, jobId: string): Promise<string> {
if (!projectPath) throw new Error('Parameter "projectPath" must be truthy');
if (!jobId) throw new Error('Parameter "jobId" must be truthy');
const project = await this.api().Projects.show(projectPath);
const job = await this.api().Jobs.show(project.id, jobId);
const commitDetails: CommitDiffSchema[] = await this.api().Commits.showDiff(projectPath, job.commit.id);
return commitDetails.map((commitDiff) => commitDiff.diff).join('\n');
}
}