blob: 7d4e878b9a5d8432bf7f8cd1d12676e8279c956c [file] [log] [blame]
/**
* @license
* Copyright (C) 2016 The Android Open Source Project
*
* 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 '../../../scripts/bundled-polymer.js';
import '../../../styles/shared-styles.js';
import '../../../styles/gr-change-metadata-shared-styles.js';
import '../../../styles/gr-change-view-integration-shared-styles.js';
import '../../../styles/gr-voting-styles.js';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
import '../../plugins/gr-external-style/gr-external-style.js';
import '../../shared/gr-account-chip/gr-account-chip.js';
import '../../shared/gr-account-link/gr-account-link.js';
import '../../shared/gr-date-formatter/gr-date-formatter.js';
import '../../shared/gr-editable-label/gr-editable-label.js';
import '../../shared/gr-icons/gr-icons.js';
import '../../shared/gr-limited-text/gr-limited-text.js';
import '../../shared/gr-linked-chip/gr-linked-chip.js';
import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
import '../gr-change-requirements/gr-change-requirements.js';
import '../gr-commit-info/gr-commit-info.js';
import '../gr-reviewer-list/gr-reviewer-list.js';
import '../../shared/gr-account-list/gr-account-list.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-change-metadata_html.js';
import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
const SubmitTypeLabel = {
FAST_FORWARD_ONLY: 'Fast Forward Only',
MERGE_IF_NECESSARY: 'Merge if Necessary',
REBASE_IF_NECESSARY: 'Rebase if Necessary',
MERGE_ALWAYS: 'Always Merge',
REBASE_ALWAYS: 'Rebase Always',
CHERRY_PICK: 'Cherry Pick',
};
const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
/**
* @enum {string}
*/
const CertificateStatus = {
/**
* This certificate status is bad.
*/
BAD: 'BAD',
/**
* This certificate status is OK.
*/
OK: 'OK',
/**
* This certificate status is TRUSTED.
*/
TRUSTED: 'TRUSTED',
};
/**
* @extends Polymer.Element
*/
class GrChangeMetadata extends mixinBehaviors( [
RESTClientBehavior,
], GestureEventListeners(
LegacyElementMixin(
PolymerElement))) {
static get template() { return htmlTemplate; }
static get is() { return 'gr-change-metadata'; }
/**
* Fired when the change topic is changed.
*
* @event topic-changed
*/
static get properties() {
return {
/** @type {?} */
change: Object,
labels: {
type: Object,
notify: true,
},
account: Object,
/** @type {?} */
revision: Object,
commitInfo: Object,
_mutable: {
type: Boolean,
computed: '_computeIsMutable(account)',
},
/** @type {?} */
serverConfig: Object,
parentIsCurrent: Boolean,
_notCurrentMessage: {
type: String,
value: NOT_CURRENT_MESSAGE,
readOnly: true,
},
_topicReadOnly: {
type: Boolean,
computed: '_computeTopicReadOnly(_mutable, change)',
},
_hashtagReadOnly: {
type: Boolean,
computed: '_computeHashtagReadOnly(_mutable, change)',
},
/**
* @type {Gerrit.PushCertificateValidation}
*/
_pushCertificateValidation: {
type: Object,
computed: '_computePushCertificateValidation(serverConfig, change)',
},
_showRequirements: {
type: Boolean,
computed: '_computeShowRequirements(change)',
},
_assignee: Array,
_isWip: {
type: Boolean,
computed: '_computeIsWip(change)',
},
_newHashtag: String,
_settingTopic: {
type: Boolean,
value: false,
},
_currentParents: {
type: Array,
computed: '_computeParents(change, revision)',
},
/** @type {?} */
_CHANGE_ROLE: {
type: Object,
readOnly: true,
value: {
OWNER: 'owner',
UPLOADER: 'uploader',
AUTHOR: 'author',
COMMITTER: 'committer',
},
},
};
}
static get observers() {
return [
'_changeChanged(change)',
'_labelsChanged(change.labels)',
'_assigneeChanged(_assignee.*)',
];
}
_labelsChanged(labels) {
this.labels = Object.assign({}, labels) || null;
}
_changeChanged(change) {
this._assignee = change.assignee ? [change.assignee] : [];
}
_assigneeChanged(assigneeRecord) {
if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
return;
}
const assignee = assigneeRecord.base;
if (assignee.length) {
const acct = assignee[0];
if (this.change.assignee &&
acct._account_id === this.change.assignee._account_id) { return; }
this.set(['change', 'assignee'], acct);
this.$.restAPI.setAssignee(this.change._number, acct._account_id);
} else {
if (!this.change.assignee) { return; }
this.set(['change', 'assignee'], undefined);
this.$.restAPI.deleteAssignee(this.change._number);
}
}
_computeHideStrategy(change) {
return !this.changeIsOpen(change);
}
/**
* @param {Object} commitInfo
* @return {?Array} If array is empty, returns null instead so
* an existential check can be used to hide or show the webLinks
* section.
*/
_computeWebLinks(commitInfo, serverConfig) {
if (!commitInfo) { return null; }
const weblinks = GerritNav.getChangeWeblinks(
this.change ? this.change.repo : '',
commitInfo.commit,
{
weblinks: commitInfo.web_links,
config: serverConfig,
});
return weblinks.length ? weblinks : null;
}
_isAssigneeEnabled(serverConfig) {
return serverConfig && serverConfig.change
&& !!serverConfig.change.enable_assignee;
}
_computeStrategy(change) {
return SubmitTypeLabel[change.submit_type];
}
_computeLabelNames(labels) {
return Object.keys(labels).sort();
}
_handleTopicChanged(e, topic) {
const lastTopic = this.change.topic;
if (!topic.length) { topic = null; }
this._settingTopic = true;
this.$.restAPI.setChangeTopic(this.change._number, topic)
.then(newTopic => {
this._settingTopic = false;
this.set(['change', 'topic'], newTopic);
if (newTopic !== lastTopic) {
this.dispatchEvent(new CustomEvent(
'topic-changed', {bubbles: true, composed: true}));
}
});
}
_showAddTopic(changeRecord, settingTopic) {
const hasTopic = !!changeRecord &&
!!changeRecord.base && !!changeRecord.base.topic;
return !hasTopic && !settingTopic;
}
_showTopicChip(changeRecord, settingTopic) {
const hasTopic = !!changeRecord &&
!!changeRecord.base && !!changeRecord.base.topic;
return hasTopic && !settingTopic;
}
_showCherryPickOf(changeRecord) {
const hasCherryPickOf = !!changeRecord &&
!!changeRecord.base && !!changeRecord.base.cherry_pick_of_change &&
!!changeRecord.base.cherry_pick_of_patch_set;
return hasCherryPickOf;
}
_handleHashtagChanged(e) {
const lastHashtag = this.change.hashtag;
if (!this._newHashtag.length) { return; }
const newHashtag = this._newHashtag;
this._newHashtag = '';
this.$.restAPI.setChangeHashtag(
this.change._number, {add: [newHashtag]}).then(newHashtag => {
this.set(['change', 'hashtags'], newHashtag);
if (newHashtag !== lastHashtag) {
this.dispatchEvent(
new CustomEvent('hashtag-changed', {
bubbles: true, composed: true}));
}
});
}
_computeTopicReadOnly(mutable, change) {
return !mutable ||
!change ||
!change.actions ||
!change.actions.topic ||
!change.actions.topic.enabled;
}
_computeHashtagReadOnly(mutable, change) {
return !mutable ||
!change ||
!change.actions ||
!change.actions.hashtags ||
!change.actions.hashtags.enabled;
}
_computeAssigneeReadOnly(mutable, change) {
return !mutable ||
!change ||
!change.actions ||
!change.actions.assignee ||
!change.actions.assignee.enabled;
}
_computeTopicPlaceholder(_topicReadOnly) {
// Action items in Material Design are uppercase -- placeholder label text
// is sentence case.
return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
}
_computeHashtagPlaceholder(_hashtagReadOnly) {
return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
}
_computeShowRequirements(change) {
if (change.status !== this.ChangeStatus.NEW) {
// TODO(maximeg) change this to display the stored
// requirements, once it is implemented server-side.
return false;
}
const hasRequirements = !!change.requirements &&
Object.keys(change.requirements).length > 0;
const hasLabels = !!change.labels &&
Object.keys(change.labels).length > 0;
return hasRequirements || hasLabels || !!change.work_in_progress;
}
/**
* @return {?Gerrit.PushCertificateValidation} object representing data for
* the push validation.
*/
_computePushCertificateValidation(serverConfig, change) {
if (!change || !serverConfig || !serverConfig.receive ||
!serverConfig.receive.enable_signed_push) {
return null;
}
const rev = change.revisions[change.current_revision];
if (!rev.push_certificate || !rev.push_certificate.key) {
return {
class: 'help',
icon: 'gr-icons:help',
message: 'This patch set was created without a push certificate',
};
}
const key = rev.push_certificate.key;
switch (key.status) {
case CertificateStatus.BAD:
return {
class: 'invalid',
icon: 'gr-icons:close',
message: this._problems('Push certificate is invalid', key),
};
case CertificateStatus.OK:
return {
class: 'notTrusted',
icon: 'gr-icons:info',
message: this._problems(
'Push certificate is valid, but key is not trusted', key),
};
case CertificateStatus.TRUSTED:
return {
class: 'trusted',
icon: 'gr-icons:check',
message: this._problems(
'Push certificate is valid and key is trusted', key),
};
default:
throw new Error(`unknown certificate status: ${key.status}`);
}
}
_problems(msg, key) {
if (!key || !key.problems || key.problems.length === 0) {
return msg;
}
return [msg + ':'].concat(key.problems).join('\n');
}
_computeShowRepoBranchTogether(repo, branch) {
return !!repo && !!branch && repo.length + branch.length < 40;
}
_computeProjectUrl(project) {
return GerritNav.getUrlForProjectChanges(project);
}
_computeBranchUrl(project, branch) {
if (!this.change || !this.change.status) return '';
return GerritNav.getUrlForBranch(branch, project,
this.change.status == this.ChangeStatus.NEW ? 'open' :
this.change.status.toLowerCase());
}
_computeCherryPickOfUrl(change, patchset, project) {
return GerritNav.getUrlForChangeById(change, project, patchset);
}
_computeTopicUrl(topic) {
return GerritNav.getUrlForTopic(topic);
}
_computeHashtagUrl(hashtag) {
return GerritNav.getUrlForHashtag(hashtag);
}
_handleTopicRemoved(e) {
const target = dom(e).rootTarget;
target.disabled = true;
this.$.restAPI.setChangeTopic(this.change._number, null)
.then(() => {
target.disabled = false;
this.set(['change', 'topic'], '');
this.dispatchEvent(
new CustomEvent('topic-changed',
{bubbles: true, composed: true}));
})
.catch(err => {
target.disabled = false;
return;
});
}
_handleHashtagRemoved(e) {
e.preventDefault();
const target = dom(e).rootTarget;
target.disabled = true;
this.$.restAPI.setChangeHashtag(this.change._number,
{remove: [target.text]})
.then(newHashtag => {
target.disabled = false;
this.set(['change', 'hashtags'], newHashtag);
})
.catch(err => {
target.disabled = false;
return;
});
}
_computeIsWip(change) {
return !!change.work_in_progress;
}
_computeShowRoleClass(change, role) {
return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
}
/**
* Get the user with the specified role on the change. Returns null if the
* user with that role is the same as the owner.
*
* @param {!Object} change
* @param {string} role One of the values from _CHANGE_ROLE
* @return {Object|null} either an accound or null.
*/
_getNonOwnerRole(change, role) {
if (!change || !change.current_revision ||
!change.revisions[change.current_revision]) {
return null;
}
const rev = change.revisions[change.current_revision];
if (!rev) { return null; }
if (role === this._CHANGE_ROLE.UPLOADER &&
rev.uploader &&
change.owner._account_id !== rev.uploader._account_id) {
return rev.uploader;
}
if (role === this._CHANGE_ROLE.AUTHOR &&
rev.commit && rev.commit.author &&
change.owner.email !== rev.commit.author.email) {
return rev.commit.author;
}
if (role === this._CHANGE_ROLE.COMMITTER &&
rev.commit && rev.commit.committer &&
change.owner.email !== rev.commit.committer.email) {
return rev.commit.committer;
}
return null;
}
_computeParents(change, revision) {
if (!revision || !revision.commit) {
if (!change || !change.current_revision) { return []; }
revision = change.revisions[change.current_revision];
if (!revision || !revision.commit) { return []; }
}
return revision.commit.parents;
}
_computeParentsLabel(parents) {
return parents && parents.length > 1 ? 'Parents' : 'Parent';
}
_computeParentListClass(parents, parentIsCurrent) {
// Undefined check for polymer 2
if (parents === undefined || parentIsCurrent === undefined) {
return '';
}
return [
'parentList',
parents && parents.length > 1 ? 'merge' : 'nonMerge',
parentIsCurrent ? 'current' : 'notCurrent',
].join(' ');
}
_computeIsMutable(account) {
return !!Object.keys(account).length;
}
editTopic() {
if (this._topicReadOnly || this.change.topic) { return; }
// Cannot use `this.$.ID` syntax because the element exists inside of a
// dom-if.
this.shadowRoot.querySelector('.topicEditableLabel').open();
}
_getReviewerSuggestionsProvider(change) {
const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
change._number, SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
provider.init();
return provider;
}
}
customElements.define(GrChangeMetadata.is, GrChangeMetadata);