• 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/bisection_without_regressed_by.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 typing import Dict, List
×
7

8
import requests
×
9
from libmozdata import utils as lmdutils
×
10
from libmozdata.bugzilla import Bugzilla
×
11

12
from auto_nag import logger, utils
×
13
from auto_nag.bzcleaner import BzCleaner
×
14
from auto_nag.people import People
×
15
from auto_nag.user_activity import UserActivity, UserStatus
×
16

17
PUSHLOG_PAT = re.compile(
×
18
    r"https:\/\/hg\.mozilla\.org\/[a-z0-9-/]*\/pushloghtml\?[a-z0-9=&]*[a-z0-9]"
19
)
20
BUG_PAT = re.compile(r"[\t ]*[Bb][Uu][Gg][\t ]*([0-9]+)")
×
21

22

23
def is_ignorable_path(path: str) -> bool:
×
24
    # TODO: also ignore other kinds of files that certainly can't cause regressions.
25

26
    if any(
×
27
        path.endswith(ext) for ext in (".txt", ".md", ".rst", ".pdf", ".doc", ".otf")
28
    ):
29
        return True
×
30

31
    # This code was adapted from https://github.com/mozsearch/mozsearch/blob/2e24a308bf66b4c149683bfeb4ceeea3b250009a/router/router.py#L127
32
    if (
×
33
        "/test/" in path
34
        or "/tests/" in path
35
        or "/mochitest/" in path
36
        or "/unit/" in path
37
        or "/gtest/" in path
38
        or "testing/" in path
39
        or "/jsapi-tests/" in path
40
        or "/reftests/" in path
41
        or "/reftest/" in path
42
        or "/crashtests/" in path
43
        or "/crashtest/" in path
44
        or "/gtests/" in path
45
        or "/googletest/" in path
46
    ):
47
        return True
×
48

49
    return False
×
50

51

52
class BisectionWithoutRegressedBy(BzCleaner):
×
53
    def __init__(
×
54
        self,
55
        max_ni: int = 3,
56
        oldest_comment_weeks: int = 26,
57
        components_skiplist: List[str] = ["Testing::mozregression"],
58
    ) -> None:
59
        """Constructor
60

61
        Args:
62
            max_ni: The maximum number of regression authors to needinfo. If the
63
                number of authors exceeds the limit no one will be needinfo'ed.
64
            oldest_comment_weeks: the number of weeks to look back. We will
65
                consider only comments posted in this period.
66
            components_skiplist: product/components to skip.
67
        """
68
        super().__init__()
×
69
        self.people = People.get_instance()
×
70
        self.autofix_regressed_by: Dict[str, str] = {}
×
71
        self.max_ni = max_ni
×
72
        self.oldest_comment_date = lmdutils.get_date("today", oldest_comment_weeks * 7)
×
73
        self.components_skiplist = components_skiplist
×
74

75
    def description(self):
×
76
        return "Bugs with a bisection analysis and without regressed_by"
×
77

78
    def has_product_component(self):
×
79
        return True
×
80

81
    def handle_bug(self, bug, data):
×
82
        # check if the product::component is in the list
83
        if utils.check_product_component(self.components_skiplist, bug):
×
84
            return None
×
85

86
        bugid = str(bug["id"])
×
87
        data[bugid] = {
×
88
            "assigned_to": bug["assigned_to"],
89
            "creation_time": bug["creation_time"],
90
            "is_open": bug["is_open"],
91
        }
92
        return bug
×
93

94
    def columns(self):
×
95
        return ["id", "summary", "pushlog_source", "comment_number"]
×
96

97
    def set_autofix(self, bugs):
×
98
        ni_template = self.get_needinfo_template()
×
99
        docs = self.get_documentation()
×
100

101
        for bug_id, bug in bugs.items():
×
102
            comment_number = bug["comment_number"]
×
103
            pushlog_source = bug["pushlog_source"]
×
104
            if "regressor_bug_id" in bug:
×
105
                autofix = {
×
106
                    "comment": {
107
                        "body": f"Setting `Regressed by` field after analyzing regression range found by {pushlog_source} in comment #{comment_number}."
108
                    },
109
                    "regressed_by": {"add": [bug["regressor_bug_id"]]},
110
                }
111
            elif "needinfo_targets" in bug:
×
112
                nicknames = [":" + user["nickname"] for user in bug["needinfo_targets"]]
×
113
                ni_comment = ni_template.render(
×
114
                    nicknames=utils.english_list(nicknames),
115
                    authors_count=len(nicknames),
116
                    is_assignee=not utils.is_no_assignee(bug["assigned_to"]),
117
                    is_open=bug["is_open"],
118
                    comment_number=comment_number,
119
                    pushlog_source=pushlog_source,
120
                    plural=utils.plural,
121
                    documentation=docs,
122
                )
123
                ni_flags = [
×
124
                    {
125
                        "name": "needinfo",
126
                        "requestee": user["mail"],
127
                        "status": "?",
128
                        "new": "true",
129
                    }
130
                    for user in bug["needinfo_targets"]
131
                ]
132
                autofix = {
×
133
                    "flags": ni_flags,
134
                    "comment": {"body": ni_comment},
135
                }
136
            else:
137
                raise Exception(
×
138
                    "The bug should either has a regressor or a needinfo target"
139
                )
140

141
            autofix.update(
×
142
                {
143
                    "keywords": {"add": ["regression"]},
144
                }
145
            )
146

147
            self.autofix_regressed_by[bug_id] = autofix
×
148

149
    def get_autofix_change(self) -> dict:
×
150
        return self.autofix_regressed_by
×
151

152
    def get_bz_params(self, date):
×
153
        return {
×
154
            "include_fields": ["assigned_to", "creation_time", "is_open"],
155
            "f1": "regressed_by",
156
            "o1": "isempty",
157
            "n2": 1,
158
            "f2": "regressed_by",
159
            "o2": "everchanged",
160
            "n3": 1,
161
            "f3": "longdesc",
162
            "o3": "substring",
163
            "v3": " this bug contains a bisection range",
164
            # TODO: Nag in the duplicate target instead, making sure it doesn't already have regressed_by.
165
            "f4": "resolution",
166
            "o4": "notequals",
167
            "v4": "DUPLICATE",
168
            "f5": "delta_ts",
169
            "o5": "greaterthan",
170
            "v5": self.oldest_comment_date,
171
            "f6": "OP",
172
            "j6": "OR",
173
            "f7": "commenter",
174
            "o7": "equals",
175
            "v7": "bugmon@mozilla.com",
176
            "f8": "longdesc",
177
            "o8": "substring",
178
            "v8": "mozregression",
179
            "f9": "CP",
180
            "f10": "keywords",
181
            "o10": "nowords",
182
            "v10": "regressionwindow-wanted",
183
        }
184

185
    @staticmethod
×
186
    def is_mozregression_analysis(comment_text: str):
×
187
        """Check if the comment has a regression range from mozregression."""
188
        return (
×
189
            "ozregression" in comment_text
190
            and "pushloghtml" in comment_text
191
            and "find-fix" not in comment_text
192
            and "First good revision" not in comment_text
193
            and "Last bad revision" not in comment_text
194
        )
195

196
    @staticmethod
×
197
    def is_bugmon_analysis(comment_text: str):
×
198
        """Check if the comment has a regression range from bugmon."""
199
        return (
×
200
            "BugMon: Reduced build range" in comment_text
201
            or "The bug appears to have been introduced in the following build range"
202
            in comment_text
203
        )
204

205
    def comment_handler(self, bug, bug_id, bugs):
×
206
        analysis_comment_number = None
×
207
        # We start from the last comment just in case bugmon has updated the range.
208
        for comment in bug["comments"][::-1]:
×
209
            if comment["creation_time"] < self.oldest_comment_date:
×
210
                break
×
211

212
            # Using comments that quote other comments will lead to report an
213
            # inaccurate comment number.
214
            if "(In reply to " in comment["text"]:
×
215
                continue
×
216

217
            # We target comments that have pushlog from BugMon or mozregression.
218
            if self.is_bugmon_analysis(comment["text"]):
×
219
                pushlog_source = "bugmon"
×
220
            elif self.is_mozregression_analysis(comment["text"]):
×
221
                pushlog_source = "mozregression"
×
222
            else:
223
                continue
×
224

225
            pushlog_match = PUSHLOG_PAT.findall(comment["text"])
×
226
            if len(pushlog_match) != 1:
×
227
                continue
×
228

229
            # Try to parse the regression range to find the regressor or at least somebody good to needinfo.
230
            url = (
×
231
                pushlog_match[0].replace("pushloghtml", "json-pushes")
232
                + "&full=1&version=2"
233
            )
234
            r = requests.get(url)
×
235
            r.raise_for_status()
×
236

237
            creation_time = lmdutils.get_timestamp(bugs[bug_id]["creation_time"])
×
238
            changesets = [
×
239
                changeset
240
                for push in r.json()["pushes"].values()
241
                if creation_time > push["date"]
242
                for changeset in push["changesets"]
243
                if any(not is_ignorable_path(path) for path in changeset["files"])
244
            ]
245

246
            if not changesets:
×
247
                continue
×
248

249
            analysis_comment_number = comment["count"]
×
250
            regressor_bug_ids = set()
×
251
            for changeset in changesets:
×
252
                bug_match = BUG_PAT.search(changeset["desc"])
×
253
                if bug_match is not None:
×
254
                    regressor_bug_ids.add(bug_match.group(1))
×
255

256
            if len(regressor_bug_ids) == 1:
×
257
                # Only one bug in the regression range, we are sure about the regressor!
258
                bugs[bug_id]["regressor_bug_id"] = regressor_bug_ids.pop()
×
259
                break
×
260

261
            if "needinfo_targets" not in bugs[bug_id]:
×
262
                authors = set(changeset["author"] for changeset in changesets)
×
263
                if authors and len(authors) <= self.max_ni:
×
264
                    needinfo_targets = []
×
265
                    for author in authors:
×
266
                        author_parts = author.split("<")
×
267
                        author_email = author_parts[1][:-1]
×
268
                        bzmail = self.people.get_bzmail_from_name(author_email)
×
269
                        if not bzmail:
×
270
                            logger.warning(f"No bzmail for {author} in bug {bug_id}")
×
271
                            continue
×
272
                        needinfo_targets.append(bzmail)
×
273

274
                    if needinfo_targets:
×
275
                        bugs[bug_id]["needinfo_targets"] = needinfo_targets
×
276
                        break
×
277

278
        # Exclude bugs that do not have a range found by BugMon or mozregression.
279
        if analysis_comment_number is not None:
×
280
            bug = bugs[bug_id]
×
281
            bug["comment_number"] = analysis_comment_number
×
282
            bug["pushlog_source"] = pushlog_source
×
283
        else:
284
            del bugs[bug_id]
×
285

286
    def find_regressor_or_needinfo_target(self, bugs: dict) -> dict:
×
287
        # Needinfo assignee when there is one.
288
        for bug in bugs.values():
×
289
            if not utils.is_no_assignee(bug["assigned_to"]):
×
290
                bug["needinfo_targets"] = [bug["assigned_to"]]
×
291

292
        Bugzilla(
×
293
            bugids=self.get_list_bugs(bugs),
294
            commenthandler=self.comment_handler,
295
            commentdata=bugs,
296
            comment_include_fields=["text", "creation_time", "count"],
297
        ).get_data().wait()
298

299
        bzemails = list(
×
300
            {
301
                bzemail
302
                for bug in bugs.values()
303
                if "needinfo_targets" in bug
304
                for bzemail in bug["needinfo_targets"]
305
            }
306
        )
307

308
        if bzemails:
×
309
            users = UserActivity(include_fields=["nick"]).get_bz_users_with_status(
×
310
                bzemails, keep_active=True
311
            )
312

313
            for bug in bugs.values():
×
314
                if "needinfo_targets" in bug:
×
315
                    needinfo_targets = []
×
316
                    for bzemail in bug["needinfo_targets"]:
×
317
                        user = users[bzemail]
×
318
                        if user["status"] == UserStatus.ACTIVE:
×
319
                            needinfo_targets.append(
×
320
                                {
321
                                    "mail": bzemail,
322
                                    "nickname": user["nick"],
323
                                }
324
                            )
325

326
                    if needinfo_targets:
×
327
                        bug["needinfo_targets"] = needinfo_targets
×
328
                    else:
329
                        del bug["needinfo_targets"]
×
330

331
        # Exclude all bugs where we couldn't find a definite regressor bug ID or an applicable needinfo target.
332
        bugs = {
×
333
            bug_id: bug
334
            for bug_id, bug in bugs.items()
335
            if "regressor_bug_id" in bug or "needinfo_targets" in bug
336
        }
337

338
        return bugs
×
339

340
    def get_bugs(self, date="today", bug_ids=[]):
×
341
        bugs = super().get_bugs(date=date, bug_ids=bug_ids)
×
342
        bugs = self.find_regressor_or_needinfo_target(bugs)
×
343
        self.set_autofix(bugs)
×
344

345
        return bugs
×
346

347

348
if __name__ == "__main__":
×
349
    BisectionWithoutRegressedBy().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