ai-git-tools 2.0.7 → 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 +1 -1
- package/src/commands/pr.js +15 -753
package/package.json
CHANGED
package/src/commands/pr.js
CHANGED
|
@@ -1,768 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PR 命令
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* PR 命令 - 完整複製自 scripts/ai-auto-pr.mjs
|
|
3
|
+
*
|
|
4
|
+
* 使用 scripts/ai-pr-modules 的完整邏輯
|
|
5
|
+
* 確保功能與 scripts 版本完全相同
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { GitHubAPI } from '../core/github-api.js';
|
|
12
|
-
import { Logger } from '../utils/logger.js';
|
|
13
|
-
import { InteractiveSelect } from '../utils/interactive-select.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
|
-
*
|
|
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 內容
|
|
337
|
-
*/
|
|
338
|
-
async function generatePRContent(baseBranch, headBranch, config) {
|
|
339
|
-
const logger = new Logger();
|
|
340
|
-
logger.step('AI 正在生成 PR 內容...');
|
|
341
|
-
|
|
342
|
-
// 獲取 diff 和 commits
|
|
343
|
-
const diff = GitOperations.getDiff(baseBranch, headBranch);
|
|
344
|
-
const commits = GitOperations.getCommits(baseBranch, headBranch);
|
|
345
|
-
const truncatedDiff = GitOperations.truncateDiff(diff, config.ai.maxDiffLength);
|
|
346
|
-
|
|
347
|
-
const prompt = `${getProjectTypePrompt()}
|
|
348
|
-
|
|
349
|
-
請根據以下 commit 訊息和程式碼變更,生成清晰的 Pull Request 標題和描述。
|
|
350
|
-
|
|
351
|
-
**Commits 列表**:
|
|
352
|
-
${commits}
|
|
353
|
-
|
|
354
|
-
**Git Diff**:
|
|
355
|
-
${truncatedDiff}
|
|
356
|
-
|
|
357
|
-
**輸出格式**(JSON):
|
|
358
|
-
{
|
|
359
|
-
"title": "[type]: PR 標題(type 為 feat/fix/refactor/perf/docs/style/test/chore 之一,繁體中文,50 字內)",
|
|
360
|
-
"description": "PR 描述(Markdown 格式,繁體中文)"
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
**PR 描述必須包含**:
|
|
364
|
-
|
|
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
|
-
`;
|
|
397
|
-
|
|
398
|
-
const response = await AIClient.sendAndWait(prompt, config.ai.model);
|
|
399
|
-
|
|
400
|
-
try {
|
|
401
|
-
const prContent = AIClient.parseJSON(response);
|
|
402
|
-
logger.success('PR 內容生成完成\n');
|
|
403
|
-
return prContent;
|
|
404
|
-
} catch (error) {
|
|
405
|
-
throw new Error(`無法解析 AI 回應: ${error.message}`);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
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
|
-
|
|
553
|
-
/**
|
|
554
|
-
* 顯示 PR 預覽
|
|
555
|
-
*/
|
|
556
|
-
function displayPreview(prData, stats) {
|
|
557
|
-
console.log('\n' + '═'.repeat(80));
|
|
558
|
-
console.log('📋 PR 預覽');
|
|
559
|
-
console.log('═'.repeat(80));
|
|
560
|
-
console.log(`\n標題: ${prData.title}\n`);
|
|
561
|
-
console.log(`統計: ${stats.stats}`);
|
|
562
|
-
console.log(`檔案數: ${stats.filesChanged} 個檔案\n`);
|
|
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));
|
|
586
|
-
console.log('描述:\n');
|
|
587
|
-
console.log(prData.description);
|
|
588
|
-
console.log('\n' + '═'.repeat(80) + '\n');
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* PR 命令主函數
|
|
14
|
+
* PR 命令主函數(完全照抄 scripts/ai-auto-pr.mjs)
|
|
593
15
|
*/
|
|
594
16
|
export async function prCommand() {
|
|
595
17
|
const logger = new Logger();
|
|
596
18
|
|
|
597
19
|
try {
|
|
598
|
-
logger.header('AI Auto PR Generator');
|
|
599
|
-
|
|
600
|
-
// 載入配置
|
|
601
|
-
const config = await loadPRConfig();
|
|
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
|
-
|
|
610
|
-
// 獲取當前分支
|
|
611
|
-
const currentBranch = GitOperations.getCurrentBranch();
|
|
612
|
-
let headBranch = config.headBranch || currentBranch;
|
|
613
|
-
let baseBranch = config.baseBranch;
|
|
614
|
-
|
|
615
|
-
// 自動偵測 base branch
|
|
616
|
-
if (!baseBranch) {
|
|
617
|
-
logger.step('正在偵測 release 分支...\n');
|
|
618
|
-
const latestRelease = GitOperations.findLatestReleaseBranch();
|
|
619
|
-
|
|
620
|
-
if (latestRelease) {
|
|
621
|
-
baseBranch = latestRelease;
|
|
622
|
-
logger.success(`自動選擇目標分支: ${baseBranch}\n`);
|
|
623
|
-
} else {
|
|
624
|
-
logger.error('未偵測到任何 release 分支');
|
|
625
|
-
console.log('💡 使用 --base 參數指定目標分支');
|
|
626
|
-
process.exit(1);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// 檢查是否在 base branch
|
|
631
|
-
if (currentBranch === baseBranch) {
|
|
632
|
-
logger.error(`你目前在 ${baseBranch} 分支,請切換到 feature 分支`);
|
|
633
|
-
console.log('💡 切換到 feature 分支: git checkout <feature-branch>');
|
|
634
|
-
process.exit(1);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
console.log('\n📌 分支資訊:');
|
|
638
|
-
console.log(` Base: ${baseBranch}`);
|
|
639
|
-
console.log(` Head: ${headBranch}\n`);
|
|
640
|
-
|
|
641
|
-
// 推送到遠端(預覽模式跳過)
|
|
642
|
-
if (!config.preview) {
|
|
643
|
-
logger.step(`推送到遠端分支: origin/${headBranch}`);
|
|
644
|
-
GitOperations.push(headBranch);
|
|
645
|
-
logger.success('推送成功\n');
|
|
646
|
-
|
|
647
|
-
// 等待 GitHub 同步
|
|
648
|
-
logger.info('等待 GitHub 同步...');
|
|
649
|
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
650
|
-
GitOperations.fetch();
|
|
651
|
-
logger.success('同步完成\n');
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// 獲取變更統計和檔案列表
|
|
655
|
-
const stats = GitOperations.getChangeStats(baseBranch, headBranch);
|
|
656
|
-
const changedFiles = GitOperations.getChangedFiles(baseBranch, headBranch);
|
|
657
|
-
console.log(`📈 變更統計: ${stats.stats}`);
|
|
658
|
-
console.log(`📁 影響檔案: ${stats.filesChanged} 個\n`);
|
|
659
|
-
|
|
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 內容
|
|
666
|
-
const prContent = await generatePRContent(baseBranch, headBranch, config);
|
|
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
|
-
|
|
691
|
-
// 顯示預覽
|
|
692
|
-
displayPreview(prData, stats);
|
|
693
|
-
|
|
694
|
-
// 預覽模式:僅顯示不創建
|
|
695
|
-
if (config.preview) {
|
|
696
|
-
logger.info('預覽模式:未創建 PR');
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
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
|
-
|
|
713
|
-
// 創建 PR(使用 GitHub CLI)
|
|
714
|
-
logger.step('創建 Pull Request...');
|
|
715
|
-
|
|
716
|
-
// 準備 PR body
|
|
717
|
-
const prBody = prData.description;
|
|
718
|
-
|
|
719
|
-
// 構建 gh pr create 命令
|
|
720
|
-
let ghCommand = `gh pr create --base ${baseBranch} --head ${headBranch} --title "${prData.title.replace(/"/g, '\\"')}"`;
|
|
721
|
-
|
|
722
|
-
// 將 body 寫入臨時檔案
|
|
723
|
-
const { writeFileSync, unlinkSync } = await import('fs');
|
|
724
|
-
const tmpFile = '/tmp/pr-body-temp.md';
|
|
725
|
-
writeFileSync(tmpFile, prBody, 'utf-8');
|
|
726
|
-
ghCommand += ` --body-file ${tmpFile}`;
|
|
727
|
-
|
|
728
|
-
if (config.draft) {
|
|
729
|
-
ghCommand += ' --draft';
|
|
730
|
-
}
|
|
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
|
-
|
|
747
|
-
try {
|
|
748
|
-
const result = execSync(ghCommand, { encoding: 'utf-8' });
|
|
749
|
-
unlinkSync(tmpFile);
|
|
20
|
+
logger.header('AI Auto PR Generator (v2.0 Enhanced)');
|
|
750
21
|
|
|
751
|
-
|
|
752
|
-
|
|
22
|
+
// 載入配置(使用 scripts/ 的配置載入邏輯)
|
|
23
|
+
const config = await loadConfig();
|
|
753
24
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
}
|
|
758
|
-
} catch (error) {
|
|
759
|
-
try {
|
|
760
|
-
unlinkSync(tmpFile);
|
|
761
|
-
} catch (e) {
|
|
762
|
-
// 忽略
|
|
763
|
-
}
|
|
764
|
-
throw new Error(`創建 PR 失敗: ${error.message}`);
|
|
765
|
-
}
|
|
25
|
+
// 執行工作流程(使用 scripts/ 的完整工作流)
|
|
26
|
+
const workflow = new PRWorkflow(config);
|
|
27
|
+
await workflow.execute();
|
|
766
28
|
} catch (error) {
|
|
767
29
|
handleError(error);
|
|
768
30
|
process.exit(1);
|