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

mozilla / relman-auto-nag / #5112

21 Jun 2024 06:38PM CUT coverage: 21.722% (-0.2%) from 21.876%
#5112

push

coveralls-python

benjaminmah
Added check for inactive patch authors

716 of 3628 branches covered (19.74%)

0 of 12 new or added lines in 1 file covered. (0.0%)

144 existing lines in 3 files now uncovered.

1933 of 8899 relevant lines covered (21.72%)

0.22 hits per line

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

41.58
/bugbot/bzcleaner.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 argparse
1✔
6
import logging
1✔
7
import os
1✔
8
import sys
1✔
9
import time
1✔
10
from collections import defaultdict
1✔
11
from datetime import datetime
1✔
12
from typing import Dict, List
1✔
13

14
from dateutil.relativedelta import relativedelta
1✔
15
from jinja2 import Environment, FileSystemLoader, Template
1✔
16
from libmozdata import config
1✔
17
from libmozdata import utils as lmdutils
1✔
18
from libmozdata.bugzilla import Bugzilla
1✔
19

20
from bugbot import db, logger, mail, utils
1✔
21
from bugbot.cache import Cache
1✔
22
from bugbot.nag_me import Nag
1✔
23

24

25
class TooManyChangesError(Exception):
1✔
26
    """Exception raised when the rule is trying to apply too many changes"""
27

28
    def __init__(self, bugs, changes, max_changes):
1✔
29
        message = f"The rule has been aborted because it was attempting to apply changes on {len(changes)} bugs. Max is {max_changes}."
×
30
        super().__init__(message)
×
31
        self.bugs = bugs
×
32
        self.changes = changes
×
33

34

35
class SilentBugzilla(Bugzilla):
1✔
36
    """Same as Bugzilla but using an account that does not trigger bugmail"""
37

38
    TOKEN = config.get("Bugzilla", "nomail-token", "")
1✔
39

40

41
class BzCleaner(object):
1✔
42
    """
43
    Attributes:
44
        no_bugmail: If `True`, a token for an account that does not trigger
45
            bugmail will be used when performing `PUT` actions on Bugzilla.
46
        normal_changes_max: The maximum number of changes that could be made in
47
            a normal situation. If exceeded, the rule will fail.
48
    """
49

50
    no_bugmail: bool = False
1✔
51
    normal_changes_max: int = 50
1✔
52

53
    def __init__(self):
1✔
54
        super(BzCleaner, self).__init__()
1✔
55
        self._set_rule_name()
1✔
56
        self.apply_autofix = True
1✔
57
        self.has_autofix = False
1✔
58
        self.autofix_changes = {}
1✔
59
        self.quota_actions = defaultdict(list)
1✔
60
        self.no_manager = set()
1✔
61
        self.auto_needinfo = {}
1✔
62
        self.has_flags = False
1✔
63
        self.cache = Cache(self.name(), self.max_days_in_cache())
1✔
64
        self.test_mode = utils.get_config("common", "test", False)
1✔
65
        self.versions = None
1✔
66
        logger.info("Run rule {}".format(self.get_rule_path()))
1✔
67

68
    def _set_rule_name(self):
1✔
69
        module = sys.modules[self.__class__.__module__]
1✔
70
        base = os.path.dirname(__file__)
1✔
71
        rules = os.path.join(base, "rules")
1✔
72
        self.__rule_path__ = os.path.relpath(module.__file__, rules)
1✔
73
        name = os.path.basename(module.__file__)
1✔
74
        name = os.path.splitext(name)[0]
1✔
75
        self.__rule_name__ = name
1✔
76

77
    def init_versions(self):
1✔
78
        self.versions = utils.get_checked_versions()
1✔
79
        return bool(self.versions)
1✔
80

81
    def max_days_in_cache(self):
1✔
82
        """Get the max number of days the data must be kept in cache"""
83
        return self.get_config("max_days_in_cache", -1)
1✔
84

85
    def description(self):
1✔
86
        """Get the description for the help"""
87
        return ""
1✔
88

89
    def name(self):
1✔
90
        """Get the rule name"""
91
        return self.__rule_name__
1✔
92

93
    def get_rule_path(self):
1✔
94
        """Get the rule path"""
95
        return self.__rule_path__
1✔
96

97
    def needinfo_template_name(self):
1✔
98
        """Get the txt template filename"""
99
        return self.name() + "_needinfo.txt"
×
100

101
    def template(self):
1✔
102
        """Get the html template filename"""
103
        return self.name() + ".html"
1✔
104

105
    def subject(self):
1✔
106
        """Get the partial email subject"""
107
        return self.description()
1✔
108

109
    def get_email_subject(self, date):
1✔
110
        """Get the email subject with a date or not"""
111
        af = "[autofix]" if self.has_autofix else ""
1✔
112
        if date:
1!
113
            return "[bugbot]{} {} for the {}".format(af, self.subject(), date)
×
114
        return "[bugbot]{} {}".format(af, self.subject())
1✔
115

116
    def ignore_date(self):
1✔
117
        """Should we ignore the date ?"""
118
        return False
1✔
119

120
    def must_run(self, date):
1✔
121
        """Check if the rule must run for this date"""
122
        days = self.get_config("must_run", None)
×
123
        if not days:
×
124
            return True
×
125
        weekday = date.weekday()
×
126
        week = utils.get_weekdays()
×
127
        for day in days:
×
128
            if week[day] == weekday:
×
129
                return True
×
130
        return False
×
131

132
    def has_enough_data(self):
1✔
133
        """Check if the rule has enough data to run"""
134
        if self.versions is None:
1!
135
            # init_versions() has never been called
136
            return True
1✔
137
        return bool(self.versions)
×
138

139
    def filter_no_nag_keyword(self):
1✔
140
        """If True, then remove the bugs with [no-nag] in whiteboard from the bug list"""
141
        return True
1✔
142

143
    def add_no_manager(self, bugid):
1✔
144
        self.no_manager.add(str(bugid))
×
145

146
    def has_assignee(self):
1✔
147
        return False
1✔
148

149
    def has_needinfo(self):
1✔
150
        return False
1✔
151

152
    def get_mail_to_auto_ni(self, bug):
1✔
153
        return None
1✔
154

155
    def all_include_fields(self):
1✔
156
        return False
1✔
157

158
    def get_max_ni(self):
1✔
159
        return -1
×
160

161
    def get_max_actions(self):
1✔
162
        return -1
×
163

164
    def exclude_no_action_bugs(self):
1✔
165
        """
166
        If `True`, then remove bugs that have no actions from the email (e.g.,
167
        needinfo got ignored due to exceeding the limit). This is applied only
168
        when using the `add_prioritized_action()` method.
169

170
        Returning `False` could be useful if we want to list all actions the rule
171
        would do if it had no limits.
172
        """
173
        return True
×
174

175
    def ignore_meta(self):
1✔
176
        return False
1✔
177

178
    def columns(self):
1✔
179
        """The fields to get for the columns in email report"""
180
        return ["id", "summary"]
×
181

182
    def sort_columns(self):
1✔
183
        """Returns the key to sort columns"""
184
        return None
×
185

186
    def get_dates(self, date):
1✔
187
        """Get the dates for the bugzilla query (changedafter and changedbefore fields)"""
188
        date = lmdutils.get_date_ymd(date)
1✔
189
        lookup = self.get_config("days_lookup", 7)
1✔
190
        start_date = date - relativedelta(days=lookup)
1✔
191
        end_date = date + relativedelta(days=1)
1✔
192

193
        return start_date, end_date
1✔
194

195
    def get_extra_for_template(self):
1✔
196
        """Get extra data to put in the template"""
197
        return {}
×
198

199
    def get_extra_for_needinfo_template(self):
1✔
200
        """Get extra data to put in the needinfo template"""
201
        return {}
×
202

203
    def get_config(self, entry, default=None):
1✔
204
        return utils.get_config(self.name(), entry, default=default)
1✔
205

206
    def get_bz_params(self, date):
1✔
207
        """Get the Bugzilla parameters for the search query"""
208
        return {}
×
209

210
    def get_data(self):
1✔
211
        """Get the data structure to use in the bughandler"""
212
        return {}
1✔
213

214
    def get_summary(self, bug):
1✔
215
        return "..." if bug["groups"] else bug["summary"]
1✔
216

217
    def get_cc_emails(self, data):
1✔
218
        return []
×
219

220
    def has_default_products(self):
1✔
221
        return True
1✔
222

223
    def has_product_component(self):
1✔
224
        return False
1✔
225

226
    def get_product_component(self):
1✔
227
        return self.prod_comp
×
228

229
    def has_access_to_sec_bugs(self):
1✔
230
        return self.get_config("sec", True)
1✔
231

232
    def handle_bug(self, bug, data):
1✔
233
        """Implement this function to get all the bugs from the query"""
234
        return bug
1✔
235

236
    def get_db_extra(self):
1✔
237
        """Get extra information required for db insertion"""
238
        return {
×
239
            bugid: ni_mail
240
            for ni_mail, v in self.auto_needinfo.items()
241
            for bugid in v["bugids"]
242
        }
243

244
    def get_auto_ni_skiplist(self):
1✔
245
        """Return a set of email addresses that should never be needinfoed"""
246
        return set(self.get_config("needinfo_skiplist", default=[]))
×
247

248
    def add_auto_ni(self, bugid, data):
1✔
249
        if not data:
1!
250
            return False
1✔
251

252
        ni_mail = data["mail"]
×
253
        if ni_mail in self.get_auto_ni_skiplist() or utils.is_no_assignee(ni_mail):
×
254
            return False
×
255
        if ni_mail in self.auto_needinfo:
×
256
            max_ni = self.get_max_ni()
×
257
            info = self.auto_needinfo[ni_mail]
×
258
            if max_ni > 0 and len(info["bugids"]) >= max_ni:
×
259
                return False
×
260
            info["bugids"].append(str(bugid))
×
261
        else:
262
            self.auto_needinfo[ni_mail] = {
×
263
                "nickname": data["nickname"],
264
                "bugids": [str(bugid)],
265
            }
266
        return True
×
267

268
    def add_prioritized_action(self, bug, quota_name, needinfo=None, autofix=None):
1✔
269
        """
270
        - `quota_name` is the key used to apply the limits, e.g., triage owner, team, or component
271
        """
272
        assert needinfo or autofix
×
273

274
        # Avoid having more than one ni from our bot
275
        if needinfo and self.has_bot_set_ni(bug):
×
276
            needinfo = autofix = None
×
277

278
        action = {
×
279
            "bug": bug,
280
            "needinfo": needinfo,
281
            "autofix": autofix,
282
        }
283

284
        self.quota_actions[quota_name].append(action)
×
285

286
    def get_bug_sort_key(self, bug):
1✔
287
        return None
×
288

289
    def _populate_prioritized_actions(self, bugs):
1✔
290
        max_actions = self.get_max_actions()
×
291
        max_ni = self.get_max_ni()
×
292
        exclude_no_action_bugs = (
×
293
            len(self.quota_actions) > 0 and self.exclude_no_action_bugs()
294
        )
295
        bugs_with_action = set()
×
296

297
        for actions in self.quota_actions.values():
×
298
            if len(actions) > max_ni or len(actions) > max_actions:
×
299
                actions.sort(
×
300
                    key=lambda action: (
301
                        not action["needinfo"],
302
                        self.get_bug_sort_key(action["bug"]),
303
                    )
304
                )
305

306
            ni_count = 0
×
307
            actions_count = 0
×
308
            for action in actions:
×
309
                bugid = str(action["bug"]["id"])
×
310
                if max_actions > 0 and actions_count >= max_actions:
×
311
                    break
×
312

313
                if action["needinfo"]:
×
314
                    if max_ni > 0 and ni_count >= max_ni:
×
315
                        continue
×
316

317
                    ok = self.add_auto_ni(bugid, action["needinfo"])
×
318
                    if not ok:
×
319
                        # If we can't needinfo, we do not add the autofix
320
                        continue
×
321

322
                    if "extra" in action["needinfo"]:
×
323
                        self.extra_ni[bugid] = action["needinfo"]["extra"]
×
324

325
                    bugs_with_action.add(bugid)
×
326
                    ni_count += 1
×
327

328
                if action["autofix"]:
×
329
                    assert bugid not in self.autofix_changes
×
330
                    self.autofix_changes[bugid] = action["autofix"]
×
331
                    bugs_with_action.add(bugid)
×
332

333
                if action["autofix"] or action["needinfo"]:
×
334
                    actions_count += 1
×
335

336
        if exclude_no_action_bugs:
×
337
            bugs = {id: bug for id, bug in bugs.items() if id in bugs_with_action}
×
338

339
        return bugs
×
340

341
    def bughandler(self, bug, data):
1✔
342
        """bug handler for the Bugzilla query"""
343
        if bug["id"] in self.cache:
1!
344
            return
×
345

346
        if self.handle_bug(bug, data) is None:
1!
347
            return
×
348

349
        bugid = str(bug["id"])
1✔
350
        res = {"id": bugid}
1✔
351

352
        auto_ni = self.get_mail_to_auto_ni(bug)
1✔
353
        self.add_auto_ni(bugid, auto_ni)
1✔
354

355
        res["summary"] = self.get_summary(bug)
1✔
356

357
        if self.has_assignee():
1!
358
            res["assignee"] = utils.get_name_from_user_detail(bug["assigned_to_detail"])
×
359

360
        if self.has_needinfo():
1!
361
            s = set()
×
362
            for flag in utils.get_needinfo(bug):
×
363
                s.add(flag["requestee"])
×
364
            res["needinfos"] = sorted(s)
×
365

366
        if self.has_product_component():
1!
367
            for k in ["product", "component"]:
×
368
                res[k] = bug[k]
×
369

370
        if isinstance(self, Nag):
1!
371
            bug = self.set_people_to_nag(bug, res)
×
372
            if not bug:
×
373
                return
×
374

375
        if bugid in data:
1!
376
            data[bugid].update(res)
×
377
        else:
378
            data[bugid] = res
1✔
379

380
    def get_products(self):
1✔
381
        return self.get_config("products") + self.get_config("additional_products", [])
1✔
382

383
    def amend_bzparams(self, params, bug_ids):
1✔
384
        """Amend the Bugzilla params"""
385
        if not self.all_include_fields():
1!
386
            if "include_fields" in params:
1!
UNCOV
387
                fields = params["include_fields"]
×
UNCOV
388
                if isinstance(fields, list):
×
UNCOV
389
                    if "id" not in fields:
×
UNCOV
390
                        fields.append("id")
×
UNCOV
391
                elif isinstance(fields, str):
×
UNCOV
392
                    if fields != "id":
×
393
                        params["include_fields"] = [fields, "id"]
×
394
                else:
395
                    params["include_fields"] = [fields, "id"]
×
396
            else:
397
                params["include_fields"] = ["id"]
1✔
398

399
            params["include_fields"] += ["summary", "groups"]
1✔
400

401
            if self.has_assignee() and "assigned_to" not in params["include_fields"]:
1!
UNCOV
402
                params["include_fields"].append("assigned_to")
×
403

404
            if self.has_product_component():
1!
UNCOV
405
                if "product" not in params["include_fields"]:
×
UNCOV
406
                    params["include_fields"].append("product")
×
UNCOV
407
                if "component" not in params["include_fields"]:
×
408
                    params["include_fields"].append("component")
×
409

410
            if self.has_needinfo() and "flags" not in params["include_fields"]:
1!
411
                params["include_fields"].append("flags")
×
412

413
        if bug_ids:
1!
414
            params["bug_id"] = bug_ids
1✔
415

416
        if self.filter_no_nag_keyword():
1!
417
            n = utils.get_last_field_num(params)
1✔
418
            params.update(
1✔
419
                {
420
                    "f" + n: "status_whiteboard",
421
                    "o" + n: "notsubstring",
422
                    "v" + n: "[no-nag]",
423
                }
424
            )
425

426
        if self.ignore_meta():
1!
UNCOV
427
            n = utils.get_last_field_num(params)
×
UNCOV
428
            params.update({"f" + n: "keywords", "o" + n: "nowords", "v" + n: "meta"})
×
429

430
        if self.has_default_products():
1!
431
            params["product"] = self.get_products()
1✔
432

433
        if not self.has_access_to_sec_bugs():
1!
434
            n = utils.get_last_field_num(params)
×
UNCOV
435
            params.update({"f" + n: "bug_group", "o" + n: "isempty"})
×
436

437
        self.has_flags = "flags" in params.get("include_fields", [])
1✔
438

439
    def get_bugs(self, date="today", bug_ids=[], chunk_size=None):
1✔
440
        """Get the bugs"""
441
        bugs = self.get_data()
1✔
442
        params = self.get_bz_params(date)
1✔
443
        self.amend_bzparams(params, bug_ids)
1✔
444
        self.query_url = utils.get_bz_search_url(params)
1✔
445

446
        if isinstance(self, Nag):
1!
UNCOV
447
            self.query_params: dict = params
×
448

449
        old_CHUNK_SIZE = Bugzilla.BUGZILLA_CHUNK_SIZE
1✔
450
        try:
1✔
451
            if chunk_size:
1!
UNCOV
452
                Bugzilla.BUGZILLA_CHUNK_SIZE = chunk_size
×
453

454
            Bugzilla(
1✔
455
                params,
456
                bughandler=self.bughandler,
457
                bugdata=bugs,
458
                timeout=self.get_config("bz_query_timeout"),
459
            ).get_data().wait()
460
        finally:
461
            Bugzilla.BUGZILLA_CHUNK_SIZE = old_CHUNK_SIZE
1✔
462

463
        self.get_comments(bugs)
1✔
464

465
        return bugs
1✔
466

467
    def commenthandler(self, bug, bugid, data):
1✔
UNCOV
468
        return
×
469

470
    def _commenthandler(self, bug, bugid, data):
1✔
UNCOV
471
        comments = bug["comments"]
×
UNCOV
472
        bugid = str(bugid)
×
UNCOV
473
        if self.has_last_comment_time():
×
474
            if comments:
×
UNCOV
475
                data[bugid]["last_comment"] = utils.get_human_lag(comments[-1]["time"])
×
476
            else:
477
                data[bugid]["last_comment"] = ""
×
478

479
        self.commenthandler(bug, bugid, data)
×
480

481
    def get_comments(self, bugs):
1✔
482
        """Get the bugs comments"""
483
        if self.has_last_comment_time():
1!
UNCOV
484
            bugids = self.get_list_bugs(bugs)
×
485
            Bugzilla(
×
486
                bugids=bugids, commenthandler=self._commenthandler, commentdata=bugs
487
            ).get_data().wait()
488
        return bugs
1✔
489

490
    def has_last_comment_time(self):
1✔
491
        return False
1✔
492

493
    def get_list_bugs(self, bugs):
1✔
UNCOV
494
        return [x["id"] for x in bugs.values()]
×
495

496
    def get_documentation(self):
1✔
497
        return "For more information, please visit [BugBot documentation](https://wiki.mozilla.org/BugBot#{}).".format(
1✔
498
            self.get_rule_path().replace("/", ".2F")
499
        )
500

501
    def has_bot_set_ni(self, bug):
1✔
UNCOV
502
        if not self.has_flags:
×
UNCOV
503
            raise Exception
×
UNCOV
504
        return utils.has_bot_set_ni(bug)
×
505

506
    def set_needinfo(self):
1✔
UNCOV
507
        if not self.auto_needinfo:
×
508
            return {}
×
509

510
        template = self.get_needinfo_template()
×
UNCOV
511
        res = {}
×
512

513
        doc = self.get_documentation()
×
514

UNCOV
515
        for ni_mail, info in self.auto_needinfo.items():
×
516
            nick = info["nickname"]
×
517
            for bugid in info["bugids"]:
×
UNCOV
518
                data = {
×
519
                    "comment": {"body": ""},
520
                    "flags": [
521
                        {
522
                            "name": "needinfo",
523
                            "requestee": ni_mail,
524
                            "status": "?",
525
                            "new": "true",
526
                        }
527
                    ],
528
                }
529

UNCOV
530
                comment = None
×
UNCOV
531
                if nick:
×
UNCOV
532
                    comment = template.render(
×
533
                        nickname=nick,
534
                        extra=self.get_extra_for_needinfo_template(),
535
                        plural=utils.plural,
536
                        bugid=bugid,
537
                        documentation=doc,
538
                    )
UNCOV
539
                    comment = comment.strip() + "\n"
×
UNCOV
540
                    data["comment"]["body"] = comment
×
541

UNCOV
542
                if bugid not in res:
×
UNCOV
543
                    res[bugid] = data
×
544
                else:
545
                    res[bugid]["flags"] += data["flags"]
×
546
                    if comment:
×
UNCOV
547
                        res[bugid]["comment"]["body"] = comment
×
548

549
        return res
×
550

551
    def get_needinfo_template(self) -> Template:
1✔
552
        """Get a template to render needinfo comment body"""
553

UNCOV
554
        template_name = self.needinfo_template_name()
×
555
        assert bool(template_name)
×
UNCOV
556
        env = Environment(loader=FileSystemLoader("templates"))
×
UNCOV
557
        template = env.get_template(template_name)
×
558

UNCOV
559
        return template
×
560

561
    def has_individual_autofix(self, changes):
1✔
562
        # check if we have a dictionary with bug numbers as keys
563
        # return True if all the keys are bug number
564
        # (which means that each bug has its own autofix)
565
        return changes and all(
1✔
566
            isinstance(bugid, int) or bugid.isdigit() for bugid in changes
567
        )
568

569
    def get_autofix_change(self):
1✔
570
        """Get the change to do to autofix the bugs"""
571
        return self.autofix_changes
1✔
572

573
    def autofix(self, bugs):
1✔
574
        """Autofix the bugs according to what is returned by get_autofix_change"""
UNCOV
575
        ni_changes = self.set_needinfo()
×
UNCOV
576
        change = self.get_autofix_change()
×
577

UNCOV
578
        if not ni_changes and not change:
×
UNCOV
579
            return bugs
×
580

581
        self.has_autofix = True
×
582
        new_changes = {}
×
UNCOV
583
        if not self.has_individual_autofix(change):
×
584
            bugids = self.get_list_bugs(bugs)
×
585
            for bugid in bugids:
×
UNCOV
586
                mrg = utils.merge_bz_changes(change, ni_changes.get(bugid, {}))
×
587
                if mrg:
×
588
                    new_changes[bugid] = mrg
×
589
        else:
590
            change = {str(k): v for k, v in change.items()}
×
591
            bugids = set(change.keys()) | set(ni_changes.keys())
×
592
            for bugid in bugids:
×
593
                mrg = utils.merge_bz_changes(
×
594
                    change.get(bugid, {}), ni_changes.get(bugid, {})
595
                )
596
                if mrg:
×
597
                    new_changes[bugid] = mrg
×
598

599
        if not self.apply_autofix:
×
UNCOV
600
            self.autofix_changes = new_changes
×
UNCOV
601
            return bugs
×
602

603
        extra = self.get_db_extra()
×
604

605
        if self.is_limited and len(new_changes) > self.normal_changes_max:
×
606
            raise TooManyChangesError(bugs, new_changes, self.normal_changes_max)
×
607

UNCOV
608
        self.apply_changes_on_bugzilla(
×
609
            self.name(),
610
            new_changes,
611
            self.no_bugmail,
612
            self.dryrun or self.test_mode,
613
            extra,
614
        )
615

UNCOV
616
        return bugs
×
617

618
    @staticmethod
1✔
619
    def apply_changes_on_bugzilla(
1✔
620
        rule_name: str,
621
        new_changes: Dict[str, dict],
622
        no_bugmail: bool = False,
623
        is_dryrun: bool = True,
624
        db_extra: Dict[str, str] | None = None,
625
    ) -> None:
626
        """Apply changes on Bugzilla
627

628
        Args:
629
            rule_name: the name of the rule that is performing the changes.
630
            new_changes: the changes that will be performed. The dictionary key
631
                should be the bug ID.
632
            no_bugmail: If True, an account that doesn't trigger bugmail will be
633
                used to apply the changes.
634
            is_dryrun: If True, no changes will be applied. Instead, the
635
                proposed changes will be logged.
636
            db_extra: extra data to be passed to the DB. The dictionary key
637
                should be the bug ID.
638
        """
UNCOV
639
        if is_dryrun:
×
UNCOV
640
            for bugid, ch in new_changes.items():
×
UNCOV
641
                logger.info("The bugs: %s\n will be autofixed with:\n%s", bugid, ch)
×
UNCOV
642
            return None
×
643

UNCOV
644
        if db_extra is None:
×
645
            db_extra = {}
×
646

647
        max_retries = utils.get_config("common", "bugzilla_max_retries", 3)
×
648
        bugzilla_cls = SilentBugzilla if no_bugmail else Bugzilla
×
649

650
        for bugid, ch in new_changes.items():
×
651
            added = False
×
UNCOV
652
            for _ in range(max_retries):
×
653
                failures = bugzilla_cls([str(bugid)]).put(ch)
×
654
                if failures:
×
UNCOV
655
                    time.sleep(1)
×
656
                else:
657
                    added = True
×
658
                    db.BugChange.add(rule_name, bugid, extra=db_extra.get(bugid, ""))
×
659
                    break
×
660
            if not added:
×
661
                logger.error(
×
662
                    "%s: Cannot put data for bug %s (change => %s): %s",
663
                    rule_name,
664
                    bugid,
665
                    ch,
666
                    failures,
667
                )
668

669
    def terminate(self):
1✔
670
        """Called when everything is done"""
UNCOV
671
        return
×
672

673
    def organize(self, bugs):
1✔
UNCOV
674
        return utils.organize(bugs, self.columns(), key=self.sort_columns())
×
675

676
    def add_to_cache(self, bugs):
1✔
677
        """Add the bug keys to cache"""
UNCOV
678
        if isinstance(bugs, dict):
×
UNCOV
679
            self.cache.add(bugs.keys())
×
680
        else:
UNCOV
681
            self.cache.add(bugs)
×
682

683
    def get_email_data(self, date: str) -> List[dict]:
1✔
684
        bugs = self.get_bugs(date=date)
×
685
        bugs = self._populate_prioritized_actions(bugs)
×
UNCOV
686
        bugs = self.autofix(bugs)
×
687
        self.add_to_cache(bugs)
×
UNCOV
688
        if not bugs:
×
UNCOV
689
            return []
×
690

691
        return self.organize(bugs)
×
692

693
    def get_email(self, date: str, data: dict, preamble: str = ""):
1✔
694
        """Get title and body for the email"""
695
        assert data, "No data to send"
×
696

697
        extra = self.get_extra_for_template()
×
UNCOV
698
        env = Environment(loader=FileSystemLoader("templates"))
×
UNCOV
699
        template = env.get_template(self.template())
×
UNCOV
700
        message = template.render(
×
701
            date=date,
702
            data=data,
703
            extra=extra,
704
            str=str,
705
            enumerate=enumerate,
706
            plural=utils.plural,
707
            no_manager=self.no_manager,
708
            table_attrs=self.get_config("table_attrs"),
709
        )
UNCOV
710
        common = env.get_template("common.html")
×
UNCOV
711
        body = common.render(
×
712
            preamble=preamble,
713
            message=message,
714
            query_url=utils.shorten_long_bz_url(self.query_url),
715
        )
716
        return self.get_email_subject(date), body
×
717

718
    def _send_alert_about_too_many_changes(self, err: TooManyChangesError):
1✔
719
        """Send an alert email when there are too many changes to apply"""
720

UNCOV
721
        env = Environment(loader=FileSystemLoader("templates"))
×
722
        template = env.get_template("aborted_preamble.html")
×
UNCOV
723
        preamble = template.render(
×
724
            changes=err.changes.items(),
725
            changes_size=len(err.changes),
726
            normal_changes_max=self.normal_changes_max,
727
            rule_name=self.name(),
728
            https_proxy=os.environ.get("https_proxy"),
729
            enumerate=enumerate,
730
            table_attrs=self.get_config("table_attrs"),
731
        )
732

UNCOV
733
        login_info = utils.get_login_info()
×
UNCOV
734
        receivers = utils.get_config("common", "receivers")
×
UNCOV
735
        date = lmdutils.get_date("today")
×
UNCOV
736
        data = self.organize(err.bugs)
×
UNCOV
737
        title, body = self.get_email(date, data, preamble)
×
UNCOV
738
        title = f"Aborted: {title}"
×
739

740
        mail.send(
×
741
            login_info["ldap_username"],
742
            receivers,
743
            title,
744
            body,
745
            html=True,
746
            login=login_info,
747
            dryrun=self.dryrun,
748
        )
749

750
    def send_email(self, date="today"):
1✔
751
        """Send the email"""
UNCOV
752
        if date:
×
UNCOV
753
            date = lmdutils.get_date(date)
×
UNCOV
754
            d = lmdutils.get_date_ymd(date)
×
UNCOV
755
            if isinstance(self, Nag):
×
UNCOV
756
                self.nag_date: datetime = d
×
757

758
            if not self.must_run(d):
×
759
                return
×
760

761
        if not self.has_enough_data():
×
762
            logger.info("The rule {} hasn't enough data to run".format(self.name()))
×
UNCOV
763
            return
×
764

765
        login_info = utils.get_login_info()
×
UNCOV
766
        data = self.get_email_data(date)
×
767
        if data:
×
768
            title, body = self.get_email(date, data)
×
769
            receivers = utils.get_receivers(self.name())
×
UNCOV
770
            cc_list = self.get_cc_emails(data)
×
771

772
            status = "Success"
×
773
            try:
×
774
                mail.send(
×
775
                    login_info["ldap_username"],
776
                    receivers,
777
                    title,
778
                    body,
779
                    Cc=cc_list,
780
                    html=True,
781
                    login=login_info,
782
                    dryrun=self.dryrun,
783
                )
UNCOV
784
            except Exception:
×
UNCOV
785
                logger.exception("Rule {}".format(self.name()))
×
UNCOV
786
                status = "Failure"
×
787

UNCOV
788
            db.Email.add(self.name(), receivers, "global", status)
×
UNCOV
789
            if isinstance(self, Nag):
×
790
                self.send_mails(title, dryrun=self.dryrun)
×
791
        else:
792
            name = self.name().upper()
×
UNCOV
793
            if date:
×
794
                logger.info("{}: No data for {}".format(name, date))
×
795
            else:
796
                logger.info("{}: No data".format(name))
×
UNCOV
797
            logger.info("Query: {}".format(self.query_url))
×
798

799
    def add_custom_arguments(self, parser):
1✔
800
        pass
×
801

802
    def parse_custom_arguments(self, args):
1✔
803
        pass
×
804

805
    def get_args_parser(self):
1✔
806
        """Get the arguments from the command line"""
UNCOV
807
        parser = argparse.ArgumentParser(description=self.description())
×
UNCOV
808
        parser.add_argument(
×
809
            "--production",
810
            dest="dryrun",
811
            action="store_false",
812
            help="If the flag is not passed, just do the query, and print emails to console without emailing anyone",
813
        )
814

UNCOV
815
        parser.add_argument(
×
816
            "--no-limit",
817
            dest="is_limited",
818
            action="store_false",
819
            default=True,
820
            help=f"If the flag is not passed, the rule will be limited to touch a maximum of {self.normal_changes_max} bugs",
821
        )
822

UNCOV
823
        if not self.ignore_date():
×
UNCOV
824
            parser.add_argument(
×
825
                "-D",
826
                "--date",
827
                dest="date",
828
                action="store",
829
                default="today",
830
                help="Date for the query",
831
            )
832

UNCOV
833
        self.add_custom_arguments(parser)
×
834

UNCOV
835
        return parser
×
836

837
    def run(self):
1✔
838
        """Run the rule"""
839
        args = self.get_args_parser().parse_args()
×
UNCOV
840
        self.parse_custom_arguments(args)
×
841
        date = "" if self.ignore_date() else args.date
×
UNCOV
842
        self.dryrun = args.dryrun
×
UNCOV
843
        self.is_limited = args.is_limited
×
UNCOV
844
        self.cache.set_dry_run(self.dryrun)
×
845

846
        if self.dryrun:
×
847
            logger.setLevel(logging.DEBUG)
×
848

849
        try:
×
850
            self.send_email(date=date)
×
UNCOV
851
            self.terminate()
×
852
            logger.info("Rule {} has finished.".format(self.get_rule_path()))
×
853
        except TooManyChangesError as err:
×
UNCOV
854
            self._send_alert_about_too_many_changes(err)
×
855
            logger.exception("Rule %s", self.name())
×
856
        except Exception:
×
857
            logger.exception("Rule {}".format(self.name()))
×
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