diff --git a/api/origin_trials_api.py b/api/origin_trials_api.py index f8480d14909e..f5dee8f9422e 100644 --- a/api/origin_trials_api.py +++ b/api/origin_trials_api.py @@ -18,6 +18,7 @@ import flask import json5 +import re import requests import validators @@ -47,6 +48,48 @@ def get_chromium_file(url: str) -> str: return b64decode(conn.read()).decode('utf-8') +def get_chromium_files_for_validation() -> dict: + """Get all chromium file contents stored in a dictionary""" + chromium_files = {} # Chromium source file contents. + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + future_to_name = {executor.submit(get_chromium_file, f['url']): f['name'] + for f in CHROMIUM_SRC_FILES} + for future in concurrent.futures.as_completed(future_to_name): + name = future_to_name[future] + try: + chromium_files[name] = future.result() + except Exception as exc: + raise exc + return chromium_files + + +def find_use_counter_value( + body: dict, chromium_files_dict: dict) -> int | None: + """Find where the use counter is defined and return its value.""" + use_counter_name = body.get( + 'ot_webfeature_use_counter', {}).get('value') + webfeature_use_counter = body.get( + 'ot_webfeature_use_counter', {}).get('value') + is_webdx_use_counter = ( + webfeature_use_counter and + webfeature_use_counter.startswith('WebDXFeature::')) + + match: re.Match | None = None + if webfeature_use_counter and is_webdx_use_counter: + # Remove "WebDXFeature::" prefix. + expression = f'(?<={use_counter_name[14:]} = )[0-9]+' + match = re.search(expression, + chromium_files_dict['webdxfeature_file']) + elif webfeature_use_counter: + expression = f'(?<={use_counter_name} = )[0-9]+' + match = re.search(expression, + chromium_files_dict['webfeature_file']) + + if match: + return int(match.group(0)) + return None + + class OriginTrialsAPI(basehandlers.EntitiesAPIHandler): def do_get(self, **kwargs): @@ -67,19 +110,8 @@ def do_get(self, **kwargs): }) def _validate_creation_args( - self, body: dict) -> dict[str, str]: + self, body: dict, chromium_files: dict) -> dict[str, str]: """Check that all provided OT creation arguments are valid.""" - chromium_files = {} # Chromium source file contents. - with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: - future_to_name = {executor.submit(get_chromium_file, f['url']): f['name'] - for f in CHROMIUM_SRC_FILES} - for future in concurrent.futures.as_completed(future_to_name): - name = future_to_name[future] - try: - chromium_files[name] = future.result() - except Exception as exc: - self.abort( - 500, f'Error obtaining Chromium file for validation: {str(exc)}') validation_errors: dict[str, str] = {} chromium_trial_name = body.get( @@ -188,13 +220,20 @@ def do_post(self, **kwargs): #TODO(markxiong0122): remove to_dict() when PR#4213 is merged body = CreateOriginTrialRequest.from_dict(self.get_json_param_dict()).to_dict() - validation_errors = self._validate_creation_args(body) + try: + chromium_files_dict = get_chromium_files_for_validation() + except Exception as exc: + self.abort( + 500, f'Error obtaining Chromium file for validation: {str(exc)}') + validation_errors = self._validate_creation_args(body, chromium_files_dict) if validation_errors: return { 'message': 'Errors found when validating arguments', 'errors': validation_errors } self.update_stage(ot_stage, body, []) + ot_stage.ot_use_counter_bucket_number = find_use_counter_value(body, chromium_files_dict) + # Flag OT stage as ready to be created. ot_stage.ot_setup_status = OT_READY_FOR_CREATION ot_stage.put() diff --git a/api/origin_trials_api_test.py b/api/origin_trials_api_test.py index f5e3173e5cd1..7a03b7b20e61 100644 --- a/api/origin_trials_api_test.py +++ b/api/origin_trials_api_test.py @@ -67,6 +67,29 @@ def setUp(self): self.google_user = AppUser(email='feature_owner@google.com') self.google_user.put() + + self.mock_trials_list = [ + { + 'id': '-5269211564023480319', + 'display_name': 'Example Trial', + 'description': 'A description.', + 'origin_trial_feature_name': 'ExampleTrial', + 'status': 'ACTIVE', + 'enabled': True, + 'chromestatus_url': 'https://example.com/chromestatus', + 'start_milestone': '123', + 'end_milestone': '456', + 'original_end_milestone': '450', + 'feedback_url': 'https://example.com/feedback', + 'documentation_url': 'https://example.com/docs', + 'intent_to_experiment_url': 'https://example.com/intent', + 'trial_extensions': [{}], + 'type': 'ORIGIN_TRIAL', + 'allow_third_party_origins': True, + 'end_time': '2025-01-01T00:00:00Z', + }, + ] + self.mock_web_usecounters_file = """ enum WebFeature { kSomeFeature = 1, @@ -78,12 +101,13 @@ def setUp(self): """ self.mock_webdx_usecounters_file = """ -enum WebFeature { +enum WebDXFeature { kSomeFeature = 1, kValidFeature = 2, - kNoThirdParty = 3 + kNoThirdParty = 3, kSample = 4, kNoCriticalTrial = 5, + kValidTrial = 6, }; """ @@ -133,7 +157,12 @@ def setUp(self): 'origin_trial_feature_name': 'ExistingFeature', } ] - + self.mock_chromium_files_dict = { + 'webfeature_file': self.mock_web_usecounters_file, + 'webdxfeature_file': self.mock_webdx_usecounters_file, + 'enabled_features_text': self.mock_features_file, + 'grace_period_file': self.mock_grace_period_file, + } def tearDown(self): for kind in [AppUser, FeatureEntry, Gate, Stage]: for entity in kind.query(): @@ -167,18 +196,6 @@ def test_get__invalid( with self.assertRaises(werkzeug.exceptions.InternalServerError): self.handler.do_get() - def mock_chromium_file_return_value_generator(self, *args, **kwargs): - """Returns mock milestone info based on input.""" - if args == ('https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/public/mojom/use_counter/metrics/web_feature.mojom?format=TEXT',): - return self.mock_web_usecounters_file - if args == ('https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/public/mojom/use_counter/metrics/webdx_feature.mojom?format=TEXT',): - return self.mock_webdx_usecounters_file - if args == ('https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/platform/runtime_enabled_features.json5?format=TEXT',): - return self.mock_features_file - if args == ('https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/common/origin_trials/manual_completion_origin_trial_features.cc?format=TEXT',): - return self.mock_grace_period_file - return '' - def test_check_post_permissions__anon(self): """Anon users cannot request origin trials.""" testing_config.sign_out() @@ -244,12 +261,12 @@ def test_do_post__anon(self): with self.assertRaises(werkzeug.exceptions.Forbidden): self.handler.do_post(**kwargs) - @mock.patch('api.origin_trials_api.get_chromium_file') - def test_validate_creation_args__valid_webfeature(self, mock_get_chromium_file): + @mock.patch('framework.origin_trials_client.get_trials_list') + def test_validate_creation_args__valid_webfeature( + self, mock_get_trials_list): """No error messages should be returned if all args are valid using a WebFeature use counter.""" - mock_get_chromium_file.side_effect = self.mock_chromium_file_return_value_generator - + mock_get_trials_list.return_value = self.mock_trials_list body = { 'ot_chromium_trial_name': { 'form_field_name': 'ot_chromium_trial_name', @@ -274,15 +291,17 @@ def test_validate_creation_args__valid_webfeature(self, mock_get_chromium_file): } # No exception should be raised. with test_app.test_request_context(self.request_path): - result = self.handler._validate_creation_args(body) + result = self.handler._validate_creation_args( + body, self.mock_chromium_files_dict) expected = {} self.assertEqual(expected, result) - @mock.patch('api.origin_trials_api.get_chromium_file') - def test_validate_creation_args__valid_webdxfeature(self, mock_get_chromium_file): + @mock.patch('framework.origin_trials_client.get_trials_list') + def test_validate_creation_args__valid_webdxfeature( + self, mock_get_trials_list): """No error messages should be returned if all args are valid using a WebDXFeature use counter.""" - mock_get_chromium_file.side_effect = self.mock_chromium_file_return_value_generator + mock_get_trials_list.return_value = self.mock_trials_list body = { 'ot_chromium_trial_name': { @@ -308,15 +327,16 @@ def test_validate_creation_args__valid_webdxfeature(self, mock_get_chromium_file } # No exception should be raised. with test_app.test_request_context(self.request_path): - result = self.handler._validate_creation_args(body) + result = self.handler._validate_creation_args( + body, self.mock_chromium_files_dict) expected = {} self.assertEqual(expected, result) - @mock.patch('api.origin_trials_api.get_chromium_file') + @mock.patch('framework.origin_trials_client.get_trials_list') def test_validate_creation_args__invalid_webfeature_use_counter( - self, mock_get_chromium_file): + self, mock_get_trials_list): """Error message returned if UseCounter not found in file.""" - mock_get_chromium_file.side_effect = self.mock_chromium_file_return_value_generator + mock_get_trials_list.return_value = self.mock_trials_list body = { 'ot_chromium_trial_name': { 'form_field_name': 'ot_chromium_trial_name', @@ -340,16 +360,17 @@ def test_validate_creation_args__invalid_webfeature_use_counter( }, } with test_app.test_request_context(self.request_path): - result = self.handler._validate_creation_args(body) + result = self.handler._validate_creation_args( + body, self.mock_chromium_files_dict) expected = { 'ot_webfeature_use_counter': 'UseCounter not landed in web_feature.mojom'} self.assertEqual(expected, result) - @mock.patch('api.origin_trials_api.get_chromium_file') + @mock.patch('framework.origin_trials_client.get_trials_list') def test_validate_creation_args__invalid_webdxfeature_use_counter( - self, mock_get_chromium_file): + self, mock_get_trials_list): """Error message returned if WebDXFeature UseCounter not found in file.""" - mock_get_chromium_file.side_effect = self.mock_chromium_file_return_value_generator + mock_get_trials_list.return_value = self.mock_trials_list body = { 'ot_chromium_trial_name': { 'form_field_name': 'ot_chromium_trial_name', @@ -373,16 +394,17 @@ def test_validate_creation_args__invalid_webdxfeature_use_counter( }, } with test_app.test_request_context(self.request_path): - result = self.handler._validate_creation_args(body) + result = self.handler._validate_creation_args( + body, self.mock_chromium_files_dict) expected = { 'ot_webfeature_use_counter': 'UseCounter not landed in webdx_feature.mojom'} self.assertEqual(expected, result) - @mock.patch('api.origin_trials_api.get_chromium_file') + @mock.patch('framework.origin_trials_client.get_trials_list') def test_validate_creation_args__missing_webdxfeature_use_counter( - self, mock_get_chromium_file): + self, mock_get_trials_list): """Error message returned if WebDXFeature UseCounter not found in file.""" - mock_get_chromium_file.side_effect = self.mock_chromium_file_return_value_generator + mock_get_trials_list.return_value = self.mock_trials_list body = { 'ot_chromium_trial_name': { 'form_field_name': 'ot_chromium_trial_name', @@ -406,17 +428,18 @@ def test_validate_creation_args__missing_webdxfeature_use_counter( }, } with test_app.test_request_context(self.request_path): - result = self.handler._validate_creation_args(body) + result = self.handler._validate_creation_args( + body, self.mock_chromium_files_dict) expected = { 'ot_webfeature_use_counter': 'No WebDXFeature use counter provided.'} self.assertEqual(expected, result) - @mock.patch('api.origin_trials_api.get_chromium_file') + @mock.patch('framework.origin_trials_client.get_trials_list') def test_validate_creation_args__missing_webfeature_use_counter( - self, mock_get_chromium_file): + self, mock_get_trials_list): """Error message returned if both UseCounter types are missing for a non-deprecation trial.""" - mock_get_chromium_file.side_effect = self.mock_chromium_file_return_value_generator + mock_get_trials_list.return_value = self.mock_trials_list body = { 'ot_chromium_trial_name': { 'form_field_name': 'ot_chromium_trial_name', @@ -436,7 +459,8 @@ def test_validate_creation_args__missing_webfeature_use_counter( }, } with test_app.test_request_context(self.request_path): - result = self.handler._validate_creation_args(body) + result = self.handler._validate_creation_args( + body, self.mock_chromium_files_dict) # A validation error should exist for both use counter fields. expected = { 'ot_webfeature_use_counter': ( @@ -444,12 +468,11 @@ def test_validate_creation_args__missing_webfeature_use_counter( } self.assertEqual(expected, result) - @mock.patch('api.origin_trials_api.get_chromium_file') + @mock.patch('framework.origin_trials_client.get_trials_list') def test_validate_creation_args__missing_webfeature_use_counter_deprecation( - self, mock_get_chromium_file): + self, mock_get_trials_list): """No error message returned for missing UseCounter if deprecation trial.""" - """Deprecation trial does not need a webfeature use counter value.""" - mock_get_chromium_file.side_effect = self.mock_chromium_file_return_value_generator + mock_get_trials_list.return_value = self.mock_trials_list body = { 'ot_chromium_trial_name': { 'form_field_name': 'ot_chromium_trial_name', @@ -469,15 +492,15 @@ def test_validate_creation_args__missing_webfeature_use_counter_deprecation( }, } with test_app.test_request_context(self.request_path): - result = self.handler._validate_creation_args(body) + result = self.handler._validate_creation_args( + body, self.mock_chromium_files_dict) expected = {} self.assertEqual(expected, result) - @mock.patch('api.origin_trials_api.get_chromium_file') - def test_validate_creation_args__invalid_chromium_trial_name( - self, mock_get_chromium_file): + @mock.patch('framework.origin_trials_client.get_trials_list') + def test_validate_creation_args__invalid_chromium_trial_name(self, mock_get_trials_list): """Error message returned if Chromium trial name not found in file.""" - mock_get_chromium_file.side_effect = self.mock_chromium_file_return_value_generator + mock_get_trials_list.return_value = self.mock_trials_list body = { 'ot_chromium_trial_name': { 'form_field_name': 'ot_chromium_trial_name', @@ -501,16 +524,17 @@ def test_validate_creation_args__invalid_chromium_trial_name( }, } with test_app.test_request_context(self.request_path): - result = self.handler._validate_creation_args(body) + result = self.handler._validate_creation_args( + body, self.mock_chromium_files_dict) expected = {'ot_chromium_trial_name': ( 'Origin trial feature name not found in file')} self.assertEqual(expected, result) - @mock.patch('api.origin_trials_api.get_chromium_file') + @mock.patch('framework.origin_trials_client.get_trials_list') def test_validate_creation_args__missing_chromium_trial_name( - self, mock_get_chromium_file): + self, mock_get_trials_list): """Error message returned if Chromium trial is missing from request.""" - mock_get_chromium_file.side_effect = self.mock_chromium_file_return_value_generator + mock_get_trials_list.return_value = self.mock_trials_list body = { 'ot_webfeature_use_counter': { 'form_field_name': 'ot_webfeature_use_counter', @@ -531,13 +555,14 @@ def test_validate_creation_args__missing_chromium_trial_name( } with test_app.test_request_context(self.request_path): with self.assertRaises(werkzeug.exceptions.BadRequest): - self.handler._validate_creation_args(body) + self.handler._validate_creation_args( + body, self.mock_chromium_files_dict) - @mock.patch('api.origin_trials_api.get_chromium_file') + @mock.patch('framework.origin_trials_client.get_trials_list') def test_validate_creation_args__invalid_third_party_trial( - self, mock_get_chromium_file): + self, mock_get_trials_list): """Error message returned if third party support not found in file.""" - mock_get_chromium_file.side_effect = self.mock_chromium_file_return_value_generator + mock_get_trials_list.return_value = self.mock_trials_list body = { 'ot_chromium_trial_name': { 'form_field_name': 'ot_chromium_trial_name', @@ -561,18 +586,19 @@ def test_validate_creation_args__invalid_third_party_trial( }, } with test_app.test_request_context(self.request_path): - result = self.handler._validate_creation_args(body) + result = self.handler._validate_creation_args( + body, self.mock_chromium_files_dict) expected = {'ot_has_third_party_support': ( 'One or more features do not have third party ' 'support set in runtime_enabled_features.json5. ' 'Feature name: NoThirdParty')} self.assertEqual(expected, result) - @mock.patch('api.origin_trials_api.get_chromium_file') + @mock.patch('framework.origin_trials_client.get_trials_list') def test_validate_creation_args__invalid_critical_trial( - self, mock_get_chromium_file): + self, mock_get_trials_list): """Error message returned if critical trial name not found in file.""" - mock_get_chromium_file.side_effect = self.mock_chromium_file_return_value_generator + mock_get_trials_list.return_value = self.mock_trials_list body = { 'ot_chromium_trial_name': { 'form_field_name': 'ot_chromium_trial_name', @@ -596,7 +622,8 @@ def test_validate_creation_args__invalid_critical_trial( }, } with test_app.test_request_context(self.request_path): - result = self.handler._validate_creation_args(body) + result = self.handler._validate_creation_args( + body, self.mock_chromium_files_dict) expected = {'ot_is_critical_trial': ( 'Use counter has not landed in grace period array for critical trial')} self.assertEqual(expected, result) @@ -657,8 +684,10 @@ def test_validate_extension_args__not_approved(self): self.feature_1_id, self.ot_stage_1, self.extension_stage_1) @mock.patch('api.origin_trials_api.OriginTrialsAPI._validate_creation_args') - def test_post__valid(self, mock_validate_func): + @mock.patch('api.origin_trials_api.get_chromium_files_for_validation') + def test_post__valid(self, mock_get_chromium_files, mock_validate_func): """A valid OT creation request is processed and marked for creation.""" + mock_get_chromium_files.return_value = self.mock_chromium_files_dict mock_validate_func.return_value = {} testing_config.sign_in('feature_owner@google.com', 1234567890) body = { @@ -724,7 +753,7 @@ def test_post__valid(self, mock_validate_func): }, 'ot_webfeature_use_counter': { 'form_field_name': 'ot_webfeature_use_counter', - 'value': 'kValidTrial', + 'value': 'WebDXFeature::kValidTrial', }, 'ot_is_critical_trial': { 'form_field_name': 'ot_is_critical_trial', @@ -761,5 +790,7 @@ def test_post__valid(self, mock_validate_func): self.assertEqual(getattr(self.ot_stage_1.milestones, field), value) continue self.assertEqual(getattr(self.ot_stage_1, field), value) + # Use counter bucket number should be updated. + self.assertEqual(self.ot_stage_1.ot_use_counter_bucket_number, 6) # Stage should be marked as "Ready for creation" self.assertEqual(self.ot_stage_1.ot_setup_status, 2) diff --git a/framework/origin_trials_client.py b/framework/origin_trials_client.py index 20ab09ab3bc3..b1c427cfdfcb 100644 --- a/framework/origin_trials_client.py +++ b/framework/origin_trials_client.py @@ -28,6 +28,9 @@ import settings +class UseCounterConfig(TypedDict): + bucket_number: int + class RequestTrial(TypedDict): id: NotRequired[int] display_name: str @@ -42,6 +45,8 @@ class RequestTrial(TypedDict): allow_third_party_origins: bool type: str origin_trial_feature_name: NotRequired[str] + blink_use_counter_config: NotRequired[UseCounterConfig] + blink_webdx_use_counter_config: NotRequired[UseCounterConfig] class InternalRegistrationConfig(TypedDict): @@ -178,6 +183,13 @@ def _send_create_trial_request( json['trial']['origin_trial_feature_name'] = ot_stage.ot_chromium_trial_name if ot_stage.ot_is_deprecation_trial: json['registration_config']['allow_public_suffix_subdomains'] = True + if ot_stage.ot_use_counter_bucket_number: + config: UseCounterConfig = {'bucket_number': ot_stage.ot_use_counter_bucket_number} + if (ot_stage.ot_chromium_trial_name + and ot_stage.ot_chromium_trial_name.startswith('WebDXFeature::')): + json['trial']['blink_webdx_use_counter_config'] = config + else: + json['trial']['blink_use_counter_config'] = config headers = {'Authorization': f'Bearer {access_token}'} url = f'{settings.OT_API_URL}/v1/trials:initialize' diff --git a/framework/origin_trials_client_test.py b/framework/origin_trials_client_test.py index 80ec303cf70f..69a05feb6359 100644 --- a/framework/origin_trials_client_test.py +++ b/framework/origin_trials_client_test.py @@ -37,6 +37,7 @@ def setUp(self): ot_emails=['anotheruser@chromium.org', 'contact@microsoft.com', 'editor@google.com', 'someuser@google.com'], ot_description='OT description', ot_has_third_party_support=True, + ot_use_counter_bucket_number=11, ot_require_approvals=True, ot_approval_buganizer_component=123456, ot_approval_criteria_url='https://example.com/criteria', ot_approval_group_email='somegroup@google.com', @@ -208,7 +209,7 @@ def test_create_origin_trial__with_api_key( """If an API key is available, POST should create trial and return true.""" mock_requests_post.return_value = mock.MagicMock( status_code=200, json=lambda : ( - {'trial': {'id': -1234567890}, 'should_retry': False})) + {'trial': {'id': -1234567890}})) mock_get_trial_end_time.return_value = 111222333 mock_get_ot_access_token.return_value = 'access_token' mock_api_key_get.return_value = 'api_key_value' @@ -237,6 +238,9 @@ def test_create_origin_trial__with_api_key( 'chromestatus_url': f'{settings.SITE_URL}feature/1', 'allow_third_party_origins': True, 'type': 'DEPRECATION', + 'blink_use_counter_config': { + 'bucket_number': 11, + } }, create_trial_json['trial']) self.assertEqual({ 'allow_public_suffix_subdomains': True, @@ -255,6 +259,40 @@ def test_create_origin_trial__with_api_key( set_up_trial_json['data_access_admin_group_name']) self.assertEqual(-1234567890, set_up_trial_json['trial_id']) + @mock.patch('framework.secrets.get_ot_data_access_admin_group') + @mock.patch('framework.secrets.get_ot_api_key') + @mock.patch('framework.origin_trials_client._get_ot_access_token') + @mock.patch('framework.origin_trials_client._get_trial_end_time') + @mock.patch('requests.post') + def test_create_origin_trial__webdx_feature( + self, mock_requests_post, mock_get_trial_end_time, + mock_get_ot_access_token, mock_api_key_get, mock_get_admin_group): + """WebDXFeature use counters should have different config in request.""" + self.ot_stage.ot_chromium_trial_name = 'WebDXFeature::Example' + self.ot_stage.put() + mock_requests_post.return_value = mock.MagicMock( + status_code=200, json=lambda : ( + {'trial': {'id': -1234567890}})) + mock_get_trial_end_time.return_value = 111222333 + mock_get_ot_access_token.return_value = 'access_token' + mock_api_key_get.return_value = 'api_key_value' + mock_get_admin_group.return_value = 'test-group-123' + + ot_id, error_text = origin_trials_client.create_origin_trial(self.ot_stage) + self.assertEqual(ot_id, '-1234567890') + self.assertIsNone(error_text) + + mock_api_key_get.assert_called_once() + mock_get_ot_access_token.assert_called_once() + # Two separate POST requests made. + self.assertEqual(2, mock_requests_post.call_count) + create_trial_json = mock_requests_post.call_args_list[0][1]['json'] + # WebFeature config should be null. + self.assertEqual(None, create_trial_json['trial'].get('blink_use_counter_config')) + # WebDXFeature config should be populated. + self.assertEqual({'bucket_number': 11}, + create_trial_json['trial']['blink_webdx_use_counter_config']) + @mock.patch('framework.secrets.get_ot_api_key') @mock.patch('requests.post') def test_activate_origin_trial__no_api_key( diff --git a/internals/notifier_helpers_test.py b/internals/notifier_helpers_test.py index 9ce20bae8603..98e327445c79 100644 --- a/internals/notifier_helpers_test.py +++ b/internals/notifier_helpers_test.py @@ -24,7 +24,7 @@ class ActivityTest(testing_config.CustomTestCase): def setUp(self): self.feature_1 = FeatureEntry( - name='feature a', summary='sum', category=1, + id=111, name='feature a', summary='sum', category=1, owner_emails=['feature_owner@example.com']) self.feature_1.put() self.feature_id = self.feature_1.key.integer_id() @@ -122,7 +122,7 @@ def test_vote_changes_activities__needs_work_note(self, mock_task_helpers): Vote.NEEDS_WORK, Vote.NA) prop_change = { - 'prop_name': 'API Owners review status http://127.0.0.1:7777/feature/2925?gate=123', + 'prop_name': 'API Owners review status http://127.0.0.1:7777/feature/111?gate=123', 'old_val': 'na', 'new_val': 'needs_work', 'note': 'Feature owners must press the "Re-request review" button after requested changes have been completed.',