| // 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) { |
| var pageDiv = document.createElement('div'); |
| document.body.appendChild(pageDiv); |
| function hideFindOwnersPage() { |
| pageDiv.style.visibility = 'hidden'; |
| } |
| function popupFindOwnersPage(change, revision, onSubmit) { |
| const COMMON_PAGE_STYLE = |
| '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: 5px;'; |
| const LARGE_PAGE_STYLE = Gerrit.css( |
| COMMON_PAGE_STYLE + 'top:5%;left:10%;height:90%;width:80%;' |
| ); |
| const SMALL_PAGE_STYLE = Gerrit.css( |
| COMMON_PAGE_STYLE + 'top:10%;left:25%;height:auto;width:50%;' |
| ); |
| 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>★</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 commit is exempted 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 showFindOwnersPage() { |
| pageDiv.style.visibility = 'visible'; |
| } |
| function getElement(id) { |
| return document.getElementById(id); |
| } |
| function getReviewers(change, callBack) { |
| Gerrit.get('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; |
| Gerrit.post('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; |
| Gerrit.delete('changes/' + changeId + |
| '/reviewers/' + reviewerId[email], |
| checkAddRemoveLists); |
| return; |
| } |
| } |
| hideFindOwnersPage(); |
| if (needRefresh) { |
| needRefresh = false; |
| Gerrit.refresh(); |
| } |
| 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(' ')); |
| 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 + ' <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(' ' + 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 showSmallPage(file2owners, args) { |
| var text = isExemptedFromOwnerApproval() ? HTML_IS_EXEMPTED : |
| (Object.keys(file2owners).length <= 0 ? HTML_NO_OWNER : null); |
| if (text == null) { |
| return false; |
| } |
| pageDiv.className = SMALL_PAGE_STYLE; |
| args.push(strElement(text)); |
| args.push(newButton('OK', hideFindOwnersPage)); |
| return true; |
| } |
| function popupWindow(reviewerList) { |
| setupReviewersMap(reviewerList); |
| var args = []; |
| if (!showSmallPage(result.file2owners, args)) { |
| pageDiv.className = LARGE_PAGE_STYLE; |
| showFilesAndOwners(result, args); |
| } |
| if (result.addDebugMsg) { |
| showDebugMessages(result, args); |
| } |
| while (pageDiv.firstChild) { |
| pageDiv.removeChild(pageDiv.firstChild); |
| } |
| args.forEach(function(e) { pageDiv.appendChild(e); }); |
| showFindOwnersPage(); |
| } |
| getReviewers(changeId, popupWindow); |
| } |
| function callServer(callBack) { |
| // Use the plugin REST API; pass only changeId; |
| // let server get current patch set, project and branch info. |
| Gerrit.get('changes/' + changeId + '/owners', showFindOwnersResults); |
| } |
| event.stopPropagation(); |
| callServer(showFindOwnersResults); |
| } |
| function onFindOwners(context) { |
| popupFindOwnersPage(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(change, revision, true); |
| return false; |
| } |
| return true; // Okay to submit. |
| } |
| function onClick(e) { |
| if (pageDiv.style.visibility != 'hidden') { |
| 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. |
| self.onAction('revision', 'find-owners', onFindOwners); |
| // 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); |
| }); |