• 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

0.0
/auto_nag/scripts/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 auto_nag import logger, utils
×
18
from auto_nag.bzcleaner import BzCleaner
×
19
from auto_nag.components import ComponentName
×
20
from auto_nag.history import History
×
21
from auto_nag.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/ci/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 summery 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
            }
173

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

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

202
        return bugs
×
203

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

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

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

224
        return data
×
225

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

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

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

249
        variant["bug_id"] = bug["id"]
×
250

251
        if variant["expiration"] > bug_expiration:
×
252
            return ExpirationAction.CLOSE_EXTENDED
×
253

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

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

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

273
        return None
×
274

275
    def get_extra_for_needinfo_template(self):
×
276
        return self.ni_extra
×
277

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

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

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

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

297
        if action == ExpirationAction.CLOSE_DROPPED:
×
298
            self.autofix_changes[bugid] = {
×
299
                "status": "RESOLVED",
300
                "resolution": "FIXED",
301
                "comment": {
302
                    "body": f"The variant has been removed from the [variants.yml]({VARIANTS_SEARCHFOX_URL}) file."
303
                },
304
            }
305
        elif action == ExpirationAction.CLOSE_EXTENDED:
×
306
            new_date = self.variants[variant_name]["expiration"].strftime("%Y-%m-%d")
×
307
            self.autofix_changes[bugid] = {
×
308
                "status": "RESOLVED",
309
                "resolution": "FIXED",
310
                "comment": {
311
                    "body": f"The variant expiration date got extended to {new_date}",
312
                },
313
            }
314
        elif action == ExpirationAction.NEEDINFO_TRIAGER:
×
315
            self.ni_extra[bugid] = {
×
316
                "has_patch": has_patch,
317
                "expiration_str": self.get_english_expiration_delta(bug_expiration),
318
            }
319
            if not self.add_auto_ni(bugid, utils.get_mail_to_ni(bug)):
×
320
                data[bugid]["action"] = ExpirationAction.SKIP
×
321

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

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

338
        return bug
×
339

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

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

357
    def is_needinfoed(self, bug) -> bool:
×
358
        """Check if the triager was already needinfo'ed"""
359
        triage_owner_ni = f"needinfo?({bug['triage_owner']})"
×
360

361
        return any(
×
362
            change["field_name"] == "flagtypes.name"
363
            and change["added"] == triage_owner_ni
364
            for history in bug["history"]
365
            if history["who"] == History.BOT
366
            for change in history["changes"]
367
        )
368

369
    def get_english_expiration_delta(self, expiration_date: datetime) -> str:
×
370
        """Get the english delta between today and the expiration date
371

372
        Args:
373
            expiration_date: The expiration date to compare to today
374

375
        Returns:
376
            The english delta
377

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

390
        if delta.days > 0:
×
391
            return "has expired " + humanize.naturaltime(delta)
×
392

393
        return "will expire in " + humanize.naturaltime(delta)
×
394

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

412

413
if __name__ == "__main__":
×
414
    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