cortexdb-connectors 0.2.9__tar.gz → 0.2.10__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 (48) hide show
  1. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/PKG-INFO +2 -2
  2. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/README.md +1 -1
  3. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/base.py +120 -16
  4. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/jira/__init__.py +170 -13
  5. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/webhooks.py +10 -2
  6. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/PKG-INFO +2 -2
  7. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/SOURCES.txt +1 -0
  8. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/pyproject.toml +87 -87
  9. cortexdb_connectors-0.2.10/tests/test_jira_attachments.py +262 -0
  10. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_jira_findings_0_2_9.py +341 -341
  11. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/__init__.py +0 -0
  12. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/cli.py +0 -0
  13. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/confluence/__init__.py +0 -0
  14. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/discord/__init__.py +0 -0
  15. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/freshdesk/__init__.py +0 -0
  16. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/github/__init__.py +0 -0
  17. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/gitlab/__init__.py +0 -0
  18. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/google_workspace/__init__.py +0 -0
  19. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/hubspot/__init__.py +0 -0
  20. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/insights/__init__.py +0 -0
  21. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/insights/api.py +0 -0
  22. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/insights/detectors.py +0 -0
  23. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/insights/engine.py +0 -0
  24. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/intercom/__init__.py +0 -0
  25. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/linear/__init__.py +0 -0
  26. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/notion/__init__.py +0 -0
  27. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/pagerduty/__init__.py +0 -0
  28. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/salesforce/__init__.py +0 -0
  29. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/servicenow/__init__.py +0 -0
  30. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/slack/__init__.py +0 -0
  31. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/state.py +0 -0
  32. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/teams/__init__.py +0 -0
  33. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/tldv/__init__.py +0 -0
  34. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/worker.py +0 -0
  35. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/zendesk/__init__.py +0 -0
  36. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/dependency_links.txt +0 -0
  37. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/entry_points.txt +0 -0
  38. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/requires.txt +0 -0
  39. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/top_level.txt +0 -0
  40. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/setup.cfg +0 -0
  41. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_base.py +0 -0
  42. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_connector_coverage.py +0 -0
  43. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_github.py +0 -0
  44. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_ingest_e2e.py +0 -0
  45. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_slack.py +0 -0
  46. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_streaming_cursor.py +0 -0
  47. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_webhooks.py +0 -0
  48. {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/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.9
3
+ Version: 0.2.10
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
@@ -156,7 +156,7 @@ All 16 connectors are **🟢 Free** to self-host and **🔒 Starter+** ($29/mo)
156
156
  slack SLACK_BOT_TOKEN
157
157
  github GITHUB_TOKEN
158
158
  gitlab GITLAB_TOKEN
159
- jira JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN
159
+ jira JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN (incl. attachments → /v1/blobs multimodal extraction, 0.2.10+)
160
160
  linear LINEAR_API_KEY
161
161
  confluence CONFLUENCE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN
162
162
  notion NOTION_TOKEN
@@ -78,7 +78,7 @@ All 16 connectors are **🟢 Free** to self-host and **🔒 Starter+** ($29/mo)
78
78
  slack SLACK_BOT_TOKEN
79
79
  github GITHUB_TOKEN
80
80
  gitlab GITLAB_TOKEN
81
- jira JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN
81
+ jira JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN (incl. attachments → /v1/blobs multimodal extraction, 0.2.10+)
82
82
  linear LINEAR_API_KEY
83
83
  confluence CONFLUENCE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN
84
84
  notion NOTION_TOKEN
@@ -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.9"
105
+ connector_version: str = "0.2.10"
106
106
 
107
107
 
108
108
  @dataclass(frozen=True)
@@ -126,6 +126,25 @@ class Visibility:
126
126
  allowed_principals: tuple[str, ...] = ()
127
127
 
128
128
 
129
+ @dataclass
130
+ class Attachment:
131
+ """A binary attachment destined for `POST /v1/blobs` + a `blob_ref`
132
+ experience, so CortexDB's multimodal pipeline (Tika/vision/ASR content
133
+ processors) extracts and indexes it.
134
+
135
+ `data` carries the raw bytes (downloaded by the connector's
136
+ `fetch_events`); when `data` is None the attachment is metadata-only
137
+ (e.g. oversized skip) and only its filename line survives in text.
138
+ """
139
+
140
+ filename: str
141
+ mime_type: str
142
+ size_bytes: int = 0
143
+ data: bytes | None = None
144
+ source_url: str | None = None
145
+ attachment_id: str = ""
146
+
147
+
129
148
  @dataclass
130
149
  class RawEvent:
131
150
  source: str
@@ -158,6 +177,11 @@ class Episode:
158
177
  parent_id: str | None = None
159
178
  idempotency_key: str | None = None
160
179
  ttl_seconds: int | None = None
180
+ # When set, this episode is a BINARY attachment: `ingest_episode` uploads
181
+ # `blob.data` to `POST /v1/blobs` and sends the experience with
182
+ # `content.kind="blob_ref"` (the multimodal path) instead of text. The
183
+ # `content` field then serves only as the no-bytes fallback text.
184
+ blob: Attachment | None = None
161
185
 
162
186
 
163
187
  @dataclass
@@ -338,8 +362,13 @@ class CortexConnector(ABC):
338
362
  )
339
363
  return tpl
340
364
 
341
- def _envelope(self, episode: Episode) -> dict[str, Any]:
342
- """Build the v1 ExperienceItem JSON for this episode."""
365
+ def _envelope(self, episode: Episode, blob_id: str | None = None) -> dict[str, Any]:
366
+ """Build the v1 ExperienceItem JSON for this episode.
367
+
368
+ With `blob_id` (an id returned by `upload_blob`) the content becomes
369
+ `kind="blob_ref"` — the server fetches the stored bytes and runs the
370
+ per-modality content processor on them. Otherwise plain text.
371
+ """
343
372
  scope = self._resolve_scope(episode)
344
373
  modality = (
345
374
  episode.metadata.get("modality")
@@ -354,11 +383,19 @@ class CortexConnector(ABC):
354
383
  for ent in episode.entities:
355
384
  labels.append(f"{ent.entity_type}:{ent.entity_id}")
356
385
 
357
- content: dict[str, Any] = {
358
- "kind": "message",
359
- "role": "user" if modality == "conversation" else "system",
360
- "text": self._guard_content(episode.content, episode),
361
- }
386
+ if blob_id is not None:
387
+ content: dict[str, Any] = {"kind": "blob_ref", "blob_id": blob_id}
388
+ # Filename/MIME ride labels blob_ref content carries no text,
389
+ # and labels are the queryable metadata surface.
390
+ if episode.blob is not None:
391
+ labels.append(f"filename={episode.blob.filename}")
392
+ labels.append(f"mime={episode.blob.mime_type}")
393
+ else:
394
+ content = {
395
+ "kind": "message",
396
+ "role": "user" if modality == "conversation" else "system",
397
+ "text": self._guard_content(episode.content, episode),
398
+ }
362
399
  # The v1 write envelope is STRICT (deny_unknown_fields) as of v0.8.0:
363
400
  # thread_id / meta / preceded_by are NOT envelope context fields, and
364
401
  # sending them returns 422 INVALID_BODY (which stalls the sync cursor
@@ -433,10 +470,67 @@ class CortexConnector(ABC):
433
470
  )
434
471
  return capped
435
472
 
473
+ def _headers(self, content_type: str = "application/json") -> dict[str, str]:
474
+ return {
475
+ "Authorization": f"Bearer {self.cortex_api_key}",
476
+ "X-Cortex-Actor": self.actor,
477
+ "Content-Type": content_type,
478
+ "User-Agent": f"cortexdb-connectors/{Source(system='base').connector_version}",
479
+ }
480
+
481
+ async def upload_blob(self, data: bytes, mime_type: str) -> str:
482
+ """POST raw bytes to `/v1/blobs`; returns the server `blob_id`."""
483
+ import httpx
484
+
485
+ async with httpx.AsyncClient(timeout=120.0) as client:
486
+ resp = await client.post(
487
+ f"{self.cortex_url}/v1/blobs",
488
+ content=data,
489
+ headers=self._headers(mime_type or "application/octet-stream"),
490
+ )
491
+ if resp.status_code >= 400:
492
+ logger.error(
493
+ "blob upload REJECTED: status=%s bytes=%d mime=%s body=%s",
494
+ resp.status_code, len(data), mime_type, resp.text[:300],
495
+ )
496
+ resp.raise_for_status()
497
+ return resp.json()["blob_id"]
498
+
499
+ async def experience_exists(self, idempotency_key: str, scope: str) -> bool:
500
+ """Probe `GET /v1/experience/status` for a prior write with this key.
501
+
502
+ Used before attachment blob uploads so a re-sync doesn't store a
503
+ fresh (orphaned) blob copy for an experience that will just replay.
504
+ Best-effort: any probe failure returns False (upload proceeds — the
505
+ experience write itself still dedups via its idempotency key).
506
+ """
507
+ import httpx
508
+
509
+ try:
510
+ async with httpx.AsyncClient(timeout=15.0) as client:
511
+ resp = await client.get(
512
+ f"{self.cortex_url}/v1/experience/status",
513
+ params={"idempotency_key": idempotency_key, "scope": scope},
514
+ headers=self._headers(),
515
+ )
516
+ if resp.status_code != 200:
517
+ return False
518
+ return bool(resp.json().get("found"))
519
+ except Exception: # noqa: BLE001
520
+ return False
521
+
436
522
  async def ingest_episode(self, episode: Episode) -> str:
437
523
  """POST `/v1/experience` with the v1 envelope.
438
524
 
439
- Returns the server-side event_id on success.
525
+ Blob-carrying episodes (`episode.blob` set with bytes) first upload
526
+ the bytes to `/v1/blobs` and go in as `content.kind="blob_ref"` so the
527
+ server's content processors extract them. A status probe on the
528
+ episode's idempotency key skips the upload when the experience already
529
+ exists (re-sync), avoiding orphaned duplicate blobs. Bytes-less blob
530
+ episodes (oversized skips) fall back to their text content.
531
+
532
+ Returns the server-side event_id on success ("exists" when the
533
+ status probe short-circuited a replay).
440
534
  """
441
535
  import httpx
442
536
 
@@ -452,13 +546,23 @@ class CortexConnector(ABC):
452
546
  "explicitly. The actor must match the token's `sub` claim."
453
547
  )
454
548
 
455
- envelope = self._envelope(episode)
456
- headers = {
457
- "Authorization": f"Bearer {self.cortex_api_key}",
458
- "X-Cortex-Actor": self.actor,
459
- "Content-Type": "application/json",
460
- "User-Agent": f"cortexdb-connectors/{Source(system='base').connector_version}",
461
- }
549
+ blob_id: str | None = None
550
+ if episode.blob is not None and episode.blob.data:
551
+ scope = self._resolve_scope(episode)
552
+ key = episode.idempotency_key or self._fallback_idem(episode, scope)
553
+ if await self.experience_exists(key, scope):
554
+ logger.info(
555
+ "attachment already ingested (idempotency_key=%s); "
556
+ "skipping blob upload",
557
+ key,
558
+ )
559
+ return "exists"
560
+ blob_id = await self.upload_blob(
561
+ episode.blob.data, episode.blob.mime_type
562
+ )
563
+
564
+ envelope = self._envelope(episode, blob_id=blob_id)
565
+ headers = self._headers()
462
566
 
463
567
  async with httpx.AsyncClient(timeout=30.0) as client:
464
568
  resp = await client.post(
@@ -14,6 +14,7 @@ from typing import Any
14
14
 
15
15
  from cortexdb_connectors.base import (
16
16
  Actor,
17
+ Attachment,
17
18
  CortexConnector,
18
19
  EntityRef,
19
20
  Episode,
@@ -30,6 +31,11 @@ _SOURCE = Source(system="jira")
30
31
 
31
32
  _PAGE_SIZE = 50
32
33
 
34
+ # Attachments above this size are listed in the issue text but their bytes are
35
+ # NOT uploaded (matches the server's 10 MB content guard; huge videos etc.
36
+ # would blow request limits). The skip is logged loudly, never silent.
37
+ _MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024
38
+
33
39
 
34
40
  class JiraConnector(CortexConnector):
35
41
  """Connector that syncs Jira issues into CortexDB.
@@ -131,15 +137,21 @@ class JiraConnector(CortexConnector):
131
137
  for issue in results:
132
138
  fields = issue.fields
133
139
  updated_str = getattr(fields, "updated", None) or ""
134
- try:
135
- updated_at = datetime.fromisoformat(
136
- updated_str.replace("Z", "+00:00")
137
- )
138
- except (ValueError, TypeError):
139
- updated_at = datetime.now(timezone.utc)
140
+ # Same +0000-offset normalization as attachments: on Python
141
+ # 3.10 the old parse silently fell back to now(), making the
142
+ # version-aware idempotency key unstable across syncs.
143
+ updated_at = self._parse_att_ts(
144
+ updated_str, datetime.now(timezone.utc)
145
+ )
140
146
 
141
147
  issue_data = self._serialize_issue(issue, sprint_field, points_field)
142
148
  project_data = issue_data.get("project", {})
149
+ permissions = {
150
+ "project_key": project_data.get("key", ""),
151
+ "project_type": project_data.get(
152
+ "projectTypeKey", "software"
153
+ ),
154
+ }
143
155
 
144
156
  raw_events.append(
145
157
  RawEvent(
@@ -147,15 +159,70 @@ class JiraConnector(CortexConnector):
147
159
  external_id=issue.key,
148
160
  timestamp=updated_at,
149
161
  data=issue_data,
150
- permissions={
151
- "project_key": project_data.get("key", ""),
152
- "project_type": project_data.get(
153
- "projectTypeKey", "software"
154
- ),
155
- },
162
+ permissions=permissions,
156
163
  )
157
164
  )
158
165
 
166
+ # One RawEvent per attachment: bytes are downloaded here (the
167
+ # jira client session is already authenticated) and ride to
168
+ # `normalize` → `ingest_episode`, which uploads them to
169
+ # /v1/blobs and sends a blob_ref experience so the server's
170
+ # content processors extract them. Attachments created before
171
+ # `since` were carried by the sync window that saw them added
172
+ # (an attachment add bumps the issue's `updated`), so they are
173
+ # skipped without re-downloading.
174
+ for att in getattr(fields, "attachment", None) or []:
175
+ att_created = self._parse_att_ts(
176
+ getattr(att, "created", None), updated_at
177
+ )
178
+ if since is not None and att_created < since:
179
+ continue
180
+ att_size = int(getattr(att, "size", 0) or 0)
181
+ att_name = getattr(att, "filename", None) or "attachment"
182
+ att_mime = (
183
+ getattr(att, "mimeType", None)
184
+ or "application/octet-stream"
185
+ )
186
+ data_bytes: bytes | None = None
187
+ if att_size > _MAX_ATTACHMENT_BYTES:
188
+ logger.error(
189
+ "jira attachment SKIPPED (too large): issue=%s "
190
+ "file=%s size=%d limit=%d — filename recorded, "
191
+ "bytes NOT ingested",
192
+ issue.key, att_name, att_size, _MAX_ATTACHMENT_BYTES,
193
+ )
194
+ else:
195
+ try:
196
+ data_bytes = att.get()
197
+ except Exception as exc: # noqa: BLE001
198
+ logger.error(
199
+ "jira attachment download FAILED: issue=%s "
200
+ "file=%s: %s — filename recorded, bytes NOT "
201
+ "ingested",
202
+ issue.key, att_name, exc,
203
+ )
204
+ raw_events.append(
205
+ RawEvent(
206
+ source="jira",
207
+ external_id=f"{issue.key}:att:{att.id}",
208
+ timestamp=att_created,
209
+ data={
210
+ "kind": "attachment",
211
+ "issue_key": issue.key,
212
+ "issue_summary": issue_data.get("summary", ""),
213
+ "attachment_id": str(att.id),
214
+ "filename": att_name,
215
+ "mime_type": att_mime,
216
+ "size": att_size,
217
+ "author": str(
218
+ getattr(att, "author", "") or ""
219
+ ),
220
+ "bytes": data_bytes,
221
+ },
222
+ permissions=dict(permissions),
223
+ )
224
+ )
225
+
159
226
  # C5: current Jira Cloud (enhanced /search/jql) reports
160
227
  # `results.total` as the CURRENT PAGE's length (and maxResults as
161
228
  # 0), so a total-based check exits after page 1 every time and
@@ -168,8 +235,10 @@ class JiraConnector(CortexConnector):
168
235
  return raw_events
169
236
 
170
237
  def normalize(self, raw: RawEvent) -> Episode:
171
- """Convert a Jira issue ``RawEvent`` into a CortexDB ``Episode``."""
238
+ """Convert a Jira issue or attachment ``RawEvent`` into an ``Episode``."""
172
239
  data = raw.data
240
+ if data.get("kind") == "attachment":
241
+ return self._normalize_attachment(raw)
173
242
  actor = self._extract_actor(data)
174
243
  entities = self._extract_entities(data)
175
244
 
@@ -183,6 +252,17 @@ class JiraConnector(CortexConnector):
183
252
  content = f"[{issue_key}] {summary}"
184
253
  if description:
185
254
  content += f"\n\n{description}"
255
+ # Cheap always-there fallback (matches Discord/Teams/Zendesk): the
256
+ # attachment NAMES are searchable on the issue itself even if a blob
257
+ # upload is skipped or fails. Bytes go via the attachment episodes.
258
+ atts = data.get("attachments") or []
259
+ if atts:
260
+ listing = ", ".join(
261
+ f"{a.get('filename', '?')} ({a.get('mimeType', '?')}, "
262
+ f"{a.get('size', 0)} bytes)"
263
+ for a in atts
264
+ )
265
+ content += f"\n\nAttachments: {listing}"
186
266
 
187
267
  return Episode(
188
268
  actor=actor,
@@ -228,8 +308,73 @@ class JiraConnector(CortexConnector):
228
308
  return Visibility(level=VisibilityLevel.RESTRICTED)
229
309
  return Visibility(level=VisibilityLevel.ORGANIZATION)
230
310
 
311
+ def _normalize_attachment(self, raw: RawEvent) -> Episode:
312
+ """Attachment RawEvent → blob-carrying Episode (`content.kind="blob_ref"`).
313
+
314
+ The idempotency key is the stable attachment id (attachments are
315
+ immutable in Jira), so re-syncs of an updated issue replay instead of
316
+ duplicating — and `ingest_episode`'s status probe skips the blob
317
+ re-upload entirely. Without bytes (oversize/download failure) the
318
+ episode degrades to its text line, so the filename is never lost.
319
+ """
320
+ data = raw.data
321
+ issue_key = data.get("issue_key", "?")
322
+ filename = data.get("filename", "attachment")
323
+ mime = data.get("mime_type", "application/octet-stream")
324
+ size = int(data.get("size", 0) or 0)
325
+ att_id = data.get("attachment_id", "")
326
+ author = data.get("author") or "unknown"
327
+
328
+ blob = Attachment(
329
+ filename=filename,
330
+ mime_type=mime,
331
+ size_bytes=size,
332
+ data=data.get("bytes"),
333
+ attachment_id=att_id,
334
+ )
335
+ return Episode(
336
+ actor=Actor(id=str(author), name=str(author)),
337
+ source=_SOURCE,
338
+ episode_type=EpisodeType.DOCUMENT,
339
+ occurred_at=raw.timestamp,
340
+ content=(
341
+ f"[Jira attachment] {filename} ({mime}, {size} bytes) "
342
+ f"on {issue_key}: {data.get('issue_summary', '')}"
343
+ ),
344
+ raw_payload={k: v for k, v in data.items() if k != "bytes"},
345
+ scope=self.scope,
346
+ namespace="jira",
347
+ entities=[EntityRef(entity_type="ticket", entity_id=issue_key)],
348
+ tags=["jira", "attachment"],
349
+ metadata={
350
+ "issue_key": issue_key,
351
+ "attachment_id": att_id,
352
+ "size_bytes": size,
353
+ },
354
+ thread_id=f"jira:{issue_key}",
355
+ idempotency_key=f"jira:att:{att_id}",
356
+ blob=blob,
357
+ )
358
+
231
359
  # -- helpers -------------------------------------------------------------
232
360
 
361
+ @staticmethod
362
+ def _parse_att_ts(value: Any, fallback: datetime) -> datetime:
363
+ """Parse a Jira attachment `created` timestamp; fallback = issue updated.
364
+
365
+ Jira emits `+0000`-style offsets (no colon), which Python 3.10's
366
+ `fromisoformat` rejects — normalize to `+00:00` so the parse works on
367
+ every supported interpreter instead of silently falling back.
368
+ """
369
+ if isinstance(value, str) and value:
370
+ v = value.replace("Z", "+00:00")
371
+ v = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", v)
372
+ try:
373
+ return datetime.fromisoformat(v)
374
+ except ValueError:
375
+ pass
376
+ return fallback
377
+
233
378
  @staticmethod
234
379
  def _custom_field_ids(client: Any) -> tuple[str | None, str | None]:
235
380
  """Resolve the instance's Sprint / Story-Points custom field ids.
@@ -336,6 +481,18 @@ class JiraConnector(CortexConnector):
336
481
  "story_points": (
337
482
  getattr(fields, points_field, None) if points_field else None
338
483
  ),
484
+ # Metadata only (id/name/mime/size) — bytes ride their own
485
+ # attachment RawEvents. Feeds the issue text's "Attachments:" line.
486
+ "attachments": [
487
+ {
488
+ "id": str(getattr(a, "id", "")),
489
+ "filename": getattr(a, "filename", None) or "attachment",
490
+ "mimeType": getattr(a, "mimeType", None)
491
+ or "application/octet-stream",
492
+ "size": int(getattr(a, "size", 0) or 0),
493
+ }
494
+ for a in (getattr(fields, "attachment", None) or [])
495
+ ],
339
496
  }
340
497
 
341
498
  @staticmethod
@@ -22,6 +22,7 @@ import hmac
22
22
  import json
23
23
  import logging
24
24
  import os
25
+ import re
25
26
  import time
26
27
  from datetime import datetime, timezone
27
28
  from typing import Any
@@ -625,10 +626,17 @@ class _HmacWebhookHandler:
625
626
 
626
627
 
627
628
  def _parse_iso(value: Any) -> datetime:
628
- """Parse an ISO-8601 string (Z or offset) → aware datetime; fall back to now."""
629
+ """Parse an ISO-8601 string (Z or offset) → aware datetime; fall back to now.
630
+
631
+ Jira emits `+0000`-style offsets (no colon), which Python 3.10's
632
+ `fromisoformat` rejects — normalize to `+00:00` so poll and webhook paths
633
+ parse identically on every interpreter (C4 key parity depends on it).
634
+ """
629
635
  if isinstance(value, str) and value:
636
+ v = value.replace("Z", "+00:00")
637
+ v = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", v)
630
638
  try:
631
- return datetime.fromisoformat(value.replace("Z", "+00:00"))
639
+ return datetime.fromisoformat(v)
632
640
  except ValueError:
633
641
  pass
634
642
  return datetime.now(timezone.utc)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexdb-connectors
3
- Version: 0.2.9
3
+ Version: 0.2.10
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
@@ -156,7 +156,7 @@ All 16 connectors are **🟢 Free** to self-host and **🔒 Starter+** ($29/mo)
156
156
  slack SLACK_BOT_TOKEN
157
157
  github GITHUB_TOKEN
158
158
  gitlab GITLAB_TOKEN
159
- jira JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN
159
+ jira JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN (incl. attachments → /v1/blobs multimodal extraction, 0.2.10+)
160
160
  linear LINEAR_API_KEY
161
161
  confluence CONFLUENCE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN
162
162
  notion NOTION_TOKEN
@@ -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_attachments.py
41
42
  tests/test_jira_findings_0_2_9.py
42
43
  tests/test_slack.py
43
44
  tests/test_streaming_cursor.py