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
|
@@ -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,
|
|
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
|
}
|