scai 0.1.75 → 0.1.77

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.
@@ -13,15 +13,20 @@ function truncate(str, length) {
13
13
  return str.length > length ? str.slice(0, length - 3) + '...' : str;
14
14
  }
15
15
  // Fetch open PRs with review requested
16
+ import { Spinner } from '../lib/spinner.js'; // adjust path as needed
16
17
  export async function getPullRequestsForReview(token, owner, repo, username, branch = 'main', filterForUser = true) {
17
- const prs = await fetchOpenPullRequests(token, owner, repo);
18
+ const spinner = new Spinner('Fetching pull requests and diffs...');
19
+ spinner.start();
18
20
  const filtered = [];
19
21
  const failedPRs = [];
20
- for (const pr of prs) {
21
- const shouldInclude = !pr.draft &&
22
- !pr.merged_at &&
23
- (!filterForUser || pr.requested_reviewers?.some(r => r.login === username));
24
- if (shouldInclude) {
22
+ try {
23
+ const prs = await fetchOpenPullRequests(token, owner, repo);
24
+ for (const pr of prs) {
25
+ const shouldInclude = !pr.draft &&
26
+ !pr.merged_at &&
27
+ (!filterForUser || pr.requested_reviewers?.some(r => r.login === username));
28
+ if (!shouldInclude)
29
+ continue;
25
30
  try {
26
31
  const prNumber = pr.number;
27
32
  const prRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, {
@@ -50,21 +55,25 @@ export async function getPullRequestsForReview(token, owner, repo, username, bra
50
55
  failedPRs.push(pr);
51
56
  }
52
57
  }
53
- }
54
- // After collecting filtered PRs
55
- if (filtered.length === 0) {
56
- if (filterForUser) {
57
- console.log(`ℹ️ No open pull requests found for review by '${username}'.`);
58
+ if (filtered.length === 0) {
59
+ const msg = filterForUser
60
+ ? `No open pull requests found for review by '${username}'.`
61
+ : `No open pull requests found.`;
62
+ spinner.succeed(msg);
58
63
  }
59
64
  else {
60
- console.log(`ℹ️ No open pull requests found.`);
65
+ spinner.succeed(`Fetched ${filtered.length} PR(s) with diffs.`);
66
+ }
67
+ if (failedPRs.length > 0) {
68
+ const failedList = failedPRs.map(pr => `#${pr.number}`).join(', ');
69
+ console.warn(`⚠️ Skipped ${failedPRs.length} PR(s): ${failedList}`);
61
70
  }
71
+ return filtered;
62
72
  }
63
- if (failedPRs.length > 0) {
64
- const failedList = failedPRs.map(pr => `#${pr.number}`).join(', ');
65
- console.warn(`⚠️ Skipped ${failedPRs.length} PR(s): ${failedList}`);
73
+ catch (err) {
74
+ spinner.fail(`Error fetching pull requests: ${err.message}`);
75
+ return [];
66
76
  }
67
- return filtered;
68
77
  }
69
78
  // Prompt user to select PR
70
79
  function askUserToPickPR(prs) {
@@ -119,7 +128,7 @@ function askReviewMethod() {
119
128
  });
120
129
  }
121
130
  // Prompt for review approval
122
- function askReviewApproval(suggestion) {
131
+ function askReviewApproval() {
123
132
  return new Promise((resolve) => {
124
133
  console.log('\n---');
125
134
  console.log('1) ✅ Approve');
@@ -143,6 +152,24 @@ function askReviewApproval(suggestion) {
143
152
  });
144
153
  });
145
154
  }
155
+ function askFinalReviewApproval() {
156
+ return new Promise((resolve) => {
157
+ console.log('\n---');
158
+ console.log('1) ✅ Approve');
159
+ console.log('2) ❌ Request Changes');
160
+ console.log('3) 🚫 Cancel');
161
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
162
+ rl.question(`\n👉 Choose an option [1-3]: `, (answer) => {
163
+ rl.close();
164
+ if (answer === '1')
165
+ resolve('approve');
166
+ else if (answer === '2')
167
+ resolve('request-changes');
168
+ else
169
+ resolve('cancel');
170
+ });
171
+ });
172
+ }
146
173
  // Prompt for custom review
147
174
  function promptCustomReview() {
148
175
  return new Promise((resolve) => {
@@ -167,15 +194,63 @@ export async function promptEditReview(suggestedReview) {
167
194
  .trim() || suggestedReview;
168
195
  }
169
196
  // Split diff into file-based chunks
170
- function chunkDiff(diff) {
197
+ function chunkDiff(diff, review_id) {
171
198
  const rawChunks = diff.split(/^diff --git /m).filter(Boolean);
172
199
  return rawChunks.map(chunk => {
173
200
  const fullChunk = 'diff --git ' + chunk;
174
201
  const filePathMatch = fullChunk.match(/^diff --git a\/(.+?) b\//);
175
202
  const filePath = filePathMatch ? filePathMatch[1] : 'unknown';
203
+ // Now we extract hunks and lines as per the DiffHunk type
204
+ const hunks = [];
205
+ let currentHunk = null;
206
+ // Split chunk into lines
207
+ const lines = fullChunk.split('\n');
208
+ lines.forEach(line => {
209
+ const hunkHeaderMatch = line.match(/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@/);
210
+ if (hunkHeaderMatch) {
211
+ // When we encounter a new hunk, process the previous one (if it exists) and start a new one
212
+ if (currentHunk) {
213
+ hunks.push(currentHunk);
214
+ }
215
+ // Parse the hunk header
216
+ const oldStart = parseInt(hunkHeaderMatch[1], 10);
217
+ const newStart = parseInt(hunkHeaderMatch[3], 10);
218
+ const oldLines = parseInt(hunkHeaderMatch[2], 10);
219
+ const newLines = parseInt(hunkHeaderMatch[4], 10);
220
+ currentHunk = {
221
+ oldStart,
222
+ newStart,
223
+ oldLines,
224
+ newLines,
225
+ lines: [],
226
+ };
227
+ }
228
+ else if (currentHunk) {
229
+ // Process the lines inside the hunk
230
+ let lineType = 'context';
231
+ if (line.startsWith('+'))
232
+ lineType = 'add';
233
+ if (line.startsWith('-'))
234
+ lineType = 'del';
235
+ // Create the DiffLine object
236
+ currentHunk.lines.push({
237
+ line,
238
+ type: lineType,
239
+ lineNumberOld: lineType === 'del' ? currentHunk.oldStart++ : undefined,
240
+ lineNumberNew: lineType === 'add' ? currentHunk.newStart++ : undefined,
241
+ review_id, // Assign the review_id here
242
+ });
243
+ }
244
+ });
245
+ // Push the last hunk (if any)
246
+ if (currentHunk) {
247
+ hunks.push(currentHunk);
248
+ }
176
249
  return {
177
250
  filePath,
178
251
  content: fullChunk,
252
+ hunks, // Return hunks, which now contain DiffLine objects with line numbers and review_id
253
+ review_id, // Assign the review_id here for each chunk
179
254
  };
180
255
  });
181
256
  }
@@ -191,18 +266,99 @@ function colorDiffLine(line) {
191
266
  }
192
267
  // Review a single chunk
193
268
  export async function reviewChunk(chunk, chunkIndex, totalChunks) {
194
- console.log(chalk.yellow(`\n🔍 Reviewing chunk ${chunkIndex + 1} of ${totalChunks}:`));
195
- console.log(`File: ${chunk.filePath}`);
196
- const coloredDiff = chunk.content.split('\n').map(colorDiffLine).join('\n');
269
+ const lines = chunk.content.split('\n');
270
+ const coloredDiff = lines.map(colorDiffLine).join('\n');
271
+ console.log(chalk.gray('\n' + '━'.repeat(60)));
272
+ console.log(`📄 ${chalk.bold('File')}: ${chalk.cyan(chunk.filePath)}`);
273
+ console.log(`🔢 ${chalk.bold('Chunk')}: ${chunkIndex + 1} of ${totalChunks}`);
274
+ const suggestion = await reviewModule.run({
275
+ content: chunk.content,
276
+ filepath: chunk.filePath
277
+ });
278
+ const summary = suggestion.content?.trim() || 'AI review summary not available.';
279
+ console.log(`🔍 ${chalk.bold('Summary')}: ${summary}`);
280
+ console.log(`\n${chalk.bold('--- Diff ---')}\n`);
197
281
  console.log(coloredDiff);
198
- const suggestion = await reviewModule.run({ content: chunk.content, filepath: chunk.filePath });
199
- console.log("\n💡 AI-suggested review:\n");
200
- console.log(suggestion.content);
201
- const reviewChoice = await askReviewApproval(suggestion.content);
202
- if (reviewChoice === 'edit') {
203
- return await promptEditReview(suggestion.content);
282
+ console.log(`\n${chalk.bold('--- AI Review ---')}\n`);
283
+ console.log(chalk.blue(`💬 ${summary}`));
284
+ console.log(chalk.gray('━'.repeat(60)));
285
+ const choice = await promptChunkReviewMenu();
286
+ if (choice === 'edit') {
287
+ const edited = await promptEditReview(summary); // edit based on the suggestion
288
+ return { choice: edited, summary: edited };
204
289
  }
205
- return reviewChoice;
290
+ else if (choice === 'skip') {
291
+ await waitForSpaceOrQ(); // pause between chunks
292
+ return { choice: 'cancel', summary }; // skip this one
293
+ }
294
+ return { choice, summary };
295
+ }
296
+ function waitForSpaceOrQ() {
297
+ return new Promise(resolve => {
298
+ process.stdin.setRawMode(true);
299
+ process.stdin.resume();
300
+ process.stdout.write('\n⏭️ (Press [space] to skip, [q] to quit, or any other key to show menu)\n');
301
+ function onKeyPress(chunk) {
302
+ const key = chunk.toString();
303
+ if (key === ' ' || key === 'q' || key === 'Q') {
304
+ process.stdin.setRawMode(false);
305
+ process.stdin.pause();
306
+ process.stdin.removeListener('data', onKeyPress);
307
+ resolve();
308
+ }
309
+ }
310
+ process.stdin.on('data', onKeyPress);
311
+ });
312
+ }
313
+ export async function promptChunkReviewMenu() {
314
+ return new Promise((resolve) => {
315
+ console.log('\nReview options for this chunk:');
316
+ console.log(' 1) 💬 Approve and post AI review as comment');
317
+ console.log(' 2) ✍️ Edit the review before posting');
318
+ console.log(' 3) ⌨️ Write a custom comment');
319
+ console.log(' 4) ❌ Mark this chunk as needing changes');
320
+ console.log(' 5) ⏭️ Skip this chunk without commenting');
321
+ console.log(chalk.gray(' (Press [space] to skip chunk, [q] to quit review, or any other key to show menu)\n'));
322
+ // Fallback to menu input if key was not space/q
323
+ function askWithReadline() {
324
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
325
+ rl.question('👉 Choose [1–5]: ', (answer) => {
326
+ rl.close();
327
+ switch (answer.trim()) {
328
+ case '1': return resolve('approve');
329
+ case '2': return resolve('edit');
330
+ case '3': return resolve('custom');
331
+ case '4': return resolve('reject');
332
+ case '5': return resolve('skip');
333
+ default:
334
+ console.log('⚠️ Invalid option. Skipping chunk.');
335
+ return resolve('skip');
336
+ }
337
+ });
338
+ }
339
+ // Raw key listener for quick actions
340
+ function onKeyPress(key) {
341
+ const keyStr = key.toString().toLowerCase();
342
+ process.stdin.setRawMode(false);
343
+ process.stdin.pause();
344
+ if (keyStr === ' ') {
345
+ return resolve('skip');
346
+ }
347
+ else if (keyStr === 'q') {
348
+ console.log('\n👋 Exiting review.');
349
+ process.exit(0);
350
+ }
351
+ else {
352
+ // flush any remaining input
353
+ process.stdin.removeAllListeners('data');
354
+ askWithReadline();
355
+ }
356
+ }
357
+ // Prepare for keypress
358
+ process.stdin.setRawMode(true);
359
+ process.stdin.resume();
360
+ process.stdin.once('data', onKeyPress);
361
+ });
206
362
  }
207
363
  // Main command to review PR
208
364
  export async function reviewPullRequestCmd(branch = 'main', showAll = false) {
@@ -220,19 +376,21 @@ export async function reviewPullRequestCmd(branch = 'main', showAll = false) {
220
376
  if (pr.body) {
221
377
  console.log(chalk.magentaBright('\n📝 PR Description:\n') + chalk.gray(pr.body));
222
378
  }
223
- const reviewMethod = await askReviewMethod();
379
+ const chunks = chunkDiff(diff, pr.number.toString());
380
+ const reviewMethod = chunks.length > 1 ? await askReviewMethod() : 'chunk';
381
+ let reviewComments = [];
224
382
  if (reviewMethod === 'whole') {
225
383
  const suggestion = await reviewModule.run({ content: diff, filepath: 'Whole PR Diff' });
226
- console.log(suggestion.content);
227
- const finalReviewChoice = await askReviewApproval(suggestion.content);
384
+ console.log(chalk.yellowBright("Suggestion: ", suggestion));
385
+ const finalReviewChoice = await askReviewApproval();
228
386
  let reviewText = '';
229
387
  if (finalReviewChoice === 'approve') {
230
388
  reviewText = 'PR approved';
231
- await submitReview(pr.number, reviewText, 'APPROVE');
389
+ await submitReview(pr.number, suggestion.content, 'APPROVE');
232
390
  }
233
391
  else if (finalReviewChoice === 'reject') {
234
392
  reviewText = 'Changes requested';
235
- await submitReview(pr.number, reviewText, 'REQUEST_CHANGES');
393
+ await submitReview(pr.number, suggestion.content, 'REQUEST_CHANGES');
236
394
  }
237
395
  else if (finalReviewChoice === 'custom') {
238
396
  reviewText = await promptCustomReview();
@@ -244,24 +402,72 @@ export async function reviewPullRequestCmd(branch = 'main', showAll = false) {
244
402
  }
245
403
  }
246
404
  else {
247
- const chunks = chunkDiff(diff);
248
405
  console.log(chalk.cyan(`🔍 Total Chunks: ${chunks.length}`));
406
+ let allApproved = true;
249
407
  for (let i = 0; i < chunks.length; i++) {
250
408
  const chunk = chunks[i];
251
- const reviewResult = await reviewChunk(chunk, i, chunks.length);
252
- if (reviewResult === 'approve') {
253
- await submitReview(pr.number, 'Approved chunk', 'APPROVE');
409
+ const { choice, summary } = await reviewChunk(chunk, i, chunks.length);
410
+ if (choice === 'approve') {
411
+ reviewComments.push({
412
+ path: chunk.filePath,
413
+ body: summary,
414
+ line: chunk.hunks[0]?.newStart || 1,
415
+ side: 'RIGHT',
416
+ });
417
+ console.log(`💬 Posted AI review for chunk ${i + 1}`);
254
418
  }
255
- else if (reviewResult === 'reject') {
256
- await submitReview(pr.number, 'Changes requested for chunk', 'REQUEST_CHANGES');
419
+ else if (choice === 'reject') {
420
+ allApproved = false;
421
+ reviewComments.push({
422
+ path: chunk.filePath,
423
+ body: summary,
424
+ line: chunk.hunks[0]?.newStart || 1,
425
+ side: 'RIGHT',
426
+ });
257
427
  }
258
- else if (reviewResult === 'custom') {
428
+ else if (choice === 'custom') {
259
429
  const customReview = await promptCustomReview();
260
- await submitReview(pr.number, customReview, 'COMMENT');
430
+ reviewComments.push({
431
+ path: chunk.filePath,
432
+ body: customReview,
433
+ line: chunk.hunks[0]?.newStart || 1,
434
+ side: 'RIGHT',
435
+ });
261
436
  }
262
- else {
263
- await submitReview(pr.number, reviewResult, 'COMMENT');
437
+ else if (choice === 'cancel' || choice === 'skip') {
438
+ console.log(chalk.gray(`⏭️ Skipped chunk ${i + 1}`));
264
439
  }
440
+ else if (typeof choice === 'string') {
441
+ reviewComments.push({
442
+ path: chunk.filePath,
443
+ body: choice,
444
+ line: chunk.hunks[0]?.newStart || 1,
445
+ side: 'RIGHT',
446
+ });
447
+ }
448
+ }
449
+ console.log(chalk.blueBright('\n📝 Review Comments Preview:'));
450
+ reviewComments.forEach((comment, idx) => {
451
+ console.log(`${idx + 1}. ${comment.path}:${comment.line} [${comment.side}] — ${comment.body}`);
452
+ });
453
+ const shouldApprove = allApproved;
454
+ const hasInlineComments = reviewComments.length > 0;
455
+ // We always submit comments first if any
456
+ const initialReviewState = shouldApprove ? 'APPROVE' : 'REQUEST_CHANGES';
457
+ const initialReviewBody = shouldApprove
458
+ ? 'Reviewed all chunks.'
459
+ : 'Requested changes based on chunk reviews.';
460
+ console.log(shouldApprove && !hasInlineComments
461
+ ? chalk.green('✔️ All chunks approved. Submitting final PR approval.')
462
+ : !shouldApprove
463
+ ? chalk.red('❌ Not all chunks were approved. Changes requested.')
464
+ : chalk.green('📝 Submitting inline comments before approval.') // ✅ NEW
465
+ );
466
+ // ✅ Submit review with inline comments or direct approval/request
467
+ await submitReview(pr.number, initialReviewBody, initialReviewState, reviewComments);
468
+ // ✅ Then submit separate approval if needed
469
+ if (shouldApprove && hasInlineComments) {
470
+ await submitReview(pr.number, 'PR approved after inline comments.', 'APPROVE');
265
471
  }
266
472
  }
267
473
  }
@@ -48,7 +48,7 @@ export async function fetchPullRequestDiff(pr, token) {
48
48
  }
49
49
  return await res.text();
50
50
  }
51
- export async function submitReview(prNumber, body, event = 'COMMENT') {
51
+ export async function submitReview(prNumber, body, event, comments) {
52
52
  const token = await ensureGitHubAuth();
53
53
  const { owner, repo } = getRepoDetails();
54
54
  const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`;
@@ -61,6 +61,7 @@ export async function submitReview(prNumber, body, event = 'COMMENT') {
61
61
  body: JSON.stringify({
62
62
  body,
63
63
  event,
64
+ comments
64
65
  }),
65
66
  });
66
67
  if (!res.ok) {
@@ -69,3 +70,52 @@ export async function submitReview(prNumber, body, event = 'COMMENT') {
69
70
  }
70
71
  console.log(`✅ Submitted ${event} review for PR #${prNumber}`);
71
72
  }
73
+ export async function postInlineComment(prNumber, commitId, path, body, line, side = 'RIGHT', reviewId = null // Associate with a review if available
74
+ ) {
75
+ const token = await ensureGitHubAuth();
76
+ const { owner, repo } = getRepoDetails();
77
+ const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/comments`;
78
+ const res = await fetch(url, {
79
+ method: 'POST',
80
+ headers: {
81
+ Authorization: `token ${token}`,
82
+ Accept: 'application/vnd.github.v3+json',
83
+ },
84
+ body: JSON.stringify({
85
+ body,
86
+ commit_id: commitId,
87
+ path,
88
+ line,
89
+ side,
90
+ review_id: reviewId, // Include review_id if available
91
+ }),
92
+ });
93
+ if (!res.ok) {
94
+ const errorText = await res.text();
95
+ throw new Error(`Failed to post inline comment: ${res.status} ${res.statusText} - ${errorText}`);
96
+ }
97
+ console.log(`💬 Posted inline comment on ${path}:${line}`);
98
+ }
99
+ export async function createReviewForPR(prNumber, body, event = 'COMMENT') {
100
+ const token = await ensureGitHubAuth();
101
+ const { owner, repo } = getRepoDetails();
102
+ const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`;
103
+ const res = await fetch(url, {
104
+ method: 'POST',
105
+ headers: {
106
+ Authorization: `token ${token}`,
107
+ Accept: 'application/vnd.github.v3+json',
108
+ },
109
+ body: JSON.stringify({
110
+ body,
111
+ event,
112
+ }),
113
+ });
114
+ if (!res.ok) {
115
+ const errorText = await res.text();
116
+ throw new Error(`Failed to create review: ${res.status} ${res.statusText} - ${errorText}`);
117
+ }
118
+ const review = await res.json();
119
+ console.log(`✅ Created review for PR #${prNumber}`);
120
+ return review.id; // Return the review ID to be used for inline comments
121
+ }
@@ -7,8 +7,12 @@ export const reviewModule = {
7
7
  const model = Config.getModel();
8
8
  const prompt = `
9
9
  You are a senior software engineer reviewing a pull request.
10
- Give clear and very short constructive feedback based on the code changes below.
11
- Only mention issues of greater concern. Less is more.
10
+ ALWAYS make 3 concise suggestions for improvements based on the input code diff.
11
+ Use this format ONLY and output ONLY those suggestions:
12
+
13
+ 1. ...
14
+ 2. ...
15
+ 3. ...
12
16
 
13
17
  Changes:
14
18
  ${content}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scai",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "scai": "./dist/index.js"