ai-git-tools 2.0.39 → 2.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 測試結果格式化模組
3
+ * 將 TestResults 轉換為 GitHub Markdown 格式,供發佈到 Issue
4
+ */
5
+
6
+ /** 唯一識別標記,用於在多次執行時更新同一則評論 */
7
+ export const COMMENT_MARKER = '<!-- ai-autodev-test-results -->';
8
+
9
+ export class ResultFormatter {
10
+ /**
11
+ * 將 TestResults 格式化為 Markdown 字串
12
+ * @param {import('./executor-base.js').TestResults} results
13
+ * @param {import('../core/issue-parser.js').IssueData} issueData
14
+ * @param {Object} [meta] - 額外的 meta 資訊
15
+ * @returns {string}
16
+ */
17
+ formatAsMarkdown(results, issueData, meta = {}) {
18
+ const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
19
+ const passRate = results.total > 0
20
+ ? ((results.passed / results.total) * 100).toFixed(1)
21
+ : '0.0';
22
+ const allPassed = results.failed === 0 && results.total > 0;
23
+ const statusEmoji = allPassed ? '✅' : '❌';
24
+ const durationSec = (results.duration / 1000).toFixed(2);
25
+
26
+ const lines = [
27
+ COMMENT_MARKER,
28
+ '',
29
+ `## ${statusEmoji} 自動測試報告`,
30
+ '',
31
+ `> 由 \`ai autodev\` 自動執行 · ${now} UTC`,
32
+ '',
33
+ '### 摘要',
34
+ '',
35
+ '| 項目 | 結果 |',
36
+ '|------|------|',
37
+ `| 框架 | \`${results.framework}\` |`,
38
+ `| 總測試數 | ${results.total} |`,
39
+ `| ✅ 通過 | ${results.passed} |`,
40
+ `| ❌ 失敗 | ${results.failed} |`,
41
+ `| ⏭️ 跳過 | ${results.skipped} |`,
42
+ `| 通過率 | **${passRate}%** |`,
43
+ `| 執行時間 | ${durationSec}s |`,
44
+ '',
45
+ ];
46
+
47
+ // 失敗詳情
48
+ const failedTests = results.tests.filter((t) => !t.passed);
49
+ if (failedTests.length > 0) {
50
+ lines.push('### ❌ 失敗測試');
51
+ lines.push('');
52
+ for (const t of failedTests.slice(0, 20)) {
53
+ lines.push(`#### \`${t.name}\``);
54
+ if (t.suite) lines.push(`- **Suite**: ${t.suite}`);
55
+ if (t.error) {
56
+ lines.push('```');
57
+ lines.push(t.error.slice(0, 500));
58
+ lines.push('```');
59
+ }
60
+ lines.push('');
61
+ }
62
+ if (failedTests.length > 20) {
63
+ lines.push(`> ... 還有 ${failedTests.length - 20} 個失敗測試未顯示`);
64
+ lines.push('');
65
+ }
66
+ }
67
+
68
+ // 後續步驟
69
+ lines.push('### 下一步');
70
+ lines.push('');
71
+ if (allPassed) {
72
+ lines.push('- 🎉 所有測試通過!');
73
+ if (meta.commitHash) {
74
+ lines.push(`- 📦 已自動提交:\`${meta.commitHash}\``);
75
+ }
76
+ if (meta.prUrl) {
77
+ lines.push(`- 🔗 已自動建立 PR:${meta.prUrl}`);
78
+ }
79
+ } else {
80
+ lines.push('- 請修復上方列出的失敗測試');
81
+ lines.push('- 修復後重新執行 `ai autodev <issue>`');
82
+ if (results.failed > 0) {
83
+ lines.push(`- 或執行 \`npx ${results.framework} --watch\` 進行偵錯`);
84
+ }
85
+ }
86
+
87
+ lines.push('');
88
+ return lines.join('\n');
89
+ }
90
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * 測試框架檢測模組
3
+ * 自動偵測專案使用的測試框架(Jest / Vitest / Mocha)
4
+ * 並發現所有相關測試檔案
5
+ */
6
+
7
+ import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
8
+ import { resolve, join } from 'path';
9
+
10
+ /** 支援的框架識別字 */
11
+ export const FRAMEWORKS = {
12
+ JEST: 'jest',
13
+ VITEST: 'vitest',
14
+ MOCHA: 'mocha',
15
+ };
16
+
17
+ export class TestDetector {
18
+ /**
19
+ * @param {string} projectRoot - 專案根目錄(預設 process.cwd())
20
+ */
21
+ constructor(projectRoot = process.cwd()) {
22
+ this.projectRoot = projectRoot;
23
+ }
24
+
25
+ /**
26
+ * 偵測測試框架
27
+ * 優先順序:設定檔 > package.json scripts > package.json devDependencies
28
+ * @returns {string|null} 框架名稱或 null
29
+ */
30
+ detectFramework() {
31
+ const root = this.projectRoot;
32
+
33
+ // 1. 專用設定檔
34
+ if (
35
+ existsSync(join(root, 'vitest.config.ts')) ||
36
+ existsSync(join(root, 'vitest.config.js')) ||
37
+ existsSync(join(root, 'vitest.config.mjs'))
38
+ ) {
39
+ return FRAMEWORKS.VITEST;
40
+ }
41
+
42
+ if (
43
+ existsSync(join(root, 'jest.config.js')) ||
44
+ existsSync(join(root, 'jest.config.ts')) ||
45
+ existsSync(join(root, 'jest.config.mjs')) ||
46
+ existsSync(join(root, 'jest.config.json'))
47
+ ) {
48
+ return FRAMEWORKS.JEST;
49
+ }
50
+
51
+ if (
52
+ existsSync(join(root, '.mocharc.js')) ||
53
+ existsSync(join(root, '.mocharc.cjs')) ||
54
+ existsSync(join(root, '.mocharc.json')) ||
55
+ existsSync(join(root, '.mocharc.yml')) ||
56
+ existsSync(join(root, '.mocharc.yaml'))
57
+ ) {
58
+ return FRAMEWORKS.MOCHA;
59
+ }
60
+
61
+ // 2. package.json
62
+ const pkgPath = join(root, 'package.json');
63
+ if (existsSync(pkgPath)) {
64
+ try {
65
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
66
+
67
+ // test script
68
+ const testScript = pkg.scripts?.test || '';
69
+ if (/\bvitest\b/.test(testScript)) return FRAMEWORKS.VITEST;
70
+ if (/\bjest\b/.test(testScript)) return FRAMEWORKS.JEST;
71
+ if (/\bmocha\b/.test(testScript)) return FRAMEWORKS.MOCHA;
72
+
73
+ // devDependencies / dependencies
74
+ const allDeps = {
75
+ ...pkg.devDependencies,
76
+ ...pkg.dependencies,
77
+ };
78
+ if (allDeps.vitest) return FRAMEWORKS.VITEST;
79
+ if (allDeps.jest || allDeps['@jest/core']) return FRAMEWORKS.JEST;
80
+ if (allDeps.mocha) return FRAMEWORKS.MOCHA;
81
+ } catch (_) {
82
+ // 解析失敗,繼續往下
83
+ }
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * 遞迴搜尋符合 patterns 的檔案
91
+ * @param {string} dir
92
+ * @param {RegExp[]} patterns
93
+ * @param {string[]} excludes - 排除目錄名稱
94
+ * @returns {string[]}
95
+ */
96
+ _glob(dir, patterns, excludes = ['node_modules', 'dist', '.next', 'coverage', '.git']) {
97
+ const results = [];
98
+
99
+ let entries;
100
+ try {
101
+ entries = readdirSync(dir);
102
+ } catch (_) {
103
+ return results;
104
+ }
105
+
106
+ for (const entry of entries) {
107
+ if (excludes.includes(entry)) continue;
108
+ const fullPath = join(dir, entry);
109
+ let stat;
110
+ try {
111
+ stat = statSync(fullPath);
112
+ } catch (_) {
113
+ continue;
114
+ }
115
+
116
+ if (stat.isDirectory()) {
117
+ results.push(...this._glob(fullPath, patterns, excludes));
118
+ } else if (patterns.some((p) => p.test(entry))) {
119
+ results.push(fullPath);
120
+ }
121
+ }
122
+
123
+ return results;
124
+ }
125
+
126
+ /**
127
+ * 依框架發現測試檔案
128
+ * @param {string} framework
129
+ * @param {Object} [config] - 可選:{ testPaths?: string[] }
130
+ * @returns {string[]}
131
+ */
132
+ discoverTests(framework, config = {}) {
133
+ // 若使用者明確指定路徑,直接回傳(轉為絕對路徑)
134
+ if (config.testPaths && config.testPaths.length > 0) {
135
+ return config.testPaths.map((p) => resolve(this.projectRoot, p));
136
+ }
137
+
138
+ const commonPatterns = [
139
+ /\.test\.(js|ts|jsx|tsx|mjs|cjs)$/,
140
+ /\.spec\.(js|ts|jsx|tsx|mjs|cjs)$/,
141
+ ];
142
+
143
+ switch (framework) {
144
+ case FRAMEWORKS.JEST:
145
+ case FRAMEWORKS.VITEST:
146
+ return this._glob(this.projectRoot, commonPatterns);
147
+
148
+ case FRAMEWORKS.MOCHA: {
149
+ // Mocha 慣例:test/ 或 tests/ 目錄
150
+ const mochaPatterns = [/\.(js|ts|mjs|cjs)$/];
151
+ const dirs = ['test', 'tests', 'spec', 'specs'].map((d) =>
152
+ join(this.projectRoot, d)
153
+ );
154
+ const results = [];
155
+ for (const d of dirs) {
156
+ if (existsSync(d)) {
157
+ results.push(...this._glob(d, mochaPatterns));
158
+ }
159
+ }
160
+ // 也搜尋根目錄的 .test. / .spec. 檔案
161
+ results.push(...this._glob(this.projectRoot, commonPatterns));
162
+ return [...new Set(results)];
163
+ }
164
+
165
+ default:
166
+ // 未知框架,使用通用 patterns
167
+ return this._glob(this.projectRoot, commonPatterns);
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Vitest 測試執行器
3
+ * 使用 --reporter=json 取得結構化輸出
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import { ExecutorBase } from './executor-base.js';
8
+
9
+ export class VitestExecutor extends ExecutorBase {
10
+ constructor(options = {}) {
11
+ super(options);
12
+ this.framework = 'vitest';
13
+ }
14
+
15
+ /**
16
+ * 執行 Vitest,回傳標準化 TestResults
17
+ * @param {string[]} testPaths
18
+ * @param {Object} options
19
+ * @returns {Promise<import('./executor-base.js').TestResults>}
20
+ */
21
+ async run(testPaths = [], options = {}) {
22
+ const pathArgs = testPaths.length > 0 ? testPaths.join(' ') : '';
23
+ const cmd = `npx vitest run --reporter=json ${pathArgs}`;
24
+
25
+ if (options.verbose) {
26
+ console.log(` 執行命令:${cmd}`);
27
+ }
28
+
29
+ let output = '';
30
+ try {
31
+ output = execSync(cmd, {
32
+ encoding: 'utf-8',
33
+ timeout: this.timeout,
34
+ stdio: ['pipe', 'pipe', 'pipe'],
35
+ cwd: options.projectRoot || process.cwd(),
36
+ });
37
+ } catch (error) {
38
+ output = error.stdout || error.output?.[1] || '';
39
+ }
40
+
41
+ return this.parseResults(output);
42
+ }
43
+
44
+ /**
45
+ * 將 Vitest --reporter=json 輸出解析為標準 TestResults
46
+ * @param {string} output
47
+ * @returns {import('./executor-base.js').TestResults}
48
+ */
49
+ parseResults(output) {
50
+ const result = this.emptyResults(this.framework);
51
+ result.rawOutput = output;
52
+
53
+ let json;
54
+ try {
55
+ const start = output.indexOf('{');
56
+ if (start === -1) return result;
57
+ json = JSON.parse(output.slice(start));
58
+ } catch (_) {
59
+ return result;
60
+ }
61
+
62
+ // Vitest JSON 結構
63
+ const sum = (json.numPassedTests ?? 0) + (json.numFailedTests ?? 0) + (json.numPendingTests ?? 0);
64
+ const numTotalTests = json.numTotalTests ?? sum;
65
+ result.total = numTotalTests;
66
+ result.passed = json.numPassedTests ?? 0;
67
+ result.failed = json.numFailedTests ?? 0;
68
+ result.skipped = json.numPendingTests ?? 0;
69
+ result.duration = json.testResults?.reduce(
70
+ (acc, s) => acc + (s.perfStats?.runtime ?? s.duration ?? 0),
71
+ 0
72
+ ) ?? 0;
73
+
74
+ for (const suite of json.testResults ?? []) {
75
+ const suiteName =
76
+ suite.name?.replace(process.cwd(), '').replace(/^\//, '') ?? '';
77
+ for (const t of suite.assertionResults ?? []) {
78
+ result.tests.push({
79
+ name: t.fullName ?? t.title,
80
+ suite: suiteName,
81
+ passed: t.status === 'passed',
82
+ duration: t.duration ?? 0,
83
+ error: t.failureMessages?.join('\n') || null,
84
+ });
85
+ }
86
+ }
87
+
88
+ return result;
89
+ }
90
+ }
@@ -394,4 +394,88 @@ export class GitHubAPI {
394
394
  log.error('無法添加團隊 reviewers,請手動操作');
395
395
  }
396
396
  }
397
+
398
+ // ─────────────────────────────────────────────────────────────
399
+ // Issue 評論功能(供 ai autodev 使用)
400
+ // ─────────────────────────────────────────────────────────────
401
+
402
+ /**
403
+ * 在 Issue 上發佈評論
404
+ * @param {string} owner
405
+ * @param {string} repo
406
+ * @param {number} issueNumber
407
+ * @param {string} body - Markdown 內容
408
+ * @returns {{ id: number, url: string }}
409
+ */
410
+ postCommentOnIssue(owner, repo, issueNumber, body) {
411
+ const tmpFile = `/tmp/ai-autodev-comment-${Date.now()}.json`;
412
+ try {
413
+ const payload = JSON.stringify({ body });
414
+ writeFileSync(tmpFile, payload, 'utf-8');
415
+ const result = execSync(
416
+ `gh api repos/${owner}/${repo}/issues/${issueNumber}/comments --input "${tmpFile}"`,
417
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
418
+ );
419
+ const data = JSON.parse(result);
420
+ return { id: data.id, url: data.html_url };
421
+ } finally {
422
+ this.safeUnlink(tmpFile);
423
+ }
424
+ }
425
+
426
+ /**
427
+ * 更新已存在的 Issue 評論
428
+ * @param {string} owner
429
+ * @param {string} repo
430
+ * @param {number} commentId
431
+ * @param {string} body
432
+ * @returns {{ id: number, url: string }}
433
+ */
434
+ editIssueComment(owner, repo, commentId, body) {
435
+ const tmpFile = `/tmp/ai-autodev-edit-${Date.now()}.json`;
436
+ try {
437
+ const payload = JSON.stringify({ body });
438
+ writeFileSync(tmpFile, payload, 'utf-8');
439
+ const result = execSync(
440
+ `gh api repos/${owner}/${repo}/issues/comments/${commentId} --input "${tmpFile}" -X PATCH`,
441
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
442
+ );
443
+ const data = JSON.parse(result);
444
+ return { id: data.id, url: data.html_url };
445
+ } finally {
446
+ this.safeUnlink(tmpFile);
447
+ }
448
+ }
449
+
450
+ /**
451
+ * 尋找 Issue 上含有特定 marker 的評論(用於重複執行時更新而非重建)
452
+ * @param {string} owner
453
+ * @param {string} repo
454
+ * @param {number} issueNumber
455
+ * @param {string} marker - 識別字串
456
+ * @returns {{ id: number }|null}
457
+ */
458
+ findCommentWithMarker(owner, repo, issueNumber, marker) {
459
+ try {
460
+ const result = execSync(
461
+ `gh api repos/${owner}/${repo}/issues/${issueNumber}/comments --jq '.[] | {id, body}'`,
462
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
463
+ );
464
+ // 每行一個 JSON 物件
465
+ const lines = result.trim().split('\n').filter(Boolean);
466
+ for (const line of lines) {
467
+ try {
468
+ const obj = JSON.parse(line);
469
+ if (obj.body && obj.body.includes(marker)) {
470
+ return { id: obj.id };
471
+ }
472
+ } catch (_) {
473
+ // 忽略解析錯誤
474
+ }
475
+ }
476
+ return null;
477
+ } catch (_) {
478
+ return null;
479
+ }
480
+ }
397
481
  }