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.
@@ -17,6 +17,12 @@ export interface GitlabTodoItem {
17
17
  targetIid?: number;
18
18
  body?: string;
19
19
  sourceBranch?: string;
20
+ /** Note ID of the @mention comment that created this todo (for emoji reactions) */
21
+ noteId?: number;
22
+ /** GitLab user ID of the todo's author (the person who @mentioned the bot) */
23
+ authorId?: number;
24
+ /** GitLab username of the todo's author */
25
+ authorUsername?: string;
20
26
  }
21
27
 
22
28
  export interface GitlabIssue {
@@ -95,6 +101,15 @@ export interface GitlabClient {
95
101
  getWikiPage(projectId: number | string, slug: string): Promise<WikiPage>;
96
102
  createWikiPage(projectId: number | string, title: string, content: string): Promise<WikiPage>;
97
103
  updateWikiPage(projectId: number | string, slug: string, title: string, content: string): Promise<WikiPage>;
104
+ getProject(projectIdOrPath: number | string): Promise<{ id: number } | undefined>;
105
+ createProject(name: string, options?: { visibility?: string; initializeWithReadme?: boolean }): Promise<{ id: number }>;
106
+ getRepositoryFile(projectId: number | string, filePath: string, ref?: string): Promise<{ content: string } | undefined>;
107
+ createOrUpdateRepositoryFile(projectId: number | string, filePath: string, content: string, commitMessage: string, options?: { create?: boolean }): Promise<void>;
108
+ addAwardEmoji(projectId: number | string, issueIid: number, noteId: number, emojiName: string): Promise<{ id: number }>;
109
+ removeAwardEmoji(projectId: number | string, issueIid: number, noteId: number, awardId: number): Promise<void>;
110
+ listAwardEmoji(projectId: number | string, issueIid: number, noteId: number): Promise<Array<{ id: number; name: string; user: { id: number } }>>;
111
+ findMergeRequestByBranch(projectId: number | string, sourceBranch: string): Promise<{ iid: number; webUrl: string } | undefined>;
112
+ getUserById(userId: number): Promise<{ id: number; email?: string; username: string }>;
98
113
  }
99
114
 
100
115
  interface GitlabGlabClientOptions {
@@ -220,6 +235,13 @@ export function parseTodoUniversal(payload: Payload): GitlabTodoItem | undefined
220
235
  ? target.source_branch as string
221
236
  : undefined;
222
237
 
238
+ const note = payload.note as Payload | undefined;
239
+ const noteId = parseInteger(note?.id);
240
+
241
+ const author = payload.author as Payload | undefined;
242
+ const authorId = parseInteger(author?.id);
243
+ const authorUsername = typeof author?.username === "string" ? author.username as string : undefined;
244
+
223
245
  return {
224
246
  id,
225
247
  projectId,
@@ -233,7 +255,10 @@ export function parseTodoUniversal(payload: Payload): GitlabTodoItem | undefined
233
255
  createdAt: typeof payload.created_at === "string" ? payload.created_at : undefined,
234
256
  labels: parseLabels(target),
235
257
  body: typeof payload.body === "string" ? payload.body : undefined,
236
- sourceBranch
258
+ sourceBranch,
259
+ noteId,
260
+ authorId,
261
+ authorUsername
237
262
  };
238
263
  }
239
264
 
@@ -693,6 +718,117 @@ export class GitlabGlabClient implements GitlabClient {
693
718
  };
694
719
  }
695
720
 
721
+ async getProject(projectIdOrPath: number | string): Promise<{ id: number } | undefined> {
722
+ try {
723
+ const encoded = typeof projectIdOrPath === "string" ? encodeURIComponent(projectIdOrPath) : projectIdOrPath;
724
+ const payload = await this.readJson(`projects/${encoded}`);
725
+ const p = (payload ?? {}) as Payload;
726
+ const id = Number(p.id);
727
+ return Number.isNaN(id) ? undefined : { id };
728
+ } catch {
729
+ return undefined;
730
+ }
731
+ }
732
+
733
+ async createProject(name: string, options?: { visibility?: string; initializeWithReadme?: boolean }): Promise<{ id: number }> {
734
+ const stdout = await this.request("projects", {
735
+ method: "POST",
736
+ fields: {
737
+ name,
738
+ path: name,
739
+ visibility: options?.visibility ?? "public",
740
+ initialize_with_readme: options?.initializeWithReadme ? "true" : "false"
741
+ }
742
+ });
743
+ const payload = JSON.parse(stdout) as Payload;
744
+ return { id: Number(payload.id) };
745
+ }
746
+
747
+ async getRepositoryFile(projectId: number | string, filePath: string, ref?: string): Promise<{ content: string } | undefined> {
748
+ try {
749
+ const encoded = typeof projectId === "string" ? encodeURIComponent(projectId) : projectId;
750
+ const endpoint = this.withQuery(`projects/${encoded}/repository/files/${encodeURIComponent(filePath)}`, { ref: ref ?? "main" });
751
+ const payload = await this.readJson(endpoint);
752
+ const p = (payload ?? {}) as Payload;
753
+ const content = typeof p.content === "string" ? Buffer.from(p.content, "base64").toString("utf8") : "";
754
+ return { content };
755
+ } catch {
756
+ return undefined;
757
+ }
758
+ }
759
+
760
+ async createOrUpdateRepositoryFile(projectId: number | string, filePath: string, content: string, commitMessage: string, options?: { create?: boolean }): Promise<void> {
761
+ const encoded = typeof projectId === "string" ? encodeURIComponent(projectId) : projectId;
762
+ const method = options?.create ? "POST" : "PUT";
763
+ await this.request(`projects/${encoded}/repository/files/${encodeURIComponent(filePath)}`, {
764
+ method,
765
+ fields: {
766
+ branch: "main",
767
+ content,
768
+ commit_message: commitMessage
769
+ }
770
+ });
771
+ }
772
+
773
+ async addAwardEmoji(projectId: number | string, issueIid: number, noteId: number, emojiName: string): Promise<{ id: number }> {
774
+ const stdout = await this.request(`projects/${projectId}/issues/${issueIid}/notes/${noteId}/award_emoji`, {
775
+ method: "POST",
776
+ fields: { name: emojiName }
777
+ });
778
+ const payload = JSON.parse(stdout) as Payload;
779
+ return { id: Number(payload.id) };
780
+ }
781
+
782
+ async removeAwardEmoji(projectId: number | string, issueIid: number, noteId: number, awardId: number): Promise<void> {
783
+ await this.request(`projects/${projectId}/issues/${issueIid}/notes/${noteId}/award_emoji/${awardId}`, {
784
+ method: "DELETE",
785
+ fields: {}
786
+ });
787
+ }
788
+
789
+ async listAwardEmoji(projectId: number | string, issueIid: number, noteId: number): Promise<Array<{ id: number; name: string; user: { id: number } }>> {
790
+ const payload = await this.readJson(`projects/${projectId}/issues/${issueIid}/notes/${noteId}/award_emoji`);
791
+ if (!Array.isArray(payload)) return [];
792
+ return payload.map((item: unknown) => {
793
+ const p = (item ?? {}) as Record<string, unknown>;
794
+ return {
795
+ id: Number(p.id),
796
+ name: String(p.name ?? ""),
797
+ user: { id: Number((p.user as Record<string, unknown> | undefined)?.id ?? 0) }
798
+ };
799
+ });
800
+ }
801
+
802
+ async findMergeRequestByBranch(projectId: number | string, sourceBranch: string): Promise<{ iid: number; webUrl: string } | undefined> {
803
+ try {
804
+ const endpoint = this.withQuery(`projects/${projectId}/merge_requests`, {
805
+ source_branch: sourceBranch,
806
+ state: "opened",
807
+ per_page: "1"
808
+ });
809
+ const payload = await this.readJson(endpoint);
810
+ if (!Array.isArray(payload) || payload.length === 0) return undefined;
811
+ const mr = (payload[0] ?? {}) as Record<string, unknown>;
812
+ const iid = Number(mr.iid);
813
+ const webUrl = String(mr.web_url ?? "");
814
+ return (iid && webUrl) ? { iid, webUrl } : undefined;
815
+ } catch {
816
+ return undefined;
817
+ }
818
+ }
819
+
820
+ async getUserById(userId: number): Promise<{ id: number; email?: string; username: string }> {
821
+ const payload = (await this.readJson(`users/${userId}`)) as Payload;
822
+ const p = payload ?? {};
823
+ return {
824
+ id: Number(p.id),
825
+ email: typeof p.email === "string" && p.email
826
+ ? p.email
827
+ : (typeof p.public_email === "string" && p.public_email ? p.public_email : undefined),
828
+ username: typeof p.username === "string" ? p.username : ""
829
+ };
830
+ }
831
+
696
832
  /**
697
833
  * Direct HTTP call using Node's built-in fetch.
698
834
  * Used for endpoints where glab CLI's response parsing has issues (e.g. PUT /user/status).
@@ -742,7 +878,7 @@ export class GitlabGlabClient implements GitlabClient {
742
878
  private async request(
743
879
  endpoint: string,
744
880
  options: {
745
- method?: "GET" | "POST" | "PUT";
881
+ method?: "GET" | "POST" | "PUT" | "DELETE";
746
882
  fields?: Record<string, string>;
747
883
  } = {}
748
884
  ): Promise<string> {
@@ -6,6 +6,7 @@ export interface WebhookPayload {
6
6
  title: string;
7
7
  message: string;
8
8
  url?: string;
9
+ status?: "accepted" | "completed" | "failed" | "busy";
9
10
  }
10
11
 
11
12
  function isFeishuUrl(url: string): boolean {
@@ -16,18 +17,58 @@ function isSlackUrl(url: string): boolean {
16
17
  return url.includes("hooks.slack.com");
17
18
  }
18
19
 
20
+ function buildFeishuCard(payload: WebhookPayload): unknown {
21
+ const statusConfig = {
22
+ accepted: { color: "blue", title: "已接单" },
23
+ completed: { color: "green", title: "已完成" },
24
+ failed: { color: "red", title: "执行失败" },
25
+ busy: { color: "orange", title: "排队中" },
26
+ };
27
+ const cfg = statusConfig[payload.status ?? "accepted"];
28
+
29
+ const elements: unknown[] = [
30
+ {
31
+ tag: "div",
32
+ text: { tag: "lark_md", content: payload.message }
33
+ }
34
+ ];
35
+
36
+ // Add URL button if available
37
+ if (payload.url) {
38
+ elements.push({
39
+ tag: "action",
40
+ actions: [
41
+ {
42
+ tag: "button",
43
+ text: { tag: "plain_text", content: payload.status === "completed" ? "查看 MR" : "查看 Issue" },
44
+ type: "primary",
45
+ url: payload.url
46
+ }
47
+ ]
48
+ });
49
+ }
50
+
51
+ return {
52
+ msg_type: "interactive",
53
+ card: {
54
+ header: {
55
+ title: { tag: "plain_text", content: payload.title },
56
+ template: cfg.color
57
+ },
58
+ elements
59
+ }
60
+ };
61
+ }
62
+
19
63
  function buildWebhookBody(url: string, payload: WebhookPayload): unknown {
64
+ if (isFeishuUrl(url)) {
65
+ return buildFeishuCard(payload);
66
+ }
67
+
20
68
  const text = payload.url
21
69
  ? `${payload.title}\n${payload.message}\n${payload.url}`
22
70
  : `${payload.title}\n${payload.message}`;
23
71
 
24
- if (isFeishuUrl(url)) {
25
- return {
26
- msg_type: "text",
27
- content: { text }
28
- };
29
- }
30
-
31
72
  if (isSlackUrl(url)) {
32
73
  return { text };
33
74
  }
@@ -69,12 +110,15 @@ export async function notifyAccepted(
69
110
  agentName: string,
70
111
  issueIid: number,
71
112
  issueTitle: string,
113
+ issueUrl?: string,
72
114
  webhookUrl?: string
73
115
  ): Promise<void> {
74
116
  if (!webhookUrl) return;
75
117
  await sendWebhook(webhookUrl, {
76
118
  title: `\u{1F916} ${agentName} 已接单`,
77
119
  message: `#${issueIid} ${issueTitle}`,
120
+ url: issueUrl,
121
+ status: "accepted",
78
122
  });
79
123
  }
80
124
 
@@ -90,6 +134,7 @@ export async function notifyCompleted(
90
134
  title: `\u2705 ${agentName} 已完成`,
91
135
  message: `#${issueIid} ${issueTitle}`,
92
136
  url: mrUrl,
137
+ status: "completed",
93
138
  });
94
139
  }
95
140
 
@@ -103,6 +148,24 @@ export async function notifyFailed(
103
148
  if (!webhookUrl) return;
104
149
  await sendWebhook(webhookUrl, {
105
150
  title: `\u274C ${agentName} 执行失败`,
106
- message: `#${issueIid} ${issueTitle}\n${summary.slice(0, 100)}`,
151
+ message: `#${issueIid} ${issueTitle}\n${summary.slice(0, 200)}`,
152
+ status: "failed",
153
+ });
154
+ }
155
+
156
+ export async function notifyQueued(
157
+ agentName: string,
158
+ issueIid: number,
159
+ issueTitle: string,
160
+ queuePosition: number,
161
+ issueUrl?: string,
162
+ webhookUrl?: string
163
+ ): Promise<void> {
164
+ if (!webhookUrl) return;
165
+ await sendWebhook(webhookUrl, {
166
+ title: `\u23F3 ${agentName} 排队中`,
167
+ message: `#${issueIid} ${issueTitle}\n排在第 ${queuePosition} 位`,
168
+ url: issueUrl,
169
+ status: "busy",
107
170
  });
108
171
  }