cortexdb-connectors 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,298 @@
1
+ """
2
+ Jira connector for CortexDB.
3
+
4
+ Polls Jira projects for recently updated issues using JQL and ingests
5
+ them as CortexDB Episodes. Uses the ``jira`` library for API access.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ from cortexdb_connectors.base import (
15
+ Actor,
16
+ CortexConnector,
17
+ EntityRef,
18
+ Episode,
19
+ EpisodeType,
20
+ RawEvent,
21
+ Source,
22
+ Visibility,
23
+ VisibilityLevel,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _SOURCE = Source(system="jira")
29
+
30
+ _PAGE_SIZE = 50
31
+
32
+
33
+ class JiraConnector(CortexConnector):
34
+ """Connector that syncs Jira issues into CortexDB.
35
+
36
+ Parameters
37
+ ----------
38
+ cortex_url:
39
+ Base URL of the CortexDB API.
40
+ cortex_api_key:
41
+ Bearer token for CortexDB authentication.
42
+ jira_url:
43
+ Base URL of the Jira instance (e.g. ``https://myteam.atlassian.net``).
44
+ jira_email:
45
+ Email address for Jira authentication.
46
+ jira_api_token:
47
+ Jira API token (Atlassian account token).
48
+ project_keys:
49
+ List of Jira project keys to sync (e.g. ``["ENG", "INFRA"]``).
50
+ tenant_id:
51
+ CortexDB tenant identifier.
52
+ """
53
+
54
+ connector_name: str = "jira"
55
+
56
+ def __init__(
57
+ self,
58
+ cortex_url: str,
59
+ cortex_api_key: str,
60
+ jira_url: str,
61
+ jira_email: str,
62
+ jira_api_token: str,
63
+ project_keys: list[str] | None = None,
64
+ tenant_id: str = "default",
65
+ ) -> None:
66
+ super().__init__(cortex_url, cortex_api_key, tenant_id)
67
+ self.jira_url = jira_url.rstrip("/")
68
+ self.jira_email = jira_email
69
+ self.jira_api_token = jira_api_token
70
+ self.project_keys = project_keys or []
71
+
72
+ # -- CortexConnector interface -------------------------------------------
73
+
74
+ async def fetch_events(
75
+ self, since: datetime | None = None
76
+ ) -> list[RawEvent]:
77
+ """Fetch issues updated since *since* from configured Jira projects.
78
+
79
+ Uses JQL ``project in (...) AND updated >= "..."`` with pagination.
80
+ """
81
+ from jira import JIRA
82
+
83
+ client = JIRA(
84
+ server=self.jira_url,
85
+ basic_auth=(self.jira_email, self.jira_api_token),
86
+ )
87
+
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})")
92
+ if since:
93
+ jql_date = since.strftime("%Y-%m-%d %H:%M")
94
+ 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"
99
+
100
+ raw_events: list[RawEvent] = []
101
+ start_at = 0
102
+
103
+ while True:
104
+ results = client.search_issues(
105
+ jql,
106
+ startAt=start_at,
107
+ maxResults=_PAGE_SIZE,
108
+ expand="changelog",
109
+ )
110
+
111
+ for issue in results:
112
+ fields = issue.fields
113
+ updated_str = getattr(fields, "updated", None) or ""
114
+ try:
115
+ updated_at = datetime.fromisoformat(
116
+ updated_str.replace("Z", "+00:00")
117
+ )
118
+ except (ValueError, TypeError):
119
+ updated_at = datetime.now(timezone.utc)
120
+
121
+ issue_data = self._serialize_issue(issue)
122
+ project_data = issue_data.get("project", {})
123
+
124
+ raw_events.append(
125
+ RawEvent(
126
+ source="jira",
127
+ external_id=issue.key,
128
+ timestamp=updated_at,
129
+ data=issue_data,
130
+ permissions={
131
+ "project_key": project_data.get("key", ""),
132
+ "project_type": project_data.get(
133
+ "projectTypeKey", "software"
134
+ ),
135
+ },
136
+ )
137
+ )
138
+
139
+ if start_at + _PAGE_SIZE >= results.total:
140
+ break
141
+ start_at += _PAGE_SIZE
142
+
143
+ return raw_events
144
+
145
+ def normalize(self, raw: RawEvent) -> Episode:
146
+ """Convert a Jira issue ``RawEvent`` into a CortexDB ``Episode``."""
147
+ data = raw.data
148
+ actor = self._extract_actor(data)
149
+ entities = self._extract_entities(data)
150
+
151
+ status = data.get("status", "Unknown")
152
+ priority = data.get("priority", "Medium")
153
+ summary = data.get("summary", "Untitled")
154
+ issue_key = data.get("key", raw.external_id)
155
+ issue_type = data.get("issuetype", "Task")
156
+ description = data.get("description", "") or ""
157
+
158
+ content = f"[{issue_key}] {summary}"
159
+ if description:
160
+ content += f"\n\n{description[:500]}"
161
+
162
+ return Episode(
163
+ actor=actor,
164
+ source=_SOURCE,
165
+ episode_type=EpisodeType.ISSUE,
166
+ occurred_at=raw.timestamp,
167
+ content=content,
168
+ raw_payload=data,
169
+ tenant_id=self.tenant_id,
170
+ namespace="jira",
171
+ entities=entities,
172
+ tags=["jira", status.lower().replace(" ", "_"), issue_type.lower()],
173
+ metadata={
174
+ "issue_key": issue_key,
175
+ "status": status,
176
+ "priority": priority,
177
+ "issue_type": issue_type,
178
+ "labels": data.get("labels", []),
179
+ "components": data.get("components", []),
180
+ "sprint": data.get("sprint"),
181
+ "story_points": data.get("story_points"),
182
+ },
183
+ thread_id=f"jira:{issue_key}",
184
+ idempotency_key=f"{raw.external_id}:{raw.timestamp.isoformat()}",
185
+ )
186
+
187
+ def map_permissions(self, raw: RawEvent) -> Visibility:
188
+ """Map Jira project visibility to CortexDB visibility.
189
+
190
+ Projects with ``projectTypeKey`` of ``service_desk`` are treated as
191
+ potentially customer-facing and marked ``Restricted``. All others
192
+ default to ``Organization``.
193
+ """
194
+ project_type = raw.permissions.get("project_type", "software")
195
+ if project_type == "service_desk":
196
+ return Visibility(level=VisibilityLevel.RESTRICTED)
197
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
198
+
199
+ # -- helpers -------------------------------------------------------------
200
+
201
+ @staticmethod
202
+ def _serialize_issue(issue: Any) -> dict[str, Any]:
203
+ """Flatten a ``jira.Issue`` into a plain dict."""
204
+ fields = issue.fields
205
+ assignee = getattr(fields, "assignee", None)
206
+ reporter = getattr(fields, "reporter", None)
207
+ priority = getattr(fields, "priority", None)
208
+ status = getattr(fields, "status", None)
209
+ issuetype = getattr(fields, "issuetype", None)
210
+ project = getattr(fields, "project", None)
211
+
212
+ return {
213
+ "key": issue.key,
214
+ "id": issue.id,
215
+ "summary": getattr(fields, "summary", ""),
216
+ "description": getattr(fields, "description", ""),
217
+ "status": str(status) if status else "Unknown",
218
+ "priority": str(priority) if priority else "Medium",
219
+ "issuetype": str(issuetype) if issuetype else "Task",
220
+ "assignee": {
221
+ "name": getattr(assignee, "displayName", "Unassigned"),
222
+ "email": getattr(assignee, "emailAddress", None),
223
+ "account_id": getattr(assignee, "accountId", None),
224
+ }
225
+ if assignee
226
+ else None,
227
+ "reporter": {
228
+ "name": getattr(reporter, "displayName", "Unknown"),
229
+ "email": getattr(reporter, "emailAddress", None),
230
+ "account_id": getattr(reporter, "accountId", None),
231
+ }
232
+ if reporter
233
+ else None,
234
+ "project": {
235
+ "key": getattr(project, "key", ""),
236
+ "name": getattr(project, "name", ""),
237
+ "projectTypeKey": getattr(project, "projectTypeKey", "software"),
238
+ }
239
+ if project
240
+ else {},
241
+ "labels": list(getattr(fields, "labels", []) or []),
242
+ "components": [
243
+ str(c) for c in (getattr(fields, "components", []) or [])
244
+ ],
245
+ "created": getattr(fields, "created", ""),
246
+ "updated": getattr(fields, "updated", ""),
247
+ "sprint": None, # Populated from custom fields if available.
248
+ "story_points": None,
249
+ }
250
+
251
+ @staticmethod
252
+ def _extract_actor(data: dict[str, Any]) -> Actor:
253
+ """Extract the primary actor (reporter) from issue data."""
254
+ reporter = data.get("reporter")
255
+ if reporter:
256
+ return Actor(
257
+ id=reporter.get("account_id", "unknown"),
258
+ name=reporter.get("name", "Unknown"),
259
+ email=reporter.get("email"),
260
+ )
261
+ return Actor(id="unknown", name="Unknown")
262
+
263
+ @staticmethod
264
+ def _extract_entities(data: dict[str, Any]) -> list[EntityRef]:
265
+ """Extract entity references from issue data."""
266
+ entities: list[EntityRef] = []
267
+
268
+ project = data.get("project", {})
269
+ if project.get("key"):
270
+ entities.append(
271
+ EntityRef(
272
+ entity_type="project",
273
+ entity_id=project["key"],
274
+ display_name=project.get("name", project["key"]),
275
+ )
276
+ )
277
+
278
+ assignee = data.get("assignee")
279
+ if assignee and assignee.get("account_id"):
280
+ entities.append(
281
+ EntityRef(
282
+ entity_type="user",
283
+ entity_id=assignee["account_id"],
284
+ display_name=assignee.get("name", ""),
285
+ )
286
+ )
287
+
288
+ reporter = data.get("reporter")
289
+ if reporter and reporter.get("account_id"):
290
+ entities.append(
291
+ EntityRef(
292
+ entity_type="user",
293
+ entity_id=reporter["account_id"],
294
+ display_name=reporter.get("name", ""),
295
+ )
296
+ )
297
+
298
+ return entities