ai-git-tools 2.0.6 → 2.0.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -1,684 +1,30 @@
1
1
  /**
2
- * PR 命令
3
- * 基於 scripts/ai-auto-pr.mjs 簡化版
4
- * 產生 PR 標題、描述並建立 Pull Request
2
+ * PR 命令 - 完整複製自 scripts/ai-auto-pr.mjs
3
+ *
4
+ * 使用 scripts/ai-pr-modules 的完整邏輯
5
+ * 確保功能與 scripts 版本完全相同
5
6
  */
6
7
 
7
- import { execSync } from 'child_process';
8
- import * as readline from 'readline/promises';
9
- import { stdin as input, stdout as output } from 'process';
10
- import { loadPRConfig } from '../core/config-loader.js';
11
- import { GitOperations } from '../core/git-operations.js';
12
- import { AIClient } from '../core/ai-client.js';
13
- import { Logger } from '../utils/logger.js';
14
- import { handleError, getProjectTypePrompt } from '../utils/helpers.js';
8
+ import { PRWorkflow } from '../../scripts/ai-pr-modules/core/workflow.mjs';
9
+ import { loadConfig } from '../../scripts/ai-pr-modules/core/config-loader.mjs';
10
+ import { handleError } from '../../scripts/ai-pr-modules/utils/helpers.mjs';
11
+ import { Logger } from '../../scripts/ai-pr-modules/ui/logger.mjs';
15
12
 
16
13
  /**
17
- * 根據 Git History 找出最常修改這些檔案的人
18
- */
19
- function getReviewersByGitHistory(changedFiles, config) {
20
- const contributors = {};
21
- const maxFiles = Math.min(changedFiles.length, 10); // 最多分析 10 個檔案
22
-
23
- changedFiles.slice(0, maxFiles).forEach((file) => {
24
- try {
25
- const logOutput = execSync(
26
- `git log -${config.reviewers.gitHistoryDepth} --format="%ae|%an" -- "${file}"`,
27
- { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
28
- );
29
-
30
- logOutput
31
- .split('\n')
32
- .filter(Boolean)
33
- .forEach((line) => {
34
- const [email, name] = line.split('|');
35
- if (email && email.includes('@')) {
36
- // 檢查是否在排除列表中
37
- const shouldExclude = config.reviewers.excludeAuthors.some((excluded) => {
38
- const normalizedExcluded = excluded.toLowerCase();
39
- return (
40
- email.toLowerCase().includes(normalizedExcluded) ||
41
- (name && name.toLowerCase().includes(normalizedExcluded))
42
- );
43
- });
44
-
45
- if (!shouldExclude) {
46
- const key = email.toLowerCase();
47
- contributors[key] = {
48
- email,
49
- name: name || email.split('@')[0],
50
- username: email.split('@')[0],
51
- commits: (contributors[key]?.commits || 0) + 1,
52
- };
53
- }
54
- }
55
- });
56
- } catch (error) {
57
- // 忽略單個檔案的錯誤
58
- }
59
- });
60
-
61
- // 排序並取前 N 名
62
- return Object.values(contributors)
63
- .sort((a, b) => b.commits - a.commits)
64
- .slice(0, config.reviewers.maxSuggested);
65
- }
66
-
67
- /**
68
- * 獲取當前 Git 使用者
69
- */
70
- function getCurrentUser() {
71
- try {
72
- const email = execSync('git config user.email', { encoding: 'utf-8' }).trim();
73
- const name = execSync('git config user.name', { encoding: 'utf-8' }).trim();
74
- return { email, name, username: email.split('@')[0] };
75
- } catch (error) {
76
- return null;
77
- }
78
- }
79
-
80
- /**
81
- * 互動式選擇 Reviewers
82
- */
83
- async function selectReviewers(suggestedReviewers, currentUser) {
84
- const logger = new Logger();
85
-
86
- console.log('\n' + '═'.repeat(80));
87
- console.log('🎯 選擇 Reviewers');
88
- console.log('═'.repeat(80) + '\n');
89
-
90
- // 過濾掉當前使用者
91
- const validReviewers = suggestedReviewers.filter(
92
- (r) => !currentUser || r.email.toLowerCase() !== currentUser.email.toLowerCase()
93
- );
94
-
95
- if (validReviewers.length === 0) {
96
- logger.warning('沒有找到建議的 reviewers');
97
- console.log('💡 您可以在創建 PR 後手動添加 reviewers\n');
98
- return [];
99
- }
100
-
101
- // 顯示建議的 Reviewers
102
- console.log('💡 建議的 Reviewers(基於 Git 歷史):\n');
103
- validReviewers.forEach((reviewer, idx) => {
104
- console.log(
105
- ` ${idx + 1}. ${reviewer.name} (@${reviewer.username}) - ${reviewer.commits} commits`
106
- );
107
- });
108
- console.log('');
109
-
110
- const rl = readline.createInterface({ input, output });
111
-
112
- try {
113
- const answer = await rl.question(
114
- '請輸入要選擇的 reviewer 編號(用逗號分隔,例如: 1,2,3),或直接按 Enter 跳過: '
115
- );
116
- rl.close();
117
-
118
- if (!answer.trim()) {
119
- logger.info('已跳過 reviewer 選擇\n');
120
- return [];
121
- }
122
-
123
- const selectedIndices = answer
124
- .split(',')
125
- .map((s) => parseInt(s.trim()) - 1)
126
- .filter((i) => i >= 0 && i < validReviewers.length);
127
-
128
- const selectedReviewers = selectedIndices.map((i) => validReviewers[i].username);
129
-
130
- if (selectedReviewers.length > 0) {
131
- logger.success(`已選擇: ${selectedReviewers.map((u) => `@${u}`).join(', ')}\n`);
132
- }
133
-
134
- return selectedReviewers;
135
- } catch (error) {
136
- rl.close();
137
- logger.warning('選擇過程發生錯誤,將跳過 reviewer 選擇\n');
138
- return [];
139
- }
140
- }
141
-
142
- /**
143
- * 分析影響範圍
144
- */
145
- async function analyzeImpact(changedFiles, diff, commits, config) {
146
- const logger = new Logger();
147
- logger.step('正在分析影響範圍和風險...');
148
-
149
- const truncatedDiff = GitOperations.truncateDiff(diff, config.ai.maxDiffLength);
150
- const fileList = changedFiles.slice(0, 30).join('\n');
151
-
152
- const prompt = `你是一個資深的程式碼審查專家,精通 React/Next.js 效能優化與前端架構設計。
153
- 請分析以下程式碼變更,提供專業的影響範圍分析。
154
-
155
- ${getProjectTypePrompt()}
156
-
157
- **變更檔案列表**:
158
- ${fileList}
159
- ${changedFiles.length > 30 ? `\n... 還有 ${changedFiles.length - 30} 個檔案` : ''}
160
-
161
- **Commit 訊息**:
162
- ${commits.split('\n').slice(0, 10).join('\n')}
163
-
164
- **程式碼變更**:
165
- \`\`\`diff
166
- ${truncatedDiff}
167
- \`\`\`
168
-
169
- 請以 JSON 格式輸出分析結果:
170
-
171
- \`\`\`json
172
- {
173
- "blastRadius": {
174
- "modules": ["影響的模組1", "影響的模組2"],
175
- "impacts": ["影響層面1", "影響層面2"],
176
- "riskLevel": "低|中|高",
177
- "riskReasons": ["風險原因1"],
178
- "externalBehaviors": ["對外行為變更說明"]
179
- },
180
- "warnings": [
181
- {
182
- "level": "⚠️|ℹ️",
183
- "message": "問題描述",
184
- "suggestion": "改善建議"
185
- }
186
- ]
187
- }
188
- \`\`\`
189
-
190
- **分析重點**:
191
- 1. 影響範圍:識別實際影響的模組和層面(API、資料庫、UI、效能等)
192
- 2. 風險評估:評估風險等級並說明原因
193
- 3. 規範檢查:檢查安全風險、效能問題、最佳實踐
194
-
195
- 請只輸出有效的 JSON。`;
196
-
197
- try {
198
- const response = await AIClient.sendAndWait(prompt, config.ai.model);
199
- const analysisResult = AIClient.parseJSON(response);
200
-
201
- const blastRadius = {
202
- modules: analysisResult.blastRadius?.modules || [],
203
- impacts: analysisResult.blastRadius?.impacts || [],
204
- riskLevel: analysisResult.blastRadius?.riskLevel || '低',
205
- riskReasons: analysisResult.blastRadius?.riskReasons || [],
206
- externalBehaviors: analysisResult.blastRadius?.externalBehaviors || [],
207
- };
208
-
209
- const warnings = analysisResult.warnings || [];
210
-
211
- logger.success('影響範圍分析完成\n');
212
- return { blastRadius, warnings };
213
- } catch (error) {
214
- logger.warning('使用基礎分析...\n');
215
- return getFallbackAnalysis(changedFiles);
216
- }
217
- }
218
-
219
- /**
220
- * 降級方案:基礎分析
221
- */
222
- function getFallbackAnalysis(changedFiles) {
223
- const modules = [];
224
- const impacts = [];
225
- let riskLevel = '低';
226
- const riskReasons = [];
227
-
228
- changedFiles.forEach((file) => {
229
- const lower = file.toLowerCase();
230
- if (lower.includes('/api/')) {
231
- if (!impacts.includes('API 層')) impacts.push('API 層');
232
- if (!modules.includes('API 服務')) modules.push('API 服務');
233
- }
234
- if (lower.includes('/components/') || lower.includes('/pages/')) {
235
- if (!impacts.includes('使用者介面')) impacts.push('使用者介面');
236
- if (!modules.includes('前端元件')) modules.push('前端元件');
237
- }
238
- if (lower.includes('db') || lower.includes('migration') || lower.includes('schema')) {
239
- if (!impacts.includes('資料庫')) impacts.push('資料庫');
240
- riskLevel = '高';
241
- riskReasons.push('涉及資料庫結構變更');
242
- }
243
- });
244
-
245
- const warnings = [];
246
- const hasTestFiles = changedFiles.some((f) => f.includes('test') || f.includes('spec'));
247
- if (!hasTestFiles && changedFiles.length > 3) {
248
- warnings.push({
249
- level: '⚠️',
250
- message: '未包含測試檔案',
251
- suggestion: '建議新增測試確保程式碼品質',
252
- });
253
- }
254
-
255
- return {
256
- blastRadius: { modules, impacts, riskLevel, riskReasons, externalBehaviors: [] },
257
- warnings,
258
- };
259
- }
260
-
261
- /**
262
- * 生成 PR 內容
263
- */
264
- async function generatePRContent(baseBranch, headBranch, config) {
265
- const logger = new Logger();
266
- logger.step('AI 正在生成 PR 內容...');
267
-
268
- // 獲取 diff 和 commits
269
- const diff = GitOperations.getDiff(baseBranch, headBranch);
270
- const commits = GitOperations.getCommits(baseBranch, headBranch);
271
- const truncatedDiff = GitOperations.truncateDiff(diff, config.ai.maxDiffLength);
272
-
273
- const prompt = `${getProjectTypePrompt()}
274
-
275
- 請根據以下 commit 訊息和程式碼變更,生成清晰的 Pull Request 標題和描述。
276
-
277
- **Commits 列表**:
278
- ${commits}
279
-
280
- **Git Diff**:
281
- ${truncatedDiff}
282
-
283
- **輸出格式**(JSON):
284
- {
285
- "title": "[type]: PR 標題(type 為 feat/fix/refactor/perf/docs/style/test/chore 之一,繁體中文,50 字內)",
286
- "description": "PR 描述(Markdown 格式,繁體中文)"
287
- }
288
-
289
- **PR 描述必須包含**:
290
-
291
- ## 📝 變更摘要
292
- [簡述這個 PR 的主要目的和影響範圍,2-3 句話]
293
-
294
- ## 🎯 主要變更
295
- - [變更項目 1]
296
- - [變更項目 2]
297
- - [變更項目 3]
298
-
299
- ## 🔀 變更類型
300
- - [ ] ✨ 新功能 (feat)
301
- - [ ] 🐛 Bug 修復 (fix)
302
- - [ ] ♻️ 重構 (refactor)
303
- - [ ] 💄 樣式調整 (style)
304
- - [ ] 📝 文件更新 (docs)
305
- - [ ] ⚡ 效能改進 (perf)
306
- - [ ] 🔧 其他 (chore)
307
-
308
- ## 🧪 測試方法
309
- 1. [具體的測試步驟 1]
310
- 2. [具體的測試步驟 2]
311
-
312
- ## 💥 Breaking Changes
313
- [如果有破壞性變更請詳細說明,沒有則填寫「無」]
314
-
315
- ## 📌 注意事項
316
- [需要特別注意的事項,沒有則填寫「無」]
317
-
318
- **規則**:
319
- 1. 標題格式:type: 簡短描述(不超過 50 字)
320
- 2. 全部使用繁體中文
321
- 3. 只輸出 JSON,不要其他文字
322
- `;
323
-
324
- const response = await AIClient.sendAndWait(prompt, config.ai.model);
325
-
326
- try {
327
- const prContent = AIClient.parseJSON(response);
328
- logger.success('PR 內容生成完成\n');
329
- return prContent;
330
- } catch (error) {
331
- throw new Error(`無法解析 AI 回應: ${error.message}`);
332
- }
333
- }
334
-
335
- /**
336
- * 分析 Labels
337
- */
338
- function analyzeLabels(prData) {
339
- const labels = new Set();
340
-
341
- // 根據 commit type
342
- const typeLabels = {
343
- feat: 'feature',
344
- fix: 'bug',
345
- refactor: 'refactor',
346
- perf: 'performance',
347
- docs: 'documentation',
348
- test: 'testing',
349
- style: 'style',
350
- chore: 'chore',
351
- };
352
-
353
- const titleMatch = prData.title.match(/^(\w+):/);
354
- if (titleMatch) {
355
- const type = titleMatch[1];
356
- if (typeLabels[type]) {
357
- labels.add(typeLabels[type]);
358
- }
359
- }
360
-
361
- // 根據影響範圍
362
- if (prData.blastRadius) {
363
- if (prData.blastRadius.impacts.includes('API 層')) {
364
- labels.add('api-change');
365
- }
366
- if (prData.blastRadius.impacts.includes('資料庫')) {
367
- labels.add('database');
368
- }
369
- if (prData.blastRadius.impacts.includes('使用者介面')) {
370
- labels.add('ui');
371
- }
372
-
373
- // 根據風險等級
374
- if (prData.blastRadius.riskLevel === '高') {
375
- labels.add('high-risk');
376
- labels.add('needs-careful-review');
377
- } else if (prData.blastRadius.riskLevel === '中') {
378
- labels.add('medium-risk');
379
- }
380
- }
381
-
382
- // 根據變更規模
383
- if (prData.stats) {
384
- if (prData.stats.filesChanged > 20) {
385
- labels.add('large-change');
386
- }
387
- }
388
-
389
- // 根據警告
390
- if (prData.warnings && prData.warnings.length > 0) {
391
- labels.add('has-warnings');
392
- }
393
-
394
- return Array.from(labels);
395
- }
396
-
397
- /**
398
- * 應用 Labels
399
- */
400
- function applyLabels(prUrl, labels) {
401
- if (!labels || labels.length === 0) return;
402
-
403
- const logger = new Logger();
404
- logger.info(`正在添加 ${labels.length} 個 Labels...`);
405
-
406
- // 從 PR URL 提取 PR 號碼
407
- const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
408
- if (!prNumberMatch) return;
409
-
410
- const prNumber = prNumberMatch[1];
411
- const requestBody = JSON.stringify({ labels });
412
-
413
- try {
414
- execSync(
415
- `printf '%s' '${requestBody.replace(
416
- /'/g,
417
- "'\\''"
418
- )}' | gh api repos/:owner/:repo/issues/${prNumber}/labels --input - -X POST`,
419
- { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
420
- );
421
- logger.success(`成功添加 Labels: ${labels.join(', ')}\n`);
422
- } catch (error) {
423
- logger.warning('無法自動添加 Labels,請手動操作\n');
424
- }
425
- }
426
-
427
- /**
428
- * 附加影響分析到 PR body
429
- */
430
- function appendAnalysisToBody(body, blastRadius, warnings) {
431
- let enhancedBody = body;
432
-
433
- // 添加影響範圍
434
- enhancedBody += '\n\n---\n\n## 💥 影響範圍分析\n\n';
435
-
436
- if (blastRadius.modules.length > 0) {
437
- enhancedBody += `**影響模組**:${blastRadius.modules.join('、')}\n\n`;
438
- }
439
-
440
- if (blastRadius.impacts.length > 0) {
441
- enhancedBody += `**影響層面**:${blastRadius.impacts.join('、')}\n\n`;
442
- }
443
-
444
- const riskEmojiMap = { 高: '🔴', 中: '🟡', 低: '🟢' };
445
- const riskEmoji = riskEmojiMap[blastRadius.riskLevel] || '🟢';
446
- enhancedBody += `**風險等級**:${riskEmoji} ${blastRadius.riskLevel}\n\n`;
447
-
448
- if (blastRadius.riskReasons && blastRadius.riskReasons.length > 0) {
449
- enhancedBody += `**風險因素**:\n`;
450
- blastRadius.riskReasons.forEach((reason) => {
451
- enhancedBody += `- ${reason}\n`;
452
- });
453
- enhancedBody += '\n';
454
- }
455
-
456
- if (blastRadius.externalBehaviors && blastRadius.externalBehaviors.length > 0) {
457
- enhancedBody += `**對外行為變更**:\n`;
458
- blastRadius.externalBehaviors.forEach((behavior) => {
459
- enhancedBody += `- ${behavior}\n`;
460
- });
461
- enhancedBody += '\n';
462
- }
463
-
464
- // 添加規範警告
465
- if (warnings.length > 0) {
466
- enhancedBody += '\n## ⚠️ 注意事項\n\n';
467
- warnings.forEach((warning) => {
468
- enhancedBody += `${warning.level} **${warning.message}**\n`;
469
- if (warning.suggestion) {
470
- enhancedBody += ` - 💡 ${warning.suggestion}\n`;
471
- }
472
- enhancedBody += '\n';
473
- });
474
- }
475
-
476
- return enhancedBody;
477
- }
478
-
479
- /**
480
- * 顯示 PR 預覽
481
- */
482
- function displayPreview(prData, stats) {
483
- console.log('\n' + '═'.repeat(80));
484
- console.log('📋 PR 預覽');
485
- console.log('═'.repeat(80));
486
- console.log(`\n標題: ${prData.title}\n`);
487
- console.log(`統計: ${stats.stats}`);
488
- console.log(`檔案數: ${stats.filesChanged} 個檔案\n`);
489
-
490
- // 顯示影響範圍
491
- if (prData.blastRadius) {
492
- const { blastRadius } = prData;
493
- console.log('─'.repeat(80));
494
- console.log('💥 影響範圍:\n');
495
- if (blastRadius.modules.length > 0) {
496
- console.log(` 模組: ${blastRadius.modules.join('、')}`);
497
- }
498
- if (blastRadius.impacts.length > 0) {
499
- console.log(` 層面: ${blastRadius.impacts.join('、')}`);
500
- }
501
- const riskEmojiMap = { 高: '🔴', 中: '🟡', 低: '🟢' };
502
- const riskEmoji = riskEmojiMap[blastRadius.riskLevel] || '🟢';
503
- console.log(` 風險: ${riskEmoji} ${blastRadius.riskLevel}`);
504
- }
505
-
506
- // 顯示 Labels
507
- if (prData.labels && prData.labels.length > 0) {
508
- console.log(`\n🏷️ Labels: ${prData.labels.join(', ')}`);
509
- }
510
-
511
- console.log('\n' + '─'.repeat(80));
512
- console.log('描述:\n');
513
- console.log(prData.description);
514
- console.log('\n' + '═'.repeat(80) + '\n');
515
- }
516
-
517
- /**
518
- * PR 命令主函數
14
+ * PR 命令主函數(完全照抄 scripts/ai-auto-pr.mjs)
519
15
  */
520
16
  export async function prCommand() {
521
17
  const logger = new Logger();
522
18
 
523
19
  try {
524
- logger.header('AI Auto PR Generator');
525
-
526
- // 載入配置
527
- const config = await loadPRConfig();
528
-
529
- if (config.output.verbose) {
530
- console.log('📋 使用配置:');
531
- console.log(` AI Model: ${config.ai.model}`);
532
- console.log(` Max Diff Length: ${config.ai.maxDiffLength}`);
533
- console.log('');
534
- }
535
-
536
- // 獲取當前分支
537
- const currentBranch = GitOperations.getCurrentBranch();
538
- let headBranch = config.headBranch || currentBranch;
539
- let baseBranch = config.baseBranch;
540
-
541
- // 自動偵測 base branch
542
- if (!baseBranch) {
543
- logger.step('正在偵測 release 分支...\n');
544
- const latestRelease = GitOperations.findLatestReleaseBranch();
545
-
546
- if (latestRelease) {
547
- baseBranch = latestRelease;
548
- logger.success(`自動選擇目標分支: ${baseBranch}\n`);
549
- } else {
550
- logger.error('未偵測到任何 release 分支');
551
- console.log('💡 使用 --base 參數指定目標分支');
552
- process.exit(1);
553
- }
554
- }
555
-
556
- // 檢查是否在 base branch
557
- if (currentBranch === baseBranch) {
558
- logger.error(`你目前在 ${baseBranch} 分支,請切換到 feature 分支`);
559
- console.log('💡 切換到 feature 分支: git checkout <feature-branch>');
560
- process.exit(1);
561
- }
562
-
563
- console.log('\n📌 分支資訊:');
564
- console.log(` Base: ${baseBranch}`);
565
- console.log(` Head: ${headBranch}\n`);
566
-
567
- // 推送到遠端(預覽模式跳過)
568
- if (!config.preview) {
569
- logger.step(`推送到遠端分支: origin/${headBranch}`);
570
- GitOperations.push(headBranch);
571
- logger.success('推送成功\n');
572
-
573
- // 等待 GitHub 同步
574
- logger.info('等待 GitHub 同步...');
575
- await new Promise(resolve => setTimeout(resolve, 2000));
576
- GitOperations.fetch();
577
- logger.success('同步完成\n');
578
- }
579
-
580
- // 獲取變更統計和檔案列表
581
- const stats = GitOperations.getChangeStats(baseBranch, headBranch);
582
- const changedFiles = GitOperations.getChangedFiles(baseBranch, headBranch);
583
- console.log(`📈 變更統計: ${stats.stats}`);
584
- console.log(`📁 影響檔案: ${stats.filesChanged} 個\n`);
585
-
586
- // 1. 分析影響範圍
587
- const diff = GitOperations.getDiff(baseBranch, headBranch);
588
- const commits = GitOperations.getCommits(baseBranch, headBranch);
589
- const { blastRadius, warnings } = await analyzeImpact(changedFiles, diff, commits, config);
590
-
591
- // 2. 生成 PR 內容
592
- const prContent = await generatePRContent(baseBranch, headBranch, config);
593
-
594
- // 3. 附加影響分析到 PR body
595
- const enhancedDescription = appendAnalysisToBody(
596
- prContent.description,
597
- blastRadius,
598
- warnings
599
- );
600
-
601
- // 4. 分析 Labels
602
- const labels = analyzeLabels({
603
- title: prContent.title,
604
- blastRadius,
605
- warnings,
606
- stats,
607
- });
608
-
609
- const prData = {
610
- title: prContent.title,
611
- description: enhancedDescription,
612
- blastRadius,
613
- warnings,
614
- labels,
615
- };
616
-
617
- // 顯示預覽
618
- displayPreview(prData, stats);
619
-
620
- // 預覽模式:僅顯示不創建
621
- if (config.preview) {
622
- logger.info('預覽模式:未創建 PR');
623
- return;
624
- }
625
-
626
- // 5. Reviewer 選擇(如果啟用)
627
- let selectedReviewers = [];
628
- if (config.reviewers.autoSelect) {
629
- const currentUser = getCurrentUser();
630
- const suggestedReviewers = getReviewersByGitHistory(changedFiles, config);
631
-
632
- if (suggestedReviewers.length > 0) {
633
- selectedReviewers = await selectReviewers(suggestedReviewers, currentUser);
634
- } else {
635
- logger.info('未找到建議的 reviewers(可能是新專案或檔案無歷史記錄)\n');
636
- }
637
- }
638
-
639
- // 創建 PR(使用 GitHub CLI)
640
- logger.step('創建 Pull Request...');
641
-
642
- // 準備 PR body
643
- const prBody = prData.description;
644
-
645
- // 構建 gh pr create 命令
646
- let ghCommand = `gh pr create --base ${baseBranch} --head ${headBranch} --title "${prData.title.replace(/"/g, '\\"')}"`;
647
-
648
- // 將 body 寫入臨時檔案
649
- const { writeFileSync, unlinkSync } = await import('fs');
650
- const tmpFile = '/tmp/pr-body-temp.md';
651
- writeFileSync(tmpFile, prBody, 'utf-8');
652
- ghCommand += ` --body-file ${tmpFile}`;
653
-
654
- if (config.draft) {
655
- ghCommand += ' --draft';
656
- }
657
-
658
- // 添加 reviewers
659
- if (selectedReviewers.length > 0) {
660
- ghCommand += ` --reviewer ${selectedReviewers.join(',')}`;
661
- }
662
-
663
- try {
664
- const result = execSync(ghCommand, { encoding: 'utf-8' });
665
- unlinkSync(tmpFile);
20
+ logger.header('AI Auto PR Generator (v2.0 Enhanced)');
666
21
 
667
- logger.success('PR 創建成功!\n');
668
- console.log(result);
22
+ // 載入配置(使用 scripts/ 的配置載入邏輯)
23
+ const config = await loadConfig();
669
24
 
670
- // 應用 Labels(如果啟用)
671
- if (config.github.autoLabels && labels.length > 0) {
672
- applyLabels(result, labels);
673
- }
674
- } catch (error) {
675
- try {
676
- unlinkSync(tmpFile);
677
- } catch (e) {
678
- // 忽略
679
- }
680
- throw new Error(`創建 PR 失敗: ${error.message}`);
681
- }
25
+ // 執行工作流程(使用 scripts/ 的完整工作流)
26
+ const workflow = new PRWorkflow(config);
27
+ await workflow.execute();
682
28
  } catch (error) {
683
29
  handleError(error);
684
30
  process.exit(1);
@@ -0,0 +1,161 @@
1
+ /**
2
+ * GitHub API 操作封裝
3
+ * 基於 scripts/ai-pr-modules/core/github-api.mjs
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import { Logger } from '../utils/logger.js';
8
+
9
+ /**
10
+ * GitHub API 操作類
11
+ */
12
+ export class GitHubAPI {
13
+ constructor(config = {}) {
14
+ this.orgName = config.orgName || 'kingsinfo-project';
15
+ }
16
+
17
+ /**
18
+ * 檢查 GitHub CLI 認證狀態
19
+ */
20
+ checkAuth() {
21
+ try {
22
+ const authStatus = execSync('gh auth status 2>&1', {
23
+ encoding: 'utf-8',
24
+ stdio: ['pipe', 'pipe', 'pipe'],
25
+ });
26
+
27
+ return {
28
+ authenticated: authStatus.includes('Logged in'),
29
+ details: authStatus,
30
+ };
31
+ } catch (error) {
32
+ return {
33
+ authenticated: false,
34
+ details: error.message,
35
+ };
36
+ }
37
+ }
38
+
39
+ /**
40
+ * 從 GitHub 抓取組織的成員列表
41
+ */
42
+ async fetchOrgMembers(orgName = this.orgName) {
43
+ const logger = new Logger();
44
+
45
+ try {
46
+ logger.step(`正在嘗試抓取 ${orgName} 組織的成員列表...`);
47
+
48
+ const membersJson = execSync(`gh api orgs/${orgName}/members --jq '.'`, {
49
+ encoding: 'utf-8',
50
+ stdio: ['pipe', 'pipe', 'pipe'],
51
+ });
52
+
53
+ const membersData = JSON.parse(membersJson);
54
+ const members = membersData.map((member) => ({
55
+ login: member.login,
56
+ name: member.name || member.login,
57
+ }));
58
+
59
+ if (members.length > 0) {
60
+ logger.success(`成功抓取 ${members.length} 位組織成員\n`);
61
+ return members;
62
+ }
63
+
64
+ return [];
65
+ } catch (error) {
66
+ return [];
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 從 GitHub 抓取組織的團隊列表
72
+ */
73
+ async fetchTeams(orgName = this.orgName) {
74
+ const logger = new Logger();
75
+
76
+ try {
77
+ // 先檢查認證狀態
78
+ const authStatus = this.checkAuth();
79
+ if (!authStatus.authenticated) {
80
+ logger.warning('GitHub CLI 未認證');
81
+ logger.info('請執行: gh auth login\n');
82
+ return { teams: {}, members: [] };
83
+ }
84
+
85
+ logger.step(`正在從 GitHub 抓取 ${orgName} 的團隊資訊...`);
86
+
87
+ let teamsJson;
88
+ try {
89
+ teamsJson = execSync(`gh api orgs/${orgName}/teams --jq '.'`, {
90
+ encoding: 'utf-8',
91
+ stdio: ['pipe', 'pipe', 'pipe'],
92
+ });
93
+ } catch (error) {
94
+ // 無法存取團隊 API,使用替代方案
95
+ logger.warning('未找到任何團隊,嘗試直接抓取組織成員...');
96
+
97
+ const members = await this.fetchOrgMembers(orgName);
98
+ if (members.length > 0) {
99
+ return { teams: {}, members };
100
+ }
101
+
102
+ throw error;
103
+ }
104
+
105
+ const teams = JSON.parse(teamsJson);
106
+
107
+ if (!teams || teams.length === 0) {
108
+ logger.warning('未找到任何團隊,嘗試直接抓取組織成員...');
109
+ const members = await this.fetchOrgMembers(orgName);
110
+ return { teams: {}, members };
111
+ }
112
+
113
+ const teamData = {};
114
+
115
+ for (const team of teams) {
116
+ try {
117
+ const membersJson = execSync(
118
+ `gh api orgs/${orgName}/teams/${team.slug}/members --jq '.'`,
119
+ {
120
+ encoding: 'utf-8',
121
+ stdio: ['pipe', 'pipe', 'pipe'],
122
+ }
123
+ );
124
+
125
+ const members = JSON.parse(membersJson);
126
+ teamData[team.slug] = {
127
+ name: team.name,
128
+ slug: team.slug,
129
+ description: team.description || '',
130
+ members: members.map((m) => ({
131
+ login: m.login,
132
+ name: m.name || m.login,
133
+ })),
134
+ };
135
+ } catch (error) {
136
+ logger.warning(`無法抓取團隊 ${team.slug} 的成員: ${error.message}`);
137
+ }
138
+ }
139
+
140
+ // 同時取得所有成員作為備選
141
+ const allMembers = await this.fetchOrgMembers(orgName);
142
+
143
+ logger.success(
144
+ `成功抓取 ${Object.keys(teamData).length} 個團隊和 ${allMembers.length} 位成員\n`
145
+ );
146
+ return { teams: teamData, members: allMembers };
147
+ } catch (error) {
148
+ logger.warning('無法從 GitHub 抓取資訊');
149
+ logger.info('請確認:');
150
+ console.log(' 1. 已安裝 GitHub CLI: brew install gh');
151
+ console.log(' 2. 已執行認證: gh auth login');
152
+ console.log(' 3. 選擇正確的認證範圍(需要 read:org 權限)');
153
+ console.log(` 4. 有權限存取 ${orgName} 組織`);
154
+ console.log(' 5. 組織名稱正確(可用 --org 參數指定)\n');
155
+
156
+ logger.info('提示: 你仍可以在創建 PR 後手動添加 reviewers\n');
157
+
158
+ return { teams: {}, members: [] };
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * 互動式選擇工具(使用方向鍵和空白鍵)
3
+ * 基於 scripts/ai-pr-modules/ui/interactive-select.mjs
4
+ */
5
+
6
+ import { emitKeypressEvents } from 'readline';
7
+
8
+ // ANSI 顏色碼
9
+ const colors = {
10
+ reset: '\x1b[0m',
11
+ bright: '\x1b[1m',
12
+ cyan: '\x1b[36m',
13
+ green: '\x1b[32m',
14
+ yellow: '\x1b[33m',
15
+ magenta: '\x1b[35m',
16
+ blue: '\x1b[34m',
17
+ };
18
+
19
+ // 游標控制
20
+ const cursor = {
21
+ hide: '\x1b[?25l',
22
+ show: '\x1b[?25h',
23
+ up: (n = 1) => `\x1b[${n}A`,
24
+ clearLine: '\x1b[2K',
25
+ };
26
+
27
+ /**
28
+ * 互動式選擇器
29
+ */
30
+ export class InteractiveSelect {
31
+ /**
32
+ * @param {Array} options - 選項陣列
33
+ * @param {string} title - 標題
34
+ * @returns {Promise<{cancelled: boolean, selected: Array}>}
35
+ */
36
+ async select(options, title = '選擇項目') {
37
+ return new Promise((resolve) => {
38
+ let currentIndex = 0;
39
+ const selected = new Set();
40
+
41
+ // 準備選項列表
42
+ const items = options.map((opt, idx) => ({
43
+ ...opt,
44
+ index: idx,
45
+ }));
46
+
47
+ const render = () => {
48
+ // 清除整個選擇區域
49
+ if (items.length > 0) {
50
+ // 向上移動到開始位置
51
+ for (let i = 0; i < items.length + 3; i++) {
52
+ process.stdout.write(cursor.up(1));
53
+ }
54
+ }
55
+
56
+ // 清除並重新繪製每一行
57
+ process.stdout.write(cursor.clearLine);
58
+ console.log(`${colors.bright}${title}${colors.reset}`);
59
+
60
+ process.stdout.write(cursor.clearLine);
61
+ console.log(
62
+ `${colors.yellow}↑/↓: 移動 Space: 選擇/取消 Enter: 確認 q: 跳過${colors.reset}`
63
+ );
64
+
65
+ process.stdout.write(cursor.clearLine);
66
+ console.log('');
67
+
68
+ items.forEach((item, idx) => {
69
+ const isSelected = selected.has(idx);
70
+ const isCurrent = idx === currentIndex;
71
+
72
+ const checkbox = isSelected ? `${colors.green}[✓]${colors.reset}` : '[ ]';
73
+ const cursorMarker = isCurrent ? `${colors.cyan}▶${colors.reset}` : ' ';
74
+ const label = item.label || item.name || item.login;
75
+ const extra = item.extra || '';
76
+
77
+ process.stdout.write(cursor.clearLine);
78
+ console.log(`${cursorMarker} ${checkbox} ${label}${extra}`);
79
+ });
80
+ };
81
+
82
+ const cleanup = () => {
83
+ process.stdin.setRawMode(false);
84
+ process.stdin.removeAllListeners('keypress');
85
+ process.stdout.write(cursor.show);
86
+ console.log('');
87
+ };
88
+
89
+ const handleKeypress = (str, key) => {
90
+ if (!key) return;
91
+
92
+ // q or Ctrl+C to quit
93
+ if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
94
+ cleanup();
95
+ resolve({ cancelled: true, selected: [] });
96
+ return;
97
+ }
98
+
99
+ // Arrow up
100
+ if (key.name === 'up') {
101
+ currentIndex = Math.max(0, currentIndex - 1);
102
+ render();
103
+ }
104
+
105
+ // Arrow down
106
+ else if (key.name === 'down') {
107
+ currentIndex = Math.min(items.length - 1, currentIndex + 1);
108
+ render();
109
+ }
110
+
111
+ // Space to toggle selection
112
+ else if (key.name === 'space') {
113
+ if (selected.has(currentIndex)) {
114
+ selected.delete(currentIndex);
115
+ } else {
116
+ selected.add(currentIndex);
117
+ }
118
+ render();
119
+ }
120
+
121
+ // Enter to confirm
122
+ else if (key.name === 'return') {
123
+ cleanup();
124
+ const selectedItems = Array.from(selected).map((idx) => items[idx]);
125
+ resolve({ cancelled: false, selected: selectedItems });
126
+ }
127
+ };
128
+
129
+ // 初始化
130
+ process.stdout.write(cursor.hide);
131
+
132
+ // 先印出佔位用的空行
133
+ for (let i = 0; i < items.length + 3; i++) {
134
+ console.log('');
135
+ }
136
+
137
+ render();
138
+
139
+ // 啟用 raw mode 和 keypress
140
+ if (process.stdin.isTTY) {
141
+ process.stdin.setRawMode(true);
142
+ process.stdin.resume();
143
+
144
+ // 需要監聽 keypress 事件
145
+ emitKeypressEvents(process.stdin);
146
+
147
+ process.stdin.on('keypress', handleKeypress);
148
+ } else {
149
+ cleanup();
150
+ resolve({ cancelled: true, selected: [] });
151
+ }
152
+ });
153
+ }
154
+ }