| /** | 
 |  * @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, | 
 |   DiffInfo, | 
 |   DiffPreferenceInput, | 
 |   DiffPreferencesInfo, | 
 |   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 { | 
 |   CancelConditionCallback, | 
 |   ErrorCallback, | 
 |   RestApiService, | 
 |   GetDiffCommentsOutput, | 
 |   GetDiffRobotCommentsOutput, | 
 | } from '../../../services/services/gr-rest-api/gr-rest-api'; | 
 | import { | 
 |   CommentSide, | 
 |   DiffViewMode, | 
 |   HttpMethod, | 
 |   IgnoreWhitespaceType, | 
 |   ReviewerState, | 
 | } from '../../../constants/constants'; | 
 |  | 
 | 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<Promise<unknown>>} = {}; // 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<string, string>; | 
 |   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; | 
 |   context?: number | 'ALL'; | 
 |   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; | 
 |   /** | 
 |    * Fired when an server error occurs. | 
 |    * | 
 |    * @event server-error | 
 |    */ | 
 |  | 
 |   /** | 
 |    * Fired when a network error occurs. | 
 |    * | 
 |    * @event network-error | 
 |    */ | 
 |  | 
 |   /** | 
 |    * Fired after an RPC completes. | 
 |    * | 
 |    * @event rpc-log | 
 |    */ | 
 |  | 
 |   @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() { | 
 |     super(); | 
 |     this.authService = appContext.authService; | 
 |     this._restApiHelper = new GrRestApiHelper( | 
 |       this._cache, | 
 |       this.authService, | 
 |       this._sharedFetchPromises, | 
 |       this | 
 |     ); | 
 |   } | 
 |  | 
 |   _fetchSharedCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> { | 
 |     // Cache is shared across instances | 
 |     return this._restApiHelper.fetchCacheURL(req); | 
 |   } | 
 |  | 
 |   getResponseObject(response: Response): Promise<ParsedJSON> { | 
 |     return this._restApiHelper.getResponseObject(response); | 
 |   } | 
 |  | 
 |   getConfig(noCache?: boolean): Promise<ServerInfo | undefined> { | 
 |     if (!noCache) { | 
 |       return this._fetchSharedCacheURL({ | 
 |         url: '/config/server/info', | 
 |         reportUrlAsIs: true, | 
 |       }) as Promise<ServerInfo | undefined>; | 
 |     } | 
 |  | 
 |     return this._restApiHelper.fetchJSON({ | 
 |       url: '/config/server/info', | 
 |       reportUrlAsIs: true, | 
 |     }) as Promise<ServerInfo | undefined>; | 
 |   } | 
 |  | 
 |   getRepo( | 
 |     repo: RepoName, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<ProjectInfo | undefined> { | 
 |     // 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<ProjectInfo | undefined>; | 
 |   } | 
 |  | 
 |   getProjectConfig( | 
 |     repo: RepoName, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<ConfigInfo | undefined> { | 
 |     // 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<ConfigInfo | undefined>; | 
 |   } | 
 |  | 
 |   getRepoAccess(repo: RepoName): Promise<ProjectAccessInfoMap | undefined> { | 
 |     // 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<ProjectAccessInfoMap | undefined>; | 
 |   } | 
 |  | 
 |   getRepoDashboards( | 
 |     repo: RepoName, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<DashboardInfo[] | undefined> { | 
 |     // 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<DashboardInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>; | 
 |  | 
 |   saveRepoConfig( | 
 |     repo: RepoName, | 
 |     config: ConfigInput, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   saveRepoConfig( | 
 |     repo: RepoName, | 
 |     config: ConfigInput, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<Response | undefined> { | 
 |     // 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, | 
 |       errFn, | 
 |       anonymizedUrl: '/projects/*/config', | 
 |     }); | 
 |   } | 
 |  | 
 |   runRepoGC(repo: RepoName): Promise<Response>; | 
 |  | 
 |   runRepoGC( | 
 |     repo: RepoName, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   runRepoGC(repo: RepoName, errFn?: ErrorCallback) { | 
 |     if (!repo) { | 
 |       // TODO(TS): fix return value | 
 |       return ''; | 
 |     } | 
 |     // 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: '', | 
 |       errFn, | 
 |       anonymizedUrl: '/projects/*/gc', | 
 |     }); | 
 |   } | 
 |  | 
 |   createRepo(config: ProjectInput & {name: RepoName}): Promise<Response>; | 
 |  | 
 |   createRepo( | 
 |     config: ProjectInput & {name: RepoName}, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   createRepo(config: ProjectInput, errFn?: ErrorCallback) { | 
 |     if (!config.name) { | 
 |       // TODO(TS): Fix return value | 
 |       return ''; | 
 |     } | 
 |     // 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, | 
 |       errFn, | 
 |       anonymizedUrl: '/projects/*', | 
 |     }); | 
 |   } | 
 |  | 
 |   createGroup(config: GroupInput & {name: string}): Promise<Response>; | 
 |  | 
 |   createGroup( | 
 |     config: GroupInput & {name: string}, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   createGroup(config: GroupInput, errFn?: ErrorCallback) { | 
 |     if (!config.name) { | 
 |       // TODO(TS): Fix return value | 
 |       return ''; | 
 |     } | 
 |     const encodeName = encodeURIComponent(config.name); | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.PUT, | 
 |       url: `/groups/${encodeName}`, | 
 |       body: config, | 
 |       errFn, | 
 |       anonymizedUrl: '/groups/*', | 
 |     }); | 
 |   } | 
 |  | 
 |   getGroupConfig( | 
 |     group: GroupId | GroupName, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<GroupInfo | undefined> { | 
 |     return this._restApiHelper.fetchJSON({ | 
 |       url: `/groups/${encodeURIComponent(group)}/detail`, | 
 |       errFn, | 
 |       anonymizedUrl: '/groups/*/detail', | 
 |     }) as Promise<GroupInfo | undefined>; | 
 |   } | 
 |  | 
 |   deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>; | 
 |  | 
 |   deleteRepoBranches( | 
 |     repo: RepoName, | 
 |     ref: GitRef, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   deleteRepoBranches(repo: RepoName, ref: GitRef, errFn?: ErrorCallback) { | 
 |     if (!repo || !ref) { | 
 |       // TODO(TS): fix return value | 
 |       return ''; | 
 |     } | 
 |     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend | 
 |     // supports it. | 
 |     const encodeName = encodeURIComponent(repo); | 
 |     const encodeRef = encodeURIComponent(ref); | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.DELETE, | 
 |       url: `/projects/${encodeName}/branches/${encodeRef}`, | 
 |       body: '', | 
 |       errFn, | 
 |       anonymizedUrl: '/projects/*/branches/*', | 
 |     }); | 
 |   } | 
 |  | 
 |   deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>; | 
 |  | 
 |   deleteRepoTags( | 
 |     repo: RepoName, | 
 |     ref: GitRef, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   deleteRepoTags(repo: RepoName, ref: GitRef, errFn?: ErrorCallback) { | 
 |     if (!repo || !ref) { | 
 |       // TODO(TS): fix return type | 
 |       return ''; | 
 |     } | 
 |     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend | 
 |     // supports it. | 
 |     const encodeName = encodeURIComponent(repo); | 
 |     const encodeRef = encodeURIComponent(ref); | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.DELETE, | 
 |       url: `/projects/${encodeName}/tags/${encodeRef}`, | 
 |       body: '', | 
 |       errFn, | 
 |       anonymizedUrl: '/projects/*/tags/*', | 
 |     }); | 
 |   } | 
 |  | 
 |   createRepoBranch( | 
 |     name: RepoName, | 
 |     branch: BranchName, | 
 |     revision: BranchInput | 
 |   ): Promise<Response>; | 
 |  | 
 |   createRepoBranch( | 
 |     name: RepoName, | 
 |     branch: BranchName, | 
 |     revision: BranchInput, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   createRepoBranch( | 
 |     name: RepoName, | 
 |     branch: BranchName, | 
 |     revision: BranchInput, | 
 |     errFn?: ErrorCallback | 
 |   ) { | 
 |     if (!name || !branch || !revision) { | 
 |       // TODO(TS) fix return type | 
 |       return ''; | 
 |     } | 
 |     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend | 
 |     // supports it. | 
 |     const encodeName = encodeURIComponent(name); | 
 |     const encodeBranch = encodeURIComponent(branch); | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.PUT, | 
 |       url: `/projects/${encodeName}/branches/${encodeBranch}`, | 
 |       body: revision, | 
 |       errFn, | 
 |       anonymizedUrl: '/projects/*/branches/*', | 
 |     }); | 
 |   } | 
 |  | 
 |   createRepoTag( | 
 |     name: RepoName, | 
 |     tag: string, | 
 |     revision: TagInput | 
 |   ): Promise<Response>; | 
 |  | 
 |   createRepoTag( | 
 |     name: RepoName, | 
 |     tag: string, | 
 |     revision: TagInput, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   createRepoTag( | 
 |     name: RepoName, | 
 |     tag: string, | 
 |     revision: TagInput, | 
 |     errFn?: ErrorCallback | 
 |   ) { | 
 |     if (!name || !tag || !revision) { | 
 |       // TODO(TS): Fix return value | 
 |       return ''; | 
 |     } | 
 |     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend | 
 |     // supports it. | 
 |     const encodeName = encodeURIComponent(name); | 
 |     const encodeTag = encodeURIComponent(tag); | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.PUT, | 
 |       url: `/projects/${encodeName}/tags/${encodeTag}`, | 
 |       body: revision, | 
 |       errFn, | 
 |       anonymizedUrl: '/projects/*/tags/*', | 
 |     }); | 
 |   } | 
 |  | 
 |   getIsGroupOwner(groupName: GroupName): Promise<boolean> { | 
 |     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, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<AccountInfo[] | undefined> { | 
 |     const encodeName = encodeURIComponent(groupName); | 
 |     return this._restApiHelper.fetchJSON({ | 
 |       url: `/groups/${encodeName}/members/`, | 
 |       errFn, | 
 |       anonymizedUrl: '/groups/*/members', | 
 |     }) as Promise<AccountInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   getIncludedGroup( | 
 |     groupName: GroupId | GroupName | 
 |   ): Promise<GroupInfo[] | undefined> { | 
 |     return this._restApiHelper.fetchJSON({ | 
 |       url: `/groups/${encodeURIComponent(groupName)}/groups/`, | 
 |       anonymizedUrl: '/groups/*/groups', | 
 |     }) as Promise<GroupInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   saveGroupName(groupId: GroupId | GroupName, name: string): Promise<Response> { | 
 |     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<Response> { | 
 |     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<Response> { | 
 |     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<Response> { | 
 |     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<GroupAuditEventInfo[] | undefined> { | 
 |     return this._fetchSharedCacheURL({ | 
 |       url: `/groups/${group}/log.audit`, | 
 |       errFn, | 
 |       anonymizedUrl: '/groups/*/log.audit', | 
 |     }) as Promise<GroupAuditEventInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   saveGroupMember( | 
 |     groupName: GroupId | GroupName, | 
 |     groupMember: AccountId | 
 |   ): Promise<AccountInfo> { | 
 |     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<AccountInfo>; | 
 |   } | 
 |  | 
 |   saveIncludedGroup( | 
 |     groupName: GroupId | GroupName, | 
 |     includedGroup: GroupId, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<GroupInfo | undefined> { | 
 |     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<Response> { | 
 |     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<Response> { | 
 |     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<string | undefined> { | 
 |     return this._fetchSharedCacheURL({ | 
 |       url: '/config/server/version', | 
 |       reportUrlAsIs: true, | 
 |     }) as Promise<string | undefined>; | 
 |   } | 
 |  | 
 |   getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> { | 
 |     return this.getLoggedIn().then(loggedIn => { | 
 |       if (loggedIn) { | 
 |         return this._fetchSharedCacheURL({ | 
 |           url: '/accounts/self/preferences.diff', | 
 |           reportUrlAsIs: true, | 
 |         }) as Promise<DiffPreferencesInfo | undefined>; | 
 |       } | 
 |       const anonymousResult: DiffPreferencesInfo = { | 
 |         auto_hide_diff_table_header: true, | 
 |         context: 10, | 
 |         cursor_blink_rate: 0, | 
 |         font_size: 12, | 
 |         ignore_whitespace: IgnoreWhitespaceType.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<EditPreferencesInfo | undefined> { | 
 |     return this.getLoggedIn().then(loggedIn => { | 
 |       if (loggedIn) { | 
 |         return this._fetchSharedCacheURL({ | 
 |           url: '/accounts/self/preferences.edit', | 
 |           reportUrlAsIs: true, | 
 |         }) as Promise<EditPreferencesInfo | undefined>; | 
 |       } | 
 |       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<Response>; | 
 |  | 
 |   savePreferences( | 
 |     prefs: PreferencesInput, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   savePreferences(prefs: PreferencesInput, errFn?: ErrorCallback) { | 
 |     // 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, | 
 |       errFn, | 
 |       reportUrlAsIs: true, | 
 |     }); | 
 |   } | 
 |  | 
 |   saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response>; | 
 |  | 
 |   saveDiffPreferences( | 
 |     prefs: DiffPreferenceInput, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   saveDiffPreferences(prefs: DiffPreferenceInput, errFn?: ErrorCallback) { | 
 |     // Invalidate the cache. | 
 |     this._cache.delete('/accounts/self/preferences.diff'); | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.PUT, | 
 |       url: '/accounts/self/preferences.diff', | 
 |       body: prefs, | 
 |       errFn, | 
 |       reportUrlAsIs: true, | 
 |     }); | 
 |   } | 
 |  | 
 |   saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response>; | 
 |  | 
 |   saveEditPreferences( | 
 |     prefs: EditPreferencesInfo, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   saveEditPreferences(prefs: EditPreferencesInfo, errFn?: ErrorCallback) { | 
 |     // Invalidate the cache. | 
 |     this._cache.delete('/accounts/self/preferences.edit'); | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.PUT, | 
 |       url: '/accounts/self/preferences.edit', | 
 |       body: prefs, | 
 |       errFn, | 
 |       reportUrlAsIs: true, | 
 |     }); | 
 |   } | 
 |  | 
 |   getAccount(): Promise<AccountDetailInfo | undefined> { | 
 |     return this._fetchSharedCacheURL({ | 
 |       url: '/accounts/self/detail', | 
 |       reportUrlAsIs: true, | 
 |       errFn: resp => { | 
 |         if (!resp || resp.status === 403) { | 
 |           this._cache.delete('/accounts/self/detail'); | 
 |         } | 
 |       }, | 
 |     }) as Promise<AccountDetailInfo | undefined>; | 
 |   } | 
 |  | 
 |   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<string | undefined>; | 
 |   } | 
 |  | 
 |   getExternalIds() { | 
 |     return this._restApiHelper.fetchJSON({ | 
 |       url: '/accounts/self/external.ids', | 
 |       reportUrlAsIs: true, | 
 |     }) as Promise<AccountExternalIdInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   deleteAccountIdentity(id: string[]) { | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.POST, | 
 |       url: '/accounts/self/external.ids:delete', | 
 |       body: id, | 
 |       parseResponse: true, | 
 |       reportUrlAsIs: true, | 
 |     }) as Promise<unknown>; | 
 |   } | 
 |  | 
 |   getAccountDetails(userId: AccountId): Promise<AccountDetailInfo | undefined> { | 
 |     return this._restApiHelper.fetchJSON({ | 
 |       url: `/accounts/${encodeURIComponent(userId)}/detail`, | 
 |       anonymizedUrl: '/accounts/*/detail', | 
 |     }) as Promise<AccountDetailInfo | undefined>; | 
 |   } | 
 |  | 
 |   getAccountEmails() { | 
 |     return this._fetchSharedCacheURL({ | 
 |       url: '/accounts/self/emails', | 
 |       reportUrlAsIs: true, | 
 |     }) as Promise<EmailInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   addAccountEmail(email: string): Promise<Response>; | 
 |  | 
 |   addAccountEmail( | 
 |     email: string, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   addAccountEmail(email: string, errFn?: ErrorCallback) { | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.PUT, | 
 |       url: '/accounts/self/emails/' + encodeURIComponent(email), | 
 |       errFn, | 
 |       anonymizedUrl: '/account/self/emails/*', | 
 |     }); | 
 |   } | 
 |  | 
 |   deleteAccountEmail(email: string): Promise<Response>; | 
 |  | 
 |   deleteAccountEmail( | 
 |     email: string, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   deleteAccountEmail(email: string, errFn?: ErrorCallback) { | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.DELETE, | 
 |       url: '/accounts/self/emails/' + encodeURIComponent(email), | 
 |       errFn, | 
 |       anonymizedUrl: '/accounts/self/email/*', | 
 |     }); | 
 |   } | 
 |  | 
 |   setPreferredAccountEmail( | 
 |     email: string, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<void> { | 
 |     // TODO(TS): add correct error handling | 
 |     const encodedEmail = encodeURIComponent(email); | 
 |     const req = { | 
 |       method: HttpMethod.PUT, | 
 |       url: `/accounts/self/emails/${encodedEmail}/preferred`, | 
 |       errFn, | 
 |       anonymizedUrl: '/accounts/self/emails/*/preferred', | 
 |     }; | 
 |     return this._restApiHelper.send(req).then(() => { | 
 |       // If result of getAccountEmails is in cache, update it in the cache | 
 |       // so we don't have to invalidate it. | 
 |       const cachedEmails = this._cache.get('/accounts/self/emails'); | 
 |       if (cachedEmails) { | 
 |         const emails = cachedEmails.map(entry => { | 
 |           if (entry.email === email) { | 
 |             return {email, preferred: true}; | 
 |           } else { | 
 |             return {email}; | 
 |           } | 
 |         }); | 
 |         this._cache.set('/accounts/self/emails', emails); | 
 |       } | 
 |     }); | 
 |   } | 
 |  | 
 |   _updateCachedAccount(obj: Partial<AccountDetailInfo>): 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, errFn?: ErrorCallback): Promise<void> { | 
 |     // TODO(TS): add correct error handling | 
 |     const req: SendJSONRequest = { | 
 |       method: HttpMethod.PUT, | 
 |       url: '/accounts/self/name', | 
 |       body: {name}, | 
 |       errFn, | 
 |       parseResponse: true, | 
 |       reportUrlAsIs: true, | 
 |     }; | 
 |     return this._restApiHelper | 
 |       .send(req) | 
 |       .then(newName => | 
 |         this._updateCachedAccount({name: (newName as unknown) as string}) | 
 |       ); | 
 |   } | 
 |  | 
 |   setAccountUsername(username: string, errFn?: ErrorCallback): Promise<void> { | 
 |     // TODO(TS): add correct error handling | 
 |     const req: SendJSONRequest = { | 
 |       method: HttpMethod.PUT, | 
 |       url: '/accounts/self/username', | 
 |       body: {username}, | 
 |       errFn, | 
 |       parseResponse: true, | 
 |       reportUrlAsIs: true, | 
 |     }; | 
 |     return this._restApiHelper | 
 |       .send(req) | 
 |       .then(newName => | 
 |         this._updateCachedAccount({username: (newName as unknown) as string}) | 
 |       ); | 
 |   } | 
 |  | 
 |   setAccountDisplayName( | 
 |     displayName: string, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<void> { | 
 |     // TODO(TS): add correct error handling | 
 |     const req: SendJSONRequest = { | 
 |       method: HttpMethod.PUT, | 
 |       url: '/accounts/self/displayname', | 
 |       body: {display_name: displayName}, | 
 |       errFn, | 
 |       parseResponse: true, | 
 |       reportUrlAsIs: true, | 
 |     }; | 
 |     return this._restApiHelper.send(req).then(newName => | 
 |       this._updateCachedAccount({ | 
 |         display_name: (newName as unknown) as string, | 
 |       }) | 
 |     ); | 
 |   } | 
 |  | 
 |   setAccountStatus(status: string, errFn?: ErrorCallback): Promise<void> { | 
 |     // TODO(TS): add correct error handling | 
 |     const req: SendJSONRequest = { | 
 |       method: HttpMethod.PUT, | 
 |       url: '/accounts/self/status', | 
 |       body: {status}, | 
 |       errFn, | 
 |       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<string | undefined>; | 
 |   } | 
 |  | 
 |   // 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<GroupInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   getAccountAgreements() { | 
 |     return this._restApiHelper.fetchJSON({ | 
 |       url: '/accounts/self/agreements', | 
 |       reportUrlAsIs: true, | 
 |     }) as Promise<ContributorAgreementInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   saveAccountAgreement(name: ContributorAgreementInput): Promise<Response> { | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.PUT, | 
 |       url: '/accounts/self/agreements', | 
 |       body: name, | 
 |       reportUrlAsIs: true, | 
 |     }); | 
 |   } | 
 |  | 
 |   getAccountCapabilities( | 
 |     params?: string[] | 
 |   ): Promise<AccountCapabilityInfo | undefined> { | 
 |     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<AccountCapabilityInfo | undefined>; | 
 |   } | 
 |  | 
 |   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<PreferencesInfo | undefined> { | 
 |     return this._fetchSharedCacheURL({ | 
 |       url: '/config/server/preferences', | 
 |       reportUrlAsIs: true, | 
 |     }) as Promise<PreferencesInfo | undefined>; | 
 |   } | 
 |  | 
 |   getPreferences(): Promise<PreferencesInfo | undefined> { | 
 |     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<ProjectWatchInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   saveWatchedProjects( | 
 |     projects: ProjectWatchInfo[], | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<ProjectWatchInfo[]> { | 
 |     return (this._restApiHelper.send({ | 
 |       method: HttpMethod.POST, | 
 |       url: '/accounts/self/watched.projects', | 
 |       body: projects, | 
 |       errFn, | 
 |       parseResponse: true, | 
 |       reportUrlAsIs: true, | 
 |     }) as unknown) as Promise<ProjectWatchInfo[]>; | 
 |   } | 
 |  | 
 |   deleteWatchedProjects( | 
 |     projects: ProjectWatchInfo[] | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   deleteWatchedProjects( | 
 |     projects: ProjectWatchInfo[], | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   deleteWatchedProjects(projects: ProjectWatchInfo[], errFn?: ErrorCallback) { | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.POST, | 
 |       url: '/accounts/self/watched.projects:delete', | 
 |       body: projects, | 
 |       errFn, | 
 |       reportUrlAsIs: true, | 
 |     }); | 
 |   } | 
 |  | 
 |   _isNarrowScreen() { | 
 |     return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX; | 
 |   } | 
 |  | 
 |   getChanges( | 
 |     changesPerPage?: number, | 
 |     query?: string, | 
 |     offset?: 'n,z' | number, | 
 |     options?: string | 
 |   ): Promise<ChangeInfo[] | undefined>; | 
 |  | 
 |   getChanges( | 
 |     changesPerPage?: number, | 
 |     query?: string[], | 
 |     offset?: 'n,z' | number, | 
 |     options?: string | 
 |   ): Promise<ChangeInfo[][] | undefined>; | 
 |  | 
 |   /** | 
 |    * @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<ChangeInfo[] | ChangeInfo[][] | undefined> { | 
 |     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<ChangeInfo[]> = | 
 |             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<string> { | 
 |     return this._changeBaseURL(changeNum, revisionId).then( | 
 |       url => url + endpoint | 
 |     ); | 
 |   } | 
 |  | 
 |   getChangeDetail( | 
 |     changeNum: NumericChangeId, | 
 |     errFn?: ErrorCallback, | 
 |     cancelCondition?: CancelConditionCallback | 
 |   ): Promise<ParsedChangeInfo | null | undefined> { | 
 |     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, | 
 |     errFn?: ErrorCallback, | 
 |     cancelCondition?: CancelConditionCallback | 
 |   ) { | 
 |     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, errFn, cancelCondition); | 
 |   } | 
 |  | 
 |   /** | 
 |    * @param optionsHex list changes options in hex | 
 |    */ | 
 |   _getChangeDetail( | 
 |     changeNum: NumericChangeId, | 
 |     optionsHex: string, | 
 |     errFn?: ErrorCallback, | 
 |     cancelCondition?: CancelConditionCallback | 
 |   ): Promise<ChangeInfo | undefined | null> { | 
 |     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 { | 
 |               this.dispatchEvent( | 
 |                 new CustomEvent('server-error', { | 
 |                   detail: {request: req, response}, | 
 |                   composed: true, | 
 |                   bubbles: true, | 
 |                 }) | 
 |               ); | 
 |             } | 
 |             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<CommitInfo | undefined>; | 
 |   } | 
 |  | 
 |   getChangeFiles( | 
 |     changeNum: NumericChangeId, | 
 |     patchRange: PatchRange | 
 |   ): Promise<FileNameToFileInfoMap | undefined> { | 
 |     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<FileNameToFileInfoMap | undefined>; | 
 |   } | 
 |  | 
 |   // 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<string[] | undefined>; | 
 |   } | 
 |  | 
 |   getChangeOrEditFiles( | 
 |     changeNum: NumericChangeId, | 
 |     patchRange: PatchRange | 
 |   ): Promise<FileNameToFileInfoMap | undefined> { | 
 |     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<ActionNameToActionInfoMap | undefined> { | 
 |     const req: FetchChangeJSON = { | 
 |       changeNum, | 
 |       endpoint: '/actions', | 
 |       revision: patchNum, | 
 |       reportEndpointAsIs: true, | 
 |     }; | 
 |     return this._getChangeURLAndFetch(req) as Promise< | 
 |       ActionNameToActionInfoMap | undefined | 
 |     >; | 
 |   } | 
 |  | 
 |   getChangeSuggestedReviewers( | 
 |     changeNum: NumericChangeId, | 
 |     inputVal: string, | 
 |     errFn?: ErrorCallback | 
 |   ) { | 
 |     return this._getChangeSuggestedGroup( | 
 |       ReviewerState.REVIEWER, | 
 |       changeNum, | 
 |       inputVal, | 
 |       errFn | 
 |     ); | 
 |   } | 
 |  | 
 |   getChangeSuggestedCCs( | 
 |     changeNum: NumericChangeId, | 
 |     inputVal: string, | 
 |     errFn?: ErrorCallback | 
 |   ) { | 
 |     return this._getChangeSuggestedGroup( | 
 |       ReviewerState.CC, | 
 |       changeNum, | 
 |       inputVal, | 
 |       errFn | 
 |     ); | 
 |   } | 
 |  | 
 |   _getChangeSuggestedGroup( | 
 |     reviewerState: ReviewerState, | 
 |     changeNum: NumericChangeId, | 
 |     inputVal: string, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<SuggestedReviewerInfo[] | undefined> { | 
 |     // 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', | 
 |       errFn, | 
 |       params, | 
 |       reportEndpointAsIs: true, | 
 |     }) as Promise<SuggestedReviewerInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   getChangeIncludedIn( | 
 |     changeNum: NumericChangeId | 
 |   ): Promise<IncludedInInfo | undefined> { | 
 |     return this._getChangeURLAndFetch({ | 
 |       changeNum, | 
 |       endpoint: '/in', | 
 |       reportEndpointAsIs: true, | 
 |     }) as Promise<IncludedInInfo | undefined>; | 
 |   } | 
 |  | 
 |   _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/'); | 
 |   } | 
 |  | 
 |   invalidateAccountsDetailCache() { | 
 |     this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail'); | 
 |   } | 
 |  | 
 |   getGroups(filter: string, groupsPerPage: number, offset?: number) { | 
 |     const url = this._getGroupsUrl(filter, groupsPerPage, offset); | 
 |  | 
 |     return this._fetchSharedCacheURL({ | 
 |       url, | 
 |       anonymizedUrl: '/groups/?*', | 
 |     }) as Promise<GroupNameToGroupInfoMap | undefined>; | 
 |   } | 
 |  | 
 |   getRepos( | 
 |     filter: string | undefined, | 
 |     reposPerPage: number, | 
 |     offset?: number | 
 |   ): Promise<ProjectInfoWithName[] | undefined> { | 
 |     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<ProjectInfoWithName[] | undefined>; | 
 |   } | 
 |  | 
 |   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<BranchInfo[] | undefined> { | 
 |     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<BranchInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   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<TagInfo[]>; | 
 |   } | 
 |  | 
 |   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<ProjectAccessInfo | undefined> { | 
 |     // 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<ProjectAccessInfo | undefined>; | 
 |   } | 
 |  | 
 |   setRepoAccessRights( | 
 |     repoName: RepoName, | 
 |     repoInfo: ProjectAccessInput | 
 |   ): Promise<Response> { | 
 |     // 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<ChangeInfo> { | 
 |     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<ChangeInfo>; | 
 |   } | 
 |  | 
 |   getSuggestedGroups( | 
 |     inputVal: string, | 
 |     n?: number, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<GroupNameToGroupInfoMap | undefined> { | 
 |     const params: QueryGroupsParams = {s: inputVal}; | 
 |     if (n) { | 
 |       params.n = n; | 
 |     } | 
 |     return this._restApiHelper.fetchJSON({ | 
 |       url: '/groups/', | 
 |       errFn, | 
 |       params, | 
 |       reportUrlAsIs: true, | 
 |     }) as Promise<GroupNameToGroupInfoMap | undefined>; | 
 |   } | 
 |  | 
 |   getSuggestedProjects( | 
 |     inputVal: string, | 
 |     n?: number, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<NameToProjectInfoMap | undefined> { | 
 |     const params = { | 
 |       m: inputVal, | 
 |       n: MAX_PROJECT_RESULTS, | 
 |       type: 'ALL', | 
 |     }; | 
 |     if (n) { | 
 |       params.n = n; | 
 |     } | 
 |     return this._restApiHelper.fetchJSON({ | 
 |       url: '/projects/', | 
 |       errFn, | 
 |       params, | 
 |       reportUrlAsIs: true, | 
 |     }); | 
 |   } | 
 |  | 
 |   getSuggestedAccounts( | 
 |     inputVal: string, | 
 |     n?: number, | 
 |     errFn?: ErrorCallback | 
 |   ): Promise<AccountInfo[] | undefined> { | 
 |     if (!inputVal) { | 
 |       return Promise.resolve([]); | 
 |     } | 
 |     const params: QueryAccountsParams = {suggest: null, q: inputVal}; | 
 |     if (n) { | 
 |       params.n = n; | 
 |     } | 
 |     return this._restApiHelper.fetchJSON({ | 
 |       url: '/accounts/', | 
 |       errFn, | 
 |       params, | 
 |       anonymizedUrl: '/accounts/?n=*', | 
 |     }) as Promise<AccountInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   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<RelatedChangesInfo | undefined> { | 
 |     return this._getChangeURLAndFetch({ | 
 |       changeNum, | 
 |       endpoint: '/related', | 
 |       revision: patchNum, | 
 |       reportEndpointAsIs: true, | 
 |     }) as Promise<RelatedChangesInfo | undefined>; | 
 |   } | 
 |  | 
 |   getChangesSubmittedTogether( | 
 |     changeNum: NumericChangeId | 
 |   ): Promise<SubmittedTogetherInfo | undefined> { | 
 |     return this._getChangeURLAndFetch({ | 
 |       changeNum, | 
 |       endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES', | 
 |       reportEndpointAsIs: true, | 
 |     }) as Promise<SubmittedTogetherInfo | undefined>; | 
 |   } | 
 |  | 
 |   getChangeConflicts( | 
 |     changeNum: NumericChangeId | 
 |   ): Promise<ChangeInfo[] | undefined> { | 
 |     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<ChangeInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   getChangeCherryPicks( | 
 |     project: RepoName, | 
 |     changeID: ChangeId, | 
 |     changeNum: NumericChangeId | 
 |   ): Promise<ChangeInfo[] | undefined> { | 
 |     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<ChangeInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   getChangesWithSameTopic( | 
 |     topic: string, | 
 |     changeNum: NumericChangeId | 
 |   ): Promise<ChangeInfo[] | undefined> { | 
 |     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<ChangeInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   getReviewedFiles( | 
 |     changeNum: NumericChangeId, | 
 |     patchNum: PatchSetNum | 
 |   ): Promise<string[] | undefined> { | 
 |     return this._getChangeURLAndFetch({ | 
 |       changeNum, | 
 |       endpoint: '/files?reviewed', | 
 |       revision: patchNum, | 
 |       reportEndpointAsIs: true, | 
 |     }) as Promise<string[] | undefined>; | 
 |   } | 
 |  | 
 |   saveFileReviewed( | 
 |     changeNum: NumericChangeId, | 
 |     patchNum: PatchSetNum, | 
 |     path: string, | 
 |     reviewed: boolean | 
 |   ): Promise<Response>; | 
 |  | 
 |   saveFileReviewed( | 
 |     changeNum: NumericChangeId, | 
 |     patchNum: PatchSetNum, | 
 |     path: string, | 
 |     reviewed: boolean, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   saveFileReviewed( | 
 |     changeNum: NumericChangeId, | 
 |     patchNum: PatchSetNum, | 
 |     path: string, | 
 |     reviewed: boolean, | 
 |     errFn?: ErrorCallback | 
 |   ) { | 
 |     return this._getChangeURLAndSend({ | 
 |       changeNum, | 
 |       method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE, | 
 |       patchNum, | 
 |       endpoint: `/files/${encodeURIComponent(path)}/reviewed`, | 
 |       errFn, | 
 |       anonymizedEndpoint: '/files/*/reviewed', | 
 |     }); | 
 |   } | 
 |  | 
 |   saveChangeReview( | 
 |     changeNum: NumericChangeId, | 
 |     patchNum: PatchSetNum, | 
 |     review: ReviewInput | 
 |   ): Promise<Response>; | 
 |  | 
 |   saveChangeReview( | 
 |     changeNum: NumericChangeId, | 
 |     patchNum: PatchSetNum, | 
 |     review: ReviewInput, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   saveChangeReview( | 
 |     changeNum: NumericChangeId, | 
 |     patchNum: PatchSetNum, | 
 |     review: ReviewInput, | 
 |     errFn?: ErrorCallback | 
 |   ) { | 
 |     const promises: [Promise<void>, Promise<string>] = [ | 
 |       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<false | EditInfo | undefined> { | 
 |     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<EditInfo | false | undefined>; | 
 |     }); | 
 |   } | 
 |  | 
 |   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<ChangeInfo | undefined>; | 
 |   } | 
 |  | 
 |   getFileContent( | 
 |     changeNum: NumericChangeId, | 
 |     path: string, | 
 |     patchNum: PatchSetNum | 
 |   ): Promise<Response | Base64FileContent | undefined> { | 
 |     // 404s indicate the file does not exist yet in the revision, so suppress | 
 |     // them. | 
 |     const suppress404s: ErrorCallback = res => { | 
 |       if (res?.status !== 404) { | 
 |         this.dispatchEvent( | 
 |           new CustomEvent('server-error', { | 
 |             detail: {res}, | 
 |             composed: true, | 
 |             bubbles: true, | 
 |           }) | 
 |         ); | 
 |       } | 
 |       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<FilePathToDiffInfoMap | undefined> { | 
 |     return this._getChangeURLAndFetch({ | 
 |       changeNum, | 
 |       revision: patchNum, | 
 |       endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`, | 
 |       reportEndpointAsId: true, | 
 |     }) as Promise<FilePathToDiffInfoMap | undefined>; | 
 |   } | 
 |  | 
 |   applyFixSuggestion( | 
 |     changeNum: NumericChangeId, | 
 |     patchNum: PatchSetNum, | 
 |     fixId: string | 
 |   ): Promise<Response> { | 
 |     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<Response> { | 
 |     // 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<Response | undefined> { | 
 |     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<string, string> | 
 |   ): Promise<Response>; | 
 |  | 
 |   send( | 
 |     method: HttpMethod, | 
 |     url: string, | 
 |     body: RequestPayload | undefined, | 
 |     errFn: ErrorCallback, | 
 |     contentType?: string, | 
 |     headers?: Record<string, string> | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   /** | 
 |    * 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<string, string> | 
 |   ): Promise<Response | undefined> { | 
 |     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 = { | 
 |       context: 'ALL', | 
 |       intraline: null, | 
 |       whitespace: whitespace || IgnoreWhitespaceType.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<DiffInfo | undefined>; | 
 |   } | 
 |  | 
 |   getDiffComments( | 
 |     changeNum: NumericChangeId | 
 |   ): Promise<PathToCommentsInfoMap | undefined>; | 
 |  | 
 |   getDiffComments( | 
 |     changeNum: NumericChangeId, | 
 |     basePatchNum: PatchSetNum, | 
 |     patchNum: PatchSetNum, | 
 |     path: string | 
 |   ): Promise<GetDiffCommentsOutput>; | 
 |  | 
 |   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<PathToRobotCommentsInfoMap | undefined>; | 
 |  | 
 |   getDiffRobotComments( | 
 |     changeNum: NumericChangeId, | 
 |     basePatchNum: PatchSetNum, | 
 |     patchNum: PatchSetNum, | 
 |     path: string | 
 |   ): Promise<GetDiffRobotCommentsOutput>; | 
 |  | 
 |   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<PathToCommentsInfoMap | undefined>; | 
 |  | 
 |   getDiffDrafts( | 
 |     changeNum: NumericChangeId, | 
 |     basePatchNum: PatchSetNum, | 
 |     patchNum: PatchSetNum, | 
 |     path: string | 
 |   ): Promise<GetDiffCommentsOutput>; | 
 |  | 
 |   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<PathToCommentsInfoMap | undefined>; | 
 |  | 
 |   _getDiffComments( | 
 |     changeNum: NumericChangeId, | 
 |     endpoint: '/robotcomments' | 
 |   ): Promise<PathToRobotCommentsInfoMap | undefined>; | 
 |  | 
 |   _getDiffComments( | 
 |     changeNum: NumericChangeId, | 
 |     endpoint: '/comments' | '/drafts', | 
 |     basePatchNum?: PatchSetNum, | 
 |     patchNum?: PatchSetNum, | 
 |     path?: string | 
 |   ): Promise<GetDiffCommentsOutput>; | 
 |  | 
 |   _getDiffComments( | 
 |     changeNum: NumericChangeId, | 
 |     endpoint: '/robotcomments', | 
 |     basePatchNum?: PatchSetNum, | 
 |     patchNum?: PatchSetNum, | 
 |     path?: string | 
 |   ): Promise<GetDiffRobotCommentsOutput>; | 
 |  | 
 |   _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); | 
 |   } | 
 |  | 
 |   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<void> { | 
 |     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<Response>; | 
 |  | 
 |   _sendDiffDraftRequest( | 
 |     method: HttpMethod.GET | HttpMethod.DELETE, | 
 |     changeNum: NumericChangeId, | 
 |     patchNum: PatchSetNum, | 
 |     draft: {id?: UrlEncodedCommentId} | 
 |   ): Promise<Response>; | 
 |  | 
 |   _sendDiffDraftRequest( | 
 |     method: HttpMethod, | 
 |     changeNum: NumericChangeId, | 
 |     patchNum: PatchSetNum, | 
 |     draft: CommentInput | {id: UrlEncodedCommentId} | 
 |   ): Promise<Response> { | 
 |     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<CommitInfo | undefined> { | 
 |     return this._restApiHelper.fetchJSON({ | 
 |       url: | 
 |         '/projects/' + | 
 |         encodeURIComponent(project) + | 
 |         '/commits/' + | 
 |         encodeURIComponent(commit), | 
 |       anonymizedUrl: '/projects/*/comments/*', | 
 |     }) as Promise<CommitInfo | undefined>; | 
 |   } | 
 |  | 
 |   _fetchB64File(url: string): Promise<Base64File> { | 
 |     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<ImagesForDiff> { | 
 |     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<string> { | 
 |     // 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 | null | 
 |   ): Promise<string> { | 
 |     return (this._getChangeURLAndSend({ | 
 |       changeNum, | 
 |       method: HttpMethod.PUT, | 
 |       endpoint: '/topic', | 
 |       body: {topic}, | 
 |       parseResponse: true, | 
 |       reportUrlAsIs: true, | 
 |     }) as unknown) as Promise<string>; | 
 |   } | 
 |  | 
 |   setChangeHashtag( | 
 |     changeNum: NumericChangeId, | 
 |     hashtag: HashtagsInput | 
 |   ): Promise<Hashtag[]> { | 
 |     return (this._getChangeURLAndSend({ | 
 |       changeNum, | 
 |       method: HttpMethod.POST, | 
 |       endpoint: '/hashtags', | 
 |       body: hashtag, | 
 |       parseResponse: true, | 
 |       reportUrlAsIs: true, | 
 |     }) as unknown) as Promise<Hashtag[]>; | 
 |   } | 
 |  | 
 |   deleteAccountHttpPassword() { | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.DELETE, | 
 |       url: '/accounts/self/password.http', | 
 |       reportUrlAsIs: true, | 
 |     }); | 
 |   } | 
 |  | 
 |   generateAccountHttpPassword(): Promise<Password> { | 
 |     return (this._restApiHelper.send({ | 
 |       method: HttpMethod.PUT, | 
 |       url: '/accounts/self/password.http', | 
 |       body: {generate: true}, | 
 |       parseResponse: true, | 
 |       reportUrlAsIs: true, | 
 |     }) as Promise<unknown>) as Promise<Password>; | 
 |   } | 
 |  | 
 |   getAccountSSHKeys() { | 
 |     return (this._fetchSharedCacheURL({ | 
 |       url: '/accounts/self/sshkeys', | 
 |       reportUrlAsIs: true, | 
 |     }) as Promise<unknown>) as Promise<SshKeyInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   addAccountSSHKey(key: string): Promise<SshKeyInfo> { | 
 |     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<unknown>) as Promise<Record<string, GpgKeyInfo>>; | 
 |   } | 
 |  | 
 |   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<string | null> { | 
 |     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<CapabilityInfoMap | undefined> { | 
 |     return this._restApiHelper.fetchJSON({ | 
 |       url: '/config/server/capabilities', | 
 |       errFn, | 
 |       reportUrlAsIs: true, | 
 |     }) as Promise<CapabilityInfoMap | undefined>; | 
 |   } | 
 |  | 
 |   getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined> { | 
 |     return this._fetchSharedCacheURL({ | 
 |       url: '/config/server/top-menus', | 
 |       errFn, | 
 |       reportUrlAsIs: true, | 
 |     }) as Promise<TopMenuEntryInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   setAssignee( | 
 |     changeNum: NumericChangeId, | 
 |     assignee: AccountId | 
 |   ): Promise<Response> { | 
 |     const body: AssigneeInput = {assignee}; | 
 |     return this._getChangeURLAndSend({ | 
 |       changeNum, | 
 |       method: HttpMethod.PUT, | 
 |       endpoint: '/assignee', | 
 |       body, | 
 |       reportUrlAsIs: true, | 
 |     }); | 
 |   } | 
 |  | 
 |   deleteAssignee(changeNum: NumericChangeId): Promise<Response> { | 
 |     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<string | undefined> { | 
 |     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; | 
 |     }); | 
 |   } | 
 |  | 
 |   startReview( | 
 |     changeNum: NumericChangeId, | 
 |     body?: RequestPayload, | 
 |     errFn?: ErrorCallback | 
 |   ) { | 
 |     return this._getChangeURLAndSend({ | 
 |       changeNum, | 
 |       method: HttpMethod.POST, | 
 |       endpoint: '/ready', | 
 |       body, | 
 |       errFn, | 
 |       reportUrlAsIs: true, | 
 |     }); | 
 |   } | 
 |  | 
 |   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<CommentInfo>; | 
 |   } | 
 |  | 
 |   /** | 
 |    * Given a changeNum, gets the change. | 
 |    */ | 
 |   getChange( | 
 |     changeNum: ChangeId | NumericChangeId, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<ChangeInfo | null> { | 
 |     // 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<RepoName | undefined> { | 
 |     const project = this._projectLookup[`${changeNum}`]; | 
 |     if (project) { | 
 |       return Promise.resolve(project); | 
 |     } | 
 |  | 
 |     const onError = (response?: Response | null) => { | 
 |       // Fire a page error so that the visual 404 is displayed. | 
 |       this.dispatchEvent( | 
 |         new CustomEvent('page-error', { | 
 |           detail: {response}, | 
 |           composed: true, | 
 |           bubbles: true, | 
 |         }) | 
 |       ); | 
 |     }; | 
 |  | 
 |     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<Response>; | 
 |  | 
 |   _getChangeURLAndSend( | 
 |     req: SendRawChangeRequest | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   _getChangeURLAndSend(req: SendJSONChangeRequest): Promise<ParsedJSON>; | 
 |  | 
 |   /** | 
 |    * Alias for _changeBaseURL.then(send). | 
 |    */ | 
 |   _getChangeURLAndSend( | 
 |     req: SendChangeRequest | 
 |   ): Promise<ParsedJSON | Response | undefined> { | 
 |     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<ParsedJSON | undefined> { | 
 |     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<Response>; | 
 |  | 
 |   executeChangeAction( | 
 |     changeNum: NumericChangeId, | 
 |     method: HttpMethod | undefined, | 
 |     endpoint: string, | 
 |     patchNum: PatchSetNum | undefined, | 
 |     payload: RequestPayload | undefined, | 
 |     errFn: ErrorCallback | 
 |   ): Promise<Response | undefined>; | 
 |  | 
 |   /** | 
 |    * 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<BlameInfo[] | undefined>; | 
 |   } | 
 |  | 
 |   /** | 
 |    * 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<Response>): Promise<Response> { | 
 |     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<string, string> | 
 |         ); | 
 |         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<DashboardInfo | undefined> { | 
 |     const url = | 
 |       '/projects/' + | 
 |       encodeURIComponent(project) + | 
 |       '/dashboards/' + | 
 |       encodeURIComponent(dashboard); | 
 |     return this._fetchSharedCacheURL({ | 
 |       url, | 
 |       errFn, | 
 |       anonymizedUrl: '/projects/*/dashboards/*', | 
 |     }) as Promise<DashboardInfo | undefined>; | 
 |   } | 
 |  | 
 |   getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> { | 
 |     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<DocResult[] | undefined>; | 
 |   } | 
 |  | 
 |   getMergeable(changeNum: NumericChangeId) { | 
 |     return this._getChangeURLAndFetch({ | 
 |       changeNum, | 
 |       endpoint: '/revisions/current/mergeable', | 
 |       reportEndpointAsIs: true, | 
 |     }) as Promise<MergeableInfo | undefined>; | 
 |   } | 
 |  | 
 |   deleteDraftComments(query: string): Promise<Response> { | 
 |     const body: DeleteDraftCommentsInput = {query}; | 
 |     return this._restApiHelper.send({ | 
 |       method: HttpMethod.POST, | 
 |       url: '/accounts/self/drafts:delete', | 
 |       body, | 
 |     }); | 
 |   } | 
 | } |