• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / relman-auto-nag / #4987

03 May 2024 06:37PM CUT coverage: 21.89% (+0.02%) from 21.872%
#4987

push

coveralls-python

suhaibmujahid
Satisfy the type linting in for the People class

716 of 3592 branches covered (19.93%)

1930 of 8817 relevant lines covered (21.89%)

0.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.33
/bugbot/people.py
1
# This Source Code Form is subject to the terms of the Mozilla Public
2
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
3
# You can obtain one at http://mozilla.org/MPL/2.0/.
4

5
import json
1✔
6
import re
1✔
7
from math import sqrt
1✔
8
from typing import Set, TypedDict
1✔
9

10
import numpy as np
1✔
11

12
WORDS = re.compile(r"(\w+)")
1✔
13
MAIL = re.compile(r"^([^@]+@[^ ]+)")
1✔
14
IMs = [
1✔
15
    "irc",
16
    "slack",
17
    "skype",
18
    "xmpp",
19
    "github",
20
    "aim",
21
    "telegram",
22
    "irc.mozilla.org",
23
    "google talk",
24
    "gtalk",
25
    "blog",
26
    "twitter",
27
]
28
IM_NICK = re.compile(r"([\w\.@]+)")
1✔
29

30
DEFAULT_PATH = "./configs/people.json"
1✔
31

32

33
_ManagerInfo = TypedDict("_ManagerInfo", {"cn": str, "dn": str})
1✔
34

35
Person = TypedDict(
1✔
36
    "Person",
37
    {
38
        "bugzillaEmail": str,
39
        "bugzillaID": str,
40
        "cn": str,
41
        "dn": str,
42
        "found_on_bugzilla": bool,
43
        "im": list[str],
44
        "isdirector": str,
45
        "ismanager": str,
46
        "mail": str,
47
        "manager": _ManagerInfo,
48
        "title": str,
49
    },
50
)
51

52

53
class People:
1✔
54
    _instance = None
1✔
55

56
    def __init__(self, people_file: str | list[Person] = DEFAULT_PATH):
1✔
57
        """Constructor
58

59
        Args:
60
            people_file: path to the people file or loaded people data.
61
        """
62
        if isinstance(people_file, str):
1!
63
            with open(people_file, "r", encoding="utf-8") as file:
×
64
                self.data = json.load(file)
×
65
        else:
66
            self.data = people_file
1✔
67

68
        self.people = self._get_people()
1✔
69
        self.people_by_bzmail: dict[str, Person] = {}
1✔
70
        self.managers: set[str] = set()
1✔
71
        self.people_with_bzmail: set[str] = set()
1✔
72
        self.release_managers: set[str] = set()
1✔
73
        self.rm_or_directors: set[str] = set()
1✔
74
        self.nicks: dict[str, Person] = {}
1✔
75
        self.directors: set[str] = set()
1✔
76
        self.vps: set[str] = set()
1✔
77
        self.names: dict[tuple, Person] = {}
1✔
78
        self._amend()
1✔
79
        self.matrix = None
1✔
80

81
    @staticmethod
1✔
82
    def get_instance():
1✔
83
        if People._instance is None:
×
84
            People._instance = People()
×
85
        return People._instance
×
86

87
    def _get_name_parts(self, name):
1✔
88
        """Get names from name"""
89
        return set(s.lower() for s in WORDS.findall(name))
1✔
90

91
    def _get_people(self) -> dict[str, Person]:
1✔
92
        people = {}
1✔
93
        for person in self.data:
1✔
94
            mail = self.get_preferred_mail(person)
1✔
95
            person["mail"] = mail
1✔
96
            people[mail] = person
1✔
97
        return people
1✔
98

99
    def _get_names(self):
1✔
100
        if not self.names:
1!
101
            for person in self.data:
1!
102
                cn = person["cn"]
×
103
                parts = self._get_name_parts(cn)
×
104
                parts = tuple(sorted(parts))
×
105
                self.names[parts] = person
×
106
        return self.names
1✔
107

108
    def _get_bigrams(self, text):
1✔
109
        text = "".join(s.lower() for s in WORDS.findall(text))
1✔
110
        return [text[i : (i + 2)] for i in range(len(text) - 1)]
1✔
111

112
    def _get_bigrams_stats(self, text):
1✔
113
        stats = {}
1✔
114
        for bi in self._get_bigrams(text):
1✔
115
            stats[bi] = stats.get(bi, 0) + 1
1✔
116

117
        return stats
1✔
118

119
    def _get_matrix_names(self):
1✔
120
        if self.matrix is None:
1✔
121
            res = {}
1✔
122
            bigrams = set()
1✔
123
            cns = {}
1✔
124
            for person in self.data:
1✔
125
                cn = person["cn"]
1✔
126
                cns[cn] = person
1✔
127
                res[cn] = stats = self._get_bigrams_stats(cn)
1✔
128
                L = sqrt(sum(v * v for v in stats.values()))
1✔
129
                for k, v in stats.items():
1✔
130
                    stats[k] = float(v) / L
1✔
131
                    bigrams.add(k)
1✔
132

133
            bigrams = sorted(bigrams)
1✔
134
            self.bigrams = {x: i for i, x in enumerate(bigrams)}
1✔
135
            self.matrix = np.zeros((len(res), len(self.bigrams)))
1✔
136
            self.matrix_map = [None] * len(res.items())
1✔
137
            for i, (name, stats) in enumerate(res.items()):
1✔
138
                for b, n in stats.items():
1✔
139
                    self.matrix[i][self.bigrams[b]] = n
1✔
140
                self.matrix_map[i] = cns[name]
1✔
141

142
    def search_by_name(self, name):
1✔
143
        # Try to find name in using cosine similarity
144
        self._get_matrix_names()
1✔
145
        stats = self._get_bigrams_stats(name)
1✔
146
        for k in set(stats.keys()) - set(self.bigrams.keys()):
1✔
147
            del stats[k]
1✔
148
        L = sqrt(sum(v * v for v in stats.values()))
1✔
149
        x = np.zeros((len(self.bigrams), 1))
1✔
150
        for k, v in stats.items():
1✔
151
            x[self.bigrams[k]][0] = float(v) / L
1✔
152
        res = np.matmul(self.matrix, x)
1✔
153
        for cos in [0.99, 0.9, 0.8, 0.7]:
1✔
154
            index = np.argwhere(res > cos)
1✔
155
            if index.shape[0] == 1:
1✔
156
                return self.matrix_map[index[0][0]]
1✔
157

158
        found = None
1✔
159
        name_parts = self._get_name_parts(name)
1✔
160
        for parts, info in self._get_names().items():
1!
161
            if name_parts <= set(parts):
×
162
                if found is None:
×
163
                    found = info
×
164
                else:
165
                    found = None
×
166
                    break
×
167
        return found
1✔
168

169
    def _get_people_by_bzmail(self):
1✔
170
        if not self.people_by_bzmail:
1✔
171
            for person in self.data:
1✔
172
                bzmail = person["bugzillaEmail"]
1✔
173
                if not bzmail:
1!
174
                    bzmail = person["mail"]
1✔
175
                self.people_by_bzmail[bzmail] = person
1✔
176
        return self.people_by_bzmail
1✔
177

178
    def get_managers(self):
1✔
179
        """Get all the managers"""
180
        if not self.managers:
×
181
            for person in self.data:
×
182
                manager = person["manager"]
×
183
                if manager:
×
184
                    self.managers.add(manager["dn"])
×
185
        return self.managers
×
186

187
    def get_people_with_bzmail(self):
1✔
188
        """Get all the people who have a bugzilla email"""
189
        if not self.people_with_bzmail:
×
190
            for person, info in self.people.items():
×
191
                mail = info["bugzillaEmail"]
×
192
                if mail:
×
193
                    self.people_with_bzmail.add(mail)
×
194
        return self.people_with_bzmail
×
195

196
    def get_info_by_nick(self, nick):
1✔
197
        """Get info for a nickname"""
198
        if not self.nicks:
1!
199
            doubloons = set()
1✔
200
            for person, info in self.people.items():
1✔
201
                bz_mail = info["bugzillaEmail"]
1✔
202
                if not bz_mail:
1!
203
                    continue
1✔
204
                nicks = self.get_nicks_from_im(info)
×
205
                nicks |= {person, bz_mail, info["mail"], info.get("githubprofile")}
×
206
                nicks |= set(self.get_aliases(info))
×
207
                nicks = {self.get_mail_prefix(n) for n in nicks if n}
×
208
                for n in nicks:
×
209
                    if n not in self.nicks:
×
210
                        self.nicks[n] = info
×
211
                    else:
212
                        doubloons.add(n)
×
213
            # doubloons are not identifiable so remove them
214
            for n in doubloons:
1!
215
                del self.nicks[n]
×
216
        return self.nicks.get(nick)
1✔
217

218
    def get_rm(self):
1✔
219
        """Get the release managers as defined in configs/rm.json"""
220
        if not self.release_managers:
×
221
            with open("./configs/rm.json", "r") as In:
×
222
                self.release_managers = set(json.load(In))
×
223
        return self.release_managers
×
224

225
    def get_directors(self):
1✔
226
        """Get the directors: people who 'director' in their job title"""
227
        if not self.directors:
1✔
228
            for person, info in self.people.items():
1✔
229
                title = info.get("title", "").lower()
1✔
230
                if "director" in title:
1✔
231
                    self.directors.add(person)
1✔
232
        return self.directors
1✔
233

234
    def get_vps(self):
1✔
235
        """Get the vp: people who've 'vp' in their job title"""
236
        if not self.vps:
1✔
237
            for person, info in self.people.items():
1✔
238
                title = info.get("title", "").lower()
1✔
239
                if (
1✔
240
                    title.startswith("vp") or title.startswith("vice president")
241
                ) and self.get_distance(person) <= 3:
242
                    self.vps.add(person)
1✔
243
        return self.vps
1✔
244

245
    def get_distance(self, mail):
1✔
246
        rank = -1
1✔
247
        while mail:
1✔
248
            rank += 1
1✔
249
            prev = mail
1✔
250
            mail = self.get_manager_mail(mail)
1✔
251
            if mail == prev:
1!
252
                break
×
253
        return rank
1✔
254

255
    def get_rm_or_directors(self):
1✔
256
        """Get a set of release managers and directors who've a bugzilla email"""
257
        if not self.rm_or_directors:
×
258
            ms = self.get_directors() | self.get_rm()
×
259
            for m in ms:
×
260
                info = self.people[m]
×
261
                mail = info["bugzillaEmail"]
×
262
                if mail:
×
263
                    self.rm_or_directors.add(mail)
×
264
        return self.rm_or_directors
×
265

266
    def _get_mail_from_dn(self, dn):
1✔
267
        dn = dn.split(",")
1✔
268
        assert len(dn) >= 2
1✔
269
        dn = dn[0].split("=")
1✔
270
        assert len(dn) == 2
1✔
271
        return dn[1]
1✔
272

273
    def _amend(self):
1✔
274
        for person in self.data:
1✔
275
            if "manager" not in person:
1✔
276
                person["manager"] = {}
1✔
277
            if "title" not in person:
1!
278
                person["title"] = ""
×
279
            manager = person["manager"]
1✔
280
            if manager:
1✔
281
                manager["dn"] = self._get_mail_from_dn(manager["dn"])
1✔
282
            if "bugzillaEmail" in person:
1✔
283
                person["bugzillaEmail"] = person["bugzillaEmail"].lower()
1✔
284
            elif "bugzillaemail" in person:
1!
285
                person["bugzillaEmail"] = person["bugzillaemail"].lower()
×
286
                del person["bugzillaemail"]
×
287
            else:
288
                person["bugzillaEmail"] = ""
1✔
289

290
    def is_mozilla(self, mail):
1✔
291
        """Check if the mail is the one from a mozilla employee"""
292
        return mail in self._get_people_by_bzmail() or mail in self.people
1✔
293

294
    def is_manager(self, mail):
1✔
295
        """Check if the mail is the one from a mozilla manager"""
296
        if mail in self._get_people_by_bzmail():
×
297
            person = self._get_people_by_bzmail()[mail]
×
298
            return person["mail"] in self._get_managers()
×
299
        elif mail in self.people:
×
300
            return mail in self._get_managers()
×
301

302
        return False
×
303

304
    def get_manager_mail(self, mail):
1✔
305
        """Get the manager of the person with this mail"""
306
        person = self._get_people_by_bzmail().get(mail, None)
1✔
307
        if not person:
1!
308
            person = self.people.get(mail, None)
×
309
        if not person:
1!
310
            return None
×
311

312
        manager = person["manager"]
1✔
313
        if not manager:
1✔
314
            return None
1✔
315

316
        manager_mail = manager["dn"]
1✔
317
        if manager_mail == mail:
1!
318
            return None
×
319

320
        return manager_mail
1✔
321

322
    def get_nth_manager_mail(self, mail, rank):
1✔
323
        """Get the nth manager of the person with this mail"""
324
        for _ in range(rank):
1✔
325
            prev = mail
1✔
326
            mail = self.get_manager_mail(mail)
1✔
327
            if not mail or mail == prev:
1!
328
                return prev
×
329
        return mail
1✔
330

331
    def get_management_chain_mails(
1✔
332
        self, person: str, superior: str, raise_on_missing: bool = True
333
    ) -> Set[str]:
334
        """Get the mails of people in the management chain between a person and
335
        their superior.
336

337
        Args:
338
            person: the moz email of an employee.
339
            superior: the moz email of one of the employee's superiors.
340
            raise_on_missing: If True, an exception will be raised when the
341
                superior is not in the management hierarchy of the employee. If
342
                False, an empty set will be returned instead of raising an
343
                exception.
344

345
        Returns:
346
            A set of moz emails for people in the management chain between
347
            `person` and `superior`. Emails for `person` and `superior` will not
348
            be returned with the result.
349
        """
350
        result: Set[str] = set()
1✔
351

352
        assert person in self.people
1✔
353
        assert superior in self.people
1✔
354
        if person == superior:
1!
355
            return result
×
356

357
        manager = self.get_manager_mail(person)
1✔
358
        while manager != superior:
1✔
359
            result.add(manager)
1✔
360
            manager = self.get_manager_mail(manager)
1✔
361

362
            if not manager:
1✔
363
                if not raise_on_missing:
1!
364
                    return set()
×
365
                raise Exception(f"Cannot identify {superior} as a superior of {person}")
1✔
366

367
            if manager in result:
1!
368
                raise Exception("Circular management chain")
×
369

370
        return result
1✔
371

372
    def get_director_mail(self, mail):
1✔
373
        """Get the director of the person with this mail"""
374
        directors = self.get_directors()
1✔
375
        while True:
1✔
376
            prev = mail
1✔
377
            mail = self.get_manager_mail(mail)
1✔
378
            if not mail:
1!
379
                break
×
380
            if mail in directors:
1✔
381
                return mail
1✔
382
            if mail == prev:
1!
383
                break
×
384
        return None
×
385

386
    def get_vp_mail(self, mail):
1✔
387
        """Get the VP of the person with this mail"""
388
        vps = self.get_vps()
1✔
389
        while True:
1✔
390
            prev = mail
1✔
391
            mail = self.get_manager_mail(mail)
1✔
392
            if not mail:
1!
393
                break
×
394
            if mail in vps:
1✔
395
                return mail
1✔
396
            if mail == prev:
1!
397
                break
×
398
        return None
×
399

400
    def get_mail_prefix(self, mail):
1✔
401
        return mail.split("@", 1)[0].lower()
×
402

403
    def get_im(self, person):
1✔
404
        im = person.get("im", "")
×
405
        if not im:
×
406
            return []
×
407
        if isinstance(im, str):
×
408
            return [im]
×
409
        return im
×
410

411
    def get_nicks_from_im(self, person):
1✔
412
        im = self.get_im(person)
×
413
        nicks = set()
×
414
        for info in im:
×
415
            info = info.lower()
×
416
            for i in IMs:
×
417
                info = info.replace(i, "")
×
418
            for nick in IM_NICK.findall(info):
×
419
                if nick.startswith("@"):
×
420
                    nick = nick[1:]
×
421
                nicks.add(nick)
×
422
        return nicks
×
423

424
    def get_aliases(self, person):
1✔
425
        aliases = person.get("emailalias", "")
1✔
426
        if not aliases:
1!
427
            return []
1✔
428
        if isinstance(aliases, str):
×
429
            return [aliases]
×
430
        return aliases
×
431

432
    def get_preferred_mail(self, person):
1✔
433
        aliases = self.get_aliases(person)
1✔
434
        for alias in aliases:
1!
435
            alias = alias.strip()
×
436
            if "preferred" in alias:
×
437
                m = MAIL.search(alias)
×
438
                if m:
×
439
                    return m.group(1)
×
440
        return person["mail"]
1✔
441

442
    def get_moz_mail(self, mail):
1✔
443
        """Get the Mozilla email of the person with this Bugzilla email"""
444
        person = self._get_people_by_bzmail().get(mail, None)
1✔
445
        if person:
1✔
446
            return person["mail"]
1✔
447
        return mail
1✔
448

449
    def get_moz_name(self, mail):
1✔
450
        """Get the name of the person with this Bugzilla email"""
451
        person = self._get_people_by_bzmail().get(mail, None)
×
452
        if person is None:
×
453
            return None
×
454
        return person["cn"]
×
455

456
    def get_info(self, mail):
1✔
457
        """Get info on person with this mail"""
458
        person = self._get_people_by_bzmail().get(mail, None)
1✔
459
        if not person:
1!
460
            person = self.people.get(mail, None)
1✔
461
        return person
1✔
462

463
    def is_under(self, mail, manager):
1✔
464
        """Check if someone is under manager in the hierarchy"""
465
        m = mail
×
466
        while True:
×
467
            m = self.get_manager_mail(m)
×
468
            if m is None:
×
469
                return False
×
470
            if m == manager:
×
471
                return True
×
472

473
    def get_bzmail_from_name(self, name):
1✔
474
        """Search bz mail for a given name"""
475

476
        if "@" in name:
1✔
477
            info = self.get_info(name)
1✔
478
        else:
479
            info = self.get_info_by_nick(name)
1✔
480
            if not info:
1!
481
                info = self.search_by_name(name)
1✔
482

483
        if info:
1✔
484
            mail = info["bugzillaEmail"]
1✔
485
            return mail if mail else info["mail"]
1✔
486

487
        return None
1✔
488

489
    def get_mozmail_from_name(self, name):
1✔
490
        """Search moz mail for a given name"""
491

492
        if "@" in name:
×
493
            info = self.get_info(name)
×
494
        else:
495
            info = self.get_info_by_nick(name)
×
496
            if not info:
×
497
                info = self.search_by_name(name)
×
498

499
        if info:
×
500
            return info["mail"]
×
501

502
        return None
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc