scai 0.1.54 → 0.1.56
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 +1 -1
- package/dist/commands/ReviewCmd.js +172 -36
- package/dist/github/postComments.js +69 -0
- package/dist/index.js +7 -0
- package/dist/pipeline/modules/reviewModule.js +2 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ No more struggling to write pull request descriptions by hand. `scai git review`
|
|
|
65
65
|
To interact with GitHub and create pull requests, `scai` needs a personal access token with **repo** permissions.
|
|
66
66
|
|
|
67
67
|
1. **Create your GitHub Access Token**
|
|
68
|
-
Follow this link to generate a token: [https://github.com/settings/tokens
|
|
68
|
+
Follow this link to generate a token: [https://github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens)
|
|
69
69
|
|
|
70
70
|
Make sure you enable at least:
|
|
71
71
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import readline from 'readline';
|
|
2
|
-
import { reviewModule } from '../pipeline/modules/reviewModule.js';
|
|
3
2
|
import { fetchOpenPullRequests, fetchPullRequestDiff, getGitHubUsername, submitReview } from '../github/github.js';
|
|
4
3
|
import { getRepoDetails } from '../github/repo.js';
|
|
5
4
|
import { ensureGitHubAuth } from '../github/auth.js';
|
|
5
|
+
import { postReviewComment } from '../github/postComments.js';
|
|
6
|
+
import { reviewModule } from '../pipeline/modules/reviewModule.js';
|
|
7
|
+
import chalk from 'chalk';
|
|
6
8
|
// Function to fetch the PRs with requested reviews for a specific branch (default to 'main')
|
|
7
9
|
export async function getPullRequestsForReview(token, owner, repo, username, branch = 'main', filterForUser = true) {
|
|
8
10
|
const prs = await fetchOpenPullRequests(token, owner, repo);
|
|
@@ -30,17 +32,13 @@ export async function getPullRequestsForReview(token, owner, repo, username, bra
|
|
|
30
32
|
filtered.push({ pr, diff });
|
|
31
33
|
}
|
|
32
34
|
catch (err) {
|
|
33
|
-
console.warn(`⚠️ Could not fetch diff for PR #${pr.number}: ${err.message}`);
|
|
34
35
|
failedPRs.push(pr);
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
if (failedPRs.length > 0) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
console.warn(` - #${pr.number}: ${pr.title}`);
|
|
42
|
-
}
|
|
43
|
-
console.warn('These PRs will not be included in the review summary.\n');
|
|
40
|
+
const failedList = failedPRs.map(pr => `#${pr.number}`).join(', ');
|
|
41
|
+
console.warn(`⚠️ Skipped ${failedPRs.length} PR(s) (${failedList}) due to diff fetch errors.`);
|
|
44
42
|
}
|
|
45
43
|
return filtered;
|
|
46
44
|
}
|
|
@@ -51,7 +49,7 @@ function askUserToPickPR(prs) {
|
|
|
51
49
|
console.log("⚠️ No pull requests with review requested.");
|
|
52
50
|
return resolve(null);
|
|
53
51
|
}
|
|
54
|
-
console.log("\n📦 Open Pull Requests with review requested:");
|
|
52
|
+
console.log(chalk.blue("\n📦 Open Pull Requests with review requested:"));
|
|
55
53
|
prs.forEach((pr, i) => {
|
|
56
54
|
console.log(`${i + 1}) #${pr.number} - ${pr.title}`);
|
|
57
55
|
});
|
|
@@ -59,14 +57,43 @@ function askUserToPickPR(prs) {
|
|
|
59
57
|
input: process.stdin,
|
|
60
58
|
output: process.stdout,
|
|
61
59
|
});
|
|
62
|
-
|
|
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
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// Ask user to choose review method: whole PR or chunk-by-chunk
|
|
77
|
+
function askReviewMethod() {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const rl = readline.createInterface({
|
|
80
|
+
input: process.stdin,
|
|
81
|
+
output: process.stdout,
|
|
82
|
+
});
|
|
83
|
+
console.log("\n🔍 Choose review method:");
|
|
84
|
+
console.log('1) Review whole PR at once');
|
|
85
|
+
console.log('2) Review chunk by chunk');
|
|
86
|
+
rl.question(`👉 Choose an option [1-2]: `, (answer) => {
|
|
63
87
|
rl.close();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
88
|
+
if (answer === '1') {
|
|
89
|
+
resolve('whole');
|
|
90
|
+
}
|
|
91
|
+
else if (answer === '2') {
|
|
92
|
+
resolve('chunk');
|
|
67
93
|
}
|
|
68
94
|
else {
|
|
69
|
-
|
|
95
|
+
console.log('⚠️ Invalid selection. Defaulting to "whole".');
|
|
96
|
+
resolve('whole');
|
|
70
97
|
}
|
|
71
98
|
});
|
|
72
99
|
});
|
|
@@ -123,12 +150,52 @@ function promptCustomReview() {
|
|
|
123
150
|
});
|
|
124
151
|
});
|
|
125
152
|
}
|
|
153
|
+
function chunkDiff(diff) {
|
|
154
|
+
const rawChunks = diff.split(/^diff --git /m).filter(Boolean);
|
|
155
|
+
return rawChunks.map(chunk => {
|
|
156
|
+
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 };
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// Function to color diff lines
|
|
164
|
+
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)
|
|
175
|
+
}
|
|
176
|
+
// Updated reviewChunk function to apply coloring
|
|
177
|
+
export async function reviewChunk(chunk, chunkIndex, totalChunks) {
|
|
178
|
+
console.log(chalk.yellow(`\n🔍 Reviewing chunk ${chunkIndex + 1} of ${totalChunks}:`));
|
|
179
|
+
console.log(`File: ${chunk.filePath}`);
|
|
180
|
+
// Split the chunk content into lines and color each line accordingly
|
|
181
|
+
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
|
+
console.log(coloredDiff);
|
|
185
|
+
// Get the review suggestion from the model (as before)
|
|
186
|
+
const suggestion = await reviewModule.run({ content: chunk.content, filepath: chunk.filePath });
|
|
187
|
+
console.log("\n💡 AI-suggested review:\n");
|
|
188
|
+
console.log(suggestion.content);
|
|
189
|
+
// Ask user to approve or reject the chunk
|
|
190
|
+
const reviewChoice = await askReviewApproval('Do you approve this code chunk?');
|
|
191
|
+
return reviewChoice;
|
|
192
|
+
}
|
|
126
193
|
export async function reviewPullRequestCmd(branch = 'main', showAll = false) {
|
|
127
194
|
try {
|
|
128
195
|
console.log("🔍 Fetching pull requests and diffs...");
|
|
129
|
-
const token = await ensureGitHubAuth();
|
|
196
|
+
const token = await ensureGitHubAuth(); // Get GitHub token
|
|
130
197
|
const username = await getGitHubUsername(token);
|
|
131
|
-
const { owner, repo } = getRepoDetails();
|
|
198
|
+
const { owner, repo } = getRepoDetails(); // Get repository details (owner, repo name)
|
|
132
199
|
console.log(`👤 Authenticated user: ${username}`);
|
|
133
200
|
console.log(`📦 GitHub repo: ${owner}/${repo}`);
|
|
134
201
|
console.log(`🔍 Filtering ${showAll ? "all" : "user-specific"} PRs for branch: ${branch}`);
|
|
@@ -147,32 +214,101 @@ export async function reviewPullRequestCmd(branch = 'main', showAll = false) {
|
|
|
147
214
|
console.log(`🔍 Fetching diff for PR #${pr.number}...`);
|
|
148
215
|
prDiff = await fetchPullRequestDiff(pr, token);
|
|
149
216
|
}
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
console.log(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
217
|
+
const chunkComments = {}; // Declare here for global access
|
|
218
|
+
const reviewMethod = await askReviewMethod(); // Ask user for review method (whole or chunk)
|
|
219
|
+
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");
|
|
223
|
+
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?');
|
|
226
|
+
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
|
+
}
|
|
231
|
+
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');
|
|
235
|
+
}
|
|
236
|
+
else if (finalReviewChoice === 'cancel') {
|
|
237
|
+
console.log(`🚪 Review process cancelled.`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
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');
|
|
245
|
+
}
|
|
167
246
|
}
|
|
168
247
|
else {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
console.log(
|
|
172
|
-
|
|
248
|
+
const chunks = chunkDiff(prDiff); // Split the diff into chunks
|
|
249
|
+
// Log the total number of chunks
|
|
250
|
+
console.log(chalk.cyan(`🔍 Total Chunks: ${chunks.length}`));
|
|
251
|
+
// Iterate over each chunk, passing the index and total chunk count
|
|
252
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
253
|
+
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'];
|
|
259
|
+
}
|
|
260
|
+
else if (reviewChoice === 'reject') {
|
|
261
|
+
console.log('❌ Rejecting this chunk.');
|
|
262
|
+
chunkComments[chunk.filePath] = ['Rejected'];
|
|
263
|
+
}
|
|
264
|
+
else if (reviewChoice === 'cancel') {
|
|
265
|
+
console.log('🚪 Review process cancelled.');
|
|
266
|
+
return; // Exit if user cancels
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
console.log('✍️ Custom review added.');
|
|
270
|
+
const customReview = await promptCustomReview();
|
|
271
|
+
console.log(`💬 Custom review: ${customReview}`);
|
|
272
|
+
chunkComments[chunk.filePath] = [customReview];
|
|
273
|
+
}
|
|
274
|
+
}
|
|
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
|
+
}
|
|
173
297
|
}
|
|
174
298
|
}
|
|
175
299
|
catch (err) {
|
|
176
300
|
console.error("❌ Error reviewing PR:", err.message);
|
|
177
301
|
}
|
|
178
302
|
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Octokit } from '@octokit/rest';
|
|
2
|
+
/**
|
|
3
|
+
* Parses the PR diff to determine the correct position for inline comments.
|
|
4
|
+
* The position is the "index" of the changed line in the diff.
|
|
5
|
+
* @param diff The diff content of the PR
|
|
6
|
+
* @param lineNumber The line number to convert to a position in the diff
|
|
7
|
+
*/
|
|
8
|
+
function getLinePositionFromDiff(diff, lineNumber) {
|
|
9
|
+
const lines = diff.split('\n');
|
|
10
|
+
let currentLine = 0;
|
|
11
|
+
// Iterate through the lines and determine the correct position for the lineNumber
|
|
12
|
+
for (let i = 0; i < lines.length; i++) {
|
|
13
|
+
// Only count the lines that are part of a diff chunk
|
|
14
|
+
if (lines[i].startsWith('+') || lines[i].startsWith('-')) {
|
|
15
|
+
currentLine++;
|
|
16
|
+
if (currentLine === lineNumber) {
|
|
17
|
+
return i; // Position is the index of the changed line in the diff
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null; // Return null if lineNumber is not found in the diff
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Posts an inline review comment on a specific line of a PR.
|
|
25
|
+
*
|
|
26
|
+
* @param token GitHub personal access token
|
|
27
|
+
* @param owner Repository owner (e.g. 'my-org')
|
|
28
|
+
* @param repo Repository name (e.g. 'my-repo')
|
|
29
|
+
* @param prNumber Pull Request number
|
|
30
|
+
* @param fileName Path to the file in the PR (relative to repo root)
|
|
31
|
+
* @param lineNumber Line number to comment on in the file (not in the diff)
|
|
32
|
+
* @param comment Text of the comment
|
|
33
|
+
*/
|
|
34
|
+
export async function postReviewComment(token, owner, repo, prNumber, fileName, lineNumber, comment) {
|
|
35
|
+
const octokit = new Octokit({ auth: `token ${token}` });
|
|
36
|
+
// First, get PR details so we can retrieve the head commit SHA
|
|
37
|
+
const pr = await octokit.pulls.get({
|
|
38
|
+
owner,
|
|
39
|
+
repo,
|
|
40
|
+
pull_number: prNumber,
|
|
41
|
+
});
|
|
42
|
+
const commitId = pr.data.head.sha;
|
|
43
|
+
// Fetch the PR diff by getting the full diff URL from the PR
|
|
44
|
+
const diffUrl = pr.data.diff_url;
|
|
45
|
+
const diffRes = await fetch(diffUrl);
|
|
46
|
+
const diff = await diffRes.text();
|
|
47
|
+
// Get the position of the line in the diff
|
|
48
|
+
const position = getLinePositionFromDiff(diff, lineNumber);
|
|
49
|
+
if (position === null) {
|
|
50
|
+
console.error(`❌ Unable to find line ${lineNumber} in the diff for PR #${prNumber}.`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// Now, post the inline comment
|
|
54
|
+
try {
|
|
55
|
+
await octokit.pulls.createReviewComment({
|
|
56
|
+
owner,
|
|
57
|
+
repo,
|
|
58
|
+
pull_number: prNumber,
|
|
59
|
+
commit_id: commitId,
|
|
60
|
+
path: fileName,
|
|
61
|
+
body: comment,
|
|
62
|
+
position: position, // Use the position calculated from the diff
|
|
63
|
+
});
|
|
64
|
+
console.log(`✅ Inline comment posted to ${fileName} at diff position ${position}.`);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.error(`❌ Error posting inline comment: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { runInspectCommand } from "./commands/InspectCmd.js";
|
|
|
25
25
|
import { reviewPullRequestCmd } from "./commands/ReviewCmd.js";
|
|
26
26
|
import { promptForToken } from "./github/token.js";
|
|
27
27
|
import { validateGitHubTokenAgainstRepo } from "./github/githubAuthCheck.js";
|
|
28
|
+
import { checkGit } from "./commands/GitCmd.js";
|
|
28
29
|
// 🎛️ CLI Setup
|
|
29
30
|
const cmd = new Command('scai')
|
|
30
31
|
.version(version)
|
|
@@ -54,6 +55,12 @@ git
|
|
|
54
55
|
.option('-c, --commit', 'Automatically commit with suggested message')
|
|
55
56
|
.option('-l, --changelog', 'Generate and optionally stage a changelog entry')
|
|
56
57
|
.action((options) => suggestCommitMessage(options));
|
|
58
|
+
git
|
|
59
|
+
.command('check')
|
|
60
|
+
.description('Check Git working directory and branch status')
|
|
61
|
+
.action(() => {
|
|
62
|
+
checkGit();
|
|
63
|
+
});
|
|
57
64
|
// Add auth-related commands
|
|
58
65
|
const auth = cmd.command('auth').description('GitHub authentication commands');
|
|
59
66
|
auth
|
|
@@ -7,7 +7,8 @@ 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
|
|
10
|
+
Give clear and very short constructive feedback based on the code changes below.
|
|
11
|
+
Only mention issues of greater concern. Less is more.
|
|
11
12
|
|
|
12
13
|
Changes:
|
|
13
14
|
${content}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.56",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"scai": "./dist/index.js"
|
|
@@ -36,9 +36,11 @@
|
|
|
36
36
|
"start": "node dist/index.js"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"@octokit/rest": "^22.0.0",
|
|
39
40
|
"acorn": "^8.11.3",
|
|
40
41
|
"acorn-walk": "^8.3.2",
|
|
41
42
|
"better-sqlite3": "^12.1.1",
|
|
43
|
+
"chalk": "^5.4.1",
|
|
42
44
|
"commander": "^11.0.0",
|
|
43
45
|
"fast-glob": "^3.3.3",
|
|
44
46
|
"proper-lockfile": "^4.1.2",
|
|
@@ -57,4 +59,4 @@
|
|
|
57
59
|
"dist/",
|
|
58
60
|
"README.md"
|
|
59
61
|
]
|
|
60
|
-
}
|
|
62
|
+
}
|