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

mozilla / relman-auto-nag / #4114

pending completion
#4114

push

coveralls-python

suhaibmujahid
[variant_expiration] Log error details when failing to create a bug

549 of 3073 branches covered (17.87%)

1774 of 7951 relevant lines covered (22.31%)

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 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
            }
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`:\n%s",
187
                        variant_name,
188
                        error.response.text,
189
                        exc_info=error,
190
                    )
191
                    continue
×
192

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

203
        return bugs
×
204

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

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

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

225
        return data
×
226

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

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

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

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

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

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

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

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

274
        return None
×
275

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

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

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

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

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

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

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

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

339
        return bug
×
340

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

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

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

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

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

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

376
        Returns:
377
            The english delta
378

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

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

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

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

413

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