Skip to content

Commit

Permalink
Send reminders when review resolution is due. (#4670)
Browse files Browse the repository at this point in the history
  • Loading branch information
jrobbins authored Jan 7, 2025
1 parent 57e9680 commit f5cd015
Show file tree
Hide file tree
Showing 9 changed files with 253 additions and 103 deletions.
74 changes: 52 additions & 22 deletions internals/reminders.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,13 +349,19 @@ class SLOOverdueHandler(basehandlers.FlaskHandler):
def get_template_data(self, **kwargs):
"""Sends notifications to reviewers of newly overdue reviews."""
self.require_cron_header()
newly_overdue_gates, long_overdue_gates, relevant_features = (
(newly_overdue_initial_response, long_overdue_initial_response,
newly_overdue_resolve, long_overdue_resolve, relevant_features) = (
self.get_overdue_gates_and_features())
newly_email_tasks = self.build_gate_email_tasks(
newly_overdue_gates, relevant_features, False)
long_email_tasks = self.build_gate_email_tasks(
long_overdue_gates, relevant_features, True)
email_tasks = newly_email_tasks + long_email_tasks
newly_initial_email_tasks = self.build_gate_email_tasks(
newly_overdue_initial_response, relevant_features, False, True)
long_initial_email_tasks = self.build_gate_email_tasks(
long_overdue_initial_response, relevant_features, True, True)
newly_resolve_email_tasks = self.build_gate_email_tasks(
newly_overdue_resolve, relevant_features, False, False)
long_resolve_email_tasks = self.build_gate_email_tasks(
long_overdue_resolve, relevant_features, True, False)
email_tasks = (newly_initial_email_tasks + long_initial_email_tasks +
newly_resolve_email_tasks + long_resolve_email_tasks)
notifier.send_emails(email_tasks)

recipients_str = ''
Expand All @@ -371,33 +377,51 @@ def get_template_data(self, **kwargs):

def get_overdue_gates_and_features(self):
"""Return lists of newly and long overdue review gates, and their FEs."""
overdue_gates: list[Gate] = slo.get_overdue_gates(
approval_defs.APPROVAL_FIELDS_BY_ID, approval_defs.DEFAULT_SLO_LIMIT)
newly_overdue_gates: list[Gate] = []
long_overdue_gates: list[Gate] = []
active_gates: list[Gate] = slo.get_active_gates()
newly_overdue_initial_response: list[Gate] = []
long_overdue_initial_response: list[Gate] = []
newly_overdue_resolve: list[Gate] = []
long_overdue_resolve: list[Gate] = []
relevant_feature_ids: set[int] = set()
for og in overdue_gates:
appr_def = approval_defs.APPROVAL_FIELDS_BY_ID.get(og.gate_type)
for g in active_gates:
appr_def = approval_defs.APPROVAL_FIELDS_BY_ID.get(g.gate_type)
slo_limit = (appr_def.slo_initial_response
if appr_def else approval_defs.DEFAULT_SLO_LIMIT)
remaining = slo.remaining_days(og.requested_on, slo_limit)
if remaining == -1:
newly_overdue_gates.append(og)
relevant_feature_ids.add(og.feature_id)
elif remaining == -slo_limit:
long_overdue_gates.append(og)
relevant_feature_ids.add(og.feature_id)
slo_resolve_limit = (appr_def.slo_resolve
if appr_def else approval_defs.DEFAULT_SLO_RESOLVE_LIMIT)
initial_remaining = slo.remaining_days(g.requested_on, slo_limit)
resolve_remaining = slo.remaining_days(g.requested_on, slo_resolve_limit)

# A review can only be overdue for its initial response if there is
# no recorded time for that initial response yet.
if g.responded_on is None:
if initial_remaining == -1:
newly_overdue_initial_response.append(g)
relevant_feature_ids.add(g.feature_id)
elif initial_remaining == -slo_limit:
long_overdue_initial_response.append(g)
relevant_feature_ids.add(g.feature_id)

# A review can be overdue for resolution regardless of initial response.
if resolve_remaining == -1:
newly_overdue_resolve.append(g)
relevant_feature_ids.add(g.feature_id)
elif resolve_remaining == -slo_resolve_limit:
long_overdue_resolve.append(g)
relevant_feature_ids.add(g.feature_id)

relevant_features = {
fe_id: FeatureEntry.get_by_id(fe_id)
for fe_id in relevant_feature_ids}
return newly_overdue_gates, long_overdue_gates, relevant_features
return (newly_overdue_initial_response, long_overdue_initial_response,
newly_overdue_resolve, long_overdue_resolve, relevant_features)

def build_gate_email_tasks(
self,
gates_to_notify: list[Gate],
relevant_features: dict[int, FeatureEntry],
is_escalated: bool
is_escalated: bool,
is_initial_response: bool
) -> list[dict[str, Any]]:
email_tasks: list[dict[str, Any]] = []
for gate in gates_to_notify:
Expand All @@ -406,11 +430,17 @@ def build_gate_email_tasks(
fe = relevant_features[gate.feature_id]
feature_id = fe.key.integer_id()
gate_url = settings.SITE_URL + f'feature/{feature_id}?gate={gate_id}'
if is_initial_response:
needed_action = 'an initial response'
else:
needed_action = 'a resolution'

body_data = {
'feature': fe,
'appr_def': appr_def,
'gate_url': gate_url,
'is_escalated': is_escalated,
'needed_action': needed_action,
}
html = render_template(self.BODY_TEMPLATE_PATH, **body_data)
subject = self.SUBJECT_FORMAT % fe.name
Expand All @@ -436,7 +466,7 @@ def choose_reviewers(self, gate: Gate, is_escalated: bool) -> list[str]:

review_team = approval_defs.get_approvers(gate.gate_type)
assignees = gate.assignee_emails or []
return list(set(assignees + review_team))
return sorted(set(assignees + review_team))


class SendOTReminderEmailsHandler(basehandlers.FlaskHandler):
Expand Down
175 changes: 155 additions & 20 deletions internals/reminders_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,13 @@ def setUp(self):
state=Gate.PREPARING)
self.gate_1.put()
self.handler = reminders.SLOOverdueHandler()
# Just a non-empty date, the value is ignored by mock_remaining_days
self.request_date = datetime(2023, 6, 7, 12, 30, 0)
self.request_date = datetime(2023, 7, 7, 12, 30, 0) # Fri, July 7, 2023
self.day_1 = datetime(2023, 7, 10, 12, 30, 0) # This Mon
self.day_6 = datetime(2023, 7, 17, 12, 30, 0) # Next Mon: Initial response due
self.day_10 = datetime(2023, 7, 21, 12, 30, 0) # Next Fri: Initial overdue
self.day_11 = datetime(2023, 7, 24, 12, 30, 0) # NN Mon: Resolution due
self.day_20 = datetime(2023, 8, 5, 12, 30, 0) # Later Fri: Resol overdue
self.day_22 = datetime(2023, 8, 9, 12, 30, 0) # Later Tue

def tearDown(self) -> None:
kinds: list[ndb.Model] = [FeatureEntry, Stage, Gate]
Expand All @@ -466,18 +471,21 @@ def tearDown(self) -> None:
entity.key.delete()

def test_get_template_data__no_reviews_pending(self):
"""The only gate is still PREPARING, so it's review can't be late."""
with test_app.app_context():
actual = self.handler.get_template_data()

expected_message = ('0 email(s) sent or logged.')
expected = {'message': expected_message}
self.assertEqual(actual, expected)

@mock.patch('internals.slo.remaining_days')
def test_get_template_data__no_reviews_due(self, mock_remaining_days):
@mock.patch('internals.slo.now_utc')
def test_get_template_data__no_reviews_due(self, mock_now_utc):
"""A review has been requested, but it is not due yet."""
self.gate_1.state = Vote.REVIEW_REQUESTED
self.gate_1.requested_on = self.request_date
self.gate_1.put()
mock_remaining_days.return_value = 1
mock_now_utc.return_value = self.day_1

with test_app.app_context():
actual = self.handler.get_template_data()
Expand All @@ -486,12 +494,13 @@ def test_get_template_data__no_reviews_due(self, mock_remaining_days):
expected = {'message': expected_message}
self.assertEqual(actual, expected)

@mock.patch('internals.slo.remaining_days')
def test_get_template_data__one_due_unassigned(self, mock_remaining_days):
@mock.patch('internals.slo.now_utc')
def test_get_template_data__one_due_unassigned(self, mock_now_utc):
"""One gate is due and it has no assigned reviewer."""
self.gate_1.state = Vote.REVIEW_REQUESTED
self.gate_1.requested_on = self.request_date
self.gate_1.put()
mock_remaining_days.return_value = -1
mock_now_utc.return_value = self.day_6

with test_app.app_context():
actual = self.handler.get_template_data()
Expand All @@ -508,14 +517,15 @@ def test_get_template_data__one_due_unassigned(self, mock_remaining_days):
expected = {'message': expected_message}
self.assertEqual(actual, expected)

@mock.patch('internals.slo.remaining_days')
def test_get_template_data__one_due_assigned(self, mock_remaining_days):
@mock.patch('internals.slo.now_utc')
def test_get_template_data__one_due_assigned(self, mock_now_utc):
"""One gate is due and it has two assigned reviewers."""
self.gate_1.state = Vote.REVIEW_REQUESTED
self.gate_1.assignee_emails = [
'[email protected]', '[email protected]']
self.gate_1.requested_on = self.request_date
self.gate_1.put()
mock_remaining_days.return_value = -1
mock_now_utc.return_value = self.day_6

with test_app.app_context():
actual = self.handler.get_template_data()
Expand All @@ -527,12 +537,13 @@ def test_get_template_data__one_due_assigned(self, mock_remaining_days):
expected = {'message': expected_message}
self.assertEqual(actual, expected)

@mock.patch('internals.slo.remaining_days')
def test_get_template_data__one_overdue_unassigned(self, mock_remaining_days):
@mock.patch('internals.slo.now_utc')
def test_get_template_data__initial_overdue_unassigned(self, mock_now_utc):
"""Overdue for initial response. Notify all reviewers."""
self.gate_1.state = Vote.REVIEW_REQUESTED
self.gate_1.requested_on = self.request_date
self.gate_1.put()
mock_remaining_days.return_value = -approval_defs.DEFAULT_SLO_LIMIT
mock_now_utc.return_value = self.day_10

with test_app.app_context():
actual = self.handler.get_template_data()
Expand All @@ -548,14 +559,15 @@ def test_get_template_data__one_overdue_unassigned(self, mock_remaining_days):
expected = {'message': expected_message}
self.assertEqual(actual, expected)

@mock.patch('internals.slo.remaining_days')
def test_get_template_data__one_overdue_assigned(self, mock_remaining_days):
@mock.patch('internals.slo.now_utc')
def test_get_template_data__initial_overdue_assigned(self, mock_now_utc):
"""Overdue for initial response. Notify assigned and others."""
self.gate_1.state = Vote.REVIEW_REQUESTED
self.gate_1.assignee_emails = [
'[email protected]', '[email protected]']
self.gate_1.requested_on = self.request_date
self.gate_1.put()
mock_remaining_days.return_value = -approval_defs.DEFAULT_SLO_LIMIT
mock_now_utc.return_value = self.day_10

with test_app.app_context():
actual = self.handler.get_template_data()
Expand All @@ -572,15 +584,138 @@ def test_get_template_data__one_overdue_assigned(self, mock_remaining_days):
expected = {'message': expected_message}
self.assertEqual(actual, expected)

@mock.patch('internals.slo.remaining_days')
def test_get_template_data__old_reviews(self, mock_remaining_days):
@mock.patch('internals.slo.now_utc')
def test_get_template_data__due_resolve_unassigned(self, mock_now_utc):
"""Due for resolution. Notify all reviewers."""
self.gate_1.state = Vote.REVIEW_REQUESTED
self.gate_1.requested_on = self.request_date
self.gate_1.put()
mock_remaining_days.return_value = 99
mock_now_utc.return_value = self.day_11

with test_app.app_context():
actual = self.handler.get_template_data()

expected_message = (f'6 email(s) sent or logged.\n'
'Recipients:\n'
'[email protected]\n'
'[email protected]\n'
'[email protected]\n'
'[email protected]\n'
'[email protected]\n'
'[email protected]')
expected = {'message': expected_message}
self.assertEqual(actual, expected)


@mock.patch('internals.slo.now_utc')
def test_get_template_data__resolve_overdue_unassigned(self, mock_now_utc):
"""Overdue for resolution. Notify all reviewers."""
self.gate_1.state = Vote.REVIEW_REQUESTED
self.gate_1.requested_on = self.request_date
self.gate_1.put()
mock_now_utc.return_value = self.day_20

with test_app.app_context():
actual = self.handler.get_template_data()

expected_message = (f'6 email(s) sent or logged.\n'
'Recipients:\n'
'[email protected]\n'
'[email protected]\n'
'[email protected]\n'
'[email protected]\n'
'[email protected]\n'
'[email protected]')
expected = {'message': expected_message}
self.assertEqual(actual, expected)

@mock.patch('internals.slo.now_utc')
def test_get_template_data__old_reviews(self, mock_now_utc):
"""More time has passed. We don't keep reminding."""
self.gate_1.state = Vote.REVIEW_REQUESTED
self.gate_1.requested_on = self.request_date
self.gate_1.put()
mock_now_utc.return_value = self.day_22

with test_app.app_context():
actual = self.handler.get_template_data()

expected_message = '0 email(s) sent or logged.'
expected = {'message': expected_message}
self.assertEqual(actual, expected)

def test_build_gate_email_tasks__initial_due(self):
"""Check the email sent when an initial respose is due."""
self.gate_1.assignee_emails = [
'[email protected]', '[email protected]']
with test_app.app_context():
actual = self.handler.build_gate_email_tasks(
[self.gate_1],
{self.feature_1.key.integer_id(): self.feature_1},
False, True)

self.assertEqual(2, len(actual))
task = actual[0]
self.assertEqual('[email protected]', task['to'])
self.assertEqual('Review due for: feature one', task['subject'])
self.assertEqual(None, task['reply_to'])
# TESTDATA.make_golden(task['html'], 'test_build_gate_email_tasks__initial_due.html')
self.assertMultiLineEqual(
TESTDATA['test_build_gate_email_tasks__initial_due.html'], task['html'])

def test_build_gate_email_tasks__initial_overdue(self):
"""Check the email sent when an initial respose is overdue."""
self.gate_1.assignee_emails = [
'[email protected]', '[email protected]']
with test_app.app_context():
actual = self.handler.build_gate_email_tasks(
[self.gate_1],
{self.feature_1.key.integer_id(): self.feature_1},
True, True)

self.assertEqual(8, len(actual))
task = actual[0]
self.assertEqual('[email protected]', task['to'])
self.assertEqual('ESCALATED: Review due for: feature one', task['subject'])
self.assertEqual(None, task['reply_to'])
# TESTDATA.make_golden(task['html'], 'test_build_gate_email_tasks__initial_overdue.html')
self.assertMultiLineEqual(
TESTDATA['test_build_gate_email_tasks__initial_overdue.html'], task['html'])

def test_build_gate_email_tasks__resolution_due(self):
"""Check the email sent when a resolution is due."""
self.gate_1.assignee_emails = [
'[email protected]', '[email protected]']
with test_app.app_context():
actual = self.handler.build_gate_email_tasks(
[self.gate_1],
{self.feature_1.key.integer_id(): self.feature_1},
False, False)

self.assertEqual(2, len(actual))
task = actual[0]
self.assertEqual('[email protected]', task['to'])
self.assertEqual('Review due for: feature one', task['subject'])
self.assertEqual(None, task['reply_to'])
# TESTDATA.make_golden(task['html'], 'test_build_gate_email_tasks__resolution_due.html')
self.assertMultiLineEqual(
TESTDATA['test_build_gate_email_tasks__resolution_due.html'], task['html'])

def test_build_gate_email_tasks__resolution_overdue(self):
"""Check the email sent when a a resolution is overdue."""
self.gate_1.assignee_emails = [
'[email protected]', '[email protected]']
with test_app.app_context():
actual = self.handler.build_gate_email_tasks(
[self.gate_1],
{self.feature_1.key.integer_id(): self.feature_1},
True, False)

self.assertEqual(8, len(actual))
task = actual[0]
self.assertEqual('[email protected]', task['to'])
self.assertEqual('ESCALATED: Review due for: feature one', task['subject'])
self.assertEqual(None, task['reply_to'])
# TESTDATA.make_golden(task['html'], 'test_build_gate_email_tasks__resolution_overdue.html')
self.assertMultiLineEqual(
TESTDATA['test_build_gate_email_tasks__resolution_overdue.html'], task['html'])
20 changes: 4 additions & 16 deletions internals/slo.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,19 +145,7 @@ def record_comment(
return False


def is_gate_overdue(gate, appr_fields, default_slo_limit) -> bool:
"""Return True if a gate's review is overdue."""
if gate.requested_on is None or gate.responded_on is not None:
return False
appr_def = appr_fields.get(gate.gate_type)
slo_limit = (appr_def.slo_initial_response
if appr_def else default_slo_limit)
return remaining_days(gate.requested_on, slo_limit) < 0


def get_overdue_gates(appr_fields, default_slo_limit) -> list[Gate]:
"""Return a list of gates with overdue reviews."""
active_gates = Gate.query(Gate.state.IN(Gate.PENDING_STATES))
overdue_gates = [g for g in active_gates
if is_gate_overdue(g, appr_fields, default_slo_limit)]
return overdue_gates
def get_active_gates() -> list[Gate]:
"""Return a list of gates with active reviews."""
active_gates = Gate.query(Gate.state.IN(Gate.PENDING_STATES)).fetch()
return active_gates
Loading

0 comments on commit f5cd015

Please sign in to comment.