cortexdb-connectors 0.2.0__py3-none-any.whl

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,749 @@
1
+ """
2
+ GitLab connector for CortexDB.
3
+
4
+ Polls GitLab REST API v4 for Merge Requests, Issues, Comments/Notes,
5
+ Commits, Pipelines, and Releases, converting them into CortexDB
6
+ Episodes. Uses the ``python-gitlab`` library for API access.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from datetime import datetime, timedelta, timezone
13
+ from typing import Any
14
+
15
+ from cortexdb_connectors.base import (
16
+ Actor,
17
+ CortexConnector,
18
+ EntityRef,
19
+ Episode,
20
+ EpisodeType,
21
+ RawEvent,
22
+ Source,
23
+ Visibility,
24
+ VisibilityLevel,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _SOURCE = Source(system="gitlab")
30
+
31
+ _DEFAULT_EVENTS = frozenset(
32
+ ["merge_request", "issue", "note", "commit", "pipeline", "release"]
33
+ )
34
+
35
+ _EVENT_TYPE_MAP: dict[str, EpisodeType] = {
36
+ "merge_request": EpisodeType.CODE_CHANGE,
37
+ "issue": EpisodeType.ISSUE,
38
+ "note": EpisodeType.COMMENT,
39
+ "commit": EpisodeType.CODE_CHANGE,
40
+ "pipeline": EpisodeType.DEPLOYMENT,
41
+ "release": EpisodeType.DEPLOYMENT,
42
+ }
43
+
44
+
45
+ class GitLabConnector(CortexConnector):
46
+ """Connector that syncs GitLab project events into CortexDB.
47
+
48
+ Parameters
49
+ ----------
50
+ cortex_url:
51
+ Base URL of the CortexDB API.
52
+ cortex_api_key:
53
+ Bearer token for CortexDB authentication.
54
+ gitlab_token:
55
+ GitLab personal access token or OAuth2 token.
56
+ gitlab_url:
57
+ GitLab instance URL. Defaults to ``https://gitlab.com``.
58
+ project_ids:
59
+ List of GitLab project IDs to sync.
60
+ group_ids:
61
+ List of GitLab group IDs (all projects in each group are synced).
62
+ events:
63
+ Event types to capture. Defaults to merge_request, issue, note,
64
+ commit, pipeline, release.
65
+ namespace:
66
+ CortexDB namespace for ingested episodes.
67
+ backfill_days:
68
+ Number of days of historical data to backfill on first sync.
69
+ tenant_id:
70
+ CortexDB tenant identifier.
71
+ """
72
+
73
+ connector_name: str = "gitlab"
74
+
75
+ def __init__(
76
+ self,
77
+ cortex_url: str,
78
+ cortex_api_key: str,
79
+ gitlab_token: str,
80
+ gitlab_url: str = "https://gitlab.com",
81
+ project_ids: list[int] | None = None,
82
+ group_ids: list[int] | None = None,
83
+ events: list[str] | None = None,
84
+ namespace: str = "gitlab",
85
+ backfill_days: int = 90,
86
+ tenant_id: str = "default",
87
+ ) -> None:
88
+ super().__init__(cortex_url, cortex_api_key, tenant_id)
89
+ self.gitlab_token = gitlab_token
90
+ self.gitlab_url = gitlab_url.rstrip("/")
91
+ self.project_ids = project_ids or []
92
+ self.group_ids = group_ids or []
93
+ self.event_filter = set(events) if events else set(_DEFAULT_EVENTS)
94
+ self.namespace = namespace
95
+ self.backfill_days = backfill_days
96
+ self._gl: Any = None
97
+
98
+ # -- GitLab client management --------------------------------------------
99
+
100
+ def _get_client(self) -> Any:
101
+ """Lazy-initialise and return the python-gitlab client."""
102
+ if self._gl is not None:
103
+ return self._gl
104
+
105
+ import gitlab
106
+
107
+ self._gl = gitlab.Gitlab(
108
+ self.gitlab_url,
109
+ private_token=self.gitlab_token,
110
+ per_page=100,
111
+ )
112
+ self._gl.auth()
113
+ return self._gl
114
+
115
+ def _resolve_projects(self) -> list[Any]:
116
+ """Resolve project IDs and group IDs into project objects."""
117
+ gl = self._get_client()
118
+ projects: list[Any] = []
119
+
120
+ for pid in self.project_ids:
121
+ try:
122
+ projects.append(gl.projects.get(pid))
123
+ except Exception:
124
+ logger.error("Could not access GitLab project %s", pid)
125
+
126
+ for gid in self.group_ids:
127
+ try:
128
+ group = gl.groups.get(gid)
129
+ for proj in group.projects.list(
130
+ iterator=True, include_subgroups=True
131
+ ):
132
+ try:
133
+ projects.append(gl.projects.get(proj.id))
134
+ except Exception:
135
+ logger.debug(
136
+ "Could not access project %s in group %s",
137
+ proj.id,
138
+ gid,
139
+ )
140
+ except Exception:
141
+ logger.error("Could not access GitLab group %s", gid)
142
+
143
+ return projects
144
+
145
+ # -- CortexConnector interface -------------------------------------------
146
+
147
+ async def fetch_events(
148
+ self, since: datetime | None = None
149
+ ) -> list[RawEvent]:
150
+ """Fetch events from configured GitLab projects.
151
+
152
+ Iterates over each project and fetches merge requests, issues,
153
+ notes, commits, pipelines, and releases as configured.
154
+ """
155
+ cutoff = since or datetime.now(timezone.utc) - timedelta(
156
+ days=self.backfill_days
157
+ )
158
+ raw_events: list[RawEvent] = []
159
+
160
+ projects = self._resolve_projects()
161
+
162
+ for project in projects:
163
+ proj_name = project.path_with_namespace
164
+ try:
165
+ if "merge_request" in self.event_filter:
166
+ raw_events.extend(
167
+ self._fetch_merge_requests(project, proj_name, cutoff)
168
+ )
169
+ if "issue" in self.event_filter:
170
+ raw_events.extend(
171
+ self._fetch_issues(project, proj_name, cutoff)
172
+ )
173
+ if "commit" in self.event_filter:
174
+ raw_events.extend(
175
+ self._fetch_commits(project, proj_name, cutoff)
176
+ )
177
+ if "pipeline" in self.event_filter:
178
+ raw_events.extend(
179
+ self._fetch_pipelines(project, proj_name, cutoff)
180
+ )
181
+ if "release" in self.event_filter:
182
+ raw_events.extend(
183
+ self._fetch_releases(project, proj_name, cutoff)
184
+ )
185
+ except Exception:
186
+ logger.exception(
187
+ "Error fetching events from project %s", proj_name
188
+ )
189
+
190
+ return raw_events
191
+
192
+ def normalize(self, raw: RawEvent) -> Episode:
193
+ """Convert a GitLab ``RawEvent`` into a CortexDB ``Episode``."""
194
+ data = raw.data
195
+ event_kind = data.get("event_kind", "unknown")
196
+ episode_type = _EVENT_TYPE_MAP.get(event_kind, EpisodeType.CUSTOM)
197
+
198
+ actor = self._extract_actor(data)
199
+ content = self._build_content(event_kind, data)
200
+ entities = self._extract_entities(event_kind, data)
201
+ tags = ["gitlab", event_kind]
202
+ thread_id = self._derive_thread_id(event_kind, data)
203
+
204
+ metadata: dict[str, Any] = {
205
+ "gitlab_event": event_kind,
206
+ "project": data.get("project", ""),
207
+ }
208
+ if data.get("state"):
209
+ metadata["state"] = data["state"]
210
+ if data.get("action"):
211
+ metadata["action"] = data["action"]
212
+
213
+ return Episode(
214
+ actor=actor,
215
+ source=_SOURCE,
216
+ episode_type=episode_type,
217
+ occurred_at=raw.timestamp,
218
+ content=content,
219
+ raw_payload=data,
220
+ tenant_id=self.tenant_id,
221
+ namespace=self.namespace,
222
+ entities=entities,
223
+ tags=tags,
224
+ metadata=metadata,
225
+ thread_id=thread_id,
226
+ idempotency_key=raw.external_id,
227
+ )
228
+
229
+ def map_permissions(self, raw: RawEvent) -> Visibility:
230
+ """Map GitLab project visibility to CortexDB visibility.
231
+
232
+ Public projects yield ``Organization``; internal or private
233
+ projects yield ``Restricted``.
234
+ """
235
+ if raw.data.get("visibility") in ("private", "internal"):
236
+ return Visibility(level=VisibilityLevel.RESTRICTED)
237
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
238
+
239
+ # -- fetchers ------------------------------------------------------------
240
+
241
+ def _fetch_merge_requests(
242
+ self, project: Any, proj_name: str, cutoff: datetime
243
+ ) -> list[RawEvent]:
244
+ """Fetch merge requests and their notes."""
245
+ events: list[RawEvent] = []
246
+ cutoff_str = cutoff.strftime("%Y-%m-%dT%H:%M:%SZ")
247
+
248
+ for mr in project.mergerequests.list(
249
+ iterator=True,
250
+ updated_after=cutoff_str,
251
+ order_by="updated_at",
252
+ sort="desc",
253
+ ):
254
+ ts = self._parse_ts(mr.updated_at)
255
+ events.append(
256
+ RawEvent(
257
+ source="gitlab",
258
+ external_id=f"mr:{project.id}:{mr.iid}",
259
+ timestamp=ts,
260
+ data={
261
+ "event_kind": "merge_request",
262
+ "project": proj_name,
263
+ "project_id": project.id,
264
+ "visibility": project.visibility,
265
+ "iid": mr.iid,
266
+ "title": mr.title,
267
+ "description": (mr.description or "")[:1000],
268
+ "state": mr.state,
269
+ "source_branch": mr.source_branch,
270
+ "target_branch": mr.target_branch,
271
+ "author": self._user_dict(mr.author),
272
+ "assignees": [
273
+ self._user_dict(a)
274
+ for a in (mr.assignees or [])
275
+ ],
276
+ "reviewers": [
277
+ self._user_dict(r)
278
+ for r in (getattr(mr, "reviewers", None) or [])
279
+ ],
280
+ "labels": mr.labels or [],
281
+ "merged_by": self._user_dict(
282
+ getattr(mr, "merged_by", None)
283
+ ),
284
+ "merge_commit_sha": getattr(
285
+ mr, "merge_commit_sha", None
286
+ ),
287
+ },
288
+ permissions={"visibility": project.visibility},
289
+ )
290
+ )
291
+
292
+ # Fetch notes on the MR.
293
+ if "note" in self.event_filter:
294
+ events.extend(
295
+ self._fetch_notes_for(
296
+ project,
297
+ proj_name,
298
+ "merge_request",
299
+ mr.iid,
300
+ mr.title,
301
+ cutoff,
302
+ )
303
+ )
304
+
305
+ return events
306
+
307
+ def _fetch_issues(
308
+ self, project: Any, proj_name: str, cutoff: datetime
309
+ ) -> list[RawEvent]:
310
+ """Fetch issues and their notes."""
311
+ events: list[RawEvent] = []
312
+ cutoff_str = cutoff.strftime("%Y-%m-%dT%H:%M:%SZ")
313
+
314
+ for issue in project.issues.list(
315
+ iterator=True,
316
+ updated_after=cutoff_str,
317
+ order_by="updated_at",
318
+ sort="desc",
319
+ ):
320
+ ts = self._parse_ts(issue.updated_at)
321
+ events.append(
322
+ RawEvent(
323
+ source="gitlab",
324
+ external_id=f"issue:{project.id}:{issue.iid}",
325
+ timestamp=ts,
326
+ data={
327
+ "event_kind": "issue",
328
+ "project": proj_name,
329
+ "project_id": project.id,
330
+ "visibility": project.visibility,
331
+ "iid": issue.iid,
332
+ "title": issue.title,
333
+ "description": (issue.description or "")[:1000],
334
+ "state": issue.state,
335
+ "author": self._user_dict(issue.author),
336
+ "assignees": [
337
+ self._user_dict(a)
338
+ for a in (issue.assignees or [])
339
+ ],
340
+ "labels": issue.labels or [],
341
+ "milestone": (
342
+ issue.milestone.get("title")
343
+ if issue.milestone
344
+ else None
345
+ ),
346
+ },
347
+ permissions={"visibility": project.visibility},
348
+ )
349
+ )
350
+
351
+ if "note" in self.event_filter:
352
+ events.extend(
353
+ self._fetch_notes_for(
354
+ project,
355
+ proj_name,
356
+ "issue",
357
+ issue.iid,
358
+ issue.title,
359
+ cutoff,
360
+ )
361
+ )
362
+
363
+ return events
364
+
365
+ def _fetch_notes_for(
366
+ self,
367
+ project: Any,
368
+ proj_name: str,
369
+ parent_type: str,
370
+ parent_iid: int,
371
+ parent_title: str,
372
+ cutoff: datetime,
373
+ ) -> list[RawEvent]:
374
+ """Fetch notes (comments) for a merge request or issue."""
375
+ events: list[RawEvent] = []
376
+
377
+ try:
378
+ if parent_type == "merge_request":
379
+ parent = project.mergerequests.get(parent_iid)
380
+ else:
381
+ parent = project.issues.get(parent_iid)
382
+
383
+ for note in parent.notes.list(
384
+ iterator=True, order_by="updated_at", sort="desc"
385
+ ):
386
+ ts = self._parse_ts(note.updated_at)
387
+ if ts < cutoff:
388
+ break
389
+
390
+ # Skip system notes (e.g. "assigned to", "changed label").
391
+ if note.system:
392
+ continue
393
+
394
+ events.append(
395
+ RawEvent(
396
+ source="gitlab",
397
+ external_id=f"note:{project.id}:{parent_type}:{parent_iid}:{note.id}",
398
+ timestamp=ts,
399
+ data={
400
+ "event_kind": "note",
401
+ "project": proj_name,
402
+ "project_id": project.id,
403
+ "visibility": project.visibility,
404
+ "parent_type": parent_type,
405
+ "parent_iid": parent_iid,
406
+ "parent_title": parent_title,
407
+ "note_id": note.id,
408
+ "body": (note.body or "")[:1000],
409
+ "author": self._user_dict(note.author),
410
+ "confidential": getattr(
411
+ note, "confidential", False
412
+ ),
413
+ },
414
+ permissions={
415
+ "visibility": project.visibility,
416
+ "confidential": getattr(
417
+ note, "confidential", False
418
+ ),
419
+ },
420
+ )
421
+ )
422
+ except Exception:
423
+ logger.debug(
424
+ "Could not fetch notes for %s !%d in %s",
425
+ parent_type,
426
+ parent_iid,
427
+ proj_name,
428
+ )
429
+
430
+ return events
431
+
432
+ def _fetch_commits(
433
+ self, project: Any, proj_name: str, cutoff: datetime
434
+ ) -> list[RawEvent]:
435
+ """Fetch recent commits from the default branch."""
436
+ events: list[RawEvent] = []
437
+ cutoff_str = cutoff.strftime("%Y-%m-%dT%H:%M:%SZ")
438
+
439
+ try:
440
+ for commit in project.commits.list(
441
+ iterator=True,
442
+ since=cutoff_str,
443
+ ref_name=project.default_branch,
444
+ ):
445
+ ts = self._parse_ts(
446
+ commit.committed_date or commit.created_at
447
+ )
448
+ events.append(
449
+ RawEvent(
450
+ source="gitlab",
451
+ external_id=f"commit:{project.id}:{commit.id}",
452
+ timestamp=ts,
453
+ data={
454
+ "event_kind": "commit",
455
+ "project": proj_name,
456
+ "project_id": project.id,
457
+ "visibility": project.visibility,
458
+ "sha": commit.id,
459
+ "short_id": commit.short_id,
460
+ "title": commit.title,
461
+ "message": (commit.message or "")[:500],
462
+ "author": {
463
+ "name": commit.author_name,
464
+ "email": commit.author_email,
465
+ },
466
+ "committer": {
467
+ "name": commit.committer_name,
468
+ "email": commit.committer_email,
469
+ },
470
+ "stats": getattr(commit, "stats", {}),
471
+ },
472
+ permissions={"visibility": project.visibility},
473
+ )
474
+ )
475
+ except Exception:
476
+ logger.debug("Could not fetch commits for %s", proj_name)
477
+
478
+ return events
479
+
480
+ def _fetch_pipelines(
481
+ self, project: Any, proj_name: str, cutoff: datetime
482
+ ) -> list[RawEvent]:
483
+ """Fetch pipeline runs."""
484
+ events: list[RawEvent] = []
485
+ cutoff_str = cutoff.strftime("%Y-%m-%dT%H:%M:%SZ")
486
+
487
+ try:
488
+ for pipeline in project.pipelines.list(
489
+ iterator=True,
490
+ updated_after=cutoff_str,
491
+ order_by="updated_at",
492
+ sort="desc",
493
+ ):
494
+ ts = self._parse_ts(pipeline.updated_at)
495
+ events.append(
496
+ RawEvent(
497
+ source="gitlab",
498
+ external_id=f"pipeline:{project.id}:{pipeline.id}",
499
+ timestamp=ts,
500
+ data={
501
+ "event_kind": "pipeline",
502
+ "project": proj_name,
503
+ "project_id": project.id,
504
+ "visibility": project.visibility,
505
+ "pipeline_id": pipeline.id,
506
+ "ref": pipeline.ref,
507
+ "status": pipeline.status,
508
+ "source": getattr(pipeline, "source", ""),
509
+ "sha": pipeline.sha,
510
+ },
511
+ permissions={"visibility": project.visibility},
512
+ )
513
+ )
514
+ except Exception:
515
+ logger.debug("Could not fetch pipelines for %s", proj_name)
516
+
517
+ return events
518
+
519
+ def _fetch_releases(
520
+ self, project: Any, proj_name: str, cutoff: datetime
521
+ ) -> list[RawEvent]:
522
+ """Fetch releases."""
523
+ events: list[RawEvent] = []
524
+
525
+ try:
526
+ for release in project.releases.list(iterator=True):
527
+ ts = self._parse_ts(release.released_at or release.created_at)
528
+ if ts < cutoff:
529
+ break
530
+ events.append(
531
+ RawEvent(
532
+ source="gitlab",
533
+ external_id=f"release:{project.id}:{release.tag_name}",
534
+ timestamp=ts,
535
+ data={
536
+ "event_kind": "release",
537
+ "project": proj_name,
538
+ "project_id": project.id,
539
+ "visibility": project.visibility,
540
+ "tag_name": release.tag_name,
541
+ "name": release.name or release.tag_name,
542
+ "description": (release.description or "")[:1000],
543
+ "author": self._user_dict(
544
+ getattr(release, "author", None)
545
+ ),
546
+ },
547
+ permissions={"visibility": project.visibility},
548
+ )
549
+ )
550
+ except Exception:
551
+ logger.debug("Could not fetch releases for %s", proj_name)
552
+
553
+ return events
554
+
555
+ # -- helpers -------------------------------------------------------------
556
+
557
+ @staticmethod
558
+ def _user_dict(user: Any) -> dict[str, Any]:
559
+ """Safely extract user data from a GitLab user object or dict."""
560
+ if user is None:
561
+ return {"id": "", "username": "unknown", "name": "Unknown"}
562
+ if isinstance(user, dict):
563
+ return {
564
+ "id": str(user.get("id", "")),
565
+ "username": user.get("username", "unknown"),
566
+ "name": user.get("name", user.get("username", "Unknown")),
567
+ "avatar_url": user.get("avatar_url"),
568
+ }
569
+ return {
570
+ "id": str(getattr(user, "id", "")),
571
+ "username": getattr(user, "username", "unknown"),
572
+ "name": getattr(user, "name", "Unknown"),
573
+ "avatar_url": getattr(user, "avatar_url", None),
574
+ }
575
+
576
+ @staticmethod
577
+ def _extract_actor(data: dict[str, Any]) -> Actor:
578
+ """Extract actor from event data."""
579
+ author = data.get("author", {})
580
+ if not author:
581
+ return Actor(id="unknown", name="Unknown")
582
+ return Actor(
583
+ id=str(author.get("id") or author.get("email", "")),
584
+ name=author.get("name") or author.get("username", "Unknown"),
585
+ email=author.get("email"),
586
+ avatar_url=author.get("avatar_url"),
587
+ )
588
+
589
+ @staticmethod
590
+ def _build_content(event_kind: str, data: dict[str, Any]) -> str:
591
+ """Build human-readable content from event data."""
592
+ if event_kind == "merge_request":
593
+ state = data.get("state", "")
594
+ title = data.get("title", "")
595
+ source = data.get("source_branch", "")
596
+ target = data.get("target_branch", "")
597
+ desc = data.get("description", "")[:200]
598
+ return f"MR [{state}]: {title} ({source} -> {target})\n{desc}".strip()
599
+
600
+ if event_kind == "issue":
601
+ state = data.get("state", "")
602
+ title = data.get("title", "")
603
+ desc = data.get("description", "")[:200]
604
+ return f"Issue [{state}]: {title}\n{desc}".strip()
605
+
606
+ if event_kind == "note":
607
+ parent_type = data.get("parent_type", "")
608
+ parent_title = data.get("parent_title", "")
609
+ body = data.get("body", "")[:500]
610
+ return f"Comment on {parent_type} [{parent_title}]: {body}"
611
+
612
+ if event_kind == "commit":
613
+ title = data.get("title", "")
614
+ sha = data.get("short_id", "")
615
+ return f"Commit {sha}: {title}"
616
+
617
+ if event_kind == "pipeline":
618
+ status = data.get("status", "")
619
+ ref = data.get("ref", "")
620
+ pid = data.get("pipeline_id", "")
621
+ return f"Pipeline #{pid} [{status}] on {ref}"
622
+
623
+ if event_kind == "release":
624
+ name = data.get("name", "")
625
+ tag = data.get("tag_name", "")
626
+ desc = data.get("description", "")[:200]
627
+ return f"Release {tag}: {name}\n{desc}".strip()
628
+
629
+ return f"GitLab event: {event_kind}"
630
+
631
+ @staticmethod
632
+ def _extract_entities(
633
+ event_kind: str, data: dict[str, Any]
634
+ ) -> list[EntityRef]:
635
+ """Extract entity references from event data."""
636
+ entities: list[EntityRef] = []
637
+
638
+ project = data.get("project", "")
639
+ if project:
640
+ entities.append(
641
+ EntityRef(
642
+ entity_type="project",
643
+ entity_id=str(data.get("project_id", project)),
644
+ display_name=project,
645
+ )
646
+ )
647
+
648
+ author = data.get("author", {})
649
+ if author and author.get("id"):
650
+ entities.append(
651
+ EntityRef(
652
+ entity_type="user",
653
+ entity_id=str(author["id"]),
654
+ display_name=author.get("name") or author.get("username", ""),
655
+ )
656
+ )
657
+
658
+ if event_kind == "merge_request":
659
+ for assignee in data.get("assignees", []):
660
+ if assignee.get("id"):
661
+ entities.append(
662
+ EntityRef(
663
+ entity_type="user",
664
+ entity_id=str(assignee["id"]),
665
+ display_name=assignee.get("username", ""),
666
+ )
667
+ )
668
+ for reviewer in data.get("reviewers", []):
669
+ if reviewer.get("id"):
670
+ entities.append(
671
+ EntityRef(
672
+ entity_type="user",
673
+ entity_id=str(reviewer["id"]),
674
+ display_name=reviewer.get("username", ""),
675
+ )
676
+ )
677
+ for label in data.get("labels", []):
678
+ entities.append(
679
+ EntityRef(
680
+ entity_type="label",
681
+ entity_id=label,
682
+ display_name=label,
683
+ )
684
+ )
685
+
686
+ if event_kind == "issue":
687
+ milestone = data.get("milestone")
688
+ if milestone:
689
+ entities.append(
690
+ EntityRef(
691
+ entity_type="milestone",
692
+ entity_id=str(milestone),
693
+ display_name=str(milestone),
694
+ )
695
+ )
696
+ for label in data.get("labels", []):
697
+ entities.append(
698
+ EntityRef(
699
+ entity_type="label",
700
+ entity_id=label,
701
+ display_name=label,
702
+ )
703
+ )
704
+
705
+ return entities
706
+
707
+ @staticmethod
708
+ def _derive_thread_id(
709
+ event_kind: str, data: dict[str, Any]
710
+ ) -> str | None:
711
+ """Derive thread_id for grouping related events."""
712
+ project = data.get("project", "")
713
+
714
+ if event_kind == "merge_request":
715
+ iid = data.get("iid")
716
+ if iid:
717
+ return f"gl:{project}:mr:{iid}"
718
+
719
+ if event_kind == "issue":
720
+ iid = data.get("iid")
721
+ if iid:
722
+ return f"gl:{project}:issue:{iid}"
723
+
724
+ if event_kind == "note":
725
+ parent_type = data.get("parent_type", "")
726
+ parent_iid = data.get("parent_iid")
727
+ if parent_iid:
728
+ return f"gl:{project}:{parent_type}:{parent_iid}"
729
+
730
+ if event_kind == "pipeline":
731
+ pid = data.get("pipeline_id")
732
+ if pid:
733
+ return f"gl:{project}:pipeline:{pid}"
734
+
735
+ if event_kind == "release":
736
+ tag = data.get("tag_name")
737
+ if tag:
738
+ return f"gl:{project}:release:{tag}"
739
+
740
+ return None
741
+
742
+ @staticmethod
743
+ def _parse_ts(value: str | None) -> datetime:
744
+ if not value:
745
+ return datetime.now(timezone.utc)
746
+ try:
747
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
748
+ except (ValueError, TypeError):
749
+ return datetime.now(timezone.utc)