Implement a menu to add xfn gates. (#3539)
* Implement a menu to add xfn gates. * Added unit tests
This commit is contained in:
Родитель
66985240cb
Коммит
97c9be0484
|
@ -17,13 +17,15 @@ import datetime
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Any, Optional, Tuple
|
from typing import Any, Optional, Tuple
|
||||||
|
from google.cloud import ndb
|
||||||
|
|
||||||
from api import converters
|
from api import converters
|
||||||
from framework import basehandlers
|
from framework import basehandlers
|
||||||
from framework import permissions
|
from framework import permissions
|
||||||
from framework.users import User
|
from framework.users import User
|
||||||
from internals import approval_defs, notifier_helpers
|
from internals import approval_defs, notifier_helpers
|
||||||
from internals.core_models import FeatureEntry
|
from internals.core_enums import *
|
||||||
|
from internals.core_models import FeatureEntry, Stage
|
||||||
from internals.review_models import Gate, Vote
|
from internals.review_models import Gate, Vote
|
||||||
|
|
||||||
|
|
||||||
|
@ -159,3 +161,60 @@ class GatesAPI(basehandlers.APIHandler):
|
||||||
is_approver = permissions.can_review_gate(reviewer, fe, gate, approvers)
|
is_approver = permissions.can_review_gate(reviewer, fe, gate, approvers)
|
||||||
if not is_approver:
|
if not is_approver:
|
||||||
self.abort(400, 'Assignee is not a reviewer')
|
self.abort(400, 'Assignee is not a reviewer')
|
||||||
|
|
||||||
|
|
||||||
|
class XfnGatesAPI(basehandlers.APIHandler):
|
||||||
|
|
||||||
|
def do_get(self, **kwargs):
|
||||||
|
"""Reject unneeded GET requests without triggering Error Reporting."""
|
||||||
|
self.abort(405, valid_methods=['POST'])
|
||||||
|
|
||||||
|
def do_post(self, **kwargs) -> dict[str, str]:
|
||||||
|
"""Add a full set of cross-functional gates to a stage."""
|
||||||
|
feature_id: int = kwargs['feature_id']
|
||||||
|
fe: FeatureEntry | None = self.get_specified_feature(feature_id=feature_id)
|
||||||
|
if fe is None:
|
||||||
|
self.abort(404, msg=f'Feature {feature_id} not found')
|
||||||
|
stage_id: int = kwargs['stage_id']
|
||||||
|
stage: Stage | None = Stage.get_by_id(stage_id)
|
||||||
|
if stage is None:
|
||||||
|
self.abort(404, msg=f'Stage {stage_id} not found')
|
||||||
|
|
||||||
|
user: User = self.get_current_user(required=True)
|
||||||
|
is_editor = fe and permissions.can_edit_feature(user, fe.key.integer_id())
|
||||||
|
is_approver = approval_defs.fields_approvable_by(user)
|
||||||
|
if not is_editor and not is_approver:
|
||||||
|
self.abort(403, msg='User lacks permission to create gates')
|
||||||
|
|
||||||
|
count = self.create_xfn_gates(feature_id, stage_id)
|
||||||
|
return {'message': f'Created {count} gates'}
|
||||||
|
|
||||||
|
def get_needed_gate_types(self) -> list[int]:
|
||||||
|
"""Return a list of gate types normally used to ship a new feature."""
|
||||||
|
needed_gate_tuples = STAGES_AND_GATES_BY_FEATURE_TYPE[
|
||||||
|
FEATURE_TYPE_INCUBATE_ID]
|
||||||
|
for stage_type, gate_types in needed_gate_tuples:
|
||||||
|
if stage_type == STAGE_BLINK_SHIPPING:
|
||||||
|
return gate_types
|
||||||
|
raise ValueError('Could not find expected list of gate types')
|
||||||
|
|
||||||
|
def create_xfn_gates(self, feature_id, stage_id) -> int:
|
||||||
|
"""Create all new incubation gates on a PSA stage"""
|
||||||
|
logging.info('Creating xfn gates')
|
||||||
|
existing_gates = Gate.query(
|
||||||
|
Gate.feature_id == feature_id, Gate.stage_id == stage_id).fetch()
|
||||||
|
existing_gate_types = set([eg.gate_type for eg in existing_gates])
|
||||||
|
logging.info('Found existing: %r', existing_gate_types)
|
||||||
|
new_gates = []
|
||||||
|
for gate_type in self.get_needed_gate_types():
|
||||||
|
if gate_type not in existing_gate_types:
|
||||||
|
logging.info(f'Creating gate type {gate_type}')
|
||||||
|
gate = Gate(
|
||||||
|
feature_id=feature_id, stage_id=stage_id, gate_type=gate_type,
|
||||||
|
state=Gate.PREPARING)
|
||||||
|
new_gates.append(gate)
|
||||||
|
|
||||||
|
ndb.put_multi(new_gates)
|
||||||
|
num_new = len(new_gates)
|
||||||
|
logging.info(f'Created {num_new} gates')
|
||||||
|
return num_new
|
||||||
|
|
|
@ -21,7 +21,8 @@ import werkzeug.exceptions # Flask HTTP stuff.
|
||||||
from google.cloud import ndb # type: ignore
|
from google.cloud import ndb # type: ignore
|
||||||
|
|
||||||
from api import reviews_api
|
from api import reviews_api
|
||||||
from internals import core_enums
|
from internals import approval_defs
|
||||||
|
from internals.core_enums import *
|
||||||
from internals import core_models
|
from internals import core_models
|
||||||
from internals.review_models import Gate, Vote
|
from internals.review_models import Gate, Vote
|
||||||
|
|
||||||
|
@ -29,6 +30,10 @@ test_app = flask.Flask(__name__)
|
||||||
|
|
||||||
NOW = datetime.datetime.now()
|
NOW = datetime.datetime.now()
|
||||||
|
|
||||||
|
ALL_SHIPPING_GATE_TYPES = [
|
||||||
|
GATE_PRIVACY_SHIP, GATE_SECURITY_SHIP, GATE_ENTERPRISE_SHIP,
|
||||||
|
GATE_DEBUGGABILITY_SHIP, GATE_TESTING_SHIP, GATE_API_SHIP]
|
||||||
|
|
||||||
|
|
||||||
class VotesAPITest(testing_config.CustomTestCase):
|
class VotesAPITest(testing_config.CustomTestCase):
|
||||||
|
|
||||||
|
@ -355,3 +360,104 @@ class GatesAPITest(testing_config.CustomTestCase):
|
||||||
'gates': [],
|
'gates': [],
|
||||||
}
|
}
|
||||||
self.assertEqual(actual, expected)
|
self.assertEqual(actual, expected)
|
||||||
|
|
||||||
|
|
||||||
|
class XfnGatesAPITest(testing_config.CustomTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.feature_1 = core_models.FeatureEntry(
|
||||||
|
name='feature one', summary='sum', category=1,
|
||||||
|
owner_emails=['owner1@example.com'])
|
||||||
|
self.feature_1.put()
|
||||||
|
self.feature_id = self.feature_1.key.integer_id()
|
||||||
|
|
||||||
|
self.stage_1 = core_models.Stage(
|
||||||
|
feature_id=self.feature_id, stage_type=STAGE_BLINK_SHIPPING)
|
||||||
|
self.stage_1.put()
|
||||||
|
self.stage_id = self.stage_1.key.integer_id()
|
||||||
|
|
||||||
|
self.gate_1 = Gate(id=1, feature_id=self.feature_id, stage_id=self.stage_id,
|
||||||
|
gate_type=GATE_API_SHIP, state=Vote.NA)
|
||||||
|
self.gate_1.put()
|
||||||
|
self.gate_1_id = self.gate_1.key.integer_id()
|
||||||
|
|
||||||
|
self.handler = reviews_api.XfnGatesAPI()
|
||||||
|
self.request_path = '/api/v0/features/%d/stages/%d/addXfnGates' % (
|
||||||
|
self.feature_id, self.stage_id)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.feature_1.key.delete()
|
||||||
|
kinds: list[ndb.Model] = [Gate, Vote]
|
||||||
|
for kind in kinds:
|
||||||
|
for entity in kind.query():
|
||||||
|
entity.key.delete()
|
||||||
|
|
||||||
|
def test_get(self):
|
||||||
|
"""We reject all GETs to this endpoint."""
|
||||||
|
with test_app.test_request_context(self.request_path):
|
||||||
|
with self.assertRaises(werkzeug.exceptions.MethodNotAllowed):
|
||||||
|
self.handler.do_get()
|
||||||
|
|
||||||
|
def test_do_post__not_found(self):
|
||||||
|
"""Handler rejects bad requests."""
|
||||||
|
with test_app.test_request_context(self.request_path):
|
||||||
|
with self.assertRaises(werkzeug.exceptions.NotFound):
|
||||||
|
self.handler.do_post(
|
||||||
|
feature_id=self.feature_id + 1, stage_id=self.stage_id)
|
||||||
|
|
||||||
|
with test_app.test_request_context(self.request_path):
|
||||||
|
with self.assertRaises(werkzeug.exceptions.NotFound):
|
||||||
|
self.handler.do_post(
|
||||||
|
feature_id=self.feature_id, stage_id=self.stage_id + 1)
|
||||||
|
|
||||||
|
def test_do_post__not_allowed(self):
|
||||||
|
"""Handler rejects users who lack permission."""
|
||||||
|
testing_config.sign_out()
|
||||||
|
with test_app.test_request_context(self.request_path):
|
||||||
|
with self.assertRaises(werkzeug.exceptions.Forbidden):
|
||||||
|
self.handler.do_post(
|
||||||
|
feature_id=self.feature_id, stage_id=self.stage_id)
|
||||||
|
|
||||||
|
testing_config.sign_in('other@example.com', 999)
|
||||||
|
with test_app.test_request_context(self.request_path):
|
||||||
|
with self.assertRaises(werkzeug.exceptions.Forbidden):
|
||||||
|
self.handler.do_post(
|
||||||
|
feature_id=self.feature_id, stage_id=self.stage_id)
|
||||||
|
|
||||||
|
@mock.patch('api.reviews_api.XfnGatesAPI.create_xfn_gates')
|
||||||
|
def test_do_post__editors_allowed(self, mock_create):
|
||||||
|
"""Handler accepts users who can edit the feature."""
|
||||||
|
testing_config.sign_in('owner1@example.com', 123567890)
|
||||||
|
mock_create.return_value = 111
|
||||||
|
with test_app.test_request_context(self.request_path):
|
||||||
|
actual = self.handler.do_post(
|
||||||
|
feature_id=self.feature_id, stage_id=self.stage_id)
|
||||||
|
|
||||||
|
mock_create.assert_called_once_with(self.feature_id, self.stage_id)
|
||||||
|
self.assertEqual(actual, {'message': 'Created 111 gates'})
|
||||||
|
|
||||||
|
@mock.patch('api.reviews_api.XfnGatesAPI.create_xfn_gates')
|
||||||
|
def test_do_post__reviewers_allowed(self, mock_create):
|
||||||
|
"""Handler accepts users who can review any gate."""
|
||||||
|
testing_config.sign_in(approval_defs.ENTERPRISE_APPROVERS[0], 123567890)
|
||||||
|
mock_create.return_value = 222
|
||||||
|
with test_app.test_request_context(self.request_path):
|
||||||
|
actual = self.handler.do_post(
|
||||||
|
feature_id=self.feature_id, stage_id=self.stage_id)
|
||||||
|
|
||||||
|
mock_create.assert_called_once_with(self.feature_id, self.stage_id)
|
||||||
|
self.assertEqual(actual, {'message': 'Created 222 gates'})
|
||||||
|
|
||||||
|
def test_get_needed_gate_types(self):
|
||||||
|
"""We always assume that we are adding all gates for STAGE_BLINK_SHIPPING."""
|
||||||
|
actual = self.handler.get_needed_gate_types()
|
||||||
|
self.assertEqual(actual,ALL_SHIPPING_GATE_TYPES)
|
||||||
|
|
||||||
|
def test_create_xfn_gates__normal(self):
|
||||||
|
"""We can create the missing gates from STAGE_BLINK_SHIPPING."""
|
||||||
|
actual = self.handler.create_xfn_gates(self.feature_id, self.stage_id)
|
||||||
|
|
||||||
|
self.assertEqual(actual, 5)
|
||||||
|
actual_gates_dict = Gate.get_feature_gates(self.feature_id)
|
||||||
|
self.assertCountEqual(
|
||||||
|
actual_gates_dict.keys(), ALL_SHIPPING_GATE_TYPES)
|
||||||
|
|
|
@ -24,6 +24,7 @@ import {
|
||||||
PLATFORMS_DISPLAYNAME,
|
PLATFORMS_DISPLAYNAME,
|
||||||
STAGE_SPECIFIC_FIELDS,
|
STAGE_SPECIFIC_FIELDS,
|
||||||
OT_MILESTONE_END_FIELDS,
|
OT_MILESTONE_END_FIELDS,
|
||||||
|
STAGE_PSA_SHIPPING,
|
||||||
ENTERPRISE_FEATURE_CATEGORIES_DISPLAYNAME,
|
ENTERPRISE_FEATURE_CATEGORIES_DISPLAYNAME,
|
||||||
ROLLOUT_IMPACT_DISPLAYNAME} from './form-field-enums';
|
ROLLOUT_IMPACT_DISPLAYNAME} from './form-field-enums';
|
||||||
import '@polymer/iron-icon';
|
import '@polymer/iron-icon';
|
||||||
|
@ -124,10 +125,14 @@ class ChromedashFeatureDetail extends LitElement {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
sl-details sl-button {
|
sl-details sl-button,
|
||||||
|
sl-details sl-dropdown {
|
||||||
float: right;
|
float: right;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
sl-details sl-dropdown sl-icon-button {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
sl-details sl-button[variant="default"]::part(base) {
|
sl-details sl-button[variant="default"]::part(base) {
|
||||||
color: var(--sl-color-primary-600);
|
color: var(--sl-color-primary-600);
|
||||||
|
@ -187,6 +192,15 @@ class ChromedashFeatureDetail extends LitElement {
|
||||||
`];
|
`];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_fireEvent(eventName, detail) {
|
||||||
|
const event = new CustomEvent(eventName, {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
this.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.intializeGateColumn();
|
this.intializeGateColumn();
|
||||||
|
@ -244,6 +258,18 @@ class ChromedashFeatureDetail extends LitElement {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleAddXfnGates(feStage) {
|
||||||
|
const prompt = (
|
||||||
|
'Would you like to add gates for Privacy, Security, etc.? \n\n' +
|
||||||
|
'This is needed if the API Owners ask you to add them, ' +
|
||||||
|
'or if you send an "Intent to Ship" rather than a PSA.');
|
||||||
|
if (confirm(prompt)) {
|
||||||
|
window.csClient.addXfnGates(feStage.feature_id, feStage.id).then(() => {
|
||||||
|
this._fireEvent('refetch-needed', {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderControls() {
|
renderControls() {
|
||||||
const editAllButton = html`
|
const editAllButton = html`
|
||||||
<sl-button variant="text"
|
<sl-button variant="text"
|
||||||
|
@ -591,12 +617,14 @@ class ChromedashFeatureDetail extends LitElement {
|
||||||
const isActive = this.feature.active_stage_id === feStage.id;
|
const isActive = this.feature.active_stage_id === feStage.id;
|
||||||
|
|
||||||
// Show any buttons that should be displayed at the top of the detail card.
|
// Show any buttons that should be displayed at the top of the detail card.
|
||||||
|
const stageMenu = this.renderStageMenu(feStage);
|
||||||
const addExtensionButton = this.renderExtensionButton(feStage);
|
const addExtensionButton = this.renderExtensionButton(feStage);
|
||||||
const editButton = this.renderEditButton(feStage, processStage);
|
const editButton = this.renderEditButton(feStage, processStage);
|
||||||
const trialButton = this.renderOriginTrialButton(feStage);
|
const trialButton = this.renderOriginTrialButton(feStage);
|
||||||
|
|
||||||
const content = html`
|
const content = html`
|
||||||
<p class="description">
|
<p class="description">
|
||||||
|
${stageMenu}
|
||||||
${trialButton}
|
${trialButton}
|
||||||
${editButton}
|
${editButton}
|
||||||
${addExtensionButton}
|
${addExtensionButton}
|
||||||
|
@ -707,6 +735,33 @@ class ChromedashFeatureDetail extends LitElement {
|
||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
offerAddXfnGates(feStage) {
|
||||||
|
const stageGates = this.gates.filter(g => g.stage_id == feStage.id);
|
||||||
|
return (feStage.stage_type == STAGE_PSA_SHIPPING &&
|
||||||
|
stageGates.length < 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStageMenu(feStage) {
|
||||||
|
const items = [];
|
||||||
|
if (this.offerAddXfnGates(feStage)) {
|
||||||
|
items.push(html`
|
||||||
|
<sl-menu-item @click=${() => this.handleAddXfnGates(feStage)}>
|
||||||
|
Add cross-functional gates
|
||||||
|
</sl-menu-item>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) return nothing;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sl-dropdown>
|
||||||
|
<sl-icon-button library="material" name="more_vert_24px" label="Stage menu"
|
||||||
|
slot="trigger"></sl-icon-button>
|
||||||
|
<sl-menu>${items}</sl-menu>
|
||||||
|
</sl-dropdown>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
renderAddStageButton() {
|
renderAddStageButton() {
|
||||||
if (!this.canEdit) {
|
if (!this.canEdit) {
|
||||||
return nothing;
|
return nothing;
|
||||||
|
|
|
@ -338,6 +338,11 @@ class ChromeStatusClient {
|
||||||
return this.doPatch(`/features/${featureId}/stages/${stageId}`, body);
|
return this.doPatch(`/features/${featureId}/stages/${stageId}`, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addXfnGates(featureId, stageId) {
|
||||||
|
return this.doPost(
|
||||||
|
`/features/${featureId}/stages/${stageId}/addXfnGates`);
|
||||||
|
}
|
||||||
|
|
||||||
// Processes API
|
// Processes API
|
||||||
async getFeatureProcess(featureId) {
|
async getFeatureProcess(featureId) {
|
||||||
return this.doGet(`/features/${featureId}/process`);
|
return this.doGet(`/features/${featureId}/process`);
|
||||||
|
|
3
main.py
3
main.py
|
@ -129,6 +129,9 @@ api_routes: list[Route] = [
|
||||||
stages_api.StagesAPI),
|
stages_api.StagesAPI),
|
||||||
Route(f'{API_BASE}/features/<int:feature_id>/stages/<int:stage_id>',
|
Route(f'{API_BASE}/features/<int:feature_id>/stages/<int:stage_id>',
|
||||||
stages_api.StagesAPI),
|
stages_api.StagesAPI),
|
||||||
|
Route(
|
||||||
|
f'{API_BASE}/features/<int:feature_id>/stages/<int:stage_id>/addXfnGates',
|
||||||
|
reviews_api.XfnGatesAPI),
|
||||||
|
|
||||||
Route(f'{API_BASE}/blinkcomponents',
|
Route(f'{API_BASE}/blinkcomponents',
|
||||||
blink_components_api.BlinkComponentsAPI),
|
blink_components_api.BlinkComponentsAPI),
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z"/></svg>
|
После Ширина: | Высота: | Размер: 409 B |
Загрузка…
Ссылка в новой задаче