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

mozilla / relman-auto-nag / #4857

19 Dec 2023 05:25PM CUT coverage: 21.812% (-0.09%) from 21.899%
#4857

push

coveralls-python

PromiseFru
Merge branch 'master' of github.com:mozilla/bugbot into performancebug-rule

716 of 3602 branches covered (0.0%)

5 of 8 new or added lines in 2 files covered. (62.5%)

49 existing lines in 2 files now uncovered.

1928 of 8839 relevant lines covered (21.81%)

0.22 hits per line

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

67.44
/bugbot/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 bugbot import logger, utils
1✔
13
from bugbot.components import Components
1✔
14
from bugbot.people import People
1✔
15
from bugbot.round_robin_calendar import (
1✔
16
    BadFallback,
17
    Calendar,
18
    InvalidCalendar,
19
    InvalidDateError,
20
)
21

22

23
class RoundRobin(object):
1✔
24
    _instances: dict = {}
1✔
25

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

39
    @staticmethod
1✔
40
    def get_instance(teams=None):
1✔
UNCOV
41
        if teams is None:
×
UNCOV
42
            if None not in RoundRobin._instances:
×
UNCOV
43
                RoundRobin._instances[None] = RoundRobin()
×
UNCOV
44
            return RoundRobin._instances[None]
×
45

46
        teams = tuple(sorted(teams))
×
47
        if teams not in RoundRobin._instances:
×
48
            RoundRobin._instances[teams] = RoundRobin(teams=teams)
×
49
        return RoundRobin._instances[teams]
×
50

51
    def feed(self, teams: Set[str] | None = None) -> None:
1✔
52
        """Fetch the rotations calendars.
53

54
        Args:
55
            teams: if provided, only calendars for the specified teams will be
56
                fetched.
57
        """
58

59
        self.data = {}
1✔
60
        cache = {}
1✔
61

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

83
                    self.data[component_name] = calendar
1✔
84

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

93
    def get_components(self):
1✔
94
        return list(self.data.keys())
×
95

96
    def get_components_for_triager(self, triager):
1✔
UNCOV
97
        return self.components_by_triager[triager]
×
98

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

108
    def get_fallback(self, bug):
1✔
UNCOV
109
        pc = bug["product"] + "::" + bug["component"]
×
UNCOV
110
        if pc not in self.data:
×
UNCOV
111
            mail = bug.get("triage_owner")
×
112
        else:
UNCOV
113
            cal = self.data[pc]
×
114
            mail = cal.get_fallback_bzmail()
×
115

116
        return self.people.get_moz_mail(mail)
×
117

118
    def get_erroneous_bzmail(self):
1✔
119
        return self.erroneous_bzmail
×
120

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

129
    def get_nick(self, bzmail, prod_comp, cal):
1✔
130
        if bzmail not in self.nicks:
×
131

132
            def handler(user):
×
UNCOV
133
                self.nicks[bzmail] = user["nick"]
×
134

135
            BugzillaUser(user_names=[bzmail], user_handler=handler).wait()
×
136

137
        if bzmail not in self.nicks:
×
138
            self.add_erroneous_bzmail(bzmail, prod_comp, cal)
×
UNCOV
139
            return None
×
140

UNCOV
141
        return self.nicks[bzmail]
×
142

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

151
            if mail is None:
1!
UNCOV
152
                logger.error("No triage owner for {}".format(pc))
×
153

154
            self.add_component_for_triager(pc, mail)
1✔
155

156
            if has_nick:
1!
157
                return mail, nick if only_one else [(mail, nick)]
1✔
UNCOV
158
            return mail if only_one else [mail]
×
159

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

168
        bzmails = []
1✔
169
        for _, p in persons:
1✔
170
            bzmails.append(fb if p is None else p)
1✔
171

172
        self.add_component_for_triager(pc, bzmails)
1✔
173

174
        if only_one:
1!
175
            bzmail = bzmails[0]
1✔
176
            if has_nick:
1!
177
                nick = self.get_nick(bzmail, pc, cal)
1✔
178
                return bzmail, nick
1✔
UNCOV
179
            return bzmail
×
180

UNCOV
181
        if has_nick:
×
UNCOV
182
            return [(bzmail, self.get_nick(bzmail, pc, cal)) for bzmail in bzmails]
×
UNCOV
183
        return bzmails
×
184

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

195
            name = cal.get_team_name()
1✔
196
            fb = cal.get_fallback_mozmail()
1✔
197
            if fb not in fallbacks:
1✔
198
                fallbacks[fb] = {}
1✔
199
            if name not in fallbacks[fb]:
1✔
200
                fallbacks[fb][name] = {"nobody": False, "persons": []}
1✔
201
            info = fallbacks[fb][name]
1✔
202

203
            if not persons:
1!
204
                info["nobody"] = True
1✔
205
            else:
UNCOV
206
                people_names = [n for n, p in persons if p is None]
×
UNCOV
207
                if people_names:
×
UNCOV
208
                    info["persons"] += people_names
×
209
        return fallbacks
1✔
210

211

212
CalendarDef = Dict[str, str]
1✔
213
ComponentCalendarDefs = Dict[str, CalendarDef]
1✔
214
TeamCalendarDefs = Dict[str, ComponentCalendarDefs]
1✔
215

216

217
class RotationDefinitions:
1✔
218
    """Definitions for triage owner rotations"""
219

220
    def __init__(self) -> None:
1✔
UNCOV
221
        self.definitions_url = utils.get_private()["round_robin_sheet"]
×
UNCOV
222
        self.components = Components.get_instance()
×
223

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

230
    def fetch_by_teams(self) -> TeamCalendarDefs:
1✔
231
        """Fetch the triage owner rotation definitions and group them by team
232

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

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

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

267
            if team_name in teams:
1✔
268
                component_calendar = teams[team_name]
1✔
269
            else:
270
                component_calendar = teams[team_name] = {}
1✔
271

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

282
            for component_name in components_to_add:
1✔
283
                component_calendar[component_name] = {
1✔
284
                    "fallback": fallback_triager,
285
                    "url": calendar_url,
286
                }
287

288
        return teams
1✔
289

290
    def get_definitions_records(self) -> list:
1✔
291
        """Fetch the triage owner rotation definitions."""
UNCOV
292
        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