ai-git-tools 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * PR 命令
3
+ *
4
+ * 生成 PR 標題、描述並創建 Pull Request
5
+ */
6
+
7
+ import chalk from 'chalk';
8
+ import inquirer from 'inquirer';
9
+ import { loadConfig } from '../core/config-loader.js';
10
+ import { AIClient } from '../core/ai-client.js';
11
+ import { GitOperations } from '../core/git-operations.js';
12
+ import { GitHubAPI } from '../core/github-api.js';
13
+ import { Logger } from '../utils/logger.js';
14
+ import {
15
+ handleError,
16
+ truncateText,
17
+ getProjectTypePrompt,
18
+ } from '../utils/helpers.js';
19
+
20
+ /**
21
+ * 決定 base 分支
22
+ */
23
+ async function determineBaseBranch(config, logger) {
24
+ if (config.baseBranch && config.baseBranch !== 'auto') {
25
+ return config.baseBranch;
26
+ }
27
+
28
+ // 嘗試找最新的 release 分支
29
+ logger.startSpinner('尋找目標分支...');
30
+ const latestRelease = GitHubAPI.getLatestReleaseBranch();
31
+
32
+ if (latestRelease) {
33
+ logger.succeedSpinner(`自動選擇目標分支: ${latestRelease}`);
34
+ return latestRelease;
35
+ }
36
+
37
+ // 預設使用 main 或 master
38
+ const defaultBranch = GitOperations.branchExists('main') ? 'main' : 'master';
39
+ logger.succeedSpinner(`使用預設分支: ${defaultBranch}`);
40
+ return defaultBranch;
41
+ }
42
+
43
+ /**
44
+ * 使用 AI 生成 PR 內容
45
+ */
46
+ async function generatePRContent(baseBranch, headBranch, config, logger) {
47
+ logger.startSpinner('AI 正在分析變更並生成 PR 內容...');
48
+
49
+ // 獲取 diff 和 commits
50
+ const diff = GitOperations.getDiffBetweenBranches(baseBranch, headBranch);
51
+ const commits = GitOperations.getCommitsBetweenBranches(baseBranch, headBranch);
52
+
53
+ const truncatedDiff = truncateText(diff, config.ai.maxDiffLength);
54
+
55
+ const aiClient = new AIClient(config);
56
+
57
+ const prompt = `${getProjectTypePrompt()}
58
+
59
+ 請根據以下資訊生成一個 Pull Request 的標題和描述。
60
+
61
+ **Commits 列表**:
62
+ ${commits.join('\n')}
63
+
64
+ **Git Diff**:
65
+ ${truncatedDiff}
66
+
67
+ **輸出格式**(JSON):
68
+ {
69
+ "title": "PR 標題(簡短、繁體中文,50 字內)",
70
+ "description": "PR 描述(使用 Markdown 格式,繁體中文)"
71
+ }
72
+
73
+ **PR 描述應包含**:
74
+ 1. ## 📝 變更摘要(簡述主要變更)
75
+ 2. ## ✨ 主要功能(列出新增功能或修正項目)
76
+ 3. ## 🔧 技術細節(選填,如有重要的技術變更)
77
+ 4. ## ✅ 測試(如何測試這些變更)
78
+
79
+ 請只輸出 JSON,不要其他文字。`;
80
+
81
+ const response = await aiClient.sendAndWait(prompt);
82
+ await aiClient.stop();
83
+
84
+ try {
85
+ const prContent = AIClient.parseJSON(response);
86
+ logger.succeedSpinner('PR 內容生成完成');
87
+ return prContent;
88
+ } catch (error) {
89
+ logger.failSpinner('生成 PR 內容失敗');
90
+ throw new Error(`無法解析 AI 回應: ${error.message}`);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 選擇 Reviewers
96
+ */
97
+ async function selectReviewers(changedFiles, config, logger) {
98
+ if (!config.reviewers.autoSelect) {
99
+ return [];
100
+ }
101
+
102
+ logger.startSpinner('分析潛在的 reviewers...');
103
+
104
+ const contributorsMap = new Map();
105
+
106
+ // 分析每個變更檔案的貢獻者
107
+ for (const file of changedFiles.slice(0, 10)) { // 限制分析前 10 個檔案
108
+ const contributors = GitOperations.getFileContributors(
109
+ file,
110
+ config.reviewers.gitHistoryDepth
111
+ );
112
+
113
+ for (const { email, name } of contributors) {
114
+ // 排除設定中的作者
115
+ if (config.reviewers.excludeAuthors.some(pattern => email.includes(pattern))) {
116
+ continue;
117
+ }
118
+
119
+ const count = contributorsMap.get(email) || 0;
120
+ contributorsMap.set(email, count + 1);
121
+ }
122
+ }
123
+
124
+ // 排序並取前 N 位
125
+ const suggested = Array.from(contributorsMap.entries())
126
+ .sort((a, b) => b[1] - a[1])
127
+ .slice(0, config.reviewers.maxSuggested)
128
+ .map(([email]) => email);
129
+
130
+ logger.succeedSpinner(`找到 ${suggested.length} 位潛在 reviewers`);
131
+
132
+ if (suggested.length === 0) {
133
+ return [];
134
+ }
135
+
136
+ // 互動式選擇
137
+ const { selectedReviewers } = await inquirer.prompt([
138
+ {
139
+ type: 'checkbox',
140
+ name: 'selectedReviewers',
141
+ message: '選擇 Reviewers:',
142
+ choices: suggested.map(email => ({
143
+ name: email,
144
+ value: email,
145
+ })),
146
+ },
147
+ ]);
148
+
149
+ return selectedReviewers;
150
+ }
151
+
152
+ /**
153
+ * 智能選擇 Labels
154
+ */
155
+ function suggestLabels(prContent, availableLabels) {
156
+ const suggestions = [];
157
+ const title = prContent.title.toLowerCase();
158
+ const description = prContent.description.toLowerCase();
159
+
160
+ // 根據關鍵字建議 labels
161
+ const labelKeywords = {
162
+ bug: ['fix', 'bug', '修正', '錯誤'],
163
+ enhancement: ['feat', 'feature', '新增', '功能'],
164
+ documentation: ['docs', '文件', 'readme'],
165
+ refactor: ['refactor', '重構'],
166
+ performance: ['perf', 'performance', '效能', '優化'],
167
+ test: ['test', '測試'],
168
+ ui: ['ui', 'style', '樣式', '介面'],
169
+ api: ['api', 'endpoint'],
170
+ };
171
+
172
+ for (const [label, keywords] of Object.entries(labelKeywords)) {
173
+ if (availableLabels.includes(label)) {
174
+ if (keywords.some(kw => title.includes(kw) || description.includes(kw))) {
175
+ suggestions.push(label);
176
+ }
177
+ }
178
+ }
179
+
180
+ return suggestions;
181
+ }
182
+
183
+ /**
184
+ * PR 命令處理器
185
+ */
186
+ export async function prCommand(options) {
187
+ const logger = new Logger(options.verbose);
188
+
189
+ try {
190
+ logger.header('AI Auto PR Generator');
191
+
192
+ // 檢查環境
193
+ if (!GitOperations.isGitRepository()) {
194
+ logger.error('當前目錄不是 Git 倉庫');
195
+ process.exit(1);
196
+ }
197
+
198
+ if (!GitHubAPI.isAvailable()) {
199
+ logger.error('GitHub CLI 未安裝或未認證');
200
+ logger.info('請先安裝並設定 GitHub CLI:');
201
+ logger.info(' brew install gh');
202
+ logger.info(' gh auth login');
203
+ process.exit(1);
204
+ }
205
+
206
+ // 載入配置
207
+ const config = await loadConfig(options);
208
+
209
+ // 確定分支
210
+ const headBranch = config.headBranch || GitOperations.getCurrentBranch();
211
+ const baseBranch = await determineBaseBranch(config, logger);
212
+
213
+ console.log(chalk.cyan(`\n📌 分支資訊:`));
214
+ console.log(` Base: ${baseBranch}`);
215
+ console.log(` Head: ${headBranch}`);
216
+
217
+ // 檢查 PR 是否已存在
218
+ if (GitHubAPI.prExists(baseBranch, headBranch)) {
219
+ logger.warn('PR 已存在');
220
+ process.exit(0);
221
+ }
222
+
223
+ // 生成 PR 內容
224
+ const prContent = await generatePRContent(baseBranch, headBranch, config, logger);
225
+
226
+ console.log(chalk.cyan('\n📝 PR 標題:'));
227
+ logger.code(prContent.title);
228
+
229
+ console.log(chalk.cyan('\n📄 PR 描述:'));
230
+ logger.code(prContent.description);
231
+
232
+ // 如果是預覽模式,不創建 PR
233
+ if (config.preview) {
234
+ logger.info('預覽模式,不創建 PR');
235
+ process.exit(0);
236
+ }
237
+
238
+ // 確認是否創建 PR
239
+ if (!config.noConfirm) {
240
+ const { confirm } = await inquirer.prompt([
241
+ {
242
+ type: 'confirm',
243
+ name: 'confirm',
244
+ message: '是否創建 PR?',
245
+ default: true,
246
+ },
247
+ ]);
248
+
249
+ if (!confirm) {
250
+ logger.info('已取消');
251
+ process.exit(0);
252
+ }
253
+ }
254
+
255
+ // 選擇 Reviewers
256
+ let reviewers = [];
257
+ if (config.reviewers.autoSelect) {
258
+ const changes = GitOperations.getAllChanges();
259
+ const changedFiles = changes.map(c => c.filePath);
260
+ reviewers = await selectReviewers(changedFiles, config, logger);
261
+ }
262
+
263
+ // 選擇 Labels
264
+ let labels = [];
265
+ if (config.github.autoLabels) {
266
+ const availableLabels = GitHubAPI.getLabels();
267
+ const suggestedLabels = suggestLabels(prContent, availableLabels);
268
+
269
+ if (suggestedLabels.length > 0) {
270
+ const { selectedLabels } = await inquirer.prompt([
271
+ {
272
+ type: 'checkbox',
273
+ name: 'selectedLabels',
274
+ message: '選擇 Labels:',
275
+ choices: suggestedLabels.map(label => ({
276
+ name: label,
277
+ value: label,
278
+ checked: true,
279
+ })),
280
+ },
281
+ ]);
282
+ labels = selectedLabels;
283
+ }
284
+ }
285
+
286
+ // 創建 PR
287
+ logger.startSpinner('正在創建 PR...');
288
+
289
+ GitHubAPI.createPR({
290
+ title: prContent.title,
291
+ body: prContent.description,
292
+ base: baseBranch,
293
+ head: headBranch,
294
+ draft: config.draft,
295
+ reviewers,
296
+ labels,
297
+ });
298
+
299
+ logger.succeedSpinner('PR 創建成功!');
300
+
301
+ } catch (error) {
302
+ handleError(error);
303
+ process.exit(1);
304
+ }
305
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Workflow 命令
3
+ *
4
+ * 完整工作流程:commit-all + pr
5
+ */
6
+
7
+ import chalk from 'chalk';
8
+ import { commitAllCommand } from './commit-all.js';
9
+ import { prCommand } from './pr.js';
10
+ import { Logger } from '../utils/logger.js';
11
+ import { handleError } from '../utils/helpers.js';
12
+
13
+ /**
14
+ * Workflow 命令處理器
15
+ */
16
+ export async function workflowCommand(options) {
17
+ const logger = new Logger(options.verbose);
18
+
19
+ try {
20
+ logger.header('完整工作流程:Commit All + PR');
21
+
22
+ // 步驟 1: Commit All
23
+ console.log(chalk.cyan('\n🔄 步驟 1: 智能分析並提交所有變更\n'));
24
+ await commitAllCommand(options);
25
+
26
+ // 步驟 2: 創建 PR
27
+ console.log(chalk.cyan('\n🔄 步驟 2: 創建 Pull Request\n'));
28
+ await prCommand(options);
29
+
30
+ logger.header('工作流程完成!');
31
+
32
+ } catch (error) {
33
+ handleError(error);
34
+ process.exit(1);
35
+ }
36
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * AI Client
3
+ *
4
+ * 封裝 GitHub Copilot SDK 的 AI 客戶端
5
+ */
6
+
7
+ import { CopilotClient } from '@github/copilot-sdk';
8
+
9
+ export class AIClient {
10
+ constructor(config = {}) {
11
+ this.config = config;
12
+ this.client = null;
13
+ this.session = null;
14
+ }
15
+
16
+ /**
17
+ * 初始化客戶端
18
+ */
19
+ async initialize() {
20
+ if (!this.client) {
21
+ this.client = new CopilotClient();
22
+ }
23
+ return this.client;
24
+ }
25
+
26
+ /**
27
+ * 創建會話
28
+ */
29
+ async createSession(options = {}) {
30
+ await this.initialize();
31
+
32
+ this.session = await this.client.createSession({
33
+ model: options.model || this.config.ai?.model || 'gpt-4.1',
34
+ ...options,
35
+ });
36
+
37
+ return this.session;
38
+ }
39
+
40
+ /**
41
+ * 發送請求並等待回應(帶重試機制)
42
+ */
43
+ async sendAndWait(prompt, options = {}) {
44
+ const maxRetries = options.maxRetries || this.config.ai?.maxRetries || 3;
45
+ let lastError = null;
46
+
47
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
48
+ try {
49
+ if (!this.session) {
50
+ await this.createSession(options);
51
+ }
52
+
53
+ const response = await this.session.sendAndWait({ prompt });
54
+ return response?.data?.content || '';
55
+ } catch (error) {
56
+ lastError = error;
57
+
58
+ if (this.config.output?.verbose) {
59
+ console.log(`⚠️ 嘗試 ${attempt}/${maxRetries} 失敗: ${error.message}`);
60
+ }
61
+
62
+ // 如果還有重試機會,重新創建 session
63
+ if (attempt < maxRetries) {
64
+ this.session = null;
65
+ continue;
66
+ }
67
+ }
68
+ }
69
+
70
+ throw new Error(`AI 請求失敗(嘗試 ${maxRetries} 次): ${lastError?.message || '未知錯誤'}`);
71
+ }
72
+
73
+ /**
74
+ * 停止客戶端
75
+ */
76
+ async stop() {
77
+ if (this.client) {
78
+ await this.client.stop();
79
+ this.client = null;
80
+ this.session = null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 清理回應內容(移除 markdown 程式碼區塊等)
86
+ */
87
+ static cleanResponse(response) {
88
+ if (!response) return '';
89
+
90
+ let cleaned = response.trim();
91
+
92
+ // 移除 markdown 程式碼區塊標記
93
+ cleaned = cleaned.replace(/^```[\w]*\n/gm, '');
94
+ cleaned = cleaned.replace(/\n```$/gm, '');
95
+ cleaned = cleaned.replace(/^```$/gm, '');
96
+
97
+ // 移除開頭和結尾的引號
98
+ cleaned = cleaned.replace(/^["']|["']$/g, '');
99
+
100
+ return cleaned.trim();
101
+ }
102
+
103
+ /**
104
+ * 解析 JSON 回應
105
+ */
106
+ static parseJSON(response) {
107
+ const cleaned = AIClient.cleanResponse(response);
108
+
109
+ try {
110
+ return JSON.parse(cleaned);
111
+ } catch (error) {
112
+ throw new Error(`無法解析 AI 回應為 JSON: ${error.message}`);
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * 配置載入器
3
+ *
4
+ * 負責載入和合併配置檔案與命令列參數
5
+ */
6
+
7
+ import { existsSync } from 'fs';
8
+ import { resolve } from 'path';
9
+
10
+ /**
11
+ * 預設配置
12
+ */
13
+ export const DEFAULT_CONFIG = {
14
+ ai: {
15
+ model: 'gpt-4.1',
16
+ maxDiffLength: 8000,
17
+ maxRetries: 3,
18
+ },
19
+ github: {
20
+ orgName: null, // 自動從 git remote 取得
21
+ defaultBase: 'auto',
22
+ autoLabels: true,
23
+ },
24
+ reviewers: {
25
+ autoSelect: false,
26
+ maxSuggested: 5,
27
+ gitHistoryDepth: 20,
28
+ excludeAuthors: [],
29
+ },
30
+ output: {
31
+ verbose: false,
32
+ saveHistory: false,
33
+ },
34
+ };
35
+
36
+ /**
37
+ * 載入配置檔案
38
+ */
39
+ async function loadConfigFile() {
40
+ const configFiles = [
41
+ '.ai-git-config.js',
42
+ '.ai-git-config.mjs',
43
+ 'ai-git.config.js',
44
+ 'ai-git.config.mjs',
45
+ ];
46
+
47
+ for (const configFile of configFiles) {
48
+ const configPath = resolve(process.cwd(), configFile);
49
+ if (existsSync(configPath)) {
50
+ try {
51
+ const imported = await import(configPath);
52
+ return imported.default || {};
53
+ } catch (error) {
54
+ console.warn(`警告: 無法載入配置檔案 ${configFile}:`, error.message);
55
+ }
56
+ }
57
+ }
58
+
59
+ return {};
60
+ }
61
+
62
+ /**
63
+ * 深度合併物件
64
+ */
65
+ function deepMerge(target, source) {
66
+ const output = { ...target };
67
+
68
+ if (isObject(target) && isObject(source)) {
69
+ Object.keys(source).forEach(key => {
70
+ if (isObject(source[key])) {
71
+ if (!(key in target)) {
72
+ Object.assign(output, { [key]: source[key] });
73
+ } else {
74
+ output[key] = deepMerge(target[key], source[key]);
75
+ }
76
+ } else {
77
+ Object.assign(output, { [key]: source[key] });
78
+ }
79
+ });
80
+ }
81
+
82
+ return output;
83
+ }
84
+
85
+ function isObject(item) {
86
+ return item && typeof item === 'object' && !Array.isArray(item);
87
+ }
88
+
89
+ /**
90
+ * 載入完整配置(配置檔 + CLI 參數)
91
+ */
92
+ export async function loadConfig(cliOptions = {}) {
93
+ // 1. 載入配置檔案
94
+ const userConfig = await loadConfigFile();
95
+
96
+ // 2. 合併預設配置與使用者配置
97
+ let config = deepMerge(DEFAULT_CONFIG, userConfig);
98
+
99
+ // 3. 合併 CLI 參數(最高優先權)
100
+ if (cliOptions.model) {
101
+ config.ai.model = cliOptions.model;
102
+ }
103
+
104
+ if (cliOptions.verbose !== undefined) {
105
+ config.output.verbose = cliOptions.verbose;
106
+ }
107
+
108
+ if (cliOptions.maxDiff) {
109
+ config.ai.maxDiffLength = cliOptions.maxDiff;
110
+ }
111
+
112
+ if (cliOptions.maxRetries) {
113
+ config.ai.maxRetries = cliOptions.maxRetries;
114
+ }
115
+
116
+ // GitHub 相關
117
+ if (cliOptions.org) {
118
+ config.github.orgName = cliOptions.org;
119
+ }
120
+
121
+ if (cliOptions.base) {
122
+ config.github.defaultBase = cliOptions.base;
123
+ }
124
+
125
+ if (cliOptions.autoReviewers !== undefined) {
126
+ config.reviewers.autoSelect = cliOptions.autoReviewers;
127
+ }
128
+
129
+ if (cliOptions.autoLabels !== undefined) {
130
+ config.github.autoLabels = cliOptions.autoLabels;
131
+ }
132
+
133
+ // 其他 CLI 參數
134
+ config.baseBranch = cliOptions.base || null;
135
+ config.headBranch = cliOptions.head || null;
136
+ config.draft = cliOptions.draft || false;
137
+ config.preview = cliOptions.preview || false;
138
+ config.noConfirm = cliOptions.noConfirm || false;
139
+
140
+ return config;
141
+ }
142
+
143
+ /**
144
+ * 驗證配置
145
+ */
146
+ export function validateConfig(config) {
147
+ const errors = [];
148
+
149
+ if (!config.ai?.model) {
150
+ errors.push('AI model 未設定');
151
+ }
152
+
153
+ if (config.ai?.maxDiffLength && config.ai.maxDiffLength < 1000) {
154
+ errors.push('maxDiffLength 太小(最少 1000)');
155
+ }
156
+
157
+ if (config.ai?.maxRetries && (config.ai.maxRetries < 1 || config.ai.maxRetries > 10)) {
158
+ errors.push('maxRetries 必須在 1-10 之間');
159
+ }
160
+
161
+ return {
162
+ valid: errors.length === 0,
163
+ errors,
164
+ };
165
+ }