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

mozilla / relman-auto-nag / #4593

pending completion
#4593

push

coveralls-python

web-flow
Set the logging level to `DEBUG` when running in dry-run mode (#2118)

641 of 3219 branches covered (19.91%)

3 of 3 new or added lines in 1 file covered. (100.0%)

1824 of 8005 relevant lines covered (22.79%)

0.23 hits per line

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

33.84
/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 has_default_products(self):
1✔
218
        return True
1✔
219

220
    def has_product_component(self):
1✔
221
        return False
1✔
222

223
    def get_product_component(self):
1✔
224
        return self.prod_comp
×
225

226
    def has_access_to_sec_bugs(self):
1✔
227
        return self.get_config("sec", True)
1✔
228

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

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

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

245
    def add_auto_ni(self, bugid, data):
1✔
246
        if not data:
1!
247
            return False
1✔
248

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

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

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

275
        action = {
×
276
            "bug": bug,
277
            "needinfo": needinfo,
278
            "autofix": autofix,
279
        }
280

281
        self.quota_actions[quota_name].append(action)
×
282

283
    def get_bug_sort_key(self, bug):
1✔
284
        return None
×
285

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

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

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

310
                if action["needinfo"]:
×
311
                    if max_ni > 0 and ni_count >= max_ni:
×
312
                        continue
×
313

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

319
                    if "extra" in action["needinfo"]:
×
320
                        self.extra_ni[bugid] = action["needinfo"]["extra"]
×
321

322
                    bugs_with_action.add(bugid)
×
323
                    ni_count += 1
×
324

325
                if action["autofix"]:
×
326
                    assert bugid not in self.autofix_changes
×
327
                    self.autofix_changes[bugid] = action["autofix"]
×
328
                    bugs_with_action.add(bugid)
×
329

330
                if action["autofix"] or action["needinfo"]:
×
331
                    actions_count += 1
×
332

333
        if exclude_no_action_bugs:
×
334
            bugs = {id: bug for id, bug in bugs.items() if id in bugs_with_action}
×
335

336
        return bugs
×
337

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

343
        if self.handle_bug(bug, data) is None:
1!
344
            return
×
345

346
        bugid = str(bug["id"])
1✔
347
        res = {"id": bugid}
1✔
348

349
        auto_ni = self.get_mail_to_auto_ni(bug)
1✔
350
        self.add_auto_ni(bugid, auto_ni)
1✔
351

352
        res["summary"] = self.get_summary(bug)
1✔
353

354
        if self.has_assignee():
1!
355
            res["assignee"] = utils.get_name_from_user_detail(bug["assigned_to_detail"])
×
356

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

363
        if self.has_product_component():
1!
364
            for k in ["product", "component"]:
×
365
                res[k] = bug[k]
×
366

367
        if isinstance(self, Nag):
1!
368
            bug = self.set_people_to_nag(bug, res)
×
369
            if not bug:
×
370
                return
×
371

372
        if bugid in data:
1!
373
            data[bugid].update(res)
×
374
        else:
375
            data[bugid] = res
1✔
376

377
    def get_products(self):
1✔
378
        return self.get_config("products") + self.get_config("additional_products", [])
1✔
379

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

396
            params["include_fields"] += ["summary", "groups"]
1✔
397

398
            if self.has_assignee() and "assigned_to" not in params["include_fields"]:
1!
399
                params["include_fields"].append("assigned_to")
×
400

401
            if self.has_product_component():
1!
402
                if "product" not in params["include_fields"]:
×
403
                    params["include_fields"].append("product")
×
404
                if "component" not in params["include_fields"]:
×
405
                    params["include_fields"].append("component")
×
406

407
            if self.has_needinfo() and "flags" not in params["include_fields"]:
1!
408
                params["include_fields"].append("flags")
×
409

410
        if bug_ids:
1!
411
            params["bug_id"] = bug_ids
1✔
412

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

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

427
        if self.has_default_products():
1!
428
            params["product"] = self.get_products()
1✔
429

430
        if not self.has_access_to_sec_bugs():
1!
431
            n = utils.get_last_field_num(params)
×
432
            params.update({"f" + n: "bug_group", "o" + n: "isempty"})
×
433

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

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

443
        if isinstance(self, Nag):
1!
444
            self.query_params: dict = params
×
445

446
        old_CHUNK_SIZE = Bugzilla.BUGZILLA_CHUNK_SIZE
1✔
447
        try:
1✔
448
            if chunk_size:
1!
449
                Bugzilla.BUGZILLA_CHUNK_SIZE = chunk_size
×
450

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

460
        self.get_comments(bugs)
1✔
461

462
        return bugs
1✔
463

464
    def commenthandler(self, bug, bugid, data):
1✔
465
        return
×
466

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

476
        self.commenthandler(bug, bugid, data)
×
477

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

487
    def has_last_comment_time(self):
1✔
488
        return False
1✔
489

490
    def get_list_bugs(self, bugs):
1✔
491
        return [x["id"] for x in bugs.values()]
×
492

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

498
    def has_bot_set_ni(self, bug):
1✔
499
        if not self.has_flags:
×
500
            raise Exception
×
501
        return utils.has_bot_set_ni(bug)
×
502

503
    def set_needinfo(self):
1✔
504
        if not self.auto_needinfo:
×
505
            return {}
×
506

507
        template = self.get_needinfo_template()
×
508
        res = {}
×
509

510
        doc = self.get_documentation()
×
511

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

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

539
                if bugid not in res:
×
540
                    res[bugid] = data
×
541
                else:
542
                    res[bugid]["flags"] += data["flags"]
×
543
                    if comment:
×
544
                        res[bugid]["comment"]["body"] = comment
×
545

546
        return res
×
547

548
    def get_needinfo_template(self) -> Template:
1✔
549
        """Get a template to render needinfo comment body"""
550

551
        template_name = self.needinfo_template_name()
×
552
        assert bool(template_name)
×
553
        env = Environment(loader=FileSystemLoader("templates"))
×
554
        template = env.get_template(template_name)
×
555

556
        return template
×
557

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

566
    def get_autofix_change(self):
1✔
567
        """Get the change to do to autofix the bugs"""
568
        return self.autofix_changes
1✔
569

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

575
        if not ni_changes and not change:
×
576
            return bugs
×
577

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

596
        if not self.apply_autofix:
×
597
            self.autofix_changes = new_changes
×
598
            return bugs
×
599

600
        extra = self.get_db_extra()
×
601

602
        if self.is_limited and len(new_changes) > self.normal_changes_max:
×
603
            raise TooManyChangesError(bugs, new_changes, self.normal_changes_max)
×
604

605
        self.apply_changes_on_bugzilla(
×
606
            self.name(),
607
            new_changes,
608
            self.no_bugmail,
609
            self.dryrun or self.test_mode,
610
            extra,
611
        )
612

613
        return bugs
×
614

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

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

641
        if db_extra is None:
×
642
            db_extra = {}
×
643

644
        max_retries = utils.get_config("common", "bugzilla_max_retries", 3)
×
645
        bugzilla_cls = SilentBugzilla if no_bugmail else Bugzilla
×
646

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

666
    def terminate(self):
1✔
667
        """Called when everything is done"""
668
        return
×
669

670
    def organize(self, bugs):
1✔
671
        return utils.organize(bugs, self.columns(), key=self.sort_columns())
×
672

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

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

688
        return self.organize(bugs)
×
689

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

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

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

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

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

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

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

755
            if not self.must_run(d):
×
756
                return
×
757

758
        if not self.has_enough_data():
×
759
            logger.info("The rule {} hasn't enough data to run".format(self.name()))
×
760
            return
×
761

762
        login_info = utils.get_login_info()
×
763
        data = self.get_email_data(date)
×
764
        if data:
×
765
            title, body = self.get_email(date, data)
×
766
            receivers = utils.get_receivers(self.name())
×
767
            status = "Success"
×
768
            try:
×
769
                mail.send(
×
770
                    login_info["ldap_username"],
771
                    receivers,
772
                    title,
773
                    body,
774
                    html=True,
775
                    login=login_info,
776
                    dryrun=self.dryrun,
777
                )
778
            except Exception:
×
779
                logger.exception("Rule {}".format(self.name()))
×
780
                status = "Failure"
×
781

782
            db.Email.add(self.name(), receivers, "global", status)
×
783
            if isinstance(self, Nag):
×
784
                self.send_mails(title, dryrun=self.dryrun)
×
785
        else:
786
            name = self.name().upper()
×
787
            if date:
×
788
                logger.info("{}: No data for {}".format(name, date))
×
789
            else:
790
                logger.info("{}: No data".format(name))
×
791
            logger.info("Query: {}".format(self.query_url))
×
792

793
    def add_custom_arguments(self, parser):
1✔
794
        pass
×
795

796
    def parse_custom_arguments(self, args):
1✔
797
        pass
×
798

799
    def get_args_parser(self):
1✔
800
        """Get the arguments from the command line"""
801
        parser = argparse.ArgumentParser(description=self.description())
×
802
        parser.add_argument(
×
803
            "--production",
804
            dest="dryrun",
805
            action="store_false",
806
            help="If the flag is not passed, just do the query, and print emails to console without emailing anyone",
807
        )
808

809
        parser.add_argument(
×
810
            "--no-limit",
811
            dest="is_limited",
812
            action="store_false",
813
            default=True,
814
            help=f"If the flag is not passed, the rule will be limited to touch a maximum of {self.normal_changes_max} bugs",
815
        )
816

817
        if not self.ignore_date():
×
818
            parser.add_argument(
×
819
                "-D",
820
                "--date",
821
                dest="date",
822
                action="store",
823
                default="today",
824
                help="Date for the query",
825
            )
826

827
        self.add_custom_arguments(parser)
×
828

829
        return parser
×
830

831
    def run(self):
1✔
832
        """Run the rule"""
833
        args = self.get_args_parser().parse_args()
×
834
        self.parse_custom_arguments(args)
×
835
        date = "" if self.ignore_date() else args.date
×
836
        self.dryrun = args.dryrun
×
837
        self.is_limited = args.is_limited
×
838
        self.cache.set_dry_run(self.dryrun)
×
839

840
        if self.dryrun:
×
841
            logger.setLevel(logging.DEBUG)
×
842

843
        try:
×
844
            self.send_email(date=date)
×
845
            self.terminate()
×
846
            logger.info("Rule {} has finished.".format(self.get_rule_path()))
×
847
        except TooManyChangesError as err:
×
848
            self._send_alert_about_too_many_changes(err)
×
849
            logger.exception("Rule %s", self.name())
×
850
        except Exception:
×
851
            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