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.
- cortexdb_connectors/__init__.py +40 -0
- cortexdb_connectors/base.py +364 -0
- cortexdb_connectors/cli.py +476 -0
- cortexdb_connectors/confluence/__init__.py +359 -0
- cortexdb_connectors/discord/__init__.py +646 -0
- cortexdb_connectors/github/__init__.py +309 -0
- cortexdb_connectors/gitlab/__init__.py +749 -0
- cortexdb_connectors/google_workspace/__init__.py +1033 -0
- cortexdb_connectors/hubspot/__init__.py +489 -0
- cortexdb_connectors/insights/__init__.py +40 -0
- cortexdb_connectors/insights/api.py +252 -0
- cortexdb_connectors/insights/detectors.py +771 -0
- cortexdb_connectors/insights/engine.py +308 -0
- cortexdb_connectors/intercom/__init__.py +631 -0
- cortexdb_connectors/jira/__init__.py +298 -0
- cortexdb_connectors/linear/__init__.py +705 -0
- cortexdb_connectors/notion/__init__.py +702 -0
- cortexdb_connectors/pagerduty/__init__.py +298 -0
- cortexdb_connectors/salesforce/__init__.py +399 -0
- cortexdb_connectors/servicenow/__init__.py +460 -0
- cortexdb_connectors/slack/__init__.py +331 -0
- cortexdb_connectors/state.py +145 -0
- cortexdb_connectors/teams/__init__.py +656 -0
- cortexdb_connectors/webhooks.py +672 -0
- cortexdb_connectors/worker.py +192 -0
- cortexdb_connectors/zendesk/__init__.py +496 -0
- cortexdb_connectors-0.2.0.dist-info/METADATA +207 -0
- cortexdb_connectors-0.2.0.dist-info/RECORD +31 -0
- cortexdb_connectors-0.2.0.dist-info/WHEEL +5 -0
- cortexdb_connectors-0.2.0.dist-info/entry_points.txt +2 -0
- cortexdb_connectors-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|