codeant-cli 0.2.1 → 0.2.3

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