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.
Files changed (47) hide show
  1. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/PKG-INFO +1 -1
  2. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/base.py +12 -1
  3. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/cli.py +31 -6
  4. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/jira/__init__.py +112 -14
  5. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/webhooks.py +44 -1
  6. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/PKG-INFO +1 -1
  7. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/SOURCES.txt +1 -0
  8. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/pyproject.toml +87 -87
  9. cortexdb_connectors-0.2.9/tests/test_jira_findings_0_2_9.py +341 -0
  10. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/README.md +0 -0
  11. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/__init__.py +0 -0
  12. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/confluence/__init__.py +0 -0
  13. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/discord/__init__.py +0 -0
  14. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/freshdesk/__init__.py +0 -0
  15. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/github/__init__.py +0 -0
  16. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/gitlab/__init__.py +0 -0
  17. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/google_workspace/__init__.py +0 -0
  18. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/hubspot/__init__.py +0 -0
  19. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/insights/__init__.py +0 -0
  20. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/insights/api.py +0 -0
  21. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/insights/detectors.py +0 -0
  22. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/insights/engine.py +0 -0
  23. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/intercom/__init__.py +0 -0
  24. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/linear/__init__.py +0 -0
  25. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/notion/__init__.py +0 -0
  26. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/pagerduty/__init__.py +0 -0
  27. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/salesforce/__init__.py +0 -0
  28. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/servicenow/__init__.py +0 -0
  29. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/slack/__init__.py +0 -0
  30. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/state.py +0 -0
  31. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/teams/__init__.py +0 -0
  32. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/tldv/__init__.py +0 -0
  33. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/worker.py +0 -0
  34. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors/zendesk/__init__.py +0 -0
  35. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/dependency_links.txt +0 -0
  36. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/entry_points.txt +0 -0
  37. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/requires.txt +0 -0
  38. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/cortexdb_connectors.egg-info/top_level.txt +0 -0
  39. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/setup.cfg +0 -0
  40. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_base.py +0 -0
  41. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_connector_coverage.py +0 -0
  42. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_github.py +0 -0
  43. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_ingest_e2e.py +0 -0
  44. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_slack.py +0 -0
  45. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_streaming_cursor.py +0 -0
  46. {cortexdb_connectors-0.2.8 → cortexdb_connectors-0.2.9}/tests/test_webhooks.py +0 -0
  47. {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.8
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.5"
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
- from cortexdb_connectors.webhooks import WebhookServer, build_handlers
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
- handlers = build_handlers(creds)
439
- if not handlers:
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 handlers configured — set per-source signing secrets "
442
- "(e.g. SLACK_SIGNING_SECRET) so the receiver can verify deliveries."
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
- WebhookServer(handlers, host=args.host, port=args.port).serve_forever()
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:
@@ -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
- jql_parts: list[str] = []
89
- if self.project_keys:
90
- keys_str = ", ".join(self.project_keys)
91
- jql_parts.append(f"project in ({keys_str})")
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
- if start_at + _PAGE_SIZE >= results.total:
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
- idempotency_key=f"{raw.external_id}:{raw.timestamp.isoformat()}",
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 _serialize_issue(issue: Any) -> dict[str, Any]:
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": None, # Populated from custom fields if available.
251
- "story_points": None,
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
- idempotency_key=f"jira:{key}:{updated or 'na'}",
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexdb-connectors
3
- Version: 0.2.8
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
@@ -38,6 +38,7 @@ tests/test_base.py
38
38
  tests/test_connector_coverage.py
39
39
  tests/test_github.py
40
40
  tests/test_ingest_e2e.py
41
+ tests/test_jira_findings_0_2_9.py
41
42
  tests/test_slack.py
42
43
  tests/test_streaming_cursor.py
43
44
  tests/test_webhooks.py
@@ -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"
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"