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,672 @@
|
|
|
1
|
+
"""Webhook receivers for real-time event ingestion from Slack and GitHub.
|
|
2
|
+
|
|
3
|
+
Provides a Starlette-based HTTP server that accepts webhook payloads from
|
|
4
|
+
Slack (Events API) and GitHub, normalizes them into CortexDB Episodes using
|
|
5
|
+
the existing connector classes, and ingests them via the CortexDB HTTP API.
|
|
6
|
+
|
|
7
|
+
Environment variables:
|
|
8
|
+
CORTEXDB_URL Base URL of the CortexDB API (default: http://localhost:8080)
|
|
9
|
+
CORTEXDB_API_KEY Bearer token for CortexDB authentication
|
|
10
|
+
SLACK_SIGNING_SECRET Used to verify Slack request signatures
|
|
11
|
+
GITHUB_WEBHOOK_SECRET Used to verify GitHub X-Hub-Signature-256 headers
|
|
12
|
+
WEBHOOK_PORT Port to listen on (default: 8081)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import time
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from starlette.applications import Starlette
|
|
28
|
+
from starlette.requests import Request
|
|
29
|
+
from starlette.responses import JSONResponse, PlainTextResponse
|
|
30
|
+
from starlette.routing import Route
|
|
31
|
+
|
|
32
|
+
from cortexdb_connectors.base import (
|
|
33
|
+
Actor,
|
|
34
|
+
EntityRef,
|
|
35
|
+
Episode,
|
|
36
|
+
EpisodeType,
|
|
37
|
+
RawEvent,
|
|
38
|
+
Source,
|
|
39
|
+
Visibility,
|
|
40
|
+
VisibilityLevel,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
_SLACK_SOURCE = Source(system="slack")
|
|
46
|
+
_GITHUB_SOURCE = Source(system="github")
|
|
47
|
+
|
|
48
|
+
# Map GitHub webhook event types to CortexDB episode types.
|
|
49
|
+
_GITHUB_WEBHOOK_TYPE_MAP: dict[str, EpisodeType] = {
|
|
50
|
+
"push": EpisodeType.CODE_CHANGE,
|
|
51
|
+
"pull_request": EpisodeType.CODE_CHANGE,
|
|
52
|
+
"issues": EpisodeType.ISSUE,
|
|
53
|
+
"issue_comment": EpisodeType.COMMENT,
|
|
54
|
+
"pull_request_review": EpisodeType.REVIEW,
|
|
55
|
+
"deployment_status": EpisodeType.DEPLOYMENT,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Slack webhook handler
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
class SlackWebhookHandler:
|
|
64
|
+
"""Handles incoming Slack Events API webhook requests.
|
|
65
|
+
|
|
66
|
+
Responsibilities:
|
|
67
|
+
- Respond to Slack URL verification challenges.
|
|
68
|
+
- Verify request authenticity using the Slack signing secret.
|
|
69
|
+
- Normalize incoming event_callback events into CortexDB Episodes.
|
|
70
|
+
- Forward episodes to the CortexDB ingestion API.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
SUPPORTED_EVENT_TYPES = {"message", "message_changed", "reaction_added"}
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
signing_secret: str,
|
|
78
|
+
cortexdb_url: str,
|
|
79
|
+
cortexdb_api_key: str,
|
|
80
|
+
tenant_id: str = "default",
|
|
81
|
+
) -> None:
|
|
82
|
+
self.signing_secret = signing_secret
|
|
83
|
+
self.cortexdb_url = cortexdb_url.rstrip("/")
|
|
84
|
+
self.cortexdb_api_key = cortexdb_api_key
|
|
85
|
+
self.tenant_id = tenant_id
|
|
86
|
+
|
|
87
|
+
def verify_signature(
|
|
88
|
+
self,
|
|
89
|
+
body: bytes,
|
|
90
|
+
timestamp: str,
|
|
91
|
+
signature: str,
|
|
92
|
+
) -> bool:
|
|
93
|
+
"""Verify the Slack request signature.
|
|
94
|
+
|
|
95
|
+
Uses HMAC-SHA256 over ``v0:<timestamp>:<body>`` and compares
|
|
96
|
+
against the ``X-Slack-Signature`` header value.
|
|
97
|
+
|
|
98
|
+
Returns True if the signature is valid.
|
|
99
|
+
"""
|
|
100
|
+
if not timestamp or not signature:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
# Reject requests older than 5 minutes to prevent replay attacks.
|
|
104
|
+
try:
|
|
105
|
+
ts = int(timestamp)
|
|
106
|
+
except (ValueError, TypeError):
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
if abs(time.time() - ts) > 300:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
|
|
113
|
+
computed = (
|
|
114
|
+
"v0="
|
|
115
|
+
+ hmac.new(
|
|
116
|
+
self.signing_secret.encode("utf-8"),
|
|
117
|
+
basestring.encode("utf-8"),
|
|
118
|
+
hashlib.sha256,
|
|
119
|
+
).hexdigest()
|
|
120
|
+
)
|
|
121
|
+
return hmac.compare_digest(computed, signature)
|
|
122
|
+
|
|
123
|
+
async def handle(self, request: Request) -> JSONResponse:
|
|
124
|
+
"""Process an incoming Slack Events API request."""
|
|
125
|
+
body = await request.body()
|
|
126
|
+
|
|
127
|
+
# -- URL verification challenge ---------------------------------------
|
|
128
|
+
try:
|
|
129
|
+
payload = json.loads(body)
|
|
130
|
+
except json.JSONDecodeError:
|
|
131
|
+
return JSONResponse({"error": "invalid json"}, status_code=400)
|
|
132
|
+
|
|
133
|
+
if payload.get("type") == "url_verification":
|
|
134
|
+
return JSONResponse({"challenge": payload.get("challenge", "")})
|
|
135
|
+
|
|
136
|
+
# -- Signature verification -------------------------------------------
|
|
137
|
+
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
|
|
138
|
+
signature = request.headers.get("X-Slack-Signature", "")
|
|
139
|
+
|
|
140
|
+
if not self.verify_signature(body, timestamp, signature):
|
|
141
|
+
logger.warning("Slack signature verification failed")
|
|
142
|
+
return JSONResponse({"error": "invalid signature"}, status_code=401)
|
|
143
|
+
|
|
144
|
+
# -- Event dispatch ---------------------------------------------------
|
|
145
|
+
if payload.get("type") != "event_callback":
|
|
146
|
+
return JSONResponse({"ok": True})
|
|
147
|
+
|
|
148
|
+
event = payload.get("event", {})
|
|
149
|
+
event_type = event.get("type", "")
|
|
150
|
+
|
|
151
|
+
if event_type not in self.SUPPORTED_EVENT_TYPES:
|
|
152
|
+
logger.debug("Ignoring unsupported Slack event type: %s", event_type)
|
|
153
|
+
return JSONResponse({"ok": True})
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
episode = self.normalize(payload, event)
|
|
157
|
+
await self._ingest(episode)
|
|
158
|
+
except Exception:
|
|
159
|
+
logger.exception("Failed to process Slack event %s", event_type)
|
|
160
|
+
return JSONResponse(
|
|
161
|
+
{"error": "processing failed"}, status_code=500
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return JSONResponse({"ok": True})
|
|
165
|
+
|
|
166
|
+
def normalize(
|
|
167
|
+
self,
|
|
168
|
+
payload: dict[str, Any],
|
|
169
|
+
event: dict[str, Any],
|
|
170
|
+
) -> Episode:
|
|
171
|
+
"""Normalize a Slack Events API event_callback into an Episode.
|
|
172
|
+
|
|
173
|
+
This mirrors SlackConnector.normalize() but operates on the
|
|
174
|
+
webhook payload structure rather than a RawEvent fetched via polling.
|
|
175
|
+
"""
|
|
176
|
+
event_type = event.get("type", "")
|
|
177
|
+
user_id = event.get("user", event.get("user_id", "unknown"))
|
|
178
|
+
channel_id = event.get("channel", "")
|
|
179
|
+
ts = event.get("ts", event.get("event_ts", "0"))
|
|
180
|
+
thread_ts = event.get("thread_ts")
|
|
181
|
+
|
|
182
|
+
# Determine content based on event type.
|
|
183
|
+
if event_type == "message" or event_type == "message_changed":
|
|
184
|
+
text = event.get("text", "")
|
|
185
|
+
if event_type == "message_changed":
|
|
186
|
+
message = event.get("message", {})
|
|
187
|
+
text = message.get("text", text)
|
|
188
|
+
user_id = message.get("user", user_id)
|
|
189
|
+
ts = message.get("ts", ts)
|
|
190
|
+
episode_type = EpisodeType.MESSAGE
|
|
191
|
+
elif event_type == "reaction_added":
|
|
192
|
+
reaction = event.get("reaction", "")
|
|
193
|
+
text = f"Reaction :{reaction}: added"
|
|
194
|
+
item = event.get("item", {})
|
|
195
|
+
channel_id = item.get("channel", channel_id)
|
|
196
|
+
ts = item.get("ts", ts)
|
|
197
|
+
episode_type = EpisodeType.MESSAGE
|
|
198
|
+
else:
|
|
199
|
+
text = ""
|
|
200
|
+
episode_type = EpisodeType.CUSTOM
|
|
201
|
+
|
|
202
|
+
actor = Actor(id=user_id, name=user_id)
|
|
203
|
+
|
|
204
|
+
parent_id = None
|
|
205
|
+
if thread_ts and thread_ts != ts:
|
|
206
|
+
parent_id = f"{channel_id}:{thread_ts}"
|
|
207
|
+
|
|
208
|
+
return Episode(
|
|
209
|
+
actor=actor,
|
|
210
|
+
source=_SLACK_SOURCE,
|
|
211
|
+
episode_type=episode_type,
|
|
212
|
+
occurred_at=datetime.fromtimestamp(float(ts), tz=timezone.utc),
|
|
213
|
+
content=text,
|
|
214
|
+
raw_payload=payload,
|
|
215
|
+
tenant_id=self.tenant_id,
|
|
216
|
+
namespace="slack",
|
|
217
|
+
entities=[
|
|
218
|
+
EntityRef(
|
|
219
|
+
entity_type="channel",
|
|
220
|
+
entity_id=channel_id,
|
|
221
|
+
display_name=channel_id,
|
|
222
|
+
),
|
|
223
|
+
],
|
|
224
|
+
tags=["slack", f"slack:{event_type}"],
|
|
225
|
+
metadata={
|
|
226
|
+
"event_type": event_type,
|
|
227
|
+
"team_id": payload.get("team_id", ""),
|
|
228
|
+
},
|
|
229
|
+
thread_id=(
|
|
230
|
+
f"{channel_id}:{thread_ts}" if thread_ts else None
|
|
231
|
+
),
|
|
232
|
+
parent_id=parent_id,
|
|
233
|
+
idempotency_key=f"{channel_id}:{ts}",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
async def _ingest(self, episode: Episode) -> None:
|
|
237
|
+
"""Send a normalized episode to CortexDB."""
|
|
238
|
+
payload = _episode_to_dict(episode)
|
|
239
|
+
async with httpx.AsyncClient() as client:
|
|
240
|
+
resp = await client.post(
|
|
241
|
+
f"{self.cortexdb_url}/api/v1/episodes",
|
|
242
|
+
json=payload,
|
|
243
|
+
headers={
|
|
244
|
+
"Authorization": f"Bearer {self.cortexdb_api_key}",
|
|
245
|
+
"Content-Type": "application/json",
|
|
246
|
+
},
|
|
247
|
+
timeout=30.0,
|
|
248
|
+
)
|
|
249
|
+
resp.raise_for_status()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# GitHub webhook handler
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
class GitHubWebhookHandler:
|
|
257
|
+
"""Handles incoming GitHub webhook requests.
|
|
258
|
+
|
|
259
|
+
Responsibilities:
|
|
260
|
+
- Verify request authenticity using the GitHub webhook secret.
|
|
261
|
+
- Normalize incoming events into CortexDB Episodes.
|
|
262
|
+
- Forward episodes to the CortexDB ingestion API.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
SUPPORTED_EVENTS = {
|
|
266
|
+
"push",
|
|
267
|
+
"pull_request",
|
|
268
|
+
"issues",
|
|
269
|
+
"issue_comment",
|
|
270
|
+
"pull_request_review",
|
|
271
|
+
"deployment_status",
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
def __init__(
|
|
275
|
+
self,
|
|
276
|
+
webhook_secret: str,
|
|
277
|
+
cortexdb_url: str,
|
|
278
|
+
cortexdb_api_key: str,
|
|
279
|
+
tenant_id: str = "default",
|
|
280
|
+
) -> None:
|
|
281
|
+
self.webhook_secret = webhook_secret
|
|
282
|
+
self.cortexdb_url = cortexdb_url.rstrip("/")
|
|
283
|
+
self.cortexdb_api_key = cortexdb_api_key
|
|
284
|
+
self.tenant_id = tenant_id
|
|
285
|
+
|
|
286
|
+
def verify_signature(self, body: bytes, signature: str) -> bool:
|
|
287
|
+
"""Verify the GitHub X-Hub-Signature-256 header.
|
|
288
|
+
|
|
289
|
+
Computes HMAC-SHA256 of the request body using the webhook secret
|
|
290
|
+
and compares it against the provided signature.
|
|
291
|
+
|
|
292
|
+
Returns True if the signature is valid.
|
|
293
|
+
"""
|
|
294
|
+
if not signature or not signature.startswith("sha256="):
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
expected = (
|
|
298
|
+
"sha256="
|
|
299
|
+
+ hmac.new(
|
|
300
|
+
self.webhook_secret.encode("utf-8"),
|
|
301
|
+
body,
|
|
302
|
+
hashlib.sha256,
|
|
303
|
+
).hexdigest()
|
|
304
|
+
)
|
|
305
|
+
return hmac.compare_digest(expected, signature)
|
|
306
|
+
|
|
307
|
+
async def handle(self, request: Request) -> JSONResponse:
|
|
308
|
+
"""Process an incoming GitHub webhook request."""
|
|
309
|
+
body = await request.body()
|
|
310
|
+
|
|
311
|
+
# -- Signature verification -------------------------------------------
|
|
312
|
+
signature = request.headers.get("X-Hub-Signature-256", "")
|
|
313
|
+
if not self.verify_signature(body, signature):
|
|
314
|
+
logger.warning("GitHub signature verification failed")
|
|
315
|
+
return JSONResponse({"error": "invalid signature"}, status_code=401)
|
|
316
|
+
|
|
317
|
+
# -- Parse payload ----------------------------------------------------
|
|
318
|
+
try:
|
|
319
|
+
payload = json.loads(body)
|
|
320
|
+
except json.JSONDecodeError:
|
|
321
|
+
return JSONResponse({"error": "invalid json"}, status_code=400)
|
|
322
|
+
|
|
323
|
+
event_type = request.headers.get("X-GitHub-Event", "")
|
|
324
|
+
delivery_id = request.headers.get("X-GitHub-Delivery", "")
|
|
325
|
+
|
|
326
|
+
if event_type == "ping":
|
|
327
|
+
return JSONResponse({"ok": True, "msg": "pong"})
|
|
328
|
+
|
|
329
|
+
if event_type not in self.SUPPORTED_EVENTS:
|
|
330
|
+
logger.debug("Ignoring unsupported GitHub event: %s", event_type)
|
|
331
|
+
return JSONResponse({"ok": True})
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
episode = self.normalize(event_type, delivery_id, payload)
|
|
335
|
+
await self._ingest(episode)
|
|
336
|
+
except Exception:
|
|
337
|
+
logger.exception(
|
|
338
|
+
"Failed to process GitHub event %s (%s)", event_type, delivery_id
|
|
339
|
+
)
|
|
340
|
+
return JSONResponse(
|
|
341
|
+
{"error": "processing failed"}, status_code=500
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return JSONResponse({"ok": True})
|
|
345
|
+
|
|
346
|
+
def normalize(
|
|
347
|
+
self,
|
|
348
|
+
event_type: str,
|
|
349
|
+
delivery_id: str,
|
|
350
|
+
payload: dict[str, Any],
|
|
351
|
+
) -> Episode:
|
|
352
|
+
"""Normalize a GitHub webhook event into a CortexDB Episode.
|
|
353
|
+
|
|
354
|
+
This mirrors GitHubConnector.normalize() but operates on the
|
|
355
|
+
raw webhook payload rather than a RawEvent from the polling API.
|
|
356
|
+
"""
|
|
357
|
+
episode_type = _GITHUB_WEBHOOK_TYPE_MAP.get(event_type, EpisodeType.CUSTOM)
|
|
358
|
+
|
|
359
|
+
# Extract actor.
|
|
360
|
+
sender = payload.get("sender", {})
|
|
361
|
+
actor = Actor(
|
|
362
|
+
id=str(sender.get("id", "")),
|
|
363
|
+
name=sender.get("login", "unknown"),
|
|
364
|
+
avatar_url=sender.get("avatar_url"),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# Extract repository.
|
|
368
|
+
repo_data = payload.get("repository", {})
|
|
369
|
+
repo_name = repo_data.get("full_name", "")
|
|
370
|
+
is_private = repo_data.get("private", False)
|
|
371
|
+
|
|
372
|
+
content = self._build_content(event_type, payload)
|
|
373
|
+
entities = self._extract_entities(repo_name, payload)
|
|
374
|
+
thread_id = self._derive_thread_id(event_type, payload, repo_name)
|
|
375
|
+
action = payload.get("action", "")
|
|
376
|
+
|
|
377
|
+
tags = ["github", event_type]
|
|
378
|
+
if action:
|
|
379
|
+
tags.append(f"github:{action}")
|
|
380
|
+
|
|
381
|
+
metadata: dict[str, Any] = {"github_event_type": event_type}
|
|
382
|
+
if action:
|
|
383
|
+
metadata["action"] = action
|
|
384
|
+
if delivery_id:
|
|
385
|
+
metadata["delivery_id"] = delivery_id
|
|
386
|
+
|
|
387
|
+
visibility = Visibility(
|
|
388
|
+
level=VisibilityLevel.RESTRICTED if is_private
|
|
389
|
+
else VisibilityLevel.ORGANIZATION
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return Episode(
|
|
393
|
+
actor=actor,
|
|
394
|
+
source=_GITHUB_SOURCE,
|
|
395
|
+
episode_type=episode_type,
|
|
396
|
+
occurred_at=datetime.now(timezone.utc),
|
|
397
|
+
content=content,
|
|
398
|
+
raw_payload=payload,
|
|
399
|
+
tenant_id=self.tenant_id,
|
|
400
|
+
namespace="github",
|
|
401
|
+
entities=entities,
|
|
402
|
+
tags=tags,
|
|
403
|
+
metadata=metadata,
|
|
404
|
+
thread_id=thread_id,
|
|
405
|
+
visibility=visibility,
|
|
406
|
+
idempotency_key=delivery_id or None,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def _build_content(event_type: str, payload: dict[str, Any]) -> str:
|
|
411
|
+
"""Produce a human-readable summary of the GitHub webhook event."""
|
|
412
|
+
action = payload.get("action", "")
|
|
413
|
+
|
|
414
|
+
if event_type == "push":
|
|
415
|
+
commits = payload.get("commits", [])
|
|
416
|
+
ref = payload.get("ref", "")
|
|
417
|
+
messages = "; ".join(
|
|
418
|
+
c.get("message", "").split("\n")[0] for c in commits[:5]
|
|
419
|
+
)
|
|
420
|
+
return f"Pushed {len(commits)} commit(s) to {ref}: {messages}"
|
|
421
|
+
|
|
422
|
+
if event_type == "pull_request":
|
|
423
|
+
pr = payload.get("pull_request", {})
|
|
424
|
+
title = pr.get("title", "")
|
|
425
|
+
return f"PR {action}: {title}"
|
|
426
|
+
|
|
427
|
+
if event_type == "issues":
|
|
428
|
+
issue = payload.get("issue", {})
|
|
429
|
+
title = issue.get("title", "")
|
|
430
|
+
return f"Issue {action}: {title}"
|
|
431
|
+
|
|
432
|
+
if event_type == "issue_comment":
|
|
433
|
+
comment = payload.get("comment", {})
|
|
434
|
+
body = (comment.get("body") or "")[:200]
|
|
435
|
+
return f"Comment: {body}"
|
|
436
|
+
|
|
437
|
+
if event_type == "pull_request_review":
|
|
438
|
+
review = payload.get("review", {})
|
|
439
|
+
state = review.get("state", "")
|
|
440
|
+
pr = payload.get("pull_request", {})
|
|
441
|
+
title = pr.get("title", "")
|
|
442
|
+
return f"Review ({state}) on PR: {title}"
|
|
443
|
+
|
|
444
|
+
if event_type == "deployment_status":
|
|
445
|
+
deployment = payload.get("deployment_status", {})
|
|
446
|
+
state = deployment.get("state", "")
|
|
447
|
+
env = deployment.get("environment", "")
|
|
448
|
+
return f"Deployment to {env}: {state}"
|
|
449
|
+
|
|
450
|
+
return f"GitHub event: {event_type}"
|
|
451
|
+
|
|
452
|
+
@staticmethod
|
|
453
|
+
def _extract_entities(
|
|
454
|
+
repo_name: str, payload: dict[str, Any]
|
|
455
|
+
) -> list[EntityRef]:
|
|
456
|
+
"""Extract entity references from the webhook payload."""
|
|
457
|
+
entities: list[EntityRef] = []
|
|
458
|
+
|
|
459
|
+
if repo_name:
|
|
460
|
+
entities.append(
|
|
461
|
+
EntityRef(
|
|
462
|
+
entity_type="repository",
|
|
463
|
+
entity_id=repo_name,
|
|
464
|
+
display_name=repo_name,
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
pr = payload.get("pull_request", {})
|
|
469
|
+
if pr:
|
|
470
|
+
pr_user = pr.get("user", {})
|
|
471
|
+
if pr_user:
|
|
472
|
+
entities.append(
|
|
473
|
+
EntityRef(
|
|
474
|
+
entity_type="user",
|
|
475
|
+
entity_id=str(pr_user.get("id", "")),
|
|
476
|
+
display_name=pr_user.get("login", ""),
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
issue = payload.get("issue", {})
|
|
481
|
+
if issue:
|
|
482
|
+
entities.append(
|
|
483
|
+
EntityRef(
|
|
484
|
+
entity_type="issue",
|
|
485
|
+
entity_id=str(issue.get("number", "")),
|
|
486
|
+
display_name=issue.get("title", ""),
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
return entities
|
|
491
|
+
|
|
492
|
+
@staticmethod
|
|
493
|
+
def _derive_thread_id(
|
|
494
|
+
event_type: str, payload: dict[str, Any], repo_name: str
|
|
495
|
+
) -> str | None:
|
|
496
|
+
"""Derive a thread_id for grouping related events."""
|
|
497
|
+
pr = payload.get("pull_request", {})
|
|
498
|
+
if pr and pr.get("number"):
|
|
499
|
+
return f"{repo_name}:pr:{pr['number']}"
|
|
500
|
+
issue = payload.get("issue", {})
|
|
501
|
+
if issue and issue.get("number"):
|
|
502
|
+
return f"{repo_name}:issue:{issue['number']}"
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
async def _ingest(self, episode: Episode) -> None:
|
|
506
|
+
"""Send a normalized episode to CortexDB."""
|
|
507
|
+
payload = _episode_to_dict(episode)
|
|
508
|
+
async with httpx.AsyncClient() as client:
|
|
509
|
+
resp = await client.post(
|
|
510
|
+
f"{self.cortexdb_url}/api/v1/episodes",
|
|
511
|
+
json=payload,
|
|
512
|
+
headers={
|
|
513
|
+
"Authorization": f"Bearer {self.cortexdb_api_key}",
|
|
514
|
+
"Content-Type": "application/json",
|
|
515
|
+
},
|
|
516
|
+
timeout=30.0,
|
|
517
|
+
)
|
|
518
|
+
resp.raise_for_status()
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ---------------------------------------------------------------------------
|
|
522
|
+
# Shared utilities
|
|
523
|
+
# ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def _episode_to_dict(episode: Episode) -> dict[str, Any]:
|
|
526
|
+
"""Serialize an Episode to a dictionary suitable for JSON ingestion."""
|
|
527
|
+
payload: dict[str, Any] = {
|
|
528
|
+
"id": episode.id,
|
|
529
|
+
"episode_type": episode.episode_type.value,
|
|
530
|
+
"content": episode.content,
|
|
531
|
+
"occurred_at": episode.occurred_at.isoformat(),
|
|
532
|
+
"tenant_id": episode.tenant_id,
|
|
533
|
+
"namespace": episode.namespace,
|
|
534
|
+
"tags": episode.tags,
|
|
535
|
+
"metadata": episode.metadata,
|
|
536
|
+
"raw_payload": episode.raw_payload,
|
|
537
|
+
"visibility": {
|
|
538
|
+
"level": episode.visibility.level.value,
|
|
539
|
+
"allowed_principals": list(episode.visibility.allowed_principals),
|
|
540
|
+
},
|
|
541
|
+
"idempotency_key": episode.idempotency_key,
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if episode.actor:
|
|
545
|
+
payload["actor"] = {
|
|
546
|
+
"id": episode.actor.id,
|
|
547
|
+
"name": episode.actor.name,
|
|
548
|
+
"email": episode.actor.email,
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if episode.source:
|
|
552
|
+
payload["source"] = {
|
|
553
|
+
"system": episode.source.system,
|
|
554
|
+
"connector_version": episode.source.connector_version,
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if episode.entities:
|
|
558
|
+
payload["entities"] = [
|
|
559
|
+
{
|
|
560
|
+
"entity_type": e.entity_type,
|
|
561
|
+
"entity_id": e.entity_id,
|
|
562
|
+
"display_name": e.display_name,
|
|
563
|
+
}
|
|
564
|
+
for e in episode.entities
|
|
565
|
+
]
|
|
566
|
+
|
|
567
|
+
if episode.thread_id:
|
|
568
|
+
payload["thread_id"] = episode.thread_id
|
|
569
|
+
if episode.parent_id:
|
|
570
|
+
payload["parent_id"] = episode.parent_id
|
|
571
|
+
if episode.ttl_seconds is not None:
|
|
572
|
+
payload["ttl_seconds"] = episode.ttl_seconds
|
|
573
|
+
|
|
574
|
+
return payload
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ---------------------------------------------------------------------------
|
|
578
|
+
# Webhook server
|
|
579
|
+
# ---------------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
class WebhookServer:
|
|
582
|
+
"""Configurable Starlette application serving Slack and GitHub webhooks.
|
|
583
|
+
|
|
584
|
+
Reads configuration from environment variables and wires up the
|
|
585
|
+
handler classes with appropriate routes.
|
|
586
|
+
|
|
587
|
+
Parameters
|
|
588
|
+
----------
|
|
589
|
+
cortexdb_url:
|
|
590
|
+
Override for CORTEXDB_URL env var.
|
|
591
|
+
cortexdb_api_key:
|
|
592
|
+
Override for CORTEXDB_API_KEY env var.
|
|
593
|
+
slack_signing_secret:
|
|
594
|
+
Override for SLACK_SIGNING_SECRET env var.
|
|
595
|
+
github_webhook_secret:
|
|
596
|
+
Override for GITHUB_WEBHOOK_SECRET env var.
|
|
597
|
+
tenant_id:
|
|
598
|
+
CortexDB tenant identifier.
|
|
599
|
+
"""
|
|
600
|
+
|
|
601
|
+
def __init__(
|
|
602
|
+
self,
|
|
603
|
+
cortexdb_url: str | None = None,
|
|
604
|
+
cortexdb_api_key: str | None = None,
|
|
605
|
+
slack_signing_secret: str | None = None,
|
|
606
|
+
github_webhook_secret: str | None = None,
|
|
607
|
+
tenant_id: str = "default",
|
|
608
|
+
) -> None:
|
|
609
|
+
self.cortexdb_url = cortexdb_url or os.environ.get(
|
|
610
|
+
"CORTEXDB_URL", "http://localhost:8080"
|
|
611
|
+
)
|
|
612
|
+
self.cortexdb_api_key = cortexdb_api_key or os.environ.get(
|
|
613
|
+
"CORTEXDB_API_KEY", ""
|
|
614
|
+
)
|
|
615
|
+
self.slack_signing_secret = slack_signing_secret or os.environ.get(
|
|
616
|
+
"SLACK_SIGNING_SECRET", ""
|
|
617
|
+
)
|
|
618
|
+
self.github_webhook_secret = github_webhook_secret or os.environ.get(
|
|
619
|
+
"GITHUB_WEBHOOK_SECRET", ""
|
|
620
|
+
)
|
|
621
|
+
self.tenant_id = tenant_id
|
|
622
|
+
|
|
623
|
+
self.slack_handler = SlackWebhookHandler(
|
|
624
|
+
signing_secret=self.slack_signing_secret,
|
|
625
|
+
cortexdb_url=self.cortexdb_url,
|
|
626
|
+
cortexdb_api_key=self.cortexdb_api_key,
|
|
627
|
+
tenant_id=self.tenant_id,
|
|
628
|
+
)
|
|
629
|
+
self.github_handler = GitHubWebhookHandler(
|
|
630
|
+
webhook_secret=self.github_webhook_secret,
|
|
631
|
+
cortexdb_url=self.cortexdb_url,
|
|
632
|
+
cortexdb_api_key=self.cortexdb_api_key,
|
|
633
|
+
tenant_id=self.tenant_id,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
def build_app(self) -> Starlette:
|
|
637
|
+
"""Construct and return the Starlette ASGI application."""
|
|
638
|
+
routes = [
|
|
639
|
+
Route("/health", self._health, methods=["GET"]),
|
|
640
|
+
Route(
|
|
641
|
+
"/webhooks/slack/events",
|
|
642
|
+
self.slack_handler.handle,
|
|
643
|
+
methods=["POST"],
|
|
644
|
+
),
|
|
645
|
+
Route(
|
|
646
|
+
"/webhooks/github/events",
|
|
647
|
+
self.github_handler.handle,
|
|
648
|
+
methods=["POST"],
|
|
649
|
+
),
|
|
650
|
+
]
|
|
651
|
+
return Starlette(routes=routes)
|
|
652
|
+
|
|
653
|
+
@staticmethod
|
|
654
|
+
async def _health(request: Request) -> JSONResponse:
|
|
655
|
+
"""Health check endpoint."""
|
|
656
|
+
return JSONResponse({"status": "ok"})
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def main() -> None:
|
|
660
|
+
"""Entry point for running the webhook server via ``python -m`` or CLI."""
|
|
661
|
+
import uvicorn
|
|
662
|
+
|
|
663
|
+
port = int(os.environ.get("WEBHOOK_PORT", "8081"))
|
|
664
|
+
server = WebhookServer()
|
|
665
|
+
app = server.build_app()
|
|
666
|
+
|
|
667
|
+
logger.info("Starting CortexDB webhook server on port %d", port)
|
|
668
|
+
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
if __name__ == "__main__":
|
|
672
|
+
main()
|