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

mozilla / relman-auto-nag / #5008

16 May 2024 03:15PM UTC coverage: 21.862%. Remained the same
#5008

push

coveralls-python

benjaminmah
Added back comma.

716 of 3594 branches covered (19.92%)

1930 of 8828 relevant lines covered (21.86%)

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
                        "name": "needinfo",
309
                        "status": "X",
310
                    }
311
                ],
312
            }
313
        elif action == ExpirationAction.CLOSE_EXTENDED:
×
314
            new_date = self.variants[variant_name]["expiration"].strftime("%Y-%m-%d")
×
315
            self.autofix_changes[bugid] = {
×
316
                "status": "RESOLVED",
317
                "resolution": "FIXED",
318
                "comment": {
319
                    "body": f"The variant expiration date got extended to {new_date}",
320
                },
321
                "flags": [
322
                    {
323
                        "name": "needinfo",
324
                        "status": "X",
325
                    }
326
                ],
327
            }
328
        elif action == ExpirationAction.NEEDINFO_TRIAGER:
×
329
            self.ni_extra[bugid] = {
×
330
                "has_patch": has_patch,
331
                "expiration_str": self.get_english_expiration_delta(bug_expiration),
332
            }
333
            if not self.add_auto_ni(bugid, utils.get_mail_to_ni(bug)):
×
334
                data[bugid]["action"] = ExpirationAction.SKIP
×
335

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

345
            if not self.has_expired_comment(bug):
×
346
                self.autofix_changes[bugid] = {
×
347
                    "comment": {
348
                        "body": EXPIRED_VARIANT_COMMENT,
349
                    },
350
                }
351

352
        return bug
×
353

354
    def is_with_patch(self, bug: dict) -> bool:
×
355
        """Check if the bug has a patch (not obsolete))"""
356
        return any(
×
357
            not attachment["is_obsolete"]
358
            and (
359
                attachment["content_type"] == "text/x-phabricator-request"
360
                or attachment["is_patch"]
361
            )
362
            for attachment in bug["attachments"]
363
        )
364

365
    def has_expired_comment(self, bug: dict) -> bool:
×
366
        """Check if the bug has the expired comment"""
367
        return any(
×
368
            "The variant has expired." in comment["raw_text"]
369
            and comment["creator"] == History.BOT
370
            for comment in bug["comments"]
371
        )
372

373
    def is_needinfoed(self, bug) -> bool:
×
374
        """Check if the triager was already needinfo'ed"""
375
        triage_owner_ni = f"needinfo?({bug['triage_owner']})"
×
376

377
        return any(
×
378
            change["field_name"] == "flagtypes.name"
379
            and change["added"] == triage_owner_ni
380
            for history in bug["history"]
381
            if history["who"] == History.BOT
382
            for change in history["changes"]
383
        )
384

385
    def get_english_expiration_delta(self, expiration_date: datetime) -> str:
×
386
        """Get the english delta between today and the expiration date
387

388
        Args:
389
            expiration_date: The expiration date to compare to today
390

391
        Returns:
392
            The english delta
393

394
        Examples
395
            - will expire in a month from now
396
            - will expire in 14 days from now
397
            - has expired today
398
            - has expired a day ago
399
            - has expired 14 days ago
400
            - has expired a month ago
401
        """
402
        delta = self.today - expiration_date
×
403
        if delta.days == 0:
×
404
            return "has expired today"
×
405

406
        if delta.days > 0:
×
407
            return "has expired " + humanize.naturaltime(delta)
×
408

409
        return "will expire in " + humanize.naturaltime(delta)
×
410

411
    def get_bz_params(self, date: str) -> dict:
×
412
        fields = [
×
413
            "triage_owner",
414
            "history",
415
            "attachments.is_patch",
416
            "attachments.is_obsolete",
417
            "attachments.content_type",
418
            "comments.raw_text",
419
            "comments.creator",
420
        ]
421
        return {
×
422
            "include_fields": fields,
423
            "resolution": "---",
424
            "status_whiteboard": "[variant-expiration]",
425
            "email1": History.BOT,
426
        }
427

428

429
if __name__ == "__main__":
×
430
    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