scai 0.1.74 → 0.1.75

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.
@@ -1,334 +1,271 @@
1
1
  import readline from 'readline';
2
- import { fetchOpenPullRequests, fetchPullRequestDiff, getGitHubUsername, submitReview } from '../github/github.js';
2
+ import { fetchOpenPullRequests, getGitHubUsername, submitReview } from '../github/github.js';
3
3
  import { getRepoDetails } from '../github/repo.js';
4
4
  import { ensureGitHubAuth } from '../github/auth.js';
5
- import { postReviewComment } from '../github/postComments.js';
6
5
  import { reviewModule } from '../pipeline/modules/reviewModule.js';
7
6
  import chalk from 'chalk';
8
- // Function to fetch the PRs with requested reviews for a specific branch (default to 'main')
7
+ import fs from 'fs';
8
+ import os from 'os';
9
+ import path from 'path';
10
+ import { spawnSync } from 'child_process';
11
+ import columnify from 'columnify';
12
+ function truncate(str, length) {
13
+ return str.length > length ? str.slice(0, length - 3) + '...' : str;
14
+ }
15
+ // Fetch open PRs with review requested
9
16
  export async function getPullRequestsForReview(token, owner, repo, username, branch = 'main', filterForUser = true) {
10
17
  const prs = await fetchOpenPullRequests(token, owner, repo);
11
18
  const filtered = [];
12
19
  const failedPRs = [];
13
20
  for (const pr of prs) {
14
- const isDraft = pr.draft;
15
- const isMerged = pr.merged_at != null;
16
- const shouldInclude = !isDraft &&
17
- !isMerged &&
21
+ const shouldInclude = !pr.draft &&
22
+ !pr.merged_at &&
18
23
  (!filterForUser || pr.requested_reviewers?.some(r => r.login === username));
19
24
  if (shouldInclude) {
20
- const prNumber = pr.number;
21
25
  try {
22
- // Fetch full PR to get the body
26
+ const prNumber = pr.number;
23
27
  const prRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, {
24
28
  headers: {
25
29
  Authorization: `token ${token}`,
26
30
  Accept: 'application/vnd.github.v3+json',
27
31
  },
28
32
  });
29
- if (!prRes.ok) {
30
- throw new Error(`Failed to fetch full PR details for #${prNumber}: ${prRes.statusText}`);
31
- }
33
+ if (!prRes.ok)
34
+ throw new Error(`Failed to fetch full PR #${prNumber}`);
32
35
  const fullPR = await prRes.json();
33
- pr.body = fullPR.body ?? ''; // Assign the body if available
34
- // Fetch diff
35
- const diffUrl = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}.diff`;
36
- const diffRes = await fetch(diffUrl, {
36
+ pr.body = fullPR.body ?? '';
37
+ const diffRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}.diff`, {
37
38
  headers: {
38
39
  Authorization: `token ${token}`,
39
40
  Accept: 'application/vnd.github.v3.diff',
40
41
  },
41
42
  });
42
- if (!diffRes.ok) {
43
- throw new Error(`${diffRes.status} ${diffRes.statusText}`);
44
- }
43
+ if (!diffRes.ok)
44
+ throw new Error(`Failed to fetch diff for PR #${prNumber}`);
45
45
  const diff = await diffRes.text();
46
46
  filtered.push({ pr, diff });
47
47
  }
48
48
  catch (err) {
49
- console.warn(`⚠️ Skipping PR #${pr.number} due to error: ${err.message}`);
49
+ console.warn(`⚠️ Skipping PR #${pr.number}: ${err.message}`);
50
50
  failedPRs.push(pr);
51
51
  }
52
52
  }
53
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
+ }
59
+ else {
60
+ console.log(`ℹ️ No open pull requests found.`);
61
+ }
62
+ }
54
63
  if (failedPRs.length > 0) {
55
64
  const failedList = failedPRs.map(pr => `#${pr.number}`).join(', ');
56
- console.warn(`⚠️ Skipped ${failedPRs.length} PR(s) (${failedList}) due to errors.`);
65
+ console.warn(`⚠️ Skipped ${failedPRs.length} PR(s): ${failedList}`);
57
66
  }
58
67
  return filtered;
59
68
  }
60
- // Ask user to pick a PR to review
69
+ // Prompt user to select PR
61
70
  function askUserToPickPR(prs) {
62
71
  return new Promise((resolve) => {
63
72
  if (prs.length === 0) {
64
73
  console.log("⚠️ No pull requests with review requested.");
65
74
  return resolve(null);
66
75
  }
67
- console.log(chalk.blue("\n📦 Open Pull Requests with review requested:"));
68
- prs.forEach((pr, i) => {
69
- console.log(`${i + 1}) #${pr.number} - ${pr.title}`);
70
- });
71
- const rl = readline.createInterface({
72
- input: process.stdin,
73
- output: process.stdout,
76
+ const rows = prs.map((pr, i) => ({
77
+ '#': i + 1,
78
+ ID: `#${pr.number}`,
79
+ Title: truncate(pr.title, 50),
80
+ Author: pr.user || '—',
81
+ Status: pr.draft ? 'Draft' : 'Open',
82
+ Created: pr.created_at?.split('T')[0] || '',
83
+ Reviewers: pr.requested_reviewers?.map(r => r.login).join(', ') || '—',
84
+ }));
85
+ console.log(chalk.blue("\n📦 Open Pull Requests:"));
86
+ console.log(columnify(rows, {
87
+ columnSplitter: ' ',
88
+ headingTransform: (h) => chalk.cyan(h.toUpperCase()),
89
+ config: {
90
+ Title: { maxWidth: 50 },
91
+ Reviewers: { maxWidth: 30 }
92
+ }
93
+ }));
94
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
95
+ rl.question(`\n👉 Choose a PR to review [1-${prs.length}]: `, (answer) => {
96
+ rl.close();
97
+ const index = parseInt(answer, 10);
98
+ if (!isNaN(index) && index >= 1 && index <= prs.length) {
99
+ resolve(index - 1);
100
+ }
101
+ else {
102
+ console.log('⚠️ Invalid selection.');
103
+ resolve(null);
104
+ }
74
105
  });
75
- const askQuestion = () => {
76
- rl.question(`\n👉 Choose a PR to review [1-${prs.length}]: `, (answer) => {
77
- const index = parseInt(answer, 10);
78
- if (!isNaN(index) && index >= 1 && index <= prs.length) {
79
- resolve(index - 1); // Return array index, not PR number
80
- rl.close();
81
- }
82
- else {
83
- console.log('⚠️ Invalid selection. Please enter a number between 1 and ' + prs.length);
84
- askQuestion(); // Retry asking for input
85
- }
86
- });
87
- };
88
- askQuestion(); // Initial call to ask the user
89
106
  });
90
107
  }
91
- // Ask user to choose review method: whole PR or chunk-by-chunk
108
+ // Prompt for review method
92
109
  function askReviewMethod() {
93
110
  return new Promise((resolve) => {
94
- const rl = readline.createInterface({
95
- input: process.stdin,
96
- output: process.stdout,
97
- });
111
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
98
112
  console.log("\n🔍 Choose review method:");
99
113
  console.log('1) Review whole PR at once');
100
114
  console.log('2) Review chunk by chunk');
101
115
  rl.question(`👉 Choose an option [1-2]: `, (answer) => {
102
116
  rl.close();
103
- if (answer === '1') {
104
- resolve('whole');
105
- }
106
- else if (answer === '2') {
107
- resolve('chunk');
108
- }
109
- else {
110
- console.log('⚠️ Invalid selection. Defaulting to "whole".');
111
- resolve('whole');
112
- }
117
+ resolve(answer === '2' ? 'chunk' : 'whole');
113
118
  });
114
119
  });
115
120
  }
116
- // Ask user to approve or reject the review suggestion
121
+ // Prompt for review approval
117
122
  function askReviewApproval(suggestion) {
118
123
  return new Promise((resolve) => {
119
- console.log('\n💡 AI-suggested review:\n');
120
- console.log(suggestion);
121
124
  console.log('\n---');
122
125
  console.log('1) ✅ Approve');
123
126
  console.log('2) ❌ Reject');
124
127
  console.log('3) ✍️ Edit');
125
128
  console.log('4) ⌨️ Write your own review');
126
129
  console.log('5) 🚫 Cancel');
127
- const rl = readline.createInterface({
128
- input: process.stdin,
129
- output: process.stdout,
130
- });
130
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
131
131
  rl.question(`\n👉 Choose an option [1-5]: `, (answer) => {
132
132
  rl.close();
133
- if (answer === '1') {
133
+ if (answer === '1')
134
134
  resolve('approve');
135
- }
136
- else if (answer === '2') {
135
+ else if (answer === '2')
137
136
  resolve('reject');
138
- }
139
- else if (answer === '3') {
137
+ else if (answer === '3')
140
138
  resolve('edit');
141
- }
142
- else if (answer === '4') {
139
+ else if (answer === '4')
143
140
  resolve('custom');
144
- }
145
- else if (answer === '5') {
141
+ else
146
142
  resolve('cancel');
147
- }
148
- else {
149
- console.log('⚠️ Invalid selection. Defaulting to "approve".');
150
- resolve('approve');
151
- }
152
143
  });
153
144
  });
154
145
  }
155
- // Prompt for custom review input
146
+ // Prompt for custom review
156
147
  function promptCustomReview() {
157
148
  return new Promise((resolve) => {
158
- const rl = readline.createInterface({
159
- input: process.stdin,
160
- output: process.stdout,
161
- });
149
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
162
150
  rl.question('\n📝 Enter your custom review:\n> ', (input) => {
163
151
  rl.close();
164
152
  resolve(input.trim());
165
153
  });
166
154
  });
167
155
  }
156
+ // Prompt for editing review
157
+ export async function promptEditReview(suggestedReview) {
158
+ const tmpFilePath = path.join(os.tmpdir(), 'scai-review.txt');
159
+ fs.writeFileSync(tmpFilePath, `# Edit your review below.\n# Lines starting with '#' will be ignored.\n\n${suggestedReview}`);
160
+ const editor = process.env.EDITOR || (process.platform === 'win32' ? 'notepad' : 'vi');
161
+ spawnSync(editor, [tmpFilePath], { stdio: 'inherit' });
162
+ const editedContent = fs.readFileSync(tmpFilePath, 'utf-8');
163
+ return editedContent
164
+ .split('\n')
165
+ .filter(line => !line.trim().startsWith('#'))
166
+ .join('\n')
167
+ .trim() || suggestedReview;
168
+ }
169
+ // Split diff into file-based chunks
168
170
  function chunkDiff(diff) {
169
171
  const rawChunks = diff.split(/^diff --git /m).filter(Boolean);
170
172
  return rawChunks.map(chunk => {
171
173
  const fullChunk = 'diff --git ' + chunk;
172
- // Try to extract the file name from the diff header
173
- const match = fullChunk.match(/^diff --git a\/(.+?) b\/.+?\n/);
174
- const filePath = match ? match[1] : 'unknown';
175
- return { filePath, content: fullChunk };
174
+ const filePathMatch = fullChunk.match(/^diff --git a\/(.+?) b\//);
175
+ const filePath = filePathMatch ? filePathMatch[1] : 'unknown';
176
+ return {
177
+ filePath,
178
+ content: fullChunk,
179
+ };
176
180
  });
177
181
  }
178
- // Function to color diff lines
182
+ // Color lines in diff
179
183
  function colorDiffLine(line) {
180
- if (line.startsWith('+')) {
181
- return chalk.green(line); // New lines (added)
182
- }
183
- else if (line.startsWith('-')) {
184
- return chalk.red(line); // Deleted lines
185
- }
186
- else if (line.startsWith('@@')) {
187
- return chalk.yellow(line); // Modified lines (with context)
188
- }
189
- return line; // Default case (unchanged lines)
184
+ if (line.startsWith('+'))
185
+ return chalk.green(line);
186
+ if (line.startsWith('-'))
187
+ return chalk.red(line);
188
+ if (line.startsWith('@@'))
189
+ return chalk.yellow(line);
190
+ return line;
190
191
  }
191
- // Updated reviewChunk function to apply coloring
192
+ // Review a single chunk
192
193
  export async function reviewChunk(chunk, chunkIndex, totalChunks) {
193
194
  console.log(chalk.yellow(`\n🔍 Reviewing chunk ${chunkIndex + 1} of ${totalChunks}:`));
194
195
  console.log(`File: ${chunk.filePath}`);
195
- // Split the chunk content into lines and color each line accordingly
196
196
  const coloredDiff = chunk.content.split('\n').map(colorDiffLine).join('\n');
197
- // Log the colored diff with line numbers and progress
198
- console.log(`Starting line number: 30`); // Adjust based on actual starting line logic
199
197
  console.log(coloredDiff);
200
- // Get the review suggestion from the model (as before)
201
198
  const suggestion = await reviewModule.run({ content: chunk.content, filepath: chunk.filePath });
202
199
  console.log("\n💡 AI-suggested review:\n");
203
200
  console.log(suggestion.content);
204
- // Ask user to approve or reject the chunk
205
- const reviewChoice = await askReviewApproval('Do you approve this code chunk?');
201
+ const reviewChoice = await askReviewApproval(suggestion.content);
202
+ if (reviewChoice === 'edit') {
203
+ return await promptEditReview(suggestion.content);
204
+ }
206
205
  return reviewChoice;
207
206
  }
207
+ // Main command to review PR
208
208
  export async function reviewPullRequestCmd(branch = 'main', showAll = false) {
209
209
  try {
210
- console.log("🔍 Fetching pull requests and diffs...");
211
- const token = await ensureGitHubAuth(); // Get GitHub token
210
+ const token = await ensureGitHubAuth();
212
211
  const username = await getGitHubUsername(token);
213
- const { owner, repo } = getRepoDetails(); // Get repository details (owner, repo name)
214
- console.log(`👤 Authenticated user: ${username}`);
215
- console.log(`📦 GitHub repo: ${owner}/${repo}`);
216
- console.log(`🔍 Filtering ${showAll ? "all" : "user-specific"} PRs for branch: ${branch}`);
212
+ const { owner, repo } = getRepoDetails();
217
213
  const prsWithReviewRequested = await getPullRequestsForReview(token, owner, repo, username, branch, !showAll);
218
- console.log(`🔍 Found ${prsWithReviewRequested.length} PR(s) requiring review.`);
219
- if (prsWithReviewRequested.length === 0) {
220
- console.log("⚠️ No PRs found with review requested.");
214
+ if (prsWithReviewRequested.length === 0)
221
215
  return;
222
- }
223
216
  const selectedIndex = await askUserToPickPR(prsWithReviewRequested.map(p => p.pr));
224
217
  if (selectedIndex === null)
225
218
  return;
226
219
  const { pr, diff } = prsWithReviewRequested[selectedIndex];
227
220
  if (pr.body) {
228
- console.log(chalk.magentaBright('\n📝 Pull Request Description:\n'));
229
- console.log(chalk.gray(pr.body));
230
- console.log(chalk.magentaBright('\n---\n'));
231
- }
232
- let prDiff = diff;
233
- if (!prDiff) {
234
- console.log(`🔍 Fetching diff for PR #${pr.number}...`);
235
- prDiff = await fetchPullRequestDiff(pr, token);
221
+ console.log(chalk.magentaBright('\n📝 PR Description:\n') + chalk.gray(pr.body));
236
222
  }
237
- const chunkComments = {}; // Declare here for global access
238
- const reviewMethod = await askReviewMethod(); // Ask user for review method (whole or chunk)
223
+ const reviewMethod = await askReviewMethod();
239
224
  if (reviewMethod === 'whole') {
240
- console.log(`🔍 Reviewing the entire PR at once...`);
241
- const suggestion = await reviewModule.run({ content: prDiff, filepath: 'Whole PR Diff' });
242
- console.log("\n💡 AI-suggested review:\n");
225
+ const suggestion = await reviewModule.run({ content: diff, filepath: 'Whole PR Diff' });
243
226
  console.log(suggestion.content);
244
- // Store the entire review choice in chunkComments
245
- const finalReviewChoice = await askReviewApproval('Do you approve, reject, or leave a final review for this PR?');
227
+ const finalReviewChoice = await askReviewApproval(suggestion.content);
228
+ let reviewText = '';
246
229
  if (finalReviewChoice === 'approve') {
247
- console.log(`✅ Review for PR #${pr.number} approved.`);
248
- await postReviewComments(pr, chunkComments, token);
249
- await submitReview(pr.number, 'PR approved', 'APPROVE');
230
+ reviewText = 'PR approved';
231
+ await submitReview(pr.number, reviewText, 'APPROVE');
250
232
  }
251
233
  else if (finalReviewChoice === 'reject') {
252
- console.log(`❌ Review for PR #${pr.number} rejected.`);
253
- await postReviewComments(pr, chunkComments, token);
254
- await submitReview(pr.number, 'Changes requested', 'REQUEST_CHANGES');
234
+ reviewText = 'Changes requested';
235
+ await submitReview(pr.number, reviewText, 'REQUEST_CHANGES');
255
236
  }
256
- else if (finalReviewChoice === 'cancel') {
257
- console.log(`🚪 Review process cancelled.`);
258
- return;
237
+ else if (finalReviewChoice === 'custom') {
238
+ reviewText = await promptCustomReview();
239
+ await submitReview(pr.number, reviewText, 'COMMENT');
259
240
  }
260
- else {
261
- const customReview = await promptCustomReview();
262
- console.log(`💬 Custom review: ${customReview}`);
263
- await postReviewComments(pr, chunkComments, token);
264
- await submitReview(pr.number, customReview, 'COMMENT');
241
+ else if (finalReviewChoice === 'edit') {
242
+ reviewText = await promptEditReview(suggestion.content);
243
+ await submitReview(pr.number, reviewText, 'COMMENT');
265
244
  }
266
245
  }
267
246
  else {
268
- const chunks = chunkDiff(prDiff); // Split the diff into chunks
269
- // Log the total number of chunks
247
+ const chunks = chunkDiff(diff);
270
248
  console.log(chalk.cyan(`🔍 Total Chunks: ${chunks.length}`));
271
- // Iterate over each chunk, passing the index and total chunk count
272
249
  for (let i = 0; i < chunks.length; i++) {
273
250
  const chunk = chunks[i];
274
- // Pass the chunk, index (i), and totalChunks to the reviewChunk function
275
- const reviewChoice = await reviewChunk(chunk, i, chunks.length); // Review each chunk
276
- if (reviewChoice === 'approve') {
277
- console.log('✅ Approving this chunk.');
278
- chunkComments[chunk.filePath] = ['Approved'];
251
+ const reviewResult = await reviewChunk(chunk, i, chunks.length);
252
+ if (reviewResult === 'approve') {
253
+ await submitReview(pr.number, 'Approved chunk', 'APPROVE');
279
254
  }
280
- else if (reviewChoice === 'reject') {
281
- console.log(' Rejecting this chunk.');
282
- chunkComments[chunk.filePath] = ['Rejected'];
255
+ else if (reviewResult === 'reject') {
256
+ await submitReview(pr.number, 'Changes requested for chunk', 'REQUEST_CHANGES');
283
257
  }
284
- else if (reviewChoice === 'cancel') {
285
- console.log('🚪 Review process cancelled.');
286
- return; // Exit if user cancels
258
+ else if (reviewResult === 'custom') {
259
+ const customReview = await promptCustomReview();
260
+ await submitReview(pr.number, customReview, 'COMMENT');
287
261
  }
288
262
  else {
289
- console.log('✍️ Custom review added.');
290
- const customReview = await promptCustomReview();
291
- console.log(`💬 Custom review: ${customReview}`);
292
- chunkComments[chunk.filePath] = [customReview];
263
+ await submitReview(pr.number, reviewResult, 'COMMENT');
293
264
  }
294
265
  }
295
- // After chunk review, ask for the final approval decision
296
- const finalReviewChoice = await askReviewApproval('Do you approve, reject, or leave a final review for this PR?');
297
- if (finalReviewChoice === 'approve') {
298
- console.log(`✅ Review for PR #${pr.number} approved.`);
299
- await postReviewComments(pr, chunkComments, token);
300
- await submitReview(pr.number, 'PR approved', 'APPROVE');
301
- }
302
- else if (finalReviewChoice === 'reject') {
303
- console.log(`❌ Review for PR #${pr.number} rejected.`);
304
- await postReviewComments(pr, chunkComments, token);
305
- await submitReview(pr.number, 'Changes requested', 'REQUEST_CHANGES');
306
- }
307
- else if (finalReviewChoice === 'cancel') {
308
- console.log(`🚪 Review process cancelled.`);
309
- return;
310
- }
311
- else {
312
- const customReview = await promptCustomReview();
313
- console.log(`💬 Custom review: ${customReview}`);
314
- await postReviewComments(pr, chunkComments, token);
315
- await submitReview(pr.number, customReview, 'COMMENT');
316
- }
317
266
  }
318
267
  }
319
268
  catch (err) {
320
269
  console.error("❌ Error reviewing PR:", err.message);
321
270
  }
322
271
  }
323
- // Function to post all comments to GitHub after the review
324
- async function postReviewComments(pr, chunkComments, token) {
325
- const { owner, repo } = getRepoDetails(); // Get the repo details
326
- for (const chunk in chunkComments) {
327
- const comments = chunkComments[chunk];
328
- const fileName = chunk; // Use chunk's file path
329
- const lineNumber = 10; // Extract the actual line number from the chunk content
330
- for (const comment of comments) {
331
- await postReviewComment(token, owner, repo, pr.number, fileName, lineNumber, comment);
332
- }
333
- }
334
- }
@@ -1,4 +1,4 @@
1
- import columnify from 'columnify';
1
+ import columnify from "columnify";
2
2
  export function styleOutput(summaryText) {
3
3
  const terminalWidth = process.stdout.columns || 80;
4
4
  // You can control wrapping here
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scai",
3
- "version": "0.1.74",
3
+ "version": "0.1.75",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "scai": "./dist/index.js"