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.
- cortexdb_connectors/__init__.py +40 -0
- cortexdb_connectors/base.py +364 -0
- cortexdb_connectors/cli.py +476 -0
- cortexdb_connectors/confluence/__init__.py +359 -0
- cortexdb_connectors/discord/__init__.py +646 -0
- cortexdb_connectors/github/__init__.py +309 -0
- cortexdb_connectors/gitlab/__init__.py +749 -0
- cortexdb_connectors/google_workspace/__init__.py +1033 -0
- cortexdb_connectors/hubspot/__init__.py +489 -0
- cortexdb_connectors/insights/__init__.py +40 -0
- cortexdb_connectors/insights/api.py +252 -0
- cortexdb_connectors/insights/detectors.py +771 -0
- cortexdb_connectors/insights/engine.py +308 -0
- cortexdb_connectors/intercom/__init__.py +631 -0
- cortexdb_connectors/jira/__init__.py +298 -0
- cortexdb_connectors/linear/__init__.py +705 -0
- cortexdb_connectors/notion/__init__.py +702 -0
- cortexdb_connectors/pagerduty/__init__.py +298 -0
- cortexdb_connectors/salesforce/__init__.py +399 -0
- cortexdb_connectors/servicenow/__init__.py +460 -0
- cortexdb_connectors/slack/__init__.py +331 -0
- cortexdb_connectors/state.py +145 -0
- cortexdb_connectors/teams/__init__.py +656 -0
- cortexdb_connectors/webhooks.py +672 -0
- cortexdb_connectors/worker.py +192 -0
- cortexdb_connectors/zendesk/__init__.py +496 -0
- cortexdb_connectors-0.2.0.dist-info/METADATA +207 -0
- cortexdb_connectors-0.2.0.dist-info/RECORD +31 -0
- cortexdb_connectors-0.2.0.dist-info/WHEEL +5 -0
- cortexdb_connectors-0.2.0.dist-info/entry_points.txt +2 -0
- cortexdb_connectors-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Linear connector for CortexDB.
|
|
3
|
+
|
|
4
|
+
Ingests issues, comments, and projects from Linear workspaces into CortexDB
|
|
5
|
+
as Episodes. Uses raw GraphQL queries via ``httpx`` against the Linear API
|
|
6
|
+
and respects the 1500 requests/hour rate limit.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from cortexdb_connectors.base import (
|
|
17
|
+
Actor,
|
|
18
|
+
CortexConnector,
|
|
19
|
+
EntityRef,
|
|
20
|
+
Episode,
|
|
21
|
+
EpisodeType,
|
|
22
|
+
RawEvent,
|
|
23
|
+
Source,
|
|
24
|
+
Visibility,
|
|
25
|
+
VisibilityLevel,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_SOURCE = Source(system="linear")
|
|
31
|
+
|
|
32
|
+
_GRAPHQL_URL = "https://api.linear.app/graphql"
|
|
33
|
+
|
|
34
|
+
# Linear rate limit: 1500 req/hour -> 0.5 s between requests.
|
|
35
|
+
_RATE_LIMIT_DELAY = 0.5
|
|
36
|
+
|
|
37
|
+
_PAGE_SIZE = 50
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# GraphQL query fragments
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
_ISSUES_QUERY = """
|
|
45
|
+
query FetchIssues($teamId: String!, $after: String, $updatedAfter: DateTime) {
|
|
46
|
+
team(id: $teamId) {
|
|
47
|
+
issues(
|
|
48
|
+
first: %d,
|
|
49
|
+
after: $after,
|
|
50
|
+
filter: { updatedAt: { gte: $updatedAfter } },
|
|
51
|
+
orderBy: updatedAt
|
|
52
|
+
) {
|
|
53
|
+
pageInfo { hasNextPage endCursor }
|
|
54
|
+
nodes {
|
|
55
|
+
id identifier title description priority priorityLabel
|
|
56
|
+
state { id name type }
|
|
57
|
+
assignee { id name email avatarUrl }
|
|
58
|
+
creator { id name email avatarUrl }
|
|
59
|
+
team { id name key }
|
|
60
|
+
project { id name }
|
|
61
|
+
cycle { id name number }
|
|
62
|
+
labels { nodes { id name } }
|
|
63
|
+
createdAt updatedAt completedAt canceledAt
|
|
64
|
+
estimate url
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
""" % _PAGE_SIZE
|
|
70
|
+
|
|
71
|
+
_COMMENTS_QUERY = """
|
|
72
|
+
query FetchComments($issueId: String!, $after: String) {
|
|
73
|
+
issue(id: $issueId) {
|
|
74
|
+
comments(first: %d, after: $after, orderBy: createdAt) {
|
|
75
|
+
pageInfo { hasNextPage endCursor }
|
|
76
|
+
nodes {
|
|
77
|
+
id body createdAt updatedAt
|
|
78
|
+
user { id name email avatarUrl }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
""" % _PAGE_SIZE
|
|
84
|
+
|
|
85
|
+
_PROJECTS_QUERY = """
|
|
86
|
+
query FetchProjects($after: String, $updatedAfter: DateTime) {
|
|
87
|
+
projects(
|
|
88
|
+
first: %d,
|
|
89
|
+
after: $after,
|
|
90
|
+
filter: { updatedAt: { gte: $updatedAfter } },
|
|
91
|
+
orderBy: updatedAt
|
|
92
|
+
) {
|
|
93
|
+
pageInfo { hasNextPage endCursor }
|
|
94
|
+
nodes {
|
|
95
|
+
id name description state
|
|
96
|
+
lead { id name email avatarUrl }
|
|
97
|
+
teams { nodes { id name key } }
|
|
98
|
+
members { nodes { id name email } }
|
|
99
|
+
startDate targetDate
|
|
100
|
+
createdAt updatedAt url
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
""" % _PAGE_SIZE
|
|
105
|
+
|
|
106
|
+
_TEAMS_QUERY = """
|
|
107
|
+
query FetchTeams {
|
|
108
|
+
teams {
|
|
109
|
+
nodes { id name key }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class LinearConnector(CortexConnector):
|
|
116
|
+
"""Connector that syncs Linear issues, comments, and projects into
|
|
117
|
+
CortexDB.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
cortex_url:
|
|
122
|
+
Base URL of the CortexDB API.
|
|
123
|
+
cortex_api_key:
|
|
124
|
+
Bearer token for CortexDB authentication.
|
|
125
|
+
linear_api_key:
|
|
126
|
+
Linear API key or OAuth token.
|
|
127
|
+
team_ids:
|
|
128
|
+
List of Linear team IDs to sync. If empty, all teams in the
|
|
129
|
+
workspace are discovered automatically.
|
|
130
|
+
tenant_id:
|
|
131
|
+
CortexDB tenant identifier.
|
|
132
|
+
namespace:
|
|
133
|
+
CortexDB namespace for ingested episodes.
|
|
134
|
+
backfill_days:
|
|
135
|
+
Number of days of history to backfill on first sync.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
connector_name: str = "linear"
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
cortex_url: str,
|
|
143
|
+
cortex_api_key: str,
|
|
144
|
+
linear_api_key: str,
|
|
145
|
+
team_ids: list[str] | None = None,
|
|
146
|
+
tenant_id: str = "default",
|
|
147
|
+
namespace: str = "linear",
|
|
148
|
+
backfill_days: int = 30,
|
|
149
|
+
) -> None:
|
|
150
|
+
super().__init__(cortex_url, cortex_api_key, tenant_id)
|
|
151
|
+
self.linear_api_key = linear_api_key
|
|
152
|
+
self.team_ids = team_ids or []
|
|
153
|
+
self.namespace = namespace
|
|
154
|
+
self.backfill_days = backfill_days
|
|
155
|
+
|
|
156
|
+
# -- CortexConnector interface -------------------------------------------
|
|
157
|
+
|
|
158
|
+
async def fetch_events(
|
|
159
|
+
self, since: datetime | None = None
|
|
160
|
+
) -> list[RawEvent]:
|
|
161
|
+
"""Fetch issues, comments, and projects from Linear.
|
|
162
|
+
|
|
163
|
+
If *since* is provided, only items updated after that timestamp
|
|
164
|
+
are returned. Pagination is handled for all queries.
|
|
165
|
+
"""
|
|
166
|
+
import httpx
|
|
167
|
+
|
|
168
|
+
cutoff = since or datetime.now(timezone.utc) - timedelta(
|
|
169
|
+
days=self.backfill_days
|
|
170
|
+
)
|
|
171
|
+
events: list[RawEvent] = []
|
|
172
|
+
|
|
173
|
+
async with httpx.AsyncClient(
|
|
174
|
+
headers={
|
|
175
|
+
"Authorization": self.linear_api_key,
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
},
|
|
178
|
+
timeout=30.0,
|
|
179
|
+
) as client:
|
|
180
|
+
# Discover teams if none configured.
|
|
181
|
+
team_ids = self.team_ids or await self._discover_teams(client)
|
|
182
|
+
|
|
183
|
+
# 1. Fetch issues per team.
|
|
184
|
+
for team_id in team_ids:
|
|
185
|
+
issue_events = await self._fetch_issues(
|
|
186
|
+
client, team_id, cutoff
|
|
187
|
+
)
|
|
188
|
+
events.extend(issue_events)
|
|
189
|
+
|
|
190
|
+
# 2. Fetch comments on each issue.
|
|
191
|
+
issue_ids_seen: set[str] = set()
|
|
192
|
+
for event in list(events):
|
|
193
|
+
iid = event.data.get("issue_id", "")
|
|
194
|
+
if iid and iid not in issue_ids_seen:
|
|
195
|
+
issue_ids_seen.add(iid)
|
|
196
|
+
comment_events = await self._fetch_comments(client, iid)
|
|
197
|
+
events.extend(comment_events)
|
|
198
|
+
|
|
199
|
+
# 3. Fetch projects.
|
|
200
|
+
project_events = await self._fetch_projects(client, cutoff)
|
|
201
|
+
events.extend(project_events)
|
|
202
|
+
|
|
203
|
+
return events
|
|
204
|
+
|
|
205
|
+
def normalize(self, raw: RawEvent) -> Episode:
|
|
206
|
+
"""Convert a Linear ``RawEvent`` into a CortexDB ``Episode``."""
|
|
207
|
+
data = raw.data
|
|
208
|
+
event_kind = data.get("_event_kind", "issue")
|
|
209
|
+
|
|
210
|
+
if event_kind == "comment":
|
|
211
|
+
return self._normalize_comment(raw)
|
|
212
|
+
if event_kind == "project":
|
|
213
|
+
return self._normalize_project(raw)
|
|
214
|
+
return self._normalize_issue(raw)
|
|
215
|
+
|
|
216
|
+
def map_permissions(self, raw: RawEvent) -> Visibility:
|
|
217
|
+
"""Map Linear access to CortexDB visibility.
|
|
218
|
+
|
|
219
|
+
Linear workspaces are private by default, so all episodes are
|
|
220
|
+
``Organization``. If the team is marked private, we use
|
|
221
|
+
``Restricted`` with team members as principals.
|
|
222
|
+
"""
|
|
223
|
+
perms = raw.permissions
|
|
224
|
+
if perms.get("private_team"):
|
|
225
|
+
members = tuple(perms.get("team_member_ids", []))
|
|
226
|
+
return Visibility(
|
|
227
|
+
level=VisibilityLevel.RESTRICTED,
|
|
228
|
+
allowed_principals=members,
|
|
229
|
+
)
|
|
230
|
+
return Visibility(level=VisibilityLevel.ORGANIZATION)
|
|
231
|
+
|
|
232
|
+
# -- issue normalization -------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _normalize_issue(self, raw: RawEvent) -> Episode:
|
|
235
|
+
"""Normalize a Linear issue into an Episode."""
|
|
236
|
+
data = raw.data
|
|
237
|
+
identifier = data.get("identifier", "")
|
|
238
|
+
title = data.get("title", "Untitled")
|
|
239
|
+
description = data.get("description", "") or ""
|
|
240
|
+
state_name = data.get("state_name", "Unknown")
|
|
241
|
+
priority_label = data.get("priority_label", "No priority")
|
|
242
|
+
|
|
243
|
+
content = f"[{identifier}] {title}"
|
|
244
|
+
if description:
|
|
245
|
+
content += f"\n\n{description[:1000]}"
|
|
246
|
+
|
|
247
|
+
actor = self._extract_actor(data, role="creator")
|
|
248
|
+
entities = self._extract_issue_entities(data)
|
|
249
|
+
|
|
250
|
+
tags = [
|
|
251
|
+
"linear",
|
|
252
|
+
state_name.lower().replace(" ", "_"),
|
|
253
|
+
priority_label.lower().replace(" ", "_"),
|
|
254
|
+
]
|
|
255
|
+
for label in data.get("labels", []):
|
|
256
|
+
tags.append(label.get("name", "").lower().replace(" ", "_"))
|
|
257
|
+
|
|
258
|
+
return Episode(
|
|
259
|
+
actor=actor,
|
|
260
|
+
source=_SOURCE,
|
|
261
|
+
episode_type=EpisodeType.ISSUE,
|
|
262
|
+
occurred_at=raw.timestamp,
|
|
263
|
+
content=content,
|
|
264
|
+
raw_payload=data,
|
|
265
|
+
tenant_id=self.tenant_id,
|
|
266
|
+
namespace=self.namespace,
|
|
267
|
+
entities=entities,
|
|
268
|
+
tags=tags,
|
|
269
|
+
metadata={
|
|
270
|
+
"identifier": identifier,
|
|
271
|
+
"state": state_name,
|
|
272
|
+
"state_type": data.get("state_type", ""),
|
|
273
|
+
"priority": data.get("priority", 0),
|
|
274
|
+
"priority_label": priority_label,
|
|
275
|
+
"estimate": data.get("estimate"),
|
|
276
|
+
"cycle_name": data.get("cycle_name"),
|
|
277
|
+
"cycle_number": data.get("cycle_number"),
|
|
278
|
+
"project_name": data.get("project_name"),
|
|
279
|
+
"completed_at": data.get("completed_at"),
|
|
280
|
+
"canceled_at": data.get("canceled_at"),
|
|
281
|
+
"url": data.get("url", ""),
|
|
282
|
+
},
|
|
283
|
+
thread_id=f"linear:{identifier}",
|
|
284
|
+
idempotency_key=(
|
|
285
|
+
f"linear:{raw.external_id}:{data.get('updated_at', '')}"
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def _normalize_comment(self, raw: RawEvent) -> Episode:
|
|
290
|
+
"""Normalize a Linear comment into an Episode."""
|
|
291
|
+
data = raw.data
|
|
292
|
+
actor = self._extract_actor(data, role="commenter")
|
|
293
|
+
identifier = data.get("issue_identifier", "")
|
|
294
|
+
|
|
295
|
+
return Episode(
|
|
296
|
+
actor=actor,
|
|
297
|
+
source=_SOURCE,
|
|
298
|
+
episode_type=EpisodeType.COMMENT,
|
|
299
|
+
occurred_at=raw.timestamp,
|
|
300
|
+
content=data.get("body", ""),
|
|
301
|
+
raw_payload=data,
|
|
302
|
+
tenant_id=self.tenant_id,
|
|
303
|
+
namespace=self.namespace,
|
|
304
|
+
entities=[
|
|
305
|
+
EntityRef(
|
|
306
|
+
entity_type="issue",
|
|
307
|
+
entity_id=data.get("issue_id", ""),
|
|
308
|
+
display_name=identifier,
|
|
309
|
+
),
|
|
310
|
+
],
|
|
311
|
+
tags=["linear", "comment"],
|
|
312
|
+
metadata={
|
|
313
|
+
"comment_id": data.get("comment_id", ""),
|
|
314
|
+
"issue_id": data.get("issue_id", ""),
|
|
315
|
+
"issue_identifier": identifier,
|
|
316
|
+
},
|
|
317
|
+
thread_id=f"linear:{identifier}",
|
|
318
|
+
parent_id=f"linear:{data.get('issue_id', '')}",
|
|
319
|
+
idempotency_key=f"linear:comment:{raw.external_id}",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def _normalize_project(self, raw: RawEvent) -> Episode:
|
|
323
|
+
"""Normalize a Linear project into an Episode."""
|
|
324
|
+
data = raw.data
|
|
325
|
+
name = data.get("name", "Untitled")
|
|
326
|
+
description = data.get("description", "") or ""
|
|
327
|
+
state = data.get("state", "planned")
|
|
328
|
+
|
|
329
|
+
content = f"[Project] {name}"
|
|
330
|
+
if description:
|
|
331
|
+
content += f"\n\n{description[:1000]}"
|
|
332
|
+
|
|
333
|
+
actor = self._extract_actor(data, role="lead")
|
|
334
|
+
entities: list[EntityRef] = [
|
|
335
|
+
EntityRef(
|
|
336
|
+
entity_type="project",
|
|
337
|
+
entity_id=data.get("project_id", ""),
|
|
338
|
+
display_name=name,
|
|
339
|
+
),
|
|
340
|
+
]
|
|
341
|
+
for team in data.get("teams", []):
|
|
342
|
+
entities.append(
|
|
343
|
+
EntityRef(
|
|
344
|
+
entity_type="team",
|
|
345
|
+
entity_id=team.get("id", ""),
|
|
346
|
+
display_name=team.get("name", ""),
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return Episode(
|
|
351
|
+
actor=actor,
|
|
352
|
+
source=_SOURCE,
|
|
353
|
+
episode_type=EpisodeType.DOCUMENT,
|
|
354
|
+
occurred_at=raw.timestamp,
|
|
355
|
+
content=content,
|
|
356
|
+
raw_payload=data,
|
|
357
|
+
tenant_id=self.tenant_id,
|
|
358
|
+
namespace=self.namespace,
|
|
359
|
+
entities=entities,
|
|
360
|
+
tags=["linear", "project", state.lower().replace(" ", "_")],
|
|
361
|
+
metadata={
|
|
362
|
+
"project_id": data.get("project_id", ""),
|
|
363
|
+
"state": state,
|
|
364
|
+
"start_date": data.get("start_date"),
|
|
365
|
+
"target_date": data.get("target_date"),
|
|
366
|
+
"member_count": len(data.get("members", [])),
|
|
367
|
+
"url": data.get("url", ""),
|
|
368
|
+
},
|
|
369
|
+
thread_id=f"linear:project:{data.get('project_id', '')}",
|
|
370
|
+
idempotency_key=(
|
|
371
|
+
f"linear:project:{raw.external_id}:{data.get('updated_at', '')}"
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# -- fetchers ------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
async def _graphql(
|
|
378
|
+
self, client: Any, query: str, variables: dict[str, Any]
|
|
379
|
+
) -> dict[str, Any]:
|
|
380
|
+
"""Execute a GraphQL query against the Linear API."""
|
|
381
|
+
resp = await client.post(
|
|
382
|
+
_GRAPHQL_URL,
|
|
383
|
+
json={"query": query, "variables": variables},
|
|
384
|
+
)
|
|
385
|
+
resp.raise_for_status()
|
|
386
|
+
body = resp.json()
|
|
387
|
+
|
|
388
|
+
if body.get("errors"):
|
|
389
|
+
error_msgs = [e.get("message", "") for e in body["errors"]]
|
|
390
|
+
logger.error("Linear GraphQL errors: %s", error_msgs)
|
|
391
|
+
|
|
392
|
+
await asyncio.sleep(_RATE_LIMIT_DELAY)
|
|
393
|
+
return body.get("data", {})
|
|
394
|
+
|
|
395
|
+
async def _discover_teams(self, client: Any) -> list[str]:
|
|
396
|
+
"""Discover all teams in the Linear workspace."""
|
|
397
|
+
data = await self._graphql(client, _TEAMS_QUERY, {})
|
|
398
|
+
teams = data.get("teams", {}).get("nodes", [])
|
|
399
|
+
team_ids = [t["id"] for t in teams if t.get("id")]
|
|
400
|
+
logger.info("Discovered %d Linear teams", len(team_ids))
|
|
401
|
+
return team_ids
|
|
402
|
+
|
|
403
|
+
async def _fetch_issues(
|
|
404
|
+
self, client: Any, team_id: str, cutoff: datetime
|
|
405
|
+
) -> list[RawEvent]:
|
|
406
|
+
"""Fetch issues for a team updated after *cutoff*."""
|
|
407
|
+
events: list[RawEvent] = []
|
|
408
|
+
has_next = True
|
|
409
|
+
after: str | None = None
|
|
410
|
+
|
|
411
|
+
while has_next:
|
|
412
|
+
variables: dict[str, Any] = {
|
|
413
|
+
"teamId": team_id,
|
|
414
|
+
"after": after,
|
|
415
|
+
"updatedAfter": cutoff.isoformat(),
|
|
416
|
+
}
|
|
417
|
+
data = await self._graphql(client, _ISSUES_QUERY, variables)
|
|
418
|
+
|
|
419
|
+
team_data = data.get("team", {})
|
|
420
|
+
issues_data = team_data.get("issues", {})
|
|
421
|
+
page_info = issues_data.get("pageInfo", {})
|
|
422
|
+
|
|
423
|
+
for node in issues_data.get("nodes", []):
|
|
424
|
+
raw = self._issue_node_to_raw(node)
|
|
425
|
+
events.append(raw)
|
|
426
|
+
|
|
427
|
+
has_next = page_info.get("hasNextPage", False)
|
|
428
|
+
after = page_info.get("endCursor")
|
|
429
|
+
|
|
430
|
+
return events
|
|
431
|
+
|
|
432
|
+
async def _fetch_comments(
|
|
433
|
+
self, client: Any, issue_id: str
|
|
434
|
+
) -> list[RawEvent]:
|
|
435
|
+
"""Fetch comments on a Linear issue."""
|
|
436
|
+
events: list[RawEvent] = []
|
|
437
|
+
has_next = True
|
|
438
|
+
after: str | None = None
|
|
439
|
+
|
|
440
|
+
while has_next:
|
|
441
|
+
variables: dict[str, Any] = {
|
|
442
|
+
"issueId": issue_id,
|
|
443
|
+
"after": after,
|
|
444
|
+
}
|
|
445
|
+
data = await self._graphql(client, _COMMENTS_QUERY, variables)
|
|
446
|
+
|
|
447
|
+
issue_data = data.get("issue", {})
|
|
448
|
+
comments_data = issue_data.get("comments", {})
|
|
449
|
+
page_info = comments_data.get("pageInfo", {})
|
|
450
|
+
|
|
451
|
+
for node in comments_data.get("nodes", []):
|
|
452
|
+
created_str = node.get("createdAt", "")
|
|
453
|
+
try:
|
|
454
|
+
created_at = datetime.fromisoformat(
|
|
455
|
+
created_str.replace("Z", "+00:00")
|
|
456
|
+
)
|
|
457
|
+
except (ValueError, TypeError):
|
|
458
|
+
created_at = datetime.now(timezone.utc)
|
|
459
|
+
|
|
460
|
+
user = node.get("user", {}) or {}
|
|
461
|
+
|
|
462
|
+
events.append(
|
|
463
|
+
RawEvent(
|
|
464
|
+
source="linear",
|
|
465
|
+
external_id=node.get("id", ""),
|
|
466
|
+
timestamp=created_at,
|
|
467
|
+
data={
|
|
468
|
+
"_event_kind": "comment",
|
|
469
|
+
"comment_id": node.get("id", ""),
|
|
470
|
+
"issue_id": issue_id,
|
|
471
|
+
"issue_identifier": "",
|
|
472
|
+
"body": node.get("body", ""),
|
|
473
|
+
"commenter_id": user.get("id", ""),
|
|
474
|
+
"commenter_name": user.get("name", ""),
|
|
475
|
+
"commenter_email": user.get("email"),
|
|
476
|
+
"commenter_avatar": user.get("avatarUrl"),
|
|
477
|
+
"created_at": created_str,
|
|
478
|
+
"updated_at": node.get("updatedAt", ""),
|
|
479
|
+
},
|
|
480
|
+
permissions={},
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
has_next = page_info.get("hasNextPage", False)
|
|
485
|
+
after = page_info.get("endCursor")
|
|
486
|
+
|
|
487
|
+
return events
|
|
488
|
+
|
|
489
|
+
async def _fetch_projects(
|
|
490
|
+
self, client: Any, cutoff: datetime
|
|
491
|
+
) -> list[RawEvent]:
|
|
492
|
+
"""Fetch projects updated after *cutoff*."""
|
|
493
|
+
events: list[RawEvent] = []
|
|
494
|
+
has_next = True
|
|
495
|
+
after: str | None = None
|
|
496
|
+
|
|
497
|
+
while has_next:
|
|
498
|
+
variables: dict[str, Any] = {
|
|
499
|
+
"after": after,
|
|
500
|
+
"updatedAfter": cutoff.isoformat(),
|
|
501
|
+
}
|
|
502
|
+
data = await self._graphql(client, _PROJECTS_QUERY, variables)
|
|
503
|
+
|
|
504
|
+
projects_data = data.get("projects", {})
|
|
505
|
+
page_info = projects_data.get("pageInfo", {})
|
|
506
|
+
|
|
507
|
+
for node in projects_data.get("nodes", []):
|
|
508
|
+
updated_str = node.get("updatedAt", "")
|
|
509
|
+
try:
|
|
510
|
+
updated_at = datetime.fromisoformat(
|
|
511
|
+
updated_str.replace("Z", "+00:00")
|
|
512
|
+
)
|
|
513
|
+
except (ValueError, TypeError):
|
|
514
|
+
updated_at = datetime.now(timezone.utc)
|
|
515
|
+
|
|
516
|
+
lead = node.get("lead") or {}
|
|
517
|
+
teams = [
|
|
518
|
+
{"id": t.get("id", ""), "name": t.get("name", ""),
|
|
519
|
+
"key": t.get("key", "")}
|
|
520
|
+
for t in node.get("teams", {}).get("nodes", [])
|
|
521
|
+
]
|
|
522
|
+
members = [
|
|
523
|
+
{"id": m.get("id", ""), "name": m.get("name", ""),
|
|
524
|
+
"email": m.get("email")}
|
|
525
|
+
for m in node.get("members", {}).get("nodes", [])
|
|
526
|
+
]
|
|
527
|
+
|
|
528
|
+
events.append(
|
|
529
|
+
RawEvent(
|
|
530
|
+
source="linear",
|
|
531
|
+
external_id=node.get("id", ""),
|
|
532
|
+
timestamp=updated_at,
|
|
533
|
+
data={
|
|
534
|
+
"_event_kind": "project",
|
|
535
|
+
"project_id": node.get("id", ""),
|
|
536
|
+
"name": node.get("name", ""),
|
|
537
|
+
"description": node.get("description", ""),
|
|
538
|
+
"state": node.get("state", "planned"),
|
|
539
|
+
"lead_id": lead.get("id", ""),
|
|
540
|
+
"lead_name": lead.get("name", ""),
|
|
541
|
+
"lead_email": lead.get("email"),
|
|
542
|
+
"lead_avatar": lead.get("avatarUrl"),
|
|
543
|
+
"teams": teams,
|
|
544
|
+
"members": members,
|
|
545
|
+
"start_date": node.get("startDate"),
|
|
546
|
+
"target_date": node.get("targetDate"),
|
|
547
|
+
"created_at": node.get("createdAt", ""),
|
|
548
|
+
"updated_at": updated_str,
|
|
549
|
+
"url": node.get("url", ""),
|
|
550
|
+
},
|
|
551
|
+
permissions={},
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
has_next = page_info.get("hasNextPage", False)
|
|
556
|
+
after = page_info.get("endCursor")
|
|
557
|
+
|
|
558
|
+
return events
|
|
559
|
+
|
|
560
|
+
# -- helpers -------------------------------------------------------------
|
|
561
|
+
|
|
562
|
+
def _issue_node_to_raw(self, node: dict[str, Any]) -> RawEvent:
|
|
563
|
+
"""Convert a Linear issue GraphQL node into a RawEvent."""
|
|
564
|
+
updated_str = node.get("updatedAt", "")
|
|
565
|
+
try:
|
|
566
|
+
updated_at = datetime.fromisoformat(
|
|
567
|
+
updated_str.replace("Z", "+00:00")
|
|
568
|
+
)
|
|
569
|
+
except (ValueError, TypeError):
|
|
570
|
+
updated_at = datetime.now(timezone.utc)
|
|
571
|
+
|
|
572
|
+
state = node.get("state", {}) or {}
|
|
573
|
+
assignee = node.get("assignee", {}) or {}
|
|
574
|
+
creator = node.get("creator", {}) or {}
|
|
575
|
+
team = node.get("team", {}) or {}
|
|
576
|
+
project = node.get("project", {}) or {}
|
|
577
|
+
cycle = node.get("cycle", {}) or {}
|
|
578
|
+
labels = [
|
|
579
|
+
{"id": l.get("id", ""), "name": l.get("name", "")}
|
|
580
|
+
for l in node.get("labels", {}).get("nodes", [])
|
|
581
|
+
]
|
|
582
|
+
|
|
583
|
+
return RawEvent(
|
|
584
|
+
source="linear",
|
|
585
|
+
external_id=node.get("id", ""),
|
|
586
|
+
timestamp=updated_at,
|
|
587
|
+
data={
|
|
588
|
+
"_event_kind": "issue",
|
|
589
|
+
"issue_id": node.get("id", ""),
|
|
590
|
+
"identifier": node.get("identifier", ""),
|
|
591
|
+
"title": node.get("title", ""),
|
|
592
|
+
"description": node.get("description", ""),
|
|
593
|
+
"priority": node.get("priority", 0),
|
|
594
|
+
"priority_label": node.get("priorityLabel", "No priority"),
|
|
595
|
+
"state_name": state.get("name", "Unknown"),
|
|
596
|
+
"state_type": state.get("type", ""),
|
|
597
|
+
"assignee_id": assignee.get("id", ""),
|
|
598
|
+
"assignee_name": assignee.get("name", ""),
|
|
599
|
+
"assignee_email": assignee.get("email"),
|
|
600
|
+
"assignee_avatar": assignee.get("avatarUrl"),
|
|
601
|
+
"creator_id": creator.get("id", ""),
|
|
602
|
+
"creator_name": creator.get("name", ""),
|
|
603
|
+
"creator_email": creator.get("email"),
|
|
604
|
+
"creator_avatar": creator.get("avatarUrl"),
|
|
605
|
+
"team_id": team.get("id", ""),
|
|
606
|
+
"team_name": team.get("name", ""),
|
|
607
|
+
"team_key": team.get("key", ""),
|
|
608
|
+
"project_id": project.get("id", ""),
|
|
609
|
+
"project_name": project.get("name", ""),
|
|
610
|
+
"cycle_name": cycle.get("name"),
|
|
611
|
+
"cycle_number": cycle.get("number"),
|
|
612
|
+
"labels": labels,
|
|
613
|
+
"estimate": node.get("estimate"),
|
|
614
|
+
"created_at": node.get("createdAt", ""),
|
|
615
|
+
"updated_at": updated_str,
|
|
616
|
+
"completed_at": node.get("completedAt"),
|
|
617
|
+
"canceled_at": node.get("canceledAt"),
|
|
618
|
+
"url": node.get("url", ""),
|
|
619
|
+
},
|
|
620
|
+
permissions={
|
|
621
|
+
"team_id": team.get("id", ""),
|
|
622
|
+
},
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
@staticmethod
|
|
626
|
+
def _extract_actor(data: dict[str, Any], role: str = "creator") -> Actor:
|
|
627
|
+
"""Extract the actor for the given role from event data."""
|
|
628
|
+
if role == "commenter":
|
|
629
|
+
return Actor(
|
|
630
|
+
id=data.get("commenter_id", "unknown"),
|
|
631
|
+
name=data.get("commenter_name", "Unknown"),
|
|
632
|
+
email=data.get("commenter_email"),
|
|
633
|
+
avatar_url=data.get("commenter_avatar"),
|
|
634
|
+
)
|
|
635
|
+
if role == "lead":
|
|
636
|
+
return Actor(
|
|
637
|
+
id=data.get("lead_id", "unknown"),
|
|
638
|
+
name=data.get("lead_name", "Unknown"),
|
|
639
|
+
email=data.get("lead_email"),
|
|
640
|
+
avatar_url=data.get("lead_avatar"),
|
|
641
|
+
)
|
|
642
|
+
# Default: creator for issues.
|
|
643
|
+
return Actor(
|
|
644
|
+
id=data.get("creator_id", "unknown"),
|
|
645
|
+
name=data.get("creator_name", "Unknown"),
|
|
646
|
+
email=data.get("creator_email"),
|
|
647
|
+
avatar_url=data.get("creator_avatar"),
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
@staticmethod
|
|
651
|
+
def _extract_issue_entities(data: dict[str, Any]) -> list[EntityRef]:
|
|
652
|
+
"""Extract entity references from issue data."""
|
|
653
|
+
entities: list[EntityRef] = []
|
|
654
|
+
|
|
655
|
+
team_id = data.get("team_id", "")
|
|
656
|
+
if team_id:
|
|
657
|
+
entities.append(
|
|
658
|
+
EntityRef(
|
|
659
|
+
entity_type="team",
|
|
660
|
+
entity_id=team_id,
|
|
661
|
+
display_name=data.get("team_name", ""),
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
project_id = data.get("project_id", "")
|
|
666
|
+
if project_id:
|
|
667
|
+
entities.append(
|
|
668
|
+
EntityRef(
|
|
669
|
+
entity_type="project",
|
|
670
|
+
entity_id=project_id,
|
|
671
|
+
display_name=data.get("project_name", ""),
|
|
672
|
+
)
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
assignee_id = data.get("assignee_id", "")
|
|
676
|
+
if assignee_id:
|
|
677
|
+
entities.append(
|
|
678
|
+
EntityRef(
|
|
679
|
+
entity_type="user",
|
|
680
|
+
entity_id=assignee_id,
|
|
681
|
+
display_name=data.get("assignee_name", ""),
|
|
682
|
+
)
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
creator_id = data.get("creator_id", "")
|
|
686
|
+
if creator_id and creator_id != assignee_id:
|
|
687
|
+
entities.append(
|
|
688
|
+
EntityRef(
|
|
689
|
+
entity_type="user",
|
|
690
|
+
entity_id=creator_id,
|
|
691
|
+
display_name=data.get("creator_name", ""),
|
|
692
|
+
)
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
for label in data.get("labels", []):
|
|
696
|
+
if label.get("id"):
|
|
697
|
+
entities.append(
|
|
698
|
+
EntityRef(
|
|
699
|
+
entity_type="label",
|
|
700
|
+
entity_id=label["id"],
|
|
701
|
+
display_name=label.get("name", ""),
|
|
702
|
+
)
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
return entities
|