scai 0.1.76 → 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.
@@ -152,6 +152,24 @@ function askReviewApproval() {
152
152
  });
153
153
  });
154
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
+ }
155
173
  // Prompt for custom review
156
174
  function promptCustomReview() {
157
175
  return new Promise((resolve) => {
@@ -176,15 +194,63 @@ export async function promptEditReview(suggestedReview) {
176
194
  .trim() || suggestedReview;
177
195
  }
178
196
  // Split diff into file-based chunks
179
- function chunkDiff(diff) {
197
+ function chunkDiff(diff, review_id) {
180
198
  const rawChunks = diff.split(/^diff --git /m).filter(Boolean);
181
199
  return rawChunks.map(chunk => {
182
200
  const fullChunk = 'diff --git ' + chunk;
183
201
  const filePathMatch = fullChunk.match(/^diff --git a\/(.+?) b\//);
184
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
+ }
185
249
  return {
186
250
  filePath,
187
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
188
254
  };
189
255
  });
190
256
  }
@@ -247,10 +313,10 @@ function waitForSpaceOrQ() {
247
313
  export async function promptChunkReviewMenu() {
248
314
  return new Promise((resolve) => {
249
315
  console.log('\nReview options for this chunk:');
250
- console.log(' 1) 💬 Post AI review as comment');
316
+ console.log(' 1) 💬 Approve and post AI review as comment');
251
317
  console.log(' 2) ✍️ Edit the review before posting');
252
318
  console.log(' 3) ⌨️ Write a custom comment');
253
- console.log(' 4) ❌ Mark this chunk as needing changes');
319
+ console.log(' 4) ❌ Mark this chunk as needing changes');
254
320
  console.log(' 5) ⏭️ Skip this chunk without commenting');
255
321
  console.log(chalk.gray(' (Press [space] to skip chunk, [q] to quit review, or any other key to show menu)\n'));
256
322
  // Fallback to menu input if key was not space/q
@@ -310,10 +376,9 @@ export async function reviewPullRequestCmd(branch = 'main', showAll = false) {
310
376
  if (pr.body) {
311
377
  console.log(chalk.magentaBright('\n📝 PR Description:\n') + chalk.gray(pr.body));
312
378
  }
313
- // Skip review method if there's only one chunk
314
- const chunks = chunkDiff(diff);
315
- const reviewMethod = chunks.length > 1 ? await askReviewMethod() : 'chunk'; // Auto-select chunk review if only 1 chunk
316
- let allApproved = true; // Track if everything is approved
379
+ const chunks = chunkDiff(diff, pr.number.toString());
380
+ const reviewMethod = chunks.length > 1 ? await askReviewMethod() : 'chunk';
381
+ let reviewComments = [];
317
382
  if (reviewMethod === 'whole') {
318
383
  const suggestion = await reviewModule.run({ content: diff, filepath: 'Whole PR Diff' });
319
384
  console.log(chalk.yellowBright("Suggestion: ", suggestion));
@@ -321,55 +386,88 @@ export async function reviewPullRequestCmd(branch = 'main', showAll = false) {
321
386
  let reviewText = '';
322
387
  if (finalReviewChoice === 'approve') {
323
388
  reviewText = 'PR approved';
324
- await submitReview(pr.number, suggestion.content, 'APPROVE'); // Final review approval
389
+ await submitReview(pr.number, suggestion.content, 'APPROVE');
325
390
  }
326
391
  else if (finalReviewChoice === 'reject') {
327
392
  reviewText = 'Changes requested';
328
- await submitReview(pr.number, suggestion.content, 'REQUEST_CHANGES'); // Final review rejection
393
+ await submitReview(pr.number, suggestion.content, 'REQUEST_CHANGES');
329
394
  }
330
395
  else if (finalReviewChoice === 'custom') {
331
396
  reviewText = await promptCustomReview();
332
- await submitReview(pr.number, reviewText, 'COMMENT'); // Custom comment
397
+ await submitReview(pr.number, reviewText, 'COMMENT');
333
398
  }
334
399
  else if (finalReviewChoice === 'edit') {
335
400
  reviewText = await promptEditReview(suggestion.content);
336
- await submitReview(pr.number, reviewText, 'COMMENT'); // Edited comment
401
+ await submitReview(pr.number, reviewText, 'COMMENT');
337
402
  }
338
403
  }
339
404
  else {
340
405
  console.log(chalk.cyan(`🔍 Total Chunks: ${chunks.length}`));
406
+ let allApproved = true;
341
407
  for (let i = 0; i < chunks.length; i++) {
342
408
  const chunk = chunks[i];
343
409
  const { choice, summary } = await reviewChunk(chunk, i, chunks.length);
344
410
  if (choice === 'approve') {
345
- console.log(`✔️ Approved chunk ${i + 1}`);
346
- await submitReview(pr.number, summary, 'COMMENT');
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}`);
347
418
  }
348
419
  else if (choice === 'reject') {
349
420
  allApproved = false;
350
- const reviewText = `Changes requested for chunk ${i + 1}: ${summary}`;
351
- await submitReview(pr.number, reviewText, 'REQUEST_CHANGES');
421
+ reviewComments.push({
422
+ path: chunk.filePath,
423
+ body: summary,
424
+ line: chunk.hunks[0]?.newStart || 1,
425
+ side: 'RIGHT',
426
+ });
352
427
  }
353
428
  else if (choice === 'custom') {
354
429
  const customReview = await promptCustomReview();
355
- 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
+ });
356
436
  }
357
437
  else if (choice === 'cancel' || choice === 'skip') {
358
438
  console.log(chalk.gray(`⏭️ Skipped chunk ${i + 1}`));
359
439
  }
360
440
  else if (typeof choice === 'string') {
361
- // e.g., from 'edit' returning custom text
362
- await submitReview(pr.number, choice, 'COMMENT');
441
+ reviewComments.push({
442
+ path: chunk.filePath,
443
+ body: choice,
444
+ line: chunk.hunks[0]?.newStart || 1,
445
+ side: 'RIGHT',
446
+ });
363
447
  }
364
448
  }
365
- // After reviewing all chunks, approve or reject the whole PR based on chunk reviews
366
- if (allApproved) {
367
- console.log(chalk.green('✔️ All chunks approved. Submitting final PR approval.'));
368
- await submitReview(pr.number, 'PR approved after reviewing all chunks', 'APPROVE'); // Final PR approval
369
- }
370
- else {
371
- console.log(chalk.red('❌ Not all chunks were approved. Changes requested.'));
372
- await submitReview(pr.number, 'PR changes requested based on chunk reviews', 'REQUEST_CHANGES'); // Final PR rejection
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');
373
471
  }
374
472
  }
375
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scai",
3
- "version": "0.1.76",
3
+ "version": "0.1.77",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "scai": "./dist/index.js"