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.
- orgdatacore/__init__.py +156 -0
- orgdatacore/_anonymization.py +465 -0
- orgdatacore/_async.py +1516 -0
- orgdatacore/_exceptions.py +82 -0
- orgdatacore/_gcs.py +317 -0
- orgdatacore/_internal/__init__.py +5 -0
- orgdatacore/_internal/testing/__init__.py +16 -0
- orgdatacore/_internal/testing/fake_gcs.py +173 -0
- orgdatacore/_internal/testing/filesource.py +147 -0
- orgdatacore/_internal/testing/helpers.py +192 -0
- orgdatacore/_log.py +98 -0
- orgdatacore/_redaction.py +215 -0
- orgdatacore/_serialization.py +24 -0
- orgdatacore/_service.py +1458 -0
- orgdatacore/_types.py +269 -0
- orgdatacore/_version.py +119 -0
- orgdatacore/py.typed +0 -0
- orgdatacore-2.0.0.dist-info/METADATA +444 -0
- orgdatacore-2.0.0.dist-info/RECORD +21 -0
- orgdatacore-2.0.0.dist-info/WHEEL +4 -0
- orgdatacore-2.0.0.dist-info/licenses/LICENSE +201 -0
orgdatacore/_service.py
ADDED
|
@@ -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)
|