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.
@@ -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
- 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(() => {});
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
- 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(() => {});
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
- 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(() => {});
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
+ }