glab-agent 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.
@@ -0,0 +1,193 @@
1
+ import { readFile, writeFile, unlink, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { execFile, spawn } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import os from "node:os";
6
+
7
+ import { agentPidPath, agentLogPath, agentStateDir } from "./agent-config.js";
8
+ import { rotateIfNeeded } from "./log-rotate.js";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ export async function readPid(repoPath: string, agentName: string): Promise<number | undefined> {
13
+ try {
14
+ const content = await readFile(agentPidPath(repoPath, agentName), "utf8");
15
+ const pid = Number.parseInt(content.trim(), 10);
16
+ return Number.isNaN(pid) ? undefined : pid;
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ export async function writePid(repoPath: string, agentName: string, pid: number): Promise<void> {
23
+ const pidFile = agentPidPath(repoPath, agentName);
24
+ await mkdir(path.dirname(pidFile), { recursive: true });
25
+ await writeFile(pidFile, String(pid), "utf8");
26
+ }
27
+
28
+ export async function removePid(repoPath: string, agentName: string): Promise<void> {
29
+ try {
30
+ await unlink(agentPidPath(repoPath, agentName));
31
+ } catch {
32
+ // ignore
33
+ }
34
+ }
35
+
36
+ export function isPidAlive(pid: number): boolean {
37
+ try {
38
+ process.kill(pid, 0);
39
+ return true;
40
+ } catch (error) {
41
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
42
+ }
43
+ }
44
+
45
+ export interface AgentProcessStatus {
46
+ name: string;
47
+ pid?: number;
48
+ alive: boolean;
49
+ }
50
+
51
+ export async function getAgentStatus(repoPath: string, agentName: string): Promise<AgentProcessStatus> {
52
+ const pid = await readPid(repoPath, agentName);
53
+ return {
54
+ name: agentName,
55
+ pid,
56
+ alive: pid !== undefined && isPidAlive(pid)
57
+ };
58
+ }
59
+
60
+ export async function startAgent(
61
+ repoPath: string,
62
+ agentName: string,
63
+ scriptDir: string,
64
+ installDir: string
65
+ ): Promise<number> {
66
+ const existing = await getAgentStatus(repoPath, agentName);
67
+ if (existing.alive) {
68
+ throw new Error(`Agent "${agentName}" is already running (PID ${existing.pid}).`);
69
+ }
70
+
71
+ const logDir = agentLogPath(repoPath, agentName);
72
+ await mkdir(logDir, { recursive: true });
73
+ await mkdir(agentStateDir(repoPath), { recursive: true });
74
+
75
+ const tsxPath = path.join(installDir, "node_modules", ".bin", "tsx");
76
+ const child = spawn(
77
+ tsxPath,
78
+ [path.join(scriptDir, "watcher.ts"), "watch", "--agent", agentName, "--project", repoPath],
79
+ {
80
+ cwd: installDir,
81
+ stdio: ["ignore", "pipe", "pipe"],
82
+ detached: true,
83
+ env: { ...process.env, AGENT_PROJECT_DIR: repoPath }
84
+ }
85
+ );
86
+
87
+ // Rotate log files if they exceed size threshold before opening
88
+ await rotateIfNeeded(path.join(logDir, "watcher.log"));
89
+ await rotateIfNeeded(path.join(logDir, "watcher.err"));
90
+
91
+ // Redirect stdout/stderr to log files
92
+ const { createWriteStream } = await import("node:fs");
93
+ const outLog = createWriteStream(path.join(logDir, "watcher.log"), { flags: "a" });
94
+ const errLog = createWriteStream(path.join(logDir, "watcher.err"), { flags: "a" });
95
+ child.stdout?.pipe(outLog);
96
+ child.stderr?.pipe(errLog);
97
+
98
+ child.unref();
99
+
100
+ if (child.pid === undefined) {
101
+ throw new Error(`Failed to start agent "${agentName}".`);
102
+ }
103
+
104
+ await writePid(repoPath, agentName, child.pid);
105
+ return child.pid;
106
+ }
107
+
108
+ export async function stopAgent(repoPath: string, agentName: string): Promise<boolean> {
109
+ const status = await getAgentStatus(repoPath, agentName);
110
+ if (!status.alive || status.pid === undefined) {
111
+ await removePid(repoPath, agentName);
112
+ return false;
113
+ }
114
+
115
+ // Send SIGTERM to the process group so both tsx and the child node process
116
+ // receive it. startAgent uses detached:true which makes tsx the group leader.
117
+ try {
118
+ process.kill(-status.pid, "SIGTERM");
119
+ } catch {
120
+ try {
121
+ process.kill(status.pid, "SIGTERM");
122
+ } catch {
123
+ // already dead
124
+ }
125
+ }
126
+
127
+ // Wait for the process to actually die (gives shutdown handler time to
128
+ // update GitLab status to offline before we clean up the PID file).
129
+ const deadline = Date.now() + 10_000;
130
+ while (Date.now() < deadline && isPidAlive(status.pid)) {
131
+ await new Promise((resolve) => setTimeout(resolve, 200));
132
+ }
133
+
134
+ await removePid(repoPath, agentName);
135
+ return true;
136
+ }
137
+
138
+ // ── LaunchD Plist Generation ─────────────────────────────────────────────────
139
+
140
+ export function generateLaunchdPlist(
141
+ agentName: string,
142
+ repoPath: string,
143
+ scriptDir: string,
144
+ installDir: string
145
+ ): string {
146
+ const projectName = path.basename(path.resolve(repoPath));
147
+ const label = `com.glab-agent.${projectName}.${agentName}`;
148
+ const logDir = agentLogPath(repoPath, agentName);
149
+ const tsxPath = path.join(installDir, "node_modules", ".bin", "tsx");
150
+
151
+ return `<?xml version="1.0" encoding="UTF-8"?>
152
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
153
+ <plist version="1.0">
154
+ <dict>
155
+ <key>Label</key>
156
+ <string>${label}</string>
157
+ <key>ProgramArguments</key>
158
+ <array>
159
+ <string>${tsxPath}</string>
160
+ <string>${path.join(scriptDir, "watcher.ts")}</string>
161
+ <string>watch</string>
162
+ <string>--agent</string>
163
+ <string>${agentName}</string>
164
+ <string>--project</string>
165
+ <string>${repoPath}</string>
166
+ </array>
167
+ <key>WorkingDirectory</key>
168
+ <string>${installDir}</string>
169
+ <key>EnvironmentVariables</key>
170
+ <dict>
171
+ <key>PATH</key>
172
+ <string>${path.dirname(process.execPath)}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.local/bin</string>
173
+ <key>AGENT_PROJECT_DIR</key>
174
+ <string>${repoPath}</string>
175
+ </dict>
176
+ <key>RunAtLoad</key>
177
+ <true/>
178
+ <key>KeepAlive</key>
179
+ <true/>
180
+ <key>ExitTimeOut</key>
181
+ <integer>15</integer>
182
+ <key>StandardOutPath</key>
183
+ <string>${path.join(logDir, "watcher.log")}</string>
184
+ <key>StandardErrorPath</key>
185
+ <string>${path.join(logDir, "watcher.err")}</string>
186
+ </dict>
187
+ </plist>`;
188
+ }
189
+
190
+ export function launchdPlistPath(agentName: string, repoPath: string): string {
191
+ const projectName = path.basename(path.resolve(repoPath));
192
+ return path.join(os.homedir(), "Library", "LaunchAgents", `com.glab-agent.${projectName}.${agentName}.plist`);
193
+ }
@@ -0,0 +1,111 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ import { compactText, truncate } from "../text.js";
5
+ import { resolveClaudeCommand } from "./claude-runner.js";
6
+ import type { ReplyContext } from "./mr-actions.js";
7
+ import { createLogger, type Logger } from "./logger.js";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ export interface ReplyRunnerOptions {
12
+ execFileImpl?: typeof execFileAsync;
13
+ env?: NodeJS.ProcessEnv;
14
+ repoPath: string;
15
+ logger?: Logger;
16
+ }
17
+
18
+ export class ReplyRunner {
19
+ private readonly execFileImpl: typeof execFileAsync;
20
+
21
+ private readonly env: NodeJS.ProcessEnv;
22
+
23
+ private readonly repoPath: string;
24
+
25
+ private readonly logger: Logger;
26
+
27
+ constructor(options: ReplyRunnerOptions) {
28
+ this.execFileImpl = options.execFileImpl ?? execFileAsync;
29
+ this.env = options.env ?? process.env;
30
+ this.repoPath = options.repoPath;
31
+ this.logger = options.logger ?? createLogger("reply");
32
+ }
33
+
34
+ async generateReply(context: ReplyContext): Promise<string> {
35
+ const prompt = buildReplyPrompt(context);
36
+ const claudeCommand = resolveClaudeCommand(this.env);
37
+
38
+ this.logger.info(`Reply for ${context.targetType} !${context.targetIid} (${prompt.length} chars)`);
39
+
40
+ try {
41
+ const { stdout } = await this.execFileImpl(
42
+ claudeCommand,
43
+ ["-p", "--output-format", "text", "--permission-mode", "bypassPermissions", prompt],
44
+ {
45
+ cwd: this.repoPath,
46
+ encoding: "utf8",
47
+ maxBuffer: 10 * 1024 * 1024,
48
+ env: this.env
49
+ }
50
+ );
51
+
52
+ return truncate(compactText(stdout || "(无回复内容)"), 2000);
53
+ } catch (error) {
54
+ const execError = error as { stdout?: string; stderr?: string; message?: string };
55
+ this.logger.error(`Reply generation failed: ${execError.stderr?.slice(0, 300) ?? String(error).slice(0, 300)}`);
56
+ // If Claude produced partial output, use that
57
+ if (execError.stdout) {
58
+ return truncate(compactText(execError.stdout), 2000);
59
+ }
60
+ throw error;
61
+ }
62
+ }
63
+ }
64
+
65
+ export function buildReplyPrompt(ctx: ReplyContext): string {
66
+ const targetLabel = ctx.targetType === "MergeRequest" ? `MR !${ctx.targetIid}` : `Issue #${ctx.targetIid}`;
67
+ const notesBlock = ctx.recentNotes.length > 0
68
+ ? ctx.recentNotes.map((n, i) => ` ${i + 1}. ${truncate(n, 300)}`).join("\n")
69
+ : " (无历史评论)";
70
+
71
+ const descriptionBlock = ctx.targetDescription
72
+ ? `\n## ${ctx.targetType === "MergeRequest" ? "MR" : "Issue"} 描述\n${truncate(ctx.targetDescription, 2000)}\n`
73
+ : "";
74
+
75
+ return [
76
+ `你是这个项目的团队成员(AI agent),有人在 ${targetLabel} 上 @mention 了你。`,
77
+ "你在项目仓库中工作,可以读取代码、文档和配置来理解项目。",
78
+ "",
79
+ "## 你的身份",
80
+ "像一个熟悉项目的资深开发者一样思考和回复。",
81
+ "参考项目的设计原则(docs/design-principles.md)、产品定位(README.md)和现有架构来回应。",
82
+ "不要机械地分析代码 diff,要站在产品和团队的角度回答。",
83
+ "",
84
+ "## 上下文",
85
+ `- ${targetLabel}: ${ctx.targetTitle}`,
86
+ `- @mention 内容: ${ctx.mentionBody}`,
87
+ `- GitLab: ${ctx.gitlabHost} | Project ID: ${ctx.projectId}`,
88
+ descriptionBlock,
89
+ "## 最近评论",
90
+ notesBlock,
91
+ "",
92
+ "## 查询更多信息",
93
+ ctx.targetType === "MergeRequest"
94
+ ? [
95
+ `- MR 详情: glab api projects/${ctx.projectId}/merge_requests/${ctx.targetIid}`,
96
+ `- MR 变更: glab api projects/${ctx.projectId}/merge_requests/${ctx.targetIid}/changes`,
97
+ `- MR 讨论: glab api projects/${ctx.projectId}/merge_requests/${ctx.targetIid}/notes`
98
+ ].join("\n")
99
+ : [
100
+ `- Issue 详情: glab api projects/${ctx.projectId}/issues/${ctx.targetIid}`,
101
+ `- Issue 评论: glab api projects/${ctx.projectId}/issues/${ctx.targetIid}/notes`
102
+ ].join("\n"),
103
+ "",
104
+ "## 输出要求",
105
+ "- 输出就是将要发到 GitLab 的回复,用 Markdown 格式。",
106
+ "- 不要输出内部思考过程(如 'Let me think...'、'Now I have...')。",
107
+ "- 结构清晰:用标题、列表、粗体组织,方便在 GitLab 上阅读。",
108
+ "- 不要修改代码或创建文件。",
109
+ "- 如果 @mention 要求评估需求或方案,给出你作为团队成员的判断和建议。"
110
+ ].join("\n");
111
+ }
@@ -0,0 +1,144 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, readdir, stat, rm } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ export interface RepoCacheOptions {
10
+ cacheDir: string; // e.g., ".glab-agent/repo-cache"
11
+ execFileImpl?: typeof execFileAsync;
12
+ }
13
+
14
+ export interface CachedRepo {
15
+ repoPath: string; // path to the cached bare clone
16
+ repoUrl: string; // original URL
17
+ lastUsed: Date;
18
+ }
19
+
20
+ export class RepoCache {
21
+ private readonly cacheDir: string;
22
+ private readonly execFileImpl: typeof execFileAsync;
23
+
24
+ constructor(options: RepoCacheOptions) {
25
+ this.cacheDir = options.cacheDir;
26
+ this.execFileImpl = options.execFileImpl ?? execFileAsync;
27
+ }
28
+
29
+ /**
30
+ * Get or create a cached bare clone of the given repo URL.
31
+ * If already cached, just fetches updates. If not, does a bare clone.
32
+ * Returns the path to the cached bare repo.
33
+ */
34
+ async ensureRepo(repoUrl: string): Promise<string> {
35
+ await mkdir(this.cacheDir, { recursive: true });
36
+
37
+ const key = RepoCache.cacheKey(repoUrl);
38
+ const repoPath = path.join(this.cacheDir, `${key}.git`);
39
+
40
+ let exists = false;
41
+ try {
42
+ await stat(repoPath);
43
+ exists = true;
44
+ } catch {
45
+ exists = false;
46
+ }
47
+
48
+ if (exists) {
49
+ await this.runGit(repoPath, ["fetch", "--all"]);
50
+ } else {
51
+ await this.runGit(this.cacheDir, ["clone", "--bare", repoUrl, `${key}.git`]);
52
+ }
53
+
54
+ return repoPath;
55
+ }
56
+
57
+ /**
58
+ * Create a worktree from a cached repo.
59
+ * Returns the worktree path.
60
+ */
61
+ async createWorktree(repoUrl: string, branch: string, worktreePath: string): Promise<string> {
62
+ const repoPath = await this.ensureRepo(repoUrl);
63
+ await this.runGit(repoPath, ["worktree", "add", worktreePath, branch]);
64
+ return worktreePath;
65
+ }
66
+
67
+ /**
68
+ * List all cached repos with their sizes and last-used dates.
69
+ */
70
+ async listCached(): Promise<CachedRepo[]> {
71
+ let entries: string[];
72
+ try {
73
+ entries = await readdir(this.cacheDir);
74
+ } catch {
75
+ return [];
76
+ }
77
+
78
+ const results: CachedRepo[] = [];
79
+
80
+ for (const entry of entries) {
81
+ if (!entry.endsWith(".git")) continue;
82
+
83
+ const repoPath = path.join(this.cacheDir, entry);
84
+
85
+ let lastUsed: Date;
86
+ try {
87
+ const statResult = await stat(repoPath);
88
+ lastUsed = statResult.mtime;
89
+ } catch {
90
+ continue;
91
+ }
92
+
93
+ let repoUrl = "";
94
+ try {
95
+ const output = await this.runGit(repoPath, ["config", "--get", "remote.origin.url"]);
96
+ repoUrl = output.trim();
97
+ } catch {
98
+ // If we can't read the URL, skip
99
+ continue;
100
+ }
101
+
102
+ results.push({ repoPath, repoUrl, lastUsed });
103
+ }
104
+
105
+ return results;
106
+ }
107
+
108
+ /**
109
+ * Remove a cached repo by URL.
110
+ */
111
+ async evict(repoUrl: string): Promise<boolean> {
112
+ const key = RepoCache.cacheKey(repoUrl);
113
+ const repoPath = path.join(this.cacheDir, `${key}.git`);
114
+
115
+ try {
116
+ await stat(repoPath);
117
+ } catch {
118
+ return false;
119
+ }
120
+
121
+ await rm(repoPath, { recursive: true, force: true });
122
+ return true;
123
+ }
124
+
125
+ /**
126
+ * Get the cache directory path for a given repo URL.
127
+ * Uses SHA-256 hash of the URL for the directory name.
128
+ */
129
+ static cacheKey(repoUrl: string): string {
130
+ return createHash("sha256").update(repoUrl).digest("hex").slice(0, 16);
131
+ }
132
+
133
+ private async runGit(cwd: string, args: string[]): Promise<string> {
134
+ try {
135
+ const { stdout } = await this.execFileImpl("git", ["-C", cwd, ...args], {
136
+ encoding: "utf8",
137
+ maxBuffer: 10 * 1024 * 1024
138
+ });
139
+ return stdout;
140
+ } catch (error) {
141
+ throw new Error(`git command failed (${args.join(" ")}): ${String(error)}`);
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,183 @@
1
+ import { readMetrics } from "./metrics.js";
2
+ import type { MetricEvent } from "./metrics.js";
3
+
4
+ export interface ReportOptions {
5
+ sinceDays: number; // default 7
6
+ }
7
+
8
+ export interface ReportResult {
9
+ agentName: string;
10
+ period: { from: Date; to: Date };
11
+ total: number;
12
+ completed: number;
13
+ failed: number;
14
+ timedOut: number;
15
+ cycleErrors: number;
16
+ successRate: number; // 0-100
17
+ avgDurationMs: number | null; // completed only
18
+ topFailures: { error: string; count: number }[];
19
+ recentRuns: {
20
+ event: string;
21
+ issueIid?: number;
22
+ issueTitle?: string;
23
+ durationMs?: number;
24
+ timestamp: string;
25
+ error?: string;
26
+ }[];
27
+ }
28
+
29
+ export async function generateReport(
30
+ metricsDir: string,
31
+ agentName: string,
32
+ options: ReportOptions
33
+ ): Promise<ReportResult> {
34
+ const { sinceDays } = options;
35
+ const now = new Date();
36
+ const sinceDate = new Date(now.getTime() - sinceDays * 24 * 60 * 60 * 1000);
37
+
38
+ const allEvents = await readMetrics(metricsDir, agentName);
39
+ const events = allEvents.filter((e) => new Date(e.timestamp) >= sinceDate);
40
+
41
+ let completed = 0;
42
+ let failed = 0;
43
+ let timedOut = 0;
44
+ let cycleErrors = 0;
45
+ const completedDurations: number[] = [];
46
+ const failureMap = new Map<string, number>();
47
+
48
+ for (const e of events) {
49
+ if (e.event === "run_complete") {
50
+ completed++;
51
+ if (e.durationMs !== undefined) {
52
+ completedDurations.push(e.durationMs);
53
+ }
54
+ } else if (e.event === "run_failed") {
55
+ failed++;
56
+ const errorKey = e.error ?? "Unknown error";
57
+ failureMap.set(errorKey, (failureMap.get(errorKey) ?? 0) + 1);
58
+ } else if (e.event === "run_timeout") {
59
+ timedOut++;
60
+ const errorKey = e.error ?? "Timeout";
61
+ failureMap.set(errorKey, (failureMap.get(errorKey) ?? 0) + 1);
62
+ } else if (e.event === "cycle_error") {
63
+ cycleErrors++;
64
+ }
65
+ }
66
+
67
+ const denominator = completed + failed + timedOut;
68
+ const successRate = denominator > 0 ? (completed / denominator) * 100 : 0;
69
+
70
+ const avgDurationMs =
71
+ completedDurations.length > 0
72
+ ? completedDurations.reduce((a, b) => a + b, 0) / completedDurations.length
73
+ : null;
74
+
75
+ const topFailures = Array.from(failureMap.entries())
76
+ .map(([error, count]) => ({ error, count }))
77
+ .sort((a, b) => b.count - a.count)
78
+ .slice(0, 5);
79
+
80
+ const runEvents = events.filter(
81
+ (e) => e.event === "run_complete" || e.event === "run_failed" || e.event === "run_timeout"
82
+ );
83
+ // Sort newest first and take last 10
84
+ const recentRuns = runEvents
85
+ .slice()
86
+ .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
87
+ .slice(0, 10)
88
+ .map((e) => ({
89
+ event: e.event,
90
+ issueIid: e.issueIid,
91
+ issueTitle: e.issueTitle,
92
+ durationMs: e.durationMs,
93
+ timestamp: e.timestamp,
94
+ error: e.error
95
+ }));
96
+
97
+ return {
98
+ agentName,
99
+ period: { from: sinceDate, to: now },
100
+ total: completed + failed + timedOut,
101
+ completed,
102
+ failed,
103
+ timedOut,
104
+ cycleErrors,
105
+ successRate,
106
+ avgDurationMs,
107
+ topFailures,
108
+ recentRuns
109
+ };
110
+ }
111
+
112
+ export function formatMs(ms: number): string {
113
+ const totalSeconds = Math.round(ms / 1000);
114
+ const hours = Math.floor(totalSeconds / 3600);
115
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
116
+ const seconds = totalSeconds % 60;
117
+ if (hours > 0) return `${hours}h ${minutes}m`;
118
+ if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
119
+ return `${seconds}s`;
120
+ }
121
+
122
+ export function formatReport(result: ReportResult): string {
123
+ const { agentName, period, total, completed, failed, timedOut, cycleErrors, successRate, avgDurationMs, topFailures, recentRuns } = result;
124
+
125
+ if (total === 0) {
126
+ const sinceDays = Math.round(
127
+ (period.to.getTime() - period.from.getTime()) / (24 * 60 * 60 * 1000)
128
+ );
129
+ return `No metrics data found for agent '${agentName}' in the last ${sinceDays} days.`;
130
+ }
131
+
132
+ const sinceDays = Math.round(
133
+ (period.to.getTime() - period.from.getTime()) / (24 * 60 * 60 * 1000)
134
+ );
135
+
136
+ const lines: string[] = [];
137
+ const header = `📊 Agent Report: ${agentName} (last ${sinceDays} days)`;
138
+ lines.push(header);
139
+ lines.push("═".repeat(header.length));
140
+ lines.push("");
141
+
142
+ // Counts line
143
+ const timeoutPart = timedOut > 0 ? ` | ${timedOut} timeout` : "";
144
+ const cyclePart = cycleErrors > 0 ? ` | ${cycleErrors} cycle_error` : "";
145
+ lines.push(`Runs: ${total} total | ${completed} completed | ${failed} failed${timeoutPart}${cyclePart}`);
146
+
147
+ // Success rate
148
+ lines.push(`Success rate: ${successRate.toFixed(1)}%`);
149
+
150
+ // Avg duration
151
+ if (avgDurationMs !== null) {
152
+ lines.push(`Avg duration: ${formatMs(avgDurationMs)} (completed only)`);
153
+ }
154
+
155
+ // Top failures
156
+ if (topFailures.length > 0) {
157
+ lines.push("");
158
+ lines.push("Top failures:");
159
+ for (let i = 0; i < topFailures.length; i++) {
160
+ const { error, count } = topFailures[i];
161
+ const plural = count === 1 ? "occurrence" : "occurrences";
162
+ lines.push(` ${i + 1}. ${error} (${count} ${plural})`);
163
+ }
164
+ }
165
+
166
+ // Recent runs
167
+ if (recentRuns.length > 0) {
168
+ lines.push("");
169
+ lines.push("Recent runs:");
170
+ for (const run of recentRuns) {
171
+ const icon = run.event === "run_complete" ? "✅" : "❌";
172
+ const issueStr = run.issueIid !== undefined ? `#${run.issueIid}` : "(no issue)";
173
+ const rawTitle = run.issueTitle ?? "(no title)";
174
+ const title = rawTitle.length > 40 ? rawTitle.slice(0, 37) + "..." : rawTitle;
175
+ const titlePadded = `"${title}"`.padEnd(44);
176
+ const durationStr = run.durationMs !== undefined ? formatMs(run.durationMs) : "-";
177
+ const dateStr = run.timestamp.slice(0, 10);
178
+ lines.push(` ${icon} ${issueStr.padEnd(6)} ${titlePadded} ${durationStr.padEnd(8)} ${dateStr}`);
179
+ }
180
+ }
181
+
182
+ return lines.join("\n");
183
+ }