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

mozilla / relman-auto-nag / #5198

06 Aug 2024 02:31PM CUT coverage: 21.524% (-0.002%) from 21.526%
#5198

push

coveralls-python

benjaminmah
Added filtering out resigned reviewers

716 of 3675 branches covered (19.48%)

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

1 existing line in 1 file now uncovered.

1932 of 8976 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

5
import re
×
6
from typing import Dict, List
×
7

8
from dateutil.relativedelta import relativedelta
×
9
from jinja2 import Environment, FileSystemLoader, Template
×
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
×
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 InactiveRevision(BzCleaner):
×
25
    """Bugs with inactive patches that are awaiting action from authors or reviewers."""
26

27
    def __init__(self, old_patch_months: int = 6, patch_activity_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
            patch_activity_months: Number of months since the last activity on the patch.
35
        """
36
        super(InactiveRevision, self).__init__()
×
37
        self.phab = PhabricatorAPI(utils.get_login_info()["phab_api_key"])
×
38
        self.user_activity = UserActivity(include_fields=["nick"], phab=self.phab)
×
39
        self.ni_author_template = self.load_template(
×
40
            self.name() + "_needinfo_author.txt"
41
        )
42
        self.ni_reviewer_template = self.load_template(
×
43
            self.name() + "_needinfo_reviewer.txt"
44
        )
45
        self.old_patch_limit = (
×
46
            lmdutils.get_date_ymd("today") - relativedelta(months=old_patch_months)
47
        ).timestamp()
48
        self.patch_activity_limit = (
×
49
            lmdutils.get_date_ymd("today") - relativedelta(months=patch_activity_months)
50
        ).timestamp()
51

52
    def description(self):
×
53
        return "Bugs with inactive patches that are awaiting action from authors or reviewers."
×
54

55
    def columns(self):
×
56
        return ["id", "summary", "revisions"]
×
57

58
    def get_bugs(self, date="today", bug_ids=[], chunk_size=None):
×
59
        bugs = super().get_bugs(date, bug_ids, chunk_size)
×
60

61
        rev_ids = {rev_id for bug in bugs.values() for rev_id in bug["rev_ids"]}
×
62
        revisions = self._get_revisions_with_inactive_action(list(rev_ids))
×
63

64
        for bugid, bug in list(bugs.items()):
×
65
            inactive_revs = [
×
66
                revisions[rev_id] for rev_id in bug["rev_ids"] if rev_id in revisions
67
            ]
68
            if inactive_revs:
×
69
                bug["revisions"] = inactive_revs
×
70
                self._add_needinfo(bugid, inactive_revs)
×
71
            else:
72
                del bugs[bugid]
×
73

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

76
        return bugs
×
77

78
    def load_template(self, template_filename: str) -> Template:
×
79
        env = Environment(loader=FileSystemLoader("templates"))
×
80
        template = env.get_template(template_filename)
×
81
        return template
×
82

83
    def _add_needinfo(self, bugid: str, inactive_revs: list) -> None:
×
84
        has_old_patch = any(
×
85
            revision["created_at"] < self.old_patch_limit for revision in inactive_revs
86
        )
87

88
        for revision in inactive_revs:
×
89
            last_action_by, _ = self._find_last_action(revision["rev_id"])
×
90
            if last_action_by == "author" and revision["reviewers"]:
×
91
                ni_mail = revision["reviewers"][0]["phab_username"]
×
92
                summary = (
×
93
                    "The last action was by the author, so needinfoing the reviewer."
94
                )
95
                template = self.ni_reviewer_template
×
96
                nickname = revision["reviewers"][0]["phab_username"]
×
97
            elif last_action_by == "reviewer":
×
98
                ni_mail = revision["author"]["phab_username"]
×
99
                summary = (
×
100
                    "The last action was by the reviewer, so needinfoing the author."
101
                )
102
                template = self.ni_author_template
×
103
                nickname = revision["author"]["phab_username"]
×
104
            else:
105
                continue
×
106

107
            comment = template.render(
×
108
                revisions=[revision],
109
                nicknames=nickname,
110
                reviewers_status_summary=summary,
111
                has_old_patch=has_old_patch,
112
                plural=utils.plural,
113
                documentation=self.get_documentation(),
114
            )
115

116
            self.autofix_changes[bugid] = {
×
117
                "comment": {"body": comment},
118
                "flags": [
119
                    {
120
                        "name": "needinfo",
121
                        "requestee": ni_mail,
122
                        "status": "?",
123
                        "new": "true",
124
                    }
125
                ],
126
            }
127

128
    def _find_last_action(self, revision_id):
×
129
        details = self._fetch_revisions([revision_id])
×
130

131
        if not details:
×
132
            return None, None
×
133

134
        revision = details[0]
×
135
        author_phid = revision["fields"]["authorPHID"]
×
136
        reviewers = [
×
137
            reviewer["reviewerPHID"]
138
            for reviewer in revision["attachments"]["reviewers"]["reviewers"]
139
        ]
140

141
        transactions = self._fetch_revision_transactions(revision["phid"])
×
142
        if not transactions:
×
143
            return "unknown", None
×
144

145
        filtered_transactions = [
×
146
            transaction
147
            for transaction in transactions
148
            if transaction["authorPHID"] == author_phid
149
            or transaction["authorPHID"] in reviewers
150
        ]
151

152
        if not filtered_transactions:
×
153
            return "unknown", None
×
154

155
        last_transaction = filtered_transactions[0]
×
156
        last_action_by_phid = last_transaction["authorPHID"]
×
157

158
        if last_action_by_phid == author_phid:
×
159
            last_action_by = "author"
×
160
        else:
161
            last_action_by = "reviewer"
×
162

163
        return last_action_by, last_transaction
×
164

165
    def _get_revisions_with_inactive_action(self, rev_ids: list) -> Dict[int, dict]:
×
166
        revisions: List[dict] = []
×
167

168
        for _rev_ids in Connection.chunks(rev_ids, PHAB_CHUNK_SIZE):
×
169
            for revision in self._fetch_revisions(_rev_ids):
×
170
                if (
×
171
                    len(revision["attachments"]["reviewers"]["reviewers"]) == 0
172
                    or revision["fields"]["status"]["value"] != "needs-review"
173
                    or revision["fields"]["isDraft"]
174
                ):
175
                    continue
×
176

177
                _, last_transaction = self._find_last_action(revision["id"])
×
178

179
                if (
×
180
                    last_transaction
181
                    and last_transaction["dateCreated"] < self.patch_activity_limit
182
                ):
183
                    reviewers = [
×
184
                        {
185
                            "phid": reviewer["reviewerPHID"],
186
                            "is_group": reviewer["reviewerPHID"].startswith(
187
                                "PHID-PROJ"
188
                            ),
189
                            "is_blocking": reviewer["isBlocking"],
190
                            "is_accepted": reviewer["status"] == "accepted",
191
                            "is_resigned": reviewer["status"] == "resigned",
192
                        }
193
                        for reviewer in revision["attachments"]["reviewers"][
194
                            "reviewers"
195
                        ]
196
                    ]
197

198
                    if any(
×
199
                        reviewer["is_group"] or reviewer["is_accepted"]
200
                        for reviewer in reviewers
201
                    ) and all(
202
                        reviewer["is_accepted"]
203
                        for reviewer in reviewers
204
                        if reviewer["is_blocking"]
205
                    ):
206
                        continue
×
207

NEW
208
                    reviewers = [
×
209
                        reviewer
210
                        for reviewer in reviewers
211
                        if not reviewer["is_resigned"]
212
                    ]
213

UNCOV
214
                    revisions.append(
×
215
                        {
216
                            "rev_id": revision["id"],
217
                            "title": revision["fields"]["title"],
218
                            "created_at": revision["fields"]["dateCreated"],
219
                            "author_phid": revision["fields"]["authorPHID"],
220
                            "reviewers": reviewers,
221
                        }
222
                    )
223

224
        user_phids = set()
×
225
        for revision in revisions:
×
226
            user_phids.add(revision["author_phid"])
×
227
            for reviewer in revision["reviewers"]:
×
228
                user_phids.add(reviewer["phid"])
×
229

230
        users = self.user_activity.get_phab_users_with_status(
×
231
            list(user_phids), keep_active=True
232
        )
233

234
        result: Dict[int, dict] = {}
×
235
        for revision in revisions:
×
236
            author_phid = revision["author_phid"]
×
237
            if author_phid in users:
×
238
                author_info = users[author_phid]
×
239
                revision["author"] = author_info
×
240
            else:
241
                continue
×
242

243
            reviewers = []
×
244
            for reviewer in revision["reviewers"]:
×
245
                reviewer_phid = reviewer["phid"]
×
246
                if reviewer_phid in users:
×
247
                    reviewer_info = users[reviewer_phid]
×
248
                    reviewer["info"] = reviewer_info
×
249
                else:
250
                    continue
×
251
                reviewers.append(reviewer)
×
252

253
            revision["reviewers"] = [
×
254
                {
255
                    "phab_username": reviewer["info"]["phab_username"],
256
                }
257
                for reviewer in reviewers
258
            ]
259
            result[revision["rev_id"]] = revision
×
260

261
        return result
×
262

263
    @retry(
×
264
        wait=wait_exponential(min=4),
265
        stop=stop_after_attempt(5),
266
    )
267
    def _fetch_revisions(self, ids: list):
×
268
        return self.phab.request(
×
269
            "differential.revision.search",
270
            constraints={"ids": ids},
271
            attachments={"reviewers": True},
272
        )["data"]
273

274
    def _fetch_revision_transactions(self, revision_phid):
×
275
        response = self.phab.request(
×
276
            "transaction.search", objectIdentifier=revision_phid
277
        )
278
        return response["data"]
×
279

280
    def handle_bug(self, bug, data):
×
281
        rev_ids = [
×
282
            # To avoid loading the attachment content (which can be very large),
283
            # we extract the revision id from the file name, which is in the
284
            # format of "phabricator-D{revision_id}-url.txt".
285
            # len("phabricator-D") == 13
286
            # len("-url.txt") == 8
287
            int(attachment["file_name"][13:-8])
288
            for attachment in bug["attachments"]
289
            if attachment["content_type"] == "text/x-phabricator-request"
290
            and PHAB_FILE_NAME_PAT.match(attachment["file_name"])
291
            and not attachment["is_obsolete"]
292
        ]
293

294
        if not rev_ids:
×
295
            return
×
296

297
        # We should not comment about the same patch more than once.
298
        rev_ids_with_ni = set()
×
299
        for comment in bug["comments"]:
×
300
            if comment["creator"] == History.BOT and comment["raw_text"].startswith(
×
301
                "The following patch"
302
            ):
303
                rev_ids_with_ni.update(
×
304
                    int(id) for id in PHAB_TABLE_PAT.findall(comment["raw_text"])
305
                )
306

307
        if rev_ids_with_ni:
×
308
            rev_ids = [id for id in rev_ids if id not in rev_ids_with_ni]
×
309

310
        if not rev_ids:
×
311
            return
×
312

313
        # It will be nicer to show a sorted list of patches
314
        rev_ids.sort()
×
315

316
        bugid = str(bug["id"])
×
317
        data[bugid] = {
×
318
            "rev_ids": rev_ids,
319
        }
320
        return bug
×
321

322
    def get_bz_params(self, date):
×
323
        fields = [
×
324
            "comments.raw_text",
325
            "comments.creator",
326
            "attachments.file_name",
327
            "attachments.content_type",
328
            "attachments.is_obsolete",
329
        ]
330
        params = {
×
331
            "include_fields": fields,
332
            "resolution": "---",
333
            "f1": "attachments.mimetype",
334
            "o1": "equals",
335
            "v1": "text/x-phabricator-request",
336
        }
337

338
        return params
×
339

340

341
if __name__ == "__main__":
×
342
    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