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.
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/glab-agent.mjs +18 -0
- package/package.json +59 -0
- package/src/local-agent/agent-config.ts +315 -0
- package/src/local-agent/agent-provider.ts +59 -0
- package/src/local-agent/agent-runner.ts +244 -0
- package/src/local-agent/claude-runner.ts +136 -0
- package/src/local-agent/cli.ts +1497 -0
- package/src/local-agent/codex-runner.ts +153 -0
- package/src/local-agent/gitlab-glab-client.ts +722 -0
- package/src/local-agent/health-server.ts +56 -0
- package/src/local-agent/heartbeat.ts +33 -0
- package/src/local-agent/log-rotate.ts +56 -0
- package/src/local-agent/logger.ts +92 -0
- package/src/local-agent/metrics.ts +51 -0
- package/src/local-agent/mr-actions.ts +121 -0
- package/src/local-agent/notifier.ts +190 -0
- package/src/local-agent/process-manager.ts +193 -0
- package/src/local-agent/reply-runner.ts +111 -0
- package/src/local-agent/repo-cache.ts +144 -0
- package/src/local-agent/report.ts +183 -0
- package/src/local-agent/skill-import.ts +344 -0
- package/src/local-agent/skill-inject.ts +109 -0
- package/src/local-agent/skill-parse.ts +47 -0
- package/src/local-agent/smoke-test.ts +443 -0
- package/src/local-agent/state-store.ts +186 -0
- package/src/local-agent/token-check.ts +37 -0
- package/src/local-agent/watcher.ts +1226 -0
- package/src/local-agent/wiki-sync.ts +290 -0
- package/src/local-agent/worktree-manager.ts +141 -0
- package/src/text.ts +16 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createServer, type Server } from "node:http";
|
|
2
|
+
|
|
3
|
+
export interface HealthStatus {
|
|
4
|
+
agent: string;
|
|
5
|
+
status: "idle" | "busy" | "offline";
|
|
6
|
+
pid: number;
|
|
7
|
+
uptime: number; // seconds since start
|
|
8
|
+
cycleCount: number;
|
|
9
|
+
activeIssue?: number; // issueIid if busy
|
|
10
|
+
lastError?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HealthServerOptions {
|
|
14
|
+
port: number;
|
|
15
|
+
agent: string;
|
|
16
|
+
getStatus: () => HealthStatus;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function startHealthServer(options: HealthServerOptions): { server: Server; close: () => void } {
|
|
20
|
+
const { port, getStatus } = options;
|
|
21
|
+
|
|
22
|
+
const server = createServer((req, res) => {
|
|
23
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
24
|
+
const status = getStatus();
|
|
25
|
+
const body = JSON.stringify(status);
|
|
26
|
+
res.writeHead(200, {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
"Content-Length": Buffer.byteLength(body),
|
|
29
|
+
});
|
|
30
|
+
res.end(body);
|
|
31
|
+
} else {
|
|
32
|
+
const body = JSON.stringify({ error: "not found" });
|
|
33
|
+
res.writeHead(404, {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
"Content-Length": Buffer.byteLength(body),
|
|
36
|
+
});
|
|
37
|
+
res.end(body);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
42
|
+
if (err.code === "EADDRINUSE") {
|
|
43
|
+
console.warn(`[health-server] Port ${port} already in use, health endpoint not available`);
|
|
44
|
+
} else {
|
|
45
|
+
console.warn(`[health-server] Error: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
server.listen(port, "0.0.0.0");
|
|
50
|
+
|
|
51
|
+
function close(): void {
|
|
52
|
+
server.close();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { server, close };
|
|
56
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface HeartbeatData {
|
|
5
|
+
timestamp: string;
|
|
6
|
+
cycleCount: number;
|
|
7
|
+
lastError?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function writeHeartbeat(heartbeatPath: string, data: HeartbeatData): Promise<void> {
|
|
11
|
+
try {
|
|
12
|
+
await mkdir(path.dirname(heartbeatPath), { recursive: true });
|
|
13
|
+
await writeFile(heartbeatPath, JSON.stringify(data), "utf8");
|
|
14
|
+
} catch {
|
|
15
|
+
// Heartbeat write failure must never affect main workflow
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function readHeartbeat(heartbeatPath: string): Promise<HeartbeatData | undefined> {
|
|
20
|
+
try {
|
|
21
|
+
const content = await readFile(heartbeatPath, "utf8");
|
|
22
|
+
return JSON.parse(content) as HeartbeatData;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isHeartbeatStale(heartbeat: HeartbeatData, pollIntervalSeconds: number, maxMissedCycles = 3): boolean {
|
|
29
|
+
const heartbeatTime = new Date(heartbeat.timestamp).getTime();
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const thresholdMs = pollIntervalSeconds * maxMissedCycles * 1000;
|
|
32
|
+
return (now - heartbeatTime) > thresholdMs;
|
|
33
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { stat, rename, unlink } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export interface LogRotateOptions {
|
|
4
|
+
maxBytes: number; // default 10 * 1024 * 1024 (10MB)
|
|
5
|
+
maxFiles: number; // default 5
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_OPTIONS: LogRotateOptions = {
|
|
9
|
+
maxBytes: 10 * 1024 * 1024,
|
|
10
|
+
maxFiles: 5,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if a log file needs rotation and perform it if needed.
|
|
15
|
+
* Rotation scheme: file → file.1 → file.2 → ... → file.N (oldest deleted)
|
|
16
|
+
*
|
|
17
|
+
* @returns true if rotation was performed
|
|
18
|
+
*/
|
|
19
|
+
export async function rotateIfNeeded(
|
|
20
|
+
filePath: string,
|
|
21
|
+
options?: Partial<LogRotateOptions>
|
|
22
|
+
): Promise<boolean> {
|
|
23
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const stats = await stat(filePath);
|
|
27
|
+
if (stats.size < opts.maxBytes) return false;
|
|
28
|
+
} catch {
|
|
29
|
+
return false; // File doesn't exist
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Delete oldest rotated file
|
|
33
|
+
try {
|
|
34
|
+
await unlink(`${filePath}.${opts.maxFiles}`);
|
|
35
|
+
} catch {
|
|
36
|
+
/* doesn't exist */
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Shift existing rotated files: .4 → .5, .3 → .4, etc.
|
|
40
|
+
for (let i = opts.maxFiles - 1; i >= 1; i--) {
|
|
41
|
+
try {
|
|
42
|
+
await rename(`${filePath}.${i}`, `${filePath}.${i + 1}`);
|
|
43
|
+
} catch {
|
|
44
|
+
/* doesn't exist */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Rotate current file to .1
|
|
49
|
+
try {
|
|
50
|
+
await rename(filePath, `${filePath}.1`);
|
|
51
|
+
} catch {
|
|
52
|
+
/* race condition, ignore */
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export interface Logger {
|
|
2
|
+
info(...args: unknown[]): void;
|
|
3
|
+
warn(...args: unknown[]): void;
|
|
4
|
+
error(...args: unknown[]): void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type LogFormat = "text" | "json";
|
|
8
|
+
|
|
9
|
+
export interface LogEntry {
|
|
10
|
+
timestamp: string;
|
|
11
|
+
level: "info" | "warn" | "error";
|
|
12
|
+
agent?: string;
|
|
13
|
+
module: string;
|
|
14
|
+
message: string;
|
|
15
|
+
context?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function timestamp(): string {
|
|
19
|
+
return new Date().toLocaleString("zh-CN", {
|
|
20
|
+
timeZone: "Asia/Shanghai",
|
|
21
|
+
year: "numeric",
|
|
22
|
+
month: "2-digit",
|
|
23
|
+
day: "2-digit",
|
|
24
|
+
hour: "2-digit",
|
|
25
|
+
minute: "2-digit",
|
|
26
|
+
second: "2-digit"
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatLogEntry(entry: LogEntry, format: LogFormat): string {
|
|
31
|
+
if (format === "json") {
|
|
32
|
+
return JSON.stringify(entry);
|
|
33
|
+
}
|
|
34
|
+
// text format: [timestamp] [agent] [module] message
|
|
35
|
+
const prefix = entry.agent ? `[${entry.agent}] [${entry.module}]` : `[${entry.module}]`;
|
|
36
|
+
const contextStr = entry.context ? ` ${JSON.stringify(entry.context)}` : "";
|
|
37
|
+
return `[${entry.timestamp}] ${prefix} ${entry.message}${contextStr}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates a tagged logger.
|
|
42
|
+
*
|
|
43
|
+
* Output format (text):
|
|
44
|
+
* [2026/04/06 14:33:25] [claude-coder] [watcher] message
|
|
45
|
+
* [2026/04/06 14:33:25] [claude-coder] [runner] message
|
|
46
|
+
*
|
|
47
|
+
* Output format (json):
|
|
48
|
+
* {"timestamp":"...","level":"info","agent":"claude-coder","module":"watcher","message":"..."}
|
|
49
|
+
*
|
|
50
|
+
* @param module Short name for the calling module (e.g. "watcher", "runner", "worktree")
|
|
51
|
+
* @param agent Optional agent name included after the timestamp for multi-agent disambiguation
|
|
52
|
+
* @param format Output format: "text" (default, human-readable) or "json" (JSON Lines / NDJSON)
|
|
53
|
+
*/
|
|
54
|
+
export function createLogger(module: string, agent?: string, format: LogFormat = "text"): Logger {
|
|
55
|
+
const makeEntry = (level: "info" | "warn" | "error", args: unknown[]): LogEntry => {
|
|
56
|
+
// If last arg is a plain object, treat as context
|
|
57
|
+
let context: Record<string, unknown> | undefined;
|
|
58
|
+
let messageArgs = args;
|
|
59
|
+
if (
|
|
60
|
+
args.length > 0 &&
|
|
61
|
+
typeof args[args.length - 1] === "object" &&
|
|
62
|
+
args[args.length - 1] !== null &&
|
|
63
|
+
!Array.isArray(args[args.length - 1])
|
|
64
|
+
) {
|
|
65
|
+
context = args[args.length - 1] as Record<string, unknown>;
|
|
66
|
+
messageArgs = args.slice(0, -1);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
timestamp: timestamp(),
|
|
70
|
+
level,
|
|
71
|
+
agent,
|
|
72
|
+
module,
|
|
73
|
+
message: messageArgs.map(String).join(" "),
|
|
74
|
+
context
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
info: (...args: unknown[]) => {
|
|
80
|
+
const entry = makeEntry("info", args);
|
|
81
|
+
console.info(formatLogEntry(entry, format));
|
|
82
|
+
},
|
|
83
|
+
warn: (...args: unknown[]) => {
|
|
84
|
+
const entry = makeEntry("warn", args);
|
|
85
|
+
console.warn(formatLogEntry(entry, format));
|
|
86
|
+
},
|
|
87
|
+
error: (...args: unknown[]) => {
|
|
88
|
+
const entry = makeEntry("error", args);
|
|
89
|
+
console.error(formatLogEntry(entry, format));
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export interface MetricEvent {
|
|
7
|
+
timestamp: string;
|
|
8
|
+
agent: string;
|
|
9
|
+
provider: string;
|
|
10
|
+
event: "run_start" | "run_complete" | "run_failed" | "run_timeout" | "cycle_error";
|
|
11
|
+
issueIid?: number;
|
|
12
|
+
issueTitle?: string;
|
|
13
|
+
durationMs?: number;
|
|
14
|
+
branch?: string;
|
|
15
|
+
mrUrl?: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function appendMetric(metricsDir: string, agentName: string, event: MetricEvent): Promise<void> {
|
|
20
|
+
try {
|
|
21
|
+
await mkdir(metricsDir, { recursive: true });
|
|
22
|
+
const filePath = path.join(metricsDir, `${agentName}.jsonl`);
|
|
23
|
+
await appendFile(filePath, JSON.stringify(event) + "\n", "utf8");
|
|
24
|
+
} catch {
|
|
25
|
+
// Metrics write failure must never affect main workflow
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readMetrics(metricsDir: string, agentName: string): Promise<MetricEvent[]> {
|
|
30
|
+
const filePath = path.join(metricsDir, `${agentName}.jsonl`);
|
|
31
|
+
const events: MetricEvent[] = [];
|
|
32
|
+
try {
|
|
33
|
+
const rl = createInterface({ input: createReadStream(filePath, "utf8"), crlfDelay: Infinity });
|
|
34
|
+
for await (const line of rl) {
|
|
35
|
+
if (line.trim()) {
|
|
36
|
+
try {
|
|
37
|
+
events.push(JSON.parse(line) as MetricEvent);
|
|
38
|
+
} catch {
|
|
39
|
+
// Skip malformed lines
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// File not found or read error
|
|
45
|
+
}
|
|
46
|
+
return events;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function metricsPath(repoPath: string): string {
|
|
50
|
+
return path.join(repoPath, ".glab-agent", "metrics");
|
|
51
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { GitlabClient, GitlabTodoItem } from "./gitlab-glab-client.js";
|
|
2
|
+
import type { Logger } from "./logger.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Types
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export type MrIntent = "rebase" | "contextual";
|
|
9
|
+
|
|
10
|
+
export type MrActionResult =
|
|
11
|
+
| { action: "rebase"; success: boolean; message: string }
|
|
12
|
+
| { action: "contextual"; message: string };
|
|
13
|
+
|
|
14
|
+
export interface ReplyContext {
|
|
15
|
+
targetType: "MergeRequest" | "Issue";
|
|
16
|
+
targetIid: number;
|
|
17
|
+
targetTitle: string;
|
|
18
|
+
targetDescription?: string;
|
|
19
|
+
mentionBody: string;
|
|
20
|
+
recentNotes: string[];
|
|
21
|
+
gitlabHost: string;
|
|
22
|
+
projectId: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MrActionDependencies {
|
|
26
|
+
gitlabClient: Pick<GitlabClient, "addMergeRequestNote" | "markTodoDone" | "getMergeRequestNotes">;
|
|
27
|
+
rebaseBranch: (branch: string) => Promise<void>;
|
|
28
|
+
generateReply: (context: ReplyContext) => Promise<string>;
|
|
29
|
+
projectId: number;
|
|
30
|
+
gitlabHost: string;
|
|
31
|
+
logger?: Logger;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Intent parsing
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export function parseMrIntent(body: string | undefined): MrIntent {
|
|
39
|
+
if (!body) return "contextual";
|
|
40
|
+
if (/\brebase\b/i.test(body)) return "rebase";
|
|
41
|
+
return "contextual";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// MR todo filtering
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export function filterMrTodos(
|
|
49
|
+
todos: GitlabTodoItem[],
|
|
50
|
+
processedTodoIds: Set<number>,
|
|
51
|
+
acceptedActions: Set<string>
|
|
52
|
+
): GitlabTodoItem[] {
|
|
53
|
+
return todos.filter(
|
|
54
|
+
(todo) =>
|
|
55
|
+
todo.targetType === "MergeRequest" &&
|
|
56
|
+
todo.state === "pending" &&
|
|
57
|
+
acceptedActions.has(todo.actionName) &&
|
|
58
|
+
!processedTodoIds.has(todo.id)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Handle a single MR todo
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export async function handleMrTodo(
|
|
67
|
+
todo: GitlabTodoItem,
|
|
68
|
+
deps: MrActionDependencies
|
|
69
|
+
): Promise<MrActionResult> {
|
|
70
|
+
const logger = deps.logger ?? console;
|
|
71
|
+
const mrIid = todo.targetIid ?? 0;
|
|
72
|
+
const intent = parseMrIntent(todo.body);
|
|
73
|
+
|
|
74
|
+
if (intent === "rebase" && todo.sourceBranch) {
|
|
75
|
+
try {
|
|
76
|
+
await deps.rebaseBranch(todo.sourceBranch);
|
|
77
|
+
const note = `已完成 rebase(分支 \`${todo.sourceBranch}\`)。`;
|
|
78
|
+
await deps.gitlabClient.addMergeRequestNote(deps.projectId, mrIid, note);
|
|
79
|
+
logger.info(`MR !${mrIid}: rebase succeeded for branch ${todo.sourceBranch}`);
|
|
80
|
+
return { action: "rebase", success: true, message: note };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const reason = String(error).slice(0, 300);
|
|
83
|
+
const note = `rebase 失败:${reason}`;
|
|
84
|
+
try {
|
|
85
|
+
await deps.gitlabClient.addMergeRequestNote(deps.projectId, mrIid, note);
|
|
86
|
+
} catch { /* silent */ }
|
|
87
|
+
logger.warn(`MR !${mrIid}: rebase failed: ${reason}`);
|
|
88
|
+
return { action: "rebase", success: false, message: note };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Contextual reply — collect context and delegate to AI
|
|
93
|
+
try {
|
|
94
|
+
const notes = await deps.gitlabClient.getMergeRequestNotes(deps.projectId, mrIid);
|
|
95
|
+
const recentNotes = notes
|
|
96
|
+
.filter((n) => !n.system)
|
|
97
|
+
.slice(-10)
|
|
98
|
+
.map((n) => n.body);
|
|
99
|
+
|
|
100
|
+
const reply = await deps.generateReply({
|
|
101
|
+
targetType: "MergeRequest",
|
|
102
|
+
targetIid: mrIid,
|
|
103
|
+
targetTitle: `MR !${mrIid}`,
|
|
104
|
+
mentionBody: todo.body ?? "",
|
|
105
|
+
recentNotes,
|
|
106
|
+
gitlabHost: deps.gitlabHost,
|
|
107
|
+
projectId: deps.projectId
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await deps.gitlabClient.addMergeRequestNote(deps.projectId, mrIid, reply);
|
|
111
|
+
logger.info(`MR !${mrIid}: posted contextual reply`);
|
|
112
|
+
return { action: "contextual", message: reply };
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const fallback = "收到!但处理你的请求时遇到了问题,请稍后重试或在 Issue 中 @我。";
|
|
115
|
+
try {
|
|
116
|
+
await deps.gitlabClient.addMergeRequestNote(deps.projectId, mrIid, fallback);
|
|
117
|
+
} catch { /* silent */ }
|
|
118
|
+
logger.warn(`MR !${mrIid}: contextual reply failed: ${String(error).slice(0, 300)}`);
|
|
119
|
+
return { action: "contextual", message: fallback };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export interface NotifyOptions {
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
message: string;
|
|
10
|
+
url?: string; // URL to open when notification is clicked
|
|
11
|
+
sound?: boolean; // default true
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Cache the terminal-notifier availability check
|
|
15
|
+
let terminalNotifierAvailable: boolean | undefined;
|
|
16
|
+
|
|
17
|
+
async function hasTerminalNotifier(): Promise<boolean> {
|
|
18
|
+
if (terminalNotifierAvailable !== undefined) return terminalNotifierAvailable;
|
|
19
|
+
try {
|
|
20
|
+
await execFileAsync("which", ["terminal-notifier"]);
|
|
21
|
+
terminalNotifierAvailable = true;
|
|
22
|
+
} catch {
|
|
23
|
+
terminalNotifierAvailable = false;
|
|
24
|
+
}
|
|
25
|
+
return terminalNotifierAvailable;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Reset cached availability — for testing only */
|
|
29
|
+
export function _resetTerminalNotifierCache(): void {
|
|
30
|
+
terminalNotifierAvailable = undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function sendNotification(options: NotifyOptions): Promise<void> {
|
|
34
|
+
const { title, subtitle, message, url, sound = true } = options;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
if (process.platform !== "darwin") return; // macOS only
|
|
38
|
+
|
|
39
|
+
if (await hasTerminalNotifier()) {
|
|
40
|
+
const args = ["-title", title, "-message", message];
|
|
41
|
+
if (subtitle) args.push("-subtitle", subtitle);
|
|
42
|
+
if (url) args.push("-open", url);
|
|
43
|
+
if (sound) args.push("-sound", "default");
|
|
44
|
+
args.push("-group", "glab-agent"); // prevent notification stacking
|
|
45
|
+
await execFileAsync("terminal-notifier", args);
|
|
46
|
+
} else {
|
|
47
|
+
// Fallback to osascript
|
|
48
|
+
const escape = (s: string) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
49
|
+
let script = `display notification "${escape(message)}" with title "${escape(title)}"`;
|
|
50
|
+
if (subtitle) script += ` subtitle "${escape(subtitle)}"`;
|
|
51
|
+
if (sound) script += ` sound name "default"`;
|
|
52
|
+
await execFileAsync("osascript", ["-e", script]);
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Notification failures must never affect the main workflow
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
60
|
+
// Webhook notification
|
|
61
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export interface WebhookPayload {
|
|
64
|
+
title: string;
|
|
65
|
+
message: string;
|
|
66
|
+
url?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isFeishuUrl(url: string): boolean {
|
|
70
|
+
return url.includes("feishu.cn") || url.includes("larksuite.com");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isSlackUrl(url: string): boolean {
|
|
74
|
+
return url.includes("hooks.slack.com");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildWebhookBody(url: string, payload: WebhookPayload): unknown {
|
|
78
|
+
const text = payload.url
|
|
79
|
+
? `${payload.title}\n${payload.message}\n${payload.url}`
|
|
80
|
+
: `${payload.title}\n${payload.message}`;
|
|
81
|
+
|
|
82
|
+
if (isFeishuUrl(url)) {
|
|
83
|
+
return {
|
|
84
|
+
msg_type: "text",
|
|
85
|
+
content: { text }
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (isSlackUrl(url)) {
|
|
90
|
+
return { text };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Generic webhook — send a structured JSON body
|
|
94
|
+
return {
|
|
95
|
+
title: payload.title,
|
|
96
|
+
message: payload.message,
|
|
97
|
+
url: payload.url
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function sendWebhook(
|
|
102
|
+
webhookUrl: string,
|
|
103
|
+
payload: WebhookPayload
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
try {
|
|
106
|
+
const body = buildWebhookBody(webhookUrl, payload);
|
|
107
|
+
const response = await fetch(webhookUrl, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
body: JSON.stringify(body),
|
|
111
|
+
signal: AbortSignal.timeout(10_000)
|
|
112
|
+
});
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
// Read body to prevent connection leak, but ignore content
|
|
115
|
+
await response.text().catch(() => {});
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Webhook failures must never affect the main workflow
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
// Pre-defined notification templates
|
|
124
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export function notifyAccepted(
|
|
127
|
+
agentName: string,
|
|
128
|
+
issueIid: number,
|
|
129
|
+
issueTitle: string,
|
|
130
|
+
webhookUrl?: string
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
const title = `\u{1F916} ${agentName} 已接单`;
|
|
133
|
+
const message = `#${issueIid} ${issueTitle}`;
|
|
134
|
+
const promises: Promise<void>[] = [
|
|
135
|
+
sendNotification({
|
|
136
|
+
title: `\u{1F916} ${agentName}`,
|
|
137
|
+
subtitle: "\u5DF2\u63A5\u5355",
|
|
138
|
+
message,
|
|
139
|
+
})
|
|
140
|
+
];
|
|
141
|
+
if (webhookUrl) {
|
|
142
|
+
promises.push(sendWebhook(webhookUrl, { title, message }));
|
|
143
|
+
}
|
|
144
|
+
return Promise.all(promises).then(() => {});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function notifyCompleted(
|
|
148
|
+
agentName: string,
|
|
149
|
+
issueIid: number,
|
|
150
|
+
issueTitle: string,
|
|
151
|
+
mrUrl?: string,
|
|
152
|
+
webhookUrl?: string
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const title = `\u2705 ${agentName} 已完成`;
|
|
155
|
+
const message = `#${issueIid} ${issueTitle}`;
|
|
156
|
+
const promises: Promise<void>[] = [
|
|
157
|
+
sendNotification({
|
|
158
|
+
title: `\u2705 ${agentName}`,
|
|
159
|
+
subtitle: "\u5DF2\u5B8C\u6210",
|
|
160
|
+
message,
|
|
161
|
+
url: mrUrl,
|
|
162
|
+
})
|
|
163
|
+
];
|
|
164
|
+
if (webhookUrl) {
|
|
165
|
+
promises.push(sendWebhook(webhookUrl, { title, message, url: mrUrl }));
|
|
166
|
+
}
|
|
167
|
+
return Promise.all(promises).then(() => {});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function notifyFailed(
|
|
171
|
+
agentName: string,
|
|
172
|
+
issueIid: number,
|
|
173
|
+
issueTitle: string,
|
|
174
|
+
summary: string,
|
|
175
|
+
webhookUrl?: string
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
const title = `\u274C ${agentName} 执行失败`;
|
|
178
|
+
const message = `#${issueIid} ${issueTitle}\n${summary.slice(0, 100)}`;
|
|
179
|
+
const promises: Promise<void>[] = [
|
|
180
|
+
sendNotification({
|
|
181
|
+
title: `\u274C ${agentName}`,
|
|
182
|
+
subtitle: "\u6267\u884C\u5931\u8D25",
|
|
183
|
+
message,
|
|
184
|
+
})
|
|
185
|
+
];
|
|
186
|
+
if (webhookUrl) {
|
|
187
|
+
promises.push(sendWebhook(webhookUrl, { title, message }));
|
|
188
|
+
}
|
|
189
|
+
return Promise.all(promises).then(() => {});
|
|
190
|
+
}
|