hamster-wheel-cli 0.1.0 → 0.2.0-beta.1
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/CHANGELOG.md +27 -1
- package/README.md +29 -0
- package/dist/cli.js +1747 -309
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +1747 -309
- package/dist/index.js.map +1 -1
- package/docs/ai-workflow.md +27 -36
- package/package.json +1 -1
- package/src/ai.ts +346 -1
- package/src/alias-viewer.ts +221 -0
- package/src/cli.ts +263 -29
- package/src/config.ts +6 -2
- package/src/gh.ts +20 -0
- package/src/git.ts +38 -3
- package/src/global-config.ts +149 -11
- package/src/log-tailer.ts +103 -0
- package/src/logs-viewer.ts +33 -11
- package/src/logs.ts +1 -0
- package/src/loop.ts +458 -120
- package/src/monitor.ts +240 -23
- package/src/multi-task.ts +117 -0
- package/src/plan.ts +61 -0
- package/src/quality.ts +48 -0
- package/src/runtime-tracker.ts +2 -1
- package/src/types.ts +23 -0
- package/tests/branch-name.test.ts +28 -0
- package/tests/e2e/cli.e2e.test.ts +41 -0
- package/tests/global-config.test.ts +52 -1
- package/tests/monitor.test.ts +17 -0
- package/tests/multi-task.test.ts +77 -0
package/src/cli.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'fs-extra';
|
|
2
3
|
import { Command } from 'commander';
|
|
3
4
|
import { buildLoopConfig, CliOptions, defaultNotesPath, defaultPlanPath, defaultWorkflowDoc } from './config';
|
|
4
|
-
import { applyShortcutArgv, loadGlobalConfig } from './global-config';
|
|
5
|
-
import {
|
|
6
|
-
import { buildAutoLogFilePath } from './logs';
|
|
5
|
+
import { applyShortcutArgv, loadGlobalConfig, normalizeAliasName, upsertAliasEntry } from './global-config';
|
|
6
|
+
import { getCurrentBranch } from './git';
|
|
7
|
+
import { buildAutoLogFilePath, formatCommandLine } from './logs';
|
|
8
|
+
import { runAliasViewer } from './alias-viewer';
|
|
7
9
|
import { runLogsViewer } from './logs-viewer';
|
|
8
10
|
import { runLoop } from './loop';
|
|
9
11
|
import { defaultLogger } from './logger';
|
|
10
|
-
import {
|
|
12
|
+
import { buildTaskPlans, normalizeTaskList, parseMultiTaskMode } from './multi-task';
|
|
13
|
+
import { resolveTerminationTarget, runMonitor } from './monitor';
|
|
14
|
+
import { tailLogFile } from './log-tailer';
|
|
11
15
|
import { resolvePath } from './utils';
|
|
12
16
|
|
|
17
|
+
const FOREGROUND_CHILD_ENV = 'WHEEL_AI_FOREGROUND_CHILD';
|
|
18
|
+
|
|
13
19
|
function parseInteger(value: string, defaultValue: number): number {
|
|
14
20
|
const parsed = Number.parseInt(value, 10);
|
|
15
21
|
if (Number.isNaN(parsed)) return defaultValue;
|
|
@@ -42,6 +48,108 @@ function buildBackgroundArgs(argv: string[], logFile: string, branchName?: strin
|
|
|
42
48
|
return filtered;
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
function extractAliasCommandArgs(argv: string[], name: string): string[] {
|
|
52
|
+
const args = argv.slice(2);
|
|
53
|
+
const start = args.findIndex((arg, index) => arg === 'set' && args[index + 1] === 'alias' && args[index + 2] === name);
|
|
54
|
+
if (start < 0) return [];
|
|
55
|
+
const rest = args.slice(start + 3);
|
|
56
|
+
if (rest[0] === '--') return rest.slice(1);
|
|
57
|
+
return rest;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function runForegroundWithDetach(options: {
|
|
61
|
+
argv: string[];
|
|
62
|
+
logFile: string;
|
|
63
|
+
branchName?: string;
|
|
64
|
+
injectBranch: boolean;
|
|
65
|
+
isMultiTask: boolean;
|
|
66
|
+
}): Promise<void> {
|
|
67
|
+
const args = buildBackgroundArgs(options.argv, options.logFile, options.branchName, options.injectBranch);
|
|
68
|
+
const child = spawn(process.execPath, [...process.execArgv, ...args], {
|
|
69
|
+
detached: true,
|
|
70
|
+
stdio: 'ignore',
|
|
71
|
+
env: {
|
|
72
|
+
...process.env,
|
|
73
|
+
[FOREGROUND_CHILD_ENV]: '1'
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
child.unref();
|
|
77
|
+
|
|
78
|
+
const resolvedLogFile = resolvePath(process.cwd(), options.logFile);
|
|
79
|
+
const existed = await fs.pathExists(resolvedLogFile);
|
|
80
|
+
const tailer = await tailLogFile({
|
|
81
|
+
filePath: resolvedLogFile,
|
|
82
|
+
startFromEnd: existed,
|
|
83
|
+
onLine: line => {
|
|
84
|
+
process.stdout.write(`${line}\n`);
|
|
85
|
+
},
|
|
86
|
+
onError: message => {
|
|
87
|
+
defaultLogger.warn(`日志读取失败:${message}`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const suffixNote = options.isMultiTask ? '(多任务将追加序号)' : '';
|
|
92
|
+
console.log(`已进入前台日志查看,按 Esc 切到后台运行,日志输出至 ${resolvedLogFile}${suffixNote}`);
|
|
93
|
+
|
|
94
|
+
let cleaned = false;
|
|
95
|
+
const cleanup = async (): Promise<void> => {
|
|
96
|
+
if (cleaned) return;
|
|
97
|
+
cleaned = true;
|
|
98
|
+
await tailer.stop();
|
|
99
|
+
if (process.stdin.isTTY) {
|
|
100
|
+
process.stdin.setRawMode(false);
|
|
101
|
+
process.stdin.pause();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const detach = async (): Promise<void> => {
|
|
106
|
+
await cleanup();
|
|
107
|
+
console.log(`已切入后台运行,日志输出至 ${resolvedLogFile}${suffixNote}`);
|
|
108
|
+
process.exit(0);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const terminate = async (): Promise<void> => {
|
|
112
|
+
if (child.pid) {
|
|
113
|
+
try {
|
|
114
|
+
const target = resolveTerminationTarget(child.pid);
|
|
115
|
+
process.kill(target, 'SIGTERM');
|
|
116
|
+
} catch (error) {
|
|
117
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
118
|
+
defaultLogger.warn(`终止子进程失败:${message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
await cleanup();
|
|
122
|
+
process.exit(0);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (process.stdin.isTTY) {
|
|
126
|
+
process.stdin.setRawMode(true);
|
|
127
|
+
process.stdin.resume();
|
|
128
|
+
process.stdin.on('data', (data: Buffer) => {
|
|
129
|
+
const input = data.toString('utf8');
|
|
130
|
+
if (input === '\u001b') {
|
|
131
|
+
void detach();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (input === '\u0003') {
|
|
135
|
+
void terminate();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
process.on('SIGINT', () => {
|
|
141
|
+
void terminate();
|
|
142
|
+
});
|
|
143
|
+
process.on('SIGTERM', () => {
|
|
144
|
+
void terminate();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
child.on('exit', async code => {
|
|
148
|
+
await cleanup();
|
|
149
|
+
process.exit(code ?? 0);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
45
153
|
/**
|
|
46
154
|
* CLI 入口。
|
|
47
155
|
*/
|
|
@@ -57,7 +165,7 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
57
165
|
|
|
58
166
|
program
|
|
59
167
|
.command('run')
|
|
60
|
-
.
|
|
168
|
+
.option('-t, --task <task>', '需要完成的任务描述(可重复传入,独立处理)', collect, [])
|
|
61
169
|
.option('-i, --iterations <number>', '最大迭代次数', value => parseInteger(value, 5), 5)
|
|
62
170
|
.option('--ai-cli <command>', 'AI CLI 命令', 'claude')
|
|
63
171
|
.option('--ai-args <args...>', 'AI CLI 参数', [])
|
|
@@ -81,33 +189,53 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
81
189
|
.option('--pr-body <path>', 'PR 描述文件路径(可留空自动生成)')
|
|
82
190
|
.option('--draft', '以草稿形式创建 PR', false)
|
|
83
191
|
.option('--reviewer <user...>', 'PR reviewers', collect, [])
|
|
192
|
+
.option('--auto-merge', 'PR 检查通过后自动合并', false)
|
|
84
193
|
.option('--webhook <url>', 'webhook 通知 URL(可重复)', collect, [])
|
|
85
194
|
.option('--webhook-timeout <ms>', 'webhook 请求超时(毫秒)', value => parseInteger(value, 8000))
|
|
195
|
+
.option('--multi-task-mode <mode>', '多任务执行模式(relay/serial/serial-continue/parallel,或中文描述)', 'relay')
|
|
86
196
|
.option('--stop-signal <token>', 'AI 输出中的停止标记', '<<DONE>>')
|
|
87
197
|
.option('--log-file <path>', '日志输出文件路径')
|
|
88
198
|
.option('--background', '切入后台运行', false)
|
|
89
199
|
.option('-v, --verbose', '输出调试日志', false)
|
|
200
|
+
.option('--skip-quality', '跳过代码质量检查', false)
|
|
90
201
|
.action(async (options) => {
|
|
202
|
+
const tasks = normalizeTaskList(options.task as string[] | string | undefined);
|
|
203
|
+
if (tasks.length === 0) {
|
|
204
|
+
throw new Error('需要至少提供一个任务描述');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const multiTaskMode = parseMultiTaskMode(options.multiTaskMode as string | undefined);
|
|
91
208
|
const useWorktree = Boolean(options.worktree);
|
|
209
|
+
if (multiTaskMode === 'parallel' && !useWorktree) {
|
|
210
|
+
throw new Error('并行模式必须启用 --worktree');
|
|
211
|
+
}
|
|
212
|
+
|
|
92
213
|
const branchInput = normalizeOptional(options.branch);
|
|
93
214
|
const logFileInput = normalizeOptional(options.logFile);
|
|
215
|
+
const worktreePathInput = normalizeOptional(options.worktreePath);
|
|
94
216
|
const background = Boolean(options.background);
|
|
217
|
+
const isMultiTask = tasks.length > 1;
|
|
218
|
+
const isForegroundChild = process.env[FOREGROUND_CHILD_ENV] === '1';
|
|
219
|
+
const canForegroundDetach = !background && !isForegroundChild && process.stdout.isTTY && process.stdin.isTTY;
|
|
95
220
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
branchName = generateBranchName();
|
|
99
|
-
}
|
|
221
|
+
const shouldInjectBranch = Boolean(useWorktree && branchInput && !isMultiTask);
|
|
222
|
+
const branchNameForBackground = branchInput;
|
|
100
223
|
|
|
101
224
|
let logFile = logFileInput;
|
|
102
|
-
if (background && !logFile) {
|
|
103
|
-
let branchForLog =
|
|
104
|
-
if (!
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
225
|
+
if ((background || canForegroundDetach) && !logFile) {
|
|
226
|
+
let branchForLog = 'multi-task';
|
|
227
|
+
if (!isMultiTask) {
|
|
228
|
+
branchForLog = branchNameForBackground ?? '';
|
|
229
|
+
if (!branchForLog) {
|
|
230
|
+
try {
|
|
231
|
+
const current = await getCurrentBranch(process.cwd(), defaultLogger);
|
|
232
|
+
branchForLog = current || 'detached';
|
|
233
|
+
} catch {
|
|
234
|
+
branchForLog = 'unknown';
|
|
235
|
+
}
|
|
110
236
|
}
|
|
237
|
+
} else if (branchInput) {
|
|
238
|
+
branchForLog = `${branchInput}-multi`;
|
|
111
239
|
}
|
|
112
240
|
logFile = buildAutoLogFilePath(branchForLog);
|
|
113
241
|
}
|
|
@@ -116,19 +244,43 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
116
244
|
if (!logFile) {
|
|
117
245
|
throw new Error('后台运行需要指定日志文件');
|
|
118
246
|
}
|
|
119
|
-
const args = buildBackgroundArgs(effectiveArgv, logFile,
|
|
247
|
+
const args = buildBackgroundArgs(effectiveArgv, logFile, branchNameForBackground, shouldInjectBranch);
|
|
120
248
|
const child = spawn(process.execPath, [...process.execArgv, ...args], {
|
|
121
249
|
detached: true,
|
|
122
250
|
stdio: 'ignore'
|
|
123
251
|
});
|
|
124
252
|
child.unref();
|
|
125
253
|
const displayLogFile = resolvePath(process.cwd(), logFile);
|
|
126
|
-
|
|
254
|
+
const suffixNote = isMultiTask ? '(多任务将追加序号)' : '';
|
|
255
|
+
console.log(`已切入后台运行,日志输出至 ${displayLogFile}${suffixNote}`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (canForegroundDetach) {
|
|
260
|
+
if (!logFile) {
|
|
261
|
+
throw new Error('切入后台需要指定日志文件');
|
|
262
|
+
}
|
|
263
|
+
await runForegroundWithDetach({
|
|
264
|
+
argv: effectiveArgv,
|
|
265
|
+
logFile,
|
|
266
|
+
branchName: branchNameForBackground,
|
|
267
|
+
injectBranch: shouldInjectBranch,
|
|
268
|
+
isMultiTask
|
|
269
|
+
});
|
|
127
270
|
return;
|
|
128
271
|
}
|
|
129
272
|
|
|
130
|
-
const
|
|
131
|
-
|
|
273
|
+
const taskPlans = buildTaskPlans({
|
|
274
|
+
tasks,
|
|
275
|
+
mode: multiTaskMode,
|
|
276
|
+
useWorktree,
|
|
277
|
+
baseBranch: options.baseBranch as string,
|
|
278
|
+
branchInput,
|
|
279
|
+
worktreePath: worktreePathInput,
|
|
280
|
+
logFile: logFileInput
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const baseOptions = {
|
|
132
284
|
iterations: options.iterations as number,
|
|
133
285
|
aiCli: options.aiCli as string,
|
|
134
286
|
aiArgs: (options.aiArgs as string[]) ?? [],
|
|
@@ -137,9 +289,6 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
137
289
|
planFile: options.planFile as string,
|
|
138
290
|
workflowDoc: options.workflowDoc as string,
|
|
139
291
|
useWorktree,
|
|
140
|
-
branch: branchName,
|
|
141
|
-
worktreePath: options.worktreePath as string | undefined,
|
|
142
|
-
baseBranch: options.baseBranch as string,
|
|
143
292
|
runTests: Boolean(options.runTests),
|
|
144
293
|
runE2e: Boolean(options.runE2e),
|
|
145
294
|
unitCommand: options.unitCommand as string | undefined,
|
|
@@ -151,21 +300,78 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
151
300
|
prBody: options.prBody as string | undefined,
|
|
152
301
|
draft: Boolean(options.draft),
|
|
153
302
|
reviewers: (options.reviewer as string[]) ?? [],
|
|
303
|
+
autoMerge: Boolean(options.autoMerge),
|
|
154
304
|
webhookUrls: (options.webhook as string[]) ?? [],
|
|
155
305
|
webhookTimeout: options.webhookTimeout as number | undefined,
|
|
156
306
|
stopSignal: options.stopSignal as string,
|
|
157
|
-
logFile,
|
|
158
307
|
verbose: Boolean(options.verbose),
|
|
159
|
-
skipInstall: Boolean(options.skipInstall)
|
|
308
|
+
skipInstall: Boolean(options.skipInstall),
|
|
309
|
+
skipQuality: Boolean(options.skipQuality)
|
|
160
310
|
};
|
|
161
311
|
|
|
162
|
-
const
|
|
163
|
-
|
|
312
|
+
const dynamicRelay = useWorktree && multiTaskMode === 'relay' && !branchInput;
|
|
313
|
+
let relayBaseBranch = options.baseBranch as string;
|
|
314
|
+
|
|
315
|
+
const runPlan = async (plan: typeof taskPlans[number], baseBranchOverride?: string): Promise<Awaited<ReturnType<typeof runLoop>>> => {
|
|
316
|
+
const cliOptions: CliOptions = {
|
|
317
|
+
task: plan.task,
|
|
318
|
+
...baseOptions,
|
|
319
|
+
branch: plan.branchName,
|
|
320
|
+
worktreePath: plan.worktreePath,
|
|
321
|
+
baseBranch: baseBranchOverride ?? plan.baseBranch,
|
|
322
|
+
logFile: plan.logFile
|
|
323
|
+
};
|
|
324
|
+
const config = buildLoopConfig(cliOptions, process.cwd());
|
|
325
|
+
return runLoop(config);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
if (multiTaskMode === 'parallel') {
|
|
329
|
+
const results = await Promise.allSettled(taskPlans.map(plan => runPlan(plan)));
|
|
330
|
+
const errors = results.flatMap((result, index) => {
|
|
331
|
+
if (result.status === 'fulfilled') return [];
|
|
332
|
+
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
333
|
+
return [`任务 ${index + 1} 失败: ${reason}`];
|
|
334
|
+
});
|
|
335
|
+
if (errors.length > 0) {
|
|
336
|
+
errors.forEach(message => defaultLogger.warn(message));
|
|
337
|
+
throw new Error(errors.join('\n'));
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (multiTaskMode === 'serial-continue') {
|
|
343
|
+
const errors: string[] = [];
|
|
344
|
+
for (const plan of taskPlans) {
|
|
345
|
+
try {
|
|
346
|
+
const baseBranch = dynamicRelay ? relayBaseBranch : plan.baseBranch;
|
|
347
|
+
const result = await runPlan(plan, baseBranch);
|
|
348
|
+
if (dynamicRelay && result.branchName) {
|
|
349
|
+
relayBaseBranch = result.branchName;
|
|
350
|
+
}
|
|
351
|
+
} catch (error) {
|
|
352
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
353
|
+
errors.push(`任务 ${plan.index + 1} 失败: ${message}`);
|
|
354
|
+
defaultLogger.warn(`任务 ${plan.index + 1} 执行失败,继续下一任务:${message}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (errors.length > 0) {
|
|
358
|
+
throw new Error(errors.join('\n'));
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const plan of taskPlans) {
|
|
364
|
+
const baseBranch = dynamicRelay ? relayBaseBranch : plan.baseBranch;
|
|
365
|
+
const result = await runPlan(plan, baseBranch);
|
|
366
|
+
if (dynamicRelay && result.branchName) {
|
|
367
|
+
relayBaseBranch = result.branchName;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
164
370
|
});
|
|
165
371
|
|
|
166
372
|
program
|
|
167
373
|
.command('monitor')
|
|
168
|
-
.description('
|
|
374
|
+
.description('查看后台运行日志(t 终止任务)')
|
|
169
375
|
.action(async () => {
|
|
170
376
|
await runMonitor();
|
|
171
377
|
});
|
|
@@ -177,6 +383,34 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
177
383
|
await runLogsViewer();
|
|
178
384
|
});
|
|
179
385
|
|
|
386
|
+
program
|
|
387
|
+
.command('set')
|
|
388
|
+
.description('写入全局配置')
|
|
389
|
+
.command('alias <name> [options...]')
|
|
390
|
+
.description('设置 alias')
|
|
391
|
+
.allowUnknownOption(true)
|
|
392
|
+
.action(async (name: string) => {
|
|
393
|
+
const normalized = normalizeAliasName(name);
|
|
394
|
+
if (!normalized) {
|
|
395
|
+
throw new Error('alias 名称不能为空且不能包含空白字符');
|
|
396
|
+
}
|
|
397
|
+
const commandArgs = extractAliasCommandArgs(effectiveArgv, name);
|
|
398
|
+
const commandLine = formatCommandLine(commandArgs);
|
|
399
|
+
if (!commandLine) {
|
|
400
|
+
throw new Error('alias 命令不能为空');
|
|
401
|
+
}
|
|
402
|
+
await upsertAliasEntry(normalized, commandLine);
|
|
403
|
+
console.log(`已写入 alias:${normalized}`);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
program
|
|
407
|
+
.command('alias')
|
|
408
|
+
.alias('aliases')
|
|
409
|
+
.description('浏览全局 alias 配置')
|
|
410
|
+
.action(async () => {
|
|
411
|
+
await runAliasViewer();
|
|
412
|
+
});
|
|
413
|
+
|
|
180
414
|
await program.parseAsync(effectiveArgv);
|
|
181
415
|
}
|
|
182
416
|
|
package/src/config.ts
CHANGED
|
@@ -29,12 +29,14 @@ export interface CliOptions {
|
|
|
29
29
|
readonly prBody?: string;
|
|
30
30
|
readonly draft: boolean;
|
|
31
31
|
readonly reviewers?: string[];
|
|
32
|
+
readonly autoMerge: boolean;
|
|
32
33
|
readonly webhookUrls: string[];
|
|
33
34
|
readonly webhookTimeout?: number;
|
|
34
35
|
readonly stopSignal: string;
|
|
35
36
|
readonly logFile?: string;
|
|
36
37
|
readonly verbose: boolean;
|
|
37
38
|
readonly skipInstall: boolean;
|
|
39
|
+
readonly skipQuality: boolean;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
function buildAiConfig(options: CliOptions): AiCliConfig {
|
|
@@ -67,7 +69,8 @@ function buildPrConfig(options: CliOptions): PrConfig {
|
|
|
67
69
|
title: options.prTitle,
|
|
68
70
|
bodyPath: options.prBody,
|
|
69
71
|
draft: options.draft,
|
|
70
|
-
reviewers: options.reviewers
|
|
72
|
+
reviewers: options.reviewers,
|
|
73
|
+
autoMerge: options.autoMerge
|
|
71
74
|
};
|
|
72
75
|
}
|
|
73
76
|
|
|
@@ -108,7 +111,8 @@ export function buildLoopConfig(options: CliOptions, cwd: string): LoopConfig {
|
|
|
108
111
|
runE2e: options.runE2e,
|
|
109
112
|
autoCommit: options.autoCommit,
|
|
110
113
|
autoPush: options.autoPush,
|
|
111
|
-
skipInstall: options.skipInstall
|
|
114
|
+
skipInstall: options.skipInstall,
|
|
115
|
+
skipQuality: options.skipQuality
|
|
112
116
|
};
|
|
113
117
|
}
|
|
114
118
|
|
package/src/gh.ts
CHANGED
|
@@ -226,3 +226,23 @@ export async function listFailedRuns(branch: string, cwd: string, logger: Logger
|
|
|
226
226
|
return [];
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 启用 PR 自动合并。
|
|
232
|
+
*/
|
|
233
|
+
export async function enableAutoMerge(target: string | number, cwd: string, logger: Logger): Promise<boolean> {
|
|
234
|
+
const targetValue = String(target);
|
|
235
|
+
const args = ['pr', 'merge', targetValue, '--auto', '--merge'];
|
|
236
|
+
const result = await runCommand('gh', args, {
|
|
237
|
+
cwd,
|
|
238
|
+
logger,
|
|
239
|
+
verboseLabel: 'gh',
|
|
240
|
+
verboseCommand: `gh ${args.join(' ')}`
|
|
241
|
+
});
|
|
242
|
+
if (result.exitCode !== 0) {
|
|
243
|
+
logger.warn(`启用自动合并失败: ${result.stderr || result.stdout}`);
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
logger.success('已启用 PR 自动合并');
|
|
247
|
+
return true;
|
|
248
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -209,7 +209,7 @@ function buildCommitArgs(message: CommitMessage): string[] {
|
|
|
209
209
|
/**
|
|
210
210
|
* 提交当前变更。
|
|
211
211
|
*/
|
|
212
|
-
export async function commitAll(message: CommitMessage, cwd: string, logger: Logger): Promise<
|
|
212
|
+
export async function commitAll(message: CommitMessage, cwd: string, logger: Logger): Promise<boolean> {
|
|
213
213
|
const add = await runCommand('git', ['add', '-A'], {
|
|
214
214
|
cwd,
|
|
215
215
|
logger,
|
|
@@ -227,9 +227,10 @@ export async function commitAll(message: CommitMessage, cwd: string, logger: Log
|
|
|
227
227
|
});
|
|
228
228
|
if (commit.exitCode !== 0) {
|
|
229
229
|
logger.warn(`git commit 跳过或失败: ${commit.stderr}`);
|
|
230
|
-
return;
|
|
230
|
+
return false;
|
|
231
231
|
}
|
|
232
232
|
logger.success('已提交当前变更');
|
|
233
|
+
return true;
|
|
233
234
|
}
|
|
234
235
|
|
|
235
236
|
/**
|
|
@@ -281,5 +282,39 @@ export async function removeWorktree(worktreePath: string, repoRoot: string, log
|
|
|
281
282
|
export function generateBranchName(): string {
|
|
282
283
|
const now = new Date();
|
|
283
284
|
const stamp = `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}-${now.getHours().toString().padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}`;
|
|
284
|
-
return `wheel-
|
|
285
|
+
return `wheel-ai/${stamp}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function guessBranchType(task: string): string {
|
|
289
|
+
const text = task.toLowerCase();
|
|
290
|
+
if (/fix|bug|修复|错误|异常|问题/.test(text)) return 'fix';
|
|
291
|
+
if (/docs|readme|changelog|文档/.test(text)) return 'docs';
|
|
292
|
+
if (/test|e2e|单测|测试/.test(text)) return 'test';
|
|
293
|
+
if (/refactor|重构/.test(text)) return 'refactor';
|
|
294
|
+
if (/chore|构建|依赖|配置/.test(text)) return 'chore';
|
|
295
|
+
return 'feat';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function slugifyTask(task: string): string {
|
|
299
|
+
const slug = task
|
|
300
|
+
.toLowerCase()
|
|
301
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
302
|
+
.replace(/-+/g, '-')
|
|
303
|
+
.replace(/^-+|-+$/g, '');
|
|
304
|
+
return slug.slice(0, 40);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function buildTimestampSlug(now: Date): string {
|
|
308
|
+
const stamp = `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}-${now.getHours().toString().padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}`;
|
|
309
|
+
return `auto-${stamp}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 基于任务生成分支名(AI 失败时兜底)。
|
|
314
|
+
*/
|
|
315
|
+
export function generateBranchNameFromTask(task: string, now: Date = new Date()): string {
|
|
316
|
+
const slug = slugifyTask(task);
|
|
317
|
+
const type = guessBranchType(task);
|
|
318
|
+
const suffix = slug || buildTimestampSlug(now);
|
|
319
|
+
return `${type}/${suffix}`;
|
|
285
320
|
}
|