glab-agent 0.1.0 → 0.2.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/README.md +21 -8
- package/package.json +1 -1
- package/src/local-agent/agent-config.ts +31 -1
- package/src/local-agent/agent-runner.ts +59 -4
- package/src/local-agent/claude-runner.ts +68 -0
- package/src/local-agent/cli.ts +903 -64
- package/src/local-agent/codex-runner.ts +63 -0
- package/src/local-agent/gitlab-glab-client.ts +71 -0
- package/src/local-agent/notifier.ts +19 -101
- package/src/local-agent/report.ts +31 -0
- package/src/local-agent/watcher.ts +164 -49
- package/src/local-agent/wiki-sync.ts +0 -290
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type AgentRunner,
|
|
15
15
|
type AgentRunnerOptions,
|
|
16
16
|
buildAgentPrompt,
|
|
17
|
+
buildContextualPrompt,
|
|
17
18
|
createOutputPath,
|
|
18
19
|
isSmokeIssue,
|
|
19
20
|
readSummary,
|
|
@@ -70,6 +71,7 @@ export class CodexRunner implements AgentRunner {
|
|
|
70
71
|
todoId: context?.todoId,
|
|
71
72
|
preamble: this.agentDefinition?.prompt.preamble,
|
|
72
73
|
append: this.agentDefinition?.prompt.append,
|
|
74
|
+
labels: this.agentDefinition?.labels,
|
|
73
75
|
// skills injected via .agent_context/skills/*.md files, not in prompt
|
|
74
76
|
});
|
|
75
77
|
const codexCommand = resolveCodexCommand(this.env);
|
|
@@ -135,6 +137,67 @@ export class CodexRunner implements AgentRunner {
|
|
|
135
137
|
outputPath
|
|
136
138
|
};
|
|
137
139
|
}
|
|
140
|
+
|
|
141
|
+
async runContextual(issue: GitlabIssue, todoBody: string, context?: AgentRunContext): Promise<AgentRunResult> {
|
|
142
|
+
const signal = context?.signal;
|
|
143
|
+
const outputPath = await createOutputPath("codex-contextual", issue.iid);
|
|
144
|
+
|
|
145
|
+
const prompt = buildContextualPrompt({
|
|
146
|
+
issueIid: issue.iid,
|
|
147
|
+
issueTitle: issue.title,
|
|
148
|
+
gitlabHost: this.gitlabHost,
|
|
149
|
+
gitlabProjectId: this.gitlabProjectId,
|
|
150
|
+
todoBody,
|
|
151
|
+
});
|
|
152
|
+
const codexCommand = resolveCodexCommand(this.env);
|
|
153
|
+
const repoPath = this.repoPath || process.cwd();
|
|
154
|
+
|
|
155
|
+
this.logger.info(`Contextual reply: ${codexCommand} exec --full-auto -C ${repoPath}`);
|
|
156
|
+
this.logger.info(`Prompt (${prompt.length} chars): contextual issue #${issue.iid} "${issue.title}"`);
|
|
157
|
+
|
|
158
|
+
const timeoutMs = this.timeoutSeconds ? this.timeoutSeconds * 1000 : 0;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const args = ["exec", "--full-auto", "-C", repoPath, "--output-last-message", outputPath];
|
|
162
|
+
if (this.agentDefinition?.model) {
|
|
163
|
+
args.push("--model", this.agentDefinition.model);
|
|
164
|
+
}
|
|
165
|
+
args.push(prompt);
|
|
166
|
+
|
|
167
|
+
await this.execFileImpl(
|
|
168
|
+
codexCommand,
|
|
169
|
+
args,
|
|
170
|
+
{
|
|
171
|
+
encoding: "utf8",
|
|
172
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
173
|
+
env: this.env,
|
|
174
|
+
...(timeoutMs > 0 ? { timeout: timeoutMs, killSignal: "SIGTERM" } : {}),
|
|
175
|
+
...(signal ? { signal } : {})
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const summary = await readSummary(outputPath, "Contextual reply sent.");
|
|
180
|
+
this.logger.info(`Contextual reply finished. Output: ${outputPath}`);
|
|
181
|
+
return { summary, outputPath };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const execError = error as { stdout?: string; stderr?: string; killed?: boolean };
|
|
184
|
+
const isTimeout = execError.killed === true && timeoutMs > 0;
|
|
185
|
+
if (isTimeout) {
|
|
186
|
+
this.logger.error(`Contextual reply timed out after ${this.timeoutSeconds}s.`);
|
|
187
|
+
throw new AgentRunnerError(
|
|
188
|
+
`Codex timed out after ${this.timeoutSeconds}s.`,
|
|
189
|
+
`Timed out after ${this.timeoutSeconds}s`,
|
|
190
|
+
outputPath
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (execError.stderr) {
|
|
194
|
+
this.logger.error(`Contextual reply stderr: ${execError.stderr.slice(0, 500)}`);
|
|
195
|
+
}
|
|
196
|
+
this.logger.error(`Contextual reply failed: ${String(error).slice(0, 300)}`);
|
|
197
|
+
const summary = await readSummary(outputPath, `Contextual reply failed: ${String(error)}`);
|
|
198
|
+
throw new AgentRunnerError("Contextual reply failed.", summary, outputPath);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
138
201
|
}
|
|
139
202
|
|
|
140
203
|
export function resolveCodexCommand(env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -53,6 +53,12 @@ export interface GitlabBoard {
|
|
|
53
53
|
name: string;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export interface WikiPage {
|
|
57
|
+
slug: string;
|
|
58
|
+
title: string;
|
|
59
|
+
content?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
export interface GitlabUser {
|
|
57
63
|
id: number;
|
|
58
64
|
username: string;
|
|
@@ -85,6 +91,10 @@ export interface GitlabClient {
|
|
|
85
91
|
updateUserStatus(emoji: string, message: string, availability?: "busy" | "not_set"): Promise<void>;
|
|
86
92
|
updateUserBio(bio: string): Promise<void>;
|
|
87
93
|
listAllPendingTodos(): Promise<GitlabTodoItem[]>;
|
|
94
|
+
listWikiPages(projectId: number): Promise<WikiPage[]>;
|
|
95
|
+
getWikiPage(projectId: number, slug: string): Promise<WikiPage>;
|
|
96
|
+
createWikiPage(projectId: number, title: string, content: string): Promise<WikiPage>;
|
|
97
|
+
updateWikiPage(projectId: number, slug: string, title: string, content: string): Promise<WikiPage>;
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
interface GitlabGlabClientOptions {
|
|
@@ -622,6 +632,67 @@ export class GitlabGlabClient implements GitlabClient {
|
|
|
622
632
|
});
|
|
623
633
|
}
|
|
624
634
|
|
|
635
|
+
async listWikiPages(projectId: number): Promise<WikiPage[]> {
|
|
636
|
+
const endpoint = this.withQuery(`projects/${projectId}/wikis`, { with_content: "0" });
|
|
637
|
+
let payload: unknown;
|
|
638
|
+
try {
|
|
639
|
+
payload = await this.readJson(endpoint);
|
|
640
|
+
} catch {
|
|
641
|
+
return []; // Wiki not enabled or no pages
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (!Array.isArray(payload)) {
|
|
645
|
+
return [];
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return payload
|
|
649
|
+
.map((item) => {
|
|
650
|
+
const p = (item ?? {}) as Payload;
|
|
651
|
+
const slug = typeof p.slug === "string" ? p.slug : "";
|
|
652
|
+
const title = typeof p.title === "string" ? p.title : "";
|
|
653
|
+
if (!slug) return undefined;
|
|
654
|
+
return { slug, title } satisfies WikiPage;
|
|
655
|
+
})
|
|
656
|
+
.filter((w): w is WikiPage => w !== undefined);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async getWikiPage(projectId: number, slug: string): Promise<WikiPage> {
|
|
660
|
+
const payload = await this.readJson(`projects/${projectId}/wikis/${encodeURIComponent(slug)}`);
|
|
661
|
+
const p = (payload ?? {}) as Payload;
|
|
662
|
+
return {
|
|
663
|
+
slug: typeof p.slug === "string" ? p.slug : slug,
|
|
664
|
+
title: typeof p.title === "string" ? p.title : "",
|
|
665
|
+
content: typeof p.content === "string" ? p.content : ""
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async createWikiPage(projectId: number, title: string, content: string): Promise<WikiPage> {
|
|
670
|
+
const stdout = await this.request(`projects/${projectId}/wikis`, {
|
|
671
|
+
method: "POST",
|
|
672
|
+
fields: { title, content }
|
|
673
|
+
});
|
|
674
|
+
const payload = JSON.parse(stdout) as Payload;
|
|
675
|
+
return {
|
|
676
|
+
slug: typeof payload.slug === "string" ? payload.slug : "",
|
|
677
|
+
title: typeof payload.title === "string" ? payload.title : title,
|
|
678
|
+
content
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async updateWikiPage(projectId: number, slug: string, title: string, content: string): Promise<WikiPage> {
|
|
683
|
+
// title must be passed to preserve directory prefix in slug (GitLab API quirk)
|
|
684
|
+
const stdout = await this.request(`projects/${projectId}/wikis/${encodeURIComponent(slug)}`, {
|
|
685
|
+
method: "PUT",
|
|
686
|
+
fields: { title, content }
|
|
687
|
+
});
|
|
688
|
+
const payload = JSON.parse(stdout) as Payload;
|
|
689
|
+
return {
|
|
690
|
+
slug: typeof payload.slug === "string" ? payload.slug : slug,
|
|
691
|
+
title: typeof payload.title === "string" ? payload.title : "",
|
|
692
|
+
content
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
625
696
|
/**
|
|
626
697
|
* Direct HTTP call using Node's built-in fetch.
|
|
627
698
|
* Used for endpoints where glab CLI's response parsing has issues (e.g. PUT /user/status).
|
|
@@ -1,61 +1,3 @@
|
|
|
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
1
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
60
2
|
// Webhook notification
|
|
61
3
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -123,68 +65,44 @@ export async function sendWebhook(
|
|
|
123
65
|
// Pre-defined notification templates
|
|
124
66
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
125
67
|
|
|
126
|
-
export function notifyAccepted(
|
|
68
|
+
export async function notifyAccepted(
|
|
127
69
|
agentName: string,
|
|
128
70
|
issueIid: number,
|
|
129
71
|
issueTitle: string,
|
|
130
72
|
webhookUrl?: string
|
|
131
73
|
): Promise<void> {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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(() => {});
|
|
74
|
+
if (!webhookUrl) return;
|
|
75
|
+
await sendWebhook(webhookUrl, {
|
|
76
|
+
title: `\u{1F916} ${agentName} 已接单`,
|
|
77
|
+
message: `#${issueIid} ${issueTitle}`,
|
|
78
|
+
});
|
|
145
79
|
}
|
|
146
80
|
|
|
147
|
-
export function notifyCompleted(
|
|
81
|
+
export async function notifyCompleted(
|
|
148
82
|
agentName: string,
|
|
149
83
|
issueIid: number,
|
|
150
84
|
issueTitle: string,
|
|
151
85
|
mrUrl?: string,
|
|
152
86
|
webhookUrl?: string
|
|
153
87
|
): Promise<void> {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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(() => {});
|
|
88
|
+
if (!webhookUrl) return;
|
|
89
|
+
await sendWebhook(webhookUrl, {
|
|
90
|
+
title: `\u2705 ${agentName} 已完成`,
|
|
91
|
+
message: `#${issueIid} ${issueTitle}`,
|
|
92
|
+
url: mrUrl,
|
|
93
|
+
});
|
|
168
94
|
}
|
|
169
95
|
|
|
170
|
-
export function notifyFailed(
|
|
96
|
+
export async function notifyFailed(
|
|
171
97
|
agentName: string,
|
|
172
98
|
issueIid: number,
|
|
173
99
|
issueTitle: string,
|
|
174
100
|
summary: string,
|
|
175
101
|
webhookUrl?: string
|
|
176
102
|
): Promise<void> {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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(() => {});
|
|
103
|
+
if (!webhookUrl) return;
|
|
104
|
+
await sendWebhook(webhookUrl, {
|
|
105
|
+
title: `\u274C ${agentName} 执行失败`,
|
|
106
|
+
message: `#${issueIid} ${issueTitle}\n${summary.slice(0, 100)}`,
|
|
107
|
+
});
|
|
190
108
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readMetrics } from "./metrics.js";
|
|
2
2
|
import type { MetricEvent } from "./metrics.js";
|
|
3
|
+
import type { GitlabClient } from "./gitlab-glab-client.js";
|
|
3
4
|
|
|
4
5
|
export interface ReportOptions {
|
|
5
6
|
sinceDays: number; // default 7
|
|
@@ -181,3 +182,33 @@ export function formatReport(result: ReportResult): string {
|
|
|
181
182
|
|
|
182
183
|
return lines.join("\n");
|
|
183
184
|
}
|
|
185
|
+
|
|
186
|
+
export async function publishReportToWiki(
|
|
187
|
+
client: GitlabClient,
|
|
188
|
+
projectId: number,
|
|
189
|
+
agentName: string,
|
|
190
|
+
reportText: string
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
const title = `reports/${agentName}`;
|
|
193
|
+
const content = [
|
|
194
|
+
`# Agent Report: ${agentName}`,
|
|
195
|
+
"",
|
|
196
|
+
`> Auto-generated by \`glab-agent report\`. Last updated: ${new Date().toISOString()}`,
|
|
197
|
+
"",
|
|
198
|
+
reportText
|
|
199
|
+
].join("\n");
|
|
200
|
+
|
|
201
|
+
// Try update first, create if not found
|
|
202
|
+
try {
|
|
203
|
+
const pages = await client.listWikiPages(projectId);
|
|
204
|
+
const existing = pages.find(p => p.slug === `reports/${agentName}` || p.slug === `reports-${agentName}`);
|
|
205
|
+
if (existing) {
|
|
206
|
+
await client.updateWikiPage(projectId, existing.slug, title, content);
|
|
207
|
+
} else {
|
|
208
|
+
await client.createWikiPage(projectId, title, content);
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// Fallback: try create
|
|
212
|
+
await client.createWikiPage(projectId, title, content);
|
|
213
|
+
}
|
|
214
|
+
}
|