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

mozilla / relman-auto-nag / #5093

19 Jun 2024 03:31AM CUT coverage: 21.874% (-0.002%) from 21.876%
#5093

push

coveralls-python

web-flow
Bump pre-commit from 3.7.0 to 3.7.1 (#2395)

Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 3.7.0 to 3.7.1.
- [Release notes](https://github.com/pre-commit/pre-commit/releases)
- [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pre-commit/pre-commit/compare/v3.7.0...v3.7.1)

---
updated-dependencies:
- dependency-name: pre-commit
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

716 of 3600 branches covered (19.89%)

1933 of 8837 relevant lines covered (21.87%)

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 list(
1✔
382
            (
383
                set(self.get_config("products"))
384
                | set(self.get_config("additional_products", []))
385
            )
386
            - set(self.get_config("exclude_products", []))
387
        )
388

389
    def amend_bzparams(self, params, bug_ids):
1✔
390
        """Amend the Bugzilla params"""
391
        if not self.all_include_fields():
1!
392
            if "include_fields" in params:
1!
393
                fields = params["include_fields"]
×
394
                if isinstance(fields, list):
×
395
                    if "id" not in fields:
×
396
                        fields.append("id")
×
397
                elif isinstance(fields, str):
×
398
                    if fields != "id":
×
399
                        params["include_fields"] = [fields, "id"]
×
400
                else:
401
                    params["include_fields"] = [fields, "id"]
×
402
            else:
403
                params["include_fields"] = ["id"]
1✔
404

405
            params["include_fields"] += ["summary", "groups"]
1✔
406

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

410
            if self.has_product_component():
1!
411
                if "product" not in params["include_fields"]:
×
412
                    params["include_fields"].append("product")
×
413
                if "component" not in params["include_fields"]:
×
414
                    params["include_fields"].append("component")
×
415

416
            if self.has_needinfo() and "flags" not in params["include_fields"]:
1!
417
                params["include_fields"].append("flags")
×
418

419
        if bug_ids:
1!
420
            params["bug_id"] = bug_ids
1✔
421

422
        if self.filter_no_nag_keyword():
1!
423
            n = utils.get_last_field_num(params)
1✔
424
            params.update(
1✔
425
                {
426
                    "f" + n: "status_whiteboard",
427
                    "o" + n: "notsubstring",
428
                    "v" + n: "[no-nag]",
429
                }
430
            )
431

432
        if self.ignore_meta():
1!
433
            n = utils.get_last_field_num(params)
×
434
            params.update({"f" + n: "keywords", "o" + n: "nowords", "v" + n: "meta"})
×
435

436
        if self.has_default_products():
1!
437
            params["product"] = self.get_products()
1✔
438

439
        if not self.has_access_to_sec_bugs():
1!
440
            n = utils.get_last_field_num(params)
×
441
            params.update({"f" + n: "bug_group", "o" + n: "isempty"})
×
442

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

445
    def get_bugs(self, date="today", bug_ids=[], chunk_size=None):
1✔
446
        """Get the bugs"""
447
        bugs = self.get_data()
1✔
448
        params = self.get_bz_params(date)
1✔
449
        self.amend_bzparams(params, bug_ids)
1✔
450
        self.query_url = utils.get_bz_search_url(params)
1✔
451

452
        if isinstance(self, Nag):
1!
453
            self.query_params: dict = params
×
454

455
        old_CHUNK_SIZE = Bugzilla.BUGZILLA_CHUNK_SIZE
1✔
456
        try:
1✔
457
            if chunk_size:
1!
458
                Bugzilla.BUGZILLA_CHUNK_SIZE = chunk_size
×
459

460
            Bugzilla(
1✔
461
                params,
462
                bughandler=self.bughandler,
463
                bugdata=bugs,
464
                timeout=self.get_config("bz_query_timeout"),
465
            ).get_data().wait()
466
        finally:
467
            Bugzilla.BUGZILLA_CHUNK_SIZE = old_CHUNK_SIZE
1✔
468

469
        self.get_comments(bugs)
1✔
470

471
        return bugs
1✔
472

473
    def commenthandler(self, bug, bugid, data):
1✔
474
        return
×
475

476
    def _commenthandler(self, bug, bugid, data):
1✔
477
        comments = bug["comments"]
×
478
        bugid = str(bugid)
×
479
        if self.has_last_comment_time():
×
480
            if comments:
×
481
                data[bugid]["last_comment"] = utils.get_human_lag(comments[-1]["time"])
×
482
            else:
483
                data[bugid]["last_comment"] = ""
×
484

485
        self.commenthandler(bug, bugid, data)
×
486

487
    def get_comments(self, bugs):
1✔
488
        """Get the bugs comments"""
489
        if self.has_last_comment_time():
1!
490
            bugids = self.get_list_bugs(bugs)
×
491
            Bugzilla(
×
492
                bugids=bugids, commenthandler=self._commenthandler, commentdata=bugs
493
            ).get_data().wait()
494
        return bugs
1✔
495

496
    def has_last_comment_time(self):
1✔
497
        return False
1✔
498

499
    def get_list_bugs(self, bugs):
1✔
500
        return [x["id"] for x in bugs.values()]
×
501

502
    def get_documentation(self):
1✔
503
        return "For more information, please visit [BugBot documentation](https://wiki.mozilla.org/BugBot#{}).".format(
1✔
504
            self.get_rule_path().replace("/", ".2F")
505
        )
506

507
    def has_bot_set_ni(self, bug):
1✔
508
        if not self.has_flags:
×
509
            raise Exception
×
510
        return utils.has_bot_set_ni(bug)
×
511

512
    def set_needinfo(self):
1✔
513
        if not self.auto_needinfo:
×
514
            return {}
×
515

516
        template = self.get_needinfo_template()
×
517
        res = {}
×
518

519
        doc = self.get_documentation()
×
520

521
        for ni_mail, info in self.auto_needinfo.items():
×
522
            nick = info["nickname"]
×
523
            for bugid in info["bugids"]:
×
524
                data = {
×
525
                    "comment": {"body": ""},
526
                    "flags": [
527
                        {
528
                            "name": "needinfo",
529
                            "requestee": ni_mail,
530
                            "status": "?",
531
                            "new": "true",
532
                        }
533
                    ],
534
                }
535

536
                comment = None
×
537
                if nick:
×
538
                    comment = template.render(
×
539
                        nickname=nick,
540
                        extra=self.get_extra_for_needinfo_template(),
541
                        plural=utils.plural,
542
                        bugid=bugid,
543
                        documentation=doc,
544
                    )
545
                    comment = comment.strip() + "\n"
×
546
                    data["comment"]["body"] = comment
×
547

548
                if bugid not in res:
×
549
                    res[bugid] = data
×
550
                else:
551
                    res[bugid]["flags"] += data["flags"]
×
552
                    if comment:
×
553
                        res[bugid]["comment"]["body"] = comment
×
554

555
        return res
×
556

557
    def get_needinfo_template(self) -> Template:
1✔
558
        """Get a template to render needinfo comment body"""
559

560
        template_name = self.needinfo_template_name()
×
561
        assert bool(template_name)
×
562
        env = Environment(loader=FileSystemLoader("templates"))
×
563
        template = env.get_template(template_name)
×
564

565
        return template
×
566

567
    def has_individual_autofix(self, changes):
1✔
568
        # check if we have a dictionary with bug numbers as keys
569
        # return True if all the keys are bug number
570
        # (which means that each bug has its own autofix)
571
        return changes and all(
1✔
572
            isinstance(bugid, int) or bugid.isdigit() for bugid in changes
573
        )
574

575
    def get_autofix_change(self):
1✔
576
        """Get the change to do to autofix the bugs"""
577
        return self.autofix_changes
1✔
578

579
    def autofix(self, bugs):
1✔
580
        """Autofix the bugs according to what is returned by get_autofix_change"""
581
        ni_changes = self.set_needinfo()
×
582
        change = self.get_autofix_change()
×
583

584
        if not ni_changes and not change:
×
585
            return bugs
×
586

587
        self.has_autofix = True
×
588
        new_changes = {}
×
589
        if not self.has_individual_autofix(change):
×
590
            bugids = self.get_list_bugs(bugs)
×
591
            for bugid in bugids:
×
592
                mrg = utils.merge_bz_changes(change, ni_changes.get(bugid, {}))
×
593
                if mrg:
×
594
                    new_changes[bugid] = mrg
×
595
        else:
596
            change = {str(k): v for k, v in change.items()}
×
597
            bugids = set(change.keys()) | set(ni_changes.keys())
×
598
            for bugid in bugids:
×
599
                mrg = utils.merge_bz_changes(
×
600
                    change.get(bugid, {}), ni_changes.get(bugid, {})
601
                )
602
                if mrg:
×
603
                    new_changes[bugid] = mrg
×
604

605
        if not self.apply_autofix:
×
606
            self.autofix_changes = new_changes
×
607
            return bugs
×
608

609
        extra = self.get_db_extra()
×
610

611
        if self.is_limited and len(new_changes) > self.normal_changes_max:
×
612
            raise TooManyChangesError(bugs, new_changes, self.normal_changes_max)
×
613

614
        self.apply_changes_on_bugzilla(
×
615
            self.name(),
616
            new_changes,
617
            self.no_bugmail,
618
            self.dryrun or self.test_mode,
619
            extra,
620
        )
621

622
        return bugs
×
623

624
    @staticmethod
1✔
625
    def apply_changes_on_bugzilla(
1✔
626
        rule_name: str,
627
        new_changes: Dict[str, dict],
628
        no_bugmail: bool = False,
629
        is_dryrun: bool = True,
630
        db_extra: Dict[str, str] | None = None,
631
    ) -> None:
632
        """Apply changes on Bugzilla
633

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

650
        if db_extra is None:
×
651
            db_extra = {}
×
652

653
        max_retries = utils.get_config("common", "bugzilla_max_retries", 3)
×
654
        bugzilla_cls = SilentBugzilla if no_bugmail else Bugzilla
×
655

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

675
    def terminate(self):
1✔
676
        """Called when everything is done"""
677
        return
×
678

679
    def organize(self, bugs):
1✔
680
        return utils.organize(bugs, self.columns(), key=self.sort_columns())
×
681

682
    def add_to_cache(self, bugs):
1✔
683
        """Add the bug keys to cache"""
684
        if isinstance(bugs, dict):
×
685
            self.cache.add(bugs.keys())
×
686
        else:
687
            self.cache.add(bugs)
×
688

689
    def get_email_data(self, date: str) -> List[dict]:
1✔
690
        bugs = self.get_bugs(date=date)
×
691
        bugs = self._populate_prioritized_actions(bugs)
×
692
        bugs = self.autofix(bugs)
×
693
        self.add_to_cache(bugs)
×
694
        if not bugs:
×
695
            return []
×
696

697
        return self.organize(bugs)
×
698

699
    def get_email(self, date: str, data: dict, preamble: str = ""):
1✔
700
        """Get title and body for the email"""
701
        assert data, "No data to send"
×
702

703
        extra = self.get_extra_for_template()
×
704
        env = Environment(loader=FileSystemLoader("templates"))
×
705
        template = env.get_template(self.template())
×
706
        message = template.render(
×
707
            date=date,
708
            data=data,
709
            extra=extra,
710
            str=str,
711
            enumerate=enumerate,
712
            plural=utils.plural,
713
            no_manager=self.no_manager,
714
            table_attrs=self.get_config("table_attrs"),
715
        )
716
        common = env.get_template("common.html")
×
717
        body = common.render(
×
718
            preamble=preamble,
719
            message=message,
720
            query_url=utils.shorten_long_bz_url(self.query_url),
721
        )
722
        return self.get_email_subject(date), body
×
723

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

727
        env = Environment(loader=FileSystemLoader("templates"))
×
728
        template = env.get_template("aborted_preamble.html")
×
729
        preamble = template.render(
×
730
            changes=err.changes.items(),
731
            changes_size=len(err.changes),
732
            normal_changes_max=self.normal_changes_max,
733
            rule_name=self.name(),
734
            https_proxy=os.environ.get("https_proxy"),
735
            enumerate=enumerate,
736
            table_attrs=self.get_config("table_attrs"),
737
        )
738

739
        login_info = utils.get_login_info()
×
740
        receivers = utils.get_config("common", "receivers")
×
741
        date = lmdutils.get_date("today")
×
742
        data = self.organize(err.bugs)
×
743
        title, body = self.get_email(date, data, preamble)
×
744
        title = f"Aborted: {title}"
×
745

746
        mail.send(
×
747
            login_info["ldap_username"],
748
            receivers,
749
            title,
750
            body,
751
            html=True,
752
            login=login_info,
753
            dryrun=self.dryrun,
754
        )
755

756
    def send_email(self, date="today"):
1✔
757
        """Send the email"""
758
        if date:
×
759
            date = lmdutils.get_date(date)
×
760
            d = lmdutils.get_date_ymd(date)
×
761
            if isinstance(self, Nag):
×
762
                self.nag_date: datetime = d
×
763

764
            if not self.must_run(d):
×
765
                return
×
766

767
        if not self.has_enough_data():
×
768
            logger.info("The rule {} hasn't enough data to run".format(self.name()))
×
769
            return
×
770

771
        login_info = utils.get_login_info()
×
772
        data = self.get_email_data(date)
×
773
        if data:
×
774
            title, body = self.get_email(date, data)
×
775
            receivers = utils.get_receivers(self.name())
×
776
            cc_list = self.get_cc_emails(data)
×
777

778
            status = "Success"
×
779
            try:
×
780
                mail.send(
×
781
                    login_info["ldap_username"],
782
                    receivers,
783
                    title,
784
                    body,
785
                    Cc=cc_list,
786
                    html=True,
787
                    login=login_info,
788
                    dryrun=self.dryrun,
789
                )
790
            except Exception:
×
791
                logger.exception("Rule {}".format(self.name()))
×
792
                status = "Failure"
×
793

794
            db.Email.add(self.name(), receivers, "global", status)
×
795
            if isinstance(self, Nag):
×
796
                self.send_mails(title, dryrun=self.dryrun)
×
797
        else:
798
            name = self.name().upper()
×
799
            if date:
×
800
                logger.info("{}: No data for {}".format(name, date))
×
801
            else:
802
                logger.info("{}: No data".format(name))
×
803
            logger.info("Query: {}".format(self.query_url))
×
804

805
    def add_custom_arguments(self, parser):
1✔
806
        pass
×
807

808
    def parse_custom_arguments(self, args):
1✔
809
        pass
×
810

811
    def get_args_parser(self):
1✔
812
        """Get the arguments from the command line"""
813
        parser = argparse.ArgumentParser(description=self.description())
×
814
        parser.add_argument(
×
815
            "--production",
816
            dest="dryrun",
817
            action="store_false",
818
            help="If the flag is not passed, just do the query, and print emails to console without emailing anyone",
819
        )
820

821
        parser.add_argument(
×
822
            "--no-limit",
823
            dest="is_limited",
824
            action="store_false",
825
            default=True,
826
            help=f"If the flag is not passed, the rule will be limited to touch a maximum of {self.normal_changes_max} bugs",
827
        )
828

829
        if not self.ignore_date():
×
830
            parser.add_argument(
×
831
                "-D",
832
                "--date",
833
                dest="date",
834
                action="store",
835
                default="today",
836
                help="Date for the query",
837
            )
838

839
        self.add_custom_arguments(parser)
×
840

841
        return parser
×
842

843
    def run(self):
1✔
844
        """Run the rule"""
845
        args = self.get_args_parser().parse_args()
×
846
        self.parse_custom_arguments(args)
×
847
        date = "" if self.ignore_date() else args.date
×
848
        self.dryrun = args.dryrun
×
849
        self.is_limited = args.is_limited
×
850
        self.cache.set_dry_run(self.dryrun)
×
851

852
        if self.dryrun:
×
853
            logger.setLevel(logging.DEBUG)
×
854

855
        try:
×
856
            self.send_email(date=date)
×
857
            self.terminate()
×
858
            logger.info("Rule {} has finished.".format(self.get_rule_path()))
×
859
        except TooManyChangesError as err:
×
860
            self._send_alert_about_too_many_changes(err)
×
861
            logger.exception("Rule %s", self.name())
×
862
        except Exception:
×
863
            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