codeant-cli 0.3.3 → 0.3.5

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.
Files changed (76) hide show
  1. package/package.json +8 -10
  2. package/src/commands/getBaseUrl.js +21 -0
  3. package/src/commands/login.js +120 -0
  4. package/src/commands/logout.js +30 -0
  5. package/src/commands/review.js +294 -0
  6. package/src/commands/secrets.js +107 -0
  7. package/src/commands/setBaseUrl.js +37 -0
  8. package/src/components/ReviewUI.js +410 -0
  9. package/src/components/SecretsUI.js +366 -0
  10. package/src/components/Welcome.js +144 -0
  11. package/src/index.js +390 -0
  12. package/src/reviewHeadless.js +198 -0
  13. package/src/rules/secrets.toml +4994 -0
  14. package/src/scm/azure/client.js +21 -0
  15. package/src/scm/azure/getCodeReview.js +20 -0
  16. package/src/scm/azure/getPullRequest.js +30 -0
  17. package/src/scm/azure/index.js +7 -0
  18. package/src/scm/azure/listCodeReviews.js +17 -0
  19. package/src/scm/azure/listPullRequestComments.js +41 -0
  20. package/src/scm/azure/listPullRequests.js +26 -0
  21. package/src/scm/azure/resolveConversation.js +22 -0
  22. package/src/scm/azure/searchComments.js +35 -0
  23. package/src/scm/bitbucket/client.js +18 -0
  24. package/src/scm/bitbucket/getCodeReview.js +3 -0
  25. package/src/scm/bitbucket/getPullRequest.js +26 -0
  26. package/src/scm/bitbucket/index.js +7 -0
  27. package/src/scm/bitbucket/listCodeReviews.js +17 -0
  28. package/src/scm/bitbucket/listPullRequestComments.js +33 -0
  29. package/src/scm/bitbucket/listPullRequests.js +33 -0
  30. package/src/scm/bitbucket/resolveConversation.js +21 -0
  31. package/src/scm/bitbucket/searchComments.js +29 -0
  32. package/src/scm/github/client.js +17 -0
  33. package/src/scm/github/getCodeReview.js +26 -0
  34. package/src/scm/github/getPullRequest.js +27 -0
  35. package/src/scm/github/index.js +7 -0
  36. package/src/scm/github/listCodeReviews.js +17 -0
  37. package/src/scm/github/listPullRequestComments.js +51 -0
  38. package/src/scm/github/listPullRequests.js +32 -0
  39. package/src/scm/github/resolveConversation.js +85 -0
  40. package/src/scm/github/searchComments.js +30 -0
  41. package/src/scm/gitlab/client.js +16 -0
  42. package/src/scm/gitlab/getCodeReview.js +18 -0
  43. package/src/scm/gitlab/getPullRequest.js +24 -0
  44. package/src/scm/gitlab/index.js +7 -0
  45. package/src/scm/gitlab/listCodeReviews.js +21 -0
  46. package/src/scm/gitlab/listPullRequestComments.js +44 -0
  47. package/src/scm/gitlab/listPullRequests.js +26 -0
  48. package/src/scm/gitlab/resolveConversation.js +14 -0
  49. package/src/scm/gitlab/searchComments.js +28 -0
  50. package/src/scm/index.js +97 -0
  51. package/src/tools/executeTool.js +31 -0
  52. package/src/tools/globTool.js +9 -0
  53. package/src/tools/grepTool.js +18 -0
  54. package/src/tools/lsTool.js +7 -0
  55. package/src/tools/readTool.js +12 -0
  56. package/src/utils/analytics.js +56 -0
  57. package/src/utils/baseUrl.js +11 -0
  58. package/src/utils/commonApiHelper.js +143 -0
  59. package/src/utils/config.js +42 -0
  60. package/src/utils/fetchApi.js +39 -0
  61. package/src/utils/gitDiffHelper.js +1203 -0
  62. package/src/utils/reviewApiHelper.js +291 -0
  63. package/src/utils/scmAuth.js +136 -0
  64. package/src/utils/secretsApiHelper.js +252 -0
  65. package/src/utils/secretsDetector.js +210 -0
  66. package/dist/178.index.js +0 -1
  67. package/dist/239.index.js +0 -1
  68. package/dist/701.index.js +0 -1
  69. package/dist/880.index.js +0 -1
  70. package/dist/935.index.js +0 -1
  71. package/dist/966.index.js +0 -1
  72. package/dist/index.js +0 -28
  73. package/dist/package.json +0 -1
  74. package/dist/review/239.index.js +0 -1
  75. package/dist/review/index.js +0 -8
  76. package/dist/review/package.json +0 -3
package/src/index.js ADDED
@@ -0,0 +1,390 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from 'commander';
4
+ import { render } from 'ink';
5
+ import React from 'react';
6
+ import { createRequire } from 'module';
7
+ import Secrets from './commands/secrets.js';
8
+ import SetBaseUrl from './commands/setBaseUrl.js';
9
+ import GetBaseUrl from './commands/getBaseUrl.js';
10
+ import Login from './commands/login.js';
11
+ import Logout from './commands/logout.js';
12
+ import Review from './commands/review.js';
13
+ import Welcome from './components/Welcome.js';
14
+ import * as scm from './scm/index.js';
15
+ import { setConfigValue } from './utils/config.js';
16
+ import { track, shutdown as analyticsShutdown, isTelemetryDisabled } from './utils/analytics.js';
17
+
18
+ // Read version from package.json
19
+ const require = createRequire(import.meta.url);
20
+ const pkg = require('../package.json');
21
+
22
+ // Split comma-separated globs while preserving commas inside {} brace expansions (e.g. "*.{js,ts}")
23
+ function splitGlobs(input) {
24
+ const parts = [];
25
+ let current = '';
26
+ let depth = 0;
27
+ for (const ch of String(input)) {
28
+ if (ch === '{') depth++;
29
+ else if (ch === '}') depth--;
30
+ if (ch === ',' && depth === 0) {
31
+ parts.push(current.trim());
32
+ current = '';
33
+ } else {
34
+ current += ch;
35
+ }
36
+ }
37
+ if (current.trim()) parts.push(current.trim());
38
+ return parts.filter(Boolean);
39
+ }
40
+
41
+ // Show welcome animation if no arguments provided
42
+ if (process.argv.length === 2) {
43
+ render(React.createElement(Welcome, { version: pkg.version }));
44
+ } else {
45
+ program
46
+ .name('codeant')
47
+ .description('Code review CLI tool')
48
+ .version(pkg.version);
49
+
50
+ program
51
+ .command('secrets')
52
+ .description('Scan for secrets in your code')
53
+ .option('--all', 'Scan committed + uncommitted changes (default)')
54
+ .option('--committed', 'Scan only unpushed commits')
55
+ .option('--uncommitted', 'Scan only uncommitted changes (staged + unstaged + untracked)')
56
+ .option('--staged', 'Scan only staged files')
57
+ .option('--last-commit', 'Scan last commit')
58
+ .option('--last-n-commits <n>', 'Scan last n commits (max 5)', parseInt)
59
+ .option('--base <branch>', 'Compare against a specific base branch (e.g. --base develop)')
60
+ .option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
61
+ .option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
62
+ .option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
63
+ .option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
64
+ .action((options) => {
65
+ let scanType = 'all';
66
+ let lastNCommits = 1;
67
+ let baseBranch = null;
68
+ let baseCommit = null;
69
+
70
+ if (options.base) {
71
+ scanType = 'base-branch';
72
+ baseBranch = options.base;
73
+ } else if (options.baseCommit) {
74
+ scanType = 'base-commit';
75
+ baseCommit = options.baseCommit;
76
+ // Check !== undefined, not truthy — passing 0 is valid and shouldn't fall through
77
+ } else if (options.lastNCommits !== undefined) {
78
+ scanType = 'last-n-commits';
79
+ lastNCommits = Math.min(Math.max(1, options.lastNCommits), 5);
80
+ } else if (options.committed) {
81
+ scanType = 'committed';
82
+ } else if (options.uncommitted) {
83
+ scanType = 'uncommitted';
84
+ } else if (options.staged) {
85
+ scanType = 'staged-only';
86
+ } else if (options.lastCommit) {
87
+ scanType = 'last-commit';
88
+ }
89
+
90
+ const include = options.include
91
+ ? (Array.isArray(options.include) ? options.include : splitGlobs(options.include))
92
+ : [];
93
+
94
+ const exclude = options.exclude
95
+ ? (Array.isArray(options.exclude) ? options.exclude : splitGlobs(options.exclude))
96
+ : [];
97
+
98
+ const failOn = options.failOn?.toUpperCase() || 'CRITICAL';
99
+
100
+ render(React.createElement(Secrets, { scanType, failOn, include, exclude, lastNCommits, baseBranch, baseCommit }));
101
+ });
102
+
103
+ program
104
+ .command('review')
105
+ .description('Run AI-powered code review')
106
+ .option('--all', 'Review committed + uncommitted changes (default)')
107
+ .option('--committed', 'Review only unpushed commits')
108
+ .option('--uncommitted', 'Review only uncommitted changes (staged + unstaged + untracked)')
109
+ .option('--staged', 'Review only staged files')
110
+ .option('--last-commit', 'Review last commit')
111
+ .option('--last-n-commits <n>', 'Review last n commits (max 5)', parseInt)
112
+ .option('--base <branch>', 'Compare against a specific base branch (e.g. --base develop)')
113
+ .option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
114
+ .option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
115
+ .option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
116
+ .option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
117
+ .action((options) => {
118
+ let scanType = 'all';
119
+ let lastNCommits = 1;
120
+ let baseBranch = null;
121
+ let baseCommit = null;
122
+
123
+ if (options.base) {
124
+ scanType = 'base-branch';
125
+ baseBranch = options.base;
126
+ } else if (options.baseCommit) {
127
+ scanType = 'base-commit';
128
+ baseCommit = options.baseCommit;
129
+ // Check !== undefined, not truthy — passing 0 is valid and shouldn't fall through
130
+ } else if (options.lastNCommits !== undefined) {
131
+ scanType = 'last-n-commits';
132
+ lastNCommits = Math.min(Math.max(1, options.lastNCommits), 5);
133
+ } else if (options.committed) {
134
+ scanType = 'committed';
135
+ } else if (options.uncommitted) {
136
+ scanType = 'uncommitted';
137
+ } else if (options.staged) {
138
+ scanType = 'staged-only';
139
+ } else if (options.lastCommit) {
140
+ scanType = 'last-commit';
141
+ }
142
+
143
+ const include = options.include
144
+ ? (Array.isArray(options.include) ? options.include : splitGlobs(options.include))
145
+ : [];
146
+
147
+ const exclude = options.exclude
148
+ ? (Array.isArray(options.exclude) ? options.exclude : splitGlobs(options.exclude))
149
+ : [];
150
+
151
+ const failOn = options.failOn?.toUpperCase() || 'CRITICAL';
152
+
153
+ render(React.createElement(Review, { scanType, lastNCommits, failOn, include, exclude, baseBranch, baseCommit }));
154
+ });
155
+
156
+ program
157
+ .command('set-base-url <url>')
158
+ .description('Set the API base URL')
159
+ .action((url) => {
160
+ render(React.createElement(SetBaseUrl, { url }));
161
+ });
162
+
163
+ program
164
+ .command('get-base-url')
165
+ .description('Show the current API base URL')
166
+ .action(() => {
167
+ render(React.createElement(GetBaseUrl));
168
+ });
169
+
170
+ program
171
+ .command('login')
172
+ .description('Login to CodeAnt')
173
+ .action(() => {
174
+ render(React.createElement(Login));
175
+ });
176
+
177
+ program
178
+ .command('logout')
179
+ .description('Logout from CodeAnt')
180
+ .action(() => {
181
+ render(React.createElement(Logout));
182
+ });
183
+
184
+ // ─── Helper: resolve repo params with auto-detection fallback ───
185
+ function resolveRepoOpts(options) {
186
+ const remote = options.remote || scm.detectRemote();
187
+ const name = options.name || scm.detectRepoName();
188
+ const defaultBranch = options.defaultBranch || scm.detectDefaultBranch();
189
+ if (!remote) { console.error('Error: Could not detect remote. Use --remote (github|gitlab|bitbucket|azure)'); process.exit(1); }
190
+ if (!name) { console.error('Error: Could not detect repo name. Use --name owner/repo'); process.exit(1); }
191
+ return { ...options, remote, name, defaultBranch };
192
+ }
193
+
194
+ // Helper: run async command and output JSON
195
+ async function runCmd(fn) {
196
+ try {
197
+ const result = await fn();
198
+ console.log(JSON.stringify(result, null, 2));
199
+ } catch (err) {
200
+ console.error(JSON.stringify({ error: err.message }, null, 2));
201
+ process.exit(1);
202
+ }
203
+ }
204
+
205
+ // ─── Token management ───
206
+ program
207
+ .command('set-token <remote> <token>')
208
+ .description('Store auth token for a platform (github|gitlab|bitbucket|azure)')
209
+ .action((remote, token) => {
210
+ const keyMap = { github: 'githubToken', gitlab: 'gitlabToken', bitbucket: 'bitbucketToken', azure: 'azureDevOpsToken' };
211
+ const key = keyMap[remote];
212
+ if (!key) { console.error(`Unknown remote "${remote}". Use: github, gitlab, bitbucket, azure`); process.exit(1); }
213
+ setConfigValue(key, token);
214
+ console.log(`${remote} token saved.`);
215
+ });
216
+
217
+ // ─── PR commands ───
218
+ const pr = program.command('pr').description('Pull request tools (GitHub, GitLab, Bitbucket, Azure DevOps)');
219
+
220
+ pr.command('list')
221
+ .description('List pull requests / merge requests')
222
+ .option('--name <repo>', 'Repository (owner/repo)')
223
+ .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
224
+ .option('--default-branch <branch>', 'Default branch name')
225
+ .option('--source-branch <branch>', 'Filter by source branch (partial match)')
226
+ .option('--author <login>', 'Filter by author (fuzzy match)')
227
+ .option('--state <state>', 'open or closed (default: open)', 'open')
228
+ .option('--limit <n>', 'Max results (default: 20, max: 100)', parseInt, 20)
229
+ .option('--offset <n>', 'Pagination offset', parseInt, 0)
230
+ .action((options) => {
231
+ const opts = resolveRepoOpts(options);
232
+ runCmd(() => scm.listPullRequests({
233
+ name: opts.name, remote: opts.remote, defaultBranch: opts.defaultBranch,
234
+ sourceBranch: opts.sourceBranch, authorLogin: opts.author,
235
+ state: opts.state, limit: opts.limit, offset: opts.offset,
236
+ }));
237
+ });
238
+
239
+ pr.command('get')
240
+ .description('Get detailed PR information including review analysis')
241
+ .option('--name <repo>', 'Repository (owner/repo)')
242
+ .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
243
+ .option('--default-branch <branch>', 'Default branch name')
244
+ .requiredOption('--pr-number <n>', 'PR number', parseInt)
245
+ .action((options) => {
246
+ const opts = resolveRepoOpts(options);
247
+ runCmd(() => scm.getPullRequest({
248
+ name: opts.name, remote: opts.remote, defaultBranch: opts.defaultBranch,
249
+ prNumber: opts.prNumber,
250
+ }));
251
+ });
252
+
253
+ pr.command('comments')
254
+ .description('List all comments for a PR')
255
+ .option('--name <repo>', 'Repository (owner/repo)')
256
+ .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
257
+ .option('--default-branch <branch>', 'Default branch name')
258
+ .requiredOption('--pr-number <n>', 'PR number', parseInt)
259
+ .option('--codeant-generated <bool>', 'Filter by CodeAnt authorship (true/false)', (v) => v === 'true')
260
+ .option('--addressed', 'Filter by addressed/resolved status')
261
+ .option('--created-after <date>', 'ISO 8601 date filter')
262
+ .option('--created-before <date>', 'ISO 8601 date filter')
263
+ .action((options) => {
264
+ const opts = resolveRepoOpts(options);
265
+ runCmd(() => scm.listPullRequestComments({
266
+ name: opts.name, remote: opts.remote, defaultBranch: opts.defaultBranch,
267
+ prNumber: opts.prNumber, codeantGenerated: opts.codeantGenerated,
268
+ addressed: opts.addressed, createdAfter: opts.createdAfter, createdBefore: opts.createdBefore,
269
+ }));
270
+ });
271
+
272
+ pr.command('resolve')
273
+ .description('Resolve a conversation/comment thread on a PR')
274
+ .option('--name <repo>', 'Repository (owner/repo)')
275
+ .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
276
+ .requiredOption('--pr-number <n>', 'PR number', parseInt)
277
+ .option('--comment-id <id>', 'Comment ID (GitHub, Bitbucket)', parseInt)
278
+ .option('--thread-id <id>', 'Thread/node ID (GitHub GraphQL, Azure)')
279
+ .option('--discussion-id <id>', 'Discussion ID (GitLab)')
280
+ .action((options) => {
281
+ const opts = resolveRepoOpts(options);
282
+ runCmd(() => scm.resolveConversation({
283
+ name: opts.name, remote: opts.remote,
284
+ prNumber: opts.prNumber,
285
+ commentId: opts.commentId,
286
+ threadId: opts.threadId,
287
+ discussionId: opts.discussionId,
288
+ }));
289
+ });
290
+
291
+ // ─── Code review commands ───
292
+ const codeReview = program.command('code-review').description('Code review tools');
293
+
294
+ codeReview.command('list')
295
+ .description('List code reviews with optional filtering')
296
+ .option('--name <repo>', 'Repository (owner/repo)')
297
+ .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
298
+ .option('--default-branch <branch>', 'Default branch name')
299
+ .option('--pr-number <n>', 'Filter by PR number', parseInt)
300
+ .option('--status <status>', 'Filter by status (PENDING, COMPLETED, FAILED, etc.)')
301
+ .option('--limit <n>', 'Max results (default: 20)', parseInt, 20)
302
+ .option('--offset <n>', 'Pagination offset', parseInt, 0)
303
+ .action((options) => {
304
+ const opts = resolveRepoOpts(options);
305
+ runCmd(() => scm.listCodeReviews({
306
+ name: opts.name, remote: opts.remote, defaultBranch: opts.defaultBranch,
307
+ prNumber: opts.prNumber, status: opts.status,
308
+ limit: opts.limit, offset: opts.offset,
309
+ }));
310
+ });
311
+
312
+ codeReview.command('get')
313
+ .description('Get detailed information for a specific code review')
314
+ .option('--name <repo>', 'Repository (owner/repo)')
315
+ .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
316
+ .requiredOption('--pr-number <n>', 'PR number', parseInt)
317
+ .requiredOption('--review-id <id>', 'Code review ID')
318
+ .action((options) => {
319
+ const opts = resolveRepoOpts(options);
320
+ runCmd(() => scm.getCodeReview({
321
+ name: opts.name, remote: opts.remote,
322
+ prNumber: opts.prNumber, reviewId: opts.reviewId,
323
+ }));
324
+ });
325
+
326
+ // ─── Comment search ───
327
+ const comments = program.command('comments').description('Comment search tools');
328
+
329
+ comments.command('search')
330
+ .description('Search across all CodeAnt comments')
331
+ .option('--name <repo>', 'Repository (owner/repo)')
332
+ .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
333
+ .requiredOption('--query <term>', 'Search term')
334
+ .option('--limit <n>', 'Max results (default: 10, max: 50)', parseInt, 10)
335
+ .option('--include-addressed', 'Include resolved comments (default: false)')
336
+ .option('--created-after <date>', 'ISO 8601 date filter')
337
+ .action((options) => {
338
+ const opts = resolveRepoOpts(options);
339
+ runCmd(() => scm.searchComments({
340
+ name: opts.name, remote: opts.remote,
341
+ query: opts.query, limit: opts.limit,
342
+ includeAddressed: opts.includeAddressed || false,
343
+ createdAfter: opts.createdAfter,
344
+ }));
345
+ });
346
+
347
+ // ─── Telemetry control ───
348
+ program
349
+ .command('set-telemetry <enabled>')
350
+ .description('Enable or disable telemetry / PostHog analytics (true or false)')
351
+ .action((enabled) => {
352
+ const val = enabled.toLowerCase();
353
+ if (val !== 'true' && val !== 'false') {
354
+ console.error('Usage: codeant set-telemetry <true|false>');
355
+ process.exit(1);
356
+ }
357
+ setConfigValue('telemetryEnabled', val === 'true');
358
+ console.log(`Telemetry ${val === 'true' ? 'enabled' : 'disabled'}.`);
359
+ });
360
+
361
+ program
362
+ .command('get-telemetry')
363
+ .description('Show current telemetry status')
364
+ .action(() => {
365
+ const disabled = isTelemetryDisabled();
366
+ console.log(`Telemetry is currently ${disabled ? 'disabled' : 'enabled'}.`);
367
+ });
368
+
369
+ // ─── Analytics tracking (for external callers like Claude Code skills) ───
370
+ program
371
+ .command('track')
372
+ .description('Send an analytics event')
373
+ .requiredOption('--event <name>', 'Event name (e.g. skill_invoked, suggestions_applied)')
374
+ .option('--props <json>', 'JSON string of event properties', '{}')
375
+ .action(async (options) => {
376
+ try {
377
+ const props = JSON.parse(options.props);
378
+ if (typeof props !== 'object' || props === null || Array.isArray(props)) {
379
+ process.stderr.write('--props must be a JSON object\n');
380
+ return;
381
+ }
382
+ track(options.event, props);
383
+ await analyticsShutdown();
384
+ } catch (err) {
385
+ process.stderr.write(`Invalid --props JSON: ${err instanceof Error ? err.message : String(err)}\n`);
386
+ }
387
+ });
388
+
389
+ program.parse();
390
+ }
@@ -0,0 +1,198 @@
1
+ import ReviewApiHelper from './utils/reviewApiHelper.js';
2
+ import { fetchApi } from './utils/fetchApi.js';
3
+ import { executeTool } from './tools/executeTool.js';
4
+ import { getConfigValue, setConfigValue } from './utils/config.js';
5
+ import { track } from './utils/analytics.js';
6
+
7
+ const MAX_TURNS = 5;
8
+
9
+ /**
10
+ * Run a single agent turn loop (per-file or reflector).
11
+ */
12
+ async function runTurnLoop(initialPayload, gitRoot, isReflectorLoop) {
13
+ let nextPayload = initialPayload;
14
+ let finalMessage = null;
15
+ let finalOutput = null;
16
+
17
+ for (let turn = 0; turn < MAX_TURNS; turn += 1) {
18
+ const resp = await fetchApi('/extension/pr-review/agent/turn', 'POST', nextPayload);
19
+
20
+ if (!resp || typeof resp !== 'object') {
21
+ throw new Error('Invalid response from agent API');
22
+ }
23
+
24
+ const sessionId = resp.session_id;
25
+ const assistantMsg = resp.response;
26
+ const done = resp.done;
27
+
28
+ if (!sessionId) {
29
+ throw new Error('Missing session_id in agent response');
30
+ }
31
+
32
+ finalMessage = assistantMsg;
33
+ if (resp.output) finalOutput = resp.output;
34
+
35
+ if (done) {
36
+ if (isReflectorLoop && resp.parsing_error) {
37
+ console.error('Warning: parsing error in reflector response');
38
+ }
39
+ break;
40
+ }
41
+
42
+ const toolCalls = assistantMsg?.tool_calls || [];
43
+ if (toolCalls.length === 0) break;
44
+
45
+ const toolResults = await Promise.all(
46
+ toolCalls.map(async (tc) => ({
47
+ tool_call_id: tc.id,
48
+ content: await executeTool(tc, gitRoot),
49
+ }))
50
+ );
51
+
52
+ nextPayload = { session_id: sessionId, tool_results: toolResults };
53
+ }
54
+
55
+ return { finalMessage, finalOutput };
56
+ }
57
+
58
+ /**
59
+ * Headless review runner — no React/Ink, returns plain JSON.
60
+ *
61
+ * @param {Object} options
62
+ * @param {string} options.workspacePath - Absolute path to the repo/workspace
63
+ * @param {string} [options.apiKey] - API key (overrides env/config)
64
+ * @param {string} [options.baseUrl] - API base URL (overrides env/config)
65
+ * @param {string} [options.scanType='all'] - all|committed|uncommitted|staged-only|last-commit|last-n-commits|base-branch|base-commit
66
+ * @param {string[]} [options.include=[]] - Glob patterns to include
67
+ * @param {string[]} [options.exclude=[]] - Glob patterns to exclude
68
+ * @param {number} [options.lastNCommits=1]
69
+ * @param {string} [options.baseBranch]
70
+ * @param {string} [options.baseCommit]
71
+ * @param {function} [options.onProgress] - Optional callback(message) for progress updates
72
+ * @returns {Promise<{issues: Array, meta: Object|null, error: string|null}>}
73
+ */
74
+ export async function runReviewHeadless(options = {}) {
75
+ const {
76
+ workspacePath,
77
+ apiKey,
78
+ baseUrl,
79
+ scanType = 'all',
80
+ include = [],
81
+ exclude = [],
82
+ lastNCommits = 1,
83
+ baseBranch = null,
84
+ baseCommit = null,
85
+ onProgress = () => {},
86
+ onFilesReady = () => {},
87
+ } = options;
88
+
89
+ // If the CLI config doesn't have an apiKey but the extension passed one, persist it
90
+ if (apiKey && !getConfigValue('apiKey')) {
91
+ setConfigValue('apiKey', apiKey);
92
+ }
93
+
94
+ // Temporarily set env vars so fetchApi picks them up
95
+ const prevToken = process.env.CODEANT_API_TOKEN;
96
+ const prevUrl = process.env.CODEANT_API_URL;
97
+
98
+ if (apiKey) process.env.CODEANT_API_TOKEN = apiKey;
99
+ if (baseUrl) process.env.CODEANT_API_URL = baseUrl;
100
+
101
+ const reviewStartTime = Date.now();
102
+ track('review_triggered', { scan_type: scanType, source: 'headless' });
103
+
104
+ try {
105
+ // ── Fetch diff ──────────────────────────────────────────────────────
106
+ onProgress('Fetching diff...');
107
+
108
+ const helper = new ReviewApiHelper(workspacePath);
109
+ await helper.init();
110
+ const gitRoot = helper.getGitRoot() || workspacePath;
111
+ const requestBody = await helper.buildReviewApiRequest(scanType, include, exclude, { lastNCommits, baseBranch, baseCommit });
112
+
113
+ const meta = requestBody?._meta || null;
114
+ delete requestBody._meta;
115
+
116
+ // Notify caller about files being reviewed as soon as we know
117
+ onFilesReady(meta?.reviewed_files || [], meta);
118
+
119
+ if (!requestBody?.diff_content?.length) {
120
+ track('review_completed', { scan_type: scanType, source: 'headless', issue_count: 0, file_count: 0, duration_ms: Date.now() - reviewStartTime, no_files: true });
121
+ return { issues: [], meta, error: null, noFiles: true };
122
+ }
123
+
124
+ // ── Split into per-file requests ────────────────────────────────────
125
+ const perFileRequests = ReviewApiHelper.splitIntoPerFileRequests(requestBody);
126
+
127
+ onProgress(`Analyzing ${perFileRequests.length} file${perFileRequests.length !== 1 ? 's' : ''} in parallel...`);
128
+
129
+ // ── Per-file agent turn loops (parallel) ────────────────────────────
130
+ const perFileResults = await Promise.all(
131
+ perFileRequests.map((fileReq) => {
132
+ const filename = fileReq._filename;
133
+ delete fileReq._filename;
134
+
135
+ if (fileReq.file_contents?.[filename]) {
136
+ fileReq.file_content = fileReq.file_contents[filename];
137
+ fileReq.file_path = filename;
138
+ }
139
+ delete fileReq.file_contents;
140
+
141
+ return runTurnLoop(fileReq, gitRoot, false);
142
+ })
143
+ );
144
+
145
+ // Pair each file's suggestions with its own diff — skip files with no real suggestions
146
+ const perFileWithSuggestions = perFileRequests.map((fileReq, i) => ({
147
+ diff_content: fileReq.diff_content,
148
+ suggestions: perFileResults[i].finalMessage?.content,
149
+ output: perFileResults[i].finalOutput,
150
+ })).filter(r => r.output?.code_suggestions?.length > 0);
151
+
152
+ // ── Per-file reflector loops (parallel) ──────────────────────────────
153
+ onProgress('Running reflector...');
154
+ const reflectorResults = await Promise.all(
155
+ perFileWithSuggestions.map(({ diff_content, suggestions }) =>
156
+ runTurnLoop(
157
+ {
158
+ diff_content,
159
+ prompt_template_name: 'reflector',
160
+ extra_variables: { suggestion_str: suggestions },
161
+ },
162
+ gitRoot,
163
+ true
164
+ )
165
+ )
166
+ );
167
+
168
+ // ── Parse results ───────────────────────────────────────────────────
169
+ const issues = reflectorResults.flatMap(r =>
170
+ (r.finalOutput?.code_suggestions || []).map((issue) => ({
171
+ issue_content: issue.issue_content || '',
172
+ relevant_file: issue.relevant_file || 'Unknown',
173
+ start_line: issue.start_line || 0,
174
+ label: issue.label || 'Code Quality',
175
+ }))
176
+ );
177
+
178
+ const labelCounts = {};
179
+ for (const i of issues) { labelCounts[i.label] = (labelCounts[i.label] || 0) + 1; }
180
+ track('review_completed', {
181
+ scan_type: scanType, source: 'headless', issue_count: issues.length,
182
+ file_count: perFileRequests.length, duration_ms: Date.now() - reviewStartTime,
183
+ label_counts: labelCounts,
184
+ });
185
+
186
+ return { issues, meta, error: null, noFiles: false };
187
+ } catch (err) {
188
+ const errMsg = err instanceof Error ? err.message : String(err);
189
+ track('review_error', { scan_type: scanType, source: 'headless', error: errMsg });
190
+ return { issues: [], meta: null, error: errMsg, noFiles: false };
191
+ } finally {
192
+ // Restore env vars
193
+ if (prevToken !== undefined) process.env.CODEANT_API_TOKEN = prevToken;
194
+ else delete process.env.CODEANT_API_TOKEN;
195
+ if (prevUrl !== undefined) process.env.CODEANT_API_URL = prevUrl;
196
+ else delete process.env.CODEANT_API_URL;
197
+ }
198
+ }