Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Needinfo the author or reviewer for inactive revisions on Phabricator #2417

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
dd98111
Initial (incomplete) `inactive_revision` rule commit
benjaminmah Jun 3, 2024
b2fc12f
Updated function to find the author of the last action
benjaminmah Jun 3, 2024
e7517fc
Added functions from `inactive_reviewer.py`
benjaminmah Jun 4, 2024
960fca7
Added functions from `inactive_reviewer.py` to maintain similar funct…
benjaminmah Jun 10, 2024
f3716bb
Removed print statement
benjaminmah Jun 10, 2024
7e17ee3
Initial templates for `inactive_revision.py` rule
benjaminmah Jun 11, 2024
20210a1
Added separate needinfo comments depending on who is being needinfo'ed
benjaminmah Jul 3, 2024
ab3df86
Removed old needinfo template
benjaminmah Jul 3, 2024
89b50ac
Fixed `_get_revisions_with_inactive_action()` to focus on inactive pa…
benjaminmah Jul 9, 2024
d7c6689
Removed initialization of `patch_activity_months` arg
benjaminmah Jul 9, 2024
d736546
Edited email template
benjaminmah Jul 9, 2024
f739f18
Replaced double negation with `all`
benjaminmah Aug 6, 2024
9dbb76a
Removed returning "other" case when finding last action
benjaminmah Aug 6, 2024
1c07ddd
Added filtering out resigned reviewers
benjaminmah Aug 6, 2024
1c83ba3
Added column for `needinfo_user` in email template
benjaminmah Aug 6, 2024
e34cd1f
Added column for `needinfo_user`
benjaminmah Aug 6, 2024
3efbfd1
Added `max_actions` to the `inactive_revision` rule
benjaminmah Aug 7, 2024
8172791
Replaced `Dict` and `List` with `dict` and `list`
benjaminmah Aug 9, 2024
69385c6
Reversed condition and added `continue`
benjaminmah Aug 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 328 additions & 0 deletions bugbot/rules/inactive_revision.py
Copy link
Member

@suhaibmujahid suhaibmujahid Aug 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a substantial amount of duplicated code from the inactive_reviewer rule.

Here are some potential solutions to mitigate the issues:

  • Using a superclass to host the common logic
  • Merge the two rules into one
  • Create some util functions

This applies to #2426 as well.

@marco-c wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I vote for the third option (create some util functions) or the second (merge the two rules into one).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, I think creating util functions will reduce duplicate code while still keeping the rule separate. Similar to the no_severity rule, the first step could be to needinfo the pending needinfo'er (which could be the reviewer or author) and the second step could be to unblock bugs and find an active reviewer. Of course, these would be separate rules to avoid having complex multi-step rules similar to no_severity. WDYT?

Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at http://mozilla.org/MPL/2.0/.

import re
from typing import Dict, List
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let us use dect and list instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done here: 8172791


from dateutil.relativedelta import relativedelta
from jinja2 import Environment, FileSystemLoader, Template
from libmozdata import utils as lmdutils
from libmozdata.connection import Connection
from libmozdata.phabricator import PhabricatorAPI
from tenacity import retry, stop_after_attempt, wait_exponential

from bugbot import utils
from bugbot.bzcleaner import BzCleaner
from bugbot.history import History
from bugbot.user_activity import PHAB_CHUNK_SIZE, UserActivity

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


class InactiveRevision(BzCleaner):
"""Bugs with inactive patches that are awaiting action from authors or reviewers."""

def __init__(self, old_patch_months: int = 6, patch_activity_months: int = 6):
"""Constructor

Args:
old_patch_months: number of months since creation of the patch to be
considered old. If the bug has an old patch, we will mention
abandon the patch as an option.
patch_activity_months: Number of months since the last activity on the patch.
"""
super(InactiveRevision, self).__init__()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
super(InactiveRevision, self).__init__()
super().__init__()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All other rules in BugBot include the rule name and self, so I was thinking we could keep it as it is for consistency. Please let me know if you think otherwise and I can go ahead with this change.

self.phab = PhabricatorAPI(utils.get_login_info()["phab_api_key"])
self.user_activity = UserActivity(include_fields=["nick"], phab=self.phab)
self.ni_author_template = self.load_template(
self.name() + "_needinfo_author.txt"
)
self.ni_reviewer_template = self.load_template(
self.name() + "_needinfo_reviewer.txt"
)
self.old_patch_limit = (
lmdutils.get_date_ymd("today") - relativedelta(months=old_patch_months)
).timestamp()
self.patch_activity_limit = (
lmdutils.get_date_ymd("today") - relativedelta(months=patch_activity_months)
).timestamp()

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

def columns(self):
return ["id", "summary", "revisions"]

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

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

for bugid, bug in list(bugs.items()):
inactive_revs = [
revisions[rev_id] for rev_id in bug["rev_ids"] if rev_id in revisions
]
if inactive_revs:
bug["revisions"] = inactive_revs
self._add_needinfo(bugid, inactive_revs)
else:
del bugs[bugid]

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

return bugs

def load_template(self, template_filename: str) -> Template:
env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template(template_filename)
return template

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

for revision in inactive_revs:
last_action_by, _ = self._find_last_action(revision["rev_id"])
if last_action_by == "author" and revision["reviewers"]:
ni_mail = revision["reviewers"][0]["phab_username"]
summary = (
"The last action was by the author, so needinfoing the reviewer."
)
template = self.ni_reviewer_template
nickname = revision["reviewers"][0]["phab_username"]
elif last_action_by == "reviewer":
ni_mail = revision["author"]["phab_username"]
summary = (
"The last action was by the reviewer, so needinfoing the author."
)
template = self.ni_author_template
nickname = revision["author"]["phab_username"]
else:
continue

comment = template.render(
revisions=[revision],
nicknames=nickname,
reviewers_status_summary=summary,
has_old_patch=has_old_patch,
plural=utils.plural,
documentation=self.get_documentation(),
)

self.autofix_changes[bugid] = {
"comment": {"body": comment},
"flags": [
{
"name": "needinfo",
"requestee": ni_mail,
"status": "?",
"new": "true",
}
],
}

def _find_last_action(self, revision_id):
details = self._fetch_revisions([revision_id])

if not details:
return None, None

revision = details[0]
author_phid = revision["fields"]["authorPHID"]
reviewers = [
reviewer["reviewerPHID"]
for reviewer in revision["attachments"]["reviewers"]["reviewers"]
]

transactions = self._fetch_revision_transactions(revision["phid"])
last_transaction = transactions[0] if transactions else None

if last_transaction:
last_action_by_phid = last_transaction["authorPHID"]
if last_action_by_phid == author_phid:
last_action_by = "author"
elif last_action_by_phid in reviewers:
last_action_by = "reviewer"
else:
last_action_by = "other"
marco-c marked this conversation as resolved.
Show resolved Hide resolved
else:
last_action_by = "unknown"

return last_action_by, last_transaction

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

for _rev_ids in Connection.chunks(rev_ids, PHAB_CHUNK_SIZE):
for revision in self._fetch_revisions(_rev_ids):
if (
len(revision["attachments"]["reviewers"]["reviewers"]) == 0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is no reviewer, we may still want to notify the author.

or revision["fields"]["status"]["value"] != "needs-review"
or revision["fields"]["isDraft"]
):
continue

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

if (
last_transaction
and last_transaction["dateCreated"] < self.patch_activity_limit
):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be more explicit if you flip the condition and use continue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Done here: 69385c6.

reviewers = [
{
"phid": reviewer["reviewerPHID"],
"is_group": reviewer["reviewerPHID"].startswith(
"PHID-PROJ"
),
"is_blocking": reviewer["isBlocking"],
"is_accepted": reviewer["status"] == "accepted",
"is_resigned": reviewer["status"] == "resigned",
marco-c marked this conversation as resolved.
Show resolved Hide resolved
}
for reviewer in revision["attachments"]["reviewers"][
"reviewers"
]
]

if any(
reviewer["is_group"] or reviewer["is_accepted"]
for reviewer in reviewers
) and not any(
not reviewer["is_accepted"]
for reviewer in reviewers
if reviewer["is_blocking"]
):
marco-c marked this conversation as resolved.
Show resolved Hide resolved
continue

revisions.append(
{
"rev_id": revision["id"],
"title": revision["fields"]["title"],
"created_at": revision["fields"]["dateCreated"],
"author_phid": revision["fields"]["authorPHID"],
"reviewers": reviewers,
}
)

user_phids = set()
for revision in revisions:
user_phids.add(revision["author_phid"])
for reviewer in revision["reviewers"]:
user_phids.add(reviewer["phid"])

users = self.user_activity.get_phab_users_with_status(
list(user_phids), keep_active=True
)

result: Dict[int, dict] = {}
for revision in revisions:
author_phid = revision["author_phid"]
if author_phid in users:
author_info = users[author_phid]
revision["author"] = author_info
else:
continue

reviewers = []
for reviewer in revision["reviewers"]:
reviewer_phid = reviewer["phid"]
if reviewer_phid in users:
reviewer_info = users[reviewer_phid]
reviewer["info"] = reviewer_info
else:
continue
reviewers.append(reviewer)

revision["reviewers"] = [
{
"phab_username": reviewer["info"]["phab_username"],
}
for reviewer in reviewers
]
result[revision["rev_id"]] = revision

return result

@retry(
wait=wait_exponential(min=4),
stop=stop_after_attempt(5),
)
def _fetch_revisions(self, ids: list):
return self.phab.request(
"differential.revision.search",
constraints={"ids": ids},
attachments={"reviewers": True},
)["data"]

def _fetch_revision_transactions(self, revision_phid):
response = self.phab.request(
"transaction.search", objectIdentifier=revision_phid
)
return response["data"]

def handle_bug(self, bug, data):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of querying bugs and then finding revisions attached to the bugs, we could search for revisions directly. WDYT @suhaibmujahid?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will need to query the bugs anyway to ensure we did not comment on them. All the queried bugs have attached revisions. Doing it the other way could be more efficient (not sure), but it will be a bit more complex since BzCleaner is designed to start from bugs.

rev_ids = [
# To avoid loading the attachment content (which can be very large),
# we extract the revision id from the file name, which is in the
# format of "phabricator-D{revision_id}-url.txt".
# len("phabricator-D") == 13
# len("-url.txt") == 8
int(attachment["file_name"][13:-8])
for attachment in bug["attachments"]
if attachment["content_type"] == "text/x-phabricator-request"
and PHAB_FILE_NAME_PAT.match(attachment["file_name"])
and not attachment["is_obsolete"]
]

if not rev_ids:
return

# We should not comment about the same patch more than once.
rev_ids_with_ni = set()
for comment in bug["comments"]:
if comment["creator"] == History.BOT and comment["raw_text"].startswith(
"The following patch"
):
rev_ids_with_ni.update(
int(id) for id in PHAB_TABLE_PAT.findall(comment["raw_text"])
)
Comment on lines +302 to +307
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches comments from the 'inactive_reviewer' rule, but not from this rule. Also, we do not list patch IDs in the needinfo comments, so this is not going to work.


if rev_ids_with_ni:
rev_ids = [id for id in rev_ids if id not in rev_ids_with_ni]

if not rev_ids:
return

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

bugid = str(bug["id"])
data[bugid] = {
"rev_ids": rev_ids,
}
return bug

def get_bz_params(self, date):
fields = [
"comments.raw_text",
"comments.creator",
"attachments.file_name",
"attachments.content_type",
"attachments.is_obsolete",
]
params = {
"include_fields": fields,
"resolution": "---",
"f1": "attachments.mimetype",
"o1": "equals",
"v1": "text/x-phabricator-request",
}

return params


if __name__ == "__main__":
InactiveRevision().run()
33 changes: 33 additions & 0 deletions templates/inactive_revision.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<p>
The following {{ plural('bug has', data, pword='bugs have') }} inactive patches that need action from either the author or a reviewer:
</p>
<table {{ table_attrs }}>
<thead>
<tr>
<th>Bug</th>
<th>Summary</th>
<th>Patches</th>
marco-c marked this conversation as resolved.
Show resolved Hide resolved
</tr>
</thead>
<tbody>
{% for i, (bugid, summary, revisions) in enumerate(data) -%}
<tr {% if i % 2 == 0 %}bgcolor="#E0E0E0"
{% endif -%}
>
<td {% if bugid in no_manager %}style="background:red;"{% endif %}>
<a href="https://bugzilla.mozilla.org/show_bug.cgi?id={{ bugid }}">{{ bugid }}</a>
</td>
<td>{{ summary | e }}</td>
<td>
<ul style="padding: 0; margin: 0">
{% for rev in revisions -%}
<li>
<a href="https://phabricator.services.mozilla.com/D{{ rev['rev_id'] }}">D{{ rev['rev_id'] }}</a>
</li>
{% endfor -%}
</ul>
</td>
</tr>
{% endfor -%}
</tbody>
</table>
3 changes: 3 additions & 0 deletions templates/inactive_revision_needinfo_author.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:{{ nicknames }}, your patch is still waiting for action. Could you please address the feedback from the reviewer {%- if has_old_patch %} or consider abandoning the patch if it is no longer relevant {%- endif %}?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A problem I see with this is if the reviewer's last action is something like adding a comment saying "I'll get to this soon". I guess we have to accept this will never be entirely precise :)


{{ documentation }}
3 changes: 3 additions & 0 deletions templates/inactive_revision_needinfo_reviewer.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:{{ nicknames }}, you have been asked to review this patch but it is still waiting for your response. Could you please review it {%- if has_old_patch %} or let the author know if it is no longer relevant {%- endif %}?

{{ documentation }}