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

mozilla / relman-auto-nag / #4077

pending completion
#4077

push

coveralls-python

suhaibmujahid
Merge remote-tracking branch 'upstream/master' into wiki-missed

549 of 3109 branches covered (17.66%)

615 of 615 new or added lines in 27 files covered. (100.0%)

1773 of 8016 relevant lines covered (22.12%)

0.22 hits per line

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

33.94
/auto_nag/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 os
1✔
7
import sys
1✔
8
import time
1✔
9
from collections import defaultdict
1✔
10
from datetime import datetime
1✔
11
from typing import Dict, List
1✔
12

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

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

23

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

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

33

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

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

39

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

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

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

67
    def _set_tool_name(self):
1✔
68
        module = sys.modules[self.__class__.__module__]
1✔
69
        base = os.path.dirname(__file__)
1✔
70
        scripts = os.path.join(base, "scripts")
1✔
71
        self.__tool_path__ = os.path.relpath(module.__file__, scripts)
1✔
72
        name = os.path.basename(module.__file__)
1✔
73
        name = os.path.splitext(name)[0]
1✔
74
        self.__tool_name__ = name
1✔
75

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

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

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

88
    def name(self):
1✔
89
        """Get the tool name"""
90
        return self.__tool_name__
1✔
91

92
    def get_tool_path(self):
1✔
93
        """Get the tool path"""
94
        return self.__tool_path__
1✔
95

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

192
        return start_date, end_date
1✔
193

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

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

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

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

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

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

216
    def has_default_products(self):
1✔
217
        return True
1✔
218

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

335
        return bugs
×
336

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

459
        self.get_comments(bugs)
1✔
460

461
        return bugs
1✔
462

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

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

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

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

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

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

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

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

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

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

509
        doc = self.get_documentation()
×
510

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

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

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

545
        return res
×
546

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

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

555
        return template
×
556

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

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

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

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

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

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

599
        extra = self.get_db_extra()
×
600

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

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

612
        return bugs
×
613

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

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

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

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

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

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

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

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

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

687
        return self.organize(bugs)
×
688

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

826
        self.add_custom_arguments(parser)
×
827

828
        return parser
×
829

830
    def run(self):
1✔
831
        """Run the tool"""
832
        args = self.get_args_parser().parse_args()
×
833
        self.parse_custom_arguments(args)
×
834
        date = "" if self.ignore_date() else args.date
×
835
        self.dryrun = args.dryrun
×
836
        self.is_limited = args.is_limited
×
837
        self.cache.set_dry_run(self.dryrun)
×
838
        try:
×
839
            self.send_email(date=date)
×
840
            self.terminate()
×
841
            logger.info("Tool {} has finished.".format(self.get_tool_path()))
×
842
        except TooManyChangesError as err:
×
843
            self._send_alert_about_too_many_changes(err)
×
844
            logger.exception("Tool %s", self.name())
×
845
        except Exception:
×
846
            logger.exception("Tool {}".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