glab-agent 0.2.5 → 0.2.7
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/package.json +1 -1
- package/src/local-agent/agent-config.ts +8 -1
- package/src/local-agent/agent-runner.ts +44 -0
- package/src/local-agent/claude-runner.ts +189 -2
- package/src/local-agent/codex-runner.ts +107 -2
- package/src/local-agent/feishu-client.ts +226 -0
- package/src/local-agent/gitlab-glab-client.ts +138 -2
- package/src/local-agent/notifier.ts +71 -8
- package/src/local-agent/watcher.ts +572 -12
package/package.json
CHANGED
|
@@ -31,6 +31,10 @@ export interface AgentGitlabConfig {
|
|
|
31
31
|
|
|
32
32
|
export interface AgentNotifications {
|
|
33
33
|
webhook_url?: string;
|
|
34
|
+
feishu_app_id?: string;
|
|
35
|
+
feishu_app_secret?: string;
|
|
36
|
+
/** Email domain for mapping GitLab username → Feishu email (e.g. "taou.com" → "{username}@taou.com") */
|
|
37
|
+
feishu_email_domain?: string;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
export interface AgentLabelConfig {
|
|
@@ -126,7 +130,10 @@ function parseNotifications(raw: unknown): AgentNotifications {
|
|
|
126
130
|
if (typeof raw !== "object" || raw === null) return {};
|
|
127
131
|
const obj = raw as RawValue;
|
|
128
132
|
return {
|
|
129
|
-
webhook_url: typeof obj.webhook_url === "string" ? obj.webhook_url : undefined
|
|
133
|
+
webhook_url: typeof obj.webhook_url === "string" ? obj.webhook_url : undefined,
|
|
134
|
+
feishu_app_id: typeof obj.feishu_app_id === "string" ? obj.feishu_app_id : undefined,
|
|
135
|
+
feishu_app_secret: typeof obj.feishu_app_secret === "string" ? obj.feishu_app_secret : undefined,
|
|
136
|
+
feishu_email_domain: typeof obj.feishu_email_domain === "string" ? obj.feishu_email_domain : undefined,
|
|
130
137
|
};
|
|
131
138
|
}
|
|
132
139
|
|
|
@@ -24,10 +24,54 @@ export interface AgentRunContext {
|
|
|
24
24
|
signal?: AbortSignal;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export interface SpawnedRunResult {
|
|
28
|
+
exitCode: number | null;
|
|
29
|
+
summary: string;
|
|
30
|
+
outputPath: string;
|
|
31
|
+
/** Set if non-zero exit */
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AgentProgressEvent {
|
|
36
|
+
type: "tool_use" | "tool_result" | "text" | "status";
|
|
37
|
+
tool?: string; // Read, Edit, Write, Bash, Grep, Glob, etc.
|
|
38
|
+
detail?: string; // file path, command, text snippet
|
|
39
|
+
timestamp: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ProgressCallback = (event: AgentProgressEvent) => void;
|
|
43
|
+
|
|
44
|
+
export interface SpawnedRun {
|
|
45
|
+
pid: number;
|
|
46
|
+
outputPath: string;
|
|
47
|
+
/** Called to check if process has exited. Returns result if done, undefined if still running. */
|
|
48
|
+
poll(): Promise<SpawnedRunResult | undefined>;
|
|
49
|
+
/** Kill the process */
|
|
50
|
+
kill(): void;
|
|
51
|
+
/** Set a callback to receive real-time progress events (stream-json mode) */
|
|
52
|
+
onProgress?: ProgressCallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
27
55
|
export interface AgentRunner {
|
|
28
56
|
run(issue: GitlabIssue, worktree: WorktreeInfo, context?: AgentRunContext): Promise<AgentRunResult>;
|
|
29
57
|
/** Run a lightweight contextual reply — no worktree, read-only. Optional: falls back to full_work if absent. */
|
|
30
58
|
runContextual?(issue: GitlabIssue, todoBody: string, context?: AgentRunContext): Promise<AgentRunResult>;
|
|
59
|
+
/** Start the agent process without waiting for it to finish. Returns a handle for polling. */
|
|
60
|
+
spawn?(issue: GitlabIssue, worktree: WorktreeInfo, context?: AgentRunContext): Promise<SpawnedRun>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Wrap a completed run() result as a SpawnedRun (for smoke tests or fallback).
|
|
65
|
+
*/
|
|
66
|
+
export function createCompletedSpawnedRun(result: AgentRunResult): SpawnedRun {
|
|
67
|
+
return {
|
|
68
|
+
pid: process.pid,
|
|
69
|
+
outputPath: result.outputPath ?? "",
|
|
70
|
+
async poll(): Promise<SpawnedRunResult> {
|
|
71
|
+
return { exitCode: 0, summary: result.summary, outputPath: result.outputPath ?? "" };
|
|
72
|
+
},
|
|
73
|
+
kill() { /* no-op, already completed */ }
|
|
74
|
+
};
|
|
31
75
|
}
|
|
32
76
|
|
|
33
77
|
export interface AgentRunnerOptions {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
1
|
+
import { execFile, spawn as spawnChild } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
5
|
import { promisify } from "node:util";
|
|
4
6
|
|
|
5
7
|
import { compactText, truncate } from "../text.js";
|
|
@@ -11,8 +13,12 @@ import {
|
|
|
11
13
|
type AgentRunResult,
|
|
12
14
|
type AgentRunner,
|
|
13
15
|
type AgentRunnerOptions,
|
|
16
|
+
type ProgressCallback,
|
|
17
|
+
type SpawnedRun,
|
|
18
|
+
type SpawnedRunResult,
|
|
14
19
|
buildAgentPrompt,
|
|
15
20
|
buildContextualPrompt,
|
|
21
|
+
createCompletedSpawnedRun,
|
|
16
22
|
createOutputPath,
|
|
17
23
|
isSmokeIssue,
|
|
18
24
|
runSmokeTestChecks
|
|
@@ -132,6 +138,187 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
132
138
|
}
|
|
133
139
|
}
|
|
134
140
|
|
|
141
|
+
async spawn(issue: GitlabIssue, worktree: WorktreeInfo, context?: AgentRunContext): Promise<SpawnedRun> {
|
|
142
|
+
const outputPath = await createOutputPath("claude", issue.iid);
|
|
143
|
+
|
|
144
|
+
if (isSmokeIssue(issue)) {
|
|
145
|
+
const result = await this.run(issue, worktree, context);
|
|
146
|
+
return createCompletedSpawnedRun(result);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const prompt = buildAgentPrompt({
|
|
150
|
+
issueIid: issue.iid,
|
|
151
|
+
issueTitle: issue.title,
|
|
152
|
+
gitlabHost: this.gitlabHost,
|
|
153
|
+
gitlabProjectId: this.gitlabProjectId,
|
|
154
|
+
worktreePath: worktree.worktreePath,
|
|
155
|
+
branch: worktree.branch,
|
|
156
|
+
provider: "claude",
|
|
157
|
+
todoId: context?.todoId,
|
|
158
|
+
preamble: this.agentDefinition?.prompt.preamble,
|
|
159
|
+
append: this.agentDefinition?.prompt.append,
|
|
160
|
+
labels: this.agentDefinition?.labels,
|
|
161
|
+
});
|
|
162
|
+
const claudeCommand = resolveClaudeCommand(this.env);
|
|
163
|
+
|
|
164
|
+
const args = ["-p", "--output-format", "stream-json", "--verbose", "--permission-mode", "bypassPermissions"];
|
|
165
|
+
if (this.agentDefinition?.model) {
|
|
166
|
+
args.push("--model", this.agentDefinition.model);
|
|
167
|
+
}
|
|
168
|
+
args.push(prompt);
|
|
169
|
+
|
|
170
|
+
this.logger.info(`Spawning: ${claudeCommand} -p ... cwd=${worktree.worktreePath}`);
|
|
171
|
+
this.logger.info(`Prompt (${prompt.length} chars): issue #${issue.iid} "${issue.title}"`);
|
|
172
|
+
|
|
173
|
+
const outStream = createWriteStream(outputPath);
|
|
174
|
+
const child = spawnChild(claudeCommand, args, {
|
|
175
|
+
cwd: worktree.worktreePath,
|
|
176
|
+
env: this.env,
|
|
177
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
178
|
+
detached: false,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
let progressCallback: ProgressCallback | undefined;
|
|
182
|
+
let lastResult = ""; // capture the final result text
|
|
183
|
+
|
|
184
|
+
const rl = createInterface({ input: child.stdout!, crlfDelay: Infinity });
|
|
185
|
+
|
|
186
|
+
rl.on("line", (line: string) => {
|
|
187
|
+
// Write raw line to output file for debugging
|
|
188
|
+
outStream.write(line + "\n");
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const event = JSON.parse(line) as Record<string, unknown>;
|
|
192
|
+
|
|
193
|
+
// Extract final result
|
|
194
|
+
if (event.type === "result" && typeof event.result === "string") {
|
|
195
|
+
lastResult = event.result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Parse progress events
|
|
199
|
+
if (event.type === "assistant" && progressCallback) {
|
|
200
|
+
const message = event.message as Record<string, unknown> | undefined;
|
|
201
|
+
const content = message?.content as Array<Record<string, unknown>> | undefined;
|
|
202
|
+
if (Array.isArray(content)) {
|
|
203
|
+
for (const block of content) {
|
|
204
|
+
if (block.type === "tool_use") {
|
|
205
|
+
const toolName = String(block.name ?? "");
|
|
206
|
+
const input = block.input as Record<string, unknown> | undefined;
|
|
207
|
+
let detail = "";
|
|
208
|
+
|
|
209
|
+
if (toolName === "Read" || toolName === "Edit" || toolName === "Write") {
|
|
210
|
+
detail = String(input?.file_path ?? "").split("/").pop() ?? "";
|
|
211
|
+
} else if (toolName === "Bash") {
|
|
212
|
+
const cmd = String(input?.command ?? "").slice(0, 80);
|
|
213
|
+
detail = cmd;
|
|
214
|
+
} else if (toolName === "Grep" || toolName === "Glob") {
|
|
215
|
+
detail = String(input?.pattern ?? input?.glob ?? "");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
progressCallback({
|
|
219
|
+
type: "tool_use",
|
|
220
|
+
tool: toolName,
|
|
221
|
+
detail,
|
|
222
|
+
timestamp: new Date().toISOString()
|
|
223
|
+
});
|
|
224
|
+
} else if (block.type === "text" && typeof block.text === "string") {
|
|
225
|
+
progressCallback({
|
|
226
|
+
type: "text",
|
|
227
|
+
detail: block.text.slice(0, 200),
|
|
228
|
+
timestamp: new Date().toISOString()
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
// Not JSON — write as-is (shouldn't happen with stream-json)
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
let stderrBuf = "";
|
|
240
|
+
child.stderr?.on("data", (chunk: Buffer) => { stderrBuf += chunk.toString(); });
|
|
241
|
+
|
|
242
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
243
|
+
if (this.timeoutSeconds) {
|
|
244
|
+
timeoutHandle = setTimeout(() => {
|
|
245
|
+
this.logger.error(`Spawn timed out after ${this.timeoutSeconds}s, killing PID ${child.pid}`);
|
|
246
|
+
child.kill("SIGTERM");
|
|
247
|
+
}, this.timeoutSeconds * 1000);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let exitCode: number | null | undefined;
|
|
251
|
+
let exited = false;
|
|
252
|
+
|
|
253
|
+
child.on("exit", (code) => {
|
|
254
|
+
exitCode = code;
|
|
255
|
+
exited = true;
|
|
256
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
257
|
+
outStream.end();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (child.pid === undefined) {
|
|
261
|
+
outStream.end();
|
|
262
|
+
throw new AgentRunnerError("Failed to spawn Claude Code process.", "Spawn failed: no PID", outputPath);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const pid = child.pid;
|
|
266
|
+
const logger = this.logger;
|
|
267
|
+
|
|
268
|
+
const spawnedRun: SpawnedRun = {
|
|
269
|
+
pid,
|
|
270
|
+
outputPath,
|
|
271
|
+
set onProgress(cb: ProgressCallback | undefined) {
|
|
272
|
+
progressCallback = cb;
|
|
273
|
+
},
|
|
274
|
+
async poll(): Promise<SpawnedRunResult | undefined> {
|
|
275
|
+
if (!exited) return undefined;
|
|
276
|
+
|
|
277
|
+
// Use the parsed result text, fall back to reading file
|
|
278
|
+
let summary = lastResult.trim();
|
|
279
|
+
if (!summary) {
|
|
280
|
+
try {
|
|
281
|
+
const raw = await readFile(outputPath, "utf8");
|
|
282
|
+
// Try to extract result from the last line of stream-json
|
|
283
|
+
const lines = raw.trim().split("\n");
|
|
284
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
285
|
+
try {
|
|
286
|
+
const parsed = JSON.parse(lines[i]) as Record<string, unknown>;
|
|
287
|
+
if (parsed.type === "result" && typeof parsed.result === "string") {
|
|
288
|
+
summary = parsed.result;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
} catch { continue; }
|
|
292
|
+
}
|
|
293
|
+
if (!summary) summary = "Claude Code finished without output.";
|
|
294
|
+
} catch {
|
|
295
|
+
summary = stderrBuf.trim() || "No output captured.";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (summary.length > 600) summary = summary.slice(0, 600);
|
|
300
|
+
|
|
301
|
+
// exitCode is set by the 'exit' event (number | null); undefined means not yet exited
|
|
302
|
+
const resolvedCode: number | null = exitCode ?? null;
|
|
303
|
+
if (resolvedCode === 0 || resolvedCode === null) {
|
|
304
|
+
logger.info(`Spawned process exited. PID=${pid} exitCode=${resolvedCode} output=${outputPath}`);
|
|
305
|
+
return { exitCode: resolvedCode, summary, outputPath };
|
|
306
|
+
} else {
|
|
307
|
+
const errorMsg = stderrBuf.slice(0, 300) || `Exit code ${resolvedCode}`;
|
|
308
|
+
logger.error(`Spawned process failed. PID=${pid} exitCode=${resolvedCode} stderr=${stderrBuf.slice(0, 200)}`);
|
|
309
|
+
return { exitCode: resolvedCode, summary, outputPath, error: errorMsg };
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
kill() {
|
|
313
|
+
if (!exited) {
|
|
314
|
+
child.kill("SIGTERM");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
return spawnedRun;
|
|
320
|
+
}
|
|
321
|
+
|
|
135
322
|
async runContextual(issue: GitlabIssue, todoBody: string, context?: AgentRunContext): Promise<AgentRunResult> {
|
|
136
323
|
const signal = context?.signal;
|
|
137
324
|
const outputPath = await createOutputPath("claude-contextual", issue.iid);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
1
|
+
import { execFile, spawn as spawnChild } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { promisify } from "node:util";
|
|
@@ -13,8 +13,11 @@ import {
|
|
|
13
13
|
type AgentRunResult,
|
|
14
14
|
type AgentRunner,
|
|
15
15
|
type AgentRunnerOptions,
|
|
16
|
+
type SpawnedRun,
|
|
17
|
+
type SpawnedRunResult,
|
|
16
18
|
buildAgentPrompt,
|
|
17
19
|
buildContextualPrompt,
|
|
20
|
+
createCompletedSpawnedRun,
|
|
18
21
|
createOutputPath,
|
|
19
22
|
isSmokeIssue,
|
|
20
23
|
readSummary,
|
|
@@ -138,6 +141,108 @@ export class CodexRunner implements AgentRunner {
|
|
|
138
141
|
};
|
|
139
142
|
}
|
|
140
143
|
|
|
144
|
+
async spawn(issue: GitlabIssue, worktree: WorktreeInfo, context?: AgentRunContext): Promise<SpawnedRun> {
|
|
145
|
+
const outputPath = await createOutputPath("codex", issue.iid);
|
|
146
|
+
|
|
147
|
+
if (isSmokeIssue(issue)) {
|
|
148
|
+
const result = await this.run(issue, worktree, context);
|
|
149
|
+
return createCompletedSpawnedRun(result);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const prompt = buildAgentPrompt({
|
|
153
|
+
issueIid: issue.iid,
|
|
154
|
+
issueTitle: issue.title,
|
|
155
|
+
gitlabHost: this.gitlabHost,
|
|
156
|
+
gitlabProjectId: this.gitlabProjectId,
|
|
157
|
+
worktreePath: worktree.worktreePath,
|
|
158
|
+
branch: worktree.branch,
|
|
159
|
+
provider: "codex",
|
|
160
|
+
todoId: context?.todoId,
|
|
161
|
+
preamble: this.agentDefinition?.prompt.preamble,
|
|
162
|
+
append: this.agentDefinition?.prompt.append,
|
|
163
|
+
labels: this.agentDefinition?.labels,
|
|
164
|
+
});
|
|
165
|
+
const codexCommand = resolveCodexCommand(this.env);
|
|
166
|
+
|
|
167
|
+
const args = ["exec", "--full-auto", "-C", worktree.worktreePath, "--output-last-message", outputPath];
|
|
168
|
+
if (this.agentDefinition?.model) {
|
|
169
|
+
args.push("--model", this.agentDefinition.model);
|
|
170
|
+
}
|
|
171
|
+
args.push(prompt);
|
|
172
|
+
|
|
173
|
+
this.logger.info(`Spawning: ${codexCommand} exec --full-auto -C ${worktree.worktreePath}`);
|
|
174
|
+
this.logger.info(`Prompt (${prompt.length} chars): issue #${issue.iid} "${issue.title}"`);
|
|
175
|
+
|
|
176
|
+
const child = spawnChild(codexCommand, args, {
|
|
177
|
+
env: this.env,
|
|
178
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
179
|
+
detached: false,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
let stderrBuf = "";
|
|
183
|
+
child.stdout?.on("data", (chunk: Buffer) => { stderrBuf += chunk.toString(); /* discard stdout, codex writes to --output-last-message */ });
|
|
184
|
+
child.stderr?.on("data", (chunk: Buffer) => { stderrBuf += chunk.toString(); });
|
|
185
|
+
|
|
186
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
187
|
+
if (this.timeoutSeconds) {
|
|
188
|
+
timeoutHandle = setTimeout(() => {
|
|
189
|
+
this.logger.error(`Spawn timed out after ${this.timeoutSeconds}s, killing PID ${child.pid}`);
|
|
190
|
+
child.kill("SIGTERM");
|
|
191
|
+
}, this.timeoutSeconds * 1000);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let exitCode: number | null | undefined;
|
|
195
|
+
let exited = false;
|
|
196
|
+
|
|
197
|
+
child.on("exit", (code) => {
|
|
198
|
+
exitCode = code;
|
|
199
|
+
exited = true;
|
|
200
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (child.pid === undefined) {
|
|
204
|
+
throw new AgentRunnerError("Failed to spawn Codex process.", "Spawn failed: no PID", outputPath);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const pid = child.pid;
|
|
208
|
+
const timeoutSec = this.timeoutSeconds;
|
|
209
|
+
const logger = this.logger;
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
pid,
|
|
213
|
+
outputPath,
|
|
214
|
+
async poll(): Promise<SpawnedRunResult | undefined> {
|
|
215
|
+
if (!exited) return undefined;
|
|
216
|
+
|
|
217
|
+
let summary: string;
|
|
218
|
+
try {
|
|
219
|
+
const raw = await readFile(outputPath, "utf8");
|
|
220
|
+
summary = raw.trim() || "Codex finished without output.";
|
|
221
|
+
} catch {
|
|
222
|
+
summary = stderrBuf.trim() || "No output captured.";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (summary.length > 600) summary = summary.slice(0, 600);
|
|
226
|
+
|
|
227
|
+
// exitCode is set by the 'exit' event (number | null); undefined means not yet exited
|
|
228
|
+
const resolvedCode: number | null = exitCode ?? null;
|
|
229
|
+
if (resolvedCode === 0 || resolvedCode === null) {
|
|
230
|
+
logger.info(`Spawned process exited. PID=${pid} exitCode=${resolvedCode} output=${outputPath}`);
|
|
231
|
+
return { exitCode: resolvedCode, summary, outputPath };
|
|
232
|
+
} else {
|
|
233
|
+
const errorMsg = stderrBuf.slice(0, 300) || `Exit code ${resolvedCode}`;
|
|
234
|
+
logger.error(`Spawned process failed. PID=${pid} exitCode=${resolvedCode} stderr=${stderrBuf.slice(0, 200)}`);
|
|
235
|
+
return { exitCode: resolvedCode, summary, outputPath, error: errorMsg };
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
kill() {
|
|
239
|
+
if (!exited) {
|
|
240
|
+
child.kill("SIGTERM");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
141
246
|
async runContextual(issue: GitlabIssue, todoBody: string, context?: AgentRunContext): Promise<AgentRunResult> {
|
|
142
247
|
const signal = context?.signal;
|
|
143
248
|
const outputPath = await createOutputPath("codex-contextual", issue.iid);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Feishu (Lark) App Client — send & update interactive cards to specific users
|
|
3
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface FeishuClientOptions {
|
|
6
|
+
appId: string;
|
|
7
|
+
appSecret: string;
|
|
8
|
+
/** Override base URL for testing. Default: https://open.feishu.cn */
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FeishuCard {
|
|
13
|
+
header: {
|
|
14
|
+
title: { tag: "plain_text"; content: string };
|
|
15
|
+
template: "blue" | "green" | "red" | "orange" | "purple";
|
|
16
|
+
};
|
|
17
|
+
elements: unknown[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class FeishuClient {
|
|
21
|
+
private readonly appId: string;
|
|
22
|
+
private readonly appSecret: string;
|
|
23
|
+
private readonly baseUrl: string;
|
|
24
|
+
|
|
25
|
+
// Token cache
|
|
26
|
+
private accessToken?: string;
|
|
27
|
+
private tokenExpiresAt = 0;
|
|
28
|
+
|
|
29
|
+
// User ID cache: email → open_id
|
|
30
|
+
private userCache = new Map<string, string>();
|
|
31
|
+
|
|
32
|
+
constructor(options: FeishuClientOptions) {
|
|
33
|
+
this.appId = options.appId;
|
|
34
|
+
this.appSecret = options.appSecret;
|
|
35
|
+
this.baseUrl = options.baseUrl ?? "https://open.feishu.cn";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Get or refresh app access token */
|
|
39
|
+
private async getToken(): Promise<string> {
|
|
40
|
+
if (this.accessToken && Date.now() < this.tokenExpiresAt) {
|
|
41
|
+
return this.accessToken;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const response = await fetch(`${this.baseUrl}/open-apis/auth/v3/app_access_token/internal`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
|
|
48
|
+
signal: AbortSignal.timeout(10_000)
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const data = await response.json() as { code: number; app_access_token?: string; expire?: number; msg?: string };
|
|
52
|
+
if (data.code !== 0 || !data.app_access_token) {
|
|
53
|
+
throw new Error(`Feishu token error: ${data.msg ?? `code ${data.code}`}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.accessToken = data.app_access_token;
|
|
57
|
+
// Refresh 5 minutes before expiry
|
|
58
|
+
this.tokenExpiresAt = Date.now() + ((data.expire ?? 7200) - 300) * 1000;
|
|
59
|
+
return this.accessToken;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Look up Feishu open_id by email */
|
|
63
|
+
async getUserByEmail(email: string): Promise<string | undefined> {
|
|
64
|
+
const cached = this.userCache.get(email);
|
|
65
|
+
if (cached) return cached;
|
|
66
|
+
|
|
67
|
+
const token = await this.getToken();
|
|
68
|
+
const response = await fetch(`${this.baseUrl}/open-apis/contact/v3/users/batch_get_id?user_id_type=open_id`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
"Authorization": `Bearer ${token}`,
|
|
72
|
+
"Content-Type": "application/json"
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({ emails: [email] }),
|
|
75
|
+
signal: AbortSignal.timeout(10_000)
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const data = await response.json() as {
|
|
79
|
+
code: number;
|
|
80
|
+
data?: { user_list?: Array<{ email?: string; user_id?: string }> };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (data.code !== 0) return undefined;
|
|
84
|
+
|
|
85
|
+
const user = data.data?.user_list?.find(u => u.email === email);
|
|
86
|
+
if (user?.user_id) {
|
|
87
|
+
this.userCache.set(email, user.user_id);
|
|
88
|
+
return user.user_id;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Send an interactive card to a user, returns message_id for later updates */
|
|
94
|
+
async sendCard(openId: string, card: FeishuCard): Promise<string | undefined> {
|
|
95
|
+
const token = await this.getToken();
|
|
96
|
+
const response = await fetch(`${this.baseUrl}/open-apis/im/v1/messages?receive_id_type=open_id`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: {
|
|
99
|
+
"Authorization": `Bearer ${token}`,
|
|
100
|
+
"Content-Type": "application/json"
|
|
101
|
+
},
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
receive_id: openId,
|
|
104
|
+
msg_type: "interactive",
|
|
105
|
+
content: JSON.stringify(card)
|
|
106
|
+
}),
|
|
107
|
+
signal: AbortSignal.timeout(10_000)
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const data = await response.json() as {
|
|
111
|
+
code: number;
|
|
112
|
+
data?: { message_id?: string };
|
|
113
|
+
msg?: string;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (data.code !== 0) {
|
|
117
|
+
throw new Error(`Feishu send failed: ${data.msg ?? `code ${data.code}`}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return data.data?.message_id;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Update an existing card message */
|
|
124
|
+
async updateCard(messageId: string, card: FeishuCard): Promise<void> {
|
|
125
|
+
const token = await this.getToken();
|
|
126
|
+
const response = await fetch(`${this.baseUrl}/open-apis/im/v1/messages/${messageId}`, {
|
|
127
|
+
method: "PATCH",
|
|
128
|
+
headers: {
|
|
129
|
+
"Authorization": `Bearer ${token}`,
|
|
130
|
+
"Content-Type": "application/json"
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
content: JSON.stringify(card)
|
|
134
|
+
}),
|
|
135
|
+
signal: AbortSignal.timeout(10_000)
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const data = await response.json() as { code: number; msg?: string };
|
|
139
|
+
if (data.code !== 0) {
|
|
140
|
+
throw new Error(`Feishu update failed: ${data.msg ?? `code ${data.code}`}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
146
|
+
// Card builder helpers
|
|
147
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
export function buildProgressCard(options: {
|
|
150
|
+
agentName: string;
|
|
151
|
+
issueIid: number;
|
|
152
|
+
issueTitle: string;
|
|
153
|
+
issueUrl?: string;
|
|
154
|
+
mrUrl?: string;
|
|
155
|
+
status: "accepted" | "running" | "completed" | "failed" | "queued";
|
|
156
|
+
progressLines?: string[]; // e.g. ["📖 阅读 README.md", "✏️ 编辑 utils.js"]
|
|
157
|
+
duration?: string; // e.g. "2m 30s"
|
|
158
|
+
summary?: string; // final summary on completion/failure
|
|
159
|
+
}): FeishuCard {
|
|
160
|
+
const statusConfig: Record<string, { color: FeishuCard["header"]["template"]; emoji: string }> = {
|
|
161
|
+
accepted: { color: "blue", emoji: "🤖" },
|
|
162
|
+
running: { color: "blue", emoji: "🔧" },
|
|
163
|
+
completed: { color: "green", emoji: "✅" },
|
|
164
|
+
failed: { color: "red", emoji: "❌" },
|
|
165
|
+
queued: { color: "orange", emoji: "⏳" },
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const cfg = statusConfig[options.status] ?? statusConfig.accepted;
|
|
169
|
+
const title = `${cfg.emoji} ${options.agentName} · #${options.issueIid} ${options.issueTitle}`;
|
|
170
|
+
|
|
171
|
+
const elements: unknown[] = [];
|
|
172
|
+
|
|
173
|
+
// Progress lines
|
|
174
|
+
if (options.progressLines && options.progressLines.length > 0) {
|
|
175
|
+
elements.push({
|
|
176
|
+
tag: "div",
|
|
177
|
+
text: { tag: "lark_md", content: options.progressLines.join("\n") }
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Summary (for completed/failed)
|
|
182
|
+
if (options.summary) {
|
|
183
|
+
elements.push({
|
|
184
|
+
tag: "div",
|
|
185
|
+
text: { tag: "lark_md", content: options.summary.slice(0, 500) }
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Duration
|
|
190
|
+
if (options.duration) {
|
|
191
|
+
elements.push({
|
|
192
|
+
tag: "div",
|
|
193
|
+
text: { tag: "lark_md", content: `⏱ ${options.duration}` }
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Action buttons
|
|
198
|
+
const actions: unknown[] = [];
|
|
199
|
+
if (options.issueUrl) {
|
|
200
|
+
actions.push({
|
|
201
|
+
tag: "button",
|
|
202
|
+
text: { tag: "plain_text", content: "查看 Issue" },
|
|
203
|
+
type: "default",
|
|
204
|
+
url: options.issueUrl
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (options.mrUrl) {
|
|
208
|
+
actions.push({
|
|
209
|
+
tag: "button",
|
|
210
|
+
text: { tag: "plain_text", content: "查看 MR" },
|
|
211
|
+
type: "primary",
|
|
212
|
+
url: options.mrUrl
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (actions.length > 0) {
|
|
216
|
+
elements.push({ tag: "action", actions });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
header: {
|
|
221
|
+
title: { tag: "plain_text", content: title.slice(0, 100) },
|
|
222
|
+
template: cfg.color
|
|
223
|
+
},
|
|
224
|
+
elements
|
|
225
|
+
};
|
|
226
|
+
}
|