ai-git-tools 2.0.69 → 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
@@ -15,7 +15,6 @@ 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 { devFromIssueCommand } from '../src/commands/dev-from-issue.js';
19
18
 
20
19
  // 讀取 package.json 獲取版本號
21
20
  const __filename = fileURLToPath(import.meta.url);
@@ -88,7 +87,6 @@ program
88
87
  .option('--no-confirm', '跳過確認直接創建')
89
88
  .option('--auto-labels', '自動添加 Labels (預設啟用)')
90
89
  .option('--include-impact', '在 PR 中包含影響範圍分析和注意事項 (預設關閉)')
91
- .option('--auto-review', 'PR 建立後發佈 AI 審查 comment')
92
90
  .option('--force-new', '強制創建新 PR,不更新現有 PR')
93
91
  .action(async (options) => {
94
92
  try {
@@ -99,24 +97,4 @@ program
99
97
  }
100
98
  });
101
99
 
102
- // Plan Issue 命令(已废棄,功能已併入 dev-from-issue)
103
- // Dev From Issue 命令(plan-issue + generate-code 合體)
104
- program
105
- .command('dev-from-issue')
106
- .description('AI 讀取 Issue → 生成計畫 → 生成代碼')
107
- .requiredOption('--issue <number>', 'GitHub Issue 編號')
108
- .option('--file <path>', '目標檔案路徑(若無則自動推斷)')
109
- .option('--context <description>', '額外說明或補充需求')
110
- .option('--max-lines <number>', '最大行數限制(預設 500)', '500')
111
- .option('--model <model>', '指定 AI 模型')
112
- .action(async (options) => {
113
- try {
114
- await devFromIssueCommand(options);
115
- process.exit(0);
116
- } catch (error) {
117
- console.error(`\n[錯誤] ${error.message}`);
118
- process.exit(1);
119
- }
120
- });
121
-
122
100
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.0.69",
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
  }
@@ -26,7 +26,6 @@ export default {
26
26
  defaultBase: 'release', // PR 預設目標分支(使用 'release' 自動偵測最新 release 分支,如 release-2025-m11.1)
27
27
  autoLabels: true, // 自動新增 Labels
28
28
  includeImpactAnalysis: false, // 是否在 PR 中包含影響範圍分析和注意事項(使用 --include-impact 啟用)
29
- autoReview: true, // PR 建立後自動發佈 AI 審查 comment
30
29
  },
31
30
 
32
31
  // Reviewers 相關配置
@@ -15,7 +15,6 @@ export function parseCliArgs() {
15
15
  interactiveReviewers: undefined,
16
16
  autoLabels: null,
17
17
  includeImpactAnalysis: null,
18
- autoReview: null,
19
18
  };
20
19
 
21
20
  for (let i = 0; i < args.length; i++) {
@@ -44,9 +43,6 @@ export function parseCliArgs() {
44
43
  case '--include-impact':
45
44
  config.includeImpactAnalysis = true;
46
45
  break;
47
- case '--auto-review':
48
- config.autoReview = true;
49
- break;
50
46
  case '--help':
51
47
  showHelp();
52
48
  process.exit(0);
@@ -75,7 +71,7 @@ export async function loadConfig() {
75
71
  // 使用內建預設值
76
72
  config = {
77
73
  ai: { model: 'gpt-4.1', maxDiffLength: 8000, maxRetries: 3 },
78
- github: { defaultBase: 'release', autoLabels: true, includeImpactAnalysis: false, autoReview: false },
74
+ github: { defaultBase: 'release', autoLabels: true, includeImpactAnalysis: false },
79
75
  reviewers: { interactiveReviewers: true, maxSuggested: 5, gitHistoryDepth: 20, excludeAuthors: [] },
80
76
  output: { verbose: false, saveHistory: false },
81
77
  };
@@ -89,7 +85,6 @@ export async function loadConfig() {
89
85
  if (cliConfig.interactiveReviewers !== undefined) config.reviewers.interactiveReviewers = cliConfig.interactiveReviewers;
90
86
  if (cliConfig.autoLabels !== null) config.github.autoLabels = cliConfig.autoLabels;
91
87
  if (cliConfig.includeImpactAnalysis !== null) config.github.includeImpactAnalysis = cliConfig.includeImpactAnalysis;
92
- if (cliConfig.autoReview !== null) config.github.autoReview = cliConfig.autoReview;
93
88
 
94
89
  // 其他 CLI 參數直接加入 config
95
90
  config.baseBranch = cliConfig.baseBranch;
@@ -115,7 +110,6 @@ function showHelp() {
115
110
  --no-confirm 跳過確認直接創建
116
111
  --interactive-reviewers 啟用互動式 reviewer 選擇 (預設啟用)
117
112
  --auto-labels 自動添加 Labels (預設啟用)
118
- --auto-review PR 建立後發佈 AI 審查 comment
119
113
  --help 顯示此說明
120
114
 
121
115
  範例:
@@ -4,7 +4,6 @@ import { GitOperations } from './git-operations.js';
4
4
  import { GitHubAPI } from './github-api.js';
5
5
  import { AIAnalyzer } from '../ai/code-analyzer.js';
6
6
  import { LabelAnalyzer } from '../ai/label-analyzer.js';
7
- import { PRReviewer } from '../ai/pr-reviewer.js';
8
7
  import { ReviewerSelector } from '../reviewers/reviewer-selector.js';
9
8
  import { Logger } from '../ui/logger.js';
10
9
  import { PRError, log } from '../utils/helpers.js';
@@ -20,7 +19,6 @@ export class PRWorkflow {
20
19
  this.github = new GitHubAPI(); // 自動從 git remote 偵測組織名稱
21
20
  this.ai = new AIAnalyzer({ model: config.ai.model });
22
21
  this.labelAnalyzer = new LabelAnalyzer();
23
- this.reviewer = new PRReviewer({ model: config.ai.model });
24
22
  this.reviewerSelector = new ReviewerSelector({
25
23
  interactiveReviewers: config.reviewers.interactiveReviewers,
26
24
  maxSuggested: config.reviewers.maxSuggested,
@@ -97,16 +95,6 @@ export class PRWorkflow {
97
95
  }
98
96
  }
99
97
 
100
- // 11. AI 自動審查(如果啟用)
101
- if (this.config.github.autoReview === true && prUrl) {
102
- try {
103
- const prNumber = prUrl.split('/').pop();
104
- await this.autoReviewPR(prNumber, changeData);
105
- } catch (error) {
106
- log.warning('AI 審查失敗,跳過: ' + error.message);
107
- }
108
- }
109
-
110
98
  this.logger.success('完成!');
111
99
  }
112
100
 
@@ -438,19 +426,6 @@ export class PRWorkflow {
438
426
  });
439
427
  }
440
428
 
441
- /**
442
- * AI 自動審查 PR
443
- */
444
- async autoReviewPR(prNumber, changeData) {
445
- log.step(`正在使用 AI 審查程式碼 (${this.config.ai.model})...\n`);
446
- const reviewBody = await this.reviewer.generateReview(
447
- changeData.diff,
448
- changeData.commits,
449
- changeData.changedFiles
450
- );
451
- await this.reviewer.postReviewComment(prNumber, reviewBody);
452
- }
453
-
454
429
  /**
455
430
  * 添加 Labels
456
431
  */
@@ -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
- }
@@ -1,156 +0,0 @@
1
- import { CopilotClient, approveAll } from '@github/copilot-sdk';
2
- import { execSync } from 'child_process';
3
- import { writeFileSync, unlinkSync, existsSync } from 'fs';
4
- import { CONSTANTS, PROJECT_SKILLS_CONTEXT } from '../utils/constants.js';
5
- import { getSkillsSummaryForPrompt, log } from '../utils/helpers.js';
6
-
7
- /**
8
- * PR 審查員 - 負責生成 AI code review 並發布為 PR comment
9
- */
10
- export class PRReviewer {
11
- constructor(config = {}) {
12
- this.model = config.model || 'gpt-4.1';
13
- }
14
-
15
- /**
16
- * 建立 AI 客戶端
17
- */
18
- async createClient() {
19
- const client = new CopilotClient();
20
- const session = await client.createSession({
21
- model: this.model,
22
- onPermissionRequest: approveAll,
23
- });
24
- return { client, session };
25
- }
26
-
27
- /**
28
- * 生成 PR 審查報告
29
- */
30
- async generateReview(diff, commits, changedFiles) {
31
- const skillsSummary = getSkillsSummaryForPrompt(PROJECT_SKILLS_CONTEXT);
32
- const prompt = this.buildReviewPrompt(diff, commits, changedFiles, skillsSummary);
33
-
34
- const { client, session } = await this.createClient();
35
-
36
- try {
37
- const responsePromise = session.sendAndWait({ prompt });
38
- const timeoutPromise = new Promise((_, reject) => {
39
- setTimeout(() => reject(new Error('AI 請求超時 (60 秒)')), 60000);
40
- });
41
-
42
- const response = await Promise.race([responsePromise, timeoutPromise]);
43
- const reviewContent = response?.data.content?.trim() || '';
44
-
45
- if (!reviewContent) {
46
- throw new Error('AI 未能生成審查報告');
47
- }
48
-
49
- return this.wrapReviewBody(reviewContent);
50
- } finally {
51
- try {
52
- await client.stop();
53
- } catch (e) {
54
- // 忽略關閉錯誤
55
- }
56
- }
57
- }
58
-
59
- /**
60
- * 將審查報告發布為 PR comment
61
- */
62
- async postReviewComment(prNumber, reviewBody) {
63
- const bodyFile = '/tmp/pr-review-comment.md';
64
- writeFileSync(bodyFile, reviewBody);
65
-
66
- try {
67
- execSync(`gh pr comment ${prNumber} --body-file "${bodyFile}"`, {
68
- stdio: ['pipe', 'pipe', 'pipe'],
69
- });
70
- log.success(` AI 審查報告已發布至 PR #${prNumber}`);
71
- } finally {
72
- try {
73
- if (existsSync(bodyFile)) unlinkSync(bodyFile);
74
- } catch (e) {
75
- // 忽略刪除錯誤
76
- }
77
- }
78
- }
79
-
80
- /**
81
- * 建立 review prompt
82
- */
83
- buildReviewPrompt(diff, commits, changedFiles, skillsSummary) {
84
- const fileList = changedFiles
85
- .slice(0, CONSTANTS.MAX_FILES_IN_PROMPT)
86
- .join('\n');
87
-
88
- const truncatedDiff = diff.substring(0, CONSTANTS.MAX_DIFF_LENGTH);
89
- const diffTruncated = diff.length > CONSTANTS.MAX_DIFF_LENGTH;
90
-
91
- return `你是一位資深工程師,正在進行嚴格但友善的程式碼審查。
92
- 請根據以下程式碼變更,以繁體中文(台灣正體)輸出審查報告。
93
-
94
- ${skillsSummary}
95
-
96
- **變更檔案**:
97
- ${fileList}
98
- ${changedFiles.length > CONSTANTS.MAX_FILES_IN_PROMPT ? `... 還有 ${changedFiles.length - CONSTANTS.MAX_FILES_IN_PROMPT} 個檔案` : ''}
99
-
100
- **Commit 訊息**:
101
- ${commits.split('\n').slice(0, CONSTANTS.MAX_COMMITS_IN_PROMPT).join('\n')}
102
-
103
- **程式碼變更(diff)**:
104
- \`\`\`diff
105
- ${truncatedDiff}
106
- ${diffTruncated ? '\n... (內容過長已截斷)' : ''}
107
- \`\`\`
108
-
109
- ---
110
-
111
- **輸出格式**(直接輸出以下 Markdown,不要加任何引導語):
112
-
113
- ### 🔴 需要處理的問題
114
- [列出明確的 bug、安全漏洞、錯誤處理缺失等必須修正的項目]
115
- [格式:- **[類型]** \`檔案名稱\` — 具體說明問題]
116
- [若無此類問題,填寫「無」]
117
-
118
- ### 🟡 建議改善項目
119
- [列出效能問題、規範違反、可讀性問題等建議改善項目]
120
- [格式:- **[類型]** \`檔案名稱\` — 具體說明問題與建議]
121
- [若無此類問題,填寫「無」]
122
-
123
- ### ✅ 良好實踐
124
- [列出本次變更中值得肯定的設計或實踐]
125
- [若無特別亮點,填寫「程式碼結構清晰,無明顯問題」]
126
-
127
- ---
128
-
129
- **分類說明**:
130
- - 🔴 需要處理:security(安全)、bug(邏輯錯誤)、error-handling(缺少錯誤處理)、breaking(破壞性變更未說明)
131
- - 🟡 建議改善:performance(效能)、naming(命名規範)、structure(檔案/架構問題)、best-practice(最佳實踐)、test(缺少測試)
132
- - ✅ 良好實踐:列出做得好的地方,鼓勵正確行為
133
-
134
- **審查準則**:
135
- 1. 具體指出問題,盡量包含檔案名稱
136
- 2. 每個問題只列一次,不重複
137
- 3. 若程式碼品質良好,不要強制填充問題
138
- 4. 不要在開頭加引導語,直接輸出 ### 開頭的 Markdown
139
- 5. 全部使用繁體中文(台灣正體)`;
140
- }
141
-
142
- /**
143
- * 包裝最終的 review body(加上頭尾)
144
- */
145
- wrapReviewBody(reviewContent) {
146
- const now = new Date().toISOString().slice(0, 10);
147
- return `## 🤖 AI 自動審查報告
148
-
149
- > ⚡ 審查模型:${this.model} | 審查日期:${now}
150
-
151
- ${reviewContent}
152
-
153
- ---
154
- *此 review 由 AI 自動生成,僅供參考,請人工確認後合併*`;
155
- }
156
- }