| /** |
| * @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', |
| }; |
| const JSON_PREFIX = ')]}\''; |
| const MAX_PROJECT_RESULTS = 25; |
| const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900; |
| const PARENT_PATCH_NUM = 'PARENT'; |
| const FAILED_TO_FETCH_ERROR = 'Failed to fetch'; |
| |
| 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/*'; |
| |
| /** |
| * 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, |
| ], |
| |
| /** |
| * Fired when an server error occurs. |
| * |
| * @event server-error |
| */ |
| |
| /** |
| * Fired when a network error occurs. |
| * |
| * @event network-error |
| */ |
| |
| /** |
| * Fired when credentials were rejected by server (e.g. expired). |
| * |
| * @event auth-error |
| */ |
| |
| /** |
| * Fired after an RPC completes. |
| * |
| * @event rpc-log |
| */ |
| |
| properties: { |
| _cache: { |
| type: Object, |
| value: new SiteBasedCache(), // Shared across instances. |
| }, |
| _credentialCheck: { |
| type: Object, |
| value: {checking: false}, // Shared across instances. |
| }, |
| _sharedFetchPromises: { |
| type: Object, |
| value: {}, // Intentional to share the object 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. |
| }, |
| _auth: { |
| type: Object, |
| value: Gerrit.Auth, // Share across instances. |
| }, |
| }, |
| |
| 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 {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 res; |
| }).catch(err => { |
| const isLoggedIn = !!this._cache.get('/accounts/self/detail'); |
| if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) { |
| this.checkCredentials(); |
| return; |
| } |
| if (req.errFn) { |
| req.errFn.call(undefined, null, err); |
| } else { |
| this.fire('network-error', {error: err}); |
| } |
| throw err; |
| }); |
| }, |
| |
| /** |
| * Fetch JSON from url provided. |
| * Returns a Promise that resolves to a parsed response. |
| * Same as {@link _fetchRawJSON}, plus error handling. |
| * @param {Defs.FetchJSONRequest} req |
| */ |
| _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); |
| }); |
| }, |
| |
| /** |
| * @param {string} url |
| * @param {?Object=} opt_params URL params, key-value hash. |
| * @return {string} |
| */ |
| _urlWithParams(url, opt_params) { |
| if (!opt_params) { return this.getBaseUrl() + url; } |
| |
| const params = []; |
| for (const p in opt_params) { |
| if (!opt_params.hasOwnProperty(p)) { continue; } |
| if (opt_params[p] == null) { |
| params.push(encodeURIComponent(p)); |
| continue; |
| } |
| for (const value of [].concat(opt_params[p])) { |
| params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`); |
| } |
| } |
| return this.getBaseUrl() + url + '?' + params.join('&'); |
| }, |
| |
| /** |
| * @param {!Object} response |
| * @return {?} |
| */ |
| getResponseObject(response) { |
| return this._readResponsePayload(response) |
| .then(payload => payload.parsed); |
| }, |
| |
| /** |
| * @param {!Object} response |
| * @return {!Object} |
| */ |
| _readResponsePayload(response) { |
| return response.text().then(text => { |
| let result; |
| try { |
| result = this._parsePrefixedJSON(text); |
| } catch (_) { |
| result = null; |
| } |
| return {parsed: result, raw: text}; |
| }); |
| }, |
| |
| /** |
| * @param {string} source |
| * @return {?} |
| */ |
| _parsePrefixedJSON(source) { |
| return JSON.parse(source.substring(JSON_PREFIX.length)); |
| }, |
| |
| 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/*', |
| }); |
| }, |
| |
| 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 encodeName = encodeURIComponent(repo); |
| return this._send({ |
| method: 'PUT', |
| url: `/projects/${encodeName}/config`, |
| 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._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._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._send({ |
| method: 'PUT', |
| url: `/groups/${encodeName}`, |
| body: config, |
| errFn: opt_errFn, |
| anonymizedUrl: '/groups/*', |
| }); |
| }, |
| |
| getGroupConfig(group, opt_errFn) { |
| return this._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._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._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._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._send({ |
| method: 'PUT', |
| url: `/projects/${encodeName}/tags/${encodeTag}`, |
| body: revision, |
| errFn: opt_errFn, |
| anonymizedUrl: '/projects/*/tags/*', |
| }); |
| }, |
| |
| /** |
| * @param {!string} groupName |
| * @returns {!Promise<boolean>} |
| */ |
| getIsGroupOwner(groupName) { |
| const encodeName = encodeURIComponent(groupName); |
| const req = { |
| url: `/groups/?owned&q=${encodeName}`, |
| anonymizedUrl: '/groups/owned&q=*', |
| }; |
| return this._fetchSharedCacheURL(req) |
| .then(configs => configs.hasOwnProperty(groupName)); |
| }, |
| |
| getGroupMembers(groupName, opt_errFn) { |
| const encodeName = encodeURIComponent(groupName); |
| return this._fetchJSON({ |
| url: `/groups/${encodeName}/members/`, |
| errFn: opt_errFn, |
| anonymizedUrl: '/groups/*/members', |
| }); |
| }, |
| |
| getIncludedGroup(groupName) { |
| return this._fetchJSON({ |
| url: `/groups/${encodeURIComponent(groupName)}/groups/`, |
| anonymizedUrl: '/groups/*/groups', |
| }); |
| }, |
| |
| saveGroupName(groupId, name) { |
| const encodeId = encodeURIComponent(groupId); |
| return this._send({ |
| method: 'PUT', |
| url: `/groups/${encodeId}/name`, |
| body: {name}, |
| anonymizedUrl: '/groups/*/name', |
| }); |
| }, |
| |
| saveGroupOwner(groupId, ownerId) { |
| const encodeId = encodeURIComponent(groupId); |
| 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({ |
| method: 'PUT', |
| url: `/groups/${encodeId}/description`, |
| body: {description}, |
| anonymizedUrl: '/groups/*/description', |
| }); |
| }, |
| |
| saveGroupOptions(groupId, options) { |
| const encodeId = encodeURIComponent(groupId); |
| return this._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._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._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({ |
| method: 'DELETE', |
| url: `/groups/${encodeName}/members/${encodeMember}`, |
| anonymizedUrl: '/groups/*/members/*', |
| }); |
| }, |
| |
| deleteIncludedGroup(groupName, includedGroup) { |
| const encodeName = encodeURIComponent(groupName); |
| const encodeIncludedGroup = encodeURIComponent(includedGroup); |
| return this._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._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 |
| */ |
| saveEditPreferences(prefs, opt_errFn) { |
| // Invalidate the cache. |
| 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({ |
| 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, |
| }); |
| }, |
| |
| /** |
| * @param {string} userId the ID of the user usch as an email address. |
| * @return {!Promise<!Object>} |
| */ |
| getAccountDetails(userId) { |
| return this._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._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._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._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._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._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._send(req) |
| .then(newStatus => this._updateCachedAccount({status: newStatus})); |
| }, |
| |
| getAccountStatus(userId) { |
| return this._fetchJSON({ |
| url: `/accounts/${encodeURIComponent(userId)}/status`, |
| anonymizedUrl: '/accounts/*/status', |
| }); |
| }, |
| |
| getAccountGroups() { |
| return this._fetchJSON({ |
| url: '/accounts/self/groups', |
| reportUrlAsIs: true, |
| }); |
| }, |
| |
| getAccountAgreements() { |
| return this._fetchJSON({ |
| url: '/accounts/self/agreements', |
| reportUrlAsIs: true, |
| }); |
| }, |
| |
| saveAccountAgreement(name) { |
| return this._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 => { return encodeURIComponent(param); }) |
| .join('&q='); |
| } |
| return this._fetchSharedCacheURL({ |
| url: '/accounts/self/capabilities' + queryString, |
| anonymizedUrl: '/accounts/self/capabilities?q=*', |
| }); |
| }, |
| |
| getLoggedIn() { |
| return this.getAccount().then(account => { |
| return account != null; |
| }); |
| }, |
| |
| getIsAdmin() { |
| return this.getLoggedIn().then(isLoggedIn => { |
| if (isLoggedIn) { |
| return this.getAccountCapabilities(); |
| } else { |
| return Promise.resolve(); |
| } |
| }).then(capabilities => { |
| return capabilities && capabilities.administrateServer; |
| }); |
| }, |
| |
| 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(req).then(res => { |
| if (!res) { return; } |
| if (res.status === 403) { |
| this.fire('auth-error'); |
| this._cache.delete('/accounts/self/detail'); |
| } else if (res.ok) { |
| return this.getResponseObject(res); |
| } |
| }).then(res => { |
| this._credentialCheck.checking = false; |
| if (res) { |
| this._cache.delete('/accounts/self/detail'); |
| } |
| return res; |
| }).catch(err => { |
| this._credentialCheck.checking = false; |
| }); |
| }, |
| |
| 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()) { |
| 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._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._send({ |
| method: 'POST', |
| url: '/accounts/self/watched.projects:delete', |
| body: projects, |
| errFn: opt_errFn, |
| reportUrlAsIs: true, |
| }); |
| }, |
| |
| /** |
| * @param {Defs.FetchJSONRequest} req |
| */ |
| _fetchSharedCacheURL(req) { |
| if (this._sharedFetchPromises[req.url]) { |
| return this._sharedFetchPromises[req.url]; |
| } |
| // TODO(andybons): Periodic cache invalidation. |
| if (this._cache.has(req.url)) { |
| return Promise.resolve(this._cache.get(req.url)); |
| } |
| this._sharedFetchPromises[req.url] = this._fetchJSON(req) |
| .then(response => { |
| if (response !== undefined) { |
| this._cache.set(req.url, response); |
| } |
| this._sharedFetchPromises[req.url] = undefined; |
| return response; |
| }).catch(err => { |
| this._sharedFetchPromises[req.url] = undefined; |
| throw err; |
| }); |
| 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() { |
| return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX; |
| }, |
| |
| /** |
| * @param {number=} opt_changesPerPage |
| * @param {string|!Array<string>=} opt_query A query or an array of queries. |
| * @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 |
| * 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._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<string>} |
| */ |
| 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) { |
| 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); |
| }); |
| }, |
| |
| /** |
| * @param {number|string} changeNum |
| * @param {function(?Response, string=)=} opt_errFn |
| * @param {function()=} opt_cancelCondition |
| */ |
| getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) { |
| const params = this.listChangesOptionsToHex( |
| this.ListChangesOption.ALL_COMMITS, |
| this.ListChangesOption.ALL_REVISIONS, |
| this.ListChangesOption.SKIP_MERGEABLE |
| ); |
| return this._getChangeDetail(changeNum, params, opt_errFn, |
| opt_cancelCondition); |
| }, |
| |
| /** |
| * @param {number|string} changeNum |
| * @param {function(?Response, string=)=} opt_errFn |
| * @param {function()=} opt_cancelCondition |
| */ |
| _getChangeDetail(changeNum, params, opt_errFn, opt_cancelCondition) { |
| return this.getChangeActionURL(changeNum, null, '/detail').then(url => { |
| const urlWithParams = this._urlWithParams(url, params); |
| 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))); |
| } |
| |
| 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._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 {Defs.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 {Defs.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<!Object>} |
| */ |
| queryChangeFiles(changeNum, patchNum, query) { |
| return this._getChangeURLAndFetch({ |
| changeNum, |
| endpoint: `/files?q=${encodeURIComponent(query)}`, |
| patchNum, |
| anonymizedEndpoint: '/files?q=*', |
| }); |
| }, |
| |
| /** |
| * @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); |
| }, |
| |
| /** |
| * The closure compiler doesn't realize this.specialFilePathCompare is |
| * valid. |
| * @suppress {checkTypes} |
| */ |
| getChangeFilePathsAsSpeciallySortedArray(changeNum, patchRange) { |
| return this.getChangeFiles(changeNum, patchRange).then(files => { |
| return Object.keys(files).sort(this.specialFilePathCompare); |
| }); |
| }, |
| |
| 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) { |
| const params = {n: 10}; |
| 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._invalidateSharedFetchPromisesPrefix('/groups/?'); |
| }, |
| |
| invalidateReposCache(filter, reposPerPage, opt_offset) { |
| this._invalidateSharedFetchPromisesPrefix('/projects/?'); |
| }, |
| |
| /** |
| * @param {string} filter |
| * @param {number} groupsPerPage |
| * @param {number=} opt_offset |
| * @return {!Promise<?Object>} |
| */ |
| 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<?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>} |
| */ |
| 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?*', |
| }); |
| }, |
| |
| /** |
| * @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, 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', |
| }); |
| }, |
| |
| 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', |
| }); |
| }, |
| |
| 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', |
| }); |
| }, |
| |
| 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 |
| */ |
| getSuggestedGroups(inputVal, opt_n, opt_errFn) { |
| const params = {s: inputVal}; |
| if (opt_n) { params.n = opt_n; } |
| return this._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._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._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._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 is:mergeable conflicts:' + changeNum, |
| }; |
| return this._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._fetchJSON({ |
| url: '/changes/', |
| params, |
| anonymizedUrl: '/changes/change:*', |
| }); |
| }, |
| |
| getChangesWithSameTopic(topic) { |
| const options = this.listChangesOptionsToHex( |
| this.ListChangesOption.LABELS, |
| this.ListChangesOption.CURRENT_REVISION, |
| this.ListChangesOption.CURRENT_COMMIT, |
| this.ListChangesOption.DETAILED_LABELS |
| ); |
| const params = { |
| O: options, |
| q: 'status:open topic:' + topic, |
| }; |
| return this._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]) => { |
| 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 => { |
| 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._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/*', |
| }); |
| }, |
| |
| // 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._send({ |
| method: starred ? 'PUT' : 'DELETE', |
| url, |
| anonymizedUrl: '/accounts/self/starred.changes/*', |
| }); |
| }); |
| }, |
| |
| saveChangeReviewed(changeNum, reviewed) { |
| return this._getChangeURLAndSend({ |
| changeNum, |
| method: 'PUT', |
| endpoint: reviewed ? '/reviewed' : '/unreviewed', |
| }); |
| }, |
| |
| /** |
| * Send an XHR. |
| * @param {Defs.SendRequest} req |
| * @return {Promise} |
| */ |
| _send(req) { |
| const options = {method: req.method}; |
| if (req.body) { |
| options.headers = new Headers(); |
| options.headers.set( |
| 'Content-Type', req.contentType || 'application/json'); |
| options.body = typeof req.body === 'string' ? |
| req.body : JSON.stringify(req.body); |
| } |
| 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]); |
| } |
| } |
| 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 (req.errFn) { |
| return req.errFn.call(undefined, response); |
| } |
| this.fire('server-error', {request: fetchReq, response}); |
| } |
| return response; |
| }).catch(err => { |
| this.fire('network-error', {error: 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 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`; |
| |
| return this._getChangeURLAndFetch({ |
| changeNum, |
| endpoint, |
| patchNum, |
| errFn: opt_errFn, |
| params, |
| anonymizedEndpoint: '/files/*/diff', |
| }); |
| }, |
| |
| /** |
| * @param {number|string} changeNum |
| * @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); |
| }, |
| |
| /** |
| * @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); |
| }, |
| |
| /** |
| * 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<!Object>} |
| */ |
| 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) => { |
| return 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<!Object>} |
| */ |
| _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<!Object>} Diff comments response. |
| */ |
| const fetchComments = opt_patchNum => { |
| return this._getChangeURLAndFetch({ |
| changeNum, |
| endpoint, |
| patchNum: opt_patchNum, |
| reportEndpointAsIs: true, |
| }); |
| }; |
| |
| 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(() => { |
| return 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<undefined>} 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._fetchJSON({ |
| url: '/projects/' + encodeURIComponent(project) + |
| '/commits/' + encodeURIComponent(commit), |
| anonymizedUrl: '/projects/*/comments/*', |
| }); |
| }, |
| |
| _fetchB64File(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'); |
| 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<string>} |
| */ |
| _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._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._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: 'plain/text', |
| 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.valid) { return Promise.reject(); } |
| return obj; |
| }); |
| }, |
| |
| deleteAccountSSHKey(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) { |
| 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._send(req).then(response => { |
| if (response.status === 204) { |
| return 'Email confirmed successfully.'; |
| } |
| return null; |
| }); |
| }, |
| |
| 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) { |
| 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 => { |
| return 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<?Object>} The change |
| */ |
| getChange(changeNum, opt_errFn) { |
| // Cannot use _changeBaseURL, as this function is used by _projectLookup. |
| 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]; |
| }); |
| }, |
| |
| /** |
| * @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<string|undefined>} |
| */ |
| 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 {Defs.ChangeSendRequest} req |
| * @return {!Promise<!Object>} |
| */ |
| _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). |
| * @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, |
| }); |
| }, |
| |
| /** |
| * 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<!Object>} |
| */ |
| 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<!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}, |
| }); |
| }, |
| }); |
| })(); |