@openma/matter-gitlab 0.1.0 → 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.
@@ -1,7 +1,11 @@
1
- import { ProviderError } from "@openma/matter/bond";
2
- import { Effect } from "effect";
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";
3
7
  //#region src/bond/http.ts
4
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5
9
  /**
6
10
  * Normalizes a GitLab instance URL to its canonical https origin. Rejects
7
11
  * plain http except for loopback hosts, and rejects paths, credentials,
@@ -31,7 +35,8 @@ const requestJson = (fetchImpl, url, init) => Effect.tryPromise({
31
35
  }
32
36
  return {
33
37
  status: response.status,
34
- body
38
+ body,
39
+ headers: response.headers
35
40
  };
36
41
  },
37
42
  catch: (cause) => new ProviderError({
@@ -48,7 +53,7 @@ const bearer = (token) => ({ authorization: `Bearer ${token}` });
48
53
  * configuration or transport problems and must not end a link.
49
54
  */
50
55
  const statusError = (what, response) => {
51
- const error = isRecord(response.body) && typeof response.body.error === "string" ? response.body.error : void 0;
56
+ const error = isRecord$1(response.body) && typeof response.body.error === "string" ? response.body.error : void 0;
52
57
  return new ProviderError({
53
58
  message: `${what} failed with HTTP ${response.status}${error === void 0 ? "" : ` (${error})`}`,
54
59
  rejected: error === "invalid_grant",
@@ -80,7 +85,7 @@ const discoverInstance = (origin, fetchImpl = globalThis.fetch) => Effect.gen(fu
80
85
  method: "GET",
81
86
  headers: { accept: "application/json" }
82
87
  });
83
- if (response.status !== 200 || !isRecord(response.body)) return yield* Effect.fail(statusError("OIDC discovery", response));
88
+ if (response.status !== 200 || !isRecord$1(response.body)) return yield* Effect.fail(statusError("OIDC discovery", response));
84
89
  const d = response.body;
85
90
  const list = (key) => Array.isArray(d[key]) ? d[key].filter((v) => typeof v === "string") : [];
86
91
  const problems = [];
@@ -117,7 +122,7 @@ const authorizationUrl = (input) => `${canonicalOrigin(input.origin)}/oauth/auth
117
122
  code_challenge_method: "S256"
118
123
  }).toString()}`;
119
124
  const tokenGrant = (body, now) => {
120
- if (!isRecord(body) || typeof body.access_token !== "string") return void 0;
125
+ if (!isRecord$1(body) || typeof body.access_token !== "string") return void 0;
121
126
  const createdMs = typeof body.created_at === "number" ? body.created_at * 1e3 : Date.parse(now);
122
127
  return {
123
128
  accessToken: body.access_token,
@@ -193,8 +198,8 @@ const readUser = (origin, token, fetchImpl = globalThis.fetch) => Effect.gen(fun
193
198
  }
194
199
  });
195
200
  const b = response.body;
196
- if (response.status !== 200 || !isRecord(b) || typeof b.id !== "number") return yield* Effect.fail(statusError("Reading the GitLab user", response));
197
- const identities = Array.isArray(b.identities) ? b.identities.flatMap((i) => isRecord(i) && typeof i.provider === "string" && typeof i.extern_uid === "string" ? [{
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" ? [{
198
203
  provider: i.provider,
199
204
  externUid: i.extern_uid
200
205
  }] : []) : [];
@@ -216,7 +221,7 @@ const readTokenInfo = (origin, token, fetchImpl = globalThis.fetch) => Effect.ge
216
221
  }
217
222
  });
218
223
  const b = response.body;
219
- if (response.status !== 200 || !isRecord(b) || typeof b.id !== "number") return yield* Effect.fail(statusError("Reading token metadata", response));
224
+ if (response.status !== 200 || !isRecord$1(b) || typeof b.id !== "number") return yield* Effect.fail(statusError("Reading token metadata", response));
220
225
  return {
221
226
  id: String(b.id),
222
227
  name: typeof b.name === "string" ? b.name : "",
@@ -633,6 +638,904 @@ const makeGitLabBondProvider = (options = {}) => {
633
638
  };
634
639
  };
635
640
  //#endregion
636
- export { ZERO_OID, assistedByTrailer, authorizationUrl, canonicalOrigin, classifyGitLabRequest, coAuthoredByTrailer, commitAuthor, createPkce, discoverInstance, evaluatePush, exchangeCode, isValidRefName, makeGitLabBondProvider, makeReceivePackHeadParser, mergeRequestBodyProblem, mergeRequestFooter, mergeRequestPushOptions, parseReceivePackHead, readTokenInfo, readUser, refreshAccessToken, revokeSelfToken, revokeToken, tokenExpiryInstant, validateBotCredential, validatePersonalAccessToken };
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 };
637
1540
 
638
1541
  //# sourceMappingURL=index.js.map