ai-git-tools 2.0.38 → 2.0.40

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/bin/cli.js CHANGED
@@ -15,6 +15,7 @@ import { commitCommand } from '../src/commands/commit.js';
15
15
  import { commitAllCommand } from '../src/commands/commit-all.js';
16
16
  import { prCommand } from '../src/commands/pr.js';
17
17
  import { initCommand } from '../src/commands/init.js';
18
+ import { autodevCommand } from '../src/commands/autodev.js';
18
19
 
19
20
  // 讀取 package.json 獲取版本號
20
21
  const __filename = fileURLToPath(import.meta.url);
@@ -96,4 +97,24 @@ program
96
97
  }
97
98
  });
98
99
 
100
+ // AutoDev 命令
101
+ program
102
+ .command('autodev <issue>')
103
+ .description('全自動開發流程:Issue → 測試 → commit-all → PR')
104
+ .option('--dry-run', '乾運行:顯示計劃但不執行')
105
+ .option('-v, --verbose', '詳細輸出')
106
+ .option('--framework <name>', '強制指定測試框架(jest / vitest / mocha)')
107
+ .option('--skip-commit', '跳過自動 commit')
108
+ .option('--skip-pr', '跳過自動 PR 建立')
109
+ .option('--skip-all', '只執行測試並發佈評論')
110
+ .option('--commit-only', '執行測試 + commit,不建立 PR')
111
+ .action(async (issue, options) => {
112
+ try {
113
+ await autodevCommand(issue, options);
114
+ process.exit(0);
115
+ } catch (error) {
116
+ process.exit(1);
117
+ }
118
+ });
119
+
99
120
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.0.38",
3
+ "version": "2.0.40",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,51 @@
1
+ /**
2
+ * AutoDev 命令
3
+ * 完整自動化開發流程:Issue 解析 → 測試 → 發佈評論 → commit-all → PR
4
+ *
5
+ * 使用方式:
6
+ * ai autodev <issue>
7
+ * ai autodev 123
8
+ * ai autodev https://github.com/org/repo/issues/123
9
+ * ai autodev 123 --dry-run
10
+ * ai autodev 123 --skip-pr
11
+ */
12
+
13
+ import { AutodevWorkflow } from '../dev-modules/core/autodev-workflow.js';
14
+ import { loadAutodevConfig } from '../core/config-loader.js';
15
+ import { handleError } from '../utils/helpers.js';
16
+
17
+ /**
18
+ * AutoDev 命令主函數
19
+ * @param {string|number} issueInput - Issue 編號或 URL
20
+ * @param {Object} cliOptions - 來自 commander 的選項
21
+ */
22
+ export async function autodevCommand(issueInput, cliOptions = {}) {
23
+ if (!issueInput) {
24
+ console.error('❌ 請提供 Issue 編號或 URL');
25
+ console.error(' 用法:ai autodev <issue>');
26
+ console.error(' 範例:ai autodev 123');
27
+ process.exit(1);
28
+ }
29
+
30
+ try {
31
+ // 載入設定
32
+ const config = await loadAutodevConfig();
33
+
34
+ // 合併 CLI 選項(CLI 優先)
35
+ const options = {
36
+ dryRun: cliOptions.dryRun ?? false,
37
+ verbose: cliOptions.verbose ?? config.verbose ?? false,
38
+ framework: cliOptions.framework ?? config.framework ?? null,
39
+ skipCommit: cliOptions.skipCommit ?? config.skipCommit ?? false,
40
+ skipPr: cliOptions.skipPr ?? config.skipPr ?? false,
41
+ skipAll: cliOptions.skipAll ?? false,
42
+ commitOnly: cliOptions.commitOnly ?? false,
43
+ };
44
+
45
+ const workflow = new AutodevWorkflow(config);
46
+ await workflow.execute(issueInput, options);
47
+ } catch (error) {
48
+ handleError(error);
49
+ throw error;
50
+ }
51
+ }
@@ -459,3 +459,82 @@ export async function commitAllCommand() {
459
459
  throw error;
460
460
  }
461
461
  }
462
+
463
+ // ─────────────────────────────────────────────────────────────
464
+ // 程序化 API(供 ai autodev 呼叫,不影響 CLI 行為)
465
+ // ─────────────────────────────────────────────────────────────
466
+
467
+ /**
468
+ * 程序化執行 commit-all 邏輯,供其他模組呼叫
469
+ * 不會呼叫 process.exit(),而是回傳結果物件
470
+ *
471
+ * @param {Object} [options]
472
+ * @param {boolean} [options.verbose]
473
+ * @returns {Promise<{ success: boolean, commitCount: number, message: string }>}
474
+ */
475
+ export async function commitAllProgrammatic(options = {}) {
476
+ const logger = new Logger();
477
+
478
+ try {
479
+ const config = await loadCommitConfig();
480
+ if (options.verbose) {
481
+ config.output.verbose = true;
482
+ }
483
+
484
+ const changes = getAllChanges();
485
+ if (changes.length === 0) {
486
+ return { success: true, commitCount: 0, message: '沒有需要提交的變更' };
487
+ }
488
+
489
+ const groups = await analyzeAndGroupChanges(changes, config);
490
+ if (!groups || groups.length === 0) {
491
+ return { success: false, commitCount: 0, message: 'AI 分析失敗,無法產生分組' };
492
+ }
493
+
494
+ // 補入未分組的檔案
495
+ const groupedIndices = new Set(groups.flatMap((g) => g.file_indices));
496
+ const ungrouped = [...Array(changes.length).keys()].filter((i) => !groupedIndices.has(i));
497
+ if (ungrouped.length > 0) {
498
+ groups.push({
499
+ group_name: '其他變更',
500
+ commit_type: 'chore',
501
+ commit_scope: 'misc',
502
+ file_indices: ungrouped,
503
+ description: '未能自動分類的其他變更',
504
+ });
505
+ }
506
+
507
+ let successCount = 0;
508
+ for (let i = 0; i < groups.length; i++) {
509
+ const group = groups[i];
510
+ const groupFiles = group.file_indices.map((index) => changes[index]);
511
+ const ok = await commitGroup(group, groupFiles, config);
512
+ if (ok) successCount++;
513
+ }
514
+
515
+ // 清理 staged
516
+ try {
517
+ execSync('git reset HEAD -- .', { stdio: 'ignore' });
518
+ } catch (_) { /* 忽略 */ }
519
+
520
+ if (successCount === 0) {
521
+ return { success: false, commitCount: 0, message: '所有群組提交失敗' };
522
+ }
523
+
524
+ // 取得最新 commit hash
525
+ let commitHash = '';
526
+ try {
527
+ commitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
528
+ } catch (_) { /* 忽略 */ }
529
+
530
+ return {
531
+ success: true,
532
+ commitCount: successCount,
533
+ commitHash,
534
+ message: `成功提交 ${successCount}/${groups.length} 個群組`,
535
+ };
536
+ } catch (error) {
537
+ logger.error(`commitAllProgrammatic 失敗:${error.message}`);
538
+ return { success: false, commitCount: 0, message: error.message };
539
+ }
540
+ }
@@ -62,3 +62,66 @@ export async function prCommand() {
62
62
  throw error;
63
63
  }
64
64
  }
65
+
66
+ // ─────────────────────────────────────────────────────────────
67
+ // 程序化 API(供 ai autodev 呼叫,不影響 CLI 行為)
68
+ // ─────────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * 程序化執行 PR 建立流程,供其他模組呼叫
72
+ * 不互動(noConfirm = true),直接建立 PR 並回傳結果
73
+ *
74
+ * @param {Object} [options]
75
+ * @param {string} [options.base] - 目標分支
76
+ * @param {string} [options.head] - 來源分支
77
+ * @param {boolean} [options.verbose]
78
+ * @param {Object} [options.testResults] - 注入測試結果到 PR 描述(保留供未來擴充)
79
+ * @returns {Promise<{ success: boolean, prUrl: string|null, prNumber: number|null, message: string }>}
80
+ */
81
+ export async function prProgrammatic(options = {}) {
82
+ const logger = new Logger();
83
+
84
+ if (!checkGHAuth(logger)) {
85
+ return { success: false, prUrl: null, prNumber: null, message: 'GitHub CLI 未登入' };
86
+ }
87
+
88
+ try {
89
+ const config = await loadConfig();
90
+
91
+ // 程序化呼叫:跳過互動確認
92
+ config.noConfirm = true;
93
+ if (options.base) config.baseBranch = options.base;
94
+ if (options.head) config.headBranch = options.head;
95
+ if (options.verbose) config.output.verbose = true;
96
+
97
+ const workflow = new PRWorkflow(config);
98
+
99
+ // 攔截 PRWorkflow 建立的 PR URL
100
+ let prUrl = null;
101
+ let prNumber = null;
102
+ const originalCreatePR = workflow.createPR?.bind(workflow);
103
+ if (originalCreatePR) {
104
+ workflow.createPR = async (...args) => {
105
+ const url = await originalCreatePR(...args);
106
+ prUrl = url;
107
+ if (url) {
108
+ const match = url.match(/\/pull\/(\d+)/);
109
+ if (match) prNumber = parseInt(match[1], 10);
110
+ }
111
+ return url;
112
+ };
113
+ }
114
+
115
+ await workflow.execute();
116
+
117
+ return {
118
+ success: true,
119
+ prUrl,
120
+ prNumber,
121
+ message: prUrl ? `PR 已建立:${prUrl}` : 'PR 執行完成',
122
+ };
123
+ } catch (error) {
124
+ logger.error(`prProgrammatic 失敗:${error.message}`);
125
+ return { success: false, prUrl: null, prNumber: null, message: error.message };
126
+ }
127
+ }
@@ -179,3 +179,46 @@ export async function loadPRConfig() {
179
179
 
180
180
  return config;
181
181
  }
182
+
183
+ /**
184
+ * 載入 AutoDev 配置
185
+ * 讀取 .ai-git-config.js(或 .mjs)的 autodev 區塊
186
+ * 所有欄位皆選填,未設定時使用合理預設值
187
+ *
188
+ * @returns {Promise<Object>}
189
+ */
190
+ export async function loadAutodevConfig() {
191
+ const defaults = {
192
+ framework: null, // null = 自動偵測
193
+ testPaths: [], // 空 = 自動發現
194
+ testTimeout: 300_000, // 5 分鐘
195
+ skipCommit: false,
196
+ skipPr: false,
197
+ verbose: false,
198
+ projectRoot: process.cwd(),
199
+ };
200
+
201
+ // 支援 .ai-git-config.js 和 .ai-git-config.mjs
202
+ const candidates = [
203
+ resolve(process.cwd(), '.ai-git-config.js'),
204
+ resolve(process.cwd(), '.ai-git-config.mjs'),
205
+ ];
206
+
207
+ let userAutodev = {};
208
+ for (const configPath of candidates) {
209
+ if (existsSync(configPath)) {
210
+ try {
211
+ const imported = await import(`file://${configPath}`);
212
+ userAutodev = (imported.default || {}).autodev || {};
213
+ break;
214
+ } catch (error) {
215
+ console.warn(`⚠️ 載入 autodev 配置失敗: ${error.message}`);
216
+ }
217
+ }
218
+ }
219
+
220
+ return {
221
+ ...defaults,
222
+ ...userAutodev,
223
+ };
224
+ }
@@ -0,0 +1,232 @@
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 { GitHubAPI } from '../../pr-modules/core/github-api.js';
11
+ import { commitAllProgrammatic } from '../../commands/commit-all.js';
12
+ import { prProgrammatic } from '../../commands/pr.js';
13
+
14
+ /**
15
+ * @typedef {Object} AutodevOptions
16
+ * @property {boolean} [dryRun] - 乾運行:只顯示計劃,不執行
17
+ * @property {boolean} [verbose] - 詳細輸出
18
+ * @property {string} [framework] - 強制指定框架(覆蓋自動偵測)
19
+ * @property {boolean} [skipCommit] - 跳過 commit-all
20
+ * @property {boolean} [skipPr] - 跳過 PR 建立
21
+ * @property {boolean} [skipAll] - 只測試 + 發佈評論,跳過 commit 和 PR
22
+ * @property {boolean} [commitOnly] - 測試 + commit,不建立 PR
23
+ */
24
+
25
+ export class AutodevWorkflow {
26
+ /**
27
+ * @param {Object} config - .ai-git-config.js 的 autodev 區塊
28
+ */
29
+ constructor(config = {}) {
30
+ this.config = config;
31
+ this.issueParser = new IssueParser();
32
+ this.testDetector = new TestDetector(config.projectRoot || process.cwd());
33
+ this.formatter = new ResultFormatter();
34
+ this.github = new GitHubAPI();
35
+ }
36
+
37
+ /**
38
+ * 主執行流程
39
+ * @param {string|number} issueInput
40
+ * @param {AutodevOptions} options
41
+ */
42
+ async execute(issueInput, options = {}) {
43
+ const step = (n, total, msg) =>
44
+ console.log(`\n[${n}/${total}] ${msg}`);
45
+
46
+ const TOTAL = this._calcTotalSteps(options);
47
+ let stepIdx = 0;
48
+
49
+ // ── 1. 解析 Issue ────────────────────────────────────────
50
+ step(++stepIdx, TOTAL, '解析 Issue...');
51
+ const issueData = await this._parseIssue(issueInput, options);
52
+
53
+ // ── 2. 偵測框架 ──────────────────────────────────────────
54
+ step(++stepIdx, TOTAL, '偵測測試框架...');
55
+ const framework = this._detectFramework(options);
56
+
57
+ // ── 3. 發現測試檔案 ──────────────────────────────────────
58
+ step(++stepIdx, TOTAL, '搜尋測試檔案...');
59
+ const testFiles = this.testDetector.discoverTests(framework, {
60
+ testPaths: this.config.testPaths,
61
+ });
62
+ console.log(` 找到 ${testFiles.length} 個測試檔案`);
63
+
64
+ if (options.dryRun) {
65
+ console.log('\n🔍 乾運行模式:以下是執行計劃\n');
66
+ console.log(` Issue : #${issueData.number} - ${issueData.title}`);
67
+ console.log(` 框架 : ${framework}`);
68
+ console.log(` 測試數量: ${testFiles.length} 個檔案`);
69
+ console.log(` Commit : ${this._willCommit(options) ? '✅ 會執行' : '⏭️ 跳過'}`);
70
+ console.log(` PR : ${this._willCreatePR(options) ? '✅ 會執行' : '⏭️ 跳過'}`);
71
+ return;
72
+ }
73
+
74
+ if (testFiles.length === 0) {
75
+ console.warn(' ⚠️ 未找到任何測試檔案,跳過測試步驟');
76
+ }
77
+
78
+ // ── 4. 執行測試 ──────────────────────────────────────────
79
+ step(++stepIdx, TOTAL, `執行 ${framework} 測試...`);
80
+ const executor = createExecutor(framework, {
81
+ timeout: this.config.testTimeout,
82
+ });
83
+ const testResults = await executor.run(testFiles, {
84
+ verbose: options.verbose,
85
+ projectRoot: this.config.projectRoot || process.cwd(),
86
+ });
87
+ const allPassed = testResults.failed === 0 && testResults.total > 0;
88
+ const passRate = testResults.total > 0
89
+ ? ((testResults.passed / testResults.total) * 100).toFixed(1)
90
+ : '-';
91
+ console.log(
92
+ ` ${allPassed ? '✅' : '❌'} ${testResults.passed}/${testResults.total} 通過(${passRate}%)· ${(testResults.duration / 1000).toFixed(1)}s`
93
+ );
94
+
95
+ // ── 5. 格式化結果 ─────────────────────────────────────────
96
+ step(++stepIdx, TOTAL, '格式化測試結果...');
97
+ const meta = {};
98
+ let markdown = this.formatter.formatAsMarkdown(testResults, issueData, meta);
99
+
100
+ // ── 6. 發佈到 Issue 評論(初稿,無 commit/PR 資訊)─────────
101
+ step(++stepIdx, TOTAL, `發佈測試結果到 Issue #${issueData.number}...`);
102
+ const commentResult = await this._postOrUpdateComment(
103
+ issueData,
104
+ markdown,
105
+ options
106
+ );
107
+ if (commentResult) {
108
+ console.log(` 📝 評論已發佈:${commentResult.url}`);
109
+ }
110
+
111
+ // ── 7. 若測試失敗,停止 ───────────────────────────────────
112
+ if (!allPassed && testResults.total > 0) {
113
+ console.log('\n❌ 測試未全部通過,已停止自動 commit / PR');
114
+ console.log(' 修復失敗的測試後,重新執行 `ai autodev <issue>`');
115
+ return;
116
+ }
117
+
118
+ // 若沒有測試,給警告但繼續
119
+ if (testResults.total === 0) {
120
+ console.warn(' ⚠️ 無測試可執行,繼續後續流程');
121
+ }
122
+
123
+ // ── 8. 自動 commit-all ───────────────────────────────────
124
+ if (this._willCommit(options)) {
125
+ step(++stepIdx, TOTAL, '自動提交(ai commit-all)...');
126
+ const commitResult = await commitAllProgrammatic({ verbose: options.verbose });
127
+ if (!commitResult.success) {
128
+ console.error(` ❌ 提交失敗:${commitResult.message}`);
129
+ console.log(' 請手動執行 `ai commit-all`,然後再次執行 `ai pr`');
130
+ return;
131
+ }
132
+ meta.commitHash = commitResult.commitHash;
133
+ console.log(` ✅ ${commitResult.message}(${commitResult.commitHash})`);
134
+ }
135
+
136
+ // ── 9. 自動建立 PR ────────────────────────────────────────
137
+ if (this._willCreatePR(options)) {
138
+ step(++stepIdx, TOTAL, '自動建立 PR(ai pr)...');
139
+ const prResult = await prProgrammatic({ verbose: options.verbose });
140
+ if (!prResult.success) {
141
+ console.error(` ❌ PR 建立失敗:${prResult.message}`);
142
+ return;
143
+ }
144
+ meta.prUrl = prResult.prUrl;
145
+ console.log(` ✅ PR 已建立:${prResult.prUrl}`);
146
+ }
147
+
148
+ // ── 更新 Issue 評論(加入 commit/PR 資訊)───────────────
149
+ if (commentResult && (meta.commitHash || meta.prUrl)) {
150
+ markdown = this.formatter.formatAsMarkdown(testResults, issueData, meta);
151
+ await this._postOrUpdateComment(issueData, markdown, options, commentResult.id);
152
+ }
153
+
154
+ // ── 完成摘要 ──────────────────────────────────────────────
155
+ console.log('\n' + '═'.repeat(60));
156
+ console.log('🎉 ai autodev 完成!');
157
+ console.log(` Issue : #${issueData.number} - ${issueData.title}`);
158
+ console.log(` 測試 : ${testResults.passed}/${testResults.total} 通過`);
159
+ if (meta.commitHash) console.log(` Commit : ${meta.commitHash}`);
160
+ if (meta.prUrl) console.log(` PR : ${meta.prUrl}`);
161
+ if (commentResult) console.log(` 評論 : ${commentResult.url}`);
162
+ console.log('═'.repeat(60));
163
+ }
164
+
165
+ // ── 私有輔助方法 ─────────────────────────────────────────────
166
+
167
+ async _parseIssue(issueInput, options) {
168
+ const issueData = await this.issueParser.parse(issueInput);
169
+ if (options.verbose) {
170
+ console.log(` #${issueData.number}: ${issueData.title}`);
171
+ console.log(` 倉庫: ${issueData.owner}/${issueData.repo}`);
172
+ }
173
+ return issueData;
174
+ }
175
+
176
+ _detectFramework(options) {
177
+ const forced = options.framework || this.config.framework;
178
+ if (forced) {
179
+ console.log(` 使用指定框架:${forced}`);
180
+ return forced;
181
+ }
182
+ const detected = this.testDetector.detectFramework();
183
+ if (!detected) {
184
+ throw new Error(
185
+ '無法自動偵測測試框架\n請在 .ai-git-config.js 的 autodev.framework 指定:jest / vitest / mocha'
186
+ );
187
+ }
188
+ console.log(` 偵測到框架:${detected}`);
189
+ return detected;
190
+ }
191
+
192
+ async _postOrUpdateComment(issueData, markdown, options, existingCommentId = null) {
193
+ if (options.dryRun) return null;
194
+
195
+ try {
196
+ const { owner, repo, number } = issueData;
197
+
198
+ // 若有已知 ID,直接更新
199
+ if (existingCommentId) {
200
+ return this.github.editIssueComment(owner, repo, existingCommentId, markdown);
201
+ }
202
+
203
+ // 搜尋相同 marker 的舊評論
204
+ const existing = this.github.findCommentWithMarker(owner, repo, number, COMMENT_MARKER);
205
+ if (existing) {
206
+ return this.github.editIssueComment(owner, repo, existing.id, markdown);
207
+ }
208
+
209
+ return this.github.postCommentOnIssue(owner, repo, number, markdown);
210
+ } catch (error) {
211
+ console.error(` ⚠️ 發佈評論失敗:${error.message}`);
212
+ return null;
213
+ }
214
+ }
215
+
216
+ _willCommit(options) {
217
+ return !options.skipAll && !options.skipCommit && !options.dryRun;
218
+ }
219
+
220
+ _willCreatePR(options) {
221
+ if (options.commitOnly) return false;
222
+ return !options.skipAll && !options.skipPr && !options.dryRun;
223
+ }
224
+
225
+ _calcTotalSteps(options) {
226
+ // 基本步驟:Issue解析 + 框架偵測 + 測試發現 + 執行 + 格式化 + 發佈
227
+ let count = 6;
228
+ if (this._willCommit(options)) count++;
229
+ if (this._willCreatePR(options)) count++;
230
+ return count;
231
+ }
232
+ }
@@ -0,0 +1,144 @@
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
+ }