commit-ai-agent 1.0.8 → 2.0.0
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/.env.example +0 -2
- package/README.md +134 -18
- package/bin/cli.js +97 -1
- package/package.json +1 -1
- package/public/app.js +191 -121
- package/public/index.html +71 -24
- package/public/style.css +161 -0
- package/src/config.js +4 -20
- package/src/git.js +0 -19
- package/src/hooks/installer.js +191 -0
- package/src/hooks/post-commit.js +90 -0
- package/src/hooks/pre-push.js +298 -0
- package/src/server.js +178 -45
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pre-push 훅 스크립트 — Secret / 자격증명 유출 탐지
|
|
4
|
+
* .git/hooks/pre-push 에서 실행됩니다.
|
|
5
|
+
*
|
|
6
|
+
* 사용: node pre-push.js <projectPath>
|
|
7
|
+
* exit 0 → push 허용 / exit 1 → push 차단
|
|
8
|
+
*/
|
|
9
|
+
import { execSync } from 'child_process';
|
|
10
|
+
import readline from 'readline';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
|
|
15
|
+
// 프로젝트 .env 로드 (GEMINI_API_KEY, PORT 등)
|
|
16
|
+
// Git Bash에서 $(pwd)는 /c/dev/... 형식으로 넘어옴 → Windows 경로로 변환
|
|
17
|
+
const rawPath = process.argv[2] || process.cwd();
|
|
18
|
+
const projectPath = rawPath.replace(/^\/([a-z])\//i, '$1:/');
|
|
19
|
+
try {
|
|
20
|
+
const envPath = path.join(projectPath, '.env');
|
|
21
|
+
if (fs.existsSync(envPath)) {
|
|
22
|
+
const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
const match = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
25
|
+
if (match && !process.env[match[1]]) {
|
|
26
|
+
process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, '');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
|
|
32
|
+
// SKIP_SECRET_SCAN=1 환경변수로 스캔 우회 가능
|
|
33
|
+
if (process.env.SKIP_SECRET_SCAN === '1') {
|
|
34
|
+
process.stderr.write(
|
|
35
|
+
'⚠️ commit-ai-agent: Secret 스캔 스킵 (SKIP_SECRET_SCAN=1)\n'
|
|
36
|
+
);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ────────────────────────────────────────────────────────
|
|
41
|
+
// 탐지 패턴 목록
|
|
42
|
+
// ────────────────────────────────────────────────────────
|
|
43
|
+
const SECRET_PATTERNS = [
|
|
44
|
+
{ name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/g, severity: 'critical' },
|
|
45
|
+
{ name: 'AWS Secret Key (40-char)', pattern: /(?<![A-Za-z0-9/+])([A-Za-z0-9/+]{40})(?![A-Za-z0-9/+])/g, severity: 'high' },
|
|
46
|
+
{ name: 'Google API Key', pattern: /AIza[0-9A-Za-z\-_]{35}/g, severity: 'critical' },
|
|
47
|
+
{ name: 'GitHub Personal Token', pattern: /ghp_[A-Za-z0-9]{36}/g, severity: 'critical' },
|
|
48
|
+
{ name: 'GitHub OAuth Token', pattern: /gho_[A-Za-z0-9]{36}/g, severity: 'critical' },
|
|
49
|
+
{ name: 'GitHub Actions Token', pattern: /github_pat_[A-Za-z0-9_]{82}/g, severity: 'critical' },
|
|
50
|
+
{ name: 'Slack Token', pattern: /xox[baprs]-[0-9a-zA-Z\-]{10,48}/g, severity: 'critical' },
|
|
51
|
+
{ name: 'Stripe Secret Key', pattern: /sk_(?:test|live)_[0-9a-zA-Z]{24}/g, severity: 'critical' },
|
|
52
|
+
{ name: 'Stripe Public Key', pattern: /pk_(?:test|live)_[0-9a-zA-Z]{24}/g, severity: 'high' },
|
|
53
|
+
{ name: 'Private Key PEM', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY/g, severity: 'critical' },
|
|
54
|
+
{ name: 'JWT Token', pattern: /eyJ[A-Za-z0-9\-_]{10,}\.eyJ[A-Za-z0-9\-_]{10,}\.[A-Za-z0-9\-_.+/]{20,}/g, severity: 'high' },
|
|
55
|
+
{ name: 'Password Assignment', pattern: /(?:password|passwd|pwd)\s*[:=]\s*['"]?(?!(?:your_|example|placeholder|changeme|dummy|test|sample|xxx)[A-Za-z0-9])[A-Za-z0-9!@#$%^&*_\-]{8,}/gi, severity: 'medium' },
|
|
56
|
+
{ name: 'API Key Assignment', pattern: /(?:api[_-]?key|apikey|api[_-]?secret)\s*[:=]\s*['"]?(?!your_|example|placeholder)[A-Za-z0-9_\-]{16,}/gi, severity: 'medium' },
|
|
57
|
+
{ name: 'Generic Token Assignment', pattern: /(?:access[_-]?token|auth[_-]?token)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{20,}/gi, severity: 'medium' },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// 알려진 공개 상수 — secret이 아님 (git 내부 SHA 등)
|
|
61
|
+
const SKIP_VALUES = new Set([
|
|
62
|
+
'0000000000000000000000000000000000000000', // git null SHA (새 브랜치)
|
|
63
|
+
'4b825dc642cb6eb9a060e54bf8d69288fbee4904', // git 빈 트리 SHA
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// 스캔 제외 파일/경로
|
|
67
|
+
const SKIP_FILENAMES = new Set([
|
|
68
|
+
'.gitignore', '.env.example', '.env.sample', '.env.template', '.env.test',
|
|
69
|
+
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
70
|
+
'CHANGELOG.md', 'README.md', 'LICENSE',
|
|
71
|
+
]);
|
|
72
|
+
const SKIP_EXTENSIONS = new Set([
|
|
73
|
+
'.min.js', '.map', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.ico',
|
|
74
|
+
'.pdf', '.woff', '.woff2', '.ttf', '.eot', '.zip', '.gz',
|
|
75
|
+
]);
|
|
76
|
+
const SKIP_DIRS = new Set([
|
|
77
|
+
'node_modules', '.git', 'dist', 'build', '.next', 'out',
|
|
78
|
+
'coverage', '__pycache__', '.venv', 'vendor',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
function shouldSkipFile(filePath) {
|
|
82
|
+
const basename = path.basename(filePath);
|
|
83
|
+
const ext = path.extname(filePath);
|
|
84
|
+
|
|
85
|
+
if (SKIP_FILENAMES.has(basename)) return true;
|
|
86
|
+
if (SKIP_EXTENSIONS.has(ext)) return true;
|
|
87
|
+
|
|
88
|
+
const parts = filePath.split(/[/\\]/);
|
|
89
|
+
for (const dir of SKIP_DIRS) {
|
|
90
|
+
if (parts.includes(dir)) return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 파일 내용에서 secret 패턴을 스캔합니다.
|
|
98
|
+
*/
|
|
99
|
+
function scanContent(content, filePath) {
|
|
100
|
+
const findings = [];
|
|
101
|
+
const isTestFile = /(?:test|spec|__test__|__mock__|fixture)/i.test(filePath);
|
|
102
|
+
const lines = content.split('\n');
|
|
103
|
+
|
|
104
|
+
for (const { name, pattern, severity } of SECRET_PATTERNS) {
|
|
105
|
+
// 테스트 파일에서는 medium severity 스킵 (오탐 多)
|
|
106
|
+
if (isTestFile && severity === 'medium') continue;
|
|
107
|
+
|
|
108
|
+
pattern.lastIndex = 0;
|
|
109
|
+
let match;
|
|
110
|
+
|
|
111
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
112
|
+
const upToMatch = content.slice(0, match.index);
|
|
113
|
+
const lineNumber = upToMatch.split('\n').length;
|
|
114
|
+
const lineContent = (lines[lineNumber - 1] || '').trim();
|
|
115
|
+
|
|
116
|
+
// 주석 줄 스킵
|
|
117
|
+
if (/^(?:\/\/|#|\*|<!--)/.test(lineContent)) continue;
|
|
118
|
+
|
|
119
|
+
const val = match[0];
|
|
120
|
+
|
|
121
|
+
// 알려진 공개 상수 스킵
|
|
122
|
+
if (SKIP_VALUES.has(val)) continue;
|
|
123
|
+
const masked =
|
|
124
|
+
val.length > 8
|
|
125
|
+
? val.slice(0, 4) + '****' + val.slice(-4)
|
|
126
|
+
: '****';
|
|
127
|
+
|
|
128
|
+
findings.push({
|
|
129
|
+
name,
|
|
130
|
+
severity,
|
|
131
|
+
file: filePath,
|
|
132
|
+
line: lineNumber,
|
|
133
|
+
masked,
|
|
134
|
+
lineContent: lineContent.slice(0, 120),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// 같은 줄에서 중복 탐지 방지
|
|
138
|
+
if (findings.filter((f) => f.file === filePath && f.line === lineNumber).length > 2) break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return findings;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* push될 커밋 범위에서 변경된 파일 목록을 가져옵니다.
|
|
147
|
+
*/
|
|
148
|
+
function getChangedFiles(localSha, remoteSha) {
|
|
149
|
+
try {
|
|
150
|
+
const isNewBranch = remoteSha === '0000000000000000000000000000000000000000';
|
|
151
|
+
const range = isNewBranch
|
|
152
|
+
? `4b825dc642cb6eb9a060e54bf8d69288fbee4904..${localSha}` // empty tree → HEAD
|
|
153
|
+
: `${remoteSha}..${localSha}`;
|
|
154
|
+
|
|
155
|
+
return execSync(`git diff --name-only ${range}`, {
|
|
156
|
+
cwd: projectPath,
|
|
157
|
+
encoding: 'utf-8',
|
|
158
|
+
})
|
|
159
|
+
.trim()
|
|
160
|
+
.split('\n')
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 특정 커밋 시점의 파일 내용을 가져옵니다.
|
|
169
|
+
*/
|
|
170
|
+
function getFileAtCommit(sha, filePath) {
|
|
171
|
+
try {
|
|
172
|
+
return execSync(`git show ${sha}:${filePath}`, {
|
|
173
|
+
cwd: projectPath,
|
|
174
|
+
encoding: 'utf-8',
|
|
175
|
+
maxBuffer: 1024 * 1024, // 1MB 제한
|
|
176
|
+
});
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Gemini AI로 regex 탐지 결과를 검증합니다 (오탐 필터링).
|
|
184
|
+
*/
|
|
185
|
+
async function verifyWithGemini(findings) {
|
|
186
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
187
|
+
if (!apiKey || apiKey === 'your_gemini_api_key_here') return findings;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const { GoogleGenerativeAI } = await import('@google/generative-ai');
|
|
191
|
+
const genAI = new GoogleGenerativeAI(apiKey);
|
|
192
|
+
const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash-lite' });
|
|
193
|
+
|
|
194
|
+
const prompt = `다음은 코드에서 regex로 발견된 잠재적 secret/credential 목록입니다.
|
|
195
|
+
각 항목이 실제 secret인지, 아니면 예시값/플레이스홀더/테스트값인지 판단하세요.
|
|
196
|
+
|
|
197
|
+
발견된 항목:
|
|
198
|
+
${findings.map((f, i) => `${i + 1}. [${f.name}] 파일: ${f.file}:${f.line}\n 코드: ${f.lineContent}`).join('\n')}
|
|
199
|
+
|
|
200
|
+
JSON 배열로만 응답하세요 (다른 텍스트 없이):
|
|
201
|
+
[{"index": 1, "isReal": true/false}]`;
|
|
202
|
+
|
|
203
|
+
const result = await model.generateContent(prompt);
|
|
204
|
+
const text = result.response.text().trim();
|
|
205
|
+
const jsonMatch = text.match(/\[[\s\S]*?\]/);
|
|
206
|
+
if (!jsonMatch) return findings;
|
|
207
|
+
|
|
208
|
+
const verdicts = JSON.parse(jsonMatch[0]);
|
|
209
|
+
return findings.filter((_, i) => {
|
|
210
|
+
const v = verdicts.find((item) => item.index === i + 1);
|
|
211
|
+
return !v || v.isReal !== false;
|
|
212
|
+
});
|
|
213
|
+
} catch {
|
|
214
|
+
return findings; // AI 실패 시 보수적으로 전체 유지
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 차단 경고 메시지를 stderr에 출력합니다.
|
|
220
|
+
*/
|
|
221
|
+
function printBlockWarning(findings) {
|
|
222
|
+
const line = '═'.repeat(54);
|
|
223
|
+
process.stderr.write(`\n╔${line}╗\n`);
|
|
224
|
+
process.stderr.write(`║ ⛔ commit-ai-agent: SECRET DETECTED ║\n`);
|
|
225
|
+
process.stderr.write(`╚${line}╝\n\n`);
|
|
226
|
+
process.stderr.write(`push가 차단됐습니다. 아래 항목을 확인하세요:\n\n`);
|
|
227
|
+
|
|
228
|
+
findings.forEach((f, i) => {
|
|
229
|
+
const badge = f.severity === 'critical' ? '[CRITICAL]' : `[${f.severity.toUpperCase()}]`;
|
|
230
|
+
process.stderr.write(` ${i + 1}. 파일: ${f.file}:${f.line} ${badge}\n`);
|
|
231
|
+
process.stderr.write(` 유형: ${f.name}\n`);
|
|
232
|
+
process.stderr.write(` 값: ${f.masked}\n\n`);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
process.stderr.write(`해결 방법:\n`);
|
|
236
|
+
process.stderr.write(` 1. 해당 파일에서 secret 제거\n`);
|
|
237
|
+
process.stderr.write(` 2. git commit --amend (마지막 커밋 수정)\n`);
|
|
238
|
+
process.stderr.write(` 3. 또는 git rebase -i 로 이전 커밋 수정\n`);
|
|
239
|
+
process.stderr.write(` ※ API 키가 이미 공개됐다면 즉시 무효화하세요!\n\n`);
|
|
240
|
+
process.stderr.write(`강제로 push하려면 (오탐인 경우):\n`);
|
|
241
|
+
process.stderr.write(` SKIP_SECRET_SCAN=1 git push\n\n`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function main() {
|
|
245
|
+
// stdin에서 push 대상 ref 목록 읽기
|
|
246
|
+
// 형식: <local-ref> SP <local-sha1> SP <remote-ref> SP <remote-sha1> LF
|
|
247
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
248
|
+
const refs = [];
|
|
249
|
+
|
|
250
|
+
await new Promise((resolve) => {
|
|
251
|
+
rl.on('line', (line) => {
|
|
252
|
+
const parts = line.trim().split(' ');
|
|
253
|
+
if (parts.length >= 4) {
|
|
254
|
+
refs.push({
|
|
255
|
+
localRef: parts[0],
|
|
256
|
+
localSha: parts[1],
|
|
257
|
+
remoteRef: parts[2],
|
|
258
|
+
remoteSha: parts[3],
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
rl.on('close', resolve);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (refs.length === 0) process.exit(0);
|
|
266
|
+
|
|
267
|
+
const allFindings = [];
|
|
268
|
+
|
|
269
|
+
for (const { localSha, remoteSha } of refs) {
|
|
270
|
+
// 삭제 ref (push 없음)
|
|
271
|
+
if (localSha === '0000000000000000000000000000000000000000') continue;
|
|
272
|
+
|
|
273
|
+
const changedFiles = getChangedFiles(localSha, remoteSha);
|
|
274
|
+
|
|
275
|
+
for (const filePath of changedFiles) {
|
|
276
|
+
if (shouldSkipFile(filePath)) continue;
|
|
277
|
+
|
|
278
|
+
const content = getFileAtCommit(localSha, filePath);
|
|
279
|
+
if (!content) continue;
|
|
280
|
+
|
|
281
|
+
const findings = scanContent(content, filePath);
|
|
282
|
+
allFindings.push(...findings);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (allFindings.length === 0) process.exit(0);
|
|
287
|
+
|
|
288
|
+
// AI 검증으로 오탐 필터링
|
|
289
|
+
const verified = await verifyWithGemini(allFindings);
|
|
290
|
+
|
|
291
|
+
if (verified.length === 0) process.exit(0);
|
|
292
|
+
|
|
293
|
+
printBlockWarning(verified);
|
|
294
|
+
process.exit(1); // push 차단
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 어떤 오류가 발생해도 push를 허용 (보안보다 개발 흐름 우선)
|
|
298
|
+
main().catch(() => process.exit(0));
|
package/src/server.js
CHANGED
|
@@ -3,15 +3,16 @@ import express from "express";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import fs from "fs";
|
|
6
|
-
import {
|
|
6
|
+
import { getLatestCommit, getWorkingStatus } from "./git.js";
|
|
7
7
|
import { analyzeCommit, analyzeWorkingStatus } from "./analyzer.js";
|
|
8
8
|
import { resolveDevRoot } from "./config.js";
|
|
9
|
+
import { installHooks, removeHooks, getHookStatus } from "./hooks/installer.js";
|
|
9
10
|
|
|
10
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const app = express();
|
|
12
13
|
|
|
13
14
|
const PORT = process.env.PORT || 50324;
|
|
14
|
-
const
|
|
15
|
+
const DEV_ROOT = resolveDevRoot();
|
|
15
16
|
const BAD_REQUEST_PREFIX = "[BAD_REQUEST]";
|
|
16
17
|
|
|
17
18
|
// npx/global install 시: COMMIT_ANALYZER_ROOT = bin/cli.js가 설정한 패키지 루트
|
|
@@ -46,37 +47,77 @@ function resolveProjectPath(projectName) {
|
|
|
46
47
|
throw createBadRequestError("프로젝트명이 필요합니다.");
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
throw createBadRequestError("프로젝트명이 비어 있습니다.");
|
|
50
|
+
if (projectName.trim() !== "__self__") {
|
|
51
|
+
throw createBadRequestError("유효하지 않은 프로젝트명입니다.");
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (!fs.existsSync(path.join(DEV_ROOT, ".git"))) {
|
|
57
|
-
throw createBadRequestError("현재 디렉토리가 Git 저장소가 아닙니다.");
|
|
58
|
-
}
|
|
59
|
-
return DEV_ROOT;
|
|
54
|
+
if (!fs.existsSync(path.join(DEV_ROOT, ".git"))) {
|
|
55
|
+
throw createBadRequestError("현재 디렉토리가 Git 저장소가 아닙니다.");
|
|
60
56
|
}
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const isOutsideRoot =
|
|
65
|
-
relativePath.startsWith("..") || path.isAbsolute(relativePath);
|
|
58
|
+
return DEV_ROOT;
|
|
59
|
+
}
|
|
66
60
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
// ──────────────────────────────────────────────
|
|
62
|
+
// SSE: 자동 분석 이벤트 브로드캐스트
|
|
63
|
+
// ──────────────────────────────────────────────
|
|
64
|
+
const sseClients = new Set();
|
|
70
65
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
66
|
+
// 최신 자동 분석 상태 (SSE 놓쳤을 때 폴링용)
|
|
67
|
+
let autoAnalysisState = { status: "idle", projectName: null, filename: null, content: null };
|
|
74
68
|
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
function broadcastEvent(data) {
|
|
70
|
+
const payload = `data: ${JSON.stringify(data)}\n\n`;
|
|
71
|
+
console.log(`[SSE] broadcast "${data.type}" → ${sseClients.size} client(s)`);
|
|
72
|
+
for (const client of sseClients) {
|
|
73
|
+
client.write(payload);
|
|
77
74
|
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ──────────────────────────────────────────────
|
|
78
|
+
// 백그라운드 분석 작업 큐 (post-commit 훅용)
|
|
79
|
+
// ──────────────────────────────────────────────
|
|
80
|
+
const analysisQueue = [];
|
|
81
|
+
let isProcessingQueue = false;
|
|
82
|
+
|
|
83
|
+
async function processNextQueueItem() {
|
|
84
|
+
if (isProcessingQueue || analysisQueue.length === 0) return;
|
|
85
|
+
isProcessingQueue = true;
|
|
78
86
|
|
|
79
|
-
|
|
87
|
+
const job = analysisQueue.shift();
|
|
88
|
+
autoAnalysisState = { status: "analyzing", projectName: job.projectName, filename: null, content: null };
|
|
89
|
+
broadcastEvent({ type: "analysis-started", projectName: job.projectName });
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const { projectPath, projectName } = job;
|
|
93
|
+
const commit = await getLatestCommit(projectPath);
|
|
94
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
95
|
+
if (apiKey && apiKey !== "your_gemini_api_key_here") {
|
|
96
|
+
const analysis = await analyzeCommit(commit, projectName, apiKey);
|
|
97
|
+
const timestamp = new Date()
|
|
98
|
+
.toISOString()
|
|
99
|
+
.replace(/[:.]/g, "-")
|
|
100
|
+
.slice(0, 19);
|
|
101
|
+
const reportFilename = `${projectName}-${timestamp}.md`;
|
|
102
|
+
const fullReport = `# 커밋 분석 리포트 (자동): ${projectName}\n\n> 생성 시각: ${new Date().toLocaleString("ko-KR")}\n> post-commit 훅에 의해 자동 생성됨\n\n## 커밋 정보\n| 항목 | 내용 |\n|---|---|\n| 해시 | \`${commit.shortHash}\` |\n| 메시지 | ${commit.message} |\n| 작성자 | ${commit.author} |\n| 날짜 | ${commit.date} |\n\n---\n\n${analysis}\n`;
|
|
103
|
+
fs.writeFileSync(path.join(REPORTS_DIR, reportFilename), fullReport, "utf-8");
|
|
104
|
+
autoAnalysisState = { status: "done", projectName, filename: reportFilename, content: fullReport };
|
|
105
|
+
broadcastEvent({ type: "analysis-done", projectName, filename: reportFilename, content: fullReport });
|
|
106
|
+
console.log(`[auto] 분석 완료: ${projectName} (${commit.shortHash})`);
|
|
107
|
+
} else {
|
|
108
|
+
autoAnalysisState = { status: "idle", projectName: null, filename: null, content: null };
|
|
109
|
+
broadcastEvent({ type: "analysis-error", message: "GEMINI_API_KEY가 설정되지 않았습니다." });
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
autoAnalysisState = { status: "idle", projectName: null, filename: null, content: null };
|
|
113
|
+
broadcastEvent({ type: "analysis-error", message: err.message });
|
|
114
|
+
console.error(`[auto] 분석 실패: ${err.message}`);
|
|
115
|
+
} finally {
|
|
116
|
+
isProcessingQueue = false;
|
|
117
|
+
if (analysisQueue.length > 0) {
|
|
118
|
+
setTimeout(processNextQueueItem, 1000);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
80
121
|
}
|
|
81
122
|
|
|
82
123
|
// ──────────────────────────────────────────────
|
|
@@ -93,37 +134,130 @@ app.get("/favicon.ico", (req, res) => {
|
|
|
93
134
|
});
|
|
94
135
|
|
|
95
136
|
// ──────────────────────────────────────────────
|
|
96
|
-
// API:
|
|
97
|
-
|
|
137
|
+
// API: SSE — 자동 분석 이벤트 스트림
|
|
98
138
|
// ──────────────────────────────────────────────
|
|
99
|
-
app.get("/api/
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
139
|
+
app.get("/api/events", (req, res) => {
|
|
140
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
141
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
142
|
+
res.setHeader("Connection", "keep-alive");
|
|
143
|
+
res.flushHeaders();
|
|
144
|
+
|
|
145
|
+
sseClients.add(res);
|
|
146
|
+
console.log(`[SSE] client connected (total: ${sseClients.size})`);
|
|
147
|
+
|
|
148
|
+
// 연결 직후 현재 상태 전달 (페이지 로드 타이밍 놓침 방지)
|
|
149
|
+
if (autoAnalysisState.status !== "idle") {
|
|
150
|
+
const eventType = autoAnalysisState.status === "analyzing" ? "analysis-started" : "analysis-done";
|
|
151
|
+
res.write(`data: ${JSON.stringify({ type: eventType, ...autoAnalysisState })}\n\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 연결 유지용 하트비트 (30초마다)
|
|
155
|
+
const heartbeat = setInterval(() => {
|
|
156
|
+
res.write(": ping\n\n");
|
|
157
|
+
}, 30000);
|
|
158
|
+
|
|
159
|
+
req.on("close", () => {
|
|
160
|
+
clearInterval(heartbeat);
|
|
161
|
+
sseClients.delete(res);
|
|
162
|
+
console.log(`[SSE] client disconnected (total: ${sseClients.size})`);
|
|
112
163
|
});
|
|
113
164
|
});
|
|
114
165
|
|
|
115
166
|
// ──────────────────────────────────────────────
|
|
116
|
-
// API:
|
|
167
|
+
// API: 자동 분석 현재 상태 (SSE 폴백용)
|
|
168
|
+
// ──────────────────────────────────────────────
|
|
169
|
+
app.get("/api/auto-analysis/state", (req, res) => {
|
|
170
|
+
res.json(autoAnalysisState);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ──────────────────────────────────────────────
|
|
174
|
+
// API: Health Check (훅 스크립트가 서버 실행 여부 확인용)
|
|
175
|
+
// ──────────────────────────────────────────────
|
|
176
|
+
app.get("/api/health", (req, res) => {
|
|
177
|
+
res.json({ status: "ok", version: "1.0.0" });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ──────────────────────────────────────────────
|
|
181
|
+
// API: 훅 관리
|
|
117
182
|
// ──────────────────────────────────────────────
|
|
118
|
-
|
|
183
|
+
|
|
184
|
+
/** 훅 상태 조회 (현재 프로젝트만) */
|
|
185
|
+
app.get("/api/hooks/status", async (req, res) => {
|
|
186
|
+
try {
|
|
187
|
+
const isSelf = fs.existsSync(path.join(DEV_ROOT, ".git"));
|
|
188
|
+
if (!isSelf) {
|
|
189
|
+
return res.json({ projects: [] });
|
|
190
|
+
}
|
|
191
|
+
const status = await getHookStatus(DEV_ROOT);
|
|
192
|
+
res.json({ projects: [{ name: "__self__", displayName: path.basename(DEV_ROOT), ...status }] });
|
|
193
|
+
} catch (err) {
|
|
194
|
+
res.status(500).json({ error: err.message });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/** 훅 설치 */
|
|
199
|
+
app.post("/api/hooks/install", async (req, res) => {
|
|
200
|
+
const { projectName } = req.body;
|
|
119
201
|
try {
|
|
120
|
-
const
|
|
121
|
-
|
|
202
|
+
const projectPath = resolveProjectPath(projectName);
|
|
203
|
+
const installed = await installHooks(projectPath);
|
|
204
|
+
res.json({ success: true, installed });
|
|
122
205
|
} catch (err) {
|
|
206
|
+
if (isBadRequestError(err)) {
|
|
207
|
+
return res.status(400).json({ error: err.message.replace(`${BAD_REQUEST_PREFIX} `, "") });
|
|
208
|
+
}
|
|
209
|
+
res.status(500).json({ error: err.message });
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/** 훅 제거 */
|
|
214
|
+
app.post("/api/hooks/remove", async (req, res) => {
|
|
215
|
+
const { projectName } = req.body;
|
|
216
|
+
try {
|
|
217
|
+
const projectPath = resolveProjectPath(projectName);
|
|
218
|
+
const removed = await removeHooks(projectPath);
|
|
219
|
+
res.json({ success: true, removed });
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (isBadRequestError(err)) {
|
|
222
|
+
return res.status(400).json({ error: err.message.replace(`${BAD_REQUEST_PREFIX} `, "") });
|
|
223
|
+
}
|
|
123
224
|
res.status(500).json({ error: err.message });
|
|
124
225
|
}
|
|
125
226
|
});
|
|
126
227
|
|
|
228
|
+
/** post-commit 훅에서 호출: 백그라운드 분석 큐에 추가 */
|
|
229
|
+
app.post("/api/hooks/post-commit-notify", (req, res) => {
|
|
230
|
+
const { projectPath } = req.body;
|
|
231
|
+
if (!projectPath || typeof projectPath !== "string") {
|
|
232
|
+
return res.status(400).json({ error: "projectPath가 필요합니다." });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (path.resolve(projectPath) !== path.resolve(DEV_ROOT)) {
|
|
236
|
+
return res.status(400).json({ error: "유효하지 않은 projectPath입니다." });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const projectName = path.basename(DEV_ROOT);
|
|
240
|
+
analysisQueue.push({ projectPath: path.resolve(projectPath), projectName });
|
|
241
|
+
|
|
242
|
+
// 즉시 응답 후 백그라운드에서 처리
|
|
243
|
+
res.json({ queued: true, jobId: Date.now() });
|
|
244
|
+
setTimeout(processNextQueueItem, 100);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ──────────────────────────────────────────────
|
|
248
|
+
// API: 설정 확인
|
|
249
|
+
// ──────────────────────────────────────────────
|
|
250
|
+
app.get("/api/config", (req, res) => {
|
|
251
|
+
const hasKey = !!(
|
|
252
|
+
process.env.GEMINI_API_KEY &&
|
|
253
|
+
process.env.GEMINI_API_KEY !== "your_gemini_api_key_here"
|
|
254
|
+
);
|
|
255
|
+
res.json({
|
|
256
|
+
hasKey,
|
|
257
|
+
projectName: path.basename(DEV_ROOT),
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
127
261
|
// ──────────────────────────────────────────────
|
|
128
262
|
// API: 최근 커밋 정보 조회
|
|
129
263
|
// ──────────────────────────────────────────────
|
|
@@ -380,6 +514,5 @@ ${analysis}
|
|
|
380
514
|
app.listen(PORT, () => {
|
|
381
515
|
console.log(`\n🚀 Commit Ai Agent 실행 중`);
|
|
382
516
|
console.log(` 브라우저: http://localhost:${PORT}`);
|
|
383
|
-
console.log(` 분석 대상: ${DEV_ROOT}`);
|
|
384
|
-
console.log(` DEV_ROOT source: ${DEV_ROOT_SOURCE}\n`);
|
|
517
|
+
console.log(` 분석 대상: ${DEV_ROOT}\n`);
|
|
385
518
|
});
|