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

mozilla / relman-auto-nag / #5057

04 Jun 2024 02:25PM UTC coverage: 21.517% (-0.2%) from 21.715%
#5057

push

coveralls-python

benjaminmah
Added functions from `inactive_reviewer.py`

716 of 3683 branches covered (19.44%)

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

2 existing lines in 1 file now uncovered.

1932 of 8979 relevant lines covered (21.52%)

0.22 hits per line

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

0.0
/bugbot/rules/inactive_revision.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

NEW
5
import datetime
×
UNCOV
6
import re
×
7

8
from dateutil.relativedelta import relativedelta
×
9
from libmozdata import utils as lmdutils
×
NEW
10
from libmozdata.connection import Connection
×
UNCOV
11
from libmozdata.phabricator import PhabricatorAPI
×
12

13
from bugbot import utils
×
14
from bugbot.bzcleaner import BzCleaner
×
NEW
15
from bugbot.history import History
×
NEW
16
from bugbot.user_activity import PHAB_CHUNK_SIZE, UserActivity, UserStatus
×
17

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

21

22
class InactiveRevision(BzCleaner):
×
23
    """Bugs with patches that are waiting for review from inactive reviewers"""
24

25
    def __init__(self, old_patch_months: int = 6):
×
26
        """Constructor
27

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

41
    def description(self):
×
42
        return "Bugs with inactive patch reviewers"
×
43

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

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

50
        rev_ids = {rev_id for bug in bugs.values() for rev_id in bug["rev_ids"]}
×
51
        revisions = self._get_revisions_with_inactive_action(list(rev_ids))
×
52

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

63
        self.query_url = utils.get_bz_search_url({"bug_id": ",".join(bugs.keys())})
×
64

65
        return bugs
×
66

67
    def _add_needinfo(self, bugid: str, inactive_revs: list) -> None:
×
68
        for revision in inactive_revs:
×
69
            last_action_by, _ = self._find_last_action(revision["rev_id"])
×
70

71
            if last_action_by == "author":
×
72
                ni_mail = revision["reviewers"][0]["phab_username"]
×
73
                summary = (
×
74
                    "The last action was by the author, so needinfoing the reviewer."
75
                )
76
            elif last_action_by == "reviewer":
×
77
                ni_mail = revision["author"]["phab_username"]
×
78
                summary = (
×
79
                    "The last action was by the reviewer, so needinfoing the author."
80
                )
81
            else:
82
                continue
×
83

84
            comment = self.ni_template.render(
×
85
                revisions=[revision],
86
                nicknames=revision["author"]["nick"],
87
                reviewers_status_summary=summary,
88
                has_old_patch=revision["created_at"] < self.old_patch_limit,
89
                plural=utils.plural,
90
                documentation=self.get_documentation(),
91
            )
92

93
            self.autofix_changes[bugid] = {
×
94
                "comment": {"body": comment},
95
                "flags": [
96
                    {
97
                        "name": "needinfo",
98
                        "requestee": ni_mail,
99
                        "status": "?",
100
                        "new": "true",
101
                    }
102
                ],
103
            }
104

105
    def _find_last_action(self, revision_id):
×
NEW
106
        details = self._fetch_revisions([revision_id])
×
107

108
        if not details:
×
109
            return None, None
×
110

111
        revision = details[0]
×
112
        author_phid = revision["fields"]["authorPHID"]
×
113
        reviewers = [
×
114
            reviewer["reviewerPHID"]
115
            for reviewer in revision["attachments"]["reviewers"]["reviewers"]
116
        ]
117

118
        transactions = self._fetch_revision_transactions(revision["phid"])
×
119

120
        last_transaction = None
×
121
        for transaction in transactions:
×
122
            if (
×
123
                last_transaction is None
124
                or transaction["dateCreated"] > last_transaction["dateCreated"]
125
            ):
126
                last_transaction = transaction
×
127

128
        if last_transaction:
×
129
            last_action_by_phid = last_transaction["authorPHID"]
×
130
            if last_action_by_phid == author_phid:
×
131
                last_action_by = "author"
×
132
            elif last_action_by_phid in reviewers:
×
133
                last_action_by = "reviewer"
×
134
            else:
135
                last_action_by = "other"
×
136
        else:
137
            last_action_by = "unknown"
×
138

139
        return last_action_by, last_transaction
×
140

NEW
141
    def _get_revisions_with_inactive_action(self, rev_ids: list) -> dict:
×
NEW
142
        revisions = []
×
143

NEW
144
        for _rev_ids in Connection.chunks(rev_ids, PHAB_CHUNK_SIZE):
×
NEW
145
            for revision in self._fetch_revisions(_rev_ids):
×
146
                # Filter out irrelevant revisions
NEW
147
                if (
×
148
                    len(revision["attachments"]["reviewers"]["reviewers"]) == 0
149
                    or revision["fields"]["status"]["value"] != "needs-review"
150
                    or revision["fields"]["isDraft"]
151
                ):
NEW
152
                    continue
×
153

NEW
154
                reviewers = [
×
155
                    {
156
                        "phid": reviewer["reviewerPHID"],
157
                        "is_group": reviewer["reviewerPHID"].startswith("PHID-PROJ"),
158
                        "is_blocking": reviewer["isBlocking"],
159
                        "is_accepted": reviewer["status"] == "accepted",
160
                        "is_resigned": reviewer["status"] == "resigned",
161
                    }
162
                    for reviewer in revision["attachments"]["reviewers"]["reviewers"]
163
                ]
164

165
                # Skip revisions where all reviewers are active or no blocking reviewer is inactive
NEW
166
                if any(
×
167
                    reviewer["is_group"] or reviewer["is_accepted"]
168
                    for reviewer in reviewers
169
                ) and not any(
170
                    not reviewer["is_accepted"]
171
                    for reviewer in reviewers
172
                    if reviewer["is_blocking"]
173
                ):
NEW
174
                    continue
×
175

NEW
176
                revisions.append(
×
177
                    {
178
                        "rev_id": revision["id"],
179
                        "title": revision["fields"]["title"],
180
                        "created_at": revision["fields"]["dateCreated"],
181
                        "author_phid": revision["fields"]["authorPHID"],
182
                        "reviewers": reviewers,
183
                    }
184
                )
185

NEW
186
        user_phids = set()
×
NEW
187
        for revision in revisions:
×
NEW
188
            user_phids.add(revision["author_phid"])
×
NEW
189
            for reviewer in revision["reviewers"]:
×
NEW
190
                user_phids.add(reviewer["phid"])
×
191

NEW
192
        users = self.user_activity.get_phab_users_with_status(
×
193
            list(user_phids), keep_active=True
194
        )
195

NEW
196
        result = {}
×
NEW
197
        for revision in revisions:
×
NEW
198
            author_info = users[revision["author_phid"]]
×
NEW
199
            if author_info["status"] != UserStatus.ACTIVE:
×
NEW
200
                continue
×
201

NEW
202
            revision["author"] = author_info
×
203

NEW
204
            inactive_reviewers = []
×
NEW
205
            for reviewer in revision["reviewers"]:
×
NEW
206
                if reviewer["is_group"]:
×
NEW
207
                    continue
×
208

NEW
209
                reviewer_info = users[reviewer["phid"]]
×
NEW
210
                if (
×
211
                    not reviewer["is_resigned"]
212
                    and reviewer_info["status"] == UserStatus.ACTIVE
213
                ):
NEW
214
                    continue
×
215

NEW
216
                reviewer["info"] = reviewer_info
×
NEW
217
                inactive_reviewers.append(reviewer)
×
218

NEW
219
            last_action_by, last_transaction = self._find_last_action(
×
220
                revision["rev_id"]
221
            )
222

NEW
223
            if last_action_by in ["author", "reviewer"]:
×
NEW
224
                revision["last_action_by"] = last_action_by
×
NEW
225
                revision["last_transaction"] = last_transaction
×
NEW
226
                revision["reviewers"] = [
×
227
                    {
228
                        "phab_username": reviewer["info"]["phab_username"],
229
                        "status_note": self._get_reviewer_status_note(reviewer),
230
                    }
231
                    for reviewer in inactive_reviewers
232
                ]
NEW
233
                result[revision["rev_id"]] = revision
×
234

NEW
235
        return result
×
236

NEW
237
    def _fetch_revisions(self, revision_ids):
×
NEW
238
        return self.phab.request(
×
239
            "differential.revision.search",
240
            constraints={"ids": revision_ids},
241
            attachments={"reviewers": True},
242
        )["data"]
243

NEW
244
    def _fetch_revision_transactions(self, revision_phid):
×
NEW
245
        response = self.phab.request(
×
246
            "transaction.search", objectIdentifier=revision_phid
247
        )
NEW
248
        return response["data"]
×
249

NEW
250
    def handle_bug(self, bug, data):
×
NEW
251
        rev_ids = [
×
252
            # To avoid loading the attachment content (which can be very large),
253
            # we extract the revision id from the file name, which is in the
254
            # format of "phabricator-D{revision_id}-url.txt".
255
            # len("phabricator-D") == 13
256
            # len("-url.txt") == 8
257
            int(attachment["file_name"][13:-8])
258
            for attachment in bug["attachments"]
259
            if attachment["content_type"] == "text/x-phabricator-request"
260
            and PHAB_FILE_NAME_PAT.match(attachment["file_name"])
261
            and not attachment["is_obsolete"]
262
        ]
263

NEW
264
        if not rev_ids:
×
NEW
265
            return
×
266

267
        # We should not comment about the same patch more than once.
NEW
268
        rev_ids_with_ni = set()
×
NEW
269
        for comment in bug["comments"]:
×
NEW
270
            if comment["creator"] == History.BOT and comment["raw_text"].startswith(
×
271
                "The following patch"
272
            ):
NEW
273
                rev_ids_with_ni.update(
×
274
                    int(id) for id in PHAB_TABLE_PAT.findall(comment["raw_text"])
275
                )
276

NEW
277
        if rev_ids_with_ni:
×
NEW
278
            rev_ids = [id for id in rev_ids if id not in rev_ids_with_ni]
×
279

NEW
280
        if not rev_ids:
×
NEW
281
            return
×
282

283
        # It will be nicer to show a sorted list of patches
NEW
284
        rev_ids.sort()
×
285

NEW
286
        bugid = str(bug["id"])
×
NEW
287
        data[bugid] = {
×
288
            "rev_ids": rev_ids,
289
        }
NEW
290
        return bug
×
291

NEW
292
    def get_bz_params(self, date):
×
NEW
293
        fields = [
×
294
            "comments.raw_text",
295
            "comments.creator",
296
            "attachments.file_name",
297
            "attachments.content_type",
298
            "attachments.is_obsolete",
299
        ]
NEW
300
        params = {
×
301
            "include_fields": fields,
302
            "resolution": "---",
303
            "f1": "attachments.mimetype",
304
            "o1": "equals",
305
            "v1": "text/x-phabricator-request",
306
        }
307

NEW
308
        return params
×
309

NEW
310
    @staticmethod
×
NEW
311
    def _get_reviewer_status_note(reviewer: dict) -> str:
×
NEW
312
        if reviewer["is_resigned"]:
×
NEW
313
            return "Resigned from review"
×
314

NEW
315
        status = reviewer["info"]["status"]
×
NEW
316
        if status == UserStatus.UNAVAILABLE:
×
NEW
317
            until = reviewer["info"]["unavailable_until"]
×
NEW
318
            if until:
×
NEW
319
                return_date = datetime.date.fromtimestamp(until).strftime("%b %-d, %Y")
×
NEW
320
                return f"Back {return_date}"
×
321

NEW
322
            return "Unavailable"
×
323

NEW
324
        if status == UserStatus.DISABLED:
×
NEW
325
            return "Disabled"
×
326

NEW
327
        return "Inactive"
×
328

329

NEW
330
if __name__ == "__main__":
×
NEW
331
    InactiveRevision().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