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

mozilla / relman-auto-nag / #5027

22 May 2024 06:45PM CUT coverage: 21.886% (-0.005%) from 21.891%
#5027

push

coveralls-python

web-flow
Autoclear needinfo on expiring variant when closing its bug (#2397)

716 of 3600 branches covered (19.89%)

0 of 2 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

1933 of 8832 relevant lines covered (21.89%)

0.22 hits per line

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

0.0
/bugbot/rules/variant_expiration.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 re
×
6
from datetime import datetime, timedelta
×
7
from enum import IntEnum, auto
×
8
from typing import Dict, Iterable, Optional
×
9

10
import humanize
×
11
import requests
×
12
import yaml
×
13
from libmozdata import utils as lmdutils
×
14
from libmozdata.bugzilla import Bugzilla
×
15
from requests.exceptions import HTTPError
×
16

17
from bugbot import logger, utils
×
18
from bugbot.bzcleaner import BzCleaner
×
19
from bugbot.components import ComponentName
×
20
from bugbot.history import History
×
21
from bugbot.nag_me import Nag
×
22

23
VARIANT_BUG_PAT = re.compile(
×
24
    r"^The variant `(.*)` expiration is on (\d{4}-\d{2}-\d{2})$"
25
)
26

27
VARIANTS_PATH = "taskcluster/kinds/test/variants.yml"
×
28
VARIANTS_SEARCHFOX_URL = "https://searchfox.org/mozilla-central/source/" + VARIANTS_PATH
×
29
VARIANTS_HG_URL = "https://hg.mozilla.org/mozilla-central/raw-file/tip/" + VARIANTS_PATH
×
30

31
BUG_DESCRIPTION = f"""
×
32
If the variant is not used anymore, please drop it from the [variants.yml]({VARIANTS_SEARCHFOX_URL}) file. If there is a need to keep the variant, please submit a patch to modify the expiration date. Variants will not be scheduled to run after the expiration date.
33

34
This bug will be closed automatically once the variant is dropped or the expiration date is updated in the [variants.yml]({VARIANTS_SEARCHFOX_URL}) file.
35

36
More information about variants can be found on [Firefox Source Docs](https://firefox-source-docs.mozilla.org/taskcluster/kinds/test.html#variants).
37

38
_Note: please do not edit the bug summary or close the bug, it could break the automation._
39
"""
40

41
EXPIRED_VARIANT_COMMENT = f"""
×
42
The variant has expired. Expired variants will not be scheduled for testing. Please remove the variant from the [variants.yml]({VARIANTS_SEARCHFOX_URL}) file.
43
"""
44

45

46
class ExpirationAction(IntEnum):
×
47
    """Actions to take on a variant expiration bug"""
48

49
    NEEDINFO_TRIAGER = auto()
×
50
    SEND_REMINDER = auto()
×
51
    CLOSE_EXTENDED = auto()
×
52
    CLOSE_DROPPED = auto()
×
53
    FILE_NEW_BUG = auto()
×
54
    SKIP = auto()
×
55

56
    def __str__(self):
×
57
        return self.name.title().replace("_", " ")
×
58

59

60
class VariantExpiration(BzCleaner, Nag):
×
61
    """Track variant expirations
62

63
    The following are the actions performed relatively to the variant expiration date:
64
        - before 30 days: create a bug in the corresponding component
65
        - before 14 days: needinfo the triage owner if there is no patch
66
        - before 7 days:  needinfo the triage owner even if there is a patch
67
        - when expired:
68
            - comment on the bug
69
            - send weekly escalation emails
70
        - If the variant extended or dropped, close the bug as FIXED
71

72
    Documentation: https://firefox-source-docs.mozilla.org/taskcluster/kinds/test.html#expired-variants
73
    """
74

75
    def __init__(
×
76
        self,
77
        open_bug_days: int = 30,
78
        needinfo_no_patch_days: int = 14,
79
        needinfo_with_patch_days: int = 7,
80
        cc_on_bugs: Iterable = (
81
            "jmaher@mozilla.com",
82
            "smujahid@mozilla.com",
83
        ),
84
    ) -> None:
85
        """Constructor
86

87
        Args:
88
            open_bug_days: number of days before the variant expiration date to
89
                create a bug.
90
            needinfo_no_patch_days: number of days before the expiration date to
91
                needinfo the triage owner if there is no patch.
92
            needinfo_with_patch_days: number of days before the expiration date
93
                to needinfo the triage owner even if there is a patch.
94
            cc_on_bugs: list of emails to cc on the bug.
95
        """
96
        super().__init__()
×
97

98
        self.variants = self.get_variants()
×
99
        self.today = lmdutils.get_date_ymd("today")
×
100
        self.open_bug_date = lmdutils.get_date_ymd(
×
101
            self.today + timedelta(open_bug_days)
102
        )
103
        self.needinfo_no_patch_date = lmdutils.get_date_ymd(
×
104
            self.today + timedelta(needinfo_no_patch_days)
105
        )
106
        self.needinfo_with_patch_date = lmdutils.get_date_ymd(
×
107
            self.today + timedelta(needinfo_with_patch_days)
108
        )
109
        self.cc_on_bugs = list(cc_on_bugs)
×
110
        self.ni_extra: Dict[str, dict] = {}
×
111

112
    def description(self) -> str:
×
113
        return "Variants that need to be dropped or extended"
×
114

115
    def has_default_products(self) -> bool:
×
116
        return False
×
117

118
    def has_product_component(self):
×
119
        return True
×
120

121
    def columns(self):
×
122
        return ["id", "product", "component", "variant_name", "expiration", "action"]
×
123

124
    def sort_columns(self):
×
125
        # sort by expiration date
126
        return lambda x: [4]
×
127

128
    def escalate(self, person, priority, **kwargs):
×
129
        # Escalate based on the number of days since the variant expiration date
130
        days = (self.today - kwargs["expiration_date"]).days
×
131
        return self.escalation.get_supervisor(priority, days, person, **kwargs)
×
132

133
    def get_variants(self) -> dict:
×
134
        """Get the variants from the variants.yml file"""
135

136
        resp = requests.get(VARIANTS_HG_URL, timeout=20)
×
137
        resp.raise_for_status()
×
138

139
        variants = yaml.safe_load(resp.text)
×
140
        for variant in variants.values():
×
141
            expiration = variant["expiration"]
×
142

143
            if expiration == "never":
×
144
                expiration = datetime.max
×
145

146
            variant["expiration"] = lmdutils.get_date_ymd(expiration)
×
147

148
        return variants
×
149

150
    def get_bugs(self, date="today", bug_ids=[], chunk_size=None) -> dict:
×
151
        bugs = super().get_bugs(date, bug_ids, chunk_size)
×
152

153
        # Create bugs for variants that will be expired soon
154
        for variant_name, variant_info in self.variants.items():
×
155
            if (
×
156
                variant_info.get("bug_id")
157
                or variant_info["expiration"] >= self.open_bug_date
158
            ):
159
                continue
×
160

161
            component = ComponentName.from_str(variant_info["component"])
×
162
            expiration = variant_info["expiration"].strftime("%Y-%m-%d")
×
163
            new_bug = {
×
164
                "summary": f"The variant `{variant_name}` expiration is on {expiration}",
165
                "product": component.product,
166
                "component": component.name,
167
                "status_whiteboard": "[variant-expiration]",
168
                "type": "task",
169
                "see_also": self.get_related_bug_ids(variant_name),
170
                "cc": self.cc_on_bugs,
171
                "description": BUG_DESCRIPTION,
172
                "version": "unspecified",
173
            }
174

175
            if self.dryrun or self.test_mode:
×
176
                bug = {"id": f"to be created for {variant_name}"}
×
177
                logger.info(
×
178
                    "A new bug for `%s` will be created with:\n%s",
179
                    variant_name,
180
                    new_bug,
181
                )
182
            else:
183
                try:
×
184
                    bug = utils.create_bug(new_bug)
×
185
                except HTTPError as error:
×
186
                    logger.error(
×
187
                        "Failed to create a bug for the variant `%s`:\n%s",
188
                        variant_name,
189
                        error.response.text,
190
                        exc_info=error,
191
                    )
192
                    continue
×
193

194
            bug_id = str(bug["id"])
×
195
            bugs[bug_id] = {
×
196
                "id": bug_id,
197
                "product": component.product,
198
                "component": component.name,
199
                "expiration": expiration,
200
                "variant_name": variant_name,
201
                "action": ExpirationAction.FILE_NEW_BUG,
202
            }
203

204
        return bugs
×
205

206
    def get_related_bug_ids(self, variant_name: str) -> list:
×
207
        """Get the list of bug ids related to the variant"""
208
        data: list = []
×
209

210
        def handler(bug, data):
×
211
            data.append(bug["id"])
×
212

213
        Bugzilla(
×
214
            {
215
                "include_fields": "id",
216
                "whiteboard": "[variant-expiration]",
217
                "email1": History.BOT,
218
                "f1": "short_desc",
219
                "o1": "casesubstring",
220
                "v1": f"The variant `{variant_name}` expiration is on",
221
            },
222
            bugdata=data,
223
            bughandler=handler,
224
        ).wait()
225

226
        return data
×
227

228
    def get_followup_action(
×
229
        self, bug: dict, variant_name: str, bug_expiration: datetime, has_patch: bool
230
    ) -> Optional[ExpirationAction]:
231
        """Get the follow up action for the bug
232

233
        Args:
234
            bug: The bug to handle
235
            variant_name: The variant id
236
            bug_expiration: The expiration of the variant as appears in the bug
237
        """
238
        variant = self.variants.get(variant_name)
×
239
        if variant is None:
×
240
            return ExpirationAction.CLOSE_DROPPED
×
241

242
        if "bug_id" in variant:
×
243
            logger.error(
×
244
                "The variant `%s` is linked to multiple bugs: %s and %s. Variants should be linked to only one open bug",
245
                variant_name,
246
                variant["bug_id"],
247
                bug["id"],
248
            )
249
            return None
×
250

251
        variant["bug_id"] = bug["id"]
×
252

253
        if variant["expiration"] > bug_expiration:
×
254
            return ExpirationAction.CLOSE_EXTENDED
×
255

256
        if variant["expiration"] < bug_expiration:
×
257
            logger.error(
×
258
                "Variant expiration for the variant `%s` (bug %s) has been decreased from %s to %s",
259
                variant_name,
260
                bug["id"],
261
                bug_expiration,
262
                variant["expiration"],
263
            )
264
            return None
×
265

266
        if variant["expiration"] <= self.today:
×
267
            return ExpirationAction.SEND_REMINDER
×
268

269
        if not self.is_needinfoed(bug):
×
270
            if variant["expiration"] <= self.needinfo_with_patch_date or (
×
271
                not has_patch and variant["expiration"] <= self.needinfo_no_patch_date
272
            ):
273
                return ExpirationAction.NEEDINFO_TRIAGER
×
274

275
        return None
×
276

277
    def get_extra_for_needinfo_template(self):
×
278
        return self.ni_extra
×
279

280
    def handle_bug(self, bug, data):
×
281
        bugid = str(bug["id"])
×
282

283
        summary_match = VARIANT_BUG_PAT.match(bug["summary"])
×
284
        assert summary_match, f"Bug {bugid} has invalid summary: {bug['summary']}"
×
285
        variant_name, bug_expiration = summary_match.groups()
×
286
        bug_expiration = lmdutils.get_date_ymd(bug_expiration)
×
287
        has_patch = self.is_with_patch(bug)
×
288

289
        action = self.get_followup_action(bug, variant_name, bug_expiration, has_patch)
×
290
        if not action:
×
291
            return None
×
292

293
        data[bugid] = {
×
294
            "action": action,
295
            "variant_name": variant_name,
296
            "expiration": bug_expiration.strftime("%Y-%m-%d"),
297
        }
298

299
        if action == ExpirationAction.CLOSE_DROPPED:
×
300
            self.autofix_changes[bugid] = {
×
301
                "status": "RESOLVED",
302
                "resolution": "FIXED",
303
                "comment": {
304
                    "body": f"The variant has been removed from the [variants.yml]({VARIANTS_SEARCHFOX_URL}) file."
305
                },
306
                "flags": [
307
                    {
308
                        "id": flag_id,
309
                        "status": "X",
310
                    }
311
                    for flag_id in self.get_needinfo_ids(bug)
312
                ],
313
            }
314

315
        elif action == ExpirationAction.CLOSE_EXTENDED:
×
316
            new_date = self.variants[variant_name]["expiration"].strftime("%Y-%m-%d")
×
317
            self.autofix_changes[bugid] = {
×
318
                "status": "RESOLVED",
319
                "resolution": "FIXED",
320
                "comment": {
321
                    "body": f"The variant expiration date got extended to {new_date}",
322
                },
323
                "flags": [
324
                    {
325
                        "id": flag_id,
326
                        "status": "X",
327
                    }
328
                    for flag_id in self.get_needinfo_ids(bug)
329
                ],
330
            }
331

332
        elif action == ExpirationAction.NEEDINFO_TRIAGER:
×
333
            self.ni_extra[bugid] = {
×
334
                "has_patch": has_patch,
335
                "expiration_str": self.get_english_expiration_delta(bug_expiration),
336
            }
337
            if not self.add_auto_ni(bugid, utils.get_mail_to_ni(bug)):
×
338
                data[bugid]["action"] = ExpirationAction.SKIP
×
339

340
        elif action == ExpirationAction.SEND_REMINDER:
×
341
            # Escalate gradually
342
            if not self.add(
×
343
                bug["triage_owner"],
344
                data[bugid],
345
                expiration_date=bug_expiration,
346
            ):
347
                data[bugid]["action"] = ExpirationAction.SKIP
×
348

349
            if not self.has_expired_comment(bug):
×
350
                self.autofix_changes[bugid] = {
×
351
                    "comment": {
352
                        "body": EXPIRED_VARIANT_COMMENT,
353
                    },
354
                }
355

356
        return bug
×
357

NEW
358
    def get_needinfo_ids(self, bug: dict) -> list[str]:
×
359
        """Get the IDs of the needinfo flags requested by the bot"""
NEW
360
        return [
×
361
            flag["id"]
362
            for flag in bug.get("flags", [])
363
            if flag["name"] == "needinfo" and flag["requestee"] == History.BOT
364
        ]
365

UNCOV
366
    def is_with_patch(self, bug: dict) -> bool:
×
367
        """Check if the bug has a patch (not obsolete))"""
368
        return any(
×
369
            not attachment["is_obsolete"]
370
            and (
371
                attachment["content_type"] == "text/x-phabricator-request"
372
                or attachment["is_patch"]
373
            )
374
            for attachment in bug["attachments"]
375
        )
376

377
    def has_expired_comment(self, bug: dict) -> bool:
×
378
        """Check if the bug has the expired comment"""
379
        return any(
×
380
            "The variant has expired." in comment["raw_text"]
381
            and comment["creator"] == History.BOT
382
            for comment in bug["comments"]
383
        )
384

385
    def is_needinfoed(self, bug) -> bool:
×
386
        """Check if the triager was already needinfo'ed"""
387
        triage_owner_ni = f"needinfo?({bug['triage_owner']})"
×
388

389
        return any(
×
390
            change["field_name"] == "flagtypes.name"
391
            and change["added"] == triage_owner_ni
392
            for history in bug["history"]
393
            if history["who"] == History.BOT
394
            for change in history["changes"]
395
        )
396

397
    def get_english_expiration_delta(self, expiration_date: datetime) -> str:
×
398
        """Get the english delta between today and the expiration date
399

400
        Args:
401
            expiration_date: The expiration date to compare to today
402

403
        Returns:
404
            The english delta
405

406
        Examples
407
            - will expire in a month from now
408
            - will expire in 14 days from now
409
            - has expired today
410
            - has expired a day ago
411
            - has expired 14 days ago
412
            - has expired a month ago
413
        """
414
        delta = self.today - expiration_date
×
415
        if delta.days == 0:
×
416
            return "has expired today"
×
417

418
        if delta.days > 0:
×
419
            return "has expired " + humanize.naturaltime(delta)
×
420

421
        return "will expire in " + humanize.naturaltime(delta)
×
422

423
    def get_bz_params(self, date: str) -> dict:
×
424
        fields = [
×
425
            "triage_owner",
426
            "history",
427
            "attachments.is_patch",
428
            "attachments.is_obsolete",
429
            "attachments.content_type",
430
            "comments.raw_text",
431
            "comments.creator",
432
        ]
433
        return {
×
434
            "include_fields": fields,
435
            "resolution": "---",
436
            "status_whiteboard": "[variant-expiration]",
437
            "email1": History.BOT,
438
        }
439

440

441
if __name__ == "__main__":
×
442
    VariantExpiration().run()
×
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