scai 0.1.83 → 0.1.84

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.
@@ -2,15 +2,13 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { runModulePipeline } from '../pipeline/runModulePipeline.js';
4
4
  import { addCommentsModule } from '../pipeline/modules/commentModule.js';
5
+ import { normalizePath } from '../utils/normalizePath.js';
5
6
  export async function handleRefactor(filepath, options = {}) {
6
7
  try {
7
- // Normalize path: add ./ prefix if no directory specified
8
- if (!filepath.startsWith('./') && !filepath.startsWith('/') && !filepath.includes('\\')) {
9
- filepath = `./${filepath}`;
10
- }
8
+ // Normalize and resolve filepath (includes expanding ~ and consistent slashes)
9
+ filepath = normalizePath(filepath);
11
10
  const { dir, name, ext } = path.parse(filepath);
12
11
  const refactoredPath = path.join(dir, `${name}.refactored${ext}`);
13
- // --apply flag: use existing refactored file and overwrite original
14
12
  if (options.apply) {
15
13
  try {
16
14
  const refactoredCode = await fs.readFile(refactoredPath, 'utf-8');
@@ -23,13 +21,10 @@ export async function handleRefactor(filepath, options = {}) {
23
21
  }
24
22
  return;
25
23
  }
26
- // Read source code
27
24
  const content = await fs.readFile(filepath, 'utf-8');
28
- // Run through pipeline modules
29
25
  const response = await runModulePipeline([addCommentsModule], { content });
30
26
  if (!response.content.trim())
31
27
  throw new Error('⚠️ Model returned empty result');
32
- // Save refactored output
33
28
  await fs.writeFile(refactoredPath, response.content, 'utf-8');
34
29
  console.log(`✅ Refactored code saved to: ${refactoredPath}`);
35
30
  console.log(`ℹ️ Run again with '--apply' to overwrite the original.`);
@@ -164,24 +164,6 @@ function askReviewApproval() {
164
164
  });
165
165
  });
166
166
  }
167
- function askFinalReviewApproval() {
168
- return new Promise((resolve) => {
169
- console.log('\n---');
170
- console.log('1) ✅ Approve');
171
- console.log('2) ❌ Request Changes');
172
- console.log('3) 🚫 Cancel');
173
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
174
- rl.question(`\n👉 Choose an option [1-3]: `, (answer) => {
175
- rl.close();
176
- if (answer === '1')
177
- resolve('approve');
178
- else if (answer === '2')
179
- resolve('request-changes');
180
- else
181
- resolve('cancel');
182
- });
183
- });
184
- }
185
167
  // Prompt for custom review
186
168
  function promptCustomReview() {
187
169
  return new Promise((resolve) => {
@@ -212,23 +194,20 @@ function chunkDiff(diff, review_id) {
212
194
  const fullChunk = 'diff --git ' + chunk;
213
195
  const filePathMatch = fullChunk.match(/^diff --git a\/(.+?) b\//);
214
196
  const filePath = filePathMatch ? filePathMatch[1] : 'unknown';
215
- // Now we extract hunks and lines as per the DiffHunk type
216
197
  const hunks = [];
217
198
  let currentHunk = null;
218
- // Split chunk into lines
199
+ // This counts diff lines for *this file only* (context/+/- lines after first @@)
200
+ let positionCounter = 0;
219
201
  const lines = fullChunk.split('\n');
220
202
  lines.forEach(line => {
221
- const hunkHeaderMatch = line.match(/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@/);
203
+ const hunkHeaderMatch = line.match(/^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
222
204
  if (hunkHeaderMatch) {
223
- // When we encounter a new hunk, process the previous one (if it exists) and start a new one
224
- if (currentHunk) {
205
+ if (currentHunk)
225
206
  hunks.push(currentHunk);
226
- }
227
- // Parse the hunk header
228
207
  const oldStart = parseInt(hunkHeaderMatch[1], 10);
229
208
  const newStart = parseInt(hunkHeaderMatch[3], 10);
230
- const oldLines = parseInt(hunkHeaderMatch[2], 10);
231
- const newLines = parseInt(hunkHeaderMatch[4], 10);
209
+ const oldLines = parseInt(hunkHeaderMatch[2] || '0', 10);
210
+ const newLines = parseInt(hunkHeaderMatch[4] || '0', 10);
232
211
  currentHunk = {
233
212
  oldStart,
234
213
  newStart,
@@ -236,33 +215,33 @@ function chunkDiff(diff, review_id) {
236
215
  newLines,
237
216
  lines: [],
238
217
  };
218
+ return; // don’t count @@ header line in positionCounter
239
219
  }
240
- else if (currentHunk) {
241
- // Process the lines inside the hunk
220
+ if (currentHunk) {
221
+ // Each line after @@ counts towards the diff position
222
+ positionCounter++;
242
223
  let lineType = 'context';
243
224
  if (line.startsWith('+'))
244
225
  lineType = 'add';
245
226
  if (line.startsWith('-'))
246
227
  lineType = 'del';
247
- // Create the DiffLine object
248
228
  currentHunk.lines.push({
249
229
  line,
250
230
  type: lineType,
251
231
  lineNumberOld: lineType === 'del' ? currentHunk.oldStart++ : undefined,
252
232
  lineNumberNew: lineType === 'add' ? currentHunk.newStart++ : undefined,
253
- review_id, // Assign the review_id here
233
+ position: positionCounter, // <-- key for GitHub inline API
234
+ review_id,
254
235
  });
255
236
  }
256
237
  });
257
- // Push the last hunk (if any)
258
- if (currentHunk) {
238
+ if (currentHunk)
259
239
  hunks.push(currentHunk);
260
- }
261
240
  return {
262
241
  filePath,
263
242
  content: fullChunk,
264
- hunks, // Return hunks, which now contain DiffLine objects with line numbers and review_id
265
- review_id, // Assign the review_id here for each chunk
243
+ hunks,
244
+ review_id,
266
245
  };
267
246
  });
268
247
  }
@@ -419,48 +398,36 @@ export async function reviewPullRequestCmd(branch = 'main', showAll = false) {
419
398
  for (let i = 0; i < chunks.length; i++) {
420
399
  const chunk = chunks[i];
421
400
  const { choice, summary } = await reviewChunk(chunk, i, chunks.length);
422
- if (choice === 'approve') {
423
- reviewComments.push({
424
- path: chunk.filePath,
425
- body: summary,
426
- line: chunk.hunks[0]?.newStart || 1,
427
- side: 'RIGHT',
428
- });
429
- console.log(`💬 Posted AI review for chunk ${i + 1}`);
430
- }
431
- else if (choice === 'reject') {
432
- allApproved = false;
433
- reviewComments.push({
434
- path: chunk.filePath,
435
- body: summary,
436
- line: chunk.hunks[0]?.newStart || 1,
437
- side: 'RIGHT',
438
- });
401
+ if (choice === 'cancel' || choice === 'skip') {
402
+ console.log(chalk.gray(`⏭️ Skipped chunk ${i + 1}`));
403
+ continue;
439
404
  }
440
- else if (choice === 'custom') {
441
- const customReview = await promptCustomReview();
442
- reviewComments.push({
443
- path: chunk.filePath,
444
- body: customReview,
445
- line: chunk.hunks[0]?.newStart || 1,
446
- side: 'RIGHT',
447
- });
405
+ // Find the first line in the chunk with a valid position (usually the first 'add' or 'context' line)
406
+ const firstLineWithPosition = chunk.hunks
407
+ .flatMap(hunk => hunk.lines)
408
+ .find(line => line.position !== undefined);
409
+ if (!firstLineWithPosition) {
410
+ console.warn(`⚠️ Could not find valid position for inline comment in chunk ${i + 1}. Skipping comment.`);
411
+ continue;
448
412
  }
449
- else if (choice === 'cancel' || choice === 'skip') {
450
- console.log(chalk.gray(`⏭️ Skipped chunk ${i + 1}`));
413
+ let commentBody = summary;
414
+ if (choice === 'custom') {
415
+ commentBody = await promptCustomReview();
451
416
  }
452
- else if (typeof choice === 'string') {
453
- reviewComments.push({
454
- path: chunk.filePath,
455
- body: choice,
456
- line: chunk.hunks[0]?.newStart || 1,
457
- side: 'RIGHT',
458
- });
417
+ else if (typeof choice === 'string' && !['approve', 'reject', 'custom'].includes(choice)) {
418
+ commentBody = choice;
459
419
  }
420
+ reviewComments.push({
421
+ path: chunk.filePath,
422
+ body: commentBody,
423
+ position: firstLineWithPosition.position,
424
+ });
425
+ if (choice === 'reject')
426
+ allApproved = false;
460
427
  }
461
428
  console.log(chalk.blueBright('\n📝 Review Comments Preview:'));
462
429
  reviewComments.forEach((comment, idx) => {
463
- console.log(`${idx + 1}. ${comment.path}:${comment.line} [${comment.side}] — ${comment.body}`);
430
+ console.log(`${idx + 1}. ${comment.path}:${comment.position} — ${comment.body}`);
464
431
  });
465
432
  const shouldApprove = allApproved;
466
433
  const hasInlineComments = reviewComments.length > 0;
@@ -81,30 +81,29 @@ export async function submitReview(prNumber, body, event, comments) {
81
81
  const token = await ensureGitHubAuth();
82
82
  const { owner, repo } = await getRepoDetails();
83
83
  const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`;
84
+ // Prepare payload
85
+ const payload = { body, event };
86
+ if (comments && comments.length > 0) {
87
+ payload.comments = comments;
88
+ }
84
89
  const res = await fetch(url, {
85
90
  method: 'POST',
86
91
  headers: {
87
92
  Authorization: `token ${token}`,
88
93
  Accept: 'application/vnd.github.v3+json',
89
94
  },
90
- body: JSON.stringify({
91
- body,
92
- event,
93
- comments,
94
- }),
95
+ body: JSON.stringify(payload),
95
96
  });
96
97
  if (!res.ok) {
97
98
  const errorText = await res.text();
98
- // Attempt to parse error body
99
99
  let parsed = {};
100
100
  try {
101
101
  parsed = JSON.parse(errorText);
102
102
  }
103
103
  catch (_) {
104
- // leave as raw text if parsing fails
104
+ // fallback to raw text
105
105
  }
106
- const knownErrors = Array.isArray(parsed.errors) ? parsed.errors.join('; ') : '';
107
- // Handle known error cases
106
+ const knownErrors = Array.isArray(parsed.errors) ? parsed.errors.map((e) => e.message || e).join('; ') : '';
108
107
  if (res.status === 422) {
109
108
  if (knownErrors.includes('Can not approve your own pull request')) {
110
109
  console.warn(`⚠️ Skipping approval: You cannot approve your own pull request.`);
@@ -114,8 +113,8 @@ export async function submitReview(prNumber, body, event, comments) {
114
113
  console.warn(`⚠️ Cannot post comments: PR has no diff.`);
115
114
  return;
116
115
  }
117
- if (knownErrors.includes('path is missing') || knownErrors.includes('line is missing')) {
118
- console.warn(`⚠️ Some inline comments are missing a path or line number. Skipping review.`);
116
+ if (knownErrors.includes('path is missing') || knownErrors.includes('line is missing') || knownErrors.includes('position is missing')) {
117
+ console.warn(`⚠️ Some inline comments are missing required fields. Skipping review.`);
119
118
  return;
120
119
  }
121
120
  if (knownErrors.includes('Position is invalid') || knownErrors.includes('line must be part of the diff')) {
@@ -123,7 +122,6 @@ export async function submitReview(prNumber, body, event, comments) {
123
122
  return;
124
123
  }
125
124
  }
126
- // Unknown error
127
125
  throw new Error(`Failed to submit review: ${res.status} ${res.statusText} - ${errorText}`);
128
126
  }
129
127
  console.log(`✅ Submitted ${event} review for PR #${prNumber}`);
@@ -1,4 +1,5 @@
1
1
  // src/utils/normalizePath.ts
2
+ import os from 'os';
2
3
  import path from "path";
3
4
  /**
4
5
  * Normalizes a path string for loose, fuzzy matching:
@@ -11,6 +12,9 @@ export function normalizePathForLooseMatch(p) {
11
12
  }
12
13
  // Helper to normalize and resolve paths to a consistent format (forward slashes)
13
14
  export function normalizePath(p) {
15
+ if (p.startsWith('~')) {
16
+ p = path.join(os.homedir(), p.slice(1));
17
+ }
14
18
  return path.resolve(p).replace(/\\/g, '/');
15
19
  }
16
20
  export function getRepoKeyForPath(pathToMatch, config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scai",
3
- "version": "0.1.83",
3
+ "version": "0.1.84",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "scai": "./dist/index.js"