blob: d2c0c97865c8a29d827d2c202ae77d91a73c190f [file] [log] [blame]
// Copyright (C) 2017 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.
Gerrit.install(function(self) {
// TODO: ideally we want to turn off find owners when code owners enabled
// but since they are two plugins right now, we just keep find owners around
// for a while
// const ENABLED_EXPERIMENTS = window.ENABLED_EXPERIMENTS || [];
// if (ENABLED_EXPERIMENTS.includes('UiFeature__plugin_code_owners')) {
// // Disable if experiment UiFeature__plugin_code_owners is enabled
// // Also hide previous 'Find Owners' button under 'MORE'.
// function removeFindOwnerFromSecondaryActions() {
// var changeActions = self.changeActions();
// changeActions.setActionHidden('revision', 'find-owners~find-owners', true);
// }
// self.on('showchange', removeFindOwnerFromSecondaryActions);
// return;
// }
// If context.popup API exists and popup content is small,
// use the API and set useContextPopup,
// otherwise, use pageDiv and set its visibility.
var useContextPopup = false;
var pageDiv = document.createElement('div');
document.body.appendChild(pageDiv);
function hideFindOwnersPage() {
pageDiv.style.visibility = 'hidden';
}
function popupFindOwnersPage(context, change, revision, onSubmit) {
const PADDING = 5;
const LARGE_PAGE_STYLE = Gerrit.css(
'visibility: hidden;' +
'background: rgba(200, 200, 200, 0.95);' +
'border: 3px solid;' +
'border-color: #c8c8c8;' +
'border-radius: 3px;' +
'position: fixed;' +
'z-index: 100;' +
'overflow: auto;' +
'padding: ' + PADDING + 'px;');
const BUTTON_STYLE = Gerrit.css(
'background-color: #4d90fe;' +
'border: 2px solid;' +
'border-color: #4d90fe;' +
'margin: 2px 10px 2px 10px;' +
'text-align: center;' +
'font-size: 8pt;' +
'font-weight: bold;' +
'color: #fff;' +
'-webkit-border-radius: 2px;' +
'cursor: pointer;');
const createElWithText = (tagName, text) => {
const el = document.createElement(tagName);
if (text) el.textContent = text;
return el;
};
const createBulletEl = () => {
return createElWithText('small', '\u2605'); // a Black Star
};
const createIsExemptedEl = () => {
return createElWithText('b', 'This change is Exempt-From-Owner-Approval.');
};
const createNoOwnerEl = () => {
return createElWithText('b', 'No owner was found for changed files.');
};
const createOnSubmitHeader = () => {
return createElWithText(
'b', 'WARNING: Need owner Code-Review vote before submit.');
};
const createOwnersHeaderEl = () => {
return createElWithText('b', 'Owners in alphabetical order:');
};
const createSelectReviewersEl = (minCRVote) => {
const el = document.createElement('p');
el.appendChild(createElWithText(
'b',
'Check the box before owner names to select reviewers, ' +
'then click the "Apply" button.'));
el.appendChild(createElWithText('br'));
const smallEl = createElWithText(
'small',
'If owner-approval requirement is enabled, ' +
`each file needs at least one Code-Review +${minCRVote} vote from an owner. ` +
'Owners listed after a file are ordered by their importance. ' +
'(Or declare "');
const smallerEl = createElWithText('b', 'Exempt-From-Owner-Approval:');
smallerEl.style.fontSize = '80%';
smallEl.appendChild(smallerEl);
smallEl.appendChild(createElWithText('i', 'reasons...'));
smallEl.appendChild(document.createTextNode('" in the commit message.)'));
el.appendChild(smallEl);
el.appendChild(createElWithText('br'));
return el;
};
// Changed files are put into groups.
// Each group has a unique list of owners and
// belongs to one of the 5 types based on owner status.
// Groups of a type are displayed in one HTML section.
// Group type names are mapped to ordered numbers starting from 0.
const GROUP_TYPE = {
'NEED_REVIEWER': 0, // no owner in Reviewers list yet
'NEED_APPROVAL': 1, // no owner Code-Review +1 yet
'STAR_APPROVED': 2, // has '*', no need of owner vote
'OWNER_APPROVED': 3, // has owner approval
'HAS_NO_OWNER': 4, // no owner at all, only shown with other types
};
const NUM_GROUP_TYPES = 5;
const createGroupTypeHeaderEl = (text) => {
const el = createElWithText('span');
el.appendChild(createElWithText('hr'));
el.appendChild(createElWithText('b', text));
el.appendChild(createElWithText('br'));
return el;
};
const HTML_GROUP_TYPE_HEADER = [
createGroupTypeHeaderEl(
'Files with owners but no owner is in the Reviewers list:'),
createGroupTypeHeaderEl(
'Files with owners but no Code-Review vote from an owner:'),
createGroupTypeHeaderEl(
'Files with owners but can be approved by anyone (*):'),
createGroupTypeHeaderEl('Files with required Code-Review vote from owners:'),
createGroupTypeHeaderEl('Files without any named owner:'),
];
const GROUP_TYPE_DIV_ID = [
'FindOwners:NeedReviewer',
'FindOwners:NeedApproval',
'FindOwners:StarApproved',
'FindOwners:OwnerApproved',
'FindOwners:HasNoOwner',
];
const APPLY_BUTTON_ID = 'FindOwners:Apply';
const CHECKBOX_ID = 'FindOwners:CheckBox';
const HEADER_DIV_ID = 'FindOwners:Header';
const OWNERS_DIV_ID = 'FindOwners:Owners';
// Aliases to values in the context.
const branch = change.branch;
const changeId = change._number;
const changeOwner = change.owner;
const message = revision.commit.message;
const project = change.project;
var minVoteLevel = 1; // could be changed by server returned results.
var reviewerId = {}; // map from a reviewer's email to account id.
var reviewerVote = {}; // map from a reviewer's email to Code-Review vote.
// addList and removeList are used only under applySelections.
var addList = []; // remain emails to add to reviewers
var removeList = []; // remain emails to remove from reviewers
var needRefresh = false; // true if to refresh after checkAddRemoveLists
function getElement(id) {
return document.getElementById(id);
}
function restApiGet(url, callback) {
self.restApi().get('/../..' + url).then(callback);
}
function restApiPost(url, data, callback) {
self.restApi().post('/../..' + url, data).then(callback);
}
function restApiDelete(url, callback, errMessage) {
self.restApi().delete('/../..' + url).then(callback).catch((e) => {
alert(errMessage);
});
}
function getReviewers(change, callBack) {
restApiGet('/changes/' + change + '/reviewers', callBack);
}
function setupReviewersMap(reviewerList) {
reviewerId = {};
reviewerVote = {};
reviewerList.forEach(function(reviewer) {
if ('email' in reviewer && '_account_id' in reviewer) {
reviewerId[reviewer.email] = reviewer._account_id;
reviewerVote[reviewer.email] = 0;
if ('approvals' in reviewer && 'Code-Review' in reviewer.approvals) {
reviewerVote[reviewer.email] =
parseInt(reviewer.approvals['Code-Review']);
// The 'Code-Review' values could be "-2", "-1", " 0", "+1", "+2"
}
}
});
// Give CL author a default minVoteLevel vote.
if (changeOwner != null && 'email' in changeOwner &&
'_account_id' in changeOwner &&
(!(changeOwner.email in reviewerId) ||
reviewerVote[changeOwner.email] == 0)) {
reviewerId[changeOwner.email] = changeOwner._account_id;
reviewerVote[changeOwner.email] = minVoteLevel;
}
}
function checkAddRemoveLists() {
// Gerrit.post and delete are asynchronous.
// Do one at a time, with checkAddRemoveLists as callBack.
for (var i = 0; i < addList.length; i++) {
var email = addList[i];
if (!(email in reviewerId)) {
addList = addList.slice(i + 1, addList.length);
// A post request can fail if given reviewer email is invalid.
// Gerrit core UI shows the error dialog and does not provide
// a way for plugins to handle the error yet.
needRefresh = true;
restApiPost(
'/changes/' + changeId + '/reviewers', {'reviewer': email},
checkAddRemoveLists);
return;
}
}
for (var i = 0; i < removeList.length; i++) {
var email = removeList[i];
if (email in reviewerId) {
removeList = removeList.slice(i + 1, removeList.length);
needRefresh = true;
restApiDelete(
'/changes/' + changeId + '/reviewers/' + reviewerId[email],
checkAddRemoveLists, 'Cannot delete reviewer: ' + email);
return;
}
}
hideFindOwnersPage();
if (needRefresh) {
needRefresh = false;
(!!Gerrit.refresh) ? Gerrit.refresh() : location.reload();
}
callServer(showFindOwnersResults);
}
function applyGetReviewers(reviewerList) {
setupReviewersMap(reviewerList);
checkAddRemoveLists(); // update and pop up window at the end
}
function hasStar(owners) {
return owners.some(function(owner) {
return owner == '*';
});
}
function hasNamedOwner(owners) {
return owners.some(function(owner) {
return owner != '*';
});
}
function hasOwnerReviewer(reviewers, owners) {
return owners.some(function(owner) {
return owner in reviewers;
});
}
function hasOwnerApproval(votes, owners) {
var foundApproval = false;
for (var j = 0; j < owners.length; j++) {
if (owners[j] in votes) {
var v = votes[owners[j]];
if (v < 0) {
return false; // cannot have any negative vote
}
foundApproval |= v >= minVoteLevel;
}
}
return foundApproval;
}
function isExemptedFromOwnerApproval() {
return message.match(/(Exempted|Exempt)-From-Owner-Approval:/);
}
function strElement(s) {
var e = document.createElement('span');
e.innerHTML = s;
return e;
}
function newButton(name, action) {
var b = document.createElement('button');
b.appendChild(document.createTextNode(name));
b.className = BUTTON_STYLE;
b.onclick = action;
b.style.display = 'inline';
b.style.float = 'right';
return b;
}
function showJsonLines(args, key, obj) {
showBoldKeyValueLines(args, key, JSON.stringify(obj, null, 2));
}
function showBoldKeyValueLines(args, key, value) {
args.push(
createElWithText('hr'), strElement('<b>' + key + '</b>:'),
createElWithText('br'));
value.split('\n').forEach(function(line) {
args.push(strElement(line), createElWithText('br'));
});
}
function showDebugMessages(result, args) {
function addKeyValue(key, value) {
args.push(strElement('<b>' + key + '</b>: ' + value + '<br>'));
}
args.push(createElWithText('hr'));
addKeyValue('changeId', changeId);
addKeyValue('project', project);
addKeyValue('branch', branch);
addKeyValue('changeOwner.email', changeOwner.email);
addKeyValue('Gerrit.url', Gerrit.url());
addKeyValue('self.url', self.url());
showJsonLines(args, 'changeOwner', change.owner);
showBoldKeyValueLines(args, 'commit.message', message);
showJsonLines(args, 'Client reviewers Ids', reviewerId);
showJsonLines(args, 'Client reviewers Votes', reviewerVote);
Object.keys(result).forEach(function(k) {
showJsonLines(args, 'Server.' + k, result[k]);
});
}
function showFilesAndOwners(result, args) {
var sortedOwners = result.owners.map(function(ownerInfo) {
return ownerInfo.email;
});
var groups = {}; // a map from group_name to
// {'type': 0..(NUM_GROUP_TYPES-1),
// 'size': num_of_files_in_this_group,
// 'owners': space_separated_owner_emails}
var header = emptyDiv(HEADER_DIV_ID);
var groupTypeDiv = Array(NUM_GROUP_TYPES);
for (var i = 0; i < NUM_GROUP_TYPES; i++) {
groupTypeDiv[i] = emptyDiv(GROUP_TYPE_DIV_ID[i]);
}
var cancelButton = newButton('Cancel', hideFindOwnersPage);
header.appendChild(cancelButton);
addApplyButton();
var ownersDiv = emptyDiv(OWNERS_DIV_ID);
var numCheckBoxes = 0;
var owner2boxes = {}; // owner name ==> array of checkbox id
var owner2email = {}; // owner name ==> email address
minVoteLevel =
('minOwnerVoteLevel' in result ? result.minOwnerVoteLevel : 1);
function addApplyButton() {
var apply = newButton('Apply', doApplyButton);
apply.id = APPLY_BUTTON_ID;
apply.style.display = 'none';
header.appendChild(apply);
}
function emptyDiv(id) {
var e = document.createElement('div');
e.id = id;
e.style.display = 'none';
args.push(e);
return e;
}
function colorSpan(str, color) {
const el = createElWithText('span', str)
el.style.color = color;
return el;
}
function doApplyButton() {
addList = [];
removeList = [];
// add each owner's email address to addList or removeList
Object.keys(owner2boxes).forEach(function(owner) {
(getElement(owner2boxes[owner][0]).checked ? addList : removeList)
.push(owner2email[owner]);
});
getReviewers(changeId, applyGetReviewers);
}
function clickBox(event) {
var name = event.target.value;
var checked = event.target.checked;
var others = owner2boxes[name];
others.forEach(function(id) {
getElement(id).checked = checked;
});
getElement(APPLY_BUTTON_ID).style.display = 'inline';
}
function addGroupsToDiv(div, keys, titleEl) {
if (keys.length <= 0) {
div.style.display = 'none';
return;
}
div.innerHTML = '';
div.style.display = 'inline';
div.appendChild(titleEl);
function addOwner(itemDiv, ownerEmail) {
if (ownerEmail == '*') {
return; // no need to list/select '*'
}
numCheckBoxes++;
var name = ownerEmail.replace(/@[^ ]*/g, '');
owner2email[name] = ownerEmail;
var id = CHECKBOX_ID + ':' + numCheckBoxes;
if (!(name in owner2boxes)) {
owner2boxes[name] = [];
}
owner2boxes[name].push(id);
var box = document.createElement('input');
box.type = 'checkbox';
box.checked = (ownerEmail in reviewerId);
box.id = id;
box.value = name;
box.onclick = clickBox;
itemDiv.appendChild(createElWithText('span', '\u00a0\u00a0'));
var nobr = document.createElement('nobr');
nobr.appendChild(box);
nobr.appendChild(strElement(name));
itemDiv.appendChild(nobr);
}
keys.forEach(function(key) {
var owners = groups[key].owners; // string of owner emails
var numFiles = groups[key].size;
var itemEl = createElWithText('span');
itemEl.appendChild(createBulletEl());
itemEl.appendChild(document.createTextNode('\u00a0'));
itemEl.appendChild(createElWithText('b', key));
itemEl.appendChild(document.createTextNode(
((numFiles > 1) ? (' (' + numFiles + ' files)') : '')));
var setOfOwners = new Set(owners.split(' '));
function add2list(list, email) {
if (setOfOwners.has(email)) {
list.push(email);
}
return list;
}
var reducedList = sortedOwners.reduce(add2list, []);
if (hasNamedOwner(reducedList)) {
itemEl.appendChild(document.createTextNode(':'));
}
let itemDiv = document.createElement('div');
itemDiv.style.paddingTop = '0.5em';
itemDiv.appendChild(itemEl);
itemDiv.appendChild(createElWithText('br'));
reducedList.forEach(addOwner.bind(this, itemDiv));
div.appendChild(itemDiv);
});
div.lastElementChild.style.paddingBottom = '0.5em';
}
function addOwnersDiv(div, titleEl) {
div.textContent = '';
div.style.display = 'inline';
div.appendChild(titleEl);
div.appendChild(createElWithText('br'));
function compareOwnerInfo(o1, o2) {
return o1.email.localeCompare(o2.email);
}
result.owners.sort(compareOwnerInfo).forEach(function(ownerInfo) {
var email = ownerInfo.email;
var emailEl = createElWithText('span', email);
if (email != '*') { // do not list special email *
var vote = reviewerVote[email];
if ((email in reviewerVote) && vote != 0) {
if (vote > 0) {
emailEl.appendChild(
colorSpan('\u00a0(+' + vote + ')', 'green'));
} else {
emailEl.appendChild(colorSpan('\u00a0(' + vote + ')', 'red'));
}
}
const spanEl = createElWithText('span');
spanEl.appendChild(document.createTextNode('\u00a0\u00a0'));
spanEl.appendChild(emailEl);
spanEl.appendChild(createElWithText('br'));
div.appendChild(spanEl);
}
});
}
function updateDivContent() {
var listOfGroup = Array(NUM_GROUP_TYPES);
for (var i = 0; i < NUM_GROUP_TYPES; i++) {
listOfGroup[i] = [];
}
Object.keys(groups).sort().forEach(function(key) {
listOfGroup[groups[key].type].push(key);
});
// Add message to header div and make visible.
let headerMessageDiv = document.createElement('div');
if (isExemptedFromOwnerApproval()) {
headerMessageDiv.appendChild(createIsExemptedEl());
} else {
if (onSubmit) {
headerMessageDiv.appendChild(createOnSubmitHeader());
}
headerMessageDiv.appendChild(createSelectReviewersEl(minVoteLevel));
}
header.appendChild(headerMessageDiv);
header.style.display = 'inline';
numCheckBoxes = 0;
owner2boxes = {};
for (var i = 0; i < NUM_GROUP_TYPES; i++) {
addGroupsToDiv(
groupTypeDiv[i], listOfGroup[i], HTML_GROUP_TYPE_HEADER[i]);
}
addOwnersDiv(ownersDiv, createOwnersHeaderEl());
}
function createGroups() {
var owners2group = {}; // owner list to group name
var firstNoOwnerFile = null;
var keysOfFile2Owners = Object.keys(result.file2owners);
keysOfFile2Owners.sort().forEach(function(name) {
var splitOwners = result.file2owners[name];
var owners = splitOwners.join(' ');
if (owners in owners2group) {
groups[owners2group[owners]].size += 1;
} else {
owners2group[owners] = name;
var type;
if (!hasNamedOwner(splitOwners)) {
firstNoOwnerFile = name;
type = GROUP_TYPE.HAS_NO_OWNER;
} else if (hasOwnerApproval(reviewerVote, splitOwners)) {
type = GROUP_TYPE.OWNER_APPROVED;
} else if (hasStar(splitOwners)) {
type = GROUP_TYPE.STAR_APPROVED;
} else if (!hasOwnerReviewer(reviewerId, splitOwners)) {
type = GROUP_TYPE.NEED_REVIEWER;
} else {
type = GROUP_TYPE.NEED_APPROVAL;
}
groups[name] = {'type': type, 'size': 1, 'owners': owners};
}
});
var numNoOwnerFiles = result.files.length - keysOfFile2Owners.length;
if (keysOfFile2Owners.length > 0 && numNoOwnerFiles > 0) {
if (!!firstNoOwnerFile) {
// count other files as HAS_NO_OWNER
groups[firstNoOwnerFile].size += numNoOwnerFiles;
} else {
// use one of the no-owner-file as group name
for (var i = 0; i < result.files.length; i++) {
var name = result.files[i];
if (!(name in result.file2owners) &&
!(('./' + name) in result.file2owners)) {
groups[name] = {
'type': GROUP_TYPE.HAS_NO_OWNER,
'size': numNoOwnerFiles,
'owners': '*',
};
break;
}
}
}
}
}
createGroups();
updateDivContent();
}
function showFindOwnersResults(result) {
function prepareElements() {
var elems = [];
var textEl =
Object.keys(result.file2owners).length <= 0 ? createNoOwnerEl() : null;
useContextPopup = !!context && !!textEl && !!context.popup;
if (!!textEl) {
if (useContextPopup) {
elems.push(createElWithText('hr'), textEl, createElWithText('hr'));
var onClick = function() {
context.hide();
};
elems.push(
context.button('OK', {onclick: onClick}), createElWithText('hr'));
} else {
elems.push(textEl, newButton('OK', hideFindOwnersPage));
}
} else {
showFilesAndOwners(result, elems);
if (result.addDebugMsg) {
showDebugMessages(result, elems);
}
}
return elems;
}
function popupWindow(reviewerList) {
setupReviewersMap(reviewerList);
var elems = prepareElements();
if (useContextPopup) {
context.popup(context.div.apply(this, elems));
} else {
while (pageDiv.firstChild) {
pageDiv.removeChild(pageDiv.firstChild);
}
elems.forEach(function(e) {
pageDiv.appendChild(e);
});
pageDiv.className = LARGE_PAGE_STYLE;
// Calculate required height, limited to 85% of window height,
// and required width, limited to 75% of window width.
var maxHeight = Math.round(window.innerHeight * 0.85);
var maxWidth = Math.round(window.innerWidth * 0.75);
pageDiv.style.top = '5%';
pageDiv.style.height = 'auto';
pageDiv.style.left = '10%';
pageDiv.style.width = 'auto';
var rect = pageDiv.getBoundingClientRect();
if (rect.width > maxWidth) {
pageDiv.style.width = maxWidth + 'px';
rect = pageDiv.getBoundingClientRect();
}
pageDiv.style.left =
Math.round((window.innerWidth - rect.width) / 2) + 'px';
if (rect.height > maxHeight) {
pageDiv.style.height = maxHeight + 'px';
rect = pageDiv.getBoundingClientRect();
}
pageDiv.style.top =
Math.round((window.innerHeight - rect.height) / 2) + 'px';
pageDiv.style.visibility = 'visible';
}
}
getReviewers(changeId, popupWindow);
}
function callServer(callBack) {
// Use the plugin REST API; pass only changeId;
// let server get current patch set, project and branch info.
restApiGet('/changes/' + changeId + '/owners', callBack);
}
callServer(showFindOwnersResults);
}
function onSubmit(change, revision) {
const OWNER_REVIEW_LABEL = 'Owner-Review-Vote';
if (change.labels.hasOwnProperty(OWNER_REVIEW_LABEL)) {
// Pop up Find Owners page; do not submit.
popupFindOwnersPage(null, change, revision, true);
return false;
}
return true; // Okay to submit.
}
var actionKey = null;
function onShowChangePolyGerrit(change, revision) {
var changeActions = self.changeActions();
// Hide previous 'Find Owners' button under 'MORE'.
changeActions.setActionHidden('revision', 'find-owners~find-owners', true);
if (!!actionKey) {
changeActions.removeTapListener(actionKey);
changeActions.remove(actionKey);
}
actionKey = changeActions.add('revision', '[FIND OWNERS]');
changeActions.setIcon(actionKey, 'robot');
changeActions.setTitle(actionKey, 'Find owners of changed files');
changeActions.addTapListener(actionKey, (e) => {
if (e) e.stopPropagation();
popupFindOwnersPage(null, change, revision, false);
});
}
function onClick(event) {
if (pageDiv.style.visibility != 'hidden' && !useContextPopup) {
var x = event.clientX;
var y = event.clientY;
var rect = pageDiv.getBoundingClientRect();
if (x < rect.left || x >= rect.left + rect.width || y < rect.top ||
y >= rect.top + rect.height) {
hideFindOwnersPage();
}
}
}
if (window.Polymer) {
self.on('showchange', onShowChangePolyGerrit);
} else {
console.log('WARNING, no [FIND OWNERS] button');
}
// When the "Submit" button is clicked, call onSubmit.
self.on('submitchange', onSubmit);
// Clicks outside the pop up window should close the window.
document.body.addEventListener('click', onClick);
// Leaving page should close the window.
window.addEventListener('popstate', hideFindOwnersPage);
});