/** * @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. */ /* NB: Order is important, because of namespaced classes. */ import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners'; import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin'; import {PolymerElement} from '@polymer/polymer/polymer-element'; import {GrEtagDecorator} from './gr-etag-decorator'; import { FetchJSONRequest, FetchParams, FetchPromisesCache, GrRestApiHelper, SendJSONRequest, SendRequest, SiteBasedCache, } from './gr-rest-apis/gr-rest-api-helper'; import { GrReviewerUpdatesParser, ParsedChangeInfo, } from './gr-reviewer-updates-parser'; import {parseDate} from '../../../utils/date-util'; import {getBaseUrl} from '../../../utils/url-util'; import {appContext} from '../../../services/app-context'; import { getParentIndex, isMergeParent, patchNumEquals, } from '../../../utils/patch-set-util'; import { ListChangesOption, listChangesOptionsToHex, } from '../../../utils/change-util'; import {assertNever, hasOwnProperty} from '../../../utils/common-util'; import {customElement, property} from '@polymer/decorators'; import {AuthRequestInit, AuthService} from '../../../services/gr-auth/gr-auth'; import { AccountCapabilityInfo, AccountDetailInfo, AccountExternalIdInfo, AccountId, AccountInfo, AssigneeInput, Base64File, Base64FileContent, Base64ImageFile, BranchInfo, BranchName, ChangeId, ChangeInfo, ChangeMessageId, CommentInfo, CommentInput, CommitId, CommitInfo, ConfigInfo, ConfigInput, DashboardId, DashboardInfo, DeleteDraftCommentsInput, DiffPreferenceInput, EditPatchSetNum, EditPreferencesInfo, EncodedGroupId, GitRef, GpgKeyId, GroupId, GroupInfo, GroupInput, GroupOptionsInput, HashtagsInput, ImagesForDiff, NameToProjectInfoMap, ParentPatchSetNum, ParsedJSON, PatchRange, PatchSetNum, PathToCommentsInfoMap, PathToRobotCommentsInfoMap, PreferencesInfo, PreferencesInput, ProjectAccessInfoMap, ProjectAccessInput, ProjectInfo, ProjectInput, ProjectWatchInfo, RepoName, ReviewInput, ServerInfo, SshKeyInfo, UrlEncodedCommentId, EditInfo, FileNameToFileInfoMap, SuggestedReviewerInfo, GroupNameToGroupInfoMap, GroupAuditEventInfo, RequestPayload, Password, ContributorAgreementInput, ContributorAgreementInfo, BranchInput, IncludedInInfo, TagInput, PluginInfo, GpgKeyInfo, GpgKeysInput, DocResult, EmailInfo, ProjectAccessInfo, CapabilityInfoMap, ProjectInfoWithName, TagInfo, RelatedChangesInfo, SubmittedTogetherInfo, NumericChangeId, EmailAddress, FixId, FilePathToDiffInfoMap, ChangeViewChangeInfo, BlameInfo, ActionNameToActionInfoMap, RevisionId, GroupName, Hashtag, TopMenuEntryInfo, MergeableInfo, } from '../../../types/common'; import { DiffInfo, DiffPreferencesInfo, IgnoreWhitespaceType, } from '../../../types/diff'; import { CancelConditionCallback, ErrorCallback, RestApiService, GetDiffCommentsOutput, GetDiffRobotCommentsOutput, } from '../../../services/services/gr-rest-api/gr-rest-api'; import { CommentSide, DiffViewMode, HttpMethod, ReviewerState, } from '../../../constants/constants'; import {firePageError, fireServerError} from '../../../utils/event-util'; 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 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_BLOCK_REGEX = /^set-cookie$/i; const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*'; const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL + '/revisions/*'; let siteBasedCache = new SiteBasedCache(); // Shared across instances. let fetchPromisesCache = new FetchPromisesCache(); // Shared across instances. let pendingRequest: {[promiseName: string]: Array>} = {}; // Shared across instances. let grEtagDecorator = new GrEtagDecorator(); // Shared across instances. let projectLookup: {[changeNum: string]: RepoName} = {}; // Shared across instances. interface FetchChangeJSON { reportEndpointAsIs?: boolean; endpoint: string; anonymizedEndpoint?: string; revision?: RevisionId; changeNum: NumericChangeId; errFn?: ErrorCallback; params?: FetchParams; fetchOptions?: AuthRequestInit; // TODO(TS): The following properties are not used, however some methods // set them to true. They should be either changed to reportEndpointAsIs: true // or deleted. This should be done carefully case by case. reportEndpointAsId?: true; } interface SendChangeRequestBase { patchNum?: PatchSetNum; reportEndpointAsIs?: boolean; endpoint: string; anonymizedEndpoint?: string; changeNum: NumericChangeId; method: HttpMethod | undefined; errFn?: ErrorCallback; headers?: Record; contentType?: string; body?: string | object; // TODO(TS): The following properties are not used, however some methods // set them to true. They should be either changed to reportEndpointAsIs: true // or deleted. This should be done carefully case by case. reportUrlAsIs?: true; reportEndpointAsId?: true; } interface SendRawChangeRequest extends SendChangeRequestBase { parseResponse?: false | null; } interface SendJSONChangeRequest extends SendChangeRequestBase { parseResponse: true; } interface QueryChangesParams { [paramName: string]: string | undefined | number | string[]; O?: string; // options S: number; // start n?: number; // changes per page q?: string | string[]; // query/queries } interface QueryAccountsParams { [paramName: string]: string | undefined | null | number; suggest: null; q: string; n?: number; } interface QueryGroupsParams { [paramName: string]: string | undefined | null | number; s: string; n?: number; } interface QuerySuggestedReviewersParams { [paramName: string]: string | undefined | null | number; n: number; q?: string; 'reviewer-state': ReviewerState; } interface GetDiffParams { [paramName: string]: string | undefined | null | number | boolean; intraline?: boolean | null; whitespace?: IgnoreWhitespaceType; parent?: number; base?: PatchSetNum; } type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest; export function _testOnlyResetGrRestApiSharedObjects() { // TODO(TS): The commented code below didn't do anything. // It is impossible to reject an existing promise. Should be rewritten in a // different way // const fetchPromisesCacheData = fetchPromisesCache.testOnlyGetData(); // for (const key in fetchPromisesCacheData) { // if (hasOwnProperty(fetchPromisesCacheData, key)) { // // reject already fulfilled promise does nothing // fetchPromisesCacheData[key]!.reject(); // } // } // // for (const key in pendingRequest) { // if (!hasOwnProperty(pendingRequest, key)) { // continue; // } // for (const req of pendingRequest[key]) { // // reject already fulfilled promise does nothing // req.reject(); // } // } siteBasedCache = new SiteBasedCache(); fetchPromisesCache = new FetchPromisesCache(); pendingRequest = {}; grEtagDecorator = new GrEtagDecorator(); projectLookup = {}; appContext.authService.clearCache(); } declare global { interface HTMLElementTagNameMap { 'gr-rest-api-interface': GrRestApiInterface; } } @customElement('gr-rest-api-interface') export class GrRestApiInterface extends GestureEventListeners(LegacyElementMixin(PolymerElement)) implements RestApiService { readonly JSON_PREFIX = JSON_PREFIX; @property({type: Object}) readonly _cache = siteBasedCache; // Shared across instances. @property({type: Object}) readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances. @property({type: Object}) readonly _pendingRequests = pendingRequest; // Shared across instances. @property({type: Object}) readonly _etags = grEtagDecorator; // Shared across instances. @property({type: Object}) readonly _projectLookup = projectLookup; // Shared across instances. // The value is set in created, before any other actions private authService: AuthService; // The value is set in created, before any other actions private readonly _restApiHelper: GrRestApiHelper; constructor(authService?: AuthService) { super(); // TODO: Make the authService constructor parameter required when we have // changed all usages of this class to not instantiate via createElement(). this.authService = authService ?? appContext.authService; this._restApiHelper = new GrRestApiHelper( this._cache, this.authService, this._sharedFetchPromises ); } _fetchSharedCacheURL(req: FetchJSONRequest): Promise { // Cache is shared across instances return this._restApiHelper.fetchCacheURL(req); } getResponseObject(response: Response): Promise { return this._restApiHelper.getResponseObject(response); } getConfig(noCache?: boolean): Promise { if (!noCache) { return this._fetchSharedCacheURL({ url: '/config/server/info', reportUrlAsIs: true, }) as Promise; } return this._restApiHelper.fetchJSON({ url: '/config/server/info', reportUrlAsIs: true, }) as Promise; } getRepo( repo: RepoName, errFn?: ErrorCallback ): Promise { // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. return this._fetchSharedCacheURL({ url: '/projects/' + encodeURIComponent(repo), errFn, anonymizedUrl: '/projects/*', }) as Promise; } getProjectConfig( repo: RepoName, errFn?: ErrorCallback ): Promise { // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. return this._fetchSharedCacheURL({ url: '/projects/' + encodeURIComponent(repo) + '/config', errFn, anonymizedUrl: '/projects/*/config', }) as Promise; } getRepoAccess(repo: RepoName): Promise { // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. return this._fetchSharedCacheURL({ url: '/access/?project=' + encodeURIComponent(repo), anonymizedUrl: '/access/?project=*', }) as Promise; } getRepoDashboards( repo: RepoName, errFn?: ErrorCallback ): Promise { // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. return this._fetchSharedCacheURL({ url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`, errFn, anonymizedUrl: '/projects/*/dashboards?inherited', }) as Promise; } saveRepoConfig(repo: RepoName, config: ConfigInput): Promise { // 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: HttpMethod.PUT, url, body: config, anonymizedUrl: '/projects/*/config', }); } runRepoGC(repo: RepoName): Promise { // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. const encodeName = encodeURIComponent(repo); return this._restApiHelper.send({ method: HttpMethod.POST, url: `/projects/${encodeName}/gc`, body: '', anonymizedUrl: '/projects/*/gc', }); } createRepo(config: ProjectInput & {name: RepoName}): Promise { // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. const encodeName = encodeURIComponent(config.name); return this._restApiHelper.send({ method: HttpMethod.PUT, url: `/projects/${encodeName}`, body: config, anonymizedUrl: '/projects/*', }); } createGroup(config: GroupInput & {name: string}): Promise { const encodeName = encodeURIComponent(config.name); return this._restApiHelper.send({ method: HttpMethod.PUT, url: `/groups/${encodeName}`, body: config, anonymizedUrl: '/groups/*', }); } getGroupConfig( group: GroupId | GroupName, errFn?: ErrorCallback ): Promise { return this._restApiHelper.fetchJSON({ url: `/groups/${encodeURIComponent(group)}/detail`, errFn, anonymizedUrl: '/groups/*/detail', }) as Promise; } deleteRepoBranches(repo: RepoName, ref: GitRef): Promise { // 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: HttpMethod.DELETE, url: `/projects/${encodeName}/branches/${encodeRef}`, body: '', anonymizedUrl: '/projects/*/branches/*', }); } deleteRepoTags(repo: RepoName, ref: GitRef): Promise { // 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: HttpMethod.DELETE, url: `/projects/${encodeName}/tags/${encodeRef}`, body: '', anonymizedUrl: '/projects/*/tags/*', }); } createRepoBranch( name: RepoName, branch: BranchName, revision: BranchInput ): Promise { // 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: HttpMethod.PUT, url: `/projects/${encodeName}/branches/${encodeBranch}`, body: revision, anonymizedUrl: '/projects/*/branches/*', }); } createRepoTag( name: RepoName, tag: string, revision: TagInput ): Promise { // 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: HttpMethod.PUT, url: `/projects/${encodeName}/tags/${encodeTag}`, body: revision, anonymizedUrl: '/projects/*/tags/*', }); } getIsGroupOwner(groupName: GroupName): Promise { const encodeName = encodeURIComponent(groupName); const req = { url: `/groups/?owned&g=${encodeName}`, anonymizedUrl: '/groups/owned&g=*', }; return this._fetchSharedCacheURL(req).then(configs => hasOwnProperty(configs, groupName) ); } getGroupMembers(groupName: GroupId | GroupName): Promise { const encodeName = encodeURIComponent(groupName); return (this._restApiHelper.fetchJSON({ url: `/groups/${encodeName}/members/`, anonymizedUrl: '/groups/*/members', }) as unknown) as Promise; } getIncludedGroup( groupName: GroupId | GroupName ): Promise { return this._restApiHelper.fetchJSON({ url: `/groups/${encodeURIComponent(groupName)}/groups/`, anonymizedUrl: '/groups/*/groups', }) as Promise; } saveGroupName(groupId: GroupId | GroupName, name: string): Promise { const encodeId = encodeURIComponent(groupId); return this._restApiHelper.send({ method: HttpMethod.PUT, url: `/groups/${encodeId}/name`, body: {name}, anonymizedUrl: '/groups/*/name', }); } saveGroupOwner( groupId: GroupId | GroupName, ownerId: string ): Promise { const encodeId = encodeURIComponent(groupId); return this._restApiHelper.send({ method: HttpMethod.PUT, url: `/groups/${encodeId}/owner`, body: {owner: ownerId}, anonymizedUrl: '/groups/*/owner', }); } saveGroupDescription( groupId: GroupId | GroupName, description: string ): Promise { const encodeId = encodeURIComponent(groupId); return this._restApiHelper.send({ method: HttpMethod.PUT, url: `/groups/${encodeId}/description`, body: {description}, anonymizedUrl: '/groups/*/description', }); } saveGroupOptions( groupId: GroupId | GroupName, options: GroupOptionsInput ): Promise { const encodeId = encodeURIComponent(groupId); return this._restApiHelper.send({ method: HttpMethod.PUT, url: `/groups/${encodeId}/options`, body: options, anonymizedUrl: '/groups/*/options', }); } getGroupAuditLog( group: EncodedGroupId, errFn?: ErrorCallback ): Promise { return this._fetchSharedCacheURL({ url: `/groups/${group}/log.audit`, errFn, anonymizedUrl: '/groups/*/log.audit', }) as Promise; } saveGroupMember( groupName: GroupId | GroupName, groupMember: AccountId ): Promise { const encodeName = encodeURIComponent(groupName); const encodeMember = encodeURIComponent(`${groupMember}`); return (this._restApiHelper.send({ method: HttpMethod.PUT, url: `/groups/${encodeName}/members/${encodeMember}`, parseResponse: true, anonymizedUrl: '/groups/*/members/*', }) as unknown) as Promise; } saveIncludedGroup( groupName: GroupId | GroupName, includedGroup: GroupId, errFn?: ErrorCallback ): Promise { const encodeName = encodeURIComponent(groupName); const encodeIncludedGroup = encodeURIComponent(includedGroup); const req = { method: HttpMethod.PUT, url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`, errFn, anonymizedUrl: '/groups/*/groups/*', }; return this._restApiHelper.send(req).then(response => { if (response?.ok) { return (this.getResponseObject(response) as unknown) as Promise< GroupInfo >; } return undefined; }); } deleteGroupMember( groupName: GroupId | GroupName, groupMember: AccountId ): Promise { const encodeName = encodeURIComponent(groupName); const encodeMember = encodeURIComponent(`${groupMember}`); return this._restApiHelper.send({ method: HttpMethod.DELETE, url: `/groups/${encodeName}/members/${encodeMember}`, anonymizedUrl: '/groups/*/members/*', }); } deleteIncludedGroup( groupName: GroupId, includedGroup: GroupId | GroupName ): Promise { const encodeName = encodeURIComponent(groupName); const encodeIncludedGroup = encodeURIComponent(includedGroup); return this._restApiHelper.send({ method: HttpMethod.DELETE, url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`, anonymizedUrl: '/groups/*/groups/*', }); } getVersion(): Promise { return this._fetchSharedCacheURL({ url: '/config/server/version', reportUrlAsIs: true, }) as Promise; } getDiffPreferences(): Promise { return this.getLoggedIn().then(loggedIn => { if (loggedIn) { return this._fetchSharedCacheURL({ url: '/accounts/self/preferences.diff', reportUrlAsIs: true, }) as Promise; } const anonymousResult: DiffPreferencesInfo = { 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', }; // 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(anonymousResult); }); } getEditPreferences(): Promise { return this.getLoggedIn().then(loggedIn => { if (loggedIn) { return this._fetchSharedCacheURL({ url: '/accounts/self/preferences.edit', reportUrlAsIs: true, }) as Promise; } const result: EditPreferencesInfo = { 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', }; // These defaults should match the defaults in // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java return Promise.resolve(result); }); } savePreferences(prefs: PreferencesInput): Promise { // 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: HttpMethod.PUT, url: '/accounts/self/preferences', body: prefs, reportUrlAsIs: true, }); } saveDiffPreferences(prefs: DiffPreferenceInput): Promise { // Invalidate the cache. this._cache.delete('/accounts/self/preferences.diff'); return this._restApiHelper.send({ method: HttpMethod.PUT, url: '/accounts/self/preferences.diff', body: prefs, reportUrlAsIs: true, }); } saveEditPreferences(prefs: EditPreferencesInfo): Promise { // Invalidate the cache. this._cache.delete('/accounts/self/preferences.edit'); return this._restApiHelper.send({ method: HttpMethod.PUT, url: '/accounts/self/preferences.edit', body: prefs, reportUrlAsIs: true, }); } getAccount(): Promise { return this._fetchSharedCacheURL({ url: '/accounts/self/detail', reportUrlAsIs: true, errFn: resp => { if (!resp || resp.status === 403) { this._cache.delete('/accounts/self/detail'); } }, }) as Promise; } 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'); } }, }) as Promise; } getExternalIds() { return this._restApiHelper.fetchJSON({ url: '/accounts/self/external.ids', reportUrlAsIs: true, }) as Promise; } deleteAccountIdentity(id: string[]) { return this._restApiHelper.send({ method: HttpMethod.POST, url: '/accounts/self/external.ids:delete', body: id, parseResponse: true, reportUrlAsIs: true, }) as Promise; } getAccountDetails(userId: AccountId): Promise { return this._restApiHelper.fetchJSON({ url: `/accounts/${encodeURIComponent(userId)}/detail`, anonymizedUrl: '/accounts/*/detail', }) as Promise; } getAccountEmails() { return this._fetchSharedCacheURL({ url: '/accounts/self/emails', reportUrlAsIs: true, }) as Promise; } addAccountEmail(email: string): Promise { return this._restApiHelper.send({ method: HttpMethod.PUT, url: '/accounts/self/emails/' + encodeURIComponent(email), anonymizedUrl: '/account/self/emails/*', }); } deleteAccountEmail(email: string): Promise { return this._restApiHelper.send({ method: HttpMethod.DELETE, url: '/accounts/self/emails/' + encodeURIComponent(email), anonymizedUrl: '/accounts/self/email/*', }); } setPreferredAccountEmail(email: string): Promise { // TODO(TS): add correct error handling const encodedEmail = encodeURIComponent(email); const req = { method: HttpMethod.PUT, url: `/accounts/self/emails/${encodedEmail}/preferred`, 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); } }); } _updateCachedAccount(obj: Partial): void { // 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', {...cachedAccount, ...obj}); } } setAccountName(name: string): Promise { // TODO(TS): add correct error handling const req: SendJSONRequest = { method: HttpMethod.PUT, url: '/accounts/self/name', body: {name}, parseResponse: true, reportUrlAsIs: true, }; return this._restApiHelper .send(req) .then(newName => this._updateCachedAccount({name: (newName as unknown) as string}) ); } setAccountUsername(username: string): Promise { // TODO(TS): add correct error handling const req: SendJSONRequest = { method: HttpMethod.PUT, url: '/accounts/self/username', body: {username}, parseResponse: true, reportUrlAsIs: true, }; return this._restApiHelper .send(req) .then(newName => this._updateCachedAccount({username: (newName as unknown) as string}) ); } setAccountDisplayName(displayName: string): Promise { // TODO(TS): add correct error handling const req: SendJSONRequest = { method: HttpMethod.PUT, url: '/accounts/self/displayname', body: {display_name: displayName}, parseResponse: true, reportUrlAsIs: true, }; return this._restApiHelper.send(req).then(newName => this._updateCachedAccount({ display_name: (newName as unknown) as string, }) ); } setAccountStatus(status: string): Promise { // TODO(TS): add correct error handling const req: SendJSONRequest = { method: HttpMethod.PUT, url: '/accounts/self/status', body: {status}, parseResponse: true, reportUrlAsIs: true, }; return this._restApiHelper .send(req) .then(newStatus => this._updateCachedAccount({status: (newStatus as unknown) as string}) ); } getAccountStatus(userId: AccountId) { return this._restApiHelper.fetchJSON({ url: `/accounts/${encodeURIComponent(userId)}/status`, anonymizedUrl: '/accounts/*/status', }) as Promise; } // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#list-groups getAccountGroups() { return this._restApiHelper.fetchJSON({ url: '/accounts/self/groups', reportUrlAsIs: true, }) as Promise; } getAccountAgreements() { return this._restApiHelper.fetchJSON({ url: '/accounts/self/agreements', reportUrlAsIs: true, }) as Promise; } saveAccountAgreement(name: ContributorAgreementInput): Promise { return this._restApiHelper.send({ method: HttpMethod.PUT, url: '/accounts/self/agreements', body: name, reportUrlAsIs: true, }); } getAccountCapabilities( params?: string[] ): Promise { let queryString = ''; if (params) { queryString = '?q=' + params.map(param => encodeURIComponent(param)).join('&q='); } return this._fetchSharedCacheURL({ url: '/accounts/self/capabilities' + queryString, anonymizedUrl: '/accounts/self/capabilities?q=*', }) as Promise; } getLoggedIn() { return this.authService.authCheck(); } getIsAdmin() { return this.getLoggedIn() .then(isLoggedIn => { if (isLoggedIn) { return this.getAccountCapabilities(); } else { return; } }) .then( (capabilities: AccountCapabilityInfo | undefined) => capabilities && capabilities.administrateServer ); } getDefaultPreferences(): Promise { return this._fetchSharedCacheURL({ url: '/config/server/preferences', reportUrlAsIs: true, }) as Promise; } getPreferences(): Promise { return this.getLoggedIn().then(loggedIn => { if (loggedIn) { const req = {url: '/accounts/self/preferences', reportUrlAsIs: true}; return this._fetchSharedCacheURL(req).then(res => { if (!res) { return res; } const prefInfo = (res as unknown) as PreferencesInfo; if (this._isNarrowScreen()) { // Note that this can be problematic, because the diff will stay // unified even after increasing the window width. prefInfo.default_diff_view = DiffViewMode.UNIFIED; } else { prefInfo.default_diff_view = prefInfo.diff_view; } return prefInfo; }); } // TODO(TS): Many properties are omitted here, but they are required. // Add default values for missed properties const anonymousPrefs = { changes_per_page: 25, default_diff_view: this._isNarrowScreen() ? DiffViewMode.UNIFIED : DiffViewMode.SIDE_BY_SIDE, diff_view: DiffViewMode.SIDE_BY_SIDE, size_bar_in_change_table: true, } as PreferencesInfo; return anonymousPrefs; }); } getWatchedProjects() { return (this._fetchSharedCacheURL({ url: '/accounts/self/watched.projects', reportUrlAsIs: true, }) as unknown) as Promise; } saveWatchedProjects( projects: ProjectWatchInfo[] ): Promise { return (this._restApiHelper.send({ method: HttpMethod.POST, url: '/accounts/self/watched.projects', body: projects, parseResponse: true, reportUrlAsIs: true, }) as unknown) as Promise; } deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise { return this._restApiHelper.send({ method: HttpMethod.POST, url: '/accounts/self/watched.projects:delete', body: projects, reportUrlAsIs: true, }); } _isNarrowScreen() { return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX; } getChanges( changesPerPage?: number, query?: string, offset?: 'n,z' | number, options?: string ): Promise; getChanges( changesPerPage?: number, query?: string[], offset?: 'n,z' | number, options?: string ): Promise; /** * @return 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( changesPerPage?: number, query?: string | string[], offset?: 'n,z' | number, options?: string ): Promise { return this.getConfig(false) .then(config => { // TODO(TS): config can be null/undefined. Need some checks options = options || this._getChangesOptionsHex(config); // Issue 4524: respect legacy token with max sortkey. if (offset === 'n,z') { offset = 0; } const params: QueryChangesParams = { O: options, S: offset || 0, }; if (changesPerPage) { params.n = changesPerPage; } if (query && query.length > 0) { params.q = query; } return { url: '/changes/', params, reportUrlAsIs: true, }; }) .then( req => this._restApiHelper.fetchJSON(req, true) as Promise< ChangeInfo[] | ChangeInfo[][] | undefined > ) .then(response => { if (!response) { return; } const iterateOverChanges = (arr: ChangeInfo[]) => { for (const change of arr) { this._maybeInsertInLookup(change); } }; // Response may be an array of changes OR an array of arrays of // changes. if (query instanceof Array) { // Normalize the response to look like a multi-query response // when there is only one query. const responseArray: Array = query.length === 1 ? [response as ChangeInfo[]] : (response as ChangeInfo[][]); for (const arr of responseArray) { iterateOverChanges(arr); } return responseArray; } else { iterateOverChanges(response as ChangeInfo[]); return response as ChangeInfo[]; } }); } /** * Inserts a change into _projectLookup iff it has a valid structure. */ _maybeInsertInLookup(change: ChangeInfo): void { if (change?.project && change._number) { this.setInProjectLookup(change._number, change.project); } } getChangeActionURL( changeNum: NumericChangeId, revisionId: RevisionId | undefined, endpoint: string ): Promise { return this._changeBaseURL(changeNum, revisionId).then( url => url + endpoint ); } getChangeDetail( changeNum: NumericChangeId, errFn?: ErrorCallback, cancelCondition?: CancelConditionCallback ): Promise { return this.getConfig(false).then(config => { const optionsHex = this._getChangeOptionsHex(config); return this._getChangeDetail( changeNum, optionsHex, errFn, cancelCondition ).then(detail => // detail has ChangeViewChangeInfo type because the optionsHex always // includes ALL_REVISIONS flag. GrReviewerUpdatesParser.parse(detail as ChangeViewChangeInfo) ); }); } _getChangesOptionsHex(config?: ServerInfo) { if ( window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.dashboardPage ) { return window.DEFAULT_DETAIL_HEXES.dashboardPage; } const options = [ ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS, ]; if (!config?.change?.enable_attention_set) { options.push(ListChangesOption.REVIEWED); } return listChangesOptionsToHex(...options); } _getChangeOptionsHex(config?: ServerInfo) { if ( window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.changePage && (!config || !(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 = [ ListChangesOption.ALL_COMMITS, ListChangesOption.ALL_REVISIONS, ListChangesOption.CHANGE_ACTIONS, ListChangesOption.DETAILED_LABELS, ListChangesOption.DOWNLOAD_COMMANDS, ListChangesOption.MESSAGES, ListChangesOption.SUBMITTABLE, ListChangesOption.WEB_LINKS, ListChangesOption.SKIP_DIFFSTAT, ]; if (config?.receive?.enable_signed_push) { options.push(ListChangesOption.PUSH_CERTIFICATES); } return listChangesOptionsToHex(...options); } getDiffChangeDetail(changeNum: NumericChangeId) { let optionsHex = ''; if (window.DEFAULT_DETAIL_HEXES?.diffPage) { optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage; } else { optionsHex = listChangesOptionsToHex( ListChangesOption.ALL_COMMITS, ListChangesOption.ALL_REVISIONS, ListChangesOption.SKIP_DIFFSTAT ); } return this._getChangeDetail(changeNum, optionsHex); } /** * @param optionsHex list changes options in hex */ _getChangeDetail( changeNum: NumericChangeId, optionsHex: string, errFn?: ErrorCallback, cancelCondition?: CancelConditionCallback ): Promise { return this.getChangeActionURL(changeNum, undefined, '/detail').then( url => { const params: FetchParams = {O: optionsHex}; const urlWithParams = this._restApiHelper.urlWithParams(url, params); const req: FetchJSONRequest = { url, errFn, cancelCondition, params, fetchOptions: this._etags.getOptions(urlWithParams), anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex, }; return this._restApiHelper.fetchRawJSON(req).then(response => { if (response?.status === 304) { return (this._restApiHelper.parsePrefixedJSON( // urlWithParams already cached this._etags.getCachedPayload(urlWithParams)! ) as unknown) as ChangeInfo; } if (response && !response.ok) { if (errFn) { errFn.call(null, response); } else { fireServerError(response, req); } return undefined; } if (!response) { return Promise.resolve(null); } return this._restApiHelper .readResponsePayload(response) .then(payload => { if (!payload) { return null; } this._etags.collect(urlWithParams, response, payload.raw); // TODO(TS): Why it is always change info? this._maybeInsertInLookup( (payload.parsed as unknown) as ChangeInfo ); return (payload.parsed as unknown) as ChangeInfo; }); }); } ); } getChangeCommitInfo(changeNum: NumericChangeId, patchNum: PatchSetNum) { return this._getChangeURLAndFetch({ changeNum, endpoint: '/commit?links', revision: patchNum, reportEndpointAsIs: true, }) as Promise; } getChangeFiles( changeNum: NumericChangeId, patchRange: PatchRange ): Promise { let params = undefined; if (isMergeParent(patchRange.basePatchNum)) { params = {parent: getParentIndex(patchRange.basePatchNum)}; } else if (!patchNumEquals(patchRange.basePatchNum, ParentPatchSetNum)) { params = {base: patchRange.basePatchNum}; } return this._getChangeURLAndFetch({ changeNum, endpoint: '/files', revision: patchRange.patchNum, params, reportEndpointAsIs: true, }) as Promise; } // TODO(TS): The output type is unclear getChangeEditFiles( changeNum: NumericChangeId, patchRange: PatchRange ): Promise<{files: FileNameToFileInfoMap} | undefined> { let endpoint = '/edit?list'; let anonymizedEndpoint = endpoint; if (patchRange.basePatchNum !== ParentPatchSetNum) { endpoint += '&base=' + encodeURIComponent(`${patchRange.basePatchNum}`); anonymizedEndpoint += '&base=*'; } return this._getChangeURLAndFetch({ changeNum, endpoint, anonymizedEndpoint, }) as Promise<{files: FileNameToFileInfoMap} | undefined>; } queryChangeFiles( changeNum: NumericChangeId, patchNum: PatchSetNum, query: string ) { return this._getChangeURLAndFetch({ changeNum, endpoint: `/files?q=${encodeURIComponent(query)}`, revision: patchNum, anonymizedEndpoint: '/files?q=*', }) as Promise; } getChangeOrEditFiles( changeNum: NumericChangeId, patchRange: PatchRange ): Promise { if (patchNumEquals(patchRange.patchNum, EditPatchSetNum)) { return this.getChangeEditFiles(changeNum, patchRange).then( res => res && res.files ); } return this.getChangeFiles(changeNum, patchRange); } getChangeRevisionActions( changeNum: NumericChangeId, patchNum: PatchSetNum ): Promise { const req: FetchChangeJSON = { changeNum, endpoint: '/actions', revision: patchNum, reportEndpointAsIs: true, }; return this._getChangeURLAndFetch(req) as Promise< ActionNameToActionInfoMap | undefined >; } getChangeSuggestedReviewers(changeNum: NumericChangeId, inputVal: string) { return this._getChangeSuggestedGroup( ReviewerState.REVIEWER, changeNum, inputVal ); } getChangeSuggestedCCs(changeNum: NumericChangeId, inputVal: string) { return this._getChangeSuggestedGroup(ReviewerState.CC, changeNum, inputVal); } _getChangeSuggestedGroup( reviewerState: ReviewerState, changeNum: NumericChangeId, inputVal: string ): Promise { // More suggestions may obscure content underneath in the reply dialog, // see issue 10793. const params: QuerySuggestedReviewersParams = { n: 6, 'reviewer-state': reviewerState, }; if (inputVal) { params.q = inputVal; } return this._getChangeURLAndFetch({ changeNum, endpoint: '/suggest_reviewers', params, reportEndpointAsIs: true, }) as Promise; } getChangeIncludedIn( changeNum: NumericChangeId ): Promise { return this._getChangeURLAndFetch({ changeNum, endpoint: '/in', reportEndpointAsIs: true, }) as Promise; } _computeFilter(filter: string) { if (filter?.startsWith('^')) { filter = '&r=' + encodeURIComponent(filter); } else if (filter) { filter = '&m=' + encodeURIComponent(filter); } else { filter = ''; } return filter; } _getGroupsUrl(filter: string, groupsPerPage: number, offset?: number) { offset = offset || 0; return ( `/groups/?n=${groupsPerPage + 1}&S=${offset}` + this._computeFilter(filter) ); } _getReposUrl( filter: string | undefined, reposPerPage: number, offset?: number ) { const defaultFilter = 'state:active OR state:read-only'; const namePartDelimiters = /[@.\-\s/_]/g; offset = 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/'); } getGroups(filter: string, groupsPerPage: number, offset?: number) { const url = this._getGroupsUrl(filter, groupsPerPage, offset); return this._fetchSharedCacheURL({ url, anonymizedUrl: '/groups/?*', }) as Promise; } getRepos( filter: string | undefined, reposPerPage: number, offset?: number ): Promise { const url = this._getReposUrl(filter, reposPerPage, offset); // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. return this._fetchSharedCacheURL({ url, // The url contains query,so the response is an array, not map anonymizedUrl: '/projects/?*', }) as Promise; } setRepoHead(repo: RepoName, ref: GitRef) { // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. return this._restApiHelper.send({ method: HttpMethod.PUT, url: `/projects/${encodeURIComponent(repo)}/HEAD`, body: {ref}, anonymizedUrl: '/projects/*/HEAD', }); } getRepoBranches( filter: string, repo: RepoName, reposBranchesPerPage: number, offset?: number, errFn?: ErrorCallback ): Promise { offset = offset || 0; const count = reposBranchesPerPage + 1; filter = this._computeFilter(filter); const encodedRepo = encodeURIComponent(repo); const url = `/projects/${encodedRepo}/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, anonymizedUrl: '/projects/*/branches?*', }) as Promise; } getRepoTags( filter: string, repo: RepoName, reposTagsPerPage: number, offset?: number, errFn?: ErrorCallback ) { offset = 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, anonymizedUrl: '/projects/*/tags', }) as unknown) as Promise; } getPlugins( filter: string, pluginsPerPage: number, offset?: number, errFn?: ErrorCallback ): Promise<{[pluginName: string]: PluginInfo} | undefined> { offset = 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, anonymizedUrl: '/plugins/?all', }); } getRepoAccessRights( repoName: RepoName, errFn?: ErrorCallback ): Promise { // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. return this._restApiHelper.fetchJSON({ url: `/projects/${encodeURIComponent(repoName)}/access`, errFn, anonymizedUrl: '/projects/*/access', }) as Promise; } setRepoAccessRights( repoName: RepoName, repoInfo: ProjectAccessInput ): Promise { // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend // supports it. return this._restApiHelper.send({ method: HttpMethod.POST, url: `/projects/${encodeURIComponent(repoName)}/access`, body: repoInfo, anonymizedUrl: '/projects/*/access', }); } setRepoAccessRightsForReview( projectName: RepoName, projectInfo: ProjectAccessInput ): Promise { return (this._restApiHelper.send({ method: HttpMethod.PUT, url: `/projects/${encodeURIComponent(projectName)}/access:review`, body: projectInfo, parseResponse: true, anonymizedUrl: '/projects/*/access:review', }) as unknown) as Promise; } getSuggestedGroups( inputVal: string, n?: number ): Promise { const params: QueryGroupsParams = {s: inputVal}; if (n) { params.n = n; } return this._restApiHelper.fetchJSON({ url: '/groups/', params, reportUrlAsIs: true, }) as Promise; } getSuggestedProjects( inputVal: string, n?: number ): Promise { const params = { m: inputVal, n: MAX_PROJECT_RESULTS, type: 'ALL', }; if (n) { params.n = n; } return this._restApiHelper.fetchJSON({ url: '/projects/', params, reportUrlAsIs: true, }); } getSuggestedAccounts( inputVal: string, n?: number ): Promise { if (!inputVal) { return Promise.resolve([]); } const params: QueryAccountsParams = {suggest: null, q: inputVal}; if (n) { params.n = n; } return this._restApiHelper.fetchJSON({ url: '/accounts/', params, anonymizedUrl: '/accounts/?n=*', }) as Promise; } addChangeReviewer( changeNum: NumericChangeId, reviewerID: AccountId | EmailAddress | GroupId ) { return this._sendChangeReviewerRequest( HttpMethod.POST, changeNum, reviewerID ); } removeChangeReviewer( changeNum: NumericChangeId, reviewerID: AccountId | EmailAddress | GroupId ) { return this._sendChangeReviewerRequest( HttpMethod.DELETE, changeNum, reviewerID ); } _sendChangeReviewerRequest( method: HttpMethod.POST | HttpMethod.DELETE, changeNum: NumericChangeId, reviewerID: AccountId | EmailAddress | GroupId ) { return this.getChangeActionURL(changeNum, undefined, '/reviewers').then( url => { let body; switch (method) { case HttpMethod.POST: body = {reviewer: reviewerID}; break; case HttpMethod.DELETE: url += '/' + encodeURIComponent(reviewerID); break; default: assertNever(method, `Unsupported HTTP method: ${method}`); } return this._restApiHelper.send({method, url, body}); } ); } getRelatedChanges( changeNum: NumericChangeId, patchNum: PatchSetNum ): Promise { return this._getChangeURLAndFetch({ changeNum, endpoint: '/related', revision: patchNum, reportEndpointAsIs: true, }) as Promise; } getChangesSubmittedTogether( changeNum: NumericChangeId ): Promise { return this._getChangeURLAndFetch({ changeNum, endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES', reportEndpointAsIs: true, }) as Promise; } getChangeConflicts( changeNum: NumericChangeId ): Promise { const options = listChangesOptionsToHex( ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT ); const params = { O: options, q: `status:open conflicts:${changeNum}`, }; return this._restApiHelper.fetchJSON({ url: '/changes/', params, anonymizedUrl: '/changes/conflicts:*', }) as Promise; } getChangeCherryPicks( project: RepoName, changeID: ChangeId, changeNum: NumericChangeId ): Promise { const options = listChangesOptionsToHex( ListChangesOption.CURRENT_REVISION, 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:*', }) as Promise; } getChangesWithSameTopic( topic: string, changeNum: NumericChangeId ): Promise { const options = listChangesOptionsToHex( ListChangesOption.LABELS, ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT, 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:*', }) as Promise; } getReviewedFiles( changeNum: NumericChangeId, patchNum: PatchSetNum ): Promise { return this._getChangeURLAndFetch({ changeNum, endpoint: '/files?reviewed', revision: patchNum, reportEndpointAsIs: true, }) as Promise; } saveFileReviewed( changeNum: NumericChangeId, patchNum: PatchSetNum, path: string, reviewed: boolean ): Promise { return this._getChangeURLAndSend({ changeNum, method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE, patchNum, endpoint: `/files/${encodeURIComponent(path)}/reviewed`, anonymizedEndpoint: '/files/*/reviewed', }); } saveChangeReview( changeNum: NumericChangeId, patchNum: PatchSetNum, review: ReviewInput ): Promise; saveChangeReview( changeNum: NumericChangeId, patchNum: PatchSetNum, review: ReviewInput, errFn: ErrorCallback ): Promise; saveChangeReview( changeNum: NumericChangeId, patchNum: PatchSetNum, review: ReviewInput, errFn?: ErrorCallback ) { const promises: [Promise, Promise] = [ this.awaitPendingDiffDrafts(), this.getChangeActionURL(changeNum, patchNum, '/review'), ]; return Promise.all(promises).then(([, url]) => this._restApiHelper.send({ method: HttpMethod.POST, url, body: review, errFn, }) ); } getChangeEdit( changeNum: NumericChangeId, downloadCommands?: boolean ): Promise { const params = downloadCommands ? {'download-commands': true} : undefined; return this.getLoggedIn().then(loggedIn => { if (!loggedIn) { return Promise.resolve(false); } return this._getChangeURLAndFetch( { changeNum, endpoint: '/edit/', params, reportEndpointAsIs: true, }, true ) as Promise; }); } createChange( project: RepoName, branch: BranchName, subject: string, topic?: string, isPrivate?: boolean, workInProgress?: boolean, baseChange?: ChangeId, baseCommit?: string ) { return (this._restApiHelper.send({ method: HttpMethod.POST, url: '/changes/', body: { project, branch, subject, topic, is_private: isPrivate, work_in_progress: workInProgress, base_change: baseChange, base_commit: baseCommit, }, parseResponse: true, reportUrlAsIs: true, }) as unknown) as Promise; } getFileContent( changeNum: NumericChangeId, path: string, patchNum: PatchSetNum ): Promise { // 404s indicate the file does not exist yet in the revision, so suppress // them. const suppress404s: ErrorCallback = res => { if (res && res?.status !== 404) { fireServerError(res); } return res; }; const promise = patchNumEquals(patchNum, EditPatchSetNum) ? this._getFileInChangeEdit(changeNum, path) : this._getFileInRevision(changeNum, path, patchNum, suppress404s); return promise.then(res => { if (!res || !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 => { const strContent = (content as unknown) as string | null; return {content: strContent, type, ok: true}; }); }); } /** * Gets a file in a specific change and revision. */ _getFileInRevision( changeNum: NumericChangeId, path: string, patchNum: PatchSetNum, errFn?: ErrorCallback ) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.GET, patchNum, endpoint: `/files/${encodeURIComponent(path)}/content`, errFn, headers: {Accept: 'application/json'}, anonymizedEndpoint: '/files/*/content', }); } /** * Gets a file in a change edit. */ _getFileInChangeEdit(changeNum: NumericChangeId, path: string) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.GET, endpoint: '/edit/' + encodeURIComponent(path), headers: {Accept: 'application/json'}, anonymizedEndpoint: '/edit/*', }); } rebaseChangeEdit(changeNum: NumericChangeId) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.POST, endpoint: '/edit:rebase', reportEndpointAsIs: true, }); } deleteChangeEdit(changeNum: NumericChangeId) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.DELETE, endpoint: '/edit', reportEndpointAsIs: true, }); } restoreFileInChangeEdit(changeNum: NumericChangeId, restore_path: string) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.POST, endpoint: '/edit', body: {restore_path}, reportEndpointAsIs: true, }); } renameFileInChangeEdit( changeNum: NumericChangeId, old_path: string, new_path: string ) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.POST, endpoint: '/edit', body: {old_path, new_path}, reportEndpointAsIs: true, }); } deleteFileInChangeEdit(changeNum: NumericChangeId, path: string) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.DELETE, endpoint: '/edit/' + encodeURIComponent(path), anonymizedEndpoint: '/edit/*', }); } saveChangeEdit(changeNum: NumericChangeId, path: string, contents: string) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.PUT, endpoint: '/edit/' + encodeURIComponent(path), body: contents, contentType: 'text/plain', anonymizedEndpoint: '/edit/*', }); } saveFileUploadChangeEdit( changeNum: NumericChangeId, path: string, content: string ) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.PUT, endpoint: '/edit/' + encodeURIComponent(path), body: {binary_content: content}, anonymizedEndpoint: '/edit/*', }); } getRobotCommentFixPreview( changeNum: NumericChangeId, patchNum: PatchSetNum, fixId: FixId ): Promise { return this._getChangeURLAndFetch({ changeNum, revision: patchNum, endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`, reportEndpointAsId: true, }) as Promise; } applyFixSuggestion( changeNum: NumericChangeId, patchNum: PatchSetNum, fixId: string ): Promise { return this._getChangeURLAndSend({ method: HttpMethod.POST, changeNum, patchNum, endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`, reportEndpointAsId: true, }); } // Deprecated, prefer to use putChangeCommitMessage instead. saveChangeCommitMessageEdit(changeNum: NumericChangeId, message: string) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.PUT, endpoint: '/edit:message', body: {message}, reportEndpointAsIs: true, }); } publishChangeEdit(changeNum: NumericChangeId) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.POST, endpoint: '/edit:publish', reportEndpointAsIs: true, }); } putChangeCommitMessage(changeNum: NumericChangeId, message: string) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.PUT, endpoint: '/message', body: {message}, reportEndpointAsIs: true, }); } deleteChangeCommitMessage( changeNum: NumericChangeId, messageId: ChangeMessageId ) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.DELETE, endpoint: `/messages/${messageId}`, reportEndpointAsIs: true, }); } saveChangeStarred( changeNum: NumericChangeId, starred: boolean ): Promise { // 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 encodedRepoName = project ? encodeURIComponent(project) + '~' : ''; const url = `/accounts/self/starred.changes/${encodedRepoName}${changeNum}`; return this._restApiHelper.send({ method: starred ? HttpMethod.PUT : HttpMethod.DELETE, url, anonymizedUrl: '/accounts/self/starred.changes/*', }); }); } saveChangeReviewed( changeNum: NumericChangeId, reviewed: boolean ): Promise { return this.getConfig().then(config => { const isAttentionSetEnabled = !!config && !!config.change && config.change.enable_attention_set; if (isAttentionSetEnabled) return; return this._getChangeURLAndSend({ changeNum, method: HttpMethod.PUT, endpoint: reviewed ? '/reviewed' : '/unreviewed', }); }); } send( method: HttpMethod, url: string, body?: RequestPayload, errFn?: undefined, contentType?: string, headers?: Record ): Promise; send( method: HttpMethod, url: string, body: RequestPayload | undefined, errFn: ErrorCallback, contentType?: string, headers?: Record ): Promise; /** * Public version of the _restApiHelper.send method preserved for plugins. * * @param body passed as null sometimes * and also apparently a number. TODO (beckysiegel) remove need for * number at least. */ send( method: HttpMethod, url: string, body?: RequestPayload, errFn?: ErrorCallback, contentType?: string, headers?: Record ): Promise { return this._restApiHelper.send({ method, url, body, errFn, contentType, headers, }); } /** * @param basePatchNum Negative values specify merge parent * index. * @param whitespace the ignore-whitespace level for the diff * algorithm. */ getDiff( changeNum: NumericChangeId, basePatchNum: PatchSetNum, patchNum: PatchSetNum, path: string, whitespace?: IgnoreWhitespaceType, errFn?: ErrorCallback ) { const params: GetDiffParams = { intraline: null, whitespace: whitespace || 'IGNORE_NONE', }; if (isMergeParent(basePatchNum)) { params.parent = getParentIndex(basePatchNum); } else if (!patchNumEquals(basePatchNum, ParentPatchSetNum)) { // TODO (TS): fix as PatchSetNum in the condition above params.base = basePatchNum; } const endpoint = `/files/${encodeURIComponent(path)}/diff`; const req: FetchChangeJSON = { changeNum, endpoint, revision: patchNum, errFn, params, anonymizedEndpoint: '/files/*/diff', }; // Invalidate the cache if its edit patch to make sure we always get latest. if (patchNum === EditPatchSetNum) { 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) as Promise; } getDiffComments( changeNum: NumericChangeId ): Promise; getDiffComments( changeNum: NumericChangeId, basePatchNum: PatchSetNum, patchNum: PatchSetNum, path: string ): Promise; getDiffComments( changeNum: NumericChangeId, basePatchNum?: PatchSetNum, patchNum?: PatchSetNum, path?: string ) { if (!basePatchNum && !patchNum && !path) { return this._getDiffComments(changeNum, '/comments'); } return this._getDiffComments( changeNum, '/comments', basePatchNum, patchNum, path ); } getDiffRobotComments( changeNum: NumericChangeId ): Promise; getDiffRobotComments( changeNum: NumericChangeId, basePatchNum: PatchSetNum, patchNum: PatchSetNum, path: string ): Promise; getDiffRobotComments( changeNum: NumericChangeId, basePatchNum?: PatchSetNum, patchNum?: PatchSetNum, path?: string ) { if (!basePatchNum && !patchNum && !path) { return this._getDiffComments(changeNum, '/robotcomments'); } return this._getDiffComments( changeNum, '/robotcomments', basePatchNum, patchNum, 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. */ getDiffDrafts( changeNum: NumericChangeId ): Promise; getDiffDrafts( changeNum: NumericChangeId, basePatchNum: PatchSetNum, patchNum: PatchSetNum, path: string ): Promise; getDiffDrafts( changeNum: NumericChangeId, basePatchNum?: PatchSetNum, patchNum?: PatchSetNum, path?: string ) { return this.getLoggedIn().then(loggedIn => { if (!loggedIn) { return {}; } if (!basePatchNum && !patchNum && !path) { return this._getDiffComments(changeNum, '/drafts'); } return this._getDiffComments( changeNum, '/drafts', basePatchNum, patchNum, path ); }); } _setRange(comments: CommentInfo[], comment: CommentInfo) { 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?: CommentInfo[]) { comments = comments || []; comments.sort( (a, b) => parseDate(a.updated).valueOf() - parseDate(b.updated).valueOf() ); for (const comment of comments) { this._setRange(comments, comment); } return comments; } _getDiffComments( changeNum: NumericChangeId, endpoint: '/comments' | '/drafts' ): Promise; _getDiffComments( changeNum: NumericChangeId, endpoint: '/robotcomments' ): Promise; _getDiffComments( changeNum: NumericChangeId, endpoint: '/comments' | '/drafts', basePatchNum?: PatchSetNum, patchNum?: PatchSetNum, path?: string ): Promise; _getDiffComments( changeNum: NumericChangeId, endpoint: '/robotcomments', basePatchNum?: PatchSetNum, patchNum?: PatchSetNum, path?: string ): Promise; _getDiffComments( changeNum: NumericChangeId, endpoint: string, basePatchNum?: PatchSetNum, patchNum?: PatchSetNum, path?: string ): Promise< | GetDiffCommentsOutput | GetDiffRobotCommentsOutput | PathToCommentsInfoMap | PathToRobotCommentsInfoMap | undefined > { /** * Fetches the comments for a given patchNum. * Helper function to make promises more legible. */ // We don't want to add accept header, since preloading of comments is // working only without accept header. const noAcceptHeader = true; const fetchComments = (patchNum?: PatchSetNum) => this._getChangeURLAndFetch( { changeNum, endpoint, revision: patchNum, reportEndpointAsIs: true, }, noAcceptHeader ) as Promise< PathToCommentsInfoMap | PathToRobotCommentsInfoMap | undefined >; if (!basePatchNum && !patchNum && !path) { return fetchComments(); } function onlyParent(c: CommentInfo) { return c.side === CommentSide.PARENT; } function withoutParent(c: CommentInfo) { return c.side !== CommentSide.PARENT; } function setPath(c: CommentInfo) { c.path = path; } const promises = []; let comments: CommentInfo[]; let baseComments: CommentInfo[]; let fetchPromise; fetchPromise = fetchComments(patchNum).then(response => { comments = (response && path && response[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 (basePatchNum === ParentPatchSetNum) { baseComments = comments.filter(onlyParent); baseComments.forEach(setPath); } comments = comments.filter(withoutParent); comments.forEach(setPath); }); promises.push(fetchPromise); if (basePatchNum !== ParentPatchSetNum) { fetchPromise = fetchComments(basePatchNum).then(response => { baseComments = ((response && path && response[path]) || []).filter( withoutParent ); baseComments = this._setRanges(baseComments); baseComments.forEach(setPath); }); promises.push(fetchPromise); } return Promise.all(promises).then(() => Promise.resolve({ baseComments, comments, }) ); } _getDiffCommentsFetchURL( changeNum: NumericChangeId, endpoint: string, patchNum?: RevisionId ) { return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint); } getPortedComments( changeNum: NumericChangeId, revision: RevisionId ): Promise { // maintaining a custom error function so that errors do not surface in UI const errFn: ErrorCallback = (response?: Response | null) => { if (response) console.info(`Fetching ported comments failed, ${response.status}`); }; return this._getChangeURLAndFetch({ changeNum, endpoint: '/ported_comments/', revision, errFn, }); } getPortedDrafts( changeNum: NumericChangeId, revision: RevisionId ): Promise { // maintaining a custom error function so that errors do not surface in UI const errFn: ErrorCallback = (response?: Response | null) => { if (response) console.info(`Fetching ported drafts failed, ${response.status}`); }; return this.getLoggedIn().then(loggedIn => { if (!loggedIn) return {}; return this._getChangeURLAndFetch({ changeNum, endpoint: '/ported_drafts/', revision, errFn, }); }); } saveDiffDraft( changeNum: NumericChangeId, patchNum: PatchSetNum, draft: CommentInput ) { return this._sendDiffDraftRequest( HttpMethod.PUT, changeNum, patchNum, draft ); } deleteDiffDraft( changeNum: NumericChangeId, patchNum: PatchSetNum, draft: {id: UrlEncodedCommentId} ) { return this._sendDiffDraftRequest( HttpMethod.DELETE, changeNum, patchNum, draft ); } /** * @returns Whether there are pending diff draft sends. */ hasPendingDiffDrafts(): number { const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT]; return promises && promises.length; } /** * @returns A promise that resolves when all pending * diff draft sends have resolved. */ awaitPendingDiffDrafts(): Promise { return Promise.all( this._pendingRequests[Requests.SEND_DIFF_DRAFT] || [] ).then(() => { this._pendingRequests[Requests.SEND_DIFF_DRAFT] = []; }); } _sendDiffDraftRequest( method: HttpMethod.PUT, changeNum: NumericChangeId, patchNum: PatchSetNum, draft: CommentInput ): Promise; _sendDiffDraftRequest( method: HttpMethod.GET | HttpMethod.DELETE, changeNum: NumericChangeId, patchNum: PatchSetNum, draft: {id?: UrlEncodedCommentId} ): Promise; _sendDiffDraftRequest( method: HttpMethod, changeNum: NumericChangeId, patchNum: PatchSetNum, draft: CommentInput | {id: UrlEncodedCommentId} ): Promise { const isCreate = !draft.id && method === HttpMethod.PUT; let endpoint = '/drafts'; let anonymizedEndpoint = endpoint; if (draft.id) { endpoint += `/${draft.id}`; anonymizedEndpoint += '/*'; } let body; if (method === HttpMethod.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: RepoName, commit: CommitId ): Promise { return this._restApiHelper.fetchJSON({ url: '/projects/' + encodeURIComponent(project) + '/commits/' + encodeURIComponent(commit), anonymizedUrl: '/projects/*/comments/*', }) as Promise; } _fetchB64File(url: string): Promise { return this._restApiHelper .fetch({url: 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}; }); }); } getB64FileContents( changeId: NumericChangeId, patchNum: RevisionId, path: string, parentIndex?: number ) { const parent = typeof parentIndex === 'number' ? `?parent=${parentIndex}` : ''; return this._changeBaseURL(changeId, patchNum).then(url => { url = `${url}/files/${encodeURIComponent(path)}/content${parent}`; return this._fetchB64File(url); }); } getImagesForDiff( changeNum: NumericChangeId, diff: DiffInfo, patchRange: PatchRange ): Promise { let promiseA; let promiseB; if (diff.meta_a?.content_type.startsWith('image/')) { if (patchRange.basePatchNum === ParentPatchSetNum) { // 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?.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 => { // Sometimes the server doesn't send back the content type. const baseImage: Base64ImageFile | null = results[0] ? { ...results[0], _expectedType: diff.meta_a.content_type, _name: diff.meta_a.name, } : null; const revisionImage: Base64ImageFile | null = results[1] ? { ...results[1], _expectedType: diff.meta_b.content_type, _name: diff.meta_b.name, } : null; const imagesForDiff: ImagesForDiff = {baseImage, revisionImage}; return imagesForDiff; }); } _changeBaseURL( changeNum: NumericChangeId, revisionId?: RevisionId, project?: RepoName ): Promise { // TODO(kaspern): For full slicer migration, app should warn with a call // stack every time _changeBaseURL is called without a project. const projectPromise = project ? Promise.resolve(project) : this.getFromProjectLookup(changeNum); return projectPromise.then(project => { // TODO(TS): unclear why project can't be null here. Fix it let url = `/changes/${encodeURIComponent( project as RepoName )}~${changeNum}`; if (revisionId) { url += `/revisions/${revisionId}`; } return url; }); } addToAttentionSet( changeNum: NumericChangeId, user: AccountId | undefined | null, reason: string ) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.POST, endpoint: '/attention', body: {user, reason}, reportUrlAsIs: true, }); } removeFromAttentionSet( changeNum: NumericChangeId, user: AccountId, reason: string ) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.DELETE, endpoint: `/attention/${user}`, anonymizedEndpoint: '/attention/*', body: {reason}, }); } setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise { return (this._getChangeURLAndSend({ changeNum, method: HttpMethod.PUT, endpoint: '/topic', body: {topic}, parseResponse: true, reportUrlAsIs: true, }) as unknown) as Promise; } setChangeHashtag( changeNum: NumericChangeId, hashtag: HashtagsInput ): Promise { return (this._getChangeURLAndSend({ changeNum, method: HttpMethod.POST, endpoint: '/hashtags', body: hashtag, parseResponse: true, reportUrlAsIs: true, }) as unknown) as Promise; } deleteAccountHttpPassword() { return this._restApiHelper.send({ method: HttpMethod.DELETE, url: '/accounts/self/password.http', reportUrlAsIs: true, }); } generateAccountHttpPassword(): Promise { return (this._restApiHelper.send({ method: HttpMethod.PUT, url: '/accounts/self/password.http', body: {generate: true}, parseResponse: true, reportUrlAsIs: true, }) as Promise) as Promise; } getAccountSSHKeys() { return (this._fetchSharedCacheURL({ url: '/accounts/self/sshkeys', reportUrlAsIs: true, }) as Promise) as Promise; } addAccountSSHKey(key: string): Promise { const req = { method: HttpMethod.POST, url: '/accounts/self/sshkeys', body: key, contentType: 'text/plain', reportUrlAsIs: true, }; return this._restApiHelper .send(req) .then((response: Response | undefined) => { if (!response || (response.status < 200 && response.status >= 300)) { return Promise.reject(new Error('error')); } return (this.getResponseObject(response) as unknown) as Promise< SshKeyInfo >; }) .then(obj => { if (!obj || !obj.valid) { return Promise.reject(new Error('error')); } return obj; }); } deleteAccountSSHKey(id: string) { return this._restApiHelper.send({ method: HttpMethod.DELETE, url: '/accounts/self/sshkeys/' + id, anonymizedUrl: '/accounts/self/sshkeys/*', }); } getAccountGPGKeys() { return (this._restApiHelper.fetchJSON({ url: '/accounts/self/gpgkeys', reportUrlAsIs: true, }) as Promise) as Promise>; } addAccountGPGKey(key: GpgKeysInput) { const req = { method: HttpMethod.POST, url: '/accounts/self/gpgkeys', body: key, reportUrlAsIs: true, }; return this._restApiHelper .send(req) .then(response => { if (!response || (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: GpgKeyId) { return this._restApiHelper.send({ method: HttpMethod.DELETE, url: `/accounts/self/gpgkeys/${id}`, anonymizedUrl: '/accounts/self/gpgkeys/*', }); } deleteVote(changeNum: NumericChangeId, account: AccountId, label: string) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.DELETE, endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`, anonymizedEndpoint: '/reviewers/*/votes/*', }); } setDescription( changeNum: NumericChangeId, patchNum: PatchSetNum, desc: string ) { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.PUT, patchNum, endpoint: '/description', body: {description: desc}, reportUrlAsIs: true, }); } confirmEmail(token: string): Promise { const req = { method: HttpMethod.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( errFn?: ErrorCallback ): Promise { return this._restApiHelper.fetchJSON({ url: '/config/server/capabilities', errFn, reportUrlAsIs: true, }) as Promise; } getTopMenus(): Promise { return this._fetchSharedCacheURL({ url: '/config/server/top-menus', reportUrlAsIs: true, }) as Promise; } setAssignee( changeNum: NumericChangeId, assignee: AccountId ): Promise { const body: AssigneeInput = {assignee}; return this._getChangeURLAndSend({ changeNum, method: HttpMethod.PUT, endpoint: '/assignee', body, reportUrlAsIs: true, }); } deleteAssignee(changeNum: NumericChangeId): Promise { return this._getChangeURLAndSend({ changeNum, method: HttpMethod.DELETE, endpoint: '/assignee', reportUrlAsIs: true, }); } probePath(path: string) { return fetch(new Request(path, {method: HttpMethod.HEAD})).then( response => response.ok ); } startWorkInProgress( changeNum: NumericChangeId, message?: string ): Promise { const body = message ? {message} : {}; const req: SendRawChangeRequest = { changeNum, method: HttpMethod.POST, endpoint: '/wip', body, reportUrlAsIs: true, }; return this._getChangeURLAndSend(req).then(response => { if (response?.status === 204) { return 'Change marked as Work In Progress.'; } return undefined; }); } deleteComment( changeNum: NumericChangeId, patchNum: PatchSetNum, commentID: UrlEncodedCommentId, reason: string ) { return (this._getChangeURLAndSend({ changeNum, method: HttpMethod.POST, patchNum, endpoint: `/comments/${commentID}/delete`, body: {reason}, parseResponse: true, anonymizedEndpoint: '/comments/*/delete', }) as unknown) as Promise; } /** * Given a changeNum, gets the change. */ getChange( changeNum: ChangeId | NumericChangeId, errFn: ErrorCallback ): Promise { // Cannot use _changeBaseURL, as this function is used by _projectLookup. return this._restApiHelper .fetchJSON({ url: `/changes/?q=change:${changeNum}`, errFn, anonymizedUrl: '/changes/?q=change:*', }) .then(res => { const changeInfos = res as ChangeInfo[] | undefined; if (!changeInfos || !changeInfos.length) { return null; } return changeInfos[0]; }); } setInProjectLookup(changeNum: NumericChangeId, project: RepoName) { 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. */ getFromProjectLookup( changeNum: NumericChangeId ): Promise { const project = this._projectLookup[`${changeNum}`]; if (project) { return Promise.resolve(project); } const onError = (response?: Response | null) => firePageError(this, response); return this.getChange(changeNum, onError).then(change => { if (!change || !change.project) { return; } this.setInProjectLookup(changeNum, change.project); return change.project; }); } // if errFn is not set, then only Response possible _getChangeURLAndSend( req: SendRawChangeRequest & {errFn?: undefined} ): Promise; _getChangeURLAndSend( req: SendRawChangeRequest ): Promise; _getChangeURLAndSend(req: SendJSONChangeRequest): Promise; /** * Alias for _changeBaseURL.then(send). */ _getChangeURLAndSend( req: SendChangeRequest ): Promise { 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 => { const request: SendRequest = { 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, }; return this._restApiHelper.send(request); }); } /** * Alias for _changeBaseURL.then(_fetchJSON). */ _getChangeURLAndFetch( req: FetchChangeJSON, noAcceptHeader?: boolean ): Promise { const anonymizedEndpoint = req.reportEndpointAsIs ? req.endpoint : req.anonymizedEndpoint; const anonymizedBaseUrl = req.revision ? ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL; return this._changeBaseURL(req.changeNum, req.revision).then(url => this._restApiHelper.fetchJSON( { url: url + req.endpoint, errFn: req.errFn, params: req.params, fetchOptions: req.fetchOptions, anonymizedUrl: anonymizedEndpoint ? anonymizedBaseUrl + anonymizedEndpoint : undefined, }, noAcceptHeader ) ); } executeChangeAction( changeNum: NumericChangeId, method: HttpMethod | undefined, endpoint: string, patchNum?: PatchSetNum, payload?: RequestPayload ): Promise; executeChangeAction( changeNum: NumericChangeId, method: HttpMethod | undefined, endpoint: string, patchNum: PatchSetNum | undefined, payload: RequestPayload | undefined, errFn: ErrorCallback ): Promise; /** * Execute a change action or revision action on a change. */ executeChangeAction( changeNum: NumericChangeId, method: HttpMethod | undefined, endpoint: string, patchNum?: PatchSetNum, payload?: RequestPayload, errFn?: ErrorCallback ) { return this._getChangeURLAndSend({ changeNum, method, patchNum, endpoint, body: payload, errFn, }); } /** * Get blame information for the given diff. * * @param base If true, requests blame for the base of the * diff, rather than the revision. */ getBlame( changeNum: NumericChangeId, patchNum: PatchSetNum, path: string, base?: boolean ) { const encodedPath = encodeURIComponent(path); return this._getChangeURLAndFetch({ changeNum, endpoint: `/files/${encodedPath}/blame`, revision: patchNum, params: base ? {base: 't'} : undefined, anonymizedEndpoint: '/files/*/blame', }) as Promise; } /** * 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 The original promise. * @return The modified promise. */ _failForCreate200(promise: Promise): 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_BLOCK_REGEX.test(key)) { obj[key] = val; } return obj; }, {} as Record ); 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 */ getDashboard( project: RepoName, dashboard: DashboardId, errFn?: ErrorCallback ): Promise { const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' + encodeURIComponent(dashboard); return this._fetchSharedCacheURL({ url, errFn, anonymizedUrl: '/projects/*/dashboards/*', }) as Promise; } getDocumentationSearches(filter: string): Promise { 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/?*', }) as Promise; } getMergeable(changeNum: NumericChangeId) { return this._getChangeURLAndFetch({ changeNum, endpoint: '/revisions/current/mergeable', reportEndpointAsIs: true, }) as Promise; } deleteDraftComments(query: string): Promise { const body: DeleteDraftCommentsInput = {query}; return this._restApiHelper.send({ method: HttpMethod.POST, url: '/accounts/self/drafts:delete', body, }); } }