ai-git-tools 2.0.74 → 2.0.76
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
|
@@ -96,16 +96,30 @@ function getAllChanges() {
|
|
|
96
96
|
async function analyzeAndGroupChanges(changes, config) {
|
|
97
97
|
console.log('🤖 正在使用 AI 分析變更並分組...\n');
|
|
98
98
|
|
|
99
|
-
//
|
|
100
|
-
const
|
|
99
|
+
// 檔案數量超過此閾值時,改為僅傳送檔名+狀態,不含 diff 內容,避免 prompt 超過模型 context window
|
|
100
|
+
const FILE_DIFF_THRESHOLD = 30;
|
|
101
|
+
const useFilenameOnly = changes.length > FILE_DIFF_THRESHOLD;
|
|
102
|
+
|
|
103
|
+
if (useFilenameOnly) {
|
|
104
|
+
console.log(`📝 檔案數量較多(${changes.length} 個),使用檔名分析模式以避免超出模型限制\n`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 每個檔案的 diff 字元預算(字元數,非行數)
|
|
108
|
+
const perFileBudget = Math.floor(config.ai.maxDiffLength / Math.max(changes.length, 1));
|
|
109
|
+
|
|
101
110
|
const changeSummary = changes
|
|
102
111
|
.map((change, index) => {
|
|
103
|
-
const diff = getFileDiff(change.filePath, change.isNew, change.isDeleted);
|
|
104
|
-
const lines = diff.split('\n');
|
|
105
|
-
const truncatedDiff = lines.slice(0, Math.min(50, maxDiffPerFile / 100)).join('\n');
|
|
106
112
|
let status = '(已修改)';
|
|
107
113
|
if (change.isNew) status = '(新檔案)';
|
|
108
114
|
if (change.isDeleted) status = '(已刪除)';
|
|
115
|
+
|
|
116
|
+
if (useFilenameOnly) {
|
|
117
|
+
return `[檔案 ${index}] ${change.filePath} ${status}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const diff = getFileDiff(change.filePath, change.isNew, change.isDeleted);
|
|
121
|
+
const truncatedDiff =
|
|
122
|
+
diff.length > perFileBudget ? diff.substring(0, perFileBudget) + '\n...(已截斷)' : diff;
|
|
109
123
|
return `[檔案 ${index}] ${change.filePath}\n${status}\n${truncatedDiff}\n`;
|
|
110
124
|
})
|
|
111
125
|
.join('\n---\n\n');
|
|
@@ -180,13 +194,19 @@ ${changeSummary}
|
|
|
180
194
|
* 為特定群組生成 commit message
|
|
181
195
|
*/
|
|
182
196
|
async function generateCommitMessage(group, files, config) {
|
|
197
|
+
// 每個檔案最多 2000 字元,避免單一群組內大量 diff 超出限制
|
|
198
|
+
const MAX_DIFF_PER_FILE = 2000;
|
|
183
199
|
const filesList = files
|
|
184
200
|
.map((file) => {
|
|
185
201
|
const diff = getFileDiff(file.filePath, file.isNew, file.isDeleted);
|
|
202
|
+
const truncatedDiff =
|
|
203
|
+
diff.length > MAX_DIFF_PER_FILE
|
|
204
|
+
? diff.substring(0, MAX_DIFF_PER_FILE) + '\n...(已截斷)'
|
|
205
|
+
: diff;
|
|
186
206
|
let status = '修改';
|
|
187
207
|
if (file.isNew) status = '新增';
|
|
188
208
|
if (file.isDeleted) status = '刪除';
|
|
189
|
-
return `檔案: ${file.filePath} [${status}]\n${
|
|
209
|
+
return `檔案: ${file.filePath} [${status}]\n${truncatedDiff}`;
|
|
190
210
|
})
|
|
191
211
|
.join('\n\n---\n\n');
|
|
192
212
|
|
|
@@ -2,6 +2,9 @@ import { CopilotClient, approveAll } from '@github/copilot-sdk';
|
|
|
2
2
|
import { CONSTANTS, PROJECT_SKILLS_CONTEXT } from '../utils/constants.js';
|
|
3
3
|
import { getSkillsSummaryForPrompt, log } from '../utils/helpers.js';
|
|
4
4
|
|
|
5
|
+
// 讓 CopilotClient 啟動的 Node 子程序繼承此設定,靜音 SQLite ExperimentalWarning
|
|
6
|
+
process.env.NODE_NO_WARNINGS = '1';
|
|
7
|
+
|
|
5
8
|
// CopilotClient 子程序啟動 + AI 模型回應可能共需 60-120s,設為 150s 保留足夠緩衝
|
|
6
9
|
const AI_TIMEOUT_MS = 150000;
|
|
7
10
|
|
|
@@ -35,6 +38,14 @@ export class AIAnalyzer {
|
|
|
35
38
|
});
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* 預熱 CopilotClient — 在 workflow 開始時盡早呼叫,
|
|
43
|
+
* 讓 subprocess 在 git 操作期間並行啟動,避免 session.idle timeout
|
|
44
|
+
*/
|
|
45
|
+
async warmup() {
|
|
46
|
+
await this._getOrCreateClient();
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
/**
|
|
39
50
|
* 釋放 CopilotClient 子程序資源
|
|
40
51
|
*/
|
|
@@ -51,28 +62,55 @@ export class AIAnalyzer {
|
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
/**
|
|
54
|
-
* 生成 PR
|
|
65
|
+
* 生成 PR 內容(失敗時自動重建 client 重試一次)
|
|
55
66
|
*/
|
|
56
67
|
async generatePRContent(commits, diff) {
|
|
57
|
-
const
|
|
58
|
-
const prompt = this.buildPRPrompt(commits, diff, skillsSummary);
|
|
68
|
+
const prompt = this.buildPRPrompt(commits, diff);
|
|
59
69
|
|
|
60
|
-
|
|
70
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
71
|
+
try {
|
|
72
|
+
if (attempt === 2) {
|
|
73
|
+
log.info(' 重試 AI 請求(重建 session)...\n');
|
|
74
|
+
await this.close(); // 釋放舊 client,下一次 _createSession 會建新的
|
|
75
|
+
}
|
|
61
76
|
|
|
62
|
-
|
|
63
|
-
const responsePromise = session.sendAndWait({ prompt });
|
|
64
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
65
|
-
setTimeout(() => reject(new Error(`AI 請求超時 (${AI_TIMEOUT_MS / 1000} 秒)`)), AI_TIMEOUT_MS);
|
|
66
|
-
});
|
|
77
|
+
const session = await this._createSession();
|
|
67
78
|
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
const responsePromise = session.sendAndWait({ prompt });
|
|
80
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
81
|
+
setTimeout(
|
|
82
|
+
() => reject(new Error(`AI 請求超時 (${AI_TIMEOUT_MS / 1000} 秒)`)),
|
|
83
|
+
AI_TIMEOUT_MS
|
|
84
|
+
);
|
|
85
|
+
});
|
|
70
86
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
87
|
+
const response = await Promise.race([responsePromise, timeoutPromise]);
|
|
88
|
+
const prContent = response?.data.content?.trim() || '';
|
|
74
89
|
|
|
75
|
-
|
|
90
|
+
if (!prContent) {
|
|
91
|
+
throw new Error('AI 未能生成 PR 內容');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return this.parsePRContent(prContent);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// SDK 內部 session.idle timeout(hardcoded 60s)→ 重試一次
|
|
97
|
+
const isSessionIdleTimeout =
|
|
98
|
+
error.message?.includes('session.idle') || error.message?.includes('Timeout after');
|
|
99
|
+
if (isSessionIdleTimeout) {
|
|
100
|
+
if (attempt === 1) {
|
|
101
|
+
log.warning(` AI session 超時,即將重試...\n`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// 兩次都超時 → 模型速度不足,給出具體建議
|
|
105
|
+
log.error(` 模型 ${this.model} 在此變更大小下回應過慢`);
|
|
106
|
+
log.info(` 建議改用更快的模型:ai-git-tools pr --model gpt-5.4\n`);
|
|
107
|
+
throw new Error(`AI 生成超時:模型 ${this.model} 回應過慢,請加 --model gpt-5.4 重試`);
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// unreachable,但保留讓 linter 滿意
|
|
113
|
+
throw new Error('AI 未能生成 PR 內容');
|
|
76
114
|
// 注意:不在此 stop() client,改由 close() 統一清理以便複用
|
|
77
115
|
}
|
|
78
116
|
|
|
@@ -91,7 +129,10 @@ export class AIAnalyzer {
|
|
|
91
129
|
// 使用超時保護(150 秒)
|
|
92
130
|
const responsePromise = session.sendAndWait({ prompt });
|
|
93
131
|
const timeoutPromise = new Promise((_, reject) => {
|
|
94
|
-
setTimeout(
|
|
132
|
+
setTimeout(
|
|
133
|
+
() => reject(new Error(`AI 請求超時 (${AI_TIMEOUT_MS / 1000} 秒)`)),
|
|
134
|
+
AI_TIMEOUT_MS
|
|
135
|
+
);
|
|
95
136
|
});
|
|
96
137
|
|
|
97
138
|
const response = await Promise.race([responsePromise, timeoutPromise]);
|
|
@@ -130,12 +171,10 @@ export class AIAnalyzer {
|
|
|
130
171
|
/**
|
|
131
172
|
* 建立 PR 生成 Prompt
|
|
132
173
|
*/
|
|
133
|
-
buildPRPrompt(commits, diff
|
|
174
|
+
buildPRPrompt(commits, diff) {
|
|
134
175
|
return `你是一個專業的前端工程師,熟悉 Next.js、React 效能優化和團隊開發規範。
|
|
135
176
|
請根據以下 commit 訊息和程式碼變更,直接輸出一個清晰的 Pull Request 標題和描述。
|
|
136
177
|
|
|
137
|
-
${skillsSummary}
|
|
138
|
-
|
|
139
178
|
**輸出格式**(不要加任何引導語,直接輸出以下內容):
|
|
140
179
|
|
|
141
180
|
# [type]: [PR 標題]
|
|
@@ -160,18 +199,7 @@ ${skillsSummary}
|
|
|
160
199
|
- [ ] ⚡ 效能改進 (perf)
|
|
161
200
|
- [ ] 🔧 其他 (chore)
|
|
162
201
|
|
|
163
|
-
>
|
|
164
|
-
>
|
|
165
|
-
> **判斷準則**:
|
|
166
|
-
> - ✨ **新功能 (feat)**: 新增檔案、新增 API、新增組件、新增功能邏輯、新增配置選項
|
|
167
|
-
> - 🐛 **Bug 修復 (fix)**: 修復錯誤、修正邏輯問題
|
|
168
|
-
> - ♻️ **重構 (refactor)**: 重組程式碼結構但不改變功能
|
|
169
|
-
> - 💄 **樣式調整 (style)**: UI/CSS 調整、格式化
|
|
170
|
-
> - 📝 **文件更新 (docs)**: README、註解、文檔變更
|
|
171
|
-
> - ⚡ **效能改進 (perf)**: 優化效能
|
|
172
|
-
> - 🔧 **其他 (chore)**: 建構工具、依賴更新、配置調整
|
|
173
|
-
>
|
|
174
|
-
> **特別注意**: 如果 diff 中有「新增檔案」或「新增功能」,**務必勾選** ✨ 新功能 (feat)
|
|
202
|
+
> 根據 diff 和 commit 自動勾選(可複選),[ ] 改為 [x];有新增檔案或功能必勾 ✨ feat
|
|
175
203
|
|
|
176
204
|
## 🧪 測試方法
|
|
177
205
|
1. [具體的測試步驟 1]
|
|
@@ -200,21 +228,7 @@ ${skillsSummary}
|
|
|
200
228
|
|
|
201
229
|
---
|
|
202
230
|
|
|
203
|
-
|
|
204
|
-
1. PR 標題格式:type: 簡短描述(不超過 50 字)
|
|
205
|
-
2. type 必須符合 Conventional Commits
|
|
206
|
-
3. 變更摘要用 2-3 句話概括整體影響
|
|
207
|
-
4. 全部使用繁體中文(台灣正體)
|
|
208
|
-
5. 不要在開頭加引導語句
|
|
209
|
-
6. 直接開始輸出 # [type]: [標題]
|
|
210
|
-
7. **變更類型判斷必須準確**:
|
|
211
|
-
- 檢查 diff 中是否有 "new file mode" 或大量 "+++" 行(表示新增檔案)
|
|
212
|
-
- 檢查 commit 訊息是否包含「新增」、「add」、「feat」等關鍵字
|
|
213
|
-
- 檢查主要變更列表,如果提到「新增 xxx」就必須勾選 ✨ 新功能 (feat)
|
|
214
|
-
- 新增配置檔、新增組件、新增 API、新增功能都算 feat
|
|
215
|
-
- 一個 PR 可以同時是多種類型(如:feat + refactor + chore)
|
|
216
|
-
8. **Risk Level 判斷**:HIGH = 影響付款/登入/資料寫入核心流程;MEDIUM = 影響現有功能但有降級保護;LOW = 新增功能或純重構
|
|
217
|
-
9. **Reviewer 重點**:列出最值得仔細看的 1-3 個地方(核心演算法、架構決策、潛在邊界條件)
|
|
231
|
+
**規則**:直接輸出 # [type]: [標題],繁體中文(台灣正體),type 符合 Conventional Commits;新增檔案/功能優先 feat,可複選多種類型;Risk Level:HIGH=核心流程,MEDIUM=影響現有功能,LOW=新增或重構;Reviewer 重點列 1-3 個值得仔細看的地方。
|
|
218
232
|
|
|
219
233
|
---
|
|
220
234
|
|
|
@@ -349,7 +363,7 @@ ${diff.length > CONSTANTS.MAX_DIFF_LENGTH ? '\n... (內容過長已截斷)' : ''
|
|
|
349
363
|
let riskLevel = '低';
|
|
350
364
|
const riskReasons = [];
|
|
351
365
|
|
|
352
|
-
changedFiles.forEach(
|
|
366
|
+
changedFiles.forEach(file => {
|
|
353
367
|
const lower = file.toLowerCase();
|
|
354
368
|
if (lower.includes('/api/')) {
|
|
355
369
|
if (!impacts.includes('API 層')) impacts.push('API 層');
|
|
@@ -367,7 +381,7 @@ ${diff.length > CONSTANTS.MAX_DIFF_LENGTH ? '\n... (內容過長已截斷)' : ''
|
|
|
367
381
|
});
|
|
368
382
|
|
|
369
383
|
const warnings = [];
|
|
370
|
-
const hasTestFiles = changedFiles.some(
|
|
384
|
+
const hasTestFiles = changedFiles.some(f => f.includes('test') || f.includes('spec'));
|
|
371
385
|
if (!hasTestFiles && changedFiles.length > 3) {
|
|
372
386
|
warnings.push({
|
|
373
387
|
level: '⚠️',
|
|
@@ -405,7 +419,7 @@ ${diff.length > CONSTANTS.MAX_DIFF_LENGTH ? '\n... (內容過長已截斷)' : ''
|
|
|
405
419
|
|
|
406
420
|
if (blastRadius.riskReasons && blastRadius.riskReasons.length > 0) {
|
|
407
421
|
enhancedBody += `**風險因素**:\n`;
|
|
408
|
-
blastRadius.riskReasons.forEach(
|
|
422
|
+
blastRadius.riskReasons.forEach(reason => {
|
|
409
423
|
enhancedBody += `- ${reason}\n`;
|
|
410
424
|
});
|
|
411
425
|
enhancedBody += '\n';
|
|
@@ -413,7 +427,7 @@ ${diff.length > CONSTANTS.MAX_DIFF_LENGTH ? '\n... (內容過長已截斷)' : ''
|
|
|
413
427
|
|
|
414
428
|
if (blastRadius.externalBehaviors && blastRadius.externalBehaviors.length > 0) {
|
|
415
429
|
enhancedBody += `**對外行為變更**:\n`;
|
|
416
|
-
blastRadius.externalBehaviors.forEach(
|
|
430
|
+
blastRadius.externalBehaviors.forEach(behavior => {
|
|
417
431
|
enhancedBody += `- ${behavior}\n`;
|
|
418
432
|
});
|
|
419
433
|
enhancedBody += '\n';
|
|
@@ -422,7 +436,7 @@ ${diff.length > CONSTANTS.MAX_DIFF_LENGTH ? '\n... (內容過長已截斷)' : ''
|
|
|
422
436
|
// 添加規範警告
|
|
423
437
|
if (warnings.length > 0) {
|
|
424
438
|
enhancedBody += '\n## ⚠️ 注意事項\n\n';
|
|
425
|
-
warnings.forEach(
|
|
439
|
+
warnings.forEach(warning => {
|
|
426
440
|
enhancedBody += `${warning.level} **${warning.message}**\n`;
|
|
427
441
|
if (warning.suggestion) {
|
|
428
442
|
enhancedBody += ` - 💡 ${warning.suggestion}\n`;
|