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

mozilla / relman-auto-nag / #4349

pending completion
#4349

push

coveralls-python

sosa-e
Revert "Minimizing config file"

This reverts commit 614159597.

564 of 3081 branches covered (18.31%)

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

1804 of 7980 relevant lines covered (22.61%)

0.23 hits per line

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

30.74
/auto_nag/utils.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 copy
1✔
6
import datetime
1✔
7
import json
1✔
8
import os
1✔
9
import random
1✔
10
import re
1✔
11
from typing import Iterable, Union
1✔
12
from urllib.parse import urlencode
1✔
13

14
import dateutil.parser
1✔
15
import humanize
1✔
16
import pytz
1✔
17
import requests
1✔
18
from dateutil.relativedelta import relativedelta
1✔
19
from libmozdata import utils as lmdutils
1✔
20
from libmozdata import versions as lmdversions
1✔
21
from libmozdata.bugzilla import Bugzilla, BugzillaShorten
1✔
22
from libmozdata.fx_trains import FirefoxTrains
1✔
23
from libmozdata.hgmozilla import Mercurial
1✔
24
from requests.exceptions import HTTPError
1✔
25

26
from auto_nag.constants import (
1✔
27
    BOT_MAIN_ACCOUNT,
28
    HIGH_PRIORITY,
29
    HIGH_SEVERITY,
30
    OLD_SEVERITY_MAP,
31
)
32

33
_CONFIG = None
1✔
34
_CYCLE_SPAN = None
1✔
35
_MERGE_DAY = None
1✔
36
_TRIAGE_OWNERS = None
1✔
37
_DEFAULT_ASSIGNEES = None
1✔
38
_CURRENT_VERSIONS = None
1✔
39
_CONFIG_PATH = "./auto_nag/scripts/configs/"
1✔
40

41

42
BZ_FIELD_PAT = re.compile(r"^[fovj]([0-9]+)$")
1✔
43
PAR_PAT = re.compile(r"\([^\)]*\)")
1✔
44
BRA_PAT = re.compile(r"\[[^\]]*\]")
1✔
45
DIA_PAT = re.compile("<[^>]*>")
1✔
46
UTC_PAT = re.compile(r"UTC\+[^ \t]*")
1✔
47
COL_PAT = re.compile(":[^:]*")
1✔
48
BACKOUT_PAT = re.compile("^back(s|(ed))?[ \t]*out", re.I)
1✔
49
BUG_PAT = re.compile(r"^bug[s]?[ \t]*([0-9]+)", re.I)
1✔
50
WHITEBOARD_ACCESS_PAT = re.compile(r"\[access\-s\d\]")
1✔
51

52
MAX_URL_LENGTH = 512
1✔
53

54

55
def get_weekdays():
1✔
56
    return {"Mon": 0, "Tue": 1, "Wed": 2, "Thu": 3, "Fri": 4, "Sat": 5, "Sun": 6}
1✔
57

58

59
def _get_config():
1✔
60
    global _CONFIG
61
    if _CONFIG is None:
1✔
62
        try:
1✔
63
            with open(_CONFIG_PATH + "/tools.json", "r") as In:
1✔
64
                _CONFIG = json.load(In)
1✔
65
        except IOError:
×
66
            _CONFIG = {}
×
67
    return _CONFIG
1✔
68

69

70
def get_config(name, entry, default=None):
1✔
71
    conf = _get_config()
1✔
72
    if name not in conf:
1✔
73
        name = "common"
1✔
74
    tool_conf = conf[name]
1✔
75
    if entry in tool_conf:
1✔
76
        return tool_conf[entry]
1✔
77
    tool_conf = conf["common"]
1✔
78
    return tool_conf.get(entry, default)
1✔
79

80

81
def get_receivers(tool_name):
1✔
82
    receiver_lists = get_config("common", "receiver_list", default={})
×
83

84
    receivers = get_config(tool_name, "receivers", [])
×
85
    if isinstance(receivers, str):
×
86
        receivers = receiver_lists[receivers]
×
87

88
    additional_receivers = get_config(tool_name, "additional_receivers", [])
×
89
    if isinstance(additional_receivers, str):
×
90
        additional_receivers = receiver_lists[additional_receivers]
×
91

92
    return list(dict.fromkeys([*receivers, *additional_receivers]))
×
93

94

95
def init_random():
1✔
96
    now = datetime.datetime.utcnow()
1✔
97
    now = now.timestamp()
1✔
98
    random.seed(now)
1✔
99

100

101
def get_signatures(sgns):
1✔
102
    if not sgns:
1!
103
        return set()
×
104

105
    res = set()
1✔
106
    sgns = map(lambda x: x.strip(), sgns.split("[@"))
1✔
107
    for s in filter(None, sgns):
1✔
108
        try:
1✔
109
            i = s.rindex("]")
1✔
110
            res.add(s[:i].strip())
1✔
111
        except ValueError:
×
112
            res.add(s)
×
113
    return res
1✔
114

115

116
def add_signatures(old, new):
1✔
117
    added_sgns = "[@ " + "]\n[@ ".join(sorted(new)) + "]"
1✔
118
    if old:
1✔
119
        return old + "\n" + added_sgns
1✔
120
    return added_sgns
1✔
121

122

123
def get_empty_assignees(params, negation=False):
1✔
124
    n = get_last_field_num(params)
1✔
125
    n = int(n)
1✔
126
    params.update(
1✔
127
        {
128
            "j" + str(n): "OR",
129
            "f" + str(n): "OP",
130
            "f" + str(n + 1): "assigned_to",
131
            "o" + str(n + 1): "equals",
132
            "v" + str(n + 1): "nobody@mozilla.org",
133
            "f" + str(n + 2): "assigned_to",
134
            "o" + str(n + 2): "regexp",
135
            "v" + str(n + 2): r"^.*\.bugs$",
136
            "f" + str(n + 3): "assigned_to",
137
            "o" + str(n + 3): "isempty",
138
            "f" + str(n + 4): "CP",
139
        }
140
    )
141
    if negation:
1!
142
        params["n" + str(n)] = 1
×
143

144
    return params
1✔
145

146

147
def is_no_assignee(mail):
1✔
148
    return mail == "nobody@mozilla.org" or mail.endswith(".bugs") or mail == ""
1✔
149

150

151
def get_login_info():
1✔
152
    with open(_CONFIG_PATH + "config.json", "r") as In:
×
153
        return json.load(In)
×
154

155

156
def get_private():
1✔
157
    with open(_CONFIG_PATH + "config.json", "r") as In:
×
158
        return json.load(In)["private"]
×
159

160

161
def plural(sword, data, pword=""):
1✔
162
    if isinstance(data, int):
1!
163
        p = data != 1
×
164
    else:
165
        p = len(data) != 1
1✔
166
    if not p:
1✔
167
        return sword
1✔
168
    if pword:
1!
169
        return pword
1✔
170
    return sword + "s"
×
171

172

173
def english_list(items):
1✔
174
    assert len(items) > 0
1✔
175
    if len(items) == 1:
1!
176
        return items[0]
×
177

178
    return "{} and {}".format(", ".join(items[:-1]), items[-1])
1✔
179

180

181
def shorten_long_bz_url(url):
1✔
182
    if not url or len(url) <= MAX_URL_LENGTH:
×
183
        return url
×
184

185
    # the url can be very long and line length are limited in email protocol:
186
    # https://datatracker.ietf.org/doc/html/rfc5322#section-2.1.1
187
    # So we need to generate a short URL.
188

189
    def url_handler(u, data):
×
190
        data["url"] = u
×
191

192
    data = {}
×
193
    try:
×
194
        BugzillaShorten(url, url_data=data, url_handler=url_handler).wait()
×
195
    except HTTPError:  # workaround for https://github.com/mozilla/relman-auto-nag/issues/1402
×
196
        return "\n".join(
×
197
            [url[i : i + MAX_URL_LENGTH] for i in range(0, len(url), MAX_URL_LENGTH)]
198
        )
199

200
    return data["url"]
×
201

202

203
def get_cycle_span() -> str:
1✔
204
    """Return the cycle span in the format YYYYMMDD-YYYYMMDD"""
205
    global _CYCLE_SPAN
206
    if _CYCLE_SPAN is None:
×
207
        schedule = FirefoxTrains().get_release_schedule("nightly")
×
208
        start = lmdutils.get_date_ymd(schedule["nightly_start"])
×
209
        end = lmdutils.get_date_ymd(schedule["merge_day"])
×
210

211
        now = lmdutils.get_date_ymd("today")
×
212
        assert start <= now <= end
×
213

214
        _CYCLE_SPAN = start.strftime("%Y%m%d") + "-" + end.strftime("%Y%m%d")
×
215

216
    return _CYCLE_SPAN
×
217

218

219
def get_next_release_date() -> datetime.datetime:
1✔
220
    """Return the next release date"""
221
    schedule = FirefoxTrains().get_release_schedule("beta")
×
222
    release_date = lmdutils.get_date_ymd(schedule["release"])
×
223
    release_date = release_date.replace(hour=0, minute=0, second=0, microsecond=0)
×
224
    return release_date
×
225

226

227
def is_merge_day(date: datetime.datetime = None) -> bool:
1✔
228
    """Check if the date is the merge day
229

230
    Args:
231
        date: the date to check. If None, the current date is used.
232

233
    Returns:
234
        True if the date is the merge day
235
    """
236
    if date is None:
×
237
        date = lmdutils.get_date_ymd("today")
×
238

239
    schedule = FirefoxTrains().get_release_schedule("nightly")
×
240
    last_merge = lmdutils.get_date_ymd(schedule["nightly_start"])
×
241
    next_merge = lmdutils.get_date_ymd(schedule["merge_day"])
×
242

243
    return date in (next_merge, last_merge)
×
244

245

246
def get_report_bugs(channel, op="+"):
1✔
247
    url = "https://bugzilla.mozilla.org/page.cgi?id=release_tracking_report.html"
×
248
    params = {
×
249
        "q": "approval-mozilla-{}:{}:{}:0:and:".format(channel, op, get_cycle_span())
250
    }
251

252
    # allow_redirects=False avoids to load the data
253
    # and we'll just get the redirected url to get all the bug ids we need
254
    r = requests.get(url, params=params, allow_redirects=False)
×
255

256
    # something like https://bugzilla.mozilla.org/buglist.cgi?bug_id=1493711,1502766,1499908
257
    url = r.headers["Location"]
×
258

259
    return url.split("=")[1].split(",")
×
260

261

262
def get_flag(version, name, channel):
1✔
263
    if name in ["status", "tracking"]:
1!
264
        if channel == "esr":
1✔
265
            return "cf_{}_firefox_esr{}".format(name, version)
1✔
266
        return "cf_{}_firefox{}".format(name, version)
1✔
267
    elif name == "approval":
×
268
        if channel == "esr":
×
269
            return "approval-mozilla-esr{}".format(version)
×
270
        return "approval-mozilla-{}".format(channel)
×
271

272

273
def get_needinfo(bug, days=-1):
1✔
274
    now = datetime.datetime.utcnow().replace(tzinfo=pytz.utc)
×
275
    for flag in bug.get("flags", []):
×
276
        if flag.get("name", "") == "needinfo" and flag["status"] == "?":
×
277
            date = flag["modification_date"]
×
278
            date = dateutil.parser.parse(date)
×
279
            if (now - date).days >= days:
×
280
                yield flag
×
281

282

283
def get_last_field_num(params):
1✔
284
    s = set()
1✔
285
    for k in params.keys():
1✔
286
        m = BZ_FIELD_PAT.match(k)
1✔
287
        if m:
1✔
288
            s.add(int(m.group(1)))
1✔
289

290
    x = max(s) + 1 if s else 1
1✔
291
    return str(x)
1✔
292

293

294
def add_prod_comp_to_query(params, prod_comp):
1✔
295
    n = int(get_last_field_num(params))
×
296
    params[f"j{n}"] = "OR"
×
297
    params[f"f{n}"] = "OP"
×
298
    n += 1
×
299
    for pc in prod_comp:
×
300
        prod, comp = pc.split("::")
×
301
        params[f"j{n}"] = "AND"
×
302
        params[f"f{n}"] = "OP"
×
303
        n += 1
×
304
        params[f"f{n}"] = "product"
×
305
        params[f"o{n}"] = "equals"
×
306
        params[f"v{n}"] = prod
×
307
        n += 1
×
308
        params[f"f{n}"] = "component"
×
309
        params[f"o{n}"] = "equals"
×
310
        params[f"v{n}"] = comp
×
311
        n += 1
×
312
        params[f"f{n}"] = "CP"
×
313
        n += 1
×
314
    params[f"f{n}"] = "CP"
×
315

316

317
def get_bz_search_url(params):
1✔
318
    return "https://bugzilla.mozilla.org/buglist.cgi?" + urlencode(params, doseq=True)
1✔
319

320

321
def has_bot_set_ni(bug):
1✔
322
    bot = get_config("common", "bot_bz_mail")
×
323
    for flag in get_needinfo(bug):
×
324
        if flag["setter"] in bot:
×
325
            return True
×
326
    return False
×
327

328

329
def get_triage_owners():
1✔
330
    global _TRIAGE_OWNERS
331
    if _TRIAGE_OWNERS is not None:
×
332
        return _TRIAGE_OWNERS
×
333

334
    # accessible is the union of:
335
    #  selectable (all product we can see)
336
    #  enterable (all product a user can file bugs into).
337
    prods = get_config("common", "products")
×
338
    url = "https://bugzilla.mozilla.org/rest/product"
×
339
    params = {
×
340
        "type": "accessible",
341
        "include_fields": ["name", "components.name", "components.triage_owner"],
342
        "names": prods,
343
    }
344
    r = requests.get(url, params=params)
×
345
    products = r.json()["products"]
×
346
    _TRIAGE_OWNERS = {}
×
347
    for prod in products:
×
348
        prod_name = prod["name"]
×
349
        for comp in prod["components"]:
×
350
            owner = comp["triage_owner"]
×
351
            if owner and not is_no_assignee(owner):
×
352
                comp_name = comp["name"]
×
353
                pc = f"{prod_name}::{comp_name}"
×
354
                if owner not in _TRIAGE_OWNERS:
×
355
                    _TRIAGE_OWNERS[owner] = [pc]
×
356
                else:
357
                    _TRIAGE_OWNERS[owner].append(pc)
×
358
    return _TRIAGE_OWNERS
×
359

360

361
def get_default_assignees():
1✔
362
    global _DEFAULT_ASSIGNEES
363
    if _DEFAULT_ASSIGNEES is not None:
×
364
        return _DEFAULT_ASSIGNEES
×
365

366
    # accessible is the union of:
367
    #  selectable (all product we can see)
368
    #  enterable (all product a user can file bugs into).
369
    prods = get_config("common", "products")
×
370
    url = "https://bugzilla.mozilla.org/rest/product"
×
371
    params = {
×
372
        "type": "accessible",
373
        "include_fields": ["name", "components.name", "components.default_assigned_to"],
374
        "names": prods,
375
    }
376
    r = requests.get(url, params=params)
×
377
    products = r.json()["products"]
×
378
    _DEFAULT_ASSIGNEES = {}
×
379
    for prod in products:
×
380
        prod_name = prod["name"]
×
381
        _DEFAULT_ASSIGNEES[prod_name] = dap = {}
×
382
        for comp in prod["components"]:
×
383
            comp_name = comp["name"]
×
384
            assignee = comp["default_assigned_to"]
×
385
            dap[comp_name] = assignee
×
386
    return _DEFAULT_ASSIGNEES
×
387

388

389
def organize(bugs, columns, key=None):
1✔
390
    if isinstance(bugs, dict):
×
391
        # we suppose that the values are the bugdata dict
392
        bugs = bugs.values()
×
393

394
    def identity(x):
×
395
        return x
×
396

397
    def bugid_key(x):
×
398
        return -int(x)
×
399

400
    lambdas = {"id": bugid_key}
×
401

402
    def mykey(p):
×
403
        return tuple(lambdas.get(c, identity)(x) for x, c in zip(p, columns))
×
404

405
    if len(columns) >= 2:
×
406
        res = [tuple(info[c] for c in columns) for info in bugs]
×
407
    else:
408
        c = columns[0]
×
409
        res = [info[c] for info in bugs]
×
410

411
    return sorted(res, key=mykey if not key else key)
×
412

413

414
def merge_bz_changes(c1, c2):
1✔
415
    if not c1:
×
416
        return c2
×
417
    if not c2:
×
418
        return c1
×
419

420
    assert set(c1.keys()).isdisjoint(
×
421
        c2.keys()
422
    ), "Merge changes with common keys is not a good idea"
423
    c = copy.deepcopy(c1)
×
424
    c.update(c2)
×
425

426
    return c
×
427

428

429
def is_test_file(path):
1✔
430
    e = os.path.splitext(path)[1][1:].lower()
×
431
    return "test" in path and e not in {"ini", "list", "in", "py", "json", "manifest"}
×
432

433

434
def get_better_name(name):
1✔
435
    if not name:
×
436
        return ""
×
437

438
    def repl(m):
×
439
        if m.start(0) == 0:
×
440
            return m.group(0)
×
441
        return ""
×
442

443
    if name.startswith("Nobody;"):
×
444
        s = "Nobody"
×
445
    else:
446
        s = PAR_PAT.sub("", name)
×
447
        s = BRA_PAT.sub("", s)
×
448
        s = DIA_PAT.sub("", s)
×
449
        s = COL_PAT.sub(repl, s)
×
450
        s = UTC_PAT.sub("", s)
×
451
        s = s.strip()
×
452
        if s.startswith(":"):
×
453
            s = s[1:]
×
454
    return s.encode("utf-8").decode("utf-8")
×
455

456

457
def is_backout(json):
1✔
458
    return json.get("backedoutby", "") != "" or bool(BACKOUT_PAT.search(json["desc"]))
1✔
459

460

461
def get_pushlog(startdate, enddate, channel="nightly"):
1✔
462
    """Get the pushlog from hg.mozilla.org"""
463
    # Get the pushes where startdate <= pushdate <= enddate
464
    # pushlog uses strict inequality, it's why we add +/- 1 second
465
    fmt = "%Y-%m-%d %H:%M:%S"
×
466
    startdate -= relativedelta(seconds=1)
×
467
    startdate = startdate.strftime(fmt)
×
468
    enddate += relativedelta(seconds=1)
×
469
    enddate = enddate.strftime(fmt)
×
470
    url = "{}/json-pushes".format(Mercurial.get_repo_url(channel))
×
471
    r = requests.get(
×
472
        url,
473
        params={"startdate": startdate, "enddate": enddate, "version": 2, "full": 1},
474
    )
475
    return r.json()
×
476

477

478
def get_bugs_from_desc(desc):
1✔
479
    """Get a bug number from the patch description"""
480
    return BUG_PAT.findall(desc)
×
481

482

483
def get_bugs_from_pushlog(startdate, enddate, channel="nightly"):
1✔
484
    pushlog = get_pushlog(startdate, enddate, channel=channel)
×
485
    bugs = set()
×
486
    for push in pushlog["pushes"].values():
×
487
        for chgset in push["changesets"]:
×
488
            if chgset.get("backedoutby", "") != "":
×
489
                continue
×
490
            desc = chgset["desc"]
×
491
            for bug in get_bugs_from_desc(desc):
×
492
                bugs.add(bug)
×
493
    return bugs
×
494

495

496
def get_checked_versions():
1✔
497
    # There are different reasons to not return versions:
498
    # i) we're merge day: the versions are changing
499
    # ii) not consecutive versions numbers
500
    # iii) bugzilla updated nightly version but p-d is not updated
501
    if is_merge_day():
×
502
        return {}
×
503

504
    versions = lmdversions.get(base=True)
×
505
    versions["central"] = versions["nightly"]
×
506

507
    v = [versions[k] for k in ["release", "beta", "central"]]
×
508
    versions = {k: str(v) for k, v in versions.items()}
×
509

510
    if v[0] + 2 == v[1] + 1 == v[2]:
×
511
        nightly_bugzilla = get_nightly_version_from_bz()
×
512
        if v[2] != nightly_bugzilla:
×
513
            from . import logger
×
514

515
            logger.info("Versions mismatch between Bugzilla and product-details")
×
516
            return {}
×
517
        return versions
×
518

519
    from . import logger
×
520

521
    logger.info("Not consecutive versions in product/details")
×
522
    return {}
×
523

524

525
def get_info_from_hg(json):
1✔
526
    res = {}
×
527
    push = json["pushdate"][0]
×
528
    push = datetime.datetime.utcfromtimestamp(push)
×
529
    push = lmdutils.as_utc(push)
×
530
    res["date"] = lmdutils.get_date_str(push)
×
531
    res["backedout"] = json.get("backedoutby", "") != ""
×
532
    m = BUG_PAT.search(json["desc"])
×
533
    res["bugid"] = m.group(1) if m else ""
×
534

535
    return res
×
536

537

538
def bz_ignore_case(s):
1✔
539
    return "[" + "][".join(c + c.upper() for c in s) + "]"
×
540

541

542
def check_product_component(data, bug):
1✔
543
    prod = bug["product"]
×
544
    comp = bug["component"]
×
545
    pc = prod + "::" + comp
×
546
    return pc in data or comp in data
×
547

548

549
def get_components(data):
1✔
550
    res = []
×
551
    for comp in data:
×
552
        if "::" in comp:
×
553
            _, comp = comp.split("::", 1)
×
554
        res.append(comp)
×
555
    return res
×
556

557

558
def get_products_components(data):
1✔
559
    prods = set()
×
560
    comps = set()
×
561
    for pc in data:
×
562
        if "::" in pc:
×
563
            p, c = pc.split("::", 1)
×
564
            prods.add(p)
×
565
        else:
566
            c = pc
×
567
        comps.add(c)
×
568
    return prods, comps
×
569

570

571
def ireplace(old, repl, text):
1✔
572
    return re.sub("(?i)" + re.escape(old), lambda m: repl, text)
×
573

574

575
def get_human_lag(date):
1✔
576
    today = pytz.utc.localize(datetime.datetime.utcnow())
×
577
    dt = dateutil.parser.parse(date) if isinstance(date, str) else date
×
578

579
    return humanize.naturaldelta(today - dt)
×
580

581

582
def get_nightly_version_from_bz():
1✔
583
    def bug_handler(bug, data):
×
584
        status = "cf_status_firefox"
×
585
        N = len(status)
×
586
        for k in bug.keys():
×
587
            if k.startswith(status):
×
588
                k = k[N:]
×
589
                if k.isdigit():
×
590
                    data.append(int(k))
×
591

592
    data = []
×
593
    Bugzilla(bugids=["1234567"], bughandler=bug_handler, bugdata=data).get_data().wait()
×
594

595
    return max(data)
×
596

597

598
def nice_round(val):
1✔
599
    return int(round(100 * val))
×
600

601

602
def is_bot_email(email: str) -> bool:
1✔
603
    """Check if the email is belong to a bot or component-watching account.
604

605
    Args:
606
        email: the account login email.
607
    """
608
    if email.endswith("@disabled.tld"):
1✔
609
        return False
1✔
610

611
    return email.endswith(".bugs") or email.endswith(".tld")
1✔
612

613

614
def get_last_no_bot_comment_date(bug: dict) -> str:
1✔
615
    """Get the create date of the last comment by non bot account.
616

617
    Args:
618
        bug: the bug dictionary; it must has the comments list.
619

620
    Returns:
621
        If no comments or all comments are posted by bots, the creation date of
622
        the bug itself will be returned.
623
    """
624
    for comment in reversed(bug["comments"]):
×
625
        if not is_bot_email(comment["creator"]):
×
626
            return comment["creation_time"]
×
627

628
    return bug["comments"][0]["creation_time"]
×
629

630

631
def get_sort_by_bug_importance_key(bug):
1✔
632
    """
633
    We need bugs with high severity (S1 or S2) or high priority (P1 or P2) to be
634
    first (do not need to be high in both). Next, bugs with higher priority and
635
    severity are preferred. Finally, for bugs with the same severity and priority,
636
    we favour recently changed or created bugs.
637
    """
638

639
    is_important = bug["priority"] in HIGH_PRIORITY or bug["severity"] in HIGH_SEVERITY
×
640
    priority = bug["priority"] if bug["priority"].startswith("P") else "P10"
×
641
    severity = (
×
642
        bug["severity"]
643
        if bug["severity"].startswith("S")
644
        else OLD_SEVERITY_MAP.get(bug["severity"], "S10")
645
    )
646
    time_order = (
×
647
        lmdutils.get_timestamp(bug["last_change_time"])
648
        if "last_change_time" in bug
649
        else int(bug["id"])  # Bug ID reflects the creation order
650
    )
651

652
    return (
×
653
        not is_important,
654
        severity,
655
        priority,
656
        time_order * -1,
657
    )
658

659

660
def get_mail_to_ni(bug: dict) -> Union[dict, None]:
1✔
661
    """Get the person that should be needinfoed about the bug.
662

663
    If the bug is assigned, the assignee will be selected. Otherwise, will
664
    fallback to the triage owner.
665

666
    Args:
667
        bug: The bug that you need to send a needinfo request about.
668

669
    Returns:
670
        A dict with the nicname and the email of the person that should receive
671
        the needinfo request. If not available will return None.
672

673
    """
674

675
    for field in ["assigned_to", "triage_owner"]:
×
676
        person = bug.get(field, "")
×
677
        if not is_no_assignee(person):
×
678
            return {"mail": person, "nickname": bug[f"{field}_detail"]["nick"]}
×
679

680
    return None
×
681

682

683
def get_name_from_user_detail(detail: dict) -> str:
1✔
684
    """Get the name of the user from the detail object.
685

686
    Returns:
687
        The name of the user or the email as a fallback.
688
    """
689
    name = detail["real_name"]
×
690
    if is_no_assignee(detail["email"]):
×
691
        name = "nobody"
×
692
    if name.strip() == "":
×
693
        name = detail["name"]
×
694
        if name.strip() == "":
×
695
            name = detail["email"]
×
696

697
    return name
×
698

699

700
def is_weekend(date: Union[datetime.datetime, str]) -> bool:
1✔
701
    """Get if the provided date is a weekend day (Saturday or Sunday)"""
702
    parsed_date = lmdutils.get_date_ymd(date)
×
703
    return parsed_date.weekday() >= 5
×
704

705

706
def get_whiteboard_access_rating(whiteboard: str) -> str:
1✔
707
    """Get the access rating tag from the whiteboard.
708

709
    Args:
710
        whiteboard: a whiteboard string that contains an access rating tag.
711

712
    Returns:
713
        An access rating tag.
714
    """
715

716
    access_tags = WHITEBOARD_ACCESS_PAT.findall(whiteboard)
×
717
    assert len(access_tags) == 1, "Should have only one access tag"
×
718

719
    return access_tags[0]
×
720

721

722
def create_bug(bug_data: dict) -> dict:
1✔
723
    """Create a new bug.
724

725
    Args:
726
        bug_data: The bug data to create.
727

728
    Returns:
729
        A dictionary with the bug id of the newly created bug.
730
    """
731
    resp = requests.post(
×
732
        url=Bugzilla.API_URL,
733
        json=bug_data,
734
        headers=Bugzilla([]).get_header(),
735
        verify=True,
736
        timeout=Bugzilla.TIMEOUT,
737
    )
738
    resp.raise_for_status()
×
739
    return resp.json()
×
740

741

742
def is_keywords_removed_by_autonag(bug: dict, keywords: Iterable) -> bool:
1✔
743
    """Check if the bug had any of the provided keywords removed by autonag.
744

745
    Args:
746
        bug: The bug to check.
747
        keywords: The keywords to check.
748

749
    Returns:
750
        True if any of the keywords was removed by autonag, False otherwise.
751
    """
752
    return any(
×
753
        keyword in change["removed"]
754
        for entry in bug["history"]
755
        if entry["who"] == BOT_MAIN_ACCOUNT
756
        for change in entry["changes"]
757
        if change["field_name"] == "keywords"
758
        for keyword in keywords
759
    )
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