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

mozilla / relman-auto-nag / #4350

pending completion
#4350

push

coveralls-python

web-flow
Update the protocol from git to https in Readme file (#1984)

563 of 3079 branches covered (18.29%)

1795 of 7971 relevant lines covered (22.52%)

0.23 hits per line

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

62.6
/auto_nag/round_robin.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
from typing import Dict, Set
1✔
6

7
import gspread
1✔
8
from dateutil.relativedelta import relativedelta
1✔
9
from libmozdata import utils as lmdutils
1✔
10
from libmozdata.bugzilla import BugzillaUser
1✔
11

12
from auto_nag import logger, utils
1✔
13
from auto_nag.components import Components
1✔
14
from auto_nag.people import People
1✔
15
from auto_nag.round_robin_calendar import BadFallback, Calendar, InvalidCalendar
1✔
16

17

18
class RoundRobin(object):
1✔
19

20
    _instances: dict = {}
1✔
21

22
    def __init__(self, rotation_definitions=None, people=None, teams=None):
1✔
23
        self.people = People.get_instance() if people is None else people
1✔
24
        self.components_by_triager: Dict[str, list] = {}
1✔
25
        self.rotation_definitions = (
1✔
26
            RotationDefinitions()
27
            if rotation_definitions is None
28
            else rotation_definitions
29
        )
30
        self.feed(None if teams is None else set(teams))
1✔
31
        self.nicks = {}
1✔
32
        self.erroneous_bzmail = {}
1✔
33
        utils.init_random()
1✔
34

35
    @staticmethod
1✔
36
    def get_instance(teams=None):
1✔
37
        if teams is None:
×
38
            if None not in RoundRobin._instances:
×
39
                RoundRobin._instances[None] = RoundRobin()
×
40
            return RoundRobin._instances[None]
×
41

42
        teams = tuple(sorted(teams))
×
43
        if teams not in RoundRobin._instances:
×
44
            RoundRobin._instances[teams] = RoundRobin(teams=teams)
×
45
        return RoundRobin._instances[teams]
×
46

47
    def feed(self, teams: Set[str] = None) -> None:
1✔
48
        """Fetch the rotations calendars.
49

50
        Args:
51
            teams: if provided, only calendars for the specified teams will be
52
                fetched.
53
        """
54

55
        self.data = {}
1✔
56
        cache = {}
1✔
57

58
        team_calendars = self.rotation_definitions.fetch_by_teams()
1✔
59
        for team_name, components in team_calendars.items():
1✔
60
            if teams is not None and team_name not in teams:
1!
61
                continue
×
62
            try:
1✔
63
                for component_name, calendar_info in components.items():
1✔
64
                    url = calendar_info["url"]
1✔
65
                    if url not in cache:
1✔
66
                        calendar = cache[url] = Calendar.get(
1✔
67
                            url,
68
                            calendar_info["fallback"],
69
                            team_name,
70
                            people=self.people,
71
                        )
72
                    else:
73
                        calendar = cache[url]
1✔
74
                        if calendar.get_fallback() != calendar_info["fallback"]:
1!
75
                            raise BadFallback(
×
76
                                "Cannot have different fallback triagers for the same calendar"
77
                            )
78

79
                    self.data[component_name] = calendar
1✔
80

81
            except (BadFallback, InvalidCalendar) as err:
×
82
                logger.error(err)
×
83
                # If one the team's calendars failed, it is better to fail loud,
84
                # and disable all team's calendars.
85
                for component_name in components:
×
86
                    if component_name in self.data:
×
87
                        del self.data[component_name]
×
88

89
    def get_components(self):
1✔
90
        return list(self.data.keys())
×
91

92
    def get_components_for_triager(self, triager):
1✔
93
        return self.components_by_triager[triager]
×
94

95
    def add_component_for_triager(self, component, triagers):
1✔
96
        if not isinstance(triagers, list):
1✔
97
            triagers = [triagers]
1✔
98
        for triager in triagers:
1✔
99
            if triager in self.components_by_triager:
1✔
100
                self.components_by_triager[triager].add(component)
1✔
101
            else:
102
                self.components_by_triager[triager] = {component}
1✔
103

104
    def get_fallback(self, bug):
1✔
105
        pc = bug["product"] + "::" + bug["component"]
×
106
        if pc not in self.data:
×
107
            mail = bug.get("triage_owner")
×
108
        else:
109
            cal = self.data[pc]
×
110
            mail = cal.get_fallback_bzmail()
×
111

112
        return self.people.get_moz_mail(mail)
×
113

114
    def get_erroneous_bzmail(self):
1✔
115
        return self.erroneous_bzmail
×
116

117
    def add_erroneous_bzmail(self, bzmail, prod_comp, cal):
1✔
118
        logger.error(f"No nick for {bzmail} for {prod_comp}")
×
119
        fb = cal.get_fallback_mozmail()
×
120
        if fb not in self.erroneous_bzmail:
×
121
            self.erroneous_bzmail[fb] = {bzmail}
×
122
        else:
123
            self.erroneous_bzmail[fb].add(bzmail)
×
124

125
    def get_nick(self, bzmail, prod_comp, cal):
1✔
126
        if bzmail not in self.nicks:
×
127

128
            def handler(user):
×
129
                self.nicks[bzmail] = user["nick"]
×
130

131
            BugzillaUser(user_names=[bzmail], user_handler=handler).wait()
×
132

133
        if bzmail not in self.nicks:
×
134
            self.add_erroneous_bzmail(bzmail, prod_comp, cal)
×
135
            return None
×
136

137
        return self.nicks[bzmail]
×
138

139
    def get(self, bug, date, only_one=True, has_nick=True):
1✔
140
        pc = bug["product"] + "::" + bug["component"]
1✔
141
        if pc not in self.data:
1✔
142
            mail = bug.get("triage_owner")
1✔
143
            nick = bug.get("triage_owner_detail", {}).get("nick")
1✔
144
            if utils.is_no_assignee(mail):
1!
145
                mail, nick = None, None
×
146

147
            if mail is None:
1!
148
                logger.error("No triage owner for {}".format(pc))
×
149

150
            self.add_component_for_triager(pc, mail)
1✔
151

152
            if has_nick:
1!
153
                return mail, nick if only_one else [(mail, nick)]
1✔
154
            return mail if only_one else [mail]
×
155

156
        cal = self.data[pc]
1✔
157
        persons = cal.get_persons(date)
1✔
158
        fb = cal.get_fallback_bzmail()
1✔
159
        if not persons or all(p is None for _, p in persons):
1!
160
            # the fallback is the triage owner
161
            self.add_component_for_triager(pc, [fb])
1✔
162
            return (fb, self.get_nick(fb, pc, cal)) if has_nick else fb
1✔
163

164
        bzmails = []
1✔
165
        for _, p in persons:
1✔
166
            bzmails.append(fb if p is None else p)
1✔
167

168
        self.add_component_for_triager(pc, bzmails)
1✔
169

170
        if only_one:
1!
171
            bzmail = bzmails[0]
1✔
172
            if has_nick:
1!
173
                nick = self.get_nick(bzmail, pc, cal)
1✔
174
                return bzmail, nick
1✔
175
            return bzmail
×
176

177
        if has_nick:
×
178
            return [(bzmail, self.get_nick(bzmail, pc, cal)) for bzmail in bzmails]
×
179
        return bzmails
×
180

181
    def get_who_to_nag(self, date):
1✔
182
        fallbacks = {}
1✔
183
        date = lmdutils.get_date_ymd(date)
1✔
184
        days = utils.get_config("round-robin", "days_to_nag", 7)
1✔
185
        next_date = date + relativedelta(days=days)
1✔
186
        for cal in set(self.data.values()):
1✔
187
            persons = cal.get_persons(next_date)
1✔
188
            if persons and all(p is not None for _, p in persons):
1✔
189
                continue
1✔
190

191
            name = cal.get_team_name()
1✔
192
            fb = cal.get_fallback_mozmail()
1✔
193
            if fb not in fallbacks:
1✔
194
                fallbacks[fb] = {}
1✔
195
            if name not in fallbacks[fb]:
1✔
196
                fallbacks[fb][name] = {"nobody": False, "persons": []}
1✔
197
            info = fallbacks[fb][name]
1✔
198

199
            if not persons:
1!
200
                info["nobody"] = True
1✔
201
            else:
202
                people_names = [n for n, p in persons if p is None]
×
203
                if people_names:
×
204
                    info["persons"] += people_names
×
205
        return fallbacks
1✔
206

207

208
CalendarDef = Dict[str, str]
1✔
209
ComponentCalendarDefs = Dict[str, CalendarDef]
1✔
210
TeamCalendarDefs = Dict[str, ComponentCalendarDefs]
1✔
211

212

213
class RotationDefinitions:
1✔
214
    """Definitions for triage owner rotations"""
215

216
    def __init__(self) -> None:
1✔
217
        self.definitions_url = utils.get_private()["round_robin_sheet"]
×
218
        self.components = Components.get_instance()
×
219

220
        gc = gspread.service_account_from_dict(utils.get_gcp_service_account_info())
×
221
        # The spreadsheet key should match the one listed in the Firefox Source Docs:
222
        # https://firefox-source-docs.mozilla.org/bug-mgmt/policies/triage-bugzilla.html#rotating-triage
223
        doc = gc.open_by_key("1EK6iCtdD8KP4UflIHscuZo6W5er2vy_TX7vsmaaBVd4")
×
224
        self.sheet = doc.worksheet("definitions")
×
225

226
    def fetch_by_teams(self) -> TeamCalendarDefs:
1✔
227
        """Fetch the triage owner rotation definitions and group them by team
228

229
        Returns:
230
            A dictionary that maps each component to its calendar and fallback
231
            person. The components are grouped by their teams. The following is
232
            the shape of the returned dictionary:
233
            {
234
                team_name: {
235
                    component_name:{
236
                            "fallback": "the name of the fallback person",
237
                            "calendar": "the URL for the rotation calendar"
238
                    }
239
                    ...
240
                }
241
                ...
242
            }
243
        """
244

245
        teams: TeamCalendarDefs = {}
1✔
246
        seen = set()
1✔
247
        for row in self.get_definitions_records():
1✔
248
            team_name = row["Team Name"]
1✔
249
            scope = row["Calendar Scope"]
1✔
250
            fallback_triager = row["Fallback Triager"]
1✔
251
            calendar_url = row["Calendar URL"]
1✔
252

253
            if (team_name, scope) in seen:
1!
254
                logger.error(
×
255
                    "The triage owner rotation definitions show more than one "
256
                    "entry for the %s team with the component scope '%s'",
257
                    team_name,
258
                    scope,
259
                )
260
            else:
261
                seen.add((team_name, scope))
1✔
262

263
            if team_name in teams:
1✔
264
                component_calendar = teams[team_name]
1✔
265
            else:
266
                component_calendar = teams[team_name] = {}
1✔
267

268
            if scope == "All Team's Components":
1!
269
                team_components = self.components.get_team_components(team_name)
×
270
                components_to_add = [
×
271
                    str(component_name)
272
                    for component_name in team_components
273
                    if str(component_name) not in component_calendar
274
                ]
275
            else:
276
                components_to_add = [scope]
1✔
277

278
            for component_name in components_to_add:
1✔
279
                component_calendar[component_name] = {
1✔
280
                    "fallback": fallback_triager,
281
                    "url": calendar_url,
282
                }
283

284
        return teams
1✔
285

286
    def get_definitions_records(self) -> list:
1✔
287
        """Fetch the triage owner rotation definitions."""
288
        return self.sheet.get_all_records()
×
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