ai-git-tools 2.0.47 → 2.0.49

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.
@@ -1,242 +0,0 @@
1
- /**
2
- * AI 代碼生成器
3
- * 基於 Issue 描述,調用 AI 生成代碼框架/實現
4
- */
5
-
6
- import { AIClient } from '../../core/ai-client.js';
7
-
8
- /**
9
- * @typedef {Object} GeneratedCode
10
- * @property {string} filePath - 要建立/修改的檔案路徑
11
- * @property {string} content - 代碼內容
12
- * @property {string} type - 'new' | 'modify' | 'skip'
13
- * @property {string|null} reason - 若 skip 時的原因
14
- */
15
-
16
- export class CodeGenerator {
17
- constructor(config = {}) {
18
- this.config = config;
19
- this.model = config.aiModel || 'gpt-4.1';
20
- }
21
-
22
- /**
23
- * 基於 Issue 生成代碼檔案
24
- * @param {import('../core/issue-parser.js').IssueData} issueData
25
- * @param {string} [projectRoot] - 專案根目錄
26
- * @returns {Promise<GeneratedCode[]>}
27
- */
28
- async generateCodeFiles(issueData, projectRoot = process.cwd()) {
29
- const prompt = this._buildPrompt(issueData, projectRoot);
30
-
31
- const VALID_MODELS = [
32
- 'gpt-4.1',
33
- 'gpt-4o',
34
- 'claude-haiku-4.5',
35
- 'claude-sonnet-4.5',
36
- 'claude-sonnet-4.6',
37
- 'o3',
38
- 'o4-mini',
39
- ];
40
- if (!VALID_MODELS.includes(this.model)) {
41
- console.warn(` ⚠️ model「${this.model}」可能無效!支援的模型:${VALID_MODELS.join(', ')}`);
42
- }
43
-
44
- // Claude 模型使用 extended thinking,需要更長的等待時間
45
- const isClaude = this.model.toLowerCase().includes('claude');
46
- const timeout = isClaude ? 300_000 : 120_000; // Claude 5 分鐘,GPT 2 分鐘
47
- const timeoutMinutes = timeout / 60_000;
48
-
49
- console.log(` 🤖 調用 AI 生成代碼框架(model: ${this.model},最多等待 ${timeoutMinutes} 分鐘)...`);
50
- if (isClaude) {
51
- console.log(' ℹ️ Claude 模型使用 extended thinking 模式,回應時間較長,請耐心等候');
52
- console.log(' 💡 若等待太久,可改用較快的模型:在 .ai-git-config 的 autodev.aiModel 改為 "gpt-4.1"');
53
- }
54
-
55
- let response;
56
- try {
57
- response = await AIClient.sendAndWait(
58
- prompt,
59
- this.model,
60
- this.config.maxRetries || 3,
61
- timeout
62
- );
63
- } catch (error) {
64
- console.warn(` ❌ AI 調用最終失敗:${error.message}`);
65
- console.warn(' 💡 排查建議:');
66
- console.warn(` 1. 確認 model「${this.model}」已在你的 Copilot 方案中啟用`);
67
- console.warn(
68
- ' 2. 嘗試改用 gpt-4.1:在 .ai-git-config 的 autodev.aiModel 改為 "gpt-4.1"'
69
- );
70
- console.warn(' 3. 確認 gh auth status 有登入 GitHub');
71
- return [];
72
- }
73
-
74
- if (!response) {
75
- console.warn(' ⚠️ AI 回傳空回應');
76
- return [];
77
- }
78
-
79
- // 解析 AI 回應
80
- const files = this._parseResponse(response);
81
- if (files.length === 0) {
82
- console.warn(' ⚠️ AI 未回傳任何檔案(可能 JSON 格式有誤)');
83
- if (process.env.AUTODEV_DEBUG) {
84
- console.log(' --- AI 原始回應 ---');
85
- console.log(response.slice(0, 500));
86
- console.log(' (設定 AUTODEV_DEBUG=1 可看到完整回應)');
87
- }
88
- } else {
89
- console.log(` ✅ AI 規劃生成 ${files.length} 個檔案:`);
90
- files.forEach(f => {
91
- const icon =
92
- f.filePath.includes('.test.') || f.filePath.includes('__tests__') ? '🧪' : '📄';
93
- console.log(` ${icon} ${f.filePath}`);
94
- });
95
- }
96
- return files;
97
- }
98
-
99
- /**
100
- * 寫入生成的檔案到磁盤
101
- * @param {GeneratedCode[]} files
102
- * @param {string} projectRoot
103
- * @returns {Promise<{ written: string[], skipped: string[] }>}
104
- */
105
- async writeFiles(files, projectRoot = process.cwd()) {
106
- const { writeFileSync, mkdirSync, existsSync } = await import('fs');
107
- const { dirname, resolve } = await import('path');
108
-
109
- const written = [];
110
- const skipped = [];
111
-
112
- for (const file of files) {
113
- if (file.type === 'skip') {
114
- console.log(` ⏭️ 跳過 ${file.filePath}${file.reason ? ` (${file.reason})` : ''}`);
115
- skipped.push(`${file.filePath}${file.reason ? ` (${file.reason})` : ''}`);
116
- continue;
117
- }
118
-
119
- const fullPath = resolve(projectRoot, file.filePath);
120
- const dir = dirname(fullPath);
121
-
122
- // 建立目錄
123
- try {
124
- if (!existsSync(dir)) {
125
- mkdirSync(dir, { recursive: true });
126
- }
127
- } catch (e) {
128
- console.warn(` ⚠️ 無法建立目錄 ${dir}:${e.message}`);
129
- skipped.push(file.filePath);
130
- continue;
131
- }
132
-
133
- // 寫入檔案(即時顯示)
134
- try {
135
- const isTest = file.filePath.includes('.test.') || file.filePath.includes('__tests__');
136
- const icon = isTest ? '🧪' : '📄';
137
- const existed = existsSync(fullPath);
138
- writeFileSync(fullPath, file.content, 'utf-8');
139
- written.push(file.filePath);
140
- console.log(` ${icon} ${existed ? '更新' : '新建'} ${file.filePath}`);
141
- } catch (e) {
142
- console.warn(` ⚠️ 無法寫入 ${file.filePath}:${e.message}`);
143
- skipped.push(file.filePath);
144
- }
145
- }
146
-
147
- return { written, skipped };
148
- }
149
-
150
- // ── 私有輔助方法
151
-
152
- _buildPrompt(issueData, projectRoot) {
153
- const { title, body, labels } = issueData;
154
-
155
- return `你是一個資深軟體工程師,負責基於 GitHub Issue 生成完整的代碼框架和測試用例。
156
-
157
- ## Issue 資訊
158
- 標題:${title}
159
- 標籤:${labels.join(', ') || '無'}
160
- 描述:
161
- ${body}
162
-
163
- ## 任務
164
- 1. 分析上方 Issue 的需求
165
- 2. **同時**設計兩套檔案:
166
- a. 實現檔案(src/ 中的原始碼)
167
- b. 測試檔案(tests/ 或 __tests__/ 中的測試)
168
- 3. 生成初始實現和初始測試
169
-
170
- ## 重要:測試檔案生成
171
- - 對每個實現檔案,都生成對應的測試檔案
172
- - 測試檔案應放在 \`tests/\` 或 \`src/__tests__/\` 目錄
173
- - 測試應覆蓋主要功能(可使用 TODO 標記待補充的測試用例)
174
- - 測試檔案命名:實現檔案 \`.js\` → 測試檔案 \`.test.js\`
175
- - 例:\`src/utils/helper.js\` → \`tests/utils/helper.test.js\`
176
-
177
- ## 輸出格式
178
- 請回傳 JSON 陣列,包含**源文件和測試文件**:
179
- \`\`\`json
180
- [
181
- {
182
- "filePath": "src/components/MyComponent.jsx",
183
- "content": "// React 元件實現...",
184
- "type": "new"
185
- },
186
- {
187
- "filePath": "tests/components/MyComponent.test.jsx",
188
- "content": "// Jest 測試...",
189
- "type": "new"
190
- },
191
- {
192
- "filePath": "src/utils/helper.js",
193
- "content": "// 工具函數...",
194
- "type": "new"
195
- },
196
- {
197
- "filePath": "tests/utils/helper.test.js",
198
- "content": "// 單元測試...",
199
- "type": "new"
200
- }
201
- ]
202
- \`\`\`
203
-
204
- 只回傳 JSON,不要其他文字。
205
-
206
- ## 測試框架指南(假設 Jest)
207
- - \`describe('名稱', () => { ... })\` 分組
208
- - \`test('應該...', () => { ... })\` 單一測試
209
- - 包含 arrange-act-assert 結構
210
- - 對 TODO 部分用 \`test.todo('待實現')\`
211
- - 可包含 \`// TODO: 補充邊界情況\` 註釋
212
-
213
- 專案根目錄:${projectRoot}`;
214
- }
215
-
216
- _parseResponse(response) {
217
- try {
218
- // 找 JSON 陣列
219
- const match = response.match(/\[[\s\S]*\]/);
220
- if (!match) {
221
- console.warn(' ⚠️ 無法從 AI 回應找到 JSON 陣列');
222
- return [];
223
- }
224
-
225
- const files = JSON.parse(match[0]);
226
- if (!Array.isArray(files)) {
227
- console.warn(' ⚠️ AI 回應不是陣列格式');
228
- return [];
229
- }
230
-
231
- return files.map(f => ({
232
- filePath: f.filePath || '',
233
- content: f.content || '',
234
- type: f.type || 'new',
235
- reason: f.reason || null,
236
- }));
237
- } catch (error) {
238
- console.warn(` ⚠️ 解析 AI 回應失敗:${error.message}`);
239
- return [];
240
- }
241
- }
242
- }
@@ -1,298 +0,0 @@
1
- /**
2
- * AutoDev 工作流程編排
3
- * 整合 Issue 解析 → 測試執行 → 結果發佈 → commit-all → PR
4
- */
5
-
6
- import { IssueParser } from './issue-parser.js';
7
- import { TestDetector } from '../test/test-detector.js';
8
- import { createExecutor } from '../test/executor-factory.js';
9
- import { ResultFormatter, COMMENT_MARKER } from '../test/result-formatter.js';
10
- import { CodeGenerator } from '../ai/code-generator.js';
11
- import { GitHubAPI } from '../../pr-modules/core/github-api.js';
12
- import { commitAllProgrammatic } from '../../commands/commit-all.js';
13
- import { prProgrammatic } from '../../commands/pr.js';
14
-
15
- /**
16
- * @typedef {Object} AutodevOptions
17
- * @property {boolean} [dryRun] - 乾運行:只顯示計劃,不執行
18
- * @property {boolean} [verbose] - 詳細輸出
19
- * @property {string} [framework] - 強制指定框架(覆蓋自動偵測)
20
- * @property {boolean} [skipCommit] - 跳過 commit-all
21
- * @property {boolean} [skipPr] - 跳過 PR 建立
22
- * @property {boolean} [skipAll] - 只測試 + 發佈評論,跳過 commit 和 PR
23
- * @property {boolean} [commitOnly] - 測試 + commit,不建立 PR
24
- * @property {boolean} [skipTests] - 無測試可執行時繼續(而非停止)
25
- */
26
-
27
- export class AutodevWorkflow {
28
- /**
29
- * @param {Object} config - .ai-git-config.js 的 autodev 區塊
30
- */
31
- constructor(config = {}) {
32
- this.config = config;
33
- this.issueParser = new IssueParser();
34
- this.testDetector = new TestDetector(config.projectRoot || process.cwd());
35
- this.formatter = new ResultFormatter();
36
- this.codeGenerator = new CodeGenerator({
37
- aiModel: config.aiModel,
38
- maxRetries: config.maxRetries,
39
- });
40
- this.github = new GitHubAPI();
41
- }
42
-
43
- /**
44
- * 主執行流程
45
- * @param {string|number} issueInput
46
- * @param {AutodevOptions} options
47
- */
48
- async execute(issueInput, options = {}) {
49
- const step = (n, total, msg) =>
50
- console.log(`\n[${n}/${total}] ${msg}`);
51
-
52
- const TOTAL = this._calcTotalSteps(options);
53
- let stepIdx = 0;
54
-
55
- // ── 1. 解析 Issue ────────────────────────────────────────
56
- step(++stepIdx, TOTAL, '解析 Issue...');
57
- const issueData = await this._parseIssue(issueInput, options);
58
-
59
- // ── 2. AI 自動開發(生成代碼框架)────────────────────────
60
- step(++stepIdx, TOTAL, '生成代碼框架...');
61
- const generatedFiles = await this._generateCode(issueData, options);
62
-
63
- // ── 3. 偵測框架 ──────────────────────────────────────────
64
- step(++stepIdx, TOTAL, '偵測測試框架...');
65
- const framework = this._detectFramework(options);
66
-
67
- // ── 4. 發現測試檔案 ──────────────────────────────────────
68
- step(++stepIdx, TOTAL, '搜尋測試檔案...');
69
- const testFiles = this.testDetector.discoverTests(framework, {
70
- testPaths: this.config.testPaths,
71
- });
72
- console.log(` 找到 ${testFiles.length} 個測試檔案`);
73
-
74
- if (options.dryRun) {
75
- console.log('\n🔍 乾運行模式:以下是執行計劃\n');
76
- console.log(` Issue : #${issueData.number} - ${issueData.title}`);
77
- console.log(` 代碼檔案 : ${generatedFiles.length} 個將被建立`);
78
- console.log(` 框架 : ${framework}`);
79
- console.log(` 測試數量 : ${testFiles.length} 個檔案`);
80
- console.log(` Commit : ${this._willCommit(options) ? '✅ 會執行' : '⏭️ 跳過'}`);
81
- console.log(` PR : ${this._willCreatePR(options) ? '✅ 會執行' : '⏭️ 跳過'}`);
82
- return;
83
- }
84
-
85
- if (testFiles.length === 0) {
86
- console.warn(' ⚠️ 未找到任何測試檔案,跳過測試步驟');
87
- }
88
-
89
- // ── 5. 執行測試 ──────────────────────────────────────────
90
- step(++stepIdx, TOTAL, `執行 ${framework} 測試...`);
91
- const executor = createExecutor(framework, {
92
- timeout: this.config.testTimeout,
93
- });
94
- const testResults = await executor.run(testFiles, {
95
- verbose: options.verbose,
96
- projectRoot: this.config.projectRoot || process.cwd(),
97
- });
98
- const allPassed = testResults.failed === 0 && testResults.total > 0;
99
- const passRate = testResults.total > 0
100
- ? ((testResults.passed / testResults.total) * 100).toFixed(1)
101
- : '-';
102
- console.log(
103
- ` ${allPassed ? '✅' : '❌'} ${testResults.passed}/${testResults.total} 通過(${passRate}%)· ${(testResults.duration / 1000).toFixed(1)}s`
104
- );
105
-
106
- // ── 6. 格式化結果 ─────────────────────────────────────────
107
- step(++stepIdx, TOTAL, '格式化測試結果...');
108
- const meta = {
109
- generatedFiles: generatedFiles.length,
110
- };
111
- let markdown = this.formatter.formatAsMarkdown(testResults, issueData, meta);
112
-
113
- // ── 7. 發佈到 Issue 評論(初稿,無 commit/PR 資訊)─────────
114
- step(++stepIdx, TOTAL, `發佈結果到 Issue #${issueData.number}...`);
115
- const commentResult = await this._postOrUpdateComment(
116
- issueData,
117
- markdown,
118
- options
119
- );
120
- if (commentResult) {
121
- console.log(` 📝 評論已發佈:${commentResult.url}`);
122
- }
123
-
124
- // ── 8. 若測試失敗,停止 ───────────────────────────────────
125
- if (!allPassed && testResults.total > 0) {
126
- console.log('\n❌ 測試未全部通過,已停止自動 commit / PR');
127
- console.log(' 修復失敗的測試後,重新執行 `ai autodev <issue>`');
128
- return;
129
- }
130
-
131
- // 若沒有測試:預設停止,加 --skip-tests 才繼續
132
- if (testResults.total === 0) {
133
- if (options.skipTests) {
134
- console.warn(' ⚠️ 無測試可執行(--skip-tests 已啟用,繼續後續流程)');
135
- } else {
136
- console.log('\n⏸️ 無測試可執行,已停止自動 commit / PR');
137
- console.log(' 可能原因:');
138
- console.log(' 1. 生成的測試檔案依賴未安裝(node_modules 缺失或模組不存在)');
139
- console.log(' 2. 生成的測試檔案語法有誤(用 npx jest --listTests 確認)');
140
- console.log(' 3. testPaths 設定範圍未涵蓋新生成的檔案');
141
- console.log('\n 若確認要跳過測試直接 commit/PR,可加上 --skip-tests:');
142
- console.log(' ai autodev <issue> --skip-tests');
143
- return;
144
- }
145
- }
146
-
147
- // ── 9. 自動 commit-all ───────────────────────────────────
148
- if (this._willCommit(options)) {
149
- step(++stepIdx, TOTAL, '自動提交(ai commit-all)...');
150
- const commitResult = await commitAllProgrammatic({ verbose: options.verbose });
151
- if (!commitResult.success) {
152
- console.error(` ❌ 提交失敗:${commitResult.message}`);
153
- console.log(' 請手動執行 `ai commit-all`,然後再次執行 `ai pr`');
154
- return;
155
- }
156
- meta.commitHash = commitResult.commitHash;
157
- console.log(` ✅ ${commitResult.message}(${commitResult.commitHash})`);
158
- }
159
-
160
- // ── 10. 自動建立 PR ───────────────────────────────────────
161
- if (this._willCreatePR(options)) {
162
- step(++stepIdx, TOTAL, '自動建立 PR(ai pr)...');
163
- const prResult = await prProgrammatic({ verbose: options.verbose });
164
- if (!prResult.success) {
165
- console.error(` ❌ PR 建立失敗:${prResult.message}`);
166
- return;
167
- }
168
- meta.prUrl = prResult.prUrl;
169
- console.log(` ✅ PR 已建立:${prResult.prUrl}`);
170
- }
171
-
172
- // ── 更新 Issue 評論(加入 commit/PR 資訊)───────────────
173
- if (commentResult && (meta.commitHash || meta.prUrl)) {
174
- markdown = this.formatter.formatAsMarkdown(testResults, issueData, meta);
175
- await this._postOrUpdateComment(issueData, markdown, options, commentResult.id);
176
- }
177
-
178
- // ── 完成摘要 ──────────────────────────────────────────────
179
- console.log('\n' + '═'.repeat(60));
180
- console.log('🎉 ai autodev 完成!');
181
- console.log(` Issue : #${issueData.number} - ${issueData.title}`);
182
- if (generatedFiles.length > 0) console.log(` 代碼檔案 : ${generatedFiles.length} 個已建立`);
183
- console.log(` 測試 : ${testResults.passed}/${testResults.total} 通過`);
184
- if (meta.commitHash) console.log(` Commit : ${meta.commitHash}`);
185
- if (meta.prUrl) console.log(` PR : ${meta.prUrl}`);
186
- if (commentResult) console.log(` 評論 : ${commentResult.url}`);
187
- console.log('═'.repeat(60));
188
- }
189
-
190
- // ── 私有輔助方法 ─────────────────────────────────────────────
191
-
192
- async _parseIssue(issueInput, options) {
193
- const issueData = await this.issueParser.parse(issueInput);
194
- if (options.verbose) {
195
- console.log(` #${issueData.number}: ${issueData.title}`);
196
- console.log(` 倉庫: ${issueData.owner}/${issueData.repo}`);
197
- }
198
- return issueData;
199
- }
200
-
201
- async _generateCode(issueData, options) {
202
- try {
203
- const projectRoot = this.config.projectRoot || process.cwd();
204
- const files = await this.codeGenerator.generateCodeFiles(issueData, projectRoot);
205
-
206
- if (files.length === 0) {
207
- console.log(' ℹ️ 沒有需要生成的代碼檔案');
208
- return [];
209
- }
210
-
211
- // 寫入檔案(每個檔案會即時顯示名稱)
212
- const { written, skipped } = await this.codeGenerator.writeFiles(files, projectRoot);
213
-
214
- if (skipped.length > 0) {
215
- console.warn(` ⚠️ 跳過 ${skipped.length} 個檔案`);
216
- }
217
-
218
- // 顯示 git diff --stat 讓用戶看到實際變動
219
- if (written.length > 0) {
220
- console.log(`\n ✅ 共寫入 ${written.length} 個檔案,git 變動摘要:`);
221
- try {
222
- const { execSync } = await import('child_process');
223
- const stat = execSync('git diff --stat HEAD 2>/dev/null || git status --short', {
224
- cwd: projectRoot,
225
- encoding: 'utf-8',
226
- }).trim();
227
- if (stat) {
228
- stat.split('\n').forEach((line) => console.log(` ${line}`));
229
- }
230
- } catch (_) {
231
- // git diff 失敗不影響流程
232
- }
233
- }
234
-
235
- return written;
236
- } catch (error) {
237
- console.error(` ❌ 代碼生成失敗:${error.message}`);
238
- return [];
239
- }
240
- }
241
-
242
- _detectFramework(options) {
243
- const forced = options.framework || this.config.framework;
244
- if (forced) {
245
- console.log(` 使用指定框架:${forced}`);
246
- return forced;
247
- }
248
- const detected = this.testDetector.detectFramework();
249
- if (!detected) {
250
- throw new Error(
251
- '無法自動偵測測試框架\n請在 .ai-git-config.js 的 autodev.framework 指定:jest / vitest / mocha'
252
- );
253
- }
254
- console.log(` 偵測到框架:${detected}`);
255
- return detected;
256
- }
257
-
258
- async _postOrUpdateComment(issueData, markdown, options, existingCommentId = null) {
259
- if (options.dryRun) return null;
260
-
261
- try {
262
- const { owner, repo, number } = issueData;
263
-
264
- // 若有已知 ID,直接更新
265
- if (existingCommentId) {
266
- return this.github.editIssueComment(owner, repo, existingCommentId, markdown);
267
- }
268
-
269
- // 搜尋相同 marker 的舊評論
270
- const existing = this.github.findCommentWithMarker(owner, repo, number, COMMENT_MARKER);
271
- if (existing) {
272
- return this.github.editIssueComment(owner, repo, existing.id, markdown);
273
- }
274
-
275
- return this.github.postCommentOnIssue(owner, repo, number, markdown);
276
- } catch (error) {
277
- console.error(` ⚠️ 發佈評論失敗:${error.message}`);
278
- return null;
279
- }
280
- }
281
-
282
- _willCommit(options) {
283
- return !options.skipAll && !options.skipCommit && !options.dryRun;
284
- }
285
-
286
- _willCreatePR(options) {
287
- if (options.commitOnly) return false;
288
- return !options.skipAll && !options.skipPr && !options.dryRun;
289
- }
290
-
291
- _calcTotalSteps(options) {
292
- // 基本步驟:Issue解析 + 代碼生成 + 框架偵測 + 測試發現 + 執行 + 格式化 + 發佈
293
- let count = 7;
294
- if (this._willCommit(options)) count++;
295
- if (this._willCreatePR(options)) count++;
296
- return count;
297
- }
298
- }
@@ -1,144 +0,0 @@
1
- /**
2
- * Issue 解析模組
3
- * 負責解析 Issue 編號/URL,並從 GitHub API 擷取 Issue 資料
4
- */
5
-
6
- import { execSync } from 'child_process';
7
-
8
- /**
9
- * 標準化 IssueData 結構
10
- * @typedef {Object} IssueData
11
- * @property {number} number - Issue 編號
12
- * @property {string} title - Issue 標題
13
- * @property {string} body - Issue 說明
14
- * @property {string[]} labels - 標籤列表
15
- * @property {string} owner - 倉庫擁有者
16
- * @property {string} repo - 倉庫名稱
17
- * @property {string} url - Issue URL
18
- */
19
-
20
- export class IssueParser {
21
- /**
22
- * 解析輸入(數字字串或完整 URL)為 Issue 編號
23
- * @param {string|number} input - Issue 編號或 GitHub Issue URL
24
- * @returns {number} Issue 編號
25
- */
26
- parseIssueNumber(input) {
27
- const str = String(input).trim();
28
-
29
- // 純數字
30
- if (/^\d+$/.test(str)) {
31
- return parseInt(str, 10);
32
- }
33
-
34
- // GitHub URL 格式:https://github.com/owner/repo/issues/123
35
- const urlMatch = str.match(/\/issues\/(\d+)/);
36
- if (urlMatch) {
37
- return parseInt(urlMatch[1], 10);
38
- }
39
-
40
- throw new Error(
41
- `無效的 Issue 輸入:「${input}」\n請提供 Issue 編號(如 123)或完整 URL(如 https://github.com/owner/repo/issues/123)`
42
- );
43
- }
44
-
45
- /**
46
- * 從 GitHub URL 解析倉庫資訊
47
- * @param {string} url
48
- * @returns {{ owner: string, repo: string, issueNumber: number }}
49
- */
50
- parseIssueURL(url) {
51
- const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
52
- if (!match) {
53
- throw new Error(`無法解析 GitHub Issue URL:${url}`);
54
- }
55
- return {
56
- owner: match[1],
57
- repo: match[2].replace(/\.git$/, ''),
58
- issueNumber: parseInt(match[3], 10),
59
- };
60
- }
61
-
62
- /**
63
- * 從 git remote 自動偵測 owner/repo
64
- * @returns {{ owner: string, repo: string }}
65
- */
66
- detectRepoFromRemote() {
67
- try {
68
- const remoteUrl = execSync('git remote get-url origin', {
69
- encoding: 'utf-8',
70
- stdio: ['pipe', 'pipe', 'pipe'],
71
- }).trim();
72
-
73
- // HTTPS: https://github.com/owner/repo.git
74
- const httpsMatch = remoteUrl.match(/github\.com\/([^/]+)\/([^/\s]+?)(?:\.git)?$/);
75
- if (httpsMatch) {
76
- return { owner: httpsMatch[1], repo: httpsMatch[2] };
77
- }
78
-
79
- // SSH: git@github.com:owner/repo.git
80
- const sshMatch = remoteUrl.match(/github\.com:([^/]+)\/([^/\s]+?)(?:\.git)?$/);
81
- if (sshMatch) {
82
- return { owner: sshMatch[1], repo: sshMatch[2] };
83
- }
84
-
85
- throw new Error('無法從 git remote 解析 owner/repo');
86
- } catch (error) {
87
- throw new Error(`偵測 git remote 失敗:${error.message}`);
88
- }
89
- }
90
-
91
- /**
92
- * 透過 GitHub CLI 擷取 Issue 資料
93
- * @param {string} owner
94
- * @param {string} repo
95
- * @param {number} issueNumber
96
- * @returns {IssueData}
97
- */
98
- async fetchIssue(owner, repo, issueNumber) {
99
- try {
100
- const json = execSync(
101
- `gh api repos/${owner}/${repo}/issues/${issueNumber}`,
102
- { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
103
- );
104
- const data = JSON.parse(json);
105
-
106
- return {
107
- number: data.number,
108
- title: data.title,
109
- body: data.body || '',
110
- labels: (data.labels || []).map((l) => l.name),
111
- owner,
112
- repo,
113
- url: data.html_url,
114
- };
115
- } catch (error) {
116
- if (error.message.includes('404')) {
117
- throw new Error(
118
- `Issue #${issueNumber} 不存在於 ${owner}/${repo}\n請確認 Issue 編號正確,且你有存取該倉庫的權限`
119
- );
120
- }
121
- throw new Error(`擷取 Issue 失敗:${error.message}`);
122
- }
123
- }
124
-
125
- /**
126
- * 解析輸入並完整擷取 Issue 資料(主要入口)
127
- * @param {string|number} input - Issue 編號或 URL
128
- * @returns {Promise<IssueData>}
129
- */
130
- async parse(input) {
131
- const str = String(input).trim();
132
-
133
- // 如果是完整 URL,直接解析 owner/repo
134
- if (str.startsWith('http')) {
135
- const { owner, repo, issueNumber } = this.parseIssueURL(str);
136
- return this.fetchIssue(owner, repo, issueNumber);
137
- }
138
-
139
- // 否則從 git remote 偵測
140
- const issueNumber = this.parseIssueNumber(str);
141
- const { owner, repo } = this.detectRepoFromRemote();
142
- return this.fetchIssue(owner, repo, issueNumber);
143
- }
144
- }