ai-git-tools 2.0.70 → 2.0.71

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
@@ -2,14 +2,11 @@
2
2
 
3
3
  /**
4
4
  * AI Git Tools CLI
5
- *
5
+ *
6
6
  * AI-powered Git automation for commit messages and PR generation
7
7
  * 完全重寫版本基於 scripts/ 原始實現
8
8
  */
9
9
 
10
- // 抑制 @github/copilot-sdk 子程序的 node:sqlite 實驗性警告
11
- process.env.NODE_NO_WARNINGS = '1';
12
-
13
10
  import { Command } from 'commander';
14
11
  import { readFileSync } from 'fs';
15
12
  import { fileURLToPath } from 'url';
@@ -18,15 +15,13 @@ import { commitCommand } from '../src/commands/commit.js';
18
15
  import { commitAllCommand } from '../src/commands/commit-all.js';
19
16
  import { prCommand } from '../src/commands/pr.js';
20
17
  import { initCommand } from '../src/commands/init.js';
21
- // import { planIssueCommand } from '../src/commands/plan-issue.js';
22
- // import { generateCodeCommand } from '../src/commands/generate-code.js';
23
- import { writeAndTestCommand } from '../src/commands/write-and-test.js';
24
- import { autoDevCommand } from '../src/commands/auto-dev.js';
25
18
 
26
19
  // 讀取 package.json 獲取版本號
27
20
  const __filename = fileURLToPath(import.meta.url);
28
21
  const __dirname = dirname(__filename);
29
- const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
22
+ const packageJson = JSON.parse(
23
+ readFileSync(join(__dirname, '../package.json'), 'utf-8')
24
+ );
30
25
 
31
26
  const program = new Command();
32
27
 
@@ -39,7 +34,7 @@ program
39
34
  program
40
35
  .command('init')
41
36
  .description('初始化配置檔案 (.ai-git-config.mjs)')
42
- .action(async options => {
37
+ .action(async (options) => {
43
38
  try {
44
39
  await initCommand(options);
45
40
  process.exit(0);
@@ -56,7 +51,7 @@ program
56
51
  .option('-v, --verbose', '顯示詳細輸出')
57
52
  .option('--max-diff <number>', '最大 diff 長度')
58
53
  .option('--max-retries <number>', '最大重試次數')
59
- .action(async options => {
54
+ .action(async (options) => {
60
55
  try {
61
56
  await commitCommand(options);
62
57
  process.exit(0);
@@ -73,7 +68,7 @@ program
73
68
  .option('-v, --verbose', '顯示詳細輸出')
74
69
  .option('--max-diff <number>', '最大 diff 長度')
75
70
  .option('--max-retries <number>', '最大重試次數')
76
- .action(async options => {
71
+ .action(async (options) => {
77
72
  try {
78
73
  await commitAllCommand(options);
79
74
  process.exit(0);
@@ -93,7 +88,7 @@ program
93
88
  .option('--auto-labels', '自動添加 Labels (預設啟用)')
94
89
  .option('--include-impact', '在 PR 中包含影響範圍分析和注意事項 (預設關閉)')
95
90
  .option('--force-new', '強制創建新 PR,不更新現有 PR')
96
- .action(async options => {
91
+ .action(async (options) => {
97
92
  try {
98
93
  await prCommand(options);
99
94
  process.exit(0);
@@ -102,25 +97,4 @@ program
102
97
  }
103
98
  });
104
99
 
105
- // Auto Dev 整合命令
106
- program
107
- .command('auto-dev')
108
- .description('一鍵自動化:從 GitHub Issue 到代碼生成、測試與提交')
109
- .requiredOption('--issue <number>', 'GitHub Issue 編號')
110
- .option('--file <path>', '目標檔案路徑(若無則自動推斷)')
111
- .option('--context <description>', '額外說明或補充需求')
112
- .option('--test-type <type>', '測試類型:auto、unit 或 component(預設 auto)', 'auto')
113
- .option('--max-lines <number>', '最大行數限制(預設 500)', '500')
114
- .option('--max-fixes <number>', '最大自動修復次數(預設 2)', '2')
115
- .option('--no-confirm', '全自動模式,跳過所有確認')
116
- .option('--model <model>', '指定 AI 模型')
117
- .action(async options => {
118
- try {
119
- await autoDevCommand(options);
120
- process.exit(0);
121
- } catch (error) {
122
- process.exit(1);
123
- }
124
- });
125
-
126
100
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.0.70",
3
+ "version": "2.0.71",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -8,8 +8,7 @@
8
8
  "ai-git-tools": "./bin/cli.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "jest",
12
- "test:watch": "jest --watch",
11
+ "test": "echo \"Error: no test specified\" && exit 1",
13
12
  "prepare": "chmod +x bin/cli.js",
14
13
  "lint": "eslint src/**/*.js",
15
14
  "format": "prettier --write \"src/**/*.js\" \"bin/**/*.js\"",
@@ -41,15 +40,13 @@
41
40
  ],
42
41
  "dependencies": {
43
42
  "@github/copilot-sdk": "0.1.30",
44
- "chalk": "^5.3.0",
45
43
  "commander": "^12.0.0",
46
- "inquirer": "^9.2.0",
47
- "ora": "^8.0.1"
44
+ "chalk": "^5.3.0",
45
+ "ora": "^8.0.1",
46
+ "inquirer": "^9.2.0"
48
47
  },
49
48
  "devDependencies": {
50
- "@jest/globals": "^29.7.0",
51
49
  "eslint": "^8.57.0",
52
- "jest": "^29.7.0",
53
50
  "prettier": "^3.2.0"
54
51
  },
55
52
  "repository": {
@@ -59,12 +56,5 @@
59
56
  "bugs": {
60
57
  "url": "https://github.com/YisoTsao/ai-git-tools/issues"
61
58
  },
62
- "homepage": "https://github.com/YisoTsao/ai-git-tools#readme",
63
- "jest": {
64
- "testEnvironment": "node",
65
- "transform": {},
66
- "moduleNameMapper": {
67
- "^(\\.{1,2}/.+)\\.js$": "$1"
68
- }
69
- }
59
+ "homepage": "https://github.com/YisoTsao/ai-git-tools#readme"
70
60
  }
@@ -9,7 +9,7 @@ export class AIClient {
9
9
  /**
10
10
  * 發送 prompt 並等待回應(帶重試機制和超時保護)
11
11
  */
12
- static async sendAndWait(prompt, model = 'gpt-4.1', maxRetries = 3, timeout = 120000) {
12
+ static async sendAndWait(prompt, model = 'gpt-4.1', maxRetries = 3, timeout = 60000) {
13
13
  let lastError = null;
14
14
 
15
15
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
@@ -2,51 +2,24 @@ 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
- const AI_TIMEOUT_MS = 120000; // 2 分鐘:子程序啟動 + AI 回應時間
6
-
7
5
  /**
8
6
  * AI 分析器 - 負責程式碼分析和 PR 內容生成
9
7
  */
10
8
  export class AIAnalyzer {
11
9
  constructor(config = {}) {
12
10
  this.model = config.model || 'gpt-4.1';
13
- this._client = null; // 複用同一個 CopilotClient,避免重複啟動子程序
14
- }
15
-
16
- /**
17
- * 取得(或建立)共用的 CopilotClient
18
- */
19
- async _getOrCreateClient() {
20
- if (!this._client) {
21
- this._client = new CopilotClient();
22
- }
23
- return this._client;
24
11
  }
25
12
 
26
13
  /**
27
- * 建立新的 AI Session(複用已有的 client)
14
+ * 建立 AI 客戶端
28
15
  */
29
- async _createSession() {
30
- const client = await this._getOrCreateClient();
31
- return client.createSession({
16
+ async createClient() {
17
+ const client = new CopilotClient();
18
+ const session = await client.createSession({
32
19
  model: this.model,
33
- onPermissionRequest: approveAll,
20
+ onPermissionRequest: approveAll
34
21
  });
35
- }
36
-
37
- /**
38
- * 釋放 CopilotClient 子程序資源
39
- */
40
- async close() {
41
- if (this._client) {
42
- try {
43
- await this._client.stop();
44
- } catch (e) {
45
- // 忽略關閉錯誤
46
- } finally {
47
- this._client = null;
48
- }
49
- }
22
+ return { client, session };
50
23
  }
51
24
 
52
25
  /**
@@ -56,13 +29,13 @@ export class AIAnalyzer {
56
29
  const skillsSummary = getSkillsSummaryForPrompt(PROJECT_SKILLS_CONTEXT);
57
30
  const prompt = this.buildPRPrompt(commits, diff, skillsSummary);
58
31
 
59
- const session = await this._createSession();
32
+ const { client, session } = await this.createClient();
60
33
 
61
34
  try {
62
- // 使用超時保護(2 分鐘:包含子程序啟動時間)
35
+ // 使用超時保護 (60 秒)
63
36
  const responsePromise = session.sendAndWait({ prompt });
64
37
  const timeoutPromise = new Promise((_, reject) => {
65
- setTimeout(() => reject(new Error(`AI 請求超時 (${AI_TIMEOUT_MS / 1000} 秒)`)), AI_TIMEOUT_MS);
38
+ setTimeout(() => reject(new Error('AI 請求超時 (60 秒)')), 60000);
66
39
  });
67
40
 
68
41
  const response = await Promise.race([responsePromise, timeoutPromise]);
@@ -73,8 +46,14 @@ export class AIAnalyzer {
73
46
  }
74
47
 
75
48
  return this.parsePRContent(prContent);
49
+ } finally {
50
+ // 確保 client 一定會被關閉
51
+ try {
52
+ await client.stop();
53
+ } catch (e) {
54
+ // 忽略關閉錯誤
55
+ }
76
56
  }
77
- // 注意:不在此處 client.stop(),交由 close() 統一清理以便複用
78
57
  }
79
58
 
80
59
  /**
@@ -84,15 +63,15 @@ export class AIAnalyzer {
84
63
  const skillsSummary = getSkillsSummaryForPrompt(PROJECT_SKILLS_CONTEXT);
85
64
  const prompt = this.buildAnalysisPrompt(changedFiles, diff, commits, skillsSummary);
86
65
 
87
- const session = await this._createSession();
66
+ const { client, session } = await this.createClient();
88
67
 
89
68
  try {
90
69
  log.info(' 正在使用 AI 深度分析程式碼變更...');
91
-
92
- // 使用超時保護(2 分鐘:包含子程序啟動時間)
70
+
71
+ // 使用超時保護 (60 秒)
93
72
  const responsePromise = session.sendAndWait({ prompt });
94
73
  const timeoutPromise = new Promise((_, reject) => {
95
- setTimeout(() => reject(new Error(`AI 請求超時 (${AI_TIMEOUT_MS / 1000} 秒)`)), AI_TIMEOUT_MS);
74
+ setTimeout(() => reject(new Error('AI 請求超時 (60 秒)')), 60000);
96
75
  });
97
76
 
98
77
  const response = await Promise.race([responsePromise, timeoutPromise]);
@@ -124,8 +103,14 @@ export class AIAnalyzer {
124
103
  } catch (error) {
125
104
  log.warning(` AI 分析失敗 (${error.message}),使用基礎分析...\n`);
126
105
  return this.getFallbackAnalysis(changedFiles);
106
+ } finally {
107
+ // 確保 client 一定會被關閉
108
+ try {
109
+ await client.stop();
110
+ } catch (e) {
111
+ // 忽略關閉錯誤
112
+ }
127
113
  }
128
- // 注意:不在此處 client.stop(),交由 close() 統一清理以便複用
129
114
  }
130
115
 
131
116
  /**
@@ -32,75 +32,70 @@ export class PRWorkflow {
32
32
  * 執行完整工作流程
33
33
  */
34
34
  async execute() {
35
- try {
36
- // 0. 確認 gh CLI 已登入(預覽模式可跳過)
37
- if (!this.config.preview) {
38
- const auth = this.github.checkAuth();
39
- if (!auth.authenticated) {
40
- log.error('GitHub CLI 未登入,請先執行: gh auth login');
41
- throw new Error('GitHub CLI 未登入');
42
- }
35
+ // 0. 確認 gh CLI 已登入(預覽模式可跳過)
36
+ if (!this.config.preview) {
37
+ const auth = this.github.checkAuth();
38
+ if (!auth.authenticated) {
39
+ log.error('GitHub CLI 未登入,請先執行: gh auth login');
40
+ throw new Error('GitHub CLI 未登入');
43
41
  }
42
+ }
44
43
 
45
- // 1. 驗證環境和分支
46
- const { baseBranch, headBranch } = await this.detectAndValidateBranches();
44
+ // 1. 驗證環境和分支
45
+ const { baseBranch, headBranch } = await this.detectAndValidateBranches();
47
46
 
48
- // 2. 檢查是否有變更
49
- await this.validateChanges(baseBranch, headBranch);
47
+ // 2. 檢查是否有變更
48
+ await this.validateChanges(baseBranch, headBranch);
50
49
 
51
- // 3. 推送到遠端(預覽模式跳過)
52
- if (!this.config.preview) {
53
- await this.pushToRemote(headBranch);
54
- }
50
+ // 3. 推送到遠端(預覽模式跳過)
51
+ if (!this.config.preview) {
52
+ await this.pushToRemote(headBranch);
53
+ }
55
54
 
56
- // 4. 收集變更資訊
57
- const changeData = this.collectChangeData(baseBranch, headBranch);
55
+ // 4. 收集變更資訊
56
+ const changeData = this.collectChangeData(baseBranch, headBranch);
58
57
 
59
- // 5. AI 分析和生成 PR 內容
60
- const prContent = await this.generatePRContent(changeData);
58
+ // 5. AI 分析和生成 PR 內容
59
+ const prContent = await this.generatePRContent(changeData);
61
60
 
62
- // 6. 顯示預覽
63
- this.displayPreview(prContent, changeData.stats);
61
+ // 6. 顯示預覽
62
+ this.displayPreview(prContent, changeData.stats);
64
63
 
65
- // 預覽模式:僅顯示不創建
66
- if (this.config.preview) {
67
- log.info('預覽模式:未創建 PR');
68
- return;
69
- }
64
+ // 預覽模式:僅顯示不創建
65
+ if (this.config.preview) {
66
+ log.info('預覽模式:未創建 PR');
67
+ return;
68
+ }
70
69
 
71
- // 7. 選擇 Reviewers
72
- const reviewers = await this.selectReviewers(changeData);
70
+ // 7. 選擇 Reviewers
71
+ const reviewers = await this.selectReviewers(changeData);
73
72
 
74
- // 8. 確認創建
75
- if (!this.config.noConfirm) {
76
- const confirmed = await this.askConfirmation('是否創建此 Pull Request?');
77
- if (!confirmed) {
78
- log.info('已取消創建 PR');
79
- return;
80
- }
73
+ // 8. 確認創建
74
+ if (!this.config.noConfirm) {
75
+ const confirmed = await this.askConfirmation('是否創建此 Pull Request?');
76
+ if (!confirmed) {
77
+ log.info('已取消創建 PR');
78
+ return;
81
79
  }
80
+ }
82
81
 
83
- // 9. 創建 PR
84
- const prUrl = await this.createPR(prContent, baseBranch, headBranch, reviewers);
82
+ // 9. 創建 PR
83
+ const prUrl = await this.createPR(prContent, baseBranch, headBranch, reviewers);
85
84
 
86
- // 10. 添加 Labels(如果啟用)
87
- if (this.config.github.autoLabels === true && prUrl) {
88
- try {
89
- const prNumber = prUrl.split('/').pop();
90
- await this.addLabels(prNumber, {
91
- ...prContent,
92
- stats: changeData.stats,
93
- });
94
- } catch (error) {
95
- log.warning('無法自動添加 Labels: ' + error.message);
96
- }
85
+ // 10. 添加 Labels(如果啟用)
86
+ if (this.config.github.autoLabels === true && prUrl) {
87
+ try {
88
+ const prNumber = prUrl.split('/').pop();
89
+ await this.addLabels(prNumber, {
90
+ ...prContent,
91
+ stats: changeData.stats,
92
+ });
93
+ } catch (error) {
94
+ log.warning('無法自動添加 Labels: ' + error.message);
97
95
  }
98
-
99
- this.logger.success('完成!');
100
- } finally {
101
- // 確保 AI 子程序一定被釋放
102
- await this.ai.close();
103
96
  }
97
+
98
+ this.logger.success('完成!');
104
99
  }
105
100
 
106
101
  /**
@@ -1,176 +0,0 @@
1
- /**
2
- * dev-from-issue 命令
3
- * 讀取 GitHub Issue → 生成實現計畫 → 生成代碼
4
- */
5
-
6
- import { resolve, join } from 'path';
7
- import { writeFileSync } from 'fs';
8
- import inquirer from 'inquirer';
9
- import { IssueReader } from '../core/issue-reader.js';
10
- import { CodeGenerator } from '../core/code-generator.js';
11
- import { AIClient } from '../core/ai-client.js';
12
- import { Logger } from '../utils/logger.js';
13
-
14
- const STATE_FILE = '.ai-git-dev-state.json';
15
-
16
- export function saveDevState(issueNumber, filePath) {
17
- const statePath = join(process.cwd(), STATE_FILE);
18
- writeFileSync(statePath, JSON.stringify({ issueNumber: String(issueNumber), filePath }, null, 2), 'utf-8');
19
- }
20
-
21
- export async function devFromIssueCommand(options = {}) {
22
- const logger = new Logger();
23
-
24
- const issueNumber = options.issue;
25
-
26
- if (!issueNumber) {
27
- logger.error('請提供 Issue 編號(--issue <number>)');
28
- throw new Error('缺少 Issue 編號');
29
- }
30
-
31
- logger.header(`dev-from-issue`);
32
-
33
- // ============================================================
34
- // 第一步:讀取 Issue
35
- // ============================================================
36
- logger.step(`正在讀取 Issue #${issueNumber}...`);
37
- const issue = await IssueReader.readIssue(issueNumber);
38
- logger.section(`Issue #${issue.number}:${issue.title}`);
39
- console.log(`作者:${issue.author} | URL:${issue.url}`);
40
- if (issue.labels.length) {
41
- console.log(`標籤:${issue.labels.join(', ')}`);
42
- }
43
- console.log('');
44
-
45
- // ============================================================
46
- // 第二步:生成實現計畫
47
- // ============================================================
48
- logger.step('正在分析 Issue 並生成實現計畫...');
49
- const plan = await AIClient.sendAndWait(buildPlanPrompt(issue), options.model);
50
- logger.section('AI 實現計畫');
51
- console.log(plan);
52
- console.log('');
53
-
54
- // ============================================================
55
- // 第三步:決定目標檔案路徑
56
- // ============================================================
57
- let filePath = options.file;
58
-
59
- if (!filePath) {
60
- filePath = await inferTargetFilePath(issue, plan, options.model);
61
- if (filePath) {
62
- logger.info(`自動推斷目標檔案:${filePath}`);
63
- }
64
- }
65
-
66
- if (!filePath) {
67
- const { file } = await inquirer.prompt([{
68
- type: 'input',
69
- name: 'file',
70
- message: '自動推斷失敗,請輸入要生成的檔案路徑(例如:src/components/MyComponent.tsx):',
71
- validate: (input) => input.trim().length > 0 ? true : '路徑不能為空',
72
- }]);
73
- filePath = file;
74
- }
75
-
76
- const absoluteFilePath = resolve(process.cwd(), filePath);
77
-
78
- // ============================================================
79
- // 第五步:生成代碼
80
- // ============================================================
81
- logger.step(`正在生成代碼:${filePath}`);
82
- let result;
83
- try {
84
- result = await CodeGenerator.generateFile(issue, absoluteFilePath, {
85
- maxLines: parseInt(options.maxLines || '500', 10),
86
- language: /\.(ts|tsx)$/.test(filePath) ? 'ts' : 'js',
87
- extraContext: options.context || '',
88
- model: options.model,
89
- });
90
- } catch (error) {
91
- logger.error(`代碼生成失敗:${error.message}`);
92
- throw error;
93
- }
94
-
95
- saveDevState(issueNumber, filePath);
96
-
97
- logger.success(`代碼已寫入:${result.filePath}(${result.linesCount} 行)`);
98
- console.log('');
99
- logger.section('✅ 開發完成');
100
- console.log(`Issue:#${issue.number} ${issue.title}`);
101
- console.log(`檔案 :${filePath}`);
102
- }
103
-
104
- // ============================================================
105
- // 工具函數
106
- // ============================================================
107
-
108
- function buildPlanPrompt(issue) {
109
- return `你是一位軟體架構師。請閱讀以下 GitHub Issue 並建立詳細的實現計畫。
110
-
111
- ## Issue #${issue.number}
112
- 標題:${issue.title}
113
-
114
- 描述:
115
- ${issue.body || '(無描述)'}
116
-
117
- ${issue.labels.length ? `標籤:${issue.labels.join(', ')}` : ''}
118
-
119
- ## 輸出要求
120
- 請輸出包含以下內容的實現計畫:
121
-
122
- 1. **摘要** - 一句話說明要做什麼
123
- 2. **需要新增/修改的檔案** - 列出所有相關檔案路徑和各自的職責
124
- 3. **實現步驟** - 有序的任務清單,說明每個步驟的目的
125
- 4. **注意事項** - 潛在風險或需要留意的事項(若有)
126
-
127
- 請使用繁體中文,格式清晰,簡潔有力。`.trim();
128
- }
129
-
130
- async function inferTargetFilePath(issue, plan, model) {
131
- const planCandidates = extractFileCandidates(plan);
132
-
133
- if (planCandidates.length === 1) {
134
- return planCandidates[0];
135
- }
136
-
137
- if (planCandidates.length > 1) {
138
- return planCandidates.find((c) => c.startsWith('src/')) || planCandidates[0];
139
- }
140
-
141
- const prompt = `請根據以下 GitHub Issue 與實作計畫,推斷「最主要的實作檔案路徑」。
142
-
143
- ## 規則
144
- - 只回傳單一檔案路徑
145
- - 優先回傳 src/ 底下的實作檔,不要回傳測試檔、文件檔、設定檔
146
- - 副檔名限制為 .js、.ts、.jsx、.tsx、.mjs、.cjs
147
- - 不要包含程式碼區塊、不要加說明
148
-
149
- ## Issue #${issue.number}
150
- 標題:${issue.title}
151
- 描述:
152
- ${issue.body || '(無描述)'}
153
-
154
- ## 實作計畫
155
- ${plan}`.trim();
156
-
157
- const inferred = (await AIClient.sendAndWait(prompt, model)).trim().split(/\s+/)[0];
158
- return isValidTargetPath(inferred) ? inferred : null;
159
- }
160
-
161
- function extractFileCandidates(plan) {
162
- const matches = plan.match(/([A-Za-z0-9_./-]+\.(?:js|ts|jsx|tsx|mjs|cjs))/g) || [];
163
- return [...new Set(matches.filter(isValidTargetPath))].filter((candidate) => {
164
- const normalized = candidate.toLowerCase();
165
- return !normalized.includes('__tests__/')
166
- && !normalized.includes('.test.')
167
- && !normalized.includes('.spec.')
168
- && !normalized.endsWith('.config.js');
169
- });
170
- }
171
-
172
- function isValidTargetPath(value) {
173
- return typeof value === 'string'
174
- && /^[A-Za-z0-9_./-]+\.(js|ts|jsx|tsx|mjs|cjs)$/.test(value)
175
- && !value.startsWith('/');
176
- }
@@ -1,128 +0,0 @@
1
- /**
2
- * CodeGenerator 服務
3
- * 使用 AIClient 生成符合專案風格的代碼,並執行 ESLint 驗證後寫入檔案
4
- */
5
-
6
- import { writeFileSync, existsSync, mkdirSync } from 'fs';
7
- import { dirname } from 'path';
8
- import { execSync } from 'child_process';
9
- import { AIClient } from './ai-client.js';
10
- import { RepoContext } from './repo-context.js';
11
-
12
- export class CodeGenerator {
13
- /**
14
- * 生成指定檔案的代碼
15
- * @param {object} issueContext - Issue 資料 { number, title, body, labels }
16
- * @param {string} filePath - 要生成的目標檔案路徑(絕對路徑)
17
- * @param {object} constraints - 限制條件
18
- * @param {number} constraints.maxLines - 最大行數(預設 500)
19
- * @param {string} constraints.language - 語言(預設 'js')
20
- * @param {string} constraints.extraContext - 額外的使用者描述
21
- * @param {string} constraints.model - AI 模型(可選)
22
- * @returns {Promise<{ filePath, linesCount }>}
23
- */
24
- static async generateFile(issueContext, filePath, constraints = {}) {
25
- const { maxLines = 500, language = 'js', extraContext = '', model } = constraints;
26
-
27
- // 防止覆蓋現有檔案
28
- if (existsSync(filePath)) {
29
- throw new Error(`檔案已存在,拒絕覆蓋:${filePath}\n請選擇一個新的目標路徑。`);
30
- }
31
-
32
- // 取得專案上下文
33
- const conventions = RepoContext.getProjectConventions();
34
- const examples = RepoContext.getSimilarFileExamples(filePath, 2);
35
-
36
- // 建立 prompt
37
- const prompt = CodeGenerator._buildPrompt({
38
- issueContext,
39
- filePath,
40
- maxLines,
41
- language,
42
- extraContext,
43
- conventions,
44
- examples,
45
- });
46
-
47
- // 呼叫 AI
48
- const rawCode = await AIClient.sendAndWait(prompt, model);
49
- const code = CodeGenerator._cleanCode(rawCode);
50
-
51
- // 行數驗證
52
- const lines = code.split('\n').length;
53
- if (lines > maxLines) {
54
- throw new Error(`生成的代碼超過行數限制(${lines} 行 > ${maxLines} 行)`);
55
- }
56
-
57
- // 確保目錄存在
58
- mkdirSync(dirname(filePath), { recursive: true });
59
-
60
- // 寫入暫時檔案並 lint
61
- writeFileSync(filePath, code, 'utf-8');
62
- CodeGenerator._lintFile(filePath);
63
-
64
- return { filePath, linesCount: lines };
65
- }
66
-
67
- /**
68
- * 建立代碼生成 prompt
69
- */
70
- static _buildPrompt({ issueContext, filePath, maxLines, language, extraContext, conventions, examples }) {
71
- return `你是一位 ${language.toUpperCase()} 專家。請依照以下需求生成代碼。
72
-
73
- ## GitHub Issue
74
- 標題:${issueContext.title}
75
- 描述:
76
- ${issueContext.body || '(無描述)'}
77
- ${issueContext.labels?.length ? `標籤:${issueContext.labels.join(', ')}` : ''}
78
-
79
- ## 目標檔案
80
- ${filePath}
81
-
82
- ## 專案約定
83
- ${conventions}
84
-
85
- ## 代碼範例(相似檔案)
86
- ${examples}
87
-
88
- ## 使用者補充說明
89
- ${extraContext || '(無)'}
90
-
91
- ## 生成規則
92
- - 最多 ${maxLines} 行
93
- - 使用 ESM (import/export) 語法
94
- - 包含必要的 JSDoc 註解
95
- - 僅輸出程式碼本身,不要 markdown 區塊(\`\`\`)
96
- - 不要輸出任何說明文字,直接輸出可執行的程式碼`.trim();
97
- }
98
-
99
- /**
100
- * 移除 AI 回傳中的 markdown code fence
101
- */
102
- static _cleanCode(raw) {
103
- return raw
104
- .replace(/^```[\w]*\n?/gm, '')
105
- .replace(/\n?```$/gm, '')
106
- .trim();
107
- }
108
-
109
- /**
110
- * 對指定檔案執行 ESLint(若存在 eslint 設定)
111
- * 不強制中止——只在 lint 有 error 時拋出例外
112
- */
113
- static _lintFile(filePath) {
114
- try {
115
- execSync(`npx eslint --no-eslintrc --rule '{"no-undef": "warn"}' "${filePath}"`, {
116
- encoding: 'utf-8',
117
- stdio: 'pipe',
118
- });
119
- } catch (error) {
120
- const output = error.stdout || error.stderr || '';
121
- // 若 eslint 回傳 exit code 1 表示有 error
122
- if (error.status === 1 && output.includes('error')) {
123
- throw new Error(`ESLint 驗證失敗:\n${output}`);
124
- }
125
- // exit code 2 為 eslint 設定問題,忽略
126
- }
127
- }
128
- }
@@ -1,58 +0,0 @@
1
- /**
2
- * IssueReader 服務
3
- * 透過 gh CLI 從 GitHub 讀取 Issue 詳情
4
- */
5
-
6
- import { execSync } from 'child_process';
7
-
8
- export class IssueReader {
9
- /**
10
- * 讀取指定 Issue 的詳情
11
- * @param {number|string} issueNumber - Issue 編號
12
- * @returns {{ number, title, body, labels, author, url }}
13
- */
14
- static async readIssue(issueNumber) {
15
- if (!issueNumber || isNaN(Number(issueNumber))) {
16
- throw new Error(`無效的 Issue 編號:${issueNumber}`);
17
- }
18
-
19
- const json = IssueReader._fetchIssueJson(issueNumber);
20
- return IssueReader._parseIssue(json);
21
- }
22
-
23
- /**
24
- * 使用 gh CLI 抓取 Issue 的 JSON 資料
25
- */
26
- static _fetchIssueJson(issueNumber) {
27
- try {
28
- const raw = execSync(
29
- `gh issue view ${issueNumber} --json number,title,body,labels,author,url`,
30
- { encoding: 'utf-8', stdio: 'pipe' }
31
- );
32
- return JSON.parse(raw.trim());
33
- } catch (error) {
34
- if (error.message.includes('Could not resolve') || error.message.includes('not found')) {
35
- throw new Error(`Issue #${issueNumber} 不存在或無法存取`);
36
- }
37
- if (error.message.includes('gh: command not found') || error.message.includes('not found: gh')) {
38
- throw new Error('找不到 gh CLI,請先安裝:https://cli.github.com');
39
- }
40
- throw new Error(`讀取 Issue 失敗:${error.message}`);
41
- }
42
- }
43
-
44
- /**
45
- * 解析 gh CLI 回傳的 JSON 為標準結構
46
- */
47
- static _parseIssue(json) {
48
- const labels = (json.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
49
- return {
50
- number: json.number,
51
- title: json.title || '',
52
- body: json.body || '',
53
- labels,
54
- author: json.author?.login || json.author || '',
55
- url: json.url || '',
56
- };
57
- }
58
- }
@@ -1,114 +0,0 @@
1
- /**
2
- * RepoContext 服務
3
- * 掃描專案目錄結構,提供代碼生成所需的上下文
4
- */
5
-
6
- import { readdirSync, readFileSync, statSync, existsSync } from 'fs';
7
- import { join, extname, relative, dirname } from 'path';
8
- import { fileURLToPath } from 'url';
9
-
10
- const __filename = fileURLToPath(import.meta.url);
11
- const __dirname = dirname(__filename);
12
- const ROOT = join(__dirname, '..', '..');
13
-
14
- // 要排除的目錄
15
- const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.nyc_output']);
16
- // 支援的程式碼副檔名
17
- const CODE_EXTS = new Set(['.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx']);
18
-
19
- export class RepoContext {
20
- /**
21
- * 依據目標檔案路徑尋找相似的現有檔案片段
22
- * @param {string} targetPath - 要生成的目標檔案路徑(相對或絕對)
23
- * @param {number} maxExamples - 最多回傳幾個範例,預設 3
24
- * @returns {string} 格式化的範例文字
25
- */
26
- static getSimilarFileExamples(targetPath, maxExamples = 3) {
27
- const ext = extname(targetPath);
28
- const allFiles = RepoContext._scanFiles(ROOT);
29
- const codeFiles = allFiles.filter(
30
- (f) => extname(f) === ext && !f.includes('.test.') && !f.includes('.spec.')
31
- );
32
-
33
- // 按路徑相似度排序,取前 N 個
34
- const sorted = codeFiles
35
- .filter((f) => f !== targetPath)
36
- .slice(0, maxExamples);
37
-
38
- if (sorted.length === 0) return '(無相似範例)';
39
-
40
- return sorted
41
- .map((f) => {
42
- try {
43
- const content = readFileSync(f, 'utf-8');
44
- const lines = content.split('\n').slice(0, 40).join('\n');
45
- const relPath = relative(ROOT, f);
46
- return `// 範例檔案:${relPath}\n${lines}`;
47
- } catch {
48
- return null;
49
- }
50
- })
51
- .filter(Boolean)
52
- .join('\n\n---\n\n');
53
- }
54
-
55
- /**
56
- * 取得專案主要約定(依據 package.json 及目錄結構判斷)
57
- * @returns {string}
58
- */
59
- static getProjectConventions() {
60
- const hints = [];
61
-
62
- // 讀取 package.json
63
- const pkgPath = join(ROOT, 'package.json');
64
- if (existsSync(pkgPath)) {
65
- try {
66
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
67
- hints.push(`模組系統:${pkg.type === 'module' ? 'ESM (import/export)' : 'CommonJS (require)'}`);
68
- if (pkg.dependencies?.typescript || pkg.devDependencies?.typescript) {
69
- hints.push('語言:TypeScript');
70
- } else {
71
- hints.push('語言:JavaScript');
72
- }
73
- const testDep = pkg.devDependencies?.jest || pkg.dependencies?.jest;
74
- if (testDep) hints.push('測試框架:Jest');
75
- } catch {
76
- // 忽略解析錯誤
77
- }
78
- }
79
-
80
- // 目錄結構提示
81
- const srcExists = existsSync(join(ROOT, 'src'));
82
- if (srcExists) hints.push('原始碼位於 src/ 目錄');
83
-
84
- return hints.length > 0 ? hints.join('\n') : '(無法自動偵測專案約定)';
85
- }
86
-
87
- /**
88
- * 遞迴掃描目錄,回傳所有程式碼檔案的絕對路徑
89
- */
90
- static _scanFiles(dir) {
91
- const results = [];
92
- let entries;
93
- try {
94
- entries = readdirSync(dir);
95
- } catch {
96
- return results;
97
- }
98
- for (const name of entries) {
99
- if (EXCLUDED_DIRS.has(name)) continue;
100
- const full = join(dir, name);
101
- try {
102
- const stat = statSync(full);
103
- if (stat.isDirectory()) {
104
- results.push(...RepoContext._scanFiles(full));
105
- } else if (CODE_EXTS.has(extname(name))) {
106
- results.push(full);
107
- }
108
- } catch {
109
- // 忽略無法存取的路徑
110
- }
111
- }
112
- return results;
113
- }
114
- }