scai 0.1.50 → 0.1.51

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,16 +1,15 @@
1
- # ⚙️ scai — Smart Commit AI ✨
1
+ # ⚙️ scai — Smart Commit & Review AI ✨
2
2
 
3
+ > AI-powered CLI tool for commit messages **and** pull request reviews — using local models.
3
4
 
4
- > AI-powered CLI tool for commits and summaries using local models.
5
+ **scai** is your AI pair‑programmer in the terminal. Focus on coding while scai:
5
6
 
6
-
7
- **scai** is a privacy-first, local AI assistant for developers. It brings semantic understanding to your codebase, directly from the terminal:
8
-
9
- - 💬 Suggest intelligent Git commit messages
10
- - 🧐 Summarize files in plain English
11
- - 📜 Auto-update your changelog
12
- - 🔍 Search and ask questions across your codebase (ALPHA)
13
- - 🔐 100% local — no API keys, no cloud, no telemetry
7
+ - 🤖 **Reviews open pull requests** and provides AI‑driven feedback (BETA)
8
+ - 💬 **Suggests intelligent Git commit messages** based on your staged diff
9
+ - 📝 Summarizes files in plain English
10
+ - 📜 Auto‑updates your changelog
11
+ - 🔍 (ALPHA) Search & ask questions across your codebase
12
+ - 🔐 100% local — no API keys, no cloud, no telemetry
14
13
 
15
14
  ---
16
15
 
@@ -0,0 +1,178 @@
1
+ import readline from 'readline';
2
+ import { reviewModule } from '../pipeline/modules/reviewModule.js';
3
+ import { fetchOpenPullRequests, fetchPullRequestDiff, getGitHubUsername, submitReview } from '../github/github.js';
4
+ import { getRepoDetails } from '../github/repo.js';
5
+ import { ensureGitHubAuth } from '../github/auth.js';
6
+ // Function to fetch the PRs with requested reviews for a specific branch (default to 'main')
7
+ export async function getPullRequestsForReview(token, owner, repo, username, branch = 'main', filterForUser = true) {
8
+ const prs = await fetchOpenPullRequests(token, owner, repo);
9
+ const filtered = [];
10
+ const failedPRs = [];
11
+ for (const pr of prs) {
12
+ const isDraft = pr.draft;
13
+ const isMerged = pr.merged_at != null;
14
+ const shouldInclude = !isDraft &&
15
+ !isMerged &&
16
+ (!filterForUser || pr.requested_reviewers?.some(r => r.login === username));
17
+ if (shouldInclude) {
18
+ const diffUrl = `https://api.github.com/repos/${owner}/${repo}/pulls/${pr.number}.diff`;
19
+ try {
20
+ const diffRes = await fetch(diffUrl, {
21
+ headers: {
22
+ Authorization: `token ${token}`,
23
+ Accept: 'application/vnd.github.v3.diff',
24
+ },
25
+ });
26
+ if (!diffRes.ok) {
27
+ throw new Error(`${diffRes.status} ${diffRes.statusText}`);
28
+ }
29
+ const diff = await diffRes.text();
30
+ filtered.push({ pr, diff });
31
+ }
32
+ catch (err) {
33
+ console.warn(`⚠️ Could not fetch diff for PR #${pr.number}: ${err.message}`);
34
+ failedPRs.push(pr);
35
+ }
36
+ }
37
+ }
38
+ if (failedPRs.length > 0) {
39
+ console.warn(`\n⚠️ Skipped ${failedPRs.length} PR(s) due to diff fetch failures:`);
40
+ for (const pr of failedPRs) {
41
+ console.warn(` - #${pr.number}: ${pr.title}`);
42
+ }
43
+ console.warn('These PRs will not be included in the review summary.\n');
44
+ }
45
+ return filtered;
46
+ }
47
+ // Ask user to pick a PR to review
48
+ function askUserToPickPR(prs) {
49
+ return new Promise((resolve) => {
50
+ if (prs.length === 0) {
51
+ console.log("⚠️ No pull requests with review requested.");
52
+ return resolve(null);
53
+ }
54
+ console.log("\n📦 Open Pull Requests with review requested:");
55
+ prs.forEach((pr, i) => {
56
+ console.log(`${i + 1}) #${pr.number} - ${pr.title}`);
57
+ });
58
+ const rl = readline.createInterface({
59
+ input: process.stdin,
60
+ output: process.stdout,
61
+ });
62
+ rl.question(`\n👉 Choose a PR to review [1-${prs.length}]: `, (answer) => {
63
+ rl.close();
64
+ const index = parseInt(answer, 10);
65
+ if (!isNaN(index) && index >= 1 && index <= prs.length) {
66
+ resolve(index - 1); // Return array index, not PR number
67
+ }
68
+ else {
69
+ resolve(null);
70
+ }
71
+ });
72
+ });
73
+ }
74
+ // Ask user to approve or reject the review suggestion
75
+ function askReviewApproval(suggestion) {
76
+ return new Promise((resolve) => {
77
+ console.log('\n💡 AI-suggested review:\n');
78
+ console.log(suggestion);
79
+ console.log('\n---');
80
+ console.log('1) ✅ Approve');
81
+ console.log('2) ❌ Reject');
82
+ console.log('3) ✍️ Edit');
83
+ console.log('4) Write your own review');
84
+ console.log('5) 🚪 Cancel');
85
+ const rl = readline.createInterface({
86
+ input: process.stdin,
87
+ output: process.stdout,
88
+ });
89
+ rl.question(`\n👉 Choose an option [1-5]: `, (answer) => {
90
+ rl.close();
91
+ if (answer === '1') {
92
+ resolve('approve');
93
+ }
94
+ else if (answer === '2') {
95
+ resolve('reject');
96
+ }
97
+ else if (answer === '3') {
98
+ resolve('edit');
99
+ }
100
+ else if (answer === '4') {
101
+ resolve('custom');
102
+ }
103
+ else if (answer === '5') {
104
+ resolve('cancel');
105
+ }
106
+ else {
107
+ console.log('⚠️ Invalid selection. Defaulting to "approve".');
108
+ resolve('approve');
109
+ }
110
+ });
111
+ });
112
+ }
113
+ // Prompt for custom review input
114
+ function promptCustomReview() {
115
+ return new Promise((resolve) => {
116
+ const rl = readline.createInterface({
117
+ input: process.stdin,
118
+ output: process.stdout,
119
+ });
120
+ rl.question('\n📝 Enter your custom review:\n> ', (input) => {
121
+ rl.close();
122
+ resolve(input.trim());
123
+ });
124
+ });
125
+ }
126
+ export async function reviewPullRequestCmd(branch = 'main', showAll = false) {
127
+ try {
128
+ console.log("🔍 Fetching pull requests and diffs...");
129
+ const token = await ensureGitHubAuth();
130
+ const username = await getGitHubUsername(token);
131
+ const { owner, repo } = getRepoDetails();
132
+ console.log(`👤 Authenticated user: ${username}`);
133
+ console.log(`📦 GitHub repo: ${owner}/${repo}`);
134
+ console.log(`🔍 Filtering ${showAll ? "all" : "user-specific"} PRs for branch: ${branch}`);
135
+ const prsWithReviewRequested = await getPullRequestsForReview(token, owner, repo, username, branch, !showAll);
136
+ console.log(`🔍 Found ${prsWithReviewRequested.length} PR(s) requiring review.`);
137
+ if (prsWithReviewRequested.length === 0) {
138
+ console.log("⚠️ No PRs found with review requested.");
139
+ return;
140
+ }
141
+ const selectedIndex = await askUserToPickPR(prsWithReviewRequested.map(p => p.pr));
142
+ if (selectedIndex === null)
143
+ return;
144
+ const { pr, diff } = prsWithReviewRequested[selectedIndex];
145
+ let prDiff = diff;
146
+ if (!prDiff) {
147
+ console.log(`🔍 Fetching diff for PR #${pr.number}...`);
148
+ prDiff = await fetchPullRequestDiff(pr, token);
149
+ }
150
+ const result = await reviewModule.run({ content: prDiff });
151
+ const reviewSuggestion = result.content || 'No review suggestion generated.';
152
+ const reviewChoice = await askReviewApproval(reviewSuggestion);
153
+ let reviewEvent;
154
+ if (reviewChoice === 'approve') {
155
+ reviewEvent = 'APPROVE';
156
+ console.log(`✅ Review for PR #${pr.number} approved.`);
157
+ await submitReview(pr.number, reviewSuggestion, reviewEvent);
158
+ }
159
+ else if (reviewChoice === 'reject') {
160
+ reviewEvent = 'REQUEST_CHANGES';
161
+ console.log(`❌ Review for PR #${pr.number} rejected.`);
162
+ await submitReview(pr.number, reviewSuggestion, reviewEvent);
163
+ }
164
+ else if (reviewChoice === 'cancel') {
165
+ console.log(`🚪 Review process for PR #${pr.number} cancelled.`);
166
+ return; // Exit the function and cancel the review process
167
+ }
168
+ else {
169
+ reviewEvent = 'COMMENT';
170
+ const customReview = await promptCustomReview();
171
+ console.log(`💬 Custom review: ${customReview}`);
172
+ await submitReview(pr.number, customReview, reviewEvent);
173
+ }
174
+ }
175
+ catch (err) {
176
+ console.error("❌ Error reviewing PR:", err.message);
177
+ }
178
+ }
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ const defaultConfig = {
5
5
  model: 'llama3',
6
6
  language: 'ts',
7
7
  indexDir: INDEX_DIR, // Default index directory from constants
8
+ githubToken: '', // Add githubToken to default config
8
9
  };
9
10
  // Function to ensure the configuration directory exists
10
11
  function ensureConfigDir() {
@@ -57,6 +58,15 @@ export const Config = {
57
58
  writeConfig({ indexDir });
58
59
  console.log(`📁 Index directory set to: ${indexDir}`);
59
60
  },
61
+ // Get the GitHub token from the config
62
+ getGitHubToken() {
63
+ return readConfig().githubToken || null;
64
+ },
65
+ // Set the GitHub token in the config
66
+ setGitHubToken(token) {
67
+ writeConfig({ githubToken: token });
68
+ console.log("✅ GitHub token updated");
69
+ },
60
70
  // Show the current configuration
61
71
  show() {
62
72
  const cfg = readConfig();
@@ -64,5 +74,6 @@ export const Config = {
64
74
  console.log(` Model : ${cfg.model}`);
65
75
  console.log(` Language : ${cfg.language}`);
66
76
  console.log(` Index dir : ${cfg.indexDir}`);
77
+ console.log(` GitHub Token: ${cfg.githubToken ? '*****' : 'Not Set'}`);
67
78
  }
68
79
  };
@@ -0,0 +1,43 @@
1
+ export async function fetchOpenPullRequests(token, owner, repo) {
2
+ const url = `https://api.github.com/repos/${owner}/${repo}/pulls`;
3
+ const res = await fetch(url, {
4
+ headers: {
5
+ Authorization: `token ${token}`,
6
+ Accept: "application/vnd.github.v3+json",
7
+ },
8
+ });
9
+ if (!res.ok) {
10
+ throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
11
+ }
12
+ const prs = await res.json();
13
+ return prs.map((pr) => ({
14
+ number: pr.number,
15
+ title: pr.title,
16
+ diff_url: pr.diff_url,
17
+ }));
18
+ }
19
+ export async function fetchPullRequestDiff(pr, token) {
20
+ const res = await fetch(pr.diff_url, {
21
+ headers: {
22
+ Authorization: `token ${token}`,
23
+ Accept: "application/vnd.github.v3.diff",
24
+ },
25
+ });
26
+ if (!res.ok) {
27
+ throw new Error(`Error fetching PR diff: ${res.status} ${res.statusText}`);
28
+ }
29
+ return await res.text();
30
+ }
31
+ export async function getGitHubUsername(token) {
32
+ const res = await fetch('https://api.github.com/user', {
33
+ headers: {
34
+ Authorization: `token ${token}`,
35
+ Accept: "application/vnd.github.v3+json",
36
+ },
37
+ });
38
+ if (!res.ok) {
39
+ throw new Error(`Error fetching user info: ${res.status} ${res.statusText}`);
40
+ }
41
+ const user = await res.json();
42
+ return user.login; // GitHub username
43
+ }
@@ -0,0 +1,15 @@
1
+ import { Config } from '../config.js';
2
+ import { promptForToken } from './token.js';
3
+ export async function ensureGitHubAuth() {
4
+ // First check if the token exists in the config
5
+ let token = Config.getGitHubToken();
6
+ if (token) {
7
+ return token; // Token already exists in config, return it
8
+ }
9
+ // Token doesn't exist in config, prompt the user for it
10
+ console.log("🔐 GitHub token not found.");
11
+ token = await promptForToken();
12
+ // Save the token in the config
13
+ Config.setGitHubToken(token.trim());
14
+ return token.trim();
15
+ }
@@ -0,0 +1,71 @@
1
+ import { ensureGitHubAuth } from './auth.js';
2
+ import { getRepoDetails } from './repo.js';
3
+ export async function fetchOpenPullRequests(token, owner, repo) {
4
+ const url = `https://api.github.com/repos/${owner}/${repo}/pulls?state=open&per_page=100`;
5
+ const res = await fetch(url, {
6
+ headers: {
7
+ Authorization: `token ${token}`,
8
+ Accept: 'application/vnd.github.v3+json',
9
+ },
10
+ });
11
+ if (!res.ok) {
12
+ throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
13
+ }
14
+ const prs = await res.json();
15
+ return prs.map((pr) => ({
16
+ number: pr.number,
17
+ title: pr.title,
18
+ url: pr.url,
19
+ diff_url: pr.diff_url,
20
+ draft: pr.draft,
21
+ merged_at: pr.merged_at,
22
+ base: pr.base,
23
+ requested_reviewers: pr.requested_reviewers,
24
+ }));
25
+ }
26
+ export async function getGitHubUsername(token) {
27
+ const res = await fetch('https://api.github.com/user', {
28
+ headers: {
29
+ Authorization: `token ${token}`,
30
+ Accept: "application/vnd.github.v3+json",
31
+ },
32
+ });
33
+ if (!res.ok) {
34
+ throw new Error(`Error fetching user info: ${res.status} ${res.statusText}`);
35
+ }
36
+ const user = await res.json();
37
+ return user.login; // GitHub username
38
+ }
39
+ export async function fetchPullRequestDiff(pr, token) {
40
+ const res = await fetch(pr.diff_url, {
41
+ headers: {
42
+ Authorization: `token ${token}`,
43
+ Accept: "application/vnd.github.v3.diff",
44
+ },
45
+ });
46
+ if (!res.ok) {
47
+ throw new Error(`Error fetching PR diff: ${res.status} ${res.statusText}`);
48
+ }
49
+ return await res.text();
50
+ }
51
+ export async function submitReview(prNumber, body, event = 'COMMENT') {
52
+ const token = await ensureGitHubAuth();
53
+ const { owner, repo } = getRepoDetails();
54
+ const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`;
55
+ const res = await fetch(url, {
56
+ method: 'POST',
57
+ headers: {
58
+ Authorization: `token ${token}`,
59
+ Accept: 'application/vnd.github.v3+json',
60
+ },
61
+ body: JSON.stringify({
62
+ body,
63
+ event,
64
+ }),
65
+ });
66
+ if (!res.ok) {
67
+ const errorText = await res.text();
68
+ throw new Error(`Failed to submit review: ${res.status} ${res.statusText} - ${errorText}`);
69
+ }
70
+ console.log(`✅ Submitted ${event} review for PR #${prNumber}`);
71
+ }
@@ -0,0 +1,17 @@
1
+ import { ensureGitHubAuth } from './auth.js';
2
+ import { getRepoDetails } from './repo.js';
3
+ export async function validateGitHubTokenAgainstRepo() {
4
+ const token = await ensureGitHubAuth();
5
+ const { owner, repo } = getRepoDetails();
6
+ const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
7
+ headers: {
8
+ Authorization: `Bearer ${token}`,
9
+ 'User-Agent': 'scai-cli',
10
+ },
11
+ });
12
+ if (!response.ok) {
13
+ const error = await response.json().catch(() => ({}));
14
+ throw new Error(`❌ Token is invalid or lacks access to ${owner}/${repo}. GitHub says: ${response.status} ${response.statusText}${error.message ? ` – ${error.message}` : ''}`);
15
+ }
16
+ return `✅ GitHub token is valid for ${owner}/${repo}`;
17
+ }
@@ -0,0 +1,57 @@
1
+ import path from 'path';
2
+ import { execSync } from 'child_process';
3
+ import { Config } from '../config.js';
4
+ /**
5
+ * Executes a Git command inside the specified working directory.
6
+ */
7
+ function runGitCommand(cmd, cwd) {
8
+ return execSync(cmd, { cwd, encoding: 'utf-8' }).trim();
9
+ }
10
+ /**
11
+ * Retrieves the owner and repo name from the Git remote URL inside the indexDir.
12
+ * This ensures we get the correct GitHub repo owner and name, regardless of current working directory.
13
+ */
14
+ function getRepoOwnerAndNameFromGit(indexDir) {
15
+ try {
16
+ const originUrl = runGitCommand('git config --get remote.origin.url', indexDir);
17
+ console.log(`🔗 Git origin URL from '${indexDir}': ${originUrl}`);
18
+ const match = originUrl.match(/github\.com[:/](.+?)(?:\.git)?$/);
19
+ if (!match)
20
+ throw new Error("❌ Could not parse GitHub repo from origin URL.");
21
+ const [owner, repo] = match[1].split('/');
22
+ console.log(`✅ Parsed from Git: owner='${owner}', repo='${repo}'`);
23
+ return { owner, repo };
24
+ }
25
+ catch (error) {
26
+ console.warn(`⚠️ Failed to parse GitHub info from Git config in '${indexDir}': ${error instanceof Error ? error.message : error}`);
27
+ throw error;
28
+ }
29
+ }
30
+ /**
31
+ * Fallback: Extracts GitHub repo owner and name from the index directory path.
32
+ */
33
+ function getRepoOwnerAndNameFromIndexDir(indexDir) {
34
+ const parts = path.resolve(indexDir).split(path.sep);
35
+ const repo = parts.pop();
36
+ const owner = parts.pop();
37
+ console.log(`📁 Parsed from indexDir: owner='${owner}', repo='${repo}'`);
38
+ return { owner, repo };
39
+ }
40
+ /**
41
+ * Get the GitHub repo details, always from the configured indexDir.
42
+ * Prefers Git config, falls back to parsing the path.
43
+ */
44
+ export function getRepoDetails() {
45
+ const indexDir = Config.getIndexDir();
46
+ if (!indexDir) {
47
+ throw new Error("❌ indexDir is not configured.");
48
+ }
49
+ console.log(`📦 Resolving GitHub repo info from indexDir: ${indexDir}`);
50
+ try {
51
+ return getRepoOwnerAndNameFromGit(indexDir);
52
+ }
53
+ catch {
54
+ console.log("🔁 Falling back to extracting from indexDir path...");
55
+ return getRepoOwnerAndNameFromIndexDir(indexDir);
56
+ }
57
+ }
@@ -0,0 +1,14 @@
1
+ // Utility function to prompt for GitHub token
2
+ import readline from 'readline';
3
+ export function promptForToken() {
4
+ return new Promise((resolve) => {
5
+ const rl = readline.createInterface({
6
+ input: process.stdin,
7
+ output: process.stdout,
8
+ });
9
+ rl.question('Paste your GitHub Personal Access Token (scopes: `repo`): ', (token) => {
10
+ rl.close();
11
+ resolve(token);
12
+ });
13
+ });
14
+ }
@@ -0,0 +1 @@
1
+ "use strict";
package/dist/index.js CHANGED
@@ -22,6 +22,9 @@ import { runAskCommand } from './commands/AskCmd.js';
22
22
  import { runBackupCommand } from './commands/BackupCmd.js';
23
23
  import { runMigrateCommand } from "./commands/MigrateCmd.js";
24
24
  import { runInspectCommand } from "./commands/InspectCmd.js";
25
+ import { reviewPullRequestCmd } from "./commands/ReviewCmd.js";
26
+ import { promptForToken } from "./github/token.js";
27
+ import { validateGitHubTokenAgainstRepo } from "./github/githubAuthCheck.js";
25
28
  // 🎛️ CLI Setup
26
29
  const cmd = new Command('scai')
27
30
  .version(version)
@@ -47,8 +50,59 @@ cmd
47
50
  defineSuggCommand(cmd);
48
51
  // 🔧 Group: Git-related commands
49
52
  const git = cmd.command('git').description('Git utilities');
53
+ git
54
+ .command('review')
55
+ .description('Review an open pull request using AI')
56
+ .option('-a, --all', 'Show all PRs requiring a review (not just for the current user)', false) // New option for showing all PRs
57
+ .action(async (cmd) => {
58
+ const showAll = cmd.all; // Access the flag passed via command line
59
+ await reviewPullRequestCmd('main', showAll); // Pass the flag to the review function
60
+ });
50
61
  // Register `sugg` under `git` group
51
62
  defineSuggCommand(git);
63
+ // Add auth-related commands
64
+ const auth = cmd.command('auth').description('GitHub authentication commands');
65
+ auth
66
+ .command('check')
67
+ .description('Check if GitHub authentication is set up and valid')
68
+ .action(async () => {
69
+ try {
70
+ const token = Config.getGitHubToken();
71
+ if (!token) {
72
+ console.log('❌ GitHub authentication not found. Please set your token.');
73
+ return;
74
+ }
75
+ // Call the new check
76
+ const result = await validateGitHubTokenAgainstRepo();
77
+ console.log(result);
78
+ }
79
+ catch (err) {
80
+ console.error(typeof err === 'string' ? err : err.message);
81
+ }
82
+ });
83
+ auth
84
+ .command('reset')
85
+ .description('Reset GitHub authentication credentials')
86
+ .action(() => {
87
+ Config.setGitHubToken(''); // Clears the GitHub token from the config
88
+ console.log('🔄 GitHub authentication has been reset.');
89
+ // Check if the token is successfully removed
90
+ const token = Config.getGitHubToken();
91
+ if (!token) {
92
+ console.log('✅ Token successfully removed from configuration.');
93
+ }
94
+ else {
95
+ console.log('❌ Token still exists in the configuration.');
96
+ }
97
+ });
98
+ auth
99
+ .command('set')
100
+ .description('Set your GitHub Personal Access Token')
101
+ .action(async () => {
102
+ const token = await promptForToken();
103
+ Config.setGitHubToken(token.trim());
104
+ console.log('🔑 GitHub token set successfully.');
105
+ });
52
106
  // 🛠️ Group: `gen` commands for content generation
53
107
  const gen = cmd.command('gen').description('Generate code-related output');
54
108
  gen
@@ -0,0 +1,21 @@
1
+ import { generate } from '../../lib/generate.js';
2
+ import { Config } from '../../config.js';
3
+ export const reviewModule = {
4
+ name: 'review',
5
+ description: 'Reviews code diff or PR content and provides feedback',
6
+ async run({ content, filepath }) {
7
+ const model = Config.getModel();
8
+ const prompt = `
9
+ You are a senior software engineer reviewing a pull request.
10
+ Give clear, constructive feedback based on the code changes below.
11
+
12
+ Changes:
13
+ ${content}
14
+ `.trim();
15
+ const response = await generate({ content: prompt, filepath }, model);
16
+ return {
17
+ content: response.content,
18
+ filepath,
19
+ };
20
+ }
21
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scai",
3
- "version": "0.1.50",
3
+ "version": "0.1.51",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "scai": "./dist/index.js"