/** * @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 DiffViewMode = { SIDE_BY_SIDE: 'SIDE_BY_SIDE', UNIFIED: 'UNIFIED_DIFF', }; const JSON_PREFIX = ')]}\''; const MAX_PROJECT_RESULTS = 25; // This value is somewhat arbitrary and not based on research or calculations. const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850; const PARENT_PATCH_NUM = 'PARENT'; const Requests = { SEND_DIFF_DRAFT: 'sendDiffDraft', }; const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE = '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/*'; /** * @appliesMixin Gerrit.FireMixin * @appliesMixin Gerrit.PathListMixin * @appliesMixin Gerrit.PatchSetMixin * @appliesMixin Gerrit.RESTClientMixin * @extends Polymer.Element */ class GrRestApiInterface extends Polymer.mixinBehaviors( [ Gerrit.FireBehavior, Gerrit.PathListBehavior, Gerrit.PatchSetBehavior, Gerrit.RESTClientBehavior, ], Polymer.GestureEventListeners( Polymer.LegacyElementMixin( Polymer.Element))) { static get is() { return 'gr-rest-api-interface'; } /** * Fired when an server error occurs. * * @event server-error */ /** * Fired when a network error occurs. * * @event network-error */ /** * Fired after an RPC completes. * * @event rpc-log */ constructor() { super(); this.JSON_PREFIX = JSON_PREFIX; } static get properties() { return { _cache: { type: Object, value: new SiteBasedCache(), // Shared across instances. }, _sharedFetchPromises: { type: Object, value: new FetchPromisesCache(), // Shared across instances. }, _pendingRequests: { type: Object, value: {}, // Intentional to share the object across instances. }, _etags: { type: Object, value: new GrEtagDecorator(), // Share across instances. }, /** * Used to maintain a mapping of changeNums to project names. */ _projectLookup: { type: Object, value: {}, // Intentional to share the object across instances. }, }; } /** @override */ created() { super.created(); this._auth = Gerrit.Auth; this._initRestApiHelper(); } _initRestApiHelper() { if (this._restApiHelper) { return; } if (this._cache && this._auth && this._sharedFetchPromises) { this._restApiHelper = new GrRestApiHelper(this._cache, this._auth, this._sharedFetchPromises, this); } } _fetchSharedCacheURL(req) { // Cache is shared across instances return this._restApiHelper.fetchCacheURL(req); } /** * @param {!Object} response * @return {?} */ getResponseObject(response) { return this._restApiHelper.getResponseObject(response); } getConfig(noCache) { if (!noCache) { return this._fetchSharedCacheURL({ url: '/config/server/info', reportUrlAsIs: true, }); } return this._restApiHelper.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/*', }); } 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', }); } 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=*', }); } 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', }); } saveRepoConfig(repo, config, opt_errFn) { // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. const url = `/projects/${encodeURIComponent(repo)}/config`; this._cache.delete(url); return this._restApiHelper.send({ method: 'PUT', url, body: config, errFn: opt_errFn, anonymizedUrl: '/projects/*/config', }); } 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._restApiHelper.send({ method: 'POST', url: `/projects/${encodeName}/gc`, body: '', errFn: opt_errFn, anonymizedUrl: '/projects/*/gc', }); } /** * @param {?Object} config * @param {function(?Response, string=)=} opt_errFn */ 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._restApiHelper.send({ method: 'PUT', url: `/projects/${encodeName}`, body: config, errFn: opt_errFn, anonymizedUrl: '/projects/*', }); } /** * @param {?Object} config * @param {function(?Response, string=)=} opt_errFn */ createGroup(config, opt_errFn) { if (!config.name) { return ''; } const encodeName = encodeURIComponent(config.name); return this._restApiHelper.send({ method: 'PUT', url: `/groups/${encodeName}`, body: config, errFn: opt_errFn, anonymizedUrl: '/groups/*', }); } getGroupConfig(group, opt_errFn) { return this._restApiHelper.fetchJSON({ url: `/groups/${encodeURIComponent(group)}/detail`, errFn: opt_errFn, anonymizedUrl: '/groups/*/detail', }); } /** * @param {string} repo * @param {string} ref * @param {function(?Response, string=)=} opt_errFn */ 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._restApiHelper.send({ method: 'DELETE', url: `/projects/${encodeName}/branches/${encodeRef}`, body: '', errFn: opt_errFn, anonymizedUrl: '/projects/*/branches/*', }); } /** * @param {string} repo * @param {string} ref * @param {function(?Response, string=)=} opt_errFn */ 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._restApiHelper.send({ method: 'DELETE', url: `/projects/${encodeName}/tags/${encodeRef}`, body: '', errFn: opt_errFn, anonymizedUrl: '/projects/*/tags/*', }); } /** * @param {string} name * @param {string} branch * @param {string} revision * @param {function(?Response, string=)=} opt_errFn */ 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._restApiHelper.send({ method: 'PUT', url: `/projects/${encodeName}/branches/${encodeBranch}`, body: revision, errFn: opt_errFn, anonymizedUrl: '/projects/*/branches/*', }); } /** * @param {string} name * @param {string} tag * @param {string} revision * @param {function(?Response, string=)=} opt_errFn */ 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._restApiHelper.send({ method: 'PUT', url: `/projects/${encodeName}/tags/${encodeTag}`, body: revision, errFn: opt_errFn, anonymizedUrl: '/projects/*/tags/*', }); } /** * @param {!string} groupName * @returns {!Promise} */ getIsGroupOwner(groupName) { const encodeName = encodeURIComponent(groupName); const req = { url: `/groups/?owned&g=${encodeName}`, anonymizedUrl: '/groups/owned&g=*', }; return this._fetchSharedCacheURL(req) .then(configs => configs.hasOwnProperty(groupName)); } getGroupMembers(groupName, opt_errFn) { const encodeName = encodeURIComponent(groupName); return this._restApiHelper.fetchJSON({ url: `/groups/${encodeName}/members/`, errFn: opt_errFn, anonymizedUrl: '/groups/*/members', }); } getIncludedGroup(groupName) { return this._restApiHelper.fetchJSON({ url: `/groups/${encodeURIComponent(groupName)}/groups/`, anonymizedUrl: '/groups/*/groups', }); } saveGroupName(groupId, name) { const encodeId = encodeURIComponent(groupId); return this._restApiHelper.send({ method: 'PUT', url: `/groups/${encodeId}/name`, body: {name}, anonymizedUrl: '/groups/*/name', }); } saveGroupOwner(groupId, ownerId) { const encodeId = encodeURIComponent(groupId); return this._restApiHelper.send({ method: 'PUT', url: `/groups/${encodeId}/owner`, body: {owner: ownerId}, anonymizedUrl: '/groups/*/owner', }); } saveGroupDescription(groupId, description) { const encodeId = encodeURIComponent(groupId); return this._restApiHelper.send({ method: 'PUT', url: `/groups/${encodeId}/description`, body: {description}, anonymizedUrl: '/groups/*/description', }); } saveGroupOptions(groupId, options) { const encodeId = encodeURIComponent(groupId); return this._restApiHelper.send({ method: 'PUT', url: `/groups/${encodeId}/options`, body: options, anonymizedUrl: '/groups/*/options', }); } 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._restApiHelper.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); const req = { method: 'PUT', url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`, errFn: opt_errFn, anonymizedUrl: '/groups/*/groups/*', }; return this._restApiHelper.send(req).then(response => { if (response.ok) { return this.getResponseObject(response); } }); } deleteGroupMembers(groupName, groupMembers) { const encodeName = encodeURIComponent(groupName); const encodeMember = encodeURIComponent(groupMembers); return this._restApiHelper.send({ method: 'DELETE', url: `/groups/${encodeName}/members/${encodeMember}`, anonymizedUrl: '/groups/*/members/*', }); } deleteIncludedGroup(groupName, includedGroup) { const encodeName = encodeURIComponent(groupName); const encodeIncludedGroup = encodeURIComponent(includedGroup); return this._restApiHelper.send({ method: 'DELETE', url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`, anonymizedUrl: '/groups/*/groups/*', }); } getVersion() { return this._fetchSharedCacheURL({ url: '/config/server/version', reportUrlAsIs: true, }); } getDiffPreferences() { return this.getLoggedIn().then(loggedIn => { if (loggedIn) { return this._fetchSharedCacheURL({ url: '/accounts/self/preferences.diff', reportUrlAsIs: true, }); } // These defaults should match the defaults in // 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({ 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', }); }); } 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 */ 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._restApiHelper.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._restApiHelper.send({ method: 'PUT', url: '/accounts/self/preferences.diff', body: prefs, errFn: opt_errFn, reportUrlAsIs: true, }); } /** * @param {?Object} prefs * @param {function(?Response, string=)=} opt_errFn */ saveEditPreferences(prefs, opt_errFn) { // Invalidate the cache. this._cache.delete('/accounts/self/preferences.edit'); return this._restApiHelper.send({ method: 'PUT', url: '/accounts/self/preferences.edit', body: prefs, errFn: opt_errFn, reportUrlAsIs: true, }); } getAccount() { 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._restApiHelper.fetchJSON({ url: '/accounts/self/external.ids', reportUrlAsIs: true, }); } deleteAccountIdentity(id) { return this._restApiHelper.send({ method: 'POST', url: '/accounts/self/external.ids:delete', body: id, parseResponse: true, reportUrlAsIs: true, }); } /** * @param {string} userId the ID of the user usch as an email address. * @return {!Promise} */ getAccountDetails(userId) { return this._restApiHelper.fetchJSON({ url: `/accounts/${encodeURIComponent(userId)}/detail`, anonymizedUrl: '/accounts/*/detail', }); } getAccountEmails() { return this._fetchSharedCacheURL({ url: '/accounts/self/emails', reportUrlAsIs: true, }); } /** * @param {string} email * @param {function(?Response, string=)=} opt_errFn */ addAccountEmail(email, opt_errFn) { return this._restApiHelper.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 */ deleteAccountEmail(email, opt_errFn) { return this._restApiHelper.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 */ 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._restApiHelper.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); } }); } /** * @param {?Object} obj */ _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.get('/accounts/self/detail'); if (cachedAccount) { // Replace object in cache with new object to force UI updates. this._cache.set('/accounts/self/detail', Object.assign({}, cachedAccount, obj)); } } /** * @param {string} name * @param {function(?Response, string=)=} opt_errFn */ setAccountName(name, opt_errFn) { const req = { method: 'PUT', url: '/accounts/self/name', body: {name}, errFn: opt_errFn, parseResponse: true, reportUrlAsIs: true, }; return this._restApiHelper.send(req) .then(newName => this._updateCachedAccount({name: newName})); } /** * @param {string} username * @param {function(?Response, string=)=} opt_errFn */ setAccountUsername(username, opt_errFn) { const req = { method: 'PUT', url: '/accounts/self/username', body: {username}, errFn: opt_errFn, parseResponse: true, reportUrlAsIs: true, }; return this._restApiHelper.send(req) .then(newName => this._updateCachedAccount({username: newName})); } /** * @param {string} status * @param {function(?Response, string=)=} opt_errFn */ setAccountStatus(status, opt_errFn) { const req = { method: 'PUT', url: '/accounts/self/status', body: {status}, errFn: opt_errFn, parseResponse: true, reportUrlAsIs: true, }; return this._restApiHelper.send(req) .then(newStatus => this._updateCachedAccount({status: newStatus})); } getAccountStatus(userId) { return this._restApiHelper.fetchJSON({ url: `/accounts/${encodeURIComponent(userId)}/status`, anonymizedUrl: '/accounts/*/status', }); } getAccountGroups() { return this._restApiHelper.fetchJSON({ url: '/accounts/self/groups', reportUrlAsIs: true, }); } getAccountAgreements() { return this._restApiHelper.fetchJSON({ url: '/accounts/self/agreements', reportUrlAsIs: true, }); } saveAccountAgreement(name) { return this._restApiHelper.send({ method: 'PUT', url: '/accounts/self/agreements', body: name, reportUrlAsIs: true, }); } /** * @param {string=} opt_params */ getAccountCapabilities(opt_params) { let queryString = ''; if (opt_params) { queryString = '?q=' + opt_params .map(param => encodeURIComponent(param)) .join('&q='); } return this._fetchSharedCacheURL({ url: '/accounts/self/capabilities' + queryString, anonymizedUrl: '/accounts/self/capabilities?q=*', }); } getLoggedIn() { return this._auth.authCheck(); } getIsAdmin() { return this.getLoggedIn() .then(isLoggedIn => { if (isLoggedIn) { return this.getAccountCapabilities(); } else { return Promise.resolve(); } }) .then( capabilities => capabilities && capabilities.administrateServer ); } getDefaultPreferences() { return this._fetchSharedCacheURL({ url: '/config/server/preferences', reportUrlAsIs: true, }); } getPreferences() { return this.getLoggedIn().then(loggedIn => { if (loggedIn) { const req = {url: '/accounts/self/preferences', reportUrlAsIs: true}; return this._fetchSharedCacheURL(req).then(res => { if (this._isNarrowScreen()) { // Note that this can be problematic, because the diff will stay // unified even after increasing the window width. res.default_diff_view = DiffViewMode.UNIFIED; } else { res.default_diff_view = res.diff_view; } return Promise.resolve(res); }); } return Promise.resolve({ changes_per_page: 25, 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({ url: '/accounts/self/watched.projects', reportUrlAsIs: true, }); } /** * @param {string} projects * @param {function(?Response, string=)=} opt_errFn */ saveWatchedProjects(projects, opt_errFn) { return this._restApiHelper.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 */ deleteWatchedProjects(projects, opt_errFn) { return this._restApiHelper.send({ method: 'POST', url: '/accounts/self/watched.projects:delete', body: projects, errFn: opt_errFn, reportUrlAsIs: true, }); } _isNarrowScreen() { return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX; } /** * @param {number=} opt_changesPerPage * @param {string|!Array=} opt_query A query or an array of queries. * @param {number|string=} opt_offset * @param {!Object=} opt_options * @return {?Array|?Array>} 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 * changeInfos. */ getChanges(opt_changesPerPage, opt_query, opt_offset, opt_options) { const options = opt_options || this.listChangesOptionsToHex( this.ListChangesOption.LABELS, this.ListChangesOption.DETAILED_ACCOUNTS ); // Issue 4524: respect legacy token with max sortkey. if (opt_offset === 'n,z') { opt_offset = 0; } const params = { O: options, S: opt_offset || 0, }; if (opt_changesPerPage) { params.n = opt_changesPerPage; } if (opt_query && opt_query.length > 0) { params.q = opt_query; } const iterateOverChanges = arr => { for (const change of (arr || [])) { this._maybeInsertInLookup(change); } }; const req = { url: '/changes/', params, reportUrlAsIs: true, }; return this._restApiHelper.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); } } else { iterateOverChanges(response); } return response; }); } /** * Inserts a change into _projectLookup iff it has a valid structure. * * @param {?{ _number: (number|string) }} change */ _maybeInsertInLookup(change) { if (change && change.project && change._number) { this.setInProjectLookup(change._number, change.project); } } /** * TODO (beckysiegel) this needs to be rewritten with the optional param * at the end. * * @param {number|string} changeNum * @param {?number|string=} opt_patchNum passed as null sometimes. * @param {?=} endpoint * @return {!Promise} */ getChangeActionURL(changeNum, opt_patchNum, endpoint) { return this._changeBaseURL(changeNum, opt_patchNum) .then(url => url + endpoint); } /** * @param {number|string} changeNum * @param {function(?Response, string=)=} opt_errFn * @param {function()=} opt_cancelCondition */ getChangeDetail(changeNum, opt_errFn, opt_cancelCondition) { return this.getConfig(false).then(config => { const optionsHex = this._getChangeOptionsHex(config); return this._getChangeDetail( changeNum, optionsHex, opt_errFn, opt_cancelCondition) .then(GrReviewerUpdatesParser.parse); }); } _getChangeOptionsHex(config) { if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.changePage && !(config.receive && config.receive.enable_signed_push)) { return window.DEFAULT_DETAIL_HEXES.changePage; } // This list MUST be kept in sync with // ChangeIT#changeDetailsDoesNotRequireIndex const options = [ this.ListChangesOption.ALL_COMMITS, this.ListChangesOption.ALL_REVISIONS, this.ListChangesOption.CHANGE_ACTIONS, this.ListChangesOption.DETAILED_LABELS, this.ListChangesOption.DOWNLOAD_COMMANDS, this.ListChangesOption.MESSAGES, this.ListChangesOption.SUBMITTABLE, this.ListChangesOption.WEB_LINKS, this.ListChangesOption.SKIP_DIFFSTAT, ]; if (config.receive && config.receive.enable_signed_push) { options.push(this.ListChangesOption.PUSH_CERTIFICATES); } return this.listChangesOptionsToHex(...options); } /** * @param {number|string} changeNum * @param {function(?Response, string=)=} opt_errFn * @param {function()=} opt_cancelCondition */ getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) { let optionsHex = ''; if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.diffPage) { optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage; } else { optionsHex = this.listChangesOptionsToHex( this.ListChangesOption.ALL_COMMITS, this.ListChangesOption.ALL_REVISIONS, this.ListChangesOption.SKIP_DIFFSTAT ); } return this._getChangeDetail(changeNum, optionsHex, opt_errFn, opt_cancelCondition); } /** * @param {number|string} changeNum * @param {string|undefined} optionsHex list changes options in hex * @param {function(?Response, string=)=} opt_errFn * @param {function()=} opt_cancelCondition */ _getChangeDetail(changeNum, optionsHex, opt_errFn, opt_cancelCondition) { return this.getChangeActionURL(changeNum, null, '/detail').then(url => { const urlWithParams = this._restApiHelper .urlWithParams(url, optionsHex); const params = {O: optionsHex}; const req = { url, errFn: opt_errFn, cancelCondition: opt_cancelCondition, params, fetchOptions: this._etags.getOptions(urlWithParams), anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex, }; return this._restApiHelper.fetchRawJSON(req).then(response => { if (response && response.status === 304) { return Promise.resolve(this._restApiHelper.parsePrefixedJSON( this._etags.getCachedPayload(urlWithParams))); } if (response && !response.ok) { if (opt_errFn) { opt_errFn.call(null, response); } else { this.fire('server-error', {request: req, response}); } return; } const payloadPromise = response ? this._restApiHelper.readResponsePayload(response) : Promise.resolve(null); return payloadPromise.then(payload => { if (!payload) { return null; } this._etags.collect(urlWithParams, response, payload.raw); this._maybeInsertInLookup(payload.parsed); return payload.parsed; }); }); }); } /** * @param {number|string} changeNum * @param {number|string} patchNum */ getChangeCommitInfo(changeNum, patchNum) { return this._getChangeURLAndFetch({ changeNum, endpoint: '/commit?links', patchNum, reportEndpointAsIs: true, }); } /** * @param {number|string} changeNum * @param {Gerrit.PatchRange} patchRange * @param {number=} opt_parentIndex */ 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: '/files', patchNum: patchRange.patchNum, params, reportEndpointAsIs: true, }); } /** * @param {number|string} changeNum * @param {Gerrit.PatchRange} patchRange */ getChangeEditFiles(changeNum, patchRange) { let endpoint = '/edit?list'; let anonymizedEndpoint = endpoint; if (patchRange.basePatchNum !== 'PARENT') { endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + ''); anonymizedEndpoint += '&base=*'; } return this._getChangeURLAndFetch({ changeNum, endpoint, anonymizedEndpoint, }); } /** * @param {number|string} changeNum * @param {number|string} patchNum * @param {string} query * @return {!Promise} */ queryChangeFiles(changeNum, patchNum, query) { return this._getChangeURLAndFetch({ changeNum, endpoint: `/files?q=${encodeURIComponent(query)}`, patchNum, anonymizedEndpoint: '/files?q=*', }); } /** * @param {number|string} changeNum * @param {Gerrit.PatchRange} patchRange * @return {!Promise>} */ 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); } getChangeRevisionActions(changeNum, patchNum) { 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; }); } /** * @param {number|string} changeNum * @param {string} inputVal * @param {function(?Response, string=)=} opt_errFn */ getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) { return this._getChangeSuggestedGroup('REVIEWER', changeNum, inputVal, opt_errFn); } /** * @param {number|string} changeNum * @param {string} inputVal * @param {function(?Response, string=)=} opt_errFn */ getChangeSuggestedCCs(changeNum, inputVal, opt_errFn) { return this._getChangeSuggestedGroup('CC', changeNum, inputVal, opt_errFn); } _getChangeSuggestedGroup(reviewerState, changeNum, inputVal, opt_errFn) { // More suggestions may obscure content underneath in the reply dialog, // see issue 10793. const params = {'n': 6, 'reviewer-state': reviewerState}; if (inputVal) { params.q = inputVal; } return this._getChangeURLAndFetch({ changeNum, endpoint: '/suggest_reviewers', errFn: opt_errFn, params, reportEndpointAsIs: true, }); } /** * @param {number|string} changeNum */ getChangeIncludedIn(changeNum) { return this._getChangeURLAndFetch({ changeNum, endpoint: '/in', reportEndpointAsIs: true, }); } _computeFilter(filter) { if (filter && filter.startsWith('^')) { filter = '&r=' + encodeURIComponent(filter); } else if (filter) { filter = '&m=' + encodeURIComponent(filter); } else { filter = ''; } return filter; } /** * @param {string} filter * @param {number} groupsPerPage * @param {number=} opt_offset */ _getGroupsUrl(filter, groupsPerPage, opt_offset) { const offset = opt_offset || 0; return `/groups/?n=${groupsPerPage + 1}&S=${offset}` + this._computeFilter(filter); } /** * @param {string} filter * @param {number} reposPerPage * @param {number=} opt_offset */ _getReposUrl(filter, reposPerPage, opt_offset) { const defaultFilter = 'state:active OR state:read-only'; const namePartDelimiters = /[@.\-\s\/_]/g; const offset = opt_offset || 0; 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}`; } invalidateGroupsCache() { this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?'); } invalidateReposCache() { this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?'); } invalidateAccountsCache() { this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/'); } /** * @param {string} filter * @param {number} groupsPerPage * @param {number=} opt_offset * @return {!Promise} */ getGroups(filter, groupsPerPage, opt_offset) { const url = this._getGroupsUrl(filter, groupsPerPage, opt_offset); return this._fetchSharedCacheURL({ url, anonymizedUrl: '/groups/?*', }); } /** * @param {string} filter * @param {number} reposPerPage * @param {number=} opt_offset * @return {!Promise} */ 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._restApiHelper.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} */ 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._restApiHelper.fetchJSON({ url, errFn: opt_errFn, anonymizedUrl: '/projects/*/branches?*', }); } /** * @param {string} filter * @param {string} repo * @param {number} reposTagsPerPage * @param {number=} opt_offset * @param {?function(?Response, string=)=} opt_errFn * @return {!Promise} */ 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._restApiHelper.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} */ 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._restApiHelper.fetchJSON({ url, errFn: opt_errFn, anonymizedUrl: '/plugins/?all', }); } getRepoAccessRights(repoName, opt_errFn) { // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. return this._restApiHelper.fetchJSON({ url: `/projects/${encodeURIComponent(repoName)}/access`, errFn: opt_errFn, anonymizedUrl: '/projects/*/access', }); } setRepoAccessRights(repoName, repoInfo) { // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. return this._restApiHelper.send({ method: 'POST', url: `/projects/${encodeURIComponent(repoName)}/access`, body: repoInfo, anonymizedUrl: '/projects/*/access', }); } setRepoAccessRightsForReview(projectName, projectInfo) { return this._restApiHelper.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 */ getSuggestedGroups(inputVal, opt_n, opt_errFn) { const params = {s: inputVal}; if (opt_n) { params.n = opt_n; } return this._restApiHelper.fetchJSON({ url: '/groups/', errFn: opt_errFn, params, reportUrlAsIs: true, }); } /** * @param {string} inputVal * @param {number} opt_n * @param {function(?Response, string=)=} opt_errFn */ 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._restApiHelper.fetchJSON({ url: '/projects/', errFn: opt_errFn, params, reportUrlAsIs: true, }); } /** * @param {string} inputVal * @param {number} opt_n * @param {function(?Response, string=)=} opt_errFn */ 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._restApiHelper.fetchJSON({ url: '/accounts/', errFn: opt_errFn, params, anonymizedUrl: '/accounts/?n=*', }); } addChangeReviewer(changeNum, reviewerID) { return this._sendChangeReviewerRequest('POST', changeNum, reviewerID); } removeChangeReviewer(changeNum, reviewerID) { return this._sendChangeReviewerRequest('DELETE', changeNum, reviewerID); } _sendChangeReviewerRequest(method, changeNum, reviewerID) { return this.getChangeActionURL(changeNum, null, '/reviewers') .then(url => { let body; switch (method) { case 'POST': body = {reviewer: reviewerID}; break; case 'DELETE': url += '/' + encodeURIComponent(reviewerID); break; default: throw Error('Unsupported HTTP method: ' + method); } return this._restApiHelper.send({method, url, body}); }); } getRelatedChanges(changeNum, patchNum) { return this._getChangeURLAndFetch({ changeNum, endpoint: '/related', patchNum, reportEndpointAsIs: true, }); } getChangesSubmittedTogether(changeNum) { return this._getChangeURLAndFetch({ changeNum, endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES', reportEndpointAsIs: true, }); } getChangeConflicts(changeNum) { const options = this.listChangesOptionsToHex( this.ListChangesOption.CURRENT_REVISION, this.ListChangesOption.CURRENT_COMMIT ); const params = { O: options, q: 'status:open conflicts:' + changeNum, }; return this._restApiHelper.fetchJSON({ url: '/changes/', params, anonymizedUrl: '/changes/conflicts:*', }); } getChangeCherryPicks(project, changeID, changeNum) { const options = this.listChangesOptionsToHex( this.ListChangesOption.CURRENT_REVISION, this.ListChangesOption.CURRENT_COMMIT ); const query = [ 'project:' + project, 'change:' + changeID, '-change:' + changeNum, '-is:abandoned', ].join(' '); const params = { O: options, q: query, }; return this._restApiHelper.fetchJSON({ url: '/changes/', params, anonymizedUrl: '/changes/change:*', }); } getChangesWithSameTopic(topic, changeNum) { const options = this.listChangesOptionsToHex( this.ListChangesOption.LABELS, this.ListChangesOption.CURRENT_REVISION, this.ListChangesOption.CURRENT_COMMIT, this.ListChangesOption.DETAILED_LABELS ); const query = [ 'status:open', '-change:' + changeNum, `topic:"${topic}"`, ].join(' '); const params = { O: options, q: query, }; return this._restApiHelper.fetchJSON({ url: '/changes/', params, anonymizedUrl: '/changes/topic:*', }); } getReviewedFiles(changeNum, patchNum) { return this._getChangeURLAndFetch({ changeNum, endpoint: '/files?reviewed', patchNum, reportEndpointAsIs: true, }); } /** * @param {number|string} changeNum * @param {number|string} patchNum * @param {string} path * @param {boolean} reviewed * @param {function(?Response, string=)=} opt_errFn */ 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', }); } /** * @param {number|string} changeNum * @param {number|string} patchNum * @param {!Object} review * @param {function(?Response, string=)=} opt_errFn */ saveChangeReview(changeNum, patchNum, review, opt_errFn) { const promises = [ this.awaitPendingDiffDrafts(), this.getChangeActionURL(changeNum, patchNum, '/review'), ]; return Promise.all(promises).then(([, url]) => this._restApiHelper.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 => { if (!loggedIn) { return false; } return this._getChangeURLAndFetch({ changeNum, endpoint: '/edit/', params, reportEndpointAsIs: true, }); }); } /** * @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._restApiHelper.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 */ _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', }); } /** * 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, method: 'POST', endpoint: '/edit:rebase', reportEndpointAsIs: true, }); } deleteChangeEdit(changeNum) { return this._getChangeURLAndSend({ changeNum, method: 'DELETE', endpoint: '/edit', reportEndpointAsIs: true, }); } restoreFileInChangeEdit(changeNum, restore_path) { return this._getChangeURLAndSend({ changeNum, method: 'POST', endpoint: '/edit', body: {restore_path}, reportEndpointAsIs: true, }); } renameFileInChangeEdit(changeNum, old_path, new_path) { return this._getChangeURLAndSend({ changeNum, method: 'POST', endpoint: '/edit', body: {old_path, new_path}, reportEndpointAsIs: true, }); } deleteFileInChangeEdit(changeNum, path) { return this._getChangeURLAndSend({ changeNum, method: 'DELETE', endpoint: '/edit/' + encodeURIComponent(path), anonymizedEndpoint: '/edit/*', }); } saveChangeEdit(changeNum, path, contents) { return this._getChangeURLAndSend({ changeNum, method: 'PUT', endpoint: '/edit/' + encodeURIComponent(path), body: contents, contentType: 'text/plain', anonymizedEndpoint: '/edit/*', }); } getRobotCommentFixPreview(changeNum, patchNum, fixId) { return this._getChangeURLAndFetch({ changeNum, patchNum, endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`, reportEndpointAsId: true, }); } applyFixSuggestion(changeNum, patchNum, fixId) { return this._getChangeURLAndSend({ method: 'POST', changeNum, patchNum, endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`, reportEndpointAsId: true, }); } // Deprecated, prefer to use putChangeCommitMessage instead. saveChangeCommitMessageEdit(changeNum, message) { return this._getChangeURLAndSend({ changeNum, method: 'PUT', endpoint: '/edit:message', body: {message}, reportEndpointAsIs: true, }); } publishChangeEdit(changeNum) { return this._getChangeURLAndSend({ changeNum, method: 'POST', endpoint: '/edit:publish', reportEndpointAsIs: true, }); } putChangeCommitMessage(changeNum, message) { return this._getChangeURLAndSend({ changeNum, method: 'PUT', endpoint: '/message', body: {message}, reportEndpointAsIs: true, }); } saveChangeStarred(changeNum, starred) { // 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._restApiHelper.send({ method: starred ? 'PUT' : 'DELETE', url, anonymizedUrl: '/accounts/self/starred.changes/*', }); }); } saveChangeReviewed(changeNum, reviewed) { return this._getChangeURLAndSend({ changeNum, method: 'PUT', endpoint: reviewed ? '/reviewed' : '/unreviewed', }); } /** * Public version of the _restApiHelper.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._restApiHelper.send({ method, url, body: opt_body, errFn: opt_errFn, contentType: opt_contentType, headers: opt_headers, }); } /** * @param {number|string} changeNum * @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 */ getDiff(changeNum, basePatchNum, patchNum, path, opt_whitespace, opt_errFn) { const params = { context: 'ALL', intraline: null, whitespace: opt_whitespace || 'IGNORE_NONE', }; 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`; const req = { changeNum, endpoint, patchNum, errFn: opt_errFn, params, anonymizedEndpoint: '/files/*/diff', }; // Invalidate the cache if its edit patch to make sure we always get latest. if (patchNum === this.EDIT_NAME) { if (!req.fetchOptions) req.fetchOptions = {}; if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers(); req.fetchOptions.headers.append('Cache-Control', 'no-cache'); } return this._getChangeURLAndFetch(req); } /** * @param {number|string} changeNum * @param {number|string=} opt_basePatchNum * @param {number|string=} opt_patchNum * @param {string=} opt_path * @return {!Promise} */ getDiffComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) { return this._getDiffComments(changeNum, '/comments', opt_basePatchNum, opt_patchNum, opt_path); } /** * @param {number|string} changeNum * @param {number|string=} opt_basePatchNum * @param {number|string=} opt_patchNum * @param {string=} opt_path * @return {!Promise} */ getDiffRobotComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) { return this._getDiffComments(changeNum, '/robotcomments', opt_basePatchNum, opt_patchNum, opt_path); } /** * If the user is logged in, fetch the user's draft diff comments. If there * is no logged in user, the request is not made and the promise yields an * empty object. * * @param {number|string} changeNum * @param {number|string=} opt_basePatchNum * @param {number|string=} opt_patchNum * @param {string=} opt_path * @return {!Promise} */ getDiffDrafts(changeNum, opt_basePatchNum, opt_patchNum, opt_path) { return this.getLoggedIn().then(loggedIn => { if (!loggedIn) { return Promise.resolve({}); } return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum, opt_patchNum, opt_path); }); } _setRange(comments, comment) { if (comment.in_reply_to && !comment.range) { for (let i = 0; i < comments.length; i++) { if (comments[i].id === comment.in_reply_to) { comment.range = comments[i].range; break; } } } return comment; } _setRanges(comments) { comments = comments || []; comments.sort( (a, b) => util.parseDate(a.updated) - util.parseDate(b.updated) ); for (const comment of comments) { this._setRange(comments, comment); } return comments; } /** * @param {number|string} changeNum * @param {string} endpoint * @param {number|string=} opt_basePatchNum * @param {number|string=} opt_patchNum * @param {string=} opt_path * @return {!Promise} */ _getDiffComments(changeNum, endpoint, opt_basePatchNum, opt_patchNum, opt_path) { /** * Fetches the comments for a given patchNum. * Helper function to make promises more legible. * * @param {string|number=} opt_patchNum * @return {!Promise} Diff comments response. */ // We don't want to add accept header, since preloading of comments is // working only without accept header. const noAcceptHeader = true; const fetchComments = opt_patchNum => this._getChangeURLAndFetch({ changeNum, endpoint, patchNum: opt_patchNum, reportEndpointAsIs: true, }, noAcceptHeader); if (!opt_basePatchNum && !opt_patchNum && !opt_path) { return fetchComments(); } 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; } const promises = []; let comments; let baseComments; let fetchPromise; fetchPromise = fetchComments(opt_patchNum).then(response => { comments = response[opt_path] || []; // TODO(kaspern): Implement this on in the backend so this can // be removed. // Sort comments by date so that parent ranges can be propagated // in a single pass. comments = this._setRanges(comments); if (opt_basePatchNum == PARENT_PATCH_NUM) { baseComments = comments.filter(onlyParent); baseComments.forEach(setPath); } comments = comments.filter(withoutParent); comments.forEach(setPath); }); promises.push(fetchPromise); if (opt_basePatchNum != PARENT_PATCH_NUM) { fetchPromise = fetchComments(opt_basePatchNum).then(response => { baseComments = (response[opt_path] || []) .filter(withoutParent); baseComments = this._setRanges(baseComments); baseComments.forEach(setPath); }); promises.push(fetchPromise); } return Promise.all(promises).then(() => Promise.resolve({ baseComments, comments, })); } /** * @param {number|string} changeNum * @param {string} endpoint * @param {number|string=} opt_patchNum */ _getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum) { return this._changeBaseURL(changeNum, opt_patchNum) .then(url => url + endpoint); } saveDiffDraft(changeNum, patchNum, draft) { return this._sendDiffDraftRequest('PUT', changeNum, patchNum, draft); } deleteDiffDraft(changeNum, patchNum, draft) { return this._sendDiffDraftRequest('DELETE', changeNum, patchNum, draft); } /** * @returns {boolean} Whether there are pending diff draft sends. */ hasPendingDiffDrafts() { const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT]; return promises && promises.length; } /** * @returns {!Promise} A promise that resolves when all pending * diff draft sends have resolved. */ awaitPendingDiffDrafts() { return Promise.all(this._pendingRequests[Requests.SEND_DIFF_DRAFT] || []) .then(() => { this._pendingRequests[Requests.SEND_DIFF_DRAFT] = []; }); } _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') { body = draft; } if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) { this._pendingRequests[Requests.SEND_DIFF_DRAFT] = []; } const req = { changeNum, method, patchNum, endpoint, body, anonymizedEndpoint, }; const promise = this._getChangeURLAndSend(req); this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise); if (isCreate) { return this._failForCreate200(promise); } return promise; } getCommitInfo(project, commit) { return this._restApiHelper.fetchJSON({ url: '/projects/' + encodeURIComponent(project) + '/commits/' + encodeURIComponent(commit), anonymizedUrl: '/projects/*/comments/*', }); } _fetchB64File(url) { return this._restApiHelper.fetch({url: this.getBaseUrl() + url}) .then(response => { if (!response.ok) { return Promise.reject(new Error(response.statusText)); } const type = response.headers.get('X-FYI-Content-Type'); return response.text() .then(text => { return {body: text, type}; }); }); } /** * @param {string} changeId * @param {string|number} patchNum * @param {string} path * @param {number=} opt_parentIndex */ getB64FileContents(changeId, patchNum, path, opt_parentIndex) { const parent = typeof opt_parentIndex === 'number' ? '?parent=' + opt_parentIndex : ''; return this._changeBaseURL(changeId, patchNum).then(url => { url = `${url}/files/${encodeURIComponent(path)}/content${parent}`; return this._fetchB64File(url); }); } getImagesForDiff(changeNum, diff, patchRange) { let promiseA; let promiseB; 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.getB64FileContents(changeNum, patchRange.patchNum, diff.meta_a.name, 1); } else { promiseA = this.getB64FileContents(changeNum, patchRange.basePatchNum, diff.meta_a.name); } } else { promiseA = Promise.resolve(null); } if (diff.meta_b && diff.meta_b.content_type.startsWith('image/')) { promiseB = this.getB64FileContents(changeNum, patchRange.patchNum, diff.meta_b.name); } else { promiseB = Promise.resolve(null); } return Promise.all([promiseA, promiseB]).then(results => { const baseImage = results[0]; const revisionImage = results[1]; // Sometimes the server doesn't send back the content type. if (baseImage) { baseImage._expectedType = diff.meta_a.content_type; baseImage._name = diff.meta_a.name; } if (revisionImage) { revisionImage._expectedType = diff.meta_b.content_type; revisionImage._name = diff.meta_b.name; } return {baseImage, revisionImage}; }); } /** * @param {number|string} changeNum * @param {?number|string=} opt_patchNum passed as null sometimes. * @param {string=} opt_project * @return {!Promise} */ _changeBaseURL(changeNum, opt_patchNum, opt_project) { // TODO(kaspern): For full slicer migration, app should warn with a call // stack every time _changeBaseURL is called without a project. const projectPromise = opt_project ? Promise.resolve(opt_project) : this.getFromProjectLookup(changeNum); return projectPromise.then(project => { let url = `/changes/${encodeURIComponent(project)}~${changeNum}`; if (opt_patchNum) { url += `/revisions/${opt_patchNum}`; } return url; }); } /** * @suppress {checkTypes} * Resulted in error: Promise.prototype.then does not match formal * parameter. */ setChangeTopic(changeNum, topic) { return this._getChangeURLAndSend({ changeNum, method: 'PUT', endpoint: '/topic', body: {topic}, parseResponse: true, reportUrlAsIs: true, }); } /** * @suppress {checkTypes} * Resulted in error: Promise.prototype.then does not match formal * parameter. */ setChangeHashtag(changeNum, hashtag) { return this._getChangeURLAndSend({ changeNum, method: 'POST', endpoint: '/hashtags', body: hashtag, parseResponse: true, reportUrlAsIs: true, }); } deleteAccountHttpPassword() { return this._restApiHelper.send({ method: 'DELETE', url: '/accounts/self/password.http', reportUrlAsIs: true, }); } /** * @suppress {checkTypes} * Resulted in error: Promise.prototype.then does not match formal * parameter. */ generateAccountHttpPassword() { return this._restApiHelper.send({ method: 'PUT', url: '/accounts/self/password.http', body: {generate: true}, parseResponse: true, reportUrlAsIs: true, }); } getAccountSSHKeys() { return this._fetchSharedCacheURL({ url: '/accounts/self/sshkeys', reportUrlAsIs: true, }); } addAccountSSHKey(key) { const req = { method: 'POST', url: '/accounts/self/sshkeys', body: key, contentType: 'text/plain', reportUrlAsIs: true, }; return this._restApiHelper.send(req) .then(response => { if (response.status < 200 && response.status >= 300) { return Promise.reject(new Error('error')); } return this.getResponseObject(response); }) .then(obj => { if (!obj.valid) { return Promise.reject(new Error('error')); } return obj; }); } deleteAccountSSHKey(id) { return this._restApiHelper.send({ method: 'DELETE', url: '/accounts/self/sshkeys/' + id, anonymizedUrl: '/accounts/self/sshkeys/*', }); } getAccountGPGKeys() { return this._restApiHelper.fetchJSON({ url: '/accounts/self/gpgkeys', reportUrlAsIs: true, }); } addAccountGPGKey(key) { const req = { method: 'POST', url: '/accounts/self/gpgkeys', body: key, reportUrlAsIs: true, }; return this._restApiHelper.send(req) .then(response => { if (response.status < 200 && response.status >= 300) { return Promise.reject(new Error('error')); } return this.getResponseObject(response); }) .then(obj => { if (!obj) { return Promise.reject(new Error('error')); } return obj; }); } deleteAccountGPGKey(id) { return this._restApiHelper.send({ method: 'DELETE', url: '/accounts/self/gpgkeys/' + id, anonymizedUrl: '/accounts/self/gpgkeys/*', }); } deleteVote(changeNum, account, label) { return this._getChangeURLAndSend({ changeNum, method: 'DELETE', endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`, anonymizedEndpoint: '/reviewers/*/votes/*', }); } setDescription(changeNum, patchNum, desc) { return this._getChangeURLAndSend({ changeNum, method: 'PUT', patchNum, endpoint: '/description', body: {description: desc}, reportUrlAsIs: true, }); } confirmEmail(token) { const req = { method: 'PUT', url: '/config/server/email.confirm', body: {token}, reportUrlAsIs: true, }; return this._restApiHelper.send(req).then(response => { if (response.status === 204) { return 'Email confirmed successfully.'; } return null; }); } getCapabilities(opt_errFn) { return this._restApiHelper.fetchJSON({ url: '/config/server/capabilities', errFn: opt_errFn, reportUrlAsIs: true, }); } getTopMenus(opt_errFn) { return this._fetchSharedCacheURL({ url: '/config/server/top-menus', errFn: opt_errFn, reportUrlAsIs: true, }); } setAssignee(changeNum, assignee) { return this._getChangeURLAndSend({ changeNum, method: 'PUT', endpoint: '/assignee', body: {assignee}, reportUrlAsIs: true, }); } deleteAssignee(changeNum) { return this._getChangeURLAndSend({ changeNum, method: 'DELETE', endpoint: '/assignee', reportUrlAsIs: true, }); } probePath(path) { return fetch(new Request(path, {method: 'HEAD'})) .then(response => response.ok); } /** * @param {number|string} changeNum * @param {number|string=} opt_message */ startWorkInProgress(changeNum, opt_message) { const body = {}; if (opt_message) { body.message = opt_message; } 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.'; } }); } /** * @param {number|string} changeNum * @param {number|string=} opt_body * @param {function(?Response, string=)=} opt_errFn */ startReview(changeNum, opt_body, opt_errFn) { return this._getChangeURLAndSend({ changeNum, method: 'POST', endpoint: '/ready', body: opt_body, errFn: opt_errFn, reportUrlAsIs: true, }); } /** * @suppress {checkTypes} * Resulted in error: Promise.prototype.then does not match formal * parameter. */ deleteComment(changeNum, patchNum, commentID, reason) { return this._getChangeURLAndSend({ changeNum, method: 'POST', patchNum, endpoint: `/comments/${commentID}/delete`, body: {reason}, parseResponse: true, anonymizedEndpoint: '/comments/*/delete', }); } /** * Given a changeNum, gets the change. * * @param {number|string} changeNum * @param {function(?Response, string=)=} opt_errFn * @return {!Promise} The change */ getChange(changeNum, opt_errFn) { // Cannot use _changeBaseURL, as this function is used by _projectLookup. return this._restApiHelper.fetchJSON({ url: `/changes/?q=change:${changeNum}`, errFn: opt_errFn, anonymizedUrl: '/changes/?q=change:*', }).then(res => { if (!res || !res.length) { return null; } return res[0]; }); } /** * @param {string|number} changeNum * @param {string=} project */ setInProjectLookup(changeNum, project) { if (this._projectLookup[changeNum] && this._projectLookup[changeNum] !== project) { console.warn('Change set with multiple project nums.' + 'One of them must be invalid.'); } this._projectLookup[changeNum] = project; } /** * Checks in _projectLookup for the changeNum. If it exists, returns the * project. If not, calls the restAPI to get the change, populates * _projectLookup with the project for that change, and returns the project. * * @param {string|number} changeNum * @return {!Promise} */ getFromProjectLookup(changeNum) { const project = this._projectLookup[changeNum]; if (project) { return Promise.resolve(project); } const onError = response => { // Fire a page error so that the visual 404 is displayed. this.fire('page-error', {response}); }; return this.getChange(changeNum, onError).then(change => { if (!change || !change.project) { return; } this.setInProjectLookup(changeNum, change.project); return change.project; }); } /** * Alias for _changeBaseURL.then(send). * * @todo(beckysiegel) clean up comments * @param {Gerrit.ChangeSendRequest} req * @return {!Promise} */ _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 => this._restApiHelper.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). * * @param {Gerrit.ChangeFetchRequest} req * @return {!Promise} */ _getChangeURLAndFetch(req, noAcceptHeader) { 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 => this._restApiHelper.fetchJSON({ url: url + req.endpoint, errFn: req.errFn, params: req.params, fetchOptions: req.fetchOptions, anonymizedUrl: anonymizedEndpoint ? (anonymizedBaseUrl + anonymizedEndpoint) : undefined, }, noAcceptHeader)); } /** * 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, }); } /** * Get blame information for the given diff. * * @param {string|number} changeNum * @param {string|number} patchNum * @param {string} path * @param {boolean=} opt_base If true, requests blame for the base of the * diff, rather than the revision. * @return {!Promise} */ getBlame(changeNum, patchNum, path, opt_base) { const encodedPath = encodeURIComponent(path); return this._getChangeURLAndFetch({ changeNum, endpoint: `/files/${encodedPath}/blame`, patchNum, params: opt_base ? {base: 't'} : undefined, anonymizedEndpoint: '/files/*/blame', }); } /** * Modify the given create draft request promise so that it fails and throws * an error if the response bears HTTP status 200 instead of HTTP 201. * * @see Issue 7763 * @param {Promise} promise The original promise. * @return {Promise} The modified promise. */ _failForCreate200(promise) { return promise.then(result => { if (result.status === 200) { // Read the response headers into an object representation. const headers = Array.from(result.headers.entries()) .reduce((obj, [key, val]) => { if (!HEADER_REPORTING_BLACKLIST.test(key)) { obj[key] = val; } return obj; }, {}); const err = new Error([ CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE, JSON.stringify(headers), ].join('\n')); // Throw the error so that it is caught by gr-reporting. throw err; } 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} */ 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} */ 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._restApiHelper.send({ method: 'POST', url: '/accounts/self/drafts:delete', body: {query}, }); } } customElements.define(GrRestApiInterface.is, GrRestApiInterface); })();