hamster-wheel-cli 0.2.0-beta.2 → 0.3.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 +29 -2
- package/README.md +56 -19
- package/dist/cli.js +567 -22
- package/dist/cli.js.map +1 -1
- package/dist/index.js +567 -22
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +376 -18
- package/src/global-config.ts +251 -0
- package/src/logs-viewer.ts +13 -1
- package/src/loop.ts +92 -12
- package/src/utils.ts +13 -0
- package/src/webhook.ts +54 -9
- package/tests/e2e/cli.e2e.test.ts +41 -3
- package/tests/global-config.test.ts +89 -1
- package/tests/webhook.test.ts +10 -6
package/src/global-config.ts
CHANGED
|
@@ -20,6 +20,14 @@ export interface AliasEntry {
|
|
|
20
20
|
readonly source: 'alias' | 'shortcut';
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* 全局 agent 配置条目。
|
|
25
|
+
*/
|
|
26
|
+
export interface AgentEntry {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly command: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
/**
|
|
24
32
|
* 全局配置结构。
|
|
25
33
|
*/
|
|
@@ -177,6 +185,13 @@ export function normalizeAliasName(name: string): string | null {
|
|
|
177
185
|
return normalizeShortcutName(name);
|
|
178
186
|
}
|
|
179
187
|
|
|
188
|
+
/**
|
|
189
|
+
* 规范化 agent 名称。
|
|
190
|
+
*/
|
|
191
|
+
export function normalizeAgentName(name: string): string | null {
|
|
192
|
+
return normalizeShortcutName(name);
|
|
193
|
+
}
|
|
194
|
+
|
|
180
195
|
function formatTomlString(value: string): string {
|
|
181
196
|
const escaped = value
|
|
182
197
|
.replace(/\\/g, '\\\\')
|
|
@@ -187,6 +202,43 @@ function formatTomlString(value: string): string {
|
|
|
187
202
|
return `"${escaped}"`;
|
|
188
203
|
}
|
|
189
204
|
|
|
205
|
+
interface SectionRange {
|
|
206
|
+
readonly name: string;
|
|
207
|
+
readonly start: number;
|
|
208
|
+
end: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function collectSectionRanges(lines: string[]): SectionRange[] {
|
|
212
|
+
const ranges: SectionRange[] = [];
|
|
213
|
+
let current: SectionRange | null = null;
|
|
214
|
+
|
|
215
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
216
|
+
const match = /^\s*\[(.+?)\]\s*$/.exec(lines[i]);
|
|
217
|
+
if (!match) continue;
|
|
218
|
+
|
|
219
|
+
if (current) {
|
|
220
|
+
current.end = i;
|
|
221
|
+
ranges.push(current);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
current = {
|
|
225
|
+
name: match[1].trim(),
|
|
226
|
+
start: i,
|
|
227
|
+
end: lines.length
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (current) {
|
|
232
|
+
ranges.push(current);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return ranges;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isAgentSection(name: string | null): boolean {
|
|
239
|
+
return name === 'agent' || name === 'agents';
|
|
240
|
+
}
|
|
241
|
+
|
|
190
242
|
/**
|
|
191
243
|
* 更新 alias 配置内容,返回新文本。
|
|
192
244
|
*/
|
|
@@ -234,6 +286,43 @@ export function updateAliasContent(content: string, name: string, command: strin
|
|
|
234
286
|
return output.endsWith('\n') ? output : `${output}\n`;
|
|
235
287
|
}
|
|
236
288
|
|
|
289
|
+
/**
|
|
290
|
+
* 删除 alias 配置内容,返回删除结果与新文本。
|
|
291
|
+
*/
|
|
292
|
+
export function removeAliasContent(content: string, name: string): { removed: boolean; nextContent: string } {
|
|
293
|
+
const lines = content.split(/\r?\n/);
|
|
294
|
+
let currentSection: string | null = null;
|
|
295
|
+
const removeIndices: number[] = [];
|
|
296
|
+
|
|
297
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
298
|
+
const sectionMatch = /^\s*\[(.+?)\]\s*$/.exec(lines[i]);
|
|
299
|
+
if (sectionMatch) {
|
|
300
|
+
currentSection = sectionMatch[1].trim();
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (currentSection !== 'alias') continue;
|
|
305
|
+
const parsed = parseTomlKeyValue(stripTomlComment(lines[i]).trim());
|
|
306
|
+
if (!parsed) continue;
|
|
307
|
+
if (parsed.key === name) {
|
|
308
|
+
removeIndices.push(i);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (removeIndices.length === 0) {
|
|
313
|
+
return { removed: false, nextContent: ensureTrailingNewline(content) };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
removeIndices
|
|
317
|
+
.sort((a, b) => b - a)
|
|
318
|
+
.forEach(index => {
|
|
319
|
+
lines.splice(index, 1);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const output = lines.join('\n');
|
|
323
|
+
return { removed: true, nextContent: output.endsWith('\n') ? output : `${output}\n` };
|
|
324
|
+
}
|
|
325
|
+
|
|
237
326
|
/**
|
|
238
327
|
* 写入或更新 alias 配置。
|
|
239
328
|
*/
|
|
@@ -245,6 +334,130 @@ export async function upsertAliasEntry(name: string, command: string, filePath:
|
|
|
245
334
|
await fs.writeFile(filePath, nextContent, 'utf8');
|
|
246
335
|
}
|
|
247
336
|
|
|
337
|
+
/**
|
|
338
|
+
* 删除 alias 配置,返回是否删除成功。
|
|
339
|
+
*/
|
|
340
|
+
export async function removeAliasEntry(name: string, filePath: string = getGlobalConfigPath()): Promise<boolean> {
|
|
341
|
+
const exists = await fs.pathExists(filePath);
|
|
342
|
+
if (!exists) return false;
|
|
343
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
344
|
+
const { removed, nextContent } = removeAliasContent(content, name);
|
|
345
|
+
if (!removed) return false;
|
|
346
|
+
await fs.writeFile(filePath, nextContent, 'utf8');
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function ensureTrailingNewline(content: string): string {
|
|
351
|
+
if (!content) return '';
|
|
352
|
+
return content.endsWith('\n') ? content : `${content}\n`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function findAgentRangeWithEntry(lines: string[], ranges: SectionRange[], name: string): SectionRange | null {
|
|
356
|
+
for (const range of ranges) {
|
|
357
|
+
for (let i = range.start + 1; i < range.end; i += 1) {
|
|
358
|
+
const parsed = parseTomlKeyValue(stripTomlComment(lines[i]).trim());
|
|
359
|
+
if (!parsed) continue;
|
|
360
|
+
if (parsed.key === name) return range;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 更新 agent 配置内容,返回新文本。
|
|
368
|
+
*/
|
|
369
|
+
export function updateAgentContent(content: string, name: string, command: string): string {
|
|
370
|
+
const lines = content.split(/\r?\n/);
|
|
371
|
+
const entryLine = `${name} = ${formatTomlString(command)}`;
|
|
372
|
+
const ranges = collectSectionRanges(lines);
|
|
373
|
+
const agentRanges = ranges.filter(range => isAgentSection(range.name));
|
|
374
|
+
let targetRange = findAgentRangeWithEntry(lines, agentRanges, name);
|
|
375
|
+
|
|
376
|
+
if (!targetRange) {
|
|
377
|
+
targetRange = agentRanges.find(range => range.name === 'agent') ?? agentRanges.find(range => range.name === 'agents') ?? null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!targetRange) {
|
|
381
|
+
const trimmed = content.trimEnd();
|
|
382
|
+
const prefix = trimmed.length > 0 ? `${trimmed}\n\n` : '';
|
|
383
|
+
return `${prefix}[agent]\n${entryLine}\n`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let replaced = false;
|
|
387
|
+
for (let i = targetRange.start + 1; i < targetRange.end; i += 1) {
|
|
388
|
+
const parsed = parseTomlKeyValue(stripTomlComment(lines[i]).trim());
|
|
389
|
+
if (!parsed) continue;
|
|
390
|
+
if (parsed.key === name) {
|
|
391
|
+
lines[i] = entryLine;
|
|
392
|
+
replaced = true;
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!replaced) {
|
|
398
|
+
lines.splice(targetRange.end, 0, entryLine);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const output = lines.join('\n');
|
|
402
|
+
return output.endsWith('\n') ? output : `${output}\n`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 删除 agent 配置内容,返回删除结果与新文本。
|
|
407
|
+
*/
|
|
408
|
+
export function removeAgentContent(content: string, name: string): { removed: boolean; nextContent: string } {
|
|
409
|
+
const lines = content.split(/\r?\n/);
|
|
410
|
+
const ranges = collectSectionRanges(lines).filter(range => isAgentSection(range.name));
|
|
411
|
+
const removeIndices: number[] = [];
|
|
412
|
+
|
|
413
|
+
for (const range of ranges) {
|
|
414
|
+
for (let i = range.start + 1; i < range.end; i += 1) {
|
|
415
|
+
const parsed = parseTomlKeyValue(stripTomlComment(lines[i]).trim());
|
|
416
|
+
if (!parsed) continue;
|
|
417
|
+
if (parsed.key === name) {
|
|
418
|
+
removeIndices.push(i);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (removeIndices.length === 0) {
|
|
424
|
+
return { removed: false, nextContent: ensureTrailingNewline(content) };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
removeIndices
|
|
428
|
+
.sort((a, b) => b - a)
|
|
429
|
+
.forEach(index => {
|
|
430
|
+
lines.splice(index, 1);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const output = lines.join('\n');
|
|
434
|
+
return { removed: true, nextContent: output.endsWith('\n') ? output : `${output}\n` };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 写入或更新 agent 配置。
|
|
439
|
+
*/
|
|
440
|
+
export async function upsertAgentEntry(name: string, command: string, filePath: string = getGlobalConfigPath()): Promise<void> {
|
|
441
|
+
const exists = await fs.pathExists(filePath);
|
|
442
|
+
const content = exists ? await fs.readFile(filePath, 'utf8') : '';
|
|
443
|
+
const nextContent = updateAgentContent(content, name, command);
|
|
444
|
+
await fs.mkdirp(path.dirname(filePath));
|
|
445
|
+
await fs.writeFile(filePath, nextContent, 'utf8');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 删除 agent 配置,返回是否删除成功。
|
|
450
|
+
*/
|
|
451
|
+
export async function removeAgentEntry(name: string, filePath: string = getGlobalConfigPath()): Promise<boolean> {
|
|
452
|
+
const exists = await fs.pathExists(filePath);
|
|
453
|
+
if (!exists) return false;
|
|
454
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
455
|
+
const { removed, nextContent } = removeAgentContent(content, name);
|
|
456
|
+
if (!removed) return false;
|
|
457
|
+
await fs.writeFile(filePath, nextContent, 'utf8');
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
|
|
248
461
|
/**
|
|
249
462
|
* 解析全局 TOML 配置文本。
|
|
250
463
|
*/
|
|
@@ -332,6 +545,44 @@ export function parseAliasEntries(content: string): AliasEntry[] {
|
|
|
332
545
|
return entries;
|
|
333
546
|
}
|
|
334
547
|
|
|
548
|
+
/**
|
|
549
|
+
* 解析 agent 配置条目(支持 [agent]/[agents])。
|
|
550
|
+
*/
|
|
551
|
+
export function parseAgentEntries(content: string): AgentEntry[] {
|
|
552
|
+
const lines = content.split(/\r?\n/);
|
|
553
|
+
let currentSection: string | null = null;
|
|
554
|
+
const entries: AgentEntry[] = [];
|
|
555
|
+
const names = new Set<string>();
|
|
556
|
+
|
|
557
|
+
for (const rawLine of lines) {
|
|
558
|
+
const line = stripTomlComment(rawLine).trim();
|
|
559
|
+
if (!line) continue;
|
|
560
|
+
|
|
561
|
+
const sectionMatch = /^\[(.+)\]$/.exec(line);
|
|
562
|
+
if (sectionMatch) {
|
|
563
|
+
currentSection = sectionMatch[1].trim();
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!isAgentSection(currentSection)) continue;
|
|
568
|
+
const parsed = parseTomlKeyValue(line);
|
|
569
|
+
if (!parsed) continue;
|
|
570
|
+
|
|
571
|
+
const name = normalizeShortcutName(parsed.key);
|
|
572
|
+
const command = parsed.value.trim();
|
|
573
|
+
if (!name || !command) continue;
|
|
574
|
+
if (names.has(name)) continue;
|
|
575
|
+
|
|
576
|
+
names.add(name);
|
|
577
|
+
entries.push({
|
|
578
|
+
name,
|
|
579
|
+
command
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return entries;
|
|
584
|
+
}
|
|
585
|
+
|
|
335
586
|
/**
|
|
336
587
|
* 读取用户目录下的全局配置。
|
|
337
588
|
*/
|
package/src/logs-viewer.ts
CHANGED
|
@@ -144,7 +144,7 @@ async function readLogLines(logFile: string): Promise<string[]> {
|
|
|
144
144
|
|
|
145
145
|
function buildListHeader(state: LogsViewerState, columns: number): string {
|
|
146
146
|
const total = state.logs.length;
|
|
147
|
-
const title = `日志列表(${total} 条)|↑/↓ 选择 Enter 查看 q 退出`;
|
|
147
|
+
const title = `日志列表(${total} 条)|↑/↓ 选择 PageUp/PageDown 翻页 Enter 查看 q 退出`;
|
|
148
148
|
return truncateLine(title, columns);
|
|
149
149
|
}
|
|
150
150
|
|
|
@@ -397,6 +397,18 @@ export async function runLogsViewer(): Promise<void> {
|
|
|
397
397
|
render(state);
|
|
398
398
|
return;
|
|
399
399
|
}
|
|
400
|
+
if (isPageUp(input)) {
|
|
401
|
+
const pageSize = getPageSize(getTerminalSize().rows);
|
|
402
|
+
state.selectedIndex = clampIndex(state.selectedIndex - pageSize, state.logs.length);
|
|
403
|
+
render(state);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (isPageDown(input)) {
|
|
407
|
+
const pageSize = getPageSize(getTerminalSize().rows);
|
|
408
|
+
state.selectedIndex = clampIndex(state.selectedIndex + pageSize, state.logs.length);
|
|
409
|
+
render(state);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
400
412
|
if (isEnter(input)) {
|
|
401
413
|
void openView();
|
|
402
414
|
return;
|
package/src/loop.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { createRunTracker } from './runtime-tracker';
|
|
|
24
24
|
import { buildFallbackSummary, buildSummaryPrompt, ensurePrBodySections, parseDeliverySummary } from './summary';
|
|
25
25
|
import { CheckRunResult, CommitMessage, DeliverySummary, LoopConfig, LoopResult, TestRunResult, TokenUsage, WorkflowFiles, WorktreeResult } from './types';
|
|
26
26
|
import { appendSection, ensureFile, isoNow, readFileSafe, runCommand } from './utils';
|
|
27
|
-
import { buildWebhookPayload, sendWebhookNotifications } from './webhook';
|
|
27
|
+
import { WebhookPayload, buildWebhookPayload, sendWebhookNotifications } from './webhook';
|
|
28
28
|
|
|
29
29
|
async function ensureWorkflowFiles(workflowFiles: WorkflowFiles): Promise<void> {
|
|
30
30
|
await ensureFile(workflowFiles.workflowDoc, '# AI 工作流程基线\n');
|
|
@@ -40,12 +40,16 @@ function trimOutput(output: string, limit = MAX_LOG_LENGTH): string {
|
|
|
40
40
|
return `${output.slice(0, limit)}\n……(输出已截断,原始长度 ${output.length} 字符)`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function truncateText(text: string, limit =
|
|
43
|
+
function truncateText(text: string, limit = 100): string {
|
|
44
44
|
const trimmed = text.trim();
|
|
45
45
|
if (trimmed.length <= limit) return trimmed;
|
|
46
46
|
return `${trimmed.slice(0, limit)}...`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function normalizePlanForWebhook(plan: string): string {
|
|
50
|
+
return plan.replace(/\r\n?/g, '\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
49
53
|
async function safeCommandOutput(command: string, args: string[], cwd: string, logger: Logger, label: string, verboseCommand: string): Promise<string> {
|
|
50
54
|
const result = await runCommand(command, args, {
|
|
51
55
|
cwd,
|
|
@@ -60,6 +64,45 @@ async function safeCommandOutput(command: string, args: string[], cwd: string, l
|
|
|
60
64
|
return result.stdout.trim();
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
function normalizeWebhookUrl(value?: string | null): string {
|
|
68
|
+
if (!value) return '';
|
|
69
|
+
const trimmed = value.trim();
|
|
70
|
+
if (!/^https?:\/\//i.test(trimmed)) return '';
|
|
71
|
+
return trimmed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeRepoUrl(remoteUrl: string): string | null {
|
|
75
|
+
const trimmed = remoteUrl.trim();
|
|
76
|
+
if (!trimmed) return null;
|
|
77
|
+
const withoutGit = trimmed.replace(/\.git$/i, '');
|
|
78
|
+
if (withoutGit.includes('://')) {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = new URL(withoutGit);
|
|
81
|
+
const protocol = parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.protocol : 'https:';
|
|
82
|
+
const pathname = parsed.pathname.replace(/\.git$/i, '');
|
|
83
|
+
return `${protocol}//${parsed.host}${pathname}`.replace(/\/+$/, '');
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const scpMatch = withoutGit.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
|
|
90
|
+
if (!scpMatch) return null;
|
|
91
|
+
const host = scpMatch[1];
|
|
92
|
+
const repoPath = scpMatch[2];
|
|
93
|
+
return `https://${host}/${repoPath}`.replace(/\/+$/, '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function resolveCommitLink(cwd: string, logger: Logger): Promise<string> {
|
|
97
|
+
const sha = await safeCommandOutput('git', ['rev-parse', 'HEAD'], cwd, logger, 'git', 'git rev-parse HEAD');
|
|
98
|
+
if (!sha) return '';
|
|
99
|
+
const remote = await safeCommandOutput('git', ['remote', 'get-url', 'origin'], cwd, logger, 'git', 'git remote get-url origin');
|
|
100
|
+
if (!remote) return '';
|
|
101
|
+
const repoUrl = normalizeRepoUrl(remote);
|
|
102
|
+
if (!repoUrl) return '';
|
|
103
|
+
return `${repoUrl}/commit/${sha}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
63
106
|
async function runSingleTest(kind: 'unit' | 'e2e', command: string, cwd: string, logger: Logger): Promise<TestRunResult> {
|
|
64
107
|
const label = kind === 'unit' ? '单元测试' : 'e2e 测试';
|
|
65
108
|
logger.info(`执行${label}: ${command}`);
|
|
@@ -289,17 +332,43 @@ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
|
|
|
289
332
|
let prInfo: GhPrInfo | null = null;
|
|
290
333
|
let prFailed = false;
|
|
291
334
|
let sessionIndex = 0;
|
|
335
|
+
let commitLink = '';
|
|
336
|
+
let prLink = '';
|
|
337
|
+
let commitCreated = false;
|
|
338
|
+
let pushSucceeded = false;
|
|
292
339
|
|
|
293
340
|
const preWorktreeRecords: string[] = [];
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
341
|
+
const resolveProjectName = (): string => path.basename(workDir);
|
|
342
|
+
|
|
343
|
+
const notifyWebhook = async (
|
|
344
|
+
event: 'task_start' | 'iteration_start' | 'task_end',
|
|
345
|
+
iteration: number,
|
|
346
|
+
stage: string,
|
|
347
|
+
plan?: string
|
|
348
|
+
): Promise<void> => {
|
|
349
|
+
const project = resolveProjectName();
|
|
350
|
+
const payload = event === 'task_start'
|
|
351
|
+
? buildWebhookPayload({
|
|
352
|
+
event,
|
|
353
|
+
task: config.task,
|
|
354
|
+
branch: branchName,
|
|
355
|
+
iteration,
|
|
356
|
+
stage,
|
|
357
|
+
project,
|
|
358
|
+
commit: commitLink,
|
|
359
|
+
pr: prLink,
|
|
360
|
+
plan
|
|
361
|
+
})
|
|
362
|
+
: buildWebhookPayload({
|
|
363
|
+
event,
|
|
364
|
+
branch: branchName,
|
|
365
|
+
iteration,
|
|
366
|
+
stage,
|
|
367
|
+
project,
|
|
368
|
+
commit: commitLink,
|
|
369
|
+
pr: prLink,
|
|
370
|
+
plan
|
|
371
|
+
});
|
|
303
372
|
await sendWebhookNotifications(config.webhooks, payload, logger);
|
|
304
373
|
};
|
|
305
374
|
|
|
@@ -396,7 +465,10 @@ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
|
|
|
396
465
|
extras?: { testResults?: TestRunResult[]; checkResults?: CheckRunResult[]; cwd?: string }
|
|
397
466
|
): Promise<void> => {
|
|
398
467
|
sessionIndex += 1;
|
|
399
|
-
|
|
468
|
+
const webhookPlan = stage === '计划生成'
|
|
469
|
+
? normalizePlanForWebhook(await readFileSafe(workflowFiles.planFile))
|
|
470
|
+
: undefined;
|
|
471
|
+
await notifyWebhook('iteration_start', sessionIndex, stage, webhookPlan);
|
|
400
472
|
logger.info(`${stage} 提示构建完成,调用 AI CLI...`);
|
|
401
473
|
|
|
402
474
|
const aiResult = await runAi(prompt, aiConfig, logger, extras?.cwd ?? workDir);
|
|
@@ -633,6 +705,7 @@ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
|
|
|
633
705
|
};
|
|
634
706
|
try {
|
|
635
707
|
const committed = await commitAll(commitMessage, workDir, logger);
|
|
708
|
+
commitCreated = committed;
|
|
636
709
|
deliveryNotes.push(committed ? `自动提交:已提交(${commitMessage.title})` : '自动提交:未生成提交(可能无变更或提交失败)');
|
|
637
710
|
} catch (error) {
|
|
638
711
|
deliveryNotes.push(`自动提交:失败(${String(error)})`);
|
|
@@ -650,6 +723,7 @@ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
|
|
|
650
723
|
} else {
|
|
651
724
|
try {
|
|
652
725
|
await pushBranch(branchName, workDir, logger);
|
|
726
|
+
pushSucceeded = true;
|
|
653
727
|
deliveryNotes.push(`自动推送:已推送(${branchName})`);
|
|
654
728
|
} catch (error) {
|
|
655
729
|
deliveryNotes.push(`自动推送:失败(${String(error)})`);
|
|
@@ -659,6 +733,10 @@ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
|
|
|
659
733
|
deliveryNotes.push('自动推送:未开启');
|
|
660
734
|
}
|
|
661
735
|
|
|
736
|
+
if (commitCreated && pushSucceeded) {
|
|
737
|
+
commitLink = await resolveCommitLink(workDir, logger);
|
|
738
|
+
}
|
|
739
|
+
|
|
662
740
|
if (config.pr.enable) {
|
|
663
741
|
if (lastTestFailed) {
|
|
664
742
|
deliveryNotes.push('PR 创建:已跳过(测试未通过)');
|
|
@@ -719,6 +797,8 @@ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
|
|
|
719
797
|
deliveryNotes.push('PR 创建:未开启(缺少分支名)');
|
|
720
798
|
}
|
|
721
799
|
|
|
800
|
+
prLink = normalizeWebhookUrl(prInfo?.url);
|
|
801
|
+
|
|
722
802
|
if (deliveryNotes.length > 0) {
|
|
723
803
|
const record = formatSystemRecord('提交与PR', deliveryNotes.join('\n'), isoNow());
|
|
724
804
|
await appendSection(workflowFiles.notesFile, record);
|
package/src/utils.ts
CHANGED
|
@@ -122,6 +122,19 @@ export function isoNow(): string {
|
|
|
122
122
|
return new Date().toISOString();
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* 返回本地时区时间戳(YYYYMMDD-HHmmss)。
|
|
127
|
+
*/
|
|
128
|
+
export function localTimestamp(date: Date = new Date()): string {
|
|
129
|
+
const year = date.getFullYear();
|
|
130
|
+
const month = pad2(date.getMonth() + 1);
|
|
131
|
+
const day = pad2(date.getDate());
|
|
132
|
+
const hours = pad2(date.getHours());
|
|
133
|
+
const minutes = pad2(date.getMinutes());
|
|
134
|
+
const seconds = pad2(date.getSeconds());
|
|
135
|
+
return `${year}${month}${day}-${hours}${minutes}${seconds}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
125
138
|
/**
|
|
126
139
|
* 基于 cwd 解析相对路径。
|
|
127
140
|
*/
|
package/src/webhook.ts
CHANGED
|
@@ -1,27 +1,57 @@
|
|
|
1
1
|
import { Logger } from './logger';
|
|
2
|
-
import {
|
|
2
|
+
import { localTimestamp } from './utils';
|
|
3
3
|
import type { WebhookConfig } from './types';
|
|
4
4
|
|
|
5
5
|
export type WebhookEvent = 'task_start' | 'iteration_start' | 'task_end';
|
|
6
6
|
|
|
7
|
-
export interface
|
|
7
|
+
export interface WebhookPayloadBase {
|
|
8
8
|
readonly event: WebhookEvent;
|
|
9
|
-
readonly task: string;
|
|
10
9
|
readonly branch: string;
|
|
11
10
|
readonly iteration: number;
|
|
12
11
|
readonly stage: string;
|
|
13
12
|
readonly timestamp: string;
|
|
13
|
+
readonly project: string;
|
|
14
|
+
readonly commit: string;
|
|
15
|
+
readonly pr: string;
|
|
16
|
+
/**
|
|
17
|
+
* 计划内容,仅在 iteration_start 且 stage 为“计划生成”时携带(换行已标准化为 \n)。
|
|
18
|
+
*/
|
|
19
|
+
readonly plan?: string;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
export
|
|
22
|
+
export type WebhookPayload =
|
|
23
|
+
| (WebhookPayloadBase & {
|
|
24
|
+
readonly event: 'task_start';
|
|
25
|
+
readonly task: string;
|
|
26
|
+
})
|
|
27
|
+
| (WebhookPayloadBase & {
|
|
28
|
+
readonly event: 'iteration_start' | 'task_end';
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export interface WebhookPayloadInputBase {
|
|
17
32
|
readonly event: WebhookEvent;
|
|
18
|
-
readonly task: string;
|
|
19
33
|
readonly branch?: string;
|
|
20
34
|
readonly iteration: number;
|
|
21
35
|
readonly stage: string;
|
|
22
36
|
readonly timestamp?: string;
|
|
37
|
+
readonly project: string;
|
|
38
|
+
readonly commit?: string;
|
|
39
|
+
readonly pr?: string;
|
|
40
|
+
/**
|
|
41
|
+
* 计划内容,仅在 iteration_start 且 stage 为“计划生成”时输入(换行已标准化为 \n)。
|
|
42
|
+
*/
|
|
43
|
+
readonly plan?: string;
|
|
23
44
|
}
|
|
24
45
|
|
|
46
|
+
export type WebhookPayloadInput =
|
|
47
|
+
| (WebhookPayloadInputBase & {
|
|
48
|
+
readonly event: 'task_start';
|
|
49
|
+
readonly task: string;
|
|
50
|
+
})
|
|
51
|
+
| (WebhookPayloadInputBase & {
|
|
52
|
+
readonly event: 'iteration_start' | 'task_end';
|
|
53
|
+
});
|
|
54
|
+
|
|
25
55
|
export type FetchLikeResponse = {
|
|
26
56
|
readonly ok: boolean;
|
|
27
57
|
readonly status: number;
|
|
@@ -45,13 +75,28 @@ export function normalizeWebhookUrls(urls?: string[]): string[] {
|
|
|
45
75
|
}
|
|
46
76
|
|
|
47
77
|
export function buildWebhookPayload(input: WebhookPayloadInput): WebhookPayload {
|
|
48
|
-
|
|
49
|
-
event: input.event,
|
|
50
|
-
task: input.task,
|
|
78
|
+
const base = {
|
|
51
79
|
branch: input.branch ?? '',
|
|
52
80
|
iteration: input.iteration,
|
|
53
81
|
stage: input.stage,
|
|
54
|
-
timestamp: input.timestamp ??
|
|
82
|
+
timestamp: input.timestamp ?? localTimestamp(),
|
|
83
|
+
project: input.project,
|
|
84
|
+
commit: input.commit ?? '',
|
|
85
|
+
pr: input.pr ?? '',
|
|
86
|
+
...(input.plan !== undefined ? { plan: input.plan } : {})
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (input.event === 'task_start') {
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
event: 'task_start',
|
|
93
|
+
task: input.task
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
...base,
|
|
99
|
+
event: input.event
|
|
55
100
|
};
|
|
56
101
|
}
|
|
57
102
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
3
5
|
import path from 'node:path';
|
|
4
6
|
import { promisify } from 'node:util';
|
|
5
7
|
import { test } from 'node:test';
|
|
@@ -78,17 +80,53 @@ test('CLI logs 在非 TTY 下输出提示', async () => {
|
|
|
78
80
|
assert.ok(stdout.includes('当前终端不支持交互式 logs。'));
|
|
79
81
|
});
|
|
80
82
|
|
|
81
|
-
test('CLI
|
|
83
|
+
test('CLI agent 帮助信息可正常输出', async () => {
|
|
82
84
|
const execFileAsync = promisify(execFile);
|
|
83
85
|
const cliPath = path.join(process.cwd(), 'src', 'cli.ts');
|
|
84
|
-
const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, '
|
|
86
|
+
const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, 'agent', '--help'], {
|
|
85
87
|
env: {
|
|
86
88
|
...process.env,
|
|
87
89
|
FORCE_COLOR: '0'
|
|
88
90
|
}
|
|
89
91
|
});
|
|
90
92
|
|
|
91
|
-
assert.ok(stdout.includes('Usage: wheel-ai
|
|
93
|
+
assert.ok(stdout.includes('Usage: wheel-ai agent'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('CLI agent list 输出配置内容', async () => {
|
|
97
|
+
const execFileAsync = promisify(execFile);
|
|
98
|
+
const cliPath = path.join(process.cwd(), 'src', 'cli.ts');
|
|
99
|
+
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wheel-ai-agent-'));
|
|
100
|
+
const configDir = path.join(homeDir, '.wheel-ai');
|
|
101
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
102
|
+
await fs.writeFile(
|
|
103
|
+
path.join(configDir, 'config.toml'),
|
|
104
|
+
`[agent]\nclaude = "claude --model sonnet"\n`,
|
|
105
|
+
'utf8'
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, 'agent', 'list'], {
|
|
109
|
+
env: {
|
|
110
|
+
...process.env,
|
|
111
|
+
HOME: homeDir,
|
|
112
|
+
FORCE_COLOR: '0'
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
assert.ok(stdout.includes('claude: claude --model sonnet'));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('CLI alias set 帮助信息可正常输出', async () => {
|
|
120
|
+
const execFileAsync = promisify(execFile);
|
|
121
|
+
const cliPath = path.join(process.cwd(), 'src', 'cli.ts');
|
|
122
|
+
const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, 'alias', 'set', '--help'], {
|
|
123
|
+
env: {
|
|
124
|
+
...process.env,
|
|
125
|
+
FORCE_COLOR: '0'
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
assert.ok(stdout.includes('Usage: wheel-ai alias set'));
|
|
92
130
|
});
|
|
93
131
|
|
|
94
132
|
test('CLI alias 帮助信息可正常输出', async () => {
|