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.
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/PKG-INFO +2 -2
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/README.md +1 -1
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/base.py +120 -16
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/jira/__init__.py +170 -13
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/webhooks.py +10 -2
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/PKG-INFO +2 -2
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/SOURCES.txt +1 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/pyproject.toml +87 -87
- cortexdb_connectors-0.2.10/tests/test_jira_attachments.py +262 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_jira_findings_0_2_9.py +341 -341
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/cli.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/confluence/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/discord/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/freshdesk/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/github/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/gitlab/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/google_workspace/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/hubspot/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/insights/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/insights/api.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/insights/detectors.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/insights/engine.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/intercom/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/linear/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/notion/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/pagerduty/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/salesforce/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/servicenow/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/slack/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/state.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/teams/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/tldv/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/worker.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/zendesk/__init__.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/dependency_links.txt +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/entry_points.txt +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/requires.txt +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors.egg-info/top_level.txt +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/setup.cfg +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_base.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_connector_coverage.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_github.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_ingest_e2e.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_slack.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_streaming_cursor.py +0 -0
- {cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/tests/test_webhooks.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
358
|
-
"kind": "
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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(
|
{cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/cortexdb_connectors/jira/__init__.py
RENAMED
|
@@ -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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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(
|
|
639
|
+
return datetime.fromisoformat(v)
|
|
632
640
|
except ValueError:
|
|
633
641
|
pass
|
|
634
642
|
return datetime.now(timezone.utc)
|
{cortexdb_connectors-0.2.9 → cortexdb_connectors-0.2.10}/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.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
|