ai-git-tools 2.0.5 → 2.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/commit-all.js +2 -2
- package/src/commands/init.js +2 -2
- package/src/commands/pr.js +608 -27
- package/src/core/config-loader.js +14 -2
- package/src/core/github-api.js +161 -0
- package/src/utils/interactive-select.js +154 -0
- package/templates/.ai-git-config.template.js +3 -3
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -225,9 +225,9 @@ feat(auth): 新增使用者登入功能
|
|
|
225
225
|
*/
|
|
226
226
|
async function commitGroup(group, files, config) {
|
|
227
227
|
try {
|
|
228
|
-
console.log(`\n📦
|
|
228
|
+
console.log(`\n📦 處理群組: ${group.group_name}`);
|
|
229
229
|
console.log(
|
|
230
|
-
`
|
|
230
|
+
` 類型: ${group.commit_type}${group.commit_scope ? `(${group.commit_scope})` : ''}`
|
|
231
231
|
);
|
|
232
232
|
console.log(` 檔案数量: ${files.length}`);
|
|
233
233
|
|
package/src/commands/init.js
CHANGED
|
@@ -23,14 +23,14 @@ export default {
|
|
|
23
23
|
|
|
24
24
|
// GitHub 相關配置
|
|
25
25
|
github: {
|
|
26
|
-
orgName: '
|
|
26
|
+
orgName: '', // GitHub 組織名稱(留空則自動從 git remote 取得,或使用 'kingsinfo-project')
|
|
27
27
|
defaultBase: 'release', // 預設 base 分支(或使用 'auto' 自動偵測最新 release 分支,如 release-2025-m11.1)
|
|
28
28
|
autoLabels: true, // 自動新增 Labels
|
|
29
29
|
},
|
|
30
30
|
|
|
31
31
|
// Reviewers 相關配置
|
|
32
32
|
reviewers: {
|
|
33
|
-
autoSelect:
|
|
33
|
+
autoSelect: true, // 啟用互動式 reviewer 選擇(true: 顯示選單,false: 跳過,創建 PR 後手動添加)
|
|
34
34
|
maxSuggested: 5, // 最多建議幾位 reviewers(基於 Git 歷史分析)
|
|
35
35
|
gitHistoryDepth: 20, // 分析 Git 歷史的深度(最近 N 筆 commits)
|
|
36
36
|
excludeAuthors: [], // 排除的作者列表(例如:['bot@example.com', 'ci-user'])
|
package/src/commands/pr.js
CHANGED
|
@@ -8,26 +8,345 @@ import { execSync } from 'child_process';
|
|
|
8
8
|
import { loadPRConfig } from '../core/config-loader.js';
|
|
9
9
|
import { GitOperations } from '../core/git-operations.js';
|
|
10
10
|
import { AIClient } from '../core/ai-client.js';
|
|
11
|
+
import { GitHubAPI } from '../core/github-api.js';
|
|
11
12
|
import { Logger } from '../utils/logger.js';
|
|
13
|
+
import { InteractiveSelect } from '../utils/interactive-select.js';
|
|
12
14
|
import { handleError, getProjectTypePrompt } from '../utils/helpers.js';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
|
-
*
|
|
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, config) {
|
|
84
|
+
const logger = new Logger();
|
|
85
|
+
|
|
86
|
+
console.log('\n' + '═'.repeat(80));
|
|
87
|
+
console.log('🎯 選擇 Reviewers');
|
|
88
|
+
console.log('═'.repeat(80) + '\n');
|
|
89
|
+
|
|
90
|
+
// 先嘗試從 GitHub 抓取團隊和成員
|
|
91
|
+
let githubTeams = {};
|
|
92
|
+
let githubMembers = [];
|
|
93
|
+
|
|
94
|
+
if (config.orgName) {
|
|
95
|
+
const githubAPI = new GitHubAPI({ orgName: config.orgName });
|
|
96
|
+
const githubData = await githubAPI.fetchTeams(config.orgName);
|
|
97
|
+
githubTeams = githubData.teams || {};
|
|
98
|
+
githubMembers = githubData.members || [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 準備選項列表
|
|
102
|
+
const options = [];
|
|
103
|
+
|
|
104
|
+
// 添加團隊選項
|
|
105
|
+
Object.entries(githubTeams).forEach(([slug, team]) => {
|
|
106
|
+
const memberCount = team.members.length;
|
|
107
|
+
options.push({
|
|
108
|
+
type: 'team',
|
|
109
|
+
slug,
|
|
110
|
+
name: team.name,
|
|
111
|
+
label: `${team.name}`,
|
|
112
|
+
extra: ` (${memberCount} 位成員)`,
|
|
113
|
+
members: team.members,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// 添加個人選項(優先使用 Git 歷史建議)
|
|
118
|
+
const individualOptions = new Map();
|
|
119
|
+
|
|
120
|
+
// 先加入建議的 reviewers(基於 Git 歷史)
|
|
121
|
+
console.log('💡 建議的 Reviewers(基於 Git 歷史):\n');
|
|
122
|
+
let suggestionDisplayed = false;
|
|
123
|
+
|
|
124
|
+
suggestedReviewers.forEach((reviewer) => {
|
|
125
|
+
// 自動排除當前使用者(在顯示時就排除)
|
|
126
|
+
if (currentUser && reviewer.email.toLowerCase() === currentUser.email.toLowerCase()) {
|
|
127
|
+
return; // 跳過當前使用者
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
suggestionDisplayed = true;
|
|
131
|
+
console.log(
|
|
132
|
+
` • ${reviewer.name} (@${reviewer.username}) - ${reviewer.commits} commits`
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
individualOptions.set(reviewer.username, {
|
|
136
|
+
type: 'individual',
|
|
137
|
+
login: reviewer.username,
|
|
138
|
+
name: reviewer.name,
|
|
139
|
+
label: `${reviewer.name} (@${reviewer.username})`,
|
|
140
|
+
extra: ` 💡 ${reviewer.commits} commits`,
|
|
141
|
+
suggested: true,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (suggestionDisplayed) {
|
|
146
|
+
console.log('');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 再加入其他 GitHub 組織成員
|
|
150
|
+
githubMembers.forEach((member) => {
|
|
151
|
+
if (!individualOptions.has(member.login)) {
|
|
152
|
+
// 同樣自動排除當前使用者
|
|
153
|
+
if (currentUser && member.login.toLowerCase() === currentUser.username?.toLowerCase()) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
individualOptions.set(member.login, {
|
|
158
|
+
type: 'individual',
|
|
159
|
+
login: member.login,
|
|
160
|
+
name: member.name,
|
|
161
|
+
label: `${member.name} (@${member.login})`,
|
|
162
|
+
extra: '',
|
|
163
|
+
suggested: false,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// 將個人選項加入列表(建議的排在前面)
|
|
169
|
+
const sortedIndividuals = Array.from(individualOptions.values()).sort((a, b) => {
|
|
170
|
+
if (a.suggested && !b.suggested) return -1;
|
|
171
|
+
if (!a.suggested && b.suggested) return 1;
|
|
172
|
+
return 0;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
options.push(...sortedIndividuals);
|
|
176
|
+
|
|
177
|
+
// 如果完全沒有選項
|
|
178
|
+
if (options.length === 0) {
|
|
179
|
+
logger.warning('沒有找到可用的 reviewers');
|
|
180
|
+
console.log('💡 您可以在創建 PR 後手動添加 reviewers\n');
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 使用互動式選擇器
|
|
185
|
+
const selector = new InteractiveSelect();
|
|
186
|
+
const result = await selector.select(options, '🎯 選擇 Reviewers (可多選)');
|
|
187
|
+
|
|
188
|
+
if (result.cancelled) {
|
|
189
|
+
logger.info('已跳過 reviewer 選擇\n');
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 處理選擇結果
|
|
194
|
+
const selectedReviewers = [];
|
|
195
|
+
const selectedTeams = [];
|
|
196
|
+
|
|
197
|
+
result.selected.forEach((item) => {
|
|
198
|
+
if (item.type === 'team') {
|
|
199
|
+
selectedTeams.push(item.slug);
|
|
200
|
+
} else {
|
|
201
|
+
selectedReviewers.push(item.login);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (selectedReviewers.length > 0 || selectedTeams.length > 0) {
|
|
206
|
+
const reviewerList = selectedReviewers.map((u) => `@${u}`).join(', ');
|
|
207
|
+
const teamList = selectedTeams.map((t) => `@${config.orgName}/${t}`).join(', ');
|
|
208
|
+
const allSelected = [reviewerList, teamList].filter(Boolean).join(', ');
|
|
209
|
+
|
|
210
|
+
logger.success(`已選擇: ${allSelected}\n`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { individuals: selectedReviewers, teams: selectedTeams };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 分析影響範圍
|
|
218
|
+
*/
|
|
219
|
+
async function analyzeImpact(changedFiles, diff, commits, config) {
|
|
220
|
+
const logger = new Logger();
|
|
221
|
+
logger.step('正在分析影響範圍和風險...');
|
|
222
|
+
|
|
223
|
+
const truncatedDiff = GitOperations.truncateDiff(diff, config.ai.maxDiffLength);
|
|
224
|
+
const fileList = changedFiles.slice(0, 30).join('\n');
|
|
225
|
+
|
|
226
|
+
const prompt = `你是一個資深的程式碼審查專家,精通 React/Next.js 效能優化與前端架構設計。
|
|
227
|
+
請分析以下程式碼變更,提供專業的影響範圍分析。
|
|
228
|
+
|
|
229
|
+
${getProjectTypePrompt()}
|
|
230
|
+
|
|
231
|
+
**變更檔案列表**:
|
|
232
|
+
${fileList}
|
|
233
|
+
${changedFiles.length > 30 ? `\n... 還有 ${changedFiles.length - 30} 個檔案` : ''}
|
|
234
|
+
|
|
235
|
+
**Commit 訊息**:
|
|
236
|
+
${commits.split('\n').slice(0, 10).join('\n')}
|
|
237
|
+
|
|
238
|
+
**程式碼變更**:
|
|
239
|
+
\`\`\`diff
|
|
240
|
+
${truncatedDiff}
|
|
241
|
+
\`\`\`
|
|
242
|
+
|
|
243
|
+
請以 JSON 格式輸出分析結果:
|
|
244
|
+
|
|
245
|
+
\`\`\`json
|
|
246
|
+
{
|
|
247
|
+
"blastRadius": {
|
|
248
|
+
"modules": ["影響的模組1", "影響的模組2"],
|
|
249
|
+
"impacts": ["影響層面1", "影響層面2"],
|
|
250
|
+
"riskLevel": "低|中|高",
|
|
251
|
+
"riskReasons": ["風險原因1"],
|
|
252
|
+
"externalBehaviors": ["對外行為變更說明"]
|
|
253
|
+
},
|
|
254
|
+
"warnings": [
|
|
255
|
+
{
|
|
256
|
+
"level": "⚠️|ℹ️",
|
|
257
|
+
"message": "問題描述",
|
|
258
|
+
"suggestion": "改善建議"
|
|
259
|
+
}
|
|
260
|
+
]
|
|
261
|
+
}
|
|
262
|
+
\`\`\`
|
|
263
|
+
|
|
264
|
+
**分析重點**:
|
|
265
|
+
1. 影響範圍:識別實際影響的模組和層面(API、資料庫、UI、效能等)
|
|
266
|
+
2. 風險評估:評估風險等級並說明原因
|
|
267
|
+
3. 規範檢查:檢查安全風險、效能問題、最佳實踐
|
|
268
|
+
|
|
269
|
+
請只輸出有效的 JSON。`;
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const response = await AIClient.sendAndWait(prompt, config.ai.model);
|
|
273
|
+
const analysisResult = AIClient.parseJSON(response);
|
|
274
|
+
|
|
275
|
+
const blastRadius = {
|
|
276
|
+
modules: analysisResult.blastRadius?.modules || [],
|
|
277
|
+
impacts: analysisResult.blastRadius?.impacts || [],
|
|
278
|
+
riskLevel: analysisResult.blastRadius?.riskLevel || '低',
|
|
279
|
+
riskReasons: analysisResult.blastRadius?.riskReasons || [],
|
|
280
|
+
externalBehaviors: analysisResult.blastRadius?.externalBehaviors || [],
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const warnings = analysisResult.warnings || [];
|
|
284
|
+
|
|
285
|
+
logger.success('影響範圍分析完成\n');
|
|
286
|
+
return { blastRadius, warnings };
|
|
287
|
+
} catch (error) {
|
|
288
|
+
logger.warning('使用基礎分析...\n');
|
|
289
|
+
return getFallbackAnalysis(changedFiles);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 降級方案:基礎分析
|
|
295
|
+
*/
|
|
296
|
+
function getFallbackAnalysis(changedFiles) {
|
|
297
|
+
const modules = [];
|
|
298
|
+
const impacts = [];
|
|
299
|
+
let riskLevel = '低';
|
|
300
|
+
const riskReasons = [];
|
|
301
|
+
|
|
302
|
+
changedFiles.forEach((file) => {
|
|
303
|
+
const lower = file.toLowerCase();
|
|
304
|
+
if (lower.includes('/api/')) {
|
|
305
|
+
if (!impacts.includes('API 層')) impacts.push('API 層');
|
|
306
|
+
if (!modules.includes('API 服務')) modules.push('API 服務');
|
|
307
|
+
}
|
|
308
|
+
if (lower.includes('/components/') || lower.includes('/pages/')) {
|
|
309
|
+
if (!impacts.includes('使用者介面')) impacts.push('使用者介面');
|
|
310
|
+
if (!modules.includes('前端元件')) modules.push('前端元件');
|
|
311
|
+
}
|
|
312
|
+
if (lower.includes('db') || lower.includes('migration') || lower.includes('schema')) {
|
|
313
|
+
if (!impacts.includes('資料庫')) impacts.push('資料庫');
|
|
314
|
+
riskLevel = '高';
|
|
315
|
+
riskReasons.push('涉及資料庫結構變更');
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const warnings = [];
|
|
320
|
+
const hasTestFiles = changedFiles.some((f) => f.includes('test') || f.includes('spec'));
|
|
321
|
+
if (!hasTestFiles && changedFiles.length > 3) {
|
|
322
|
+
warnings.push({
|
|
323
|
+
level: '⚠️',
|
|
324
|
+
message: '未包含測試檔案',
|
|
325
|
+
suggestion: '建議新增測試確保程式碼品質',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
blastRadius: { modules, impacts, riskLevel, riskReasons, externalBehaviors: [] },
|
|
331
|
+
warnings,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 生成 PR 內容
|
|
16
337
|
*/
|
|
17
338
|
async function generatePRContent(baseBranch, headBranch, config) {
|
|
18
339
|
const logger = new Logger();
|
|
19
|
-
logger.step('AI
|
|
340
|
+
logger.step('AI 正在生成 PR 內容...');
|
|
20
341
|
|
|
21
342
|
// 獲取 diff 和 commits
|
|
22
343
|
const diff = GitOperations.getDiff(baseBranch, headBranch);
|
|
23
344
|
const commits = GitOperations.getCommits(baseBranch, headBranch);
|
|
24
|
-
|
|
25
|
-
// 智慧截断 diff
|
|
26
345
|
const truncatedDiff = GitOperations.truncateDiff(diff, config.ai.maxDiffLength);
|
|
27
346
|
|
|
28
347
|
const prompt = `${getProjectTypePrompt()}
|
|
29
348
|
|
|
30
|
-
|
|
349
|
+
請根據以下 commit 訊息和程式碼變更,生成清晰的 Pull Request 標題和描述。
|
|
31
350
|
|
|
32
351
|
**Commits 列表**:
|
|
33
352
|
${commits}
|
|
@@ -35,45 +354,238 @@ ${commits}
|
|
|
35
354
|
**Git Diff**:
|
|
36
355
|
${truncatedDiff}
|
|
37
356
|
|
|
38
|
-
|
|
357
|
+
**輸出格式**(JSON):
|
|
39
358
|
{
|
|
40
|
-
"title": "PR
|
|
41
|
-
"description": "PR
|
|
359
|
+
"title": "[type]: PR 標題(type 為 feat/fix/refactor/perf/docs/style/test/chore 之一,繁體中文,50 字內)",
|
|
360
|
+
"description": "PR 描述(Markdown 格式,繁體中文)"
|
|
42
361
|
}
|
|
43
362
|
|
|
44
|
-
**PR
|
|
45
|
-
1. ## 📝 變更摘要(簡述主要變更)
|
|
46
|
-
2. ## ✨ 主要功能(列出新增功能或修正項目)
|
|
47
|
-
3. ## 🔧 技術細節(選填,如有重要的技術變更)
|
|
48
|
-
4. ## ✅ 測試(如何測試這些變更)
|
|
363
|
+
**PR 描述必須包含**:
|
|
49
364
|
|
|
50
|
-
|
|
365
|
+
## 📝 變更摘要
|
|
366
|
+
[簡述這個 PR 的主要目的和影響範圍,2-3 句話]
|
|
367
|
+
|
|
368
|
+
## 🎯 主要變更
|
|
369
|
+
- [變更項目 1]
|
|
370
|
+
- [變更項目 2]
|
|
371
|
+
- [變更項目 3]
|
|
372
|
+
|
|
373
|
+
## 🔀 變更類型
|
|
374
|
+
- [ ] ✨ 新功能 (feat)
|
|
375
|
+
- [ ] 🐛 Bug 修復 (fix)
|
|
376
|
+
- [ ] ♻️ 重構 (refactor)
|
|
377
|
+
- [ ] 💄 樣式調整 (style)
|
|
378
|
+
- [ ] 📝 文件更新 (docs)
|
|
379
|
+
- [ ] ⚡ 效能改進 (perf)
|
|
380
|
+
- [ ] 🔧 其他 (chore)
|
|
381
|
+
|
|
382
|
+
## 🧪 測試方法
|
|
383
|
+
1. [具體的測試步驟 1]
|
|
384
|
+
2. [具體的測試步驟 2]
|
|
385
|
+
|
|
386
|
+
## 💥 Breaking Changes
|
|
387
|
+
[如果有破壞性變更請詳細說明,沒有則填寫「無」]
|
|
388
|
+
|
|
389
|
+
## 📌 注意事項
|
|
390
|
+
[需要特別注意的事項,沒有則填寫「無」]
|
|
391
|
+
|
|
392
|
+
**規則**:
|
|
393
|
+
1. 標題格式:type: 簡短描述(不超過 50 字)
|
|
394
|
+
2. 全部使用繁體中文
|
|
395
|
+
3. 只輸出 JSON,不要其他文字
|
|
396
|
+
`;
|
|
51
397
|
|
|
52
398
|
const response = await AIClient.sendAndWait(prompt, config.ai.model);
|
|
53
399
|
|
|
54
400
|
try {
|
|
55
401
|
const prContent = AIClient.parseJSON(response);
|
|
56
|
-
logger.success('PR
|
|
402
|
+
logger.success('PR 內容生成完成\n');
|
|
57
403
|
return prContent;
|
|
58
404
|
} catch (error) {
|
|
59
405
|
throw new Error(`無法解析 AI 回應: ${error.message}`);
|
|
60
406
|
}
|
|
61
407
|
}
|
|
62
408
|
|
|
409
|
+
/**
|
|
410
|
+
* 分析 Labels
|
|
411
|
+
*/
|
|
412
|
+
function analyzeLabels(prData) {
|
|
413
|
+
const labels = new Set();
|
|
414
|
+
|
|
415
|
+
// 根據 commit type
|
|
416
|
+
const typeLabels = {
|
|
417
|
+
feat: 'feature',
|
|
418
|
+
fix: 'bug',
|
|
419
|
+
refactor: 'refactor',
|
|
420
|
+
perf: 'performance',
|
|
421
|
+
docs: 'documentation',
|
|
422
|
+
test: 'testing',
|
|
423
|
+
style: 'style',
|
|
424
|
+
chore: 'chore',
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const titleMatch = prData.title.match(/^(\w+):/);
|
|
428
|
+
if (titleMatch) {
|
|
429
|
+
const type = titleMatch[1];
|
|
430
|
+
if (typeLabels[type]) {
|
|
431
|
+
labels.add(typeLabels[type]);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// 根據影響範圍
|
|
436
|
+
if (prData.blastRadius) {
|
|
437
|
+
if (prData.blastRadius.impacts.includes('API 層')) {
|
|
438
|
+
labels.add('api-change');
|
|
439
|
+
}
|
|
440
|
+
if (prData.blastRadius.impacts.includes('資料庫')) {
|
|
441
|
+
labels.add('database');
|
|
442
|
+
}
|
|
443
|
+
if (prData.blastRadius.impacts.includes('使用者介面')) {
|
|
444
|
+
labels.add('ui');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 根據風險等級
|
|
448
|
+
if (prData.blastRadius.riskLevel === '高') {
|
|
449
|
+
labels.add('high-risk');
|
|
450
|
+
labels.add('needs-careful-review');
|
|
451
|
+
} else if (prData.blastRadius.riskLevel === '中') {
|
|
452
|
+
labels.add('medium-risk');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// 根據變更規模
|
|
457
|
+
if (prData.stats) {
|
|
458
|
+
if (prData.stats.filesChanged > 20) {
|
|
459
|
+
labels.add('large-change');
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// 根據警告
|
|
464
|
+
if (prData.warnings && prData.warnings.length > 0) {
|
|
465
|
+
labels.add('has-warnings');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return Array.from(labels);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* 應用 Labels
|
|
473
|
+
*/
|
|
474
|
+
function applyLabels(prUrl, labels) {
|
|
475
|
+
if (!labels || labels.length === 0) return;
|
|
476
|
+
|
|
477
|
+
const logger = new Logger();
|
|
478
|
+
logger.info(`正在添加 ${labels.length} 個 Labels...`);
|
|
479
|
+
|
|
480
|
+
// 從 PR URL 提取 PR 號碼
|
|
481
|
+
const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
|
|
482
|
+
if (!prNumberMatch) return;
|
|
483
|
+
|
|
484
|
+
const prNumber = prNumberMatch[1];
|
|
485
|
+
const requestBody = JSON.stringify({ labels });
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
execSync(
|
|
489
|
+
`printf '%s' '${requestBody.replace(
|
|
490
|
+
/'/g,
|
|
491
|
+
"'\\''"
|
|
492
|
+
)}' | gh api repos/:owner/:repo/issues/${prNumber}/labels --input - -X POST`,
|
|
493
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
494
|
+
);
|
|
495
|
+
logger.success(`成功添加 Labels: ${labels.join(', ')}\n`);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
logger.warning('無法自動添加 Labels,請手動操作\n');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* 附加影響分析到 PR body
|
|
503
|
+
*/
|
|
504
|
+
function appendAnalysisToBody(body, blastRadius, warnings) {
|
|
505
|
+
let enhancedBody = body;
|
|
506
|
+
|
|
507
|
+
// 添加影響範圍
|
|
508
|
+
enhancedBody += '\n\n---\n\n## 💥 影響範圍分析\n\n';
|
|
509
|
+
|
|
510
|
+
if (blastRadius.modules.length > 0) {
|
|
511
|
+
enhancedBody += `**影響模組**:${blastRadius.modules.join('、')}\n\n`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (blastRadius.impacts.length > 0) {
|
|
515
|
+
enhancedBody += `**影響層面**:${blastRadius.impacts.join('、')}\n\n`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const riskEmojiMap = { 高: '🔴', 中: '🟡', 低: '🟢' };
|
|
519
|
+
const riskEmoji = riskEmojiMap[blastRadius.riskLevel] || '🟢';
|
|
520
|
+
enhancedBody += `**風險等級**:${riskEmoji} ${blastRadius.riskLevel}\n\n`;
|
|
521
|
+
|
|
522
|
+
if (blastRadius.riskReasons && blastRadius.riskReasons.length > 0) {
|
|
523
|
+
enhancedBody += `**風險因素**:\n`;
|
|
524
|
+
blastRadius.riskReasons.forEach((reason) => {
|
|
525
|
+
enhancedBody += `- ${reason}\n`;
|
|
526
|
+
});
|
|
527
|
+
enhancedBody += '\n';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (blastRadius.externalBehaviors && blastRadius.externalBehaviors.length > 0) {
|
|
531
|
+
enhancedBody += `**對外行為變更**:\n`;
|
|
532
|
+
blastRadius.externalBehaviors.forEach((behavior) => {
|
|
533
|
+
enhancedBody += `- ${behavior}\n`;
|
|
534
|
+
});
|
|
535
|
+
enhancedBody += '\n';
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 添加規範警告
|
|
539
|
+
if (warnings.length > 0) {
|
|
540
|
+
enhancedBody += '\n## ⚠️ 注意事項\n\n';
|
|
541
|
+
warnings.forEach((warning) => {
|
|
542
|
+
enhancedBody += `${warning.level} **${warning.message}**\n`;
|
|
543
|
+
if (warning.suggestion) {
|
|
544
|
+
enhancedBody += ` - 💡 ${warning.suggestion}\n`;
|
|
545
|
+
}
|
|
546
|
+
enhancedBody += '\n';
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return enhancedBody;
|
|
551
|
+
}
|
|
552
|
+
|
|
63
553
|
/**
|
|
64
554
|
* 顯示 PR 預覽
|
|
65
555
|
*/
|
|
66
|
-
function displayPreview(
|
|
67
|
-
console.log('\n' + '═'.repeat(
|
|
556
|
+
function displayPreview(prData, stats) {
|
|
557
|
+
console.log('\n' + '═'.repeat(80));
|
|
68
558
|
console.log('📋 PR 預覽');
|
|
69
|
-
console.log('═'.repeat(
|
|
70
|
-
console.log(`\n標題: ${
|
|
559
|
+
console.log('═'.repeat(80));
|
|
560
|
+
console.log(`\n標題: ${prData.title}\n`);
|
|
71
561
|
console.log(`統計: ${stats.stats}`);
|
|
72
562
|
console.log(`檔案數: ${stats.filesChanged} 個檔案\n`);
|
|
73
|
-
|
|
563
|
+
|
|
564
|
+
// 顯示影響範圍
|
|
565
|
+
if (prData.blastRadius) {
|
|
566
|
+
const { blastRadius } = prData;
|
|
567
|
+
console.log('─'.repeat(80));
|
|
568
|
+
console.log('💥 影響範圍:\n');
|
|
569
|
+
if (blastRadius.modules.length > 0) {
|
|
570
|
+
console.log(` 模組: ${blastRadius.modules.join('、')}`);
|
|
571
|
+
}
|
|
572
|
+
if (blastRadius.impacts.length > 0) {
|
|
573
|
+
console.log(` 層面: ${blastRadius.impacts.join('、')}`);
|
|
574
|
+
}
|
|
575
|
+
const riskEmojiMap = { 高: '🔴', 中: '🟡', 低: '🟢' };
|
|
576
|
+
const riskEmoji = riskEmojiMap[blastRadius.riskLevel] || '🟢';
|
|
577
|
+
console.log(` 風險: ${riskEmoji} ${blastRadius.riskLevel}`);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// 顯示 Labels
|
|
581
|
+
if (prData.labels && prData.labels.length > 0) {
|
|
582
|
+
console.log(`\n🏷️ Labels: ${prData.labels.join(', ')}`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
console.log('\n' + '─'.repeat(80));
|
|
74
586
|
console.log('描述:\n');
|
|
75
|
-
console.log(
|
|
76
|
-
console.log('\n' + '═'.repeat(
|
|
587
|
+
console.log(prData.description);
|
|
588
|
+
console.log('\n' + '═'.repeat(80) + '\n');
|
|
77
589
|
}
|
|
78
590
|
|
|
79
591
|
/**
|
|
@@ -88,6 +600,13 @@ export async function prCommand() {
|
|
|
88
600
|
// 載入配置
|
|
89
601
|
const config = await loadPRConfig();
|
|
90
602
|
|
|
603
|
+
if (config.output.verbose) {
|
|
604
|
+
console.log('📋 使用配置:');
|
|
605
|
+
console.log(` AI Model: ${config.ai.model}`);
|
|
606
|
+
console.log(` Max Diff Length: ${config.ai.maxDiffLength}`);
|
|
607
|
+
console.log('');
|
|
608
|
+
}
|
|
609
|
+
|
|
91
610
|
// 獲取當前分支
|
|
92
611
|
const currentBranch = GitOperations.getCurrentBranch();
|
|
93
612
|
let headBranch = config.headBranch || currentBranch;
|
|
@@ -132,16 +651,45 @@ export async function prCommand() {
|
|
|
132
651
|
logger.success('同步完成\n');
|
|
133
652
|
}
|
|
134
653
|
|
|
135
|
-
//
|
|
654
|
+
// 獲取變更統計和檔案列表
|
|
136
655
|
const stats = GitOperations.getChangeStats(baseBranch, headBranch);
|
|
656
|
+
const changedFiles = GitOperations.getChangedFiles(baseBranch, headBranch);
|
|
137
657
|
console.log(`📈 變更統計: ${stats.stats}`);
|
|
138
658
|
console.log(`📁 影響檔案: ${stats.filesChanged} 個\n`);
|
|
139
659
|
|
|
140
|
-
//
|
|
660
|
+
// 1. 分析影響範圍
|
|
661
|
+
const diff = GitOperations.getDiff(baseBranch, headBranch);
|
|
662
|
+
const commits = GitOperations.getCommits(baseBranch, headBranch);
|
|
663
|
+
const { blastRadius, warnings } = await analyzeImpact(changedFiles, diff, commits, config);
|
|
664
|
+
|
|
665
|
+
// 2. 生成 PR 內容
|
|
141
666
|
const prContent = await generatePRContent(baseBranch, headBranch, config);
|
|
142
667
|
|
|
668
|
+
// 3. 附加影響分析到 PR body
|
|
669
|
+
const enhancedDescription = appendAnalysisToBody(
|
|
670
|
+
prContent.description,
|
|
671
|
+
blastRadius,
|
|
672
|
+
warnings
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
// 4. 分析 Labels
|
|
676
|
+
const labels = analyzeLabels({
|
|
677
|
+
title: prContent.title,
|
|
678
|
+
blastRadius,
|
|
679
|
+
warnings,
|
|
680
|
+
stats,
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const prData = {
|
|
684
|
+
title: prContent.title,
|
|
685
|
+
description: enhancedDescription,
|
|
686
|
+
blastRadius,
|
|
687
|
+
warnings,
|
|
688
|
+
labels,
|
|
689
|
+
};
|
|
690
|
+
|
|
143
691
|
// 顯示預覽
|
|
144
|
-
displayPreview(
|
|
692
|
+
displayPreview(prData, stats);
|
|
145
693
|
|
|
146
694
|
// 預覽模式:僅顯示不創建
|
|
147
695
|
if (config.preview) {
|
|
@@ -149,14 +697,27 @@ export async function prCommand() {
|
|
|
149
697
|
return;
|
|
150
698
|
}
|
|
151
699
|
|
|
700
|
+
// 5. Reviewer 選擇(如果啟用)
|
|
701
|
+
let selectedReviewers = { individuals: [], teams: [] };
|
|
702
|
+
if (config.reviewers.autoSelect) {
|
|
703
|
+
const currentUser = getCurrentUser();
|
|
704
|
+
const suggestedReviewers = getReviewersByGitHistory(changedFiles, config);
|
|
705
|
+
|
|
706
|
+
if (suggestedReviewers.length > 0 || config.orgName) {
|
|
707
|
+
selectedReviewers = await selectReviewers(suggestedReviewers, currentUser, config);
|
|
708
|
+
} else {
|
|
709
|
+
logger.info('未找到建議的 reviewers(可能是新專案或檔案無歷史記錄)\n');
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
152
713
|
// 創建 PR(使用 GitHub CLI)
|
|
153
714
|
logger.step('創建 Pull Request...');
|
|
154
715
|
|
|
155
716
|
// 準備 PR body
|
|
156
|
-
const prBody =
|
|
717
|
+
const prBody = prData.description;
|
|
157
718
|
|
|
158
719
|
// 構建 gh pr create 命令
|
|
159
|
-
let ghCommand = `gh pr create --base ${baseBranch} --head ${headBranch} --title "${
|
|
720
|
+
let ghCommand = `gh pr create --base ${baseBranch} --head ${headBranch} --title "${prData.title.replace(/"/g, '\\"')}"`;
|
|
160
721
|
|
|
161
722
|
// 將 body 寫入臨時檔案
|
|
162
723
|
const { writeFileSync, unlinkSync } = await import('fs');
|
|
@@ -168,12 +729,32 @@ export async function prCommand() {
|
|
|
168
729
|
ghCommand += ' --draft';
|
|
169
730
|
}
|
|
170
731
|
|
|
732
|
+
// 添加 individual reviewers
|
|
733
|
+
if (selectedReviewers.individuals && selectedReviewers.individuals.length > 0) {
|
|
734
|
+
ghCommand += ` --reviewer ${selectedReviewers.individuals.join(',')}`;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// 添加 team reviewers(需要加上組織名稱前綴)
|
|
738
|
+
if (selectedReviewers.teams && selectedReviewers.teams.length > 0) {
|
|
739
|
+
const teamReviewers = selectedReviewers.teams.map((team) => `${config.orgName}/${team}`);
|
|
740
|
+
if (selectedReviewers.individuals && selectedReviewers.individuals.length > 0) {
|
|
741
|
+
ghCommand += `,${teamReviewers.join(',')}`;
|
|
742
|
+
} else {
|
|
743
|
+
ghCommand += ` --reviewer ${teamReviewers.join(',')}`;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
171
747
|
try {
|
|
172
748
|
const result = execSync(ghCommand, { encoding: 'utf-8' });
|
|
173
749
|
unlinkSync(tmpFile);
|
|
174
750
|
|
|
175
751
|
logger.success('PR 創建成功!\n');
|
|
176
752
|
console.log(result);
|
|
753
|
+
|
|
754
|
+
// 應用 Labels(如果啟用)
|
|
755
|
+
if (config.github.autoLabels && labels.length > 0) {
|
|
756
|
+
applyLabels(result, labels);
|
|
757
|
+
}
|
|
177
758
|
} catch (error) {
|
|
178
759
|
try {
|
|
179
760
|
unlinkSync(tmpFile);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* 支援從任何目錄下的 .ai-git-config.mjs 載入配置
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { execSync } from 'child_process';
|
|
7
8
|
import { existsSync } from 'fs';
|
|
8
9
|
import { resolve } from 'path';
|
|
9
10
|
|
|
@@ -174,12 +175,23 @@ export async function loadPRConfig() {
|
|
|
174
175
|
if (!config) {
|
|
175
176
|
config = {
|
|
176
177
|
ai: { model: 'gpt-4.1', maxDiffLength: 8000, maxRetries: 3 },
|
|
177
|
-
github: { orgName: '
|
|
178
|
-
reviewers: { autoSelect:
|
|
178
|
+
github: { orgName: '', defaultBase: 'release', autoLabels: true },
|
|
179
|
+
reviewers: { autoSelect: true, maxSuggested: 5, gitHistoryDepth: 20, excludeAuthors: [] },
|
|
179
180
|
output: { verbose: false, saveHistory: false },
|
|
180
181
|
};
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
// 如果 orgName 為空,嘗試從 git remote 取得,否則使用 'kingsinfo-project'
|
|
185
|
+
if (!config.github.orgName) {
|
|
186
|
+
try {
|
|
187
|
+
const remoteUrl = execSync('git config --get remote.origin.url', { encoding: 'utf-8' }).trim();
|
|
188
|
+
const match = remoteUrl.match(/github\.com[:/]([^/]+)\//);
|
|
189
|
+
config.github.orgName = match ? match[1] : 'kingsinfo-project';
|
|
190
|
+
} catch (error) {
|
|
191
|
+
config.github.orgName = 'kingsinfo-project';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
183
195
|
// 合併命令行參數
|
|
184
196
|
const cliConfig = parsePRCliArgs();
|
|
185
197
|
if (cliConfig.model) config.ai.model = cliConfig.model;
|
|
@@ -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
|
+
}
|
|
@@ -17,14 +17,14 @@ export default {
|
|
|
17
17
|
|
|
18
18
|
// GitHub 設定(用於 PR 工具)
|
|
19
19
|
github: {
|
|
20
|
-
orgName:
|
|
21
|
-
defaultBase: '
|
|
20
|
+
orgName: '', // GitHub 組織名稱(留空則自動從 git remote 取得,或使用 'kingsinfo-project')
|
|
21
|
+
defaultBase: 'release', // 預設目標分支:'release' | 'auto' | 'main' | 'develop' | 'master'
|
|
22
22
|
autoLabels: true, // 自動添加 Labels
|
|
23
23
|
},
|
|
24
24
|
|
|
25
25
|
// Reviewer 設定(用於 PR 工具)
|
|
26
26
|
reviewers: {
|
|
27
|
-
autoSelect:
|
|
27
|
+
autoSelect: true, // 是否啟用 reviewer 選擇功能(true: 啟用互動選擇 | false: 跳過選擇)
|
|
28
28
|
maxSuggested: 5, // 最多建議的 reviewers 數量(基於 Git 歷史分析)
|
|
29
29
|
gitHistoryDepth: 20, // Git 歷史分析深度(查看最近 N 筆 commit)
|
|
30
30
|
excludeAuthors: [], // 排除特定作者(email 或 username),例如: ['bot@', 'ci-user']
|