@krotovm/gitlab-ai-review 1.0.41 → 1.0.43

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/README.md CHANGED
@@ -76,7 +76,7 @@ Set these in your project/group CI settings:
76
76
  - `OPENAI_BASE_URL` (optional, for OpenAI-compatible providers/proxies)
77
77
  - `AI_MODEL` (optional, default: `gpt-4o-mini`; example: `gpt-4o`)
78
78
  - `AI_PROMPT_PROFILE` (optional, default: `default`; one of `default` \| `weak`). Use `weak` for small or heavily-quantized models (≤ ~7B, local Ollama, etc.). The `weak` profile uses shorter system prompts, positive rules instead of negations, and inline few-shot examples for triage / per-file review / consolidation / verification, which dramatically improves output-format adherence on weak models. When the variable is not set, the profile is auto-detected: if the triage pass returns unparseable JSON with `default` prompts, triage is retried once with `weak` and, on success, the whole run continues with it. Set the variable explicitly to pin a profile and disable auto-detection.
79
- - `PROJECT_ACCESS_TOKEN` (optional for public projects, but required for most private projects; token with `api` scope)
79
+ - `PROJECT_ACCESS_TOKEN` (required; project or personal access token with `api` scope — `CI_JOB_TOKEN` can't post MR comments or use the search API)
80
80
  - `GITLAB_TOKEN` (optional alias for `PROJECT_ACCESS_TOKEN`)
81
81
  - `AI_REVIEW_CONCURRENCY` (optional) — same as `--max-review-concurrency`, handy as a CI/CD variable; the flag wins when both are set.
82
82
  - `AI_REVIEW_ARTIFACT_HTML_FILE` (optional, default: `ai-review-report.html`; used with `--include-artifacts`)
@@ -88,7 +88,7 @@ GitLab provides these automatically in Merge Request pipelines:
88
88
  - `CI_API_V4_URL`
89
89
  - `CI_PROJECT_ID`
90
90
  - `CI_MERGE_REQUEST_IID`
91
- - `CI_JOB_TOKEN` (used only when `PROJECT_ACCESS_TOKEN` is not provided)
91
+ - `CI_MERGE_REQUEST_PROJECT_ID` (the MR's target project; used instead of `CI_PROJECT_ID`, so MRs from forks work)
92
92
 
93
93
  ## Flags
94
94
 
@@ -3,7 +3,7 @@ import OpenAI from "openai";
3
3
  import { buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, buildVerificationPrompt, DEFAULT_PROMPT_PROFILE, extractCompletionText, isEmptyReviewBody, NO_FINDINGS_SENTENCE, parseTriageResponseDetailed, parseTriageResponse, } from "../prompt/index.js";
4
4
  import { fetchFileAtRef, searchRepository, } from "../gitlab/services.js";
5
5
  import { parseReviewFindings, truncateWithMarker } from "../prompt/utils.js";
6
- import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFICATION_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
6
+ import { logToolFailure, logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFICATION_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
7
7
  /** Hard deadline per model call, retries included. Must exceed the gateway's own
8
8
  * proxy timeout so the SDK's 5xx retries stay the first line of defense. */
9
9
  const COMPLETION_TIMEOUT_MS = 180_000;
@@ -14,6 +14,7 @@ let streamUsageSupported = true;
14
14
  * round, so oversized results multiply prefill time on self-hosted models. */
15
15
  const MAX_TOOL_FILE_CHARS = 12_000;
16
16
  const MAX_TOOL_GREP_CHARS = 800;
17
+ const MAX_TOOL_GREP_RESULTS = 10;
17
18
  function buildTriageDecisions(params) {
18
19
  const { changes, triageResult } = params;
19
20
  const triageMap = new Map(triageResult.files.map((f) => [f.path, f]));
@@ -143,13 +144,13 @@ function buildReviewMetadata(changes, refs) {
143
144
  changed_files: files,
144
145
  tool_usage_guidance: [
145
146
  "If diff context is insufficient, call get_file_at_ref to read a specific file.",
146
- "Use grep_repository to search for usages, definitions, or patterns across the codebase.",
147
+ "Use grep_repository to find usages or definitions by literal text (no regex) across the codebase.",
147
148
  "Use refs.base to inspect pre-change content and refs.head for current content.",
148
149
  "Prefer targeted searches and file fetches; avoid broad context requests.",
149
150
  ],
150
151
  }, null, 2);
151
152
  }
152
- async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers) {
153
+ async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep) {
153
154
  try {
154
155
  const parsed = JSON.parse(argsRaw);
155
156
  const path = parsed.path?.trim();
@@ -161,16 +162,18 @@ async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers) {
161
162
  });
162
163
  }
163
164
  const fileText = await fetchFileAtRef({
164
- gitLabBaseUrl: gitLabProjectApiUrl,
165
+ gitLabProjectApiUrl: gitLabProjectApiUrl,
165
166
  headers,
166
167
  filePath: path,
167
168
  ref,
168
169
  });
169
170
  if (fileText instanceof Error) {
171
+ logToolFailure(logStep, TOOL_NAME_GET_FILE, fileText);
170
172
  return JSON.stringify({
171
173
  ok: false,
172
174
  path,
173
175
  ref,
176
+ status: fileText.statusCode,
174
177
  error: fileText.message,
175
178
  });
176
179
  }
@@ -183,46 +186,57 @@ async function handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers) {
183
186
  });
184
187
  }
185
188
  catch (error) {
189
+ logToolFailure(logStep, TOOL_NAME_GET_FILE, error);
186
190
  return JSON.stringify({
187
191
  ok: false,
188
- error: `Failed to parse tool arguments: ${String(error?.message ?? error)}`,
192
+ error: `Tool call failed: ${String(error?.message ?? error)}`,
189
193
  raw: argsRaw,
190
194
  });
191
195
  }
192
196
  }
193
- async function handleGrepTool(argsRaw, defaultRef, gitLabProjectApiUrl, headers, projectId) {
197
+ async function handleGrepTool(argsRaw, refs, gitLabProjectApiUrl, headers, logStep) {
194
198
  try {
195
199
  const parsed = JSON.parse(argsRaw);
196
200
  const query = parsed.query?.trim();
197
201
  if (!query)
198
202
  return JSON.stringify({ ok: false, error: "query is required." });
199
- const ref = parsed.ref?.trim() || defaultRef;
203
+ // GitLab search answers 200 [] for unknown refs, so only the MR's own refs are searched.
204
+ const ref = parsed.ref?.trim() === refs.base ? refs.base : refs.head;
200
205
  const results = await searchRepository({
201
- gitLabBaseUrl: gitLabProjectApiUrl,
206
+ gitLabProjectApiUrl: gitLabProjectApiUrl,
202
207
  headers,
203
208
  query,
204
209
  ref,
205
- projectId,
206
210
  });
207
211
  if (results instanceof Error) {
212
+ logToolFailure(logStep, TOOL_NAME_GREP, results);
208
213
  return JSON.stringify({
209
214
  ok: false,
210
215
  query,
211
216
  ref,
217
+ status: results.statusCode,
212
218
  error: results.message,
213
219
  });
214
220
  }
215
- const trimmed = results.map((r) => ({
221
+ // GitLab lists file-name matches (file head, startline 1) before content matches;
222
+ // rank hits that contain the query first so they aren't crowded out.
223
+ const needle = query.toLowerCase();
224
+ const hasNeedle = (r) => (r.data ?? "").toLowerCase().includes(needle);
225
+ const trimmed = results
226
+ .sort((a, b) => Number(hasNeedle(b)) - Number(hasNeedle(a)))
227
+ .slice(0, MAX_TOOL_GREP_RESULTS)
228
+ .map((r) => ({
216
229
  path: r.path,
217
230
  startline: r.startline,
218
- data: r.data.slice(0, MAX_TOOL_GREP_CHARS),
231
+ data: (r.data ?? "").slice(0, MAX_TOOL_GREP_CHARS),
219
232
  }));
220
233
  return JSON.stringify({ ok: true, query, ref, matches: trimmed });
221
234
  }
222
235
  catch (error) {
236
+ logToolFailure(logStep, TOOL_NAME_GREP, error);
223
237
  return JSON.stringify({
224
238
  ok: false,
225
- error: `Failed to parse tool arguments: ${String(error?.message ?? error)}`,
239
+ error: `Tool call failed: ${String(error?.message ?? error)}`,
226
240
  raw: argsRaw,
227
241
  });
228
242
  }
@@ -241,8 +255,8 @@ async function mapWithConcurrency(items, concurrency, fn) {
241
255
  await Promise.all(workers);
242
256
  return results;
243
257
  }
244
- export async function reviewMergeRequestWithTools(params) {
245
- const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
258
+ async function reviewMergeRequestWithTools(params) {
259
+ const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, headers, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
246
260
  const { logDebug, logStep } = loggers;
247
261
  logStep(`Single-pass review started for ${changes.length} file(s) (fallback mode).`);
248
262
  const messages = buildPrompt({
@@ -279,14 +293,14 @@ export async function reviewMergeRequestWithTools(params) {
279
293
  type: "function",
280
294
  function: {
281
295
  name: TOOL_NAME_GREP,
282
- description: "Search the repository for a keyword or pattern. Returns up to 10 matching code fragments with file paths and line numbers.",
296
+ description: "Search the repository for literal text (case-insensitive substring match). Regular expressions are not supported: `a|b` or `foo.*bar` are searched as literal text, so query one identifier at a time. Returns up to 10 matching code fragments with file paths and line numbers.",
283
297
  parameters: {
284
298
  type: "object",
285
299
  additionalProperties: false,
286
300
  properties: {
287
301
  query: {
288
302
  type: "string",
289
- description: "Search string (keyword, function name, variable, etc.).",
303
+ description: "Literal text to find, e.g. a function or variable name (no regex).",
290
304
  },
291
305
  ref: {
292
306
  type: "string",
@@ -343,10 +357,10 @@ export async function reviewMergeRequestWithTools(params) {
343
357
  logToolUsageMinimal(logStep, toolName, argsRaw);
344
358
  let toolContent;
345
359
  if (toolName === TOOL_NAME_GET_FILE) {
346
- toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
360
+ toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep);
347
361
  }
348
362
  else if (toolName === TOOL_NAME_GREP) {
349
- toolContent = await handleGrepTool(argsRaw, refs.head, gitLabProjectApiUrl, headers, projectId);
363
+ toolContent = await handleGrepTool(argsRaw, refs, gitLabProjectApiUrl, headers, logStep);
350
364
  }
351
365
  else {
352
366
  toolContent = JSON.stringify({
@@ -390,7 +404,7 @@ export async function reviewMergeRequestWithTools(params) {
390
404
  return buildAnswer(finalCompletion);
391
405
  }
392
406
  async function runFileReviewWithTools(params) {
393
- const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, projectId, headers, forceTools, promptProfile, loggers, debugDumpFile, debugRecordWriter, } = params;
407
+ const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, headers, forceTools, promptProfile, loggers, debugDumpFile, debugRecordWriter, } = params;
394
408
  const { logDebug, logStep } = loggers;
395
409
  const messages = buildFileReviewPrompt({
396
410
  filePath,
@@ -424,14 +438,14 @@ async function runFileReviewWithTools(params) {
424
438
  type: "function",
425
439
  function: {
426
440
  name: TOOL_NAME_GREP,
427
- description: "Search the repository for a keyword or pattern. Returns up to 10 matching code fragments with file paths and line numbers.",
441
+ description: "Search the repository for literal text (case-insensitive substring match). Regular expressions are not supported: `a|b` or `foo.*bar` are searched as literal text, so query one identifier at a time. Returns up to 10 matching code fragments with file paths and line numbers.",
428
442
  parameters: {
429
443
  type: "object",
430
444
  additionalProperties: false,
431
445
  properties: {
432
446
  query: {
433
447
  type: "string",
434
- description: "Search string (keyword, function name, variable, etc.).",
448
+ description: "Literal text to find, e.g. a function or variable name (no regex).",
435
449
  },
436
450
  ref: {
437
451
  type: "string",
@@ -487,10 +501,10 @@ async function runFileReviewWithTools(params) {
487
501
  logToolUsageMinimal(logStep, toolName, argsRaw, filePath);
488
502
  let toolContent;
489
503
  if (toolName === TOOL_NAME_GET_FILE) {
490
- toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
504
+ toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep);
491
505
  }
492
506
  else if (toolName === TOOL_NAME_GREP) {
493
- toolContent = await handleGrepTool(argsRaw, refs.head, gitLabProjectApiUrl, headers, projectId);
507
+ toolContent = await handleGrepTool(argsRaw, refs, gitLabProjectApiUrl, headers, logStep);
494
508
  }
495
509
  else {
496
510
  toolContent = JSON.stringify({
@@ -537,7 +551,7 @@ function draftHasStructuredFindings(consolidatedText) {
537
551
  return /-\s*\[(?:high|medium)\]/i.test(consolidatedText);
538
552
  }
539
553
  async function runVerificationWithTools(params) {
540
- const { openaiInstance, aiModel, baseMessages, refs, gitLabProjectApiUrl, projectId, headers, forceTools, consolidatedDraft, loggers, debugDumpFile, debugRecordWriter, } = params;
554
+ const { openaiInstance, aiModel, baseMessages, refs, gitLabProjectApiUrl, headers, forceTools, consolidatedDraft, loggers, debugDumpFile, debugRecordWriter, } = params;
541
555
  const { logDebug, logStep } = loggers;
542
556
  const messages = [...baseMessages];
543
557
  const tools = [
@@ -564,14 +578,14 @@ async function runVerificationWithTools(params) {
564
578
  type: "function",
565
579
  function: {
566
580
  name: TOOL_NAME_GREP,
567
- description: "Search the repository for a keyword or pattern. Returns up to 10 matching code fragments with file paths and line numbers.",
581
+ description: "Search the repository for literal text (case-insensitive substring match). Regular expressions are not supported: `a|b` or `foo.*bar` are searched as literal text, so query one identifier at a time. Returns up to 10 matching code fragments with file paths and line numbers.",
568
582
  parameters: {
569
583
  type: "object",
570
584
  additionalProperties: false,
571
585
  properties: {
572
586
  query: {
573
587
  type: "string",
574
- description: "Search string (keyword, function name, variable, etc.).",
588
+ description: "Literal text to find, e.g. a function or variable name (no regex).",
575
589
  },
576
590
  ref: {
577
591
  type: "string",
@@ -627,10 +641,10 @@ async function runVerificationWithTools(params) {
627
641
  logToolUsageMinimal(logStep, toolName, argsRaw, "(verify)");
628
642
  let toolContent;
629
643
  if (toolName === TOOL_NAME_GET_FILE) {
630
- toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers);
644
+ toolContent = await handleGetFileTool(argsRaw, gitLabProjectApiUrl, headers, logStep);
631
645
  }
632
646
  else if (toolName === TOOL_NAME_GREP) {
633
- toolContent = await handleGrepTool(argsRaw, refs.head, gitLabProjectApiUrl, headers, projectId);
647
+ toolContent = await handleGrepTool(argsRaw, refs, gitLabProjectApiUrl, headers, logStep);
634
648
  }
635
649
  else {
636
650
  toolContent = JSON.stringify({
@@ -672,7 +686,7 @@ async function runVerificationWithTools(params) {
672
686
  });
673
687
  }
674
688
  export async function reviewMergeRequestMultiPass(params) {
675
- const { openaiInstance, aiModel, promptLimits, triageDiffChars, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, allowProfileAutoDemote = false, loggers, debugDumpFile, debugRecordWriter, } = params;
689
+ const { openaiInstance, aiModel, promptLimits, triageDiffChars, changes, refs, gitLabProjectApiUrl, headers, maxFindings, reviewConcurrency, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, allowProfileAutoDemote = false, loggers, debugDumpFile, debugRecordWriter, } = params;
676
690
  const { logStep } = loggers;
677
691
  let effectiveProfile = promptProfile;
678
692
  logStep(`Pass 1/4: triaging ${changes.length} file(s) ` +
@@ -773,7 +787,6 @@ export async function reviewMergeRequestMultiPass(params) {
773
787
  changes,
774
788
  refs,
775
789
  gitLabProjectApiUrl,
776
- projectId,
777
790
  headers,
778
791
  forceTools,
779
792
  promptProfile: effectiveProfile,
@@ -821,7 +834,6 @@ export async function reviewMergeRequestMultiPass(params) {
821
834
  otherChangedFiles: otherFiles,
822
835
  refs,
823
836
  gitLabProjectApiUrl,
824
- projectId,
825
837
  headers,
826
838
  forceTools,
827
839
  promptProfile: effectiveProfile,
@@ -909,7 +921,6 @@ export async function reviewMergeRequestMultiPass(params) {
909
921
  baseMessages: verificationMessages,
910
922
  refs,
911
923
  gitLabProjectApiUrl,
912
- projectId,
913
924
  headers,
914
925
  forceTools,
915
926
  consolidatedDraft: consolidatedText,
@@ -27,3 +27,9 @@ export function logToolUsageMinimal(logStep, toolName, argsRaw, contextFile) {
27
27
  const suffix = contextFile ? ` review_file=${contextFile}` : "";
28
28
  logStep(`[tools] ${toolName} args=${argsRaw.slice(0, 120)}${suffix}`);
29
29
  }
30
+ /** Operator-facing line for a failed tool call: GitLab status and request URL when known. */
31
+ export function logToolFailure(logStep, toolName, error) {
32
+ const status = error?.statusCode != null ? ` status=${error.statusCode}` : "";
33
+ const url = typeof error?.cause?.url === "string" ? ` url=${error.cause.url}` : "";
34
+ logStep(`[tools] ${toolName} failed: ${String(error?.message ?? error)}${status}${url}`);
35
+ }
package/dist/cli.js CHANGED
@@ -8,43 +8,41 @@ import { reviewMergeRequestMultiPass } from "./cli/ci-review.js";
8
8
  import { fetchMergeRequestChanges, postMergeRequestNote, } from "./gitlab/services.js";
9
9
  import { renderDebugArtifactsHtml } from "./cli/debug-artifacts-html.js";
10
10
  function printHelp() {
11
- process.stdout.write([
12
- "gitlab-ai-review",
13
- "",
14
- "Usage:",
15
- " gitlab-ai-review",
16
- " gitlab-ai-review --help",
17
- " gitlab-ai-review --debug",
18
- " gitlab-ai-review --ignore-ext=md,lock",
19
- "",
20
- "Debug:",
21
- " --debug Print full error details (stack, API error fields).",
22
- " --include-artifacts Generate local HTML artifact without printing payloads to console.",
23
- " --force-tools Force at least one tool-call round in tool-enabled review paths.",
24
- " --ignore-ext Ignore file extensions (comma-separated only). Example: --ignore-ext=md,lock",
25
- " --max-diffs=50",
26
- " --max-diff-chars=16000",
27
- " --max-total-prompt-chars=220000",
28
- " --triage-diff-chars=2000 Max chars per file diff in triage pass (Pass 1).",
29
- " --max-findings=5 Max findings in final review (CI multi-pass only).",
30
- " --max-review-concurrency=2 Parallel per-file review calls (CI multi-pass only).",
31
- "",
32
- "Env vars:",
33
- " OPENAI_API_KEY (required) OpenAI API key.",
34
- " OPENAI_BASE_URL (optional) Custom OpenAI-compatible API base URL.",
35
- " AI_MODEL (optional) OpenAI chat model, e.g. gpt-4o. Default: gpt-4o-mini.",
36
- " AI_REVIEW_CONCURRENCY (optional) Same as --max-review-concurrency; the flag wins.",
37
- " AI_PROMPT_PROFILE (optional) Prompt style: \"default\" | \"weak\". Default: default.",
38
- " Use \"weak\" for small/quantized models — shorter prompts,",
39
- " positive rules, and few-shot examples.",
40
- " When unset, an unparseable triage response auto-retries",
41
- " with \"weak\" and keeps it for the whole run.",
42
- " PROJECT_ACCESS_TOKEN (optional) GitLab Project/Personal Access Token for API calls (required for most private repos; should have api scope).",
43
- "",
44
- "CI-only env vars (provided by GitLab):",
45
- " CI_API_V4_URL, CI_PROJECT_ID, CI_MERGE_REQUEST_IID, CI_JOB_TOKEN (only if PROJECT_ACCESS_TOKEN is not set)",
46
- "",
47
- ].join("\n"));
11
+ process.stdout.write(`gitlab-ai-review
12
+
13
+ Usage:
14
+ gitlab-ai-review
15
+ gitlab-ai-review --help
16
+ gitlab-ai-review --debug
17
+ gitlab-ai-review --ignore-ext=md,lock
18
+
19
+ Debug:
20
+ --debug Print full error details (stack, API error fields).
21
+ --include-artifacts Generate local HTML artifact without printing payloads to console.
22
+ --force-tools Force at least one tool-call round in tool-enabled review paths.
23
+ --ignore-ext Ignore file extensions (comma-separated only). Example: --ignore-ext=md,lock
24
+ --max-diffs=50
25
+ --max-diff-chars=16000
26
+ --max-total-prompt-chars=220000
27
+ --triage-diff-chars=2000 Max chars per file diff in triage pass (Pass 1).
28
+ --max-findings=5 Max findings in final review (CI multi-pass only).
29
+ --max-review-concurrency=2 Parallel per-file review calls (CI multi-pass only).
30
+
31
+ Env vars:
32
+ OPENAI_API_KEY (required) OpenAI API key.
33
+ OPENAI_BASE_URL (optional) Custom OpenAI-compatible API base URL.
34
+ AI_MODEL (optional) OpenAI chat model, e.g. gpt-4o. Default: gpt-4o-mini.
35
+ AI_REVIEW_CONCURRENCY (optional) Same as --max-review-concurrency; the flag wins.
36
+ AI_PROMPT_PROFILE (optional) Prompt style: "default" | "weak". Default: default.
37
+ Use "weak" for small/quantized models — shorter prompts,
38
+ positive rules, and few-shot examples.
39
+ When unset, an unparseable triage response auto-retries
40
+ with "weak" and keeps it for the whole run.
41
+ PROJECT_ACCESS_TOKEN (required) GitLab project/personal access token with api scope (alias: GITLAB_TOKEN). CI_JOB_TOKEN can't post MR comments.
42
+
43
+ CI-only env vars (provided by GitLab):
44
+ CI_API_V4_URL, CI_PROJECT_ID, CI_MERGE_REQUEST_PROJECT_ID, CI_MERGE_REQUEST_IID
45
+ `);
48
46
  }
49
47
  const DEBUG_MODE = hasDebugFlag(process.argv);
50
48
  const FORCE_TOOLS = hasForceToolsFlag(process.argv);
@@ -109,24 +107,24 @@ async function main() {
109
107
  "CI_PROJECT_ID",
110
108
  "CI_MERGE_REQUEST_IID",
111
109
  ];
110
+ // CI_JOB_TOKEN can't post MR notes or call the search API, so a real token is required.
112
111
  if (projectAccessToken == null)
113
- gitlabRequired.push("CI_JOB_TOKEN");
112
+ gitlabRequired.push("PROJECT_ACCESS_TOKEN");
114
113
  const envs = requireEnvs(gitlabRequired);
115
114
  const openaiApiKey = envs["OPENAI_API_KEY"];
116
115
  const ciApiV4Url = envs["CI_API_V4_URL"];
117
- const projectId = envs["CI_PROJECT_ID"];
116
+ // MR endpoints live in the MR's target project; in fork pipelines CI_PROJECT_ID is the fork.
117
+ const projectId = envOrUndefined("CI_MERGE_REQUEST_PROJECT_ID") ?? envs["CI_PROJECT_ID"];
118
118
  const mergeRequestIid = envs["CI_MERGE_REQUEST_IID"];
119
- const headers = {};
120
- if (projectAccessToken != null)
121
- headers["PRIVATE-TOKEN"] = projectAccessToken;
122
- else
123
- headers["JOB-TOKEN"] = envs["CI_JOB_TOKEN"];
119
+ const gitLabProjectApiUrl = new URL(`${ciApiV4Url}/projects/${projectId}`);
120
+ const headers = {
121
+ "PRIVATE-TOKEN": projectAccessToken,
122
+ };
124
123
  try {
125
124
  logStep("Fetching merge request changes");
126
125
  const mrChanges = await fetchMergeRequestChanges({
127
- gitLabBaseUrl: new URL(ciApiV4Url),
126
+ gitLabProjectApiUrl: gitLabProjectApiUrl,
128
127
  headers,
129
- projectId,
130
128
  mergeRequestIid,
131
129
  });
132
130
  if (mrChanges instanceof Error)
@@ -149,8 +147,7 @@ async function main() {
149
147
  base: mrChanges.diff_refs?.base_sha ?? "HEAD",
150
148
  head: mrChanges.diff_refs?.head_sha ?? "HEAD",
151
149
  },
152
- gitLabProjectApiUrl: new URL(`${ciApiV4Url}/projects/${projectId}`),
153
- projectId,
150
+ gitLabProjectApiUrl: gitLabProjectApiUrl,
154
151
  headers,
155
152
  maxFindings,
156
153
  reviewConcurrency,
@@ -162,10 +159,11 @@ async function main() {
162
159
  });
163
160
  logStep("Posting AI review note to merge request");
164
161
  const noteRes = await postMergeRequestNote({
165
- gitLabBaseUrl: new URL(`${ciApiV4Url}/projects/${projectId}`),
162
+ gitLabProjectApiUrl: gitLabProjectApiUrl,
166
163
  headers,
167
164
  mergeRequestIid,
168
- }, { body: answer });
165
+ note: answer,
166
+ });
169
167
  if (noteRes instanceof Error)
170
168
  throw noteRes;
171
169
  process.stdout.write("Posted AI review comment to merge request.\n");
@@ -1,49 +1,7 @@
1
1
  /** @format */
2
- import OpenAI from "openai";
3
- import { AI_MODEL_TEMPERATURE } from "../prompt/index.js";
4
- import { GitLabError, OpenAIError, } from "./types.js";
5
- export const fetchPreEditFiles = async ({ gitLabBaseUrl, headers, changesOldPaths, ref }) => {
6
- const oldFilesRequestUrls = changesOldPaths.map((filePath) => {
7
- const url = new URL(`${gitLabBaseUrl}/repository/files/${encodeURIComponent(filePath)}/raw`);
8
- url.searchParams.set("ref", ref);
9
- return url;
10
- });
11
- let oldFiles;
12
- try {
13
- oldFiles = await Promise.allSettled(oldFilesRequestUrls.map(async (url) => {
14
- const res = await fetch(url, { headers: { ...headers } });
15
- if (!res.ok) {
16
- const bodyText = await res.text().catch(() => "");
17
- throw new Error(`Failed to fetch old file: ${url.toString()} (status ${res.status} ${res.statusText}, body: ${bodyText.slice(0, 500)})`);
18
- }
19
- return await res.text();
20
- }));
21
- }
22
- catch (error) {
23
- oldFiles = error;
24
- }
25
- if (oldFiles instanceof Error) {
26
- return new GitLabError({
27
- name: "MISSING_OLD_FILES",
28
- message: "Failed to fetch old files",
29
- cause: {
30
- message: oldFiles.message,
31
- stack: oldFiles.stack,
32
- },
33
- });
34
- }
35
- return oldFiles.reduce((acc, file, index) => {
36
- if (file.status === "fulfilled") {
37
- acc.push({
38
- fileName: changesOldPaths[index],
39
- fileContent: file.value,
40
- });
41
- }
42
- return acc;
43
- }, []);
44
- };
45
- export const fetchFileAtRef = async ({ gitLabBaseUrl, headers, filePath, ref }) => {
46
- const url = new URL(`${gitLabBaseUrl}/repository/files/${encodeURIComponent(filePath)}/raw`);
2
+ import { GitLabError, } from "./types.js";
3
+ export const fetchFileAtRef = async ({ gitLabProjectApiUrl, headers, filePath, ref }) => {
4
+ const url = new URL(`${gitLabProjectApiUrl}/repository/files/${encodeURIComponent(filePath)}/raw`);
47
5
  url.searchParams.set("ref", ref);
48
6
  let res;
49
7
  try {
@@ -81,30 +39,8 @@ export const fetchFileAtRef = async ({ gitLabBaseUrl, headers, filePath, ref })
81
39
  }
82
40
  return await res.text();
83
41
  };
84
- export async function generateAICompletion(messages, openaiInstance, aiModel) {
85
- let completion;
86
- try {
87
- completion = await openaiInstance.chat.completions.create({
88
- model: aiModel,
89
- temperature: AI_MODEL_TEMPERATURE,
90
- stream: false,
91
- messages,
92
- });
93
- }
94
- catch (error) {
95
- completion = error;
96
- }
97
- if (completion instanceof Error) {
98
- return new OpenAIError({
99
- name: "MISSING_AI_COMPLETION",
100
- message: "Failed to generate AI completion",
101
- cause: completion,
102
- });
103
- }
104
- return completion;
105
- }
106
- export const postMergeRequestNote = async ({ gitLabBaseUrl, headers, mergeRequestIid }, commentPayload) => {
107
- const commentUrl = new URL(`${gitLabBaseUrl}/merge_requests/${mergeRequestIid}/notes`);
42
+ export const postMergeRequestNote = async ({ gitLabProjectApiUrl, headers, mergeRequestIid, note }) => {
43
+ const commentUrl = new URL(`${gitLabProjectApiUrl}/merge_requests/${mergeRequestIid}/notes`);
108
44
  let aiComment;
109
45
  try {
110
46
  aiComment = await fetch(commentUrl, {
@@ -113,7 +49,7 @@ export const postMergeRequestNote = async ({ gitLabBaseUrl, headers, mergeReques
113
49
  ...headers,
114
50
  "Content-Type": "application/json",
115
51
  },
116
- body: JSON.stringify(commentPayload),
52
+ body: JSON.stringify({ body: note }),
117
53
  });
118
54
  }
119
55
  catch (error) {
@@ -147,12 +83,13 @@ export const postMergeRequestNote = async ({ gitLabBaseUrl, headers, mergeReques
147
83
  });
148
84
  }
149
85
  };
150
- export const searchRepository = async ({ gitLabBaseUrl, headers, query, ref, projectId }) => {
151
- const url = new URL(`${gitLabBaseUrl}/projects/${projectId}/search`);
86
+ export const searchRepository = async ({ gitLabProjectApiUrl, headers, query, ref }) => {
87
+ const url = new URL(`${gitLabProjectApiUrl}/search`);
152
88
  url.searchParams.set("scope", "blobs");
153
89
  url.searchParams.set("search", query);
154
90
  url.searchParams.set("ref", ref);
155
- url.searchParams.set("per_page", "10");
91
+ // Name matches come before content matches; the grep tool re-ranks and keeps 10.
92
+ url.searchParams.set("per_page", "50");
156
93
  let res;
157
94
  try {
158
95
  res = await fetch(url, { headers: { ...headers } });
@@ -185,8 +122,8 @@ export const searchRepository = async ({ gitLabBaseUrl, headers, query, ref, pro
185
122
  }
186
123
  return (await res.json());
187
124
  };
188
- export const fetchMergeRequestChanges = async ({ gitLabBaseUrl, headers, projectId, mergeRequestIid }) => {
189
- const url = new URL(`${gitLabBaseUrl}/projects/${projectId}/merge_requests/${mergeRequestIid}/changes`);
125
+ export const fetchMergeRequestChanges = async ({ gitLabProjectApiUrl, headers, mergeRequestIid }) => {
126
+ const url = new URL(`${gitLabProjectApiUrl}/merge_requests/${mergeRequestIid}/changes`);
190
127
  // Ask GitLab for raw diffs to reduce truncation on larger merge requests.
191
128
  url.searchParams.set("access_raw_diffs", "true");
192
129
  let res;
@@ -1,5 +1,3 @@
1
1
  import { BaseError } from '../errors.js';
2
2
  export class GitLabError extends BaseError {
3
3
  }
4
- export class OpenAIError extends BaseError {
5
- }
@@ -1,7 +1,7 @@
1
1
  /** @format */
2
2
  import { buildFileReviewSystemMessage, buildMainSystemMessages, buildTriageSystemMessage, } from "./messages.js";
3
3
  import { extractFirstJsonObject, normalizeReviewFindingsMarkdown, sanitizeGitLabMarkdown, truncateWithMarker, } from "./utils.js";
4
- import { getConsolidateSystemLines, getVerificationSystemLines, } from "./templates/postprocess-system.js";
4
+ import { getConsolidateSystemPrompt, getVerificationSystemPrompt, } from "./templates/postprocess-system.js";
5
5
  import { buildConsolidateUserContent, buildFileReviewUserContent, buildMainReviewUserContent, buildTriageUserContent, buildVerificationUserContent, } from "./templates/user-prompts.js";
6
6
  import { DEFAULT_PROMPT_PROFILE, isEmptyReviewBody } from "./profile.js";
7
7
  export { DEFAULT_PROMPT_PROFILE, NO_FINDINGS_SENTENCE, isEmptyReviewBody, parsePromptProfile, } from "./profile.js";
@@ -12,8 +12,6 @@ export const DEFAULT_PROMPT_LIMITS = {
12
12
  };
13
13
  /** Max chars of each file diff sent to the triage pass (Pass 1). */
14
14
  export const DEFAULT_TRIAGE_DIFF_CHARS = 2000;
15
- export const AI_MODEL_TEMPERATURE = 0.2;
16
- export const AI_MAX_OUTPUT_TOKENS = 600;
17
15
  export const buildPrompt = ({ changes, limits, allowTools = false, profile = DEFAULT_PROMPT_PROFILE, }) => {
18
16
  const effectiveLimits = {
19
17
  ...DEFAULT_PROMPT_LIMITS,
@@ -160,7 +158,7 @@ export function buildConsolidatePrompt(params) {
160
158
  return [
161
159
  {
162
160
  role: "system",
163
- content: getConsolidateSystemLines(profile, maxFindings).join("\n"),
161
+ content: getConsolidateSystemPrompt(profile, maxFindings),
164
162
  },
165
163
  {
166
164
  role: "user",
@@ -180,7 +178,7 @@ export function buildVerificationPrompt(params) {
180
178
  return [
181
179
  {
182
180
  role: "system",
183
- content: getVerificationSystemLines(profile, maxFindings).join("\n"),
181
+ content: getVerificationSystemPrompt(profile, maxFindings),
184
182
  },
185
183
  {
186
184
  role: "user",
@@ -1,22 +1,18 @@
1
1
  /** @format */
2
- import { getFileReviewSystemLines, getMainSystemLines, } from "./templates/review-system.js";
3
- import { getTriageSystemLines } from "./templates/triage-system.js";
2
+ import { getFileReviewSystemPrompt, getMainSystemPrompt, } from "./templates/review-system.js";
3
+ import { getTriageSystemPrompt } from "./templates/triage-system.js";
4
4
  import { DEFAULT_PROMPT_PROFILE } from "./profile.js";
5
5
  export const buildMainSystemMessages = (profile = DEFAULT_PROMPT_PROFILE) => [
6
6
  {
7
7
  role: "system",
8
- content: getMainSystemLines(profile).join("\n"),
8
+ content: getMainSystemPrompt(profile),
9
9
  },
10
10
  ];
11
11
  export const buildTriageSystemMessage = (profile = DEFAULT_PROMPT_PROFILE) => ({
12
12
  role: "system",
13
- content: getTriageSystemLines(profile).join("\n"),
13
+ content: getTriageSystemPrompt(profile),
14
14
  });
15
15
  export const buildFileReviewSystemMessage = (profile = DEFAULT_PROMPT_PROFILE) => ({
16
16
  role: "system",
17
- content: getFileReviewSystemLines(profile).join("\n"),
17
+ content: getFileReviewSystemPrompt(profile),
18
18
  });
19
- /** @deprecated kept for backward compatibility — use buildTriageSystemMessage(). */
20
- export const TRIAGE_SYSTEM = buildTriageSystemMessage();
21
- /** @deprecated kept for backward compatibility — use buildFileReviewSystemMessage(). */
22
- export const FILE_REVIEW_SYSTEM = buildFileReviewSystemMessage();