// 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. (function() { 'use strict'; var JSON_PREFIX = ')]}\''; var PARENT_PATCH_NUM = 'PARENT'; // Must be kept in sync with the ListChangesOption enum and protobuf. var ListChangesOption = { LABELS: 0, DETAILED_LABELS: 8, // Return information on the current patch set of the change. CURRENT_REVISION: 1, ALL_REVISIONS: 2, // If revisions are included, parse the commit object. CURRENT_COMMIT: 3, ALL_COMMITS: 4, // If a patch set is included, include the files of the patch set. CURRENT_FILES: 5, ALL_FILES: 6, // If accounts are included, include detailed account info. DETAILED_ACCOUNTS: 7, // Include messages associated with the change. MESSAGES: 9, // Include allowed actions client could perform. CURRENT_ACTIONS: 10, // Set the reviewed boolean for the caller. REVIEWED: 11, // Include download commands for the caller. DOWNLOAD_COMMANDS: 13, // Include patch set weblinks. WEB_LINKS: 14, // Include consistency check results. CHECK: 15, // Include allowed change actions client could perform. CHANGE_ACTIONS: 16, // Include a copy of commit messages including review footers. COMMIT_FOOTERS: 17, // Include push certificate information along with any patch sets. PUSH_CERTIFICATES: 18, // Include change's reviewer updates. REVIEWER_UPDATES: 19, // Set the submittable boolean. SUBMITTABLE: 20 }; Polymer({ is: 'gr-rest-api-interface', behaviors: [Gerrit.PathListBehavior], /** * Fired when an server error occurs. * * @event server-error */ /** * Fired when a network error occurs. * * @event network-error */ properties: { _cache: { type: Object, value: {}, // Intentional to share the object accross instances. }, _sharedFetchPromises: { type: Object, value: {}, // Intentional to share the object accross instances. }, }, fetchJSON: function(url, opt_errFn, opt_cancelCondition, opt_params, opt_opts) { opt_opts = opt_opts || {}; var fetchOptions = { credentials: 'same-origin', headers: opt_opts.headers, }; var urlWithParams = this._urlWithParams(url, opt_params); return fetch(urlWithParams, fetchOptions).then(function(response) { if (opt_cancelCondition && opt_cancelCondition()) { response.body.cancel(); return; } if (!response.ok) { if (opt_errFn) { opt_errFn.call(null, response); return; } this.fire('server-error', {response: response}); return; } return this.getResponseObject(response); }.bind(this)).catch(function(err) { if (opt_errFn) { opt_errFn.call(null, null, err); } else { this.fire('network-error', {error: err}); throw err; } throw err; }.bind(this)); }, _urlWithParams: function(url, opt_params) { if (!opt_params) { return url; } var params = []; for (var p in opt_params) { if (opt_params[p] == null) { params.push(encodeURIComponent(p)); continue; } var values = [].concat(opt_params[p]); for (var i = 0; i < values.length; i++) { params.push( encodeURIComponent(p) + '=' + encodeURIComponent(values[i])); } } return url + '?' + params.join('&'); }, getResponseObject: function(response) { return response.text().then(function(text) { var result; try { result = JSON.parse(text.substring(JSON_PREFIX.length)); } catch (_) { result = null; } return result; }); }, getConfig: function() { return this._fetchSharedCacheURL('/config/server/info'); }, getProjectConfig: function(project) { return this._fetchSharedCacheURL( '/projects/' + encodeURIComponent(project) + '/config'); }, getVersion: function() { return this._fetchSharedCacheURL('/config/server/version'); }, getDiffPreferences: function() { return this.getLoggedIn().then(function(loggedIn) { if (loggedIn) { return this._fetchSharedCacheURL('/accounts/self/preferences.diff'); } // These defaults should match the defaults in // gerrit-extension-api/src/main/jcg/gerrit/extensions/client/DiffPreferencesInfo.java // NOTE: There are some settings that don't apply to PolyGerrit // (Render mode being at least one of them). return Promise.resolve({ auto_hide_diff_table_header: true, context: 10, cursor_blink_rate: 0, font_size: 12, ignore_whitespace: 'IGNORE_NONE', intraline_difference: true, line_length: 100, line_wrapping: false, show_line_endings: true, show_tabs: true, show_whitespace_errors: true, syntax_highlighting: true, tab_size: 8, theme: 'DEFAULT', }); }.bind(this)); }, savePreferences: function(prefs, opt_errFn, opt_ctx) { return this.send('PUT', '/accounts/self/preferences', prefs, opt_errFn, opt_ctx); }, saveDiffPreferences: function(prefs, opt_errFn, opt_ctx) { // Invalidate the cache. this._cache['/accounts/self/preferences.diff'] = undefined; return this.send('PUT', '/accounts/self/preferences.diff', prefs, opt_errFn, opt_ctx); }, getAccount: function() { return this._fetchSharedCacheURL('/accounts/self/detail', function(resp) { if (resp.status === 403) { this._cache['/accounts/self/detail'] = null; } }.bind(this)); }, getAccountEmails: function() { return this._fetchSharedCacheURL('/accounts/self/emails'); }, addAccountEmail: function(email, opt_errFn, opt_ctx) { return this.send('PUT', '/accounts/self/emails/' + encodeURIComponent(email), null, opt_errFn, opt_ctx); }, deleteAccountEmail: function(email, opt_errFn, opt_ctx) { return this.send('DELETE', '/accounts/self/emails/' + encodeURIComponent(email), null, opt_errFn, opt_ctx); }, setPreferredAccountEmail: function(email, opt_errFn, opt_ctx) { return this.send('PUT', '/accounts/self/emails/' + encodeURIComponent(email) + '/preferred', null, opt_errFn, opt_ctx).then(function() { // If result of getAccountEmails is in cache, update it in the cache // so we don't have to invalidate it. var cachedEmails = this._cache['/accounts/self/emails']; if (cachedEmails) { var emails = cachedEmails.map(function(entry) { if (entry.email === email) { return {email: email, preferred: true}; } else { return {email: email}; } }); this._cache['/accounts/self/emails'] = emails; } }.bind(this)); }, setAccountName: function(name, opt_errFn, opt_ctx) { return this.send('PUT', '/accounts/self/name', {name: name}, opt_errFn, opt_ctx).then(function(response) { // If result of getAccount is in cache, update it in the cache // so we don't have to invalidate it. var cachedAccount = this._cache['/accounts/self/detail']; if (cachedAccount) { return this.getResponseObject(response).then(function(newName) { // Replace object in cache with new object to force UI updates. // TODO(logan): Polyfill for Object.assign in IE this._cache['/accounts/self/detail'] = Object.assign( {}, cachedAccount, {name: newName}); }.bind(this)); } }.bind(this)); }, getAccountGroups: function() { return this._fetchSharedCacheURL('/accounts/self/groups'); }, getLoggedIn: function() { return this.getAccount().then(function(account) { return account != null; }); }, refreshCredentials: function() { this._cache = {}; return this.getLoggedIn(); }, getPreferences: function() { return this.getLoggedIn().then(function(loggedIn) { if (loggedIn) { return this._fetchSharedCacheURL('/accounts/self/preferences'); } return Promise.resolve({ changes_per_page: 25, diff_view: 'SIDE_BY_SIDE', }); }.bind(this)); }, getWatchedProjects: function() { return this._fetchSharedCacheURL('/accounts/self/watched.projects'); }, saveWatchedProjects: function(projects, opt_errFn, opt_ctx) { return this.send('POST', '/accounts/self/watched.projects', projects, opt_errFn, opt_ctx) .then(function(response) { return this.getResponseObject(response); }.bind(this)); }, deleteWatchedProjects: function(projects, opt_errFn, opt_ctx) { return this.send('POST', '/accounts/self/watched.projects:delete', projects, opt_errFn, opt_ctx); }, _fetchSharedCacheURL: function(url, opt_errFn) { if (this._sharedFetchPromises[url]) { return this._sharedFetchPromises[url]; } // TODO(andybons): Periodic cache invalidation. if (this._cache[url] !== undefined) { return Promise.resolve(this._cache[url]); } this._sharedFetchPromises[url] = this.fetchJSON(url, opt_errFn).then( function(response) { if (response !== undefined) { this._cache[url] = response; } this._sharedFetchPromises[url] = undefined; return response; }.bind(this)).catch(function(err) { this._sharedFetchPromises[url] = undefined; throw err; }.bind(this)); return this._sharedFetchPromises[url]; }, getChanges: function(changesPerPage, opt_query, opt_offset) { var options = this._listChangesOptionsToHex( ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS ); // Issue 4524: respect legacy token with max sortkey. if (opt_offset === 'n,z') { opt_offset = 0; } var params = { n: changesPerPage, O: options, S: opt_offset || 0, }; if (opt_query && opt_query.length > 0) { params.q = opt_query; } return this.fetchJSON('/changes/', null, null, params); }, getDashboardChanges: function() { var options = this._listChangesOptionsToHex( ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS, ListChangesOption.REVIEWED ); var params = { O: options, q: [ 'is:open owner:self', 'is:open ((reviewer:self -owner:self -star:ignore) OR assignee:self)', 'is:closed (owner:self OR reviewer:self OR assignee:self) -age:4w ' + 'limit:10', ], }; return this.fetchJSON('/changes/', null, null, params); }, getChangeActionURL: function(changeNum, opt_patchNum, endpoint) { return this._changeBaseURL(changeNum, opt_patchNum) + endpoint; }, getChangeDetail: function(changeNum, opt_errFn, opt_cancelCondition) { var options = this._listChangesOptionsToHex( ListChangesOption.ALL_REVISIONS, ListChangesOption.CHANGE_ACTIONS, ListChangesOption.CURRENT_ACTIONS, ListChangesOption.CURRENT_COMMIT, ListChangesOption.DOWNLOAD_COMMANDS, ListChangesOption.SUBMITTABLE, ListChangesOption.WEB_LINKS ); return this._getChangeDetail( changeNum, options, opt_errFn, opt_cancelCondition); }, getDiffChangeDetail: function(changeNum, opt_errFn, opt_cancelCondition) { var options = this._listChangesOptionsToHex( ListChangesOption.ALL_REVISIONS ); return this._getChangeDetail(changeNum, options, opt_errFn, opt_cancelCondition); }, _getChangeDetail: function(changeNum, options, opt_errFn, opt_cancelCondition) { return this.fetchJSON( this.getChangeActionURL(changeNum, null, '/detail'), opt_errFn, opt_cancelCondition, {O: options}); }, getChangeCommitInfo: function(changeNum, patchNum) { return this.fetchJSON( this.getChangeActionURL(changeNum, patchNum, '/commit?links')); }, getChangeFiles: function(changeNum, patchRange) { var endpoint = '/files'; if (patchRange.basePatchNum !== 'PARENT') { endpoint += '?base=' + encodeURIComponent(patchRange.basePatchNum); } return this.fetchJSON( this.getChangeActionURL(changeNum, patchRange.patchNum, endpoint)); }, getChangeFilesAsSpeciallySortedArray: function(changeNum, patchRange) { return this.getChangeFiles(changeNum, patchRange).then( this._normalizeChangeFilesResponse.bind(this)); }, getChangeFilePathsAsSpeciallySortedArray: function(changeNum, patchRange) { return this.getChangeFiles(changeNum, patchRange).then(function(files) { return Object.keys(files).sort(this.specialFilePathCompare); }.bind(this)); }, _normalizeChangeFilesResponse: function(response) { var paths = Object.keys(response).sort(this.specialFilePathCompare); var files = []; for (var i = 0; i < paths.length; i++) { var info = response[paths[i]]; info.__path = paths[i]; info.lines_inserted = info.lines_inserted || 0; info.lines_deleted = info.lines_deleted || 0; files.push(info); } return files; }, getChangeRevisionActions: function(changeNum, patchNum) { return this.fetchJSON( this.getChangeActionURL(changeNum, patchNum, '/actions')).then( function(revisionActions) { // The rebase button on change screen is always enabled. if (revisionActions.rebase) { revisionActions.rebase.enabled = true; } return revisionActions; }); }, getChangeSuggestedReviewers: function(changeNum, inputVal, opt_errFn, opt_ctx) { var url = this.getChangeActionURL(changeNum, null, '/suggest_reviewers'); return this.fetchJSON(url, opt_errFn, opt_ctx, { n: 10, // Return max 10 results q: inputVal, }); }, getSuggestedGroups: function(inputVal, opt_n, opt_errFn, opt_ctx) { var params = {s: inputVal}; if (opt_n) { params.n = opt_n; } return this.fetchJSON('/groups/', opt_errFn, opt_ctx, params); }, getSuggestedProjects: function(inputVal, opt_n, opt_errFn, opt_ctx) { var params = {p: inputVal}; if (opt_n) { params.n = opt_n; } return this.fetchJSON('/projects/', opt_errFn, opt_ctx, params); }, getSuggestedAccounts: function(inputVal, opt_n, opt_errFn, opt_ctx) { var params = {q: inputVal, suggest: null}; if (opt_n) { params.n = opt_n; } return this.fetchJSON('/accounts/', opt_errFn, opt_ctx, params); }, addChangeReviewer: function(changeNum, reviewerID) { return this._sendChangeReviewerRequest('POST', changeNum, reviewerID); }, removeChangeReviewer: function(changeNum, reviewerID) { return this._sendChangeReviewerRequest('DELETE', changeNum, reviewerID); }, _sendChangeReviewerRequest: function(method, changeNum, reviewerID) { var url = this.getChangeActionURL(changeNum, null, '/reviewers'); var body; switch (method) { case 'POST': body = {reviewer: reviewerID}; break; case 'DELETE': url += '/' + reviewerID; break; default: throw Error('Unsupported HTTP method: ' + method); } return this.send(method, url, body); }, getRelatedChanges: function(changeNum, patchNum) { return this.fetchJSON( this.getChangeActionURL(changeNum, patchNum, '/related')); }, getChangesSubmittedTogether: function(changeNum) { return this.fetchJSON( this.getChangeActionURL(changeNum, null, '/submitted_together')); }, getChangeConflicts: function(changeNum) { var options = this._listChangesOptionsToHex( ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT ); var params = { O: options, q: 'status:open is:mergeable conflicts:' + changeNum, }; return this.fetchJSON('/changes/', null, null, params); }, getChangeCherryPicks: function(project, changeID, changeNum) { var options = this._listChangesOptionsToHex( ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT ); var query = [ 'project:' + project, 'change:' + changeID, '-change:' + changeNum, '-is:abandoned', ].join(' '); var params = { O: options, q: query, }; return this.fetchJSON('/changes/', null, null, params); }, getChangesWithSameTopic: function(topic) { var options = this._listChangesOptionsToHex( ListChangesOption.LABELS, ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT, ListChangesOption.DETAILED_LABELS ); var params = { O: options, q: 'status:open topic:' + topic, }; return this.fetchJSON('/changes/', null, null, params); }, getReviewedFiles: function(changeNum, patchNum) { return this.fetchJSON( this.getChangeActionURL(changeNum, patchNum, '/files?reviewed')); }, saveFileReviewed: function(changeNum, patchNum, path, reviewed, opt_errFn, opt_ctx) { var method = reviewed ? 'PUT' : 'DELETE'; var url = this.getChangeActionURL(changeNum, patchNum, '/files/' + encodeURIComponent(path) + '/reviewed'); return this.send(method, url, null, opt_errFn, opt_ctx); }, saveChangeReview: function(changeNum, patchNum, review, opt_errFn, opt_ctx) { var url = this.getChangeActionURL(changeNum, patchNum, '/review'); return this.send('POST', url, review, opt_errFn, opt_ctx); }, saveChangeCommitMessageEdit: function(changeNum, message) { var url = this.getChangeActionURL(changeNum, null, '/edit:message'); return this.send('PUT', url, {message: message}); }, publishChangeEdit: function(changeNum) { return this.send('POST', this.getChangeActionURL(changeNum, null, '/edit:publish')); }, saveChangeStarred: function(changeNum, starred) { var url = '/accounts/self/starred.changes/' + changeNum; var method = starred ? 'PUT' : 'DELETE'; return this.send(method, url); }, send: function(method, url, opt_body, opt_errFn, opt_ctx, opt_contentType) { var headers = new Headers({ 'X-Gerrit-Auth': this._getCookie('XSRF_TOKEN'), }); var options = { method: method, headers: headers, credentials: 'same-origin', }; if (opt_body) { headers.append('Content-Type', opt_contentType || 'application/json'); if (typeof opt_body !== 'string') { opt_body = JSON.stringify(opt_body); } options.body = opt_body; } return fetch(url, options).then(function(response) { if (!response.ok) { if (opt_errFn) { opt_errFn.call(null, response); return undefined; } this.fire('server-error', {response: response}); } return response; }.bind(this)).catch(function(err) { this.fire('network-error', {error: err}); if (opt_errFn) { opt_errFn.call(opt_ctx, null, err); } else { throw err; } }.bind(this)); }, getDiff: function(changeNum, basePatchNum, patchNum, path, opt_errFn, opt_cancelCondition) { var url = this._getDiffFetchURL(changeNum, patchNum, path); var params = { context: 'ALL', intraline: null, whitespace: 'IGNORE_NONE', }; if (basePatchNum != PARENT_PATCH_NUM) { params.base = basePatchNum; } return this.fetchJSON(url, opt_errFn, opt_cancelCondition, params); }, _getDiffFetchURL: function(changeNum, patchNum, path) { return this._changeBaseURL(changeNum, patchNum) + '/files/' + encodeURIComponent(path) + '/diff'; }, getDiffComments: function(changeNum, opt_basePatchNum, opt_patchNum, opt_path) { return this._getDiffComments(changeNum, '/comments', opt_basePatchNum, opt_patchNum, opt_path); }, getDiffDrafts: function(changeNum, opt_basePatchNum, opt_patchNum, opt_path) { return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum, opt_patchNum, opt_path); }, _getDiffComments: function(changeNum, endpoint, opt_basePatchNum, opt_patchNum, opt_path) { if (!opt_basePatchNum && !opt_patchNum && !opt_path) { return this.fetchJSON( this._getDiffCommentsFetchURL(changeNum, endpoint)); } function onlyParent(c) { return c.side == PARENT_PATCH_NUM; } function withoutParent(c) { return c.side != PARENT_PATCH_NUM; } function setPath(c) { c.path = opt_path; } var promises = []; var comments; var baseComments; var url = this._getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum); promises.push(this.fetchJSON(url).then(function(response) { comments = response[opt_path] || []; if (opt_basePatchNum == PARENT_PATCH_NUM) { baseComments = comments.filter(onlyParent); baseComments.forEach(setPath); } comments = comments.filter(withoutParent); comments.forEach(setPath); }.bind(this))); if (opt_basePatchNum != PARENT_PATCH_NUM) { var baseURL = this._getDiffCommentsFetchURL(changeNum, endpoint, opt_basePatchNum); promises.push(this.fetchJSON(baseURL).then(function(response) { baseComments = (response[opt_path] || []).filter(withoutParent); baseComments.forEach(setPath); })); } return Promise.all(promises).then(function() { return Promise.resolve({ baseComments: baseComments, comments: comments, }); }); }, _getDiffCommentsFetchURL: function(changeNum, endpoint, opt_patchNum) { return this._changeBaseURL(changeNum, opt_patchNum) + endpoint; }, saveDiffDraft: function(changeNum, patchNum, draft) { return this._sendDiffDraftRequest('PUT', changeNum, patchNum, draft); }, deleteDiffDraft: function(changeNum, patchNum, draft) { return this._sendDiffDraftRequest('DELETE', changeNum, patchNum, draft); }, _sendDiffDraftRequest: function(method, changeNum, patchNum, draft) { var url = this.getChangeActionURL(changeNum, patchNum, '/drafts'); if (draft.id) { url += '/' + draft.id; } var body; if (method === 'PUT') { body = draft; } return this.send(method, url, body); }, _changeBaseURL: function(changeNum, opt_patchNum) { var v = '/changes/' + changeNum; if (opt_patchNum) { v += '/revisions/' + opt_patchNum; } return v; }, // Derived from // gerrit-extension-api/src/main/j/c/g/gerrit/extensions/client/ListChangesOption.java _listChangesOptionsToHex: function() { var v = 0; for (var i = 0; i < arguments.length; i++) { v |= 1 << arguments[i]; } return v.toString(16); }, _getCookie: function(name) { var key = name + '='; var cookies = document.cookie.split(';'); for (var i = 0; i < cookies.length; i++) { var c = cookies[i]; while (c.charAt(0) == ' ') { c = c.substring(1); } if (c.indexOf(key) == 0) { return c.substring(key.length, c.length); } } return ''; }, getCommitInfo: function(project, commit) { return this.fetchJSON( '/projects/' + encodeURIComponent(project) + '/commits/' + encodeURIComponent(commit)); }, _fetchB64File: function(url) { return fetch(url).then(function(response) { var type = response.headers.get('X-FYI-Content-Type'); return response.text() .then(function(text) { return {body: text, type: type}; }); }); }, getChangeFileContents: function(changeId, patchNum, path) { return this._fetchB64File( '/changes/' + encodeURIComponent(changeId) + '/revisions/' + encodeURIComponent(patchNum) + '/files/' + encodeURIComponent(path) + '/content'); }, getCommitFileContents: function(projectName, commit, path) { return this._fetchB64File( '/projects/' + encodeURIComponent(projectName) + '/commits/' + encodeURIComponent(commit) + '/files/' + encodeURIComponent(path) + '/content'); }, getImagesForDiff: function(project, commit, changeNum, diff, patchRange) { var promiseA; var promiseB; if (diff.meta_a && diff.meta_a.content_type.indexOf('image/') === 0) { if (patchRange.basePatchNum === 'PARENT') { // Need the commit info know the parent SHA. promiseA = this.getCommitInfo(project, commit).then(function(info) { if (info.parents.length !== 1) { return Promise.reject('Change commit has multiple parents.'); } var parent = info.parents[0].commit; return this.getCommitFileContents(project, parent, diff.meta_a.name); }.bind(this)); } else { promiseA = this.getChangeFileContents(changeNum, patchRange.basePatchNum, diff.meta_a.name); } } else { promiseA = Promise.resolve(null); } if (diff.meta_b && diff.meta_b.content_type.indexOf('image/') === 0) { promiseB = this.getChangeFileContents(changeNum, patchRange.patchNum, diff.meta_b.name); } else { promiseB = Promise.resolve(null); } return Promise.all([promiseA, promiseB]) .then(function(results) { var baseImage = results[0]; var revisionImage = results[1]; // Sometimes the server doesn't send back the content type. if (baseImage) { baseImage._expectedType = diff.meta_a.content_type; } if (revisionImage) { revisionImage._expectedType = diff.meta_b.content_type; } return {baseImage: baseImage, revisionImage: revisionImage}; }.bind(this)); }, setChangeTopic: function(changeNum, topic) { return this.send('PUT', '/changes/' + encodeURIComponent(changeNum) + '/topic', {topic: topic}); }, getAccountHttpPassword: function(opt_errFn) { return this._fetchSharedCacheURL('/accounts/self/password.http', opt_errFn); }, deleteAccountHttpPassword: function() { return this.send('DELETE', '/accounts/self/password.http'); }, generateAccountHttpPassword: function() { return this.send('PUT', '/accounts/self/password.http', {generate: true}) .then(this.getResponseObject); }, getAccountSSHKeys: function() { return this._fetchSharedCacheURL('/accounts/self/sshkeys'); }, addAccountSSHKey: function(key) { return this.send('POST', '/accounts/self/sshkeys', key, null, null, 'plain/text') .then(function(response) { if (response.status < 200 && response.status >= 300) { return Promise.reject(); } return this.getResponseObject(response); }.bind(this)) .then(function(obj) { if (!obj.valid) { return Promise.reject(); } return obj; }); }, deleteAccountSSHKey: function(id) { return this.send('DELETE', '/accounts/self/sshkeys/' + id); }, deleteVote: function(changeID, account, label) { return this.send('DELETE', '/changes/' + changeID + '/reviewers/' + account + '/votes/' + encodeURIComponent(label)); }, }); })();