glab-agent 0.1.0
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/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/glab-agent.mjs +18 -0
- package/package.json +59 -0
- package/src/local-agent/agent-config.ts +315 -0
- package/src/local-agent/agent-provider.ts +59 -0
- package/src/local-agent/agent-runner.ts +244 -0
- package/src/local-agent/claude-runner.ts +136 -0
- package/src/local-agent/cli.ts +1497 -0
- package/src/local-agent/codex-runner.ts +153 -0
- package/src/local-agent/gitlab-glab-client.ts +722 -0
- package/src/local-agent/health-server.ts +56 -0
- package/src/local-agent/heartbeat.ts +33 -0
- package/src/local-agent/log-rotate.ts +56 -0
- package/src/local-agent/logger.ts +92 -0
- package/src/local-agent/metrics.ts +51 -0
- package/src/local-agent/mr-actions.ts +121 -0
- package/src/local-agent/notifier.ts +190 -0
- package/src/local-agent/process-manager.ts +193 -0
- package/src/local-agent/reply-runner.ts +111 -0
- package/src/local-agent/repo-cache.ts +144 -0
- package/src/local-agent/report.ts +183 -0
- package/src/local-agent/skill-import.ts +344 -0
- package/src/local-agent/skill-inject.ts +109 -0
- package/src/local-agent/skill-parse.ts +47 -0
- package/src/local-agent/smoke-test.ts +443 -0
- package/src/local-agent/state-store.ts +186 -0
- package/src/local-agent/token-check.ts +37 -0
- package/src/local-agent/watcher.ts +1226 -0
- package/src/local-agent/wiki-sync.ts +290 -0
- package/src/local-agent/worktree-manager.ts +141 -0
- package/src/text.ts +16 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export interface GitlabTodoItem {
|
|
7
|
+
id: number;
|
|
8
|
+
issueIid: number;
|
|
9
|
+
issueId?: number;
|
|
10
|
+
projectId: number;
|
|
11
|
+
actionName: string;
|
|
12
|
+
targetType: string;
|
|
13
|
+
state: string;
|
|
14
|
+
targetUrl?: string;
|
|
15
|
+
createdAt?: string;
|
|
16
|
+
labels?: string[];
|
|
17
|
+
targetIid?: number;
|
|
18
|
+
body?: string;
|
|
19
|
+
sourceBranch?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GitlabIssue {
|
|
23
|
+
id: number;
|
|
24
|
+
iid: number;
|
|
25
|
+
projectId: number;
|
|
26
|
+
title: string;
|
|
27
|
+
description: string;
|
|
28
|
+
labels: string[];
|
|
29
|
+
webUrl: string;
|
|
30
|
+
state?: "opened" | "closed";
|
|
31
|
+
stateReason?: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MergeRequestResult {
|
|
35
|
+
iid: number;
|
|
36
|
+
webUrl: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GitlabNote {
|
|
40
|
+
id: number;
|
|
41
|
+
body: string;
|
|
42
|
+
system: boolean;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface GitlabLabel {
|
|
47
|
+
id: number;
|
|
48
|
+
name: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface GitlabBoard {
|
|
52
|
+
id: number;
|
|
53
|
+
name: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface GitlabUser {
|
|
57
|
+
id: number;
|
|
58
|
+
username: string;
|
|
59
|
+
name: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GitlabClient {
|
|
63
|
+
getCurrentUser(): Promise<GitlabUser>;
|
|
64
|
+
listMentionedIssueTodos(projectId: number): Promise<GitlabTodoItem[]>;
|
|
65
|
+
getIssue(projectId: number, issueIid: number): Promise<GitlabIssue>;
|
|
66
|
+
updateIssueLabels(projectId: number, issueIid: number, labels: string[]): Promise<void>;
|
|
67
|
+
updateIssueDescription(projectId: number, issueIid: number, description: string): Promise<void>;
|
|
68
|
+
updateIssueTitle(projectId: number, issueIid: number, title: string): Promise<void>;
|
|
69
|
+
closeIssue(projectId: number, issueIid: number): Promise<void>;
|
|
70
|
+
addIssueNote(projectId: number, issueIid: number, body: string): Promise<void>;
|
|
71
|
+
getIssueNotes(projectId: number, issueIid: number): Promise<GitlabNote[]>;
|
|
72
|
+
markTodoDone(todoId: number): Promise<void>;
|
|
73
|
+
createMergeRequest(projectId: number, sourceBranch: string, targetBranch: string, title: string, description: string, issueIid: number): Promise<MergeRequestResult>;
|
|
74
|
+
createIssue(projectId: number, title: string, description: string, labels: string[]): Promise<{ iid: number }>;
|
|
75
|
+
listPendingTodos(projectId: number): Promise<GitlabTodoItem[]>;
|
|
76
|
+
addMergeRequestNote(projectId: number, mrIid: number, body: string): Promise<void>;
|
|
77
|
+
getMergeRequestNotes(projectId: number, mrIid: number): Promise<GitlabNote[]>;
|
|
78
|
+
searchIssuesByLabel(projectId: number, label: string): Promise<GitlabIssue[]>;
|
|
79
|
+
listClosedIssuesByLabel(projectId: number, label: string): Promise<GitlabIssue[]>;
|
|
80
|
+
listLabels(projectId: number): Promise<GitlabLabel[]>;
|
|
81
|
+
createLabel(projectId: number, name: string, color: string): Promise<GitlabLabel>;
|
|
82
|
+
listBoards(projectId: number): Promise<GitlabBoard[]>;
|
|
83
|
+
createBoard(projectId: number, name: string): Promise<GitlabBoard>;
|
|
84
|
+
createBoardList(projectId: number, boardId: number, labelId: number): Promise<{ id: number }>;
|
|
85
|
+
updateUserStatus(emoji: string, message: string, availability?: "busy" | "not_set"): Promise<void>;
|
|
86
|
+
updateUserBio(bio: string): Promise<void>;
|
|
87
|
+
listAllPendingTodos(): Promise<GitlabTodoItem[]>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface GitlabGlabClientOptions {
|
|
91
|
+
host: string;
|
|
92
|
+
token: string;
|
|
93
|
+
execFileImpl?: typeof execFileAsync;
|
|
94
|
+
retryCount?: number; // default 3
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isRetryableError(errorMessage: string): boolean {
|
|
98
|
+
const retryablePatterns = [
|
|
99
|
+
/5\d{2}/, // 5xx status codes
|
|
100
|
+
/ECONNRESET/,
|
|
101
|
+
/ECONNREFUSED/,
|
|
102
|
+
/ETIMEDOUT/,
|
|
103
|
+
/ENOTFOUND/,
|
|
104
|
+
/socket hang up/i,
|
|
105
|
+
/network/i,
|
|
106
|
+
/timeout/i
|
|
107
|
+
];
|
|
108
|
+
return retryablePatterns.some(pattern => pattern.test(errorMessage));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function retrySleep(ms: number): Promise<void> {
|
|
112
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type Payload = Record<string, unknown>;
|
|
116
|
+
|
|
117
|
+
function parseInteger(value: unknown): number | undefined {
|
|
118
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
123
|
+
const parsed = Number.parseInt(value, 10);
|
|
124
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseIssueIid(payload: Payload): number | undefined {
|
|
131
|
+
const fromTarget = parseInteger((payload.target as Payload | undefined)?.iid);
|
|
132
|
+
|
|
133
|
+
if (fromTarget !== undefined) {
|
|
134
|
+
return fromTarget;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const targetUrl = typeof payload.target_url === "string" ? payload.target_url : "";
|
|
138
|
+
const matched = targetUrl.match(/\/issues\/(\d+)$/);
|
|
139
|
+
return matched ? parseInteger(matched[1]) : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Bot mention todos may use either the mentioned or directly_addressed action.
|
|
143
|
+
function parseTodo(payload: Payload): GitlabTodoItem | undefined {
|
|
144
|
+
const id = parseInteger(payload.id);
|
|
145
|
+
const projectId =
|
|
146
|
+
parseInteger(payload.project_id) ??
|
|
147
|
+
parseInteger((payload.project as Payload | undefined)?.id) ??
|
|
148
|
+
parseInteger((payload.target as Payload | undefined)?.project_id);
|
|
149
|
+
const issueIid = parseIssueIid(payload);
|
|
150
|
+
|
|
151
|
+
if (id === undefined || projectId === undefined || issueIid === undefined) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const target = payload.target as Payload | undefined;
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
id,
|
|
159
|
+
projectId,
|
|
160
|
+
issueIid,
|
|
161
|
+
issueId: parseInteger(target?.id),
|
|
162
|
+
actionName: typeof payload.action_name === "string" ? payload.action_name : "",
|
|
163
|
+
targetType: typeof payload.target_type === "string" ? payload.target_type : "",
|
|
164
|
+
state: typeof payload.state === "string" ? payload.state : "",
|
|
165
|
+
targetUrl: typeof payload.target_url === "string" ? payload.target_url : undefined,
|
|
166
|
+
createdAt: typeof payload.created_at === "string" ? payload.created_at : undefined,
|
|
167
|
+
labels: parseLabels(target)
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function parseTargetIid(payload: Payload): number | undefined {
|
|
172
|
+
const fromTarget = parseInteger((payload.target as Payload | undefined)?.iid);
|
|
173
|
+
if (fromTarget !== undefined) return fromTarget;
|
|
174
|
+
|
|
175
|
+
const targetUrl = typeof payload.target_url === "string" ? payload.target_url : "";
|
|
176
|
+
const matched = targetUrl.match(/\/(issues|merge_requests)\/(\d+)$/);
|
|
177
|
+
return matched ? parseInteger(matched[2]) : undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseLabels(target: Payload | undefined): string[] | undefined {
|
|
181
|
+
const rawLabels = target?.labels;
|
|
182
|
+
if (!Array.isArray(rawLabels)) return undefined;
|
|
183
|
+
return rawLabels
|
|
184
|
+
.map((item: unknown) => {
|
|
185
|
+
if (typeof item === "string") return item;
|
|
186
|
+
if (typeof item === "object" && item !== null && "name" in item && typeof (item as Record<string, unknown>).name === "string") {
|
|
187
|
+
return (item as Record<string, unknown>).name as string;
|
|
188
|
+
}
|
|
189
|
+
return undefined;
|
|
190
|
+
})
|
|
191
|
+
.filter((item): item is string => item !== undefined);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function parseTodoUniversal(payload: Payload): GitlabTodoItem | undefined {
|
|
195
|
+
const id = parseInteger(payload.id);
|
|
196
|
+
const projectId =
|
|
197
|
+
parseInteger(payload.project_id) ??
|
|
198
|
+
parseInteger((payload.project as Payload | undefined)?.id) ??
|
|
199
|
+
parseInteger((payload.target as Payload | undefined)?.project_id);
|
|
200
|
+
const targetIid = parseTargetIid(payload);
|
|
201
|
+
|
|
202
|
+
if (id === undefined || projectId === undefined || targetIid === undefined) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const target = payload.target as Payload | undefined;
|
|
207
|
+
const targetType = typeof payload.target_type === "string" ? payload.target_type : "";
|
|
208
|
+
const issueIid = targetType === "Issue" ? targetIid : 0;
|
|
209
|
+
const sourceBranch = targetType === "MergeRequest" && typeof target?.source_branch === "string"
|
|
210
|
+
? target.source_branch as string
|
|
211
|
+
: undefined;
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
id,
|
|
215
|
+
projectId,
|
|
216
|
+
issueIid,
|
|
217
|
+
targetIid,
|
|
218
|
+
issueId: parseInteger(target?.id),
|
|
219
|
+
actionName: typeof payload.action_name === "string" ? payload.action_name : "",
|
|
220
|
+
targetType,
|
|
221
|
+
state: typeof payload.state === "string" ? payload.state : "",
|
|
222
|
+
targetUrl: typeof payload.target_url === "string" ? payload.target_url : undefined,
|
|
223
|
+
createdAt: typeof payload.created_at === "string" ? payload.created_at : undefined,
|
|
224
|
+
labels: parseLabels(target),
|
|
225
|
+
body: typeof payload.body === "string" ? payload.body : undefined,
|
|
226
|
+
sourceBranch
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseIssue(payload: Payload): GitlabIssue {
|
|
231
|
+
const id = parseInteger(payload.id);
|
|
232
|
+
const iid = parseInteger(payload.iid);
|
|
233
|
+
const projectId = parseInteger(payload.project_id);
|
|
234
|
+
|
|
235
|
+
if (id === undefined || iid === undefined || projectId === undefined) {
|
|
236
|
+
throw new Error("GitLab issue payload is missing id/iid/project_id.");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
id,
|
|
241
|
+
iid,
|
|
242
|
+
projectId,
|
|
243
|
+
title: typeof payload.title === "string" ? payload.title : `Issue #${iid}`,
|
|
244
|
+
description: typeof payload.description === "string" ? payload.description : "",
|
|
245
|
+
labels: Array.isArray(payload.labels) ? payload.labels.filter((item): item is string => typeof item === "string") : [],
|
|
246
|
+
webUrl: typeof payload.web_url === "string" ? payload.web_url : "",
|
|
247
|
+
state: payload.state === "closed" ? "closed" : "opened",
|
|
248
|
+
stateReason: typeof payload.state_reason === "string" ? payload.state_reason : null
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export class GitlabGlabClient implements GitlabClient {
|
|
253
|
+
private readonly host: string;
|
|
254
|
+
|
|
255
|
+
private readonly token: string;
|
|
256
|
+
|
|
257
|
+
private readonly execFileImpl: typeof execFileAsync;
|
|
258
|
+
|
|
259
|
+
private readonly retryCount: number;
|
|
260
|
+
|
|
261
|
+
constructor(options: GitlabGlabClientOptions) {
|
|
262
|
+
this.host = options.host.trim();
|
|
263
|
+
this.token = options.token.trim();
|
|
264
|
+
this.execFileImpl = options.execFileImpl ?? execFileAsync;
|
|
265
|
+
this.retryCount = options.retryCount ?? 3;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async getCurrentUser(): Promise<GitlabUser> {
|
|
269
|
+
const payload = (await this.readJson("user")) as Payload;
|
|
270
|
+
const id = parseInteger(payload.id);
|
|
271
|
+
const username = typeof payload.username === "string" ? payload.username : "";
|
|
272
|
+
const name = typeof payload.name === "string" ? payload.name : "";
|
|
273
|
+
|
|
274
|
+
if (!id || !username) {
|
|
275
|
+
throw new Error("Failed to parse current user from GitLab API.");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { id, username, name: name || username };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async listMentionedIssueTodos(projectId: number): Promise<GitlabTodoItem[]> {
|
|
282
|
+
const endpoint = this.withQuery("todos", {
|
|
283
|
+
state: "pending",
|
|
284
|
+
type: "Issue",
|
|
285
|
+
project_id: String(projectId),
|
|
286
|
+
per_page: "100"
|
|
287
|
+
});
|
|
288
|
+
const payload = await this.readJson(endpoint);
|
|
289
|
+
|
|
290
|
+
if (!Array.isArray(payload)) {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return payload
|
|
295
|
+
.map((item) => parseTodo((item ?? {}) as Payload))
|
|
296
|
+
.filter((item): item is GitlabTodoItem => Boolean(item))
|
|
297
|
+
.sort((left, right) => {
|
|
298
|
+
if (left.createdAt && right.createdAt) {
|
|
299
|
+
return left.createdAt.localeCompare(right.createdAt);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return left.id - right.id;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async getIssue(projectId: number, issueIid: number): Promise<GitlabIssue> {
|
|
307
|
+
const payload = await this.readJson(`projects/${projectId}/issues/${issueIid}`);
|
|
308
|
+
return parseIssue((payload ?? {}) as Payload);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async updateIssueLabels(projectId: number, issueIid: number, labels: string[]): Promise<void> {
|
|
312
|
+
await this.request(`projects/${projectId}/issues/${issueIid}`, {
|
|
313
|
+
method: "PUT",
|
|
314
|
+
fields: {
|
|
315
|
+
labels: labels.join(",")
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async updateIssueDescription(projectId: number, issueIid: number, description: string): Promise<void> {
|
|
321
|
+
await this.request(`projects/${projectId}/issues/${issueIid}`, {
|
|
322
|
+
method: "PUT",
|
|
323
|
+
fields: { description }
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async updateIssueTitle(projectId: number, issueIid: number, title: string): Promise<void> {
|
|
328
|
+
await this.request(`projects/${projectId}/issues/${issueIid}`, {
|
|
329
|
+
method: "PUT",
|
|
330
|
+
fields: { title }
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async closeIssue(projectId: number, issueIid: number): Promise<void> {
|
|
335
|
+
await this.request(`projects/${projectId}/issues/${issueIid}`, {
|
|
336
|
+
method: "PUT",
|
|
337
|
+
fields: { state_event: "close" }
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async addIssueNote(projectId: number, issueIid: number, body: string): Promise<void> {
|
|
342
|
+
await this.request(`projects/${projectId}/issues/${issueIid}/notes`, {
|
|
343
|
+
method: "POST",
|
|
344
|
+
fields: {
|
|
345
|
+
body
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async getIssueNotes(projectId: number, issueIid: number): Promise<GitlabNote[]> {
|
|
351
|
+
const payload = await this.readJson(`projects/${projectId}/issues/${issueIid}/notes`);
|
|
352
|
+
|
|
353
|
+
if (!Array.isArray(payload)) {
|
|
354
|
+
return [];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return payload
|
|
358
|
+
.map((item) => {
|
|
359
|
+
const p = (item ?? {}) as Payload;
|
|
360
|
+
const id = parseInteger(p.id);
|
|
361
|
+
if (id === undefined) return undefined;
|
|
362
|
+
return {
|
|
363
|
+
id,
|
|
364
|
+
body: typeof p.body === "string" ? p.body : "",
|
|
365
|
+
system: p.system === true,
|
|
366
|
+
createdAt: typeof p.created_at === "string" ? p.created_at : ""
|
|
367
|
+
} satisfies GitlabNote;
|
|
368
|
+
})
|
|
369
|
+
.filter((n): n is GitlabNote => n !== undefined);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async markTodoDone(todoId: number): Promise<void> {
|
|
373
|
+
await this.request(`todos/${todoId}/mark_as_done`, {
|
|
374
|
+
method: "POST"
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async listPendingTodos(projectId: number): Promise<GitlabTodoItem[]> {
|
|
379
|
+
const endpoint = this.withQuery("todos", {
|
|
380
|
+
state: "pending",
|
|
381
|
+
project_id: String(projectId),
|
|
382
|
+
per_page: "100"
|
|
383
|
+
});
|
|
384
|
+
const payload = await this.readJson(endpoint);
|
|
385
|
+
|
|
386
|
+
if (!Array.isArray(payload)) {
|
|
387
|
+
return [];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return payload
|
|
391
|
+
.map((item) => parseTodoUniversal((item ?? {}) as Payload))
|
|
392
|
+
.filter((item): item is GitlabTodoItem => Boolean(item))
|
|
393
|
+
.sort((left, right) => {
|
|
394
|
+
if (left.createdAt && right.createdAt) {
|
|
395
|
+
return left.createdAt.localeCompare(right.createdAt);
|
|
396
|
+
}
|
|
397
|
+
return left.id - right.id;
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async addMergeRequestNote(projectId: number, mrIid: number, body: string): Promise<void> {
|
|
402
|
+
await this.request(`projects/${projectId}/merge_requests/${mrIid}/notes`, {
|
|
403
|
+
method: "POST",
|
|
404
|
+
fields: { body }
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async getMergeRequestNotes(projectId: number, mrIid: number): Promise<GitlabNote[]> {
|
|
409
|
+
const payload = await this.readJson(`projects/${projectId}/merge_requests/${mrIid}/notes`);
|
|
410
|
+
|
|
411
|
+
if (!Array.isArray(payload)) {
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return payload
|
|
416
|
+
.map((item) => {
|
|
417
|
+
const p = (item ?? {}) as Payload;
|
|
418
|
+
const id = parseInteger(p.id);
|
|
419
|
+
if (id === undefined) return undefined;
|
|
420
|
+
return {
|
|
421
|
+
id,
|
|
422
|
+
body: typeof p.body === "string" ? p.body : "",
|
|
423
|
+
system: p.system === true,
|
|
424
|
+
createdAt: typeof p.created_at === "string" ? p.created_at : ""
|
|
425
|
+
} satisfies GitlabNote;
|
|
426
|
+
})
|
|
427
|
+
.filter((n): n is GitlabNote => n !== undefined);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async createMergeRequest(
|
|
431
|
+
projectId: number,
|
|
432
|
+
sourceBranch: string,
|
|
433
|
+
targetBranch: string,
|
|
434
|
+
title: string,
|
|
435
|
+
description: string,
|
|
436
|
+
issueIid: number
|
|
437
|
+
): Promise<MergeRequestResult> {
|
|
438
|
+
const stdout = await this.request(`projects/${projectId}/merge_requests`, {
|
|
439
|
+
method: "POST",
|
|
440
|
+
fields: {
|
|
441
|
+
source_branch: sourceBranch,
|
|
442
|
+
target_branch: targetBranch,
|
|
443
|
+
title,
|
|
444
|
+
description: `${description}\n\nCloses #${issueIid}`,
|
|
445
|
+
remove_source_branch: "true"
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
const payload = JSON.parse(stdout) as Payload;
|
|
449
|
+
const iid = parseInteger(payload.iid);
|
|
450
|
+
const webUrl = typeof payload.web_url === "string" ? payload.web_url : "";
|
|
451
|
+
if (iid === undefined) {
|
|
452
|
+
throw new Error("Failed to create merge request: missing iid in response.");
|
|
453
|
+
}
|
|
454
|
+
return { iid, webUrl };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async createIssue(projectId: number, title: string, description: string, labels: string[]): Promise<{ iid: number }> {
|
|
458
|
+
const stdout = await this.request(`projects/${projectId}/issues`, {
|
|
459
|
+
method: "POST",
|
|
460
|
+
fields: {
|
|
461
|
+
title,
|
|
462
|
+
description,
|
|
463
|
+
labels: labels.join(",")
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
const payload = JSON.parse(stdout) as Payload;
|
|
467
|
+
const iid = parseInteger(payload.iid);
|
|
468
|
+
if (iid === undefined) {
|
|
469
|
+
throw new Error("Failed to create issue: missing iid in response.");
|
|
470
|
+
}
|
|
471
|
+
return { iid };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async searchIssuesByLabel(projectId: number, label: string): Promise<GitlabIssue[]> {
|
|
475
|
+
const endpoint = this.withQuery(`projects/${projectId}/issues`, {
|
|
476
|
+
labels: label,
|
|
477
|
+
state: "opened"
|
|
478
|
+
});
|
|
479
|
+
const payload = await this.readJson(endpoint);
|
|
480
|
+
|
|
481
|
+
if (!Array.isArray(payload)) {
|
|
482
|
+
return [];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return payload
|
|
486
|
+
.map((item) => {
|
|
487
|
+
try {
|
|
488
|
+
return parseIssue((item ?? {}) as Payload);
|
|
489
|
+
} catch {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
.filter((i): i is GitlabIssue => i !== undefined);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async listClosedIssuesByLabel(projectId: number, label: string): Promise<GitlabIssue[]> {
|
|
497
|
+
const endpoint = this.withQuery(`projects/${projectId}/issues`, {
|
|
498
|
+
labels: label,
|
|
499
|
+
state: "closed"
|
|
500
|
+
});
|
|
501
|
+
const payload = await this.readJson(endpoint);
|
|
502
|
+
|
|
503
|
+
if (!Array.isArray(payload)) {
|
|
504
|
+
return [];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return payload
|
|
508
|
+
.map((item) => {
|
|
509
|
+
try {
|
|
510
|
+
return parseIssue((item ?? {}) as Payload);
|
|
511
|
+
} catch {
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
})
|
|
515
|
+
.filter((i): i is GitlabIssue => i !== undefined);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async listLabels(projectId: number): Promise<GitlabLabel[]> {
|
|
519
|
+
const payload = await this.readJson(`projects/${projectId}/labels`);
|
|
520
|
+
|
|
521
|
+
if (!Array.isArray(payload)) {
|
|
522
|
+
return [];
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return payload
|
|
526
|
+
.map((item) => {
|
|
527
|
+
const p = (item ?? {}) as Payload;
|
|
528
|
+
const id = parseInteger(p.id);
|
|
529
|
+
if (id === undefined) return undefined;
|
|
530
|
+
return { id, name: typeof p.name === "string" ? p.name : "" } satisfies GitlabLabel;
|
|
531
|
+
})
|
|
532
|
+
.filter((l): l is GitlabLabel => l !== undefined);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async createLabel(projectId: number, name: string, color: string): Promise<GitlabLabel> {
|
|
536
|
+
const stdout = await this.request(`projects/${projectId}/labels`, {
|
|
537
|
+
method: "POST",
|
|
538
|
+
fields: { name, color }
|
|
539
|
+
});
|
|
540
|
+
const payload = JSON.parse(stdout) as Payload;
|
|
541
|
+
const id = parseInteger(payload.id);
|
|
542
|
+
if (id === undefined) {
|
|
543
|
+
throw new Error(`Failed to create label "${name}": missing id in response.`);
|
|
544
|
+
}
|
|
545
|
+
return { id, name };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async listBoards(projectId: number): Promise<GitlabBoard[]> {
|
|
549
|
+
const payload = await this.readJson(`projects/${projectId}/boards`);
|
|
550
|
+
|
|
551
|
+
if (!Array.isArray(payload)) {
|
|
552
|
+
return [];
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return payload
|
|
556
|
+
.map((item) => {
|
|
557
|
+
const p = (item ?? {}) as Payload;
|
|
558
|
+
const id = parseInteger(p.id);
|
|
559
|
+
if (id === undefined) return undefined;
|
|
560
|
+
return { id, name: typeof p.name === "string" ? p.name : "" } satisfies GitlabBoard;
|
|
561
|
+
})
|
|
562
|
+
.filter((b): b is GitlabBoard => b !== undefined);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async createBoard(projectId: number, name: string): Promise<GitlabBoard> {
|
|
566
|
+
const stdout = await this.request(`projects/${projectId}/boards`, {
|
|
567
|
+
method: "POST",
|
|
568
|
+
fields: { name }
|
|
569
|
+
});
|
|
570
|
+
const payload = JSON.parse(stdout) as Payload;
|
|
571
|
+
const id = parseInteger(payload.id);
|
|
572
|
+
if (id === undefined) {
|
|
573
|
+
throw new Error(`Failed to create board "${name}": missing id in response.`);
|
|
574
|
+
}
|
|
575
|
+
return { id, name };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async createBoardList(projectId: number, boardId: number, labelId: number): Promise<{ id: number }> {
|
|
579
|
+
const stdout = await this.request(`projects/${projectId}/boards/${boardId}/lists`, {
|
|
580
|
+
method: "POST",
|
|
581
|
+
fields: { label_id: String(labelId) }
|
|
582
|
+
});
|
|
583
|
+
const payload = JSON.parse(stdout) as Payload;
|
|
584
|
+
const id = parseInteger(payload.id);
|
|
585
|
+
if (id === undefined) {
|
|
586
|
+
throw new Error(`Failed to create board list for labelId=${labelId}: missing id in response.`);
|
|
587
|
+
}
|
|
588
|
+
return { id };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async updateUserStatus(emoji: string, message: string, availability?: "busy" | "not_set"): Promise<void> {
|
|
592
|
+
const body: Record<string, string> = { emoji, message };
|
|
593
|
+
if (availability) {
|
|
594
|
+
body.availability = availability;
|
|
595
|
+
}
|
|
596
|
+
await this.fetchApi("user/status", "PUT", body);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async updateUserBio(bio: string): Promise<void> {
|
|
600
|
+
await this.fetchApi("user", "PUT", { bio });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async listAllPendingTodos(): Promise<GitlabTodoItem[]> {
|
|
604
|
+
const endpoint = this.withQuery("todos", {
|
|
605
|
+
state: "pending",
|
|
606
|
+
per_page: "100"
|
|
607
|
+
});
|
|
608
|
+
const payload = await this.readJson(endpoint);
|
|
609
|
+
|
|
610
|
+
if (!Array.isArray(payload)) {
|
|
611
|
+
return [];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return payload
|
|
615
|
+
.map((item) => parseTodoUniversal((item ?? {}) as Payload))
|
|
616
|
+
.filter((item): item is GitlabTodoItem => Boolean(item))
|
|
617
|
+
.sort((left, right) => {
|
|
618
|
+
if (left.createdAt && right.createdAt) {
|
|
619
|
+
return left.createdAt.localeCompare(right.createdAt);
|
|
620
|
+
}
|
|
621
|
+
return left.id - right.id;
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Direct HTTP call using Node's built-in fetch.
|
|
627
|
+
* Used for endpoints where glab CLI's response parsing has issues (e.g. PUT /user/status).
|
|
628
|
+
*/
|
|
629
|
+
private async fetchApi(endpoint: string, method: string, body: Record<string, unknown>): Promise<void> {
|
|
630
|
+
const protocol = this.host.startsWith("localhost") || this.host.startsWith("127.") ? "http" : "https";
|
|
631
|
+
const url = `${protocol}://${this.host}/api/v4/${endpoint}`;
|
|
632
|
+
let lastError: Error | undefined;
|
|
633
|
+
for (let attempt = 0; attempt <= this.retryCount; attempt++) {
|
|
634
|
+
try {
|
|
635
|
+
const response = await fetch(url, {
|
|
636
|
+
method,
|
|
637
|
+
headers: {
|
|
638
|
+
"Content-Type": "application/json",
|
|
639
|
+
"PRIVATE-TOKEN": this.token
|
|
640
|
+
},
|
|
641
|
+
body: JSON.stringify(body)
|
|
642
|
+
});
|
|
643
|
+
if (!response.ok) {
|
|
644
|
+
const text = await response.text().catch(() => "");
|
|
645
|
+
const msg = `GitLab API ${method} ${endpoint} failed: ${response.status} ${response.statusText} ${text}`;
|
|
646
|
+
if (attempt < this.retryCount && response.status >= 500) {
|
|
647
|
+
lastError = new Error(msg);
|
|
648
|
+
await retrySleep(1000 * Math.pow(2, attempt));
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
throw new Error(msg);
|
|
652
|
+
}
|
|
653
|
+
return;
|
|
654
|
+
} catch (error) {
|
|
655
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
656
|
+
if (attempt < this.retryCount && isRetryableError(String(error))) {
|
|
657
|
+
await retrySleep(1000 * Math.pow(2, attempt));
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
throw lastError ?? new Error(`fetchApi failed for ${endpoint}`);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private async readJson(endpoint: string): Promise<unknown> {
|
|
667
|
+
const stdout = await this.request(endpoint);
|
|
668
|
+
return JSON.parse(stdout);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private async request(
|
|
672
|
+
endpoint: string,
|
|
673
|
+
options: {
|
|
674
|
+
method?: "GET" | "POST" | "PUT";
|
|
675
|
+
fields?: Record<string, string>;
|
|
676
|
+
} = {}
|
|
677
|
+
): Promise<string> {
|
|
678
|
+
const args = ["api"];
|
|
679
|
+
|
|
680
|
+
if (options.method) {
|
|
681
|
+
args.push("--method", options.method);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
for (const [key, value] of Object.entries(options.fields ?? {})) {
|
|
685
|
+
args.push("--raw-field", `${key}=${value}`);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
args.push(endpoint);
|
|
689
|
+
|
|
690
|
+
let lastError: Error | undefined;
|
|
691
|
+
for (let attempt = 0; attempt <= this.retryCount; attempt++) {
|
|
692
|
+
try {
|
|
693
|
+
const { stdout } = await this.execFileImpl("glab", args, {
|
|
694
|
+
encoding: "utf8",
|
|
695
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
696
|
+
env: {
|
|
697
|
+
...process.env,
|
|
698
|
+
GITLAB_HOST: this.host,
|
|
699
|
+
GITLAB_TOKEN: this.token
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
return stdout;
|
|
704
|
+
} catch (error) {
|
|
705
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
706
|
+
|
|
707
|
+
if (attempt < this.retryCount && isRetryableError(String(error))) {
|
|
708
|
+
const delayMs = 1000 * Math.pow(2, attempt); // 1s, 2s, 4s
|
|
709
|
+
await retrySleep(delayMs);
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
throw new Error(`glab api request failed for ${endpoint}: ${String(lastError)}`);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
private withQuery(endpoint: string, query: Record<string, string>): string {
|
|
719
|
+
const params = new URLSearchParams(query);
|
|
720
|
+
return `${endpoint}?${params.toString()}`;
|
|
721
|
+
}
|
|
722
|
+
}
|