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

mozilla / relman-auto-nag / #4514

pending completion
#4514

push

coveralls-python

suhaibmujahid
Some cleanup

641 of 3228 branches covered (19.86%)

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

1817 of 8049 relevant lines covered (22.57%)

0.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/bugbot/rules/inactive_reviewer.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
×
7
from typing import Dict, List
×
8

9
from dateutil.relativedelta import relativedelta
×
10
from libmozdata import utils as lmdutils
×
11
from libmozdata.connection import Connection
×
12
from libmozdata.phabricator import PhabricatorAPI
×
13
from tenacity import retry, stop_after_attempt, wait_exponential
×
14

15
from bugbot import utils
×
16
from bugbot.bzcleaner import BzCleaner
×
17
from bugbot.history import History
×
18
from bugbot.user_activity import PHAB_CHUNK_SIZE, UserActivity, UserStatus
×
19

20
PHAB_FILE_NAME_PAT = re.compile(r"phabricator-D([0-9]+)-url\.txt")
×
21
PHAB_TABLE_PAT = re.compile(r"^\|\ \[D([0-9]+)\]\(h", flags=re.M)
×
22

23

24
class InactiveReviewer(BzCleaner):
×
25
    """Bugs with patches that are waiting for review from inactive reviewers"""
26

27
    def __init__(self, old_patch_months: int = 6):
×
28
        """Constructor
29

30
        Args:
31
            old_patch_months: number of months since creation of the patch to be
32
                considered old. If the bug has an old patch, we will mention
33
                abandon the patch as an option.
34
        """
35
        super(InactiveReviewer, self).__init__()
×
36
        self.phab = PhabricatorAPI(utils.get_login_info()["phab_api_key"])
×
37
        self.user_activity = UserActivity(include_fields=["nick"], phab=self.phab)
×
38
        self.ni_template = self.get_needinfo_template()
×
39
        self.old_patch_limit = (
×
40
            lmdutils.get_date_ymd("today") - relativedelta(months=old_patch_months)
41
        ).timestamp()
42

43
    def description(self):
×
44
        return "Bugs with inactive patch reviewers"
×
45

46
    def columns(self):
×
47
        return ["id", "summary", "revisions"]
×
48

49
    def get_bugs(self, date="today", bug_ids=[], chunk_size=None):
×
50
        bugs = super().get_bugs(date, bug_ids, chunk_size)
×
51

52
        rev_ids = {rev_id for bug in bugs.values() for rev_id in bug["rev_ids"]}
×
53
        revisions = self._get_revisions_with_inactive_reviewers(list(rev_ids))
×
54

55
        for bugid, bug in list(bugs.items()):
×
56
            inactive_revs = [
×
57
                revisions[rev_id] for rev_id in bug["rev_ids"] if rev_id in revisions
58
            ]
59
            if inactive_revs:
×
60
                bug["revisions"] = inactive_revs
×
61
                self._add_needinfo(bugid, inactive_revs)
×
62
            else:
63
                del bugs[bugid]
×
64

65
        # Resolving https://github.com/mozilla/bugbot/issues/1300 should clean this
66
        # including improve the wording in the template (i.e., "See the search query on Bugzilla").
67
        self.query_url = utils.get_bz_search_url({"bug_id": ",".join(bugs.keys())})
×
68

69
        return bugs
×
70

71
    def _add_needinfo(self, bugid: str, inactive_revs: list) -> None:
×
72
        ni_mails = {rev["author"]["name"] for rev in inactive_revs}
×
73
        nicknames = utils.english_list(
×
74
            sorted({rev["author"]["nick"] for rev in inactive_revs})
75
        )
76
        has_old_patch = any(
×
77
            revision["created_at"] < self.old_patch_limit for revision in inactive_revs
78
        )
79

80
        reviewers = {
×
81
            (reviewer["phab_username"], reviewer["status_note"])
82
            for revision in inactive_revs
83
            for reviewer in revision["reviewers"]
84
        }
85
        has_resigned = any(note == "Resigned from review" for _, note in reviewers)
×
86

87
        if len(reviewers) == 1:
×
88
            if has_resigned:
×
89
                summary = "a reviewer who resigned from the review"
×
90
            else:
91
                summary = "an inactive reviewer"
×
92
        else:
93
            if has_resigned:
×
94
                summary = "reviewers who are inactive or resigned from the review"
×
95
            else:
96
                summary = "inactive reviewers"
×
97

98
        comment = self.ni_template.render(
×
99
            revisions=inactive_revs,
100
            nicknames=nicknames,
101
            reviewers_status_summary=summary,
102
            has_old_patch=has_old_patch,
103
            plural=utils.plural,
104
            documentation=self.get_documentation(),
105
        )
106

107
        self.autofix_changes[bugid] = {
×
108
            "comment": {"body": comment},
109
            "flags": [
110
                {
111
                    "name": "needinfo",
112
                    "requestee": ni_mail,
113
                    "status": "?",
114
                    "new": "true",
115
                }
116
                for ni_mail in ni_mails
117
            ],
118
        }
119

120
    def _get_revisions_with_inactive_reviewers(self, rev_ids: list) -> Dict[int, dict]:
×
121
        revisions: List[dict] = []
×
122
        for _rev_ids in Connection.chunks(rev_ids, PHAB_CHUNK_SIZE):
×
123
            for revision in self._fetch_revisions(_rev_ids):
×
124
                if (
×
125
                    len(revision["attachments"]["reviewers"]["reviewers"]) == 0
126
                    or revision["fields"]["status"]["value"] != "needs-review"
127
                    or revision["fields"]["isDraft"]
128
                ):
129
                    continue
×
130

131
                reviewers = [
×
132
                    {
133
                        "phid": reviewer["reviewerPHID"],
134
                        "is_group": reviewer["reviewerPHID"].startswith("PHID-PROJ"),
135
                        "is_blocking": reviewer["isBlocking"],
136
                        "is_accepted": reviewer["status"] == "accepted",
137
                        "is_resigned": reviewer["status"] == "resigned",
138
                    }
139
                    for reviewer in revision["attachments"]["reviewers"]["reviewers"]
140
                ]
141

142
                # Group reviewers will be consider always active; so if there is
143
                # no other reviewers blocking, we don't need to check further.
144
                if any(
×
145
                    reviewer["is_group"] or reviewer["is_accepted"]
146
                    for reviewer in reviewers
147
                ) and not any(
148
                    not reviewer["is_accepted"]
149
                    for reviewer in reviewers
150
                    if reviewer["is_blocking"]
151
                ):
152
                    continue
×
153

154
                revisions.append(
×
155
                    {
156
                        "rev_id": revision["id"],
157
                        "title": revision["fields"]["title"],
158
                        "created_at": revision["fields"]["dateCreated"],
159
                        "author_phid": revision["fields"]["authorPHID"],
160
                        "reviewers": reviewers,
161
                    }
162
                )
163

164
        user_phids = set()
×
165
        for revision in revisions:
×
166
            user_phids.add(revision["author_phid"])
×
167
            for reviewer in revision["reviewers"]:
×
168
                user_phids.add(reviewer["phid"])
×
169

170
        users = self.user_activity.get_phab_users_with_status(
×
171
            list(user_phids), keep_active=True
172
        )
173

174
        result: Dict[int, dict] = {}
×
175
        for revision in revisions:
×
176
            # It is not useful to notify an inactive author about an inactive
177
            # reviewer, thus we should exclude revisions with inactive authors.
178
            author_info = users[revision["author_phid"]]
×
179
            if author_info["status"] != UserStatus.ACTIVE:
×
180
                continue
×
181

182
            revision["author"] = author_info
×
183

184
            inactive_reviewers = []
×
185
            for reviewer in revision["reviewers"]:
×
186
                if reviewer["is_group"]:
×
187
                    continue
×
188

189
                reviewer_info = users[reviewer["phid"]]
×
190
                if (
×
191
                    not reviewer["is_resigned"]
192
                    and reviewer_info["status"] == UserStatus.ACTIVE
193
                ):
194
                    continue
×
195

196
                reviewer["info"] = reviewer_info
×
197
                inactive_reviewers.append(reviewer)
×
198

199
            if len(inactive_reviewers) == len(revision["reviewers"]) or any(
×
200
                reviewer["is_blocking"] and not reviewer["is_accepted"]
201
                for reviewer in inactive_reviewers
202
            ):
203
                revision["reviewers"] = [
×
204
                    {
205
                        "phab_username": reviewer["info"]["phab_username"],
206
                        "status_note": self._get_reviewer_status_note(reviewer),
207
                    }
208
                    for reviewer in inactive_reviewers
209
                ]
210
                result[revision["rev_id"]] = revision
×
211

212
        return result
×
213

214
    @staticmethod
×
215
    def _get_reviewer_status_note(reviewer: dict) -> str:
×
216
        if reviewer["is_resigned"]:
×
217
            return "Resigned from review"
×
218

219
        status = reviewer["info"]["status"]
×
220
        if status == UserStatus.UNAVAILABLE:
×
221
            until = reviewer["info"]["unavailable_until"]
×
222
            if until:
×
223
                return_date = datetime.fromtimestamp(until).strftime("%b %-d, %Y")
×
224
                return f"Back {return_date}"
×
225

226
            return "Unavailable"
×
227

228
        if status == UserStatus.DISABLED:
×
229
            return "Disabled"
×
230

231
        return "Inactive"
×
232

233
    @retry(
×
234
        wait=wait_exponential(min=4),
235
        stop=stop_after_attempt(5),
236
    )
237
    def _fetch_revisions(self, ids: list):
×
238
        return self.phab.request(
×
239
            "differential.revision.search",
240
            constraints={"ids": ids},
241
            attachments={"reviewers": True},
242
        )["data"]
243

244
    def handle_bug(self, bug, data):
×
245
        rev_ids = [
×
246
            int(PHAB_FILE_NAME_PAT.findall(attachment["file_name"])[0])
247
            for attachment in bug["attachments"]
248
            if attachment["content_type"] == "text/x-phabricator-request"
249
            and not attachment["is_obsolete"]
250
        ]
251

252
        if not rev_ids:
×
253
            return
×
254

255
        # We should not comment about the same patch more than once.
256
        rev_ids_with_ni = set()
×
257
        for comment in bug["comments"]:
×
258
            if comment["creator"] == History.BOT and comment["raw_text"].startswith(
×
259
                "The following patch"
260
            ):
261
                rev_ids_with_ni.update(
×
262
                    int(id) for id in PHAB_TABLE_PAT.findall(comment["raw_text"])
263
                )
264

265
        if rev_ids_with_ni:
×
266
            rev_ids = [id for id in rev_ids if id not in rev_ids_with_ni]
×
267

268
        if not rev_ids:
×
269
            return
×
270

271
        # It will be nicer to show a sorted list of patches
272
        rev_ids.sort()
×
273

274
        bugid = str(bug["id"])
×
275
        data[bugid] = {
×
276
            "rev_ids": rev_ids,
277
        }
278
        return bug
×
279

280
    def get_bz_params(self, date):
×
281
        fields = [
×
282
            "comments.raw_text",
283
            "comments.creator",
284
            "attachments.file_name",
285
            "attachments.content_type",
286
            "attachments.is_obsolete",
287
        ]
288
        params = {
×
289
            "include_fields": fields,
290
            "resolution": "---",
291
            "f1": "attachments.mimetype",
292
            "o1": "equals",
293
            "v1": "text/x-phabricator-request",
294
        }
295

296
        return params
×
297

298

299
if __name__ == "__main__":
×
300
    InactiveReviewer().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