cortexdb-connectors 0.2.8__tar.gz → 0.2.9__tar.gz
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-0.2.8 → cortexdb_connectors-0.2.9}/PKG-INFO +1 -1
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/base.py +12 -1
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/cli.py +31 -6
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/jira/__init__.py +112 -14
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/webhooks.py +44 -1
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/PKG-INFO +1 -1
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/SOURCES.txt +1 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/pyproject.toml +87 -87
- cortexdb_connectors-0.2.9/tests/test_jira_findings_0_2_9.py +341 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/README.md +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/confluence/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/discord/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/freshdesk/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/github/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/gitlab/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/google_workspace/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/hubspot/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/insights/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/insights/api.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/insights/detectors.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/insights/engine.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/intercom/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/linear/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/notion/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/pagerduty/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/salesforce/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/servicenow/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/slack/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/state.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/teams/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/tldv/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/worker.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/zendesk/__init__.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/dependency_links.txt +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/entry_points.txt +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/requires.txt +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/top_level.txt +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/setup.cfg +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_base.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_connector_coverage.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_github.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_ingest_e2e.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_slack.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_streaming_cursor.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_webhooks.py +0 -0
- {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_webhooks_replay.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortexdb-connectors
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API.
|
|
5
5
|
Author-email: CortexDB Team <team@cortexdb.ai>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -102,7 +102,7 @@ class VisibilityLevel(str, Enum):
|
|
|
102
102
|
@dataclass(frozen=True)
|
|
103
103
|
class Source:
|
|
104
104
|
system: str
|
|
105
|
-
connector_version: str = "0.2.
|
|
105
|
+
connector_version: str = "0.2.9"
|
|
106
106
|
|
|
107
107
|
|
|
108
108
|
@dataclass(frozen=True)
|
|
@@ -325,6 +325,17 @@ class CortexConnector(ABC):
|
|
|
325
325
|
|
|
326
326
|
for key, value in replacements.items():
|
|
327
327
|
tpl = tpl.replace("{" + key + "}", value or "")
|
|
328
|
+
# C6d: an unresolved placeholder used to pass through literally,
|
|
329
|
+
# producing a silently malformed scope (`.../issue:{entity.issue}`)
|
|
330
|
+
# that the server happily stores as a real path. Fail loudly instead —
|
|
331
|
+
# the sync loop records the error per event and holds the cursor.
|
|
332
|
+
leftover = re.findall(r"\{[^{}]+\}", tpl)
|
|
333
|
+
if leftover:
|
|
334
|
+
raise ValueError(
|
|
335
|
+
f"scope template resolved to {tpl!r} with unresolved "
|
|
336
|
+
f"placeholder(s) {', '.join(leftover)} — this episode does "
|
|
337
|
+
"not carry the referenced entity/field."
|
|
338
|
+
)
|
|
328
339
|
return tpl
|
|
329
340
|
|
|
330
341
|
def _envelope(self, episode: Episode) -> dict[str, Any]:
|
|
@@ -432,16 +432,41 @@ def _cmd_sync(args: argparse.Namespace) -> None:
|
|
|
432
432
|
|
|
433
433
|
def _cmd_serve(args: argparse.Namespace) -> None:
|
|
434
434
|
"""Run the webhook receiver for real-time create/update/delete."""
|
|
435
|
-
|
|
435
|
+
# C3: this used to import a `build_handlers` that never existed —
|
|
436
|
+
# `cortexdb-sync serve` ImportError'd on startup, killing every
|
|
437
|
+
# connector's webhook mode. WebhookServer wires its own handlers from
|
|
438
|
+
# env secrets; the CLI only supplies CortexDB creds + bind address.
|
|
439
|
+
import uvicorn
|
|
440
|
+
|
|
441
|
+
from cortexdb_connectors.webhooks import WebhookServer
|
|
436
442
|
|
|
437
443
|
creds = _load_cortex_creds(args)
|
|
438
|
-
|
|
439
|
-
|
|
444
|
+
secret_envs = [
|
|
445
|
+
"SLACK_SIGNING_SECRET",
|
|
446
|
+
"GITHUB_WEBHOOK_SECRET",
|
|
447
|
+
"JIRA_WEBHOOK_SECRET",
|
|
448
|
+
"FRESHDESK_WEBHOOK_SECRET",
|
|
449
|
+
"TLDV_WEBHOOK_SECRET",
|
|
450
|
+
]
|
|
451
|
+
if not any(os.environ.get(v) for v in secret_envs):
|
|
440
452
|
logger.warning(
|
|
441
|
-
"no webhook
|
|
442
|
-
"
|
|
453
|
+
"no webhook signing secrets configured — every delivery will be "
|
|
454
|
+
"rejected as unsigned. Set one or more of: %s",
|
|
455
|
+
", ".join(secret_envs),
|
|
443
456
|
)
|
|
444
|
-
|
|
457
|
+
server = WebhookServer(
|
|
458
|
+
cortexdb_url=creds["api_url"] or None,
|
|
459
|
+
cortexdb_api_key=creds["api_key"] or None,
|
|
460
|
+
scope=creds["scope_template"] or "user:default",
|
|
461
|
+
actor=creds["actor"] or None,
|
|
462
|
+
)
|
|
463
|
+
logger.info(
|
|
464
|
+
"webhook receiver listening on %s:%d (routes: /webhooks/{slack,github,"
|
|
465
|
+
"jira,freshdesk,tldv}/events, /health)",
|
|
466
|
+
args.host,
|
|
467
|
+
args.port,
|
|
468
|
+
)
|
|
469
|
+
uvicorn.run(server.build_app(), host=args.host, port=args.port, log_level="info")
|
|
445
470
|
|
|
446
471
|
|
|
447
472
|
def _cmd_watch(args: argparse.Namespace) -> None:
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/jira/__init__.py
RENAMED
|
@@ -8,6 +8,7 @@ them as CortexDB Episodes. Uses the ``jira`` library for API access.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import logging
|
|
11
|
+
import re
|
|
11
12
|
from datetime import datetime, timezone
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
@@ -80,22 +81,41 @@ class JiraConnector(CortexConnector):
|
|
|
80
81
|
"""
|
|
81
82
|
from jira import JIRA
|
|
82
83
|
|
|
84
|
+
# C6b: an empty key list would produce unbounded JQL, which Jira
|
|
85
|
+
# Cloud rejects with an unhandled 400 mid-sync. Fail up front with
|
|
86
|
+
# an actionable message instead.
|
|
87
|
+
if not self.project_keys:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"JiraConnector requires at least one project key "
|
|
90
|
+
"(project_keys=[...] / JIRA_PROJECT_KEYS) — Jira Cloud "
|
|
91
|
+
"rejects unbounded JQL queries."
|
|
92
|
+
)
|
|
93
|
+
|
|
83
94
|
client = JIRA(
|
|
84
95
|
server=self.jira_url,
|
|
85
96
|
basic_auth=(self.jira_email, self.jira_api_token),
|
|
86
97
|
)
|
|
87
98
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
99
|
+
# C6a: Jira Cloud answers /search/jql with an EMPTY result set (not
|
|
100
|
+
# 401) for bad/expired tokens, so without this pre-flight a broken
|
|
101
|
+
# credential looks identical to an empty project ("sync done
|
|
102
|
+
# fetched=0 errors=0"). myself() does require valid auth.
|
|
103
|
+
try:
|
|
104
|
+
client.myself()
|
|
105
|
+
except Exception as exc: # noqa: BLE001
|
|
106
|
+
raise RuntimeError(
|
|
107
|
+
f"Jira authentication failed for {self.jira_email} at "
|
|
108
|
+
f"{self.jira_url} — check JIRA_EMAIL / JIRA_API_TOKEN: {exc}"
|
|
109
|
+
) from exc
|
|
110
|
+
|
|
111
|
+
sprint_field, points_field = self._custom_field_ids(client)
|
|
112
|
+
|
|
113
|
+
keys_str = ", ".join(self.project_keys)
|
|
114
|
+
jql_parts: list[str] = [f"project in ({keys_str})"]
|
|
92
115
|
if since:
|
|
93
116
|
jql_date = since.strftime("%Y-%m-%d %H:%M")
|
|
94
117
|
jql_parts.append(f'updated >= "{jql_date}"')
|
|
95
|
-
|
|
96
|
-
jql = " AND ".join(jql_parts) if jql_parts else "ORDER BY updated DESC"
|
|
97
|
-
if jql_parts:
|
|
98
|
-
jql += " ORDER BY updated DESC"
|
|
118
|
+
jql = " AND ".join(jql_parts) + " ORDER BY updated DESC"
|
|
99
119
|
|
|
100
120
|
raw_events: list[RawEvent] = []
|
|
101
121
|
start_at = 0
|
|
@@ -118,7 +138,7 @@ class JiraConnector(CortexConnector):
|
|
|
118
138
|
except (ValueError, TypeError):
|
|
119
139
|
updated_at = datetime.now(timezone.utc)
|
|
120
140
|
|
|
121
|
-
issue_data = self._serialize_issue(issue)
|
|
141
|
+
issue_data = self._serialize_issue(issue, sprint_field, points_field)
|
|
122
142
|
project_data = issue_data.get("project", {})
|
|
123
143
|
|
|
124
144
|
raw_events.append(
|
|
@@ -136,7 +156,12 @@ class JiraConnector(CortexConnector):
|
|
|
136
156
|
)
|
|
137
157
|
)
|
|
138
158
|
|
|
139
|
-
|
|
159
|
+
# C5: current Jira Cloud (enhanced /search/jql) reports
|
|
160
|
+
# `results.total` as the CURRENT PAGE's length (and maxResults as
|
|
161
|
+
# 0), so a total-based check exits after page 1 every time and
|
|
162
|
+
# projects >_PAGE_SIZE issues silently under-sync. A short page is
|
|
163
|
+
# the only trustworthy end-of-results signal.
|
|
164
|
+
if len(results) < _PAGE_SIZE:
|
|
140
165
|
break
|
|
141
166
|
start_at += _PAGE_SIZE
|
|
142
167
|
|
|
@@ -184,7 +209,11 @@ class JiraConnector(CortexConnector):
|
|
|
184
209
|
# Version-aware key: each updated state of the issue is its own
|
|
185
210
|
# retained event (occurred_at = the issue's updated time), so the
|
|
186
211
|
# full history is preserved (e.g. owner Alice → Bob, with when).
|
|
187
|
-
|
|
212
|
+
# C4: source-namespaced and shaped exactly like the webhook
|
|
213
|
+
# handler's key (`jira:{key}:{updated.isoformat()}`) so the same
|
|
214
|
+
# issue state dedups across poll and webhook paths instead of
|
|
215
|
+
# double-storing (or 409ing a second scope's sync).
|
|
216
|
+
idempotency_key=f"jira:{raw.external_id}:{raw.timestamp.isoformat()}",
|
|
188
217
|
)
|
|
189
218
|
|
|
190
219
|
def map_permissions(self, raw: RawEvent) -> Visibility:
|
|
@@ -202,7 +231,61 @@ class JiraConnector(CortexConnector):
|
|
|
202
231
|
# -- helpers -------------------------------------------------------------
|
|
203
232
|
|
|
204
233
|
@staticmethod
|
|
205
|
-
def
|
|
234
|
+
def _custom_field_ids(client: Any) -> tuple[str | None, str | None]:
|
|
235
|
+
"""Resolve the instance's Sprint / Story-Points custom field ids.
|
|
236
|
+
|
|
237
|
+
C6c: sprint and story points live in per-instance ``customfield_*``
|
|
238
|
+
ids, so they must be looked up by NAME once per sync (one
|
|
239
|
+
``client.fields()`` call). Returns ``(sprint_id, points_id)``; either
|
|
240
|
+
is None when the instance has no such field — serialization then
|
|
241
|
+
keeps the documented null.
|
|
242
|
+
"""
|
|
243
|
+
sprint_field: str | None = None
|
|
244
|
+
points_field: str | None = None
|
|
245
|
+
try:
|
|
246
|
+
for f in client.fields():
|
|
247
|
+
name = (f.get("name") or "").strip().lower()
|
|
248
|
+
if name == "sprint" and sprint_field is None:
|
|
249
|
+
sprint_field = f.get("id")
|
|
250
|
+
elif name in ("story points", "story point estimate") and (
|
|
251
|
+
points_field is None
|
|
252
|
+
):
|
|
253
|
+
points_field = f.get("id")
|
|
254
|
+
except Exception as exc: # noqa: BLE001
|
|
255
|
+
logger.warning(
|
|
256
|
+
"could not resolve Jira custom fields (sprint/story points "
|
|
257
|
+
"stay null): %s",
|
|
258
|
+
exc,
|
|
259
|
+
)
|
|
260
|
+
return sprint_field, points_field
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _sprint_name(value: Any) -> str | None:
|
|
264
|
+
"""Best-effort current-sprint name from a Sprint custom-field value.
|
|
265
|
+
|
|
266
|
+
The field is a list; entries are objects with ``.name`` on current
|
|
267
|
+
API versions, or legacy ``com.atlassian.greenhopper...[...,name=X,...]``
|
|
268
|
+
strings. The LAST entry is the most recent sprint.
|
|
269
|
+
"""
|
|
270
|
+
if not value:
|
|
271
|
+
return None
|
|
272
|
+
entries = value if isinstance(value, list) else [value]
|
|
273
|
+
last = entries[-1]
|
|
274
|
+
name = getattr(last, "name", None)
|
|
275
|
+
if name:
|
|
276
|
+
return str(name)
|
|
277
|
+
if isinstance(last, str):
|
|
278
|
+
m = re.search(r"name=([^,\]]+)", last)
|
|
279
|
+
if m:
|
|
280
|
+
return m.group(1)
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _serialize_issue(
|
|
285
|
+
issue: Any,
|
|
286
|
+
sprint_field: str | None = None,
|
|
287
|
+
points_field: str | None = None,
|
|
288
|
+
) -> dict[str, Any]:
|
|
206
289
|
"""Flatten a ``jira.Issue`` into a plain dict."""
|
|
207
290
|
fields = issue.fields
|
|
208
291
|
assignee = getattr(fields, "assignee", None)
|
|
@@ -247,8 +330,12 @@ class JiraConnector(CortexConnector):
|
|
|
247
330
|
],
|
|
248
331
|
"created": getattr(fields, "created", ""),
|
|
249
332
|
"updated": getattr(fields, "updated", ""),
|
|
250
|
-
"sprint":
|
|
251
|
-
|
|
333
|
+
"sprint": JiraConnector._sprint_name(
|
|
334
|
+
getattr(fields, sprint_field, None) if sprint_field else None
|
|
335
|
+
),
|
|
336
|
+
"story_points": (
|
|
337
|
+
getattr(fields, points_field, None) if points_field else None
|
|
338
|
+
),
|
|
252
339
|
}
|
|
253
340
|
|
|
254
341
|
@staticmethod
|
|
@@ -268,6 +355,17 @@ class JiraConnector(CortexConnector):
|
|
|
268
355
|
"""Extract entity references from issue data."""
|
|
269
356
|
entities: list[EntityRef] = []
|
|
270
357
|
|
|
358
|
+
# C6d: the webhook path emits an `issue` entity; the poll path must
|
|
359
|
+
# too, or an `{entity.issue}` scope template never resolves.
|
|
360
|
+
if data.get("key"):
|
|
361
|
+
entities.append(
|
|
362
|
+
EntityRef(
|
|
363
|
+
entity_type="issue",
|
|
364
|
+
entity_id=data["key"],
|
|
365
|
+
display_name=data["key"],
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
|
|
271
369
|
project = data.get("project", {})
|
|
272
370
|
if project.get("key"):
|
|
273
371
|
entities.append(
|
|
@@ -574,6 +574,29 @@ class _HmacWebhookHandler:
|
|
|
574
574
|
payload = json.loads(body)
|
|
575
575
|
except json.JSONDecodeError:
|
|
576
576
|
return JSONResponse({"error": "invalid json"}, status_code=400)
|
|
577
|
+
# C2: a source-side hard delete must REMOVE data, not ingest another
|
|
578
|
+
# event. When the subclass recognizes a delete event it names the
|
|
579
|
+
# deleted entity and we redact it via /v1/forget instead of calling
|
|
580
|
+
# normalize (which can only produce an Episode to ingest).
|
|
581
|
+
try:
|
|
582
|
+
deleted_entity = self.deletion_entity(payload)
|
|
583
|
+
except Exception:
|
|
584
|
+
logger.exception("%s webhook delete-detection failed", self.provider)
|
|
585
|
+
return JSONResponse({"error": "processing failed"}, status_code=500)
|
|
586
|
+
if deleted_entity:
|
|
587
|
+
try:
|
|
588
|
+
await _forget_entity_v1(
|
|
589
|
+
deleted_entity,
|
|
590
|
+
self.cortexdb_url,
|
|
591
|
+
self.cortexdb_api_key,
|
|
592
|
+
self.actor,
|
|
593
|
+
self.scope,
|
|
594
|
+
source=self.provider,
|
|
595
|
+
)
|
|
596
|
+
except Exception:
|
|
597
|
+
logger.exception("%s webhook forget failed", self.provider)
|
|
598
|
+
return JSONResponse({"error": "forget failed"}, status_code=500)
|
|
599
|
+
return JSONResponse({"ok": True, "forgot": deleted_entity})
|
|
577
600
|
try:
|
|
578
601
|
episode = self.normalize(payload)
|
|
579
602
|
except Exception:
|
|
@@ -593,6 +616,13 @@ class _HmacWebhookHandler:
|
|
|
593
616
|
def normalize(self, payload: dict[str, Any]) -> Episode | None: # pragma: no cover
|
|
594
617
|
raise NotImplementedError
|
|
595
618
|
|
|
619
|
+
def deletion_entity(self, payload: dict[str, Any]) -> str | None:
|
|
620
|
+
"""Return the deleted entity's id when *payload* is a hard-delete
|
|
621
|
+
event (→ the handler redacts it via ``/v1/forget`` instead of
|
|
622
|
+
ingesting), or None for everything else. Default: no delete
|
|
623
|
+
detection."""
|
|
624
|
+
return None
|
|
625
|
+
|
|
596
626
|
|
|
597
627
|
def _parse_iso(value: Any) -> datetime:
|
|
598
628
|
"""Parse an ISO-8601 string (Z or offset) → aware datetime; fall back to now."""
|
|
@@ -609,6 +639,13 @@ class JiraWebhookHandler(_HmacWebhookHandler):
|
|
|
609
639
|
|
|
610
640
|
provider = "jira"
|
|
611
641
|
|
|
642
|
+
def deletion_entity(self, payload: dict[str, Any]) -> str | None:
|
|
643
|
+
# C2: jira:issue_deleted → redact the issue (its key is what the
|
|
644
|
+
# ingested content/entities carry) instead of ingesting a new event.
|
|
645
|
+
if payload.get("webhookEvent") == "jira:issue_deleted":
|
|
646
|
+
return (payload.get("issue") or {}).get("key") or None
|
|
647
|
+
return None
|
|
648
|
+
|
|
612
649
|
def normalize(self, payload: dict[str, Any]) -> Episode | None:
|
|
613
650
|
issue = payload.get("issue") or {}
|
|
614
651
|
fields = issue.get("fields") or {}
|
|
@@ -644,7 +681,13 @@ class JiraWebhookHandler(_HmacWebhookHandler):
|
|
|
644
681
|
"issuetype": issuetype,
|
|
645
682
|
"webhook_event": payload.get("webhookEvent", ""),
|
|
646
683
|
},
|
|
647
|
-
|
|
684
|
+
# C4: keyed as `jira:{key}:{updated.isoformat()}` — the SAME shape
|
|
685
|
+
# (and the same parsed-timestamp formatting) as the poll path, so
|
|
686
|
+
# one issue state dedups across webhook and poll instead of
|
|
687
|
+
# double-storing.
|
|
688
|
+
idempotency_key=(
|
|
689
|
+
f"jira:{key}:{_parse_iso(updated).isoformat() if updated else 'na'}"
|
|
690
|
+
),
|
|
648
691
|
)
|
|
649
692
|
|
|
650
693
|
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortexdb-connectors
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API.
|
|
5
5
|
Author-email: CortexDB Team <team@cortexdb.ai>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -1,87 +1,87 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "cortexdb-connectors"
|
|
7
|
-
version = "0.2.
|
|
8
|
-
description = "Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API."
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.10"
|
|
11
|
-
license = "Apache-2.0"
|
|
12
|
-
authors = [
|
|
13
|
-
{name = "CortexDB Team", email = "team@cortexdb.ai"},
|
|
14
|
-
]
|
|
15
|
-
keywords = ["ai", "memory", "connectors", "etl", "slack", "github", "jira", "cortexdb"]
|
|
16
|
-
classifiers = [
|
|
17
|
-
"Development Status :: 4 - Beta",
|
|
18
|
-
"Environment :: Console",
|
|
19
|
-
"Intended Audience :: Developers",
|
|
20
|
-
"Programming Language :: Python :: 3",
|
|
21
|
-
"Programming Language :: Python :: 3.10",
|
|
22
|
-
"Programming Language :: Python :: 3.11",
|
|
23
|
-
"Programming Language :: Python :: 3.12",
|
|
24
|
-
"Programming Language :: Python :: 3.13",
|
|
25
|
-
"Topic :: Database",
|
|
26
|
-
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
-
]
|
|
28
|
-
dependencies = [
|
|
29
|
-
"httpx>=0.27",
|
|
30
|
-
"pydantic>=2.0",
|
|
31
|
-
"croniter>=2.0",
|
|
32
|
-
]
|
|
33
|
-
|
|
34
|
-
[project.optional-dependencies]
|
|
35
|
-
slack = ["slack-sdk>=3.0"]
|
|
36
|
-
github = ["pygithub>=2.0"]
|
|
37
|
-
pagerduty = ["pdpyras>=5.0"]
|
|
38
|
-
jira = ["jira>=3.0"]
|
|
39
|
-
confluence = ["atlassian-python-api>=3.0"]
|
|
40
|
-
notion = ["notion-client>=2.0"]
|
|
41
|
-
linear = []
|
|
42
|
-
discord = ["aiohttp>=3.9"]
|
|
43
|
-
teams = ["msal>=1.28"]
|
|
44
|
-
google-workspace = ["google-api-python-client>=2.0", "google-auth>=2.0"]
|
|
45
|
-
salesforce = ["simple-salesforce>=1.12"]
|
|
46
|
-
hubspot = ["hubspot-api-client>=9.0"]
|
|
47
|
-
zendesk = []
|
|
48
|
-
intercom = []
|
|
49
|
-
servicenow = []
|
|
50
|
-
gitlab = ["python-gitlab>=4.0"]
|
|
51
|
-
webhooks = [
|
|
52
|
-
"starlette>=0.37",
|
|
53
|
-
"uvicorn>=0.29",
|
|
54
|
-
"python-multipart>=0.0.9",
|
|
55
|
-
]
|
|
56
|
-
all = [
|
|
57
|
-
"slack-sdk>=3.0",
|
|
58
|
-
"pygithub>=2.0",
|
|
59
|
-
"pdpyras>=5.0",
|
|
60
|
-
"jira>=3.0",
|
|
61
|
-
"atlassian-python-api>=3.0",
|
|
62
|
-
"notion-client>=2.0",
|
|
63
|
-
"aiohttp>=3.9",
|
|
64
|
-
"msal>=1.28",
|
|
65
|
-
"google-api-python-client>=2.0",
|
|
66
|
-
"google-auth>=2.0",
|
|
67
|
-
"simple-salesforce>=1.12",
|
|
68
|
-
"hubspot-api-client>=9.0",
|
|
69
|
-
"python-gitlab>=4.0",
|
|
70
|
-
"PyYAML>=6.0",
|
|
71
|
-
"starlette>=0.37",
|
|
72
|
-
"uvicorn>=0.29",
|
|
73
|
-
"python-multipart>=0.0.9",
|
|
74
|
-
]
|
|
75
|
-
|
|
76
|
-
[project.scripts]
|
|
77
|
-
cortexdb-sync = "cortexdb_connectors.cli:main"
|
|
78
|
-
|
|
79
|
-
[project.urls]
|
|
80
|
-
Homepage = "https://cortexdb.ai"
|
|
81
|
-
Documentation = "https://cortexdb.ai/docs/connectors/slack"
|
|
82
|
-
Repository = "https://github.com/cortexdb/cortexdb"
|
|
83
|
-
Issues = "https://github.com/cortexdb/cortexdb/issues"
|
|
84
|
-
|
|
85
|
-
[tool.setuptools.packages.find]
|
|
86
|
-
where = ["."]
|
|
87
|
-
include = ["cortexdb_connectors*"]
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cortexdb-connectors"
|
|
7
|
+
version = "0.2.9"
|
|
8
|
+
description = "Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "CortexDB Team", email = "team@cortexdb.ai"},
|
|
14
|
+
]
|
|
15
|
+
keywords = ["ai", "memory", "connectors", "etl", "slack", "github", "jira", "cortexdb"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Database",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"httpx>=0.27",
|
|
30
|
+
"pydantic>=2.0",
|
|
31
|
+
"croniter>=2.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
slack = ["slack-sdk>=3.0"]
|
|
36
|
+
github = ["pygithub>=2.0"]
|
|
37
|
+
pagerduty = ["pdpyras>=5.0"]
|
|
38
|
+
jira = ["jira>=3.0"]
|
|
39
|
+
confluence = ["atlassian-python-api>=3.0"]
|
|
40
|
+
notion = ["notion-client>=2.0"]
|
|
41
|
+
linear = []
|
|
42
|
+
discord = ["aiohttp>=3.9"]
|
|
43
|
+
teams = ["msal>=1.28"]
|
|
44
|
+
google-workspace = ["google-api-python-client>=2.0", "google-auth>=2.0"]
|
|
45
|
+
salesforce = ["simple-salesforce>=1.12"]
|
|
46
|
+
hubspot = ["hubspot-api-client>=9.0"]
|
|
47
|
+
zendesk = []
|
|
48
|
+
intercom = []
|
|
49
|
+
servicenow = []
|
|
50
|
+
gitlab = ["python-gitlab>=4.0"]
|
|
51
|
+
webhooks = [
|
|
52
|
+
"starlette>=0.37",
|
|
53
|
+
"uvicorn>=0.29",
|
|
54
|
+
"python-multipart>=0.0.9",
|
|
55
|
+
]
|
|
56
|
+
all = [
|
|
57
|
+
"slack-sdk>=3.0",
|
|
58
|
+
"pygithub>=2.0",
|
|
59
|
+
"pdpyras>=5.0",
|
|
60
|
+
"jira>=3.0",
|
|
61
|
+
"atlassian-python-api>=3.0",
|
|
62
|
+
"notion-client>=2.0",
|
|
63
|
+
"aiohttp>=3.9",
|
|
64
|
+
"msal>=1.28",
|
|
65
|
+
"google-api-python-client>=2.0",
|
|
66
|
+
"google-auth>=2.0",
|
|
67
|
+
"simple-salesforce>=1.12",
|
|
68
|
+
"hubspot-api-client>=9.0",
|
|
69
|
+
"python-gitlab>=4.0",
|
|
70
|
+
"PyYAML>=6.0",
|
|
71
|
+
"starlette>=0.37",
|
|
72
|
+
"uvicorn>=0.29",
|
|
73
|
+
"python-multipart>=0.0.9",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[project.scripts]
|
|
77
|
+
cortexdb-sync = "cortexdb_connectors.cli:main"
|
|
78
|
+
|
|
79
|
+
[project.urls]
|
|
80
|
+
Homepage = "https://cortexdb.ai"
|
|
81
|
+
Documentation = "https://cortexdb.ai/docs/connectors/slack"
|
|
82
|
+
Repository = "https://github.com/cortexdb/cortexdb"
|
|
83
|
+
Issues = "https://github.com/cortexdb/cortexdb/issues"
|
|
84
|
+
|
|
85
|
+
[tool.setuptools.packages.find]
|
|
86
|
+
where = ["."]
|
|
87
|
+
include = ["cortexdb_connectors*"]
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Regression tests for the 0.2.8 Jira connector findings report (C2–C6).
|
|
2
|
+
|
|
3
|
+
C5 pagination termination must not trust `results.total`
|
|
4
|
+
C3 `cortexdb-sync serve` must not ImportError
|
|
5
|
+
C2 jira:issue_deleted webhook → /v1/forget, not ingest
|
|
6
|
+
C4 poll + webhook idempotency keys share one shape
|
|
7
|
+
C6a bad-token auth pre-flight fails loudly
|
|
8
|
+
C6b empty project_keys fails loudly
|
|
9
|
+
C6c sprint / story_points populated from custom fields
|
|
10
|
+
C6d poll path emits `issue` entity; unresolved scope placeholders raise
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import sys
|
|
17
|
+
import types
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from unittest.mock import AsyncMock, patch
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
from cortexdb_connectors.base import Episode, EpisodeType, RawEvent, Source
|
|
24
|
+
from cortexdb_connectors.jira import JiraConnector
|
|
25
|
+
from cortexdb_connectors.webhooks import JiraWebhookHandler, _parse_iso
|
|
26
|
+
|
|
27
|
+
CORTEXDB_URL = "http://localhost:8080"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Fake jira client machinery
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _FakeField:
|
|
36
|
+
def __init__(self, **kw):
|
|
37
|
+
self.__dict__.update(kw)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _FakeIssue:
|
|
41
|
+
def __init__(self, key: str, idx: int, **custom):
|
|
42
|
+
self.key = key
|
|
43
|
+
self.id = str(10000 + idx)
|
|
44
|
+
self.fields = _FakeField(
|
|
45
|
+
summary=f"Issue {key}",
|
|
46
|
+
description=f"Body of {key}",
|
|
47
|
+
status="To Do",
|
|
48
|
+
priority="Medium",
|
|
49
|
+
issuetype="Task",
|
|
50
|
+
project=_FakeField(key="CT", name="Cortex Test", projectTypeKey="software"),
|
|
51
|
+
labels=[],
|
|
52
|
+
components=[],
|
|
53
|
+
created="2026-07-20T10:00:00.000+0000",
|
|
54
|
+
updated=f"2026-07-20T10:{idx:02d}:00.000+0000",
|
|
55
|
+
assignee=None,
|
|
56
|
+
reporter=_FakeField(
|
|
57
|
+
displayName="Rep Orter",
|
|
58
|
+
emailAddress="rep@example.com",
|
|
59
|
+
accountId=f"712020:uuid-{idx}",
|
|
60
|
+
),
|
|
61
|
+
**custom,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _FakePage(list):
|
|
66
|
+
"""Mimics jira's ResultList: current Jira Cloud reports `total` as the
|
|
67
|
+
CURRENT PAGE length (the C5 trap), and maxResults as 0."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, items):
|
|
70
|
+
super().__init__(items)
|
|
71
|
+
self.total = len(items)
|
|
72
|
+
self.maxResults = 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _FakeJira:
|
|
76
|
+
def __init__(self, issues, fields=None, auth_ok=True):
|
|
77
|
+
self._issues = issues
|
|
78
|
+
self._fields = fields or []
|
|
79
|
+
self._auth_ok = auth_ok
|
|
80
|
+
self.search_calls = []
|
|
81
|
+
|
|
82
|
+
def myself(self):
|
|
83
|
+
if not self._auth_ok:
|
|
84
|
+
raise RuntimeError("401 Unauthorized")
|
|
85
|
+
return {"accountId": "712020:me"}
|
|
86
|
+
|
|
87
|
+
def fields(self):
|
|
88
|
+
return self._fields
|
|
89
|
+
|
|
90
|
+
def search_issues(self, jql, startAt=0, maxResults=50, **kw):
|
|
91
|
+
self.search_calls.append(startAt)
|
|
92
|
+
return _FakePage(self._issues[startAt : startAt + maxResults])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _connector(**kw) -> JiraConnector:
|
|
96
|
+
defaults = dict(
|
|
97
|
+
cortex_url=CORTEXDB_URL,
|
|
98
|
+
cortex_api_key="k",
|
|
99
|
+
jira_url="https://example.atlassian.net",
|
|
100
|
+
jira_email="me@example.com",
|
|
101
|
+
jira_api_token="t",
|
|
102
|
+
project_keys=["CT"],
|
|
103
|
+
)
|
|
104
|
+
defaults.update(kw)
|
|
105
|
+
return JiraConnector(**defaults)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fetch(connector: JiraConnector, fake: _FakeJira):
|
|
109
|
+
"""Run fetch_events with the fake jira client injected."""
|
|
110
|
+
fake_mod = types.SimpleNamespace(JIRA=lambda **kw: fake)
|
|
111
|
+
with patch.dict(sys.modules, {"jira": fake_mod}):
|
|
112
|
+
return asyncio.run(connector.fetch_events())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# C5 — pagination
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestPagination:
|
|
121
|
+
def test_fetches_past_page_one_despite_lying_total(self, monkeypatch):
|
|
122
|
+
import cortexdb_connectors.jira as jira_mod
|
|
123
|
+
|
|
124
|
+
monkeypatch.setattr(jira_mod, "_PAGE_SIZE", 2)
|
|
125
|
+
issues = [_FakeIssue(f"CT-{i}", i) for i in range(5)]
|
|
126
|
+
fake = _FakeJira(issues)
|
|
127
|
+
events = _fetch(_connector(), fake)
|
|
128
|
+
assert len(events) == 5, "must page past `total`==page-length"
|
|
129
|
+
assert fake.search_calls == [0, 2, 4]
|
|
130
|
+
|
|
131
|
+
def test_exact_multiple_of_page_size_terminates(self, monkeypatch):
|
|
132
|
+
import cortexdb_connectors.jira as jira_mod
|
|
133
|
+
|
|
134
|
+
monkeypatch.setattr(jira_mod, "_PAGE_SIZE", 2)
|
|
135
|
+
issues = [_FakeIssue(f"CT-{i}", i) for i in range(4)]
|
|
136
|
+
fake = _FakeJira(issues)
|
|
137
|
+
events = _fetch(_connector(), fake)
|
|
138
|
+
assert len(events) == 4
|
|
139
|
+
# One trailing empty page is the price of not trusting `total`.
|
|
140
|
+
assert fake.search_calls == [0, 2, 4]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# C6a / C6b — loud failures
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestLoudFailures:
|
|
149
|
+
def test_bad_token_raises(self):
|
|
150
|
+
fake = _FakeJira([], auth_ok=False)
|
|
151
|
+
with pytest.raises(RuntimeError, match="authentication failed"):
|
|
152
|
+
_fetch(_connector(), fake)
|
|
153
|
+
|
|
154
|
+
def test_empty_project_keys_raises(self):
|
|
155
|
+
fake = _FakeJira([])
|
|
156
|
+
with pytest.raises(ValueError, match="project key"):
|
|
157
|
+
_fetch(_connector(project_keys=[]), fake)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# C6c — sprint / story points
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestCustomFields:
|
|
166
|
+
FIELDS = [
|
|
167
|
+
{"id": "customfield_10020", "name": "Sprint"},
|
|
168
|
+
{"id": "customfield_10016", "name": "Story point estimate"},
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
def test_populated_from_custom_fields(self):
|
|
172
|
+
issue = _FakeIssue(
|
|
173
|
+
"CT-1",
|
|
174
|
+
1,
|
|
175
|
+
customfield_10020=[_FakeField(name="Sprint 7")],
|
|
176
|
+
customfield_10016=5.0,
|
|
177
|
+
)
|
|
178
|
+
fake = _FakeJira([issue], fields=self.FIELDS)
|
|
179
|
+
events = _fetch(_connector(), fake)
|
|
180
|
+
assert events[0].data["sprint"] == "Sprint 7"
|
|
181
|
+
assert events[0].data["story_points"] == 5.0
|
|
182
|
+
|
|
183
|
+
def test_greenhopper_string_sprint(self):
|
|
184
|
+
raw = (
|
|
185
|
+
"com.atlassian.greenhopper.service.sprint.Sprint@1f4[id=7,"
|
|
186
|
+
"rapidViewId=3,state=ACTIVE,name=Sprint 7,startDate=2026-07-01]"
|
|
187
|
+
)
|
|
188
|
+
issue = _FakeIssue("CT-2", 2, customfield_10020=[raw])
|
|
189
|
+
fake = _FakeJira([issue], fields=self.FIELDS)
|
|
190
|
+
events = _fetch(_connector(), fake)
|
|
191
|
+
assert events[0].data["sprint"] == "Sprint 7"
|
|
192
|
+
|
|
193
|
+
def test_absent_fields_stay_null(self):
|
|
194
|
+
issue = _FakeIssue("CT-3", 3)
|
|
195
|
+
fake = _FakeJira([issue], fields=[])
|
|
196
|
+
events = _fetch(_connector(), fake)
|
|
197
|
+
assert events[0].data["sprint"] is None
|
|
198
|
+
assert events[0].data["story_points"] is None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# C4 — idempotency key parity, C6d — issue entity
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class TestKeyAndEntities:
|
|
207
|
+
UPDATED = "2026-07-20T10:01:00.000+0000"
|
|
208
|
+
|
|
209
|
+
def _poll_episode(self) -> Episode:
|
|
210
|
+
conn = _connector()
|
|
211
|
+
fake = _FakeJira([_FakeIssue("CT-1", 1)])
|
|
212
|
+
raw = _fetch(conn, fake)[0]
|
|
213
|
+
return conn.normalize(raw)
|
|
214
|
+
|
|
215
|
+
def test_poll_key_is_source_namespaced_and_matches_webhook(self):
|
|
216
|
+
poll = self._poll_episode()
|
|
217
|
+
handler = JiraWebhookHandler(
|
|
218
|
+
secret="s", cortexdb_url=CORTEXDB_URL, cortexdb_api_key="k"
|
|
219
|
+
)
|
|
220
|
+
webhook = handler.normalize(
|
|
221
|
+
{
|
|
222
|
+
"webhookEvent": "jira:issue_updated",
|
|
223
|
+
"issue": {
|
|
224
|
+
"key": "CT-1",
|
|
225
|
+
"fields": {
|
|
226
|
+
"summary": "Issue CT-1",
|
|
227
|
+
"description": "Body of CT-1",
|
|
228
|
+
"updated": self.UPDATED,
|
|
229
|
+
"reporter": {"accountId": "712020:uuid-1"},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
assert poll.idempotency_key.startswith("jira:CT-1:")
|
|
235
|
+
assert poll.idempotency_key == webhook.idempotency_key
|
|
236
|
+
assert len(poll.idempotency_key) <= 64
|
|
237
|
+
|
|
238
|
+
def test_poll_path_emits_issue_entity(self):
|
|
239
|
+
poll = self._poll_episode()
|
|
240
|
+
kinds = {(e.entity_type, e.entity_id) for e in poll.entities}
|
|
241
|
+
assert ("issue", "CT-1") in kinds
|
|
242
|
+
|
|
243
|
+
def test_issue_scope_template_resolves(self):
|
|
244
|
+
conn = _connector()
|
|
245
|
+
conn.scope_template = "org:acme/issue:{entity.issue}"
|
|
246
|
+
assert conn._resolve_scope(self._poll_episode()) == "org:acme/issue:CT-1"
|
|
247
|
+
|
|
248
|
+
def test_unresolved_placeholder_raises(self):
|
|
249
|
+
conn = _connector()
|
|
250
|
+
conn.scope_template = "org:acme/x:{entity.bogus}"
|
|
251
|
+
with pytest.raises(ValueError, match="unresolved"):
|
|
252
|
+
conn._resolve_scope(self._poll_episode())
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# C2 — deletion wiring
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class TestDeletion:
|
|
261
|
+
def _handler(self) -> JiraWebhookHandler:
|
|
262
|
+
return JiraWebhookHandler(
|
|
263
|
+
secret="s",
|
|
264
|
+
cortexdb_url=CORTEXDB_URL,
|
|
265
|
+
cortexdb_api_key="k",
|
|
266
|
+
actor="user:tester",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def test_issue_deleted_names_the_entity(self):
|
|
270
|
+
h = self._handler()
|
|
271
|
+
assert (
|
|
272
|
+
h.deletion_entity({"webhookEvent": "jira:issue_deleted", "issue": {"key": "CT-9"}})
|
|
273
|
+
== "CT-9"
|
|
274
|
+
)
|
|
275
|
+
assert h.deletion_entity({"webhookEvent": "jira:issue_updated", "issue": {"key": "CT-9"}}) is None
|
|
276
|
+
|
|
277
|
+
def test_handle_routes_delete_to_forget_not_ingest(self):
|
|
278
|
+
import hashlib
|
|
279
|
+
import hmac as hmac_mod
|
|
280
|
+
import json as json_mod
|
|
281
|
+
|
|
282
|
+
h = self._handler()
|
|
283
|
+
body = json_mod.dumps(
|
|
284
|
+
{"webhookEvent": "jira:issue_deleted", "issue": {"key": "CT-9"}}
|
|
285
|
+
).encode()
|
|
286
|
+
sig = "sha256=" + hmac_mod.new(b"s", body, hashlib.sha256).hexdigest()
|
|
287
|
+
|
|
288
|
+
class _Req:
|
|
289
|
+
headers = {"X-Hub-Signature-256": sig}
|
|
290
|
+
|
|
291
|
+
async def body(self):
|
|
292
|
+
return body
|
|
293
|
+
|
|
294
|
+
with patch(
|
|
295
|
+
"cortexdb_connectors.webhooks._forget_entity_v1", new_callable=AsyncMock
|
|
296
|
+
) as forget, patch(
|
|
297
|
+
"cortexdb_connectors.webhooks._ingest_episode_v1", new_callable=AsyncMock
|
|
298
|
+
) as ingest:
|
|
299
|
+
resp = asyncio.run(h.handle(_Req()))
|
|
300
|
+
assert resp.status_code == 200
|
|
301
|
+
forget.assert_awaited_once()
|
|
302
|
+
assert forget.await_args.args[0] == "CT-9"
|
|
303
|
+
ingest.assert_not_awaited()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
# C3 — serve wiring
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class TestServe:
|
|
312
|
+
def test_serve_imports_and_builds_app(self):
|
|
313
|
+
# The 0.2.8 crash was an ImportError at `from ... import build_handlers`.
|
|
314
|
+
from cortexdb_connectors import cli
|
|
315
|
+
from cortexdb_connectors.webhooks import WebhookServer
|
|
316
|
+
|
|
317
|
+
app = WebhookServer(
|
|
318
|
+
cortexdb_url=CORTEXDB_URL, cortexdb_api_key="k"
|
|
319
|
+
).build_app()
|
|
320
|
+
paths = {r.path for r in app.routes}
|
|
321
|
+
assert "/webhooks/jira/events" in paths
|
|
322
|
+
assert callable(cli._cmd_serve)
|
|
323
|
+
|
|
324
|
+
def test_cmd_serve_runs_uvicorn(self):
|
|
325
|
+
import argparse
|
|
326
|
+
|
|
327
|
+
from cortexdb_connectors import cli
|
|
328
|
+
|
|
329
|
+
args = argparse.Namespace(
|
|
330
|
+
host="127.0.0.1",
|
|
331
|
+
port=0,
|
|
332
|
+
api_url=CORTEXDB_URL,
|
|
333
|
+
api_key="k",
|
|
334
|
+
actor="user:tester",
|
|
335
|
+
scope_template="user:tester",
|
|
336
|
+
config=None,
|
|
337
|
+
)
|
|
338
|
+
with patch("uvicorn.run") as run:
|
|
339
|
+
cli._cmd_serve(args)
|
|
340
|
+
run.assert_called_once()
|
|
341
|
+
assert run.call_args.kwargs["host"] == "127.0.0.1"
|
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/confluence/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/discord/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/freshdesk/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/github/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/gitlab/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/hubspot/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/insights/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/insights/detectors.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/insights/engine.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/intercom/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/linear/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/notion/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/pagerduty/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/salesforce/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/servicenow/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/slack/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/teams/__init__.py
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/tldv/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/zendesk/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/requires.txt
RENAMED
|
File without changes
|
{cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|