diff options
Diffstat (limited to 'polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js')
-rw-r--r-- | polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js | 2298 |
1 files changed, 1700 insertions, 598 deletions
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js index 09a2962437..09eff805c6 100644 --- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js +++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js @@ -1,19 +1,118 @@ -// 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. +/** + * @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. + */ (function() { 'use strict'; + const Defs = {}; + + /** + * @typedef {{ + * basePatchNum: (string|number), + * patchNum: (number), + * }} + */ + Defs.patchRange; + + /** + * @typedef {{ + * url: string, + * fetchOptions: (Object|null|undefined), + * anonymizedUrl: (string|undefined), + * }} + */ + Defs.FetchRequest; + + /** + * Object to describe a request for passing into _fetchJSON or _fetchRawJSON. + * - url is the URL for the request (excluding get params) + * - errFn is a function to invoke when the request fails. + * - cancelCondition is a function that, if provided and returns true, will + * cancel the response after it resolves. + * - params is a key-value hash to specify get params for the request URL. + * @typedef {{ + * url: string, + * errFn: (function(?Response, string=)|null|undefined), + * cancelCondition: (function()|null|undefined), + * params: (Object|null|undefined), + * fetchOptions: (Object|null|undefined), + * anonymizedUrl: (string|undefined), + * reportUrlAsIs: (boolean|undefined), + * }} + */ + Defs.FetchJSONRequest; + + /** + * @typedef {{ + * changeNum: (string|number), + * endpoint: string, + * patchNum: (string|number|null|undefined), + * errFn: (function(?Response, string=)|null|undefined), + * params: (Object|null|undefined), + * fetchOptions: (Object|null|undefined), + * anonymizedEndpoint: (string|undefined), + * reportEndpointAsIs: (boolean|undefined), + * }} + */ + Defs.ChangeFetchRequest; + + /** + * Object to describe a request for passing into _send. + * - method is the HTTP method to use in the request. + * - url is the URL for the request + * - body is a request payload. + * TODO (beckysiegel) remove need for number at least. + * - errFn is a function to invoke when the request fails. + * - cancelCondition is a function that, if provided and returns true, will + * cancel the response after it resolves. + * - contentType is the content type of the body. + * - headers is a key-value hash to describe HTTP headers for the request. + * - parseResponse states whether the result should be parsed as a JSON + * object using getResponseObject. + * @typedef {{ + * method: string, + * url: string, + * body: (string|number|Object|null|undefined), + * errFn: (function(?Response, string=)|null|undefined), + * contentType: (string|null|undefined), + * headers: (Object|undefined), + * parseResponse: (boolean|undefined), + * anonymizedUrl: (string|undefined), + * reportUrlAsIs: (boolean|undefined), + * }} + */ + Defs.SendRequest; + + /** + * @typedef {{ + * changeNum: (string|number), + * method: string, + * patchNum: (string|number|undefined), + * endpoint: string, + * body: (string|number|Object|null|undefined), + * errFn: (function(?Response, string=)|null|undefined), + * contentType: (string|null|undefined), + * headers: (Object|undefined), + * parseResponse: (boolean|undefined), + * anonymizedEndpoint: (string|undefined), + * reportEndpointAsIs: (boolean|undefined), + * }} + */ + Defs.ChangeSendRequest; + const DiffViewMode = { SIDE_BY_SIDE: 'SIDE_BY_SIDE', UNIFIED: 'UNIFIED_DIFF', @@ -22,8 +121,6 @@ const MAX_PROJECT_RESULTS = 25; const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900; const PARENT_PATCH_NUM = 'PARENT'; - const CHECK_SIGN_IN_DEBOUNCE_MS = 3 * 1000; - const CHECK_SIGN_IN_DEBOUNCER_NAME = 'checkCredentials'; const FAILED_TO_FETCH_ERROR = 'Failed to fetch'; const Requests = { @@ -34,12 +131,62 @@ 'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)'; const HEADER_REPORTING_BLACKLIST = /^set-cookie$/i; + const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*'; + const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL + + '/revisions/*'; + + /** + * Wrapper around Map for caching server responses. Site-based so that + * changes to CANONICAL_PATH will result in a different cache going into + * effect. + */ + class SiteBasedCache { + constructor() { + // Container of per-canonical-path caches. + this._data = new Map(); + } + + // Returns the cache for the current canonical path. + _cache() { + if (!this._data.has(window.CANONICAL_PATH)) { + this._data.set(window.CANONICAL_PATH, new Map()); + } + return this._data.get(window.CANONICAL_PATH); + } + + has(key) { + return this._cache().has(key); + } + + get(key) { + return this._cache().get(key); + } + + set(key, value) { + this._cache().set(key, value); + } + + delete(key) { + this._cache().delete(key); + } + + invalidatePrefix(prefix) { + const newMap = new Map(); + for (const [key, value] of this._cache().entries()) { + if (!key.startsWith(prefix)) { + newMap.set(key, value); + } + } + this._data.set(window.CANONICAL_PATH, newMap); + } + } Polymer({ is: 'gr-rest-api-interface', behaviors: [ Gerrit.PathListBehavior, + Gerrit.PatchSetBehavior, Gerrit.RESTClientBehavior, ], @@ -61,10 +208,20 @@ * @event auth-error */ + /** + * Fired after an RPC completes. + * + * @event rpc-log + */ + properties: { _cache: { type: Object, - value: {}, // Intentional to share the object across instances. + value: new SiteBasedCache(), // Shared across instances. + }, + _credentialCheck: { + type: Object, + value: {checking: false}, // Shared across instances. }, _sharedFetchPromises: { type: Object, @@ -94,39 +251,76 @@ JSON_PREFIX, /** + * Wraps calls to the underlying authenticated fetch function (_auth.fetch) + * with timing and logging. + * @param {Defs.FetchRequest} req + */ + _fetch(req) { + const start = Date.now(); + const xhr = this._auth.fetch(req.url, req.fetchOptions); + + // Log the call after it completes. + xhr.then(res => this._logCall(req, start, res.status)); + + // Return the XHR directly (without the log). + return xhr; + }, + + /** + * Log information about a REST call. Because the elapsed time is determined + * by this method, it should be called immediately after the request + * finishes. + * @param {Defs.FetchRequest} req + * @param {number} startTime the time that the request was started. + * @param {number} status the HTTP status of the response. The status value + * is used here rather than the response object so there is no way this + * method can read the body stream. + */ + _logCall(req, startTime, status) { + const method = (req.fetchOptions && req.fetchOptions.method) ? + req.fetchOptions.method : 'GET'; + const elapsed = (Date.now() - startTime); + console.log([ + 'HTTP', + status, + method, + elapsed + 'ms', + req.anonymizedUrl || req.url, + ].join(' ')); + if (req.anonymizedUrl) { + this.fire('rpc-log', + {status, method, elapsed, anonymizedUrl: req.anonymizedUrl}); + } + }, + + /** * Fetch JSON from url provided. * Returns a Promise that resolves to a native Response. * Doesn't do error checking. Supports cancel condition. Performs auth. * Validates auth expiry errors. - * @param {string} url - * @param {?function(?Response, string=)=} opt_errFn - * passed as null sometimes. - * @param {?function()=} opt_cancelCondition - * passed as null sometimes. - * @param {?Object=} opt_params URL params, key-value hash. - * @param {?Object=} opt_options Fetch options. - */ - _fetchRawJSON(url, opt_errFn, opt_cancelCondition, opt_params, - opt_options) { - const urlWithParams = this._urlWithParams(url, opt_params); - return this._auth.fetch(urlWithParams, opt_options).then(response => { - if (opt_cancelCondition && opt_cancelCondition()) { - response.body.cancel(); + * @param {Defs.FetchJSONRequest} req + */ + _fetchRawJSON(req) { + const urlWithParams = this._urlWithParams(req.url, req.params); + const fetchReq = { + url: urlWithParams, + fetchOptions: req.fetchOptions, + anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl, + }; + return this._fetch(fetchReq).then(res => { + if (req.cancelCondition && req.cancelCondition()) { + res.body.cancel(); return; } - return response; + return res; }).catch(err => { - const isLoggedIn = !!this._cache['/accounts/self/detail']; + const isLoggedIn = !!this._cache.get('/accounts/self/detail'); if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) { - if (!this.isDebouncerActive(CHECK_SIGN_IN_DEBOUNCER_NAME)) { - this.checkCredentials(); - } - this.debounce(CHECK_SIGN_IN_DEBOUNCER_NAME, this.checkCredentials, - CHECK_SIGN_IN_DEBOUNCE_MS); + this.checkCredentials(); return; } - if (opt_errFn) { - opt_errFn.call(undefined, null, err); + if (req.errFn) { + req.errFn.call(undefined, null, err); } else { this.fire('network-error', {error: err}); } @@ -138,31 +332,23 @@ * Fetch JSON from url provided. * Returns a Promise that resolves to a parsed response. * Same as {@link _fetchRawJSON}, plus error handling. - * @param {string} url - * @param {?function(?Response, string=)=} opt_errFn - * passed as null sometimes. - * @param {?function()=} opt_cancelCondition - * passed as null sometimes. - * @param {?Object=} opt_params URL params, key-value hash. - * @param {?Object=} opt_options Fetch options. + * @param {Defs.FetchJSONRequest} req */ - fetchJSON(url, opt_errFn, opt_cancelCondition, opt_params, opt_options) { - return this._fetchRawJSON( - url, opt_errFn, opt_cancelCondition, opt_params, opt_options) - .then(response => { - if (!response) { - return; - } - if (!response.ok) { - if (opt_errFn) { - opt_errFn.call(null, response); - return; - } - this.fire('server-error', {response}); - return; - } - return response && this.getResponseObject(response); - }); + _fetchJSON(req) { + return this._fetchRawJSON(req).then(response => { + if (!response) { + return; + } + if (!response.ok) { + if (req.errFn) { + req.errFn.call(null, response); + return; + } + this.fire('server-error', {request: req, response}); + return; + } + return response && this.getResponseObject(response); + }); }, /** @@ -220,101 +406,166 @@ return JSON.parse(source.substring(JSON_PREFIX.length)); }, - getConfig() { - return this._fetchSharedCacheURL('/config/server/info'); + getConfig(noCache) { + if (!noCache) { + return this._fetchSharedCacheURL({ + url: '/config/server/info', + reportUrlAsIs: true, + }); + } + + return this._fetchJSON({ + url: '/config/server/info', + reportUrlAsIs: true, + }); + }, + + getRepo(repo, opt_errFn) { + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + return this._fetchSharedCacheURL({ + url: '/projects/' + encodeURIComponent(repo), + errFn: opt_errFn, + anonymizedUrl: '/projects/*', + }); }, - getProject(project) { - return this._fetchSharedCacheURL( - '/projects/' + encodeURIComponent(project)); + getProjectConfig(repo, opt_errFn) { + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + return this._fetchSharedCacheURL({ + url: '/projects/' + encodeURIComponent(repo) + '/config', + errFn: opt_errFn, + anonymizedUrl: '/projects/*/config', + }); }, - getProjectConfig(project) { - return this._fetchSharedCacheURL( - '/projects/' + encodeURIComponent(project) + '/config'); + getRepoAccess(repo) { + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + return this._fetchSharedCacheURL({ + url: '/access/?project=' + encodeURIComponent(repo), + anonymizedUrl: '/access/?project=*', + }); }, - getProjectAccess(project) { - return this._fetchSharedCacheURL( - '/access/?project=' + encodeURIComponent(project)); + getRepoDashboards(repo, opt_errFn) { + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + return this._fetchSharedCacheURL({ + url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`, + errFn: opt_errFn, + anonymizedUrl: '/projects/*/dashboards?inherited', + }); }, - saveProjectConfig(project, config, opt_errFn, opt_ctx) { - const encodeName = encodeURIComponent(project); - return this.send('PUT', `/projects/${encodeName}/config`, config, - opt_errFn, opt_ctx); + saveRepoConfig(repo, config, opt_errFn) { + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + const encodeName = encodeURIComponent(repo); + return this._send({ + method: 'PUT', + url: `/projects/${encodeName}/config`, + body: config, + errFn: opt_errFn, + anonymizedUrl: '/projects/*/config', + }); }, - runProjectGC(project, opt_errFn, opt_ctx) { - if (!project) { - return ''; - } - const encodeName = encodeURIComponent(project); - return this.send('POST', `/projects/${encodeName}/gc`, '', - opt_errFn, opt_ctx); + runRepoGC(repo, opt_errFn) { + if (!repo) { return ''; } + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + const encodeName = encodeURIComponent(repo); + return this._send({ + method: 'POST', + url: `/projects/${encodeName}/gc`, + body: '', + errFn: opt_errFn, + anonymizedUrl: '/projects/*/gc', + }); }, /** * @param {?Object} config * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - createProject(config, opt_errFn, opt_ctx) { + createRepo(config, opt_errFn) { if (!config.name) { return ''; } + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. const encodeName = encodeURIComponent(config.name); - return this.send('PUT', `/projects/${encodeName}`, config, opt_errFn, - opt_ctx); + return this._send({ + method: 'PUT', + url: `/projects/${encodeName}`, + body: config, + errFn: opt_errFn, + anonymizedUrl: '/projects/*', + }); }, /** * @param {?Object} config * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - createGroup(config, opt_errFn, opt_ctx) { + createGroup(config, opt_errFn) { if (!config.name) { return ''; } const encodeName = encodeURIComponent(config.name); - return this.send('PUT', `/groups/${encodeName}`, config, opt_errFn, - opt_ctx); + return this._send({ + method: 'PUT', + url: `/groups/${encodeName}`, + body: config, + errFn: opt_errFn, + anonymizedUrl: '/groups/*', + }); }, - getGroupConfig(group) { - const encodeName = encodeURIComponent(group); - return this.fetchJSON(`/groups/${encodeName}/detail`); + getGroupConfig(group, opt_errFn) { + return this._fetchJSON({ + url: `/groups/${encodeURIComponent(group)}/detail`, + errFn: opt_errFn, + anonymizedUrl: '/groups/*/detail', + }); }, /** - * @param {string} project + * @param {string} repo * @param {string} ref * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - deleteProjectBranches(project, ref, opt_errFn, opt_ctx) { - if (!project || !ref) { - return ''; - } - const encodeName = encodeURIComponent(project); + deleteRepoBranches(repo, ref, opt_errFn) { + if (!repo || !ref) { return ''; } + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + const encodeName = encodeURIComponent(repo); const encodeRef = encodeURIComponent(ref); - return this.send('DELETE', - `/projects/${encodeName}/branches/${encodeRef}`, '', - opt_errFn, opt_ctx); + return this._send({ + method: 'DELETE', + url: `/projects/${encodeName}/branches/${encodeRef}`, + body: '', + errFn: opt_errFn, + anonymizedUrl: '/projects/*/branches/*', + }); }, /** - * @param {string} project + * @param {string} repo * @param {string} ref * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - deleteProjectTags(project, ref, opt_errFn, opt_ctx) { - if (!project || !ref) { - return ''; - } - const encodeName = encodeURIComponent(project); + deleteRepoTags(repo, ref, opt_errFn) { + if (!repo || !ref) { return ''; } + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + const encodeName = encodeURIComponent(repo); const encodeRef = encodeURIComponent(ref); - return this.send('DELETE', - `/projects/${encodeName}/tags/${encodeRef}`, '', - opt_errFn, opt_ctx); + return this._send({ + method: 'DELETE', + url: `/projects/${encodeName}/tags/${encodeRef}`, + body: '', + errFn: opt_errFn, + anonymizedUrl: '/projects/*/tags/*', + }); }, /** @@ -322,15 +573,20 @@ * @param {string} branch * @param {string} revision * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - createProjectBranch(name, branch, revision, opt_errFn, opt_ctx) { + createRepoBranch(name, branch, revision, opt_errFn) { if (!name || !branch || !revision) { return ''; } + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. const encodeName = encodeURIComponent(name); const encodeBranch = encodeURIComponent(branch); - return this.send('PUT', - `/projects/${encodeName}/branches/${encodeBranch}`, - revision, opt_errFn, opt_ctx); + return this._send({ + method: 'PUT', + url: `/projects/${encodeName}/branches/${encodeBranch}`, + body: revision, + errFn: opt_errFn, + anonymizedUrl: '/projects/*/branches/*', + }); }, /** @@ -338,14 +594,20 @@ * @param {string} tag * @param {string} revision * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - createProjectTag(name, tag, revision, opt_errFn, opt_ctx) { + createRepoTag(name, tag, revision, opt_errFn) { if (!name || !tag || !revision) { return ''; } + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. const encodeName = encodeURIComponent(name); const encodeTag = encodeURIComponent(tag); - return this.send('PUT', `/projects/${encodeName}/tags/${encodeTag}`, - revision, opt_errFn, opt_ctx); + return this._send({ + method: 'PUT', + url: `/projects/${encodeName}/tags/${encodeTag}`, + body: revision, + errFn: opt_errFn, + anonymizedUrl: '/projects/*/tags/*', + }); }, /** @@ -354,91 +616,142 @@ */ getIsGroupOwner(groupName) { const encodeName = encodeURIComponent(groupName); - return this._fetchSharedCacheURL(`/groups/?owned&q=${encodeName}`) + const req = { + url: `/groups/?owned&q=${encodeName}`, + anonymizedUrl: '/groups/owned&q=*', + }; + return this._fetchSharedCacheURL(req) .then(configs => configs.hasOwnProperty(groupName)); }, - getGroupMembers(groupName) { + getGroupMembers(groupName, opt_errFn) { const encodeName = encodeURIComponent(groupName); - return this.send('GET', `/groups/${encodeName}/members/`) - .then(response => this.getResponseObject(response)); + return this._fetchJSON({ + url: `/groups/${encodeName}/members/`, + errFn: opt_errFn, + anonymizedUrl: '/groups/*/members', + }); }, getIncludedGroup(groupName) { - const encodeName = encodeURIComponent(groupName); - return this.send('GET', `/groups/${encodeName}/groups/`) - .then(response => this.getResponseObject(response)); + return this._fetchJSON({ + url: `/groups/${encodeURIComponent(groupName)}/groups/`, + anonymizedUrl: '/groups/*/groups', + }); }, saveGroupName(groupId, name) { const encodeId = encodeURIComponent(groupId); - return this.send('PUT', `/groups/${encodeId}/name`, {name}); + return this._send({ + method: 'PUT', + url: `/groups/${encodeId}/name`, + body: {name}, + anonymizedUrl: '/groups/*/name', + }); }, saveGroupOwner(groupId, ownerId) { const encodeId = encodeURIComponent(groupId); - return this.send('PUT', `/groups/${encodeId}/owner`, {owner: ownerId}); + return this._send({ + method: 'PUT', + url: `/groups/${encodeId}/owner`, + body: {owner: ownerId}, + anonymizedUrl: '/groups/*/owner', + }); }, saveGroupDescription(groupId, description) { const encodeId = encodeURIComponent(groupId); - return this.send('PUT', `/groups/${encodeId}/description`, - {description}); + return this._send({ + method: 'PUT', + url: `/groups/${encodeId}/description`, + body: {description}, + anonymizedUrl: '/groups/*/description', + }); }, saveGroupOptions(groupId, options) { const encodeId = encodeURIComponent(groupId); - return this.send('PUT', `/groups/${encodeId}/options`, options); + return this._send({ + method: 'PUT', + url: `/groups/${encodeId}/options`, + body: options, + anonymizedUrl: '/groups/*/options', + }); }, - getGroupAuditLog(group) { - return this._fetchSharedCacheURL('/groups/' + group + '/log.audit'); + getGroupAuditLog(group, opt_errFn) { + return this._fetchSharedCacheURL({ + url: '/groups/' + group + '/log.audit', + errFn: opt_errFn, + anonymizedUrl: '/groups/*/log.audit', + }); }, saveGroupMembers(groupName, groupMembers) { const encodeName = encodeURIComponent(groupName); const encodeMember = encodeURIComponent(groupMembers); - return this.send('PUT', `/groups/${encodeName}/members/${encodeMember}`) - .then(response => this.getResponseObject(response)); + return this._send({ + method: 'PUT', + url: `/groups/${encodeName}/members/${encodeMember}`, + parseResponse: true, + anonymizedUrl: '/groups/*/members/*', + }); }, saveIncludedGroup(groupName, includedGroup, opt_errFn) { const encodeName = encodeURIComponent(groupName); const encodeIncludedGroup = encodeURIComponent(includedGroup); - return this.send('PUT', - `/groups/${encodeName}/groups/${encodeIncludedGroup}`, null, - opt_errFn).then(response => { - if (response.ok) { - return this.getResponseObject(response); - } - }); + const req = { + method: 'PUT', + url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`, + errFn: opt_errFn, + anonymizedUrl: '/groups/*/groups/*', + }; + return this._send(req).then(response => { + if (response.ok) { + return this.getResponseObject(response); + } + }); }, deleteGroupMembers(groupName, groupMembers) { const encodeName = encodeURIComponent(groupName); const encodeMember = encodeURIComponent(groupMembers); - return this.send('DELETE', - `/groups/${encodeName}/members/${encodeMember}`); + return this._send({ + method: 'DELETE', + url: `/groups/${encodeName}/members/${encodeMember}`, + anonymizedUrl: '/groups/*/members/*', + }); }, deleteIncludedGroup(groupName, includedGroup) { const encodeName = encodeURIComponent(groupName); const encodeIncludedGroup = encodeURIComponent(includedGroup); - return this.send('DELETE', - `/groups/${encodeName}/groups/${encodeIncludedGroup}`); + return this._send({ + method: 'DELETE', + url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`, + anonymizedUrl: '/groups/*/groups/*', + }); }, getVersion() { - return this._fetchSharedCacheURL('/config/server/version'); + return this._fetchSharedCacheURL({ + url: '/config/server/version', + reportUrlAsIs: true, + }); }, getDiffPreferences() { return this.getLoggedIn().then(loggedIn => { if (loggedIn) { - return this._fetchSharedCacheURL('/accounts/self/preferences.diff'); + return this._fetchSharedCacheURL({ + url: '/accounts/self/preferences.diff', + reportUrlAsIs: true, + }); } // These defaults should match the defaults in - // gerrit-extension-api/src/main/jcg/gerrit/extensions/client/DiffPreferencesInfo.java + // java/com/google/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({ @@ -460,39 +773,127 @@ }); }, + getEditPreferences() { + return this.getLoggedIn().then(loggedIn => { + if (loggedIn) { + return this._fetchSharedCacheURL({ + url: '/accounts/self/preferences.edit', + reportUrlAsIs: true, + }); + } + // These defaults should match the defaults in + // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java + return Promise.resolve({ + auto_close_brackets: false, + cursor_blink_rate: 0, + hide_line_numbers: false, + hide_top_menu: false, + indent_unit: 2, + indent_with_tabs: false, + key_map_type: 'DEFAULT', + line_length: 100, + line_wrapping: false, + match_brackets: true, + show_base: false, + show_tabs: true, + show_whitespace_errors: true, + syntax_highlighting: true, + tab_size: 8, + theme: 'DEFAULT', + }); + }); + }, + /** * @param {?Object} prefs * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - savePreferences(prefs, opt_errFn, opt_ctx) { + savePreferences(prefs, opt_errFn) { // Note (Issue 5142): normalize the download scheme with lower case before // saving. if (prefs.download_scheme) { prefs.download_scheme = prefs.download_scheme.toLowerCase(); } - return this.send('PUT', '/accounts/self/preferences', prefs, opt_errFn, - opt_ctx); + return this._send({ + method: 'PUT', + url: '/accounts/self/preferences', + body: prefs, + errFn: opt_errFn, + reportUrlAsIs: true, + }); + }, + + /** + * @param {?Object} prefs + * @param {function(?Response, string=)=} opt_errFn + */ + saveDiffPreferences(prefs, opt_errFn) { + // Invalidate the cache. + this._cache.delete('/accounts/self/preferences.diff'); + return this._send({ + method: 'PUT', + url: '/accounts/self/preferences.diff', + body: prefs, + errFn: opt_errFn, + reportUrlAsIs: true, + }); }, /** * @param {?Object} prefs * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - saveDiffPreferences(prefs, opt_errFn, opt_ctx) { + saveEditPreferences(prefs, opt_errFn) { // Invalidate the cache. - this._cache['/accounts/self/preferences.diff'] = undefined; - return this.send('PUT', '/accounts/self/preferences.diff', prefs, - opt_errFn, opt_ctx); + this._cache.delete('/accounts/self/preferences.edit'); + return this._send({ + method: 'PUT', + url: '/accounts/self/preferences.edit', + body: prefs, + errFn: opt_errFn, + reportUrlAsIs: true, + }); }, getAccount() { - return this._fetchSharedCacheURL('/accounts/self/detail', resp => { - if (resp.status === 403) { - this._cache['/accounts/self/detail'] = null; - } + return this._fetchSharedCacheURL({ + url: '/accounts/self/detail', + reportUrlAsIs: true, + errFn: resp => { + if (!resp || resp.status === 403) { + this._cache.delete('/accounts/self/detail'); + } + }, + }); + }, + + getAvatarChangeUrl() { + return this._fetchSharedCacheURL({ + url: '/accounts/self/avatar.change.url', + reportUrlAsIs: true, + errFn: resp => { + if (!resp || resp.status === 403) { + this._cache.delete('/accounts/self/avatar.change.url'); + } + }, + }); + }, + + getExternalIds() { + return this._fetchJSON({ + url: '/accounts/self/external.ids', + reportUrlAsIs: true, + }); + }, + + deleteAccountIdentity(id) { + return this._send({ + method: 'POST', + url: '/accounts/self/external.ids:delete', + body: id, + parseResponse: true, + reportUrlAsIs: true, }); }, @@ -501,56 +902,72 @@ * @return {!Promise<!Object>} */ getAccountDetails(userId) { - return this.fetchJSON(`/accounts/${encodeURIComponent(userId)}/detail`); + return this._fetchJSON({ + url: `/accounts/${encodeURIComponent(userId)}/detail`, + anonymizedUrl: '/accounts/*/detail', + }); }, getAccountEmails() { - return this._fetchSharedCacheURL('/accounts/self/emails'); + return this._fetchSharedCacheURL({ + url: '/accounts/self/emails', + reportUrlAsIs: true, + }); }, /** * @param {string} email * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - addAccountEmail(email, opt_errFn, opt_ctx) { - return this.send('PUT', '/accounts/self/emails/' + - encodeURIComponent(email), null, opt_errFn, opt_ctx); + addAccountEmail(email, opt_errFn) { + return this._send({ + method: 'PUT', + url: '/accounts/self/emails/' + encodeURIComponent(email), + errFn: opt_errFn, + anonymizedUrl: '/account/self/emails/*', + }); }, /** * @param {string} email * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - deleteAccountEmail(email, opt_errFn, opt_ctx) { - return this.send('DELETE', '/accounts/self/emails/' + - encodeURIComponent(email), null, opt_errFn, opt_ctx); + deleteAccountEmail(email, opt_errFn) { + return this._send({ + method: 'DELETE', + url: '/accounts/self/emails/' + encodeURIComponent(email), + errFn: opt_errFn, + anonymizedUrl: '/accounts/self/email/*', + }); }, /** * @param {string} email * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx - */ - setPreferredAccountEmail(email, opt_errFn, opt_ctx) { - return this.send('PUT', '/accounts/self/emails/' + - encodeURIComponent(email) + '/preferred', null, - opt_errFn, opt_ctx).then(() => { - // If result of getAccountEmails is in cache, update it in the cache - // so we don't have to invalidate it. - const cachedEmails = this._cache['/accounts/self/emails']; - if (cachedEmails) { - const emails = cachedEmails.map(entry => { - if (entry.email === email) { - return {email, preferred: true}; - } else { - return {email}; - } - }); - this._cache['/accounts/self/emails'] = emails; + */ + setPreferredAccountEmail(email, opt_errFn) { + const encodedEmail = encodeURIComponent(email); + const req = { + method: 'PUT', + url: `/accounts/self/emails/${encodedEmail}/preferred`, + errFn: opt_errFn, + anonymizedUrl: '/accounts/self/emails/*/preferred', + }; + return this._send(req).then(() => { + // If result of getAccountEmails is in cache, update it in the cache + // so we don't have to invalidate it. + const cachedEmails = this._cache.get('/accounts/self/emails'); + if (cachedEmails) { + const emails = cachedEmails.map(entry => { + if (entry.email === email) { + return {email, preferred: true}; + } else { + return {email}; } }); + this._cache.set('/accounts/self/emails', emails); + } + }); }, /** @@ -559,58 +976,93 @@ _updateCachedAccount(obj) { // If result of getAccount is in cache, update it in the cache // so we don't have to invalidate it. - const cachedAccount = this._cache['/accounts/self/detail']; + const cachedAccount = this._cache.get('/accounts/self/detail'); if (cachedAccount) { // Replace object in cache with new object to force UI updates. - this._cache['/accounts/self/detail'] = - Object.assign({}, cachedAccount, obj); + this._cache.set('/accounts/self/detail', + Object.assign({}, cachedAccount, obj)); } }, /** * @param {string} name * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - setAccountName(name, opt_errFn, opt_ctx) { - return this.send('PUT', '/accounts/self/name', {name}, opt_errFn, opt_ctx) - .then(response => this.getResponseObject(response) - .then(newName => this._updateCachedAccount({name: newName}))); + setAccountName(name, opt_errFn) { + const req = { + method: 'PUT', + url: '/accounts/self/name', + body: {name}, + errFn: opt_errFn, + parseResponse: true, + reportUrlAsIs: true, + }; + return this._send(req) + .then(newName => this._updateCachedAccount({name: newName})); }, /** * @param {string} username * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - setAccountUsername(username, opt_errFn, opt_ctx) { - return this.send('PUT', '/accounts/self/username', {username}, opt_errFn, - opt_ctx).then(response => this.getResponseObject(response) - .then(newName => this._updateCachedAccount({username: newName}))); + setAccountUsername(username, opt_errFn) { + const req = { + method: 'PUT', + url: '/accounts/self/username', + body: {username}, + errFn: opt_errFn, + parseResponse: true, + reportUrlAsIs: true, + }; + return this._send(req) + .then(newName => this._updateCachedAccount({username: newName})); }, /** * @param {string} status * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - setAccountStatus(status, opt_errFn, opt_ctx) { - return this.send('PUT', '/accounts/self/status', {status}, - opt_errFn, opt_ctx).then(response => this.getResponseObject(response) - .then(newStatus => this._updateCachedAccount( - {status: newStatus}))); + setAccountStatus(status, opt_errFn) { + const req = { + method: 'PUT', + url: '/accounts/self/status', + body: {status}, + errFn: opt_errFn, + parseResponse: true, + reportUrlAsIs: true, + }; + return this._send(req) + .then(newStatus => this._updateCachedAccount({status: newStatus})); }, getAccountStatus(userId) { - return this.fetchJSON(`/accounts/${encodeURIComponent(userId)}/status`); + return this._fetchJSON({ + url: `/accounts/${encodeURIComponent(userId)}/status`, + anonymizedUrl: '/accounts/*/status', + }); }, getAccountGroups() { - return this._fetchSharedCacheURL('/accounts/self/groups'); + return this._fetchJSON({ + url: '/accounts/self/groups', + reportUrlAsIs: true, + }); }, getAccountAgreements() { - return this._fetchSharedCacheURL('/accounts/self/agreements'); + return this._fetchJSON({ + url: '/accounts/self/agreements', + reportUrlAsIs: true, + }); + }, + + saveAccountAgreement(name) { + return this._send({ + method: 'PUT', + url: '/accounts/self/agreements', + body: name, + reportUrlAsIs: true, + }); }, /** @@ -623,8 +1075,10 @@ .map(param => { return encodeURIComponent(param); }) .join('&q='); } - return this._fetchSharedCacheURL('/accounts/self/capabilities' + - queryString); + return this._fetchSharedCacheURL({ + url: '/accounts/self/capabilities' + queryString, + anonymizedUrl: '/accounts/self/capabilities?q=*', + }); }, getLoggedIn() { @@ -646,39 +1100,50 @@ }, checkCredentials() { + if (this._credentialCheck.checking) { + return; + } + this._credentialCheck.checking = true; + const req = {url: '/accounts/self/detail', reportUrlAsIs: true}; // Skip the REST response cache. - return this._fetchRawJSON('/accounts/self/detail').then(response => { - if (!response) { return; } - if (response.status === 403) { + return this._fetchRawJSON(req).then(res => { + if (!res) { return; } + if (res.status === 403) { this.fire('auth-error'); - this._cache['/accounts/self/detail'] = null; - } else if (response.ok) { - return this.getResponseObject(response); + this._cache.delete('/accounts/self/detail'); + } else if (res.ok) { + return this.getResponseObject(res); } - }).then(response => { - if (response) { - this._cache['/accounts/self/detail'] = response; + }).then(res => { + this._credentialCheck.checking = false; + if (res) { + this._cache.delete('/accounts/self/detail'); } - return response; + return res; + }).catch(err => { + this._credentialCheck.checking = false; }); }, getDefaultPreferences() { - return this._fetchSharedCacheURL('/config/server/preferences'); + return this._fetchSharedCacheURL({ + url: '/config/server/preferences', + reportUrlAsIs: true, + }); }, getPreferences() { return this.getLoggedIn().then(loggedIn => { if (loggedIn) { - return this._fetchSharedCacheURL('/accounts/self/preferences').then( - res => { - if (this._isNarrowScreen()) { - res.default_diff_view = DiffViewMode.UNIFIED; - } else { - res.default_diff_view = res.diff_view; - } - return Promise.resolve(res); - }); + const req = {url: '/accounts/self/preferences', reportUrlAsIs: true}; + return this._fetchSharedCacheURL(req).then(res => { + if (this._isNarrowScreen()) { + res.default_diff_view = DiffViewMode.UNIFIED; + } else { + res.default_diff_view = res.diff_view; + } + return Promise.resolve(res); + }); } return Promise.resolve({ @@ -686,61 +1151,84 @@ default_diff_view: this._isNarrowScreen() ? DiffViewMode.UNIFIED : DiffViewMode.SIDE_BY_SIDE, diff_view: 'SIDE_BY_SIDE', + size_bar_in_change_table: true, }); }); }, getWatchedProjects() { - return this._fetchSharedCacheURL('/accounts/self/watched.projects'); + return this._fetchSharedCacheURL({ + url: '/accounts/self/watched.projects', + reportUrlAsIs: true, + }); }, /** * @param {string} projects * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - saveWatchedProjects(projects, opt_errFn, opt_ctx) { - return this.send('POST', '/accounts/self/watched.projects', projects, - opt_errFn, opt_ctx) - .then(response => { - return this.getResponseObject(response); - }); + saveWatchedProjects(projects, opt_errFn) { + return this._send({ + method: 'POST', + url: '/accounts/self/watched.projects', + body: projects, + errFn: opt_errFn, + parseResponse: true, + reportUrlAsIs: true, + }); }, /** * @param {string} projects * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - deleteWatchedProjects(projects, opt_errFn, opt_ctx) { - return this.send('POST', '/accounts/self/watched.projects:delete', - projects, opt_errFn, opt_ctx); + deleteWatchedProjects(projects, opt_errFn) { + return this._send({ + method: 'POST', + url: '/accounts/self/watched.projects:delete', + body: projects, + errFn: opt_errFn, + reportUrlAsIs: true, + }); }, /** - * @param {string} url - * @param {function(?Response, string=)=} opt_errFn + * @param {Defs.FetchJSONRequest} req */ - _fetchSharedCacheURL(url, opt_errFn) { - if (this._sharedFetchPromises[url]) { - return this._sharedFetchPromises[url]; + _fetchSharedCacheURL(req) { + if (this._sharedFetchPromises[req.url]) { + return this._sharedFetchPromises[req.url]; } // TODO(andybons): Periodic cache invalidation. - if (this._cache[url] !== undefined) { - return Promise.resolve(this._cache[url]); + if (this._cache.has(req.url)) { + return Promise.resolve(this._cache.get(req.url)); } - this._sharedFetchPromises[url] = this.fetchJSON(url, opt_errFn) + this._sharedFetchPromises[req.url] = this._fetchJSON(req) .then(response => { if (response !== undefined) { - this._cache[url] = response; + this._cache.set(req.url, response); } - this._sharedFetchPromises[url] = undefined; + this._sharedFetchPromises[req.url] = undefined; return response; }).catch(err => { - this._sharedFetchPromises[url] = undefined; + this._sharedFetchPromises[req.url] = undefined; throw err; }); - return this._sharedFetchPromises[url]; + return this._sharedFetchPromises[req.url]; + }, + + /** + * @param {string} prefix + */ + _invalidateSharedFetchPromisesPrefix(prefix) { + const newObject = {}; + Object.entries(this._sharedFetchPromises).forEach(([key, value]) => { + if (!key.startsWith(prefix)) { + newObject[key] = value; + } + }); + this._sharedFetchPromises = newObject; + this._cache.invalidatePrefix(prefix); }, _isNarrowScreen() { @@ -753,8 +1241,8 @@ * @param {number|string=} opt_offset * @param {!Object=} opt_options * @return {?Array<!Object>|?Array<!Array<!Object>>} If opt_query is an - * array, fetchJSON will return an array of arrays of changeInfos. If it - * is unspecified or a string, fetchJSON will return an array of + * array, _fetchJSON will return an array of arrays of changeInfos. If it + * is unspecified or a string, _fetchJSON will return an array of * changeInfos. */ getChanges(opt_changesPerPage, opt_query, opt_offset, opt_options) { @@ -779,10 +1267,20 @@ this._maybeInsertInLookup(change); } }; - return this.fetchJSON('/changes/', null, null, params).then(response => { + const req = { + url: '/changes/', + params, + reportUrlAsIs: true, + }; + return this._fetchJSON(req).then(response => { // Response may be an array of changes OR an array of arrays of // changes. if (opt_query instanceof Array) { + // Normalize the response to look like a multi-query response + // when there is only one query. + if (opt_query.length === 1) { + response = [response]; + } for (const arr of response) { iterateOverChanges(arr); } @@ -823,18 +1321,27 @@ * @param {function()=} opt_cancelCondition */ getChangeDetail(changeNum, opt_errFn, opt_cancelCondition) { - const options = this.listChangesOptionsToHex( - this.ListChangesOption.ALL_COMMITS, - this.ListChangesOption.ALL_REVISIONS, - this.ListChangesOption.CHANGE_ACTIONS, - this.ListChangesOption.CURRENT_ACTIONS, - this.ListChangesOption.DOWNLOAD_COMMANDS, - this.ListChangesOption.SUBMITTABLE, - this.ListChangesOption.WEB_LINKS - ); - return this._getChangeDetail( - changeNum, options, opt_errFn, opt_cancelCondition) - .then(GrReviewerUpdatesParser.parse); + const options = [ + this.ListChangesOption.ALL_COMMITS, + this.ListChangesOption.ALL_REVISIONS, + this.ListChangesOption.CHANGE_ACTIONS, + this.ListChangesOption.CURRENT_ACTIONS, + this.ListChangesOption.DETAILED_LABELS, + this.ListChangesOption.DOWNLOAD_COMMANDS, + this.ListChangesOption.MESSAGES, + this.ListChangesOption.SUBMITTABLE, + this.ListChangesOption.WEB_LINKS, + this.ListChangesOption.SKIP_MERGEABLE, + ]; + return this.getConfig(false).then(config => { + if (config.receive && config.receive.enable_signed_push) { + options.push(this.ListChangesOption.PUSH_CERTIFICATES); + } + const optionsHex = this.listChangesOptionsToHex(...options); + return this._getChangeDetail( + changeNum, optionsHex, opt_errFn, opt_cancelCondition) + .then(GrReviewerUpdatesParser.parse); + }); }, /** @@ -844,7 +1351,9 @@ */ getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) { const params = this.listChangesOptionsToHex( - this.ListChangesOption.ALL_REVISIONS + this.ListChangesOption.ALL_COMMITS, + this.ListChangesOption.ALL_REVISIONS, + this.ListChangesOption.SKIP_MERGEABLE ); return this._getChangeDetail(changeNum, params, opt_errFn, opt_cancelCondition); @@ -855,44 +1364,44 @@ * @param {function(?Response, string=)=} opt_errFn * @param {function()=} opt_cancelCondition */ - _getChangeDetail(changeNum, params, opt_errFn, - opt_cancelCondition) { + _getChangeDetail(changeNum, params, opt_errFn, opt_cancelCondition) { return this.getChangeActionURL(changeNum, null, '/detail').then(url => { const urlWithParams = this._urlWithParams(url, params); - return this._fetchRawJSON( - url, - opt_errFn, - opt_cancelCondition, - {O: params}, - this._etags.getOptions(urlWithParams)) - .then(response => { - if (response && response.status === 304) { - return Promise.resolve(this._parsePrefixedJSON( - this._etags.getCachedPayload(urlWithParams))); - } - - if (response && !response.ok) { - if (opt_errFn) { - opt_errFn.call(null, response); - } else { - this.fire('server-error', {response}); - } - return; - } + const req = { + url, + errFn: opt_errFn, + cancelCondition: opt_cancelCondition, + params: {O: params}, + fetchOptions: this._etags.getOptions(urlWithParams), + anonymizedUrl: '/changes/*~*/detail?O=' + params, + }; + return this._fetchRawJSON(req).then(response => { + if (response && response.status === 304) { + return Promise.resolve(this._parsePrefixedJSON( + this._etags.getCachedPayload(urlWithParams))); + } - const payloadPromise = response ? - this._readResponsePayload(response) : - Promise.resolve(null); + if (response && !response.ok) { + if (opt_errFn) { + opt_errFn.call(null, response); + } else { + this.fire('server-error', {request: req, response}); + } + return; + } - return payloadPromise.then(payload => { - if (!payload) { return null; } + const payloadPromise = response ? + this._readResponsePayload(response) : + Promise.resolve(null); - this._etags.collect(urlWithParams, response, payload.raw); - this._maybeInsertInLookup(payload); + return payloadPromise.then(payload => { + if (!payload) { return null; } + this._etags.collect(urlWithParams, response, payload.raw); + this._maybeInsertInLookup(payload.parsed); - return payload.parsed; - }); - }); + return payload.parsed; + }); + }); }); }, @@ -901,42 +1410,79 @@ * @param {number|string} patchNum */ getChangeCommitInfo(changeNum, patchNum) { - return this._getChangeURLAndFetch(changeNum, '/commit?links', patchNum); + return this._getChangeURLAndFetch({ + changeNum, + endpoint: '/commit?links', + patchNum, + reportEndpointAsIs: true, + }); }, /** * @param {number|string} changeNum - * @param {!Promise<?Object>} patchRange + * @param {Defs.patchRange} patchRange + * @param {number=} opt_parentIndex */ - getChangeFiles(changeNum, patchRange) { - let endpoint = '/files'; - if (patchRange.basePatchNum !== 'PARENT') { - endpoint += '?base=' + encodeURIComponent(patchRange.basePatchNum); + getChangeFiles(changeNum, patchRange, opt_parentIndex) { + let params = undefined; + if (this.isMergeParent(patchRange.basePatchNum)) { + params = {parent: this.getParentIndex(patchRange.basePatchNum)}; + } else if (!this.patchNumEquals(patchRange.basePatchNum, 'PARENT')) { + params = {base: patchRange.basePatchNum}; } - return this._getChangeURLAndFetch(changeNum, endpoint, - patchRange.patchNum); + return this._getChangeURLAndFetch({ + changeNum, + endpoint: '/files', + patchNum: patchRange.patchNum, + params, + reportEndpointAsIs: true, + }); }, /** * @param {number|string} changeNum - * @param {!Promise<?Object>} patchRange + * @param {Defs.patchRange} patchRange */ getChangeEditFiles(changeNum, patchRange) { let endpoint = '/edit?list'; + let anonymizedEndpoint = endpoint; if (patchRange.basePatchNum !== 'PARENT') { - endpoint += '?base=' + encodeURIComponent(patchRange.basePatchNum); + endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + ''); + anonymizedEndpoint += '&base=*'; } - return this._getChangeURLAndFetch(changeNum, endpoint); + return this._getChangeURLAndFetch({ + changeNum, + endpoint, + anonymizedEndpoint, + }); }, - getChangeFilesAsSpeciallySortedArray(changeNum, patchRange) { - return this.getChangeFiles(changeNum, patchRange).then( - this._normalizeChangeFilesResponse.bind(this)); + /** + * @param {number|string} changeNum + * @param {number|string} patchNum + * @param {string} query + * @return {!Promise<!Object>} + */ + queryChangeFiles(changeNum, patchNum, query) { + return this._getChangeURLAndFetch({ + changeNum, + endpoint: `/files?q=${encodeURIComponent(query)}`, + patchNum, + anonymizedEndpoint: '/files?q=*', + }); }, - getChangeEditFilesAsSpeciallySortedArray(changeNum, patchRange) { - return this.getChangeEditFiles(changeNum, patchRange).then(files => - this._normalizeChangeFilesResponse(files.files)); + /** + * @param {number|string} changeNum + * @param {Defs.patchRange} patchRange + * @return {!Promise<!Array<!Object>>} + */ + getChangeOrEditFiles(changeNum, patchRange) { + if (this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME)) { + return this.getChangeEditFiles(changeNum, patchRange).then(res => + res.files); + } + return this.getChangeFiles(changeNum, patchRange); }, /** @@ -950,36 +1496,22 @@ }); }, - /** - * The closure compiler doesn't realize this.specialFilePathCompare is - * valid. - * @suppress {checkTypes} - */ - _normalizeChangeFilesResponse(response) { - if (!response) { return []; } - const paths = Object.keys(response).sort(this.specialFilePathCompare); - const files = []; - for (let i = 0; i < paths.length; i++) { - const 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(changeNum, patchNum) { - return this._getChangeURLAndFetch(changeNum, '/actions', patchNum) - .then(revisionActions => { - // The rebase button on change screen is always enabled. - if (revisionActions.rebase) { - revisionActions.rebase.rebaseOnCurrent = - !!revisionActions.rebase.enabled; - revisionActions.rebase.enabled = true; - } - return revisionActions; - }); + const req = { + changeNum, + endpoint: '/actions', + patchNum, + reportEndpointAsIs: true, + }; + return this._getChangeURLAndFetch(req).then(revisionActions => { + // The rebase button on change screen is always enabled. + if (revisionActions.rebase) { + revisionActions.rebase.rebaseOnCurrent = + !!revisionActions.rebase.enabled; + revisionActions.rebase.enabled = true; + } + return revisionActions; + }); }, /** @@ -990,15 +1522,24 @@ getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) { const params = {n: 10}; if (inputVal) { params.q = inputVal; } - return this._getChangeURLAndFetch(changeNum, '/suggest_reviewers', null, - opt_errFn, null, params); + return this._getChangeURLAndFetch({ + changeNum, + endpoint: '/suggest_reviewers', + errFn: opt_errFn, + params, + reportEndpointAsIs: true, + }); }, /** * @param {number|string} changeNum */ getChangeIncludedIn(changeNum) { - return this._getChangeURLAndFetch(changeNum, '/in', null); + return this._getChangeURLAndFetch({ + changeNum, + endpoint: '/in', + reportEndpointAsIs: true, + }); }, _computeFilter(filter) { @@ -1016,138 +1557,251 @@ * @param {string} filter * @param {number} groupsPerPage * @param {number=} opt_offset - * @return {!Promise<?Object>} */ - getGroups(filter, groupsPerPage, opt_offset) { + _getGroupsUrl(filter, groupsPerPage, opt_offset) { const offset = opt_offset || 0; - return this._fetchSharedCacheURL( - `/groups/?n=${groupsPerPage + 1}&S=${offset}` + - this._computeFilter(filter) - ); + return `/groups/?n=${groupsPerPage + 1}&S=${offset}` + + this._computeFilter(filter); }, /** * @param {string} filter - * @param {number} projectsPerPage + * @param {number} reposPerPage * @param {number=} opt_offset - * @return {!Promise<?Object>} */ - getProjects(filter, projectsPerPage, opt_offset) { + _getReposUrl(filter, reposPerPage, opt_offset) { + const defaultFilter = 'state:active OR state:read-only'; + const namePartDelimiters = /[@.\-\s\/_]/g; const offset = opt_offset || 0; - return this._fetchSharedCacheURL( - `/projects/?d&n=${projectsPerPage + 1}&S=${offset}` + - this._computeFilter(filter) - ); + if (filter && !filter.includes(':') && filter.match(namePartDelimiters)) { + // The query language specifies hyphens as operators. Split the string + // by hyphens and 'AND' the parts together as 'inname:' queries. + // If the filter includes a semicolon, the user is using a more complex + // query so we trust them and don't do any magic under the hood. + const originalFilter = filter; + filter = ''; + originalFilter.split(namePartDelimiters).forEach(part => { + if (part) { + filter += (filter === '' ? 'inname:' : ' AND inname:') + part; + } + }); + } + // Check if filter is now empty which could be either because the user did + // not provide it or because the user provided only a split character. + if (!filter) { + filter = defaultFilter; + } + + filter = filter.trim(); + const encodedFilter = encodeURIComponent(filter); + + return `/projects/?n=${reposPerPage + 1}&S=${offset}` + + `&query=${encodedFilter}`; }, - setProjectHead(project, ref) { - return this.send( - 'PUT', `/projects/${encodeURIComponent(project)}/HEAD`, {ref}); + invalidateGroupsCache() { + this._invalidateSharedFetchPromisesPrefix('/groups/?'); + }, + + invalidateReposCache() { + this._invalidateSharedFetchPromisesPrefix('/projects/?'); }, /** * @param {string} filter - * @param {string} project - * @param {number} projectsBranchesPerPage + * @param {number} groupsPerPage * @param {number=} opt_offset * @return {!Promise<?Object>} */ - getProjectBranches(filter, project, projectsBranchesPerPage, opt_offset) { - const offset = opt_offset || 0; + getGroups(filter, groupsPerPage, opt_offset) { + const url = this._getGroupsUrl(filter, groupsPerPage, opt_offset); - return this.fetchJSON( - `/projects/${encodeURIComponent(project)}/branches` + - `?n=${projectsBranchesPerPage + 1}&S=${offset}` + - this._computeFilter(filter) - ); + return this._fetchSharedCacheURL({ + url, + anonymizedUrl: '/groups/?*', + }); }, /** * @param {string} filter - * @param {string} project - * @param {number} projectsTagsPerPage + * @param {number} reposPerPage + * @param {number=} opt_offset + * @return {!Promise<?Object>} + */ + getRepos(filter, reposPerPage, opt_offset) { + const url = this._getReposUrl(filter, reposPerPage, opt_offset); + + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + return this._fetchSharedCacheURL({ + url, + anonymizedUrl: '/projects/?*', + }); + }, + + setRepoHead(repo, ref) { + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + return this._send({ + method: 'PUT', + url: `/projects/${encodeURIComponent(repo)}/HEAD`, + body: {ref}, + anonymizedUrl: '/projects/*/HEAD', + }); + }, + + /** + * @param {string} filter + * @param {string} repo + * @param {number} reposBranchesPerPage * @param {number=} opt_offset + * @param {?function(?Response, string=)=} opt_errFn * @return {!Promise<?Object>} */ - getProjectTags(filter, project, projectsTagsPerPage, opt_offset) { + getRepoBranches(filter, repo, reposBranchesPerPage, opt_offset, opt_errFn) { const offset = opt_offset || 0; + const count = reposBranchesPerPage + 1; + filter = this._computeFilter(filter); + repo = encodeURIComponent(repo); + const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`; + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + return this._fetchJSON({ + url, + errFn: opt_errFn, + anonymizedUrl: '/projects/*/branches?*', + }); + }, - return this.fetchJSON( - `/projects/${encodeURIComponent(project)}/tags` + - `?n=${projectsTagsPerPage + 1}&S=${offset}` + - this._computeFilter(filter) - ); + /** + * @param {string} filter + * @param {string} repo + * @param {number} reposTagsPerPage + * @param {number=} opt_offset + * @param {?function(?Response, string=)=} opt_errFn + * @return {!Promise<?Object>} + */ + getRepoTags(filter, repo, reposTagsPerPage, opt_offset, opt_errFn) { + const offset = opt_offset || 0; + const encodedRepo = encodeURIComponent(repo); + const n = reposTagsPerPage + 1; + const encodedFilter = this._computeFilter(filter); + const url = `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` + + encodedFilter; + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + return this._fetchJSON({ + url, + errFn: opt_errFn, + anonymizedUrl: '/projects/*/tags', + }); }, /** * @param {string} filter * @param {number} pluginsPerPage * @param {number=} opt_offset + * @param {?function(?Response, string=)=} opt_errFn * @return {!Promise<?Object>} */ - getPlugins(filter, pluginsPerPage, opt_offset) { + getPlugins(filter, pluginsPerPage, opt_offset, opt_errFn) { const offset = opt_offset || 0; + const encodedFilter = this._computeFilter(filter); + const n = pluginsPerPage + 1; + const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`; + return this._fetchJSON({ + url, + errFn: opt_errFn, + anonymizedUrl: '/plugins/?all', + }); + }, - return this.fetchJSON( - `/plugins/?all&n=${pluginsPerPage + 1}&S=${offset}` + - this._computeFilter(filter) - ); + getRepoAccessRights(repoName, opt_errFn) { + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + return this._fetchJSON({ + url: `/projects/${encodeURIComponent(repoName)}/access`, + errFn: opt_errFn, + anonymizedUrl: '/projects/*/access', + }); }, - getProjectAccessRights(projectName) { - return this._fetchSharedCacheURL( - `/projects/${encodeURIComponent(projectName)}/access`); + setRepoAccessRights(repoName, repoInfo) { + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + return this._send({ + method: 'POST', + url: `/projects/${encodeURIComponent(repoName)}/access`, + body: repoInfo, + anonymizedUrl: '/projects/*/access', + }); }, - setProjectAccessRights(projectName, projectInfo) { - return this.send( - 'POST', `/projects/${encodeURIComponent(projectName)}/access`, - projectInfo); + setRepoAccessRightsForReview(projectName, projectInfo) { + return this._send({ + method: 'PUT', + url: `/projects/${encodeURIComponent(projectName)}/access:review`, + body: projectInfo, + parseResponse: true, + anonymizedUrl: '/projects/*/access:review', + }); }, /** * @param {string} inputVal * @param {number} opt_n * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - getSuggestedGroups(inputVal, opt_n, opt_errFn, opt_ctx) { + getSuggestedGroups(inputVal, opt_n, opt_errFn) { const params = {s: inputVal}; if (opt_n) { params.n = opt_n; } - return this.fetchJSON('/groups/', opt_errFn, opt_ctx, params); + return this._fetchJSON({ + url: '/groups/', + errFn: opt_errFn, + params, + reportUrlAsIs: true, + }); }, /** * @param {string} inputVal * @param {number} opt_n * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - getSuggestedProjects(inputVal, opt_n, opt_errFn, opt_ctx) { + getSuggestedProjects(inputVal, opt_n, opt_errFn) { const params = { m: inputVal, n: MAX_PROJECT_RESULTS, type: 'ALL', }; if (opt_n) { params.n = opt_n; } - return this.fetchJSON('/projects/', opt_errFn, opt_ctx, params); + return this._fetchJSON({ + url: '/projects/', + errFn: opt_errFn, + params, + reportUrlAsIs: true, + }); }, /** * @param {string} inputVal * @param {number} opt_n * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - getSuggestedAccounts(inputVal, opt_n, opt_errFn, opt_ctx) { + getSuggestedAccounts(inputVal, opt_n, opt_errFn) { if (!inputVal) { return Promise.resolve([]); } const params = {suggest: null, q: inputVal}; if (opt_n) { params.n = opt_n; } - return this.fetchJSON('/accounts/', opt_errFn, opt_ctx, params); + return this._fetchJSON({ + url: '/accounts/', + errFn: opt_errFn, + params, + anonymizedUrl: '/accounts/?n=*', + }); }, addChangeReviewer(changeNum, reviewerID) { @@ -1173,16 +1827,25 @@ throw Error('Unsupported HTTP method: ' + method); } - return this.send(method, url, body); + return this._send({method, url, body}); }); }, getRelatedChanges(changeNum, patchNum) { - return this._getChangeURLAndFetch(changeNum, '/related', patchNum); + return this._getChangeURLAndFetch({ + changeNum, + endpoint: '/related', + patchNum, + reportEndpointAsIs: true, + }); }, getChangesSubmittedTogether(changeNum) { - return this._getChangeURLAndFetch(changeNum, '/submitted_together', null); + return this._getChangeURLAndFetch({ + changeNum, + endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES', + reportEndpointAsIs: true, + }); }, getChangeConflicts(changeNum) { @@ -1194,7 +1857,11 @@ O: options, q: 'status:open is:mergeable conflicts:' + changeNum, }; - return this.fetchJSON('/changes/', null, null, params); + return this._fetchJSON({ + url: '/changes/', + params, + anonymizedUrl: '/changes/conflicts:*', + }); }, getChangeCherryPicks(project, changeID, changeNum) { @@ -1212,7 +1879,11 @@ O: options, q: query, }; - return this.fetchJSON('/changes/', null, null, params); + return this._fetchJSON({ + url: '/changes/', + params, + anonymizedUrl: '/changes/change:*', + }); }, getChangesWithSameTopic(topic, changeNum) { @@ -1225,17 +1896,26 @@ const query = [ 'status:open', '-change:' + changeNum, - 'topic:' + topic, + `topic:"${topic}"`, ].join(' '); const params = { O: options, q: query, }; - return this.fetchJSON('/changes/', null, null, params); + return this._fetchJSON({ + url: '/changes/', + params, + anonymizedUrl: '/changes/topic:*', + }); }, getReviewedFiles(changeNum, patchNum) { - return this._getChangeURLAndFetch(changeNum, '/files?reviewed', patchNum); + return this._getChangeURLAndFetch({ + changeNum, + endpoint: '/files?reviewed', + patchNum, + reportEndpointAsIs: true, + }); }, /** @@ -1244,13 +1924,16 @@ * @param {string} path * @param {boolean} reviewed * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - saveFileReviewed(changeNum, patchNum, path, reviewed, opt_errFn, opt_ctx) { - const method = reviewed ? 'PUT' : 'DELETE'; - const e = `/files/${encodeURIComponent(path)}/reviewed`; - return this.getChangeURLAndSend(changeNum, method, patchNum, e, null, - opt_errFn, opt_ctx); + saveFileReviewed(changeNum, patchNum, path, reviewed, opt_errFn) { + return this._getChangeURLAndSend({ + changeNum, + method: reviewed ? 'PUT' : 'DELETE', + patchNum, + endpoint: `/files/${encodeURIComponent(path)}/reviewed`, + errFn: opt_errFn, + anonymizedEndpoint: '/files/*/reviewed', + }); }, /** @@ -1258,165 +1941,346 @@ * @param {number|string} patchNum * @param {!Object} review * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx */ - saveChangeReview(changeNum, patchNum, review, opt_errFn, opt_ctx) { + saveChangeReview(changeNum, patchNum, review, opt_errFn) { const promises = [ this.awaitPendingDiffDrafts(), this.getChangeActionURL(changeNum, patchNum, '/review'), ]; return Promise.all(promises).then(([, url]) => { - return this.send('POST', url, review, opt_errFn, opt_ctx); + return this._send({ + method: 'POST', + url, + body: review, + errFn: opt_errFn, + }); }); }, getChangeEdit(changeNum, opt_download_commands) { const params = opt_download_commands ? {'download-commands': true} : null; return this.getLoggedIn().then(loggedIn => { - return loggedIn ? - this._getChangeURLAndFetch(changeNum, '/edit/', null, null, null, - params) : - false; + if (!loggedIn) { return false; } + return this._getChangeURLAndFetch({ + changeNum, + endpoint: '/edit/', + params, + reportEndpointAsIs: true, + }); }); }, /** - * @param {!string} project - * @param {!string} branch - * @param {!string} subject - * @param {!string} topic - * @param {!boolean} isPrivate - * @param {!boolean} workInProgress + * @param {string} project + * @param {string} branch + * @param {string} subject + * @param {string=} opt_topic + * @param {boolean=} opt_isPrivate + * @param {boolean=} opt_workInProgress + * @param {string=} opt_baseChange + * @param {string=} opt_baseCommit + */ + createChange(project, branch, subject, opt_topic, opt_isPrivate, + opt_workInProgress, opt_baseChange, opt_baseCommit) { + return this._send({ + method: 'POST', + url: '/changes/', + body: { + project, + branch, + subject, + topic: opt_topic, + is_private: opt_isPrivate, + work_in_progress: opt_workInProgress, + base_change: opt_baseChange, + base_commit: opt_baseCommit, + }, + parseResponse: true, + reportUrlAsIs: true, + }); + }, + + /** + * @param {number|string} changeNum + * @param {string} path + * @param {number|string} patchNum + */ + getFileContent(changeNum, path, patchNum) { + // 404s indicate the file does not exist yet in the revision, so suppress + // them. + const suppress404s = res => { + if (res && res.status !== 404) { this.fire('server-error', {res}); } + return res; + }; + const promise = this.patchNumEquals(patchNum, this.EDIT_NAME) ? + this._getFileInChangeEdit(changeNum, path) : + this._getFileInRevision(changeNum, path, patchNum, suppress404s); + + return promise.then(res => { + if (!res.ok) { return res; } + + // The file type (used for syntax highlighting) is identified in the + // X-FYI-Content-Type header of the response. + const type = res.headers.get('X-FYI-Content-Type'); + return this.getResponseObject(res).then(content => { + return {content, type, ok: true}; + }); + }); + }, + + /** + * Gets a file in a specific change and revision. + * @param {number|string} changeNum + * @param {string} path + * @param {number|string} patchNum + * @param {?function(?Response, string=)=} opt_errFn */ - createChange(project, branch, subject, topic, isPrivate, - workInProgress) { - return this.send('POST', '/changes/', - {project, branch, subject, topic, is_private: isPrivate, - work_in_progress: workInProgress}) - .then(response => this.getResponseObject(response)); + _getFileInRevision(changeNum, path, patchNum, opt_errFn) { + return this._getChangeURLAndSend({ + changeNum, + method: 'GET', + patchNum, + endpoint: `/files/${encodeURIComponent(path)}/content`, + errFn: opt_errFn, + headers: {Accept: 'application/json'}, + anonymizedEndpoint: '/files/*/content', + }); }, - getFileInChangeEdit(changeNum, path) { - const e = '/edit/' + encodeURIComponent(path); - return this.getChangeURLAndSend(changeNum, 'GET', null, e); + /** + * Gets a file in a change edit. + * @param {number|string} changeNum + * @param {string} path + */ + _getFileInChangeEdit(changeNum, path) { + return this._getChangeURLAndSend({ + changeNum, + method: 'GET', + endpoint: '/edit/' + encodeURIComponent(path), + headers: {Accept: 'application/json'}, + anonymizedEndpoint: '/edit/*', + }); }, rebaseChangeEdit(changeNum) { - return this.getChangeURLAndSend(changeNum, 'POST', null, '/edit:rebase'); + return this._getChangeURLAndSend({ + changeNum, + method: 'POST', + endpoint: '/edit:rebase', + reportEndpointAsIs: true, + }); }, deleteChangeEdit(changeNum) { - return this.getChangeURLAndSend(changeNum, 'DELETE', null, '/edit'); + return this._getChangeURLAndSend({ + changeNum, + method: 'DELETE', + endpoint: '/edit', + reportEndpointAsIs: true, + }); }, restoreFileInChangeEdit(changeNum, restore_path) { - const p = {restore_path}; - return this.getChangeURLAndSend(changeNum, 'POST', null, '/edit', p); + return this._getChangeURLAndSend({ + changeNum, + method: 'POST', + endpoint: '/edit', + body: {restore_path}, + reportEndpointAsIs: true, + }); }, renameFileInChangeEdit(changeNum, old_path, new_path) { - const p = {old_path, new_path}; - return this.getChangeURLAndSend(changeNum, 'POST', null, '/edit', p); + return this._getChangeURLAndSend({ + changeNum, + method: 'POST', + endpoint: '/edit', + body: {old_path, new_path}, + reportEndpointAsIs: true, + }); }, deleteFileInChangeEdit(changeNum, path) { - const e = '/edit/' + encodeURIComponent(path); - return this.getChangeURLAndSend(changeNum, 'DELETE', null, e); + return this._getChangeURLAndSend({ + changeNum, + method: 'DELETE', + endpoint: '/edit/' + encodeURIComponent(path), + anonymizedEndpoint: '/edit/*', + }); }, saveChangeEdit(changeNum, path, contents) { - const e = '/edit/' + encodeURIComponent(path); - return this.getChangeURLAndSend(changeNum, 'PUT', null, e, contents); + return this._getChangeURLAndSend({ + changeNum, + method: 'PUT', + endpoint: '/edit/' + encodeURIComponent(path), + body: contents, + contentType: 'text/plain', + anonymizedEndpoint: '/edit/*', + }); }, // Deprecated, prefer to use putChangeCommitMessage instead. saveChangeCommitMessageEdit(changeNum, message) { - const p = {message}; - return this.getChangeURLAndSend(changeNum, 'PUT', null, '/edit:message', - p); + return this._getChangeURLAndSend({ + changeNum, + method: 'PUT', + endpoint: '/edit:message', + body: {message}, + reportEndpointAsIs: true, + }); }, publishChangeEdit(changeNum) { - return this.getChangeURLAndSend(changeNum, 'POST', null, - '/edit:publish'); + return this._getChangeURLAndSend({ + changeNum, + method: 'POST', + endpoint: '/edit:publish', + reportEndpointAsIs: true, + }); }, putChangeCommitMessage(changeNum, message) { - const p = {message}; - return this.getChangeURLAndSend(changeNum, 'PUT', null, '/message', p); + return this._getChangeURLAndSend({ + changeNum, + method: 'PUT', + endpoint: '/message', + body: {message}, + reportEndpointAsIs: true, + }); }, saveChangeStarred(changeNum, starred) { - const url = '/accounts/self/starred.changes/' + changeNum; - const method = starred ? 'PUT' : 'DELETE'; - return this.send(method, url); + // Some servers may require the project name to be provided + // alongside the change number, so resolve the project name + // first. + return this.getFromProjectLookup(changeNum).then(project => { + const url = '/accounts/self/starred.changes/' + + (project ? encodeURIComponent(project) + '~' : '') + changeNum; + return this._send({ + method: starred ? 'PUT' : 'DELETE', + url, + anonymizedUrl: '/accounts/self/starred.changes/*', + }); + }); + }, + + saveChangeReviewed(changeNum, reviewed) { + return this._getChangeURLAndSend({ + changeNum, + method: 'PUT', + endpoint: reviewed ? '/reviewed' : '/unreviewed', + }); }, /** - * @param {string} method - * @param {string} url - * @param {?string|number|Object=} opt_body passed as null sometimes - * and also apparently a number. TODO (beckysiegel) remove need for - * number at least. - * @param {?function(?Response, string=)=} opt_errFn - * passed as null sometimes. - * @param {?=} opt_ctx - * @param {?string=} opt_contentType + * Send an XHR. + * @param {Defs.SendRequest} req + * @return {Promise} */ - send(method, url, opt_body, opt_errFn, opt_ctx, opt_contentType) { - const options = {method}; - if (opt_body) { + _send(req) { + const options = {method: req.method}; + if (req.body) { options.headers = new Headers(); options.headers.set( - 'Content-Type', opt_contentType || 'application/json'); - if (typeof opt_body !== 'string') { - opt_body = JSON.stringify(opt_body); - } - options.body = opt_body; + 'Content-Type', req.contentType || 'application/json'); + options.body = typeof req.body === 'string' ? + req.body : JSON.stringify(req.body); } - if (!url.startsWith('http')) { - url = this.getBaseUrl() + url; + if (req.headers) { + if (!options.headers) { options.headers = new Headers(); } + for (const header in req.headers) { + if (!req.headers.hasOwnProperty(header)) { continue; } + options.headers.set(header, req.headers[header]); + } } - return this._auth.fetch(url, options).then(response => { + const url = req.url.startsWith('http') ? + req.url : this.getBaseUrl() + req.url; + const fetchReq = { + url, + fetchOptions: options, + anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl, + }; + const xhr = this._fetch(fetchReq).then(response => { if (!response.ok) { - if (opt_errFn) { - return opt_errFn.call(opt_ctx || null, response); + if (req.errFn) { + return req.errFn.call(undefined, response); } - this.fire('server-error', {response}); + this.fire('server-error', {request: fetchReq, response}); } return response; }).catch(err => { this.fire('network-error', {error: err}); - if (opt_errFn) { - return opt_errFn.call(opt_ctx, null, err); + if (req.errFn) { + return req.errFn.call(undefined, null, err); } else { throw err; } }); + + if (req.parseResponse) { + return xhr.then(res => this.getResponseObject(res)); + } + + return xhr; + }, + + /** + * Public version of the _send method preserved for plugins. + * @param {string} method + * @param {string} url + * @param {?string|number|Object=} opt_body passed as null sometimes + * and also apparently a number. TODO (beckysiegel) remove need for + * number at least. + * @param {?function(?Response, string=)=} opt_errFn + * passed as null sometimes. + * @param {?string=} opt_contentType + * @param {Object=} opt_headers + */ + send(method, url, opt_body, opt_errFn, opt_contentType, + opt_headers) { + return this._send({ + method, + url, + body: opt_body, + errFn: opt_errFn, + contentType: opt_contentType, + headers: opt_headers, + }); }, /** * @param {number|string} changeNum - * @param {number|string} basePatchNum + * @param {number|string} basePatchNum Negative values specify merge parent + * index. * @param {number|string} patchNum * @param {string} path + * @param {string=} opt_whitespace the ignore-whitespace level for the diff + * algorithm. * @param {function(?Response, string=)=} opt_errFn - * @param {function()=} opt_cancelCondition */ - getDiff(changeNum, basePatchNum, patchNum, path, - opt_errFn, opt_cancelCondition) { + getDiff(changeNum, basePatchNum, patchNum, path, opt_whitespace, + opt_errFn) { const params = { context: 'ALL', intraline: null, - whitespace: 'IGNORE_NONE', + whitespace: opt_whitespace || 'IGNORE_NONE', }; - if (basePatchNum != PARENT_PATCH_NUM) { + if (this.isMergeParent(basePatchNum)) { + params.parent = this.getParentIndex(basePatchNum); + } else if (!this.patchNumEquals(basePatchNum, PARENT_PATCH_NUM)) { params.base = basePatchNum; } const endpoint = `/files/${encodeURIComponent(path)}/diff`; - return this._getChangeURLAndFetch(changeNum, endpoint, patchNum, - opt_errFn, opt_cancelCondition, params); + return this._getChangeURLAndFetch({ + changeNum, + endpoint, + patchNum, + errFn: opt_errFn, + params, + anonymizedEndpoint: '/files/*/diff', + }); }, /** @@ -1424,15 +2288,23 @@ * @param {number|string=} opt_basePatchNum * @param {number|string=} opt_patchNum * @param {string=} opt_path + * @return {!Promise<!Object>} */ getDiffComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) { return this._getDiffComments(changeNum, '/comments', opt_basePatchNum, opt_patchNum, opt_path); }, - getDiffRobotComments(changeNum, basePatchNum, patchNum, opt_path) { - return this._getDiffComments(changeNum, '/robotcomments', basePatchNum, - patchNum, opt_path); + /** + * @param {number|string} changeNum + * @param {number|string=} opt_basePatchNum + * @param {number|string=} opt_patchNum + * @param {string=} opt_path + * @return {!Promise<!Object>} + */ + getDiffRobotComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) { + return this._getDiffComments(changeNum, '/robotcomments', + opt_basePatchNum, opt_patchNum, opt_path); }, /** @@ -1444,7 +2316,7 @@ * @param {number|string=} opt_basePatchNum * @param {number|string=} opt_patchNum * @param {string=} opt_path - * @return {!Promise<?Object>} + * @return {!Promise<!Object>} */ getDiffDrafts(changeNum, opt_basePatchNum, opt_patchNum, opt_path) { return this.getLoggedIn().then(loggedIn => { @@ -1483,6 +2355,7 @@ * @param {number|string=} opt_basePatchNum * @param {number|string=} opt_patchNum * @param {string=} opt_path + * @return {!Promise<!Object>} */ _getDiffComments(changeNum, endpoint, opt_basePatchNum, opt_patchNum, opt_path) { @@ -1491,10 +2364,15 @@ * Helper function to make promises more legible. * * @param {string|number=} opt_patchNum - * @return {!Object} Diff comments response. + * @return {!Promise<!Object>} Diff comments response. */ const fetchComments = opt_patchNum => { - return this._getChangeURLAndFetch(changeNum, endpoint, opt_patchNum); + return this._getChangeURLAndFetch({ + changeNum, + endpoint, + patchNum: opt_patchNum, + reportEndpointAsIs: true, + }); }; if (!opt_basePatchNum && !opt_patchNum && !opt_path) { @@ -1584,8 +2462,10 @@ _sendDiffDraftRequest(method, changeNum, patchNum, draft) { const isCreate = !draft.id && method === 'PUT'; let endpoint = '/drafts'; + let anonymizedEndpoint = endpoint; if (draft.id) { endpoint += '/' + draft.id; + anonymizedEndpoint += '/*'; } let body; if (method === 'PUT') { @@ -1596,8 +2476,16 @@ this._pendingRequests[Requests.SEND_DIFF_DRAFT] = []; } - const promise = this.getChangeURLAndSend(changeNum, method, patchNum, - endpoint, body); + const req = { + changeNum, + method, + patchNum, + endpoint, + body, + anonymizedEndpoint, + }; + + const promise = this._getChangeURLAndSend(req); this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise); if (isCreate) { @@ -1608,13 +2496,15 @@ }, getCommitInfo(project, commit) { - return this.fetchJSON( - '/projects/' + encodeURIComponent(project) + - '/commits/' + encodeURIComponent(commit)); + return this._fetchJSON({ + url: '/projects/' + encodeURIComponent(project) + + '/commits/' + encodeURIComponent(commit), + anonymizedUrl: '/projects/*/comments/*', + }); }, _fetchB64File(url) { - return this._auth.fetch(this.getBaseUrl() + url) + return this._fetch({url: this.getBaseUrl() + url}) .then(response => { if (!response.ok) { return Promise.reject(response.statusText); } const type = response.headers.get('X-FYI-Content-Type'); @@ -1631,7 +2521,7 @@ * @param {string} path * @param {number=} opt_parentIndex */ - getChangeFileContents(changeId, patchNum, path, opt_parentIndex) { + getB64FileContents(changeId, patchNum, path, opt_parentIndex) { const parent = typeof opt_parentIndex === 'number' ? '?parent=' + opt_parentIndex : ''; return this._changeBaseURL(changeId, patchNum).then(url => { @@ -1647,10 +2537,10 @@ if (diff.meta_a && diff.meta_a.content_type.startsWith('image/')) { if (patchRange.basePatchNum === 'PARENT') { // Note: we only attempt to get the image from the first parent. - promiseA = this.getChangeFileContents(changeNum, patchRange.patchNum, + promiseA = this.getB64FileContents(changeNum, patchRange.patchNum, diff.meta_a.name, 1); } else { - promiseA = this.getChangeFileContents(changeNum, + promiseA = this.getB64FileContents(changeNum, patchRange.basePatchNum, diff.meta_a.name); } } else { @@ -1658,7 +2548,7 @@ } if (diff.meta_b && diff.meta_b.content_type.startsWith('image/')) { - promiseB = this.getChangeFileContents(changeNum, patchRange.patchNum, + promiseB = this.getB64FileContents(changeNum, patchRange.patchNum, diff.meta_b.name); } else { promiseB = Promise.resolve(null); @@ -1709,9 +2599,14 @@ * parameter. */ setChangeTopic(changeNum, topic) { - const p = {topic}; - return this.getChangeURLAndSend(changeNum, 'PUT', null, '/topic', p) - .then(this.getResponseObject.bind(this)); + return this._getChangeURLAndSend({ + changeNum, + method: 'PUT', + endpoint: '/topic', + body: {topic}, + parseResponse: true, + reportUrlAsIs: true, + }); }, /** @@ -1720,12 +2615,22 @@ * parameter. */ setChangeHashtag(changeNum, hashtag) { - return this.getChangeURLAndSend(changeNum, 'POST', null, '/hashtags', - hashtag).then(this.getResponseObject.bind(this)); + return this._getChangeURLAndSend({ + changeNum, + method: 'POST', + endpoint: '/hashtags', + body: hashtag, + parseResponse: true, + reportUrlAsIs: true, + }); }, deleteAccountHttpPassword() { - return this.send('DELETE', '/accounts/self/password.http'); + return this._send({ + method: 'DELETE', + url: '/accounts/self/password.http', + reportUrlAsIs: true, + }); }, /** @@ -1734,17 +2639,31 @@ * parameter. */ generateAccountHttpPassword() { - return this.send('PUT', '/accounts/self/password.http', {generate: true}) - .then(this.getResponseObject.bind(this)); + return this._send({ + method: 'PUT', + url: '/accounts/self/password.http', + body: {generate: true}, + parseResponse: true, + reportUrlAsIs: true, + }); }, getAccountSSHKeys() { - return this._fetchSharedCacheURL('/accounts/self/sshkeys'); + return this._fetchSharedCacheURL({ + url: '/accounts/self/sshkeys', + reportUrlAsIs: true, + }); }, addAccountSSHKey(key) { - return this.send('POST', '/accounts/self/sshkeys', key, null, null, - 'plain/text') + const req = { + method: 'POST', + url: '/accounts/self/sshkeys', + body: key, + contentType: 'plain/text', + reportUrlAsIs: true, + }; + return this._send(req) .then(response => { if (response.status < 200 && response.status >= 300) { return Promise.reject(); @@ -1758,41 +2677,115 @@ }, deleteAccountSSHKey(id) { - return this.send('DELETE', '/accounts/self/sshkeys/' + id); + return this._send({ + method: 'DELETE', + url: '/accounts/self/sshkeys/' + id, + anonymizedUrl: '/accounts/self/sshkeys/*', + }); + }, + + getAccountGPGKeys() { + return this._fetchJSON({ + url: '/accounts/self/gpgkeys', + reportUrlAsIs: true, + }); + }, + + addAccountGPGKey(key) { + const req = { + method: 'POST', + url: '/accounts/self/gpgkeys', + body: key, + reportUrlAsIs: true, + }; + return this._send(req) + .then(response => { + if (response.status < 200 && response.status >= 300) { + return Promise.reject(); + } + return this.getResponseObject(response); + }) + .then(obj => { + if (!obj) { return Promise.reject(); } + return obj; + }); + }, + + deleteAccountGPGKey(id) { + return this._send({ + method: 'DELETE', + url: '/accounts/self/gpgkeys/' + id, + anonymizedUrl: '/accounts/self/gpgkeys/*', + }); }, deleteVote(changeNum, account, label) { - const e = `/reviewers/${account}/votes/${encodeURIComponent(label)}`; - return this.getChangeURLAndSend(changeNum, 'DELETE', null, e); + return this._getChangeURLAndSend({ + changeNum, + method: 'DELETE', + endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`, + anonymizedEndpoint: '/reviewers/*/votes/*', + }); }, setDescription(changeNum, patchNum, desc) { - const p = {description: desc}; - return this.getChangeURLAndSend(changeNum, 'PUT', patchNum, - '/description', p); + return this._getChangeURLAndSend({ + changeNum, + method: 'PUT', patchNum, + endpoint: '/description', + body: {description: desc}, + reportUrlAsIs: true, + }); }, confirmEmail(token) { - return this.send('PUT', '/config/server/email.confirm', {token}) - .then(response => { - if (response.status === 204) { - return 'Email confirmed successfully.'; - } - return null; - }); + const req = { + method: 'PUT', + url: '/config/server/email.confirm', + body: {token}, + reportUrlAsIs: true, + }; + return this._send(req).then(response => { + if (response.status === 204) { + return 'Email confirmed successfully.'; + } + return null; + }); }, - getCapabilities(token) { - return this.fetchJSON('/config/server/capabilities'); + getCapabilities(token, opt_errFn) { + return this._fetchJSON({ + url: '/config/server/capabilities', + errFn: opt_errFn, + reportUrlAsIs: true, + }); + }, + + getTopMenus(opt_errFn) { + return this._fetchJSON({ + url: '/config/server/top-menus', + errFn: opt_errFn, + reportUrlAsIs: true, + }); }, setAssignee(changeNum, assignee) { - const p = {assignee}; - return this.getChangeURLAndSend(changeNum, 'PUT', null, '/assignee', p); + return this._getChangeURLAndSend({ + changeNum, + method: 'PUT', + endpoint: '/assignee', + body: {assignee}, + reportUrlAsIs: true, + }); }, deleteAssignee(changeNum) { - return this.getChangeURLAndSend(changeNum, 'DELETE', null, '/assignee'); + return this._getChangeURLAndSend({ + changeNum, + method: 'DELETE', + endpoint: '/assignee', + reportUrlAsIs: true, + }); }, probePath(path) { @@ -1807,16 +2800,22 @@ * @param {number|string=} opt_message */ startWorkInProgress(changeNum, opt_message) { - const payload = {}; + const body = {}; if (opt_message) { - payload.message = opt_message; + body.message = opt_message; } - return this.getChangeURLAndSend(changeNum, 'POST', null, '/wip', payload) - .then(response => { - if (response.status === 204) { - return 'Change marked as Work In Progress.'; - } - }); + const req = { + changeNum, + method: 'POST', + endpoint: '/wip', + body, + reportUrlAsIs: true, + }; + return this._getChangeURLAndSend(req).then(response => { + if (response.status === 204) { + return 'Change marked as Work In Progress.'; + } + }); }, /** @@ -1825,8 +2824,14 @@ * @param {function(?Response, string=)=} opt_errFn */ startReview(changeNum, opt_body, opt_errFn) { - return this.getChangeURLAndSend(changeNum, 'POST', null, '/ready', - opt_body, opt_errFn); + return this._getChangeURLAndSend({ + changeNum, + method: 'POST', + endpoint: '/ready', + body: opt_body, + errFn: opt_errFn, + reportUrlAsIs: true, + }); }, /** @@ -1835,10 +2840,15 @@ * parameter. */ deleteComment(changeNum, patchNum, commentID, reason) { - const endpoint = `/comments/${commentID}/delete`; - const payload = {reason}; - return this.getChangeURLAndSend(changeNum, 'POST', patchNum, endpoint, - payload).then(this.getResponseObject.bind(this)); + return this._getChangeURLAndSend({ + changeNum, + method: 'POST', + patchNum, + endpoint: `/comments/${commentID}/delete`, + body: {reason}, + parseResponse: true, + anonymizedEndpoint: '/comments/*/delete', + }); }, /** @@ -1850,7 +2860,14 @@ */ getChange(changeNum, opt_errFn) { // Cannot use _changeBaseURL, as this function is used by _projectLookup. - return this.fetchJSON(`/changes/${changeNum}`, opt_errFn); + return this._fetchJSON({ + url: `/changes/?q=change:${changeNum}`, + errFn: opt_errFn, + anonymizedUrl: '/changes/?q=change:*', + }).then(res => { + if (!res || !res.length) { return null; } + return res[0]; + }); }, /** @@ -1893,42 +2910,71 @@ /** * Alias for _changeBaseURL.then(send). * @todo(beckysiegel) clean up comments - * @param {string|number} changeNum - * @param {string} method - * @param {?string|number} patchNum gets passed as null. - * @param {?string} endpoint gets passed as null. - * @param {?Object|number|string=} opt_payload gets passed as null, string, - * Object, or number. - * @param {function(?Response, string=)=} opt_errFn - * @param {?=} opt_ctx - * @param {?=} opt_contentType + * @param {Defs.ChangeSendRequest} req * @return {!Promise<!Object>} */ - getChangeURLAndSend(changeNum, method, patchNum, endpoint, opt_payload, - opt_errFn, opt_ctx, opt_contentType) { - return this._changeBaseURL(changeNum, patchNum).then(url => { - return this.send(method, url + endpoint, opt_payload, opt_errFn, - opt_ctx, opt_contentType); + _getChangeURLAndSend(req) { + const anonymizedBaseUrl = req.patchNum ? + ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL; + const anonymizedEndpoint = req.reportEndpointAsIs ? + req.endpoint : req.anonymizedEndpoint; + + return this._changeBaseURL(req.changeNum, req.patchNum).then(url => { + return this._send({ + method: req.method, + url: url + req.endpoint, + body: req.body, + errFn: req.errFn, + contentType: req.contentType, + headers: req.headers, + parseResponse: req.parseResponse, + anonymizedUrl: anonymizedEndpoint ? + (anonymizedBaseUrl + anonymizedEndpoint) : undefined, + }); }); }, - /** - * Alias for _changeBaseURL.then(fetchJSON). - * @todo(beckysiegel) clean up comments - * @param {string|number} changeNum - * @param {string} endpoint - * @param {?string|number=} opt_patchNum gets passed as null. - * @param {?function(?Response, string=)=} opt_errFn gets passed as null. - * @param {?function()=} opt_cancelCondition gets passed as null. - * @param {?Object=} opt_params gets passed as null. - * @param {!Object=} opt_options - * @return {!Promise<!Object>} - */ - _getChangeURLAndFetch(changeNum, endpoint, opt_patchNum, opt_errFn, - opt_cancelCondition, opt_params, opt_options) { - return this._changeBaseURL(changeNum, opt_patchNum).then(url => { - return this.fetchJSON(url + endpoint, opt_errFn, opt_cancelCondition, - opt_params, opt_options); + /** + * Alias for _changeBaseURL.then(_fetchJSON). + * @param {Defs.ChangeFetchRequest} req + * @return {!Promise<!Object>} + */ + _getChangeURLAndFetch(req) { + const anonymizedEndpoint = req.reportEndpointAsIs ? + req.endpoint : req.anonymizedEndpoint; + const anonymizedBaseUrl = req.patchNum ? + ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL; + return this._changeBaseURL(req.changeNum, req.patchNum).then(url => { + return this._fetchJSON({ + url: url + req.endpoint, + errFn: req.errFn, + params: req.params, + fetchOptions: req.fetchOptions, + anonymizedUrl: anonymizedEndpoint ? + (anonymizedBaseUrl + anonymizedEndpoint) : undefined, + }); + }); + }, + + /** + * Execute a change action or revision action on a change. + * @param {number} changeNum + * @param {string} method + * @param {string} endpoint + * @param {string|number|undefined} opt_patchNum + * @param {Object=} opt_payload + * @param {?function(?Response, string=)=} opt_errFn + * @return {Promise} + */ + executeChangeAction(changeNum, method, endpoint, opt_patchNum, opt_payload, + opt_errFn) { + return this._getChangeURLAndSend({ + changeNum, + method, + patchNum: opt_patchNum, + endpoint, + body: opt_payload, + errFn: opt_errFn, }); }, @@ -1943,9 +2989,13 @@ */ getBlame(changeNum, patchNum, path, opt_base) { const encodedPath = encodeURIComponent(path); - return this._getChangeURLAndFetch(changeNum, - `/files/${encodedPath}/blame`, patchNum, undefined, undefined, - opt_base ? {base: 't'} : undefined); + return this._getChangeURLAndFetch({ + changeNum, + endpoint: `/files/${encodedPath}/blame`, + patchNum, + params: opt_base ? {base: 't'} : undefined, + anonymizedEndpoint: '/files/*/blame', + }); }, /** @@ -1976,5 +3026,57 @@ return result; }); }, + + /** + * Fetch a project dashboard definition. + * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard + * @param {string} project + * @param {string} dashboard + * @param {function(?Response, string=)=} opt_errFn + * passed as null sometimes. + * @return {!Promise<!Object>} + */ + getDashboard(project, dashboard, opt_errFn) { + const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' + + encodeURIComponent(dashboard); + return this._fetchSharedCacheURL({ + url, + errFn: opt_errFn, + anonymizedUrl: '/projects/*/dashboards/*', + }); + }, + + /** + * @param {string} filter + * @return {!Promise<?Object>} + */ + getDocumentationSearches(filter) { + filter = filter.trim(); + const encodedFilter = encodeURIComponent(filter); + + // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend + // supports it. + return this._fetchSharedCacheURL({ + url: `/Documentation/?q=${encodedFilter}`, + anonymizedUrl: '/Documentation/?*', + }); + }, + + getMergeable(changeNum) { + return this._getChangeURLAndFetch({ + changeNum, + endpoint: '/revisions/current/mergeable', + parseResponse: true, + reportEndpointAsIs: true, + }); + }, + + deleteDraftComments(query) { + return this._send({ + method: 'POST', + url: '/accounts/self/drafts:delete', + body: {query}, + }); + }, }); })(); |