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,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