blob: 7ab6b804513f4159c70ebe076fd65c6b36f8e53e [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) {
// 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 HTML_BULLET = '<small>&#x2605;</small>'; // a Black Star
const HTML_HAS_APPROVAL_HEADER =
'<hr><b>Files with +1 or +2 Code-Review vote from owners:</b><br>';
const HTML_IS_EXEMPTED =
'<b>This change is Exempt-From-Owner-Approval.</b>';
const HTML_NEED_REVIEWER_HEADER =
'<hr><b>Files without owner reviewer:</b><br>';
const HTML_NEED_APPROVAL_HEADER =
'<hr><b>Files without Code-Review vote from an owner:</b><br>';
const HTML_NO_OWNER =
'<b>No owner was found for changed files.</b>';
const HTML_ONSUBMIT_HEADER =
'<b>WARNING: Need owner approval vote before submit.</b><hr>';
const HTML_OWNERS_HEADER = '<hr><b>Owners in alphabetical order:</b><br>';
const HTML_SELECT_REVIEWERS =
'<b>Check the box before owner names to select reviewers, ' +
'then click the "Apply" button.' +
'</b><br><small>Each file needs at least one owner code review vote. ' +
'Owners listed after a file are ordered by their importance. ' +
'(Or declare "<b><span style="font-size:80%;">' +
'Exempt-From-Owner-Approval:</span></b> ' +
'<i>reasons...</i>" in the Commit Message.)</small><br>';
const APPLY_BUTTON_ID = 'FindOwners:Apply';
const CHECKBOX_ID = 'FindOwners:CheckBox';
const HEADER_DIV_ID = 'FindOwners:Header';
const OWNERS_DIV_ID = 'FindOwners:Owners';
const HAS_APPROVAL_DIV_ID = 'FindOwners:HasApproval';
const NEED_APPROVAL_DIV_ID = 'FindOwners:NeedApproval';
const NEED_REVIEWER_DIV_ID = 'FindOwners:NeedReviewer';
// 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 httpGet(url, callback) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onreadystatechange = function() {
if (xhr.readyState == XMLHttpRequest.DONE) {
// skip special characters ")]}'\n"
callback(!!xhr.responseText ?
JSON.parse(xhr.responseText.substring(5)) : {});
}
};
xhr.send();
}
function httpError(msg, callback) {
console.log('UNIMPLEMENTED: ' + msg);
callback();
}
function gerritGet(url, callback) {
(!!Gerrit.get) ? Gerrit.get(url, callback) :
((!!self.get) ? self.get('/../..' + url, callback) :
httpGet(url, callback));
}
function gerritPost(url, data, callback) {
(!!Gerrit.post) ? Gerrit.post(url, data, callback) :
((!!self.post) ? self.post('/../..' + url, data, callback) :
httpError('POST ' + url, callback));
}
function gerritDelete(url, callback) {
(!!Gerrit.delete) ? Gerrit.delete(url, callback) :
((!!self.delete) ? self.delete('/../..' + url, callback) :
httpError('DELETE ' + url, callback));
}
function getReviewers(change, callBack) {
gerritGet('/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;
gerritPost('/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;
gerritDelete('/changes/' + changeId +
'/reviewers/' + reviewerId[email],
checkAddRemoveLists);
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 hasOwnerReviewer(reviewers, owners) {
return owners.some(function(owner) {
return (owner in reviewers || owner == '*');
});
}
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 showDiv(div, text) {
div.style.display = 'inline';
div.innerHTML = text;
}
function strElement(s) {
var e = document.createElement('span');
e.innerHTML = s;
return e;
}
function br() {
return document.createElement('br');
}
function hr() {
return document.createElement('hr');
}
function newButton(name, action) {
var b = document.createElement('button');
b.appendChild(document.createTextNode(name));
b.className = BUTTON_STYLE;
b.onclick = action;
return b;
}
function showJsonLines(args, key, obj) {
showBoldKeyValueLines(args, key, JSON.stringify(obj, null, 2));
}
function showBoldKeyValueLines(args, key, value) {
args.push(hr(), strElement('<b>' + key + '</b>:'), br());
value.split('\n').forEach(function(line) {
args.push(strElement(line), br());
});
}
function showDebugMessages(result, args) {
function addKeyValue(key, value) {
args.push(strElement('<b>' + key + '</b>: ' + value + '<br>'));
}
args.push(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 = {};
// group name ==> {needReviewer, needApproval, owners}
var groupSize = {};
// group name ==> number of files in group
var header = emptyDiv(HEADER_DIV_ID);
var needReviewerDiv = emptyDiv(NEED_REVIEWER_DIV_ID);
var needApprovalDiv = emptyDiv(NEED_APPROVAL_DIV_ID);
var hasApprovalDiv = emptyDiv(HAS_APPROVAL_DIV_ID);
addApplyButton();
args.push(newButton('Cancel', hideFindOwnersPage));
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';
args.push(apply);
}
function emptyDiv(id) {
var e = document.createElement('div');
e.id = id;
e.style.display = 'none';
args.push(e);
return e;
}
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, title) {
if (keys.length <= 0) {
div.style.display = 'none';
return;
}
div.innerHTML = '';
div.style.display = 'inline';
div.appendChild(strElement(title));
function addOwner(ownerEmail) {
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;
div.appendChild(strElement('&nbsp;&nbsp; '));
var nobr = document.createElement('nobr');
nobr.appendChild(box);
nobr.appendChild(strElement(name));
div.appendChild(nobr);
}
keys.forEach(function(key) {
var owners = groups[key].owners; // string of owner emails
var numFiles = groupSize[key];
var item = HTML_BULLET + '&nbsp;<b>' + key + '</b>' +
((numFiles > 1) ? (' (' + numFiles + ' files):') : ':');
var setOfOwners = new Set(owners.split(' '));
function add2list(list, email) {
if (setOfOwners.has(email)) {
list.push(email);
}
return list;
}
div.appendChild(strElement(item));
sortedOwners.reduce(add2list, []).forEach(addOwner);
div.appendChild(br());
});
}
function addOwnersDiv(div, title) {
div.innerHTML = '';
div.style.display = 'inline';
div.appendChild(strElement(title));
function compareOwnerInfo(o1, o2) {
return o1.email.localeCompare(o2.email);
}
result.owners.sort(compareOwnerInfo).forEach(function(ownerInfo) {
var email = ownerInfo.email;
var vote = reviewerVote[email];
if ((email in reviewerVote) && vote != 0) {
email += ' <font color="' +
((vote > 0) ? 'green">(+' : 'red">(') + vote + ')</font>';
}
div.appendChild(strElement('&nbsp;&nbsp;' + email + '<br>'));
});
}
function updateDivContent() {
var groupNeedReviewer = [];
var groupNeedApproval = [];
var groupHasApproval = [];
numCheckBoxes = 0;
owner2boxes = {};
Object.keys(groups).sort().forEach(function(key) {
var g = groups[key];
if (g.needReviewer) {
groupNeedReviewer.push(key);
} else if (g.needApproval) {
groupNeedApproval.push(key);
} else {
groupHasApproval.push(key);
}
});
showDiv(header,
(onSubmit ? HTML_ONSUBMIT_HEADER : '') + HTML_SELECT_REVIEWERS);
addGroupsToDiv(needReviewerDiv, groupNeedReviewer,
HTML_NEED_REVIEWER_HEADER);
addGroupsToDiv(needApprovalDiv, groupNeedApproval,
HTML_NEED_APPROVAL_HEADER);
addGroupsToDiv(hasApprovalDiv, groupHasApproval,
HTML_HAS_APPROVAL_HEADER);
addOwnersDiv(ownersDiv, HTML_OWNERS_HEADER);
}
function createGroups() {
var owners2group = {}; // owner list to group name
Object.keys(result.file2owners).sort().forEach(function(name) {
var splitOwners = result.file2owners[name];
var owners = splitOwners.join(' ');
if (owners in owners2group) {
groupSize[owners2group[owners]] += 1;
} else {
owners2group[owners] = name;
groupSize[name] = 1;
var needReviewer = !hasOwnerReviewer(reviewerId, splitOwners);
var needApproval = !needReviewer &&
!hasOwnerApproval(reviewerVote, splitOwners);
groups[name] = {
'needReviewer': needReviewer,
'needApproval': needApproval,
'owners': owners};
}
});
}
createGroups();
updateDivContent();
}
function showFindOwnersResults(result) {
function prepareElements() {
var elems = [];
var text = isExemptedFromOwnerApproval() ? HTML_IS_EXEMPTED :
(Object.keys(result.file2owners).length <= 0 ?
HTML_NO_OWNER : null);
useContextPopup = !!context && !!text && !!context.popup;
if (!!text) {
if (useContextPopup) {
elems.push(hr(), strElement(text), hr());
var onClick = function() { context.hide(); };
elems.push(context.button('OK', {onclick: onClick}), hr());
} else {
elems.push(strElement(text), 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.
pageDiv.style.top = '5%';
pageDiv.style.height = 'auto';
pageDiv.style.left = '10%';
pageDiv.style.width = 'auto';
var rect = pageDiv.getBoundingClientRect();
var h = Math.round(Math.min(rect.height - 2 * PADDING,
window.innerHeight * 0.85));
pageDiv.style.height = h + 'px';
pageDiv.style.top = Math.round((window.innerHeight - h) / 2) + 'px';
var w = Math.round(Math.min(rect.width - 2 * PADDING,
window.innerWidth * 0.75));
pageDiv.style.width = w + 'px';
pageDiv.style.left = Math.round((window.innerWidth - w) / 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.
gerritGet('/changes/' + changeId + '/owners', showFindOwnersResults);
}
event.stopPropagation();
callServer(showFindOwnersResults);
}
function onFindOwners(context) {
popupFindOwnersPage(context, context.change, context.revision, false);
}
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.
}
function onClick(e) {
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();
}
}
}
// When the "Find Owners" button is clicked, call onFindOwners.
if (!!self.onAction) { // PolyGerrit does not have self.onAction
self.onAction('revision', 'find-owners', onFindOwners);
} else {
console.log('WARNING, no handler for the 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);
});