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,309 @@
1
+ """
2
+ GitHub connector for CortexDB.
3
+
4
+ Polls GitHub repositories for events (pushes, PRs, issues, comments,
5
+ reviews) and ingests them as CortexDB Episodes via the Connector SDK.
6
+ Uses the ``PyGithub`` library for API access.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from datetime import datetime, 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="github")
30
+
31
+ _DEFAULT_EVENT_TYPES = frozenset(
32
+ [
33
+ "push",
34
+ "pull_request",
35
+ "issues",
36
+ "issue_comment",
37
+ "pull_request_review",
38
+ ]
39
+ )
40
+
41
+ # Map GitHub event type strings to CortexDB episode types.
42
+ _EVENT_TYPE_MAP: dict[str, EpisodeType] = {
43
+ "PushEvent": EpisodeType.CODE_CHANGE,
44
+ "PullRequestEvent": EpisodeType.CODE_CHANGE,
45
+ "IssuesEvent": EpisodeType.ISSUE,
46
+ "IssueCommentEvent": EpisodeType.COMMENT,
47
+ "PullRequestReviewEvent": EpisodeType.REVIEW,
48
+ }
49
+
50
+
51
+ class GitHubConnector(CortexConnector):
52
+ """Connector that syncs GitHub repository events into CortexDB.
53
+
54
+ Parameters
55
+ ----------
56
+ cortex_url:
57
+ Base URL of the CortexDB API.
58
+ cortex_api_key:
59
+ Bearer token for CortexDB authentication.
60
+ github_token:
61
+ GitHub personal access token or GitHub App installation token.
62
+ repos:
63
+ List of repository full names (``owner/repo``) to sync.
64
+ events:
65
+ GitHub event types to capture. Defaults to push, PR, issues,
66
+ issue_comment, and pull_request_review.
67
+ tenant_id:
68
+ CortexDB tenant identifier.
69
+ """
70
+
71
+ connector_name: str = "github"
72
+
73
+ def __init__(
74
+ self,
75
+ cortex_url: str,
76
+ cortex_api_key: str,
77
+ github_token: str,
78
+ repos: list[str] | None = None,
79
+ events: list[str] | None = None,
80
+ tenant_id: str = "default",
81
+ ) -> None:
82
+ super().__init__(cortex_url, cortex_api_key, tenant_id)
83
+ self.github_token = github_token
84
+ self.repos = repos or []
85
+ self.event_filter = set(events) if events else set(_DEFAULT_EVENT_TYPES)
86
+
87
+ # -- CortexConnector interface -------------------------------------------
88
+
89
+ async def fetch_events(
90
+ self, since: datetime | None = None
91
+ ) -> list[RawEvent]:
92
+ """Fetch repository events from GitHub since *since*.
93
+
94
+ Uses the repository events API endpoint which returns up to 300
95
+ events (paginated, 30 per page, max 10 pages).
96
+ """
97
+ from github import Github
98
+
99
+ gh = Github(self.github_token)
100
+ raw_events: list[RawEvent] = []
101
+
102
+ for repo_name in self.repos:
103
+ try:
104
+ repo = gh.get_repo(repo_name)
105
+ except Exception:
106
+ logger.error("Could not access repository %s", repo_name)
107
+ continue
108
+
109
+ for event in repo.get_events():
110
+ created_at = event.created_at.replace(tzinfo=timezone.utc)
111
+
112
+ if since and created_at <= since:
113
+ # Events are returned newest-first; once we pass the
114
+ # cursor we can stop for this repo.
115
+ break
116
+
117
+ if not self._matches_filter(event.type):
118
+ continue
119
+
120
+ raw = RawEvent(
121
+ source="github",
122
+ external_id=str(event.id),
123
+ timestamp=created_at,
124
+ data={
125
+ "type": event.type,
126
+ "actor": {
127
+ "login": event.actor.login if event.actor else "unknown",
128
+ "id": event.actor.id if event.actor else 0,
129
+ "avatar_url": (
130
+ event.actor.avatar_url if event.actor else None
131
+ ),
132
+ },
133
+ "repo": repo_name,
134
+ "payload": event.payload,
135
+ "public": not repo.private,
136
+ },
137
+ permissions={"private": repo.private},
138
+ )
139
+ raw_events.append(raw)
140
+
141
+ return raw_events
142
+
143
+ def normalize(self, raw: RawEvent) -> Episode:
144
+ """Convert a GitHub event ``RawEvent`` into a CortexDB ``Episode``."""
145
+ data = raw.data
146
+ payload = data.get("payload", {})
147
+ event_type_str = data.get("type", "")
148
+ episode_type = _EVENT_TYPE_MAP.get(event_type_str, EpisodeType.CUSTOM)
149
+
150
+ actor_data = data.get("actor", {})
151
+ actor = Actor(
152
+ id=str(actor_data.get("id", "")),
153
+ name=actor_data.get("login", "unknown"),
154
+ avatar_url=actor_data.get("avatar_url"),
155
+ )
156
+
157
+ content = self._build_content(event_type_str, payload)
158
+ entities = self._extract_entities(data, payload)
159
+ tags = ["github", event_type_str.lower()]
160
+
161
+ metadata: dict[str, Any] = {"github_event_type": event_type_str}
162
+ if "action" in payload:
163
+ metadata["action"] = payload["action"]
164
+
165
+ # Thread grouping: PR events share the PR number as thread_id.
166
+ thread_id = self._derive_thread_id(event_type_str, payload, data)
167
+
168
+ return Episode(
169
+ actor=actor,
170
+ source=_SOURCE,
171
+ episode_type=episode_type,
172
+ occurred_at=raw.timestamp,
173
+ content=content,
174
+ raw_payload=data,
175
+ tenant_id=self.tenant_id,
176
+ namespace="github",
177
+ entities=entities,
178
+ tags=tags,
179
+ metadata=metadata,
180
+ thread_id=thread_id,
181
+ idempotency_key=raw.external_id,
182
+ )
183
+
184
+ def map_permissions(self, raw: RawEvent) -> Visibility:
185
+ """Map GitHub repository visibility to CortexDB visibility.
186
+
187
+ Public repositories yield ``Organization`` visibility; private
188
+ repositories yield ``Restricted``.
189
+ """
190
+ if raw.permissions.get("private"):
191
+ return Visibility(level=VisibilityLevel.RESTRICTED)
192
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
193
+
194
+ # -- helpers -------------------------------------------------------------
195
+
196
+ def _matches_filter(self, event_type: str) -> bool:
197
+ """Check whether *event_type* matches the configured filter set."""
198
+ mapping = {
199
+ "PushEvent": "push",
200
+ "PullRequestEvent": "pull_request",
201
+ "IssuesEvent": "issues",
202
+ "IssueCommentEvent": "issue_comment",
203
+ "PullRequestReviewEvent": "pull_request_review",
204
+ }
205
+ short = mapping.get(event_type, event_type)
206
+ return short in self.event_filter
207
+
208
+ @staticmethod
209
+ def _build_content(event_type: str, payload: dict[str, Any]) -> str:
210
+ """Produce a human-readable summary of the event."""
211
+ if event_type == "PushEvent":
212
+ commits = payload.get("commits", [])
213
+ count = len(commits)
214
+ ref = payload.get("ref", "")
215
+ messages = "; ".join(
216
+ c.get("message", "").split("\n")[0] for c in commits[:5]
217
+ )
218
+ return f"Pushed {count} commit(s) to {ref}: {messages}"
219
+
220
+ if event_type == "PullRequestEvent":
221
+ pr = payload.get("pull_request", {})
222
+ action = payload.get("action", "")
223
+ title = pr.get("title", "")
224
+ return f"PR {action}: {title}"
225
+
226
+ if event_type == "IssuesEvent":
227
+ issue = payload.get("issue", {})
228
+ action = payload.get("action", "")
229
+ title = issue.get("title", "")
230
+ return f"Issue {action}: {title}"
231
+
232
+ if event_type == "IssueCommentEvent":
233
+ comment = payload.get("comment", {})
234
+ body = (comment.get("body") or "")[:200]
235
+ return f"Comment: {body}"
236
+
237
+ if event_type == "PullRequestReviewEvent":
238
+ review = payload.get("review", {})
239
+ state = review.get("state", "")
240
+ pr = payload.get("pull_request", {})
241
+ title = pr.get("title", "")
242
+ return f"Review ({state}) on PR: {title}"
243
+
244
+ return f"GitHub event: {event_type}"
245
+
246
+ @staticmethod
247
+ def _extract_entities(
248
+ data: dict[str, Any], payload: dict[str, Any]
249
+ ) -> list[EntityRef]:
250
+ """Extract entity references from the event."""
251
+ entities: list[EntityRef] = []
252
+
253
+ repo_name = data.get("repo", "")
254
+ if repo_name:
255
+ entities.append(
256
+ EntityRef(
257
+ entity_type="repository",
258
+ entity_id=repo_name,
259
+ display_name=repo_name,
260
+ )
261
+ )
262
+
263
+ # PR author and reviewers.
264
+ pr = payload.get("pull_request", {})
265
+ if pr:
266
+ pr_user = pr.get("user", {})
267
+ if pr_user:
268
+ entities.append(
269
+ EntityRef(
270
+ entity_type="user",
271
+ entity_id=str(pr_user.get("id", "")),
272
+ display_name=pr_user.get("login", ""),
273
+ )
274
+ )
275
+ for reviewer in pr.get("requested_reviewers", []):
276
+ entities.append(
277
+ EntityRef(
278
+ entity_type="user",
279
+ entity_id=str(reviewer.get("id", "")),
280
+ display_name=reviewer.get("login", ""),
281
+ )
282
+ )
283
+
284
+ # Issue references.
285
+ issue = payload.get("issue", {})
286
+ if issue:
287
+ entities.append(
288
+ EntityRef(
289
+ entity_type="issue",
290
+ entity_id=str(issue.get("number", "")),
291
+ display_name=issue.get("title", ""),
292
+ )
293
+ )
294
+
295
+ return entities
296
+
297
+ @staticmethod
298
+ def _derive_thread_id(
299
+ event_type: str, payload: dict[str, Any], data: dict[str, Any]
300
+ ) -> str | None:
301
+ """Derive a thread_id for grouping related events."""
302
+ repo = data.get("repo", "")
303
+ pr = payload.get("pull_request", {})
304
+ if pr and pr.get("number"):
305
+ return f"{repo}:pr:{pr['number']}"
306
+ issue = payload.get("issue", {})
307
+ if issue and issue.get("number"):
308
+ return f"{repo}:issue:{issue['number']}"
309
+ return None