hamster-wheel-cli 0.1.0

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.
Files changed (55) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +107 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +15 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
  4. package/.github/workflows/ci-pr.yml +50 -0
  5. package/.github/workflows/publish.yml +121 -0
  6. package/.github/workflows/sync-master-to-dev.yml +100 -0
  7. package/AGENTS.md +20 -0
  8. package/CHANGELOG.md +12 -0
  9. package/LICENSE +21 -0
  10. package/README.md +90 -0
  11. package/dist/cli.d.ts +6 -0
  12. package/dist/cli.js +2678 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/index.d.ts +80 -0
  15. package/dist/index.js +2682 -0
  16. package/dist/index.js.map +1 -0
  17. package/docs/ai-workflow.md +58 -0
  18. package/package.json +44 -0
  19. package/src/ai.ts +173 -0
  20. package/src/cli.ts +189 -0
  21. package/src/config.ts +134 -0
  22. package/src/deps.ts +210 -0
  23. package/src/gh.ts +228 -0
  24. package/src/git.ts +285 -0
  25. package/src/global-config.ts +296 -0
  26. package/src/index.ts +3 -0
  27. package/src/logger.ts +122 -0
  28. package/src/logs-viewer.ts +420 -0
  29. package/src/logs.ts +132 -0
  30. package/src/loop.ts +422 -0
  31. package/src/monitor.ts +291 -0
  32. package/src/runtime-tracker.ts +65 -0
  33. package/src/summary.ts +255 -0
  34. package/src/types.ts +176 -0
  35. package/src/utils.ts +179 -0
  36. package/src/webhook.ts +107 -0
  37. package/tests/deps.test.ts +72 -0
  38. package/tests/e2e/cli.e2e.test.ts +77 -0
  39. package/tests/e2e/gh-pr-create.e2e.test.ts +55 -0
  40. package/tests/e2e/gh-run-list.e2e.test.ts +47 -0
  41. package/tests/gh-pr-create.test.ts +55 -0
  42. package/tests/gh-run-list.test.ts +35 -0
  43. package/tests/global-config.test.ts +52 -0
  44. package/tests/logger-file.test.ts +56 -0
  45. package/tests/logger.test.ts +72 -0
  46. package/tests/logs-viewer.test.ts +57 -0
  47. package/tests/logs.test.ts +33 -0
  48. package/tests/prompt.test.ts +20 -0
  49. package/tests/run-command-stream.test.ts +60 -0
  50. package/tests/summary.test.ts +58 -0
  51. package/tests/token-usage.test.ts +33 -0
  52. package/tests/utils.test.ts +8 -0
  53. package/tests/webhook.test.ts +89 -0
  54. package/tsconfig.json +18 -0
  55. package/tsup.config.ts +18 -0
@@ -0,0 +1,33 @@
1
+ import assert from 'node:assert/strict';
2
+ import path from 'node:path';
3
+ import { test } from 'node:test';
4
+ import { buildAutoLogFilePath, formatCommandLine, formatTimeString, getLogMetaPath, getLogsDir, sanitizeBranchName } from '../src/logs';
5
+
6
+ test('formatTimeString 输出 14 位时间串', () => {
7
+ const date = new Date(2025, 0, 1, 14, 1, 1);
8
+ assert.equal(formatTimeString(date), '20250101140101');
9
+ });
10
+
11
+ test('sanitizeBranchName 会替换非法字符', () => {
12
+ assert.equal(sanitizeBranchName('feature/foo/bar'), 'feature-foo-bar');
13
+ assert.equal(sanitizeBranchName(' fix:bug? '), 'fix-bug');
14
+ });
15
+
16
+ test('buildAutoLogFilePath 会拼接日志目录与分支名', () => {
17
+ const date = new Date(2025, 0, 1, 8, 0, 0);
18
+ const filePath = buildAutoLogFilePath('feat/alpha', date);
19
+ const expected = path.join(getLogsDir(), 'wheel-ai-auto-log-20250101080000-feat-alpha.log');
20
+ assert.equal(filePath, expected);
21
+ });
22
+
23
+ test('getLogMetaPath 使用同名 json', () => {
24
+ const logFile = path.join('/tmp', 'wheel-ai-auto-log-20250101140101-main.log');
25
+ const metaPath = getLogMetaPath(logFile);
26
+ const expected = path.join(getLogsDir(), 'wheel-ai-auto-log-20250101140101-main.json');
27
+ assert.equal(metaPath, expected);
28
+ });
29
+
30
+ test('formatCommandLine 会为包含空格的参数加引号', () => {
31
+ const commandLine = formatCommandLine(['node', 'cli.js', 'run', '--log-file', 'a b']);
32
+ assert.equal(commandLine, 'node cli.js run --log-file "a b"');
33
+ });
@@ -0,0 +1,20 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { buildPrompt } from '../src/ai';
4
+
5
+ test('buildPrompt 输出包含核心段落与迭代信息', () => {
6
+ const prompt = buildPrompt({
7
+ task: 'demo task',
8
+ workflowGuide: 'workflow guide',
9
+ plan: 'current plan',
10
+ notes: 'historical notes',
11
+ iteration: 2
12
+ });
13
+
14
+ assert.ok(prompt.includes('# 背景任务'));
15
+ assert.ok(prompt.includes('demo task'));
16
+ assert.ok(prompt.includes('workflow guide'));
17
+ assert.ok(prompt.includes('current plan'));
18
+ assert.ok(prompt.includes('historical notes'));
19
+ assert.ok(prompt.includes('迭代'));
20
+ });
@@ -0,0 +1,60 @@
1
+ import assert from 'node:assert/strict';
2
+ import { afterEach, beforeEach, test } from 'node:test';
3
+ import { Logger } from '../src/logger';
4
+ import { runCommand } from '../src/utils';
5
+
6
+ type ConsoleMethodName = 'log';
7
+
8
+ const ansiPattern = /\u001b\[[0-9;]*m/g;
9
+ const timestampPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} /;
10
+
11
+ const originalConsole: Record<ConsoleMethodName, Console['log']> = {
12
+ log: console.log
13
+ };
14
+
15
+ const outputs: string[] = [];
16
+
17
+ const stripAnsi = (value: string): string => value.replace(ansiPattern, '');
18
+
19
+ const capture = (method: ConsoleMethodName, sink: string[]): void => {
20
+ const writer = (...data: unknown[]): void => {
21
+ sink.push(data.map(item => String(item)).join(' '));
22
+ };
23
+ console[method] = writer as Console['log'];
24
+ };
25
+
26
+ const hasLine = (lines: string[], message: string): boolean => {
27
+ return lines.some(line => {
28
+ if (!timestampPattern.test(line)) return false;
29
+ const withoutTimestamp = line.replace(timestampPattern, '');
30
+ return withoutTimestamp === `info ${message}`;
31
+ });
32
+ };
33
+
34
+ beforeEach(() => {
35
+ outputs.length = 0;
36
+ capture('log', outputs);
37
+ });
38
+
39
+ afterEach(() => {
40
+ console.log = originalConsole.log;
41
+ });
42
+
43
+ test('runCommand 流式输出会写入日志', async () => {
44
+ const logger = new Logger({ verbose: false });
45
+ const script = "process.stdout.write('alpha\\nbeta\\n'); process.stderr.write('gamma\\n');";
46
+
47
+ await runCommand(process.execPath, ['-e', script], {
48
+ logger,
49
+ stream: {
50
+ enabled: true,
51
+ stdoutPrefix: '[AI] ',
52
+ stderrPrefix: '[AI-err] '
53
+ }
54
+ });
55
+
56
+ const cleaned = outputs.map(stripAnsi);
57
+ assert.ok(hasLine(cleaned, '[AI] alpha'));
58
+ assert.ok(hasLine(cleaned, '[AI] beta'));
59
+ assert.ok(hasLine(cleaned, '[AI-err] gamma'));
60
+ });
@@ -0,0 +1,58 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { buildFallbackSummary, ensurePrBodySections, parseDeliverySummary } from '../src/summary';
4
+ import type { TestRunResult } from '../src/types';
5
+
6
+ test('parseDeliverySummary 解析 JSON 输出', () => {
7
+ const output = JSON.stringify({
8
+ commitTitle: 'chore: 更新 PR 文案生成',
9
+ commitBody: '- 优化 PR 标题\n- 补充提交摘要',
10
+ prTitle: 'chore: 更新 PR 文案生成',
11
+ prBody: '# 变更摘要\n- 优化 PR 标题\n\n# 测试结果\n- 未运行\n\n# 风险与回滚\n- 风险:低\n- 回滚:git revert 对应提交'
12
+ });
13
+
14
+ const parsed = parseDeliverySummary(output);
15
+
16
+ assert.ok(parsed);
17
+ assert.equal(parsed?.commitTitle, 'chore: 更新 PR 文案生成');
18
+ assert.equal(parsed?.prTitle, 'chore: 更新 PR 文案生成');
19
+ });
20
+
21
+ test('parseDeliverySummary 支持 code fence 输出', () => {
22
+ const output = [
23
+ '```json',
24
+ '{"commitTitle":"chore: 文案优化","prTitle":"chore: 文案优化","prBody":"# 变更摘要\\n- 更新\\n\\n# 测试结果\\n- 未运行\\n\\n# 风险与回滚\\n- 风险:待评估","commitBody":"- 更新"}',
25
+ '```'
26
+ ].join('\n');
27
+
28
+ const parsed = parseDeliverySummary(output);
29
+
30
+ assert.ok(parsed);
31
+ assert.equal(parsed?.commitBody, '- 更新');
32
+ });
33
+
34
+ test('ensurePrBodySections 在缺少标题时使用兜底模板', () => {
35
+ const testResults: TestRunResult[] = [];
36
+ const fallback = {
37
+ commitTitle: 'chore: 补充提交摘要',
38
+ commitBody: '- 调整 PR 文案输出',
39
+ testResults
40
+ };
41
+
42
+ const prBody = ensurePrBodySections('简短描述', fallback);
43
+
44
+ assert.ok(prBody.includes('# 变更摘要'));
45
+ assert.ok(prBody.includes('# 测试结果'));
46
+ assert.ok(prBody.includes('# 风险与回滚'));
47
+ assert.ok(prBody.includes('- 调整 PR 文案输出'));
48
+ });
49
+
50
+ test('buildFallbackSummary 使用任务生成标题', () => {
51
+ const summary = buildFallbackSummary({
52
+ task: '优化 PR 标题与提交信息生成',
53
+ testResults: null
54
+ });
55
+
56
+ assert.ok(summary.prTitle.includes('优化 PR 标题与提交信息生成'));
57
+ assert.ok(summary.prBody.includes('# 变更摘要'));
58
+ });
@@ -0,0 +1,33 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { mergeTokenUsage, parseTokenUsage } from '../src/ai';
4
+
5
+ test('parseTokenUsage 可解析 total_tokens 行', () => {
6
+ const logs = 'model: demo\nTotal_tokens: 321\n';
7
+ const usage = parseTokenUsage(logs);
8
+
9
+ assert.ok(usage);
10
+ assert.equal(usage?.totalTokens, 321);
11
+ });
12
+
13
+ test('parseTokenUsage 可累加 prompt/output tokens', () => {
14
+ const logs = 'prompt_tokens: 120\ncompletion_tokens: 30\n';
15
+ const usage = parseTokenUsage(logs);
16
+
17
+ assert.ok(usage);
18
+ assert.equal(usage?.totalTokens, 150);
19
+ assert.equal(usage?.inputTokens, 120);
20
+ assert.equal(usage?.outputTokens, 30);
21
+ });
22
+
23
+ test('mergeTokenUsage 会累计各轮数据', () => {
24
+ const merged = mergeTokenUsage(
25
+ { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
26
+ { inputTokens: 5, outputTokens: 10, totalTokens: 15 }
27
+ );
28
+
29
+ assert.ok(merged);
30
+ assert.equal(merged?.totalTokens, 45);
31
+ assert.equal(merged?.inputTokens, 15);
32
+ assert.equal(merged?.outputTokens, 30);
33
+ });
@@ -0,0 +1,8 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { pad2 } from '../src/utils';
4
+
5
+ test('pad2 会补齐两位数字', () => {
6
+ assert.equal(pad2(1), '01');
7
+ assert.equal(pad2(12), '12');
8
+ });
@@ -0,0 +1,89 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { Logger } from '../src/logger';
4
+ import { buildWebhookPayload, normalizeWebhookUrls, sendWebhookNotifications, type FetchLike } from '../src/webhook';
5
+
6
+ test('normalizeWebhookUrls 会去除空值并裁剪空白', () => {
7
+ const urls = normalizeWebhookUrls([' https://example.com ', '', ' ', 'http://a.local']);
8
+ assert.deepEqual(urls, ['https://example.com', 'http://a.local']);
9
+ });
10
+
11
+ test('buildWebhookPayload 会补齐时间戳', () => {
12
+ const payload = buildWebhookPayload({
13
+ event: 'task_start',
14
+ task: 'demo',
15
+ iteration: 0,
16
+ stage: '任务开始'
17
+ });
18
+ assert.equal(payload.event, 'task_start');
19
+ assert.equal(payload.task, 'demo');
20
+ assert.equal(payload.iteration, 0);
21
+ assert.ok(payload.timestamp.length > 0);
22
+ });
23
+
24
+ test('sendWebhookNotifications 在无配置时直接跳过', async () => {
25
+ const logger = new Logger({ verbose: false });
26
+ let called = false;
27
+ const fetcher: FetchLike = async () => {
28
+ called = true;
29
+ return { ok: true, status: 200, statusText: 'OK' };
30
+ };
31
+
32
+ const payload = buildWebhookPayload({
33
+ event: 'task_start',
34
+ task: 'demo',
35
+ iteration: 0,
36
+ stage: '任务开始',
37
+ timestamp: '2024-01-01T00:00:00.000Z'
38
+ });
39
+
40
+ await sendWebhookNotifications(null, payload, logger, fetcher);
41
+ assert.equal(called, false);
42
+ });
43
+
44
+ test('sendWebhookNotifications 会向多个地址发送', async () => {
45
+ const logger = new Logger({ verbose: false });
46
+ const calls: Array<{ url: string; body: string; contentType: string }> = [];
47
+ const fetcher: FetchLike = async (url, init) => {
48
+ calls.push({
49
+ url,
50
+ body: init.body,
51
+ contentType: init.headers['content-type']
52
+ });
53
+ return { ok: true, status: 204, statusText: 'No Content' };
54
+ };
55
+
56
+ const payload = buildWebhookPayload({
57
+ event: 'iteration_start',
58
+ task: 'demo',
59
+ branch: 'feat/demo',
60
+ iteration: 2,
61
+ stage: '开始第 2 轮迭代',
62
+ timestamp: '2024-01-01T00:00:00.000Z'
63
+ });
64
+
65
+ await sendWebhookNotifications({ urls: ['https://a.test', 'https://b.test'], timeoutMs: 1000 }, payload, logger, fetcher);
66
+
67
+ assert.equal(calls.length, 2);
68
+ assert.equal(calls[0].contentType, 'application/json');
69
+ const decoded = JSON.parse(calls[0].body) as typeof payload;
70
+ assert.equal(decoded.task, 'demo');
71
+ assert.equal(decoded.branch, 'feat/demo');
72
+ });
73
+
74
+ test('sendWebhookNotifications 捕获异常并不中断', async () => {
75
+ const logger = new Logger({ verbose: false });
76
+ const payload = buildWebhookPayload({
77
+ event: 'task_end',
78
+ task: 'demo',
79
+ iteration: 1,
80
+ stage: '任务结束',
81
+ timestamp: '2024-01-01T00:00:00.000Z'
82
+ });
83
+
84
+ await assert.doesNotReject(async () => {
85
+ await sendWebhookNotifications({ urls: ['https://fail.test'] }, payload, logger, async () => {
86
+ throw new Error('boom');
87
+ });
88
+ });
89
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "CommonJS",
5
+ "moduleResolution": "node",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "esModuleInterop": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "strict": true,
11
+ "skipLibCheck": true,
12
+ "resolveJsonModule": true,
13
+ "noImplicitOverride": true,
14
+ "noUnusedLocals": false,
15
+ "noUnusedParameters": false
16
+ },
17
+ "include": ["src/**/*"]
18
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: {
5
+ cli: 'src/cli.ts',
6
+ index: 'src/index.ts'
7
+ },
8
+ format: ['cjs'],
9
+ target: 'node18',
10
+ dts: true,
11
+ sourcemap: true,
12
+ clean: true,
13
+ minify: false,
14
+ shims: false,
15
+ banner: {
16
+ js: '#!/usr/bin/env node'
17
+ }
18
+ });