[3/3] New Intent SPA component and direct intent posting (#4114)

* new Intents API and notifier for intent sending

* OpenAPI for Intents API

* Add request body for Intents API POST

* reference request object correctly

* Intents API tests

* notifier tests for intent posting

* fix tests

* changes suggested by @jrobbins

* changes suggested by @jcscottiii

* Fix tests and add new ones

* mypy and test fix

* Changes suggested by @jcscottiii

* Use OpenAPI object to create response

* UI changes and paths for intent draft and posting

* changes suggested by @jrobbins

* lint-fix

* read subject from API response

* remove unused imports

* Move copy intent body event to @click

* Additional fixes

intents_api.py: Pass in `gate_id` for intent GET request to populate URL query string. Also, pass `sections_to_show` param for template rendering.
chromedash-intent-preview-page.js: When finding matching stage, search extension stages as well.
cs-client.js: Pass `gateId` in GET request.
notifier.py: Add APP_TITLE to populate intent template.
main.py: Update route for passing `gate_id` in GET request.
api.yaml: Add definition for `gate_id` in GET request.
This commit is contained in:
Daniel Smith 2024-07-25 12:08:06 -07:00 коммит произвёл GitHub
Родитель ff3f830926
Коммит e4b279863d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
20 изменённых файлов: 726 добавлений и 17 удалений

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

@ -28,6 +28,7 @@ from internals.core_enums import INTENT_STAGES_BY_STAGE_TYPE
from internals.core_models import FeatureEntry
from internals.review_models import Gate
from pages.intentpreview import compute_subject_prefix
import settings
# Format for Google Cloud Task body passed to cloud_tasks_helpers.enqueue_task
@ -58,6 +59,14 @@ class IntentsAPI(basehandlers.APIHandler):
if stage.feature_id != feature_id:
self.abort(400, msg='Stage does not belong to given feature')
gate_id = int(kwargs.get('gate_id', 0))
if gate_id:
gate: Gate|None = Gate.get_by_id(gate_id)
if not gate:
self.abort(400, msg='Invalid Gate ID')
elif gate.stage_id != stage_id:
self.abort(400, msg='Given gate does not belong to stage')
# Check that the user has feature edit permissions.
redirect_resp = permissions.validate_feature_edit_permission(
self, feature_id)
@ -68,13 +77,19 @@ class IntentsAPI(basehandlers.APIHandler):
intent_stage = INTENT_STAGES_BY_STAGE_TYPE[stage.stage_type]
default_url = (f'{self.request.scheme}://{self.request.host}'
f'/feature/{feature_id}')
if gate_id:
default_url += f'?gate={gate_id}'
template_data = {
'feature': converters.feature_entry_to_json_verbose(feature),
'stage_info': stage_helpers.get_stage_info_for_templates(feature),
'should_render_mstone_table': stage_info['should_render_mstone_table'],
'should_render_intents': stage_info['should_render_intents'],
'sections_to_show': processes.INTENT_EMAIL_SECTIONS.get(
intent_stage, []),
'intent_stage': intent_stage,
'default_url': default_url,
'APP_TITLE': settings.APP_TITLE,
}
return GetIntentResponse(
subject=(f'{compute_subject_prefix(feature, intent_stage)}: '

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

@ -75,6 +75,8 @@ import './elements/chromedash-guide-stage-page';
import './elements/chromedash-guide-metadata-page';
import './elements/chromedash-guide-verify-accuracy-page';
import './elements/chromedash-header';
import './elements/chromedash-intent-content';
import './elements/chromedash-intent-preview-page';
import './elements/chromedash-legend';
import './elements/chromedash-login-required-page';
import './elements/chromedash-myfeatures-page';

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

@ -442,6 +442,13 @@ export class ChromedashApp extends LitElement {
this.pageComponent.appTitle = this.appTitle;
}
);
page('/feature/:featureId(\\d+)/gate/:gateId(\\d+/intent)', ctx => {
if (!this.setupNewPage(ctx, 'chromedash-intent-preview-page')) return;
this.pageComponent.featureId = parseInt(ctx.params.featureId);
this.pageComponent.gateId = parseInt(ctx.params.gateId);
this.pageComponent.appTitle = this.appTitle;
this.hideSidebar();
});
page('/ot_creation_request/:featureId(\\d+)/:stageId(\\d+)', ctx => {
if (!this.setupNewPage(ctx, 'chromedash-ot-creation-page')) return;
this.pageComponent.featureId = parseInt(ctx.params.featureId);

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

@ -550,9 +550,8 @@ class ChromedashFeatureDetail extends LitElement {
const label = action.name;
const url = action.url
.replace('{feature_id}', this.feature.id)
.replace('{intent_stage}', stage.outgoing_stage)
// No gate_id for this URL.
.replace('/{gate_id}', '');
// No gate_id for this URL. Use 0 by default.
.replace('{gate_id}', '0');
const gatesForStage = this.gates.filter(g => g.stage_id == feStage.id);
const checkCompletion = () => {

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

@ -485,8 +485,7 @@ export class ChromedashGateColumn extends LitElement {
const label = action.name;
const url = action.url
.replace('{feature_id}', this.feature.id)
.replace('{intent_stage}', processStage.outgoing_stage)
.replace('{gate_id}', this.gate.id);
.replace('{gate_id}', this.gate.id || 0);
const checkCompletion = () => {
if (

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

@ -0,0 +1,197 @@
import {LitElement, css, html, nothing} from 'lit';
import {unsafeHTML} from 'lit/directives/unsafe-html.js';
import {SHARED_STYLES} from '../css/shared-css.js';
import {showToastMessage} from './utils.js';
export class ChromedashIntentContent extends LitElement {
static get properties() {
return {
appTitle: {type: String},
subject: {type: String},
intentBody: {type: String},
};
}
constructor() {
super();
this.appTitle = '';
this.subject = '';
this.intentBody = '';
}
static get styles() {
return [
...SHARED_STYLES,
css`
#copy-email-body {
cursor: pointer;
color: var(--link-color);
}
.email-content-border {
border: 1px solid #ddd;
box-shadow: rgba(0, 0, 0, 0.067) 1px 1px 4px;
margin: 8px 0 24px 0;
}
.email-content-div {
background: white;
padding: 12px;
}
p {
color: #444;
}
h3 {
margin-bottom: 10px;
&:before {
counter-increment: h3;
content: counter(h3) '.';
margin-right: 5px;
}
}
#content section > div {
background: white;
border: 1px solid #ddd;
box-shadow: rgba(0, 0, 0, 0.067) 1px 1px 4px;
padding: 12px;
margin: 8px 0 16px 0;
}
#content section > p {
color: #444;
}
.email .help {
font-style: italic;
color: #aaa;
}
.email h4 {
font-weight: 600;
}
.alertbox {
margin: 2em;
padding: 1em;
background: var(--warning-background);
color: var(--warning-color);
}
.subject {
font-size: 16px;
}
table {
tr[hidden] {
th,
td {
padding: 0;
}
}
th {
padding: 12px 10px 5px 0;
vertical-align: top;
}
td {
padding: 6px 10px;
vertical-align: top;
}
td:first-of-type {
width: 60%;
}
.helptext {
display: block;
font-size: small;
max-width: 40em;
margin-top: 2px;
}
input[type='text'],
input[type='url'],
input[type='email'],
textarea {
width: 100%;
font: var(--form-element-font);
}
select {
max-width: 350px;
}
:required {
border: 1px solid $chromium-color-dark;
}
.interacted:valid {
border: 1px solid green;
}
.interacted:invalid {
border: 1px solid $invalid-color;
}
input:not([type='submit']):not([type='search']) {
outline: 1px dotted var(--error-border-color);
background-color: #ffedf5;
}
}
`,
];
}
renderEmailBody() {
if (this.intentBody) {
// Needed for rendering HTML format returned from the API.
return unsafeHTML(this.intentBody);
}
return nothing;
}
copyIntentBodyHandler() {
const copyEmailBodyEl = this.shadowRoot.querySelector('#copy-email-body');
const emailBodyEl = this.shadowRoot.querySelector('.email');
if (copyEmailBodyEl && emailBodyEl) {
window.getSelection().removeAllRanges();
const range = document.createRange();
range.selectNode(emailBodyEl);
window.getSelection().addRange(range);
document.execCommand('copy');
showToastMessage('Email body copied');
}
}
render() {
return html`
<p>Email to</p>
<div class="email-content-border">
<div class="subject email-content-div">blink-dev@chromium.org</div>
</div>
<p>Subject</p>
<div class="email-content-border">
<div class="subject email-content-div" id="email-subject-content">
${this.subject}
</div>
</div>
<p>
Body
<span
class="tooltip copy-text"
style="float:right"
title="Copy text to clipboard"
>
<iron-icon
icon="chromestatus:content_copy"
id="copy-email-body"
@click="${() => this.copyIntentBodyHandler()}"
></iron-icon>
</span>
</p>
<div class="email-content-border">
<div class="email email-content-div" id="email-body-content">
${this.renderEmailBody()}
</div>
</div>
`;
}
}
customElements.define('chromedash-intent-content', ChromedashIntentContent);

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

@ -0,0 +1,235 @@
import {LitElement, css, html, nothing} from 'lit';
import {SHARED_STYLES} from '../css/shared-css.js';
import {showToastMessage} from './utils';
import {openPostIntentDialog} from './chromedash-post-intent-dialog.js';
import {
STAGE_TYPES_DEV_TRIAL,
STAGE_TYPES_SHIPPING,
} from './form-field-enums.js';
import './chromedash-intent-content.js';
class ChromedashIntentPreviewPage extends LitElement {
static get properties() {
return {
appTitle: {type: String},
featureId: {type: Number},
gateId: {type: Number},
feature: {type: Object},
stage: {type: Object},
gate: {type: Object},
loading: {type: Boolean},
subject: {type: String},
intentBody: {type: String},
displayFeatureUnlistedWarning: {type: Boolean},
};
}
constructor() {
super();
this.appTitle = '';
this.featureId = 0;
this.gateId = 0;
this.feature = undefined;
this.stage = undefined;
this.gate = undefined;
this.loading = true;
this.subject = '';
this.intentBody = '';
this.displayFeatureUnlistedWarning = false;
}
static get styles() {
return [
...SHARED_STYLES,
css`
#content {
flex-direction: column;
counter-reset: h3;
height: auto;
}
#content section {
max-width: 800px;
flex: 1 0 auto;
margin-bottom: 15px;
}
h3 {
margin-bottom: 10px;
}
#content h3:before {
counter-increment: h3;
content: counter(h3) '.';
margin-right: 5px;
}
#post-intent-button {
float: right;
}
.inline {
display: inline;
}
`,
];
}
connectedCallback() {
super.connectedCallback();
this.fetchData();
}
fetchData() {
Promise.all([
window.csClient.getFeature(this.featureId),
window.csClient.getGates(this.featureId),
])
.then(([feature, gates]) => {
this.feature = feature;
document.title = `${this.feature.name} - ${this.appTitle}`;
// TODO(DanielRyanSmith): only fetch a single gate based on given ID.
if (this.gateId) {
this.gate = gates.gates.find(gate => gate.id === this.gateId);
}
if (this.gate) {
// Find the stage that matches the given gate.
for (const stage of this.feature.stages) {
if (this.stage) {
break;
}
if (stage.id === this.gate.stage_id) {
this.stage = stage;
}
// Check if gate matches an extension stage.
if (!this.stage) {
this.stage = stage.extensions.find(
e => e.id === this.gate.stage_id
);
}
}
} else if (!this.gateId) {
// This is a "Ready for Developer Testing" intent if no gate is supplied (0).
this.stage = this.feature.stages.find(stage =>
STAGE_TYPES_DEV_TRIAL.has(stage.stage_type)
);
} else {
throw new Error('Invalid gate ID');
}
if (this.feature.unlisted) {
this.displayFeatureUnlistedWarning = true;
}
// Finally, get the contents of the intent based on the feature/stage.
return window.csClient.getIntentBody(
this.featureId,
this.stage.id,
this.gateId
);
})
.then(intentResp => {
this.subject = intentResp.subject;
this.intentBody = intentResp.email_body;
this.loading = false;
})
.catch(() => {
showToastMessage(
'Some errors occurred. Please refresh the page or try again later.'
);
});
}
renderThreeLGTMSection() {
// Show for shipping stages only.
if (!STAGE_TYPES_SHIPPING.has(this.stage?.stage_type)) return nothing;
return html` <section>
<h3>Obtain LGTMs from 3 API Owners</h3>
<span class="help">
You will need three LGTMs from API owners. According to the
<a href="http://www.chromium.org/blink#launch-process"
>Blink Launch process</a
>
after that, you're free to ship your feature.
</span>
</section>`;
}
renderFeatureUnlistedAlert() {
if (!this.displayFeatureUnlistedWarning) return nothing;
return html`<div class="alertbox">
Important: This feature is currently unlisted. Please only share feature
details with people who are collaborating with you on the feature.
</div>`;
}
renderSkeletonSection() {
return html`
<section>
<h3><sl-skeleton effect="sheen"></sl-skeleton></h3>
<p>
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
</p>
</section>
`;
}
renderSkeletons() {
return html`
<div id="feature" style="margin-top: 65px;">
${this.renderSkeletonSection()} ${this.renderSkeletonSection()}
${this.renderSkeletonSection()} ${this.renderSkeletonSection()}
</div>
`;
}
render() {
if (this.loading) {
return this.renderSkeletons();
}
return html`
<div id="content">
<div id="subheader">
<div>
<h2>Next steps for the Blink launch process</h2>
</div>
</div>
${this.renderFeatureUnlistedAlert()}
<section>
<h3>Reach out to a spec mentor</h3>
<p style="margin-left: 1em">
Consider showing your draft intent email to your spec mentor or
sending it to spec-mentors@chromium.org. They can help make sure
that your intent email is ready for review.
</p>
</section>
<section>
<h3 class="inline">Send this text for your "Intent to ..." email</h3>
<input
ref()
id="post-intent-button"
class="button inline"
type="submit"
value="Post directly to blink-dev"
@click="${() =>
openPostIntentDialog(
this.feature.id,
this.stage.id,
this.feature.owner_emails,
this.gate?.id
)}"
/>
<chromedash-intent-content
appTitle="${this.appTitle}"
.feature=${this.feature}
.stage=${this.stage}
subject="${this.subject}"
intentBody="${this.intentBody}"
>
</chromedash-intent-content>
</section>
${this.renderThreeLGTMSection()}
</div>
`;
}
}
customElements.define(
'chromedash-intent-preview-page',
ChromedashIntentPreviewPage
);

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

@ -0,0 +1,26 @@
import {html} from 'lit';
import {assert, fixture} from '@open-wc/testing';
import {ChromedashIntentContent} from './chromedash-intent-content';
describe('chromedash-intent-content', () => {
it('renders with fake data', async () => {
const component = await fixture(
html`<chromedash-intent-content
appTitle="Chrome Status Test"
subject="A fake subject"
intentBody="<div>A basic intent body</div>"
>
</chromedash-intent-content>`
);
assert.exists(component);
assert.instanceOf(component, ChromedashIntentContent);
const subject = component.shadowRoot.querySelector(
'#email-subject-content'
);
const body = component.shadowRoot.querySelector('#email-body-content');
assert.equal(body.innerText, 'A basic intent body');
assert.equal(subject.innerText, 'A fake subject');
});
});

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

@ -0,0 +1,161 @@
import {LitElement, css, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {ref} from 'lit/directives/ref.js';
import {FORM_STYLES} from '../css/forms-css.js';
import {SHARED_STYLES} from '../css/shared-css.js';
import {ALL_FIELDS} from './form-field-specs.js';
import {showToastMessage} from './utils.js';
let dialogEl;
export async function openPostIntentDialog(
featureId: number,
stageId: number,
ownerEmails: Array<string>,
gateId = 0
) {
if (!dialogEl) {
dialogEl = document.createElement('chromedash-post-intent-dialog');
document.body.appendChild(dialogEl);
dialogEl.featureId = featureId;
dialogEl.stageId = stageId;
dialogEl.gateId = gateId;
dialogEl.ownerEmails = ownerEmails;
await dialogEl.updateComplete;
}
dialogEl.show();
}
@customElement('chromedash-post-intent-dialog')
class ChromedashPostIntentDialog extends LitElement {
@property({type: Number})
featureId = 0;
@property({type: Number})
stageId = 0;
@property({type: Number})
gateId = 0;
@property({type: Array<string>})
ownerEmails = [];
static get styles() {
return [
...SHARED_STYLES,
...FORM_STYLES,
css`
#prereqs-list li {
margin-left: 8px;
margin-bottom: 8px;
list-style: circle;
}
#prereqs-header {
margin-bottom: 8px;
}
#update-button {
margin-right: 8px;
}
.float-right {
float: right;
}
sl-input::part(base) {
margin-top: 8px;
}
sl-input[data-user-invalid]::part(base) {
border-color: red;
}
`,
];
}
show() {
this.shadowRoot!.querySelector('sl-dialog')!.show();
}
updateAttributes(el) {
if (!el) return;
const attrs = ALL_FIELDS.intent_cc_emails.attrs || {};
Object.keys(attrs).map(attr => {
el.setAttribute(attr, attrs[attr]);
});
}
renderIntentCCEmailOption() {
const fieldInfo = ALL_FIELDS.intent_cc_emails;
const defaultCCEmails = this.ownerEmails.join(',');
return html`${fieldInfo.help_text}<br />
<sl-input
${ref(this.updateAttributes)}
id="id_${fieldInfo.name}"
size="small"
autocomplete="off"
.value=${defaultCCEmails}
?required=${fieldInfo.required}
>
</sl-input>`;
}
submitIntent() {
// Make sure that the CC emails input is valid.
const ccEmailsInput = this.shadowRoot!.querySelector('sl-input');
if (!ccEmailsInput || ccEmailsInput.hasAttribute('data-user-invalid')) {
return;
}
const submitButton = this.shadowRoot!.querySelector(
'#submit-intent-button'
);
if (submitButton) {
submitButton.setAttribute('disabled', '');
}
window.csClient
.postIntentToBlinkDev(this.featureId, this.stageId, {
gate_id: this.gateId,
intent_cc_emails: ccEmailsInput?.value?.split(','),
})
.then(() => {
showToastMessage(
'Intent submitted! Check for your thread on blink-dev shortly.'
);
setTimeout(() => {
window.location.href = `/feature/${this.featureId}`;
}, 3000);
})
.catch(() => {
showToastMessage(
'Some errors occurred. Please refresh the page or try again later.'
);
if (submitButton) {
submitButton.removeAttribute('disabled');
}
});
}
renderDialog() {
return html`<sl-dialog label="Post intent to blink-dev">
<p>
This intent will be sent directly to
<a
href="https://groups.google.com/a/chromium.org/g/blink-dev"
target="_blank"
rel="noopener noreferrer"
>blink-dev</a
>.
</p>
<br /><br />
${this.renderIntentCCEmailOption()}
<br /><br />
<sl-button
class="float-right"
id="submit-intent-button"
variant="primary"
size="small"
@click=${() => this.submitIntent()}
>Submit intent</sl-button
>
</sl-dialog>`;
}
render() {
return this.renderDialog();
}
}

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

@ -2104,6 +2104,15 @@ export const ALL_FIELDS: Record<string, Field> = {
help_text: html` This is a breaking change: customers or developers must
take action to continue using some existing functionaity.`,
},
intent_cc_emails: {
type: 'input',
attrs: MULTI_EMAIL_FIELD_ATTRS,
required: false,
label: 'Intent email CC list',
help_text: html`Add emails to the CC list of the intent email.<br />
Comma separated list of full email addresses.`,
},
};
// Return a simplified field type to help differentiate the

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

@ -661,6 +661,14 @@ export class ChromeStatusClient {
return this.doGet(`/features/${featureId}/process`);
}
// Intents API
async getIntentBody(featureId, stageId, gateId) {
return this.doGet(`/features/${featureId}/${stageId}/${gateId}/intent`);
}
async postIntentToBlinkDev(featureId, stageId, body) {
return this.doPost(`/features/${featureId}/${stageId}/intent`, body);
}
// Progress API
async getFeatureProgress(featureId) {
return this.doGet(`/features/${featureId}/progress`);

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

@ -55,6 +55,7 @@ export interface AddUserToComponentRequest {
export interface GetIntentBodyRequest {
featureId: number;
stageId: number;
gateId: number;
}
export interface ListExternalReviewsRequest {
@ -73,6 +74,7 @@ export interface ListSpecMentorsRequest {
export interface PostIntentToBlinkDevRequest {
featureId: number;
stageId: number;
gateId: number;
postIntentRequest?: PostIntentRequest;
}
@ -111,6 +113,7 @@ export interface DefaultApiInterface {
* @summary Get the HTML body of an intent draft
* @param {number} featureId Feature ID
* @param {number} stageId Stage ID
* @param {number} gateId Gate ID
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof DefaultApiInterface
@ -201,6 +204,7 @@ export interface DefaultApiInterface {
* @summary Submit an intent to be posted on blink-dev
* @param {number} featureId Feature ID
* @param {number} stageId Stage ID
* @param {number} gateId Gate ID
* @param {PostIntentRequest} [postIntentRequest] Gate ID and additional users to CC email to.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
@ -301,12 +305,19 @@ export class DefaultApi extends runtime.BaseAPI implements DefaultApiInterface {
);
}
if (requestParameters['gateId'] == null) {
throw new runtime.RequiredError(
'gateId',
'Required parameter "gateId" was null or undefined when calling getIntentBody().'
);
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
const response = await this.request({
path: `/features/{feature_id}/{stage_id}/intent`.replace(`{${"feature_id"}}`, encodeURIComponent(String(requestParameters['featureId']))).replace(`{${"stage_id"}}`, encodeURIComponent(String(requestParameters['stageId']))),
path: `/features/{feature_id}/{stage_id}/{gate_id}/intent`.replace(`{${"feature_id"}}`, encodeURIComponent(String(requestParameters['featureId']))).replace(`{${"stage_id"}}`, encodeURIComponent(String(requestParameters['stageId']))).replace(`{${"gate_id"}}`, encodeURIComponent(String(requestParameters['gateId']))),
method: 'GET',
headers: headerParameters,
query: queryParameters,
@ -508,6 +519,13 @@ export class DefaultApi extends runtime.BaseAPI implements DefaultApiInterface {
);
}
if (requestParameters['gateId'] == null) {
throw new runtime.RequiredError(
'gateId',
'Required parameter "gateId" was null or undefined when calling postIntentToBlinkDev().'
);
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
@ -515,7 +533,7 @@ export class DefaultApi extends runtime.BaseAPI implements DefaultApiInterface {
headerParameters['Content-Type'] = 'application/json';
const response = await this.request({
path: `/features/{feature_id}/{stage_id}/intent`.replace(`{${"feature_id"}}`, encodeURIComponent(String(requestParameters['featureId']))).replace(`{${"stage_id"}}`, encodeURIComponent(String(requestParameters['stageId']))),
path: `/features/{feature_id}/{stage_id}/{gate_id}/intent`.replace(`{${"feature_id"}}`, encodeURIComponent(String(requestParameters['featureId']))).replace(`{${"stage_id"}}`, encodeURIComponent(String(requestParameters['stageId']))).replace(`{${"gate_id"}}`, encodeURIComponent(String(requestParameters['gateId']))),
method: 'POST',
headers: headerParameters,
query: queryParameters,

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

@ -34,7 +34,7 @@ def add_user_to_component(component_id, user_id, component_users_request=None):
return 'do some magic!'
def get_intent_body(feature_id, stage_id): # noqa: E501
def get_intent_body(feature_id, stage_id, gate_id): # noqa: E501
"""Get the HTML body of an intent draft
# noqa: E501
@ -43,6 +43,8 @@ def get_intent_body(feature_id, stage_id): # noqa: E501
:type feature_id: int
:param stage_id: Stage ID
:type stage_id: int
:param gate_id: Gate ID
:type gate_id: int
:rtype: Union[GetIntentResponse, Tuple[GetIntentResponse, int], Tuple[GetIntentResponse, int, Dict[str, str]]
"""
@ -115,7 +117,7 @@ def list_spec_mentors(after=None): # noqa: E501
return 'do some magic!'
def post_intent_to_blink_dev(feature_id, stage_id, post_intent_request=None): # noqa: E501
def post_intent_to_blink_dev(feature_id, stage_id, gate_id, post_intent_request=None): # noqa: E501
"""Submit an intent to be posted on blink-dev
# noqa: E501
@ -124,6 +126,8 @@ def post_intent_to_blink_dev(feature_id, stage_id, post_intent_request=None): #
:type feature_id: int
:param stage_id: Stage ID
:type stage_id: int
:param gate_id: Gate ID
:type gate_id: int
:param post_intent_request: Gate ID and additional users to CC email to.
:type post_intent_request: dict | bytes

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

@ -160,7 +160,7 @@ paths:
format.
summary: List how long each feature took to launch
x-openapi-router-controller: chromestatus_openapi.controllers.default_controller
/features/{feature_id}/{stage_id}/intent:
/features/{feature_id}/{stage_id}/{gate_id}/intent:
get:
operationId: get_intent_body
parameters:
@ -180,6 +180,14 @@ paths:
schema:
type: integer
style: simple
- description: Gate ID
explode: false
in: path
name: gate_id
required: true
schema:
type: integer
style: simple
responses:
"200":
content:
@ -212,6 +220,14 @@ paths:
schema:
type: integer
style: simple
- description: Gate ID
explode: false
in: path
name: gate_id
required: true
schema:
type: integer
style: simple
requestBody:
content:
application/json:

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

@ -45,7 +45,7 @@ class TestDefaultController(BaseTestCase):
'Accept': 'application/json:',
}
response = self.client.open(
'/api/v0/features/{feature_id}/{stage_id}/intent'.format(feature_id=56, stage_id=56),
'/api/v0/features/{feature_id}/{stage_id}/{gate_id}/intent'.format(feature_id=56, stage_id=56, gate_id=56),
method='GET',
headers=headers)
self.assert200(response,
@ -143,7 +143,7 @@ class TestDefaultController(BaseTestCase):
'Content-Type': 'application/json',
}
response = self.client.open(
'/api/v0/features/{feature_id}/{stage_id}/intent'.format(feature_id=56, stage_id=56),
'/api/v0/features/{feature_id}/{stage_id}/{gate_id}/intent'.format(feature_id=56, stage_id=56, gate_id=56),
method='POST',
headers=headers,
data=json.dumps(post_intent_request),

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

@ -902,6 +902,7 @@ class IntentToBlinkDevHandler(basehandlers.FlaskHandler):
'should_render_intents': stage_info['should_render_intents'],
'intent_stage': json_data['intent_stage'],
'default_url': json_data['default_url'],
'APP_TITLE': settings.APP_TITLE,
}
body = render_template(self.EMAIL_TEMPLATE_PATH, **template_data)

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

@ -72,9 +72,7 @@ def process_to_dict(process):
# The param "intent" adds clauses the template to include details
# needed for an intent email. The param "launch" causes those
# details to be omitted and a link to create a launch bug shown instead.
INTENT_EMAIL_URL = ('/admin/features/launch/{feature_id}'
'/{intent_stage}/{gate_id}'
'?intent=1')
INTENT_EMAIL_URL = ('/feature/{feature_id}/gate/{gate_id}/intent')
LAUNCH_BUG_TEMPLATE_URL = '/admin/features/launch/{feature_id}?launch=1'
# TODO(jrobbins): Creation of the launch bug has been a TODO for 5 years.

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

@ -149,7 +149,7 @@ False
<br><br><h4>Link to entry on the </h4>
<br><br><h4>Link to entry on the Local testing</h4>
<a href="https://chromestatus.com/feature/1?gate=$100">https://chromestatus.com/feature/1?gate=$100</a>

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

@ -30,6 +30,7 @@ from api import (
feature_latency_api,
feature_links_api,
features_api,
intents_api,
login_api,
logout_api,
metricsdata,
@ -134,6 +135,10 @@ api_routes: list[Route] = [
Route(
f'{API_BASE}/features/<int:feature_id>/stages/<int:stage_id>/addXfnGates',
reviews_api.XfnGatesAPI),
Route(f'{API_BASE}/features/<int:feature_id>/<int:stage_id>/intent',
intents_api.IntentsAPI),
Route(f'{API_BASE}/features/<int:feature_id>/<int:stage_id>/<int:gate_id>/intent',
intents_api.IntentsAPI),
Route(f'{API_BASE}/blinkcomponents',
blink_components_api.BlinkComponentsAPI),
@ -202,6 +207,8 @@ spa_page_routes = [
defaults={'require_edit_feature': True}),
Route('/guide/stage/<int:feature_id>/metadata',
defaults={'require_edit_feature': True}),
Route('/feature/<int:feature_id>/gate/<int:gate_id>/intent',
defaults={'require_edit_feature': True}),
Route('/ot_creation_request/<int:feature_id>/<int:stage_id>',
defaults={'require_signin': True}),
Route('/ot_extension_request/<int:feature_id>/<int:stage_id>',
@ -300,6 +307,7 @@ internals_routes: list[Route] = [
Route('/tasks/email-ot-extended', notifier.OTExtendedHandler),
Route('/tasks/email-ot-extension-approved',
notifier.OTExtensionApprovedHandler),
Route('/tasks/email-intent-to-blink-dev', notifier.IntentToBlinkDevHandler),
# OT process reminder emails
Route('/tasks/email-ot-first-branch', notifier.OTFirstBranchReminderHandler),

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

@ -146,7 +146,7 @@ paths:
$ref: '#/components/schemas/SpecMentor'
'400':
description: The ?after query parameter isn't a valid date in ISO YYYY-MM-DD format.
/features/{feature_id}/{stage_id}/intent:
/features/{feature_id}/{stage_id}/{gate_id}/intent:
parameters:
- name: feature_id
in: path
@ -160,6 +160,12 @@ paths:
required: true
schema:
type: integer
- name: gate_id
in: path
description: Gate ID
required: true
schema:
type: integer
get:
summary: Get the HTML body of an intent draft
operationId: getIntentBody