@openma/matter-gitlab 0.0.0-stage → 0.2.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.
@@ -0,0 +1,1541 @@
1
+ import { ProviderError, checkResourceRequest, resourceId } from "@openma/matter/bond";
2
+ import { Data, Effect, Schema } from "effect";
3
+ import { JsonValueSchema } from "@openma/matter";
4
+ import { CredentialError, makeCredentialResolver } from "@openma/matter/credentials";
5
+ import { IntegrationError } from "@openma/matter/integration";
6
+ import { defineLoop } from "@openma/matter-loop";
7
+ //#region src/bond/http.ts
8
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9
+ /**
10
+ * Normalizes a GitLab instance URL to its canonical https origin. Rejects
11
+ * plain http except for loopback hosts, and rejects paths, credentials,
12
+ * queries, and fragments.
13
+ */
14
+ const canonicalOrigin = (input) => {
15
+ const url = new URL(input);
16
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
17
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) throw new Error("GitLab instance URL must use https");
18
+ if (url.username || url.password || url.search || url.hash) throw new Error("GitLab instance URL must not carry credentials, query, or fragment");
19
+ if (url.pathname !== "/" && url.pathname !== "") throw new Error("GitLab instance URL must be an origin without a path");
20
+ return url.origin.toLowerCase();
21
+ };
22
+ /** One JSON request. Never follows redirects: a redirect is an error. */
23
+ const requestJson = (fetchImpl, url, init) => Effect.tryPromise({
24
+ try: async () => {
25
+ const response = await fetchImpl(url, {
26
+ ...init,
27
+ redirect: "manual"
28
+ });
29
+ const text = await response.text();
30
+ let body = void 0;
31
+ if (text.length > 0) try {
32
+ body = JSON.parse(text);
33
+ } catch {
34
+ body = void 0;
35
+ }
36
+ return {
37
+ status: response.status,
38
+ body,
39
+ headers: response.headers
40
+ };
41
+ },
42
+ catch: (cause) => new ProviderError({
43
+ message: `GitLab request failed: ${new URL(url).pathname}`,
44
+ rejected: false,
45
+ cause
46
+ })
47
+ });
48
+ const bearer = (token) => ({ authorization: `Bearer ${token}` });
49
+ /**
50
+ * Maps a non-2xx GitLab response to a ProviderError without echoing bodies.
51
+ * Only OAuth `invalid_grant` is a rejection, the one answer that means the
52
+ * grant itself is gone. `invalid_client`, other 4xx, and 5xx are
53
+ * configuration or transport problems and must not end a link.
54
+ */
55
+ const statusError = (what, response) => {
56
+ const error = isRecord$1(response.body) && typeof response.body.error === "string" ? response.body.error : void 0;
57
+ return new ProviderError({
58
+ message: `${what} failed with HTTP ${response.status}${error === void 0 ? "" : ` (${error})`}`,
59
+ rejected: error === "invalid_grant",
60
+ status: response.status
61
+ });
62
+ };
63
+ //#endregion
64
+ //#region src/bond/oauth.ts
65
+ const base64url = (bytes) => {
66
+ let binary = "";
67
+ for (const b of bytes) binary += String.fromCharCode(b);
68
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
69
+ };
70
+ /**
71
+ * Reads OIDC discovery and requires authorization code with PKCE S256 and
72
+ * refresh tokens, and an issuer equal to the instance origin. Device flow is
73
+ * recorded when present; GitLab can disable it per instance.
74
+ */
75
+ const discoverInstance = (origin, fetchImpl = globalThis.fetch) => Effect.gen(function* () {
76
+ const canonical = yield* Effect.try({
77
+ try: () => canonicalOrigin(origin),
78
+ catch: (cause) => new ProviderError({
79
+ message: cause instanceof Error ? cause.message : "Invalid origin",
80
+ rejected: true,
81
+ cause
82
+ })
83
+ });
84
+ const response = yield* requestJson(fetchImpl, `${canonical}/.well-known/openid-configuration`, {
85
+ method: "GET",
86
+ headers: { accept: "application/json" }
87
+ });
88
+ if (response.status !== 200 || !isRecord$1(response.body)) return yield* Effect.fail(statusError("OIDC discovery", response));
89
+ const d = response.body;
90
+ const list = (key) => Array.isArray(d[key]) ? d[key].filter((v) => typeof v === "string") : [];
91
+ const problems = [];
92
+ if (typeof d.issuer !== "string" || d.issuer.replace(/\/$/, "").toLowerCase() !== canonical) problems.push("issuer does not match the instance origin");
93
+ if (!list("code_challenge_methods_supported").includes("S256")) problems.push("PKCE S256 is not supported");
94
+ if (!list("grant_types_supported").includes("refresh_token")) problems.push("refresh_token grant is not supported");
95
+ if (typeof d.authorization_endpoint !== "string" || typeof d.token_endpoint !== "string") problems.push("authorization or token endpoint is missing");
96
+ if (problems.length > 0) return yield* Effect.fail(new ProviderError({
97
+ message: `GitLab instance is not usable: ${problems.join("; ")}`,
98
+ rejected: true
99
+ }));
100
+ return {
101
+ origin: canonical,
102
+ deviceFlow: typeof d.device_authorization_endpoint === "string"
103
+ };
104
+ });
105
+ /** A fresh PKCE verifier and its S256 challenge. Keep the verifier server-side. */
106
+ const createPkce = () => Effect.promise(async () => {
107
+ const verifier = base64url(globalThis.crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(48)));
108
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
109
+ return {
110
+ verifier,
111
+ challenge: base64url(new Uint8Array(digest)),
112
+ method: "S256"
113
+ };
114
+ });
115
+ const authorizationUrl = (input) => `${canonicalOrigin(input.origin)}/oauth/authorize?${new URLSearchParams({
116
+ client_id: input.clientId,
117
+ redirect_uri: input.redirectUri,
118
+ response_type: "code",
119
+ scope: input.scopes.join(" "),
120
+ state: input.state,
121
+ code_challenge: input.codeChallenge,
122
+ code_challenge_method: "S256"
123
+ }).toString()}`;
124
+ const tokenGrant = (body, now) => {
125
+ if (!isRecord$1(body) || typeof body.access_token !== "string") return void 0;
126
+ const createdMs = typeof body.created_at === "number" ? body.created_at * 1e3 : Date.parse(now);
127
+ return {
128
+ accessToken: body.access_token,
129
+ ...typeof body.refresh_token === "string" ? { refreshToken: body.refresh_token } : {},
130
+ ...typeof body.expires_in === "number" ? { accessExpiresAt: new Date(createdMs + body.expires_in * 1e3).toISOString() } : {},
131
+ ...typeof body.scope === "string" ? { scopes: body.scope.split(" ").filter(Boolean) } : {}
132
+ };
133
+ };
134
+ const postToken = (client, what, params) => Effect.gen(function* () {
135
+ const origin = canonicalOrigin(client.origin);
136
+ const now = (client.clock ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
137
+ const response = yield* requestJson(client.fetch ?? globalThis.fetch, `${origin}/oauth/token`, {
138
+ method: "POST",
139
+ headers: {
140
+ "content-type": "application/x-www-form-urlencoded",
141
+ accept: "application/json"
142
+ },
143
+ body: new URLSearchParams({
144
+ ...params,
145
+ client_id: client.clientId,
146
+ ...client.clientSecret === void 0 ? {} : { client_secret: client.clientSecret }
147
+ }).toString()
148
+ });
149
+ const grant = response.status === 200 ? tokenGrant(response.body, now) : void 0;
150
+ if (grant === void 0) return yield* Effect.fail(statusError(what, response));
151
+ return grant;
152
+ });
153
+ /** Exchanges an authorization code. A wrong verifier does not consume the code on GitLab. */
154
+ const exchangeCode = (client, input) => postToken(client, "Authorization code exchange", {
155
+ grant_type: "authorization_code",
156
+ code: input.code,
157
+ redirect_uri: input.redirectUri,
158
+ code_verifier: input.codeVerifier
159
+ });
160
+ /**
161
+ * Refreshes a user token. GitLab rotates both tokens and invalidates the old
162
+ * access token immediately, and does not detect refresh-token reuse, so the
163
+ * caller must hold the Bond refresh lease.
164
+ */
165
+ const refreshAccessToken = (client, refreshToken) => postToken(client, "Token refresh", {
166
+ grant_type: "refresh_token",
167
+ refresh_token: refreshToken
168
+ });
169
+ /**
170
+ * Revokes one access or refresh token. GitLab (Doorkeeper) stores an access
171
+ * token and its refresh token as one record, so revoking either ends that
172
+ * pair only. Other tokens the same user holds for the same application keep
173
+ * working: on a live instance, revoking the previous token after a relink left
174
+ * the new one valid.
175
+ */
176
+ const revokeToken = (client, token) => Effect.gen(function* () {
177
+ const origin = canonicalOrigin(client.origin);
178
+ const response = yield* requestJson(client.fetch ?? globalThis.fetch, `${origin}/oauth/revoke`, {
179
+ method: "POST",
180
+ headers: { "content-type": "application/x-www-form-urlencoded" },
181
+ body: new URLSearchParams({
182
+ token,
183
+ client_id: client.clientId,
184
+ ...client.clientSecret === void 0 ? {} : { client_secret: client.clientSecret }
185
+ }).toString()
186
+ });
187
+ if (response.status !== 200) return yield* Effect.fail(statusError("Token revocation", response));
188
+ });
189
+ //#endregion
190
+ //#region src/bond/tokens.ts
191
+ const api = (origin, path) => `${canonicalOrigin(origin)}/api/v4${path}`;
192
+ const readUser = (origin, token, fetchImpl = globalThis.fetch) => Effect.gen(function* () {
193
+ const response = yield* requestJson(fetchImpl, api(origin, "/user"), {
194
+ method: "GET",
195
+ headers: {
196
+ ...bearer(token),
197
+ accept: "application/json"
198
+ }
199
+ });
200
+ const b = response.body;
201
+ if (response.status !== 200 || !isRecord$1(b) || typeof b.id !== "number") return yield* Effect.fail(statusError("Reading the GitLab user", response));
202
+ const identities = Array.isArray(b.identities) ? b.identities.flatMap((i) => isRecord$1(i) && typeof i.provider === "string" && typeof i.extern_uid === "string" ? [{
203
+ provider: i.provider,
204
+ externUid: i.extern_uid
205
+ }] : []) : [];
206
+ return {
207
+ id: String(b.id),
208
+ username: typeof b.username === "string" ? b.username : "",
209
+ name: typeof b.name === "string" ? b.name : "",
210
+ ...typeof b.commit_email === "string" && b.commit_email.length > 0 ? { commitEmail: b.commit_email } : {},
211
+ bot: b.bot === true,
212
+ identities
213
+ };
214
+ });
215
+ const readTokenInfo = (origin, token, fetchImpl = globalThis.fetch) => Effect.gen(function* () {
216
+ const response = yield* requestJson(fetchImpl, api(origin, "/personal_access_tokens/self"), {
217
+ method: "GET",
218
+ headers: {
219
+ ...bearer(token),
220
+ accept: "application/json"
221
+ }
222
+ });
223
+ const b = response.body;
224
+ if (response.status !== 200 || !isRecord$1(b) || typeof b.id !== "number") return yield* Effect.fail(statusError("Reading token metadata", response));
225
+ return {
226
+ id: String(b.id),
227
+ name: typeof b.name === "string" ? b.name : "",
228
+ active: b.active === true,
229
+ revoked: b.revoked === true,
230
+ scopes: Array.isArray(b.scopes) ? b.scopes.filter((s) => typeof s === "string") : [],
231
+ ...typeof b.expires_at === "string" ? { expiresAt: b.expires_at } : {},
232
+ userId: String(b.user_id)
233
+ };
234
+ });
235
+ /** `DELETE /api/v4/personal_access_tokens/self`: a token revokes itself. */
236
+ const revokeSelfToken = (origin, token, fetchImpl = globalThis.fetch) => Effect.gen(function* () {
237
+ const response = yield* requestJson(fetchImpl, api(origin, "/personal_access_tokens/self"), {
238
+ method: "DELETE",
239
+ headers: bearer(token)
240
+ });
241
+ if (response.status !== 204 && response.status !== 200) return yield* Effect.fail(statusError("Token self-revocation", response));
242
+ });
243
+ /** End of the UTC expiry day, which is when GitLab stops accepting the token. */
244
+ const tokenExpiryInstant = (expiresAt) => (/* @__PURE__ */ new Date(`${expiresAt}T23:59:59.999Z`)).toISOString();
245
+ const commonProblems = (info, user, policy) => {
246
+ const problems = [];
247
+ if (info.userId !== user.id) problems.push("user_mismatch");
248
+ if (info.revoked) problems.push("revoked");
249
+ if (!info.active) problems.push("inactive");
250
+ if (info.scopes.some((s) => !policy.allowedScopes.includes(s))) problems.push("scope_not_allowed");
251
+ if (info.expiresAt === void 0) problems.push("no_expiry");
252
+ else {
253
+ const end = Date.parse(tokenExpiryInstant(info.expiresAt));
254
+ const now = Date.parse(policy.now);
255
+ if (end <= now) problems.push("expired");
256
+ else if (end - now > (policy.maxLifetimeDays + 1) * 24 * 60 * 6e4) problems.push("lifetime_exceeded");
257
+ }
258
+ return problems;
259
+ };
260
+ /**
261
+ * Checks a pasted personal access token: active, scopes within the allowed
262
+ * set, an expiry within the maximum lifetime, and a human owner.
263
+ */
264
+ const validatePersonalAccessToken = (info, user, policy) => [...commonProblems(info, user, policy), ...user.bot ? ["is_a_bot"] : []];
265
+ /**
266
+ * Checks a bot credential registered as an Installation: the same token
267
+ * checks, plus `bot: true` on its user. A human account's token is never
268
+ * accepted as an app principal.
269
+ */
270
+ const validateBotCredential = (info, user, policy) => [...commonProblems(info, user, policy), ...user.bot ? [] : ["not_a_bot"]];
271
+ //#endregion
272
+ //#region src/bond/classify.ts
273
+ const deny = (reason) => ({
274
+ allow: false,
275
+ reason
276
+ });
277
+ const normalizePath = (raw) => {
278
+ let decoded;
279
+ try {
280
+ decoded = decodeURIComponent(raw);
281
+ } catch {
282
+ return;
283
+ }
284
+ const path = decoded.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "").toLowerCase();
285
+ const segments = path.split("/");
286
+ if (segments.length < 2 || segments.some((s) => s === "" || s === "." || s === ".." || !/^[\w.-]+$/.test(s))) return;
287
+ return path;
288
+ };
289
+ const projectRefOf = (raw) => /^\d+$/.test(raw) ? {
290
+ kind: "id",
291
+ id: Number(raw)
292
+ } : (() => {
293
+ const path = normalizePath(raw);
294
+ return path === void 0 ? void 0 : {
295
+ kind: "path",
296
+ path
297
+ };
298
+ })();
299
+ /**
300
+ * REST reads, by exact shape. A branch name may carry encoded slashes
301
+ * (`feature%2Fx`), and nothing else is percent-encoded.
302
+ */
303
+ const READ_TAILS = [
304
+ /^$/,
305
+ /^\/merge_requests$/,
306
+ /^\/merge_requests\/\d+(\/(notes|commits|changes|diffs|approvals))?$/,
307
+ /^\/repository\/branches$/,
308
+ /^\/repository\/branches\/([\w.-]|%2[fF])+$/,
309
+ /^\/repository\/commits$/,
310
+ /^\/repository\/commits\/[0-9a-f]{7,64}$/
311
+ ];
312
+ const GIT_ROUTE = /^\/(.+?)(?:\.git)?\/(info\/refs|git-upload-pack|git-receive-pack)$/i;
313
+ /**
314
+ * Classifies one request in GitLab's own URL shape. Allows git smart HTTP
315
+ * fetch and push, REST reads of a project, merge request creation, and
316
+ * merge request notes. Denies GraphQL, LFS, archives, raw files, and every
317
+ * other route.
318
+ */
319
+ const classifyGitLabRequest = (method, url) => {
320
+ const u = typeof url === "string" ? new URL(url, "https://gitlab.invalid") : url;
321
+ const verb = method.toUpperCase();
322
+ const path = u.pathname;
323
+ if (path.includes("/../") || path.endsWith("/..")) return deny("path traversal");
324
+ if (/%2e|%5c/i.test(path)) return deny("encoded traversal characters");
325
+ if (/^\/api\/graphql\/?$/i.test(path)) return deny("GraphQL is not proxied");
326
+ if (/\/info\/lfs(\/|$)/i.test(path)) return deny("LFS is not proxied");
327
+ const git = path.match(GIT_ROUTE);
328
+ if (git !== null && !/^\/api\//i.test(path)) {
329
+ const [, rawProject, endpoint] = git;
330
+ const ref = rawProject === void 0 ? void 0 : normalizePath(rawProject);
331
+ if (ref === void 0) return deny("invalid project path");
332
+ const service = endpoint?.toLowerCase() === "info/refs" ? u.searchParams.get("service") : endpoint?.toLowerCase();
333
+ const operation = service === "git-upload-pack" ? "fetch" : service === "git-receive-pack" ? "push" : void 0;
334
+ if (operation === void 0) return deny("git service not allowed");
335
+ if (verb !== (endpoint?.toLowerCase() === "info/refs" ? "GET" : "POST")) return deny(`method ${verb} not allowed`);
336
+ return {
337
+ allow: true,
338
+ projectRef: {
339
+ kind: "path",
340
+ path: ref
341
+ },
342
+ operation,
343
+ surface: "git"
344
+ };
345
+ }
346
+ const rest = path.match(/^\/api\/v4\/projects\/([^/]+)(\/.*)?$/i);
347
+ if (rest !== null) {
348
+ const [, rawProject, rest2] = rest;
349
+ const projectRef = rawProject === void 0 ? void 0 : projectRefOf(rawProject);
350
+ if (projectRef === void 0) return deny("invalid project reference");
351
+ const tail = (rest2 ?? "").replace(/\/$/, "");
352
+ if (tail.includes("..")) return deny("path traversal");
353
+ const allow = (operation) => ({
354
+ allow: true,
355
+ projectRef,
356
+ operation,
357
+ surface: "rest",
358
+ restTail: tail
359
+ });
360
+ if (verb === "GET" && READ_TAILS.some((re) => re.test(tail))) return allow("read");
361
+ if (verb === "POST") {
362
+ if (u.search !== "") return deny("query parameters on a write");
363
+ if (tail === "/merge_requests") return allow("open_mr");
364
+ if (/^\/merge_requests\/\d+\/notes$/.test(tail)) return allow("comment_mr");
365
+ }
366
+ return deny("REST route not allowed");
367
+ }
368
+ return deny("route not allowed");
369
+ };
370
+ /**
371
+ * GitLab lets `POST /projects/:id/merge_requests` name another project in
372
+ * `target_project_id`, so the URL alone does not bound where the merge
373
+ * request opens. Returns the reason to deny, or undefined when the body
374
+ * targets the classified project.
375
+ *
376
+ * Pass the parsed JSON object or form body and the numeric ID the URL's
377
+ * project reference resolved to, then forward the body you checked
378
+ * (re-serialized), never the raw bytes: parsers disagree on repeated keys.
379
+ * For the same reason a form that repeats `target_project_id`, or uses a
380
+ * variant such as `target_project_id[]`, is denied.
381
+ */
382
+ const mergeRequestBodyProblem = (body, resolvedProjectId) => {
383
+ let target;
384
+ if (body instanceof URLSearchParams) {
385
+ const keys = [...body.keys()].filter((k) => k.startsWith("target_project_id"));
386
+ if (keys.some((k) => k !== "target_project_id")) return "target_project_id has an unsupported form";
387
+ if (keys.length > 1) return "target_project_id appears more than once";
388
+ target = body.get("target_project_id") ?? void 0;
389
+ } else if (typeof body === "object" && body !== null && !Array.isArray(body)) {
390
+ const record = body;
391
+ if (Object.keys(record).some((k) => k.startsWith("target_project_id") && k !== "target_project_id")) return "target_project_id has an unsupported form";
392
+ target = record.target_project_id;
393
+ } else if (body === void 0 || body === null) target = void 0;
394
+ else return "merge request body is not an object";
395
+ if (target === void 0 || target === "") return void 0;
396
+ const id = typeof target === "number" ? target : typeof target === "string" && /^\d+$/.test(target) ? Number(target) : NaN;
397
+ if (!Number.isSafeInteger(id)) return "target_project_id is not a project ID";
398
+ return id === resolvedProjectId ? void 0 : "target_project_id names another project";
399
+ };
400
+ //#endregion
401
+ //#region src/bond/receive-pack.ts
402
+ const ZERO_OID = "0".repeat(40);
403
+ const decoder = new TextDecoder("utf-8", { fatal: true });
404
+ const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
405
+ /**
406
+ * A strict subset of `git check-ref-format`: a full `refs/` name with no
407
+ * control characters, no characters git forbids, no empty, dot-leading, or
408
+ * `.lock` components, no `..`, and no `@{`.
409
+ */
410
+ const isValidRefName = (ref) => {
411
+ if (!ref.startsWith("refs/") || ref.length > 1024) return false;
412
+ if (/[\x00-\x20\x7f~^:?*[\\]/.test(ref)) return false;
413
+ if (ref.includes("..") || ref.includes("@{") || ref.endsWith(".")) return false;
414
+ return ref.split("/").every((c) => c !== "" && !c.startsWith(".") && !c.endsWith(".lock"));
415
+ };
416
+ const concat = (a, b) => {
417
+ if (a.length === 0) return b;
418
+ const out = new Uint8Array(a.length + b.length);
419
+ out.set(a, 0);
420
+ out.set(b, a.length);
421
+ return out;
422
+ };
423
+ /**
424
+ * Incremental receive-pack head parser. Each byte is examined once, and only
425
+ * an unfinished pkt-line is buffered, so a head fed in many small chunks
426
+ * costs linear time. Capabilities are the union over every command line, as
427
+ * in git's `read_head`: a client cannot hide `push-options` behind a later
428
+ * line without them.
429
+ */
430
+ const makeReceivePackHeadParser = (limits = {}) => {
431
+ const maxHeadBytes = limits.maxHeadBytes ?? 1048576;
432
+ const maxCommands = limits.maxCommands ?? 1e3;
433
+ const maxOptions = limits.maxOptions ?? 100;
434
+ let pending = /* @__PURE__ */ new Uint8Array(0);
435
+ let consumed = 0;
436
+ let phase = "commands";
437
+ const commands = [];
438
+ const capabilities = /* @__PURE__ */ new Set();
439
+ const options = [];
440
+ let result = { _tag: "Incomplete" };
441
+ const malformed = (reason) => {
442
+ result = {
443
+ _tag: "Malformed",
444
+ reason
445
+ };
446
+ phase = "done";
447
+ return result;
448
+ };
449
+ const complete = () => {
450
+ phase = "done";
451
+ result = {
452
+ _tag: "Complete",
453
+ head: {
454
+ commands,
455
+ capabilities: [...capabilities],
456
+ options,
457
+ headLength: consumed
458
+ }
459
+ };
460
+ return result;
461
+ };
462
+ /** One pkt-line: text, null for a flush, undefined when more bytes are needed. */
463
+ const nextPkt = () => {
464
+ if (pending.length < 4) return void 0;
465
+ let lenText;
466
+ try {
467
+ lenText = decoder.decode(pending.subarray(0, 4));
468
+ } catch {
469
+ return malformed("bad pkt-line length");
470
+ }
471
+ if (!/^[0-9a-f]{4}$/i.test(lenText)) return malformed("bad pkt-line length");
472
+ const len = Number.parseInt(lenText, 16);
473
+ if (len === 0) {
474
+ pending = pending.subarray(4);
475
+ consumed += 4;
476
+ return null;
477
+ }
478
+ if (len < 4) return malformed("bad pkt-line length");
479
+ if (pending.length < len) return void 0;
480
+ let line;
481
+ try {
482
+ line = decoder.decode(pending.subarray(4, len));
483
+ } catch {
484
+ return malformed("pkt-line is not valid UTF-8");
485
+ }
486
+ pending = pending.subarray(len);
487
+ consumed += len;
488
+ return line.endsWith("\n") ? line.slice(0, -1) : line;
489
+ };
490
+ const step = () => {
491
+ for (;;) {
492
+ const pkt = nextPkt();
493
+ if (pkt === void 0) return consumed + pending.length > maxHeadBytes ? malformed("receive-pack head is too large") : result;
494
+ if (typeof pkt === "object" && pkt !== null) return pkt;
495
+ if (phase === "commands") {
496
+ if (pkt === null) {
497
+ if (commands.length === 0) return malformed("no receive-pack commands");
498
+ if (!capabilities.has("push-options")) return complete();
499
+ phase = "options";
500
+ continue;
501
+ }
502
+ if (pkt.startsWith("shallow ")) continue;
503
+ const nul = pkt.indexOf("\0");
504
+ const command = nul === -1 ? pkt : pkt.slice(0, nul);
505
+ if (nul !== -1) {
506
+ for (const cap of pkt.slice(nul + 1).split(" ")) if (cap !== "") capabilities.add(cap);
507
+ }
508
+ const [oldId, newId, ref, extra] = command.split(" ");
509
+ if (oldId === void 0 || newId === void 0 || ref === void 0 || extra !== void 0 || !OID.test(oldId) || !OID.test(newId)) return malformed("bad receive-pack command");
510
+ if (!isValidRefName(ref)) return malformed("invalid ref name");
511
+ if (commands.length >= maxCommands) return malformed("too many receive-pack commands");
512
+ commands.push({
513
+ oldId,
514
+ newId,
515
+ ref
516
+ });
517
+ continue;
518
+ }
519
+ if (pkt === null) return complete();
520
+ if (options.length >= maxOptions) return malformed("too many push options");
521
+ options.push(pkt);
522
+ }
523
+ };
524
+ return {
525
+ push: (chunk) => {
526
+ if (phase === "done") return result;
527
+ pending = concat(pending, chunk);
528
+ return step();
529
+ },
530
+ remainder: () => phase === "done" ? pending : /* @__PURE__ */ new Uint8Array(0)
531
+ };
532
+ };
533
+ /**
534
+ * Parses as much of the receive-pack head as `bytes` holds. A convenience
535
+ * for a buffered prefix; streaming proxies should use
536
+ * `makeReceivePackHeadParser` and feed chunks as they arrive.
537
+ */
538
+ const parseReceivePackHead = (bytes, limits) => makeReceivePackHeadParser(limits).push(bytes);
539
+ const MERGE_REQUEST_OPTIONS = [
540
+ /^merge_request\.create$/,
541
+ /^merge_request\.target=[\w./-]{1,255}$/,
542
+ /^merge_request\.title=.{1,255}$/s,
543
+ /^merge_request\.description=.{0,10000}$/s,
544
+ /^merge_request\.label=[\w.:-]{1,100}$/,
545
+ /^merge_request\.remove_source_branch$/,
546
+ /^merge_request\.draft$/
547
+ ];
548
+ /**
549
+ * Returns the reason a push is denied, or undefined when it is allowed.
550
+ * Denies tags and other non-branch refs, deletions, the default branch, and
551
+ * any push option outside the merge request allow-list. GitLab still applies
552
+ * its own branch protection afterwards.
553
+ */
554
+ const evaluatePush = (head, policy) => {
555
+ for (const c of head.commands) {
556
+ if (!c.ref.startsWith("refs/heads/")) return `ref ${c.ref} is not a branch`;
557
+ if (/^0+$/.test(c.newId)) return `deleting ${c.ref} is not allowed`;
558
+ const branch = c.ref.slice(11);
559
+ if (branch === policy.defaultBranch && policy.allowDefaultBranch !== true) return "pushing to the default branch is not allowed";
560
+ if (policy.branchPrefix !== void 0 && !branch.startsWith(policy.branchPrefix)) return `branch ${branch} is outside ${policy.branchPrefix}`;
561
+ }
562
+ for (const option of head.options) {
563
+ const name = option.split("=")[0] ?? option;
564
+ if (!policy.allowMergeRequestOptions || !MERGE_REQUEST_OPTIONS.some((re) => re.test(option))) return `push option ${name} is not allowed`;
565
+ }
566
+ if (head.options.includes("merge_request.create") && !head.options.includes(`merge_request.label=${policy.requiredMergeRequestLabel}`)) return `merge requests must carry merge_request.label=${policy.requiredMergeRequestLabel}`;
567
+ };
568
+ //#endregion
569
+ //#region src/bond/attribution.ts
570
+ /** The commit author for a GitLab user: their name and `commit_email`. */
571
+ const commitAuthor = (user) => {
572
+ if (user.commitEmail === void 0) throw new Error(`GitLab user ${user.username} has no commit email`);
573
+ return {
574
+ name: user.name || user.username,
575
+ email: user.commitEmail
576
+ };
577
+ };
578
+ const oneLine = (value) => value.replace(/[\r\n]+/g, " ").trim();
579
+ /** `Assisted-by: <app> <session URL>`, added to every commit. */
580
+ const assistedByTrailer = (appName, sessionUrl) => `Assisted-by: ${oneLine(appName)} <${oneLine(sessionUrl)}>`;
581
+ /** `Co-authored-by` for a participant or, with the app principal, the requester. */
582
+ const coAuthoredByTrailer = (who) => `Co-authored-by: ${oneLine(who.name)} <${oneLine(who.email)}>`;
583
+ /** Description footer for merge requests and comments. */
584
+ const mergeRequestFooter = (input) => ["---", input.requestedBy === void 0 ? `Opened through ${oneLine(input.appName)}: ${oneLine(input.sessionUrl)}` : `Requested by @${oneLine(input.requestedBy)} via ${oneLine(input.appName)}: ${oneLine(input.sessionUrl)}`].join("\n");
585
+ /** `git push -o` options that create a labelled merge request. */
586
+ const mergeRequestPushOptions = (input) => [
587
+ "merge_request.create",
588
+ `merge_request.target=${input.target}`,
589
+ `merge_request.title=${oneLine(input.title)}`,
590
+ `merge_request.description=${oneLine(input.description)}`,
591
+ `merge_request.label=${input.label}`
592
+ ];
593
+ //#endregion
594
+ //#region src/bond/provider.ts
595
+ /**
596
+ * Bond's GitLab provider. A configured `oauth.clientSecret` requires
597
+ * `resolveSecret`; the provider never silently degrades to a public client. GitLab has no delegation marker, so user-principal
598
+ * writes need the ProviderApp's `allowUserPrincipalOutput`. The app principal
599
+ * is a registered bot credential stored on the Installation; GitLab 17.1
600
+ * cannot rotate it from the bot itself, so an administrator replaces it.
601
+ */
602
+ const makeGitLabBondProvider = (options = {}) => {
603
+ const client = (config) => Effect.gen(function* () {
604
+ if (config.oauth === void 0) return yield* Effect.fail(new ProviderError({
605
+ message: `${config.id} has no OAuth application`,
606
+ rejected: false
607
+ }));
608
+ const secretRef = config.oauth.clientSecret;
609
+ if (secretRef !== void 0 && options.resolveSecret === void 0) return yield* Effect.fail(new ProviderError({
610
+ message: `${config.id} has an OAuth client secret but the provider has no resolveSecret`,
611
+ rejected: false
612
+ }));
613
+ const clientSecret = secretRef === void 0 || options.resolveSecret === void 0 ? void 0 : yield* options.resolveSecret(secretRef).pipe(Effect.mapError((cause) => new ProviderError({
614
+ message: "Unable to resolve the OAuth client secret",
615
+ rejected: false,
616
+ cause
617
+ })));
618
+ return {
619
+ origin: config.baseUrl,
620
+ clientId: config.oauth.clientId,
621
+ ...clientSecret === void 0 ? {} : { clientSecret },
622
+ ...options.fetch === void 0 ? {} : { fetch: options.fetch },
623
+ ...options.clock === void 0 ? {} : { clock: options.clock }
624
+ };
625
+ });
626
+ return {
627
+ provider: "gitlab",
628
+ delegationMarker: false,
629
+ refreshUserToken: (config, refreshToken) => client(config).pipe(Effect.flatMap((c) => refreshAccessToken(c, refreshToken))),
630
+ revokeUserToken: (config, tokens) => tokens.method === "pat" ? revokeSelfToken(config.baseUrl, tokens.accessToken, options.fetch) : client(config).pipe(Effect.flatMap((c) => revokeToken(c, tokens.accessToken))),
631
+ appCredential: (config, installation, stored) => stored === void 0 ? Effect.fail(new ProviderError({
632
+ message: `Installation ${installation.id} of ${config.id} has no bot credential`,
633
+ rejected: true
634
+ })) : Effect.succeed({
635
+ token: stored,
636
+ ...installation.credentialExpiresAt === void 0 ? {} : { expiresAt: installation.credentialExpiresAt }
637
+ })
638
+ };
639
+ };
640
+ //#endregion
641
+ //#region src/bond/resources.ts
642
+ /** Minimum GitLab access level per operation: 20 Reporter, 30 Developer. */
643
+ const GITLAB_MIN_ACCESS_LEVEL = {
644
+ fetch: 20,
645
+ read: 20,
646
+ push: 30,
647
+ open_mr: 30,
648
+ comment_mr: 30
649
+ };
650
+ const gitlabManifest = {
651
+ provider: "gitlab",
652
+ title: "GitLab",
653
+ docsUrl: "https://docs.gitlab.com/integration/oauth_provider/",
654
+ setupSchema: {
655
+ type: "object",
656
+ properties: {
657
+ baseUrl: {
658
+ type: "string",
659
+ title: "Instance URL",
660
+ format: "uri",
661
+ description: "Canonical https origin, such as https://gitlab.example.com"
662
+ },
663
+ oauthClientId: {
664
+ type: "string",
665
+ title: "OAuth application ID",
666
+ description: "Optional. Without an OAuth application, users link with personal access tokens."
667
+ },
668
+ oauthClientSecret: {
669
+ type: "string",
670
+ title: "OAuth application secret",
671
+ "x-secret": true
672
+ },
673
+ linkMethodPolicy: {
674
+ type: "string",
675
+ title: "Link methods",
676
+ enum: [
677
+ "pat_only",
678
+ "oauth_only",
679
+ "oauth_preferred"
680
+ ],
681
+ default: "oauth_preferred"
682
+ },
683
+ principalPolicy: {
684
+ type: "string",
685
+ title: "Principal policy",
686
+ enum: [
687
+ "user_only",
688
+ "user",
689
+ "app"
690
+ ],
691
+ default: "user_only"
692
+ },
693
+ maxPatLifetimeDays: {
694
+ type: "integer",
695
+ title: "Maximum personal access token lifetime, in days",
696
+ minimum: 1,
697
+ maximum: 365,
698
+ default: 90
699
+ },
700
+ allowUserPrincipalOutput: {
701
+ type: "boolean",
702
+ title: "Allow writes as the linked user",
703
+ default: false
704
+ }
705
+ },
706
+ required: [
707
+ "baseUrl",
708
+ "linkMethodPolicy",
709
+ "principalPolicy"
710
+ ]
711
+ },
712
+ linkMethods: ["oauth", "pat"],
713
+ resourceKinds: [{
714
+ kind: "project",
715
+ title: "Project",
716
+ operations: [
717
+ {
718
+ name: "fetch",
719
+ description: "Clone and fetch over git",
720
+ access: "read"
721
+ },
722
+ {
723
+ name: "read",
724
+ description: "Read the project through the REST API",
725
+ access: "read"
726
+ },
727
+ {
728
+ name: "push",
729
+ description: "Push branches",
730
+ access: "write"
731
+ },
732
+ {
733
+ name: "open_mr",
734
+ description: "Open merge requests",
735
+ access: "write"
736
+ },
737
+ {
738
+ name: "comment_mr",
739
+ description: "Comment on merge requests",
740
+ access: "write"
741
+ }
742
+ ]
743
+ }]
744
+ };
745
+ const PROJECT_ID = /^[1-9][0-9]{0,18}$/;
746
+ const DEFAULT_PAGE = 20;
747
+ const MAX_PAGE = 100;
748
+ const toResource = (body) => {
749
+ if (!isRecord$1(body) || typeof body.id !== "number") return void 0;
750
+ const title = typeof body.path_with_namespace === "string" ? body.path_with_namespace : typeof body.name === "string" ? body.name : String(body.id);
751
+ return {
752
+ id: resourceId("project", body.id),
753
+ kind: "project",
754
+ title,
755
+ ...typeof body.web_url === "string" ? { url: body.web_url } : {}
756
+ };
757
+ };
758
+ const accessLevel = (body) => {
759
+ const permissions = isRecord$1(body.permissions) ? body.permissions : {};
760
+ const level = (key) => {
761
+ const entry = permissions[key];
762
+ return isRecord$1(entry) && typeof entry.access_level === "number" ? entry.access_level : 0;
763
+ };
764
+ return Math.max(level("project_access"), level("group_access"));
765
+ };
766
+ /**
767
+ * Projects as Bond resources, with IDs `project:<numeric id>`. Paths are
768
+ * display data only. `list` returns projects where the token holds at least
769
+ * Reporter; `validate` checks the access level each operation needs and
770
+ * refuses writes to archived projects. An instance administrator without a
771
+ * membership reports no access level and is treated as having none.
772
+ */
773
+ const makeGitLabResourceCatalog = (options = {}) => {
774
+ const fetchImpl = options.fetch ?? globalThis.fetch;
775
+ return {
776
+ list: ({ config, token, query, cursor, limit }) => Effect.gen(function* () {
777
+ const perPage = Math.min(Math.max(limit ?? DEFAULT_PAGE, 1), MAX_PAGE);
778
+ const page = cursor !== void 0 && /^[1-9][0-9]*$/.test(cursor) ? cursor : "1";
779
+ const url = new URL("/api/v4/projects", config.baseUrl);
780
+ url.searchParams.set("membership", "true");
781
+ url.searchParams.set("min_access_level", "20");
782
+ url.searchParams.set("simple", "true");
783
+ url.searchParams.set("order_by", "last_activity_at");
784
+ url.searchParams.set("per_page", String(perPage));
785
+ url.searchParams.set("page", page);
786
+ if (query !== void 0 && query.trim() !== "") url.searchParams.set("search", query.trim());
787
+ const response = yield* requestJson(fetchImpl, url.toString(), { headers: bearer(token) });
788
+ if (response.status < 200 || response.status >= 300) return yield* Effect.fail(statusError("GitLab project list", response));
789
+ const items = (Array.isArray(response.body) ? response.body : []).flatMap((row) => {
790
+ const resource = toResource(row);
791
+ return resource === void 0 ? [] : [resource];
792
+ });
793
+ const next = response.headers?.get("x-next-page") ?? "";
794
+ return /^[1-9][0-9]*$/.test(next) ? {
795
+ items,
796
+ nextCursor: next
797
+ } : { items };
798
+ }),
799
+ validate: ({ config, token, resource, operations }) => Effect.gen(function* () {
800
+ const checked = checkResourceRequest(gitlabManifest, resource, operations);
801
+ if (!checked.ok) return checked;
802
+ if (!PROJECT_ID.test(checked.key)) return {
803
+ ok: false,
804
+ reason: "malformed_id",
805
+ message: "GitLab project resources use the numeric project ID"
806
+ };
807
+ const url = new URL(`/api/v4/projects/${checked.key}`, config.baseUrl);
808
+ const response = yield* requestJson(fetchImpl, url.toString(), { headers: bearer(token) });
809
+ if (response.status === 404 || response.status === 403) return {
810
+ ok: false,
811
+ reason: "not_found",
812
+ message: `Project ${checked.key} is not visible to this token`
813
+ };
814
+ if (response.status < 200 || response.status >= 300 || !isRecord$1(response.body)) return yield* Effect.fail(statusError("GitLab project lookup", response));
815
+ const project = toResource(response.body);
816
+ if (project === void 0 || project.id !== resource) return yield* Effect.fail(new ProviderError({
817
+ message: "GitLab project lookup returned a different project",
818
+ rejected: false
819
+ }));
820
+ const required = Math.max(0, ...operations.map((op) => GITLAB_MIN_ACCESS_LEVEL[op] ?? Infinity));
821
+ const held = accessLevel(response.body);
822
+ if (held < required) return {
823
+ ok: false,
824
+ reason: "insufficient_access",
825
+ message: `Needs GitLab access level ${required}; the token holds ${held}`
826
+ };
827
+ if (operations.some((op) => checked.kind.operations.find((o) => o.name === op)?.access === "write") && response.body.archived === true) return {
828
+ ok: false,
829
+ reason: "insufficient_access",
830
+ message: `Project ${checked.key} is archived`
831
+ };
832
+ return {
833
+ ok: true,
834
+ resource: project
835
+ };
836
+ })
837
+ };
838
+ };
839
+ /** The provider, manifest, and resource catalog a host registers for GitLab. */
840
+ const makeGitLabBinding = (options = {}) => ({
841
+ manifest: gitlabManifest,
842
+ provider: makeGitLabBondProvider(options),
843
+ resources: makeGitLabResourceCatalog(options.fetch === void 0 ? {} : { fetch: options.fetch })
844
+ });
845
+ //#endregion
846
+ //#region src/bond/integration/shared.ts
847
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
848
+ const sensitiveKeys = /* @__PURE__ */ new Set([
849
+ "access_token",
850
+ "authorization",
851
+ "client_secret",
852
+ "password",
853
+ "private_key",
854
+ "private_token",
855
+ "runners_token",
856
+ "secret",
857
+ "secret_token",
858
+ "token",
859
+ "webhook_secret"
860
+ ]);
861
+ /** Webhook payloads are untrusted. Recursively remove conventional credential
862
+ * keys before a provider snapshot can enter the durable event log. */
863
+ const withoutGitLabCredentials = (value) => {
864
+ if (Array.isArray(value)) return value.map(withoutGitLabCredentials);
865
+ if (!isRecord(value)) return value;
866
+ return Object.fromEntries(Object.entries(value).flatMap(([key, nested]) => sensitiveKeys.has(key.toLowerCase()) ? [] : [[key, withoutGitLabCredentials(nested)]]));
867
+ };
868
+ const gitlabSource = (input) => ({
869
+ provider: "gitlab",
870
+ authority: input.authority,
871
+ ...input.project === void 0 ? {} : { conversationId: input.project },
872
+ ...input.threadId === void 0 ? {} : { threadId: input.threadId },
873
+ ...input.messageId === void 0 ? {} : { messageId: input.messageId },
874
+ ...input.uri === void 0 ? {} : { uri: input.uri }
875
+ });
876
+ const positiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
877
+ const nonEmptyString = (value) => typeof value === "string" && value.length > 0;
878
+ /** GitLab timestamps come as ISO 8601 or as `2026-08-23 08:00:00 UTC`. */
879
+ const isoTimestamp = (...values) => {
880
+ for (const value of values) {
881
+ if (typeof value !== "string") continue;
882
+ const parsed = Date.parse(value.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) UTC$/, "$1T$2Z"));
883
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
884
+ }
885
+ };
886
+ //#endregion
887
+ //#region src/bond/integration/client.ts
888
+ const invalid = (message) => new IntegrationError({
889
+ message,
890
+ retryable: false
891
+ });
892
+ const validate = (value) => {
893
+ if (!isRecord(value) || !nonEmptyString(value.token) || !nonEmptyString(value.baseUrl)) return Effect.fail(invalid("GitLab credentials must include a baseUrl and a token"));
894
+ let url;
895
+ try {
896
+ url = new URL(value.baseUrl);
897
+ } catch {
898
+ return Effect.fail(invalid("GitLab baseUrl is not a URL"));
899
+ }
900
+ if (url.protocol !== "https:" && url.protocol !== "http:") return Effect.fail(invalid("GitLab baseUrl must use http or https"));
901
+ return Effect.succeed({
902
+ baseUrl: url.href,
903
+ token: value.token
904
+ });
905
+ };
906
+ const makeGitLabCredentialsFor = (option) => {
907
+ if (typeof option === "object" && option !== null && !("resolve" in option)) {
908
+ const fixed = validate(option);
909
+ return () => fixed;
910
+ }
911
+ const resolver = typeof option === "function" ? makeCredentialResolver(({ authority }) => option(authority)) : option;
912
+ return (authority) => authority.length === 0 ? Effect.fail(invalid("GitLab authority is required to resolve credentials")) : Effect.suspend(() => resolver.resolve({
913
+ integrationId: "gitlab",
914
+ authority
915
+ })).pipe(Effect.mapError((cause) => new IntegrationError({
916
+ message: cause instanceof CredentialError ? cause.message : `Unable to resolve GitLab credentials for ${authority}`,
917
+ retryable: cause instanceof CredentialError ? cause.retryable : true,
918
+ cause
919
+ })), Effect.flatMap(validate));
920
+ };
921
+ const makeGitLabClient = (input) => {
922
+ const retryAtFrom = (response) => {
923
+ const retryAfter = response.headers.get("retry-after");
924
+ if (retryAfter !== null) {
925
+ const seconds = Number.parseFloat(retryAfter);
926
+ if (Number.isFinite(seconds) && seconds >= 0) {
927
+ const now = Date.parse(input.clock());
928
+ if (Number.isFinite(now)) return new Date(now + seconds * 1e3).toISOString();
929
+ }
930
+ const absolute = Date.parse(retryAfter);
931
+ if (Number.isFinite(absolute)) return new Date(absolute).toISOString();
932
+ }
933
+ if (response.status !== 429) return void 0;
934
+ const reset = Number.parseInt(response.headers.get("ratelimit-reset") ?? "", 10);
935
+ return Number.isSafeInteger(reset) && reset >= 0 ? (/* @__PURE__ */ new Date(reset * 1e3)).toISOString() : void 0;
936
+ };
937
+ /** Calls `/api/v4{path}` on the authority's instance. */
938
+ const request = (options) => {
939
+ const label = `GitLab ${options.method} ${options.path}`;
940
+ return input.credentialsFor(options.authority).pipe(Effect.flatMap(({ baseUrl, token }) => Effect.tryPromise({
941
+ try: () => input.fetch(new URL(`api/v4${options.path}`, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`), {
942
+ method: options.method,
943
+ headers: {
944
+ accept: "application/json",
945
+ authorization: `Bearer ${token}`,
946
+ ...options.body === void 0 ? {} : { "content-type": "application/json; charset=utf-8" }
947
+ },
948
+ ...options.body === void 0 ? {} : { body: JSON.stringify(options.body) }
949
+ }),
950
+ catch: (cause) => new IntegrationError({
951
+ message: `${label} request failed`,
952
+ retryable: true,
953
+ cause
954
+ })
955
+ })), Effect.flatMap((response) => {
956
+ const retryable = response.status === 429 || response.status >= 500;
957
+ const retryAt = retryAtFrom(response);
958
+ const failure = (message, cause) => new IntegrationError({
959
+ message,
960
+ retryable,
961
+ ...retryAt === void 0 ? {} : { retryAt },
962
+ ...cause === void 0 ? {} : { cause }
963
+ });
964
+ return Effect.tryPromise({
965
+ try: () => response.text(),
966
+ catch: (cause) => failure(`${label} response could not be read`, cause)
967
+ }).pipe(Effect.flatMap((text) => {
968
+ let payload = null;
969
+ if (text.length > 0) try {
970
+ payload = JSON.parse(text);
971
+ } catch (cause) {
972
+ return Effect.fail(response.ok ? failure(`${label} returned invalid JSON`, cause) : failure(`${label} failed with HTTP ${response.status}`));
973
+ }
974
+ if (!response.ok) return Effect.fail(failure(`${label} failed with HTTP ${response.status}`, payload));
975
+ if (!Schema.is(JsonValueSchema)(payload)) return Effect.fail(failure(`${label} returned non-portable data`));
976
+ return Effect.succeed({
977
+ payload,
978
+ headers: response.headers
979
+ });
980
+ }));
981
+ }));
982
+ };
983
+ return { request };
984
+ };
985
+ //#endregion
986
+ //#region src/bond/integration/delivery.ts
987
+ /** Every Effect operation `deliver` accepts. */
988
+ const GITLAB_OPERATIONS = [
989
+ "merge_request.note.create",
990
+ "merge_request.discussion.reply",
991
+ "issue.note.create",
992
+ "issue.discussion.reply"
993
+ ];
994
+ const fail = (message) => Effect.fail(new IntegrationError({
995
+ message,
996
+ retryable: false
997
+ }));
998
+ /**
999
+ * Delivers GitLab Effects. Every input names the `authority` whose
1000
+ * credentials post the note, the `projectId` (numeric id or full path), the
1001
+ * merge request or issue `iid`, and a non-empty `body`. Discussion replies
1002
+ * also name the `discussionId`; replying to a single comment turns it into a
1003
+ * thread.
1004
+ */
1005
+ const makeGitLabDelivery = (client) => (effect) => {
1006
+ const operation = GITLAB_OPERATIONS.find((name) => name === effect.operation);
1007
+ if (effect.integrationId !== "gitlab" || operation === void 0) return fail(`GitLab does not support the operation ${effect.operation}`);
1008
+ if (!isRecord(effect.input)) return fail(`GitLab ${operation} requires an object input`);
1009
+ const { authority, projectId, body, discussionId } = effect.input;
1010
+ const [parent, iidKey] = operation.startsWith("merge_request.") ? ["merge_requests", "mergeRequestIid"] : ["issues", "issueIid"];
1011
+ const iid = effect.input[iidKey];
1012
+ const replying = operation.endsWith(".discussion.reply");
1013
+ if (!nonEmptyString(authority) || !(positiveInteger(projectId) || nonEmptyString(projectId)) || !positiveInteger(iid) || !nonEmptyString(body) || replying && !nonEmptyString(discussionId)) return fail(`GitLab ${operation} requires authority, projectId, a positive ${iidKey}, ${replying ? "discussionId, " : ""}and a non-empty body`);
1014
+ const base = `/projects/${encodeURIComponent(String(projectId))}/${parent}/${iid}`;
1015
+ const path = replying ? `${base}/discussions/${encodeURIComponent(discussionId)}/notes` : `${base}/notes`;
1016
+ return client.request({
1017
+ authority,
1018
+ method: "POST",
1019
+ path,
1020
+ body: { body }
1021
+ }).pipe(Effect.map(({ payload }) => ({ providerReceipt: isRecord(payload) ? {
1022
+ ...positiveInteger(payload.id) ? { noteId: payload.id } : {},
1023
+ ...nonEmptyString(discussionId) ? { discussionId } : {}
1024
+ } : null })));
1025
+ };
1026
+ //#endregion
1027
+ //#region src/bond/integration/events.ts
1028
+ const MERGE_REQUEST_ACTIONS = {
1029
+ open: "opened",
1030
+ reopen: "reopened",
1031
+ update: "updated",
1032
+ close: "closed",
1033
+ merge: "merged",
1034
+ approved: "approved",
1035
+ unapproved: "unapproved",
1036
+ approval: "approval_added",
1037
+ unapproval: "approval_removed"
1038
+ };
1039
+ const NOTEABLE_KINDS = {
1040
+ MergeRequest: "merge_request",
1041
+ Issue: "issue",
1042
+ Commit: "commit",
1043
+ Snippet: "snippet"
1044
+ };
1045
+ const PIPELINE_STATUSES = [
1046
+ "created",
1047
+ "waiting_for_resource",
1048
+ "preparing",
1049
+ "pending",
1050
+ "running",
1051
+ "success",
1052
+ "failed",
1053
+ "canceled",
1054
+ "skipped",
1055
+ "manual",
1056
+ "scheduled"
1057
+ ];
1058
+ /** Every Event type `ingest` can produce. */
1059
+ const GITLAB_EVENT_TYPES = [
1060
+ ...Object.values(MERGE_REQUEST_ACTIONS).map((action) => `gitlab.merge_request.${action}`),
1061
+ ...Object.values(NOTEABLE_KINDS).flatMap((kind) => [`gitlab.${kind}.note.created`, `gitlab.${kind}.note.updated`]),
1062
+ ...PIPELINE_STATUSES.map((status) => `gitlab.pipeline.${status}`),
1063
+ "gitlab.event.received"
1064
+ ];
1065
+ const optional = (key, value) => value === void 0 ? {} : { [key]: value };
1066
+ const mergeRequest = (attributes) => {
1067
+ const action = typeof attributes.action === "string" ? MERGE_REQUEST_ACTIONS[attributes.action] : void 0;
1068
+ const iid = positiveInteger(attributes.iid) ? attributes.iid : void 0;
1069
+ const lastCommit = isRecord(attributes.last_commit) ? attributes.last_commit : void 0;
1070
+ const draft = typeof attributes.draft === "boolean" ? attributes.draft : typeof attributes.work_in_progress === "boolean" ? attributes.work_in_progress : void 0;
1071
+ const uri = nonEmptyString(attributes.url) ? attributes.url : void 0;
1072
+ const occurredAt = isoTimestamp(attributes.updated_at, attributes.created_at);
1073
+ return {
1074
+ type: action === void 0 ? "gitlab.event.received" : `gitlab.merge_request.${action}`,
1075
+ ...iid === void 0 ? {} : { threadId: `merge_request:${iid}` },
1076
+ ...uri === void 0 ? {} : { uri },
1077
+ ...occurredAt === void 0 ? {} : { occurredAt },
1078
+ fields: {
1079
+ resourceType: "merge_request",
1080
+ ...optional("mergeRequestIid", iid),
1081
+ ...optional("title", typeof attributes.title === "string" ? attributes.title : void 0),
1082
+ ...optional("draft", draft),
1083
+ ...optional("state", typeof attributes.state === "string" ? attributes.state : void 0),
1084
+ ...optional("sourceBranch", nonEmptyString(attributes.source_branch) ? attributes.source_branch : void 0),
1085
+ ...optional("targetBranch", nonEmptyString(attributes.target_branch) ? attributes.target_branch : void 0),
1086
+ ...optional("headSha", nonEmptyString(lastCommit?.id) ? lastCommit.id : void 0),
1087
+ newCommits: attributes.action === "open" || attributes.action === "reopen" || attributes.action === "update" && nonEmptyString(attributes.oldrev)
1088
+ }
1089
+ };
1090
+ };
1091
+ const note = (attributes, payload) => {
1092
+ const kind = typeof attributes.noteable_type === "string" ? NOTEABLE_KINDS[attributes.noteable_type] : void 0;
1093
+ const action = attributes.action === void 0 || attributes.action === "create" ? "created" : attributes.action === "update" ? "updated" : void 0;
1094
+ const parent = kind === "merge_request" ? payload.merge_request : kind === "issue" ? payload.issue : kind === "commit" ? payload.commit : kind === "snippet" ? payload.snippet : void 0;
1095
+ const parentRecord = isRecord(parent) ? parent : void 0;
1096
+ const parentIid = positiveInteger(parentRecord?.iid) ? parentRecord.iid : void 0;
1097
+ const threadId = kind === "merge_request" || kind === "issue" ? parentIid === void 0 ? void 0 : `${kind}:${parentIid}` : kind === "commit" ? nonEmptyString(parentRecord?.id) ? `commit:${parentRecord.id}` : void 0 : kind === "snippet" && positiveInteger(parentRecord?.id) ? `snippet:${parentRecord.id}` : void 0;
1098
+ const noteId = positiveInteger(attributes.id) ? attributes.id : void 0;
1099
+ const uri = nonEmptyString(attributes.url) ? attributes.url : void 0;
1100
+ const occurredAt = isoTimestamp(attributes.updated_at, attributes.created_at);
1101
+ return {
1102
+ type: kind === void 0 || action === void 0 ? "gitlab.event.received" : `gitlab.${kind}.note.${action}`,
1103
+ ...threadId === void 0 ? {} : { threadId },
1104
+ ...noteId === void 0 ? {} : { messageId: `note:${noteId}` },
1105
+ ...uri === void 0 ? {} : { uri },
1106
+ ...occurredAt === void 0 ? {} : { occurredAt },
1107
+ fields: {
1108
+ resourceType: "note",
1109
+ ...optional("noteId", noteId),
1110
+ ...optional("text", typeof attributes.note === "string" ? attributes.note : void 0),
1111
+ ...optional("discussionId", nonEmptyString(attributes.discussion_id) ? attributes.discussion_id : void 0),
1112
+ ...optional("noteableType", typeof attributes.noteable_type === "string" ? attributes.noteable_type : void 0),
1113
+ ...optional(kind === "issue" ? "issueIid" : "mergeRequestIid", kind === "merge_request" || kind === "issue" ? parentIid : void 0),
1114
+ ...optional("draft", kind === "merge_request" && typeof parentRecord?.draft === "boolean" ? parentRecord.draft : void 0),
1115
+ system: attributes.system === true
1116
+ }
1117
+ };
1118
+ };
1119
+ const pipeline = (attributes, payload) => {
1120
+ const status = PIPELINE_STATUSES.find((candidate) => candidate === attributes.status);
1121
+ const id = positiveInteger(attributes.id) ? attributes.id : void 0;
1122
+ const mergeRequestIid = isRecord(payload.merge_request) && positiveInteger(payload.merge_request.iid) ? payload.merge_request.iid : void 0;
1123
+ const uri = nonEmptyString(attributes.url) ? attributes.url : void 0;
1124
+ const occurredAt = isoTimestamp(attributes.finished_at, attributes.created_at);
1125
+ return {
1126
+ type: status === void 0 ? "gitlab.event.received" : `gitlab.pipeline.${status}`,
1127
+ ...mergeRequestIid !== void 0 ? { threadId: `merge_request:${mergeRequestIid}` } : id === void 0 ? {} : { threadId: `pipeline:${id}` },
1128
+ ...id === void 0 || status === void 0 ? {} : { messageId: `pipeline:${id}:${status}` },
1129
+ ...uri === void 0 ? {} : { uri },
1130
+ ...occurredAt === void 0 ? {} : { occurredAt },
1131
+ fields: {
1132
+ resourceType: "pipeline",
1133
+ ...optional("pipelineId", id),
1134
+ ...optional("status", status),
1135
+ ...optional("ref", nonEmptyString(attributes.ref) ? attributes.ref : void 0),
1136
+ ...optional("sha", nonEmptyString(attributes.sha) ? attributes.sha : void 0),
1137
+ ...optional("mergeRequestIid", mergeRequestIid)
1138
+ }
1139
+ };
1140
+ };
1141
+ const normalize = (payload) => {
1142
+ const attributes = isRecord(payload.object_attributes) ? payload.object_attributes : void 0;
1143
+ if (attributes !== void 0) {
1144
+ if (payload.object_kind === "merge_request") return mergeRequest(attributes);
1145
+ if (payload.object_kind === "note") return note(attributes, payload);
1146
+ if (payload.object_kind === "pipeline") return pipeline(attributes, payload);
1147
+ }
1148
+ return {
1149
+ type: "gitlab.event.received",
1150
+ fields: {}
1151
+ };
1152
+ };
1153
+ /**
1154
+ * Turns a verified `GitLabWebhookInput` into one WorkEvent. Inputs of another
1155
+ * kind produce no Events. The Event id combines the authority and the
1156
+ * delivery id, so a GitLab retry is the same Event.
1157
+ */
1158
+ const normalizeGitLabEvents = (input, clock) => Effect.try({
1159
+ try: () => {
1160
+ if (!isRecord(input) || input.kind !== "gitlab.webhook" || !nonEmptyString(input.authority) || !nonEmptyString(input.eventName) || !nonEmptyString(input.deliveryId) || !isRecord(input.payload) || !Schema.is(JsonValueSchema)(input.payload)) return [];
1161
+ const webhook = input;
1162
+ const payload = webhook.payload;
1163
+ const project = isRecord(payload.project) ? payload.project : void 0;
1164
+ const projectId = positiveInteger(project?.id) ? project.id : positiveInteger(payload.project_id) ? payload.project_id : void 0;
1165
+ const projectPath = nonEmptyString(project?.path_with_namespace) ? project.path_with_namespace : void 0;
1166
+ const user = isRecord(payload.user) ? payload.user : void 0;
1167
+ const actor = {
1168
+ ...optional("id", positiveInteger(user?.id) ? String(user.id) : void 0),
1169
+ ...optional("username", nonEmptyString(user?.username) ? user.username : void 0),
1170
+ ...optional("name", nonEmptyString(user?.name) ? user.name : void 0)
1171
+ };
1172
+ const normalized = normalize(payload);
1173
+ const receivedAt = clock();
1174
+ const id = `gitlab:${webhook.authority}:${webhook.deliveryId}`;
1175
+ return [{
1176
+ schemaVersion: "0.1",
1177
+ id,
1178
+ type: normalized.type,
1179
+ occurredAt: normalized.occurredAt ?? receivedAt,
1180
+ receivedAt,
1181
+ idempotencyKey: id,
1182
+ source: gitlabSource({
1183
+ authority: webhook.authority,
1184
+ ...projectPath === void 0 ? {} : { project: projectPath },
1185
+ ...normalized.threadId === void 0 ? {} : { threadId: normalized.threadId },
1186
+ ...normalized.messageId === void 0 ? {} : { messageId: normalized.messageId },
1187
+ ...normalized.uri === void 0 ? {} : { uri: normalized.uri }
1188
+ }),
1189
+ payload: {
1190
+ activation: "observation",
1191
+ eventName: webhook.eventName,
1192
+ ...optional("objectKind", nonEmptyString(payload.object_kind) ? payload.object_kind : void 0),
1193
+ authority: webhook.authority,
1194
+ ...optional("instance", webhook.instance),
1195
+ ...optional("projectId", projectId),
1196
+ ...optional("project", projectPath),
1197
+ ...optional("resource", projectId === void 0 ? void 0 : `project:${projectId}`),
1198
+ ...normalized.fields,
1199
+ ...Object.keys(actor).length === 0 ? {} : { actor }
1200
+ },
1201
+ raw: withoutGitLabCredentials(webhook.payload)
1202
+ }];
1203
+ },
1204
+ catch: (cause) => new IntegrationError({
1205
+ message: "Unable to normalize GitLab webhook",
1206
+ retryable: false,
1207
+ cause
1208
+ })
1209
+ });
1210
+ //#endregion
1211
+ //#region src/bond/integration/http.ts
1212
+ var GitLabHttpIngressError = class extends Data.TaggedError("GitLabHttpIngressError") {};
1213
+ var GitLabHttpSubmissionError = class extends Data.TaggedError("GitLabHttpSubmissionError") {};
1214
+ const sha256 = async (value) => new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)));
1215
+ /**
1216
+ * Compares the `X-Gitlab-Token` header with the configured secret token in
1217
+ * constant time. Both values are hashed first, so the comparison does not
1218
+ * reveal the secret's length either. An empty configured token never matches.
1219
+ */
1220
+ const verifyGitLabToken = (input) => Effect.tryPromise({
1221
+ try: async () => {
1222
+ if (input.webhookToken.length === 0) return false;
1223
+ const [expected, received] = await Promise.all([sha256(input.webhookToken), sha256(input.received)]);
1224
+ let difference = 0;
1225
+ for (let index = 0; index < expected.length; index += 1) difference |= expected[index] ^ received[index];
1226
+ return difference === 0;
1227
+ },
1228
+ catch: (cause) => new GitLabHttpIngressError({
1229
+ message: "Unable to verify GitLab request",
1230
+ status: 401,
1231
+ cause
1232
+ })
1233
+ });
1234
+ const header = (request, name) => {
1235
+ const value = request.headers.get(name);
1236
+ return value === null || value.length === 0 ? void 0 : value;
1237
+ };
1238
+ /**
1239
+ * Verifies and decodes one GitLab webhook request. The delivery id is the
1240
+ * first of `webhook-id` and `Idempotency-Key` (both stable across retries,
1241
+ * GitLab 17.4+) and `X-Gitlab-Event-UUID` (GitLab 14.8+).
1242
+ */
1243
+ const decodeGitLabHttpRequest = (request, options) => Effect.gen(function* () {
1244
+ if (!(yield* verifyGitLabToken({
1245
+ webhookToken: options.webhookToken,
1246
+ received: header(request, "x-gitlab-token") ?? ""
1247
+ }))) return yield* new GitLabHttpIngressError({
1248
+ message: "GitLab webhook token is invalid",
1249
+ status: 401
1250
+ });
1251
+ const eventName = header(request, "x-gitlab-event");
1252
+ const deliveryId = header(request, "webhook-id") ?? header(request, "idempotency-key") ?? header(request, "x-gitlab-event-uuid");
1253
+ if (eventName === void 0 || deliveryId === void 0) return yield* new GitLabHttpIngressError({
1254
+ message: "GitLab event and delivery id headers are required",
1255
+ status: 400
1256
+ });
1257
+ const rawBody = yield* Effect.tryPromise({
1258
+ try: () => request.text(),
1259
+ catch: (cause) => new GitLabHttpIngressError({
1260
+ message: "Unable to read GitLab request body",
1261
+ status: 400,
1262
+ cause
1263
+ })
1264
+ });
1265
+ const payload = yield* Effect.try({
1266
+ try: () => JSON.parse(rawBody),
1267
+ catch: (cause) => new GitLabHttpIngressError({
1268
+ message: "GitLab request body is malformed",
1269
+ status: 400,
1270
+ cause
1271
+ })
1272
+ });
1273
+ if (!isRecord(payload) || !Schema.is(JsonValueSchema)(payload)) return yield* new GitLabHttpIngressError({
1274
+ message: "GitLab request body must be a portable JSON object",
1275
+ status: 400
1276
+ });
1277
+ const instance = header(request, "x-gitlab-instance");
1278
+ return {
1279
+ kind: "gitlab.webhook",
1280
+ authority: options.authority,
1281
+ eventName,
1282
+ deliveryId,
1283
+ ...instance === void 0 ? {} : { instance },
1284
+ payload
1285
+ };
1286
+ });
1287
+ const jsonResponse = (body, status) => new Response(JSON.stringify(body), {
1288
+ status,
1289
+ headers: { "content-type": "application/json; charset=utf-8" }
1290
+ });
1291
+ /**
1292
+ * A webhook endpoint for one GitLab authority. Hosts that serve many
1293
+ * authorities from one route call `decodeGitLabHttpRequest` with the token
1294
+ * and authority they look up for the request instead.
1295
+ */
1296
+ const makeGitLabHttpEndpoint = (options) => ({
1297
+ method: "POST",
1298
+ path: options.path ?? "/gitlab/events",
1299
+ handle: (request) => Effect.runPromise(decodeGitLabHttpRequest(request, options).pipe(Effect.flatMap((input) => Effect.tryPromise({
1300
+ try: () => options.submit(input),
1301
+ catch: (cause) => new GitLabHttpSubmissionError({
1302
+ message: "Unable to submit GitLab work",
1303
+ status: 503,
1304
+ cause
1305
+ })
1306
+ }).pipe(Effect.as(new Response(null, { status: 202 })))), Effect.catchAll((error) => Effect.succeed(jsonResponse({
1307
+ ok: false,
1308
+ error: error.message
1309
+ }, error.status)))))
1310
+ });
1311
+ //#endregion
1312
+ //#region src/bond/integration/integration.ts
1313
+ /**
1314
+ * The GitLab work integration: `ingest` takes a `GitLabWebhookInput` from
1315
+ * `decodeGitLabHttpRequest`, and `deliver` posts merge request and issue
1316
+ * notes.
1317
+ */
1318
+ const makeGitLabIntegration = (options) => {
1319
+ const clock = options.clock ?? (() => (/* @__PURE__ */ new Date()).toISOString());
1320
+ const client = makeGitLabClient({
1321
+ credentialsFor: makeGitLabCredentialsFor(options.credentials),
1322
+ fetch: options.fetch ?? ((...args) => globalThis.fetch(...args)),
1323
+ clock
1324
+ });
1325
+ return {
1326
+ manifest: {
1327
+ id: "gitlab",
1328
+ displayName: "GitLab",
1329
+ events: GITLAB_EVENT_TYPES,
1330
+ operations: GITLAB_OPERATIONS
1331
+ },
1332
+ ingest: (input) => normalizeGitLabEvents(input, clock),
1333
+ deliver: makeGitLabDelivery(client)
1334
+ };
1335
+ };
1336
+ //#endregion
1337
+ //#region src/bond/integration/templates.ts
1338
+ const VERSION = "0.1.0";
1339
+ const agentField = {
1340
+ type: "string",
1341
+ minLength: 1,
1342
+ title: "Agent",
1343
+ "x-oma-widget": "agent"
1344
+ };
1345
+ const projectsField = {
1346
+ type: "array",
1347
+ items: {
1348
+ type: "string",
1349
+ minLength: 1
1350
+ },
1351
+ minItems: 1,
1352
+ title: "Projects",
1353
+ description: "Bond resource ids of the projects the Loop watches",
1354
+ "x-oma-widget": "bond-resources",
1355
+ "x-bond-provider": "gitlab"
1356
+ };
1357
+ const instructionsField = {
1358
+ type: "string",
1359
+ default: "",
1360
+ title: "Extra instructions",
1361
+ description: "Appended to the instructions the Agent receives"
1362
+ };
1363
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1364
+ const fields = (event) => isObject(event.payload) ? event.payload : {};
1365
+ const watched = (config, event) => {
1366
+ const resource = fields(event).resource;
1367
+ return typeof resource === "string" && Array.isArray(config.projects) && config.projects.includes(resource);
1368
+ };
1369
+ const mergeRequestTarget = (event) => {
1370
+ const { projectId, mergeRequestIid } = fields(event);
1371
+ return typeof projectId === "number" && typeof mergeRequestIid === "number" ? {
1372
+ authority: event.source.authority,
1373
+ projectId,
1374
+ mergeRequestIid
1375
+ } : void 0;
1376
+ };
1377
+ /**
1378
+ * One Session per merge request: both templates continue the same Work
1379
+ * Thread, so an Agent that reviewed a merge request remembers it when
1380
+ * mentioned there later.
1381
+ */
1382
+ const sessionFor = (target) => {
1383
+ const scopeId = `gitlab:${target.authority}:project:${target.projectId}`;
1384
+ return {
1385
+ scopeId,
1386
+ workThreadId: `merge_request:${target.mergeRequestIid}`,
1387
+ privacyPartition: scopeId
1388
+ };
1389
+ };
1390
+ /** Runs one Agent Turn and posts its output as a merge request note. */
1391
+ const answer = async (work, target, instructions, reply) => {
1392
+ const binding = sessionFor(target);
1393
+ const capability = `gitlab.${reply.operation}`;
1394
+ const context = await work.context.project({
1395
+ scopeId: binding.scopeId,
1396
+ workThreadId: binding.workThreadId,
1397
+ items: [work.context.event(), work.context.value({
1398
+ kind: "instructions",
1399
+ value: instructions,
1400
+ provenance: [{
1401
+ sourceType: "loop-template",
1402
+ sourceId: "gitlab"
1403
+ }]
1404
+ })],
1405
+ grants: [capability]
1406
+ });
1407
+ const turn = await work.agent(String(work.config.agentId)).session(binding).turn({
1408
+ context,
1409
+ allow: [capability]
1410
+ });
1411
+ const body = typeof turn.output === "string" ? turn.output.trim() : "";
1412
+ if (turn.outcome !== "completed" || body.length === 0) return work.react.none(turn.outcome === "completed" ? "The Agent produced no reply" : `The Agent Turn ended ${turn.outcome}`);
1413
+ const note = await work.effect(context, {
1414
+ integrationId: "gitlab",
1415
+ operation: reply.operation,
1416
+ input: {
1417
+ authority: target.authority,
1418
+ projectId: target.projectId,
1419
+ mergeRequestIid: target.mergeRequestIid,
1420
+ body,
1421
+ ...reply.operation === "merge_request.discussion.reply" ? { discussionId: reply.discussionId } : {}
1422
+ }
1423
+ });
1424
+ return work.react.effects([note]);
1425
+ };
1426
+ const extra = (config) => typeof config.instructions === "string" && config.instructions.trim() !== "" ? `\n\n${config.instructions.trim()}` : "";
1427
+ /**
1428
+ * Reviews a merge request when it opens, reopens, or receives new commits,
1429
+ * and posts the review as a note. Title or label edits do not trigger a
1430
+ * review. Draft merge requests are skipped unless `skipDrafts` is false.
1431
+ */
1432
+ const gitlabMergeRequestReview = defineLoop({
1433
+ id: "gitlab-mr-review",
1434
+ version: VERSION,
1435
+ description: "Review merge requests when they open or receive new commits",
1436
+ spec: {
1437
+ sources: [
1438
+ "gitlab.merge_request.opened",
1439
+ "gitlab.merge_request.reopened",
1440
+ "gitlab.merge_request.updated"
1441
+ ],
1442
+ workThread: "gitlab.merge_request",
1443
+ session: "per-work-thread",
1444
+ reaction: "effects"
1445
+ },
1446
+ runAs: "app",
1447
+ requiredBonds: [{
1448
+ provider: "gitlab",
1449
+ operations: ["fetch", "comment_mr"]
1450
+ }],
1451
+ configSchema: {
1452
+ type: "object",
1453
+ properties: {
1454
+ agentId: agentField,
1455
+ projects: projectsField,
1456
+ skipDrafts: {
1457
+ type: "boolean",
1458
+ default: true,
1459
+ title: "Skip draft merge requests"
1460
+ },
1461
+ instructions: instructionsField
1462
+ },
1463
+ required: ["agentId", "projects"]
1464
+ }
1465
+ }, (app) => app.on([
1466
+ "gitlab.merge_request.opened",
1467
+ "gitlab.merge_request.reopened",
1468
+ "gitlab.merge_request.updated"
1469
+ ], (work) => {
1470
+ const target = mergeRequestTarget(work.event);
1471
+ const { draft, newCommits, project } = fields(work.event);
1472
+ if (target === void 0) return work.react.none("Not a merge request Event");
1473
+ if (!watched(work.config, work.event)) return work.react.none("Project is not watched");
1474
+ if (newCommits !== true) return work.react.none("No new commits");
1475
+ if (draft === true && work.config.skipDrafts !== false) return work.react.none("Draft merge request");
1476
+ return answer(work, target, `Review merge request !${target.mergeRequestIid} in ${String(project ?? target.projectId)}. Fetch the source branch through the GitLab Bond, read the diff against the target branch, and reply with a concise review: correctness problems first, then risks, then optional suggestions. Your reply is posted as a merge request note.` + extra(work.config), { operation: "merge_request.note.create" });
1477
+ }));
1478
+ const mentionPattern = (username) => new RegExp(`(^|[^\\w@.-])@${username.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w.-])`, "i");
1479
+ /**
1480
+ * Answers a merge request comment that mentions the Agent's GitLab user, in
1481
+ * the same thread. System notes and the Agent's own notes are ignored, so a
1482
+ * reply never triggers another reply.
1483
+ */
1484
+ const gitlabMention = defineLoop({
1485
+ id: "gitlab-mention",
1486
+ version: VERSION,
1487
+ description: "Answer merge request comments that mention the Agent",
1488
+ spec: {
1489
+ sources: ["gitlab.merge_request.note.created"],
1490
+ workThread: "gitlab.merge_request",
1491
+ session: "per-work-thread",
1492
+ reaction: "effects"
1493
+ },
1494
+ runAs: "app",
1495
+ requiredBonds: [{
1496
+ provider: "gitlab",
1497
+ operations: ["fetch", "comment_mr"]
1498
+ }],
1499
+ configSchema: {
1500
+ type: "object",
1501
+ properties: {
1502
+ agentId: agentField,
1503
+ projects: projectsField,
1504
+ username: {
1505
+ type: "string",
1506
+ minLength: 1,
1507
+ title: "GitLab username",
1508
+ description: "The app principal's username, without @. Comments that mention it are answered."
1509
+ },
1510
+ instructions: instructionsField
1511
+ },
1512
+ required: [
1513
+ "agentId",
1514
+ "projects",
1515
+ "username"
1516
+ ]
1517
+ }
1518
+ }, (app, { config }) => {
1519
+ const username = String(config.username).replace(/^@/, "");
1520
+ const mentions = mentionPattern(username);
1521
+ app.on("gitlab.merge_request.note.created", (work) => {
1522
+ const target = mergeRequestTarget(work.event);
1523
+ const { text, system, discussionId, actor } = fields(work.event);
1524
+ const author = isObject(actor) ? actor.username : void 0;
1525
+ if (target === void 0) return work.react.none("Not a merge request comment");
1526
+ if (!watched(work.config, work.event)) return work.react.none("Project is not watched");
1527
+ if (system === true) return work.react.none("System note");
1528
+ if (typeof author === "string" && author.toLowerCase() === username.toLowerCase()) return work.react.none("The Agent's own note");
1529
+ if (typeof text !== "string" || !mentions.test(text)) return work.react.none("The Agent is not mentioned");
1530
+ return answer(work, target, `@${String(author ?? "someone")} mentioned you (@${username}) in a comment on merge request !${target.mergeRequestIid}. Answer the comment. Fetch the code through the GitLab Bond if you need it. Your reply is posted in the same thread.` + extra(work.config), typeof discussionId === "string" && discussionId.length > 0 ? {
1531
+ operation: "merge_request.discussion.reply",
1532
+ discussionId
1533
+ } : { operation: "merge_request.note.create" });
1534
+ });
1535
+ });
1536
+ /** The GitLab templates, in the order a console lists them. */
1537
+ const gitlabTemplates = [gitlabMergeRequestReview, gitlabMention];
1538
+ //#endregion
1539
+ export { GITLAB_EVENT_TYPES, GITLAB_MIN_ACCESS_LEVEL, GITLAB_OPERATIONS, GitLabHttpIngressError, GitLabHttpSubmissionError, ZERO_OID, assistedByTrailer, authorizationUrl, canonicalOrigin, classifyGitLabRequest, coAuthoredByTrailer, commitAuthor, createPkce, decodeGitLabHttpRequest, discoverInstance, evaluatePush, exchangeCode, gitlabManifest, gitlabMention, gitlabMergeRequestReview, gitlabTemplates, isValidRefName, makeGitLabBinding, makeGitLabBondProvider, makeGitLabClient, makeGitLabCredentialsFor, makeGitLabDelivery, makeGitLabHttpEndpoint, makeGitLabIntegration, makeGitLabResourceCatalog, makeReceivePackHeadParser, mergeRequestBodyProblem, mergeRequestFooter, mergeRequestPushOptions, normalizeGitLabEvents, parseReceivePackHead, readTokenInfo, readUser, refreshAccessToken, revokeSelfToken, revokeToken, tokenExpiryInstant, validateBotCredential, validatePersonalAccessToken, verifyGitLabToken };
1540
+
1541
+ //# sourceMappingURL=index.js.map