Implement server-side changes for review SLOs (#3062)

* progress

* Progress

* progress

* Added tests

* Actually add new test file

* Another test

* Update internals/slo.py

Co-authored-by: Kyle Ju <kyleju@google.com>

---------

Co-authored-by: Kyle Ju <kyleju@google.com>
This commit is contained in:
Jason Robbins 2023-06-09 09:56:29 -07:00 коммит произвёл GitHub
Родитель e5c96c8682
Коммит 0b0d432187
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 431 добавлений и 16 удалений

Просмотреть файл

@ -18,7 +18,10 @@ from typing import Any
from framework import basehandlers
from framework import permissions
from internals.review_models import Activity, Amendment, Gate
from internals import notifier, notifier_helpers
from internals import approval_defs
from internals import notifier
from internals import notifier_helpers
from internals import slo
def amendment_to_json_dict(amendment: Amendment) -> dict[str, Any]:
@ -92,13 +95,16 @@ class CommentsAPI(basehandlers.APIHandler):
comment_activity.put()
# Notify subscribers of new comments when user posts a comment
# via the gate column.
# via the gate column. Also, record SLO initial response time.
if gate_id:
gate = Gate.get_by_id(gate_id)
if not gate:
self.abort(404, msg='Gate not found; notifications abort.')
notifier_helpers.notify_subscribers_of_new_comments(
feature, gate, user.email(), comment_content)
approvers = approval_defs.get_approvers(gate.gate_type)
if slo.record_comment(feature, gate, user, approvers):
gate.put()
# We can only be certain which intent thread we want to post to with
# a relevant gate ID in order to get the intent_thread_url field from

Просмотреть файл

@ -22,6 +22,7 @@ from internals.core_models import FeatureEntry, MilestoneSet, Stage
from internals.data_types import StageDict, VerboseFeatureDict
from internals.review_models import Vote, Gate
from internals import approval_defs
from internals import slo
SIMPLE_TYPES = frozenset((int, float, bool, dict, str, list))
@ -529,7 +530,21 @@ def vote_value_to_json_dict(vote: Vote) -> dict[str, Any]:
def gate_value_to_json_dict(gate: Gate) -> dict[str, Any]:
next_action = str(gate.next_action) if gate.next_action else None
requested_on = str(gate.requested_on) if gate.requested_on else None
responded_on = str(gate.responded_on) if gate.responded_on else None
appr_def = approval_defs.APPROVAL_FIELDS_BY_ID.get(gate.gate_type)
slo_initial_response = approval_defs.DEFAULT_SLO_LIMIT
if appr_def:
slo_initial_response = appr_def.slo_initial_response
slo_initial_response_remaining = None
slo_initial_response_took = None
if requested_on:
if responded_on:
slo_initial_response_took = slo.weekdays_between(
gate.requested_on, gate.responded_on)
else:
slo_initial_response_remaining = slo.remaining_days(
gate.requested_on, slo_initial_response)
return {
'id': gate.key.integer_id(),
'feature_id': gate.feature_id,
@ -539,7 +554,11 @@ def gate_value_to_json_dict(gate: Gate) -> dict[str, Any]:
'gate_name': appr_def.name if appr_def else 'Gate',
'state': gate.state,
'requested_on': requested_on, # YYYY-MM-DD HH:MM:SS or None
'responded_on': responded_on, # YYYY-MM-DD HH:MM:SS or None
'owners': gate.owners,
'next_action': next_action, # YYYY-MM-DD or None
'additional_review': gate.additional_review
'additional_review': gate.additional_review,
'slo_initial_response': slo_initial_response,
'slo_initial_response_took': slo_initial_response_took,
'slo_initial_response_remaining': slo_initial_response_remaining,
}

Просмотреть файл

@ -13,6 +13,7 @@
# limitations under the License.
import testing_config # Must be imported before the module under test.
from unittest import mock
from datetime import datetime
@ -429,7 +430,7 @@ class FeatureConvertersTest(testing_config.CustomTestCase):
},
'other': {
'view': {
'notes': 'other notes',
'notes': 'other notes',
'text': None,
'url': None,
'val': None,
@ -508,21 +509,29 @@ class GateConvertersTest(testing_config.CustomTestCase):
'gate_name': appr_def.name,
'state': 4,
'requested_on': None,
'responded_on': None,
'owners': [],
'next_action': None,
'additional_review': False,
'slo_initial_response': 2,
'slo_initial_response_took': None,
'slo_initial_response_remaining': None,
}
self.assertEqual(expected, actual)
def test_maxmimal(self):
@mock.patch('internals.slo.now_utc')
def test_maxmimal(self, mock_now):
"""If a Gate has all fields set, we can convert it to JSON."""
gate = Gate(
feature_id=1, stage_id=2, gate_type=3, state=4,
requested_on=datetime(2022, 12, 14, 1, 2, 3),
requested_on=datetime(2022, 12, 14, 1, 2, 3), # Wednesday
owners=['appr1@example.com', 'appr2@example.com'],
next_action=datetime(2022, 12, 25),
additional_review=True)
gate.put()
# The review weas due on Friday 2022-12-16.
mock_now.return_value = datetime(2022, 12, 20, 1, 2, 3) # Tuesday after.
actual = converters.gate_value_to_json_dict(gate)
appr_def = approval_defs.APPROVAL_FIELDS_BY_ID[gate.gate_type]
expected = {
@ -534,8 +543,27 @@ class GateConvertersTest(testing_config.CustomTestCase):
'gate_name': appr_def.name,
'state': 4,
'requested_on': '2022-12-14 01:02:03',
'responded_on': None,
'owners': ['appr1@example.com', 'appr2@example.com'],
'next_action': '2022-12-25',
'additional_review': True,
'slo_initial_response': 2,
'slo_initial_response_took': None, # Review is still in-progress.
'slo_initial_response_remaining': -2, # Two weekdays overdue.
}
self.assertEqual(expected, actual)
def test_slo_complete_review(self):
"""If a Gate review was completed, response includes the number of days."""
gate = Gate(
feature_id=1, stage_id=2, gate_type=3, state=4,
requested_on=datetime(2022, 12, 14, 1, 2, 3),
responded_on=datetime(2022, 12, 20, 1, 2, 3),
owners=['appr1@example.com', 'appr2@example.com'],
next_action=datetime(2022, 12, 25),
additional_review=True)
gate.put()
actual = converters.gate_value_to_json_dict(gate)
self.assertEqual(4, actual['slo_initial_response_took'])
self.assertEqual(None, actual['slo_initial_response_remaining'])

Просмотреть файл

@ -324,9 +324,13 @@ class GatesAPITest(testing_config.CustomTestCase):
"gate_name": "Intent to Prototype",
"state": 1,
"requested_on": None,
"responded_on": None,
"owners": [],
"next_action": None,
"additional_review": False,
'slo_initial_response': 2,
'slo_initial_response_took': None,
'slo_initial_response_remaining': None,
},
],
"possible_owners": {

Просмотреть файл

@ -22,6 +22,7 @@ import requests
from framework import permissions
from internals import core_enums
from internals import slo
from internals.review_models import Gate, OwnersFile, Vote
import settings
@ -56,6 +57,8 @@ TESTING_APPROVERS = [
'vivianz@google.com',
]
DEFAULT_SLO_LIMIT = 2 # Two weekdays in the Pacific timezone.
@dataclass(eq=True, frozen=True)
class ApprovalFieldDef:
name: str
@ -64,6 +67,8 @@ class ApprovalFieldDef:
rule: str
approvers: str | list[str]
team_name: str
slo_initial_response: int = DEFAULT_SLO_LIMIT
# Note: This can be requested manually through the UI, but it is not
# triggered by a blink-dev thread because i2p intents are only FYIs to
@ -154,7 +159,7 @@ APPROVAL_FIELDS_BY_ID = {
}
def fetch_owners(url):
def fetch_owners(url) -> list[str]:
"""Load a list of email addresses from an OWNERS file."""
raw_content = OwnersFile.get_raw_owner_file(url)
if raw_content:
@ -170,7 +175,7 @@ def fetch_owners(url):
return decode_raw_owner_content(response.content)
def decode_raw_owner_content(raw_content):
def decode_raw_owner_content(raw_content) -> list[str]:
owners = []
decoded = base64.b64decode(raw_content).decode()
for line in decoded.split('\n'):
@ -184,7 +189,7 @@ def decode_raw_owner_content(raw_content):
return owners
def get_approvers(field_id):
def get_approvers(field_id) -> list[str]:
"""Return a list of email addresses of users allowed to approve."""
afd = APPROVAL_FIELDS_BY_ID[field_id]
@ -279,8 +284,12 @@ def set_vote(feature_id: int, gate_type: int | None, new_state: int,
gate_type=gate_type, state=new_state, set_on=now, set_by=set_by_email)
new_vote.put()
if gate and update_gate_approval_state(gate):
gate.put()
if gate:
votes = Vote.get_votes(gate_id=gate.key.integer_id())
state_was_updated = update_gate_approval_state(gate, votes)
slo_was_updated = slo.record_vote(gate, votes)
if state_was_updated or slo_was_updated:
gate.put()
def get_gate_by_type(feature_id: int, gate_type: int):
@ -323,9 +332,8 @@ def _calc_gate_state(votes: list[Vote], rule: str) -> int:
return Gate.PREPARING
def update_gate_approval_state(gate: Gate) -> int:
def update_gate_approval_state(gate: Gate, votes: list[Vote]) -> bool:
"""Change the Gate state in RAM based on its votes. Return True if changed."""
votes = Vote.get_votes(gate_id=gate.key.integer_id())
afd = APPROVAL_FIELDS_BY_ID.get(gate.gate_type)
# Assume any gate of a type that is not currently supported is ONE_LGTM.
rule = afd.rule if afd else ONE_LGTM
@ -335,4 +343,9 @@ def update_gate_approval_state(gate: Gate) -> int:
gate.state = new_state
if votes:
gate.requested_on = min(v.set_on for v in votes)
# Starting a review resets responded_on.
if new_state == Vote.REVIEW_REQUESTED:
gate.responded_on = None
return True

Просмотреть файл

@ -369,21 +369,24 @@ class UpdateTest(testing_config.CustomTestCase):
def test_update_approval_stage__needs_update(self):
"""Gate's approval state will be updated based on votes."""
# Gate 1 should evaluate to approved after updating.
votes = Vote.get_votes(gate_id=self.gate_1.key.integer_id())
self.assertTrue(
approval_defs.update_gate_approval_state(self.gate_1))
approval_defs.update_gate_approval_state(self.gate_1, votes))
self.assertEqual(self.gate_1.state, Vote.APPROVED)
def test_update_approval_state__no_change(self):
"""Gate's approval state does not change unless it needs to."""
# Gate 2 is already marked as approved and should not change.
votes = Vote.get_votes(gate_id=self.gate_2.key.integer_id())
self.assertFalse(
approval_defs.update_gate_approval_state(self.gate_2))
approval_defs.update_gate_approval_state(self.gate_2, votes))
self.assertEqual(self.gate_2.state, Vote.APPROVED)
def test_update_approval_state__unsupported_gate_type(self):
"""If we don't recognize the gate type, assume rule ONE_LGTM."""
self.gate_1.gate_type = 999
# Gate 1 should evaluate to approved after updating.
votes = Vote.get_votes(gate_id=self.gate_1.key.integer_id())
self.assertTrue(
approval_defs.update_gate_approval_state(self.gate_1))
approval_defs.update_gate_approval_state(self.gate_1, votes))
self.assertEqual(self.gate_1.state, Vote.APPROVED)

Просмотреть файл

@ -36,6 +36,7 @@ BAKE_APPROVAL_DEF_DICT = collections.OrderedDict([
('field_id', 9),
('rule', approval_defs.ONE_LGTM),
('approvers', ['chef@example.com']),
('slo_initial_response', 2),
])
PI_COLD_DOUGH = processes.ProgressItem('Cold dough', 'dough')

Просмотреть файл

@ -136,6 +136,8 @@ class Gate(ndb.Model):
state = ndb.IntegerProperty(required=True)
# The datetime of the first vote on this gate.
requested_on = ndb.DateTimeProperty()
# The first comment or vote on this gate from a reviewer after the request.
responded_on = ndb.DateTimeProperty()
owners = ndb.StringProperty(repeated=True)
next_action = ndb.DateProperty()

99
internals/slo.py Normal file
Просмотреть файл

@ -0,0 +1,99 @@
# Copyright 2023 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License")
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import datetime
import logging
import pytz
from framework import permissions
from framework.users import User
from internals.core_models import FeatureEntry
from internals.review_models import Gate, Vote
PACIFIC_TZ = pytz.timezone('US/Pacific')
MAX_DAYS = 9999
def is_weekday(d: datetime.datetime) -> bool:
"""Return True if d is a weekday: Monday through Friday."""
return d.weekday() < 5
def weekdays_between(start: datetime.datetime, end: datetime.datetime) -> int:
"""Return the number of Pacific timezone weekdays between two UTC dates."""
d_ptz = start.astimezone(PACIFIC_TZ)
# The day of the request does not count.
d_ptz = d_ptz.replace(hour=23, minute=59, second=59)
end_ptz = end.astimezone(tz=PACIFIC_TZ)
weekday_counter = 0
while d_ptz < end_ptz and weekday_counter < MAX_DAYS:
d_ptz = d_ptz + datetime.timedelta(days=1)
if is_weekday(d_ptz):
weekday_counter += 1
return weekday_counter
def now_utc() -> datetime.datetime:
"""A mockable version of datetime.datetime.now()."""
return datetime.datetime.now()
def remaining_days(requested_on: datetime.datetime, slo_limit: int) -> int:
"""Return the number of weekdays before the SLO deadline is reached."""
# Positive: There are days remaining.
# Zero: The review is due today.
# Negative: The review is overdue.
return slo_limit - weekdays_between(requested_on, now_utc())
def is_overdue(requested_on: datetime.datetime, slo_limit: int) -> bool:
"""Return True if a review is overdue."""
return remaining_days(requested_on, slo_limit) < 0
def record_vote(gate: Gate, votes: list[Vote]) -> bool:
"""Record a Gate SLO response time if needed. Return True if changed."""
if gate.requested_on is None:
return False # Review has not been requested yet.
elif gate.responded_on is not None:
return False # We already recorded the time of the initial response.
elif not votes:
return False # No votes yet
else:
recent_vote_time = max(v.set_on for v in votes)
if recent_vote_time > gate.requested_on:
logging.info('SLO: Got reviewer vote as initial response')
gate.responded_on = recent_vote_time
return True
return False
def record_comment(
feature: FeatureEntry, gate: Gate, user: User,
approvers: list[str]) -> bool:
"""Record Gate SLO response time if needed. Return True if changed."""
if gate.requested_on is None:
return False # Review has not been requested yet.
elif gate.responded_on is not None:
return False # We already recorded the time of the initial response.
else:
is_approver = permissions.can_approve_feature(user, feature, approvers)
if is_approver:
logging.info('SLO: Got reviewer comment as initial response')
gate.responded_on = now_utc()
return True
return False

239
internals/slo_test.py Normal file
Просмотреть файл

@ -0,0 +1,239 @@
# Copyright 2023 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License")
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import testing_config # Must be imported before the module under test.
import datetime
from unittest import mock
from framework import permissions
from framework.users import User
from internals.core_models import FeatureEntry
from internals.review_models import Gate, Vote
from internals import slo
class SLOFunctionTests(testing_config.CustomTestCase):
def test_is_weekday(self):
"""We can tell if a day is a weekday or weekend."""
self.assertFalse(slo.is_weekday(datetime.datetime(2023, 6, 4))) # Sun
self.assertTrue(slo.is_weekday(datetime.datetime(2023, 6, 5))) # Mon
self.assertTrue(slo.is_weekday(datetime.datetime(2023, 6, 6))) # Tue
self.assertTrue(slo.is_weekday(datetime.datetime(2023, 6, 7))) # Wed
self.assertTrue(slo.is_weekday(datetime.datetime(2023, 6, 8))) # Thu
self.assertTrue(slo.is_weekday(datetime.datetime(2023, 6, 9))) # Fri
self.assertFalse(slo.is_weekday(datetime.datetime(2023, 6, 10))) # Sat
def test_weekdays_between__same_day(self):
"""There are zero days between different times on the same day."""
start = datetime.datetime(2023, 6, 7, 12, 30, 0)
end = datetime.datetime(2023, 6, 7, 14, 15, 10)
actual = slo.weekdays_between(start, end)
self.assertEqual(0, actual)
def test_weekdays_between__normal(self):
"""We can count the weekdays between dates."""
start = datetime.datetime(2023, 6, 7, 12, 30, 0)
end = datetime.datetime(2023, 6, 8, 14, 15, 10)
actual = slo.weekdays_between(start, end)
self.assertEqual(1, actual)
start = datetime.datetime(2023, 6, 7, 12, 30, 0)
end = datetime.datetime(2023, 6, 9, 14, 15, 10)
actual = slo.weekdays_between(start, end)
self.assertEqual(2, actual)
def test_weekdays_between__friday_into_weekend(self):
"""We can count the weekdays between dates."""
start = datetime.datetime(2023, 6, 9, 12, 30, 0) # Fri
end = datetime.datetime(2023, 6, 10, 14, 15, 10) # Sat
actual = slo.weekdays_between(start, end)
# Friday does not count because it is the day of the request,
# and Saturday is a weekend.
self.assertEqual(0, actual)
def test_weekdays_between__backwards(self):
"""If end is before start, that counts as zero."""
start = datetime.datetime(2023, 6, 7, 12, 30, 0)
end = datetime.datetime(2023, 6, 1, 14, 15, 10)
actual = slo.weekdays_between(start, end)
self.assertEqual(0, actual)
def test_weekdays_between__huge(self):
"""We can stop counting at 9999 days."""
start = datetime.datetime(1970, 6, 7, 12, 30, 0)
end = datetime.datetime(2111, 6, 9, 14, 15, 10)
actual = slo.weekdays_between(start, end)
self.assertEqual(9999, actual)
def test_now_utc(self):
"""This function returns a datetime."""
actual = slo.now_utc()
self.assertEqual(datetime.datetime, type(actual))
@mock.patch('internals.slo.now_utc')
def test_remaining_days__starting_midweek(self, mock_now):
"""We can calculate days remaining in the SLO limit."""
start = datetime.datetime(2023, 6, 7, 12, 30, 0) # Wed
mock_now.return_value = datetime.datetime(2023, 6, 7, 12, 30, 0) # Wed
actual = slo.remaining_days(start, 2)
self.assertEqual(2, actual)
mock_now.return_value = datetime.datetime(2023, 6, 8, 12, 30, 0) # Thu
actual = slo.remaining_days(start, 2)
self.assertEqual(1, actual)
mock_now.return_value = datetime.datetime(2023, 6, 9, 12, 30, 0) # Fri
actual = slo.remaining_days(start, 2)
self.assertEqual(0, actual)
mock_now.return_value = datetime.datetime(2023, 6, 10, 12, 30, 0) # Sat
actual = slo.remaining_days(start, 2)
self.assertEqual(0, actual)
mock_now.return_value = datetime.datetime(2023, 6, 11, 12, 30, 0) # Sun
actual = slo.remaining_days(start, 2)
self.assertEqual(0, actual)
mock_now.return_value = datetime.datetime(2023, 6, 12, 12, 30, 0) # Mon
actual = slo.remaining_days(start, 2)
self.assertEqual(-1, actual)
@mock.patch('internals.slo.now_utc')
def test_remaining_days__starting_weekend(self, mock_now):
"""We can calculate days remaining in the SLO limit."""
start = datetime.datetime(2023, 6, 10, 12, 30, 0) # Sat
mock_now.return_value = datetime.datetime(2023, 6, 11, 12, 30, 0) # Sun
actual = slo.remaining_days(start, 2)
self.assertEqual(2, actual)
mock_now.return_value = datetime.datetime(2023, 6, 12, 12, 30, 0) # Mon
actual = slo.remaining_days(start, 2)
self.assertEqual(1, actual)
@mock.patch('internals.slo.now_utc')
def test_remaining_days__backwards(self, mock_now):
"""If the end is before the start, we count zero days used."""
start = datetime.datetime(2023, 6, 7, 12, 30, 0) # Wed
mock_now.return_value = datetime.datetime(2023, 6, 7, 12, 30, 0) # Tue
actual = slo.remaining_days(start, 2)
self.assertEqual(2, actual)
@mock.patch('internals.slo.now_utc')
def test_is_overdue(self, mock_now):
"""We can calculate days remaining in the SLO limit."""
start = datetime.datetime(2023, 6, 7, 12, 30, 0) # Wed
mock_now.return_value = datetime.datetime(2023, 6, 7, 12, 30, 0) # Wed
mock_now.return_value = datetime.datetime(2023, 6, 8, 12, 30, 0) # Thu
self.assertFalse(slo.is_overdue(start, 2))
mock_now.return_value = datetime.datetime(2023, 6, 9, 12, 30, 0) # Fri
self.assertFalse(slo.is_overdue(start, 2))
mock_now.return_value = datetime.datetime(2023, 6, 10, 12, 30, 0) # Sat
self.assertFalse(slo.is_overdue(start, 2))
mock_now.return_value = datetime.datetime(2023, 6, 11, 12, 30, 0) # Sun
self.assertFalse(slo.is_overdue(start, 2))
mock_now.return_value = datetime.datetime(2023, 6, 12, 12, 30, 0) # Mon
self.assertTrue(slo.is_overdue(start, 2))
class SLORecordingTests(testing_config.CustomTestCase):
def setUp(self):
self.gate = Gate(feature_id=1, stage_id=2, gate_type=3, state=4)
self.vote_review_requested = Vote(
feature_id=1, gate_id=2, gate_type=3, state=Vote.REVIEW_REQUESTED,
set_on=datetime.datetime(2023, 6, 7, 1, 2, 3), # Wed
set_by='feature-owner@example.com')
self.vote_needs_work = Vote(
feature_id=1, gate_id=2, gate_type=3, state=Vote.NEEDS_WORK,
set_on=datetime.datetime(2023, 6, 12, 1, 2, 3), # Mon
set_by='reviewer@example.com')
self.a_date = datetime.datetime(2023, 6, 17, 1, 2, 3)
def test_record_vote__not_started(self):
"""If this somehow gets called before the review starts, it's a no-op."""
# Note that self.gate.requested_on is None.
self.assertFalse(slo.record_vote(self.gate, []))
self.assertFalse(slo.record_vote(self.gate, [self.vote_review_requested]))
self.assertFalse(slo.record_vote(
self.gate, [self.vote_review_requested, self.vote_needs_work]))
self.assertIsNone(self.gate.requested_on)
self.assertIsNone(self.gate.responded_on)
def test_record_vote__just_started(self):
"""If checked after the request but before the response, it's a no-op."""
self.gate.requested_on = self.vote_review_requested.set_on
self.assertFalse(slo.record_vote(self.gate, []))
self.assertFalse(slo.record_vote(self.gate, [self.vote_review_requested]))
self.assertEqual(self.vote_review_requested.set_on, self.gate.requested_on)
self.assertIsNone(self.gate.responded_on)
def test_record_vote__got_response(self):
"""If called with the reviewer's response, that is recorded."""
self.gate.requested_on = self.vote_review_requested.set_on
self.assertTrue(slo.record_vote(
self.gate, [self.vote_review_requested, self.vote_needs_work]))
self.assertEqual(self.vote_review_requested.set_on, self.gate.requested_on)
self.assertEqual(self.vote_needs_work.set_on, self.gate.responded_on)
def test_record_vote__already_finished(self):
"""If this gets called after the review is done, it's a no-op."""
self.gate.requested_on = self.a_date
self.gate.responded_on = self.a_date
self.assertFalse(slo.record_vote(self.gate, []))
self.assertFalse(slo.record_vote(self.gate, [self.vote_review_requested]))
self.assertFalse(slo.record_vote(
self.gate, [self.vote_review_requested, self.vote_needs_work]))
self.assertEqual(self.a_date, self.gate.requested_on)
self.assertEqual(self.a_date, self.gate.responded_on)
@mock.patch('framework.permissions.can_approve_feature')
def test_record_comment__not_started(self, mock_caf):
"""Comments posted before the review starts don't count."""
feature, user, approvers = 'fake feature', 'fake user', ['fake approvers']
# Note that self.gate.requested_on is None.
mock_caf.return_value = False
self.assertFalse(slo.record_comment(feature, self.gate, user, approvers))
mock_caf.return_value = True
self.assertFalse(slo.record_comment(feature, self.gate, user, approvers))
self.assertIsNone(self.gate.requested_on)
self.assertIsNone(self.gate.responded_on)
@mock.patch('framework.permissions.can_approve_feature')
def test_record_comment__non_appover(self, mock_caf):
"""Comments posted during the review by non-approvers don't count."""
feature, user, approvers = 'fake feature', 'fake user', ['fake approvers']
self.gate.requested_on = self.a_date
mock_caf.return_value = False
self.assertFalse(slo.record_comment(feature, self.gate, user, approvers))
self.assertEqual(self.a_date, self.gate.requested_on)
self.assertIsNone(self.gate.responded_on)
@mock.patch('internals.slo.now_utc')
@mock.patch('framework.permissions.can_approve_feature')
def test_record_comment__appover(self, mock_caf, mock_now):
"""Comments posted during the review by an approver do count."""
feature, user, approvers = 'fake feature', 'fake user', ['fake approvers']
self.gate.requested_on = self.a_date
mock_caf.return_value = True
mock_now.return_value = self.a_date
self.assertTrue(slo.record_comment(feature, self.gate, user, approvers))
self.assertEqual(self.a_date, self.gate.requested_on)
self.assertEqual(self.a_date, self.gate.responded_on)

Просмотреть файл

@ -12,3 +12,4 @@ types-protobuf
types-redis
types-requests
types-setuptools
types-pytz