@codecora/cli 0.0.3
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 +242 -0
- package/bin/cora.js +183 -0
- package/dist/api/client.js +221 -0
- package/dist/api/types.js +33 -0
- package/dist/commands/auth.js +255 -0
- package/dist/commands/hook.js +160 -0
- package/dist/commands/index.js +8 -0
- package/dist/commands/review.js +215 -0
- package/dist/config/storage.js +166 -0
- package/dist/git/diff.js +162 -0
- package/dist/index.js +12 -0
- package/dist/utils/exec.js +76 -0
- package/dist/version.js +1 -0
- package/package.json +95 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Commands
|
|
3
|
+
*
|
|
4
|
+
* Handles login/logout for CLI via GitHub OAuth.
|
|
5
|
+
*/
|
|
6
|
+
import { createApiClient } from '../api/client.js';
|
|
7
|
+
import { getLocalConfig, isAuthenticated, setAuthToken, clearAuthToken, setLocalConfig, } from '../config/storage.js';
|
|
8
|
+
import * as http from 'node:http';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
/**
|
|
11
|
+
* Login command
|
|
12
|
+
*
|
|
13
|
+
* Opens browser for GitHub OAuth flow.
|
|
14
|
+
* The server now exchanges the OAuth code and returns the session token directly.
|
|
15
|
+
*/
|
|
16
|
+
export async function login(serverUrl) {
|
|
17
|
+
// Check if already logged in
|
|
18
|
+
if (await isAuthenticated()) {
|
|
19
|
+
const config = await getLocalConfig();
|
|
20
|
+
console.log(`Already logged in as ${config.user?.email}`);
|
|
21
|
+
console.log('Run "cora auth logout" to sign out first.');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// Get server URL from config or parameter
|
|
25
|
+
const config = await getLocalConfig();
|
|
26
|
+
const targetServerUrl = serverUrl || config.auth?.serverUrl || 'https://codecora.dev';
|
|
27
|
+
console.log(`Logging in to ${targetServerUrl}...`);
|
|
28
|
+
// Create local HTTP server for callback
|
|
29
|
+
const { port, promise } = createCallbackServer();
|
|
30
|
+
// Build OAuth URL
|
|
31
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
32
|
+
const authUrl = `${targetServerUrl}/api/auth/cli?redirect_uri=${encodeURIComponent(redirectUri)}`;
|
|
33
|
+
// Open browser
|
|
34
|
+
console.log('\nOpening browser for GitHub authorization...');
|
|
35
|
+
console.log(`If browser doesn't open, visit:\n ${authUrl}\n`);
|
|
36
|
+
try {
|
|
37
|
+
await openBrowser(authUrl);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.log(`\nPlease open this URL in your browser:\n ${authUrl}\n`);
|
|
41
|
+
}
|
|
42
|
+
// Wait for callback
|
|
43
|
+
console.log('Waiting for authorization...');
|
|
44
|
+
try {
|
|
45
|
+
const authResult = await promise;
|
|
46
|
+
if (authResult instanceof Error) {
|
|
47
|
+
throw authResult;
|
|
48
|
+
}
|
|
49
|
+
// Store session directly from server response
|
|
50
|
+
await setAuthToken(authResult.token, targetServerUrl, authResult.userId, authResult.email, authResult.expiresAt);
|
|
51
|
+
console.log(`\nSuccessfully logged in as ${authResult.email}`);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw new Error(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Logout command
|
|
60
|
+
*
|
|
61
|
+
* Clears stored session token.
|
|
62
|
+
*/
|
|
63
|
+
export async function logout() {
|
|
64
|
+
if (!(await isAuthenticated())) {
|
|
65
|
+
console.log('Not logged in.');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const config = await getLocalConfig();
|
|
69
|
+
console.log(`Logging out ${config.user?.email}...`);
|
|
70
|
+
await clearAuthToken();
|
|
71
|
+
console.log('Logged out successfully.');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Status command
|
|
75
|
+
*
|
|
76
|
+
* Shows current authentication status.
|
|
77
|
+
*/
|
|
78
|
+
export async function status() {
|
|
79
|
+
const config = await getLocalConfig();
|
|
80
|
+
if (!config.auth?.sessionToken) {
|
|
81
|
+
console.log('Status: Not logged in');
|
|
82
|
+
console.log('Run "cora auth login" to authenticate.');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
console.log('Status: Authenticated');
|
|
86
|
+
console.log(`Email: ${config.user?.email || 'Unknown'}`);
|
|
87
|
+
console.log(`User ID: ${config.user?.id || 'Unknown'}`);
|
|
88
|
+
console.log(`Server: ${config.auth?.serverUrl || 'Unknown'}`);
|
|
89
|
+
// Verify session with server
|
|
90
|
+
try {
|
|
91
|
+
const client = await createApiClient();
|
|
92
|
+
const verification = await client.verifySession();
|
|
93
|
+
if (verification.valid) {
|
|
94
|
+
console.log('Session: Valid');
|
|
95
|
+
if (verification.expiresAt) {
|
|
96
|
+
const expires = new Date(verification.expiresAt);
|
|
97
|
+
console.log(`Expires: ${expires.toLocaleString()}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.log('Session: Invalid or expired');
|
|
102
|
+
console.log('Please run "cora auth login" to re-authenticate.');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.log('Session verification failed:', error instanceof Error ? error.message : String(error));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Config command
|
|
111
|
+
*
|
|
112
|
+
* Set or view configuration.
|
|
113
|
+
*/
|
|
114
|
+
export async function config(key, value) {
|
|
115
|
+
const currentConfig = await getLocalConfig();
|
|
116
|
+
if (!key) {
|
|
117
|
+
// Show all config
|
|
118
|
+
console.log('Current configuration:');
|
|
119
|
+
console.log(JSON.stringify(currentConfig, null, 2));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (value === undefined) {
|
|
123
|
+
// Show specific config value
|
|
124
|
+
const keys = key.split('.');
|
|
125
|
+
let currentValue = currentConfig;
|
|
126
|
+
for (const k of keys) {
|
|
127
|
+
currentValue = currentValue?.[k];
|
|
128
|
+
}
|
|
129
|
+
console.log(`${key}: ${currentValue !== undefined ? JSON.stringify(currentValue) : 'not set'}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Set config value
|
|
133
|
+
console.log(`Setting ${key} = ${value}`);
|
|
134
|
+
const updates = {};
|
|
135
|
+
let updateTarget = updates;
|
|
136
|
+
const keys = key.split('.');
|
|
137
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
138
|
+
const k = keys[i];
|
|
139
|
+
if (k) {
|
|
140
|
+
updateTarget[k] = {};
|
|
141
|
+
updateTarget = updateTarget[k];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const lastKey = keys[keys.length - 1];
|
|
145
|
+
if (lastKey) {
|
|
146
|
+
updateTarget[lastKey] = value;
|
|
147
|
+
}
|
|
148
|
+
await setLocalConfig({ ...currentConfig, ...updates });
|
|
149
|
+
console.log('Configuration updated.');
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Create local HTTP server for OAuth callback
|
|
153
|
+
*
|
|
154
|
+
* Uses a fixed port (4200) for GitHub OAuth redirect URI configuration.
|
|
155
|
+
* GitHub OAuth requires pre-configured redirect URIs, so we use
|
|
156
|
+
* http://localhost:4200/callback consistently.
|
|
157
|
+
*
|
|
158
|
+
* The server now receives the session token directly from the server
|
|
159
|
+
* instead of the OAuth code, simplifying the flow.
|
|
160
|
+
*/
|
|
161
|
+
function createCallbackServer() {
|
|
162
|
+
let resolvePromise;
|
|
163
|
+
const promise = new Promise((resolve) => {
|
|
164
|
+
resolvePromise = resolve;
|
|
165
|
+
});
|
|
166
|
+
// Use fixed port for GitHub OAuth compatibility
|
|
167
|
+
// Users must add http://localhost:4200/callback to their GitHub App redirect URIs
|
|
168
|
+
const FIXED_PORT = 4200;
|
|
169
|
+
const server = http.createServer((req, res) => {
|
|
170
|
+
if (req.url?.startsWith('/callback')) {
|
|
171
|
+
const url = new URL(req.url, `http://localhost:${FIXED_PORT}`);
|
|
172
|
+
const token = url.searchParams.get('token');
|
|
173
|
+
const userId = url.searchParams.get('userId');
|
|
174
|
+
const email = url.searchParams.get('email');
|
|
175
|
+
const expiresAt = url.searchParams.get('expiresAt');
|
|
176
|
+
const error = url.searchParams.get('error');
|
|
177
|
+
if (error) {
|
|
178
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
179
|
+
res.end(`Authentication error: ${error}`);
|
|
180
|
+
resolvePromise(error);
|
|
181
|
+
server.close();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (token && userId && email && expiresAt) {
|
|
185
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
186
|
+
res.end(`
|
|
187
|
+
<!DOCTYPE html>
|
|
188
|
+
<html>
|
|
189
|
+
<head><title>Authentication Successful</title></head>
|
|
190
|
+
<body>
|
|
191
|
+
<h1>Authentication Successful!</h1>
|
|
192
|
+
<p>You can close this window and return to the terminal.</p>
|
|
193
|
+
</body>
|
|
194
|
+
</html>
|
|
195
|
+
`);
|
|
196
|
+
resolvePromise({ token, userId, email, expiresAt });
|
|
197
|
+
server.close();
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
201
|
+
res.end('Missing required parameters');
|
|
202
|
+
resolvePromise(new Error('Missing required parameters'));
|
|
203
|
+
server.close();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
server.listen(FIXED_PORT, () => {
|
|
208
|
+
// Server started on fixed port
|
|
209
|
+
});
|
|
210
|
+
// Handle port already in use
|
|
211
|
+
server.on('error', (error) => {
|
|
212
|
+
if (error.code === 'EADDRINUSE') {
|
|
213
|
+
resolvePromise(new Error(`Port ${FIXED_PORT} is already in use. Please stop the other process and try again.`));
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
resolvePromise(error);
|
|
217
|
+
}
|
|
218
|
+
server.close();
|
|
219
|
+
});
|
|
220
|
+
return { port: FIXED_PORT, promise };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Open browser for OAuth flow
|
|
224
|
+
*/
|
|
225
|
+
async function openBrowser(url) {
|
|
226
|
+
const platform = os.platform();
|
|
227
|
+
let command;
|
|
228
|
+
let args;
|
|
229
|
+
switch (platform) {
|
|
230
|
+
case 'darwin':
|
|
231
|
+
command = 'open';
|
|
232
|
+
args = [url];
|
|
233
|
+
break;
|
|
234
|
+
case 'win32':
|
|
235
|
+
command = 'cmd';
|
|
236
|
+
args = ['/c', 'start', '', url];
|
|
237
|
+
break;
|
|
238
|
+
default:
|
|
239
|
+
command = 'xdg-open';
|
|
240
|
+
args = [url];
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
const { execFile } = await import('node:child_process');
|
|
244
|
+
return new Promise((resolve, reject) => {
|
|
245
|
+
execFile(command, args, (error) => {
|
|
246
|
+
if (error) {
|
|
247
|
+
// Browser open failed, but user can open URL manually
|
|
248
|
+
reject(error);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
resolve();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook Commands
|
|
3
|
+
*
|
|
4
|
+
* Manage git hooks for automatic code review.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { execFileSafe } from '../utils/exec.js';
|
|
9
|
+
/**
|
|
10
|
+
* Hook script template
|
|
11
|
+
*/
|
|
12
|
+
const HOOK_SCRIPT = `#!/bin/sh
|
|
13
|
+
# CORA Pre-commit Hook
|
|
14
|
+
# Auto-generated by cora cli
|
|
15
|
+
|
|
16
|
+
# Skip if CORA_SKIP is set
|
|
17
|
+
if [ -n "$CORA_SKIP" ]; then
|
|
18
|
+
exit 0
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
# Run cora review
|
|
22
|
+
npx cora review --staged --quiet
|
|
23
|
+
|
|
24
|
+
# Exit with cora's exit code
|
|
25
|
+
exit $?
|
|
26
|
+
`;
|
|
27
|
+
/**
|
|
28
|
+
* Install git hook
|
|
29
|
+
*
|
|
30
|
+
* Installs pre-commit hook for automatic review.
|
|
31
|
+
*/
|
|
32
|
+
export async function installHook(type = 'pre-commit') {
|
|
33
|
+
const gitDir = getGitDir();
|
|
34
|
+
if (!gitDir) {
|
|
35
|
+
throw new Error('Not in a git repository');
|
|
36
|
+
}
|
|
37
|
+
const hooksDir = path.join(gitDir, 'hooks');
|
|
38
|
+
// Create hooks directory if it doesn't exist
|
|
39
|
+
if (!existsSync(hooksDir)) {
|
|
40
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
const hookPath = path.join(hooksDir, type);
|
|
43
|
+
// Check if hook already exists
|
|
44
|
+
if (existsSync(hookPath)) {
|
|
45
|
+
const existing = readFileSync(hookPath, 'utf-8');
|
|
46
|
+
if (existing.includes('# CORA Pre-commit Hook')) {
|
|
47
|
+
console.log(`Hook already installed: ${hookPath}`);
|
|
48
|
+
console.log('Use "cora hook uninstall" to remove it first.');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Backup existing hook
|
|
52
|
+
const backupPath = `${hookPath}.backup`;
|
|
53
|
+
writeFileSync(backupPath, existing, { mode: 0o755 });
|
|
54
|
+
console.log(`Backed up existing hook to: ${backupPath}`);
|
|
55
|
+
}
|
|
56
|
+
// Write hook script
|
|
57
|
+
writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
|
|
58
|
+
console.log(`Installed ${type} hook: ${hookPath}`);
|
|
59
|
+
console.log('\nHook will run automatically on git ${type}.');
|
|
60
|
+
console.log('Skip temporarily with: CORA_SKIP=1 git commit');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Uninstall git hook
|
|
64
|
+
*
|
|
65
|
+
* Removes pre-commit hook.
|
|
66
|
+
*/
|
|
67
|
+
export async function uninstallHook(type = 'pre-commit') {
|
|
68
|
+
const gitDir = getGitDir();
|
|
69
|
+
if (!gitDir) {
|
|
70
|
+
throw new Error('Not in a git repository');
|
|
71
|
+
}
|
|
72
|
+
const hookPath = path.join(gitDir, 'hooks', type);
|
|
73
|
+
if (!existsSync(hookPath)) {
|
|
74
|
+
console.log(`Hook not installed: ${type}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Check if it's a cora hook
|
|
78
|
+
const content = readFileSync(hookPath, 'utf-8');
|
|
79
|
+
if (!content.includes('# CORA Pre-commit Hook')) {
|
|
80
|
+
console.log(`Hook exists but was not installed by cora: ${type}`);
|
|
81
|
+
console.log('To remove it manually, delete:', hookPath);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Remove hook
|
|
85
|
+
unlinkSync(hookPath);
|
|
86
|
+
console.log(`Uninstalled ${type} hook`);
|
|
87
|
+
// Restore backup if exists
|
|
88
|
+
const backupPath = `${hookPath}.backup`;
|
|
89
|
+
if (existsSync(backupPath)) {
|
|
90
|
+
const backup = readFileSync(backupPath, 'utf-8');
|
|
91
|
+
writeFileSync(hookPath, backup, { mode: 0o755 });
|
|
92
|
+
console.log(`Restored backup from: ${backupPath}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* List installed hooks
|
|
97
|
+
*/
|
|
98
|
+
export async function listHooks() {
|
|
99
|
+
const gitDir = getGitDir();
|
|
100
|
+
if (!gitDir) {
|
|
101
|
+
throw new Error('Not in a git repository');
|
|
102
|
+
}
|
|
103
|
+
const hooksDir = path.join(gitDir, 'hooks');
|
|
104
|
+
const hookTypes = ['pre-commit', 'pre-push'];
|
|
105
|
+
console.log('Installed hooks:\n');
|
|
106
|
+
let found = false;
|
|
107
|
+
for (const type of hookTypes) {
|
|
108
|
+
const hookPath = path.join(hooksDir, type);
|
|
109
|
+
if (!existsSync(hookPath)) {
|
|
110
|
+
console.log(` ${type}: Not installed`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const content = readFileSync(hookPath, 'utf-8');
|
|
114
|
+
const isCoraHook = content.includes('# CORA Pre-commit Hook');
|
|
115
|
+
if (isCoraHook) {
|
|
116
|
+
console.log(` ${type}: ā Installed`);
|
|
117
|
+
found = true;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
console.log(` ${type}: Other hook (not managed by cora)`);
|
|
121
|
+
found = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!found) {
|
|
125
|
+
console.log(' No hooks installed');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Get .git directory path
|
|
130
|
+
*/
|
|
131
|
+
function getGitDir() {
|
|
132
|
+
// Try git rev-parse --git-dir
|
|
133
|
+
const result = execFileSafe('git', ['rev-parse', '--git-dir']);
|
|
134
|
+
if (result.status === 0 && result.stdout.trim()) {
|
|
135
|
+
return result.stdout.trim();
|
|
136
|
+
}
|
|
137
|
+
// Fallback to .git in current directory
|
|
138
|
+
const cwd = process.cwd();
|
|
139
|
+
const gitPath = path.join(cwd, '.git');
|
|
140
|
+
if (existsSync(gitPath)) {
|
|
141
|
+
return gitPath;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Enable auto-review
|
|
147
|
+
*
|
|
148
|
+
* Alias for installHook for convenience.
|
|
149
|
+
*/
|
|
150
|
+
export async function enable() {
|
|
151
|
+
await installHook();
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Disable auto-review
|
|
155
|
+
*
|
|
156
|
+
* Alias for uninstallHook for convenience.
|
|
157
|
+
*/
|
|
158
|
+
export async function disable() {
|
|
159
|
+
await uninstallHook();
|
|
160
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Commands Export
|
|
3
|
+
*
|
|
4
|
+
* Central export for all CLI commands.
|
|
5
|
+
*/
|
|
6
|
+
export { login, logout, status, config as authConfig } from './auth.js';
|
|
7
|
+
export { review, autoReview } from './review.js';
|
|
8
|
+
export { installHook, uninstallHook, listHooks, enable, disable } from './hook.js';
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review Command
|
|
3
|
+
*
|
|
4
|
+
* Main command for reviewing code diffs.
|
|
5
|
+
*/
|
|
6
|
+
import { CLIApiClient, createApiClient } from '../api/client.js';
|
|
7
|
+
import { isAuthenticated } from '../config/storage.js';
|
|
8
|
+
import { getReviewDiff, getRepoInfo, validateDiffSize, isGitRepo, } from '../git/diff.js';
|
|
9
|
+
/**
|
|
10
|
+
* Review code changes
|
|
11
|
+
*
|
|
12
|
+
* @param options - Review options from command line
|
|
13
|
+
* @returns Exit code
|
|
14
|
+
*/
|
|
15
|
+
export async function review(options = {}) {
|
|
16
|
+
// Check authentication
|
|
17
|
+
if (!(await isAuthenticated())) {
|
|
18
|
+
console.error('Error: Not authenticated. Run "cora auth login" first.');
|
|
19
|
+
return 2; // CLIExitCode.AuthError
|
|
20
|
+
}
|
|
21
|
+
// Check if in git repo
|
|
22
|
+
const cwd = process.cwd();
|
|
23
|
+
if (!isGitRepo(cwd)) {
|
|
24
|
+
console.error('Error: Not in a git repository.');
|
|
25
|
+
return 1; // CLIExitCode.GeneralError
|
|
26
|
+
}
|
|
27
|
+
// Get repository info
|
|
28
|
+
const repoInfo = getRepoInfo(cwd);
|
|
29
|
+
const repository = options.repository || `${repoInfo.owner}/${repoInfo.repo}`;
|
|
30
|
+
const branch = options.branch || repoInfo.branch;
|
|
31
|
+
// Get diff
|
|
32
|
+
console.log('Fetching code changes...');
|
|
33
|
+
let diff;
|
|
34
|
+
try {
|
|
35
|
+
diff = getReviewDiff({
|
|
36
|
+
staged: options.staged !== false,
|
|
37
|
+
files: options.files,
|
|
38
|
+
cwd,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
console.error(`Error: Failed to get diff: ${error instanceof Error ? error.message : String(error)}`);
|
|
43
|
+
return 1; // CLIExitCode.GeneralError
|
|
44
|
+
}
|
|
45
|
+
if (!diff.trim()) {
|
|
46
|
+
console.log('No changes to review.');
|
|
47
|
+
return 0; // CLIExitCode.Success
|
|
48
|
+
}
|
|
49
|
+
// Validate diff size
|
|
50
|
+
const sizeCheck = validateDiffSize(diff);
|
|
51
|
+
if (!sizeCheck.valid) {
|
|
52
|
+
console.error(`Error: ${sizeCheck.error}`);
|
|
53
|
+
return 1; // CLIExitCode.GeneralError
|
|
54
|
+
}
|
|
55
|
+
console.log(`Diff size: ${(sizeCheck.size / 1024).toFixed(2)} KB`);
|
|
56
|
+
// Mock mode for testing
|
|
57
|
+
if (options.mock) {
|
|
58
|
+
return mockReview(diff, options);
|
|
59
|
+
}
|
|
60
|
+
// Create API client
|
|
61
|
+
let client;
|
|
62
|
+
try {
|
|
63
|
+
client = await createApiClient();
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
console.error(`Error: Failed to create API client: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
return 1; // CLIExitCode.GeneralError
|
|
68
|
+
}
|
|
69
|
+
// Send review request
|
|
70
|
+
console.log('Reviewing code...');
|
|
71
|
+
try {
|
|
72
|
+
const response = await client.reviewDiff({
|
|
73
|
+
diff,
|
|
74
|
+
workspaceId: options.workspace,
|
|
75
|
+
repository,
|
|
76
|
+
branch,
|
|
77
|
+
});
|
|
78
|
+
return displayResults(response, options.format);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const apiError = error;
|
|
82
|
+
if (apiError.statusCode === 429) {
|
|
83
|
+
console.error('Error: Quota exceeded. Please upgrade your plan or wait for quota reset.');
|
|
84
|
+
return 3; // CLIExitCode.QuotaExceeded
|
|
85
|
+
}
|
|
86
|
+
console.error(`Error: Review failed: ${apiError.message || String(error)}`);
|
|
87
|
+
return 1; // CLIExitCode.GeneralError
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Display review results
|
|
92
|
+
*/
|
|
93
|
+
function displayResults(response, format = 'pretty') {
|
|
94
|
+
const { success, issues, summary, walkthrough, tokensUsed, quotaRemaining, quotaResetAt, shouldBlock, blockReason } = response;
|
|
95
|
+
// Clear the "Reviewing code..." line
|
|
96
|
+
process.stdout.write('\r' + ' '.repeat(50) + '\r');
|
|
97
|
+
// Display summary
|
|
98
|
+
console.log('\n' + '='.repeat(60));
|
|
99
|
+
console.log('REVIEW SUMMARY');
|
|
100
|
+
console.log('='.repeat(60));
|
|
101
|
+
console.log(summary);
|
|
102
|
+
console.log('='.repeat(60));
|
|
103
|
+
// Group issues by severity
|
|
104
|
+
const bySeverity = {
|
|
105
|
+
critical: issues.filter((i) => i.severity === 'critical'),
|
|
106
|
+
major: issues.filter((i) => i.severity === 'major'),
|
|
107
|
+
minor: issues.filter((i) => i.severity === 'minor'),
|
|
108
|
+
info: issues.filter((i) => i.severity === 'info'),
|
|
109
|
+
};
|
|
110
|
+
// Display issues by severity
|
|
111
|
+
for (const severity of ['critical', 'major', 'minor', 'info']) {
|
|
112
|
+
const severityIssues = bySeverity[severity] ?? [];
|
|
113
|
+
if (severityIssues.length === 0)
|
|
114
|
+
continue;
|
|
115
|
+
console.log(`\n${severity.toUpperCase()} (${severityIssues.length}):`);
|
|
116
|
+
console.log('-'.repeat(60));
|
|
117
|
+
for (const issue of severityIssues) {
|
|
118
|
+
displayIssue(issue, format);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Display walkthrough if requested
|
|
122
|
+
if (walkthrough) {
|
|
123
|
+
console.log('\n' + '='.repeat(60));
|
|
124
|
+
console.log('WALKTHROUGH');
|
|
125
|
+
console.log('='.repeat(60));
|
|
126
|
+
console.log(walkthrough);
|
|
127
|
+
console.log('='.repeat(60));
|
|
128
|
+
}
|
|
129
|
+
// Display usage info
|
|
130
|
+
console.log(`\nTokens used: ${tokensUsed.toLocaleString()}`);
|
|
131
|
+
console.log(`Quota remaining: ${quotaRemaining.toLocaleString()}`);
|
|
132
|
+
if (quotaResetAt) {
|
|
133
|
+
const resetDate = new Date(quotaResetAt);
|
|
134
|
+
console.log(`Quota resets: ${resetDate.toLocaleString()}`);
|
|
135
|
+
}
|
|
136
|
+
// Handle blocking
|
|
137
|
+
if (shouldBlock) {
|
|
138
|
+
console.error(`\nā COMMIT BLOCKED: ${blockReason || 'Issues found that must be resolved.'}`);
|
|
139
|
+
return 4; // CLIExitCode.BlockedByIssues
|
|
140
|
+
}
|
|
141
|
+
if (issues.some((i) => i.severity === 'critical')) {
|
|
142
|
+
console.log('\nā ļø Critical issues found. Please review before committing.');
|
|
143
|
+
}
|
|
144
|
+
return success ? 0 : 1;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Display a single issue
|
|
148
|
+
*/
|
|
149
|
+
function displayIssue(issue, format) {
|
|
150
|
+
const location = issue.line ? `${issue.file}:${issue.line}` : issue.file;
|
|
151
|
+
if (format === 'json') {
|
|
152
|
+
console.log(JSON.stringify(issue));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (format === 'compact') {
|
|
156
|
+
console.log(` [${issue.severity.toUpperCase()}] ${location}: ${issue.title}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Pretty format (default)
|
|
160
|
+
const severityIcons = {
|
|
161
|
+
critical: 'š“',
|
|
162
|
+
major: 'š ',
|
|
163
|
+
minor: 'š”',
|
|
164
|
+
info: 'šµ',
|
|
165
|
+
};
|
|
166
|
+
console.log(`\n${severityIcons[issue.severity] || 'ā¢'} ${issue.title}`);
|
|
167
|
+
console.log(` Location: ${location}`);
|
|
168
|
+
console.log(` Type: ${issue.issueType}`);
|
|
169
|
+
if (issue.body) {
|
|
170
|
+
console.log(` Description: ${issue.body}`);
|
|
171
|
+
}
|
|
172
|
+
if (issue.suggestedFix) {
|
|
173
|
+
console.log(` Suggested fix: ${issue.suggestedFix}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Mock review for testing
|
|
178
|
+
*/
|
|
179
|
+
function mockReview(diff, options) {
|
|
180
|
+
console.log('Running in MOCK mode (no API call)');
|
|
181
|
+
const lines = diff.split('\n').length;
|
|
182
|
+
const files = diff.match(/^diff --git a\/(.+?) b/m)?.[1] || 'unknown';
|
|
183
|
+
return displayResults({
|
|
184
|
+
success: true,
|
|
185
|
+
issues: [
|
|
186
|
+
{
|
|
187
|
+
file: files,
|
|
188
|
+
line: 1,
|
|
189
|
+
severity: 'info',
|
|
190
|
+
issueType: 'best_practice',
|
|
191
|
+
title: 'Mock Review (Testing Mode)',
|
|
192
|
+
body: `This is a mock review result. The actual diff has ${lines} lines.`,
|
|
193
|
+
suggestedFix: 'Run without --mock flag for real review.',
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
summary: 'Mock review completed successfully.',
|
|
197
|
+
walkthrough: options.includeWalkthrough ? 'This is a mock walkthrough for testing purposes.' : undefined,
|
|
198
|
+
tokensUsed: 100,
|
|
199
|
+
quotaRemaining: 99900,
|
|
200
|
+
shouldBlock: false,
|
|
201
|
+
}, options.format);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Auto-review for pre-commit hook
|
|
205
|
+
*
|
|
206
|
+
* Simplified output suitable for git hooks.
|
|
207
|
+
*/
|
|
208
|
+
export async function autoReview(options = {}) {
|
|
209
|
+
// Force quiet mode for hooks
|
|
210
|
+
const result = await review({
|
|
211
|
+
...options,
|
|
212
|
+
format: 'compact',
|
|
213
|
+
});
|
|
214
|
+
return result;
|
|
215
|
+
}
|