• 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

30.05
/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 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 release_calendar as rc
1✔
20
from libmozdata import utils as lmdutils
1✔
21
from libmozdata import versions as lmdversions
1✔
22
from libmozdata.bugzilla import Bugzilla, BugzillaShorten
1✔
23
from libmozdata.hgmozilla import Mercurial
1✔
24
from requests.exceptions import HTTPError
1✔
25

26
from auto_nag.constants import HIGH_PRIORITY, HIGH_SEVERITY, OLD_SEVERITY_MAP
1✔
27

28
_CONFIG = None
1✔
29
_CYCLE_SPAN = None
1✔
30
_MERGE_DAY = None
1✔
31
_TRIAGE_OWNERS = None
1✔
32
_DEFAULT_ASSIGNEES = None
1✔
33
_CURRENT_VERSIONS = None
1✔
34
_CONFIG_PATH = "./auto_nag/scripts/configs/"
1✔
35

36

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

47
MAX_URL_LENGTH = 512
1✔
48

49

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

53

54
def _get_config():
1✔
55
    global _CONFIG
56
    if _CONFIG is None:
1✔
57
        try:
1✔
58
            with open(_CONFIG_PATH + "/tools.json", "r") as In:
1✔
59
                _CONFIG = json.load(In)
1✔
60
        except IOError:
×
61
            _CONFIG = {}
×
62
    return _CONFIG
1✔
63

64

65
def get_config(name, entry, default=None):
1✔
66
    conf = _get_config()
1✔
67
    if name not in conf:
1✔
68
        name = "common"
1✔
69
    tool_conf = conf[name]
1✔
70
    if entry in tool_conf:
1✔
71
        return tool_conf[entry]
1✔
72
    tool_conf = conf["common"]
1✔
73
    return tool_conf.get(entry, default)
1✔
74

75

76
def get_receivers(tool_name):
1✔
77
    receiver_lists = get_config("common", "receiver_list", default={})
×
78

79
    receivers = get_config(tool_name, "receivers", [])
×
80
    if isinstance(receivers, str):
×
81
        receivers = receiver_lists[receivers]
×
82

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

87
    return list(dict.fromkeys([*receivers, *additional_receivers]))
×
88

89

90
def init_random():
1✔
91
    now = datetime.datetime.utcnow()
1✔
92
    now = now.timestamp()
1✔
93
    random.seed(now)
1✔
94

95

96
def get_signatures(sgns):
1✔
97
    if not sgns:
1!
98
        return set()
×
99

100
    res = set()
1✔
101
    sgns = map(lambda x: x.strip(), sgns.split("[@"))
1✔
102
    for s in filter(None, sgns):
1✔
103
        try:
1✔
104
            i = s.rindex("]")
1✔
105
            res.add(s[:i].strip())
1✔
106
        except ValueError:
×
107
            res.add(s)
×
108
    return res
1✔
109

110

111
def add_signatures(old, new):
1✔
112
    added_sgns = "[@ " + "]\n[@ ".join(sorted(new)) + "]"
1✔
113
    if old:
1✔
114
        return old + "\n" + added_sgns
1✔
115
    return added_sgns
1✔
116

117

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

139
    return params
1✔
140

141

142
def is_no_assignee(mail):
1✔
143
    return mail == "nobody@mozilla.org" or mail.endswith(".bugs") or mail == ""
1✔
144

145

146
def get_login_info():
1✔
147
    with open(_CONFIG_PATH + "config.json", "r") as In:
×
148
        return json.load(In)
×
149

150

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

155

156
def plural(sword, data, pword=""):
1✔
157
    if isinstance(data, int):
1!
158
        p = data != 1
×
159
    else:
160
        p = len(data) != 1
1✔
161
    if not p:
1✔
162
        return sword
1✔
163
    if pword:
1!
164
        return pword
1✔
165
    return sword + "s"
×
166

167

168
def english_list(items):
1✔
169
    assert len(items) > 0
1✔
170
    if len(items) == 1:
1!
171
        return items[0]
×
172

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

175

176
def shorten_long_bz_url(url):
1✔
177
    if not url or len(url) <= MAX_URL_LENGTH:
×
178
        return url
×
179

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

184
    def url_handler(u, data):
×
185
        data["url"] = u
×
186

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

195
    return data["url"]
×
196

197

198
def search_prev_merge(beta):
1✔
199
    tables = rc.get_all()
×
200

201
    # the first table is the future and the second is the recent past
202
    table = tables[1]
×
203
    central = table[0].index("Central")
×
204
    central = rc.get_versions(table[1][central])[0][0]
×
205

206
    # just check consistency
207
    assert beta == central
×
208

209
    merge = table[0].index("Merge Date")
×
210

211
    return lmdutils.get_date_ymd(table[1][merge])
×
212

213

214
def get_cycle_span():
1✔
215
    global _CYCLE_SPAN
216
    if _CYCLE_SPAN is None:
×
217
        cal = get_release_calendar()
×
218
        now = lmdutils.get_date_ymd("today")
×
219
        cycle = None
×
220
        for i, c in enumerate(cal):
×
221
            if now < c["merge"]:
×
222
                if i == 0:
×
223
                    cycle = [search_prev_merge(c["beta"]), c["merge"]]
×
224
                else:
225
                    cycle = [cal[i - 1]["merge"], c["merge"]]
×
226
                break
×
227
        if cycle:
×
228
            _CYCLE_SPAN = "-".join(x.strftime("%Y%m%d") for x in cycle)
×
229

230
    return _CYCLE_SPAN
×
231

232

233
def get_next_release_date():
1✔
234
    return rc.get_next_release_date()
×
235

236

237
def get_release_calendar():
1✔
238
    return rc.get_calendar()
×
239

240

241
def get_merge_day():
1✔
242
    global _MERGE_DAY
243
    if _MERGE_DAY is None:
×
244
        cal = get_release_calendar()
×
245
        _MERGE_DAY = cal[0]["merge"]
×
246
    return _MERGE_DAY
×
247

248

249
def is_merge_day():
1✔
250
    next_merge = get_merge_day()
×
251
    today = lmdutils.get_date_ymd("today")
×
252

253
    return next_merge == today
×
254

255

256
def get_report_bugs(channel, op="+"):
1✔
257
    url = "https://bugzilla.mozilla.org/page.cgi?id=release_tracking_report.html"
×
258
    params = {
×
259
        "q": "approval-mozilla-{}:{}:{}:0:and:".format(channel, op, get_cycle_span())
260
    }
261

262
    # allow_redirects=False avoids to load the data
263
    # and we'll just get the redirected url to get all the bug ids we need
264
    r = requests.get(url, params=params, allow_redirects=False)
×
265

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

269
    return url.split("=")[1].split(",")
×
270

271

272
def get_flag(version, name, channel):
1✔
273
    if name in ["status", "tracking"]:
1!
274
        if channel == "esr":
1✔
275
            return "cf_{}_firefox_esr{}".format(name, version)
1✔
276
        return "cf_{}_firefox{}".format(name, version)
1✔
277
    elif name == "approval":
×
278
        if channel == "esr":
×
279
            return "approval-mozilla-esr{}".format(version)
×
280
        return "approval-mozilla-{}".format(channel)
×
281

282

283
def get_needinfo(bug, days=-1):
1✔
284
    now = datetime.datetime.utcnow().replace(tzinfo=pytz.utc)
×
285
    for flag in bug.get("flags", []):
×
286
        if flag.get("name", "") == "needinfo" and flag["status"] == "?":
×
287
            date = flag["modification_date"]
×
288
            date = dateutil.parser.parse(date)
×
289
            if (now - date).days >= days:
×
290
                yield flag
×
291

292

293
def get_last_field_num(params):
1✔
294
    s = set()
1✔
295
    for k in params.keys():
1✔
296
        m = BZ_FIELD_PAT.match(k)
1✔
297
        if m:
1✔
298
            s.add(int(m.group(1)))
1✔
299

300
    x = max(s) + 1 if s else 1
1✔
301
    return str(x)
1✔
302

303

304
def add_prod_comp_to_query(params, prod_comp):
1✔
305
    n = int(get_last_field_num(params))
×
306
    params[f"j{n}"] = "OR"
×
307
    params[f"f{n}"] = "OP"
×
308
    n += 1
×
309
    for pc in prod_comp:
×
310
        prod, comp = pc.split("::")
×
311
        params[f"j{n}"] = "AND"
×
312
        params[f"f{n}"] = "OP"
×
313
        n += 1
×
314
        params[f"f{n}"] = "product"
×
315
        params[f"o{n}"] = "equals"
×
316
        params[f"v{n}"] = prod
×
317
        n += 1
×
318
        params[f"f{n}"] = "component"
×
319
        params[f"o{n}"] = "equals"
×
320
        params[f"v{n}"] = comp
×
321
        n += 1
×
322
        params[f"f{n}"] = "CP"
×
323
        n += 1
×
324
    params[f"f{n}"] = "CP"
×
325

326

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

330

331
def has_bot_set_ni(bug):
1✔
332
    bot = get_config("common", "bot_bz_mail")
×
333
    for flag in get_needinfo(bug):
×
334
        if flag["setter"] in bot:
×
335
            return True
×
336
    return False
×
337

338

339
def get_triage_owners():
1✔
340
    global _TRIAGE_OWNERS
341
    if _TRIAGE_OWNERS is not None:
×
342
        return _TRIAGE_OWNERS
×
343

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

370

371
def get_default_assignees():
1✔
372
    global _DEFAULT_ASSIGNEES
373
    if _DEFAULT_ASSIGNEES is not None:
×
374
        return _DEFAULT_ASSIGNEES
×
375

376
    # accessible is the union of:
377
    #  selectable (all product we can see)
378
    #  enterable (all product a user can file bugs into).
379
    prods = get_config("common", "products")
×
380
    url = "https://bugzilla.mozilla.org/rest/product"
×
381
    params = {
×
382
        "type": "accessible",
383
        "include_fields": ["name", "components.name", "components.default_assigned_to"],
384
        "names": prods,
385
    }
386
    r = requests.get(url, params=params)
×
387
    products = r.json()["products"]
×
388
    _DEFAULT_ASSIGNEES = {}
×
389
    for prod in products:
×
390
        prod_name = prod["name"]
×
391
        _DEFAULT_ASSIGNEES[prod_name] = dap = {}
×
392
        for comp in prod["components"]:
×
393
            comp_name = comp["name"]
×
394
            assignee = comp["default_assigned_to"]
×
395
            dap[comp_name] = assignee
×
396
    return _DEFAULT_ASSIGNEES
×
397

398

399
def organize(bugs, columns, key=None):
1✔
400
    if isinstance(bugs, dict):
×
401
        # we suppose that the values are the bugdata dict
402
        bugs = bugs.values()
×
403

404
    def identity(x):
×
405
        return x
×
406

407
    def bugid_key(x):
×
408
        return -int(x)
×
409

410
    lambdas = {"id": bugid_key}
×
411

412
    def mykey(p):
×
413
        return tuple(lambdas.get(c, identity)(x) for x, c in zip(p, columns))
×
414

415
    if len(columns) >= 2:
×
416
        res = [tuple(info[c] for c in columns) for info in bugs]
×
417
    else:
418
        c = columns[0]
×
419
        res = [info[c] for info in bugs]
×
420

421
    return sorted(res, key=mykey if not key else key)
×
422

423

424
def merge_bz_changes(c1, c2):
1✔
425
    if not c1:
×
426
        return c2
×
427
    if not c2:
×
428
        return c1
×
429

430
    assert set(c1.keys()).isdisjoint(
×
431
        c2.keys()
432
    ), "Merge changes with common keys is not a good idea"
433
    c = copy.deepcopy(c1)
×
434
    c.update(c2)
×
435

436
    return c
×
437

438

439
def is_test_file(path):
1✔
440
    e = os.path.splitext(path)[1][1:].lower()
×
441
    return "test" in path and e not in {"ini", "list", "in", "py", "json", "manifest"}
×
442

443

444
def get_better_name(name):
1✔
445
    if not name:
×
446
        return ""
×
447

448
    def repl(m):
×
449
        if m.start(0) == 0:
×
450
            return m.group(0)
×
451
        return ""
×
452

453
    if name.startswith("Nobody;"):
×
454
        s = "Nobody"
×
455
    else:
456
        s = PAR_PAT.sub("", name)
×
457
        s = BRA_PAT.sub("", s)
×
458
        s = DIA_PAT.sub("", s)
×
459
        s = COL_PAT.sub(repl, s)
×
460
        s = UTC_PAT.sub("", s)
×
461
        s = s.strip()
×
462
        if s.startswith(":"):
×
463
            s = s[1:]
×
464
    return s.encode("utf-8").decode("utf-8")
×
465

466

467
def is_backout(json):
1✔
468
    return json.get("backedoutby", "") != "" or bool(BACKOUT_PAT.search(json["desc"]))
1✔
469

470

471
def get_pushlog(startdate, enddate, channel="nightly"):
1✔
472
    """Get the pushlog from hg.mozilla.org"""
473
    # Get the pushes where startdate <= pushdate <= enddate
474
    # pushlog uses strict inequality, it's why we add +/- 1 second
475
    fmt = "%Y-%m-%d %H:%M:%S"
×
476
    startdate -= relativedelta(seconds=1)
×
477
    startdate = startdate.strftime(fmt)
×
478
    enddate += relativedelta(seconds=1)
×
479
    enddate = enddate.strftime(fmt)
×
480
    url = "{}/json-pushes".format(Mercurial.get_repo_url(channel))
×
481
    r = requests.get(
×
482
        url,
483
        params={"startdate": startdate, "enddate": enddate, "version": 2, "full": 1},
484
    )
485
    return r.json()
×
486

487

488
def get_bugs_from_desc(desc):
1✔
489
    """Get a bug number from the patch description"""
490
    return BUG_PAT.findall(desc)
×
491

492

493
def get_bugs_from_pushlog(startdate, enddate, channel="nightly"):
1✔
494
    pushlog = get_pushlog(startdate, enddate, channel=channel)
×
495
    bugs = set()
×
496
    for push in pushlog["pushes"].values():
×
497
        for chgset in push["changesets"]:
×
498
            if chgset.get("backedoutby", "") != "":
×
499
                continue
×
500
            desc = chgset["desc"]
×
501
            for bug in get_bugs_from_desc(desc):
×
502
                bugs.add(bug)
×
503
    return bugs
×
504

505

506
def get_checked_versions():
1✔
507
    # There are different reasons to not return versions:
508
    # i) we're merge day: the versions are changing
509
    # ii) not consecutive versions numbers
510
    # iii) bugzilla updated nightly version but p-d is not updated
511
    if is_merge_day():
×
512
        return {}
×
513

514
    versions = lmdversions.get(base=True)
×
515
    versions["central"] = versions["nightly"]
×
516

517
    v = [versions[k] for k in ["release", "beta", "central"]]
×
518
    versions = {k: str(v) for k, v in versions.items()}
×
519

520
    if v[0] + 2 == v[1] + 1 == v[2]:
×
521
        nightly_bugzilla = get_nightly_version_from_bz()
×
522
        if v[2] != nightly_bugzilla:
×
523
            from . import logger
×
524

525
            logger.info("Versions mismatch between Bugzilla and product-details")
×
526
            return {}
×
527
        return versions
×
528

529
    from . import logger
×
530

531
    logger.info("Not consecutive versions in product/details")
×
532
    return {}
×
533

534

535
def get_info_from_hg(json):
1✔
536
    res = {}
×
537
    push = json["pushdate"][0]
×
538
    push = datetime.datetime.utcfromtimestamp(push)
×
539
    push = lmdutils.as_utc(push)
×
540
    res["date"] = lmdutils.get_date_str(push)
×
541
    res["backedout"] = json.get("backedoutby", "") != ""
×
542
    m = BUG_PAT.search(json["desc"])
×
543
    res["bugid"] = m.group(1) if m else ""
×
544

545
    return res
×
546

547

548
def bz_ignore_case(s):
1✔
549
    return "[" + "][".join(c + c.upper() for c in s) + "]"
×
550

551

552
def check_product_component(data, bug):
1✔
553
    prod = bug["product"]
×
554
    comp = bug["component"]
×
555
    pc = prod + "::" + comp
×
556
    return pc in data or comp in data
×
557

558

559
def get_components(data):
1✔
560
    res = []
×
561
    for comp in data:
×
562
        if "::" in comp:
×
563
            _, comp = comp.split("::", 1)
×
564
        res.append(comp)
×
565
    return res
×
566

567

568
def get_products_components(data):
1✔
569
    prods = set()
×
570
    comps = set()
×
571
    for pc in data:
×
572
        if "::" in pc:
×
573
            p, c = pc.split("::", 1)
×
574
            prods.add(p)
×
575
        else:
576
            c = pc
×
577
        comps.add(c)
×
578
    return prods, comps
×
579

580

581
def ireplace(old, repl, text):
1✔
582
    return re.sub("(?i)" + re.escape(old), lambda m: repl, text)
×
583

584

585
def get_human_lag(date):
1✔
586
    today = pytz.utc.localize(datetime.datetime.utcnow())
×
587
    dt = dateutil.parser.parse(date) if isinstance(date, str) else date
×
588

589
    return humanize.naturaldelta(today - dt)
×
590

591

592
def get_nightly_version_from_bz():
1✔
593
    def bug_handler(bug, data):
×
594
        status = "cf_status_firefox"
×
595
        N = len(status)
×
596
        for k in bug.keys():
×
597
            if k.startswith(status):
×
598
                k = k[N:]
×
599
                if k.isdigit():
×
600
                    data.append(int(k))
×
601

602
    data = []
×
603
    Bugzilla(bugids=["1234567"], bughandler=bug_handler, bugdata=data).get_data().wait()
×
604

605
    return max(data)
×
606

607

608
def nice_round(val):
1✔
609
    return int(round(100 * val))
×
610

611

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

615
    Args:
616
        email: the account login email.
617
    """
618
    if email.endswith("@disabled.tld"):
1✔
619
        return False
1✔
620

621
    return email.endswith(".bugs") or email.endswith(".tld")
1✔
622

623

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

627
    Args:
628
        bug: the bug dictionary; it must has the comments list.
629

630
    Returns:
631
        If no comments or all comments are posted by bots, the creation date of
632
        the bug itself will be returned.
633
    """
634
    for comment in reversed(bug["comments"]):
×
635
        if not is_bot_email(comment["creator"]):
×
636
            return comment["creation_time"]
×
637

638
    return bug["comments"][0]["creation_time"]
×
639

640

641
def get_sort_by_bug_importance_key(bug):
1✔
642
    """
643
    We need bugs with high severity (S1 or S2) or high priority (P1 or P2) to be
644
    first (do not need to be high in both). Next, bugs with higher priority and
645
    severity are preferred. Finally, for bugs with the same severity and priority,
646
    we favour recently changed or created bugs.
647
    """
648

649
    is_important = bug["priority"] in HIGH_PRIORITY or bug["severity"] in HIGH_SEVERITY
×
650
    priority = bug["priority"] if bug["priority"].startswith("P") else "P10"
×
651
    severity = (
×
652
        bug["severity"]
653
        if bug["severity"].startswith("S")
654
        else OLD_SEVERITY_MAP.get(bug["severity"], "S10")
655
    )
656
    time_order = (
×
657
        lmdutils.get_timestamp(bug["last_change_time"])
658
        if "last_change_time" in bug
659
        else int(bug["id"])  # Bug ID reflects the creation order
660
    )
661

662
    return (
×
663
        not is_important,
664
        severity,
665
        priority,
666
        time_order * -1,
667
    )
668

669

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

673
    If the bug is assigned, the assignee will be selected. Otherwise, will
674
    fallback to the triage owner.
675

676
    Args:
677
        bug: The bug that you need to send a needinfo request about.
678

679
    Returns:
680
        A dict with the nicname and the email of the person that should receive
681
        the needinfo request. If not available will return None.
682

683
    """
684

685
    for field in ["assigned_to", "triage_owner"]:
×
686
        person = bug.get(field, "")
×
687
        if not is_no_assignee(person):
×
688
            return {"mail": person, "nickname": bug[f"{field}_detail"]["nick"]}
×
689

690
    return None
×
691

692

693
def get_name_from_user_detail(detail: dict) -> str:
1✔
694
    """Get the name of the user from the detail object.
695

696
    Returns:
697
        The name of the user or the email as a fallback.
698
    """
699
    name = detail["real_name"]
×
700
    if is_no_assignee(detail["email"]):
×
701
        name = "nobody"
×
702
    if name.strip() == "":
×
703
        name = detail["name"]
×
704
        if name.strip() == "":
×
705
            name = detail["email"]
×
706

707
    return name
×
708

709

710
def is_weekend(date: Union[datetime.datetime, str]) -> bool:
1✔
711
    """Get if the provided date is a weekend day (Saturday or Sunday)"""
712
    parsed_date = lmdutils.get_date_ymd(date)
×
713
    return parsed_date.weekday() >= 5
×
714

715

716
def get_whiteboard_access_rating(whiteboard: str) -> str:
1✔
717
    """Get the access rating tag from the whiteboard.
718

719
    Args:
720
        whiteboard: a whiteboard string that contains an access rating tag.
721

722
    Returns:
723
        An access rating tag.
724
    """
725

726
    access_tags = WHITEBOARD_ACCESS_PAT.findall(whiteboard)
×
727
    assert len(access_tags) == 1, "Should have only one access tag"
×
728

729
    return access_tags[0]
×
730

731

732
def create_bug(bug_data: dict) -> dict:
1✔
733
    """Create a new bug.
734

735
    Args:
736
        bug_data: The bug data to create.
737

738
    Returns:
739
        A dictionary with the bug id of the newly created bug.
740
    """
741
    resp = requests.post(
×
742
        url=Bugzilla.API_URL,
743
        json=bug_data,
744
        headers=Bugzilla([]).get_header(),
745
        verify=True,
746
        timeout=Bugzilla.TIMEOUT,
747
    )
748
    resp.raise_for_status()
×
749
    return resp.json()
×
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