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