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

mozilla / relman-auto-nag / #5005

11 May 2024 04:15PM CUT coverage: 21.862%. Remained the same
#5005

push

coveralls-python

web-flow
Update the path for the `variants.yml` file (#2391)

716 of 3594 branches covered (19.92%)

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

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

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

324
        elif action == ExpirationAction.SEND_REMINDER:
×
325
            # Escalate gradually
326
            if not self.add(
×
327
                bug["triage_owner"],
328
                data[bugid],
329
                expiration_date=bug_expiration,
330
            ):
331
                data[bugid]["action"] = ExpirationAction.SKIP
×
332

333
            if not self.has_expired_comment(bug):
×
334
                self.autofix_changes[bugid] = {
×
335
                    "comment": {
336
                        "body": EXPIRED_VARIANT_COMMENT,
337
                    },
338
                }
339

340
        return bug
×
341

342
    def is_with_patch(self, bug: dict) -> bool:
×
343
        """Check if the bug has a patch (not obsolete))"""
344
        return any(
×
345
            not attachment["is_obsolete"]
346
            and (
347
                attachment["content_type"] == "text/x-phabricator-request"
348
                or attachment["is_patch"]
349
            )
350
            for attachment in bug["attachments"]
351
        )
352

353
    def has_expired_comment(self, bug: dict) -> bool:
×
354
        """Check if the bug has the expired comment"""
355
        return any(
×
356
            "The variant has expired." in comment["raw_text"]
357
            and comment["creator"] == History.BOT
358
            for comment in bug["comments"]
359
        )
360

361
    def is_needinfoed(self, bug) -> bool:
×
362
        """Check if the triager was already needinfo'ed"""
363
        triage_owner_ni = f"needinfo?({bug['triage_owner']})"
×
364

365
        return any(
×
366
            change["field_name"] == "flagtypes.name"
367
            and change["added"] == triage_owner_ni
368
            for history in bug["history"]
369
            if history["who"] == History.BOT
370
            for change in history["changes"]
371
        )
372

373
    def get_english_expiration_delta(self, expiration_date: datetime) -> str:
×
374
        """Get the english delta between today and the expiration date
375

376
        Args:
377
            expiration_date: The expiration date to compare to today
378

379
        Returns:
380
            The english delta
381

382
        Examples
383
            - will expire in a month from now
384
            - will expire in 14 days from now
385
            - has expired today
386
            - has expired a day ago
387
            - has expired 14 days ago
388
            - has expired a month ago
389
        """
390
        delta = self.today - expiration_date
×
391
        if delta.days == 0:
×
392
            return "has expired today"
×
393

394
        if delta.days > 0:
×
395
            return "has expired " + humanize.naturaltime(delta)
×
396

397
        return "will expire in " + humanize.naturaltime(delta)
×
398

399
    def get_bz_params(self, date: str) -> dict:
×
400
        fields = [
×
401
            "triage_owner",
402
            "history",
403
            "attachments.is_patch",
404
            "attachments.is_obsolete",
405
            "attachments.content_type",
406
            "comments.raw_text",
407
            "comments.creator",
408
        ]
409
        return {
×
410
            "include_fields": fields,
411
            "resolution": "---",
412
            "status_whiteboard": "[variant-expiration]",
413
            "email1": History.BOT,
414
        }
415

416

417
if __name__ == "__main__":
×
418
    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