orgdatacore 2.0.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,1458 @@
1
+ """Service implementation for orgdatacore."""
2
+
3
+ import json
4
+ import threading
5
+ from datetime import datetime, timedelta
6
+ from typing import Any, NamedTuple
7
+
8
+ from ._exceptions import DataLoadError
9
+ from ._log import get_logger
10
+ from ._types import (
11
+ Component,
12
+ ComponentOwnerInfo,
13
+ ComponentOwnership,
14
+ ContextItemInfo,
15
+ Data,
16
+ DataSource,
17
+ DataVersion,
18
+ Employee,
19
+ EntityType,
20
+ EscalationContactInfo,
21
+ Group,
22
+ HierarchyEntity,
23
+ HierarchyNode,
24
+ HierarchyPathEntry,
25
+ JiraOwnerInfo,
26
+ MembershipInfo,
27
+ MembershipType,
28
+ Org,
29
+ OrgInfo,
30
+ OrgInfoType,
31
+ Pillar,
32
+ Team,
33
+ TeamGroup,
34
+ )
35
+
36
+
37
+ def _normalize_slack_channel(channel: str) -> str:
38
+ return channel.strip().lstrip("#").lower()
39
+
40
+
41
+ class _EntityRef(NamedTuple):
42
+ """Identity-keyed view of a hierarchy entity for relationship traversal.
43
+
44
+ Names are not unique across types, so parent and child edges are followed by
45
+ stable ID rather than by name.
46
+ """
47
+
48
+ name: str
49
+ type: EntityType
50
+ parent_id: str
51
+ stable_id: str
52
+
53
+
54
+ class _NameTypeKey(NamedTuple):
55
+ """Resolves a (name, type) pair to a stable ID."""
56
+
57
+ name: str
58
+ type: EntityType
59
+
60
+
61
+ def _to_entity_type(value: str) -> EntityType | None:
62
+ """Convert a raw type string to EntityType, or None if it is not a known type."""
63
+ try:
64
+ return EntityType(value)
65
+ except ValueError:
66
+ return None
67
+
68
+
69
+ def _ancestor_org_info_type(entity_type: EntityType) -> OrgInfoType:
70
+ """Map a hierarchy-ancestor entity type to its OrgInfoType.
71
+
72
+ A team seen as an ancestor is reported as a parent team.
73
+ """
74
+ match entity_type:
75
+ case EntityType.ORG:
76
+ return OrgInfoType.ORGANIZATION
77
+ case EntityType.PILLAR:
78
+ return OrgInfoType.PILLAR
79
+ case EntityType.TEAM_GROUP:
80
+ return OrgInfoType.TEAM_GROUP
81
+ case EntityType.TEAM:
82
+ return OrgInfoType.PARENT_TEAM
83
+ case _:
84
+ return OrgInfoType.ORGANIZATION
85
+
86
+
87
+ # String fields on an employee record whose JSON null is coerced to "" to match
88
+ # Go's json.Unmarshal zero-value semantics: Go leaves a null string field as ""
89
+ # and loads successfully, so a null uid/full_name/etc. must not raise and abort
90
+ # the entire load on the Python side.
91
+ _EMP_NULL_COERCED = (
92
+ "uid",
93
+ "full_name",
94
+ "email",
95
+ "job_title",
96
+ "github_id",
97
+ "manager_uid",
98
+ "slack_uid",
99
+ "timezone",
100
+ )
101
+
102
+ # An absent group on a hierarchy entity is modelled as None by the canonical
103
+ # schema; queries read through this empty group so they never dereference None.
104
+ _EMPTY_GROUP = Group()
105
+
106
+
107
+ def _group(entity: HierarchyEntity) -> Group:
108
+ """Return the entity's group, or an empty group when the entity has none."""
109
+ return entity.group if entity.group is not None else _EMPTY_GROUP
110
+
111
+
112
+ def _coerce_employee_nulls(raw: Any) -> Any:
113
+ """Coerce an employee record's null fields to Go zero values before validation:
114
+ null strings to "", and a null ``possible_github_ids`` list to [] (Go marshals a
115
+ nil []string as JSON null, which the non-nullable canonical field would reject)."""
116
+ if not isinstance(raw, dict):
117
+ return raw
118
+ coerced = {**raw, **{k: (raw.get(k) or "") for k in _EMP_NULL_COERCED}}
119
+ if "possible_github_ids" in raw and raw["possible_github_ids"] is None:
120
+ coerced["possible_github_ids"] = []
121
+ return coerced
122
+
123
+
124
+ def _parse_employee(raw: dict[str, Any]) -> Employee:
125
+ """Validate an employee record, coercing null string fields to "" to match Go
126
+ zero-value semantics."""
127
+ return Employee.model_validate(_coerce_employee_nulls(raw))
128
+
129
+
130
+ def parse_data(raw_data: dict[str, Any]) -> Data:
131
+ """Parse the complete Data structure from JSON against the canonical models.
132
+
133
+ Employee null string fields are coerced to "" before validation (Go
134
+ zero-value parity); everything else validates directly against the canonical
135
+ on-disk shape.
136
+ """
137
+ raw: dict[str, Any] = dict(raw_data)
138
+ lookups = raw.get("lookups")
139
+ if isinstance(lookups, dict):
140
+ employees = lookups.get("employees")
141
+ if isinstance(employees, dict):
142
+ raw["lookups"] = {
143
+ **lookups,
144
+ "employees": {
145
+ k: _coerce_employee_nulls(v) for k, v in employees.items()
146
+ },
147
+ }
148
+ return Data.model_validate(raw)
149
+
150
+
151
+ def _validate_data(data: Data, source: DataSource) -> None:
152
+ """Validate that required data structures are present."""
153
+ if data.metadata.pii_free:
154
+ if data.lookups.employees:
155
+ raise DataLoadError(
156
+ f"invalid data from {source}: pii_free is set but lookups.employees is not empty"
157
+ )
158
+ if data.indexes.membership.membership_index:
159
+ raise DataLoadError(
160
+ f"invalid data from {source}: pii_free is set but membership_index is not empty"
161
+ )
162
+ return
163
+ if not data.lookups.employees:
164
+ raise DataLoadError(f"invalid data from {source}: missing lookups.employees")
165
+ if not data.indexes.membership.membership_index:
166
+ raise DataLoadError(
167
+ f"invalid data from {source}: missing indexes.membership.membership_index"
168
+ )
169
+
170
+
171
+ class Service:
172
+ """
173
+ Service implements the core organizational data service.
174
+
175
+ The service can be initialized in two ways:
176
+
177
+ 1. Lazy loading (matches Go API):
178
+ service = Service()
179
+ service.load_from_data_source(gcs_source)
180
+
181
+ 2. Constructor injection:
182
+ service = Service(data_source=gcs_source)
183
+
184
+ Both approaches are equivalent. Use constructor injection for simpler code,
185
+ or lazy loading if you need to defer data loading.
186
+ """
187
+
188
+ def __init__(self, *, data_source: DataSource | None = None) -> None:
189
+ """
190
+ Create a new organizational data service.
191
+
192
+ Args:
193
+ data_source: Optional data source to load immediately.
194
+ If provided, data is loaded during construction.
195
+ Must be passed as keyword argument.
196
+ """
197
+ self._lock = threading.RLock()
198
+ self._data: Data | None = None
199
+ self._version = DataVersion()
200
+ self._watcher_running = False
201
+ self._stop_event = threading.Event()
202
+ self._slack_channel_index: dict[str, list[str]] = {}
203
+
204
+ # Derived identity indexes, rebuilt on every load. Populated only when
205
+ # the index carries stable IDs (_use_stable_ids); otherwise traversal
206
+ # falls back to the legacy name+type behavior.
207
+ self._entity_by_id: dict[str, _EntityRef] = {}
208
+ self._children_by_id: dict[str, list[str]] = {}
209
+ self._id_by_name_type: dict[_NameTypeKey, str] = {}
210
+ self._use_stable_ids: bool = False
211
+
212
+ if data_source is not None:
213
+ self.load_from_data_source(data_source)
214
+
215
+ def load_from_data_source(self, source: DataSource) -> None:
216
+ """Load organizational data from a data source.
217
+
218
+ Args:
219
+ source: Data source to load from.
220
+
221
+ Raises:
222
+ DataLoadError: If loading or parsing fails.
223
+ """
224
+ logger = get_logger()
225
+ logger.debug("Loading data from source", extra={"source": str(source)})
226
+
227
+ try:
228
+ reader = source.load()
229
+ except Exception as e:
230
+ logger.error(
231
+ "Failed to load from data source",
232
+ extra={"source": str(source), "error": str(e)},
233
+ )
234
+ raise DataLoadError(f"failed to load from data source {source}: {e}") from e
235
+
236
+ try:
237
+ raw_data = json.load(reader)
238
+ except json.JSONDecodeError as e:
239
+ logger.error(
240
+ "Failed to parse JSON", extra={"source": str(source), "error": str(e)}
241
+ )
242
+ raise DataLoadError(
243
+ f"failed to parse JSON from source {source}: {e}"
244
+ ) from e
245
+ finally:
246
+ reader.close()
247
+
248
+ try:
249
+ org_data = parse_data(raw_data)
250
+ except Exception as e:
251
+ logger.error(
252
+ "Failed to parse data structure",
253
+ extra={"source": str(source), "error": str(e)},
254
+ )
255
+ raise DataLoadError(
256
+ f"failed to parse data structure from source {source}: {e}"
257
+ ) from e
258
+
259
+ _validate_data(org_data, source)
260
+
261
+ with self._lock:
262
+ self._data = org_data
263
+ self._version = DataVersion(
264
+ load_time=datetime.now(),
265
+ org_count=len(org_data.lookups.orgs),
266
+ employee_count=len(org_data.lookups.employees),
267
+ )
268
+
269
+ self._slack_channel_index = {}
270
+ for team in org_data.lookups.teams.values():
271
+ slack = _group(team).slack
272
+ if slack is None:
273
+ continue
274
+ for ch in slack.channels:
275
+ if ch.channel:
276
+ normalized = _normalize_slack_channel(ch.channel)
277
+ self._slack_channel_index.setdefault(normalized, []).append(
278
+ team.name
279
+ )
280
+
281
+ self._build_derived_indexes()
282
+
283
+ logger.info(
284
+ "Data loaded successfully",
285
+ extra={
286
+ "source": str(source),
287
+ "employee_count": self._version.employee_count,
288
+ "org_count": self._version.org_count,
289
+ },
290
+ )
291
+
292
+ def _build_derived_indexes(self) -> None:
293
+ """Build stable-ID relationship indexes from the loaded data.
294
+
295
+ Caller must hold the lock. Enables ``_use_stable_ids`` only when the
296
+ index is a sound identity graph: every entity carries a unique
297
+ ``stable_id``, every parent edge carries a ``parent_id``, and every
298
+ ``parent_id`` resolves to a known entity. Any defect (or an old-format
299
+ index) falls back to the legacy name+type traversal rather than silently
300
+ returning wrong ancestry.
301
+ """
302
+ self._entity_by_id = {}
303
+ self._children_by_id = {}
304
+ self._id_by_name_type = {}
305
+ self._use_stable_ids = False
306
+ if self._data is None:
307
+ return
308
+
309
+ any_stable_id = True
310
+ all_edges_identified = True
311
+ duplicate_stable_id = False
312
+
313
+ def register(
314
+ name: str,
315
+ typ: EntityType,
316
+ stable_id: str,
317
+ parent_id: str,
318
+ has_parent: bool,
319
+ ) -> None:
320
+ nonlocal any_stable_id, all_edges_identified, duplicate_stable_id
321
+ if not stable_id:
322
+ any_stable_id = False
323
+ return
324
+ if stable_id in self._entity_by_id:
325
+ duplicate_stable_id = True
326
+ self._entity_by_id[stable_id] = _EntityRef(
327
+ name=name, type=typ, parent_id=parent_id, stable_id=stable_id
328
+ )
329
+ self._id_by_name_type[_NameTypeKey(name, typ)] = stable_id
330
+ if has_parent and not parent_id:
331
+ all_edges_identified = False
332
+
333
+ for name, team in self._data.lookups.teams.items():
334
+ register(
335
+ name,
336
+ EntityType.TEAM,
337
+ team.stable_id,
338
+ team.parent_id,
339
+ team.parent is not None,
340
+ )
341
+ for name, org in self._data.lookups.orgs.items():
342
+ register(
343
+ name,
344
+ EntityType.ORG,
345
+ org.stable_id,
346
+ org.parent_id,
347
+ org.parent is not None,
348
+ )
349
+ for name, pillar in self._data.lookups.pillars.items():
350
+ register(
351
+ name,
352
+ EntityType.PILLAR,
353
+ pillar.stable_id,
354
+ pillar.parent_id,
355
+ pillar.parent is not None,
356
+ )
357
+ for name, tg in self._data.lookups.team_groups.items():
358
+ register(
359
+ name,
360
+ EntityType.TEAM_GROUP,
361
+ tg.stable_id,
362
+ tg.parent_id,
363
+ tg.parent is not None,
364
+ )
365
+
366
+ # Every parent_id must resolve to a known entity, otherwise traversal
367
+ # would silently truncate ancestry or drop a descendants branch.
368
+ all_parents_resolve = all(
369
+ ref.parent_id in self._entity_by_id
370
+ for ref in self._entity_by_id.values()
371
+ if ref.parent_id
372
+ )
373
+
374
+ if (
375
+ not self._entity_by_id
376
+ or not any_stable_id
377
+ or not all_edges_identified
378
+ or duplicate_stable_id
379
+ or not all_parents_resolve
380
+ ):
381
+ self._entity_by_id = {}
382
+ self._id_by_name_type = {}
383
+ return
384
+
385
+ for stable_id, ref in self._entity_by_id.items():
386
+ if ref.parent_id:
387
+ self._children_by_id.setdefault(ref.parent_id, []).append(stable_id)
388
+
389
+ self._use_stable_ids = True
390
+
391
+ def _resolve_start_id(self, entity_name: str, entity_type: str) -> str:
392
+ """Map a (name, type) pair to a stable ID. Caller must hold the lock.
393
+
394
+ When ``entity_type`` is empty the type is inferred by name (first match,
395
+ matching the legacy path).
396
+ """
397
+ if not entity_type:
398
+ inferred = self._get_entity_type(entity_name)
399
+ if not inferred:
400
+ return ""
401
+ entity_type = inferred
402
+ etype = _to_entity_type(entity_type.lower())
403
+ if etype is None:
404
+ return ""
405
+ return self._id_by_name_type.get(_NameTypeKey(entity_name, etype), "")
406
+
407
+ def start_data_source_watcher(self, source: DataSource) -> None:
408
+ """Start watching a data source for changes.
409
+
410
+ Performs initial load, then starts background watcher for hot reload.
411
+
412
+ Args:
413
+ source: Data source to watch.
414
+
415
+ Raises:
416
+ DataLoadError: If initial load fails.
417
+ RuntimeError: If watcher is already running.
418
+ """
419
+ logger = get_logger()
420
+
421
+ if self._watcher_running:
422
+ raise RuntimeError("Watcher is already running")
423
+
424
+ self._watcher_running = True
425
+ self._stop_event.clear()
426
+
427
+ try:
428
+ self.load_from_data_source(source)
429
+
430
+ def callback() -> Exception | None:
431
+ if self._stop_event.is_set():
432
+ return None
433
+ try:
434
+ logger.info(
435
+ "Reloading data from source", extra={"source": str(source)}
436
+ )
437
+ self.load_from_data_source(source)
438
+ return None
439
+ except Exception as e:
440
+ logger.error(
441
+ "Failed to reload data",
442
+ extra={"source": str(source), "error": str(e)},
443
+ )
444
+ return e
445
+
446
+ logger.info("Starting data source watcher", extra={"source": str(source)})
447
+ err = source.watch(callback)
448
+ if err:
449
+ logger.error(
450
+ "Failed to start watcher",
451
+ extra={"source": str(source), "error": str(err)},
452
+ )
453
+ raise err
454
+ finally:
455
+ self._watcher_running = False
456
+
457
+ def stop_watcher(self) -> None:
458
+ """Stop the data source watcher if running."""
459
+ self._stop_event.set()
460
+ self._watcher_running = False
461
+
462
+ def is_healthy(self) -> bool:
463
+ """Check if the service is healthy and has data loaded."""
464
+ with self._lock:
465
+ return self._data is not None
466
+
467
+ def is_ready(self) -> bool:
468
+ """Check if the service is ready to serve requests."""
469
+ with self._lock:
470
+ if self._data is None:
471
+ return False
472
+ return bool(self._data.lookups.employees) or self._data.metadata.pii_free
473
+
474
+ def get_version(self) -> DataVersion:
475
+ """Get the current data version."""
476
+ with self._lock:
477
+ return self._version
478
+
479
+ def get_data_age(self) -> timedelta:
480
+ """Get the duration since data was last loaded.
481
+
482
+ Returns:
483
+ timedelta since last load, or timedelta(0) if no data loaded.
484
+ """
485
+ with self._lock:
486
+ if self._version.load_time == datetime.min:
487
+ return timedelta(0)
488
+ return datetime.now() - self._version.load_time
489
+
490
+ def is_data_stale(self, max_age: timedelta) -> bool:
491
+ """Check if data is older than max_age, or if no data is loaded.
492
+
493
+ Use this in health checks to detect stale data from failed reloads.
494
+
495
+ Args:
496
+ max_age: Maximum acceptable age for the data.
497
+
498
+ Returns:
499
+ True if data is stale or not loaded, False otherwise.
500
+ """
501
+ with self._lock:
502
+ if self._data is None or self._version.load_time == datetime.min:
503
+ return True
504
+ return (datetime.now() - self._version.load_time) > max_age
505
+
506
+ def get_employee_by_uid(self, uid: str) -> Employee | None:
507
+ """Get an employee by UID."""
508
+ with self._lock:
509
+ if self._data is None or not self._data.lookups.employees:
510
+ return None
511
+ return self._data.lookups.employees.get(uid)
512
+
513
+ def get_employee_by_email(self, email: str) -> Employee | None:
514
+ """Get an employee by their email address."""
515
+ with self._lock:
516
+ if self._data is None or not self._data.lookups.employees:
517
+ return None
518
+ email_lower = email.lower()
519
+ for emp in self._data.lookups.employees.values():
520
+ if (emp.email or "").lower() == email_lower:
521
+ return emp
522
+ return None
523
+
524
+ def get_employee_by_slack_id(self, slack_id: str) -> Employee | None:
525
+ """Get an employee by Slack ID."""
526
+ with self._lock:
527
+ if (
528
+ self._data is None
529
+ or not self._data.indexes.slack_id_mappings.slack_uid_to_uid
530
+ or not self._data.lookups.employees
531
+ ):
532
+ return None
533
+
534
+ uid = self._data.indexes.slack_id_mappings.slack_uid_to_uid.get(
535
+ slack_id, ""
536
+ )
537
+ if not uid:
538
+ return None
539
+
540
+ return self._data.lookups.employees.get(uid)
541
+
542
+ def get_employee_by_github_id(self, github_id: str) -> Employee | None:
543
+ """Get an employee by GitHub ID."""
544
+ with self._lock:
545
+ if (
546
+ self._data is None
547
+ or not self._data.indexes.github_id_mappings.github_id_to_uid
548
+ or not self._data.lookups.employees
549
+ ):
550
+ return None
551
+
552
+ uid = self._data.indexes.github_id_mappings.github_id_to_uid.get(
553
+ github_id, ""
554
+ )
555
+ if not uid:
556
+ return None
557
+
558
+ return self._data.lookups.employees.get(uid)
559
+
560
+ def get_manager_for_employee(self, uid: str) -> Employee | None:
561
+ """Get the manager for a given employee UID."""
562
+ with self._lock:
563
+ if self._data is None or not self._data.lookups.employees:
564
+ return None
565
+
566
+ emp = self._data.lookups.employees.get(uid)
567
+ if not emp or not emp.manager_uid:
568
+ return None
569
+
570
+ return self._data.lookups.employees.get(emp.manager_uid)
571
+
572
+ def get_team_by_name(self, team_name: str) -> Team | None:
573
+ """Get a team by name."""
574
+ with self._lock:
575
+ if self._data is None or not self._data.lookups.teams:
576
+ return None
577
+ return self._data.lookups.teams.get(team_name)
578
+
579
+ def get_teams_by_slack_channel(self, channel: str) -> list[Team]:
580
+ """Get teams associated with a Slack channel name.
581
+
582
+ Args:
583
+ channel: Slack channel name (e.g., "#test-team" or "test-team").
584
+ The "#" prefix and casing are ignored.
585
+
586
+ Returns:
587
+ List of matching teams, or empty list if none found.
588
+ """
589
+ with self._lock:
590
+ if self._data is None or not channel:
591
+ return []
592
+
593
+ team_names = self._slack_channel_index.get(
594
+ _normalize_slack_channel(channel), []
595
+ )
596
+ return [
597
+ self._data.lookups.teams[name]
598
+ for name in team_names
599
+ if name in self._data.lookups.teams
600
+ ]
601
+
602
+ def get_team_escalation(self, team_name: str) -> list[EscalationContactInfo]:
603
+ """Get the escalation contacts for a team.
604
+
605
+ Args:
606
+ team_name: The team name to look up.
607
+
608
+ Returns:
609
+ Ordered list of escalation contacts, or empty list if team
610
+ not found or has no escalation data.
611
+ """
612
+ with self._lock:
613
+ if self._data is None or not self._data.lookups.teams:
614
+ return []
615
+ team = self._data.lookups.teams.get(team_name)
616
+ if team is None:
617
+ return []
618
+ return list(_group(team).escalation)
619
+
620
+ def get_org_by_name(self, org_name: str) -> Org | None:
621
+ """Get an organization by name."""
622
+ with self._lock:
623
+ if self._data is None or not self._data.lookups.orgs:
624
+ return None
625
+ return self._data.lookups.orgs.get(org_name)
626
+
627
+ def get_pillar_by_name(self, pillar_name: str) -> Pillar | None:
628
+ """Get a pillar by name."""
629
+ with self._lock:
630
+ if self._data is None or not self._data.lookups.pillars:
631
+ return None
632
+ return self._data.lookups.pillars.get(pillar_name)
633
+
634
+ def get_team_group_by_name(self, team_group_name: str) -> TeamGroup | None:
635
+ """Get a team group by name."""
636
+ with self._lock:
637
+ if self._data is None or not self._data.lookups.team_groups:
638
+ return None
639
+ return self._data.lookups.team_groups.get(team_group_name)
640
+
641
+ def get_component_by_name(self, component_name: str) -> Component | None:
642
+ """Get a component by name."""
643
+ with self._lock:
644
+ if self._data is None or not self._data.lookups.components:
645
+ return None
646
+ return self._data.lookups.components.get(component_name)
647
+
648
+ def get_all_components(self) -> list[Component]:
649
+ """Get all components in the system."""
650
+ with self._lock:
651
+ if self._data is None or not self._data.lookups.components:
652
+ return []
653
+ return list(self._data.lookups.components.values())
654
+
655
+ def get_all_component_names(self) -> list[str]:
656
+ """Get all component names in the system."""
657
+ with self._lock:
658
+ if self._data is None or not self._data.lookups.components:
659
+ return []
660
+ return list(self._data.lookups.components.keys())
661
+
662
+ def get_teams_for_component(self, component_name: str) -> list[ComponentOwnerInfo]:
663
+ """Get all teams/entities that own a component.
664
+
665
+ Args:
666
+ component_name: Component name to look up
667
+
668
+ Returns:
669
+ List of owner entities with ownership types.
670
+ """
671
+ with self._lock:
672
+ if self._data is None:
673
+ return []
674
+ owners = self._data.indexes.component_ownership.get(component_name, [])
675
+ return list(owners)
676
+
677
+ def get_components_for_team(self, team_name: str) -> list[ComponentOwnership]:
678
+ """Get all components owned by a team.
679
+
680
+ Uses the team's component_roles list for O(1) team lookup, then
681
+ resolves ownership types from the component_ownership index.
682
+
683
+ Args:
684
+ team_name: Team name to look up
685
+
686
+ Returns:
687
+ List of ComponentOwnership with component name and ownership types.
688
+ """
689
+ with self._lock:
690
+ if self._data is None:
691
+ return []
692
+ team = self._data.lookups.teams.get(team_name)
693
+ if not team:
694
+ return []
695
+ result: list[ComponentOwnership] = []
696
+ for cr in _group(team).component_roles:
697
+ ownership_types: list[str] = []
698
+ owners = self._data.indexes.component_ownership.get(cr, [])
699
+ for owner in owners:
700
+ if owner.name == team_name:
701
+ ownership_types = owner.ownership_types
702
+ break
703
+ result.append(
704
+ ComponentOwnership(
705
+ component=cr,
706
+ ownership_types=tuple(ownership_types),
707
+ )
708
+ )
709
+ return result
710
+
711
+ def get_teams_for_uid(self, uid: str) -> list[str]:
712
+ """Get all teams a UID is a member of."""
713
+ with self._lock:
714
+ return self._get_teams_for_uid(uid)
715
+
716
+ def _get_teams_for_uid(self, uid: str) -> list[str]:
717
+ """Internal: Get all teams a UID is a member of. Caller must hold lock."""
718
+ if self._data is None or not self._data.indexes.membership.membership_index:
719
+ return []
720
+
721
+ memberships = self._data.indexes.membership.membership_index.get(uid, ())
722
+ teams: list[str] = []
723
+ for membership in memberships:
724
+ if membership.type == MembershipType.TEAM:
725
+ teams.append(membership.name)
726
+ return teams
727
+
728
+ def get_teams_for_slack_id(self, slack_id: str) -> list[str]:
729
+ """Get all teams a Slack user is a member of."""
730
+ with self._lock:
731
+ uid = self._get_uid_from_slack_id(slack_id)
732
+ if not uid:
733
+ return []
734
+ return self._get_teams_for_uid(uid)
735
+
736
+ def get_team_members(self, team_name: str) -> list[Employee]:
737
+ """Get all members of a team."""
738
+ with self._lock:
739
+ if self._data is None or not self._data.lookups.teams:
740
+ return []
741
+
742
+ team = self._data.lookups.teams.get(team_name)
743
+ if not team:
744
+ return []
745
+
746
+ return [
747
+ emp
748
+ for uid in _group(team).resolved_people_uid_list
749
+ if (emp := self._data.lookups.employees.get(uid))
750
+ ]
751
+
752
+ def is_employee_in_team(self, uid: str, team_name: str) -> bool:
753
+ """Check if an employee is in a specific team."""
754
+ with self._lock:
755
+ return self._is_employee_in_team(uid, team_name)
756
+
757
+ def _is_employee_in_team(self, uid: str, team_name: str) -> bool:
758
+ """Internal: Check if an employee is in a specific team. Caller must hold lock."""
759
+ teams = self._get_teams_for_uid(uid)
760
+ return team_name in teams
761
+
762
+ def is_slack_user_in_team(self, slack_id: str, team_name: str) -> bool:
763
+ """Check if a Slack user is in a specific team."""
764
+ with self._lock:
765
+ uid = self._get_uid_from_slack_id(slack_id)
766
+ if not uid:
767
+ return False
768
+ return self._is_employee_in_team(uid, team_name)
769
+
770
+ def is_employee_in_org(self, uid: str, org_name: str) -> bool:
771
+ """Check if an employee is in a specific organization."""
772
+ with self._lock:
773
+ return self._is_employee_in_org(uid, org_name)
774
+
775
+ def _is_employee_in_org(self, uid: str, org_name: str) -> bool:
776
+ """Internal: Check if an employee is in a specific organization. Caller must hold lock."""
777
+ if self._data is None or not self._data.indexes.membership.membership_index:
778
+ return False
779
+
780
+ memberships = self._data.indexes.membership.membership_index.get(uid, ())
781
+
782
+ for membership in memberships:
783
+ if membership.type == MembershipType.ORG and membership.name == org_name:
784
+ return True
785
+ elif membership.type == MembershipType.TEAM:
786
+ hierarchy_path = self._get_hierarchy_path(
787
+ membership.name, EntityType.TEAM
788
+ )
789
+ for entry in hierarchy_path:
790
+ if entry.type == EntityType.ORG and entry.name == org_name:
791
+ return True
792
+
793
+ return False
794
+
795
+ def is_slack_user_in_org(self, slack_id: str, org_name: str) -> bool:
796
+ """Check if a Slack user is in a specific organization."""
797
+ with self._lock:
798
+ uid = self._get_uid_from_slack_id(slack_id)
799
+ if not uid:
800
+ return False
801
+ return self._is_employee_in_org(uid, org_name)
802
+
803
+ def get_user_organizations(self, slack_user_id: str) -> list[OrgInfo]:
804
+ """Get the complete organizational hierarchy a Slack user belongs to."""
805
+ with self._lock:
806
+ if self._data is None or not self._data.indexes.membership.membership_index:
807
+ return []
808
+
809
+ uid = self._get_uid_from_slack_id(slack_user_id)
810
+ if not uid:
811
+ return []
812
+
813
+ if self._use_stable_ids:
814
+ return self._user_organizations_by_id(uid)
815
+
816
+ memberships = self._data.indexes.membership.membership_index.get(uid, ())
817
+ orgs: list[OrgInfo] = []
818
+ # Legacy mode: dedupe by (name, type). Names are not unique across
819
+ # types, so keying on name alone would drop a legitimately distinct
820
+ # entity (e.g. a team_group sharing a team's name) from the result.
821
+ seen: set[_NameTypeKey] = set()
822
+
823
+ for membership in memberships:
824
+ if membership.type == MembershipType.ORG:
825
+ key = _NameTypeKey(membership.name, EntityType.ORG)
826
+ if key not in seen:
827
+ orgs.append(
828
+ OrgInfo(name=membership.name, type=OrgInfoType.ORGANIZATION)
829
+ )
830
+ seen.add(key)
831
+
832
+ elif membership.type == MembershipType.TEAM:
833
+ key = _NameTypeKey(membership.name, EntityType.TEAM)
834
+ if key not in seen:
835
+ orgs.append(
836
+ OrgInfo(name=membership.name, type=OrgInfoType.TEAM)
837
+ )
838
+ seen.add(key)
839
+
840
+ hierarchy_path = self._get_hierarchy_path(
841
+ membership.name, EntityType.TEAM
842
+ )
843
+ self._add_hierarchy_path_items(orgs, seen, tuple(hierarchy_path))
844
+
845
+ return orgs
846
+
847
+ def _user_organizations_by_id(self, uid: str) -> list[OrgInfo]:
848
+ """Resolve a user's organizations by stable ID. Caller must hold lock.
849
+
850
+ Each membership is resolved through its ``stable_id`` (the canonical
851
+ identity), team ancestry is walked by ``parent_id``, and results are
852
+ deduplicated by stable ID. Used only when ``_use_stable_ids`` is true.
853
+ """
854
+ assert self._data is not None
855
+ memberships = self._data.indexes.membership.membership_index.get(uid, ())
856
+ orgs: list[OrgInfo] = []
857
+ seen: set[str] = set()
858
+
859
+ for membership in memberships:
860
+ if membership.type not in (MembershipType.ORG, MembershipType.TEAM):
861
+ continue
862
+
863
+ stable_id = membership.stable_id
864
+ if not stable_id:
865
+ # Membership without a stable_id: resolve it by (name, type).
866
+ etype = _to_entity_type(membership.type)
867
+ if etype is not None:
868
+ stable_id = self._id_by_name_type.get(
869
+ _NameTypeKey(membership.name, etype), ""
870
+ )
871
+ ref = self._entity_by_id.get(stable_id)
872
+ if ref is None:
873
+ continue
874
+
875
+ if stable_id not in seen:
876
+ seen.add(stable_id)
877
+ direct_type = (
878
+ OrgInfoType.ORGANIZATION
879
+ if membership.type == MembershipType.ORG
880
+ else OrgInfoType.TEAM
881
+ )
882
+ orgs.append(
883
+ OrgInfo(name=ref.name, type=direct_type, stable_id=ref.stable_id)
884
+ )
885
+
886
+ if membership.type != MembershipType.TEAM:
887
+ continue
888
+ # Walk ancestors with a per-walk visited set. Using the shared seen
889
+ # set as the stop condition would halt at an ancestor already
890
+ # recorded by an earlier (e.g. direct org) membership and drop its
891
+ # higher ancestors. visited also terminates cyclic parent links.
892
+ visited: set[str] = set()
893
+ parent_id = ref.parent_id
894
+ while parent_id and parent_id not in visited:
895
+ visited.add(parent_id)
896
+ parent = self._entity_by_id.get(parent_id)
897
+ if parent is None:
898
+ break
899
+ if parent_id not in seen:
900
+ seen.add(parent_id)
901
+ orgs.append(
902
+ OrgInfo(
903
+ name=parent.name,
904
+ type=_ancestor_org_info_type(parent.type),
905
+ stable_id=parent.stable_id,
906
+ )
907
+ )
908
+ parent_id = parent.parent_id
909
+
910
+ return orgs
911
+
912
+ def _add_hierarchy_path_items(
913
+ self,
914
+ orgs: list[OrgInfo],
915
+ seen: set[_NameTypeKey],
916
+ hierarchy_path: tuple[HierarchyPathEntry, ...],
917
+ ) -> None:
918
+ """Add hierarchy path items to the orgs list, avoiding duplicates."""
919
+ for entry in hierarchy_path[1:]:
920
+ # entry.type is a free-form string from the index; skip anything
921
+ # that is not a known hierarchy type (empty/unknown) rather than
922
+ # raising, matching the Go behavior.
923
+ entry_type = _to_entity_type(entry.type.lower())
924
+ if entry_type is None:
925
+ continue
926
+ key = _NameTypeKey(entry.name, entry_type)
927
+ if key not in seen:
928
+ orgs.append(
929
+ OrgInfo(
930
+ name=entry.name,
931
+ type=_ancestor_org_info_type(entry_type),
932
+ stable_id=entry.stable_id,
933
+ )
934
+ )
935
+ seen.add(key)
936
+
937
+ def _get_uid_from_slack_id(self, slack_id: str) -> str:
938
+ """Get the UID for a given Slack ID."""
939
+ if (
940
+ self._data is None
941
+ or not self._data.indexes.slack_id_mappings.slack_uid_to_uid
942
+ ):
943
+ return ""
944
+ return self._data.indexes.slack_id_mappings.slack_uid_to_uid.get(slack_id, "")
945
+
946
+ def get_user_memberships(self, uid: str) -> list[MembershipInfo]:
947
+ """Get all memberships for a user.
948
+
949
+ Args:
950
+ uid: The employee UID.
951
+
952
+ Returns:
953
+ List of membership entries, or empty list if not found.
954
+ """
955
+ with self._lock:
956
+ if self._data is None or not self._data.indexes.membership.membership_index:
957
+ return []
958
+ return list(self._data.indexes.membership.membership_index.get(uid, ()))
959
+
960
+ def get_user_teams(self, uid: str) -> list[str]:
961
+ """Get team names for a user.
962
+
963
+ Args:
964
+ uid: The employee UID.
965
+
966
+ Returns:
967
+ List of team names the user belongs to.
968
+ """
969
+ with self._lock:
970
+ return self._get_teams_for_uid(uid)
971
+
972
+ def get_all_employees(self) -> list[Employee]:
973
+ """Get all employees in the system."""
974
+ with self._lock:
975
+ if self._data is None or not self._data.lookups.employees:
976
+ return []
977
+ return list(self._data.lookups.employees.values())
978
+
979
+ def get_all_teams(self) -> list[Team]:
980
+ """Get all teams in the system."""
981
+ with self._lock:
982
+ if self._data is None or not self._data.lookups.teams:
983
+ return []
984
+ return list(self._data.lookups.teams.values())
985
+
986
+ def get_all_orgs(self) -> list[Org]:
987
+ """Get all organizations in the system."""
988
+ with self._lock:
989
+ if self._data is None or not self._data.lookups.orgs:
990
+ return []
991
+ return list(self._data.lookups.orgs.values())
992
+
993
+ def get_all_pillars(self) -> list[Pillar]:
994
+ """Get all pillars in the system."""
995
+ with self._lock:
996
+ if self._data is None or not self._data.lookups.pillars:
997
+ return []
998
+ return list(self._data.lookups.pillars.values())
999
+
1000
+ def get_all_team_groups(self) -> list[TeamGroup]:
1001
+ """Get all team groups in the system."""
1002
+ with self._lock:
1003
+ if self._data is None or not self._data.lookups.team_groups:
1004
+ return []
1005
+ return list(self._data.lookups.team_groups.values())
1006
+
1007
+ def get_org_members(self, org_name: str) -> list[Employee]:
1008
+ """Get all members of an organization.
1009
+
1010
+ Args:
1011
+ org_name: The organization name.
1012
+
1013
+ Returns:
1014
+ List of employees in the organization.
1015
+ """
1016
+ with self._lock:
1017
+ if self._data is None or not self._data.lookups.orgs:
1018
+ return []
1019
+ org = self._data.lookups.orgs.get(org_name)
1020
+ if not org:
1021
+ return []
1022
+ return [
1023
+ emp
1024
+ for uid in _group(org).resolved_people_uid_list
1025
+ if (emp := self._data.lookups.employees.get(uid))
1026
+ ]
1027
+
1028
+ def get_all_employee_uids(self) -> list[str]:
1029
+ """Get all employee UIDs in the system."""
1030
+ with self._lock:
1031
+ if self._data is None or not self._data.lookups.employees:
1032
+ return []
1033
+ return list(self._data.lookups.employees.keys())
1034
+
1035
+ def get_all_team_names(self) -> list[str]:
1036
+ """Get all team names in the system."""
1037
+ with self._lock:
1038
+ if self._data is None or not self._data.lookups.teams:
1039
+ return []
1040
+ return list(self._data.lookups.teams.keys())
1041
+
1042
+ def get_all_org_names(self) -> list[str]:
1043
+ """Get all organization names in the system."""
1044
+ with self._lock:
1045
+ if self._data is None or not self._data.lookups.orgs:
1046
+ return []
1047
+ return list(self._data.lookups.orgs.keys())
1048
+
1049
+ def get_all_pillar_names(self) -> list[str]:
1050
+ """Get all pillar names in the system."""
1051
+ with self._lock:
1052
+ if self._data is None or not self._data.lookups.pillars:
1053
+ return []
1054
+ return list(self._data.lookups.pillars.keys())
1055
+
1056
+ def get_all_team_group_names(self) -> list[str]:
1057
+ """Get all team group names in the system."""
1058
+ with self._lock:
1059
+ if self._data is None or not self._data.lookups.team_groups:
1060
+ return []
1061
+ return list(self._data.lookups.team_groups.keys())
1062
+
1063
+ def _get_entity_by_type(
1064
+ self, entity_name: str, entity_type: str
1065
+ ) -> Team | Org | Pillar | TeamGroup | None:
1066
+ """Get entity from lookups by name and type."""
1067
+ if self._data is None:
1068
+ return None
1069
+ entity_type_lower = entity_type.lower()
1070
+ if entity_type_lower == "team":
1071
+ return self._data.lookups.teams.get(entity_name)
1072
+ elif entity_type_lower == "org":
1073
+ return self._data.lookups.orgs.get(entity_name)
1074
+ elif entity_type_lower == "pillar":
1075
+ return self._data.lookups.pillars.get(entity_name)
1076
+ elif entity_type_lower == "team_group":
1077
+ return self._data.lookups.team_groups.get(entity_name)
1078
+ return None
1079
+
1080
+ def _get_entity_type(self, entity_name: str) -> str:
1081
+ """Look up entity type by scanning lookups."""
1082
+ if self._data is None:
1083
+ return ""
1084
+ if entity_name in self._data.lookups.teams:
1085
+ return "team"
1086
+ if entity_name in self._data.lookups.orgs:
1087
+ return "org"
1088
+ if entity_name in self._data.lookups.pillars:
1089
+ return "pillar"
1090
+ if entity_name in self._data.lookups.team_groups:
1091
+ return "team_group"
1092
+ return ""
1093
+
1094
+ def get_hierarchy_path(
1095
+ self, entity_name: str, entity_type: str = "team"
1096
+ ) -> list[HierarchyPathEntry]:
1097
+ """Get ordered hierarchy path from entity to root.
1098
+
1099
+ Computes path by walking parent references in entities.
1100
+
1101
+ Args:
1102
+ entity_name: Name of the team/org/pillar/team_group
1103
+ entity_type: Type of entity ("team", "org", "pillar", "team_group")
1104
+
1105
+ Returns:
1106
+ Ordered list from entity to root. Empty list if not found.
1107
+ """
1108
+ with self._lock:
1109
+ return self._get_hierarchy_path(entity_name, entity_type)
1110
+
1111
+ def _get_hierarchy_path(
1112
+ self, entity_name: str, entity_type: str = "team"
1113
+ ) -> list[HierarchyPathEntry]:
1114
+ """Internal: Get hierarchy path. Caller must hold lock."""
1115
+ if self._data is None:
1116
+ return []
1117
+
1118
+ if self._use_stable_ids:
1119
+ return self._compute_hierarchy_path_by_id(entity_name, entity_type)
1120
+
1121
+ entity = self._get_entity_by_type(entity_name, entity_type)
1122
+ if entity is None:
1123
+ return []
1124
+
1125
+ path = [HierarchyPathEntry(name=entity_name, type=entity_type)]
1126
+ # Guard against cycles by tracking visited entities by both name and
1127
+ # type. Different entity types can share a name (e.g. a team and its
1128
+ # parent team_group), so keying on name alone would stop the walk
1129
+ # prematurely.
1130
+ visited = {(entity_name, entity_type)}
1131
+ current: Team | Org | Pillar | TeamGroup | None = entity
1132
+
1133
+ while current and current.parent:
1134
+ parent = current.parent
1135
+ if (parent.name, parent.type) in visited:
1136
+ break
1137
+ visited.add((parent.name, parent.type))
1138
+ path.append(HierarchyPathEntry(name=parent.name, type=parent.type))
1139
+ current = self._get_entity_by_type(parent.name, parent.type)
1140
+
1141
+ return path
1142
+
1143
+ def _compute_hierarchy_path_by_id(
1144
+ self, entity_name: str, entity_type: str
1145
+ ) -> list[HierarchyPathEntry]:
1146
+ """Walk the parent chain by stable ID. Caller must hold the lock.
1147
+
1148
+ Used only when ``_use_stable_ids`` is true. Because relationships are
1149
+ keyed by identity, an explicitly requested type resolves the correct
1150
+ entity even when its name is shared across types.
1151
+ """
1152
+ start_id = self._resolve_start_id(entity_name, entity_type)
1153
+ if not start_id:
1154
+ return []
1155
+
1156
+ path: list[HierarchyPathEntry] = []
1157
+ visited: set[str] = set()
1158
+ current_id = start_id
1159
+ while current_id and current_id not in visited:
1160
+ visited.add(current_id)
1161
+ ref = self._entity_by_id.get(current_id)
1162
+ if ref is None:
1163
+ break
1164
+ path.append(
1165
+ HierarchyPathEntry(
1166
+ name=ref.name, type=ref.type, stable_id=ref.stable_id
1167
+ )
1168
+ )
1169
+ current_id = ref.parent_id
1170
+ return path
1171
+
1172
+ def get_descendants_tree(self, entity_name: str) -> HierarchyNode | None:
1173
+ """Get all descendants of an entity as a nested tree.
1174
+
1175
+ Computes tree by scanning all entities for children.
1176
+
1177
+ Args:
1178
+ entity_name: Name of the org/pillar/team_group/team
1179
+
1180
+ Returns:
1181
+ Nested tree structure with all descendants, or None if not found.
1182
+ """
1183
+ with self._lock:
1184
+ if self._data is None:
1185
+ return None
1186
+
1187
+ if self._use_stable_ids:
1188
+ return self._get_descendants_tree_by_id(entity_name)
1189
+
1190
+ # Build children map by scanning all entities
1191
+ children_map: dict[str, list[tuple[str, str]]] = {}
1192
+ all_entities: list[tuple[str, Team | Org | Pillar | TeamGroup, str]] = [
1193
+ *(
1194
+ (name, info, "team")
1195
+ for name, info in self._data.lookups.teams.items()
1196
+ ),
1197
+ *(
1198
+ (name, info, "org")
1199
+ for name, info in self._data.lookups.orgs.items()
1200
+ ),
1201
+ *(
1202
+ (name, info, "pillar")
1203
+ for name, info in self._data.lookups.pillars.items()
1204
+ ),
1205
+ *(
1206
+ (name, info, "team_group")
1207
+ for name, info in self._data.lookups.team_groups.items()
1208
+ ),
1209
+ ]
1210
+
1211
+ entity_type = self._get_entity_type(entity_name)
1212
+ if not entity_type:
1213
+ return None
1214
+
1215
+ for name, info, etype in all_entities:
1216
+ if info.parent:
1217
+ if info.parent.name not in children_map:
1218
+ children_map[info.parent.name] = []
1219
+ children_map[info.parent.name].append((name, etype))
1220
+
1221
+ def build_node(name: str, type_: str, visited: set[str]) -> HierarchyNode:
1222
+ if name in visited:
1223
+ return HierarchyNode(name=name, type=type_, children=())
1224
+ visited.add(name)
1225
+ children = children_map.get(name, [])
1226
+ child_nodes = tuple(build_node(n, t, visited) for n, t in children)
1227
+ return HierarchyNode(name=name, type=type_, children=child_nodes)
1228
+
1229
+ return build_node(entity_name, entity_type, set())
1230
+
1231
+ def _get_descendants_tree_by_id(self, entity_name: str) -> HierarchyNode | None:
1232
+ """Build the descendants tree using stable-ID edges. Caller holds lock.
1233
+
1234
+ Used only when ``_use_stable_ids`` is true. Keying children and cycle
1235
+ detection by stable ID keeps the subtrees of same-named parents
1236
+ distinct. The root type is still inferred by name (the public API
1237
+ accepts only a name), but the tree below it is collision-safe.
1238
+ """
1239
+ entity_type = self._get_entity_type(entity_name)
1240
+ if not entity_type:
1241
+ return None
1242
+ start_id = self._id_by_name_type.get(
1243
+ _NameTypeKey(entity_name, EntityType(entity_type)), ""
1244
+ )
1245
+ if not start_id:
1246
+ return None
1247
+
1248
+ def build_node(stable_id: str, visited: set[str]) -> HierarchyNode:
1249
+ ref = self._entity_by_id[stable_id]
1250
+ if stable_id in visited:
1251
+ return HierarchyNode(
1252
+ name=ref.name, type=ref.type, stable_id=ref.stable_id, children=()
1253
+ )
1254
+ visited.add(stable_id)
1255
+ # Deterministic ordering (dict insertion order follows lookups).
1256
+ child_ids = sorted(
1257
+ self._children_by_id.get(stable_id, []),
1258
+ key=lambda cid: (
1259
+ self._entity_by_id[cid].name,
1260
+ self._entity_by_id[cid].type,
1261
+ ),
1262
+ )
1263
+ child_nodes = tuple(build_node(cid, visited) for cid in child_ids)
1264
+ return HierarchyNode(
1265
+ name=ref.name,
1266
+ type=ref.type,
1267
+ stable_id=ref.stable_id,
1268
+ children=child_nodes,
1269
+ )
1270
+
1271
+ return build_node(start_id, set())
1272
+
1273
+ def get_jira_projects(self) -> list[str]:
1274
+ """Get all Jira project keys."""
1275
+ with self._lock:
1276
+ if self._data is None:
1277
+ return []
1278
+ return list(self._data.indexes.jira.keys())
1279
+
1280
+ def get_jira_components(self, project: str) -> list[str]:
1281
+ """Get all components for a Jira project.
1282
+
1283
+ Args:
1284
+ project: Jira project key (e.g., "OCPBUGS")
1285
+
1286
+ Returns:
1287
+ List of component names. "_project_level" indicates project-level ownership.
1288
+ """
1289
+ with self._lock:
1290
+ if self._data is None:
1291
+ return []
1292
+ components = self._data.indexes.jira.get(project, {})
1293
+ return list(components.keys())
1294
+
1295
+ def get_teams_by_jira_project(self, project: str) -> list[JiraOwnerInfo]:
1296
+ """Get all teams/entities that own any component in a Jira project.
1297
+
1298
+ Args:
1299
+ project: Jira project key (e.g., "OCPBUGS")
1300
+
1301
+ Returns:
1302
+ Deduplicated list of owner entities across all components.
1303
+ """
1304
+ with self._lock:
1305
+ if self._data is None:
1306
+ return []
1307
+ components = self._data.indexes.jira.get(project, {})
1308
+ seen: set[str] = set()
1309
+ result: list[JiraOwnerInfo] = []
1310
+ for owners in components.values():
1311
+ for owner in owners:
1312
+ if owner.name not in seen:
1313
+ seen.add(owner.name)
1314
+ result.append(owner)
1315
+ return result
1316
+
1317
+ def get_teams_by_jira_component(
1318
+ self, project: str, component: str
1319
+ ) -> list[JiraOwnerInfo]:
1320
+ """Get teams/entities that own a specific Jira component.
1321
+
1322
+ Args:
1323
+ project: Jira project key (e.g., "OCPBUGS")
1324
+ component: Component name (or "_project_level" for project ownership)
1325
+
1326
+ Returns:
1327
+ List of owner entities for the component.
1328
+ """
1329
+ with self._lock:
1330
+ if self._data is None:
1331
+ return []
1332
+ components = self._data.indexes.jira.get(project, {})
1333
+ owners = components.get(component, [])
1334
+ return list(owners)
1335
+
1336
+ def get_jira_ownership_for_team(self, team_name: str) -> list[dict[str, str]]:
1337
+ """Get all Jira projects and components owned by a team.
1338
+
1339
+ Args:
1340
+ team_name: Team name to look up
1341
+
1342
+ Returns:
1343
+ List of dicts with "project" and "component" keys.
1344
+ """
1345
+ with self._lock:
1346
+ if self._data is None:
1347
+ return []
1348
+ result: list[dict[str, str]] = []
1349
+ for (
1350
+ project,
1351
+ components,
1352
+ ) in self._data.indexes.jira.items():
1353
+ for component, owners in components.items():
1354
+ for owner in owners:
1355
+ if owner.name == team_name:
1356
+ result.append({"project": project, "component": component})
1357
+ break
1358
+ return result
1359
+
1360
+ def get_context_for_team(self, team_name: str) -> list[ContextItemInfo]:
1361
+ """Get resolved context items for a team (including inherited).
1362
+
1363
+ Args:
1364
+ team_name: The team name to look up.
1365
+
1366
+ Returns:
1367
+ List of resolved context items, or empty list if not found.
1368
+ """
1369
+ with self._lock:
1370
+ if self._data is None or not self._data.lookups.teams:
1371
+ return []
1372
+ team = self._data.lookups.teams.get(team_name)
1373
+ if team is None:
1374
+ return []
1375
+ return list(_group(team).resolved_context)
1376
+
1377
+ def get_context_for_entity(
1378
+ self, entity_name: str, entity_type: str = "team"
1379
+ ) -> list[ContextItemInfo]:
1380
+ """Get resolved context items for any entity type.
1381
+
1382
+ Args:
1383
+ entity_name: Name of the entity.
1384
+ entity_type: Type of entity ("team", "org", "pillar", "team_group").
1385
+
1386
+ Returns:
1387
+ List of resolved context items, or empty list if not found.
1388
+ """
1389
+ with self._lock:
1390
+ if self._data is None:
1391
+ return []
1392
+ entity = self._get_entity_by_type(entity_name, entity_type)
1393
+ if entity is None:
1394
+ return []
1395
+ return list(_group(entity).resolved_context)
1396
+
1397
+ def get_context_by_type(
1398
+ self, entity_name: str, context_type: str, entity_type: str = "team"
1399
+ ) -> list[ContextItemInfo]:
1400
+ """Get resolved context items filtered by a specific context type.
1401
+
1402
+ Args:
1403
+ entity_name: Name of the entity.
1404
+ context_type: Context type to filter by (e.g., "release_framework").
1405
+ entity_type: Type of entity ("team", "org", "pillar", "team_group").
1406
+
1407
+ Returns:
1408
+ List of matching context items, or empty list if not found.
1409
+ """
1410
+ with self._lock:
1411
+ if self._data is None:
1412
+ return []
1413
+ entity = self._get_entity_by_type(entity_name, entity_type)
1414
+ if entity is None:
1415
+ return []
1416
+ return [
1417
+ item
1418
+ for item in _group(entity).resolved_context
1419
+ if context_type in item.types
1420
+ ]
1421
+
1422
+ def get_all_context_types_for_entity(
1423
+ self, entity_name: str, entity_type: str = "team"
1424
+ ) -> list[str]:
1425
+ """Get distinct context types available for an entity.
1426
+
1427
+ Args:
1428
+ entity_name: Name of the entity.
1429
+ entity_type: Type of entity ("team", "org", "pillar", "team_group").
1430
+
1431
+ Returns:
1432
+ List of distinct context type strings.
1433
+ """
1434
+ with self._lock:
1435
+ if self._data is None:
1436
+ return []
1437
+ entity = self._get_entity_by_type(entity_name, entity_type)
1438
+ if entity is None:
1439
+ return []
1440
+ seen: set[str] = set()
1441
+ result: list[str] = []
1442
+ for item in _group(entity).resolved_context:
1443
+ for t in item.types:
1444
+ if t not in seen:
1445
+ seen.add(t)
1446
+ result.append(t)
1447
+ return result
1448
+
1449
+ def get_context_type_descriptions(self) -> dict[str, str]:
1450
+ """Get the description registry for all context types.
1451
+
1452
+ Returns a dict mapping context type enum values to their human-readable
1453
+ descriptions, sourced from the index metadata.
1454
+ """
1455
+ with self._lock:
1456
+ if self._data is None:
1457
+ return {}
1458
+ return dict(self._data.metadata.context_type_descriptions)